"""Payload assembly — every answer this Space returns goes through here. The contract (ADR-0001 §5, accepted 2026-10-20), on top of the family invariants: - Every payload is a dict with ``status`` in {"ok", "refused", "error", "denied"} plus a human-readable ``reason`` on every non-ok status. Refusals are answers, never exceptions. - Every payload except a denial carries a ``run_id`` (the gate mints none for a caller it turned away). - Every ``ok`` payload carries ``kind: "annotation"`` and **no** ``hierarchy`` or ``n_for_inference``. This is the deliberate break from the data specialists: a row count here is not a sample size, and nothing downstream may render it as one. - Every row carries a complete ``provenance`` block (:mod:`src.provenance`); :func:`ok` refuses to build a payload around a row that does not. - ``coverage_notes`` and ``attribution`` come from config for every source the payload used, and the deterministic ``narrative`` says "according to (queried )". """ from __future__ import annotations import datetime import json import secrets from collections.abc import Iterable from typing import Any from src import config from src.provenance import missing_fields STATUSES = ("ok", "refused", "error", "denied") SOURCE_ERROR_REASONS = ("source_unavailable", "rate_limited", "upstream_schema_drift") KIND = "annotation" DISCLAIMER = ( "External annotation, not analysis: these rows are what the named public sources say, " "as of the query date on each row. Nothing here was computed from a dataset, and no " "statistic was run." ) def mint_run_id() -> str: """A sortable, collision-resistant run id (sibling-agent format).""" stamp = datetime.datetime.now(datetime.UTC).strftime("%Y%m%d_%H%M%S") return f"{stamp}_{secrets.randbelow(10**6):06d}" def source_error(source: str, reason: str, detail: str) -> dict[str, str]: if reason not in SOURCE_ERROR_REASONS: raise ValueError(f"source error reason must be one of {SOURCE_ERROR_REASONS}") return {"source": source, "reason": reason, "detail": detail} def _attribution(source_ids: Iterable[str]) -> list[dict[str, str]]: out = [] for sid in sorted(set(source_ids)): src = config.source(sid) out.append( { "source": sid, "name": src["name"], "citation": src["citation"], "licence": src["licence"], "licence_url": src["licence_url"], } ) return out def _coverage_notes(source_ids: Iterable[str]) -> list[dict[str, str]]: return [ {"source": sid, "note": note} for sid in sorted(set(source_ids)) for note in config.source(sid)["coverage_notes"] ] def ok( endpoint: str, *, query: dict[str, Any], rows: list[dict[str, Any]], sources_used: Iterable[str], narrative: str, unresolved: Iterable[dict[str, str]] = (), source_errors: Iterable[dict[str, str]] = (), run_id: str | None = None, **extra: Any, ) -> dict[str, Any]: """An ok payload. Raises ValueError (a programming error) on an unstamped row.""" for i, row in enumerate(rows): gaps = missing_fields(row) if gaps: raise ValueError(f"row {i} is missing provenance fields {gaps}") if not narrative.strip(): raise ValueError("an ok payload needs its deterministic narrative") used = list(sources_used) forbidden = {"hierarchy", "n_for_inference"} & set(extra) if forbidden: raise ValueError(f"annotation payloads never carry {sorted(forbidden)}") payload: dict[str, Any] = { "status": "ok", "run_id": run_id or mint_run_id(), "kind": KIND, "endpoint": endpoint, "query": query, "rows": rows, "unresolved": list(unresolved), "source_errors": list(source_errors), "coverage_notes": _coverage_notes(used), "attribution": _attribution(used), "narrative": narrative, "disclaimer": DISCLAIMER, } payload.update(extra) return payload def refused(endpoint: str, reason: str, message: str, *, run_id: str | None = None) -> dict: return { "status": "refused", "run_id": run_id or mint_run_id(), "endpoint": endpoint, "reason": reason, "message": message, } def error( endpoint: str, reason: str, message: str, *, source_errors: Iterable[dict[str, str]] = (), run_id: str | None = None, ) -> dict: return { "status": "error", "run_id": run_id or mint_run_id(), "endpoint": endpoint, "reason": reason, "message": message, "source_errors": list(source_errors), } def denied(message: str) -> dict: return {"status": "denied", "reason": "access_denied", "message": message} def to_json(payload: dict[str, Any]) -> str: if payload.get("status") not in STATUSES: raise ValueError(f"unknown status {payload.get('status')!r}") return json.dumps(payload, ensure_ascii=False)