Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,55 @@
# 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.

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 (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`.

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).
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;
- `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.
Neither does this node's own verdict: read that from `svaf-decision` or `attestationsFor()`.

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. 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`. 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)

### Fixed — a crashed Windows session no longer locks its node name until reboot
Expand Down
111 changes: 109 additions & 2 deletions lib/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand All @@ -1199,10 +1204,112 @@ 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.
*
* 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 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 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) {
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 categories = {};
for (const f of CAT7_CATEGORIES) {
const v = att.categories && att.categories[f];
categories[f] = v ? String(v) : null; // the form canonicalCategories signs
}
const role = verifyAttestationRole(att, (id, at) => this.resolveRole(id, at));
// 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),
byName: signed(this._peers.get(att.by)?.name),
verdict: signed(att.verdict),
categories: Object.freeze(categories),
role: signed(att.role),
roleClaimed,
roleResolved,
roleMatches: roleClaimed === roleResolved,
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(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) {
note(`attestation-received not emitted for ${subject}: ${why(err)}`);
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);
}

/**
* 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, payload);
if (r && typeof r.then === 'function') {
r.then(undefined, (err) => note(`${name} listener rejected for ${subject}: ${why(err)}`));
}
} catch (err) {
note(`${name} listener failed for ${subject}: ${why(err)}`);
}
}
}

/**
* Chain-integrity check for an attester (default: this node) — the local half of
* omission-evidence. Reports `seq` gaps (suppressed attestations) and `prev`
Expand Down
Loading
Loading