@cello-protocol/daemon 0.0.189 → 0.0.191
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-id-migration.d.ts.map +1 -1
- package/dist/agent-id-migration.js +8 -0
- package/dist/agent-id-migration.js.map +1 -1
- package/dist/daemon.js +5 -1
- package/dist/daemon.js.map +1 -1
- package/dist/park-envelope.d.ts +58 -1
- package/dist/park-envelope.d.ts.map +1 -1
- package/dist/park-envelope.js +61 -1
- package/dist/park-envelope.js.map +1 -1
- package/dist/refusal-reasons.d.ts +21 -0
- package/dist/refusal-reasons.d.ts.map +1 -1
- package/dist/refusal-reasons.js +27 -0
- package/dist/refusal-reasons.js.map +1 -1
- package/dist/session-connection-gater.d.ts +19 -3
- package/dist/session-connection-gater.d.ts.map +1 -1
- package/dist/session-connection-gater.js +40 -11
- package/dist/session-connection-gater.js.map +1 -1
- package/dist/session-node-manager.d.ts +69 -5
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +1221 -135
- package/dist/session-node-manager.js.map +1 -1
- package/dist/session-relay-client.d.ts +93 -3
- package/dist/session-relay-client.d.ts.map +1 -1
- package/dist/session-relay-client.js +352 -40
- package/dist/session-relay-client.js.map +1 -1
- package/package.json +5 -5
|
@@ -30,7 +30,7 @@ import { ensureIdentitySchema } from "./db-identity-store.js";
|
|
|
30
30
|
import { migrateSessionTablesToAgentId } from "./agent-id-migration.js";
|
|
31
31
|
import { TIER, normalizeTier, isKnownTierValue, tierBoundsFor, DEFAULT_TIER_BOUNDS, migrateContactsAddTierMetadata } from "./contacts-tier-migration.js";
|
|
32
32
|
import { normalizeContactPubkey, foldContactPubkeyCase } from "./contact-pubkey-case.js";
|
|
33
|
-
import { REFUSAL_KINDS } from "./refusal-reasons.js";
|
|
33
|
+
import { REFUSAL_KINDS, relayAckHashRefusalNotice } from "./refusal-reasons.js";
|
|
34
34
|
import { migrateCborBlobsToCanonical } from "./cbor-blob-migration.js";
|
|
35
35
|
import { ensureTrustSignalSchema } from "./trust-signal-store.js";
|
|
36
36
|
import { boundSettingKey, settableTierName, isValidSettingKey, awayTierSettingKey, AWAY_DEFAULT_KEY } from "./agent-settings-keys.js";
|
|
@@ -38,7 +38,7 @@ import { publishableEndpoint, relayOnlyState } from "./relay-only.js";
|
|
|
38
38
|
import { randomUUID, createHash, randomBytes } from "node:crypto";
|
|
39
39
|
import * as lp from "it-length-prefixed";
|
|
40
40
|
import { decode } from "cbor-x";
|
|
41
|
-
import { encodeCbor, decodeStructure1, encodeStructure1 } from "@cello-protocol/protocol-types";
|
|
41
|
+
import { encodeCbor, decodeStructure1, encodeStructure1, computeGenesisPrevRoot } from "@cello-protocol/protocol-types";
|
|
42
42
|
import { MAX_SESSION_NODES, STANDING_RECEIVER_AGENT_NAME } from "./types.js";
|
|
43
43
|
import { SessionConnectionGater } from "./session-connection-gater.js";
|
|
44
44
|
import { SessionTree, sessionTreeLeafKindFromDb } from "./session-tree.js";
|
|
@@ -286,6 +286,28 @@ const REDIAL_COOLDOWN_MS = 15_000;
|
|
|
286
286
|
* span about 2.5 hours.
|
|
287
287
|
*/
|
|
288
288
|
export const SR_RESERVATION_MAX_RETRIES = 5;
|
|
289
|
+
/** The relay peer id inside a `/…/p2p/<relay>/p2p-circuit/…` address. */
|
|
290
|
+
const CIRCUIT_RELAY_ID = /\/p2p\/([^/]+)\/p2p-circuit/;
|
|
291
|
+
/**
|
|
292
|
+
* 032-RELAYSPREAD — the relays a node ACTUALLY HOLDS a circuit with, read off the addresses it is
|
|
293
|
+
* announcing. One entry per relay, deduped.
|
|
294
|
+
*
|
|
295
|
+
* This is the single definition of "a reservation is held", and it is deliberately the strictest
|
|
296
|
+
* one available: an ANNOUNCED circuit address. `start()` resolving is not enough — a relay out of
|
|
297
|
+
* reservation slots completes the handshake, grants nothing, and leaves a node that looks started
|
|
298
|
+
* and is dialable by nobody. Nor is a candidate address enough: a candidate is a relay we asked.
|
|
299
|
+
*/
|
|
300
|
+
function heldRelayIdsOf(node) {
|
|
301
|
+
const ids = new Set();
|
|
302
|
+
for (const addr of node.listenAddresses()) {
|
|
303
|
+
if (!addr.includes("/p2p-circuit"))
|
|
304
|
+
continue;
|
|
305
|
+
const id = CIRCUIT_RELAY_ID.exec(addr)?.[1];
|
|
306
|
+
if (id !== undefined)
|
|
307
|
+
ids.add(id);
|
|
308
|
+
}
|
|
309
|
+
return [...ids];
|
|
310
|
+
}
|
|
289
311
|
/**
|
|
290
312
|
* DOD-M15-RELAYSLOTS-1 — how long an agent skips a relay that refused it for a relay-side fault.
|
|
291
313
|
*
|
|
@@ -449,6 +471,34 @@ const AUTHORSHIP_CONTENT_HASH_MISMATCH = "authorship_hash_mismatch";
|
|
|
449
471
|
* not of the code, because the check ran before the signature was verified.
|
|
450
472
|
*/
|
|
451
473
|
const AUTHORSHIP_SESSION_MISMATCH = "session_mismatch";
|
|
474
|
+
/**
|
|
475
|
+
* ─── 033-ACKEMIT: the three things that can be wrong with an ACKNOWLEDGEMENT ─────────────────────
|
|
476
|
+
*
|
|
477
|
+
* All three are `unusable` — the message is refused and the session lives. None of them is an
|
|
478
|
+
* identity fault: by the time any is returned the signature has verified, the signer IS this
|
|
479
|
+
* session's counterparty, and the claim is about this content in this conversation. What is wrong is
|
|
480
|
+
* what the claim says the sender had SEEN.
|
|
481
|
+
*
|
|
482
|
+
* They are three names and not one because the operator's next move differs for each, and because an
|
|
483
|
+
* investigator who cannot tell "your counterparty is on an older build" from "your counterparty
|
|
484
|
+
* acknowledged something you never sent" is looking at the wrong half of the problem.
|
|
485
|
+
*
|
|
486
|
+
* ⚠️ **NAME WHAT WAS OBSERVED, NEVER AN INFERRED CONCLUSION** (`DOD-M15-ERRSTRING-1`). Not one of
|
|
487
|
+
* these says "peer is malicious" — a mismatch is equally what a genuine software fault on the other
|
|
488
|
+
* side looks like, and an error that names a party the code did not check is this milestone's
|
|
489
|
+
* founding defect.
|
|
490
|
+
*/
|
|
491
|
+
/** A v1 claim: it carries no `last_seen_hash`, so it asserts a POSITION and no content at all. */
|
|
492
|
+
const AUTHORSHIP_ACK_HASH_ABSENT = "ack_hash_absent";
|
|
493
|
+
/** The hash names content this side does not hold at the position the claim names. */
|
|
494
|
+
const AUTHORSHIP_ACK_HASH_MISMATCH = "ack_hash_mismatch";
|
|
495
|
+
/** The hash names content this side has never held — not in the tree, and not held pending a gap. */
|
|
496
|
+
const AUTHORSHIP_ACK_HASH_UNKNOWN = "ack_hash_unknown_content";
|
|
497
|
+
const ACK_HASH_REASONS = new Set([
|
|
498
|
+
AUTHORSHIP_ACK_HASH_ABSENT,
|
|
499
|
+
AUTHORSHIP_ACK_HASH_MISMATCH,
|
|
500
|
+
AUTHORSHIP_ACK_HASH_UNKNOWN,
|
|
501
|
+
]);
|
|
452
502
|
/**
|
|
453
503
|
* ⚠️ **THE REFUSALS THAT SAY THIS ARE THE ONES WHERE THE REFUSAL DOES NOT HOLD — NOT ALL OF THEM.**
|
|
454
504
|
*
|
|
@@ -669,7 +719,10 @@ export class SessionNodeManager {
|
|
|
669
719
|
*/
|
|
670
720
|
const claimedRegistration = !client.hasSession(sessionId);
|
|
671
721
|
if (claimedRegistration) {
|
|
672
|
-
|
|
722
|
+
// 033-ACKEMIT: the seal transport submits a ctrl leaf like any other, so it needs the same
|
|
723
|
+
// acknowledgement seed. It carries no assignment of its own, so the genesis is supplied here
|
|
724
|
+
// from the session's own active entry rather than derived inside the client.
|
|
725
|
+
client.registerSession(sessionId, node, undefined, undefined, this.#sessionGenesisPrevRoot(agentName, sessionId));
|
|
673
726
|
}
|
|
674
727
|
else {
|
|
675
728
|
/**
|
|
@@ -998,9 +1051,10 @@ export class SessionNodeManager {
|
|
|
998
1051
|
// initiator (consuming its agent's standing receiver) and the responder (consuming its agent's)
|
|
999
1052
|
// would contend for a single node and thrash. Keyed by agentName. A creation-in-flight guard set
|
|
1000
1053
|
// prevents two concurrent ensure() calls from building two nodes for the same agent.
|
|
1001
|
-
// `
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1054
|
+
// `relayPeerIds`: the relays this receiver holds an announced circuit through. The watchdog reads
|
|
1055
|
+
// it as a COUNT — zero tells "never had one" (already degraded, and already loud) apart from a
|
|
1056
|
+
// loss, and a drop that leaves it non-empty is a lost relay the agent can absorb without being
|
|
1057
|
+
// rebuilt. See #reservationWatchdogTick.
|
|
1004
1058
|
/**
|
|
1005
1059
|
* DOD-M12B-SESSION-SEED-1 — session id → the transport seed its node identity derives from.
|
|
1006
1060
|
*
|
|
@@ -1501,6 +1555,18 @@ export class SessionNodeManager {
|
|
|
1501
1555
|
* permanently less provable than the identical message that did not, for a reason with nothing to
|
|
1502
1556
|
* do with authorship.
|
|
1503
1557
|
*/
|
|
1558
|
+
/**
|
|
1559
|
+
* 033-ACKEMIT review F1 — what this side has ACTUALLY RECEIVED, per session: the canonical
|
|
1560
|
+
* position and the content hash at it.
|
|
1561
|
+
*
|
|
1562
|
+
* ⚠️ **IT MIRRORS THE RELAY CLIENT'S `#lastSeen` RATHER THAN REPLACING IT, and the duplication is
|
|
1563
|
+
* deliberate.** The submit path needs the value on the client, because that is where the claim is
|
|
1564
|
+
* built; the unwitnessed content path needs it here, because a session with no relay client has no
|
|
1565
|
+
* client to read it from. Both are written from ONE place — `#noteAcknowledgeable` below — so they
|
|
1566
|
+
* cannot come to disagree, and the client is preferred on read because it also sees leaves the
|
|
1567
|
+
* relay delivered that never came through this path.
|
|
1568
|
+
*/
|
|
1569
|
+
#lastAck = new Map();
|
|
1504
1570
|
#heldContent = new Map();
|
|
1505
1571
|
// DOD-MSG-4: the relay's high-water canonical sequence for this session — the largest sequence the
|
|
1506
1572
|
// relay has witnessed (max over leaf_deliver). Keyed #k(agent,session). EXPOSED for the next
|
|
@@ -1821,7 +1887,7 @@ export class SessionNodeManager {
|
|
|
1821
1887
|
* when its value is `undefined` — forces each of the three callers to state what this message was
|
|
1822
1888
|
* hashed under, so a new fourth caller cannot omit it by accident.
|
|
1823
1889
|
*/
|
|
1824
|
-
async #parkContent(agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg) {
|
|
1890
|
+
async #parkContent(agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg, structure1Signature, parkLeafKind) {
|
|
1825
1891
|
// Fault injection FIRST, so it reproduces the real shape: the refusal happens at the same point
|
|
1826
1892
|
// the live hook refuses (before any deposit), with the same event and the same `cause`.
|
|
1827
1893
|
if (this.#parkFaultRemaining > 0) {
|
|
@@ -1857,6 +1923,11 @@ export class SessionNodeManager {
|
|
|
1857
1923
|
// on recover too (sealed INTO the ciphertext envelope — INV-3: the relay still sees only ciphertext).
|
|
1858
1924
|
structure1Cbor,
|
|
1859
1925
|
structure2Cbor,
|
|
1926
|
+
// 034-CARRYLEAF: the author's signature over `structure1Cbor`, so the RECIPIENT can witness
|
|
1927
|
+
// this leaf if its author never does. Without it the mailbox route stays truncatable.
|
|
1928
|
+
structure1Signature,
|
|
1929
|
+
// 034-CARRYLEAF: the leaf DOMAIN, so a recovered leaf is never witnessed under a guess.
|
|
1930
|
+
leafKind: parkLeafKind,
|
|
1860
1931
|
// B2b: the park route must name the same algorithm the direct frame did, or the recipient
|
|
1861
1932
|
// verifies the same message two different ways depending on which route it took.
|
|
1862
1933
|
contentHashAlg,
|
|
@@ -1994,6 +2065,24 @@ export class SessionNodeManager {
|
|
|
1994
2065
|
// sealed record so it survives a daemon restart and is readable on the cert-read surface
|
|
1995
2066
|
// (cello_get_sealed_receipt). JSON string with hex-encoded pubkeys; NULL until sealed.
|
|
1996
2067
|
// Inline idempotent migration (NOT Flyway — this is the client-side SQLite, AC-011).
|
|
2068
|
+
/**
|
|
2069
|
+
* 033-ACKEMIT — THE SESSION'S GENESIS PREV_ROOT, and it is persisted for ONE reason: a
|
|
2070
|
+
* restart.
|
|
2071
|
+
*
|
|
2072
|
+
* It is a pure function of the two participant keys, the session id and the SESSION
|
|
2073
|
+
* TIMESTAMP — and the timestamp arrives on the directory-signed relay assignment and lives
|
|
2074
|
+
* nowhere else. A session restored from this table after a daemon restart re-registers with
|
|
2075
|
+
* no assignment in hand, so without this column the daemon could not say what the first
|
|
2076
|
+
* message of that session acknowledges, and every send on it would be refused rather than
|
|
2077
|
+
* signed. Deriving is still preferred where the assignment IS in memory; this is what makes
|
|
2078
|
+
* the derivation survive the process.
|
|
2079
|
+
*
|
|
2080
|
+
* NULL for every session opened before this column existed. Those sessions acknowledge
|
|
2081
|
+
* nothing until the counterparty has sent something — they claim position 0 with no hash,
|
|
2082
|
+
* which asserts nothing rather than asserting a position they cannot back — and from the
|
|
2083
|
+
* first leaf they receive they acknowledge content like any other session.
|
|
2084
|
+
*/
|
|
2085
|
+
"ALTER TABLE sessions ADD COLUMN genesis_prev_root BLOB",
|
|
1997
2086
|
"ALTER TABLE sessions ADD COLUMN seal_legibility TEXT",
|
|
1998
2087
|
"ALTER TABLE sessions ADD COLUMN sealed_root_hex TEXT",
|
|
1999
2088
|
// M7 legibility-TBS-binding (responder verify): the counterparty's FROST primary (group)
|
|
@@ -4146,7 +4235,9 @@ export class SessionNodeManager {
|
|
|
4146
4235
|
const sr = this.#standingReceivers.get(agentName);
|
|
4147
4236
|
if (!sr)
|
|
4148
4237
|
return "absent";
|
|
4149
|
-
|
|
4238
|
+
// AT LEAST ONE. Holding two circuits and losing one leaves the agent perfectly dialable, so it
|
|
4239
|
+
// is not "retrying" — reporting it as such sends an operator hunting a fault that is not there.
|
|
4240
|
+
if (sr.relayPeerIds.length > 0)
|
|
4150
4241
|
return "reserved";
|
|
4151
4242
|
const retry = this.#srReservationRetry.get(agentName);
|
|
4152
4243
|
return retry !== undefined && retry.attempts > SR_RESERVATION_MAX_RETRIES ? "unreachable" : "retrying";
|
|
@@ -4169,6 +4260,21 @@ export class SessionNodeManager {
|
|
|
4169
4260
|
* counterparty_session_* fields. Read-only — does NOT consume the standing receiver
|
|
4170
4261
|
* (unlike acceptSession, which hands it off).
|
|
4171
4262
|
*/
|
|
4263
|
+
/**
|
|
4264
|
+
* 032-RELAYSPREAD — would this receiver ADMIT an inbound dial from this relay?
|
|
4265
|
+
*
|
|
4266
|
+
* The gater's inbound carve-out is the security-sensitive half of the spread: only relays whose
|
|
4267
|
+
* own reservation is confirmed held earn it, so a directory that merely NAMES a relay cannot dial
|
|
4268
|
+
* in behind the gate. Nothing could observe that from outside the manager, and the review found
|
|
4269
|
+
* the consequence: substituting the CANDIDATE list for the held list at the `setReservedRelayPeers`
|
|
4270
|
+
* call kept every test in the unit green while shipping exactly that hole. A guard whose wiring
|
|
4271
|
+
* cannot be observed is a guard nothing can test.
|
|
4272
|
+
*
|
|
4273
|
+
* Reads the live gater rather than a copy, so it cannot drift from what the gate actually does.
|
|
4274
|
+
*/
|
|
4275
|
+
isRelayCarvedOutInbound(agentName, relayPeerId) {
|
|
4276
|
+
return this.#standingReceivers.get(agentName)?.gater.holdsInboundCarveOut(relayPeerId) ?? false;
|
|
4277
|
+
}
|
|
4172
4278
|
getStandingReceiverInfo(agentName) {
|
|
4173
4279
|
// DOD-LOOP-1: the initiator advertises ITS OWN agent's standing receiver, which it then reuses
|
|
4174
4280
|
// as the session node — so the advertised endpoint matches the node the counterparty dials.
|
|
@@ -4742,8 +4848,22 @@ export class SessionNodeManager {
|
|
|
4742
4848
|
this.#relayClients.set(clientKey, client);
|
|
4743
4849
|
}
|
|
4744
4850
|
const sessionIdHexForRelay = Buffer.from(relay.sessionIdBytes).toString("hex");
|
|
4745
|
-
|
|
4851
|
+
/**
|
|
4852
|
+
* ⚠️ THE GENESIS IS WRITTEN BEFORE THE REGISTRATION, NOT AFTER — review F10.
|
|
4853
|
+
*
|
|
4854
|
+
* `#sessionGenesisPrevRoot` reads the entry's assignment first and the stored column second,
|
|
4855
|
+
* and BOTH were still unset at this line: the entry's assignment is set below and the column
|
|
4856
|
+
* is written below that. So the argument was always `undefined` here on a first attach, and
|
|
4857
|
+
* the seed only survived because `registerSession` falls back to deriving one from the
|
|
4858
|
+
* assignment it is handed. That is a dead argument standing next to a live fallback, which
|
|
4859
|
+
* reads as deliberate and is the shape a later edit removes the wrong half of.
|
|
4860
|
+
*/
|
|
4746
4861
|
const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
|
|
4862
|
+
if (entry)
|
|
4863
|
+
entry.relayAssignment = relay.assignment;
|
|
4864
|
+
if (relay.assignment)
|
|
4865
|
+
this.#persistGenesisPrevRoot(agentName, sessionId, relay.assignment);
|
|
4866
|
+
client.registerSession(sessionIdHexForRelay, node, this.#relayLeafHandler(agentName, sessionId, correlationId), relay.assignment, this.#sessionGenesisPrevRoot(agentName, sessionId));
|
|
4747
4867
|
if (entry) {
|
|
4748
4868
|
entry.relayClient = client;
|
|
4749
4869
|
entry.relaySessionIdBytes = relay.sessionIdBytes;
|
|
@@ -4752,7 +4872,8 @@ export class SessionNodeManager {
|
|
|
4752
4872
|
entry.relayPeerId = relay.relayPeerId;
|
|
4753
4873
|
entry.relayAddrs = relay.relayAddrs;
|
|
4754
4874
|
// Review H1: the dial path needs the credential in hand, not just the endpoint.
|
|
4755
|
-
|
|
4875
|
+
// (Set above, before `registerSession`, so the genesis lookup it does has something to
|
|
4876
|
+
// find — see the note there.)
|
|
4756
4877
|
// MSG-2 startup-flush: also PERSIST it, so a restart's crash-backstop flush (which runs
|
|
4757
4878
|
// before the in-memory entry exists) can deposit un-acked content to the same relay.
|
|
4758
4879
|
try {
|
|
@@ -4868,6 +4989,10 @@ export class SessionNodeManager {
|
|
|
4868
4989
|
}
|
|
4869
4990
|
// registerSession presents the assignment eagerly (see its own comment). No leaf handler: this
|
|
4870
4991
|
// relay is not witnessing the session, it only needs the binding that authorizes the dial.
|
|
4992
|
+
// 033-ACKEMIT: no genesis is passed and none is needed. This client is not witnessing the
|
|
4993
|
+
// session — it never submits — and `registerSession` derives a seed from the assignment
|
|
4994
|
+
// anyway. Reaching into the session record for one here would also be reaching with the RELAY
|
|
4995
|
+
// session id, which is not the key that record is stored under.
|
|
4871
4996
|
client.registerSession(sessionIdHex, node, undefined, relay.assignment);
|
|
4872
4997
|
this.#logger.info("session.relay.assignment.presented_to_reservation_relay", {
|
|
4873
4998
|
agentName,
|
|
@@ -7126,6 +7251,76 @@ export class SessionNodeManager {
|
|
|
7126
7251
|
* on first access (so it survives a restart — AC-007). Never returns null;
|
|
7127
7252
|
* an unknown session yields an empty tree.
|
|
7128
7253
|
*/
|
|
7254
|
+
/**
|
|
7255
|
+
* The session's genesis prev_root — what its FIRST message acknowledges, before anything has been
|
|
7256
|
+
* received (033-ACKEMIT).
|
|
7257
|
+
*
|
|
7258
|
+
* DERIVED FIRST, STORED SECOND — and this docblock used to say "derived, never stored", which
|
|
7259
|
+
* stopped being true inside this same unit. Rewritten rather than deleted: a reader who believed
|
|
7260
|
+
* the first sentence would delete the column read below as redundant, and take the restart case
|
|
7261
|
+
* with it.
|
|
7262
|
+
*
|
|
7263
|
+
* The live assignment is authoritative, because it is the thing the value is defined by. The
|
|
7264
|
+
* stored column covers the one case the derivation cannot: a session restored after a restart
|
|
7265
|
+
* re-registers with no assignment, and the session TIMESTAMP the genesis needs lives nowhere
|
|
7266
|
+
* else.
|
|
7267
|
+
*
|
|
7268
|
+
* `undefined` when neither is available. The callers do not paper over that — they say, in the
|
|
7269
|
+
* log and in the claim itself, that this session acknowledges nothing yet.
|
|
7270
|
+
*/
|
|
7271
|
+
#sessionGenesisPrevRoot(agentName, sessionId) {
|
|
7272
|
+
const assignment = this.#activeNodes.get(this.#k(agentName, sessionId))?.relayAssignment;
|
|
7273
|
+
if (assignment) {
|
|
7274
|
+
return computeGenesisPrevRoot(assignment.participantA, assignment.participantB, Uint8Array.from(Buffer.from(sessionId, "hex")), assignment.sessionTimestamp);
|
|
7275
|
+
}
|
|
7276
|
+
/**
|
|
7277
|
+
* THE RESTART CASE. A session restored from the database re-registers with no assignment, so
|
|
7278
|
+
* the derivation above has nothing to work from and the stored copy is the only answer. Read
|
|
7279
|
+
* second, never first: the live assignment is authoritative, and a stored value that ever
|
|
7280
|
+
* disagreed with it would be the more dangerous of the two to prefer.
|
|
7281
|
+
*/
|
|
7282
|
+
const row = this.#db
|
|
7283
|
+
?.prepare("SELECT genesis_prev_root FROM sessions WHERE agent_id = ? AND session_id = ?")
|
|
7284
|
+
.get(this.#requireAgentId(agentName), sessionId);
|
|
7285
|
+
const stored = row?.genesis_prev_root;
|
|
7286
|
+
const bytes = stored instanceof Uint8Array ? stored : Buffer.isBuffer(stored) ? new Uint8Array(stored) : null;
|
|
7287
|
+
// A stored value of the wrong width is not a genesis. Refusing it here sends the caller down its
|
|
7288
|
+
// own named refusal, which is a better outcome than signing an acknowledgement of 17 bytes.
|
|
7289
|
+
return bytes && bytes.length === 32 ? bytes : undefined;
|
|
7290
|
+
}
|
|
7291
|
+
/**
|
|
7292
|
+
* Persist the session's genesis prev_root, once, at the moment the assignment arrives.
|
|
7293
|
+
*
|
|
7294
|
+
* `WHERE genesis_prev_root IS NULL` rather than a plain update: the value cannot legitimately
|
|
7295
|
+
* change for the life of a session, so the second writer is either redundant or wrong, and the
|
|
7296
|
+
* first write is the one derived closest to the assignment that opened the session.
|
|
7297
|
+
*/
|
|
7298
|
+
#persistGenesisPrevRoot(agentName, sessionId, assignment) {
|
|
7299
|
+
if (!this.#db)
|
|
7300
|
+
return;
|
|
7301
|
+
try {
|
|
7302
|
+
const genesis = computeGenesisPrevRoot(assignment.participantA, assignment.participantB, Uint8Array.from(Buffer.from(sessionId, "hex")), assignment.sessionTimestamp);
|
|
7303
|
+
this.#db
|
|
7304
|
+
.prepare("UPDATE sessions SET genesis_prev_root = ? WHERE agent_id = ? AND session_id = ? AND genesis_prev_root IS NULL")
|
|
7305
|
+
.run(Buffer.from(genesis), this.#requireAgentId(agentName), sessionId);
|
|
7306
|
+
}
|
|
7307
|
+
catch (err) {
|
|
7308
|
+
/**
|
|
7309
|
+
* LOUD, AND IT DOES NOT BLOCK. Losing this row costs the session its acknowledgements after a
|
|
7310
|
+
* restart — sends are then refused by name until the counterparty speaks — and that is a far
|
|
7311
|
+
* smaller harm than failing the session open that is in progress. Reported at ERROR because
|
|
7312
|
+
* the failure is invisible until a restart that may be days away.
|
|
7313
|
+
*/
|
|
7314
|
+
this.#logger.error("session.genesis.persist.failed", {
|
|
7315
|
+
agentName, sessionId,
|
|
7316
|
+
error: err instanceof Error ? err.message : String(err),
|
|
7317
|
+
impact: "this session's starting point was not written to the database. Everything works until " +
|
|
7318
|
+
"this daemon restarts; after that, a send on this session is refused until the " +
|
|
7319
|
+
"counterparty has sent something, because the daemon cannot say what its first message " +
|
|
7320
|
+
"acknowledges.",
|
|
7321
|
+
});
|
|
7322
|
+
}
|
|
7323
|
+
}
|
|
7129
7324
|
getSessionTree(agentName, sessionId) {
|
|
7130
7325
|
const key = this.#k(agentName, sessionId);
|
|
7131
7326
|
const cached = this.#trees.get(key);
|
|
@@ -7321,7 +7516,7 @@ export class SessionNodeManager {
|
|
|
7321
7516
|
entry.extraRelayClientKeys = [...(entry.extraRelayClientKeys ?? []), clientKey];
|
|
7322
7517
|
}
|
|
7323
7518
|
// No leaf handler: this relay is not witnessing the session, it only needs the binding.
|
|
7324
|
-
client.registerSession(sessionIdHex, entry.node, undefined, assignment);
|
|
7519
|
+
client.registerSession(sessionIdHex, entry.node, undefined, assignment, this.#sessionGenesisPrevRoot(agentName, sessionId));
|
|
7325
7520
|
const recorded = await client.recordAssignmentAndWait(entry.node, sessionIdHex);
|
|
7326
7521
|
if (recorded) {
|
|
7327
7522
|
this.#logger.info("session.transport.dial_authorized", {
|
|
@@ -7688,6 +7883,43 @@ export class SessionNodeManager {
|
|
|
7688
7883
|
...(witnessed.detail === undefined ? {} : { detail: witnessed.detail }),
|
|
7689
7884
|
correlationId,
|
|
7690
7885
|
});
|
|
7886
|
+
/**
|
|
7887
|
+
* ─── 033-ACKEMIT: THIS ONE REACHES THE OPERATOR, NOT JUST THE LOG ─────────────────────
|
|
7888
|
+
*
|
|
7889
|
+
* Every other refusal on this branch is an availability answer — the relay is busy, the
|
|
7890
|
+
* session is not recorded, the stream died — and the send degrades to unwitnessed, which
|
|
7891
|
+
* is the documented path. These two are not availability. The WITNESS is telling us that
|
|
7892
|
+
* the acknowledgement this daemon signed disagrees with the record, and that is a
|
|
7893
|
+
* statement about the integrity of the conversation.
|
|
7894
|
+
*
|
|
7895
|
+
* `logger.warn` followed by a bare assignment is the exact shape Invariant 2's recurring
|
|
7896
|
+
* box names — a guard that fires correctly into a file nobody opens. The named surface is
|
|
7897
|
+
* `noteContentRefusal`, the same one every inbound refusal in this file uses, so it lands
|
|
7898
|
+
* where the operator already looks for "something was rejected and here is why".
|
|
7899
|
+
*
|
|
7900
|
+
* It does NOT stop the send. The relay refused to witness this leaf, so the message
|
|
7901
|
+
* degrades to unwitnessed exactly as any other refusal does and the operator keeps their
|
|
7902
|
+
* conversation; what changes is that they are told the record has stopped agreeing with
|
|
7903
|
+
* itself, at the moment it happens, instead of discovering it at the seal.
|
|
7904
|
+
*/
|
|
7905
|
+
if (witnessed.reason === "ack_hash_mismatch" || witnessed.reason === "ack_hash_unverifiable") {
|
|
7906
|
+
const relayFault = witnessed.reason === "ack_hash_unverifiable";
|
|
7907
|
+
/**
|
|
7908
|
+
* THE SENTENCES LIVE IN `refusal-reasons.ts` — 033-ACKEMIT review F6. They were inline
|
|
7909
|
+
* here, behind a real relay answering a real refusal, so nothing could test them; and
|
|
7910
|
+
* the one that was wrong (a remedy naming a relay handover this system does not have)
|
|
7911
|
+
* was wrong for as long as that lasted.
|
|
7912
|
+
*/
|
|
7913
|
+
const { impact, guidance } = relayAckHashRefusalNotice(relayFault, this.#mailboxRouteAvailable(agentName));
|
|
7914
|
+
this.#logger.error("session.relay.ack_hash.refused", {
|
|
7915
|
+
agentName, sessionId, correlationId, reason: witnessed.reason,
|
|
7916
|
+
...(witnessed.detail === undefined ? {} : { detail: witnessed.detail }),
|
|
7917
|
+
impact, guidance,
|
|
7918
|
+
});
|
|
7919
|
+
this.noteContentRefusal(agentName, sessionId, witnessed.reason, {
|
|
7920
|
+
kind: REFUSAL_KINDS.REFUSED, impact, guidance,
|
|
7921
|
+
});
|
|
7922
|
+
}
|
|
7691
7923
|
relayRefusal = witnessed.reason;
|
|
7692
7924
|
}
|
|
7693
7925
|
}
|
|
@@ -7708,6 +7940,16 @@ export class SessionNodeManager {
|
|
|
7708
7940
|
// `finally` retires — same defect, other end, other cap (64 outbound per protocol per
|
|
7709
7941
|
// connection). See the note on #handleContentStream's finally.
|
|
7710
7942
|
let sendStream;
|
|
7943
|
+
/**
|
|
7944
|
+
* 034-CARRYLEAF — HOISTED OUT OF THE TRY so the PARK path in the catch can carry them.
|
|
7945
|
+
*
|
|
7946
|
+
* The park copy is built when the direct send fails, which is inside the catch below; declaring
|
|
7947
|
+
* these in the try left the parked envelope with only the relay-WITNESSED claim, so a message
|
|
7948
|
+
* its author deliberately did not witness was parked with no ordering claim at all and could
|
|
7949
|
+
* never be witnessed by its recipient.
|
|
7950
|
+
*/
|
|
7951
|
+
let frameS1;
|
|
7952
|
+
let frameSig;
|
|
7711
7953
|
try {
|
|
7712
7954
|
/**
|
|
7713
7955
|
* ─── EVERY FRAME CARRIES ITS OWN PROOF — `DOD-M15-AUTHORSHIP-ABSENT-1` ────────────────────
|
|
@@ -7731,8 +7973,8 @@ export class SessionNodeManager {
|
|
|
7731
7973
|
* widened that window enough to break the live two-node round trip. Measured, not reasoned
|
|
7732
7974
|
* about: seam-3 went red and both daemons logged `session.key.agreed` before the refusal.
|
|
7733
7975
|
*/
|
|
7734
|
-
|
|
7735
|
-
|
|
7976
|
+
frameS1 = orderingS1;
|
|
7977
|
+
frameSig = orderingSig;
|
|
7736
7978
|
let frameS2 = orderingS2;
|
|
7737
7979
|
if (frameS1 === undefined || frameSig === undefined) {
|
|
7738
7980
|
const own = await this.#signOwnContentClaim(agentName, sessionId, entry, contentHash);
|
|
@@ -7870,6 +8112,21 @@ export class SessionNodeManager {
|
|
|
7870
8112
|
// unknown CBOR key, so emitting it is safe for every build in existence; a newer one reads
|
|
7871
8113
|
// it and verifies under the named algorithm instead of assuming.
|
|
7872
8114
|
content_hash_alg: contentHashAlg,
|
|
8115
|
+
/**
|
|
8116
|
+
* 034-CARRYLEAF review F5 — WHICH LEAF DOMAIN this content belongs to.
|
|
8117
|
+
*
|
|
8118
|
+
* Documents and rejection envelopes ride this same frame, and the receiver had no way to
|
|
8119
|
+
* recover their kind: it appended them locally as "doc" from its own inspection of the
|
|
8120
|
+
* body, while anything it witnessed on the sender's behalf went to the relay as a MESSAGE
|
|
8121
|
+
* leaf. The certified root is over content hashes, so no root diverges — but the relay's
|
|
8122
|
+
* canonical log and the carried `leaf_kind` would describe the leaf as something it is not,
|
|
8123
|
+
* and a leaf kind selects a HASH DOMAIN everywhere else in this protocol.
|
|
8124
|
+
*
|
|
8125
|
+
* Same argument as `content_hash_alg` beside it: an older peer ignores an unknown CBOR key,
|
|
8126
|
+
* so emitting it is safe for every build in existence, and a receiver that does not see it
|
|
8127
|
+
* declines to witness rather than guessing (see `#witnessReceivedLeaf`).
|
|
8128
|
+
*/
|
|
8129
|
+
leaf_kind: leafKind,
|
|
7873
8130
|
});
|
|
7874
8131
|
// Injected dial failure — thrown from inside the try so it lands in exactly the catch the
|
|
7875
8132
|
// real connection_lost lands in, and the whole downstream path (untrack → park → durable
|
|
@@ -7948,7 +8205,21 @@ export class SessionNodeManager {
|
|
|
7948
8205
|
...this.#streamCensus(entry.node, entry.counterpartySessionPeerId),
|
|
7949
8206
|
correlationId,
|
|
7950
8207
|
});
|
|
7951
|
-
|
|
8208
|
+
/**
|
|
8209
|
+
* ⚠️ **THE PARKED COPY CARRIES THE SIGNED CLAIM, NOT ONLY THE WITNESSED ONE — 034-CARRYLEAF
|
|
8210
|
+
* review F1, and this is what closes the withholding attack on the MAILBOX route.**
|
|
8211
|
+
*
|
|
8212
|
+
* It passed `orderingS1`, which exists only when the relay witnessed this leaf — so a message
|
|
8213
|
+
* the sender deliberately did not witness was parked with no ordering claim at all, and the
|
|
8214
|
+
* recipient recovered it holding nothing the relay would accept as proof of authorship. They
|
|
8215
|
+
* could read it and could never put it in a receipt.
|
|
8216
|
+
*
|
|
8217
|
+
* `frameS1`/`frameSig` are the pair the DIRECT frame carries for this same message: the
|
|
8218
|
+
* relay's witnessed claim when there is one, and otherwise a claim this agent signed itself.
|
|
8219
|
+
* Either way it is the author's signature over the author's own ordering claim, which is the
|
|
8220
|
+
* only form the relay accepts when the recipient witnesses on their behalf.
|
|
8221
|
+
*/
|
|
8222
|
+
const attempt = await this.#parkContent(agentName, sessionId, hashHex, content, frameS1, orderingS2, contentHashAlg, frameSig, leafKind);
|
|
7952
8223
|
if (attempt.outcome === "parked") {
|
|
7953
8224
|
this.#noteImpairmentRetention(agentName, sessionId, "parked");
|
|
7954
8225
|
return { ok: true, delivered: false, parked: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(sentAuthorship === undefined ? {} : { authorship: sentAuthorship }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
|
|
@@ -12008,6 +12279,32 @@ export class SessionNodeManager {
|
|
|
12008
12279
|
* wired, this line is the difference between an all-clear and a permanent silent discard — and
|
|
12009
12280
|
* finding that then costs more than the clause costs now.
|
|
12010
12281
|
*/
|
|
12282
|
+
/**
|
|
12283
|
+
* ─── WITNESS A RECOVERED MESSAGE ITS SENDER NEVER WITNESSED — 034-CARRYLEAF review F1 ────────
|
|
12284
|
+
*
|
|
12285
|
+
* The mailbox route's half of the withholding fix. `recoveredSeq` absent means no relay ordering
|
|
12286
|
+
* record came with it, which is the same shape the direct path treats as "their submit never
|
|
12287
|
+
* happened" — and it is reached the same two ways: their relay was briefly unreachable, or they
|
|
12288
|
+
* are withholding on purpose.
|
|
12289
|
+
*
|
|
12290
|
+
* ⚠️ THE SIGNATURE COMES FROM THE ENVELOPE, AND ONLY A v4 ENVELOPE HAS ONE. `parkSig`
|
|
12291
|
+
* authenticates the DEPOSIT — it signs `(session_id, recipient_pubkey, content_hash)` — and the
|
|
12292
|
+
* relay will not accept it, because a counter-submit is admissible only against the author's own
|
|
12293
|
+
* signature over their own ordering claim. A v2 or v3 envelope therefore cannot be witnessed on
|
|
12294
|
+
* its author's behalf, and is left alone rather than guessed at.
|
|
12295
|
+
*
|
|
12296
|
+
* **So this route is closed against a peer running a stock client, and open to one that
|
|
12297
|
+
* deliberately emits an older envelope.** Requiring v4 is the step that closes it completely,
|
|
12298
|
+
* and it waits on nothing in the field emitting v2 or v3 — the same tolerate-then-enforce
|
|
12299
|
+
* sequence every bilateral wire change in this milestone follows.
|
|
12300
|
+
*/
|
|
12301
|
+
if (result.ok && result.held !== true && result.screenedOut !== true &&
|
|
12302
|
+
recoveredSeq === null && env.structure1Cbor && env.structure1Signature) {
|
|
12303
|
+
const kind = env.leafKind;
|
|
12304
|
+
if (typeof kind === "number") {
|
|
12305
|
+
this.#witnessReceivedLeaf(agentName, sessionId, contentHash, env.structure1Cbor, env.structure1Signature, kind, correlationId);
|
|
12306
|
+
}
|
|
12307
|
+
}
|
|
12011
12308
|
if (priorDeclaredAlg !== undefined && result.ok && result.held !== true && result.screenedOut !== true) {
|
|
12012
12309
|
// Cleared ONLY on a real reconciliation. Clearing on the lookup (as this did) forgets the
|
|
12013
12310
|
// refusal even when the recovery fails, so the next genuine reconciliation says nothing.
|
|
@@ -12710,6 +13007,145 @@ export class SessionNodeManager {
|
|
|
12710
13007
|
this.#sessionContentKeys.set(this.#k(agentName, sessionId), Uint8Array.from(key));
|
|
12711
13008
|
this.#contentEncryptionReasons.delete(this.#k(agentName, sessionId));
|
|
12712
13009
|
}
|
|
13010
|
+
/**
|
|
13011
|
+
* Hand the relay a leaf this agent RECEIVED whose author never submitted it — 034-CARRYLEAF.
|
|
13012
|
+
*
|
|
13013
|
+
* **The attack this closes:** somebody sends you something, declines to have it witnessed, and
|
|
13014
|
+
* seals one message short. The relay's account really does end before their last message, so your
|
|
13015
|
+
* receipt does too — every leaf validly signed, nothing false, the last thing said simply absent.
|
|
13016
|
+
*
|
|
13017
|
+
* **Why this is admissible and not a forgery:** the bytes are theirs, the signature over them is
|
|
13018
|
+
* theirs, and `#verifyAuthorshipClaim` verified it against this session's counterparty before a
|
|
13019
|
+
* word of it was ingested. The relay verifies it again against the directory-signed assignment.
|
|
13020
|
+
* Nothing here is asserted by us except that we received it.
|
|
13021
|
+
*
|
|
13022
|
+
* ⚠️ BEST-EFFORT, AND ITS FAILURE IS NOT SILENT. If the relay cannot be reached, the message is
|
|
13023
|
+
* still delivered and read — refusing it would make the relay a precondition for reading mail,
|
|
13024
|
+
* which is the thing every unit on this path has been careful not to do. What is lost is only the
|
|
13025
|
+
* guarantee that it can enter a receipt, and that surfaces where the operator can act on it: the
|
|
13026
|
+
* seal's own pre-flight refuses a gapped chain by name (`seal_carry_noncontiguous`) with guidance,
|
|
13027
|
+
* so the consequence reaches them at the moment it matters rather than as a log line here.
|
|
13028
|
+
*/
|
|
13029
|
+
#witnessReceivedLeaf(agentName, sessionId, contentHash, structure1Cbor, senderSignature,
|
|
13030
|
+
/** The domain the AUTHOR assigned this leaf, read off their frame — never guessed (review F5). */
|
|
13031
|
+
leafKind, correlationId) {
|
|
13032
|
+
const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
|
|
13033
|
+
if (!entry?.relayClient || !entry.relaySessionIdBytes) {
|
|
13034
|
+
this.#logger.warn("session.content.witness_received.unavailable", {
|
|
13035
|
+
agentName, sessionId, correlationId,
|
|
13036
|
+
impact: "a message arrived that its sender never had witnessed, and this side has no relay client " +
|
|
13037
|
+
"for the session, so it could not be witnessed here either. It is delivered and readable; " +
|
|
13038
|
+
"it cannot enter a notarized receipt until some party witnesses it.",
|
|
13039
|
+
});
|
|
13040
|
+
return;
|
|
13041
|
+
}
|
|
13042
|
+
void entry.relayClient
|
|
13043
|
+
.witnessReceivedLeaf(entry.node, entry.relaySessionIdBytes, contentHash, leafKind, {
|
|
13044
|
+
structure1Cbor,
|
|
13045
|
+
senderSignature,
|
|
13046
|
+
})
|
|
13047
|
+
.then((res) => {
|
|
13048
|
+
if (res.ok) {
|
|
13049
|
+
this.#logger.info("session.content.witness_received", {
|
|
13050
|
+
agentName, sessionId, correlationId, relaySequence: res.sequence_number,
|
|
13051
|
+
impact: "this side witnessed a message its SENDER did not. The leaf now holds a canonical " +
|
|
13052
|
+
"position, so it can appear in a receipt whatever the sender does next.",
|
|
13053
|
+
});
|
|
13054
|
+
// It has a position now, so it can be acknowledged like any other received message.
|
|
13055
|
+
this.#noteAcknowledgeable(agentName, sessionId, res.sequence_number - 1, contentHash);
|
|
13056
|
+
return;
|
|
13057
|
+
}
|
|
13058
|
+
/**
|
|
13059
|
+
* `counter_submit_duplicate` is NOT a failure and must not be logged as one: it means this
|
|
13060
|
+
* relay already holds the leaf, which is the outcome we wanted. It fires on the ordinary
|
|
13061
|
+
* race where the sender's own submit lands while ours is in flight.
|
|
13062
|
+
*/
|
|
13063
|
+
if (res.reason === "counter_submit_duplicate") {
|
|
13064
|
+
this.#logger.info("session.content.witness_received.already_held", {
|
|
13065
|
+
agentName, sessionId, correlationId,
|
|
13066
|
+
impact: "the relay already held this leaf — its sender witnessed it after all, or in parallel with us.",
|
|
13067
|
+
});
|
|
13068
|
+
return;
|
|
13069
|
+
}
|
|
13070
|
+
this.#logger.error("session.content.witness_received.failed", {
|
|
13071
|
+
agentName, sessionId, correlationId, reason: res.reason,
|
|
13072
|
+
...(res.detail === undefined ? {} : { detail: res.detail }),
|
|
13073
|
+
impact: "a message arrived that its sender never had witnessed, and this side could not witness " +
|
|
13074
|
+
"it either. It is delivered and readable. What is at risk is the RECEIPT: if this " +
|
|
13075
|
+
"message is still unwitnessed when the conversation is sealed, the seal will refuse a " +
|
|
13076
|
+
"gapped chain by name rather than quietly leaving it out.",
|
|
13077
|
+
});
|
|
13078
|
+
})
|
|
13079
|
+
.catch((err) => {
|
|
13080
|
+
this.#logger.error("session.content.witness_received.threw", {
|
|
13081
|
+
agentName, sessionId, correlationId, error: extractErrorMessage(err),
|
|
13082
|
+
});
|
|
13083
|
+
});
|
|
13084
|
+
}
|
|
13085
|
+
/**
|
|
13086
|
+
* Record that a message ARRIVED and was accepted at a known canonical position — 033-ACKEMIT
|
|
13087
|
+
* review F1.
|
|
13088
|
+
*
|
|
13089
|
+
* The one writer for both copies of the acknowledgement, so the claim this daemon signs says what
|
|
13090
|
+
* it actually received rather than what the relay got round to delivering back to it.
|
|
13091
|
+
*
|
|
13092
|
+
* Monotonic, and it must be: a re-delivery or a recovered park of an EARLIER message must not walk
|
|
13093
|
+
* the acknowledgement backwards, and must not swap the hash under an unchanged position.
|
|
13094
|
+
*/
|
|
13095
|
+
#noteAcknowledgeable(agentName, sessionId, canonicalSeq, contentHash) {
|
|
13096
|
+
// Relay sequences are 1-based; a canonical leaf index is 0-based. The claim carries the relay's
|
|
13097
|
+
// number, because the relay is what checks it.
|
|
13098
|
+
const relaySeq = canonicalSeq + 1;
|
|
13099
|
+
if (relaySeq < 1)
|
|
13100
|
+
return;
|
|
13101
|
+
const key = this.#k(agentName, sessionId);
|
|
13102
|
+
const prev = this.#lastAck.get(key);
|
|
13103
|
+
if (prev && relaySeq <= prev.seq)
|
|
13104
|
+
return;
|
|
13105
|
+
this.#lastAck.set(key, { seq: relaySeq, hash: Uint8Array.from(contentHash) });
|
|
13106
|
+
const entry = this.#activeNodes.get(key);
|
|
13107
|
+
const sessionIdHex = entry?.relaySessionIdBytes
|
|
13108
|
+
? Buffer.from(entry.relaySessionIdBytes).toString("hex")
|
|
13109
|
+
: sessionId;
|
|
13110
|
+
entry?.relayClient?.noteReceivedLeaf(sessionIdHex, relaySeq, contentHash);
|
|
13111
|
+
}
|
|
13112
|
+
/**
|
|
13113
|
+
* Test seam: put an own leaf in the HELD state instead of the tree — 033-ACKEMIT.
|
|
13114
|
+
*
|
|
13115
|
+
* The state `placeOwnLeaf` produces when the relay assigns a position ahead of our tail: the leaf
|
|
13116
|
+
* exists on this side and is not in the tree, while the counterparty already has it from the relay
|
|
13117
|
+
* and can acknowledge it. Reproducing it through the real hold map rather than by asserting the
|
|
13118
|
+
* tree is short is what makes the acknowledgement test measure the case instead of a neighbour of
|
|
13119
|
+
* it.
|
|
13120
|
+
*/
|
|
13121
|
+
holdOwnLeafForTest(agentName, sessionId, canonicalSeq, contentHashHex) {
|
|
13122
|
+
const key = this.#k(agentName, sessionId);
|
|
13123
|
+
let held = this.#heldContent.get(key);
|
|
13124
|
+
if (!held) {
|
|
13125
|
+
held = new Map();
|
|
13126
|
+
this.#heldContent.set(key, held);
|
|
13127
|
+
}
|
|
13128
|
+
held.set(canonicalSeq, { content: new Uint8Array(), contentHashHex, origin: "sent", kind: "msg" });
|
|
13129
|
+
}
|
|
13130
|
+
/**
|
|
13131
|
+
* Test seam: put the session's genesis prev_root where a completed session open leaves it —
|
|
13132
|
+
* 033-ACKEMIT.
|
|
13133
|
+
*
|
|
13134
|
+
* ⚠️ THE STATE IS THE PRODUCTION ONE; ONLY HOW IT GOT THERE IS SHORT-CIRCUITED, exactly as
|
|
13135
|
+
* `setSessionContentKeyForTest` short-circuits the key exchange next door.
|
|
13136
|
+
*
|
|
13137
|
+
* In production this value is derived from the directory-signed relay assignment and written to
|
|
13138
|
+
* the session row the moment the session learns it, so every real session has one. A fixture that
|
|
13139
|
+
* builds a session node directly never sees an assignment — so without this seam every content
|
|
13140
|
+
* test built on the fixture would be exercising the "no starting point" REFUSAL path instead of
|
|
13141
|
+
* the thing it was written for, and would report that as a pass or a mysterious failure depending
|
|
13142
|
+
* on which side of the send it sat on.
|
|
13143
|
+
*/
|
|
13144
|
+
setSessionGenesisForTest(agentName, sessionId, genesis) {
|
|
13145
|
+
this.#db
|
|
13146
|
+
?.prepare("UPDATE sessions SET genesis_prev_root = ? WHERE agent_id = ? AND session_id = ?")
|
|
13147
|
+
.run(Buffer.from(genesis), this.#requireAgentId(agentName), sessionId);
|
|
13148
|
+
}
|
|
12713
13149
|
/**
|
|
12714
13150
|
* Test seam: drop the agreed key while leaving the session up — the state before an exchange
|
|
12715
13151
|
* completes, and after a teardown evicts one. Its mirror above is what a completed exchange
|
|
@@ -14243,18 +14679,70 @@ export class SessionNodeManager {
|
|
|
14243
14679
|
// second meaning: for every session created without an assignment the two are the same value
|
|
14244
14680
|
// (`relaySessionIdBytes` is set from `sessionId` on exactly those paths).
|
|
14245
14681
|
const sessionIdBytes = entry.relaySessionIdBytes ?? Uint8Array.from(Buffer.from(sessionId, "hex"));
|
|
14682
|
+
/**
|
|
14683
|
+
* ⚠️ **THIS COMMENT USED TO READ "v1 DELIBERATELY" AND IT WAS RIGHT UNTIL NOW — 033-ACKEMIT.**
|
|
14684
|
+
*
|
|
14685
|
+
* It said `last_seen_hash` was `WITHHOLD-SEAL-1`'s emitter and "not owed here", and that a v1
|
|
14686
|
+
* claim makes no content acknowledgement at all, "which is honest, where an invented one would
|
|
14687
|
+
* not be." Accurate for `020-ACKHASH`, which shipped the reader only. It is rewritten rather
|
|
14688
|
+
* than deleted because it is the sentence that would otherwise explain away the LAST production
|
|
14689
|
+
* path still emitting v1 — and this unit's own Definition of Done is that a grep finds none.
|
|
14690
|
+
*
|
|
14691
|
+
* The reasoning it rested on has been answered: nothing is invented here. The acknowledgement
|
|
14692
|
+
* is read from the same `#lastSeen` entry the submit reads, so a frame built on this path and
|
|
14693
|
+
* one built by a submit make the same claim about the same message.
|
|
14694
|
+
*/
|
|
14695
|
+
const sessionIdHexForAck = Buffer.from(sessionIdBytes).toString("hex");
|
|
14696
|
+
/**
|
|
14697
|
+
* The pair, from ONE accessor. Falling back to the session's genesis when there is no relay
|
|
14698
|
+
* client at all is not an invention either: nothing has been witnessed on this session, so the
|
|
14699
|
+
* honest acknowledgement is position 0 and the agreed starting point of the chain.
|
|
14700
|
+
*/
|
|
14701
|
+
const ack = entry.relayClient?.lastSeenAck(sessionIdHexForAck)
|
|
14702
|
+
?? this.#lastAck.get(this.#k(agentName, sessionId))
|
|
14703
|
+
?? (() => { const g = this.#sessionGenesisPrevRoot(agentName, sessionId); return g ? { seq: 0, hash: g } : undefined; })();
|
|
14704
|
+
if (!ack) {
|
|
14705
|
+
/**
|
|
14706
|
+
* ⚠️ **v1, AND ONLY BECAUSE THERE IS NOTHING TO ACKNOWLEDGE — see `#verifyAcknowledgedContent`
|
|
14707
|
+
* for the receiving half of the same rule, which is what makes this safe rather than a
|
|
14708
|
+
* downgrade.**
|
|
14709
|
+
*
|
|
14710
|
+
* Reaching here means this session has no recorded starting point AND has received nothing.
|
|
14711
|
+
* The claim it produces is `last_seen_seq: 0` with no hash: "I have seen nothing of yours, and
|
|
14712
|
+
* I assert nothing about your content." That is honest, and it is not the fail-open the unit
|
|
14713
|
+
* closes — the hole is a claim that names a POSITION with no content behind it, and this names
|
|
14714
|
+
* no position. A receiver refuses a v1 claim the moment it acknowledges position 1 or beyond.
|
|
14715
|
+
*
|
|
14716
|
+
* It does not throw, and an earlier version did. Sessions brokered without a relay assignment
|
|
14717
|
+
* are real — the directory does not always return one — and throwing there stopped those
|
|
14718
|
+
* sessions sending at all, which trades a hole this claim does not have for a failure of the
|
|
14719
|
+
* thing the product is for.
|
|
14720
|
+
*/
|
|
14721
|
+
this.#logger.info("session.content.claim.unacknowledged", {
|
|
14722
|
+
agentName, sessionId,
|
|
14723
|
+
impact: "this message is signed with no acknowledgement of anything received, because this " +
|
|
14724
|
+
"session has no recorded starting point and nothing has arrived on it yet. It binds the " +
|
|
14725
|
+
"sender and the content as always; it makes no claim about the counterparty's messages.",
|
|
14726
|
+
});
|
|
14727
|
+
const bare = encodeStructure1({
|
|
14728
|
+
contentHash,
|
|
14729
|
+
senderPubkey: await signer.getPublicKey(),
|
|
14730
|
+
sessionId: sessionIdBytes,
|
|
14731
|
+
lastSeenSeq: 0,
|
|
14732
|
+
timestamp: Date.now(),
|
|
14733
|
+
});
|
|
14734
|
+
return { structure1: bare, signature: await signer.sign(bare) };
|
|
14735
|
+
}
|
|
14246
14736
|
const structure1 = encodeStructure1({
|
|
14247
14737
|
contentHash,
|
|
14248
14738
|
senderPubkey: await signer.getPublicKey(),
|
|
14249
14739
|
sessionId: sessionIdBytes,
|
|
14250
14740
|
// The highest counterparty position this session has seen, from the same source the submit
|
|
14251
|
-
// reads
|
|
14252
|
-
//
|
|
14253
|
-
lastSeenSeq:
|
|
14741
|
+
// reads — and now the content hash at it, taken from the same entry so the two cannot
|
|
14742
|
+
// describe different messages.
|
|
14743
|
+
lastSeenSeq: ack.seq,
|
|
14254
14744
|
timestamp: Date.now(),
|
|
14255
|
-
|
|
14256
|
-
// a v1 claim makes no content acknowledgement at all, which is honest, where an invented one
|
|
14257
|
-
// would not be.
|
|
14745
|
+
lastSeenHash: ack.hash,
|
|
14258
14746
|
});
|
|
14259
14747
|
return { structure1, signature: await signer.sign(structure1) };
|
|
14260
14748
|
}
|
|
@@ -14386,8 +14874,178 @@ export class SessionNodeManager {
|
|
|
14386
14874
|
if (!bytesEqual(s1.fields.sessionId, expectedSessionId)) {
|
|
14387
14875
|
return { verdict: "unusable", reason: AUTHORSHIP_SESSION_MISMATCH };
|
|
14388
14876
|
}
|
|
14877
|
+
/**
|
|
14878
|
+
* ─── AND IT MUST ACKNOWLEDGE SOMETHING THAT WAS ACTUALLY SAID — 033-ACKEMIT ──────────────────
|
|
14879
|
+
*
|
|
14880
|
+
* **THIS IS THE HALF THAT NEEDS NO RELAY, and it is the reason the unit exists.** Everything the
|
|
14881
|
+
* check consumes is on this machine: the counterparty's own signed bytes, and our own tree. We
|
|
14882
|
+
* do not ask the relay what position 7 held — we already know, because we placed the leaf there.
|
|
14883
|
+
*
|
|
14884
|
+
* Until now a signed acknowledgement was a NUMBER. "I saw position 7" attests to a position and
|
|
14885
|
+
* never to content, so the only thing binding the acknowledgement to a message was the relay's
|
|
14886
|
+
* separate receipt over `content_hash ‖ seq ‖ timestamp`. Withhold the relay's half and the
|
|
14887
|
+
* signed claim is an unbacked number — which is how a counterparty seals one message short.
|
|
14888
|
+
* With the hash signed, the claim stands on its own and the relay is no longer load-bearing for
|
|
14889
|
+
* it.
|
|
14890
|
+
*
|
|
14891
|
+
* It runs LAST for the reason the two checks above run last: everything from here down answers
|
|
14892
|
+
* `unusable`, which refuses the message and leaves the session alive, while a `refuted` FREEZES
|
|
14893
|
+
* it. A check that could answer before the signature and the signer were established would hand
|
|
14894
|
+
* a peer a switch for choosing the softer outcome. By this line the claim provably came from
|
|
14895
|
+
* this session's counterparty, about this content, in this conversation — the only question
|
|
14896
|
+
* left is whether what they say they saw is what we sent.
|
|
14897
|
+
*/
|
|
14898
|
+
const ackVerdict = this.#verifyAcknowledgedContent(agentName, sessionId, s1.fields);
|
|
14899
|
+
if (ackVerdict)
|
|
14900
|
+
return ackVerdict;
|
|
14389
14901
|
return { verdict: "verified", senderPubkey: s1Pubkey, senderSig: senderSignature };
|
|
14390
14902
|
}
|
|
14903
|
+
/**
|
|
14904
|
+
* Does this claim's `last_seen_hash` name content this side actually put at that position?
|
|
14905
|
+
*
|
|
14906
|
+
* Returns `undefined` when the acknowledgement holds, or the `unusable` verdict to refuse with.
|
|
14907
|
+
* Split out of `#verifyAuthorshipClaim` so the three refusal causes can be named separately —
|
|
14908
|
+
* a claim that carries NO hash, one that names a position we never reached, and one that names
|
|
14909
|
+
* the wrong content — rather than collapsing into a single "the proof was bad".
|
|
14910
|
+
*
|
|
14911
|
+
* ⚠️ MISSING, MALFORMED AND MISMATCHED TAKE ONE PATH (§5). A v1 claim carries no content
|
|
14912
|
+
* assertion at all, and treating that as "fine, skip the check" would recreate the fail-open this
|
|
14913
|
+
* unit is closing one layer down: an attacker who wants to evade a mismatch check simply never
|
|
14914
|
+
* supplies a checkable proof. `decodeStructure1` has already refused a v2 whose hash is the wrong
|
|
14915
|
+
* width, so `lastSeenHash === null` here means exactly one thing — a v1 layout — and it is
|
|
14916
|
+
* refused by its own name.
|
|
14917
|
+
*/
|
|
14918
|
+
#verifyAcknowledgedContent(agentName, sessionId, fields) {
|
|
14919
|
+
/**
|
|
14920
|
+
* ⚠️ **A v1 CLAIM IS REFUSED THE MOMENT IT NAMES A POSITION — and accepted when it names none.
|
|
14921
|
+
* The split is the whole rule, so it is stated rather than left to the reader.**
|
|
14922
|
+
*
|
|
14923
|
+
* `last_seen_seq >= 1` with no hash IS the defect: "I saw position 7" attests to a position and
|
|
14924
|
+
* never to content, which is the unbacked number this unit exists to stop accepting. Treating
|
|
14925
|
+
* that as "fine, skip the check" would recreate `DOD-M15-AUTHORSHIP-ABSENT-1` one layer down —
|
|
14926
|
+
* an attacker evading a mismatch check simply never supplies a checkable proof.
|
|
14927
|
+
*
|
|
14928
|
+
* `last_seen_seq === 0` with no hash claims nothing about our messages, so there is no check to
|
|
14929
|
+
* skip and nothing to bind. A sender genuinely in that state — a session brokered without a
|
|
14930
|
+
* relay assignment, which the directory does not always return — has nothing to acknowledge,
|
|
14931
|
+
* and refusing them would stop the product's own advertised journey to close a hole they are
|
|
14932
|
+
* not in.
|
|
14933
|
+
*
|
|
14934
|
+
* **THE BOUND, SAID PLAINLY:** a peer can decline to bind by never acknowledging anything.
|
|
14935
|
+
* That costs them their own ratification of our history rather than falsifying it, and it is
|
|
14936
|
+
* the same under-claiming the relay has always allowed (it refuses a `last_seen_seq` that runs
|
|
14937
|
+
* AHEAD of its counter, never one that lags). This unit does not change that either way, and
|
|
14938
|
+
* the follow-on that does is the receiver submitting a hash for what it received.
|
|
14939
|
+
*/
|
|
14940
|
+
if (fields.lastSeenHash === null) {
|
|
14941
|
+
return fields.lastSeenSeq >= 1
|
|
14942
|
+
? { verdict: "unusable", reason: AUTHORSHIP_ACK_HASH_ABSENT }
|
|
14943
|
+
: undefined;
|
|
14944
|
+
}
|
|
14945
|
+
/**
|
|
14946
|
+
* THE GENESIS IS A VALUE, NEVER AN ABSENCE. The first message of a session has seen nothing, and
|
|
14947
|
+
* that case is a defined 32 bytes — the agreed starting point of this two-party chain, derived
|
|
14948
|
+
* from both keys, the session id and the session timestamp. Not 32 zero bytes: a constant
|
|
14949
|
+
* identical across every session is one an attacker can present for any session, so the one
|
|
14950
|
+
* position most exposed to a forged acknowledgement would be the only one nobody could check.
|
|
14951
|
+
*/
|
|
14952
|
+
if (fields.lastSeenSeq <= 0) {
|
|
14953
|
+
const genesis = this.#sessionGenesisPrevRoot(agentName, sessionId);
|
|
14954
|
+
/**
|
|
14955
|
+
* ⚠️ **SOFT HERE, AND THIS IS THE ONE BRANCH WHERE THAT IS NOT A FAIL-OPEN — the reasoning is
|
|
14956
|
+
* the load-bearing part, so it is written down rather than assumed.**
|
|
14957
|
+
*
|
|
14958
|
+
* `last_seen_seq` 0 means "I have received nothing from you", and the hash that goes with it
|
|
14959
|
+
* is the session's agreed starting point. It is a genuine value and this daemon always emits
|
|
14960
|
+
* it — but as a CHECK it is close to redundant, because the thing it establishes (that this
|
|
14961
|
+
* claim was made for THIS session) has already been established three lines above by the
|
|
14962
|
+
* session-id binding, against a value derived from the same session id.
|
|
14963
|
+
*
|
|
14964
|
+
* **What an attacker gains by reaching this branch: nothing.** They cannot skip the real
|
|
14965
|
+
* comparison by claiming 0, because claiming 0 is claiming to have acknowledged NOTHING of
|
|
14966
|
+
* ours — it removes their own ratification of our history rather than falsifying it, and the
|
|
14967
|
+
* positional check below is what a claim about our messages has to survive. And they cannot
|
|
14968
|
+
* cause the absence either: whether we hold a genesis depends on our own assignment and our
|
|
14969
|
+
* own database, never on anything they send.
|
|
14970
|
+
*
|
|
14971
|
+
* The alternative was refusing, and it would have been the wrong kind of strict: a session
|
|
14972
|
+
* restored from a row written before this column existed holds no genesis, and every first
|
|
14973
|
+
* message on it would be refused for something the counterparty did not do.
|
|
14974
|
+
*/
|
|
14975
|
+
if (!genesis) {
|
|
14976
|
+
this.#logger.info("session.content.ack_hash.genesis_unavailable", {
|
|
14977
|
+
agentName, sessionId,
|
|
14978
|
+
impact: "this message acknowledges nothing yet, and this side holds no recorded starting point " +
|
|
14979
|
+
"for the session, so the acknowledgement was not compared. The message is accepted: it " +
|
|
14980
|
+
"is already bound to this conversation by the session id inside the signed bytes.",
|
|
14981
|
+
});
|
|
14982
|
+
return undefined;
|
|
14983
|
+
}
|
|
14984
|
+
return bytesEqual(fields.lastSeenHash, genesis)
|
|
14985
|
+
? undefined
|
|
14986
|
+
: { verdict: "unusable", reason: AUTHORSHIP_ACK_HASH_MISMATCH };
|
|
14987
|
+
}
|
|
14988
|
+
/**
|
|
14989
|
+
* ─── THE ACKNOWLEDGED CONTENT MUST BE SOMETHING THIS SIDE ACTUALLY HOLDS ─────────────────────
|
|
14990
|
+
*
|
|
14991
|
+
* ⚠️ **AN EARLIER VERSION OF THIS CHECK WAS POSITION-ONLY AND HAD A HOLE THE ATTACKER COULD
|
|
14992
|
+
* OPEN THEMSELVES. It is kept described, not deleted, because the false reasoning is the part
|
|
14993
|
+
* worth not repeating.**
|
|
14994
|
+
*
|
|
14995
|
+
* It compared `hashAt(last_seen_seq - 1)` and WAIVED the whole comparison on a session marked
|
|
14996
|
+
* diverged, on this stated ground: *"Who controls this absence? Not the peer: divergence is
|
|
14997
|
+
* caused by OUR submit failing, and nothing the counterparty sends can produce it."*
|
|
14998
|
+
*
|
|
14999
|
+
* **That was false, and the party who could falsify it is the exact attacker this line names.**
|
|
15000
|
+
* Send a message direct-only and never submit its hash: we have no ordering record, so it is
|
|
15001
|
+
* appended at the tail and our tree runs one ahead of the relay's counter. Our very next send
|
|
15002
|
+
* then gets an assigned position BEHIND our frontier, `placeOwnLeaf` takes its
|
|
15003
|
+
* `position_behind_frontier` branch and calls `markSessionDiverged` — and from that moment every
|
|
15004
|
+
* inbound acknowledgement skipped the check entirely. **One withheld message plus one reply from
|
|
15005
|
+
* us disabled the guard, using the behaviour the guard exists to catch.**
|
|
15006
|
+
*
|
|
15007
|
+
* A second defect sat beside it: a claim naming a position our tree has not reached was refused
|
|
15008
|
+
* outright, and a HELD own leaf is exactly that — `placeOwnLeaf` returns `{placed: false}` when
|
|
15009
|
+
* the relay hands us a position ahead of our tail, so the leaf is not in the tree while the
|
|
15010
|
+
* counterparty has already received it and is acknowledging it. We refused their reply for a
|
|
15011
|
+
* transient gap on our own machine.
|
|
15012
|
+
*
|
|
15013
|
+
* **So the question asked is now about CONTENT, not about an index.** Is the hash they name
|
|
15014
|
+
* something this side has — placed in the tree, or held pending a gap? That cannot be switched
|
|
15015
|
+
* off by divergence (it consults no positions), it cannot false-refuse a held leaf, and it still
|
|
15016
|
+
* refuses a hash we have never held, which is the falsehood the check exists to catch.
|
|
15017
|
+
*
|
|
15018
|
+
* The POSITION is then used only to make the check STRONGER where it is safe to: on a session
|
|
15019
|
+
* whose indices still mean relay positions, the hash must sit exactly where they say it does.
|
|
15020
|
+
* Divergence loses that strengthening and keeps the membership test, rather than losing both.
|
|
15021
|
+
*/
|
|
15022
|
+
const tree = this.getSessionTree(agentName, sessionId);
|
|
15023
|
+
const ackHex = Buffer.from(fields.lastSeenHash).toString("hex");
|
|
15024
|
+
const heldHere = this.#heldContent.get(this.#k(agentName, sessionId));
|
|
15025
|
+
const held = heldHere ? [...heldHere.values()].some((e) => e.contentHashHex === ackHex) : false;
|
|
15026
|
+
if (tree.indexOfHash(ackHex) === -1 && !held) {
|
|
15027
|
+
return { verdict: "unusable", reason: AUTHORSHIP_ACK_HASH_UNKNOWN };
|
|
15028
|
+
}
|
|
15029
|
+
/**
|
|
15030
|
+
* THE POSITIONAL STRENGTHENING. Skipped — with a WARN, never silently — when this side's indices
|
|
15031
|
+
* no longer mean relay positions, or when the leaf at that position is still held. Neither is
|
|
15032
|
+
* a pass: the membership test above has already run and refused anything we do not hold.
|
|
15033
|
+
*/
|
|
15034
|
+
const atPosition = tree.hashAt(fields.lastSeenSeq - 1);
|
|
15035
|
+
if (this.isSessionDiverged(agentName, sessionId) || atPosition === null) {
|
|
15036
|
+
this.#logger.warn("session.content.ack_hash.position_unverifiable", {
|
|
15037
|
+
agentName, sessionId, lastSeenSeq: fields.lastSeenSeq,
|
|
15038
|
+
diverged: this.isSessionDiverged(agentName, sessionId),
|
|
15039
|
+
impact: "the acknowledged content IS in this side's record, so the claim was accepted — but its " +
|
|
15040
|
+
"POSITION was not checked, because this session's local positions no longer line up with " +
|
|
15041
|
+
"the relay's, or the leaf at that position has not been placed yet.",
|
|
15042
|
+
});
|
|
15043
|
+
return undefined;
|
|
15044
|
+
}
|
|
15045
|
+
return ackHex === atPosition
|
|
15046
|
+
? undefined
|
|
15047
|
+
: { verdict: "unusable", reason: AUTHORSHIP_ACK_HASH_MISMATCH };
|
|
15048
|
+
}
|
|
14391
15049
|
/**
|
|
14392
15050
|
* `DOD-M15-AUTHORSHIP-ABSENT-1` — the refusal an inbound frame gets when its authorship cannot be
|
|
14393
15051
|
* established. NOT a freeze: see `AuthorshipVerdict` for why those are different facts.
|
|
@@ -14412,7 +15070,29 @@ export class SessionNodeManager {
|
|
|
14412
15070
|
? "a message arrived carrying no proof of who wrote it, so it was NOT ingested, NOT shown and NOT attributed to anyone. Every message in this conversation has to be provable to whoever reads its receipt later, and this one could not be."
|
|
14413
15071
|
: reason === "authorship_wrong_conversation"
|
|
14414
15072
|
? "a message arrived carrying a VALID signature by this conversation's counterparty — made for a DIFFERENT conversation. The same message, or an old one of theirs, was presented here. It was NOT ingested, NOT shown and NOT added to this conversation's record."
|
|
14415
|
-
|
|
15073
|
+
/**
|
|
15074
|
+
* 033-ACKEMIT. Says what was OBSERVED — the two records disagree about what was said —
|
|
15075
|
+
* and stops there. It does NOT say the counterparty is lying: the same signal is what a
|
|
15076
|
+
* genuine fault on their side looks like, and naming a conclusion the code did not reach
|
|
15077
|
+
* is the error-fidelity defect this milestone was opened for.
|
|
15078
|
+
*/
|
|
15079
|
+
/**
|
|
15080
|
+
* ⚠️ THREE CAUSES, THREE SENTENCES — review F5. They shared one, and it described none of
|
|
15081
|
+
* them properly: an ABSENT acknowledgement has no part that "does not match", because it
|
|
15082
|
+
* has no part at all.
|
|
15083
|
+
*
|
|
15084
|
+
* All three say what was OBSERVED and stop there. None says the counterparty is lying:
|
|
15085
|
+
* the same signal is what a genuine fault on their side looks like, and naming a
|
|
15086
|
+
* conclusion the code did not reach is the error-fidelity defect this milestone exists
|
|
15087
|
+
* for.
|
|
15088
|
+
*/
|
|
15089
|
+
: reason === AUTHORSHIP_ACK_HASH_ABSENT
|
|
15090
|
+
? "a message arrived that is genuinely from your counterparty and genuinely about this conversation — and it does not say which of your messages they had received. Their build is older than yours: a message has to say what it is answering, so that nobody can later leave your last message out of the receipt. It was NOT ingested and NOT shown."
|
|
15091
|
+
: reason === AUTHORSHIP_ACK_HASH_MISMATCH
|
|
15092
|
+
? "a message arrived that is genuinely from your counterparty — and it names a DIFFERENT message of yours in the position where your own record holds one. Both sides agree the message exists; you disagree about which one sits there. It was NOT ingested and NOT shown."
|
|
15093
|
+
: reason === AUTHORSHIP_ACK_HASH_UNKNOWN
|
|
15094
|
+
? "a message arrived that is genuinely from your counterparty — and it says they received something from you that this side has no record of ever holding. It was NOT ingested and NOT shown. This is the check that stops someone quietly rewriting what was said before the receipt is made."
|
|
15095
|
+
: "a message arrived whose proof of authorship could not be checked against it — it was unreadable, or it was signed over different content. It was NOT ingested, NOT shown and NOT attributed to anyone.";
|
|
14416
15096
|
/**
|
|
14417
15097
|
* ⚠️ THE VERB IS THE COUNTERPARTY'S, AND THE GUIDANCE SAYS SO. The reader is the RECEIVING
|
|
14418
15098
|
* operator, and there is nothing on their machine to change — the missing signature is produced
|
|
@@ -14454,16 +15134,44 @@ export class SessionNodeManager {
|
|
|
14454
15134
|
* substring match on a sentence cannot see that the sentence lost its head — so the assertion
|
|
14455
15135
|
* below pins what it OPENS with, which a truncation cannot survive.
|
|
14456
15136
|
*/
|
|
14457
|
-
|
|
14458
|
-
|
|
14459
|
-
|
|
14460
|
-
|
|
14461
|
-
|
|
14462
|
-
|
|
14463
|
-
|
|
14464
|
-
|
|
14465
|
-
|
|
14466
|
-
|
|
15137
|
+
/**
|
|
15138
|
+
* 033-ACKEMIT — TWO PATHS, AND THE FIRST IS THE ONE THAT ACTUALLY HAPPENS.
|
|
15139
|
+
*
|
|
15140
|
+
* Capped at two (Invariant 4): an affordance list that enumerates everything is a menu. The
|
|
15141
|
+
* verb is the counterparty's in both cases — there is nothing to change on this machine — so
|
|
15142
|
+
* it names the one move that fixes the likely cause and the one that settles the other.
|
|
15143
|
+
*/
|
|
15144
|
+
/**
|
|
15145
|
+
* ⚠️ AND THREE REMEDIES, because the shared one was WRONG for two of the three. It told the
|
|
15146
|
+
* reader their counterparty's build was probably old — which is impossible for a claim that
|
|
15147
|
+
* carries an acknowledgement, since only a newer build sends one — and then told them to
|
|
15148
|
+
* abandon the conversation. Two is the cap on each (Invariant 4); the verb is the
|
|
15149
|
+
* counterparty's in every case, because there is nothing to change on this machine.
|
|
15150
|
+
*/
|
|
15151
|
+
: reason === AUTHORSHIP_ACK_HASH_ABSENT
|
|
15152
|
+
? "STOPPED ON PURPOSE, and this is NOT about their signature — it verified. " +
|
|
15153
|
+
(this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
|
|
15154
|
+
" Their build is older than yours and does not say what it has received. Ask which version " +
|
|
15155
|
+
"they are running and tell them to upgrade — only they can fix it, and this will keep " +
|
|
15156
|
+
"happening until they do."
|
|
15157
|
+
: reason === AUTHORSHIP_ACK_HASH_MISMATCH || reason === AUTHORSHIP_ACK_HASH_UNKNOWN
|
|
15158
|
+
? "STOPPED ON PURPOSE, and this is NOT about their signature or their version — both are " +
|
|
15159
|
+
"fine. " +
|
|
15160
|
+
(this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
|
|
15161
|
+
" Your record of this conversation and theirs have stopped agreeing about what you sent " +
|
|
15162
|
+
"them. Confirm with them OUT OF BAND what they actually received from you. If it matches " +
|
|
15163
|
+
"what you sent, this was a fault and a new session will clear it; if it does not, do not " +
|
|
15164
|
+
"carry on in this one."
|
|
15165
|
+
: "STOPPED ON PURPOSE. This copy was refused and the message itself was not kept. " +
|
|
15166
|
+
// Review F2: chosen from what THIS machine can do, not asserted. An agent with no identity
|
|
15167
|
+
// key cannot open a mailbox copy either, and telling them to wait for one would be the same
|
|
15168
|
+
// false promise on a different refusal.
|
|
15169
|
+
(this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
|
|
15170
|
+
" Almost always their CELLO build is older than this one: a build from before message signing " +
|
|
15171
|
+
"does not attach a signature at all. Ask which version they are running, and tell them to " +
|
|
15172
|
+
"upgrade — this will keep happening until they do, and only they can fix it. If they are on " +
|
|
15173
|
+
"the SAME version as you, that explanation does not hold: confirm with them OUT OF BAND " +
|
|
15174
|
+
"before opening another session.";
|
|
14467
15175
|
this.#logger.error("session.content.refused", {
|
|
14468
15176
|
agentName, sessionId, correlationId, reason, ...detail, impact, guidance,
|
|
14469
15177
|
});
|
|
@@ -14546,7 +15254,29 @@ export class SessionNodeManager {
|
|
|
14546
15254
|
*/
|
|
14547
15255
|
const auth = this.#verifyAuthorshipClaim(agentName, sessionId, structure1Cbor, s2Sig, contentHash);
|
|
14548
15256
|
if (auth.verdict === "unusable") {
|
|
14549
|
-
|
|
15257
|
+
/**
|
|
15258
|
+
* ⚠️ **THE ACK CAUSES REACH THIS PATH TOO, AND THEY ARE NOT A DECODER PROBLEM** — review F7.
|
|
15259
|
+
*
|
|
15260
|
+
* `#verifyAuthorshipClaim` has two callers. This one is reached from park RECOVERY, where
|
|
15261
|
+
* it is the only authorship check that runs — so 033-ACKEMIT's acknowledgement causes
|
|
15262
|
+
* started arriving here and fell into the generic `else` below, which logs
|
|
15263
|
+
* `…ordering.malformed` and buries the cause in `structure1Reason`. That event name sends
|
|
15264
|
+
* the next reader to audit a decoder for a record that decoded perfectly.
|
|
15265
|
+
*
|
|
15266
|
+
* SOFT, like every other `unusable` on this path, and deliberately: position may always
|
|
15267
|
+
* fall back to the witness stream, and a recovered parked message is authenticated by the
|
|
15268
|
+
* ENVELOPE's own signature rather than by this. What changes is that the log says which
|
|
15269
|
+
* thing disagreed.
|
|
15270
|
+
*/
|
|
15271
|
+
if (ACK_HASH_REASONS.has(auth.reason)) {
|
|
15272
|
+
this.#logger.warn("session.content.ordering.ack_hash_unverified", {
|
|
15273
|
+
sessionId, correlationId, reason: auth.reason,
|
|
15274
|
+
impact: "a recovered message's acknowledgement of what its sender had received does not " +
|
|
15275
|
+
"reconcile with this side's record, so no canonical POSITION was taken from it. The " +
|
|
15276
|
+
"message itself is authenticated by its park envelope and is not refused here.",
|
|
15277
|
+
});
|
|
15278
|
+
}
|
|
15279
|
+
else if (auth.reason === AUTHORSHIP_CONTENT_HASH_MISMATCH) {
|
|
14550
15280
|
// SOFT: the record does not describe this content. Nothing is proven about the signer's
|
|
14551
15281
|
// identity — only that this record and these bytes do not belong together.
|
|
14552
15282
|
this.#logger.warn("session.content.ordering.hash_mismatch", { sessionId, correlationId });
|
|
@@ -14970,7 +15700,28 @@ export class SessionNodeManager {
|
|
|
14970
15700
|
// A replayed claim gets its own name on BOTH surfaces, not just in the log context: it is
|
|
14971
15701
|
// the one `unusable` cause that may be adversarial, and it is the one the operator can act
|
|
14972
15702
|
// on. The others are a peer whose build or bytes we could not read.
|
|
14973
|
-
|
|
15703
|
+
/**
|
|
15704
|
+
* 033-ACKEMIT — AND THE THREE ACKNOWLEDGEMENT CAUSES GET THEIR OWN SURFACE REASON, for the
|
|
15705
|
+
* same argument that gave the replay one: `authorship_proof_unusable` tells the operator the
|
|
15706
|
+
* proof was "unreadable, or signed over different content", and for these it is neither.
|
|
15707
|
+
* The proof is perfect; what it CLAIMS TO HAVE SEEN is wrong. Filing them under the generic
|
|
15708
|
+
* name would send someone to audit a decoder, and would spend the operator's attention
|
|
15709
|
+
* asking their counterparty about a version number that is not the question.
|
|
15710
|
+
*/
|
|
15711
|
+
this.#refuseUnprovenAuthorship(agentName, sessionId, authorship.reason === AUTHORSHIP_SESSION_MISMATCH
|
|
15712
|
+
? "authorship_wrong_conversation"
|
|
15713
|
+
: ACK_HASH_REASONS.has(authorship.reason)
|
|
15714
|
+
/**
|
|
15715
|
+
* ⚠️ THE SPECIFIC CAUSE, NOT THE CLASS — review F5, and the diff's own comment on
|
|
15716
|
+
* `ACK_HASH_REASONS` had already said why: "the operator's next move differs for
|
|
15717
|
+
* each." It then collapsed all three into ONE surface reason carrying ONE sentence,
|
|
15718
|
+
* so the three names survived only in a log field nobody reads. For an absent
|
|
15719
|
+
* acknowledgement the shared impact was flatly false — there is no part that "does
|
|
15720
|
+
* not match", because there is no part — and for the other two the shared guidance
|
|
15721
|
+
* sent the reader to ask about a build version that cannot be the cause.
|
|
15722
|
+
*/
|
|
15723
|
+
? authorship.reason
|
|
15724
|
+
: "authorship_proof_unusable", contentHash, { detail: authorship.reason }, correlationId);
|
|
14974
15725
|
return;
|
|
14975
15726
|
}
|
|
14976
15727
|
if (authorship.verdict === "verified") {
|
|
@@ -15025,6 +15776,60 @@ export class SessionNodeManager {
|
|
|
15025
15776
|
// acknowledged `persisted` — the sender's TTF→park backstop then guarantees the
|
|
15026
15777
|
// missing-earlier message is fetchable, and dedup absorbs the redundant copy.
|
|
15027
15778
|
if (ingest.ok && !ingest.held) {
|
|
15779
|
+
/**
|
|
15780
|
+
* 033-ACKEMIT review F1 — ACKNOWLEDGE WHAT ARRIVED, HERE, not when the relay gets round to
|
|
15781
|
+
* delivering its copy back to us.
|
|
15782
|
+
*
|
|
15783
|
+
* Placed after a successful, non-held ingest deliberately: a HELD frame is not yet a durable
|
|
15784
|
+
* leaf and is not acknowledged `persisted` either, so claiming to have seen it would put a
|
|
15785
|
+
* position in our signed claim that our own record does not yet hold.
|
|
15786
|
+
*
|
|
15787
|
+
* `framedSeq` is the relay's canonical position taken from the sender's own signed ordering
|
|
15788
|
+
* record and verified before it got here. When it is absent the message arrived with no
|
|
15789
|
+
* ordering record — the withheld-submit case — and there is no position to acknowledge,
|
|
15790
|
+
* whatever we hold of the content. That limit is structural to a (position, content) pair
|
|
15791
|
+
* and it is what the carried-leaf follow-on closes.
|
|
15792
|
+
*/
|
|
15793
|
+
if (framedSeq !== null) {
|
|
15794
|
+
this.#noteAcknowledgeable(agentName, sessionId, framedSeq, contentHash);
|
|
15795
|
+
}
|
|
15796
|
+
else {
|
|
15797
|
+
/**
|
|
15798
|
+
* ─── WITNESS WHAT THEY DID NOT — 034-CARRYLEAF, and this is the line that closes
|
|
15799
|
+
* `DOD-M15-WITHHOLD-SEAL-1` ────────────────────────────────────────────────────────────
|
|
15800
|
+
*
|
|
15801
|
+
* No ordering record means the sender never asked the relay to witness this message. Two
|
|
15802
|
+
* things look identical from here: their relay was briefly unreachable, or they are
|
|
15803
|
+
* withholding it on purpose so it cannot appear in the receipt. **We do not need to tell
|
|
15804
|
+
* those apart, and that is the point** — the same action repairs both, and it costs the
|
|
15805
|
+
* honest case nothing.
|
|
15806
|
+
*
|
|
15807
|
+
* We hold their signature over their own bytes. So we hand it to the relay ourselves.
|
|
15808
|
+
*/
|
|
15809
|
+
/**
|
|
15810
|
+
* ⚠️ **THE KIND COMES OFF THE FRAME, AND WITHOUT IT WE DECLINE TO WITNESS — review F5.**
|
|
15811
|
+
*
|
|
15812
|
+
* A leaf kind selects a HASH DOMAIN, and documents and rejection envelopes ride this same
|
|
15813
|
+
* frame. Hardcoding `msg` meant a document witnessed on its author's behalf entered the
|
|
15814
|
+
* relay's canonical log described as something it is not. A peer too old to send the field
|
|
15815
|
+
* is left alone rather than guessed at: witnessing their leaf under the wrong domain would
|
|
15816
|
+
* be a worse outcome than not witnessing it, because it puts a wrong statement in the
|
|
15817
|
+
* record instead of leaving a gap the seal can name.
|
|
15818
|
+
*/
|
|
15819
|
+
const framedKind = frame["leaf_kind"];
|
|
15820
|
+
if (typeof framedKind === "number") {
|
|
15821
|
+
void this.#witnessReceivedLeaf(agentName, sessionId, contentHash, s1Cbor, senderSig, framedKind, correlationId);
|
|
15822
|
+
}
|
|
15823
|
+
else {
|
|
15824
|
+
this.#logger.info("session.content.witness_received.kind_unknown", {
|
|
15825
|
+
agentName, sessionId, correlationId,
|
|
15826
|
+
impact: "a message arrived that its sender never had witnessed, and the frame did not say " +
|
|
15827
|
+
"which leaf domain it belongs to — their build predates the field. It was NOT " +
|
|
15828
|
+
"witnessed on their behalf, because witnessing it under a guessed domain would put " +
|
|
15829
|
+
"a wrong statement in the record rather than leave a gap the seal can name.",
|
|
15830
|
+
});
|
|
15831
|
+
}
|
|
15832
|
+
}
|
|
15028
15833
|
void this.#sendDeliveryAck(agentName, sessionId, contentHash, correlationId);
|
|
15029
15834
|
}
|
|
15030
15835
|
}
|
|
@@ -15333,17 +16138,27 @@ export class SessionNodeManager {
|
|
|
15333
16138
|
/**
|
|
15334
16139
|
* DOD-M12B-SESSION-SEED-1 (review F8): drop it zeroed, like every other seed.
|
|
15335
16140
|
*
|
|
15336
|
-
* (review F7, DECIDED AGAINST — deliberately NOT reusing this seed for the
|
|
16141
|
+
* (review F7, STILL DECIDED AGAINST — deliberately NOT reusing this seed for the
|
|
16142
|
+
* replacement — but its stated blocker is GONE and the reason has changed. Restated rather
|
|
16143
|
+
* than reworded, because a decision whose premise has been reversed is a decision nobody
|
|
16144
|
+
* has actually made.)
|
|
16145
|
+
*
|
|
15337
16146
|
* Reuse is attractive: this receiver's peer id may already be inside a `session_offer_accept`
|
|
15338
16147
|
* the counterparty is acting on, and a rebuild in that window is the documented "we record
|
|
15339
|
-
* an identity that no longer exists… every send in this direction parks forever" defect.
|
|
15340
|
-
*
|
|
15341
|
-
*
|
|
15342
|
-
*
|
|
15343
|
-
*
|
|
15344
|
-
*
|
|
15345
|
-
*
|
|
15346
|
-
*
|
|
16148
|
+
* an identity that no longer exists… every send in this direction parks forever" defect.
|
|
16149
|
+
*
|
|
16150
|
+
* The old blocker was that a preserved identity would reach the candidate loop, whose
|
|
16151
|
+
* rejected candidates were stopped WITHOUT awaiting `start()`, putting two live nodes on one
|
|
16152
|
+
* advertised peer id. **032-RELAYSPREAD already crossed that line**: the walk now runs one
|
|
16153
|
+
* shared seed through every candidate, with a settlement-chained teardown, and it is safe
|
|
16154
|
+
* there because the receiver's gater admits nobody inbound.
|
|
16155
|
+
*
|
|
16156
|
+
* What still stops reuse HERE is different and is about the OLD node, not the new one. This
|
|
16157
|
+
* rebuild path awaits `sr.node.stop()`, but a stop can hang on a stuck libp2p teardown, and
|
|
16158
|
+
* handing the replacement the same identity before the previous receiver is provably dead
|
|
16159
|
+
* would put two nodes on a peer id a COUNTERPARTY has been told to dial — which is not the
|
|
16160
|
+
* candidate case at all: that node has a content handler and can be promoted. Doing it
|
|
16161
|
+
* safely needs a bounded, verified teardown first. Still follow-on work.
|
|
15347
16162
|
*/
|
|
15348
16163
|
sr.seed.fill(0);
|
|
15349
16164
|
try {
|
|
@@ -15555,7 +16370,19 @@ export class SessionNodeManager {
|
|
|
15555
16370
|
// "No relay would grant" and "there was no relay to ask" are different facts and lead to
|
|
15556
16371
|
// different places — the first at relay capacity, the second at this agent's directory
|
|
15557
16372
|
// connection. Without this they are the same sentence.
|
|
15558
|
-
|
|
16373
|
+
//
|
|
16374
|
+
// 032-RELAYSPREAD: this was also called `reservationsRequested` — the same mis-naming as
|
|
16375
|
+
// the reachability events, in its worst form, because here the value is a BOOLEAN under a
|
|
16376
|
+
// name that reads as a count. It is NOT `relaysOffered`: that field counts the merged
|
|
16377
|
+
// candidate list the walk actually asks (directory pool + persisted endpoints, minus
|
|
16378
|
+
// quarantine), and this reads the directory pool alone. Two populations must not share
|
|
16379
|
+
// one field name, so this one is named for what it measures.
|
|
16380
|
+
hadRelayToAsk: (this.#directoryRelayEndpoints.get(agentName)?.length ?? 0) > 0,
|
|
16381
|
+
// …and HOW MANY the walk actually asks, so this event stands on its own instead of
|
|
16382
|
+
// needing the last reachability line to be read beside it. Same population and same
|
|
16383
|
+
// meaning as `relaysOffered` everywhere else: the merged, quarantine-filtered candidate
|
|
16384
|
+
// list.
|
|
16385
|
+
relaysOffered: this.#reservationCircuitAddrs(agentName).addrs.length,
|
|
15559
16386
|
impact: "no relay would grant this agent a circuit reservation, so anyone behind NAT cannot reach or dial it — inbound sessions will only arrive from peers that can connect directly, and everything else falls back to the relay's store-and-forward",
|
|
15560
16387
|
});
|
|
15561
16388
|
}
|
|
@@ -15600,11 +16427,27 @@ export class SessionNodeManager {
|
|
|
15600
16427
|
// BOUNDED, never on this 30-second grid. A reservation is scarce: the relay holds it for its
|
|
15601
16428
|
// full TTL even after the client disconnects, and churning attempts across a fleet is how a
|
|
15602
16429
|
// relay is exhausted (`#startReceiverNode` records that hazard).
|
|
15603
|
-
if (
|
|
15604
|
-
|
|
15605
|
-
|
|
16430
|
+
if (sr.relayPeerIds.length === 0) {
|
|
16431
|
+
// …unless one has arrived since. Review F4, same class as the recompute below: the
|
|
16432
|
+
// slow-start path installs a receiver before every circuit has bound, so "held nothing at
|
|
16433
|
+
// install" is not the same fact as "holds nothing now". Adopting it here is what stops the
|
|
16434
|
+
// retry ladder rebuilding a receiver that is already reachable.
|
|
16435
|
+
const arrived = heldRelayIdsOf(sr.node)
|
|
16436
|
+
.filter((id) => sr.node.getConnections().some((c) => c.peerId === id && c.status === "open"));
|
|
16437
|
+
if (arrived.length === 0) {
|
|
16438
|
+
this.#retryReservationIfDue(agentName);
|
|
16439
|
+
continue;
|
|
16440
|
+
}
|
|
16441
|
+
sr.relayPeerIds = arrived;
|
|
16442
|
+
sr.gater.setReservedRelayPeers(arrived);
|
|
16443
|
+
this.#logger.info("session.standing_receiver.reservation.gained", {
|
|
16444
|
+
agentName,
|
|
16445
|
+
relayPeerIds: arrived,
|
|
16446
|
+
reservationsHeld: arrived.length,
|
|
16447
|
+
});
|
|
15606
16448
|
}
|
|
15607
|
-
// It has one — any earlier retry budget, and the reason the last attempt failed, are
|
|
16449
|
+
// It has at least one — any earlier retry budget, and the reason the last attempt failed, are
|
|
16450
|
+
// stale.
|
|
15608
16451
|
this.#srReservationRetry.delete(agentName);
|
|
15609
16452
|
this.#srLastRejectionReason.delete(agentName);
|
|
15610
16453
|
// Watch the CONNECTION to the relay, not the circuit address.
|
|
@@ -15622,24 +16465,106 @@ export class SessionNodeManager {
|
|
|
15622
16465
|
// investigation turned on. Without the status check a registered corpse reads as "still
|
|
15623
16466
|
// connected", the rebuild never fires, and the agent silently stops being reachable while
|
|
15624
16467
|
// this loop reports it healthy. The comment above claimed liveness; only this tests it.
|
|
15625
|
-
|
|
15626
|
-
|
|
15627
|
-
|
|
15628
|
-
|
|
16468
|
+
// 032-RELAYSPREAD — PER RELAY, and the health question is now a COUNT.
|
|
16469
|
+
//
|
|
16470
|
+
// This used to evaluate one peer id and rebuild the entire standing receiver when it went
|
|
16471
|
+
// false. With a pool of one that was the only thing it could do; with a pool of three it is
|
|
16472
|
+
// the churn engine — every relay is another watchdog subject, and a full rebuild per loss
|
|
16473
|
+
// multiplies the 30-second grid by the size of the pool while throwing away reservations that
|
|
16474
|
+
// are perfectly healthy.
|
|
16475
|
+
/**
|
|
16476
|
+
* RECOMPUTED FROM THE NODE, never filtered down from the stored list. Review F4: filtering
|
|
16477
|
+
* `sr.relayPeerIds` makes it SHRINK-ONLY, and a list that can only shrink cannot see a
|
|
16478
|
+
* circuit arrive. Three things went wrong with that, and the first one happens routinely:
|
|
16479
|
+
* - the slow-start path installs the receiver before every circuit has bound, so a relay
|
|
16480
|
+
* that binds four seconds later was invisible to this watchdog and absent from the
|
|
16481
|
+
* gater's carve-out set FOREVER — its AutoNAT probe reply refused by our own gate;
|
|
16482
|
+
* - shrinking to zero then rebuilt a receiver that was announcing live circuits, which is
|
|
16483
|
+
* the exact defect this unit is against;
|
|
16484
|
+
* - anything that ever restores a circuit could not be counted.
|
|
16485
|
+
* Reading the node's own addresses costs the same and has none of that.
|
|
16486
|
+
*/
|
|
16487
|
+
const open = sr.node.getConnections().filter((c) => c.status === "open").map((c) => c.peerId);
|
|
16488
|
+
const stillHeld = heldRelayIdsOf(sr.node).filter((id) => open.includes(id));
|
|
16489
|
+
const lost = sr.relayPeerIds.filter((id) => !stillHeld.includes(id));
|
|
16490
|
+
const gained = stillHeld.filter((id) => !sr.relayPeerIds.includes(id));
|
|
16491
|
+
sr.relayPeerIds = stillHeld;
|
|
16492
|
+
if (gained.length > 0) {
|
|
16493
|
+
// A circuit this receiver did not have at install. Said out loud because it is the visible
|
|
16494
|
+
// half of the slow-start case, and because it is the moment that relay earns its inbound
|
|
16495
|
+
// carve-out — a silent widening of the gate is not something to do without a line.
|
|
16496
|
+
this.#logger.info("session.standing_receiver.reservation.gained", {
|
|
16497
|
+
agentName,
|
|
16498
|
+
relayPeerIds: gained,
|
|
16499
|
+
reservationsHeld: stillHeld.length,
|
|
16500
|
+
});
|
|
16501
|
+
}
|
|
16502
|
+
if (lost.length === 0) {
|
|
16503
|
+
// Nothing lost. The gater still gets the current set, because `gained` may have widened it.
|
|
16504
|
+
if (gained.length > 0)
|
|
16505
|
+
sr.gater.setReservedRelayPeers(stillHeld);
|
|
15629
16506
|
continue;
|
|
15630
|
-
|
|
15631
|
-
//
|
|
15632
|
-
//
|
|
15633
|
-
|
|
15634
|
-
|
|
15635
|
-
|
|
15636
|
-
|
|
15637
|
-
|
|
15638
|
-
|
|
15639
|
-
|
|
15640
|
-
|
|
15641
|
-
|
|
15642
|
-
|
|
16507
|
+
}
|
|
16508
|
+
// REVOKE FIRST. A relay whose reservation is gone must lose its inbound carve-out in the same
|
|
16509
|
+
// breath as the loss is noticed, or the gater's bound quietly becomes "granted one once".
|
|
16510
|
+
sr.gater.setReservedRelayPeers(stillHeld);
|
|
16511
|
+
for (const relayPeerId of lost) {
|
|
16512
|
+
// DOD-RELAY-KEEPALIVE-1 (review F4): carry the CAUSE, not just the exit point.
|
|
16513
|
+
// `relay_connection_gone` says where this was noticed — a poll of getConnections() — by
|
|
16514
|
+
// which time the abort reason that actually killed the link is long discarded. The relay
|
|
16515
|
+
// client for this (agent, relay) pair kept the error that ended its reader; that is the
|
|
16516
|
+
// nearest thing to an upstream cause available here, and its absence is how 2,061 of these
|
|
16517
|
+
// went untraced.
|
|
16518
|
+
const upstreamReason = this.#relayClients.get(`${agentName}::${relayPeerId}`)?.getLastReaderError();
|
|
16519
|
+
this.#logger.warn("session.standing_receiver.reservation.lost", {
|
|
16520
|
+
agentName,
|
|
16521
|
+
relayPeerId,
|
|
16522
|
+
reason: open.includes(relayPeerId) ? "circuit_address_vanished" : "relay_connection_gone",
|
|
16523
|
+
...(upstreamReason ? { upstreamReason } : {}),
|
|
16524
|
+
reservationsHeld: stillHeld.length,
|
|
16525
|
+
// The line an operator reads, and the two cases are not the same event at all.
|
|
16526
|
+
impact: stillHeld.length > 0
|
|
16527
|
+
? "this agent still holds " + stillHeld.length + " other circuit reservation(s), so it "
|
|
16528
|
+
+ "stays dialable from behind NAT and the receiver is NOT rebuilt. Losing one relay "
|
|
16529
|
+
+ "costs this agent nothing it can feel."
|
|
16530
|
+
: "this agent now holds NO circuit reservation, so nobody behind a home router can "
|
|
16531
|
+
+ "reach it. The receiver is being rebuilt against the rest of the pool.",
|
|
16532
|
+
});
|
|
16533
|
+
}
|
|
16534
|
+
if (stillHeld.length === 0) {
|
|
16535
|
+
// ZERO HELD IS STILL THE LOUD, STRUCTURAL CASE — the agent is unreachable behind NAT and
|
|
16536
|
+
// only a new node can take a new reservation, because a circuit listener is fixed at node
|
|
16537
|
+
// creation.
|
|
16538
|
+
void this.#rebuildStandingReceiver(agentName);
|
|
16539
|
+
continue;
|
|
16540
|
+
}
|
|
16541
|
+
/**
|
|
16542
|
+
* STILL REACHABLE, SO THE RECEIVER STANDS, AND NOTHING ELSE HAPPENS HERE. That second half is
|
|
16543
|
+
* the part worth reading, because the obvious next line is wrong twice over.
|
|
16544
|
+
*
|
|
16545
|
+
* **A LOST CONFIGURED CIRCUIT CANNOT BE RETAKEN BY THIS NODE.** Read out of
|
|
16546
|
+
* `@libp2p/circuit-relay-v2@4.2.5`, not assumed: for an explicit relay address
|
|
16547
|
+
* `transport/listener.js#listen()` is a ONE-SHOT — it reserves once and nothing calls it
|
|
16548
|
+
* again; `reservation-store.js#removeReservation()` clears the refresh timeout and deletes
|
|
16549
|
+
* the entry; and the listener's `_onAddRelayPeer` returns early for `type === 'configured'`,
|
|
16550
|
+
* so even a later reservation would not be announced. A circuit listener is fixed at node
|
|
16551
|
+
* creation, and the only thing that takes a new one is a NEW NODE — which is exactly the
|
|
16552
|
+
* rebuild this branch exists to refuse.
|
|
16553
|
+
*
|
|
16554
|
+
* **AND RE-PROVING TO THE LOST RELAY WOULD REBUILD THE RECEIVER ANYWAY.** Review F3: an
|
|
16555
|
+
* earlier version called `#authenticateStandingReceiver` here to "remove the relay-side
|
|
16556
|
+
* reason for the revocation". That function ends with `if (refusal?.tryAnotherRelay) { …
|
|
16557
|
+
* void this.#rebuildStandingReceiver(agentName); }` — and a dead or misconfigured relay is
|
|
16558
|
+
* precisely the one that answers that way. So the common case was: lose relay A while
|
|
16559
|
+
* holding B, decline to rebuild, prove to A, A refuses, rebuild the whole receiver and throw
|
|
16560
|
+
* B's healthy reservation away. The churn engine, re-entered through the back door.
|
|
16561
|
+
*
|
|
16562
|
+
* **THE BOUND, STATED PLAINLY BECAUSE IT IS A REAL SHORTFALL AGAINST THE DoD:** a lost
|
|
16563
|
+
* circuit is gone until the receiver is next rebuilt for another reason. What the agent buys
|
|
16564
|
+
* is that it never STOPS BEING REACHABLE while that is true — the surviving relays carry it,
|
|
16565
|
+
* the loss is named in the log with its cause, and the lost relay's inbound carve-out is
|
|
16566
|
+
* revoked above. That is availability, not restoration in place.
|
|
16567
|
+
*/
|
|
15643
16568
|
}
|
|
15644
16569
|
}
|
|
15645
16570
|
/**
|
|
@@ -15838,20 +16763,42 @@ export class SessionNodeManager {
|
|
|
15838
16763
|
}
|
|
15839
16764
|
}
|
|
15840
16765
|
async #startReceiverNode(agentName, sessionId, gater, candidateCircuitAddrs, correlationId) {
|
|
16766
|
+
/**
|
|
16767
|
+
* 032-RELAYSPREAD — **ONE SEED FOR THE RECEIVER, REUSED ACROSS RELAYS**, replacing
|
|
16768
|
+
* DOD-M12B-SESSION-SEED-1's seed-per-candidate.
|
|
16769
|
+
*
|
|
16770
|
+
* The agent is ONE identity and must be dialable at ONE peer id through any of its circuits, so
|
|
16771
|
+
* every reservation this walk collects has to belong to the same key. A seed per relay would
|
|
16772
|
+
* give the agent a different peer id down each circuit — N half-agents, none of them the one
|
|
16773
|
+
* the counterparty was told to dial.
|
|
16774
|
+
*
|
|
16775
|
+
* ⚠️ THE RULE THIS REPLACES WAS RIGHT ABOUT ITS OWN CASE, so here is what changed and what did
|
|
16776
|
+
* not. Its hazard is real and survives: a rejected candidate is torn down while its `start()`
|
|
16777
|
+
* may still be in flight, so two nodes can briefly be live on this peer id. Two things bound it
|
|
16778
|
+
* now, and neither existed when that rule was written:
|
|
16779
|
+
* - **THE ONE THAT CARRIES THE WEIGHT: DOD-M15-ASSIGN-1** made a standing receiver's gater
|
|
16780
|
+
* admit NOBODY inbound until a session offer names the dialer. The old rule's stated danger
|
|
16781
|
+
* — "sharing this gater, so it admits dials … an open endpoint under our advertised id" —
|
|
16782
|
+
* is not true of this gater any more. `#startReceiverNode` has exactly one caller and it
|
|
16783
|
+
* constructs that gater with `allowedPeerId: null` and an empty reserved set, so an
|
|
16784
|
+
* overlapping candidate is an endpoint that refuses everyone.
|
|
16785
|
+
* - the teardown is chained onto the candidate's OWN start promise (the `#buildRevivedNode`
|
|
16786
|
+
* pattern, verified against libp2p 3.3.2: `stop()` returns immediately unless the status is
|
|
16787
|
+
* `started`, and through the whole timeout window it is `starting`, so the old unawaited
|
|
16788
|
+
* `stop()` stopped nothing). ⚠️ This bounds the LEAK, not the OVERLAP — a timed-out
|
|
16789
|
+
* candidate is not awaited and the walk moves straight to the next one on the same seed, so
|
|
16790
|
+
* overlap is the normal shape of that case, not a remote possibility. It guarantees the
|
|
16791
|
+
* loser dies, and nothing more.
|
|
16792
|
+
* `#buildRevivedNode` already runs a fixed identity through this same walk for the same reason.
|
|
16793
|
+
*/
|
|
16794
|
+
const receiverSeed = randomBytes(32);
|
|
16795
|
+
/** Circuit addresses whose relay ACTUALLY GRANTED this identity a reservation. */
|
|
16796
|
+
const grantedAddrs = [];
|
|
16797
|
+
// For `spread.grant_not_bound` below: the walk's own duration is measured against the relay's
|
|
16798
|
+
// two-minute proof memory, so it has to be a number rather than an inference.
|
|
16799
|
+
const walkStartedAt = Date.now();
|
|
15841
16800
|
for (const circuitAddr of candidateCircuitAddrs) {
|
|
15842
|
-
|
|
15843
|
-
//
|
|
15844
|
-
// A rejected candidate is stopped with an unawaited `void …then(() => candidate.stop())`
|
|
15845
|
-
// while its `start()` may still be in flight, so two candidate nodes can briefly be live at
|
|
15846
|
-
// once. Sharing one seed would give both the SAME peer id — and the loser would then be a
|
|
15847
|
-
// second live node under the identity we advertise in `session_offer_accept`, sharing this
|
|
15848
|
-
// gater (so it admits dials) with no content handler registered. Inbound arriving there goes
|
|
15849
|
-
// nowhere, and it is an open endpoint under our advertised id: the "connection a malicious
|
|
15850
|
-
// agent can farm for" the tenet names. Before seeds existed the loser had its own random key
|
|
15851
|
-
// and was harmless; introducing a shared seed is what would have made it dangerous.
|
|
15852
|
-
//
|
|
15853
|
-
// Nothing reads the seed before the winner is installed, so per-candidate costs nothing.
|
|
15854
|
-
const candidateSeed = randomBytes(32);
|
|
16801
|
+
const candidateSeed = receiverSeed;
|
|
15855
16802
|
/**
|
|
15856
16803
|
* DOD-M15-RELAYSLOTS-1 — **TWO ATTEMPTS PER RELAY: ask, prove, ask again.**
|
|
15857
16804
|
*
|
|
@@ -15866,7 +16813,7 @@ export class SessionNodeManager {
|
|
|
15866
16813
|
* announces no circuit address for it, because it only announces addresses for reservations
|
|
15867
16814
|
* its own relay-discovery made. The agent would hold a slot nobody could dial through.
|
|
15868
16815
|
*/
|
|
15869
|
-
let
|
|
16816
|
+
let candidateGranted = false;
|
|
15870
16817
|
// Set when the relay refused the AGENT rather than being unwilling itself: every other relay
|
|
15871
16818
|
// in the pool answers identically, so the walk ends here rather than reproducing it N times.
|
|
15872
16819
|
let candidateRefusedAgent = false;
|
|
@@ -15882,9 +16829,13 @@ export class SessionNodeManager {
|
|
|
15882
16829
|
const timedOut = Symbol("reservation_timeout");
|
|
15883
16830
|
let outcome = "failed";
|
|
15884
16831
|
let error = "";
|
|
16832
|
+
// KEEP THE START PROMISE. Every candidate now carries the receiver's identity, so an
|
|
16833
|
+
// abandoned one must be reliably torn down rather than best-effort — and only its own start
|
|
16834
|
+
// promise says when it is stoppable (see the seed note above).
|
|
16835
|
+
const startP = candidate.start();
|
|
15885
16836
|
try {
|
|
15886
16837
|
outcome = await Promise.race([
|
|
15887
|
-
|
|
16838
|
+
startP.then(() => "started"),
|
|
15888
16839
|
new Promise((resolve) => {
|
|
15889
16840
|
timer = setTimeout(() => resolve(timedOut), this.#srReservationTimeoutMs);
|
|
15890
16841
|
}),
|
|
@@ -15902,7 +16853,15 @@ export class SessionNodeManager {
|
|
|
15902
16853
|
// completes the handshake and simply grants nothing, leaving a node that looks
|
|
15903
16854
|
// started and is reachable by nobody.
|
|
15904
16855
|
if (outcome === "started" && candidate.listenAddresses().some((a) => a.includes("/p2p-circuit"))) {
|
|
15905
|
-
|
|
16856
|
+
candidateGranted = true;
|
|
16857
|
+
// The probe has done its job: this relay grants THIS identity. Tear it down and ask the
|
|
16858
|
+
// next relay — the reservation is re-taken by the final node below, which is the only one
|
|
16859
|
+
// that can listen on every granted address at once. AWAITED, because the next probe comes
|
|
16860
|
+
// up on this same peer id.
|
|
16861
|
+
try {
|
|
16862
|
+
await candidate.stop();
|
|
16863
|
+
}
|
|
16864
|
+
catch { /* it may never have finished starting */ }
|
|
15906
16865
|
break;
|
|
15907
16866
|
}
|
|
15908
16867
|
/**
|
|
@@ -15990,27 +16949,125 @@ export class SessionNodeManager {
|
|
|
15990
16949
|
...(error !== "" ? { error } : {}),
|
|
15991
16950
|
correlationId,
|
|
15992
16951
|
});
|
|
15993
|
-
// Abandon it. start() may still be parked on
|
|
15994
|
-
// and
|
|
15995
|
-
|
|
15996
|
-
|
|
15997
|
-
|
|
16952
|
+
// Abandon it — but on its OWN settlement, never best-effort. `start()` may still be parked on
|
|
16953
|
+
// a dial, and this candidate carries the receiver's identity: an unawaited `stop()` on a node
|
|
16954
|
+
// whose status is still `starting` returns without stopping anything, and the node then goes
|
|
16955
|
+
// live on our peer id with nothing left holding a reference to kill it.
|
|
16956
|
+
void startP.then(() => candidate.stop().catch(() => { }), () => { });
|
|
15998
16957
|
break;
|
|
15999
16958
|
}
|
|
16000
|
-
if (
|
|
16001
|
-
|
|
16959
|
+
if (candidateGranted)
|
|
16960
|
+
grantedAddrs.push(circuitAddr);
|
|
16961
|
+
// 032-RELAYSPREAD: DO NOT BREAK ON THE FIRST GRANT. The walk used to stop here, which is why
|
|
16962
|
+
// an agent held exactly one reservation and losing that relay cost it every NAT'd caller for
|
|
16963
|
+
// however long detection happened to take. It now asks every remaining relay.
|
|
16002
16964
|
if (candidateRefusedAgent)
|
|
16003
16965
|
break;
|
|
16004
16966
|
}
|
|
16005
|
-
|
|
16006
|
-
|
|
16967
|
+
/**
|
|
16968
|
+
* THE RECEIVER, listening on EVERY granted circuit address.
|
|
16969
|
+
*
|
|
16970
|
+
* One node per agent, as before — what changed is how many circuits it announces. Each address
|
|
16971
|
+
* here belongs to a relay that granted THIS seed moments ago and therefore still remembers the
|
|
16972
|
+
* identity, so the final node's first ask is the one that succeeds; the two-attempt dance was
|
|
16973
|
+
* already paid per relay in the walk.
|
|
16974
|
+
*
|
|
16975
|
+
* ⚠️ RACED AGAINST A DEADLINE, and that is measured rather than cautious: `#buildRevivedNode`
|
|
16976
|
+
* records a live 2026-08-18 result where a node handed two relay addresses at once with no
|
|
16977
|
+
* deadline never finished starting at all (10,002ms and counting). Its identity was unproven at
|
|
16978
|
+
* both relays, which is not this case — but "not this case" is a prediction, and the standing
|
|
16979
|
+
* receiver is the thing that makes an agent reachable, so it does not wait on one.
|
|
16980
|
+
*
|
|
16981
|
+
* An empty `grantedAddrs` yields the plain TCP floor, exactly as before: reachable by peers
|
|
16982
|
+
* that can dial directly, and loud about it (`session.standing_receiver.reservation.none`).
|
|
16983
|
+
*/
|
|
16984
|
+
const node = await this.#createAgentNode(agentName, {
|
|
16007
16985
|
sessionId,
|
|
16008
16986
|
connectionGater: gater,
|
|
16009
16987
|
nodeType: "standing_receiver",
|
|
16010
|
-
|
|
16988
|
+
...(grantedAddrs.length > 0 ? { circuitRelayListenAddrs: grantedAddrs } : {}),
|
|
16989
|
+
transportPrivateKey: receiverSeed,
|
|
16011
16990
|
});
|
|
16012
|
-
|
|
16013
|
-
|
|
16991
|
+
if (grantedAddrs.length === 0) {
|
|
16992
|
+
await node.start();
|
|
16993
|
+
return { node, seed: receiverSeed };
|
|
16994
|
+
}
|
|
16995
|
+
/**
|
|
16996
|
+
* ⚠️ SLOW AND FAILED ARE DIFFERENT ANSWERS AND MUST NOT SHARE A BRANCH. Review F1: a single
|
|
16997
|
+
* `.catch(() => false)` around this race collapsed every `start()` REJECTION into the deadline
|
|
16998
|
+
* branch — and `CelloNodeImpl.start()` rejects by design, stopping the node and throwing
|
|
16999
|
+
* `listen_failed` when no direct (non-circuit) listener materialised. That is the guard the
|
|
17000
|
+
* transport keeps precisely so `FaultTolerance.NO_FATAL` cannot mask a real `EADDRINUSE`.
|
|
17001
|
+
*
|
|
17002
|
+
* Swallowed, it installed a STOPPED node as the agent's front door: no addresses to advertise,
|
|
17003
|
+
* `#tryCreateStandingReceiver` never saw a failure so the M8B F14 retry never fired, and the
|
|
17004
|
+
* operator was told the receiver "did not finish binding every circuit inside the deadline" and
|
|
17005
|
+
* "is reachable through those" — sending them to the relay fleet for a port held by an orphan
|
|
17006
|
+
* daemon on their own machine. The rejection is rethrown so it reaches
|
|
17007
|
+
* `session.node.create.failed` with its own cause, exactly as it does on the no-relay path.
|
|
17008
|
+
*/
|
|
17009
|
+
let deadline;
|
|
17010
|
+
let startError;
|
|
17011
|
+
const started = node.start().then(() => "ok", (err) => { startError = err; return "failed"; });
|
|
17012
|
+
const outcome = await Promise.race([
|
|
17013
|
+
started,
|
|
17014
|
+
new Promise((resolve) => {
|
|
17015
|
+
// Per granted relay: each circuit listener is its own dial and its own reservation, so a
|
|
17016
|
+
// pool of three must not be judged on a budget sized for one.
|
|
17017
|
+
deadline = setTimeout(() => resolve("slow"), this.#srReservationTimeoutMs * grantedAddrs.length);
|
|
17018
|
+
}),
|
|
17019
|
+
]);
|
|
17020
|
+
if (deadline !== undefined)
|
|
17021
|
+
clearTimeout(deadline);
|
|
17022
|
+
if (outcome === "failed")
|
|
17023
|
+
throw startError;
|
|
17024
|
+
/**
|
|
17025
|
+
* GRANTED IN THE WALK, REFUSED AT INSTALL — a distinct fact and, until this line, an invisible
|
|
17026
|
+
* one. The receiver would simply report `reservationsHeld: 2` where 3 relays granted, with
|
|
17027
|
+
* nothing naming which relay went missing or why.
|
|
17028
|
+
*
|
|
17029
|
+
* ⚠️ IT HAS A KNOWN CAUSE AND A CROSS-REPO CLOCK. The walk stops the granted candidate and the
|
|
17030
|
+
* node below RE-ASKS, which works because the relay remembers the proof — for
|
|
17031
|
+
* `PROVEN_PEER_MEMORY_MS = 2 minutes` (`relay-connection-gater.ts`, trustless-cello). The walk
|
|
17032
|
+
* costs up to `#srReservationTimeoutMs` × 2 attempts per relay, so a pool of three at the
|
|
17033
|
+
* 15s default can spend 90 seconds before the final node asks relay 1 again. The earliest
|
|
17034
|
+
* proof can expire before it is used, and that is what this event catches.
|
|
17035
|
+
*/
|
|
17036
|
+
const boundRelays = new Set(heldRelayIdsOf(node));
|
|
17037
|
+
const grantedButUnbound = grantedAddrs
|
|
17038
|
+
.map((a) => CIRCUIT_RELAY_ID.exec(a)?.[1])
|
|
17039
|
+
.filter((id) => id !== undefined && !boundRelays.has(id));
|
|
17040
|
+
if (grantedButUnbound.length > 0) {
|
|
17041
|
+
this.#logger.warn("session.standing_receiver.spread.grant_not_bound", {
|
|
17042
|
+
agentName,
|
|
17043
|
+
relayPeerIds: grantedButUnbound,
|
|
17044
|
+
relaysGranted: grantedAddrs.length,
|
|
17045
|
+
reservationsHeld: boundRelays.size,
|
|
17046
|
+
walkMs: Date.now() - walkStartedAt,
|
|
17047
|
+
correlationId,
|
|
17048
|
+
impact: "these relays granted this agent a reservation during the walk and then bound no " +
|
|
17049
|
+
"circuit on the receiver itself, so the agent is reachable through fewer relays than it " +
|
|
17050
|
+
"earned. The relay remembers a proof for two minutes; if walkMs is near or past that, " +
|
|
17051
|
+
"the proof expired before the receiver asked and the walk is what needs shortening — " +
|
|
17052
|
+
"not the relay fleet.",
|
|
17053
|
+
});
|
|
17054
|
+
}
|
|
17055
|
+
if (outcome === "slow") {
|
|
17056
|
+
// NOT a teardown, and now this line means only what it says: the node is starting and has not
|
|
17057
|
+
// finished. It is installed with whatever circuits did materialise, because some reachability
|
|
17058
|
+
// beats none and the reservation watchdog is what settles the rest.
|
|
17059
|
+
this.#logger.warn("session.standing_receiver.spread.slow_start", {
|
|
17060
|
+
agentName,
|
|
17061
|
+
relaysGranted: grantedAddrs.length,
|
|
17062
|
+
circuitAddrs: node.listenAddresses().filter((a) => a.includes("/p2p-circuit")).length,
|
|
17063
|
+
budgetMs: this.#srReservationTimeoutMs * grantedAddrs.length,
|
|
17064
|
+
correlationId,
|
|
17065
|
+
impact: "the receiver did not finish binding every circuit it was granted inside the " +
|
|
17066
|
+
"deadline, so it is being installed with the circuits it has. It is reachable through " +
|
|
17067
|
+
"those; the reservation watchdog re-checks the rest on its next tick.",
|
|
17068
|
+
});
|
|
17069
|
+
}
|
|
17070
|
+
return { node, seed: receiverSeed };
|
|
16014
17071
|
}
|
|
16015
17072
|
/** One standing-receiver create attempt (extracted for the M8B F14 retry loop). */
|
|
16016
17073
|
async #tryCreateStandingReceiver(agentName, correlationId) {
|
|
@@ -16032,11 +17089,15 @@ export class SessionNodeManager {
|
|
|
16032
17089
|
}
|
|
16033
17090
|
let node;
|
|
16034
17091
|
/**
|
|
16035
|
-
* DOD-M12B-SESSION-SEED-1 — the transport identity of
|
|
17092
|
+
* DOD-M12B-SESSION-SEED-1 — the transport identity of this receiver.
|
|
16036
17093
|
*
|
|
16037
|
-
* Minted
|
|
16038
|
-
*
|
|
16039
|
-
*
|
|
17094
|
+
* Minted ONCE inside `#startReceiverNode` and returned with the node, not minted here. It is
|
|
17095
|
+
* one seed for the whole walk (032-RELAYSPREAD): the receiver reserves with every relay that
|
|
17096
|
+
* grants, and an agent must be dialable at ONE peer id through any of its circuits, so every
|
|
17097
|
+
* reservation has to belong to the same key. What makes that safe is DOD-M15-ASSIGN-1 — the
|
|
17098
|
+
* gater above admits NOBODY inbound — not the teardown, which bounds how long a rejected
|
|
17099
|
+
* candidate lives rather than preventing it from overlapping. See the seed note in
|
|
17100
|
+
* `#startReceiverNode` for the full argument.
|
|
16040
17101
|
*
|
|
16041
17102
|
* FRESH EVERY TIME, which is the privacy property rather than an implementation detail. A
|
|
16042
17103
|
* receiver serves at most one session (it is promoted into the session at handoff and replaced),
|
|
@@ -16090,39 +17151,39 @@ export class SessionNodeManager {
|
|
|
16090
17151
|
probers: this.#autoNatProbers(),
|
|
16091
17152
|
});
|
|
16092
17153
|
autoNat.emitInitialResult();
|
|
16093
|
-
|
|
16094
|
-
|
|
16095
|
-
|
|
16096
|
-
|
|
16097
|
-
|
|
16098
|
-
|
|
16099
|
-
|
|
16100
|
-
|
|
16101
|
-
|
|
16102
|
-
|
|
16103
|
-
|
|
16104
|
-
|
|
16105
|
-
|
|
16106
|
-
|
|
16107
|
-
|
|
16108
|
-
|
|
16109
|
-
|
|
16110
|
-
|
|
16111
|
-
|
|
16112
|
-
const
|
|
16113
|
-
|
|
16114
|
-
|
|
16115
|
-
// DOD-M15-ASSIGN-1 review N3: the
|
|
16116
|
-
// inbound AutoNAT carve-out — nothing else does.
|
|
16117
|
-
// completed, so a directory that merely NAMES a relay cannot dial
|
|
16118
|
-
|
|
17154
|
+
/**
|
|
17155
|
+
* EVERY RELAY THE NODE ACTUALLY HOLDS A CIRCUIT WITH — derived from the addresses the node
|
|
17156
|
+
* holds, never from `reservations.addrs`.
|
|
17157
|
+
*
|
|
17158
|
+
* The old code read `reservations.addrs[0]`'s relay id as a fallback, and its own comment
|
|
17159
|
+
* called the hazard "dormant while the pool is size 1; the pool is designed to be larger."
|
|
17160
|
+
* THIS UNIT IS WHAT MAKES THE POOL LARGER, so the dormant case wakes up: candidate 0 refusing
|
|
17161
|
+
* while candidate 1 grants recorded a relay we are not connected to, the watchdog found it
|
|
17162
|
+
* absent on every tick forever, and it rebuilt on the 30-second grid — churning the very
|
|
17163
|
+
* reservations this unit exists to conserve. A candidate is a relay we ASKED; only a held
|
|
17164
|
+
* address is a relay that ANSWERED, and the fallback conflated the two.
|
|
17165
|
+
*
|
|
17166
|
+
* The fallback's own stated worry stands, and is answered by the count rather than by the
|
|
17167
|
+
* candidate list: if a transport ever reports a circuit address without the relay's peer id in
|
|
17168
|
+
* `/p2p/<id>/p2p-circuit` form, that address yields no id and is not counted as held — so the
|
|
17169
|
+
* receiver reads as degraded and gets rebuilt, instead of reading as healthy against a relay
|
|
17170
|
+
* nobody is connected to. Degrading toward "rebuild" is the safe direction; the other one is
|
|
17171
|
+
* the silent unreachability this whole file exists to kill.
|
|
17172
|
+
*/
|
|
17173
|
+
const heldRelayPeerIds = heldRelayIdsOf(node);
|
|
17174
|
+
const circuitAddrs = heldRelayPeerIds.length;
|
|
17175
|
+
const heldCircuitAddrs = node.listenAddresses().filter((a) => a.includes("/p2p-circuit"));
|
|
17176
|
+
// DOD-M15-ASSIGN-1 review N3, widened by 032-RELAYSPREAD: the relays this receiver actually
|
|
17177
|
+
// reserved with earn the inbound AutoNAT carve-out — nothing else does. Populated only from
|
|
17178
|
+
// reservations that genuinely completed, so a directory that merely NAMES a relay cannot dial
|
|
17179
|
+
// in behind it, however many relays it names.
|
|
17180
|
+
gater.setReservedRelayPeers(heldRelayPeerIds);
|
|
16119
17181
|
this.#standingReceivers.set(agentName, {
|
|
16120
17182
|
node,
|
|
16121
17183
|
gater,
|
|
16122
17184
|
autoNat,
|
|
16123
17185
|
seed,
|
|
16124
|
-
|
|
16125
|
-
...(reservedRelayPeerId !== undefined ? { relayPeerId: reservedRelayPeerId } : {}),
|
|
17186
|
+
relayPeerIds: heldRelayPeerIds,
|
|
16126
17187
|
});
|
|
16127
17188
|
this.#logger.info("session.node.created", {
|
|
16128
17189
|
sessionId,
|
|
@@ -16136,31 +17197,54 @@ export class SessionNodeManager {
|
|
|
16136
17197
|
// real session to exist, is what keeps this reservation alive past that grace window.
|
|
16137
17198
|
// Best-effort and unawaited: a failure here costs nothing beyond the relay's own grace-window
|
|
16138
17199
|
// revoke, which the reservation watchdog already treats as an ordinary lost reservation.
|
|
16139
|
-
|
|
16140
|
-
|
|
17200
|
+
// ONCE PER HELD RELAY. Each relay revokes independently — it times out the reservation of any
|
|
17201
|
+
// peer that has not proven key possession TO IT — so proving to one of three and calling the
|
|
17202
|
+
// receiver authenticated would lose the other two circuits about fifteen seconds later, which
|
|
17203
|
+
// is the same silent unreachability with two more relays paying for it.
|
|
17204
|
+
for (const relayPeerId of heldRelayPeerIds) {
|
|
17205
|
+
const heldCircuitAddr = heldCircuitAddrs.find((a) => a.includes(`/p2p/${relayPeerId}/p2p-circuit`));
|
|
17206
|
+
if (heldCircuitAddr === undefined)
|
|
17207
|
+
continue;
|
|
17208
|
+
void this.#authenticateStandingReceiver(agentName, node, relayPeerId, heldCircuitAddr, correlationId)
|
|
16141
17209
|
.catch((err) => {
|
|
16142
17210
|
this.#logger.warn("session.standing_receiver.relay_auth.failed", {
|
|
16143
17211
|
agentName,
|
|
16144
|
-
relayPeerId
|
|
17212
|
+
relayPeerId,
|
|
16145
17213
|
error: extractErrorMessage(err),
|
|
16146
17214
|
correlationId,
|
|
16147
17215
|
});
|
|
16148
17216
|
});
|
|
16149
17217
|
}
|
|
16150
|
-
// DOD-NAT-REACHABILITY-1 observability: how reachable did this receiver come
|
|
16151
|
-
//
|
|
16152
|
-
//
|
|
16153
|
-
//
|
|
17218
|
+
// DOD-NAT-REACHABILITY-1 observability: how reachable did this receiver come up? Zero held
|
|
17219
|
+
// while relays were offered means every relay refused or was unreachable — the agent is deaf
|
|
17220
|
+
// to NAT'd initiators (public ones can still connect directly). That must be LOUD, not a quiet
|
|
17221
|
+
// shrug.
|
|
17222
|
+
//
|
|
17223
|
+
// 032-RELAYSPREAD — TWO NUMBERS, SO TWO NAMES. Both events used to carry one field,
|
|
17224
|
+
// `reservationsRequested`, holding `reservations.addrs.length` — the size of the CANDIDATE
|
|
17225
|
+
// list, under a name that reads as a count of asks. That is why "the client already requests a
|
|
17226
|
+
// reservation with every relay it knows" read as true in an audit: the outcome was one and the
|
|
17227
|
+
// request was one too, and a single field could report neither.
|
|
17228
|
+
// relaysOffered — how many relays were in the candidate list (deduped by relay peer id in
|
|
17229
|
+
// `#reservationCircuitAddrs`, so it counts relays, not addresses).
|
|
17230
|
+
// reservationsHeld — how many reservations this node actually holds, counted the only way
|
|
17231
|
+
// that proves a grant: ANNOUNCED /p2p-circuit listen addresses. `start()`
|
|
17232
|
+
// resolving is not enough — a relay out of reservation slots completes the
|
|
17233
|
+
// handshake, grants nothing, and leaves a node that looks started and is
|
|
17234
|
+
// dialable by nobody.
|
|
16154
17235
|
this.#logger.info("session.standing_receiver.reachability", {
|
|
16155
17236
|
agentName,
|
|
16156
|
-
|
|
16157
|
-
|
|
17237
|
+
relaysOffered: reservations.addrs.length,
|
|
17238
|
+
reservationsHeld: circuitAddrs,
|
|
16158
17239
|
correlationId,
|
|
16159
17240
|
});
|
|
16160
17241
|
if (reservations.addrs.length > 0 && circuitAddrs === 0) {
|
|
16161
17242
|
this.#logger.warn("session.standing_receiver.reservation.none", {
|
|
16162
17243
|
agentName,
|
|
16163
|
-
|
|
17244
|
+
relaysOffered: reservations.addrs.length,
|
|
17245
|
+
// Zero by this branch's own condition, and stated rather than implied: the event reads
|
|
17246
|
+
// "offered 3, held 0" on its own, without the reader having to find the gate above it.
|
|
17247
|
+
reservationsHeld: circuitAddrs,
|
|
16164
17248
|
relayPeerIds: reservations.relayPeerIds,
|
|
16165
17249
|
correlationId,
|
|
16166
17250
|
});
|
|
@@ -16459,7 +17543,9 @@ export class SessionNodeManager {
|
|
|
16459
17543
|
}
|
|
16460
17544
|
this.#relayClients.set(clientKey, client);
|
|
16461
17545
|
}
|
|
16462
|
-
|
|
17546
|
+
// 033-ACKEMIT: a revived session re-registers with no assignment in hand, so the genesis comes
|
|
17547
|
+
// from the entry that was just restored above.
|
|
17548
|
+
client.registerSession(sessionId, node, this.#relayLeafHandler(agentName, sessionId, correlationId), undefined, this.#sessionGenesisPrevRoot(agentName, sessionId));
|
|
16463
17549
|
const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
|
|
16464
17550
|
if (entry) {
|
|
16465
17551
|
entry.relayClient = client;
|