@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.
@@ -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
- client.registerSession(sessionId, node);
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
- // `hasReservation`: this receiver came up holding a /p2p-circuit address. The
1002
- // watchdog uses it to tell "lost its reservation" (must recover) apart from
1003
- // "never had one" (already degraded, and already loud) see #reservationWatchdogTick.
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
- if (sr.hasReservation && sr.relayPeerId !== undefined)
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
- client.registerSession(sessionIdHexForRelay, node, this.#relayLeafHandler(agentName, sessionId, correlationId), relay.assignment);
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
- entry.relayAssignment = relay.assignment;
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
- let frameS1 = orderingS1;
7735
- let frameSig = orderingSig;
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
- const attempt = await this.#parkContent(agentName, sessionId, hashHex, content, orderingS1, orderingS2, contentHashAlg);
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. Zero when there is no relay client at all, which is honest: nothing has been
14252
- // witnessed on this session, so there is no position to acknowledge.
14253
- lastSeenSeq: entry.relayClient?.lastSeenSeq(Buffer.from(sessionIdBytes).toString("hex")) ?? 0,
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
- // v1 DELIBERATELY. `last_seen_hash` (v2) is `WITHHOLD-SEAL-1`'s emitter and is not owed here;
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
- : "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.";
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
- : "STOPPED ON PURPOSE. This copy was refused and the message itself was not kept. " +
14458
- // Review F2: chosen from what THIS machine can do, not asserted. An agent with no identity
14459
- // key cannot open a mailbox copy either, and telling them to wait for one would be the same
14460
- // false promise on a different refusal.
14461
- (this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
14462
- " Almost always their CELLO build is older than this one: a build from before message signing " +
14463
- "does not attach a signature at all. Ask which version they are running, and tell them to " +
14464
- "upgrade — this will keep happening until they do, and only they can fix it. If they are on " +
14465
- "the SAME version as you, that explanation does not hold: confirm with them OUT OF BAND " +
14466
- "before opening another session.";
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
- if (auth.reason === AUTHORSHIP_CONTENT_HASH_MISMATCH) {
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
- this.#refuseUnprovenAuthorship(agentName, sessionId, authorship.reason === AUTHORSHIP_SESSION_MISMATCH ? "authorship_wrong_conversation" : "authorship_proof_unusable", contentHash, { detail: authorship.reason }, correlationId);
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 replacement.)
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. But
15340
- * a preserved identity would have to be handed to the candidate loop in
15341
- * `#startReceiverNode`, whose rejected candidates are stopped WITHOUT awaiting `start()`
15342
- * so two nodes could be briefly live on one advertised peer id, which is review F1, a HIGH,
15343
- * and the reason each candidate now mints its own. Fixing F7 properly means bounding and
15344
- * awaiting the loser's teardown first, and an unawaited stop is precisely what the current
15345
- * code chose to avoid a stuck libp2p teardown blocking receiver creation. Filed as
15346
- * follow-on work rather than trading a MEDIUM fix for a HIGH regression.
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
- reservationsRequested: (this.#directoryRelayEndpoints.get(agentName)?.length ?? 0) > 0,
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 (!sr.hasReservation || sr.relayPeerId === undefined) {
15604
- this.#retryReservationIfDue(agentName);
15605
- continue;
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 stale.
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
- const stillConnected = sr.node.getConnections()
15626
- .some((c) => c.peerId === sr.relayPeerId && c.status === "open");
15627
- const stillAdvertising = sr.node.listenAddresses().some((a) => a.includes("/p2p-circuit"));
15628
- if (stillConnected && stillAdvertising)
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
- // DOD-RELAY-KEEPALIVE-1 (review F4): carry the CAUSE, not just the exit point.
15631
- // `relay_connection_gone` says where this was noticed a poll of getConnections() by which
15632
- // time the abort reason that actually killed the link is long discarded. The relay client for
15633
- // this (agent, relay) pair kept the error that ended its reader; that is the nearest thing to
15634
- // an upstream cause available here, and its absence is how 2,061 of these went untraced.
15635
- const upstreamReason = this.#relayClients.get(`${agentName}::${sr.relayPeerId}`)?.getLastReaderError();
15636
- this.#logger.warn("session.standing_receiver.reservation.lost", {
15637
- agentName,
15638
- relayPeerId: sr.relayPeerId,
15639
- reason: stillConnected ? "circuit_address_vanished" : "relay_connection_gone",
15640
- ...(upstreamReason ? { upstreamReason } : {}),
15641
- });
15642
- void this.#rebuildStandingReceiver(agentName);
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
- // DOD-M12B-SESSION-SEED-1: A SEED PER CANDIDATE, NOT ONE FOR THE LOOP.
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 candidateNode;
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
- candidate.start().then(() => "started"),
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
- candidateNode = candidate;
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 a dial, so stop() is best-effort
15994
- // and must never block the fallback.
15995
- void Promise.resolve()
15996
- .then(() => candidate.stop())
15997
- .catch(() => { });
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 (candidateNode)
16001
- return { node: candidateNode, seed: candidateSeed };
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
- const plainSeed = randomBytes(32);
16006
- const plain = await this.#createAgentNode(agentName, {
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
- transportPrivateKey: plainSeed,
16988
+ ...(grantedAddrs.length > 0 ? { circuitRelayListenAddrs: grantedAddrs } : {}),
16989
+ transportPrivateKey: receiverSeed,
16011
16990
  });
