# OpenSpec Change: enterprise-undo - **Change ID**: enterprise-undo - **Package**: `@suaveplan/undo` - **Category**: universal - **Target path**: `universal/control-flow/undo` - **Status**: IN PROGRESS - **Date**: 2026-04-23 - **Reference spec**: `/mnt/s/web/suaveplan/enterprise/undo.md` ## Summary Greenfield implementation of `@suaveplan/undo` for the genesis branch. Ships a full undo/redo engine with branching history, async commands, history compression, selective undo, and command merging. All public API from the main-branch reference implementation is preserved. Genesis adds structured errors (`UNDO.*` codes via `@suaveplan/error`), Zod option schemas, `EventBusLike` integration for `UndoStack` change notifications, `Clock` injection for `BranchingHistory` timestamps, OTel counters/histograms, property-based tests (fast-check), and mitata benchmarks. The package depends on `@suaveplan/diff` for `invertPatch`/`applyPatch` inside `createPatchCommand` — no reimplementation of JSON Patch operations. ## Current-state audit (genesis) The package does not exist on genesis yet — this is a fresh build. ## Available workspace deps on genesis | Package | Catalog / version | Role | |---------|-------------------|------| | `@suaveplan/error` | `workspace:^` | Structured error base class + `defineCodes` | | `@suaveplan/types` | `workspace:^` | `Clock` (timestamps in `BranchingHistory`), `EventBusLike` (UndoStack events), `IdGenerator` (command IDs) | | `@suaveplan/events` | `workspace:^` | Concrete `EventBus` implementation; injected into `UndoStack` | | `@suaveplan/diff` | `workspace:^` | `diff`, `applyPatch`, `invertPatch` — powers `createPatchCommand` | | `zod` | `catalog:forms` | Zod schemas for all option types | | `@opentelemetry/api` | `catalog:observability` | Counters, histograms, spans | | `tslib` | `catalog:build` | TypeScript helpers | Dev deps (all `catalog:testing`): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`. ## Preserved public API surface (from main branch) ### Command primitives - `createCommand(label, execute, undo, metadata?)` → `Command` - `composeCommands(label, commands)` → `Command` - `executeCommand(state, command)` → `T` - `undoCommand(state, command)` → `T` - `createPatchCommand(label, state, newState, metadata?)` → `PatchCommand` — uses `@suaveplan/diff`'s `diff` + `invertPatch`; no local reimplementation - `estimateCommandSize(command)` → `number` ### Type guards - `isPatchCommand(cmd)` → `cmd is PatchCommand` - `isAsyncCommand(cmd)` → `cmd is AsyncCommand` ### Sync linear stack - `class UndoStack` — `push`, `pushState`, `undo`, `redo`, `beginTransaction`, `clear`, `reset`, `snapshot`, `getUndoStack`, `getRedoStack`; getters: `current`, `canUndo`, `canRedo`, `undoSize`, `redoSize`, `activeBranchId` ### Branching history - `class BranchingHistory` — `push`, `pushState`, `undo`, `redo`, `switchBranch`, `getBranch`, `deleteBranch`, `beginTransaction`, `snapshot`; getters: `current`, `canUndo`, `canRedo`, `activeBranchId`, `branches` ### Async command API - `createAsyncCommand(label, execute, undo, metadata?)` → `AsyncCommand` - `composeAsyncCommands(label, commands)` → `AsyncCommand` - `executeAsyncCommand(state, command)` → `Promise` - `undoAsyncCommand(state, command)` → `Promise` - `class AsyncUndoStack` — async `push`, `undo`, `redo`, `beginTransaction` (returns `AsyncTransactionHandle`), `clear`, `reset`, `snapshot`; same getters as `UndoStack` ### History compression - `class HistoryCompressor(options)` — `compress(stack)` → `CompressionResult & { stack: UndoStack }`; getters: `compressionDepth`, `maxHistorySize` - `compressHistory(stack, options)` → `CompressionResult & { stack: UndoStack }` ### Selective undo - `class SelectiveUndoStack` extends `UndoStack` — `undoById(commandId)`, `getDependents(commandId)`, `canUndoById(commandId)` → `SelectiveUndoResult` ### Command merging - `class CommandMerger(options?, predicate?)` — `canMerge(a, b)`, `add(command)`, `flush()`, `reset()`; getters: `hasPending`, `pendingCount` ### Types (all re-exported from `src/types/undo.types.ts`) `Command`, `PatchCommand`, `AsyncCommand`, `StackEntry`, `Branch`, `UndoStackOptions`, `BranchingOptions`, `UndoStackState`, `BranchingState`, `TransactionHandle`, `AsyncTransactionHandle`, `CommandMergerOptions`, `SelectiveUndoOptions`, `SelectiveUndoResult`, `DependencyChecker`, `HistoryCompressorOptions`, `CompressionResult`, `MergePredicate` ## New additions for genesis ### Structured errors `UndoError` extends `SuaveplanError`; error codes registered via `defineCodes` under the `UNDO` namespace: | Code | Thrown by | |------|-----------| | `UNDO.EMPTY_STACK` | `undo()` / `redo()` when nothing to undo/redo (optional — currently silent; add when callers need to distinguish) | | `UNDO.AT_BRANCH_ROOT` | `BranchingHistory.switchBranch` when target branch is not found | | `UNDO.DEPENDENCY_CONFLICT` | `SelectiveUndoStack.undoById` when conflicts exist and `throwOnConflict` option is set | | `UNDO.COMMAND_FAILED` | `AsyncUndoStack.push/undo/redo` when the async command rejects | | `UNDO.TRANSACTION_ACTIVE` | `UndoStack.beginTransaction` when a transaction is already open | | `UNDO.TRANSACTION_INACTIVE` | `TransactionHandle.add/commit/rollback` when the transaction was already closed | ### Zod schemas (sibling `*.schemas.ts` files) `UndoStackOptionsSchema`, `BranchingOptionsSchema`, `HistoryCompressorOptionsSchema`, `CommandMergerOptionsSchema`, `SelectiveUndoOptionsSchema` — validated at construction time in their respective classes. ### EventBusLike integration `UndoStack` constructor accepts an optional `EventBusLike` (from `@suaveplan/types`). When provided it emits typed events: | Event | Payload | |-------|---------| | `undo:push` | `{ commandId, label, undoSize }` | | `undo:undo` | `{ commandId, label, undoSize }` | | `undo:redo` | `{ commandId, label, redoSize }` | | `undo:clear` | `{}` | ### Clock injection `BranchingHistory` and `createCommand` / `createPatchCommand` / `createAsyncCommand` accept an optional `Clock` (from `@suaveplan/types`) so branch `createdAt` and command `timestamp` fields are deterministic in tests (no bare `Date.now()` calls). ### IdGenerator injection `createCommand`, `createPatchCommand`, `createAsyncCommand`, and `BranchingHistory._forkRedoEntries` accept an optional `IdGenerator` (from `@suaveplan/types`) so `id` fields are deterministic in tests (no bare `crypto.randomUUID()` calls at module scope). ### OTel instrumentation Counters (via `@opentelemetry/api` meter `undo`): - `undo.push.total` — labels: `stack_type` (`linear` | `branching` | `async` | `selective`) - `undo.undo.total` — same labels - `undo.redo.total` — same labels - `undo.compress.total` Histograms: - `undo.stack.depth` — recorded on every push/undo/redo - `undo.compress.ratio` — `originalCount / compressedCount` recorded on each compression ### Property-based tests (fast-check) - Push → undo → redo round-trip: `stack.undo(); stack.redo()` restores exact state for any sequence of commands. - Stack size invariant: `undoSize + redoSize` equals total pushed (minus truncated). - `compressHistory` idempotency: re-compressing an already-compressed stack returns `squashedCount === 0` for entries within depth. - `CommandMerger` merge associativity: merging `[a, b, c]` produces the same result regardless of flush timing. - `BranchingHistory` branch isolation: switching branches never mutates other branches. ### Benchmarks (mitata) - `UndoStack.push` / `undo` / `redo` — 10 000 cycles with no-op closure commands. - `UndoStack.push` / `undo` / `redo` — 10 000 cycles with patch commands on a medium-sized JSON object. - `compressHistory` on a 1 000-entry stack with `compressionDepth: 100`. - `BranchingHistory.push` + `switchBranch` — 100 branches of 10 commands each. ## Impact - Adds one new package: `universal/control-flow/undo` - Adds one new spec capability: `undo` - Depends on: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/diff` - Unblocks: any package requiring undo/redo history (editor tooling, collaborative editing, UI form revert)