Story 1899: the door no one could open — the create-exhibit-marking write

Why this exists (the catastrophe it prevents). The exhibit-marking junction has been built up over three Stories: the table with all its “never” rules (Story-1878, mig 0142), the register reads (Story-1882), and the renumber edit (Story-1891). And yet the data UAT (#1890) could not run, because of a gap nobody had filled: there was no code path anywhere that inserts a marking row — only test fixtures did. A document could be read, its (empty) register listed, an (imaginary) marking renumbered — but it could never become a marked exhibit in the first place. The register was a door welded shut: every firm got [] forever. This is the canonical “done at the component boundary” trap — reads and edits had green tests against seeded rows, but the first real run (an empty firm marking its first exhibit) had no code to call.

This Story closes the loop with the missing CREATE, reached two ways: counsel marks a document by hand (the POST endpoint), and Blake (the Record Registrar agent, #1883) proposes a marking through the broker. Both land a real document_exhibit_marking row through one service method, so the same DB gates protect both: a privileged document can never be marked (waiver protection), an agent can never assert admission/rejection (a tribunal act), and a duplicate exhibit number in a party series is refused.

The analogy to hold the whole way down: the court clerk can finally accept a document for marking, not just read back the register or fix a number. Counsel hands over a document and says “mark this C-1 for the claimant”; the automated stamp (Blake) can propose a marking too. But the clerk refuses three things, always: a document stamped privileged / work-product is handed straight back (it may never be tendered); the automated stamp may record a marking but may never declare it admitted or rejected (only the tribunal does that); and no two exhibits in one party’s series may carry the same number.

🛰 Satellite view: one write, two doors

DoorSurfaceEffect
Counsel (human)POST /app/matters/{matter_id}/proceedings/{proceeding_id}/exhibit-register201 with the created DocumentExhibitMarkingView. source='human', recorded_by_user_id from the JWT. matters:write gated.
Blake (agent)broker tool intent propose_exhibit_marking → execute_tool_intentPersists the marking with source='agent' + agent_run_id, always lifecycle marked. The marking row is the proposal artifact.

No migration. The junction, its DB trigger, the human-only CHECK, and the party-series uniqueness constraint already exist (mig 0142); this Story is one shared service create method, one route, one request DTO, one registered broker tool + its executor branch, and one audit-vocabulary action (exhibit_marking.created, reusing the existing created verb). The route nests as a sibling of the read register (a create on the collection it lists); the broker tool routes by name to its own executor branch.

✈️ Airplane view: the gates the write must respect

InvariantMechanismWhat it prevents
Privileged document is never markable (DR-2)App pre-check on document.privilege_flag → typed 422; the DB BEFORE INSERT trigger (document_exhibit_marking_not_privileged) is the authoritative backstop, mapped to the same 422 if a concurrent flip slips the pre-check.Waiving privilege by tendering a work-product document as an exhibit — and the opaque trigger-500 a lawyer cannot decode.
Admission/rejection is human-only (DR-1)The service refuses source='agent' with any lifecycle but marked (422); the DB document_exhibit_marking_admission_human_only CHECK is the backstop.An agent asserting a tribunal act — recording an exhibit as admitted/rejected, which only the tribunal may decide.
Unique exhibit number per party seriesThe DB uq_document_exhibit_marking_series unique constraint on (firm, matter, proceeding, party-role-canonical, normalized-label); the IntegrityError is mapped to a typed PramaanConflictError → 409.Two exhibits sharing one number in a series — a corrupt record — and a raw 500.
Firm + matter isolationFirm from the RLS session context (never the request); the document resolved by the composite (firm, matter, id) key → 404 on a wrong-matter/foreign document before any insert. Firm RLS WITH CHECK + the trigger’s document↔(firm,matter) consistency are the floor.Marking a document the caller cannot see, or pinning a marking to the wrong matter.
Every create is on the recordemit(action="exhibit_marking.created", …) — a summary snapshot (label, party series, source) scoped to (matter, proceeding), uniformly for both paths; the agent path also carries source/agent_run_id in the audit metadata.A marking appearing with no trail of who tendered it (counsel or agent) or when.

🔬 Microscopic view: the decisions that matter

1. One shared create path, two callers

The human endpoint and the broker handler both reach DocumentService.create_exhibit_marking, so every gate above is enforced once, in one place — the root-cause fix, not a guard duplicated per door. The only differences are the provenance the caller stamps and how the matter is resolved:

  # human: resolve the firm-local matter NUMBER -> UUID (RLS-bound), source='human'
  mark_document_as_exhibit(matter_id=1, ...)  ->  _matter_for_number -> create_exhibit_marking(source='human', recorded_by_user_id=)

  # agent: the broker already holds the matter UUID, source='agent'
  execute_tool_intent(propose_exhibit_marking) -> create_exhibit_marking(source='agent', agent_run_id=)

A new marking is always lifecycle_status='marked' — as-tendered. Admission is a separate tribunal act and is out of this contract entirely; the human request DTO has no lifecycle field, and the agent path is refused anything but marked.

2. Why the broker tool gets its own executor branch

Every other Blake tool records a ledger-only effect keyed on the (document_id, extraction_run_id) registration boundary. An exhibit proposal has no extraction run — and its idempotency key is the party-series uniqueness constraint, not the registration boundary. More fundamentally, a document either is or isn’t tendered as an exhibit, so the proposal is a real document_exhibit_marking row, not a bare suggestion. So propose_exhibit_marking is registered on the leash (a human PR to registry.py + a registrar.yaml line, ship-policy suggest, never auto) but routed by name to a dedicated branch before the generic effect-producing gates — mirroring how the gated-vocabulary path is its own branch that writes a business artifact.

  if intent.tool_name == EXHIBIT_MARKING_TOOL:        # routed before extraction_run_id / V-CITE / idempotency
      return await _propose_exhibit_marking(...)       # -> DocumentService.create_exhibit_marking(source='agent')

3. The error mapping is driver-agnostic

The DB gates are authoritative (a service convention alone is insufficient for an evidentiary table), so a violation that reaches the flush must become a typed 4xx, never a 500. The constraint name and SQLSTATE live in different places per driver — psycopg3 exposes orig.diag.constraint_name; asyncpg (the runtime async driver) exposes constraint_name/sqlstate on the underlying error, which SQLAlchemy wraps onto orig.__cause__. The mapper walks both, so the same code maps the unique-violation to a 409 and the trigger/CHECK violations to 422 regardless of which driver raised it.

Belt and braces, deliberately. Privilege and agent-admission are pre-checked in the app for a clean typed error and caught from the DB as the backstop — the trigger/CHECK win even on a concurrent privilege flip. The duplicate-label case has no pre-check; the unique constraint is the single guard and its IntegrityError is mapped to the 409.

4. A new audit action, not a schema migration

The marking subject (exhibit_marking) and the created verb already exist in the closed audit vocabulary, so exhibit_marking.created resolves with no registry change at all; only the new POST route is added to the emit-coverage classifier so the “every mutating route is audited” gate stays green. The broker route was already classified; its exhibit intents now emit exhibit_marking.created (the marking is the audited business row), and the route stays covered.

How it was proven

One-sentence recall: a document can finally become a marked exhibit — counsel by hand or Blake by proposal — through one shared create path where the DB’s privilege trigger, human-only admission CHECK, and party-series uniqueness are each enforced and surfaced as a clean 4xx, and the new marking shows up in the register read that was empty before.