CONTEXT_ENRICH_QUEUE_URL was unset — the SSM parameter and
the queue did not yet exist. The enrich kick at the end of registration is deliberately
best-effort (a dead queue must never un-register a document), so it returned silently. The
documents were marked registered and nothing else happened to them.
registered document, because registered is a terminal state with no
outbound transition (pramaan-docs ADR-128 §2). And the CE-18 backfill — the thing whose
docstring says it re-drives “un-enriched documents” — filtered on
registration_status IN (not_requested, registration_failed, registration_needs_review),
which excludes registered by design so Blake is never double-run. Neither door opened.
Live proof: matter ea90a732, documents 3ee1dc28 and be39d48c
— registered, with context.chunks, context.assertions,
and context.document_cards all at zero. A document that is indexed but unsearchable
is worse than a missing one, because the retrieval layer answers as if the matter were complete.
The CE-18 backfill grows a second re-drive class: for a registered
document that step 8 never carded and CE-3 never chunked, it performs the step-8 fan-out
directly — no Blake re-run, no state transition, just the enrichment kick that was
swallowed — and it reports the two classes separately as
{"requeued": n, "enrich_kicked": m}. The fan-out itself is no longer copied: it is
extracted from the consumer into src/shared/context/fanout.py, and both producers call
the same function.
Picture the court registry. A document arrives; the registrar (Blake) reads it,
decides what it is, and stamps the file REGISTERED. That stamp is final — a
registrar never re-stamps a stamped file, and the rules forbid it (there is no arrow out of
registered).
Behind the registrar stands a runner, whose only job is to carry the stamped file down the corridor to the index room, where clerks cut it into cards and file them so any future search finds it. The runner's instructions are humane and correct: if the index room is locked, do not tear up the file — just come back and say nothing. A stamped document is still a stamped document.
For several months the index room did not exist. Not locked — unbuilt. The corridor
ended in a wall. The runner walked down it, found nothing, came back, said nothing. Every file was
stamped REGISTERED. Every shelf in the index room was bare. And because the registrar
will not re-stamp, and because the night-shift sweeper (the backfill) was told to collect only
unstamped files, nobody was ever going to walk down that corridor again on their behalf.
This Story does two things. It gives the sweeper a second list: stamped files with no cards in the index room. For each, the sweeper does not wake the registrar — the stamp is fine, the reading was done, and a second reading costs a brain run. It simply walks the file down the (now-built) corridor itself. And it moves the runner's instructions out of the registrar's office into a corridor of their own, so that the sweeper and the registrar send files down the same corridor and can never drift into two different ones.
src/shared/context/fanout.py, new)Step 8 of the registration consumer used to be four private helpers. They are now one exported
function with the seams injected, and the consumer calls it through a thin
_fan_out(deps, ctx, result) that keeps the one registration-specific job —
extracting Blake's card and checking its identity against the document being registered.
fan_out_document_context(seams, *, firm_id, matter_id, document_id,
extraction_run_id, card, log_source, log_extra)
card is not None ──▶ _ingest_card ──(ok)──▶ _normalize_card ──▶ _project_chronology ──┐
│ │ │
│ └──(rejected: span/OCR gate)─────────────────────────────┐ │
│ ▼ ▼
└──(card is None: the orphan cohort)────────────────────────────────▶ _kick_enrichment
│
▼
FanOutResult(card_ingested,
enrich_kicked)
Three properties are load-bearing. Failure isolation survives the move: every
sub-step runs in its own transaction and swallows its fault into a log line, because the document is
already registered when the fan-out runs and a rejected card or a dead queue must never
un-register it (RFD-CTX-001 §5.1 invariant M-12b). Card ingest gates the card-derived
steps but not the kick: a span-rejected card yields no assertions, yet the document's text
still belongs in the moat. And card=None is a first-class input —
ingest, normalize, and chronology all hang off a card, so with no card the fan-out is the
enrichment kick, which is exactly and only what the env gap swallowed.
The consumer's log-line names are preserved byte-for-byte via log_source
(document_registration_consumer.enrich_kicked and friends), so no CloudWatch query or
dashboard breaks; the backfill emits the same shapes under context_backfill.*. One new
line appears where there used to be pure silence: <source>.enrich_kick_skipped_no_queue.
That silence was this bug.
FanOutResult)The old _kick_enrichment returned None in all three cases: sent, skipped
(no queue configured), and failed. A caller could not tell a delivered document from a lost one. It
now returns a boolean that rides out in FanOutResult and up into the Lambda's response
body. Best-effort is preserved — nothing raises — but “best effort” now has to
say out loud whether the effort worked.
src/shared/context/backfill.py)The second class selects the documents the first class structurally cannot see. Read it as one sentence: a live, indexed, non-privileged document that Blake already registered, that step 8 never carded, and that CE-3 never chunked.
registration_status = 'registered' -- invisible to the requeue class, by design AND availability = 'live' AND processing_status = 'indexed' AND privilege_flag = false -- FN-PRIVILEGE: counsel already ruled. Never indexed. AND NOT EXISTS (context.document_cards for this document) -- step 8 never ran AND NOT EXISTS (context.chunks for this document) -- "un-enriched", literally AND NOT EXISTS (open privilege_suspicion inbox item) -- I-11: counsel is still ruling ORDER BY created_at LIMIT :limit
Two of those predicates are safety, not selection, and neither was named in the ticket.
privilege_flag = false is the one that would have bitten: the consumer's
I-11 hold checks only for an open privilege_suspicion item, but once counsel
resolves that item with [Mark privileged] the item closes and the durable answer moves to the
column. A backfill that checked only the open item would have cheerfully enriched a document counsel
had just declared work product — privilege waiver being the worst legal failure in the system.
The open-item check is the other half: while counsel is still deciding, enrichment
holds, because you cannot un-read a suspected-privileged document out of a moat query.
The two NOT EXISTS markers do different jobs. No card means step 8
never ran for this document, and it is what keeps the class from re-kicking every carded document on
every invocation forever. No chunks is the literal reading of “un-enriched”
and, crucially, it is self-clearing: once the CE-3 embed worker's chunks land, the document
leaves the eligible set and a rerun skips it. That is the resumable cursor, with no extra state
— the same trick the requeue class plays with its → queued transition.
The requeue class takes FOR UPDATE SKIP LOCKED because it mutates state and mints an
outbox row; two invocations must not both drive one document into Blake. The enrich class needs no
lock and takes none. It writes nothing to the database. Its only side effect is an SQS send, and the
embed worker inserts ON CONFLICT (firm_id, document_id, chunk_hash) DO NOTHING — so
a double kick, whether from a concurrent invocation or a rerun before the chunks land, is
arithmetically harmless. Holding row locks on registered documents across an SQS round-trip would have
been strictly worse.
backfill_matter)limit caps the whole invocation, not each class. The requeue class draws first —
it is the expensive one, spending a Blake run per document — and the enrich class takes whatever
remains. A matter with a large requeue backlog therefore cannot fan out an unbounded number of kicks
in the same pass, and because both classes are self-draining, the next invocation resumes exactly
where this one stopped.
{"firm_id": "...", "matter_ids": ["..."], "limit": 25}
│
├── class 1 requeue not_requested / registration_failed / registration_needs_review
│ → mint fresh outbox row, → queued, relay → consumer → Blake → step 8
│
└── class 2 enrich kick registered ∧ no card ∧ no chunks ∧ not privileged ∧ no open hold
→ fan_out_document_context(card=None) → SQS → CE-3 chunk+embed
{"matters": [{"matter_id": "...", "requeued": 2, "enrich_kicked": 1}],
"requeued": 2, "enrich_kicked": 1}
| Deliberately absent | Why |
|---|---|
Re-run Blake on a registered document | registered is terminal (pramaan-docs ADR-128 §2) and a re-read costs a brain run. The stamp is good; only the delivery failed. |
Recover the missing document_cards / assertions rows | The card is Blake's product and rides his turn stream; it cannot be re-derived without re-running him. Chunks can be, and chunks are what “un-enriched” means. Restoring cards for this cohort is a separate, Blake-spending decision. |
Kick a registered document that has a card but no chunks | Per the ticket's acceptance criteria (“registered doc WITH card untouched”). A carded document did reach the fan-out; the card row is the “step 8 ran” marker and the only thing keeping the class drainable. Known gap: if such documents exist, they stay orphaned and need their own ticket. |
| Change the consumer's behaviour | The refactor is behaviour-preserving: same order, same failure isolation, same log-line names. The consumer's own tests pass unchanged in substance. |
| A migration, a route, or a DTO | None. make openapi produces a zero diff. |
| A new manual span | pramaan-docs ADR-124: the registry is closed. The kick already opens the approved DOCUMENT_ENRICH_KICK producer span; the fan-out is orchestration, covered by the caller. |
| Check | Result |
|---|---|
tests/unit/test_context_fanout.py (new) | 5 tests over the shared fan-out: card=None kicks and touches no DB session (the session factory raises if called); an unset queue seam reports enrich_kicked=False rather than lying; a failing send is isolated and reported; a card drives ingest → normalize → chronology → kick in order; a rejected card skips assertions but still kicks. |
tests/integration/test_context_backfill.py (extended) | System-boundary, real Postgres, freshly provisioned firm: of five seeded documents only the orphan is kicked — the carded one, the privilege_flag one, and the one under an open I-11 hold are untouched on both axes, while the needs_review one still takes the requeue path. Asserts the orphan is not re-queued (registered, zero outbox rows). A second test proves limit is one budget across both classes. The zero-document first-run case still returns a typed zero. |
tests/unit/test_document_registration_consumer.py | Pass. The two I-11 tests now patch _fan_out — the question they ask (does the consumer reach the fan-out when a privilege hold is open?) is unchanged. |
| Full suite | 2675 passed, 2 skipped against the isolated Docker test DB. |
ruff check src/ tests/ | Clean. |
make openapi | Zero diff — no route, DTO, or trust-boundary payload changed. |
registered stamp outlived their enrichment —
selecting them by the two absences that define the orphan (no card, no chunks) and the two that
protect the client (no privilege flag, no open privilege hold) — and it extracts the
consumer's step-8 fan-out into one shared function so the rescue path and the live path can never
drift apart.