@cello-protocol/daemon 0.0.192 → 0.0.194

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 (36) hide show
  1. package/dist/agent-id-migration.d.ts.map +1 -1
  2. package/dist/agent-id-migration.js +6 -0
  3. package/dist/agent-id-migration.js.map +1 -1
  4. package/dist/daemon.js +12 -5
  5. package/dist/daemon.js.map +1 -1
  6. package/dist/inbound-sessions.d.ts +1 -1
  7. package/dist/inbound-sessions.d.ts.map +1 -1
  8. package/dist/inbound-sessions.js +124 -5
  9. package/dist/inbound-sessions.js.map +1 -1
  10. package/dist/initiate-session-handler.d.ts.map +1 -1
  11. package/dist/initiate-session-handler.js +19 -0
  12. package/dist/initiate-session-handler.js.map +1 -1
  13. package/dist/park-envelope.d.ts +17 -0
  14. package/dist/park-envelope.d.ts.map +1 -1
  15. package/dist/park-envelope.js.map +1 -1
  16. package/dist/refusal-reasons.d.ts +23 -0
  17. package/dist/refusal-reasons.d.ts.map +1 -1
  18. package/dist/refusal-reasons.js +66 -0
  19. package/dist/refusal-reasons.js.map +1 -1
  20. package/dist/retry-queue.d.ts +12 -1
  21. package/dist/retry-queue.d.ts.map +1 -1
  22. package/dist/retry-queue.js +14 -5
  23. package/dist/retry-queue.js.map +1 -1
  24. package/dist/session-node-manager.d.ts +30 -19
  25. package/dist/session-node-manager.d.ts.map +1 -1
  26. package/dist/session-node-manager.js +657 -102
  27. package/dist/session-node-manager.js.map +1 -1
  28. package/dist/session-own-chain-store.d.ts +65 -0
  29. package/dist/session-own-chain-store.d.ts.map +1 -0
  30. package/dist/session-own-chain-store.js +75 -0
  31. package/dist/session-own-chain-store.js.map +1 -0
  32. package/dist/session-relay-client.d.ts +35 -3
  33. package/dist/session-relay-client.d.ts.map +1 -1
  34. package/dist/session-relay-client.js +211 -30
  35. package/dist/session-relay-client.js.map +1 -1
  36. package/package.json +5 -5
@@ -59,6 +59,7 @@ import { extractErrorMessage } from "./error-message.js";
59
59
  import { terminalRelayRefusal } from "./session-terminal-refusal.js";
60
60
  import { RelayReceiptStore } from "./relay-receipt-store.js";
61
61
  import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
62
+ import { SessionOwnChainStore } from "./session-own-chain-store.js";
62
63
  import { certifiedLeafSetFrom } from "./sealed-leaf-set.js";
63
64
  import { addColumnIfMissing } from "./column-birth.js";
64
65
  import { quarantineRedaction, retentionSentence } from "./quarantine-framing.js";
@@ -488,6 +489,20 @@ const AUTHORSHIP_SESSION_MISMATCH = "session_mismatch";
488
489
  * side looks like, and an error that names a party the code did not check is this milestone's
489
490
  * founding defect.
490
491
  */
492
+ /**
493
+ * `DOD-M15-SELFCHAIN-1` — the sender's link to their OWN previous message names content this side
494
+ * did not receive from them as their last one.
495
+ *
496
+ * ⚠️ THIS IS THE ONE THAT MEANS THE ORDER OF THE CONVERSATION IS IN DISPUTE, and it is why it is
497
+ * named apart from the acknowledgement reasons above rather than folded in with them. Those say the
498
+ * sender is wrong about what WE said; this says they are wrong about what THEY said, which is the
499
+ * only thing they cannot be honestly mistaken about for long.
500
+ *
501
+ * ⚠️ AND IT STILL NAMES WHAT WAS OBSERVED, NEVER A CONCLUSION. The same signal is produced by a
502
+ * peer reordering a conversation and by a peer whose own chain record went out of step after a
503
+ * restart, and this side cannot tell them apart.
504
+ */
505
+ const AUTHORSHIP_SELF_CHAIN_MISMATCH = "self_chain_mismatch";
491
506
  /** A v1 claim: it carries no `last_seen_hash`, so it asserts a POSITION and no content at all. */
492
507
  const AUTHORSHIP_ACK_HASH_ABSENT = "ack_hash_absent";
493
508
  /** The hash names content this side does not hold at the position the claim names. */
@@ -583,6 +598,14 @@ function carryContentHashInputs(carry) {
583
598
  }
584
599
  return inputs;
585
600
  }
601
+ /**
602
+ * How many of the counterparty's content hashes a session remembers for the self-link check.
603
+ *
604
+ * Bounded because a peer feeds it. 256 covers any realistic gap in our own copy of a conversation —
605
+ * a held message, one the inbound screen refused, one lost in flight — while keeping the memory a
606
+ * single session can cost fixed. Past the cap the check gets STRICTER, never looser.
607
+ */
608
+ const SELF_CHAIN_MEMORY = 256;
586
609
  export class SessionNodeManager {
587
610
  #factory;
588
611
  #logger;
@@ -599,6 +622,8 @@ export class SessionNodeManager {
599
622
  #relayReceiptStore = null;
600
623
  /** FED-OPTIONB-SEAL-001: the per-session leaf log (both parties) carried at a unilateral seal. */
601
624
  #sealLeafStore = null;
625
+ /** `DOD-M15-SELFCHAIN-1` — this agent's own last message per session, so the next one links to it. */
626
+ #ownChainStore = null;
602
627
  // M9-CORE-001: the inbound screening seam. Every byte that reaches the agent passes
603
628
  // through #appendVerifiedContent's buffer write; screenInbound gates it there, on every
604
629
  // arrival path (direct, held-release, recovered-park). Defaults to always-allow when no
@@ -678,6 +703,7 @@ export class SessionNodeManager {
678
703
  client = this.#detachedRelayClientBuilder?.(agentName, ep.relayPeerId, [...ep.relayAddrs], {
679
704
  receiptStore: this.#relayReceiptStore ?? undefined,
680
705
  sealLeafStore: this.#sealLeafStore ?? undefined,
706
+ ownChainStore: this.#ownChainStore ?? undefined,
681
707
  // DOD-M15-RELAYSLOTS-1: read at each auth, never snapshotted — the token expires hourly.
682
708
  onlineToken: () => this.getDirectoryOnlineToken(agentName),
683
709
  });
@@ -791,6 +817,7 @@ export class SessionNodeManager {
791
817
  client = this.#detachedRelayClientBuilder?.(agentName, relayPeerId, [baseRelayAddr], {
792
818
  receiptStore: this.#relayReceiptStore ?? undefined,
793
819
  sealLeafStore: this.#sealLeafStore ?? undefined,
820
+ ownChainStore: this.#ownChainStore ?? undefined,
794
821
  // DOD-M15-RELAYSLOTS-1: read at each auth, never snapshotted — the token expires hourly.
795
822
  onlineToken: () => this.getDirectoryOnlineToken(agentName),
796
823
  });
