@cello-protocol/daemon 0.0.188 → 0.0.190

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.
Files changed (38) hide show
  1. package/dist/agent-id-migration.d.ts.map +1 -1
  2. package/dist/agent-id-migration.js +8 -0
  3. package/dist/agent-id-migration.js.map +1 -1
  4. package/dist/content-encryption-status.d.ts +14 -0
  5. package/dist/content-encryption-status.d.ts.map +1 -1
  6. package/dist/content-encryption-status.js +36 -0
  7. package/dist/content-encryption-status.js.map +1 -1
  8. package/dist/content-park-client.d.ts +31 -0
  9. package/dist/content-park-client.d.ts.map +1 -1
  10. package/dist/content-park-client.js +89 -4
  11. package/dist/content-park-client.js.map +1 -1
  12. package/dist/content-park.d.ts +1 -0
  13. package/dist/content-park.d.ts.map +1 -1
  14. package/dist/content-park.js +238 -15
  15. package/dist/content-park.js.map +1 -1
  16. package/dist/notification-handlers.d.ts.map +1 -1
  17. package/dist/notification-handlers.js +17 -7
  18. package/dist/notification-handlers.js.map +1 -1
  19. package/dist/refusal-reasons.d.ts +35 -0
  20. package/dist/refusal-reasons.d.ts.map +1 -1
  21. package/dist/refusal-reasons.js +48 -0
  22. package/dist/refusal-reasons.js.map +1 -1
  23. package/dist/session-connection-gater.d.ts +19 -3
  24. package/dist/session-connection-gater.d.ts.map +1 -1
  25. package/dist/session-connection-gater.js +40 -11
  26. package/dist/session-connection-gater.js.map +1 -1
  27. package/dist/session-content-handlers.d.ts.map +1 -1
  28. package/dist/session-content-handlers.js +20 -3
  29. package/dist/session-content-handlers.js.map +1 -1
  30. package/dist/session-node-manager.d.ts +178 -28
  31. package/dist/session-node-manager.d.ts.map +1 -1
  32. package/dist/session-node-manager.js +2289 -286
  33. package/dist/session-node-manager.js.map +1 -1
  34. package/dist/session-relay-client.d.ts +51 -2
  35. package/dist/session-relay-client.d.ts.map +1 -1
  36. package/dist/session-relay-client.js +211 -27
  37. package/dist/session-relay-client.js.map +1 -1
  38. package/package.json +5 -5
@@ -23,14 +23,14 @@
23
23
  import { contentHashFor, resolveContentHashAlg, CONTENT_HASH_ALGS } from "./wire-content-hash.js";
24
24
  import { CAPACITY_REASONS } from "./refusal-reasons.js";
25
25
  import { onPeerSaltFrame, ownSaltFrame, SALT_ADOPTION_LABEL_MAX, SALT_ADOPTION_LABELS, SALT_FREEZE_GUIDANCE, } from "./session-salt-agreement.js";
26
- import { CONTENT_ENCRYPTION_REASONS, CONTENT_ENCRYPTION_GUIDANCE, SESSION_CONTENT_ENCRYPTION_V1, } from "./content-encryption-status.js";
26
+ import { CONTENT_ENCRYPTION_REASONS, CONTENT_ENCRYPTION_GUIDANCE, CONTENT_ENCRYPTION_INBOUND_GUIDANCE, SESSION_CONTENT_ENCRYPTION_V1, } from "./content-encryption-status.js";
27
27
  import { openEncryptedDatabase, resolveDbKey, dbKeyPathFor, } from "./sqlcipher-db.js";
28
28
  import { migrateToEncryptedIfNeeded } from "./identity-migration.js";
29
29
  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 } 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
  *
@@ -381,6 +403,155 @@ export const REVIVE_RESERVATION_CANDIDATES = 2;
381
403
  * the healthy direct latency and far below anything a person would notice.
382
404
  */
383
405
  export const LEAF_FETCH_GRACE_MS = 2_000;
406
+ /**
407
+ * DOD-M15-REFUSALTERMINAL-1 — the refusal reasons no retry can ever get past.
408
+ *
409
+ * MEASURED LIVE 2026-09-04: one message aimed at a conversation the counterparty had already
410
+ * closed, refused `session_committed` and re-fetched roughly twice a second for 62 hours across
411
+ * several daemon restarts — 232,056 refusal events on that one session and a 484 MB `daemon.log`.
412
+ *
413
+ * **WHY `session_committed` QUALIFIES.** A committed session carries a signature over its contents.
414
+ * Nothing can be appended to it by anyone — not the counterparty, not us — so there is no future
415
+ * state in which this content is accepted. That is the bar, and it is the whole bar.
416
+ *
417
+ * **⚠️ DO NOT ADD A REASON WITHOUT MEETING IT.** A reason wrongly called terminal silently drops a
418
+ * message that would have arrived on the next try, which is worse than the loop this set exists to
419
+ * end. The tempting ones and why each fails:
420
+ *
421
+ * - `content_hash_mismatch` — the fetch is BY CONTENT HASH, and a later fetch may retrieve a
422
+ * correct copy from a different relay. Retrying can succeed.
423
+ * - `sender_unresolved` — the sender may become resolvable when a profile arrives or a directory
424
+ * syncs. Retrying can succeed.
425
+ * - `session_orphaned` — `024-ORPHANTRIAGE` owns that path and decides its disposition.
426
+ * - `session_size_limit_exceeded` — the cap IS monotonic, but its bound is a setting, and an
427
+ * operator who raises it must be able to un-stick the conversation.
428
+ * - a transient screener block — transient is in its name.
429
+ */
430
+ /**
431
+ * ⚠️ NOT the same concept as `session-terminal-refusal.ts` (`DOD-MP-SESSION-RETIRE-1`), which is the
432
+ * RELAY terminally refusing one of OUR SENDS. This set is about INBOUND content this side will
433
+ * never accept. Two "terminal refusal" ideas live in this daemon and they point in opposite
434
+ * directions — review F9.
435
+ */
436
+ export const TERMINAL_REFUSAL_REASONS = new Set(["session_committed"]);
437
+ /**
438
+ * DOD-M15-REFUSALTERMINAL-1 review F3 — how long a FAILED read of the terminal-refusal rows is
439
+ * backed off for, per session.
440
+ *
441
+ * A minute: long enough that a database throwing on every witnessed leaf produces one ERROR rather
442
+ * than one per message (the exact log growth this unit exists to end), short enough that a disk
443
+ * which recovers is noticed within a message or two rather than at the next restart.
444
+ */
445
+ export const TERMINAL_REFUSAL_READ_RETRY_MS = 60_000;
446
+ /**
447
+ * DOD-M15-REFUSALTERMINAL-1 review F7 — how many terminally-refused content hashes are remembered
448
+ * per session.
449
+ *
450
+ * The counterparty chooses how many rows this table gets: one per distinct message aimed at a
451
+ * closed conversation, written even after the byte cap has stopped retaining evidence. 512 matches
452
+ * `MAX_REFUSED_PARKED_ENTRIES`, is far above any honest volume for a conversation that has ENDED,
453
+ * and bounds the table at (sessions × 512) small rows.
454
+ */
455
+ export const MAX_TERMINAL_REFUSALS_PER_SESSION = 512;
456
+ /**
457
+ * The `unusable` reason for a proof that is real and describes some OTHER message.
458
+ *
459
+ * ⚠️ NOT `"content_hash_mismatch"` — review §6. That string is already the refusal reason for the
460
+ * RECEIVER's own recompute failing (`ingestReceivedContent`), which is a tamper signal about the
461
+ * BODY. This one says the sender's signed claim is about different content. Two different failures
462
+ * sharing one name is a collision an operator grepping the log walks straight into.
463
+ */
464
+ const AUTHORSHIP_CONTENT_HASH_MISMATCH = "authorship_hash_mismatch";
465
+ /**
466
+ * The `unusable` reason for a proof that is real, is by the right signer, describes this content —
467
+ * and was signed for a DIFFERENT conversation. A replay, not a forgery.
468
+ *
469
+ * Every one of those properties has been ESTABLISHED by the time this is returned; see the ordering
470
+ * note in `#verifyAuthorshipClaim`. An earlier version of this sentence was true of the intent and
471
+ * not of the code, because the check ran before the signature was verified.
472
+ */
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
+ ]);
502
+ /**
503
+ * ⚠️ **THE REFUSALS THAT SAY THIS ARE THE ONES WHERE THE REFUSAL DOES NOT HOLD — NOT ALL OF THEM.**
504
+ *
505
+ * It said "EVERY INBOUND REFUSAL SAYS THIS", and review F5 measured that: fifteen call sites file a
506
+ * refusal notice in this file and four carry this sentence — the three encryption causes and the
507
+ * authorship one. The rest MUST NOT. A screened-out message is deliberately never delivered by any
508
+ * route, and a transcript write failure lost content that was already accepted; promising either
509
+ * operator a second chance would be a lie in the opposite direction. Rewritten rather than deleted,
510
+ * because "EVERY" read as a rule and the next person to add a refusal would have applied it blindly.
511
+ *
512
+ * Where it DOES apply: refusing an inbound frame sends back no delivery acknowledgement, so a CELLO
513
+ * sender's TTF backstop parks a copy in the relay mailbox — sealed to this agent's LONG-TERM
514
+ * IDENTITY key, not the session key — and recovery opens that one whatever went wrong with the
515
+ * direct copy.
516
+ *
517
+ * ⚠️ AND ONLY WHEN THIS MACHINE CAN OPEN ONE. See `REFUSAL_NO_OTHER_ROUTE`; the choice is made by
518
+ * `#mailboxRouteAvailable`, never by a caller writing the sentence into a literal.
519
+ */
520
+ const REFUSAL_MAY_STILL_ARRIVE = "IT MAY STILL REACH YOU BY THE OTHER ROUTE: a refusal sends back no acknowledgement, so a CELLO " +
521
+ "counterparty's agent parks a copy in the relay mailbox and this side opens that one with your " +
522
+ "long-term key instead of this session's. If it arrives, it arrives without whatever this check " +
523
+ "was unable to confirm — and if they are not running CELLO, there is no such copy and it will " +
524
+ "not arrive.";
525
+ /**
526
+ * ⚠️ **THE OTHER ROUTE DOES NOT EXIST ON THIS MACHINE, AND SAYING SO IS THE POINT** — review F2.
527
+ *
528
+ * Opening a mailbox copy needs `KeyProvider.openContentSeal`, which is OPTIONAL: a threshold or
529
+ * signing-only provider does not implement it, and an agent loaded without a provider has none at
530
+ * all. `content-park.ts` refuses both — `signing_key_unavailable`, `cannot_unseal`.
531
+ *
532
+ * That is the SAME condition `CONTENT_ENCRYPTION_REASONS.NO_LOCAL_IDENTITY` reports. So on the one
533
+ * refusal that names a missing local identity, the reassurance above was false: both routes are shut
534
+ * by one cause, permanently, for every message on every session of that agent — and the operator was
535
+ * told to wait for a delivery that cannot happen. That is the H1 defect exactly: a refusal
536
+ * announcing a better outcome than it delivers.
537
+ */
538
+ const REFUSAL_NO_OTHER_ROUTE = "AND IT WILL NOT REACH YOU BY THE OTHER ROUTE EITHER: the relay mailbox copy is opened with this " +
539
+ "agent's long-term identity key, which is the very thing this machine is missing. One cause shuts " +
540
+ "both routes, and it will keep shutting them until the agent is loaded with its identity key. Do " +
541
+ "not wait for this message to turn up.";
542
+ /**
543
+ * Constant-shape byte equality for the two binding checks. Lifted rather than hand-rolled a second
544
+ * time — `seal-frontier-verify` has the same helper for the same comparison, and two copies of
545
+ * "are these the same bytes" is two things to keep true.
546
+ */
547
+ function bytesEqual(a, b) {
548
+ if (a.length !== b.length)
549
+ return false;
550
+ for (let i = 0; i < a.length; i++)
551
+ if (a[i] !== b[i])
552
+ return false;
553
+ return true;
554
+ }
384
555
  /**
385
556
  * The relay's peer id out of a circuit listen address, or `null` if the address does not name one.
386
557
  *
@@ -548,7 +719,10 @@ export class SessionNodeManager {
548
719
  */
549
720
  const claimedRegistration = !client.hasSession(sessionId);
550
721
  if (claimedRegistration) {
551
- 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));
552
726
  }
