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.

Built to merged doctrine ADR-128 (the Phase-0 keystone). ADR-128 §3 fixes the ledger columns and the state machine, §4 fixes the idempotency key, §5 fixes the per-effect source snapshot, §8/§10 carry the hard invariants, and §11 reconciles the spec's prose to the live schema. This Story implements exactly that. It is the migration base for Track-A: Stories 1540 (tool executor) and 1541 (approval) stack on top of this single Alembic head.

🛰️ 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.

✈️ 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:

🔬 Microscopic: the boundary lines that matter

Detail (titled by its reason)What & where
So the custody record outlives erasuredocument_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 transactionclaim_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 staterecord_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 humanTransitions 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 replayableThe §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 fixtureEvery 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).
Reuse, not rebuild (ADR-128 §11). No parallel substrate is invented. Idempotency extends the existing 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.
Observability (pramaan-docs ADR-124). The ledger writes are small DB writes that ride the caller's broker/handler span on the ADOT T1 auto floor — no registry-approved manual span name covers an effect-ledger write, so no hand-rolled span is added (that would be a PR reject). Each write records the active 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.