Skip to content

0.14.3 identity v2: one source of truth; a 0.13 agent is moved, never copied - #64

Open
sym-bot wants to merge 4 commits into
release/0.14.3from
fix/0.14.3-identity-v2
Open

sym-bot wants to merge 4 commits into
release/0.14.3from
fix/0.14.3-identity-v2

Conversation

@sym-bot

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

Copy link
Copy Markdown
Owner

Replaces the copy-and-commit identity layer of the 0.14.3 candidate (9f6847c) with design v2: one source of truth, and a 0.13 agent moved (or linked to), never copied. Into release/0.14.3; not merged, tagged or published.

The design as built

  • One source of truth. An agent exists exactly when its identity is committed (nodes/by-id/<nodeId>/identity.json; with SYM_IDENTITY_DIR, SYM_IDENTITY_DIR/by-id/<nodeId>/identity.json). by-name entries and the nodes/<name> compat links are caches: written after the commit; when missing or unreadable, re-derived from the committed identities' name (the compat link a second witness); never read as "no such agent". An entry naming an identity not in place halts; two committed identities of one name halt naming both.
  • Names are case-insensitive everywhere (NFC, lower case): entry keyed by the folded name, holding the exact one; 0.14.2's exact-name entry read and written beside it.
  • Mint only when nothing on the host holds the name, under a short per-name mint lock (staging → one rename → compat link → exclusive link() claim).
  • Move, don't copy. One rename of the whole 0.13 directory (node dir first, identity last = the commit); the compat link made before and renamed in right after the commit, before any sync. Link, mount point, other volume, or a relative link leading out: by-id/<id> becomes an absolute link to it. Across volumes only identity.json/e2e-keypair.json are copied (staged, synced, read back, one rename) after a free-space check.
  • Husks set aside whole (by-id/.<id>.husk-…, short per-nodeId lock), the node moved in, differing files kept and reported (SYM_HUSK_SET_ASIDE).
  • No loose keys: 0700/0600 narrowed on every load; asides narrowed at once and on load; dead staging, temp links/entries/lock temps cleared on every load; a stray key copy in the node dir (SYM_IDENTITY_DIR) removed.
  • Pinned starts use the same migration and never take a name another committed identity, entry or unmigrated 0.13 agent holds.
  • Export: intent → tombstone (beside the key and in the node dir, which 0.14.2 reads) → bundle hash → bundle → complete → scrub of every key copy. Resume: same out (while a complete bundle may exist), same recipient. abortExport only while no complete bundle was produced; CLI sym node abort-export. Import: identity first, each install marked with its bundle, a killed import finished by running it again. SymNode re-checks after taking the identity lock.
  • Kept from 9f6847c: the atomic stale-lock reclaim, Windows names, R4-9, the skipped-live refusal, halts not mints, error texts.

lib/node.js is touched only where the identity is loaded/locked (_assertStillHere after acquireIdentityLock, at construction and in _takeOwnership).

Deviation from the brief

The mint takes a short per-name lock instead of racing lock-free with "the loser removes its own identity": a loser killed between its commit and its cleanup leaves a second committed identity of the name, which no later start can tell from a real second agent. With the lock there are no losers; a killed minter's identity is adopted by the next start.

Results

Every R1 and re-review scenario ends correct on this branch (the review scripts pointed at this tree, 9f6847c and fe285a3, all from git archive copies):

scenario 9f6847c / 0.14.2 this branch
rr-e6 ENOSPC (26 MB store, 48 MB HFS+ image) ENOSPC every start (9f6847c) same agent, 8 KB used
rr-e24 5,000-file first start 21,390 ms (9f6847c) 61 ms
rr-e23 12 agents in one process 15,520 ms (9f6847c) 595 ms
rr-e5 0.14.2 split crash state, then export key + e2e key left in husk at 0644 no file holds either key
rr-e12 a killed starter's staging holding the key survived 3 more starts and the export (9f6847c) cleared on every load; no key after export
rr-e11 partials of SIGKILLed starters, then export 3 partials left after 3 more starts, 6 of 6 rounds (9f6847c) 0 partials, no key after export (6/6)
rr-e14 mount point copied onto the state volume by-id links to the mount, no copy
rr-e7 store behind a link on another volume 5 MB copied (9f6847c), NEW identity (0.14.2) linked, 4 KB on the state volume
rr-e22 relative link aside dangling, key 0644 left after export nothing dangling, no key left
rr-e13 / e13b export retried to another out two importable bundles one bundle; the retry only finishes the scrub
rr-e16 import killed between installs pin absent, re-import refused re-import completes, agent by pin and name
rr-e17 pin under a 0.13 agent's name pin took the name (0.13 agent shadowed) halts; by name: the 0.13 agent
rr-e18 0.14.2 after an export (split / pending) 0.14.2 loaded keyless / started with the key beside a complete bundle 0.14.2 refuses (tombstone in the node dir, before any bundle)
rr-e19 claim crash starts; lock temps left starts; temps cleared
rr-e20 stopped node exported, restarted took the lock again with the key EIDENTITYTOMBSTONED
rr-e25 husk diverged beside a live 0.13 halt (9f6847c) / key overwritten (0.14.2) starts; husk kept aside, reported
rr-e1 0.14.2 host → this → this → 0.14.2 dirs left 0755 (9f6847c) all SAME, 0700/0600
rr-e8 messages / rr-e9 / rr-e15 — correct (index EACCES/empty now loads)
R1 e4, e5, e6, e7 (index lost: 4 of 6 halted on 9f6847c), e9, e10, e11, e12, e13, e3, e1, e2/e3b/e8 (EXDEV, HFS+ image, kill) 0.14.2 minted in e4/e5/e6 all correct