553
727
  else {
554
728
  /**
@@ -877,9 +1051,10 @@ export class SessionNodeManager {
877
1051
  // initiator (consuming its agent's standing receiver) and the responder (consuming its agent's)
878
1052
  // would contend for a single node and thrash. Keyed by agentName. A creation-in-flight guard set
879
1053
  // prevents two concurrent ensure() calls from building two nodes for the same agent.
880
- // `hasReservation`: this receiver came up holding a /p2p-circuit address. The
881
- // watchdog uses it to tell "lost its reservation" (must recover) apart from
882
- // "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.
883
1058
  /**
884
1059
  * DOD-M12B-SESSION-SEED-1 — session id → the transport seed its node identity derives from.
885
1060
  *
@@ -911,6 +1086,21 @@ export class SessionNodeManager {
911
1086
  /** In-flight grace timers, keyed session+hash, so a redelivered leaf does not schedule a second
912
1087
  * fetch for the same content — a slow relay must not be turned into a storm against itself. */
913
1088
  #leafFetchTimers = new Map();
1089
+ /**
1090
+ * DOD-M15-REFUSALTERMINAL-1: content hashes refused for a reason no retry can get past — a READ
1091
+ * CACHE over `terminal_content_refusals`, never the record itself.
1092
+ *
1093
+ * ⚠️ **NOT the same fact as `#resolvedContent`, and collapsing them is the trap.** "Resolved"
1094
+ * means we HAVE the content. Terminally refused means we have it and are never accepting it.
1095
+ * Filing one under the other tells the next reader that refused content was delivered.
1096
+ */
1097
+ #terminallyRefused = new Map();
1098
+ /** Sessions whose terminal-refusal rows have been read from the database into the map above.
1099
+ * Nothing ever un-marks content, so a loaded set only grows and can never go stale. */
1100
+ #terminalRefusalsLoaded = new Set();
1101
+ /** DOD-M15-REFUSALTERMINAL-1 review F3: when the load above last FAILED, per session. Bounds the
1102
+ * retry and the ERROR to once a minute instead of once per witnessed leaf. */
1103
+ #terminalRefusalsReadFailedAt = new Map();
914
1104
  /** Test seam: collapse the grace window so a test does not have to wait two real seconds. The
915
1105
  * window itself is covered by its own case. */
916
1106
  #leafFetchGraceMs = LEAF_FETCH_GRACE_MS;
@@ -1324,6 +1514,26 @@ export class SessionNodeManager {
1324
1514
  * entry is removed the moment it is reconciled.
1325
1515
  */
1326
1516
  #unreadableAlgSeen = new Map();
1517
+ /**
1518
+ * `DOD-M15-AUTHORSHIP-ABSENT-1` review H1, widened by `029c` review F4 — the content hashes this
1519
+ * side refused ON THE DIRECT PATH, for any reason, so the park path can say so when the same
1520
+ * message arrives the other way.
1521
+ *
1522
+ * One map rather than one per refusal: what the park path needs to know is "did we turn this
1523
+ * content away and tell somebody so", and the reason is already on the notice.
1524
+ *
1525
+ * **The silence this closes.** A direct-path refusal sends no delivery ACK, so the sender's TTF
1526
+ * backstop parks the message and it arrives through the relay mailbox seconds later — where the
1527
+ * ENVELOPE's signature is what authenticates it, and recovery correctly accepts it. So the
1528
+ * message is delivered, with no per-message proof, moments after the operator was told it was
1529
+ * refused. Nothing tied the two events together, which is the same shape the algorithm refusal
1530
+ * above already had and the same remedy.
1531
+ *
1532
+ * Same bounded shape and the same reason: it is fed entirely by a remote party, so losing an
1533
+ * entry costs one reconciliation line and an unbounded map would be a leak with a peer's hand on
1534
+ * the tap.
1535
+ */
1536
+ #refusedOnDirectPath = new Map();
1327
1537
  // DOD-MSG-4 (strict in-order): the RELAY is the ordering authority (Structure 2). For each
1328
1538
  // message the relay witnesses, it delivers B a (content_hash -> canonical sequence) binding via
1329
1539
  // the leaf_deliver stream. B records it here — keyed #k(agent,session) -> (contentHashHex -> seq)
@@ -1345,6 +1555,18 @@ export class SessionNodeManager {
1345
1555
  * permanently less provable than the identical message that did not, for a reason with nothing to
1346
1556
  * do with authorship.
1347
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();
1348
1570
  #heldContent = new Map();
1349
1571
  // DOD-MSG-4: the relay's high-water canonical sequence for this session — the largest sequence the
1350
1572
  // relay has witnessed (max over leaf_deliver). Keyed #k(agent,session). EXPOSED for the next
@@ -1838,6 +2060,24 @@ export class SessionNodeManager {
1838
2060
  // sealed record so it survives a daemon restart and is readable on the cert-read surface
1839
2061
  // (cello_get_sealed_receipt). JSON string with hex-encoded pubkeys; NULL until sealed.
1840
2062
  // Inline idempotent migration (NOT Flyway — this is the client-side SQLite, AC-011).
2063
+ /**
2064
+ * 033-ACKEMIT — THE SESSION'S GENESIS PREV_ROOT, and it is persisted for ONE reason: a
2065
+ * restart.
2066
+ *
2067
+ * It is a pure function of the two participant keys, the session id and the SESSION
2068
+ * TIMESTAMP — and the timestamp arrives on the directory-signed relay assignment and lives
2069
+ * nowhere else. A session restored from this table after a daemon restart re-registers with
2070
+ * no assignment in hand, so without this column the daemon could not say what the first
2071
+ * message of that session acknowledges, and every send on it would be refused rather than
2072
+ * signed. Deriving is still preferred where the assignment IS in memory; this is what makes
2073
+ * the derivation survive the process.
2074
+ *
2075
+ * NULL for every session opened before this column existed. Those sessions acknowledge
2076
+ * nothing until the counterparty has sent something — they claim position 0 with no hash,
2077
+ * which asserts nothing rather than asserting a position they cannot back — and from the
2078
+ * first leaf they receive they acknowledge content like any other session.
2079
+ */
2080
+ "ALTER TABLE sessions ADD COLUMN genesis_prev_root BLOB",
1841
2081
  "ALTER TABLE sessions ADD COLUMN seal_legibility TEXT",
1842
2082
  "ALTER TABLE sessions ADD COLUMN sealed_root_hex TEXT",
1843
2083
  // M7 legibility-TBS-binding (responder verify): the counterparty's FROST primary (group)
@@ -2111,9 +2351,17 @@ export class SessionNodeManager {
2111
2351
  -- else — which is the whole point of a notarized record.
2112
2352
  --
2113
2353
  -- sender_sig holds one of TWO things, and which one is told by direction:
2114
- -- RECEIVED row -> the Structure-2 signature, stored ONLY after the receiver verified it
2115
- -- against the pubkey inside the sender's own signed bytes
2116
- -- (#recordFrameOrdering). Verified, never claimed.
2354
+ -- RECEIVED row -> the sender's signature over their own Structure-1 bytes, carried on the
2355
+ -- content frame BESIDE those bytes, stored ONLY after the receiver
2356
+ -- verified it against the pubkey inside them (#verifyAuthorshipClaim).
2357
+ -- Verified, never claimed.
2358
+ -- ⚠️ THIS USED TO READ "the Structure-2 signature ... (#recordFrameOrdering)"
2359
+ -- and it named a real place: until DOD-M15-AUTHORSHIP-ABSENT-1 the only
2360
+ -- copy of that signature this side ever saw was the one the RELAY had
2361
+ -- committed at Structure-2 index 3, so a message with no relay record had
2362
+ -- no checkable author at all. Rewritten rather than deleted: an auditor
2363
+ -- reading the old sentence goes looking for Structure-2 bytes that, on a
2364
+ -- relay-degraded message, do not exist.
2117
2365
  -- SENT row -> OUR OWN signature over the Structure-1 bytes we put on the wire, taken
2118
2366
  -- from the submit result. Produced, not verified — there was no
2119
2367
  -- counterparty in the act, so it must NEVER be labelled verified_signature.
@@ -2121,11 +2369,16 @@ export class SessionNodeManager {
2121
2369
  -- ⚠️ self_authored COVERS TWO PROVENANCES, and sender_sig IS NOT NULL is the discriminator.
2122
2370
  -- Named here because it is the same shape this column exists to prevent, one level up: a
2123
2371
  -- provable sent row and an unprovable one share a label, so a reader keying on attribution
2124
- -- alone cannot tell them apart. An unprovable sent row is legitimate — an UNWITNESSED send
2125
- -- never put a Structure 1 on the wire, so there is nothing signed to store — but the reader
2126
- -- has to be told where the distinction lives, or it will be rediscovered as a bug.
2372
+ -- alone cannot tell them apart.
2127
2373
  -- self_authored + sender_sig NOT NULL -> we wrote it and can prove we did
2128
- -- self_authored + sender_sig NULL -> we wrote it; the relay never witnessed it
2374
+ -- self_authored + sender_sig NULL -> we wrote it; no proof was stored for this row
2375
+ --
2376
+ -- ⚠️ THE NULL CASE USED TO READ "the relay never witnessed it", and DOD-M15-AUTHORSHIP-ABSENT-1
2377
+ -- made that false. Every content frame now carries this side's signature over its own
2378
+ -- Structure 1 whether or not a relay witnessed the leaf, so an unwitnessed send is provable
2379
+ -- too. Rewritten rather than deleted: the old sentence is why a NULL here was read as
2380
+ -- ordinary. It is not ordinary now — it means this machine could not sign at all, or the row
2381
+ -- came by a path that carries no proof, and both are worth a second look.
2129
2382
  --
2130
2383
  -- attribution is NOT NULL ON PURPOSE, and it is the load-bearing column. There is a soft
2131
2384
  -- path — session.content.ordering.decode_failed falls back to hash-dedup — that ingests a
@@ -2135,7 +2388,7 @@ export class SessionNodeManager {
2135
2388
  -- nothing distinguishes them. Forcing every writer to name which it is makes silent NULL
2136
2389
  -- impossible rather than merely discouraged.
2137
2390
  sender_pubkey TEXT, -- from INSIDE the sender's signed bytes; NULL unless verified
2138
- sender_sig BLOB, -- the VERIFIED Structure-2 signature; NULL unless verified
2391
+ sender_sig BLOB, -- the VERIFIED sender signature over structure1_cbor (see above); NULL unless verified
2139
2392
  attribution TEXT NOT NULL DEFAULT 'local_session_state', -- verified_signature | self_authored | local_session_state
2140
2393
  PRIMARY KEY (agent_id, session_id, sequence, direction)
2141
2394
  )
@@ -2349,6 +2602,129 @@ export class SessionNodeManager {
2349
2602
  last_at INTEGER NOT NULL,
2350
2603
  PRIMARY KEY (agent_id, session_id, reason)
2351
2604
  )
2605
+ `);
2606
+ /**
2607
+ * DOD-M15-REFUSALTERMINAL-1 — the lifetime refusal count, which `content_refusal_notices` is
2608
+ * NOT and never was.
2609
+ *
2610
+ * `cello_dismiss` DELETEs the notice row (`dismissContentRefusals`), so `notices.count` restarts
2611
+ * at 1 after every dismissal. That is correct for the notice — the operator said "I know" and
2612
+ * the next announcement should describe what happened since — and it is exactly why the number
2613
+ * shown beside it cannot be described as a lifetime figure. Live on 2026-09-04 an inbox reported
2614
+ * `times: 58` for a refusal that had fired tens of thousands of times.
2615
+ *
2616
+ * A separate table rather than a column, because the two have different lifetimes: this one is
2617
+ * never deleted by anything an operator does. Same key, so the read is one LEFT JOIN.
2618
+ */
2619
+ this.#db.exec(`
2620
+ CREATE TABLE IF NOT EXISTS content_refusal_totals (
2621
+ agent_id TEXT NOT NULL,
2622
+ session_id TEXT NOT NULL,
2623
+ reason TEXT NOT NULL,
2624
+ total INTEGER NOT NULL,
2625
+ first_at INTEGER NOT NULL,
2626
+ last_at INTEGER NOT NULL,
2627
+ -- 1 when this row was SEEDED from an existing notice at upgrade rather than counted from
2628
+ -- the first refusal. Its total is then a LOWER BOUND, not a figure, and the drain reports
2629
+ -- it under a different field name so a reader cannot mistake one for the other.
2630
+ seeded INTEGER NOT NULL DEFAULT 0,
2631
+ PRIMARY KEY (agent_id, session_id, reason)
2632
+ )
2633
+ `);
2634
+ /**
2635
+ * ⚠️ **THE BACKFILL, and without it this unit ships the original lie with the new name on it.**
2636
+ *
2637
+ * Review finding 1. A new table is created EMPTY. Every daemon that already has refusal notices
2638
+ * — including the one that produced this incident, whose notice sat at 58 — would report
2639
+ * `times_since_dismissed: 59` beside a `times_total` of **1**, on the very field the guidance
2640
+ * tells an operator to judge severity by. Smaller than the number it exists to dwarf.
2641
+ *
2642
+ * `count` is the best figure available at upgrade and it is a LOWER BOUND: dismissals before
2643
+ * this build deleted history nothing can recover. So the row is marked `seeded` and reported as
2644
+ * "at least", never as a total. A lower bound is a true statement; `total = 1` is not.
2645
+ *
2646
+ * `INSERT OR IGNORE` makes it idempotent and self-healing — it fills only rows that do not
2647
+ * exist, so a real counted total is never overwritten by a seeded one, and running it at every
2648
+ * boot costs one indexed scan of a table bounded by (sessions × reasons).
2649
+ */
2650
+ /**
2651
+ * ⚠️ **AND `CREATE TABLE IF NOT EXISTS` IS A NO-OP AGAINST A TABLE THAT ALREADY EXISTS** —
2652
+ * review F1b, and it is the same hazard `DOD-M12B-INDEX-1` records for `held_content.origin`
2653
+ * three hundred lines above.
2654
+ *
2655
+ * The table shipped one commit earlier WITHOUT `seeded`, and that build ran on a real daemon to
2656
+ * take this unit's live measurement. On that machine the `CREATE` does nothing, the backfill
2657
+ * below names a column that is not there, and the throw comes out of schema init — **the daemon
2658
+ * does not open at all.** The one machine that most needs the backfill is the one it would have
2659
+ * bricked.
2660
+ */
2661
+ try {
2662
+ this.#db.exec("ALTER TABLE content_refusal_totals ADD COLUMN seeded INTEGER NOT NULL DEFAULT 0");
2663
+ }
2664
+ catch (err) {
2665
+ const msg = err instanceof Error ? err.message : String(err);
2666
+ if (!/duplicate column name/i.test(msg))
2667
+ throw err;
2668
+ }
2669
+ this.#db.exec(`
2670
+ INSERT OR IGNORE INTO content_refusal_totals
2671
+ (agent_id, session_id, reason, total, first_at, last_at, seeded)
2672
+ SELECT agent_id, session_id, reason, count, first_at, last_at, 1
2673
+ FROM content_refusal_notices
2674
+ `);
2675
+ /**
2676
+ * ⚠️ **THE INVARIANT: a lifetime total can never be SMALLER than a since-dismissal count.**
2677
+ * Caught on the live daemon, not by review — the inbox read
2678
+ * `times_since_dismissed: 78, times_total: 12`.
2679
+ *
2680
+ * `INSERT OR IGNORE` above only fills rows that are ABSENT. A row that already exists but began
2681
+ * counting AFTER the notice did — the totals table shipped one commit before `seeded`, so its
2682
+ * rows default to 0 and claim to be exact — is left alone, and then presents a partial tally as
2683
+ * a lifetime figure. Smaller than the number beside it, which is the tell.
2684
+ *
2685
+ * `count` resets on dismissal and `total` does not, so in healthy operation `total >= count`
2686
+ * always. `count > total` therefore means one thing only: this row's total did not start at the
2687
+ * beginning. Repaired to the best floor available and marked `seeded`, because that is what it
2688
+ * is. Runs at every boot — it is also the repair for a totals write that failed while the
2689
+ * notice's succeeded.
2690
+ */
2691
+ this.#db.exec(`
2692
+ UPDATE content_refusal_totals
2693
+ SET total = (SELECT n.count FROM content_refusal_notices n
2694
+ WHERE n.agent_id = content_refusal_totals.agent_id
2695
+ AND n.session_id = content_refusal_totals.session_id
2696
+ AND n.reason = content_refusal_totals.reason),
2697
+ seeded = 1
2698
+ WHERE EXISTS (SELECT 1 FROM content_refusal_notices n
2699
+ WHERE n.agent_id = content_refusal_totals.agent_id
2700
+ AND n.session_id = content_refusal_totals.session_id
2701
+ AND n.reason = content_refusal_totals.reason
2702
+ AND n.count > content_refusal_totals.total)
2703
+ `);
2704
+ /**
2705
+ * DOD-M15-REFUSALTERMINAL-1 — content this agent will never accept, so the daemon stops going
2706
+ * to fetch it.
2707
+ *
2708
+ * **DURABLE BECAUSE THE DEFECT CROSSED RESTARTS.** The 62-hour loop spanned several `cello
2709
+ * login` cycles; a marker held in a `Set` on the manager would have passed every test and
2710
+ * shipped nothing.
2711
+ *
2712
+ * NOT the `'quarantined'` transcript row, which is the natural candidate and does not work: it
2713
+ * is keyed on the BYTES, and the fetch scheduler is keyed on the content hash the sender
2714
+ * committed to. On the two refusals where those provably differ (a tamper, an algorithm we
2715
+ * cannot read) the row cannot answer the question this table is asked.
2716
+ *
2717
+ * Keyed on `agent_id` — the stable key. `agent_name` is a display label.
2718
+ */
2719
+ this.#db.exec(`
2720
+ CREATE TABLE IF NOT EXISTS terminal_content_refusals (
2721
+ agent_id TEXT NOT NULL,
2722
+ session_id TEXT NOT NULL,
2723
+ content_hash TEXT NOT NULL,
2724
+ reason TEXT NOT NULL,
2725
+ marked_at INTEGER NOT NULL,
2726
+ PRIMARY KEY (agent_id, session_id, content_hash)
2727
+ )
2352
2728
  `);
2353
2729
  this.#db.exec(`
2354
2730
  CREATE TABLE IF NOT EXISTS content_refusal_reads (
@@ -3467,6 +3843,25 @@ export class SessionNodeManager {
3467
3843
  * a reason the caller is expected to log.
3468
3844
  */
3469
3845
  #quarantineRefusedContent(agentName, sessionId, reason, content, contentHashHex, opts) {
3846
+ const sequence = this.#retainRefusedContent(agentName, sessionId, reason, content, contentHashHex, opts);
3847
+ /**
3848
+ * DOD-M15-REFUSALTERMINAL-1 — **THE FUNNEL, and the reason it lives here.**
3849
+ *
3850
+ * Every refusal that retains evidence passes through this method carrying its reason and its
3851
+ * content hash, so this is the one place where "which reasons stop the work" can be a LIST
3852
+ * rather than a decision copied into seven branches. `TERMINAL_REFUSAL_REASONS` decides; the
3853
+ * six other reasons that reach here — a hash mismatch, an unreadable algorithm, a missing salt,
3854
+ * an unresolved sender, an orphaned session, a terminal screen block — all keep retrying, and
3855
+ * each of them can succeed on a later attempt.
3856
+ *
3857
+ * AFTER the retention, deliberately: the evidence has to exist before anything stops going to
3858
+ * look for the message.
3859
+ */
3860
+ this.#considerTerminalRefusal(agentName, sessionId, contentHashHex, reason);
3861
+ return sequence;
3862
+ }
3863
+ /** DOD-M15-REFUSEDEVIDENCE-1: the retention itself. Reached only through the funnel above. */
3864
+ #retainRefusedContent(agentName, sessionId, reason, content, contentHashHex, opts) {
3470
3865
  if (!this.#db)
3471
3866
  return null;
3472
3867
  try {
@@ -3835,7 +4230,9 @@ export class SessionNodeManager {
3835
4230
  const sr = this.#standingReceivers.get(agentName);
3836
4231
  if (!sr)
3837
4232
  return "absent";
3838
- if (sr.hasReservation && sr.relayPeerId !== undefined)
4233
+ // AT LEAST ONE. Holding two circuits and losing one leaves the agent perfectly dialable, so it
4234
+ // is not "retrying" — reporting it as such sends an operator hunting a fault that is not there.
4235
+ if (sr.relayPeerIds.length > 0)
3839
4236
  return "reserved";
3840
4237
  const retry = this.#srReservationRetry.get(agentName);
3841
4238
  return retry !== undefined && retry.attempts > SR_RESERVATION_MAX_RETRIES ? "unreachable" : "retrying";
@@ -3858,6 +4255,21 @@ export class SessionNodeManager {
3858
4255
  * counterparty_session_* fields. Read-only — does NOT consume the standing receiver
3859
4256
  * (unlike acceptSession, which hands it off).
3860
4257
  */
4258
+ /**
4259
+ * 032-RELAYSPREAD — would this receiver ADMIT an inbound dial from this relay?
4260
+ *
4261
+ * The gater's inbound carve-out is the security-sensitive half of the spread: only relays whose
4262
+ * own reservation is confirmed held earn it, so a directory that merely NAMES a relay cannot dial
4263
+ * in behind the gate. Nothing could observe that from outside the manager, and the review found
4264
+ * the consequence: substituting the CANDIDATE list for the held list at the `setReservedRelayPeers`
4265
+ * call kept every test in the unit green while shipping exactly that hole. A guard whose wiring
4266
+ * cannot be observed is a guard nothing can test.
4267
+ *
4268
+ * Reads the live gater rather than a copy, so it cannot drift from what the gate actually does.
4269
+ */
4270
+ isRelayCarvedOutInbound(agentName, relayPeerId) {
4271
+ return this.#standingReceivers.get(agentName)?.gater.holdsInboundCarveOut(relayPeerId) ?? false;
4272
+ }
3861
4273
  getStandingReceiverInfo(agentName) {
3862
4274
  // DOD-LOOP-1: the initiator advertises ITS OWN agent's standing receiver, which it then reuses
3863
4275
  // as the session node — so the advertised endpoint matches the node the counterparty dials.
@@ -4431,8 +4843,22 @@ export class SessionNodeManager {
4431
4843
  this.#relayClients.set(clientKey, client);
4432
4844
  }
4433
4845
  const sessionIdHexForRelay = Buffer.from(relay.sessionIdBytes).toString("hex");
4434
- client.registerSession(sessionIdHexForRelay, node, this.#relayLeafHandler(agentName, sessionId, correlationId), relay.assignment);
4846
+ /**
4847
+ * ⚠️ THE GENESIS IS WRITTEN BEFORE THE REGISTRATION, NOT AFTER — review F10.
4848
+ *
4849
+ * `#sessionGenesisPrevRoot` reads the entry's assignment first and the stored column second,
4850
+ * and BOTH were still unset at this line: the entry's assignment is set below and the column
4851
+ * is written below that. So the argument was always `undefined` here on a first attach, and
4852
+ * the seed only survived because `registerSession` falls back to deriving one from the
4853
+ * assignment it is handed. That is a dead argument standing next to a live fallback, which
4854
+ * reads as deliberate and is the shape a later edit removes the wrong half of.
4855
+ */
4435
4856
  const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
4857
+ if (entry)
4858
+ entry.relayAssignment = relay.assignment;
4859
+ if (relay.assignment)
4860
+ this.#persistGenesisPrevRoot(agentName, sessionId, relay.assignment);
4861
+ client.registerSession(sessionIdHexForRelay, node, this.#relayLeafHandler(agentName, sessionId, correlationId), relay.assignment, this.#sessionGenesisPrevRoot(agentName, sessionId));
4436
4862
  if (entry) {
4437
4863
  entry.relayClient = client;
4438
4864
  entry.relaySessionIdBytes = relay.sessionIdBytes;
@@ -4441,7 +4867,8 @@ export class SessionNodeManager {
4441
4867
  entry.relayPeerId = relay.relayPeerId;
4442
4868
  entry.relayAddrs = relay.relayAddrs;
4443
4869
  // Review H1: the dial path needs the credential in hand, not just the endpoint.
4444
- entry.relayAssignment = relay.assignment;
4870
+ // (Set above, before `registerSession`, so the genesis lookup it does has something to
4871
+ // find — see the note there.)
4445
4872
  // MSG-2 startup-flush: also PERSIST it, so a restart's crash-backstop flush (which runs
4446
4873
  // before the in-memory entry exists) can deposit un-acked content to the same relay.
4447
4874
  try {
@@ -4557,6 +4984,10 @@ export class SessionNodeManager {
4557
4984
  }
4558
4985
  // registerSession presents the assignment eagerly (see its own comment). No leaf handler: this
4559
4986
  // relay is not witnessing the session, it only needs the binding that authorizes the dial.
4987
+ // 033-ACKEMIT: no genesis is passed and none is needed. This client is not witnessing the
4988
+ // session — it never submits — and `registerSession` derives a seed from the assignment
4989
+ // anyway. Reaching into the session record for one here would also be reaching with the RELAY
4990
+ // session id, which is not the key that record is stored under.
4560
4991
  client.registerSession(sessionIdHex, node, undefined, relay.assignment);
4561
4992
  this.#logger.info("session.relay.assignment.presented_to_reservation_relay", {
4562
4993
  agentName,
@@ -5326,7 +5757,20 @@ export class SessionNodeManager {
5326
5757
  // session ends, most sharply for `session_committed` — a refusal that exists only because the
5327
5758
  // session was already sealed. Dropping them at seal would delete exactly the ones a sealed
5328
5759
  // session produces. Growth is one row per (session, reason), i.e. proportional to `sessions`.
5760
+ /**
5761
+ * DOD-M15-REFUSALTERMINAL-1 review F7 — named because this list is the documented teardown set.
5762
+ *
5763
+ * `#terminallyRefused` and `#terminalRefusalsLoaded` are a READ CACHE over
5764
+ * `terminal_content_refusals` and are dropped here with everything else in-memory; the durable
5765
+ * rows stay, for the same reason the notices do — the question they answer outlives the
5766
+ * session, and a fresh check reloads them on demand. `#terminalRefusalsReadFailedAt` goes too,
5767
+ * so a torn-down session's back-off does not delay the first read after it is revived.
5768
+ */
5769
+ this.#terminallyRefused.delete(key);
5770
+ this.#terminalRefusalsLoaded.delete(key);
5771
+ this.#terminalRefusalsReadFailedAt.delete(key);
5329
5772
  this.#unreadableAlgSeen.delete(key);
5773
+ this.#refusedOnDirectPath.delete(key);
5330
5774
  this.#responderSealSubmitted.delete(key);
5331
5775
  // DOD-MSG-4: drop the strict-in-order bookkeeping (witness map, held plaintext, high-water)
5332
5776
  // so a torn-down session retains no stale ordering state or buffered plaintext.
@@ -6802,6 +7246,76 @@ export class SessionNodeManager {
6802
7246
  * on first access (so it survives a restart — AC-007). Never returns null;
6803
7247
  * an unknown session yields an empty tree.
6804
7248
  */
7249
+ /**
7250
+ * The session's genesis prev_root — what its FIRST message acknowledges, before anything has been
7251
+ * received (033-ACKEMIT).
7252
+ *
7253
+ * DERIVED FIRST, STORED SECOND — and this docblock used to say "derived, never stored", which
7254
+ * stopped being true inside this same unit. Rewritten rather than deleted: a reader who believed
7255
+ * the first sentence would delete the column read below as redundant, and take the restart case
7256
+ * with it.
7257
+ *
7258
+ * The live assignment is authoritative, because it is the thing the value is defined by. The
7259
+ * stored column covers the one case the derivation cannot: a session restored after a restart
7260
+ * re-registers with no assignment, and the session TIMESTAMP the genesis needs lives nowhere
7261
+ * else.
7262
+ *
7263
+ * `undefined` when neither is available. The callers do not paper over that — they say, in the
7264
+ * log and in the claim itself, that this session acknowledges nothing yet.
7265
+ */
7266
+ #sessionGenesisPrevRoot(agentName, sessionId) {
7267
+ const assignment = this.#activeNodes.get(this.#k(agentName, sessionId))?.relayAssignment;
7268
+ if (assignment) {
7269
+ return computeGenesisPrevRoot(assignment.participantA, assignment.participantB, Uint8Array.from(Buffer.from(sessionId, "hex")), assignment.sessionTimestamp);
7270
+ }
7271
+ /**
7272
+ * THE RESTART CASE. A session restored from the database re-registers with no assignment, so
7273
+ * the derivation above has nothing to work from and the stored copy is the only answer. Read
7274
+ * second, never first: the live assignment is authoritative, and a stored value that ever
7275
+ * disagreed with it would be the more dangerous of the two to prefer.
7276
+ */
7277
+ const row = this.#db
7278
+ ?.prepare("SELECT genesis_prev_root FROM sessions WHERE agent_id = ? AND session_id = ?")
7279
+ .get(this.#requireAgentId(agentName), sessionId);
7280
+ const stored = row?.genesis_prev_root;
7281
+ const bytes = stored instanceof Uint8Array ? stored : Buffer.isBuffer(stored) ? new Uint8Array(stored) : null;
7282
+ // A stored value of the wrong width is not a genesis. Refusing it here sends the caller down its
7283
+ // own named refusal, which is a better outcome than signing an acknowledgement of 17 bytes.
7284
+ return bytes && bytes.length === 32 ? bytes : undefined;
7285
+ }
7286
+ /**
7287
+ * Persist the session's genesis prev_root, once, at the moment the assignment arrives.
7288
+ *
7289
+ * `WHERE genesis_prev_root IS NULL` rather than a plain update: the value cannot legitimately
7290
+ * change for the life of a session, so the second writer is either redundant or wrong, and the
7291
+ * first write is the one derived closest to the assignment that opened the session.
7292
+ */
7293
+ #persistGenesisPrevRoot(agentName, sessionId, assignment) {
7294
+ if (!this.#db)
7295
+ return;
7296
+ try {
7297
+ const genesis = computeGenesisPrevRoot(assignment.participantA, assignment.participantB, Uint8Array.from(Buffer.from(sessionId, "hex")), assignment.sessionTimestamp);
7298
+ this.#db
7299
+ .prepare("UPDATE sessions SET genesis_prev_root = ? WHERE agent_id = ? AND session_id = ? AND genesis_prev_root IS NULL")
7300
+ .run(Buffer.from(genesis), this.#requireAgentId(agentName), sessionId);
7301
+ }
7302
+ catch (err) {
7303
+ /**
7304
+ * LOUD, AND IT DOES NOT BLOCK. Losing this row costs the session its acknowledgements after a
7305
+ * restart — sends are then refused by name until the counterparty speaks — and that is a far
7306
+ * smaller harm than failing the session open that is in progress. Reported at ERROR because
7307
+ * the failure is invisible until a restart that may be days away.
7308
+ */
7309
+ this.#logger.error("session.genesis.persist.failed", {
7310
+ agentName, sessionId,
7311
+ error: err instanceof Error ? err.message : String(err),
7312
+ impact: "this session's starting point was not written to the database. Everything works until " +
7313
+ "this daemon restarts; after that, a send on this session is refused until the " +
7314
+ "counterparty has sent something, because the daemon cannot say what its first message " +
7315
+ "acknowledges.",
7316
+ });
7317
+ }
7318
+ }
6805
7319
  getSessionTree(agentName, sessionId) {
6806
7320
  const key = this.#k(agentName, sessionId);
6807
7321
  const cached = this.#trees.get(key);
@@ -6997,7 +7511,7 @@ export class SessionNodeManager {
6997
7511
  entry.extraRelayClientKeys = [...(entry.extraRelayClientKeys ?? []), clientKey];
6998
7512
  }
6999
7513
  // No leaf handler: this relay is not witnessing the session, it only needs the binding.
7000
- client.registerSession(sessionIdHex, entry.node, undefined, assignment);
7514
+ client.registerSession(sessionIdHex, entry.node, undefined, assignment, this.#sessionGenesisPrevRoot(agentName, sessionId));
7001
7515
  const recorded = await client.recordAssignmentAndWait(entry.node, sessionIdHex);
7002
7516
  if (recorded) {
7003
7517
  this.#logger.info("session.transport.dial_authorized", {
@@ -7107,6 +7621,12 @@ export class SessionNodeManager {
7107
7621
  // the leaf_deliver witness stream / arrival order.
7108
7622
  let orderingS1;
7109
7623
  let orderingS2;
7624
+ /**
7625
+ * `DOD-M15-AUTHORSHIP-ABSENT-1`: the sender's signature over `orderingS1`, when the relay
7626
+ * witnessed this leaf. Undefined when it did not — and the frame builder below then signs a
7627
+ * Structure 1 of its own rather than shipping a frame with nothing to check.
7628
+ */
7629
+ let orderingSig;
7110
7630
  /**
7111
7631
  * DOD-M15-SEALWIRE-1 bullet 5, SENT half. Our own Ed25519 signature over `orderingS1`.
7112
7632
  *
@@ -7135,6 +7655,11 @@ export class SessionNodeManager {
7135
7655
  if (witnessed.ok) {
7136
7656
  orderingS1 = witnessed.structure1_cbor;
7137
7657
  orderingS2 = witnessed.structure2_cbor;
7658
+ // `DOD-M15-AUTHORSHIP-ABSENT-1`: the signature that goes ON THE FRAME beside `orderingS1`.
7659
+ // Captured here, next to the bytes it signs, because a signature assigned anywhere else
7660
+ // could end up beside a different Structure 1 — a proof next to the wrong signed bytes is
7661
+ // worse than no proof, since it looks checkable and fails.
7662
+ orderingSig = witnessed.sender_signature;
7138
7663
  /**
7139
7664
  * PAIRED WITH THE BYTES IT SIGNS, in one place, so the two can never be assigned apart.
7140
7665
  *
@@ -7154,8 +7679,10 @@ export class SessionNodeManager {
7154
7679
  * tell "the relay never witnessed this" from "we witnessed it, held the proof, and
7155
7680
  * dropped it decoding our own bytes."
7156
7681
  *
7157
- * And the asymmetry with the received half is the argument. `#recordFrameOrdering` is
7158
- * soft because the COUNTERPARTY supplied those bytes an absence we cannot resolve.
7682
+ * And the asymmetry with the received half is the argument. The received half is soft
7683
+ * about a missing ORDERING record (`#recordFrameOrdering`) because the COUNTERPARTY
7684
+ * supplied those bytes — an absence we cannot resolve. It is not soft about a missing
7685
+ * authorship proof any more; `DOD-M15-AUTHORSHIP-ABSENT-1` refuses that outright.
7159
7686
  * Here **we produced them**, in `session-relay-client.ts`, moments earlier. A failure
7160
7687
  * means our own encoder and decoder disagree: an internal invariant break that would
7161
7688
  * strip authorship from every sent row for the life of the process. Soft is still right
@@ -7204,7 +7731,7 @@ export class SessionNodeManager {
7204
7731
  * two different submits would all have been persisted as a row that **looks checkable
7205
7732
  * to an auditor and fails** — strictly worse than the honest unproven row it replaced.
7206
7733
  *
7207
- * The received half has always done this (`#recordFrameOrdering` verifies before
7734
+ * The received half has always done this (`#verifyAuthorshipClaim` verifies before
7208
7735
  * storing and treats a failure as fatal). The sent half did not, and every ingredient
7209
7736
  * was already in scope on this line.
7210
7737
  *
@@ -7351,6 +7878,43 @@ export class SessionNodeManager {
7351
7878
  ...(witnessed.detail === undefined ? {} : { detail: witnessed.detail }),
7352
7879
  correlationId,
7353
7880
  });
7881
+ /**
7882
+ * ─── 033-ACKEMIT: THIS ONE REACHES THE OPERATOR, NOT JUST THE LOG ─────────────────────
7883
+ *
7884
+ * Every other refusal on this branch is an availability answer — the relay is busy, the
7885
+ * session is not recorded, the stream died — and the send degrades to unwitnessed, which
7886
+ * is the documented path. These two are not availability. The WITNESS is telling us that
7887
+ * the acknowledgement this daemon signed disagrees with the record, and that is a
7888
+ * statement about the integrity of the conversation.
7889
+ *
7890
+ * `logger.warn` followed by a bare assignment is the exact shape Invariant 2's recurring
7891
+ * box names — a guard that fires correctly into a file nobody opens. The named surface is
7892
+ * `noteContentRefusal`, the same one every inbound refusal in this file uses, so it lands
7893
+ * where the operator already looks for "something was rejected and here is why".
7894
+ *
7895
+ * It does NOT stop the send. The relay refused to witness this leaf, so the message
7896
+ * degrades to unwitnessed exactly as any other refusal does and the operator keeps their
7897
+ * conversation; what changes is that they are told the record has stopped agreeing with
7898
+ * itself, at the moment it happens, instead of discovering it at the seal.
7899
+ */
7900
+ if (witnessed.reason === "ack_hash_mismatch" || witnessed.reason === "ack_hash_unverifiable") {
7901
+ const relayFault = witnessed.reason === "ack_hash_unverifiable";
7902
+ /**
7903
+ * THE SENTENCES LIVE IN `refusal-reasons.ts` — 033-ACKEMIT review F6. They were inline
7904
+ * here, behind a real relay answering a real refusal, so nothing could test them; and
7905
+ * the one that was wrong (a remedy naming a relay handover this system does not have)
7906
+ * was wrong for as long as that lasted.
7907
+ */
7908
+ const { impact, guidance } = relayAckHashRefusalNotice(relayFault, this.#mailboxRouteAvailable(agentName));
7909
+ this.#logger.error("session.relay.ack_hash.refused", {
7910
+ agentName, sessionId, correlationId, reason: witnessed.reason,
7911
+ ...(witnessed.detail === undefined ? {} : { detail: witnessed.detail }),
7912
+ impact, guidance,
7913
+ });
7914
+ this.noteContentRefusal(agentName, sessionId, witnessed.reason, {
7915
+ kind: REFUSAL_KINDS.REFUSED, impact, guidance,
7916
+ });
7917
+ }
7354
7918
  relayRefusal = witnessed.reason;
7355
7919
  }
7356
7920
  }
@@ -7372,6 +7936,72 @@ export class SessionNodeManager {
7372
7936
  // connection). See the note on #handleContentStream's finally.
7373
7937
  let sendStream;
7374
7938
  try {
7939
+ /**
7940
+ * ─── EVERY FRAME CARRIES ITS OWN PROOF — `DOD-M15-AUTHORSHIP-ABSENT-1` ────────────────────
7941
+ *
7942
+ * Structure 1 used to be built and signed INSIDE the relay submit, so a send the relay never
7943
+ * witnessed put a frame on the wire with nothing on it to check — and the receiver, having no
7944
+ * proof to compare, ingested it and attributed it anyway. There was always something to sign;
7945
+ * nobody signed it.
7946
+ *
7947
+ * ⚠️ `structure2_cbor` IS DROPPED WHEN WE BUILD OUR OWN, and that pairing is load-bearing. The
7948
+ * relay's record commits its copy of the sender's signature to the EXACT Structure 1 that was
7949
+ * submitted; put it beside a Structure 1 built here (different timestamp, different
7950
+ * last_seen_seq) and the receiver's cross-check fails against bytes that were never altered —
7951
+ * a freeze on an honest message. The two travel together or the relay's half does not travel.
7952
+ *
7953
+ * ⚠️ SIGNED HERE, BEFORE THE SESSION KEY IS READ, and the order is load-bearing rather than
7954
+ * tidy. `sessionKey` below is read once and used to seal the body several `await`s later; a
7955
+ * key agreed with the counterparty inside that window leaves this side sealing under the key
7956
+ * it captured while the far side has already moved on, and every message is refused as
7957
+ * `decrypt_failed`. Signing costs two awaits, and putting them between the read and the seal
7958
+ * widened that window enough to break the live two-node round trip. Measured, not reasoned
7959
+ * about: seam-3 went red and both daemons logged `session.key.agreed` before the refusal.
7960
+ */
7961
+ let frameS1 = orderingS1;
7962
+ let frameSig = orderingSig;
7963
+ let frameS2 = orderingS2;
7964
+ if (frameS1 === undefined || frameSig === undefined) {
7965
+ const own = await this.#signOwnContentClaim(agentName, sessionId, entry, contentHash);
7966
+ frameS1 = own.structure1;
7967
+ frameSig = own.signature;
7968
+ frameS2 = undefined;
7969
+ /**
7970
+ * ⚠️ **AND OUR OWN TRANSCRIPT ROW GETS THE PROOF TOO** — review M3.
7971
+ *
7972
+ * `sentAuthorship` is set above only when the relay witnessed the leaf, because that was
7973
+ * the only path that ever produced a signature. This path produces one — and dropping it
7974
+ * here would leave the counterparty's transcript able to prove we wrote the message while
7975
+ * ours recorded `self_authored` with a NULL signature. That is exactly the half-provable
7976
+ * transcript `DOD-M15-SEALWIRE-1` bullet 5 exists to close, reappearing on the one path
7977
+ * that had no proof to lose before this unit and has one now.
7978
+ *
7979
+ * VERIFIED BEFORE IT IS STORED, the same discipline as the witnessed path: the pubkey comes
7980
+ * from INSIDE the bytes we signed, never from an agent lookup, and a pair that does not
7981
+ * verify is dropped loudly rather than persisted as a row that looks checkable and fails.
7982
+ * A failure here means this daemon's own encoder and decoder disagree.
7983
+ */
7984
+ const s1Decoded = decodeStructure1(own.structure1);
7985
+ const pk = s1Decoded.ok ? s1Decoded.fields.senderPubkey : undefined;
7986
+ if (!s1Decoded.ok) {
7987
+ this.#logger.warn("session.sent.authorship.unavailable", {
7988
+ agentName, sessionId, correlationId, reason: "own_structure1_decode_failed",
7989
+ structure1Reason: s1Decoded.reason,
7990
+ impact: "this sent message is recorded with attribution 'self_authored' and NO signature, so the row asserts its author rather than proving one — even though this side signed the claim it put on the wire.",
7991
+ guidance: "We produced these bytes ourselves moments ago, so a decode failure here means this daemon's own encoder and decoder disagree. Treat it as an internal invariant break, not a peer problem.",
7992
+ });
7993
+ }
7994
+ else if (!verify(pk, own.structure1, own.signature)) {
7995
+ this.#logger.warn("session.sent.authorship.unavailable", {
7996
+ agentName, sessionId, correlationId, reason: "own_pair_does_not_verify",
7997
+ impact: "this sent message is recorded with attribution 'self_authored' and NO signature. The signature this side just produced does not verify against the key inside the bytes it signed.",
7998
+ guidance: "An internal invariant break: the signer and the encoder disagree. The message still went out; only the local proof was dropped.",
7999
+ });
8000
+ }
8001
+ else {
8002
+ sentAuthorship = { senderPubkey: pk, senderSig: own.signature };
8003
+ }
8004
+ }
7375
8005
  /**
7376
8006
  * 🚨 NO KEY, NO DIRECT SEND — `DOD-M15-EPHEMERAL-AUTH-1`, and there is no plaintext fallback.
7377
8007
  *
@@ -7383,11 +8013,12 @@ export class SessionNodeManager {
7383
8013
  * frame and a system that "carries on, degraded" gives up the body while the operator reads a
7384
8014
  * warning they have learned to scroll past. That is why this is a throw and not a warning.
7385
8015
  */
7386
- const encState = this.#contentEncryptionState(agentName, sessionId);
7387
- if (encState.key === null) {
7388
- throw new Error(`content_not_encryptable: ${encState.reason} — ${CONTENT_ENCRYPTION_GUIDANCE[encState.reason]}`);
8016
+ // FAIL FAST, before a stream is opened for a message that cannot go out. The key this reads is
8017
+ // NOT the one that seals the body — see the read beside `sealSessionContent` below.
8018
+ const preflight = this.#contentEncryptionState(agentName, sessionId);
8019
+ if (preflight.key === null) {
8020
+ throw new Error(`content_not_encryptable: ${preflight.reason} — ${CONTENT_ENCRYPTION_GUIDANCE[preflight.reason]}`);
7389
8021
  }
7390
- const sessionKey = encState.key;
7391
8022
  const stream = await this.#openContentStream(agentName, sessionId, entry, correlationId);
7392
8023
  sendStream = stream;
7393
8024
  // AC-001/AC-003: arm the TTF tracking BEFORE the frame goes on the wire. The
@@ -7414,8 +8045,36 @@ export class SessionNodeManager {
7414
8045
  * THE WIRE COPY. `content_hash` above was computed over the PLAINTEXT and stays that way: the
7415
8046
  * transcript, the seal and the salted hash all depend on it meaning what it means today, and
7416
8047
  * the receiver decrypts before it verifies.
8048
+ *
8049
+ * ⚠️ **THE KEY IS READ HERE, ADJACENT TO THE SEAL, AND IT USED TO BE READ FAR ABOVE.**
8050
+ *
8051
+ * The read sat before `#openContentStream`, so the captured key crossed an `await` — several,
8052
+ * once this unit added signing — before it sealed anything. A session key agreed with the
8053
+ * counterparty inside that window left this side sealing under the key it captured while the
8054
+ * far side had already moved on, and **every message was then refused as `decrypt_failed`**:
8055
+ * a false tamper report on honest content, on both sides, for the life of the session.
8056
+ *
8057
+ * Not theoretical. It is what reddened four live-libp2p fixtures when identity keys were first
8058
+ * wired into them — both daemons logged `session.key.agreed`, and the refusal followed.
8059
+ *
8060
+ * A window cannot be closed by reasoning about who wins it, only by removing it: nothing may
8061
+ * run between the read and the seal. The preflight above stays because failing before a stream
8062
+ * is opened is worth one extra read.
8063
+ *
8064
+ * ⚠️ THIS CLOSES THE LOCAL WINDOW AND NOT THE CLASS (review F8). The sender still seals at T
8065
+ * and the receiver still decrypts at T+flight, so a re-key landing in THAT interval produces
8066
+ * the same false tamper report. What is removed is the part this side controls; the rest is a
8067
+ * property of there being two machines.
7417
8068
  */
7418
- const wireBody = sealSessionContent(sessionKey, content);
8069
+ const sealState = this.#contentEncryptionState(agentName, sessionId);
8070
+ if (sealState.key === null) {
8071
+ // Reachable only if the key vanished between the preflight and here — a re-key or a
8072
+ // teardown mid-send. Named as its own cause rather than reusing the preflight's, so a log
8073
+ // reader can tell "never had one" from "had one and lost it while sending".
8074
+ throw new Error(`content_not_encryptable: ${sealState.reason} — the key was present when this send began ` +
8075
+ `and gone by the time it sealed. ${CONTENT_ENCRYPTION_GUIDANCE[sealState.reason]}`);
8076
+ }
8077
+ const wireBody = sealSessionContent(sealState.key, content);
7419
8078
  const frame = encodeCbor({
7420
8079
  type: "content_frame",
7421
8080
  session_id: sessionId,
@@ -7428,9 +8087,12 @@ export class SessionNodeManager {
7428
8087
  // DOD-MSG-4 (self-ordering): the relay's signed ordering record, so the receiver verifies +
7429
8088
  // orders from the frame ALONE (no dependence on the separate leaf_deliver witness timing).
7430
8089
  // structure1_cbor = sender-signed bytes (verify); structure2_cbor = relay's committed seq +
7431
- // prev_root (order). Omitted if the relay was unreachable — receiver falls back to the witness.
7432
- structure1_cbor: orderingS1,
7433
- structure2_cbor: orderingS2,
8090
+ // prev_root (order). Structure 2 is omitted if the relay was unreachable — the receiver
8091
+ // falls back to the witness stream for POSITION. Structure 1 and its signature are never
8092
+ // omitted: `DOD-M15-AUTHORSHIP-ABSENT-1`, and a frame without them is refused on arrival.
8093
+ structure1_cbor: frameS1,
8094
+ sender_signature: frameSig,
8095
+ structure2_cbor: frameS2,
7434
8096
  // DOD-M15-SEALWIRE-1 part B2b: HOW `content_hash` was produced. An older peer ignores an
7435
8097
  // unknown CBOR key, so emitting it is safe for every build in existence; a newer one reads
7436
8098
  // it and verifies under the named algorithm instead of assuming.
@@ -7462,7 +8124,17 @@ export class SessionNodeManager {
7462
8124
  return { ok: true, delivered: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(sentAuthorship === undefined ? {} : { authorship: sentAuthorship }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
7463
8125
  }
7464
8126
  catch (err) {
7465
- this.#markSessionImpaired(agentName, sessionId, { cause: "direct_send", error: err instanceof Error ? err.message : String(err), correlationId });
8127
+ /**
8128
+ * Review F7: a content-key fault is NOT a transport fault, and labelling it `direct_send`
8129
+ * points the operator at the connection when the connection is fine. `content_not_encryptable`
8130
+ * is thrown twice above — once before the stream is opened, once at the seal — and both are
8131
+ * about this machine's key state.
8132
+ */
8133
+ const failure = err instanceof Error ? err.message : String(err);
8134
+ this.#markSessionImpaired(agentName, sessionId, {
8135
+ cause: failure.startsWith("content_not_encryptable") ? "content_key" : "direct_send",
8136
+ error: failure, correlationId,
8137
+ });
7466
8138
  if (sendStream !== undefined) {
7467
8139
  try {
7468
8140
  sendStream.abort(err instanceof Error ? err : new Error(String(err)));
@@ -8041,6 +8713,27 @@ export class SessionNodeManager {
8041
8713
  }
8042
8714
  byHash.set(Buffer.from(contentHash).toString("hex"), declaredAlg);
8043
8715
  }
8716
+ /**
8717
+ * Remember that THIS frame was refused for carrying no usable proof of who wrote it, so the park
8718
+ * path can say so when the same content arrives through the relay mailbox — review H1.
8719
+ *
8720
+ * Bounded for the same reason and by the same cap as `#noteUnreadableAlgFrame`: a peer sending
8721
+ * unprovable frames feeds this map, so it drops the oldest rather than growing.
8722
+ */
8723
+ #noteRefusedOnDirectPath(agentName, sessionId, contentHash) {
8724
+ const key = this.#k(agentName, sessionId);
8725
+ let hashes = this.#refusedOnDirectPath.get(key);
8726
+ if (!hashes) {
8727
+ hashes = new Set();
8728
+ this.#refusedOnDirectPath.set(key, hashes);
8729
+ }
8730
+ if (hashes.size >= MAX_UNREADABLE_ALG_FRAMES) {
8731
+ const oldest = hashes.values().next();
8732
+ if (!oldest.done)
8733
+ hashes.delete(oldest.value);
8734
+ }
8735
+ hashes.add(Buffer.from(contentHash).toString("hex"));
8736
+ }
8044
8737
  /**
8045
8738
  * ─── DOD-M15-NO-SILENT-REFUSAL-1: refusals the RECEIVING operator can actually see ────────────
8046
8739
  *
@@ -8090,16 +8783,61 @@ export class SessionNodeManager {
8090
8783
  throw new Error("database is not open");
8091
8784
  const agentId = this.#requireAgentId(agentName);
8092
8785
  const now = Date.now();
8093
- // `count` grows on conflict; impact and guidance are refreshed, because a later refusal of the
8094
- // same reason may know more than the first (the salt branch has four causes and names them).
8095
- this.#db
8096
- .prepare(`INSERT INTO content_refusal_notices
8786
+ /**
8787
+ * ⚠️ **BOTH WRITES OR NEITHER review F6, and the comment this replaces was wrong.**
8788
+ *
8789
+ * It argued that putting the totals insert in the same `try` made the two "fail together".
8790
+ * It does not: each `.run()` autocommits, so the notice could persist and the total throw —
8791
+ * and the `catch` then ALSO writes an in-memory fallback entry for a notice that is already
8792
+ * in the table. The drain unions the two halves without deduplicating on (session, reason),
8793
+ * so the operator would see the same refusal TWICE, once with a lifetime figure and once
8794
+ * without, one of them blaming a disk fault. Before this unit a single statement made that
8795
+ * state impossible; the second statement is what created it.
8796
+ *
8797
+ * `ROLLBACK` is best-effort because SQLite may have aborted the transaction already — the
8798
+ * same shape `agent-id-migration.ts` uses — and it must never mask the original error.
8799
+ */
8800
+ this.#db.exec("BEGIN");
8801
+ try {
8802
+ // `count` grows on conflict; impact and guidance are refreshed, because a later refusal of the
8803
+ // same reason may know more than the first (the salt branch has four causes and names them).
8804
+ this.#db
8805
+ .prepare(`INSERT INTO content_refusal_notices
8097
8806
  (agent_id, session_id, reason, kind, impact, guidance, count, first_at, last_at)
8098
8807
  VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
8099
8808
  ON CONFLICT(agent_id, session_id, reason) DO UPDATE SET
8100
8809
  count = count + 1, kind = excluded.kind, impact = excluded.impact,
8101
8810
  guidance = excluded.guidance, last_at = excluded.last_at`)
8102
- .run(agentId, sessionId, reason, detail.kind, detail.impact, detail.guidance, now, now);
8811
+ .run(agentId, sessionId, reason, detail.kind, detail.impact, detail.guidance, now, now);
8812
+ /**
8813
+ * DOD-M15-REFUSALTERMINAL-1 — the lifetime tally, in the SAME `try` on purpose.
8814
+ *
8815
+ * If the notice write failed there is no notice to hang a total off, and the fallback below
8816
+ * has no durable counterpart to read — so the two must fail together. A total that survived a
8817
+ * failed notice would be a number nobody could see, and one that was written twice for a
8818
+ * retried notice would be worse than absent.
8819
+ */
8820
+ this.#db
8821
+ .prepare(
8822
+ // `seeded` stays whatever the row already has. A row seeded at upgrade remains a LOWER
8823
+ // BOUND for the life of that (session, reason) — counting forward from an incomplete
8824
+ // figure does not recover the refusals dismissal already erased, and clearing the flag
8825
+ // would turn "at least 58" into a claimed total of 59.
8826
+ `INSERT INTO content_refusal_totals
8827
+ (agent_id, session_id, reason, total, first_at, last_at, seeded)
8828
+ VALUES (?, ?, ?, 1, ?, ?, 0)
8829
+ ON CONFLICT(agent_id, session_id, reason) DO UPDATE SET
8830
+ total = total + 1, last_at = excluded.last_at`)
8831
+ .run(agentId, sessionId, reason, now, now);
8832
+ this.#db.exec("COMMIT");
8833
+ }
8834
+ catch (inner) {
8835
+ try {
8836
+ this.#db.exec("ROLLBACK");
8837
+ }
8838
+ catch { /* already aborted by SQLite */ }
8839
+ throw inner;
8840
+ }
8103
8841
  }
8104
8842
  catch (err) {
8105
8843
  this.#logger.error("session.refusal.persist.failed", {
@@ -8272,21 +9010,29 @@ export class SessionNodeManager {
8272
9010
  */
8273
9011
  const rows = (sessionId === undefined
8274
9012
  ? this.#db
8275
- .prepare(`SELECT n.session_id, n.reason, n.kind, n.impact, n.guidance, n.count, r.seen_count
9013
+ .prepare(`SELECT n.session_id, n.reason, n.kind, n.impact, n.guidance, n.count, r.seen_count,
9014
+ t.total AS lifetime_total, t.seeded AS lifetime_seeded
8276
9015
  FROM content_refusal_notices n
8277
9016
  LEFT JOIN content_refusal_reads r
8278
9017
  ON r.agent_id = n.agent_id AND r.session_id = n.session_id
8279
9018
  AND r.reason = n.reason AND r.consumer_id = ?
9019
+ LEFT JOIN content_refusal_totals t
9020
+ ON t.agent_id = n.agent_id AND t.session_id = n.session_id
9021
+ AND t.reason = n.reason
8280
9022
  WHERE n.agent_id = ?
8281
9023
  AND (r.seen_count IS NULL OR n.count >= r.seen_count * 10)
8282
9024
  ORDER BY n.last_at DESC, n.rowid DESC LIMIT ?`)
8283
9025
  .all(consumerId, agentId, MAX_REFUSALS_PER_READ + 1)
8284
9026
  : this.#db
8285
- .prepare(`SELECT n.session_id, n.reason, n.kind, n.impact, n.guidance, n.count, r.seen_count
9027
+ .prepare(`SELECT n.session_id, n.reason, n.kind, n.impact, n.guidance, n.count, r.seen_count,
9028
+ t.total AS lifetime_total, t.seeded AS lifetime_seeded
8286
9029
  FROM content_refusal_notices n
8287
9030
  LEFT JOIN content_refusal_reads r
8288
9031
  ON r.agent_id = n.agent_id AND r.session_id = n.session_id
8289
9032
  AND r.reason = n.reason AND r.consumer_id = ?
9033
+ LEFT JOIN content_refusal_totals t
9034
+ ON t.agent_id = n.agent_id AND t.session_id = n.session_id
9035
+ AND t.reason = n.reason
8290
9036
  WHERE n.agent_id = ? AND n.session_id = ?
8291
9037
  AND (r.seen_count IS NULL OR n.count >= r.seen_count * 10)
8292
9038
  ORDER BY n.last_at DESC, n.rowid DESC LIMIT ?`)
@@ -8323,7 +9069,21 @@ export class SessionNodeManager {
8323
9069
  kind: row.kind,
8324
9070
  impact: row.impact,
8325
9071
  guidance: row.guidance,
8326
- count: row.count,
9072
+ timesSinceDismissed: row.count,
9073
+ /**
9074
+ * DOD-M15-REFUSALTERMINAL-1 — three states, and they are three different claims.
9075
+ *
9076
+ * A counted total is a FIGURE. A seeded row is a FLOOR, and says so by using a different
9077
+ * field name (review F1c). `null` means no totals row at all, which after the upgrade
9078
+ * backfill can only happen when the notice write itself failed — reported as ABSENT
9079
+ * rather than as the smaller number, because substituting it is the defect this unit
9080
+ * exists to remove.
9081
+ */
9082
+ ...(row.lifetime_total === null
9083
+ ? {}
9084
+ : row.lifetime_seeded === 1
9085
+ ? { timesTotalAtLeast: row.lifetime_total }
9086
+ : { timesTotal: row.lifetime_total }),
8327
9087
  ...(firstTime ? {} : { repeat: true }),
8328
9088
  });
8329
9089
  }
@@ -8359,9 +9119,12 @@ export class SessionNodeManager {
8359
9119
  if (!oldest.done)
8360
9120
  notice.surfacedTo.delete(oldest.value);
8361
9121
  }
9122
+ // `timesTotal` is deliberately absent: this notice exists because the durable write failed,
9123
+ // so there is no lifetime record to report and inventing one from `notice.count` would
9124
+ // restore the exact misreading this unit removes.
8362
9125
  fromFallback.push({
8363
9126
  sessionId: sid, reason, kind: notice.kind, impact: notice.impact,
8364
- guidance: notice.guidance, count: notice.count,
9127
+ guidance: notice.guidance, timesSinceDismissed: notice.count,
8365
9128
  ...(firstTime ? {} : { repeat: true }),
8366
9129
  });
8367
9130
  }
@@ -8579,12 +9342,19 @@ export class SessionNodeManager {
8579
9342
  contentHashAlgIn,
8580
9343
  /**
8581
9344
  * DOD-M15-SEALWIRE-1 bullet 5: the VERIFIED authorship proof for this message, when the caller
8582
- * has one. The caller is the only place that has it — `#recordFrameOrdering` verifies the
8583
- * signature against the key inside the sender's own signed bytes and matches the signer to this
8584
- * session's counterparty, and that result reaches here or nowhere.
9345
+ * has one. The caller is the only place that has it — `#verifyAuthorshipClaim` verifies the
9346
+ * signature the frame carries beside the sender's own signed bytes, against the key inside those
9347
+ * bytes, and matches the signer to this session's counterparty. That result reaches here or
9348
+ * nowhere.
9349
+ *
9350
+ * ⚠️ IT USED TO NAME `#recordFrameOrdering`, and that was accurate until
9351
+ * `DOD-M15-AUTHORSHIP-ABSENT-1`: the signature arrived only inside the RELAY's Structure 2, so
9352
+ * checking authorship needed a relay record. It does not now, and the old name sends a reader to
9353
+ * a method that answers a different question. Rewritten, not deleted — that dependence is the
9354
+ * defect the unit removed.
8585
9355
  *
8586
- * Optional, because the soft decode-failure path ingests without it. The row records which it
8587
- * was, so absence is never silent.
9356
+ * Optional, because the PARK route ingests without it: recovered mail proves its sender by the
9357
+ * mailbox envelope instead. The row records which it was, so absence is never silent.
8588
9358
  */
8589
9359
  verifiedAuthorship,
8590
9360
  /**
@@ -8706,6 +9476,15 @@ export class SessionNodeManager {
8706
9476
  this.#quarantineRefusedContent(agentName, sessionId, "session_committed", content, contentHashHex, {
8707
9477
  senderPubkeyHex: record.counterparty_pubkey ?? null, correlationId,
8708
9478
  });
9479
+ /**
9480
+ * DOD-M15-REFUSALTERMINAL-1 — the retention call above is also what STOPS THE WORK: it runs
9481
+ * the terminal funnel, and `session_committed` is the one reason in it.
9482
+ *
9483
+ * Without that, the relay's next redelivery of the witness leaf armed another park fetch,
9484
+ * which drained, verified, arrived here, and was refused again — measured at ~2 per second
9485
+ * for 62 hours on one message. `#markContentResolved` could not be reused: this content did
9486
+ * not land, and saying that it did is a lie a future reader would act on.
9487
+ */
8709
9488
  // DOD-M15-NO-SILENT-REFUSAL-1. `currentStatus` on the log line carries the REAL status —
8710
9489
  // sealed, seal_interrupted_pending or abandoned — and the notice must not flatten those into
8711
9490
  // one claim, so it names the record as frozen rather than asserting which way it ended.
@@ -9575,38 +10354,200 @@ export class SessionNodeManager {
9575
10354
  this.#leafFetchTimers.delete(timerKey);
9576
10355
  }
9577
10356
  }
9578
- #scheduleLeafFetchIfUnresolved(agentName, sessionId, contentHashHex) {
9579
- const key = this.#k(agentName, sessionId);
9580
- if (this.#resolvedContent.get(key)?.has(contentHashHex))
9581
- return;
9582
- const timerKey = `${key}::${contentHashHex}`;
9583
- // ONE fetch per content hash. The relay redelivers, and a redelivery carries the same sequence
9584
- // scheduling per redelivery turns a slow relay into a storm against itself.
9585
- if (this.#leafFetchTimers.has(timerKey))
10357
+ /**
10358
+ * DOD-M15-REFUSALTERMINAL-1 the funnel. A refusal stops the work ONLY if its reason is in
10359
+ * `TERMINAL_REFUSAL_REASONS`; every other reason keeps retrying, which is what makes a transient
10360
+ * screener block or a version skew recoverable.
10361
+ *
10362
+ * One place decides, so "is this reason terminal?" is answerable from the set rather than from
10363
+ * thirteen call sites.
10364
+ */
10365
+ #considerTerminalRefusal(agentName, sessionId, contentHashHex, reason) {
10366
+ if (!TERMINAL_REFUSAL_REASONS.has(reason))
9586
10367
  return;
9587
- const timer = setTimeout(() => {
9588
- this.#leafFetchTimers.delete(timerKey);
9589
- if (this.#resolvedContent.get(key)?.has(contentHashHex))
9590
- return; // the direct path won
9591
- if (this.#shuttingDown)
9592
- return;
9593
- this.#logger.info("session.content.leaf_unresolved.fetch", {
9594
- agentName,
9595
- sessionId,
9596
- contentHash: contentHashHex,
9597
- graceMs: this.#leafFetchGraceMs,
9598
- impact: "the relay told us this message exists and its plaintext never arrived directly — "
9599
- + "fetching it now instead of waiting for the periodic sweep",
9600
- });
9601
- this.#fireParkedDrain(agentName, "witnessed_leaf_unresolved");
9602
- }, this.#leafFetchGraceMs);
9603
- timer.unref?.();
9604
- this.#leafFetchTimers.set(timerKey, timer);
10368
+ this.#markContentTerminallyRefused(agentName, sessionId, contentHashHex, reason);
9605
10369
  }
9606
- /** DOD-M12B-LEAF-TRIGGERS-FETCH-1 test seams. */
9607
- setLeafFetchGraceMsForTest(ms) { this.#leafFetchGraceMs = ms; }
9608
- markContentPresentForTest(agentName, sessionId, contentHashHex) {
9609
- this.#markContentResolved(agentName, sessionId, contentHashHex);
10370
+ /**
10371
+ * DOD-M15-REFUSALTERMINAL-1: this content can NEVER be accepted on this session — cancel the
10372
+ * pending fetch and make sure no future one is scheduled, across restarts.
10373
+ *
10374
+ * The durable write comes FIRST and the in-memory cache second, so a process that dies between
10375
+ * them wakes up with the stop still in force. A failed write is announced at ERROR and the
10376
+ * in-memory mark is still taken: the loop stops for the life of THIS process, and the log says
10377
+ * plainly that it will resume after a restart. That is a degraded stop, not a silent one.
10378
+ */
10379
+ #markContentTerminallyRefused(agentName, sessionId, contentHashHex, reason) {
10380
+ const key = this.#k(agentName, sessionId);
10381
+ /**
10382
+ * Read BEFORE the write, so the announcement below fires on the TRANSITION rather than on every
10383
+ * re-refusal. The same message can be refused again by a drain triggered for another reason, and
10384
+ * an INFO line per repeat is a smaller version of the noise this unit exists to remove.
10385
+ *
10386
+ * The durable write is still ATTEMPTED every time, deliberately: `INSERT OR IGNORE` costs
10387
+ * nothing when the row is already there, and skipping it would mean a write that failed once —
10388
+ * the branch that logs the error below — never got another chance to succeed.
10389
+ */
10390
+ const alreadyKnown = this.#isTerminallyRefused(agentName, sessionId, contentHashHex);
10391
+ try {
10392
+ if (!this.#db)
10393
+ throw new Error("database is not open");
10394
+ const agentId = this.#requireAgentId(agentName);
10395
+ this.#db
10396
+ .prepare(`INSERT OR IGNORE INTO terminal_content_refusals
10397
+ (agent_id, session_id, content_hash, reason, marked_at)
10398
+ VALUES (?, ?, ?, ?, ?)`)
10399
+ .run(agentId, sessionId, contentHashHex, reason, Date.now());
10400
+ /**
10401
+ * ⚠️ **BOUNDED, because the counterparty chooses how many rows exist — review F7.**
10402
+ *
10403
+ * One row per distinct content hash aimed at a closed conversation, and the funnel that calls
10404
+ * this runs even when the byte cap has already stopped RETENTION. So a peer who has exhausted
10405
+ * the session's storage budget can still write rows here, indefinitely, on a table nothing
10406
+ * else deletes. Every sibling store in this file is bounded (`MAX_UNREADABLE_ALG_FRAMES`, the
10407
+ * tier byte cap, `MAX_REFUSAL_READERS`); this one was not.
10408
+ *
10409
+ * Oldest-dropped, so the newest refusals keep their stop and the loop stays closed for what
10410
+ * is arriving now. A dropped row costs at most one extra fetch for content nobody is sending
10411
+ * any more — the pre-fix behaviour for that one hash, and nothing worse.
10412
+ */
10413
+ const dropped = this.#db
10414
+ .prepare(`DELETE FROM terminal_content_refusals
10415
+ WHERE agent_id = ? AND session_id = ? AND content_hash NOT IN (
10416
+ SELECT content_hash FROM terminal_content_refusals
10417
+ WHERE agent_id = ? AND session_id = ?
10418
+ ORDER BY marked_at DESC LIMIT ${MAX_TERMINAL_REFUSALS_PER_SESSION}
10419
+ )`)
10420
+ .run(agentId, sessionId, agentId, sessionId);
10421
+ if (Number(dropped.changes) > 0) {
10422
+ // Loud, because it means a counterparty has aimed more than the cap's worth of distinct
10423
+ // messages at one closed conversation — which is abuse, not ordinary traffic.
10424
+ this.#logger.warn("session.content.terminal_refusal.evicted", {
10425
+ agentName, sessionId, dropped: Number(dropped.changes),
10426
+ cap: MAX_TERMINAL_REFUSALS_PER_SESSION,
10427
+ impact: "more distinct messages have been refused on this closed conversation than the cap keeps a record of, so the oldest stops were dropped. If one of those arrives again it costs one wasted fetch; nothing is delivered and nothing is lost.",
10428
+ });
10429
+ }
10430
+ }
10431
+ catch (err) {
10432
+ this.#logger.error("session.content.terminal_refusal.persist.failed", {
10433
+ agentName, sessionId, reason,
10434
+ contentHash: contentHashHex,
10435
+ error: extractErrorMessage(err),
10436
+ impact: "this message can never be accepted on this conversation, and that fact could not be written down. Fetching for it stops while this daemon runs, and RESUMES after the next restart — which is the loop that filled a log with a quarter of a million refusals for one message.",
10437
+ guidance: "This is a fault on THIS machine, not with the counterparty. Check free disk space and the permissions on ~/.cello; session.refusal.persist.failed in this log usually appears alongside it with the underlying error.",
10438
+ });
10439
+ }
10440
+ let set = this.#terminallyRefused.get(key);
10441
+ if (!set) {
10442
+ set = new Set();
10443
+ this.#terminallyRefused.set(key, set);
10444
+ }
10445
+ set.add(contentHashHex);
10446
+ // The same cancellation `#markContentResolved` performs, for the opposite fact: an already-armed
10447
+ // grace timer must not fire for content we have just decided never to accept.
10448
+ const timerKey = `${key}::${contentHashHex}`;
10449
+ const t = this.#leafFetchTimers.get(timerKey);
10450
+ if (t !== undefined) {
10451
+ clearTimeout(t);
10452
+ this.#leafFetchTimers.delete(timerKey);
10453
+ }
10454
+ if (!alreadyKnown) {
10455
+ this.#logger.info("session.content.terminal_refusal", {
10456
+ agentName, sessionId, reason,
10457
+ contentHash: contentHashHex,
10458
+ impact: "no further attempt will be made to fetch this message. The conversation it was sent to is closed and signed, so no retry could ever have succeeded.",
10459
+ });
10460
+ }
10461
+ }
10462
+ /**
10463
+ * DOD-M15-REFUSALTERMINAL-1: has this content already been refused terminally?
10464
+ *
10465
+ * Reads the durable rows for a session ONCE and caches them, so the hot path — a witnessed leaf
10466
+ * on a healthy session — costs one `Set` lookup rather than a query per message. A read failure
10467
+ * returns `false`: the cost is the loop continuing, which is the pre-fix behaviour, and it is
10468
+ * announced rather than swallowed. Answering `true` on a failed read would be the dangerous
10469
+ * direction, because it silently stops fetching content that was never refused.
10470
+ */
10471
+ #isTerminallyRefused(agentName, sessionId, contentHashHex) {
10472
+ const key = this.#k(agentName, sessionId);
10473
+ /**
10474
+ * ⚠️ **A FAILING READ MUST NOT BE RETRIED PER MESSAGE — review F3.**
10475
+ *
10476
+ * The loaded flag is set only on success, so a database that throws (a full disk, a corrupt
10477
+ * page) sent this method back to SQLite on EVERY witnessed leaf, logged an ERROR each time, and
10478
+ * — because nothing was ever cached — made `alreadyKnown` false forever, so the mark's INFO
10479
+ * fired on every refusal too. That is the ~2/s log growth this unit exists to end, reproduced
10480
+ * by its own fix in the failure mode.
10481
+ *
10482
+ * Backed off instead: one attempt per session per minute, so the read still recovers when the
10483
+ * disk does, and the ERROR is bounded rather than proportional to traffic.
10484
+ */
10485
+ const failedAt = this.#terminalRefusalsReadFailedAt.get(key);
10486
+ const backedOff = failedAt !== undefined && Date.now() - failedAt < TERMINAL_REFUSAL_READ_RETRY_MS;
10487
+ if (!this.#terminalRefusalsLoaded.has(key) && !backedOff && this.#db) {
10488
+ try {
10489
+ const rows = this.#db
10490
+ .prepare("SELECT content_hash FROM terminal_content_refusals WHERE agent_id = ? AND session_id = ?")
10491
+ .all(this.#requireAgentId(agentName), sessionId);
10492
+ let set = this.#terminallyRefused.get(key);
10493
+ if (!set) {
10494
+ set = new Set();
10495
+ this.#terminallyRefused.set(key, set);
10496
+ }
10497
+ for (const r of rows)
10498
+ set.add(r.content_hash);
10499
+ this.#terminalRefusalsLoaded.add(key);
10500
+ this.#terminalRefusalsReadFailedAt.delete(key);
10501
+ }
10502
+ catch (err) {
10503
+ this.#terminalRefusalsReadFailedAt.set(key, Date.now());
10504
+ this.#logger.error("session.content.terminal_refusal.read.failed", {
10505
+ agentName, sessionId,
10506
+ error: extractErrorMessage(err),
10507
+ retryInMs: TERMINAL_REFUSAL_READ_RETRY_MS,
10508
+ impact: "the record of messages this conversation can never accept could not be read, so this daemon may keep fetching one of them. Nothing is lost; the cost is repeated work and log noise. The read is retried once a minute rather than on every message, so this line is bounded — its absence for a while does NOT mean the fault cleared.",
10509
+ guidance: "This is a fault on THIS machine, not with any counterparty. Check free disk space and the permissions on ~/.cello; the error above carries SQLite's own message.",
10510
+ });
10511
+ }
10512
+ }
10513
+ return this.#terminallyRefused.get(key)?.has(contentHashHex) === true;
10514
+ }
10515
+ #scheduleLeafFetchIfUnresolved(agentName, sessionId, contentHashHex) {
10516
+ const key = this.#k(agentName, sessionId);
10517
+ if (this.#resolvedContent.get(key)?.has(contentHashHex))
10518
+ return;
10519
+ // DOD-M15-REFUSALTERMINAL-1: a refusal nothing can get past is the end of the work, not a
10520
+ // reason to come back in two seconds.
10521
+ if (this.#isTerminallyRefused(agentName, sessionId, contentHashHex))
10522
+ return;
10523
+ const timerKey = `${key}::${contentHashHex}`;
10524
+ // ONE fetch per content hash. The relay redelivers, and a redelivery carries the same sequence —
10525
+ // scheduling per redelivery turns a slow relay into a storm against itself.
10526
+ if (this.#leafFetchTimers.has(timerKey))
10527
+ return;
10528
+ const timer = setTimeout(() => {
10529
+ this.#leafFetchTimers.delete(timerKey);
10530
+ if (this.#resolvedContent.get(key)?.has(contentHashHex))
10531
+ return; // the direct path won
10532
+ if (this.#shuttingDown)
10533
+ return;
10534
+ this.#logger.info("session.content.leaf_unresolved.fetch", {
10535
+ agentName,
10536
+ sessionId,
10537
+ contentHash: contentHashHex,
10538
+ graceMs: this.#leafFetchGraceMs,
10539
+ impact: "the relay told us this message exists and its plaintext never arrived directly — "
10540
+ + "fetching it now instead of waiting for the periodic sweep",
10541
+ });
10542
+ this.#fireParkedDrain(agentName, "witnessed_leaf_unresolved");
10543
+ }, this.#leafFetchGraceMs);
10544
+ timer.unref?.();
10545
+ this.#leafFetchTimers.set(timerKey, timer);
10546
+ }
10547
+ /** DOD-M12B-LEAF-TRIGGERS-FETCH-1 test seams. */
10548
+ setLeafFetchGraceMsForTest(ms) { this.#leafFetchGraceMs = ms; }
10549
+ markContentPresentForTest(agentName, sessionId, contentHashHex) {
10550
+ this.#markContentResolved(agentName, sessionId, contentHashHex);
9610
10551
  }
9611
10552
  /**
9612
10553
  * DOD-MSG-4: the relay's high-water canonical sequence for this session (largest witnessed leaf),
@@ -9910,9 +10851,13 @@ export class SessionNodeManager {
9910
10851
  originalContent,
9911
10852
  /**
9912
10853
  * DOD-M15-SEALWIRE-1 bullet 5: threaded from `ingestReceivedContent`, which is the only place
9913
- * that has it — `#recordFrameOrdering` verified this signature against the pubkey inside the
9914
- * sender's own signed bytes and matched the signer to this session's counterparty. It reaches
9915
- * the transcript row from here or not at all.
10854
+ * that has it — `#verifyAuthorshipClaim` verified this signature (carried on the frame beside
10855
+ * the bytes it signs) against the pubkey inside those bytes, and matched the signer to this
10856
+ * session's counterparty. It reaches the transcript row from here or not at all.
10857
+ *
10858
+ * ⚠️ IT USED TO NAME `#recordFrameOrdering`, true until `DOD-M15-AUTHORSHIP-ABSENT-1` moved the
10859
+ * check off the relay's record and onto the frame's own signature. Rewritten rather than
10860
+ * deleted: the old name is the evidence of what authorship used to depend on.
9916
10861
  *
9917
10862
  * Undefined on the held-release and soft-fallback paths; the row records that as
9918
10863
  * `local_session_state` rather than leaving it indistinguishable from a proven one.
@@ -11257,6 +12202,14 @@ export class SessionNodeManager {
11257
12202
  */
11258
12203
  const memoKey = this.#k(agentName, sessionId);
11259
12204
  const priorDeclaredAlg = this.#unreadableAlgSeen.get(memoKey)?.get(contentHashHex);
12205
+ /**
12206
+ * `DOD-M15-AUTHORSHIP-ABSENT-1` review H1 — READ BEFORE THE INGEST, reported after it.
12207
+ *
12208
+ * Same reasoning as `priorDeclaredAlg` directly above: the memo says what THIS side did to this
12209
+ * content on the direct path, and the ingest below is what decides whether the other route
12210
+ * succeeded. Reading it after would race the clear.
12211
+ */
12212
+ const refusedForAuthorship = this.#refusedOnDirectPath.get(memoKey)?.has(contentHashHex) === true;
11260
12213
  const result = await this.ingestReceivedContent(agentName, sessionId, env.content, contentHash, correlationId, recoveredSeq ?? undefined,
11261
12214
  // The envelope's own claim, verbatim — `undefined` on a v2 envelope, which resolves to
11262
12215
  // `sha256` and is exactly right for a peer that predates the field.
@@ -11297,6 +12250,48 @@ export class SessionNodeManager {
11297
12250
  impact: "THIS EXACT MESSAGE was refused on the direct path because it named an algorithm this build cannot read, and the same content has now been accepted via the relay park under an algorithm this build CAN read. The refusal did not hold: the message was delivered by the other route.",
11298
12251
  });
11299
12252
  }
12253
+ /**
12254
+ * ⚠️ **THE AUTHORSHIP REFUSAL DOES NOT HOLD EITHER, AND THIS IS WHERE IT SAYS SO** — review H1.
12255
+ *
12256
+ * `DOD-M15-AUTHORSHIP-ABSENT-1` refuses a direct-path frame with no usable proof of who wrote
12257
+ * it. Refusing sends no delivery ACK, so the sender's TTF backstop parks the message and it
12258
+ * arrives here — where the ENVELOPE's signature over (session_id, recipient_pubkey,
12259
+ * content_hash) is what authenticates it, and `authenticateParkedEntry` above has already
12260
+ * accepted it. That is correct and it is deliberately NOT changed here: gating mail retrieval
12261
+ * on a per-message record the relay-degraded path is allowed to omit is the false-positive shape
12262
+ * this whole unit is careful to avoid, and the order that added the refusal scopes the park
12263
+ * envelope out explicitly.
12264
+ *
12265
+ * What must not stand is the SILENCE. Without this line the operator reads "refused" and then
12266
+ * watches the message appear, with nothing connecting the two — the same reconciliation gap the
12267
+ * algorithm refusal above already pays for. What they need to know is the part that is really
12268
+ * lost: the message arrived, and its INDIVIDUAL author is attested by the mailbox envelope
12269
+ * rather than by a signature over that message's own bytes.
12270
+ *
12271
+ * Same `ok && !held && !screenedOut` predicate as above, and for the same reason: `ok` is not
12272
+ * "delivered".
12273
+ */
12274
+ if (refusedForAuthorship && result.ok && result.held !== true && result.screenedOut !== true) {
12275
+ const hashes = this.#refusedOnDirectPath.get(memoKey);
12276
+ hashes?.delete(contentHashHex);
12277
+ if (hashes && hashes.size === 0)
12278
+ this.#refusedOnDirectPath.delete(memoKey);
12279
+ /**
12280
+ * ⚠️ RENAMED FROM `…authorship_refusal_reconciled` by `029c` review F4, because the memo it
12281
+ * reads now covers EVERY direct-path refusal and not only the authorship one. Keeping the old
12282
+ * name would have put "no usable proof of who wrote it" on a message that was actually
12283
+ * refused for not decrypting — a wrong cause is worse than a general one.
12284
+ *
12285
+ * The specific reason is already on the operator's notice; what this event adds is that the
12286
+ * refusal did not hold.
12287
+ */
12288
+ this.#logger.warn("content.recover.refusal_reconciled", {
12289
+ agentName, sessionId, correlationId,
12290
+ contentHash: contentHashHex,
12291
+ impact: "THIS EXACT MESSAGE was refused on the direct path and the same content has now been accepted from the relay mailbox, where the sealed envelope proves the sender. The refusal did not hold: the message WAS delivered by the other route. What the direct path could not confirm is still unconfirmed — the mailbox proves WHO sent it and nothing about the check that refused it — so the receipt can show this message arrived without showing everything a directly-delivered one would.",
12292
+ guidance: "Nothing to do about this message. The fix named on the original refusal still stands: until the cause clears, every message on this session takes the slower route and lands with less attached to it.",
12293
+ });
12294
+ }
11300
12295
  return result;
11301
12296
  }
11302
12297
  /**
@@ -11942,6 +12937,70 @@ export class SessionNodeManager {
11942
12937
  this.#sessionContentKeys.set(this.#k(agentName, sessionId), Uint8Array.from(key));
11943
12938
  this.#contentEncryptionReasons.delete(this.#k(agentName, sessionId));
11944
12939
  }
12940
+ /**
12941
+ * Record that a message ARRIVED and was accepted at a known canonical position — 033-ACKEMIT
12942
+ * review F1.
12943
+ *
12944
+ * The one writer for both copies of the acknowledgement, so the claim this daemon signs says what
12945
+ * it actually received rather than what the relay got round to delivering back to it.
12946
+ *
12947
+ * Monotonic, and it must be: a re-delivery or a recovered park of an EARLIER message must not walk
12948
+ * the acknowledgement backwards, and must not swap the hash under an unchanged position.
12949
+ */
12950
+ #noteAcknowledgeable(agentName, sessionId, canonicalSeq, contentHash) {
12951
+ // Relay sequences are 1-based; a canonical leaf index is 0-based. The claim carries the relay's
12952
+ // number, because the relay is what checks it.
12953
+ const relaySeq = canonicalSeq + 1;
12954
+ if (relaySeq < 1)
12955
+ return;
12956
+ const key = this.#k(agentName, sessionId);
12957
+ const prev = this.#lastAck.get(key);
12958
+ if (prev && relaySeq <= prev.seq)
12959
+ return;
12960
+ this.#lastAck.set(key, { seq: relaySeq, hash: Uint8Array.from(contentHash) });
12961
+ const entry = this.#activeNodes.get(key);
12962
+ const sessionIdHex = entry?.relaySessionIdBytes
12963
+ ? Buffer.from(entry.relaySessionIdBytes).toString("hex")
12964
+ : sessionId;
12965
+ entry?.relayClient?.noteReceivedLeaf(sessionIdHex, relaySeq, contentHash);
12966
+ }
12967
+ /**
12968
+ * Test seam: put an own leaf in the HELD state instead of the tree — 033-ACKEMIT.
12969
+ *
12970
+ * The state `placeOwnLeaf` produces when the relay assigns a position ahead of our tail: the leaf
12971
+ * exists on this side and is not in the tree, while the counterparty already has it from the relay
12972
+ * and can acknowledge it. Reproducing it through the real hold map rather than by asserting the
12973
+ * tree is short is what makes the acknowledgement test measure the case instead of a neighbour of
12974
+ * it.
12975
+ */
12976
+ holdOwnLeafForTest(agentName, sessionId, canonicalSeq, contentHashHex) {
12977
+ const key = this.#k(agentName, sessionId);
12978
+ let held = this.#heldContent.get(key);
12979
+ if (!held) {
12980
+ held = new Map();
12981
+ this.#heldContent.set(key, held);
12982
+ }
12983
+ held.set(canonicalSeq, { content: new Uint8Array(), contentHashHex, origin: "sent", kind: "msg" });
12984
+ }
12985
+ /**
12986
+ * Test seam: put the session's genesis prev_root where a completed session open leaves it —
12987
+ * 033-ACKEMIT.
12988
+ *
12989
+ * ⚠️ THE STATE IS THE PRODUCTION ONE; ONLY HOW IT GOT THERE IS SHORT-CIRCUITED, exactly as
12990
+ * `setSessionContentKeyForTest` short-circuits the key exchange next door.
12991
+ *
12992
+ * In production this value is derived from the directory-signed relay assignment and written to
12993
+ * the session row the moment the session learns it, so every real session has one. A fixture that
12994
+ * builds a session node directly never sees an assignment — so without this seam every content
12995
+ * test built on the fixture would be exercising the "no starting point" REFUSAL path instead of
12996
+ * the thing it was written for, and would report that as a pass or a mysterious failure depending
12997
+ * on which side of the send it sat on.
12998
+ */
12999
+ setSessionGenesisForTest(agentName, sessionId, genesis) {
13000
+ this.#db
13001
+ ?.prepare("UPDATE sessions SET genesis_prev_root = ? WHERE agent_id = ? AND session_id = ?")
13002
+ .run(Buffer.from(genesis), this.#requireAgentId(agentName), sessionId);
13003
+ }
11945
13004
  /**
11946
13005
  * Test seam: drop the agreed key while leaving the session up — the state before an exchange
11947
13006
  * completes, and after a teardown evicts one. Its mirror above is what a completed exchange
@@ -13450,41 +14509,649 @@ export class SessionNodeManager {
13450
14509
  });
13451
14510
  }
13452
14511
  }
14512
+ /**
14513
+ * `DOD-M15-AUTHORSHIP-ABSENT-1` — SIGN OUR OWN CLAIM, with no relay involved.
14514
+ *
14515
+ * The relay submit has always built these bytes and signed them (`session-relay-client.ts`); this
14516
+ * is the same construction, for the path where no submit happens. It is not a fallback in the
14517
+ * silent sense — it produces exactly the artifact the witnessed path produces, minus the relay's
14518
+ * countersigned position, which was never part of the authorship claim.
14519
+ *
14520
+ * ⚠️ THROWS when this agent has no identity key, and the throw is the correct outcome. It lands in
14521
+ * the direct-send catch, which parks the message to the relay mailbox exactly as a failed dial
14522
+ * does — so the message is not lost, and the operator hears about a local fault instead of a
14523
+ * counterparty who mysteriously stopped receiving. Shipping the frame unsigned would guarantee a
14524
+ * refusal at the far end and blame the wrong machine for it.
14525
+ */
14526
+ async #signOwnContentClaim(agentName, sessionId, entry, contentHash) {
14527
+ const signer = this.#keyProviderResolver?.(agentName);
14528
+ if (!signer) {
14529
+ throw new Error("content_not_signable: this machine has no identity key for this agent, so it cannot sign " +
14530
+ "the message it is about to send and the counterparty would refuse it as unattributable");
14531
+ }
14532
+ // The 16-byte relay session id when this session has one, so a frame built here is
14533
+ // byte-comparable with one built by the submit. Falling back to the local id is not a
14534
+ // second meaning: for every session created without an assignment the two are the same value
14535
+ // (`relaySessionIdBytes` is set from `sessionId` on exactly those paths).
14536
+ const sessionIdBytes = entry.relaySessionIdBytes ?? Uint8Array.from(Buffer.from(sessionId, "hex"));
14537
+ /**
14538
+ * ⚠️ **THIS COMMENT USED TO READ "v1 DELIBERATELY" AND IT WAS RIGHT UNTIL NOW — 033-ACKEMIT.**
14539
+ *
14540
+ * It said `last_seen_hash` was `WITHHOLD-SEAL-1`'s emitter and "not owed here", and that a v1
14541
+ * claim makes no content acknowledgement at all, "which is honest, where an invented one would
14542
+ * not be." Accurate for `020-ACKHASH`, which shipped the reader only. It is rewritten rather
14543
+ * than deleted because it is the sentence that would otherwise explain away the LAST production
14544
+ * path still emitting v1 — and this unit's own Definition of Done is that a grep finds none.
14545
+ *
14546
+ * The reasoning it rested on has been answered: nothing is invented here. The acknowledgement
14547
+ * is read from the same `#lastSeen` entry the submit reads, so a frame built on this path and
14548
+ * one built by a submit make the same claim about the same message.
14549
+ */
14550
+ const sessionIdHexForAck = Buffer.from(sessionIdBytes).toString("hex");
14551
+ /**
14552
+ * The pair, from ONE accessor. Falling back to the session's genesis when there is no relay
14553
+ * client at all is not an invention either: nothing has been witnessed on this session, so the
14554
+ * honest acknowledgement is position 0 and the agreed starting point of the chain.
14555
+ */
14556
+ const ack = entry.relayClient?.lastSeenAck(sessionIdHexForAck)
14557
+ ?? this.#lastAck.get(this.#k(agentName, sessionId))
14558
+ ?? (() => { const g = this.#sessionGenesisPrevRoot(agentName, sessionId); return g ? { seq: 0, hash: g } : undefined; })();
14559
+ if (!ack) {
14560
+ /**
14561
+ * ⚠️ **v1, AND ONLY BECAUSE THERE IS NOTHING TO ACKNOWLEDGE — see `#verifyAcknowledgedContent`
14562
+ * for the receiving half of the same rule, which is what makes this safe rather than a
14563
+ * downgrade.**
14564
+ *
14565
+ * Reaching here means this session has no recorded starting point AND has received nothing.
14566
+ * The claim it produces is `last_seen_seq: 0` with no hash: "I have seen nothing of yours, and
14567
+ * I assert nothing about your content." That is honest, and it is not the fail-open the unit
14568
+ * closes — the hole is a claim that names a POSITION with no content behind it, and this names
14569
+ * no position. A receiver refuses a v1 claim the moment it acknowledges position 1 or beyond.
14570
+ *
14571
+ * It does not throw, and an earlier version did. Sessions brokered without a relay assignment
14572
+ * are real — the directory does not always return one — and throwing there stopped those
14573
+ * sessions sending at all, which trades a hole this claim does not have for a failure of the
14574
+ * thing the product is for.
14575
+ */
14576
+ this.#logger.info("session.content.claim.unacknowledged", {
14577
+ agentName, sessionId,
14578
+ impact: "this message is signed with no acknowledgement of anything received, because this " +
14579
+ "session has no recorded starting point and nothing has arrived on it yet. It binds the " +
14580
+ "sender and the content as always; it makes no claim about the counterparty's messages.",
14581
+ });
14582
+ const bare = encodeStructure1({
14583
+ contentHash,
14584
+ senderPubkey: await signer.getPublicKey(),
14585
+ sessionId: sessionIdBytes,
14586
+ lastSeenSeq: 0,
14587
+ timestamp: Date.now(),
14588
+ });
14589
+ return { structure1: bare, signature: await signer.sign(bare) };
14590
+ }
14591
+ const structure1 = encodeStructure1({
14592
+ contentHash,
14593
+ senderPubkey: await signer.getPublicKey(),
14594
+ sessionId: sessionIdBytes,
14595
+ // The highest counterparty position this session has seen, from the same source the submit
14596
+ // reads — and now the content hash at it, taken from the same entry so the two cannot
14597
+ // describe different messages.
14598
+ lastSeenSeq: ack.seq,
14599
+ timestamp: Date.now(),
14600
+ lastSeenHash: ack.hash,
14601
+ });
14602
+ return { structure1, signature: await signer.sign(structure1) };
14603
+ }
14604
+ /**
14605
+ * `DOD-M15-AUTHORSHIP-ABSENT-1` — DID THIS SENDER PROVE THEY WROTE THIS MESSAGE?
14606
+ *
14607
+ * The one place that answers it, for both callers, so "checked" cannot mean two different things
14608
+ * in two places. It takes the signature as an ARGUMENT rather than digging it out of a structure,
14609
+ * which is the whole of the fix: the signature used to be read only from `structure2_cbor` — the
14610
+ * RELAY's record — so a receiver could not check authorship without a relay record, and refusing
14611
+ * on its absence would have made the relay a precondition for reading mail. The content frame now
14612
+ * carries the signature beside the bytes it signs, exactly as `hash_submit` always has, and this
14613
+ * method does not care which of the two handed it over.
14614
+ *
14615
+ * It VERIFIES and it does not LOG. The severity of each verdict differs by caller — the content
14616
+ * frame refuses an `unusable`, the park path shrugs at one — and a method that logged its own
14617
+ * conclusion would either report a refusal that did not happen or stay silent on one that did.
14618
+ */
14619
+ #verifyAuthorshipClaim(agentName, sessionId, structure1Cbor, senderSignature, contentHash) {
14620
+ // Structure 1 content_hash is index 1 and sender_pubkey index 2 in BOTH layouts — 020-ACKHASH
14621
+ // appended last_seen_hash at 6 rather than inserting it, so neither read moved. A v2 claim
14622
+ // decodes here exactly as a v1 one does; its hash is not consulted, because this unit ships
14623
+ // reading and not enforcing.
14624
+ const s1 = decodeStructure1(structure1Cbor);
14625
+ // A layout this build cannot name yields no pubkey and no hash, so there is nothing to check the
14626
+ // signature against. Its reason is carried out so an unreadable CLAIM and a wrong SIGNATURE stay
14627
+ // distinguishable — they take the same outcome by different routes.
14628
+ if (!s1.ok)
14629
+ return { verdict: "unusable", reason: s1.reason };
14630
+ const s1Hash = s1.fields.contentHash;
14631
+ const s1Pubkey = s1.fields.senderPubkey;
14632
+ // The SENDER's Ed25519 signature over the exact signed bytes — the same check the relay
14633
+ // performs. `verify` never throws, so a wrong-width or garbage signature lands here as `false`:
14634
+ // supplied and refuted, which is a different fact from not supplied at all.
14635
+ if (!verify(s1Pubkey, structure1Cbor, senderSignature)) {
14636
+ return { verdict: "refuted", reason: "bad_signature" };
14637
+ }
14638
+ // Sovereign-node cross-check: the signer MUST be THIS session's counterparty, not an unrelated
14639
+ // key. Review M1: compare BYTES, not hex strings — `counterparty_pubkey` is stored verbatim from
14640
+ // the IPC param and is never case-normalized, so a string compare would fail for a mixed-case
14641
+ // pubkey and silently strip the canonical ordering from every message in that session.
14642
+ const counterparty = this.getSessionRecord(agentName, sessionId)?.counterparty_pubkey;
14643
+ if (!pubkeyMatchesHex(s1Pubkey, counterparty)) {
14644
+ /**
14645
+ * REFUTED when the counterparty is KNOWN and the signer is someone else.
14646
+ *
14647
+ * This is the session-open MITM detection from the 2026-08-21 T-of-N investigation, which
14648
+ * found this check *"fires correctly, and its answer is thrown away."* A rogue quorum of the
14649
+ * directories holding shares for agent B can sign a false SessionAssignment naming M's key as
14650
+ * B's, and everything downstream is genuinely real — M signs with M's own valid key. Nothing
14651
+ * is missing for A to notice. This comparison is where the substitution shows, because
14652
+ * `counterparty_pubkey` comes from A's own request and is untouched by anything the directory
14653
+ * returns.
14654
+ *
14655
+ * ⚠️ AN EARLIER VERSION OF THIS COMMENT ADDED "it shows only when the record is present" —
14656
+ * true when the proof was optional, and no longer the shape of the code: a content frame with
14657
+ * no checkable proof is refused before it reaches ingest, so M cannot decline to supply one
14658
+ * and be admitted anyway. Rewritten rather than deleted, because that sentence is the evidence
14659
+ * of what the gap was. The park envelope is the remaining path where the ordering record is
14660
+ * genuinely optional, and there the sender's authorship is proven by the envelope's own
14661
+ * signature instead.
14662
+ *
14663
+ * `verified_unmatched` stays soft deliberately: with no counterparty on record we cannot prove
14664
+ * the signer either way, and refusing there would strand a session whose row we failed to read
14665
+ * rather than one that is under attack. It is also the only signal the orphan branch has.
14666
+ */
14667
+ return counterparty
14668
+ ? { verdict: "refuted", reason: "signer_not_counterparty" }
14669
+ : { verdict: "verified_unmatched", senderPubkey: s1Pubkey };
14670
+ }
14671
+ /**
14672
+ * ─── THE BINDING CHECKS RUN LAST, AND THE ORDER IS A SECURITY PROPERTY ───────────────────────
14673
+ *
14674
+ * ⚠️ **THEY USED TO RUN FIRST, AND THAT HANDED THE ATTACKER A FREEZE-SUPPRESSION SWITCH** —
14675
+ * review of `029b`, and it is the finding that mattered most.
14676
+ *
14677
+ * Everything below this line is `unusable`: the message is REFUSED and the session lives.
14678
+ * Everything above it is `refuted`: the session FREEZES. So a check that can answer `unusable`
14679
+ * before the signature has been verified lets a peer choose the softer outcome — flip one
14680
+ * unauthenticated byte of `session_id` inside your own claim and a garbage signature, or a
14681
+ * signature by a MITM's own key, stops being an identity incident and becomes a quiet refusal.
14682
+ * The session-open MITM detection this function exists to serve was bypassable by exactly the
14683
+ * party it detects.
14684
+ *
14685
+ * So the order is: decode → SIGNATURE → SIGNER → then what the proof is about. By the time
14686
+ * either check below runs, the claim provably came from this session's counterparty, and the
14687
+ * only question left is which message and which conversation they made it for.
14688
+ *
14689
+ * `seal-frontier-verify` already does it in this order (verify at :55, session id at :77). This
14690
+ * code had it inverted.
14691
+ */
14692
+ // The claim must bind to THIS content. A signature over somebody else's bytes verifies perfectly
14693
+ // and proves nothing about this message — without this, one signed claim could be replayed onto
14694
+ // every frame that follows it.
14695
+ if (!bytesEqual(s1Hash, contentHash)) {
14696
+ return { verdict: "unusable", reason: AUTHORSHIP_CONTENT_HASH_MISMATCH };
14697
+ }
14698
+ /**
14699
+ * ⚠️ **AND IT MUST BIND TO THIS CONVERSATION** — review M4, ruled in by Andre 2026-09-04.
14700
+ *
14701
+ * Binding the content and the signer is not enough on its own: a claim the counterparty
14702
+ * genuinely signed in another session verifies unchanged here for the same bytes. Not a
14703
+ * stranger and not forged content — a real line of theirs, landing in a transcript it was never
14704
+ * written for, with a signature that checks out. That is worse than an unsigned message,
14705
+ * because the receipt then PROVES something that did not happen.
14706
+ *
14707
+ * `session_id` has been in Structure 1 since v1 and this path never read it.
14708
+ * `seal-frontier-verify` already compares it; the live receive path simply did not.
14709
+ *
14710
+ * **THE TWO VALUES CANNOT DIVERGE, and that is what makes this safe to enforce.** Both are
14711
+ * derived from ONE session id on each side, by construction:
14712
+ * initiator — `sessionId = hex(assignment.session_id)` (`initiate-session-handler`) and
14713
+ * `relayParams.sessionIdBytes = assignment.session_id` (`daemon.ts`);
14714
+ * responder — `acceptSession(parsed.sessionIdHex)` and
14715
+ * `sessionIdBytes = Buffer.from(parsed.sessionIdHex, "hex")` (`inbound-sessions`);
14716
+ * direct/persisted — `relaySessionIdBytes = Buffer.from(sessionId, "hex")`.
14717
+ * The two names exist because one keys the in-memory maps and one goes on the wire, not because
14718
+ * they can hold different values. Getting this wrong would refuse EVERY message on EVERY live
14719
+ * session, so it is stated rather than assumed.
14720
+ *
14721
+ * REFUSED, not frozen — and the sentence is TRUE where it now stands. The signature has
14722
+ * verified and the signer has been matched to this session's counterparty three lines above;
14723
+ * what is wrong is only the conversation the claim was made for, which is a replay rather than
14724
+ * an identity fault. Said because an earlier version of this comment made the same claim from
14725
+ * ABOVE the verification, where none of it had happened yet.
14726
+ */
14727
+ const expectedSessionId = this.#activeNodes.get(this.#k(agentName, sessionId))?.relaySessionIdBytes
14728
+ ?? Uint8Array.from(Buffer.from(sessionId, "hex"));
14729
+ if (!bytesEqual(s1.fields.sessionId, expectedSessionId)) {
14730
+ return { verdict: "unusable", reason: AUTHORSHIP_SESSION_MISMATCH };
14731
+ }
14732
+ /**
14733
+ * ─── AND IT MUST ACKNOWLEDGE SOMETHING THAT WAS ACTUALLY SAID — 033-ACKEMIT ──────────────────
14734
+ *
14735
+ * **THIS IS THE HALF THAT NEEDS NO RELAY, and it is the reason the unit exists.** Everything the
14736
+ * check consumes is on this machine: the counterparty's own signed bytes, and our own tree. We
14737
+ * do not ask the relay what position 7 held — we already know, because we placed the leaf there.
14738
+ *
14739
+ * Until now a signed acknowledgement was a NUMBER. "I saw position 7" attests to a position and
14740
+ * never to content, so the only thing binding the acknowledgement to a message was the relay's
14741
+ * separate receipt over `content_hash ‖ seq ‖ timestamp`. Withhold the relay's half and the
14742
+ * signed claim is an unbacked number — which is how a counterparty seals one message short.
14743
+ * With the hash signed, the claim stands on its own and the relay is no longer load-bearing for
14744
+ * it.
14745
+ *
14746
+ * It runs LAST for the reason the two checks above run last: everything from here down answers
14747
+ * `unusable`, which refuses the message and leaves the session alive, while a `refuted` FREEZES
14748
+ * it. A check that could answer before the signature and the signer were established would hand
14749
+ * a peer a switch for choosing the softer outcome. By this line the claim provably came from
14750
+ * this session's counterparty, about this content, in this conversation — the only question
14751
+ * left is whether what they say they saw is what we sent.
14752
+ */
14753
+ const ackVerdict = this.#verifyAcknowledgedContent(agentName, sessionId, s1.fields);
14754
+ if (ackVerdict)
14755
+ return ackVerdict;
14756
+ return { verdict: "verified", senderPubkey: s1Pubkey, senderSig: senderSignature };
14757
+ }
14758
+ /**
14759
+ * Does this claim's `last_seen_hash` name content this side actually put at that position?
14760
+ *
14761
+ * Returns `undefined` when the acknowledgement holds, or the `unusable` verdict to refuse with.
14762
+ * Split out of `#verifyAuthorshipClaim` so the three refusal causes can be named separately —
14763
+ * a claim that carries NO hash, one that names a position we never reached, and one that names
14764
+ * the wrong content — rather than collapsing into a single "the proof was bad".
14765
+ *
14766
+ * ⚠️ MISSING, MALFORMED AND MISMATCHED TAKE ONE PATH (§5). A v1 claim carries no content
14767
+ * assertion at all, and treating that as "fine, skip the check" would recreate the fail-open this
14768
+ * unit is closing one layer down: an attacker who wants to evade a mismatch check simply never
14769
+ * supplies a checkable proof. `decodeStructure1` has already refused a v2 whose hash is the wrong
14770
+ * width, so `lastSeenHash === null` here means exactly one thing — a v1 layout — and it is
14771
+ * refused by its own name.
14772
+ */
14773
+ #verifyAcknowledgedContent(agentName, sessionId, fields) {
14774
+ /**
14775
+ * ⚠️ **A v1 CLAIM IS REFUSED THE MOMENT IT NAMES A POSITION — and accepted when it names none.
14776
+ * The split is the whole rule, so it is stated rather than left to the reader.**
14777
+ *
14778
+ * `last_seen_seq >= 1` with no hash IS the defect: "I saw position 7" attests to a position and
14779
+ * never to content, which is the unbacked number this unit exists to stop accepting. Treating
14780
+ * that as "fine, skip the check" would recreate `DOD-M15-AUTHORSHIP-ABSENT-1` one layer down —
14781
+ * an attacker evading a mismatch check simply never supplies a checkable proof.
14782
+ *
14783
+ * `last_seen_seq === 0` with no hash claims nothing about our messages, so there is no check to
14784
+ * skip and nothing to bind. A sender genuinely in that state — a session brokered without a
14785
+ * relay assignment, which the directory does not always return — has nothing to acknowledge,
14786
+ * and refusing them would stop the product's own advertised journey to close a hole they are
14787
+ * not in.
14788
+ *
14789
+ * **THE BOUND, SAID PLAINLY:** a peer can decline to bind by never acknowledging anything.
14790
+ * That costs them their own ratification of our history rather than falsifying it, and it is
14791
+ * the same under-claiming the relay has always allowed (it refuses a `last_seen_seq` that runs
14792
+ * AHEAD of its counter, never one that lags). This unit does not change that either way, and
14793
+ * the follow-on that does is the receiver submitting a hash for what it received.
14794
+ */
14795
+ if (fields.lastSeenHash === null) {
14796
+ return fields.lastSeenSeq >= 1
14797
+ ? { verdict: "unusable", reason: AUTHORSHIP_ACK_HASH_ABSENT }
14798
+ : undefined;
14799
+ }
14800
+ /**
14801
+ * THE GENESIS IS A VALUE, NEVER AN ABSENCE. The first message of a session has seen nothing, and
14802
+ * that case is a defined 32 bytes — the agreed starting point of this two-party chain, derived
14803
+ * from both keys, the session id and the session timestamp. Not 32 zero bytes: a constant
14804
+ * identical across every session is one an attacker can present for any session, so the one
14805
+ * position most exposed to a forged acknowledgement would be the only one nobody could check.
14806
+ */
14807
+ if (fields.lastSeenSeq <= 0) {
14808
+ const genesis = this.#sessionGenesisPrevRoot(agentName, sessionId);
14809
+ /**
14810
+ * ⚠️ **SOFT HERE, AND THIS IS THE ONE BRANCH WHERE THAT IS NOT A FAIL-OPEN — the reasoning is
14811
+ * the load-bearing part, so it is written down rather than assumed.**
14812
+ *
14813
+ * `last_seen_seq` 0 means "I have received nothing from you", and the hash that goes with it
14814
+ * is the session's agreed starting point. It is a genuine value and this daemon always emits
14815
+ * it — but as a CHECK it is close to redundant, because the thing it establishes (that this
14816
+ * claim was made for THIS session) has already been established three lines above by the
14817
+ * session-id binding, against a value derived from the same session id.
14818
+ *
14819
+ * **What an attacker gains by reaching this branch: nothing.** They cannot skip the real
14820
+ * comparison by claiming 0, because claiming 0 is claiming to have acknowledged NOTHING of
14821
+ * ours — it removes their own ratification of our history rather than falsifying it, and the
14822
+ * positional check below is what a claim about our messages has to survive. And they cannot
14823
+ * cause the absence either: whether we hold a genesis depends on our own assignment and our
14824
+ * own database, never on anything they send.
14825
+ *
14826
+ * The alternative was refusing, and it would have been the wrong kind of strict: a session
14827
+ * restored from a row written before this column existed holds no genesis, and every first
14828
+ * message on it would be refused for something the counterparty did not do.
14829
+ */
14830
+ if (!genesis) {
14831
+ this.#logger.info("session.content.ack_hash.genesis_unavailable", {
14832
+ agentName, sessionId,
14833
+ impact: "this message acknowledges nothing yet, and this side holds no recorded starting point " +
14834
+ "for the session, so the acknowledgement was not compared. The message is accepted: it " +
14835
+ "is already bound to this conversation by the session id inside the signed bytes.",
14836
+ });
14837
+ return undefined;
14838
+ }
14839
+ return bytesEqual(fields.lastSeenHash, genesis)
14840
+ ? undefined
14841
+ : { verdict: "unusable", reason: AUTHORSHIP_ACK_HASH_MISMATCH };
14842
+ }
14843
+ /**
14844
+ * ─── THE ACKNOWLEDGED CONTENT MUST BE SOMETHING THIS SIDE ACTUALLY HOLDS ─────────────────────
14845
+ *
14846
+ * ⚠️ **AN EARLIER VERSION OF THIS CHECK WAS POSITION-ONLY AND HAD A HOLE THE ATTACKER COULD
14847
+ * OPEN THEMSELVES. It is kept described, not deleted, because the false reasoning is the part
14848
+ * worth not repeating.**
14849
+ *
14850
+ * It compared `hashAt(last_seen_seq - 1)` and WAIVED the whole comparison on a session marked
14851
+ * diverged, on this stated ground: *"Who controls this absence? Not the peer: divergence is
14852
+ * caused by OUR submit failing, and nothing the counterparty sends can produce it."*
14853
+ *
14854
+ * **That was false, and the party who could falsify it is the exact attacker this line names.**
14855
+ * Send a message direct-only and never submit its hash: we have no ordering record, so it is
14856
+ * appended at the tail and our tree runs one ahead of the relay's counter. Our very next send
14857
+ * then gets an assigned position BEHIND our frontier, `placeOwnLeaf` takes its
14858
+ * `position_behind_frontier` branch and calls `markSessionDiverged` — and from that moment every
14859
+ * inbound acknowledgement skipped the check entirely. **One withheld message plus one reply from
14860
+ * us disabled the guard, using the behaviour the guard exists to catch.**
14861
+ *
14862
+ * A second defect sat beside it: a claim naming a position our tree has not reached was refused
14863
+ * outright, and a HELD own leaf is exactly that — `placeOwnLeaf` returns `{placed: false}` when
14864
+ * the relay hands us a position ahead of our tail, so the leaf is not in the tree while the
14865
+ * counterparty has already received it and is acknowledging it. We refused their reply for a
14866
+ * transient gap on our own machine.
14867
+ *
14868
+ * **So the question asked is now about CONTENT, not about an index.** Is the hash they name
14869
+ * something this side has — placed in the tree, or held pending a gap? That cannot be switched
14870
+ * off by divergence (it consults no positions), it cannot false-refuse a held leaf, and it still
14871
+ * refuses a hash we have never held, which is the falsehood the check exists to catch.
14872
+ *
14873
+ * The POSITION is then used only to make the check STRONGER where it is safe to: on a session
14874
+ * whose indices still mean relay positions, the hash must sit exactly where they say it does.
14875
+ * Divergence loses that strengthening and keeps the membership test, rather than losing both.
14876
+ */
14877
+ const tree = this.getSessionTree(agentName, sessionId);
14878
+ const ackHex = Buffer.from(fields.lastSeenHash).toString("hex");
14879
+ const heldHere = this.#heldContent.get(this.#k(agentName, sessionId));
14880
+ const held = heldHere ? [...heldHere.values()].some((e) => e.contentHashHex === ackHex) : false;
14881
+ if (tree.indexOfHash(ackHex) === -1 && !held) {
14882
+ return { verdict: "unusable", reason: AUTHORSHIP_ACK_HASH_UNKNOWN };
14883
+ }
14884
+ /**
14885
+ * THE POSITIONAL STRENGTHENING. Skipped — with a WARN, never silently — when this side's indices
14886
+ * no longer mean relay positions, or when the leaf at that position is still held. Neither is
14887
+ * a pass: the membership test above has already run and refused anything we do not hold.
14888
+ */
14889
+ const atPosition = tree.hashAt(fields.lastSeenSeq - 1);
14890
+ if (this.isSessionDiverged(agentName, sessionId) || atPosition === null) {
14891
+ this.#logger.warn("session.content.ack_hash.position_unverifiable", {
14892
+ agentName, sessionId, lastSeenSeq: fields.lastSeenSeq,
14893
+ diverged: this.isSessionDiverged(agentName, sessionId),
14894
+ impact: "the acknowledged content IS in this side's record, so the claim was accepted — but its " +
14895
+ "POSITION was not checked, because this session's local positions no longer line up with " +
14896
+ "the relay's, or the leaf at that position has not been placed yet.",
14897
+ });
14898
+ return undefined;
14899
+ }
14900
+ return ackHex === atPosition
14901
+ ? undefined
14902
+ : { verdict: "unusable", reason: AUTHORSHIP_ACK_HASH_MISMATCH };
14903
+ }
14904
+ /**
14905
+ * `DOD-M15-AUTHORSHIP-ABSENT-1` — the refusal an inbound frame gets when its authorship cannot be
14906
+ * established. NOT a freeze: see `AuthorshipVerdict` for why those are different facts.
14907
+ *
14908
+ * Both surfaces, always. The ERROR is the durable forensic record an investigation reads days
14909
+ * later; the notice is the CONTROL — the thing that actually reaches the operator, who otherwise
14910
+ * watches a conversation go quiet and concludes the other person stopped replying.
14911
+ */
14912
+ #refuseUnprovenAuthorship(agentName, sessionId, reason, contentHash, detail, correlationId) {
14913
+ /**
14914
+ * ⚠️ **THREE REASONS, THREE SENTENCES — AND THE THIRD USED TO BORROW THE SECOND'S** (review of
14915
+ * `029b`, and it is the operator half of the same finding as the check order).
14916
+ *
14917
+ * A replayed claim is the one branch on this path that is potentially ADVERSARIAL: a real,
14918
+ * valid, correctly-signed line of your counterparty's, presented in a conversation it was not
14919
+ * written for. It was reaching the operator under the `unusable` wording, which says the proof
14920
+ * was "unreadable, or signed over different content" — neither is true — and under guidance
14921
+ * telling them to go and ask their counterparty to upgrade. A version number is not the
14922
+ * question, and sending someone to chase one spends their attention on the wrong thing.
14923
+ */
14924
+ const impact = reason === "authorship_proof_absent"
14925
+ ? "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."
14926
+ : reason === "authorship_wrong_conversation"
14927
+ ? "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."
14928
+ /**
14929
+ * 033-ACKEMIT. Says what was OBSERVED — the two records disagree about what was said —
14930
+ * and stops there. It does NOT say the counterparty is lying: the same signal is what a
14931
+ * genuine fault on their side looks like, and naming a conclusion the code did not reach
14932
+ * is the error-fidelity defect this milestone was opened for.
14933
+ */
14934
+ /**
14935
+ * ⚠️ THREE CAUSES, THREE SENTENCES — review F5. They shared one, and it described none of
14936
+ * them properly: an ABSENT acknowledgement has no part that "does not match", because it
14937
+ * has no part at all.
14938
+ *
14939
+ * All three say what was OBSERVED and stop there. None says the counterparty is lying:
14940
+ * the same signal is what a genuine fault on their side looks like, and naming a
14941
+ * conclusion the code did not reach is the error-fidelity defect this milestone exists
14942
+ * for.
14943
+ */
14944
+ : reason === AUTHORSHIP_ACK_HASH_ABSENT
14945
+ ? "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."
14946
+ : reason === AUTHORSHIP_ACK_HASH_MISMATCH
14947
+ ? "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."
14948
+ : reason === AUTHORSHIP_ACK_HASH_UNKNOWN
14949
+ ? "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."
14950
+ : "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.";
14951
+ /**
14952
+ * ⚠️ THE VERB IS THE COUNTERPARTY'S, AND THE GUIDANCE SAYS SO. The reader is the RECEIVING
14953
+ * operator, and there is nothing on their machine to change — the missing signature is produced
14954
+ * on the sender's. Telling them to do something local would be an affordance that resolves to
14955
+ * nothing. So it names the one move that works (tell them to upgrade) and the one that settles
14956
+ * the other explanation (confirm out of band), and it stops at two.
14957
+ *
14958
+ * ⚠️ **IT USED TO OPEN "Nothing was shown and nothing was stored." THAT SENTENCE WAS FALSE** —
14959
+ * review H1, and it is kept here rather than deleted because it is the exact shape this
14960
+ * milestone exists to catch: a refusal that announces a stronger outcome than it delivers.
14961
+ *
14962
+ * Refusing sends no delivery ACK, so the sender's TTF backstop parks the message and it arrives
14963
+ * through the relay mailbox seconds later, where the ENVELOPE's signature authenticates it and
14964
+ * recovery accepts it — correctly, and with no per-message proof. So the message may well be
14965
+ * delivered, moments after the operator was told it was not. The reconciliation is logged
14966
+ * (`content.recover.refusal_reconciled`) and the sentence below now says what is
14967
+ * actually true of this path: nothing was shown YET, and this refusal does not stop the copy
14968
+ * coming the other way.
14969
+ */
14970
+ const guidance = reason === "authorship_wrong_conversation"
14971
+ ? "STOPPED ON PURPOSE, and this one is NOT a version problem — do not go and ask them about " +
14972
+ "their build. The signature is real and it is theirs; what is wrong is that it was made for " +
14973
+ "another conversation, so something replayed it into this one. That is either software on " +
14974
+ "one of your machines re-sending an old message into the wrong session, or someone in " +
14975
+ "between doing it deliberately. ONE thing to do: ask your counterparty OUT OF BAND (a " +
14976
+ "channel that is not this one) whether they meant to send this, before you continue here."
14977
+ /**
14978
+ * ⚠️ **THIS BRANCH SHIPPED AS `NaNcopy in the relay mailbox…` AND NOTHING NOTICED** — review
14979
+ * F1, and it is worth more than the one-line fix.
14980
+ *
14981
+ * Splitting the guidance in two dropped the opening literal and left behind the `+` that had
14982
+ * joined it, which is not a concatenation with nothing on its left — it is a UNARY PLUS on the
14983
+ * next string. `+"REACH YOU BY…"` is `NaN`, and `NaN + "copy in the relay mailbox…"` is a
14984
+ * perfectly good string. So the flagship refusal of this whole milestone reached the operator
14985
+ * beginning mid-word with `NaN`, with its "STOPPED ON PURPOSE" framing and its reason gone.
14986
+ *
14987
+ * **The test was green because it asked the wrong question.** The only assertion on this
14988
+ * string was `.toMatch(/upgrade/i)`, and "tell them to upgrade" survives at the tail. A
14989
+ * substring match on a sentence cannot see that the sentence lost its head — so the assertion
14990
+ * below pins what it OPENS with, which a truncation cannot survive.
14991
+ */
14992
+ /**
14993
+ * 033-ACKEMIT — TWO PATHS, AND THE FIRST IS THE ONE THAT ACTUALLY HAPPENS.
14994
+ *
14995
+ * Capped at two (Invariant 4): an affordance list that enumerates everything is a menu. The
14996
+ * verb is the counterparty's in both cases — there is nothing to change on this machine — so
14997
+ * it names the one move that fixes the likely cause and the one that settles the other.
14998
+ */
14999
+ /**
15000
+ * ⚠️ AND THREE REMEDIES, because the shared one was WRONG for two of the three. It told the
15001
+ * reader their counterparty's build was probably old — which is impossible for a claim that
15002
+ * carries an acknowledgement, since only a newer build sends one — and then told them to
15003
+ * abandon the conversation. Two is the cap on each (Invariant 4); the verb is the
15004
+ * counterparty's in every case, because there is nothing to change on this machine.
15005
+ */
15006
+ : reason === AUTHORSHIP_ACK_HASH_ABSENT
15007
+ ? "STOPPED ON PURPOSE, and this is NOT about their signature — it verified. " +
15008
+ (this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
15009
+ " Their build is older than yours and does not say what it has received. Ask which version " +
15010
+ "they are running and tell them to upgrade — only they can fix it, and this will keep " +
15011
+ "happening until they do."
15012
+ : reason === AUTHORSHIP_ACK_HASH_MISMATCH || reason === AUTHORSHIP_ACK_HASH_UNKNOWN
15013
+ ? "STOPPED ON PURPOSE, and this is NOT about their signature or their version — both are " +
15014
+ "fine. " +
15015
+ (this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
15016
+ " Your record of this conversation and theirs have stopped agreeing about what you sent " +
15017
+ "them. Confirm with them OUT OF BAND what they actually received from you. If it matches " +
15018
+ "what you sent, this was a fault and a new session will clear it; if it does not, do not " +
15019
+ "carry on in this one."
15020
+ : "STOPPED ON PURPOSE. This copy was refused and the message itself was not kept. " +
15021
+ // Review F2: chosen from what THIS machine can do, not asserted. An agent with no identity
15022
+ // key cannot open a mailbox copy either, and telling them to wait for one would be the same
15023
+ // false promise on a different refusal.
15024
+ (this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
15025
+ " Almost always their CELLO build is older than this one: a build from before message signing " +
15026
+ "does not attach a signature at all. Ask which version they are running, and tell them to " +
15027
+ "upgrade — this will keep happening until they do, and only they can fix it. If they are on " +
15028
+ "the SAME version as you, that explanation does not hold: confirm with them OUT OF BAND " +
15029
+ "before opening another session.";
15030
+ this.#logger.error("session.content.refused", {
15031
+ agentName, sessionId, correlationId, reason, ...detail, impact, guidance,
15032
+ });
15033
+ this.noteContentRefusal(agentName, sessionId, reason, { kind: REFUSAL_KINDS.REFUSED, impact, guidance });
15034
+ // Armed AFTER the refusal is filed, so the memo can never claim a refusal that did not happen.
15035
+ this.#noteRefusedOnDirectPath(agentName, sessionId, contentHash);
15036
+ }
15037
+ /**
15038
+ * An inbound content frame refused before it could be read — the ENCRYPTION gate's three causes.
15039
+ *
15040
+ * ⚠️ **THESE LOGGED AND FILED NOTHING, AND THAT IS WHY THIS EXISTS.** All three carried a good
15041
+ * `impact` and `guidance` at ERROR and none of them called `noteContentRefusal`, so the sentences
15042
+ * an operator needed were in a file they have no reason to open. From their chair a message never
15043
+ * arrived and the conversation went quiet — the exact defect `DOD-M15-NO-SILENT-REFUSAL-1` was
15044
+ * built to end, on the same path, three checks above the one that respected it.
15045
+ *
15046
+ * Both surfaces, always: the ERROR is the durable forensic record an investigation reads days
15047
+ * later, and the notice is the control — the thing that actually reaches the person.
15048
+ */
15049
+ /**
15050
+ * Can a refused message still reach this operator through the relay mailbox? — review F2.
15051
+ *
15052
+ * Feature-detected, not assumed: `openContentSeal` is documented OPTIONAL on `KeyProvider`, and
15053
+ * `content-park.ts` refuses recovery without it. Asking the same resolver `content-park.ts` asks
15054
+ * is what keeps the sentence on the operator's screen tied to what their machine can actually do.
15055
+ */
15056
+ #mailboxRouteAvailable(agentName) {
15057
+ const kp = this.#keyProviderResolver?.(agentName);
15058
+ return kp !== undefined && typeof kp.openContentSeal === "function";
15059
+ }
15060
+ #refuseInboundContent(agentName, sessionId, reason, contentHash, detail, correlationId) {
15061
+ // The sentence about the other route is chosen HERE, from what this machine can actually do —
15062
+ // never written into a caller's literal, where it would be a promise nobody re-checked.
15063
+ const guidance = `${detail.guidance} ${this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE}`;
15064
+ this.#logger.error("session.content.refused", { agentName, sessionId, correlationId, reason, ...detail, guidance });
15065
+ this.noteContentRefusal(agentName, sessionId, reason, {
15066
+ kind: REFUSAL_KINDS.REFUSED, impact: detail.impact, guidance,
15067
+ });
15068
+ /**
15069
+ * Review F4 — A PROMISE MADE HERE IS CLOSED IN `recoverParkedEntry`, not left standing.
15070
+ *
15071
+ * The guidance above tells the operator the message may arrive by the mailbox. Both sibling
15072
+ * refusals on this path already arm a memo so the recovery can say the refusal did not hold;
15073
+ * this one armed nothing, so a delivered message would have left a permanent alarm sitting in
15074
+ * `cello_check_notifications` saying it had been turned away.
15075
+ *
15076
+ * Armed AFTER the notice is filed, so the memo can never claim a refusal that did not happen.
15077
+ */
15078
+ this.#noteRefusedOnDirectPath(agentName, sessionId, contentHash);
15079
+ }
13453
15080
  #recordFrameOrdering(agentName, sessionId, structure1Cbor, structure2Cbor, contentHash, correlationId, source = "content_frame") {
13454
15081
  try {
13455
- // Structure 1 content_hash is index 1 and sender_pubkey index 2 in BOTH layouts — 020-ACKHASH
13456
- // appended last_seen_hash at 6 rather than inserting it, so neither read moved. A v2 claim
13457
- // decodes here exactly as a v1 one does; its hash is not consulted, because this unit ships
13458
- // reading and not enforcing.
13459
- const s1 = decodeStructure1(structure1Cbor);
13460
15082
  const s2 = decode(structure2Cbor);
13461
- const s1Hash = s1.ok ? s1.fields.contentHash : undefined;
13462
- const s1Pubkey = s1.ok ? s1.fields.senderPubkey : undefined;
13463
15083
  const seq = typeof s2?.[0] === "number" ? s2[0] : -1;
13464
15084
  const s2Sig = s2?.[3];
13465
- if (!(s1Hash instanceof Uint8Array) || !(s1Pubkey instanceof Uint8Array) || !(s2Sig instanceof Uint8Array) || seq < 1) {
15085
+ if (!(s2Sig instanceof Uint8Array) || seq < 1) {
13466
15086
  // SOFT: we could not read the record, so we learned nothing about the signer either way.
13467
15087
  // Position falls back to the witness stream, exactly as an absent record does.
13468
15088
  // The Structure 1 reason is carried so an unreadable RECORD and an unnamed LAYOUT are
13469
15089
  // distinguishable in the log — they arrive at the same soft outcome by different routes.
15090
+ const s1Layout = decodeStructure1(structure1Cbor);
13470
15091
  this.#logger.warn("session.content.ordering.malformed", {
13471
15092
  sessionId,
13472
15093
  correlationId,
13473
- ...(s1.ok ? {} : { structure1Reason: s1.reason }),
15094
+ ...(s1Layout.ok ? {} : { structure1Reason: s1Layout.reason }),
13474
15095
  });
13475
15096
  return { seq: null };
13476
15097
  }
13477
- // The framed ordering record must bind to THIS content (its hash) — else it orders the wrong bytes.
13478
- const contentHashHex = Buffer.from(contentHash).toString("hex");
13479
- if (Buffer.from(s1Hash).toString("hex") !== contentHashHex) {
13480
- // SOFT: the record does not describe this content. Nothing is proven about the signer's
13481
- // identity only that this record and these bytes do not belong together.
13482
- this.#logger.warn("session.content.ordering.hash_mismatch", { sessionId, correlationId });
15098
+ /**
15099
+ * `DOD-M15-AUTHORSHIP-ABSENT-1` the same verifier the content frame uses, handed the
15100
+ * signature the RELAY committed (`structure2_cbor` index 3) instead of the one the frame
15101
+ * carries. Two claims about the same message, and both must hold: if the relay's copy of the
15102
+ * sender's signature does not verify against the bytes on the frame, one of them has been
15103
+ * altered in flight.
15104
+ *
15105
+ * The verdicts map to this path's own severities, which are NOT the content frame's:
15106
+ * an `unusable` record leaves POSITION unknown and is soft here, because position may always
15107
+ * fall back to the witness stream. Identity is the half that may never be soft, and it is
15108
+ * established before this is called.
15109
+ */
15110
+ const auth = this.#verifyAuthorshipClaim(agentName, sessionId, structure1Cbor, s2Sig, contentHash);
15111
+ if (auth.verdict === "unusable") {
15112
+ /**
15113
+ * ⚠️ **THE ACK CAUSES REACH THIS PATH TOO, AND THEY ARE NOT A DECODER PROBLEM** — review F7.
15114
+ *
15115
+ * `#verifyAuthorshipClaim` has two callers. This one is reached from park RECOVERY, where
15116
+ * it is the only authorship check that runs — so 033-ACKEMIT's acknowledgement causes
15117
+ * started arriving here and fell into the generic `else` below, which logs
15118
+ * `…ordering.malformed` and buries the cause in `structure1Reason`. That event name sends
15119
+ * the next reader to audit a decoder for a record that decoded perfectly.
15120
+ *
15121
+ * SOFT, like every other `unusable` on this path, and deliberately: position may always
15122
+ * fall back to the witness stream, and a recovered parked message is authenticated by the
15123
+ * ENVELOPE's own signature rather than by this. What changes is that the log says which
15124
+ * thing disagreed.
15125
+ */
15126
+ if (ACK_HASH_REASONS.has(auth.reason)) {
15127
+ this.#logger.warn("session.content.ordering.ack_hash_unverified", {
15128
+ sessionId, correlationId, reason: auth.reason,
15129
+ impact: "a recovered message's acknowledgement of what its sender had received does not " +
15130
+ "reconcile with this side's record, so no canonical POSITION was taken from it. The " +
15131
+ "message itself is authenticated by its park envelope and is not refused here.",
15132
+ });
15133
+ }
15134
+ else if (auth.reason === AUTHORSHIP_CONTENT_HASH_MISMATCH) {
15135
+ // SOFT: the record does not describe this content. Nothing is proven about the signer's
15136
+ // identity — only that this record and these bytes do not belong together.
15137
+ this.#logger.warn("session.content.ordering.hash_mismatch", { sessionId, correlationId });
15138
+ }
15139
+ else if (auth.reason === AUTHORSHIP_SESSION_MISMATCH) {
15140
+ // Its own name, because `…malformed` points a reader at a decoder and this record decoded
15141
+ // perfectly — it belongs to another conversation. Unreachable in practice on this path:
15142
+ // `authenticateParkedEntry` binds `session_id` in the park TBS before anything is
15143
+ // unsealed, so a mismatched record cannot get this far. Named anyway, because an event
15144
+ // that lies about its cause is worse the day it does fire.
15145
+ this.#logger.warn("session.content.ordering.session_mismatch", { sessionId, correlationId });
15146
+ }
15147
+ else {
15148
+ this.#logger.warn("session.content.ordering.malformed", {
15149
+ sessionId, correlationId, structure1Reason: auth.reason,
15150
+ });
15151
+ }
13483
15152
  return { seq: null };
13484
15153
  }
13485
- // Verify the SENDER's Ed25519 signature over the exact signed bytes (structure1_cbor) — the same
13486
- // check the relay performs. Proves the counterparty committed to this (content_hash @ sequence).
13487
- if (!verify(s1Pubkey, structure1Cbor, s2Sig)) {
15154
+ if (auth.verdict === "refuted" && auth.reason === "bad_signature") {
13488
15155
  // FATAL. The sender supplied a signature and it does not verify against the key inside its
13489
15156
  // own record. That is not an absence we could not resolve — it is a proof that failed.
13490
15157
  this.#logger.warn("session.content.ordering.bad_signature", { sessionId, correlationId });
@@ -13494,55 +15161,23 @@ export class SessionNodeManager {
13494
15161
  // key. FAIL CLOSED (review L) — if the counterparty pubkey is unknown we cannot prove the signer,
13495
15162
  // so we do NOT trust the framed ordering record (fall back to the witness stream / arrival). The
13496
15163
  // "B does not trust the counterparty for ordering" invariant is non-negotiable; never fail open.
13497
- // Review M1: compare BYTES, not hex strings — `counterparty_pubkey` is stored verbatim from the
13498
- // IPC param and is never case-normalized, so a string compare would fail for a mixed-case
13499
- // pubkey and silently strip the canonical ordering from every message in that session.
13500
- const counterparty = this.getSessionRecord(agentName, sessionId)?.counterparty_pubkey;
13501
- if (!pubkeyMatchesHex(s1Pubkey, counterparty)) {
15164
+ if (auth.verdict !== "verified") {
13502
15165
  /**
13503
- * FATAL when the counterparty is KNOWN and the signer is someone else. SOFT when we simply
13504
- * do not know who the counterparty is.
15166
+ * FATAL when the counterparty is KNOWN and the signer is someone else (`refuted`). SOFT when
15167
+ * we simply do not know who the counterparty is (`verified_unmatched`) — the reasoning for
15168
+ * both, and the MITM substitution this catches, lives on `#verifyAuthorshipClaim`.
13505
15169
  *
13506
- * The fatal half is the session-open MITM detection from the 2026-08-21 T-of-N
13507
- * investigation, which found this check *"fires correctly, and its answer is thrown away."*
13508
- * A rogue quorum of the directories holding shares for agent B can sign a false
13509
- * SessionAssignment naming M's key as B's, and everything downstream is genuinely real
13510
- * M signs with M's own valid key. Nothing is missing for A to notice. This comparison is
13511
- * where the substitution shows, because `counterparty_pubkey` comes from A's own request
13512
- * and is untouched by anything the directory returns.
13513
- *
13514
- * ⚠️ IT SHOWS ONLY WHEN THE RECORD IS PRESENT (review F3). An earlier version of this
13515
- * comment said this was "the one place the substitution shows", full stop — and that
13516
- * asserted a property the code does not have: M can decline to supply an ordering record
13517
- * and be ingested without ever reaching this line. The caller logs
13518
- * `session.content.ordering.absent` so the weaker case is at least visible, and closing it
13519
- * needs a check that does not depend on the sender's cooperation — the relay's independent
13520
- * copy, `DOD-M15-CORROBORATE-1`.
13521
- *
13522
- * The soft half stays soft deliberately: `counterparty_unknown` means we cannot prove the
13523
- * signer either way, and refusing there would strand sessions whose record we failed to
13524
- * read rather than sessions that are under attack.
15170
+ * The soft half is the one that matters HERE: `counterparty_unknown` means we cannot prove
15171
+ * the signer either way, so we decline to take a POSITION from a record we cannot attribute,
15172
+ * and the caller falls back to the witness stream. Nothing about the message is refused on
15173
+ * this path that decision was already made, on the frame's own proof.
13525
15174
  */
13526
- const reason = counterparty ? "signer_not_counterparty" : "counterparty_unknown";
15175
+ const reason = auth.verdict === "refuted" ? auth.reason : "counterparty_unknown";
13527
15176
  this.#logger.warn("session.content.ordering.wrong_signer", { sessionId, reason, correlationId });
13528
- /**
13529
- * 024-ORPHANTRIAGE — THE VERIFIED SIGNER SURVIVES THE SESSION LOOKUP NOW.
13530
- *
13531
- * The `verify(...)` three lines above has already passed: whoever produced this record holds
13532
- * the private key for the key inside their own signed bytes. That is session-independent —
13533
- * it needs no `sessions` row and never did. The soft branch then threw it away, so the
13534
- * orphan branch downstream had nothing to go on and told the operator to go and talk to
13535
- * whoever sent it.
13536
- *
13537
- * Carried out only on the `counterparty_unknown` half. The fatal half needs nothing: the
13538
- * session freezes and no triage runs.
13539
- */
13540
- return counterparty
13541
- ? { seq: null, fatal: { reason } }
13542
- : { seq: null, verifiedSignerUnmatched: s1Pubkey };
15177
+ return auth.verdict === "refuted" ? { seq: null, fatal: { reason } } : { seq: null };
13543
15178
  }
13544
15179
  // Verified — record the relay-assigned canonical sequence (1-based → 0-based leaf index) for the gate.
13545
- this.recordWitnessedSequence(agentName, sessionId, contentHashHex, seq - 1);
15180
+ this.recordWitnessedSequence(agentName, sessionId, Buffer.from(contentHash).toString("hex"), seq - 1);
13546
15181
  this.#logger.info("session.content.ordering.recorded", {
13547
15182
  sessionId,
13548
15183
  canonicalSeq: seq - 1,
@@ -13550,18 +15185,15 @@ export class SessionNodeManager {
13550
15185
  correlationId,
13551
15186
  });
13552
15187
  /**
13553
- * DOD-M15-SEALWIRE-1 bullet 5: return the VERIFIED proof, not just the position.
15188
+ * THE POSITION, AND ONLY THE POSITION `DOD-M15-AUTHORSHIP-ABSENT-1`.
13554
15189
  *
13555
- * Three lines above, `verify(s1Pubkey, structure1Cbor, s2Sig)` has already passed and the
13556
- * signer has been matched to this session's counterparty. That is the strongest statement
13557
- * this daemon ever makes about who wrote a message and until now it was made, used to
13558
- * decide a sequence number, and then discarded. The transcript row that outlives it recorded
13559
- * only a direction.
13560
- *
13561
- * Returned rather than stashed, for the same reason `seq` is: a caller that has to go looking
13562
- * for it in a side map is a caller that will not.
15190
+ * `DOD-M15-SEALWIRE-1` bullet 5 had this return the verified proof as well, because this was
15191
+ * the only place a signer was ever checked and the transcript row needed it from somewhere.
15192
+ * The frame now carries the sender's signature beside the bytes it signs and the caller
15193
+ * verifies it before calling this at all, so the proof reaches the transcript from there. What
15194
+ * this answers is the question it is named for: WHERE the relay says this message sits.
13563
15195
  */
13564
- return { seq: seq - 1, senderPubkey: s1Pubkey, senderSig: s2Sig };
15196
+ return { seq: seq - 1 };
13565
15197
  }
13566
15198
  catch (err) {
13567
15199
  this.#logger.warn("session.content.ordering.decode_failed", {
@@ -13803,40 +15435,37 @@ export class SessionNodeManager {
13803
15435
  const encState = this.#contentEncryptionState(agentName, sessionId);
13804
15436
  let plaintextBody;
13805
15437
  if (declaredEncryption !== SESSION_CONTENT_ENCRYPTION_V1) {
13806
- this.#logger.error("session.content.refused", {
13807
- agentName, sessionId, correlationId,
13808
- reason: "content_encryption_absent_or_unknown",
15438
+ this.#refuseInboundContent(agentName, sessionId, "content_encryption_absent_or_unknown", contentHash, {
13809
15439
  declared: typeof declaredEncryption === "string" ? declaredEncryption : "(absent)",
13810
- impact: "the frame did not say it was encrypted under this session's key, so it was refused unread. Nothing was shown and nothing was stored.",
15440
+ impact: "the frame did not say it was encrypted under this session's key, so it was refused unread nothing was shown and this copy was not kept.",
13811
15441
  guidance: "STOPPED ON PURPOSE. A message arrived that was not encrypted under this session's key. " +
13812
15442
  "This build never sends one, so either something between you rewrote the frame, or your " +
13813
15443
  "counterparty is running something that is not CELLO. Confirm with them OUT OF BAND " +
13814
15444
  "before opening another session.",
13815
- });
15445
+ }, correlationId);
13816
15446
  return;
13817
15447
  }
13818
15448
  if (encState.key === null) {
13819
- this.#logger.error("session.content.refused", {
13820
- agentName, sessionId, correlationId,
13821
- reason: "no_session_key",
15449
+ this.#refuseInboundContent(agentName, sessionId, "no_session_key", contentHash, {
13822
15450
  detail: encState.reason,
13823
15451
  impact: "an encrypted message arrived and this side has no agreed key to open it, so it was refused unread rather than shown as garbage.",
13824
- guidance: CONTENT_ENCRYPTION_GUIDANCE[encState.reason],
13825
- });
15452
+ // Review F6: the RECEIVE-side wording. The send-side table explains what became of a
15453
+ // message this operator sent, which is the wrong direction entirely for a message they
15454
+ // cannot open.
15455
+ guidance: CONTENT_ENCRYPTION_INBOUND_GUIDANCE[encState.reason],
15456
+ }, correlationId);
13826
15457
  return;
13827
15458
  }
13828
15459
  const opened = openSessionContent(encState.key, contentBytes);
13829
15460
  if (opened === null) {
13830
15461
  // GCM's tag is the only thing separating "not for us" from "modified in flight", and this
13831
15462
  // side must not branch on which — that would be branching on attacker-controlled input.
13832
- this.#logger.error("session.content.refused", {
13833
- agentName, sessionId, correlationId,
13834
- reason: "decrypt_failed",
15463
+ this.#refuseInboundContent(agentName, sessionId, "decrypt_failed", contentHash, {
13835
15464
  impact: "the message did not decrypt under this session's agreed key — it was modified in flight, or it was encrypted under a different key. Refused unread.",
13836
- guidance: "STOPPED ON PURPOSE. Nothing was shown and nothing was stored. A message that fails this " +
13837
- "check has either been altered on its way to you or was not encrypted for this session. " +
13838
- "Confirm with your counterparty OUT OF BAND, then start a new session.",
13839
- });
15465
+ guidance: "STOPPED ON PURPOSE. Nothing was shown and this copy was not kept. A message that fails " +
15466
+ "this check has either been altered on its way to you or was not encrypted for this " +
15467
+ "session. Confirm with your counterparty OUT OF BAND, then start a new session.",
15468
+ }, correlationId);
13840
15469
  return;
13841
15470
  }
13842
15471
  plaintextBody = opened;
@@ -13854,6 +15483,12 @@ export class SessionNodeManager {
13854
15483
  // different fact, and it is now refused.
13855
15484
  const s1Cbor = frame["structure1_cbor"];
13856
15485
  const s2Cbor = frame["structure2_cbor"];
15486
+ /**
15487
+ * `DOD-M15-AUTHORSHIP-ABSENT-1` — the sender's own signature, carried BESIDE the bytes it
15488
+ * signs, exactly as `hash_submit` has always carried it. This field is why identity no longer
15489
+ * depends on the relay: it arrives whether or not a relay witnessed the message.
15490
+ */
15491
+ const senderSig = frame["sender_signature"];
13857
15492
  let framedSeq = null;
13858
15493
  /**
13859
15494
  * DOD-M15-SEALWIRE-1 bullet 5. Set ONLY when the ordering record verified — the signature
@@ -13869,37 +15504,108 @@ export class SessionNodeManager {
13869
15504
  * session record exists, so this stays `undefined` and nothing reads it.
13870
15505
  */
13871
15506
  let verifiedSignerUnmatched;
13872
- if (s1Cbor instanceof Uint8Array && s2Cbor instanceof Uint8Array) {
15507
+ /**
15508
+ * ─── NO PASSPORT, NO ENTRY — `DOD-M15-AUTHORSHIP-ABSENT-1` ───────────────────────────────
15509
+ *
15510
+ * ⚠️ **THIS COMMENT USED TO SAY THE OPPOSITE, AND THE SENTENCE IT REPLACES IS THE DEFECT.**
15511
+ * It read: *"it means the per-message signer check is **opt-in for the sender** — a party that
15512
+ * passed the peer gate and wants to avoid the comparison simply omits the proof."* That was an
15513
+ * accurate description of the code, which is why it is rewritten here rather than deleted: it
15514
+ * is the sentence a reader with a coding agent finds, and it must now describe what the code
15515
+ * does. A frame that supplies nothing checkable is REFUSED. Omitting the proof buys the sender
15516
+ * nothing except a message that does not arrive.
15517
+ *
15518
+ * The old reasoning was sound as far as it went — the signature was only ever DELIVERED inside
15519
+ * the relay's Structure 2, so refusing on its absence would have made the relay a precondition
15520
+ * for reading mail. It stopped one field short: the signature travels beside the bytes it
15521
+ * signs now, on every content frame, so identity no longer needs the relay and position still
15522
+ * does not require identity.
15523
+ *
15524
+ * ⚠️ REFUSED, NOT FROZEN. A frozen session is only cleared by opening a new one, and the
15525
+ * overwhelmingly likely cause of an absent proof is a counterparty on an older build. The
15526
+ * freeze is for a proof that FAILED (below, and in `#recordFrameOrdering`) — a positive fact
15527
+ * about their key.
15528
+ */
15529
+ if (!(s1Cbor instanceof Uint8Array) || !(senderSig instanceof Uint8Array)) {
15530
+ this.#refuseUnprovenAuthorship(agentName, sessionId, "authorship_proof_absent", contentHash, {
15531
+ // WHICH half is missing. A sender on an older build supplies neither; a stripped frame is
15532
+ // likelier to be missing one, and an investigator should not have to guess which.
15533
+ hasStructure1: s1Cbor instanceof Uint8Array,
15534
+ hasSenderSignature: senderSig instanceof Uint8Array,
15535
+ }, correlationId);
15536
+ return;
15537
+ }
15538
+ const authorship = this.#verifyAuthorshipClaim(agentName, sessionId, s1Cbor, senderSig, contentHash);
15539
+ if (authorship.verdict === "refuted") {
15540
+ /**
15541
+ * THE FORENSIC LINE, BEFORE THE FREEZE. `session.content.identity.frozen` records that a
15542
+ * session was stopped; this records WHICH check stopped it and on WHICH proof — the frame's
15543
+ * own signature, not the relay's copy of it. The two used to be the same event because there
15544
+ * was only one place a signer was checked; there are two now, and an investigation that
15545
+ * cannot tell them apart is looking at the wrong half of the wire.
15546
+ */
15547
+ this.#logger.warn("session.content.authorship.refuted", {
15548
+ agentName, sessionId, correlationId, reason: authorship.reason,
15549
+ impact: "a message arrived with a proof of authorship that FAILED — it does not verify, or it is signed by a key that is not this session's counterparty. Nothing was ingested and the session is being frozen.",
15550
+ });
15551
+ await this.#freezeOnIdentityFailure(agentName, sessionId, authorship.reason, correlationId);
15552
+ return;
15553
+ }
15554
+ if (authorship.verdict === "unusable") {
15555
+ // A replayed claim gets its own name on BOTH surfaces, not just in the log context: it is
15556
+ // the one `unusable` cause that may be adversarial, and it is the one the operator can act
15557
+ // on. The others are a peer whose build or bytes we could not read.
15558
+ /**
15559
+ * 033-ACKEMIT — AND THE THREE ACKNOWLEDGEMENT CAUSES GET THEIR OWN SURFACE REASON, for the
15560
+ * same argument that gave the replay one: `authorship_proof_unusable` tells the operator the
15561
+ * proof was "unreadable, or signed over different content", and for these it is neither.
15562
+ * The proof is perfect; what it CLAIMS TO HAVE SEEN is wrong. Filing them under the generic
15563
+ * name would send someone to audit a decoder, and would spend the operator's attention
15564
+ * asking their counterparty about a version number that is not the question.
15565
+ */
15566
+ this.#refuseUnprovenAuthorship(agentName, sessionId, authorship.reason === AUTHORSHIP_SESSION_MISMATCH
15567
+ ? "authorship_wrong_conversation"
15568
+ : ACK_HASH_REASONS.has(authorship.reason)
15569
+ /**
15570
+ * ⚠️ THE SPECIFIC CAUSE, NOT THE CLASS — review F5, and the diff's own comment on
15571
+ * `ACK_HASH_REASONS` had already said why: "the operator's next move differs for
15572
+ * each." It then collapsed all three into ONE surface reason carrying ONE sentence,
15573
+ * so the three names survived only in a log field nobody reads. For an absent
15574
+ * acknowledgement the shared impact was flatly false — there is no part that "does
15575
+ * not match", because there is no part — and for the other two the shared guidance
15576
+ * sent the reader to ask about a build version that cannot be the cause.
15577
+ */
15578
+ ? authorship.reason
15579
+ : "authorship_proof_unusable", contentHash, { detail: authorship.reason }, correlationId);
15580
+ return;
15581
+ }
15582
+ if (authorship.verdict === "verified") {
15583
+ verifiedAuthorship = { senderPubkey: authorship.senderPubkey, senderSig: authorship.senderSig };
15584
+ }
15585
+ else {
15586
+ verifiedSignerUnmatched = authorship.senderPubkey;
15587
+ }
15588
+ if (s2Cbor instanceof Uint8Array) {
13873
15589
  const ordering = this.#recordFrameOrdering(agentName, sessionId, s1Cbor, s2Cbor, contentHash, correlationId);
13874
15590
  if (ordering.fatal) {
13875
15591
  await this.#freezeOnIdentityFailure(agentName, sessionId, ordering.fatal.reason, correlationId);
13876
15592
  return;
13877
15593
  }
13878
15594
  framedSeq = ordering.seq;
13879
- if (ordering.senderPubkey !== undefined && ordering.senderSig !== undefined) {
13880
- verifiedAuthorship = { senderPubkey: ordering.senderPubkey, senderSig: ordering.senderSig };
13881
- }
13882
- verifiedSignerUnmatched = ordering.verifiedSignerUnmatched;
13883
15595
  }
