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
- The live matter-v3
business.filingstable gains a document link (document_id) and the nine-state lifecycle (current_state) — additive; the legacy 3-valuestatusis left untouched so matter-v3 keeps working. documents.documentsgains annexure columns (parent_document_id,exhibit_label).- The endpoint derives the linked filing's
filing_type+current_stateat read time, resolving through the parent for annexures, returning "not a filing" (None/"—") when unlinked.
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:
- Annexures ride the parent. If
parent_document_idis set, the lookup uses the parent's id. An exhibit shows its parent affidavit's filing state; it can never be filed alone (ADR-125 D3). - Type is the proceeding-owned vocabulary name.
filings.filing_type_idpoints atproceeding_filing_types(matter-v3); the join resolves itsnameas the display type. No filing-type column is duplicated onto the document. - State is read, never stored on the document. The register column is a join, single-source-of-truth, impossible to desync (ADR-125 D5).
🔬 Microscopic: the boundary lines that matter
| Detail (titled by its reason) | What & where |
|---|---|
| So the document never owns the lifecycle | documents 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 filing | Partial-unique index uq_filings_firm_document on (firm_id, document_id) WHERE document_id IS NOT NULL. |
| So an annexure is well-formed | Check 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 blur | filings.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 faithful | Migration maps the legacy status onto current_state: draft→draft, filed→filed, archived→withdrawn; new rows default not_started. |
| So tenants stay isolated | firm_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 registry | A 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 criterion | Test / check |
|---|---|
| API returns derived filing type/state | test_document_filing_link_derives_type_and_state (lead doc → Statement of Claim / served) |
| "—" when unlinked | same test (loose upload) + test_document_filing_link_empty_firm_first_run_is_unlinked (real empty-firm GET) |
| Annexure rides parent's filing | test_document_filing_link_derives_type_and_state (annexure A-1 inherits state, ridesParentFiling=true) |
| Annexure whose parent is unfiled is itself unlinked | test_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 isolation | test_document_filing_link_unknown_document_404 + firm-scoped query |
| Test | 4 new integration tests; full suite 982 passed |
| Migration upgrade/downgrade/check clean | make test-db-alembic-roundtrip + alembic check → "No new upgrade operations detected" |
| OpenAPI mirror synced | make openapi → openapi/app-api.json gains DocumentFilingLinkView, FilingState, route |
| Walkthrough doc included | this 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.