Skip to content

0.14.3: an agent no longer loses its key to the 0.13 -> by-id upgrade - #56

Merged
sym-bot merged 15 commits into
mainfrom
fix/0.14.3-identity-migration
Oct 18, 2026
Merged

sym-bot merged 15 commits into
mainfrom
fix/0.14.3-identity-migration

Conversation

@sym-bot

@sym-bot sym-bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

0.14.3: an agent no longer loses its key to the 0.13 -> by-id upgrade

An XMesh review of lib/config.js 0.14.2 returned DO-NOT-SHIP with 13 findings. The first fix (632b95d) was reviewed again (R1) and failed the same way twice over: it read the state of an identity from whatever was at a path (a by-id directory partly there, an index entry that could not be read, a node directory without identity.json, a stale lock's content), and each patch for one case made another. R1 proved eleven defects by running them. This PR replaces the design assumption instead of patching the cases.

The design: identity state is a record committed in one atomic step

  • Migration copies; it never moves. The 0.13 directory is copied into nodes/by-id/.<nodeId>.partial-<pid>-<rand> (beside its destination, so on its volume), links followed, identity.json last, every file synced, verified byte for byte, a commit record (.sym-commit) written, and installed by ONE rename. Several starts at once: one rename wins, the rest find the same identity and discard their staging. Then the 0.13 directory is renamed aside to nodes/.legacy-0.13/<name> (never deleted, keys 0600) and the compat link takes its place.
  • A by-id dir without the commit record is a husk, never "the identity is in place": empty or stale-lock-only husks are removed with rmdir (cannot remove anything with content) and the commit made; a husk with files is merged into the staging and kept at .<nodeId>.husk; a differing file or identity is a conflict (halt, nothing changed); a live lock refuses.
  • A name is claimed by an exclusive create of its index entry (written under a temp name, synced, hard-linked into place), after the commit. A losing first start discards its identity and loads the winner. The index reads as absent only on ENOENT; anything else halts and says where the identity is.
  • Mint only with no trace of the name (index entry, nodes/<name> link or non-empty dir, SYM_IDENTITY_DIR/<name>, an aside dir, an unindexed installed identity of that name). A trace that provably is the name's identity is adopted (the name claimed for it); any other trace halts with what it is.
  • Pinned and unpinned starts share the migration path. A live 0.13 process in the 0.13 directory refuses the start (EIDENTITYLOCK).
  • Stale lock reclaim is compare-and-delete: removing a lock needs a claim named after that lock's (dev, ino, content), itself a lock; holding it, the lock is removed only if it is still that file. Lock files are created whole (linked into place).
  • Export: intent beside the key, bundle durable, tombstone beside the key (identity dir), scrub (also of the aside copy). Interrupted: resumable (run again) or abortable (relocation.abortExport). Import: staged, dirs 0700, keys 0600, the key only in the identity dir.
  • validateName refuses names Windows cannot hold (mesh-channel's nodeNameProblem rule); compat links compared as resolved paths (junctions).

Deleted: moveTree, mergeMove, sameTree, mergeConflict, legacyDirsRemain, acquireMigrationLock and the nodes/.migrating lock (Atomics.wait up to 30 s). lib/node.js is not changed.

Also fixed here: R4-9 (mesh-channel review): a pinned start no longer re-points a name another identity holds; it halts and leaves the entry (create: false with the pin absent: IdentityAbsentError saying the name is another agent's).

Verification (numbers in the CHANGELOG)

  • tests/identity-commit.test.js (new): 33 tests, 32 fail on 632b95d. tests/identity-migration-by-id.test.js: 53 tests (14 fail on 632b95d); every changed expectation carries its reason in the test.
  • 27 mutants (one per guard), all caught.
  • Multi-process stall fuzz: 6 modes x 40 rounds x 4 starters, 0 failed; 6 starters x 30 rounds x 2 modes, 0 failed. It found one race in the first version (a committed dir set aside as a husk, same identity, nothing lost), fixed in ad03aed with a deterministic test. Stale-lock race: 0/20 rounds with two holders, with and without stalls (632b95d 3/8). New-name race: 0/10 and 0/6 SymNode (632b95d 6/6). Cross-volume (hdiutil HFS+ image) multi-process: 50 rounds, all original.
  • kill -9 fuzz: same volume 360 rounds (180 killed within 60 ms), cross volume 120 rounds (60 killed mid-run): every start the original with its whole store (632b95d across volumes: 4/10 lost store files).
  • Full suite (macOS, --test-concurrency=2): 1568 tests, 1564 pass, 0 fail, 4 skipped; gate:stock-nodes passed; npm audit 0. CI green on Node 20 and 22.

Still known (CHANGELOG)

The two-rename window between setting the 0.13 dir aside and the link (a 0.13 build started in exactly that state would mint); a husk merge race that leaves files kept aside, not merged; the aside copies are never removed by sym (disk); 0.13 processes still reclaim locks non-atomically; abortExport not on the CLI; consumers that list nodes/ (xmesh src/node-tree.ts) should skip dot-names (.legacy-0.13). Behaviour changes: a node dir with files and no identity halts; names Windows cannot hold are refused; a pinned start never takes a name.

Not merged, not tagged, not published.

…y-id upgrade

An XMesh review of lib/config.js 0.14.2 returned DO-NOT-SHIP with 13 findings. Each was reproduced in a
sandbox state dir before anything was changed; the data-loss one was also reported from a developer's machine.

What lost the key: migrateNodeDir refused to move a 0.13 node when nodes/by-id/<nodeId>/ existed, even empty or
holding only lock.pid, and left the name unindexed. loadIdentity then read "refused" as "no such agent", made a
new identity, renamed the 0.13 directory onto the new node's path and wrote the new identity.json over the old
one. A pinned start was bricked instead. The guard asked whether a directory existed; it must ask whether an
identity does.

Root causes, changed rather than patched:
- A second claim on a nodeId is a different identity (or content that cannot merge) already filed under it. An
  empty directory or a stale lock claims nothing: the node is merged into it. The same identity on both sides is
  an interrupted move and is finished. A real duplicate is still refused and nothing is overwritten.
- loadIdentity treated every answer it did not recognise as "mint". It now mints only when nothing on the host is
  known under the name: a 0.13 identity still under the name, or a name indexing a node whose identity is missing,
  halts (IdentityHaltError).
- The name is indexed before anything moves and the identity moves last, so a second starter finds the agent and
  an interrupted move is re-entered by the identity's absence. A step that fails because another process just did
  it is re-run; a path that became an alias of its destination is never merged onto itself.
- Key files: rename keeps the mode 0.13 wrote (0644). identity.json and e2e-keypair.json now end at 0600 and their
  directories at 0700 after a migration; loading narrows an existing key file, which heals nodes already migrated by
  0.14.0-0.14.2. A rewrite of identity.json is atomic.
- A move across file systems copies, verifies byte for byte, then removes the source.
- Smaller: an identity with a public key and no private key halts; the live-lock fallback checks the tombstone;
  a symlink that is not a by-id link no longer reads as already indexed; SYM_IDENTITY_DIR switched on for a node
  whose identity is in its node dir halts with instructions instead of making a new identity.

Not fixed, listed in the CHANGELOG: migrateIdentities still discards per-node errors; loadIdentity({nodeId, name})
re-points a name that indexes another node; SYM_IDENTITY_DIR first-time does not move the key for the operator;
an explicit nodeId unknown to the index is ignored when a new identity is made.

tests/identity-migration-by-id.test.js: 36 tests, 30 of which fail on 0.14.2. Four real processes starting one
0.13 agent at once, 320 starters: every one loaded the original agent. Seventeen mutants of lib/config.js, each
caught.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-14T09:03:40.168874Z 0e31e38 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

A name whose identity file is missing halts and names what to remove to start the name again as a different
agent. It named only nodes/by-name/<name>.json, but nodes/<name> is a link to the old by-id directory and
re-indexes the name from it on the next start, so removing the index alone halts again. The message now names
both, and a test follows the message to the end (the first removal is shown not to be enough).

The test count in the CHANGELOG is now 37 (31 fail on 0.14.2).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0e31e380d9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/config.js Outdated
fs.chmodSync(dst, st.mode & 0o777);
return;
}
fs.copyFileSync(src, dst, fs.constants.COPYFILE_EXCL);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip stale Unix sockets during cross-filesystem migration

On POSIX, a crashed node can leave <node dir>/interior/interior.sock behind (lib/interior.js places the socket there, and lib/relocation.js already treats it as ephemeral). When this new EXDEV fallback migrates such a node across filesystems, copyFileSync is called on the socket and fails with ENXIO; loadIdentity then fails after its retries, so the node cannot upgrade or start. Skip socket entries such as interior.sock during the copy rather than treating every non-directory entry as a regular file.

Useful? React with 👍 / 👎.

…n lost the key)

The merge into an existing by-id directory was safe only if no other process was doing the same. It assumed
each step was idempotent, so several processes could interleave them. They cannot: when one finishes it
replaces nodes/<name> with the compat link, and another, still holding the old path, then sees that path lead
to the destination, finds the "duplicate" file identical to itself and deletes it - the only copy of
identity.json. Plain concurrent runs passed 320 starters. A fuzzer that stalls every file call for a few ms
in four real processes lost the identity file in 2 of 300 rounds (everything else filed, identity.json gone).
A Linux run under a throttled CPU (node 22, 0.3 cpu) failed the new race test twice in 40 with the same
symptom: identity.json missing from by-id after every starter had finished.

The design assumption was "a retry makes concurrent migration safe". It does not, and a guard on the alias
only narrows the window. The migrator now holds a lock (nodes/.migrating/lock.pid, the identity lock) while it
moves something; others wait, read the state again on their turn, and find it done. It is taken only when a
0.13 directory is there to move. The retry loop and its ENOENT tolerance are gone; the alias check stays as a
last guard on a key file. With the lock the fuzzer ran 1,200 starters (300 rounds, husk) and 1,600 (400 rounds,
none) with every starter loading the original agent and no errors.

Also fixed in this commit: the F6 test passed for the wrong reason once the lock existed (its injected write
failure hit the lock file's write); it now fails only the identity temp file. A starter that read the index
just before another process wrote it, and then migrates nothing, re-reads the index instead of making a new
identity; that line had no test and now has one. Tests: 41 (33 fail on 0.14.2); 17 mutants, each caught.
…g it waited

The test asserted the second start waited at least 250 ms of a 400 ms hold. Under a throttled cpu (node 20 in
a container at 0.3 cpu) the holder's timer had run for 150-170 ms before the test process was told it held the
lock, so 4 of 30 runs measured 225-243 ms and failed although the start had waited. It now has the holder
report the time it let go, and asserts the start returned at or after that time (a start that moved the node
alongside the holder returns well before it). Mutant "no migration lock" still fails it.
loadIdentity ran the migration only for a start by name alone (`!nodeId && name`). mesh-channel starts every
node as { name, nodeId: <the pin in node.json>, create: false }, so a pinned node whose files were still the
0.13 nodes/<name>/ could not start (EIDENTITYABSENT) until something started it by name; with the default
create: true, 0.14.2 made a new identity and lost the key, and the first 0.14.3 draft halted. Reproduced in a
sandbox state dir on fe285a3 and on the branch before this change (plain, empty by-id/<pin>, create true/false).

When the pin's identity is not in place and a 0.13 directory under the name is still there, loadIdentity now
runs the same locked, idempotent migrateNodeDir(name) and goes on to load the pinned agent. It is one path:
the unpinned and pinned starts share the block; migrateNodeDir takes the pin as an optional second argument.
It is not run when there is no 0.13 directory to move, so a pinned start writes nothing it did not before.

Decision, a pin that is a DIFFERENT nodeId than the directory's: that directory is another agent's, so it is
left where it is, unindexed (migrateNodeDir answers 'skipped-duplicate' before it moves or indexes anything).
The pinned identity stays absent and the start fails as before: IdentityAbsentError with create: false, and
with create: true the halt that names the stranded 0.13 identity and the pin. Nothing is lost and nothing is
made. That agent moves when it is started by name or by its own pin.

Tests (tests/identity-migration-by-id.test.js, 51 now): plain and empty/locked by-id/<pin> with create false;
create true loads the original and does not mint; SYM_IDENTITY_DIR; a pin that is another agent's; a link to
another agent does not get indexed by a pinned start; the name indexed to another agent; a live 0.13 holder and
the tombstone; four real processes with injected stalls, with and without a by-id dir already in place. Eight
of them fail on 0.14.3 as pushed (051f44b) and on fe285a3; two guard against over-migrating. Four new mutants
(no pinned migrate, no pin guard, no legacy-dir gate, adopting the index) are each caught.
…t, exclusive name claim

The design assumption that failed twice: the state of an identity was inferred from
whatever was at a path (a partial destination, an unreadable index, a node dir without
identity.json, a stale lock's content). Replaced:

- 0.13 -> by-id migration COPIES: staged beside its destination (nodes/by-id/.<id>.partial-*),
  identity.json last, synced, verified byte for byte, installed by ONE rename. The loser of
  two commits finds the same identity and discards its staging. The 0.13 directory is then
  renamed aside to .legacy-0.13/<name> (never deleted) and the compat link takes its place.
  A by-id dir without the commit record (.sym-commit) is a husk: emptied with rmdir, or its
  files merged into the staging and the husk kept at .<id>.husk. mergeMove, moveTree and
  the nodes/.migrating lock (Atomics.wait up to 30 s) are gone.
- A name is claimed by an exclusive create (link) of its index entry after the commit; a
  losing first start discards its minted identity and loads the winner. The index reads as
  absent only on ENOENT; anything else halts. Index writes are fsynced.
- A mint happens only when there is no trace of the name; a trace that provably is the
  name's identity is adopted, any other halts with what it is.
- A live 0.13 process in the 0.13 directory refuses the start (EIDENTITYLOCK).
- A stale lock is removed only by the holder of a claim named after that lock's file
  identity and content (compare-and-delete); locks are created whole via link.
- relocation: intent, bundle, tombstone beside the key, scrub; resumable and abortable
  (abortExport); imports staged with 0700 dirs, 0600 keys, the key only in the identity dir.
- validateName refuses names Windows cannot hold; compat links compared as resolved paths.

tests/identity-commit.test.js: 25 tests, all failing on 632b95d.
…art installed

A start copying the 0.13 directory while another start sets it aside can see it go mid-copy.
That start's error is not the agent's state: the other start installed the identity and
claimed the name, so loadIdentity reads it by the index instead of failing.

Tests: four guard tests for the staged commit (a copy that does not read back is not
installed; a linked subdirectory is copied as a directory; a husk with a live lock refuses;
a live process's staging is left alone).
…tombstone, export intent, commit record) from a bundle
…identity (R4-9)

loadIdentity({ name, nodeId: pin }) re-pointed the name's index entry to the pin whenever it
named another identity. mesh-channel pins node_id from a repository's .sym/node.json, so a
folder pinning X under "reviewer" silently took the name from another folder's agent Y: Y's
next start by name ran as X and Y was left without its name, and a cloned repository could
choose which identity on the host a session signs as.

Now a pinned start may claim a name nobody has, and that is all. A name whose entry names
another identity halts (IdentityHaltError, saying both and how to resolve it) and the entry
is left as it is; with create: false and the pin not on this host it is IdentityAbsentError,
saying the name is another agent's. A pinned start is never answered with the name's agent.
…nd by the multi-process fuzz)

Four starts at a 0.13 node over a lock-only by-id husk: one start judged the target a husk,
another emptied it (stale lock removed, rmdir) and committed its copy there, and the first then
read the directory's entries (the committed copy's), took them for a husk's files and renamed
the committed directory aside to .<id>.husk. Same identity, nothing lost, but an installed
directory moved: 9 of 40 rounds (6 of 40 pinned).

The husk is now examined again immediately before it is set aside; a commit found there is
the other start's ('exists'). A husk with files cannot be replaced by a commit (a rename onto
a non-empty directory fails), so after that look only another claim can move it, and then the
rename aside fails. Lock artifacts (a claim, a temp lock file) are not a husk's content: dead
ones (and this process's own, left by a failed attempt) are removed before the rmdir; one that
stays has the husk set aside with it.

The real cross-filesystem test is named for what it now checks: no rename crosses the volumes.
… aside (the fuzz's race, made deterministic)
@sym-bot
sym-bot merged commit 04a9d40 into main Oct 18, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant