Skip to content

sym 0.14.0 Core Secure - #53

Merged
sym-bot merged 158 commits into
mainfrom
feat/0.14.0-core-secure
Oct 13, 2026
Merged

sym-bot merged 158 commits into
mainfrom
feat/0.14.0-core-secure

Conversation

@sym-bot

@sym-bot sym-bot commented Oct 13, 2026

Copy link
Copy Markdown
Owner

sym 0.14.0 "Core Secure": MMP 2.0 update 1 (published 8 October 2026, sym-bot/meshcognition-website c07ff20) implemented end to end, with the 0.13.17 hotfix merged in.

What it is (full list in CHANGELOG.md):

  • the §6.6 authority store, rebuilt to the published authority-by-reference spec: grants, revokes and endorses by hash, resolved as a set; one strict Ed25519 verifier at every signature site;
  • sealed sessions and control frames, the canonical signed record projection, chained attestation checkpoints, discovery on _sym._tcp with TXT room, the relay client for relay 0.6.1;
  • the delivery ledger, which fixes inbox-ID reuse and redelivery loops;
  • remix() is asynchronous and §15.8-tethered (a breaking change, in the CHANGELOG with before and after).

Breaking: LAN meshing with 0.13.17 stops except through Legacy Import routes, by design; room names must follow the §5.8 grammar (EBADROOM); all 0.13 role grants confer nothing until the anchor re-issues them (docs/AUTHORITY-OPERATOR.md).

Reviewed independently: the authority store (release review, narrow re-review, diff check: SHIP); PR #43's folds by three layer reviewers; the alignment round by a final combined review (SHIP-WITH-FIXES; every finding fixed); the §15.8 guard by a mutation check.

Verified at 6519392: full suite 1417 tests, 1414 pass, 0 fail, 3 skipped; integration 8/8; npm run gate:stock-nodes passed; npm audit 0 vulnerabilities; npm pack lists 84 files with no tests.

Release: the founder approved publishing 0.14.0 under the npm tag next; latest stays 0.13.17 until mesh-channel and XMesh are verified on it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VMk5HbMaz4oD7zoakvPyA6

sym-bot and others added 30 commits October 1, 2026 18:25
…els expire

Every peer re-sends its whole wake-channel list (peer-info) on every connect, and the
receiver logged a line and rewrote wake-channels.json per entry per frame, even for
entries it already held. On the daemon, xmesh reconnects thousands of times a day, so
stdout.log reached 1 GB of "learned wake channel for unknown".

- WakeManager.learnWakeChannel(nodeId, channel, {source, lastSeen}) is the one way a
  channel is recorded, from the peer itself (direct), the relay's list (relay) or
  another peer's peer-info (gossip). A weaker source never replaces a stronger one's
  token, so unauthenticated gossip cannot repoint a phone that gave us its token.
- lastSeen is the last first-hand sighting. Gossip forwards the lastSeen it holds
  instead of the time of sending, and a channel unseen for 30 days expires, in memory
  and on disk. A channel saved before lastSeen existed gets one TTL of grace, once.
- peer-info and the relay list log one line per frame, only when something was
  learned, and write the file only when something changed. The relay list handling
  moves to RelayConnection._handleRelayPeers so it can be tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
…ay by re-spelling it

The directed de-duplication mark was the carried assertionId (which no signature covers) or
the raw base64url signature string. Base64url decoding ignores padding and the unused bits
of the last character, so `sig + '='`, a twin final character, or an injected assertionId
all verified as the same signed record under a new mark, and a relay could make one signed
directed record surface again and again. The mark is now sha256 of the decoded signature
bytes. Ed25519 is deterministic, so one assertion has one signature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
§18.3.1: every Core Secure CMB MUST be signed. A signing failure was logged and the
record was then stored and broadcast unsigned, so a node with a broken key kept
emitting records every verifying peer refuses. remember() now throws ESIGN (with a
cmb-signing-failed metric) before anything is stored or dispatched, on first signing
and on the re-sign after a collapse. The daemon's IPC handler reports a failed command
as that command's error, instead of logging every handler error as a parse error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
buildTranscript bound the room and both party names, and aeadAADv2 the
room, as raw code points. The published reference (conformance lib.mjs)
NFC-normalizes all three, and so does sym's own record signature. The same
visible text arrives composed or decomposed depending on where it was typed
(macOS tends to NFD), so two honest peers in the room "café" hashed
different transcripts and rejected each other's proofs, and a correctly
sealed frame failed authentication.

Tests: a decomposed room/name gives the same transcript and AAD bytes as
the composed one, and a frame sealed under one form opens under the other.
They fail on the previous code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
…by an hour

The relay's list is first-hand evidence, so each repeat moved lastSeen by a few
milliseconds and asked for a save: a file write per relay reconnect. Against a 30-day
TTL that precision is worthless. The record is still updated in memory; a save is
asked for only when lastSeen moves by an hour or the source gets stronger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
…ceipts counted

- B-R10 (§7): createCMB names the room 'default' when the author names none, instead of
  null, which signed the string "null" and read as "every room" on receipt. checkAudience
  now places a record without a room in 'default', so it is not admitted in every room it
  is replayed into, and the v2.0 preimage refuses a record with no room. A fixture that
  set a top-level `room` (read by nothing) passed only through that hole.
- B-R11: mood valence and arousal are kept only when measured, and must be numbers in
  [-1, 1]. They were invented as 0 when absent, and a mood given only as numbers became
  {text:'neutral'}, dropping them.
- B-R13: a category is at most 256 KiB, the seven at most 960 KiB, an agent id at most
  64 bytes (§3.1.2), so every record fits a 1 MiB frame. createCMB throws ECMBSIZE, and
  a receiver refuses an oversized record before rendering, verifying or storing it
  (metric cmb-oversize-rejected).
- B-R3 (interim): an unsigned record is still accepted as unverified, but each one is
  counted (metric cmb-unsigned-received) and the peer is named once in the log, so the
  emitters a signed-only default would cut off can be found first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
…lication-v2

The SDK's record-signature and handshake vectors were not the published
files: they lacked the 2026-09-14 errata fields (`usage`,
`expectedSignatureIsOneOfMany`), and the tests asserted the opposite of
what those fields say, comparing our Ed25519 signatures and handshake proofs
byte for byte. A hedged signer (WebKit, CryptoKit) is conforming and fails
that comparison. There was also no SDK copy of application-v2 and no test
of the mmp-app-v1 commitment.

- copy all four published conformance/v2 files verbatim
  (record-signature, handshake, e2e, application)
