Story 2062: The turnstile a card must pass before it becomes evidence

WHY this exists. CE-6 (agents) has Blake read a document during registration and emit a DocumentCard โ€” "what this document IS": its type, dates, parties, amounts, and a handful of contradiction-eligible assertions. Every value carries the SourceSpans that supposedly prove it. But a producer's word is not proof. Persist that card unchecked and two catastrophes wait. First, the company-ending one: a span that lies โ€” a quote the model paraphrased or hallucinated, or one that drifted when the page was re-OCR'd โ€” gets stored as a "citation," surfaced to counsel, and cited into a real filing in a real court, where opposing counsel opens the document, finds the quote isn't there, and the evidence is struck. Second, the quieter one: a page of garbage OCR (a smudged scan at 0.4 confidence) becomes a fake fact nobody can see is fake. CE-7 is the turnstile between Blake's card and the record: every span is re-verified byte-for-byte against the real page bytes, low-confidence pages are barred, and only a card that passes is stored and announced. A card that fails is a typed rejection โ€” never a confidently-stored wrong fact.

๐Ÿ›ฐ๏ธ Satellite: one function, three gates, two side effects

The whole story is ingest_document_card() in src/shared/context/card_ingest.py. It takes a validated DocumentCard and, in the caller's transaction, runs three fail-closed gates and โ€” only if all pass โ€” performs two side effects:

StepWhat it doesRFD
OCR-confidence floorLoad the document's pages; exclude any whose OCR mean_confidence is below CONTEXT_CARD_OCR_CONFIDENCE_FLOOR. Native-extracted pages (no confidence) always stay.ยง5.1 M-8a
Byte-exact span resolutionEvery span the card claims must byte-resolve against an admitted page โ€” reusing the CE-2 resolver (the live V-CITE primitive). Any failure โ‡’ reject the whole card.ยง4
Persist (on pass)Upsert the card row whole into context.document_cards (JSONB envelope + queryable columns), replaced per run.ยง5.1
Announce (on pass, in-txn)Emit document_card.updated through the outbox, in the same transaction as the row write.ยง9

Wiring: the registration consumer, on a clean registered terminal, ingests Blake's card (in its own isolated transaction) and fires a direct SQS enrich kick for the per-document work downstream (chunk + embed, CE-3).

โœˆ๏ธ Airplane: the analogy โ€” an evidence clerk with a magnifying glass

Blake is a paralegal who hands the clerk a summary sheet: "this contract was executed 5 May 2019 โ€” see page 3, line 4." The clerk does not take his word. She pulls page 3 and reads line 4 with a magnifying glass, checking the words match letter for letter (byte-exact, no whitespace-forgiveness, no case-folding). If the page is a smudged photocopy she can't read clearly (below the confidence floor), she sets it aside as unreadable โ€” a claim resting on an unreadable page is no claim. Only when every quote checks out does she file the sheet in the firm's cage and stamp the intake log. If a single quote is wrong, the whole sheet bounces back โ€” and, crucially, the document stays registered anyway: a bad summary sheet never un-files the document itself.

๐Ÿ”ฌ Microscopic: the code paths that matter

The fail-closed gate (why a rejection is typed, not a flag)

failures = unresolved(card_spans(card), admitted)
if failures:
    raise CardSpanUnresolvedError(
        f"{len(failures)} card span(s) did not byte-resolve ...",
        details={"reasons": sorted({res.reason for _, res in failures if res.reason}), ...},
    )

unresolved() (CE-2) returns each span that failed to verify, paired with a low-cardinality reason code (quote_not_byte_present, page_not_found, โ€ฆ) โ€” never the quote or page text, so a rejection is safe to log. A non-empty result raises CardSpanUnresolvedError and nothing is written: no card row, no event. Fail-closed means the default is "not evidence."

The confidence floor is the resolution corpus

def eligible_pages(pages, floor):
    return [p for p in pages
            if p.mean_confidence is None or p.mean_confidence >= floor]

The floor isn't a separate check โ€” it's which pages exist for resolution. A span anchored to a below-floor page finds no page to resolve against (page_not_found) and the card is rejected. Two design choices, each titled by its reason: mean_confidence is None stays admitted because native-extracted text (pdfium) has perfect fidelity and no OCR score to doubt; >= floor (not >) so a page exactly at the threshold is trusted, not barred by an off-by-one.

The event rides in the same transaction as the row

insert_domain_event(session, detail_type="document_card.updated", โ€ฆ) writes the outbox row on the caller's open session, so the card row and its announcement commit or roll back together โ€” a consumer (assertion normalizer CE-9, bundle rebuilder CE-13) can never see a card with no event, or an event with no card. One grammar note: RFD prose writes it document.card.updated, but the audit+event vocabulary is single-dot <subject>.<verb>, so the wire-valid token is document_card.updated โ€” a new document_card subject in the audit registry, keyed by document_id.

Failure isolation: a bad card never un-registers a document

# registration consumer, only on a clean `registered` terminal:
if to_status == "registered":
    await _ingest_card(deps, ctx, result)   # own txn, swallows CardSpanUnresolvedError
    _kick_enrichment(deps, ctx)             # direct SQS send, best-effort

_ingest_card runs in its own transaction and swallows every error into a needs_review log line (RFD ยง5.1 invariant M-12b) โ€” the document is already registered and stays so. The enrich kick is a direct send_message (not via the outbox โ€” the event-backbone fold-in is its own later Chore, RFD ยง9 critique m-6); a failed kick is logged, and the CE-18 backfill re-drives any missed document.

The acceptance gates, proven

GateWhere
unresolvable-span typed rejection โ€” a hallucinated quote raises CardSpanUnresolvedError and writes nothingtests/integration/test_context_card_ingest.py (gate 2) + unit test_hallucinated_quote_is_unresolved
zero-rows first-run โ€” a valid card into an EMPTY table writes the row (with queryable columns) and emits the event, as the pramaan_app roleintegration gate 1
confidence floor config-named & exercised โ€” a 0.50 page is rejected at the default floor and admitted once the floor drops below itintegration gate 3 + unit test_below_floor_page_makes_the_card_fail_closed

The integration test runs against a real Postgres including the empty first-run state (AGENTS.md "done = system boundary"): a real INSERT as the runtime role, not a green unit test over a fixture.

Recall in one sentence: CE-7 is the turnstile that re-verifies every span of Blake's card byte-for-byte against the confidence-floored page bytes before it can become evidence โ€” pass and it's stored and announced in one transaction; fail and it's a typed rejection that never touches the record and never un-registers the document.