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.

The analogy to hold the whole way down: the court clerk now holds a pen, not just the register. Counsel can ask the clerk to change a number — but the clerk refuses two rules. The clerk will not write a number that already belongs to another exhibit in the same party’s series (collision → 409). And only a human clerk may make the change — the automated stamp (an agent) can record a marking, never renumber one — and the clerk writes the change into the margin log every time (the audit row), with the old number crossed out and the new one beside it.

🛰 Satellite view: one write, two enriched read fields

ChangeSurfaceEffect
Renumber writePATCH /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 rowDocumentExhibitMarkingViewNow 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

InvariantMechanismWhat it prevents
Human-only authorityRoute 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 collisionA 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 editableThe 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 recordemit(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
Deliberate simplification (marked in code). This is a pre-check, not an 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

One-sentence recall: counsel can now renumber an exhibit through one human-only, collision-checked, audited PATCH, and the marking view carries the annexure label beside the exhibit number — so the register row a lawyer reads is editable, unique within its party series, and on the record every time it changes.