From b8e5ae91131233455b5fa74bb12e269801f317df Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 16:31:53 +0100 Subject: [PATCH 1/5] attestation-received: a peer's admission verdict is observable as it lands A receiver gossips a signed Admission Attestation for every CMB it gates, but the author, or a mesh visualiser, could only see it by polling attestationsFor(). The node now emits 'attestation-received' on first sight of a verified, recorded attestation: of, by/byName, verdict, per-field categories, method, role, seq/prev, sig, verified, keySource (anchor, grant or handshake; handshake is trust on first use), from/fromPeerId and relayed. Duplicates, rate-limited copies, roster mismatches, unknown attesters and bad signatures emit nothing. Listeners get a copy, so they cannot alter the stored record, and a throwing listener does not undo the ingest. Asked for by dev-team-2 for the XMesh World hotel-bots view, which shows real SVAF signals instead of hardcoded passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- CHANGELOG.md | 22 ++++++ lib/node.js | 49 +++++++++++- tests/admission-attestation.test.js | 85 +++++++++++++++++++++ tests/integration/e2e-attestation-gossip.js | 13 ++++ 4 files changed, 168 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1b17c1f..769d3ffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## Unreleased + +### Added — a peer's admission verdict is observable as it lands (`attestation-received`) + +A receiver signs an Admission Attestation for every CMB it gates and gossips it to the room. Until now the +author, or anything watching the mesh, could see those verdicts only by polling `attestationsFor(cmbKey)`. +The node now emits `attestation-received` the first time it verifies and records a peer's attestation: + +- `of` (the gated CMB key), `by` (the attester's nodeId) and `byName` (its peer name, when it is a direct + peer); +- `verdict`, the per-field `categories` (admit / guard / redundant / reject / silent), `method` and `role`; +- `seq` and `prev`, its place on the attester's hash chain, plus `sig`, `roster` and `at`; +- `verified: true` and `keySource`, which names where the key that verified it came from: `anchor`, `grant`, + or `handshake`. A `handshake` key is trusted on first use: it belongs to whoever completed the handshake + under that nodeId; +- `from` / `fromPeerId`, the peer that delivered the frame, and `relayed`, true when that peer is not the + attester. + +A duplicate, a rate-limited copy, a roster mismatch, an unknown attester or a bad signature emits nothing. +The event is a copy of the stored attestation, so a listener cannot change the audit record. A listener that +throws does not undo the ingest. + ## 0.13.13 (2026-10-01) ### Fixed — a crashed Windows session no longer locks its node name until reboot diff --git a/lib/node.js b/lib/node.js index 2f4c5445..164852a2 100644 --- a/lib/node.js +++ b/lib/node.js @@ -1183,6 +1183,11 @@ class SymNode extends EventEmitter { * outcome so the frame-handler can log. Verification of a RELAYED attester (one that * is not a direct peer) needs the permissioned-roster key registry — pending; in the * current fully-connected roster every attester is a direct peer. + * + * On first sight of a verified attestation this emits 'attestation-received', so an + * observer (an author watching its CMB's admission, a mesh visualiser) sees a peer's + * verdict as it lands instead of polling attestationsFor(). A duplicate, a rate-limited + * copy, a roster mismatch, an unknown attester or a bad signature emits nothing. * @private */ _ingestAttestation(att, fromPeerId, fromPeerName) { @@ -1199,10 +1204,52 @@ class SymNode extends EventEmitter { if (!v.valid) return { ok: false, reason: v.error || 'bad-signature' }; // Record (rate-limited as ingested). Relay-once only on first sight. const r = this._attestations.record(att, { ingested: true }); - if (r.stored) this._gossipAttestation(att, fromPeerId); + if (r.stored) { + this._gossipAttestation(att, fromPeerId); + this._emitAttestationReceived(att, fromPeerId, fromPeerName); + } return { ok: r.stored, reason: r.reason }; } + /** + * Emit 'attestation-received' for an attestation that was just verified and recorded. + * The event is a copy: a listener that edits it cannot alter the stored audit record. + * + * `verified` means the signature checked out against the key this node holds for + * `by`, and `keySource` says where that key came from: 'anchor', 'grant', or + * 'handshake' (first use: whoever completed the handshake under that nodeId). + * `from` is the peer that delivered the frame; `relayed` is true when that peer is + * not the attester. `seq`/`prev` place it on the attester's hash chain. + * @private + */ + _emitAttestationReceived(att, fromPeerId, fromPeerName) { + try { + this.emit('attestation-received', { + of: att.of, + by: att.by, + byName: this._peers.get(att.by)?.name ?? null, + verdict: att.verdict, + categories: att.categories ? { ...att.categories } : null, + method: att.method ?? null, + role: att.role ?? null, + roster: att.roster ?? null, + at: att.at ?? null, + seq: att.seq, + prev: att.prev ?? null, + sig: att.sig, + verified: true, + keySource: this._roster.source(att.by) ?? (this._peerIdentityKeys?.has(att.by) ? 'handshake' : null), + from: fromPeerName ?? null, + fromPeerId: fromPeerId ?? null, + relayed: !!fromPeerId && fromPeerId !== att.by, + receivedAt: Date.now(), + }); + } catch (err) { + // A listener's failure must not undo or block the ingest it is observing. + this._log(`attestation-received listener failed: ${err.message}`); + } + } + /** * Chain-integrity check for an attester (default: this node) — the local half of * omission-evidence. Reports `seq` gaps (suppressed attestations) and `prev` diff --git a/tests/admission-attestation.test.js b/tests/admission-attestation.test.js index b2799f30..3287656c 100644 --- a/tests/admission-attestation.test.js +++ b/tests/admission-attestation.test.js @@ -148,3 +148,88 @@ describe('node checkpoints its chain; reconciliation catches omission (D3)', () }); }); }); + +describe("'attestation-received' — a peer's verified verdict is observable as it lands", () => { + // B ingests attestations A built, as it would from an `attestation` frame. B holds A's key the + // way a handshake leaves it (_pinPeerKey), so verification runs for real; no transport is needed. + function withPair(fn) { + return withNode('att-rx-a', { room: 'hotel' }, (A) => withNode('att-rx-b', { room: 'hotel' }, (B) => { + B._pinPeerKey(A.nodeId, A._identity.publicKey); + const seen = []; + B.on('attestation-received', (e) => seen.push(e)); + return fn(A, B, seen); + })); + } + + it('emits once per verified attestation, carrying who judged which CMB, how, and where on the chain', () => { + withPair((A, B, seen) => { + const first = A._buildAdmissionAttestation('cmb-request-1', 'aligned', verdicts, 'heuristic'); + const att = A._buildAdmissionAttestation('cmb-request-2', 'guarded', verdicts, 'neural'); + assert.deepStrictEqual(B._ingestAttestation(att, A.nodeId, 'concierge'), { ok: true, reason: undefined }); + assert.strictEqual(seen.length, 1); + const e = seen[0]; + assert.strictEqual(e.of, 'cmb-request-2'); + assert.strictEqual(e.by, A.nodeId); + assert.strictEqual(e.verdict, 'guarded'); + assert.deepStrictEqual(e.categories, verdicts); + assert.strictEqual(e.method, 'neural'); + assert.strictEqual(e.roster, 'hotel'); + assert.strictEqual(e.seq, 2); + assert.strictEqual(e.prev, crypto.createHash('sha256').update(first.sig).digest('hex')); + assert.strictEqual(e.sig, att.sig); + assert.strictEqual(e.verified, true); + assert.strictEqual(e.keySource, 'handshake'); + assert.strictEqual(e.from, 'concierge'); + assert.strictEqual(e.fromPeerId, A.nodeId); + assert.strictEqual(e.relayed, false); + assert.strictEqual(typeof e.receivedAt, 'number'); + }); + }); + + it('marks a copy that arrived through another peer as relayed', () => { + withPair((A, B, seen) => { + const att = A._buildAdmissionAttestation('cmb-request-3', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(att, 'relay-node-id', 'housekeeping').ok, true); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].by, A.nodeId); + assert.strictEqual(seen[0].from, 'housekeeping'); + assert.strictEqual(seen[0].relayed, true); + }); + }); + + it('stays silent for a duplicate, a forgery, a foreign roster and an unknown attester', () => { + withPair((A, B, seen) => { + const att = A._buildAdmissionAttestation('cmb-request-4', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + assert.strictEqual(B._ingestAttestation({ ...att }, A.nodeId, 'concierge').reason, 'duplicate'); + + const forged = { ...A._buildAdmissionAttestation('cmb-request-5', 'rejected', verdicts, 'heuristic'), verdict: 'aligned' }; + assert.strictEqual(B._ingestAttestation(forged, A.nodeId, 'concierge').ok, false); + + const foreign = A._buildAdmissionAttestation('cmb-request-6', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation({ ...foreign, roster: 'other-room' }, A.nodeId, 'concierge').reason, 'roster-mismatch'); + + withNode('att-rx-stranger', { room: 'hotel' }, (S) => { + const unknown = S._buildAdmissionAttestation('cmb-request-7', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(unknown, S.nodeId, 'stranger').reason, 'unknown-attester-key'); + }); + + assert.strictEqual(seen.length, 1, 'only the first verified copy is announced'); + assert.strictEqual(seen[0].of, 'cmb-request-4'); + }); + }); + + it('hands listeners a copy, and a failing listener does not undo the ingest', () => { + withPair((A, B, seen) => { + B.on('attestation-received', (e) => { e.verdict = 'rejected'; e.categories.focus = 'reject'; throw new Error('listener bug'); }); + const att = A._buildAdmissionAttestation('cmb-request-8', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true, 'the ingest still reports success'); + const stored = B.attestationsFor('cmb-request-8'); + assert.strictEqual(stored.length, 1, 'the attestation is recorded'); + assert.strictEqual(stored[0].verdict, 'aligned', 'the stored verdict is untouched'); + assert.strictEqual(stored[0].categories.focus, 'admit', 'the stored categories are untouched'); + assert.deepStrictEqual(verifyAttestation(stored[0], A._identity.publicKey), { signed: true, valid: true }); + assert.strictEqual(seen.length, 1); + }); + }); +}); diff --git a/tests/integration/e2e-attestation-gossip.js b/tests/integration/e2e-attestation-gossip.js index 878885e8..56715c20 100644 --- a/tests/integration/e2e-attestation-gossip.js +++ b/tests/integration/e2e-attestation-gossip.js @@ -46,6 +46,8 @@ describe('E2E Admission Attestation gossip (D2)', () => { A._addPeer(A._createPeer(tA, B.nodeId, bName, true, 'bonjour')); B._addPeer(B._createPeer(tB, A.nodeId, aName, false, 'bonjour')); await sleep(400); + const announced = []; + B.on('attestation-received', (e) => announced.push(e)); assert.ok(B._peerIdentityKeys.get(A.nodeId), 'B has A\'s authenticated identity key from the handshake'); const entry = B.remember({ @@ -63,6 +65,16 @@ describe('E2E Admission Attestation gossip (D2)', () => { assert.strictEqual(att.of, K, 'bound to B\'s gated CMB'); assert.deepStrictEqual(verifyAttestation(att, A._identity.publicKey), { signed: true, valid: true }, 'A\'s original signature verifies end-to-end'); assert.ok(A.attestationsFor(K).length >= 1, 'the gater A also holds it'); + // The author sees A's verdict live, over the real frame path, without polling. + const live = announced.filter((e) => e.of === K); + assert.strictEqual(live.length, 1, "B announced A's attestation once"); + assert.strictEqual(live[0].by, A.nodeId); + assert.strictEqual(live[0].byName, aName); + assert.strictEqual(live[0].from, aName); + assert.strictEqual(live[0].relayed, false); + assert.strictEqual(live[0].verified, true); + assert.strictEqual(live[0].keySource, 'handshake'); + assert.strictEqual(live[0].sig, att.sig); // A forged attestation (bad signature) must be dropped on ingest. const before = B._attestations.size(); @@ -72,6 +84,7 @@ describe('E2E Admission Attestation gossip (D2)', () => { }); await sleep(50); assert.strictEqual(B._attestations.size(), before, 'a forged attestation is rejected, not recorded'); + assert.strictEqual(announced.filter((e) => e.sig === 'AAAAforged').length, 0, 'a forged attestation is never announced'); await A.stop(); await B.stop(); fs.rmSync(nodeDir(aName), { recursive: true, force: true }); From d9bc57f1dc708dd948cdfb64e23577788662332c Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 16:46:20 +0100 Subject: [PATCH 2/5] attestation-received: pass on only what is signed, and isolate every listener Fixes from review mission-2b03e90f1876 (SHIP-WITH-FIXES, F1-F14): - method is outside the signed bytes, so it is now methodUnsigned (F1). - role is the attester's claim; roleResolved/roleMatches give what this node's grant chain resolves (F2). - categories are projected onto the seven CAT7 fields as the strings the signature covers: no nested value aliases the stored record, and no unsigned key a relay added reaches a listener (F3, F4). - The event is built from a clone and frozen. Each listener is called on its own: a throw (of any value) or an async rejection is logged with the attestation it was handling and cannot starve later listeners, undo the ingest, or become an unhandled rejection. once() still works (F5, F6, F10). - sigAlg is carried, so verifyAttestation(event, key) re-checks it (F7). - relayed is null when the deliverer is unknown (F12). - Tests: byName vs from with different names, unknown deliverer, claimed vs resolved role, relay-added fields, the rate-limited path, listener order, throw null, async rejection, once (F8, F9). Each protection was removed in turn and fails at least one test. - CHANGELOG separates signed fields from what this node adds, documents receivedAt, and says this node's own verdict is not on the event (F11, F13, F14). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- CHANGELOG.md | 44 ++++++--- lib/node.js | 85 ++++++++++++----- tests/admission-attestation.test.js | 136 ++++++++++++++++++++++------ 3 files changed, 201 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 769d3ffd..160f8969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,21 +6,39 @@ A receiver signs an Admission Attestation for every CMB it gates and gossips it to the room. Until now the author, or anything watching the mesh, could see those verdicts only by polling `attestationsFor(cmbKey)`. -The node now emits `attestation-received` the first time it verifies and records a peer's attestation: - -- `of` (the gated CMB key), `by` (the attester's nodeId) and `byName` (its peer name, when it is a direct - peer); -- `verdict`, the per-field `categories` (admit / guard / redundant / reject / silent), `method` and `role`; -- `seq` and `prev`, its place on the attester's hash chain, plus `sig`, `roster` and `at`; -- `verified: true` and `keySource`, which names where the key that verified it came from: `anchor`, `grant`, - or `handshake`. A `handshake` key is trusted on first use: it belongs to whoever completed the handshake - under that nodeId; -- `from` / `fromPeerId`, the peer that delivered the frame, and `relayed`, true when that peer is not the - attester. +The node now emits `attestation-received` the first time it verifies and records a peer's attestation. + +Fields covered by the attester's signature, passed on as signed: + +- `of` (the gated CMB key), `by` (the attester's nodeId), `at`, `roster`, `seq` and `prev` (its place on the + attester's hash chain); +- `verdict`, and `categories`: the seven CAT7 fields (admit / guard / redundant / reject / silent), as the + strings the signature covers. Any other key in the frame is unsigned and dropped; +- `role`, the role the attester claims. A node can stamp any role and still sign validly; +- `sig` and `sigAlg`, so a consumer holding the attester's key can re-check the event with + `verifyAttestation(event, key)` instead of trusting `verified`. + +Fields this node adds, which the signature does not vouch for: + +- `verified: true`, and `keySource`, where the verifying key came from: `anchor`, `grant`, or `handshake`. A + `handshake` key is trusted on first use: it belongs to whoever completed the handshake under that nodeId; +- `roleResolved` and `roleMatches`, the role this node's grant chain resolves for the attester. Weight a + verdict by these, not by `role`; +- `methodUnsigned`, the evaluation method (`heuristic` / `neural`). It is outside the signed bytes, so a relay + could have changed it; +- `byName`, the name the attester announced when it connected, only when it is a direct peer. It is a label, + not an authenticated name: `by` is the identity; +- `from` / `fromPeerId`, the peer that delivered the frame, and `relayed`: true when that peer is not the + attester, false when it is, null when the deliverer is unknown; +- `receivedAt`, this node's clock, not the attester's `at`. A duplicate, a rate-limited copy, a roster mismatch, an unknown attester or a bad signature emits nothing. -The event is a copy of the stored attestation, so a listener cannot change the audit record. A listener that -throws does not undo the ingest. +Neither does this node's own verdict: read that from `svaf-decision` or `attestationsFor()`. + +The event is built from a copy and frozen, so a listener cannot change the stored record or what the next +listener sees. Each listener is called on its own. One that throws (any value), or an async listener whose +promise rejects, is logged with the attestation it was handling. It does not stop later listeners, undo the +ingest, or become an unhandled rejection. ## 0.13.13 (2026-10-01) diff --git a/lib/node.js b/lib/node.js index 164852a2..a38d83a0 100644 --- a/lib/node.js +++ b/lib/node.js @@ -38,7 +38,7 @@ const { e2eGenerateKeyPair, e2eDeriveSharedSecret, encryptCategories, signCMB, assertionIdV2_0, mintRemixKey, signAttestation, verifyAttestation, verifyAttestationRole, merkleRoot, signCheckpoint, verifyCheckpoint, signWitness, verifyWitness, - signGrant, roleRank, + signGrant, roleRank, CAT7_CATEGORIES, } = require('./core'); const { MMP_EMIT_V2 } = require('./emit-policy'); // the single reader-first flip switch const { FrameHandler } = require('./frame-handler'); @@ -1213,40 +1213,77 @@ class SymNode extends EventEmitter { /** * Emit 'attestation-received' for an attestation that was just verified and recorded. - * The event is a copy: a listener that edits it cannot alter the stored audit record. * - * `verified` means the signature checked out against the key this node holds for - * `by`, and `keySource` says where that key came from: 'anchor', 'grant', or - * 'handshake' (first use: whoever completed the handshake under that nodeId). - * `from` is the peer that delivered the frame; `relayed` is true when that peer is - * not the attester. `seq`/`prev` place it on the attester's hash chain. + * Signed fields pass through as signed: `of`, `by`, `at`, `roster`, `verdict`, `seq`, `prev`, + * `role`, and the seven CAT7 `categories` as the strings the signature covers (any other key is + * unsigned and dropped), with `sig`/`sigAlg`, so verifyAttestation(event, key) re-checks it. + * `role` is the attester's own CLAIM: a node can stamp any role and still sign validly, so + * `roleResolved`/`roleMatches` give the role this node's grant chain resolves. What the signature + * does not cover is named so: `methodUnsigned` is outside the signed bytes, so a relay could have + * changed it, and `byName` is the label the peer announced; `by` is the identity. + * + * `verified` means the signature checked out against the key this node holds for `by`, and + * `keySource` says where that key came from: 'anchor', 'grant', or 'handshake' (trust on first + * use: whoever completed the handshake under that nodeId). `from` is the peer that delivered the + * frame; `relayed` is true when that peer is not the attester, null when the deliverer is + * unknown. `receivedAt` is this node's clock, not the attester's `at`. + * + * The event is built from a clone and frozen, so a listener can neither reach the stored record + * nor change what the next listener sees. Each listener is called on its own: one that throws, + * or returns a promise that rejects, is logged and cannot starve later listeners or undo the + * ingest. * @private */ _emitAttestationReceived(att, fromPeerId, fromPeerName) { + const why = (e) => { try { return (e && e.message) || String(e); } catch { return 'unprintable error'; } }; + const subject = `${String(att.of).slice(0, 12)} by ${String(att.by).slice(0, 8)}`; + let event; try { - this.emit('attestation-received', { - of: att.of, - by: att.by, - byName: this._peers.get(att.by)?.name ?? null, - verdict: att.verdict, - categories: att.categories ? { ...att.categories } : null, - method: att.method ?? null, - role: att.role ?? null, - roster: att.roster ?? null, - at: att.at ?? null, - seq: att.seq, - prev: att.prev ?? null, - sig: att.sig, + const a = structuredClone(att); + const categories = {}; + for (const f of CAT7_CATEGORIES) { + const v = a.categories && a.categories[f]; + categories[f] = v ? String(v) : null; // the form canonicalCategories signs + } + const role = verifyAttestationRole(a, (id, at) => this.resolveRole(id, at)); + event = Object.freeze({ + of: a.of, + by: a.by, + byName: this._peers.get(a.by)?.name ?? null, + verdict: a.verdict ?? null, + categories: Object.freeze(categories), + role: a.role ?? null, + roleResolved: role.resolved, + roleMatches: role.matches, + methodUnsigned: a.method ?? null, + roster: a.roster ?? null, + at: a.at ?? null, + seq: a.seq, + prev: a.prev ?? null, + sig: a.sig, + sigAlg: a.sigAlg ?? null, verified: true, - keySource: this._roster.source(att.by) ?? (this._peerIdentityKeys?.has(att.by) ? 'handshake' : null), + keySource: this._roster.source(a.by) ?? (this._peerIdentityKeys?.has(a.by) ? 'handshake' : null), from: fromPeerName ?? null, fromPeerId: fromPeerId ?? null, - relayed: !!fromPeerId && fromPeerId !== att.by, + relayed: fromPeerId ? fromPeerId !== a.by : null, receivedAt: Date.now(), }); } catch (err) { - // A listener's failure must not undo or block the ingest it is observing. - this._log(`attestation-received listener failed: ${err.message}`); + this._log(`attestation-received not emitted for ${subject}: ${why(err)}`); + return; + } + // Not this.emit(): it stops at the first listener that throws and drops async rejections. + // rawListeners() keeps once() wrappers, so a once-listener still removes itself. + for (const listener of this.rawListeners('attestation-received')) { + try { + const r = listener.call(this, event); + if (r && typeof r.then === 'function') { + r.then(undefined, (err) => this._log(`attestation-received listener rejected for ${subject}: ${why(err)}`)); + } + } catch (err) { + this._log(`attestation-received listener failed for ${subject}: ${why(err)}`); + } } } diff --git a/tests/admission-attestation.test.js b/tests/admission-attestation.test.js index 3287656c..a750bef3 100644 --- a/tests/admission-attestation.test.js +++ b/tests/admission-attestation.test.js @@ -152,17 +152,24 @@ describe('node checkpoints its chain; reconciliation catches omission (D3)', () describe("'attestation-received' — a peer's verified verdict is observable as it lands", () => { // B ingests attestations A built, as it would from an `attestation` frame. B holds A's key the // way a handshake leaves it (_pinPeerKey), so verification runs for real; no transport is needed. - function withPair(fn) { - return withNode('att-rx-a', { room: 'hotel' }, (A) => withNode('att-rx-b', { room: 'hotel' }, (B) => { - B._pinPeerKey(A.nodeId, A._identity.publicKey); - const seen = []; - B.on('attestation-received', (e) => seen.push(e)); - return fn(A, B, seen); - })); + function makePair({ aOpts = {}, aAsPeer = null } = {}) { + const tag = () => `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const A = new SymNode({ name: `att-rx-a-${tag()}`, silent: true, discovery: new NullDiscovery(), room: 'hotel', ...aOpts }); + const B = new SymNode({ name: `att-rx-b-${tag()}`, silent: true, discovery: new NullDiscovery(), room: 'hotel' }); + B._pinPeerKey(A.nodeId, A._identity.publicKey); + // A direct peer is what gives the attester a name; the label is whatever it announced. + if (aAsPeer) B._peers.set(A.nodeId, { peerId: A.nodeId, name: aAsPeer, transport: { send() {} } }); + const seen = []; + const cleanup = () => { for (const n of [A, B]) fs.rmSync(nodeDir(n.name), { recursive: true, force: true }); }; + return { A, B, seen, cleanup, collect: () => B.on('attestation-received', (e) => seen.push(e)) }; + } + function withPair(opts, fn) { + const p = makePair(opts); + try { p.collect(); return fn(p); } finally { p.cleanup(); } } - it('emits once per verified attestation, carrying who judged which CMB, how, and where on the chain', () => { - withPair((A, B, seen) => { + it('emits once per verified attestation: who judged which CMB, how, where on the chain, re-verifiable', () => { + withPair({}, ({ A, B, seen }) => { const first = A._buildAdmissionAttestation('cmb-request-1', 'aligned', verdicts, 'heuristic'); const att = A._buildAdmissionAttestation('cmb-request-2', 'guarded', verdicts, 'neural'); assert.deepStrictEqual(B._ingestAttestation(att, A.nodeId, 'concierge'), { ok: true, reason: undefined }); @@ -171,65 +178,140 @@ describe("'attestation-received' — a peer's verified verdict is observable as assert.strictEqual(e.of, 'cmb-request-2'); assert.strictEqual(e.by, A.nodeId); assert.strictEqual(e.verdict, 'guarded'); - assert.deepStrictEqual(e.categories, verdicts); - assert.strictEqual(e.method, 'neural'); + assert.deepStrictEqual({ ...e.categories }, verdicts); + assert.strictEqual(e.methodUnsigned, 'neural'); assert.strictEqual(e.roster, 'hotel'); assert.strictEqual(e.seq, 2); assert.strictEqual(e.prev, crypto.createHash('sha256').update(first.sig).digest('hex')); assert.strictEqual(e.sig, att.sig); + assert.strictEqual(e.sigAlg, 'ed25519'); assert.strictEqual(e.verified, true); assert.strictEqual(e.keySource, 'handshake'); assert.strictEqual(e.from, 'concierge'); assert.strictEqual(e.fromPeerId, A.nodeId); assert.strictEqual(e.relayed, false); assert.strictEqual(typeof e.receivedAt, 'number'); + assert.deepStrictEqual(verifyAttestation(e, A._identity.publicKey), { signed: true, valid: true }, + 'a consumer holding the key can re-check the event itself'); + assert.ok(Object.isFrozen(e) && Object.isFrozen(e.categories), 'the event is frozen'); }); }); - it('marks a copy that arrived through another peer as relayed', () => { - withPair((A, B, seen) => { + it('names the attester by its own peer entry, never by the peer that relayed the copy', () => { + withPair({ aAsPeer: 'front-desk' }, ({ A, B, seen }) => { const att = A._buildAdmissionAttestation('cmb-request-3', 'aligned', verdicts, 'heuristic'); assert.strictEqual(B._ingestAttestation(att, 'relay-node-id', 'housekeeping').ok, true); assert.strictEqual(seen.length, 1); assert.strictEqual(seen[0].by, A.nodeId); + assert.strictEqual(seen[0].byName, 'front-desk'); assert.strictEqual(seen[0].from, 'housekeeping'); + assert.strictEqual(seen[0].fromPeerId, 'relay-node-id'); assert.strictEqual(seen[0].relayed, true); }); }); - it('stays silent for a duplicate, a forgery, a foreign roster and an unknown attester', () => { - withPair((A, B, seen) => { + it('reports relayed as unknown, not direct, when the deliverer is unknown', () => { + withPair({}, ({ A, B, seen }) => { const att = A._buildAdmissionAttestation('cmb-request-4', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(att).ok, true); + assert.strictEqual(seen[0].relayed, null); + assert.strictEqual(seen[0].from, null); + assert.strictEqual(seen[0].byName, null, 'not a direct peer, so no name'); + }); + }); + + it('gives the claimed role beside the role this node resolves', () => { + withPair({ aOpts: { lifecycleRole: 'anchor' } }, ({ A, B, seen }) => { + const att = A._buildAdmissionAttestation('cmb-request-5', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + assert.strictEqual(seen[0].role, 'anchor', 'what A stamped'); + assert.strictEqual(seen[0].roleResolved, 'participant', 'B holds no grant that makes A an anchor'); + assert.strictEqual(seen[0].roleMatches, false); + }); + }); + + it('passes on only what the signature covers: CAT7 categories as signed strings, nothing a relay added', () => { + withPair({}, ({ A, B, seen }) => { + // A signs a nested object as a category value: the signature covers its string form only. + const att = A._buildAdmissionAttestation('cmb-request-6', 'aligned', { ...verdicts, focus: { verdict: 'admit' } }, 'heuristic'); + // A relay adds a category key and rewrites the method; neither is in the signed bytes. + const tampered = { ...att, method: 'neural', categories: { ...att.categories, recommendation: 'escalate' } }; + assert.strictEqual(B._ingestAttestation(tampered, 'relay-node-id', 'housekeeping').ok, true, 'still verifies'); + const e = seen[0]; + assert.deepStrictEqual(Object.keys(e.categories), ['focus', 'issue', 'intent', 'motivation', 'commitment', 'perspective', 'mood']); + assert.strictEqual(e.categories.focus, '[object Object]', 'the signed form, not the object'); + assert.strictEqual('method' in e, false, 'an unsigned method is never presented as signed'); + assert.strictEqual(e.methodUnsigned, 'neural'); + assert.deepStrictEqual(verifyAttestation(e, A._identity.publicKey), { signed: true, valid: true }); + }); + }); + + it('stays silent for a duplicate, a forgery, a foreign roster and an unknown attester', () => { + withPair({}, ({ A, B, seen }) => { + const att = A._buildAdmissionAttestation('cmb-request-7', 'aligned', verdicts, 'heuristic'); assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); assert.strictEqual(B._ingestAttestation({ ...att }, A.nodeId, 'concierge').reason, 'duplicate'); - const forged = { ...A._buildAdmissionAttestation('cmb-request-5', 'rejected', verdicts, 'heuristic'), verdict: 'aligned' }; + const forged = { ...A._buildAdmissionAttestation('cmb-request-8', 'rejected', verdicts, 'heuristic'), verdict: 'aligned' }; assert.strictEqual(B._ingestAttestation(forged, A.nodeId, 'concierge').ok, false); - const foreign = A._buildAdmissionAttestation('cmb-request-6', 'aligned', verdicts, 'heuristic'); + const foreign = A._buildAdmissionAttestation('cmb-request-9', 'aligned', verdicts, 'heuristic'); assert.strictEqual(B._ingestAttestation({ ...foreign, roster: 'other-room' }, A.nodeId, 'concierge').reason, 'roster-mismatch'); withNode('att-rx-stranger', { room: 'hotel' }, (S) => { - const unknown = S._buildAdmissionAttestation('cmb-request-7', 'aligned', verdicts, 'heuristic'); + const unknown = S._buildAdmissionAttestation('cmb-request-10', 'aligned', verdicts, 'heuristic'); assert.strictEqual(B._ingestAttestation(unknown, S.nodeId, 'stranger').reason, 'unknown-attester-key'); }); assert.strictEqual(seen.length, 1, 'only the first verified copy is announced'); - assert.strictEqual(seen[0].of, 'cmb-request-4'); + assert.strictEqual(seen[0].of, 'cmb-request-7'); + }); + }); + + it('stays silent for a rate-limited copy: a flood about one CMB is not an event stream', () => { + withPair({}, ({ A, B, seen }) => { + const limit = B._attestations._ratePerWindow; + const reasons = []; + for (let i = 0; i <= limit; i++) { + const att = A._buildAdmissionAttestation('cmb-flooded', 'aligned', verdicts, 'heuristic'); + reasons.push(B._ingestAttestation(att, A.nodeId, 'concierge').reason); + } + assert.strictEqual(reasons[limit], 'rate-limited'); + assert.strictEqual(seen.length, limit, 'one event per recorded attestation, none for the rate-limited one'); }); }); - it('hands listeners a copy, and a failing listener does not undo the ingest', () => { - withPair((A, B, seen) => { - B.on('attestation-received', (e) => { e.verdict = 'rejected'; e.categories.focus = 'reject'; throw new Error('listener bug'); }); - const att = A._buildAdmissionAttestation('cmb-request-8', 'aligned', verdicts, 'heuristic'); + it('a failing listener cannot reach the stored record, starve a later listener, or undo the ingest', async () => { + const { A, B, seen, cleanup, collect } = makePair(); + const unhandled = []; + const onUnhandled = (r) => unhandled.push(r); + process.on('unhandledRejection', onUnhandled); + try { + // Registered BEFORE the collector, so a throw that escaped would starve it. + B.on('attestation-received', (e) => { e.categories.focus = 'reject'; }); // frozen: throws in strict mode + B.on('attestation-received', () => { throw null; }); // not an Error + B.on('attestation-received', async () => { throw new Error('view socket closed'); }); + let once = 0; + B.once('attestation-received', () => { once++; }); + collect(); + + const att = A._buildAdmissionAttestation('cmb-request-11', 'aligned', verdicts, 'heuristic'); assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true, 'the ingest still reports success'); - const stored = B.attestationsFor('cmb-request-8'); + const second = A._buildAdmissionAttestation('cmb-request-12', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(second, A.nodeId, 'concierge').ok, true); + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); + + assert.strictEqual(seen.length, 2, 'the later listener saw both events'); + assert.strictEqual(once, 1, 'a once-listener fired once and removed itself'); + assert.deepStrictEqual(unhandled, [], 'an async listener that rejects is not an unhandled rejection'); + const stored = B.attestationsFor('cmb-request-11'); assert.strictEqual(stored.length, 1, 'the attestation is recorded'); - assert.strictEqual(stored[0].verdict, 'aligned', 'the stored verdict is untouched'); assert.strictEqual(stored[0].categories.focus, 'admit', 'the stored categories are untouched'); assert.deepStrictEqual(verifyAttestation(stored[0], A._identity.publicKey), { signed: true, valid: true }); - assert.strictEqual(seen.length, 1); - }); + } finally { + process.off('unhandledRejection', onUnhandled); + cleanup(); + } }); }); From 4467b872accbe9f08313bd3271bd0fb7c92688cd Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 17:02:35 +0100 Subject: [PATCH 3/5] attestation-received: primitives only, no work without a listener, drops reported Fixes from confirmation review mission-adf61be25234 (F1-F14 all FIXED; SHIP-WITH-FIXES on six low findings): - N1: return early when nothing listens, and pick the signed fields instead of deep-cloning the whole record (an unsigned blob is no longer cloned per ingest). - N2: every field is a primitive; an object-valued signed field is given as the string the canonicalizer signs, so one listener cannot change what the next sees, and the event still re-verifies. - N3: roleClaimed carries the claim roleMatches was computed against. - N4: an event that cannot be built is reported on metric as attestation-event-dropped; the attestation is still recorded. - N5: the log calls inside the failure handlers are guarded. - N6: tests for prependListener order and for listeners removed or added during dispatch. The async-rejection test now waits 20 ms, and removing the rejection handler fails it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- CHANGELOG.md | 16 +++--- lib/node.js | 60 ++++++++++++--------- tests/admission-attestation.test.js | 82 +++++++++++++++++++++++++++-- 3 files changed, 123 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 160f8969..41e7b625 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,9 @@ Fields this node adds, which the signature does not vouch for: - `verified: true`, and `keySource`, where the verifying key came from: `anchor`, `grant`, or `handshake`. A `handshake` key is trusted on first use: it belongs to whoever completed the handshake under that nodeId; -- `roleResolved` and `roleMatches`, the role this node's grant chain resolves for the attester. Weight a - verdict by these, not by `role`; +- `roleResolved` and `roleMatches`, the role this node's grant chain resolves for the attester, and + `roleClaimed`, the claim `roleMatches` compares against (`participant` when the attester stamped none). + Weight a verdict by these, not by `role`; - `methodUnsigned`, the evaluation method (`heuristic` / `neural`). It is outside the signed bytes, so a relay could have changed it; - `byName`, the name the attester announced when it connected, only when it is a direct peer. It is a label, @@ -35,10 +36,13 @@ Fields this node adds, which the signature does not vouch for: A duplicate, a rate-limited copy, a roster mismatch, an unknown attester or a bad signature emits nothing. Neither does this node's own verdict: read that from `svaf-decision` or `attestationsFor()`. -The event is built from a copy and frozen, so a listener cannot change the stored record or what the next -listener sees. Each listener is called on its own. One that throws (any value), or an async listener whose -promise rejects, is logged with the attestation it was handling. It does not stop later listeners, undo the -ingest, or become an unhandled rejection. +The event holds only primitives and is frozen. A field the attester filled with an object is given as the +string its signature covers, so a listener cannot change the stored record or what the next listener sees. +Each listener is called on its own, in registration order, from a snapshot taken when dispatch starts. A +listener that throws (any value), or an async listener whose promise rejects, is logged with the attestation +it was handling. It does not stop later listeners, undo the ingest, or become an unhandled rejection. An event +that cannot be built is reported on `metric` as `attestation-event-dropped`, and the attestation is still +recorded. With no listener, none of this work is done. ## 0.13.13 (2026-10-01) diff --git a/lib/node.js b/lib/node.js index a38d83a0..6a11eae4 100644 --- a/lib/node.js +++ b/lib/node.js @@ -1228,49 +1228,57 @@ class SymNode extends EventEmitter { * frame; `relayed` is true when that peer is not the attester, null when the deliverer is * unknown. `receivedAt` is this node's clock, not the attester's `at`. * - * The event is built from a clone and frozen, so a listener can neither reach the stored record - * nor change what the next listener sees. Each listener is called on its own: one that throws, - * or returns a promise that rejects, is logged and cannot starve later listeners or undo the - * ingest. + * The event holds only primitives, frozen: an object-valued field is given as the string its + * signature covers, so a listener can neither reach the stored record nor change what the next + * listener sees. `roleClaimed` is the claim `roleMatches` was computed against ('participant' + * when the attester stamped none). Each listener is called on its own: one that throws, or returns + * a promise that rejects, is logged and cannot starve later listeners or undo the ingest. An event + * that cannot be built is reported on 'metric' as 'attestation-event-dropped'. * @private */ _emitAttestationReceived(att, fromPeerId, fromPeerName) { + if (this.listenerCount('attestation-received') === 0) return; const why = (e) => { try { return (e && e.message) || String(e); } catch { return 'unprintable error'; } }; + // A log sink that fails must not become the failure it is reporting. + const note = (m) => { try { this._log(m); } catch { /* nothing left to tell */ } }; const subject = `${String(att.of).slice(0, 12)} by ${String(att.by).slice(0, 8)}`; + // The canonicalizer joins these with ToString, so String() of an object keeps the signed bytes. + const signed = (v) => (v === null || v === undefined ? null : (typeof v === 'object' || typeof v === 'function') ? String(v) : v); let event; try { - const a = structuredClone(att); const categories = {}; for (const f of CAT7_CATEGORIES) { - const v = a.categories && a.categories[f]; + const v = att.categories && att.categories[f]; categories[f] = v ? String(v) : null; // the form canonicalCategories signs } - const role = verifyAttestationRole(a, (id, at) => this.resolveRole(id, at)); + const role = verifyAttestationRole(att, (id, at) => this.resolveRole(id, at)); event = Object.freeze({ - of: a.of, - by: a.by, - byName: this._peers.get(a.by)?.name ?? null, - verdict: a.verdict ?? null, + of: signed(att.of), + by: signed(att.by), + byName: signed(this._peers.get(att.by)?.name), + verdict: signed(att.verdict), categories: Object.freeze(categories), - role: a.role ?? null, + role: signed(att.role), + roleClaimed: role.claimed, roleResolved: role.resolved, roleMatches: role.matches, - methodUnsigned: a.method ?? null, - roster: a.roster ?? null, - at: a.at ?? null, - seq: a.seq, - prev: a.prev ?? null, - sig: a.sig, - sigAlg: a.sigAlg ?? null, + methodUnsigned: signed(att.method), + roster: signed(att.roster), + at: signed(att.at), + seq: signed(att.seq), + prev: signed(att.prev), + sig: signed(att.sig), + sigAlg: signed(att.sigAlg), verified: true, - keySource: this._roster.source(a.by) ?? (this._peerIdentityKeys?.has(a.by) ? 'handshake' : null), - from: fromPeerName ?? null, - fromPeerId: fromPeerId ?? null, - relayed: fromPeerId ? fromPeerId !== a.by : null, + keySource: this._roster.source(att.by) ?? (this._peerIdentityKeys?.has(att.by) ? 'handshake' : null), + from: signed(fromPeerName), + fromPeerId: signed(fromPeerId), + relayed: fromPeerId ? fromPeerId !== att.by : null, receivedAt: Date.now(), }); } catch (err) { - this._log(`attestation-received not emitted for ${subject}: ${why(err)}`); + note(`attestation-received not emitted for ${subject}: ${why(err)}`); + try { this.emit('metric', { type: 'attestation-event-dropped', of: att.of, by: att.by, reason: why(err) }); } catch { /* the drop is already logged */ } return; } // Not this.emit(): it stops at the first listener that throws and drops async rejections. @@ -1279,10 +1287,10 @@ class SymNode extends EventEmitter { try { const r = listener.call(this, event); if (r && typeof r.then === 'function') { - r.then(undefined, (err) => this._log(`attestation-received listener rejected for ${subject}: ${why(err)}`)); + r.then(undefined, (err) => note(`attestation-received listener rejected for ${subject}: ${why(err)}`)); } } catch (err) { - this._log(`attestation-received listener failed for ${subject}: ${why(err)}`); + note(`attestation-received listener failed for ${subject}: ${why(err)}`); } } } diff --git a/tests/admission-attestation.test.js b/tests/admission-attestation.test.js index a750bef3..8734aa76 100644 --- a/tests/admission-attestation.test.js +++ b/tests/admission-attestation.test.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 } = require('../lib/core'); +const { verifyAttestation, verifyAttestationRole, signAttestation } = require('../lib/core'); // Construct (no start) — the builder needs only identity / room / role / chain state. function withNode(baseName, opts, fn) { @@ -225,6 +225,7 @@ describe("'attestation-received' — a peer's verified verdict is observable as const att = A._buildAdmissionAttestation('cmb-request-5', 'aligned', verdicts, 'heuristic'); assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); assert.strictEqual(seen[0].role, 'anchor', 'what A stamped'); + assert.strictEqual(seen[0].roleClaimed, 'anchor'); assert.strictEqual(seen[0].roleResolved, 'participant', 'B holds no grant that makes A an anchor'); assert.strictEqual(seen[0].roleMatches, false); }); @@ -281,6 +282,82 @@ describe("'attestation-received' — a peer's verified verdict is observable as }); }); + it("gives a foreign attester's object-valued or missing fields in their signed form", () => { + withPair({}, ({ A, B, seen }) => { + // Validly signed but unusual: the canonicalizer signs an array through ToString, and no role. + const base = A._buildAdmissionAttestation('cmb-request-13', 'aligned', verdicts, 'heuristic'); + const att = { ...base, prev: ['abc'] }; + delete att.role; + signAttestation(att, A._identity.privateKey); + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + const e = seen[0]; + assert.strictEqual(e.prev, 'abc', 'a string, so no listener can change it under the next one'); + assert.strictEqual(e.role, null, 'no role was signed'); + assert.strictEqual(e.roleClaimed, 'participant', 'the claim roleMatches is computed against'); + assert.strictEqual(e.roleMatches, true); + assert.deepStrictEqual(verifyAttestation(e, A._identity.publicKey), { signed: true, valid: true }); + for (const [k, v] of Object.entries(e)) { + if (k !== 'categories') assert.ok(v === null || typeof v !== 'object', `${k} is a primitive`); + } + }); + }); + + it('dispatches in listener order from a snapshot, like emit', () => { + withPair({}, ({ A, B, seen }) => { + const order = []; + const collector = B.rawListeners('attestation-received')[0]; + const late = () => order.push('late'); + B.prependListener('attestation-received', () => { + order.push('first'); + B.off('attestation-received', collector); // removed mid-dispatch: still sees this event + B.on('attestation-received', late); // added mid-dispatch: does not + }); + const att = A._buildAdmissionAttestation('cmb-request-14', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + assert.deepStrictEqual(order, ['first']); + assert.strictEqual(seen.length, 1, 'the collector removed during dispatch still got this event'); + const next = A._buildAdmissionAttestation('cmb-request-15', 'aligned', verdicts, 'heuristic'); + B._ingestAttestation(next, A.nodeId, 'concierge'); + assert.deepStrictEqual(order, ['first', 'first', 'late']); + assert.strictEqual(seen.length, 1, 'and nothing after it was removed'); + }); + }); + + it('reports an event it could not build on metric, and still records the attestation', () => { + withPair({}, ({ A, B, seen }) => { + const metrics = []; + B.on('metric', (m) => metrics.push(m)); + B.resolveRole = () => { throw new Error('grant chain unreadable'); }; + const att = A._buildAdmissionAttestation('cmb-request-16', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + assert.strictEqual(seen.length, 0); + const dropped = metrics.filter((m) => m.type === 'attestation-event-dropped'); + assert.strictEqual(dropped.length, 1); + assert.strictEqual(dropped[0].of, 'cmb-request-16'); + assert.strictEqual(dropped[0].reason, 'grant chain unreadable'); + assert.strictEqual(B.attestationsFor('cmb-request-16').length, 1); + }); + }); + + it('a log sink that throws does not turn a failing listener into a failed ingest', async () => { + const { A, B, cleanup } = makePair(); + const unhandled = []; + const onUnhandled = (r) => unhandled.push(r); + process.on('unhandledRejection', onUnhandled); + try { + B._log = () => { throw new Error('log pipe closed'); }; + B.on('attestation-received', () => { throw new Error('listener bug'); }); + B.on('attestation-received', async () => { throw new Error('view socket closed'); }); + const att = A._buildAdmissionAttestation('cmb-request-17', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + await new Promise((r) => setTimeout(r, 20)); + assert.deepStrictEqual(unhandled, []); + } finally { + process.off('unhandledRejection', onUnhandled); + cleanup(); + } + }); + it('a failing listener cannot reach the stored record, starve a later listener, or undo the ingest', async () => { const { A, B, seen, cleanup, collect } = makePair(); const unhandled = []; @@ -299,8 +376,7 @@ describe("'attestation-received' — a peer's verified verdict is observable as assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true, 'the ingest still reports success'); const second = A._buildAdmissionAttestation('cmb-request-12', 'aligned', verdicts, 'heuristic'); assert.strictEqual(B._ingestAttestation(second, A.nodeId, 'concierge').ok, true); - await new Promise((r) => setImmediate(r)); - await new Promise((r) => setImmediate(r)); + await new Promise((r) => setTimeout(r, 20)); assert.strictEqual(seen.length, 2, 'the later listener saw both events'); assert.strictEqual(once, 1, 'a once-listener fired once and removed itself'); From 6bd84ca8e698656eb50a28485ec8fdce2ca81275 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 17:14:51 +0100 Subject: [PATCH 4/5] attestation-received: coerce the role claim, isolate metric listeners too Fixes from round-3 review mission-cddbd2cddca0 (N1-N6: 4 fixed, 2 partly; SHIP-WITH-FIXES on five findings): - F1: roleClaimed and roleResolved go through the same coercion as the signed fields, and roleMatches compares those forms, so an array-valued role neither aliases the stored record nor fails to match a claim that signs identically. - F5: the attestation-event-dropped report reaches every metric listener through the same isolated dispatch (_emitIsolated) the event uses. - F2: a test that the no-listener path does no work. - F3: the log-sink test exercises the async rejection handler on its own, then the sync catch. - F4: the order test observes prependListener order. Each protection was removed in turn and fails a test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- CHANGELOG.md | 7 +++-- lib/node.js | 36 ++++++++++++++-------- tests/admission-attestation.test.js | 48 ++++++++++++++++++++++++++--- 3 files changed, 71 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41e7b625..ecb3d0b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,8 @@ Fields this node adds, which the signature does not vouch for: - `verified: true`, and `keySource`, where the verifying key came from: `anchor`, `grant`, or `handshake`. A `handshake` key is trusted on first use: it belongs to whoever completed the handshake under that nodeId; - `roleResolved` and `roleMatches`, the role this node's grant chain resolves for the attester, and - `roleClaimed`, the claim `roleMatches` compares against (`participant` when the attester stamped none). + `roleClaimed`, the claim `roleMatches` compares against (`participant` when the attester stamped none), + in the same string form as the signed fields, so a claim that signs identically matches identically. Weight a verdict by these, not by `role`; - `methodUnsigned`, the evaluation method (`heuristic` / `neural`). It is outside the signed bytes, so a relay could have changed it; @@ -41,8 +42,8 @@ string its signature covers, so a listener cannot change the stored record or wh Each listener is called on its own, in registration order, from a snapshot taken when dispatch starts. A listener that throws (any value), or an async listener whose promise rejects, is logged with the attestation it was handling. It does not stop later listeners, undo the ingest, or become an unhandled rejection. An event -that cannot be built is reported on `metric` as `attestation-event-dropped`, and the attestation is still -recorded. With no listener, none of this work is done. +that cannot be built is reported on `metric` as `attestation-event-dropped`, to each `metric` listener the same +isolated way, and the attestation is still recorded. With no listener, none of this work is done. ## 0.13.13 (2026-10-01) diff --git a/lib/node.js b/lib/node.js index 6a11eae4..f91f7954 100644 --- a/lib/node.js +++ b/lib/node.js @@ -1230,8 +1230,8 @@ class SymNode extends EventEmitter { * * The event holds only primitives, frozen: an object-valued field is given as the string its * signature covers, so a listener can neither reach the stored record nor change what the next - * listener sees. `roleClaimed` is the claim `roleMatches` was computed against ('participant' - * when the attester stamped none). Each listener is called on its own: one that throws, or returns + * listener sees. `roleClaimed` is the claim `roleMatches` compares with `roleResolved` + * ('participant' when the attester stamped none), both in the same signed string form. Each listener is called on its own: one that throws, or returns * a promise that rejects, is logged and cannot starve later listeners or undo the ingest. An event * that cannot be built is reported on 'metric' as 'attestation-event-dropped'. * @private @@ -1252,6 +1252,9 @@ class SymNode extends EventEmitter { categories[f] = v ? String(v) : null; // the form canonicalCategories signs } const role = verifyAttestationRole(att, (id, at) => this.resolveRole(id, at)); + // Coerced like the signed fields: ['anchor'] signs exactly as 'anchor' does, so it claims the same. + const roleClaimed = signed(role.claimed); + const roleResolved = signed(role.resolved); event = Object.freeze({ of: signed(att.of), by: signed(att.by), @@ -1259,9 +1262,9 @@ class SymNode extends EventEmitter { verdict: signed(att.verdict), categories: Object.freeze(categories), role: signed(att.role), - roleClaimed: role.claimed, - roleResolved: role.resolved, - roleMatches: role.matches, + roleClaimed, + roleResolved, + roleMatches: roleClaimed === roleResolved, methodUnsigned: signed(att.method), roster: signed(att.roster), at: signed(att.at), @@ -1278,19 +1281,28 @@ class SymNode extends EventEmitter { }); } catch (err) { note(`attestation-received not emitted for ${subject}: ${why(err)}`); - try { this.emit('metric', { type: 'attestation-event-dropped', of: att.of, by: att.by, reason: why(err) }); } catch { /* the drop is already logged */ } + this._emitIsolated('metric', Object.freeze({ type: 'attestation-event-dropped', of: signed(att.of), by: signed(att.by), reason: why(err) }), subject, why, note); return; } - // Not this.emit(): it stops at the first listener that throws and drops async rejections. - // rawListeners() keeps once() wrappers, so a once-listener still removes itself. - for (const listener of this.rawListeners('attestation-received')) { + this._emitIsolated('attestation-received', event, subject, why, note); + } + + /** + * Call each listener of `name` on its own, in order, from a snapshot. Not this.emit(): that stops + * at the first listener that throws and leaves an async listener's rejection unhandled. A throw + * or a rejection is noted with `subject` and the next listener still runs. rawListeners() keeps + * once() wrappers, so a once-listener still removes itself. + * @private + */ + _emitIsolated(name, payload, subject, why, note) { + for (const listener of this.rawListeners(name)) { try { - const r = listener.call(this, event); + const r = listener.call(this, payload); if (r && typeof r.then === 'function') { - r.then(undefined, (err) => note(`attestation-received listener rejected for ${subject}: ${why(err)}`)); + r.then(undefined, (err) => note(`${name} listener rejected for ${subject}: ${why(err)}`)); } } catch (err) { - note(`attestation-received listener failed for ${subject}: ${why(err)}`); + note(`${name} listener failed for ${subject}: ${why(err)}`); } } } diff --git a/tests/admission-attestation.test.js b/tests/admission-attestation.test.js index 8734aa76..7122dd68 100644 --- a/tests/admission-attestation.test.js +++ b/tests/admission-attestation.test.js @@ -302,11 +302,41 @@ describe("'attestation-received' — a peer's verified verdict is observable as }); }); + it("coerces an object-valued role claim, so it neither aliases the record nor fails to match", () => { + withPair({ aOpts: { lifecycleRole: 'participant' } }, ({ A, B, seen }) => { + const base = A._buildAdmissionAttestation('cmb-request-18', 'aligned', verdicts, 'heuristic'); + const att = { ...base, role: ['participant'] }; // signs exactly as 'participant' does + signAttestation(att, A._identity.privateKey); + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + const e = seen[0]; + assert.strictEqual(e.role, 'participant'); + assert.strictEqual(e.roleClaimed, 'participant'); + assert.strictEqual(e.roleMatches, true, 'a byte-identical claim matches'); + for (const [k, v] of Object.entries(e)) { + if (k !== 'categories') assert.ok(v === null || typeof v !== 'object', `${k} is a primitive`); + } + assert.deepStrictEqual(verifyAttestation(B.attestationsFor('cmb-request-18')[0], A._identity.publicKey), { signed: true, valid: true }); + }); + }); + + it('does no work when nothing listens', () => { + const { A, B, cleanup } = makePair(); + try { + const metrics = []; + B.on('metric', (m) => metrics.push(m)); + B.resolveRole = () => { throw new Error('must not be called without a listener'); }; + const att = A._buildAdmissionAttestation('cmb-request-19', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + assert.strictEqual(metrics.filter((m) => m.type === 'attestation-event-dropped').length, 0); + } finally { cleanup(); } + }); + it('dispatches in listener order from a snapshot, like emit', () => { withPair({}, ({ A, B, seen }) => { const order = []; const collector = B.rawListeners('attestation-received')[0]; const late = () => order.push('late'); + B.on('attestation-received', () => order.push('appended')); B.prependListener('attestation-received', () => { order.push('first'); B.off('attestation-received', collector); // removed mid-dispatch: still sees this event @@ -314,11 +344,11 @@ describe("'attestation-received' — a peer's verified verdict is observable as }); const att = A._buildAdmissionAttestation('cmb-request-14', 'aligned', verdicts, 'heuristic'); assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); - assert.deepStrictEqual(order, ['first']); + assert.deepStrictEqual(order, ['first', 'appended'], 'the prepended listener runs first'); assert.strictEqual(seen.length, 1, 'the collector removed during dispatch still got this event'); const next = A._buildAdmissionAttestation('cmb-request-15', 'aligned', verdicts, 'heuristic'); B._ingestAttestation(next, A.nodeId, 'concierge'); - assert.deepStrictEqual(order, ['first', 'first', 'late']); + assert.deepStrictEqual(order, ['first', 'appended', 'first', 'appended', 'late']); assert.strictEqual(seen.length, 1, 'and nothing after it was removed'); }); }); @@ -326,6 +356,8 @@ describe("'attestation-received' — a peer's verified verdict is observable as it('reports an event it could not build on metric, and still records the attestation', () => { withPair({}, ({ A, B, seen }) => { const metrics = []; + B.on('metric', () => { throw new Error('counter rejects an unknown type'); }); // must not starve the next + B.on('metric', async () => { throw new Error('shipper offline'); }); // must not go unhandled B.on('metric', (m) => metrics.push(m)); B.resolveRole = () => { throw new Error('grant chain unreadable'); }; const att = A._buildAdmissionAttestation('cmb-request-16', 'aligned', verdicts, 'heuristic'); @@ -346,12 +378,18 @@ describe("'attestation-received' — a peer's verified verdict is observable as process.on('unhandledRejection', onUnhandled); try { B._log = () => { throw new Error('log pipe closed'); }; - B.on('attestation-received', () => { throw new Error('listener bug'); }); - B.on('attestation-received', async () => { throw new Error('view socket closed'); }); + // Only an async listener first, so the rejection handler's own logging is what is under test. + const onlyAsync = async () => { throw new Error('view socket closed'); }; + B.on('attestation-received', onlyAsync); const att = A._buildAdmissionAttestation('cmb-request-17', 'aligned', verdicts, 'heuristic'); assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); await new Promise((r) => setTimeout(r, 20)); - assert.deepStrictEqual(unhandled, []); + assert.deepStrictEqual(unhandled, [], 'the rejection handler cannot itself reject'); + // Then a synchronous throw, whose catch block logs through the same failing sink. + B.off('attestation-received', onlyAsync); + B.on('attestation-received', () => { throw new Error('listener bug'); }); + const next = A._buildAdmissionAttestation('cmb-request-20', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(next, A.nodeId, 'concierge').ok, true, 'the catch block cannot throw out'); } finally { process.off('unhandledRejection', onUnhandled); cleanup(); From 94d2b1929c31f6d5b587b667539e5b5628307e44 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Thu, 1 Oct 2026 17:29:33 +0100 Subject: [PATCH 5/5] attestation-received: correct the CAT7 field names in the notes, string role compare Fixes from release-gate review mission-9b6156331681 (round-3 F1-F5 all FIXED; code gate SHIP; five low findings): - F1: the CHANGELOG named the seven CAT7 fields with the verdict vocabulary; it now lists focus ... mood, each carrying admit ... silent. - F2: the attestation-event-dropped payload is a plain object like the other 22 metric emits, so a listener that stamps it still records the drop; the notes say it is dispatched in isolation, not through emit(). - F3: roleClaimed/roleResolved are compared as strings, so a numeric role that signs as '2' matches a resolved '2'. - F4: the zero-listener test asserts the grant chain is never walked. - F5: the notes and JSDoc say listeners run synchronously on the frame-ingest path, after gossip, so heavy work belongs in a queue. Unit suite 642/644 (0 failed, 2 skipped), integration gossip e2e green; each new protection was removed in turn and fails a test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014y3qC1m9acDVaerca56dvV --- CHANGELOG.md | 19 ++++++++++++------- lib/node.js | 17 ++++++++++------- tests/admission-attestation.test.js | 20 ++++++++++++++++++-- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecb3d0b8..438bb3e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,9 @@ Fields covered by the attester's signature, passed on as signed: - `of` (the gated CMB key), `by` (the attester's nodeId), `at`, `roster`, `seq` and `prev` (its place on the attester's hash chain); -- `verdict`, and `categories`: the seven CAT7 fields (admit / guard / redundant / reject / silent), as the - strings the signature covers. Any other key in the frame is unsigned and dropped; +- `verdict`, and `categories`: the seven CAT7 fields (focus / issue / intent / motivation / commitment / + perspective / mood), each carrying one of admit / guard / redundant / reject / silent, as the strings the + signature covers. Any other key in the frame is unsigned and dropped; - `role`, the role the attester claims. A node can stamp any role and still sign validly; - `sig` and `sigAlg`, so a consumer holding the attester's key can re-check the event with `verifyAttestation(event, key)` instead of trusting `verified`. @@ -23,8 +24,9 @@ Fields this node adds, which the signature does not vouch for: - `verified: true`, and `keySource`, where the verifying key came from: `anchor`, `grant`, or `handshake`. A `handshake` key is trusted on first use: it belongs to whoever completed the handshake under that nodeId; - `roleResolved` and `roleMatches`, the role this node's grant chain resolves for the attester, and - `roleClaimed`, the claim `roleMatches` compares against (`participant` when the attester stamped none), - in the same string form as the signed fields, so a claim that signs identically matches identically. + `roleClaimed`, the claim `roleMatches` compares against (`participant` when the attester stamped none). + Both are compared as strings, the form the signature covers, so a claim that signs identically matches + identically. Weight a verdict by these, not by `role`; - `methodUnsigned`, the evaluation method (`heuristic` / `neural`). It is outside the signed bytes, so a relay could have changed it; @@ -39,11 +41,14 @@ Neither does this node's own verdict: read that from `svaf-decision` or `attesta The event holds only primitives and is frozen. A field the attester filled with an object is given as the string its signature covers, so a listener cannot change the stored record or what the next listener sees. -Each listener is called on its own, in registration order, from a snapshot taken when dispatch starts. A +Each listener is called on its own, in registration order, from a snapshot taken when dispatch starts. The +calls are synchronous, on the frame-ingest path: gossip to the room has already gone out, but a listener that +blocks delays this node's next frames, so hand heavy work to a queue. A listener that throws (any value), or an async listener whose promise rejects, is logged with the attestation it was handling. It does not stop later listeners, undo the ingest, or become an unhandled rejection. An event -that cannot be built is reported on `metric` as `attestation-event-dropped`, to each `metric` listener the same -isolated way, and the attestation is still recorded. With no listener, none of this work is done. +that cannot be built is reported on `metric` as `attestation-event-dropped`. That one metric is delivered to +each `metric` listener the same isolated way, not through `emit()`, so code that wraps `emit` does not see it. +The attestation is still recorded. With no listener, none of this work is done. ## 0.13.13 (2026-10-01) diff --git a/lib/node.js b/lib/node.js index f91f7954..607a20e3 100644 --- a/lib/node.js +++ b/lib/node.js @@ -1231,9 +1231,11 @@ class SymNode extends EventEmitter { * The event holds only primitives, frozen: an object-valued field is given as the string its * signature covers, so a listener can neither reach the stored record nor change what the next * listener sees. `roleClaimed` is the claim `roleMatches` compares with `roleResolved` - * ('participant' when the attester stamped none), both in the same signed string form. Each listener is called on its own: one that throws, or returns - * a promise that rejects, is logged and cannot starve later listeners or undo the ingest. An event - * that cannot be built is reported on 'metric' as 'attestation-event-dropped'. + * ('participant' when the attester stamped none), both as the strings the signature would cover. Each listener is called on its own: one that throws, or returns + * a promise that rejects, is logged and cannot starve later listeners or undo the ingest. Listeners + * run synchronously on the frame-ingest path (gossip has already gone out), so heavy work belongs + * in a queue. An event that cannot be built is reported on 'metric' as 'attestation-event-dropped', + * dispatched the same isolated way rather than through emit(). * @private */ _emitAttestationReceived(att, fromPeerId, fromPeerName) { @@ -1252,9 +1254,10 @@ class SymNode extends EventEmitter { categories[f] = v ? String(v) : null; // the form canonicalCategories signs } const role = verifyAttestationRole(att, (id, at) => this.resolveRole(id, at)); - // Coerced like the signed fields: ['anchor'] signs exactly as 'anchor' does, so it claims the same. - const roleClaimed = signed(role.claimed); - const roleResolved = signed(role.resolved); + // As strings, the form the canonicalizer signs: ['anchor'] and 'anchor' claim the same, as do 2 and + // '2'. Neither field is signed, and both default to 'participant', so String() never yields 'null'. + const roleClaimed = String(role.claimed); + const roleResolved = String(role.resolved); event = Object.freeze({ of: signed(att.of), by: signed(att.by), @@ -1281,7 +1284,7 @@ class SymNode extends EventEmitter { }); } catch (err) { note(`attestation-received not emitted for ${subject}: ${why(err)}`); - this._emitIsolated('metric', Object.freeze({ type: 'attestation-event-dropped', of: signed(att.of), by: signed(att.by), reason: why(err) }), subject, why, note); + this._emitIsolated('metric', { type: 'attestation-event-dropped', of: signed(att.of), by: signed(att.by), reason: why(err) }, subject, why, note); return; } this._emitIsolated('attestation-received', event, subject, why, note); diff --git a/tests/admission-attestation.test.js b/tests/admission-attestation.test.js index 7122dd68..5dd3362e 100644 --- a/tests/admission-attestation.test.js +++ b/tests/admission-attestation.test.js @@ -319,14 +319,30 @@ describe("'attestation-received' — a peer's verified verdict is observable as }); }); + it('matches a numeric role claim against the same role resolved as a string', () => { + withPair({}, ({ A, B, seen }) => { + const base = A._buildAdmissionAttestation('cmb-request-21', 'aligned', verdicts, 'heuristic'); + const att = { ...base, role: 2 }; // signs as '2' + signAttestation(att, A._identity.privateKey); + B.resolveRole = () => '2'; + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + assert.strictEqual(seen[0].roleClaimed, '2'); + assert.strictEqual(seen[0].roleResolved, '2'); + assert.strictEqual(seen[0].roleMatches, true); + assert.deepStrictEqual(verifyAttestation(seen[0], A._identity.publicKey), { signed: true, valid: true }); + }); + }); + it('does no work when nothing listens', () => { const { A, B, cleanup } = makePair(); try { const metrics = []; B.on('metric', (m) => metrics.push(m)); - B.resolveRole = () => { throw new Error('must not be called without a listener'); }; + let called = false; + B.resolveRole = () => { called = true; throw new Error('must not be called without a listener'); }; const att = A._buildAdmissionAttestation('cmb-request-19', 'aligned', verdicts, 'heuristic'); assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + assert.strictEqual(called, false, 'the grant chain is not walked with no listener'); assert.strictEqual(metrics.filter((m) => m.type === 'attestation-event-dropped').length, 0); } finally { cleanup(); } }); @@ -358,7 +374,7 @@ describe("'attestation-received' — a peer's verified verdict is observable as const metrics = []; B.on('metric', () => { throw new Error('counter rejects an unknown type'); }); // must not starve the next B.on('metric', async () => { throw new Error('shipper offline'); }); // must not go unhandled - B.on('metric', (m) => metrics.push(m)); + B.on('metric', (m) => { m.node = 'b'; metrics.push(m); }); // stamps its payload, like the other 22 metrics allow B.resolveRole = () => { throw new Error('grant chain unreadable'); }; const att = A._buildAdmissionAttestation('cmb-request-16', 'aligned', verdicts, 'heuristic'); assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true);