13884
15596
  else {
13885
15597
  /**
13886
- * Review F3 THE WEAKER GUARANTEE MUST NOT BE INDISTINGUISHABLE FROM THE STRONGER ONE.
13887
- *
13888
- * A frame with no ordering record is still ingested, and that is correct: it is the
13889
- * documented relay-degraded path, and refusing it would make the relay a precondition for
13890
- * reading mail. But it means the per-message signer check is **opt-in for the sender** — a
13891
- * party that passed the peer gate and wants to avoid the comparison simply omits the proof.
13892
- * Silently, until now: nothing recorded that a message arrived unverified, so the log looked
13893
- * identical to one where every message had been checked.
15598
+ * POSITION IS THE ONLY THING THAT CAN BE ABSENT NOW, and this event is about position.
13894
15599
  *
13895
- * Not fatal, and deliberately not: an absent record proves nothing about the signer, and
13896
- * refusing on an absence would strand every relay-degraded session. What closes the omission
13897
- * case is relay-side corroboration `DOD-M15-CORROBORATE-1` where the relay holds the
13898
- * sender's signed hash independently and never routes it through this daemon.
15600
+ * It fires on the relay-degraded path, where the sender had no witnessed record to stamp
15601
+ * on. The message is ingested its author is proven, above, by the frame's own signature —
15602
+ * and only its place in the canonical sequence falls back to the witness stream. Refusing
15603
+ * here would make the relay a precondition for reading mail, which is the thing this unit
15604
+ * was careful NOT to do.
13899
15605
  */
13900
15606
  this.#logger.info("session.content.ordering.absent", {
13901
15607
  agentName, sessionId, correlationId,
13902
- impact: "this frame carried no signed ordering record, so its SIGNER was not verified for this message it was ingested on the strength of the authenticated transport alone",
15608
+ impact: "this frame carried no relay ordering record, so its POSITION in the canonical sequence is not known from the frame and falls back to the witness stream. Its AUTHOR was verified from the frame's own signature.",
13903
15609
  });
