From 6890b7a7abdfe59c5b3a4b095f397930c2a95417 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:25:40 +0100 Subject: [PATCH 001/148] wake channels: quiet on repeats, gossip cannot repoint a phone, channels 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/core/wake.js | 102 ++++++++++++++++++++++++- lib/frame-handler.js | 27 ++++--- lib/node.js | 9 +-- lib/relay.js | 44 +++++++---- tests/wake-channels.test.js | 145 ++++++++++++++++++++++++++++++++++++ 5 files changed, 290 insertions(+), 37 deletions(-) create mode 100644 tests/wake-channels.test.js diff --git a/lib/core/wake.js b/lib/core/wake.js index a0c6c7da..fd7875c7 100644 --- a/lib/core/wake.js +++ b/lib/core/wake.js @@ -25,6 +25,12 @@ function ensureDir(dir) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } +/** A wake channel nobody has seen first-hand for this long is dropped (30 days). */ +const WAKE_CHANNEL_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +/** How far each source of a wake channel is trusted. Gossip is unauthenticated. */ +const SOURCE_RANK = Object.freeze({ legacy: 0, gossip: 1, relay: 2, direct: 3 }); + /** * Manages wake channel persistence and peer wake notifications. */ @@ -56,6 +62,8 @@ class WakeManager { this._getMeshNode = opts.getMeshNode; this._getIdentity = opts.getIdentity; this._nodeName = opts.nodeName; + this._ttlMs = opts.wakeChannelTtlMs ?? WAKE_CHANNEL_TTL_MS; + this._now = opts.now || Date.now; this._apnsConfig = null; this._apnsKey = null; @@ -64,19 +72,26 @@ class WakeManager { /** * Load persisted peer wake channels from disk. * + * A channel saved before records carried `lastSeen` gets one TTL of grace from now: it may belong + * to a phone that is asleep and can only be reached through it, so it is not dropped on sight. + * * @returns {void} */ loadWakeChannels() { try { if (fs.existsSync(this._wakeChannelsFile)) { const data = JSON.parse(fs.readFileSync(this._wakeChannelsFile, 'utf8')); + const now = this._now(); for (const [id, ch] of Object.entries(data)) { if (ch?.platform && ch?.token) { - this._peerWakeChannels.set(id, ch); + const lastSeen = Number.isFinite(ch.lastSeen) ? Math.min(ch.lastSeen, now) : now; + this._peerWakeChannels.set(id, { ...ch, source: ch.source || 'legacy', lastSeen }); } } + const expired = this.pruneWakeChannels({ save: false }); + if (expired > 0) this.saveWakeChannels(); if (this._peerWakeChannels.size > 0) { - this._log(`Loaded ${this._peerWakeChannels.size} wake channel(s) from disk`); + this._log(`Loaded ${this._peerWakeChannels.size} wake channel(s) from disk${expired ? `, ${expired} expired` : ''}`); } } } catch (err) { @@ -84,6 +99,87 @@ class WakeManager { } } + /** + * Record a peer's wake channel, from one of three sources: + * - `direct`: the peer's own `wake-channel` frame, over its own connection; + * - `relay`: the relay's peer list, after relay-auth; + * - `gossip`: another peer's `peer-info`, which nothing authenticates. + * + * A weaker source never replaces a stronger one's token, so an unauthenticated `peer-info` cannot + * repoint a phone that told us its token itself. `lastSeen` is when someone last had first-hand + * evidence of the channel, never the time a copy of it was forwarded, so a channel nobody has seen + * ages out after the TTL however often it is gossiped. + * + * @param {string} nodeId + * @param {{platform:string, token?:string, environment?:string}} channel + * @param {{source:'direct'|'relay'|'gossip', lastSeen?:number}} opts + * @returns {'added'|'updated'|'refreshed'|'unchanged'|'ignored'} what changed; only the first + * three need saving. + */ + learnWakeChannel(nodeId, channel, { source, lastSeen } = {}) { + if (!nodeId || !channel?.platform || channel.platform === 'none') return 'ignored'; + const now = this._now(); + // The peer itself, or the relay that holds its registration, is first-hand evidence now; a + // gossiped copy is only as fresh as the sighting it carries. + const firstHand = source === 'direct' || (source === 'relay' && !Number.isFinite(lastSeen)); + const seen = firstHand ? now : Math.min(Number.isFinite(lastSeen) ? lastSeen : 0, now); + if (now - seen > this._ttlMs) return 'ignored'; + const next = { platform: channel.platform, token: channel.token, environment: channel.environment, source, lastSeen: seen }; + const prev = this._peerWakeChannels.get(nodeId); + if (!prev) { this._peerWakeChannels.set(nodeId, next); return 'added'; } + const sameToken = prev.platform === next.platform && prev.token === next.token && prev.environment === next.environment; + if (sameToken) { + // The same channel again: keep the stronger source and the later sighting. + const keepSource = (SOURCE_RANK[source] ?? 0) > (SOURCE_RANK[prev.source] ?? 0) ? source : prev.source; + const keepSeen = Math.max(seen, prev.lastSeen ?? 0); + if (keepSource === prev.source && keepSeen === prev.lastSeen) return 'unchanged'; + this._peerWakeChannels.set(nodeId, { ...prev, source: keepSource, lastSeen: keepSeen }); + return 'refreshed'; + } + // A different token: only a source at least as strong may replace it, and gossip must also be newer. + if ((SOURCE_RANK[source] ?? 0) < (SOURCE_RANK[prev.source] ?? 0)) return 'ignored'; + if (source === 'gossip' && seen <= (prev.lastSeen ?? 0)) return 'ignored'; + this._peerWakeChannels.set(nodeId, next); + return 'updated'; + } + + /** + * Drop channels nobody has seen for longer than the TTL. + * @param {{save?: boolean}} [opts] + * @returns {number} how many were dropped + */ + pruneWakeChannels({ save = true } = {}) { + const now = this._now(); + let dropped = 0; + for (const [id, ch] of this._peerWakeChannels) { + if (now - (ch.lastSeen ?? 0) > this._ttlMs) { this._peerWakeChannels.delete(id); dropped++; } + } + if (dropped > 0 && save) this.saveWakeChannels(); + return dropped; + } + + /** + * The entries a `peer-info` frame to `excludeId` should carry: live channels only, each with the + * lastSeen this node holds rather than the time of sending. + * @param {string} excludeId + * @param {(id:string) => string|undefined} nameOf + * @returns {Array<{nodeId:string, name:string, wakeChannel:object, lastSeen:number}>} + */ + gossipEntries(excludeId, nameOf = () => undefined) { + this.pruneWakeChannels(); + const out = []; + for (const [id, ch] of this._peerWakeChannels) { + if (id === excludeId) continue; + out.push({ + nodeId: id, + name: nameOf(id) || 'unknown', + wakeChannel: { platform: ch.platform, token: ch.token, environment: ch.environment }, + lastSeen: ch.lastSeen, + }); + } + return out; + } + /** * Persist peer wake channels to disk. * @@ -295,4 +391,4 @@ class WakeManager { } } -module.exports = { WakeManager }; +module.exports = { WakeManager, WAKE_CHANNEL_TTL_MS }; diff --git a/lib/frame-handler.js b/lib/frame-handler.js index a243d13a..b9358ad1 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -1372,13 +1372,10 @@ class FrameHandler { */ _handleWakeChannel(peerId, peerName, msg) { if (!msg.platform) return; - this._node._peerWakeChannels.set(peerId, { - platform: msg.platform, - token: msg.token, - environment: msg.environment, - }); - this._node._wakeManager.saveWakeChannels(); - this._node._log(`Wake channel from ${peerName}: ${msg.platform}`); + const wm = this._node._wakeManager; + const r = wm.learnWakeChannel(peerId, { platform: msg.platform, token: msg.token, environment: msg.environment }, { source: 'direct' }); + if (r === 'added' || r === 'updated' || r === 'refreshed') wm.saveWakeChannels(); + if (r === 'added' || r === 'updated') this._node._log(`Wake channel from ${peerName}: ${msg.platform}`); } /** @@ -1408,13 +1405,19 @@ class FrameHandler { */ _handlePeerInfo(peerId, peerName, msg) { if (!Array.isArray(msg.peers)) return; + // Every peer re-sends its whole list on every connect, so most entries are ones already held. + // Those change nothing and say nothing: logging and rewriting the file per entry per connect + // filled the daemon log at a flapping peer's reconnect rate. + const wm = this._node._wakeManager; + let learned = 0, changed = 0; for (const p of msg.peers) { - if (p.nodeId && p.wakeChannel && p.nodeId !== this._node._identity.nodeId) { - this._node._peerWakeChannels.set(p.nodeId, p.wakeChannel); - this._node._log(`Gossip from ${peerName}: learned wake channel for ${p.name}`); - } + if (!p || !p.nodeId || !p.wakeChannel || p.nodeId === this._node._identity.nodeId) continue; + const r = wm.learnWakeChannel(p.nodeId, p.wakeChannel, { source: 'gossip', lastSeen: p.lastSeen }); + if (r === 'added' || r === 'updated') learned++; + if (r === 'added' || r === 'updated' || r === 'refreshed') changed++; } - this._node._wakeManager.saveWakeChannels(); + if (changed > 0) wm.saveWakeChannels(); + if (learned > 0) this._node._log(`Gossip from ${peerName}: learned ${learned} wake channel(s)`); } /** diff --git a/lib/node.js b/lib/node.js index 607a20e3..b350a986 100644 --- a/lib/node.js +++ b/lib/node.js @@ -526,6 +526,7 @@ class SymNode extends EventEmitter { nodeName: this.name, peerWakeChannels: this._peerWakeChannels, saveWakeChannels: () => this._wakeManager.saveWakeChannels(), + learnWakeChannel: (id, ch, o) => this._wakeManager.learnWakeChannel(id, ch, o), }); // Frame handler — cliHostMode forwards frames without storing or SVAF. @@ -3014,13 +3015,7 @@ class SymNode extends EventEmitter { } // Send peer-info gossip - const knownPeers = []; - for (const [id, wc] of this._peerWakeChannels) { - if (id !== peer.peerId) { - const peerEntry = this._peers.get(id); - knownPeers.push({ nodeId: id, name: peerEntry?.name || 'unknown', wakeChannel: wc, lastSeen: Date.now() }); - } - } + const knownPeers = this._wakeManager.gossipEntries(peer.peerId, (id) => this._peers.get(id)?.name); if (knownPeers.length > 0) { peer.transport.send({ type: 'peer-info', peers: knownPeers }); } diff --git a/lib/relay.js b/lib/relay.js index abf6bf8f..3b5da25a 100644 --- a/lib/relay.js +++ b/lib/relay.js @@ -32,6 +32,7 @@ class RelayConnection { * @param {string} opts.nodeName — this node's name * @param {Map} opts.peerWakeChannels — shared peer wake channels Map * @param {function} opts.saveWakeChannels — () => void + * @param {function} opts.learnWakeChannel — (nodeId, channel, {source, lastSeen}) => outcome (WakeManager) * @param {function} [opts.onIdentityCollision] — ({nodeId, name, code}) => void — * called when the relay reports our nodeId is already held * by another connection. If not provided, the default @@ -66,6 +67,7 @@ class RelayConnection { this._nodeName = opts.nodeName; this._peerWakeChannels = opts.peerWakeChannels; this._saveWakeChannels = opts.saveWakeChannels; + this._learnWakeChannel = opts.learnWakeChannel; this._identityCollision = false; this._relayWs = null; @@ -174,6 +176,32 @@ class RelayConnection { * Open WebSocket connection to the relay and authenticate. * Auto-reconnects on disconnect with exponential backoff. */ + /** + * The relay's peer list, sent after a successful relay-auth (a refusal episode ends here). The + * relay re-sends the whole list on every (re)connect, so wake channels are learned quietly and + * what changed is said once. + * @private + */ + _handleRelayPeers(msg) { + this._authRefused = false; + this._refused = null; + this._attempts = 0; + this._setPhase('connected'); + let learned = 0, changed = 0; + for (const p of (msg.peers || [])) { + if (p.wakeChannel && p.wakeChannel.platform !== 'none') { + const r = this._learnWakeChannel(p.nodeId, p.wakeChannel, { source: 'relay', lastSeen: p.lastSeen }); + if (r === 'added' || r === 'updated') learned++; + if (r === 'added' || r === 'updated' || r === 'refreshed') changed++; + } + if (!p.offline) { + this._handleRelayPeerJoined(p.nodeId, p.name); + } + } + if (changed > 0) this._saveWakeChannels(); + if (learned > 0) this._log(`Relay: learned ${learned} wake channel(s)`); + } + /** * The auth frame names the ENGINE VERSION beside the identity. One field, no payload, no * confidentiality cost — and it turns "which engines ever reached path X through the relay" @@ -264,21 +292,7 @@ class RelayConnection { } else if (msg.type === 'relay-peer-left') { this._handleRelayPeerLeft(msg.nodeId, msg.name); } else if (msg.type === 'relay-peers') { - // The peer list is sent only after a successful relay-auth: a refusal episode ends here. - this._authRefused = false; - this._refused = null; - this._attempts = 0; - this._setPhase('connected'); - for (const p of (msg.peers || [])) { - if (p.wakeChannel && p.wakeChannel.platform !== 'none') { - this._peerWakeChannels.set(p.nodeId, p.wakeChannel); - this._log(`Gossip: learned wake channel for ${p.name} (${p.wakeChannel.platform})`); - } - if (!p.offline) { - this._handleRelayPeerJoined(p.nodeId, p.name); - } - } - this._saveWakeChannels(); + this._handleRelayPeers(msg); } else if (msg.type === 'relay-ping') { ws.send(JSON.stringify({ type: 'relay-pong' })); } else if (msg.type === 'relay-reauth') { diff --git a/tests/wake-channels.test.js b/tests/wake-channels.test.js new file mode 100644 index 00000000..7f89e091 --- /dev/null +++ b/tests/wake-channels.test.js @@ -0,0 +1,145 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME to a temp sandbox before lib/config loads + +/** + * Wake channels: learned from the peer itself, the relay's list, or another peer's `peer-info` + * gossip, which nothing authenticates. Every peer re-sends its whole list on every connect, so a + * repeat must change nothing and log nothing; gossip must never repoint a phone's own token; and a + * channel nobody has seen first-hand ages out however often it is forwarded. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const { SymNode } = require('../lib/node'); +const { NullDiscovery } = require('../lib/discovery'); +const { nodeDir } = require('../lib/config'); +const { WAKE_CHANNEL_TTL_MS } = require('../lib/core/wake'); + +const DAY = 24 * 60 * 60 * 1000; +const apns = (token) => ({ platform: 'apns', token, environment: 'sandbox' }); + +function withNode(fn) { + const name = `wake-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node = new SymNode({ name, silent: true, discovery: new NullDiscovery() }); + const lines = []; + node._log = (m) => lines.push(m); + node._wakeManager._log = (m) => lines.push(m); + let writes = 0; + const save = node._wakeManager.saveWakeChannels.bind(node._wakeManager); + node._wakeManager.saveWakeChannels = () => { writes++; save(); }; + try { return fn(node, { lines, writes: () => writes }); } finally { fs.rmSync(nodeDir(name), { recursive: true, force: true }); } +} + +describe('peer-info gossip', () => { + it('learns new channels with one line per frame, and a repeat of the same list is silent', () => { + withNode((node, io) => { + const now = Date.now(); + const frame = { type: 'peer-info', peers: [ + { nodeId: 'phone-1', name: 'unknown', wakeChannel: apns('t1'), lastSeen: now - 1000 }, + { nodeId: 'phone-2', name: 'unknown', wakeChannel: apns('t2'), lastSeen: now - 2000 }, + ] }; + node._frameHandler._handlePeerInfo('peer-x', 'xmesh', frame); + assert.deepStrictEqual(io.lines.filter((l) => /Gossip/.test(l)), ['Gossip from xmesh: learned 2 wake channel(s)']); + assert.strictEqual(io.writes(), 1); + for (let i = 0; i < 50; i++) node._frameHandler._handlePeerInfo('peer-x', 'xmesh', frame); + assert.strictEqual(io.lines.filter((l) => /Gossip/.test(l)).length, 1, 'a reconnect storm logs nothing more'); + assert.strictEqual(io.writes(), 1, 'and writes nothing more'); + assert.strictEqual(node._peerWakeChannels.get('phone-1').lastSeen, now - 1000, 'lastSeen is the sighting the frame carried'); + }); + }); + + it('never replaces a token the phone gave us itself', () => { + withNode((node) => { + node._frameHandler._handleWakeChannel('phone-1', 'melotune', apns('own-token')); + node._frameHandler._handlePeerInfo('peer-x', 'mallory', { peers: [{ nodeId: 'phone-1', wakeChannel: apns('attacker'), lastSeen: Date.now() }] }); + assert.strictEqual(node._peerWakeChannels.get('phone-1').token, 'own-token'); + assert.strictEqual(node._peerWakeChannels.get('phone-1').source, 'direct'); + }); + }); + + it('replaces an older gossiped token only with a newer sighting', () => { + withNode((node) => { + const now = Date.now(); + const wm = node._wakeManager; + assert.strictEqual(wm.learnWakeChannel('phone-1', apns('old'), { source: 'gossip', lastSeen: now - 5000 }), 'added'); + assert.strictEqual(wm.learnWakeChannel('phone-1', apns('stale'), { source: 'gossip', lastSeen: now - 9000 }), 'ignored'); + assert.strictEqual(wm.learnWakeChannel('phone-1', apns('fresh'), { source: 'gossip', lastSeen: now - 100 }), 'updated'); + assert.strictEqual(node._peerWakeChannels.get('phone-1').token, 'fresh'); + }); + }); + + it('ignores a sighting older than the TTL and a frame entry with no sighting', () => { + withNode((node) => { + const wm = node._wakeManager; + assert.strictEqual(wm.learnWakeChannel('phone-1', apns('t'), { source: 'gossip', lastSeen: Date.now() - WAKE_CHANNEL_TTL_MS - DAY }), 'ignored'); + assert.strictEqual(wm.learnWakeChannel('phone-2', apns('t'), { source: 'gossip' }), 'ignored', 'no lastSeen means no evidence'); + assert.strictEqual(node._peerWakeChannels.size, 0); + }); + }); +}); + +describe('gossip sent and expiry', () => { + it('forwards the lastSeen it holds, not the time of sending, and drops expired channels', () => { + withNode((node) => { + const wm = node._wakeManager; + const now = Date.now(); + wm.learnWakeChannel('phone-1', apns('a'), { source: 'gossip', lastSeen: now - 3 * DAY }); + wm.learnWakeChannel('phone-2', apns('b'), { source: 'gossip', lastSeen: now - 1000 }); + let t = now; + wm._now = () => t; + const out = wm.gossipEntries('peer-x'); + assert.deepStrictEqual(out.map((e) => [e.nodeId, e.lastSeen]).sort(), [['phone-1', now - 3 * DAY], ['phone-2', now - 1000]]); + t = now + WAKE_CHANNEL_TTL_MS - 2 * DAY; // phone-1 is now past the TTL, phone-2 is not + assert.deepStrictEqual(wm.gossipEntries('peer-x').map((e) => e.nodeId), ['phone-2']); + assert.strictEqual(node._peerWakeChannels.has('phone-1'), false, 'the expired channel is gone from memory too'); + }); + }); + + it('gives a channel saved before lastSeen existed one TTL of grace, once', () => { + withNode((node) => { + const wm = node._wakeManager; + fs.mkdirSync(require('path').dirname(wm._wakeChannelsFile), { recursive: true }); + fs.writeFileSync(wm._wakeChannelsFile, JSON.stringify({ 'old-phone': apns('legacy') })); + node._peerWakeChannels.clear(); + const loadedAt = Date.now(); + wm._now = () => loadedAt; + wm.loadWakeChannels(); + assert.strictEqual(node._peerWakeChannels.get('old-phone').lastSeen, loadedAt); + wm.saveWakeChannels(); + node._peerWakeChannels.clear(); + wm._now = () => loadedAt + WAKE_CHANNEL_TTL_MS + DAY; + wm.loadWakeChannels(); + assert.strictEqual(node._peerWakeChannels.has('old-phone'), false, 'the grace is not renewed by a later load'); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(wm._wakeChannelsFile, 'utf8')), {}, 'and the file is cleaned'); + }); + }); +}); + +describe('relay peer list', () => { + it('learns from the relay with one line per list, silent on a repeat', () => { + withNode((node, io) => { + const relayLines = []; + node._relay._log = (m) => relayLines.push(m); + node._relay._setPhase = () => {}; + const list = { type: 'relay-peers', peers: [ + { nodeId: 'phone-1', name: 'melotune', wakeChannel: apns('r1'), offline: true }, + { nodeId: 'phone-2', name: 'melomove', wakeChannel: apns('r2'), offline: true }, + ] }; + node._relay._handleRelayPeers(list); + node._relay._handleRelayPeers(list); + assert.deepStrictEqual(relayLines.filter((l) => /wake channel/.test(l)), ['Relay: learned 2 wake channel(s)']); + assert.strictEqual(io.writes(), 1, 'one write for the first list, none for the repeat'); + assert.strictEqual(node._peerWakeChannels.get('phone-1').source, 'relay'); + }); + }); + + it('a gossiped token cannot repoint a channel the relay holds', () => { + withNode((node) => { + node._relay._learnWakeChannel('phone-1', apns('relay-token'), { source: 'relay' }); + node._frameHandler._handlePeerInfo('peer-x', 'mallory', { peers: [{ nodeId: 'phone-1', wakeChannel: apns('attacker'), lastSeen: Date.now() }] }); + assert.strictEqual(node._peerWakeChannels.get('phone-1').token, 'relay-token'); + }); + }); +}); From 83cf3d587304bda0f6f154c6bacd7755856f5d15 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:26:50 +0100 Subject: [PATCH 002/148] directed de-dup: key on the decoded signature, so a relay cannot replay 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/frame-handler.js | 22 ++++++++++++++++--- tests/audit-delivery-authorship.test.js | 29 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/lib/frame-handler.js b/lib/frame-handler.js index b9358ad1..73781aa2 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -1,5 +1,7 @@ 'use strict'; +const crypto = require('crypto'); + const { recordKey, recordLineage, recordParents, recordCreatedBy, setRecordLineage } = require('./record'); /** @@ -39,6 +41,17 @@ const { encode, DIM, processHeuristicSVAF, computeCategoryVerdicts, decryptCateg // disambiguates itself with a salt (mesh-channel send path). The TTL must exceed the slowest // sender's replay horizon, and the spool's is unbounded — seven days covers every horizon // observed while the size cap still bounds the cache. +/** + * A stable identity for a signature: sha256 of its decoded bytes, or null when there are none. + * @param {string} sig base64url + * @returns {string|null} + */ +function signatureMark(sig) { + if (typeof sig !== 'string' || !sig) return null; + const bytes = Buffer.from(sig, 'base64url'); + return bytes.length ? `sig:${crypto.createHash('sha256').update(bytes).digest('hex')}` : null; +} + const SEEN_CMB_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days const SEEN_CMB_MAX = 10000; @@ -797,10 +810,13 @@ class FrameHandler { // new signed send of the same words, or words already seen as a broadcast, surfaces. Nothing // unauthenticated widens the key — a frame timestamp or an unsigned createdTimestamp is the // sender's choice, and would let one peer surface the same content without bound (review F2). + // + // The mark is the hash of the decoded signature bytes, never a string a relay can re-spell: + // base64url decoding ignores padding and the unused bits of the last character, so `sig + '='` + // or a twin final character verifies as the same signature, and a carried `assertionId` is + // covered by no signature at all. Ed25519 is deterministic, so one assertion has one signature. const cognitionKey = recordKey(msg.cmb) || msg.key; - const assertionMark = msg._cmbVerified === true - ? (msg.cmb?.metadata?.assertionId || msg.cmb?.metadata?.sig || null) - : null; + const assertionMark = msg._cmbVerified === true ? signatureMark(msg.cmb?.metadata?.sig) : null; const incomingKey = cognitionKey && msg._directedToMe && assertionMark ? `${cognitionKey}#${assertionMark}` : cognitionKey; diff --git a/tests/audit-delivery-authorship.test.js b/tests/audit-delivery-authorship.test.js index d50bac74..53acb4a0 100644 --- a/tests/audit-delivery-authorship.test.js +++ b/tests/audit-delivery-authorship.test.js @@ -194,6 +194,35 @@ describe('directed delivery (MMP §9.2.2, §8.8.2)', () => { }); }); + it('N1: a relay cannot replay a signed directed record by re-spelling its signature or adding an assertionId', async () => { + await withNode('d2-respell', async (node) => { + node._pinPeerKey('peerA', PEER_A.pub); + node._svafEvaluator.evaluate = async () => REJECTED; + const seen = collect(node); + const f = directed(node, signed(mkCmb('approve the release', { to: node.nodeId }))); + const sig = f.cmb.metadata.sig; + // The same 64 signature bytes, spelled three other ways base64url decoding accepts. + const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + const last = B64.indexOf(sig[sig.length - 1]); + const twin = B64[(last & 0b110000) | ((last + 1) & 0b001111)]; // same top 2 data bits, other padding bits + assert.deepStrictEqual(Buffer.from(sig.slice(0, -1) + twin, 'base64url'), Buffer.from(sig, 'base64url')); + const variants = [ + (c) => { c.cmb.metadata.sig = sig + '='; }, + (c) => { c.cmb.metadata.sig = sig.slice(0, -1) + twin; }, + (c) => { c.cmb.metadata.assertionId = 'asrt-0000000000000000000000000000000000000000000000000000000000000000'; }, + ]; + node._frameHandler.handle('peerA', 'peerA', JSON.parse(JSON.stringify(f))); + await settle(); + for (const v of variants) { + const copy = JSON.parse(JSON.stringify(f)); + v(copy); + node._frameHandler.handle('peerA', 'peerA', copy); + await settle(); + } + assert.strictEqual(seen.accepted.length, 1, 'every re-spelling is the same assertion'); + }); + }); + it('F2: an unverified directed CMB cannot widen its de-duplication key with unsigned fields', async () => { await withNode('d2-unsigned', async (node) => { node._svafEvaluator.evaluate = async () => REJECTED; From 80a7d905e54fead8c0ecaee8487f9f50e9d73463 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:28:09 +0100 Subject: [PATCH 003/148] remember(): a record that cannot be signed is refused, not sent unsigned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- bin/sym-daemon.js | 7 +++-- lib/node.js | 32 +++++++++++++++------- tests/sign-fail-closed.test.js | 50 ++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 tests/sign-fail-closed.test.js diff --git a/bin/sym-daemon.js b/bin/sym-daemon.js index ffe01a04..7bc689b9 100755 --- a/bin/sym-daemon.js +++ b/bin/sym-daemon.js @@ -228,10 +228,13 @@ function startIPCServer() { const line = buffer.slice(0, idx); buffer = buffer.slice(idx + 1); if (line.trim()) { + let msg; + try { msg = JSON.parse(line); } catch (err) { log(`IPC parse error: ${err.message}`); continue; } try { - handleIPCMessage(socketId, socket, JSON.parse(line)); + handleIPCMessage(socketId, socket, msg); } catch (err) { - log(`IPC parse error: ${err.message}`); + log(`IPC ${msg && msg.type ? `'${msg.type}'` : 'message'} failed: ${err.message}`); + if (msg && msg.type) sendIPC(socket, { type: 'result', action: msg.type, error: err.message, code: err.code }); } } } diff --git a/lib/node.js b/lib/node.js index b350a986..21c5602b 100644 --- a/lib/node.js +++ b/lib/node.js @@ -1979,11 +1979,9 @@ class SymNode extends EventEmitter { // Authenticate the CMB with this node's Ed25519 identity key. Receivers // verify the signature against the public key we announce in the // handshake and reject any CMB that is forged or tampered (MMP §8.3). - try { - signCMB(opts.cmb, this._identity.privateKey); - } catch (err) { - this._log(`CMB signing failed: ${err.message}`); - } + // A record that cannot be signed is not sent or stored unsigned (§18.3.1): a node whose key is + // broken must say so, not quietly emit records every verifying peer will refuse. + this._signOrThrow(opts.cmb, 'CMB signing failed'); } // §7.5 COLLAPSE-BEFORE-MINT [MUST] — AC-2.4, the emit-side half of the Rule A self-loop. // @@ -2007,11 +2005,7 @@ class SymNode extends EventEmitter { if (builtHere && opts.cmb.metadata) { opts.cmb.metadata.lineage = null; if (MMP_EMIT_V2) opts.cmb.metadata.assertionId = assertionIdV2_0(opts.cmb); - try { - signCMB(opts.cmb, this._identity.privateKey); - } catch (err) { - this._log(`CMB re-signing after collapse failed: ${err.message}`); - } + this._signOrThrow(opts.cmb, 'CMB re-signing after collapse failed'); } this._metrics.collapsed = (this._metrics.collapsed ?? 0) + 1; this.emit('metric', { type: 'cmb-collapsed', key: mintKey, reason: 'identical-to-own-head' }); @@ -2163,6 +2157,24 @@ class SymNode extends EventEmitter { return entry; } + /** + * Sign a record with this node's identity key, or throw ESIGN. Nothing is stored or dispatched + * before this runs, so a failure leaves no unsigned copy anywhere. + * @private + */ + _signOrThrow(cmb, what) { + try { + signCMB(cmb, this._identity.privateKey); + } catch (err) { + this._log(`${what}: ${err.message} — nothing stored or sent`); + this.emit('metric', { type: 'cmb-signing-failed', reason: err.message }); + const e = new Error(`${what}: ${err.message}`); + e.code = 'ESIGN'; + e.cause = err; + throw e; + } + } + /** * Search local mesh memory by keyword. * See MMP v0.2.0 Section 6 (Memory). diff --git a/tests/sign-fail-closed.test.js b/tests/sign-fail-closed.test.js new file mode 100644 index 00000000..d2cc3482 --- /dev/null +++ b/tests/sign-fail-closed.test.js @@ -0,0 +1,50 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME to a temp sandbox before lib/config loads + +/** + * §18.3.1: every Core Secure CMB MUST be signed. A node that cannot sign used to log the failure and + * then store and broadcast the record unsigned; now remember() throws ESIGN before anything is + * stored or dispatched. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const { SymNode } = require('../lib/node'); +const { NullDiscovery } = require('../lib/discovery'); +const { nodeDir } = require('../lib/config'); + +function withNode(fn) { + const name = `signfail-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node = new SymNode({ name, silent: true, discovery: new NullDiscovery() }); + try { return fn(node); } finally { fs.rmSync(nodeDir(name), { recursive: true, force: true }); } +} + +describe('a record that cannot be signed', () => { + it('throws ESIGN, and nothing is stored or sent', () => { + withNode((node) => { + const sent = []; + node._peers.set('peer-1', { peerId: 'peer-1', name: 'peer', transport: { send: (f) => { sent.push(f); return true; } } }); + const metrics = []; + node.on('metric', (m) => metrics.push(m)); + const before = node._store.count ? node._store.count() : node.recall('').length; + node._identity.privateKey = 'not-a-key'; + assert.throws(() => node.remember({ focus: 'unsignable' }), (e) => e.code === 'ESIGN'); + const after = node._store.count ? node._store.count() : node.recall('').length; + assert.strictEqual(after, before, 'nothing stored'); + assert.strictEqual(sent.filter((f) => f.type === 'cmb').length, 0, 'nothing sent'); + assert.ok(metrics.some((m) => m.type === 'cmb-signing-failed')); + }); + }); + + it('a node with a working key still signs and sends', () => { + withNode((node) => { + const sent = []; + node._peers.set('peer-1', { peerId: 'peer-1', name: 'peer', transport: { send: (f) => { sent.push(f); return true; } } }); + const entry = node.remember({ focus: 'signable' }); + assert.ok(entry?.cmb?.metadata?.sig, 'signed'); + assert.strictEqual(sent.filter((f) => f.type === 'cmb').length, 1); + }); + }); +}); From 741895a188ac0da043779d611e36342e342fd616 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:32:39 +0100 Subject: [PATCH 004/148] handshake + AAD: NFC-normalize the room and names, as the reference does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/core/e2e-v2.js | 6 +++++- lib/core/handshake-v2.js | 10 ++++++++-- tests/mmp-v2-e2e.test.js | 30 ++++++++++++++++++++++++++++++ tests/mmp-v2-handshake.test.js | 30 ++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/lib/core/e2e-v2.js b/lib/core/e2e-v2.js index b6e9b0e0..2ce72ca3 100644 --- a/lib/core/e2e-v2.js +++ b/lib/core/e2e-v2.js @@ -23,6 +23,10 @@ const { lp } = require('./cmb-encoder'); * The AEAD associated data (mmp-aead-aad-v2), byte-exact with the published contract. Binds the * ciphertext to exactly one signed record and one position in the session, so a valid ciphertext * cannot be moved to another record, direction, or sequence. + * + * The room is NFC-normalized, as the published reference does and as the record signature + * already does: the same visible room name can arrive composed or decomposed, and an AAD built + * from the other form fails authentication on a frame that was sealed correctly. */ function aeadAADv2({ protocolVersion, sessionId, direction, sequence, key, assertionId, createdByNodeId, room, to }) { return Buffer.concat([ @@ -34,7 +38,7 @@ function aeadAADv2({ protocolVersion, sessionId, direction, sequence, key, asser lp(String(key)), lp(String(assertionId)), lp(String(createdByNodeId)), - lp(String(room ?? '')), + lp(String(room ?? '').normalize('NFC')), lp(String(to ?? '')), ]); } diff --git a/lib/core/handshake-v2.js b/lib/core/handshake-v2.js index 37da8706..4269709d 100644 --- a/lib/core/handshake-v2.js +++ b/lib/core/handshake-v2.js @@ -47,6 +47,12 @@ const dec = (n) => String(n >>> 0); * Each party carries its own `nonce` (as it does on its hello frame); both nonces are emitted, * client then server, before the party blocks. * + * The room and both names are NFC-normalized, as the published reference does. They are the + * human-typed values in the transcript, and the same visible text can arrive as different code + * points: macOS input and file names tend to produce decomposed (NFD) forms, most other systems + * composed (NFC). Without normalization two peers that agree on the room "café" would hash + * different transcripts, and every proof between them would fail as if one were an impostor. + * * @param {object} o * @param {string} o.protocolVersion * @param {string} o.room @@ -64,7 +70,7 @@ function buildTranscript({ protocolVersion, room, client, server, selectedExtens lp(String(p.nodeId)), lp(String(p.identityPublicKey)), lp(String(p.e2ePublicKey)), - lp(String(p.name)), + lp(String(p.name).normalize('NFC')), lp(String(p.implementation && p.implementation.name)), lp(String(p.implementation && p.implementation.version)), lp(dec(exts.length)), @@ -75,7 +81,7 @@ function buildTranscript({ protocolVersion, room, client, server, selectedExtens return Buffer.concat([ Buffer.from(TRANSCRIPT_DOMAIN, 'utf8'), lp(String(protocolVersion)), - lp(String(room)), + lp(String(room).normalize('NFC')), lp(String(clientNonce)), lp(String(serverNonce)), ...party(client), diff --git a/tests/mmp-v2-e2e.test.js b/tests/mmp-v2-e2e.test.js index 869b149e..b0048c39 100644 --- a/tests/mmp-v2-e2e.test.js +++ b/tests/mmp-v2-e2e.test.js @@ -46,3 +46,33 @@ describe('MMP v2.0 E2E AEAD conformance (X25519-HKDF-SHA256-ChaCha20-Poly1305)', }); } }); + +// The AAD binds the record's room. The same visible room name can arrive composed or +// decomposed; the reference NFC-normalizes it (as the record signature does), so a frame sealed +// by a peer that typed the other form still authenticates. +describe('MMP v2.0 AAD: the room is NFC-normalized', () => { + const m = vec.metadata; + const c = vec.cases[0]; + const aadFor = (room) => aeadAADv2({ + protocolVersion: vec.protocolVersion, sessionId: vec.sessionId, + direction: c.direction, sequence: c.sequence, + key: m.key, assertionId: m.assertionId, createdByNodeId: m.createdByNodeId, room, to: m.to, + }); + + it('a decomposed room produces the same AAD bytes as the composed one', () => { + const NFC = 'café-rööm'; + const NFD = 'café-rööm'; + assert.notStrictEqual(NFC, NFD); + assert.strictEqual(aadFor(NFD).toString('hex'), aadFor(NFC).toString('hex')); + assert.ok(aadFor(NFD).includes(Buffer.from(NFC, 'utf8')), 'the AAD carries the composed form'); + }); + + it('so a frame sealed under the composed room opens under the decomposed one', () => { + const NFC = 'café-room'; + const NFD = 'café-room'; + const key = Buffer.from(c.trafficKeyHex, 'hex'); + const nonce = Buffer.from(c.nonceHex, 'hex'); + const sealed = sealV2(key, nonce, aadFor(NFC), Buffer.from('x')); + assert.strictEqual(openV2(key, nonce, aadFor(NFD), sealed).toString(), 'x'); + }); +}); diff --git a/tests/mmp-v2-handshake.test.js b/tests/mmp-v2-handshake.test.js index 28ddeb86..7aa6a39e 100644 --- a/tests/mmp-v2-handshake.test.js +++ b/tests/mmp-v2-handshake.test.js @@ -123,3 +123,33 @@ describe('MMP v2.0 transcript construction + extension binding', () => { assert.ok(buildTranscript(withExt).includes(Buffer.from(EXT_CMB_ENCRYPTED_V2, 'utf8'))); }); }); + +// The room and the two names are the human-typed values in the transcript, and the same visible +// text arrives as different code points depending on where it was typed (macOS tends to NFD, +// most other systems NFC). The reference NFC-normalizes them; a transcript that did not would +// make two honest peers hash different bytes and reject each other's proofs. +describe('MMP v2.0 transcript: room and names are NFC-normalized', () => { + const NFC = 'café-rööm'; // composed é, ö + const NFD = 'café-rööm'; // e + combining acute, o + combining diaeresis + const withText = (room, clientName, serverName) => { + const h = JSON.parse(JSON.stringify(vec.fixture.handshake)); + h.room = room; + h.client.name = clientName; + h.server.name = serverName; + return h; + }; + + it('precondition: the two spellings are different code points for the same text', () => { + assert.notStrictEqual(NFC, NFD); + assert.strictEqual(NFD.normalize('NFC'), NFC); + }); + + it('a decomposed room or name produces the same transcript bytes as the composed one', () => { + const composed = buildTranscript(withText(NFC, NFC, NFC)); + assert.strictEqual(buildTranscript(withText(NFD, NFC, NFC)).toString('hex'), composed.toString('hex'), 'room'); + assert.strictEqual(buildTranscript(withText(NFC, NFD, NFC)).toString('hex'), composed.toString('hex'), 'client name'); + assert.strictEqual(buildTranscript(withText(NFC, NFC, NFD)).toString('hex'), composed.toString('hex'), 'server name'); + assert.ok(composed.includes(Buffer.from(NFC, 'utf8')), 'the transcript carries the composed form'); + assert.ok(!buildTranscript(withText(NFD, NFD, NFD)).includes(Buffer.from(NFD, 'utf8')), 'never the decomposed one'); + }); +}); From 628567988f444a178bbfd4394aa59aaa0595bb0c Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:33:45 +0100 Subject: [PATCH 005/148] wake channels: persist a repeat sighting only once it moves lastSeen 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/core/wake.js | 6 ++++++ tests/wake-channels.test.js | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/lib/core/wake.js b/lib/core/wake.js index fd7875c7..fe3ff1c1 100644 --- a/lib/core/wake.js +++ b/lib/core/wake.js @@ -28,6 +28,9 @@ function ensureDir(dir) { /** A wake channel nobody has seen first-hand for this long is dropped (30 days). */ const WAKE_CHANNEL_TTL_MS = 30 * 24 * 60 * 60 * 1000; +/** A newer sighting of the same channel is worth persisting once it moves lastSeen by this much. */ +const REFRESH_GRANULARITY_MS = 60 * 60 * 1000; + /** How far each source of a wake channel is trusted. Gossip is unauthenticated. */ const SOURCE_RANK = Object.freeze({ legacy: 0, gossip: 1, relay: 2, direct: 3 }); @@ -134,6 +137,9 @@ class WakeManager { const keepSeen = Math.max(seen, prev.lastSeen ?? 0); if (keepSource === prev.source && keepSeen === prev.lastSeen) return 'unchanged'; this._peerWakeChannels.set(nodeId, { ...prev, source: keepSource, lastSeen: keepSeen }); + // Against a 30-day TTL a sighting a few minutes newer changes nothing worth a write: the + // record is updated in memory, and reported as needing a save only once it moves by an hour. + if (keepSource === prev.source && keepSeen - (prev.lastSeen ?? 0) < REFRESH_GRANULARITY_MS) return 'unchanged'; return 'refreshed'; } // A different token: only a source at least as strong may replace it, and gossip must also be newer. diff --git a/tests/wake-channels.test.js b/tests/wake-channels.test.js index 7f89e091..20ddc51f 100644 --- a/tests/wake-channels.test.js +++ b/tests/wake-channels.test.js @@ -80,6 +80,22 @@ describe('peer-info gossip', () => { }); }); +describe('refreshes', () => { + it('a repeat sighting updates memory, and asks for a save only once it is an hour newer', () => { + withNode((node) => { + const wm = node._wakeManager; + let t = 1_000_000_000_000; + wm._now = () => t; + assert.strictEqual(wm.learnWakeChannel('phone-1', apns('t'), { source: 'direct' }), 'added'); + t += 5 * 60 * 1000; + assert.strictEqual(wm.learnWakeChannel('phone-1', apns('t'), { source: 'direct' }), 'unchanged'); + assert.strictEqual(node._peerWakeChannels.get('phone-1').lastSeen, t, 'memory still holds the newest sighting'); + t += 61 * 60 * 1000; + assert.strictEqual(wm.learnWakeChannel('phone-1', apns('t'), { source: 'direct' }), 'refreshed'); + }); + }); +}); + describe('gossip sent and expiry', () => { it('forwards the lastSeen it holds, not the time of sending, and drops expired channels', () => { withNode((node) => { From 8e9347f33865be844a6267ee06e217d45a591aea Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:33:45 +0100 Subject: [PATCH 006/148] records: an explicit room, measured moods, bounded sizes; unsigned receipts counted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/core/cmb-encoder.js | 63 ++++++++++-- lib/core/cmb-signing.js | 14 ++- lib/frame-handler.js | 25 +++++ tests/frame-handler-author-key.test.js | 5 +- tests/record-bounds.test.js | 127 +++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 tests/record-bounds.test.js diff --git a/lib/core/cmb-encoder.js b/lib/core/cmb-encoder.js index a551f8ca..edeab1ec 100644 --- a/lib/core/cmb-encoder.js +++ b/lib/core/cmb-encoder.js @@ -456,6 +456,40 @@ function encodeCategory(text) { * @returns {{categories: object, metadata: object}} The two-section record (§7.1): `categories` is what * the agent says, `metadata` is what the mesh proves. No third section exists. */ +/** The room a record names when its author names none (§7): the literal 'default'. */ +const DEFAULT_ROOM = 'default'; +/** §3.1.2: an agent id is 1–64 bytes. */ +const MAX_NAME_BYTES = 64; +/** + * Size bounds. The spec says categories are bounded but gives no number; these keep every record + * sendable: one category may not take more than a quarter of a 1 MiB frame, and the seven together + * leave room for metadata and the signature inside it. + */ +const MAX_CATEGORY_BYTES = 256 * 1024; +const MAX_RECORD_TEXT_BYTES = 960 * 1024; + +/** + * Throw ECMBSIZE when a category, or the record's text as a whole, is over its bound, or the author + * id is longer than §3.1.2 allows. Shared by the emitter and the receiver. + * @param {object} categories + * @param {string} [createdBy] + */ +function assertRecordSize(categories, createdBy) { + const fail = (msg) => { const e = new RangeError(msg); e.code = 'ECMBSIZE'; throw e; }; + if (typeof createdBy === 'string' && Buffer.byteLength(createdBy, 'utf8') > MAX_NAME_BYTES) { + fail(`createdBy is ${Buffer.byteLength(createdBy, 'utf8')} bytes; an agent id is at most ${MAX_NAME_BYTES} (§3.1.2)`); + } + let total = 0; + for (const f of CAT7_CATEGORIES) { + const text = categories?.[f]?.text; + if (typeof text !== 'string') continue; + const n = Buffer.byteLength(text, 'utf8'); + if (n > MAX_CATEGORY_BYTES) fail(`${f} is ${n} bytes; a category is at most ${MAX_CATEGORY_BYTES}`); + total += n; + } + if (total > MAX_RECORD_TEXT_BYTES) fail(`the categories total ${total} bytes; a record's text is at most ${MAX_RECORD_TEXT_BYTES}`); +} + function createCMB(opts = {}) { // §7.2 [MUST]: createdBy is the agent id and is REQUIRED. It used to default to the string // 'unknown', which minted a record whose author could never be resolved to a key — an @@ -494,17 +528,26 @@ function createCMB(opts = {}) { // receiver-autonomous and kernel-local, so drift is measured against the receiver's anchors // in the receiver's kernel, and a sender's vector is comparable only if kernels match, // which nothing guarantees. - if (f === 'mood' && typeof value === 'object' && value.text) { - categories[f] = { - text: value.text, - valence: value.valence ?? 0, - arousal: value.arousal ?? 0, - }; + if (f === 'mood' && value && typeof value === 'object') { + // valence and arousal are measurements in [-1, 1]. One that is absent is OMITTED, never + // invented as 0 (a 0 claims a measured neutral), and one out of range is refused. A mood given + // only as numbers keeps them: dropping to {text:'neutral'} threw the measurement away. + const mood = { text: (typeof value.text === 'string' && value.text) || 'neutral' }; + for (const axis of ['valence', 'arousal']) { + const v = value[axis]; + if (v === undefined || v === null) continue; + if (typeof v !== 'number' || !Number.isFinite(v) || v < -1 || v > 1) { + throw new RangeError(`createCMB: mood.${axis} must be a number in [-1, 1] (got ${JSON.stringify(v)})`); + } + mood[axis] = v; + } + categories[f] = mood; } else { const text = (typeof value === 'string' ? value : value?.text) || 'neutral'; categories[f] = { text }; } } + assertRecordSize(categories, createdBy); // Per-category metadata (§7.3): each category carries its own address and its own semantic // descent. This is the section's reason to exist — admission verdicts, anchors, membrane @@ -536,7 +579,9 @@ function createCMB(opts = {}) { createdBy, createdTimestamp, lineage: opts.lineage || null, - room: opts.room ?? null, + // §7: the room is explicit, and a record with none is in the literal room 'default'. A null + // here signed the string "null" and read as "every room" on receipt. + room: (typeof opts.room === 'string' && opts.room) ? opts.room : DEFAULT_ROOM, to: opts.to ?? null, }; @@ -651,4 +696,8 @@ module.exports = { cosineSimilarity, l2Normalize, CAT7_CATEGORIES, + assertRecordSize, + DEFAULT_ROOM, + MAX_CATEGORY_BYTES, + MAX_RECORD_TEXT_BYTES, }; diff --git a/lib/core/cmb-signing.js b/lib/core/cmb-signing.js index 8bb1dc89..90008e87 100644 --- a/lib/core/cmb-signing.js +++ b/lib/core/cmb-signing.js @@ -212,6 +212,11 @@ function signingPayloadV2_0(cmb) { if (m.lineage != null && m.lineage.parents != null && !Array.isArray(m.lineage.parents)) { throw new Error('mmp-sig-v2.0: lineage.parents must be an array when present'); } + // The v2.0 schema requires a room (minLength 1): signing the string "null" made a record nobody + // could place, so a v2.0 record without one is refused rather than signed. + if (typeof m.room !== 'string' || m.room.length === 0) { + throw new Error('mmp-sig-v2.0 requires metadata.room, a non-empty string'); + } const parents = sortedBytewise(m.lineage?.parents ?? []); return Buffer.concat([ Buffer.from('mmp-sig-v2.0\n', 'utf8'), @@ -302,10 +307,11 @@ function checkAudience(cmb, receiverRoom, receiverNodeId) { // invisible acceptance. if (receiverRoom == null) return { ok: false, reason: 'receiver-room-unknown' }; - // An ABSENT room on the record still means BROADCAST, and that is semantics rather than a - // fallback: the author addressed everyone. It is kept, and it is the only permissive path left. - const room = m.room != null ? String(m.room).normalize('NFC') : ''; - if (room && room !== String(receiverRoom).normalize('NFC')) { + // §7: a record names its room, and one that names none is in the literal room 'default'. It + // used to mean "every room", the one permissive path left, so a record minted without a room + // was admitted wherever it was replayed. + const room = (m.room != null && m.room !== '') ? String(m.room).normalize('NFC') : 'default'; + if (room !== String(receiverRoom).normalize('NFC')) { return { ok: false, reason: 'wrong-audience' }; } const to = m.to != null ? String(m.to).normalize('NFC') : ''; diff --git a/lib/frame-handler.js b/lib/frame-handler.js index 73781aa2..d0407f01 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -3,6 +3,7 @@ const crypto = require('crypto'); const { recordKey, recordLineage, recordParents, recordCreatedBy, setRecordLineage } = require('./record'); +const { assertRecordSize } = require('./core/cmb-encoder'); /** * @module @sym-bot/sym/frame-handler @@ -75,6 +76,7 @@ class FrameHandler { constructor(node, opts = {}) { this._node = node; this._cliHostMode = opts.cliHostMode || false; + this._unsignedWarned = new Set(); // THE ADMISSION SEAM (sym-core split). Frame routing is open; the engine that judges an // inbound block is injectable. Stock nodes run the open §9.2 baseline; a consumer may // substitute its own engine with the same call contract. The post-admission observer is a @@ -716,6 +718,19 @@ class FrameHandler { } } + // Size first: an oversized record is refused before anything renders, verifies or stores it, + // under the same bounds the emitter enforces. + if (msg.cmb?.categories) { + try { + assertRecordSize(msg.cmb.categories, recordCreatedBy(msg.cmb)); + } catch (err) { + if (err.code !== 'ECMBSIZE') throw err; + this._node._log(`CMB from ${peerName} refused: ${err.message}`); + this._node.emit('metric', { type: 'cmb-oversize-rejected', from: peerName, key: recordKey(msg.cmb), reason: err.message }); + return; + } + } + // Derive content from CMB categories if not present on the frame // (MMP Section 7: cmb frames carry structured categories, not necessarily a content string) if (!msg.content && msg.cmb?.categories) { @@ -734,6 +749,16 @@ class FrameHandler { // through for interop but flagged unverified on msg._cmbVerified. if (this._rejectOnBadSignature(peerId, peerName, msg)) return; + // An UNSIGNED record is still accepted (as unverified) for interop, but counted and named once + // per peer, so the emitters that would break under a signed-only default can be found first. + if (msg.cmb?.metadata && !msg.cmb.metadata.sig) { + this._node.emit('metric', { type: 'cmb-unsigned-received', from: peerName, key: recordKey(msg.cmb) }); + if (!this._unsignedWarned.has(peerId)) { + this._unsignedWarned.add(peerId); + this._node._log(`UNSIGNED CMB from ${peerName}: accepted as unverified. A future release will refuse unsigned records by default.`); + } + } + // Who wrote it, and who handed it to us. `name` is the author's record label (MMP §8.8.4: // a display label, not an identity). Every surfaced and stored entry carries this, so no // layer has to read the store envelope's "+". diff --git a/tests/frame-handler-author-key.test.js b/tests/frame-handler-author-key.test.js index b8e96b07..72bda389 100644 --- a/tests/frame-handler-author-key.test.js +++ b/tests/frame-handler-author-key.test.js @@ -44,8 +44,9 @@ function identity() { /** A signed CMB as it appears on the wire (emit.js: whole object, then signCMB). */ function signedCMB({ author, signWith }) { - const cmb = createCMB({ categories: { focus: 'a relayed observation' }, createdBy: author }); - cmb.room = GROUP; + // The room goes in metadata, where the signature binds it. (A top-level `room` is read by + // nothing; this fixture once passed only because a record with no room read as "every room".) + const cmb = createCMB({ categories: { focus: 'a relayed observation' }, createdBy: author, room: GROUP }); signCMB(cmb, signWith); return cmb; } diff --git a/tests/record-bounds.test.js b/tests/record-bounds.test.js new file mode 100644 index 00000000..7e38e2de --- /dev/null +++ b/tests/record-bounds.test.js @@ -0,0 +1,127 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME to a temp sandbox before lib/config loads + +/** + * What a record may carry, on the emitting side and the receiving side alike: + * - B-R10: a record names its room; one that names none is in the literal room 'default' (§7), + * not "every room", and a v2.0 record without a room is not signed. + * - B-R11: mood valence and arousal are measurements in [-1, 1]: absent ones are omitted, not + * invented as 0, out-of-range ones are refused, and numbers given without text are kept. + * - B-R13: a category is at most 256 KiB, the seven together at most 960 KiB, an agent id at most + * 64 bytes (§3.1.2), so every record fits a 1 MiB frame. Receivers refuse what emitters may not mint. + * - B-R3 (interim): an unsigned record is accepted as unverified, counted, and named once per peer. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const { createCMB, checkAudience, signCMB } = require('../lib/core'); +const { MAX_CATEGORY_BYTES, MAX_RECORD_TEXT_BYTES } = require('../lib/core/cmb-encoder'); +const { signingPayloadV2_0 } = require('../lib/core/cmb-signing'); +const { SymNode } = require('../lib/node'); +const { NullDiscovery } = require('../lib/discovery'); +const { nodeDir } = require('../lib/config'); + +describe('room (B-R10)', () => { + it('a record minted without a room is in the room "default"', () => { + const cmb = createCMB({ categories: { focus: 'x' }, createdBy: 'a' }); + assert.strictEqual(cmb.metadata.room, 'default'); + }); + + it('a record with no room is refused outside "default", accepted inside it', () => { + const cmb = createCMB({ categories: { focus: 'x' }, createdBy: 'a' }); + cmb.metadata.room = null; // as an older emitter minted it + assert.deepStrictEqual(checkAudience(cmb, 'xmesh-world-room', 'me'), { ok: false, reason: 'wrong-audience' }); + assert.deepStrictEqual(checkAudience(cmb, 'default', 'me'), { ok: true }); + }); + + it('the v2.0 preimage refuses a record without a room instead of signing "null"', () => { + const cmb = createCMB({ categories: { focus: 'x' }, createdBy: 'a', emitV2: true, createdByNodeId: 'node-a' }); + cmb.metadata.room = null; + assert.throws(() => signingPayloadV2_0(cmb), /requires metadata.room/); + }); +}); + +describe('mood (B-R11)', () => { + const mood = (m) => createCMB({ categories: { focus: 'x', mood: m }, createdBy: 'a' }).categories.mood; + it('keeps measured values, and omits the ones not given', () => { + assert.deepStrictEqual({ ...mood({ text: 'calm', valence: 0.4, arousal: -0.2 }), meta: undefined }, { text: 'calm', valence: 0.4, arousal: -0.2, meta: undefined }); + const m = mood({ text: 'calm' }); + assert.strictEqual('valence' in m, false, 'no invented 0'); + assert.strictEqual('arousal' in m, false); + }); + it('keeps numbers given without text', () => { + const m = mood({ valence: -0.8 }); + assert.strictEqual(m.text, 'neutral'); + assert.strictEqual(m.valence, -0.8); + }); + it('refuses values outside [-1, 1] or not numbers', () => { + for (const bad of [7, -1.01, Number.NaN, '0.5']) { + assert.throws(() => mood({ text: 'x', valence: bad }), RangeError); + assert.throws(() => mood({ text: 'x', arousal: bad }), RangeError); + } + }); +}); + +describe('size (B-R13)', () => { + const big = (n) => 'a'.repeat(n); + it('refuses a category over 256 KiB and a record whose text is over 960 KiB', () => { + assert.throws(() => createCMB({ categories: { focus: big(MAX_CATEGORY_BYTES + 1) }, createdBy: 'a' }), (e) => e.code === 'ECMBSIZE'); + const near = big(MAX_CATEGORY_BYTES); + assert.throws(() => createCMB({ categories: { focus: near, issue: near, intent: near, motivation: near }, createdBy: 'a' }), (e) => e.code === 'ECMBSIZE' && /total/.test(e.message)); + assert.ok(MAX_RECORD_TEXT_BYTES < 1024 * 1024); + }); + it('counts bytes, not characters', () => { + const emoji = '😀'.repeat(MAX_CATEGORY_BYTES / 4 + 1); // 4 bytes each + assert.throws(() => createCMB({ categories: { focus: emoji }, createdBy: 'a' }), (e) => e.code === 'ECMBSIZE'); + }); + it('refuses an agent id over 64 bytes', () => { + assert.throws(() => createCMB({ categories: { focus: 'x' }, createdBy: 'n'.repeat(65) }), (e) => e.code === 'ECMBSIZE'); + assert.ok(createCMB({ categories: { focus: 'x' }, createdBy: 'n'.repeat(64) })); + }); +}); + +describe('on receipt', () => { + function withNode(fn) { + const name = `bounds-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node = new SymNode({ name, silent: true, discovery: new NullDiscovery() }); + const metrics = []; + const lines = []; + node.on('metric', (m) => metrics.push(m)); + node._log = (m) => lines.push(m); + try { return fn(node, metrics, lines); } finally { fs.rmSync(nodeDir(name), { recursive: true, force: true }); } + } + + it('refuses an oversized record before it is verified, stored or surfaced', () => { + withNode((node, metrics) => { + const cmb = createCMB({ categories: { focus: 'fine' }, createdBy: 'peer-a' }); + cmb.categories.focus.text = 'a'.repeat(MAX_CATEGORY_BYTES + 1); // a peer that skipped the bound + let surfaced = 0; + node.on('cmb-accepted', () => surfaced++); + node._frameHandler.handle('peer-a', 'peer-a', { type: 'cmb', cmb, timestamp: Date.now() }); + assert.ok(metrics.some((m) => m.type === 'cmb-oversize-rejected')); + assert.strictEqual(surfaced, 0); + }); + }); + + it('counts every unsigned record and names the peer once', () => { + withNode((node, metrics, lines) => { + for (let i = 0; i < 3; i++) { + const cmb = createCMB({ categories: { focus: `unsigned ${i}` }, createdBy: 'old-peer' }); + node._frameHandler.handle('peer-old', 'old-peer', { type: 'cmb', cmb, timestamp: Date.now() }); + } + assert.strictEqual(metrics.filter((m) => m.type === 'cmb-unsigned-received').length, 3); + assert.strictEqual(lines.filter((l) => /UNSIGNED CMB from old-peer/.test(l)).length, 1); + }); + }); + + it('a signed record is not counted as unsigned', () => { + withNode((node, metrics) => { + const cmb = createCMB({ categories: { focus: 'signed' }, createdBy: node.name }); + signCMB(cmb, node._identity.privateKey); + node._frameHandler.handle('peer-x', 'peer-x', { type: 'cmb', cmb, timestamp: Date.now() }); + assert.strictEqual(metrics.filter((m) => m.type === 'cmb-unsigned-received').length, 0); + }); + }); +}); From ae2e5b8dbfe78b69443244e1381a856bb0d7fabf Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:34:12 +0100 Subject: [PATCH 007/148] tests: consume the published v2 conformance vectors verbatim, add application-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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- tests/mmp-v2-application.test.js | 64 ++++++++ tests/mmp-v2-application.vector.json | 24 +++ tests/mmp-v2-handshake.test.js | 37 ++++- tests/mmp-v2-handshake.vector.json | 1 + tests/mmp-v2-record-signature.vector.json | 188 +++++++++++++++++++++- tests/mmp-v2-signing.test.js | 45 +++--- tests/mmp-v2-vectors-published.test.js | 33 ++++ 7 files changed, 367 insertions(+), 25 deletions(-) create mode 100644 tests/mmp-v2-application.test.js create mode 100644 tests/mmp-v2-application.vector.json create mode 100644 tests/mmp-v2-vectors-published.test.js diff --git a/tests/mmp-v2-application.test.js b/tests/mmp-v2-application.test.js new file mode 100644 index 00000000..8b0abf6f --- /dev/null +++ b/tests/mmp-v2-application.test.js @@ -0,0 +1,64 @@ +'use strict'; + +// MMP v2.0 application commitment conformance (mmp-app-v1). The application section is the +// one place a record carries opaque, actionable bytes (a deploy instruction, a tool call), and +// the record signature commits to it only through this commitment. So sym must reproduce the +// published commitment exactly for both the absent and the present case, and must refuse a +// section whose bytes no longer match what it declares — otherwise a relay could alter the +// payload under a valid signature. Vector: the published application-v2.json, copied verbatim +// (digest pinned in mmp-v2-vectors-published.test.js). + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const crypto = require('crypto'); +const { applicationCommitmentV1 } = require('../lib/core/cmb-signing'); +const vec = require('./mmp-v2-application.vector.json'); +const sigVec = require('./mmp-v2-record-signature.vector.json'); + +describe('MMP v2.0 application commitment conformance (mmp-app-v1)', () => { + it('the vector is the mmp-app-v1 construction for protocol 2.0', () => { + assert.strictEqual(vec.protocolVersion, '2.0'); + assert.strictEqual(vec.construction, 'mmp-app-v1'); + }); + + for (const c of vec.cases) { + it(`commitment reproduces the published bytes — ${c.label}`, () => { + assert.strictEqual(applicationCommitmentV1(c.application), c.expectedCommitment); + }); + } + + const present = vec.cases.find((c) => c.application); + const absent = vec.cases.find((c) => c.application === null); + + it('absent and present are distinct commitments (an absent section is not an empty one)', () => { + assert.notStrictEqual(absent.expectedCommitment, present.expectedCommitment); + assert.strictEqual(applicationCommitmentV1(undefined), absent.expectedCommitment, 'undefined is absent too'); + }); + + it('the present case carries exactly the bytes it declares', () => { + const bytes = Buffer.from(present.application.data, 'base64url'); + assert.strictEqual(bytes.toString('utf8'), present.decodedDataUtf8); + assert.strictEqual(bytes.length, present.application.byteLength); + assert.strictEqual(`sha256-${crypto.createHash('sha256').update(bytes).digest('hex')}`, present.application.digest); + }); + + it('one changed payload byte is refused, not committed to', () => { + const bytes = Buffer.from(present.application.data, 'base64url'); + bytes[0] ^= 0x01; + const tampered = { ...present.application, data: bytes.toString('base64url') }; + assert.throws(() => applicationCommitmentV1(tampered), /digest mismatch/); + }); + + it('a declared length that does not match the bytes is refused', () => { + assert.throws(() => applicationCommitmentV1({ ...present.application, byteLength: present.application.byteLength + 1 }), /byteLength mismatch/); + }); + + it('padded or non-canonical base64url is refused (one payload, one encoding)', () => { + assert.throws(() => applicationCommitmentV1({ ...present.application, data: `${present.application.data}=` }), /unpadded base64url/); + }); + + it('the record-signature vector signs this same section (the two vectors agree)', () => { + const withApp = sigVec.cases.find((k) => k.record.metadata.application); + assert.deepStrictEqual(withApp.record.metadata.application, present.application); + }); +}); diff --git a/tests/mmp-v2-application.vector.json b/tests/mmp-v2-application.vector.json new file mode 100644 index 00000000..65d697c3 --- /dev/null +++ b/tests/mmp-v2-application.vector.json @@ -0,0 +1,24 @@ +{ + "protocolVersion": "2.0", + "construction": "mmp-app-v1", + "cases": [ + { + "label": "application absent", + "application": null, + "expectedCommitment": "28cd4e3d828cf5d009c50c6958a92df1abc8e4011e27c97f995f5776cd2e33ba" + }, + { + "label": "application present", + "decodedDataUtf8": "{\"action\":\"deploy\",\"environment\":\"staging\"}", + "application": { + "mediaType": "application/json", + "schema": "https://meshcognition.org/schema/action-v1.json", + "encoding": "base64url", + "byteLength": 43, + "digest": "sha256-089f613acdc06074b6a9c4a3e1814b0440352c0a9a33a142d70a8964a88822ff", + "data": "eyJhY3Rpb24iOiJkZXBsb3kiLCJlbnZpcm9ubWVudCI6InN0YWdpbmcifQ" + }, + "expectedCommitment": "dd920b2cb64ac0e45cdada75fc08b207467eae64c86b3a44ed2f7d09b62dc370" + } + ] +} diff --git a/tests/mmp-v2-handshake.test.js b/tests/mmp-v2-handshake.test.js index 7aa6a39e..5e3014a1 100644 --- a/tests/mmp-v2-handshake.test.js +++ b/tests/mmp-v2-handshake.test.js @@ -1,8 +1,10 @@ 'use strict'; // MMP v2.0 handshake proof-of-possession conformance (P0.3). sym must reproduce -// meshcognition.org's transcript hash, session id, and proofs byte-for-byte, and must REJECT an -// unproven/tampered handshake — the impersonation fix. Vector: website #12, digest-pinned. +// meshcognition.org's transcript, transcript hash, session id, proof payloads and key schedule +// byte-for-byte, must ACCEPT the published proofs, and must REJECT an unproven/tampered +// handshake — the impersonation fix. Vector: the published handshake-v2.json, copied verbatim +// (digest pinned in mmp-v2-vectors-published.test.js). const { describe, it } = require('node:test'); const assert = require('node:assert'); @@ -31,13 +33,36 @@ describe('MMP v2.0 handshake proof-of-possession (P0.3)', () => { assert.strictEqual(sessionIdFromTranscript(tx), e.sessionId); }); - it('client proof payload is byte-identical to the vector', () => { + it('client and server proof payloads are byte-identical to the vector', () => { assert.strictEqual(proofPayload('client', tx).toString('hex'), e.clientProofPayloadHex); + assert.strictEqual(proofPayload('server', tx).toString('hex'), e.serverProofPayloadHex); }); - it('client and server proofs are byte-identical to the vector', () => { - assert.strictEqual(signProof('client', tx, vec.fixture.clientIdentityPrivateSeedBase64url), e.clientProofBase64url); - assert.strictEqual(signProof('server', tx, vec.fixture.serverIdentityPrivateSeedBase64url), e.serverProofBase64url); + // The vector's `usage` (2026-09-14 errata): the proofs are for VERIFICATION only — never sign + // the payload and compare bytes, because a hedged Ed25519 signer (WebKit, CryptoKit) returns a + // different valid signature on every call. Every other pinned value here is deterministic and + // is compared exactly. So our own proofs are checked the way the contract checks anyone's: + // two signings over one transcript both verify, and nothing is asserted about their bytes. + it('the vector marks the proofs as verification-only', () => { + assert.match(vec.usage, /Verification only/); + assert.match(vec.usage, /clientProofBase64url and serverProofBase64url/); + }); + + it('our proofs verify against the presented identity keys (signed twice, bytes not compared)', () => { + for (const [role, seed, pub] of [ + ['client', vec.fixture.clientIdentityPrivateSeedBase64url, clientPub], + ['server', vec.fixture.serverIdentityPrivateSeedBase64url, serverPub], + ]) { + const a = signProof(role, tx, seed); + const b = signProof(role, tx, seed); + assert.ok(verifyProof(role, tx, a, pub), `${role}: first proof verifies`); + assert.ok(verifyProof(role, tx, b, pub), `${role}: second proof verifies`); + } + }); + + it('the presented identity keys are the ones the vector pins in the handshake', () => { + assert.strictEqual(clientPub, vec.fixture.handshake.client.identityPublicKey); + assert.strictEqual(serverPub, vec.fixture.handshake.server.identityPublicKey); }); it('the published proofs verify against the presented identity keys', () => { diff --git a/tests/mmp-v2-handshake.vector.json b/tests/mmp-v2-handshake.vector.json index 5296f5a0..89190331 100644 --- a/tests/mmp-v2-handshake.vector.json +++ b/tests/mmp-v2-handshake.vector.json @@ -1,6 +1,7 @@ { "protocolVersion": "2.0", "warning": "all private keys and nonces are fixed conformance fixtures", + "usage": "Verification only, for clientProofBase64url and serverProofBase64url: check that your implementation ACCEPTS each proof over its pinned payload and key; never sign the payload and compare bytes. Every other pinned value here — transcript hash, HKDF outputs, key confirmations — is deterministic and MUST reproduce exactly.", "fixture": { "handshake": { "protocolVersion": "2.0", diff --git a/tests/mmp-v2-record-signature.vector.json b/tests/mmp-v2-record-signature.vector.json index 9e73a0f3..8b748db5 100644 --- a/tests/mmp-v2-record-signature.vector.json +++ b/tests/mmp-v2-record-signature.vector.json @@ -1 +1,187 @@ -{"protocolVersion": "2.0", "signatureSuite": "mmp-sig-v2.0", "addressScheme": "mmp-cmb-merkle-v2", "testKey": {"warning": "fixed test key; never use as an identity", "privateSeedBase64url": "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI", "publicKeyBase64url": "IVL40Zt5HSRFMkLhXy6rbLfP-ntqXtMAl5YOBpiB2xI"}, "invariant": "Both cases have one cognition key and two assertion identities.", "cases": [{"label": "same cognition without application", "record": {"categories": {"focus": {"text": "publish corrected MMP conformance artifacts", "meta": {"key": "5016f1bd82f5ec8ad064f9fb0a47c10988190c407c9d534e76b366634bf2e268", "parents": []}}, "issue": {"text": "the public vectors and runtime disagree", "meta": {"key": "8f37da919d4165770650b6508b66af65f0710157209b10724e7cc4aab717da83", "parents": ["cmb-abababababababababababababababababababababababababababababababab"]}}, "intent": {"text": "make independent implementations interoperate", "meta": {"key": "f47998bfc4aedee3ac20c485f9cd07caacccf622ceced426c02e178741718f67", "parents": []}}, "motivation": {"text": "a protocol claim must be executable", "meta": {"key": "9dfa469fab3eeb8c98f79df69c3b2f02f277a017f9cbe3bf0f32ccbf9a4f129d", "parents": []}}, "commitment": {"text": "one contract drives every implementation", "meta": {"key": "d3bbf94949feceaea72667efe8c43a4bf3aed557ecc632caf82b7c3f2447e3c8", "parents": []}}, "perspective": {"text": "protocol editor", "meta": {"key": "3e48fe2e89a1e02f5c17c08e853bd09d5355d18a491a1bd926568089626ffa35", "parents": []}}, "mood": {"text": "determined", "meta": {"key": "a515fb1d0492e7661b89c2b02d6bf81372ee07560bfca054863c76dec74880da", "parents": []}}}, "metadata": {"key": "cmb-7f7b55653aa9854999c2efeb9751ce4428f9dfe95839e34e4358ab0be5cdf98d", "addressScheme": "mmp-cmb-merkle-v2", "signatureSuite": "mmp-sig-v2.0", "createdByNodeId": "018f47a0-7b21-7abc-8def-0123456789ab", "createdBy": "vector-author", "createdTimestamp": 1786611600000, "room": "conformance-room", "to": "018f47a0-7b21-7abc-8def-fedcba987654", "lineage": {"parents": ["cmb-abababababababababababababababababababababababababababababababab"], "method": "svaf-heuristic"}, "application": null, "assertionId": "asrt-926d08932fe23abc3950001f80df80f93f9ae0942eb04329cd5d6c4748a0ff7e", "sigAlg": "ed25519", "sig": "ae5wP18m0_bGBxiqL4faj70eTjwK_nZL1FocG021xhwmHqmX1Ne3SlVZzdquOZiuh5haKtss6nGXWZdvXzVpCg"}}, "expectedCategoryParentsCommitment": "8b0ce7e861fb884dfe3b65a59c7c426ceafbdc716503505701d540dc5a950354", "expectedSigningPayloadHex": "6d6d702d7369672d76322e300a333a322e3031373a6d6d702d636d622d6d65726b6c652d763236383a636d622d3766376235353635336161393835343939396332656665623937353163653434323866396466653935383339653334653433353861623062653563646639386433363a30313866343761302d376232312d376162632d386465662d30313233343536373839616231333a766563746f722d617574686f7231333a3137383636313136303030303031363a636f6e666f726d616e63652d726f6f6d33363a30313866343761302d376232312d376162632d386465662d666564636261393837363534313a3136383a636d622d6162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616236343a3862306365376538363166623838346466653362363561353963376334323663656166626463373136353033353035373031643534306463356139353033353436343a32386364346533643832386366356430303963353063363935386139326466316162633865343031316532376339376639393566353737366364326533336261", "expectedAssertionId": "asrt-926d08932fe23abc3950001f80df80f93f9ae0942eb04329cd5d6c4748a0ff7e", "expectedSignature": "ae5wP18m0_bGBxiqL4faj70eTjwK_nZL1FocG021xhwmHqmX1Ne3SlVZzdquOZiuh5haKtss6nGXWZdvXzVpCg"}, {"label": "same cognition with application", "record": {"categories": {"focus": {"text": "publish corrected MMP conformance artifacts", "meta": {"key": "5016f1bd82f5ec8ad064f9fb0a47c10988190c407c9d534e76b366634bf2e268", "parents": []}}, "issue": {"text": "the public vectors and runtime disagree", "meta": {"key": "8f37da919d4165770650b6508b66af65f0710157209b10724e7cc4aab717da83", "parents": ["cmb-abababababababababababababababababababababababababababababababab"]}}, "intent": {"text": "make independent implementations interoperate", "meta": {"key": "f47998bfc4aedee3ac20c485f9cd07caacccf622ceced426c02e178741718f67", "parents": []}}, "motivation": {"text": "a protocol claim must be executable", "meta": {"key": "9dfa469fab3eeb8c98f79df69c3b2f02f277a017f9cbe3bf0f32ccbf9a4f129d", "parents": []}}, "commitment": {"text": "one contract drives every implementation", "meta": {"key": "d3bbf94949feceaea72667efe8c43a4bf3aed557ecc632caf82b7c3f2447e3c8", "parents": []}}, "perspective": {"text": "protocol editor", "meta": {"key": "3e48fe2e89a1e02f5c17c08e853bd09d5355d18a491a1bd926568089626ffa35", "parents": []}}, "mood": {"text": "determined", "meta": {"key": "a515fb1d0492e7661b89c2b02d6bf81372ee07560bfca054863c76dec74880da", "parents": []}}}, "metadata": {"key": "cmb-7f7b55653aa9854999c2efeb9751ce4428f9dfe95839e34e4358ab0be5cdf98d", "addressScheme": "mmp-cmb-merkle-v2", "signatureSuite": "mmp-sig-v2.0", "createdByNodeId": "018f47a0-7b21-7abc-8def-0123456789ab", "createdBy": "vector-author", "createdTimestamp": 1786611600000, "room": "conformance-room", "to": "018f47a0-7b21-7abc-8def-fedcba987654", "lineage": {"parents": ["cmb-abababababababababababababababababababababababababababababababab"], "method": "svaf-heuristic"}, "application": {"mediaType": "application/json", "schema": "https://meshcognition.org/schema/action-v1.json", "encoding": "base64url", "byteLength": 43, "digest": "sha256-089f613acdc06074b6a9c4a3e1814b0440352c0a9a33a142d70a8964a88822ff", "data": "eyJhY3Rpb24iOiJkZXBsb3kiLCJlbnZpcm9ubWVudCI6InN0YWdpbmcifQ"}, "assertionId": "asrt-07ccedd2eaf5297b193c554b64f8ad4f02908adaff478618dc9ceb4eea63b011", "sigAlg": "ed25519", "sig": "LKeUxp7if-ce3zsI6OBAiukvgwHuEFfLfS0VjIP63c2usrKy6WLM66a1uFJC-ZDB6PxSik-36Wp7HglTvIp_DQ"}}, "expectedCategoryParentsCommitment": "8b0ce7e861fb884dfe3b65a59c7c426ceafbdc716503505701d540dc5a950354", "expectedSigningPayloadHex": "6d6d702d7369672d76322e300a333a322e3031373a6d6d702d636d622d6d65726b6c652d763236383a636d622d3766376235353635336161393835343939396332656665623937353163653434323866396466653935383339653334653433353861623062653563646639386433363a30313866343761302d376232312d376162632d386465662d30313233343536373839616231333a766563746f722d617574686f7231333a3137383636313136303030303031363a636f6e666f726d616e63652d726f6f6d33363a30313866343761302d376232312d376162632d386465662d666564636261393837363534313a3136383a636d622d6162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616236343a3862306365376538363166623838346466653362363561353963376334323663656166626463373136353033353035373031643534306463356139353033353436343a64643932306232636236346163306534356364616461373566633038623230373436376561653634633836623361343465643266376430396236326463333730", "expectedAssertionId": "asrt-07ccedd2eaf5297b193c554b64f8ad4f02908adaff478618dc9ceb4eea63b011", "expectedSignature": "LKeUxp7if-ce3zsI6OBAiukvgwHuEFfLfS0VjIP63c2usrKy6WLM66a1uFJC-ZDB6PxSik-36Wp7HglTvIp_DQ"}]} \ No newline at end of file +{ + "protocolVersion": "2.0", + "signatureSuite": "mmp-sig-v2.0", + "addressScheme": "mmp-cmb-merkle-v2", + "testKey": { + "warning": "fixed test key; never use as an identity", + "privateSeedBase64url": "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI", + "publicKeyBase64url": "IVL40Zt5HSRFMkLhXy6rbLfP-ntqXtMAl5YOBpiB2xI" + }, + "invariant": "Both cases have one cognition key and two assertion identities.", + "usage": "Verification only. Check that your implementation ACCEPTS expectedSignature for the given payload and key; never sign the payload and compare bytes. WebKit (Safari, and every browser on iOS) ships a hedged Ed25519 signer that returns a different valid signature on each call, so signature equality is not a property of a conforming implementation. To test signing, sign the same payload twice and assert that both verify — and assert nothing about whether they are equal.", + "cases": [ + { + "label": "same cognition without application", + "record": { + "categories": { + "focus": { + "text": "publish corrected MMP conformance artifacts", + "meta": { + "key": "5016f1bd82f5ec8ad064f9fb0a47c10988190c407c9d534e76b366634bf2e268", + "parents": [] + } + }, + "issue": { + "text": "the public vectors and runtime disagree", + "meta": { + "key": "8f37da919d4165770650b6508b66af65f0710157209b10724e7cc4aab717da83", + "parents": [ + "cmb-abababababababababababababababababababababababababababababababab" + ] + } + }, + "intent": { + "text": "make independent implementations interoperate", + "meta": { + "key": "f47998bfc4aedee3ac20c485f9cd07caacccf622ceced426c02e178741718f67", + "parents": [] + } + }, + "motivation": { + "text": "a protocol claim must be executable", + "meta": { + "key": "9dfa469fab3eeb8c98f79df69c3b2f02f277a017f9cbe3bf0f32ccbf9a4f129d", + "parents": [] + } + }, + "commitment": { + "text": "one contract drives every implementation", + "meta": { + "key": "d3bbf94949feceaea72667efe8c43a4bf3aed557ecc632caf82b7c3f2447e3c8", + "parents": [] + } + }, + "perspective": { + "text": "protocol editor", + "meta": { + "key": "3e48fe2e89a1e02f5c17c08e853bd09d5355d18a491a1bd926568089626ffa35", + "parents": [] + } + }, + "mood": { + "text": "determined", + "meta": { + "key": "a515fb1d0492e7661b89c2b02d6bf81372ee07560bfca054863c76dec74880da", + "parents": [] + } + } + }, + "metadata": { + "key": "cmb-7f7b55653aa9854999c2efeb9751ce4428f9dfe95839e34e4358ab0be5cdf98d", + "addressScheme": "mmp-cmb-merkle-v2", + "signatureSuite": "mmp-sig-v2.0", + "createdByNodeId": "018f47a0-7b21-7abc-8def-0123456789ab", + "createdBy": "vector-author", + "createdTimestamp": 1786611600000, + "room": "conformance-room", + "to": "018f47a0-7b21-7abc-8def-fedcba987654", + "lineage": { + "parents": [ + "cmb-abababababababababababababababababababababababababababababababab" + ], + "method": "svaf-heuristic" + }, + "application": null, + "assertionId": "asrt-926d08932fe23abc3950001f80df80f93f9ae0942eb04329cd5d6c4748a0ff7e", + "sigAlg": "ed25519", + "sig": "ae5wP18m0_bGBxiqL4faj70eTjwK_nZL1FocG021xhwmHqmX1Ne3SlVZzdquOZiuh5haKtss6nGXWZdvXzVpCg" + } + }, + "expectedCategoryParentsCommitment": "8b0ce7e861fb884dfe3b65a59c7c426ceafbdc716503505701d540dc5a950354", + "expectedSigningPayloadHex": "6d6d702d7369672d76322e300a333a322e3031373a6d6d702d636d622d6d65726b6c652d763236383a636d622d3766376235353635336161393835343939396332656665623937353163653434323866396466653935383339653334653433353861623062653563646639386433363a30313866343761302d376232312d376162632d386465662d30313233343536373839616231333a766563746f722d617574686f7231333a3137383636313136303030303031363a636f6e666f726d616e63652d726f6f6d33363a30313866343761302d376232312d376162632d386465662d666564636261393837363534313a3136383a636d622d6162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616236343a3862306365376538363166623838346466653362363561353963376334323663656166626463373136353033353035373031643534306463356139353033353436343a32386364346533643832386366356430303963353063363935386139326466316162633865343031316532376339376639393566353737366364326533336261", + "expectedAssertionId": "asrt-926d08932fe23abc3950001f80df80f93f9ae0942eb04329cd5d6c4748a0ff7e", + "expectedSignature": "ae5wP18m0_bGBxiqL4faj70eTjwK_nZL1FocG021xhwmHqmX1Ne3SlVZzdquOZiuh5haKtss6nGXWZdvXzVpCg", + "expectedSignatureIsOneOfMany": "One valid signature over expectedSigningPayloadHex. Verify it; do not reproduce it. A hedged signer (WebKit) returns different valid bytes each call." + }, + { + "label": "same cognition with application", + "record": { + "categories": { + "focus": { + "text": "publish corrected MMP conformance artifacts", + "meta": { + "key": "5016f1bd82f5ec8ad064f9fb0a47c10988190c407c9d534e76b366634bf2e268", + "parents": [] + } + }, + "issue": { + "text": "the public vectors and runtime disagree", + "meta": { + "key": "8f37da919d4165770650b6508b66af65f0710157209b10724e7cc4aab717da83", + "parents": [ + "cmb-abababababababababababababababababababababababababababababababab" + ] + } + }, + "intent": { + "text": "make independent implementations interoperate", + "meta": { + "key": "f47998bfc4aedee3ac20c485f9cd07caacccf622ceced426c02e178741718f67", + "parents": [] + } + }, + "motivation": { + "text": "a protocol claim must be executable", + "meta": { + "key": "9dfa469fab3eeb8c98f79df69c3b2f02f277a017f9cbe3bf0f32ccbf9a4f129d", + "parents": [] + } + }, + "commitment": { + "text": "one contract drives every implementation", + "meta": { + "key": "d3bbf94949feceaea72667efe8c43a4bf3aed557ecc632caf82b7c3f2447e3c8", + "parents": [] + } + }, + "perspective": { + "text": "protocol editor", + "meta": { + "key": "3e48fe2e89a1e02f5c17c08e853bd09d5355d18a491a1bd926568089626ffa35", + "parents": [] + } + }, + "mood": { + "text": "determined", + "meta": { + "key": "a515fb1d0492e7661b89c2b02d6bf81372ee07560bfca054863c76dec74880da", + "parents": [] + } + } + }, + "metadata": { + "key": "cmb-7f7b55653aa9854999c2efeb9751ce4428f9dfe95839e34e4358ab0be5cdf98d", + "addressScheme": "mmp-cmb-merkle-v2", + "signatureSuite": "mmp-sig-v2.0", + "createdByNodeId": "018f47a0-7b21-7abc-8def-0123456789ab", + "createdBy": "vector-author", + "createdTimestamp": 1786611600000, + "room": "conformance-room", + "to": "018f47a0-7b21-7abc-8def-fedcba987654", + "lineage": { + "parents": [ + "cmb-abababababababababababababababababababababababababababababababab" + ], + "method": "svaf-heuristic" + }, + "application": { + "mediaType": "application/json", + "schema": "https://meshcognition.org/schema/action-v1.json", + "encoding": "base64url", + "byteLength": 43, + "digest": "sha256-089f613acdc06074b6a9c4a3e1814b0440352c0a9a33a142d70a8964a88822ff", + "data": "eyJhY3Rpb24iOiJkZXBsb3kiLCJlbnZpcm9ubWVudCI6InN0YWdpbmcifQ" + }, + "assertionId": "asrt-07ccedd2eaf5297b193c554b64f8ad4f02908adaff478618dc9ceb4eea63b011", + "sigAlg": "ed25519", + "sig": "LKeUxp7if-ce3zsI6OBAiukvgwHuEFfLfS0VjIP63c2usrKy6WLM66a1uFJC-ZDB6PxSik-36Wp7HglTvIp_DQ" + } + }, + "expectedCategoryParentsCommitment": "8b0ce7e861fb884dfe3b65a59c7c426ceafbdc716503505701d540dc5a950354", + "expectedSigningPayloadHex": "6d6d702d7369672d76322e300a333a322e3031373a6d6d702d636d622d6d65726b6c652d763236383a636d622d3766376235353635336161393835343939396332656665623937353163653434323866396466653935383339653334653433353861623062653563646639386433363a30313866343761302d376232312d376162632d386465662d30313233343536373839616231333a766563746f722d617574686f7231333a3137383636313136303030303031363a636f6e666f726d616e63652d726f6f6d33363a30313866343761302d376232312d376162632d386465662d666564636261393837363534313a3136383a636d622d6162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616236343a3862306365376538363166623838346466653362363561353963376334323663656166626463373136353033353035373031643534306463356139353033353436343a64643932306232636236346163306534356364616461373566633038623230373436376561653634633836623361343465643266376430396236326463333730", + "expectedAssertionId": "asrt-07ccedd2eaf5297b193c554b64f8ad4f02908adaff478618dc9ceb4eea63b011", + "expectedSignature": "LKeUxp7if-ce3zsI6OBAiukvgwHuEFfLfS0VjIP63c2usrKy6WLM66a1uFJC-ZDB6PxSik-36Wp7HglTvIp_DQ", + "expectedSignatureIsOneOfMany": "One valid signature over expectedSigningPayloadHex. Verify it; do not reproduce it. A hedged signer (WebKit) returns different valid bytes each call." + } + ] +} diff --git a/tests/mmp-v2-signing.test.js b/tests/mmp-v2-signing.test.js index b67c516f..be2df74c 100644 --- a/tests/mmp-v2-signing.test.js +++ b/tests/mmp-v2-signing.test.js @@ -1,17 +1,19 @@ 'use strict'; // MMP v2.0 published-contract conformance: sym must reproduce meshcognition.org's mmp-sig-v2.0 -// byte-for-byte. The vector is the normative record-signature-v2.json (website PR #12@3d66352). -// This is the gate for the reader-first migration: if sym's preimage diverges by one byte, an -// independent implementer's signature and sym's disagree and the mesh silently splits. +// signing preimage byte-for-byte and accept the published signatures. The vector is the +// normative record-signature-v2.json, copied verbatim (2026-09-14 errata included; the digest +// is pinned in mmp-v2-vectors-published.test.js). This is the gate for the reader-first +// migration: if sym's preimage diverges by one byte, an independent implementer's signature +// and sym's disagree and the mesh silently splits. const { describe, it } = require('node:test'); const assert = require('node:assert'); const crypto = require('crypto'); -const { signingPayloadV2_0, assertionIdV2_0, signCMB, verifyCMB, privateKeyObject } = require('../lib/core/cmb-signing'); +const { signingPayloadV2_0, assertionIdV2_0, categoryParentsCommitment, verifyCMB, privateKeyObject, publicKeyObject } = require('../lib/core/cmb-signing'); const vec = require('./mmp-v2-record-signature.vector.json'); -// The fixed test key (Ed25519, deterministic per RFC 8032 in Node — so a byte-exact sig match holds). +// The fixed test key. Signatures made with it are verified, never compared: see below. const seed = Buffer.from(vec.testKey.privateSeedBase64url, 'base64url'); const privB64url = seed.toString('base64url'); const pubB64url = vec.testKey.publicKeyBase64url; @@ -22,17 +24,24 @@ describe('MMP v2.0 signing conformance (mmp-sig-v2.0)', () => { assert.strictEqual(assertionIdV2_0(kase.record), kase.record.metadata.assertionId); }); - // NODE DETERMINISM, NOT CONFORMANCE. Ed25519 is deterministic in RFC 8032 and Node honours it, - // so this pins that OUR signer still produces the published bytes — a regression guard on the - // vector and on this runtime. It is NOT a conformance requirement and MUST NOT be copied into - // one: Apple's implementations hedge, measured 2026-09-14 (CryptoKit: five signings of RFC 8032 - // vector 2 under one key gave five distinct valid signatures, none matching the published one; - // WebKit the same, dev-team-3 on Safari 26.5). A correct implementation on those platforms - // fails this assertion, which is why the spec now requires verification instead - // (meshcognition.org §17.4, §20.3, §17.5). - it(`signature is byte-identical to the published vector — Node determinism, not conformance — ${kase.label}`, () => { - const sig = crypto.sign(null, signingPayloadV2_0(kase.record), privateKeyObject(privB64url)); - assert.strictEqual(sig.toString('base64url'), kase.record.metadata.sig); + // The signing PREIMAGE is deterministic and must reproduce exactly; the signature over it + // must not be compared. The vector says so itself (`usage`, and `expectedSignatureIsOneOfMany` + // on every case, from the 2026-09-14 errata): Apple's signers hedge, so a conforming + // implementation there returns different valid bytes on every call (measured 2026-09-14: + // CryptoKit gave five distinct valid signatures for one payload under one key, none equal + // to the published one). Node happens to be deterministic, and asserting byte equality here + // would pin that accident as if it were the contract. + it(`signing payload and assertion id reproduce the published bytes — ${kase.label}`, () => { + assert.strictEqual(signingPayloadV2_0(kase.record).toString('hex'), kase.expectedSigningPayloadHex); + assert.strictEqual(assertionIdV2_0(kase.record), kase.expectedAssertionId); + assert.strictEqual(categoryParentsCommitment(kase.record.categories), kase.expectedCategoryParentsCommitment); + }); + + it(`expectedSignature is honoured as one valid signature of many: verified, never reproduced — ${kase.label}`, () => { + assert.ok(kase.expectedSignatureIsOneOfMany, 'the published vector marks the signature as one of many'); + assert.ok(crypto.verify(null, Buffer.from(kase.expectedSigningPayloadHex, 'hex'), + publicKeyObject(pubB64url), Buffer.from(kase.expectedSignature, 'base64url')), + 'the published expectedSignature must verify over the published payload'); }); // THE CONFORMANCE PROPERTY, which every implementation must satisfy including a hedged signer: @@ -40,7 +49,7 @@ describe('MMP v2.0 signing conformance (mmp-sig-v2.0)', () => { it(`two signatures over one payload both verify — ${kase.label}`, () => { const payload = signingPayloadV2_0(kase.record); const key = privateKeyObject(privB64url); - const pub = require('../lib/core/cmb-signing').publicKeyObject(pubB64url); + const pub = publicKeyObject(pubB64url); const a = crypto.sign(null, payload, key); const b = crypto.sign(null, payload, key); assert.ok(crypto.verify(null, payload, pub, a), 'first signature must verify'); @@ -51,7 +60,7 @@ describe('MMP v2.0 signing conformance (mmp-sig-v2.0)', () => { const r = verifyCMB(kase.record, pubB64url); // verifyCMB currently checks the v2 preimage; assert the v2.0 preimage verifies directly. const ok = crypto.verify(null, signingPayloadV2_0(kase.record), - require('../lib/core/cmb-signing').publicKeyObject(pubB64url), + publicKeyObject(pubB64url), Buffer.from(kase.record.metadata.sig, 'base64url')); assert.ok(ok, 'published v2.0 signature must verify'); void r; diff --git a/tests/mmp-v2-vectors-published.test.js b/tests/mmp-v2-vectors-published.test.js new file mode 100644 index 00000000..0a863093 --- /dev/null +++ b/tests/mmp-v2-vectors-published.test.js @@ -0,0 +1,33 @@ +'use strict'; + +// The v2 conformance vectors in this directory are the PUBLISHED files, byte for byte — not +// local edits of them. The SDK's copies of record-signature and handshake once drifted from +// the published ones: they lacked the 2026-09-14 errata fields (`usage`, +// `expectedSignatureIsOneOfMany`) that tell an implementer to verify signatures rather than +// reproduce them, so the SDK's tests went on asserting the opposite of the contract. A digest +// pin makes any such drift fail here instead of silently. +// +// Source: meshcognition.org/spec/mmp/conformance/v2/ (repository meshcognition-website, +// public/spec/mmp/conformance/v2/; mirrored in mesh-memory-protocol/conformance/v2/), whose +// artifact-manifest.json pins these same sha256 digests. To update: copy the published file +// verbatim and replace its digest below from that manifest — never edit a vector by hand. + +const { test } = require('node:test'); +const assert = require('node:assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const PUBLISHED = { + 'mmp-v2-application.vector.json': ['application-v2.json', '28ef0e7a7a7f2fb1e85a8a35b2f899962a648193e61717165540e5f4b842eb30'], + 'mmp-v2-e2e.vector.json': ['e2e-v2.json', '3e5d4adec0137a01f85c98cacba5c8d65b5d603d02085149f950c3d5ea77dbdc'], + 'mmp-v2-handshake.vector.json': ['handshake-v2.json', 'c93a8440c29b07b08cccd9b0ec51008f98bfd3f631e9a654f631cb391dfe3125'], + 'mmp-v2-record-signature.vector.json': ['record-signature-v2.json', '3725744c2162e3d9f2a6fb9499253057cff018ff1148ea934861826d033018d7'], +}; + +for (const [local, [published, digest]] of Object.entries(PUBLISHED)) { + test(`${local} is the published conformance/v2/${published}, byte for byte`, () => { + const bytes = fs.readFileSync(path.join(__dirname, local)); + assert.strictEqual(crypto.createHash('sha256').update(bytes).digest('hex'), digest); + }); +} From d09d38af7e38bb92fc7e4532e43b8658b66bf4a7 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:34:46 +0100 Subject: [PATCH 008/148] tests: the lease test's foreign holder is a live child with a real start 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- tests/identity-halt.test.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/identity-halt.test.js b/tests/identity-halt.test.js index f3c86a33..c40b72c1 100644 --- a/tests/identity-halt.test.js +++ b/tests/identity-halt.test.js @@ -24,6 +24,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const { spawn } = require('node:child_process'); /** * Point SYM_IDENTITY_DIR at a throwaway dir. No require-cache busting: identityDir() reads the @@ -160,19 +161,29 @@ test('AC-3.2: no `-2` identity can be created by any path', () => { } finally { restore(); fs.rmSync(dir, { recursive: true, force: true }); } }); -test('the lease refuses a second holder and never suggests renaming', () => { +test('the lease refuses a second holder and never suggests renaming', (t) => { // The old error advised "set a different SYM_NODE_NAME", which under agent-id-is-identity // is advice to become a different agent — the exact move the ruling forbids. + // + // The foreign holder is a real, live child process recorded with its real start time — the + // current lock format exactly as a running holder writes it. This used to be PID 1 with a + // `startTime` key: Windows has no PID 1, and the reader's key is `start`, so the metadata was + // ignored and the test only ever exercised the legacy no-start-time branch. + const holder = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }); + t.after(() => { try { holder.kill('SIGKILL'); } catch {} }); const dir = tmp(); const { cfg, restore } = freshConfig(dir); const name = `agent-lease-${process.pid}@mesh`; const lockPath = path.join(cfg.nodeDir(name), 'lock.pid'); try { + const start = cfg.processStartTime(holder.pid); + assert.ok(start, 'precondition: this platform can read the holder\'s start time'); fs.mkdirSync(cfg.nodeDir(name), { recursive: true }); - // A live foreign holder: PID 1 always exists and is never us. - fs.writeFileSync(lockPath, `1\n${JSON.stringify({ startTime: 0 })}`); + fs.writeFileSync(lockPath, `${holder.pid}\n${JSON.stringify({ start, createdAt: Date.now() })}\n`); + assert.equal(cfg.readLockFile(lockPath).start, start, 'the lock carries the start time the reader checks'); assert.throws(() => cfg.acquireIdentityLock(name), (e) => { assert.equal(e.code, 'EIDENTITYLOCK'); + assert.equal(e.holderPid, holder.pid); assert.ok(!/different SYM_NODE_NAME/.test(e.message), 'must not advise renaming — that is advice to become a different agent'); assert.match(e.message, /same agent/); From 34512090a1eb0e274943cf0d5c7b9bc8e53a2337 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:35:14 +0100 Subject: [PATCH 009/148] tests: skip the SIGTERM lock test on Windows; cover hard-kill reclaim 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- tests/config.test.js | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/tests/config.test.js b/tests/config.test.js index a245107b..70907103 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -390,7 +390,12 @@ describe('acquireIdentityLock', () => { assert.strictEqual(fs.existsSync(lockPathOf(name)), false); }); - it('releases the lock on SIGTERM when the host has no handler', async () => { + // Windows has no POSIX signals: child.kill('SIGTERM') there is TerminateProcess, a hard kill + // that runs no handler and no 'exit' hook, so there is nothing for this test to observe. The + // Windows case is covered by the hard-kill reclaim test below, which runs on every platform. + it('releases the lock on SIGTERM when the host has no handler', { + skip: process.platform === 'win32' && 'win32: kill(\'SIGTERM\') is TerminateProcess, which runs no exit hook — see the hard-kill reclaim test', + }, async () => { const name = mkName(); const script = ` require(${JSON.stringify(require.resolve('../lib/config'))}).acquireIdentityLock(${JSON.stringify(name)}); @@ -409,6 +414,33 @@ describe('acquireIdentityLock', () => { }); assert.strictEqual(fs.existsSync(lockPathOf(name)), false); }); + + // A holder killed so hard that no code of its own runs — SIGKILL on POSIX, TerminateProcess on + // Windows (which is what any child.kill() is there) — leaves its lockfile behind. Nothing can + // release that lock at the time; the next acquire must recognise the dead holder and reclaim + // it, or the agent stays locked out until someone deletes the file by hand. + it('reclaims the lock a hard-killed holder left behind (SIGKILL / TerminateProcess)', async () => { + const name = mkName(); + const script = ` + require(${JSON.stringify(require.resolve('../lib/config'))}).acquireIdentityLock(${JSON.stringify(name)}); + console.log('locked'); + setInterval(() => {}, 1000);`; + let holderPid; + await new Promise((resolve, reject) => { + const c = spawn(process.execPath, ['-e', script], { stdio: ['ignore', 'pipe', 'ignore'], env: process.env }); + holderPid = c.pid; + c.on('error', reject); + c.stdout.on('data', (d) => { if (String(d).includes('locked')) c.kill('SIGKILL'); }); + c.on('close', () => resolve()); + }); + const left = readLockFile(lockPathOf(name)); + assert.ok(left, 'precondition: a hard kill runs no exit hook, so the lockfile is left behind'); + assert.strictEqual(left.pid, holderPid); + assert.ok(left.start, 'precondition: the holder recorded its start time'); + const release = acquireIdentityLock(name); // must NOT throw EIDENTITYLOCK + assert.strictEqual(readLockFile(lockPathOf(name)).pid, process.pid, 'the next acquire reclaimed it'); + release(); + }); }); describe('log', () => { From 6955b1edc53ecad2b4184bead504c23cba95c5f0 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:35:55 +0100 Subject: [PATCH 010/148] lineage tether: walk only through verified records (B-L4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/core/lineage-tether.js | 101 ++++++++++++--- tests/lineage-tether-severance.test.js | 35 +++++ tests/lineage-tether-traversal.test.js | 173 ++++++++++++++++++++----- 3 files changed, 255 insertions(+), 54 deletions(-) diff --git a/lib/core/lineage-tether.js b/lib/core/lineage-tether.js index 84d5ff9e..c2743bfd 100644 --- a/lib/core/lineage-tether.js +++ b/lib/core/lineage-tether.js @@ -29,8 +29,9 @@ * @license Apache-2.0 */ -const { cosineSimilarity, CAT7_CATEGORIES } = require('./cmb-encoder'); -const { encodeForSVAF, isSemanticReady, kernelId } = require('./context-encoder'); +const { cosineSimilarity, CAT7_CATEGORIES, blockKeyV2 } = require('./cmb-encoder'); +const { encodeForSVAF, kernelId } = require('./context-encoder'); +const { keyOf, verifyAddress, VERIFY } = require('./record-shape'); /** * Evaluate the §15.8 lineage tether of a remix against its anchor. @@ -75,13 +76,63 @@ function computeLineageTether({ remixCategories, anchorCategories, categoryWeigh } /** - * Resolve the §15.8 anchor for an incoming CMB: the EARLIEST-STORED lineage - * ancestor the local store can resolve, falling back to the incoming block - * itself when none resolves (a root is its own anchor). + * Does a STORED record verify, so a §15.8 walk may step onto it and follow its parents? + * + * §15.2: a verifier validates each parent's content address and signature, and repeats. Both + * halves are needed here, for different reasons. + * + * Address. The tether compares against the anchor's TEXT, and "any holder of the root re-encodes + * its text" only means something if that text is the content the key names. A stored record + * whose categories no longer recompute to the key it is held under is not the root it claims to + * be, whatever else is true of it. + * + * Authorship. A record is a step only if this node knows who wrote it: either it wrote the + * record itself (the store's local entries carry no peerId), or it admitted the record after + * verifying the author's signature — the frame handler's verdict, `_cmbVerified`, which the + * store persists with the entry. Signature verification needs the author's key, which this pure + * module does not hold; the verdict reached when the key WAS at hand is the evidence that exists. + * An admission with no verdict, or a false one, proves nothing about who chose its content or + * its parents, and walking through it let the author of one unsigned block pick the anchor of + * every remix that cites it. + * + * A pre-boundary record (no metadata section) carries no v2 attestation and never verifies + * (§7.8 unverified-legacy). A bare record with no store envelope has no verdict and does not either. + * + * @param {object} entry - stored entry ({ cmb, peerId, _cmbVerified, ... }). + * @param {string} key - the key the walk looked the entry up under. + * @returns {boolean} + */ +function storedRecordVerifies(entry, key) { + if (!entry || typeof entry !== 'object') return false; + const cmb = entry.cmb; + if (!cmb || !cmb.metadata) return false; + if (keyOf(cmb) !== key) return false; + if (verifyAddress(cmb, blockKeyV2).state !== VERIFY.VERIFIED) return false; + // `== null`, the store's own test for a locally-written entry (hasLocalKey, compactByOrigin). + // receiveFromPeer always overwrites peerId, so a peer frame cannot arrive claiming to be local. + if (entry.peerId == null) return true; + return entry._cmbVerified === true; +} + +/** + * Resolve the §15.8 anchor for an incoming CMB: the EARLIEST-STORED lineage ancestor reachable + * through records that verify (storedRecordVerifies). + * + * Three outcomes, never collapsed into one another: + * - a verified ancestor was reached → it is the anchor (resolvedFromStore: true); + * - the incoming block is a ROOT (no parents) → it is its own anchor; + * - it is a remix and no verified ancestor is reachable → there is NO anchor: key and categories + * are null. The tether is then unverified, which §15.8 makes a trust state, never a severance. + * This used to fall back to the remix itself, so the gate measured a remix against its own + * text, always found it tethered, and the integrator signed that as a tether to a root. + * + * `complete` says whether every edge the walk met resolved to a verified record within bounds. + * When it is false the anchor (if any) is the oldest VERIFIED record reached, not necessarily the + * chain's true root — §15.2's incomplete lineage proof, which MUST NOT be presented as complete. * * @param {object} incomingCMB - The incoming CMB (categories + lineage). - * @param {(key: string) => object|undefined} getEntry - Store lookup: key → stored entry (with .cmb and .storedAt / .cmb.originTimestamp). - * @returns {{ key: string|null, categories: object|null, resolvedFromStore: boolean }} + * @param {(key: string) => object|undefined} getEntry - Store lookup: key → stored entry (with .cmb, .storedAt and the store envelope). + * @returns {{ key: string|null, categories: object|null, resolvedFromStore: boolean, complete: boolean }} */ function resolveTetherAnchor(incomingCMB, getEntry) { // Lineage lives in `metadata` on a §7 record and at the top level on a pre-boundary one. @@ -94,12 +145,14 @@ function resolveTetherAnchor(incomingCMB, getEntry) { const lin = incomingCMB?.metadata?.lineage ?? incomingCMB?.lineage ?? null; // MMP v2.0: a record carries DIRECT PARENTS ONLY (§7.5 retires the transitive closure). The // earliest anchor is found by VERIFIED GRAPH TRAVERSAL — walk out from the direct parents - // through records THIS NODE has actually stored, following each stored record's own parents. + // through records THIS NODE has actually stored AND can verify, following each such record's + // own parents. // // The incoming `lineage.ancestors` is NEVER read. Seeding the walk from a sender-supplied // closure let a non-conformant peer inject arbitrary apparent roots into tether resolution — - // lineage amplification. A parent this node has not stored is incomplete LOCAL proof: the walk - // simply cannot pass through it, never permission to trust the sender's claim about it. + // lineage amplification. A parent this node has not stored, or holds but cannot verify, is + // incomplete LOCAL proof: the walk cannot pass through it, and it is never permission to trust + // the sender's claim about it. // // Bounded: MAX_HOPS depth and MAX_NODES visited cap the walk, and `seen` makes a cyclic or // repeated-key chain terminate. The earliest-stored reachable record wins. @@ -113,17 +166,23 @@ function resolveTetherAnchor(incomingCMB, getEntry) { let best = null; let bestTime = Infinity; let visited = 0; + let complete = true; // Frontier of {key, depth} starting at the incoming record's DIRECT parents only. - const frontier = (Array.isArray(lin?.parents) ? lin.parents : []).map((key) => ({ key, depth: 1 })); + const directParents = Array.isArray(lin?.parents) ? lin.parents : []; + const frontier = directParents.map((key) => ({ key, depth: 1 })); while (frontier.length) { const { key, depth } = frontier.shift(); - if (seen.has(key) || depth > MAX_HOPS || visited >= MAX_NODES) continue; + if (seen.has(key)) continue; + if (depth > MAX_HOPS || visited >= MAX_NODES) { complete = false; continue; } seen.add(key); const entry = typeof getEntry === 'function' ? getEntry(key) : undefined; - if (!entry) continue; // parent not stored locally → cannot walk through it; never trust the wire + // Not stored locally → cannot walk through it; never trust the wire. + if (!entry) { complete = false; continue; } visited++; - const cmb = entry.cmb || entry; - if (!cmb || !cmb.categories) continue; + // Stored but unverifiable → this branch ends here. Its content and its parents are claims by + // an author this node cannot name, so it is neither a candidate anchor nor a way past itself. + if (!storedRecordVerifies(entry, key)) { complete = false; continue; } + const cmb = entry.cmb; // Rank by RECEIVER-LOCAL storedAt — never author/transport `originTimestamp`, which is // unverified and backdatable: a nearer record carrying an artificially old author time must // not displace the locally-verified earliest-stored anchor. Tie-break deterministically by @@ -135,11 +194,13 @@ function resolveTetherAnchor(incomingCMB, getEntry) { // Continue the VERIFIED walk from this stored record's own parents (never its wire ancestors). for (const pk of parentsOf(cmb)) if (!seen.has(pk)) frontier.push({ key: pk, depth: depth + 1 }); } - if (best) return best; - if (incomingCMB && incomingCMB.categories) { - return { key: incomingCMB.metadata?.key ?? incomingCMB.key ?? null, categories: incomingCMB.categories, resolvedFromStore: false }; + if (best) return { ...best, complete }; + // Only a ROOT is its own anchor. A remix with no verified ancestor in reach has an unverifiable + // root, and saying so is the honest answer — not substituting the remix for the root it cites. + if (directParents.length === 0 && incomingCMB && incomingCMB.categories) { + return { key: incomingCMB.metadata?.key ?? incomingCMB.key ?? null, categories: incomingCMB.categories, resolvedFromStore: false, complete: true }; } - return { key: null, categories: null, resolvedFromStore: false }; + return { key: null, categories: null, resolvedFromStore: false, complete: false }; } /** @@ -186,4 +247,4 @@ async function evaluateLineageTetherFromText({ remixCategories, anchorCategories }; } -module.exports = { computeLineageTether, resolveTetherAnchor, evaluateLineageTetherFromText }; +module.exports = { computeLineageTether, resolveTetherAnchor, storedRecordVerifies, evaluateLineageTetherFromText }; diff --git a/tests/lineage-tether-severance.test.js b/tests/lineage-tether-severance.test.js index 4d269383..47d1df76 100644 --- a/tests/lineage-tether-severance.test.js +++ b/tests/lineage-tether-severance.test.js @@ -139,6 +139,41 @@ describe('MMP §15.8 lineage tether — severance through the gate', () => { }); }); + // §15.8 anchors on a root reached by recursively VERIFYING parent records. A peer block this node + // admitted without verifying its author is not such a record: whoever wrote it chose its content + // and its claimed parents, so treating it as the anchor let an unattributable block certify the + // next remix. The remix is stored unchanged (unverifiable is a trust state, never a severance), + // but the integrator signs no tether naming that block. + for (const [label, verified] of [['an UNVERIFIED admission is not an anchor', false], ['a VERIFIED admission is (control)', true]]) { + it(`parent stored from a peer: ${label}`, async () => { + await withNode(`tether-verify-${verified}`, async (node) => { + await awaitSemantic(); + for (const t of TOPIC_B) node.remember(cat7(t)); + const accepted = []; + node.on('cmb-accepted', (e) => accepted.push(e)); + const now = Date.now(); + const parent = createCMB({ categories: cat7(TOPIC_B_ROOT), createdBy: 'peerA' }); + // The frame handler sets this from the author-key signature check before admission; the + // gate is driven directly here, so the verdict is set the way that check would leave it. + const parentFrame = { type: 'cmb', timestamp: now, content: TOPIC_B_ROOT, cmb: parent, _cmbVerified: verified }; + await node._frameHandler._processHeuristicSVAF(parentFrame, 'peerA', 'peerA', now, now, 0); + assert.ok(node._store.get(parent.metadata.key), 'precondition: the parent was admitted and stored'); + + await node._frameHandler._processHeuristicSVAF(inboundFrame(TOPIC_B_NEW, parent.metadata.key), 'peerA', 'peerA', now, now, 0); + const remix = accepted.find((e) => e.cmb.categories.focus.text === TOPIC_B_NEW); + assert.ok(remix, 'the remix admits'); + assert.deepStrictEqual(remix.cmb.metadata.lineage.parents, [parent.metadata.key], 'lineage is kept either way'); + if (verified) { + assert.strictEqual(remix.cmb.tether?.anchor, parent.metadata.key, 'a verified parent anchors the tether'); + assert.strictEqual(remix.cmb.tether.verdict, 'tethered'); + } else { + assert.strictEqual(remix.cmb.tether, undefined, 'no tether attestation names an unverified record'); + assert.strictEqual(remix.cmb.provenance?.tether, undefined, 'the tether is unverified, not evaluated'); + } + }); + }); + } + it('tether disabled (SYM_LINEAGE_TETHER analogue: opts.lineageTether=false) → no severance', async () => { const name = `tether-off-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const node = new SymNode({ name, silent: true, discovery: new NullDiscovery(), lineageTether: false }); diff --git a/tests/lineage-tether-traversal.test.js b/tests/lineage-tether-traversal.test.js index a80120dc..0434251f 100644 --- a/tests/lineage-tether-traversal.test.js +++ b/tests/lineage-tether-traversal.test.js @@ -10,67 +10,172 @@ * - `originTimestamp`, the author's unsigned clock. A backdated time won the earliest-anchor race. * Anchor resolution now walks DIRECT parents through records this node stored, following each * stored record's own parents, and ranks by `storedAt`. + * + * A third input was still the sender's: any STORED record counted as a step, including one this + * node admitted without being able to verify who wrote it. §15.8 walks recursively VERIFIED + * parents, so a step must be a record whose content address recomputes and whose authorship this + * node established — it wrote the record itself, or verified the author's signature on admission. */ const { describe, it } = require('node:test'); const assert = require('node:assert'); -const { resolveTetherAnchor } = require('../lib/core'); +const { resolveTetherAnchor, storedRecordVerifies, createCMB } = require('../lib/core'); -function store(records) { - const m = new Map(records.map((r) => [r.key, r])); +function store(entries) { + const m = new Map(entries.map((e) => [e.key, e])); return (key) => m.get(key); } -const cats = (t) => ({ focus: { text: t }, issue: { text: 'x' } }); -const record = (key, storedAt, parents, extra = {}) => - ({ key, storedAt, cmb: { categories: cats(key), metadata: { lineage: { parents } }, ...extra } }); +const cats = (t) => ({ focus: t, issue: 'x' }); + +/** A real record as the store holds it. `peerId: null` is a record this node wrote; a peer + * admission carries the frame handler's verification verdict in `_cmbVerified`. */ +function entry(text, storedAt, parents = [], envelope = { peerId: null }) { + const cmb = createCMB({ + categories: cats(text), + createdBy: 'author', + lineage: parents.length ? { parents, method: 'SVAF-v2' } : null, + }); + return { key: cmb.metadata.key, storedAt, cmb, ...envelope }; +} +const verifiedAdmission = { peerId: 'peer-1', _cmbVerified: true }; +const unverifiedAdmission = { peerId: 'peer-1', _cmbVerified: false }; + +function remixOf(parents, extra = {}) { + return { metadata: { key: 'cmb-' + 'e'.repeat(64), lineage: { parents, ...extra } }, categories: createCMB({ categories: cats('remix'), createdBy: 'peer' }).categories }; +} describe('§15.8 anchor resolution — sender-supplied inputs never pick the anchor', () => { it('a STORED record named only in lineage.ancestors is not the anchor', () => { // The receiver holds `unrelated` (stored earliest) for its own reasons; the sender names it as // an ancestor. It is not reachable through any parent, so it must not be chosen. - const getEntry = store([ - record('unrelated', 100, []), - record('p1', 900, []), - ]); - const incoming = { metadata: { lineage: { parents: ['p1'], ancestors: ['unrelated', 'p1'] } }, categories: cats('remix') }; - assert.strictEqual(resolveTetherAnchor(incoming, getEntry).key, 'p1'); + const unrelated = entry('unrelated', 100); + const p1 = entry('p1', 900); + const getEntry = store([unrelated, p1]); + const incoming = remixOf([p1.key], { ancestors: [unrelated.key, p1.key] }); + assert.strictEqual(resolveTetherAnchor(incoming, getEntry).key, p1.key); }); it('a backdated author originTimestamp does not win the earliest-anchor race', () => { // Both are direct parents, so this isolates the ranking from the walk. - const getEntry = store([ - record('root', 500, [], { originTimestamp: 500 }), - record('p1', 900, [], { originTimestamp: 1 }), - ]); - const incoming = { metadata: { lineage: { parents: ['p1', 'root'] } }, categories: cats('remix') }; - assert.strictEqual(resolveTetherAnchor(incoming, getEntry).key, 'root'); + const root = entry('root', 500, [], { peerId: null, originTimestamp: 500 }); + const p1 = entry('p1', 900, [], { peerId: null, originTimestamp: 1 }); + const incoming = remixOf([p1.key, root.key]); + assert.strictEqual(resolveTetherAnchor(incoming, store([root, p1])).key, root.key); }); it('a parents-only chain is walked through stored records to the earliest stored root', () => { - const getEntry = store([ - record('root', 500, []), - record('mid', 700, ['root']), - record('p1', 900, ['mid']), - ]); - const incoming = { metadata: { lineage: { parents: ['p1'] } }, categories: cats('remix') }; - assert.strictEqual(resolveTetherAnchor(incoming, getEntry).key, 'root'); + const root = entry('root', 500); + const mid = entry('mid', 700, [root.key], verifiedAdmission); + const p1 = entry('p1', 900, [mid.key], verifiedAdmission); + const r = resolveTetherAnchor(remixOf([p1.key]), store([root, mid, p1])); + assert.strictEqual(r.key, root.key); + assert.strictEqual(r.resolvedFromStore, true); + assert.strictEqual(r.complete, true, 'every edge resolved and verified'); }); it('the walk cannot pass through a parent this node never stored', () => { // mid is missing locally, so root — though stored — is unreachable from p1. - const getEntry = store([record('root', 500, []), record('p1', 900, ['mid'])]); - const incoming = { metadata: { lineage: { parents: ['p1'], ancestors: ['root'] } }, categories: cats('remix') }; - assert.strictEqual(resolveTetherAnchor(incoming, getEntry).key, 'p1'); + const root = entry('root', 500); + const p1 = entry('p1', 900, ['cmb-' + '9'.repeat(64)]); + const incoming = remixOf([p1.key], { ancestors: [root.key] }); + const r = resolveTetherAnchor(incoming, store([root, p1])); + assert.strictEqual(r.key, p1.key); + assert.strictEqual(r.complete, false, 'an unavailable parent is an incomplete proof, never a complete one (§15.2)'); }); it('a cyclic chain terminates', () => { - const getEntry = store([record('a', 800, ['b']), record('b', 700, ['a'])]); - const incoming = { metadata: { lineage: { parents: ['a'] } }, categories: cats('remix') }; - assert.strictEqual(resolveTetherAnchor(incoming, getEntry).key, 'b'); + // Content addresses cannot form a real cycle, so the lineage is set by hand to make one. + const a = entry('a', 800); + const b = entry('b', 700); + a.cmb.metadata.lineage = { parents: [b.key] }; + b.cmb.metadata.lineage = { parents: [a.key] }; + assert.strictEqual(resolveTetherAnchor(remixOf([a.key]), store([a, b])).key, b.key); + }); + + it('a root is its own anchor', () => { + const root = createCMB({ categories: cats('a root'), createdBy: 'peer' }); + const r = resolveTetherAnchor(root, store([])); + assert.strictEqual(r.key, root.metadata.key); + assert.strictEqual(r.resolvedFromStore, false); + assert.strictEqual(r.complete, true); + }); + + it('nothing resolvable locally → the root is unverifiable, never the remix itself or a wire claim', () => { + // A remix is not a root. Anchoring it to itself measured its drift from itself — always + // "tethered" — and the integrator then signed that as a tether to a root nobody verified. + const incoming = remixOf(['cmb-' + '1'.repeat(64)], { ancestors: ['cmb-' + '2'.repeat(64)] }); + const r = resolveTetherAnchor(incoming, store([])); + assert.deepStrictEqual(r, { key: null, categories: null, resolvedFromStore: false, complete: false }); + }); +}); + +describe('§15.8 anchor resolution — the walk passes only through VERIFIED records', () => { + it('a stored parent admitted UNVERIFIED ends that branch — the root behind it is not reached', () => { + // mid's parents are whatever its unverifiable author claimed. Following them let anyone who + // could get one unsigned block admitted choose which stored record anchors the next remix. + const root = entry('root', 500); + const mid = entry('mid', 700, [root.key], unverifiedAdmission); + const p1 = entry('p1', 900, [mid.key], verifiedAdmission); + const r = resolveTetherAnchor(remixOf([p1.key]), store([root, mid, p1])); + assert.strictEqual(r.key, p1.key, 'the oldest record reached through verified edges'); + assert.strictEqual(r.complete, false, 'the walk stopped at an unverifiable parent'); + }); + + it('a remix whose only parent is an unverified admission has no anchor — the root is unverifiable', () => { + const u = entry('unverified', 500, [], unverifiedAdmission); + const r = resolveTetherAnchor(remixOf([u.key]), store([u])); + assert.deepStrictEqual(r, { key: null, categories: null, resolvedFromStore: false, complete: false }); + }); + + it('an admission with NO recorded verdict is unverified, not verified', () => { + // A peer record stored without the frame handler's verdict (an older store, or an admission + // path that never ran the signature check) proves nothing about its author. + const u = entry('no verdict', 500, [], { peerId: 'peer-1' }); + assert.strictEqual(resolveTetherAnchor(remixOf([u.key]), store([u])).key, null); + }); + + it('a stored record whose categories no longer match its address is not walked', () => { + const root = entry('root', 500); + root.cmb.categories.focus.text = 'rewritten after it was addressed'; + const r = resolveTetherAnchor(remixOf([root.key]), store([root])); + assert.strictEqual(r.key, null, 'content that does not answer to its key is not the content the key names'); + }); + + it('two parents: the unverified branch ends, the verified branch still resolves', () => { + const root = entry('root', 500); + const u = entry('unverified older', 100, [], unverifiedAdmission); + const r = resolveTetherAnchor(remixOf([u.key, root.key]), store([root, u])); + assert.strictEqual(r.key, root.key, 'an older unverified record does not win the earliest-stored race'); + assert.strictEqual(r.complete, false); + }); +}); + +describe('storedRecordVerifies — what counts as a verified step', () => { + it('a record this node wrote verifies', () => { + const e = entry('own', 1); + assert.strictEqual(storedRecordVerifies(e, e.key), true); + }); + + it('a peer admission verifies only with a recorded signature verdict', () => { + const v = entry('peer verified', 1, [], verifiedAdmission); + const u = entry('peer unverified', 1, [], unverifiedAdmission); + assert.strictEqual(storedRecordVerifies(v, v.key), true); + assert.strictEqual(storedRecordVerifies(u, u.key), false); + }); + + it('a record stored under a different key does not verify', () => { + const e = entry('own', 1); + assert.strictEqual(storedRecordVerifies(e, 'cmb-' + '3'.repeat(64)), false); + }); + + it('a pre-boundary record (no metadata section) does not verify — §7.8 unverified-legacy', () => { + const e = entry('legacy', 1); + const legacy = { key: e.key, peerId: null, cmb: { key: e.key, categories: e.cmb.categories } }; + assert.strictEqual(storedRecordVerifies(legacy, e.key), false); }); - it('nothing resolvable locally → the block anchors itself, never a wire claim', () => { - const incoming = { metadata: { lineage: { parents: ['p1'], ancestors: ['root'] } }, categories: cats('remix') }; - assert.strictEqual(resolveTetherAnchor(incoming, store([])).resolvedFromStore, false); + it('a bare record with no store envelope does not verify', () => { + const e = entry('bare', 1); + assert.strictEqual(storedRecordVerifies(e.cmb, e.key), false); }); }); From 465b733d6333c0dca8e8b222ef9a4609159bfaa9 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:36:25 +0100 Subject: [PATCH 011/148] directed delivery: a signed addressee decides, whatever the frame says MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/frame-handler.js | 8 +++++++- tests/audit-delivery-authorship.test.js | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/frame-handler.js b/lib/frame-handler.js index d0407f01..3c2a0c67 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -784,7 +784,13 @@ class FrameHandler { const meta = msg.cmb?.metadata; const signedTo = meta && Object.prototype.hasOwnProperty.call(meta, 'to') ? meta.to : undefined; const verified = msg._cmbVerified === true; - msg._directedToMe = frameDirected && !(verified && signedTo !== undefined && signedTo !== this._node.nodeId); + // When the author signed an addressee, that alone decides (§8.8.5 step 7): the frame flags are + // a relay's to set, so stripping `directed` must not turn a signed directed CMB into an + // SVAF-gated broadcast, and adding it must not make a signed broadcast directed. + const signedAddressee = verified && typeof signedTo === 'string' && signedTo.length > 0; + msg._directedToMe = signedAddressee + ? signedTo === this._node.nodeId + : frameDirected && !(verified && signedTo !== undefined && signedTo !== this._node.nodeId); const authenticatedDirected = msg._directedToMe && verified && signedTo === this._node.nodeId; const now = Date.now(); diff --git a/tests/audit-delivery-authorship.test.js b/tests/audit-delivery-authorship.test.js index 53acb4a0..79470ef4 100644 --- a/tests/audit-delivery-authorship.test.js +++ b/tests/audit-delivery-authorship.test.js @@ -194,6 +194,20 @@ describe('directed delivery (MMP §9.2.2, §8.8.2)', () => { }); }); + it('B-R8: a signed addressee decides: a relay that strips the frame\'s directed flag cannot turn it into a broadcast', async () => { + await withNode('r8', async (node) => { + node._pinPeerKey('peerA', PEER_A.pub); + node._svafEvaluator.evaluate = async () => REJECTED; // as a broadcast it would be gated away + const seen = collect(node); + const f = frame(signed(mkCmb('for your eyes: the rollback plan', { to: node.nodeId }))); + assert.strictEqual(f.directed, undefined, 'precondition: the frame does not say directed'); + node._frameHandler.handle('peerA', 'peerA', f); + await settle(); + assert.strictEqual(seen.accepted.length, 1, 'it surfaces as the directed CMB its author signed'); + assert.strictEqual(seen.accepted[0].directed, true); + }); + }); + it('N1: a relay cannot replay a signed directed record by re-spelling its signature or adding an assertionId', async () => { await withNode('d2-respell', async (node) => { node._pinPeerKey('peerA', PEER_A.pub); From 189e0f8fc8b9e300f70248a6e0bba8092ce15cf9 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:37:42 +0100 Subject: [PATCH 012/148] v2.0 receipt: verify against the author's node id, check the assertionId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/frame-handler.js | 30 ++++++++++- tests/v2-author-binding.test.js | 93 +++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 tests/v2-author-binding.test.js diff --git a/lib/frame-handler.js b/lib/frame-handler.js index 3c2a0c67..0253267e 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -446,7 +446,35 @@ class FrameHandler { // than no log, because it reads as a record with no address. const cmbKeyOf = recordKey; // shared accessor — see lib/record.js if (!cmb) { msg._cmbVerified = false; return false; } - const { verifyCMB } = require('./core'); + const { verifyCMB, assertionIdV2_0 } = require('./core'); + + // Published v2.0 records (mmp-sig-v2.0) sign the author's NODE ID, so the verifying key is the + // one held for createdByNodeId, never the delivering peer's (§8.8.4, §18.3.1): verified against + // the deliverer, a peer could vouch for a record by signing it itself under someone else's id. + // Their assertionId must also be the one the preimage yields (§8.8.5 step 5). + const md = cmb.metadata; + if (md && md.signatureSuite === 'mmp-sig-v2.0' && typeof md.createdByNodeId === 'string' && md.createdByNodeId) { + const authorKey = typeof this._node._identityKey === 'function' + ? this._node._identityKey(md.createdByNodeId) + : this._node._peerIdentityKeys?.get(md.createdByNodeId); + // No key for the author yet is key-distribution latency, not forgery: unverified, as for v1. + if (!authorKey) { msg._cmbVerified = false; return false; } + const v2 = verifyCMB(cmb, authorKey); + const idOk = md.assertionId === undefined || md.assertionId === assertionIdV2_0(cmb); + if (v2.valid && idOk) { msg._cmbVerified = true; return false; } + const reason = !v2.valid ? (v2.error || 'invalid') : 'assertion-id-mismatch'; + const k = recordKey(cmb); + this._node._log(`[sym-security] v2.0 CMB ${String(k || '').slice(0, 16)} from ${peerName} rejected — ${reason} (author ${md.createdByNodeId.slice(0, 8)})`); + this._node.emit('metric', { type: 'cmb-signature-rejected', from: peerName, key: k, reason: 'invalid', error: reason }); + if (typeof this._node._recordDecision === 'function') { + this._node._recordDecision({ method: 'signature', source: msg.source || peerName, cmbKey: k, decision: 'rejected-signature', totalDrift: null, categoryDrifts: null, gateValues: null, focusLabel: reason, remix: recordParents(cmb).length > 0 }); + } + return true; + } + // On any other suite no signature covers an assertionId, so a carried one is an unsigned claim + // a relay could set. It is dropped rather than kept beside verified fields. + if (md && Object.prototype.hasOwnProperty.call(md, 'assertionId')) delete md.assertionId; + // Resolve through the roster registry (anchor > handshake > grant-vouched) // so CMBs relayed from peers we never directly handshook still verify, // falling back to the direct-handshake map. diff --git a/tests/v2-author-binding.test.js b/tests/v2-author-binding.test.js new file mode 100644 index 00000000..f7356a38 --- /dev/null +++ b/tests/v2-author-binding.test.js @@ -0,0 +1,93 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME to a temp sandbox before lib/config loads + +/** + * Receipt of published v2.0 records (mmp-sig-v2.0), which sign the author's node id: + * - B-R4 (§8.8.4, §18.3.1): the verifying key is the one held for `createdByNodeId`, never the + * key of whichever peer delivered the frame, so a peer cannot vouch for a record by signing it + * itself under someone else's id, and a genuine relay verifies against the author. + * - B-R6 (§8.8.5 step 5): a carried `assertionId` must be the one the preimage yields; on records + * of the older suite, where no signature covers it, a carried `assertionId` is dropped. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const { SymNode } = require('../lib/node'); +const { NullDiscovery } = require('../lib/discovery'); +const { nodeDir } = require('../lib/config'); +const { createCMB, signCMB, assertionIdV2_0 } = require('../lib/core'); + +function kp() { + const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', { + publicKeyEncoding: { type: 'spki', format: 'der' }, privateKeyEncoding: { type: 'pkcs8', format: 'der' }, + }); + return { pub: publicKey.slice(-32).toString('base64url'), priv: privateKey.slice(-32).toString('base64url') }; +} +const ALICE = kp(), MALLORY = kp(), RELAY = kp(); + +function v2Record({ nodeId, createdBy, signWith, focus = 'v2 observation' }) { + const cmb = createCMB({ categories: { focus }, createdBy, emitV2: true, createdByNodeId: nodeId, room: 'default' }); + cmb.metadata.assertionId = assertionIdV2_0(cmb); + signCMB(cmb, signWith.priv); + return cmb; +} + +function withNode(fn) { + const name = `v2bind-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node = new SymNode({ name, silent: true, discovery: new NullDiscovery() }); + node._pinPeerKey('node-alice', ALICE.pub); + node._pinPeerKey('node-mallory', MALLORY.pub); + node._pinPeerKey('node-relay', RELAY.pub); + try { return fn(node); } finally { fs.rmSync(nodeDir(name), { recursive: true, force: true }); } +} + +describe('v2.0 author binding (B-R4)', () => { + it('a v2.0 record relayed by another peer verifies against its author\'s key', () => { + withNode((node) => { + const msg = { cmb: v2Record({ nodeId: 'node-alice', createdBy: 'alice', signWith: ALICE }) }; + assert.strictEqual(node._frameHandler._rejectOnBadSignature('node-relay', 'relay', msg), false); + assert.strictEqual(msg._cmbVerified, true); + }); + }); + + it('a peer that signs a record under another node\'s id is refused, even delivering it itself', () => { + withNode((node) => { + const msg = { cmb: v2Record({ nodeId: 'node-alice', createdBy: 'alice', signWith: MALLORY }) }; + assert.strictEqual(node._frameHandler._rejectOnBadSignature('node-mallory', 'mallory', msg), true); + }); + }); + + it('an author whose key this node does not hold yet is unverified, not refused', () => { + withNode((node) => { + const stranger = kp(); + const msg = { cmb: v2Record({ nodeId: 'node-stranger', createdBy: 'stranger', signWith: stranger }) }; + assert.strictEqual(node._frameHandler._rejectOnBadSignature('node-relay', 'relay', msg), false); + assert.strictEqual(msg._cmbVerified, false); + }); + }); +}); + +describe('assertionId (B-R6)', () => { + it('a v2.0 record whose carried assertionId is not the preimage\'s is refused', () => { + withNode((node) => { + const cmb = v2Record({ nodeId: 'node-alice', createdBy: 'alice', signWith: ALICE }); + cmb.metadata.assertionId = 'asrt-' + '0'.repeat(64); + assert.strictEqual(node._frameHandler._rejectOnBadSignature('node-alice', 'alice', { cmb }), true); + }); + }); + + it('on an older-suite record, a carried assertionId (which nothing signs) is dropped', () => { + withNode((node) => { + const cmb = createCMB({ categories: { focus: 'old suite' }, createdBy: 'alice', room: 'default' }); + signCMB(cmb, ALICE.priv); + cmb.metadata.assertionId = 'asrt-' + '1'.repeat(64); + const msg = { cmb }; + assert.strictEqual(node._frameHandler._rejectOnBadSignature('node-alice', 'alice', msg), false); + assert.strictEqual(msg._cmbVerified, true); + assert.strictEqual('assertionId' in msg.cmb.metadata, false); + }); + }); +}); From ee965cc9b2e882e013f1fdb0d2a6eb8e65d5600a Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:39:04 +0100 Subject: [PATCH 013/148] identity lock: remember a live pid's start time; read many pids in one 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/config.js | 189 ++++++++++++++++++++++++++------ lib/node.js | 31 +++--- tests/config.test.js | 16 +++ tests/start-time-cache.test.js | 192 +++++++++++++++++++++++++++++++++ 4 files changed, 379 insertions(+), 49 deletions(-) create mode 100644 tests/start-time-cache.test.js diff --git a/lib/config.js b/lib/config.js index be431d41..24447e9d 100644 --- a/lib/config.js +++ b/lib/config.js @@ -341,10 +341,12 @@ function bootTimeMs() { * lock distinguish "our previous holder is still running" from "an * unrelated process recycled the holder's PID". * @param {number} pid + * @param {{fresh?: boolean}} [opts] — fresh: re-read even if a start time is remembered * @returns {string|null} */ -function processStartTime(pid) { - if (process.platform === 'win32') return windowsProcessStartTime(pid); +function processStartTime(pid, { fresh = false } = {}) { + const cache = startTimeCache(); + if (cache) return cachedProcessStartTime(cache, pid, fresh); try { const out = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', @@ -358,43 +360,133 @@ function processStartTime(pid) { } /** - * Windows has no `ps`. PowerShell reports a process's start time; the UTC ISO-8601 form is stable - * across calls and locales, so the lock writer (for itself) and a later reader (for the same PID) - * produce the same string. Without it, a Windows lock recorded no start time and a crashed - * holder's recycled PID kept the name locked until reboot. + * Read the start times of several processes at once, so a later processStartTime() for any of + * them is answered from memory. A no-op where reads are cheap enough not to be remembered (`ps`). + * @param {number[]} pids + */ +function primeProcessStartTimes(pids) { + const cache = startTimeCache(); + if (cache) cache.prime(pids.filter(validStartTimePid)); +} + +/** + * A memory of process start times in front of a slow lookup — on Windows every read is a + * PowerShell process (~0.45 s, synchronous), and a daemon start checks the lock of every node + * directory. + * + * A start time belongs to a PROCESS, and a live process's start time never changes, so a + * successful read is kept for as long as the pid stays alive. Once the pid is seen dead the + * entry is dropped, because the number is now free for an unrelated process. A failed read is + * kept only briefly: it may be a transient timeout, but retrying it on every check would put a + * multi-second call back on every lock check for a process we cannot read. * + * What memory cannot see is a holder that dies and whose pid is reused BETWEEN two of our + * checks: nothing observed it dead, so its remembered start outlives it. `fresh` re-reads and + * replaces the entry, and the lock check re-reads before it acts on a mismatch (see + * lockIsHeldByLiveProcess) — so a remembered value can only err toward "held", the direction a + * failed lookup already takes, never toward handing a live holder's lock to someone else. + * + * @param {(pids: number[]) => Map} lookup — reads many pids in ONE call; a pid + * it cannot read is absent from the result; throwing means it could read none + * @param {object} [o] + * @param {(pid: number) => boolean} [o.isAlive] + * @param {number} [o.failureTtlMs] — how long a failed read is remembered + * @param {() => number} [o.now] + */ +function createStartTimeCache(lookup, { isAlive = pidIsAlive, failureTtlMs = START_FAILURE_TTL_MS, now = Date.now } = {}) { + const entries = new Map(); // pid → { value: string|null, at: ms } + const remembered = (pid) => { + const e = entries.get(pid); + return Boolean(e) && (e.value !== null || now() - e.at < failureTtlMs); + }; + const read = (pids) => { + let got; + try { got = lookup(pids); } catch { got = null; } + for (const pid of pids) { + const value = got instanceof Map && typeof got.get(pid) === 'string' ? got.get(pid) : null; + entries.set(pid, { value, at: now() }); + } + }; + return { + /** The start time of `pid`, or null when it is dead or cannot be read. */ + get(pid, { fresh = false } = {}) { + if (!isAlive(pid)) { entries.delete(pid); return null; } + if (fresh || !remembered(pid)) read([pid]); + return entries.get(pid).value; + }, + /** Read every live pid not already remembered, in one lookup. */ + prime(pids) { + const need = []; + for (const pid of new Set(pids)) { + if (!isAlive(pid)) entries.delete(pid); + else if (!remembered(pid)) need.push(pid); + } + if (need.length) read(need); + }, + clear() { entries.clear(); }, + }; +} +const START_FAILURE_TTL_MS = 10000; + +function validStartTimePid(pid) { + return Number.isInteger(pid) && pid > 0 && pid <= 0xffffffff; +} + +let _startTimeCache = null; +let _startTimeLookupOverride = null; // test hook: see _setProcessStartTimeLookup +let _startTimeWarned = false; + +/** The remembered-start-time path, or null where start times are read directly (`ps`). */ +function startTimeCache() { + if (!_startTimeLookupOverride && process.platform !== 'win32') return null; + if (!_startTimeCache) _startTimeCache = createStartTimeCache(_startTimeLookupOverride || windowsStartTimes); + return _startTimeCache; +} + +/** * Failure (no PowerShell, a timeout, access denied for another user's or a protected process) * returns null, which every reader treats as "held": the safe direction, and the pre-0.13.13 - * behaviour. A one-time warning says so. Results are cached briefly per PID, because a daemon - * checks every node directory's lock at start and many of them share one holder process. - * @param {number} pid - * @returns {string|null} + * behaviour. A one-time warning says so. */ -function windowsProcessStartTime(pid) { - if (!Number.isInteger(pid) || pid <= 0 || pid > 0xffffffff) return null; - const cached = _winStartCache.get(pid); - if (cached && Date.now() - cached.at < WIN_START_CACHE_MS) return cached.value; - let value = null; - try { - const out = execFileSync(windowsPowerShellPath(), [ - '-NoProfile', '-NonInteractive', '-Command', - `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')`, - ], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim(); - value = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/.test(out) ? out : null; - } catch { - value = null; - } - if (value === null && !_winStartWarned) { - _winStartWarned = true; +function cachedProcessStartTime(cache, pid, fresh) { + if (!validStartTimePid(pid)) return null; + const value = cache.get(pid, { fresh }); + if (value === null && !_startTimeWarned && pidIsAlive(pid)) { + _startTimeWarned = true; process.emitWarning(`SYM identity lock: could not read the start time of process ${pid}; ` + 'a lock held by that PID is treated as live (delete ~/.sym/nodes//lock.pid if its holder is gone).'); } - _winStartCache.set(pid, { value, at: Date.now() }); return value; } -const WIN_START_CACHE_MS = 10000; -const _winStartCache = new Map(); -let _winStartWarned = false; + +/** + * Windows has no `ps`. PowerShell reports a process's start time; the UTC ISO-8601 form is stable + * across calls and locales, so the lock writer (for itself) and a later reader (for the same PID) + * produce the same string. Without it, a Windows lock recorded no start time and a crashed + * holder's recycled PID kept the name locked until reboot. + * + * Every pid asked for is read in ONE PowerShell process: `Get-Process -Id a,b,c` skips the ones + * that are gone, and a process whose start time cannot be read (access denied) prints nothing, + * so it is simply absent from the result. `exit 0` because PowerShell exits 1 when any pid in + * the list was missing, which would otherwise throw away the ones it did read. The + * per-process expression is the one earlier versions used for a single pid, so the strings + * match locks those versions wrote. + * @param {number[]} pids + * @returns {Map} + */ +function windowsStartTimes(pids) { + const out = execFileSync(windowsPowerShellPath(), [ + '-NoProfile', '-NonInteractive', '-Command', + `Get-Process -Id ${pids.join(',')} -ErrorAction SilentlyContinue | ForEach-Object { ` + + `try { '{0} {1}' -f $_.Id, $_.StartTime.ToUniversalTime().ToString('o') } catch { } }; exit 0`, + ], { encoding: 'utf8', timeout: 5000 + 250 * pids.length, windowsHide: true }); + const times = new Map(); + for (const line of out.split(/\r?\n/)) { + const m = /^(\d+) (\d{4}-\d{2}-\d{2}T[\d:.]+Z)$/.exec(line.trim()); + if (m) times.set(Number(m[1]), m[2]); + } + return times; +} /** The system PowerShell by absolute path, never whatever `powershell.exe` the search path finds first. */ function windowsPowerShellPath() { @@ -483,7 +575,11 @@ function lockIsHeldByLiveProcess(lock) { } if (!pidIsAlive(lock.pid)) return false; if (lock.start) { - const current = processStartTime(lock.pid); + let current = processStartTime(lock.pid); + // A remembered start time (Windows) may predate a death and reuse of this pid that no + // check observed. A mismatch hands the lock to someone else, so it is never concluded + // from memory: re-read first. A match errs only toward "held". + if (current && current !== lock.start) current = processStartTime(lock.pid, { fresh: true }); if (current && current !== lock.start) return false; // PID reused } else if (lock.mtimeMs < bootTimeMs() - LOCK_BOOT_SLACK_MS) { // Legacy lock with no start-time metadata, written before this boot: @@ -493,6 +589,18 @@ function lockIsHeldByLiveProcess(lock) { return true; } +/** + * lockIsHeldByLiveProcess for several locks, with every holder's start time read in one + * lookup rather than one per lock — on Windows that is one PowerShell process instead of one + * per node directory. + * @param {Array<{pid: number|null, start: string|null, mtimeMs: number}|null>} locks + * @returns {boolean[]} + */ +function locksHeldByLiveProcess(locks) { + primeProcessStartTimes(locks.filter((l) => l && l.pid !== null && l.start).map((l) => l.pid)); + return locks.map((l) => lockIsHeldByLiveProcess(l)); +} + /** * Read the holder PID from a node's lockfile, or null if the lockfile is * absent, unreadable, or has non-numeric content. Understands both the @@ -685,10 +793,25 @@ module.exports = { normalizeMdnsHostname, pidIsAlive, processStartTime, - /** Test hook: forget cached Windows start times, so a test reaches PowerShell on every call. */ - _clearProcessStartTimeCache: () => _winStartCache.clear(), + primeProcessStartTimes, + /** Test hook: forget remembered start times, so the next read reaches the lookup. */ + _clearProcessStartTimeCache: () => { if (_startTimeCache) _startTimeCache.clear(); }, + /** + * Test hook: read start times through `lookup` (see createStartTimeCache) on every platform, + * so the remembered-start-time path Windows takes can be exercised anywhere. null restores + * the platform default. + */ + _setProcessStartTimeLookup: (lookup) => { + _startTimeLookupOverride = lookup || null; + _startTimeCache = null; + _startTimeWarned = false; + }, + _createStartTimeCache: createStartTimeCache, + /** Test hook (win32 only): the raw batched PowerShell lookup. */ + _windowsStartTimes: windowsStartTimes, readLockFile, lockIsHeldByLiveProcess, + locksHeldByLiveProcess, lockHolderPid, acquireIdentityLock, log, diff --git a/lib/node.js b/lib/node.js index 607a20e3..0fd7c571 100644 --- a/lib/node.js +++ b/lib/node.js @@ -3174,25 +3174,24 @@ class SymNode extends EventEmitter { // node at sym / mesh-channel install so readers use the `cmbs/` name with no // fallback. A LIVE node is skipped — it is running the prior code on `meshmem/` // and self-migrates on its next restart; renaming under it would break it. -function _nodeIsLive(nodeDir) { - const { readLockFile, lockIsHeldByLiveProcess } = require('./config'); - return lockIsHeldByLiveProcess(readLockFile(path.join(nodeDir, 'lock.pid'))); -} - +// +// Only a node that still needs the rename has its lock checked, and all of those +// checks share one start-time lookup: on Windows each lookup is a PowerShell +// process, and the daemon runs this pass at every start over every node dir. function migrateStores(nodesRoot = path.join(require('./core/state-root').SYM_STATE_DIR, 'nodes')) { - let migrated = 0; + const { readLockFile, locksHeldByLiveProcess } = require('./config'); let dirs; try { dirs = fs.readdirSync(nodesRoot, { withFileTypes: true }); } catch { return 0; } - for (const d of dirs) { - if (!d.isDirectory()) continue; - const nodeDir = path.join(nodesRoot, d.name); - if (_nodeIsLive(nodeDir)) continue; // live node self-migrates on restart - const meshmem = path.join(nodeDir, 'meshmem'); - const cmbs = path.join(nodeDir, 'cmbs'); - if (fs.existsSync(meshmem) && !fs.existsSync(cmbs)) { - try { fs.renameSync(meshmem, cmbs); migrated++; } catch { /* leave it */ } - } - } + const pending = dirs + .filter((d) => d.isDirectory()) + .map((d) => path.join(nodesRoot, d.name)) + .filter((nodeDir) => fs.existsSync(path.join(nodeDir, 'meshmem')) && !fs.existsSync(path.join(nodeDir, 'cmbs'))); + const live = locksHeldByLiveProcess(pending.map((nodeDir) => readLockFile(path.join(nodeDir, 'lock.pid')))); + let migrated = 0; + pending.forEach((nodeDir, i) => { + if (live[i]) return; // live node self-migrates on restart + try { fs.renameSync(path.join(nodeDir, 'meshmem'), path.join(nodeDir, 'cmbs')); migrated++; } catch { /* leave it */ } + }); return migrated; } diff --git a/tests/config.test.js b/tests/config.test.js index 70907103..80f0ff9d 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -13,6 +13,7 @@ const { uuidv7, validateName, generateSigningKeyPair, loadOrCreateIdentity, normalizeMdnsHostname, pidIsAlive, lockHolderPid, log, acquireIdentityLock, readLockFile, processStartTime, _clearProcessStartTimeCache, + primeProcessStartTimes, _windowsStartTimes, } = require('../lib/config'); describe('uuidv7', () => { @@ -348,6 +349,21 @@ describe('acquireIdentityLock', () => { assert.notStrictEqual(processStartTime(liveChild.pid), a, 'a different process differs'); }); + it('on Windows, one PowerShell call reads several pids and agrees with single reads', { skip: process.platform !== 'win32' }, () => { + // A pid that is gone must not cost the others their answer: PowerShell exits 1 when any id + // in the list is missing, and the lookup must still return the ones it read. + const both = _windowsStartTimes([process.pid, liveChild.pid, DEAD_PID]); + assert.strictEqual(both.has(DEAD_PID), false); + _clearProcessStartTimeCache(); + primeProcessStartTimes([process.pid, liveChild.pid]); + const batched = [processStartTime(process.pid), processStartTime(liveChild.pid)]; + assert.deepStrictEqual(batched, [both.get(process.pid), both.get(liveChild.pid)]); + assert.deepStrictEqual(batched, [ + processStartTime(process.pid, { fresh: true }), processStartTime(liveChild.pid, { fresh: true }), + ], 'a single-pid read gives the same strings'); + assert.match(String(batched[1]), /^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/); + }); + it('on Windows, a lock recorded with a recycled PID\'s old start time is reclaimed', { skip: process.platform !== 'win32' }, () => { const name = mkName(); writeLock(name, `${liveChild.pid}\n{"start":"2004-01-01T00:00:00.0000000Z","createdAt":1}\n`); diff --git a/tests/start-time-cache.test.js b/tests/start-time-cache.test.js new file mode 100644 index 00000000..0cf45a00 --- /dev/null +++ b/tests/start-time-cache.test.js @@ -0,0 +1,192 @@ +'use strict'; + +require('./_isolate-home'); // sandbox HOME/USERPROFILE before anything reads os.homedir() + +/** + * Remembered process start times — the path every Windows identity-lock check takes. + * + * On Windows a start-time read is a PowerShell process (~0.45 s, synchronous). It used to run + * once per pid per lock check, remembered for 10 s, so a daemon start that checked every node + * directory's lock paid it per directory. A live process's start time cannot change, so a read + * is now remembered until the pid is seen dead, and a pass over many locks reads all of their + * holders in one lookup. + * + * Everything here runs through an injected lookup, so it is exercised on every platform; the + * PowerShell lookup itself only runs on Windows (config.test.js has the win32-only checks). + */ + +const { describe, it, before, after, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const cfg = require('../lib/config'); +const { migrateStores } = require('../lib/node'); +const { tmpdir } = require('./_tmpdir'); + +/** A lookup that answers from `table` and records every call it receives. */ +function recordingLookup(table) { + const calls = []; + const lookup = (pids) => { + calls.push([...pids].sort((a, b) => a - b)); + const out = new Map(); + for (const pid of pids) if (typeof table[pid] === 'string') out.set(pid, table[pid]); + return out; + }; + return { lookup, calls }; +} + +describe('createStartTimeCache', () => { + it('a live pid is read once and remembered well past the old 10 s window', () => { + let t = 0; + const { lookup, calls } = recordingLookup({ 100: 'S100' }); + const cache = cfg._createStartTimeCache(lookup, { isAlive: () => true, now: () => t }); + assert.equal(cache.get(100), 'S100'); + t += 60 * 60 * 1000; // an hour later + assert.equal(cache.get(100), 'S100'); + assert.equal(cache.get(100), 'S100'); + assert.equal(calls.length, 1, 'one lookup for a pid that stayed alive'); + }); + + it('a pid seen dead is forgotten, and a reused pid is read afresh', () => { + let alive = true; + const table = { 200: 'old-process' }; + const { lookup, calls } = recordingLookup(table); + const cache = cfg._createStartTimeCache(lookup, { isAlive: () => alive }); + assert.equal(cache.get(200), 'old-process'); + alive = false; + assert.equal(cache.get(200), null, 'a dead pid has no start time'); + assert.equal(calls.length, 1, 'and is not looked up'); + alive = true; + table[200] = 'new-process'; // the number was reused + assert.equal(cache.get(200), 'new-process'); + assert.equal(calls.length, 2); + }); + + it('fresh re-reads, and a different start replaces the remembered one', () => { + const table = { 300: 'A' }; + const { lookup, calls } = recordingLookup(table); + const cache = cfg._createStartTimeCache(lookup, { isAlive: () => true }); + assert.equal(cache.get(300), 'A'); + table[300] = 'B'; // died and was reused between two checks, unobserved + assert.equal(cache.get(300), 'A', 'memory cannot see an unobserved reuse'); + assert.equal(cache.get(300, { fresh: true }), 'B'); + assert.equal(cache.get(300), 'B', 'the new read replaced the old one'); + assert.equal(calls.length, 2); + }); + + it('prime reads every live, unremembered pid in ONE lookup', () => { + const { lookup, calls } = recordingLookup({ 1: 'a', 2: 'b', 3: 'c', 4: 'd' }); + const cache = cfg._createStartTimeCache(lookup, { isAlive: (pid) => pid !== 9 }); + cache.prime([3, 1, 2, 2, 9]); + assert.deepEqual(calls, [[1, 2, 3]], 'one call, duplicates folded, the dead pid skipped'); + assert.deepEqual([cache.get(1), cache.get(2), cache.get(3)], ['a', 'b', 'c']); + assert.equal(calls.length, 1, 'answered from memory'); + cache.prime([1, 4]); + assert.deepEqual(calls[1], [4], 'only the pid not already remembered'); + }); + + it('a failed read is remembered only briefly, then retried', () => { + let t = 0; + let fail = true; + const calls = []; + const lookup = (pids) => { + calls.push(pids); + if (fail) throw new Error('powershell timed out'); + return new Map(pids.map((p) => [p, 'S'])); + }; + const cache = cfg._createStartTimeCache(lookup, { isAlive: () => true, now: () => t, failureTtlMs: 10000 }); + assert.equal(cache.get(500), null); + t += 5000; + assert.equal(cache.get(500), null); + assert.equal(calls.length, 1, 'not retried on every check'); + t += 6000; + fail = false; + assert.equal(cache.get(500), 'S', 'retried once the failure has aged out'); + assert.equal(calls.length, 2); + }); + + it('a pid the lookup could not read (absent from its answer) is a failed read', () => { + const { lookup } = recordingLookup({ 1: 'a' }); + const cache = cfg._createStartTimeCache(lookup, { isAlive: () => true }); + cache.prime([1, 2]); + assert.equal(cache.get(1), 'a'); + assert.equal(cache.get(2), null); + }); +}); + +describe('the lock check on the remembered-start-time path', () => { + let holderA; + let holderB; + before(() => { + holderA = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }); + holderB = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }); + }); + after(() => { + for (const c of [holderA, holderB]) { try { c.kill('SIGKILL'); } catch {} } + }); + afterEach(() => cfg._setProcessStartTimeLookup(null)); + + const lock = (pid, start) => ({ pid, start, mtimeMs: Date.now() }); + + it('a lookup failure leaves the lock held, and warns once', async () => { + cfg._setProcessStartTimeLookup(() => { throw new Error('no PowerShell'); }); + const warnings = []; + const onWarning = (w) => { if (/SYM identity lock/.test(w.message)) warnings.push(w); }; + process.on('warning', onWarning); + try { + assert.equal(cfg.lockIsHeldByLiveProcess(lock(holderA.pid, 'S-A')), true); + assert.equal(cfg.lockIsHeldByLiveProcess(lock(holderB.pid, 'S-B')), true); + await new Promise((resolve) => setImmediate(resolve)); // emitWarning delivers on a later tick + assert.equal(warnings.length, 1, 'one warning, not one per check'); + } finally { + process.off('warning', onWarning); + } + }); + + it('a mismatch on a remembered start is re-read before the lock is handed over', () => { + const table = { [holderA.pid]: 'first-occupant' }; + const { lookup, calls } = recordingLookup(table); + cfg._setProcessStartTimeLookup(lookup); + assert.equal(cfg.processStartTime(holderA.pid), 'first-occupant'); + // The first occupant died and the pid was reused, unobserved; the new occupant wrote the lock. + table[holderA.pid] = 'second-occupant'; + assert.equal(cfg.lockIsHeldByLiveProcess(lock(holderA.pid, 'second-occupant')), true, + 'a live holder must not lose its lock to a stale memory'); + assert.equal(calls.length, 2, 'the mismatch was confirmed by a fresh read'); + assert.equal(cfg.lockIsHeldByLiveProcess(lock(holderA.pid, 'recycled-away')), false, 'a real mismatch is still stale'); + }); + + it('a match is answered from memory: one read per holder however often it is checked', () => { + const { lookup, calls } = recordingLookup({ [holderA.pid]: 'S-A' }); + cfg._setProcessStartTimeLookup(lookup); + for (let i = 0; i < 5; i++) assert.equal(cfg.lockIsHeldByLiveProcess(lock(holderA.pid, 'S-A')), true); + assert.equal(calls.length, 1); + }); + + it('migrateStores reads every pending node\'s holder in ONE lookup, and only the pending ones', () => { + const root = tmpdir('sym-migrate-'); + const DEAD = 2147483646; + const nodes = { + 'held-by-a-1': { pid: holderA.pid, start: 'S-A', pending: true }, + 'held-by-a-2': { pid: holderA.pid, start: 'S-A', pending: true }, + 'held-by-b': { pid: holderB.pid, start: 'S-B', pending: true }, + 'holder-dead': { pid: DEAD, start: 'S-dead', pending: true }, + 'already-migrated': { pid: holderB.pid, start: 'S-B', pending: false }, + }; + for (const [name, n] of Object.entries(nodes)) { + const dir = path.join(root, name); + fs.mkdirSync(path.join(dir, n.pending ? 'meshmem' : 'cmbs'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'lock.pid'), `${n.pid}\n${JSON.stringify({ start: n.start, createdAt: Date.now() })}\n`); + } + const { lookup, calls } = recordingLookup({ [holderA.pid]: 'S-A', [holderB.pid]: 'S-B' }); + cfg._setProcessStartTimeLookup(lookup); + + assert.equal(migrateStores(root), 1, 'only the node whose holder is dead is migrated'); + assert.deepEqual(calls, [[holderA.pid, holderB.pid].sort((x, y) => x - y)], 'one lookup for both live holders'); + assert.ok(fs.existsSync(path.join(root, 'holder-dead', 'cmbs'))); + for (const live of ['held-by-a-1', 'held-by-a-2', 'held-by-b']) { + assert.ok(fs.existsSync(path.join(root, live, 'meshmem')), `${live} is live and left alone`); + } + }); +}); From 4523e95140524e2940c55495134152ff42976b24 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:39:15 +0100 Subject: [PATCH 014/148] lineage tether: measure the stored record's text, in one kernel (B-L5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/core/lineage-tether.js | 55 ++++--- lib/core/svaf-baseline.js | 69 ++++----- tests/lineage-tether-reproducible.test.js | 172 ++++++++++++++++++++++ 3 files changed, 233 insertions(+), 63 deletions(-) create mode 100644 tests/lineage-tether-reproducible.test.js diff --git a/lib/core/lineage-tether.js b/lib/core/lineage-tether.js index c2743bfd..46fc4e3e 100644 --- a/lib/core/lineage-tether.js +++ b/lib/core/lineage-tether.js @@ -204,16 +204,24 @@ function resolveTetherAnchor(incomingCMB, getEntry) { } /** - * Text-based tether evaluation for RETROACTIVE audit (§15.8 applied to stored - * chains). Unlike the in-gate evaluation — which compares the freshly fused - * remix vectors against the re-encoded anchor — an audit holds two stored - * rows whose persisted vectors may predate the current encoder, so BOTH - * sides' category texts are re-encoded with the current kernel before the drift - * check. Returns the §15.8 record shape including the kernelId the verdict - * was made in. + * THE §15.8 tether evaluation: content-only, from the two records' TEXT, in one kernel. The gate + * runs it on the record it stores, and the retroactive audit on stored chains, so an integrator's + * verdict and an auditor's are the same computation. + * + * Content-only is what makes the number reproducible. The root is content-addressed and vectors + * are excluded from the address and from cmb-fetch, so any holder of the root and the record has + * exactly two texts and its kernel. Every input here is one of those: each category's text is + * re-encoded, and a vector riding on either side is ignored — including on a category with no + * text, where it used to be taken as-is, so a value no other holder has could reach the verdict. + * + * One kernel is what makes it a comparison at all (§15.8 MUST). The semantic encoder replaces the + * lexical one while a node runs, and the two produce same-length vectors in unrelated spaces, so an + * evaluation whose encodes straddle the switch would compare noise and could sever a faithful + * chain. The switch is one-way, so equal kernel identities before and after the encodes mean none + * happened in between; when they differ, both sides are encoded again. * * @param {object} opts - * @param {object} opts.remixCategories - stored remix CAT7 categories (text used). + * @param {object} opts.remixCategories - the remix record's CAT7 categories (text used). * @param {object} opts.anchorCategories - resolved/fetched anchor CAT7 categories (text used). * @param {object} [opts.categoryWeights] * @param {number} [opts.guardedThreshold=0.5] @@ -227,24 +235,25 @@ async function evaluateLineageTetherFromText({ remixCategories, anchorCategories const f = categories ? categories[category] : null; if (!f) continue; const text = typeof f === 'object' ? (f.text ?? '') : String(f ?? ''); - out[category] = { text }; - if (text) { - const { h1 } = await encodeForSVAF(text); - out[category].vector = h1; - } else if (f && typeof f === 'object' && f.vector) { - out[category].vector = f.vector; - } + if (!text) continue; // no text, no content — nothing a holder of the record could recompute + const { h1 } = await encodeForSVAF(text); + out[category] = { text, vector: h1 }; } return out; } - // With the semantic encoder ready both sides land in one kernel; on the - // lexical fallback encodeForSVAF is the n-gram encoder, so the property - // (single kernel per comparison) holds either way. - const [r, a] = [await reencode(remixCategories), await reencode(anchorCategories)]; - return { - kernelId: kernelId(), - ...computeLineageTether({ remixCategories: r, anchorCategories: a, categoryWeights, guardedThreshold }), - }; + // A switch happens at most once per process, so a second pass is always in one kernel; the bound + // only keeps a broken encoder from looping, and what it returns then is the honest "unchecked". + for (let attempt = 0; attempt < 3; attempt++) { + const kernel = kernelId(); + const r = await reencode(remixCategories); + const a = await reencode(anchorCategories); + if (kernelId() !== kernel) continue; + return { + kernelId: kernel, + ...computeLineageTether({ remixCategories: r, anchorCategories: a, categoryWeights, guardedThreshold }), + }; + } + return { kernelId: kernelId(), checked: false, tethered: true, drift: null, evaluableCategories: [] }; } module.exports = { computeLineageTether, resolveTetherAnchor, storedRecordVerifies, evaluateLineageTetherFromText }; diff --git a/lib/core/svaf-baseline.js b/lib/core/svaf-baseline.js index 33e62192..7dafc8ff 100644 --- a/lib/core/svaf-baseline.js +++ b/lib/core/svaf-baseline.js @@ -23,7 +23,7 @@ const { createCMB, renderContent: renderCMB, cosineSimilarity, l2Normalize, CAT7_CATEGORIES, categoryKeyV1, encodeCategory, blockKeyV2 } = require('./cmb-encoder'); const { encodeForSVAF, isSemanticReady, kernelId } = require('./context-encoder'); -const { computeLineageTether } = require('./lineage-tether'); +const { evaluateLineageTetherFromText } = require('./lineage-tether'); /** Per-category admission verdicts — the first-class gating output (MMP §9.2). */ const CATEGORY_VERDICT = Object.freeze({ @@ -236,6 +236,31 @@ function strippedOfVectors(categories) { return out; } +/** + * MMP §15.8 lineage tether of the record an admission stores, against the anchor the caller + * resolved. The caller applies severance and signs the attestation; this only reports. + * + * Measured on the RECORD'S TEXT, never on the fused vectors. A fused vector is this receiver's + * attention readout over its own recent anchors (computeCategoryDrifts), so a tether taken on it + * measured how the remix sat in THIS node's memory — two integrators holding the same root and the + * same record signed different drifts, and nobody holding them could recompute either one. §15.8 + * makes the tether content-only so that any holder of the root re-encodes its text and gets the + * number the integrator signed; the text of both records is all such a holder has. + * + * @returns {Promise} null when no anchor was resolved (the tether is unverified). + */ +async function tetherOfRecord(recordCategories, tetherAnchor, config) { + if (!tetherAnchor || !tetherAnchor.categories) return null; + const ev = await evaluateLineageTetherFromText({ + remixCategories: recordCategories, + anchorCategories: tetherAnchor.categories, + categoryWeights: config.categoryWeights, + guardedThreshold: config.guardedThreshold, + }); + // §15.8 kernel identity rides in `ev.kernelId`: verdicts are comparable iff it matches. + return { anchorKey: tetherAnchor.key ?? null, ...ev }; +} + /** * The per-category drift arithmetic (§9.2/§9.2.1), as a PURE function of already-encoded vectors. * @@ -568,9 +593,8 @@ async function _processHeuristicSVAF(opts) { // "Emitters MUST NOT include embedding vectors" — and fusion was putting one back in on the // receiver side, so this applies the existing rule to the remix rather than inventing one. // - // Stripped HERE and not in computeCategoryDrifts, because the in-call vectors are still load - // bearing: computeLineageTether reads remixCategories[f].vector and silently degrades to - // "not checked" without it. Computation keeps its vectors; the RECORD carries none. + // Stripped HERE and not in computeCategoryDrifts, whose fused readout is the measurement it + // returns. Computation keeps its vectors; the RECORD carries none. // `meta` is preserved — categoryParentsCommitment signs meta.parents. categories: strippedOfVectors(fusedCategories), createdBy: localName, @@ -587,42 +611,7 @@ async function _processHeuristicSVAF(opts) { const fusedContent = renderCMB(fusedCMB); const fusedEntry = { ...msg, key: fusedKey, content: fusedContent, source: `${localName}+${msg.source || peerName}`, cmb: fusedCMB, storedAt: now }; - // MMP §15.8 lineage tether — evaluated HERE so both sides share one kernel: - // the remix's fused vectors are current-encoder products, while a stored - // anchor's vectors may predate the encoder (n-gram vs semantic occupy the - // same 192 dims but different spaces, so a raw comparison is meaningless). - // Re-encode the anchor's category texts exactly as the gate re-encodes its - // anchors, then run the content-only drift check. The caller resolves the - // anchor and applies severance; this only reports. - let tether = null; - const tetherAnchor = opts.tetherAnchor; - if (tetherAnchor && tetherAnchor.categories) { - let anchorCategories = tetherAnchor.categories; - if (useSemantic) { - anchorCategories = {}; - for (const category of CAT7_CATEGORIES) { - const f = tetherAnchor.categories[category]; - if (!f) continue; - anchorCategories[category] = { ...f }; - if (f.text) { - const { h1 } = await encodeForSVAF(f.text); - anchorCategories[category].vector = h1; - } - } - } - tether = { - anchorKey: tetherAnchor.key ?? null, - // §15.8 kernel identity — the kernel this evaluation was made in; - // verdicts are comparable iff kernelId matches. - kernelId: kernelId(), - ...computeLineageTether({ - remixCategories: fusedCategories, - anchorCategories, - categoryWeights: config.categoryWeights, - guardedThreshold: config.guardedThreshold, - }), - }; - } + const tether = await tetherOfRecord(fusedCMB.categories, opts.tetherAnchor, config); return { accepted: true, decision, totalDrift, categoryVerdicts: computeCategoryVerdicts(verdictDrifts, config, nearestDrifts), fusedEntry, fusedContent, effectiveTau: tau, changeSignal: 0, tether }; } diff --git a/tests/lineage-tether-reproducible.test.js b/tests/lineage-tether-reproducible.test.js new file mode 100644 index 00000000..7f1055a9 --- /dev/null +++ b/tests/lineage-tether-reproducible.test.js @@ -0,0 +1,172 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME to a temp sandbox before lib/config loads + +/** + * MMP §15.8 — the tether is CONTENT-ONLY, so it is REPRODUCIBLE. + * + * "No vector crosses the wire: the root is content-addressed, so any holder of the root re-encodes + * its text and recomputes the tether." That sentence is the reason a tether attestation can be + * checked at all: a node that holds the root and the record recomputes the number the integrator + * signed. It holds only if the number is a function of the two texts and the kernel — nothing that + * belongs to the integrator alone. + * + * The gate measured the remix side on its FUSED vectors, and a fused vector is the receiver's + * attention readout over its own recent anchors. Two nodes holding the same root and the same + * record signed different drifts, and neither could reproduce the other's. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const { SymNode } = require('../lib/node'); +const { NullDiscovery } = require('../lib/discovery'); +const { nodeDir } = require('../lib/config'); +const core = require('../lib/core'); +const { createCMB, isSemanticReady, evaluateLineageTetherFromText } = core; + +// The reject-floor calibration assumes the semantic kernel (§9.2.1: thresholds are meaningful only +// within a pinned encoder), and a comparison across two nodes is only meaningful in one kernel. +async function awaitSemantic(timeoutMs = 30000) { + const t0 = Date.now(); + while (!isSemanticReady()) { + if (Date.now() - t0 > timeoutMs) throw new Error('semantic encoder did not become ready'); + await new Promise((r) => setTimeout(r, 200)); + } +} + +async function withNode(baseName, fn) { + const name = `${baseName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node = new SymNode({ name, silent: true, discovery: new NullDiscovery() }); + await node.start(); + try { + return await fn(node); + } finally { + await node.stop(); + fs.rmSync(nodeDir(name), { recursive: true, force: true }); + } +} + +function cat7(t) { + return { + focus: t, issue: t, intent: t, motivation: t, commitment: t, + perspective: 'peerA', mood: { text: 'neutral', valence: 0, arousal: 0 }, + }; +} + +const ROOT = 'overall report on snowy mountain hiking trail conditions this weekend'; +const INCOMING = 'fresh snowfall reported on the upper mountain trail sections'; +// Two different memories of the same domain: both admit INCOMING, through different anchors. +const MEMORY_1 = [ + 'hiking trail conditions in the mountain snow this weekend', + 'which boots and crampons to pack for the icy mountain ascent', + 'trailhead parking permits and the shuttle bus timetable', + 'weather forecast apps for alpine ridge crossings', + 'campsite reservations near the mountain trailhead', +]; +const MEMORY_2 = [ + 'avalanche risk bulletin for the backcountry slopes above the trail', + 'snowshoe routes recommended for beginners on the mountain this winter', + 'trail map showing the switchbacks up to the alpine lake', + 'mountain hut booking for the overnight hike in the snow', + 'icy patches reported on the lower forest section of the trail', +]; + +function remixFrame(text, rootKey) { + const cmb = createCMB({ categories: cat7(text), createdBy: 'peerA', lineage: { parents: [rootKey], method: 'SVAF-v2' } }); + return { type: 'cmb', timestamp: Date.now(), content: text, cmb }; +} + +/** Deliver a frame to a node's gate and return the record it stored for it. */ +async function admit(node, frame) { + const accepted = []; + const onAccepted = (e) => accepted.push(e); + node.on('cmb-accepted', onAccepted); + const now = Date.now(); + try { + await node._frameHandler._processHeuristicSVAF(frame, 'peerA', 'peerA', now, now, 0); + } finally { + node.off('cmb-accepted', onAccepted); + } + return accepted.find((e) => e.key === frame.cmb.metadata.key) || null; +} + +describe('§15.8 tether is reproducible by any holder of the root and the record (B-L5)', () => { + it('two nodes with different local memories sign the same tether for the same record', async () => { + await awaitSemantic(); + const seen = []; + for (const [label, memory] of [['n1', MEMORY_1], ['n2', MEMORY_2]]) { + await withNode(`tether-repro-${label}`, async (node) => { + const root = node.remember(cat7(ROOT)); + for (const t of memory) node.remember(cat7(t)); + const stored = await admit(node, remixFrame(INCOMING, root.key)); + assert.ok(stored, `${label}: the remix admits`); + assert.ok(stored.cmb.tether, `${label}: the integrator signed a tether`); + assert.strictEqual(stored.cmb.tether.anchor, root.key, `${label}: anchored on the shared root`); + seen.push({ label, att: stored.cmb.tether, record: stored.cmb, root: root.cmb, node }); + }); + } + const [a, b] = seen; + assert.strictEqual(a.att.kernelId, b.att.kernelId, 'precondition: one kernel, so the drifts are comparable'); + assert.strictEqual(a.record.metadata.key, b.record.metadata.key, 'precondition: the same record on both nodes'); + // The attestation signs the drift at six fractional digits, so that is the value a holder reproduces. + assert.strictEqual(a.att.drift.toFixed(6), b.att.drift.toFixed(6), 'both integrators measured the same drift'); + + // And a third party holding only the root and the record recomputes it from text alone. + const recomputed = await evaluateLineageTetherFromText({ + remixCategories: a.record.categories, anchorCategories: a.root.categories, + categoryWeights: a.node._svafCategoryWeights, guardedThreshold: a.node._svafGuardedThreshold, + }); + assert.strictEqual(recomputed.kernelId, a.att.kernelId); + assert.strictEqual(recomputed.drift.toFixed(6), a.att.drift.toFixed(6), 'the signed drift is recomputable from the two texts'); + assert.strictEqual(recomputed.tethered ? 'tethered' : 'severed', a.att.verdict); + }); + + it('a vector no text backs is not content, and never enters the tether', async () => { + // A vector is outside the content address and is not served by cmb-fetch, so a holder who + // fetched the root has none: a tether that used one could not be recomputed by anyone else. + const v = Array.from({ length: 8 }, (_, i) => (i === 0 ? 1 : 0)); + const r = await evaluateLineageTetherFromText({ + remixCategories: { focus: { text: '', vector: v } }, + anchorCategories: { focus: { text: '', vector: v } }, + guardedThreshold: 0.5, + }); + assert.strictEqual(r.checked, false, 'nothing content-addressed to compare'); + assert.deepStrictEqual(r.evaluableCategories, []); + }); + + it('both sides are encoded in ONE kernel even when the encoder switches mid-evaluation', async () => { + // The semantic encoder loads asynchronously and replaces the lexical one while the node is + // running, so the two sides of a single evaluation could otherwise straddle the switch: the + // remix encoded lexically, the anchor semantically, in spaces that share a dimension and + // nothing else. §15.8: both sides MUST be encoded within a single kernel. + const ctxPath = require.resolve('../lib/core/context-encoder'); + const ltPath = require.resolve('../lib/core/lineage-tether'); + const realCtx = require.cache[ctxPath]; + const realLt = require.cache[ltPath]; + let kernel = 'kernel-before'; + let encodes = 0; + const fakeCtx = { + ...realCtx.exports, + kernelId: () => kernel, + encodeForSVAF: async () => { + const k = kernel; + if (++encodes === 1) kernel = 'kernel-after'; // the switch lands right after the first encode + return { h1: k === 'kernel-before' ? [1, 0] : [0, 1], h2: [] }; + }, + }; + try { + require.cache[ctxPath] = { id: ctxPath, filename: ctxPath, loaded: true, exports: fakeCtx }; + delete require.cache[ltPath]; + const lt = require(ltPath); + const same = { focus: { text: 'the same words on both sides' } }; + const r = await lt.evaluateLineageTetherFromText({ remixCategories: same, anchorCategories: same, guardedThreshold: 0.5 }); + assert.strictEqual(r.kernelId, 'kernel-after', 'the verdict names the kernel both sides were encoded in'); + assert.strictEqual(r.drift, 0, 'identical text in one kernel has no drift'); + assert.strictEqual(r.tethered, true); + } finally { + require.cache[ctxPath] = realCtx; + require.cache[ltPath] = realLt; + } + }); +}); From bedc823ff17bd952de2ec9c021fc0f87a31b2ff9 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:39:39 +0100 Subject: [PATCH 015/148] svaf: cold-start admissions carry a lineage tether (B-L6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/core/svaf-baseline.js | 7 +++- tests/lineage-tether-reproducible.test.js | 49 ++++++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/lib/core/svaf-baseline.js b/lib/core/svaf-baseline.js index 7dafc8ff..e16cf186 100644 --- a/lib/core/svaf-baseline.js +++ b/lib/core/svaf-baseline.js @@ -548,9 +548,14 @@ async function _processHeuristicSVAF(opts) { const csContent = renderCMB(csCMB); const csEntry = { ...msg, key: csKey, content: csContent, source: `${localName}+${msg.source || peerName}`, cmb: csCMB, storedAt: now }; const csDecision = (!lensUncovered && csTotalDrift <= config.stableThreshold) ? 'aligned' : 'guarded'; + // §15.8 applies to every remix this node integrates, and an empty anchor window says nothing + // about the chain: the tether compares the record with its lineage root, not with memory. This + // exit returned without one, so the caller had nothing to sever on and a remix drifted past its + // root's floor kept the lineage exactly where the receiver's memory is thinnest. + const csTether = await tetherOfRecord(csCMB.categories, opts.tetherAnchor, config); // Cold-start: no category was evaluable, so every per-category verdict is `silent` // (admitted on temporal grounds to bootstrap memory, gated on no category). - return { accepted: true, totalDrift: csTotalDrift, decision: csDecision, maxCategoryDrift: 0, categoryVerdicts: computeCategoryVerdicts(verdictDrifts, config), fusedEntry: csEntry, fusedContent: csContent, effectiveTau: tau, changeSignal: 0, coldStartCause }; + return { accepted: true, totalDrift: csTotalDrift, decision: csDecision, maxCategoryDrift: 0, categoryVerdicts: computeCategoryVerdicts(verdictDrifts, config), fusedEntry: csEntry, fusedContent: csContent, effectiveTau: tau, changeSignal: 0, coldStartCause, tether: csTether }; } let weightedDriftSum = 0, weightSum = 0; diff --git a/tests/lineage-tether-reproducible.test.js b/tests/lineage-tether-reproducible.test.js index 7f1055a9..a4d8e2c5 100644 --- a/tests/lineage-tether-reproducible.test.js +++ b/tests/lineage-tether-reproducible.test.js @@ -14,6 +14,10 @@ require('./_isolate-home'); // redirect $HOME to a temp sandbox before lib/confi * The gate measured the remix side on its FUSED vectors, and a fused vector is the receiver's * attention readout over its own recent anchors. Two nodes holding the same root and the same * record signed different drifts, and neither could reproduce the other's. + * + * The cold-start exit returned without a tether at all, so a node with nothing in its anchor window + * stored a remix with its lineage unexamined — a laundered chain passed exactly where memory is + * thinnest. */ const { describe, it } = require('node:test'); @@ -23,7 +27,7 @@ const { SymNode } = require('../lib/node'); const { NullDiscovery } = require('../lib/discovery'); const { nodeDir } = require('../lib/config'); const core = require('../lib/core'); -const { createCMB, isSemanticReady, evaluateLineageTetherFromText } = core; +const { createCMB, isSemanticReady, evaluateLineageTetherFromText, processHeuristicSVAF } = core; // The reject-floor calibration assumes the semantic kernel (§9.2.1: thresholds are meaningful only // within a pinned encoder), and a comparison across two nodes is only meaningful in one kernel. @@ -56,6 +60,7 @@ function cat7(t) { const ROOT = 'overall report on snowy mountain hiking trail conditions this weekend'; const INCOMING = 'fresh snowfall reported on the upper mountain trail sections'; +const LAUNDERED_ROOT = 'quarterly financial audit of the accounting ledger and tax filings'; // Two different memories of the same domain: both admit INCOMING, through different anchors. const MEMORY_1 = [ 'hiking trail conditions in the mountain snow this weekend', @@ -72,6 +77,8 @@ const MEMORY_2 = [ 'icy patches reported on the lower forest section of the trail', ]; +const POLICY = { stableThreshold: 0.25, guardedThreshold: 0.5, temporalLambda: 0.3, freshnessSeconds: 1800 }; + function remixFrame(text, rootKey) { const cmb = createCMB({ categories: cat7(text), createdBy: 'peerA', lineage: { parents: [rootKey], method: 'SVAF-v2' } }); return { type: 'cmb', timestamp: Date.now(), content: text, cmb }; @@ -170,3 +177,43 @@ describe('§15.8 tether is reproducible by any holder of the root and the record } }); }); + +describe('§15.8 cold-start admissions carry a tether like warm ones (B-L6)', () => { + it('an empty-memory admission of a remix is tether-checked against its anchor', async () => { + await awaitSemantic(); + const root = createCMB({ categories: cat7(LAUNDERED_ROOT), createdBy: 'author' }); + const frame = remixFrame(INCOMING, root.metadata.key); + const r = await processHeuristicSVAF({ + msg: frame, peerName: 'peerA', localName: 'receiver', originTs: Date.now(), now: Date.now(), ageSeconds: 0, + recentCMBs: [], config: POLICY, + tetherAnchor: { key: root.metadata.key, categories: root.categories }, + }); + assert.strictEqual(r.accepted, true, 'cold start admits to bootstrap memory'); + assert.strictEqual(r.coldStartCause, 'empty-memory', 'precondition: this is the cold-start exit'); + assert.ok(r.tether, 'the cold-start admission carries a tether'); + assert.strictEqual(r.tether.anchorKey, root.metadata.key); + assert.strictEqual(r.tether.checked, true); + assert.strictEqual(r.tether.tethered, false, 'a remix drifted past the floor is caught at cold start too'); + + const recomputed = await evaluateLineageTetherFromText({ + remixCategories: r.fusedEntry.cmb.categories, anchorCategories: root.categories, guardedThreshold: POLICY.guardedThreshold, + }); + assert.strictEqual(r.tether.drift.toFixed(6), recomputed.drift.toFixed(6), 'the same computation as the warm path'); + assert.strictEqual(r.tether.kernelId, recomputed.kernelId); + }); + + it('a fresh node signs a tether on its first (cold-start) admission, as a warm node does', async () => { + await awaitSemantic(); + for (const warm of [false, true]) { + await withNode(`tether-cold-${warm ? 'warm' : 'cold'}`, async (node) => { + if (warm) for (const t of MEMORY_1) node.remember(cat7(t)); + const peerRoot = createCMB({ categories: cat7(ROOT), createdBy: 'peerA' }); + const stored = await admit(node, { type: 'cmb', timestamp: Date.now(), content: ROOT, cmb: peerRoot }); + assert.ok(stored, `${warm ? 'warm' : 'cold'}: the root admits`); + assert.strictEqual(stored.cmb.tether?.anchor, peerRoot.metadata.key, `${warm ? 'warm' : 'cold'}: a root is its own anchor`); + assert.strictEqual(stored.cmb.tether.verdict, 'tethered'); + assert.strictEqual(stored.cmb.provenance?.tether?.severed, false); + }); + } + }); +}); From 40b1b0dd1222fa38fab5de437b60ade3fb432ef3 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:41:13 +0100 Subject: [PATCH 016/148] tether audit: the fetch fallback never returns a refused local record 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/node.js | 3 +++ tests/lineage-tether-audit.test.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/lib/node.js b/lib/node.js index 607a20e3..71b16627 100644 --- a/lib/node.js +++ b/lib/node.js @@ -712,6 +712,9 @@ class SymNode extends EventEmitter { if (!anchorCategories && opts.fetch === true) { const anc = recordLineage(cmb)?.ancestors; for (const k of (anc?.length ? anc : parents)) { + // A key this store holds was already judged by the verified walk above, and fetchCMB + // answers from the store first — it would hand back the record that walk refused. + if (this._store.get(k)) continue; const hit = await this.fetchCMB(k, { timeoutMs: opts.timeoutMs ?? 3000 }); if (hit?.cmb?.categories) { anchorCategories = hit.cmb.categories; anchorKey = k; report.fetched++; break; } } diff --git a/tests/lineage-tether-audit.test.js b/tests/lineage-tether-audit.test.js index bf7cfe0c..03f58301 100644 --- a/tests/lineage-tether-audit.test.js +++ b/tests/lineage-tether-audit.test.js @@ -99,6 +99,35 @@ describe('MMP §15.8 retroactive tether audit', () => { } }); + it('fetch: a parent held locally but unverified does not come back as the anchor', async () => { + // fetchCMB answers from the local store before asking any peer, so the fetch fallback handed + // the audit the very record the verified walk had just refused, and the audit attested a tether + // to it. §15.8 anchors on records reached by VERIFYING parents; a local copy is not a second + // opinion about its own author. + const name = `audit-unv-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node = new SymNode({ name, silent: true, discovery: new NullDiscovery() }); + await node.start(); + try { + await awaitSemantic(); + const parent = createCMB({ categories: cat7(TOPIC_B_ROOT), createdBy: 'unverifiable-peer' }); + // A peer admission with no verified signature verdict, stored the way receiveFromPeer stores one. + node._store.receiveFromPeer('unverifiable-peer', { + key: parent.metadata.key, content: TOPIC_B_ROOT, source: 'unverifiable-peer', cmb: parent, storedAt: Date.now(), + }); + const remix = storeLegacyRemix(node, parent.metadata.key, TOPIC_B); + const r = await node.auditLineageTethers({ fetch: true, timeoutMs: 50 }); + assert.strictEqual(r.audited, 1); + assert.strictEqual(r.fetched, 0, 'the local unverified copy is not a fetch'); + assert.strictEqual(r.unchecked, 1, 'the tether is unverified'); + const e = node._store.get(remix.key); + assert.strictEqual(e.cmb.tether, undefined, 'no tether attestation names the unverified record'); + assert.strictEqual(e.cmb.provenance?.tether, undefined); + } finally { + await node.stop(); + fs.rmSync(nodeDir(name), { recursive: true, force: true }); + } + }); + it('unresolvable roots are unchecked, never severed', async () => { const name = `audit-un-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const node = new SymNode({ name, silent: true, discovery: new NullDiscovery() }); From cb6dcbe5715b312a07ed35864e7edbeeb326f75a Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:43:08 +0100 Subject: [PATCH 017/148] IPC: one endpoint resolution for daemon and clients; a path is a named 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 /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--, 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- bin/sym.js | 4 +- lib/ipc-client.js | 21 ++++--- lib/platform.js | 63 +++++++++++++++++-- tests/daemon-relay-only.test.js | 49 ++++++++++++--- tests/ipc-endpoint.test.js | 104 ++++++++++++++++++++++++++++++++ 5 files changed, 218 insertions(+), 23 deletions(-) create mode 100644 tests/ipc-endpoint.test.js diff --git a/bin/sym.js b/bin/sym.js index 6c4ceaff..fa0a2450 100755 --- a/bin/sym.js +++ b/bin/sym.js @@ -51,7 +51,9 @@ const PID_FILE = path.join(os.homedir(), '.sym', 'daemon.pid'); function sleepMs(ms) { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch {} } -const SOCKET_PATH = process.env.SYM_SOCKET || getSocketPath(); +// getSocketPath already honours SYM_SOCKET — through the same mapping the daemon uses (a +// named pipe on Windows). Reading the variable here as well would dial the raw path instead. +const SOCKET_PATH = getSocketPath(); const LOG_DIR = getLogDir('sym-daemon'); const VERSION = require('../package.json').version; diff --git a/lib/ipc-client.js b/lib/ipc-client.js index a512edb9..be4ae655 100644 --- a/lib/ipc-client.js +++ b/lib/ipc-client.js @@ -3,7 +3,8 @@ /** * IPC client for connecting to sym-daemon as a virtual node. * - * Communicates via Unix socket with newline-delimited JSON. + * Communicates over the daemon's IPC endpoint (a Unix domain socket; a named pipe on + * Windows) with newline-delimited JSON. * Provides the same API surface as SymNode so consumers * (MCP server, etc.) can use either transparently. * @@ -14,24 +15,28 @@ const net = require('net'); const { EventEmitter } = require('events'); - -const DEFAULT_SOCKET = '/tmp/sym.sock'; +const { getSocketPath, ipcEndpoint } = require('./platform'); /** * IPC client that connects to sym-daemon as a virtual node. - * Provides a SymNode-compatible API over Unix socket IPC. + * Provides a SymNode-compatible API over the daemon's IPC endpoint. */ class SymDaemonClient extends EventEmitter { /** * @param {object} [opts] - * @param {string} [opts.socketPath='/tmp/sym.sock'] — daemon Unix socket path + * @param {string} [opts.socketPath] — the daemon's socket path; defaults to the endpoint the + * daemon itself listens on (getSocketPath). It is resolved the way the daemon resolves + * SYM_SOCKET, so the same path reaches the same named pipe on Windows. * @param {string} [opts.name='virtual-node'] — virtual node name * @param {string} [opts.cognitiveProfile] — cognitive profile for encoding */ constructor(opts = {}) { super(); - this._socketPath = opts.socketPath || DEFAULT_SOCKET; + // The default was once a fixed /tmp/sym.sock, left behind when the daemon moved to + // /daemon.sock: a client built with no socketPath dialled an address no daemon + // listened on. Both sides now resolve the endpoint through lib/platform. + this._socketPath = opts.socketPath ? ipcEndpoint(opts.socketPath) : getSocketPath(); this._name = opts.name || 'virtual-node'; this._cognitiveProfile = opts.cognitiveProfile || null; this._socket = null; @@ -267,7 +272,7 @@ class SymDaemonClient extends EventEmitter { * Install daemon: node bin/sym-daemon.js --install * * @param {object} [opts] - * @param {string} [opts.socketPath] — daemon Unix socket path + * @param {string} [opts.socketPath] — daemon socket path (default: the daemon's own endpoint) * @param {string} [opts.name='claude-code'] — virtual node name * @param {string} [opts.cognitiveProfile] — cognitive profile * @returns {Promise} connected client @@ -275,7 +280,7 @@ class SymDaemonClient extends EventEmitter { */ async function connectToDaemon(opts = {}) { const client = new SymDaemonClient({ - socketPath: opts.socketPath || DEFAULT_SOCKET, + socketPath: opts.socketPath, name: opts.name || 'claude-code', cognitiveProfile: opts.cognitiveProfile, }); diff --git a/lib/platform.js b/lib/platform.js index 737712a2..76202dbf 100644 --- a/lib/platform.js +++ b/lib/platform.js @@ -14,6 +14,7 @@ const os = require('os'); const path = require('path'); const fs = require('fs'); +const crypto = require('crypto'); const { execSync, execFileSync } = require('child_process'); // ── Platform Detection ────────────────────────────────────── @@ -184,16 +185,65 @@ function getSymDir() { return require('./core/state-root').SYM_STATE_DIR; } +/** The Windows named-pipe namespace: `\\.\pipe\` or `\\?\pipe\`, either slash. */ +function isWindowsPipe(address) { + return /^[\\/]{2}[.?][\\/]pipe[\\/]./i.test(String(address)); +} + +/** + * The endpoint `net.listen` / `net.createConnection` must use for a configured IPC address. + * + * On POSIX the address is a Unix domain socket path and is used as given. Windows has no domain + * sockets at a filesystem path: listening on `C:\...\d.sock` fails (EACCES), and IPC must go + * through a named pipe. So on Windows a filesystem path is mapped to a pipe name derived from + * it, and a name already in the pipe namespace is used as given. + * + * The daemon (server) and every client — the CLI, SymDaemonClient — resolve the address + * through this one function, so they agree on the pipe for the same SYM_SOCKET; a client that + * dialled the raw path while the daemon listened on the pipe would find nothing there. The + * name is a digest of the absolute path, lower-cased because Windows paths are + * case-insensitive, so two spellings of one path reach one pipe and two paths never share one. + * + * @param {string} address — a socket path, or (on Windows) a pipe name + * @param {string} [platform=process.platform] + * @returns {string} + */ +function ipcEndpoint(address, platform = process.platform) { + const a = String(address); + if (platform !== 'win32' || isWindowsPipe(a)) return a; + const abs = path.win32.resolve(a).toLowerCase(); + const base = path.win32.basename(abs).replace(/[^a-z0-9._-]/g, '_').slice(0, 40); + const digest = crypto.createHash('sha256').update(abs, 'utf8').digest('hex').slice(0, 16); + return `\\\\.\\pipe\\sym-${base}-${digest}`; +} + +/** + * Resolve the daemon's IPC endpoint from its inputs (see getSocketPath). + * @param {object} o + * @param {string} [o.configured] — SYM_SOCKET, when set + * @param {string} o.symDir — the state root + * @param {boolean} o.rooted — whether SYM_STATE_DIR re-rooted this process + * @param {string} [o.platform=process.platform] + * @returns {string} + */ +function resolveSocketPath({ configured, symDir, rooted, platform = process.platform }) { + if (configured) return ipcEndpoint(configured, platform); + // The long-standing Windows default, kept so an unrooted CLI and daemon of any version meet. + if (platform === 'win32' && !rooted) return '\\\\.\\pipe\\sym-daemon'; + // A rooted deployment keeps its endpoint inside its root on every platform; on Windows that + // is a pipe named for the root, not the one host-global pipe every tenant would share. + return ipcEndpoint(path.join(symDir, 'daemon.sock'), platform); +} + /** - * Get the IPC socket path. - * Windows uses named pipes; Unix uses domain sockets. + * Get the IPC endpoint the daemon listens on and its clients dial: SYM_SOCKET if set, else + * `/daemon.sock`, mapped to a named pipe on Windows (see ipcEndpoint). * * @returns {string} */ function getSocketPath() { - return process.env.SYM_SOCKET || (isWin - ? '\\\\.\\pipe\\sym-daemon' - : path.join(getSymDir(), 'daemon.sock')); + const { SYM_STATE_DIR, IS_ROOTED } = require('./core/state-root'); + return resolveSocketPath({ configured: process.env.SYM_SOCKET, symDir: SYM_STATE_DIR, rooted: IS_ROOTED }); } /** @@ -332,6 +382,9 @@ module.exports = { // Path utilities getSymDir, getSocketPath, + resolveSocketPath, + ipcEndpoint, + isWindowsPipe, getLogDir, projectPath, safeReadFile, diff --git a/tests/daemon-relay-only.test.js b/tests/daemon-relay-only.test.js index b7c6ca04..54f27f3e 100644 --- a/tests/daemon-relay-only.test.js +++ b/tests/daemon-relay-only.test.js @@ -3,36 +3,67 @@ * RELAY-ONLY. A host with no usable multicast (Termux on Android, a locked-down container) * joins over the relay alone: SYM_RELAY_ONLY=1 makes the daemon skip LAN discovery entirely and * still authenticate to the relay. Measured here against a fake relay, with the daemon's own - * process, its own HOME and its own IPC socket so it never touches the real one. + * process, its own HOME and its own IPC endpoint so it never touches the real one. + * + * The relay leg and the IPC leg are checked separately. The relay connection is started by + * node.start(), before the IPC server exists, and relay-auth is asserted on its own. The IPC + * endpoint is asserted after it, by name and by the CLI actually reaching the daemon through + * it. On Windows the endpoint for SYM_SOCKET=\d.sock is a named pipe (lib/platform + * ipcEndpoint): listening on the file path itself failed with EACCES, the daemon exited, and + * the failure surfaced as a missing relay-auth. An IPC failure is now reported as an IPC failure. */ const { test } = require('node:test'); const assert = require('node:assert/strict'); -const { spawn } = require('node:child_process'); +const { spawn, execFile } = require('node:child_process'); const { WebSocketServer } = require('ws'); const fs = require('fs'); const os = require('os'); const path = require('path'); +const { ipcEndpoint } = require('../lib/platform'); -test('SYM_RELAY_ONLY=1: LAN discovery off, relay-auth still sent', async () => { +const BIN = path.join(__dirname, '..', 'bin'); + +test('SYM_RELAY_ONLY=1: LAN discovery off, relay-auth still sent, CLI reaches the daemon over its IPC endpoint', async () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sym-relay-only-')); const wss = new WebSocketServer({ port: 0 }); const auths = []; wss.on('connection', (ws) => ws.on('message', (m) => { const f = JSON.parse(String(m)); if (f.type === 'relay-auth') auths.push(f); })); const relayUrl = `ws://127.0.0.1:${wss.address().port}`; - const daemon = spawn(process.execPath, [path.join(__dirname, '..', 'bin', 'sym-daemon.js')], { - env: { ...process.env, HOME: home, USERPROFILE: home, SYM_SOCKET: path.join(home, 'd.sock'), SYM_NODE_NAME: 'relay-only-test', - SYM_ROOM: 'relay-only-room', SYM_RELAY_ONLY: '1', SYM_RELAY_URL: relayUrl, SYM_RELAY_TOKEN: 'x'.repeat(32) }, - stdio: ['ignore', 'pipe', 'pipe'], - }); + const socket = path.join(home, 'd.sock'); + const endpoint = ipcEndpoint(socket); // what the daemon and every client resolve SYM_SOCKET to + const env = { ...process.env, HOME: home, USERPROFILE: home, SYM_SOCKET: socket, SYM_NODE_NAME: 'relay-only-test', + SYM_ROOM: 'relay-only-room', SYM_RELAY_ONLY: '1', SYM_RELAY_URL: relayUrl, SYM_RELAY_TOKEN: 'x'.repeat(32) }; + const daemon = spawn(process.execPath, [path.join(BIN, 'sym-daemon.js')], { env, stdio: ['ignore', 'pipe', 'pipe'] }); let out = ''; daemon.stdout.on('data', (b) => { out += b; }); daemon.stderr.on('data', (b) => { out += b; }); + const listening = () => out.includes(`IPC server listening: ${endpoint}`); try { - for (let i = 0; i < 100 && !(auths.length && /relay-only: LAN discovery off/.test(out)); i++) await new Promise((r) => setTimeout(r, 100)); + for (let i = 0; i < 100 && daemon.exitCode === null && !(auths.length && listening()); i++) { + await new Promise((r) => setTimeout(r, 100)); + } + assert.doesNotMatch(out, /IPC server error/, `the daemon could not open its IPC endpoint ${endpoint}:\n${out.slice(-800)}`); + + // The relay leg, on its own. assert.match(out, /relay-only: LAN discovery off \(SYM_RELAY_ONLY\); joining ws:/, out.slice(-800)); assert.equal(auths.length >= 1, true, `relay-auth expected; log:\n${out.slice(-800)}`); assert.equal(auths[0].name, 'relay-only-test'); assert.equal(typeof auths[0].engine, 'string'); + assert.ok(out.indexOf('SYM node started') < out.indexOf('IPC server listening'), + 'the relay connection is started before the IPC server exists, so it is not gated on it'); + + // The IPC leg: the daemon listens where a client resolves the same SYM_SOCKET, and the CLI + // gets through. (`sym start` records the daemon pid; on Windows the CLI checks that pid + // rather than a socket file, so record it here the way `sym start` would.) + assert.ok(listening(), `IPC server listening on ${endpoint} expected; log:\n${out.slice(-800)}`); + fs.writeFileSync(path.join(home, '.sym', 'daemon.pid'), String(daemon.pid)); + const status = await new Promise((resolve, reject) => { + execFile(process.execPath, [path.join(BIN, 'sym.js'), 'status', '--json'], { env, timeout: 15000 }, (err, stdout, stderr) => { + if (err) reject(new Error(`sym status failed: ${err.message}\n${stderr}\ndaemon log:\n${out.slice(-800)}`)); + else resolve(stdout); + }); + }); + assert.equal(JSON.parse(status).status.name, 'relay-only-test', 'the CLI reached this daemon'); assert.equal(daemon.exitCode, null, 'the daemon is still running'); } finally { daemon.kill('SIGTERM'); diff --git a/tests/ipc-endpoint.test.js b/tests/ipc-endpoint.test.js new file mode 100644 index 00000000..d39b1158 --- /dev/null +++ b/tests/ipc-endpoint.test.js @@ -0,0 +1,104 @@ +'use strict'; + +require('./_isolate-home'); // sandbox HOME/USERPROFILE before anything reads os.homedir() + +/** + * The daemon's IPC endpoint, as the daemon and its clients resolve it. + * + * Windows has no Unix domain sockets at a filesystem path: a daemon told SYM_SOCKET=C:\...\d.sock + * failed to listen (EACCES) and exited. On Windows a path must become a named pipe, and the + * server and every client must turn the same path into the same pipe, or the CLI dials an + * address nobody listens on. The mapping is a pure function of the path, so its Windows + * behaviour is checked here on any platform; only the listen itself needs Windows. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { ipcEndpoint, isWindowsPipe, resolveSocketPath, getSocketPath } = require('../lib/platform'); + +const PIPE = /^\\\\\.\\pipe\\sym-[a-z0-9._-]+-[0-9a-f]{16}$/; + +describe('ipcEndpoint', () => { + it('on POSIX a socket path is used as given', () => { + for (const platform of ['darwin', 'linux']) { + assert.equal(ipcEndpoint('/tmp/x/d.sock', platform), '/tmp/x/d.sock'); + } + }); + + it('on Windows a filesystem path becomes a named pipe', () => { + const pipe = ipcEndpoint('C:\\Users\\agent\\AppData\\Local\\Temp\\sym-relay-only-1\\d.sock', 'win32'); + assert.match(pipe, PIPE); + assert.ok(isWindowsPipe(pipe)); + }); + + it('the mapping is stable, and two spellings of one path reach one pipe', () => { + const a = ipcEndpoint('C:\\Users\\Agent\\d.sock', 'win32'); + assert.equal(ipcEndpoint('C:\\Users\\Agent\\d.sock', 'win32'), a, 'same input, same pipe'); + assert.equal(ipcEndpoint('c:/users/agent/D.SOCK', 'win32'), a, 'case and slash direction do not matter on Windows'); + assert.equal(ipcEndpoint('C:\\Users\\Agent\\sub\\..\\d.sock', 'win32'), a, 'the path is resolved first'); + }); + + it('two different paths never share a pipe', () => { + assert.notEqual(ipcEndpoint('C:\\a\\d.sock', 'win32'), ipcEndpoint('C:\\b\\d.sock', 'win32')); + }); + + it('a name already in the pipe namespace is used as given', () => { + for (const p of ['\\\\.\\pipe\\sym-daemon', '\\\\?\\pipe\\custom', '//./pipe/fwd']) { + assert.ok(isWindowsPipe(p), p); + assert.equal(ipcEndpoint(p, 'win32'), p); + } + assert.ok(!isWindowsPipe('C:\\pipe\\not-a-pipe')); + assert.ok(!isWindowsPipe('\\\\.\\pipe\\'), 'the namespace alone names no pipe'); + }); +}); + +describe('resolveSocketPath', () => { + it('SYM_SOCKET goes through the mapping on every platform', () => { + assert.equal(resolveSocketPath({ configured: '/s/d.sock', symDir: '/r', rooted: false, platform: 'linux' }), '/s/d.sock'); + assert.equal(resolveSocketPath({ configured: 'C:\\s\\d.sock', symDir: 'C:\\r', rooted: false, platform: 'win32' }), + ipcEndpoint('C:\\s\\d.sock', 'win32')); + }); + + it('unset, POSIX uses /daemon.sock', () => { + assert.equal(resolveSocketPath({ symDir: '/home/a/.sym', rooted: false, platform: 'darwin' }), '/home/a/.sym/daemon.sock'); + }); + + it('unset and unrooted, Windows keeps its long-standing pipe', () => { + assert.equal(resolveSocketPath({ symDir: 'C:\\Users\\a\\.sym', rooted: false, platform: 'win32' }), '\\\\.\\pipe\\sym-daemon'); + }); + + it('unset and rooted, Windows gets a pipe of its own root, not the host-global one', () => { + const a = resolveSocketPath({ symDir: 'D:\\tenant-a', rooted: true, platform: 'win32' }); + const b = resolveSocketPath({ symDir: 'D:\\tenant-b', rooted: true, platform: 'win32' }); + assert.match(a, PIPE); + assert.notEqual(a, b); + assert.notEqual(a, '\\\\.\\pipe\\sym-daemon'); + }); +}); + +describe('the daemon and its clients agree on the endpoint', () => { + it('SymDaemonClient dials the endpoint the daemon listens on, by default', () => { + const { SymDaemonClient } = require('../lib/ipc-client'); + assert.equal(new SymDaemonClient()._socketPath, getSocketPath()); + }); + + it('an explicit socketPath is resolved the way the daemon resolves SYM_SOCKET', () => { + const { SymDaemonClient } = require('../lib/ipc-client'); + const p = path.join(__dirname, 'nowhere', 'd.sock'); + assert.equal(new SymDaemonClient({ socketPath: p })._socketPath, ipcEndpoint(p)); + }); + + it('with SYM_SOCKET set, the daemon\'s resolution and the client\'s are the same endpoint', () => { + const p = path.join(__dirname, 'nowhere', 'd.sock'); + const out = execFileSync(process.execPath, ['-e', ` + const { getSocketPath } = require(${JSON.stringify(require.resolve('../lib/platform'))}); + const { SymDaemonClient } = require(${JSON.stringify(require.resolve('../lib/ipc-client'))}); + console.log(JSON.stringify([getSocketPath(), new SymDaemonClient()._socketPath]));`], + { env: { ...process.env, SYM_SOCKET: p }, encoding: 'utf8' }); + const [server, client] = JSON.parse(out); + assert.equal(server, ipcEndpoint(p)); + assert.equal(client, server); + }); +}); From a8cacfbae3b9aaeeddeffb96cbc7abea002f9e38 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:43:20 +0100 Subject: [PATCH 018/148] cmb-fetch: serve a record's metadata whole, so a served v2.0 record still 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/frame-handler.js | 12 +++----- tests/cmb-fetch-serves-v2.test.js | 48 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 tests/cmb-fetch-serves-v2.test.js diff --git a/lib/frame-handler.js b/lib/frame-handler.js index 0253267e..200aa0c8 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -650,16 +650,12 @@ class FrameHandler { // self-verifying, so the requester would recompute the address and refuse it. // A pre-boundary record is served in the shape it was stored in: the legacy DAG stays // readable and is never re-keyed (§7.8). + // The metadata is served WHOLE, as stored. Picking fields dropped the v2.0 ones + // (signatureSuite, addressScheme, createdByNodeId, application, assertionId), and a v2.0 + // record without them no longer verifies at the requester. It is a copy, never the store's object. const m = cmb.metadata; served = m - ? { - categories, - metadata: { - key: m.key, createdBy: m.createdBy, createdTimestamp: m.createdTimestamp, - lineage: m.lineage ?? null, room: m.room ?? null, to: m.to ?? null, - sig: m.sig, sigAlg: m.sigAlg, - }, - } + ? { categories, metadata: structuredClone(m) } : { key: cmb.key, createdBy: cmb.createdBy, createdAt: cmb.createdAt, categories, lineage: cmb.lineage ?? null, diff --git a/tests/cmb-fetch-serves-v2.test.js b/tests/cmb-fetch-serves-v2.test.js new file mode 100644 index 00000000..d22eb8b0 --- /dev/null +++ b/tests/cmb-fetch-serves-v2.test.js @@ -0,0 +1,48 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME to a temp sandbox before lib/config loads + +/** + * B-R9 (part): cmb-fetch serves the record as stored. A served v2.0 record (mmp-sig-v2.0) must keep + * signatureSuite, addressScheme, createdByNodeId, application and assertionId, or the requester + * cannot verify it. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const { SymNode } = require('../lib/node'); +const { NullDiscovery } = require('../lib/discovery'); +const { nodeDir } = require('../lib/config'); +const { createCMB, signCMB, verifyCMB, assertionIdV2_0 } = require('../lib/core'); + +describe('cmb-fetch serving', () => { + it('a served v2.0 record still verifies against its author', () => { + const name = `fetchv2-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node = new SymNode({ name, silent: true, discovery: new NullDiscovery() }); + try { + const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', { + publicKeyEncoding: { type: 'spki', format: 'der' }, privateKeyEncoding: { type: 'pkcs8', format: 'der' }, + }); + const pub = publicKey.slice(-32).toString('base64url'); + const priv = privateKey.slice(-32).toString('base64url'); + const cmb = createCMB({ categories: { focus: 'served whole' }, createdBy: 'alice', emitV2: true, createdByNodeId: 'node-alice', room: 'default' }); + cmb.metadata.assertionId = assertionIdV2_0(cmb); + signCMB(cmb, priv); + assert.deepStrictEqual(verifyCMB(cmb, pub).valid, true, 'precondition: the stored record verifies'); + node._store.get = (k) => (k === cmb.metadata.key ? { key: k, cmb } : null); + const sent = []; + node._peers.set('peer-r', { peerId: 'peer-r', name: 'requester', transport: { send: (f) => { sent.push(f); return true; } } }); + node._frameHandler._handleCmbFetch('peer-r', 'requester', { type: 'cmb-fetch', key: cmb.metadata.key, reqId: 'r1' }); + const res = sent.find((f) => f.type === 'cmb-fetch-result'); + assert.ok(res?.found); + for (const k of ['signatureSuite', 'addressScheme', 'createdByNodeId', 'application', 'assertionId']) { + assert.ok(k in res.cmb.metadata, `${k} is served`); + } + assert.strictEqual(verifyCMB(JSON.parse(JSON.stringify(res.cmb)), pub).valid, true, 'the served record verifies'); + res.cmb.metadata.key = 'tampered'; + assert.notStrictEqual(cmb.metadata.key, 'tampered', 'the store keeps its own copy'); + } finally { fs.rmSync(nodeDir(name), { recursive: true, force: true }); } + }); +}); From fb4582cdc99b69af5426e68248e8bbb28d30706d Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:45:19 +0100 Subject: [PATCH 019/148] receive de-dup: hold off a copy while the first is in flight; directed marks kept apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/frame-handler.js | 73 +++++++++++++++++++------ tests/audit-delivery-authorship.test.js | 34 ++++++++++++ 2 files changed, 89 insertions(+), 18 deletions(-) diff --git a/lib/frame-handler.js b/lib/frame-handler.js index 200aa0c8..544186ee 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -89,6 +89,11 @@ class FrameHandler { // raw *incoming* keys so the same CMB re-sent on reconnect/resync converges // instead of cycling. See _handleMemoryShare and MMP §4.2 O2. this._seenCmbKeys = new Map(); + // Directed assertion marks (key#sig:…) get their own map and cap, so a broadcast flood cannot + // evict them and make a replayed directed record surface again, and the reverse. + this._seenDirectedKeys = new Map(); + // Keys whose first copy is still being processed (an async SVAF pass): see _handleMemoryShare. + this._inFlightKeys = new Set(); // Persist the dedup cache across plugin reloads / process restarts. Without this, // a reload wipes the cache and every already-processed CMB re-surfaces as new — a // primary trigger of the cross-node echo storm (MMP §14). Best-effort: any FS error @@ -110,7 +115,7 @@ class FrameHandler { const raw = JSON.parse(fs.readFileSync(this._seenKeysPath, 'utf8')); for (const k of Object.keys(raw)) { const ts = raw[k]; - if (typeof ts === 'number' && (now - ts) < SEEN_CMB_TTL_MS) this._seenCmbKeys.set(k, ts); + if (typeof ts === 'number' && (now - ts) < SEEN_CMB_TTL_MS) this._seenKeysFor(k).set(k, ts); } } catch { /* degrade to in-memory only */ } } @@ -123,6 +128,7 @@ class FrameHandler { try { const obj = {}; for (const [k, ts] of this._seenCmbKeys) obj[k] = ts; + for (const [k, ts] of this._seenDirectedKeys) obj[k] = ts; fs.writeFileSync(this._seenKeysPath, JSON.stringify(obj)); } catch { /* ignore */ } } @@ -134,20 +140,26 @@ class FrameHandler { * @private */ _recordSeenCmbKey(key, now) { - this._seenCmbKeys.set(key, now); - if (this._seenCmbKeys.size > SEEN_CMB_MAX) { - for (const [k, ts] of this._seenCmbKeys) { - if (this._seenCmbKeys.size <= SEEN_CMB_MAX) break; - if (now - ts >= SEEN_CMB_TTL_MS) this._seenCmbKeys.delete(k); + const map = this._seenKeysFor(key); + map.set(key, now); + if (map.size > SEEN_CMB_MAX) { + for (const [k, ts] of map) { + if (map.size <= SEEN_CMB_MAX) break; + if (now - ts >= SEEN_CMB_TTL_MS) map.delete(k); } - while (this._seenCmbKeys.size > SEEN_CMB_MAX) { - const oldest = this._seenCmbKeys.keys().next().value; - this._seenCmbKeys.delete(oldest); + while (map.size > SEEN_CMB_MAX) { + const oldest = map.keys().next().value; + map.delete(oldest); } } this._persistSeenCmbKeys(now); } + /** The de-dup map a key belongs to: directed assertion marks are kept apart from content keys. @private */ + _seenKeysFor(key) { + return typeof key === 'string' && key.includes('#sig:') ? this._seenDirectedKeys : this._seenCmbKeys; + } + /** * Mark an inbound CMB's content-hash key as having actually surfaced to the * application layer (admitted/stored, mood delivered, or CLI-host forwarded). @@ -716,6 +728,22 @@ class FrameHandler { } _handleMemoryShare(peerId, peerName, msg) { + let pending; + try { + pending = this._handleMemoryShareInner(peerId, peerName, msg); + } finally { + // Release the in-flight mark when this frame is finished with: now, or when its SVAF pass settles. + const k = msg && msg._inFlightKey; + if (k) { + if (pending && typeof pending.then === 'function') pending.finally(() => this._inFlightKeys.delete(k)).catch(() => {}); + else this._inFlightKeys.delete(k); + } + } + return pending; + } + + /** @private */ + _handleMemoryShareInner(peerId, peerName, msg) { // A cmb frame is {type, timestamp, cmb}; an honest sender never sets `source`. Every name this // handler records for the deliverer must come from the connection (peerName), so a // frame-supplied one is dropped before anything reads it. @@ -876,12 +904,21 @@ class FrameHandler { ? `${cognitionKey}#${assertionMark}` : cognitionKey; if (incomingKey) { - const lastSeen = this._seenCmbKeys.get(incomingKey); + const lastSeen = this._seenKeysFor(incomingKey).get(incomingKey); if (lastSeen !== undefined && (now - lastSeen) < SEEN_CMB_TTL_MS) { - this._seenCmbKeys.set(incomingKey, now); // refresh recency + this._seenKeysFor(incomingKey).set(incomingKey, now); // refresh recency this._node._log(`Duplicate CMB ${String(incomingKey).slice(0, 16)} from ${peerName} — seen within TTL, skipping (convergence)`); return; } + // The mark is only recorded once a CMB has surfaced, which can be after an async SVAF pass, so + // a copy arriving meanwhile (the same record via two peers, or a reconnect replay) would pass + // the check above and surface twice. It is held off while the first copy is in flight. + if (this._inFlightKeys.has(incomingKey)) { + this._node._log(`Duplicate CMB ${String(incomingKey).slice(0, 16)} from ${peerName} — the same record is still being processed, skipping`); + return; + } + this._inFlightKeys.add(incomingKey); + msg._inFlightKey = incomingKey; } // Carry the key down the processing chain so each surface point can record // it once the CMB has actually been delivered. @@ -954,20 +991,20 @@ class FrameHandler { const recentEntries = this._node._store.allEntries().slice(0, 5); const anchorTexts = recentEntries.map(e => ({ text: e.content, source: e.source, tags: e.tags || [] })); - // Try neural SVAF first (Layer 4 cognition) - this._node._svafEvaluator.evaluate( + // Try neural SVAF first (Layer 4 cognition). The chain is returned so the caller knows when this + // frame is finished with (the in-flight de-dup mark is released then). + return this._node._svafEvaluator.evaluate( { text: msg.content, source: msg.source || peerName, tags: msg.tags || [], confidence: msg.confidence || 0.8 }, anchorTexts, ageSeconds, ).then((neuralResult) => { if (neuralResult) { - this._processNeuralSVAF(neuralResult, msg, peerName, peerId, originTs, now); - } else { - this._runHeuristicSVAFContained(msg, peerName, peerId, originTs, now, ageSeconds); + return this._processNeuralSVAF(neuralResult, msg, peerName, peerId, originTs, now); } + return this._runHeuristicSVAFContained(msg, peerName, peerId, originTs, now, ageSeconds); }).catch((err) => { this._node._log(`SVAF neural error: ${err.message} — falling back to heuristic`); - this._runHeuristicSVAFContained(msg, peerName, peerId, originTs, now, ageSeconds); + return this._runHeuristicSVAFContained(msg, peerName, peerId, originTs, now, ageSeconds); }); } @@ -979,7 +1016,7 @@ class FrameHandler { * @private */ _runHeuristicSVAFContained(msg, peerName, peerId, originTs, now, ageSeconds) { - Promise.resolve() + return Promise.resolve() .then(() => this._processHeuristicSVAF(msg, peerName, peerId, originTs, now, ageSeconds)) .catch((err) => this._node._log(`SVAF heuristic error on frame from ${peerName}: ${err.message} — frame dropped`)); } diff --git a/tests/audit-delivery-authorship.test.js b/tests/audit-delivery-authorship.test.js index 79470ef4..edb0ca30 100644 --- a/tests/audit-delivery-authorship.test.js +++ b/tests/audit-delivery-authorship.test.js @@ -237,6 +237,40 @@ describe('directed delivery (MMP §9.2.2, §8.8.2)', () => { }); }); + it('K1: two copies of one record in flight at once surface once', async () => { + await withNode('k1', async (node) => { + node._pinPeerKey('peerA', PEER_A.pub); + node._pinPeerKey('peerB', PEER_A.pub); + let release; + const gate = new Promise((r) => { release = r; }); + node._svafEvaluator.evaluate = async () => { await gate; return ALIGNED; }; // the first copy is still in SVAF + const seen = collect(node); + const f = frame(signed(mkCmb('the same record, arriving twice'))); + node._frameHandler.handle('peerA', 'peerA', JSON.parse(JSON.stringify(f))); + node._frameHandler.handle('peerB', 'peerB', JSON.parse(JSON.stringify(f))); // via a second peer, meanwhile + release(); + await settle(); + assert.strictEqual(seen.accepted.length, 1); + assert.strictEqual(node._frameHandler._inFlightKeys.size, 0, 'the in-flight mark is released'); + }); + }); + + it('K3: a broadcast flood cannot evict a directed assertion mark', async () => { + await withNode('k3', async (node) => { + node._pinPeerKey('peerA', PEER_A.pub); + node._svafEvaluator.evaluate = async () => REJECTED; + const seen = collect(node); + const f = directed(node, signed(mkCmb('approve the release', { to: node.nodeId }))); + node._frameHandler.handle('peerA', 'peerA', JSON.parse(JSON.stringify(f))); + await settle(); + const now = Date.now(); + for (let i = 0; i <= 10000; i++) node._frameHandler._recordSeenCmbKey(`cmb-flood-${i}`, now); + node._frameHandler.handle('peerA', 'peerA', JSON.parse(JSON.stringify(f))); + await settle(); + assert.strictEqual(seen.accepted.length, 1, 'the replay is still recognised'); + }); + }); + it('F2: an unverified directed CMB cannot widen its de-duplication key with unsigned fields', async () => { await withNode('d2-unsigned', async (node) => { node._svafEvaluator.evaluate = async () => REJECTED; From 4867904fefac19b9871b05ef8def0135e5e538b5 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:46:30 +0100 Subject: [PATCH 020/148] under the test runner, a node refuses a home outside the temp dir (ETESTHOME) 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- bin/sym-daemon.js | 4 + lib/core/state-root.js | 66 ++++++++++++++- lib/node.js | 4 + tests/_isolate-home.js | 4 + tests/test-home-guard.test.js | 154 ++++++++++++++++++++++++++++++++++ 5 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 tests/test-home-guard.test.js diff --git a/bin/sym-daemon.js b/bin/sym-daemon.js index ffe01a04..dec467a5 100755 --- a/bin/sym-daemon.js +++ b/bin/sym-daemon.js @@ -55,6 +55,10 @@ process.on('unhandledRejection', (reason) => { console.error(`[${new Date().toISOString()}] [ERROR] Unhandled rejection: ${reason}`); }); +// Under the test runner, a daemon whose state root is the real home is refused (ETESTHOME) +// before it reads relay.env, migrates stores or builds its node — all of which touch ~/.sym. +require('../lib/core/state-root').assertTestSandbox(); + // ── Configuration ────────────────────────────────────────────── const SYM_DIR = path.join(os.homedir(), '.sym'); diff --git a/lib/core/state-root.js b/lib/core/state-root.js index aab49b50..54f524e8 100644 --- a/lib/core/state-root.js +++ b/lib/core/state-root.js @@ -23,6 +23,7 @@ * Nothing moves for anyone who sets nothing: with SYM_STATE_DIR unset every path resolves * exactly where it did before, byte for byte. */ +const fs = require('fs'); const os = require('os'); const path = require('path'); @@ -49,4 +50,67 @@ function claudeProjectsDir() { return IS_ROOTED ? path.join(SYM_STATE_DIR, 'claude-projects') : path.join(os.homedir(), '.claude', 'projects'); } -module.exports = { SYM_STATE_DIR, IS_ROOTED, symPath, claudeProjectsDir }; +/** + * Under Node's test runner, refuse to start a node whose state would land outside a temp dir. + * + * WHY: a test that constructs a SymNode without first sandboxing HOME writes identities, private + * keys, stores and locks into the developer's real ~/.sym — where they surface afterwards as + * phantom agents in the operator's mesh, and where a test can read the real relay.env and its + * API key. A per-file convention (require tests/_isolate-home.js first) cannot hold that line: + * the file that forgets it is exactly the file that leaks, and nothing says so. Node's runner + * sets NODE_TEST_CONTEXT in every test process (and its children inherit it), so the engine can + * tell it is under test and check the one thing that matters: where it is about to write. + * + * Checked: the home directory (os.homedir() — HOME, USERPROFILE on Windows), because several + * paths still derive from it directly (relay.env, the daemon's room and task files, agent + * memory); the state root actually in effect, captured when this module loaded, so a test + * that sandboxed HOME too late is caught; and SYM_IDENTITY_DIR when set, since that is where + * the keypair goes. Each must resolve inside fs.realpathSync(os.tmpdir()) — through symlinks, + * because macOS's /var/folders temp dir is a link to /private/var/folders. + * + * SYM_TEST_REAL_HOME=1 opts out, for a test that means to use the real home and says so. + * + * @throws {Error} code 'ETESTHOME' + */ +function assertTestSandbox() { + if (!process.env.NODE_TEST_CONTEXT || process.env.SYM_TEST_REAL_HOME === '1') return; + const tmp = realpathOfPath(os.tmpdir()); + const roots = [ + ['home directory (HOME / USERPROFILE)', os.homedir()], + ['state root', SYM_STATE_DIR], + ]; + if (process.env.SYM_IDENTITY_DIR) roots.push(['SYM_IDENTITY_DIR', process.env.SYM_IDENTITY_DIR]); + const outside = roots.filter(([, dir]) => !isWithin(realpathOfPath(dir), tmp)); + if (!outside.length) return; + const err = new Error( + '[SYM] ETESTHOME: refusing to start a node under the Node test runner with its state outside a temp dir.\n' + + outside.map(([what, dir]) => ` ${what}: ${dir}\n`).join('') + + ` temp dir: ${tmp}\n\n` + + ' Sandbox HOME and USERPROFILE to a temp dir before any lib module loads ' + + "(require('./_isolate-home') as the first line of the test file), or this run writes " + + 'identities, keys and stores into the real ~/.sym. Set SYM_TEST_REAL_HOME=1 to opt out deliberately.' + ); + err.code = 'ETESTHOME'; + throw err; +} + +/** The real path of `p`, resolving through symlinks even when `p` itself does not exist yet. */ +function realpathOfPath(p) { + let head = path.resolve(p); + const tail = []; + for (;;) { + try { return path.join(fs.realpathSync(head), ...tail); } catch { /* not there yet */ } + const parent = path.dirname(head); + if (parent === head) return path.resolve(p); + tail.unshift(path.basename(head)); + head = parent; + } +} + +function isWithin(child, parent) { + const fold = (s) => (process.platform === 'win32' ? s.toLowerCase() : s); + const rel = path.relative(fold(parent), fold(child)); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} + +module.exports = { SYM_STATE_DIR, IS_ROOTED, symPath, claudeProjectsDir, assertTestSandbox }; diff --git a/lib/node.js b/lib/node.js index 0fd7c571..c4dd4efb 100644 --- a/lib/node.js +++ b/lib/node.js @@ -25,6 +25,7 @@ const crypto = require('crypto'); const { EventEmitter } = require('events'); const { MeshNode } = require('./core'); const { nodeDir, loadOrCreateIdentity, acquireIdentityLock, log: logMsg } = require('./config'); +const { assertTestSandbox } = require('./core/state-root'); const { MemoryStore } = require('./memory-store'); const { AttestationStore, chainHash } = require('./attestation-store'); const { RoleGrantStore } = require('./role-grant-store'); @@ -123,6 +124,9 @@ class SymNode extends EventEmitter { */ constructor(opts = {}) { super(); + // Before anything is read or written: under the test runner, a node whose state root is the + // real home is refused (ETESTHOME) rather than allowed to mint identities into ~/.sym. + assertTestSandbox(); if (!opts.name) throw new Error('SymNode requires a name'); this._silent = opts.silent || false; diff --git a/tests/_isolate-home.js b/tests/_isolate-home.js index 1fe53c05..ade8295e 100644 --- a/tests/_isolate-home.js +++ b/tests/_isolate-home.js @@ -11,6 +11,10 @@ * * Without this, every `npm test` leaked `test-*`/fixture node dirs into the * real mesh store, which then surfaced as phantom "agents" in mesh-edge. + * It is no longer only a convention: under the test runner SymNode and the + * daemon refuse a home or state root outside the temp dir (ETESTHOME, see + * assertTestSandbox in lib/core/state-root.js), so a file that forgets this + * fails loudly instead of leaking. * * The sandbox is removed on process exit (node:test runs each file in its * own process, so one temp HOME per test file, cleaned when that file's diff --git a/tests/test-home-guard.test.js b/tests/test-home-guard.test.js new file mode 100644 index 00000000..a3ddc542 --- /dev/null +++ b/tests/test-home-guard.test.js @@ -0,0 +1,154 @@ +'use strict'; + +require('./_isolate-home'); // sandbox HOME/USERPROFILE before anything reads os.homedir() + +/** + * ETESTHOME — under Node's test runner a node will not start with its state outside a temp dir. + * + * A test file that forgot to sandbox HOME used to write identities, keys and stores into the + * developer's real ~/.sym, silently. The runner sets NODE_TEST_CONTEXT in every test process + * (children inherit it), so SymNode and the daemon now refuse that state root, and say how to + * sandbox it; SYM_TEST_REAL_HOME=1 is the explicit way out. + * + * The "outside a temp dir" home used here is a scratch dir inside this checkout, created by no + * one: the guard must throw before anything is written there, and the test checks that nothing + * was. (If the checkout itself sits under the temp dir, there is no such place to point at and + * those cases are skipped.) + */ + +const { describe, it, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync, spawnSync } = require('node:child_process'); +const { assertTestSandbox } = require('../lib/core/state-root'); +const { sandbox } = require('./_isolate-home'); + +const NODE = require.resolve('../lib/node'); +const DAEMON = path.join(__dirname, '..', 'bin', 'sym-daemon.js'); +const REAL_TMP = fs.realpathSync(os.tmpdir()); +const outsideTmp = path.join(__dirname, '..', `.test-home-guard-${process.pid}`); +const checkoutInTmp = !path.relative(REAL_TMP, fs.realpathSync(path.join(__dirname, '..'))).startsWith('..'); +const needsOutside = { skip: checkoutInTmp && 'this checkout is itself under the temp dir, so there is no non-temp path to test with' }; + +after(() => fs.rmSync(outsideTmp, { recursive: true, force: true })); + +/** Construct a SymNode in a fresh process under `env`; report whether it threw, and with what. */ +function constructIn(env) { + const r = spawnSync(process.execPath, ['-e', ` + try { + const n = new (require(${JSON.stringify(NODE)}).SymNode)({ name: 'guard-probe', silent: true }); + console.log(JSON.stringify({ ok: true, dir: n._dir })); + } catch (e) { console.log(JSON.stringify({ ok: false, code: e.code, message: e.message })); } + process.exit(0);`], { env, encoding: 'utf8', timeout: 30000 }); + const line = String(r.stdout).trim().split('\n').pop(); + assert.ok(line, `child produced no result: ${r.stderr}`); + return JSON.parse(line); +} + +const underTest = (over) => { + const env = { ...process.env, NODE_TEST_CONTEXT: process.env.NODE_TEST_CONTEXT || 'child', ...over }; + delete env.SYM_TEST_REAL_HOME; + if (!('SYM_STATE_DIR' in over)) delete env.SYM_STATE_DIR; + if (!('SYM_IDENTITY_DIR' in over)) delete env.SYM_IDENTITY_DIR; + return env; +}; + +describe('assertTestSandbox', () => { + it('this test process is under the runner, and its sandboxed home passes', () => { + assert.ok(process.env.NODE_TEST_CONTEXT, 'node --test sets NODE_TEST_CONTEXT'); + assert.doesNotThrow(() => assertTestSandbox()); + }); + + it('a home outside the temp dir throws ETESTHOME, naming it and the way out', needsOutside, () => { + const prev = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; + process.env.HOME = outsideTmp; + process.env.USERPROFILE = outsideTmp; + try { + assert.throws(() => assertTestSandbox(), (e) => { + assert.equal(e.code, 'ETESTHOME'); + assert.ok(e.message.includes(outsideTmp), 'names the offending directory'); + assert.match(e.message, /HOME and USERPROFILE/); + assert.match(e.message, /SYM_TEST_REAL_HOME=1/); + return true; + }); + process.env.SYM_TEST_REAL_HOME = '1'; + assert.doesNotThrow(() => assertTestSandbox(), 'the explicit opt-out'); + } finally { + delete process.env.SYM_TEST_REAL_HOME; + process.env.HOME = prev.HOME; + process.env.USERPROFILE = prev.USERPROFILE; + } + }); + + it('SYM_IDENTITY_DIR outside the temp dir is refused too: that is where the keypair goes', needsOutside, () => { + process.env.SYM_IDENTITY_DIR = outsideTmp; + try { + assert.throws(() => assertTestSandbox(), (e) => e.code === 'ETESTHOME' && e.message.includes('SYM_IDENTITY_DIR')); + } finally { delete process.env.SYM_IDENTITY_DIR; } + }); + + it('a temp dir reached through a symlink counts as the temp dir (macOS /var -> /private/var)', () => { + const prev = process.env.HOME; + try { + for (const home of [sandbox, fs.realpathSync(sandbox), path.join(sandbox, 'not', 'made', 'yet')]) { + process.env.HOME = home; + if (process.platform === 'win32') process.env.USERPROFILE = home; + assert.doesNotThrow(() => assertTestSandbox(), home); + } + } finally { + process.env.HOME = prev; + process.env.USERPROFILE = prev; + } + }); + + it('outside the test runner nothing is checked', needsOutside, () => { + const out = execFileSync(process.execPath, ['-e', + `require(${JSON.stringify(require.resolve('../lib/core/state-root'))}).assertTestSandbox(); console.log('ok')`], + { env: (() => { const e = { ...process.env, HOME: outsideTmp, USERPROFILE: outsideTmp }; delete e.NODE_TEST_CONTEXT; return e; })(), encoding: 'utf8' }); + assert.equal(out.trim(), 'ok'); + }); +}); + +describe('SymNode construction', () => { + it('throws ETESTHOME when HOME is not sandboxed — and writes nothing there', needsOutside, () => { + const r = constructIn(underTest({ HOME: outsideTmp, USERPROFILE: outsideTmp })); + assert.equal(r.ok, false, 'construction must be refused'); + assert.equal(r.code, 'ETESTHOME', r.message); + assert.equal(fs.existsSync(outsideTmp), false, 'nothing was written to the unsandboxed home'); + }); + + it('throws ETESTHOME when SYM_STATE_DIR is outside the temp dir, even with HOME sandboxed', needsOutside, () => { + const r = constructIn(underTest({ SYM_STATE_DIR: path.join(outsideTmp, 'state') })); + assert.equal(r.code, 'ETESTHOME', r.message); + assert.ok(r.message.includes('state root')); + assert.equal(fs.existsSync(outsideTmp), false); + }); + + it('constructs normally in a sandbox', () => { + const r = constructIn(underTest({})); + assert.equal(r.ok, true, r.message); + assert.ok(r.dir.startsWith(sandbox), `the node lives in the sandbox: ${r.dir}`); + }); + + it('SYM_TEST_REAL_HOME=1 opts out deliberately', needsOutside, () => { + const r = constructIn({ ...underTest({ HOME: outsideTmp, USERPROFILE: outsideTmp }), SYM_TEST_REAL_HOME: '1' }); + assert.equal(r.ok, true, r.message); + assert.ok(fs.existsSync(path.join(outsideTmp, '.sym', 'nodes', 'guard-probe', 'identity.json')), 'and then it writes where it was told'); + fs.rmSync(outsideTmp, { recursive: true, force: true }); + }); +}); + +describe('daemon start', () => { + it('refuses to start with an unsandboxed home, and writes nothing there', needsOutside, () => { + const r = spawnSync(process.execPath, [DAEMON], { + env: underTest({ HOME: outsideTmp, USERPROFILE: outsideTmp, SYM_RELAY_ONLY: '1', SYM_NODE_NAME: 'guard-daemon', + SYM_SOCKET: path.join(sandbox, 'guard.sock') }), + encoding: 'utf8', timeout: 20000, + }); + assert.notEqual(r.status, 0, `the daemon must not run; stdout:\n${r.stdout}`); + assert.match(String(r.stderr), /ETESTHOME/); + assert.equal(fs.existsSync(outsideTmp), false, 'nothing was written to the unsandboxed home'); + }); +}); From 99c454d7feeff8ac33cec6e99d0ff408122a6a2a Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:46:53 +0100 Subject: [PATCH 021/148] identity lock: re-read a mismatched start time only when it was remembered 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/config.js b/lib/config.js index 24447e9d..dbdeed31 100644 --- a/lib/config.js +++ b/lib/config.js @@ -578,8 +578,8 @@ function lockIsHeldByLiveProcess(lock) { let current = processStartTime(lock.pid); // A remembered start time (Windows) may predate a death and reuse of this pid that no // check observed. A mismatch hands the lock to someone else, so it is never concluded - // from memory: re-read first. A match errs only toward "held". - if (current && current !== lock.start) current = processStartTime(lock.pid, { fresh: true }); + // from memory: re-read first. A match errs only toward "held". (`ps` is never remembered.) + if (current && current !== lock.start && startTimeCache()) current = processStartTime(lock.pid, { fresh: true }); if (current && current !== lock.start) return false; // PID reused } else if (lock.mtimeMs < bootTimeMs() - LOCK_BOOT_SLACK_MS) { // Legacy lock with no start-time metadata, written before this boot: From e59f71923db99eff5c0756677f417ca483679ce7 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:48:46 +0100 Subject: [PATCH 022/148] inbox and clock: the limit counts unread, `from` names a proven party, 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/frame-handler.js | 7 ++- lib/node.js | 53 ++++++++++++++++-- tests/audit-delivery-authorship.test.js | 19 ++++++- tests/inbox-limit-and-clock.test.js | 73 +++++++++++++++++++++++++ tests/v2-author-binding.test.js | 20 +++++++ 5 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 tests/inbox-limit-and-clock.test.js diff --git a/lib/frame-handler.js b/lib/frame-handler.js index 544186ee..300a9ea6 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -473,7 +473,7 @@ class FrameHandler { if (!authorKey) { msg._cmbVerified = false; return false; } const v2 = verifyCMB(cmb, authorKey); const idOk = md.assertionId === undefined || md.assertionId === assertionIdV2_0(cmb); - if (v2.valid && idOk) { msg._cmbVerified = true; return false; } + if (v2.valid && idOk) { msg._cmbVerified = true; msg._verifiedAuthorNodeId = md.createdByNodeId; return false; } const reason = !v2.valid ? (v2.error || 'invalid') : 'assertion-id-mismatch'; const k = recordKey(cmb); this._node._log(`[sym-security] v2.0 CMB ${String(k || '').slice(0, 16)} from ${peerName} rejected — ${reason} (author ${md.createdByNodeId.slice(0, 8)})`); @@ -821,7 +821,10 @@ class FrameHandler { const claimedNodeId = msg.cmb?.metadata?.createdByNodeId || null; msg.author = { name: recordCreatedBy(msg.cmb) || peerName, - nodeId: msg._cmbVerified === true && claimedNodeId === peerId ? claimedNodeId : null, + // Established when the signature proved it: a v2.0 record verified against its signed node id + // (even when relayed), or any verified record its author delivered itself. + nodeId: msg._verifiedAuthorNodeId + || (msg._cmbVerified === true && claimedNodeId === peerId ? claimedNodeId : null), via: { name: peerName, nodeId: peerId }, }; diff --git a/lib/node.js b/lib/node.js index 21c5602b..cf04f941 100644 --- a/lib/node.js +++ b/lib/node.js @@ -1,6 +1,6 @@ 'use strict'; -const { recordKey, recordCreatedBy, recordLineage, recordParents, setRecordLineage } = require('./record'); +const { recordKey, recordCreatedBy, recordCreatedAt, recordLineage, recordParents, setRecordLineage } = require('./record'); /** * SymNode — sovereign mesh node with cognitive coupling. @@ -41,6 +41,8 @@ const { signGrant, roleRank, CAT7_CATEGORIES, } = require('./core'); const { MMP_EMIT_V2 } = require('./emit-policy'); // the single reader-first flip switch +/** How far this node's record timestamps may run ahead of its clock before they follow it again. */ +const MAX_CLOCK_LEAD_MS = 60 * 1000; const { FrameHandler } = require('./frame-handler'); const { TcpTransport } = require('./transport'); const { RelayConnection } = require('./relay'); @@ -1770,8 +1772,12 @@ class SymNode extends EventEmitter { this._inbox.push({ seq, id, - // The author, not the store envelope's "+" bookkeeping string. - from: entry.author?.name || recordCreatedBy(cmb) || entry.source || 'unknown', + // Who can be held to it: the author when its signature proved who that is, otherwise the peer + // that delivered it. The unproven claim stays in author.name, so `from` is safe to + // authorize on. (It used to be the author's label whether or not anything proved it.) + from: (entry.author?.nodeId && entry.author?.name) + || entry.author?.via?.name + || recordCreatedBy(cmb) || entry.source || 'unknown', author: entry.author || null, content: entry.content || '', categories: cmb.categories || null, @@ -1868,7 +1874,14 @@ class SymNode extends EventEmitter { const limit = Math.max(1, Math.min(opts.limit || 50, this._inboxMax)); const from = opts.since !== undefined ? opts.since : this._inboxCursor; const fresh = this._inbox.filter((m) => m.seq > from); - const slice = fresh.slice(0, limit); + // The limit counts what is still unread: an item already acked (read in full by id) comes back + // marked, but does not use up a place a new delivery needed. + const slice = []; + let unread = 0; + for (const m of fresh) { + if (!m.acked) { if (unread >= limit) break; unread++; } + slice.push(m); + } if (!opts.peek && slice.length) { this._inboxCursor = Math.max(this._inboxCursor, slice[slice.length - 1].seq); this._persistInbox(); // the drain moves the cursor — a restart must not replay it @@ -1961,7 +1974,17 @@ class SymNode extends EventEmitter { // Two of this node's records never share a createdTimestamp: the timestamp is the one preimage // term that tells two assertions of the same words apart, so two sends in one millisecond // would otherwise carry identical signatures and the second would read as a replay. - const ts = Math.max(opts.cmb.metadata.createdTimestamp, (this._lastCreatedTimestamp ?? 0) + 1); + // The ratchet starts from this node's newest stored record, so it holds across restarts. It + // follows the clock again after a backward step of more than MAX_CLOCK_LEAD_MS instead of + // running ahead of it indefinitely. + const clock = opts.cmb.metadata.createdTimestamp; + let last = this._timestampRatchetBase(); + if (last - clock > MAX_CLOCK_LEAD_MS) { + this._log(`The clock is ${Math.round((last - clock) / 1000)} s behind this node's newest record; timestamps follow the clock again`); + this.emit('metric', { type: 'clock-stepped-back', byMs: last - clock }); + last = clock - 1; + } + const ts = Math.max(clock, last + 1); opts.cmb.metadata.createdTimestamp = ts; this._lastCreatedTimestamp = ts; // assertionId is a hash OF the v2.0 preimage, set after construction and before signing. @@ -2157,6 +2180,26 @@ class SymNode extends EventEmitter { return entry; } + /** + * The newest createdTimestamp this node has minted, read once from its own stored records. + * @private + */ + _timestampRatchetBase() { + if (this._lastCreatedTimestamp === undefined) { + let max = 0; + try { + for (const e of this._store.allEntries()) { + if (e && e.peerId == null) { + const t = recordCreatedAt(e.cmb); + if (Number.isFinite(t) && t > max) max = t; + } + } + } catch { /* an unreadable store starts the ratchet at zero */ } + this._lastCreatedTimestamp = max; + } + return this._lastCreatedTimestamp; + } + /** * Sign a record with this node's identity key, or throw ESIGN. Nothing is stored or dispatched * before this runs, so a failure leaves no unsigned copy anywhere. diff --git a/tests/audit-delivery-authorship.test.js b/tests/audit-delivery-authorship.test.js index edb0ca30..2e48887b 100644 --- a/tests/audit-delivery-authorship.test.js +++ b/tests/audit-delivery-authorship.test.js @@ -381,7 +381,7 @@ describe('F3: an admitted broadcast the store already holds', () => { }); describe('author and inbox id on surfaced entries', () => { - it('carries the author and the delivering peer, and the inbox reads the author', async () => { + it('carries the author and the delivering peer; the inbox `from` is the peer when nothing proves the author (K5)', async () => { await withNode('author', async (node) => { node._svafEvaluator.evaluate = async () => ALIGNED; const seen = collect(node); @@ -392,11 +392,26 @@ describe('author and inbox id on surfaced entries', () => { assert.ok(!String(e.author.name).includes('+')); const item = node.inboxGet(e.inboxId); assert.ok(item, 'the surfaced entry names its inbox id'); - assert.strictEqual(item.from, 'claude-sym-agent-a'); + assert.strictEqual(item.from, 'peerA', 'an unsigned claim is not who the inbox says it is from'); + assert.strictEqual(item.author.name, 'claude-sym-agent-a', 'the claim is kept, as a claim'); assert.strictEqual(item.seq, e.inboxSeq); }); }); + it('K5: the inbox `from` is the author when its signature proved who that is', async () => { + await withNode('author-proven', async (node) => { + node._pinPeerKey('peerA', PEER_A.pub); + node._svafEvaluator.evaluate = async () => ALIGNED; + const seen = collect(node); + const cmb = signed(mkCmb('proven authorship', { by: 'peerA' })); + cmb.metadata.createdByNodeId = undefined; + node._frameHandler.handle('peerA', 'peerA', frame(cmb)); + await settle(); + const item = node.inboxGet(seen.accepted[0].inboxId); + assert.strictEqual(item.from, 'peerA'); + }); + }); + it('every surfaced path carries author.via: rejected-directed, CLI-host and admitted', async () => { const paths = { 'rejected-directed': async (node) => { node._svafEvaluator.evaluate = async () => REJECTED; return directed(node, mkCmb('via rejected')); }, diff --git a/tests/inbox-limit-and-clock.test.js b/tests/inbox-limit-and-clock.test.js new file mode 100644 index 00000000..4b6040d6 --- /dev/null +++ b/tests/inbox-limit-and-clock.test.js @@ -0,0 +1,73 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME to a temp sandbox before lib/config loads + +/** + * - K4: inbox()'s limit counts unread deliveries; an item already acked (read in full by id) comes + * back marked but does not take a new delivery's place. + * - K6: record timestamps are strictly increasing across restarts (the ratchet starts from the newest + * stored record), and after the clock steps back by more than a minute they follow the clock again + * instead of running ahead of it indefinitely. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const { SymNode } = require('../lib/node'); +const { NullDiscovery } = require('../lib/discovery'); +const { nodeDir } = require('../lib/config'); + +const mkNode = (name) => new SymNode({ name, silent: true, discovery: new NullDiscovery() }); +const uniq = (p) => `${p}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + +describe('inbox limit (K4)', () => { + it('acked items do not use up the limit', () => { + const name = uniq('inbox'); + const node = mkNode(name); + try { + for (let i = 0; i < 6; i++) node._pushInbox({ cmb: { categories: { focus: { text: `m${i}` } } }, content: `m${i}`, source: 'peer' }); + const ids = node._inbox.map((m) => m.id); + node.inboxAck(ids[0]); node.inboxAck(ids[1]); node.inboxAck(ids[2]); + const r = node.inbox({ limit: 2 }); + const unread = r.messages.filter((m) => !m.acked).map((m) => m.id); + assert.deepStrictEqual(unread, [ids[3], ids[4]], 'two unread deliveries, as asked'); + assert.ok(r.messages.slice(0, 3).every((m) => m.acked), 'the acked ones come back marked'); + assert.strictEqual(r.remaining, 1); + } finally { fs.rmSync(nodeDir(name), { recursive: true, force: true }); } + }); +}); + +describe('record timestamps (K6)', () => { + it('stay strictly increasing across a restart', () => { + const name = uniq('clock'); + try { + const a = mkNode(name); + const realNow = Date.now; + const future = realNow() + 30 * 1000; // records written 30 s "ahead", as after a small clock step + Date.now = () => future; + const first = a.remember({ focus: 'before the restart' }); + Date.now = realNow; + a._identityLock?.release?.(); + const b = mkNode(name); // a new process on the same store + const second = b.remember({ focus: 'after the restart' }); + assert.ok(second.cmb.metadata.createdTimestamp > first.cmb.metadata.createdTimestamp); + } finally { fs.rmSync(nodeDir(name), { recursive: true, force: true }); } + }); + + it('follow the clock again after it steps back by more than a minute', () => { + const name = uniq('clockstep'); + const node = mkNode(name); + const metrics = []; + node.on('metric', (m) => metrics.push(m)); + const realNow = Date.now; + try { + const t0 = realNow(); + Date.now = () => t0 + 60 * 60 * 1000; // an hour ahead, then the clock is corrected + node.remember({ focus: 'written while the clock was an hour fast' }); + Date.now = () => t0; + const after = node.remember({ focus: 'written after the correction' }); + assert.ok(Math.abs(after.cmb.metadata.createdTimestamp - t0) < 1000, 'the timestamp is the clock again'); + assert.ok(metrics.some((m) => m.type === 'clock-stepped-back')); + } finally { Date.now = realNow; fs.rmSync(nodeDir(name), { recursive: true, force: true }); } + }); +}); diff --git a/tests/v2-author-binding.test.js b/tests/v2-author-binding.test.js index f7356a38..2342cd30 100644 --- a/tests/v2-author-binding.test.js +++ b/tests/v2-author-binding.test.js @@ -91,3 +91,23 @@ describe('assertionId (B-R6)', () => { }); }); }); + +describe('the inbox names a proven author (K5)', () => { + it('a relayed v2.0 record verified against its signed node id is from its author, not the relay', async () => { + const name = `v2inbox-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node = new SymNode({ name, silent: true, discovery: new NullDiscovery() }); + await node.start(); + try { + node._pinPeerKey('node-alice', ALICE.pub); + node._pinPeerKey('node-relay', RELAY.pub); + node._svafEvaluator.evaluate = async () => ({ decision: 'aligned', total_drift: 0.1, category_drifts: { focus: 0.1 }, gate_values: { g: 1 } }); + const got = []; + node.on('cmb-accepted', (e) => got.push(e)); + node._frameHandler.handle('node-relay', 'relay', { type: 'cmb', timestamp: Date.now(), cmb: v2Record({ nodeId: 'node-alice', createdBy: 'alice', signWith: ALICE, focus: 'relayed but proven' }) }); + await new Promise((r) => setTimeout(r, 150)); + assert.strictEqual(got.length, 1); + assert.strictEqual(got[0].author.nodeId, 'node-alice'); + assert.strictEqual(node.inboxGet(got[0].inboxId).from, 'alice'); + } finally { await node.stop(); fs.rmSync(nodeDir(name), { recursive: true, force: true }); } + }); +}); From 4430532b2d1b7aaee0e40477a6713bf259664f23 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:51:12 +0100 Subject: [PATCH 023/148] daemon: keep its files in SYM_STATE_DIR, no LAN beacon when relay-only; 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- bin/sym-daemon.js | 11 +++++++--- lib/frame-parser.js | 3 +++ tests/daemon-relay-only.test.js | 31 ++++++++++++++++++++++++++++ tests/send-frame-dead-socket.test.js | 29 ++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 tests/send-frame-dead-socket.test.js diff --git a/bin/sym-daemon.js b/bin/sym-daemon.js index 95274a8d..a03263d2 100755 --- a/bin/sym-daemon.js +++ b/bin/sym-daemon.js @@ -61,7 +61,10 @@ require('../lib/core/state-root').assertTestSandbox(); // ── Configuration ────────────────────────────────────────────── -const SYM_DIR = path.join(os.homedir(), '.sym'); +// The daemon's own files live in the same state root as every node's: SYM_STATE_DIR when set, else +// ~/.sym. Building it from the home dir sent a rooted daemon's room, tasks and relay.env to the +// user's real ~/.sym. +const SYM_DIR = require('../lib/core/state-root').SYM_STATE_DIR; const { getSocketPath, getLogDir } = require('../lib/platform'); const SOCKET_PATH = getSocketPath(); // Stable name: use SYM_NODE_NAME env, or platform-scoped default @@ -78,7 +81,7 @@ const LOG_DIR = getLogDir('sym-daemon'); // Load relay config from ~/.sym/relay.env if env vars not set if (!process.env.SYM_RELAY_URL) { - const envFile = path.join(os.homedir(), '.sym', 'relay.env'); + const envFile = path.join(SYM_DIR, 'relay.env'); if (fs.existsSync(envFile)) { for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) { const m = line.match(/^(\w+)=(.*)$/); @@ -871,7 +874,9 @@ async function main() { await node.start(); log(`SYM node started (${node._identity?.nodeId?.slice(0, 8)})`); - startRoomBeacon(); + // A relay-only daemon has LAN discovery off, so it must not announce its room on the LAN either. + if (RELAY_ONLY) log('Room beacon: off (relay-only)'); + else startRoomBeacon(); loadTasks(); log(`Loaded ${tasks.size} task(s)`); diff --git a/lib/frame-parser.js b/lib/frame-parser.js index 93f6a6eb..66429a74 100644 --- a/lib/frame-parser.js +++ b/lib/frame-parser.js @@ -94,6 +94,9 @@ class FrameParser extends EventEmitter { function sendFrame(socket, msg) { const payload = Buffer.from(JSON.stringify(msg), 'utf8'); if (payload.length > MAX_FRAME_SIZE) return false; + // A socket that is destroyed or no longer writable has not taken the frame, even though write() + // on it may not throw: 'close' can arrive after the destroy, and until then this counted as sent. + if (!socket || socket.destroyed || socket.writable === false) return false; const header = Buffer.alloc(4); header.writeUInt32BE(payload.length, 0); try { diff --git a/tests/daemon-relay-only.test.js b/tests/daemon-relay-only.test.js index 54f27f3e..028718cb 100644 --- a/tests/daemon-relay-only.test.js +++ b/tests/daemon-relay-only.test.js @@ -46,6 +46,8 @@ test('SYM_RELAY_ONLY=1: LAN discovery off, relay-auth still sent, CLI reaches th // The relay leg, on its own. assert.match(out, /relay-only: LAN discovery off \(SYM_RELAY_ONLY\); joining ws:/, out.slice(-800)); + assert.match(out, /Room beacon: off \(relay-only\)/, 'a relay-only daemon does not announce its room on the LAN'); + assert.doesNotMatch(out, /Room beacon: room=/); assert.equal(auths.length >= 1, true, `relay-auth expected; log:\n${out.slice(-800)}`); assert.equal(auths[0].name, 'relay-only-test'); assert.equal(typeof auths[0].engine, 'string'); @@ -71,3 +73,32 @@ test('SYM_RELAY_ONLY=1: LAN discovery off, relay-auth still sent, CLI reaches th fs.rmSync(home, { recursive: true, force: true }); } }); + +test('a daemon with SYM_STATE_DIR keeps its own files in that root, not in ~/.sym', async () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'sym-rooted-')); + const home = path.join(base, 'home'); fs.mkdirSync(home); + const root = path.join(base, 'root'); fs.mkdirSync(root); + const wss = new WebSocketServer({ port: 0 }); + const auths = []; + wss.on('connection', (ws) => ws.on('message', (m) => { const f = JSON.parse(String(m)); if (f.type === 'relay-auth') auths.push(f); })); + // The relay config and the room live only in the root: a daemon reading ~/.sym finds neither. + fs.writeFileSync(path.join(root, 'relay.env'), `SYM_RELAY_URL=ws://127.0.0.1:${wss.address().port}\nSYM_RELAY_TOKEN=${'y'.repeat(32)}\n`); + fs.writeFileSync(path.join(root, 'room'), 'rooted-room'); + const socket = path.join(base, 'r.sock'); + const env = { ...process.env, HOME: home, USERPROFILE: home, SYM_STATE_DIR: root, SYM_SOCKET: socket, SYM_NODE_NAME: 'rooted-test', SYM_RELAY_ONLY: '1' }; + delete env.SYM_RELAY_URL; delete env.SYM_RELAY_TOKEN; delete env.SYM_ROOM; + const daemon = spawn(process.execPath, [path.join(BIN, 'sym-daemon.js')], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let out = ''; + daemon.stdout.on('data', (b) => { out += b; }); + daemon.stderr.on('data', (b) => { out += b; }); + try { + for (let i = 0; i < 100 && daemon.exitCode === null && !auths.length; i++) await new Promise((r) => setTimeout(r, 100)); + assert.ok(auths.length >= 1, `the relay config was read from the root; log:\n${out.slice(-800)}`); + assert.match(out, /rooted-room/, 'the room was read from the root'); + assert.equal(fs.existsSync(path.join(home, '.sym')), false, 'nothing was written to ~/.sym'); + } finally { + daemon.kill('SIGTERM'); + await new Promise((r) => wss.close(() => r())); + fs.rmSync(base, { recursive: true, force: true }); + } +}); diff --git a/tests/send-frame-dead-socket.test.js b/tests/send-frame-dead-socket.test.js new file mode 100644 index 00000000..fb91f1a5 --- /dev/null +++ b/tests/send-frame-dead-socket.test.js @@ -0,0 +1,29 @@ +'use strict'; + +/** + * B-D6 (residual): a frame handed to a socket that is destroyed, or no longer writable, was counted + * as sent until 'close' fired, so a send in that window reported delivery.dispatched for nothing. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const net = require('node:net'); +const { sendFrame } = require('../lib/frame-parser'); + +describe('sendFrame', () => { + it('refuses a destroyed socket, a non-writable one, and none at all', () => { + const destroyed = new net.Socket(); + destroyed.destroy(); + assert.strictEqual(sendFrame(destroyed, { type: 'cmb' }), false); + const ended = { destroyed: false, writable: false, write: () => true }; + assert.strictEqual(sendFrame(ended, { type: 'cmb' }), false); + assert.strictEqual(sendFrame(null, { type: 'cmb' }), false); + }); + + it('still sends on a writable socket', () => { + const writes = []; + const live = { destroyed: false, writable: true, write: (b) => { writes.push(b); return true; } }; + assert.strictEqual(sendFrame(live, { type: 'cmb', n: 1 }), true); + assert.strictEqual(writes.length, 1); + }); +}); From c56fb0f4eacc45596169383461a56d92d47bb665 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:51:35 +0100 Subject: [PATCH 024/148] test: pin admission-as-collapse (B-L3), the conformance boundary of spec PR #17 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- tests/audit-delivery-authorship.test.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/audit-delivery-authorship.test.js b/tests/audit-delivery-authorship.test.js index 2e48887b..1915bb59 100644 --- a/tests/audit-delivery-authorship.test.js +++ b/tests/audit-delivery-authorship.test.js @@ -380,6 +380,29 @@ describe('F3: an admitted broadcast the store already holds', () => { }); }); +describe('B-L3: admission that leaves the text unchanged is a collapse (spec PR #17, §15.5 "Collapsed integration")', () => { + it('stores the author\'s record exactly as signed, mints no receiver remix, writes no self-edge', async () => { + await withNode('collapse', async (node) => { + node._pinPeerKey('peerA', PEER_A.pub); + node._svafEvaluator.evaluate = async () => ALIGNED; + const before = node.recall('').length; + const cmb = signed(mkCmb('an observation the receiver integrates unchanged')); + const key = cmb.metadata.key; + node._frameHandler.handle('peerA', 'peerA', frame(cmb)); + await settle(); + const stored = node._store.get(key); + assert.ok(stored, 'the record is held under the author\'s address'); + assert.strictEqual(recordCreatedBy(stored.cmb), 'peerA', 'never attributed to the receiver'); + assert.deepStrictEqual(core.verifyCMB(stored.cmb, PEER_A.pub).valid, true, 'kept exactly as signed'); + const parents = stored.cmb.metadata?.lineage?.parents || []; + assert.ok(!parents.includes(key), 'no K→K self-edge'); + const own = node.recall('').filter((e) => recordCreatedBy(e.cmb) === node.name); + assert.strictEqual(own.length, 0, 'the receiver minted no remix of its own'); + assert.strictEqual(node.recall('').length, before + 1); + }); + }); +}); + describe('author and inbox id on surfaced entries', () => { it('carries the author and the delivering peer; the inbox `from` is the peer when nothing proves the author (K5)', async () => { await withNode('author', async (node) => { From 38640074211379d8f075262ae8796e98ae08dcf9 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 18:52:25 +0100 Subject: [PATCH 025/148] =?UTF-8?q?CHANGELOG:=20Unreleased=20(0.13.15)=20?= =?UTF-8?q?=E2=80=94=20audit=20findings,=20known=20limits,=20Windows=20tes?= =?UTF-8?q?t=20debt,=20the=20daemon's=20log=20flood?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- CHANGELOG.md | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 823fb4e5..bd728c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,105 @@ # Changelog +## Unreleased + +Fixes from the MMP 2.0 conformance audit's open findings, the 0.13.12 known limits, the Windows test +debt, and the daemon's log flood. Every item has a test that fails without its fix. + +### Fixed — security + +- **A relay could replay a signed directed record.** The directed de-duplication mark was the raw + base64url signature or the carried `assertionId`. Base64url decoding ignores padding and the + unused bits of the last character, and no signature covers `assertionId`, so a relay could + re-spell either and make one signed directed record surface again and again. The mark is now + the hash of the decoded signature bytes. +- **A peer could vouch for a v2.0 record by signing it under another node's id (B-R4).** 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 it. It is now verified against the key held for its + `createdByNodeId`, so a genuine relay also verifies. A v2.0 record whose carried `assertionId` + is not the one its preimage yields is refused (B-R6). On other suites a carried `assertionId`, + which nothing signs, is dropped. +- **Stripping the frame's `directed` flag turned a signed directed CMB into a broadcast (B-R8).** + A signed addressee could only veto directed treatment. When the author signed one, it now alone + decides. +- **Unauthenticated gossip could repoint a phone's wake token.** Any room peer's `peer-info` could + overwrite a wake channel the phone had given this node itself. A channel learned from a weaker + source (gossip < relay < the peer itself) now never replaces a stronger one's token. +- **A record with no room was admitted in every room it was replayed into (B-R10).** A record that + names no room is now in the literal room `default` (§7). `createCMB` names `default` instead of + null (which signed the string `"null"`), and the v2.0 preimage refuses a record without a room. +- **A record that could not be signed was sent unsigned (B-R12).** `remember()` now throws `ESIGN` + before anything is stored or dispatched (§18.3.1). + +### Fixed — delivery and records + +- **Two copies of one record arriving together could both surface (K1).** A key is held in flight + from the de-duplication check until its SVAF pass settles. +- **A broadcast flood could evict directed de-duplication marks (K3).** They have their own map and + cap now. +- **`inbox()`'s limit counted acked items (K4).** It counts unread deliveries; acked ones still come + back, marked. +- **The inbox `from` was the author's label whether or not anything proved it (K5).** It is now the + author when the signature proved who that is, and otherwise the peer that delivered it. The claim + stays in `author.name`. A v2.0 record verified against its signed node id carries `author.nodeId`, + even when relayed. +- **Record timestamps ran ahead of the clock after a backward step, and restarted every process + (K6).** The ratchet starts from this node's newest stored record. After a step back of more than a + minute, timestamps follow the clock again, with a `clock-stepped-back` metric. +- **Mood values were invented or lost (B-R11).** `valence` and `arousal` are kept only when measured, + must be numbers in [-1, 1], and a mood given only as numbers keeps them. +- **Records had no size bound below the 1 MiB frame (B-R13).** A category is at most 256 KiB, the + seven at most 960 KiB, and an agent id at most 64 bytes (§3.1.2). The emitter throws `ECMBSIZE`, and + a receiver refuses an oversized record before rendering, verifying or storing it. +- **A served v2.0 record no longer verified (B-R9, part).** `cmb-fetch` now serves a record's + metadata whole, as a copy. +- **A frame written to a destroyed socket counted as sent (B-D6, residual).** +- **Unsigned records are now counted (B-R3, interim).** They are still accepted as unverified for + interop, but each is counted (`cmb-unsigned-received`) and the sending peer is named once in the + log, so the emitters a signed-only default would cut off can be found first. + +### Fixed — lineage + +- **The root walk trusted unverified records (B-L4).** It walks only through records whose address + recomputes and that this node wrote or admitted as verified. A remix with no verified ancestor in + reach resolves to no anchor, instead of being anchored to itself, and the result says whether the + walk was `complete`. +- **A tether could not be reproduced by another node (B-L5).** It is measured on the stored record's + text, encoded in one kernel, instead of on vectors blended with local memory. +- **Cold-start admissions had no tether (B-L6).** +- The tether audit's fetch fallback no longer returns a local record the walk had refused. + +### Fixed — the daemon + +- **The log flood.** Every peer re-sends its whole wake-channel list on every connect, and the + receiver logged a line and rewrote `wake-channels.json` per entry per frame, so the daemon's + `stdout.log` reached 1 GB of `learned wake channel for unknown`. A repeat now changes nothing and + logs nothing; a frame that does teach something logs one line. Channels carry the time of the last + first-hand sighting (gossip forwards it instead of the time of sending) and expire after 30 days + unseen. A channel saved before this release gets one 30-day grace period, once. +- **A daemon rooted with `SYM_STATE_DIR` kept its room, tasks and relay.env in `~/.sym`.** It uses + the state root now. +- **A relay-only daemon still announced its room on the LAN.** It no longer does. +- **IPC on Windows.** The daemon and every client now resolve the IPC endpoint one way, and on + Windows a file path becomes a named pipe. `SymDaemonClient` defaulted to `/tmp/sym.sock`, which no + daemon listens on (every OS). +- **Windows lock checks.** A live pid's start time is cached until the pid is seen dead, and several + pids are read in one PowerShell call. A mismatch is re-read before a lock is reclaimed. +- The daemon reports a failed IPC command as that command's error instead of a parse error. + +### Fixed — tests and conformance + +- **A test node can no longer reach the real `~/.sym`.** Under the Node test runner, a `SymNode` (or + the daemon) throws `ETESTHOME` when its home, state root or identity dir is outside the temp dir. + `SYM_TEST_REAL_HOME=1` opts out. **This affects other projects:** a suite that builds a `SymNode` + under `node --test` with the real HOME now fails until it sandboxes HOME and USERPROFILE. +- The published v2 conformance vectors are consumed verbatim, with their errata, plus a new + application-v2 test. The handshake transcript and AAD normalise room and names to NFC, as the + reference does (B-R17). +- Windows test debt is fixed. The SIGTERM lock test is skipped on Windows, with a hard-kill reclaim + test on every platform. The lease test uses a live foreign holder with a real start time; it never + reached the start-time check before, on any OS. The relay-only test uses the platform's IPC endpoint. +- Admission-as-collapse (B-L3) is pinned by a test: the conformance boundary of spec PR #17. + ## 0.13.14 (2026-10-01) ### Added — a peer's admission verdict is observable as it lands (`attestation-received`) From 30f7ff616ae097bb7bd6988615ce04fdbf3bf837 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 19:02:36 +0100 Subject: [PATCH 026/148] svaf: anchors are measured through a side map, never written to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/core/svaf-baseline.js | 40 ++++++++++++------ tests/svaf-anchor-vectors.test.js | 68 +++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 13 deletions(-) create mode 100644 tests/svaf-anchor-vectors.test.js diff --git a/lib/core/svaf-baseline.js b/lib/core/svaf-baseline.js index e16cf186..a7dc9cdb 100644 --- a/lib/core/svaf-baseline.js +++ b/lib/core/svaf-baseline.js @@ -120,21 +120,26 @@ async function _localVector(categoryName, text, useSemantic) { } /** - * Replace every category's vector with one this node computed from the category's own text. + * The categories as this node encodes them: a NEW map of `{ text, vector }`, one entry per category + * that has text, every vector computed here from that text. * - * DELETES first, then recomputes — so a record that arrives carrying vectors is silently - * ignored rather than rejected (interop), and is admitted on the re-encoded value. The delete - * is the security-relevant line: without it a transmitted vector would survive for any category - * whose text is empty. + * Built beside the record, never on it. The records the gate reads are the store's own cached + * objects (MemoryStore.anchors hands out entry.cmb) and the incoming record the frame handler + * goes on to surface or store, so a vector attached in place stayed on them and was written to + * disk by the next persist of that entry, though a record carries no vector (§7.1). + * + * Only text is read, so a vector that arrived on the wire never reaches the measurement: a + * category with no text has no entry here at all, whatever the sender attached to it. */ -async function localiseVectors(categories, useSemantic) { - if (!categories) return; +async function localisedCategories(categories, useSemantic) { + const out = {}; + if (!categories) return out; for (const category of CAT7_CATEGORIES) { const f = categories[category]; - if (!f || typeof f !== 'object') continue; - delete f.vector; - if (f.text) f.vector = await _localVector(category, f.text, useSemantic); + if (!f || typeof f !== 'object' || !f.text) continue; + out[category] = { text: f.text, vector: await _localVector(category, f.text, useSemantic) }; } + return out; } /** @@ -484,10 +489,19 @@ async function _processHeuristicSVAF(opts) { // categoryKey either, so it is equally unsigned and equally alterable. Gating trust on an // attacker-supplied label tells the attacker which value to write. const useSemantic = isSemanticReady(); - await localiseVectors(incomingCMB.categories, useSemantic); - for (const anchor of anchors) await localiseVectors(anchor.categories, useSemantic); + const incomingCategories = await localisedCategories(incomingCMB.categories, useSemantic); + // Each anchor is measured through a view holding only what computeCategoryDrifts reads, so the + // stored record it came from is never touched. + const anchorViews = []; + for (const anchor of anchors) { + anchorViews.push({ + storedAt: anchor.storedAt, + confidence: anchor.confidence, + categories: await localisedCategories(anchor.categories, useSemantic), + }); + } - const dr = computeCategoryDrifts({ incomingCategories: incomingCMB.categories, anchors, config, now, tau }); + const dr = computeCategoryDrifts({ incomingCategories, anchors: anchorViews, config, now, tau }); const categoryDrifts = dr.categoryDrifts; const nearestDrifts = dr.nearestDrifts; const fusedCategories = dr.fusedCategories; diff --git a/tests/svaf-anchor-vectors.test.js b/tests/svaf-anchor-vectors.test.js new file mode 100644 index 00000000..c3d8cc03 --- /dev/null +++ b/tests/svaf-anchor-vectors.test.js @@ -0,0 +1,68 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME to a temp sandbox before lib/config loads + +/** + * The SVAF gate measures with receiver-local vectors, and those vectors must never land on a record. + * + * A record carries no vector (§7.1; see the NO VECTOR note in createCMB): a vector is covered by + * neither the address nor the signature, so one written into a stored record is a value nobody can + * check, and it is served and re-read as if it were part of what the author said. + * + * The gate's anchors are the store's own cached records (MemoryStore.anchors returns entry.cmb), + * and the gate used to attach each category's vector to them IN PLACE. The vectors then sat on the + * cached record until the next write of that entry, which is routine: a peer remix citing the + * record advances its lifecycle and persists it. Every anchor the gate ever read was written back + * to disk with a vector on each category. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { tmpdir } = require('./_tmpdir'); +const { MemoryStore } = require('../lib/memory-store'); +const { createCMB, processHeuristicSVAF, CAT7_CATEGORIES } = require('../lib/core'); + +const POLICY = { stableThreshold: 0.35, guardedThreshold: 0.75, temporalLambda: 0.3, freshnessSeconds: 3600 }; + +function cat7(t) { + return { + focus: t, issue: t, intent: t, motivation: t, commitment: t, + perspective: 'observer', mood: { text: 'neutral', valence: 0, arousal: 0 }, + }; +} + +function vectorCategories(record) { + return CAT7_CATEGORIES.filter((c) => record?.categories?.[c] && 'vector' in record.categories[c]); +} + +describe('SVAF gate: anchors are measured, never written to', () => { + it('after a gate pass the store holds no vector, and the next persist writes none', async () => { + const dir = tmpdir('sym-svaf-anchor-vectors-'); + const store = new MemoryStore(dir, 'receiver'); + const anchor = store.write('anchor', { cmb: createCMB({ categories: cat7('the build pipeline caches artifacts per commit'), createdBy: 'receiver' }) }); + const incoming = createCMB({ categories: cat7('the build pipeline caches artifacts per branch'), createdBy: 'peer' }); + + const now = Date.now(); + const r = await processHeuristicSVAF({ + msg: { type: 'cmb', cmb: incoming, content: 'incoming' }, + peerName: 'peer', localName: 'receiver', originTs: now, now, ageSeconds: 0, + recentCMBs: store.recentCMBs(5), config: POLICY, + }); + // Precondition: the gate really measured against the anchor rather than taking a cold-start exit. + assert.strictEqual(r.coldStartCause, undefined, 'the anchor was consulted'); + assert.ok(Object.values(r.categoryVerdicts).some((v) => v !== 'silent'), 'at least one category was graded against it'); + + assert.deepStrictEqual(vectorCategories(store.get(anchor.key).cmb), [], 'the cached anchor record carries no vector'); + assert.deepStrictEqual(vectorCategories(incoming), [], 'the incoming record the gate was handed carries no vector'); + + // The routine write that used to carry the vectors to disk: a peer remix citing the anchor + // advances the anchor's lifecycle, which persists the anchor's whole entry. + const remix = createCMB({ categories: cat7('a remix that cites the cached anchor'), createdBy: 'peer', lineage: { parents: [anchor.key], method: 'test' } }); + assert.ok(store.receiveFromPeer('peer', { content: 'remix', cmb: remix }), 'the remix is stored'); + const onDisk = JSON.parse(fs.readFileSync(path.join(dir, `${anchor.key}.json`), 'utf8')); + assert.strictEqual(onDisk.lifecycle, 'remixed', 'precondition: the anchor entry was persisted again'); + assert.deepStrictEqual(vectorCategories(onDisk.cmb), [], 'the persisted anchor carries no vector'); + }); +}); From 4092a8f4a70226c49390c898b83cb8719dd26471 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 19:04:56 +0100 Subject: [PATCH 027/148] svaf: cache each vector under the kernel that produced it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/core/context-encoder.js | 42 +++++++++++---- lib/core/svaf-baseline.js | 29 ++++++---- tests/svaf-kernel-cache.test.js | 95 +++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 21 deletions(-) create mode 100644 tests/svaf-kernel-cache.test.js diff --git a/lib/core/context-encoder.js b/lib/core/context-encoder.js index ffc1cab4..162039c9 100644 --- a/lib/core/context-encoder.js +++ b/lib/core/context-encoder.js @@ -119,17 +119,20 @@ function loadSemanticEncoder() { } /** - * Encode text using semantic embeddings (async). + * Encode text with a given semantic pipeline (async). The pipeline and its dimensionality are + * arguments rather than module state so a pinned kernel (svafKernel) encodes with exactly the + * pipeline it names. + * @param {Function} pipe - the feature-extraction pipeline. + * @param {number} dim - the dimensionality of each half. * @param {string} text * @returns {Promise<{ h1: number[], h2: number[] }>} */ -async function encodeSemantic(text) { - if (!_semanticEncoder) return encodeNgram(text); - const result = await _semanticEncoder(text, { pooling: 'mean', normalize: true }); +async function encodeWith(pipe, dim, text) { + const result = await pipe(text, { pooling: 'mean', normalize: true }); const data = Array.from(result.data); return { - h1: data.slice(0, _semanticDim), - h2: data.slice(_semanticDim), + h1: data.slice(0, dim), + h2: data.slice(dim), }; } @@ -155,8 +158,7 @@ function encode(text) { * @returns {Promise<{ h1: number[], h2: number[] }>} */ async function encodeForSVAF(text) { - if (_semanticReady) return encodeSemantic(text); - return encodeNgram(text); + return svafKernel().encode(text); } /** @@ -164,6 +166,26 @@ async function encodeForSVAF(text) { */ function isSemanticReady() { return _semanticReady; } +/** + * The kernel SVAF encodes in right now, PINNED: its §15.8 identity and an encoder that keeps using + * that kernel for as long as the caller holds it. + * + * The semantic encoder finishes loading while a node runs, so an evaluation that reads the kernel + * identity and encodes in separate steps can name one kernel and encode in the other. One value + * carrying both makes that unrepresentable: whatever `encode` returns was produced by the kernel + * `id` names, even if the switch lands while the caller is still encoding. + * + * @returns {{ id: string, encode: (text: string) => Promise<{ h1: number[], h2: number[] }> }} + */ +function svafKernel() { + if (_semanticReady) { + const pipe = _semanticEncoder; + const dim = _semanticDim; + return { id: `minilm-l6-v2-h${dim}`, encode: (text) => encodeWith(pipe, dim, text) }; + } + return { id: `ngram-h${DIM}`, encode: async (text) => encodeNgram(text) }; +} + /** * MMP §15.8 kernel identity: a short stable token naming the encoder and the * comparison dimensionality this node's φ-space judgements are made in. @@ -172,10 +194,10 @@ function isSemanticReady() { return _semanticReady; } * @returns {string} */ function kernelId() { - return _semanticReady ? `minilm-l6-v2-h${_semanticDim}` : `ngram-h${DIM}`; + return svafKernel().id; } // Auto-load semantic encoder at import time loadSemanticEncoder(); -module.exports = { encode, encodeForSVAF, isSemanticReady, kernelId, DIM }; +module.exports = { encode, encodeForSVAF, isSemanticReady, kernelId, svafKernel, DIM }; diff --git a/lib/core/svaf-baseline.js b/lib/core/svaf-baseline.js index a7dc9cdb..49959706 100644 --- a/lib/core/svaf-baseline.js +++ b/lib/core/svaf-baseline.js @@ -21,8 +21,8 @@ * @license Apache-2.0 */ -const { createCMB, renderContent: renderCMB, cosineSimilarity, l2Normalize, CAT7_CATEGORIES, categoryKeyV1, encodeCategory, blockKeyV2 } = require('./cmb-encoder'); -const { encodeForSVAF, isSemanticReady, kernelId } = require('./context-encoder'); +const { createCMB, renderContent: renderCMB, cosineSimilarity, l2Normalize, CAT7_CATEGORIES, categoryKeyV1, blockKeyV2 } = require('./cmb-encoder'); +const { svafKernel } = require('./context-encoder'); const { evaluateLineageTetherFromText } = require('./lineage-tether'); /** Per-category admission verdicts — the first-class gating output (MMP §9.2). */ @@ -104,16 +104,20 @@ function computeCategoryVerdicts(categoryDrifts, config, nearestDrifts) { * twice, not a coincidence. * * The kernel is part of the key because a semantic and an n-gram encoding of the same text are - * different vectors and must never be served for one another. + * different vectors and must never be served for one another. The kernel named in the key is the + * one that PRODUCED the vector: `kernel` is a pinned svafKernel(), whose id and encoder are one + * value. The key used to be read from the kernel in force at lookup time while the encoder had been + * chosen at the start of the gate, so a gate still encoding when the semantic encoder finished + * loading filed lexical vectors under the semantic kernel's name, and later gates were served them. */ const _embedCache = new Map(); const _EMBED_CACHE_MAX = 4096; -async function _localVector(categoryName, text, useSemantic) { - const k = `${kernelId()}|${categoryKeyV1(categoryName, text)}`; +async function _localVector(categoryName, text, kernel) { + const k = `${kernel.id}|${categoryKeyV1(categoryName, text)}`; const hit = _embedCache.get(k); if (hit) return hit; - const v = useSemantic ? (await encodeForSVAF(text)).h1 : encodeCategory(text); + const v = (await kernel.encode(text)).h1; if (_embedCache.size >= _EMBED_CACHE_MAX) _embedCache.delete(_embedCache.keys().next().value); _embedCache.set(k, v); return v; @@ -131,13 +135,13 @@ async function _localVector(categoryName, text, useSemantic) { * Only text is read, so a vector that arrived on the wire never reaches the measurement: a * category with no text has no entry here at all, whatever the sender attached to it. */ -async function localisedCategories(categories, useSemantic) { +async function localisedCategories(categories, kernel) { const out = {}; if (!categories) return out; for (const category of CAT7_CATEGORIES) { const f = categories[category]; if (!f || typeof f !== 'object' || !f.text) continue; - out[category] = { text: f.text, vector: await _localVector(category, f.text, useSemantic) }; + out[category] = { text: f.text, vector: await _localVector(category, f.text, kernel) }; } return out; } @@ -488,8 +492,11 @@ async function _processHeuristicSVAF(opts) { // kernelId gating was the tempting cheap fix and it is not a fix: kernelId is not in the // categoryKey either, so it is equally unsigned and equally alterable. Gating trust on an // attacker-supplied label tells the attacker which value to write. - const useSemantic = isSemanticReady(); - const incomingCategories = await localisedCategories(incomingCMB.categories, useSemantic); + // + // ONE kernel for the whole gate (§9.2.1: drift is meaningful only within a pinned encoder), held + // even if the semantic encoder finishes loading while this gate is still encoding. + const kernel = svafKernel(); + const incomingCategories = await localisedCategories(incomingCMB.categories, kernel); // Each anchor is measured through a view holding only what computeCategoryDrifts reads, so the // stored record it came from is never touched. const anchorViews = []; @@ -497,7 +504,7 @@ async function _processHeuristicSVAF(opts) { anchorViews.push({ storedAt: anchor.storedAt, confidence: anchor.confidence, - categories: await localisedCategories(anchor.categories, useSemantic), + categories: await localisedCategories(anchor.categories, kernel), }); } diff --git a/tests/svaf-kernel-cache.test.js b/tests/svaf-kernel-cache.test.js new file mode 100644 index 00000000..38533122 --- /dev/null +++ b/tests/svaf-kernel-cache.test.js @@ -0,0 +1,95 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME to a temp sandbox before lib/config loads + +/** + * The gate's embedding cache must file every vector under the kernel that produced it. + * + * The semantic encoder loads asynchronously and replaces the lexical one while a node runs. The + * gate picked its encoder once, at its start, but named each cache entry by the kernel in force at + * the moment of the lookup. A gate that started lexical and was still encoding when the semantic + * encoder finished loading therefore cached lexical vectors under the semantic kernel's name, and + * every later gate was served them as semantic ones: two unrelated spaces compared as one, for as + * long as the entries stayed in the cache. + * + * The switch is injected by standing a fake context-encoder in for the real one, the same seam + * lineage-tether-reproducible uses, so the moment it lands is exact rather than a race. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const { CAT7_CATEGORIES, createCMB } = require('../lib/core'); + +const POLICY = { stableThreshold: 0.35, guardedThreshold: 0.75, temporalLambda: 0.3, freshnessSeconds: 3600 }; + +/** Distinct text in every category, so each category's encode can be told apart. */ +function cat7(prefix) { + const c = {}; + for (const f of CAT7_CATEGORIES) c[f] = `${prefix} ${f}`; + c.mood = { text: `${prefix} mood`, valence: 0, arousal: 0 }; + return c; +} + +/** + * A context-encoder whose semantic kernel becomes ready when the test says so. Its semantic encoder + * maps every text to the same unit vector, so in that kernel any two records are paraphrases, and + * it records each text it is asked to encode. + */ +function fakeEncoder(real) { + const state = { semantic: false, semanticTexts: [] }; + const LEX = 'ngram-h32'; + const SEM = 'fake-semantic-h32'; + const unit = Array.from({ length: real.DIM }, (_, i) => (i === 0 ? 1 : 0)); + const semantic = async (text) => { state.semanticTexts.push(text); return { h1: unit.slice(), h2: [] }; }; + const lexical = async (text) => real.encode(text); + const exports = { + ...real, + isSemanticReady: () => state.semantic, + kernelId: () => (state.semantic ? SEM : LEX), + encodeForSVAF: (text) => (state.semantic ? semantic(text) : lexical(text)), + svafKernel: () => (state.semantic ? { id: SEM, encode: semantic } : { id: LEX, encode: lexical }), + }; + return { state, exports }; +} + +describe('SVAF embedding cache: keyed by the kernel that produced the vector', () => { + it('a gate that straddles the encoder switch does not poison the semantic kernel', async () => { + const ctxPath = require.resolve('../lib/core/context-encoder'); + const sbPath = require.resolve('../lib/core/svaf-baseline'); + const realCtx = require.cache[ctxPath]; + const realSb = require.cache[sbPath]; + const fake = fakeEncoder(realCtx.exports); + try { + require.cache[ctxPath] = { id: ctxPath, filename: ctxPath, loaded: true, exports: fake.exports }; + delete require.cache[sbPath]; + const { processHeuristicSVAF } = require(sbPath); // a fresh gate, with a fresh cache, on the fake encoder + + const now = Date.now(); + const gate = (cmb, recentCMBs) => processHeuristicSVAF({ + msg: { type: 'cmb', cmb, content: 'incoming' }, + peerName: 'peer', localName: 'receiver', originTs: now, now, ageSeconds: 0, recentCMBs, config: POLICY, + }); + + // Gate 1 starts while only the lexical kernel exists. Its synchronous prefix runs inside the + // call, so flipping the flag right after it returns lands the switch mid-gate. + const pending = gate(createCMB({ categories: cat7('incoming'), createdBy: 'peer' }), []); + fake.state.semantic = true; + await pending; + + // Gate 2 runs wholly in the semantic kernel, on the same incoming text, against an anchor whose + // text has never been encoded. + fake.state.semanticTexts.length = 0; + const anchor = createCMB({ categories: cat7('anchor'), createdBy: 'receiver' }); + const r = await gate(createCMB({ categories: cat7('incoming'), createdBy: 'peer' }), [anchor]); + + const encoded = new Set(fake.state.semanticTexts); + const missed = CAT7_CATEGORIES.filter((f) => !encoded.has(f === 'mood' ? 'incoming mood' : `incoming ${f}`)); + assert.deepStrictEqual(missed, [], + 'every incoming category was encoded by the semantic kernel, never served a lexical vector cached under its name'); + assert.strictEqual(r.decision, 'redundant', 'in the semantic kernel the two records are indistinguishable'); + } finally { + require.cache[ctxPath] = realCtx; + require.cache[sbPath] = realSb; + } + }); +}); From 300ae7379c2b9377e9cdcd1660411c96700adbc9 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 19:07:22 +0100 Subject: [PATCH 028/148] store: keep the record as signed; the expanded lineage lives on the entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/memory-store.js | 35 +++++++-- tests/memory-store-record-as-signed.test.js | 85 +++++++++++++++++++++ 2 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 tests/memory-store-record-as-signed.test.js diff --git a/lib/memory-store.js b/lib/memory-store.js index 50dd11a3..ef349027 100644 --- a/lib/memory-store.js +++ b/lib/memory-store.js @@ -33,6 +33,28 @@ const { roleRank } = require('./core'); /** A stored record file: every .json in the store except the one-off migration marker. */ const isStoreFile = (f) => f.endsWith('.json') && f !== 'migration-v1.json'; +/** + * A stored entry as read from disk, its record restored to exactly what was signed. + * + * Stores before 0.13.15 wrote the store's expanded lineage onto every record as a top-level + * `lineage` member. A two-section record has no such member (§8.8.1: `categories` and `metadata`, + * lineage in metadata.lineage), and readers that take a top-level lineage as the record's own (the + * pre-boundary shape, e.g. classifyAddress) derived the wrong address from it and reported the + * record as a mismatch. The entry carries the same lineage on itself, which is where the store's + * closure belongs, so dropping the copy loses nothing. A pre-boundary record (no metadata) keeps + * its top-level lineage: on that shape it is the record's own section. + */ +function asStored(entry) { + const cmb = entry && entry.cmb; + if (cmb && typeof cmb === 'object' && cmb.metadata && typeof cmb.metadata === 'object' + && Object.prototype.hasOwnProperty.call(cmb, 'lineage')) { + const record = { ...cmb }; + delete record.lineage; + entry.cmb = record; + } + return entry; +} + class MemoryIndex { /** Create an empty index. */ constructor() { @@ -184,7 +206,7 @@ class MemoryStore { const slice = files.slice(i, i + batch); const parsed = await Promise.all(slice.map(async (file) => { const filePath = path.join(this._dir, file); - try { return { filePath, entry: JSON.parse(await fs.promises.readFile(filePath, 'utf8')) }; } + try { return { filePath, entry: asStored(JSON.parse(await fs.promises.readFile(filePath, 'utf8'))) }; } catch (e) { console.error(`[SYM] cmbs: failed to index ${file}: ${e.message}`); return null; } })); for (const p of parsed) { @@ -242,7 +264,9 @@ class MemoryStore { peerId: null, tier: 'hot', lifecycle: 'observed', - cmb: cmb ? { ...cmb, lineage } : null, + // The record as signed. The store's expanded lineage goes on the entry, never into the record: + // a two-section record has exactly two members (§8.8.1). + cmb: cmb ? { ...cmb } : null, lineage, }; @@ -299,7 +323,8 @@ class MemoryStore { lifecycle: 'observed', anchorWeight: isValidatorOrigin ? 2.0 : 1.0, storedAt: now, - cmb: cmb ? { ...cmb, lineage } : null, + // The record as signed; the store's expanded lineage lives on the entry (see write()). + cmb: cmb ? { ...cmb } : null, lineage, }; @@ -842,7 +867,7 @@ class MemoryStore { _load(filePath) { try { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); + return asStored(JSON.parse(fs.readFileSync(filePath, 'utf8'))); } catch { return null; } @@ -855,7 +880,7 @@ class MemoryStore { for (const file of files) { const filePath = path.join(this._dir, file); try { - const entry = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const entry = asStored(JSON.parse(fs.readFileSync(filePath, 'utf8'))); this._cache.set(entry.key, entry); this._index.add(this._indexEntryFor(entry, filePath)); } catch (e) { diff --git a/tests/memory-store-record-as-signed.test.js b/tests/memory-store-record-as-signed.test.js new file mode 100644 index 00000000..5a8cdfa8 --- /dev/null +++ b/tests/memory-store-record-as-signed.test.js @@ -0,0 +1,85 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME to a temp sandbox before lib/config loads + +/** + * The store keeps a record exactly as it was signed. What the store needs for itself lives on the + * entry that wraps the record, never inside it. + * + * Both write paths stored `{ ...cmb, lineage }`: the store's expanded lineage (direct parents plus a + * locally computed ancestor closure) copied onto the record as a top-level member. A two-section + * record has exactly two members, `categories` and `metadata`, and its lineage lives in + * metadata.lineage (§8.8.1). The third member read as the pre-boundary shape, so classifyAddress + * took its parents as the record's, derived a remix address, and reported every stored v2 remix as + * a MISMATCH, which reads as tampering, though the record was untouched. + * + * Stores written before the fix hold that member on disk, so reading must drop it as well. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { tmpdir } = require('./_tmpdir'); +const { MemoryStore } = require('../lib/memory-store'); +const { createCMB, classifyAddress } = require('../lib/core'); + +function cat7(t) { + return { + focus: t, issue: t, intent: t, motivation: t, commitment: t, + perspective: 'observer', mood: { text: 'neutral', valence: 0, arousal: 0 }, + }; +} + +function remixOf(parentKey, text) { + return createCMB({ categories: cat7(text), createdBy: 'peer', lineage: { parents: [parentKey], method: 'svaf-heuristic' } }); +} + +describe('MemoryStore keeps the record as signed', () => { + it('a stored v2 remix is the two-section record and classifies as verified', () => { + const store = new MemoryStore(tmpdir('sym-store-as-signed-'), 'receiver'); + const root = store.write('root', { cmb: createCMB({ categories: cat7('the root block'), createdBy: 'receiver' }) }); + const remix = remixOf(root.key, 'a remix of the root block'); + assert.strictEqual(classifyAddress(remix).state, 'verified', 'precondition: the record verifies as signed'); + + const received = store.receiveFromPeer('peer', { content: 'remix', cmb: remix }); + const written = store.write('local remix', { cmb: remixOf(root.key, 'a local remix of the root block') }); + for (const [label, entry] of [['receiveFromPeer', received], ['write', written]]) { + const cmb = store.get(entry.key).cmb; + assert.deepStrictEqual(classifyAddress(cmb), { state: 'verified', scheme: 'block-v2', reason: null }, `${label}: the stored remix verifies`); + assert.deepStrictEqual(Object.keys(cmb).sort(), ['categories', 'metadata'], `${label}: exactly the two sections`); + assert.deepStrictEqual(cmb.metadata.lineage.parents, [root.key], `${label}: its own lineage, as signed`); + // The store's closure is still kept, on the entry. + assert.deepStrictEqual(entry.lineage.ancestors, [root.key], `${label}: the entry carries the store's closure`); + assert.deepStrictEqual(store.ancestors(entry.key), [root.key], `${label}: and the index walks from it`); + } + }); + + it('a store file written before the fix reads back as the record it was signed as', async () => { + const dir = tmpdir('sym-store-legacy-staple-'); + const root = createCMB({ categories: cat7('the root block'), createdBy: 'peer' }); + const remix = remixOf(root.metadata.key, 'a remix of the root block'); + const lineage = { parents: [root.metadata.key], ancestors: [root.metadata.key], method: 'svaf-heuristic' }; + // The exact shape receiveFromPeer used to persist. + const stale = { key: remix.metadata.key, content: 'remix', peerId: 'peer', tier: 'hot', lifecycle: 'observed', storedAt: Date.now(), cmb: { ...remix, lineage }, lineage }; + fs.writeFileSync(path.join(dir, `${stale.key}.json`), JSON.stringify(stale, null, 2)); + assert.strictEqual(classifyAddress(stale.cmb).state, 'mismatch', 'precondition: the stapled member is what misclassified it'); + + // Both ways a store builds its index: on first touch (synchronous) and through load(). + const syncStore = new MemoryStore(dir, 'receiver'); + const asyncStore = new MemoryStore(dir, 'receiver'); + await asyncStore.load(); + for (const [label, store] of [['first touch', syncStore], ['load()', asyncStore]]) { + const entry = store.get(stale.key); + assert.strictEqual(classifyAddress(entry.cmb).state, 'verified', `${label}: the stored remix verifies`); + assert.deepStrictEqual(Object.keys(entry.cmb).sort(), ['categories', 'metadata'], `${label}: the stapled member is gone`); + assert.deepStrictEqual(store.ancestors(stale.key), [root.metadata.key], `${label}: the closure still comes from the entry`); + } + + // Compaction re-reads the file and writes it back; what it writes is the record as signed. + syncStore.compact(-1); + const rewritten = JSON.parse(fs.readFileSync(path.join(dir, `${stale.key}.json`), 'utf8')); + assert.strictEqual(rewritten.tier, 'cold', 'precondition: compaction rewrote the file'); + assert.deepStrictEqual(Object.keys(rewritten.cmb).sort(), ['categories', 'metadata'], 'the rewritten file holds the record as signed'); + }); +}); From c4d123ec8b51cb73cf4aac735f924533a13d3366 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 19:09:01 +0100 Subject: [PATCH 029/148] tether audit: read the store's lineage closure, not the record's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit picked fetch candidates from, and unhooked severed remixes by, recordLineage(cmb).ancestors. That held the store's closure only while the store stapled a copy onto the record; on a two-section record it reads metadata.lineage, which carries direct parents only, or, from a non-conformant sender, an `ancestors` list the sender chose (§7.5: never carried, never trusted). So a severed remix stayed in the descendant set of every ancestor beyond its parents, keeping them hot and unpurgeable, and a sender's list chose what the audit fetched as the anchor. With the store no longer stapling, pre-boundary records would have gone the same way. Fetch candidates now come from the store's index (store.ancestors), and severance goes through a new MemoryStore.unlinkLineage(key), which leaves the index exactly as a rebuild from the severed entry would. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/memory-store.js | 16 +++++++++ lib/node.js | 11 ++++--- tests/lineage-tether-audit.test.js | 52 ++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/lib/memory-store.js b/lib/memory-store.js index ef349027..d6f210dd 100644 --- a/lib/memory-store.js +++ b/lib/memory-store.js @@ -494,6 +494,22 @@ class MemoryStore { return this._index.get(key)?.parents || []; } + /** + * Make a stored entry a root in the index after its lineage was severed (§15.8): drop it from the + * descendant set of every ancestor it was indexed under, and clear its own parents and ancestors. + * This is exactly what rebuilding the index from the severed entry produces, applied now rather + * than at the next start; until then the severed remix kept its old ancestors hot and unpurgeable. + * The caller clears the record's lineage and the entry's, and persists. + * @param {string} key + */ + unlinkLineage(key) { + const idx = this._index.get(key); + if (!idx) return; + for (const a of idx.ancestors || []) this._index.byAncestor.get(a)?.delete(key); + idx.parents = []; + idx.ancestors = []; + } + // ── Search (user-facing) ───────────────────────────────── /** diff --git a/lib/node.js b/lib/node.js index 7b2f5c7d..5fee424c 100644 --- a/lib/node.js +++ b/lib/node.js @@ -1,6 +1,6 @@ 'use strict'; -const { recordKey, recordCreatedBy, recordCreatedAt, recordLineage, recordParents, setRecordLineage } = require('./record'); +const { recordKey, recordCreatedBy, recordCreatedAt, recordParents, setRecordLineage } = require('./record'); /** * SymNode — sovereign mesh node with cognitive coupling. @@ -717,8 +717,10 @@ class SymNode extends EventEmitter { const anchor = resolveTetherAnchor(cmb, (k) => (k === entry.key ? undefined : this._store.get(k))); if (anchor.resolvedFromStore) { anchorCategories = anchor.categories; anchorKey = anchor.key; } if (!anchorCategories && opts.fetch === true) { - const anc = recordLineage(cmb)?.ancestors; - for (const k of (anc?.length ? anc : parents)) { + // The store's own closure, from its index. The record carries direct parents only (§7.5); + // an `ancestors` list on it is the sender's claim and must not choose what is fetched. + const anc = this._store.ancestors(entry.key); + for (const k of (anc.length ? anc : parents)) { // A key this store holds was already judged by the verified walk above, and fetchCMB // answers from the store first — it would hand back the record that walk refused. if (this._store.get(k)) continue; @@ -755,10 +757,9 @@ class SymNode extends EventEmitter { } else { report.failedFloor++; if (applySever) { - const oldAncestors = recordLineage(cmb)?.ancestors || parents; setRecordLineage(cmb, null); // clears the section the record ACTUALLY carries entry.lineage = null; - for (const a of oldAncestors) this._store._index.byAncestor?.get(a)?.delete(entry.key); + this._store.unlinkLineage(entry.key); report.severed++; this._log(`[§15.8 audit] lineage severed: ${String(entry.key).slice(0, 16)}… drift ${ev.drift.toFixed(3)} from anchor ${String(anchorKey).slice(0, 16)}…`); this.emit('metric', { type: 'lineage-tether-severed', key: entry.key, anchor: anchorKey, drift: ev.drift, audit: true }); diff --git a/tests/lineage-tether-audit.test.js b/tests/lineage-tether-audit.test.js index 03f58301..e6816d58 100644 --- a/tests/lineage-tether-audit.test.js +++ b/tests/lineage-tether-audit.test.js @@ -145,4 +145,56 @@ describe('MMP §15.8 retroactive tether audit', () => { fs.rmSync(nodeDir(name), { recursive: true, force: true }); } }); + + // The audit's knowledge of a remix's ancestry is the store's own closure, held on the entry and in + // its index. It used to read the closure off the RECORD, which held it only because the store + // stapled a copy there. On a two-section record the read found metadata.lineage instead, which + // carries direct parents only, or, from a non-conformant sender, an `ancestors` list the sender + // chose (§7.5: never carried, never trusted). + it('sever: the remix is unhooked from every ancestor the store indexed it under', async () => { + const name = `audit-unhook-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node = new SymNode({ name, silent: true, discovery: new NullDiscovery() }); + await node.start(); + try { + await awaitSemantic(); + const root = node.remember(cat7(TOPIC_A)); + // A faithful hop, admitted from a verified peer, then a laundered hop citing it. + const hop = createCMB({ categories: cat7(`${TOPIC_A} for the second quarter`), createdBy: 'peer', lineage: { parents: [root.key], method: 'SVAF-v2' } }); + node._store.receiveFromPeer('peer', { key: hop.metadata.key, content: 'hop', source: 'peer', cmb: hop, _cmbVerified: true }); + const laundered = createCMB({ categories: cat7(TOPIC_B2), createdBy: 'peer', lineage: { parents: [hop.metadata.key], method: 'SVAF-v2' } }); + node._store.receiveFromPeer('peer', { key: laundered.metadata.key, content: 'laundered', source: 'peer', cmb: laundered, _cmbVerified: true }); + assert.ok(node._store.descendants(root.key).includes(laundered.metadata.key), 'precondition: indexed under the root through the hop'); + + const r = await node.auditLineageTethers({ sever: true }); + assert.strictEqual(r.severed, 1, 'the laundered hop is severed'); + for (const [label, k] of [['the hop', hop.metadata.key], ['the root', root.key]]) { + assert.ok(!node._store.descendants(k).includes(laundered.metadata.key), `no longer a descendant of ${label}`); + } + assert.deepStrictEqual(node._store.ancestors(laundered.metadata.key), [], 'a severed remix is a root in the index'); + assert.deepStrictEqual(node._store.parents(laundered.metadata.key), []); + } finally { + await node.stop(); + fs.rmSync(nodeDir(name), { recursive: true, force: true }); + } + }); + + it('fetch: the candidates are the store\'s closure, never an ancestors list the record carries', async () => { + const name = `audit-anc-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node = new SymNode({ name, silent: true, discovery: new NullDiscovery() }); + await node.start(); + try { + const parent = 'cmb-' + 'a'.repeat(64); + const chosen = 'cmb-' + 'b'.repeat(64); + const cmb = createCMB({ categories: cat7(TOPIC_B), createdBy: 'peer' }); + cmb.metadata.lineage = { parents: [parent], ancestors: [chosen], method: 'SVAF-v2' }; + node._store.receiveFromPeer('peer', { key: cmb.metadata.key, content: TOPIC_B, source: 'peer', cmb }); + const asked = []; + node.fetchCMB = async (k) => { asked.push(k); return null; }; + await node.auditLineageTethers({ fetch: true, timeoutMs: 50 }); + assert.deepStrictEqual(asked, [parent], 'only the parent the store derived its closure from is fetched'); + } finally { + await node.stop(); + fs.rmSync(nodeDir(name), { recursive: true, force: true }); + } + }); }); From c4ee1e9c766a2891db6270c2d5263aa1db2a2380 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 19:20:56 +0100 Subject: [PATCH 030/148] =?UTF-8?q?frame-handler:=20the=20neural=20admissi?= =?UTF-8?q?on=20path=20applies=20the=20=C2=A715.8=20tether?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frame handler offers each inbound block to the injected Layer-4 evaluator first and runs the heuristic baseline only when it declines. The heuristic path checked the inbound tether attestation, resolved the lineage root, evaluated the stored record against it, severed a drifted chain and signed a tether attestation. The neural path did none of it: a remix the neural evaluator admitted kept a laundered lineage, carried no attestation, and kept whatever attestation its sender attached. Dormant in a stock install (models/svaf_v2.pt is not shipped), but any consumer injecting an evaluator got a node that never applied §15.8. Both paths now share the same steps: _prepareLineageTether (inbound attestation check and anchor resolution), tetherOfRecord (moved from the heuristic engine to lineage-tether and exported, so the engine and the neural path run one computation), _applyLineageTether (severance, provenance and the signed attestation) and _admissionPolicy (one policy builder). The neural path stores the incoming record object itself, so it drops the sender's attestation from it first, as the heuristic path's rebuilt record never carries one. Also removes an unused read of the retired `ancestors` field from the neural path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/core/lineage-tether.js | 35 ++++- lib/core/svaf-baseline.js | 27 +--- lib/frame-handler.js | 207 +++++++++++++++++----------- tests/lineage-tether-neural.test.js | 159 +++++++++++++++++++++ 4 files changed, 318 insertions(+), 110 deletions(-) create mode 100644 tests/lineage-tether-neural.test.js diff --git a/lib/core/lineage-tether.js b/lib/core/lineage-tether.js index 46fc4e3e..0332c7db 100644 --- a/lib/core/lineage-tether.js +++ b/lib/core/lineage-tether.js @@ -256,4 +256,37 @@ async function evaluateLineageTetherFromText({ remixCategories, anchorCategories return { kernelId: kernelId(), checked: false, tethered: true, drift: null, evaluableCategories: [] }; } -module.exports = { computeLineageTether, resolveTetherAnchor, storedRecordVerifies, evaluateLineageTetherFromText }; +/** + * MMP §15.8 lineage tether of the record an admission stores, against the anchor the caller + * resolved. The caller applies severance and signs the attestation; this only reports. + * + * ONE helper for every admission path. The heuristic engine calls it on the record it builds and + * the frame handler calls it on the record a neural evaluator admits, so the two gates cannot reach + * different tether verdicts on the same record and root; before it was shared the neural path + * evaluated no tether at all. + * + * Measured on the RECORD'S TEXT, never on the fused vectors. A fused vector is this receiver's + * attention readout over its own recent anchors (computeCategoryDrifts), so a tether taken on it + * measured how the remix sat in THIS node's memory — two integrators holding the same root and the + * same record signed different drifts, and nobody holding them could recompute either one. §15.8 + * makes the tether content-only so that any holder of the root re-encodes its text and gets the + * number the integrator signed; the text of both records is all such a holder has. + * + * @param {object} recordCategories - the CAT7 categories of the record being stored. + * @param {object|undefined} tetherAnchor - the resolved anchor ({ key, categories }), if any. + * @param {object} config - the admission policy (categoryWeights, guardedThreshold). + * @returns {Promise} null when no anchor was resolved (the tether is unverified). + */ +async function tetherOfRecord(recordCategories, tetherAnchor, config) { + if (!tetherAnchor || !tetherAnchor.categories) return null; + const ev = await evaluateLineageTetherFromText({ + remixCategories: recordCategories, + anchorCategories: tetherAnchor.categories, + categoryWeights: config.categoryWeights, + guardedThreshold: config.guardedThreshold, + }); + // §15.8 kernel identity rides in `ev.kernelId`: verdicts are comparable iff it matches. + return { anchorKey: tetherAnchor.key ?? null, ...ev }; +} + +module.exports = { computeLineageTether, resolveTetherAnchor, storedRecordVerifies, evaluateLineageTetherFromText, tetherOfRecord }; diff --git a/lib/core/svaf-baseline.js b/lib/core/svaf-baseline.js index 49959706..5c0cd463 100644 --- a/lib/core/svaf-baseline.js +++ b/lib/core/svaf-baseline.js @@ -23,7 +23,7 @@ const { createCMB, renderContent: renderCMB, cosineSimilarity, l2Normalize, CAT7_CATEGORIES, categoryKeyV1, blockKeyV2 } = require('./cmb-encoder'); const { svafKernel } = require('./context-encoder'); -const { evaluateLineageTetherFromText } = require('./lineage-tether'); +const { tetherOfRecord } = require('./lineage-tether'); /** Per-category admission verdicts — the first-class gating output (MMP §9.2). */ const CATEGORY_VERDICT = Object.freeze({ @@ -245,31 +245,6 @@ function strippedOfVectors(categories) { return out; } -/** - * MMP §15.8 lineage tether of the record an admission stores, against the anchor the caller - * resolved. The caller applies severance and signs the attestation; this only reports. - * - * Measured on the RECORD'S TEXT, never on the fused vectors. A fused vector is this receiver's - * attention readout over its own recent anchors (computeCategoryDrifts), so a tether taken on it - * measured how the remix sat in THIS node's memory — two integrators holding the same root and the - * same record signed different drifts, and nobody holding them could recompute either one. §15.8 - * makes the tether content-only so that any holder of the root re-encodes its text and gets the - * number the integrator signed; the text of both records is all such a holder has. - * - * @returns {Promise} null when no anchor was resolved (the tether is unverified). - */ -async function tetherOfRecord(recordCategories, tetherAnchor, config) { - if (!tetherAnchor || !tetherAnchor.categories) return null; - const ev = await evaluateLineageTetherFromText({ - remixCategories: recordCategories, - anchorCategories: tetherAnchor.categories, - categoryWeights: config.categoryWeights, - guardedThreshold: config.guardedThreshold, - }); - // §15.8 kernel identity rides in `ev.kernelId`: verdicts are comparable iff it matches. - return { anchorKey: tetherAnchor.key ?? null, ...ev }; -} - /** * The per-category drift arithmetic (§9.2/§9.2.1), as a PURE function of already-encoded vectors. * diff --git a/lib/frame-handler.js b/lib/frame-handler.js index 300a9ea6..ab38ab8f 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -24,7 +24,7 @@ const { assertRecordSize } = require('./core/cmb-encoder'); const fs = require('fs'); const path = require('path'); -const { encode, DIM, processHeuristicSVAF, computeCategoryVerdicts, decryptCategories, blockKeyV2, resolveTetherAnchor, signTetherAttestation, verifyTetherAttestation, recomputeKey, classifyAddress } = require('./core'); +const { encode, DIM, processHeuristicSVAF, computeCategoryVerdicts, decryptCategories, blockKeyV2, resolveTetherAnchor, tetherOfRecord, signTetherAttestation, verifyTetherAttestation, recomputeKey, classifyAddress } = require('./core'); // Receive-path dedup window (MMP §4.2 O2 — rejoin-without-replay convergence). // A CMB whose content-hash key we have already processed within this window is @@ -1028,8 +1028,10 @@ class FrameHandler { * Process a successful neural SVAF result. * @private */ - _processNeuralSVAF(result, msg, peerName, peerId, originTs, now) { + async _processNeuralSVAF(result, msg, peerName, peerId, originTs, now) { const { decision, total_drift, category_drifts, gate_values } = result; + // §15.8 runs on this path exactly as on the heuristic one: same inbound check, same anchor. + const tetherAnchor = this._prepareLineageTether(msg); // Record EVERY evaluation (admit AND reject). The autonomy IS the decision; // a rejection leaves no other trace, so this is where sovereignty is captured. @@ -1076,7 +1078,6 @@ class FrameHandler { // Propagate lineage: fused CMB is a child of the incoming CMB const incomingKey = recordKey(msg.cmb) || msg.key; - const incomingAncestors = recordLineage(msg.cmb)?.ancestors || []; const fusedEntry = { ...msg, @@ -1133,6 +1134,14 @@ class FrameHandler { } // Opaque payload rides alongside CAT7 — carry it onto the admitted remix. this._preserveIncomingPayload(fusedEntry, msg); + if (fusedEntry.cmb) { + // This path stores the incoming record object itself, so it still carries any attestation its + // sender attached. That one was an input, checked in _prepareLineageTether; the record this + // node stores carries its own evaluation or none, as the heuristic path's rebuilt record does. + delete fusedEntry.cmb.tether; + const tether = await tetherOfRecord(fusedEntry.cmb.categories, tetherAnchor, this._admissionPolicy()); + this._applyLineageTether(fusedEntry, tether, now); + } // The admitted-path surface is the store's own cmb-accepted emit. The store returns null when // it already holds the key ('redundant') or the write failed ('not-stored'), and a directed CMB // then reached nobody: surface it. Everything after this runs exactly as for any admit. @@ -1181,33 +1190,12 @@ class FrameHandler { && typeof this._node._store?.has === 'function' && recordParents(msg.cmb).some(p => this._node._store.has(p)); - // Inbound §15.8 tether attestation: a re-emitted remix may carry its - // integrator's signed tether evaluation. Verify against the integrator's - // roster-resolved key. An INVALID one is stripped — a forged certificate - // is worse than an absent one; a valid-but-unverifiable one (no resolvable - // key) rides through unverified, the same posture as unsigned CMBs. - if (msg.cmb?.tether && typeof verifyTetherAttestation === 'function') { - const att = msg.cmb.tether; - const integKey = this._node._identityKey(att.by); - const v = verifyTetherAttestation(att, integKey); - msg._tetherAttested = v.valid === true; - if (v.signed && !v.valid && integKey) { - this._node._log(`[§15.8] inbound tether attestation INVALID from ${String(att.by).slice(0, 8)} — stripped`); - this._node.emit('metric', { type: 'tether-attestation-rejected', by: att.by, of: att.of }); - delete msg.cmb.tether; - } - } - - // MMP §15.8 lineage tether: resolve the earliest-stored resolvable lineage - // root of the incoming CMB (the incoming block itself when it is a root). - // The gate evaluates the remix against it in-kernel and reports; severance - // is applied below on the result. - const tetherAnchor = this._node._lineageTether && typeof resolveTetherAnchor === 'function' - ? resolveTetherAnchor(msg.cmb, (k) => this._node._store.get(k)) - : null; + // MMP §15.8: the gate evaluates the remix against the resolved anchor in-kernel and reports; + // severance and the attestation are applied below on the result. + const tetherAnchor = this._prepareLineageTether(msg); const result = await this._admit({ - tetherAnchor: tetherAnchor && tetherAnchor.categories ? tetherAnchor : undefined, + tetherAnchor, msg, peerName, localName: this._node.name, @@ -1217,17 +1205,7 @@ class FrameHandler { groundingWaiver, recentCMBs: this._node._store.recentCMBs(5), recentDecisions: this._node._recentSvafDecisions, - config: { - stableThreshold: this._node._svafStableThreshold, - guardedThreshold: this._node._svafGuardedThreshold, - temporalLambda: this._node._svafTemporalLambda, - freshnessSeconds: this._node._svafFreshnessSeconds, - categoryWeights: this._node._svafCategoryWeights, - adaptiveTimescale: this._node._svafAdaptiveTimescale, - minFreshnessSeconds: this._node._svafMinFreshnessSeconds, - reactivity: this._node._svafReactivity, - changeWeights: this._node._svafChangeWeights ?? undefined, - }, + config: this._admissionPolicy(), }); // The observer fires on EVERY outcome, before any branch — an observation recorded only @@ -1289,50 +1267,7 @@ class FrameHandler { // CMB from categories and drops it, so re-attach before storing the remix. this._preserveIncomingPayload(result.fusedEntry, msg); - // MMP §15.8 lineage tether — severance. The gate evaluated the remix - // against the resolved anchor content-only, in one kernel; a remix - // drifted past the reject floor MUST NOT carry the chain's lineage - // (keeping it would forge fidelity), so it is stored as a fresh root - // with the departed source recorded informally in provenance. An - // unverifiable tether (checked=false) is a trust state, never severed. - if (result.tether?.checked && !result.tether.tethered && recordLineage(result.fusedEntry?.cmb)) { - const departedFrom = recordParents(result.fusedEntry.cmb)[0] ?? null; - setRecordLineage(result.fusedEntry.cmb, null); // fresh root — must clear the section the record ACTUALLY carries - result.fusedEntry.cmb.provenance = { - ...result.fusedEntry.cmb.provenance, - tether: { severed: true, anchor: result.tether.anchorKey, kernelId: result.tether.kernelId, drift: result.tether.drift, departedFrom }, - }; - this._node._log(`[§15.8] lineage severed: remix drift ${result.tether.drift.toFixed(3)} from anchor ${String(result.tether.anchorKey).slice(0, 16)} exceeds the reject floor — stored as fresh root (departed-from in provenance)`); - this._node.emit('metric', { type: 'lineage-tether-severed', key: recordKey(result.fusedEntry.cmb), anchor: result.tether.anchorKey, drift: result.tether.drift }); - } else if (result.tether?.checked && result.fusedEntry?.cmb) { - result.fusedEntry.cmb.provenance = { - ...result.fusedEntry.cmb.provenance, - tether: { severed: false, anchor: result.tether.anchorKey, kernelId: result.tether.kernelId, drift: result.tether.drift }, - }; - } - - // MMP §15.8 tether attestation: sign the exact evaluation performed — - // remix key, anchor, kernel identity, drift, verdict — so a downstream - // receiver that cannot resolve the anchor holds attested-by-integrator - // instead of unchecked. Carried on the remix (cmb.tether); trust in the - // verdict is weighed through the integrator's resolved authority, and - // verdicts are comparable only within one kernelId. - if (result.tether?.checked && result.fusedEntry?.cmb - && typeof signTetherAttestation === 'function' && this._node._identity?.privateKey) { - try { - result.fusedEntry.cmb.tether = signTetherAttestation({ - of: recordKey(result.fusedEntry.cmb), - anchor: result.tether.anchorKey, - kernelId: result.tether.kernelId, - drift: result.tether.drift, - verdict: result.tether.tethered ? 'tethered' : 'severed', - by: this._node.nodeId, - at: now, - }, this._node._identity.privateKey); - } catch (e) { - this._node._log(`[§15.8] tether attestation signing failed: ${e.message}`); - } - } + this._applyLineageTether(result.fusedEntry, result.tether, now); // The admitted-path surface is the store's own cmb-accepted emit. The store returns null when // it already holds the key ('redundant') or the write failed ('not-stored'), and a directed CMB @@ -1361,6 +1296,112 @@ class FrameHandler { this._node.emit('memory-received', { from: peerName, entry: result.fusedEntry, decision: result.decision }); } + /** + * The receiver's admission policy, as the gates are handed it. One builder, so the heuristic + * engine and the neural path's tether evaluation judge with the same thresholds and weights. + * @private + */ + _admissionPolicy() { + return { + stableThreshold: this._node._svafStableThreshold, + guardedThreshold: this._node._svafGuardedThreshold, + temporalLambda: this._node._svafTemporalLambda, + freshnessSeconds: this._node._svafFreshnessSeconds, + categoryWeights: this._node._svafCategoryWeights, + adaptiveTimescale: this._node._svafAdaptiveTimescale, + minFreshnessSeconds: this._node._svafMinFreshnessSeconds, + reactivity: this._node._svafReactivity, + changeWeights: this._node._svafChangeWeights ?? undefined, + }; + } + + /** + * MMP §15.8 before a gate decides, on EITHER gate path: check the attestation an inbound remix + * carries, and resolve the anchor its tether is evaluated against. + * + * Shared because the two paths are one admission decision. The neural path skipped all of + * §15.8, so a remix its evaluator admitted kept a drifted lineage and was never attested, while + * the same record through the heuristic path was severed and signed. + * + * @returns {object|undefined} the anchor ({ key, categories, ... }), or undefined when there is + * none to evaluate against (tether disabled, or no verified root in reach). + * @private + */ + _prepareLineageTether(msg) { + // A re-emitted remix may carry its integrator's signed tether evaluation. Verify against the + // integrator's roster-resolved key. An INVALID one is stripped — a forged certificate is worse + // than an absent one; a valid-but-unverifiable one (no resolvable key) rides through + // unverified, the same posture as unsigned CMBs. + if (msg.cmb?.tether && typeof verifyTetherAttestation === 'function') { + const att = msg.cmb.tether; + const integKey = this._node._identityKey(att.by); + const v = verifyTetherAttestation(att, integKey); + msg._tetherAttested = v.valid === true; + if (v.signed && !v.valid && integKey) { + this._node._log(`[§15.8] inbound tether attestation INVALID from ${String(att.by).slice(0, 8)} — stripped`); + this._node.emit('metric', { type: 'tether-attestation-rejected', by: att.by, of: att.of }); + delete msg.cmb.tether; + } + } + // The earliest-stored resolvable lineage root of the incoming CMB (the incoming block itself + // when it is a root). + const anchor = this._node._lineageTether && typeof resolveTetherAnchor === 'function' + ? resolveTetherAnchor(msg.cmb, (k) => this._node._store.get(k)) + : null; + return anchor && anchor.categories ? anchor : undefined; + } + + /** + * MMP §15.8 after a gate admits, on EITHER gate path: act on the tether evaluation of the record + * about to be stored. `tether` is what tetherOfRecord reported for it (null when unverified). + * + * Severance: a remix drifted past the reject floor MUST NOT carry the chain's lineage (keeping it + * would forge fidelity), so it is stored as a fresh root with the departed source recorded + * informally in provenance. An unverifiable tether (checked=false) is a trust state, never + * severed. + * + * Attestation: sign the exact evaluation performed — remix key, anchor, kernel identity, drift, + * verdict — so a downstream receiver that cannot resolve the anchor holds + * attested-by-integrator instead of unchecked. Carried on the remix (cmb.tether); trust in the + * verdict is weighed through the integrator's resolved authority, and verdicts are comparable + * only within one kernelId. + * @private + */ + _applyLineageTether(entry, tether, now) { + const cmb = entry?.cmb; + if (!tether?.checked || !cmb) return; + if (!tether.tethered && recordLineage(cmb)) { + const departedFrom = recordParents(cmb)[0] ?? null; + setRecordLineage(cmb, null); // fresh root — must clear the section the record ACTUALLY carries + cmb.provenance = { + ...cmb.provenance, + tether: { severed: true, anchor: tether.anchorKey, kernelId: tether.kernelId, drift: tether.drift, departedFrom }, + }; + this._node._log(`[§15.8] lineage severed: remix drift ${tether.drift.toFixed(3)} from anchor ${String(tether.anchorKey).slice(0, 16)} exceeds the reject floor — stored as fresh root (departed-from in provenance)`); + this._node.emit('metric', { type: 'lineage-tether-severed', key: recordKey(cmb), anchor: tether.anchorKey, drift: tether.drift }); + } else { + cmb.provenance = { + ...cmb.provenance, + tether: { severed: false, anchor: tether.anchorKey, kernelId: tether.kernelId, drift: tether.drift }, + }; + } + if (typeof signTetherAttestation === 'function' && this._node._identity?.privateKey) { + try { + cmb.tether = signTetherAttestation({ + of: recordKey(cmb), + anchor: tether.anchorKey, + kernelId: tether.kernelId, + drift: tether.drift, + verdict: tether.tethered ? 'tethered' : 'severed', + by: this._node.nodeId, + at: now, + }, this._node._identity.privateKey); + } catch (e) { + this._node._log(`[§15.8] tether attestation signing failed: ${e.message}`); + } + } + } + /** * Re-encode local state after a CMB is admitted — on EITHER gate path. * diff --git a/tests/lineage-tether-neural.test.js b/tests/lineage-tether-neural.test.js new file mode 100644 index 00000000..c20a7e74 --- /dev/null +++ b/tests/lineage-tether-neural.test.js @@ -0,0 +1,159 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME to a temp sandbox before lib/config loads + +/** + * MMP §15.8 applies to every remix a node integrates, whichever gate admitted it. + * + * The frame handler offers each inbound block to the injected Layer-4 evaluator first and runs the + * heuristic baseline only when that evaluator declines. The heuristic path resolved the lineage + * root, evaluated the stored record against it, severed a drifted chain and signed a tether + * attestation. The neural path did none of it: a remix the neural evaluator admitted kept a + * laundered lineage, carried no attestation, and kept whatever attestation the sender had attached. + * + * The neural evaluator is dormant in a stock install (models/svaf_v2.pt is not shipped), so it is + * stubbed here with the result shape it returns. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const { SymNode } = require('../lib/node'); +const { NullDiscovery } = require('../lib/discovery'); +const { nodeDir } = require('../lib/config'); +const { createCMB, isSemanticReady, verifyTetherAttestation } = require('../lib/core'); + +const ALIGNED = { decision: 'aligned', total_drift: 0.1, category_drifts: { focus: 0.1 }, gate_values: { focus: 1 } }; + +// The reject floor is calibrated on the semantic kernel (§9.2.1), as in the other tether tests. +async function awaitSemantic(timeoutMs = 30000) { + const t0 = Date.now(); + while (!isSemanticReady()) { + if (Date.now() - t0 > timeoutMs) throw new Error('semantic encoder did not become ready'); + await new Promise((r) => setTimeout(r, 200)); + } +} + +function cat7(t) { + return { + focus: t, issue: t, intent: t, motivation: t, commitment: t, + perspective: 'peerA', mood: { text: 'neutral', valence: 0, arousal: 0 }, + }; +} + +const TOPIC_A = 'quarterly financial audit of the accounting ledger and tax filings'; +const TOPIC_B = [ + 'hiking trail conditions in the mountain snow this weekend', + 'which boots and crampons to pack for the icy mountain ascent', + 'trailhead parking permits and the shuttle bus timetable', + 'weather forecast apps for alpine ridge crossings', + 'campsite reservations near the mountain trailhead', +]; +const TOPIC_B_ROOT = 'overall report on snowy mountain hiking trail conditions this weekend'; +const TOPIC_B_NEW = 'fresh snowfall reported on the upper mountain trail sections'; + +async function withNode(baseName, opts, fn) { + const name = `${baseName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node = new SymNode({ name, silent: true, discovery: new NullDiscovery(), ...opts }); + await node.start(); + try { + return await fn(node); + } finally { + await node.stop(); + fs.rmSync(nodeDir(name), { recursive: true, force: true }); + } +} + +function remixFrame(text, parentKey, extra = {}) { + const cmb = createCMB({ categories: cat7(text), createdBy: 'peerA' }); + cmb.metadata.lineage = { parents: [parentKey], method: 'SVAF-v2' }; + Object.assign(cmb, extra); + return { type: 'cmb', timestamp: Date.now(), content: text, cmb }; +} + +/** Seed a node with a root and the recent topic-B window both gates admit against. */ +function seed(node, rootText) { + const root = node.remember(cat7(rootText)); + for (const t of TOPIC_B) node.remember(cat7(t)); + return root; +} + +/** Admit one remix of `rootText` through the named path; return the stored entry. */ +async function admitThrough(path, rootText, frameOf) { + return withNode(`tether-${path}`, path === 'neural' ? { svafEvaluator: { evaluate: async () => ALIGNED } } : {}, async (node) => { + const root = seed(node, rootText); + const frame = frameOf(root.key); + const now = Date.now(); + if (path === 'neural') await node._frameHandler._processNeuralSVAF(ALIGNED, frame, 'peerA', 'peerA', now, now); + else await node._frameHandler._processHeuristicSVAF(frame, 'peerA', 'peerA', now, now, 0); + const stored = node._store.get(frame.cmb.metadata.key); + assert.ok(stored, `${path}: the remix is stored`); + const att = stored.cmb.tether; + return { + root, stored, att, + attValid: att ? verifyTetherAttestation(att, node._identity.publicKey).valid : null, + nodeId: node.nodeId, + }; + }); +} + +describe('MMP §15.8 lineage tether on the neural admission path', () => { + it('the stock dispatch, with a neural evaluator that admits, severs a laundered chain and attests it', async () => { + await awaitSemantic(); + await withNode('tether-neural-dispatch', { svafEvaluator: { evaluate: async () => ALIGNED } }, async (node) => { + // The root is held as a verified peer admission: a remix citing a block this node authored + // would be dropped as an echo before any gate runs. + const root = createCMB({ categories: cat7(TOPIC_A), createdBy: 'peerR' }); + node._store.receiveFromPeer('peerR', { key: root.metadata.key, content: TOPIC_A, source: 'peerR', cmb: root, _cmbVerified: true }); + const frame = remixFrame(TOPIC_B_NEW, root.metadata.key); + await node._frameHandler._handleMemoryShare('peerA', 'peerA', frame); + + const stored = node._store.get(frame.cmb.metadata.key); + assert.ok(stored, 'the neural evaluator admitted the remix'); + assert.strictEqual(stored.svaf?.method, 'neural', 'precondition: the neural path stored it'); + assert.strictEqual(stored.cmb.metadata.lineage, null, 'the laundered lineage is severed'); + assert.strictEqual(stored.cmb.provenance?.tether?.severed, true); + assert.strictEqual(stored.cmb.provenance.tether.departedFrom, root.metadata.key); + const att = stored.cmb.tether; + assert.ok(att, 'a tether attestation is attached'); + assert.strictEqual(att.verdict, 'severed'); + assert.strictEqual(att.anchor, root.metadata.key); + assert.strictEqual(att.by, node.nodeId); + assert.strictEqual(verifyTetherAttestation(att, node._identity.publicKey).valid, true); + }); + }); + + for (const [label, rootText] of [['a laundered chain', TOPIC_A], ['a faithful chain', TOPIC_B_ROOT]]) { + it(`${label}: the neural path reaches the verdict the heuristic path does`, async () => { + await awaitSemantic(); + const frameOf = (rootKey) => remixFrame(TOPIC_B_NEW, rootKey); + const neural = await admitThrough('neural', rootText, frameOf); + const heuristic = await admitThrough('heuristic', rootText, frameOf); + for (const r of [neural, heuristic]) { + assert.ok(r.att, 'attested'); + assert.strictEqual(r.attValid, true); + assert.strictEqual(r.att.by, r.nodeId); + assert.strictEqual(r.att.anchor, r.root.key); + } + assert.strictEqual(neural.att.verdict, heuristic.att.verdict, 'same verdict'); + assert.strictEqual(neural.att.kernelId, heuristic.att.kernelId, 'one kernel, so the drifts are comparable'); + assert.strictEqual(neural.att.drift.toFixed(6), heuristic.att.drift.toFixed(6), 'same drift: one computation'); + const withoutDrift = ({ drift, ...rest }) => rest; // drift is compared above, to the precision it is signed at + assert.deepStrictEqual(withoutDrift(neural.stored.cmb.provenance.tether), withoutDrift(heuristic.stored.cmb.provenance.tether), 'same provenance'); + assert.deepStrictEqual(neural.stored.cmb.metadata.lineage, heuristic.stored.cmb.metadata.lineage, 'same lineage outcome'); + assert.strictEqual(neural.att.verdict, rootText === TOPIC_A ? 'severed' : 'tethered'); + }); + } + + it('an unresolvable root: neither path attests or severs, and neither stores the sender\'s attestation', async () => { + await awaitSemantic(); + const sendersOwn = { of: 'cmb-' + 'c'.repeat(64), anchor: 'cmb-' + 'd'.repeat(64), verdict: 'tethered', by: 'some-other-integrator', drift: 0 }; + const frameOf = () => remixFrame(TOPIC_B_NEW, 'cmb-' + 'e'.repeat(64), { tether: { ...sendersOwn } }); + for (const path of ['neural', 'heuristic']) { + const r = await admitThrough(path, TOPIC_B_ROOT, frameOf); + assert.strictEqual(r.att, undefined, `${path}: no tether attestation is stored`); + assert.strictEqual(r.stored.cmb.provenance?.tether, undefined, `${path}: the tether is unverified, not evaluated`); + assert.deepStrictEqual(r.stored.cmb.metadata.lineage.parents, ['cmb-' + 'e'.repeat(64)], `${path}: lineage is kept`); + } + }); +}); From 59bcfd8fad8a1085ca9f3e6c1239d0d4a664d4f6 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 19:22:17 +0100 Subject: [PATCH 031/148] tests: wait for in-flight frames instead of a fixed 150 ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four files handed a frame to the frame handler, slept 150 ms and asserted on what had surfaced. That guessed how long asynchronous admission takes, and the guess got tighter once the neural path evaluated the §15.8 tether (encoding the stored record and its root): under heavy machine load 15 of audit-delivery-authorship's 39 tests failed with nothing yet surfaced, and all of them passed with a longer wait. tests/_settle.js records every processing chain the frame handler starts; settle(ms) waits at least ms, as before, then until every chain has settled. With every tether evaluation slowed by 400 ms the four files pass (64/64); with the fixed sleeps 25 of the 64 fail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- tests/_settle.js | 36 +++++++++++++++++++++++++ tests/audit-delivery-authorship.test.js | 3 ++- tests/cmb-signing.test.js | 3 ++- tests/inbound-cmb-surfacing.test.js | 5 ++-- tests/v2-author-binding.test.js | 3 ++- 5 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 tests/_settle.js diff --git a/tests/_settle.js b/tests/_settle.js new file mode 100644 index 00000000..8bec34a3 --- /dev/null +++ b/tests/_settle.js @@ -0,0 +1,36 @@ +'use strict'; + +/** + * Wait for inbound CMB frames to finish processing instead of guessing how long that takes. + * + * node._frameHandler.handle() returns before a cmb frame is processed: admission is asynchronous + * (the injected evaluator, then the §15.8 tether evaluation, which encodes the stored record and + * its root), and the store write and every surface happen at the end of that chain. A fixed sleep + * is a guess at its duration, and the guess fails whenever the machine is slower than it, which is + * how these tests started failing under load once the neural path began evaluating the tether. + * + * Every processing chain the frame handler starts is recorded here, and settle(ms) waits at least + * `ms` (what the fixed sleep used to give, for anything that is not a frame) and then until every + * recorded chain has settled. Require it after ./_isolate-home, like any module that loads lib/. + */ + +const { FrameHandler } = require('../lib/frame-handler'); + +const inFlight = new Set(); +const handleMemoryShare = FrameHandler.prototype._handleMemoryShare; +FrameHandler.prototype._handleMemoryShare = function tracked(...args) { + const chain = handleMemoryShare.apply(this, args); + if (chain && typeof chain.then === 'function') { + inFlight.add(chain); + const done = () => inFlight.delete(chain); + chain.then(done, done); + } + return chain; +}; + +async function settle(ms = 150) { + await new Promise((r) => setTimeout(r, ms)); + while (inFlight.size) await Promise.allSettled([...inFlight]); +} + +module.exports = { settle }; diff --git a/tests/audit-delivery-authorship.test.js b/tests/audit-delivery-authorship.test.js index 2e48887b..bdb92827 100644 --- a/tests/audit-delivery-authorship.test.js +++ b/tests/audit-delivery-authorship.test.js @@ -73,7 +73,8 @@ function mkCmb(focus, { by = 'peerA', parents, mood = NEUTRAL, to } = {}) { const signed = (cmb, kp = PEER_A) => { core.signCMB(cmb, kp.priv); return cmb; }; const frame = (cmb) => JSON.parse(JSON.stringify({ type: 'cmb', timestamp: Date.now(), cmb })); const directed = (node, cmb) => Object.assign(frame(cmb), { to: node.nodeId, directed: true }); -const settle = (ms = 150) => new Promise((r) => setTimeout(r, ms)); +// Waits for every in-flight frame to finish, not a fixed guess at how long that takes. +const { settle } = require('./_settle'); const tick = () => new Promise((r) => setTimeout(r, 5)); // distinct createdTimestamp function collect(node) { diff --git a/tests/cmb-signing.test.js b/tests/cmb-signing.test.js index 5976f0bd..5d26e688 100644 --- a/tests/cmb-signing.test.js +++ b/tests/cmb-signing.test.js @@ -62,7 +62,8 @@ function signedCmbFrame(priv, createdBy = 'peerA') { const ALIGNED = { decision: 'aligned', total_drift: 0.1, category_drifts: { focus: 0.1 }, gate_values: { g: 1 } }; const wire = (f) => JSON.parse(JSON.stringify(f)); -const settle = (ms = 150) => new Promise((r) => setTimeout(r, ms)); +// Waits for every in-flight frame to finish, not a fixed guess at how long that takes. +const { settle } = require('./_settle'); describe('CMB authentication — Ed25519 sign + verify (MMP §8.3)', () => { it('a node signs the CMBs it authors; the signature verifies against its own identity key', async () => { diff --git a/tests/inbound-cmb-surfacing.test.js b/tests/inbound-cmb-surfacing.test.js index 10aa47b4..aeccc3f3 100644 --- a/tests/inbound-cmb-surfacing.test.js +++ b/tests/inbound-cmb-surfacing.test.js @@ -86,9 +86,8 @@ function directedFrame(receiverNode, focusText, mood) { return frame; } -async function settle(ms = 150) { - await new Promise((r) => setTimeout(r, ms)); -} +// Waits for every in-flight frame to finish, not a fixed guess at how long that takes. +const { settle } = require('./_settle'); describe('inbound CMB surfacing — public real-time mesh claim', () => { it('a first-seen inbound CMB surfaces (admitted) — baseline receive works', async () => { diff --git a/tests/v2-author-binding.test.js b/tests/v2-author-binding.test.js index 2342cd30..a469e7f0 100644 --- a/tests/v2-author-binding.test.js +++ b/tests/v2-author-binding.test.js @@ -19,6 +19,7 @@ const { SymNode } = require('../lib/node'); const { NullDiscovery } = require('../lib/discovery'); const { nodeDir } = require('../lib/config'); const { createCMB, signCMB, assertionIdV2_0 } = require('../lib/core'); +const { settle } = require('./_settle'); // waits for every in-flight frame, not a fixed guess function kp() { const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', { @@ -104,7 +105,7 @@ describe('the inbox names a proven author (K5)', () => { const got = []; node.on('cmb-accepted', (e) => got.push(e)); node._frameHandler.handle('node-relay', 'relay', { type: 'cmb', timestamp: Date.now(), cmb: v2Record({ nodeId: 'node-alice', createdBy: 'alice', signWith: ALICE, focus: 'relayed but proven' }) }); - await new Promise((r) => setTimeout(r, 150)); + await settle(); assert.strictEqual(got.length, 1); assert.strictEqual(got[0].author.nodeId, 'node-alice'); assert.strictEqual(node.inboxGet(got[0].inboxId).from, 'alice'); From abeb955a154e3692cb9c4e31542378fa03ae3ab5 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 19:31:13 +0100 Subject: [PATCH 032/148] store: SVAF anchors carry the entry's age and weight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §9.2.1 weighs each anchor by max(cos,0) · exp(−age/τ) · conf. The age is the anchor's time in this store and conf the weight this store gives it (§6.4: validated counts more, dismissed less); both live on the entry. MemoryStore.anchors() handed the gate bare records, which carry neither, so every anchor read as stored this instant at full weight: memory never aged and a dismissal changed nothing. anchors() now returns a view per record: a new object with the record's own sections plus the entry's storedAt and its anchorWeight as confidence. Nothing is written onto the record. A frame's `confidence` is not used: it is the sender's unsigned claim about its own block. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/memory-store.js | 34 ++++++++--- tests/svaf-anchor-weight.test.js | 96 ++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 9 deletions(-) create mode 100644 tests/svaf-anchor-weight.test.js diff --git a/lib/memory-store.js b/lib/memory-store.js index d6f210dd..1c5443ac 100644 --- a/lib/memory-store.js +++ b/lib/memory-store.js @@ -377,26 +377,42 @@ class MemoryStore { } /** - * Recent CMBs with full data (for SVAF anchor retrieval). - * Replaces old recentCMBs(). + * The most recent records as SVAF anchors: a VIEW of each record carrying what the gate weighs it + * by, which lives on the entry rather than on the record (§9.2.1: w = max(cos,0) · exp(−age/τ) · + * conf). + * + * storedAt the entry's receiver-local storage time, from which the gate takes the age. + * confidence the entry's anchorWeight, this store's own weight for the anchor (§6.4: 2.0 once + * validated, 0.5 once dismissed). Absent means no reduction was claimed. A frame's + * `confidence` is never used: it is the sender's unsigned claim about its own block. + * + * These used to be the bare records, which carry neither, so every anchor read as stored this + * instant at full weight: memory never aged and a dismissal changed nothing. The view is a new + * object whose sections are the record's own; nothing is written onto the record. */ anchors(limit = 5) { const keys = this._index.recentKeys(limit); - const cmbs = []; + const views = []; for (const key of keys) { const entry = this.get(key); if (!entry) continue; - if (entry.cmb) { - cmbs.push(entry.cmb); - } else { + let record = entry.cmb; + if (!record) { // Legacy entry without CMB — create on demand try { const { createCMB } = require('./core'); - cmbs.push(createCMB({ rawText: entry.content, createdBy: entry.source || this._source })); - } catch { /* core not available — skip */ } + record = createCMB({ rawText: entry.content, createdBy: entry.source || this._source }); + } catch { continue; /* core not available — skip */ } } + const storedAt = entry.storedAt ?? entry.timestamp; + const weight = entry.anchorWeight; + views.push({ + ...record, + storedAt: typeof storedAt === 'number' && Number.isFinite(storedAt) ? storedAt : undefined, + confidence: typeof weight === 'number' && Number.isFinite(weight) && weight >= 0 ? weight : undefined, + }); } - return cmbs; + return views; } /** Backward compat alias */ diff --git a/tests/svaf-anchor-weight.test.js b/tests/svaf-anchor-weight.test.js new file mode 100644 index 00000000..449fbfdf --- /dev/null +++ b/tests/svaf-anchor-weight.test.js @@ -0,0 +1,96 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME to a temp sandbox before lib/config loads + +/** + * §9.2.1: an anchor's weight in the memory readout is max(cos, 0) · exp(−age/τ) · conf. The age is + * the anchor's time in THIS store and conf is the weight this store gives it (§6.4: a validated + * anchor counts more, a dismissed one less). Both live on the store entry, not on the record. + * + * MemoryStore.anchors() handed the gate bare records, which carry neither, so the gate read + * `storedAt` as now and conf as 1 for every anchor: memory never aged and a dismissal changed + * nothing. The anchors are views carrying the entry's values; the record itself is not written to. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const { tmpdir } = require('./_tmpdir'); +const { MemoryStore } = require('../lib/memory-store'); +const { createCMB, processHeuristicSVAF, isSemanticReady } = require('../lib/core'); + +const TAU = 3600; +const POLICY = { stableThreshold: 0.35, guardedThreshold: 0.75, temporalLambda: 0.3, freshnessSeconds: TAU }; + +// One kernel for every gate the test compares (§9.2.1: drifts compare only within a pinned +// encoder), and the one a node runs once its semantic encoder has loaded. +async function awaitSemantic(timeoutMs = 30000) { + const t0 = Date.now(); + while (!isSemanticReady()) { + if (Date.now() - t0 > timeoutMs) throw new Error('semantic encoder did not become ready'); + await new Promise((r) => setTimeout(r, 200)); + } +} + +function cat7(t) { + return { + focus: t, issue: t, intent: t, motivation: t, commitment: t, + perspective: 'observer', mood: { text: 'neutral', valence: 0, arousal: 0 }, + }; +} + +const AGREES = 'the build pipeline caches artifacts per commit'; +const OTHER = 'the deploy job publishes container images every night'; +const INCOMING = 'the build pipeline caches artifacts per commit hash'; + +/** A store holding the anchor the incoming block agrees with and one it does not; `age` the first. */ +function storeWith(adjust) { + const store = new MemoryStore(tmpdir('sym-anchor-weight-'), 'receiver'); + const agrees = store.write('agrees', { cmb: createCMB({ categories: cat7(AGREES), createdBy: 'receiver' }) }); + store.write('other', { cmb: createCMB({ categories: cat7(OTHER), createdBy: 'receiver' }) }); + if (adjust) adjust(store, agrees.key); + return { store, agreesKey: agrees.key }; +} + +async function totalDriftAgainst(store) { + const now = Date.now(); + const r = await processHeuristicSVAF({ + msg: { type: 'cmb', cmb: createCMB({ categories: cat7(INCOMING), createdBy: 'peer' }), content: 'incoming' }, + peerName: 'peer', localName: 'receiver', originTs: now, now, ageSeconds: 0, + recentCMBs: store.recentCMBs(5), config: POLICY, + }); + assert.strictEqual(r.coldStartCause, undefined, 'precondition: the anchors were consulted'); + return r.totalDrift; +} + +describe('SVAF anchors carry the store\'s age and weight', () => { + it('an old anchor weighs less than a fresh one, and a dismissed one less than an observed one', async () => { + await awaitSemantic(); + const fresh = storeWith(); + const aged = storeWith((store, key) => { store.get(key).storedAt = Date.now() - 3 * TAU * 1000; }); + const dismissed = storeWith((store, key) => { store.dismissCMB(key); }); + + const dFresh = await totalDriftAgainst(fresh.store); + const dAged = await totalDriftAgainst(aged.store); + const dDismissed = await totalDriftAgainst(dismissed.store); + assert.ok(dAged > dFresh, `the anchor the incoming agrees with pulls less once it is old (${dAged} vs ${dFresh})`); + assert.ok(dDismissed > dFresh, `and less once it is dismissed (${dDismissed} vs ${dFresh})`); + + for (const { store, agreesKey } of [fresh, aged, dismissed]) { + const record = store.get(agreesKey).cmb; + assert.deepStrictEqual(Object.keys(record).sort(), ['categories', 'metadata'], 'nothing was written onto the record'); + } + }); + + it('an anchor older than memory reaches is no anchor at all', async () => { + const { store } = storeWith((s, key) => { s.get(key).storedAt = Date.now() - 30 * 24 * 3600 * 1000; }); + // Keep only the aged anchor in the window. + const views = store.recentCMBs(5).filter((a) => a.categories.focus.text === AGREES); + assert.strictEqual(views.length, 1); + const now = Date.now(); + const r = await processHeuristicSVAF({ + msg: { type: 'cmb', cmb: createCMB({ categories: cat7(INCOMING), createdBy: 'peer' }), content: 'incoming' }, + peerName: 'peer', localName: 'receiver', originTs: now, now, ageSeconds: 0, recentCMBs: views, config: POLICY, + }); + assert.strictEqual(r.coldStartCause, 'empty-memory', 'thirty days at τ = 1 h leaves no live memory of any category'); + }); +}); From bb69b43db0fff6e4d2edee56c42b0beb36d5bd8f Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 19:33:20 +0100 Subject: [PATCH 033/148] frame-handler: the neural path stores a copy, never the caller's record The neural path built its entry as `{ ...msg }`, so the record it stored was msg.cmb itself. Everything it then wrote to its record (the admission attestation, the tether annotations, severance, and the key and lineage it writes when the content does not collapse onto the incoming address) landed on the frame the caller handed in. record-shape gains recordAsSigned(): a copy of a received record as its author signed it, sharing nothing with the original (a two-section record comes back as exactly categories and metadata; categories keep text, signed meta and mood's valence/arousal, never a vector). The neural path stores that copy, and the heuristic engine's collapsed record is now built by the same function, so the two paths share one definition. strippedOfVectors moves to record-shape as categoriesAsSigned, now copying nested meta too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/core/record-shape.js | 41 ++++++++++++++++- lib/core/svaf-baseline.js | 41 +++++------------ lib/frame-handler.js | 12 ++--- tests/neural-admission-copy.test.js | 69 +++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 36 deletions(-) create mode 100644 tests/neural-admission-copy.test.js diff --git a/lib/core/record-shape.js b/lib/core/record-shape.js index 024c466c..3d8da2e8 100644 --- a/lib/core/record-shape.js +++ b/lib/core/record-shape.js @@ -167,4 +167,43 @@ function verifyAddress(record, derive) { : { state: VERIFY.MISMATCH, reason: `stored ${key.slice(0, 20)}… derived ${derived.slice(0, 20)}…` }; } -module.exports = { resolveCategories, requireCategories, hasCategories, containerState, unreadableReason, keyOf, verifyAddress, VERIFY }; +/** + * A record-shaped projection of categories: each category's text, its signed `meta`, and mood's + * valence/arousal. Never a vector (§7.1), and never a member the record format does not define (a + * wire extra no signature or address covers). Nested values are copies, so the result shares + * nothing with its input. + */ +function categoriesAsSigned(categories) { + const out = {}; + for (const [name, f] of Object.entries(categories || {})) { + if (!f) continue; + const kept = { text: f.text }; + if (f.meta !== undefined) kept.meta = structuredClone(f.meta); + if (f.valence !== undefined) kept.valence = f.valence; + if (f.arousal !== undefined) kept.arousal = f.arousal; + out[name] = kept; + } + return out; +} + +/** + * A copy of a received record as its author signed it, to be stored in place of the object that + * arrived. It shares nothing with that object, so nothing a receiver records against the copy + * reaches the caller's frame. + * + * A two-section record comes back as exactly `categories` and `metadata` (§8.8.1). A pre-boundary + * record (no metadata section) is copied whole, its shape being its own, with the same treatment + * of its categories. + */ +function recordAsSigned(record) { + if (!record || typeof record !== 'object') return record; + const categories = record.categories && typeof record.categories === 'object' + ? categoriesAsSigned(record.categories) + : record.categories; + if (record.metadata && typeof record.metadata === 'object') { + return { categories, metadata: structuredClone(record.metadata) }; + } + return { ...structuredClone(record), categories }; +} + +module.exports = { resolveCategories, requireCategories, hasCategories, containerState, unreadableReason, keyOf, verifyAddress, VERIFY, categoriesAsSigned, recordAsSigned }; diff --git a/lib/core/svaf-baseline.js b/lib/core/svaf-baseline.js index 5c0cd463..ecd19f8d 100644 --- a/lib/core/svaf-baseline.js +++ b/lib/core/svaf-baseline.js @@ -24,6 +24,7 @@ const { createCMB, renderContent: renderCMB, cosineSimilarity, l2Normalize, CAT7_CATEGORIES, categoryKeyV1, blockKeyV2 } = require('./cmb-encoder'); const { svafKernel } = require('./context-encoder'); const { tetherOfRecord } = require('./lineage-tether'); +const { categoriesAsSigned, recordAsSigned } = require('./record-shape'); /** Per-category admission verdicts — the first-class gating output (MMP §9.2). */ const CATEGORY_VERDICT = Object.freeze({ @@ -188,7 +189,7 @@ function incomingLineageOf(cmb) { * audit would have nothing left to check. That is the failure this comment exists to prevent, * because the first cut of this function did exactly that and the store still looked healthy. */ -function buildFusedRecord({ categories, createdBy, parentKey, parentLineage, parentMetadata, parentCategories, parentCreatedBy, method, provenance }) { +function buildFusedRecord({ categories, createdBy, parentKey, parentLineage, parentRecord, parentCreatedBy, method, provenance }) { const key = blockKeyV2(categories); const selfEdge = parentKey && key === parentKey; // Same content ⇒ same block ⇒ the AUTHOR'S record, carried through as signed (MMP §8.8.4, @@ -196,13 +197,11 @@ function buildFusedRecord({ categories, createdBy, parentKey, parentLineage, par // rebuilt categories do not carry) and the author's metadata and signature. Rewriting createdBy // to the receiver here made every admitted block claim the receiver as author at the author's // address, and cmb-fetch then served that to third parties (2026-10-01 audit, B-L1). - if (selfEdge && parentMetadata && typeof parentMetadata === 'object') { - return { - categories: parentCategories ? strippedOfVectors(parentCategories) : categories, - metadata: { ...parentMetadata, key }, - provenance, - collapsed: true, - }; + // recordAsSigned is the one definition of that copy, shared with the neural path. + if (selfEdge && parentRecord?.metadata && typeof parentRecord.metadata === 'object') { + const record = recordAsSigned(parentRecord); + record.metadata.key = key; + return { ...record, provenance, collapsed: true }; } // A pre-boundary record (§7.8, address and author at the top level) cannot be carried through // as signed in this shape, but its author is still its author, never the receiver. @@ -229,22 +228,6 @@ function buildFusedRecord({ categories, createdBy, parentKey, parentLineage, par }; } -/** A record-shaped projection of evaluated categories: text and meta, never the vector (§7.1). */ -function strippedOfVectors(categories) { - const out = {}; - for (const [name, f] of Object.entries(categories || {})) { - if (!f) continue; - // Text, the signed `meta`, and the mood's valence/arousal; never the vector, and never a field - // the record format does not define (a wire extra no signature or address covers). - const kept = { text: f.text }; - if (f.meta !== undefined) kept.meta = f.meta; - if (f.valence !== undefined) kept.valence = f.valence; - if (f.arousal !== undefined) kept.arousal = f.arousal; - out[name] = kept; - } - return out; -} - /** * The per-category drift arithmetic (§9.2/§9.2.1), as a PURE function of already-encoded vectors. * @@ -530,12 +513,11 @@ async function _processHeuristicSVAF(opts) { // Vectors are stripped on EVERY exit that stores a record, including this cold-start one. // Demonstrated rather than assumed: the first run of the store-none proof landed HERE and // persisted all seven vectors while the main path was already clean. - categories: strippedOfVectors(incomingCMB.categories), + categories: categoriesAsSigned(incomingCMB.categories), createdBy: localName, parentKey: csIncomingKey, parentLineage: incomingLineageOf(msg.cmb), - parentMetadata: msg.cmb?.metadata, - parentCategories: msg.cmb?.categories, + parentRecord: msg.cmb, parentCreatedBy: msg.cmb?.createdBy, method: 'svaf-heuristic-coldstart', provenance: { categoryDrift: {}, totalDrift: csTotalDrift, temporalDrift, effectiveTau: tau, changeSignal: 0, fusionMethod: 'svaf-heuristic-coldstart', fusedAt: now }, @@ -597,12 +579,11 @@ async function _processHeuristicSVAF(opts) { // Stripped HERE and not in computeCategoryDrifts, whose fused readout is the measurement it // returns. Computation keeps its vectors; the RECORD carries none. // `meta` is preserved — categoryParentsCommitment signs meta.parents. - categories: strippedOfVectors(fusedCategories), + categories: categoriesAsSigned(fusedCategories), createdBy: localName, parentKey: incomingKey, parentLineage: incomingLineageOf(msg.cmb), - parentMetadata: msg.cmb?.metadata, - parentCategories: msg.cmb?.categories, + parentRecord: msg.cmb, parentCreatedBy: msg.cmb?.createdBy, method: 'svaf-heuristic', provenance: { categoryDrift: categoryDrifts, totalDrift, temporalDrift, effectiveTau: tau, changeSignal: 0, fusionMethod: 'svaf-heuristic', fusedAt: now }, diff --git a/lib/frame-handler.js b/lib/frame-handler.js index ab38ab8f..b9677fb5 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -24,7 +24,7 @@ const { assertRecordSize } = require('./core/cmb-encoder'); const fs = require('fs'); const path = require('path'); -const { encode, DIM, processHeuristicSVAF, computeCategoryVerdicts, decryptCategories, blockKeyV2, resolveTetherAnchor, tetherOfRecord, signTetherAttestation, verifyTetherAttestation, recomputeKey, classifyAddress } = require('./core'); +const { encode, DIM, processHeuristicSVAF, computeCategoryVerdicts, decryptCategories, blockKeyV2, resolveTetherAnchor, tetherOfRecord, signTetherAttestation, verifyTetherAttestation, recomputeKey, classifyAddress, recordAsSigned } = require('./core'); // Receive-path dedup window (MMP §4.2 O2 — rejoin-without-replay convergence). // A CMB whose content-hash key we have already processed within this window is @@ -1081,6 +1081,10 @@ class FrameHandler { const fusedEntry = { ...msg, + // A copy of the record as its author signed it, never the caller's object. Everything below + // writes to the record this node stores; on the incoming object it reached the caller's + // frame. The heuristic path stores a record built the same way (recordAsSigned). + cmb: msg.cmb ? recordAsSigned(msg.cmb) : msg.cmb, source: `${this._node.name}+${msg.source || peerName}`, storedAt: now, svaf: { @@ -1135,10 +1139,8 @@ class FrameHandler { // Opaque payload rides alongside CAT7 — carry it onto the admitted remix. this._preserveIncomingPayload(fusedEntry, msg); if (fusedEntry.cmb) { - // This path stores the incoming record object itself, so it still carries any attestation its - // sender attached. That one was an input, checked in _prepareLineageTether; the record this - // node stores carries its own evaluation or none, as the heuristic path's rebuilt record does. - delete fusedEntry.cmb.tether; + // The copy carries no attestation the sender attached: that one was an input, checked in + // _prepareLineageTether. The record this node stores carries its own evaluation or none. const tether = await tetherOfRecord(fusedEntry.cmb.categories, tetherAnchor, this._admissionPolicy()); this._applyLineageTether(fusedEntry, tether, now); } diff --git a/tests/neural-admission-copy.test.js b/tests/neural-admission-copy.test.js new file mode 100644 index 00000000..356dea29 --- /dev/null +++ b/tests/neural-admission-copy.test.js @@ -0,0 +1,69 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME to a temp sandbox before lib/config loads + +/** + * The neural admission path stores a copy of the incoming record, never the caller's object. + * + * It built its entry as `{ ...msg }`, so the entry's record WAS msg.cmb, and everything the path + * then did to "its" record (the admission attestation, the tether annotations, severance, and the + * key and lineage it writes when the content does not collapse onto the incoming address) landed on + * the frame the caller handed in. The heuristic path builds a new record and never had this. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const { SymNode } = require('../lib/node'); +const { NullDiscovery } = require('../lib/discovery'); +const { nodeDir } = require('../lib/config'); +const { createCMB } = require('../lib/core'); + +const ALIGNED = { decision: 'aligned', total_drift: 0.1, category_drifts: { focus: 0.1 }, gate_values: { focus: 1 } }; + +function cat7(t) { + return { + focus: t, issue: t, intent: t, motivation: t, commitment: t, + perspective: 'peerA', mood: { text: 'neutral', valence: 0, arousal: 0 }, + }; +} + +async function withNode(fn) { + const name = `neural-copy-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node = new SymNode({ name, silent: true, discovery: new NullDiscovery(), svafEvaluator: { evaluate: async () => ALIGNED } }); + await node.start(); + try { + return await fn(node); + } finally { + await node.stop(); + fs.rmSync(nodeDir(name), { recursive: true, force: true }); + } +} + +describe('neural admission works on a copy of the incoming record', () => { + it('the caller\'s record is untouched, whether the content collapses onto its address or not', async () => { + await withNode(async (node) => { + const root = node.remember(cat7('quarterly financial audit of the accounting ledger and tax filings')); + const cases = []; + // Collapses: correctly addressed, cites a root this node holds (so the tether is evaluated), + // and carries a sender's attestation the path must not store. + const collapses = createCMB({ categories: cat7('fresh snowfall reported on the upper mountain trail sections'), createdBy: 'peerA', lineage: { parents: [root.key], method: 'SVAF-v2' } }); + collapses.tether = { of: collapses.metadata.key, anchor: root.key, verdict: 'tethered', by: 'another-integrator', drift: 0 }; + cases.push(['collapses', collapses]); + // Does not collapse: the address it carries is not its content's, so the path writes a new + // key and lineage on the record it stores. + const misaddressed = createCMB({ categories: cat7('a record whose carried address is not its own'), createdBy: 'peerA' }); + misaddressed.metadata.key = 'cmb-' + '9'.repeat(64); + cases.push(['does not collapse', misaddressed]); + + for (const [label, cmb] of cases) { + const frame = { type: 'cmb', timestamp: Date.now(), content: label, cmb }; + const before = structuredClone(frame); + const now = Date.now(); + await node._frameHandler._processNeuralSVAF(ALIGNED, frame, 'peerA', 'peerA', now, now); + assert.ok(node._store.count() > 1, `${label}: precondition: something was stored`); + assert.deepStrictEqual(frame.cmb, before.cmb, `${label}: the caller's record is exactly as it was handed in`); + } + }); + }); +}); From 0b977a33327a7256f8b562a6263ca62cb0f0e9cd Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 19:38:02 +0100 Subject: [PATCH 034/148] store entry, not record: admission, tether, provenance and collapsed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A two-section record has exactly `categories` and `metadata` (§8.8.1). The receive path added four members to every stored record: `admission` (the admission attestation), `tether` (the §15.8 attestation), `provenance` (fusion evidence and the tether annotation) and `collapsed`. None is the author's and none is signed; they are this node's record about the record. They are entry members now: - the heuristic engine returns the record as exactly two sections and puts provenance and collapsed on the fused entry (buildFusedRecord returns { record, collapsed }); - the frame handler puts the admission and tether attestations and the tether provenance on the entry, on both gate paths, as does the tether audit; - the store's asStored() moves any of the four it finds on a two-section record onto the entry (the entry's own value wins), on write as well as on every read, so files written before keep their annotations and a writer that still puts them on the record cannot break the shape; - frame intake drops frame-supplied admission/tether/provenance/collapsed and svaf, since the stored entry is built from the frame and these name this node's own work. Readers moved with them: the tether tests, the admission attestation test, the e2e-admission and e2e-cmb-path integration tests, and the two-stock-nodes release-gate driver. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/core/svaf-baseline.js | 44 ++++---- lib/frame-handler.js | 36 ++++--- lib/memory-store.js | 47 ++++++--- lib/node.js | 7 +- scripts/two-stock-nodes-driver.cjs | 15 +-- tests/admission-attestation.test.js | 34 +++--- tests/integration/e2e-admission.js | 7 +- tests/integration/e2e-cmb-path.js | 4 +- tests/lineage-tether-audit.test.js | 14 +-- tests/lineage-tether-neural.test.js | 12 +-- tests/lineage-tether-reproducible.test.js | 12 +-- tests/lineage-tether-severance.test.js | 30 +++--- tests/stored-entry-annotations.test.js | 123 ++++++++++++++++++++++ 13 files changed, 282 insertions(+), 103 deletions(-) create mode 100644 tests/stored-entry-annotations.test.js diff --git a/lib/core/svaf-baseline.js b/lib/core/svaf-baseline.js index ecd19f8d..47e3cb41 100644 --- a/lib/core/svaf-baseline.js +++ b/lib/core/svaf-baseline.js @@ -171,7 +171,10 @@ function incomingLineageOf(cmb) { } /** - * Build the fused remix as a §7 two-section record. + * Build the fused remix as a §7 two-section record: `{ record, collapsed }`, where `record` is + * exactly `categories` and `metadata` (§8.8.1) and `collapsed` says it is the incoming block itself. + * What this node records ABOUT the remix (its provenance, its collapse, the attestations the frame + * handler adds) belongs on the store entry beside the record, never inside it. * * Addressed by CONTENT ALONE (blockKeyV2), not by mintRemixKey. Under content-only addressing a * lineage-bearing block is addressed exactly like any other block with the same content, so a @@ -189,7 +192,7 @@ function incomingLineageOf(cmb) { * audit would have nothing left to check. That is the failure this comment exists to prevent, * because the first cut of this function did exactly that and the store still looked healthy. */ -function buildFusedRecord({ categories, createdBy, parentKey, parentLineage, parentRecord, parentCreatedBy, method, provenance }) { +function buildFusedRecord({ categories, createdBy, parentKey, parentLineage, parentRecord, parentCreatedBy, method }) { const key = blockKeyV2(categories); const selfEdge = parentKey && key === parentKey; // Same content ⇒ same block ⇒ the AUTHOR'S record, carried through as signed (MMP §8.8.4, @@ -201,13 +204,13 @@ function buildFusedRecord({ categories, createdBy, parentKey, parentLineage, par if (selfEdge && parentRecord?.metadata && typeof parentRecord.metadata === 'object') { const record = recordAsSigned(parentRecord); record.metadata.key = key; - return { ...record, provenance, collapsed: true }; + return { record, collapsed: true }; } // A pre-boundary record (§7.8, address and author at the top level) cannot be carried through // as signed in this shape, but its author is still its author, never the receiver. if (selfEdge && typeof parentCreatedBy === 'string' && parentCreatedBy) createdBy = parentCreatedBy; const inherited = selfEdge ? (parentLineage ?? null) : null; - return { + const record = { categories, metadata: { key, @@ -223,9 +226,8 @@ function buildFusedRecord({ categories, createdBy, parentKey, parentLineage, par room: null, to: null, }, - provenance, - collapsed: selfEdge || undefined, }; + return { record, collapsed: !!selfEdge }; } /** @@ -509,7 +511,7 @@ async function _processHeuristicSVAF(opts) { return { accepted: false, totalDrift: csTotalDrift, decision: 'rejected', categoryVerdicts: computeCategoryVerdicts(verdictDrifts, config), effectiveTau: tau, changeSignal: 0, coldStartCause }; } const csIncomingKey = incomingKeyOf(msg.cmb); - const csCMB = buildFusedRecord({ + const cs = buildFusedRecord({ // Vectors are stripped on EVERY exit that stores a record, including this cold-start one. // Demonstrated rather than assumed: the first run of the store-none proof landed HERE and // persisted all seven vectors while the main path was already clean. @@ -520,17 +522,20 @@ async function _processHeuristicSVAF(opts) { parentRecord: msg.cmb, parentCreatedBy: msg.cmb?.createdBy, method: 'svaf-heuristic-coldstart', - provenance: { categoryDrift: {}, totalDrift: csTotalDrift, temporalDrift, effectiveTau: tau, changeSignal: 0, fusionMethod: 'svaf-heuristic-coldstart', fusedAt: now }, }); - const csKey = csCMB.metadata.key; - const csContent = renderCMB(csCMB); - const csEntry = { ...msg, key: csKey, content: csContent, source: `${localName}+${msg.source || peerName}`, cmb: csCMB, storedAt: now }; + const csKey = cs.record.metadata.key; + const csContent = renderCMB(cs.record); + const csEntry = { + ...msg, key: csKey, content: csContent, source: `${localName}+${msg.source || peerName}`, cmb: cs.record, storedAt: now, + provenance: { categoryDrift: {}, totalDrift: csTotalDrift, temporalDrift, effectiveTau: tau, changeSignal: 0, fusionMethod: 'svaf-heuristic-coldstart', fusedAt: now }, + collapsed: cs.collapsed || undefined, + }; const csDecision = (!lensUncovered && csTotalDrift <= config.stableThreshold) ? 'aligned' : 'guarded'; // §15.8 applies to every remix this node integrates, and an empty anchor window says nothing // about the chain: the tether compares the record with its lineage root, not with memory. This // exit returned without one, so the caller had nothing to sever on and a remix drifted past its // root's floor kept the lineage exactly where the receiver's memory is thinnest. - const csTether = await tetherOfRecord(csCMB.categories, opts.tetherAnchor, config); + const csTether = await tetherOfRecord(cs.record.categories, opts.tetherAnchor, config); // Cold-start: no category was evaluable, so every per-category verdict is `silent` // (admitted on temporal grounds to bootstrap memory, gated on no category). return { accepted: true, totalDrift: csTotalDrift, decision: csDecision, maxCategoryDrift: 0, categoryVerdicts: computeCategoryVerdicts(verdictDrifts, config), fusedEntry: csEntry, fusedContent: csContent, effectiveTau: tau, changeSignal: 0, coldStartCause, tether: csTether }; @@ -571,7 +576,7 @@ async function _processHeuristicSVAF(opts) { const decision = totalDrift <= config.stableThreshold ? 'aligned' : 'guarded'; const incomingKey = incomingKeyOf(msg.cmb); - const fusedCMB = buildFusedRecord({ + const fused = buildFusedRecord({ // STORE NO VECTOR (§7.1). createCMB already refuses to put a vector in an emitted record — // "Emitters MUST NOT include embedding vectors" — and fusion was putting one back in on the // receiver side, so this applies the existing rule to the remix rather than inventing one. @@ -586,14 +591,17 @@ async function _processHeuristicSVAF(opts) { parentRecord: msg.cmb, parentCreatedBy: msg.cmb?.createdBy, method: 'svaf-heuristic', - provenance: { categoryDrift: categoryDrifts, totalDrift, temporalDrift, effectiveTau: tau, changeSignal: 0, fusionMethod: 'svaf-heuristic', fusedAt: now }, }); - const fusedKey = fusedCMB.metadata.key; + const fusedKey = fused.record.metadata.key; - const fusedContent = renderCMB(fusedCMB); - const fusedEntry = { ...msg, key: fusedKey, content: fusedContent, source: `${localName}+${msg.source || peerName}`, cmb: fusedCMB, storedAt: now }; + const fusedContent = renderCMB(fused.record); + const fusedEntry = { + ...msg, key: fusedKey, content: fusedContent, source: `${localName}+${msg.source || peerName}`, cmb: fused.record, storedAt: now, + provenance: { categoryDrift: categoryDrifts, totalDrift, temporalDrift, effectiveTau: tau, changeSignal: 0, fusionMethod: 'svaf-heuristic', fusedAt: now }, + collapsed: fused.collapsed || undefined, + }; - const tether = await tetherOfRecord(fusedCMB.categories, opts.tetherAnchor, config); + const tether = await tetherOfRecord(fused.record.categories, opts.tetherAnchor, config); return { accepted: true, decision, totalDrift, categoryVerdicts: computeCategoryVerdicts(verdictDrifts, config, nearestDrifts), fusedEntry, fusedContent, effectiveTau: tau, changeSignal: 0, tether }; } diff --git a/lib/frame-handler.js b/lib/frame-handler.js index b9677fb5..09674cbb 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -26,6 +26,9 @@ const fs = require('fs'); const path = require('path'); const { encode, DIM, processHeuristicSVAF, computeCategoryVerdicts, decryptCategories, blockKeyV2, resolveTetherAnchor, tetherOfRecord, signTetherAttestation, verifyTetherAttestation, recomputeKey, classifyAddress, recordAsSigned } = require('./core'); +/** Entry members this node writes about an admission, which a frame must never supply. */ +const ENTRY_ANNOTATIONS = Object.freeze(['admission', 'tether', 'provenance', 'collapsed', 'svaf']); + // Receive-path dedup window (MMP §4.2 O2 — rejoin-without-replay convergence). // A CMB whose content-hash key we have already processed within this window is // suppressed rather than re-evaluated/re-remixed/re-emitted. Bounds the dedup @@ -751,6 +754,13 @@ class FrameHandler { this._node._log(`Ignored a frame-supplied source (${JSON.stringify(String(msg.source)).slice(0, 40)}) on a CMB from ${peerName}`); delete msg.source; } + // The same for what this node records about an admission. The stored entry is built from the + // frame (`{ ...msg }`), and these entry members are this node's own: its admission and tether + // attestations, its fusion provenance, its collapse, its neural evidence. One a frame carried + // would be stored as if this node had written it. + for (const own of ENTRY_ANNOTATIONS) { + if (Object.prototype.hasOwnProperty.call(msg, own)) delete msg[own]; + } // Decrypt E2E-encrypted CMB categories if present if (msg.cmb && typeof msg.cmb.categories === 'string' && msg.cmb._e2e) { @@ -1126,7 +1136,7 @@ class FrameHandler { } } // Admission Attestation (MMP) — the per-category gating verdict, signed + bound to - // the gated CMB, persisted on the remix as the durable audit record. The neural + // the gated CMB, persisted on the remix's store entry as the durable audit record. The neural // path emits per-category drift; map it to verdicts with the same thresholds (Phase A). if (incomingKey && fusedEntry.cmb) { const categoryVerdicts = computeCategoryVerdicts(category_drifts || {}, { @@ -1134,7 +1144,8 @@ class FrameHandler { guardedThreshold: this._node._svafGuardedThreshold, }); const att = this._node._buildAdmissionAttestation(incomingKey, decision, categoryVerdicts, 'neural'); - if (att) fusedEntry.cmb.admission = att; + // On the entry, beside the record: a two-section record has exactly two members (§8.8.1). + if (att) fusedEntry.admission = att; } // Opaque payload rides alongside CAT7 — carry it onto the admitted remix. this._preserveIncomingPayload(fusedEntry, msg); @@ -1259,11 +1270,12 @@ class FrameHandler { // Admission Attestation (MMP) — the heuristic gate already produced the per-category // verdict (Phase A, result.categoryVerdicts); sign + bind it to the gated CMB and - // persist it on the remix as the durable audit record. + // persist it on the remix's store entry as the durable audit record. if (result.fusedEntry && result.fusedEntry.cmb) { const of = recordKey(msg.cmb) || msg.key; const att = this._node._buildAdmissionAttestation(of, result.decision, result.categoryVerdicts, 'heuristic'); - if (att) result.fusedEntry.cmb.admission = att; + // On the entry, beside the record: a two-section record has exactly two members (§8.8.1). + if (att) result.fusedEntry.admission = att; } // Opaque payload rides alongside CAT7 — the heuristic fusion rebuilds the // CMB from categories and drops it, so re-attach before storing the remix. @@ -1364,9 +1376,9 @@ class FrameHandler { * * Attestation: sign the exact evaluation performed — remix key, anchor, kernel identity, drift, * verdict — so a downstream receiver that cannot resolve the anchor holds - * attested-by-integrator instead of unchecked. Carried on the remix (cmb.tether); trust in the - * verdict is weighed through the integrator's resolved authority, and verdicts are comparable - * only within one kernelId. + * attested-by-integrator instead of unchecked. Kept on the store entry (entry.tether), beside the + * record and never inside it (§8.8.1); trust in the verdict is weighed through the integrator's + * resolved authority, and verdicts are comparable only within one kernelId. * @private */ _applyLineageTether(entry, tether, now) { @@ -1375,21 +1387,21 @@ class FrameHandler { if (!tether.tethered && recordLineage(cmb)) { const departedFrom = recordParents(cmb)[0] ?? null; setRecordLineage(cmb, null); // fresh root — must clear the section the record ACTUALLY carries - cmb.provenance = { - ...cmb.provenance, + entry.provenance = { + ...entry.provenance, tether: { severed: true, anchor: tether.anchorKey, kernelId: tether.kernelId, drift: tether.drift, departedFrom }, }; this._node._log(`[§15.8] lineage severed: remix drift ${tether.drift.toFixed(3)} from anchor ${String(tether.anchorKey).slice(0, 16)} exceeds the reject floor — stored as fresh root (departed-from in provenance)`); this._node.emit('metric', { type: 'lineage-tether-severed', key: recordKey(cmb), anchor: tether.anchorKey, drift: tether.drift }); } else { - cmb.provenance = { - ...cmb.provenance, + entry.provenance = { + ...entry.provenance, tether: { severed: false, anchor: tether.anchorKey, kernelId: tether.kernelId, drift: tether.drift }, }; } if (typeof signTetherAttestation === 'function' && this._node._identity?.privateKey) { try { - cmb.tether = signTetherAttestation({ + entry.tether = signTetherAttestation({ of: recordKey(cmb), anchor: tether.anchorKey, kernelId: tether.kernelId, diff --git a/lib/memory-store.js b/lib/memory-store.js index 1c5443ac..e8f400c6 100644 --- a/lib/memory-store.js +++ b/lib/memory-store.js @@ -34,24 +34,43 @@ const { roleRank } = require('./core'); const isStoreFile = (f) => f.endsWith('.json') && f !== 'migration-v1.json'; /** - * A stored entry as read from disk, its record restored to exactly what was signed. + * What this node records ABOUT a stored record, which lives on the entry beside the record: the + * admission attestation, the §15.8 tether attestation, the fusion provenance, and whether the + * record is the incoming block itself. + */ +const ENTRY_ANNOTATIONS = Object.freeze(['admission', 'tether', 'provenance', 'collapsed']); + +/** + * An entry whose record is exactly what was signed, for every entry the store writes or reads. + * + * A two-section record has exactly `categories` and `metadata` (§8.8.1). Stores before 0.13.15 put + * more on it, and files they wrote still hold it: + * + * lineage the store's expanded lineage as a top-level member. Readers that take a top-level + * lineage as the record's own (the pre-boundary shape, e.g. classifyAddress) derived + * the wrong address from it and reported the record as a mismatch. The entry carries + * the same lineage on itself, so the copy is dropped. + * admission, tether, provenance, collapsed + * this node's own annotations (ENTRY_ANNOTATIONS). They move onto the entry, where + * they are kept; one the entry already has wins. * - * Stores before 0.13.15 wrote the store's expanded lineage onto every record as a top-level - * `lineage` member. A two-section record has no such member (§8.8.1: `categories` and `metadata`, - * lineage in metadata.lineage), and readers that take a top-level lineage as the record's own (the - * pre-boundary shape, e.g. classifyAddress) derived the wrong address from it and reported the - * record as a mismatch. The entry carries the same lineage on itself, which is where the store's - * closure belongs, so dropping the copy loses nothing. A pre-boundary record (no metadata) keeps - * its top-level lineage: on that shape it is the record's own section. + * Applied on write as well as on read, so a writer that still puts them on the record (an + * injected admission engine, an older caller) cannot break the shape. A pre-boundary record (no + * metadata) is left alone: its top-level members are its own sections. */ function asStored(entry) { const cmb = entry && entry.cmb; - if (cmb && typeof cmb === 'object' && cmb.metadata && typeof cmb.metadata === 'object' - && Object.prototype.hasOwnProperty.call(cmb, 'lineage')) { - const record = { ...cmb }; - delete record.lineage; - entry.cmb = record; + if (!cmb || typeof cmb !== 'object' || !cmb.metadata || typeof cmb.metadata !== 'object') return entry; + const own = (k) => Object.prototype.hasOwnProperty.call(cmb, k); + if (!own('lineage') && !ENTRY_ANNOTATIONS.some(own)) return entry; + const record = { ...cmb }; + delete record.lineage; + for (const k of ENTRY_ANNOTATIONS) { + if (!own(k)) continue; + if (entry[k] === undefined) entry[k] = cmb[k]; + delete record[k]; } + entry.cmb = record; return entry; } @@ -270,6 +289,7 @@ class MemoryStore { lineage, }; + asStored(entry); if (!this._persist(entry)) return null; this._cache.set(key, entry); @@ -328,6 +348,7 @@ class MemoryStore { lineage, }; + asStored(stored); if (!this._persist(stored)) return null; this._cache.set(key, stored); diff --git a/lib/node.js b/lib/node.js index 5fee424c..95ed60db 100644 --- a/lib/node.js +++ b/lib/node.js @@ -737,8 +737,9 @@ class SymNode extends EventEmitter { if (!ev.checked) { report.unchecked++; continue; } const applySever = !ev.tethered && opts.sever === true; - cmb.provenance = { - ...cmb.provenance, + // On the entry, beside the record: a two-section record has exactly two members (§8.8.1). + entry.provenance = { + ...entry.provenance, tether: { severed: applySever, anchor: anchorKey, kernelId: ev.kernelId, drift: ev.drift, audited: true, @@ -746,7 +747,7 @@ class SymNode extends EventEmitter { }, }; try { - cmb.tether = signTetherAttestation({ + entry.tether = signTetherAttestation({ of: recordKey(cmb), anchor: anchorKey, kernelId: ev.kernelId, drift: ev.drift, verdict: ev.tethered ? 'tethered' : 'severed', by: this.nodeId, at: Date.now(), }, this._identity.privateKey); diff --git a/scripts/two-stock-nodes-driver.cjs b/scripts/two-stock-nodes-driver.cjs index 1e0b6a2c..8876878c 100644 --- a/scripts/two-stock-nodes-driver.cjs +++ b/scripts/two-stock-nodes-driver.cjs @@ -101,16 +101,17 @@ const FOCUS = 'two stock sym nodes prove the open runtime is self-sufficient'; const stored = evt.entry; assert.ok(stored, 'ADMIT: the admitted record must be handed back as a stored entry'); - // The durable, signed record of the verdict is the Admission Attestation on the remix — the - // heuristic gate (the ratified production default) carries no `svaf` block on the entry, so - // asserting one here would have gated on the neural path nobody runs. - const admission = stored.cmb && stored.cmb.admission; + // The durable, signed record of the verdict is the Admission Attestation on the remix's store + // entry, beside the record (§8.8.1: the record is exactly its two sections) — the heuristic gate + // (the ratified production default) carries no `svaf` block on the entry, so asserting one here + // would have gated on the neural path nobody runs. + const admission = stored.admission; assert.ok(admission, 'ADMIT: an admitted remix must carry its signed Admission Attestation'); assert.strictEqual(admission.of, sent.key, `ADMIT: the attestation must bind A's record ${sent.key}, got ${admission.of}`); assert.strictEqual(admission.verdict, evt.decision, 'ADMIT: the persisted verdict must be the verdict that was reached'); - assert.ok(stored.cmb.provenance && typeof stored.cmb.provenance.totalDrift === 'number', + assert.ok(stored.provenance && typeof stored.provenance.totalDrift === 'number', 'ADMIT: the remix must record the drift it was admitted at'); // LINEAGE, AS v2 DEFINES IT. Heuristic fusion keeps the incoming category text verbatim and v2 @@ -120,7 +121,7 @@ const FOCUS = 'two stock sym nodes prove the open runtime is self-sufficient'; // are legitimate, so the gate asserts the property that must hold either way — the stored record // is reachable back to what A sent. const parents = (stored.cmb.metadata && stored.cmb.metadata.lineage && stored.cmb.metadata.lineage.parents) || []; - const collapsed = stored.key === sent.key && stored.cmb.collapsed === true; + const collapsed = stored.key === sent.key && stored.collapsed === true; assert.ok(collapsed || parents.includes(sent.key), `LINEAGE: stored remix must either collapse onto A's block ${sent.key} or parent it — ` + `got key ${stored.key}, parents [${parents.join(', ')}]`); @@ -141,7 +142,7 @@ const FOCUS = 'two stock sym nodes prove the open runtime is self-sufficient'; assert.ok(survivor, `RESTART: after restarting B, the admitted record descending from ${sent.key} was not recallable ` + `(recall returned ${recalled.length} entr${recalled.length === 1 ? 'y' : 'ies'})`); - assert.ok(survivor.cmb && survivor.cmb.admission && survivor.cmb.admission.of === sent.key, + assert.ok(survivor.admission && survivor.admission.of === sent.key, 'RESTART: the survivor must still carry the signed attestation of its admission — a record that ' + 'survives without its verdict cannot be audited later'); console.log(` after restart B recalled it, lineage intact`); diff --git a/tests/admission-attestation.test.js b/tests/admission-attestation.test.js index 5dd3362e..493cd648 100644 --- a/tests/admission-attestation.test.js +++ b/tests/admission-attestation.test.js @@ -4,10 +4,10 @@ require('./_isolate-home'); // redirect $HOME to a temp sandbox before lib/confi /** * Phase C — the node builds + signs an Admission Attestation when it gates a CMB, - * and the store persists it on the remix. + * and the store persists it on the remix's entry. * * Deterministic (no encoder / no SVAF run): exercises node._buildAdmissionAttestation - * directly and the memory-store preservation of cmb.admission. The full receive-path + * directly and the memory-store preservation of entry.admission. The full receive-path * wiring (frame-handler attaches it on admit) is covered by the integration test * tests/integration/e2e-admission.js. */ @@ -19,7 +19,7 @@ const fs = require('fs'); const { SymNode } = require('../lib/node'); const { NullDiscovery } = require('../lib/discovery'); const { nodeDir } = require('../lib/config'); -const { verifyAttestation, verifyAttestationRole, signAttestation } = require('../lib/core'); +const { verifyAttestation, verifyAttestationRole, signAttestation, createCMB } = require('../lib/core'); // Construct (no start) — the builder needs only identity / room / role / chain state. function withNode(baseName, opts, fn) { @@ -69,19 +69,29 @@ describe('node._buildAdmissionAttestation', () => { }); }); -describe('memory-store persists cmb.admission on the remix', () => { +describe('memory-store persists the admission attestation on the remix\'s entry', () => { + // The attestation is this node's record ABOUT the remix, so it lives on the entry beside the + // record; a two-section record has exactly `categories` and `metadata` (§8.8.1). + const record = () => createCMB({ categories: { focus: 'f', issue: 'i', intent: 'n', motivation: 'm', commitment: 'c', perspective: 'p', mood: 'neutral' }, createdBy: 'peer' }); + it('preserves a signed admission attestation through receiveFromPeer', () => { withNode('att-store', { lifecycleRole: 'participant', room: 'g' }, (node) => { const att = node._buildAdmissionAttestation('cmb-of', 'aligned', verdicts, 'heuristic'); - const entry = { - source: `${node.name}+peer`, content: 'x', - cmb: { key: 'remix-1', categories: { focus: { text: 'f' } }, admission: att }, - storedAt: Date.now(), - }; + const entry = { source: `${node.name}+peer`, content: 'x', cmb: record(), admission: att, storedAt: Date.now() }; const stored = node._store.receiveFromPeer('peer-id', entry); - assert.ok(stored && stored.cmb && stored.cmb.admission, 'admission preserved on the stored remix'); - assert.strictEqual(stored.cmb.admission.of, 'cmb-of'); - assert.deepStrictEqual(verifyAttestation(stored.cmb.admission, node._identity.publicKey), { signed: true, valid: true }); + assert.ok(stored && stored.admission, 'admission preserved on the stored remix\'s entry'); + assert.strictEqual(stored.admission.of, 'cmb-of'); + assert.deepStrictEqual(verifyAttestation(stored.admission, node._identity.publicKey), { signed: true, valid: true }); + assert.deepStrictEqual(Object.keys(stored.cmb).sort(), ['categories', 'metadata']); + }); + }); + + it('a writer that still puts it on the record has it moved onto the entry', () => { + withNode('att-store-lift', { lifecycleRole: 'participant', room: 'g' }, (node) => { + const att = node._buildAdmissionAttestation('cmb-of', 'aligned', verdicts, 'heuristic'); + const stored = node._store.receiveFromPeer('peer-id', { content: 'x', cmb: { ...record(), admission: att }, storedAt: Date.now() }); + assert.deepStrictEqual(Object.keys(stored.cmb).sort(), ['categories', 'metadata'], 'the record keeps exactly its two sections'); + assert.deepStrictEqual(verifyAttestation(stored.admission, node._identity.publicKey), { signed: true, valid: true }); }); }); }); diff --git a/tests/integration/e2e-admission.js b/tests/integration/e2e-admission.js index e7153274..94d0bb48 100644 --- a/tests/integration/e2e-admission.js +++ b/tests/integration/e2e-admission.js @@ -7,7 +7,7 @@ require('../_isolate-home'); // sandbox HOME/USERPROFILE: node state must never * remix it stores (MMP admission-attestation layer, Phase C). * * Two wired in-process nodes; A sends a directed CMB to B; B gates it (heuristic - * forced) and ADMITS it; the stored remix's cmb.admission must be a valid signed + * forced) and ADMITS it; the stored remix's entry.admission must be a valid signed * attestation bound to A's CMB, attributed to B, signed by B's identity key. * * Run with: npm run test:integration (heuristic path loads the encoder). @@ -59,8 +59,9 @@ describe('E2E Admission Attestation — gate attaches a signed verdict to the re const admitted = await (async () => { for (let i = 0; i < 100 && !received; i++) await sleep(50); return !!received; })(); assert.ok(admitted, 'B must process and admit the directed CMB'); - const att = received.entry?.cmb?.admission; - assert.ok(att, 'stored remix carries an admission attestation'); + // On the store entry, beside the record (§8.8.1: the record is exactly its two sections). + const att = received.entry?.admission; + assert.ok(att, 'stored remix\'s entry carries an admission attestation'); assert.strictEqual(att.of, entry.key, 'attestation is bound to A\'s gated CMB'); assert.strictEqual(att.by, B.nodeId, 'attested by the gating node B'); assert.strictEqual(att.roster, 'sym-bot-team', 'scoped to B\'s roster'); diff --git a/tests/integration/e2e-cmb-path.js b/tests/integration/e2e-cmb-path.js index d9254508..e5565172 100644 --- a/tests/integration/e2e-cmb-path.js +++ b/tests/integration/e2e-cmb-path.js @@ -146,8 +146,8 @@ describe('E2E CMB path — MMP §4.2 / §4.4.4 / §9.2', () => { { const storedEntry = outcomes.memoryReceived.entry; assert.ok(storedEntry, 'memory-received event should carry the fused entry'); - // The gate's evidence: the neural path writes `svaf`, the heuristic path `cmb.provenance`. - const evidence = storedEntry.svaf || storedEntry.cmb?.provenance; + // The gate's evidence, on the entry: the neural path writes `svaf`, the heuristic path `provenance`. + const evidence = storedEntry.svaf || storedEntry.provenance; assert.ok(evidence, 'an admission carries the SVAF evaluation (§9.2)'); assert.ok(typeof evidence.totalDrift === 'number', 'totalDrift is numeric'); // B adds no new cognition (the heuristic gate keeps the text verbatim), so content diff --git a/tests/lineage-tether-audit.test.js b/tests/lineage-tether-audit.test.js index e6816d58..0a2a5873 100644 --- a/tests/lineage-tether-audit.test.js +++ b/tests/lineage-tether-audit.test.js @@ -75,9 +75,9 @@ describe('MMP §15.8 retroactive tether audit', () => { const l1 = node._store.get(laundered.key); assert.ok(l1.cmb.metadata.lineage && (l1.cmb.metadata.lineage.parents || []).length === 1, 'lineage kept in annotate-only mode'); - assert.strictEqual(l1.cmb.provenance.tether.audited, true); - assert.ok(l1.cmb.provenance.tether.drift > 0.5); - const att = l1.cmb.tether; + assert.strictEqual(l1.provenance.tether.audited, true); + assert.ok(l1.provenance.tether.drift > 0.5); + const att = l1.tether; assert.strictEqual(att.verdict, 'severed', 'attestation records the evaluation outcome'); assert.strictEqual(verifyTetherAttestation(att, node._identity.publicKey).valid, true); @@ -86,13 +86,13 @@ describe('MMP §15.8 retroactive tether audit', () => { assert.strictEqual(r2.severed, 1); const l2 = node._store.get(laundered.key); assert.ok(!l2.cmb.metadata.lineage || (l2.cmb.metadata.lineage.parents || []).length === 0, 'laundered chain severed'); - assert.strictEqual(l2.cmb.provenance.tether.departedFrom, rootA.key); + assert.strictEqual(l2.provenance.tether.departedFrom, rootA.key); assert.ok(![...node._store._index.byAncestor.get(rootA.key) ?? []].includes(laundered.key), 'ancestor index no longer lists the severed remix'); const f2 = node._store.get(faithful.key); assert.ok(f2.cmb.metadata.lineage && f2.cmb.metadata.lineage.ancestors.includes(rootB.key), 'faithful chain untouched'); - assert.strictEqual(f2.cmb.tether.verdict, 'tethered'); + assert.strictEqual(f2.tether.verdict, 'tethered'); } finally { await node.stop(); fs.rmSync(nodeDir(name), { recursive: true, force: true }); @@ -120,8 +120,8 @@ describe('MMP §15.8 retroactive tether audit', () => { assert.strictEqual(r.fetched, 0, 'the local unverified copy is not a fetch'); assert.strictEqual(r.unchecked, 1, 'the tether is unverified'); const e = node._store.get(remix.key); - assert.strictEqual(e.cmb.tether, undefined, 'no tether attestation names the unverified record'); - assert.strictEqual(e.cmb.provenance?.tether, undefined); + assert.strictEqual(e.tether, undefined, 'no tether attestation names the unverified record'); + assert.strictEqual(e.provenance?.tether, undefined); } finally { await node.stop(); fs.rmSync(nodeDir(name), { recursive: true, force: true }); diff --git a/tests/lineage-tether-neural.test.js b/tests/lineage-tether-neural.test.js index c20a7e74..593e6cac 100644 --- a/tests/lineage-tether-neural.test.js +++ b/tests/lineage-tether-neural.test.js @@ -88,7 +88,7 @@ async function admitThrough(path, rootText, frameOf) { else await node._frameHandler._processHeuristicSVAF(frame, 'peerA', 'peerA', now, now, 0); const stored = node._store.get(frame.cmb.metadata.key); assert.ok(stored, `${path}: the remix is stored`); - const att = stored.cmb.tether; + const att = stored.tether; return { root, stored, att, attValid: att ? verifyTetherAttestation(att, node._identity.publicKey).valid : null, @@ -112,9 +112,9 @@ describe('MMP §15.8 lineage tether on the neural admission path', () => { assert.ok(stored, 'the neural evaluator admitted the remix'); assert.strictEqual(stored.svaf?.method, 'neural', 'precondition: the neural path stored it'); assert.strictEqual(stored.cmb.metadata.lineage, null, 'the laundered lineage is severed'); - assert.strictEqual(stored.cmb.provenance?.tether?.severed, true); - assert.strictEqual(stored.cmb.provenance.tether.departedFrom, root.metadata.key); - const att = stored.cmb.tether; + assert.strictEqual(stored.provenance?.tether?.severed, true); + assert.strictEqual(stored.provenance.tether.departedFrom, root.metadata.key); + const att = stored.tether; assert.ok(att, 'a tether attestation is attached'); assert.strictEqual(att.verdict, 'severed'); assert.strictEqual(att.anchor, root.metadata.key); @@ -139,7 +139,7 @@ describe('MMP §15.8 lineage tether on the neural admission path', () => { assert.strictEqual(neural.att.kernelId, heuristic.att.kernelId, 'one kernel, so the drifts are comparable'); assert.strictEqual(neural.att.drift.toFixed(6), heuristic.att.drift.toFixed(6), 'same drift: one computation'); const withoutDrift = ({ drift, ...rest }) => rest; // drift is compared above, to the precision it is signed at - assert.deepStrictEqual(withoutDrift(neural.stored.cmb.provenance.tether), withoutDrift(heuristic.stored.cmb.provenance.tether), 'same provenance'); + assert.deepStrictEqual(withoutDrift(neural.stored.provenance.tether), withoutDrift(heuristic.stored.provenance.tether), 'same provenance'); assert.deepStrictEqual(neural.stored.cmb.metadata.lineage, heuristic.stored.cmb.metadata.lineage, 'same lineage outcome'); assert.strictEqual(neural.att.verdict, rootText === TOPIC_A ? 'severed' : 'tethered'); }); @@ -152,7 +152,7 @@ describe('MMP §15.8 lineage tether on the neural admission path', () => { for (const path of ['neural', 'heuristic']) { const r = await admitThrough(path, TOPIC_B_ROOT, frameOf); assert.strictEqual(r.att, undefined, `${path}: no tether attestation is stored`); - assert.strictEqual(r.stored.cmb.provenance?.tether, undefined, `${path}: the tether is unverified, not evaluated`); + assert.strictEqual(r.stored.provenance?.tether, undefined, `${path}: the tether is unverified, not evaluated`); assert.deepStrictEqual(r.stored.cmb.metadata.lineage.parents, ['cmb-' + 'e'.repeat(64)], `${path}: lineage is kept`); } }); diff --git a/tests/lineage-tether-reproducible.test.js b/tests/lineage-tether-reproducible.test.js index a4d8e2c5..bcc371c6 100644 --- a/tests/lineage-tether-reproducible.test.js +++ b/tests/lineage-tether-reproducible.test.js @@ -108,9 +108,9 @@ describe('§15.8 tether is reproducible by any holder of the root and the record for (const t of memory) node.remember(cat7(t)); const stored = await admit(node, remixFrame(INCOMING, root.key)); assert.ok(stored, `${label}: the remix admits`); - assert.ok(stored.cmb.tether, `${label}: the integrator signed a tether`); - assert.strictEqual(stored.cmb.tether.anchor, root.key, `${label}: anchored on the shared root`); - seen.push({ label, att: stored.cmb.tether, record: stored.cmb, root: root.cmb, node }); + assert.ok(stored.tether, `${label}: the integrator signed a tether`); + assert.strictEqual(stored.tether.anchor, root.key, `${label}: anchored on the shared root`); + seen.push({ label, att: stored.tether, record: stored.cmb, root: root.cmb, node }); }); } const [a, b] = seen; @@ -210,9 +210,9 @@ describe('§15.8 cold-start admissions carry a tether like warm ones (B-L6)', () const peerRoot = createCMB({ categories: cat7(ROOT), createdBy: 'peerA' }); const stored = await admit(node, { type: 'cmb', timestamp: Date.now(), content: ROOT, cmb: peerRoot }); assert.ok(stored, `${warm ? 'warm' : 'cold'}: the root admits`); - assert.strictEqual(stored.cmb.tether?.anchor, peerRoot.metadata.key, `${warm ? 'warm' : 'cold'}: a root is its own anchor`); - assert.strictEqual(stored.cmb.tether.verdict, 'tethered'); - assert.strictEqual(stored.cmb.provenance?.tether?.severed, false); + assert.strictEqual(stored.tether?.anchor, peerRoot.metadata.key, `${warm ? 'warm' : 'cold'}: a root is its own anchor`); + assert.strictEqual(stored.tether.verdict, 'tethered'); + assert.strictEqual(stored.provenance?.tether?.severed, false); }); } }); diff --git a/tests/lineage-tether-severance.test.js b/tests/lineage-tether-severance.test.js index 47d1df76..d5759d4e 100644 --- a/tests/lineage-tether-severance.test.js +++ b/tests/lineage-tether-severance.test.js @@ -106,17 +106,18 @@ describe('MMP §15.8 lineage tether — severance through the gate', () => { await withNode('tether-sever', async (node) => { const { accepted } = await seedAndReceive(node, TOPIC_A, TOPIC_B_NEW); assert.strictEqual(accepted.length, 1, 'incoming admits (aligned with recent topic-B anchors)'); - const cmb = accepted[0].cmb; + const entry = accepted[0]; + const cmb = entry.cmb; assert.ok(isRootShaped(cmb.metadata.lineage), 'lineage severed — stored as a fresh root'); - assert.ok(cmb.provenance.tether, 'tether recorded in provenance'); - assert.strictEqual(cmb.provenance.tether.severed, true); - assert.ok(cmb.provenance.tether.drift > 0.5, `drift ${cmb.provenance.tether.drift} exceeds the reject floor`); - assert.ok(cmb.provenance.tether.departedFrom, 'departed source recorded informally'); + assert.ok(entry.provenance.tether, 'tether recorded in provenance'); + assert.strictEqual(entry.provenance.tether.severed, true); + assert.ok(entry.provenance.tether.drift > 0.5, `drift ${entry.provenance.tether.drift} exceeds the reject floor`); + assert.ok(entry.provenance.tether.departedFrom, 'departed source recorded informally'); // §15.8 tether attestation: the integrator's signed record of this exact // evaluation rides the remix, verifiable against the node's identity key, // and names the kernel the verdict was made in. - const att = cmb.tether; + const att = entry.tether; assert.ok(att, 'tether attestation attached'); assert.strictEqual(att.verdict, 'severed'); assert.strictEqual(att.of, cmb.metadata.key); @@ -130,12 +131,13 @@ describe('MMP §15.8 lineage tether — severance through the gate', () => { await withNode('tether-keep', async (node) => { const { root, accepted } = await seedAndReceive(node, TOPIC_B_ROOT, TOPIC_B_NEW); assert.strictEqual(accepted.length, 1, 'incoming admits'); - const cmb = accepted[0].cmb; + const entry = accepted[0]; + const cmb = entry.cmb; assert.ok(!isRootShaped(cmb.metadata.lineage), 'lineage intact'); assert.ok(cmb.metadata.lineage.ancestors.includes(root.key), 'chain still reaches its root'); - assert.strictEqual(cmb.provenance.tether.severed, false); - assert.ok(cmb.provenance.tether.drift <= 0.5); - assert.strictEqual(cmb.tether?.verdict, 'tethered', 'kept chains carry a tethered attestation'); + assert.strictEqual(entry.provenance.tether.severed, false); + assert.ok(entry.provenance.tether.drift <= 0.5); + assert.strictEqual(entry.tether?.verdict, 'tethered', 'kept chains carry a tethered attestation'); }); }); @@ -164,11 +166,11 @@ describe('MMP §15.8 lineage tether — severance through the gate', () => { assert.ok(remix, 'the remix admits'); assert.deepStrictEqual(remix.cmb.metadata.lineage.parents, [parent.metadata.key], 'lineage is kept either way'); if (verified) { - assert.strictEqual(remix.cmb.tether?.anchor, parent.metadata.key, 'a verified parent anchors the tether'); - assert.strictEqual(remix.cmb.tether.verdict, 'tethered'); + assert.strictEqual(remix.tether?.anchor, parent.metadata.key, 'a verified parent anchors the tether'); + assert.strictEqual(remix.tether.verdict, 'tethered'); } else { - assert.strictEqual(remix.cmb.tether, undefined, 'no tether attestation names an unverified record'); - assert.strictEqual(remix.cmb.provenance?.tether, undefined, 'the tether is unverified, not evaluated'); + assert.strictEqual(remix.tether, undefined, 'no tether attestation names an unverified record'); + assert.strictEqual(remix.provenance?.tether, undefined, 'the tether is unverified, not evaluated'); } }); }); diff --git a/tests/stored-entry-annotations.test.js b/tests/stored-entry-annotations.test.js new file mode 100644 index 00000000..5f410b34 --- /dev/null +++ b/tests/stored-entry-annotations.test.js @@ -0,0 +1,123 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME to a temp sandbox before lib/config loads + +/** + * What a node records ABOUT an admitted record lives on the store entry, beside the record. + * + * A two-section record has exactly `categories` and `metadata` (§8.8.1). The receive path added + * four more members to the stored record: `admission` (the admission attestation), `tether` (the + * §15.8 attestation), `provenance` (fusion evidence and the tether annotation) and `collapsed`. None + * is the author's, none is signed, and each made the stored record a shape no reader of the record + * format expects. They are entry members now, and files written before keep reading. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { SymNode } = require('../lib/node'); +const { NullDiscovery } = require('../lib/discovery'); +const { nodeDir } = require('../lib/config'); +const { tmpdir } = require('./_tmpdir'); +const { MemoryStore } = require('../lib/memory-store'); +const { createCMB, signCMB, verifyCMB, verifyAttestation, verifyTetherAttestation } = require('../lib/core'); + +const ALIGNED = { decision: 'aligned', total_drift: 0.1, category_drifts: { focus: 0.1 }, gate_values: { focus: 1 } }; + +function rawKeypair() { + const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', { + publicKeyEncoding: { type: 'spki', format: 'der' }, + privateKeyEncoding: { type: 'pkcs8', format: 'der' }, + }); + return { pub: publicKey.slice(-32).toString('base64url'), priv: privateKey.slice(-32).toString('base64url') }; +} +const AUTHOR = rawKeypair(); + +function cat7(t) { + return { + focus: t, issue: t, intent: t, motivation: t, commitment: t, + perspective: 'peerA', mood: { text: 'neutral', valence: 0, arousal: 0 }, + }; +} + +async function withNode(opts, fn) { + const name = `annotations-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node = new SymNode({ name, silent: true, discovery: new NullDiscovery(), ...opts }); + await node.start(); + try { + return await fn(node); + } finally { + await node.stop(); + fs.rmSync(nodeDir(name), { recursive: true, force: true }); + } +} + +describe('admission annotations live on the store entry', () => { + for (const path_ of ['heuristic', 'neural']) { + it(`${path_} path: the stored record is exactly {categories, metadata}; the entry carries the rest`, async () => { + const opts = path_ === 'neural' ? { svafEvaluator: { evaluate: async () => ALIGNED } } : {}; + await withNode(opts, async (node) => { + node._pinPeerKey('peerA', AUTHOR.pub); + const record = createCMB({ categories: cat7(`an observation admitted through the ${path_} path`), createdBy: 'peerA' }); + signCMB(record, AUTHOR.priv); + // A frame that also tries to supply the node's own annotations. + const frame = { + type: 'cmb', timestamp: Date.now(), cmb: JSON.parse(JSON.stringify(record)), + admission: { forged: true }, provenance: { forged: true }, tether: { forged: true }, collapsed: 'forged', svaf: { forged: true }, + }; + await node._frameHandler._handleMemoryShare('peerA', 'peerA', frame); + + const entry = node._store.get(record.metadata.key); + assert.ok(entry, 'admitted and stored'); + assert.strictEqual(entry.svaf?.method === 'neural', path_ === 'neural', 'precondition: the intended path stored it'); + assert.deepStrictEqual(Object.keys(entry.cmb).sort(), ['categories', 'metadata'], 'the record has exactly its two sections'); + assert.strictEqual(verifyCMB(entry.cmb, AUTHOR.pub).valid, true, 'and still verifies under its author\'s key'); + const onDisk = JSON.parse(fs.readFileSync(path.join(node._store._dir, `${entry.key}.json`), 'utf8')); + assert.deepStrictEqual(Object.keys(onDisk.cmb).sort(), ['categories', 'metadata'], 'on disk too'); + + assert.strictEqual(entry.admission?.of, record.metadata.key, 'the admission attestation is on the entry'); + assert.strictEqual(entry.admission.by, node.nodeId); + assert.deepStrictEqual(verifyAttestation(entry.admission, node._identity.publicKey), { signed: true, valid: true }); + assert.strictEqual(entry.tether?.anchor, record.metadata.key, 'the tether attestation is on the entry (a root is its own anchor)'); + assert.strictEqual(verifyTetherAttestation(entry.tether, node._identity.publicKey).valid, true); + assert.strictEqual(entry.provenance?.tether?.severed, false, 'the provenance is on the entry'); + assert.strictEqual(entry.provenance.forged, undefined, 'nothing the frame supplied reached it'); + assert.strictEqual(entry.collapsed, true, 'the record is the incoming block itself'); + if (path_ === 'heuristic') { + assert.strictEqual(typeof entry.provenance.totalDrift, 'number', 'the gate\'s evidence'); + assert.strictEqual(entry.svaf, undefined, 'no neural evidence a frame supplied'); + } + }); + }); + } + + it('a store file written before reads back with its annotations on the entry', async () => { + const dir = tmpdir('sym-annotations-legacy-'); + const record = createCMB({ categories: cat7('a record stored by an earlier version'), createdBy: 'peerA' }); + signCMB(record, AUTHOR.priv); + const admission = { of: record.metadata.key, by: 'n1', verdict: 'aligned' }; + const tether = { of: record.metadata.key, anchor: record.metadata.key, verdict: 'tethered', by: 'n1' }; + const provenance = { totalDrift: 0.1, tether: { severed: false } }; + // The shape the receive path used to persist. + const stale = { + key: record.metadata.key, content: 'x', peerId: 'peer', storedAt: Date.now(), lineage: { parents: [], ancestors: [], method: null }, + cmb: { ...record, admission, tether, provenance, collapsed: true }, + }; + fs.writeFileSync(path.join(dir, `${stale.key}.json`), JSON.stringify(stale, null, 2)); + + const syncStore = new MemoryStore(dir, 'receiver'); + const asyncStore = new MemoryStore(dir, 'receiver'); + await asyncStore.load(); + for (const [label, store] of [['first touch', syncStore], ['load()', asyncStore]]) { + const entry = store.get(stale.key); + assert.deepStrictEqual(Object.keys(entry.cmb).sort(), ['categories', 'metadata'], `${label}: the record has exactly its two sections`); + assert.strictEqual(verifyCMB(entry.cmb, AUTHOR.pub).valid, true, `${label}: and verifies`); + assert.deepStrictEqual( + { admission: entry.admission, tether: entry.tether, provenance: entry.provenance, collapsed: entry.collapsed }, + { admission, tether, provenance, collapsed: true }, + `${label}: every annotation is still there, on the entry`); + } + }); +}); From cee4808fca4b73ba429b3398a931d00d6de8969d Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 19:44:21 +0100 Subject: [PATCH 035/148] =?UTF-8?q?=C2=A715.8=20severance=20lives=20on=20t?= =?UTF-8?q?he=20entry;=20the=20author's=20record=20stays=20as=20signed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the gate admits a block unchanged, the record it stores is the author's signed block. Severance nulled that record's metadata.lineage, which the signature covers, so a severed record failed verification under its author's key. The retroactive audit did the same to stored records, which are immutable once created (§8). Severance is now kept where this node's judgements live: - MemoryStore stores the entry's lineage as a root marked severed (receiveFromPeer opts.severLineage for the gate; severLineage(key) for the audit, replacing unlinkLineage) and the index treats it as a root, including when rebuilt from disk; - isLineageSevered(entry) (lineage-tether) is honoured by every walk of stored lineage: the §15.8 anchor walk, the index build, the audit (which no longer re-audits a severed entry) and mesh-agent's remix-of-remix check, which now reads the store's lineage first; - _applyLineageTether edits a record only when it is the remix this node is minting right now (not collapsed, unsigned), which §15.8 says to mint as a root; otherwise it reports the severance for the store to record. The neural path also wrote a new key and lineage into the author's record when the carried address was not the content's own, keeping the author's name and a signature that no longer covered it. It now builds its record with the heuristic gate's buildFusedRecord (exported), so a non-collapsing block is minted as this node's own remix and the two paths cannot diverge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/core/lineage-tether.js | 22 +++- lib/core/svaf-baseline.js | 2 +- lib/frame-handler.js | 83 +++++++-------- lib/memory-store.js | 42 ++++++-- lib/mesh-agent.js | 4 +- lib/node.js | 13 +-- tests/lineage-severance-signed.test.js | 139 +++++++++++++++++++++++++ tests/lineage-tether-audit.test.js | 5 +- tests/lineage-tether-neural.test.js | 8 +- tests/lineage-tether-severance.test.js | 7 +- tests/mesh-agent.test.js | 24 +++++ tests/neural-admission-copy.test.js | 19 ++++ 12 files changed, 298 insertions(+), 70 deletions(-) create mode 100644 tests/lineage-severance-signed.test.js diff --git a/lib/core/lineage-tether.js b/lib/core/lineage-tether.js index 0332c7db..1341f906 100644 --- a/lib/core/lineage-tether.js +++ b/lib/core/lineage-tether.js @@ -102,6 +102,22 @@ function computeLineageTether({ remixCategories, anchorCategories, categoryWeigh * @param {string} key - the key the walk looked the entry up under. * @returns {boolean} */ +/** + * Whether this node severed a stored entry's lineage (§15.8). + * + * Severance is the receiver's judgement about a chain, so it is kept on the entry + * (`entry.lineage.severed`, written by MemoryStore) and never written into the record: the record + * is usually the author's signed block, whose signature covers its lineage. The record therefore + * still names its parents, and every walk of stored lineage must ask the entry before following + * them. + * + * @param {object} entry - a stored entry. + * @returns {boolean} + */ +function isLineageSevered(entry) { + return entry?.lineage?.severed === true; +} + function storedRecordVerifies(entry, key) { if (!entry || typeof entry !== 'object') return false; const cmb = entry.cmb; @@ -191,7 +207,9 @@ function resolveTetherAnchor(incomingCMB, getEntry) { if (t < bestTime || (t === bestTime && best && key < best.key)) { bestTime = t; best = { key, categories: cmb.categories, resolvedFromStore: true }; } - // Continue the VERIFIED walk from this stored record's own parents (never its wire ancestors). + // Continue the VERIFIED walk from this stored record's own parents (never its wire ancestors), + // unless this node severed them: the record still names them as signed, the entry says no. + if (isLineageSevered(entry)) continue; for (const pk of parentsOf(cmb)) if (!seen.has(pk)) frontier.push({ key: pk, depth: depth + 1 }); } if (best) return { ...best, complete }; @@ -289,4 +307,4 @@ async function tetherOfRecord(recordCategories, tetherAnchor, config) { return { anchorKey: tetherAnchor.key ?? null, ...ev }; } -module.exports = { computeLineageTether, resolveTetherAnchor, storedRecordVerifies, evaluateLineageTetherFromText, tetherOfRecord }; +module.exports = { computeLineageTether, resolveTetherAnchor, storedRecordVerifies, isLineageSevered, evaluateLineageTetherFromText, tetherOfRecord }; diff --git a/lib/core/svaf-baseline.js b/lib/core/svaf-baseline.js index 47e3cb41..8fd8a6dd 100644 --- a/lib/core/svaf-baseline.js +++ b/lib/core/svaf-baseline.js @@ -607,6 +607,6 @@ async function _processHeuristicSVAF(opts) { } module.exports = { - computeCategoryDrifts, processHeuristicSVAF, computeCategoryVerdicts, + computeCategoryDrifts, processHeuristicSVAF, computeCategoryVerdicts, buildFusedRecord, CATEGORY_VERDICT, DEFAULT_REDUNDANCY_THRESHOLD, }; diff --git a/lib/frame-handler.js b/lib/frame-handler.js index 09674cbb..e8c00630 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -24,7 +24,7 @@ const { assertRecordSize } = require('./core/cmb-encoder'); const fs = require('fs'); const path = require('path'); -const { encode, DIM, processHeuristicSVAF, computeCategoryVerdicts, decryptCategories, blockKeyV2, resolveTetherAnchor, tetherOfRecord, signTetherAttestation, verifyTetherAttestation, recomputeKey, classifyAddress, recordAsSigned } = require('./core'); +const { encode, DIM, processHeuristicSVAF, buildFusedRecord, computeCategoryVerdicts, decryptCategories, resolveTetherAnchor, tetherOfRecord, signTetherAttestation, verifyTetherAttestation, recomputeKey, classifyAddress, categoriesAsSigned } = require('./core'); /** Entry members this node writes about an admission, which a frame must never supply. */ const ENTRY_ANNOTATIONS = Object.freeze(['admission', 'tether', 'provenance', 'collapsed', 'svaf']); @@ -1086,15 +1086,30 @@ class FrameHandler { return; } - // Propagate lineage: fused CMB is a child of the incoming CMB const incomingKey = recordKey(msg.cmb) || msg.key; + // The record to store, built by the same function as the heuristic gate's (buildFusedRecord), + // never the caller's object and never an edit of the author's signed record. Neural evaluation + // keeps the incoming text, so under content-only addressing the record collapses onto the + // incoming block (§7.5 collapse-before-mint: cite, do not mint, claim no descent from oneself) + // and is a copy of the author's record as signed. Only when the carried address is not the + // content's own does this node mint a remix of its own citing it. This path used to write the + // new key and lineage into the author's record instead, keeping the author's name and a + // signature that no longer covered it. + const fused = msg.cmb?.categories && typeof msg.cmb.categories === 'object' + ? buildFusedRecord({ + categories: categoriesAsSigned(msg.cmb.categories), + createdBy: this._node.name, + parentKey: incomingKey || null, + parentLineage: recordLineage(msg.cmb), + parentRecord: msg.cmb, + parentCreatedBy: msg.cmb.createdBy, + method: 'svaf-neural', + }) + : null; const fusedEntry = { ...msg, - // A copy of the record as its author signed it, never the caller's object. Everything below - // writes to the record this node stores; on the incoming object it reached the caller's - // frame. The heuristic path stores a record built the same way (recordAsSigned). - cmb: msg.cmb ? recordAsSigned(msg.cmb) : msg.cmb, + ...(fused ? { key: fused.record.metadata.key, cmb: fused.record, collapsed: fused.collapsed || undefined } : {}), source: `${this._node.name}+${msg.source || peerName}`, storedAt: now, svaf: { @@ -1105,36 +1120,6 @@ class FrameHandler { gateValues: gate_values, }, }; - // §7.5 COLLAPSE-BEFORE-MINT [MUST] — the Rule A self-loop. - // - // This used to mint a remix key over categories + parents + the RECEIVER'S NAME, which - // guaranteed remix key != parent key by construction. Under content-only addressing that - // guarantee is gone and must be replaced by a check rather than assumed: a fusion that - // leaves the content unchanged (first-observation forwarding, a redundant re-assertion) - // now addresses to exactly the parent's key, so writing lineage.parents = [incomingKey] - // would produce the edge K -> K. A reachability walk never leaves it. - // - // The collapse property is what prevents it: the re-assertion MINTS NOTHING and cites the - // address that already exists. Rule A is sound under content-only addressing IFF collapse - // holds, so this is the place that has to hold it. - if (incomingKey && fusedEntry.cmb) { - const fusedKey = blockKeyV2(fusedEntry.cmb.categories); - if (fusedKey === incomingKey) { - // Nothing new was said. Cite, do not mint, and do not claim descent from oneself. - // The record IS the author's block: its own lineage and signature stay as signed - // (the heuristic path inherits the same way — see buildFusedRecord). - fusedEntry.cmb.metadata.key = incomingKey; - fusedEntry.key = incomingKey; - fusedEntry.collapsed = true; - } else { - fusedEntry.cmb.metadata.key = fusedKey; - fusedEntry.key = fusedKey; - fusedEntry.cmb.metadata.lineage = { - parents: [incomingKey], - method: 'svaf-neural', - }; - } - } // Admission Attestation (MMP) — the per-category gating verdict, signed + bound to // the gated CMB, persisted on the remix's store entry as the durable audit record. The neural // path emits per-category drift; map it to verdicts with the same thresholds (Phase A). @@ -1149,16 +1134,17 @@ class FrameHandler { } // Opaque payload rides alongside CAT7 — carry it onto the admitted remix. this._preserveIncomingPayload(fusedEntry, msg); + let severLineage = false; if (fusedEntry.cmb) { // The copy carries no attestation the sender attached: that one was an input, checked in // _prepareLineageTether. The record this node stores carries its own evaluation or none. const tether = await tetherOfRecord(fusedEntry.cmb.categories, tetherAnchor, this._admissionPolicy()); - this._applyLineageTether(fusedEntry, tether, now); + severLineage = this._applyLineageTether(fusedEntry, tether, now); } // The admitted-path surface is the store's own cmb-accepted emit. The store returns null when // it already holds the key ('redundant') or the write failed ('not-stored'), and a directed CMB // then reached nobody: surface it. Everything after this runs exactly as for any admit. - const storedNeural = this._node._store.receiveFromPeer(peerId, fusedEntry, { creatorRole: this._getCreatorRole(peerId, msg) }); + const storedNeural = this._node._store.receiveFromPeer(peerId, fusedEntry, { creatorRole: this._getCreatorRole(peerId, msg), severLineage }); if (!storedNeural && msg._directedToMe) { this._surfaceDirectedReject(msg, peerName, peerId, now, this._node._store.get(fusedEntry.key) ? 'redundant' : 'not-stored'); } @@ -1281,12 +1267,12 @@ class FrameHandler { // CMB from categories and drops it, so re-attach before storing the remix. this._preserveIncomingPayload(result.fusedEntry, msg); - this._applyLineageTether(result.fusedEntry, result.tether, now); + const severLineage = this._applyLineageTether(result.fusedEntry, result.tether, now); // The admitted-path surface is the store's own cmb-accepted emit. The store returns null when // it already holds the key ('redundant') or the write failed ('not-stored'), and a directed CMB // then reached nobody: surface it. Everything after this runs exactly as for any admit. - const storedHeuristic = this._node._store.receiveFromPeer(peerId, result.fusedEntry, { creatorRole: this._getCreatorRole(peerId, msg) }); + const storedHeuristic = this._node._store.receiveFromPeer(peerId, result.fusedEntry, { creatorRole: this._getCreatorRole(peerId, msg), severLineage }); if (!storedHeuristic && msg._directedToMe) { this._surfaceDirectedReject(msg, peerName, peerId, now, this._node._store.get(result.fusedEntry.key) ? 'redundant' : 'not-stored'); } @@ -1372,7 +1358,7 @@ class FrameHandler { * Severance: a remix drifted past the reject floor MUST NOT carry the chain's lineage (keeping it * would forge fidelity), so it is stored as a fresh root with the departed source recorded * informally in provenance. An unverifiable tether (checked=false) is a trust state, never - * severed. + * severed. Returns whether it severed, so the caller stores the entry as a root. * * Attestation: sign the exact evaluation performed — remix key, anchor, kernel identity, drift, * verdict — so a downstream receiver that cannot resolve the anchor holds @@ -1383,10 +1369,18 @@ class FrameHandler { */ _applyLineageTether(entry, tether, now) { const cmb = entry?.cmb; - if (!tether?.checked || !cmb) return; - if (!tether.tethered && recordLineage(cmb)) { + if (!tether?.checked || !cmb) return false; + let severed = false; + if (!tether.tethered && recordParents(cmb).length > 0) { const departedFrom = recordParents(cmb)[0] ?? null; - setRecordLineage(cmb, null); // fresh root — must clear the section the record ACTUALLY carries + // Severance never edits a record someone signed. When the gate stored the incoming block + // itself (collapsed) the record is its author's, and its signature covers its lineage: + // nulling it there made the stored record fail verification under the author's key. The + // caller stores that one as a root on the entry instead (receiveFromPeer severLineage) and + // the record keeps its parents as signed. A remix this node is minting right now is its own + // and not yet created, so it is simply minted as a root, as §15.8 says. + if (entry.collapsed !== true && !cmb.metadata?.sig) setRecordLineage(cmb, null); + severed = true; entry.provenance = { ...entry.provenance, tether: { severed: true, anchor: tether.anchorKey, kernelId: tether.kernelId, drift: tether.drift, departedFrom }, @@ -1414,6 +1408,7 @@ class FrameHandler { this._node._log(`[§15.8] tether attestation signing failed: ${e.message}`); } } + return severed; } /** diff --git a/lib/memory-store.js b/lib/memory-store.js index e8f400c6..8f720407 100644 --- a/lib/memory-store.js +++ b/lib/memory-store.js @@ -22,7 +22,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { ensureDir } = require('./config'); -const { roleRank } = require('./core'); +const { roleRank, isLineageSevered } = require('./core'); // ── In-Memory Index ────────────────────────────────────────── @@ -33,6 +33,14 @@ const { roleRank } = require('./core'); /** A stored record file: every .json in the store except the one-off migration marker. */ const isStoreFile = (f) => f.endsWith('.json') && f !== 'migration-v1.json'; +/** + * The store's lineage for an entry whose lineage this node severed (§15.8): a root, marked as + * severed so every walk of stored lineage knows not to follow the parents the record still names. + */ +function severedLineage() { + return { parents: [], ancestors: [], method: null, severed: true }; +} + /** * What this node records ABOUT a stored record, which lives on the entry beside the record: the * admission attestation, the §15.8 tether attestation, the fusion provenance, and whether the @@ -244,13 +252,15 @@ class MemoryStore { /** One index entry from one stored record — shared by both build paths so they cannot drift. */ _indexEntryFor(entry, filePath) { + // A severed entry is a root to this store although its record still names parents as signed. + const severed = isLineageSevered(entry); return { key: entry.key, storedAt: entry.storedAt || entry.timestamp || 0, createdBy: recordCreatedBy(entry.cmb) || entry.source || this._source, peerId: entry.peerId || null, - parents: entry.lineage?.parents || recordParents(entry.cmb), - ancestors: entry.lineage?.ancestors || recordLineage(entry.cmb)?.ancestors || [], + parents: severed ? [] : (entry.lineage?.parents || recordParents(entry.cmb)), + ancestors: severed ? [] : (entry.lineage?.ancestors || recordLineage(entry.cmb)?.ancestors || []), tier: entry.tier || 'hot', filePath, }; @@ -318,6 +328,8 @@ class MemoryStore { * @param {object} entry * @param {object} opts * @param {string} opts.creatorRole — lifecycle role of the creating node (participant/validator/anchor) + * @param {boolean} [opts.severLineage] — the gate severed this record's lineage (§15.8): store it as a + * root although its record, kept as signed, still names parents * @returns {object|null} stored entry or null if duplicate */ receiveFromPeer(peerId, entry, opts = {}) { @@ -327,7 +339,7 @@ class MemoryStore { // Dedup — same CMB from multiple peers stored once if (this._index.has(key)) return null; - const lineage = this._expandLineage(recordLineage(cmb)); + const lineage = opts.severLineage === true ? severedLineage() : this._expandLineage(recordLineage(cmb)); const now = Date.now(); // Section 6.4 + 11.1: validator/anchor-origin CMBs enter at weight 2.0. @@ -532,19 +544,27 @@ class MemoryStore { } /** - * Make a stored entry a root in the index after its lineage was severed (§15.8): drop it from the - * descendant set of every ancestor it was indexed under, and clear its own parents and ancestors. - * This is exactly what rebuilding the index from the severed entry produces, applied now rather - * than at the next start; until then the severed remix kept its old ancestors hot and unpurgeable. - * The caller clears the record's lineage and the entry's, and persists. + * Sever a stored entry's lineage after the fact (§15.8 retroactive audit), on the entry and in the + * index, never in the record. The record is immutable once stored (§8) and usually the author's + * signed block, whose signature covers its lineage, so it keeps naming its parents; the entry + * records that this store no longer walks them (isLineageSevered). + * + * The index is left exactly as a rebuild from the severed entry would leave it: the entry drops + * out of every ancestor's descendant set and has no parents or ancestors of its own. Until then + * a severed remix kept its old ancestors hot and unpurgeable. * @param {string} key + * @returns {boolean} true when the entry exists and was persisted */ - unlinkLineage(key) { + severLineage(key) { + const entry = this.get(key); const idx = this._index.get(key); - if (!idx) return; + if (!entry || !idx) return false; for (const a of idx.ancestors || []) this._index.byAncestor.get(a)?.delete(key); idx.parents = []; idx.ancestors = []; + entry.lineage = severedLineage(); + this._cache.set(key, entry); + return this._persist(entry); } // ── Search (user-facing) ───────────────────────────────── diff --git a/lib/mesh-agent.js b/lib/mesh-agent.js index 42ff5a9f..99b1a4a7 100644 --- a/lib/mesh-agent.js +++ b/lib/mesh-agent.js @@ -263,7 +263,9 @@ class MeshAgent { // A CMB with lineage parents is itself a remix — re-remixing it creates // cascade storms across agents. Store it (SVAF accepted it) but don't // spend an LLM call to remix it. - const lineage = recordLineage(entry.cmb) || entry.lineage; + // The store's lineage first: it is the record's own unless this node severed it (§15.8), and a + // severed record still names its parents as signed, though it is a root to this node. + const lineage = entry.lineage || recordLineage(entry.cmb); if (lineage?.parents?.length > 0) { console.log(`[${this._name}] accepted remix from ${source} — no re-remix`); return; diff --git a/lib/node.js b/lib/node.js index 95ed60db..43695179 100644 --- a/lib/node.js +++ b/lib/node.js @@ -1,6 +1,6 @@ 'use strict'; -const { recordKey, recordCreatedBy, recordCreatedAt, recordParents, setRecordLineage } = require('./record'); +const { recordKey, recordCreatedBy, recordCreatedAt, recordParents } = require('./record'); /** * SymNode — sovereign mesh node with cognitive coupling. @@ -700,7 +700,7 @@ class SymNode extends EventEmitter { * failedFloor:number, unchecked:number, fetched:number}>} */ async auditLineageTethers(opts = {}) { - const { resolveTetherAnchor, evaluateLineageTetherFromText, signTetherAttestation } = require('./core'); + const { resolveTetherAnchor, evaluateLineageTetherFromText, signTetherAttestation, isLineageSevered } = require('./core'); const report = { audited: 0, tethered: 0, severed: 0, failedFloor: 0, unchecked: 0, fetched: 0 }; if (typeof evaluateLineageTetherFromText !== 'function') return report; const keys = [...this._store._index.byKey.keys()]; @@ -708,7 +708,8 @@ class SymNode extends EventEmitter { const entry = this._store.get(key); if (!entry) continue; const cmb = entry.cmb; - const parents = recordParents(cmb); + // An entry this node already severed is a root to it, though its record still names parents. + const parents = isLineageSevered(entry) ? [] : recordParents(cmb); if (!cmb || !cmb.categories || parents.length === 0) continue; report.audited++; @@ -758,9 +759,9 @@ class SymNode extends EventEmitter { } else { report.failedFloor++; if (applySever) { - setRecordLineage(cmb, null); // clears the section the record ACTUALLY carries - entry.lineage = null; - this._store.unlinkLineage(entry.key); + // On the entry and in the index, never in the record: a stored record is immutable (§8), + // and this one is usually its author's signed block, whose signature covers its lineage. + this._store.severLineage(entry.key); report.severed++; this._log(`[§15.8 audit] lineage severed: ${String(entry.key).slice(0, 16)}… drift ${ev.drift.toFixed(3)} from anchor ${String(anchorKey).slice(0, 16)}…`); this.emit('metric', { type: 'lineage-tether-severed', key: entry.key, anchor: anchorKey, drift: ev.drift, audit: true }); diff --git a/tests/lineage-severance-signed.test.js b/tests/lineage-severance-signed.test.js new file mode 100644 index 00000000..851375b6 --- /dev/null +++ b/tests/lineage-severance-signed.test.js @@ -0,0 +1,139 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME to a temp sandbox before lib/config loads + +/** + * §15.8 severance never edits a signed record. + * + * When the gate admits a block unchanged, the record it stores IS the author's signed block (the + * collapse). Severance used to null that record's metadata.lineage, which the signature covers, so + * a severed record no longer verified under its author's key: the receiver's judgement about a + * chain was written into someone else's signed statement and broke it. The retroactive audit did + * the same to records already stored. + * + * Severance is this node's judgement, so it is kept where this node's judgements live: on the + * entry (entry.lineage.severed) and in the store's lineage index. The record stays exactly as + * signed, and every walk of stored lineage honours the entry: the index, the §15.8 anchor walk, + * and a rebuild of the index from disk. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const { SymNode } = require('../lib/node'); +const { NullDiscovery } = require('../lib/discovery'); +const { nodeDir } = require('../lib/config'); +const { MemoryStore } = require('../lib/memory-store'); +const { createCMB, signCMB, verifyCMB, isSemanticReady, resolveTetherAnchor } = require('../lib/core'); + +const ALIGNED = { decision: 'aligned', total_drift: 0.1, category_drifts: { focus: 0.1 }, gate_values: { focus: 1 } }; + +function rawKeypair() { + const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', { + publicKeyEncoding: { type: 'spki', format: 'der' }, + privateKeyEncoding: { type: 'pkcs8', format: 'der' }, + }); + return { pub: publicKey.slice(-32).toString('base64url'), priv: privateKey.slice(-32).toString('base64url') }; +} +const AUTHOR = rawKeypair(); + +// The reject floor is calibrated on the semantic kernel (§9.2.1), as in the other tether tests. +async function awaitSemantic(timeoutMs = 30000) { + const t0 = Date.now(); + while (!isSemanticReady()) { + if (Date.now() - t0 > timeoutMs) throw new Error('semantic encoder did not become ready'); + await new Promise((r) => setTimeout(r, 200)); + } +} + +function cat7(t) { + return { + focus: t, issue: t, intent: t, motivation: t, commitment: t, + perspective: 'peerA', mood: { text: 'neutral', valence: 0, arousal: 0 }, + }; +} + +const TOPIC_A = 'quarterly financial audit of the accounting ledger and tax filings'; +const TOPIC_B = [ + 'hiking trail conditions in the mountain snow this weekend', + 'which boots and crampons to pack for the icy mountain ascent', + 'trailhead parking permits and the shuttle bus timetable', + 'weather forecast apps for alpine ridge crossings', + 'campsite reservations near the mountain trailhead', +]; +const LAUNDERED = 'fresh snowfall reported on the upper mountain trail sections'; +const CITES_SEVERED = 'deep snow drifts covering the mountain hiking path near the summit ridge'; + +async function withNode(opts, fn) { + const name = `sever-signed-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node = new SymNode({ name, silent: true, discovery: new NullDiscovery(), ...opts }); + await node.start(); + try { + return await fn(node, name); + } finally { + await node.stop(); + fs.rmSync(nodeDir(name), { recursive: true, force: true }); + } +} + +/** A remix of `parentKey`, signed by AUTHOR. */ +function signedRemix(text, parentKey) { + const cmb = createCMB({ categories: cat7(text), createdBy: 'peerA', lineage: { parents: [parentKey], method: 'SVAF-v2' } }); + signCMB(cmb, AUTHOR.priv); + return cmb; +} + +/** The severance as every reader of stored lineage must see it, live and after a rebuild from disk. */ +function assertSevered(node, label, record, rootKey) { + const entry = node._store.get(record.metadata.key); + assert.ok(entry, `${label}: stored`); + assert.strictEqual(verifyCMB(entry.cmb, AUTHOR.pub).valid, true, `${label}: the stored record still verifies under its author's key`); + assert.deepStrictEqual(entry.cmb.metadata.lineage, record.metadata.lineage, `${label}: its lineage is exactly as signed`); + assert.strictEqual(entry.lineage?.severed, true, `${label}: the severance is on the entry`); + assert.strictEqual(entry.provenance?.tether?.severed, true, `${label}: and in its provenance`); + for (const [where, store] of [['live', node._store], ['rebuilt from disk', new MemoryStore(node._store._dir, 'reader')]]) { + assert.deepStrictEqual(store.parents(record.metadata.key), [], `${label} (${where}): no parents in the lineage index`); + assert.deepStrictEqual(store.ancestors(record.metadata.key), [], `${label} (${where}): no ancestors`); + assert.ok(!store.descendants(rootKey).includes(record.metadata.key), `${label} (${where}): no longer walked as the root's descendant`); + } + // The §15.8 walk from a block citing the severed one stops there: it does not reach the root + // through a lineage this node severed. + const next = signedRemix(CITES_SEVERED, record.metadata.key); + const anchor = resolveTetherAnchor(next, (k) => node._store.get(k)); + assert.strictEqual(anchor.key, record.metadata.key, `${label}: the anchor walk does not pass through the severance`); +} + +describe('§15.8 severance keeps the author\'s record as signed', () => { + for (const path of ['heuristic', 'neural']) { + it(`${path} gate: a severed collapsed record still verifies and is no longer a descendant`, async () => { + await awaitSemantic(); + const opts = path === 'neural' ? { svafEvaluator: { evaluate: async () => ALIGNED } } : {}; + await withNode(opts, async (node) => { + const root = node.remember(cat7(TOPIC_A)); + for (const t of TOPIC_B) node.remember(cat7(t)); // the recent window the heuristic gate admits against + const record = signedRemix(LAUNDERED, root.key); + const frame = { type: 'cmb', timestamp: Date.now(), content: LAUNDERED, cmb: JSON.parse(JSON.stringify(record)), _cmbVerified: true }; + const now = Date.now(); + if (path === 'neural') await node._frameHandler._processNeuralSVAF(ALIGNED, frame, 'peerA', 'peerA', now, now); + else await node._frameHandler._processHeuristicSVAF(frame, 'peerA', 'peerA', now, now, 0); + assert.strictEqual(node._store.get(record.metadata.key)?.collapsed, true, 'precondition: the stored record is the author\'s block'); + assertSevered(node, path, record, root.key); + }); + }); + } + + it('the retroactive audit severs on the entry too', async () => { + await awaitSemantic(); + await withNode({}, async (node) => { + const root = node.remember(cat7(TOPIC_A)); + const record = signedRemix(LAUNDERED, root.key); + node._store.receiveFromPeer('peerA', { key: record.metadata.key, content: LAUNDERED, source: 'peerA', cmb: JSON.parse(JSON.stringify(record)), _cmbVerified: true }); + const r1 = await node.auditLineageTethers({ sever: true }); + assert.strictEqual(r1.severed, 1, 'precondition: the audit severed it'); + assertSevered(node, 'audit', record, root.key); + const r2 = await node.auditLineageTethers({ sever: true }); + assert.strictEqual(r2.audited, 0, 'a severed record is a root to the audit: there is no chain left to check'); + }); + }); +}); diff --git a/tests/lineage-tether-audit.test.js b/tests/lineage-tether-audit.test.js index 0a2a5873..e5db3bbe 100644 --- a/tests/lineage-tether-audit.test.js +++ b/tests/lineage-tether-audit.test.js @@ -85,7 +85,10 @@ describe('MMP §15.8 retroactive tether audit', () => { const r2 = await node.auditLineageTethers({ sever: true }); assert.strictEqual(r2.severed, 1); const l2 = node._store.get(laundered.key); - assert.ok(!l2.cmb.metadata.lineage || (l2.cmb.metadata.lineage.parents || []).length === 0, 'laundered chain severed'); + // Severed on the entry and in the index; the stored record is never edited. + assert.strictEqual(l2.lineage.severed, true, 'laundered chain severed'); + assert.deepStrictEqual(node._store.parents(laundered.key), [], 'the store walks no parents from it'); + assert.deepStrictEqual(l2.cmb.metadata.lineage.parents, [rootA.key], 'the record\'s own lineage is untouched'); assert.strictEqual(l2.provenance.tether.departedFrom, rootA.key); assert.ok(![...node._store._index.byAncestor.get(rootA.key) ?? []].includes(laundered.key), 'ancestor index no longer lists the severed remix'); diff --git a/tests/lineage-tether-neural.test.js b/tests/lineage-tether-neural.test.js index 593e6cac..e1aeb5f4 100644 --- a/tests/lineage-tether-neural.test.js +++ b/tests/lineage-tether-neural.test.js @@ -111,7 +111,10 @@ describe('MMP §15.8 lineage tether on the neural admission path', () => { const stored = node._store.get(frame.cmb.metadata.key); assert.ok(stored, 'the neural evaluator admitted the remix'); assert.strictEqual(stored.svaf?.method, 'neural', 'precondition: the neural path stored it'); - assert.strictEqual(stored.cmb.metadata.lineage, null, 'the laundered lineage is severed'); + // Severed on the entry and in the index; the record is the author's and keeps its lineage. + assert.strictEqual(stored.lineage.severed, true, 'the laundered lineage is severed'); + assert.deepStrictEqual(node._store.parents(stored.key), [], 'the store walks no parents from it'); + assert.deepStrictEqual(stored.cmb.metadata.lineage, frame.cmb.metadata.lineage, 'the record\'s own lineage is untouched'); assert.strictEqual(stored.provenance?.tether?.severed, true); assert.strictEqual(stored.provenance.tether.departedFrom, root.metadata.key); const att = stored.tether; @@ -140,7 +143,8 @@ describe('MMP §15.8 lineage tether on the neural admission path', () => { assert.strictEqual(neural.att.drift.toFixed(6), heuristic.att.drift.toFixed(6), 'same drift: one computation'); const withoutDrift = ({ drift, ...rest }) => rest; // drift is compared above, to the precision it is signed at assert.deepStrictEqual(withoutDrift(neural.stored.provenance.tether), withoutDrift(heuristic.stored.provenance.tether), 'same provenance'); - assert.deepStrictEqual(neural.stored.cmb.metadata.lineage, heuristic.stored.cmb.metadata.lineage, 'same lineage outcome'); + assert.deepStrictEqual(neural.stored.cmb.metadata.lineage, heuristic.stored.cmb.metadata.lineage, 'same record lineage, as signed'); + assert.deepStrictEqual(neural.stored.lineage, heuristic.stored.lineage, 'same lineage in the store: severed or kept alike'); assert.strictEqual(neural.att.verdict, rootText === TOPIC_A ? 'severed' : 'tethered'); }); } diff --git a/tests/lineage-tether-severance.test.js b/tests/lineage-tether-severance.test.js index d5759d4e..9cf190fb 100644 --- a/tests/lineage-tether-severance.test.js +++ b/tests/lineage-tether-severance.test.js @@ -76,7 +76,7 @@ const TOPIC_B_NEW = 'fresh snowfall reported on the upper mountain trail section // admit against the recents — near-duplicating one would be redundant-banned). const TOPIC_B_ROOT = 'overall report on snowy mountain hiking trail conditions this weekend'; -// The store normalizes a severed (null) lineage to root shape. +// A record's lineage with no parents: a root. function isRootShaped(lineage) { return lineage == null || ((lineage.parents ?? []).length === 0 && (lineage.ancestors ?? []).length === 0); @@ -108,7 +108,10 @@ describe('MMP §15.8 lineage tether — severance through the gate', () => { assert.strictEqual(accepted.length, 1, 'incoming admits (aligned with recent topic-B anchors)'); const entry = accepted[0]; const cmb = entry.cmb; - assert.ok(isRootShaped(cmb.metadata.lineage), 'lineage severed — stored as a fresh root'); + // Severed on the entry and in the index; the record is the author's and keeps its lineage. + assert.strictEqual(entry.lineage.severed, true, 'lineage severed — stored as a fresh root'); + assert.deepStrictEqual(node._store.parents(entry.key), [], 'the store walks no parents from it'); + assert.ok(!isRootShaped(cmb.metadata.lineage), 'the record\'s own lineage is untouched'); assert.ok(entry.provenance.tether, 'tether recorded in provenance'); assert.strictEqual(entry.provenance.tether.severed, true); assert.ok(entry.provenance.tether.drift > 0.5, `drift ${entry.provenance.tether.drift} exceeds the reject floor`); diff --git a/tests/mesh-agent.test.js b/tests/mesh-agent.test.js index 8ad02f03..67991673 100644 --- a/tests/mesh-agent.test.js +++ b/tests/mesh-agent.test.js @@ -124,4 +124,28 @@ describe('MeshAgent', () => { assert.strictEqual(agent._shouldRemix({ source: 'special' }), true); assert.strictEqual(agent._shouldRemix({ source: 'other' }), false); }); + + // §15.8 severance lives on the store entry: the record keeps the parents its author signed, but + // this node stored it as a root. Remix-of-remix suppression must read the entry, or a severed + // block is never remixed, though to this node it is an original observation. + it('remixes a block whose lineage this node severed, as it does any root', async () => { + const remixed = []; + const agent = new MeshAgent({ + name: 'sever-aware', + fetchDomain: async () => null, + reason: async () => null, + remix: async (incoming) => { remixed.push(incoming.key); return null; }, + }); + agent._node._hasNewDomainData = true; + const { createCMB } = require('../lib/core'); + const cmb = createCMB({ + categories: { focus: 'a block whose chain was severed', issue: 'i', intent: 'n', motivation: 'm', commitment: 'c', perspective: 'p', mood: 'neutral' }, + createdBy: 'other-agent', lineage: { parents: ['cmb-' + 'a'.repeat(64)], method: 'SVAF-v2' }, + }); + const severed = { key: cmb.metadata.key, source: 'other-agent', cmb, lineage: { parents: [], ancestors: [], method: null, severed: true } }; + const kept = { ...severed, key: 'kept', lineage: { parents: ['cmb-' + 'a'.repeat(64)], ancestors: [], method: 'SVAF-v2' } }; + await agent._onCMBAccepted(kept); + await agent._onCMBAccepted(severed); + assert.deepStrictEqual(remixed, [cmb.metadata.key], 'the kept remix is not re-remixed; the severed one is a root'); + }); }); diff --git a/tests/neural-admission-copy.test.js b/tests/neural-admission-copy.test.js index 356dea29..4b995deb 100644 --- a/tests/neural-admission-copy.test.js +++ b/tests/neural-admission-copy.test.js @@ -66,4 +66,23 @@ describe('neural admission works on a copy of the incoming record', () => { } }); }); + + it('a record that does not collapse is minted as this node\'s own remix, never rewritten under its author\'s name', async () => { + // The path used to write the new key and lineage into the author's record and store that: a + // record naming the author, carrying the author's signature, over content and descent the author + // never signed. It is minted the way the heuristic gate mints one (buildFusedRecord). + await withNode(async (node) => { + const record = createCMB({ categories: cat7('a record whose carried address is not its own content'), createdBy: 'peerA' }); + record.metadata.key = 'cmb-' + '8'.repeat(64); + record.metadata.sig = 'not-a-signature-over-anything-this-node-will-store'; + const now = Date.now(); + await node._frameHandler._processNeuralSVAF(ALIGNED, { type: 'cmb', timestamp: now, content: 'x', cmb: record }, 'peerA', 'peerA', now, now); + const minted = node._store.recall('').find((e) => e.cmb?.categories?.focus?.text === record.categories.focus.text); + assert.ok(minted, 'stored'); + assert.strictEqual(minted.cmb.metadata.createdBy, node.name, 'this node authors the remix it mints'); + assert.strictEqual(minted.cmb.metadata.sig, undefined, 'and carries no signature it did not make'); + assert.deepStrictEqual(minted.cmb.metadata.lineage.parents, [record.metadata.key], 'citing the block it came from'); + assert.notStrictEqual(minted.collapsed, true); + }); + }); }); From 2651cf3bcc2b87e94aa3914932f0cc7a3d63ecf9 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 19:48:14 +0100 Subject: [PATCH 036/148] frame-handler: drop two imports nothing uses (DIM, recomputeKey) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/frame-handler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/frame-handler.js b/lib/frame-handler.js index e8c00630..98e98f33 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -24,7 +24,7 @@ const { assertRecordSize } = require('./core/cmb-encoder'); const fs = require('fs'); const path = require('path'); -const { encode, DIM, processHeuristicSVAF, buildFusedRecord, computeCategoryVerdicts, decryptCategories, resolveTetherAnchor, tetherOfRecord, signTetherAttestation, verifyTetherAttestation, recomputeKey, classifyAddress, categoriesAsSigned } = require('./core'); +const { encode, processHeuristicSVAF, buildFusedRecord, computeCategoryVerdicts, decryptCategories, resolveTetherAnchor, tetherOfRecord, signTetherAttestation, verifyTetherAttestation, classifyAddress, categoriesAsSigned } = require('./core'); /** Entry members this node writes about an admission, which a frame must never supply. */ const ENTRY_ANNOTATIONS = Object.freeze(['admission', 'tether', 'provenance', 'collapsed', 'svaf']); From d806b1b0b4ced097129a31872540fa8f22b9182f Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 19:51:35 +0100 Subject: [PATCH 037/148] deps: floors past the vulnerable versions; tests await the encoder instead of a guessed timeout - npm audit is clean. ws ^8.20.2 (two high advisories below it), @huggingface/transformers ^4.3.0, and the overrides for sharp (^0.35.4) and adm-zip (^0.6.1). The overrides only apply when sym is the root project, so the floors are what protect a consumer's install. - context-encoder: semanticSettled() resolves when the semantic encoder is ready (true) or has failed (false), and a second loadSemanticEncoder() call no longer starts a second pipeline while the first loads. The six tests that need the semantic kernel await it through one helper (tests/_semantic.js) instead of polling for 30 s, which the full suite outlasted when several test processes loaded the model at once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/core/context-encoder.js | 23 +- package-lock.json | 430 ++++++++++------------ package.json | 8 +- tests/_semantic.js | 15 + tests/lineage-severance-signed.test.js | 11 +- tests/lineage-tether-audit.test.js | 12 +- tests/lineage-tether-neural.test.js | 11 +- tests/lineage-tether-reproducible.test.js | 11 +- tests/lineage-tether-severance.test.js | 11 +- tests/svaf-anchor-weight.test.js | 11 +- 10 files changed, 237 insertions(+), 306 deletions(-) create mode 100644 tests/_semantic.js diff --git a/lib/core/context-encoder.js b/lib/core/context-encoder.js index 162039c9..46bbb659 100644 --- a/lib/core/context-encoder.js +++ b/lib/core/context-encoder.js @@ -95,29 +95,46 @@ let _semanticEncoder = null; let _semanticDim = null; let _semanticReady = false; let _semanticFailed = false; +/** The one load in flight, so a second call cannot start a second pipeline while the first loads. */ +let _semanticLoading = null; /** * Load the semantic encoder. Non-blocking — returns immediately. * Call once at startup. encode() uses semantic once loaded. */ function loadSemanticEncoder() { - if (_semanticReady || _semanticFailed) return; + if (_semanticReady || _semanticFailed || _semanticLoading) return; try { const { pipeline } = require('@huggingface/transformers'); - pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2').then(pipe => { + _semanticLoading = pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2').then(pipe => { _semanticEncoder = pipe; _semanticDim = 192; // 384 / 2 _semanticReady = true; console.log('[encoder] Semantic encoder ready (all-MiniLM-L6-v2)'); + return true; }).catch(err => { _semanticFailed = true; console.warn(`[encoder] Semantic encoder failed: ${err.message?.slice(0, 60)}`); + return false; }); } catch { _semanticFailed = true; } } +/** + * Resolves once the semantic encoder has settled: true when it is ready, false when it failed or + * cannot be loaded here (the lexical encoder then stays in use). For callers that need the + * semantic kernel, instead of polling isSemanticReady() against a guessed timeout. + * @returns {Promise} + */ +function semanticSettled() { + if (_semanticReady) return Promise.resolve(true); + if (_semanticFailed) return Promise.resolve(false); + loadSemanticEncoder(); + return _semanticLoading || Promise.resolve(_semanticReady); +} + /** * Encode text with a given semantic pipeline (async). The pipeline and its dimensionality are * arguments rather than module state so a pinned kernel (svafKernel) encodes with exactly the @@ -200,4 +217,4 @@ function kernelId() { // Auto-load semantic encoder at import time loadSemanticEncoder(); -module.exports = { encode, encodeForSVAF, isSemanticReady, kernelId, svafKernel, DIM }; +module.exports = { encode, encodeForSVAF, isSemanticReady, semanticSettled, kernelId, svafKernel, DIM }; diff --git a/package-lock.json b/package-lock.json index 290f3a74..395ae9a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,17 +10,17 @@ "license": "Apache-2.0", "dependencies": { "bonjour-service": "^1.3.0", - "ws": "^8.20.0" + "ws": "^8.20.2" }, "bin": { "sym": "bin/sym.js", "sym-daemon": "bin/sym-daemon.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { - "@huggingface/transformers": "^4.0.1" + "@huggingface/transformers": "^4.3.0" } }, "node_modules/@emnapi/runtime": { @@ -34,9 +34,9 @@ } }, "node_modules/@huggingface/jinja": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz", - "integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==", + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.10.tgz", + "integrity": "sha512-SgS1D1bglQ94ceD4ZCL6eayUDy9uV1xuyk61OjgSxU5GJh7upqZCKII0JoTYMc6wWsg3JUgaPRX0ElQzXPk3Cw==", "license": "MIT", "optional": true, "engines": { @@ -44,24 +44,24 @@ } }, "node_modules/@huggingface/tokenizers": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz", - "integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.2.0.tgz", + "integrity": "sha512-LidMHe1FpcSYH4vcSjXooda34pC0M8m1gmDOv7SQi6Iv+ib1zX7J3sHBCC9/3FPCkn2k1b55FneVqMRxrr99pg==", "license": "Apache-2.0", "optional": true }, "node_modules/@huggingface/transformers": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz", - "integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.3.0.tgz", + "integrity": "sha512-fL1A/WUZwouPrOlYxU5dzIwD2T5J781JiB2jDR8bFe5DwCj0Gfudq+NEXCMno49kQgajHA7xQkrRLJlqG1veEA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@huggingface/jinja": "^0.5.6", - "@huggingface/tokenizers": "^0.1.3", - "onnxruntime-node": "1.24.3", - "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", - "sharp": "^0.34.5" + "@huggingface/jinja": "^0.5.10", + "@huggingface/tokenizers": "^0.2.0", + "onnxruntime-node": "1.30.0", + "onnxruntime-web": "1.31.0-dev.20260914-8d85527a0", + "sharp": "^0.35.4" } }, "node_modules/@img/colour": { @@ -75,9 +75,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -93,13 +93,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -115,20 +115,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -138,9 +138,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -154,9 +154,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -170,9 +170,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], @@ -186,9 +186,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], @@ -202,9 +202,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], @@ -218,9 +218,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], @@ -234,9 +234,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -250,9 +250,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -266,9 +266,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -282,9 +282,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -298,9 +298,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -316,13 +316,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -338,13 +338,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -360,13 +360,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -382,13 +382,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -404,13 +404,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -426,13 +426,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -448,13 +448,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -470,17 +470,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -490,16 +490,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -509,9 +509,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -528,9 +528,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -547,9 +547,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -638,19 +638,19 @@ "optional": true }, "node_modules/@types/node": { - "version": "26.1.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", - "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", "license": "MIT", "optional": true, "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~8.9.0" } }, "node_modules/adm-zip": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", - "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz", + "integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==", "license": "MIT", "optional": true, "engines": { @@ -667,14 +667,6 @@ "multicast-dns": "^7.2.5" } }, - "node_modules/boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT", - "optional": true - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -721,13 +713,6 @@ "node": ">=8" } }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "license": "MIT", - "optional": true - }, "node_modules/dns-packet": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", @@ -760,13 +745,6 @@ "node": ">= 0.4" } }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "license": "MIT", - "optional": true - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -794,18 +772,16 @@ "optional": true }, "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz", + "integrity": "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==", "license": "BSD-3-Clause", "optional": true, "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" + "globalthis": "^1.0.2", + "matcher": "^4.0.0", + "semver": "^7.3.5", + "serialize-error": "^8.1.0" }, "engines": { "node": ">=10.0" @@ -861,13 +837,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC", - "optional": true - }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -876,9 +845,9 @@ "optional": true }, "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz", + "integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==", "license": "MIT", "optional": true, "dependencies": { @@ -886,6 +855,9 @@ }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/multicast-dns": { @@ -912,16 +884,16 @@ } }, "node_modules/onnxruntime-common": { - "version": "1.24.3", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", - "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.30.0.tgz", + "integrity": "sha512-7fdVWjAID1dVhH/G8qK3APARunV4VkBFoCQAP7qp4Wkab0mrorvmc+sqiT+mKXOzDqdjN5j+/Z9nb4gzNPWcyA==", "license": "MIT", "optional": true }, "node_modules/onnxruntime-node": { - "version": "1.24.3", - "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", - "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.30.0.tgz", + "integrity": "sha512-twhs1C2C/BFkz1yc5OY0KIU2GUq6DURO7hD4bx5Q2Qy3nAMJwRXW8xU3NVczE29VA9lolLOYepoD8fjTGOfIqw==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -931,30 +903,30 @@ "linux" ], "dependencies": { - "adm-zip": "^0.5.16", - "global-agent": "^3.0.0", - "onnxruntime-common": "1.24.3" + "adm-zip": "^0.6.0", + "global-agent": "^4.1.3", + "onnxruntime-common": "1.30.0" } }, "node_modules/onnxruntime-web": { - "version": "1.26.0-dev.20260416-b7804b056c", - "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz", - "integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==", + "version": "1.31.0-dev.20260914-8d85527a0", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.31.0-dev.20260914-8d85527a0.tgz", + "integrity": "sha512-Iy7rtadoBgxS/LLvDr3QW38DB1PNXRnr0GJMcL0TAt7c9qjgVQl83UlGCVyeAnK2InpmW8Uc3PL8XIuqtDeF6g==", "license": "MIT", "optional": true, "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", - "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", + "onnxruntime-common": "1.31.0-dev.20260911-2a43ec07e", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { - "version": "1.24.0-dev.20251116-b39e144322", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz", - "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==", + "version": "1.31.0-dev.20260911-2a43ec07e", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.31.0-dev.20260911-2a43ec07e.tgz", + "integrity": "sha512-gBuF6U32YErKIAt+yD7DeGBpjRxhJ1uJwako6ygjokSZULDU6Pd+KWComX8Tumk1hYVHRxjPj7mdMnD0ryAPOw==", "license": "MIT", "optional": true }, @@ -966,9 +938,9 @@ "optional": true }, "node_modules/protobufjs": { - "version": "7.6.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", - "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", "hasInstallScript": true, "license": "BSD-3-Clause", "optional": true, @@ -989,24 +961,6 @@ "node": ">=12.0.0" } }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -1020,21 +974,14 @@ "node": ">=10" } }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "license": "MIT", - "optional": true - }, "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", + "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", "license": "MIT", "optional": true, "dependencies": { - "type-fest": "^0.13.1" + "type-fest": "^0.20.2" }, "engines": { "node": ">=10" @@ -1044,9 +991,9 @@ } }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1061,31 +1008,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -1093,13 +1040,6 @@ } } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause", - "optional": true - }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", @@ -1114,9 +1054,9 @@ "optional": true }, "node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "license": "(MIT OR CC0-1.0)", "optional": true, "engines": { @@ -1127,9 +1067,9 @@ } }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "license": "MIT", "optional": true }, diff --git a/package.json b/package.json index b3107237..13b6f3cf 100644 --- a/package.json +++ b/package.json @@ -34,11 +34,11 @@ }, "dependencies": { "bonjour-service": "^1.3.0", - "ws": "^8.20.0" + "ws": "^8.20.2" }, "overrides": { - "sharp": "^0.35.0", - "adm-zip": "^0.6.0" + "sharp": "^0.35.4", + "adm-zip": "^0.6.1" }, "files": [ "bin/", @@ -49,6 +49,6 @@ "README.md" ], "optionalDependencies": { - "@huggingface/transformers": "^4.0.1" + "@huggingface/transformers": "^4.3.0" } } diff --git a/tests/_semantic.js b/tests/_semantic.js new file mode 100644 index 00000000..72432954 --- /dev/null +++ b/tests/_semantic.js @@ -0,0 +1,15 @@ +'use strict'; + +/** + * Wait for the semantic encoder (the kernel the §9.2.1 reject floor is calibrated on). It awaits the + * encoder's own load instead of polling against a fixed timeout: the full suite loads the model in + * several test processes at once, and a guessed 30 s ran out under that contention. The test's own + * timeout is the only bound. + */ +const { semanticSettled } = require('../lib/core/context-encoder'); + +async function awaitSemantic() { + if (!(await semanticSettled())) throw new Error('the semantic encoder failed to load (see the [encoder] line above)'); +} + +module.exports = { awaitSemantic }; diff --git a/tests/lineage-severance-signed.test.js b/tests/lineage-severance-signed.test.js index 851375b6..e868cc11 100644 --- a/tests/lineage-severance-signed.test.js +++ b/tests/lineage-severance-signed.test.js @@ -25,7 +25,7 @@ const { SymNode } = require('../lib/node'); const { NullDiscovery } = require('../lib/discovery'); const { nodeDir } = require('../lib/config'); const { MemoryStore } = require('../lib/memory-store'); -const { createCMB, signCMB, verifyCMB, isSemanticReady, resolveTetherAnchor } = require('../lib/core'); +const { createCMB, signCMB, verifyCMB, resolveTetherAnchor } = require('../lib/core'); const ALIGNED = { decision: 'aligned', total_drift: 0.1, category_drifts: { focus: 0.1 }, gate_values: { focus: 1 } }; @@ -38,14 +38,7 @@ function rawKeypair() { } const AUTHOR = rawKeypair(); -// The reject floor is calibrated on the semantic kernel (§9.2.1), as in the other tether tests. -async function awaitSemantic(timeoutMs = 30000) { - const t0 = Date.now(); - while (!isSemanticReady()) { - if (Date.now() - t0 > timeoutMs) throw new Error('semantic encoder did not become ready'); - await new Promise((r) => setTimeout(r, 200)); - } -} +const { awaitSemantic } = require('./_semantic'); function cat7(t) { return { diff --git a/tests/lineage-tether-audit.test.js b/tests/lineage-tether-audit.test.js index e5db3bbe..d9ecca85 100644 --- a/tests/lineage-tether-audit.test.js +++ b/tests/lineage-tether-audit.test.js @@ -15,15 +15,9 @@ const fs = require('fs'); const { SymNode } = require('../lib/node'); const { NullDiscovery } = require('../lib/discovery'); const { nodeDir } = require('../lib/config'); -const { createCMB, isSemanticReady, verifyTetherAttestation } = require('../lib/core'); - -async function awaitSemantic(timeoutMs = 30000) { - const t0 = Date.now(); - while (!isSemanticReady()) { - if (Date.now() - t0 > timeoutMs) throw new Error('semantic encoder did not become ready'); - await new Promise((r) => setTimeout(r, 200)); - } -} +const { createCMB, verifyTetherAttestation } = require('../lib/core'); + +const { awaitSemantic } = require('./_semantic'); function cat7(t) { return { diff --git a/tests/lineage-tether-neural.test.js b/tests/lineage-tether-neural.test.js index e1aeb5f4..0885521e 100644 --- a/tests/lineage-tether-neural.test.js +++ b/tests/lineage-tether-neural.test.js @@ -21,18 +21,11 @@ const fs = require('fs'); const { SymNode } = require('../lib/node'); const { NullDiscovery } = require('../lib/discovery'); const { nodeDir } = require('../lib/config'); -const { createCMB, isSemanticReady, verifyTetherAttestation } = require('../lib/core'); +const { createCMB, verifyTetherAttestation } = require('../lib/core'); const ALIGNED = { decision: 'aligned', total_drift: 0.1, category_drifts: { focus: 0.1 }, gate_values: { focus: 1 } }; -// The reject floor is calibrated on the semantic kernel (§9.2.1), as in the other tether tests. -async function awaitSemantic(timeoutMs = 30000) { - const t0 = Date.now(); - while (!isSemanticReady()) { - if (Date.now() - t0 > timeoutMs) throw new Error('semantic encoder did not become ready'); - await new Promise((r) => setTimeout(r, 200)); - } -} +const { awaitSemantic } = require('./_semantic'); function cat7(t) { return { diff --git a/tests/lineage-tether-reproducible.test.js b/tests/lineage-tether-reproducible.test.js index bcc371c6..b22410a2 100644 --- a/tests/lineage-tether-reproducible.test.js +++ b/tests/lineage-tether-reproducible.test.js @@ -27,17 +27,10 @@ const { SymNode } = require('../lib/node'); const { NullDiscovery } = require('../lib/discovery'); const { nodeDir } = require('../lib/config'); const core = require('../lib/core'); -const { createCMB, isSemanticReady, evaluateLineageTetherFromText, processHeuristicSVAF } = core; +const { createCMB, evaluateLineageTetherFromText, processHeuristicSVAF } = core; // The reject-floor calibration assumes the semantic kernel (§9.2.1: thresholds are meaningful only -// within a pinned encoder), and a comparison across two nodes is only meaningful in one kernel. -async function awaitSemantic(timeoutMs = 30000) { - const t0 = Date.now(); - while (!isSemanticReady()) { - if (Date.now() - t0 > timeoutMs) throw new Error('semantic encoder did not become ready'); - await new Promise((r) => setTimeout(r, 200)); - } -} +const { awaitSemantic } = require('./_semantic'); async function withNode(baseName, fn) { const name = `${baseName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; diff --git a/tests/lineage-tether-severance.test.js b/tests/lineage-tether-severance.test.js index 9cf190fb..655e7f98 100644 --- a/tests/lineage-tether-severance.test.js +++ b/tests/lineage-tether-severance.test.js @@ -25,19 +25,12 @@ const fs = require('fs'); const { SymNode } = require('../lib/node'); const { NullDiscovery } = require('../lib/discovery'); const { nodeDir } = require('../lib/config'); -const { createCMB, isSemanticReady, verifyTetherAttestation, kernelId } = require('../lib/core'); +const { createCMB, verifyTetherAttestation, kernelId } = require('../lib/core'); // The tether's reject-floor calibration assumes the semantic kernel (the // production default — §9.2.1: thresholds are meaningful only within a pinned // encoder). The encoder loads async at module require; wait for it so the -// test exercises the deployed configuration, not the n-gram warmup fallback. -async function awaitSemantic(timeoutMs = 30000) { - const t0 = Date.now(); - while (!isSemanticReady()) { - if (Date.now() - t0 > timeoutMs) throw new Error('semantic encoder did not become ready'); - await new Promise((r) => setTimeout(r, 200)); - } -} +const { awaitSemantic } = require('./_semantic'); async function withNode(baseName, fn) { const name = `${baseName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; diff --git a/tests/svaf-anchor-weight.test.js b/tests/svaf-anchor-weight.test.js index 449fbfdf..af262595 100644 --- a/tests/svaf-anchor-weight.test.js +++ b/tests/svaf-anchor-weight.test.js @@ -16,20 +16,13 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const { tmpdir } = require('./_tmpdir'); const { MemoryStore } = require('../lib/memory-store'); -const { createCMB, processHeuristicSVAF, isSemanticReady } = require('../lib/core'); +const { createCMB, processHeuristicSVAF } = require('../lib/core'); const TAU = 3600; const POLICY = { stableThreshold: 0.35, guardedThreshold: 0.75, temporalLambda: 0.3, freshnessSeconds: TAU }; // One kernel for every gate the test compares (§9.2.1: drifts compare only within a pinned -// encoder), and the one a node runs once its semantic encoder has loaded. -async function awaitSemantic(timeoutMs = 30000) { - const t0 = Date.now(); - while (!isSemanticReady()) { - if (Date.now() - t0 > timeoutMs) throw new Error('semantic encoder did not become ready'); - await new Promise((r) => setTimeout(r, 200)); - } -} +const { awaitSemantic } = require('./_semantic'); function cat7(t) { return { From c5cdcbd6a5d66eb5e2c6be27900d64a0cd23ab41 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 19:51:56 +0100 Subject: [PATCH 038/148] CHANGELOG: the store, SVAF anchor decay and dependency changes for 0.13.15 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd728c58..bad03abd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,48 @@ debt, and the daemon's log flood. Every item has a test that fails without its f - **Cold-start admissions had no tether (B-L6).** - The tether audit's fetch fallback no longer returns a local record the walk had refused. +### Changed — read these before upgrading + +- **A stored record is exactly what its author signed.** The store used to add members to a + two-section record: `admission`, `tether`, `provenance` and `collapsed`, plus an expanded + top-level `lineage`. The record now stays `{categories, metadata}` as signed (§8.8.1), and those + annotations live on the store **entry**. Stored files from earlier releases are read as before and + moved on first touch. **Code outside sym that reads `entry.cmb.admission`, `.tether`, + `.provenance` or `.collapsed`, from stored entries or from `cmb-accepted` / `memory-received` + events, must read them from the entry.** xmesh 0.10.11 reads both places. +- **SVAF anchors decay with age (§9.2.1).** The gate treated every stored anchor as fresh at full + weight, because the anchor view carried neither the entry's age nor its weight. Anchors now carry + both: `storedAt`, and the store's §6.4 `anchorWeight` (2.0 once validated, 0.5 once dismissed, never + the sender's unsigned confidence). At the default freshness of 1800 s, an anchor older than about + nine hours carries no weight. **A node whose recent memory is all older than that admits the next + block under the empty-memory rule**, as §9.2.1 specifies. + +### Fixed — SVAF and the store + +- Anchor vectors are measured through a side map and are never written onto stored records, where a + later save persisted them. +- A vector is cached under the kernel that produced it. When the semantic encoder finished loading + during a gate, lexical vectors were cached under the semantic key. +- The neural admission path applies the §15.8 tether exactly as the heuristic path does, through the + same helpers. It stores a copy, never the caller's record. When a carried address did not match + the content, it now mints this node's own remix, as the heuristic path does; before, it rewrote the + author's record under the author's name with a signature that no longer covered it. +- §15.8 severance no longer edits a signed record (that broke the author's signature). Severance is + recorded on the entry and in the lineage index, and every reader of stored lineage honours it: + the anchor walk, an index rebuild from disk, the tether audit, and mesh-agent's remix check. +- The tether audit reads the store's lineage, not an `ancestors` list a sender chose, and never + fetches a local record the walk had refused. +- A frame cannot supply `admission`, `tether`, `provenance`, `collapsed` or `svaf` that this node + would then store as its own. +- The semantic encoder no longer starts a second model load while the first is loading. + `semanticSettled()` resolves when it is ready or has failed. + +### Fixed — dependencies + +- `npm audit` is clean. Floors are raised past the vulnerable versions, because `overrides` do not + reach a consumer's install: `ws` `^8.20.2`, `@huggingface/transformers` `^4.3.0`, and `sharp` and + `adm-zip` as overrides. + ### Fixed — the daemon - **The log flood.** Every peer re-sends its whole wake-channel list on every connect, and the @@ -99,6 +141,7 @@ debt, and the daemon's log flood. Every item has a test that fails without its f test on every platform. The lease test uses a live foreign holder with a real start time; it never reached the start-time check before, on any OS. The relay-only test uses the platform's IPC endpoint. - Admission-as-collapse (B-L3) is pinned by a test: the conformance boundary of spec PR #17. +- Tests wait on what they test instead of fixed sleeps: in-flight frames, and the encoder's own load. ## 0.13.14 (2026-10-01) From 212ea2b2e693b528a4e3d2f8a8f294e21f0eb9cc Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 20:22:01 +0100 Subject: [PATCH 039/148] =?UTF-8?q?comments:=20the=20anti-echo=20rule=20is?= =?UTF-8?q?=20MMP=20v2.0=20=C2=A715.7,=20not=20=C2=A714?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/frame-handler.js | 6 +++--- lib/memory-store.js | 2 +- lib/node.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/frame-handler.js b/lib/frame-handler.js index 98e98f33..954257d1 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -17,7 +17,7 @@ const { assertRecordSize } = require('./core/cmb-encoder'); * * See MMP v0.2.0 Section 9: Coupling & SVAF. * See MMP v0.2.0 Section 12: xMesh (Layer 6). - * See MMP v0.2.0 Section 14: Echo loop prevention. + * Echo loop prevention: the anti-echo rule of MMP v2.0 §15.7 (it was Section 14 in v0.2.0). * * Copyright (c) 2026 SYM.BOT. Apache 2.0 License. */ @@ -99,7 +99,7 @@ class FrameHandler { this._inFlightKeys = new Set(); // Persist the dedup cache across plugin reloads / process restarts. Without this, // a reload wipes the cache and every already-processed CMB re-surfaces as new — a - // primary trigger of the cross-node echo storm (MMP §14). Best-effort: any FS error + // primary trigger of the cross-node echo storm (MMP §15.7, anti-echo). Best-effort: any FS error // degrades silently to in-memory-only. Stored as a dotfile WITHOUT a .json extension // so the store's own *.json scan never mistakes it for a CMB entry. this._seenKeysPath = null; @@ -937,7 +937,7 @@ class FrameHandler { // it once the CMB has actually been delivered. msg._incomingKey = incomingKey || null; - // Echo loop prevention (MMP Section 14): if the incoming CMB's + // Echo loop prevention (MMP §15.7, anti-echo): if the incoming CMB's // lineage parents include a key that exists in our local cmbs, // this CMB is a derivative of our own broadcast. Skip all // processing — including mood delivery — to prevent ping-pong diff --git a/lib/memory-store.js b/lib/memory-store.js index 8f720407..79dd2a2a 100644 --- a/lib/memory-store.js +++ b/lib/memory-store.js @@ -609,7 +609,7 @@ class MemoryStore { /** * Check if a CMB key exists in local entries (authored by this node). - * Used for echo loop prevention (MMP Section 14): incoming CMBs whose + * Used for echo loop prevention (MMP §15.7, anti-echo): incoming CMBs whose * lineage parents exist in our local store are derivatives of our own * broadcasts and should be silently dropped. * diff --git a/lib/node.js b/lib/node.js index 43695179..4ce0bb20 100644 --- a/lib/node.js +++ b/lib/node.js @@ -3049,7 +3049,7 @@ class SymNode extends EventEmitter { const sharedSecret = this._peerSharedSecrets.get(peer.peerId); // Anchors must be this node's OWN recent emissions (peerId == null). Forwarding // CMBs we merely received from other peers re-gossips them across the mesh - // (A->B->C->A) and is the amplifier behind cross-node echo storms (MMP §14): a + // (A->B->C->A) and is the amplifier behind cross-node echo storms (MMP §15.7, anti-echo): a // peer learns each node's state from that node directly, so own-origin anchors // are sufficient context. Pull a wider window, then keep the 5 most-recent own. const recentAnchors = this._store.recent(50).filter(a => a.cmb && a.peerId == null).slice(0, 5); From 1b2779f40a3c97ba2289fe4b4d034692b74f1acd Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 20:28:27 +0100 Subject: [PATCH 040/148] docs: a handshake-learned key binding is trusted on first use, not authenticated Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- docs/DESIGN-room-join-authorization.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/DESIGN-room-join-authorization.md b/docs/DESIGN-room-join-authorization.md index 3afa4a98..5af2317b 100644 --- a/docs/DESIGN-room-join-authorization.md +++ b/docs/DESIGN-room-join-authorization.md @@ -68,8 +68,9 @@ B cannot mint an invite for any room xmesh actually creates. ## What already exists to build on (read, not imagined) -- **Ed25519 node identity** and **roster-keys** (`lib/roster-keys.js`): authenticated - nodeId→publicKey bindings with source precedence — anchor (2) > handshake (1) > grant (0); +- **Ed25519 node identity** and **roster-keys** (`lib/roster-keys.js`): nodeId→publicKey + bindings (a handshake-learned key is trusted on first use until Core Secure proves possession) + with source precedence — anchor (2) > handshake (1) > grant (0); a gossiped grant can never overwrite a handshake-learned key; the relayer never vouches. - **Signed grants** along the rooted authority chain (`lib/role-grant-store.js`): a grant binds the grantee's key into the grantor's signed payload — tamper-evident vouching that From fd76924f988161c9112364ab05c5340b2f2184fa Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 20:55:06 +0100 Subject: [PATCH 041/148] F1: a malformed v2.0 frame is refused, never thrown; a frame that throws is dropped, not fatal assertionIdV2_0 throws on a record that declares the v2.0 suite but has no preimage (no room, no key, ...). It ran outside any try, and on the relay path nothing above it caught the throw, so one unsigned frame from an admitted peer ended the daemon. The derived id is now null for such a record (a mismatch, rejected with its metric), and FrameHandler.handle contains any throw from frame handling: the frame is dropped with a log line and a frame-handler-error metric. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/frame-handler.js | 19 +++++++++++++++++- tests/v2-author-binding.test.js | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/lib/frame-handler.js b/lib/frame-handler.js index 954257d1..8dec25fa 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -205,6 +205,18 @@ class FrameHandler { return; } } + // One frame must never end the node. The TCP parser catches a throw from here, but the relay + // path calls this from a WebSocket handler with nothing above it, so a throw there was an + // uncaught exception and the daemon exited. A frame that throws is dropped and said so. + try { + this._dispatch(peerId, peerName, msg); + } catch (err) { + this._node._log(`Frame '${msg && msg.type}' from ${peerName} dropped: ${err && err.message ? err.message : err}`); + this._node.emit('metric', { type: 'frame-handler-error', from: peerName, frameType: msg && msg.type, error: String(err && err.message ? err.message : err) }); + } + } + + _dispatch(peerId, peerName, msg) { switch (msg.type) { case 'handshake': this._handleHandshake(peerId, peerName, msg); @@ -475,7 +487,12 @@ class FrameHandler { // No key for the author yet is key-distribution latency, not forgery: unverified, as for v1. if (!authorKey) { msg._cmbVerified = false; return false; } const v2 = verifyCMB(cmb, authorKey); - const idOk = md.assertionId === undefined || md.assertionId === assertionIdV2_0(cmb); + // A record that declares the v2.0 suite but is malformed for it (no room, no key, …) has no + // preimage: assertionIdV2_0 throws. That is a mismatch to reject, never an exception: on the + // relay path nothing above this catches it, and one such frame ended the daemon. + let derivedId = null; + try { derivedId = assertionIdV2_0(cmb); } catch { /* not a well-formed v2.0 record */ } + const idOk = md.assertionId === undefined || md.assertionId === derivedId; if (v2.valid && idOk) { msg._cmbVerified = true; msg._verifiedAuthorNodeId = md.createdByNodeId; return false; } const reason = !v2.valid ? (v2.error || 'invalid') : 'assertion-id-mismatch'; const k = recordKey(cmb); diff --git a/tests/v2-author-binding.test.js b/tests/v2-author-binding.test.js index a469e7f0..c6e77780 100644 --- a/tests/v2-author-binding.test.js +++ b/tests/v2-author-binding.test.js @@ -112,3 +112,38 @@ describe('the inbox names a proven author (K5)', () => { } finally { await node.stop(); fs.rmSync(nodeDir(name), { recursive: true, force: true }); } }); }); + +describe('a malformed v2.0 frame is refused, never thrown (0.13.15 review F1)', () => { + // Declares the v2.0 suite but carries no room and no signature: it has no preimage, and building + // one throws. On the relay path that throw reached the process and ended the daemon. + function malformed() { + const cmb = createCMB({ categories: { focus: 'x' }, createdBy: 'alice', emitV2: true, createdByNodeId: 'node-alice', room: 'default' }); + cmb.metadata.assertionId = 'asrt-0'; + delete cmb.metadata.room; + return cmb; + } + + it('the signature check rejects it as a mismatch', () => { + withNode((node) => { + const metrics = []; + node.on('metric', (m) => metrics.push(m)); + const msg = { cmb: malformed() }; + assert.doesNotThrow(() => assert.strictEqual(node._frameHandler._rejectOnBadSignature('node-alice', 'alice', msg), true)); + assert.ok(metrics.some((m) => m.type === 'cmb-signature-rejected'), 'rejected through the ordinary path, with its metric'); + }); + }); + + it('a frame that throws anywhere in handling is dropped and counted, and the node goes on', () => { + withNode((node) => { + node._roomDoor = () => ({ pass: true }); + const metrics = []; + node.on('metric', (m) => metrics.push(m)); + assert.doesNotThrow(() => node._frameHandler.handle('node-alice', 'alice', { type: 'cmb', cmb: malformed() })); + const original = node._frameHandler._handleMemoryShare; + node._frameHandler._handleMemoryShare = () => { throw new Error('boom'); }; + assert.doesNotThrow(() => node._frameHandler.handle('node-alice', 'alice', { type: 'cmb', cmb: malformed() })); + node._frameHandler._handleMemoryShare = original; + assert.ok(metrics.some((m) => m.type === 'frame-handler-error' && /boom/.test(m.error))); + }); + }); +}); From a3eebdeea574a193559f04118ba24d2b109edaa1 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 20:56:24 +0100 Subject: [PATCH 042/148] F2, F3: a pre-0.13.15 wake channel is not repointed by gossip; the wake-channel map and each peer-info are bounded A channel loaded from a 0.13.14 file has no source and ranked below gossip, so any room peer could repoint a phone's token, the case this release says is closed. It now ranks with the relay: gossip cannot replace it, the relay and the phone can. The map holds at most 1024 channels, making room by dropping the weakest-sourced, oldest sighting and never a stronger source for a weaker one, and one peer-info frame is read for at most 256 entries. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/core/wake.js | 39 ++++++++++++++++++++++++++++---- lib/frame-handler.js | 7 +++++- tests/wake-channels.test.js | 45 +++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/lib/core/wake.js b/lib/core/wake.js index fe3ff1c1..a6796a42 100644 --- a/lib/core/wake.js +++ b/lib/core/wake.js @@ -31,8 +31,14 @@ const WAKE_CHANNEL_TTL_MS = 30 * 24 * 60 * 60 * 1000; /** A newer sighting of the same channel is worth persisting once it moves lastSeen by this much. */ const REFRESH_GRANULARITY_MS = 60 * 60 * 1000; -/** How far each source of a wake channel is trusted. Gossip is unauthenticated. */ -const SOURCE_RANK = Object.freeze({ legacy: 0, gossip: 1, relay: 2, direct: 3 }); +/** How far each source of a wake channel is trusted. Gossip is unauthenticated. A channel kept by + * a release before 0.13.15 carries no source: it may have come from the phone itself, so gossip + * must not repoint it, while the relay and the phone still can. */ +const SOURCE_RANK = Object.freeze({ gossip: 1, legacy: 2, relay: 2, direct: 3 }); + +/** At most this many wake channels are kept. Gossip names nodeIds nothing vouches for, so without a + * bound a peer could grow the map, its file and every peer-info this node sends. */ +const WAKE_CHANNELS_MAX = 1024; /** * Manages wake channel persistence and peer wake notifications. @@ -66,6 +72,7 @@ class WakeManager { this._getIdentity = opts.getIdentity; this._nodeName = opts.nodeName; this._ttlMs = opts.wakeChannelTtlMs ?? WAKE_CHANNEL_TTL_MS; + this._maxChannels = opts.maxWakeChannels ?? WAKE_CHANNELS_MAX; this._now = opts.now || Date.now; this._apnsConfig = null; @@ -129,7 +136,11 @@ class WakeManager { if (now - seen > this._ttlMs) return 'ignored'; const next = { platform: channel.platform, token: channel.token, environment: channel.environment, source, lastSeen: seen }; const prev = this._peerWakeChannels.get(nodeId); - if (!prev) { this._peerWakeChannels.set(nodeId, next); return 'added'; } + if (!prev) { + if (this._peerWakeChannels.size >= this._maxChannels && !this._evictFor(source)) return 'ignored'; + this._peerWakeChannels.set(nodeId, next); + return 'added'; + } const sameToken = prev.platform === next.platform && prev.token === next.token && prev.environment === next.environment; if (sameToken) { // The same channel again: keep the stronger source and the later sighting. @@ -149,6 +160,26 @@ class WakeManager { return 'updated'; } + /** + * Make room for one channel from `source`: drop the weakest-sourced channel, the oldest sighting + * among those, but never one from a stronger source than the newcomer's. So fabricated gossip + * only ever displaces other gossip. + * @private + * @returns {boolean} whether there is room now + */ + _evictFor(source) { + const rank = SOURCE_RANK[source] ?? 0; + let victim = null; + for (const [id, ch] of this._peerWakeChannels) { + const r = SOURCE_RANK[ch.source] ?? 0; + const seen = ch.lastSeen ?? 0; + if (!victim || r < victim.r || (r === victim.r && seen < victim.seen)) victim = { id, r, seen }; + } + if (!victim || victim.r > rank) return false; + this._peerWakeChannels.delete(victim.id); + return true; + } + /** * Drop channels nobody has seen for longer than the TTL. * @param {{save?: boolean}} [opts] @@ -397,4 +428,4 @@ class WakeManager { } } -module.exports = { WakeManager, WAKE_CHANNEL_TTL_MS }; +module.exports = { WakeManager, WAKE_CHANNEL_TTL_MS, WAKE_CHANNELS_MAX }; diff --git a/lib/frame-handler.js b/lib/frame-handler.js index 8dec25fa..1c60318b 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -58,6 +58,8 @@ function signatureMark(sig) { const SEEN_CMB_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days const SEEN_CMB_MAX = 10000; +/** The most `peer-info` entries read from one frame. */ +const PEER_INFO_MAX = 256; /** * Processes inbound peer frames for a SymNode. @@ -1588,7 +1590,10 @@ class FrameHandler { // filled the daemon log at a flapping peer's reconnect rate. const wm = this._node._wakeManager; let learned = 0, changed = 0; - for (const p of msg.peers) { + // A room's peer list is short; a frame naming thousands of nodeIds is not one. Only the first + // PEER_INFO_MAX are read. + if (msg.peers.length > PEER_INFO_MAX) this._node._log(`Gossip from ${peerName}: ${msg.peers.length} entries, reading the first ${PEER_INFO_MAX}`); + for (const p of msg.peers.slice(0, PEER_INFO_MAX)) { if (!p || !p.nodeId || !p.wakeChannel || p.nodeId === this._node._identity.nodeId) continue; const r = wm.learnWakeChannel(p.nodeId, p.wakeChannel, { source: 'gossip', lastSeen: p.lastSeen }); if (r === 'added' || r === 'updated') learned++; diff --git a/tests/wake-channels.test.js b/tests/wake-channels.test.js index 20ddc51f..21cd7711 100644 --- a/tests/wake-channels.test.js +++ b/tests/wake-channels.test.js @@ -159,3 +159,48 @@ describe('relay peer list', () => { }); }); }); + +describe('0.13.15 review F2/F3', () => { + it('a channel kept by 0.13.14 (no source) is not repointed by gossip, and the relay or the phone still can', () => { + withNode((node) => { + const wm = node._wakeManager; + fs.mkdirSync(require('path').dirname(wm._wakeChannelsFile), { recursive: true }); + fs.writeFileSync(wm._wakeChannelsFile, JSON.stringify({ 'phone-1': apns('phones-own') })); + wm.loadWakeChannels(); + node._frameHandler.handle('peer-x', 'peer-x', { type: 'peer-info', peers: [{ nodeId: 'phone-1', wakeChannel: apns('ATTACKER'), lastSeen: Date.now() }] }); + assert.strictEqual(wm._peerWakeChannels.get('phone-1').token, 'phones-own', 'gossip cannot repoint it'); + assert.strictEqual(wm.learnWakeChannel('phone-1', apns('re-registered'), { source: 'relay' }), 'updated', 'the relay can'); + assert.strictEqual(wm.learnWakeChannel('phone-1', apns('again'), { source: 'direct' }), 'updated', 'the phone can'); + }); + }); + + it('fabricated gossip is bounded: one frame reads at most 256 entries, and the map holds at most its cap', () => { + withNode((node, io) => { + const wm = node._wakeManager; + wm._maxChannels = 300; + wm.learnWakeChannel('real-phone', apns('own'), { source: 'direct' }); + const now = Date.now(); + const flood = (from) => ({ type: 'peer-info', peers: Array.from({ length: 2000 }, (_, i) => ({ nodeId: `fake-${from}-${i}`, wakeChannel: apns(`f${i}`), lastSeen: now })) }); + node._frameHandler.handle('peer-x', 'peer-x', flood('a')); + assert.strictEqual(wm._peerWakeChannels.size, 1 + 256, 'the first 256 entries of one frame'); + assert.ok(io.lines.some((l) => /2000 entries, reading the first 256/.test(l))); + node._frameHandler.handle('peer-x', 'peer-x', flood('b')); + assert.strictEqual(wm._peerWakeChannels.size, 300, 'never past the cap'); + assert.strictEqual(wm._peerWakeChannels.get('real-phone').token, 'own', 'gossip only displaces gossip'); + }); + }); + + it('a full map of first-hand channels is not displaced by gossip, and a new first-hand one displaces the oldest weakest', () => { + withNode(() => { + const { WakeManager } = require('../lib/core/wake'); + let t = 1_000_000; + const wm = new WakeManager({ wakeChannelsFile: require('path').join(require('os').tmpdir(), `wc-${process.pid}-${Date.now()}.json`), peerWakeChannels: new Map(), peerLastWake: new Map(), pendingFrames: new Map(), maxWakeChannels: 3, now: () => t, log: () => {} }); + wm.learnWakeChannel('p1', apns('1'), { source: 'direct' }); t += 1000; + wm.learnWakeChannel('p2', apns('2'), { source: 'relay' }); t += 1000; + wm.learnWakeChannel('p3', apns('3'), { source: 'direct' }); t += 1000; + assert.strictEqual(wm.learnWakeChannel('g1', apns('g'), { source: 'gossip', lastSeen: t }), 'ignored'); + assert.strictEqual(wm.learnWakeChannel('p4', apns('4'), { source: 'direct' }), 'added'); + assert.deepStrictEqual([...wm._peerWakeChannels.keys()].sort(), ['p1', 'p3', 'p4'], 'the relay-sourced one went: the weakest'); + }); + }); +}); From 80388722ed8fb2c994fa9262371e676a0d2f08ac Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 20:56:40 +0100 Subject: [PATCH 043/148] test: the legacy-channel case pins a later clock, so it fails on the old ranking Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- tests/wake-channels.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/wake-channels.test.js b/tests/wake-channels.test.js index 21cd7711..5dd4e059 100644 --- a/tests/wake-channels.test.js +++ b/tests/wake-channels.test.js @@ -167,7 +167,10 @@ describe('0.13.15 review F2/F3', () => { fs.mkdirSync(require('path').dirname(wm._wakeChannelsFile), { recursive: true }); fs.writeFileSync(wm._wakeChannelsFile, JSON.stringify({ 'phone-1': apns('phones-own') })); wm.loadWakeChannels(); - node._frameHandler.handle('peer-x', 'peer-x', { type: 'peer-info', peers: [{ nodeId: 'phone-1', wakeChannel: apns('ATTACKER'), lastSeen: Date.now() }] }); + // A minute later, so the gossip is strictly newer than the load (the old ranking let it through). + const later = Date.now() + 60_000; + wm._now = () => later; + node._frameHandler.handle('peer-x', 'peer-x', { type: 'peer-info', peers: [{ nodeId: 'phone-1', wakeChannel: apns('ATTACKER'), lastSeen: later }] }); assert.strictEqual(wm._peerWakeChannels.get('phone-1').token, 'phones-own', 'gossip cannot repoint it'); assert.strictEqual(wm.learnWakeChannel('phone-1', apns('re-registered'), { source: 'relay' }), 'updated', 'the relay can'); assert.strictEqual(wm.learnWakeChannel('phone-1', apns('again'), { source: 'direct' }), 'updated', 'the phone can'); From 045a00cc644e08adf3c3e9eeae779562bff2a699 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 20:57:01 +0100 Subject: [PATCH 044/148] F4: inbox().from is a display label; authorize on author.nodeId Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/node.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/node.js b/lib/node.js index 4ce0bb20..366a8121 100644 --- a/lib/node.js +++ b/lib/node.js @@ -1782,9 +1782,10 @@ class SymNode extends EventEmitter { this._inbox.push({ seq, id, - // Who can be held to it: the author when its signature proved who that is, otherwise the peer - // that delivered it. The unproven claim stays in author.name, so `from` is safe to - // authorize on. (It used to be the author's label whether or not anything proved it.) + // A display label: the author's when its signature proved who wrote it, otherwise the peer that + // delivered it. (It used to be the author's label whether or not anything proved it.) A label + // is not unique — two node ids can each sign createdBy "alice" — so authorize on + // author.nodeId, the proven identity, never on `from`. from: (entry.author?.nodeId && entry.author?.name) || entry.author?.via?.name || recordCreatedBy(cmb) || entry.source || 'unknown', From a3b96fbbf8bcde9ba169c18e3c25245853e9c63f Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 20:59:02 +0100 Subject: [PATCH 045/148] F5: the CLI's pid file, room, relay.env and nodes path follow SYM_STATE_DIR The daemon was re-rooted in this release but the CLI was not: two SYM_STATE_DIR deployments sharing a home shared ~/.sym/daemon.pid, so `sym stop` for one SIGTERMed the other, and a room or relay set for a rooted daemon was written to ~/.sym, where it never reads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- bin/sym.js | 25 +++++++++++++++---------- tests/state-root-tenancy.test.js | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/bin/sym.js b/bin/sym.js index fa0a2450..0743025c 100755 --- a/bin/sym.js +++ b/bin/sym.js @@ -38,13 +38,18 @@ const { recordCreatedBy } = require('../lib/record'); const net = require('net'); const fs = require('fs'); const path = require('path'); -const os = require('os'); const { execSync, spawn } = require('child_process'); const { getSocketPath, getLogDir } = require('../lib/platform'); const { isValidRoom, roomServiceType } = require('../lib/rooms'); -const ROOM_FILE = path.join(os.homedir(), '.sym', 'room'); -const PID_FILE = path.join(os.homedir(), '.sym', 'daemon.pid'); +// The CLI's state lives where its daemon's does (SYM_STATE_DIR, default ~/.sym): rooted in the +// home, two SYM_STATE_DIR deployments shared one pid file (`sym stop` for one stopped the other), +// and a room or relay set for a rooted daemon went to a file it never reads. +const { symPath } = require('../lib/core/state-root'); +const { NODES_DIR } = require('../lib/config'); +const ROOM_FILE = symPath('room'); +const PID_FILE = symPath('daemon.pid'); +const RELAY_ENV = symPath('relay.env'); // Portable synchronous sleep — no shell dependency (`sleep` is POSIX-only and // absent on Windows). Used between daemon stop/start on a room switch. @@ -139,9 +144,9 @@ function readRoom() { } function persistRelay(url, token, relayOnly) { - const dir = path.join(os.homedir(), '.sym'); + const dir = path.dirname(RELAY_ENV); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - const f = path.join(dir, 'relay.env'); + const f = RELAY_ENV; const kv = {}; try { for (const line of fs.readFileSync(f, 'utf8').split('\n')) { @@ -192,7 +197,7 @@ function applyStartFlags() { function readRelayEnv() { const kv = {}; try { - for (const line of fs.readFileSync(path.join(os.homedir(), '.sym', 'relay.env'), 'utf8').split('\n')) { + for (const line of fs.readFileSync(RELAY_ENV, 'utf8').split('\n')) { const m = line.match(/^([A-Z_]+)=(.*)$/); if (m) kv[m[1]] = m[2]; } @@ -504,10 +509,10 @@ function cmdPublish() { async function standaloneObserve(categories, opts) { const { SymNode } = require('..'); - // Load relay credentials from ~/.sym/relay.env if the env vars are + // Load relay credentials from the state root's relay.env if the env vars are // not already present. Same pattern as MeshAgent (sym/lib/mesh-agent.js:160). if (!process.env.SYM_RELAY_URL || !process.env.SYM_RELAY_TOKEN) { - const envFile = path.join(os.homedir(), '.sym', 'relay.env'); + const envFile = RELAY_ENV; if (fs.existsSync(envFile)) { for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) { const m = line.match(/^(\w+)=(.*)$/); @@ -636,7 +641,7 @@ function cmdRecall() { } const query = recallArgs.join(' ').toLowerCase(); - const nodesDir = path.join(os.homedir(), '.sym', 'nodes'); + const nodesDir = NODES_DIR; if (!fs.existsSync(nodesDir)) { console.log('No memories found.'); return; @@ -820,7 +825,7 @@ function broadcastQuestion(question) { * most recent memories when nothing matches, so `ask` always has context. */ function gatherMeshMemory(question, limit) { - const nodesDir = path.join(os.homedir(), '.sym', 'nodes'); + const nodesDir = NODES_DIR; if (!fs.existsSync(nodesDir)) return []; const words = question.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2); diff --git a/tests/state-root-tenancy.test.js b/tests/state-root-tenancy.test.js index 5bb7b7c9..b9551a01 100644 --- a/tests/state-root-tenancy.test.js +++ b/tests/state-root-tenancy.test.js @@ -84,3 +84,22 @@ test('and the SAME root still refuses a second holder — the single-writer rule assert.equal(out, 'REFUSED EIDENTITYLOCK', 'rooting the tree must not weaken the lock within a root'); } finally { holder.kill(); } }); + +// The CLI's own state follows SYM_STATE_DIR, as its daemon's does (0.13.15 review F5): rooted in +// the home, two rooted deployments shared one pid file, so `sym stop` for one stopped the other, +// and a room or relay set for a rooted daemon was written where that daemon never reads. +test('the CLI reads its room from SYM_STATE_DIR, not the home (pid and relay.env resolve the same way)', () => { + const home = tmpdir('cli-home-'); + const root = tmpdir('cli-root-'); + try { + fs.writeFileSync(path.join(root, 'room'), 'rooted-room\n'); + fs.mkdirSync(path.join(home, '.sym'), { recursive: true }); + fs.writeFileSync(path.join(home, '.sym', 'room'), 'home-room\n'); + const env = { ...process.env, HOME: home, USERPROFILE: home, SYM_STATE_DIR: root }; + const out = execFileSync(process.execPath, [path.resolve(__dirname, '..', 'bin', 'sym.js'), 'room'], { env, encoding: 'utf8' }); + assert.match(out, /current room: rooted-room/, out); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(root, { recursive: true, force: true }); + } +}); From 21a0d96ba0bd65323335c61afa0f13b3608b1b9f Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 20:59:52 +0100 Subject: [PATCH 046/148] F6: inbox()'s limit bounds the reply again; acked items are listed up to the same limit K4 made the limit count only unread items, so a ring of acked items came back whole on every call. Acked items are now listed up to the limit and passed over beyond it (they were read), and the cursor moves past them, so unread deliveries are still never starved. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/node.js | 14 +++++++++----- tests/inbox-limit-and-clock.test.js | 17 ++++++++++++++++- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/node.js b/lib/node.js index 366a8121..8205d5a3 100644 --- a/lib/node.js +++ b/lib/node.js @@ -1886,21 +1886,25 @@ class SymNode extends EventEmitter { const from = opts.since !== undefined ? opts.since : this._inboxCursor; const fresh = this._inbox.filter((m) => m.seq > from); // The limit counts what is still unread: an item already acked (read in full by id) comes back - // marked, but does not use up a place a new delivery needed. + // marked, but does not use up a place a new delivery needed. Acked items are listed up to the + // same limit; past it they are passed over (they were read), so `limit` still bounds the reply. const slice = []; - let unread = 0; + let unread = 0, ackedListed = 0, passed = 0, lastSeq = null; for (const m of fresh) { if (!m.acked) { if (unread >= limit) break; unread++; } + else if (ackedListed >= limit) { passed++; lastSeq = m.seq; continue; } + else ackedListed++; slice.push(m); + lastSeq = m.seq; } - if (!opts.peek && slice.length) { - this._inboxCursor = Math.max(this._inboxCursor, slice[slice.length - 1].seq); + if (!opts.peek && lastSeq !== null) { + this._inboxCursor = Math.max(this._inboxCursor, lastSeq); this._persistInbox(); // the drain moves the cursor — a restart must not replay it } return { messages: slice, drained: slice.length, - remaining: fresh.length - slice.length, + remaining: fresh.length - slice.length - passed, cursor: this._inboxCursor, }; } diff --git a/tests/inbox-limit-and-clock.test.js b/tests/inbox-limit-and-clock.test.js index 4b6040d6..4724de54 100644 --- a/tests/inbox-limit-and-clock.test.js +++ b/tests/inbox-limit-and-clock.test.js @@ -31,10 +31,25 @@ describe('inbox limit (K4)', () => { const r = node.inbox({ limit: 2 }); const unread = r.messages.filter((m) => !m.acked).map((m) => m.id); assert.deepStrictEqual(unread, [ids[3], ids[4]], 'two unread deliveries, as asked'); - assert.ok(r.messages.slice(0, 3).every((m) => m.acked), 'the acked ones come back marked'); + assert.deepStrictEqual(r.messages.filter((m) => m.acked).map((m) => m.id), [ids[0], ids[1]], 'acked ones come back marked, up to the same limit'); assert.strictEqual(r.remaining, 1); } finally { fs.rmSync(nodeDir(name), { recursive: true, force: true }); } }); + + // 0.13.15 review F6: counting only unread items let the reply grow to the whole ring. + it('the limit still bounds the reply when most of the ring is acked', () => { + const name = uniq('inbox-bound'); + const node = mkNode(name); + try { + for (let i = 0; i < 300; i++) node._pushInbox({ cmb: { categories: { focus: { text: `m${i}` } } }, content: `m${i}`, source: 'peer' }); + for (const m of node._inbox.slice(0, 299)) node.inboxAck(m.id); + const r = node.inbox({ limit: 1 }); + assert.ok(r.messages.length <= 2, `one unread and at most one acked, not ${r.messages.length}`); + assert.deepStrictEqual(r.messages.filter((m) => !m.acked).map((m) => m.id), [node._inbox[299].id], 'the unread delivery is not starved by acked ones'); + assert.strictEqual(r.remaining, 0, 'acked items passed over are behind the cursor'); + assert.strictEqual(node.inbox({ limit: 1 }).messages.length, 0, 'and are not handed back next time'); + } finally { fs.rmSync(nodeDir(name), { recursive: true, force: true }); } + }); }); describe('record timestamps (K6)', () => { From 3f6c9befb034e119dacead405bcc77cd50a9ebb6 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 21:01:52 +0100 Subject: [PATCH 047/148] F7: directed de-duplication marks the assertion by its preimage digest, not its signature bytes A hedged Ed25519 signer (WebKit) signs one assertion differently each time, so the signature hash named one directed assertion twice and it surfaced twice. The mark is now the digest of the preimage the signature covers, recomputed locally (assertionIdV2_0 for v2.0, sigDigestV2 otherwise), with the signature hash as the fallback; marks persisted by 0.13.14 still route to the directed map. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/frame-handler.js | 40 +++++++++++++++++++------ tests/audit-delivery-authorship.test.js | 26 ++++++++++++++++ 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/lib/frame-handler.js b/lib/frame-handler.js index 1c60318b..c36ab825 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -56,8 +56,28 @@ function signatureMark(sig) { return bytes.length ? `sig:${crypto.createHash('sha256').update(bytes).digest('hex')}` : null; } +/** + * The identity of a verified record's ASSERTION (§8.8.2), for directed de-duplication: the digest + * of the preimage its signature covers, recomputed here, never a value the frame carries. A hedged + * Ed25519 signer (WebKit) signs one assertion differently each time, so the signature bytes would + * name one assertion twice; for a deterministic signer the two marks are equivalent. The signature + * hash is the fallback for a record whose preimage cannot be rebuilt. + * @param {object} cmb a record whose signature verified + * @returns {string|null} + */ +function assertionMark(cmb) { + const { assertionIdV2_0, sigDigestV2 } = require('./core'); + try { + return cmb?.metadata?.signatureSuite === 'mmp-sig-v2.0' ? assertionIdV2_0(cmb) : `sd:${sigDigestV2(cmb)}`; + } catch { + return signatureMark(cmb?.metadata?.sig); + } +} + const SEEN_CMB_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days const SEEN_CMB_MAX = 10000; +/** A directed de-duplication key: the record key, `#`, and its assertion mark. */ +const DIRECTED_MARK = /#(asrt-|sd:|sig:)/; /** The most `peer-info` entries read from one frame. */ const PEER_INFO_MAX = 256; @@ -162,7 +182,9 @@ class FrameHandler { /** The de-dup map a key belongs to: directed assertion marks are kept apart from content keys. @private */ _seenKeysFor(key) { - return typeof key === 'string' && key.includes('#sig:') ? this._seenDirectedKeys : this._seenCmbKeys; + // A directed mark is `#` (assertionMark): `asrt-…`, `sd:…`, or `sig:…` + // (the signature hash, and every mark 0.13.14 persisted). + return typeof key === 'string' && DIRECTED_MARK.test(key) ? this._seenDirectedKeys : this._seenCmbKeys; } /** @@ -926,14 +948,14 @@ class FrameHandler { // unauthenticated widens the key — a frame timestamp or an unsigned createdTimestamp is the // sender's choice, and would let one peer surface the same content without bound (review F2). // - // The mark is the hash of the decoded signature bytes, never a string a relay can re-spell: - // base64url decoding ignores padding and the unused bits of the last character, so `sig + '='` - // or a twin final character verifies as the same signature, and a carried `assertionId` is - // covered by no signature at all. Ed25519 is deterministic, so one assertion has one signature. + // The mark is the digest of the preimage the signature covers, recomputed here (assertionMark): + // never a string a relay can re-spell (base64url ignores padding and the unused bits of the + // last character, so `sig + '='` verifies as the same signature), never a carried assertionId, + // which no signature covers, and not the signature bytes, which a hedged signer varies. const cognitionKey = recordKey(msg.cmb) || msg.key; - const assertionMark = msg._cmbVerified === true ? signatureMark(msg.cmb?.metadata?.sig) : null; - const incomingKey = cognitionKey && msg._directedToMe && assertionMark - ? `${cognitionKey}#${assertionMark}` + const mark = msg._cmbVerified === true ? assertionMark(msg.cmb) : null; + const incomingKey = cognitionKey && msg._directedToMe && mark + ? `${cognitionKey}#${mark}` : cognitionKey; if (incomingKey) { const lastSeen = this._seenKeysFor(incomingKey).get(incomingKey); @@ -1648,4 +1670,4 @@ class FrameHandler { } } -module.exports = { FrameHandler }; +module.exports = { FrameHandler, assertionMark }; diff --git a/tests/audit-delivery-authorship.test.js b/tests/audit-delivery-authorship.test.js index 715e0901..179dbc3f 100644 --- a/tests/audit-delivery-authorship.test.js +++ b/tests/audit-delivery-authorship.test.js @@ -728,3 +728,29 @@ describe('B-D5: directed sends that mint nothing still deliver (MMP §4.4.4)', ( }); }); }); + +// 0.13.15 review F7: a hedged Ed25519 signer (WebKit) signs one assertion differently each time, so +// a mark taken from the signature bytes named one directed assertion twice. +describe('directed de-duplication marks the assertion, not its signature bytes', () => { + const { assertionMark } = require('../lib/frame-handler'); + const crypto = require('crypto'); + const otherSig = () => crypto.randomBytes(64).toString('base64url'); + + it('two signatures over one v2.0 assertion give one mark; different words give another', () => { + const cmb = core.createCMB({ categories: { focus: 'directed once' }, createdBy: 'alice', emitV2: true, createdByNodeId: 'node-alice', room: 'default' }); + const a = structuredClone(cmb); a.metadata.sig = otherSig(); + const b = structuredClone(cmb); b.metadata.sig = otherSig(); + assert.strictEqual(assertionMark(a), assertionMark(b)); + assert.match(assertionMark(a), /^asrt-/); + const other = core.createCMB({ categories: { focus: 'directed twice' }, createdBy: 'alice', emitV2: true, createdByNodeId: 'node-alice', room: 'default' }); + other.metadata.sig = a.metadata.sig; + assert.notStrictEqual(assertionMark(other), assertionMark(a), 'the same signature bytes do not make two assertions one'); + }); + + it('records of the older suite are marked by their preimage digest the same way', () => { + const cmb = core.createCMB({ categories: { focus: 'older suite' }, createdBy: 'alice', room: 'default' }); + const a = structuredClone(cmb); a.metadata.sig = otherSig(); + const b = structuredClone(cmb); b.metadata.sig = otherSig(); + assert.strictEqual(assertionMark(a), assertionMark(b)); + }); +}); From cf3cba41ba6304739f97e29d5b73c7ec020cd273 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 21:03:06 +0100 Subject: [PATCH 048/148] F8: a mesh agent whose node cannot sign halts once, instead of an error per admitted record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remember() throws ESIGN since this release (fail closed, §18.3.1). MeshAgent's remix and observation paths did not catch it, so every admitted record logged an error and still spent a model call. It now says once that it is halted and stops remixing and observing. Its remix comment cites §15.7. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- lib/mesh-agent.js | 30 +++++++++++++++++++++++++----- tests/mesh-agent.test.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/lib/mesh-agent.js b/lib/mesh-agent.js index 99b1a4a7..f3003223 100644 --- a/lib/mesh-agent.js +++ b/lib/mesh-agent.js @@ -254,7 +254,7 @@ class MeshAgent { // ── Event-driven remix (Section 14) ──────────────────── async _onCMBAccepted(entry) { - if (!this._shouldRemix(entry)) return; + if (this._signingHalted || !this._shouldRemix(entry)) return; const source = entry.source || recordCreatedBy(entry.cmb) || 'unknown'; const focusText = entry.cmb?.categories?.focus?.text || entry.content || ''; @@ -280,9 +280,9 @@ class MeshAgent { console.log(`[${this._name}] accepted from ${source}: ${focusText.slice(0, 80)}`); - // Section 14.7: remix requires new domain data + // MMP §15.7: remix requires new domain data if (!this._node.canRemix()) { - console.log(`[${this._name}] no new domain data — silent (14.7)`); + console.log(`[${this._name}] no new domain data — silent (§15.7)`); return; } @@ -316,7 +316,8 @@ class MeshAgent { lineage: entry.lineage || recordLineage(entry.cmb) || null, }; - const stored = this._node.remember(remixCategories, { parents: [parent], ...(meta ? { meta } : {}) }); + const stored = this._remember(remixCategories, { parents: [parent], ...(meta ? { meta } : {}) }); + if (this._signingHalted) return; if (!stored) { console.log(`[${this._name}] remix rejected by SDK`); return; @@ -327,7 +328,25 @@ class MeshAgent { // ── Domain observation (Section 13) ──────────────────── + /** + * remember(), for an agent: the SDK throws ESIGN when this node cannot sign (§18.3.1, fail + * closed). An agent that cannot sign cannot emit anything, so it says so once and stops remixing + * and observing, instead of an error per admitted record and a model call it can never use. + * @private + */ + _remember(categories, opts) { + try { + return this._node.remember(categories, opts); + } catch (err) { + if (!err || err.code !== 'ESIGN') throw err; + this._signingHalted = true; + console.error(`[${this._name}] HALTED: this node cannot sign (${err.message}). It emits nothing until its identity key is readable again; fix that and restart it.`); + return null; + } + } + async _checkDomain() { + if (this._signingHalted) return; console.log(`[${this._name}] checking domain...`); const result = await this._fetchDomain(); @@ -371,7 +390,8 @@ class MeshAgent { } // Original observation — no parents - const stored = this._node.remember(categories, meta ? { meta } : undefined); + const stored = this._remember(categories, meta ? { meta } : undefined); + if (this._signingHalted) return; if (!stored) { console.log(`[${this._name}] observation rejected (duplicate)`); return; diff --git a/tests/mesh-agent.test.js b/tests/mesh-agent.test.js index 67991673..6516c572 100644 --- a/tests/mesh-agent.test.js +++ b/tests/mesh-agent.test.js @@ -149,3 +149,34 @@ describe('MeshAgent', () => { assert.deepStrictEqual(remixed, [cmb.metadata.key], 'the kept remix is not re-remixed; the severed one is a root'); }); }); + +// 0.13.15 review F8: remember() throws ESIGN when the node cannot sign. The agent says so once and +// stops, instead of an error for every admitted record and a model call it could never use. +describe('MeshAgent when its node cannot sign', () => { + const { MeshAgent } = require('../lib/mesh-agent'); + it('halts once on ESIGN and stops remixing and observing', async () => { + let remixCalls = 0; + const agent = new MeshAgent({ + name: `esign-${Date.now()}`, + fetchDomain: async () => ({ data: 'd', fingerprint: String(Math.random()) }), + reason: async () => ({ focus: 'an observation' }), + remix: async () => { remixCalls++; return { focus: 'a remix' }; }, + shouldRemix: () => true, + }); + agent.node.canRemix = () => true; + agent.node.remember = () => { const e = new Error('CMB signing failed: key unreadable'); e.code = 'ESIGN'; throw e; }; + const errors = []; + const origError = console.error, origLog = console.log; + console.error = (...a) => errors.push(a.join(' ')); + console.log = () => {}; + try { + const entry = () => ({ key: 'k-' + Math.random(), source: 'peer', cmb: { categories: { focus: { text: 'x' } } } }); + await agent._onCMBAccepted(entry()); + await agent._onCMBAccepted(entry()); + await agent._checkDomain(); + } finally { console.error = origError; console.log = origLog; } + assert.strictEqual(errors.filter((l) => /HALTED: this node cannot sign/.test(l)).length, 1, 'said once'); + assert.strictEqual(remixCalls, 1, 'no model call after the halt'); + assert.strictEqual(agent._signingHalted, true); + }); +}); From ce9d26128c9d2d18f8a272da4db7852e21749aef Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 21:04:57 +0100 Subject: [PATCH 049/148] tests: the encoder's single load, a rooted daemon's task board, and the daemon's IPC error replies The release review found three claims with no test: one model load however many callers ask, the task board read from SYM_STATE_DIR, and an IPC request whose handler throws answered with the error. The daemon's remember reply now also carries the SDK's error code (ECMBSIZE, ESIGN). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- bin/sym-daemon.js | 2 +- tests/daemon-relay-only.test.js | 28 ++++++++++++++++++++++++ tests/semantic-single-load.test.js | 34 ++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/semantic-single-load.test.js diff --git a/bin/sym-daemon.js b/bin/sym-daemon.js index a03263d2..885bb4ad 100755 --- a/bin/sym-daemon.js +++ b/bin/sym-daemon.js @@ -450,7 +450,7 @@ function handleIPCMessage(socketId, socket, msg) { } } catch (err) { log(`remember failed: ${err.message}`); - sendIPC(socket, { type: 'result', action: 'remember', error: err.message }); + sendIPC(socket, { type: 'result', action: 'remember', error: err.message, code: err.code }); } } break; diff --git a/tests/daemon-relay-only.test.js b/tests/daemon-relay-only.test.js index 028718cb..3dbb4ec6 100644 --- a/tests/daemon-relay-only.test.js +++ b/tests/daemon-relay-only.test.js @@ -19,6 +19,7 @@ const { WebSocketServer } = require('ws'); const fs = require('fs'); const os = require('os'); const path = require('path'); +const net = require('net'); const { ipcEndpoint } = require('../lib/platform'); const BIN = path.join(__dirname, '..', 'bin'); @@ -84,6 +85,7 @@ test('a daemon with SYM_STATE_DIR keeps its own files in that root, not in ~/.sy // The relay config and the room live only in the root: a daemon reading ~/.sym finds neither. fs.writeFileSync(path.join(root, 'relay.env'), `SYM_RELAY_URL=ws://127.0.0.1:${wss.address().port}\nSYM_RELAY_TOKEN=${'y'.repeat(32)}\n`); fs.writeFileSync(path.join(root, 'room'), 'rooted-room'); + fs.writeFileSync(path.join(root, 'tasks.json'), JSON.stringify({ tasks: [{ id: 'task-1', title: 'kept in the root', status: 'backlog' }], nextId: 2 })); const socket = path.join(base, 'r.sock'); const env = { ...process.env, HOME: home, USERPROFILE: home, SYM_STATE_DIR: root, SYM_SOCKET: socket, SYM_NODE_NAME: 'rooted-test', SYM_RELAY_ONLY: '1' }; delete env.SYM_RELAY_URL; delete env.SYM_RELAY_TOKEN; delete env.SYM_ROOM; @@ -95,6 +97,32 @@ test('a daemon with SYM_STATE_DIR keeps its own files in that root, not in ~/.sy for (let i = 0; i < 100 && daemon.exitCode === null && !auths.length; i++) await new Promise((r) => setTimeout(r, 100)); assert.ok(auths.length >= 1, `the relay config was read from the root; log:\n${out.slice(-800)}`); assert.match(out, /rooted-room/, 'the room was read from the root'); + const endpoint = ipcEndpoint(socket); + for (let i = 0; i < 100 && !out.includes(`IPC server listening: ${endpoint}`); i++) await new Promise((r) => setTimeout(r, 100)); + const ipc = (msg) => new Promise((resolve, reject) => { + const c = net.createConnection(endpoint); + let buf = ''; + c.on('data', (d) => { + buf += d; + for (const line of buf.split('\n').slice(0, -1)) { + const m = JSON.parse(line); + if (m.type === 'result' && m.action === msg.type) { c.end(); resolve(m); return; } + } + }); + c.on('error', reject); + c.on('connect', () => c.write(JSON.stringify(msg) + '\n')); + }); + const listed = await ipc({ type: 'task-list' }); + assert.deepEqual(listed.tasks.map((t) => t.title), ['kept in the root'], 'the task board was read from the root'); + // A refused record answers with the SDK's reason and its code, so a client can say why. + const big = await ipc({ type: 'remember', categories: { focus: 'x'.repeat(300 * 1024) } }); + assert.match(String(big.error), /at most/); + assert.equal(big.code, 'ECMBSIZE'); + // A handler that throws answers the request with the error instead of leaving it unanswered. + assert.ok((await ipc({ type: 'remember', categories: { focus: 'one stored record' } })).key); + const bad = await ipc({ type: 'recall', query: 123 }); + assert.ok(bad.error, `the throw is reported to the caller: ${JSON.stringify(bad)}`); + assert.equal(daemon.exitCode, null, 'and the daemon is still running'); assert.equal(fs.existsSync(path.join(home, '.sym')), false, 'nothing was written to ~/.sym'); } finally { daemon.kill('SIGTERM'); diff --git a/tests/semantic-single-load.test.js b/tests/semantic-single-load.test.js new file mode 100644 index 00000000..bba456de --- /dev/null +++ b/tests/semantic-single-load.test.js @@ -0,0 +1,34 @@ +'use strict'; + +/** + * The semantic encoder is loaded once per process (0.13.15). Under load, a second caller asking + * for it while the first load was still running started a second model load, and parallel loads + * were what made the full suite report "semantic encoder did not become ready". + * + * Run in a child process with the model library replaced by a counting stand-in, so no model is + * downloaded and the module's load-time call is part of what is counted. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const ENCODER = path.resolve(__dirname, '..', 'lib', 'core', 'context-encoder.js'); + +test('one model load however many callers ask while it is loading', () => { + const out = execFileSync(process.execPath, ['-e', ` + const Module = require('module'); + let loads = 0; + const stand = { pipeline: () => { loads++; return new Promise((r) => setTimeout(() => r(async () => ({ data: new Float32Array(384) })), 100)); } }; + const real = Module._load; + Module._load = function (req, ...rest) { return req === '@huggingface/transformers' ? stand : real.call(this, req, ...rest); }; + const enc = require(${JSON.stringify(ENCODER)}); + Promise.all([enc.semanticSettled(), enc.semanticSettled(), enc.semanticSettled()]).then((r) => { + process.stdout.write(JSON.stringify({ loads, settled: r, ready: enc.isSemanticReady() })); + });`], { encoding: 'utf8' }); + const r = JSON.parse(out.trim().split('\n').pop()); + assert.equal(r.loads, 1, 'the module load and three callers share one model load'); + assert.deepEqual(r.settled, [true, true, true]); + assert.equal(r.ready, true); +}); From cbe301621a24fee4fb5e40e1612acccbbe7a56cb Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 21:05:25 +0100 Subject: [PATCH 050/148] =?UTF-8?q?CHANGELOG:=20the=20release=20review's?= =?UTF-8?q?=20fixes,=20and=20the=20=C2=A716=20consequence=20of=20anchor=20?= =?UTF-8?q?decay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- CHANGELOG.md | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bad03abd..c645a4d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,25 +23,37 @@ debt, and the daemon's log flood. Every item has a test that fails without its f decides. - **Unauthenticated gossip could repoint a phone's wake token.** Any room peer's `peer-info` could overwrite a wake channel the phone had given this node itself. A channel learned from a weaker - source (gossip < relay < the peer itself) now never replaces a stronger one's token. + source (gossip < relay < the peer itself) now never replaces a stronger one's token. A channel kept + by an earlier release, which recorded no source, ranks with the relay: gossip cannot repoint it. + At most 1024 channels are kept (gossip only ever displaces gossip), and one `peer-info` frame is + read for at most 256 entries, so fabricated node ids cannot grow the map, its file or the gossip + this node sends. +- **A frame whose handling threw ended a relay-connected daemon.** The LAN path caught a throw from + frame handling, but the relay path had nothing above it, so the exception reached the process. A + frame that throws is now dropped with a log line and a `frame-handler-error` metric. A record that + declares the v2.0 suite but has no buildable preimage is rejected as a signature mismatch. - **A record with no room was admitted in every room it was replayed into (B-R10).** A record that names no room is now in the literal room `default` (§7). `createCMB` names `default` instead of null (which signed the string `"null"`), and the v2.0 preimage refuses a record without a room. - **A record that could not be signed was sent unsigned (B-R12).** `remember()` now throws `ESIGN` - before anything is stored or dispatched (§18.3.1). + before anything is stored or dispatched (§18.3.1). A `MeshAgent` whose node cannot sign says so + once and stops remixing and observing. ### Fixed — delivery and records - **Two copies of one record arriving together could both surface (K1).** A key is held in flight from the de-duplication check until its SVAF pass settles. - **A broadcast flood could evict directed de-duplication marks (K3).** They have their own map and - cap now. -- **`inbox()`'s limit counted acked items (K4).** It counts unread deliveries; acked ones still come - back, marked. + cap now. A directed assertion is marked by the digest of the preimage its signature covers, + recomputed on receipt, not by the signature bytes: a hedged Ed25519 signer (WebKit) signs one + assertion differently each time, and its re-signed copy surfaced twice. +- **`inbox()`'s limit counted acked items (K4).** It counts unread deliveries. Acked ones still come + back, marked, up to the same limit, and are passed over beyond it, so the limit bounds the reply. - **The inbox `from` was the author's label whether or not anything proved it (K5).** It is now the author when the signature proved who that is, and otherwise the peer that delivered it. The claim stays in `author.name`. A v2.0 record verified against its signed node id carries `author.nodeId`, - even when relayed. + even when relayed. `from` is a display label (two node ids can sign the same `createdBy`): + authorize on `author.nodeId`. - **Record timestamps ran ahead of the clock after a backward step, and restarted every process (K6).** The ratchet starts from this node's newest stored record. After a step back of more than a minute, timestamps follow the clock again, with a `clock-stepped-back` metric. @@ -82,7 +94,10 @@ debt, and the daemon's log flood. Every item has a test that fails without its f both: `storedAt`, and the store's §6.4 `anchorWeight` (2.0 once validated, 0.5 once dismissed, never the sender's unsigned confidence). At the default freshness of 1800 s, an anchor older than about nine hours carries no weight. **A node whose recent memory is all older than that admits the next - block under the empty-memory rule**, as §9.2.1 specifies. + block under the empty-memory rule**, as §9.2.1 specifies. The spec discloses what that rule costs: + while it applies, the §16 influence bound does not cover the node. With decay that window recurs + after every quiet period of about 18.4 × `freshnessSeconds` (about nine hours at the default), not + only on a fresh node. `freshnessSeconds` is the dial: a larger value keeps older memory gating. ### Fixed — SVAF and the store @@ -119,7 +134,10 @@ debt, and the daemon's log flood. Every item has a test that fails without its f first-hand sighting (gossip forwards it instead of the time of sending) and expire after 30 days unseen. A channel saved before this release gets one 30-day grace period, once. - **A daemon rooted with `SYM_STATE_DIR` kept its room, tasks and relay.env in `~/.sym`.** It uses - the state root now. + the state root now, and so does the `sym` CLI: its pid file, room, relay.env and node directory. + Two rooted deployments sharing a home shared one pid file, so `sym stop` for one stopped the other. +- **A daemon IPC request whose handler threw went unanswered.** It is answered with the error, and a + refused `remember` carries the SDK's code (`ECMBSIZE`, `ESIGN`). - **A relay-only daemon still announced its room on the LAN.** It no longer does. - **IPC on Windows.** The daemon and every client now resolve the IPC endpoint one way, and on Windows a file path becomes a named pipe. `SymDaemonClient` defaulted to `/tmp/sym.sock`, which no From f112207a2fe3f8f5dac0de038b1cbd4970681f9d Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 21:48:19 +0100 Subject: [PATCH 051/148] the witness storm: one checkpoint and one witness per statement, bounded logs, archived not reread Two signed copies of one witness (a node re-witnessing after a restart) were relayed by every node without end: the store kept only the latest copy per witness, so each copy was new whenever the other arrived. Checkpoints and witnesses are now deduplicated by what they assert, a node witnesses a checkpoint once, the logs are bounded (32 checkpoints per attester, 20,000 witnesses), a log past 8 MiB is archived unchanged and restarted from what is held, and an oversized legacy log is read from its tail only. Attestation eviction no longer shifts an array. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- CHANGELOG.md | 16 ++++ lib/attestation-store.js | 140 ++++++++++++++++++++++++---- lib/node.js | 3 + tests/admission-attestation.test.js | 31 ++++++ tests/attestation-store.test.js | 75 +++++++++++++++ 5 files changed, 249 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c645a4d2..961eedab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,22 @@ debt, and the daemon's log flood. Every item has a test that fails without its f before anything is stored or dispatched (§18.3.1). A `MeshAgent` whose node cannot sign says so once and stops remixing and observing. +### Fixed — the witness storm + +- **Two signed copies of one witness were relayed by every node without end.** A node that + witnessed a checkpoint again after a restart signed a second copy of the same statement. The store + kept only the latest copy per witness, so each copy was new whenever the other arrived, and every + node stored and relayed it again. On one host this grew one node's `witnesses.jsonl` to 625,079 + lines (257 MB) of two witnesses, and kept every node in the room busy relaying them. A checkpoint + is now held once per (attester, position) and a witness once per (attester, position, witness): + a later copy is a duplicate and is not relayed, and one asserting a different root is a conflict, + kept out. A node witnesses a checkpoint once, across restarts. +- **The attestation logs are bounded.** At most 32 checkpoints per attester (with their witnesses) + and 20,000 witnesses are kept, the oldest dropped first. A log past 8 MiB is moved, unchanged, into + `attestations/archive/` and restarted from the records held, so nothing appended is lost. A node + reads only the live logs at start, and of an oversized log from an earlier release only its last + 8 MiB; reading the whole of one held a node's thread for minutes. + ### Fixed — delivery and records - **Two copies of one record arriving together could both surface (K1).** A key is held in flight diff --git a/lib/attestation-store.js b/lib/attestation-store.js index f074275d..cfc53d79 100644 --- a/lib/attestation-store.js +++ b/lib/attestation-store.js @@ -13,6 +13,17 @@ * anchored checkpoints add the cross-node half in a later step). * Deduplicated by signature (idempotent record / relay-once). Bounded by count. * + * Checkpoints and witnesses are deduplicated by WHAT they assert, not by signature: one checkpoint + * per (attester, upto_seq) and one witness per (attester, upto_seq, witness). A node that witnesses + * a checkpoint again after a restart signs a second, different-looking copy of the same statement; + * keeping only the latest copy made the two copies new to each other forever, so every node stored + * and relayed each one again whenever the other arrived. A later copy of a held statement is a + * duplicate; one asserting a different root for the same position is a conflict, kept out. + * + * Persistence is append-only, and bounded: a log that outgrows its limit is moved, whole and + * unchanged, into `archive/`, and a new log starts from the records still held. A node reads only + * the live logs at start, and of an oversized one only its tail. + * * This store does NOT verify signatures or roster membership — the caller verifies * before recording (a recorded attestation is assumed already verified). It only * indexes, dedups, reports chain integrity, and bounds memory. @@ -47,8 +58,13 @@ class AttestationStore { this._byCmb = new Map(); // of -> Map(sig -> att) this._byAttester = new Map(); // by -> Map(seq -> att) this._seen = new Set(); // sig -> dedup / relay-once - this._order = []; // [{ sig, of, by, seq }] insertion order, for eviction + this._order = new Map(); // sig -> { of, by, seq }, insertion order, for O(1) eviction this._max = opts.max || 50000; + this._maxCheckpointsPerAttester = opts.maxCheckpointsPerAttester || 32; + this._maxWitnesses = opts.maxWitnesses || 20000; + this._maxLiveBytes = opts.maxLiveBytes || 8 * 1024 * 1024; + this._witnessOrder = new Map(); // `${attester}|${upto_seq}|${by}` -> true, insertion order + this._bytes = new Map(); // live file -> bytes written to it // Per-(of,by) ingest rate-limit — bounds a Sybil/flood of attestations about a // single CMB on the immutable log (research's DoS-at-ingest finding). Applies // only to GOSSIPED-IN attestations, never to this node's own gating output. @@ -86,7 +102,7 @@ class AttestationStore { } this._seen.add(att.sig); - this._order.push({ sig: att.sig, of: att.of, by: att.by, seq: att.seq }); + this._order.set(att.sig, { of: att.of, by: att.by, seq: att.seq }); let cmbIdx = this._byCmb.get(att.of); if (!cmbIdx) { cmbIdx = new Map(); this._byCmb.set(att.of, cmbIdx); } @@ -144,30 +160,64 @@ class AttestationStore { // ── Checkpoints + witnesses (omission-evidence) ────────────────────────────── - /** Record an (already-verified) checkpoint, keyed by attester + upto_seq. Idempotent. */ + /** Record an (already-verified) checkpoint, keyed by attester + upto_seq. Idempotent: a later + * copy of a held checkpoint is a duplicate, one with a different root is a conflict (both kept + * out, so neither is relayed again). At most `maxCheckpointsPerAttester` are kept per attester. */ recordCheckpoint(cp) { if (!cp || !cp.by || cp.upto_seq === undefined || !cp.root || !cp.sig) return { stored: false, reason: 'malformed' }; let m = this._checkpoints.get(cp.by); if (!m) { m = new Map(); this._checkpoints.set(cp.by, m); } const existing = m.get(cp.upto_seq); - if (existing && existing.sig === cp.sig) return { stored: false, reason: 'duplicate' }; + if (existing) return { stored: false, reason: existing.root === cp.root ? 'duplicate' : 'conflict' }; + if (m.size >= this._maxCheckpointsPerAttester) { + const oldest = Math.min(...m.keys()); + if (cp.upto_seq < oldest) return { stored: false, reason: 'stale' }; + m.delete(oldest); + this._dropWitnessesOf(cp.by, oldest); + } m.set(cp.upto_seq, cp); this._append(CP_FILE, cp); return { stored: true }; } - /** Record an (already-verified) witness countersignature, keyed by (attester, upto_seq, witness). */ + /** Record an (already-verified) witness countersignature, keyed by (attester, upto_seq, witness). + * Idempotent on what it asserts (see the module note); at most `maxWitnesses` are kept. */ recordWitness(w) { if (!w || !w.attester || w.upto_seq === undefined || !w.root || !w.by || !w.sig) return { stored: false, reason: 'malformed' }; const key = `${w.attester}|${w.upto_seq}`; let m = this._witnesses.get(key); + const existing = m && m.get(w.by); + if (existing) return { stored: false, reason: existing.root === w.root ? 'duplicate' : 'conflict' }; if (!m) { m = new Map(); this._witnesses.set(key, m); } - if (m.has(w.by) && m.get(w.by).sig === w.sig) return { stored: false, reason: 'duplicate' }; m.set(w.by, w); + this._witnessOrder.set(`${key}|${w.by}`, true); + while (this._witnessOrder.size > this._maxWitnesses) { + const first = this._witnessOrder.keys().next().value; + this._witnessOrder.delete(first); + const cut = first.lastIndexOf('|'); + const k = first.slice(0, cut), by = first.slice(cut + 1); + const wm = this._witnesses.get(k); + if (wm) { wm.delete(by); if (wm.size === 0) this._witnesses.delete(k); } + } this._append(WIT_FILE, w); return { stored: true }; } + /** Whether `by` has witnessed (attester, upto_seq) already, in any copy. */ + hasWitnessed(attester, upto_seq, by) { + const m = this._witnesses.get(`${attester}|${upto_seq}`); + return !!(m && m.has(by)); + } + + /** @private Drop the witnesses of a checkpoint no longer held. */ + _dropWitnessesOf(attester, upto_seq) { + const key = `${attester}|${upto_seq}`; + const m = this._witnesses.get(key); + if (!m) return; + for (const by of m.keys()) this._witnessOrder.delete(`${key}|${by}`); + this._witnesses.delete(key); + } + /** An attester's checkpoints, ordered by upto_seq. */ checkpointsOf(by) { const m = this._checkpoints.get(by); @@ -190,11 +240,47 @@ class AttestationStore { // ── Durable persistence (append-only JSONL) ────────────────────────────────── - /** Append one record to its JSONL file. No-op without a dir or while replaying. */ + /** Append one record to its JSONL file. No-op without a dir or while replaying. A log past + * `maxLiveBytes` is archived and restarted from what is held (_rotate). */ _append(file, obj) { if (!this._dir || this._loading) return; - try { fs.appendFileSync(path.join(this._dir, file), JSON.stringify(obj) + '\n'); } - catch { /* best effort — never let persistence break gating */ } + try { + const line = JSON.stringify(obj) + '\n'; + fs.appendFileSync(path.join(this._dir, file), line); + const bytes = (this._bytes.get(file) || 0) + Buffer.byteLength(line); + this._bytes.set(file, bytes); + if (bytes > this._maxLiveBytes) this._rotate(file); + } catch { /* best effort — never let persistence break gating */ } + } + + /** The records of one log this store still holds, in the order they were recorded. @private */ + _held(file) { + if (file === ATT_FILE) { + const out = []; + for (const [sig, { of }] of this._order) { const a = this._byCmb.get(of)?.get(sig); if (a) out.push(a); } + return out; + } + if (file === CP_FILE) return [...this._checkpoints.values()].flatMap((m) => [...m.values()]); + const out = []; + for (const k of this._witnessOrder.keys()) { + const cut = k.lastIndexOf('|'); + const w = this._witnesses.get(k.slice(0, cut))?.get(k.slice(cut + 1)); + if (w) out.push(w); + } + return out; + } + + /** Move a log, whole and unchanged, into archive/ and start a new one from the records held. + * Nothing is rewritten: the archived file keeps every line ever appended. @private */ + _rotate(file) { + const live = path.join(this._dir, file); + const archiveDir = path.join(this._dir, 'archive'); + fs.mkdirSync(archiveDir, { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + fs.renameSync(live, path.join(archiveDir, `${file.replace(/\.jsonl$/, '')}.${stamp}.jsonl`)); + const text = this._held(file).map((o) => JSON.stringify(o) + '\n').join(''); + fs.writeFileSync(live, text); + this._bytes.set(file, Buffer.byteLength(text)); } /** Replay persisted records into the in-memory index on construction. Reuses the @@ -202,20 +288,41 @@ class AttestationStore { * malformed line is skipped, never fatal. */ _load() { this._loading = true; - this._replay(ATT_FILE, (o) => this.record(o)); - this._replay(CP_FILE, (o) => this.recordCheckpoint(o)); - this._replay(WIT_FILE, (o) => this.recordWitness(o)); + const oversized = []; + if (this._replay(ATT_FILE, (o) => this.record(o))) oversized.push(ATT_FILE); + if (this._replay(CP_FILE, (o) => this.recordCheckpoint(o))) oversized.push(CP_FILE); + if (this._replay(WIT_FILE, (o) => this.recordWitness(o))) oversized.push(WIT_FILE); this._loading = false; + for (const file of oversized) { try { this._rotate(file); } catch { /* best effort */ } } } + /** Replay one log. Of a log larger than `maxLiveBytes` only the last `maxLiveBytes` are read: + * the newest records, which are all a bounded store keeps anyway. Reading a whole oversized log + * at start held a node's thread for minutes. @returns {boolean} whether the log was oversized */ _replay(file, fn) { + const p = path.join(this._dir, file); + let size; + try { size = fs.statSync(p).size; } catch { return false; } // no file yet let text; - try { text = fs.readFileSync(path.join(this._dir, file), 'utf8'); } - catch { return; } // no file yet + const oversized = size > this._maxLiveBytes; + try { + if (!oversized) text = fs.readFileSync(p, 'utf8'); + else { + const fd = fs.openSync(p, 'r'); + try { + const buf = Buffer.alloc(this._maxLiveBytes); + const n = fs.readSync(fd, buf, 0, buf.length, size - buf.length); + text = buf.subarray(0, n).toString('utf8'); + text = text.slice(text.indexOf('\n') + 1); // the first line is cut + } finally { fs.closeSync(fd); } + } + } catch { return false; } for (const line of text.split('\n')) { if (!line.trim()) continue; try { fn(JSON.parse(line)); } catch { /* skip a corrupt line */ } } + if (!oversized) this._bytes.set(file, size); + return oversized; } /** Sliding-window rate gate for ingested attestations, per (of, by). */ @@ -231,8 +338,9 @@ class AttestationStore { } _evict() { - while (this._seen.size > this._max && this._order.length) { - const { sig, of, by, seq } = this._order.shift(); + while (this._seen.size > this._max && this._order.size) { + const [sig, { of, by, seq }] = this._order.entries().next().value; + this._order.delete(sig); if (!this._seen.delete(sig)) continue; const cmbIdx = this._byCmb.get(of); if (cmbIdx) { cmbIdx.delete(sig); if (cmbIdx.size === 0) this._byCmb.delete(of); } diff --git a/lib/node.js b/lib/node.js index 8205d5a3..2720c997 100644 --- a/lib/node.js +++ b/lib/node.js @@ -1125,6 +1125,9 @@ class SymNode extends EventEmitter { /** Countersign a checkpoint (witness) and gossip the witness. @private */ _witnessCheckpoint(cp) { if (cp.by === this.nodeId) return; // don't witness your own + // Once per checkpoint, across restarts: a second signing is a second copy of the same + // statement, and two copies in circulation were relayed back and forth without end. + if (this._attestations.hasWitnessed(cp.by, cp.upto_seq, this.nodeId)) return; const w = { type: 'witness', attester: cp.by, diff --git a/tests/admission-attestation.test.js b/tests/admission-attestation.test.js index 493cd648..a519c761 100644 --- a/tests/admission-attestation.test.js +++ b/tests/admission-attestation.test.js @@ -455,3 +455,34 @@ describe("'attestation-received' — a peer's verified verdict is observable as } }); }); + +// The witness storm (2026-10-01): two signed copies of one witness were relayed back and forth by +// every node without end. A node relays a witness once, whatever copy arrives, and witnesses a +// checkpoint once, across restarts. +describe('a witness is relayed and signed once per statement', () => { + const { signCheckpoint, signWitness } = require('../lib/core'); + const kp = () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', { publicKeyEncoding: { type: 'spki', format: 'der' }, privateKeyEncoding: { type: 'pkcs8', format: 'der' } }); + return { pub: publicKey.slice(-32).toString('base64url'), priv: privateKey.slice(-32).toString('base64url') }; + }; + it('a second copy of a witness is not relayed, and a held checkpoint is not witnessed again', () => { + const ATT = kp(), WIT = kp(); + withNode('att-storm', { lifecycleRole: 'participant', room: 'g' }, (node) => { + node._pinPeerKey('node-att', ATT.pub); + node._pinPeerKey('node-wit', WIT.pub); + const relayed = []; + node._gossipToRoster = (frame) => relayed.push(frame.type); + const cp = { type: 'checkpoint', by: 'node-att', roster: 'g', upto_seq: 8, root: 'r8', at: 1 }; + signCheckpoint(cp, ATT.priv); + assert.strictEqual(node._ingestCheckpoint(cp, 'node-att').ok, true); + assert.deepStrictEqual(relayed, ['checkpoint', 'witness'], 'relayed once and witnessed once'); + const copy = (at) => { const w = { type: 'witness', attester: 'node-att', roster: 'g', upto_seq: 8, root: 'r8', by: 'node-wit', role: 'participant', at }; signWitness(w, WIT.priv); return w; }; + assert.strictEqual(node._ingestWitness(copy(1), 'node-wit').ok, true); + assert.strictEqual(node._ingestWitness(copy(2), 'node-wit').ok, false, 'a second signing of the same witness is not new'); + assert.strictEqual(node._ingestWitness(copy(1), 'node-wit').ok, false, 'nor is the first one, again'); + assert.deepStrictEqual(relayed, ['checkpoint', 'witness', 'witness'], 'one relay per witness statement'); + node._witnessCheckpoint(cp); + assert.strictEqual(relayed.length, 3, 'this node does not sign a second copy of its own witness'); + }); + }); +}); diff --git a/tests/attestation-store.test.js b/tests/attestation-store.test.js index 003329dc..9096c440 100644 --- a/tests/attestation-store.test.js +++ b/tests/attestation-store.test.js @@ -177,3 +177,78 @@ describe('AttestationStore — durable persistence (append-only, reload on const assert.strictEqual(st.size(), 1); }); }); + +// The witness storm (2026-10-01): a node that witnessed a checkpoint again after a restart signed a +// second copy of the same statement. The store kept only the latest copy per witness, so the two +// copies were new to each other forever, and every node stored and relayed each one again whenever +// the other arrived: 625,079 witness lines, 257 MB, re-read on every start. +describe('AttestationStore — one record per statement, bounded, archived', () => { + const w = (sig, extra = {}) => ({ attester: 'A', upto_seq: 8, root: 'r8', by: 'W1', sig, ...extra }); + + it('a second copy of a held witness is a duplicate, never stored or relayed again', () => { + const st = new AttestationStore(); + assert.strictEqual(st.recordWitness(w('copy-1')).stored, true); + assert.deepStrictEqual(st.recordWitness(w('copy-2', { at: 2 })), { stored: false, reason: 'duplicate' }); + assert.deepStrictEqual(st.recordWitness(w('copy-1')), { stored: false, reason: 'duplicate' }, 'and the first copy is not new again'); + assert.deepStrictEqual(st.recordWitness(w('other-root', { root: 'r-other' })), { stored: false, reason: 'conflict' }); + assert.strictEqual(st.witnessesFor('A', 8).length, 1); + assert.strictEqual(st.hasWitnessed('A', 8, 'W1'), true); + }); + + it('a second copy of a held checkpoint is a duplicate; another root for the same position is a conflict', () => { + const st = new AttestationStore(); + assert.strictEqual(st.recordCheckpoint({ by: 'A', upto_seq: 8, root: 'r8', sig: 'c8' }).stored, true); + assert.deepStrictEqual(st.recordCheckpoint({ by: 'A', upto_seq: 8, root: 'r8', sig: 'c8-resigned' }), { stored: false, reason: 'duplicate' }); + assert.deepStrictEqual(st.recordCheckpoint({ by: 'A', upto_seq: 8, root: 'r-forked', sig: 'c8x' }), { stored: false, reason: 'conflict' }); + }); + + it('checkpoints are bounded per attester, and a dropped checkpoint takes its witnesses with it', () => { + const st = new AttestationStore({ maxCheckpointsPerAttester: 3 }); + for (const n of [1, 2, 3]) { st.recordCheckpoint({ by: 'A', upto_seq: n, root: `r${n}`, sig: `c${n}` }); st.recordWitness({ attester: 'A', upto_seq: n, root: `r${n}`, by: 'W', sig: `w${n}` }); } + st.recordCheckpoint({ by: 'A', upto_seq: 4, root: 'r4', sig: 'c4' }); + assert.deepStrictEqual(st.checkpointsOf('A').map((c) => c.upto_seq), [2, 3, 4]); + assert.strictEqual(st.witnessesFor('A', 1).length, 0); + assert.deepStrictEqual(st.recordCheckpoint({ by: 'A', upto_seq: 1, root: 'r1', sig: 'c1' }), { stored: false, reason: 'stale' }); + }); + + it('witnesses are bounded in total, oldest first', () => { + const st = new AttestationStore({ maxWitnesses: 100 }); + for (let i = 0; i < 250; i++) st.recordWitness({ attester: 'A', upto_seq: i, root: `r${i}`, by: 'W', sig: `w${i}` }); + assert.strictEqual(st.witnessesFor('A', 249).length, 1); + assert.strictEqual(st.witnessesFor('A', 0).length, 0); + let held = 0; for (let i = 0; i < 250; i++) held += st.witnessesFor('A', i).length; + assert.strictEqual(held, 100); + }); + + it('a log past its limit is archived whole and restarted from what is held; a reload sees the same', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'att-rotate-')); + try { + const st = new AttestationStore({ dir, maxWitnesses: 10, maxLiveBytes: 4096 }); + for (let i = 0; i < 200; i++) st.recordWitness({ attester: 'A', upto_seq: i, root: `r${i}`, by: 'W', sig: `w${i}`.padEnd(64, 'x') }); + const archived = fs.readdirSync(path.join(dir, 'archive')); + assert.ok(archived.length >= 1 && archived.every((f) => /^witnesses\..+\.jsonl$/.test(f)), archived.join(',')); + const archivedLines = archived.map((f) => fs.readFileSync(path.join(dir, 'archive', f), 'utf8').trim().split('\n').length).reduce((a, b) => a + b, 0); + const liveLines = fs.readFileSync(path.join(dir, 'witnesses.jsonl'), 'utf8').trim().split('\n').length; + assert.ok(fs.statSync(path.join(dir, 'witnesses.jsonl')).size <= 4096 * 2, 'the live log stays bounded'); + assert.ok(archivedLines + liveLines >= 200, 'every line appended is still on disk'); + const again = new AttestationStore({ dir, maxWitnesses: 10, maxLiveBytes: 4096 }); + assert.strictEqual(again.witnessesFor('A', 199).length, 1, 'the newest is reloaded'); + assert.strictEqual(again.witnessesFor('A', 0).length, 0); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + it('an oversized log from an earlier release is read from its tail only, then archived', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'att-legacy-')); + try { + const lines = []; + for (let i = 0; i < 2000; i++) lines.push(JSON.stringify({ attester: 'A', upto_seq: i, root: `r${i}`, by: 'W', sig: `w${i}` })); + fs.writeFileSync(path.join(dir, 'witnesses.jsonl'), lines.join('\n') + '\n'); + const size = fs.statSync(path.join(dir, 'witnesses.jsonl')).size; + const st = new AttestationStore({ dir, maxLiveBytes: 8192 }); + assert.strictEqual(st.witnessesFor('A', 1999).length, 1, 'the newest records are read'); + assert.strictEqual(st.witnessesFor('A', 0).length, 0, 'the head of the oversized log is not'); + const archived = fs.readdirSync(path.join(dir, 'archive')); + assert.strictEqual(fs.statSync(path.join(dir, 'archive', archived[0])).size, size, 'the original is archived unchanged'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); +}); From 78f25b59b261bd0a3367bdc65eefa74d409f057c Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 21:55:13 +0100 Subject: [PATCH 052/148] gossip repeats are dropped before verification; one peer's gossip frames are capped Under the witness storm a node spent most of its thread checking the signatures of copies it already held. A held attestation, checkpoint or witness is now a duplicate before verification, and each peer gets a gossip budget (burst 500, 100/s) with a gossip-rate-limited metric. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- CHANGELOG.md | 4 +++ lib/attestation-store.js | 6 ++++ lib/frame-handler.js | 24 +++++++++++++++ lib/node.js | 6 ++++ tests/admission-attestation.test.js | 45 +++++++++++++++++++++++++++++ 5 files changed, 85 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 961eedab..54958eb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,10 @@ debt, and the daemon's log flood. Every item has a test that fails without its f `attestations/archive/` and restarted from the records held, so nothing appended is lost. A node reads only the live logs at start, and of an oversized log from an earlier release only its last 8 MiB; reading the whole of one held a node's thread for minutes. +- **A repeat cost a signature check, and one peer could flood a node.** An attestation, checkpoint or + witness already held is dropped before its signature is verified. One peer's gossip frames + (attestation, checkpoint, witness) are capped at a burst of 500 and 100 a second; the excess is + dropped and counted (`gossip-rate-limited`). ### Fixed — delivery and records diff --git a/lib/attestation-store.js b/lib/attestation-store.js index cfc53d79..21b20aba 100644 --- a/lib/attestation-store.js +++ b/lib/attestation-store.js @@ -203,6 +203,12 @@ class AttestationStore { return { stored: true }; } + /** Whether a checkpoint for (by, upto_seq) is held, in any copy. */ + hasCheckpoint(by, upto_seq) { + const m = this._checkpoints.get(by); + return !!(m && m.has(upto_seq)); + } + /** Whether `by` has witnessed (attester, upto_seq) already, in any copy. */ hasWitnessed(attester, upto_seq, by) { const m = this._witnesses.get(`${attester}|${upto_seq}`); diff --git a/lib/frame-handler.js b/lib/frame-handler.js index c36ab825..13e3fda4 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -80,6 +80,12 @@ const SEEN_CMB_MAX = 10000; const DIRECTED_MARK = /#(asrt-|sd:|sig:)/; /** The most `peer-info` entries read from one frame. */ const PEER_INFO_MAX = 256; +/** Gossip frames (attestation, checkpoint, witness) one peer may send: a burst, then a rate per + * second. A node's own traffic is a few per gating event; a peer far past this is relaying a storm, + * and every frame of it would cost a parse and a signature check. */ +const GOSSIP_BURST = 500; +const GOSSIP_PER_SECOND = 100; +const GOSSIP_TYPES = new Set(['attestation', 'checkpoint', 'witness']); /** * Processes inbound peer frames for a SymNode. @@ -240,7 +246,25 @@ class FrameHandler { } } + /** Spend one of `peerId`'s gossip tokens; false when the peer is past its budget. @private */ + _gossipAllowed(peerId, peerName, now = Date.now()) { + if (!this._gossipBuckets) this._gossipBuckets = new Map(); + let b = this._gossipBuckets.get(peerId); + if (!b) { b = { tokens: GOSSIP_BURST, at: now, dropped: 0, saidAt: 0 }; this._gossipBuckets.set(peerId, b); } + b.tokens = Math.min(GOSSIP_BURST, b.tokens + ((now - b.at) / 1000) * GOSSIP_PER_SECOND); + b.at = now; + if (b.tokens >= 1) { b.tokens -= 1; return true; } + b.dropped++; + if (now - b.saidAt > 60_000) { + b.saidAt = now; + this._node._log(`Gossip from ${peerName} past ${GOSSIP_PER_SECOND}/s: ${b.dropped} frame(s) dropped`); + this._node.emit('metric', { type: 'gossip-rate-limited', from: peerName, dropped: b.dropped }); + } + return false; + } + _dispatch(peerId, peerName, msg) { + if (GOSSIP_TYPES.has(msg.type) && !this._gossipAllowed(peerId, peerName)) return; switch (msg.type) { case 'handshake': this._handleHandshake(peerId, peerName, msg); diff --git a/lib/node.js b/lib/node.js index 2720c997..39d81e4c 100644 --- a/lib/node.js +++ b/lib/node.js @@ -1111,6 +1111,8 @@ class SymNode extends EventEmitter { _ingestCheckpoint(cp, fromPeerId) { if (!cp || !cp.by || cp.upto_seq === undefined || !cp.root) return { ok: false, reason: 'malformed' }; if (cp.roster && this._room && cp.roster !== this._room) return { ok: false, reason: 'roster-mismatch' }; + // A position already held is a duplicate, whatever copy this is: dropped before the signature check. + if (this._attestations.hasCheckpoint(cp.by, cp.upto_seq)) return { ok: false, reason: 'duplicate' }; const key = this._identityKey(cp.by); if (!key) return { ok: false, reason: 'unknown-attester-key' }; if (!verifyCheckpoint(cp, key).valid) return { ok: false, reason: 'bad-signature' }; @@ -1155,6 +1157,7 @@ class SymNode extends EventEmitter { _ingestWitness(w, fromPeerId) { if (!w || !w.attester || w.upto_seq === undefined || !w.root || !w.by) return { ok: false, reason: 'malformed' }; if (w.roster && this._room && w.roster !== this._room) return { ok: false, reason: 'roster-mismatch' }; + if (this._attestations.hasWitnessed(w.attester, w.upto_seq, w.by)) return { ok: false, reason: 'duplicate' }; const key = this._identityKey(w.by); if (!key) return { ok: false, reason: 'unknown-witness-key' }; if (!verifyWitness(w, key).valid) return { ok: false, reason: 'bad-signature' }; @@ -1214,6 +1217,9 @@ class SymNode extends EventEmitter { } // Verify against the attester's authenticated identity key. Known for a direct // peer (handshake key map); a relayed attester awaits the roster key registry. + // A signature already held was verified when it was stored: a repeat is dropped before the + // signature check, which is most of what a flood of repeats costs. + if (this._attestations.has(att.sig)) return { ok: false, reason: 'duplicate' }; const attesterKey = this._identityKey(att.by); if (!attesterKey) return { ok: false, reason: 'unknown-attester-key' }; const v = verifyAttestation(att, attesterKey); diff --git a/tests/admission-attestation.test.js b/tests/admission-attestation.test.js index a519c761..0233f95f 100644 --- a/tests/admission-attestation.test.js +++ b/tests/admission-attestation.test.js @@ -486,3 +486,48 @@ describe('a witness is relayed and signed once per statement', () => { }); }); }); + +// Under the storm every repeat still cost a signature check, and a node spent most of its thread +// verifying copies it already held. Repeats are dropped before verification, and one peer's gossip +// frames are capped. +describe('gossip repeats are cheap, and one peer cannot flood a node', () => { + const { signCheckpoint, signWitness } = require('../lib/core'); + const kp = () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', { publicKeyEncoding: { type: 'spki', format: 'der' }, privateKeyEncoding: { type: 'pkcs8', format: 'der' } }); + return { pub: publicKey.slice(-32).toString('base64url'), priv: privateKey.slice(-32).toString('base64url') }; + }; + it('a repeat is dropped before its signature is checked', () => { + const ATT = kp(), WIT = kp(); + withNode('att-early', { lifecycleRole: 'participant', room: 'g' }, (node) => { + node._pinPeerKey('node-att', ATT.pub); + node._pinPeerKey('node-wit', WIT.pub); + node._gossipToRoster = () => {}; + const cp = { type: 'checkpoint', by: 'node-att', roster: 'g', upto_seq: 8, root: 'r8', at: 1 }; + signCheckpoint(cp, ATT.priv); + assert.strictEqual(node._ingestCheckpoint(cp, 'node-att').ok, true); + const forgedCp = { ...cp, sig: 'not-a-signature' }; + assert.strictEqual(node._ingestCheckpoint(forgedCp, 'node-att').reason, 'duplicate', 'not even verified'); + const w = { type: 'witness', attester: 'node-att', roster: 'g', upto_seq: 8, root: 'r8', by: 'node-wit', role: 'participant', at: 1 }; + signWitness(w, WIT.priv); + assert.strictEqual(node._ingestWitness(w, 'node-wit').ok, true); + assert.strictEqual(node._ingestWitness({ ...w, sig: 'garbage' }, 'node-wit').reason, 'duplicate'); + }); + }); + + it('one peer past its gossip budget is dropped and counted; another peer is unaffected', () => { + withNode('att-rate', { lifecycleRole: 'participant', room: 'g' }, (node) => { + node._roomDoor = () => ({ pass: true }); + let ingested = 0; + node._ingestWitness = () => { ingested++; return { ok: false }; }; + const metrics = []; + node.on('metric', (m) => metrics.push(m)); + const frame = { type: 'witness', witness: { attester: 'a', upto_seq: 1, root: 'r', by: 'b', sig: 's' } }; + for (let i = 0; i < 2000; i++) node._frameHandler.handle('flooder', 'flooder', frame); + assert.ok(ingested <= 520, `at most the burst (and what refilled meanwhile) was handled: ${ingested}`); + assert.ok(metrics.some((m) => m.type === 'gossip-rate-limited' && m.from === 'flooder')); + const before = ingested; + node._frameHandler.handle('quiet-peer', 'quiet-peer', frame); + assert.strictEqual(ingested, before + 1, 'another peer has its own budget'); + }); + }); +}); From 3807ad42cd39d68de394544a92b612de81b91288 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 22:04:13 +0100 Subject: [PATCH 053/148] daemon: never remove a served socket; listen again when ours is removed startIPCServer removed any socket file as stale, so a second daemon start unlinked the live daemon's socket and that daemon became unreachable while it kept running. The path is probed first (a second daemon exits), shutdown removes only this daemon's own socket (by inode), and a removed socket is re-bound within 30 s (SYM_SOCKET_CHECK_MS for tests). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- CHANGELOG.md | 6 ++++ bin/sym-daemon.js | 55 +++++++++++++++++++++++++++++---- tests/daemon-relay-only.test.js | 44 ++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54958eb8..1d0c1970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -156,6 +156,12 @@ debt, and the daemon's log flood. Every item has a test that fails without its f - **A daemon rooted with `SYM_STATE_DIR` kept its room, tasks and relay.env in `~/.sym`.** It uses the state root now, and so does the `sym` CLI: its pid file, room, relay.env and node directory. Two rooted deployments sharing a home shared one pid file, so `sym stop` for one stopped the other. +- **A second daemon start took the socket from the one serving it.** The daemon removed any socket + file at start, assuming it stale, so a second start unlinked a live daemon's socket; that daemon + ran on, reachable by no client, and every client reported "sym-daemon not running". A socket file + is now removed only when nothing answers on it (otherwise the second start exits), a daemon + removes only its own socket at shutdown, and a daemon whose socket file is removed listens on it + again within 30 s. - **A daemon IPC request whose handler threw went unanswered.** It is answered with the error, and a refused `remember` carries the SDK's code (`ECMBSIZE`, `ESIGN`). - **A relay-only daemon still announced its room on the LAN.** It no longer does. diff --git a/bin/sym-daemon.js b/bin/sym-daemon.js index 885bb4ad..b87ea5dd 100755 --- a/bin/sym-daemon.js +++ b/bin/sym-daemon.js @@ -214,12 +214,41 @@ const listeners = new Map(); // socketId → socket — real-time event subscrib * See MMP v0.2.0 Section 13 (Application). * @returns {net.Server} */ -function startIPCServer() { +/** + * Whether a daemon is serving the socket path: a connect that succeeds means yes; a refused or + * missing socket is stale. Named pipes (Windows) have no file to remove, so they are not probed. + * @returns {Promise} + */ +function socketServed() { + if (process.platform === 'win32' || !fs.existsSync(SOCKET_PATH)) return Promise.resolve(false); + return new Promise((resolve) => { + const c = net.createConnection(SOCKET_PATH); + const done = (v) => { c.destroy(); resolve(v); }; + c.once('connect', () => done(true)); + c.once('error', () => done(false)); + setTimeout(() => done(false), 2000).unref(); + }); +} + +/** The socket file this daemon created (its inode), so it never removes another daemon's. */ +let ownSocketIno = null; +function socketIsOurs() { + if (process.platform === 'win32' || ownSocketIno === null) return false; + try { return fs.statSync(SOCKET_PATH).ino === ownSocketIno; } catch { return false; } +} + +async function startIPCServer() { // Ensure ~/.sym/ exists if (!fs.existsSync(SYM_DIR)) { fs.mkdirSync(SYM_DIR, { recursive: true }); } - // Clean up stale socket + // A socket file is removed only when nothing answers on it. Removing it unconditionally let a + // second daemon start take the path from the one already serving: that daemon kept running, + // reachable by no client, and every client reported "sym-daemon not running". + if (await socketServed()) { + log(`Another sym-daemon is serving ${SOCKET_PATH}; not starting a second one.`); + process.exit(1); + } if (fs.existsSync(SOCKET_PATH)) { try { fs.unlinkSync(SOCKET_PATH); } catch {} } @@ -264,13 +293,26 @@ function startIPCServer() { }); }); - server.listen(SOCKET_PATH, () => { + const onListening = () => { // chmod not applicable on Windows named pipes if (process.platform !== 'win32') { try { fs.chmodSync(SOCKET_PATH, 0o700); } catch {} + try { ownSocketIno = fs.statSync(SOCKET_PATH).ino; } catch { ownSocketIno = null; } } log(`IPC server listening: ${SOCKET_PATH}`); - }); + }; + server.listen(SOCKET_PATH, onListening); + + // If the socket file is removed or replaced while this daemon runs, clients can no longer reach + // it. It is checked every 30 s and, when gone, listened on again. + if (process.platform !== 'win32') { + setInterval(() => { + if (ownSocketIno === null || socketIsOurs()) return; + if (fs.existsSync(SOCKET_PATH)) return; // another process's socket now: leave it alone + log(`IPC socket ${SOCKET_PATH} was removed while this daemon was serving; listening again`); + server.close(() => server.listen(SOCKET_PATH, onListening)); + }, Number(process.env.SYM_SOCKET_CHECK_MS) || 30_000).unref(); + } server.on('error', (err) => { log(`IPC server error: ${err.message}`); @@ -929,7 +971,7 @@ async function main() { broadcastToListeners({ type: 'event', event: 'peer-left', data: { name: data.name || 'unknown', peerId: data.peerId } }); }); - const ipcServer = startIPCServer(); + const ipcServer = await startIPCServer(); log('sym-daemon ready'); @@ -939,7 +981,8 @@ async function main() { stopRoomBeacon(); node.stop(); ipcServer.close(); - if (fs.existsSync(SOCKET_PATH)) { + // Only our own socket file: a path another daemon now serves is left to it. + if (socketIsOurs()) { try { fs.unlinkSync(SOCKET_PATH); } catch {} } process.exit(0); diff --git a/tests/daemon-relay-only.test.js b/tests/daemon-relay-only.test.js index 3dbb4ec6..a3789e01 100644 --- a/tests/daemon-relay-only.test.js +++ b/tests/daemon-relay-only.test.js @@ -130,3 +130,47 @@ test('a daemon with SYM_STATE_DIR keeps its own files in that root, not in ~/.sy fs.rmSync(base, { recursive: true, force: true }); } }); + +// A second daemon start removed the socket of the daemon already serving it, which then ran on, +// reachable by no client (every client: "sym-daemon not running"). A served socket is never +// removed, and a daemon whose socket file is removed listens on it again. +test('a second daemon never takes a served socket, and a removed socket is listened on again', { skip: process.platform === 'win32' && 'a socket file' }, async () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'sym-sock-')); + const socket = path.join(base, 'd.sock'); + const start = (name) => { + const home = path.join(base, name); fs.mkdirSync(home); + const env = { ...process.env, HOME: home, USERPROFILE: home, SYM_STATE_DIR: path.join(home, '.sym'), SYM_SOCKET: socket, SYM_NODE_NAME: name, SYM_RELAY_ONLY: '1', SYM_SOCKET_CHECK_MS: '300' }; + delete env.SYM_RELAY_URL; delete env.SYM_RELAY_TOKEN; delete env.SYM_ROOM; + const d = spawn(process.execPath, [path.join(BIN, 'sym-daemon.js')], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + d.out = ''; + d.stdout.on('data', (b) => { d.out += b; }); + d.stderr.on('data', (b) => { d.out += b; }); + return d; + }; + const until = async (cond, ms = 15000) => { for (let t = 0; t < ms && !cond(); t += 100) await new Promise((r) => setTimeout(r, 100)); return cond(); }; + const status = () => new Promise((resolve) => { + const c = net.createConnection(socket); + let buf = ''; + c.on('data', (d) => { buf += d; if (buf.includes('\n')) { c.end(); resolve(true); } }); + c.on('error', () => resolve(false)); + c.on('connect', () => c.write(JSON.stringify({ type: 'status' }) + '\n')); + setTimeout(() => resolve(false), 3000); + }); + const a = start('first'); + let b = null; + try { + assert.ok(await until(() => a.out.includes('IPC server listening')), a.out.slice(-600)); + b = start('second'); + assert.ok(await until(() => b.exitCode !== null), `the second daemon exits: ${b.out.slice(-400)}`); + assert.match(b.out, /Another sym-daemon is serving/); + assert.equal(await status(), true, 'the first is still reachable on the socket'); + fs.unlinkSync(socket); + assert.ok(await until(() => fs.existsSync(socket), 5000), 'the first listens again on its removed socket'); + assert.equal(await status(), true); + } finally { + a.kill('SIGTERM'); + if (b && b.exitCode === null) b.kill('SIGTERM'); + await new Promise((r) => setTimeout(r, 300)); + fs.rmSync(base, { recursive: true, force: true }); + } +}); From b405fead9475fbb00460fd174d013966fbed534d Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 22:17:14 +0100 Subject: [PATCH 054/148] 0.14.0: the large release is a minor version (the store's annotations moved to the entry) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- CHANGELOG.md | 5 ++++- lib/core/wake.js | 2 +- lib/memory-store.js | 2 +- tests/audit-delivery-authorship.test.js | 2 +- tests/inbox-limit-and-clock.test.js | 2 +- tests/mesh-agent.test.js | 2 +- tests/semantic-single-load.test.js | 2 +- tests/state-root-tenancy.test.js | 2 +- tests/v2-author-binding.test.js | 2 +- tests/wake-channels.test.js | 2 +- 10 files changed, 13 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d0c1970..032ad2f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog -## Unreleased +## 0.14.0 (unreleased) + +A minor version because one change breaks readers outside sym: the store's annotations moved from +the stored record to the entry (see Changed). Also carries the 0.13.15 witness-storm fix. Fixes from the MMP 2.0 conformance audit's open findings, the 0.13.12 known limits, the Windows test debt, and the daemon's log flood. Every item has a test that fails without its fix. diff --git a/lib/core/wake.js b/lib/core/wake.js index a6796a42..9f42fb61 100644 --- a/lib/core/wake.js +++ b/lib/core/wake.js @@ -32,7 +32,7 @@ const WAKE_CHANNEL_TTL_MS = 30 * 24 * 60 * 60 * 1000; const REFRESH_GRANULARITY_MS = 60 * 60 * 1000; /** How far each source of a wake channel is trusted. Gossip is unauthenticated. A channel kept by - * a release before 0.13.15 carries no source: it may have come from the phone itself, so gossip + * a release before 0.14.0 carries no source: it may have come from the phone itself, so gossip * must not repoint it, while the relay and the phone still can. */ const SOURCE_RANK = Object.freeze({ gossip: 1, legacy: 2, relay: 2, direct: 3 }); diff --git a/lib/memory-store.js b/lib/memory-store.js index 79dd2a2a..915dc866 100644 --- a/lib/memory-store.js +++ b/lib/memory-store.js @@ -51,7 +51,7 @@ const ENTRY_ANNOTATIONS = Object.freeze(['admission', 'tether', 'provenance', 'c /** * An entry whose record is exactly what was signed, for every entry the store writes or reads. * - * A two-section record has exactly `categories` and `metadata` (§8.8.1). Stores before 0.13.15 put + * A two-section record has exactly `categories` and `metadata` (§8.8.1). Stores before 0.14.0 put * more on it, and files they wrote still hold it: * * lineage the store's expanded lineage as a top-level member. Readers that take a top-level diff --git a/tests/audit-delivery-authorship.test.js b/tests/audit-delivery-authorship.test.js index 179dbc3f..e5ce064b 100644 --- a/tests/audit-delivery-authorship.test.js +++ b/tests/audit-delivery-authorship.test.js @@ -729,7 +729,7 @@ describe('B-D5: directed sends that mint nothing still deliver (MMP §4.4.4)', ( }); }); -// 0.13.15 review F7: a hedged Ed25519 signer (WebKit) signs one assertion differently each time, so +// 0.14.0 review F7: a hedged Ed25519 signer (WebKit) signs one assertion differently each time, so // a mark taken from the signature bytes named one directed assertion twice. describe('directed de-duplication marks the assertion, not its signature bytes', () => { const { assertionMark } = require('../lib/frame-handler'); diff --git a/tests/inbox-limit-and-clock.test.js b/tests/inbox-limit-and-clock.test.js index 4724de54..4b19cd3c 100644 --- a/tests/inbox-limit-and-clock.test.js +++ b/tests/inbox-limit-and-clock.test.js @@ -36,7 +36,7 @@ describe('inbox limit (K4)', () => { } finally { fs.rmSync(nodeDir(name), { recursive: true, force: true }); } }); - // 0.13.15 review F6: counting only unread items let the reply grow to the whole ring. + // 0.14.0 review F6: counting only unread items let the reply grow to the whole ring. it('the limit still bounds the reply when most of the ring is acked', () => { const name = uniq('inbox-bound'); const node = mkNode(name); diff --git a/tests/mesh-agent.test.js b/tests/mesh-agent.test.js index 6516c572..5d2aa05e 100644 --- a/tests/mesh-agent.test.js +++ b/tests/mesh-agent.test.js @@ -150,7 +150,7 @@ describe('MeshAgent', () => { }); }); -// 0.13.15 review F8: remember() throws ESIGN when the node cannot sign. The agent says so once and +// 0.14.0 review F8: remember() throws ESIGN when the node cannot sign. The agent says so once and // stops, instead of an error for every admitted record and a model call it could never use. describe('MeshAgent when its node cannot sign', () => { const { MeshAgent } = require('../lib/mesh-agent'); diff --git a/tests/semantic-single-load.test.js b/tests/semantic-single-load.test.js index bba456de..879dda2b 100644 --- a/tests/semantic-single-load.test.js +++ b/tests/semantic-single-load.test.js @@ -1,7 +1,7 @@ 'use strict'; /** - * The semantic encoder is loaded once per process (0.13.15). Under load, a second caller asking + * The semantic encoder is loaded once per process (0.14.0). Under load, a second caller asking * for it while the first load was still running started a second model load, and parallel loads * were what made the full suite report "semantic encoder did not become ready". * diff --git a/tests/state-root-tenancy.test.js b/tests/state-root-tenancy.test.js index b9551a01..3ba48ea3 100644 --- a/tests/state-root-tenancy.test.js +++ b/tests/state-root-tenancy.test.js @@ -85,7 +85,7 @@ test('and the SAME root still refuses a second holder — the single-writer rule } finally { holder.kill(); } }); -// The CLI's own state follows SYM_STATE_DIR, as its daemon's does (0.13.15 review F5): rooted in +// The CLI's own state follows SYM_STATE_DIR, as its daemon's does (0.14.0 review F5): rooted in // the home, two rooted deployments shared one pid file, so `sym stop` for one stopped the other, // and a room or relay set for a rooted daemon was written where that daemon never reads. test('the CLI reads its room from SYM_STATE_DIR, not the home (pid and relay.env resolve the same way)', () => { diff --git a/tests/v2-author-binding.test.js b/tests/v2-author-binding.test.js index c6e77780..48205ef8 100644 --- a/tests/v2-author-binding.test.js +++ b/tests/v2-author-binding.test.js @@ -113,7 +113,7 @@ describe('the inbox names a proven author (K5)', () => { }); }); -describe('a malformed v2.0 frame is refused, never thrown (0.13.15 review F1)', () => { +describe('a malformed v2.0 frame is refused, never thrown (0.14.0 review F1)', () => { // Declares the v2.0 suite but carries no room and no signature: it has no preimage, and building // one throws. On the relay path that throw reached the process and ended the daemon. function malformed() { diff --git a/tests/wake-channels.test.js b/tests/wake-channels.test.js index 5dd4e059..69b8dd24 100644 --- a/tests/wake-channels.test.js +++ b/tests/wake-channels.test.js @@ -160,7 +160,7 @@ describe('relay peer list', () => { }); }); -describe('0.13.15 review F2/F3', () => { +describe('0.14.0 review F2/F3', () => { it('a channel kept by 0.13.14 (no source) is not repointed by gossip, and the relay or the phone still can', () => { withNode((node) => { const wm = node._wakeManager; From 00ced01f1ee6dc48ea399ac446554c12a92ded6d Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Fri, 2 Oct 2026 02:15:54 +0100 Subject: [PATCH 055/148] 0.14.0: the attestation logs are rotated, and archive/ is bounded A log is rotated once the bytes in it beyond the records held exceed max(8 MiB, bytes held): a rotation always reclaims more than it writes, is never followed by another until that much more has been appended and dropped, and a start applies the same rule, so it rotates only a log over budget. A rotation writes the held set to a temp file (every byte, fsynced), links the old log into archive/ under ..