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.
🛰 Satellite view: one write, two doors
| Door | Surface | Effect |
|---|---|---|
| Counsel (human) | POST /app/matters/{matter_id}/proceedings/{proceeding_id}/exhibit-register | 201 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_intent | Persists 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
| Invariant | Mechanism | What 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 series | The 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 isolation | Firm 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 record | emit(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.
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
- System-boundary integration tests (
tests/integration/test_app_exhibit_marking_create.py) — the happy paths run against a freshly seeded firm (the empty first-run state the welded-shut door hid), and each ends by reading the marking back out of the Exhibit Register:- Human create:
POST→ 201,source=human, recording user, JOINed display fields; the marking then appears in the register read; exactly oneexhibit_marking.createdaudit row. - Agent create via broker: a real
execute_tool_intent(propose_exhibit_marking)→proposal_recorded; the register read showssource=agentwith theagentRunIdset and no recording user. - Privileged rejected: human
POST→ 422pramaan.exhibit_marking.privileged_document(not a 500); the agent path raises the same typed error; nothing is persisted. - Admitted-by-agent rejected: an agent intent carrying
lifecycle_status='admitted'raisespramaan.exhibit_marking.admission_human_only; the register stays empty. - Duplicate rejected: a second marking with a case-insensitively colliding label in the same series → 409
pramaan.exhibit_marking.duplicate_labelon both the human and agent paths.
- Human create:
- Regression: the registrar executor + policy suites (the new tool is coherent with the registry and never
auto), the existing register reads/renumber, the exhibit-marking RLS + migration suites, and the audit route-coverage classifier all pass — 1358 tests green. - Verify:
ruff checkclean;make openapiregenerated andopenapi/app-api.jsoncommitted (operationappMarkDocumentAsExhibit; admin-api untouched).