13904
15610
  }
13905
15611
  // AC-001: carry the sender's correlationId from the frame into the receive
@@ -13925,6 +15631,22 @@ export class SessionNodeManager {
13925
15631
  // acknowledged `persisted` — the sender's TTF→park backstop then guarantees the
13926
15632
  // missing-earlier message is fetchable, and dedup absorbs the redundant copy.
13927
15633
  if (ingest.ok && !ingest.held) {
15634
+ /**
15635
+ * 033-ACKEMIT review F1 — ACKNOWLEDGE WHAT ARRIVED, HERE, not when the relay gets round to
15636
+ * delivering its copy back to us.
15637
+ *
15638
+ * Placed after a successful, non-held ingest deliberately: a HELD frame is not yet a durable
15639
+ * leaf and is not acknowledged `persisted` either, so claiming to have seen it would put a
15640
+ * position in our signed claim that our own record does not yet hold.
15641
+ *
15642
+ * `framedSeq` is the relay's canonical position taken from the sender's own signed ordering
15643
+ * record and verified before it got here. When it is absent the message arrived with no
15644
+ * ordering record — the withheld-submit case — and there is no position to acknowledge,
15645
+ * whatever we hold of the content. That limit is structural to a (position, content) pair
15646
+ * and it is what the carried-leaf follow-on closes.
15647
+ */
15648
+ if (framedSeq !== null)
15649
+ this.#noteAcknowledgeable(agentName, sessionId, framedSeq, contentHash);
13928
15650
  void this.#sendDeliveryAck(agentName, sessionId, contentHash, correlationId);
13929
15651
  }
13930
15652
  }
