Story 1891: counsel picks up the pen — the exhibit-marking renumber write & the two-identity row
Why this exists (the catastrophe it prevents). Story-1882 opened the registers for reading; building the Filings UI on top of them (#1886) hit two walls that no read endpoint can scale. First: counsel renumbers exhibits constantly — the tribunal says “mark this as C-7, not C-1” mid-hearing — but there was no write path at all to change exhibit_label. A read-only register is a printed page you cannot annotate; the lawyer is stuck. Second: the register row showed the exhibit number but not the annexure identity of the same document (“Annexure A”), so the UI could not render the line a lawyer actually reads — “Annexure A, marked Exhibit C-1” — without a second fetch per row. This Story closes both gaps: one PATCH endpoint to renumber a marking, and two extra columns on the marking view so the annexure label and the exhibit number arrive on one row.
The catastrophe the write guards against is sharper than a missing feature. An exhibit number is an identity within a party series — there is exactly one “C-1” for the claimant in a proceeding. If a renumber silently lets two markings collide on C-1, the record is corrupt and the conflict surfaces later as a database error a lawyer cannot decode. And admission/rejection is a tribunal act: an agent must never be able to relabel an exhibit and thereby touch the record. So the write is human-only, collision-checked, and auditied — every relabel is on the record, old→new.
🛰 Satellite view: one write, two enriched read fields
| Change | Surface | Effect |
|---|---|---|
| Renumber write | PATCH /app/matters/{matter_id}/proceedings/{proceeding_id}/exhibit-register/{marking_id} | Sets the marking’s exhibit_label; stamps source='human' + the recording user; 409 on a series collision; audited exhibit_marking.renamed (old→new). Returns the enriched DocumentExhibitMarkingView. |
| Two-identity row | DocumentExhibitMarkingView | Now carries filing_label + parent_document_id, JOINed from documents, so the UI renders “Annexure A · Exhibit C-1” from the marking endpoint alone. Privileged docs stay excluded. |
No migration. The junction and its “never” rules (Story-1878, mig 0142) and the annexure columns on documents (Story-1877, mig 0140) already exist; this Story is one write method, one route, two view fields, and one audit-vocabulary entry. The route nests as a sibling of the read register (.../exhibit-register/{marking_id}) because a renumber is an edit to one row of that register.
✈️ Airplane view: the four invariants the write must hold
| Invariant | Mechanism | What it prevents |
|---|---|---|
| Human-only authority | Route is require_permission("matters:write") (counsel-tier); the service unconditionally stamps source='human' and records the user. The DB document_exhibit_marking_admission_human_only CHECK (lifecycle='marked' OR source='human') therefore always holds — even relabelling an admitted/rejected marking is fine, because the edit keeps it human-sourced. | An agent silently relabelling an exhibit, or an admitted exhibit’s label being changed into an agent-sourced state (a DB-rejected, record-corrupting move). |
| 409 on series collision | A pre-write check against the same uniqueness key the DB enforces — (firm, matter, proceeding, party-role-canonical, normalized-label) — using the byte-identical lower(normalize(btrim(...), NFKC)) expression, excluding this row itself. A typed PramaanConflictError → 409. | Two exhibits sharing one number in a party series, or a raw IntegrityError 500 the lawyer cannot read. |
| DR-2: privileged / non-live not editable | The marking is resolved through the SAME privilege+live JOIN as the read register. A marking whose document is privileged or archived is a 404, not a silent edit. | Editing a marking that the register itself drops — an edit to a row the lawyer cannot even see, and a privilege-waiver-adjacent surface. |
| Every relabel is on the record | emit(action="exhibit_marking.renamed", …) with a diff_snapshot({exhibit_label: old}, {exhibit_label: new}), scoped to (matter, proceeding). | A silent identity change — renumbering an exhibit with no trail of who changed C-1 to C-7 or when. |
🔬 Microscopic view: the decisions that matter
1. Resolve-and-enrich in one query; the privilege gate is the JOIN
The write resolves the marking through the read register’s exact gate, and grabs the document display fields in the same round-trip — so the response can carry the two-identity row without a second fetch:
select(DocumentExhibitMarking,
Document.original_filename, Document.display_name,
Document.parent_document_id, Document.filing_label)
.join(Document, Document.id == DocumentExhibitMarking.document_id)
.where(firm_id == :firm, matter_id == :matter, proceeding_id == :proc,
id == :marking_id,
Document.privilege_flag.is_(False),
Document.availability == 'live')
# no row -> 404: a marking on a privileged/archived doc is not tenderable, so not editable
One resolve query, one flush, one audit insert — bounded and near-constant (ADR-130 DR-1). The marking has no ORM relationship to its document, so the JOIN selects both: the ORM marking to mutate, the document columns to render.
2. The collision check uses the DB’s own normalization, not a Python guess
The uniqueness key is on a STORED generated column, exhibit_label_normalized = lower(normalize(btrim(exhibit_label), NFKC)). The pre-write check must compare against the same expression, or it would disagree with the constraint at the margins (case, whitespace, Unicode form). So the check is run in SQL with the identical function, excluding the row being edited:
SELECT 1 FROM documents.document_exhibit_marking
WHERE firm_id = :firm AND matter_id = :matter AND proceeding_id = :proc
AND exhibit_party_role_canonical = :role_canonical
AND exhibit_label_normalized = lower(normalize(btrim(:label), NFKC))
AND id <> :marking_id
LIMIT 1 -- found -> PramaanConflictError -> 409
IntegrityError catch. The unique index is still the real guard; a concurrent collision (two counsel renumbering the same series at the same instant) would surface as a 500, which is acceptable for the single-editor-per-matter renumber path. The upgrade path, if that ever becomes real, is a savepoint-and-catch around the flush.3. A new audit subject, not a schema migration
Auditing the relabel needs a registered action. The audit registry is closed vocabulary — but extending it is an edit to the SUBJECTS/VERBS tables, never a migration (that is the registry’s explicit design). So this Story adds one subject, exhibit_marking (matter-scoped, no PII to exclude, not a containment-chain entity so no self-stamped scope), and reuses the existing renamed verb (a diff snapshot). The action exhibit_marking.renamed then resolves, and the matter/proceeding scope rides the Scope passed at emit. The route is also registered in the audit EMIT_ROUTES coverage table, so the “every mutating route is classified” gate stays green.
4. The producer mirror is resynced; consumers resync separately
One new route, one new request DTO (ExhibitMarkingRenumberRequest), and two new fields on a response DTO are a contract change, so make openapi regenerates openapi/app-api.json (admin-api untouched). The operation ID comes out clean and camelCase (appRenumberExhibitMarking); the route declares response_model=, the AUTH_NOT_FOUND_CONFLICT_VALIDATION_SERVER_RESPONSES error map (it can 409), and inherits its router tag. The app frontend’s codegen-sync + the inline-renumber UI consume this in their own follow-up PRs.
How it was proven
- System-boundary integration tests (
tests/integration/test_app_registers.py) — every assertion is a real request through the app router against the Docker test DB:- Renumber writes human + audit (b): a marking seeded
source='agent'on a child annexure (filing_label “Annexure A”) is PATCHed C-1→C-7; the response showssource=human, the recording user, and the enrichedfilingLabel/parentDocumentId; and exactly oneops.audit_logrow with actionexhibit_marking.renamedand adiffsnapshotold=C-1, new=C-7. - Two-identity row (c): the read register now returns
filingLabel/parentDocumentIdfor the child-annexure marking, andnullfor a lead document with no parent. - 409 on duplicate (a): renumbering C-1→
c-2(case-insensitive) into a series that already has C-2 returns 409 with codepramaan.exhibit_marking.duplicate_label; a non-colliding renumber of the same row still succeeds. - Privileged still excluded (d): a marking whose document was flipped privileged after marking returns 404 on PATCH — not tenderable, not editable.
- Renumber writes human + audit (b): a marking seeded
- Audit gates: the registry/emit unit tests, the route-coverage classifier (which forced the new route into
EMIT_ROUTES), and the reused-action route tests all pass; the exhibit-marking RLS suite is unaffected (26 passed). - Verify:
ruff checkclean on changed files;make openapiregenerated and the app-api snapshot committed (admin-api unchanged); the broader unit+integration suite shows only pre-existing, unrelated failures (theregistrar_vocab_approvalintra-file isolation flake, confirmed to fail identically on the base commit with these changes stashed).