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.
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:
| Step | What it does | RFD |
|---|---|---|
| Type | Turn 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) |
| Gate | How 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. |
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.
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.
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.
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.
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.
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.