@cello-protocol/daemon 0.0.193 → 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.
@@ -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
  });
@@ -1995,6 +2022,20 @@ export class SessionNodeManager {
1995
2022
  this.#logger.info("persist.db.opened", { encrypted: true, migrated: migration.migrated });
1996
2023
  // PERSIST-002: the identity store (agents + manifest_state) lives in the same encrypted DB.
1997
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);
1998
2039
  this.#db.exec(`
1999
2040
  CREATE TABLE IF NOT EXISTS sessions (
2000
2041
  session_id TEXT NOT NULL,
@@ -4598,6 +4639,20 @@ export class SessionNodeManager {
4598
4639
  "Close an existing session before starting a new one.",
4599
4640
  };
4600
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
+ */
4601
4656
  // The session node N_A: either a FRESH ephemeral node (default), or — for the initiator
4602
4657
  // path (reuseStandingReceiver) — the standing receiver handed off as the session node. The
4603
4658
  // latter makes N_A's peer id equal the SESSION endpoint the initiator ADVERTISED to the
@@ -7276,6 +7331,19 @@ export class SessionNodeManager {
7276
7331
  if (assignment) {
7277
7332
  return computeGenesisPrevRoot(assignment.participantA, assignment.participantB, Uint8Array.from(Buffer.from(sessionId, "hex")), assignment.sessionTimestamp);
7278
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;
7279
7347
  /**
7280
7348
  * THE RESTART CASE. A session restored from the database re-registers with no assignment, so
7281
7349
  * the derivation above has nothing to work from and the stored copy is the only answer. Read
@@ -7289,7 +7357,9 @@ export class SessionNodeManager {
7289
7357
  const bytes = stored instanceof Uint8Array ? stored : Buffer.isBuffer(stored) ? new Uint8Array(stored) : null;
7290
7358
  // A stored value of the wrong width is not a genesis. Refusing it here sends the caller down its
7291
7359
  // own named refusal, which is a better outcome than signing an acknowledgement of 17 bytes.
7292
- return bytes && bytes.length === 32 ? bytes : undefined;
7360
+ if (bytes && bytes.length === 32)
7361
+ return bytes;
7362
+ return undefined;
7293
7363
  }
7294
7364
  /**
7295
7365
  * Persist the session's genesis prev_root, once, at the moment the assignment arrives.
@@ -7298,11 +7368,64 @@ export class SessionNodeManager {
7298
7368
  * change for the life of a session, so the second writer is either redundant or wrong, and the
7299
7369
  * first write is the one derived closest to the assignment that opened the session.
7300
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();
7301
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);
7302
7426
  if (!this.#db)
7303
7427
  return;
7304
7428
  try {
7305
- const genesis = computeGenesisPrevRoot(assignment.participantA, assignment.participantB, Uint8Array.from(Buffer.from(sessionId, "hex")), assignment.sessionTimestamp);
7306
7429
  this.#db
7307
7430
  .prepare("UPDATE sessions SET genesis_prev_root = ? WHERE agent_id = ? AND session_id = ? AND genesis_prev_root IS NULL")
7308
7431
  .run(Buffer.from(genesis), this.#requireAgentId(agentName), sessionId);
@@ -7943,6 +8066,10 @@ export class SessionNodeManager {
7943
8066
  // `finally` retires — same defect, other end, other cap (64 outbound per protocol per
7944
8067
  // connection). See the note on #handleContentStream's finally.
7945
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;
7946
8073
  /**
7947
8074
  * 034-CARRYLEAF — HOISTED OUT OF THE TRY so the PARK path in the catch can carry them.
7948
8075
  *
@@ -7979,8 +8106,10 @@ export class SessionNodeManager {
7979
8106
  frameS1 = orderingS1;
7980
8107
  frameSig = orderingSig;
7981
8108
  let frameS2 = orderingS2;
8109
+ // A relay-witnessed claim advanced the chain on its ack; this flag covers the other case.
7982
8110
  if (frameS1 === undefined || frameSig === undefined) {
7983
8111
  const own = await this.#signOwnContentClaim(agentName, sessionId, entry, contentHash);
8112
+ ownClaimAwaitingSend = true;
7984
8113
  frameS1 = own.structure1;
7985
8114
  frameSig = own.signature;
7986
8115
  frameS2 = undefined;
@@ -8155,6 +8284,16 @@ export class SessionNodeManager {
8155
8284
  // A close that failed for a benign reason costs a redundant park, which the receiver dedups
8156
8285
  // on the content hash. A false delivered costs the message.
8157
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);
8158
8297
  this.#clearSessionImpairment(agentName, sessionId, "direct_send", correlationId);
8159
8298
  return { ok: true, delivered: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(sentAuthorship === undefined ? {} : { authorship: sentAuthorship }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
8160
8299
  }
@@ -8226,6 +8365,17 @@ export class SessionNodeManager {
8226
8365
  */
8227
8366
  const attempt = await this.#parkContent(agentName, sessionId, hashHex, content, frameS1, orderingS2, contentHashAlg, frameSig, leafKind);
8228
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);
8229
8379
  this.#noteImpairmentRetention(agentName, sessionId, "parked");
8230
8380
  return { ok: true, delivered: false, parked: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(sentAuthorship === undefined ? {} : { authorship: sentAuthorship }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
8231
8381
  }
@@ -11789,6 +11939,17 @@ export class SessionNodeManager {
11789
11939
  throw new Error(`patchRelayClientForTest: no active node for (${agentName}, ${sessionId})`);
11790
11940
  entry.relayClient = relayClient;
11791
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));
11792
11953
  }
11793
11954
  pushReceivedContentForTest(agentName, sessionId, seq, content, senderPubkey) {
11794
11955
  this.recordTranscriptMessage(agentName, sessionId, seq, "received", new TextEncoder().encode(content), "test");
@@ -13169,9 +13330,31 @@ export class SessionNodeManager {
13169
13330
  * on which side of the send it sat on.
13170
13331
  */
13171
13332
  setSessionGenesisForTest(agentName, sessionId, genesis) {
13172
- this.#db
13173
- ?.prepare("UPDATE sessions SET genesis_prev_root = ? WHERE agent_id = ? AND session_id = ?")
13174
- .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 */ }
13175
13358
  }
13176
13359
  /**
13177
13360
  * Test seam: drop the agreed key while leaving the session up — the state before an exchange
@@ -14730,35 +14913,21 @@ export class SessionNodeManager {
14730
14913
  ?? (() => { const g = this.#sessionGenesisPrevRoot(agentName, sessionId); return g ? { seq: 0, hash: g } : undefined; })();
14731
14914
  if (!ack) {
14732
14915
  /**
14733
- * ⚠️ **v1, AND ONLY BECAUSE THERE IS NOTHING TO ACKNOWLEDGE see `#verifyAcknowledgedContent`
14734
- * for the receiving half of the same rule, which is what makes this safe rather than a
14735
- * downgrade.**
14916
+ * ⚠️ NO STARTING POINT MEANS NO SEND — `DOD-M15-SELFCHAIN-1`.
14736
14917
  *
14737
- * Reaching here means this session has no recorded starting point AND has received nothing.
14738
- * The claim it produces is `last_seen_seq: 0` with no hash: "I have seen nothing of yours, and
14739
- * I assert nothing about your content." That is honest, and it is not the fail-open the unit
14740
- * closes the hole is a claim that names a POSITION with no content behind it, and this names
14741
- * 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.
14742
14922
  *
14743
- * It does not throw, and an earlier version did. Sessions brokered without a relay assignment
14744
- * are real the directory does not always return one and throwing there stopped those
14745
- * sessions sending at all, which trades a hole this claim does not have for a failure of the
14746
- * 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.
14747
14927
  */
14748
- this.#logger.info("session.content.claim.unacknowledged", {
14749
- agentName, sessionId,
14750
- impact: "this message is signed with no acknowledgement of anything received, because this " +
14751
- "session has no recorded starting point and nothing has arrived on it yet. It binds the " +
14752
- "sender and the content as always; it makes no claim about the counterparty's messages.",
14753
- });
14754
- const bare = encodeStructure1({
14755
- contentHash,
14756
- senderPubkey: await signer.getPublicKey(),
14757
- sessionId: sessionIdBytes,
14758
- lastSeenSeq: 0,
14759
- timestamp: Date.now(),
14760
- });
14761
- 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.");
14762
14931
  }
