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 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.
| Depth | Diagnosed when | What is dispatched | What 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. |
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.
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.
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.)
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.
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.
UNAUDITED_ROUTES ("re-derives
rebuildable understanding; writes no audited business row") — true of Story-2276's resegment,
false now: at extract depth the door rewinds Mode-1 custody truth on
documents.documents. It emits document.reread_requested carrying the depth and
reason codes, never content.
manual_span: the registry is closed and this is a small DB
write inside the route's FastAPI span (the documented exemption). The depth rides that span as
document.reread_depth, a registered low-cardinality KPI attribute (closed four-value
vocabulary), kept in the KPI set so it is structurally excluded from X-Ray annotation promotion.
AGENTS.md: no green counts until it has been red. Both planted negatives were observed failing before the suite was allowed to pass.
| Planted bug | What 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.
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.