Story 2377: one Re-read, and the machine decides how deep to repair

WHY. Story-2353 shipped the founder-ruled two-verb document vocabulary: counsel sees exactly Re-read and Delete. The whole point of that ruling is that the internal repair ladder — re-extract / reclassify / re-segment / re-brief — is the machine's business (the minimize-HITL law: machines check machines, never approval-ask). The door to do that did not exist. The endpoint behind the verb was segmentation-only: it re-cut a document's sections against its CURRENT extraction run. So a lawyer who pressed Re-read on a badly-SCANNED document got its sections re-cut against the same bad text, and nothing improved. The verb promised a repair the backend could not perform.

The shape: same path, same verb — the app changes nothing

POST /app/matters/{matter_id}/documents/{doc_id}/re-read keeps its path, its matters:write gate, and its honest 202. pramaan-app is already wired to it (documentsGateway.reread through the BFF proxy), so this Story needs zero app changes — the verb simply starts meaning what it says. What changed is that the server now diagnoses the depth. The caller never names a stage; if the app picked, the ladder would have leaked back into the UI and the ruling would be dead.

The ladder

The decision lives in src/domains/document/reread.py as a pure function of the document's two lifecycle axes (pramaan-docs ADR-128 §2: processing_status is Mode-1's, registration_status is Blake's — they never share values). Pure, so it is unit-testable without a database and cannot drift from the dispatch that follows it. It is ordered deepest-first, because a deeper rung's ticket already re-runs every shallower one — so exactly ONE ticket is written per re-read.

DepthDiagnosed whenWhat is dispatchedWhat re-runs
extract Extraction is degraded (failed / needs_review / unreadable) or the document has no extraction_run_id at all. One document.ingest.requested. Everything. The worker re-extracts, mints a fresh run id, and calls request_registration itself — so Blake reclassifies and his fan-out re-segments and re-briefs.
classify Extraction is healthy; registration is not_requested, registration_failed, or registration_needs_review. One document.registration.requested, against the CURRENT run. Blake reclassifies; his fan-out re-segments and re-briefs.
segment Both axes healthy (indexed + registered). One document.resegment.requested — the Story-2276 pass. Sections, then the brief (the pass finishes through complete_document_segmentation, which kicks enrich — Bug-2310).
none A pass is already in flight, the upload never stored, or the document is soft-deleted. Nothing. Nothing. An honest 202 with queued: false and a reason — not a fabricated no-op ticket.

The two things that make the cascade real

Deciding the depth is the easy half. Each rung has to leave the document in a state the pipeline will actually pick up — and both non-obvious traps here are idempotency keys doing exactly what they were built to do, in a case where we do not want them to.

Trap 1 — a retained extraction_run_id stops the ladder at the text. ADR-128 §4 keys registration on (document_id, extraction_run_id): "one extraction pass, one registration request". That is right for the pipeline — and fatal for a re-extract. The worker only mints a run id if document.extraction_run_id is None, so a re-extraction that kept the old id would re-register onto the same outbox row, ON CONFLICT DO NOTHING would swallow it, and the document would get new text with Blake's old verdict still standing over it. So the extract rung discards the run id and disarms registration_status to not_requested. The registration state machine gains one new edge out of each terminal for exactly this rewind — registered can still never move forward again.
Trap 2 — the deterministic key swallows a deliberate re-queue. The classify rung re-queues Blake against an unchanged run, so the deterministic ADR-128 §4 key is already on the outbox from the first (failed) attempt. Reusing it means the INSERT is swallowed, registration_status flips to queued, and nothing on any queue ever drains it — a document parked in a lie. request_registration therefore takes an optional idempotency_key; the pipeline never passes one, and the re-read passes a per-request uuid. (Same reason the extract and segment tickets carry one: D31 — counsel may re-read at will; cost is recorded, never optimized against.)
The extraction worker's own guards are the spec. A re-extract must land the row at stored — the worker's _EXTRACTABLE_STATE. Any terminal (including the needs_review we came from) is in _TERMINAL_STATES and would be acked as ALREADY_DONE without re-paying OCR. The processing_status_reason code is CHECK-bound to its owning terminal, so it is cleared in the same write.

What counsel sees: the JOURNEY

The app renders a document's JOURNEY from documents.document_status_events. Every depth that DID something appends reread_<depth> there, attributed to the counsel who pressed the verb and stamped with the active trace_id; the extract rung appends its stored rewind after it. So the ledger reads ["reread_extract", "stored"] — the machine says what it decided and what it did. A none re-read writes no row: a JOURNEY entry for work that never happened is the dishonest half of the same ruling.

Audit & observability

Proof — and the red run behind it

AGENTS.md: no green counts until it has been red. Both planted negatives were observed failing before the suite was allowed to pass.

Planted bugWhat went red
The ticket's own bug: degraded extraction falls through to the segment rung. test_degraded_extraction_is_actually_re_extracted, test_re_extract_is_repeatable, test_re_read_emits_one_audit_row_carrying_the_depth
The classify rung falls back to the deterministic ADR-128 §4 key. test_failed_classification_is_requeued_with_a_fresh_key (the outbox swallows the row; queued with nothing to drain it)

tests/unit/test_document_reread_depth.py holds the golden table — every (processing_status × registration_status) shape and the depth the machine owes it — plus the drift guard that the classify rung is exactly the set request_registration will act on. tests/integration/test_document_reread_door.py holds the DB-backed proofs against a real Postgres: the row state, the outbox tickets, the ledger, and the audit row.

If you remember one thing. The depth decision is not the feature — the cascade is. One ticket at the deepest warranted rung, and the pipeline's existing hand-offs (worker → request_registration → Blake → fan-out → segmentation → enrich) re-run everything below it. That is why the door writes one ticket, never a list of stages: the ladder already exists, it just had no honest way in.