14763
14932
  const structure1 = encodeStructure1({
14764
14933
  contentHash,
@@ -14770,9 +14939,76 @@ export class SessionNodeManager {
14770
14939
  lastSeenSeq: ack.seq,
14771
14940
  timestamp: Date.now(),
14772
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,
14773
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
+ */
14774
14975
  return { structure1, signature: await signer.sign(structure1) };
14775
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
+ }
14776
15012
  /**
14777
15013
  * `DOD-M15-AUTHORSHIP-ABSENT-1` — DID THIS SENDER PROVE THEY WROTE THIS MESSAGE?
14778
15014
  *
@@ -14925,6 +15161,20 @@ export class SessionNodeManager {
14925
15161
  const ackVerdict = this.#verifyAcknowledgedContent(agentName, sessionId, s1.fields);
14926
15162
  if (ackVerdict)
14927
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;
14928
15178
  return { verdict: "verified", senderPubkey: s1Pubkey, senderSig: senderSignature };
14929
15179
  }
14930
15180
  /**
@@ -14942,6 +15192,120 @@ export class SessionNodeManager {
14942
15192
  * width, so `lastSeenHash === null` here means exactly one thing — a v1 layout — and it is
14943
15193
  * refused by its own name.
14944
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
+ }
14945
15309
  #verifyAcknowledgedContent(agentName, sessionId, fields) {
14946
15310
  /**
14947
15311
  * ⚠️ **A v1 CLAIM IS REFUSED THE MOMENT IT NAMES A POSITION — and accepted when it names none.
@@ -14964,11 +15328,15 @@ export class SessionNodeManager {
14964
15328
  * AHEAD of its counter, never one that lags). This unit does not change that either way, and
14965
15329
  * the follow-on that does is the receiver submitting a hash for what it received.
14966
15330
  */
14967
- if (fields.lastSeenHash === null) {
14968
- return fields.lastSeenSeq >= 1
14969
- ? { verdict: "unusable", reason: AUTHORSHIP_ACK_HASH_ABSENT }
14970
- : undefined;
14971
- }
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
+ */
14972
15340
  /**
14973
15341
  * THE GENESIS IS A VALUE, NEVER AN ABSENCE. The first message of a session has seen nothing, and
14974
15342
  * that case is a defined 32 bytes — the agreed starting point of this two-party chain, derived
@@ -15113,13 +15481,26 @@ export class SessionNodeManager {
15113
15481
  * conclusion the code did not reach is the error-fidelity defect this milestone exists
15114
15482
  * for.
15115
15483
  */
15116
- : reason === AUTHORSHIP_ACK_HASH_ABSENT
15117
- ? "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."
15118
- : reason === AUTHORSHIP_ACK_HASH_MISMATCH
15119
- ? "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."
15120
- : reason === AUTHORSHIP_ACK_HASH_UNKNOWN
15121
- ? "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."
15122
- : "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.";
15123
15504
  /**
15124
15505
  * ⚠️ THE VERB IS THE COUNTERPARTY'S, AND THE GUIDANCE SAYS SO. The reader is the RECEIVING
15125
15506
  * operator, and there is nothing on their machine to change — the missing signature is produced
@@ -15175,30 +15556,63 @@ export class SessionNodeManager {
15175
15556
  * abandon the conversation. Two is the cap on each (Invariant 4); the verb is the
15176
15557
  * counterparty's in every case, because there is nothing to change on this machine.
15177
15558
  */
15178
- : reason === AUTHORSHIP_ACK_HASH_ABSENT
15179
- ? "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. " +
15180
15561
  (this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
15181
- " Their build is older than yours and does not say what it has received. Ask which version " +
15182
- "they are running and tell them to upgrade only they can fix it, and this will keep " +
15183
- "happening until they do."
15184
- : reason === AUTHORSHIP_ACK_HASH_MISMATCH || reason === AUTHORSHIP_ACK_HASH_UNKNOWN
15185
- ? "STOPPED ON PURPOSE, and this is NOT about their signature or their version both are " +
15186
- "fine. " +
15187
- (this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
15188
- " Your record of this conversation and theirs have stopped agreeing about what you sent " +
15189
- "them. Confirm with them OUT OF BAND what they actually received from you. If it matches " +
15190
- "what you sent, this was a fault and a new session will clear it; if it does not, do not " +
15191
- "carry on in this one."
15192
- : "STOPPED ON PURPOSE. This copy was refused and the message itself was not kept. " +
15193
- // Review F2: chosen from what THIS machine can do, not asserted. An agent with no identity
15194
- // key cannot open a mailbox copy either, and telling them to wait for one would be the same
15195
- // 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. " +
15196
15572
  (this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
15197
- " Almost always their CELLO build is older than this one: a build from before message signing " +
15198
- "does not attach a signature at all. Ask which version they are running, and tell them to " +
15199
- "upgrade — this will keep happening until they do, and only they can fix it. If they are on " +
15200
- "the SAME version as you, that explanation does not hold: confirm with them OUT OF BAND " +
15201
- "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.";
15202
15616
  this.#logger.error("session.content.refused", {
15203
15617
  agentName, sessionId, correlationId, reason, ...detail, impact, guidance,
15204
15618
  });
@@ -15737,18 +16151,41 @@ export class SessionNodeManager {
15737
16151
  */
15738
16152
  this.#refuseUnprovenAuthorship(agentName, sessionId, authorship.reason === AUTHORSHIP_SESSION_MISMATCH
15739
16153
  ? "authorship_wrong_conversation"
15740
- : ACK_HASH_REASONS.has(authorship.reason)
15741
- /**
15742
- * ⚠️ THE SPECIFIC CAUSE, NOT THE CLASS — review F5, and the diff's own comment on
15743
- * `ACK_HASH_REASONS` had already said why: "the operator's next move differs for
15744
- * each." It then collapsed all three into ONE surface reason carrying ONE sentence,
15745
- * so the three names survived only in a log field nobody reads. For an absent
15746
- * acknowledgement the shared impact was flatly false — there is no part that "does
15747
- * not match", because there is no part — and for the other two the shared guidance
15748
- * sent the reader to ask about a build version that cannot be the cause.
15749
- */
15750
- ? authorship.reason
15751
- : "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
+ }
15752
16189
  return;
15753
16190
  }
15754
16191
  if (authorship.verdict === "verified") {
@@ -15803,6 +16240,20 @@ export class SessionNodeManager {
15803
16240
  // acknowledged `persisted` — the sender's TTF→park backstop then guarantees the
15804
16241
  // missing-earlier message is fetchable, and dedup absorbs the redundant copy.
15805
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);
15806
16257
  /**
15807
16258
  * 033-ACKEMIT review F1 — ACKNOWLEDGE WHAT ARRIVED, HERE, not when the relay gets round to
15808
16259
  * delivering its copy back to us.
@@ -18225,11 +18676,20 @@ export class SessionNodeManager {
18225
18676
  return false;
18226
18677
  const now = Date.now();
18227
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));
18228
18688
  this.#db
18229
18689
  .prepare(`INSERT INTO sessions
18230
- (session_id, agent_id, counterparty_pubkey, status, created_at, updated_at)
18231
- VALUES (?, ?, ?, ?, ?, ?)`)
18232
- .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);
18233
18693
  return true;
18234
18694
  }
18235
18695
  catch (err) {