Story 1882: opening the ledger — the Exhibit & Annexure register reads
Why this exists (the catastrophe it prevents). Story-1877 and Story-1878 built the storage: annexure columns on documents, and the net-new document_exhibit_marking junction with every “never” rule welded into the schema. But storage with no read path is a door welded shut — the firm-app can write a marking and never see it back. This Story is the two read endpoints that turn those tables into registers a lawyer can actually look at: which documents are tendered as exhibits in this proceeding, and which annexures ride this filing, in what order. The catastrophe to avoid is subtle and exactly the one the schema fought for upstream: a register is a tenderable surface — it is what counsel hands across to the other side. If a single privileged work-product document leaks into either list, the act of showing it is a privilege waiver, the worst-case legal failure (FN-PRIVILEGE). So the read path is not just “SELECT *”; it carries the privilege gate as a first-class predicate, and it must do so without an N+1 query storm that turns a 50-exhibit hearing bundle into 50 round-trips against RDS.
🛰 Satellite view: two reads, no new storage
Both endpoints are pure reads — no migration, no new table. The Annexure Register is computed from columns Story-1877 already added (parent_document_id, annexure_position, filing_label, filing_copy_type); the Exhibit Register reads the Story-1878 junction. The DTOs (DocumentExhibitMarkingView and its enums) already shipped with 1878; this Story adds the list-response wrappers and the two routes.
| Endpoint | Scope key | Returns |
|---|---|---|
GET /app/matters/{matter_id}/proceedings/{proceeding_id}/exhibit-register | (firm, matter, proceeding) | ExhibitRegisterResponse — every marking in the proceeding, JOINed to its document for display fields, ordered by sort_key then label. |
GET /app/documents/{doc_id}/annexures | parent document | AnnexureRegisterResponse — the parent’s child documents ordered by annexure_position, each with its filing label, description, copy type, and page count. |
The exhibit route nests under proceedings/{id} because a marking is an event in a proceeding; the annexure route nests under documents/{id} because annexures are children of one parent filing document. Each route’s matter_id in the path is the firm-local matter number (the repo’s URL convention), resolved to a UUID by the same RLS-bound _matter_for_number the rest of the document surface uses.
✈️ Airplane view: the two invariants every register read must hold
| Invariant | Mechanism | What it prevents |
|---|---|---|
| DR-2: privileged docs never appear in a tenderable register | Exhibit: the JOIN to documents carries AND NOT d.privilege_flag. Annexure: the children query carries Document.privilege_flag.is_(False). | A privilege waiver. Upstream, the 1878 trigger blocks marking a privileged doc — but a document can be marked and later flipped privileged. The read gate is the second line of defence: even a pre-existing marking row is dropped at read time. |
| ADR-130 DR-1: bounded, near-constant query count | Exhibit: a single JOIN query. Annexure: a fixed 3 statements (parent resolve + children + one grouped IN page-count query keyed by id → dict), never one query per annexure. | An N+1 storm. A 50-page hearing bundle is 50 child documents; a naive per-row page-count lookup is 50 extra RDS round-trips. DR-2 says: loop over TABLES, not rows. |
Access control rides on the existing perimeter: _matter_for_number is RLS-bound (business.can_read_matter), so a caller not on the matter gets a 404 before any marking is read; firm RLS then scopes the markings themselves. Like the sibling /filing read, these are firm-member reads — no require_permission write gate.
🔬 Microscopic view: the decisions that matter
1. The privilege gate is the JOIN, not a post-filter
It would be tempting to fetch all markings, then drop the privileged ones in Python. That is both an N+1 (one document lookup per marking) and a footgun (forget the filter, leak the doc). Instead the gate is a predicate on the single JOIN, so the database never even returns a privileged row, and the same JOIN supplies the document display fields (filename, display name) the register needs:
FROM documents.document_exhibit_marking AS m
JOIN documents.documents AS d ON d.id = m.document_id
WHERE m.firm_id = :firm_id
AND m.matter_id = :matter_id
AND m.proceeding_id = :proceeding_id
AND NOT d.privilege_flag
AND d.availability = 'live'
ORDER BY m.sort_key ASC NULLS LAST, m.exhibit_label ASC, m.id ASC
One round-trip, firm-leading (it rides the ix_document_exhibit_marking_firm_proceeding index 1878 created), privilege-gated, and ordered by the stable sort_key so renumbering the display label never reshuffles the register (DR-7). document_exhibit_marking has no SQLModel entity (it is write-owned by a later Story), so this read uses a parameterised text() query — never f-stringing values into SQL — and maps each row mapping straight into the existing DocumentExhibitMarkingView.
2. Page counts in one grouped query, not one-per-annexure
The Annexure Register shows each annexure’s page count from document_pages. The forbidden shape is a per-child SELECT count(*). The bounded shape is one grouped query over the whole child set, zipped back in memory (DR-2 — loop over the fixed set of children once, in Python, not in SQL):
select(DocumentPage.document_id, func.count())
.where(DocumentPage.firm_id == firm_id,
DocumentPage.document_id.in_([c.id for c in children]))
.group_by(DocumentPage.document_id)
# -> {document_id: count}; annexures with zero pages default to 0
So the whole endpoint is three statements regardless of annexure count: resolve the parent (RLS-scoped, 404 on an unknown/foreign id so a typo is not silently an empty register), fetch the live non-privileged children ordered by annexure_position NULLS LAST, and one page-count roll-up. A test asserts the completion line’s db_query_count stays under budget — the durable guard against an N+1 creeping back.
3. The Annexure Register is computed — no annexure table
An annexure is not a separate entity; it is a document with parent_document_id set, riding its parent’s filing (ADR-125 D3). The register is therefore a derived view over documents ordered by the counsel-reorderable annexure_position column — exactly as the Story scope demands (“computed, NO new table”). The response carries each child’s filing_label, description, filing_copy_type, and page count: enough for the front-of-bundle index without a second fetch.
4. The producer mirror is resynced; consumers resync separately
Two new routes and three new DTOs are a contract change, so make openapi regenerates openapi/app-api.json (admin-api is untouched — these are app routes). The operation IDs come out clean and camelCase (appGetProceedingExhibitRegister, appListDocumentAnnexures); every route declares response_model=, an AUTH_NOT_FOUND_VALIDATION_SERVER_RESPONSES error map, and inherits its router tag. The app/admin frontends resync their generated clients from this snapshot in their own PRs.
How it was proven
- System-boundary integration tests (
tests/integration/test_app_registers.py) — every assertion is a real GET through the app router against the Docker test DB, including the empty first-run state (a freshly provisioned firm: both registers return[], not a 500/404):- Exhibit Register: two markings ordered by
sort_key(R-4 before C-1 despite the label), full DTO round-trip including the JOINeddocumentFilename/documentDisplayName, and — the dedicated privilege test — a third document marked then flipped privileged, whose marking row survives in the table but is dropped from the response. - Annexure Register: children inserted out of order returned sorted by
annexure_position, with correct per-annexure page counts (1 and 3), filing label / copy type / description carried through, a privileged child excluded, and adb_query_countbudget assertion proving the page counts are one grouped query, not an N+1.
- Exhibit Register: two markings ordered by
- No regression: the document filing-link and exhibit-marking RLS suites still pass alongside the new tests (10 passed).
- Verify:
ruff checkclean on changed files;alembic checkreports “No new upgrade operations detected” (this Story adds no schema);make openapiregenerated and the app-api snapshot committed; admin-api unchanged.