Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 undo reverses 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:

  1. 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). So undo must not be “apply the logical inverse from the audit log.”
  2. 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 (not pending, rejected, or already undone).
  • The created entity must still exist and not have been re-mutated after the accept (a non-empty x-ai-history or 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 from undo_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: undo is 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.