Story 2065: Turning what a document says into facts you can reason over

WHY this exists. CE-7 stored a verified DocumentCard — "what this document IS": its dates, amounts, and span-carrying statements. But a card is still prose. PRAMAAN cannot notice that a sale deed cites an execution date of 5 May 2019 while an affidavit swears it was signed in 2020, because "5 May 2019" in one document and "the fifth of May, 2019" in another are just two unrelated strings. Contradiction detection, the chronology timeline, the war-room indices — none of them can stand on prose. They need a fact identity: a key that is the same no matter which voice, in which document, states the fact. That is what this story mints. It reads a card's already-extracted, already-spanned dates and amounts and turns each into a typed assertion stamped with fact_key = sha256(subject_norm, attribute, value_norm) — voice-independent by construction. Now "X owes ₹5L" and "₹5L is owed by X" collide as one fact, and two documents disagreeing on the same fact_key attribute are a contradiction the engine can find instead of a coincidence it can only hope a human notices.

🛰️ Satellite: card in, rows + graph out, three properties guaranteed

The whole story is normalize_document_card() in src/shared/context/assertion_normalizer.py, wired into the registration consumer right after CE-7 stores the card. It types the card, writes the relational projection, announces it, and projects the graph — in the one order the RFD fixes (relational first, graph follows), preserving three invariants that are the story's acceptance gates:

StepWhat it doesRFD
TypeTurn each card date → a date_assertion and each amount → an amount_assertion, keyed by the card field's own kind as the closed attribute, with a typed value and a fact_key.§5.3 assertion production
Persist (relational first)One multi-row upsert into context.assertions. Every row is born provisional — never trusted until a human confirms it.§5.3 write path (1)
Announce (in-txn)Emit matter_assertions.updated through the outbox, in the SAME transaction as the rows: no rows without the event, no event without rows.§9
Project (graph follows)After the commit, idempotent openCypher upserts through the CE-8 tenancy helper — an (:Assertion) node per row, its (:Document), and an EVIDENCED_BY edge.§5.3 write path (2)

✈️ Airplane: the three gates, and why each is structural

GateHow it is guaranteed (not just tested)
Provisional trust on every row The context.assertions.confirmation_status column defaults to provisional; the normalizer never sets it. The upsert's DO UPDATE clause deliberately does not touch the trust columns, so a later re-run can never demote a human-confirmed (canonical) assertion back to provisional. The node inherits the row's real status via RETURNING.
Idempotent per run Two layers. Within one card, typed assertions are deduped by fact_key (a card repeating the same fact is one row). Across runs, the write is ON CONFLICT (firm_id, document_id, fact_key) DO UPDATE … RETURNING assertion_id: a rerun writes no new rows and returns the same stable assertion_id, so the graph re-projects the identical node (MERGE, not create).
Projection verified node-for-row The same build_projections() feeds both the post-write projector and the reconciler source, so a freshly-written graph and a healed one are byte-identical. Every persisted row yields exactly one Assertion node keyed by its assertion_id, one Document node per source document, and one EVIDENCED_BY edge carrying the span ids.

🔬 Microscopic: the fact_key recipe

fact_key = sha256( normalize(subject) · attribute · value_norm )

normalize(subject) = casefold + collapse whitespace   # "  Sale  Deed " → "sale deed"
value_norm(date)   = ISO 8601                          # 2019-05-05
value_norm(amount) = "<minor>:<CURRENCY>"           # 5000000:INR

The three fields are joined by \x1f (unit separator) so "a" · "b" can never collide with "ab" · "" across a field boundary. A different value on the same (subject, attribute) yields a different key — that difference is exactly the contradiction candidate CE-10 will look up by index, never by an O(N²) model sweep.

Where subject comes from — and what this story does NOT do

Deterministic pass only. This story types the card's already-structured fields — dates and amounts — because Blake already paid to extract and span them; typing them needs no model call. The freetext key_assertions (admission / denial / obligation / position) require the CE-19 normalizer roster — a config-named model, distinct from Blake's, prompted to refute — and are intentionally deferred to that story (marked with a ponytail: seam in the code). The subject of a date/amount assertion is the document's doc_type (the instrument the value is a property of), so two like instruments with conflicting values collide as a contradiction candidate; CE-19's LLM refines that to a resolved entity when it lands. Until then the graph degrades gracefully, exactly as the RFD's sides_unknown posture allows.
Story-2119 gate (founder doctrine). Documents carry a home: record (world fact) or strategy (counsel's internal analysis). Assertion production consumes only home='record' documents — a strategy document NEVER becomes a world-fact assertion. normalize_document_card short-circuits to an empty outcome for any non-record document, at the production root (so the consumer, backfill, and every future caller are gated once). The Story-2119 home column/helper is not merged yet, so resolve_document_home returns record with a TODO(#2119) to swap in the real read; the exclusion branch is live and tested today.

Wiring & failure isolation

In the registration consumer, on a clean registered terminal, CE-7 ingests the card and — only if it stored — CE-9 normalizes it, in its own isolated transaction. A normalize or projection fault is logged, never raised: the document is registered and its card stored regardless, and the CE-8 reconciler plus the rate-capped backfill (CE-18) re-drive any missed matter. Because matter_assertions.updated's idempotency key is <detail>:<firm>:<matter>, every assertion change in a matter coalesces onto one pending event — the §9 per-matter coalescing law the edge writers and bundle rebuilder react to.

Observability

Instrumentation-exempt under pramaan-docs ADR-124 (documented at the module): the boundary runs inside the registration consumer's per-record span, the DB upsert is T1 auto-instrumented, and the outbox insert opens its own approved DOMAIN_EVENT_OUTBOX_ENQUEUE span — no hand-rolled span is added, matching the sibling card-ingest boundary.

Proof

tests/unit/test_context_assertion_normalizer.py proves the pure logic (typing, voice-independent fact_key, per-run dedup, unknown/freetext-attribute skip, node-for-row projection through the in-memory CE-8 seam). tests/integration/test_context_assertion_normalizer.py runs the real write path against Postgres as pramaan_app, asserting all three gates — provisional on every row, idempotent rerun (same assertion_ids), node-for-row projection plus a reconciler heal from relational truth — and the zero-rows first-run (a card with nothing typable writes nothing and emits no event).

Recall in one sentence: CE-9 turns a card's dates and amounts into provisional, voice-independent typed assertions — relational rows first (with an in-txn matter_assertions.updated), then a node-for-row graph projection — so the engine can finally reason over facts instead of prose.