ADR-039: syn undo reversal semantics
Date: 2026-06-21 Status: Accepted Author: Rômulo Febasi
Scopes SYN-287 (#275). Companion to ADR-026 (audit log), ADR-027/031 (supersession), ADR-030 (git as integrity substrate), and especially ADR-036 (audit-log replay-to-store is unsound). Defines how
syn undoreverses a governed accept.
Context
The product promises reversibility (“undo any of it”, “reversible against git”),
but there is no syn undo command. Today reversal is manual: git revert
the workspace, or syn pending reject (which only works before acceptance).
After a proposal is accepted, undoing the specific change is a hand-rolled git
operation. That is a real gap between the narrative and the binary.
Two hard constraints shape any design:
- ADR-036: replaying the hash-only audit log to reconstruct store state is
unsound (the log stores
payload_sha256, not payloads; the store is hand-editable Markdown; there is no journaled wall-clock). Soundomust not be “apply the logical inverse from the audit log.” - The prior value is not recorded. The only per-decision snapshot is
pending.context_snapshot, which captures the LLM’s view at proposal time (what it read), not a pre-image of the files the apply touched. So for a decision that mutated an existing fact, the prior value lives only in git history: nowhere else.
Decision
syn undo <decision-id> reverses a governed accept by the soundest mechanism
available for that decision’s kind, in phases.
Phase 1 (this iteration): structurally-invertible creates. Undo a
capture_fact that created a new built-in entity by removing that entity
(file + index, atomically, via the same remove_atomic path as syn rm). The
inverse is computed deterministically from the recorded payload
(type + slugify(title)): no prior state and no git commit are required, so
it works on a just-accepted, uncommitted write. This is the dominant AI write
(a new fact), so Phase 1 closes the “no undo command exists” gap for the common
case.
Guards (Phase 1 refuses rather than clobber):
- The decision row must be
accepted/auto-accepted(notpending,rejected, or alreadyundone). - The created entity must still exist and not have been re-mutated after the
accept (a non-empty
x-ai-historyor a recorded supersession means a later change: undo refuses, pointing the user at git/manual reversal). This is the conflict guard: undo never overwrites work done after the decision. - The created type must be built-in (custom-typed and supersession-bearing creates are Phase 2).
undo is CLI-only by design, not an MCP tool. Reversing an accepted
decision is a human corrective action; exposing it to the agent would let the
agent erase its own accepted history, inverting the gate (the agent proposes,
the human disposes: ADR-022). The port-parity manifest (SYN-264) records this
as a deliberate single-door capability, not a gap.
Every undo is itself a governed, recorded event: it runs in one
Workspace::tx (atomic: a crash leaves consistent state, BR-6), reindexes the
touched dirs, flips the decision row to undone, and appends an undone
audit entry. History is append-only: undo never deletes the original decision
record (ADR-026).
Phase 2 (SYN-292): state-mutating kinds, shipped for link + status. Chose
option (b) pre-image capture over git-restore: it works on uncommitted writes
(the common case) and is sound (stored data, not log-replay). Migration 0008 adds
pending.undo_image; accept captures the pre-image before mutating.
link_entity→ delete the edge (payload-derived; no pre-image needed).update_status→ restore the prior status fromundo_image; refuses if the status changed again after the decision (conflict guard). Still refuse (revert via git): supersessions (reopening F1), custom-typed creates, and batches: their pre-image/interval logic is a later increment. No audit-log replay anywhere (ADR-036).
Consequences
- Improves: the reversibility promise becomes a real command for the common
case; the #1 code-vs-pitch gap is closed honestly; undo is atomic, audited,
and conflict-guarded; nothing in the binary’s integrity model changes (it
reuses
remove_atomic+ reindex). - Worsens / limits: Phase 1 undoes only built-in creates; mutations and links refuse (with guidance) until Phase 2. The command’s help text states exactly what is reversible now, so the limit is visible, not silent.
- Honesty:
undois not advertised as universal until Phase 2 lands; the README/whitepaper claims stay scoped to what ships.
Alternatives considered
- Audit-log replay to reconstruct prior state. Rejected by ADR-036: unsound for a state-based, hash-only-logged, hand-editable store.
- Reconstruct prior state from
context_snapshot. Rejected: that snapshot is the LLM’s read-view, not a pre-image of the touched files. - Ship a universal git-based undo now (all kinds). Deferred to Phase 2: it requires the decision to be committed (a just-accepted write usually isn’t), so as a sole mechanism it would refuse the most common “I just accepted this, undo it” case: a poor first cut. Phase 1’s payload-derived create-undo works uncommitted and covers that case; git-restore is the right tool for the mutation cases Phase 2 adds.