@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,948 @@
1
+ /**
2
+ * CELLO Daemon — CLOSING A CONVERSATION, AND PROVING IT CLOSED
3
+ *
4
+ * Split out of `session-node-manager.ts`. The seal is the artefact the whole product exists to
5
+ * produce: when both parties close, each holds a notarized root over the same transcript, and
6
+ * either can later show a third party that the conversation happened and was not altered.
7
+ *
8
+ * What is here: deciding whether a session is ready to seal and telling the operator when it is
9
+ * not, submitting our own SEAL leaf, carrying the counterparty's, persisting the commitment when a
10
+ * close is interrupted, recording the certificate, verifying a certified root against our own tree,
11
+ * and the auto-acknowledgement gate that lets a seal complete without a human on both ends.
12
+ *
13
+ * **Moved verbatim, comments included.**
14
+ *
15
+ * ⚠️ **THE TWO STORES BELOW ARE OPENED LAZILY, and that is why they are read-write on the context
16
+ * rather than passed in.** Both are created on first use, because they need the database and the
17
+ * database is opened long after the manager is constructed. The manager and this file must see the
18
+ * SAME instance — a second `SessionSealLeafStore` over the same rows is two writers that agree only
19
+ * by luck.
20
+ */
21
+ import { createHash } from "node:crypto";
22
+ import { buildMerkleTree, merkleRoot } from "@cello-protocol/crypto";
23
+ import { encodeSealPayload, decodeStructure1 } from "@cello-protocol/protocol-types";
24
+ import { AUTOACK_BROKER_GRACE_MS, carryContentHashInputs } from "./session-node-types.js";
25
+ import { LEAF_KIND_CTRL } from "./session-relay-client.js";
26
+ import { RelayReceiptStore } from "./relay-receipt-store.js";
27
+ import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
28
+ export class SessionSeal {
29
+ #ctx;
30
+ constructor(ctx) {
31
+ this.#ctx = ctx;
32
+ }
33
+ /** A getter so the moved queries still read `this.#db` and narrow exactly as they did. */
34
+ get #db() {
35
+ return this.#ctx.db();
36
+ }
37
+ /**
38
+ * M12-P15: what a seal leaf actually needs, resolved from a LIVE node when there is one and from
39
+ * durable state when there is not.
40
+ *
41
+ * `submitSealLeaf` used to hard-require an `#activeNodes` entry. But it is reachable — by design —
42
+ * for an `interrupted` session, and EVERY producer of that status deletes the entry. So the guard
43
+ * refused 100% of the calls the seal-interrupted path could ever make, which is what made M12-P15's
44
+ * first fix inert. `submitLeaf(node, sessionId, contentHash, leafKind)` takes everything
45
+ * explicitly; nothing about it needs a per-session node.
46
+ *
47
+ * The fallback is the same shape `startupParkFn` already uses for content: the persisted relay
48
+ * endpoint (`relay_peer_id`/`relay_addrs`, columns that exist for exactly this reason) plus the
49
+ * owning agent's standing receiver. Every failure is named for its own cause rather than collapsed
50
+ * into "no node", because that collapse is what sent this investigation at the session lifecycle
51
+ * instead of at the endpoint.
52
+ */
53
+ #resolveSealTransport(agentName, sessionId) {
54
+ const entry = this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId));
55
+ if (entry) {
56
+ // The live path is unchanged and takes precedence — a session with its own registered client
57
+ // must never seal through a rebuilt one.
58
+ if (!entry.relayClient || !entry.relaySessionIdBytes)
59
+ return { error: "relay_unavailable" };
60
+ return { node: entry.node, relayClient: entry.relayClient, relaySessionIdBytes: entry.relaySessionIdBytes };
61
+ }
62
+ const ep = this.#ctx.queries.getPersistedRelayEndpoint(agentName, sessionId);
63
+ if (!ep)
64
+ return { error: "no_persisted_relay_endpoint" };
65
+ const node = this.#ctx.receivers.getStandingReceiverNode(agentName);
66
+ if (!node)
67
+ return { error: "standing_receiver_unavailable" };
68
+ // Reuse the agent's existing client for this relay when the process still has one; otherwise ask
69
+ // the composition root to build one. Without the builder this path would work only within the
70
+ // lifetime that created the session — and the case that matters most is precisely a daemon that
71
+ // RESTARTED, which is what marked the session interrupted in the first place.
72
+ const clientKey = `${agentName}::${ep.relayPeerId}`;
73
+ let client = this.#ctx.relayClients.get(clientKey);
74
+ if (!client) {
75
+ // Review HIGH-1: the stores are NOT optional here. Without them `#captureReceipt` silently
76
+ // `return false`s — the submit still reports ok while the relay's signed receipt and our OWN
77
+ // 0x02 ctrl leaf are never persisted. That drops the unilateral-escalation carry chain AND
78
+ // defeats this very unit's ceremony discriminator, which reads `session_seal_leaves`: a seal
79
+ // sent through here would later read as "ceremony unknown" and the peer would decline to
80
+ // sign. The fix would have broken the fix.
81
+ if (!this.#ctx.relayReceiptStore && this.#db)
82
+ this.#ctx.relayReceiptStore = new RelayReceiptStore(this.#db, this.#ctx.logger);
83
+ if (!this.#ctx.sealLeafStore && this.#db)
84
+ this.#ctx.sealLeafStore = new SessionSealLeafStore(this.#db, this.#ctx.logger);
85
+ client = this.#ctx.detachedRelayClientBuilder?.(agentName, ep.relayPeerId, [...ep.relayAddrs], {
86
+ receiptStore: this.#ctx.relayReceiptStore ?? undefined,
87
+ sealLeafStore: this.#ctx.sealLeafStore ?? undefined,
88
+ ownChainStore: this.#ctx.ownChainStore ?? undefined,
89
+ // DOD-M15-RELAYSLOTS-1: read at each auth, never snapshotted — the token expires hourly.
90
+ onlineToken: () => this.#ctx.getDirectoryOnlineToken(agentName),
91
+ });
92
+ if (!client)
93
+ return { error: "relay_client_unavailable" };
94
+ // Review MEDIUM-4: cache it, so a retry loop does not leak one authenticated relay stream per
95
+ // attempt. Safe ONLY because the client above now carries the stores — a store-less client
96
+ // cached under this key would be picked up by the live `#connectSessionRelay` path and poison
97
+ // it for the rest of the process.
98
+ this.#ctx.relayClients.set(clientKey, client);
99
+ }
100
+ // Review HIGH-2: register the session (no assignment — there is nothing to re-present) so the
101
+ // relay's `session_not_found` is interpreted honestly. Unregistered, `recordedBefore` is false,
102
+ // the retry loop runs pointlessly, and the `recordedBefore && session_not_found ->
103
+ // relay_session_gone` branch — which exists to tell "the relay never had it" apart from "the
104
+ // relay swept or sealed it" — can never fire, so the operator is handed a first-message-race
105
+ // label for a swept session.
106
+ /**
107
+ * DOD-M15-RELAYLEAK-1 — **RE-REGISTERING A SESSION THAT IS ALREADY REGISTERED BLINDS IT.**
108
+ *
109
+ * `registerSession` REPLACES the entry's `onLeafDeliver` with `onLeafDeliver ?? (() => {})`, and
110
+ * unlike `assignment` / `recorded` it does NOT carry the existing handler forward. **This path
111
+ * passes no handler.** So a detached seal on a session that still holds a live registration
112
+ * would swap that session's inbound leaf delivery for a no-op: the counterparty's leaves keep
113
+ * arriving at the relay client and are dropped on the floor, while the operator sees a healthy
114
+ * session that has simply gone quiet.
115
+ *
116
+ * A call that finds the session already registered is therefore a PASSENGER: it uses the client
117
+ * and touches nothing. It also does not release — ownership is never inferred, only claimed.
118
+ *
119
+ * ⚠️ **This is NOT justified by a concurrency race, and an earlier version of this comment said
120
+ * it was.** Review checked: `#resolveSealTransport` and everything above the first `await` are
121
+ * synchronous, and a second caller plants/hits `#responderSealSubmitted` and returns
122
+ * `responder_seal_already_submitted` before ever reaching `submitLeaf`. Two callers cannot be in
123
+ * the client at once through the only caller, so "the second one closes the client the first is
124
+ * awaiting" **cannot happen**. The wrong reason mattered: it invites a future reader to delete
125
+ * this guard as defensive clutter once they notice the race is impossible — taking the
126
+ * handler-clobbering protection with it.
127
+ */
128
+ const claimedRegistration = !client.hasSession(sessionId);
129
+ if (claimedRegistration) {
130
+ // 033-ACKEMIT: the seal transport submits a ctrl leaf like any other, so it needs the same
131
+ // acknowledgement seed. It carries no assignment of its own, so the genesis is supplied here
132
+ // from the session's own active entry rather than derived inside the client.
133
+ client.registerSession(sessionId, node, undefined, undefined, this.#ctx.leafRecords.sessionGenesisPrevRoot(agentName, sessionId));
134
+ }
135
+ else {
136
+ /**
137
+ * Review MEDIUM-1 — **A PATH THAT DECLINES TO FIX A LEAK MUST SAY SO.**
138
+ *
139
+ * Reaching here means the session is registered on this client while `#activeNodes` holds no
140
+ * entry for it — and the ways that happens are all orphans: a previous `releaseDetached()`
141
+ * that threw (loud once, then silent forever after), or a revival that registered and then
142
+ * failed to hang the client on an entry. Declining to unregister is right — we cannot prove
143
+ * no live session owns it — but without this line the orphan is invisible, which is the exact
144
+ * shape this whole line exists to remove.
145
+ */
146
+ this.#ctx.logger.info("session.seal.transport.registration_shared", {
147
+ agentName,
148
+ sessionId,
149
+ impact: "this seal is using a relay registration it did not create, so it will not release it. If " +
150
+ "no live session owns that registration the client is held until process exit.",
151
+ });
152
+ }
153
+ /**
154
+ * DOD-M15-RELAYLEAK-1 — **`releaseOnDone` IS THE RELEASE SIGNAL, and its absence was the leak.**
155
+ *
156
+ * This branch (no `#activeNodes` entry) CACHES a relay client and registers a session on it, and
157
+ * nothing ever unregistered. `#detachSessionRelay` closes a client only when
158
+ * `!client.hasSessions()`, so a registration that is never removed keeps that predicate false
159
+ * **forever** — the client, its authenticated stream and its reader survive for the process
160
+ * lifetime. The LIVE branch above registers nothing here, so only this one needs releasing, and
161
+ * marking it is what lets the caller tell them apart without guessing.
162
+ */
163
+ return {
164
+ node,
165
+ relayClient: client,
166
+ relaySessionIdBytes: new Uint8Array(Buffer.from(sessionId, "hex")),
167
+ // Not `true` unconditionally: a passenger call must not release a registration it did not
168
+ // claim — see the note above `claimedRegistration`.
169
+ ...(claimedRegistration ? { releaseOnDone: true } : {}),
170
+ };
171
+ }
172
+ /**
173
+ * REBUILD THE CERTIFIED ROOT FROM THIS DAEMON'S OWN LEAVES — `DOD-M15-SEALWIRE-1` bullet 2.
174
+ *
175
+ * The receipt used to prove only that the directory signed SOMETHING: the client took the sealed
176
+ * root off the wire, confirmed the directory's signature over those bytes, stored it, and threw
177
+ * away the root it had computed a step earlier. At co-signing time that means **your key signs a
178
+ * root you never checked.**
179
+ *
180
+ * Bullet 1 moved the certified root into the content-hash domain, which is the domain this daemon
181
+ * can actually rebuild — each carry leaf's `content_hash` is the leaf hash (RFC 6962 §2.1 "hash"
182
+ * leaves are used as-is), and the carry is ordered by the relay's canonical `sequence_number`,
183
+ * which is the order the directory rebuilds in.
184
+ *
185
+ * ─── Why this returns "cannot judge" instead of always answering ───────────────────────────
186
+ *
187
+ * A root comparison that is WRONG makes every session unsealable, and force-abandon — no receipt —
188
+ * becomes the only exit. That failure is worse than the one being guarded, and this file already
189
+ * carries two comments saying so about other gates.
190
+ *
191
+ * The carry is this daemon's view, and it is not guaranteed complete at the instant a certificate
192
+ * arrives: the counterparty's SEAL ctrl leaf is what TRIGGERS the seal, so it may not have been
193
+ * witnessed here yet. So completeness is checked FIRST, against the certificate's own leaf count.
194
+ * A short carry means this daemon cannot judge — which is a different answer from "the roots
195
+ * disagree", and conflating them would turn a local timing gap into an accusation.
196
+ */
197
+ verifyCertifiedRoot(agentPubkeyHex, sessionIdHex, certifiedRoot, certifiedLeafCount) {
198
+ const carry = this.getSealCarry(agentPubkeyHex, sessionIdHex);
199
+ if (carry.length === 0)
200
+ return { verdict: "cannot_judge", reason: "no_carry" };
201
+ /**
202
+ * COMPLETENESS IS ESTABLISHED FROM THE CARRY'S OWN EVIDENCE, NEVER FROM THE CERTIFICATE.
203
+ *
204
+ * Review F3, and the first cut had this exactly backwards. It gated on
205
+ * `carry.length !== certifiedLeafCount`, where `certifiedLeafCount` is a field the DIRECTORY
206
+ * chooses and signs — so the party being checked controlled whether it was checked. A directory
207
+ * certifying a root over a different conversation had only to state a `leaf_count` that did not
208
+ * match, and the client answered "cannot judge" and accepted. The signature still verified,
209
+ * because the count is signed inside the same TBS.
210
+ *
211
+ * That is the hole §2b names in as many words: *"an attacker who wants to evade a mismatch check
212
+ * simply never supplies a checkable proof. Treating 'we could not tell' as harmless is the
213
+ * hole."* I defended against a false POSITIVE and left the false NEGATIVE one field away.
214
+ *
215
+ * The carry can answer the question by itself. A complete bilateral leaf set is:
216
+ * - sequences contiguous from 1 — no gap where a leaf this daemon never saw would sit; and
217
+ * - exactly two SEAL ctrl leaves, from two DISTINCT senders — which is what a bilateral seal
218
+ * is, and is the condition that says the counterparty's closing leaf has landed here.
219
+ * Both predicates already exist in `seal-escalation.ts`; this reuses their shape rather than
220
+ * inventing a second opinion about the same question.
221
+ *
222
+ * When the carry IS self-evidently complete, a `leaf_count` that disagrees is no longer "I
223
+ * cannot tell" — it is the certificate describing a different leaf set, which is a MISMATCH.
224
+ */
225
+ const sequences = carry.map((l) => l.sequenceNumber).sort((a, b) => a - b);
226
+ const contiguousFromOne = sequences.every((n, i) => n === i + 1);
227
+ const ctrlSenders = new Set(carry.filter((l) => l.leafKind === LEAF_KIND_CTRL).map((l) => l.senderPubkeyHex));
228
+ const selfEvidentlyComplete = contiguousFromOne && ctrlSenders.size === 2;
229
+ /**
230
+ * 🚨 THE CERTIFICATE MAY COVER EXACTLY WHAT THIS SIDE HOLDS — ASK THAT FIRST.
231
+ *
232
+ * `DOD-M15-UNILATERAL-1`. The completeness predicate below describes a BILATERAL leaf set: two
233
+ * SEAL ctrl leaves, from two distinct senders. **A solo seal can never satisfy it**, because the
234
+ * counterparty is gone and never posts one — that is the entire premise. So on the solo path
235
+ * this returned `cannot_judge` every time, `session-ceremony.ts` refuses to co-sign on anything
236
+ * that is not `match`, and **the sealing party refused to co-sign its own unilateral seal.** The
237
+ * FROST ceremony never reached threshold, the directory never completed, and the close came back
238
+ * `seal_unilateral_timeout` — the label that names our own wait. Measured against the real
239
+ * binaries: `j-unilateral` failed on exactly this, with the directory having already verified the
240
+ * chain and recorded the counterparty ABSENT.
241
+ *
242
+ * Completeness was only ever needed to tell TWO KINDS OF DISAGREEMENT apart — "the roots differ
243
+ * because my carry is behind" (cannot judge) from "the roots differ because the directory
244
+ * certified something else" (mismatch). It answers nothing when the roots AGREE: a certificate
245
+ * whose root and leaf count are exactly what this daemon holds is, by construction, over this
246
+ * daemon's own leaves. Nothing is taken on trust — both values are recomputed here from the
247
+ * carry, and an adversary who could satisfy them would have to have produced this leaf set.
248
+ *
249
+ * Deliberately BOTH values. A count that disagreed while the root matched would be a certificate
250
+ * contradicting itself, and this is not the place to wave that through.
251
+ */
252
+ const carryInputs = carryContentHashInputs(carry);
253
+ if (carryInputs !== null &&
254
+ carry.length === certifiedLeafCount &&
255
+ Buffer.compare(Buffer.from(merkleRoot(buildMerkleTree(carryInputs))), Buffer.from(certifiedRoot)) === 0) {
256
+ return { verdict: "match" };
257
+ }
258
+ if (!selfEvidentlyComplete) {
259
+ return {
260
+ verdict: "cannot_judge",
261
+ reason: contiguousFromOne
262
+ ? `carry_incomplete: ${ctrlSenders.size} of 2 SEAL ctrl leaves witnessed here`
263
+ : `carry_noncontiguous: hold ${carry.length} leaves with a gap in the relay sequence`,
264
+ };
265
+ }
266
+ if (carry.length !== certifiedLeafCount) {
267
+ // The carry proves itself complete and the certificate claims a different size, so the
268
+ // certificate is over a different leaf set. Accusing is correct here.
269
+ return {
270
+ verdict: "mismatch",
271
+ ownRootHex: null, // no root computed — the sets differ in SIZE, which is decisive on its own
272
+ detail: `leaf_count_disagrees: this daemon holds a provably complete ${carry.length}-leaf set, the certificate claims ${certifiedLeafCount}`,
273
+ };
274
+ }
275
+ if (carryInputs === null) {
276
+ // A leaf this daemon cannot decode is a leaf it cannot judge. Never an accusation.
277
+ return { verdict: "cannot_judge", reason: "structure1_content_hash_unreadable" };
278
+ }
279
+ const ownRoot = merkleRoot(buildMerkleTree(carryInputs));
280
+ const ownRootHex = Buffer.from(ownRoot).toString("hex");
281
+ return Buffer.compare(Buffer.from(ownRoot), Buffer.from(certifiedRoot)) === 0
282
+ ? { verdict: "match" }
283
+ : { verdict: "mismatch", ownRootHex, detail: "root_disagrees: same leaf count, different leaves or different order" };
284
+ }
285
+ /**
286
+ * FED-OPTIONB-SEAL-001: the complete ordered leaf chain (both parties) a UNILATERAL seal carries to the
287
+ * directory for the OFFLINE tree rebuild. Empty when no leaves were logged (e.g. a direct-only session
288
+ * with no relay witness) — the caller then has nothing to carry and the seal stays bilateral/pending.
289
+ *
290
+ * ⚠️ Stranded on `verifyCertifiedRoot` before this split — the third block found doing that, and the
291
+ * reason the rule is now written down: a method reduced to a delegator leaves its documentation
292
+ * behind, and the block below it ends up carrying two descriptions of which the first is a stranger.
293
+ */
294
+ getSealCarry(agentPubkeyHex, sessionIdHex) {
295
+ if (!this.#ctx.sealLeafStore && this.#db) {
296
+ this.#ctx.sealLeafStore = new SessionSealLeafStore(this.#db, this.#ctx.logger);
297
+ }
298
+ return this.#ctx.sealLeafStore?.getCarry(agentPubkeyHex, sessionIdHex) ?? [];
299
+ }
300
+ /** Fix #1 EXTENSION: inject the broker-connection opener. Setter injection, same construction-order reason. */
301
+ setEnsureSealBroker(cb) {
302
+ this.#ctx.ensureSealBroker = cb;
303
+ }
304
+ /**
305
+ * DOD-M12B-INTERRUPTED-ESCALATE-1 — flip a session to `sealed`, synchronously, without needing a
306
+ * live node.
307
+ *
308
+ * **`destroySessionNode(…, "sealed")` cannot be relied on to do this.** It returns early at
309
+ * `if (!entry) return`, and the status write lives 26 lines BELOW that guard — so it flips the
310
+ * status only for a session that still has an `#activeNodes` entry. An interrupted session has
311
+ * none by construction: every producer of that status deletes the entry. Before this method, a
312
+ * unilateral seal on an interrupted session stored the notarized root and the certificate and
313
+ * left the row saying `interrupted` — the receipt landed and nothing that represents it moved.
314
+ * `cello_sessions` still showed it stuck, `cello_close_session` still refused it by name, and the
315
+ * restart-seal resolver re-selected it on the next boot to run the whole ceremony again against a
316
+ * session that already held a receipt.
317
+ *
318
+ * STATUS FIRST AND SYNCHRONOUS, teardown second — the order `abandonSession` uses and the one
319
+ * `retireSession` documents. The flip is the load-bearing half; the teardown makes memory agree
320
+ * with it. `#updateSessionStatus` also runs the terminal disposition hooks (held content is
321
+ * annexed, not stranded), which the early return skipped entirely.
322
+ */
323
+ markSealed(agentName, sessionId) {
324
+ // The terminal guard (abandoned/sealed must not be overwritten) lives in #updateSessionStatus,
325
+ // so it holds for destroySessionNode and retireSession too — not only for callers of this
326
+ // wrapper.
327
+ return this.#ctx.updateSessionStatus(agentName, sessionId, "sealed");
328
+ }
329
+ /**
330
+ * M8B FINDING-6 (cascade-2): persist a seal certificate for a session that may have NO local
331
+ * `sessions` row. recordSealCertificate (a manager delegator onto `session-queries.ts`) is an `UPDATE ... WHERE` — a SILENT no-op when the
332
+ * row is absent (the exact trap the cascade-2 reviewer flagged). The ABSENT party (B), learning of
333
+ * a seal on reconnect via seal_unilateral_notification, may never have persisted a row for this
334
+ * session. This ensures a minimal stub row first (INSERT OR IGNORE — a no-op if a row already
335
+ * exists, e.g. an 'interrupted' row after a restart) so B's receipt is actually durable + retrievable
336
+ * via cello_get_sealed_receipt. The counterparty pubkey is required by the schema (NOT NULL); B
337
+ * derives it from the notification's present_pubkey.
338
+ */
339
+ recordSealCertificateEnsuringRow(agentName, sessionId, counterpartyPubkeyHex, sealedRootHex, legibilityJson) {
340
+ if (!this.#db)
341
+ return;
342
+ const now = Date.now();
343
+ this.#db
344
+ .prepare(`INSERT OR IGNORE INTO sessions
345
+ (session_id, agent_id, counterparty_pubkey, status, created_at, updated_at)
346
+ VALUES (?, ?, ?, ?, ?, ?)`)
347
+ .run(sessionId, this.#ctx.requireAgentId(agentName), counterpartyPubkeyHex, "sealed", now, now);
348
+ this.#ctx.queries.recordSealCertificate(agentName, sessionId, sealedRootHex, legibilityJson);
349
+ }
350
+ /**
351
+ * M7-SESSION-001 (H-1): persist a verified bilateral SEAL-INTERRUPTED
352
+ * commitment and transition the session to 'seal_interrupted_pending'.
353
+ *
354
+ * This is NOT a seal. It records that both parties produced and exchanged
355
+ * K_local-signed SEAL-INTERRUPTED leaves over the same {leafCount, merkleRoot}.
356
+ * The FROST threshold notarization is a separate, currently-unwired step (see
357
+ * daemon.ts handleSealInterruptedFlow H-1 note), which is precisely why the
358
+ * status is 'seal_interrupted_pending' and never 'sealed'.
359
+ *
360
+ * The status update is guarded so it only advances a session out of the
361
+ * 'interrupted' state — it will not overwrite a 'sealed' row.
362
+ *
363
+ * @returns true if the session row was advanced to seal_interrupted_pending.
364
+ */
365
+ persistSealInterruptedCommitment(opts) {
366
+ if (!this.#db)
367
+ return false;
368
+ const now = Date.now();
369
+ try {
370
+ this.#db
371
+ .prepare(`INSERT OR REPLACE INTO seal_interrupted_artifacts
372
+ (agent_id, session_id, role, own_leaf, counterparty_leaf, merkle_root, nonce, created_at)
373
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
374
+ .run(this.#ctx.requireAgentId(opts.agentName), opts.sessionId, opts.role, JSON.stringify(opts.ownLeaf), JSON.stringify(opts.counterpartyLeaf), opts.merkleRoot, opts.nonce, now);
375
+ }
376
+ catch (err) {
377
+ this.#ctx.logger.error("session.interrupted.db.write.failed", {
378
+ sessionId: opts.sessionId,
379
+ error: err instanceof Error ? err.message : String(err),
380
+ });
381
+ return false;
382
+ }
383
+ // DAEMON-004: the bilateral commitment advances a session out of either
384
+ // 'interrupted' (SESSION-001 interrupted-seal flow) OR 'active' (DAEMON-004
385
+ // active-session seal). The guard still refuses to overwrite a terminal
386
+ // 'sealed' row or an already-pending one.
387
+ const result = this.#db
388
+ .prepare("UPDATE sessions SET status = 'seal_interrupted_pending', updated_at = ? WHERE agent_id = ? AND session_id = ? AND status IN ('active', 'interrupted')")
389
+ .run(now, this.#ctx.requireAgentId(opts.agentName), opts.sessionId);
390
+ const landed = Number(result.changes) > 0;
391
+ if (landed) {
392
+ // DOD-M12B-SESSION-SEED-1 (review F3): `seal_interrupted_pending` is NOT a state revival
393
+ // exists for, and the first build's comment wrongly grouped it with `interrupted`.
394
+ // `ingestReceivedContent` (in `session-content-ingest.ts`) refuses it outright, and BOTH
395
+ // sweeps that could otherwise close a
396
+ // session — `listRestartOrphanedSessions` and `listExpiredUnrevivableSessions` — filter
397
+ // `status = 'interrupted'`, so a pending-seal session is unrevivable AND unswept. Keeping its
398
+ // identity meant holding it until the process exited. Entry 42's own measurement is that 59%
399
+ // of seals that start never finish, so that is the common path, not a corner.
400
+ this.#ctx.destroySessionSeed(opts.agentName, opts.sessionId);
401
+ }
402
+ return landed;
403
+ }
404
+ /**
405
+ * M7 DOD-SPINE-7: submit THIS party's SEAL ctrl leaf (0x02) to the relay witness.
406
+ * Structure: content_hash = SHA-256(0x02 || encodeSealPayload({session_id, final_root,
407
+ * close_timestamp, "PENDING"})), where final_root is the daemon's OWN tree root. Two
408
+ * distinct-sender SEAL leaves in the relay's log trigger the relay's #maybeProcessSeal
409
+ * → directory processSeal (rebuild + verify the signed chain) → FROST notarization →
410
+ * session_sealed. Requires an active relay client; the caller falls back to the
411
+ * directory-mediated path when this returns relay_unavailable.
412
+ */
413
+ async submitSealLeaf(agentName, sessionId, correlationId) {
414
+ const sealKey = this.#ctx.sessionKey(agentName, sessionId);
415
+ // M12-P15: resolved, not required. See #resolveSealTransport — an interrupted session has no
416
+ // in-memory node BY CONSTRUCTION, and refusing here is what made the first fix inert.
417
+ const transport = this.#resolveSealTransport(agentName, sessionId);
418
+ if ("error" in transport)
419
+ return { ok: false, reason: transport.error };
420
+ const entry = transport;
421
+ /**
422
+ * DOD-M15-RELAYLEAK-1 — release a DETACHED seal transport when this submission is done.
423
+ *
424
+ * Only the detached branch registers a session here, so this can never remove a live one. The
425
+ * client is closed only when it has no sessions left and the cache still holds THIS client —
426
+ * the same two guards `#detachSessionRelay` uses, and for the same reason: a racing teardown
427
+ * must not close a freshly-built replacement for the same key.
428
+ */
429
+ const releaseDetached = () => {
430
+ if (transport.releaseOnDone !== true)
431
+ return;
432
+ try {
433
+ entry.relayClient.unregisterSession(sessionId);
434
+ if (!entry.relayClient.hasSessions()) {
435
+ for (const [key, cached] of this.#ctx.relayClients) {
436
+ if (cached === entry.relayClient) {
437
+ cached.close();
438
+ this.#ctx.relayClients.delete(key);
439
+ break;
440
+ }
441
+ }
442
+ }
443
+ }
444
+ catch (err) {
445
+ this.#ctx.logger.warn("session.seal.transport.release_failed", {
446
+ agentName,
447
+ sessionId,
448
+ reason: err instanceof Error ? err.message : String(err),
449
+ impact: "a detached seal relay client could not be released; it is held until process exit",
450
+ });
451
+ }
452
+ };
453
+ /**
454
+ * ⚠️ try/finally, NOT a call at each return. There are three exits today and adding a release
455
+ * to each would be a hand-kept list — the shape this milestone has been bitten by repeatedly,
456
+ * where the FOURTH exit added later quietly leaks. Here the release cannot be bypassed by a new
457
+ * return, and it runs on the throw path too, which is where a leak matters most.
458
+ */
459
+ try {
460
+ // M7-UPGRADE-002 idempotency: this party submits its responder SEAL leaf AT MOST ONCE per
461
+ // session. BOTH cello_close_session and the auto-acknowledge path call here; the first to reach
462
+ // this point wins, the second short-circuits. The check+set is SYNCHRONOUS (before any await) so
463
+ // two near-simultaneous triggers (e.g. B's own close racing A's delivered SEAL ctrl leaf) cannot
464
+ // both submit. Cleared below on a relay submit failure so a genuine retry can proceed.
465
+ // DOD-M12B-INTERRUPTED-ESCALATE-1 — THE MARK MUST SURVIVE A RESTART, or one automatic retry
466
+ // permanently forfeits the receipt.
467
+ //
468
+ // `#responderSealSubmitted` is in memory. A session whose close was in flight when the daemon
469
+ // stopped already has our SEAL ctrl leaf in the relay log — and on the next boot the mark is
470
+ // empty, so the restart-seal resolver's automatic close would submit a SECOND one. The
471
+ // directory requires exactly one ctrl leaf (`ctrlLeaves.length !== 1 → unilateral_seal_leaf_invalid`)
472
+ // and the carry is durable, so every future attempt would carry both and be refused forever.
473
+ //
474
+ // The durable evidence already exists and was simply not consulted: our own ctrl leaf is in
475
+ // `session_seal_leaves`. Recover the escalation values from it instead of submitting again.
476
+ if (!this.#ctx.responderSealSubmitted.has(sealKey)) {
477
+ const durable = this.#ctx.park.recoverOwnSealCtrlLeaf(agentName, sessionId);
478
+ if (durable === "unknown") {
479
+ // REFUSE, do not submit. A second ctrl leaf makes the session unsealable forever, and the
480
+ // question "is one already there?" just failed to answer. Refusing costs this close; a
481
+ // second leaf costs the receipt permanently.
482
+ return { ok: false, reason: "seal_leaf_recovery_unavailable" };
483
+ }
484
+ if (durable !== "none") {
485
+ this.#ctx.logger.info("session.seal.leaf.already_submitted.recovered", {
486
+ sessionId, agentName, sequenceNumber: durable.sequenceNumber,
487
+ impact: "our SEAL ctrl leaf is already in the relay log from a previous run; submitting a second would make this session unsealable forever",
488
+ });
489
+ this.#ctx.responderSealSubmitted.set(sealKey, durable);
490
+ }
491
+ }
492
+ if (this.#ctx.responderSealSubmitted.has(sealKey)) {
493
+ // M8B FINDING-1: carry the FIRST submit's reported root/sequence so a retry close can
494
+ // still escalate to a unilateral seal. A null value means that submit is still in
495
+ // flight — return the bare reason and let the caller fall back to the pending path.
496
+ const prior = this.#ctx.responderSealSubmitted.get(sealKey);
497
+ return prior
498
+ ? {
499
+ ok: false,
500
+ reason: "responder_seal_already_submitted",
501
+ reportedRootHex: prior.reportedRootHex,
502
+ sequenceNumber: prior.sequenceNumber,
503
+ }
504
+ : { ok: false, reason: "responder_seal_already_submitted" };
505
+ }
506
+ this.#ctx.responderSealSubmitted.set(sealKey, null);
507
+ // A throw anywhere before the mark is finalized would strand the null in-flight marker
508
+ // and lock every future close out of escalation (a FINDING-1-shaped deadlock via a
509
+ // different trigger) — clear the mark on any unexpected exception.
510
+ try {
511
+ const finalRootHex = this.#ctx.getSessionTreeRootHex(agentName, sessionId);
512
+ const sealPayload = encodeSealPayload({
513
+ session_id: entry.relaySessionIdBytes,
514
+ final_root: new Uint8Array(Buffer.from(finalRootHex, "hex")),
515
+ close_timestamp: Date.now(),
516
+ attestation: "PENDING",
517
+ });
518
+ // content_hash = SHA-256(0x02 || seal_payload) — the ctrl leaf kind byte is 0x02.
519
+ const contentHash = new Uint8Array(createHash("sha256").update(new Uint8Array([LEAF_KIND_CTRL])).update(sealPayload).digest());
520
+ /**
521
+ * ⚠️ `sealPayload` IS PASSED, AND ITS ABSENCE WAS THE WHOLE DEFECT — `DOD-M15-SEALWIRE-1`
522
+ * bullets 3+4, review pass 1, F1.
523
+ *
524
+ * These exact bytes were computed two lines above, hashed, and then dropped: `submitLeaf` had
525
+ * no parameter for them. So the directory received a SHA-256 pre-image nobody transmitted, and
526
+ * the client's SIGNED `final_root` — the one value in the seal the relay cannot produce — was
527
+ * unrecoverable. Four legs of this line shipped and were reviewed green while the head of the
528
+ * chain did not exist.
529
+ *
530
+ * The payload and the hash MUST come from the same derivation. If they ever diverge the
531
+ * directory reports `seal_payload_unbound`, whose guidance says *"someone between them and here
532
+ * altered or fabricated the payload — the relay is the only party on that path"* — a correct
533
+ * relay accused by name, in an error written to sound like an attack, for a mismatch made here.
534
+ */
535
+ const result = await entry.relayClient.submitLeaf(entry.node, entry.relaySessionIdBytes, contentHash, LEAF_KIND_CTRL, sealPayload);
536
+ if (!result.ok) {
537
+ // Clear the idempotency mark so a genuine retry (agent close / reconnect) can proceed (DB-001).
538
+ this.#ctx.responderSealSubmitted.delete(sealKey);
539
+ this.#ctx.logger.warn("session.seal.leaf.submit.failed", { sessionId, reason: result.reason, correlationId });
540
+ return { ok: false, reason: result.reason };
541
+ }
542
+ // SESSION-002: the reported_root for a unilateral seal is the content-hash root the
543
+ // local tree WOULD have with this SEAL ctrl leaf appended — the same root the directory
544
+ // rebuilds from the relay's content-hash chain (the relay records the identical
545
+ // content_hash for this ctrl leaf). Computed without mutating the durable tree /
546
+ // message_count, so the bilateral + interrupted seal paths are unaffected.
547
+ const contentHashHex = Buffer.from(contentHash).toString("hex");
548
+ const reportedRootHex = this.#ctx.getSessionTree(agentName, sessionId).rootWithAppendedHex(contentHashHex);
549
+ // M8B FINDING-1: durably associate the submit's escalation values with the idempotency
550
+ // mark, so any LATER close call can retrieve them via the already-submitted result.
551
+ this.#ctx.responderSealSubmitted.set(sealKey, { reportedRootHex, sequenceNumber: result.sequence_number });
552
+ this.#ctx.logger.info("session.seal.leaf.submitted", {
553
+ sessionId,
554
+ sequenceNumber: result.sequence_number,
555
+ correlationId,
556
+ });
557
+ // M7-UPGRADE-002: #responderSealSubmitted was set synchronously at the top of this method —
558
+ // the guard now blocks any second submit (auto-ack OR a redelivered counterparty SEAL ctrl leaf).
559
+ return { ok: true, sequenceNumber: result.sequence_number, reportedRootHex };
560
+ }
561
+ catch (err) {
562
+ this.#ctx.responderSealSubmitted.delete(sealKey);
563
+ throw err;
564
+ }
565
+ }
566
+ finally {
567
+ releaseDetached();
568
+ }
569
+ }
570
+ /**
571
+ * CELLO-M7-UPGRADE-001 (DOD-UP-1): readiness of a session for B to RATIFY a unilateral seal
572
+ * (the returning absent party). This is the SAME verifiability bar as the UP-2 auto-ack gate:
573
+ *
574
+ * - `known`: the session exists locally with its content (B has a transcript to ratify). After a
575
+ * restart B reloads it from SQLite, and autoRecoverForAgent re-pulls any parked content first.
576
+ * - `tampered`: the content cross-check flagged a content_hash mismatch (#contentDesynced) — B
577
+ * must NEVER ratify content it could not integrity-verify (the KERNEL refusal, AC-003).
578
+ *
579
+ * The directory separately verifies B's ack signature is genuine; B separately verifies the
580
+ * unilateral cert signature (R1 is authentic). NOTE: a full "B's frontier covers R1's tail"
581
+ * completeness check (the `desynced` reason) requires the deferred MSG-001-3b canonical-sequence
582
+ * reconciliation — same documented limitation as the UP-2 gate above.
583
+ */
584
+ getSealUpgradeReadiness(agentName, sessionId) {
585
+ const record = this.#ctx.queries.getSessionRecord(agentName, sessionId);
586
+ return {
587
+ known: !!record,
588
+ /**
589
+ * THE REASON TRAVELS WITH THE VERDICT — review F-A, correcting review F1's fix.
590
+ *
591
+ * F1 made the gate binary and moved the label into the log, and I did that in ONE consumer.
592
+ * This struct feeds a second one (`evaluateSealUpgrade`), which had only a boolean to read and
593
+ * therefore called every cause `content_tamper` — at ERROR, with no guidance. An honest peer on
594
+ * a newer build raised a security alarm on B's reconnect: exactly the harm the F1 fix was
595
+ * written to remove, reached through the consumer it did not check.
596
+ *
597
+ * Returning the LABEL rather than a boolean is what makes that impossible to reintroduce: a
598
+ * caller cannot flatten what it never receives flat.
599
+ */
600
+ unverifiable: this.#ctx.contentDesynced.get(this.#ctx.sessionKey(agentName, sessionId)) ?? null,
601
+ };
602
+ }
603
+ /**
604
+ * M7-UPGRADE-002: auto-acknowledge close (POSTMORTEM Workstream E / C-5). When B's daemon
605
+ * ingests the COUNTERPARTY's SEAL control leaf and B has verified the content, B's OWN node
606
+ * auto-co-signs + submits its responder SEAL leaf WITHOUT waiting for B's agent to call
607
+ * cello_close_session — so a bilateral seal completes promptly instead of degrading to
608
+ * unilateral on a slow/busy/crashed agent.
609
+ *
610
+ * SI-001 (non-negotiable): B's signature is ALWAYS produced by B's own node — submitSealLeaf
611
+ * signs the responder SEAL leaf with B's K_local. We remove the agent PROMPT, never the SIGNER;
612
+ * nothing here lets the directory or the peer synthesize B's acknowledgement.
613
+ *
614
+ * SI-002 (verifiability gate): auto-ack ONLY content B has verified. A session whose content
615
+ * cross-check failed (content_hash_mismatch = tamper, recorded in #contentDesynced) is NEVER
616
+ * auto-signed — it surfaces to the agent as a genuine decision point. DISAGREEMENT with the
617
+ * content is NOT a gate failure (C-6): the gate is "can I verify integrity?", never "do I agree?"
618
+ * — a verified-but-disliked tail is auto-sealed and the transcript speaks for B.
619
+ *
620
+ * Idempotent + non-throwing: marks #responderSealSubmitted BEFORE the async submit so a
621
+ * redelivered ctrl leaf cannot double-submit; clears the mark on submit failure so a later
622
+ * agent close / reconnect can still complete the seal (DB-001 — never a silent half-seal).
623
+ */
624
+ maybeAutoAcknowledgeSeal(agentName, sessionId, correlationId) {
625
+ const ackKey = this.#ctx.sessionKey(agentName, sessionId);
626
+ // Idempotency: at most one responder seal per session (auto-ack or agent close).
627
+ if (this.#ctx.responderSealSubmitted.has(ackKey))
628
+ return;
629
+ const record = this.#ctx.queries.getSessionRecord(agentName, sessionId);
630
+ // Only an ACTIVE session auto-acks. A committed/sealing/sealed/interrupted session is out of
631
+ // scope (already sealing, or needs the interrupted/upgrade path), not an auto-ack candidate.
632
+ if (!record || record.status !== "active")
633
+ return;
634
+ /**
635
+ * SI-002 verifiability gate: never auto-sign a session whose content we could not verify.
636
+ *
637
+ * ⚠️ THIS COMMENT SAID THE OPPOSITE UNTIL `DOD-M15-SEALWIRE-1` part B1 (review F-C). It read
638
+ * *"Today the ONLY tracked unverifiable cause is a content_hash mismatch = TAMPER"*, which was
639
+ * true when a mismatch was the only way to fail the cross-check and is false now: B1 added an
640
+ * unreadable algorithm name and a salted frame with no salt, and **both are ordinary**.
641
+ *
642
+ * Genuine tamper is a SECURITY event — ERROR, reason `content_tamper`, which is what the AC-008
643
+ * alarm keys on. The other two must NOT wear that name, or the alarm fires on an honest peer
644
+ * running a newer build and the operator learns to dismiss it.
645
+ *
646
+ * 🚨 AND `content_unverifiable` IS RESERVED — DO NOT REUSE IT HERE. It is specced for *parked
647
+ * content unrecoverable*, one of the two reasons (with `desynced`, B's tree behind the canonical
648
+ * sealed tail) awaiting the deferred MSG-001-3b canonical-sequence reconciliation. B1's first
649
+ * attempt emitted exactly that string for a different condition, which would have made the two
650
+ * indistinguishable in the log the day the follow-on landed. Hence
651
+ * `content_verification_unavailable`, deliberately distinct.
652
+ */
653
+ const unverifiable = this.#ctx.contentDesynced.get(ackKey);
654
+ if (unverifiable) {
655
+ /**
656
+ * DOD-M15-SEALWIRE-1 part B1 (review F1) — THE GATE FIRES FOR BOTH, THE ALARM ONLY FOR ONE.
657
+ *
658
+ * `content_tamper` is what the AC-008 alarm keys on, and it must keep meaning what it says. A
659
+ * frame we could not verify because the peer named an algorithm this build cannot read is an
660
+ * ordinary version difference; raising a tamper alarm for it would train an operator to
661
+ * dismiss the alarm, which costs more than the skew.
662
+ *
663
+ * But the REFUSAL TO AUTO-SIGN is identical in both cases and non-negotiable: SI-002 is "never
664
+ * auto-sign a session whose content we could not verify", and "could not" covers both.
665
+ */
666
+ this.#ctx.logger.error("session.seal.autoack.skipped", {
667
+ sessionId,
668
+ reason: unverifiable === "tampered" ? "content_tamper" : "content_verification_unavailable",
669
+ correlationId,
670
+ });
671
+ // AC-002: the verifiability gate refused — surface counterparty_closing to B's agent as a
672
+ // GENUINE decision point (the seal will not auto-complete; B must decide). Uses the existing
673
+ // session-state push to the live MCP clients; best-effort (never throws out of this gate).
674
+ try {
675
+ this.#ctx.onSessionStateChanged?.(record.agent_name, sessionId, "counterparty_closing", record.counterparty_pubkey);
676
+ }
677
+ catch (err) {
678
+ this.#ctx.logger.debug("session.state.notify.failed", {
679
+ sessionId,
680
+ reason: err instanceof Error ? err.message : String(err),
681
+ });
682
+ }
683
+ return;
684
+ }
685
+ const entry = this.#ctx.activeNodes.get(ackKey);
686
+ const responderPubkey = entry?.relayClient?.senderPubkeyHex ?? "unknown";
687
+ // submitSealLeaf owns the #responderSealSubmitted idempotency mark (set synchronously at its
688
+ // top), so the auto-ack does not pre-mark — it just reacts to the result.
689
+ // Establish the broker visiting connection BEFORE submitting, not after. The directory acts on
690
+ // the leaf in ~60ms and pushes `seal_verified` straight back; if the stream is not up by then it
691
+ // defers the frame and the seal stalls. Proven on GCP: leaf submitted 18:41:56.555, directory
692
+ // deferred at 18:41:56.615 (initiator_stream_absent), the explicit-close path opened the
693
+ // connection at 18:42:01.529 — five seconds too late.
694
+ void (async () => {
695
+ let sealBrokerConn = null;
696
+ try {
697
+ sealBrokerConn = (await this.#ctx.ensureSealBroker?.(agentName, sessionId)) ?? null;
698
+ }
699
+ catch (err) {
700
+ // Best-effort: a same-node session needs no visiting connection, and a failure here must not
701
+ // suppress the seal leaf — losing the leaf is strictly worse than racing the push.
702
+ this.#ctx.logger.warn("session.seal.autoack.broker.failed", {
703
+ sessionId,
704
+ correlationId,
705
+ reason: err instanceof Error ? err.message : String(err),
706
+ });
707
+ }
708
+ const submitted = await this.submitSealLeaf(agentName, sessionId, correlationId);
709
+ // RELEASE AFTER A GRACE WINDOW, not when the submit resolves.
710
+ //
711
+ // `submitSealLeaf` settles at the relay ack plus a local root computation — milliseconds — while
712
+ // the frame this connection exists to catch arrives ~60ms LATER (the timeline in the comment
713
+ // above is the measurement). Releasing on submit therefore closed the stream before the push it
714
+ // was opened for, which is the stall it was written to prevent.
715
+ //
716
+ // It is worse than a lost race. The directory drains its DURABLE notification queue on ANY
717
+ // stream that authenticates — visiting included — and DELETES each row once sent. So a visiting
718
+ // stream that authenticates and dies milliseconds later invites the directory to send-and-delete
719
+ // queued seal frames into a closing stream: the receipt is gone from the queue and never
720
+ // arrived. That is permanent loss, not a retry.
721
+ //
722
+ // The close path holds its connection around the entire bilateral wait; this path has no waiter
723
+ // to hang off, so it uses a bounded grace instead — generous against a ~60ms push, and bounded
724
+ // so a stalled seal cannot leak the connection. Unref'd: it must never hold the process open.
725
+ if (sealBrokerConn) {
726
+ const conn = sealBrokerConn;
727
+ const t = setTimeout(() => { void conn.stop("autoack-seal-grace-elapsed").catch(() => { }); }, AUTOACK_BROKER_GRACE_MS);
728
+ t.unref?.();
729
+ }
730
+ return submitted;
731
+ })()
732
+ .then((result) => {
733
+ if (result.ok) {
734
+ // SI-001: the responder SEAL leaf was signed by B's OWN node (K_local) in submitSealLeaf.
735
+ this.#ctx.logger.info("session.seal.autoacknowledged", {
736
+ sessionId,
737
+ responderPubkey,
738
+ correlationId,
739
+ });
740
+ }
741
+ else if (result.reason === "responder_seal_already_submitted") {
742
+ // B's agent close already submitted the responder seal (it won the race) — nothing to do.
743
+ return;
744
+ }
745
+ else {
746
+ // Submission failed (e.g. relay path down) — the agent close / reconnect can still
747
+ // complete the seal; never a silent half-seal (DB-001).
748
+ this.#ctx.logger.warn("session.seal.autoack.skipped", {
749
+ sessionId,
750
+ reason: result.reason,
751
+ correlationId,
752
+ });
753
+ }
754
+ })
755
+ .catch((err) => {
756
+ this.#ctx.logger.warn("session.seal.autoack.skipped", {
757
+ sessionId,
758
+ reason: err instanceof Error ? err.message : String(err),
759
+ correlationId,
760
+ });
761
+ });
762
+ }
763
+ /**
764
+ * M12-P14: is this side's chain COMPLETE enough to be sealed?
765
+ *
766
+ * A seal is a bilateral signature over the same conversation, so a side that is missing a leaf
767
+ * cannot produce a signable one — the counterparty compares frontiers and refuses with
768
+ * `leaf_count_mismatch`. That refusal is correct and it is also terminal: there is no backfill
769
+ * request in the protocol, so the only exit is a force-abandon, which yields NO notarized receipt.
770
+ * Measured 2026-08-05 on two sessions that died exactly this way (initiator 2 leaves, responder 3).
771
+ *
772
+ * The cheap prevention is to notice BEFORE asking. Two local signals already exist and, until now,
773
+ * nothing read either of them at close time:
774
+ * - `#highWaterSeq` — the largest canonical sequence the RELAY has witnessed for this session.
775
+ * The relay is the ordering authority, so a high-water above our own frontier is proof that a
776
+ * leaf exists which we have not appended. (Its own doc comment called it "reserved … NOT yet
777
+ * consumed by the gate" — this is that consumer.)
778
+ * - `#heldContent` — content we HAVE received and verified but cannot append because it sits
779
+ * behind a gap. Holding content and sealing anyway would seal a chain we know is short.
780
+ *
781
+ * Deliberately NOT a network call: it must work when the counterparty is unreachable, which is
782
+ * the whole situation a seal-interrupted exists for.
783
+ *
784
+ * KNOWN LIMIT, stated rather than hidden: both maps are in-memory and cleared on teardown, so
785
+ * after a daemon restart this returns ready for a session whose gap predates the restart — which
786
+ * is the shape of the 2026-08-05 incident itself. Closing that needs the mailbox drained (or the
787
+ * high-water persisted) before the check; tracked with M12-P14, not claimed here.
788
+ */
789
+ sealReadiness(agentName, sessionId) {
790
+ const key = this.#ctx.sessionKey(agentName, sessionId);
791
+ // DOD-M12B-STRAND-1: hydrate first. An under-counted `heldCount` reports a gapped session as
792
+ // READY, and this gate's whole purpose is to stop a short chain being signed — the counterparty
793
+ // answers `leaf_count_mismatch`, which is TERMINAL and costs the receipt permanently. Failing
794
+ // open here is the one outcome worse than refusing a healthy close.
795
+ //
796
+ // Hydrate WITHOUT releasing: this is a read path now — the status surface asks it for every
797
+ // active session — and a read that appends leaves, advances the root and rings the doorbell is
798
+ // a diagnostic command that delivers messages. The release still runs on every path that was
799
+ // going to mutate anyway.
800
+ this.#ctx.held.ensureHeldRestored(agentName, sessionId, { release: false });
801
+ const treeSize = this.#ctx.getSessionTree(agentName, sessionId).size();
802
+ const highWaterSeq = this.#ctx.highWaterSeq.get(key) ?? -1;
803
+ const heldCount = this.#ctx.heldContent.get(key)?.size ?? 0;
804
+ // Review HIGH-2: NOT `(highWaterSeq + 1) - treeSize`. That subtraction silently assumes the
805
+ // relay's sequence space and this tree's index space count the same things, and they do not:
806
+ // `relay-node.ts` increments seq_counter for EVERY accepted leaf including CTRL (0x02), while
807
+ // NOTHING APPENDS A CTRL LEAF TO THIS TREE — `submitSealLeaf` deliberately computes its root
808
+ // without mutating the durable tree. So one seal ctrl leaf offsets the two spaces permanently,
809
+ // and any msg witnessed afterwards would read as a missing leaf FOREVER. That is a false
810
+ // positive, and a false positive here is worse than the bug it guards: it makes a healthy
811
+ // session unsealable, leaving force-abandon (no receipt) as the only exit. `seal-upgrade.ts`
812
+ //
813
+ // ⚠️ THIS SENTENCE USED TO READ *"`appendSessionLeaf` is only ever called with 'msg'"*, AND THAT
814
+ // WAS FALSE WHEN IT WAS WRITTEN — corrected, not deleted, per review pass 1 F8. Line ~7730
815
+ // appends "doc", and `WritableSessionTreeLeafKind` permits "ctrl" outright, so the type does not
816
+ // enforce it either. Doc leaves are harmless to this subtraction because they are witnessed by
817
+ // the relay too (LEAF_KIND_DOC) and counted in BOTH spaces; the load-bearing property is only
818
+ // ever about CTRL. Stating it as "msg only" made a narrower claim than the code supports and a
819
+ // stronger one than it holds — so a reader checking it would find a counter-example, conclude
820
+ // the reasoning was stale, and be one step from "fixing" the subtraction back.
821
+ // already documents the same `leaf_count - 1` offset.
822
+ //
823
+ // `#witnessedSeq` answers the question directly instead of inferring it. It gains an entry when
824
+ // the relay witnesses a COUNTERPARTY msg leaf (ctrl leaves are excluded at the call site) and
825
+ // loses it the moment that leaf is appended. So its remaining size IS the count of leaves the
826
+ // ordering authority has committed and this tree has not — no arithmetic, no space mismatch,
827
+ // and it cannot go negative.
828
+ const missingLeaves = this.#ctx.witnessedSeq.get(key)?.size ?? 0;
829
+ // DOD-M12B-INDEX-1: our OWN held sends are counted separately. They block a seal just as a
830
+ // received hold does, but they are not "a message from the counterparty that has not arrived" —
831
+ // and a refusal that calls them that tells the operator to wait for something already in hand,
832
+ // which is how a close-retry loop ends at force-abandon and no receipt.
833
+ let heldOwn = 0;
834
+ for (const e of this.#ctx.heldContent.get(key)?.values() ?? [])
835
+ if (e.origin === "sent")
836
+ heldOwn++;
837
+ // DOD-M15-DIVERGE-1: the third term, and the one that closes the asymmetry. `#diverged` is set
838
+ // only where the parting is PROVEN — an ack came back behind our frontier — never where it is
839
+ // merely suspected, because a gate that refuses a healthy session forever is worse than the bug
840
+ // it guards: force-abandon, with no receipt, becomes the only exit.
841
+ const diverged = this.#ctx.records.isSessionDiverged(agentName, sessionId);
842
+ return {
843
+ ready: missingLeaves === 0 && heldCount === 0 && !diverged,
844
+ treeSize, highWaterSeq, heldCount, missingLeaves,
845
+ heldOwn, heldReceived: heldCount - heldOwn,
846
+ diverged,
847
+ };
848
+ }
849
+ /**
850
+ * DOD-M12B-SEAL-STUCK-1 — the operator-facing answer to "can this session be closed?".
851
+ *
852
+ * THREE STATES, because there are three answers. `sealReadiness` above returns a boolean plus raw
853
+ * counters, and both of its counters are easy to misread on a surface:
854
+ *
855
+ * - `missingLeaves` is `#witnessedSeq.size`, which is every position the relay witnessed that
856
+ * this tree has not appended — and a HELD frame keeps its witness entry. So it INCLUDES the
857
+ * held ones. Reporting it beside `heldCount` counts the same message twice and labels one copy
858
+ * "never received" when it is sitting on our own disk. Split here into what each actually is.
859
+ * - Neither counter survives a restart on its own: `#witnessedSeq` is memory-only. Held content
860
+ * is durable since DOD-M12B-STRAND-1, but a position the relay witnessed for content that
861
+ * never arrived leaves no trace. So for a session carrying leaves this process did not watch
862
+ * arrive, "clean" is unknowable — and saying `ready` there invites a close that gets
863
+ * `leaf_count_mismatch` back, which is terminal and costs the receipt for good.
864
+ */
865
+ sealReadinessView(agentName, sessionId) {
866
+ const key = this.#ctx.sessionKey(agentName, sessionId);
867
+ const r = this.sealReadiness(agentName, sessionId);
868
+ // DOD-M15-DIVERGE-1: READ THIS BEFORE THE `!ready` BRANCH, and the order is load-bearing.
869
+ // `diverged` is now a term in `ready`, so a diverged session reaches `!ready` — where it would
870
+ // report `blocked` with awaitingArrival and heldBehindGap both ZERO, replacing an accurate,
871
+ // specific answer with one that describes nothing and invites a retry that can never work.
872
+ // Divergence is permanent and has its own state; the gap cases below are the ones that resolve.
873
+ if (r.diverged) {
874
+ // NOT `ready`. The tree is ahead of the relay's counter for good, so a close here signs a root
875
+ // the counterparty answers `leaf_count_mismatch` to — terminal, and the receipt is gone. The
876
+ // raw counters cannot see this: nothing is missing and nothing is held.
877
+ return { state: "unknown", reason: "record_diverged_from_relay" };
878
+ }
879
+ if (!r.ready) {
880
+ const oldestHeldMs = this.#ctx.queries.oldestHeldMs(agentName, sessionId);
881
+ return {
882
+ state: "blocked",
883
+ // The witness map counts a held frame until it is appended, so subtract the RECEIVED holds
884
+ // to avoid reporting one message twice. NOT the own-sends: the witness map only ever
885
+ // carries counterparty leaves, so subtracting ours would push this count below the truth.
886
+ awaitingArrival: Math.max(0, r.missingLeaves - r.heldReceived),
887
+ heldBehindGap: r.heldCount,
888
+ oldestHeldMs,
889
+ };
890
+ }
891
+ if (r.treeSize > 0 && !this.#ctx.orderingObserved.has(key)) {
892
+ return {
893
+ state: "unknown",
894
+ reason: "witness_state_predates_daemon_start",
895
+ };
896
+ }
897
+ return { state: "ready" };
898
+ }
899
+ /**
900
+ * WHERE THE MUTUALLY-SIGNED PREFIX ENDS, DERIVED FROM THIS DAEMON'S OWN LEAVES —
901
+ * `DOD-M15-UNILATERAL-1`, review F2.
902
+ *
903
+ * ⚠️ **THE FIRST VERSION COMPUTED THIS FROM THE CERTIFICATE'S OWN PARTICIPANT LIST, AND CALLED
904
+ * THAT "recomputed, cannot be steered".** It could be steered. On the SOLO path the certificate's
905
+ * TBS binds no legibility at all, and the client verifies only the *live* party's frontier — so
906
+ * the absent party's `content_frontier_seq` and every `last_authored_seq` arrived unchecked. One
907
+ * directory node could publish the absent party's frontier as 3 and the receipt would say
908
+ * "mutually signed through 3" over a transcript that party never signed for. That is the precise
909
+ * conflation this field exists to prevent, reintroduced by the field itself.
910
+ *
911
+ * The carry answers it without trusting anybody. This daemon holds the counterparty's own leaves,
912
+ * each carrying, inside the bytes THEY signed, both the sequence they authored and the
913
+ * `last_seen_seq` they acknowledged. So a party's commitment reaches
914
+ * `max(highest sequence they authored, highest sequence they acknowledged)`, and the transcript is
915
+ * mutually signed only as far as the LEAST-committed party reaches.
916
+ *
917
+ * Fewer than two distinct authors ⇒ `0`: nobody countersigned anything, which is the honest floor
918
+ * for a conversation where the other side only ever received. `null` when the carry is empty or
919
+ * unreadable — the caller must then publish NO boundary rather than fall back to a number
920
+ * somebody else supplied.
921
+ */
922
+ countersignedThroughSeqFromCarry(agentPubkeyHex, sessionIdHex) {
923
+ const carry = this.getSealCarry(agentPubkeyHex, sessionIdHex);
924
+ if (carry.length === 0)
925
+ return null;
926
+ const reach = new Map();
927
+ for (const leaf of carry) {
928
+ // Structure 1 = [version, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp],
929
+ // plus last_seen_hash at 6 on a v2 claim. `last_seen_seq` is index 4 in both — 020-ACKHASH
930
+ // APPENDS, so this read did not move. The hash is not consulted here: this derives a
931
+ // POSITIONAL boundary, which is the job last_seen_seq keeps doing alongside the new field.
932
+ const s1 = decodeStructure1(leaf.structure1Cbor);
933
+ // Unreadable, or a layout this build cannot name: publish no boundary rather than a
934
+ // half-derived one somebody else could have shaped.
935
+ if (!s1.ok)
936
+ return null;
937
+ const signedLastSeen = Number.isFinite(Number(s1.fields.lastSeenSeq))
938
+ ? Number(s1.fields.lastSeenSeq)
939
+ : 0;
940
+ const prior = reach.get(leaf.senderPubkeyHex) ?? 0;
941
+ reach.set(leaf.senderPubkeyHex, Math.max(prior, leaf.sequenceNumber, signedLastSeen));
942
+ }
943
+ if (reach.size < 2)
944
+ return 0;
945
+ return Math.min(...reach.values());
946
+ }
947
+ }
948
+ //# sourceMappingURL=session-seal.js.map