@@ -1143,6 +1170,8 @@ export class SessionNodeManager {
1143
1170
  /** The reason the last reservation attempt was refused, per agent — captured at the rejection so
1144
1171
  * the retry and give-up can name a CAUSE instead of only their own exit point. */
1145
1172
  #srLastRejectionReason = new Map();
1173
+ /** 032-RELAYSPREAD: when this agent's receiver was last re-spread, so it never rides the 30s grid. */
1174
+ #srLastRespreadAt = new Map();
1146
1175
  #srReservationRetry = new Map();
1147
1176
  #reservationWatchdog = null;
1148
1177
  /** DOD-PARK-DRAIN-1: how often the backstop drain rides the watchdog grid — see #parkedDrainBackstopTick. */
@@ -1993,6 +2022,20 @@ export class SessionNodeManager {
1993
2022
  this.#logger.info("persist.db.opened", { encrypted: true, migrated: migration.migrated });
1994
2023
  // PERSIST-002: the identity store (agents + manifest_state) lives in the same encrypted DB.
1995
2024
  ensureIdentitySchema(this.#db);
2025
+ /**
2026
+ * ⚠️ THE OWN-CHAIN STORE IS BUILT HERE, NOT LAZILY INSIDE THE RELAY-CLIENT BUILDERS —
2027
+ * `DOD-M15-SELFCHAIN-1`, review F2.
2028
+ *
2029
+ * It was constructed only when a relay client was attached, so a session that never attached one
2030
+ * left it null: nothing was ever recorded, and every message that side sent carried the same
2031
+ * self link. That is the exact defect this unit exists to close, on the path its own comments
2032
+ * call the one that matters most — a conversation that ran while the relay was down is precisely
2033
+ * the one whose order gets disputed later.
2034
+ *
2035
+ * The chain belongs to the AGENT and the SESSION. The relay is how the conversation travels; it
2036
+ * is not what makes the conversation provable.
2037
+ */
2038
+ this.#ownChainStore = new SessionOwnChainStore(this.#db, this.#logger);
1996
2039
  this.#db.exec(`
1997
2040
  CREATE TABLE IF NOT EXISTS sessions (
1998
2041
  session_id TEXT NOT NULL,
@@ -4596,6 +4639,20 @@ export class SessionNodeManager {
4596
4639
  "Close an existing session before starting a new one.",
4597
4640
  };
4598
4641
  }
4642
+ /**
4643
+ * ⚠️ THE "NO ASSIGNMENT" REFUSAL IS NOT HERE, AND THE PLACE IT MOVED TO IS THE POINT.
4644
+ *
4645
+ * `DOD-M15-SELFCHAIN-1`, ruled 2026-09-06: a session offered with no directory assignment is
4646
+ * suspicious and must be refused and surfaced. It was briefly enforced HERE, and that was the
4647
+ * wrong door: `createSessionNode` also runs on this agent's OWN outbound path, where the
4648
+ * counterparty has no say in whether an assignment exists. A refusal there fires on our own
4649
+ * initiations and says nothing about anyone's conduct.
4650
+ *
4651
+ * A counterparty can only attempt it INBOUND, so that is where it is refused and recorded —
4652
+ * see `inbound-sessions.ts`. What remains true here is the correctness backstop: a session with
4653
+ * no anchor cannot sign a chained message, so the SEND path refuses (`session_unchainable`)
4654
+ * rather than emitting a message whose place could never be proven.
4655
+ */
4599
4656
  // The session node N_A: either a FRESH ephemeral node (default), or — for the initiator
4600
4657
  // path (reuseStandingReceiver) — the standing receiver handed off as the session node. The
4601
4658
  // latter makes N_A's peer id equal the SESSION endpoint the initiator ADVERTISED to the
@@ -6146,6 +6203,7 @@ export class SessionNodeManager {
6146
6203
  this.#standingReceivers.clear();
6147
6204
  this.#srReservationRetry.clear();
6148
6205
  this.#srLastRejectionReason.clear();
6206
+ this.#srLastRespreadAt.clear();
6149
6207
  // Release the SQLite handle so the DB file is no longer held open after shutdown
6150
6208
  // (review L5). Queries guard on `#db === null` and degrade to empty/null.
6151
6209
  if (this.#db) {
@@ -7273,6 +7331,19 @@ export class SessionNodeManager {
7273
7331
  if (assignment) {
7274
7332
  return computeGenesisPrevRoot(assignment.participantA, assignment.participantB, Uint8Array.from(Buffer.from(sessionId, "hex")), assignment.sessionTimestamp);
7275
7333
  }
7334
+ /**
7335
+ * ⚠️ THE IN-MEMORY RECORD, READ BEFORE THE DATABASE — and this ordering is the fix, not a
7336
+ * cache.
7337
+ *
7338
+ * `recordSessionGenesis` is called BEFORE the session node exists, because registering the
7339
+ * session is what seeds the relay client's acknowledgement state and the seed has to be
7340
+ * available by then. At that moment there is no session ROW to write to — `createSessionNode`
7341
+ * inserts it — so a database-only record would still be empty at the one moment it is read.
7342
+ * The row is written from this map when the insert happens, and read back after a restart.
7343
+ */
7344
+ const recorded = this.#sessionGenesis.get(this.#k(agentName, sessionId));
7345
+ if (recorded)
7346
+ return recorded;
7276
7347
  /**
7277
7348
  * THE RESTART CASE. A session restored from the database re-registers with no assignment, so
7278
7349
  * the derivation above has nothing to work from and the stored copy is the only answer. Read
@@ -7286,7 +7357,9 @@ export class SessionNodeManager {
7286
7357
  const bytes = stored instanceof Uint8Array ? stored : Buffer.isBuffer(stored) ? new Uint8Array(stored) : null;
7287
7358
  // A stored value of the wrong width is not a genesis. Refusing it here sends the caller down its
7288
7359
  // own named refusal, which is a better outcome than signing an acknowledgement of 17 bytes.
7289
- return bytes && bytes.length === 32 ? bytes : undefined;
7360
+ if (bytes && bytes.length === 32)
7361
+ return bytes;
7362
+ return undefined;
7290
7363
  }
7291
7364
  /**
7292
7365
  * Persist the session's genesis prev_root, once, at the moment the assignment arrives.
@@ -7295,11 +7368,64 @@ export class SessionNodeManager {
7295
7368
  * change for the life of a session, so the second writer is either redundant or wrong, and the
7296
7369
  * first write is the one derived closest to the assignment that opened the session.
7297
7370
  */
7371
+ /**
7372
+ * Record the session's starting point from the ASSIGNMENT — `DOD-M15-SELFCHAIN-1`.
7373
+ *
7374
+ * ⚠️ **CALL THIS BEFORE `createSessionNode` / `acceptSession`, NOT AFTER.** Registering the
7375
+ * session is what seeds the relay client's acknowledgement state, so the value has to exist by
7376
+ * then; recorded afterwards, the first message of the session has nothing to chain to and is
7377
+ * refused. Both the initiator and the responder derive this from the same FROST-signed assignment
7378
+ * before they build anything.
7379
+ *
7380
+ * ⚠️ THE ANCHOR BELONGS TO THE SESSION, NOT TO THE RELAY, and treating it as the relay's was a
7381
+ * real gap. It was derived only when a relay assignment CARRY was present — and that carry is
7382
+ * built only for a relay-mode assignment that also carries a per-node relay signature. So a
7383
+ * direct-mode session, brokered and FROST-signed exactly like any other, recorded no starting
7384
+ * point at all, and every message on it had nothing to chain to.
7385
+ *
7386
+ * Both transport modes get their assignment from the same ceremony and derive the same value from
7387
+ * it. The relay is how the conversation travels; it is not what makes the conversation provable.
7388
+ */
7389
+ recordSessionGenesis(agentName, sessionId, participantA, participantB, sessionTimestamp) {
7390
+ this.#persistGenesisPrevRoot(agentName, sessionId, {
7391
+ participantA, participantB, sessionTimestamp,
7392
+ });
7393
+ }
7394
+ /**
7395
+ * The starting point of each live session's chain, in memory.
7396
+ *
7397
+ * ⚠️ NOT A CACHE OF THE DATABASE — it is the only copy that exists at the moment the value is
7398
+ * first needed. It is recorded before the session node is built, and the session ROW does not
7399
+ * exist until that build inserts it (`#insertSessionRow` writes the column from here). The row is
7400
+ * what survives a restart; this is what the session open itself reads.
7401
+ */
7402
+ #sessionGenesis = new Map();
7298
7403
  #persistGenesisPrevRoot(agentName, sessionId, assignment) {
7404
+ let genesis;
7405
+ try {
7406
+ genesis = computeGenesisPrevRoot(assignment.participantA, assignment.participantB, Uint8Array.from(Buffer.from(sessionId, "hex")), assignment.sessionTimestamp);
7407
+ }
7408
+ catch (err) {
7409
+ /**
7410
+ * The DERIVATION failed, which is a different failure from the write below and must not be
7411
+ * reported as one. It means the assignment's own fields are not what this function needs, and
7412
+ * no amount of database health would help.
7413
+ */
7414
+ this.#logger.error("session.genesis.derive.failed", {
7415
+ agentName, sessionId,
7416
+ error: err instanceof Error ? err.message : String(err),
7417
+ impact: "this session's starting point could not be computed from its assignment, so nothing " +
7418
+ "sent on it can be chained and every send will be refused by name. The session open " +
7419
+ "continues; the conversation cannot.",
7420
+ });
7421
+ return;
7422
+ }
7423
+ // The in-memory record FIRST, and unconditionally: it is what the session open reads, and it
7424
+ // must not depend on a database write that may not have anywhere to land yet.
7425
+ this.#sessionGenesis.set(this.#k(agentName, sessionId), genesis);
7299
7426
  if (!this.#db)
7300
7427
  return;
7301
7428
  try {
7302
- const genesis = computeGenesisPrevRoot(assignment.participantA, assignment.participantB, Uint8Array.from(Buffer.from(sessionId, "hex")), assignment.sessionTimestamp);
7303
7429
  this.#db
7304
7430
  .prepare("UPDATE sessions SET genesis_prev_root = ? WHERE agent_id = ? AND session_id = ? AND genesis_prev_root IS NULL")
7305
7431
  .run(Buffer.from(genesis), this.#requireAgentId(agentName), sessionId);
@@ -7940,6 +8066,10 @@ export class SessionNodeManager {
7940
8066
  // `finally` retires — same defect, other end, other cap (64 outbound per protocol per
7941
8067
  // connection). See the note on #handleContentStream's finally.
7942
8068
  let sendStream;
8069
+ // 🔗 `DOD-M15-SELFCHAIN-1`: true when THIS side built the claim, so this side owns advancing its
8070
+ // chain once the message has gone. Declared out here because the PARK path — which is also a
8071
+ // delivery — runs in the catch below.
8072
+ let ownClaimAwaitingSend = false;
7943
8073
  /**
7944
8074
  * 034-CARRYLEAF — HOISTED OUT OF THE TRY so the PARK path in the catch can carry them.
7945
8075
  *
@@ -7976,8 +8106,10 @@ export class SessionNodeManager {
7976
8106
  frameS1 = orderingS1;
7977
8107
  frameSig = orderingSig;
7978
8108
  let frameS2 = orderingS2;
8109
+ // A relay-witnessed claim advanced the chain on its ack; this flag covers the other case.
7979
8110
  if (frameS1 === undefined || frameSig === undefined) {
7980
8111
  const own = await this.#signOwnContentClaim(agentName, sessionId, entry, contentHash);
8112
+ ownClaimAwaitingSend = true;
7981
8113
  frameS1 = own.structure1;
7982
8114
  frameSig = own.signature;
7983
8115
  frameS2 = undefined;
@@ -8055,7 +8187,9 @@ export class SessionNodeManager {
8055
8187
  *
8056
8188
  * So the session key encrypts the copy that goes ON THE WIRE, below, and nothing else.
8057
8189
  */
8058
- this.#trackAwaitingAck(agentName, sessionId, content, contentHash, correlationId, orderingS1, orderingS2, contentHashAlg);
8190
+ // 034-CARRYLEAF: the SIGNED claim and its domain ride the awaiting entry, so a message that
8191
+ // ends up re-parked after a restart still reaches its recipient in a shape they can witness.
8192
+ this.#trackAwaitingAck(agentName, sessionId, content, contentHash, correlationId, frameS1 ?? orderingS1, orderingS2, contentHashAlg, frameSig, leafKind);
8059
8193
  /**
8060
8194
  * THE WIRE COPY. `content_hash` above was computed over the PLAINTEXT and stays that way: the
8061
8195
  * transcript, the seal and the salted hash all depend on it meaning what it means today, and
@@ -8150,6 +8284,16 @@ export class SessionNodeManager {
8150
8284
  // A close that failed for a benign reason costs a redundant park, which the receiver dedups
8151
8285
  // on the content hash. A false delivered costs the message.
8152
8286
  await stream.close();
8287
+ /**
8288
+ * 🔗 THE MESSAGE HAS GONE, SO THE CHAIN ADVANCES — `DOD-M15-SELFCHAIN-1`, review F7.
8289
+ *
8290
+ * Below `stream.close()` deliberately: close waits for the write buffer to drain, so a reset
8291
+ * mid-flush throws above this line and the bytes never left. Advancing before it would point
8292
+ * the chain at a message the counterparty never saw, and every later message would then be
8293
+ * refused by them for a reason that names tampering.
8294
+ */
8295
+ if (ownClaimAwaitingSend)
8296
+ this.#advanceOwnChain(agentName, sessionId, entry, contentHash);
8153
8297
  this.#clearSessionImpairment(agentName, sessionId, "direct_send", correlationId);
8154
8298
  return { ok: true, delivered: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(sentAuthorship === undefined ? {} : { authorship: sentAuthorship }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
8155
8299
  }
@@ -8221,6 +8365,17 @@ export class SessionNodeManager {
8221
8365
  */
8222
8366
  const attempt = await this.#parkContent(agentName, sessionId, hashHex, content, frameS1, orderingS2, contentHashAlg, frameSig, leafKind);
8223
8367
  if (attempt.outcome === "parked") {
8368
+ /**
8369
+ * 🔗 A PARK IS A DELIVERY, so the chain advances here too — `DOD-M15-SELFCHAIN-1`.
8370
+ *
8371
+ * The mailbox copy is sealed to the counterparty's long-term key and they WILL open it, so
8372
+ * the message is part of the conversation. Leaving the chain behind here would make this
8373
+ * side's next message link to something the counterparty has already moved past, and they
8374
+ * would refuse it as a broken chain — a false tamper report caused by our own relay being
8375
+ * briefly unreachable.
8376
+ */
8377
+ if (ownClaimAwaitingSend)
8378
+ this.#advanceOwnChain(agentName, sessionId, entry, contentHash);
8224
8379
  this.#noteImpairmentRetention(agentName, sessionId, "parked");
8225
8380
  return { ok: true, delivered: false, parked: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(sentAuthorship === undefined ? {} : { authorship: sentAuthorship }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
8226
8381
  }
@@ -11784,6 +11939,17 @@ export class SessionNodeManager {
11784
11939
  throw new Error(`patchRelayClientForTest: no active node for (${agentName}, ${sessionId})`);
11785
11940
  entry.relayClient = relayClient;
11786
11941
  entry.relaySessionIdBytes = relaySessionIdBytes;
11942
+ /**
11943
+ * ⚠️ REGISTER THE SESSION TOO — the seam must leave the state production leaves.
11944
+ *
11945
+ * A relay client that has never been told about a session holds no starting point for it, and
11946
+ * since `DOD-M15-SELFCHAIN-1` every submit on such a session is refused: there is nothing for
11947
+ * the chain links to anchor to. Production always registers, because attaching a relay is what
11948
+ * registration IS. A seam that attached the client and skipped the registration left fixtures
11949
+ * exercising a refusal path, and the failure surfaced as "the seal never happened" in a test
11950
+ * about away-mode replies.
11951
+ */
11952
+ relayClient.registerSession(Buffer.from(relaySessionIdBytes).toString("hex"), entry.node, undefined, entry.relayAssignment, this.#sessionGenesisPrevRoot(agentName, sessionId));
11787
11953
  }
11788
11954
  pushReceivedContentForTest(agentName, sessionId, seq, content, senderPubkey) {
11789
11955
  this.recordTranscriptMessage(agentName, sessionId, seq, "received", new TextEncoder().encode(content), "test");
@@ -11846,7 +12012,7 @@ export class SessionNodeManager {
11846
12012
  * route reads this map, and a v2 envelope omits the field entirely whenever the value is `sha256`,
11847
12013
  * which is every value in play today. That re-opened the exact finding the fix closed.
11848
12014
  */
11849
- #trackAwaitingAck(agentName, sessionId, content, contentHash, correlationId, structure1Cbor, structure2Cbor, contentHashAlg) {
12015
+ #trackAwaitingAck(agentName, sessionId, content, contentHash, correlationId, structure1Cbor, structure2Cbor, contentHashAlg, structure1Signature, leafKind) {
11850
12016
  const hashHex = Buffer.from(contentHash).toString("hex");
11851
12017
  const ackKey = this.#k(agentName, sessionId);
11852
12018
  let bySession = this.#awaitingAck.get(ackKey);
@@ -11868,7 +12034,7 @@ export class SessionNodeManager {
11868
12034
  // B2b-1 review F2: the algorithm rides WITH the entry. The TTF-expiry park route reads this map
11869
12035
  // minutes later, in-process, and without it that copy names nothing (= sha256) while the direct
11870
12036
  // frame named something else — the same message, two claims about what it is, no restart needed.
11871
- bySession.set(hashHex, { timer, content, correlationId, structure1Cbor, structure2Cbor, contentHashAlg });
12037
+ bySession.set(hashHex, { timer, content, correlationId, structure1Cbor, structure2Cbor, contentHashAlg, structure1Signature, leafKind });
11872
12038
  }
11873
12039
  /**
11874
12040
  * Resolve an awaiting-ACK entry on a `persisted` delivery ACK (AC-001/AC-002): cancel
@@ -11924,7 +12090,7 @@ export class SessionNodeManager {
11924
12090
  // M12-P12 (review pass 2): the ordering record travels on THIS path too. It is in hand — the
11925
12091
  // very next statement hands it to #parkContent — and a TTF row written without it re-parks in
11926
12092
  // arrival order, which is the divergent-leaf-index failure the durable columns exist to stop.
11927
- this.#onAwaitingTtf?.(agentName, sessionId, hashHex, entry.content, entry.structure1Cbor, entry.structure2Cbor, entry.contentHashAlg);
12093
+ this.#onAwaitingTtf?.(agentName, sessionId, hashHex, entry.content, entry.structure1Cbor, entry.structure2Cbor, entry.contentHashAlg, entry.structure1Signature, entry.leafKind);
11928
12094
  }
11929
12095
  catch (err) {
11930
12096
  this.#logger.error("content.park.backstop.failed", {
@@ -12253,6 +12419,28 @@ export class SessionNodeManager {
12253
12419
  * content on the direct path, and the ingest below is what decides whether the other route
12254
12420
  * succeeded. Reading it after would race the clear.
12255
12421
  */
12422
+ /**
12423
+ * ⚠️ **REFUSING AN UNNOTARIZABLE MAILBOX MESSAGE WAS TRIED HERE AND REVERTED — recorded so the
12424
+ * next attempt starts from what actually blocks it, not from the compatibility argument that
12425
+ * does not.**
12426
+ *
12427
+ * The mailbox is the remaining route for the withholding attack: a counterparty who parks a
12428
+ * message with no ordering record AND no signature over its ordering claim delivers something
12429
+ * readable that can never enter a receipt. The obvious fix is to refuse it here.
12430
+ *
12431
+ * **It cannot ship yet, and the reason is our OWN path, not an older peer's.** `SEC-1` AC5 is
12432
+ * explicit: the crash-backstop shape — signed by the sender, no ordering record — is legal and
12433
+ * must be accepted. That envelope is produced when content is queued before anything witnessed
12434
+ * it, and from the recipient's side it is INDISTINGUISHABLE from an attacker's stripped one. So
12435
+ * this refusal rejects our own crash recovery along with the attack.
12436
+ *
12437
+ * **What closes it:** make the crash backstop sign an ordering claim at enqueue time, the way
12438
+ * the live park path now does (`#signOwnContentClaim` already produces exactly this artifact).
12439
+ * Then "no ordering record and no signed claim" is a shape only a modified client emits, and
12440
+ * refusing it costs nothing real. The retry queue already carries the two columns for it —
12441
+ * `structure1_sig` and `leaf_kind` — which were added for this and are populated on the live
12442
+ * path today.
12443
+ */
12256
12444
  const refusedForAuthorship = this.#refusedOnDirectPath.get(memoKey)?.has(contentHashHex) === true;
12257
12445
  const result = await this.ingestReceivedContent(agentName, sessionId, env.content, contentHash, correlationId, recoveredSeq ?? undefined,
12258
12446
  // The envelope's own claim, verbatim — `undefined` on a v2 envelope, which resolves to
@@ -13142,9 +13330,31 @@ export class SessionNodeManager {
13142
13330
  * on which side of the send it sat on.
13143
13331
  */
13144
13332
  setSessionGenesisForTest(agentName, sessionId, genesis) {
13145
- this.#db
13146
- ?.prepare("UPDATE sessions SET genesis_prev_root = ? WHERE agent_id = ? AND session_id = ?")
13147
- .run(Buffer.from(genesis), this.#requireAgentId(agentName), sessionId);
13333
+ /**
13334
+ * ⚠️ WRITES THE SAME MAP PRODUCTION WRITES, deliberately `DOD-M15-SELFCHAIN-1`.
13335
+ *
13336
+ * This seam exists because a fixture builds a session below the paths that derive a starting
13337
+ * point from a directory assignment; it does NOT exist to install a second, quieter source of
13338
+ * the value. Sharing the map means a fixture and a real session read through exactly the same
13339
+ * lookup, so a change to that lookup cannot pass the tests while breaking production.
13340
+ *
13341
+ * ⚠️ CALL IT BEFORE `createSessionNode`, the same rule production follows: registering the
13342
+ * session is what seeds the relay client's acknowledgement state, and a value recorded after
13343
+ * that leaves the first send with nothing to chain to.
13344
+ */
13345
+ this.#sessionGenesis.set(this.#k(agentName, sessionId), Uint8Array.from(genesis));
13346
+ /**
13347
+ * The durable half is BEST EFFORT. Some fixtures run against a database whose schema was never
13348
+ * created, and a seam that threw there would turn "this fixture has no sessions table" into a
13349
+ * failure of whatever it was actually testing. Harmless when the row does not exist yet either:
13350
+ * `#insertSessionRow` writes the column from the map above.
13351
+ */
13352
+ try {
13353
+ this.#db
13354
+ ?.prepare("UPDATE sessions SET genesis_prev_root = ? WHERE agent_id = ? AND session_id = ?")
13355
+ .run(Buffer.from(genesis), this.#requireAgentId(agentName), sessionId);
13356
+ }
13357
+ catch { /* see above — the in-memory half is the load-bearing one */ }
13148
13358
  }
13149
13359
  /**
13150
13360
  * Test seam: drop the agreed key while leaving the session up — the state before an exchange
@@ -14703,35 +14913,21 @@ export class SessionNodeManager {
14703
14913
  ?? (() => { const g = this.#sessionGenesisPrevRoot(agentName, sessionId); return g ? { seq: 0, hash: g } : undefined; })();
14704
14914
  if (!ack) {
14705
14915
  /**
14706
- * ⚠️ **v1, AND ONLY BECAUSE THERE IS NOTHING TO ACKNOWLEDGE see `#verifyAcknowledgedContent`
14707
- * for the receiving half of the same rule, which is what makes this safe rather than a
14708
- * downgrade.**
14916
+ * ⚠️ NO STARTING POINT MEANS NO SEND — `DOD-M15-SELFCHAIN-1`.
14709
14917
  *
14710
- * Reaching here means this session has no recorded starting point AND has received nothing.
14711
- * The claim it produces is `last_seen_seq: 0` with no hash: "I have seen nothing of yours, and
14712
- * I assert nothing about your content." That is honest, and it is not the fail-open the unit
14713
- * closes the hole is a claim that names a POSITION with no content behind it, and this names
14714
- * no position. A receiver refuses a v1 claim the moment it acknowledges position 1 or beyond.
14918
+ * This used to emit a shorter claim: `last_seen_seq: 0` with no hashes, on the argument that
14919
+ * "I have seen nothing of yours" is honest and asserts nothing false. It IS honest, and it is
14920
+ * no longer a shape this protocol has. Both chain links are required, and a session with no
14921
+ * recorded starting point has nothing for them to anchor to.
14715
14922
  *
14716
- * It does not throw, and an earlier version did. Sessions brokered without a relay assignment
14717
- * are real the directory does not always return one and throwing there stopped those
14718
- * sessions sending at all, which trades a hole this claim does not have for a failure of the
14719
- * thing the product is for.
14923
+ * Refusing costs a message on a session that was brokered without an assignment. Sending one
14924
+ * costs the ability to prove the order of the whole conversation later, and the cost is
14925
+ * invisible until someone disputes it which is exactly the failure this unit exists to end.
14926
+ * Refuse loudly, and say what to do about it.
14720
14927
  */
14721
- this.#logger.info("session.content.claim.unacknowledged", {
14722
- agentName, sessionId,
14723
- impact: "this message is signed with no acknowledgement of anything received, because this " +
14724
- "session has no recorded starting point and nothing has arrived on it yet. It binds the " +
14725
- "sender and the content as always; it makes no claim about the counterparty's messages.",
14726
- });
14727
- const bare = encodeStructure1({
14728
- contentHash,
14729
- senderPubkey: await signer.getPublicKey(),
14730
- sessionId: sessionIdBytes,
14731
- lastSeenSeq: 0,
14732
- timestamp: Date.now(),
14733
- });
14734
- return { structure1: bare, signature: await signer.sign(bare) };
14928
+ throw new Error("session_unchainable: this session has no recorded starting point on this machine, so a " +
14929
+ "message sent on it could not link to anything and its place in the conversation could " +
14930
+ "never be proven. Restart the session so it is registered with its genesis.");
14735
14931
  }
14736
14932
  const structure1 = encodeStructure1({
14737
14933
  contentHash,
@@ -14743,9 +14939,76 @@ export class SessionNodeManager {
14743
14939
  lastSeenSeq: ack.seq,
14744
14940
  timestamp: Date.now(),
14745
14941
  lastSeenHash: ack.hash,
14942
+ /**
14943
+ * ─── THE SELF LINK ON THE UNWITNESSED PATH — `DOD-M15-SELFCHAIN-1` ────────────────────────
14944
+ *
14945
+ * This is the path that matters most for it. A conversation that ran while the relay was down
14946
+ * is precisely the one whose order gets disputed later, so the chain cannot depend on a relay
14947
+ * having been there to witness it.
14948
+ *
14949
+ * ⚠️ ONE CHAIN, READ THROUGH THE RELAY CLIENT FIRST. This used to read only the durable
14950
+ * store, while the witnessed path reads an in-memory map first — so a session that mixed the
14951
+ * two, which is every session where the relay comes and goes, walked two different chains and
14952
+ * the lagging one produced a link the counterparty refuses.
14953
+ *
14954
+ * ⚠️ AND THE LAST FALLBACK IS THE SESSION GENESIS, NOT `ack.hash`. `ack.hash` is what the
14955
+ * COUNTERPARTY last said; it is only the genesis until they have said anything. Falling back
14956
+ * to it meant this agent's own first message linked to the other party's message — refused by
14957
+ * every checker, and reported as tampering against a party that had done nothing.
14958
+ */
14959
+ prevOwnHash: this.#ownChainOf(agentName, sessionId, entry, await signer.getPublicKey())
14960
+ ?? this.#sessionGenesisPrevRoot(agentName, sessionId)
14961
+ ?? ack.hash,
14746
14962
  });
14963
+ /**
14964
+ * ⚠️ THE CHAIN IS NOT ADVANCED HERE, AND IT USED TO BE — review F7.
14965
+ *
14966
+ * `SessionOwnChainStore.record` says in capitals that it is called AFTER the send succeeds. This
14967
+ * ran at signing time, before a stream was even opened. Any failure between here and delivery
14968
+ * left the chain pointing at a message the counterparty never saw, and every later message was
14969
+ * then refused by them for a reason that names tampering. A retransmission must re-use the same
14970
+ * predecessor, because a retransmission is the same message.
14971
+ *
14972
+ * `#advanceOwnChain` is called by the send path at each point where the message has actually
14973
+ * gone — the direct send, and the relay park that is the fallback for it.
14974
+ */
14747
14975
  return { structure1, signature: await signer.sign(structure1) };
14748
14976
  }
14977
+ /**
14978
+ * This agent's own last message on a session, from the ONE chain both send paths share.
14979
+ *
14980
+ * `undefined` means it has not spoken here yet, which is the session genesis and not an absence.
14981
+ * The caller supplies that; this does not guess.
14982
+ */
14983
+ #ownChainOf(agentName, sessionId, entry, ownPubkey) {
14984
+ const relayHex = entry?.relaySessionIdBytes
14985
+ ? Buffer.from(entry.relaySessionIdBytes).toString("hex")
14986
+ : sessionId;
14987
+ return entry?.relayClient?.lastOwnHash(relayHex)
14988
+ ?? this.#ownChainStore?.lastOwnHash(Buffer.from(ownPubkey).toString("hex"), sessionId)
14989
+ ?? undefined;
14990
+ }
14991
+ /**
14992
+ * Record what this agent just sent — called ONLY once the message has actually gone.
14993
+ *
14994
+ * Writes through the relay client when there is one, so the in-memory chain the witnessed path
14995
+ * reads and the durable row stay one chain rather than two. Falls back to the store directly for
14996
+ * a session with no relay client at all, which is exactly the unwitnessed case this unit exists
14997
+ * to cover.
14998
+ */
14999
+ #advanceOwnChain(agentName, sessionId, entry, contentHash) {
15000
+ const relayHex = entry?.relaySessionIdBytes
15001
+ ? Buffer.from(entry.relaySessionIdBytes).toString("hex")
15002
+ : sessionId;
15003
+ if (entry?.relayClient) {
15004
+ entry.relayClient.noteOwnLeaf(relayHex, contentHash);
15005
+ return;
15006
+ }
15007
+ const ownPubkeyHex = this.#ownPubkeyHex(agentName);
15008
+ if (!ownPubkeyHex)
15009
+ return;
15010
+ this.#ownChainStore?.record(ownPubkeyHex, sessionId, contentHash, Date.now());
15011
+ }
14749
15012
  /**
14750
15013
  * `DOD-M15-AUTHORSHIP-ABSENT-1` — DID THIS SENDER PROVE THEY WROTE THIS MESSAGE?
14751
15014
  *
@@ -14898,6 +15161,20 @@ export class SessionNodeManager {
14898
15161
  const ackVerdict = this.#verifyAcknowledgedContent(agentName, sessionId, s1.fields);
14899
15162
  if (ackVerdict)
14900
15163
  return ackVerdict;
15164
+ /**
15165
+ * ─── AND IT MUST LINK TO THEIR OWN PREVIOUS MESSAGE — `DOD-M15-SELFCHAIN-1` ──────────────────
15166
+ *
15167
+ * The check above asks whether they are right about what WE said. This one asks whether they
15168
+ * are right about what THEY said, and it is the check that makes the ORDER of the conversation
15169
+ * provable rather than merely its contents.
15170
+ *
15171
+ * **THIS IS THE HALF THAT NEEDS NO RELAY**, in the strongest sense: the expected value is the
15172
+ * last message we received from them, which is a fact about our own inbox. A relay that is
15173
+ * absent, slow, colluding or lying cannot change it, and cannot wave a broken chain past us.
15174
+ */
15175
+ const chainVerdict = this.#verifySenderSelfChain(agentName, sessionId, s1.fields);
15176
+ if (chainVerdict)
15177
+ return chainVerdict;
14901
15178
  return { verdict: "verified", senderPubkey: s1Pubkey, senderSig: senderSignature };
14902
15179
  }
14903
15180
  /**
@@ -14915,6 +15192,120 @@ export class SessionNodeManager {
14915
15192
  * width, so `lastSeenHash === null` here means exactly one thing — a v1 layout — and it is
14916
15193
  * refused by its own name.
14917
15194
  */
15195
+ /**
15196
+ * Does this claim link to the last message we actually received from this sender?
15197
+ *
15198
+ * ─── Why the expected value is our INBOX and not our tree ──────────────────────────────────────
15199
+ *
15200
+ * The tree holds every leaf in canonical order and records no authorship, so it cannot say which
15201
+ * of them this sender wrote. What can is the acknowledgement state this daemon already keeps: the
15202
+ * last content hash received from the counterparty, updated on every message as it is ingested.
15203
+ * That IS their previous message, by definition.
15204
+ *
15205
+ * It is seeded at session registration with the session GENESIS, so a sender's first message has
15206
+ * a real expected value rather than a special case — "they have not spoken here yet" is a value,
15207
+ * derived per session, and not an absence.
15208
+ *
15209
+ * ⚠️ RUNS BEFORE THIS MESSAGE IS INGESTED, which is what makes the comparison meaningful: the
15210
+ * state still holds their PREVIOUS message. Moving this after ingest would compare a message to
15211
+ * itself and pass every time.
15212
+ */
15213
+ #verifySenderSelfChain(agentName, sessionId, fields) {
15214
+ const key = this.#k(agentName, sessionId);
15215
+ const genesis = this.#sessionGenesisPrevRoot(agentName, sessionId);
15216
+ /**
15217
+ * ⚠️ THE EXPECTED VALUE IS OUR INBOX — `#lastAck` — AND NOT THE SESSION GENESIS.
15218
+ *
15219
+ * `#lastFromCounterparty` is the last content hash we accepted FROM THIS COUNTERPARTY, written
15220
+ * on every successful ingest. That IS their previous message, by definition. The genesis is only
15221
+ * the answer before they have said anything.
15222
+ *
15223
+ * It used to read the relay client's acknowledgement with the genesis as a fallback, which is
15224
+ * the same conflation the emitter had: on a session with no relay client the fallback never
15225
+ * moved, so the counterparty's SECOND message was refused as a broken chain for the rest of the
15226
+ * conversation. Four live-transport fixtures caught it the moment the emitter started producing
15227
+ * a real chain.
15228
+ */
15229
+ const expected = this.#lastFromCounterparty.get(key) ?? genesis;
15230
+ /**
15231
+ * ⚠️ NO EXPECTED VALUE MEANS NO COMPARISON, AND THIS IS THE ONE PLACE THAT IS NOT A FAIL-OPEN —
15232
+ * because of WHO CONTROLS THE ABSENCE. Whether this side holds a starting point for the session
15233
+ * depends on our own assignment and our own database. Nothing the sender puts on the wire can
15234
+ * cause it, so it is not a switch they can reach for. A session restored from a row written
15235
+ * before this existed is the real case, and refusing there would refuse every message on it for
15236
+ * something the counterparty did not do.
15237
+ */
15238
+ if (!expected) {
15239
+ this.#logger.info("session.content.self_chain.unverifiable", {
15240
+ agentName, sessionId,
15241
+ impact: "this side holds no record of this sender's previous message in this session, so the " +
15242
+ "link inside their signed bytes was not compared. The message is accepted; it is already " +
15243
+ "bound to this conversation and to its author by the checks above.",
15244
+ });
15245
+ return undefined;
15246
+ }
15247
+ if (bytesEqual(fields.prevOwnHash, expected))
15248
+ return undefined;
15249
+ /**
15250
+ * ─── OUR OWN GAP IS NOT THEIR TAMPERING, AND THE DIFFERENCE IS DECIDABLE ─────────────────────
15251
+ *
15252
+ * `expected` is the last message from them we ACCEPTED. Our record can legitimately be behind
15253
+ * theirs: a message of theirs can arrive out of order and be HELD, be refused by the inbound
15254
+ * screen, or be lost in flight. In every one of those cases their next message links to
15255
+ * something real that we simply do not have at the front of our record — and refusing it would
15256
+ * be a fabricated tamper report caused by our own gap, against a party that did nothing.
15257
+ *
15258
+ * So a link naming ANY message we have accepted from them is accepted, and the gap is reported
15259
+ * as OUR problem. A link naming something we have never held from them is the actual accusation:
15260
+ * it is either invented or it belongs to a conversation this is not.
15261
+ *
15262
+ * ⚠️ THIS IS DELIBERATELY WEAKER THAN THE RELAY'S CHECK, AND SAYING SO IS THE POINT. The relay
15263
+ * holds the whole ordered log and refuses anything but the immediate predecessor; the directory
15264
+ * does the same at seal time. This side holds only what reached it, so the strongest honest
15265
+ * question it can ask is "did you name something you actually said to me?". Two strict checkers
15266
+ * plus one honest one beats three checkers where the weakest one fabricates accusations.
15267
+ */
15268
+ const seen = this.#receivedFromCounterparty.get(key);
15269
+ if (seen?.has(Buffer.from(fields.prevOwnHash).toString("hex"))) {
15270
+ this.#logger.info("session.content.self_chain.behind", {
15271
+ agentName, sessionId,
15272
+ impact: "this message links to an earlier message from your counterparty than the last one this " +
15273
+ "side accepted, so this side's copy of the conversation has a gap in it. The message is " +
15274
+ "accepted — the link names something they really did send you. The gap is on this side.",
15275
+ });
15276
+ return undefined;
15277
+ }
15278
+ return { verdict: "unusable", reason: AUTHORSHIP_SELF_CHAIN_MISMATCH };
15279
+ }
15280
+ /**
15281
+ * Every content hash accepted from the counterparty on a session, so a link to one of THEIR
15282
+ * earlier messages can be told apart from a link to something they never sent.
15283
+ *
15284
+ * ⚠️ BOUNDED, because it is fed by a peer. A conversation must not cost unbounded memory because
15285
+ * the other side kept talking: the oldest entries are dropped past the cap, and a link older than
15286
+ * that is refused. That is the right way round — the cap makes the check STRICTER as it bites,
15287
+ * never looser.
15288
+ */
15289
+ #receivedFromCounterparty = new Map();
15290
+ /** The MOST RECENT of those, which is what an honest next message links to. */
15291
+ #lastFromCounterparty = new Map();
15292
+ #noteReceivedFromCounterparty(agentName, sessionId, contentHash) {
15293
+ const key = this.#k(agentName, sessionId);
15294
+ this.#lastFromCounterparty.set(key, Uint8Array.from(contentHash));
15295
+ let seen = this.#receivedFromCounterparty.get(key);
15296
+ if (!seen) {
15297
+ seen = new Set();
15298
+ this.#receivedFromCounterparty.set(key, seen);
15299
+ }
15300
+ seen.add(Buffer.from(contentHash).toString("hex"));
15301
+ while (seen.size > SELF_CHAIN_MEMORY) {
15302
+ // Sets iterate in insertion order, so the first entry is the oldest.
15303
+ const oldest = seen.values().next().value;
15304
+ if (oldest === undefined)
15305
+ break;
15306
+ seen.delete(oldest);
15307
+ }
15308
+ }
14918
15309
  #verifyAcknowledgedContent(agentName, sessionId, fields) {
14919
15310
  /**
14920
15311
  * ⚠️ **A v1 CLAIM IS REFUSED THE MOMENT IT NAMES A POSITION — and accepted when it names none.
@@ -14937,11 +15328,15 @@ export class SessionNodeManager {
14937
15328
  * AHEAD of its counter, never one that lags). This unit does not change that either way, and
14938
15329
  * the follow-on that does is the receiver submitting a hash for what it received.
14939
15330
  */
14940
- if (fields.lastSeenHash === null) {
14941
- return fields.lastSeenSeq >= 1
14942
- ? { verdict: "unusable", reason: AUTHORSHIP_ACK_HASH_ABSENT }
14943
- : undefined;
14944
- }
15331
+ /**
15332
+ * ⚠️ THE "NO ACKNOWLEDGEMENT AT ALL" BRANCH IS GONE, and its absence is the point.
15333
+ *
15334
+ * It used to accept a claim carrying no `last_seen_hash` as long as it also named no position —
15335
+ * honest, and a shape a peer could choose. `DOD-M15-SELFCHAIN-1` deleted every layout that can
15336
+ * express it: there is one Structure 1 and both chain links are required, so a claim without one
15337
+ * does not decode at all and never reaches this method. `AUTHORSHIP_ACK_HASH_ABSENT` is kept as
15338
+ * a reason because the wording that routes off it is still reachable from other callers.
15339
+ */
14945
15340
  /**
14946
15341
  * THE GENESIS IS A VALUE, NEVER AN ABSENCE. The first message of a session has seen nothing, and
14947
15342
  * that case is a defined 32 bytes — the agreed starting point of this two-party chain, derived
@@ -15086,13 +15481,26 @@ export class SessionNodeManager {
15086
15481
  * conclusion the code did not reach is the error-fidelity defect this milestone exists
15087
15482
  * for.
15088
15483
  */
15089
- : reason === AUTHORSHIP_ACK_HASH_ABSENT
15090
- ? "a message arrived that is genuinely from your counterparty and genuinely about this conversation and it does not say which of your messages they had received. Their build is older than yours: a message has to say what it is answering, so that nobody can later leave your last message out of the receipt. It was NOT ingested and NOT shown."
15091
- : reason === AUTHORSHIP_ACK_HASH_MISMATCH
15092
- ? "a message arrived that is genuinely from your counterparty — and it names a DIFFERENT message of yours in the position where your own record holds one. Both sides agree the message exists; you disagree about which one sits there. It was NOT ingested and NOT shown."
15093
- : reason === AUTHORSHIP_ACK_HASH_UNKNOWN
15094
- ? "a message arrived that is genuinely from your counterparty — and it says they received something from you that this side has no record of ever holding. It was NOT ingested and NOT shown. This is the check that stops someone quietly rewriting what was said before the receipt is made."
15095
- : "a message arrived whose proof of authorship could not be checked against it it was unreadable, or it was signed over different content. It was NOT ingested, NOT shown and NOT attributed to anyone.";
15484
+ /**
15485
+ * `DOD-M15-SELFCHAIN-1`THE ORDER, NOT THE CONTENT, AND IT NEEDS ITS OWN WORDS.
15486
+ *
15487
+ * This reached the operator under the generic `authorship_proof_unusable` sentence, which
15488
+ * says the proof was "unreadable, or signed over different content". Neither is true: the
15489
+ * proof is perfect and it is about this conversation. What is in dispute is WHERE this
15490
+ * message sitsand telling someone their decoder failed sends them to audit the wrong
15491
+ * subsystem entirely. The reason's own comment claimed it was "named apart from the
15492
+ * acknowledgement reasons"; the surface collapsed it back, which is error substitution on
15493
+ * the strongest evidence this protocol can produce.
15494
+ */
15495
+ : reason === AUTHORSHIP_SELF_CHAIN_MISMATCH
15496
+ ? "a message arrived that is genuinely from your counterparty, about this conversation, and correctly signed — and it names a message of THEIR OWN that they never sent you. Each message says which of their own came before it, and that is what fixes the ORDER of the conversation. This one points somewhere your record has never been. It was NOT ingested and NOT shown."
15497
+ : reason === AUTHORSHIP_ACK_HASH_ABSENT
15498
+ ? "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."
15499
+ : reason === AUTHORSHIP_ACK_HASH_MISMATCH
15500
+ ? "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."
15501
+ : reason === AUTHORSHIP_ACK_HASH_UNKNOWN
15502
+ ? "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."
15503
+ : "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.";
15096
15504
  /**
15097
15505
  * ⚠️ THE VERB IS THE COUNTERPARTY'S, AND THE GUIDANCE SAYS SO. The reader is the RECEIVING
15098
15506
  * operator, and there is nothing on their machine to change — the missing signature is produced
@@ -15148,30 +15556,63 @@ export class SessionNodeManager {
15148
15556
  * abandon the conversation. Two is the cap on each (Invariant 4); the verb is the
15149
15557
  * counterparty's in every case, because there is nothing to change on this machine.
15150
15558
  */
15151
- : reason === AUTHORSHIP_ACK_HASH_ABSENT
15152
- ? "STOPPED ON PURPOSE, and this is NOT about their signature it verified. " +
15559
+ : reason === AUTHORSHIP_SELF_CHAIN_MISMATCH
15560
+ ? "STOPPED ON PURPOSE, and this is the most serious of these refusals. " +
15153
15561
  (this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
15154
- " Their build is older than yours and does not say what it has received. Ask which version " +
15155
- "they are running and tell them to upgrade only they can fix it, and this will keep " +
15156
- "happening until they do."
15157
- : reason === AUTHORSHIP_ACK_HASH_MISMATCH || reason === AUTHORSHIP_ACK_HASH_UNKNOWN
15158
- ? "STOPPED ON PURPOSE, and this is NOT about their signature or their version both are " +
15159
- "fine. " +
15160
- (this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
15161
- " Your record of this conversation and theirs have stopped agreeing about what you sent " +
15162
- "them. Confirm with them OUT OF BAND what they actually received from you. If it matches " +
15163
- "what you sent, this was a fault and a new session will clear it; if it does not, do not " +
15164
- "carry on in this one."
15165
- : "STOPPED ON PURPOSE. This copy was refused and the message itself was not kept. " +
15166
- // Review F2: chosen from what THIS machine can do, not asserted. An agent with no identity
15167
- // key cannot open a mailbox copy either, and telling them to wait for one would be the same
15168
- // false promise on a different refusal.
15562
+ " Their signature is real and it is about this conversation. What does not hold is the " +
15563
+ "ORDER: this message names one of their own as the one before it, and that message was " +
15564
+ "never sent to you. Either something between you is rearranging what they say, or their " +
15565
+ "agent's record of what it has said went out of step. THIS SESSION IS NOW FROZEN — no " +
15566
+ "further message on it will be accepted, because carrying on writes a disputed order into " +
15567
+ "the receipt. Reach them OUT OF BAND — a channel that is not this one — and ask them to " +
15568
+ "read back the last few things they sent you. If it matches what you have, open a NEW " +
15569
+ "session; if it does not, do not."
15570
+ : reason === AUTHORSHIP_ACK_HASH_ABSENT
15571
+ ? "STOPPED ON PURPOSE, and this is NOT about their signature it verified. " +
15169
15572
  (this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
15170
- " Almost always their CELLO build is older than this one: a build from before message signing " +
15171
- "does not attach a signature at all. Ask which version they are running, and tell them to " +
15172
- "upgrade — this will keep happening until they do, and only they can fix it. If they are on " +
15173
- "the SAME version as you, that explanation does not hold: confirm with them OUT OF BAND " +
15174
- "before opening another session.";
15573
+ " Their build is older than yours and does not say what it has received. Ask which version " +
15574
+ "they are running and tell them to upgrade only they can fix it, and this will keep " +
15575
+ "happening until they do."
15576
+ /**
15577
+ * ⚠️ THESE TWO SHARED ONE SENTENCE, AND THE TEST THAT WAS MEANT TO CATCH THAT COULD NOT SEE
15578
+ * IT — `DOD-M15-SELFCHAIN-1`.
15579
+ *
15580
+ * The file's thesis is "three causes, three sentences", and it compared ABSENT against
15581
+ * MISMATCH and stopped. Mismatch and unknown-content shared a remedy the whole time; the pair
15582
+ * was never compared, so the defect the test existed for was live inside it.
15583
+ *
15584
+ * They are not the same situation. A MISMATCH means you both agree a message sits at that
15585
+ * position and disagree about which one — a record that has drifted. UNKNOWN CONTENT means
15586
+ * they are acknowledging something this side never held at all, which is the shape of content
15587
+ * being attributed to you that you did not send. The second is the more serious reading and
15588
+ * the operator's next move differs, so it gets its own sentence.
15589
+ */
15590
+ : reason === AUTHORSHIP_ACK_HASH_MISMATCH
15591
+ ? "STOPPED ON PURPOSE, and this is NOT about their signature or their version — both are " +
15592
+ "fine. " +
15593
+ (this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
15594
+ " Your record of this conversation and theirs have stopped agreeing about what you sent " +
15595
+ "them. Confirm with them OUT OF BAND what they actually received from you. If it matches " +
15596
+ "what you sent, this was a fault and a new session will clear it; if it does not, do not " +
15597
+ "carry on in this one."
15598
+ : reason === AUTHORSHIP_ACK_HASH_UNKNOWN
15599
+ ? "STOPPED ON PURPOSE, and this one is more serious than a record that has drifted. " +
15600
+ (this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
15601
+ " They are acknowledging a message from you that this side has NEVER held — not at that " +
15602
+ "position, not anywhere. Either their record contains something you did not send, or " +
15603
+ "yours is missing something you did. Ask them OUT OF BAND to read you back what they " +
15604
+ "believe you sent. Do not continue this conversation until you know which of the two it " +
15605
+ "is: carrying on writes their version into the receipt."
15606
+ : "STOPPED ON PURPOSE. This copy was refused and the message itself was not kept. " +
15607
+ // Review F2: chosen from what THIS machine can do, not asserted. An agent with no identity
15608
+ // key cannot open a mailbox copy either, and telling them to wait for one would be the same
15609
+ // false promise on a different refusal.
15610
+ (this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
15611
+ " Almost always their CELLO build is older than this one: a build from before message signing " +
15612
+ "does not attach a signature at all. Ask which version they are running, and tell them to " +
15613
+ "upgrade — this will keep happening until they do, and only they can fix it. If they are on " +
15614
+ "the SAME version as you, that explanation does not hold: confirm with them OUT OF BAND " +
15615
+ "before opening another session.";
15175
15616
  this.#logger.error("session.content.refused", {
15176
15617
  agentName, sessionId, correlationId, reason, ...detail, impact, guidance,
15177
15618
  });
@@ -15710,18 +16151,41 @@ export class SessionNodeManager {
15710
16151
  */
15711
16152
  this.#refuseUnprovenAuthorship(agentName, sessionId, authorship.reason === AUTHORSHIP_SESSION_MISMATCH
15712
16153
  ? "authorship_wrong_conversation"
15713
- : ACK_HASH_REASONS.has(authorship.reason)
15714
- /**
15715
- * ⚠️ THE SPECIFIC CAUSE, NOT THE CLASS — review F5, and the diff's own comment on
15716
- * `ACK_HASH_REASONS` had already said why: "the operator's next move differs for
15717
- * each." It then collapsed all three into ONE surface reason carrying ONE sentence,
15718
- * so the three names survived only in a log field nobody reads. For an absent
15719
- * acknowledgement the shared impact was flatly false — there is no part that "does
15720
- * not match", because there is no part — and for the other two the shared guidance
15721
- * sent the reader to ask about a build version that cannot be the cause.
15722
- */
15723
- ? authorship.reason
15724
- : "authorship_proof_unusable", contentHash, { detail: authorship.reason }, correlationId);
16154
+ /**
16155
+ * `DOD-M15-SELFCHAIN-1` — its own name on the surface the operator reads, not only in a
16156
+ * log field. See the sentences in `#refuseUnprovenAuthorship`.
16157
+ */
16158
+ : authorship.reason === AUTHORSHIP_SELF_CHAIN_MISMATCH
16159
+ ? AUTHORSHIP_SELF_CHAIN_MISMATCH
16160
+ : ACK_HASH_REASONS.has(authorship.reason)
16161
+ /**
16162
+ * ⚠️ THE SPECIFIC CAUSE, NOT THE CLASS review F5, and the diff's own comment on
16163
+ * `ACK_HASH_REASONS` had already said why: "the operator's next move differs for
16164
+ * each." It then collapsed all three into ONE surface reason carrying ONE sentence,
16165
+ * so the three names survived only in a log field nobody reads. For an absent
16166
+ * acknowledgement the shared impact was flatly false — there is no part that "does
16167
+ * not match", because there is no part — and for the other two the shared guidance
16168
+ * sent the reader to ask about a build version that cannot be the cause.
16169
+ */
16170
+ ? authorship.reason
16171
+ : "authorship_proof_unusable", contentHash, { detail: authorship.reason }, correlationId);
16172
+ /**
16173
+ * ─── AND THE SESSION FREEZES — `DOD-M15-SELFCHAIN-1`, the escalation clause ──────────────
16174
+ *
16175
+ * ⚠️ ONLY THIS ONE OF THE `unusable` CAUSES FREEZES, and the split is the whole rule.
16176
+ *
16177
+ * The acknowledgement causes say the sender is wrong about what WE said, which a record
16178
+ * that has drifted produces honestly, and refusing the message is proportionate. This one
16179
+ * says they are wrong about what THEY said — the one thing a party cannot be honestly
16180
+ * mistaken about for long — so continuing writes a disputed order into the receipt. There
16181
+ * is nothing to gain from message N+1 on a conversation whose order is already in question.
16182
+ *
16183
+ * The freeze is what makes the refusal an ESCALATION rather than a dropped frame: it is
16184
+ * visible in the session's own state, not only in a notice the operator has to go and read.
16185
+ */
16186
+ if (authorship.reason === AUTHORSHIP_SELF_CHAIN_MISMATCH) {
16187
+ await this.#freezeOnIdentityFailure(agentName, sessionId, authorship.reason, correlationId);
16188
+ }
15725
16189
  return;
15726
16190
  }
15727
16191
  if (authorship.verdict === "verified") {
@@ -15776,6 +16240,20 @@ export class SessionNodeManager {
15776
16240
  // acknowledged `persisted` — the sender's TTF→park backstop then guarantees the
15777
16241
  // missing-earlier message is fetchable, and dedup absorbs the redundant copy.
15778
16242
  if (ingest.ok && !ingest.held) {
16243
+ /**
16244
+ * ─── THE SELF CHAIN IS PURELY CONTENT, SO IT ADVANCES HERE — `DOD-M15-SELFCHAIN-1` ───────
16245
+ *
16246
+ * ⚠️ NOT INSIDE `#noteAcknowledgeable`, and that placement was the bug. The acknowledgement
16247
+ * is a (POSITION, content) pair and needs the relay's number, so on a session the relay
16248
+ * never witnessed it is never written at all. The self link needs no position — it is one
16249
+ * party's hash chain over their own messages — so tying it to the acknowledgement meant the
16250
+ * receiver's record never moved on an unwitnessed session, and the counterparty's SECOND
16251
+ * message was refused as a broken chain for the rest of the conversation.
16252
+ *
16253
+ * That is the path this unit exists for: a conversation that ran while the relay was down is
16254
+ * precisely the one whose order gets disputed later.
16255
+ */
16256
+ this.#noteReceivedFromCounterparty(agentName, sessionId, contentHash);
15779
16257
  /**
15780
16258
  * 033-ACKEMIT review F1 — ACKNOWLEDGE WHAT ARRIVED, HERE, not when the relay gets round to
15781
16259
  * delivering its copy back to us.
@@ -15807,28 +16285,31 @@ export class SessionNodeManager {
15807
16285
  * We hold their signature over their own bytes. So we hand it to the relay ourselves.
15808
16286
  */
15809
16287
  /**
15810
- * ⚠️ **THE KIND COMES OFF THE FRAME, AND WITHOUT IT WE DECLINE TO WITNESS — review F5.**
16288
+ * ─── THE KIND COMES OFF THE FRAME, AND A FRAME WITHOUT ONE IS REFUSED ─────────────────
16289
+ *
16290
+ * A leaf kind selects a HASH DOMAIN — documents and rejection envelopes ride this same
16291
+ * frame — so witnessing under a guessed domain would put a wrong statement in the
16292
+ * canonical record.
16293
+ *
16294
+ * ⚠️ **THIS USED TO DECLINE TO WITNESS AND DELIVER THE MESSAGE ANYWAY, "because a peer
16295
+ * too old to send the field should be left alone". THAT SENTENCE WAS INHERITED, NOT
16296
+ * DERIVED, AND IT LEFT THE WHOLE ATTACK OPEN.** CELLO is alpha with no users; there is no
16297
+ * older peer to protect. What the leniency actually bought was an opt-out: emit the shape
16298
+ * a 2026-09-04 build emitted, and your message is delivered AND cannot be witnessed —
16299
+ * which is precisely the withholding this line exists to stop, reachable by anyone
16300
+ * willing to modify their client.
15811
16301
  *
15812
- * A leaf kind selects a HASH DOMAIN, and documents and rejection envelopes ride this same
15813
- * frame. Hardcoding `msg` meant a document witnessed on its author's behalf entered the
15814
- * relay's canonical log described as something it is not. A peer too old to send the field
15815
- * is left alone rather than guessed at: witnessing their leaf under the wrong domain would
15816
- * be a worse outcome than not witnessing it, because it puts a wrong statement in the
15817
- * record instead of leaving a gap the seal can name.
16302
+ * So it is refused. Missing, malformed and mismatched take one path (§5), and a peer that
16303
+ * cannot say which domain its own leaf belongs to has supplied an unusable proof.
15818
16304
  */
15819
16305
  const framedKind = frame["leaf_kind"];
15820
- if (typeof framedKind === "number") {
15821
- void this.#witnessReceivedLeaf(agentName, sessionId, contentHash, s1Cbor, senderSig, framedKind, correlationId);
15822
- }
15823
- else {
15824
- this.#logger.info("session.content.witness_received.kind_unknown", {
15825
- agentName, sessionId, correlationId,
15826
- impact: "a message arrived that its sender never had witnessed, and the frame did not say " +
15827
- "which leaf domain it belongs to — their build predates the field. It was NOT " +
15828
- "witnessed on their behalf, because witnessing it under a guessed domain would put " +
15829
- "a wrong statement in the record rather than leave a gap the seal can name.",
15830
- });
16306
+ if (typeof framedKind !== "number") {
16307
+ this.#refuseUnprovenAuthorship(agentName, sessionId, "authorship_proof_unusable", contentHash, {
16308
+ detail: "leaf_kind_absent",
16309
+ }, correlationId);
16310
+ return;
15831
16311
  }
16312
+ void this.#witnessReceivedLeaf(agentName, sessionId, contentHash, s1Cbor, senderSig, framedKind, correlationId);
15832
16313
  }
15833
16314
  void this.#sendDeliveryAck(agentName, sessionId, contentHash, correlationId);
15834
16315
  }
@@ -16564,8 +17045,68 @@ export class SessionNodeManager {
16564
17045
  * is that it never STOPS BEING REACHABLE while that is true — the surviving relays carry it,
16565
17046
  * the loss is named in the log with its cause, and the lost relay's inbound carve-out is
16566
17047
  * revoked above. That is availability, not restoration in place.
17048
+ *
17049
+ * WHICH LEAVES A RATCHET, and `#respreadIfDecayed` below is what stops it: relays are only
17050
+ * ever lost between rebuilds, never regained, so an agent nobody talks to walks itself back
17051
+ * down to one relay — the exact state this unit exists to get it out of.
16567
17052
  */
16568
17053
  }
17054
+ for (const agentName of this.#standingReceivers.keys()) {
17055
+ if (this.#agentsWantingReceiver.has(agentName))
17056
+ this.#respreadIfDecayed(agentName);
17057
+ }
17058
+ }
17059
+ /**
17060
+ * 032-RELAYSPREAD — **AN IDLE AGENT MUST NOT RATCHET ITSELF BACK DOWN TO ONE RELAY.**
17061
+ *
17062
+ * Spreading happens when a receiver is BUILT, and between builds the count only falls: a lost
17063
+ * circuit cannot be retaken by a running node (a circuit listener is fixed at node creation), and
17064
+ * a relay the directory announces later is skipped while any circuit is held. An agent in
17065
+ * conversation re-spreads constantly — the receiver is handed into each session and a fresh one
17066
+ * is built behind it — so this is about the agent nobody has talked to for a day. It loses relays
17067
+ * one at a time, nothing pulls it back up, and it ends up exactly where this unit found it:
17068
+ * reachable through one relay, one relay away from being reachable through none.
17069
+ *
17070
+ * **THE COST OF FIXING IT IS A NEW PEER ID**, which is why it is fenced three ways rather than
17071
+ * simply rebuilding on sight:
17072
+ * - **ONLY WHEN IDLE.** A rebuild replaces the receiver's transport identity, and a counterparty
17073
+ * may be holding the old one from a `session_offer_accept`. With a live session for this agent
17074
+ * we leave it alone — a degraded spread costs redundancy, a changed peer id mid-conversation
17075
+ * costs the conversation.
17076
+ * - **ONLY WHEN THERE IS SOMETHING TO GAIN.** Holding every relay that was offered is not decay.
17077
+ * - **ON ITS OWN SLOW CLOCK**, never the watchdog's 30-second grid. A reservation is scarce —
17078
+ * the relay holds it for its full TTL even after we disconnect — so this reuses the
17079
+ * reservation retry interval rather than inventing a faster one.
17080
+ */
17081
+ #respreadIfDecayed(agentName) {
17082
+ if (this.#shuttingDown)
17083
+ return;
17084
+ const sr = this.#standingReceivers.get(agentName);
17085
+ if (!sr || sr.relayPeerIds.length === 0)
17086
+ return; // zero held is the loud path
17087
+ for (const entry of this.#activeNodes.values()) {
17088
+ if (entry.agentName === agentName)
17089
+ return; // in conversation — hands off
17090
+ }
17091
+ const offered = this.#reservationCircuitAddrs(agentName).addrs.length;
17092
+ if (sr.relayPeerIds.length >= offered)
17093
+ return; // nothing to gain
17094
+ const now = Date.now();
17095
+ const last = this.#srLastRespreadAt.get(agentName) ?? 0;
17096
+ if (now - last < this.#srReservationRetryMs)
17097
+ return;
17098
+ this.#srLastRespreadAt.set(agentName, now);
17099
+ this.#logger.info("session.standing_receiver.respread", {
17100
+ agentName,
17101
+ reservationsHeld: sr.relayPeerIds.length,
17102
+ relaysOffered: offered,
17103
+ impact: "this agent is idle and holds fewer relay reservations than it was offered, so its " +
17104
+ "receiver is being rebuilt to take the rest. Without this it can only lose relays between " +
17105
+ "rebuilds, and an agent nobody talks to drifts back down to a single relay — one relay " +
17106
+ "away from being unreachable behind NAT, which is the state this whole mechanism exists " +
17107
+ "to keep it out of.",
17108
+ });
17109
+ void this.#rebuildStandingReceiver(agentName);
16569
17110
  }
16570
17111
  /**
16571
17112
  * DOD-PARK-DRAIN-1: the backstop sweep — every agent holding a standing receiver gets a drain
@@ -17178,6 +17719,11 @@ export class SessionNodeManager {
17178
17719
  // reservations that genuinely completed, so a directory that merely NAMES a relay cannot dial
17179
17720
  // in behind it, however many relays it names.
17180
17721
  gater.setReservedRelayPeers(heldRelayPeerIds);
17722
+ // The re-spread clock starts HERE, at the build, not at the epoch. Otherwise the first decay
17723
+ // re-spreads instantly — undoing the "a lost relay does not rebuild the receiver" rule seconds
17724
+ // after it fires, and changing the peer id of an agent that just lost one relay of three. The
17725
+ // ratchet this guards against runs over hours; nothing about it needs answering in a second.
17726
+ this.#srLastRespreadAt.set(agentName, Date.now());
17181
17727
  this.#standingReceivers.set(agentName, {
17182
17728
  node,
17183
17729
  gater,
@@ -18130,11 +18676,20 @@ export class SessionNodeManager {
18130
18676
  return false;
18131
18677
  const now = Date.now();
18132
18678
  try {
18679
+ /**
18680
+ * ⚠️ THE SESSION'S STARTING POINT GOES IN AT INSERT — `DOD-M15-SELFCHAIN-1`.
18681
+ *
18682
+ * It is recorded before this row exists (the session open needs it before the node is built),
18683
+ * so an UPDATE at that moment has nothing to match. Writing it here is what puts it on disk,
18684
+ * and on disk is what lets the chain be resumed after a restart. `null` when nothing recorded
18685
+ * one, which is a session whose sends will be refused by name rather than silently unlinked.
18686
+ */
18687
+ const genesis = this.#sessionGenesis.get(this.#k(agentName, sessionId));
18133
18688
  this.#db
18134
18689
  .prepare(`INSERT INTO sessions
18135
- (session_id, agent_id, counterparty_pubkey, status, created_at, updated_at)
18136
- VALUES (?, ?, ?, ?, ?, ?)`)
18137
- .run(sessionId, this.#requireAgentId(agentName), counterpartyPubkey, status, now, now);
18690
+ (session_id, agent_id, counterparty_pubkey, status, created_at, updated_at, genesis_prev_root)
18691
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
18692
+ .run(sessionId, this.#requireAgentId(agentName), counterpartyPubkey, status, now, now, genesis ? Buffer.from(genesis) : null);
18138
18693
  return true;
18139
18694
  }
18140
18695
  catch (err) {