- signing: reproduce the preimage, assertion id and category commitment
  exactly; verify expectedSignature, never reproduce it
- handshake: reproduce both proof payloads; verify the published proofs and
  two of our own signings, no byte comparison
- new application-v2 test: both commitments, payload integrity, refusal of
  a changed byte, a wrong length and padded base64url
- new digest pin: each local vector must hash to the published manifest's
  sha256, so a hand edit or a stale copy fails instead of drifting

The digest pins and the errata assertions fail on the previous copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
…art time

"the lease refuses a second holder" wrote lock.pid as `1` plus
{"startTime":0}. Windows has no PID 1, so the test failed there; and on
every OS the reader's metadata key is `start`, not `startTime`, so the
metadata was ignored and the test only exercised the legacy
no-start-time branch, never the v2 start-time check it was meant to cover.

The holder is now a sleeping child process, recorded exactly as a running
holder writes its lock ({"start": processStartTime(pid)}), and killed in
cleanup. The test also asserts the reader sees that start time and that
the refusal names the holder's pid.

Mutation check: making the start-time comparison always report a mismatch
fails the new test; the old one still passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
… everywhere

On win32 child.kill('SIGTERM') is TerminateProcess: no signal handler and
no exit hook run, so "releases the lock on SIGTERM when the host has no
handler" can only fail there (lock.pid stays). It is now skipped on win32,
with that reason in the skip message.

What Windows actually needs is the hard-kill path, so a new test runs on
every platform: a child acquires the lock, is killed with SIGKILL
(TerminateProcess on Windows), its lockfile is shown to be left behind
with its pid and start time, and the next acquire must reclaim it.

The new test passes on the current lib, because reclaim already works; a
mutation that stops treating a dead pid as stale makes it fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
§15.8 anchors a remix on the oldest root reachable by recursively
VERIFYING parent records. resolveTetherAnchor stepped onto any stored
entry, including a peer block admitted without its author's signature
being verified, and followed whatever parents that block claimed. The
author of one unsigned block could pick the anchor of every remix that
cites it, and the integrator then signed a tether naming it.

A step now has to verify (storedRecordVerifies): its categories
recompute to the key it is held under, and this node either wrote it
or recorded a verified signature verdict when admitting it. An
unverifiable parent ends that branch. A remix with no verified
ancestor in reach now reports an unverifiable root (key and categories
null) instead of anchoring on itself, which measured the remix against
its own text and attested it as tethered. `complete` reports whether
the walk met an unavailable or unverifiable edge (§15.2 incomplete
proof).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
A verified record's signed `to` could only veto directed treatment, never grant it, so a
relay that stripped the frame's `directed` flag turned a signed directed CMB into a
broadcast that SVAF could reject and that never surfaced (§9.2.2). When the author signed
an addressee, it now alone decides whether the CMB is directed to this node (§8.8.5 step 7).
The frame flags still decide for unverified records and records that sign no addressee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
- B-R4 (§8.8.4, §18.3.1): a published v2.0 record (mmp-sig-v2.0) signs its author's node
  id, but it was verified against the key of whichever peer delivered the frame. A peer
  could vouch for a record by signing it itself under another node's id, and a genuine
  relay failed. It is now verified against the key held for createdByNodeId. A missing
  key is unverified, not refused: that is key-distribution latency, as for v1.
- B-R6 (§8.8.5 step 5): a v2.0 record's carried assertionId must equal the one its
  preimage yields, or the record is refused. On other suites no signature covers an
  assertionId, so a carried one is dropped instead of kept beside verified fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
…e lookup

On Windows every start-time read is a synchronous PowerShell process
(~0.45 s), run per pid per lock check and remembered for only 10 s, so a
daemon start paid it once per node directory whose lock it checked.

- createStartTimeCache: a successful read is kept until the pid is seen
  dead (or a fresh read returns a different start); a failed read is kept
  10 s as before, so lookup failure still means "held", warned once
- one PowerShell call reads every pid asked for (Get-Process -Id a,b,c;
  `exit 0` so one vanished pid does not discard the others), using the
  same per-process expression as before, so strings match existing locks
- locksHeldByLiveProcess / primeProcessStartTimes: a pass over many locks
  reads all their holders in one lookup; migrateStores now checks only the
  nodes that still need the rename, all in one batch
- the one hazard of remembering: a pid that dies and is reused between two
  checks is never seen dead. A mismatch, which hands the lock to someone
  else, is therefore always confirmed by a fresh read, so a remembered
  value can only err toward "held"

POSIX keeps reading `ps` directly (cheap, nothing remembered). Tests run
the cache through an injected lookup on every platform: one read per live
pid however often it is checked, forgotten when seen dead, failures
retried after 10 s, one lookup per prime and per migrateStores pass, a
mismatch re-read before reclaiming, a failure held and warned once. They
fail on the previous code, which had no batched or injectable path. The
PowerShell batch itself runs only in the win32-only config test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
§15.8 makes the tether content-only so any holder of the root and the
record re-encodes the two texts and recomputes what the integrator
signed. The gate measured the remix side on its fused vectors, which
are this receiver's attention readout over its own recent anchors: two
nodes holding the same root and record signed different drifts
(0.134757 vs 0.183918 in the new test), and no holder could reproduce
either.

The gate now runs evaluateLineageTetherFromText on the record it
stores, the same computation the retroactive audit uses. That function
is made content-only throughout (a vector on a category with no text
was taken as-is) and single-kernel: the semantic encoder replaces the
lexical one at runtime, and an evaluation whose encodes straddled the
switch compared unrelated spaces, so it now re-encodes both sides when
the kernel identity changes underneath it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
The cold-start exit of the heuristic gate stored a remix and returned
without a tether, so the frame handler had nothing to sever on: a
remix drifted past its root's reject floor kept its lineage whenever
the receiver's anchor window was empty or uncovered. An empty window
says nothing about the chain; §15.8 compares the record with its
lineage root, not with memory. The cold-start exit now runs the same
tether computation as the warm path before returning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
auditLineageTethers falls back to fetchCMB when the verified walk finds
no anchor. fetchCMB answers from the local store before asking any
peer, so for a parent held locally but unverifiable it handed back the
very record the walk had just refused, and the audit attested a tether
to it (the B-L4 defect, reached through the opt-in fetch path). Keys
the store already holds are skipped there; only records this node does
not hold are fetched, and those self-verify against their address.

One guard line in lib/node.js, kept in its own commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
…d pipe on Windows

On Windows the daemon listened on SYM_SOCKET as given. A filesystem path
there cannot be a Unix socket (listen EACCES on ...\d.sock), so the daemon
exited and daemon-relay-only failed. And the clients did not resolve the
endpoint the way the daemon did:

