"""Corrections desk — free for anyone, logged publicly. A ticket is 'this figure is wrong, here is the source that contradicts it'. Every step is an append-only, hash-chained public log entry.""" import hashlib import json import os import re import uuid from datetime import datetime, timezone from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field from deps import db, require_operator router = APIRouter(prefix="/api/corrections") OUTCOMES = {"corrected": "Our figure or method was wrong and has been fixed; the record was re-pulled from the source.", "source_confirmed": "The source was re-pulled and shows the figure as published; no change.", "source_changed": "The agency changed its record; the platform updated for everyone.", "not_ours": "The figure is the source's own line; the requester was pointed to the agency's correction process."} POLICY = ["Corrections are free, for anyone, with no account.", "A request cannot change a figure; only the source re-pull or a method fix can — and the fix applies to every company.", "Every request and outcome is logged here with date, reason and source. Requester contact details are never published.", "No payment, sponsorship or subscription changes the queue order or the outcome.", "Service commitment (published 2026-06): a human-filed request is acknowledged within 2 business days and resolved or given a written reason within 10 business days; machine-opened accuracy tickets are re-pulled within 12 hours. Median days to resolve is published live above; misses against this commitment are themselves logged."] class CorrectionIn(BaseModel): page_url: str = Field(min_length=1, max_length=400) figure: str = Field(min_length=3, max_length=300) claim: str = Field(min_length=10, max_length=2000) source_url: str = Field(default="", max_length=600) email: str = Field(default="", max_length=200) class ResolveIn(BaseModel): outcome: Literal["corrected", "source_confirmed", "source_changed", "not_ours"] note: str = Field(min_length=5, max_length=2000) source_url: str = Field(default="", max_length=600) def _now() -> str: return datetime.now(timezone.utc).isoformat() async def _log(event: str, ticket_id: str, details: dict) -> dict: last = await db.corrections_log.find_one({}, {"_id": 0, "hash": 1, "seq": 1}, sort=[("seq", -1)]) entry = {"seq": (last or {}).get("seq", 0) + 1, "ts": _now(), "event": event, "ticket_id": ticket_id, "details": details, "prev_hash": (last or {}).get("hash") or "0" * 64} entry["hash"] = hashlib.sha256(json.dumps(entry, sort_keys=True, default=str).encode()).hexdigest() await db.corrections_log.insert_one(dict(entry)) return entry def _public(t: dict) -> dict: return {k: v for k, v in t.items() if k not in ("email", "_id", "ip")} @router.post("") async def submit(body: CorrectionIn, request: Request): if body.source_url and not re.match(r"^https?://", body.source_url): raise HTTPException(status_code=422, detail="Source link must start with http(s)://") ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "").split(",")[0].strip() recent = await db.corrections.count_documents({"ip": ip, "created_at": {"$gt": datetime.now(timezone.utc).replace(hour=0, minute=0, second=0).isoformat()}}) if recent >= 10: raise HTTPException(status_code=429, detail="Daily limit reached for this connection; email the desk instead.") t = {"ticket_id": uuid.uuid4().hex[:10].upper(), "page_url": body.page_url.strip(), "figure": body.figure.strip(), "claim": body.claim.strip(), "source_url": body.source_url.strip(), "email": body.email.strip().lower(), "ip": ip, "status": "received", "outcome": None, "created_at": _now(), "resolved_at": None, "resolution": None, "cost_to_requester": 0} await db.corrections.insert_one(dict(t)) entry = await _log("correction_requested", t["ticket_id"], {"page_url": t["page_url"], "figure": t["figure"], "source_url": t["source_url"] or None, "cost_to_requester": 0}) try: import emailer owner = os.environ.get("ALERT_EMAIL_TO") or os.environ.get("REPLY_TO_EMAIL") if owner: await emailer.send_alert(owner, f"[Corrections desk] {t['ticket_id']} — {t['figure'][:60]}", f"Page: {t['page_url']}\nFigure: {t['figure']}\nClaim: {t['claim']}\nSource: {t['source_url'] or 'none given'}\nRequester: {t['email'] or 'anonymous'}\n\nResolve: POST /api/corrections/{t['ticket_id']}/resolve") if t["email"]: await emailer.send_alert(t["email"], f"Correction request {t['ticket_id']} received — Alpha Covenant Holdings", f"We received your correction request for {t['page_url']}.\n\nFigure: {t['figure']}\n\nIt is free, it is logged publicly at /corrections (without your contact details), and it is handled in the order received. You will get one more email with the outcome and the source that settled it.") except Exception: pass return {"ticket_id": t["ticket_id"], "status": "received", "log_hash": entry["hash"], "public_url": f"/corrections#{t['ticket_id']}", "cost_to_requester": 0} @router.get("") async def public_log(limit: int = 200): tickets = await db.corrections.find({}, {"_id": 0, "email": 0, "ip": 0}).sort("created_at", -1).to_list(min(limit, 500)) log = await db.corrections_log.find({}, {"_id": 0}).sort("seq", -1).to_list(min(limit, 500)) total = await db.corrections.count_documents({}) resolved = [t for t in tickets if t.get("resolved_at")] days = [(datetime.fromisoformat(t["resolved_at"]) - datetime.fromisoformat(t["created_at"])).total_seconds() / 86400 for t in resolved] by_outcome = {} for t in resolved: by_outcome[t["outcome"]] = by_outcome.get(t["outcome"], 0) + 1 return {"policy": POLICY, "outcomes_defined": OUTCOMES, "stats": {"total": total, "open": total - len(resolved), "resolved": len(resolved), "by_outcome": by_outcome, "median_days_to_resolve": round(sorted(days)[len(days) // 2], 1) if days else None, "paid_requests": 0}, "tickets": tickets, "log": log, "chain_head": log[0]["hash"] if log else None, "verify": "Each log entry's hash = SHA-256 of the entry (seq, ts, event, ticket_id, details, prev_hash) as sorted JSON; prev_hash links to the previous entry."} @router.get("/{ticket_id}") async def ticket(ticket_id: str): t = await db.corrections.find_one({"ticket_id": ticket_id.upper()}, {"_id": 0, "email": 0, "ip": 0}) if not t: raise HTTPException(status_code=404, detail="No such ticket.") log = await db.corrections_log.find({"ticket_id": t["ticket_id"]}, {"_id": 0}).sort("seq", 1).to_list(50) return {**t, "log": log} @router.post("/{ticket_id}/resolve") async def resolve(ticket_id: str, body: ResolveIn, _op: dict = Depends(require_operator)): t = await db.corrections.find_one({"ticket_id": ticket_id.upper()}) if not t: raise HTTPException(status_code=404, detail="No such ticket.") if t.get("resolved_at"): raise HTTPException(status_code=409, detail="Already resolved; the log is append-only.") upd = {"status": "resolved", "outcome": body.outcome, "resolution": body.note.strip(), "resolution_source_url": body.source_url.strip(), "resolved_at": _now()} await db.corrections.update_one({"ticket_id": t["ticket_id"]}, {"$set": upd}) entry = await _log("correction_resolved", t["ticket_id"], {"outcome": body.outcome, "meaning": OUTCOMES[body.outcome], "note": upd["resolution"], "source_url": upd["resolution_source_url"] or None, "charged": 0}) if t.get("email"): try: import emailer await emailer.send_alert(t["email"], f"Correction request {t['ticket_id']} resolved: {body.outcome.replace('_', ' ')}", f"Outcome: {OUTCOMES[body.outcome]}\n\nNote: {upd['resolution']}\nSource: {upd['resolution_source_url'] or 'see page'}\n\nPublic log entry: /corrections#{t['ticket_id']} (hash {entry['hash'][:12]}…). Charged: $0.") except Exception: pass return {**_public({**t, **upd}), "log_hash": entry["hash"]}