Story-1719 Recovery (b/2) — fail-honest · undo-as-write · never-fake-success
RFD-AGT-002 D-10. This is the brain-side half of the failure-&-recovery contract (the agents sibling of the functions Recovery a/2). The broker owns the control plane — it mints the idempotency key, re-GETs row state at confirm time, executes a write exactly once, and reports back one typed outcome (ADR-110: the broker owns all safety machinery exactly once). The brain's only job after a write is to turn that outcome into honest counsel-facing copy and, on request, propose an undo. All of it is pure, offline, no model call.
The outcome vocabulary (recovery.WriteOutcome)
| Outcome | Meaning | Recovery action |
|---|---|---|
applied | Succeeded and acked | confirm_applied (offer undo) |
failed | Definitively did not apply — zero rows changed | report_failed |
ambiguous | Applied-but-unacked ("maybe-applied") | surface_ambiguous (a GET resolves it) |
ambiguous is a first-class outcome, deliberately not folded into
failed — treating a maybe-applied write as failed is exactly the silent-retry /
duplicate-filing trap D-10 exists to prevent.
AC1 — On rail/tool failure: honest message, zero fabricated rows
render_outcome(FAILED)→ "I couldn't … nothing was changed." withapplied = False.- Structural fail-honest:
RecoveryMessagehas no row-bearing field (rows/data/results…), so the brain cannot paint generated rows. The page refetches real authorized rows (RFD §6). - Keystone invariant: a model-validator rejects
applied = Truefor any non-APPLIEDoutcome — a write can never render as success unless the broker said so.
AC2 — Undo runs as a fresh idempotent write that revalidates current state
build_undo(result)→ a fresh confirm-firstWriteProposalthat reversesresult.operation_id.revalidate_before_applyis forcedTrue— undo is never a free rollback; it revalidates current state before applying.- No client-minted idempotency key.
WriteProposalisextra="forbid", so a strayidempotency_keyis rejected at the type boundary — the broker mints a fresh one (D-10). - The reversing op is itself a v1 reversible write (
create⇒archive,archive⇒edit,edit⇒edit) — never a destructive op. - You cannot undo a
FAILED(nothing to reverse) orAMBIGUOUS(resolve with a GET first) write —build_undoraisesRecoveryError.
AC3 — Ambiguous outcome surfaced ("may have applied — here's how to check")
render_outcome(AMBIGUOUS)→ "… may have applied, but I didn't get confirmation — here's how to check: <hint>." withapplied = False.- The broker's
check_hintis used when supplied; otherwise a generic, always actionable hint (never silent). silent_retry_forbidden(AMBIGUOUS)isTrue— the brain never emits a re-write for a maybe-applied outcome; a GET resolves it (sibling Recovery a/2).
Files
agents/atlas/recovery.py—WriteOutcome,OperationResult,RecoveryMessage+render_outcome(),WriteProposal+build_undo(),plan_recovery(),silent_retry_forbidden().agents/atlas/tests/test_recovery.py— 18 deterministic, model-free tests pinning the 3 ACs.
Scope note: this delivers and pins the behaviour contract; wiring it into the live
operation turn grows with the forums slice (D-11) — exactly as Story-1722 delivered the command
vocabulary ahead of full wiring. WriteProposal is the propose_operation
wire intent named (not yet modelled) in command_vocabulary.IntentClass.
Verification
ruff check agents/→ all checks passedcd agents/atlas && pytest tests/test_recovery.py -q→ 18 passed