kill -9 at every step (scripts/identity-fuzz/kill.js, SIGKILL before and after each mutating fs call): 492 crash points (489 reached) over plain / pinned / husk (stale lock) / husk with files / SYM_IDENTITY_DIR / by-id on another volume / SYM_IDENTITY_DIR/by-id on another volume (HFS+ image) / mint / mint split. The next 0.14.3 start: the same agent, whole store, keys 0600, at every point. 0.14.2 / 0.13 rollback: a second identity only at the one point between the commit rename and the compat-link rename (2 points per scenario = one instant), and where 0.14.2's own bugs act on untouched state (a husk: 0.14.2 destroys the key itself; EXDEV). Export 102 + 130 (split), import 104 + 132: never a startable source beside a complete bundle; abort or resume always completes; one early split run had 2 of 130 points report a key file left after resume, not reproduced in 4 reruns (516 points) + 96 targeted repeats.

Multi-process: rr-e2 (6 starters, legacy/new/husk/lock, 5 rounds each) all GOOD; rr-e10 chaos 20/20 GOOD; rr-e11 6/6; rr-e3 lock race 6/6 one holder; race.js plain, pinned-mix, husk-content, split, new, chaos 5/5 each. Mixed 0.14.2/0.14.3 (rr-e21, 20 rounds of 6): 0.14.3 only 20/20; 0.14.2 only 9/20 bad (0.14.2's own TOCTOU: skipped-duplicate → mint); mixed 12/20 bad — the original key kept, and 0.14.3 then halts naming both identities. Mixed lock race: 1 of 6 rounds two holders (0.14.2's non-atomic reclaim). Hence the upgrade note: upgrade every embedder before the first start.

Rollback / rehearsal: rollback.js (0.14.2 host: migrated, minted, renamed, re-pointed, mixed case, split) through this tree twice and back: every agent same in every phase. Rehearsal: APFS clone of a sandboxed 15-agent 0.13.17 + 0.14.2 host (real stores, links, husk, rename, pinned start under a second name): 15/15 unchanged by name, pinned and as SymNode through two 0.14.3 starts and a 0.14.2 rollback.

Mutation: 42 mutants (one per guard), 42 caught.

Suite: full suite --test-concurrency=2 1688 tests, 1684 pass, 0 fail, 4 skipped; integration 6/6; gate-two-stock-nodes passed; npm audit 0. GitHub CI (Linux, Node 20 and 22) green. Linux docker (node:22-slim, node:20-slim, overlayfs, no cloning) for the identity test files (identity-v2, identity-commit, identity-migration-by-id, core-secure-identity, core-secure-final-review, config, identity-halt): 200 tests, 192 pass, 4 skipped, 4 fail — the lock tests that read process start times with ps, which the slim images do not ship (the same 3 config.test.js tests fail on 9f6847c there; all pass in CI and on macOS; the lock code is unchanged). Before-check in docker: the two case-sensitive tests (exact-name entry, names that fold together) fail on both 9f6847c and 0.14.2 on ext4/overlay.

Left open (in the CHANGELOG's known limits)

  • The one-call window between the commit rename and the compat-link rename: a kill exactly there followed by a 0.14.2/0.13 start (not a 0.14.3 one) makes a second identity; the original is kept and 0.14.3 halts naming both. Closing it needs an atomic exchange (renameat2/renamex_np), not available from Node.
  • 0.14.2 entries written for renames/pinned re-points are honoured as aliases; a lost alias entry and its compat link both gone is re-derived from the identities' names (could be another agent of that name).
  • An export whose bundle was moved away from out between its rename and the complete record cannot tell it was complete.

An agent exists exactly when its identity is committed (by-id/<nodeId>/identity.json, in SYM_IDENTITY_DIR when set).
The name index and the 0.13 compat links are caches of which agent a name is: written after the commit, re-derived
from the committed identities' names when missing or unreadable, never read as "no such agent". Names are compared
case-insensitively (NFC, lower case): the entry is keyed by the folded name and holds the exact one.

A 0.13 directory is moved in by ONE rename (node dir first, identity last); the compat link is made before the commit
and renamed onto the 0.13 path right after it. A 0.13 directory behind a link, on a mount point, on another volume, or
holding a relative link that leads out of it is linked to from by-id, never copied; across volumes only the identity
files are copied (staged, synced, read back, committed by one rename) after a free-space check. A husk is set aside
whole under a short per-nodeId lock and reported; a mint holds the name's short mint lock from staging to claim.
Keys never left loose: dirs 0700 and keys 0600 narrowed on every load, asides narrowed at once and on load, dead
staging, temp links, temp entries and temp lock files cleared on every load, a stray key copy in the node dir removed.

Export: intent, tombstone (beside the key and in the node dir), the bundle's hash, the bundle, complete, the scrub of
every copy of the key. Resume needs the same out (while a complete bundle may exist) and the same recipient; abort only
while no complete bundle was produced, also on the CLI (sym node abort-export). Import: the identity first, each
install staged and marked with its bundle; an interrupted import is finished by running it again.

lib/node.js (identity and lock only): once the identity lock is held, at construction and at a restart after a stop,
an identity exported or being exported meanwhile is refused and the lock released.
…identity-v2), and the spec tests moved to v2

