Story 1542: The Notary's Register (the effect ledger — registrar guardrails)
Why this exists (the catastrophe it prevents). Blake, the Record Registrar agent, is about to be let off ADR-070's read-only leash: he will rename, summarise, type, tag, and link the documents in a matter Record. The moment an agent mutates evidence, one question becomes existential: can we prove what it did, undo it, and show it was the agent — not a lawyer — that did it? If "Blake tagged this contract privileged" is just a value that appeared on a row, with no record of the page he read, the model that read it, or who could reverse it, the Record stops being evidence. Worse, if a firm user's session could write an agent-attributed effect, the chain of custody is a forgery waiting to happen. This Story builds the substrate that makes agent mutation safe before the tools (Story-1540) or the approval surface (Story-1541) exist: an append-only effect ledger, a state machine for undo/review, idempotency that survives re-extraction, and attribution that no JWT can counterfeit.
The one analogy: the notary's register
A notary does not scribble changes onto the deed. Every act goes into a bound register, one numbered line per act: what was changed (before → after), on which page of which version of the document, how sure the notary was, under which rulebook, and the notary's own seal — never a client's. A line is never torn out; a reversed act gets a new status in the margin ("revoked", "confirmed by counsel"), but the original line stays. And the notary cannot register the same act twice for the same reading of the same document — the register already holds it. agents.agent_effect is that bound register; this Story builds the book, its sealing rules, and the no-double-entry latch.
🛰️ Satellite: what shipped
One migration (the new single head 0118), one Core table, one service module, and the proof tests. No routes, no DTO contract changes (those belong to 1540/1541), so no OpenAPI mirror moves.
agents.agent_effect— the append-only ledger. Firm+matter RLS; the broker role maySELECT/INSERT/UPDATEbut neverDELETE(custody). Columns per ADR-128 §3 + the §5 source snapshot.- The effect state machine —
proposed → auto_applied → counsel_accepted | rejected | undoneandprovisional_type → promoted_to_firm_catalog | merged_into_existing— enforced by an atomic conditionalUPDATE, the same race-safe idiom the HITL approval service already uses. - Idempotency keyed on
(document_id, extraction_run_id), composed into the existingagents.agent_idempotency_keyclaim table as a newregistrationboundary — not a parallel store. - Attribution that is structural:
actoris DB-constrained to the registrar set, and every effect rides a broker-mintedrun_id(FK toagent_run_key). State transitions mirror intodocuments.document_status_eventswithactor_type='agent'.
✈️ Airplane: how one registration resolves
claim = INSERT agent_idempotency_key (boundary='registration',
input_sha256 = sha256("{document_id}:{extraction_run_id}"))
ON CONFLICT (firm_id, input_sha256) WHERE boundary='registration' DO NOTHING
if claim returned no row -> already registered for THIS substrate -> 0 new effects, stop
else for each broker-decided effect:
INSERT agent_effect (actor='blake', state='proposed'|'auto_applied',
before/after, confidence, page_refs, source_snapshot,
trace_id = active span, run_id = minted run) <- FK to agent_run_key
mirror -> documents.document_status_events (actor_type='agent', to_status=state)
later, a human decision:
UPDATE agent_effect SET state='counsel_accepted', resolved_by=:user
WHERE effect_id=:id AND firm_id=:firm AND state IN (allowed predecessors)
0 rows updated -> raise PramaanConflictError (illegal transition)
Four turns make this correct:
- The claim is run-independent on purpose. The unique index is
(firm_id, input_sha256), andinput_sha256hashes only(document_id, extraction_run_id)— notrun_id. So a retry under a different agent run collides and writes nothing, while a fresh extraction run (re-OCR) hashes differently and legitimately re-registers. Baredocument_idwould have permanently blocked valid re-extraction (ADR-128 §4). - The state machine lives in the
WHERE, not in a read-modify-write. The transition only lands if the row is currently in an allowed predecessor state, checked inside theUPDATEpredicate, so two concurrent transitions cannot both win. Zero rows updated is the signal for "illegal from here" — we then read the current state to tell not found from bad transition. - Attribution cannot be forged.
actoris a DBCHECKagainst the registrar set, so an effect can never name a firm user; and the composite FK(firm_id, run_id) → agent_run_keymeans an effect must ride a broker-minted run identity — a thing no firm-user JWT can mint (ADR-119). Forgery fails at the database, not at a code review. - The line is never torn out. The app/broker role is
GRANTedSELECT, INSERT, UPDATEand explicitlyREVOKEdDELETE. Undo is a state (undone), not a deletion — the original act stays in the register, and the immutable transition history lives indocument_status_events.
🔬 Microscopic: the boundary lines that matter
| Detail (titled by its reason) | What & where |
|---|---|
| So the custody record outlives erasure | document_id carries no FK — deliberately, mirroring ops.audit_log — so erasing a document never silently deletes the proof of what was done to it. (Migration 0118, with the reasoning in the table comment.) |
| So a lost idempotency race never poisons the transaction | claim_registration uses INSERT ... ON CONFLICT DO NOTHING ... RETURNING; an empty result means "already claimed", with no exception to roll the open transaction back (src/shared/agent_effect_ledger.py). |
| So an effect can only be born in a real starting state | record_effect rejects any state not in {proposed, auto_applied, provisional_type}; counsel_accepted/undone are reachable only by transition. The 8 valid values are a DB CHECK; the valid transitions are the service map, proven by test_state_machine_matches_adr_128. |
| So a human reversal always names the human | Transitions into counsel_accepted/rejected/undone require resolved_by; a paired CHECK keeps resolved_at and resolved_by set-together. Proven by test_effect_state_machine_transitions. |
| So the suggestion is replayable | The §5 source snapshot is frozen per effect: document_version, content_sha256, extraction_run_id, page_refs/span_refs, plus the markdown-artifact / catalog / registrar-policy hashes and model_id/prompt_version/schema_version in source_snapshot. |
| So the empty first run is real, not a fixture | Every integration assertion provisions a fresh firm and runs as pramaan_app through the real RLS, including firm-isolation and the forged-attribution rejection (tests/integration/test_agent_effect_rls.py). |
agent_idempotency_key claim table with one boundary value; the per-document agent timeline reuses document_status_events's existing actor_type='agent' axis and hex trace_id. The Story adds a ledger and a service, nothing that already exists is duplicated.trace_id and logs low-cardinality product IDs only (effect_id, tool_name, state, document_id, batch_id); no raw document text enters telemetry and firm_id never enters baggage. Instrumentation-exempt by the auto tier, not by omission.One-sentence recall
The effect ledger is the notary's register — an append-only, firm-scoped, agent-sealed line per mutation with its source page frozen in, advanced (never erased) through a state machine, and latched against double-entry by a re-extraction-aware idempotency key — so Blake can change the Record and we can always prove, undo, and attribute exactly what he changed.