@@ -14233,17 +15955,27 @@ export class SessionNodeManager {
14233
15955
  /**
14234
15956
  * DOD-M12B-SESSION-SEED-1 (review F8): drop it zeroed, like every other seed.
14235
15957
  *
14236
- * (review F7, DECIDED AGAINST — deliberately NOT reusing this seed for the replacement.)
15958
+ * (review F7, STILL DECIDED AGAINST — deliberately NOT reusing this seed for the
15959
+ * replacement — but its stated blocker is GONE and the reason has changed. Restated rather
15960
+ * than reworded, because a decision whose premise has been reversed is a decision nobody
15961
+ * has actually made.)
15962
+ *
14237
15963
  * Reuse is attractive: this receiver's peer id may already be inside a `session_offer_accept`
14238
15964
  * the counterparty is acting on, and a rebuild in that window is the documented "we record
14239
- * an identity that no longer exists… every send in this direction parks forever" defect. But
14240
- * a preserved identity would have to be handed to the candidate loop in
14241
- * `#startReceiverNode`, whose rejected candidates are stopped WITHOUT awaiting `start()`
14242
- * so two nodes could be briefly live on one advertised peer id, which is review F1, a HIGH,
14243
- * and the reason each candidate now mints its own. Fixing F7 properly means bounding and
14244
- * awaiting the loser's teardown first, and an unawaited stop is precisely what the current
14245
- * code chose to avoid a stuck libp2p teardown blocking receiver creation. Filed as
14246
- * follow-on work rather than trading a MEDIUM fix for a HIGH regression.
15965
+ * an identity that no longer exists… every send in this direction parks forever" defect.
15966
+ *
15967
+ * The old blocker was that a preserved identity would reach the candidate loop, whose
15968
+ * rejected candidates were stopped WITHOUT awaiting `start()`, putting two live nodes on one
15969
+ * advertised peer id. **032-RELAYSPREAD already crossed that line**: the walk now runs one
15970
+ * shared seed through every candidate, with a settlement-chained teardown, and it is safe
15971
+ * there because the receiver's gater admits nobody inbound.
15972
+ *
15973
+ * What still stops reuse HERE is different and is about the OLD node, not the new one. This
15974
+ * rebuild path awaits `sr.node.stop()`, but a stop can hang on a stuck libp2p teardown, and
15975
+ * handing the replacement the same identity before the previous receiver is provably dead
15976
+ * would put two nodes on a peer id a COUNTERPARTY has been told to dial — which is not the
15977
+ * candidate case at all: that node has a content handler and can be promoted. Doing it
15978
+ * safely needs a bounded, verified teardown first. Still follow-on work.
14247
15979
  */
14248
15980
  sr.seed.fill(0);
14249
15981
  try {
@@ -14455,7 +16187,19 @@ export class SessionNodeManager {
14455
16187
  // "No relay would grant" and "there was no relay to ask" are different facts and lead to
14456
16188
  // different places — the first at relay capacity, the second at this agent's directory
14457
16189
  // connection. Without this they are the same sentence.
14458
- reservationsRequested: (this.#directoryRelayEndpoints.get(agentName)?.length ?? 0) > 0,
16190
+ //
16191
+ // 032-RELAYSPREAD: this was also called `reservationsRequested` — the same mis-naming as
16192
+ // the reachability events, in its worst form, because here the value is a BOOLEAN under a
16193
+ // name that reads as a count. It is NOT `relaysOffered`: that field counts the merged
16194
+ // candidate list the walk actually asks (directory pool + persisted endpoints, minus
16195
+ // quarantine), and this reads the directory pool alone. Two populations must not share
16196
+ // one field name, so this one is named for what it measures.
16197
+ hadRelayToAsk: (this.#directoryRelayEndpoints.get(agentName)?.length ?? 0) > 0,
16198
+ // …and HOW MANY the walk actually asks, so this event stands on its own instead of
16199
+ // needing the last reachability line to be read beside it. Same population and same
16200
+ // meaning as `relaysOffered` everywhere else: the merged, quarantine-filtered candidate
16201
+ // list.
16202
+ relaysOffered: this.#reservationCircuitAddrs(agentName).addrs.length,
14459
16203
  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",
14460
16204
  });
14461
16205
  }
@@ -14500,11 +16244,27 @@ export class SessionNodeManager {
14500
16244
  // BOUNDED, never on this 30-second grid. A reservation is scarce: the relay holds it for its
14501
16245
  // full TTL even after the client disconnects, and churning attempts across a fleet is how a
14502
16246
  // relay is exhausted (`#startReceiverNode` records that hazard).
14503
- if (!sr.hasReservation || sr.relayPeerId === undefined) {
14504
- this.#retryReservationIfDue(agentName);
14505
- continue;
16247
+ if (sr.relayPeerIds.length === 0) {
16248
+ // …unless one has arrived since. Review F4, same class as the recompute below: the
16249
+ // slow-start path installs a receiver before every circuit has bound, so "held nothing at
16250
+ // install" is not the same fact as "holds nothing now". Adopting it here is what stops the
16251
+ // retry ladder rebuilding a receiver that is already reachable.
16252
+ const arrived = heldRelayIdsOf(sr.node)
16253
+ .filter((id) => sr.node.getConnections().some((c) => c.peerId === id && c.status === "open"));
16254
+ if (arrived.length === 0) {
16255
+ this.#retryReservationIfDue(agentName);
16256
+ continue;
16257
+ }
16258
+ sr.relayPeerIds = arrived;
16259
+ sr.gater.setReservedRelayPeers(arrived);
16260
+ this.#logger.info("session.standing_receiver.reservation.gained", {
16261
+ agentName,
16262
+ relayPeerIds: arrived,
16263
+ reservationsHeld: arrived.length,
16264
+ });
14506
16265
  }
14507
- // It has one — any earlier retry budget, and the reason the last attempt failed, are stale.
16266
+ // It has at least one — any earlier retry budget, and the reason the last attempt failed, are
16267
+ // stale.
14508
16268
  this.#srReservationRetry.delete(agentName);
14509
16269
  this.#srLastRejectionReason.delete(agentName);
14510
16270
  // Watch the CONNECTION to the relay, not the circuit address.
@@ -14522,24 +16282,106 @@ export class SessionNodeManager {
14522
16282
  // investigation turned on. Without the status check a registered corpse reads as "still
14523
16283
  // connected", the rebuild never fires, and the agent silently stops being reachable while
14524
16284
  // this loop reports it healthy. The comment above claimed liveness; only this tests it.
14525
- const stillConnected = sr.node.getConnections()
14526
- .some((c) => c.peerId === sr.relayPeerId && c.status === "open");
14527
- const stillAdvertising = sr.node.listenAddresses().some((a) => a.includes("/p2p-circuit"));
14528
- if (stillConnected && stillAdvertising)
16285
+ // 032-RELAYSPREAD PER RELAY, and the health question is now a COUNT.
16286
+ //
16287
+ // This used to evaluate one peer id and rebuild the entire standing receiver when it went
16288
+ // false. With a pool of one that was the only thing it could do; with a pool of three it is
16289
+ // the churn engine — every relay is another watchdog subject, and a full rebuild per loss
16290
+ // multiplies the 30-second grid by the size of the pool while throwing away reservations that
16291
+ // are perfectly healthy.
16292
+ /**
16293
+ * RECOMPUTED FROM THE NODE, never filtered down from the stored list. Review F4: filtering
16294
+ * `sr.relayPeerIds` makes it SHRINK-ONLY, and a list that can only shrink cannot see a
16295
+ * circuit arrive. Three things went wrong with that, and the first one happens routinely:
16296
+ * - the slow-start path installs the receiver before every circuit has bound, so a relay
16297
+ * that binds four seconds later was invisible to this watchdog and absent from the
16298
+ * gater's carve-out set FOREVER — its AutoNAT probe reply refused by our own gate;
16299
+ * - shrinking to zero then rebuilt a receiver that was announcing live circuits, which is
16300
+ * the exact defect this unit is against;
16301
+ * - anything that ever restores a circuit could not be counted.
16302
+ * Reading the node's own addresses costs the same and has none of that.
16303
+ */
16304
+ const open = sr.node.getConnections().filter((c) => c.status === "open").map((c) => c.peerId);
16305
+ const stillHeld = heldRelayIdsOf(sr.node).filter((id) => open.includes(id));
16306
+ const lost = sr.relayPeerIds.filter((id) => !stillHeld.includes(id));
16307
+ const gained = stillHeld.filter((id) => !sr.relayPeerIds.includes(id));
16308
+ sr.relayPeerIds = stillHeld;
16309
+ if (gained.length > 0) {
16310
+ // A circuit this receiver did not have at install. Said out loud because it is the visible
16311
+ // half of the slow-start case, and because it is the moment that relay earns its inbound
16312
+ // carve-out — a silent widening of the gate is not something to do without a line.
16313
+ this.#logger.info("session.standing_receiver.reservation.gained", {
16314
+ agentName,
16315
+ relayPeerIds: gained,
16316
+ reservationsHeld: stillHeld.length,
16317
+ });
16318
+ }
16319
+ if (lost.length === 0) {
16320
+ // Nothing lost. The gater still gets the current set, because `gained` may have widened it.
16321
+ if (gained.length > 0)
16322
+ sr.gater.setReservedRelayPeers(stillHeld);
14529
16323
  continue;
14530
- // DOD-RELAY-KEEPALIVE-1 (review F4): carry the CAUSE, not just the exit point.
14531
- // `relay_connection_gone` says where this was noticed a poll of getConnections() by which
14532
- // time the abort reason that actually killed the link is long discarded. The relay client for
14533
- // this (agent, relay) pair kept the error that ended its reader; that is the nearest thing to
14534
- // an upstream cause available here, and its absence is how 2,061 of these went untraced.
14535
- const upstreamReason = this.#relayClients.get(`${agentName}::${sr.relayPeerId}`)?.getLastReaderError();
14536
- this.#logger.warn("session.standing_receiver.reservation.lost", {
14537
- agentName,
14538
- relayPeerId: sr.relayPeerId,
14539
- reason: stillConnected ? "circuit_address_vanished" : "relay_connection_gone",
14540
- ...(upstreamReason ? { upstreamReason } : {}),
14541
- });
14542
- void this.#rebuildStandingReceiver(agentName);
16324
+ }
16325
+ // REVOKE FIRST. A relay whose reservation is gone must lose its inbound carve-out in the same
16326
+ // breath as the loss is noticed, or the gater's bound quietly becomes "granted one once".
16327
+ sr.gater.setReservedRelayPeers(stillHeld);
16328
+ for (const relayPeerId of lost) {
16329
+ // DOD-RELAY-KEEPALIVE-1 (review F4): carry the CAUSE, not just the exit point.
16330
+ // `relay_connection_gone` says where this was noticed — a poll of getConnections() — by
16331
+ // which time the abort reason that actually killed the link is long discarded. The relay
16332
+ // client for this (agent, relay) pair kept the error that ended its reader; that is the
16333
+ // nearest thing to an upstream cause available here, and its absence is how 2,061 of these
16334
+ // went untraced.
16335
+ const upstreamReason = this.#relayClients.get(`${agentName}::${relayPeerId}`)?.getLastReaderError();
16336
+ this.#logger.warn("session.standing_receiver.reservation.lost", {
16337
+ agentName,
16338
+ relayPeerId,
16339
+ reason: open.includes(relayPeerId) ? "circuit_address_vanished" : "relay_connection_gone",
16340
+ ...(upstreamReason ? { upstreamReason } : {}),
16341
+ reservationsHeld: stillHeld.length,
16342
+ // The line an operator reads, and the two cases are not the same event at all.
16343
+ impact: stillHeld.length > 0
16344
+ ? "this agent still holds " + stillHeld.length + " other circuit reservation(s), so it "
16345
+ + "stays dialable from behind NAT and the receiver is NOT rebuilt. Losing one relay "
16346
+ + "costs this agent nothing it can feel."
16347
+ : "this agent now holds NO circuit reservation, so nobody behind a home router can "
16348
+ + "reach it. The receiver is being rebuilt against the rest of the pool.",
16349
+ });
16350
+ }
16351
+ if (stillHeld.length === 0) {
16352
+ // ZERO HELD IS STILL THE LOUD, STRUCTURAL CASE — the agent is unreachable behind NAT and
16353
+ // only a new node can take a new reservation, because a circuit listener is fixed at node
16354
+ // creation.
16355
+ void this.#rebuildStandingReceiver(agentName);
16356
+ continue;
16357
+ }
16358
+ /**
16359
+ * STILL REACHABLE, SO THE RECEIVER STANDS, AND NOTHING ELSE HAPPENS HERE. That second half is
16360
+ * the part worth reading, because the obvious next line is wrong twice over.
16361
+ *
16362
+ * **A LOST CONFIGURED CIRCUIT CANNOT BE RETAKEN BY THIS NODE.** Read out of
16363
+ * `@libp2p/circuit-relay-v2@4.2.5`, not assumed: for an explicit relay address
16364
+ * `transport/listener.js#listen()` is a ONE-SHOT — it reserves once and nothing calls it
16365
+ * again; `reservation-store.js#removeReservation()` clears the refresh timeout and deletes
16366
+ * the entry; and the listener's `_onAddRelayPeer` returns early for `type === 'configured'`,
16367
+ * so even a later reservation would not be announced. A circuit listener is fixed at node
16368
+ * creation, and the only thing that takes a new one is a NEW NODE — which is exactly the
16369
+ * rebuild this branch exists to refuse.
16370
+ *
16371
+ * **AND RE-PROVING TO THE LOST RELAY WOULD REBUILD THE RECEIVER ANYWAY.** Review F3: an
16372
+ * earlier version called `#authenticateStandingReceiver` here to "remove the relay-side
16373
+ * reason for the revocation". That function ends with `if (refusal?.tryAnotherRelay) { …
16374
+ * void this.#rebuildStandingReceiver(agentName); }` — and a dead or misconfigured relay is
16375
+ * precisely the one that answers that way. So the common case was: lose relay A while
16376
+ * holding B, decline to rebuild, prove to A, A refuses, rebuild the whole receiver and throw
16377
+ * B's healthy reservation away. The churn engine, re-entered through the back door.
16378
+ *
16379
+ * **THE BOUND, STATED PLAINLY BECAUSE IT IS A REAL SHORTFALL AGAINST THE DoD:** a lost
16380
+ * circuit is gone until the receiver is next rebuilt for another reason. What the agent buys
16381
+ * is that it never STOPS BEING REACHABLE while that is true — the surviving relays carry it,
16382
+ * the loss is named in the log with its cause, and the lost relay's inbound carve-out is
16383
+ * revoked above. That is availability, not restoration in place.
16384
+ */
14543
16385
  }