- SymDaemonClient defaulted to /tmp/sym.sock, an address no daemon has
  listened on since the socket moved to <state root>/daemon.sock, on any OS
- bin/sym.js read SYM_SOCKET itself before getSocketPath, bypassing any
  mapping the daemon applied

lib/platform now has ipcEndpoint(address, platform): unchanged on POSIX;
on Windows a pipe name is used as given and a filesystem path becomes
\\.\pipe\sym-<basename>-<sha256 of the resolved, lower-cased path>, so two
spellings of one path reach one pipe and two paths never share one.
getSocketPath resolves SYM_SOCKET through it, and the daemon, the CLI and
SymDaemonClient all use getSocketPath / ipcEndpoint. The unrooted Windows
default stays \\.\pipe\sym-daemon; a rooted deployment (SYM_STATE_DIR) gets
a pipe of its own root, as its socket is under its root elsewhere.

Tests: the mapping and resolution for win32 and POSIX as pure functions
(fail on the previous code, which had none); SymDaemonClient's default and
an explicit path resolve to the daemon's endpoint (fails before on macOS
too: /tmp/sym.sock); daemon-relay-only now asserts relay-auth on its own,
that the relay leg starts before the IPC server, the endpoint the daemon
listens on, and that `sym status` reaches it through that endpoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
…till verifies

_handleCmbFetch rebuilt metadata from a fixed list of fields and dropped the v2.0 ones
(signatureSuite, addressScheme, createdByNodeId, application, assertionId), so a v2.0
record fetched from this node failed verification at the requester. The stored metadata
is now served whole, as a copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
…d marks kept apart

- K1: a key is marked seen only once its CMB has surfaced, which can be after an async
  SVAF pass, so a second copy arriving meanwhile (the same record via two peers, or a
  reconnect replay) passed the check and surfaced twice. A key is now held in flight from
  the check until its frame is finished with, and a copy arriving meanwhile is skipped.
  The SVAF chain returns its promise so the release happens exactly then.
- K3: directed assertion marks (key#sig:…) shared the broadcast map and its 10,000 cap,
  so a broadcast flood could evict them and let a replayed directed record surface again.
  They now have their own map and cap, persisted in the same file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
…ESTHOME)

Sandboxing HOME was a per-file convention (require tests/_isolate-home
first). The file that forgets it is exactly the one that leaks: it writes
identities, private keys, stores and locks into the real ~/.sym, where they
surface as phantom agents, and can read the real relay.env and its API key.
Nothing said so.

Node's test runner sets NODE_TEST_CONTEXT in every test process (children
inherit it). assertTestSandbox in lib/core/state-root.js, the module that
decides where state lives, now throws ETESTHOME when it is set and any of
these resolves outside fs.realpathSync(os.tmpdir()):

- os.homedir() (HOME; USERPROFILE on Windows), since several paths still
  derive from it directly
- the state root in effect, captured at load, so HOME sandboxed too late is
  caught
- SYM_IDENTITY_DIR when set, where the keypair goes

The message names the directories and says to sandbox HOME and USERPROFILE;
SYM_TEST_REAL_HOME=1 opts out. SymNode's constructor calls it before
anything is read or written, and the daemon before it reads relay.env,
migrates stores or builds its node.

Every existing test file passes under the guard: the files that construct a
node or start the daemon already sandbox (the 38 that do not load
_isolate-home were run under it, 393/393). New tests: in-process and child
process refusals for HOME, SYM_STATE_DIR and SYM_IDENTITY_DIR, nothing
written to the refused home, symlinked temp dirs accepted, the opt-out, no
check outside the runner, and the daemon refusing to start. 8 of 10 fail on
the previous code (the two that pass describe a sandboxed run and the
opt-out, which behave the same before and after).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
…bered

The confirming re-read before a mismatch reclaims a lock exists because a
remembered start time can outlive an unobserved pid reuse. `ps` reads are
never remembered, so on POSIX the second read repeated the first for
nothing; it now happens only on the remembered (Windows) path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
…, timestamps follow the clock

- K4: inbox()'s limit counted acked items, so a drain could return fewer unread deliveries
  than asked. It now counts unread; acked items still come back, marked.
- K5: the inbox `from` was the author's label whether or not anything proved it. It is now
  the author when the signature proved who that is (a v2.0 record verified against its signed
  node id, even when relayed, or a verified record its author delivered), and otherwise the
  peer that delivered it. The claim stays in author.name. A v2.0 record verified that way now
  carries author.nodeId.
- K6: the createdTimestamp ratchet restarted from zero in every process, and after a backward
  clock step it ran ahead of the clock indefinitely. It now starts from this node's newest
  stored record, and after a step of more than a minute it follows the clock again, with a
  log line and a clock-stepped-back metric.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
…y; no sends on dead sockets

- bin/sym-daemon.js built its own directory from os.homedir(), so a daemon rooted with
  SYM_STATE_DIR read and wrote its room, tasks and relay.env in the user's real ~/.sym. It
  now uses the same state root as every node.
- A relay-only daemon (SYM_RELAY_ONLY) has LAN discovery off, but it still published its room
  beacon on mDNS. It no longer does, and says so in the log.
- sendFrame counted a frame written to a destroyed or non-writable socket as sent, until
  'close' arrived (B-D6 residual). It returns false for those now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
…pec PR #17

sym integrates an admitted CMB as the identity, so every admission is the "collapsed
integration" case PR #17 adds to §15.5: the author's record is kept exactly as signed, the
receiver mints nothing, nothing is attributed to the receiver and no K→K self-edge is
written. The test pins that, so a future integration that changes the text (which §15.5
would then require to mint a receiver remix) fails loudly here instead of silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
…ws test debt, the daemon's log flood

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
The heuristic gate attached each category's receiver-local vector to the
records it read, in place. Its anchors are the store's own cached objects
(MemoryStore.anchors hands out entry.cmb), so every anchor the gate read kept
a vector on each category until the next persist of that entry, which is
routine (a peer remix citing it advances its lifecycle), and the vectors were
written to disk. A record carries no vector (§7.1). The incoming record was
mutated the same way.

localisedCategories now returns a new {text, vector} map built from text
alone, and anchors are measured through views holding only what
computeCategoryDrifts reads. A wire vector still never reaches the
measurement: a category with no text has no entry in the map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
The gate chose its encoder once, at its start (isSemanticReady), but named
each embedding-cache entry by kernelId() read at lookup time. The semantic
encoder loads asynchronously while a node runs, so a gate that started
lexical and was still encoding when it finished filed lexical vectors under
the semantic kernel's name, and every later gate was served them as semantic
vectors for as long as they stayed cached.

