vps-lxc—my bash container manager, told in full in Containers in 2010—registered compensation before its template build and upgrade paths took their risky steps. Each handler was prepended to an array, so entering the explicit fatal path ran the newest one first. SIGINT and SIGTERM routed into that same path:
trap 'vps_exception ${LINENO} fatal "caught SIGTERM"' TERM
trap 'vps_exception ${LINENO} fatal "caught SIGINT"' INTCtrl-C halfway through a template build lands in the handler that deletes the half-built template. An explicitly routed pacman failure with /dev, /sys, and /proc bind-mounted into a template rootfs unmounts them before the rootfs is deleted, because the unmount handler was registered second and runs first. Unchecked shell failures could bypass the path; the handler stack was manual compensation, not language-level try/finally.
Over the years, that approach kept reappearing in other work: btrfs subvolumes, a vim plugin directory, a venv builder, a game-server fleet, and now a Python installer’s append-only receipts.
Boot the copy, never the original
The boot-time version is the btrfs hook I put on AUR, covered in Never Boot the Real Root. Its layout makes the risk land on copies from the start: /__active, a snapshot of the installed system, is the default boot target, and choosing an older snapshot at boot still does not boot it—the hook deletes any stale /__rollback, re-snapshots the choice there, and boots the new clone. __active survives and the chosen snapshot survives; the previous disposable rollback does not. The recovery path is not taken after damage. The layout arranges for the tree being risked to be replaceable.
A .old beside the .new
The humblest version is two files in my dotfiles:
vim/autoload/plug.vim
vim/autoload/plug.vim.oldvim-plug, the plugin manager itself, is vendored—committed to autoload/, so a fresh machine bootstraps offline and pinned—and beside it sits its own previous version. Nearly every upgrade carried its predecessor along as a .old file: one step of reversibility, maintained by hand.
Rename, then delete
pyproject, an experiment in virtual environments, applied the instinct to teardown itself. Its universal bin/activate elects one venv builder with an atomic mkdir—useful, but not a complete mutex—and on an ordinary nonzero failure after that, attempts to move the half-built venv away before deleting it:
cleanup () { (($1)) && mv $VIRTUAL_ENV{,.$$} && rm -r $VIRTUAL_ENV.$$; } >&2Rename first, delete second. When cleanup receives a nonzero status, the rename atomically removes the failed venv from the published name, and the slow rm -r grinds away on a name new runners will not resolve. The activation note covers the remaining defects: a signal could skip cleanup, and another runner could use the unfinished venv.
Hide the partial; verify before publish
snap-gs, the Go supervisor for a VR game-server fleet, carried the avoidance form of the instinct. The intended protocol wrote match data as file.lock, closed the gzip stream and file, attached the S3 metadata as an xattr, and only then renamed it to the public name. The co-designed watcher rewrote foo.lock events into Create of foo, so even the notification layer spoke in terms of the completed name. The two-party convention has its own note.
The historical writer discarded the errors from its deferred finalization steps, so rename could still publish an incomplete result. The failure analysis is the missing half of the technique: ordering says what should happen; checked results decide whether publication is allowed.
Reversal values as data
The current end of the thread is the public Python implementation of Spice. Its initializer first produces a side-effect-free ordered plan. Each operation carries the value and mode observed before the change, the value and mode Spice intends to generate, its scope, and an ownership digest. Apply stores each completed operation in <worktree-git-dir>/.spice/init-receipt.jsonl.
That receipt is an append-only fact log, not a status document rewritten after each step. Spice encodes and bounds a complete record before opening the file, then emits it with one unbuffered write to a regular file opened with O_APPEND. A changed repository approval becomes another record; earlier bytes remain intact.
spice init --unapply reads those recorded operations in exact reverse order. It restores a file or Git value only while the live state still matches what Spice generated. If someone changed the file after initialization, unapply keeps their version and reports a retained-diverged residue instead of replacing it with the old bytes. If another worktree still owns a shared config setting, ownership transfers to that worktree rather than removing a dependency from under it.
The preview includes the current receipt digest. Apply recomputes it and refuses if the receipt changed between inspection and execution. The thing the operator approved is therefore one observed reversal plan, not a vague promise that uninstall will probably be safe.
Rollback, compensation, avoidance
Lined up, the six are not one idea but three. The handler stack and the virtualenv teardown are compensation: attempt to undo partial work in the required order. The boot scheme, the .old file, and the receipt are rollback: retain the observed prior state and restore it only while ownership is still clear. A correctly checked .lock rename is avoidance: keep the intermediate state hidden behind the .lock name until publication.