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?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.
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.
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
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.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=…)
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
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"}}
tests/test_registrar_revert_capability.py (offline, no model call) covers the three required acceptance cases plus wiring:
| What | Test | |
|---|---|---|
| (a) clean full-run revert — every annexure restored, every Blake-inserted row deleted, 0 conflicts | test_clean_full_run_revert_restores_and_deletes_everything | pass |
| (b) human edit after the run preserved + reported, not stomped (DL-14) | test_annexure_changed_after_run_is_skipped_and_reported_not_stomped | pass |
| (c) a Blake-inserted row a human later admitted is NOT deleted — refused + surfaced | test_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_closed | pass |
# agents/registrar/ pytest tests/ -q → 237 passed (11 new revert tests) ruff check . → All checks passed