tests/identity-v2.test.js: 36 tests, each a scenario of the re-review (rr-eN) or of R1 that fails on 9f6847c or on
0.14.2 (fe285a3), plus one per guard of the design (each caught by its mutant). identity-commit and
identity-migration-by-id: the expectations the copy design wrote are changed to the move design, each with its reason
in the test (moved not copied, linked not copied, the index a cache, the tombstone before the bundle, a husk set aside
not a halt).
…ed versions, rollback and timing

prepare.js git-archives v0.14.2 and v0.13.17 (rollback trees); kill.js kills one operation at every mutating fs call
(before and after it) and checks the next start, a pinned start, a 0.14.2 and a 0.13 rollback (migration, mint, husks,
SYM_IDENTITY_DIR, across volumes with SYM_FUZZ_FAR, export, import); race.js starts one agent from N processes at once
(plain, pinned, husk, split, new, SIGKILLed at random, mixed with 0.14.2); rollback.js runs a 0.14.2 host through this
tree twice and back, and times the first start of a 5,000-file agent and of 12 agents in one process.
… as built, upgrade notes, known limits, numbers

The section describes what ships (one source of truth, moved not copied, linked across volumes, the export order),
with the upgrade notes (upgrade every embedder on a host before the first start; the behaviour changes), the limits
left (the one-call window, mixed versions, an export bundle moved away before its complete record) and the measured
numbers (tests, mutation, kill -9 at every step, races, rollback, rehearsal, timing, disk).
@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-14T19:57:08.129336Z cd12d83 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.

@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: cd12d835d9

ℹ️ 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/relocation.js
Comment on lines +556 to +557
if (!resuming) install(idDest, split ? (staging) => write(staging, idFile) : storeFiles); // THE COMMIT
if (split && !ours(dest)) install(dest, storeFiles);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Install the store before exposing the imported identity

When SYM_IDENTITY_DIR is separate, this installs idDest first, making the identity discoverable and startable before dest contains the bundled store. If the process dies between these calls, an automatic start by name can claim the identity and run against an empty or stale node directory; while it holds the lock, retrying the import fails, and after it stops the retry archives any newly written state as a husk rather than merging it into the imported history. Keep the identity unstartable until the store installation is complete, for example by installing the store first and treating the identity installation as the commit.

Useful? React with 👍 / 👎.

Comment thread lib/config.js
Comment on lines +1349 to +1351
if (srcs.nodes) {
const lock = readLockFile(path.join(srcs.nodes.path, 'lock.pid'));
if (lock && !lock.migrating && lock.pid !== process.pid && lockIsHeldByLiveProcess(lock)) return { status: 'skipped-live', nodeId, holderPid: lock.pid };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Hold the legacy lock throughout the directory move

In the mixed-version migration path, this one-time lock check leaves a race with a 0.13 process: it can load nodes/<name>/identity.json after this check, the migration can rename that directory to by-id, and the old process can then recreate nodes/<name> when acquiring its lock. The temporary compatibility link cannot replace that nonempty directory, while the new process acquires the unrelated lock in by-id, so both processes run the same key concurrently. Acquire and retain the legacy directory lock across the rename/link transition rather than merely sampling it.

Useful? React with 👍 / 👎.

Comment thread lib/relocation.js
Comment on lines 365 to +368
for (const d of new Set([config.identityDirById(id), dir])) {
try { fs.unlinkSync(path.join(d, config.EXPORTING)); fsyncDir(d); } catch { /* */ }
try { fs.unlinkSync(path.join(d, config.TOMBSTONE)); fsyncDir(d); } catch { /* */ }
}
return { nodeId: id, aborted: true, bundle: intent.out || null, bundleRemoved };
removeIntent(id);

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 Preserve the intent when tombstone removal fails

During abortExport, every tombstone unlink error is swallowed and the export intent is removed unconditionally. In split-directory mode, if deletion succeeds beside the key but transiently fails in the node directory, the call reports success yet readTombstone() still finds the remaining copy; because the intent is gone, future aborts classify the export as completed and the node remains permanently refused despite no bundle being produced. Only remove the intent after all existing tombstone copies were removed successfully.

Useful? React with 👍 / 👎.

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