Undo History
Table of contents
An application: every Personal Todo action is reversible. Composes Personal Todo with Event Log to give the user a familiar Cmd+Z experience without modifying either constituent atom.
Intent
The user expects a familiar undo capability — the ability to take back the last thing they did. Personal Todo on its own does not provide this: each action is committed and there is no native “undo” surface. Event Log on its own provides a faithful record of what happened but does not act on it.
This application composes the two. Every action the user takes against Personal Todo is recorded in an Event Log instance owned by the application. An additional undo action consults the log, identifies the most recent forward action not already undone, and adjusts the application’s derived Personal Todo state to be equivalent to the state had that action never occurred.
The application is event-sourced (state is derived by replaying a log of recorded events rather than storing the current state directly). Personal Todo state at any moment is defined as the result of replaying the log’s non-undone events from the beginning. Forward actions append events; undo appends compensating events; replay produces the current state. Personal Todo’s atom spec is unchanged. Event Log’s atom spec is unchanged. The application is the wiring.
Summary
Undo History is a two-atom composition (a spec that wires two or more atoms together — here, Personal Todo and Event Log — into a single coherent application) that gives a single-user task list the ability to reverse any previous action with a familiar Cmd+Z experience. Personal Todo is an atom (a freestanding pattern spec that does not name any other pattern) governing a single user’s task lifecycle — adding, editing, completing, and deleting tasks. Event Log is an atom that records every action as an append-only (records can be added but never changed or deleted), immutable (unchangeable once written) sequence. On their own, neither atom provides undo: Personal Todo commits each action and offers no reversal surface; Event Log records faithfully but takes no action on what it records. Undo History wires them by making the application event-sourced — Personal Todo’s state at any moment is not stored separately but defined as the result of replaying the Event Log’s non-undone events from the beginning. Every forward action appends an event to the log; an undo call appends a compensating event that marks the most recent forward event as skipped; replay over the adjusted log produces the state the user expects to see.
Beyond what either constituent atom guarantees individually, Undo History produces two emergent guarantees. First, every successful action is recorded exactly once — a structural log-faithfulness guarantee that neither atom alone could enforce. Second, and more architecturally significant, identity preservation across delete and undo: when a user deletes a task and then undoes the deletion, the task is restored with its original system-generated identifier, its original timestamps, and its prior description intact. Personal Todo’s native delete is terminal — a subsequent add would produce a new identifier with reset timestamps. The composition with Event Log makes identity preservation an emergent invariant (a property that only appears when atoms are combined — no single atom carries it), falling out of the replay mechanism rather than being designed in as a special case.
The most common uses are personal productivity applications where users expect Cmd+Z to work on task edits, completions, and deletions; any single-actor task surface where the data model requires a recoverable audit trail as a side effect; and as a building block for more complex compositions where adding tamper-evident audit logging or multi-actor support are the next layer.
Composes
- Personal Todo — provides the substrate state machine, transition rules, and all its invariants. The application maintains a Personal Todo–shaped state derived from the Event Log.
- Event Log — provides the durable, append-only record of every action. The application owns one Event Log instance for each Personal Todo it operates on.
Composition logic
Event schemas
The application writes five event types into its Event Log instance:
{type: "add", event_id, recorded_at, id, description}
{type: "edit", event_id, recorded_at, id, prior_description, new_description}
{type: "complete", event_id, recorded_at, id}
{type: "delete", event_id, recorded_at, id, snapshot}
{type: "undo", event_id, recorded_at, undone_event_id, undone_event_type}
event_id and recorded_at are supplied by Event Log’s append (per its event-immutability invariant). id is the Personal Todo unit identifier (per Personal Todo’s identity model). snapshot for delete events captures the unit’s full state — description, current state (Pending or Done), and all defined timestamps — sufficient for replay to reconstruct the unit faithfully.
Action wiring
The application replaces Personal Todo’s direct API surface. Users call the application’s actions; the application updates both the Event Log and the derived state.
add(description) → id | rejected(invalid-description | duplicate-active | storage-failure)— Validate against Personal Todo’saddprecondition (description policy + active-set uniqueness against the current derived state); reject withinvalid-descriptionorduplicate-activeif the precondition fails. On success, generate a newid, append{type: "add", id, description}to the Event Log. If the append returnsrejected(storage-failure), returnstorage-failureto the caller without updating the derived state — the action did not happen. On successful append, update the derived state, returnid.edit(id, newDescription) → ok | rejected(not-known | not-pending | not-editable | invalid-description | duplicate-active | storage-failure)— Validate against Personal Todo’seditprecondition; reject withnot-known,not-pending,not-editable,invalid-description, orduplicate-activeif the precondition fails. On success, capture the unit’s current description asprior_description, append{type: "edit", id, prior_description, new_description}. If the append returnsrejected(storage-failure), returnstorage-failurewithout updating the derived state. On successful append, update the derived state, returnok.complete(id) → ok | rejected(not-known | not-pending | storage-failure)— Validate against Personal Todo’scompleteprecondition; reject withnot-knownornot-pendingif the precondition fails. On success, append{type: "complete", id}. If the append returnsrejected(storage-failure), returnstorage-failurewithout updating the derived state. On successful append, update the derived state, returnok.delete(id) → ok | rejected(not-known | storage-failure)— Validate against Personal Todo’sdeleteprecondition; reject withnot-knownif the precondition fails. On success, capture the unit’s full state assnapshot, append{type: "delete", id, snapshot}. If the append returnsrejected(storage-failure), returnstorage-failurewithout updating the derived state. On successful append, update the derived state, returnok.undo() → undone_event_type | rejected(nothing-to-undo | storage-failure)— Identify the most recent forward event (type ∈ {add, edit, complete, delete}) whoseevent_idis not already in the undone set. If none, reject asnothing-to-undo. Otherwise, append{type: "undo", undone_event_id, undone_event_type}to the Event Log. If the append returnsrejected(storage-failure), returnstorage-failurewithout recomputing the derived state — the undo did not happen. On successful append, recompute the derived state per the Replay semantics section below (replaying the Event Log and skipping events whoseevent_idis now in the undone set), return the type of the undone action.read_history(query) → ordered_sequence_of_events | rejected(invalid-query)— Pass through to Event Log’sread. The user can inspect their history at any time.
Replay semantics
The derived Personal Todo state at any moment is computed as follows:
- Read all events from the Event Log in
sequence_numberorder. - Build the undone set: the set of
undone_event_idvalues from allundoevents. - For each event in order:
- If the event’s type is
undo, skip (already accounted for in the undone set). - If the event’s
event_idis in the undone set, skip. - Otherwise, apply the event to a Personal Todo–shaped state under construction:
add→ introduce a unit at the recordedidin Pending, withadded_at = recorded_atand the recordeddescription.edit→ replace the unit’sdescriptionwithnew_description; setlast_edited_at = recorded_at.complete→ move the unit atidfrom Pending to Done withcompleted_at = recorded_at.delete→ remove the unit atidfrom the state.
- If the event’s type is
Replay assumes events were recorded only on successful actions, which guarantees Personal Todo’s preconditions hold at every replay step.
The load-bearing wiring decision
The decision the composition exists to enforce: identity preservation across delete and undo is achieved through replay of the original event sequence rather than through state restoration from a snapshot.
Principle. When a user undoes a delete, the deleted unit must be restored at its original id, with its original added_at, last_edited_at (if any), and state intact — not as a fresh unit with a new id and reset timestamps. This identity preservation is the property users expect from undo and the property that makes the composition useful as a building block for audit-trail-adjacent applications.
Likely objection. “Couldn’t the delete action save a snapshot and undo restore from it?” Per-action snapshots (the Memento pattern) restore state but produce a new copy of the unit — a fresh add against Personal Todo would issue a new id, resetting timestamps and losing the unit’s historical identity.
Mechanism. The composition makes Personal Todo event-sourced: it never calls Personal Todo’s native delete directly on state and stores no separate state. The Event Log is the source of truth; the derived state is a projection. Undoing a delete appends a compensating event and rereplays the log skipping that event — the original add event is still in the log, so the unit is reconstructed at its original id with its original timestamps. Personal Todo’s own delete is terminal and irreversible; the composition circumvents this by operating at the log level rather than the state level.
Result. Identity preservation across delete/undo (Invariant 6) falls out of the replay mechanism as an emergent property — it is not designed in as a special case. The constituent atoms are unchanged; the composition is entirely in the wiring.
Composition-level invariants
These invariants emerge from the composition. None of them belong to a single constituent atom; each requires both atoms working together to hold.
- Invariant 1 — Log faithfulness. Every successful user action (forward or undo) appends exactly one event to the Event Log. No event appears in the log without a corresponding user action; no user action goes unrecorded.
- Invariant 2 — State equivalence. At any time, the application’s exposed Personal Todo state equals the result of replaying the Event Log’s non-undone events from the beginning under the replay semantics above. The state is not stored separately; it is defined by the log.
- Invariant 3 — Undo targets the most recent forward event. Each
undoevent’sundone_event_idreferences the most recent forward event whoseevent_idwas not already in the undone set at the time the undo was issued. - Invariant 4 — Personal Todo’s invariants are preserved. All invariants from Personal Todo hold over the derived state at every moment. Replay never produces an invalid Personal Todo state, because every recorded forward event was a successful action against a then-valid state.
- Invariant 5 — Event Log’s invariants are preserved. All invariants from Event Log hold. The application never deletes or rewrites events; undo is implemented via compensating appends (new events that logically cancel a prior event, leaving the original record intact), not via mutation.
- Invariant 6 — Identity preservation across delete/undo. Undoing a
deleterestores the unit at its originalidwith its originaladded_at,last_edited_at(if any), and prior state (Pending or Done). The originaladdevent remains in the log; replay skipping thedeletereconstructs the unit faithfully. Personal Todo on its own cannot do this — itsdeleteis terminal and a freshaddproduces a new id. The composition with Event Log buys back identity preservation as an emergent property. - Invariant 7 — Reachability of prior states. From any point in the user’s history, the user can return to any prior application-visible state via a finite sequence of
undocalls — provided no further forward actions intervene. After any forward action following undos, the previously-undone events remain in the log but cannot be reached viaundo(that would require a separate Redo pattern).
Examples
Walkthrough
A user opens a fresh Personal Todo with Undo History:
add("buy milk")→ returnst1. Log:[add(t1)]. State:t1Pending.add("renew passport")→ returnst2. Log:[add(t1), add(t2)]. State:t1,t2Pending.complete(t1)→ok. Log:[add(t1), add(t2), complete(t1)]. State:t1Done,t2Pending.undo()→ returns"complete". Log appendsundo(complete(t1)). Replay skipscomplete(t1). State:t1Pending,t2Pending.delete(t1)→ok. Log appendsdelete(t1, snapshot). State:t2Pending.undo()→ returns"delete". Log appendsundo(delete(t1)). Replay skipsdelete(t1). State:t1Pending,t2Pending —t1is back with its original id, originaladded_at, and Pending state.add("walk dog")→ returnst3. State:t1,t2,t3Pending. The previously-undone events (complete(t1),delete(t1)) remain in the log but are unreachable via furtherundo(that would require Redo).
Identity preservation across delete/undo
A user adds “buy milk” (id m1), completes m1, deletes m1. The Event Log holds the full trail. The user undoes the delete; replay skips delete(m1) and m1 returns to the derived state as Done — same id, same added_at, same completed_at, same description. Identity is preserved across the delete-undo cycle. Personal Todo’s atom on its own cannot do this; its delete is terminal and restoration via add would produce a new id with reset timestamps. The composition with Event Log produces identity preservation as an emergent property of replay.
Audit-as-side-effect
A user later asks “what did I do this week?” The application calls read_history({recorded_at: last_7_days}) and returns the full sequence including undos. Same Event Log instance, no additional atoms required. If the user later wants the history protected for compliance, composing this application’s Event Log with Audit Trail adds attribution, retention, and tamper-evidence without changing the composition above.
Rejection paths
A user starts fresh:
add("buy milk")→t1. State:t1Pending.undo()→ returns"add". Log appendsundo(add(t1)). State: empty.undo()→rejected(nothing-to-undo). The log has one forward event (add(t1)) and one undo event referencing it. Every forward event is already in the undone set; there is nothing left to undo. Log and state are unchanged.
Storage failure path: the user calls add("walk dog") and Event Log’s append returns rejected(storage-failure). The composition returns storage-failure to the caller; the derived state is not updated. No partial state is visible; the action did not happen. The same pattern applies for undo: if the compensating-event append fails, undo returns storage-failure and the derived state is not recomputed.
Edge cases and explicit non-goals
What this application does not cover:
- Redo. Once an action is undone and a new forward action is taken, the undone action cannot be re-applied via this application. Redo requires a Redo Stack pattern that interprets a different class of compensating events. The current application’s
undois one-directional. - Branching history. No support for “go back in time and take a different action.” The log is linear; alternate timelines are out of scope.
- Selective undo.
undoalways targets the most recent non-undone forward event. Undoing a specific earlier action while preserving more recent actions (“undo my edit from twenty minutes ago, keep everything since”) is not supported — it would require event-dependency analysis and is a separate pattern. - Undo of undo. Forward events can be undone; undo events cannot. Reversing an undo is the redo operation, out of scope.
- Persistence across restarts. The application assumes the Event Log is durable across application restarts (a deployment property of the Event Log instance). If the log is volatile, the undo history resets at restart, which most users will not expect.
- Initialization from an existing log. The application assumes its Event Log instance is either fresh (no events) at start, or an existing log whose events represent the prior history of the same Personal Todo. Inheriting an Event Log from a different application or substrate, or merging logs across substrates, is out of scope — that is an Import or Migration pattern.
- Concurrent actors. Single-actor only, inherited from Personal Todo. Multi-actor undo (one user undoes another user’s action) requires composing Shared Todo + Event Log + a Concurrency Resolution pattern.
- Long-history performance. Replay from the beginning of the log is O(n) in log size. For systems with millions of events, compose with a Snapshot pattern (forthcoming) that periodically captures the derived state and lets replay start from the most recent snapshot.
- User expectation of undo scope. The application’s rule — “most recent forward event not already undone” — is unambiguous, but in long sequences with multiple undos the rule may not match user intuition (which often imagines undo as walking back through visible history rather than unredacted history). The mapping between this rule and the surface UX is a presentation-layer concern, not a spec concern.
Where the composition breaks down: when the underlying Event Log cannot guarantee durability or total order; when Personal Todo is replaced with a substrate whose actions are not all reversible by replay (actions with external side effects — sending emails, charging cards — where the side effect is not reversible by skipping the event).
Standards references
This composition draws on:
- Event sourcing (Greg Young, Martin Fowler) — the architectural pattern of deriving state from an append-only log of events. Undo via compensating events is a classical event-sourcing technique.
- Memento pattern (GoF) — the object-oriented antecedent: capture state before each action, restore on undo. Memento is per-object and ephemeral; event-sourcing generalizes it across the whole application and persists it.
- Command pattern (GoF) — actions as first-class objects. Each forward event in the log is essentially a serialized command.
- Vim’s undo tree, Emacs’s undo ring — practical implementations of linear and branching undo in editor history. Linear undo is the closest analogue to this application; branching is out of scope.
The two atoms it composes carry their own standards inheritance — Personal Todo (Jackson / EOS, Eiffel design-by-contract, LTL) and Event Log (ISO/IEC 27001, NIST SP 800-92, W3C Activity Streams 2.0, write-ahead logging literature).
Status
grounded — 2026-05-20 — composition logic specified, seven application-level invariants stated and justified, walkthrough example exercises the full action surface including delete/undo identity preservation, edge cases identify deferred concerns and the substrate’s natural breakdown points. First entry in compositions/. Demonstrates that two existing atoms compose into a useful application without modifying either constituent.
Lineage notes
This application survived all three pressure-testing passes (see PRESSURE_TESTING.md) on its first revision.
Pass 1 — Structural completeness (GRID). Clean. The user-level Flow is captured in the Walkthrough example rather than as a dedicated Flow subsection — acceptable for an application, where the per-action wiring in Composition logic is the substantive structure and a separate flow would duplicate it.
Pass 2 — Conceptual independence (EOS). Clean. The application is properly scoped: it composes Personal Todo + Event Log without absorbing concerns that belong to additional atoms. Redo, branching history, snapshots, concurrency resolution, import/migration are all named as future composing patterns rather than folded into Undo History.
Pass 3 — Adversarial scrutiny (Linus mode). Two findings, both fixed:
- “Session” undefined. The first draft said “the application owns one Event Log instance per Personal Todo session” without defining what a session is. Fixed: replaced with “instance” throughout, removing the under-specified term. Composes section now reads “one Event Log instance for each Personal Todo it operates on”; the corresponding Edge cases entry was renamed “Persistence across restarts” with cleaner language.
- Initialization from an existing log not addressed. The first draft assumed the log starts empty. Fixed: Edge cases now names log initialization explicitly as a deployment-shaped concern — fresh or existing logs are both supported, but cross-substrate import or merge is out of scope and belongs to an Import or Migration pattern.
The composition’s most architecturally interesting result — identity preservation across delete/undo (Invariant 6) — survived all three passes unchanged. It remains the showcase emergent property: neither Personal Todo nor Event Log carries it alone, and it falls out of the wiring rather than being designed in.
The application is grounded — 2026-05-13 after one round.
Refinement round 1. Three findings, all closed in-pattern. Conventions inherited from the methodology directly.
- Action signatures used
rejected(reason)placeholders;storage-failureabsent from all five. All five actions had placeholder rejection forms. Resolved: forward action signatures expanded with named reason taxonomies sourced from Personal Todo’s precondition rejections (not-known,not-pending,invalid-request,duplicate-active) plusstorage-failurefrom Event Log’sappend;undosignature expanded torejected(nothing-to-undo | storage-failure). The full Personal Todo rejection taxonomy will be confirmed against Personal Todo’s own refinement round. read_historyomitted its rejection form. The signature showed only the success path.read_historyis a passthrough to Event Log’sread, which carriesrejected(invalid-query). Resolved: signature updated toread_history(query) → ordered_sequence_of_events | rejected(invalid-query).- Action wiring missing the
appendstorage-failure path. Every action appends to the Event Log; none of the wiring descriptions specified what happens ifappendreturnsrejected(storage-failure). This is load-bearing for Invariant 1 (Log faithfulness): the converse of “every successful action appends an event” is “if the append fails, the action is not successful.” Without the failure path, an implementation might update the derived state even when the event didn’t land, violating State equivalence (Invariant 2). Resolved: each action’s wiring extended — if theappendreturnsrejected(storage-failure), the caller receivesstorage-failureand the derived state is not updated.
Scheduled rescan: 2026-05-20. Pass 1 GRID clean — constituent API spot-check confirmed: Personal Todo retains eight invariants and the not-editable rejection in edit; Event Log retains seven invariants. Pass 2 EOS clean. Pass 3 Linus (fresh-reader) — two refining findings, both closed in-pattern.
- No “load-bearing wiring decision” subsection (refining). The canonical composition shape (SPEC_FORMAT.md) requires a named subsection defending the key architectural decision in-line with the four-part rubric. The decision — identity preservation via replay rather than per-action snapshot — was implicit across the Replay semantics section and Invariant 6, but not stated and defended as a standalone subsection. Resolved: “The load-bearing wiring decision” subsection added to Composition logic, defending event-sourced replay as the structural mechanism that makes identity preservation an emergent property rather than a special case.
- No rejection-path example for
nothing-to-undo(refining). The Examples section covered the happy path and delete/undo identity preservation;undo()returningrejected(nothing-to-undo)was not exercised. The storage-failure path was similarly unexercised with concrete values. Resolved: “Rejection paths” example added walking bothnothing-to-undo(all forward events already undone) and thestorage-failurepropagation pattern. Round closes clean.