14544
16386
  }
14545
16387
  /**
@@ -14738,20 +16580,42 @@ export class SessionNodeManager {
14738
16580
  }
14739
16581
  }
14740
16582
  async #startReceiverNode(agentName, sessionId, gater, candidateCircuitAddrs, correlationId) {
16583
+ /**
16584
+ * 032-RELAYSPREAD — **ONE SEED FOR THE RECEIVER, REUSED ACROSS RELAYS**, replacing
16585
+ * DOD-M12B-SESSION-SEED-1's seed-per-candidate.
16586
+ *
16587
+ * The agent is ONE identity and must be dialable at ONE peer id through any of its circuits, so
16588
+ * every reservation this walk collects has to belong to the same key. A seed per relay would
16589
+ * give the agent a different peer id down each circuit — N half-agents, none of them the one
16590
+ * the counterparty was told to dial.
16591
+ *
16592
+ * ⚠️ THE RULE THIS REPLACES WAS RIGHT ABOUT ITS OWN CASE, so here is what changed and what did
16593
+ * not. Its hazard is real and survives: a rejected candidate is torn down while its `start()`
16594
+ * may still be in flight, so two nodes can briefly be live on this peer id. Two things bound it
16595
+ * now, and neither existed when that rule was written:
16596
+ * - **THE ONE THAT CARRIES THE WEIGHT: DOD-M15-ASSIGN-1** made a standing receiver's gater
16597
+ * admit NOBODY inbound until a session offer names the dialer. The old rule's stated danger
16598
+ * — "sharing this gater, so it admits dials … an open endpoint under our advertised id" —
16599
+ * is not true of this gater any more. `#startReceiverNode` has exactly one caller and it
16600
+ * constructs that gater with `allowedPeerId: null` and an empty reserved set, so an
16601
+ * overlapping candidate is an endpoint that refuses everyone.
16602
+ * - the teardown is chained onto the candidate's OWN start promise (the `#buildRevivedNode`
16603
+ * pattern, verified against libp2p 3.3.2: `stop()` returns immediately unless the status is
16604
+ * `started`, and through the whole timeout window it is `starting`, so the old unawaited
16605
+ * `stop()` stopped nothing). ⚠️ This bounds the LEAK, not the OVERLAP — a timed-out
16606
+ * candidate is not awaited and the walk moves straight to the next one on the same seed, so
16607
+ * overlap is the normal shape of that case, not a remote possibility. It guarantees the
16608
+ * loser dies, and nothing more.
16609
+ * `#buildRevivedNode` already runs a fixed identity through this same walk for the same reason.
16610
+ */
16611
+ const receiverSeed = randomBytes(32);
16612
+ /** Circuit addresses whose relay ACTUALLY GRANTED this identity a reservation. */
16613
+ const grantedAddrs = [];
16614
+ // For `spread.grant_not_bound` below: the walk's own duration is measured against the relay's
16615
+ // two-minute proof memory, so it has to be a number rather than an inference.
16616
+ const walkStartedAt = Date.now();
14741
16617
  for (const circuitAddr of candidateCircuitAddrs) {
14742
- // DOD-M12B-SESSION-SEED-1: A SEED PER CANDIDATE, NOT ONE FOR THE LOOP.
14743
- //
14744
- // A rejected candidate is stopped with an unawaited `void …then(() => candidate.stop())`
14745
- // while its `start()` may still be in flight, so two candidate nodes can briefly be live at
14746
- // once. Sharing one seed would give both the SAME peer id — and the loser would then be a
14747
- // second live node under the identity we advertise in `session_offer_accept`, sharing this
14748
- // gater (so it admits dials) with no content handler registered. Inbound arriving there goes
14749
- // nowhere, and it is an open endpoint under our advertised id: the "connection a malicious
14750
- // agent can farm for" the tenet names. Before seeds existed the loser had its own random key
14751
- // and was harmless; introducing a shared seed is what would have made it dangerous.
14752
- //
14753
- // Nothing reads the seed before the winner is installed, so per-candidate costs nothing.
14754
- const candidateSeed = randomBytes(32);
16618
+ const candidateSeed = receiverSeed;
14755
16619
  /**
14756
16620
  * DOD-M15-RELAYSLOTS-1 — **TWO ATTEMPTS PER RELAY: ask, prove, ask again.**
14757
16621
  *
@@ -14766,7 +16630,7 @@ export class SessionNodeManager {
14766
16630
  * announces no circuit address for it, because it only announces addresses for reservations
14767
16631
  * its own relay-discovery made. The agent would hold a slot nobody could dial through.
14768
16632
  */
14769
- let candidateNode;
16633
+ let candidateGranted = false;
14770
16634
  // Set when the relay refused the AGENT rather than being unwilling itself: every other relay
14771
16635
  // in the pool answers identically, so the walk ends here rather than reproducing it N times.
14772
16636
  let candidateRefusedAgent = false;
@@ -14782,9 +16646,13 @@ export class SessionNodeManager {
14782
16646
  const timedOut = Symbol("reservation_timeout");
14783
16647
  let outcome = "failed";
14784
16648
  let error = "";
16649
+ // KEEP THE START PROMISE. Every candidate now carries the receiver's identity, so an
16650
+ // abandoned one must be reliably torn down rather than best-effort — and only its own start
16651
+ // promise says when it is stoppable (see the seed note above).
16652
+ const startP = candidate.start();
14785
16653
  try {
14786
16654
  outcome = await Promise.race([
14787
- candidate.start().then(() => "started"),
16655
+ startP.then(() => "started"),
14788
16656
  new Promise((resolve) => {
14789
16657
  timer = setTimeout(() => resolve(timedOut), this.#srReservationTimeoutMs);
14790
16658
  }),
@@ -14802,7 +16670,15 @@ export class SessionNodeManager {
14802
16670
  // completes the handshake and simply grants nothing, leaving a node that looks
14803
16671
  // started and is reachable by nobody.
14804
16672
  if (outcome === "started" && candidate.listenAddresses().some((a) => a.includes("/p2p-circuit"))) {
14805
- candidateNode = candidate;
16673
+ candidateGranted = true;
16674
+ // The probe has done its job: this relay grants THIS identity. Tear it down and ask the
16675
+ // next relay — the reservation is re-taken by the final node below, which is the only one
16676
+ // that can listen on every granted address at once. AWAITED, because the next probe comes
16677
+ // up on this same peer id.
16678
+ try {
16679
+ await candidate.stop();
16680
+ }
16681
+ catch { /* it may never have finished starting */ }
14806
16682
  break;
14807
16683
  }
14808
16684
  /**
@@ -14890,27 +16766,125 @@ export class SessionNodeManager {
14890
16766
  ...(error !== "" ? { error } : {}),
14891
16767
  correlationId,
14892
16768
  });
14893
- // Abandon it. start() may still be parked on a dial, so stop() is best-effort
14894
- // and must never block the fallback.
14895
- void Promise.resolve()
14896
- .then(() => candidate.stop())
14897
- .catch(() => { });
16769
+ // Abandon it — but on its OWN settlement, never best-effort. `start()` may still be parked on
16770
+ // a dial, and this candidate carries the receiver's identity: an unawaited `stop()` on a node
16771
+ // whose status is still `starting` returns without stopping anything, and the node then goes
16772
+ // live on our peer id with nothing left holding a reference to kill it.
16773
+ void startP.then(() => candidate.stop().catch(() => { }), () => { });
14898
16774
  break;
14899
16775
  }
14900
- if (candidateNode)
14901
- return { node: candidateNode, seed: candidateSeed };
16776
+ if (candidateGranted)
16777
+ grantedAddrs.push(circuitAddr);
16778
+ // 032-RELAYSPREAD: DO NOT BREAK ON THE FIRST GRANT. The walk used to stop here, which is why
16779
+ // an agent held exactly one reservation and losing that relay cost it every NAT'd caller for
16780
+ // however long detection happened to take. It now asks every remaining relay.
14902
16781
  if (candidateRefusedAgent)
14903
16782
  break;
14904
16783
  }
14905
- const plainSeed = randomBytes(32);
14906
- const plain = await this.#createAgentNode(agentName, {
16784
+ /**
16785
+ * THE RECEIVER, listening on EVERY granted circuit address.
16786
+ *
16787
+ * One node per agent, as before — what changed is how many circuits it announces. Each address
16788
+ * here belongs to a relay that granted THIS seed moments ago and therefore still remembers the
16789
+ * identity, so the final node's first ask is the one that succeeds; the two-attempt dance was
16790
+ * already paid per relay in the walk.
16791
+ *
16792
+ * ⚠️ RACED AGAINST A DEADLINE, and that is measured rather than cautious: `#buildRevivedNode`
16793
+ * records a live 2026-08-18 result where a node handed two relay addresses at once with no
16794
+ * deadline never finished starting at all (10,002ms and counting). Its identity was unproven at
16795
+ * both relays, which is not this case — but "not this case" is a prediction, and the standing
16796
+ * receiver is the thing that makes an agent reachable, so it does not wait on one.
16797
+ *
16798
+ * An empty `grantedAddrs` yields the plain TCP floor, exactly as before: reachable by peers
16799
+ * that can dial directly, and loud about it (`session.standing_receiver.reservation.none`).
16800
+ */
16801
+ const node = await this.#createAgentNode(agentName, {
14907
16802
  sessionId,
14908
16803
  connectionGater: gater,
14909
16804
  nodeType: "standing_receiver",
14910
- transportPrivateKey: plainSeed,
16805
+ ...(grantedAddrs.length > 0 ? { circuitRelayListenAddrs: grantedAddrs } : {}),
16806
+ transportPrivateKey: receiverSeed,
14911
16807
  });
14912
- await plain.start();
14913
- return { node: plain, seed: plainSeed };
16808
+ if (grantedAddrs.length === 0) {
16809
+ await node.start();
16810
+ return { node, seed: receiverSeed };
16811
+ }
16812
+ /**
16813
+ * ⚠️ SLOW AND FAILED ARE DIFFERENT ANSWERS AND MUST NOT SHARE A BRANCH. Review F1: a single
16814
+ * `.catch(() => false)` around this race collapsed every `start()` REJECTION into the deadline
16815
+ * branch — and `CelloNodeImpl.start()` rejects by design, stopping the node and throwing
16816
+ * `listen_failed` when no direct (non-circuit) listener materialised. That is the guard the
16817
+ * transport keeps precisely so `FaultTolerance.NO_FATAL` cannot mask a real `EADDRINUSE`.
16818
+ *
16819
+ * Swallowed, it installed a STOPPED node as the agent's front door: no addresses to advertise,
16820
+ * `#tryCreateStandingReceiver` never saw a failure so the M8B F14 retry never fired, and the
16821
+ * operator was told the receiver "did not finish binding every circuit inside the deadline" and
16822
+ * "is reachable through those" — sending them to the relay fleet for a port held by an orphan
16823
+ * daemon on their own machine. The rejection is rethrown so it reaches
16824
+ * `session.node.create.failed` with its own cause, exactly as it does on the no-relay path.
16825
+ */
16826
+ let deadline;
16827
+ let startError;
16828
+ const started = node.start().then(() => "ok", (err) => { startError = err; return "failed"; });
16829
+ const outcome = await Promise.race([
16830
+ started,
16831
+ new Promise((resolve) => {
16832
+ // Per granted relay: each circuit listener is its own dial and its own reservation, so a
16833
+ // pool of three must not be judged on a budget sized for one.
16834
+ deadline = setTimeout(() => resolve("slow"), this.#srReservationTimeoutMs * grantedAddrs.length);
16835
+ }),
16836
+ ]);
16837
+ if (deadline !== undefined)
16838
+ clearTimeout(deadline);
16839
+ if (outcome === "failed")
16840
+ throw startError;
16841
+ /**
16842
+ * GRANTED IN THE WALK, REFUSED AT INSTALL — a distinct fact and, until this line, an invisible
16843
+ * one. The receiver would simply report `reservationsHeld: 2` where 3 relays granted, with
16844
+ * nothing naming which relay went missing or why.
16845
+ *
16846
+ * ⚠️ IT HAS A KNOWN CAUSE AND A CROSS-REPO CLOCK. The walk stops the granted candidate and the
16847
+ * node below RE-ASKS, which works because the relay remembers the proof — for
16848
+ * `PROVEN_PEER_MEMORY_MS = 2 minutes` (`relay-connection-gater.ts`, trustless-cello). The walk
16849
+ * costs up to `#srReservationTimeoutMs` × 2 attempts per relay, so a pool of three at the
16850
+ * 15s default can spend 90 seconds before the final node asks relay 1 again. The earliest
16851
+ * proof can expire before it is used, and that is what this event catches.
16852
+ */
16853
+ const boundRelays = new Set(heldRelayIdsOf(node));
16854
+ const grantedButUnbound = grantedAddrs
16855
+ .map((a) => CIRCUIT_RELAY_ID.exec(a)?.[1])
16856
+ .filter((id) => id !== undefined && !boundRelays.has(id));
16857
+ if (grantedButUnbound.length > 0) {
16858
+ this.#logger.warn("session.standing_receiver.spread.grant_not_bound", {
16859
+ agentName,
16860
+ relayPeerIds: grantedButUnbound,
16861
+ relaysGranted: grantedAddrs.length,
16862
+ reservationsHeld: boundRelays.size,
16863
+ walkMs: Date.now() - walkStartedAt,
16864
+ correlationId,
16865
+ impact: "these relays granted this agent a reservation during the walk and then bound no " +
16866
+ "circuit on the receiver itself, so the agent is reachable through fewer relays than it " +
16867
+ "earned. The relay remembers a proof for two minutes; if walkMs is near or past that, " +
16868
+ "the proof expired before the receiver asked and the walk is what needs shortening — " +
16869
+ "not the relay fleet.",
16870
+ });
16871
+ }
16872
+ if (outcome === "slow") {
16873
+ // NOT a teardown, and now this line means only what it says: the node is starting and has not
16874
+ // finished. It is installed with whatever circuits did materialise, because some reachability
16875
+ // beats none and the reservation watchdog is what settles the rest.
16876
+ this.#logger.warn("session.standing_receiver.spread.slow_start", {
16877
+ agentName,
16878
+ relaysGranted: grantedAddrs.length,
16879
+ circuitAddrs: node.listenAddresses().filter((a) => a.includes("/p2p-circuit")).length,
16880
+ budgetMs: this.#srReservationTimeoutMs * grantedAddrs.length,
16881
+ correlationId,
16882
+ impact: "the receiver did not finish binding every circuit it was granted inside the " +
16883
+ "deadline, so it is being installed with the circuits it has. It is reachable through " +
16884
+ "those; the reservation watchdog re-checks the rest on its next tick.",
16885
+ });
16886
+ }
16887
+ return { node, seed: receiverSeed };
14914
16888
  }
14915
16889
  /** One standing-receiver create attempt (extracted for the M8B F14 retry loop). */
14916
16890
  async #tryCreateStandingReceiver(agentName, correlationId) {
@@ -14932,11 +16906,15 @@ export class SessionNodeManager {
14932
16906
  }
14933
16907
  let node;
14934
16908
  /**
14935
- * DOD-M12B-SESSION-SEED-1 — the transport identity of the receiver that actually survived.
16909
+ * DOD-M12B-SESSION-SEED-1 — the transport identity of this receiver.
14936
16910
  *
14937
- * Minted per CANDIDATE inside `#startReceiverNode` and returned with the winner, not minted
14938
- * here: a rejected candidate is stopped without awaiting its `start()`, so two candidates can
14939
- * be briefly live, and one shared seed would put both on the same advertised peer id.
16911
+ * Minted ONCE inside `#startReceiverNode` and returned with the node, not minted here. It is
16912
+ * one seed for the whole walk (032-RELAYSPREAD): the receiver reserves with every relay that
16913
+ * grants, and an agent must be dialable at ONE peer id through any of its circuits, so every
16914
+ * reservation has to belong to the same key. What makes that safe is DOD-M15-ASSIGN-1 — the
16915
+ * gater above admits NOBODY inbound — not the teardown, which bounds how long a rejected
16916
+ * candidate lives rather than preventing it from overlapping. See the seed note in
16917
+ * `#startReceiverNode` for the full argument.
14940
16918
  *
14941
16919
  * FRESH EVERY TIME, which is the privacy property rather than an implementation detail. A
14942
16920
  * receiver serves at most one session (it is promoted into the session at handoff and replaced),
@@ -14990,39 +16968,39 @@ export class SessionNodeManager {
14990
16968
  probers: this.#autoNatProbers(),
14991
16969
  });
14992
16970
  autoNat.emitInitialResult();
14993
- const circuitAddrs = node.listenAddresses().filter((a) => a.includes("/p2p-circuit")).length;
14994
- // FROM THE ADDRESS THE NODE ACTUALLY HOLDS, not from `reservations.addrs[0]`.
14995
- //
14996
- // `#startReceiverNode` tries candidates in order and returns the FIRST that actually grants —
14997
- // so when candidate 0 refuses (the measured `relay_granted_no_reservation` case) and candidate 1
14998
- // grants, reading candidate 0's address records a relay we are not connected to. The watchdog
14999
- // then evaluates `getConnections().some(c => c.peerId === relayPeerId)` against that wrong peer,
15000
- // finds it false on every tick forever, and rebuilds on the 30-second grid churning the very
15001
- // reservations this unit exists to conserve. Dormant while the pool is size 1; the pool is
15002
- // designed to be larger.
15003
- // PREFER the held address, FALL BACK to the candidate strictly better than either alone.
15004
- // The held address is authoritative about which relay actually granted, but it is libp2p's
15005
- // string, not ours: if a transport ever reports the circuit address without the relay's peer id
15006
- // in `/p2p/<id>/p2p-circuit` form, reading only it would yield UNDEFINED, and an undefined
15007
- // relayPeerId makes the watchdog treat a perfectly healthy reservation as absent and rebuild it.
15008
- // That would be a regression on the single-relay case that works today. The candidate string is
15009
- // ours and always carries the id, so it is the safe floor.
15010
- const heldCircuitAddr = node.listenAddresses().find((a) => a.includes("/p2p-circuit"));
15011
- const CIRCUIT_RELAY_ID = /\/p2p\/([^/]+)\/p2p-circuit/;
15012
- const reservedRelayPeerId = circuitAddrs > 0
15013
- ? (heldCircuitAddr?.match(CIRCUIT_RELAY_ID)?.[1] ?? reservations.addrs[0]?.match(CIRCUIT_RELAY_ID)?.[1])
15014
- : undefined;
15015
- // DOD-M15-ASSIGN-1 review N3: the ONE relay this receiver actually reserved with earns the
15016
- // inbound AutoNAT carve-out — nothing else does. Set only when a reservation genuinely
15017
- // completed, so a directory that merely NAMES a relay cannot dial in behind it.
15018
- gater.setReservedRelayPeer(circuitAddrs > 0 && reservedRelayPeerId !== undefined ? reservedRelayPeerId : null);
16971
+ /**
16972
+ * EVERY RELAY THE NODE ACTUALLY HOLDS A CIRCUIT WITH — derived from the addresses the node
16973
+ * holds, never from `reservations.addrs`.
16974
+ *
16975
+ * The old code read `reservations.addrs[0]`'s relay id as a fallback, and its own comment
16976
+ * called the hazard "dormant while the pool is size 1; the pool is designed to be larger."
16977
+ * THIS UNIT IS WHAT MAKES THE POOL LARGER, so the dormant case wakes up: candidate 0 refusing
16978
+ * while candidate 1 grants recorded a relay we are not connected to, the watchdog found it
16979
+ * absent on every tick forever, and it rebuilt on the 30-second grid churning the very
16980
+ * reservations this unit exists to conserve. A candidate is a relay we ASKED; only a held
16981
+ * address is a relay that ANSWERED, and the fallback conflated the two.
16982
+ *
16983
+ * The fallback's own stated worry stands, and is answered by the count rather than by the
16984
+ * candidate list: if a transport ever reports a circuit address without the relay's peer id in
16985
+ * `/p2p/<id>/p2p-circuit` form, that address yields no id and is not counted as held so the
16986
+ * receiver reads as degraded and gets rebuilt, instead of reading as healthy against a relay
16987
+ * nobody is connected to. Degrading toward "rebuild" is the safe direction; the other one is
16988
+ * the silent unreachability this whole file exists to kill.
16989
+ */
16990
+ const heldRelayPeerIds = heldRelayIdsOf(node);
16991
+ const circuitAddrs = heldRelayPeerIds.length;
16992
+ const heldCircuitAddrs = node.listenAddresses().filter((a) => a.includes("/p2p-circuit"));
16993
+ // DOD-M15-ASSIGN-1 review N3, widened by 032-RELAYSPREAD: the relays this receiver actually
16994
+ // reserved with earn the inbound AutoNAT carve-out — nothing else does. Populated only from
16995
+ // reservations that genuinely completed, so a directory that merely NAMES a relay cannot dial
16996
+ // in behind it, however many relays it names.
16997
+ gater.setReservedRelayPeers(heldRelayPeerIds);
15019
16998
  this.#standingReceivers.set(agentName, {
15020
16999
  node,
15021
17000
  gater,
15022
17001
  autoNat,
15023
17002
  seed,
15024
- hasReservation: circuitAddrs > 0,
15025
- ...(reservedRelayPeerId !== undefined ? { relayPeerId: reservedRelayPeerId } : {}),
17003
+ relayPeerIds: heldRelayPeerIds,
15026
17004
  });
