Story 1502: The Documents↔Filings Derive API (FN-FILING-LINK)

Why this exists (the catastrophe it prevents). Counsel reading a 400-document matter needs one honest answer per file: "is this filed, and as what?" The tempting shortcut is to stamp a filing_state and a filing_type column straight onto the document row. That shortcut is a lie waiting to happen. A document is never "filed" — the court filing it belongs to is. The filing's life has nine acts (drafted, awaited, filed, served, heard, decided, withdrawn, rejected, or not-yet-started); a 3-value flag bolted to the document diverges from the truth the instant a filing is served or decided, and it makes an annexure claim to be filed on its own when annexures are never separately lodged. pramaan-docs ADR-125 freezes the boundary: the Filing owns the lifecycle, the document merely links, and the register derives what it shows at read time. This Story builds that derive.

The one analogy: the filing is the envelope, the document is the letter

A Filing is an envelope lodged with the court. It has a type stamped on it (Statement of Claim, Reply Affidavit) and a state as it moves through the registry (the nine acts). Inside rides one lead document (the letter) plus its annexures (enclosures: A-1, B, C-3). You never ask a letter "are you filed?" — you ask the envelope. The enclosures share whatever happens to the envelope; they are not posted separately. This API answers, for any letter or enclosure you point at, "which envelope, what type, what state?" — or "not in any envelope" for the raw uploads and correspondence that make up most of a corpus.

🛰️ Satellite: what shipped

One read endpoint, plus the schema link that makes it answerable:

GET /app/documents/{doc_id}/filing  ->  200 DocumentFilingLinkView
Why bridge the existing table instead of a new one. A business.filings table already existed (matter-v3, migration 0090): proceeding-scoped, 3-value status, no document link. ADR-125 D2/D3 read as extending that table (it cites the design note that already places filings.document_id). So this Story adds the link and the lifecycle additively rather than forking a second "filing" concept. The ADR amendment that records this reconciliation is tracked on pramaan-spec#1495; no doctrine file is edited here.

✈️ Airplane: how a request resolves

doc = load document (firm-scoped; 404 if not in caller's firm via RLS)
effective_id = doc.parent_document_id or doc.id      <- annexure rides its parent
filing = SELECT id, current_state, proceeding_filing_types.name
           FROM business.filings
           JOIN business.proceeding_filing_types ON (firm_id, id = filing_type_id)
          WHERE firm_id = caller AND document_id = effective_id
if filing is None  -> isFiling=false, type=None, state=None   ("Not a filing / —")
else               -> isFiling=true,  type=name, state=current_state

Three turns make this correct:

🔬 Microscopic: the boundary lines that matter

Detail (titled by its reason)What & where
So the document never owns the lifecycledocuments gets no filing_state/filing_type column (ADR-125 D4). Only parent_document_id + exhibit_label are added.
So a document leads at most one filingPartial-unique index uq_filings_firm_document on (firm_id, document_id) WHERE document_id IS NOT NULL.
So an annexure is well-formedCheck exhibit_label IS NULL OR parent_document_id IS NOT NULL (a label needs a parent) and parent_document_id <> id (no self-parent).
So filing state and processing state never blurfilings.current_state (legal lifecycle) is independent of documents.status (ingestion). A test advances current_state='served' while the document stays uploaded (ADR-125 D6).
So the backfill is faithfulMigration maps the legacy status onto current_state: draft→draft, filed→filed, archived→withdrawn; new rows default not_started.
So tenants stay isolatedfirm_id on both tables; existing RLS/policies cover the new columns. The derive is always firm-scoped; a foreign-firm doc_id 404s.
So observability obeys the closed registryA pure read; covered by ADOT T1 auto-instrumentation (HTTP + DB spans). No registry-approved manual-span name exists for this derive (ADR-124 closed registry), so none is hand-rolled — documented at the service method.

The first real run is the empty firm

Most of a corpus is raw uploads and correspondence that lead no filing. That zero-state — a freshly provisioned firm whose documents link to nothing — is the actual first run, not a fixture edge case. test_document_filing_link_empty_firm_first_run_is_unlinked drives a live GET against exactly that state and asserts isFiling=false, type/state None ("—"). A consumer that got an error or a phantom filing here would be a door welded shut.

Acceptance evidence

Acceptance criterionTest / check
API returns derived filing type/statetest_document_filing_link_derives_type_and_state (lead doc → Statement of Claim / served)
"—" when unlinkedsame test (loose upload) + test_document_filing_link_empty_firm_first_run_is_unlinked (real empty-firm GET)
Annexure rides parent's filingtest_document_filing_link_derives_type_and_state (annexure A-1 inherits state, ridesParentFiling=true)
Annexure whose parent is unfiled is itself unlinkedtest_document_filing_link_annexure_parent_unfiled
Lifecycle orthogonal to processing status (ADR-125 D6)current_state='served' with document status='uploaded' in the derive test
Tenant isolationtest_document_filing_link_unknown_document_404 + firm-scoped query
Test4 new integration tests; full suite 982 passed
Migration upgrade/downgrade/check cleanmake test-db-alembic-roundtrip + alembic check → "No new upgrade operations detected"
OpenAPI mirror syncedmake openapi → openapi/app-api.json gains DocumentFilingLinkView, FilingState, route
Walkthrough doc includedthis document

One-sentence recall: a document is never "filed" — you ask the envelope, not the letter, so this API derives the linked Filing's type and nine-state lifecycle (riding the parent for annexures, "—" when unlinked) and never lets that truth be stored on the document.