ADR 0002 — Every mutation is a Command; history is a subscriber, not a core feature

Status: accepted · Amends: — · Amended by: —

Context

Undo is the feature users notice only when it is wrong. In an editing tool it is not a convenience — a surveyor who cannot take back a mis-drag will not use the tool at all.

The hard part is not implementing undo. It is implementing undo across plugins that have never heard of each other. The draw plugin adds a polygon; the edit plugin moves a vertex; the topology plugin repairs a self-intersection; a plugin nobody has written yet does something none of us anticipated. All four must land in one coherent stack, in the right order, with one Ctrl+Z per user-visible action — without any of them importing any other.

The obvious implementations fail in specific, well-known ways:

Decision

Every state change in Blaeu is a Command, and a Command is the only way state changes.

interface Command<R = void> {
  readonly type: string
  readonly label?: string // shown in the undo menu, already localised
  readonly transient?: boolean // executes, but is never recorded
  readonly gesture?: string // one pointer-down to the matching pointer-up
  execute(ctx: CommandContext): R
  undo(ctx: CommandContext): void
  redo?(ctx: CommandContext): R
  coalesceWith?(previous: Command): Command | null
}

The contract is strict and stated plainly: undo(execute(s)) must restore s to deep equality. Not "close enough", not "visually identical". If your undo cannot restore deep equality, the command captured too little state — capture more. Every command owes the round-trip test.

The CommandBus holds no undo stack. It exposes onDidExecute(handler). The history plugin subscribes to that, keeps two stacks of Commands, and calls undo() on them. It knows nothing about geometry, drawing, parcels or vertices.

Supporting machinery, each earning its place:

Alternatives rejected

A command bus that owns the undo stack. One less plugin, and undo works out of the box. Rejected because it forces every product to pay for undo: a read-only viewer, a kiosk, an embedded thumbnail renderer all carry an undo stack they will never touch. More decisively, it forecloses the interesting variants — a collaborative product needs a history that respects other people's commands (you may not undo my edit), and a server-backed product needs one that reconciles with a remote log. As a subscriber, history is replaceable. As a core feature, it is not.

Event sourcing over a persistent store. Genuinely attractive, and closer to what a collaborative back end will want. Rejected for now because it imposes an immutable-log model on every plugin author, including one writing a throwaway internal tool, and because the in-memory command stack is a strict subset of it: an event-sourced history plugin can be written later, against the same Command interface, without touching the kernel. That is the whole point of history being a subscriber.

Undo via inverse-operation inference (auto-derive undo from execute). Requires the store to be a pure function of the command, which stops being true the moment a command mints an id or stamps a timestamp. And an inferred inverse that is subtly wrong is far worse than an explicit one you can test.

Consequences

Edit this page on GitHub — this site is generated from docs/adr/0002-commands-for-cross-plugin-undo.md, which is the source of truth.