context-encoder gains svafKernel(): the kernel in force now, pinned, as one
value carrying its §15.8 id and an encoder bound to that same kernel.
kernelId() and encodeForSVAF() are defined through it. The gate pins one
kernel for its whole run and _localVector keys and encodes with it, so the
id in the key is the producer by construction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
…ntry

write() and receiveFromPeer() stored `{ ...cmb, lineage }`, copying the
store's expanded lineage (parents plus a locally computed ancestor closure)
onto the record as a top-level member. A two-section record has exactly
`categories` and `metadata` (§8.8.1) and its lineage is metadata.lineage, so
the extra member read as the pre-boundary shape: classifyAddress took its
parents as the record's, tried only the remix derivation, and reported every
stored v2 remix as a mismatch though the record was untouched.

The store now keeps the record as given; the entry already carries the same
lineage, which is where the closure belongs. Files written by earlier
versions hold the stapled member, so every read of a store file (first-touch
index build, load(), _load) drops it from two-section records. A pre-boundary
record keeps its top-level lineage, which on that shape is its own section.
classifyAddress was right for the shape it was given and is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV
sym-bot and others added 28 commits October 8, 2026 10:25
…iew R1, R2)

SECURITY.md, the CHANGELOG and WIRE §3: at most 200,000 statements held, and beyond that only
in-force revokes and in-force anchor-level statements, which the quotas bound; a dead or over-quota
revoke is an ordinary candidate; the file is compacted. Resolving is not charged to a lane but paced:
at settles, a pull's end decided at the next settle, one resolve per eviction pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
… protected by its kind

The mutation check of the R1 fix found one guard no test held: tier 2 protects in-force anchor-level
statements, not every live one. A removed anchor-level grant stays live while it is the chain of a
statement an endorse rescued; once a pass has dropped that statement (deeper, so first), the grant
goes too, while the anchor's in-force revoke and endorse stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
…per arrival; the slack said

The diff check of f26fc92 (SHIP), its four lows:
1. _writeFile writes the temp file, fsyncs it, renames it over the file and fsyncs the directory:
   a power loss leaves the old file or the new one, never an empty one (the lines kept from other
   pins cannot be fetched from a peer).
2. A crash's statements.jsonl.<pid>.tmp (of a process no longer alive) is removed when the store
   opens; a live process's is left alone.
3. A pass that compacts the file during an arrival has written that statement already: it is not
   appended again.
4. SECURITY.md, the CHANGELOG and WIRE §3 say that up to EVICT_SLACK (3,125 at the default bound)
   more can be held between passes, and that the protected floor is judged again at the next pass.

Tests (authority-attacks, both fail on f26fc92): no statement appears twice in the file after 600
arrivals across compactions; a rewrite fsyncs the temp file and the directory, and a stale temp
file is removed at open while a live process's stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
…eir schema, the sequence before the parse, Close codes close (S1, S2, S4; #23)

MMP 2.0 update 1, from the reviews of #26, #31 and #23 against 390b4af:

- S1: _sealRecord refuses a record whose signed room is not the session's room (AudienceError,
  'not-addressed'), as it refuses one signed to another node. The store is per node, not per room,
  so after a room change a fetch answer or an anchor replay could otherwise disclose an old room's
  record before the receiver refused it.
- S2: verifyRoomGrant checks the grant against room-join.schema.json before anything else: a closed
  object, lowercase UUID ids, integer timestamps. The payload signs decimal text, so string
  timestamps verified and the expiry timer (which needs a number) never armed: the grantee kept its
  session past expiry. signRoomGrant mints the schema's object only and refuses one that could never
  verify; the node sends grantForWire(grant), the schema's members only.
- S4: a sealed frame that opens advances the receive sequence; its inner frame or record is parsed
  only after the position is taken (openControlSealed / parseControlPlain, openEncryptedSealed /
  parseEncryptedPlain). Refusing what it carries changes nothing else; before, the counter stayed
  behind and the next frame closed the session as a gap.
- #23/§7.2: every sealed error whose action is Close (1001, 1003-1010) ends the session at the
  receiver; others are information. sendError takes only 1002 and 2xxx (never 1009, 1010, 1011, the
  other Close codes or 4xxx): EBADERRORCODE.
- Stale comments: sealed-control.js (error is sealed), room-grant.js (the session closes at expiry;
  the proving handshake is the connection path).

