Undo History

Table of contents
  1. Undo History
    1. Intent
    2. Summary
    3. Composes
    4. Composition logic
      1. Event schemas
      2. Action wiring
      3. Replay semantics
      4. The load-bearing wiring decision
    5. Composition-level invariants
    6. Examples
      1. Walkthrough
      2. Identity preservation across delete/undo
      3. Audit-as-side-effect
      4. Rejection paths
    7. Edge cases and explicit non-goals
    8. Standards references
    9. Status
    10. Lineage notes

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’s add precondition (description policy + active-set uniqueness against the current derived state); reject with invalid-description or duplicate-active if the precondition fails. On success, generate a new id, append {type: "add", id, description} to the Event Log. If the append returns rejected(storage-failure), return storage-failure to the caller without updating the derived state — the action did not happen. On successful append, update the derived state, return id.
  • edit(id, newDescription) → ok | rejected(not-known | not-pending | not-editable | invalid-description | duplicate-active | storage-failure) — Validate against Personal Todo’s edit precondition; reject with not-known, not-pending, not-editable, invalid-description, or duplicate-active if the precondition fails. On success, capture the unit’s current description as prior_description, append {type: "edit", id, prior_description, new_description}. If the append returns rejected(storage-failure), return storage-failure without updating the derived state. On successful append, update the derived state, return ok.
  • complete(id) → ok | rejected(not-known | not-pending | storage-failure) — Validate against Personal Todo’s complete precondition; reject with not-known or not-pending if the precondition fails. On success, append {type: "complete", id}. If the append returns rejected(storage-failure), return storage-failure without updating the derived state. On successful append, update the derived state, return ok.
  • delete(id) → ok | rejected(not-known | storage-failure) — Validate against Personal Todo’s delete precondition; reject with not-known if the precondition fails. On success, capture the unit’s full state as snapshot, append {type: "delete", id, snapshot}. If the append returns rejected(storage-failure), return storage-failure without updating the derived state. On successful append, update the derived state, return ok.
  • undo() → undone_event_type | rejected(nothing-to-undo | storage-failure) — Identify the most recent forward event (type ∈ {add, edit, complete, delete}) whose event_id is not already in the undone set. If none, reject as nothing-to-undo. Otherwise, append {type: "undo", undone_event_id, undone_event_type} to the Event Log. If the append returns rejected(storage-failure), return storage-failure without 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 whose event_id is 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’s read. The user can inspect their history at any time.

Replay semantics

The derived Personal Todo state at any moment is computed as follows:

  1. Read all events from the Event Log in sequence_number order.
  2. Build the undone set: the set of undone_event_id values from all undo events.
  3. 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_id is in the undone set, skip.
    • Otherwise, apply the event to a Personal Todo–shaped state under construction:
      • add → introduce a unit at the recorded id in Pending, with added_at = recorded_at and the recorded description.
      • edit → replace the unit’s description with new_description; set last_edited_at = recorded_at.
      • complete → move the unit at id from Pending to Done with completed_at = recorded_at.
      • delete → remove the unit at id from the state.

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 undo event’s undone_event_id references the most recent forward event whose event_id was 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 delete restores the unit at its original id with its original added_at, last_edited_at (if any), and prior state (Pending or Done). The original add event remains in the log; replay skipping the delete reconstructs the unit faithfully. Personal Todo on its own cannot do this — its delete is terminal and a fresh add produces 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 undo calls — provided no further forward actions intervene. After any forward action following undos, the previously-undone events remain in the log but cannot be reached via undo (that would require a separate Redo pattern).

Examples

Walkthrough

A user opens a fresh Personal Todo with Undo History:

  1. add("buy milk") → returns t1. Log: [add(t1)]. State: t1 Pending.
  2. add("renew passport") → returns t2. Log: [add(t1), add(t2)]. State: t1, t2 Pending.
  3. complete(t1)ok. Log: [add(t1), add(t2), complete(t1)]. State: t1 Done, t2 Pending.
  4. undo() → returns "complete". Log appends undo(complete(t1)). Replay skips complete(t1). State: t1 Pending, t2 Pending.
  5. delete(t1)ok. Log appends delete(t1, snapshot). State: t2 Pending.
  6. undo() → returns "delete". Log appends undo(delete(t1)). Replay skips delete(t1). State: t1 Pending, t2 Pending — t1 is back with its original id, original added_at, and Pending state.
  7. add("walk dog") → returns t3. State: t1, t2, t3 Pending. The previously-undone events (complete(t1), delete(t1)) remain in the log but are unreachable via further undo (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:

  1. add("buy milk")t1. State: t1 Pending.
  2. undo() → returns "add". Log appends undo(add(t1)). State: empty.
  3. 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 undo is 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. undo always 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-failure absent 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) plus storage-failure from Event Log’s append; undo signature expanded to rejected(nothing-to-undo | storage-failure). The full Personal Todo rejection taxonomy will be confirmed against Personal Todo’s own refinement round.
  • read_history omitted its rejection form. The signature showed only the success path. read_history is a passthrough to Event Log’s read, which carries rejected(invalid-query). Resolved: signature updated to read_history(query) → ordered_sequence_of_events | rejected(invalid-query).
  • Action wiring missing the append storage-failure path. Every action appends to the Event Log; none of the wiring descriptions specified what happens if append returns rejected(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 the append returns rejected(storage-failure), the caller receives storage-failure and 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() returning rejected(nothing-to-undo) was not exercised. The storage-failure path was similarly unexercised with concrete values. Resolved: “Rejection paths” example added walking both nothing-to-undo (all forward events already undone) and the storage-failure propagation pattern. Round closes clean.

Grace Commons — open foundation for business logic patterns.

This site uses Just the Docs, a documentation theme for Jekyll.