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: "—".
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)
DocumentFilingLinkViewgainsfiled_by: a nullableFiledByView={participant_name, participant_label_name, side}.- A new system enum
FilingSide=ours | opposing | court | others— a fixed structural axis, never a firm-editable vocabulary. - The derive resolves the linked Filing's filer into that view at read time, reusing the ADR-1179 participant model (matter participant + per-proceeding label). Annexures inherit the parent filing's
filed_by. Unlinked, or a filing that names no filer →filed_by = None("—").
✈️ 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:
- The filer joins are
LEFT, never inner. A filing may name no filer; a named filer may carry no label in this proceeding. An inner join would silently drop the whole filing row in either case — the document would read "not a filing" the instant a filer was missing. The base filing facts (type, state) must survive a blank return address. - Name + side come off the matter participant; the label comes off the proceeding role. "Claimant" is Ours or Opposing depending on whom we act for, so
sidelives on the participant (set once per matter), while the label is per-proceeding vocabulary (ADR-1179). The derive reads both through the onefiled_by_participant_id. - Annexures inherit the return address. The
effective_idindirection already resolves an annexure to its parent's filing;filed_byfalls out of the same row, so an exhibit shows its parent's filer and can never claim a sender of its own. - Nothing is stored on the document.
filed_byis a join projected at read time, single-source-of-truth on the Filing — impossible to desync, exactly like filing state (ADR-125 D5).
🔬 Microscopic: the boundary lines that matter
| Detail (titled by its reason) | What & where |
|---|---|
| So a blank filer never erases the filing | filed_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 nonsense | FilingSide 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 who | participant_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 explicit | filed_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 fixture | Every 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_*). |
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.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.