Apply: guarantees, layout and failure modes
@codai/axiom-apply takes a verified ManifestBundle and a root and either
writes the change set atomically (from the user tree’s point of view) or leaves
the tree exactly as it found it. apply() never throws; every failure is an
ApplyResult with status: failed | rolled-back and an error.code from the
closed enum (error-codes.md). This page describes
what is promised, what is not, and what is on disk afterwards. The pipeline it
sits in is drawn in pipeline.md; package README:
packages/apply/README.md.
Guarantees
Section titled “Guarantees”Nothing outside root is written. Before the first byte is staged, every
artifact path passes, in order:
- Schema
RelPathrules (relative POSIX, NFC, no.., no<>:"|?*, Windows device names rejected on every OS, no trailing dot/space). - Case-insensitive collision check between artifacts (
ERR_PATH_CASE_COLLISION); on a case-insensitive filesystem (probed once via.axiom/tmp/CaseProbe-…) also against existing on-disk names. - Segment walk from the root with
lstat: any symlink or junction in the ancestry →ERR_SYMLINK_IN_PATH. realpath.native(dirname(target))must equal the realpath’d root or start with it plus a separator →ERR_CONTAINMENT. Compared case-insensitively on Windows.- Target type: existing directory or symlink where a file is expected →
ERR_TARGET_TYPE; existing file withop: create→ERR_EXISTS.
Content is what the manifest says. Blobs are resolved (blobs → CAS) and
re-hashed before staging (ERR_DIGEST_MISMATCH, ERR_SIZE_MISMATCH,
ERR_BLOB_MISSING), and every staged file is re-read and re-hashed after the
write. The whole bundle is resolved before the first write.
All-or-nothing on the user tree. Phase 1 writes only under
.axiom/staging/<hex>/. Phase 2 moves files into place with rename and rolls
back in reverse on any error, scoped to exactly the manifest’s paths. A crash
mid-commit is recovered at the next apply on that root.
Hash-gated. mode: fs requires confirmDigest === bundle.manifestDigest
(ERR_CONFIRM_DIGEST_MISMATCH) and a manifestDigest that matches the recomputed
JCS hash (ERR_NOT_CANONICAL). A caller must have seen the digest — typically
from axiom_apply_dry_run — to apply it.
Pre-image verified at commit (TOCTOU guard). Right before each rename,
the current on-disk file is hashed again and compared with what was observed
during staging. If another writer changed it in between → ERR_PREIMAGE_CHANGED
and the transaction rolls back. This is what makes apply safe on a tree that
several agents share.
Single writer per root. .axiom/lock is created with O_EXCL and holds
{ pid, hostname, startedAt, manifestDigest }. A lock is stale when its pid is
dead on the same host or it is older than one hour; waiters queue in FIFO order
for 30 s (--lock-timeout) and then fail with ERR_LOCKED (details carry the
holder). Overlapping in-flight applies are refused up front with
ERR_CONFLICT — see Multi-agent roots.
Idempotent. If .axiom/applied/<hex>.json exists and every artifact’s
on-disk digest still matches the manifest, the result is status: noop with
every file unchanged; nothing is touched and the lock is held only briefly.
If the marker exists but files drifted, the apply proceeds as a re-apply and
lists the re-written paths in result.drifted. A drifted create target is
not ERR_EXISTS: it is committed as an overwrite and the foreign bytes are
backed up under .axiom/backup/<hex>/ like any pre-image. Without the marker a
create over an existing file is still ERR_EXISTS.
Pre-image binding (S-402). A manifest compiled with a root carries
preImage[] — the sha256 (or absent) of every artifact path as compile saw
it. On a first apply (no applied marker) every entry is re-verified before
staging; a mismatch is ERR_PREIMAGE_CHANGED with details.phase: "prepare"
and nothing is written. This is separate from the in-transaction TOCTOU check
(same code, no phase), which compares against what staging captured. A
manifest without preImage (compiled without a root) skips this step.
Rollback failure is its own error. When a commit step fails and the scoped
rollback also fails, the result is status: failed with error.code: ERR_ROLLBACK (the original error is in the message and details), the journal
stays in rolling-back, and axiom journal rollback <digest> (or the recovery at the
next apply) retries it.
Pre-apply checks are part of the transaction. When a preChecks callback
is supplied (the MCP server and CLI always do, using the profile), it runs on
the staged tree after phase 1; any verdict other than pass aborts with
ERR_CHECKS_FAILED before phase 2.
Non-guarantees
Section titled “Non-guarantees”- A writer that ignores
.axiom/lockcan still race. The pre-image check narrows the window to the instant beforerename; it cannot close it without a snapshotting filesystem. - Backups are not version control.
.axiom/backup/keeps pre-images for the lastkeepBackupsmanifests (default 3,--keep-backups <n>), then prunes. - No network.
mode: prnever pushes or opens a pull request (see below). refsources are never fetched by apply (ERR_REF_OFFLINEwhen the blob is not in the local CAS);axiom plan compile --allow-neton the same root fetches and pins them first.- Directory
fsyncis skipped on Windows; a power loss in the milliseconds after a rename can lose the rename on NTFS. Journal and marker files are still fsynced. - File mode
0755is recorded but not applied on Windows. - Nothing is done about file ownership, ACLs, extended attributes or timestamps.
.axiom/ layout
Section titled “.axiom/ layout”<root>/.axiom/ lock single-writer lockfile (JSON holder) queue/*.ticket FIFO waiters for the lock (one per waiting apply) intents/*.json in-flight path claims of applies that passed validation staging/<hex>/ full new tree for one manifest; removed after commit or on any failure journal/<hex>.json Journal { phase, steps[], startedAt, pid }; fsynced before phase 2 journal/chain.jsonl hash-chained log, one line per apply that reached a terminal state backup/<hex>/ pre-images of overwritten/deleted files (hardlink, else copy) applied/<hex>.json ApplyResult of a committed apply; presence = idempotency marker cas/sha256/<aa>/<hex> optional content-addressed store (written by compile --store cas) manifests/ bundles stored by the MCP server when a root is given to compile axiom.json gate policy + check profiles (3.0; committed) profiles/<name>.json 2.x custom check profiles (still read, deprecated) wt/<hex12>/ PR mode only: the isolated worktree, removed when the apply ends tmp/ case-sensitivity probe scratch<hex> is the 64-char manifest digest without the sha256: prefix. The
default profile protects .axiom/** from being overwritten by a manifest.
axiom setup init adds .axiom/* to .gitignore with ! exceptions for the committed policy
files (profiles.md).
Two-phase flow
Section titled “Two-phase flow”stateDiagram-v2
accTitle: Apply state machine
accDescr: The states an apply moves through from acquiring the lock to committed, with the rollback paths on failure.
[*] --> lock: acquire .axiom/lock
lock --> recover: journal left in committing / rolling-back?
recover --> noop: applied marker + digests match
recover --> prepare
prepare --> aborted: containment · blob hash · preChecks fail
prepare --> staged: journal { phase: staged }
staged --> dryrun: mode dry-run → diff, staging removed
staged --> committing
committing --> committed: every step renamed
committing --> rollingBack: any error (ERR_PREIMAGE_CHANGED, ERR_EBUSY, …)
rollingBack --> rolledBack: steps replayed in reverse
rollingBack --> failed: ERR_ROLLBACK — journal kept for axiom journal rollback
committed --> [*]: applied/<hex>.json · lock released
rolledBack --> [*]
noop --> [*]
aborted --> [*]
dryrun --> [*]
failed --> [*]
acquire lock ─► recover any journal left in committing/rolling-back │ ├─ applied marker present and on-disk digests match? ─► noop │ ▼Phase 1 (prepare) — no user file touched containment for every artifact resolve + hash every blob write staging/<hex>/… (tmp → fsync → rename → re-hash) record pre-image hash of every existing target preChecks(staged) must be pass ─► else ERR_CHECKS_FAILED, staging removed dry-run? ─► unified diff, staging removed, return write journal { phase: staged }, fsync │ ▼Phase 2 (commit) — journal { phase: committing } for each step in path order: re-hash current target; differs from pre-image ─► ERR_PREIMAGE_CHANGED overwrite/delete: move pre-image to backup/<hex>/<path> (hardlink → copy fallback) create/overwrite: rename(staging/<path>, target) mark step done; fsync journal every 32 steps journal { phase: committed } write applied/<hex>.json; remove staging; prune old backups; release lockAny exception in phase 2 triggers rollback: journal rolling-back, then
steps replayed in reverse where done — restore from backup or unlink the
created file — then rolled-back. The result is status: rolled-back with
error set to the original cause and journal pointing at the file. If the
rollback itself fails, status: failed and the message names both errors; the
journal is left for axiom journal rollback <digest> --root . or manual inspection
(.github/skills/debug-apply-journal).
Staging lives under the root so rename stays on one volume and is atomic
per file.
Rollback on demand
Section titled “Rollback on demand”rollback(root, manifestDigest) (CLI axiom journal rollback, MCP axiom_rollback)
replays the journal of a committed apply in reverse using backup/<hex>/. It
is scoped to the paths in that journal; files created after the apply by
someone else are not touched. A journal whose backups were already pruned
cannot be rolled back — check keepBackups.
Dry-run and diff
Section titled “Dry-run and diff”mode: dry-run executes phase 1 only, then produces a unified diff of every
text artifact against the current on-disk content (binary content is reported
as differing without a hunk), removes staging and returns
status: applied, files[], diff. The diff is capped at 1 MiB. No journal is
written, no lock file remains, no marker is created. The manifestDigest in the
result is the value to pass as confirmDigest.
Multi-agent roots
Section titled “Multi-agent roots”Several agents (or several sessions of one) may apply to the same root at once. Correctness never depends on them cooperating — the lock and the pre-image re-hash at commit still hold — but four mechanisms turn a late, confusing failure into an early, explicit one and make the shared state inspectable.
sequenceDiagram accTitle: Concurrent applies accDescr: Two applies on the same root serialised by the lock, with the second one failing its pre-image check. participant A as apply A (src/x.ts) participant B as apply B (src/x.ts) participant I as .axiom/intents participant Q as .axiom/queue + lock A->>I: registerIntent(digest A, paths) B->>I: registerIntent(digest B, paths) I-->>B: ERR_CONFLICT otherDigest A, paths A->>Q: ticket → head of queue → O_EXCL lock A->>Q: 2PC · journal · chain.jsonl · release A->>I: release intent
Early conflict detection — ERR_CONFLICT. After validation and before
queueing for the lock, a non-dry-run apply registers an intent:
.axiom/intents/<hex>-<seq>.json with { manifestDigest, paths (sorted), pid, host, since, seq },
written atomically under a short registry mutex so check-and-register is
atomic. If a live intent of a different digest touches an overlapping path
— the same path, or one a directory prefix of the other (a vs a/b),
compared case-insensitively on Windows and macOS — the apply fails immediately
with ERR_CONFLICT, details: { otherDigest, paths }, and nothing is
registered or written. The same digest (a re-apply) never conflicts with
itself; a dry-run registers nothing and never fails ERR_CONFLICT. Intents of a
dead pid on this host or older than one hour are ignored and pruned. The intent
is released when the apply returns. Retry after the other apply finishes, or
recompile against the tree it produced.
Fair lock queue. A waiter writes .axiom/queue/<sortable-id>-<pid>.ticket;
only the head of the queue (oldest live ticket) competes for .axiom/lock, so
waiters are served first-come first-served instead of by polling luck. Tickets
of dead pids are skipped and removed. The wait is bounded by --lock-timeout <ms>
(CLI, 0..600000, default 30000; MCP axiom_apply { lockTimeoutMs }); on
timeout the result is ERR_LOCKED with
details: { holder, waitedMs, queuePosition, lock } (queuePosition 1 = head).
Inspecting a root. axiom journal status [--root <dir>] [--json] (MCP
axiom_status, resource axiom://lock) is a read-only snapshot that never
takes the lock: holder (pid, host, digest, since, STALE), queue, registered
intents with their paths, interrupted journals that need axiom journal rollback, the
last applied entry and whether the chain verifies. axiom journal log [--root <dir>] [--limit <n>] [--json]
prints the journal chain newest first (default 20):
#seq at state manifestDigest name (files) [code].
Tamper-evident journal chain. Every apply that reaches a terminal state
(committed, rolled-back, failed) appends one line to
.axiom/journal/chain.jsonl: { seq, manifestDigest, state, at, prev, files, code? },
where prev is the sha256 of the previous line’s exact bytes (64 zeros for
seq 1). axiom journal verify --root <dir> re-walks it and exits 1 with
code: ERR_JOURNAL_CHAIN and firstBad { seq, reason: prev-mismatch | seq-gap | parse }
on any break; it also returns head (digest of the last line) and missing
(entries whose journal file was pruned — legal, not a fault). An apply never
extends a broken chain, and a broken chain never turns a successful write into
an error: status and doctor report it. What the chain cannot detect on
its own: an edit or deletion of the last line, or truncation of the tail —
nothing follows them to disagree. Pin head somewhere the agent cannot write
(a CI log, an attestation) when that matters.
Backup retention. After a committed apply, .axiom/backup/ keeps the
pre-images of the keepBackups most recent applies (--keep-backups <n>,
0..1000, default 3; MCP axiom_apply { keepBackups }) and deletes the rest.
Order comes from the chain’s seq, not file mtimes — a copy or a clock change
can reset an mtime, not a sequence number. Backups the chain does not know
(roots from before 2.4, or an unreadable chain) fall back to mtime order and
are pruned first. A pruned backup means that apply can no longer be rolled back.
PR mode
Section titled “PR mode”mode: pr runs the normal two-phase apply in an isolated linked worktree
and commits it to a new branch. The shared checkout’s HEAD, index and files
are never touched, so other agents working in the same clone see nothing
change. It is the v2 replacement for v1’s applyPR, which spawned git with
shell: true and a caller-supplied branch name (command injection). Sequence:
- Branch name —
options.branchor the deterministic defaultaxiom/<manifest.name>/<manifestDigest hex 0..12>(no timestamps). It must match^[A-Za-z0-9._/-]{1,120}$with no..,//,@{, leading-//, trailing/,.or.lock, and then passgit check-ref-format --branch <name>(as an argv element). Anything else →ERR_GIT_BRANCH_INVALID, and no git process is spawned for the syntactic rejects. git rev-parse --show-toplevelmust be the root itself (realpath, case insensitive on Windows) → elseERR_GIT_NOT_REPO. A subdirectory of a repo is rejected on purpose: artifact paths are relative to the root.git status --porcelain -- <touched paths>must be empty → elseERR_GIT_DIRTYwith the dirty list indetails. Only the paths the manifest touches are inspected; other agents’ uncommitted work elsewhere in the tree is left alone and is not committed.refs/heads/<branch>must not exist → elseERR_GIT_BRANCH_EXISTS.- Under the shared root’s lock (intent registered first, as for
fs): any leftover worktree of the same digest is removed, the branch check is repeated, then a linked worktree is created at.axiom/wt/<hex12>on the new branch, started from the currentHEAD(git worktreewith-b <branch> <path> HEAD). If the worktree already holds the manifest’s result the apply isnoop(PR-mode idempotency is content-based). Otherwise the ordinary two-phase apply runs inside the worktree (containment against the worktree’s realpath; CAS blobs still read from the shared root’s.axiom/cas). git add -- <written/deleted paths>in the worktree (explicit paths only — this stages deletions of tracked files too),git commit --quiet -F -with the message on stdin (default:axiom: apply <name> (<digest12>)+Manifest:/Plan:trailers),git rev-parse HEAD. Hooks (pre-commit,commit-msg) are honoured — a hook that rejects the commit fails the apply with statusrolled-back.result.git = { branch, commit, compareUrl? }.compareUrlis derived fromgit remote get-url originwhen it is GitHub (/compare/<default>...<branch>?expand=1, default branch fromrefs/remotes/origin/HEAD, fallbackmain) or GitLab (/-/merge_requests/new?merge_request[source_branch]=<branch>); otherwise absent.- Always, success or failure: the worktree directory is deleted and its admin
entry pruned, together with its own
.axiom/(journal, staging, marker). The branch is kept only when the commit landed; on any failure a branch this apply created is deleted. Nothing reaches the shared tree either way.
Step 3 is not needed for isolation any more — the worktree starts clean from
HEAD — but it is kept on purpose: a branch built from HEAD would otherwise
silently ignore your uncommitted edits to the very paths it rewrites, so those
still fail with ERR_GIT_DIRTY.
What PR mode does not do: push, open a pull request, touch the network, or
run git through a shell. Every invocation is
spawn("git", args, { shell: false, windowsHide: true, cwd: root, env }) with
the env reduced to PATH, HOME, USERPROFILE, SYSTEMROOT, TEMP/TMP
and GIT_* minus GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE, plus
GIT_TERMINAL_PROMPT=0, GIT_ASKPASS=echo, LC_ALL=C so nothing can block on
a prompt. A git call that exits non-zero → ERR_GIT_FAILED (first stderr line
in the message, last 4 KiB in details.stderr); no git on PATH →
ERR_GIT_NOT_FOUND; 60 s timeout per call. To publish the branch afterwards:
git push -u origin <branch>gh pr create --head <branch> # or open result.git.compareUrlIdempotency in PR mode is content-based: when HEAD already holds every
artifact the manifest declares, the result is noop and no branch is left
behind. Re-running with the same branch name after a landed commit is
ERR_GIT_BRANCH_EXISTS (the branch is yours; push it or delete it).
Windows notes
Section titled “Windows notes”- Paths longer than 240 chars are passed as
\\?\-prefixed namespaced paths for every fs call. fs.realpath.nativeis used soc:/C:and 8.3 short names normalise; root containment compares case-insensitively.- Junctions are reported by
lstat().isSymbolicLink()and rejected like symlinks. renameonto a file another process holds open fails withEBUSY/EPERM/EACCES; it is retried 5 times with 50 ms × attempt backoff, thenERR_EBUSYand rollback. Close editors and watchers on the target files.- Windows reserved names are rejected on every platform so a manifest compiled on Linux applies on Windows.
- Directory
fsyncis a no-op;0755is not applied.
See also
- Pipeline — where apply sits and the two-phase commit as a sequence diagram
- Invariants — the promises this page implements (3: hash-gated, TOCTOU, single writer)
- Checks — the
preChecksthat run on the staged tree before phase 2 - Error codes — every
ERR_*anApplyResultcan carry