Blake learns to take it all back — safely

Story-1883 taught Blake to propose a whole matter's labels in one labeling run; every proposal the broker wrote carries the run's agent_run_id. Story-1884 is the undo button: counsel clicks "revert this labeling run" and Blake un-does exactly what that run did — and nothing a human has touched since. The whole job is one deterministic question asked per field: is the run's value still the value sitting in the database?
Story-1884 · EPIC-1876 · RFD-DOC-003 D7 · DL-14 · DR-4 · builds on #1883 · pramaan-docs ADR-128 (truth boundary) · ADR-124 (manual spans) · repo pramaan-agents · branch issue-1884-blake-run-revert

A labeling run wrote two kinds of thing through the broker: an annexure label as a field on documents.filing_label, and an exhibit-marking row Blake inserted into documents.document_exhibit_marking. Reverting is not "delete everything the run id touched" — between the run and the click, a tribunal may have admitted a marking, or counsel may have relabelled an annexure. Those are human-recorded facts. The revert must restore the run's work without stomping theirs.

The naive fix that detonates: "delete every row and reset every field stamped with this agent_run_id." That destroys evidence — a marking a judge admitted gets silently deleted; an annexure counsel corrected gets reset to the model's guess. The shape that holds (DL-14): a per-field rule — act iff current == run.new — and an absolute refusal to delete a marking row a human has since edited. What the revert can't safely undo, it reports.

1 · DR-4 — a labeling revert is its own contract, not the extraction boundary

There is already an undo path for the classify/extraction batch (undo_agent_effect, keyed on the ledger's extraction_run_id + document_version). A labeling pass is not an extraction run — it has no document version and writes a different kind of effect. So Story-1884 defines the labeling-effect contract explicitly in models.py, keyed by agent_run_id: the run's marking-change trail the broker hands Blake — every field it set, with the current DB value beside it.

# models.py — the per-field revert unit (DL-14)
class RevertFieldState:
    field: str
    run_new: str | None    # what THIS run wrote
    current: str | None    # the live DB value (broker read-context)
    run_old: str | None    # the value BEFORE the run — restored on a field UPDATE
Why the brain never reads the DB: Blake holds no DB credentials (ADR-128). The broker supplies current as read-context; Blake folds it into a typed revert proposal; the broker executes the reversal under firm/matter RLS + audit. The brain stays config + soul.

2 · The one rule, applied per field (DL-14)

A field is cleanly revertable iff current == run.new — the run's value is still in place, so no human has touched it. If they differ, a human (or a later run) changed it: leave it, and surface it in the conflict report. The annexure label reverts by exactly this rule — restore run_old when clean, else conflict.

# policy.intents_for_revert — annexure field UPDATE
if state.current == state.run_new:        # clean → restore the prior value
    intent("revert_annexure_label", restore_value=state.run_old, …)
else:                                     # changed after the run → DL-14
    conflict(target="annexure", reason="changed_after_run", run_new=…, current=…)

3 · A Blake-inserted marking row — delete only if untouched, else REFUSE

A marking row was inserted by the run, so its clean revert is a delete. But the row carries fields a human acts on: lifecycle_status (a tribunal sets admitted), the label, and marked_at. The same per-field rule decides the whole row: delete it iff every field is still the agent's write. If any field deviates, the delete is refused and the row surfaced — a blind delete would erase a human-recorded evidentiary fact.

# policy.intents_for_revert — inserted marking ROW
if not mk.row_present:        # already gone → idempotent re-run, not a conflict
    continue
dirty = [f for f in mk.fields if f.current != f.run_new]
if dirty:                     # a human admitted / relabelled / re-timestamped it
    for f in dirty: conflict("marking", reason="human_edited_after_insert", …)
else:
    intent("revert_exhibit_marking", marking_id=…, agent_run_id=…)   # safe delete
Why a whole-row refusal, not a field-by-field unset: the row is one evidentiary object. Once a judge has admitted it, the right move is to hand it back to counsel — not to half-revert it. The conflict report names every deviating field so counsel sees exactly why it was kept.

4 · Deterministic, no model call — and gated, never auto

Like summarize / undo_batch, revert is a pure fold over broker-supplied input — no model call, zero tokens (agent.revert_label_run under a registrar.revert manual span; runtime.py routes the revert mode through the deterministic handler, emitting honest agent_event_stream.v1 frames). The two new tools — revert_annexure_label / revert_exhibit_marking — are group-D gated and LOCKED: a revert is counsel-initiated and can never ship auto. Each intent is keyed to the run with an idempotency key so re-clicking revert is a no-op.

# the run-keyed, idempotent revert intent the broker executes
{"tool": "revert_exhibit_marking", "policy": "gated",
 "args": {"marking_id":…, "agent_run_id": "run-1883",
          "idempotency_key": "firm-1:revert:run-1883:marking:mk-1"}}

5 · Proof — the repo test gate

tests/test_registrar_revert_capability.py (offline, no model call) covers the three required acceptance cases plus wiring:

WhatTest
(a) clean full-run revert — every annexure restored, every Blake-inserted row deleted, 0 conflictstest_clean_full_run_revert_restores_and_deletes_everythingpass
(b) human edit after the run preserved + reported, not stomped (DL-14)test_annexure_changed_after_run_is_skipped_and_reported_not_stompedpass
(c) a Blake-inserted row a human later admitted is NOT deleted — refused + surfacedtest_human_admitted_marking_row_is_refused_for_delete_and_surfaced, …_relabelled_or_retimestamped_…pass
per-row partial revert; idempotent already-gone row; gated+locked tools; run-keyed source_refs; runtime revert mode frames; fail-closed parse…_clean_row_alongside_dirty_row…, …_already_deleted_row_is_idempotent_skip…, …_revert_tools_registered_and_gated, …_runtime_revert_mode_emits_honest_frames, …_malformed_input_fails_closedpass
# agents/registrar/
pytest tests/ -q   → 237 passed   (11 new revert tests)
ruff check .       → All checks passed
What this Story deliberately does NOT do: the broker-side handlers that perform the actual DB delete / field restore (pramaan-functions), and the UI "revert this run" surface. Blake's job is to propose the reversal honestly — restore what is still his, refuse what is now a human's, and report the difference — and let the broker execute under RLS + audit.