16012
- await plain.start();
16013
- return { node: plain, seed: plainSeed };
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 the receiver that actually survived.
17092
+ * DOD-M12B-SESSION-SEED-1 — the transport identity of this receiver.
16036
17093
  *
16037
- * Minted per CANDIDATE inside `#startReceiverNode` and returned with the winner, not minted
16038
- * here: a rejected candidate is stopped without awaiting its `start()`, so two candidates can
16039
- * be briefly live, and one shared seed would put both on the same advertised peer id.
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
- const circuitAddrs = node.listenAddresses().filter((a) => a.includes("/p2p-circuit")).length;
16094
- // FROM THE ADDRESS THE NODE ACTUALLY HOLDS, not from `reservations.addrs[0]`.
16095
- //
16096
- // `#startReceiverNode` tries candidates in order and returns the FIRST that actually grants —
16097
- // so when candidate 0 refuses (the measured `relay_granted_no_reservation` case) and candidate 1
16098
- // grants, reading candidate 0's address records a relay we are not connected to. The watchdog
16099
- // then evaluates `getConnections().some(c => c.peerId === relayPeerId)` against that wrong peer,
16100
- // finds it false on every tick forever, and rebuilds on the 30-second grid churning the very
16101
- // reservations this unit exists to conserve. Dormant while the pool is size 1; the pool is
16102
- // designed to be larger.
16103
- // PREFER the held address, FALL BACK to the candidate strictly better than either alone.
16104
- // The held address is authoritative about which relay actually granted, but it is libp2p's
16105
- // string, not ours: if a transport ever reports the circuit address without the relay's peer id
16106
- // in `/p2p/<id>/p2p-circuit` form, reading only it would yield UNDEFINED, and an undefined
16107
- // relayPeerId makes the watchdog treat a perfectly healthy reservation as absent and rebuild it.
16108
- // That would be a regression on the single-relay case that works today. The candidate string is
16109
- // ours and always carries the id, so it is the safe floor.
16110
- const heldCircuitAddr = node.listenAddresses().find((a) => a.includes("/p2p-circuit"));
16111
- const CIRCUIT_RELAY_ID = /\/p2p\/([^/]+)\/p2p-circuit/;
16112
- const reservedRelayPeerId = circuitAddrs > 0
16113
- ? (heldCircuitAddr?.match(CIRCUIT_RELAY_ID)?.[1] ?? reservations.addrs[0]?.match(CIRCUIT_RELAY_ID)?.[1])
16114
- : undefined;
16115
- // DOD-M15-ASSIGN-1 review N3: the ONE relay this receiver actually reserved with earns the
16116
- // inbound AutoNAT carve-out — nothing else does. Set only when a reservation genuinely
16117
- // completed, so a directory that merely NAMES a relay cannot dial in behind it.
16118
- gater.setReservedRelayPeer(circuitAddrs > 0 && reservedRelayPeerId !== undefined ? reservedRelayPeerId : null);
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
- hasReservation: circuitAddrs > 0,
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
- if (reservedRelayPeerId !== undefined && heldCircuitAddr !== undefined) {
16140
- void this.#authenticateStandingReceiver(agentName, node, reservedRelayPeerId, heldCircuitAddr, correlationId)
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: reservedRelayPeerId,
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
- // up? circuitAddrs === 0 with reservations requested means every relay
16152
- // refused/was unreachable the agent is deaf to NAT'd initiators (public
16153
- // ones can still connect directly). That must be LOUD, not a quiet shrug.
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
- circuitAddrs,
16157
- reservationsRequested: reservations.addrs.length,
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
- reservationsRequested: reservations.addrs.length,
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
- client.registerSession(sessionId, node, this.#relayLeafHandler(agentName, sessionId, correlationId));
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;