Tests: spec-update-session (each fails on 390b4af): a record for another room is never sealed or
served; string timestamps and extra members are refused, mint and wire carry the schema's members;
a refused inner frame and a refused record plaintext leave the session taking the next frame; a
sealed 1008 closes, a sealed 2001 does not; sendError refuses the protocol's codes. The grant tests
use schema-valid grants (lowercase UUID ids, a bound key).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
…ire grammar; equivocation relayed once; no root over a suffix (S3, D1, #27)

MMP 2.0 update 1 (#27, sym-attest-v1) and the review of it against 390b4af:

- S3: no attestation is signed about a directed record (a signed `to`) or a Legacy Import record,
  so none enters this node's chain or is gossiped: the room would otherwise learn a sealed one-to-one
  record's cognition key, assertion and verdict (a confirmation oracle on private content), and
  every receiver refused the Legacy Import ones anyway (no assertion id). A peer's attestation about
  a directed record this node holds is kept as evidence and never relayed.
- Grammar: the attestation object is closed (a member the schema does not define refuses it), with
  exactly seven verdicts; by and attester are lowercase UUIDs; a role is up to 64 characters (any
  role a §6.6.2 grant confers), a method up to 32.
- Equivocation: the conflicting checkpoint is relayed once, as evidence; a witness already signed
  stands; a third root is dropped before any work, as before.
- _attesterRole judges a scoped role on the record its assertionId names: a cognition key is shared
  by every record with the same categories, so one held under it but asserted by another judges no
  scope.
- D1 (until #27's chained construction lands): a checkpoint root is never signed over a suffix of
  this node's chain. Once its own early attestations are evicted, checkpoints pause, and the node
  says so once.
- The attestation-received doc comment no longer calls `method` unsigned.

Tests: spec-update-attest (each fails on 390b4af). gossip-lanes and admission-attestation use UUID
signers, and expect the conflicting copy relayed once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
…ndored (D2, #34, #37)

MMP 2.0 update 1 (meshcognition-website PR #43, spec/mmp-2.0-update-1 at 2660ab9), §8.8.5 step 1:
a relayer could reorder parents, respell a signed string in NFD or send lineage {parents: []} for
null, and the record still verified while nodes stored different bytes for one assertion
(records/probe-malleability.js). Now:

- a received record whose category text, createdBy or room is not NFC is refused, never
  normalised; createCMB mints all three NFC;
- lineage.parents and each meta.parents are held sorted bytewise, as they are signed, and minted
  as a sorted set (each reference once);
- a lineage with no parents is held, and minted, as null;
- the 256-character caps on createdBy, room and a parent reference count code points, as JSON
  Schema's maxLength does (a 200-emoji label was refused at 400 UTF-16 units);
- MAX_RECORD_BYTES is measured on the two-section record only: remember()'s decoded payload kept
  beside its own record was counted a second time at mint.

HELD, by the coordinator's word: the update also projects an absent application as null; that
rule is under review, so a null application is still dropped. The vector test pins exactly that
one member as the difference.

Vendored from 2660ab9 with their manifest digests in SOURCES.json: record-projection-v2,
record-size-v2, control-encrypted-v2, the cmb, control-frame, control-encrypted, cmb-fetch,
cmb-fetch-result, room-join, relay-frame, sym-attest-frame and encrypted-cmb-frame schemas, the
frame registry, and scripts/mmp/lib.mjs, the reference construction, which
tests/spec-update-vectors.test.js runs beside sym's code (projection, size measures, control AAD,
the never-inner list).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
…ealed ping; fetched records verified (D4, #26, #30, #23)

MMP 2.0 update 1 (meshcognition-website PR #43 at 2660ab9) and the founder's mood ruling:

- mood is { type, mood, context, timestamp }: mood 1 to 1,024 characters, context at most 4,096
  or null, counted in code points (lib/core/mood-frame). broadcastMood sends no from/fromName and
  throws EMOODFRAME rather than send an out-of-bounds frame. On receipt a frame carrying any other
  member (from, fromName, valence) is refused, and the event is labelled with the session's proven
  name, never a payload name.
- cmb-fetch carries no from and names one key (a keys array, or any member the schema does not
  define, is refused and counted); cmb-fetch-result carries no timestamp, and both are checked
  closed on receipt. A record is served only into a session of its own signed room. fetchCMB asks
  only for a cognition key.
- D4: a fetched record is put through the whole of §8.8.5 (author key by createdByNodeId,
  signature, assertion identity, audience). fetchCMB returns { verified: true, cmb, authorNodeId }
  only when it passes, and otherwise { verified: false, reason, categories } with no metadata, which
  the §15.8 audit may use for the tether. A record held under Legacy Import answers the same way. A
  non-v2.0 record answers nothing; an altered category is reported as a mismatch, apart from a
  malformed record.
- cmb-anchors: only a non-empty list starts the once-a-minute limit (an admission with nothing to
  replay no longer held back the next one's context); replay is limited to records this node
  authored in the session's room; a frame that is not the schema's (more than 50 keys, a non-key, an
  extra member) is refused whole.
- A clear 1011 prompts a re-handshake only on the client side, while the peer is present, when its
  detail names a session this node holds with that peer or a probe ping is outstanding, while no
  newer session is waiting to supersede, and no faster than the retry backoff (1 s doubling to 30 s).
- A sealed ping is answered with a pong, and a pong (clear or sealed) ends an outstanding probe.
- control-encrypted is checked as its schema bounds it: the members exactly, a 32-hex sessionId, a
  sequence of at most 29 digits, a sealed value at least as long as the tag.

Already as update 1 states, with no change: the server supersedes at admission (after room-join
in a gated room); a sealed error never completes a client's supersession (it never reaches the
frame path); 1009 is sent sealed and not retried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
… application.schema NFC

From the PR #43 verification (coordinator, 2026-10-08; the hold on #34's application rule lifted):

- An absent application projects to null (§8.8.4: both sign as lp("0")). Every published record
  spells it so, and encrypted-cmb-frame.schema.json requires the member: sym's sealed records with
  no application were schema-invalid (the reviewer reproduced it with buildEncryptedFrame). The
  record-projection-v2 vector now matches with no exception, byte for byte, as does lib.mjs's
  recordProjectionV2.
- One room grammar, §5.8's [a-z0-9._-] of 1 to 64 characters (lib/core/room-id): a node's own room
  (SymNode throws EBADROOM), the handshake hello, a record's signed room (received and minted), an
  attestation's room (was any text up to 256), and rooms.isValidRoom, which refused §5.8-valid
  dotted and underscored rooms (the spec's recommended acme.prod among them). `sym` stays refused and
  unownable while per-room service types exist (§5.1 migration). Being ASCII, a room is its own NFC
  form: the NFC tests for rooms become refusals.
- application.schema is signed as NFC(schema): minted NFC, and refused on receipt when it is not.
- legacyServiceType(room): the per-room DNS-SD type earlier releases used, only where it is a valid
  RFC 6335 service name (for the §5.1 migration browse).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
…mmp list, RFC 6763 TXT keys (#17, #22)

MMP 2.0 update 1, §5.1 (PR #43 at 2660ab9), and the relay review's ALIGN-SYM items:

- The daemon advertises every room on _sym._tcp with its name in TXT room, as the library already
  did; it advertised and browsed only _<room>._tcp, so a daemon node and a library node in one room
  never saw each other. It browses the per-room type earlier releases advertised as well
  (discoveryBrowseTypes), where that is a valid RFC 6335 service name, and never advertises it.
- A node dials only an advertisement whose TXT room is its own (absent reads as default), and
  whose mmp, a comma-separated version list, lists 2.0 (it compared mmp === '2.0').
- TXT keys are read from the raw strings as RFC 6763 §6.4 says: case-insensitively, the first
  occurrence winning, a string beginning with '=' ignored (bonjour-service's own object lets the
  last one win and keeps case).
- A discovery the host built advertises the node's own room (Discovery.setRoom): a caller's
  BonjourDiscovery advertised 'default' whatever room its node was in, which the TXT room filter
  would have turned into a partition.
- The loopback registry takes a registration under any type this node browses.
- The dns-sd code path (_startDnsSd, _resolvePeer) was unreachable since 0.3.72 and held the
  unanchored /mmp=(\S+)/ match the review found: removed.
- CLI and daemon messages say the §5.8 grammar and the one service type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
…socket; relay-auth lowercase (#25, #20 deferred)

MMP 2.0 update 1, §4.4.9 (PR #43 at 2660ab9), and the relay review:

- 4007 is reconnected with backoff like any close but 4004 and 4006. It belongs to relay-auth key
  proof (draft #20), deferred past 0.14.0 and not in MMP; its review found that "MUST NOT reconnect
  after 4007" makes a lockout of up to a week. It was a hard stop here.
- What a relay listed in relay-peers.features (fan-out) is forgotten when its socket closes: the
  next relay says its own. It was kept across connections.
- relay-auth carries the nodeId in its lowercase form (§3.1.1; a relay refuses another with 4002).

Already as update 1 states, now pinned by tests: 1001, 1011, 1013, 4005 and 4008 reconnect with
backoff (1009 is handled the same way: every code outside HARD_STOPS is).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
…n; only gated remixes count (D3, #35, #17)

MMP 2.0 update 1 (PR #43 at 2660ab9):

- D3 (#35): a receiver MUST NOT skip or refuse a record because its lineage cites the receiver's
  own records. The ingest echo skip is gone: a reply, an outcome or a grounding citing this node is
  verified, gated, stored and delivered like any record. The anti-echo rule sits at the remix
  trigger (MeshAgent: a record citing this node's own records is not remixed) and the remix gate.
- §15.5 (#17): the §15.8 tether does not apply to a collapsed integration. sym's gates keep the
  incoming text, so every integration collapses onto the author's record as signed: no tether is
  evaluated, the record is never stored as a severed root, and no tether attestation is signed about
  it. The retroactive audit applies the same rule: it judges only records this node minted, never a
  peer's record it holds. The §15.8 suites that pinned receiver-side severance now pin the
  exemption.
- #35 counters: remixProduced counts remixes minted through the gated remix path (remix()), not
  every record with parents; the decision log says whether a refused record cites parents (`cites`),
  never that it was a remix, which a receiver cannot know.
- Layer 6 is given no valence or arousal: unsigned (§8.8.4), dropped from Core Secure records by the
  projection and unverified on a Legacy Import record.
- xmesh-insight-v1 is not offered by default (it is registered nowhere; the registry lists
  xmesh-insight as a legacy type); a host adds it with opts.extraExtensions.

Reported, not changed: with the exemption, §15.8 runs nowhere in sym's receive path, and the
remixing side (remix()) has never evaluated the tether; see the round's report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
…ment ships; interop with 0.13.17

- CHANGELOG 0.14.0: a section for MMP 2.0 update 1 (PR #43 at 2660ab9), item by item, and
  "Interop with 0.13.17": LAN meshing with 0.13.17 stops except through Legacy Import routes, by
  design. The passages the round made stale are corrected in place (the mood frame, rooms in NFC,
  discovery, the counter rule, cmb-fetch's keys, xmesh-insight-v1, 4007, the B-L5/B-L6 tethers).
  The released 0.13.17 (2026-10-07, a relay fix) gets its own entry; the earlier, unreleased
  0.13.17 security candidate keeps its record under that name.
- SECURITY.md: the counter advances when a frame opens; sealed Close-action errors; the clear-1011
  conditions; the seal point's room check; the canonical projection and §5.8 rooms; fetched records
  attributed only after §8.8.5; the mood frame; the attestation rules; grant schema checks; 4007 an
  ordinary close; TXT room as a hint; and, under what it does not solve, that §15.8 applies to
  nothing sym receives (collapsed integrations) while remix() does not evaluate it, and the 0.13.17
  LAN break.
- docs/WIRE-0.14.0.md: rewritten against update 1. What the spec now defines is named by section;
  what remains is what sym chooses (anchor replay, 1011 pacing, the probe, sendError, the relay pace
  against the §19.1 floors).
- reference.md and SKILL.md: the §5.8 room grammar and the one service type.
- application.schema.json vendored (the record schema references it) with its manifest digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
…ion by range or prev (D1, #27); re-vendor 5417176

MMP 2.0 update 1 at 5417176 (PR #43 with its verification fixes; the chained checkpoint pending one
reviewer check):

- A checkpoint covers the attester's attestations since its previous one, seq fromSeq..uptoSeq,
  chained to that checkpoint's root (`genesis` for the first):
  root = SHA-256("mmp-attest-chain-v1\n" || lp(prev) || lp(fromSeq) || lp(uptoSeq) ||
  lp(segment root)); it signs fromSeq and prev. So this node holds only its last segment. A node
  that lost its segment (or, with no checkpoint held, seq 1) signs nothing over fewer: its chain ends,
  visibly (`checkpoint-chain-ended`), and no further checkpoint is signed under it. The segment and
  the checkpoint are appended to the logs before anything that depends on them is gossiped.
- A witness carries and signs fromSeq (after lp(NFC(room))). A witness naming another range or root
  than the checkpoint held at its position is refused and said (`disagrees`), never taken as the
  attester's equivocation: it is only the witness's word, and would let any witness frame an honest
  attester.
- Two checkpoints from one attester that are not the same checkpoint conflict when their ranges
  overlap or they name the same prev (the rule catches a fork cut at other boundaries). Duplicates are
  judged by content (range, prev, root). The link checks run before any signature: a reversed range,
  prev not genesis exactly at fromSeq 1, or a checkpoint that does not start right after the one its
  prev names, is malformed, never evidence. After a conflict this node never witnesses that attester
  again and drops its further checkpoints unverified (`attester-equivocated`), which bounds the
  evidence to one copy. The stale drop stays "older than every position held".
- The wire forms are the schema's closed objects (node-stats included); time and position are safe
  integers. Logs written before the update hold unchained checkpoints; they are read and kept as
  before, one per position, and reconcileChain checks either form.
- cmb-encrypted is bounded as encrypted-cmb-frame.schema.json now bounds it (sequence of at most 29
  digits, sealed 22 to 983,062 characters, a 32-hex sessionId).
- The relay pacer sends relay-auth and relay-pong ahead of every other frame but inside the bucket
  (the update's floors are 25/300; sym stays at 20/200): they went out at once whatever the bucket
  held.

Re-vendored from 5417176 with the manifest digests in SOURCES.json: sym-attest-v1.json (new),
record-projection-v2 (six new cases: an application-bearing record, 256 against 257 astral
characters, an NFD application.schema, a non-§5.8 room, an uppercase to), record-size-v2 (a new CJK
case), control-encrypted-v2, the cmb, encrypted-cmb-frame, control-frame, control-encrypted,
room-join, relay-frame and sym-attest-frame schemas, the registry, and lib.mjs. sym passes every case,
and lib.mjs's sym-attest constructions, conflict and link rules agree with sym's on all of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
…the changelog, SECURITY and WIRE

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
…guards removed

A mutation pass over the round's changes (36 mutants, then 7) killed every mutant but five:

- Removing the signature check of a fetched record survived: no test served a record whose key and
  assertion matched under a swapped signature. Now one does (D4: `bad-signature`, categories only).
- Four guards duplicated a rule that holds elsewhere, so no test could tell them apart from nothing,
  and they are gone: the remix trigger's own cites-this-node check (the trigger already keeps every
  record that cites a parent out of the remix cycle); the witness's equivocation check (only a
  checkpoint the store took is witnessed, and it takes none of an equivocating attester's); the
  tether's collapsed check (no gate evaluates a tether for a collapsed integration); and the fetch
  server's room check (the session's one seal point refuses a record of another room, and the
  answer lists it missing). The comments say where each rule now holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
…e range check at step 1 (#27 ruling)

The coordinator's ruling for #27, which the spec author is applying to PR #43:

- Witnesses are not equivocation evidence: they are not attester-signed. Only two conflicting
  attester-signed checkpoints are (as sym already held). A verified witness that names another range
  or root than the attester-signed checkpoint held at its position counts against the witness: it is
  refused (`disagrees`), said once (`witness-contradicts-checkpoint`), and that witness's further
  witnesses are dropped unverified for 10 minutes (`witness-muted`; the ruling's MAY).
- The link checks run in §6's order: uptoSeq >= fromSeq at step 1 (fromWireCheckpoint refuses a
  reversed range, as it already refused prev other than genesis at fromSeq 1), and the prev link to
  a held checkpoint after step 4 (after the duplicate drop, before any signature work), as before.

The hello's room already has the §5.8 grammar here (c0e615f).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
…tates them; re-vendor 0ab7b76

meshcognition-website PR #43 at 0ab7b76 ("Verify #43, round 2: witness evidence, link-check
placement, and the handshake room"):

- A witness is never equivocation evidence against the attester. One that names the range of an
  attester-signed checkpoint held, with another root, is evidence against the witness (`disagrees`:
  refused, said once, the witness muted for 10 minutes). One that overlaps a held checkpoint without
  matching it is a lead (`witness-lead`: refused and said, no mute). sym muted the witness for any
  mismatch at the held position, a different range included; a lead is not evidence against anyone.
  AttestationStore.witnessAssessment gives the three verdicts; the vector's witnessLeads and lib.mjs's
  attestWitnessAssessment agree with it.
- §6's order (the range at step 1, the prev link at step 5, after the duplicate drop): as sym does,
  except that sym checks the signature's spelling (step 3) after steps 4 and 5; all three discard
  with no state changed, so only the refusal's reason differs (said in WIRE-0.14.0.md).
- The hello's room has the §5.8 pattern in handshake.schema.json now, as sym already refuses.

Re-vendored from 0ab7b76: sym-attest-v1.json and lib.mjs (nothing else changed); SOURCES.json
records the head and the digests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
…stations are about room-bound records

The full suite and the integration suite at f3c1472 (under the heavy lock) found four tests that
still pinned what update 1 changed:

- node-stats: the stats object is closed (sym-attest-frame.schema.json), so a frame carrying a
  self-asserted name and nodeId is now refused, not read around; the test sends both.
- e2e-admission, e2e-attestation-gossip, e2e-checkpoint-witness (integration): they gated directed
  records, about which no attestation is signed since S3 (018206d; sym-attest-v1 §5.1). They gate
  room-bound records now; e2e-admission also pins that a directed record is attested by nobody, and
  e2e-checkpoint-witness that the first checkpoint is chained to genesis over 1..2.

handshake.schema.json is vendored from 0ab7b76 too (PR #43's final head, per the coordinator), and
one test pins that the hello, a record, a sealed record, a grant, relay-auth and sym-attest all name a
room by the §5.8 pattern sym uses, and the hello its nodeId in the lowercase spelling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
…root is minted as a fresh root

By the founder's ruling (0.14.0 is the breaking release): §15.8 runs where the spec puts it, on the
remixing node. node.remix() now resolves the remix's nearest verified lineage root from the parents
it cites and evaluates the remix's own text against it (content-only, one kernel, α-weighted against
Tguarded) before minting:

- faithful: minted with its lineage, a `tethered` mmp-tether-v1 attestation on the entry;
- drifted past the floor: minted as a fresh root, no parent from the severed chain, the departed
  source in provenance.tether.departedFrom, a `severed` attestation;
- no verified root in reach (or nothing evaluable): minted with its lineage, unverified, unsigned.

The §15.7 flag is consumed when the call begins (a concurrent remix() is refused) and given back when
nothing is minted (a duplicate, a collapse); remember() inside remix() no longer sets it. remix()
refuses opts.cmb. MeshAgent's remix cycle and the XMesh synthesis loop await it; createCMB and the
tether share one category-text rule (mintCategoryText). The receive path's collapsed integrations
stay exempt (§15.5), unchanged. CHANGELOG: under breaking, with before and after.

Test: tests/spec-update-remix-tether.test.js. Not done (coordinator: wrap up): the mutation check on
the new guard, and the downstream survey of node.remix() callers in mesh-channel and xmesh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRVMpaJkAA2fFbvuHCEzqH
…, §15.8)

The full suite at 15849b0 had one failure: "an ESIGN from the synthesis loop is said as the node
failing to sign" asserted synchronously, but remix() is asynchronous since 15849b0 and the loop
says the failure when it settles. The handler already contains it (a rejection is logged, never
left unhandled), so this is the test, not the lib. The test is async and awaits the settle;
withNode cleans up after an async callback settles instead of when it returns.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMk5HbMaz4oD7zoakvPyA6
…two redundant lines removed

A 28-mutant pass over remix()'s tether guard (skip the evaluation, always faithful, always severed,
unverifiable as faithful, no fresh root, a parent kept, no or wrong attestation, the verdict
reversed, the §15.7 flag not consumed or not given back, opts.cmb allowed, and others) left 11
survivors. Seven were real gaps and now have tests (tests/spec-update-remix-tether.test.js, 12 tests):

- the anchor and attestation are about the lineage ROOT, not the parent cited (a hop above the root);
- a severed remix citing several parents keeps none, and no category descends from anything;
- the signed drift is what a third party recomputes from the minted record and the root (a remix
  given only a focus is evaluated on what it was minted with);
- the verdict is on disk (a store rebuilt from the node's directory holds it);
- nothing evaluable (every category weighted out): lineage kept, unverified, nothing signed;
- lineageTether:false: no evaluation, no severance;
- opts.cmb is refused and the flag is not spent; a remix that fails to mint gives the flag back.

Two lines were redundant and are removed: `categoryParents: undefined` on a severed remix (remember()
never reads categoryParents; the several-parents test would catch it if it ever does), and the copy of
the verdict onto the returned entry (remember() returns the store's own entry, so it already carries
it; the same test reads it from the returned entry). One mutant stays: dropping the `parentKeys.length
> 0` early-out changes nothing observable (a record with no parents resolves to no root), so it is an
equivalent mutant, kept as a cheap shortcut.

A test that read the anchor through two records stored in one millisecond was flaky (the walk takes the
earliest-stored ancestor): it waits 5 ms between them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMk5HbMaz4oD7zoakvPyA6
…ted kernel; tests for the fetched-record and 1011 guards; three hardenings

F1 (Medium). remix()'s tether measured in whatever kernel happened to be loaded. The §9.2.1 reject
floor is calibrated on the semantic encoder (all-MiniLM-L6-v2); the lexical n-gram encoder that runs
until it loads, or in its place where the optional @huggingface/transformers dependency is missing,
measures drift on another scale, so a `tethered` or `severed` verdict, an attestation or a severance
made in it means nothing. remix() now awaits the calibrated kernel, at most `tetherKernelWaitMs`
(default 10 s). If it is unavailable by then the remix keeps its lineage, unverified: nothing is
evaluated, severed or signed, and a line is logged once (a `tether-kernel-unavailable` metric). A
verdict from the lexical kernel is never signed. auditLineageTethers follows the same rule and
leaves a chain unchecked.

F4. Tests that fail when each guard is removed: the clear-1011 server-role guard (the server side
never prompts a handshake), and the fetched record's assertion-identity, audience and signature
checks (one record each, the reviewer's f-fetched-verify.js as the model).

F7. Legacy Import's trySend refuses a record signed for another room, as the seal point does. The
relay client's own-frame queue (auth, pong) is bounded: a pong already waiting answers the next, and
at most 8 wait. A remix() that began before stop() mints nothing after it (`{ refused: 'node-stopped'
}`, the flag given back). Stale comments refreshed (lib/rooms.js, bin/sym-daemon.js), and the
"vouched" wording in the unresolvable-author message and comment now says what §6.6.9 does (an
in-force grant names the key).

Mutation check of the new guards (13 mutants: the kernel check, its bound, its said-once line, the
audit check, the stop guard, the 1011 server-role guard, the three fetched-record checks, the
legacy room check, the queue cap and the pong dedupe): all caught.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMk5HbMaz4oD7zoakvPyA6
… say what the code does; the tarball ships the docs it points to

F2. Every spec pin cites c07ff20, the merge of PR #43 into meshcognition-website main (the artifacts
are byte-identical to the 0ab7b76 ones vendored before): SOURCES.json, WIRE-0.14.0.md, the CHANGELOG
and the headers of the spec-update tests. "Pending merge" wording is gone.

F3. e2e-v2.json re-vendored from c07ff20 (80f6183e…, differing from the earlier copy only by a
`note`); the pin in tests/mmp-v2-vectors-published.test.js moved. The two tests that read it keep their
path, so the one copy is replaced in place.

F5. WIRE-0.14.0.md: a gap closes the session with a sealed 1010 on a relay session; on the LAN the
transport closes and nothing is sent.

F6. SECURITY.md: losing the attestation log while keeping the identity makes an honest attester look
like an equivocator to peers that kept its checkpoints; use a new identity after a wipe. And the
§15.8 paragraph (and the CHANGELOG's remix entry) now say that remix() and the audit measure in the
calibrated kernel only, and that without the optional dependency §15.8 is not enforced.

Release prep, short of the version: package.json "files" adds docs/AUTHORITY-OPERATOR.md,
docs/WIRE-0.14.0.md and CHANGELOG.md (the shipped SECURITY.md points to the two docs); npm pack
--dry-run lists 84 files, no tests. The EBADROOM note for a room like "My Team Room" moves from the
update-1 list to "Core Secure — breaking". "version" stays 0.13.17 and the heading "(unreleased)".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMk5HbMaz4oD7zoakvPyA6
Main-only: 2cf4dea and 3188621 (0.13.17: a re-announced relay peer keeps a live transport; a LAN
transport added to a relay-first peer is used), c10b45e, and the XMesh-in-prose naming commits
(977f32d and merges). Five files conflicted; each main hunk was read and decided:

- lib/node.js (4 hunks): taken from 0.14.0. All were the 0.13 peer table: the peer-with-no-transport
  skip in the two send loops (trySend(null, ...) already returns "send failed", counted as not reached,
  never thrown on), stop() closing every transport a peer holds (stop() closes every session, and every
  transport belongs to one), and _addPeer's detach-before-close and bestTransport recompute (_createPeer
  and _addPeer are gone; _admitSession and _supersede recompute the active transport before the old
  session is closed, and a session's close removes only itself).
- lib/frame-handler.js, bin/sym-daemon.js, lib/rooms.js: taken from 0.14.0; main's hunks were the
  XMesh spelling in comments the rewrite replaced (the spelling holds on the whole tree).
- CHANGELOG.md: the 0.14.0 entry kept; main's 0.13.17 text replaces the summary in the history section,
  with a note why 0.14.0 does not carry that code; the XMesh spelling applied to the Tenant-suffixed
  bullet; no duplicated headings.

Ported from main: its three 0.13.17 tests drove the 0.13 peer table (_createPeer, _addPeer), which
0.14.0 does not have. The first two were already run under Core Secure sessions in
tests/relay-reannounce-scenes.test.js (d2099e7); the third (stop() survives a peer without a transport)
is added there, with a room send past such a peer. tests/product-name.test.js (the naming rule) is
kept and passes. package.json and package-lock.json merged without conflict.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMk5HbMaz4oD7zoakvPyA6
The hello's implementation, node.version, status().version and the module's
version are read from package.json; a release cannot leave a literal behind.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMk5HbMaz4oD7zoakvPyA6
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMk5HbMaz4oD7zoakvPyA6
The ADMIT clause asserts the Admission Attestation on the stored entry. Since #27
(018206d) no attestation is signed about a directed record, so the driver's
{ to: nodeB.nodeId } record failed the clause on a correct runtime. The record is
now a room broadcast, which is attested. Dev tooling only: scripts/ is not in the
tarball, so the published artifact is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMk5HbMaz4oD7zoakvPyA6
@sym-bot
sym-bot merged commit 316534b into main Oct 13, 2026
0 of 2 checks passed
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 13, 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-13T18:04:43.975067Z 6519392 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.

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