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.

The analogy to hold the whole way down: a court clerk reading the exhibit register aloud. The clerk reads each line in order — number, party, document — but physically cannot read out a sealed/privileged document, because those were never allowed onto the register page. The annexure register is the same clerk reading the index at the front of a filing bundle: “Annexure A, the signed agreement, 1 page; Annexure B, the bank statement, 3 pages,” in the order counsel arranged them. This Story is that clerk — two GET endpoints — and the privilege gate is the rule that a sealed page is simply not on the register to be read.

🛰 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.

EndpointScope keyReturns
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}/annexuresparent documentAnnexureRegisterResponse — 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

InvariantMechanismWhat it prevents
DR-2: privileged docs never appear in a tenderable registerExhibit: 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 countExhibit: 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

One-sentence recall: two read endpoints open the registers the prior Stories built — markings by proceeding, annexures by parent — each privilege-gated in the query itself so a work-product document can never leak into a tenderable list, and each bounded to a near-constant query count so a big hearing bundle stays one or three round-trips, not fifty.