Story 2219: the stamp said “filed”, the shelf was empty — rescuing the documents the Context Engine never learned

WHY. The catastrophe this Story prevents is a lawyer asking the matter a question and getting a confident, incomplete answer. Every document in a matter is supposed to end up in the moat: chunked, embedded, retrievable. Between the day the registration consumer shipped and 2026-07-10, 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.

That left them doubly unreachable. The registration consumer no-ops on a 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.

🛰 Satellite — the one sentence

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.

✈️ Airplane — the registry clerk, the runner, and the door to nowhere

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.

🔬 Microscopic — what changed, part by part

1. The fan-out becomes one shared thing (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.

2. The kick's outcome is reported, not just logged (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.

3. The orphan cohort, as a predicate (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.

4. Idempotency, without a lock

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.

5. One budget, two classes (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}

What this Story does not do

Deliberately absentWhy
Re-run Blake on a registered documentregistered 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 rowsThe 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 chunksPer 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 behaviourThe 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 DTONone. make openapi produces a zero diff.
A new manual spanpramaan-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.

Test evidence

CheckResult
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.pyPass. 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 suite2675 passed, 2 skipped against the isolated Docker test DB.
ruff check src/ tests/Clean.
make openapiZero diff — no route, DTO, or trust-boundary payload changed.
Recall in one sentence: Story 2219 gives the CE-18 backfill a second, Blake-free re-drive class for documents whose 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.