@cello-protocol/daemon 0.0.196 → 0.0.198

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 (213) hide show
  1. package/dist/agent-admin-handlers.d.ts +56 -0
  2. package/dist/agent-admin-handlers.d.ts.map +1 -0
  3. package/dist/agent-admin-handlers.js +70 -0
  4. package/dist/agent-admin-handlers.js.map +1 -0
  5. package/dist/agent-handlers.d.ts +8 -7
  6. package/dist/agent-handlers.d.ts.map +1 -1
  7. package/dist/agent-handlers.js.map +1 -1
  8. package/dist/agent-selection-root.d.ts +29 -0
  9. package/dist/agent-selection-root.d.ts.map +1 -0
  10. package/dist/agent-selection-root.js +126 -0
  11. package/dist/agent-selection-root.js.map +1 -0
  12. package/dist/attendance-wiring.d.ts +44 -0
  13. package/dist/attendance-wiring.d.ts.map +1 -0
  14. package/dist/attendance-wiring.js +432 -0
  15. package/dist/attendance-wiring.js.map +1 -0
  16. package/dist/backup-restore-handlers.d.ts +10 -0
  17. package/dist/backup-restore-handlers.d.ts.map +1 -0
  18. package/dist/backup-restore-handlers.js +82 -0
  19. package/dist/backup-restore-handlers.js.map +1 -0
  20. package/dist/boot-agents.d.ts +64 -0
  21. package/dist/boot-agents.d.ts.map +1 -0
  22. package/dist/boot-agents.js +241 -0
  23. package/dist/boot-agents.js.map +1 -0
  24. package/dist/boot-connection-state.d.ts +40 -0
  25. package/dist/boot-connection-state.d.ts.map +1 -0
  26. package/dist/boot-connection-state.js +136 -0
  27. package/dist/boot-connection-state.js.map +1 -0
  28. package/dist/boot-core.d.ts +36 -0
  29. package/dist/boot-core.d.ts.map +1 -0
  30. package/dist/boot-core.js +292 -0
  31. package/dist/boot-core.js.map +1 -0
  32. package/dist/boot-parked-content.d.ts +34 -0
  33. package/dist/boot-parked-content.d.ts.map +1 -0
  34. package/dist/boot-parked-content.js +527 -0
  35. package/dist/boot-parked-content.js.map +1 -0
  36. package/dist/boot-sweeps.d.ts +22 -0
  37. package/dist/boot-sweeps.d.ts.map +1 -0
  38. package/dist/boot-sweeps.js +50 -0
  39. package/dist/boot-sweeps.js.map +1 -0
  40. package/dist/connection-agents.d.ts +16 -0
  41. package/dist/connection-agents.d.ts.map +1 -0
  42. package/dist/connection-agents.js +68 -0
  43. package/dist/connection-agents.js.map +1 -0
  44. package/dist/content-park.d.ts.map +1 -1
  45. package/dist/content-park.js +274 -25
  46. package/dist/content-park.js.map +1 -1
  47. package/dist/daemon-handle.d.ts +57 -0
  48. package/dist/daemon-handle.d.ts.map +1 -0
  49. package/dist/daemon-handle.js +2 -0
  50. package/dist/daemon-handle.js.map +1 -0
  51. package/dist/daemon-status-report.d.ts +40 -0
  52. package/dist/daemon-status-report.d.ts.map +1 -0
  53. package/dist/daemon-status-report.js +78 -0
  54. package/dist/daemon-status-report.js.map +1 -0
  55. package/dist/daemon.d.ts +5 -66
  56. package/dist/daemon.d.ts.map +1 -1
  57. package/dist/daemon.js +332 -4894
  58. package/dist/daemon.js.map +1 -1
  59. package/dist/delivery-open-registry.d.ts +1 -1
  60. package/dist/delivery-open-registry.js +1 -1
  61. package/dist/directory-connect.d.ts +27 -0
  62. package/dist/directory-connect.d.ts.map +1 -0
  63. package/dist/directory-connect.js +98 -0
  64. package/dist/directory-connect.js.map +1 -0
  65. package/dist/disconnect-cleanup.d.ts +45 -0
  66. package/dist/disconnect-cleanup.d.ts.map +1 -0
  67. package/dist/disconnect-cleanup.js +65 -0
  68. package/dist/disconnect-cleanup.js.map +1 -0
  69. package/dist/document-surface.d.ts +32 -0
  70. package/dist/document-surface.d.ts.map +1 -0
  71. package/dist/document-surface.js +150 -0
  72. package/dist/document-surface.js.map +1 -0
  73. package/dist/document-wiring.d.ts +53 -0
  74. package/dist/document-wiring.d.ts.map +1 -0
  75. package/dist/document-wiring.js +323 -0
  76. package/dist/document-wiring.js.map +1 -0
  77. package/dist/held-content.d.ts +7 -3
  78. package/dist/held-content.d.ts.map +1 -1
  79. package/dist/held-content.js.map +1 -1
  80. package/dist/inbound-sessions.d.ts +9 -1
  81. package/dist/inbound-sessions.d.ts.map +1 -1
  82. package/dist/inbound-sessions.js +9 -1
  83. package/dist/inbound-sessions.js.map +1 -1
  84. package/dist/index.d.ts +1 -2
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +1 -2
  87. package/dist/index.js.map +1 -1
  88. package/dist/ipc-surface.d.ts +44 -0
  89. package/dist/ipc-surface.d.ts.map +1 -0
  90. package/dist/ipc-surface.js +106 -0
  91. package/dist/ipc-surface.js.map +1 -0
  92. package/dist/network-directory-node.d.ts +0 -18
  93. package/dist/network-directory-node.d.ts.map +1 -1
  94. package/dist/network-directory-node.js +1 -38
  95. package/dist/network-directory-node.js.map +1 -1
  96. package/dist/notification-handlers.d.ts.map +1 -1
  97. package/dist/notification-handlers.js +2 -0
  98. package/dist/notification-handlers.js.map +1 -1
  99. package/dist/operator-guidance.d.ts +25 -0
  100. package/dist/operator-guidance.d.ts.map +1 -0
  101. package/dist/operator-guidance.js +50 -0
  102. package/dist/operator-guidance.js.map +1 -0
  103. package/dist/park-recovery.d.ts +22 -1
  104. package/dist/park-recovery.d.ts.map +1 -1
  105. package/dist/park-recovery.js.map +1 -1
  106. package/dist/park-refusals.d.ts +147 -0
  107. package/dist/park-refusals.d.ts.map +1 -0
  108. package/dist/park-refusals.js +331 -0
  109. package/dist/park-refusals.js.map +1 -0
  110. package/dist/refusal-notices.d.ts.map +1 -1
  111. package/dist/refusal-notices.js +22 -2
  112. package/dist/refusal-notices.js.map +1 -1
  113. package/dist/refusal-reasons.d.ts +13 -0
  114. package/dist/refusal-reasons.d.ts.map +1 -1
  115. package/dist/refusal-reasons.js +19 -0
  116. package/dist/refusal-reasons.js.map +1 -1
  117. package/dist/register-handler.d.ts +8 -7
  118. package/dist/register-handler.d.ts.map +1 -1
  119. package/dist/register-handler.js.map +1 -1
  120. package/dist/session-content-context.d.ts +144 -0
  121. package/dist/session-content-context.d.ts.map +1 -0
  122. package/dist/session-content-context.js +2 -0
  123. package/dist/session-content-context.js.map +1 -0
  124. package/dist/session-content-handlers.d.ts.map +1 -1
  125. package/dist/session-content-handlers.js +2 -0
  126. package/dist/session-content-handlers.js.map +1 -1
  127. package/dist/session-content-ingest.d.ts +209 -0
  128. package/dist/session-content-ingest.d.ts.map +1 -0
  129. package/dist/session-content-ingest.js +2216 -0
  130. package/dist/session-content-ingest.js.map +1 -0
  131. package/dist/session-content-send.d.ts +184 -0
  132. package/dist/session-content-send.d.ts.map +1 -0
  133. package/dist/session-content-send.js +1300 -0
  134. package/dist/session-content-send.js.map +1 -0
  135. package/dist/session-lifecycle.d.ts +303 -0
  136. package/dist/session-lifecycle.d.ts.map +1 -0
  137. package/dist/session-lifecycle.js +1643 -0
  138. package/dist/session-lifecycle.js.map +1 -0
  139. package/dist/session-node-factory.d.ts +18 -0
  140. package/dist/session-node-factory.d.ts.map +1 -0
  141. package/dist/session-node-factory.js +182 -0
  142. package/dist/session-node-factory.js.map +1 -0
  143. package/dist/session-node-manager.d.ts +68 -766
  144. package/dist/session-node-manager.d.ts.map +1 -1
  145. package/dist/session-node-manager.js +966 -7940
  146. package/dist/session-node-manager.js.map +1 -1
  147. package/dist/session-node-types.d.ts +30 -0
  148. package/dist/session-node-types.d.ts.map +1 -1
  149. package/dist/session-node-types.js.map +1 -1
  150. package/dist/session-notify.d.ts +46 -0
  151. package/dist/session-notify.d.ts.map +1 -0
  152. package/dist/session-notify.js +115 -0
  153. package/dist/session-notify.js.map +1 -0
  154. package/dist/session-read-handlers.d.ts.map +1 -1
  155. package/dist/session-read-handlers.js +0 -1
  156. package/dist/session-read-handlers.js.map +1 -1
  157. package/dist/session-relay.d.ts +361 -0
  158. package/dist/session-relay.d.ts.map +1 -0
  159. package/dist/session-relay.js +1471 -0
  160. package/dist/session-relay.js.map +1 -0
  161. package/dist/session-salt-agreement.d.ts +0 -7
  162. package/dist/session-salt-agreement.d.ts.map +1 -1
  163. package/dist/session-salt-agreement.js +0 -7
  164. package/dist/session-salt-agreement.js.map +1 -1
  165. package/dist/session-salts.d.ts +13 -0
  166. package/dist/session-salts.d.ts.map +1 -1
  167. package/dist/session-salts.js +13 -0
  168. package/dist/session-salts.js.map +1 -1
  169. package/dist/session-seal.d.ts +336 -0
  170. package/dist/session-seal.d.ts.map +1 -0
  171. package/dist/session-seal.js +948 -0
  172. package/dist/session-seal.js.map +1 -0
  173. package/dist/session-views.d.ts +46 -0
  174. package/dist/session-views.d.ts.map +1 -0
  175. package/dist/session-views.js +197 -0
  176. package/dist/session-views.js.map +1 -0
  177. package/dist/signal-handlers.d.ts +66 -0
  178. package/dist/signal-handlers.d.ts.map +1 -0
  179. package/dist/signal-handlers.js +975 -0
  180. package/dist/signal-handlers.js.map +1 -0
  181. package/dist/signaling-wiring.d.ts +100 -0
  182. package/dist/signaling-wiring.d.ts.map +1 -0
  183. package/dist/signaling-wiring.js +392 -0
  184. package/dist/signaling-wiring.js.map +1 -0
  185. package/dist/start-agent.d.ts +64 -0
  186. package/dist/start-agent.d.ts.map +1 -0
  187. package/dist/start-agent.js +136 -0
  188. package/dist/start-agent.js.map +1 -0
  189. package/dist/status-handler.d.ts +46 -0
  190. package/dist/status-handler.d.ts.map +1 -0
  191. package/dist/status-handler.js +58 -0
  192. package/dist/status-handler.js.map +1 -0
  193. package/dist/test-handlers.d.ts +62 -0
  194. package/dist/test-handlers.d.ts.map +1 -0
  195. package/dist/test-handlers.js +239 -0
  196. package/dist/test-handlers.js.map +1 -0
  197. package/dist/types.d.ts +0 -11
  198. package/dist/types.d.ts.map +1 -1
  199. package/dist/types.js +0 -12
  200. package/dist/types.js.map +1 -1
  201. package/dist/unresolved-nodes-report.d.ts +14 -0
  202. package/dist/unresolved-nodes-report.d.ts.map +1 -0
  203. package/dist/unresolved-nodes-report.js +96 -0
  204. package/dist/unresolved-nodes-report.js.map +1 -0
  205. package/dist/who-resolver.d.ts +15 -0
  206. package/dist/who-resolver.d.ts.map +1 -0
  207. package/dist/who-resolver.js +46 -0
  208. package/dist/who-resolver.js.map +1 -0
  209. package/dist/wire-content-hash.d.ts +0 -2
  210. package/dist/wire-content-hash.d.ts.map +1 -1
  211. package/dist/wire-content-hash.js +0 -4
  212. package/dist/wire-content-hash.js.map +1 -1
  213. package/package.json +5 -5
