Story 1532: Whose Filing Is This? (FN-2 — extend the document→filing derive)

Why this exists (the catastrophe it prevents). The Record shows counsel every piece of a matter — our filings, the opponent's filings, the court's orders, and the loose documents underneath. Story-1502 (FN-FILING-LINK) taught the register to answer "is this filed, and as what?" But it left a more dangerous blank: whose filing is it? A reviewer scrolling 400 rows who cannot instantly tell our Statement of Claim from the opponent's Statement of Defence will, sooner or later, read the wrong side's pleading as their own and brief on it. The tempting shortcut — stamp a "party" string onto each document — is the same lie ADR-125 already forbade for filing state: a document has no party of its own; the filing it belongs to was lodged by a party. This Story extends the read-time derive to carry that party — name, role-label, and side — without storing a single new byte on the document.

The one analogy: the return address on the envelope

Keep Story-1502's picture. A Filing is an envelope lodged with the court; inside rides one lead document (the letter) plus its annexures (enclosures A-1, B, C-3). FN-2 reads the return address printed on that envelope: who sent it (the participant's display name, e.g. "Vega Capital"), in what capacity (their label in this proceeding, e.g. "Respondent"), and which side of the table they sit on relative to our client (the side badge: Ours · Opposing · Court/Tribunal · Others). You never write the sender on the enclosure — you read it off the envelope, and every enclosure inherits it. A loose letter in no envelope has no return address: "—".

Built on FN-1 (Story-1531), which lands first. The return address has to be written before it can be read. FN-1 adds the two columns this derive consumes: matter_participants.side (the 4-value structural axis, default others) and business.filings.filed_by_participant_id (the nullable FK naming the filer). FN-2 owns no schema — it is pure read-path. This branch references those columns and rebases onto main once FN-1 merges; that rebase is when CI fully greens.

🛰️ Satellite: what shipped

The same one read endpoint, now richer — no new route, no new write path:

GET /app/documents/{doc_id}/filing  ->  200 DocumentFilingLinkView (+ filedBy)

✈️ 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
row = SELECT filings.id, filings.status,
             proceeding_filing_types.name           AS filing_type,
             filings.filed_by_participant_id,
             matter_participants.name                AS filer_name,    <- LEFT JOIN
             matter_participants.side                AS filer_side,
             proceeding_participant_labels.name      AS filer_label    <- LEFT JOIN (role)
        FROM business.filings
        JOIN proceeding_filing_types          ON (firm_id, id = filing_type_id)
   LEFT JOIN matter_participants              ON (firm_id, matter_id, id = filed_by_participant_id)
   LEFT JOIN proceeding_participant_roles     ON (firm_id, proceeding_id, participant_id = filed_by_participant_id)
   LEFT JOIN proceeding_participant_labels    ON (firm_id, proceeding_id, id = role.participant_label_id)
       WHERE firm_id = caller AND document_id = effective_id
if row is None              -> isFiling=false, filed_by=None        ("Not a filing / —")
elif filed_by_participant_id-> filed_by = {filer_name, filer_label, FilingSide(filer_side)}
else                        -> filed_by = None                     (filing names no filer)

Four turns make this correct:

🔬 Microscopic: the boundary lines that matter

Detail (titled by its reason)What & where
So a blank filer never erases the filingfiled_by is populated only when row.filed_by_participant_id is not None and row.filer_name is not None; otherwise None. The three filer joins are isouter=True in DocumentService.filing_link (src/domains/document/service.py).
So "side" can't be edited into nonsenseFilingSide is a closed StrEnum (ours/opposing/court/others) in src/domains/document/dtos/document.py — a structural axis, not firm vocabulary. The derive wraps the raw column with FilingSide(row.filer_side), so an out-of-domain value fails loudly.
So an unlabelled filer still shows whoparticipant_label_name is nullable: name + side present, label None → "—" in the label slot. Proven by test_document_filing_link_filer_without_proceeding_label.
So the consumer contract stays explicitfiled_by declared on the response model with Field(description=...); make openapi regenerates openapi/app-api.json (FiledByView + FilingSide schemas) so the app mirror is exercised. Admin API is untouched — this route is app-only.
So the empty first run is real, not a fixtureEvery assertion runs a live GET through the app router against the test DB, including a freshly provisioned firm whose filings name no filer (test_document_filing_link_*).
Reuse, not rebuild (ADR-1179). No new participant concept is introduced. The filer is an existing matter_participants row; the capacity is an existing proceeding_participant_roles + proceeding_participant_labels assignment. FN-2 only adds a read-time projection of what already exists, keyed by FN-1's one FK.
Observability. The derive stays on the ADOT T1 auto floor (HTTP server span + DB span). No registry-approved manual span name (pramaan-docs ADR-124 closed registry) covers a documents↔filings derive, and the change is additional LEFT JOINs on a single firm-scoped read — instrumentation-exempt by the auto tier, not by omission.

One-sentence recall

FN-2 reads the return address off the envelope — who filed, in what capacity, on which side — as a read-time join over FN-1's filer FK, so the Record can tell our filings from the opponent's without ever stamping a party onto a document.