15027
17005
  this.#logger.info("session.node.created", {
15028
17006
  sessionId,
@@ -15036,31 +17014,54 @@ export class SessionNodeManager {
15036
17014
  // real session to exist, is what keeps this reservation alive past that grace window.
15037
17015
  // Best-effort and unawaited: a failure here costs nothing beyond the relay's own grace-window
15038
17016
  // revoke, which the reservation watchdog already treats as an ordinary lost reservation.
15039
- if (reservedRelayPeerId !== undefined && heldCircuitAddr !== undefined) {
15040
- void this.#authenticateStandingReceiver(agentName, node, reservedRelayPeerId, heldCircuitAddr, correlationId)
17017
+ // ONCE PER HELD RELAY. Each relay revokes independently — it times out the reservation of any
17018
+ // peer that has not proven key possession TO IT — so proving to one of three and calling the
17019
+ // receiver authenticated would lose the other two circuits about fifteen seconds later, which
17020
+ // is the same silent unreachability with two more relays paying for it.
17021
+ for (const relayPeerId of heldRelayPeerIds) {
17022
+ const heldCircuitAddr = heldCircuitAddrs.find((a) => a.includes(`/p2p/${relayPeerId}/p2p-circuit`));
17023
+ if (heldCircuitAddr === undefined)
17024
+ continue;
17025
+ void this.#authenticateStandingReceiver(agentName, node, relayPeerId, heldCircuitAddr, correlationId)
15041
17026
  .catch((err) => {
15042
17027
  this.#logger.warn("session.standing_receiver.relay_auth.failed", {
15043
17028
  agentName,
15044
- relayPeerId: reservedRelayPeerId,
17029
+ relayPeerId,
15045
17030
  error: extractErrorMessage(err),
15046
17031
  correlationId,
15047
17032
  });
15048
17033
  });
15049
17034
  }