@@ -0,0 +1,2216 @@
1
+ /**
2
+ * CELLO Daemon — TAKING A MESSAGE IN
3
+ *
4
+ * The inbound half of the content path, split out of `session-node-manager.ts` with its outbound
5
+ * counterpart in `session-content-send.ts`. Everything between a frame arriving on the wire and a
6
+ * row appearing in the transcript: the stream that carries it, the ordering record that places it,
7
+ * the hash, the salt, the authorship proof, the screening gateway, the dedupe, the size bound, and
8
+ * the ten distinct reasons a message can be refused.
9
+ *
10
+ * **Moved verbatim, comments included.** The comments here are the record of why each guard
11
+ * exists, several of them recording a defect that was reintroduced once already. They are the
12
+ * asset; they moved with the code they describe and none was summarised.
13
+ *
14
+ * `ingestReceivedContent` alone is ~1,000 lines and forty top-level statements, and it is not
15
+ * decomposed here: every guard reads locals the guards above it declared, so lifting a phase out
16
+ * needs either a state object or a signature nobody can read. Moving it intact preserves behaviour
17
+ * exactly, which is the point of this pass; taking it apart is a separate piece of work.
18
+ */
19
+ import * as lp from "it-length-prefixed";
20
+ import { decode } from "cbor-x";
21
+ import { encodeCbor, decodeStructure1 } from "@cello-protocol/protocol-types";
22
+ import { openSessionContent } from "@cello-protocol/crypto";
23
+ import { CELLO_CONTENT_PROTOCOL_ID } from "@cello-protocol/transport";
24
+ import { GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT } from "@cello-protocol/gateway";
25
+ import { contentHashFor, resolveContentHashAlg } from "./wire-content-hash.js";
26
+ import { SALT_ADOPTION_LABEL_MAX } from "./session-salt-agreement.js";
27
+ import { CONTENT_ENCRYPTION_INBOUND_GUIDANCE, SESSION_CONTENT_ENCRYPTION_V1 } from "./content-encryption-status.js";
28
+ import { REFUSAL_KINDS } from "./refusal-reasons.js";
29
+ import { triageOrphanedContent } from "./orphan-triage.js";
30
+ import { extractErrorMessage } from "./error-message.js";
31
+ import { retentionSentence } from "./quarantine-framing.js";
32
+ import { LEAF_KIND_CTRL } from "./session-relay-client.js";
33
+ import { ACK_HASH_REASONS, AUTHORSHIP_SELF_CHAIN_MISMATCH, AUTHORSHIP_SESSION_MISMATCH, CONTENT_MAX_INBOUND_STREAMS, CONTENT_STREAM_LINGER_MS, RECEIVED_BUFFER_CAP, REFUSAL_MAY_STILL_ARRIVE, REFUSAL_NO_OTHER_ROUTE } from "./session-node-types.js";
34
+ export class SessionContentIngest {
35
+ #ctx;
36
+ /**
37
+ * The outbound half — reached for exactly ONE thing, and the reason is worth stating.
38
+ *
39
+ * The counterparty's delivery acknowledgement arrives on the SAME stream this class is already
40
+ * reading, so the receiver is the only code positioned to notice it; what it settles, though, is
41
+ * a message WE sent, whose timer and parked copy belong to the sender. Rather than give both
42
+ * halves a claim on that state, the frame is handed across this one edge.
43
+ */
44
+ #send;
45
+ constructor(ctx, send) {
46
+ this.#ctx = ctx;
47
+ this.#send = send;
48
+ }
49
+ /**
50
+ * DOD-M12B-REVIVE-RELAY-1 — the relay witness leaf handler, shared by establishment and revival.
51
+ *
52
+ * Extracted because a REVIVED session must register the same handler. It was inline in
53
+ * `#connectSessionRelay`, so revival — which never called that at all — had no live inbound path:
54
+ * every message fell back to the five-minute mailbox poll, which is why a reconnected session took
55
+ * three minutes to deliver what a fresh one delivers in seconds, and why doorbells stopped firing.
56
+ *
57
+ * A revived session that behaves differently from a fresh one is the defect. This is one of the
58
+ * two halves of making them the same.
59
+ */
60
+ relayLeafHandler(agentName, sessionId, correlationId) {
61
+ return (frame) => {
62
+ // The counterparty's witnessed leaf arrived with its canonical sequence. The
63
+ // plaintext is delivered separately over the direct content stream; this is the
64
+ // ordering/witness signal. Full canonical-sequence reconciliation against the
65
+ // local tree is MSG-001-3b (J-CONTENT).
66
+ this.#ctx.logger.info("session.relay.leaf.delivered", {
67
+ sessionId,
68
+ sequenceNumber: frame.sequence_number,
69
+ leafKind: frame.leaf_kind,
70
+ correlationId,
71
+ });
72
+ // DOD-MSG-4 (strict in-order): record the relay-witnessed canonical sequence for the
73
+ // counterparty's MSG leaves. The relay is the ordering authority; structure1_cbor =
74
+ // [version, content_hash(32), sender_pubkey, session_id, last_seen_seq, ts] (+ last_seen_hash
75
+ // at index 6 on a v2 claim — 020-ACKHASH; content_hash stays at 1). The relay sequence
76
+ // is 1-based and global per session; the daemon tree is 0-based — normalize with -1. Only
77
+ // COUNTERPARTY leaves (the ones B will ingest); our own echoed leaf already lands via the
78
+ // send path. The gate (ingestReceivedContent) reads this map to hold out-of-order arrivals.
79
+ if (!frame.authored_by_us && frame.leaf_kind !== LEAF_KIND_CTRL) {
80
+ const s1 = decodeStructure1(frame.structure1_cbor);
81
+ if (s1.ok) {
82
+ if (frame.sequence_number > 0) {
83
+ this.recordWitnessedSequence(agentName, sessionId, Buffer.from(s1.fields.contentHash).toString("hex"), frame.sequence_number - 1);
84
+ }
85
+ }
86
+ else {
87
+ // `structure1Reason`, not `error` — review F6. This is a named refusal code, and putting
88
+ // it in a field called `error` reads as an exception message to anyone scanning logs.
89
+ // The old `try` here also wrapped `recordWitnessedSequence`, so a throw from THAT was
90
+ // reported as a decode failure; the decode no longer throws, and the split is deliberate.
91
+ this.#ctx.logger.warn("session.relay.leaf.witness.decode.failed", {
92
+ sessionId,
93
+ structure1Reason: s1.reason,
94
+ correlationId,
95
+ });
96
+ }
97
+ }
98
+ // M7-UPGRADE-002: auto-acknowledge close. When the COUNTERPARTY's SEAL ctrl leaf (0x02)
99
+ // arrives and B has verified the content, B's OWN node auto-co-signs the responder SEAL
100
+ // leaf — no agent prompt — so the bilateral seal completes promptly instead of degrading
101
+ // to unilateral on a slow/busy/crashed agent. Never auto-ack our OWN echoed ctrl leaf.
102
+ if (frame.leaf_kind === LEAF_KIND_CTRL && !frame.authored_by_us) {
103
+ this.#ctx.maybeAutoAcknowledgeSeal(agentName, sessionId, correlationId);
104
+ }
105
+ };
106
+ }
107
+ /**
108
+ * DOD-M12B-ABANDON-NOTIFY-1 — drive the REAL inbound content handler with one framed message and
109
+ * a claimed peer identity.
110
+ *
111
+ * The handler is registered on a live libp2p node, so without this the only way to reach its
112
+ * branches is a full two-node transport fixture — which is why the session-abandoned branch and
113
+ * its peer pinning had no coverage at all. This feeds the same function the protocol handler
114
+ * calls, including the authentication check, rather than a copy of its logic.
115
+ */
116
+ async handleContentFrameForTest(agentName, sessionId, framedBytes, remotePeerId) {
117
+ const source = {
118
+ async *[Symbol.asyncIterator]() { yield framedBytes; },
119
+ close: async () => { },
120
+ abort: () => { },
121
+ status: "closed",
122
+ };
123
+ await this.#handleContentStream(agentName, sessionId, source, remotePeerId);
124
+ }
125
+ async ingestReceivedContent(agentName, sessionId, content, contentHash, correlationId,
126
+ /**
127
+ * DOD-FRONTIER-STRAND-1 AC1: the relay-assigned canonical position for THIS message, taken from
128
+ * the verified ordering record by the caller. Passed EXPLICITLY rather than recovered from
129
+ * `#witnessedSeq`, because that map is keyed by content hash — so two byte-identical messages
130
+ * collapse in it before dedup is ever consulted, which is the whole defect. Absent when the
131
+ * session has no relay witness (relay-degraded): see the announced fallback below.
132
+ */
133
+ canonicalSeqIn,
134
+ /**
135
+ * DOD-M15-SEALWIRE-1 part B1 — the algorithm the SENDER named on the frame, verbatim.
136
+ *
137
+ * `undefined` means the frame carried no name, which is a peer that predates the field and is
138
+ * the one case we may safely assume `sha256` for. It is threaded through rather than read off
139
+ * the session, because whether a hash is salted is a fact about the FRAME and its sender, never
140
+ * about what this side happens to hold.
141
+ */
142
+ contentHashAlgIn,
143
+ /**
144
+ * DOD-M15-SEALWIRE-1 bullet 5: the VERIFIED authorship proof for this message, when the caller
145
+ * has one. The caller is the only place that has it — `#verifyAuthorshipClaim` verifies the
146
+ * signature the frame carries beside the sender's own signed bytes, against the key inside those
147
+ * bytes, and matches the signer to this session's counterparty. That result reaches here or
148
+ * nowhere.
149
+ *
150
+ * ⚠️ IT USED TO NAME `#recordFrameOrdering`, and that was accurate until
151
+ * `DOD-M15-AUTHORSHIP-ABSENT-1`: the signature arrived only inside the RELAY's Structure 2, so
152
+ * checking authorship needed a relay record. It does not now, and the old name sends a reader to
153
+ * a method that answers a different question. Rewritten, not deleted — that dependence is the
154
+ * defect the unit removed.
155
+ *
156
+ * Optional, because the PARK route ingests without it: recovered mail proves its sender by the
157
+ * mailbox envelope instead. The row records which it was, so absence is never silent.
158
+ */
159
+ verifiedAuthorship,
160
+ /**
161
+ * 024-ORPHANTRIAGE — the key whose signature VERIFIED on a frame we could not tie to a session.
162
+ *
163
+ * Read by the orphan branch below and NOWHERE ELSE. It exists because the daemon establishes,
164
+ * cryptographically, that the sender holds a private key — and then discarded that the instant
165
+ * the session lookup came back empty, leaving the operator advised to go and make contact with
166
+ * whoever sent a message for a conversation that does not exist.
167
+ *
168
+ * Absent on the park-recovery caller, which cannot reach the orphan branch at all:
169
+ * `authenticateParkedEntry` refuses `counterparty_unknown` from the same missing record first.
170
+ */
171
+ verifiedSignerUnmatched) {
172
+ // The transcript is frozen ONLY once it is COMMITTED + signed — 'sealed' or
173
+ // 'seal_interrupted_pending' (the bilateral seal commitment) — because a later FROST
174
+ // notarization attests that exact root; a late leaf would diverge from it.
175
+ //
176
+ // MSG-001-3b recovery: a merely 'interrupted' session is NOT yet committed. The
177
+ // counterparty's last message(s) may have been parked while this party was offline, so its
178
+ // local transcript is INCOMPLETE (not frozen-final). Recovering that parked content COMPLETES
179
+ // the local view to match the counterparty BEFORE the bilateral seal — it is not a resumption
180
+ // (no new activity, no re-accept) and its root was never committed. So allow 'active' AND
181
+ // 'interrupted'; reject only the two committed states.
182
+ const record = this.#ctx.queries.getSessionRecord(agentName, sessionId);
183
+ // DOD-UNREAD-1 D4a: NEVER record content you cannot attribute. With no sessions row there is
184
+ // no counterparty — the transcript has no counterparty column, so a row written here is
185
+ // unattributable forever, counted unread by getUnreadSummary, and unreadable by cello_receive
186
+ // (the phantom-session residue). The old "(No DB row = test-only path, allowed.)" fallback
187
+ // papered that in with senderPubkey="unknown". Refuse loudly instead; the content stays
188
+ // un-acked, so a live sender redelivers once the session actually exists. After D3
189
+ // (DOD-INBOUND-GUARD-1) this path is unreachable from the wire — a fail-loud assertion.
190
+ /**
191
+ * DOD-M15-REFUSEDEVIDENCE-1 — HOISTED from below the hash cross-check, so that every refusal
192
+ * above that point can retain the bytes under it. Same expression, earlier.
193
+ *
194
+ * It is the SENDER'S CLAIM at this point — nothing has checked it yet, and on a
195
+ * `content_hash_mismatch` it provably does not describe these bytes. The quarantine read
196
+ * recomputes its own hash over what was retained rather than reprinting this one.
197
+ */
198
+ const contentHashHex = Buffer.from(contentHash).toString("hex");
199
+ if (!record) {
200
+ /**
201
+ * RETAINED FIRST, because the triage below now tells the operator whether there is an artifact
202
+ * to report — and that claim has to be made after the write, never before it (023 review F3).
203
+ *
204
+ * This is the case retention matters most for. A message for a session this daemon has no
205
+ * record of is the least explicable thing that can arrive, so it is the thing an operator has
206
+ * the least other way to show anyone. There is no `sessions` row and no counterparty, so no
207
+ * tier — `#quarantineRefusedContent` bounds it at UNKNOWN and files it at a negative position,
208
+ * outside the chain it never joined.
209
+ */
210
+ const keptOrphan = this.#ctx.refusals.quarantineRefusedContent(agentName, sessionId, "session_orphaned", content, contentHashHex, { correlationId });
211
+ /**
212
+ * 024-ORPHANTRIAGE — TWO ACTIONS EXIST AND THE EVIDENCE DECIDES WHICH.
213
+ *
214
+ * The advice here used to be *"ask the counterparty to start a NEW session."* When the message
215
+ * is a stranger probing a peer id, obeying that advice is the probe succeeding: it confirms
216
+ * somebody is home and that this agent answers, from a message that was refused.
217
+ *
218
+ * All three signals are read from things the sender does not control — their signature is
219
+ * checked against the key inside their own signed bytes, "known" comes from OUR address book,
220
+ * and "ongoing" comes from OUR transcript rows rather than the sequence number they chose.
221
+ */
222
+ const evidence = this.#ctx.refusals.orphanEvidence(agentName, sessionId, verifiedSignerUnmatched);
223
+ const triage = triageOrphanedContent(evidence, retentionSentence(sessionId, keptOrphan));
224
+ /**
225
+ * BOTH SURFACES, per Invariant 2. The log is the durable forensic record and carries the
226
+ * signals structurally — this is where an investigation days later reads what was known and
227
+ * when. The notice below is the control: it is what the agent actually reads and acts on.
228
+ */
229
+ this.#ctx.logger.warn("session.content.orphaned", {
230
+ agentName, sessionId, correlationId,
231
+ signerPubkey: evidence.signerPubkeyHex ?? "(no verifiable signature)",
232
+ signatureVerified: evidence.signerPubkeyHex !== null,
233
+ // Review F6: `"not_checked"` where nothing was measured, never a `false` that reads as a
234
+ // reading. An investigator filtering this event is the only person who will ever ask.
235
+ knownContact: evidence.knownContact,
236
+ ongoingConversation: evidence.ongoingConversation,
237
+ action: triage.action,
238
+ // 023: whether the evidence the triage points at actually exists.
239
+ retained: keptOrphan !== null,
240
+ impact: triage.impact,
241
+ });
242
+ // DOD-M15-NO-SILENT-REFUSAL-1. The notice is written even though there is no session row —
243
+ // the store is keyed (agent_id, session_id) and holds no foreign key to `sessions` precisely
244
+ // so this case can be recorded. A refusal for a session that does not exist here is the one
245
+ // the operator has the least other way to learn about.
246
+ this.#ctx.notices.noteContentRefusal(agentName, sessionId, "session_orphaned", {
247
+ kind: REFUSAL_KINDS.REFUSED,
248
+ impact: triage.impact,
249
+ guidance: triage.guidance,
250
+ });
251
+ return { ok: false, reason: "session_orphaned" };
252
+ }
253
+ // DOD-TERMINAL-WAKE-1 (review F1): `abandoned` belongs here too. It is terminal and, unlike
254
+ // `interrupted`, can NEVER complete — there is nothing left to append to and no seal to join.
255
+ // Without it, late content for a force-abandoned session was accepted: a leaf was written, the
256
+ // `cello_message` doorbell rang, the away-response and Telegram doorbell fired, and
257
+ // `cello_receive` handed it over as live work. That is the same "agent obeys a directive out of
258
+ // a conversation that has ended" harm as the sealed case, reached with no restart at all.
259
+ //
260
+ // `currentStatus` carries the real status onward: the content-park disposition and the operator
261
+ // must be able to tell an abandoned session from a sealed one, and `session_committed` alone is
262
+ // the exit point, not the cause.
263
+ if (record.status === "sealed" ||
264
+ record.status === "seal_interrupted_pending" ||
265
+ record.status === "abandoned") {
266
+ this.#ctx.logger.warn("session.content.cross_check.failed", {
267
+ sessionId,
268
+ reason: "session_committed",
269
+ currentStatus: record.status,
270
+ correlationId,
271
+ });
272
+ // DOD-M15-REFUSEDEVIDENCE-1 — RETAINED. A post-seal straggler on the DIRECT path kept nothing
273
+ // before this: `sealed_session_annex` covers the park-drain and held-drift routes, not this
274
+ // exit. Something arriving into a signed, closed conversation is exactly the kind of thing an
275
+ // operator later wants to produce.
276
+ const retainedSeq = this.#ctx.refusals.quarantineRefusedContent(agentName, sessionId, "session_committed", content, contentHashHex, {
277
+ senderPubkeyHex: record.counterparty_pubkey ?? null, correlationId,
278
+ });
279
+ /**
280
+ * DOD-M15-REFUSALTERMINAL-1 — the retention call above is also what STOPS THE WORK: it runs
281
+ * the terminal funnel, and `session_committed` is the one reason in it.
282
+ *
283
+ * Without that, the relay's next redelivery of the witness leaf armed another park fetch,
284
+ * which drained, verified, arrived here, and was refused again — measured at ~2 per second
285
+ * for 62 hours on one message. `#markContentResolved` could not be reused: this content did
286
+ * not land, and saying that it did is a lie a future reader would act on.
287
+ */
288
+ // DOD-M15-NO-SILENT-REFUSAL-1. `currentStatus` on the log line carries the REAL status —
289
+ // sealed, seal_interrupted_pending or abandoned — and the notice must not flatten those into
290
+ // one claim, so it names the record as frozen rather than asserting which way it ended.
291
+ this.#ctx.notices.noteContentRefusal(agentName, sessionId, "session_committed", {
292
+ kind: REFUSAL_KINDS.REFUSED,
293
+ impact: `This conversation is closed (it ended as "${record.status}"), so the message could not be delivered and neither can anything else they send to it. A closed conversation is signed and cannot be added to — that is what closing it means. Nothing is wrong on your side.`,
294
+ guidance: "There is nothing to repair here. If they still have something to say, ask them to start a NEW conversation — a closed one cannot be reopened, and it is worth telling them, because they may not realise it ended. Read what was said before it closed with cello_transcript.",
295
+ });
296
+ return { ok: false, reason: "session_committed", retained: retainedSeq !== null };
297
+ }
298
+ /**
299
+ * DOD-M15-SEALWIRE-1 part B1 — VERIFY UNDER THE ALGORITHM THE SENDER NAMED.
300
+ *
301
+ * Three outcomes and they must stay apart, because two of them are version differences and only
302
+ * the third is evidence of tampering. Collapsing them is how a routine skew becomes a security
303
+ * incident in the operator's log, and how a real tamper gets dismissed as a skew.
304
+ */
305
+ /**
306
+ * ⚠️ `content_hash_alg` IS NOT COVERED BY ANY SIGNATURE — review F1, and it shapes both branches
307
+ * below.
308
+ *
309
+ * The sender's signature is over `structure1_cbor`, which binds `content_hash`. It does NOT bind
310
+ * the frame envelope, so this field is an unauthenticated CLAIM by whoever sent the frame. That
311
+ * is fine for choosing how to verify — a wrong choice simply fails — but it means neither branch
312
+ * may state, as fact, anything it learned only from this field.
313
+ *
314
+ * It also means both branches MUST mark the session unverifiable. Before B1 every frame that
315
+ * failed the cross-check reached `#contentDesynced`, which gates auto-co-signing and unilateral
316
+ * ratification. Returning early here would have let a sender bypass the tamper detector by
317
+ * appending one unsigned string: sign hash H, send different bytes, add an unreadable algorithm
318
+ * name, and the receiver refuses politely, records nothing, and auto-co-signs at seal time.
319
+ */
320
+ const algResolved = resolveContentHashAlg(contentHashAlgIn);
321
+ if (!algResolved.ok) {
322
+ // A NAME WE CANNOT READ. Not a legacy peer — an unreadable one. There is no value to compare
323
+ // against, so `content_hash_mismatch` here would be an exit-point label standing in for
324
+ // "their build is newer than ours" (Invariant 2). Refused by its own name instead.
325
+ this.#ctx.markContentUnverifiable(agentName, sessionId, "unverifiable");
326
+ this.#ctx.refusals.noteUnreadableAlgFrame(agentName, sessionId, contentHash, algResolved.value);
327
+ this.#ctx.logger.error("session.content.cross_check.failed", {
328
+ sessionId, correlationId,
329
+ reason: "content_hash_alg_unknown",
330
+ declaredAlg: algResolved.value,
331
+ // States only what is KNOWN. The old wording said "nothing was altered and nobody did
332
+ // anything wrong" and "Do not treat this as a security event" — both inferred from the
333
+ // unsigned field, i.e. from the attacker in the case that matters.
334
+ impact: "this message could not be verified, so it was NOT ingested and NOT shown. The algorithm name is a claim by the sender and is not covered by any signature, so it does not establish what they actually did. This session will not auto-co-sign at close.",
335
+ guidance: "Almost always their CELLO build is newer than this one: ask which version they are running, and upgrade. If they are on the SAME version as you, that explanation does not hold and the frame was malformed or crafted — do not close the session by auto-acknowledgement.",
336
+ });
337
+ // DOD-M15-REFUSEDEVIDENCE-1 — RETAINED. The algorithm name is an unsigned claim by whoever
338
+ // sent the frame, so this branch is reachable by crafting as well as by version skew, and the
339
+ // crafted case is one to be able to show someone.
340
+ this.#ctx.refusals.quarantineRefusedContent(agentName, sessionId, "content_hash_alg_unknown", content, contentHashHex, {
341
+ senderPubkeyHex: record.counterparty_pubkey ?? null, correlationId,
342
+ });
343
+ // DOD-M15-REFUSED-INBOUND-SILENT-1: the SAME strings the log just carried, to the operator.
344
+ // This reason is a version skew, so it affects every message from that counterparty — without
345
+ // this the conversation goes permanently quiet and they conclude the peer stopped replying.
346
+ this.#ctx.notices.noteContentRefusal(agentName, sessionId, "content_hash_alg_unknown", {
347
+ kind: REFUSAL_KINDS.REFUSED,
348
+ impact: "this message could not be verified, so it was NOT ingested and NOT shown. The algorithm name is a claim by the sender and is not covered by any signature, so it does not establish what they actually did. This session will not auto-co-sign at close.",
349
+ guidance: "Almost always their CELLO build is newer than this one: ask which version they are running, and upgrade. If they are on the SAME version as you, that explanation does not hold and the frame was malformed or crafted — do not close the session by auto-acknowledgement.",
350
+ });
351
+ return { ok: false, reason: "content_hash_alg_unknown" };
352
+ }
353
+ let computed;
354
+ try {
355
+ computed = contentHashFor(content, {
356
+ alg: algResolved.alg,
357
+ // The salt is OURS — the sender's frame never carries one, and could not be trusted if it
358
+ // did. A salted frame we hold no salt for throws below and is refused by name.
359
+ salt: this.#ctx.salts.getSessionSalt(agentName, sessionId),
360
+ });
361
+ }
362
+ catch (err) {
363
+ // Reached when the peer named the salted algorithm and this side holds no salt for the
364
+ // session — the agreement never completed, or its record is gone. Distinct from a mismatch
365
+ // for the same reason as above: nothing was tampered with, we simply cannot check it.
366
+ this.#ctx.markContentUnverifiable(agentName, sessionId, "unverifiable");
367
+ this.#ctx.logger.error("session.content.cross_check.failed", {
368
+ sessionId, correlationId,
369
+ reason: "content_hash_salt_unavailable",
370
+ declaredAlg: algResolved.alg,
371
+ detail: extractErrorMessage(err),
372
+ // "Nothing was altered" was the same mistake as the branch above: it is not knowable from
373
+ // here. What IS knowable is that we could not check.
374
+ impact: "this message could not be verified — the sender says it is salted and this side holds no salt for the session — so it was NOT ingested and NOT shown. This session will not auto-co-sign at close.",
375
+ // Review F6: `#getSessionSalt` returns null for THREE conditions and only one of them wants
376
+ // a close. A read failure and a corrupt row both leave us holding no salt, which is exactly
377
+ // what makes the agreement re-offer a contribution and repair itself on the next connect.
378
+ //
379
+ // The adoption refusal is the FOURTH, added with the Decision #8 guard, and it is the only
380
+ // one that does not repair: this side declined the salt permanently for this session, so
381
+ // waiting for a reconnect is exactly the wrong advice. Leaving it out of this list would
382
+ // have sent an operator to look for a read failure that is not there and never will be.
383
+ // DOD-M15-SALTSPLIT-1 review MEDIUM-3: `session.salt.discarded` is the FIFTH cause, and it
384
+ // was added by the discard without appearing in this tree. Without it an operator whose salt
385
+ // was deliberately dropped is sent to look for three events that will not be there and then
386
+ // told a fifth thing that is false — the agreement DID complete here, and was then undone on
387
+ // purpose.
388
+ guidance: "Look for session.salt.discarded first: if it is there, this side dropped its salt because the counterparty said it could never hold one, the agreement did complete and was deliberately undone, and a new session is the repair. Otherwise look for session.salt.adoption.refused: if it is there, this side declined the salt because the session had already hashed messages, that is permanent for this session, and reconnecting will NOT fix it — close the session and start a new one. Otherwise look for session.salt.read.failed or session.salt.persist.failed. If either is present the agreement re-runs on the next reconnect and this repairs itself — wait for that before doing anything. If none of the four is present, the agreement never completed with this counterparty: close the session and start a new one. In every case the transcript up to here is intact.",
389
+ });
390
+ // DOD-M15-REFUSEDEVIDENCE-1 — RETAINED. We could not check it, which is precisely why the
391
+ // bytes have to survive: the question of what they actually were stays open, and a hash we
392
+ // could not verify answers none of it.
393
+ this.#ctx.refusals.quarantineRefusedContent(agentName, sessionId, "content_hash_salt_unavailable", content, contentHashHex, {
394
+ senderPubkeyHex: record.counterparty_pubkey ?? null, correlationId,
395
+ });
396
+ // DOD-M15-REFUSED-INBOUND-SILENT-1 — and this branch needed it MORE than the two that had it.
397
+ //
398
+ // It was refused, logged with a full impact and guidance, not ingested, not shown — and the
399
+ // operator was told nothing. Twenty lines below the branches that were wired, in the same
400
+ // function, with the same shape.
401
+ //
402
+ // One of its four causes is permanent, and the guidance above says so in its own words: an
403
+ // adoption refusal means this side declined the salt for the life of the session and
404
+ // reconnecting will NOT fix it. So the failure this line exists to close — the conversation
405
+ // goes quiet, the explanation sits in a log nobody opens — was still live on the one branch
406
+ // that never repairs itself.
407
+ //
408
+ // The guidance is passed by reference to the log's own text rather than duplicated: a second
409
+ // copy is a second thing to keep true, and the log's version is the one that gets maintained.
410
+ this.#ctx.notices.noteContentRefusal(agentName, sessionId, "content_hash_salt_unavailable", {
411
+ kind: REFUSAL_KINDS.REFUSED,
412
+ impact: "this message could not be verified — the sender says it is salted and this side holds no salt for the session — so it was NOT ingested and NOT shown. This session will not auto-co-sign at close.",
413
+ guidance: "If session.salt.discarded is present, this side dropped its salt on purpose because the counterparty said it could never hold one — a new session is the repair. If this side refused the salt because the session had already hashed messages, that is PERMANENT for this session and reconnecting will not fix it — close the session and start a new one. Otherwise the salt agreement re-runs on the next reconnect and this repairs itself. Check session.salt.discarded and session.salt.adoption.refused in the log to tell which. The transcript up to here is intact either way.",
414
+ });
415
+ return { ok: false, reason: "content_hash_salt_unavailable" };
416
+ }
417
+ if (Buffer.from(computed).toString("hex") !== contentHashHex) {
418
+ this.#ctx.logger.warn("session.content.cross_check.failed", {
419
+ sessionId,
420
+ reason: "content_hash_mismatch",
421
+ // WHICH algorithm the comparison ran under. Without it, a mismatch is unfalsifiable from the
422
+ // log: an operator cannot tell "the bytes were altered" from "we checked it the wrong way".
423
+ declaredAlg: algResolved.alg,
424
+ correlationId,
425
+ });
426
+ // M7-UPGRADE-002 (SI-002): a tamper makes this session's content unverifiable — the
427
+ // auto-acknowledge gate must never auto-co-sign it. The session stays alive (DOD-MSG-7),
428
+ // but the responder seal now requires the agent's explicit decision, not an auto-ack.
429
+ this.#ctx.markContentUnverifiable(agentName, sessionId, "tampered");
430
+ /**
431
+ * DOD-M15-REFUSEDEVIDENCE-1 — RETAINED, and this is the highest-value row in the table.
432
+ *
433
+ * A tampered frame is the one case where the message and the sender's commitment PROVABLY
434
+ * disagree, and the proof only exists while both halves do. Before this, the bytes went on the
435
+ * floor and all that survived was a hash of something nobody still had.
436
+ *
437
+ * `verifiedAuthorship` is stored when the caller verified a signature over the sender's own
438
+ * bytes. That is what makes the row evidence rather than a note: the signature is checked
439
+ * against the key inside the sender's signed bytes, not against anything this side chose.
440
+ */
441
+ this.#ctx.refusals.quarantineRefusedContent(agentName, sessionId, "content_hash_mismatch", content, contentHashHex, {
442
+ senderPubkeyHex: this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId))?.counterpartyPubkey ?? record.counterparty_pubkey ?? null,
443
+ ...(verifiedAuthorship ? { authorship: verifiedAuthorship } : {}),
444
+ correlationId,
445
+ });
446
+ // DOD-M15-REFUSED-INBOUND-SILENT-1. Deliberately does NOT include the content or the hashes:
447
+ // it failed verification, and showing it is the injection path this cross-check closes.
448
+ this.#ctx.notices.noteContentRefusal(agentName, sessionId, "content_hash_mismatch", {
449
+ kind: REFUSAL_KINDS.REFUSED,
450
+ impact: "a message arrived whose bytes do not match the hash the sender committed to, so it was NOT ingested and NOT shown. This session will not auto-co-sign at close.",
451
+ guidance: "Either the message was altered in transit or the sender's record is wrong. Ask the counterparty to resend. Do not close this session by auto-acknowledgement — seal it only by an explicit decision.",
452
+ });
453
+ return { ok: false, reason: "content_hash_mismatch" };
454
+ }
455
+ const entry = this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId));
456
+ const senderPubkey = entry?.counterpartyPubkey ?? record.counterparty_pubkey;
457
+ if (!senderPubkey) {
458
+ // DOD-UNREAD-1 D4a (AC4, supersedes the MSGWAKE-1 F1 paper-in): the schema requires
459
+ // counterparty_pubkey NOT NULL, so this is unreachable unless a row was hand-crafted empty.
460
+ // Either way, "unknown" is never written to a transcript row — refuse instead.
461
+ this.#ctx.logger.warn("session.content.sender_unresolved", { sessionId, agentName, correlationId });
462
+ // DOD-M15-REFUSEDEVIDENCE-1 — RETAINED, with NO sender key, because there is none and that
463
+ // absence is the evidence. The guidance below says to report this; this is the artifact there
464
+ // is to report. Bounded at the UNKNOWN tier — there is no contact to look a tier up on, which
465
+ // is the same fact that made it unattributable.
466
+ const keptUnresolved = this.#ctx.refusals.quarantineRefusedContent(agentName, sessionId, "sender_unresolved", content, contentHashHex, { correlationId });
467
+ this.#ctx.notices.noteContentRefusal(agentName, sessionId, "sender_unresolved", {
468
+ kind: REFUSAL_KINDS.REFUSED,
469
+ impact: "A message arrived that this daemon could not attribute to anyone, so it was not delivered. This conversation's record does not say who the other party is, which a conversation opened normally always does. TREAT THIS AS HOSTILE: a message that cannot be tied to a sender is far more likely to be a probe or an attack than a fault.",
470
+ /**
471
+ * ⚠️ NO "WHEN IN DOUBT" HERE — Andre, 2026-09-03: *"This message has no sender, the chances
472
+ * that it is hostile are very high. When in doubt? No. Just report it."*
473
+ *
474
+ * That hedge belongs on the ambiguous branch in `024-ORPHANTRIAGE`, where a verified
475
+ * signature from a known contact leaves a real judgement to make. There is no judgement
476
+ * here. Softening it would teach the operator to weigh a case that does not need weighing.
477
+ *
478
+ * ⚠️ IT NAMES NO REPORTING DESTINATION, and that is still true — but HALF of the reason has
479
+ * gone, so the sentence is rewritten rather than left to read as though nothing changed.
480
+ *
481
+ * It used to rest on two facts: `CELLO_Reporting` does not exist (`DOD-M15-ORPHANTRIAGE-1`,
482
+ * still open) and **the message itself is not retained**. The second is no longer true —
483
+ * `DOD-M15-REFUSEDEVIDENCE-1` retains it, and the guidance below now says so and names where
484
+ * it is. Telling an operator to report something while keeping nothing to report was the
485
+ * gap; naming a destination nobody can reach would be Invariant 4's failure. So: the
486
+ * artifact is named now, the destination when 024 lands.
487
+ *
488
+ * ⚠️ THE ROTATION ADVICE IS MEASURED, NOT ASSUMED. `#startReceiverNode` mints the standing
489
+ * receiver's transport key with `randomBytes(32)` and never persists it, so a logout/login
490
+ * genuinely yields a NEW peer id and fresh directory connections. **And the bound is stated
491
+ * in the same breath:** session nodes DO persist their seed (`DOD-M12B-SESSION-SEED-1`, so a
492
+ * revived conversation keeps its address), so this rotates the front door and not the doors
493
+ * already open. Telling an operator to rotate without that bound would have them believe
494
+ * they had closed something they had not.
495
+ */
496
+ guidance:
497
+ // "That is the artifact to show someone" is NOT appended: it would be false on the branch
498
+ // where nothing was retained, which is the branch this sentence exists to be honest about.
499
+ "Report this. " + retentionSentence(sessionId, keptUnresolved) +
500
+ "Do not try to reply — there is no one to reply to, and answering an unattributable message is what a probe is looking for. " +
501
+ "Then rotate your address: run cello logout followed by cello login. Your standing receiver's network identity is generated fresh each time it starts and is never stored, so this gives you a new one and rebuilds your connections to the directory — anyone holding the old address is left talking to something that no longer answers. " +
502
+ "It does NOT change the addresses of conversations you already have open: those identities are kept on purpose so an interrupted conversation can resume. " +
503
+ "This conversation cannot be repaired: close it with cello_close_session, and open a new one yourself if you were expecting someone. See session.content.sender_unresolved in the daemon log.",
504
+ });
505
+ return { ok: false, reason: "sender_unresolved" };
506
+ }
507
+ // DOD-MSG-5: a content_hash satisfies AT MOST ONE Merkle leaf, exactly once. If this hash is
508
+ // already a leaf in the tree — it arrived BOTH directly and via the relay-park backstop, or it
509
+ // is a replay — do NOT append a second leaf and do NOT double-count it. The recipient already
510
+ // holds this message at its assigned sequence. (In the normal single-delivery case this find is
511
+ // -1, so the live/recover append paths are unchanged.)
512
+ // ─── DOD-FRONTIER-STRAND-1 AC1: the discriminator is the POSITION, not the content ───
513
+ //
514
+ // The old rule ("a content_hash satisfies AT MOST ONE Merkle leaf") is false whenever two
515
+ // genuinely distinct messages match byte-for-byte — and two instances of the same model,
516
+ // answering the same message with similar context, collide far more readily than humans do.
517
+ // That is what stranded session dbb93dfc... for a week: an away responder fired twice with
518
+ // identical text, the sender appended both, the receiver dropped the second as a "redelivery",
519
+ // and the two frontiers disagreed forever. No receipt was ever possible.
520
+ //
521
+ // The relay already assigns every submission a unique position: a REDELIVERY carries the same
522
+ // position, a genuinely new identical message carries a NEW one. So a duplicate is the same
523
+ // hash AT THE SAME POSITION -- never the same hash anywhere.
524
+ const tree = this.#ctx.getSessionTree(agentName, sessionId);
525
+ let existingIdx;
526
+ if (canonicalSeqIn !== undefined && canonicalSeqIn >= 0 && tree.hashAt(canonicalSeqIn) === contentHashHex) {
527
+ // The relay position holds exactly this content: a redelivery.
528
+ existingIdx = canonicalSeqIn;
529
+ }
530
+ else if (canonicalSeqIn !== undefined && canonicalSeqIn >= 0 && canonicalSeqIn >= tree.size()) {
531
+ // The position is at or beyond the frontier, so it cannot be a leaf we already hold. A
532
+ // genuinely new message — including one byte-identical to an earlier leaf, which is the whole
533
+ // point of AC1.
534
+ existingIdx = -1;
535
+ }
536
+ else if (canonicalSeqIn !== undefined && canonicalSeqIn >= 0) {
537
+ // ─── POSITION DRIFT (review F2, a regression this fix introduced and this branch repairs) ───
538
+ //
539
+ // `canonicalSeqIn < tree.size()` yet that slot holds different content, so **leaf index is no
540
+ // longer the relay position** and the position cannot be used as an index into the tree. That
541
+ // is §7a's drift: a first message whose relay submit failed is appended locally and never
542
+ // counted by the relay, leaving the local record permanently one ahead.
543
+ //
544
+ // Using the position as an index here made a TRUE REDELIVERY append a second leaf — measured:
545
+ // tree size 3 where the pre-fix code correctly gave 2. That is the "too permissive" direction,
546
+ // and it inflates this side's tree against the counterparty's: the strand, from the other end.
547
+ //
548
+ // So under drift, fall back to the content-hash rule. It is weaker — it still cannot tell two
549
+ // identical messages apart — but it is CORRECT about redelivery, which is the failure actually
550
+ // reachable here, and it is exactly the pre-existing behavior, so this is not a regression in
551
+ // either direction. Loudly announced, because the ambiguity is real and the drift is the thing
552
+ // that should be fixed (DOD-FIRSTMSG-WITNESS-1 closes the producer).
553
+ existingIdx = tree.indexOfHash(contentHashHex);
554
+ // Announce only when the fallback actually DECIDED something (it found a duplicate). When it
555
+ // finds nothing the message simply appends, `session.content.sequence_behind_tree` already
556
+ // reports the drift itself, and a second warn on every message of a drifted session would
557
+ // bury the case that matters. A signal that fires on the normal case is not a signal.
558
+ if (existingIdx >= 0)
559
+ this.#ctx.logger.warn("session.content.dedup.position_drifted", {
560
+ sessionId,
561
+ agentName,
562
+ contentHashHex,
563
+ canonicalSeq: canonicalSeqIn,
564
+ treeSize: tree.size(),
565
+ dedupedAt: existingIdx,
566
+ reason: "leaf_index_is_not_relay_position_fell_back_to_content_hash",
567
+ correlationId,
568
+ });
569
+ }
570
+ else {
571
+ // RELAY-DEGRADED: no witness, so no discriminator exists and the content-hash rule is all
572
+ // there is. Keeping it preserves today's protection against real redelivery and today's blind
573
+ // spot for identical messages -- the strand can still form on this path. Section 5a permits
574
+ // proceeding rather than refusing (losing content is worse than mis-ordering it), but only
575
+ // ANNOUNCED: a silent fallback is exactly how this went a week unnoticed. Fires only when the
576
+ // hash actually matches, so it marks a real decision rather than every unwitnessed message.
577
+ existingIdx = tree.indexOfHash(contentHashHex);
578
+ // Gated exactly as its sibling `session.content.unwitnessed` is (see :3933): a session with NO
579
+ // RELAY ATTACHED has no witness BY DESIGN, so warning there would fire on every message of a
580
+ // normal no-relay session and bury the case that means something. A signal that fires on the
581
+ // normal case is not a signal. The reason distinguishes the two shapes rather than asserting
582
+ // the relay is absent — the position can also be missing because this particular frame carried
583
+ // no ordering record while the relay is perfectly healthy.
584
+ if (existingIdx >= 0 && this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId))?.relayClient) {
585
+ this.#ctx.logger.warn("session.content.dedup.unwitnessed", {
586
+ sessionId,
587
+ agentName,
588
+ contentHashHex,
589
+ sequenceNumber: existingIdx,
590
+ reason: "no_ordering_record_deduped_on_content_hash",
591
+ correlationId,
592
+ });
593
+ }
594
+ }
595
+ if (existingIdx >= 0) {
596
+ this.#ctx.logger.info("session.content.deduplicated", {
597
+ sessionId,
598
+ contentHashHex,
599
+ sequenceNumber: existingIdx,
600
+ witnessed: canonicalSeqIn !== undefined,
601
+ correlationId,
602
+ });
603
+ // appendedCount 0 — a dedup appends NO new leaf, so a recover that re-pulls an already-ingested
604
+ // entry (e.g. after auto-recover already drained it) must not count it as a fresh recovery.
605
+ return { ok: true, leafIndex: existingIdx, sequenceNumber: existingIdx, appendedCount: 0 };
606
+ }
607
+ // M8C-ABUSE-1 (reviewer HIGH fix, D18): per-session total-size cap (anti-drip-feed) —
608
+ // "whitelisted senders bounded only by disk" (DoD), so a known contact is exempt entirely.
609
+ // MUST run BEFORE the hold-branch below — the original placement (after it) let a
610
+ // non-contact sender drip-feed unbounded bytes by making every message arrive "out of order"
611
+ // relative to the relay witness (held content skipped the cap entirely, then #releaseHeld
612
+ // appended it later with no re-check). Accounts for bytes already committed AND bytes
613
+ // currently sitting in the hold buffer (multiple held chunks could otherwise each individually
614
+ // pass the check while cumulatively exceeding it once released). Runs BEFORE the M9 screening
615
+ // seam below (cheap + synchronous — fail fast on volume before spending gateway compute on
616
+ // content headed for rejection anyway); both gates are independent and either rejects on its
617
+ // own criteria, so ordering between them does not change correctness.
618
+ {
619
+ // DOD-TIER-2 AC2: the per-session byte cap is the sender's TIER cap (DEFAULT_TIER_BOUNDS),
620
+ // applied to EVERY sender — no tier is unbounded (INV-TIER-BOUND), so a contact is no longer
621
+ // "exempt entirely". A stranger (no row → UNKNOWN) keeps the 25 MB cap; KNOWN+ get more.
622
+ const senderTier = this.#ctx.records.getTier(agentName, senderPubkey);
623
+ const cap = this.#ctx.records.resolveTierBound(agentName, senderTier, "max_bytes");
624
+ const priorTotal = this.#ctx.queries.getReceivedBytesTotal(agentName, sessionId);
625
+ const heldTotal = this.#ctx.held.getHeldBytesTotal(agentName, sessionId);
626
+ if (priorTotal + heldTotal + content.length > cap) {
627
+ this.#ctx.logger.warn("session.content.abuse_bound.session_size_exceeded", {
628
+ sessionId,
629
+ agentName,
630
+ senderPubkey,
631
+ priorTotal,
632
+ heldTotal,
633
+ incoming: content.length,
634
+ cap,
635
+ tier: senderTier,
636
+ correlationId,
637
+ });
638
+ this.#ctx.notices.noteSizeCapRefusal(agentName, sessionId, cap, senderTier);
639
+ return { ok: false, reason: "session_size_limit_exceeded" };
640
+ }
641
+ }
642
+ // M9-CORE-001: the inbound screening seam (INV-5). Screen here — after the content is proven
643
+ // authentic (hash cross-check) and confirmed not a duplicate, before it is either held for
644
+ // ordering or appended to the agent-facing buffer. This is the SINGLE inbound funnel: direct
645
+ // arrivals, recovered/parked content (daemon recover → here), and held-then-released content
646
+ // (held below, screened now, released already-screened) all pass this point. A non-allow
647
+ // verdict means the content is NOT delivered to the agent: it is not held, not buffered, and
648
+ // no leaf is appended — the message stays un-acked so the sender's TTF/park/retry redelivers
649
+ // it once the gateway is reachable again (DB-001 fail-closed: hold, never expose ungated).
650
+ // DOD-DOC-SCREEN-CLASSIFY-1: a DOCUMENT frame skips the gateway's content screen HERE, and is
651
+ // screened later on text instead of bytes. Every content step is inert or worse for one at this
652
+ // point — the sanitizer's rewrites are deliberately discarded by the funnel below (rewriting a
653
+ // signed envelope destroys it), and language/injection judge a UTF-8 decode of binary. Size stays
654
+ // bounded twice (MAX_DOCUMENT_FRAME_BYTES at classify, the gate's own cap).
655
+ //
656
+ // WHAT IS TRADED, stated plainly: the screen skipped here is fail-CLOSED (a gateway that is down
657
+ // returns a transient block, and the frame is held un-acked for redelivery). Its replacement —
658
+ // the gate's in-process rules, then the semantic screen at `document-inbound.ts` step 7a-bis —
659
+ // is fail-OPEN on that same condition, because holding document convergence hostage to an
660
+ // optional layer breaks a layer that degrades by design. That degradation is LOGGED BY NAME
661
+ // there (`document.inbound.screen.unavailable`); it is not silent, and it is not free.
662
+ //
663
+ // Logged by name so the skip is visible rather than assumed.
664
+ const isDocFrame = this.#ctx.isDocumentFrame?.(content) === true;
665
+ if (isDocFrame) {
666
+ this.#ctx.logger.info("session.content.screen.skipped_document_frame", {
667
+ sessionId,
668
+ agentName,
669
+ correlationId,
670
+ });
671
+ }
672
+ const inboundVerdict = isDocFrame
673
+ ? { disposition: "allow", content }
674
+ : await this.#ctx.securityGateway.screenInbound(content, {
675
+ direction: "inbound",
676
+ agentName,
677
+ sessionId,
678
+ correlationId,
679
+ });
680
+ // M9 terminal-vs-transient split. A TERMINAL block (inboundVerdict.terminal) is a detector
681
+ // rejecting the CONTENT itself — a confident non-allowlisted language (IN-003), a high-score
682
+ // injection (IN-002), or an oversized payload (IN-001). The identical bytes would be rejected
683
+ // identically on redelivery, so holding them un-acked would loop the sender forever. Instead a
684
+ // terminal block is `screenedOut`: it records a leaf binding the ORIGINAL content hash and is
685
+ // acknowledged (the sender stops), but is NEVER buffered for the agent (cello_receive never sees
686
+ // it). The leaf is REQUIRED, not cosmetic: the sender appended this leaf at its CANONICAL position
687
+ // on send, so a terminal block must take the SAME strict-in-order path as a delivered message —
688
+ // record the leaf at its canonical index, not in arrival order — or the two parties' hash chains
689
+ // diverge by POSITION and the bilateral seal cross-check mismatches (code-review HIGH-1). The only
690
+ // difference from a normal message is that it leafs WITHOUT buffering. A TRANSIENT block (a
691
+ // fail-closed gateway_unavailable / governance_timeout) records nothing and is not acked.
692
+ const terminalBlock = inboundVerdict.disposition === "block" && inboundVerdict.terminal === true;
693
+ if (inboundVerdict.disposition !== "allow" && inboundVerdict.disposition !== "redact" && !terminalBlock) {
694
+ // TRANSIENT block / warn HOLD (do not deliver, do not leaf, do not ack). The message stays
695
+ // un-acked so the sender's TTF/park/retry redelivers and re-screens it once the gateway recovers.
696
+ // (If we committed a leaf, dedup would later swallow the redelivery and the agent would never
697
+ // receive it.)
698
+ if (inboundVerdict.reason === GOVERNANCE_TIMEOUT) {
699
+ this.#ctx.logger.error("security.gateway.timeout", {
700
+ sessionId,
701
+ reason: inboundVerdict.reason,
702
+ correlationId,
703
+ });
704
+ }
705
+ else if (inboundVerdict.reason === GATEWAY_UNAVAILABLE) {
706
+ this.#ctx.logger.error("security.gateway.unavailable", {
707
+ direction: "inbound",
708
+ reason: inboundVerdict.reason,
709
+ correlationId,
710
+ });
711
+ }
712
+ else {
713
+ this.#ctx.logger.warn("security.gateway.inbound.blocked", {
714
+ sessionId,
715
+ disposition: inboundVerdict.disposition,
716
+ reason: inboundVerdict.reason,
717
+ correlationId,
718
+ });
719
+ }
720
+ /**
721
+ * DOD-M15-REFUSEDEVIDENCE-1 — **A TRANSIENT BLOCK RETAINS NOTHING, and nothing is lost by
722
+ * that.** Nothing was recorded and, decisively, nothing was ACKNOWLEDGED: the message is still
723
+ * with the sender, whose daemon redelivers it. When the gateway recovers the same bytes are
724
+ * screened, and if they are blocked they are retained then, under the detector's own reason.
725
+ *
726
+ * Retaining here would file a copy of a message that is coming back — a duplicate, not
727
+ * evidence — and it would do so for content nothing has yet judged, once per redelivery
728
+ * attempt, for as long as the gateway stays down.
729
+ */
730
+ // DOD-M15-NO-SILENT-REFUSAL-1 — a TRANSIENT block, and saying which it is, is the whole
731
+ // value of the notice. Nothing was recorded and nothing was acked, so the sender's daemon
732
+ // redelivers on its own. An operator who reads the silence as delivery, or who asks the
733
+ // counterparty to resend, is acting on the opposite of what happened.
734
+ this.#ctx.notices.noteContentRefusal(agentName, sessionId, inboundVerdict.reason ?? "inbound_screen_blocked", {
735
+ kind: REFUSAL_KINDS.DEFERRED,
736
+ impact: "the screener could not reach a verdict on an inbound message, so it was NOT ingested and NOT shown. Nothing was recorded and nothing was acknowledged — the message is still with the sender and their daemon will redeliver it once screening works again. Do not read this silence as delivery.",
737
+ guidance: "TRANSIENT — do not ask the counterparty to resend, and do not close the session. Get the local screening gateway healthy and the backlog comes through on its own: look for security.gateway.timeout, security.gateway.unavailable and security.gateway.inbound.blocked in the daemon log — the third is what an internal screen_error logs, and naming only the first two sends you looking for lines that will not be there. While it stays down, every message from every counterparty takes this path.",
738
+ });
739
+ return { ok: false, reason: inboundVerdict.reason ?? "inbound_screen_blocked" };
740
+ }
741
+ // Assigned only on the terminal-block branch and invoked beside each retention attempt below.
742
+ let noteTerminalBlock;
743
+ if (terminalBlock) {
744
+ this.#ctx.logger.warn("security.gateway.inbound.terminal_block", {
745
+ sessionId,
746
+ disposition: inboundVerdict.disposition,
747
+ reason: inboundVerdict.reason,
748
+ correlationId,
749
+ });
750
+ /**
751
+ * DOD-M15-REFUSEDEVIDENCE-1 — retention for a terminal block happens where its LEAF happens,
752
+ * not here. Two sites below (the hold branch and the in-order append), each writing the
753
+ * quarantine row at the same index as the leaf it accompanies.
754
+ *
755
+ * Not here, deliberately: this point is upstream of the post-screen dedup re-check and the
756
+ * size-cap re-check, either of which can still refuse. Retaining above them would file
757
+ * evidence for a message this call then reports as capped — and the cap path is the one that
758
+ * is ruled NOT to retain.
759
+ */
760
+ /**
761
+ * DOD-M15-NO-SILENT-REFUSAL-1 — **the moment the product catches the attack it exists to
762
+ * catch, and until now the operator was told nothing about it.**
763
+ *
764
+ * This path is not an error path, which is exactly why it had no notice: the block leafs the
765
+ * original content hash at its canonical position and acknowledges the sender, so nothing
766
+ * fails and nothing loops. The message is simply never handed to the agent. From the
767
+ * operator's chair a message they were expecting never arrives and the record shows a leaf
768
+ * with nothing in it.
769
+ *
770
+ * The notice NEVER carries the blocked content — a screener that can be talked into surfacing
771
+ * what it blocked is not a screener.
772
+ *
773
+ * ⚠️ **THE GUIDANCE USED TO SAY "DO NOT ASK FOR THE ORIGINAL TEXT", AND THAT IS NOW WRONG.**
774
+ * Rewritten rather than deleted, per the claim-comment rule, because the reasoning is what
775
+ * changed and not just the sentence. It rested on the content being unavailable; under
776
+ * `DOD-M15-REFUSEDEVIDENCE-1` it is retained and there is a route that returns it FRAMED. And
777
+ * the friction was never protection: Andre, 2026-09-03 — *"eventually the LLM is going to go
778
+ * searching for it, because human beings are going to direct their LLMs to find it, and it's
779
+ * going to come back and say 'Hey, I found it here, the message says…' — which is far
780
+ * worse."* Withholding the route removes the WARNING from the read, not the read.
781
+ *
782
+ * What survives unchanged: do not turn screening off. That is still the one action that makes
783
+ * things worse, and it is the one the guidance still refuses.
784
+ */
785
+ /**
786
+ * ⚠️ THE DETECTOR'S OWN REASON SURVIVES — `inbound_screen_blocked` is only the fallback.
787
+ *
788
+ * Invariant 3: a downstream handler must not replace an upstream descriptive error with a
789
+ * generic one. The verdict already says WHICH detector fired — `inbound_language_blocked` and
790
+ * an injection block are different problems with different remedies, and one of them has an
791
+ * operator command that fixes it. Flattening both to `inbound_screen_blocked` would also
792
+ * deduplicate them together, so the second kind would be silent for the life of the session.
793
+ *
794
+ * The gateway's own `guidance` is appended when it has one, for the same reason: it is the
795
+ * half that names the actual command.
796
+ */
797
+ // `?? "inbound_screen_blocked"` is a floor, not a live branch: every verdict producer in the
798
+ // tree sets `reason`, so today it never fires. It stays because `reason` is optional on the
799
+ // type, and a notice keyed on `undefined` would collapse every future detector into one row.
800
+ /**
801
+ * ⚠️ **DEFERRED UNTIL THE RETENTION HAS ACTUALLY RUN — review F3.** The notice used to be
802
+ * written here, above both append sites, and claimed the message was kept before anything had
803
+ * tried to keep it. It is now a closure invoked beside each `#quarantineRefusedContent` call,
804
+ * carrying that call's own answer.
805
+ *
806
+ * Two paths between here and there deliberately write NO notice now, and both are the better
807
+ * answer: a post-screen dedup means this exact message was already noticed the first time, and
808
+ * a size-cap refusal writes `#noteSizeCapRefusal` instead — which is what actually happened,
809
+ * where before the operator got both stories at once.
810
+ */
811
+ noteTerminalBlock = (stored) => {
812
+ this.#ctx.notices.noteContentRefusal(agentName, sessionId, inboundVerdict.reason ?? "inbound_screen_blocked", {
813
+ kind: REFUSAL_KINDS.BLOCKED,
814
+ impact: "the screener blocked an inbound message: its content matched a detector this agent runs on everything that arrives. It was NOT shown to the agent. It IS recorded in the hash chain at its position and the sender was acknowledged, so they will not resend it and they were not told it was blocked.",
815
+ guidance: "This is the protection doing its job, and nothing is required of you. If you were expecting something from this counterparty around now, tell them it was blocked and ask them to say it differently. " +
816
+ retentionSentence(sessionId, stored) +
817
+ (stored === null ? "" : "There is no reason to read it unless you need to show someone, or judge whether this was an attack. ") +
818
+ "Do NOT turn screening off to read it: that is the one action here that makes things worse. security.gateway.inbound.terminal_block in the daemon log names which detector fired." +
819
+ (inboundVerdict.guidance !== undefined ? ` The detector says: ${inboundVerdict.guidance}` : ""),
820
+ });
821
+ };
822
+ }
823
+ // M9-IN-001: a `redact` verdict (inbound sanitization) DELIVERS the sanitized text to the agent,
824
+ // while the Merkle leaf still binds the ORIGINAL content hash below — the transcript records what
825
+ // the peer actually sent; the agent sees the sanitized form. `allow` leaves the content unchanged.
826
+ // A terminal block carries the original bytes here only so its leaf binds the right hash; it is
827
+ // never delivered (the screenedOut flag below routes it to a leaf-without-buffer).
828
+ const deliverContent = inboundVerdict.disposition === "redact" && inboundVerdict.content !== undefined
829
+ ? inboundVerdict.content
830
+ : content;
831
+ // screenInbound above is the ONLY suspension point in this method, and it splits the dedup check
832
+ // (indexOfHash, above) from the leaf append (below). Across that await, two concurrent ingests of
833
+ // the SAME content hash — e.g. a direct retry and a park-recovery racing on reconnect — can BOTH
834
+ // pass the first dedup check before either appends, producing two leaves for one hash
835
+ // (DOD-MSG-5 break → leafIndex≠canonicalSeq → root divergence). So re-check dedup on resume.
836
+ // Everything from here to the append is synchronous (atomic under Node's single thread): the
837
+ // first to resume appends, and the second sees its leaf and dedups.
838
+ //
839
+ // Adding any further await between here and the append reopens the window.
840
+ // DOD-FRONTIER-STRAND-1 AC1: this re-check must use the SAME discriminator as the first one.
841
+ // Left keyed on the content hash it silently re-created the whole defect one branch later --
842
+ // the pre-screen check would correctly let a second identical-but-distinct message through, and
843
+ // then this one would drop it anyway. The race it exists to close is unaffected: two concurrent
844
+ // ingests of a true redelivery share a position, so the second still sees the first's leaf.
845
+ const treeAfterScreen = this.#ctx.getSessionTree(agentName, sessionId);
846
+ const dedupAfterScreen = canonicalSeqIn !== undefined && canonicalSeqIn >= 0
847
+ ? (treeAfterScreen.hashAt(canonicalSeqIn) === contentHashHex ? canonicalSeqIn : -1)
848
+ : treeAfterScreen.indexOfHash(contentHashHex);
849
+ if (dedupAfterScreen >= 0) {
850
+ this.#ctx.logger.info("session.content.deduplicated", {
851
+ sessionId,
852
+ contentHashHex,
853
+ sequenceNumber: dedupAfterScreen,
854
+ witnessed: canonicalSeqIn !== undefined,
855
+ phase: "post_screen",
856
+ correlationId,
857
+ });
858
+ return { ok: true, leafIndex: dedupAfterScreen, sequenceNumber: dedupAfterScreen, appendedCount: 0, ...(terminalBlock ? { screenedOut: true } : {}) };
859
+ }
860
+ // M8C-ABUSE-1 (cello-unit-reviewer HIGH fix, post-M9INT-1 merge): re-check the size cap here,
861
+ // in the SAME synchronous window as the dedup re-check above. The original check (before the
862
+ // screenInbound await) used totals that can go stale: two concurrent ingests for the same
863
+ // non-contact session — e.g. a live direct arrival racing a recoverParkedFromRelay pull —
864
+ // could each independently pass the pre-await check using the SAME stale totals, then both
865
+ // append/hold, jointly exceeding the cap. Symmetric to the dedup fix: everything from here to
866
+ // the append/hold branch is synchronous, so whichever call resumes first appends/holds before
867
+ // the second's re-check runs, and the second's freshly-recomputed totals correctly include the
868
+ // first's contribution.
869
+ {
870
+ // DOD-TIER-2 AC2 (re-check): the SAME tier cap as the primary gate above, recomputed in this
871
+ // synchronous window (the totals can go stale across the screenInbound await). Applied to EVERY
872
+ // sender — a contact is no longer exempt (INV-TIER-BOUND). Must mirror the primary gate exactly
873
+ // so a sender can never pass one and fail the other.
874
+ const senderTier = this.#ctx.records.getTier(agentName, senderPubkey);
875
+ const cap = this.#ctx.records.resolveTierBound(agentName, senderTier, "max_bytes");
876
+ const priorTotal = this.#ctx.queries.getReceivedBytesTotal(agentName, sessionId);
877
+ const heldTotal = this.#ctx.held.getHeldBytesTotal(agentName, sessionId);
878
+ if (priorTotal + heldTotal + content.length > cap) {
879
+ this.#ctx.logger.warn("session.content.abuse_bound.session_size_exceeded", {
880
+ sessionId,
881
+ agentName,
882
+ senderPubkey,
883
+ priorTotal,
884
+ heldTotal,
885
+ incoming: content.length,
886
+ cap,
887
+ tier: senderTier,
888
+ correlationId,
889
+ recheck: true,
890
+ });
891
+ this.#ctx.notices.noteSizeCapRefusal(agentName, sessionId, cap, senderTier);
892
+ return { ok: false, reason: "session_size_limit_exceeded" };
893
+ }
894
+ }
895
+ // DOD-MSG-4 (strict in-order gate): the RELAY is the ordering authority. If B holds the
896
+ // canonical sequence for this hash (witnessed via leaf_deliver) and it is AHEAD of the next
897
+ // expected leaf, HOLD the content rather than append it out of order. The missing in-between
898
+ // sequence(s) are recovered from the relay mailbox; #releaseHeld then drains the held entries
899
+ // in canonical order. This keeps the daemon-owned leaf index === the canonical sequence by
900
+ // construction, so two parties' roots match even when direct delivery and park-recovery
901
+ // interleave. With NO witness for this hash (relay-degraded) B falls back to arrival-order
902
+ // append — the pre-MSG-4 behavior (no ordering signal available).
903
+ const key = this.#ctx.sessionKey(agentName, sessionId);
904
+ // Prefer the position the CALLER verified for this specific message over the hash-keyed map.
905
+ // The map cannot distinguish two identical messages (AC1) -- it holds one entry per hash, so the
906
+ // second firing overwrites the first's position. The explicit value is per-message and correct;
907
+ // the map remains the fallback for paths that have no ordering record.
908
+ const canonicalSeq = canonicalSeqIn !== undefined && canonicalSeqIn >= 0
909
+ ? canonicalSeqIn
910
+ : this.#ctx.witnessedSeq.get(key)?.get(contentHashHex);
911
+ const nextExpected = this.#ctx.getSessionTree(agentName, sessionId).size();
912
+ if (canonicalSeq !== undefined && canonicalSeq > nextExpected) {
913
+ this.#ctx.held.ensureHeldRestored(agentName, sessionId);
914
+ let held = this.#ctx.heldContent.get(key);
915
+ if (!held) {
916
+ held = new Map();
917
+ this.#ctx.heldContent.set(key, held);
918
+ }
919
+ // A terminal block out of canonical order is held WITHOUT delivery (screenedOut): #releaseHeld
920
+ // leafs it at its canonical index when the gap fills, but never buffers it for the agent. This
921
+ // keeps leafIndex === canonicalSeq for screened-out content too (code-review HIGH-1).
922
+ // THE PEER'S RAW BYTES RIDE ALONG. Classification (document frame vs conversation) reads
923
+ // byte 0, and `deliverContent` is the SCREENED copy — for a CBOR frame that is no longer a
924
+ // map header, so a held document frame was released into the CONVERSATION path: transcript,
925
+ // doorbell, and `cello_receive` handing an agent raw CBOR as though a person typed it.
926
+ // The in-order path has always passed these bytes; only the held path dropped them.
927
+ held.set(canonicalSeq, { content: deliverContent, originalContent: content, contentHashHex, correlationId, ...(terminalBlock ? { screenedOut: true } : {}) });
928
+ // DOD-M12B-STRAND-1: and to disk, before we answer. The in-memory Map is the working copy;
929
+ // this row is the one that survives the teardown that used to destroy it.
930
+ this.#ctx.queries.persistHeldContent(agentName, sessionId, canonicalSeq, deliverContent, content, contentHashHex, terminalBlock === true, correlationId);
931
+ // DOD-M15-REFUSEDEVIDENCE-1 (site 1 of 2 for a terminal block): a block held behind an
932
+ // ordering gap. `#releaseHeld` appends its leaf later WITHOUT re-entering this method, so
933
+ // retaining at release is not available — it is retained here, at the position the leaf will
934
+ // take. `held_content` is not a substitute: that row is deleted the moment the gap fills.
935
+ if (terminalBlock) {
936
+ const keptHeld = this.#ctx.refusals.quarantineRefusedContent(agentName, sessionId, inboundVerdict.reason ?? "inbound_screen_blocked", content, contentHashHex, {
937
+ senderPubkeyHex: senderPubkey, canonicalSeq,
938
+ ...(verifiedAuthorship ? { authorship: verifiedAuthorship } : {}),
939
+ correlationId,
940
+ });
941
+ noteTerminalBlock?.(keptHeld);
942
+ }
943
+ this.#ctx.logger.info("session.content.held", {
944
+ sessionId,
945
+ canonicalSeq,
946
+ nextExpected,
947
+ gap: canonicalSeq - nextExpected,
948
+ screenedOut: terminalBlock,
949
+ correlationId,
950
+ });
951
+ // Held content is NOT yet a durable leaf, so it is deliberately NOT acknowledged `persisted`
952
+ // (the caller checks `held`). The sender's TTF→park backstop and the recover/dedup path
953
+ // guarantee eventual delivery; B never claims persisted for content it only holds in memory.
954
+ return { ok: true, leafIndex: canonicalSeq, sequenceNumber: canonicalSeq, held: true, ...(terminalBlock ? { screenedOut: true } : {}) };
955
+ }
956
+ if (canonicalSeq !== undefined && canonicalSeq < nextExpected) {
957
+ // Contradiction (review finding #2): the witness says this hash belongs BEHIND the current
958
+ // tree, yet the dedup scan above found no existing leaf for it — so it is neither a duplicate
959
+ // nor in canonical order. This is only reachable via the accepted content-before-witness /
960
+ // relay-degraded interleaving (the next sub-increment's pending-witness buffer closes it). Log
961
+ // it loudly (the leaf-index===sequence invariant is at risk) and append rather than DROP the
962
+ // message — losing content is worse than a transient mis-order the seal cross-check will catch.
963
+ this.#ctx.logger.warn("session.content.sequence_behind_tree", {
964
+ sessionId,
965
+ canonicalSeq,
966
+ nextExpected,
967
+ correlationId,
968
+ });
969
+ }
970
+ // In-order append. A terminal block leafs the ORIGINAL content hash WITHOUT buffering it for the
971
+ // agent (screenedOut); a delivered message buffers + leafs via #appendVerifiedContent.
972
+ const leafIndex = terminalBlock
973
+ ? this.#ctx.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, correlationId).leafIndex
974
+ : this.appendVerifiedContent(agentName, sessionId, deliverContent, contentHashHex, senderPubkey, correlationId, content, verifiedAuthorship).leafIndex;
975
+ /**
976
+ * DOD-M15-REFUSEDEVIDENCE-1 (site 2 of 2) — **the moment the product catches the attack it
977
+ * exists to catch, and until now it kept only the hash.**
978
+ *
979
+ * The terminal-block branch above takes `appendSessionLeaf`, not `#appendVerifiedContent`, so
980
+ * the row carrying the plaintext, the sender's key and the sender's signature was never written.
981
+ * A hash proves a message you still hold has not changed; it proves nothing about one you threw
982
+ * away — and this is precisely the message an operator would most want to produce.
983
+ *
984
+ * At `leafIndex`, so the leaf and the evidence describe one event and DoD 7's leaf placement is
985
+ * untouched. The ORIGINAL bytes, never the sanitized `deliverContent`: evidence is what they
986
+ * sent, not what a filter made of it.
987
+ */
988
+ if (terminalBlock) {
989
+ const keptBlocked = this.#ctx.refusals.quarantineRefusedContent(agentName, sessionId, inboundVerdict.reason ?? "inbound_screen_blocked", content, contentHashHex, {
990
+ senderPubkeyHex: senderPubkey, canonicalSeq: leafIndex,
991
+ ...(verifiedAuthorship ? { authorship: verifiedAuthorship } : {}),
992
+ correlationId,
993
+ });
994
+ noteTerminalBlock?.(keptBlocked);
995
+ /**
996
+ * ⚠️ **DROP THE WITNESS — A BLOCKED MESSAGE MADE THE SESSION PERMANENTLY UNSEALABLE.**
997
+ *
998
+ * THE THIRD INSTANCE of the shape already fixed for document frames at `:10593`, found by the
999
+ * first journey that ever sealed a session after a screener block.
1000
+ *
1001
+ * `sealReadiness` derives `missingLeaves` from `#witnessedSeq.size` — every position the
1002
+ * ordering authority committed that this tree has not appended. The entry is dropped where the
1003
+ * leaf is credited, and that drop lives inside `#appendVerifiedContent`. A terminal block does
1004
+ * not go through it: the branch above takes `appendSessionLeaf` directly, so the leaf WAS
1005
+ * committed and the witness was never retired.
1006
+ *
1007
+ * **From the operator's chair:** their screener catches one hostile message, and from that
1008
+ * moment `cello_close_session` answers `session_incomplete` forever — *"waiting on an earlier
1009
+ * message from the counterparty that has not arrived"* — about a message that arrived, was
1010
+ * judged, and is sitting in the chain. The only exit is a force-abandon, which forfeits the
1011
+ * notarized receipt. Measured live: `treeSize 3, highWaterSeq 2, missingLeaves 1`.
1012
+ *
1013
+ * Not introduced by `DOD-M15-REFUSEDEVIDENCE-1` — it is older than this unit and simply had no
1014
+ * test that both blocked a message and then sealed. It is fixed here because this unit's own
1015
+ * DoD requires that session to seal.
1016
+ */
1017
+ this.#ctx.witnessedSeq.get(key)?.delete(contentHashHex);
1018
+ }
1019
+ // DOD-COATTEND-1 (review F2): the plaintext failed to reach the transcript, and since Tier 1 the
1020
+ // transcript IS the delivery path — so this message can never be handed to any session. Report
1021
+ // the ingest as failed. Reporting `ok: true` here is what let a local SQLCipher failure surface,
1022
+ // 30 seconds later and one subsystem away, as "no content arrived — keep waiting": the operator
1023
+ // is sent to debug a counterparty who did nothing wrong.
1024
+ //
1025
+ // The leaf STAYS. It is genuinely committed to the hash chain, and unwinding a committed leaf to
1026
+ // tidy up a reporting problem would corrupt the frontier the counterparty already co-signs
1027
+ // against. The hole is now crossable by delivery (F1), so it costs a gap, not a stall.
1028
+ if (!terminalBlock && this.getUndeliverableSeqs(agentName, sessionId).includes(leafIndex)) {
1029
+ /**
1030
+ * DOD-M15-REFUSEDEVIDENCE-1 — **THIS PATH CANNOT RETAIN, because the storage layer is what
1031
+ * just failed.** The write that would keep the evidence is the same `INSERT` into the same
1032
+ * table that has already thrown for this message. Attempting it produces a second error line
1033
+ * and no evidence. Named here rather than left to be rediscovered as a missing case.
1034
+ */
1035
+ // DOD-M15-NO-SILENT-REFUSAL-1. `#appendVerifiedContent` already noted `content_undeliverable`
1036
+ // at the point the write failed; this is the INGEST's own refusal, and it is a different fact
1037
+ // — the sender is told the ingest failed, so it will redeliver, and every redelivery of the
1038
+ // same hash now dedups against a leaf whose plaintext is not there. Two reasons, because a
1039
+ // reader fixing the disk fault needs to know both that the text is gone and that the sender
1040
+ // is retrying into a hole.
1041
+ this.#ctx.notices.noteContentRefusal(agentName, sessionId, "transcript_write_failed", {
1042
+ kind: REFUSAL_KINDS.LOST,
1043
+ impact: "A message reached this agent, was verified, and was committed to the conversation's record — and then its text could not be written to local storage, so it can never be delivered. There is a permanent gap in your copy of this conversation. This is a fault on THIS machine; the counterparty did nothing wrong and cannot fix it.",
1044
+ /**
1045
+ * ⚠️ THE READER IS USUALLY ALREADY IN A CODING AGENT, so the guidance says GO AND LOOK
1046
+ * rather than listing symptoms. Andre, 2026-09-03: *"The message should mention to try and
1047
+ * figure out why you cannot store it — it is likely a local machine problem. But if you
1048
+ * truly cannot figure this out using a coding agent, then we advise reaching out to
1049
+ * CELLO_Support."*
1050
+ *
1051
+ * That ordering matters: this is a machine fault with an ordinary cause, and an operator
1052
+ * sent straight to support for a full disk has been wasted. Support is the exit, not the
1053
+ * first step.
1054
+ */
1055
+ guidance: "Find out why the write failed — this is almost always something ordinary on this machine. " +
1056
+ "If you are reading this through a coding agent, have it check: free disk space, the permissions on ~/.cello, whether the database file is readable and writable, and transcript.message.record.failed in the daemon log, which carries the underlying error. " +
1057
+ "Waiting cannot recover the message. Once the fault is fixed, ask them to resend — the text is gone and only its hash remains. " +
1058
+ "If you genuinely cannot work out the cause, reach out to CELLO_Support.",
1059
+ });
1060
+ return { ok: false, reason: "transcript_write_failed" };
1061
+ }
1062
+ // NO relay witness for this hash. We appended it anyway — refusing would make the relay a hard
1063
+ // precondition for reading mail, so a relay outage would render the inbox unreadable, and the
1064
+ // direct path and park backstop exist precisely to survive that. But this append is a WEAKER
1065
+ // guarantee and must not masquerade as the stronger one: with a witness, the received content is
1066
+ // checked against a hash the sender committed to a third party; without one, the only available
1067
+ // hash rode in the same frame as the content, so the check is the sender's claim against the
1068
+ // sender's own claim. Say so. A sender who simply never submits to the relay is otherwise
1069
+ // indistinguishable from one the relay merely has not witnessed YET.
1070
+ // The relay witness is an INDEPENDENT attestation: a (content_hash → sequence) binding derived
1071
+ // from the sender's own signed leaf. Holding one, we check received content against a hash the
1072
+ // sender committed to a THIRD PARTY. Holding none, the only hash available rode in the same frame
1073
+ // as the content — the sender's claim checked against the sender's claim.
1074
+ //
1075
+ // Unwitnessed content is still ingested. Refusing it would make the relay a precondition for
1076
+ // READING mail, so a relay outage would render the inbox unreadable — the redundancy the direct
1077
+ // path and the park backstop exist to provide.
1078
+ //
1079
+ // Warn ONLY when a witness was EXPECTED. A session with no relay attached has no witness BY
1080
+ // DESIGN, and warning on every message there would bury the one case that means something —
1081
+ // a relay IS attached, so the sender's leaf should have been submitted and witnessed, and it
1082
+ // was not. A signal that fires on the normal case is not a signal.
1083
+ if (canonicalSeq === undefined && this.#ctx.activeNodes.get(key)?.relayClient) {
1084
+ this.#ctx.logger.warn("session.content.unwitnessed", {
1085
+ agentName,
1086
+ sessionId,
1087
+ leafIndex,
1088
+ contentHash: contentHashHex,
1089
+ correlationId,
1090
+ guidance: "a relay is attached to this session but no witness bound this content hash — it was ingested with no independent commitment from the sender",
1091
+ });
1092
+ }
1093
+ // A just-appended leaf may unblock held out-of-order arrivals whose turn is now next.
1094
+ // appendedCount = this leaf + any held leaves released by it, so a caller (recover) can tally the
1095
+ // leaves ACTUALLY written, not just the directly-ingested one (review #3).
1096
+ const released = this.#ctx.held.releaseHeld(agentName, sessionId, senderPubkey);
1097
+ return { ok: true, leafIndex, sequenceNumber: leafIndex, appendedCount: 1 + released, ...(terminalBlock ? { screenedOut: true } : {}) };
1098
+ }
1099
+ /**
1100
+ * DOD-MSG-4: record the relay-witnessed canonical sequence for a content hash. The relay is the
1101
+ * ordering authority (Structure 2): it assigns each message a sequence from its hash and delivers
1102
+ * B the (content_hash -> sequence) binding via leaf_deliver. The strict-in-order gate orders the
1103
+ * transcript by THIS — never a sender-stamped field. Also advances the per-session high-water mark
1104
+ * (the largest witnessed sequence) reserved for the future catch-up-before-live increment. Idempotent.
1105
+ */
1106
+ recordWitnessedSequence(agentName, sessionId, contentHashHex, sequenceNumber) {
1107
+ if (sequenceNumber < 0)
1108
+ return;
1109
+ const key = this.#ctx.sessionKey(agentName, sessionId);
1110
+ // DOD-M12B-SEAL-STUCK-1: this process has now seen this session's ordering state, so an empty
1111
+ // witness map for it means "no gap" rather than "never looked".
1112
+ this.#ctx.orderingObserved.add(key);
1113
+ let map = this.#ctx.witnessedSeq.get(key);
1114
+ if (!map) {
1115
+ map = new Map();
1116
+ this.#ctx.witnessedSeq.set(key, map);
1117
+ }
1118
+ map.set(contentHashHex, sequenceNumber);
1119
+ const hw = this.#ctx.highWaterSeq.get(key) ?? -1;
1120
+ if (sequenceNumber > hw)
1121
+ this.#ctx.highWaterSeq.set(key, sequenceNumber);
1122
+ /**
1123
+ * DOD-M12B-LEAF-TRIGGERS-FETCH-1 — A LEAF WE CANNOT READ IS A FETCH ORDER.
1124
+ *
1125
+ * MEASURED LIVE 2026-08-18: the relay delivered this leaf one second after the counterparty
1126
+ * sent. We had the hash and the sequence, the bytes were parked at that same relay, and the
1127
+ * plaintext arrived 102 seconds later on a background sweep. Nothing connected the two facts —
1128
+ * this method recorded the sequence and stopped.
1129
+ *
1130
+ * The witness leaf and the plaintext are separate deliveries: the leaf comes over the relay, the
1131
+ * bytes over the direct content stream. After an interruption the two session nodes have no
1132
+ * direct connection, so the bytes go to the park instead and only a timer ever finds them.
1133
+ *
1134
+ * The grace window is what keeps this off the hot path. On a healthy session the direct content
1135
+ * lands within milliseconds of its leaf, so fetching immediately would mean a relay round trip
1136
+ * for every message in every session. We give the direct path its two seconds first.
1137
+ */
1138
+ this.#scheduleLeafFetchIfUnresolved(agentName, sessionId, contentHashHex);
1139
+ }
1140
+ /**
1141
+ * Cancel a pending leaf fetch for one piece of content. The TIMERS stay the manager's — it has
1142
+ * three users and only one of them moved — so `#markContentTerminallyRefused`, now in
1143
+ * `inbound-refusals.ts`, asks for the cancellation instead of reaching into the map.
1144
+ */
1145
+ cancelLeafFetch(key, contentHashHex) {
1146
+ const timerKey = `${key}::${contentHashHex}`;
1147
+ const t = this.#ctx.leafFetchTimers.get(timerKey);
1148
+ if (t !== undefined) {
1149
+ clearTimeout(t);
1150
+ this.#ctx.leafFetchTimers.delete(timerKey);
1151
+ }
1152
+ }
1153
+ /** DOD-M12B-LEAF-TRIGGERS-FETCH-1: this content is here — no fetch is owed for it, and any
1154
+ * pending one is cancelled. Called wherever content actually lands. */
1155
+ markContentResolved(agentName, sessionId, contentHashHex) {
1156
+ const key = this.#ctx.sessionKey(agentName, sessionId);
1157
+ let set = this.#ctx.resolvedContent.get(key);
1158
+ if (!set) {
1159
+ set = new Set();
1160
+ this.#ctx.resolvedContent.set(key, set);
1161
+ }
1162
+ set.add(contentHashHex);
1163
+ const timerKey = `${key}::${contentHashHex}`;
1164
+ const t = this.#ctx.leafFetchTimers.get(timerKey);
1165
+ if (t !== undefined) {
1166
+ clearTimeout(t);
1167
+ this.#ctx.leafFetchTimers.delete(timerKey);
1168
+ }
1169
+ }
1170
+ #scheduleLeafFetchIfUnresolved(agentName, sessionId, contentHashHex) {
1171
+ const key = this.#ctx.sessionKey(agentName, sessionId);
1172
+ if (this.#ctx.resolvedContent.get(key)?.has(contentHashHex))
1173
+ return;
1174
+ // DOD-M15-REFUSALTERMINAL-1: a refusal nothing can get past is the end of the work, not a
1175
+ // reason to come back in two seconds.
1176
+ if (this.#ctx.refusals.isTerminallyRefused(agentName, sessionId, contentHashHex))
1177
+ return;
1178
+ const timerKey = `${key}::${contentHashHex}`;
1179
+ // ONE fetch per content hash. The relay redelivers, and a redelivery carries the same sequence —
1180
+ // scheduling per redelivery turns a slow relay into a storm against itself.
1181
+ if (this.#ctx.leafFetchTimers.has(timerKey))
1182
+ return;
1183
+ const timer = setTimeout(() => {
1184
+ this.#ctx.leafFetchTimers.delete(timerKey);
1185
+ if (this.#ctx.resolvedContent.get(key)?.has(contentHashHex))
1186
+ return; // the direct path won
1187
+ if (this.#ctx.shuttingDown)
1188
+ return;
1189
+ this.#ctx.logger.info("session.content.leaf_unresolved.fetch", {
1190
+ agentName,
1191
+ sessionId,
1192
+ contentHash: contentHashHex,
1193
+ graceMs: this.#ctx.leafFetchGraceMs,
1194
+ impact: "the relay told us this message exists and its plaintext never arrived directly — "
1195
+ + "fetching it now instead of waiting for the periodic sweep",
1196
+ });
1197
+ this.#ctx.park.fireParkedDrain(agentName, "witnessed_leaf_unresolved");
1198
+ }, this.#ctx.leafFetchGraceMs);
1199
+ timer.unref?.();
1200
+ this.#ctx.leafFetchTimers.set(timerKey, timer);
1201
+ }
1202
+ /**
1203
+ * DOD-MSG-4: the relay's high-water canonical sequence for this session (largest witnessed leaf),
1204
+ * or -1 if none. The relay is the ordering authority, so this is the outside view of how far the
1205
+ * session has actually progressed — which is why it is the right input to a catch-up-before-live
1206
+ * gate. Consumed by `sealReadiness` (M12-P14) for REPORTING only: the missing-leaf decision is made
1207
+ * from `#witnessedSeq`, because this counts the relay's sequence space (which includes ctrl leaves)
1208
+ * and the tree does not. Maintained by `recordWitnessedSequence`.
1209
+ */
1210
+ /**
1211
+ * DOD-COATTEND-1 (review F2): leaf sequences whose plaintext failed to reach the transcript and
1212
+ * are therefore undeliverable. Empty is the overwhelmingly normal case.
1213
+ */
1214
+ getUndeliverableSeqs(agentName, sessionId) {
1215
+ return [...(this.#ctx.undeliverableSeqs.get(this.#ctx.sessionKey(agentName, sessionId)) ?? [])];
1216
+ }
1217
+ /** DOD-MSG-4 / DAEMON-004: append a verified message leaf and buffer it for cello_receive. */
1218
+ appendVerifiedContent(agentName, sessionId, content, contentHashHex, senderPubkey, correlationId,
1219
+ /**
1220
+ * The bytes as the PEER SENT THEM, before inbound sanitization — for the document classifier
1221
+ * only. Defaults to `content` for callers that never screened (the held-release path).
1222
+ *
1223
+ * A `redact` verdict rewrites `content` for the agent's benefit, and that is right for
1224
+ * conversation: the operator sees the sanitized form while the leaf still binds the original.
1225
+ * It is WRONG for a document frame, and not marginally. Rewriting bytes inside a signed CBOR
1226
+ * envelope does not sanitize it — it destroys it. The frame stops decoding, stops being
1227
+ * recognised as document traffic at all, and falls through to the conversation path, where it
1228
+ * is recorded as something a person said and handed to the agent by `cello_receive`.
1229
+ *
1230
+ * Measured live: roughly half of proposals vanished this way. Intermittent because a proposal
1231
+ * carries a random 16-byte nonce, so whether its bytes trip a sanitizer rule varies per run —
1232
+ * which is why it read as flakiness rather than as a rule firing.
1233
+ *
1234
+ * Documents are NOT unscreened as a result. They are screened by `DocumentGate`, which is built
1235
+ * for them and REFUSES rather than mutates (§16.7) — because mutating one party's replica of a
1236
+ * CRDT is not a false positive, it is permanent divergence that both sides converge on and
1237
+ * neither can see.
1238
+ */
1239
+ originalContent,
1240
+ /**
1241
+ * DOD-M15-SEALWIRE-1 bullet 5: threaded from `ingestReceivedContent`, which is the only place
1242
+ * that has it — `#verifyAuthorshipClaim` verified this signature (carried on the frame beside
1243
+ * the bytes it signs) against the pubkey inside those bytes, and matched the signer to this
1244
+ * session's counterparty. It reaches the transcript row from here or not at all.
1245
+ *
1246
+ * ⚠️ IT USED TO NAME `#recordFrameOrdering`, true until `DOD-M15-AUTHORSHIP-ABSENT-1` moved the
1247
+ * check off the relay's record and onto the frame's own signature. Rewritten rather than
1248
+ * deleted: the old name is the evidence of what authorship used to depend on.
1249
+ *
1250
+ * Undefined on the held-release and soft-fallback paths; the row records that as
1251
+ * `local_session_state` rather than leaving it indistinguishable from a proven one.
1252
+ */
1253
+ verifiedAuthorship) {
1254
+ // M14 / DOD-DOC-INBOUND-2 — DOCUMENT FRAMES DIVERGE HERE, and the three-way split is the whole
1255
+ // contract:
1256
+ //
1257
+ // LEAF yes, and as `doc` (0x04) rather than `msg` (0x00). The seal covers document traffic
1258
+ // — that is what makes the exchange provable — but it is not conversation, and the
1259
+ // leaf kind is what a verifier renders it by.
1260
+ // TRANSCRIPT no. Recording CRDT bytes as a received message puts them in the operator's
1261
+ // conversation history, where `cello_receive` hands them to an agent as something a
1262
+ // person said.
1263
+ // DOORBELL no (§11.3). A collaborator typing produces a stream of updates; a doorbell each
1264
+ // time would interrupt the operator's agent continuously for something with no
1265
+ // deadline.
1266
+ //
1267
+ // The hook is injected and absent by default, so a daemon without the document layer behaves
1268
+ // exactly as before — this cannot change the conversation path by being unwired.
1269
+ const routed = this.#ctx.onDocumentFrame?.(agentName, sessionId,
1270
+ // THE PEER'S BYTES, not the sanitized ones. See `originalContent` above.
1271
+ originalContent ?? content, senderPubkey, correlationId);
1272
+ if (routed?.consumed === true) {
1273
+ const { leafIndex } = this.#ctx.appendSessionLeaf(agentName, sessionId, "doc", contentHashHex, correlationId);
1274
+ // DROP THE WITNESS, exactly as the conversation branch does once its leaf is appended. The
1275
+ // witness has done its ordering job either way — the leaf IS committed here.
1276
+ //
1277
+ // This branch returns early and so never reached that cleanup, and every inbound document
1278
+ // frame left a permanent entry behind. Harmless until `sealReadiness` started deriving
1279
+ // `missingLeaves` from the size of that map (M12-P14): from then on a session that carried
1280
+ // ANY document traffic could never seal, because the ordering authority was recorded as
1281
+ // having committed leaves this tree had — but had not been credited with. The refusal is
1282
+ // `session_incomplete`, whose only escape is a force-abandon with no notarized receipt.
1283
+ //
1284
+ // Two correct changes, each fine alone, that break where they meet. Caught by running the
1285
+ // live enforcers straight after merging main rather than trusting a green unit suite.
1286
+ this.#ctx.witnessedSeq.get(this.#ctx.sessionKey(agentName, sessionId))?.delete(contentHashHex);
1287
+ /**
1288
+ * ⚠️ THIS LINE USED TO LOG `ok: routed.ok` AND `reason: routed.reason`, AND NEITHER CAN EVER
1289
+ * BE PRESENT HERE. Removed rather than left, because their absence was read as evidence.
1290
+ *
1291
+ * The producer is `DocumentFrameRouter.routeSync`, and it has four returns — `unshaped`,
1292
+ * `undecodable`, `owner_unresolved`, and the normal path — **none of which sets either
1293
+ * field.** It cannot: the normal path is `void this.#enqueue(...)`, fire-and-forget, so at the
1294
+ * instant this line is written the frame has been CLASSIFIED and QUEUED and nothing has yet
1295
+ * decided whether it will be accepted. The verdict is genuinely not knowable here.
1296
+ *
1297
+ * **What that cost:** `j-stale-session` reported `framesReceived=3 inbound=0`, and the
1298
+ * investigation recorded that `ok` and `reason` were "ABSENT from every line in the run — so
1299
+ * the router returned neither, which is itself the next thread to pull: a routing result that
1300
+ * reports no outcome cannot say whether it accepted or dropped the frame." That thread leads
1301
+ * nowhere. The router did not fail to report an outcome; **it has no outcome to report at this
1302
+ * point in the flow**, and a JSON logger omits an `undefined` field, so a structural absence
1303
+ * looked exactly like a fault. A field that can never be populated is worse than no field.
1304
+ *
1305
+ * **Where the verdict actually lands**, named here so the next reader does not have to find it
1306
+ * the hard way: a refusal is `document.frame.refused` (warn, carrying `kind` + `reason`,
1307
+ * emitted from `#enqueue`'s continuation under the same `correlationId`). Acceptance is
1308
+ * silent on this event. So "was this frame ingested?" is answered by joining on
1309
+ * `correlationId`, never by reading this line alone.
1310
+ */
1311
+ this.#ctx.logger.info("session.document.received", {
1312
+ sessionId,
1313
+ senderPubkey,
1314
+ contentHashHex,
1315
+ sequenceNumber: leafIndex,
1316
+ kind: routed.kind,
1317
+ // The verdict is asynchronous. Stated positively so absence is not mistaken for silence.
1318
+ dispatch: "queued",
1319
+ verdictEvent: "document.frame.refused",
1320
+ correlationId,
1321
+ });
1322
+ return { leafIndex };
1323
+ }
1324
+ const { leafIndex } = this.#ctx.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, correlationId);
1325
+ // DOD-LOG-1: persist the readable RECEIVED plaintext to the durable transcript, keyed by the
1326
+ // canonical leaf sequence so it joins the committed hash chain (survives restart; INV-3 — the
1327
+ // relay/directory never see this plaintext, only the hash).
1328
+ const durable = this.#ctx.records.recordTranscriptMessage(agentName, sessionId, leafIndex, "received", content, correlationId,
1329
+ // DOD-M15-SEALWIRE-1 bullet 5: present only when the ordering record verified AND the signer
1330
+ // matched this session's counterparty. Undefined on the soft fallback, which the row records
1331
+ // as `local_session_state` rather than leaving indistinguishable.
1332
+ verifiedAuthorship);
1333
+ const recvKey = this.#ctx.sessionKey(agentName, sessionId);
1334
+ if (!durable) {
1335
+ // The leaf is committed and the plaintext is not. Delivery reads the transcript, so this
1336
+ // message is now unreachable by every session — record it so the receive path can SAY that
1337
+ // rather than time out wearing the quiet-counterparty answer (review F2).
1338
+ let lost = this.#ctx.undeliverableSeqs.get(recvKey);
1339
+ if (!lost) {
1340
+ lost = new Set();
1341
+ this.#ctx.undeliverableSeqs.set(recvKey, lost);
1342
+ }
1343
+ lost.add(leafIndex);
1344
+ // DOD-M15-NO-SILENT-REFUSAL-1: noted HERE, where the write actually fails, and not on the
1345
+ // cello_receive exit that reports it. `#undeliverableSeqs` is in memory, so the receive exit
1346
+ // stops being able to say this after a restart while the transcript hole stays permanent —
1347
+ // and the exit only runs if somebody is attending, which is the case this whole line is for.
1348
+ this.#ctx.notices.noteContentRefusal(agentName, sessionId, "content_undeliverable", {
1349
+ kind: REFUSAL_KINDS.LOST,
1350
+ impact: `a message arrived and was committed to the hash chain at sequence ${leafIndex}, and then its text could not be written to the local transcript. Delivery reads the transcript, so that message can never be handed to any session — it is a permanent hole in this side's copy of the conversation.`,
1351
+ guidance: "This is a fault on THIS machine; the counterparty did nothing wrong. Find out why the write failed — it is almost always something ordinary. " +
1352
+ "If you are reading this through a coding agent, have it check free disk space, the permissions on ~/.cello, and transcript.message.record.failed in the daemon log, which carries the underlying error. " +
1353
+ "Waiting cannot recover it. Once the fault is fixed, ask them to resend. If you genuinely cannot work out the cause, reach out to CELLO_Support.",
1354
+ });
1355
+ }
1356
+ // Review finding #6: the witness for this hash has done its ordering job once the leaf is
1357
+ // appended — drop it so #witnessedSeq stays proportional to held/pending content, not the whole
1358
+ // transcript. A later replay of the same hash is still caught by the dedup leaf-scan, which is
1359
+ // independent of the witness map.
1360
+ this.#ctx.witnessedSeq.get(recvKey)?.delete(contentHashHex);
1361
+ // DOD-M12B-LEAF-TRIGGERS-FETCH-1: the bytes are here, so cancel any fetch the witness leaf
1362
+ // scheduled. On a healthy session this is the branch that runs — the direct path beats the
1363
+ // grace window and the relay is never asked, which is what keeps a fetch off the hot path of
1364
+ // every message.
1365
+ this.markContentResolved(agentName, sessionId, contentHashHex);
1366
+ let buf = this.#ctx.receivedContent.get(recvKey);
1367
+ if (!buf) {
1368
+ buf = [];
1369
+ this.#ctx.receivedContent.set(recvKey, buf);
1370
+ }
1371
+ buf.push({ contentHex: Buffer.from(content).toString("hex"), senderPubkey, sequenceNumber: leafIndex });
1372
+ // DOD-COATTEND-1: BOUNDED, because delivery no longer drains this. Its remaining job is
1373
+ // `peekLatestReceivedContentHex` (M8C-AWAY-1 reads the TAIL to spot a [[WRAP]]), so only the
1374
+ // recent tail is load-bearing — but an unbounded array holding every message of every live
1375
+ // session, in memory, for the life of the daemon, is a leak the old destructive read hid.
1376
+ if (buf.length > RECEIVED_BUFFER_CAP)
1377
+ buf.splice(0, buf.length - RECEIVED_BUFFER_CAP);
1378
+ this.#ctx.logger.info("session.content.received", {
1379
+ sessionId,
1380
+ senderPubkey,
1381
+ contentHashHex,
1382
+ sequenceNumber: leafIndex,
1383
+ correlationId,
1384
+ });
1385
+ // M8C-MSGWAKE-1: content is now buffered and drainable — fire the doorbell AFTER the push so a
1386
+ // woken cello_receive finds the message. Content-free (agent/session/senderPubkey only). Never
1387
+ // let a listener error escape the content path.
1388
+ try {
1389
+ this.#ctx.onContentArrived?.(agentName, sessionId, senderPubkey);
1390
+ }
1391
+ catch (err) {
1392
+ this.#ctx.logger.warn("notification.cello_message.dispatch.failed", {
1393
+ sessionId, agentName, reason: err instanceof Error ? err.message : String(err),
1394
+ });
1395
+ }
1396
+ return { leafIndex };
1397
+ }
1398
+ /** DAEMON-004: pop the oldest verified received content for cello_receive. */
1399
+ takeReceivedContent(agentName, sessionId) {
1400
+ const buf = this.#ctx.receivedContent.get(this.#ctx.sessionKey(agentName, sessionId));
1401
+ if (!buf || buf.length === 0)
1402
+ return null;
1403
+ return buf.shift() ?? null;
1404
+ }
1405
+ /**
1406
+ * Send an unsigned `persisted` delivery ACK back to the sender over the same
1407
+ * /cello/content/1.0.0 protocol (AC-001). Best-effort: authentication is the Noise
1408
+ * session channel, so the ACK carries no signature; a failed ACK send is logged and
1409
+ * the sender recovers via its TTF/recovery path rather than a thrown error here.
1410
+ */
1411
+ async #sendDeliveryAck(agentName, sessionId, contentHash, correlationId) {
1412
+ const entry = this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId));
1413
+ if (!entry) {
1414
+ // NOT a silent return. No ACK is exactly this milestone's symptom — the sender's TTF expires
1415
+ // and the message parks — so the one case where we knowingly decline to send one has to say
1416
+ // so, or it is indistinguishable from the defect.
1417
+ this.#ctx.logger.debug("content.delivery.ack.skipped", {
1418
+ agentName,
1419
+ sessionId,
1420
+ contentHash: Buffer.from(contentHash).toString("hex"),
1421
+ reason: "session_node_gone",
1422
+ correlationId,
1423
+ });
1424
+ return;
1425
+ }
1426
+ // Held outside the try so the catch can retire a stream that was opened and then failed to
1427
+ // write. Without it every failure leaks the OUTBOUND half of the stream the receiver-side
1428
+ // `finally` retires — same defect, other end, other cap. See the note on #handleContentStream.
1429
+ // Assigned IMMEDIATELY after newStream: anything between the two is a window where a throw
1430
+ // leaks the stream because the catch cannot see it.
1431
+ let ackStream;
1432
+ try {
1433
+ const stream = await entry.node.newStream(entry.counterpartySessionPeerId, CELLO_CONTENT_PROTOCOL_ID);
1434
+ ackStream = stream;
1435
+ // Injected ACK-write failure — thrown from inside the try so it lands in exactly the catch a
1436
+ // real reset lands in, and the whole downstream path (impair → abort → log) runs unmodified.
1437
+ if (this.#ctx.ackFaultRemaining > 0) {
1438
+ this.#ctx.ackFaultRemaining -= 1;
1439
+ this.#ctx.logger.warn("content.delivery.ack.fault.injected", { sessionId });
1440
+ throw new Error("connection_lost: injected delivery-ack fault");
1441
+ }
1442
+ const frame = encodeCbor({
1443
+ type: "content_delivery_ack",
1444
+ session_id: sessionId,
1445
+ content_hash: contentHash,
1446
+ level: "persisted",
1447
+ correlation_id: correlationId,
1448
+ });
1449
+ stream.send(lp.encode.single(frame));
1450
+ // NOT SWALLOWED, for the same reason the direct-send path stopped swallowing it: `close()`
1451
+ // waits for the write buffer to drain, so a reset mid-flush throws HERE and that is exactly
1452
+ // the case where the bytes never left. A swallowed close made two things happen at once —
1453
+ // this log claimed the ACK went out while the sender's TTF fired and parked, and the abort in
1454
+ // the catch below (the thing that frees the stream slot) became unreachable.
1455
+ await stream.close();
1456
+ // AFTER the close, because that is when it is true. The receiver-side counterpart to the
1457
+ // sender's content.delivery.acked: B has acknowledged this content `persisted`, so the sender
1458
+ // stops retrying/parking. Emitted for BOTH a normally delivered message AND a terminal-screen
1459
+ // block (the block is a definitive receipt — the leaf is recorded, so the sender must stop) —
1460
+ // and deliberately NOT for a transient hold.
1461
+ this.#ctx.logger.info("content.delivery.ack.sent", {
1462
+ sessionId,
1463
+ contentHash: Buffer.from(contentHash).toString("hex"),
1464
+ correlationId,
1465
+ });
1466
+ // An agent that mostly LISTENS sends content rarely and ACKs constantly. Clearing only on the
1467
+ // content path would leave exactly those sessions reporting a broken conversation forever
1468
+ // after one bad ACK — the one-way door, on the other send path.
1469
+ this.#ctx.liveness.clearSessionImpairment(agentName, sessionId, "delivery_ack", correlationId);
1470
+ }
1471
+ catch (err) {
1472
+ this.#ctx.logger.warn("content.delivery.ack.send.failed", {
1473
+ sessionId,
1474
+ contentHash: Buffer.from(contentHash).toString("hex"),
1475
+ error: err instanceof Error ? err.message : String(err),
1476
+ // "Cannot write to a stream that is closed" names where the write died, never why. The
1477
+ // why is almost always the per-protocol stream cap, and these two numbers are what turn
1478
+ // that from a log-measurement session into a grep.
1479
+ ...this.#ctx.streamCensus(entry.node, entry.counterpartySessionPeerId),
1480
+ correlationId,
1481
+ });
1482
+ // The ACK travels the same direct path as our own content, so a failure here is the same
1483
+ // evidence: writes to this counterparty are not landing.
1484
+ this.#ctx.liveness.markSessionImpaired(agentName, sessionId, { cause: "delivery_ack", error: err instanceof Error ? err.message : String(err), correlationId });
1485
+ if (ackStream !== undefined) {
1486
+ try {
1487
+ ackStream.abort(err instanceof Error ? err : new Error(String(err)));
1488
+ }
1489
+ catch { /* already gone */ }
1490
+ }
1491
+ }
1492
+ }
1493
+ /**
1494
+ * DAEMON-004: register the /cello/content/1.0.0 handler on a session node so
1495
+ * inbound content_frames are decoded, cross-checked, and ingested.
1496
+ */
1497
+ // Awaited by createSessionNode / acceptSession so the /cello/content/1.0.0 handler
1498
+ // is provably registered before the caller returns (and thus before any peer sends
1499
+ // content). libp2p registers the protocol synchronously today, but awaiting removes
1500
+ // the fragile dependency on that internal timing (review L4).
1501
+ async registerContentHandler(agentName, sessionId, node, _counterpartyPubkey) {
1502
+ try {
1503
+ await node.handle(CELLO_CONTENT_PROTOCOL_ID, (stream, remotePeerId) => {
1504
+ // `.catch` is not decoration: the handler builds its length-prefixed decoder before its own
1505
+ // try, and a throw there would otherwise become an unhandled rejection that takes the
1506
+ // daemon down for one malformed inbound stream.
1507
+ void this.#handleContentStream(agentName, sessionId, stream, remotePeerId).catch((err) => {
1508
+ this.#ctx.logger.warn("session.content.stream.handler.failed", {
1509
+ sessionId,
1510
+ error: err instanceof Error ? err.message : String(err),
1511
+ });
1512
+ });
1513
+ }, { maxInboundStreams: CONTENT_MAX_INBOUND_STREAMS });
1514
+ }
1515
+ catch (err) {
1516
+ this.#ctx.logger.error("session.content.handler.register.failed", {
1517
+ sessionId,
1518
+ error: err instanceof Error ? err.message : String(err),
1519
+ });
1520
+ }
1521
+ }
1522
+ /**
1523
+ * Hand the relay a leaf this agent RECEIVED whose author never submitted it — 034-CARRYLEAF.
1524
+ *
1525
+ * **The attack this closes:** somebody sends you something, declines to have it witnessed, and
1526
+ * seals one message short. The relay's account really does end before their last message, so your
1527
+ * receipt does too — every leaf validly signed, nothing false, the last thing said simply absent.
1528
+ *
1529
+ * **Why this is admissible and not a forgery:** the bytes are theirs, the signature over them is
1530
+ * theirs, and `#verifyAuthorshipClaim` verified it against this session's counterparty before a
1531
+ * word of it was ingested. The relay verifies it again against the directory-signed assignment.
1532
+ * Nothing here is asserted by us except that we received it.
1533
+ *
1534
+ * ⚠️ BEST-EFFORT, AND ITS FAILURE IS NOT SILENT. If the relay cannot be reached, the message is
1535
+ * still delivered and read — refusing it would make the relay a precondition for reading mail,
1536
+ * which is the thing every unit on this path has been careful not to do. What is lost is only the
1537
+ * guarantee that it can enter a receipt, and that surfaces where the operator can act on it: the
1538
+ * seal's own pre-flight refuses a gapped chain by name (`seal_carry_noncontiguous`) with guidance,
1539
+ * so the consequence reaches them at the moment it matters rather than as a log line here.
1540
+ */
1541
+ witnessReceivedLeaf(agentName, sessionId, contentHash, structure1Cbor, senderSignature,
1542
+ /** The domain the AUTHOR assigned this leaf, read off their frame — never guessed (review F5). */
1543
+ leafKind, correlationId) {
1544
+ const entry = this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId));
1545
+ if (!entry?.relayClient || !entry.relaySessionIdBytes) {
1546
+ this.#ctx.logger.warn("session.content.witness_received.unavailable", {
1547
+ agentName, sessionId, correlationId,
1548
+ impact: "a message arrived that its sender never had witnessed, and this side has no relay client " +
1549
+ "for the session, so it could not be witnessed here either. It is delivered and readable; " +
1550
+ "it cannot enter a notarized receipt until some party witnesses it.",
1551
+ });
1552
+ return;
1553
+ }
1554
+ void entry.relayClient
1555
+ .witnessReceivedLeaf(entry.node, entry.relaySessionIdBytes, contentHash, leafKind, {
1556
+ structure1Cbor,
1557
+ senderSignature,
1558
+ })
1559
+ .then((res) => {
1560
+ if (res.ok) {
1561
+ this.#ctx.logger.info("session.content.witness_received", {
1562
+ agentName, sessionId, correlationId, relaySequence: res.sequence_number,
1563
+ impact: "this side witnessed a message its SENDER did not. The leaf now holds a canonical " +
1564
+ "position, so it can appear in a receipt whatever the sender does next.",
1565
+ });
1566
+ // It has a position now, so it can be acknowledged like any other received message.
1567
+ this.noteAcknowledgeable(agentName, sessionId, res.sequence_number - 1, contentHash);
1568
+ return;
1569
+ }
1570
+ /**
1571
+ * `counter_submit_duplicate` is NOT a failure and must not be logged as one: it means this
1572
+ * relay already holds the leaf, which is the outcome we wanted. It fires on the ordinary
1573
+ * race where the sender's own submit lands while ours is in flight.
1574
+ */
1575
+ if (res.reason === "counter_submit_duplicate") {
1576
+ this.#ctx.logger.info("session.content.witness_received.already_held", {
1577
+ agentName, sessionId, correlationId,
1578
+ impact: "the relay already held this leaf — its sender witnessed it after all, or in parallel with us.",
1579
+ });
1580
+ return;
1581
+ }
1582
+ this.#ctx.logger.error("session.content.witness_received.failed", {
1583
+ agentName, sessionId, correlationId, reason: res.reason,
1584
+ ...(res.detail === undefined ? {} : { detail: res.detail }),
1585
+ impact: "a message arrived that its sender never had witnessed, and this side could not witness " +
1586
+ "it either. It is delivered and readable. What is at risk is the RECEIPT: if this " +
1587
+ "message is still unwitnessed when the conversation is sealed, the seal will refuse a " +
1588
+ "gapped chain by name rather than quietly leaving it out.",
1589
+ });
1590
+ })
1591
+ .catch((err) => {
1592
+ this.#ctx.logger.error("session.content.witness_received.threw", {
1593
+ agentName, sessionId, correlationId, error: extractErrorMessage(err),
1594
+ });
1595
+ });
1596
+ }
1597
+ /**
1598
+ * Record that a message ARRIVED and was accepted at a known canonical position — 033-ACKEMIT
1599
+ * review F1.
1600
+ *
1601
+ * The one writer for both copies of the acknowledgement, so the claim this daemon signs says what
1602
+ * it actually received rather than what the relay got round to delivering back to it.
1603
+ *
1604
+ * Monotonic, and it must be: a re-delivery or a recovered park of an EARLIER message must not walk
1605
+ * the acknowledgement backwards, and must not swap the hash under an unchanged position.
1606
+ */
1607
+ noteAcknowledgeable(agentName, sessionId, canonicalSeq, contentHash) {
1608
+ // Relay sequences are 1-based; a canonical leaf index is 0-based. The claim carries the relay's
1609
+ // number, because the relay is what checks it.
1610
+ const relaySeq = canonicalSeq + 1;
1611
+ if (relaySeq < 1)
1612
+ return;
1613
+ const key = this.#ctx.sessionKey(agentName, sessionId);
1614
+ const prev = this.#ctx.lastAck.get(key);
1615
+ if (prev && relaySeq <= prev.seq)
1616
+ return;
1617
+ this.#ctx.lastAck.set(key, { seq: relaySeq, hash: Uint8Array.from(contentHash) });
1618
+ const entry = this.#ctx.activeNodes.get(key);
1619
+ const sessionIdHex = entry?.relaySessionIdBytes
1620
+ ? Buffer.from(entry.relaySessionIdBytes).toString("hex")
1621
+ : sessionId;
1622
+ entry?.relayClient?.noteReceivedLeaf(sessionIdHex, relaySeq, contentHash);
1623
+ }
1624
+ #refuseInboundContent(agentName, sessionId, reason, contentHash, detail, correlationId) {
1625
+ // The sentence about the other route is chosen HERE, from what this machine can actually do —
1626
+ // never written into a caller's literal, where it would be a promise nobody re-checked.
1627
+ const guidance = `${detail.guidance} ${this.#ctx.mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE}`;
1628
+ this.#ctx.logger.error("session.content.refused", { agentName, sessionId, correlationId, reason, ...detail, guidance });
1629
+ this.#ctx.notices.noteContentRefusal(agentName, sessionId, reason, {
1630
+ kind: REFUSAL_KINDS.REFUSED, impact: detail.impact, guidance,
1631
+ });
1632
+ /**
1633
+ * Review F4 — A PROMISE MADE HERE IS CLOSED IN `recoverParkedEntry`, not left standing.
1634
+ *
1635
+ * The guidance above tells the operator the message may arrive by the mailbox. Both sibling
1636
+ * refusals on this path already arm a memo so the recovery can say the refusal did not hold;
1637
+ * this one armed nothing, so a delivered message would have left a permanent alarm sitting in
1638
+ * `cello_check_notifications` saying it had been turned away.
1639
+ *
1640
+ * Armed AFTER the notice is filed, so the memo can never claim a refusal that did not happen.
1641
+ */
1642
+ this.#ctx.refusals.noteRefusedOnDirectPath(agentName, sessionId, contentHash);
1643
+ }
1644
+ async #handleContentStream(agentName, sessionId, stream, remotePeerId) {
1645
+ // CLOSING THIS STREAM IS WHAT KEEPS THE SESSION ALIVE PAST ITS 33RD MESSAGE.
1646
+ //
1647
+ // Every content frame and every delivery ACK opens a fresh /cello/content/1.0.0 stream on the
1648
+ // one muxed connection the session holds, and libp2p caps INBOUND streams per protocol per
1649
+ // connection. It enforces that cap AFTER multistream-select has answered, so an over-cap stream
1650
+ // negotiates fine and is reset an instant later, and the SENDER's next `stream.send(...)`
1651
+ // throws "Cannot write to a stream that is closed" — an error that names the exit point and not
1652
+ // one thing about the cause.
1653
+ //
1654
+ // A stream leaves the muxer's set only on its `close` event, and closing our write end triggers
1655
+ // that only once the peer has closed its end too. So a handler that reads its frame and returns
1656
+ // leaves the stream half-open for the life of the connection and the count only ever rises.
1657
+ // Measured on a live daemon: 115 failures over 3.5 hours, with EXACTLY 32 successful streams
1658
+ // before the first one on both affected sessions (M12B Entry 10).
1659
+ //
1660
+ // The decoder is built INSIDE the try so a malformed stream cannot throw past the close below.
1661
+ let iter;
1662
+ try {
1663
+ iter = lp.decode(stream)[Symbol.asyncIterator]();
1664
+ const result = await iter.next();
1665
+ if (result.done || result.value === undefined)
1666
+ return;
1667
+ const bytes = result.value instanceof Uint8Array ? result.value
1668
+ : Buffer.isBuffer(result.value) ? new Uint8Array(result.value)
1669
+ : result.value.slice();
1670
+ const frame = decode(bytes);
1671
+ const correlationId = typeof frame["correlation_id"] === "string" ? frame["correlation_id"] : undefined;
1672
+ const frameType = typeof frame["type"] === "string" ? frame["type"] : "(absent)";
1673
+ /**
1674
+ * DOD-M15-FRAME-1 — ONE GATE, BEFORE THE DISPATCH, FOR EVERY FRAME ON THIS PROTOCOL.
1675
+ *
1676
+ * A stranger could dial an agent's standing receiver (it admitted everyone until DOD-M15-ASSIGN-1), hold
1677
+ * the connection open through promotion — libp2p's gater runs only at connection
1678
+ * establishment, so narrowing it does not evict anyone already attached — and then speak the
1679
+ * content protocol the moment it activated. The frame was ingested, leafed, transcribed, and
1680
+ * attributed to the legitimate counterparty, because attribution is read from local session
1681
+ * state rather than from anything the frame proved.
1682
+ *
1683
+ * DELIBERATELY SHARED RATHER THAN COPIED INTO EACH BRANCH. `session_abandoned_notice` already
1684
+ * had both checks, correct and complete, twenty lines below — and the other two frame types
1685
+ * did not. Copying the pattern a third and fourth time would fix today's three and leave the
1686
+ * fifth frame type, added later by someone who did not read this comment, unguarded again.
1687
+ * Placing it above the dispatch makes the guard the DEFAULT: a new frame type is protected by
1688
+ * construction and has to opt OUT visibly rather than opt in silently.
1689
+ *
1690
+ * Verified safe for all three current types by enumeration, not assumption — `content_frame`
1691
+ * (:5169), `session_abandoned_notice` (:6663) and `content_delivery_ack` (:7439) are the only
1692
+ * senders on `CELLO_CONTENT_PROTOCOL_ID`, and all three put `session_id` in the frame.
1693
+ *
1694
+ * MISSING, MALFORMED AND MISMATCHED TAKE ONE PATH. An attacker evading a mismatch check does
1695
+ * not send a wrong value — it sends no value, and a guard that only fires on a present-and-
1696
+ * wrong field is a guard that is trivially skipped. That is exactly what the old
1697
+ * `content_frame` check did: `typeof x === "string" && x !== sessionId`.
1698
+ */
1699
+ const expectedPeer = this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId))?.counterpartySessionPeerId;
1700
+ if (!remotePeerId || !expectedPeer || remotePeerId !== expectedPeer) {
1701
+ // Loud in the LOG — there is no caller to answer on an inbound stream, so this is the whole
1702
+ // surface. Neutral wording: this is an observation, not a verdict about intent. The same
1703
+ // signal comes from a real impersonation attempt and from our own fallback paths
1704
+ // mishandling a reconnect, and nothing here can tell them apart.
1705
+ this.#ctx.logger.warn("session.content.peer_mismatch", {
1706
+ agentName, sessionId, frameType,
1707
+ remotePeerId: remotePeerId ?? "(absent)", expected: expectedPeer ?? "(unknown)",
1708
+ impact: "a frame arrived on this session's content protocol from a peer that is not its counterparty; it was refused — not ingested, not attributed, not recorded — and the peer was disconnected",
1709
+ });
1710
+ /**
1711
+ * PEER-ENDING, NOT SESSION-ENDING — and the difference is a deliberate deviation from the
1712
+ * DoD clause (review F2).
1713
+ *
1714
+ * The clause says the refusal is session-ending. Applied HERE that would be a worse hole
1715
+ * than the one it closes: a pre-positioned stranger could kill any session on the machine
1716
+ * with a single frame, trading an injection hole for a denial-of-service hole. The
1717
+ * session-ending response belongs where the evidence is about the SESSION's counterparty —
1718
+ * `#freezeOnIdentityFailure`, reached when a party that IS the peer we dialled signs with a
1719
+ * key that is not theirs.
1720
+ *
1721
+ * Here the evidence is about the PEER: they are not party to this session at all. So the
1722
+ * connection goes and the session is untouched. Without this the stranger stayed attached
1723
+ * for the life of the session and the gate re-refused each frame forever — and the eviction
1724
+ * sweep's own fallback ("the frame gate still refuses anything this peer sends") only closes
1725
+ * the loop if the frame gate does something about the connection.
1726
+ *
1727
+ * Fire-and-forget: a hang-up that fails must not turn a successful refusal into a thrown
1728
+ * handler, and the refusal above has already done the load-bearing work.
1729
+ */
1730
+ if (remotePeerId) {
1731
+ const entry = this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId));
1732
+ void entry?.node.hangUp(remotePeerId).catch((err) => {
1733
+ this.#ctx.logger.debug("session.content.peer_mismatch.hangup_failed", {
1734
+ sessionId, peerId: remotePeerId, error: extractErrorMessage(err),
1735
+ });
1736
+ });
1737
+ }
1738
+ return;
1739
+ }
1740
+ const claimedSessionId = frame["session_id"];
1741
+ if (typeof claimedSessionId !== "string" || claimedSessionId !== sessionId) {
1742
+ this.#ctx.logger.warn("session.content.session_mismatch", {
1743
+ agentName, sessionId, frameType,
1744
+ claimedSessionId: typeof claimedSessionId === "string" ? claimedSessionId : "(absent)",
1745
+ impact: "the frame does not name the session whose stream it arrived on; it was refused rather than routed, because the authenticated stream is the better authority for where content belongs",
1746
+ });
1747
+ return;
1748
+ }
1749
+ // CELLO-M7-MSG-001 (AC-001/AC-002): a `persisted` delivery ACK arriving on the
1750
+ // same /cello/content/1.0.0 protocol resolves the sender's awaiting-ACK timer.
1751
+ // The protocol acts on `persisted` ONLY — any other level leaves the timer armed.
1752
+ if (frame["type"] === "content_delivery_ack") {
1753
+ const ackHash = frame["content_hash"];
1754
+ const level = frame["level"];
1755
+ if (ackHash instanceof Uint8Array && level === "persisted") {
1756
+ this.#send.resolveAwaitingAck(agentName, sessionId, ackHash);
1757
+ }
1758
+ return;
1759
+ }
1760
+ // DOD-M12B-ABANDON-NOTIFY-1: the counterparty force-abandoned. Handled here, on the same
1761
+ // authenticated stream the delivery acknowledgement rides, and AFTER the session-id check
1762
+ // below cannot be skipped — the frame names its session and the handler is bound to one.
1763
+ if (frame["type"] === "session_abandoned_notice") {
1764
+ // DOD-M15-FRAME-1: the peer and session checks that used to live here now run above, for
1765
+ // EVERY frame type, unchanged in substance — this branch was where they were written first
1766
+ // and correctly, and it is the reference the shared gate was lifted from. Its comment is
1767
+ // preserved there, including the reason the transport being authenticated is not enough.
1768
+ // Left as a bare dispatch on purpose: a second copy of a guard is a second thing to keep in
1769
+ // step, and the one that drifts is the one nobody is reading.
1770
+ void this.#ctx.retireOnCounterpartyAbandon(agentName, sessionId, correlationId);
1771
+ return;
1772
+ }
1773
+ /**
1774
+ * DOD-M15-SEALWIRE-1 bullet 6 (part A) — the salt agreement.
1775
+ *
1776
+ * Placed BELOW the shared peer/session gate deliberately, which is the whole reason that gate
1777
+ * was lifted above the dispatch: a new frame type is protected by construction rather than
1778
+ * having to remember to opt in. A stranger's salt frame is refused before it reaches here, so
1779
+ * nothing about this session's salt can be steered by a peer that is not its counterparty.
1780
+ *
1781
+ * The fields are read defensively into the frame shape rather than cast: an inbound value is
1782
+ * whatever a peer chose to encode, and `onPeerSaltFrame` refuses both-fields and neither-field
1783
+ * by name — so a non-Uint8Array in either slot must arrive at that function as ABSENT, not as
1784
+ * a present-but-wrong value it would then try to use.
1785
+ */
1786
+ /**
1787
+ * 007-CRYPTO — the peer's SIGNED ephemeral.
1788
+ *
1789
+ * Fields are read defensively rather than cast, exactly like the salt frame below: an inbound
1790
+ * value is whatever a peer chose to encode, and `verifySessionEphemeral` refuses a missing or
1791
+ * wrong-width one BY NAME — so a non-`Uint8Array` must arrive there as ABSENT rather than as a
1792
+ * present-but-wrong value it would try to use.
1793
+ */
1794
+ if (frame["type"] === "session_key_agreement") {
1795
+ const ephemeralPublic = frame["ephemeral_public"];
1796
+ const signature = frame["ephemeral_sig"];
1797
+ await this.#ctx.ephemerals.handleEphemeralFrame(agentName, sessionId, {
1798
+ ...(ephemeralPublic instanceof Uint8Array ? { ephemeralPublic } : {}),
1799
+ ...(signature instanceof Uint8Array ? { signature } : {}),
1800
+ }, correlationId);
1801
+ return;
1802
+ }
1803
+ if (frame["type"] === "session_salt_agreement") {
1804
+ const contribution = frame["contribution"];
1805
+ const fingerprint = frame["fingerprint"];
1806
+ const adoptionClosed = frame["adoption_closed"];
1807
+ await this.#ctx.salts.handleSaltFrame(agentName, sessionId, {
1808
+ ...(contribution instanceof Uint8Array ? { contribution } : {}),
1809
+ ...(fingerprint instanceof Uint8Array ? { fingerprint } : {}),
1810
+ // A non-string stays ABSENT rather than being coerced, exactly like the other two: the
1811
+ // decision function refuses a shape it cannot read, and must never be handed a `"42"`.
1812
+ //
1813
+ // TRUNCATED AT THE BOUNDARY — 006-CRYPTO finding 6. Every label CELLO sends is under
1814
+ // twenty characters, and this one is chosen entirely by the peer. Cutting it here means
1815
+ // no unbounded peer string is stored, logged or rendered anywhere downstream; the
1816
+ // rendering that keeps it out of our own sentences is `renderPeerAdoptionLabel`.
1817
+ ...(typeof adoptionClosed === "string" && adoptionClosed.length > 0
1818
+ ? { adoptionClosed: adoptionClosed.slice(0, SALT_ADOPTION_LABEL_MAX) }
1819
+ : {}),
1820
+ }, correlationId);
1821
+ return;
1822
+ }
1823
+ if (frame["type"] !== "content_frame") {
1824
+ // LOGGED, not silently dropped. This handler is bound to one session, and a frame it does
1825
+ // not understand arriving on that stream is either a peer speaking a newer protocol or a
1826
+ // bug on our side — both worth a line, and neither distinguishable from "nothing arrived"
1827
+ // when the return is silent.
1828
+ this.#ctx.logger.warn("session.content.frame_unknown_type", {
1829
+ sessionId,
1830
+ type: typeof frame["type"] === "string" ? String(frame["type"]) : "(absent)",
1831
+ });
1832
+ return;
1833
+ }
1834
+ // DOD-M15-FRAME-1: the session-id check moved to the shared gate above, and its `&&` became
1835
+ // `||` on the way. It read `typeof x === "string" && x !== sessionId` — firing only when the
1836
+ // field was PRESENT and wrong, so omitting it passed. Its own sibling twenty lines up already
1837
+ // refused absence, with a comment saying treating a missing field as agreement is how a guard
1838
+ // stops guarding. Same file, same switch, opposite conclusion.
1839
+ // Review F4: hand the DECODED frame to a test observer before anything consumes it. Absent in
1840
+ // production — the field is null unless a test installs one.
1841
+ this.#ctx.inboundFrameObserver?.(frame);
1842
+ const contentBytes = frame["content_bytes"];
1843
+ const contentHash = frame["content_hash"];
1844
+ if (!(contentBytes instanceof Uint8Array) || !(contentHash instanceof Uint8Array)) {
1845
+ // Same reasoning as the unknown type above: a malformed frame that vanishes without a trace
1846
+ // is indistinguishable, from the operator's side, from a counterparty who never sent
1847
+ // anything.
1848
+ this.#ctx.logger.warn("session.content.frame_malformed", {
1849
+ sessionId,
1850
+ hasContent: contentBytes instanceof Uint8Array,
1851
+ hasHash: contentHash instanceof Uint8Array,
1852
+ });
1853
+ return;
1854
+ }
1855
+ /**
1856
+ * 🚨 DECRYPT BEFORE ANYTHING ELSE READS THE BODY — `DOD-M15-EPHEMERAL-AUTH-1`.
1857
+ *
1858
+ * `content_hash` is over the PLAINTEXT, so the hash check, the transcript, the seal and the
1859
+ * salted hash all keep meaning exactly what they mean today — but only if the body is put back
1860
+ * before any of them run.
1861
+ *
1862
+ * ⚠️ ABSENT IS NOT A PASS. A frame with no `content_encryption` is refused rather than read as
1863
+ * plaintext. There is no unencrypted sender to be compatible with, and treating a missing
1864
+ * marker as "this one is in the clear" is precisely the downgrade an attacker asks for: strip
1865
+ * one field and the receiver reads the body raw. Missing and unknown take the same path as a
1866
+ * failed decrypt, for the reason that runs through this whole unit — a check lenient about an
1867
+ * absent proof is a check that gets skipped.
1868
+ */
1869
+ const declaredEncryption = frame["content_encryption"];
1870
+ const encState = this.#ctx.ephemerals.contentEncryptionState(agentName, sessionId);
1871
+ let plaintextBody;
1872
+ if (declaredEncryption !== SESSION_CONTENT_ENCRYPTION_V1) {
1873
+ this.#refuseInboundContent(agentName, sessionId, "content_encryption_absent_or_unknown", contentHash, {
1874
+ declared: typeof declaredEncryption === "string" ? declaredEncryption : "(absent)",
1875
+ impact: "the frame did not say it was encrypted under this session's key, so it was refused unread — nothing was shown and this copy was not kept.",
1876
+ guidance: "STOPPED ON PURPOSE. A message arrived that was not encrypted under this session's key. " +
1877
+ "This build never sends one, so either something between you rewrote the frame, or your " +
1878
+ "counterparty is running something that is not CELLO. Confirm with them OUT OF BAND " +
1879
+ "before opening another session.",
1880
+ }, correlationId);
1881
+ return;
1882
+ }
1883
+ if (encState.key === null) {
1884
+ this.#refuseInboundContent(agentName, sessionId, "no_session_key", contentHash, {
1885
+ detail: encState.reason,
1886
+ impact: "an encrypted message arrived and this side has no agreed key to open it, so it was refused unread rather than shown as garbage.",
1887
+ // Review F6: the RECEIVE-side wording. The send-side table explains what became of a
1888
+ // message this operator sent, which is the wrong direction entirely for a message they
1889
+ // cannot open.
1890
+ guidance: CONTENT_ENCRYPTION_INBOUND_GUIDANCE[encState.reason],
1891
+ }, correlationId);
1892
+ return;
1893
+ }
1894
+ const opened = openSessionContent(encState.key, contentBytes);
1895
+ if (opened === null) {
1896
+ // GCM's tag is the only thing separating "not for us" from "modified in flight", and this
1897
+ // side must not branch on which — that would be branching on attacker-controlled input.
1898
+ this.#refuseInboundContent(agentName, sessionId, "decrypt_failed", contentHash, {
1899
+ impact: "the message did not decrypt under this session's agreed key — it was modified in flight, or it was encrypted under a different key. Refused unread.",
1900
+ guidance: "STOPPED ON PURPOSE. Nothing was shown and this copy was not kept. A message that fails " +
1901
+ "this check has either been altered on its way to you or was not encrypted for this " +
1902
+ "session. Confirm with your counterparty OUT OF BAND, then start a new session.",
1903
+ }, correlationId);
1904
+ return;
1905
+ }
1906
+ plaintextBody = opened;
1907
+ // DOD-MSG-4 (self-ordering content frame): if the frame carries the relay's signed ordering
1908
+ // record, verify the sender signature and record the canonical sequence FROM THE FRAME, BEFORE
1909
+ // ingest — so the strict-in-order gate has the position without waiting on the separate
1910
+ // leaf_deliver witness (removes the content-before-witness race).
1911
+ //
1912
+ // DOD-M15-FRAME-1 — POSITION MAY BE SOFT; IDENTITY MAY NOT. The old comment here read "A
1913
+ // bad/absent record is non-fatal: the content still ingests", and it was accurate: a
1914
+ // signature that failed to verify, and a signature by a key that is NOT this session's
1915
+ // counterparty, both returned null and the content was ingested and attributed anyway. An
1916
+ // ABSENT record stays soft — that is the documented relay-degraded path and refusing it would
1917
+ // make the relay a precondition for reading mail. A record that is PRESENT and REFUTED is a
1918
+ // different fact, and it is now refused.
1919
+ const s1Cbor = frame["structure1_cbor"];
1920
+ const s2Cbor = frame["structure2_cbor"];
1921
+ /**
1922
+ * `DOD-M15-AUTHORSHIP-ABSENT-1` — the sender's own signature, carried BESIDE the bytes it
1923
+ * signs, exactly as `hash_submit` has always carried it. This field is why identity no longer
1924
+ * depends on the relay: it arrives whether or not a relay witnessed the message.
1925
+ */
1926
+ const senderSig = frame["sender_signature"];
1927
+ let framedSeq = null;
1928
+ /**
1929
+ * DOD-M15-SEALWIRE-1 bullet 5. Set ONLY when the ordering record verified — the signature
1930
+ * checked against the pubkey inside the sender's own signed bytes AND the signer matched this
1931
+ * session's counterparty. It is deliberately NOT set on the two soft paths below (no record
1932
+ * supplied; decode failed), because on those the author is attested by local session state
1933
+ * and the transcript row must say so rather than imply a proof it does not have.
1934
+ */
1935
+ let verifiedAuthorship;
1936
+ /**
1937
+ * 024-ORPHANTRIAGE: the signer when the signature verified but there was no counterparty to
1938
+ * match it against. Its ONLY consumer is the orphan branch inside ingest — everywhere else a
1939
+ * session record exists, so this stays `undefined` and nothing reads it.
1940
+ */
1941
+ let verifiedSignerUnmatched;
1942
+ /**
1943
+ * ─── NO PASSPORT, NO ENTRY — `DOD-M15-AUTHORSHIP-ABSENT-1` ───────────────────────────────
1944
+ *
1945
+ * ⚠️ **THIS COMMENT USED TO SAY THE OPPOSITE, AND THE SENTENCE IT REPLACES IS THE DEFECT.**
1946
+ * It read: *"it means the per-message signer check is **opt-in for the sender** — a party that
1947
+ * passed the peer gate and wants to avoid the comparison simply omits the proof."* That was an
1948
+ * accurate description of the code, which is why it is rewritten here rather than deleted: it
1949
+ * is the sentence a reader with a coding agent finds, and it must now describe what the code
1950
+ * does. A frame that supplies nothing checkable is REFUSED. Omitting the proof buys the sender
1951
+ * nothing except a message that does not arrive.
1952
+ *
1953
+ * The old reasoning was sound as far as it went — the signature was only ever DELIVERED inside
1954
+ * the relay's Structure 2, so refusing on its absence would have made the relay a precondition
1955
+ * for reading mail. It stopped one field short: the signature travels beside the bytes it
1956
+ * signs now, on every content frame, so identity no longer needs the relay and position still
1957
+ * does not require identity.
1958
+ *
1959
+ * ⚠️ REFUSED, NOT FROZEN. A frozen session is only cleared by opening a new one, and the
1960
+ * overwhelmingly likely cause of an absent proof is a counterparty on an older build. The
1961
+ * freeze is for a proof that FAILED (below, and in `#recordFrameOrdering`) — a positive fact
1962
+ * about their key.
1963
+ */
1964
+ if (!(s1Cbor instanceof Uint8Array) || !(senderSig instanceof Uint8Array)) {
1965
+ this.#ctx.refusals.refuseUnprovenAuthorship(agentName, sessionId, "authorship_proof_absent", contentHash, {
1966
+ // WHICH half is missing. A sender on an older build supplies neither; a stripped frame is
1967
+ // likelier to be missing one, and an investigator should not have to guess which.
1968
+ hasStructure1: s1Cbor instanceof Uint8Array,
1969
+ hasSenderSignature: senderSig instanceof Uint8Array,
1970
+ }, correlationId);
1971
+ return;
1972
+ }
1973
+ const authorship = this.#ctx.authorship.verifyAuthorshipClaim(agentName, sessionId, s1Cbor, senderSig, contentHash);
1974
+ if (authorship.verdict === "refuted") {
1975
+ /**
1976
+ * THE FORENSIC LINE, BEFORE THE FREEZE. `session.content.identity.frozen` records that a
1977
+ * session was stopped; this records WHICH check stopped it and on WHICH proof — the frame's
1978
+ * own signature, not the relay's copy of it. The two used to be the same event because there
1979
+ * was only one place a signer was checked; there are two now, and an investigation that
1980
+ * cannot tell them apart is looking at the wrong half of the wire.
1981
+ */
1982
+ this.#ctx.logger.warn("session.content.authorship.refuted", {
1983
+ agentName, sessionId, correlationId, reason: authorship.reason,
1984
+ impact: "a message arrived with a proof of authorship that FAILED — it does not verify, or it is signed by a key that is not this session's counterparty. Nothing was ingested and the session is being frozen.",
1985
+ });
1986
+ await this.#ctx.freezeOnIdentityFailure(agentName, sessionId, authorship.reason, correlationId);
1987
+ return;
1988
+ }
1989
+ if (authorship.verdict === "unusable") {
1990
+ // A replayed claim gets its own name on BOTH surfaces, not just in the log context: it is
1991
+ // the one `unusable` cause that may be adversarial, and it is the one the operator can act
1992
+ // on. The others are a peer whose build or bytes we could not read.
1993
+ /**
1994
+ * 033-ACKEMIT — AND THE THREE ACKNOWLEDGEMENT CAUSES GET THEIR OWN SURFACE REASON, for the
1995
+ * same argument that gave the replay one: `authorship_proof_unusable` tells the operator the
1996
+ * proof was "unreadable, or signed over different content", and for these it is neither.
1997
+ * The proof is perfect; what it CLAIMS TO HAVE SEEN is wrong. Filing them under the generic
1998
+ * name would send someone to audit a decoder, and would spend the operator's attention
1999
+ * asking their counterparty about a version number that is not the question.
2000
+ */
2001
+ this.#ctx.refusals.refuseUnprovenAuthorship(agentName, sessionId, authorship.reason === AUTHORSHIP_SESSION_MISMATCH
2002
+ ? "authorship_wrong_conversation"
2003
+ /**
2004
+ * `DOD-M15-SELFCHAIN-1` — its own name on the surface the operator reads, not only in a
2005
+ * log field. See the sentences in `#refuseUnprovenAuthorship`.
2006
+ */
2007
+ : authorship.reason === AUTHORSHIP_SELF_CHAIN_MISMATCH
2008
+ ? AUTHORSHIP_SELF_CHAIN_MISMATCH
2009
+ : ACK_HASH_REASONS.has(authorship.reason)
2010
+ /**
2011
+ * ⚠️ THE SPECIFIC CAUSE, NOT THE CLASS — review F5, and the diff's own comment on
2012
+ * `ACK_HASH_REASONS` had already said why: "the operator's next move differs for
2013
+ * each." It then collapsed all three into ONE surface reason carrying ONE sentence,
2014
+ * so the three names survived only in a log field nobody reads. For an absent
2015
+ * acknowledgement the shared impact was flatly false — there is no part that "does
2016
+ * not match", because there is no part — and for the other two the shared guidance
2017
+ * sent the reader to ask about a build version that cannot be the cause.
2018
+ */
2019
+ ? authorship.reason
2020
+ : "authorship_proof_unusable", contentHash, { detail: authorship.reason }, correlationId);
2021
+ /**
2022
+ * ─── AND THE SESSION FREEZES — `DOD-M15-SELFCHAIN-1`, the escalation clause ──────────────
2023
+ *
2024
+ * ⚠️ ONLY THIS ONE OF THE `unusable` CAUSES FREEZES, and the split is the whole rule.
2025
+ *
2026
+ * The acknowledgement causes say the sender is wrong about what WE said, which a record
2027
+ * that has drifted produces honestly, and refusing the message is proportionate. This one
2028
+ * says they are wrong about what THEY said — the one thing a party cannot be honestly
2029
+ * mistaken about for long — so continuing writes a disputed order into the receipt. There
2030
+ * is nothing to gain from message N+1 on a conversation whose order is already in question.
2031
+ *
2032
+ * The freeze is what makes the refusal an ESCALATION rather than a dropped frame: it is
2033
+ * visible in the session's own state, not only in a notice the operator has to go and read.
2034
+ */
2035
+ if (authorship.reason === AUTHORSHIP_SELF_CHAIN_MISMATCH) {
2036
+ await this.#ctx.freezeOnIdentityFailure(agentName, sessionId, authorship.reason, correlationId);
2037
+ }
2038
+ return;
2039
+ }
2040
+ if (authorship.verdict === "verified") {
2041
+ verifiedAuthorship = { senderPubkey: authorship.senderPubkey, senderSig: authorship.senderSig };
2042
+ }
2043
+ else {
2044
+ verifiedSignerUnmatched = authorship.senderPubkey;
2045
+ }
2046
+ if (s2Cbor instanceof Uint8Array) {
2047
+ const ordering = this.#ctx.refusals.recordFrameOrdering(agentName, sessionId, s1Cbor, s2Cbor, contentHash, correlationId);
2048
+ if (ordering.fatal) {
2049
+ await this.#ctx.freezeOnIdentityFailure(agentName, sessionId, ordering.fatal.reason, correlationId);
2050
+ return;
2051
+ }
2052
+ framedSeq = ordering.seq;
2053
+ }
2054
+ else {
2055
+ /**
2056
+ * POSITION IS THE ONLY THING THAT CAN BE ABSENT NOW, and this event is about position.
2057
+ *
2058
+ * It fires on the relay-degraded path, where the sender had no witnessed record to stamp
2059
+ * on. The message is ingested — its author is proven, above, by the frame's own signature —
2060
+ * and only its place in the canonical sequence falls back to the witness stream. Refusing
2061
+ * here would make the relay a precondition for reading mail, which is the thing this unit
2062
+ * was careful NOT to do.
2063
+ */
2064
+ this.#ctx.logger.info("session.content.ordering.absent", {
2065
+ agentName, sessionId, correlationId,
2066
+ impact: "this frame carried no relay ordering record, so its POSITION in the canonical sequence is not known from the frame and falls back to the witness stream. Its AUTHOR was verified from the frame's own signature.",
2067
+ });
2068
+ }
2069
+ // AC-001: carry the sender's correlationId from the frame into the receive
2070
+ // path so both sides log the same flow id (never re-minted on receipt).
2071
+ /**
2072
+ * DOD-M15-SEALWIRE-1 part B1 — the algorithm the sender named, taken from the FRAME.
2073
+ *
2074
+ * Read as `unknown` and passed through verbatim, deliberately: `resolveContentHashAlg` is the
2075
+ * one place that decides what a value means, and it distinguishes ABSENT (a peer predating
2076
+ * the field — verify as `sha256`) from a non-string or an unreadable name (refuse by name).
2077
+ * Coercing here would collapse that distinction and turn a version skew into a tamper report.
2078
+ */
2079
+ const declaredAlg = frame["content_hash_alg"];
2080
+ const ingest = await this.ingestReceivedContent(
2081
+ // THE DECRYPTED body — everything downstream (the hash cross-check, the leaf, the transcript,
2082
+ // the delivery buffer) works on plaintext, exactly as it did before this layer existed.
2083
+ agentName, sessionId, plaintextBody, contentHash, correlationId, framedSeq ?? undefined, declaredAlg === undefined ? undefined : declaredAlg, verifiedAuthorship, verifiedSignerUnmatched);
2084
+ // AC-001: after the content is durably ingested AND its hash cross-check
2085
+ // succeeds, emit an unsigned `persisted` delivery ACK back to the sender. A
2086
+ // rejected ingest (tamper / not-active) produces NO ACK, so the sender's TTF
2087
+ // path can park / recover.
2088
+ // DOD-MSG-4: a HELD (out-of-order) frame is NOT yet a durable leaf, so it is NOT
2089
+ // acknowledged `persisted` — the sender's TTF→park backstop then guarantees the
2090
+ // missing-earlier message is fetchable, and dedup absorbs the redundant copy.
2091
+ if (ingest.ok && !ingest.held) {
2092
+ /**
2093
+ * ─── THE SELF CHAIN IS PURELY CONTENT, SO IT ADVANCES HERE — `DOD-M15-SELFCHAIN-1` ───────
2094
+ *
2095
+ * ⚠️ NOT INSIDE `#noteAcknowledgeable`, and that placement was the bug. The acknowledgement
2096
+ * is a (POSITION, content) pair and needs the relay's number, so on a session the relay
2097
+ * never witnessed it is never written at all. The self link needs no position — it is one
2098
+ * party's hash chain over their own messages — so tying it to the acknowledgement meant the
2099
+ * receiver's record never moved on an unwitnessed session, and the counterparty's SECOND
2100
+ * message was refused as a broken chain for the rest of the conversation.
2101
+ *
2102
+ * That is the path this unit exists for: a conversation that ran while the relay was down is
2103
+ * precisely the one whose order gets disputed later.
2104
+ */
2105
+ this.#ctx.authorship.noteReceivedFromCounterparty(agentName, sessionId, contentHash);
2106
+ /**
2107
+ * 033-ACKEMIT review F1 — ACKNOWLEDGE WHAT ARRIVED, HERE, not when the relay gets round to
2108
+ * delivering its copy back to us.
2109
+ *
2110
+ * Placed after a successful, non-held ingest deliberately: a HELD frame is not yet a durable
2111
+ * leaf and is not acknowledged `persisted` either, so claiming to have seen it would put a
2112
+ * position in our signed claim that our own record does not yet hold.
2113
+ *
2114
+ * `framedSeq` is the relay's canonical position taken from the sender's own signed ordering
2115
+ * record and verified before it got here. When it is absent the message arrived with no
2116
+ * ordering record — the withheld-submit case — and there is no position to acknowledge,
2117
+ * whatever we hold of the content. That limit is structural to a (position, content) pair
2118
+ * and it is what the carried-leaf follow-on closes.
2119
+ */
2120
+ if (framedSeq !== null) {
2121
+ this.noteAcknowledgeable(agentName, sessionId, framedSeq, contentHash);
2122
+ }
2123
+ else {
2124
+ /**
2125
+ * ─── WITNESS WHAT THEY DID NOT — 034-CARRYLEAF, and this is the line that closes
2126
+ * `DOD-M15-WITHHOLD-SEAL-1` ────────────────────────────────────────────────────────────
2127
+ *
2128
+ * No ordering record means the sender never asked the relay to witness this message. Two
2129
+ * things look identical from here: their relay was briefly unreachable, or they are
2130
+ * withholding it on purpose so it cannot appear in the receipt. **We do not need to tell
2131
+ * those apart, and that is the point** — the same action repairs both, and it costs the
2132
+ * honest case nothing.
2133
+ *
2134
+ * We hold their signature over their own bytes. So we hand it to the relay ourselves.
2135
+ */
2136
+ /**
2137
+ * ─── THE KIND COMES OFF THE FRAME, AND A FRAME WITHOUT ONE IS REFUSED ─────────────────
2138
+ *
2139
+ * A leaf kind selects a HASH DOMAIN — documents and rejection envelopes ride this same
2140
+ * frame — so witnessing under a guessed domain would put a wrong statement in the
2141
+ * canonical record.
2142
+ *
2143
+ * ⚠️ **THIS USED TO DECLINE TO WITNESS AND DELIVER THE MESSAGE ANYWAY, "because a peer
2144
+ * too old to send the field should be left alone". THAT SENTENCE WAS INHERITED, NOT
2145
+ * DERIVED, AND IT LEFT THE WHOLE ATTACK OPEN.** CELLO is alpha with no users; there is no
2146
+ * older peer to protect. What the leniency actually bought was an opt-out: emit the shape
2147
+ * a 2026-09-04 build emitted, and your message is delivered AND cannot be witnessed —
2148
+ * which is precisely the withholding this line exists to stop, reachable by anyone
2149
+ * willing to modify their client.
2150
+ *
2151
+ * So it is refused. Missing, malformed and mismatched take one path (§5), and a peer that
2152
+ * cannot say which domain its own leaf belongs to has supplied an unusable proof.
2153
+ */
2154
+ const framedKind = frame["leaf_kind"];
2155
+ if (typeof framedKind !== "number") {
2156
+ this.#ctx.refusals.refuseUnprovenAuthorship(agentName, sessionId, "authorship_proof_unusable", contentHash, {
2157
+ detail: "leaf_kind_absent",
2158
+ }, correlationId);
2159
+ return;
2160
+ }
2161
+ void this.witnessReceivedLeaf(agentName, sessionId, contentHash, s1Cbor, senderSig, framedKind, correlationId);
2162
+ }
2163
+ void this.#sendDeliveryAck(agentName, sessionId, contentHash, correlationId);
2164
+ }
2165
+ }
2166
+ catch (err) {
2167
+ this.#ctx.logger.warn("session.content.stream.read.failed", {
2168
+ sessionId,
2169
+ error: err instanceof Error ? err.message : String(err),
2170
+ });
2171
+ }
2172
+ finally {
2173
+ // `close()` waits only for OUR write buffer, which is empty here, so this cannot stall the
2174
+ // handler; it runs on every exit above, and there are several early returns.
2175
+ try {
2176
+ await stream.close();
2177
+ }
2178
+ catch (err) {
2179
+ // NOT SILENT. A close that fails here is the signature of the cap biting from the other
2180
+ // side, and it was the absence of exactly this line that turned the original diagnosis
2181
+ // into a 6,451-record log measurement.
2182
+ this.#ctx.logger.warn("session.content.stream.close.failed", {
2183
+ sessionId,
2184
+ error: err instanceof Error ? err.message : String(err),
2185
+ });
2186
+ try {
2187
+ stream.abort(err instanceof Error ? err : new Error(String(err)));
2188
+ }
2189
+ catch { /* already gone */ }
2190
+ return;
2191
+ }
2192
+ // OUR CLOSE ALONE DOES NOT FREE THE SLOT — the peer has to close its end too, and a peer
2193
+ // owns its own daemon. Without this, someone who opens content streams and never closes them
2194
+ // pins every inbound slot we have and puts us straight back into the defect above, with the
2195
+ // same unreadable error. `abort` resets unilaterally, so it works regardless of the peer;
2196
+ // the delay is what keeps it from landing while a well-behaved sender is still inside its
2197
+ // own `close()`. Unref'd so it can never hold the process open at shutdown, and tracked so
2198
+ // teardown can drop it.
2199
+ if (stream.status === "open" || stream.status === "closing") {
2200
+ const linger = setTimeout(() => {
2201
+ this.#ctx.lingeringStreams.delete(linger);
2202
+ if (stream.status !== "open" && stream.status !== "closing")
2203
+ return;
2204
+ this.#ctx.logger.debug("session.content.stream.linger.reset", { sessionId });
2205
+ try {
2206
+ stream.abort(new Error("inbound content stream not closed by peer"));
2207
+ }
2208
+ catch { /* already gone */ }
2209
+ }, CONTENT_STREAM_LINGER_MS);
2210
+ linger.unref?.();
2211
+ this.#ctx.lingeringStreams.add(linger);
2212
+ }
2213
+ }
2214
+ }
2215
+ }
2216
+ //# sourceMappingURL=session-content-ingest.js.map