15050
- // DOD-NAT-REACHABILITY-1 observability: how reachable did this receiver come
15051
- // up? circuitAddrs === 0 with reservations requested means every relay
15052
- // refused/was unreachable the agent is deaf to NAT'd initiators (public
15053
- // ones can still connect directly). That must be LOUD, not a quiet shrug.
17035
+ // DOD-NAT-REACHABILITY-1 observability: how reachable did this receiver come up? Zero held
17036
+ // while relays were offered means every relay refused or was unreachable — the agent is deaf
17037
+ // to NAT'd initiators (public ones can still connect directly). That must be LOUD, not a quiet
17038
+ // shrug.
17039
+ //
17040
+ // 032-RELAYSPREAD — TWO NUMBERS, SO TWO NAMES. Both events used to carry one field,
17041
+ // `reservationsRequested`, holding `reservations.addrs.length` — the size of the CANDIDATE
17042
+ // list, under a name that reads as a count of asks. That is why "the client already requests a
17043
+ // reservation with every relay it knows" read as true in an audit: the outcome was one and the
17044
+ // request was one too, and a single field could report neither.
17045
+ // relaysOffered — how many relays were in the candidate list (deduped by relay peer id in
17046
+ // `#reservationCircuitAddrs`, so it counts relays, not addresses).
17047
+ // reservationsHeld — how many reservations this node actually holds, counted the only way
17048
+ // that proves a grant: ANNOUNCED /p2p-circuit listen addresses. `start()`
17049
+ // resolving is not enough — a relay out of reservation slots completes the
17050
+ // handshake, grants nothing, and leaves a node that looks started and is
17051
+ // dialable by nobody.
15054
17052
  this.#logger.info("session.standing_receiver.reachability", {
15055
17053
  agentName,
15056
- circuitAddrs,
15057
- reservationsRequested: reservations.addrs.length,
17054
+ relaysOffered: reservations.addrs.length,
17055
+ reservationsHeld: circuitAddrs,
15058
17056
  correlationId,
15059
17057
  });
15060
17058
  if (reservations.addrs.length > 0 && circuitAddrs === 0) {
15061
17059
  this.#logger.warn("session.standing_receiver.reservation.none", {
15062
17060
  agentName,
15063
- reservationsRequested: reservations.addrs.length,
17061
+ relaysOffered: reservations.addrs.length,
17062
+ // Zero by this branch's own condition, and stated rather than implied: the event reads
17063
+ // "offered 3, held 0" on its own, without the reader having to find the gate above it.
17064
+ reservationsHeld: circuitAddrs,
15064
17065
  relayPeerIds: reservations.relayPeerIds,
15065
17066
  correlationId,
15066
17067
  });
@@ -15359,7 +17360,9 @@ export class SessionNodeManager {
15359
17360
  }
15360
17361
  this.#relayClients.set(clientKey, client);
15361
17362
  }
15362
- client.registerSession(sessionId, node, this.#relayLeafHandler(agentName, sessionId, correlationId));
17363
+ // 033-ACKEMIT: a revived session re-registers with no assignment in hand, so the genesis comes
17364
+ // from the entry that was just restored above.
17365
+ client.registerSession(sessionId, node, this.#relayLeafHandler(agentName, sessionId, correlationId), undefined, this.#sessionGenesisPrevRoot(agentName, sessionId));
15363
17366
  const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
15364
17367
  if (entry) {
15365
17368
  entry.relayClient = client;