@cello-protocol/daemon 0.0.227 → 0.0.228

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 (895) 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 +73 -0
  4. package/dist/agent-admin-handlers.js.map +1 -0
  5. package/dist/agent-handlers.d.ts +72 -0
  6. package/dist/agent-handlers.d.ts.map +1 -0
  7. package/dist/agent-handlers.js +604 -0
  8. package/dist/agent-handlers.js.map +1 -0
  9. package/dist/agent-id-migration.d.ts +70 -0
  10. package/dist/agent-id-migration.d.ts.map +1 -0
  11. package/dist/agent-id-migration.js +468 -0
  12. package/dist/agent-id-migration.js.map +1 -0
  13. package/dist/agent-loader.d.ts +37 -0
  14. package/dist/agent-loader.d.ts.map +1 -0
  15. package/dist/agent-loader.js +37 -0
  16. package/dist/agent-loader.js.map +1 -0
  17. package/dist/agent-selection-root.d.ts +29 -0
  18. package/dist/agent-selection-root.d.ts.map +1 -0
  19. package/dist/agent-selection-root.js +126 -0
  20. package/dist/agent-selection-root.js.map +1 -0
  21. package/dist/agent-selection.d.ts +73 -0
  22. package/dist/agent-selection.d.ts.map +1 -0
  23. package/dist/agent-selection.js +80 -0
  24. package/dist/agent-selection.js.map +1 -0
  25. package/dist/agent-settings-keys.d.ts +52 -0
  26. package/dist/agent-settings-keys.d.ts.map +1 -0
  27. package/dist/agent-settings-keys.js +114 -0
  28. package/dist/agent-settings-keys.js.map +1 -0
  29. package/dist/agent-state.d.ts +67 -0
  30. package/dist/agent-state.d.ts.map +1 -0
  31. package/dist/agent-state.js +66 -0
  32. package/dist/agent-state.js.map +1 -0
  33. package/dist/assignment-verify.d.ts +90 -0
  34. package/dist/assignment-verify.d.ts.map +1 -0
  35. package/dist/assignment-verify.js +301 -0
  36. package/dist/assignment-verify.js.map +1 -0
  37. package/dist/attendance-wiring.d.ts +23 -0
  38. package/dist/attendance-wiring.d.ts.map +1 -0
  39. package/dist/attendance-wiring.js +248 -0
  40. package/dist/attendance-wiring.js.map +1 -0
  41. package/dist/attestation-body.d.ts +47 -0
  42. package/dist/attestation-body.d.ts.map +1 -0
  43. package/dist/attestation-body.js +71 -0
  44. package/dist/attestation-body.js.map +1 -0
  45. package/dist/authorship-verification.d.ts +91 -0
  46. package/dist/authorship-verification.d.ts.map +1 -0
  47. package/dist/authorship-verification.js +532 -0
  48. package/dist/authorship-verification.js.map +1 -0
  49. package/dist/away-detection.d.ts +139 -0
  50. package/dist/away-detection.d.ts.map +1 -0
  51. package/dist/away-detection.js +186 -0
  52. package/dist/away-detection.js.map +1 -0
  53. package/dist/away-inbox-oneshot.d.ts +40 -0
  54. package/dist/away-inbox-oneshot.d.ts.map +1 -0
  55. package/dist/away-inbox-oneshot.js +311 -0
  56. package/dist/away-inbox-oneshot.js.map +1 -0
  57. package/dist/backup-restore-handlers.d.ts +10 -0
  58. package/dist/backup-restore-handlers.d.ts.map +1 -0
  59. package/dist/backup-restore-handlers.js +82 -0
  60. package/dist/backup-restore-handlers.js.map +1 -0
  61. package/dist/backup-restore.d.ts +102 -0
  62. package/dist/backup-restore.d.ts.map +1 -0
  63. package/dist/backup-restore.js +341 -0
  64. package/dist/backup-restore.js.map +1 -0
  65. package/dist/bin/cello-daemon.d.ts +13 -0
  66. package/dist/bin/cello-daemon.d.ts.map +1 -0
  67. package/dist/bin/cello-daemon.js.map +1 -0
  68. package/dist/boot-agents.d.ts +64 -0
  69. package/dist/boot-agents.d.ts.map +1 -0
  70. package/dist/boot-agents.js +241 -0
  71. package/dist/boot-agents.js.map +1 -0
  72. package/dist/boot-connection-state.d.ts +38 -0
  73. package/dist/boot-connection-state.d.ts.map +1 -0
  74. package/dist/boot-connection-state.js +95 -0
  75. package/dist/boot-connection-state.js.map +1 -0
  76. package/dist/boot-core.d.ts +36 -0
  77. package/dist/boot-core.d.ts.map +1 -0
  78. package/dist/boot-core.js +293 -0
  79. package/dist/boot-core.js.map +1 -0
  80. package/dist/boot-parked-content.d.ts +34 -0
  81. package/dist/boot-parked-content.d.ts.map +1 -0
  82. package/dist/boot-parked-content.js +528 -0
  83. package/dist/boot-parked-content.js.map +1 -0
  84. package/dist/boot-sweeps.d.ts +22 -0
  85. package/dist/boot-sweeps.d.ts.map +1 -0
  86. package/dist/boot-sweeps.js +51 -0
  87. package/dist/boot-sweeps.js.map +1 -0
  88. package/dist/bundled-consortium-manifest.d.ts +47 -0
  89. package/dist/bundled-consortium-manifest.d.ts.map +1 -0
  90. package/dist/bundled-consortium-manifest.js +116 -0
  91. package/dist/bundled-consortium-manifest.js.map +1 -0
  92. package/dist/cbor-blob-migration.d.ts +8 -0
  93. package/dist/cbor-blob-migration.d.ts.map +1 -0
  94. package/dist/cbor-blob-migration.js +116 -0
  95. package/dist/cbor-blob-migration.js.map +1 -0
  96. package/dist/challenge-verifier.d.ts +12 -0
  97. package/dist/challenge-verifier.d.ts.map +1 -0
  98. package/dist/challenge-verifier.js +11 -0
  99. package/dist/challenge-verifier.js.map +1 -0
  100. package/dist/channel-log-store.d.ts +80 -0
  101. package/dist/channel-log-store.d.ts.map +1 -0
  102. package/dist/channel-log-store.js +290 -0
  103. package/dist/channel-log-store.js.map +1 -0
  104. package/dist/close-commitment.d.ts +67 -0
  105. package/dist/close-commitment.d.ts.map +1 -0
  106. package/dist/close-commitment.js +78 -0
  107. package/dist/close-commitment.js.map +1 -0
  108. package/dist/close-session-handler.d.ts +94 -0
  109. package/dist/close-session-handler.d.ts.map +1 -0
  110. package/dist/close-session-handler.js +1418 -0
  111. package/dist/close-session-handler.js.map +1 -0
  112. package/dist/co-attendance.d.ts +29 -0
  113. package/dist/co-attendance.d.ts.map +1 -0
  114. package/dist/co-attendance.js +32 -0
  115. package/dist/co-attendance.js.map +1 -0
  116. package/dist/column-birth.d.ts +40 -0
  117. package/dist/column-birth.d.ts.map +1 -0
  118. package/dist/column-birth.js +61 -0
  119. package/dist/column-birth.js.map +1 -0
  120. package/dist/connect-or-start.d.ts +25 -0
  121. package/dist/connect-or-start.d.ts.map +1 -0
  122. package/dist/connect-or-start.js +175 -0
  123. package/dist/connect-or-start.js.map +1 -0
  124. package/dist/connection-agents.d.ts +16 -0
  125. package/dist/connection-agents.d.ts.map +1 -0
  126. package/dist/connection-agents.js +68 -0
  127. package/dist/connection-agents.js.map +1 -0
  128. package/dist/consent-migration.d.ts +49 -0
  129. package/dist/consent-migration.d.ts.map +1 -0
  130. package/dist/consent-migration.js +128 -0
  131. package/dist/consent-migration.js.map +1 -0
  132. package/dist/consortium-bootstrap.d.ts +138 -0
  133. package/dist/consortium-bootstrap.d.ts.map +1 -0
  134. package/dist/consortium-bootstrap.js +339 -0
  135. package/dist/consortium-bootstrap.js.map +1 -0
  136. package/dist/consortium-fingerprint.d.ts +115 -0
  137. package/dist/consortium-fingerprint.d.ts.map +1 -0
  138. package/dist/consortium-fingerprint.js +175 -0
  139. package/dist/consortium-fingerprint.js.map +1 -0
  140. package/dist/contact-handlers.d.ts +59 -0
  141. package/dist/contact-handlers.d.ts.map +1 -0
  142. package/dist/contact-handlers.js +343 -0
  143. package/dist/contact-handlers.js.map +1 -0
  144. package/dist/contact-pubkey-case.d.ts +65 -0
  145. package/dist/contact-pubkey-case.d.ts.map +1 -0
  146. package/dist/contact-pubkey-case.js +136 -0
  147. package/dist/contact-pubkey-case.js.map +1 -0
  148. package/dist/contacts-tier-migration.d.ts +90 -0
  149. package/dist/contacts-tier-migration.d.ts.map +1 -0
  150. package/dist/contacts-tier-migration.js +150 -0
  151. package/dist/contacts-tier-migration.js.map +1 -0
  152. package/dist/content-encryption-status.d.ts +111 -0
  153. package/dist/content-encryption-status.d.ts.map +1 -0
  154. package/dist/content-encryption-status.js +158 -0
  155. package/dist/content-encryption-status.js.map +1 -0
  156. package/dist/content-park-client.d.ts +90 -0
  157. package/dist/content-park-client.d.ts.map +1 -0
  158. package/dist/content-park-client.js +362 -0
  159. package/dist/content-park-client.js.map +1 -0
  160. package/dist/content-park.d.ts +52 -0
  161. package/dist/content-park.d.ts.map +1 -0
  162. package/dist/content-park.js +1309 -0
  163. package/dist/content-park.js.map +1 -0
  164. package/dist/cross-node-negotiation.d.ts +44 -0
  165. package/dist/cross-node-negotiation.d.ts.map +1 -0
  166. package/dist/cross-node-negotiation.js +34 -0
  167. package/dist/cross-node-negotiation.js.map +1 -0
  168. package/dist/daemon-handle.d.ts +57 -0
  169. package/dist/daemon-handle.d.ts.map +1 -0
  170. package/dist/daemon-handle.js +2 -0
  171. package/dist/daemon-handle.js.map +1 -0
  172. package/dist/daemon-status-report.d.ts +50 -0
  173. package/dist/daemon-status-report.d.ts.map +1 -0
  174. package/dist/daemon-status-report.js +81 -0
  175. package/dist/daemon-status-report.js.map +1 -0
  176. package/dist/daemon.d.ts +45 -0
  177. package/dist/daemon.d.ts.map +1 -0
  178. package/dist/daemon.js +1177 -0
  179. package/dist/daemon.js.map +1 -0
  180. package/dist/db-identity-store.d.ts +159 -0
  181. package/dist/db-identity-store.d.ts.map +1 -0
  182. package/dist/db-identity-store.js +502 -0
  183. package/dist/db-identity-store.js.map +1 -0
  184. package/dist/delivery-open-registry.d.ts +92 -0
  185. package/dist/delivery-open-registry.d.ts.map +1 -0
  186. package/dist/delivery-open-registry.js +121 -0
  187. package/dist/delivery-open-registry.js.map +1 -0
  188. package/dist/delivery-session-suspects.d.ts +56 -0
  189. package/dist/delivery-session-suspects.d.ts.map +1 -0
  190. package/dist/delivery-session-suspects.js +94 -0
  191. package/dist/delivery-session-suspects.js.map +1 -0
  192. package/dist/directory-auth-posture.d.ts +87 -0
  193. package/dist/directory-auth-posture.d.ts.map +1 -0
  194. package/dist/directory-auth-posture.js +134 -0
  195. package/dist/directory-auth-posture.js.map +1 -0
  196. package/dist/directory-bootstrap.d.ts +310 -0
  197. package/dist/directory-bootstrap.d.ts.map +1 -0
  198. package/dist/directory-bootstrap.js +557 -0
  199. package/dist/directory-bootstrap.js.map +1 -0
  200. package/dist/directory-connect.d.ts +27 -0
  201. package/dist/directory-connect.d.ts.map +1 -0
  202. package/dist/directory-connect.js +106 -0
  203. package/dist/directory-connect.js.map +1 -0
  204. package/dist/disconnect-cleanup.d.ts +51 -0
  205. package/dist/disconnect-cleanup.d.ts.map +1 -0
  206. package/dist/disconnect-cleanup.js +72 -0
  207. package/dist/disconnect-cleanup.js.map +1 -0
  208. package/dist/document-amendment-store.d.ts +120 -0
  209. package/dist/document-amendment-store.d.ts.map +1 -0
  210. package/dist/document-amendment-store.js +266 -0
  211. package/dist/document-amendment-store.js.map +1 -0
  212. package/dist/document-delivery-transport.d.ts +168 -0
  213. package/dist/document-delivery-transport.d.ts.map +1 -0
  214. package/dist/document-delivery-transport.js +206 -0
  215. package/dist/document-delivery-transport.js.map +1 -0
  216. package/dist/document-engine.d.ts +134 -0
  217. package/dist/document-engine.d.ts.map +1 -0
  218. package/dist/document-engine.js +282 -0
  219. package/dist/document-engine.js.map +1 -0
  220. package/dist/document-flag.d.ts +58 -0
  221. package/dist/document-flag.d.ts.map +1 -0
  222. package/dist/document-flag.js +70 -0
  223. package/dist/document-flag.js.map +1 -0
  224. package/dist/document-frame-router.d.ts +245 -0
  225. package/dist/document-frame-router.d.ts.map +1 -0
  226. package/dist/document-frame-router.js +397 -0
  227. package/dist/document-frame-router.js.map +1 -0
  228. package/dist/document-gate-wiring.d.ts +61 -0
  229. package/dist/document-gate-wiring.d.ts.map +1 -0
  230. package/dist/document-gate-wiring.js +125 -0
  231. package/dist/document-gate-wiring.js.map +1 -0
  232. package/dist/document-gate.d.ts +149 -0
  233. package/dist/document-gate.d.ts.map +1 -0
  234. package/dist/document-gate.js +509 -0
  235. package/dist/document-gate.js.map +1 -0
  236. package/dist/document-handlers.d.ts +47 -0
  237. package/dist/document-handlers.d.ts.map +1 -0
  238. package/dist/document-handlers.js +2203 -0
  239. package/dist/document-handlers.js.map +1 -0
  240. package/dist/document-handshake.d.ts +176 -0
  241. package/dist/document-handshake.d.ts.map +1 -0
  242. package/dist/document-handshake.js +452 -0
  243. package/dist/document-handshake.js.map +1 -0
  244. package/dist/document-inbound.d.ts +162 -0
  245. package/dist/document-inbound.d.ts.map +1 -0
  246. package/dist/document-inbound.js +530 -0
  247. package/dist/document-inbound.js.map +1 -0
  248. package/dist/document-json.d.ts +120 -0
  249. package/dist/document-json.d.ts.map +1 -0
  250. package/dist/document-json.js +191 -0
  251. package/dist/document-json.js.map +1 -0
  252. package/dist/document-layer.d.ts +215 -0
  253. package/dist/document-layer.d.ts.map +1 -0
  254. package/dist/document-layer.js +1025 -0
  255. package/dist/document-layer.js.map +1 -0
  256. package/dist/document-lifecycle.d.ts +52 -0
  257. package/dist/document-lifecycle.d.ts.map +1 -0
  258. package/dist/document-lifecycle.js +134 -0
  259. package/dist/document-lifecycle.js.map +1 -0
  260. package/dist/document-live-docs.d.ts +58 -0
  261. package/dist/document-live-docs.d.ts.map +1 -0
  262. package/dist/document-live-docs.js +126 -0
  263. package/dist/document-live-docs.js.map +1 -0
  264. package/dist/document-notify.d.ts +228 -0
  265. package/dist/document-notify.d.ts.map +1 -0
  266. package/dist/document-notify.js +580 -0
  267. package/dist/document-notify.js.map +1 -0
  268. package/dist/document-profile.d.ts +61 -0
  269. package/dist/document-profile.d.ts.map +1 -0
  270. package/dist/document-profile.js +112 -0
  271. package/dist/document-profile.js.map +1 -0
  272. package/dist/document-publish.d.ts +87 -0
  273. package/dist/document-publish.d.ts.map +1 -0
  274. package/dist/document-publish.js +173 -0
  275. package/dist/document-publish.js.map +1 -0
  276. package/dist/document-reachability.d.ts +42 -0
  277. package/dist/document-reachability.d.ts.map +1 -0
  278. package/dist/document-reachability.js +80 -0
  279. package/dist/document-reachability.js.map +1 -0
  280. package/dist/document-reconcile-engine.d.ts +66 -0
  281. package/dist/document-reconcile-engine.d.ts.map +1 -0
  282. package/dist/document-reconcile-engine.js +225 -0
  283. package/dist/document-reconcile-engine.js.map +1 -0
  284. package/dist/document-reconcile-scheduler.d.ts +163 -0
  285. package/dist/document-reconcile-scheduler.d.ts.map +1 -0
  286. package/dist/document-reconcile-scheduler.js +303 -0
  287. package/dist/document-reconcile-scheduler.js.map +1 -0
  288. package/dist/document-rejection.d.ts +251 -0
  289. package/dist/document-rejection.d.ts.map +1 -0
  290. package/dist/document-rejection.js +435 -0
  291. package/dist/document-rejection.js.map +1 -0
  292. package/dist/document-screen.d.ts +114 -0
  293. package/dist/document-screen.d.ts.map +1 -0
  294. package/dist/document-screen.js +223 -0
  295. package/dist/document-screen.js.map +1 -0
  296. package/dist/document-store.d.ts +372 -0
  297. package/dist/document-store.d.ts.map +1 -0
  298. package/dist/document-store.js +931 -0
  299. package/dist/document-store.js.map +1 -0
  300. package/dist/document-surface.d.ts +32 -0
  301. package/dist/document-surface.d.ts.map +1 -0
  302. package/dist/document-surface.js +151 -0
  303. package/dist/document-surface.js.map +1 -0
  304. package/dist/document-types.d.ts +94 -0
  305. package/dist/document-types.d.ts.map +1 -0
  306. package/dist/document-types.js +90 -0
  307. package/dist/document-types.js.map +1 -0
  308. package/dist/document-watch.d.ts +69 -0
  309. package/dist/document-watch.d.ts.map +1 -0
  310. package/dist/document-watch.js +108 -0
  311. package/dist/document-watch.js.map +1 -0
  312. package/dist/document-wiring.d.ts +53 -0
  313. package/dist/document-wiring.d.ts.map +1 -0
  314. package/dist/document-wiring.js +323 -0
  315. package/dist/document-wiring.js.map +1 -0
  316. package/dist/document-write-guard.d.ts +66 -0
  317. package/dist/document-write-guard.d.ts.map +1 -0
  318. package/dist/document-write-guard.js +98 -0
  319. package/dist/document-write-guard.js.map +1 -0
  320. package/dist/document-write-path.d.ts +94 -0
  321. package/dist/document-write-path.d.ts.map +1 -0
  322. package/dist/document-write-path.js +530 -0
  323. package/dist/document-write-path.js.map +1 -0
  324. package/dist/error-message.d.ts +7 -0
  325. package/dist/error-message.d.ts.map +1 -0
  326. package/dist/error-message.js +19 -0
  327. package/dist/error-message.js.map +1 -0
  328. package/dist/file-manifest-provider.d.ts +37 -0
  329. package/dist/file-manifest-provider.d.ts.map +1 -0
  330. package/dist/file-manifest-provider.js +105 -0
  331. package/dist/file-manifest-provider.js.map +1 -0
  332. package/dist/frame-values.d.ts +3 -0
  333. package/dist/frame-values.d.ts.map +1 -0
  334. package/dist/frame-values.js +84 -0
  335. package/dist/frame-values.js.map +1 -0
  336. package/dist/frontier-mismatch.d.ts +73 -0
  337. package/dist/frontier-mismatch.d.ts.map +1 -0
  338. package/dist/frontier-mismatch.js +89 -0
  339. package/dist/frontier-mismatch.js.map +1 -0
  340. package/dist/gateway-config-handlers.d.ts +26 -0
  341. package/dist/gateway-config-handlers.d.ts.map +1 -0
  342. package/dist/gateway-config-handlers.js +429 -0
  343. package/dist/gateway-config-handlers.js.map +1 -0
  344. package/dist/held-content.d.ts +145 -0
  345. package/dist/held-content.d.ts.map +1 -0
  346. package/dist/held-content.js +389 -0
  347. package/dist/held-content.js.map +1 -0
  348. package/dist/http-manifest-poll.d.ts +67 -0
  349. package/dist/http-manifest-poll.d.ts.map +1 -0
  350. package/dist/http-manifest-poll.js +150 -0
  351. package/dist/http-manifest-poll.js.map +1 -0
  352. package/dist/identity-migration.d.ts +40 -0
  353. package/dist/identity-migration.d.ts.map +1 -0
  354. package/dist/identity-migration.js +461 -0
  355. package/dist/identity-migration.js.map +1 -0
  356. package/dist/inbound-refusals.d.ts +283 -0
  357. package/dist/inbound-refusals.d.ts.map +1 -0
  358. package/dist/inbound-refusals.js +919 -0
  359. package/dist/inbound-refusals.js.map +1 -0
  360. package/dist/inbound-seal-request.d.ts +32 -0
  361. package/dist/inbound-seal-request.d.ts.map +1 -0
  362. package/dist/inbound-seal-request.js +228 -0
  363. package/dist/inbound-seal-request.js.map +1 -0
  364. package/dist/inbound-sessions.d.ts +291 -0
  365. package/dist/inbound-sessions.d.ts.map +1 -0
  366. package/dist/inbound-sessions.js +1609 -0
  367. package/dist/inbound-sessions.js.map +1 -0
  368. package/dist/inclusion-proof-handlers.d.ts +43 -0
  369. package/dist/inclusion-proof-handlers.d.ts.map +1 -0
  370. package/dist/inclusion-proof-handlers.js +565 -0
  371. package/dist/inclusion-proof-handlers.js.map +1 -0
  372. package/dist/inclusion-proof.d.ts +151 -0
  373. package/dist/inclusion-proof.d.ts.map +1 -0
  374. package/dist/inclusion-proof.js +228 -0
  375. package/dist/inclusion-proof.js.map +1 -0
  376. package/dist/index.d.ts +32 -0
  377. package/dist/index.d.ts.map +1 -0
  378. package/dist/index.js.map +1 -0
  379. package/dist/initiate-session-handler.d.ts +55 -0
  380. package/dist/initiate-session-handler.d.ts.map +1 -0
  381. package/dist/initiate-session-handler.js +310 -0
  382. package/dist/initiate-session-handler.js.map +1 -0
  383. package/dist/ipc-client.d.ts +31 -0
  384. package/dist/ipc-client.d.ts.map +1 -0
  385. package/dist/ipc-client.js +113 -0
  386. package/dist/ipc-client.js.map +1 -0
  387. package/dist/ipc-server.d.ts +63 -0
  388. package/dist/ipc-server.d.ts.map +1 -0
  389. package/dist/ipc-server.js +429 -0
  390. package/dist/ipc-server.js.map +1 -0
  391. package/dist/ipc-surface.d.ts +44 -0
  392. package/dist/ipc-surface.d.ts.map +1 -0
  393. package/dist/ipc-surface.js +107 -0
  394. package/dist/ipc-surface.js.map +1 -0
  395. package/dist/line-lcs.d.ts +51 -0
  396. package/dist/line-lcs.d.ts.map +1 -0
  397. package/dist/line-lcs.js +71 -0
  398. package/dist/line-lcs.js.map +1 -0
  399. package/dist/lock-file.d.ts +39 -0
  400. package/dist/lock-file.d.ts.map +1 -0
  401. package/dist/lock-file.js +120 -0
  402. package/dist/lock-file.js.map +1 -0
  403. package/dist/log-collapse.d.ts +66 -0
  404. package/dist/log-collapse.d.ts.map +1 -0
  405. package/dist/log-collapse.js +244 -0
  406. package/dist/log-collapse.js.map +1 -0
  407. package/dist/log-rotate.d.ts +67 -0
  408. package/dist/log-rotate.d.ts.map +1 -0
  409. package/dist/log-rotate.js +134 -0
  410. package/dist/log-rotate.js.map +1 -0
  411. package/dist/manifest-deps.d.ts +25 -0
  412. package/dist/manifest-deps.d.ts.map +1 -0
  413. package/dist/manifest-deps.js +151 -0
  414. package/dist/manifest-deps.js.map +1 -0
  415. package/dist/manifest-poll-scheduler.d.ts +31 -0
  416. package/dist/manifest-poll-scheduler.d.ts.map +1 -0
  417. package/dist/manifest-poll-scheduler.js +59 -0
  418. package/dist/manifest-poll-scheduler.js.map +1 -0
  419. package/dist/manifest-validity.d.ts +153 -0
  420. package/dist/manifest-validity.d.ts.map +1 -0
  421. package/dist/manifest-validity.js +268 -0
  422. package/dist/manifest-validity.js.map +1 -0
  423. package/dist/manifest-version-store-db.d.ts +24 -0
  424. package/dist/manifest-version-store-db.d.ts.map +1 -0
  425. package/dist/manifest-version-store-db.js +58 -0
  426. package/dist/manifest-version-store-db.js.map +1 -0
  427. package/dist/manifest-version-store.d.ts +16 -0
  428. package/dist/manifest-version-store.d.ts.map +1 -0
  429. package/dist/manifest-version-store.js +15 -0
  430. package/dist/manifest-version-store.js.map +1 -0
  431. package/dist/network-directory-node.d.ts +136 -0
  432. package/dist/network-directory-node.d.ts.map +1 -0
  433. package/dist/network-directory-node.js +810 -0
  434. package/dist/network-directory-node.js.map +1 -0
  435. package/dist/nonce-dedup.d.ts +68 -0
  436. package/dist/nonce-dedup.d.ts.map +1 -0
  437. package/dist/nonce-dedup.js +205 -0
  438. package/dist/nonce-dedup.js.map +1 -0
  439. package/dist/notification-dispatcher.d.ts +92 -0
  440. package/dist/notification-dispatcher.d.ts.map +1 -0
  441. package/dist/notification-dispatcher.js +210 -0
  442. package/dist/notification-dispatcher.js.map +1 -0
  443. package/dist/notification-handlers.d.ts +53 -0
  444. package/dist/notification-handlers.d.ts.map +1 -0
  445. package/dist/notification-handlers.js +461 -0
  446. package/dist/notification-handlers.js.map +1 -0
  447. package/dist/onboarding-guidance.d.ts +79 -0
  448. package/dist/onboarding-guidance.d.ts.map +1 -0
  449. package/dist/onboarding-guidance.js +95 -0
  450. package/dist/onboarding-guidance.js.map +1 -0
  451. package/dist/operator-guidance.d.ts +25 -0
  452. package/dist/operator-guidance.d.ts.map +1 -0
  453. package/dist/operator-guidance.js +50 -0
  454. package/dist/operator-guidance.js.map +1 -0
  455. package/dist/orphan-triage.d.ts +130 -0
  456. package/dist/orphan-triage.d.ts.map +1 -0
  457. package/dist/orphan-triage.js +207 -0
  458. package/dist/orphan-triage.js.map +1 -0
  459. package/dist/outbound-sessions.d.ts +126 -0
  460. package/dist/outbound-sessions.d.ts.map +1 -0
  461. package/dist/outbound-sessions.js +1052 -0
  462. package/dist/outbound-sessions.js.map +1 -0
  463. package/dist/park-envelope.d.ts +329 -0
  464. package/dist/park-envelope.d.ts.map +1 -0
  465. package/dist/park-envelope.js +509 -0
  466. package/dist/park-envelope.js.map +1 -0
  467. package/dist/park-recovery.d.ts +273 -0
  468. package/dist/park-recovery.d.ts.map +1 -0
  469. package/dist/park-recovery.js +717 -0
  470. package/dist/park-recovery.js.map +1 -0
  471. package/dist/park-refusals.d.ts +147 -0
  472. package/dist/park-refusals.d.ts.map +1 -0
  473. package/dist/park-refusals.js +331 -0
  474. package/dist/park-refusals.js.map +1 -0
  475. package/dist/quarantine-framing.d.ts +92 -0
  476. package/dist/quarantine-framing.d.ts.map +1 -0
  477. package/dist/quarantine-framing.js +111 -0
  478. package/dist/quarantine-framing.js.map +1 -0
  479. package/dist/reconnect-drain.d.ts +22 -0
  480. package/dist/reconnect-drain.d.ts.map +1 -0
  481. package/dist/reconnect-drain.js +65 -0
  482. package/dist/reconnect-drain.js.map +1 -0
  483. package/dist/recovered-position.d.ts +14 -0
  484. package/dist/recovered-position.d.ts.map +1 -0
  485. package/dist/recovered-position.js +59 -0
  486. package/dist/recovered-position.js.map +1 -0
  487. package/dist/refusal-notices.d.ts +196 -0
  488. package/dist/refusal-notices.d.ts.map +1 -0
  489. package/dist/refusal-notices.js +516 -0
  490. package/dist/refusal-notices.js.map +1 -0
  491. package/dist/refusal-reasons.d.ts +245 -0
  492. package/dist/refusal-reasons.d.ts.map +1 -0
  493. package/dist/refusal-reasons.js +363 -0
  494. package/dist/refusal-reasons.js.map +1 -0
  495. package/dist/register-handler.d.ts +36 -0
  496. package/dist/register-handler.d.ts.map +1 -0
  497. package/dist/register-handler.js +285 -0
  498. package/dist/register-handler.js.map +1 -0
  499. package/dist/registration-context.d.ts +72 -0
  500. package/dist/registration-context.d.ts.map +1 -0
  501. package/dist/registration-context.js +126 -0
  502. package/dist/registration-context.js.map +1 -0
  503. package/dist/registration-manager.d.ts +94 -0
  504. package/dist/registration-manager.d.ts.map +1 -0
  505. package/dist/registration-manager.js +585 -0
  506. package/dist/registration-manager.js.map +1 -0
  507. package/dist/registration-persistence.d.ts +183 -0
  508. package/dist/registration-persistence.d.ts.map +1 -0
  509. package/dist/registration-persistence.js +263 -0
  510. package/dist/registration-persistence.js.map +1 -0
  511. package/dist/registry-poll.d.ts +52 -0
  512. package/dist/registry-poll.d.ts.map +1 -0
  513. package/dist/registry-poll.js +140 -0
  514. package/dist/registry-poll.js.map +1 -0
  515. package/dist/registry-version-store-db.d.ts +22 -0
  516. package/dist/registry-version-store-db.d.ts.map +1 -0
  517. package/dist/registry-version-store-db.js +50 -0
  518. package/dist/registry-version-store-db.js.map +1 -0
  519. package/dist/relay-endpoints.d.ts +18 -0
  520. package/dist/relay-endpoints.d.ts.map +1 -0
  521. package/dist/relay-endpoints.js +9 -0
  522. package/dist/relay-endpoints.js.map +1 -0
  523. package/dist/relay-only.d.ts +140 -0
  524. package/dist/relay-only.d.ts.map +1 -0
  525. package/dist/relay-only.js +193 -0
  526. package/dist/relay-only.js.map +1 -0
  527. package/dist/relay-receipt-store.d.ts +155 -0
  528. package/dist/relay-receipt-store.d.ts.map +1 -0
  529. package/dist/relay-receipt-store.js +284 -0
  530. package/dist/relay-receipt-store.js.map +1 -0
  531. package/dist/relay-reconnect.d.ts +32 -0
  532. package/dist/relay-reconnect.d.ts.map +1 -0
  533. package/dist/relay-reconnect.js +29 -0
  534. package/dist/relay-reconnect.js.map +1 -0
  535. package/dist/reply-lag.d.ts +11 -0
  536. package/dist/reply-lag.d.ts.map +1 -0
  537. package/dist/reply-lag.js +46 -0
  538. package/dist/reply-lag.js.map +1 -0
  539. package/dist/resolve-named-agent.d.ts +49 -0
  540. package/dist/resolve-named-agent.d.ts.map +1 -0
  541. package/dist/resolve-named-agent.js +77 -0
  542. package/dist/resolve-named-agent.js.map +1 -0
  543. package/dist/restart-seal-resolver.d.ts +110 -0
  544. package/dist/restart-seal-resolver.d.ts.map +1 -0
  545. package/dist/restart-seal-resolver.js +353 -0
  546. package/dist/restart-seal-resolver.js.map +1 -0
  547. package/dist/resume-last-seen.d.ts +15 -0
  548. package/dist/resume-last-seen.d.ts.map +1 -0
  549. package/dist/resume-last-seen.js +44 -0
  550. package/dist/resume-last-seen.js.map +1 -0
  551. package/dist/retry-queue.d.ts +203 -0
  552. package/dist/retry-queue.d.ts.map +1 -0
  553. package/dist/retry-queue.js +701 -0
  554. package/dist/retry-queue.js.map +1 -0
  555. package/dist/roster-freshness.d.ts +160 -0
  556. package/dist/roster-freshness.d.ts.map +1 -0
  557. package/dist/roster-freshness.js +250 -0
  558. package/dist/roster-freshness.js.map +1 -0
  559. package/dist/screening-status.d.ts +15 -0
  560. package/dist/screening-status.d.ts.map +1 -0
  561. package/dist/screening-status.js +65 -0
  562. package/dist/screening-status.js.map +1 -0
  563. package/dist/seal-carried-close.d.ts +37 -0
  564. package/dist/seal-carried-close.d.ts.map +1 -0
  565. package/dist/seal-carried-close.js +179 -0
  566. package/dist/seal-carried-close.js.map +1 -0
  567. package/dist/seal-certificate-pull.d.ts +79 -0
  568. package/dist/seal-certificate-pull.d.ts.map +1 -0
  569. package/dist/seal-certificate-pull.js +186 -0
  570. package/dist/seal-certificate-pull.js.map +1 -0
  571. package/dist/seal-certified-root-check.d.ts +37 -0
  572. package/dist/seal-certified-root-check.d.ts.map +1 -0
  573. package/dist/seal-certified-root-check.js +127 -0
  574. package/dist/seal-certified-root-check.js.map +1 -0
  575. package/dist/seal-coordinator.d.ts +118 -0
  576. package/dist/seal-coordinator.d.ts.map +1 -0
  577. package/dist/seal-coordinator.js +927 -0
  578. package/dist/seal-coordinator.js.map +1 -0
  579. package/dist/seal-escalation.d.ts +80 -0
  580. package/dist/seal-escalation.d.ts.map +1 -0
  581. package/dist/seal-escalation.js +283 -0
  582. package/dist/seal-escalation.js.map +1 -0
  583. package/dist/seal-evidence-root-check.d.ts +70 -0
  584. package/dist/seal-evidence-root-check.d.ts.map +1 -0
  585. package/dist/seal-evidence-root-check.js +199 -0
  586. package/dist/seal-evidence-root-check.js.map +1 -0
  587. package/dist/seal-failure-store.d.ts +139 -0
  588. package/dist/seal-failure-store.d.ts.map +1 -0
  589. package/dist/seal-failure-store.js +207 -0
  590. package/dist/seal-failure-store.js.map +1 -0
  591. package/dist/seal-flows.d.ts +107 -0
  592. package/dist/seal-flows.d.ts.map +1 -0
  593. package/dist/seal-flows.js +638 -0
  594. package/dist/seal-flows.js.map +1 -0
  595. package/dist/seal-frontier-verify.d.ts +103 -0
  596. package/dist/seal-frontier-verify.d.ts.map +1 -0
  597. package/dist/seal-frontier-verify.js +143 -0
  598. package/dist/seal-frontier-verify.js.map +1 -0
  599. package/dist/seal-leaf.d.ts +58 -0
  600. package/dist/seal-leaf.d.ts.map +1 -0
  601. package/dist/seal-leaf.js +112 -0
  602. package/dist/seal-leaf.js.map +1 -0
  603. package/dist/seal-legibility-tbs.d.ts +25 -0
  604. package/dist/seal-legibility-tbs.d.ts.map +1 -0
  605. package/dist/seal-legibility-tbs.js +77 -0
  606. package/dist/seal-legibility-tbs.js.map +1 -0
  607. package/dist/seal-local-terminus.d.ts +41 -0
  608. package/dist/seal-local-terminus.d.ts.map +1 -0
  609. package/dist/seal-local-terminus.js +170 -0
  610. package/dist/seal-local-terminus.js.map +1 -0
  611. package/dist/seal-receipt-upgrade.d.ts +30 -0
  612. package/dist/seal-receipt-upgrade.d.ts.map +1 -0
  613. package/dist/seal-receipt-upgrade.js +49 -0
  614. package/dist/seal-receipt-upgrade.js.map +1 -0
  615. package/dist/seal-relay-silence.d.ts +51 -0
  616. package/dist/seal-relay-silence.d.ts.map +1 -0
  617. package/dist/seal-relay-silence.js +76 -0
  618. package/dist/seal-relay-silence.js.map +1 -0
  619. package/dist/seal-settle.d.ts +38 -0
  620. package/dist/seal-settle.d.ts.map +1 -0
  621. package/dist/seal-settle.js +33 -0
  622. package/dist/seal-settle.js.map +1 -0
  623. package/dist/seal-upgrade.d.ts +107 -0
  624. package/dist/seal-upgrade.d.ts.map +1 -0
  625. package/dist/seal-upgrade.js +200 -0
  626. package/dist/seal-upgrade.js.map +1 -0
  627. package/dist/sealed-conversation.d.ts +36 -0
  628. package/dist/sealed-conversation.d.ts.map +1 -0
  629. package/dist/sealed-conversation.js +171 -0
  630. package/dist/sealed-conversation.js.map +1 -0
  631. package/dist/sealed-leaf-set.d.ts +92 -0
  632. package/dist/sealed-leaf-set.d.ts.map +1 -0
  633. package/dist/sealed-leaf-set.js +122 -0
  634. package/dist/sealed-leaf-set.js.map +1 -0
  635. package/dist/send-claims.d.ts +56 -0
  636. package/dist/send-claims.d.ts.map +1 -0
  637. package/dist/send-claims.js +50 -0
  638. package/dist/send-claims.js.map +1 -0
  639. package/dist/session-assignment-parser.d.ts +104 -0
  640. package/dist/session-assignment-parser.d.ts.map +1 -0
  641. package/dist/session-assignment-parser.js +345 -0
  642. package/dist/session-assignment-parser.js.map +1 -0
  643. package/dist/session-category.d.ts +19 -0
  644. package/dist/session-category.d.ts.map +1 -0
  645. package/dist/session-category.js +14 -0
  646. package/dist/session-category.js.map +1 -0
  647. package/dist/session-ceremony.d.ts +297 -0
  648. package/dist/session-ceremony.d.ts.map +1 -0
  649. package/dist/session-ceremony.js +964 -0
  650. package/dist/session-ceremony.js.map +1 -0
  651. package/dist/session-closed.d.ts +79 -0
  652. package/dist/session-closed.d.ts.map +1 -0
  653. package/dist/session-closed.js +189 -0
  654. package/dist/session-closed.js.map +1 -0
  655. package/dist/session-connection-gater.d.ts +148 -0
  656. package/dist/session-connection-gater.d.ts.map +1 -0
  657. package/dist/session-connection-gater.js +332 -0
  658. package/dist/session-connection-gater.js.map +1 -0
  659. package/dist/session-content-context.d.ts +162 -0
  660. package/dist/session-content-context.d.ts.map +1 -0
  661. package/dist/session-content-context.js +2 -0
  662. package/dist/session-content-context.js.map +1 -0
  663. package/dist/session-content-handlers.d.ts +82 -0
  664. package/dist/session-content-handlers.d.ts.map +1 -0
  665. package/dist/session-content-handlers.js +1382 -0
  666. package/dist/session-content-handlers.js.map +1 -0
  667. package/dist/session-content-ingest.d.ts +206 -0
  668. package/dist/session-content-ingest.d.ts.map +1 -0
  669. package/dist/session-content-ingest.js +2167 -0
  670. package/dist/session-content-ingest.js.map +1 -0
  671. package/dist/session-content-send.d.ts +191 -0
  672. package/dist/session-content-send.d.ts.map +1 -0
  673. package/dist/session-content-send.js +1360 -0
  674. package/dist/session-content-send.js.map +1 -0
  675. package/dist/session-delivery-acks.d.ts +169 -0
  676. package/dist/session-delivery-acks.d.ts.map +1 -0
  677. package/dist/session-delivery-acks.js +569 -0
  678. package/dist/session-delivery-acks.js.map +1 -0
  679. package/dist/session-ephemerals.d.ts +279 -0
  680. package/dist/session-ephemerals.d.ts.map +1 -0
  681. package/dist/session-ephemerals.js +591 -0
  682. package/dist/session-ephemerals.js.map +1 -0
  683. package/dist/session-leaf-records.d.ts +159 -0
  684. package/dist/session-leaf-records.d.ts.map +1 -0
  685. package/dist/session-leaf-records.js +408 -0
  686. package/dist/session-leaf-records.js.map +1 -0
  687. package/dist/session-lifecycle.d.ts +303 -0
  688. package/dist/session-lifecycle.d.ts.map +1 -0
  689. package/dist/session-lifecycle.js +1679 -0
  690. package/dist/session-lifecycle.js.map +1 -0
  691. package/dist/session-liveness.d.ts +135 -0
  692. package/dist/session-liveness.d.ts.map +1 -0
  693. package/dist/session-liveness.js +347 -0
  694. package/dist/session-liveness.js.map +1 -0
  695. package/dist/session-name.d.ts +35 -0
  696. package/dist/session-name.d.ts.map +1 -0
  697. package/dist/session-name.js +60 -0
  698. package/dist/session-name.js.map +1 -0
  699. package/dist/session-node-factory.d.ts +18 -0
  700. package/dist/session-node-factory.d.ts.map +1 -0
  701. package/dist/session-node-factory.js +182 -0
  702. package/dist/session-node-factory.js.map +1 -0
  703. package/dist/session-node-manager.d.ts +877 -0
  704. package/dist/session-node-manager.d.ts.map +1 -0
  705. package/dist/session-node-manager.js +2998 -0
  706. package/dist/session-node-manager.js.map +1 -0
  707. package/dist/session-node-types.d.ts +1057 -0
  708. package/dist/session-node-types.d.ts.map +1 -0
  709. package/dist/session-node-types.js +657 -0
  710. package/dist/session-node-types.js.map +1 -0
  711. package/dist/session-notify.d.ts +46 -0
  712. package/dist/session-notify.d.ts.map +1 -0
  713. package/dist/session-notify.js +116 -0
  714. package/dist/session-notify.js.map +1 -0
  715. package/dist/session-own-chain-store.d.ts +65 -0
  716. package/dist/session-own-chain-store.d.ts.map +1 -0
  717. package/dist/session-own-chain-store.js +75 -0
  718. package/dist/session-own-chain-store.js.map +1 -0
  719. package/dist/session-queries.d.ts +476 -0
  720. package/dist/session-queries.d.ts.map +1 -0
  721. package/dist/session-queries.js +1007 -0
  722. package/dist/session-queries.js.map +1 -0
  723. package/dist/session-read-handlers.d.ts +87 -0
  724. package/dist/session-read-handlers.d.ts.map +1 -0
  725. package/dist/session-read-handlers.js +689 -0
  726. package/dist/session-read-handlers.js.map +1 -0
  727. package/dist/session-records.d.ts +341 -0
  728. package/dist/session-records.d.ts.map +1 -0
  729. package/dist/session-records.js +858 -0
  730. package/dist/session-records.js.map +1 -0
  731. package/dist/session-relay-client.d.ts +659 -0
  732. package/dist/session-relay-client.d.ts.map +1 -0
  733. package/dist/session-relay-client.js +2876 -0
  734. package/dist/session-relay-client.js.map +1 -0
  735. package/dist/session-relay.d.ts +397 -0
  736. package/dist/session-relay.d.ts.map +1 -0
  737. package/dist/session-relay.js +1636 -0
  738. package/dist/session-relay.js.map +1 -0
  739. package/dist/session-salt-agreement.d.ts +331 -0
  740. package/dist/session-salt-agreement.d.ts.map +1 -0
  741. package/dist/session-salt-agreement.js +472 -0
  742. package/dist/session-salt-agreement.js.map +1 -0
  743. package/dist/session-salts.d.ts +432 -0
  744. package/dist/session-salts.d.ts.map +1 -0
  745. package/dist/session-salts.js +1540 -0
  746. package/dist/session-salts.js.map +1 -0
  747. package/dist/session-schema.d.ts +30 -0
  748. package/dist/session-schema.d.ts.map +1 -0
  749. package/dist/session-schema.js +877 -0
  750. package/dist/session-schema.js.map +1 -0
  751. package/dist/session-seal-leaf-store.d.ts +70 -0
  752. package/dist/session-seal-leaf-store.d.ts.map +1 -0
  753. package/dist/session-seal-leaf-store.js +105 -0
  754. package/dist/session-seal-leaf-store.js.map +1 -0
  755. package/dist/session-seal.d.ts +334 -0
  756. package/dist/session-seal.d.ts.map +1 -0
  757. package/dist/session-seal.js +1017 -0
  758. package/dist/session-seal.js.map +1 -0
  759. package/dist/session-terminal-refusal.d.ts +65 -0
  760. package/dist/session-terminal-refusal.d.ts.map +1 -0
  761. package/dist/session-terminal-refusal.js +87 -0
  762. package/dist/session-terminal-refusal.js.map +1 -0
  763. package/dist/session-tree.d.ts +110 -0
  764. package/dist/session-tree.d.ts.map +1 -0
  765. package/dist/session-tree.js +144 -0
  766. package/dist/session-tree.js.map +1 -0
  767. package/dist/session-views.d.ts +47 -0
  768. package/dist/session-views.d.ts.map +1 -0
  769. package/dist/session-views.js +278 -0
  770. package/dist/session-views.js.map +1 -0
  771. package/dist/signal-handlers.d.ts +63 -0
  772. package/dist/signal-handlers.d.ts.map +1 -0
  773. package/dist/signal-handlers.js +980 -0
  774. package/dist/signal-handlers.js.map +1 -0
  775. package/dist/signal-requirement-policy.d.ts +51 -0
  776. package/dist/signal-requirement-policy.d.ts.map +1 -0
  777. package/dist/signal-requirement-policy.js +89 -0
  778. package/dist/signal-requirement-policy.js.map +1 -0
  779. package/dist/signal-revocability.d.ts +49 -0
  780. package/dist/signal-revocability.d.ts.map +1 -0
  781. package/dist/signal-revocability.js +93 -0
  782. package/dist/signal-revocability.js.map +1 -0
  783. package/dist/signal-submission.d.ts +181 -0
  784. package/dist/signal-submission.d.ts.map +1 -0
  785. package/dist/signal-submission.js +368 -0
  786. package/dist/signal-submission.js.map +1 -0
  787. package/dist/signaling-connect.d.ts +118 -0
  788. package/dist/signaling-connect.d.ts.map +1 -0
  789. package/dist/signaling-connect.js +538 -0
  790. package/dist/signaling-connect.js.map +1 -0
  791. package/dist/signaling-wiring.d.ts +105 -0
  792. package/dist/signaling-wiring.d.ts.map +1 -0
  793. package/dist/signaling-wiring.js +393 -0
  794. package/dist/signaling-wiring.js.map +1 -0
  795. package/dist/singleton-lock.d.ts +85 -0
  796. package/dist/singleton-lock.d.ts.map +1 -0
  797. package/dist/singleton-lock.js +219 -0
  798. package/dist/singleton-lock.js.map +1 -0
  799. package/dist/sqlcipher-db.d.ts +139 -0
  800. package/dist/sqlcipher-db.d.ts.map +1 -0
  801. package/dist/sqlcipher-db.js +357 -0
  802. package/dist/sqlcipher-db.js.map +1 -0
  803. package/dist/standing-receivers.d.ts +319 -0
  804. package/dist/standing-receivers.d.ts.map +1 -0
  805. package/dist/standing-receivers.js +1295 -0
  806. package/dist/standing-receivers.js.map +1 -0
  807. package/dist/start-agent.d.ts +64 -0
  808. package/dist/start-agent.d.ts.map +1 -0
  809. package/dist/start-agent.js +136 -0
  810. package/dist/start-agent.js.map +1 -0
  811. package/dist/status-handler.d.ts +54 -0
  812. package/dist/status-handler.d.ts.map +1 -0
  813. package/dist/status-handler.js +61 -0
  814. package/dist/status-handler.js.map +1 -0
  815. package/dist/submission-retry.d.ts +208 -0
  816. package/dist/submission-retry.d.ts.map +1 -0
  817. package/dist/submission-retry.js +506 -0
  818. package/dist/submission-retry.js.map +1 -0
  819. package/dist/telegram-bot-client.d.ts +34 -0
  820. package/dist/telegram-bot-client.d.ts.map +1 -0
  821. package/dist/telegram-bot-client.js +36 -0
  822. package/dist/telegram-bot-client.js.map +1 -0
  823. package/dist/telegram-doorbell.d.ts +38 -0
  824. package/dist/telegram-doorbell.d.ts.map +1 -0
  825. package/dist/telegram-doorbell.js +140 -0
  826. package/dist/telegram-doorbell.js.map +1 -0
  827. package/dist/test-handlers.d.ts +62 -0
  828. package/dist/test-handlers.d.ts.map +1 -0
  829. package/dist/test-handlers.js +240 -0
  830. package/dist/test-handlers.js.map +1 -0
  831. package/dist/testing.d.ts +10 -0
  832. package/dist/testing.d.ts.map +1 -0
  833. package/dist/testing.js +10 -0
  834. package/dist/testing.js.map +1 -0
  835. package/dist/transport-composition.d.ts +31 -0
  836. package/dist/transport-composition.d.ts.map +1 -0
  837. package/dist/transport-composition.js +55 -0
  838. package/dist/transport-composition.js.map +1 -0
  839. package/dist/transport-selector.d.ts +202 -0
  840. package/dist/transport-selector.d.ts.map +1 -0
  841. package/dist/transport-selector.js +196 -0
  842. package/dist/transport-selector.js.map +1 -0
  843. package/dist/trust-signal-pickup-listener.d.ts +43 -0
  844. package/dist/trust-signal-pickup-listener.d.ts.map +1 -0
  845. package/dist/trust-signal-pickup-listener.js +48 -0
  846. package/dist/trust-signal-pickup-listener.js.map +1 -0
  847. package/dist/trust-signal-store.d.ts +406 -0
  848. package/dist/trust-signal-store.d.ts.map +1 -0
  849. package/dist/trust-signal-store.js +939 -0
  850. package/dist/trust-signal-store.js.map +1 -0
  851. package/dist/trust-signal-sweep-tick.d.ts +60 -0
  852. package/dist/trust-signal-sweep-tick.d.ts.map +1 -0
  853. package/dist/trust-signal-sweep-tick.js +101 -0
  854. package/dist/trust-signal-sweep-tick.js.map +1 -0
  855. package/dist/trust-signal-sweep.d.ts +94 -0
  856. package/dist/trust-signal-sweep.d.ts.map +1 -0
  857. package/dist/trust-signal-sweep.js +147 -0
  858. package/dist/trust-signal-sweep.js.map +1 -0
  859. package/dist/type-registry.d.ts +42 -0
  860. package/dist/type-registry.d.ts.map +1 -0
  861. package/dist/type-registry.js +37 -0
  862. package/dist/type-registry.js.map +1 -0
  863. package/dist/types.d.ts +746 -0
  864. package/dist/types.d.ts.map +1 -0
  865. package/dist/types.js +20 -0
  866. package/dist/types.js.map +1 -0
  867. package/dist/unresolved-nodes-report.d.ts +14 -0
  868. package/dist/unresolved-nodes-report.d.ts.map +1 -0
  869. package/dist/unresolved-nodes-report.js +96 -0
  870. package/dist/unresolved-nodes-report.js.map +1 -0
  871. package/dist/vocabulary.d.ts +150 -0
  872. package/dist/vocabulary.d.ts.map +1 -0
  873. package/dist/vocabulary.js +386 -0
  874. package/dist/vocabulary.js.map +1 -0
  875. package/dist/who-label.d.ts +28 -0
  876. package/dist/who-label.d.ts.map +1 -0
  877. package/dist/who-label.js +31 -0
  878. package/dist/who-label.js.map +1 -0
  879. package/dist/who-resolver.d.ts +15 -0
  880. package/dist/who-resolver.d.ts.map +1 -0
  881. package/dist/who-resolver.js +47 -0
  882. package/dist/who-resolver.js.map +1 -0
  883. package/dist/wire-content-hash.d.ts +93 -0
  884. package/dist/wire-content-hash.d.ts.map +1 -0
  885. package/dist/wire-content-hash.js +116 -0
  886. package/dist/wire-content-hash.js.map +1 -0
  887. package/dist/withheld-content.d.ts +13 -0
  888. package/dist/withheld-content.d.ts.map +1 -0
  889. package/dist/withheld-content.js +46 -0
  890. package/dist/withheld-content.js.map +1 -0
  891. package/dist/witness-alerts.d.ts +40 -0
  892. package/dist/witness-alerts.d.ts.map +1 -0
  893. package/dist/witness-alerts.js +102 -0
  894. package/dist/witness-alerts.js.map +1 -0
  895. package/package.json +5 -5
@@ -0,0 +1,2876 @@
1
+ var _a;
2
+ /**
3
+ * Daemon-side relay witness client (PER AGENT).
4
+ *
5
+ * In CELLO the relay is the ordering/witness authority (Structure 2): it never sees
6
+ * plaintext (content is peer↔peer), only the SIGNED content-hash leaves. It assigns
7
+ * the canonical `sequence_number` and forwards each witnessed leaf to the counterparty
8
+ * (`leaf_deliver`). A `cello_send` whose hash the relay never witnessed has no canonical
9
+ * sequence and is not a complete CELLO message — so the session submits the message leaf
10
+ * hash here, in parallel with the direct content delivery.
11
+ *
12
+ * ONE stream per AGENT, not per session. The relay authenticates a stream by the agent's
13
+ * K_local pubkey and keys its delivery/queue maps by that pubkey (relay-node
14
+ * #handleRelayStream / #processHashSubmit), so a second stream for the same pubkey would
15
+ * OVERWRITE the first's delivery stream and steal its queued `leaf_deliver`s. The protocol
16
+ * is designed for one relay connection per agent identity multiplexing all that agent's
17
+ * sessions — every wire frame carries `session_id`. So this client is shared across an
18
+ * agent's sessions: submits are globally FIFO-serialized on the stream (a `hash_submit_ack`
19
+ * carries NO session_id, so at most one submit may be outstanding at a time and acks match
20
+ * the queue head in order), while inbound `leaf_deliver` (which DOES carry `session_id`) is
21
+ * routed to the owning session's handler.
22
+ *
23
+ * The stream is (re)dialed from whatever live session node is current at submit time, so it
24
+ * survives individual session teardown (the relay treats a same-pubkey reconnect as a
25
+ * reconnect, re-auths, and re-drains).
26
+ *
27
+ * The server contract this must match: `packages/relay/src/relay-node.ts`.
28
+ *
29
+ * Crypto: Ed25519 RFC 8032. Relay auth domain: "CELLO-RELAY-AUTH-v1".
30
+ */
31
+ import { createHash } from "node:crypto";
32
+ import * as lp from "it-length-prefixed";
33
+ import { decode } from "cbor-x";
34
+ import { encodeCbor, decodeSealPayload, encodeStructure1, decodeStructure1, computeGenesisPrevRoot, computeChainAnchor } from "@cello-protocol/protocol-types";
35
+ import { encodeSessionLivenessQuery, decodeSessionLivenessResponse, encodeSessionAttendanceNotice, } from "@cello-protocol/protocol-types";
36
+ import { verify } from "@cello-protocol/crypto";
37
+ import { extractErrorMessage } from "./error-message.js";
38
+ import { evaluateRelayAck, readSubmittedLeaf } from "./relay-receipt-store.js";
39
+ import { lastSeenFromRecord } from "./resume-last-seen.js";
40
+ import { reconnectWithBackoff, RELAY_RECONNECT_BASE_MS } from "./relay-reconnect.js";
41
+ /**
42
+ * DOD-M15-AWAYSCOPE-1 — what a liveness query answers with.
43
+ *
44
+ * `refused` is kept separate from the values rather than folded into `liveness`, because it means
45
+ * something different: the relay declined to say, most likely because this caller is not a
46
+ * participant of the session it named. The liveness is 'unknown' either way — the operator surface
47
+ * shows the same thing — but a caller that wants to tell "the relay would not tell me" from "the
48
+ * relay does not know" can, and a log line that conflated them would send the next investigation
49
+ * looking at the counterparty instead of at the session id.
50
+ */
51
+ /**
52
+ * The key an answer is matched to its question by: the two fields the RESPONSE carries back. One
53
+ * relay client serves every session an agent holds there, so two concurrent queries on one stream
54
+ * is ordinary — and an answer routed to whatever was asked last shows the wrong counterparty's row.
55
+ */
56
+ function livenessKey(sessionId, counterpartyPubkey) {
57
+ return `${Buffer.from(sessionId).toString("hex")}:${Buffer.from(counterpartyPubkey).toString("hex")}`;
58
+ }
59
+ export const RELAY_PROTOCOL_ID = "/cello/relay/1.0.0";
60
+ /**
61
+ * How long to wait before re-proving after a transport-class failure.
62
+ *
63
+ * Sized against the measured event, not guessed: libp2p logged
64
+ * `connection-manager closing 1 connections / stopped / started` inside 3ms, and the redial that
65
+ * followed completed ~1s later. A second is comfortably past the restart while still being
66
+ * invisible next to the ~5 minutes the previous behaviour cost (quarantine the relay, wait for the
67
+ * next reachability sweep).
68
+ */
69
+ export const PROOF_RETRY_DELAY_MS = 1_000;
70
+ export const RELAY_AUTH_DOMAIN = "CELLO-RELAY-AUTH-v1";
71
+ /**
72
+ * DOD-WITNESS-STALL-1 — relay refusals that can NEVER resolve.
73
+ *
74
+ * `sendContent` treats a failed leaf-hash submit as a transient degradation: the content is real,
75
+ * the peer still gets it, and the canonical sequence is recovered later. That is correct for a relay
76
+ * that is briefly unreachable.
77
+ *
78
+ * It is WRONG for these two. They mean the relay has ended the session — there is no later, and
79
+ * nothing sent from here can ever enter the record. Collapsing them into the transient case is what
80
+ * let a conversation run for 68 minutes and 8 messages, every send reporting `delivered: true`,
81
+ * against a chain that had stopped growing.
82
+ *
83
+ * ENUMERATED, never pattern-matched. A substring rule like `reason.includes("sealed")` would absorb
84
+ * a future reason nobody has considered — which is the same collapse in a new coat.
85
+ */
86
+ export const TERMINAL_RELAY_REFUSALS = new Set([
87
+ "session_sealed",
88
+ "session_not_found",
89
+ /**
90
+ * `DOD-M15-TERMINAL-REASON-1` split `session_sealed` into named causes, and this set is one of
91
+ * THREE places keyed on the old literal — a rename on the relay silently made a terminal refusal
92
+ * non-terminal here, which is the 68-minute defect above reopened by a string change.
93
+ *
94
+ * `seal_refused`: a directory READ the seal and rejected it. Terminal in the strongest sense —
95
+ * there is no later, and no retry can change a merits verdict.
96
+ *
97
+ * `seal_in_progress` is deliberately ABSENT. A seal in flight may still succeed, and after
98
+ * `DOD-M15-TRANSPORT-TERMINAL-1` the session can return to `active` — treating it as terminal
99
+ * would retire a conversation that is about to seal normally.
100
+ */
101
+ "seal_refused",
102
+ // The counterparty closed first: terminal for THIS SEND only, never retires (live 2026-09-13).
103
+ "session_closing",
104
+ ]);
105
+ /** True when the relay has ended this session and no later submit can succeed. */
106
+ export function isTerminalRelayRefusal(reason) {
107
+ return reason !== undefined && TERMINAL_RELAY_REFUSALS.has(reason);
108
+ }
109
+ /**
110
+ * DOD-M15-RELAYSLOTS-1 — **WHICH REFUSALS JUSTIFY TRYING A DIFFERENT RELAY.**
111
+ *
112
+ * We run several relays, so "move on to the next one" is always available — which is exactly why it
113
+ * needs a rule. Moving on from a problem that every relay will have turns one client-side fault into
114
+ * what looks like a fleet-wide outage, and the operator then goes looking for a broken relay.
115
+ *
116
+ * ENUMERATED, never pattern-matched, for the same reason `TERMINAL_RELAY_REFUSALS` is: a substring
117
+ * rule would silently absorb a future reason nobody has considered.
118
+ */
119
+ const RELAY_SIDE_REFUSALS = new Set([
120
+ /**
121
+ * The relay holds no directory public key, so it can verify nothing and is refusing everyone.
122
+ * That is this relay being misconfigured, not us being wrong — another relay is the right move,
123
+ * and it is the whole reason we run more than one.
124
+ */
125
+ "online_token_no_directory_key",
126
+ ]);
127
+ /**
128
+ * ─── WHOSE FAULT IS IT? — `DOD-M15-TOKENSTALE-1` ───────────────────────────────────────────────
129
+ *
130
+ * A SECOND question about the same refusal, and it is deliberately not `tryAnotherRelay` inverted.
131
+ * That one asks "would another relay help?"; this asks "is the broken thing ours?". They disagree
132
+ * on the case that matters: a slot cap is not worth failing over AND is entirely the relay's own
133
+ * answer about its own table — so reusing the failover flag to mean "our fault" would be right for
134
+ * the wrong reason, and would start telling operators their machine is broken when it is not.
135
+ *
136
+ * The consumer is the submit boundary. `#ensureConnected` returns a BOOLEAN, so every refusal below
137
+ * used to collapse into `relay_unavailable` — "the relay is unreachable", which is transient,
138
+ * somebody else's, and the documented reason a send degrades to an unwitnessed leaf and still
139
+ * reports success. An expired token is none of those. Measured live 2026-09-10: an agent reported
140
+ * `ok:true, delivered:true` on every send for over two days while the relay refused all thirteen
141
+ * authentications, and no conversation it held could produce a receipt.
142
+ *
143
+ * ⚠️ AN UNKNOWN REASON IS NOT LOCAL, and the direction of that default is a real choice. A future
144
+ * relay-side reason defaulting to "local" would accuse the reader's own machine, and a wrong
145
+ * accusation about your own setup costs more than a vague one about someone else's.
146
+ */
147
+ const LOCAL_CREDENTIAL_REFUSALS = new Set([
148
+ "online_token_required",
149
+ "online_token_expired",
150
+ "online_token_pubkey_mismatch",
151
+ ]);
152
+ export function isLocalCredentialRefusal(reason) {
153
+ return LOCAL_CREDENTIAL_REFUSALS.has(reason);
154
+ }
155
+ /**
156
+ * Classify a relay's auth refusal: what to tell the operator, and whether another relay would help.
157
+ *
158
+ * Everything not in `RELAY_SIDE_REFUSALS` defaults to "do not try another", and that default is the
159
+ * safe direction. A token problem reproduces identically on every relay in the fleet, so retrying
160
+ * around the fleet spends real time turning a client fault into an apparent outage — and a slot cap
161
+ * IS satisfiable elsewhere, but spreading to another relay papers over sessions that leaked and
162
+ * brings the same wall back on the next one.
163
+ */
164
+ export function classifyRelayAuthRefusal(reason, extra = {}) {
165
+ const tryAnotherRelay = RELAY_SIDE_REFUSALS.has(reason);
166
+ let advice;
167
+ switch (reason) {
168
+ case "online_token_required":
169
+ advice = "This agent has no online token from a directory yet. It is issued when a directory " +
170
+ "marks the agent online, so this usually clears itself on the next directory connection. If " +
171
+ "it persists, the agent is not reaching any directory — check that first, not the relay.";
172
+ break;
173
+ case "online_token_expired":
174
+ /**
175
+ * ⚠️ THIS SENTENCE USED TO PROMISE A REFRESH THAT DOES NOT HAPPEN, and the promise is why the
176
+ * condition survived for days. It read: *"refreshed on the next directory connection"*. True,
177
+ * and useless — the token is issued only in the directory handshake, it lasts ONE HOUR, and a
178
+ * healthy agent does not make another connection. Measured on one box: a token at
179
+ * 2026-09-08 09:06 and the next over two days later.
180
+ *
181
+ * It now leads with the CONSEQUENCE, because the operator arrives here having been told every
182
+ * send succeeded, and names the one action that works today. The automatic refresh is
183
+ * `DOD-M15-TOKENSTALE-1` unit 2 and needs a directory frame that does not exist yet.
184
+ */
185
+ advice = "This agent's pass from the directory has expired, so the relay is refusing to " +
186
+ "witness anything it sends: messages still arrive, but nothing is being recorded as proof " +
187
+ "and no conversation can produce a receipt until this clears. It is NOT refreshed while the " +
188
+ "daemon keeps running — restart it (cello logout, then cello login) and the next session " +
189
+ "will be witnessed again. If it comes back within the hour, this machine's clock or its " +
190
+ "directory connection is the thing to look at.";
191
+ break;
192
+ case "online_token_signature_invalid":
193
+ case "online_token_malformed":
194
+ case "online_token_lifetime_too_long":
195
+ advice = "This relay would not accept the token this agent was issued. Most often the relay " +
196
+ "and the directory are not in the same consortium — check which directories this relay is " +
197
+ "configured to trust.";
198
+ break;
199
+ case "online_token_pubkey_mismatch":
200
+ advice = "The token names a different key from the one this agent signed with. That is an " +
201
+ "identity mix-up on this machine, not a relay problem.";
202
+ break;
203
+ case "online_token_no_directory_key":
204
+ advice = "This relay holds no directory public key, so it cannot verify anyone and is " +
205
+ "refusing every agent. Its operator needs to configure one; another relay will work now.";
206
+ break;
207
+ case "slot_cap_exceeded":
208
+ advice = extra.slotsHeld !== undefined && extra.slotCap !== undefined
209
+ ? `This agent already holds ${String(extra.slotsHeld)} of a maximum ${String(extra.slotCap)} ` +
210
+ "reservations on this relay, and none is idle enough to reclaim. That is almost always " +
211
+ "sessions that were never closed — close some and this clears. Moving to another relay " +
212
+ "would work now and hit the same wall there."
213
+ : "This agent already holds the most reservations one agent may hold on this relay. That is " +
214
+ "almost always sessions that were never closed — close some and this clears.";
215
+ break;
216
+ case "session_tuple_cap_exceeded":
217
+ advice = extra.slotsHeld !== undefined && extra.slotCap !== undefined
218
+ ? `You already have ${String(extra.slotsHeld)} conversations open with this counterparty, ` +
219
+ `which is the maximum of ${String(extra.slotCap)} this relay allows between one pair of ` +
220
+ "agents. Close some and try again — this is almost always conversations that were never " +
221
+ "closed rather than ones anybody is still using."
222
+ : "You already have the maximum number of concurrent conversations open with this " +
223
+ "counterparty. Close some and try again.";
224
+ break;
225
+ case "rate_limited":
226
+ advice = extra.retryAfterMs !== undefined
227
+ ? `This relay is throttling this agent; it clears on its own in about ${String(Math.ceil(extra.retryAfterMs / 1000))}s.`
228
+ : "This relay is throttling this agent; it clears on its own after the throttle window.";
229
+ break;
230
+ default:
231
+ advice = "This agent could not authenticate to this relay, so it cannot hold a reservation " +
232
+ "here and is reachable only over a direct connection.";
233
+ }
234
+ return {
235
+ reason,
236
+ advice,
237
+ tryAnotherRelay,
238
+ ...(extra.slotsHeld !== undefined ? { slotsHeld: extra.slotsHeld } : {}),
239
+ ...(extra.slotCap !== undefined ? { slotCap: extra.slotCap } : {}),
240
+ ...(extra.retryAfterMs !== undefined ? { retryAfterMs: extra.retryAfterMs } : {}),
241
+ };
242
+ }
243
+ /** Structure 1 leaf kind — these values match the relay's. Message leaf (the `cello_send` default). */
244
+ export const LEAF_KIND_MSG = 0x00;
245
+ /** Control leaf (SEAL etc.) — two distinct-sender ctrl leaves trigger directory notarization. */
246
+ export const LEAF_KIND_CTRL = 0x02;
247
+ /** Document-operation leaf (DOD-DOC-LEAF-1): a CRDT update riding the session tree. */
248
+ export const LEAF_KIND_DOC = 0x04;
249
+ /** Rejection leaf (DOD-DOC-LEAF-1): references the rejected update envelope's hash. */
250
+ export const LEAF_KIND_REJECT = 0x05;
251
+ const RELAY_AUTH_TIMEOUT_MS = 5_000;
252
+ const HASH_SUBMIT_TIMEOUT_MS = 10_000;
253
+ /**
254
+ * The Ed25519-signed payload that proves K_local ownership to the relay:
255
+ * SHA-256("CELLO-RELAY-AUTH-v1" || nonce || pubkey). The relay verifies the
256
+ * signature against exactly this hash (relay-node #handleRelayStream).
257
+ */
258
+ export function buildRelayAuthPayload(nonce, pubkey) {
259
+ const domain = Buffer.from(RELAY_AUTH_DOMAIN, "utf8");
260
+ const authMsg = Buffer.concat([domain, nonce, pubkey]);
261
+ return new Uint8Array(createHash("sha256").update(authMsg).digest());
262
+ }
263
+ /**
264
+ * Rebuild the bytes a relay signs when it reports a witnessed forgery.
265
+ *
266
+ * ⚠️ **MIRRORED CODEC** — `packages/relay/src/leaf-witness.ts` `buildWitnessAlertTbs` in
267
+ * trustless-cello is the other half and the two MUST stay in sync. Both call `encodeCbor` from
268
+ * `@cello-protocol/protocol-types` rather than configuring an encoder, so the only thing that can
269
+ * drift is the field list, and both list it in one place.
270
+ */
271
+ const RELAY_WITNESS_DOMAIN = "CELLO-RELAY-WITNESS-v1";
272
+ function buildWitnessAlertTbs(sessionId, reason, observedAt, submitterIsCounterparty) {
273
+ const body = encodeCbor([RELAY_WITNESS_DOMAIN, sessionId, reason, observedAt, submitterIsCounterparty]);
274
+ return new Uint8Array(createHash("sha256").update(body).digest());
275
+ }
276
+ /**
277
+ * The genesis prev_root derivable from a relay assignment — 033-ACKEMIT.
278
+ *
279
+ * The carry holds both participant keys and the session timestamp, which with the session id are
280
+ * exactly `computeGenesisPrevRoot`'s inputs. Returns `undefined` when there is no assignment, and
281
+ * the caller then has no seed: absence is reported at the submit, never papered over.
282
+ *
283
+ * ⚠️ NOT 32 ZERO BYTES, and not any other constant. A value identical across every session is one
284
+ * an attacker can present for any session, which would make the first message's acknowledgement
285
+ * unfalsifiable exactly where it is most exposed.
286
+ */
287
+ function genesisFromAssignment(sessionIdHex, assignment) {
288
+ if (!assignment?.sessionSignature)
289
+ return undefined;
290
+ const genesis = computeGenesisPrevRoot(assignment.participantA, assignment.participantB, Uint8Array.from(Buffer.from(sessionIdHex, "hex")), assignment.sessionTimestamp);
291
+ return computeChainAnchor(genesis, assignment.sessionSignature);
292
+ }
293
+ function toU8(v) {
294
+ if (v instanceof Uint8Array)
295
+ return v;
296
+ if (Buffer.isBuffer(v))
297
+ return new Uint8Array(v);
298
+ if (v && typeof v.subarray === "function") {
299
+ return v.subarray();
300
+ }
301
+ if (v && typeof v.slice === "function") {
302
+ return v.slice();
303
+ }
304
+ return new Uint8Array();
305
+ }
306
+ async function nextWithTimeout(iter, ms) {
307
+ let timer;
308
+ const timeout = new Promise((resolve) => {
309
+ timer = setTimeout(() => resolve({ value: undefined, done: true }), ms);
310
+ });
311
+ try {
312
+ const result = (await Promise.race([iter.next(), timeout]));
313
+ return { value: result.value, done: !!result.done };
314
+ }
315
+ finally {
316
+ clearTimeout(timer);
317
+ }
318
+ }
319
+ /**
320
+ * Per-AGENT relay witness client. Shared across all of an agent's sessions; one
321
+ * authenticated stream at a time (the relay keys by agent pubkey). Submits are FIFO and
322
+ * single-in-flight on the stream; `leaf_deliver` is routed by session_id.
323
+ */
324
+ export class AgentRelayClient {
325
+ /**
326
+ * DOD-RELAY-KEEPALIVE-1 (review F4): the last error that ended this relay's reader.
327
+ * The reservation watchdog reports `relay_connection_gone` — an exit-point label derived from a
328
+ * poll, with the real abort reason already discarded. This carries the cause across to it.
329
+ */
330
+ #lastReaderError = null;
331
+ /** The cause of the most recent reader end, or null if it has not ended. */
332
+ getLastReaderError() {
333
+ return this.#lastReaderError;
334
+ }
335
+ #relayPeerId;
336
+ #relayAddrs;
337
+ #keyProvider;
338
+ #senderPubkey;
339
+ #logger;
340
+ #receiptStore;
341
+ #sealLeafStore;
342
+ #ownChainStore;
343
+ /**
344
+ * `DOD-M15-SELFCHAIN-1` — this agent's own last message per session, in memory.
345
+ *
346
+ * ⚠️ THE MAP IS THE CHAIN; THE STORE IS ITS DURABILITY. Separating them is deliberate. Within a
347
+ * running process the map is authoritative and always available, so a send never depends on a
348
+ * database being wired. Across a RESTART only the store can answer, and without it this daemon
349
+ * would silently start a new chain mid-conversation — the counterparty refusing every message
350
+ * after it, with no way to tell that from tampering.
351
+ *
352
+ * So a missing store is a durability gap, and never a reason to refuse a send: it is caused by our
353
+ * own wiring, never by anything a peer does, and refusing there would break the product to close a
354
+ * hole the peer cannot reach. It IS reported — once per client, at construction, by the error
355
+ * below. An earlier version of this comment promised that report and nothing emitted it, which is
356
+ * the shape that lets a wiring gap sit invisible until a restart days later.
357
+ */
358
+ #ownChain = new Map();
359
+ /**
360
+ * `DOD-M15-SELFCHAIN-1` — each session's GENESIS, kept apart from `#lastSeen`, and keeping them
361
+ * apart is the entire point of this field.
362
+ *
363
+ * `#lastSeen` is seeded with the genesis at registration and then ADVANCES as the counterparty
364
+ * speaks. So after the counterparty's first message it holds THEIR last content hash, not the
365
+ * starting point. Reading it as the self link's fallback handed a sender the other party's hash
366
+ * for their own first message, which the relay then refused — and, worse, told the innocent
367
+ * counterparty their chain was broken. This map never advances, so it is always the right answer
368
+ * to "what does a party who has not spoken here yet link to?".
369
+ */
370
+ #genesis = new Map();
371
+ /** DOD-M15-RELAYSLOTS-1 — read fresh at every auth. See `AgentRelayClientOpts.onlineToken`. */
372
+ #onlineToken;
373
+ /** DOD-M15-CORROBORATE-1 — where a relay's witness alert goes. See the opt of the same name. */
374
+ #onWitnessAlert;
375
+ /** DOD-M15-CORROBORATE-1 review F7 — where an UNREADABLE witness alert goes. */
376
+ #onWitnessUnreadable;
377
+ /**
378
+ * DOD-M15-RELAYSLOTS-1: the last refusal this relay gave us, classified. Kept because a log line
379
+ * reaches neither the operator asking why their agent is unreachable nor the code deciding
380
+ * whether a different relay would do any better.
381
+ */
382
+ #lastAuthRefusal = null;
383
+ /**
384
+ * review F6 — which credential refusal we have already explained in full, so a condition that
385
+ * lasts until a relogin is explained once rather than on every send. Reset with the refusal
386
+ * itself, so the next occurrence after a recovery is a first occurrence again.
387
+ */
388
+ #loggedCredentialRefusal = null;
389
+ /** The last classified auth refusal from this relay, or null if the last attempt succeeded. */
390
+ getLastAuthRefusal() {
391
+ return this.#lastAuthRefusal;
392
+ }
393
+ /**
394
+ * Drop any verdict from an earlier attempt — review HIGH-2.
395
+ *
396
+ * A METHOD rather than an inline `= null`, deliberately: assigning the field directly narrows its
397
+ * type to `null` for the rest of the enclosing method, and the later
398
+ * `#lastAuthRefusal?.reason` read then fails to compile against `never`. Clearing through a call
399
+ * keeps the declared type and says what the reset is for.
400
+ */
401
+ #clearAuthRefusal() {
402
+ this.#loggedCredentialRefusal = null;
403
+ this.#lastAuthRefusal = null;
404
+ }
405
+ #stream = null;
406
+ #connecting = null;
407
+ #closed = false;
408
+ #reconnectRetryMs = RELAY_RECONNECT_BASE_MS;
409
+ #reconnecting = false;
410
+ /**
411
+ * PER-SESSION acknowledgement state (session_id hex → the position AND the content at it).
412
+ *
413
+ * `seq` is the highest relay-assigned sequence, advanced by an ack or a deliver. The relay's
414
+ * `seq_counter` is per session and it rejects `last_seen_seq > seq_counter`, so each session's
415
+ * submit MUST carry that session's own high-water mark — NOT an agent-global one (which would make a newer session's first submit look
416
+ * ahead and get rejected).
417
+ *
418
+ * ⚠️ `hash` IS THE SAME FACT AS `seq`, WHICH IS WHY THEY LIVE IN ONE ENTRY — 033-ACKEMIT.
419
+ *
420
+ * `last_seen_seq` is a NUMBER: "I saw position 7" attests to a POSITION and never to CONTENT, so
421
+ * a signed acknowledgement was an unbacked number that only the relay's separate receipt gave any
422
+ * meaning to. `hash` is the content hash of the message AT that position, and the two are written
423
+ * together in `#bumpLastSeen` from ONE decode of ONE leaf. They cannot be assigned apart, so they
424
+ * cannot come to mean different messages — which is the defect this unit exists to remove, not a
425
+ * disagreement to reconcile later.
426
+ *
427
+ * Seeded at `registerSession` with `{ seq: 0, hash: genesisPrevRoot }`: the first message of a
428
+ * session has seen nothing, and that case is a DEFINED 32-byte value — the agreed starting point
429
+ * of this two-party chain — never a missing field and never a fallback to v1.
430
+ */
431
+ #lastSeen = new Map();
432
+ /** The one outstanding submit's resolver (global FIFO — ack carries no session_id). */
433
+ #pendingAck = null;
434
+ // The sender-signed structure1_cbor of the in-flight submit, paired with its ack so the
435
+ // SubmitResult can carry it (the ack itself only returns the relay's structure2_cbor).
436
+ #pendingStructure1 = null;
437
+ /**
438
+ * DOD-M15-SEALWIRE-1 bullet 5 (sent half) — OUR signature over the in-flight `#pendingStructure1`,
439
+ * paired with its ack for the same reason that one is: the ack returns the relay's record, never
440
+ * ours. Cleared wherever `#pendingStructure1` is cleared; the two must never drift apart, because
441
+ * a signature paired with the WRONG signed bytes is worse than no signature at all.
442
+ */
443
+ #pendingSignature = null;
444
+ // The in-flight submit's leaf kind (0x00 msg / 0x02 ctrl), paired with its ack so
445
+ // #captureReceipt can persist it alongside the Structure2/Structure1 carry bytes for the unilateral seal.
446
+ #pendingLeafKind = null;
447
+ /** The session_id hex of the in-flight submit, so its ack updates the right #lastSeen. */
448
+ #pendingAckSessionHex = null;
449
+ /**
450
+ * Resolver for the in-flight `client_record_assignment` ack. The ack carries
451
+ * no session_id (like hash_submit_ack), so at most one record is in flight; records are serialized on
452
+ * the same `#submitChain` as submits, guaranteeing no overlap.
453
+ */
454
+ #pendingRecord = null;
455
+ /**
456
+ * 054-SRSPLIT — the in-flight `relay_release_reservation`. At most one: releases are rare and
457
+ * serialized on the shared stream, exactly like `#pendingRecord`, whose ack also carries no id.
458
+ */
459
+ #pendingRelease = null;
460
+ /**
461
+ * DOD-M15-AWAYSCOPE-1 — in-flight liveness queries, keyed `sessionIdHex:counterpartyHex`.
462
+ *
463
+ * ⚠️ A MAP, NOT A SLOT, and the first version was a slot. One `AgentRelayClient` serves EVERY
464
+ * session an agent holds on a relay and both callers fan out with `Promise.all`, so `cello status`
465
+ * with two open conversations fired two queries on one stream: the second overwrote the first's
466
+ * resolver, the first answer resolved the WRONG session's promise, and the other waited the full
467
+ * submit timeout — blowing the caller's budget so every row came back unenriched. With more than
468
+ * one session the feature did nothing, and when it did something it could show the wrong
469
+ * counterparty. The key is what the RESPONSE carries, so an answer is matched to its question.
470
+ */
471
+ #pendingLiveness = new Map();
472
+ /** Serializes submits so only one is in flight at a time across all sessions. */
473
+ #submitChain = Promise.resolve();
474
+ /** session_id hex → { the live node to (re)dial from, inbound leaf handler, Option-B assignment to present }. */
475
+ #sessions = new Map();
476
+ constructor(opts) {
477
+ this.#relayPeerId = opts.relayPeerId;
478
+ this.#relayAddrs = opts.relayAddrs;
479
+ this.#keyProvider = opts.keyProvider;
480
+ this.#senderPubkey = opts.senderPubkey;
481
+ this.#logger = opts.logger;
482
+ this.#receiptStore = opts.receiptStore;
483
+ this.#sealLeafStore = opts.sealLeafStore;
484
+ this.#ownChainStore = opts.ownChainStore;
485
+ if (!this.#ownChainStore) {
486
+ /**
487
+ * ONCE PER CLIENT, AT CONSTRUCTION, and at ERROR because the consequence is invisible until a
488
+ * restart that may be days away — at which point this daemon silently starts a new chain
489
+ * mid-conversation and the counterparty refuses everything after it, for a reason that names
490
+ * tampering.
491
+ */
492
+ this.#logger.error("session.relay.own_chain.store_absent", {
493
+ relayPeerId: opts.relayPeerId,
494
+ impact: "this relay client was wired without a durable record of what this agent has said. " +
495
+ "Conversations work until the daemon restarts; after a restart, mid-conversation, this " +
496
+ "side starts a new chain and the counterparty refuses every message after it as though " +
497
+ "the record had been altered.",
498
+ });
499
+ }
500
+ this.#onlineToken = opts.onlineToken;
501
+ this.#onWitnessAlert = opts.onWitnessAlert;
502
+ this.#onWitnessUnreadable = opts.onWitnessUnreadable;
503
+ if (opts.reconnectRetryMs !== undefined)
504
+ this.#reconnectRetryMs = opts.reconnectRetryMs;
505
+ }
506
+ /** The agent's K_local public key as hex — the responder identity for auto-acknowledge. */
507
+ get senderPubkeyHex() {
508
+ return Buffer.from(this.#senderPubkey).toString("hex");
509
+ }
510
+ /**
511
+ * Register a session's inbound leaf handler + a live node to (re)dial the relay from
512
+ * (idempotent). Storing the node per session lets a pure-receiver session re-establish
513
+ * the shared stream if the node that originally dialed is torn down.
514
+ */
515
+ registerSession(sessionIdHex, node, onLeafDeliver, assignment,
516
+ /**
517
+ * 033-ACKEMIT — the session's genesis prev_root: what the FIRST message of this session
518
+ * acknowledges, before anything has been received.
519
+ *
520
+ * Supplied by the caller because `session-node-manager` is where the session record lives. When
521
+ * it is absent an ASSIGNMENT can still produce it (both participant keys and the session
522
+ * timestamp are on the carry), and that covers re-registration of a session whose row predates
523
+ * the column.
524
+ *
525
+ * ⚠️ WHEN NEITHER IS AVAILABLE THE SESSION CANNOT SUBMIT AT ALL — and this sentence used to say
526
+ * the opposite, that the first submit would claim position 0 with no hash. That shape no longer
527
+ * exists: `DOD-M15-SELFCHAIN-1` made both chain links required, so a session with no starting
528
+ * point has nothing for them to anchor to and every submit on it is refused by name. Registering
529
+ * without a seed is therefore a real fault, not a degraded mode.
530
+ */
531
+ genesisPrevRoot,
532
+ /**
533
+ * 069-ORDERPROOF — the relay key this session's ordering attestations verify under, from
534
+ * `relay_id` on the directory-signed assignment. Supplied by the caller because
535
+ * `session-node-manager` is where the durable copy lives, exactly as with the genesis above.
536
+ *
537
+ * ⚠️ ABSENT IS NOT A DEGRADED MODE. A session with no anchor refuses every attestation that
538
+ * arrives on it, by name, because the only alternative is checking a signature against a key
539
+ * the signer handed us.
540
+ */
541
+ relayAnchorHex) {
542
+ const existing = this.#sessions.get(sessionIdHex);
543
+ const carriedAssignment = assignment ?? existing?.assignment;
544
+ // The anchor is carried forward for the same reason the assignment is: a re-registration with
545
+ // nothing in hand must not silently strip a session of the key it verifies against.
546
+ const carriedAnchor = relayAnchorHex ?? carriedAssignment?.relayPubkeyHex ?? existing?.relayAnchorHex;
547
+ this.#sessions.set(sessionIdHex, {
548
+ node,
549
+ onLeafDeliver: onLeafDeliver ?? (() => { }),
550
+ // Carry the assignment forward across re-registration; never lose a recorded flag on re-register.
551
+ assignment: carriedAssignment,
552
+ ...(carriedAnchor ? { relayAnchorHex: carriedAnchor } : {}),
553
+ recorded: existing?.recorded ?? false,
554
+ recordRejected: existing?.recordRejected ?? false,
555
+ recordTimedOut: existing?.recordTimedOut ?? false,
556
+ });
557
+ /**
558
+ * SEED THE ACKNOWLEDGEMENT, never backwards. A session that already received a leaf holds a REAL
559
+ * `{ seq, hash }`; the genesis must not overwrite it. And a RESUMED session starts from what its
560
+ * durable record says it saw, never from 0 — the gap that left interrupted sessions unsealable
561
+ * (see resume-last-seen.ts).
562
+ */
563
+ const genesis = genesisPrevRoot ?? genesisFromAssignment(sessionIdHex, carriedAssignment);
564
+ if (genesis)
565
+ this.#genesis.set(sessionIdHex, genesis);
566
+ if (!this.#lastSeen.has(sessionIdHex) && genesis)
567
+ this.#lastSeen.set(sessionIdHex, { seq: 0, hash: genesis });
568
+ const resumed = this.#sealLeafStore ? lastSeenFromRecord(this.#sealLeafStore, this.#logger, this.#senderPubkey, sessionIdHex) : undefined;
569
+ if (resumed)
570
+ this.#bumpLastSeen(sessionIdHex, resumed.seq, resumed.hash);
571
+ // Eagerly present the assignment so the relay records the session (binds peer IDs, creates the
572
+ // session entry) BEFORE the first hash_submit or the counterparty's leaves arrive — the relay
573
+ // rejects frames for a session it has not recorded. Best-effort + serialized on the submit chain
574
+ // (the ack carries no session_id, so no overlap).
575
+ if (assignment) {
576
+ this.#submitChain = this.#submitChain
577
+ .then(() => this.#doRecord(node, sessionIdHex))
578
+ .then(() => undefined, () => undefined);
579
+ }
580
+ }
581
+ /**
582
+ * DOD-M15-RELAYAUTH-1 review H1 — **present the assignment and WAIT for the relay to say it
583
+ * recorded it.**
584
+ *
585
+ * `registerSession` above presents eagerly and forgets: the record is queued onto the submit chain
586
+ * and nobody can observe when it lands. That is correct for the witness relay, where the only
587
+ * requirement is "before the first submit". It is NOT sufficient for the relay that GATES A DIAL,
588
+ * because there the record is a precondition of an action we are about to take on another thread
589
+ * of the protocol — and losing that race denies a legitimate dial (review H1).
590
+ *
591
+ * Chained on `#submitChain` exactly like `#doSubmit`, so it cannot interleave with a submit on the
592
+ * same stream. Idempotent by construction: `#doRecord` returns `true` immediately once the session
593
+ * is recorded, so calling this straight after `registerSession` waits for the record that call
594
+ * already queued rather than sending a second one.
595
+ *
596
+ * Returns whether the relay recorded it. NEVER throws — a caller must be free to proceed on false
597
+ * (a dial that might be denied still beats no dial at all).
598
+ */
599
+ async recordAssignmentAndWait(node, sessionIdHex) {
600
+ const run = this.#submitChain.then(() => this.#doRecord(node, sessionIdHex));
601
+ this.#submitChain = run.then(() => undefined, () => undefined);
602
+ return run.catch(() => false);
603
+ }
604
+ /**
605
+ * Present the directory-signed assignment to the relay. Idempotent
606
+ * (no-op once `recorded`, or when the session has no assignment — direct/persisted/legacy sessions).
607
+ * The relay reconstructs the TBS and verifies the per-node directory signature against any consortium
608
+ * key. On success the session is recorded; the send/ack is single-in-flight (mirrors #doSubmit).
609
+ */
610
+ async #doRecord(node, sessionIdHex) {
611
+ if (this.#closed)
612
+ return false;
613
+ const sess = this.#sessions.get(sessionIdHex);
614
+ if (!sess || !sess.assignment)
615
+ return true;
616
+ // Nothing to PRESENT. The carry may exist only to name the relay this session's attestations
617
+ // verify against; there is no frame to send and nothing to wait for, so the submit proceeds.
618
+ if (!sess.assignment.assignmentSignature)
619
+ return true;
620
+ if (sess.recorded)
621
+ return true;
622
+ // Terminal rejection: a relay that cleanly rejected this assignment will reject it
623
+ // again — stop re-presenting so a misconfigured/forged case can't storm the shared stream.
624
+ if (sess.recordRejected)
625
+ return false;
626
+ if (!(await this.#ensureConnected(node)))
627
+ return false;
628
+ const stream = this.#stream;
629
+ if (!stream)
630
+ return false;
631
+ const a = sess.assignment;
632
+ const frame = encodeCbor({
633
+ type: "client_record_assignment",
634
+ session_id: new Uint8Array(Buffer.from(sessionIdHex, "hex")),
635
+ participant_a: a.participantA,
636
+ participant_b: a.participantB,
637
+ session_timestamp: a.sessionTimestamp,
638
+ initiator_session_peer_id: a.initiatorSessionPeerId,
639
+ counterparty_session_peer_id: a.counterpartySessionPeerId,
640
+ assignment_signature: a.assignmentSignature,
641
+ session_signature: a.sessionSignature,
642
+ });
643
+ let resolveRec;
644
+ const ackPromise = new Promise((r) => { resolveRec = r; });
645
+ this.#pendingRecord = resolveRec;
646
+ try {
647
+ stream.send(lp.encode.single(frame));
648
+ }
649
+ catch (err) {
650
+ if (this.#pendingRecord === resolveRec)
651
+ this.#pendingRecord = null;
652
+ this.#logger.warn("session.relay.record.send.failed", { relayPeerId: this.#relayPeerId, error: extractErrorMessage(err) });
653
+ return false;
654
+ }
655
+ let timer;
656
+ const timeout = new Promise((r) => { timer = setTimeout(() => r("timeout"), HASH_SUBMIT_TIMEOUT_MS); });
657
+ try {
658
+ const result = await Promise.race([ackPromise, timeout]);
659
+ if (result === "ok") {
660
+ sess.recorded = true;
661
+ sess.recordTimedOut = false;
662
+ this.#logger.info("session.relay.assignment.recorded", { relayPeerId: this.#relayPeerId, sessionShort: sessionIdHex.slice(0, 16) });
663
+ return true;
664
+ }
665
+ if (result === "timeout") {
666
+ // ONLY here reset the stream: a late ack on this superseded stream would settle a LATER submit's
667
+ // resolver (FIFO desync). recorded stays false ⇒ a transient timeout is retried on reconnect.
668
+ this.#resetStream();
669
+ sess.recordTimedOut = true;
670
+ this.#logger.warn("session.relay.assignment.record.timeout", { relayPeerId: this.#relayPeerId, sessionShort: sessionIdHex.slice(0, 16) });
671
+ return false;
672
+ }
673
+ if (result === "rejected") {
674
+ // The relay cleanly rejected (assignment_invalid) — the ack already arrived, the stream is HEALTHY.
675
+ // Do NOT reset (that would tear down sibling sessions' in-flight submits). Mark terminal so we stop
676
+ // re-presenting. The session has no relay witness; sends still complete via the direct path
677
+ // (sovereign-node redundancy) and a hash_submit will fail loud (session_not_found) — diagnosable.
678
+ sess.recordRejected = true;
679
+ this.#logger.warn("session.relay.assignment.record.rejected", { relayPeerId: this.#relayPeerId, sessionShort: sessionIdHex.slice(0, 16) });
680
+ return false;
681
+ }
682
+ // "closed": the stream dropped while the record was in flight (settled by the reader/close path).
683
+ // Transient — no reset needed (already gone); recorded stays false ⇒ retried on reconnect.
684
+ return false;
685
+ }
686
+ finally {
687
+ clearTimeout(timer);
688
+ if (this.#pendingRecord === resolveRec)
689
+ this.#pendingRecord = null;
690
+ }
691
+ }
692
+ /** Remove a session; caller closes the client when no sessions remain. */
693
+ unregisterSession(sessionIdHex) {
694
+ this.#sessions.delete(sessionIdHex);
695
+ this.#lastSeen.delete(sessionIdHex);
696
+ this.#genesis.delete(sessionIdHex);
697
+ }
698
+ hasSessions() {
699
+ return this.#sessions.size > 0;
700
+ }
701
+ /**
702
+ * Is THIS session already registered on this client?
703
+ *
704
+ * DOD-M15-RELAYLEAK-1 (review MEDIUM-5). The detached seal transport releases its registration
705
+ * when the submit finishes, and "did I register it, or did I find it already there?" is the
706
+ * difference between releasing my own and **pulling a live one out from under a concurrent
707
+ * caller** — which closes the client that caller is mid-`submitLeaf` on. `hasSessions()` cannot
708
+ * answer it: it is a count, and by then the id is in the set either way.
709
+ */
710
+ hasSession(sessionIdHex) {
711
+ return this.#sessions.has(sessionIdHex);
712
+ }
713
+ /** Settle the one outstanding submit (if any) exactly once. */
714
+ #settlePending(r) {
715
+ const resolve = this.#pendingAck;
716
+ this.#pendingAck = null;
717
+ this.#pendingAckSessionHex = null;
718
+ this.#pendingStructure1 = null;
719
+ this.#pendingSignature = null;
720
+ this.#pendingLeafKind = null;
721
+ if (resolve)
722
+ resolve(r);
723
+ }
724
+ /**
725
+ * Tear down the current stream (drops it so the next submit re-dials). Used on a submit
726
+ * timeout: because `hash_submit_ack` carries no session_id, ack↔submit matching is purely
727
+ * FIFO, so a LATE ack from a timed-out submit would settle the NEXT submit's resolver and
728
+ * shift every subsequent ack by one. Resetting the stream prevents that desync (the relay
729
+ * re-auths + re-drains on the reconnect).
730
+ */
731
+ #resetStream() {
732
+ const stream = this.#stream;
733
+ this.#stream = null;
734
+ if (stream) {
735
+ try {
736
+ void stream.close();
737
+ }
738
+ catch { /* best-effort */ }
739
+ }
740
+ }
741
+ /**
742
+ * Advance this session's acknowledgement to a counterparty leaf: the POSITION and the CONTENT
743
+ * AT IT, written together (033-ACKEMIT).
744
+ *
745
+ * `contentHash` comes from the same `decodeStructure1` of the same leaf that produced `seq`, so
746
+ * the pair describes one message by construction. There is no path that advances one without the
747
+ * other, and that is deliberate: a `last_seen_seq` and a `last_seen_hash` that could drift apart
748
+ * would let this daemon sign an acknowledgement of a message it never saw.
749
+ *
750
+ * Monotonic on `seq` — a re-delivery of an earlier leaf must not walk the acknowledgement
751
+ * backwards, and it must not swap the hash under an unchanged position either.
752
+ */
753
+ #bumpLastSeen(sessionIdHex, seq, contentHash) {
754
+ if (seq < 0)
755
+ return;
756
+ const prev = this.#lastSeen.get(sessionIdHex);
757
+ if (prev && seq <= prev.seq)
758
+ return;
759
+ this.#lastSeen.set(sessionIdHex, { seq, hash: contentHash });
760
+ }
761
+ /** True if this Structure-1 leaf was authored by US (sender_pubkey === our K_local). */
762
+ #isOwnLeaf(structure1Cbor) {
763
+ // Structure 1 = [version, content_hash, sender_pubkey, session_id, last_seen_seq, ts], plus
764
+ // last_seen_hash at index 6 on a v2 claim (020-ACKHASH). sender_pubkey is index 2 in both.
765
+ const s1 = decodeStructure1(structure1Cbor);
766
+ /**
767
+ * ⚠️ `false` IS THE DANGEROUS DIRECTION, NOT THE CONSERVATIVE ONE — review F8, correcting a
768
+ * comment that claimed the opposite.
769
+ *
770
+ * It read "conservative: don't suppress a real counterparty leaf." At the call site, `false`
771
+ * means our OWN echoed leaf gets `#bumpLastSeen` applied — which the comment there says must not
772
+ * happen — and is written into the seal-leaf log as a COUNTERPARTY leaf. Returning `false`
773
+ * wrongly corrupts the log; returning `true` wrongly drops one witness signal.
774
+ *
775
+ * It stays `false` because the input set is bytes the relay already decoded and accepted, so an
776
+ * unreadable leaf here means this daemon and the relay disagree about a frame the relay passed —
777
+ * not a hostile peer. Widening it to every layout failure (it was only a CBOR throw before) does
778
+ * not change that: the relay's own decoder gates every path that reaches here.
779
+ */
780
+ if (!s1.ok)
781
+ return false;
782
+ return Buffer.from(s1.fields.senderPubkey).equals(Buffer.from(this.#senderPubkey));
783
+ }
784
+ /**
785
+ * The relay pubkey the DIRECTORY named for a session — 069-ORDERPROOF, and the only key a relay
786
+ * ordering attestation is ever checked against.
787
+ *
788
+ * It rides the assignment carry, which is built from an assignment whose FROST signature this
789
+ * daemon verified before the session began, so it is anchored to something the relay does not
790
+ * control. `undefined` means no such key was recorded, and every attestation on that session is
791
+ * then refused for want of an anchor — never accepted against whatever key the frame supplies.
792
+ */
793
+ #expectedRelayPubkeyHex(sessionIdHex) {
794
+ return this.#sessions.get(sessionIdHex)?.relayAnchorHex;
795
+ }
796
+ /**
797
+ * Verify a relay `hash_submit_ack`'s ordering attestation and durably store the receipt.
798
+ *
799
+ * The relay signs the session, the content hash, the position it assigned and the running root of
800
+ * the tree after this leaf. We verify it **against the relay key on the directory-signed
801
+ * assignment**, and refuse otherwise — missing, malformed and mismatched all take the one path.
802
+ *
803
+ * Returns true to REJECT the submit. A send must not settle ok on a position nothing witnessed.
804
+ */
805
+ #captureReceipt(frame, structure1Cbor, seq) {
806
+ // ⚠️ REJECTS WHEN THE CHECK COULD NOT BE RUN, not only when it failed — `readSubmittedLeaf`
807
+ // carries the reasoning. Both of these used to settle the send ok having verified nothing.
808
+ const leaf = readSubmittedLeaf(structure1Cbor, seq);
809
+ if (leaf.kind === "none")
810
+ return false;
811
+ if (leaf.kind === "unreadable") {
812
+ this.#logger.warn(leaf.event, { seq, structure1Reason: leaf.reason, impact: "this side cannot read the leaf it just submitted, so the relay's ordering attestation has " +
813
+ "nothing to be checked against. The send is refused rather than settled on a position " +
814
+ "nothing witnessed." });
815
+ return true;
816
+ }
817
+ const { contentHash, sessionId } = leaf;
818
+ const sessionIdHex = Buffer.from(sessionId).toString("hex");
819
+ const ev = evaluateRelayAck({
820
+ sessionId,
821
+ contentHash,
822
+ runningRoot: frame["running_root"] instanceof Uint8Array ? frame["running_root"] : undefined,
823
+ sessionIdHex,
824
+ agentPubkeyHex: this.senderPubkeyHex,
825
+ expectedRelayPubkeyHex: this.#expectedRelayPubkeyHex(sessionIdHex),
826
+ relayId: typeof frame["relay_id"] === "string" ? frame["relay_id"] : undefined,
827
+ relaySignature: frame["relay_signature"] instanceof Uint8Array ? frame["relay_signature"] : undefined,
828
+ timestamp: typeof frame["timestamp"] === "number" ? frame["timestamp"] : undefined,
829
+ sequenceNumber: seq,
830
+ });
831
+ switch (ev.kind) {
832
+ case "refused":
833
+ /**
834
+ * ONE OUTCOME, and the cause is in the log rather than in the control flow.
835
+ *
836
+ * The submit is rejected for all five causes. A message whose position no assigned relay
837
+ * attested has no ordering evidence behind it, and letting the send settle ok would report
838
+ * a witnessed message that nothing witnessed. `attestation_absent` takes this path with the
839
+ * rest deliberately: the party who can produce the absence is the relay, which is the party
840
+ * the check exists to constrain, so a softer answer for absence is a mute button with our
841
+ * own name on it.
842
+ */
843
+ this.#logger.warn("relay.attestation.refused", {
844
+ seq,
845
+ session: sessionIdHex,
846
+ cause: ev.cause,
847
+ hashShort: Buffer.from(contentHash).toString("hex").slice(0, 16),
848
+ impact: "the relay's ordering attestation for this message was refused, so this send did not " +
849
+ "settle and no evidence was stored. This agent holds no proof of where this message " +
850
+ "sits in the conversation, and nothing was recorded that could later be presented as one.",
851
+ });
852
+ return true;
853
+ case "store": {
854
+ if (!this.#receiptStore)
855
+ return false;
856
+ try {
857
+ const wrote = this.#receiptStore.store(ev.receipt, Date.now());
858
+ if (wrote) {
859
+ this.#logger.info("relay.receipt.stored", { seq, hashShort: ev.receipt.hashHex.slice(0, 16), relayShort: ev.receipt.relayId.slice(0, 16) });
860
+ }
861
+ // Record this OWN leaf in the seal-leaf log WITH its relay receipt (the
862
+ // relay's signature pins content_hash→seq — the teeth that stop a supplier reordering its own
863
+ // leaves). structure2_cbor rides the ack we just verified; structure1Cbor + the leaf kind are this
864
+ // submit's paired in-flight values. Best-effort + separate from the receipt write.
865
+ const structure2Cbor = frame["structure2_cbor"] instanceof Uint8Array ? frame["structure2_cbor"] : undefined;
866
+ if (this.#sealLeafStore && structure2Cbor && structure1Cbor && this.#pendingLeafKind !== null) {
867
+ /**
868
+ * ⚠️ **THE AUTHOR COMES FROM THE SIGNED BYTES, AND THE RECEIPT ONLY ATTACHES TO OUR OWN
869
+ * LEAF — 034-CARRYLEAF review F3.**
870
+ *
871
+ * This wrote `senderPubkeyHex: this.senderPubkeyHex` unconditionally, which was true for
872
+ * as long as the only thing a client could submit was its own leaf. `witnessReceivedLeaf`
873
+ * ended that: on a counter-submit `structure1Cbor` holds the COUNTERPARTY's bytes, so
874
+ * this row labelled their leaf as ours — and attached a relay receipt to it, which is the
875
+ * one thing that must never happen to a leaf we did not author, because a receipt is what
876
+ * pins OUR leaves to a sequence we could otherwise renumber.
877
+ *
878
+ * It was inert on the wire by luck: `sender_pubkey_hex` is not transmitted and the
879
+ * directory re-derives the author from `structure2_cbor`. It was never inert locally —
880
+ * the store is `INSERT OR IGNORE` on `(agent, session, sequence)`, and the ack arrives
881
+ * BEFORE the `leaf_deliver` echo, so this row won and the correct one was silently
882
+ * dropped.
883
+ *
884
+ * Same rule as everywhere else on this path: the identity comes from inside the bytes
885
+ * the author signed, never from an ambient value that happens to be right today.
886
+ */
887
+ const authored = decodeStructure1(structure1Cbor);
888
+ const authorHex = authored.ok
889
+ ? Buffer.from(authored.fields.senderPubkey).toString("hex")
890
+ : this.senderPubkeyHex;
891
+ const ourOwnLeaf = authorHex === this.senderPubkeyHex;
892
+ this.#sealLeafStore.store(this.senderPubkeyHex, ev.receipt.sessionIdHex, {
893
+ sequenceNumber: seq,
894
+ leafKind: this.#pendingLeafKind,
895
+ senderPubkeyHex: authorHex,
896
+ structure2Cbor,
897
+ structure1Cbor,
898
+ ...(ourOwnLeaf
899
+ ? {
900
+ relayId: ev.receipt.relayId,
901
+ relayTimestamp: ev.receipt.timestamp,
902
+ relaySignatureHex: ev.receipt.signatureHex,
903
+ ...(ev.receipt.runningRootHex ? { relayRunningRootHex: ev.receipt.runningRootHex } : {}),
904
+ }
905
+ : {}),
906
+ }, Date.now());
907
+ }
908
+ }
909
+ catch (err) {
910
+ // A durable-evidence write failure must be LOUD — the relay will not re-emit this ack, so a
911
+ // swallowed write permanently loses a verified receipt.
912
+ this.#logger.error("relay.receipt.store_failed", { seq, error: extractErrorMessage(err) });
913
+ }
914
+ return false;
915
+ }
916
+ }
917
+ }
918
+ #dispatch(frame) {
919
+ const type = frame["type"];
920
+ if (type === "hash_submit_ack") {
921
+ const seq = typeof frame["sequence_number"] === "number" ? frame["sequence_number"] : -1;
922
+ // DO NOT advance #lastSeen here: the ack is for OUR OWN leaf. last_seen_seq must track
923
+ // the highest COUNTERPARTY sequence we've observed — the directory's causal check rejects a leaf
924
+ // whose last_seen_seq exceeds the max sequence of OTHER-sender leaves before it. Advancing on our
925
+ // own ack would inflate it and trip causal_chain_violated on a subsequent submit (e.g. the SEAL
926
+ // leaf after a sent message).
927
+ // Pair the relay's committed structure2_cbor with our in-flight structure1_cbor so
928
+ // the SubmitResult carries the full signed ordering record for the self-ordering content frame.
929
+ // Captured BEFORE #settlePending clears #pendingStructure1.
930
+ const s2 = frame["structure2_cbor"];
931
+ const structure2Cbor = s2 instanceof Uint8Array ? s2 : undefined;
932
+ const structure1Cbor = this.#pendingStructure1 ?? undefined;
933
+ // Captured with structure1Cbor and BEFORE #settlePending clears both — see #pendingSignature.
934
+ const senderSignature = this.#pendingSignature ?? undefined;
935
+ // Verify the relay's signed ordering record and durably store the receipt BEFORE
936
+ // settling (which clears #pendingStructure1, the source of the content hash + session id). A
937
+ // signed-but-INVALID ACK rejects the submit so the send does not settle ok on an unverified sequence.
938
+ const rejectSubmit = this.#captureReceipt(frame, structure1Cbor, seq);
939
+ this.#settlePending(rejectSubmit
940
+ /**
941
+ * ONE REASON for every refusal cause — 069-ORDERPROOF Done When 3. A distinct reason per
942
+ * cause would be observable, and the clause requires that a missing attestation and a
943
+ * malformed one look the same from outside. The cause is named in the log line above.
944
+ */
945
+ ? { ok: false, reason: "relay_ack_unverified" }
946
+ : seq >= 0
947
+ ? { ok: true, sequence_number: seq, structure1_cbor: structure1Cbor, structure2_cbor: structure2Cbor, sender_signature: senderSignature }
948
+ : { ok: false, reason: "relay_ack_malformed" });
949
+ }
950
+ else if (type === "session_liveness_response" || type === "session_liveness_refused") {
951
+ /**
952
+ * DOD-M15-AWAYSCOPE-1 — the relay's answer to "is my counterparty reachable, and is anyone
953
+ * attending it". Decoded through the shared codec rather than read field by field here: that
954
+ * codec is what refuses an attendance paired with a non-alive liveness, and re-reading the
955
+ * fields inline would quietly reintroduce the tolerance branch it exists to prevent.
956
+ *
957
+ * A REFUSAL resolves the waiter rather than dropping it. The relay refuses a query naming a
958
+ * session the caller is not in, and it deliberately does not say which of the two reasons —
959
+ * so the honest local answer is "unknown", not a hang until the timeout.
960
+ */
961
+ /**
962
+ * ⚠️ A REFUSAL NAMES NOTHING, so it cannot be matched. The relay refuses without echoing the
963
+ * session or the subject — deliberately, because telling "no such session" from "not your
964
+ * session" is the enumeration signal `DOD-M15-RELAYAUTH-1` closed. So there is nothing to key
965
+ * on, and the only sound reading is that every outstanding query on this stream was refused:
966
+ * the refusal is a property of the CALLER, and the caller is the same for all of them.
967
+ */
968
+ if (type === "session_liveness_refused") {
969
+ for (const r of this.#pendingLiveness.values())
970
+ r({ liveness: "unknown", observedAt: 0, refused: true });
971
+ this.#pendingLiveness.clear();
972
+ }
973
+ else {
974
+ const decoded = decodeSessionLivenessResponse(encodeCbor(frame));
975
+ if (decoded === null) {
976
+ // Malformed fails exactly like missing. A frame this build cannot read is not evidence of
977
+ // anything, and reporting it as 'gone' would invent an observation nobody made. It also
978
+ // cannot be matched to a waiter, so every outstanding query is answered 'unknown' rather
979
+ // than left to time out on a stream that just proved it speaks a shape we cannot read.
980
+ this.#logger.warn("session.relay.liveness.response_malformed", {
981
+ relayPeerId: this.#relayPeerId,
982
+ impact: "the counterparty's reachability is reported as unknown, which is what it is",
983
+ });
984
+ for (const r of this.#pendingLiveness.values())
985
+ r({ liveness: "unknown", observedAt: 0 });
986
+ this.#pendingLiveness.clear();
987
+ }
988
+ else {
989
+ // MATCHED ON WHAT THE ANSWER ITSELF NAMES. Resolving the most recent waiter instead would
990
+ // be right exactly once — when only one query is outstanding — and silently wrong the
991
+ // moment an agent holds two conversations through one relay, which is ordinary.
992
+ const key = livenessKey(decoded.session_id, decoded.counterparty_pubkey);
993
+ const r = this.#pendingLiveness.get(key);
994
+ if (r) {
995
+ this.#pendingLiveness.delete(key);
996
+ r({
997
+ liveness: decoded.liveness,
998
+ observedAt: decoded.observed_at,
999
+ ...(decoded.attendance !== undefined ? { attendance: decoded.attendance } : {}),
1000
+ ...(decoded.attendance_observed_at !== undefined ? { attendanceObservedAt: decoded.attendance_observed_at } : {}),
1001
+ });
1002
+ }
1003
+ else {
1004
+ // A late answer to a query that already timed out, or one nobody asked for. Dropped —
1005
+ // handing it to a different session's waiter is exactly the defect this key exists for.
1006
+ this.#logger.debug("session.relay.liveness.response_unmatched", { relayPeerId: this.#relayPeerId });
1007
+ }
1008
+ }
1009
+ }
1010
+ }
1011
+ else if (type === "hash_submit_error") {
1012
+ const reason = typeof frame["reason"] === "string" ? frame["reason"] : "relay_rejected";
1013
+ // Carry the relay's `detail` through — see `SubmitResult`. Reading the class and discarding
1014
+ // what happened is how a refusal arrives as a bare code with no cause attached to it.
1015
+ const detail = typeof frame["detail"] === "string" ? frame["detail"] : undefined;
1016
+ // DOD-M15-RELAYABUSE-1: the relay knows when its window clears and says so. Carried, not
1017
+ // dropped — `#doSubmit` waits it out and resubmits, so a throttle never reaches the operator.
1018
+ const rawRetry = frame["retry_after_ms"];
1019
+ const retry_after_ms = typeof rawRetry === "number" && Number.isFinite(rawRetry) && rawRetry > 0 ? rawRetry : undefined;
1020
+ const awaited = frame["awaited_seq"];
1021
+ this.#settlePending({
1022
+ ok: false, reason, ...(detail ? { detail } : {}), ...(retry_after_ms !== undefined ? { retry_after_ms } : {}),
1023
+ ...(typeof awaited === "number" && Number.isInteger(awaited) && awaited > 0 ? { awaited_seq: awaited } : {}),
1024
+ });
1025
+ }
1026
+ else if (type === "relay_release_ok") {
1027
+ // 054-SRSPLIT. `released` is the relay's own answer to "did I actually hold one?" — a
1028
+ // release of a slot we never had is a true `false`, not a failure, and the caller says so.
1029
+ const r = this.#pendingRelease;
1030
+ this.#pendingRelease = null;
1031
+ if (r)
1032
+ r(frame["released"] === true);
1033
+ }
1034
+ else if (type === "assignment_ok") {
1035
+ // The relay verified + recorded our client-presented assignment.
1036
+ const r = this.#pendingRecord;
1037
+ this.#pendingRecord = null;
1038
+ if (r)
1039
+ r("ok");
1040
+ }
1041
+ else if (type === "assignment_invalid") {
1042
+ // The relay rejected the assignment (e.g. directory_signature_invalid — not signed by any
1043
+ // consortium directory). Fail LOUD: the session has no relay witness until this is resolved.
1044
+ const reason = typeof frame["reason"] === "string" ? frame["reason"] : "unknown";
1045
+ this.#logger.warn("session.relay.assignment.invalid", { relayPeerId: this.#relayPeerId, reason });
1046
+ /**
1047
+ * DOD-M15-RELAYSLOTS-1 review M2 — **the tuple cap has to reach the operator too.**
1048
+ *
1049
+ * Clause 7 says EVERY refusal reaches them with a cause and an affordance, and this one was
1050
+ * arriving as `assignment_invalid` in a log. It is also the refusal most likely to hit a real
1051
+ * person, for the reason the order itself gives: nobody knows what sessions they have open, so
1052
+ * whoever hits it believes they have none. Routed through the same classifier and onto the
1053
+ * same surface as every other relay refusal.
1054
+ */
1055
+ const concurrent = typeof frame["concurrent_sessions"] === "number" ? frame["concurrent_sessions"] : undefined;
1056
+ const cap = typeof frame["session_cap"] === "number" ? frame["session_cap"] : undefined;
1057
+ this.#lastAuthRefusal = classifyRelayAuthRefusal(reason, {
1058
+ ...(concurrent !== undefined ? { slotsHeld: concurrent } : {}),
1059
+ ...(cap !== undefined ? { slotCap: cap } : {}),
1060
+ });
1061
+ const r = this.#pendingRecord;
1062
+ this.#pendingRecord = null;
1063
+ if (r)
1064
+ r("rejected");
1065
+ }
1066
+ else if (type === "leaf_deliver") {
1067
+ const seq = typeof frame["sequence_number"] === "number" ? frame["sequence_number"] : -1;
1068
+ const sidHex = Buffer.from(toU8(frame["session_id"])).toString("hex");
1069
+ const s1 = toU8(frame["structure1_cbor"]);
1070
+ /**
1071
+ * ONE DECODE, TWO CONSUMERS — 033-ACKEMIT. The acknowledgement bump and the seal-leaf capture
1072
+ * below both need the sender's own signed fields, and they must agree about which leaf they
1073
+ * are looking at. Decoding twice would let a future edit change one read and not the other.
1074
+ */
1075
+ const deliveredS1 = decodeStructure1(s1);
1076
+ // Advance last_seen ONLY for a COUNTERPARTY leaf. The relay also echoes our OWN
1077
+ // leaf back as a leaf_deliver — that must NOT advance it (same reason as the ack above).
1078
+ const authoredByUs = this.#isOwnLeaf(s1);
1079
+ /**
1080
+ * The POSITION and the CONTENT AT IT, from the counterparty's own signed bytes.
1081
+ *
1082
+ * ⚠️ THE HASH COMES FROM INSIDE `structure1_cbor`, NEVER FROM AN ENVELOPE FIELD. The frame
1083
+ * also carries `structure2_cbor`, which the RELAY built — taking the hash from there would key
1084
+ * our acknowledgement on a value the witness supplies, and a tampering relay could then make
1085
+ * us sign an acknowledgement of content the counterparty never sent. Index 1 of Structure 1 is
1086
+ * inside the bytes the counterparty signed, so it is the one copy neither we nor the relay can
1087
+ * move.
1088
+ *
1089
+ * A leaf whose layout this build cannot name advances NOTHING — position included. The
1090
+ * previous code advanced `seq` from the envelope regardless, so an unreadable leaf could move
1091
+ * the acknowledgement forward while leaving the hash behind it; refusing to advance keeps the
1092
+ * pair describing one real message, and the relay's own decoder already gates what reaches
1093
+ * here.
1094
+ */
1095
+ if (seq >= 0 && !authoredByUs) {
1096
+ if (deliveredS1.ok) {
1097
+ this.#bumpLastSeen(sidHex, seq, deliveredS1.fields.contentHash);
1098
+ }
1099
+ else {
1100
+ this.#logger.warn("relay.leaf_deliver.unreadable", {
1101
+ seq,
1102
+ session: sidHex,
1103
+ structure1Reason: deliveredS1.reason,
1104
+ impact: "this delivered leaf could not be read, so this session's acknowledgement was NOT " +
1105
+ "advanced to it. The next message this agent sends will acknowledge the last leaf it " +
1106
+ "could read, which is honest — it never claims to have seen something it could not.",
1107
+ });
1108
+ }
1109
+ }
1110
+ // Record the COUNTERPARTY's delivered leaf in the seal-leaf log (no relay
1111
+ // receipt — the relay does not ack-sign a delivery to the recipient). It is pinned at seal by the
1112
+ // absent party's sender_signature (unforgeable) + sequence contiguity against our receipt-pinned own
1113
+ // leaves. Our OWN echoed leaf is skipped here (it is recorded WITH its receipt on the ack path).
1114
+ /**
1115
+ * ─── OUR OWN LEAF, WITNESSED BY SOMEBODY ELSE — 034-CARRYLEAF review F2 ──────────────────
1116
+ *
1117
+ * ⚠️ **WITHOUT THIS, MAKING THE COUNTERPARTY ABLE TO WITNESS OUR LEAF COST US THE RECEIPT.**
1118
+ *
1119
+ * When the counterparty counter-submits a leaf WE authored, the relay assigns it a position
1120
+ * and delivers it to us — and `authoredByUs` is true, so the capture below skipped it. We
1121
+ * never submitted it ourselves, so no ack ever arrived and the ack path never wrote a row
1122
+ * either. The result was a permanent hole at that position in our own carry: the unilateral
1123
+ * seal refused it as `seal_carry_noncontiguous`, and the bilateral one refused to co-sign a
1124
+ * root it could not judge. **An honest sender whose relay hiccuped once lost the receipt for
1125
+ * the entire conversation** — for a message sitting in their own transcript.
1126
+ *
1127
+ * So the leaf is stored, **with NO relay receipt**, because we hold none: nobody acked it to
1128
+ * us. That asymmetry is exactly the one the seal design already relies on. A bilateral seal
1129
+ * needs a contiguous chain and gets one. A UNILATERAL seal additionally requires every one of
1130
+ * our OWN leaves to carry a receipt — so a party who never witnesses their own messages still
1131
+ * cannot seal alone on them (`unilateral_own_leaf_unwitnessed`), which is precisely what
1132
+ * `DOD-M15-WITHHOLD-SEAL-1` intends.
1133
+ *
1134
+ * `INSERT OR IGNORE` keeps whichever row lands first, and a real receipt-bearing row for the
1135
+ * same position can only come from our own ack — which cannot exist here, or we would have
1136
+ * submitted it ourselves.
1137
+ */
1138
+ if (this.#sealLeafStore && seq >= 0 && authoredByUs && deliveredS1.ok) {
1139
+ const s2Own = frame["structure2_cbor"];
1140
+ if (s2Own instanceof Uint8Array && s1.length > 0) {
1141
+ try {
1142
+ const wrote = this.#sealLeafStore.store(this.senderPubkeyHex, sidHex, {
1143
+ sequenceNumber: seq,
1144
+ leafKind: typeof frame["leaf_kind"] === "number" ? frame["leaf_kind"] : LEAF_KIND_MSG,
1145
+ senderPubkeyHex: this.senderPubkeyHex,
1146
+ structure2Cbor: s2Own,
1147
+ structure1Cbor: s1,
1148
+ }, Date.now());
1149
+ if (wrote) {
1150
+ this.#logger.info("relay.seal_leaf.own.witnessed_by_counterparty", {
1151
+ seq,
1152
+ session: sidHex,
1153
+ impact: "a message THIS agent wrote was witnessed by the counterparty rather than by us — " +
1154
+ "our own submit did not land. The leaf is kept so this conversation can still be " +
1155
+ "sealed together; sealing it alone would still need a receipt we do not hold.",
1156
+ });
1157
+ }
1158
+ }
1159
+ catch (err) {
1160
+ this.#logger.error("relay.seal_leaf.own.store_failed", { seq, session: sidHex, error: extractErrorMessage(err) });
1161
+ }
1162
+ }
1163
+ }
1164
+ if (this.#sealLeafStore && seq >= 0 && !authoredByUs) {
1165
+ const s2 = frame["structure2_cbor"];
1166
+ const structure2Cbor = s2 instanceof Uint8Array ? s2 : undefined;
1167
+ // Review F1: a raw positional read of index 2 with NO version and NO length check lived here
1168
+ // — it accepted a v3 array, a 40-element array, anything with 32 bytes at index 2, and fed
1169
+ // the result into the seal-leaf log as the counterparty's identity. It is in the same file as
1170
+ // #isOwnLeaf and #captureReceipt and was simply missed by the order's reader list, which is
1171
+ // exactly the "next layout change has to find them again" problem the shared decoder exists
1172
+ // to end. Behaviour for every relay-accepted leaf is unchanged; the fail-open closes.
1173
+ const senderHex = deliveredS1.ok
1174
+ ? Buffer.from(deliveredS1.fields.senderPubkey).toString("hex")
1175
+ : undefined;
1176
+ if (structure2Cbor && s1.length > 0 && senderHex) {
1177
+ try {
1178
+ this.#sealLeafStore.store(this.senderPubkeyHex, sidHex, {
1179
+ sequenceNumber: seq,
1180
+ leafKind: typeof frame["leaf_kind"] === "number" ? frame["leaf_kind"] : LEAF_KIND_MSG,
1181
+ senderPubkeyHex: senderHex,
1182
+ structure2Cbor,
1183
+ structure1Cbor: s1,
1184
+ }, Date.now());
1185
+ }
1186
+ catch (err) {
1187
+ this.#logger.error("relay.seal_leaf.counterparty.store_failed", { seq, session: sidHex, error: extractErrorMessage(err) });
1188
+ }
1189
+ }
1190
+ else {
1191
+ this.#logger.warn("relay.seal_leaf.counterparty.capture_skipped", { seq, session: sidHex, hasS2: !!structure2Cbor, hasS1: s1.length > 0, hasSender: !!senderHex });
1192
+ }
1193
+ }
1194
+ /**
1195
+ * ─── THE RECIPIENT KEEPS THE ORDERING PROOF TOO — 069-ORDERPROOF unit 2 ──────────────────
1196
+ *
1197
+ * Before this, the relay's attestation went to the SENDER alone, on the submit
1198
+ * acknowledgement. The recipient held no proof of where a single message of the conversation
1199
+ * sat. That made one party's copy the only copy, which is the dependency this order exists to
1200
+ * remove wearing a different coat.
1201
+ *
1202
+ * It is verified here exactly as on the send path — against the relay key the DIRECTORY named
1203
+ * for this session, never `relay_id` on the frame — and an attestation that does not verify is
1204
+ * refused and NOT stored. Nothing else changes: the leaf is still delivered to the session, the
1205
+ * acknowledgement still advances, and no tree, root or leaf count is touched. This is evidence
1206
+ * recorded beside the chain, never in it.
1207
+ *
1208
+ * Our own echoed leaf comes through here too. Storing it is a no-op — the ack path already
1209
+ * wrote the same row at the same position, and the store is INSERT OR IGNORE — but it is not
1210
+ * skipped, because the echo is the only copy a leaf COUNTER-SUBMITTED by the counterparty
1211
+ * ever produces for us.
1212
+ */
1213
+ if (this.#receiptStore && seq >= 0 && deliveredS1.ok) {
1214
+ const ev = evaluateRelayAck({
1215
+ sessionId: deliveredS1.fields.sessionId,
1216
+ contentHash: deliveredS1.fields.contentHash,
1217
+ runningRoot: frame["running_root"] instanceof Uint8Array ? frame["running_root"] : undefined,
1218
+ sessionIdHex: sidHex,
1219
+ agentPubkeyHex: this.senderPubkeyHex,
1220
+ expectedRelayPubkeyHex: this.#expectedRelayPubkeyHex(sidHex),
1221
+ relayId: typeof frame["relay_id"] === "string" ? frame["relay_id"] : undefined,
1222
+ relaySignature: frame["relay_signature"] instanceof Uint8Array ? frame["relay_signature"] : undefined,
1223
+ timestamp: typeof frame["timestamp"] === "number" ? frame["timestamp"] : undefined,
1224
+ sequenceNumber: seq,
1225
+ });
1226
+ if (ev.kind === "refused") {
1227
+ /**
1228
+ * LOUD, AND THE DELIVERY STILL LANDS. This is the one place the two halves differ, and
1229
+ * deliberately: on the send path a refusal means OUR message has no witness, so the send
1230
+ * must not settle. Here the counterparty's message has already been written and read —
1231
+ * dropping it would let a relay silence a conversation by withholding its own signature,
1232
+ * which hands the party the check constrains a far better weapon than the one it takes
1233
+ * away. So the message arrives and the evidence is recorded as absent.
1234
+ */
1235
+ this.#logger.warn("relay.attestation.delivered.refused", {
1236
+ seq,
1237
+ session: sidHex,
1238
+ cause: ev.cause,
1239
+ impact: "this message arrived, but the relay's proof of WHERE it sits in the conversation did " +
1240
+ "not, so this agent cannot later show a third party that this message was ordered here. " +
1241
+ "The message itself is unaffected and is in the transcript.",
1242
+ });
1243
+ }
1244
+ else {
1245
+ try {
1246
+ this.#receiptStore.store(ev.receipt, Date.now());
1247
+ }
1248
+ catch (err) {
1249
+ // A durable-evidence write failure is LOUD: the relay never re-sends a delivery, so a
1250
+ // swallowed write permanently loses this side's only copy of the ordering proof.
1251
+ this.#logger.error("relay.attestation.delivered.store_failed", {
1252
+ seq, session: sidHex, error: extractErrorMessage(err),
1253
+ });
1254
+ }
1255
+ }
1256
+ }
1257
+ const session = this.#sessions.get(sidHex);
1258
+ if (session && seq >= 0) {
1259
+ session.onLeafDeliver({
1260
+ sequence_number: seq,
1261
+ leaf_kind: typeof frame["leaf_kind"] === "number" ? frame["leaf_kind"] : LEAF_KIND_MSG,
1262
+ structure1_cbor: s1,
1263
+ structure2_cbor: toU8(frame["structure2_cbor"]),
1264
+ // Tell the consumer whether this is our own echoed leaf (so the
1265
+ // auto-acknowledge gate never co-signs in response to its own SEAL ctrl leaf).
1266
+ authored_by_us: authoredByUs,
1267
+ });
1268
+ }
1269
+ }
1270
+ else if (type === "session_witness_alert") {
1271
+ /**
1272
+ * DOD-M15-CORROBORATE-1 — **the relay is telling us what it saw, and this is where it lands.**
1273
+ *
1274
+ * A relay is not trusted to send a well-formed frame any more than a peer is, so every field
1275
+ * is checked before anything is reported. A frame that does not decode is a MISBEHAVING OR
1276
+ * SKEWED RELAY, not an alert: reporting it as one would let a broken build manufacture
1277
+ * accusations against a counterparty who did nothing. It is logged loudly and goes no further.
1278
+ */
1279
+ const sid = frame["session_id"];
1280
+ const rawRelayId = frame["relay_id"];
1281
+ const observedAt = frame["observed_at"];
1282
+ const submitterIsCounterparty = frame["submitter_is_counterparty"];
1283
+ const sidBytes = sid instanceof Uint8Array || Buffer.isBuffer(sid) ? toU8(sid) : new Uint8Array();
1284
+ const wellFormed = sidBytes.length === 16
1285
+ && (frame["reason"] === "leaf_signed_by_neither_participant"
1286
+ || frame["reason"] === "leaf_witnessed_by_counterparty")
1287
+ && typeof observedAt === "number" && Number.isFinite(observedAt)
1288
+ && typeof submitterIsCounterparty === "boolean"
1289
+ && (rawRelayId === undefined || typeof rawRelayId === "string");
1290
+ const unreadable = (why) => {
1291
+ this.#logger.error("session.relay.witness.malformed", {
1292
+ relayPeerId: this.#relayPeerId,
1293
+ why,
1294
+ impact: "this relay sent a witness alert this build cannot read, so NOTHING has been " +
1295
+ "reported to the operator about it. Treat it as a relay fault or a version skew, not " +
1296
+ "as evidence about either participant.",
1297
+ });
1298
+ /**
1299
+ * Review F7 — **and it reaches a surface, not only this file.** If a version skew makes
1300
+ * every relay's alert unreadable, the witness layer is silently dead and the operator would
1301
+ * have no way to find that out. Deliberately carries NO session and NO party: it says a
1302
+ * relay sent something we could not read, and claims nothing about anyone.
1303
+ */
1304
+ this.#onWitnessUnreadable?.(this.#relayPeerId, why);
1305
+ };
1306
+ if (!wellFormed) {
1307
+ unreadable("field_shape");
1308
+ return;
1309
+ }
1310
+ const sessionIdHex = Buffer.from(sidBytes).toString("hex");
1311
+ /**
1312
+ * ⚠️ **IT MUST BE A SESSION THIS CLIENT ACTUALLY HOLDS ON THIS RELAY** — review F2.
1313
+ *
1314
+ * `wellFormed` checks shape and shape only. Without this, any relay we are authenticated to
1315
+ * could push alerts naming arbitrary session ids — including conversations carried by a
1316
+ * DIFFERENT relay — and they would land in the operator's inbox as statements of fact about
1317
+ * a counterparty. Combined with a bounded notice list that is the cheap mute: flood
1318
+ * fabrications until the real one is gone.
1319
+ */
1320
+ if (!this.#sessions.has(sessionIdHex)) {
1321
+ /**
1322
+ * ⚠️ **REFUSED AS AN ALERT, BUT NOT BINNED IN SILENCE** — fallback-finder HIGH 1.
1323
+ *
1324
+ * It cannot be reported as an observation about a conversation: a relay naming a session we
1325
+ * do not hold is exactly the fabrication the check above exists to stop, and rendering it
1326
+ * would put a claim about a counterparty in front of an operator on a stranger's say-so.
1327
+ *
1328
+ * But dropping it entirely was worse than it looked, because the relay's own copy is GONE by
1329
+ * then: its queue is keyed by PUBKEY and `drainWitnessAlerts` splices, so it hands over
1330
+ * everything it held for this agent the moment any client authenticates. The detached seal
1331
+ * client registers exactly ONE session and then authenticates — so a restart-then-seal on
1332
+ * one conversation destroyed a held alert about a different one, at both ends, and the
1333
+ * operator's inbox looked clean.
1334
+ *
1335
+ * So it goes to the same neutral surface a version skew does: something arrived that this
1336
+ * daemon could not place. No session, no party, no claim.
1337
+ */
1338
+ this.#logger.error("session.relay.witness.unknown_session", {
1339
+ relayPeerId: this.#relayPeerId,
1340
+ session: sessionIdHex,
1341
+ impact: "not rendered as an observation — this relay named a session this client is not " +
1342
+ "holding. Reported to the operator as an unplaceable witness report, never as a claim " +
1343
+ "about a counterparty.",
1344
+ });
1345
+ this.#onWitnessUnreadable?.(this.#relayPeerId, "session_not_held_here");
1346
+ return;
1347
+ }
1348
+ /**
1349
+ * A DECLARED IDENTITY MUST BE PROVEN — review F3. `relay_id` is the hex of the key that signs
1350
+ * every `hash_submit_ack`, so the same check that verifies a receipt verifies this. Missing,
1351
+ * malformed and mismatched take ONE path: omitting the proof is the cheapest way to dodge it.
1352
+ */
1353
+ const alertReason = frame["reason"];
1354
+ const rawSig = frame["witness_signature"];
1355
+ const sigBytes = rawSig instanceof Uint8Array || Buffer.isBuffer(rawSig) ? toU8(rawSig) : null;
1356
+ let verifiable = false;
1357
+ if (typeof rawRelayId === "string") {
1358
+ if (!/^[0-9a-fA-F]{64}$/.test(rawRelayId)) {
1359
+ unreadable("relay_id_not_a_pubkey");
1360
+ return;
1361
+ }
1362
+ if (!sigBytes || sigBytes.length !== 64) {
1363
+ unreadable("declared_relay_id_without_signature");
1364
+ return;
1365
+ }
1366
+ // THE REASON THE RELAY SENT, not a literal: the reason is inside the signed bytes, so
1367
+ // verifying a different one than arrived would refuse every alert of the new kind.
1368
+ const tbs = buildWitnessAlertTbs(sidBytes, alertReason, observedAt, submitterIsCounterparty);
1369
+ if (!verify(new Uint8Array(Buffer.from(rawRelayId, "hex")), tbs, sigBytes)) {
1370
+ unreadable("witness_signature_invalid");
1371
+ return;
1372
+ }
1373
+ verifiable = true;
1374
+ }
1375
+ const alert = {
1376
+ sessionIdHex,
1377
+ reason: alertReason,
1378
+ relayId: typeof rawRelayId === "string" ? rawRelayId : null,
1379
+ observedAt,
1380
+ submitterIsCounterparty,
1381
+ witnessPeerId: this.#relayPeerId,
1382
+ verifiable,
1383
+ };
1384
+ // BOTH halves, per Invariant 2: the log is the durable forensic record, and the callback is
1385
+ // the half that actually reaches a person.
1386
+ this.#logger.error("session.relay.witness.alert", {
1387
+ relayPeerId: this.#relayPeerId,
1388
+ session: alert.sessionIdHex,
1389
+ relayId: alert.relayId ?? "(unnamed)",
1390
+ submitterIsCounterparty: alert.submitterIsCounterparty,
1391
+ verifiable: alert.verifiable,
1392
+ observation: "one relay refused a leaf on this session because it verified against neither participant key",
1393
+ impact: "nothing was added to the conversation record. This is ONE relay's observation and " +
1394
+ "establishes only that it saw and refused that submission — not who sent it.",
1395
+ });
1396
+ if (this.#onWitnessAlert)
1397
+ this.#onWitnessAlert(alert);
1398
+ }
1399
+ else if (type === "relay_slot_reclaimed") {
1400
+ /**
1401
+ * DOD-M15-RELAYSLOTS-1 clause 8 — **the reaped party is told, and this is where it lands.**
1402
+ *
1403
+ * The relay reclaimed this agent's circuit reservation to free capacity. Without a branch
1404
+ * here the frame fell off the end of this chain and was discarded in silence, which is the
1405
+ * trap the order records in its own words: a refusal that only reaches the relay's log does
1406
+ * not exist. From the agent's side the reservation simply stops working.
1407
+ *
1408
+ * Recorded as a refusal so it reaches `cello_status` through the same surface as every other
1409
+ * relay refusal, with the same shape: a cause, and what to do about it.
1410
+ */
1411
+ const idleMs = typeof frame["idle_ms"] === "number" ? frame["idle_ms"] : undefined;
1412
+ const detail = typeof frame["detail"] === "string" ? frame["detail"] : undefined;
1413
+ this.#lastAuthRefusal = {
1414
+ reason: "slot_reclaimed",
1415
+ advice: detail ?? "This relay reclaimed your circuit reservation to free capacity because it " +
1416
+ "had carried no traffic for a long time. Your agent stays online and rebuilds its receiver " +
1417
+ "automatically; a new session will take a fresh reservation.",
1418
+ // Not the relay's fault and not ours — it was under pressure and we were the quietest. The
1419
+ // client rebuilds against the same pool, so there is nothing to fail over from.
1420
+ tryAnotherRelay: false,
1421
+ };
1422
+ this.#logger.warn("session.relay.slot_reclaimed", {
1423
+ relayPeerId: this.#relayPeerId,
1424
+ ...(idleMs !== undefined ? { idleHours: Math.round(idleMs / 3_600_000) } : {}),
1425
+ impact: "this relay reclaimed our circuit reservation to free capacity. Until a receiver is " +
1426
+ "rebuilt, this agent is reachable only over a direct connection.",
1427
+ });
1428
+ }
1429
+ // session_interrupted / content_park_notify are out of scope here — session interruption
1430
+ // is handled by the session node manager's dedicated relay-stream watcher.
1431
+ }
1432
+ /**
1433
+ * Proactively establish the authenticated stream from `node`. The RECEIVER must connect
1434
+ * before the counterparty submits, so the relay has its stream to deliver `leaf_deliver`
1435
+ * to (otherwise the relay queues until the recipient connects). Best-effort.
1436
+ */
1437
+ async connect(node) {
1438
+ return this.#ensureConnected(node);
1439
+ }
1440
+ /**
1441
+ * DOD-M15-RELAYAUTH-1 review HIGH-1 — prove key possession FROM THIS NODE, on its own stream.
1442
+ *
1443
+ * ⚠️ **`connect()` CANNOT be used for this, and using it was the defect.** `#ensureConnected`
1444
+ * returns `true` the moment `#stream` is non-null, and `#stream` belongs to whichever node
1445
+ * connected FIRST. An agent legitimately runs several nodes against one relay — the node promoted
1446
+ * into a live session, plus the replacement standing receiver built behind it — and they share
1447
+ * one `AgentRelayClient` because the cache is keyed `${agent}::${relay}`. So calling `connect()`
1448
+ * from the replacement receiver short-circuited on the session node's stream, sent nothing, and
1449
+ * the relay never saw that receiver's transport identity: it revoked the reservation ~15s later,
1450
+ * the watchdog rebuilt, and the agent churned on a ~45s loop holding no usable circuit address
1451
+ * for as long as the conversation lasted. Any future "reuse the existing connection" optimisation
1452
+ * here reintroduces exactly that.
1453
+ *
1454
+ * So this always opens its own short-lived stream from `node`, and marks it
1455
+ * `purpose: "reservation"` so the relay proves possession WITHOUT rebinding the agent's delivery
1456
+ * stream (which would steal the live session's inbound leaves — see the relay-side dispatch).
1457
+ */
1458
+ /**
1459
+ * ⚠️ **THE FIRST ATTEMPT AFTER A COLD START IS EXPECTED TO FAIL, AND THAT IS NOT A FAULT ON
1460
+ * EITHER SIDE.** Root-caused 2026-09-07 from libp2p's own debug log:
1461
+ *
1462
+ * ```
1463
+ * circuit-relay listener: making reservation on relay
1464
+ * reservation failed with status PERMISSION_DENIED <- the relay's gate, working as designed
1465
+ * could not add discovered relay
1466
+ * connection-manager closing 1 connections / stopped / started
1467
+ * ```
1468
+ *
1469
+ * libp2p's circuit-relay listener asks the relay for a reservation ON ITS OWN, before any CELLO
1470
+ * code has proven anything. The relay's gate refuses an unproven peer — deliberately; its flow is
1471
+ * *"asks, is refused, proves itself, and asks again"*. libp2p reacts to that refusal by discarding
1472
+ * the relay and RESTARTING ITS CONNECTION MANAGER, which closes every connection — including the
1473
+ * healthy one this proof is being opened on. Whichever lands first decides the outcome, so the
1474
+ * failure is intermittent (measured 5/5, 2/4, 0/4, 1/6 across cold logins).
1475
+ *
1476
+ * **Both components are behaving as specified, so the refusal cannot be designed away from here** —
1477
+ * removing it means either weakening the gate or reaching into libp2p's internal
1478
+ * `transportManager.listen` to defer the circuit listener until after the proof. What the client
1479
+ * CAN do is survive it: a transport-class failure is retried on a fresh connection, once.
1480
+ *
1481
+ * A REFUSAL IS NOT RETRIED. If the relay reached a verdict — no token, slot cap, misconfigured —
1482
+ * the answer will be identical a second later, and retrying would spend the operator's reachability
1483
+ * on a question already answered. Only a failure that never reached a verdict is retried.
1484
+ */
1485
+ async proveReservation(node) {
1486
+ if (this.#closed)
1487
+ return false;
1488
+ const first = await this.#proveReservationOnce(node);
1489
+ if (first !== "transport_failed")
1490
+ return first;
1491
+ /**
1492
+ * The connection manager has just restarted, so the previous connection is gone and a fresh
1493
+ * dial is required — which `#proveReservationOnce` does at its head. The brief wait is for
1494
+ * libp2p to finish restarting; without it the redial races the restart it is recovering from.
1495
+ */
1496
+ await new Promise((r) => setTimeout(r, PROOF_RETRY_DELAY_MS));
1497
+ if (this.#closed)
1498
+ return false;
1499
+ this.#logger.info("session.relay.reservation_proof.retry", {
1500
+ relayPeerId: this.#relayPeerId,
1501
+ afterMs: PROOF_RETRY_DELAY_MS,
1502
+ impact: "the first proof never reached a verdict — the connection was destroyed under it, which is " +
1503
+ "what libp2p does to a relay that just refused its automatic reservation. Retrying once on " +
1504
+ "a fresh connection. If this succeeds the agent is reachable and nothing was wrong.",
1505
+ });
1506
+ const second = await this.#proveReservationOnce(node);
1507
+ return second === true;
1508
+ }
1509
+ /**
1510
+ * One attempt. `true`/`false` are VERDICTS (the relay answered); `"transport_failed"` means no
1511
+ * verdict was reached and the question is still open — the distinction the retry above turns on.
1512
+ */
1513
+ async #proveReservationOnce(node) {
1514
+ if (this.#closed)
1515
+ return false;
1516
+ /**
1517
+ * ⚠️ **CLEAR THE REFUSAL FIRST — review HIGH-2.** `#lastAuthRefusal` is set when a relay REFUSES
1518
+ * a proof on the merits, and cleared only on auth SUCCESS. Both early returns below are
1519
+ * transport failures that never reach a verdict, so without this a stale refusal from an
1520
+ * earlier attempt survives them — and `session-relay.ts` reads `getLastAuthRefusal()` off a
1521
+ * CACHED client, writes it into `srRelayRefusal`, and quarantines the relay and rebuilds the
1522
+ * receiver when it says `tryAnotherRelay`. One real refusal followed by a later transport blip
1523
+ * would therefore explain the agent's unreachability with a cause that is no longer true, while
1524
+ * churning receivers over it. A transport failure must leave no verdict behind.
1525
+ */
1526
+ this.#clearAuthRefusal();
1527
+ /**
1528
+ * A FAILED DIAL MUST NOT BE REPORTED AS A FAILED STREAM. This loop used to swallow every dial
1529
+ * error and fall through to `newStream` regardless. `#connect()` below has always tracked this
1530
+ * and logs `session.relay.dial.failed`; the two were written from one shape and this one lost
1531
+ * the check.
1532
+ *
1533
+ * ⚠️ **This branch did NOT cause the cold-login failure** (measured 2026-09-07: the dial
1534
+ * succeeds, because libp2p's `findExistingConnection` returns an already-open connection rather
1535
+ * than dialling). It is a real gap on the malformed-address and limited-connection paths, and
1536
+ * nothing more. Do not read its presence as the reachability fix.
1537
+ */
1538
+ let dialed = false;
1539
+ let lastDialError = "";
1540
+ for (const addr of this.#relayAddrs) {
1541
+ try {
1542
+ await node.dial(addr);
1543
+ dialed = true;
1544
+ break;
1545
+ }
1546
+ catch (err) {
1547
+ lastDialError = extractErrorMessage(err);
1548
+ }
1549
+ }
1550
+ if (!dialed && this.#relayAddrs.length > 0) {
1551
+ this.#logger.warn("session.relay.reservation_proof.failed", {
1552
+ relayPeerId: this.#relayPeerId,
1553
+ relayAddrs: this.#relayAddrs,
1554
+ reason: "dial",
1555
+ error: lastDialError,
1556
+ impact: "could not open a connection to this relay, so no proof was sent and the relay saw " +
1557
+ "nothing. The agent holds no reservation on it and is unreachable through it until a " +
1558
+ "later attempt succeeds. This is a REACHABILITY fault, not a rejection — the relay did " +
1559
+ "not refuse anything.",
1560
+ });
1561
+ return "transport_failed";
1562
+ }
1563
+ // TEMPORARY INSTRUMENT (2026-09-07): snapshot immediately BEFORE newStream. Paired with the
1564
+ // snapshot in the catch, this localises the destruction to the newStream call itself rather
1565
+ // than "sometime around the failure".
1566
+ const snap = () => node.getConnections().map((c) => ({
1567
+ toThisRelay: c.peerId === this.#relayPeerId,
1568
+ peer: c.peerId.slice(0, 16),
1569
+ status: c.status,
1570
+ muxer: c.muxerStatus,
1571
+ streams: c.streamCount,
1572
+ protocols: c.streamProtocols,
1573
+ }));
1574
+ const before = snap();
1575
+ let stream;
1576
+ try {
1577
+ stream = await node.newStream(this.#relayPeerId, RELAY_PROTOCOL_ID);
1578
+ }
1579
+ catch (err) {
1580
+ /**
1581
+ * ⚠️ **DO NOT COLLAPSE THIS TO `reason: "stream"`.** `CelloNode.newStream` throws STRUCTURED
1582
+ * errors — plain objects, not `Error`s — whose `reason` is the only field that says what
1583
+ * happened. This site used to overwrite it with the literal `"stream"`, so every distinct
1584
+ * cause reached the operator as one line. Measured 2026-09-07: a whole clean-room
1585
+ * investigation went to the relay servers because the cause was discarded at capture.
1586
+ *
1587
+ * The set is whatever `mapStreamError` returns — deliberately NOT enumerated here, because
1588
+ * the first version of this comment listed four of the six and the two it missed
1589
+ * (`protocol_not_supported`, `limited_connection_refused`) are exactly the ones a redial
1590
+ * cannot fix.
1591
+ *
1592
+ * ⚠️ **`connection_lost` IS THE CATCH-ALL, NOT A DIAGNOSIS** (`node.ts` says so). It covers a
1593
+ * dead muxer on a live socket, a per-protocol stream cap, and a remote reset during
1594
+ * multistream negotiation alike. **`muxerStatus` is what separates them**, which is why the
1595
+ * connections are dumped below: the transport exposes that field for this exact question and
1596
+ * without it the next cold-login failure is guessed at again.
1597
+ *
1598
+ * And note what this does NOT establish: the relay's handler only runs AFTER negotiation
1599
+ * succeeds, so a relay that reset the stream mid-negotiation would log nothing either. Relay
1600
+ * silence is not relay innocence.
1601
+ */
1602
+ const raw = (err ?? {});
1603
+ const reason = typeof raw.reason === "string" ? raw.reason : "stream";
1604
+ this.#logger.warn("session.relay.reservation_proof.failed", {
1605
+ relayPeerId: this.#relayPeerId,
1606
+ relayAddrs: this.#relayAddrs,
1607
+ nodePeerId: node.getPeerId(),
1608
+ reason,
1609
+ error: extractErrorMessage(err),
1610
+ // The discriminator. `status` is the SOCKET; `muxerStatus` is the layer that carries data
1611
+ // and the one `newStream` checks first — a connection reading open/closed is the P5 shape.
1612
+ connsBeforeNewStream: before,
1613
+ connsAtFailure: snap(),
1614
+ nodeStatusAtFailure: node.lifecycleStatus?.() ?? "(unavailable)",
1615
+ impact: "no proof was sent, so this relay granted no reservation and the agent is unreachable " +
1616
+ "through it until a later attempt succeeds. The relay did not refuse anything — it may " +
1617
+ "never have seen the stream.",
1618
+ });
1619
+ return "transport_failed";
1620
+ }
1621
+ try {
1622
+ const iter = lp.decode(stream)[Symbol.asyncIterator]();
1623
+ const ok = await this.#authenticate(stream, iter, "reservation");
1624
+ this.#logger.info("session.relay.reservation_proof.result", {
1625
+ relayPeerId: this.#relayPeerId,
1626
+ nodePeerId: node.getPeerId(),
1627
+ ok,
1628
+ // DOD-M15-RELAYSLOTS-1: name the cause here too. `ok: false` alone sent people looking at
1629
+ // the transport for what is usually a token or a cap.
1630
+ ...(ok ? {} : { refusalReason: this.#lastAuthRefusal?.reason ?? "no_relay_verdict" }),
1631
+ });
1632
+ return ok;
1633
+ }
1634
+ finally {
1635
+ await stream.close().catch(() => { });
1636
+ }
1637
+ }
1638
+ /**
1639
+ * 054-SRSPLIT — **TELL THE RELAY WE ARE DONE WITH OUR SLOT.**
1640
+ *
1641
+ * Resolves whether the relay confirmed it freed one. `false` covers "we held none" and "we could
1642
+ * not reach the relay to say so" alike, and the caller must not read it as failure: releasing is
1643
+ * tidy-up, and one that could not be delivered costs a slot until its TTL rather than breaking
1644
+ * anything the agent is doing. Every path that returns false says why.
1645
+ *
1646
+ * ⚠️ **IT GOES ON THE LONG-LIVED DELIVERY STREAM, NOT A RESERVATION-PURPOSE ONE, AND THAT IS NOT
1647
+ * A STYLE CHOICE.** The first version opened a fresh stream and authenticated with
1648
+ * `purpose: "reservation"` — mirroring the proof. The relay's handler treats that purpose as a
1649
+ * proof and nothing else: it answers `relay_auth_ok`, **closes the stream and returns without
1650
+ * entering the message loop**. So the release frame went into a stream nobody was reading, the
1651
+ * reply never came, and the verb was inert while every test passed. Found in review, not by the
1652
+ * suite.
1653
+ *
1654
+ * ⚠️ **THE FRAME CARRIES NO PEER ID, deliberately.** The relay frees the peer this connection
1655
+ * authenticated as. A peer id on the wire would let any registered agent free another's.
1656
+ */
1657
+ /**
1658
+ * DOD-M15-AWAYSCOPE-1 — tell this relay whether anyone is attending this agent, for one session.
1659
+ *
1660
+ * ── WHY THIS EXISTS AT ALL ──────────────────────────────────────────────────────────────────────
1661
+ *
1662
+ * An unattended agent used to answer inbound messages with its away greeting. That greeting took a
1663
+ * hash-chain leaf inside a live conversation, and on session `e7dd3f43…` it cost two operators a
1664
+ * completed conversation's receipt, permanently. The fact itself is worth telling — a counterparty
1665
+ * mid-exchange genuinely needs to know whether to wait — but it is a fact ABOUT the session, not a
1666
+ * sentence IN it. So it rides here instead, and takes no leaf.
1667
+ *
1668
+ * ── FIRE AND FORGET, AND THAT IS THE DESIGN ─────────────────────────────────────────────────────
1669
+ *
1670
+ * Nothing is awaited and the relay sends no ack. The notice may be dropped, duplicated or arrive
1671
+ * out of order; `observed_at` settles it, last-write-wins. A failure changes nothing the operator
1672
+ * is doing — the counterparty reads "unknown" until the next notice, which is what is true.
1673
+ *
1674
+ * ⚠️ NO RETRY, NO QUEUE. Anything that made this reliable would want ordering, ordering wants a
1675
+ * witness, and a witnessed position is how machine chatter reached the leaf set. A status hint
1676
+ * that is allowed to be lost is the safe shape.
1677
+ */
1678
+ announceAttendance(node, sessionId, attendance) {
1679
+ if (this.#closed)
1680
+ return;
1681
+ void (async () => {
1682
+ try {
1683
+ if (!(await this.#ensureConnected(node)))
1684
+ return;
1685
+ const stream = this.#stream;
1686
+ if (!stream)
1687
+ return;
1688
+ stream.send(lp.encode.single(encodeSessionAttendanceNotice({
1689
+ type: "session_attendance_notice",
1690
+ session_id: sessionId,
1691
+ attendance,
1692
+ // OUR clock, stated plainly. The relay bounds it against its own and refuses a value too
1693
+ // far ahead rather than clamping — clamping would turn a hostile timestamp into a valid
1694
+ // recent one, and this client is the code an adversary gets to rewrite.
1695
+ observed_at: Date.now(),
1696
+ })));
1697
+ this.#logger.debug("session.relay.attendance.announced", {
1698
+ relayPeerId: this.#relayPeerId,
1699
+ sessionId: Buffer.from(sessionId).toString("hex").slice(0, 16),
1700
+ attendance,
1701
+ });
1702
+ }
1703
+ catch (err) {
1704
+ this.#logger.debug("session.relay.attendance.announce_failed", {
1705
+ relayPeerId: this.#relayPeerId,
1706
+ error: extractErrorMessage(err),
1707
+ impact: "the counterparty reads 'unknown' for this agent until the next notice lands",
1708
+ });
1709
+ }
1710
+ })();
1711
+ }
1712
+ /**
1713
+ * DOD-M15-AWAYSCOPE-1 — ask the relay about the counterparty: reachable, and attended?
1714
+ *
1715
+ * Two facts with two producers, which is why they are separate fields. `liveness` is the RELAY's
1716
+ * observation — it holds that agent's standing connection or it does not. `attendance` is the far
1717
+ * DAEMON's assertion about itself, which the relay was told and repeats; absent whenever
1718
+ * `liveness` is not 'alive', because a daemon nobody can reach asserts nothing.
1719
+ *
1720
+ * ⚠️ EVERY FAILURE ANSWERS 'unknown', never 'gone'. A timeout, a dead stream, a refusal and an
1721
+ * unparseable frame all mean "this side learned nothing"; reporting any as `gone` invents an
1722
+ * observation nobody made, and `gone` is the value that tells an operator their peer has left.
1723
+ */
1724
+ /**
1725
+ * Test-only seams for the liveness query path, which cannot be reached any other way.
1726
+ *
1727
+ * ⚠️ THEY EXIST BECAUSE A ONE-SESSION FAKE HID A REAL DEFECT. Every other test of this feature
1728
+ * stubs `queryLiveness` outright, so the code that matches an ANSWER to its QUESTION never ran —
1729
+ * and it was wrong: a single resolver slot handed one session's answer to another session's
1730
+ * promise the moment an agent held two conversations through one relay, which is ordinary.
1731
+ * Reaching that code needs a live stream and a dispatched frame, and both are private.
1732
+ *
1733
+ * Named `ForTest` like `__setLastActivityAtForTest` and `patchRelayClientForTest`, and doing
1734
+ * nothing production does not: one installs the stream a dial would have produced, the other
1735
+ * feeds the reader loop a frame the relay would have sent.
1736
+ */
1737
+ installStreamForTest(stream) { this.#stream = stream; }
1738
+ dispatchForTest(frame) { this.#dispatch(frame); }
1739
+ async queryLiveness(sessionId, counterpartyPubkey) {
1740
+ const unknown = { liveness: "unknown", observedAt: 0 };
1741
+ if (this.#closed)
1742
+ return unknown;
1743
+ /**
1744
+ * ⚠️ IT DOES NOT DIAL — the one place in this client that refuses to connect. Every other caller
1745
+ * here does the operator's work; this one serves a READ (`cello status`, `cello sessions`), and
1746
+ * a status command that opens a connection as a side effect changes what it is reporting on. It
1747
+ * would also make a read of a dozen sessions pay a dial each, against a budget measured in what
1748
+ * a person waits at a terminal. The cost is small and precise: right after a daemon restart a
1749
+ * status read says 'unknown' instead of dialling; the first real message establishes the stream.
1750
+ */
1751
+ const stream = this.#stream;
1752
+ if (!stream)
1753
+ return unknown;
1754
+ let resolveLiveness;
1755
+ const answer = new Promise((r) => { resolveLiveness = r; });
1756
+ const key = livenessKey(sessionId, counterpartyPubkey);
1757
+ this.#pendingLiveness.set(key, resolveLiveness);
1758
+ try {
1759
+ stream.send(lp.encode.single(encodeSessionLivenessQuery({
1760
+ type: "session_liveness_query",
1761
+ session_id: sessionId,
1762
+ counterparty_pubkey: counterpartyPubkey,
1763
+ })));
1764
+ }
1765
+ catch (err) {
1766
+ this.#pendingLiveness.delete(key);
1767
+ this.#logger.debug("session.relay.liveness.query_failed", {
1768
+ relayPeerId: this.#relayPeerId, error: extractErrorMessage(err),
1769
+ });
1770
+ return unknown;
1771
+ }
1772
+ let timer;
1773
+ const timeout = new Promise((r) => { timer = setTimeout(() => r("timeout"), HASH_SUBMIT_TIMEOUT_MS); });
1774
+ try {
1775
+ const result = await Promise.race([answer, timeout]);
1776
+ if (result === "timeout") {
1777
+ // NOT a stream reset, for the same reason a release timeout is not: a liveness query is not
1778
+ // ordered against anything, so tearing the shared stream down would cost every sibling
1779
+ // session its in-flight submits to tidy up one status read.
1780
+ this.#pendingLiveness.delete(key);
1781
+ this.#logger.debug("session.relay.liveness.query_timeout", { relayPeerId: this.#relayPeerId });
1782
+ return unknown;
1783
+ }
1784
+ return result;
1785
+ }
1786
+ finally {
1787
+ clearTimeout(timer);
1788
+ }
1789
+ }
1790
+ async releaseReservation(node) {
1791
+ if (this.#closed)
1792
+ return false;
1793
+ if (!(await this.#ensureConnected(node))) {
1794
+ this.#logger.warn("session.relay.reservation_release.failed", {
1795
+ relayPeerId: this.#relayPeerId,
1796
+ reason: "not_connected",
1797
+ impact: "this relay was not told the slot is free, so it holds it until the reservation " +
1798
+ "TTL expires. Nothing the agent is doing is affected.",
1799
+ });
1800
+ return false;
1801
+ }
1802
+ const stream = this.#stream;
1803
+ if (!stream)
1804
+ return false;
1805
+ let resolveRel;
1806
+ const relPromise = new Promise((r) => { resolveRel = r; });
1807
+ this.#pendingRelease = resolveRel;
1808
+ try {
1809
+ stream.send(lp.encode.single(encodeCbor({ type: "relay_release_reservation" })));
1810
+ }
1811
+ catch (err) {
1812
+ if (this.#pendingRelease === resolveRel)
1813
+ this.#pendingRelease = null;
1814
+ this.#logger.warn("session.relay.reservation_release.failed", {
1815
+ relayPeerId: this.#relayPeerId,
1816
+ reason: "send",
1817
+ error: extractErrorMessage(err),
1818
+ impact: "the relay holds the slot until its TTL expires.",
1819
+ });
1820
+ return false;
1821
+ }
1822
+ let timer;
1823
+ const timeout = new Promise((r) => { timer = setTimeout(() => r("timeout"), HASH_SUBMIT_TIMEOUT_MS); });
1824
+ try {
1825
+ const result = await Promise.race([relPromise, timeout]);
1826
+ if (result === "timeout") {
1827
+ // NOT a stream reset. A release is not ordered against anything — no ack of a later frame
1828
+ // can be settled by a late reply to this one, because the reader clears the resolver — so
1829
+ // tearing the stream down would cost sibling sessions their in-flight submits for a tidy-up.
1830
+ this.#logger.warn("session.relay.reservation_release.failed", {
1831
+ relayPeerId: this.#relayPeerId,
1832
+ reason: "no_reply",
1833
+ impact: "the relay did not answer, so it holds the slot until its TTL expires.",
1834
+ });
1835
+ return false;
1836
+ }
1837
+ this.#logger.info("session.relay.reservation_release.result", {
1838
+ relayPeerId: this.#relayPeerId,
1839
+ nodePeerId: node.getPeerId(),
1840
+ released: result,
1841
+ });
1842
+ return result;
1843
+ }
1844
+ finally {
1845
+ clearTimeout(timer);
1846
+ if (this.#pendingRelease === resolveRel)
1847
+ this.#pendingRelease = null;
1848
+ }
1849
+ }
1850
+ /** Ensure an authenticated stream exists, (re)dialing from `node` if needed. */
1851
+ async #ensureConnected(node) {
1852
+ if (this.#closed)
1853
+ return false;
1854
+ if (this.#stream)
1855
+ return true;
1856
+ if (this.#connecting)
1857
+ return this.#connecting;
1858
+ this.#connecting = this.#connect(node).finally(() => { this.#connecting = null; });
1859
+ return this.#connecting;
1860
+ }
1861
+ async #connect(node) {
1862
+ /**
1863
+ * ⚠️ DROP THE PREVIOUS ATTEMPT'S VERDICT FIRST — `DOD-M15-TOKENSTALE-1` review F4, and the same
1864
+ * reasoning `#proveReservationOnce` already carries in capitals twelve lines from here.
1865
+ *
1866
+ * `#lastAuthRefusal` is cleared only on auth SUCCESS. Both failure exits below — the dial and
1867
+ * the stream open — are transport failures that never reach a verdict at all, so without this
1868
+ * they return `false` carrying whatever an earlier attempt left behind.
1869
+ *
1870
+ * That was survivable while the submit boundary answered `relay_unavailable` for every `false`.
1871
+ * It is not now: this unit makes that boundary REPORT the stored refusal, so a stale
1872
+ * `online_token_expired` would relabel a relay that is genuinely down as our own dead
1873
+ * credential — sending the operator to restart their daemon over somebody else's outage, and
1874
+ * stripping the seal fallbacks that exist for exactly that outage. Clearing here is what makes
1875
+ * the promotion mean "this attempt's verdict" instead of "some past verdict".
1876
+ */
1877
+ this.#clearAuthRefusal();
1878
+ // Best-effort dial: newStream auto-dials a known peer, but the relay's addrs may not
1879
+ // be in the peerstore yet, so dial each addr first. One success is enough.
1880
+ let dialed = false;
1881
+ let lastDialError = "";
1882
+ for (const addr of this.#relayAddrs) {
1883
+ try {
1884
+ await node.dial(addr);
1885
+ dialed = true;
1886
+ break;
1887
+ }
1888
+ catch (err) {
1889
+ lastDialError = extractErrorMessage(err);
1890
+ }
1891
+ }
1892
+ if (!dialed && this.#relayAddrs.length > 0) {
1893
+ this.#logger.warn("session.relay.dial.failed", {
1894
+ relayPeerId: this.#relayPeerId,
1895
+ relayAddrs: this.#relayAddrs,
1896
+ error: lastDialError,
1897
+ });
1898
+ return false;
1899
+ }
1900
+ let stream;
1901
+ try {
1902
+ stream = await node.newStream(this.#relayPeerId, RELAY_PROTOCOL_ID);
1903
+ }
1904
+ catch (err) {
1905
+ /**
1906
+ * Same error substitution as `proveReservation`'s stream branch, and on the MORE important
1907
+ * path — this is the stream that carries `leaf_deliver` into a live session. `newStream`'s
1908
+ * structured `reason` was being dropped here too, leaving `connection_lost`, a stream cap and
1909
+ * a refused protocol indistinguishable while a conversation is running.
1910
+ */
1911
+ const raw = (err ?? {});
1912
+ this.#logger.warn("session.relay.stream.failed", {
1913
+ relayPeerId: this.#relayPeerId,
1914
+ reason: typeof raw.reason === "string" ? raw.reason : "stream",
1915
+ error: extractErrorMessage(err),
1916
+ relayConnections: node
1917
+ .getConnections()
1918
+ .filter((c) => c.peerId === this.#relayPeerId)
1919
+ .map((c) => ({ status: c.status, muxerStatus: c.muxerStatus, streams: c.streamCount })),
1920
+ });
1921
+ return false;
1922
+ }
1923
+ // ONE shared lp.decode iterator for the whole stream lifetime — splitting it signals
1924
+ // EOF to the relay's single-iterator reader and breaks subsequent reads.
1925
+ const iter = lp.decode(stream)[Symbol.asyncIterator]();
1926
+ if (!(await this.#authenticate(stream, iter)))
1927
+ return false;
1928
+ this.#stream = stream;
1929
+ this.#logger.info("session.relay.connected", { relayPeerId: this.#relayPeerId });
1930
+ this.#startReader(stream, iter);
1931
+ return true;
1932
+ }
1933
+ async #authenticate(stream, iter, purpose) {
1934
+ const challengeRes = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
1935
+ if (challengeRes.done || challengeRes.value === undefined) {
1936
+ this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "no_challenge" });
1937
+ return false;
1938
+ }
1939
+ let challenge;
1940
+ try {
1941
+ challenge = decode(toU8(challengeRes.value));
1942
+ }
1943
+ catch {
1944
+ this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "challenge_decode" });
1945
+ return false;
1946
+ }
1947
+ if (challenge["type"] !== "relay_auth_challenge") {
1948
+ this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "not_challenge" });
1949
+ return false;
1950
+ }
1951
+ const nonce = toU8(challenge["nonce"]);
1952
+ if (nonce.length !== 32) {
1953
+ this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "bad_nonce" });
1954
+ return false;
1955
+ }
1956
+ const authSig = await this.#keyProvider.sign(buildRelayAuthPayload(nonce, this.#senderPubkey));
1957
+ /**
1958
+ * DOD-M15-RELAYSLOTS-1: read the token NOW, not at construction — it is reissued on every
1959
+ * signaling reconnect and the one this client was built with is usually already gone.
1960
+ *
1961
+ * When there is none we send the auth anyway. Declining to try would replace a named refusal
1962
+ * from the relay (`online_token_required`, which says what is wrong and what to do) with
1963
+ * silence on both sides — and silence is what an operator reads as "the product is broken".
1964
+ */
1965
+ const onlineToken = this.#onlineToken?.();
1966
+ if (!onlineToken) {
1967
+ this.#logger.warn("session.relay.auth.no_online_token", {
1968
+ relayPeerId: this.#relayPeerId,
1969
+ impact: "authenticating without the directory's online token. The relay will refuse this and " +
1970
+ "will not let this node keep a circuit reservation, so the agent is reachable by nobody " +
1971
+ "over this relay. The usual cause is that no directory connection has been established " +
1972
+ "yet; the next signaling connect issues a token and the receiver re-authenticates.",
1973
+ });
1974
+ }
1975
+ try {
1976
+ stream.send(lp.encode.single(encodeCbor({
1977
+ type: "relay_auth_response",
1978
+ pubkey: this.#senderPubkey,
1979
+ signature: authSig,
1980
+ // DOD-M15-RELAYAUTH-1: absent for the ordinary session auth (which also registers this
1981
+ // stream as the agent's delivery target). `"reservation"` proves possession from THIS
1982
+ // node's transport identity and nothing more — see proveReservation().
1983
+ ...(purpose ? { purpose } : {}),
1984
+ // DOD-M15-RELAYSLOTS-1: opaque bytes from the directory, forwarded verbatim. The client
1985
+ // never parses them — a format it does not read is a format it cannot get wrong.
1986
+ ...(onlineToken ? { online_token: onlineToken } : {}),
1987
+ })));
1988
+ }
1989
+ catch (err) {
1990
+ this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "response_send", error: extractErrorMessage(err) });
1991
+ return false;
1992
+ }
1993
+ const ackRes = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
1994
+ if (ackRes.done || ackRes.value === undefined) {
1995
+ this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "no_auth_ok" });
1996
+ return false;
1997
+ }
1998
+ let ackFrame;
1999
+ try {
2000
+ ackFrame = decode(toU8(ackRes.value));
2001
+ }
2002
+ catch {
2003
+ this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "auth_ok_decode" });
2004
+ return false;
2005
+ }
2006
+ if (ackFrame["type"] !== "relay_auth_ok") {
2007
+ /**
2008
+ * DOD-M15-RELAYABUSE-1 review F2 — **THE RELAY SAYS WHY, AND WE USED TO THROW IT AWAY.**
2009
+ *
2010
+ * `relay_auth_failed` carries a `reason` — `rate_limited`, `signature_invalid`, `nonce_expired`,
2011
+ * `nonce_reused`, `nonce_unknown` — and, when throttled, a `retry_after_ms`. This branch
2012
+ * collapsed all of them into the single word `auth_rejected`, so a throttled agent looked
2013
+ * exactly like a bad signature, which looked exactly like a dead relay. That is precisely the
2014
+ * distinction the order that added those refusals set out to create, undone at the last hop.
2015
+ *
2016
+ * The difference matters to whoever is looking: `rate_limited` clears by itself and says when;
2017
+ * `signature_invalid` never clears and means a key or clock problem; a nonce failure means the
2018
+ * handshake raced and an immediate retry is the right move. One label for all three sends
2019
+ * someone to look for a broken relay in all three cases.
2020
+ */
2021
+ const relayReason = typeof ackFrame["reason"] === "string" ? ackFrame["reason"] : undefined;
2022
+ const retryAfterMs = typeof ackFrame["retry_after_ms"] === "number" ? ackFrame["retry_after_ms"] : undefined;
2023
+ /**
2024
+ * DOD-M15-RELAYSLOTS-1: keep the refusal, do not merely log it.
2025
+ *
2026
+ * Everything below writes an excellent warn line into a file nobody opens. The operator who
2027
+ * runs `cello_use_agent` and finds their agent unreachable never sees it, and the daemon
2028
+ * deciding whether another relay would help cannot read it either. So the classified refusal
2029
+ * — reason, what to do about it, and whether to fail over — is stored where both can reach it.
2030
+ */
2031
+ const slotsHeld = typeof ackFrame["slots_held"] === "number" ? ackFrame["slots_held"] : undefined;
2032
+ const slotCap = typeof ackFrame["slot_cap"] === "number" ? ackFrame["slot_cap"] : undefined;
2033
+ this.#lastAuthRefusal = classifyRelayAuthRefusal(ackFrame["type"] === "relay_auth_failed" ? (relayReason ?? "auth_rejected") : "unexpected_frame", {
2034
+ ...(slotsHeld !== undefined ? { slotsHeld } : {}),
2035
+ ...(slotCap !== undefined ? { slotCap } : {}),
2036
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
2037
+ });
2038
+ this.#logger.warn("session.relay.auth.failed", {
2039
+ relayPeerId: this.#relayPeerId,
2040
+ reason: ackFrame["type"] === "relay_auth_failed" ? (relayReason ?? "auth_rejected") : "unexpected_frame",
2041
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
2042
+ impact: relayReason === "rate_limited"
2043
+ ? "this relay is throttling us; it clears on its own after the stated window, and until it does this agent cannot reserve or witness here"
2044
+ : "this agent could not authenticate to this relay, so it cannot witness leaves or hold a reservation here",
2045
+ });
2046
+ return false;
2047
+ }
2048
+ // DOD-M15-RELAYSLOTS-1: a success clears the stored refusal, so a stale one is never reported
2049
+ // as the current state of a relay that has since started admitting us.
2050
+ this.#lastAuthRefusal = null;
2051
+ return true;
2052
+ }
2053
+ #startReader(stream, iter) {
2054
+ void (async () => {
2055
+ try {
2056
+ while (!this.#closed && this.#stream === stream) {
2057
+ const res = await iter.next();
2058
+ if (res.done || res.value === undefined)
2059
+ break;
2060
+ // The await above can suspend across a #resetStream() (timeout) that supersedes this
2061
+ // stream. Re-check identity before dispatching so a late frame from a stale stream
2062
+ // (e.g. a buffered ack for a timed-out submit) can't bump/settle the wrong session.
2063
+ if (this.#stream !== stream)
2064
+ break;
2065
+ let frame;
2066
+ try {
2067
+ frame = decode(toU8(res.value));
2068
+ }
2069
+ catch {
2070
+ continue;
2071
+ }
2072
+ this.#dispatch(frame);
2073
+ }
2074
+ }
2075
+ catch (err) {
2076
+ // WARN, not debug (DOD-RELAY-KEEPALIVE-1 review F4). This is the ONLY place the cause of a
2077
+ // dead relay link survives: the watchdog that notices later reports `relay_connection_gone`,
2078
+ // which names where it noticed, not why. At debug, 2,061 of these went untraced through a
2079
+ // launch — the reader ending is not routine, it means in-flight submits just failed.
2080
+ this.#logger.warn("session.relay.reader.ended", { relayPeerId: this.#relayPeerId, error: extractErrorMessage(err) });
2081
+ this.#lastReaderError = extractErrorMessage(err);
2082
+ }
2083
+ finally {
2084
+ // Stream gone — clear it so the next submit re-dials, and fail any in-flight submit.
2085
+ if (this.#stream === stream)
2086
+ this.#stream = null;
2087
+ this.#settlePending({ ok: false, reason: "relay_stream_closed" });
2088
+ // Settle an in-flight record too, so #doRecord doesn't
2089
+ // wait the full timeout on a dropped stream. "closed" is transient (not a directory rejection) ⇒
2090
+ // recorded stays false and it is retried after reconnect.
2091
+ {
2092
+ const r = this.#pendingRecord;
2093
+ this.#pendingRecord = null;
2094
+ if (r)
2095
+ r("closed");
2096
+ }
2097
+ // 054-SRSPLIT: settle an in-flight release too — `false`, because the relay was not told.
2098
+ {
2099
+ const r = this.#pendingRelease;
2100
+ this.#pendingRelease = null;
2101
+ if (r)
2102
+ r(false);
2103
+ }
2104
+ // DOD-M15-AWAYSCOPE-1: and an in-flight liveness query, so a status read does not wait its
2105
+ // whole timeout on a stream that is already gone. "unknown" is the honest answer.
2106
+ {
2107
+ for (const r of this.#pendingLiveness.values())
2108
+ r({ liveness: "unknown", observedAt: 0 });
2109
+ this.#pendingLiveness.clear();
2110
+ }
2111
+ // A receiving session never submits, so nothing else re-dials: reconnect so queued leaf_delivers arrive.
2112
+ if (!this.#closed && this.#sessions.size > 0)
2113
+ void this.#reconnectFromAnySession();
2114
+ }
2115
+ })();
2116
+ }
2117
+ /** Re-establish the shared stream from any session's node, retrying while one depends on it — see `relay-reconnect.ts`. */
2118
+ async #reconnectFromAnySession() {
2119
+ if (this.#reconnecting)
2120
+ return;
2121
+ this.#reconnecting = true;
2122
+ const stranded = () => !this.#closed && this.#stream === null && this.#sessions.size > 0;
2123
+ await reconnectWithBackoff({
2124
+ relayPeerId: this.#relayPeerId, logger: this.#logger, baseMs: this.#reconnectRetryMs, shouldStop: () => !stranded(), refusal: () => this.#lastAuthRefusal,
2125
+ connectOnce: async () => { for (const { node } of this.#sessions.values())
2126
+ if (await this.#ensureConnected(node))
2127
+ return true; return false; },
2128
+ }).finally(() => { this.#reconnecting = false; });
2129
+ if (stranded())
2130
+ void this.#reconnectFromAnySession(); // a reader that ended during the loop's exit saw the flag set
2131
+ }
2132
+ /**
2133
+ * Submit a session's CONTENT-leaf hash to the relay. Connects/re-connects from `node` if needed.
2134
+ * Globally FIFO across the agent's sessions (the ack has no session_id).
2135
+ *
2136
+ * `leafKind` defaults to MESSAGE, which is what `cello_send` wants. It is a PARAMETER because
2137
+ * the document path needs 0x04/0x05: the seal certificate is computed by the directory from the
2138
+ * leaves the RELAY witnessed, and `seal-legibility.ts` excludes doc/reject leaves from
2139
+ * `final_message` and from `answered` — guards that could never fire while this method hardcoded
2140
+ * MSG for every caller. See `document-leaf-kind-on-the-wire.test.ts` for what that cost.
2141
+ */
2142
+ async submitMessageHash(node, sessionId, contentHash,
2143
+ /**
2144
+ * REQUIRED — `DOD-M15-SEALWIRE-1` B2b-1 pass-2 F3. This default was the last one on the path,
2145
+ * and it is the reason a test passing `undefined` for `leafKind` looked correct: the value was
2146
+ * silently rebuilt as MESSAGE one hop below the parameter the fix had just made required. Its
2147
+ * one production caller always passes explicitly, so the default was dead and misleading.
2148
+ */
2149
+ leafKind) {
2150
+ /**
2151
+ * `null`, and it is now IMPOSSIBLE to omit — see `submitLeaf`'s note on why the parameter is
2152
+ * required. A message leaf's content belongs to the operator and never reaches the relay.
2153
+ */
2154
+ return this.submitLeaf(node, sessionId, contentHash, leafKind, null);
2155
+ }
2156
+ /**
2157
+ * ─── WITNESS A LEAF THIS AGENT RECEIVED BUT DID NOT AUTHOR — 034-CARRYLEAF ────────────────────
2158
+ *
2159
+ * **This is what closes `DOD-M15-WITHHOLD-SEAL-1`.** Until it existed, `submitMessageHash` had
2160
+ * one production caller on the SEND path, so nothing ever witnessed a message that was RECEIVED.
2161
+ * A counterparty who delivered a message directly and never submitted its hash left the relay's
2162
+ * account of the conversation one message short — permanently — and a unilateral seal then agreed
2163
+ * with the witness. Every leaf validly signed, nothing false, the last thing said simply absent.
2164
+ *
2165
+ * **The teeth are the author's own signature.** It arrived on the content frame beside the bytes
2166
+ * it signs, this daemon verified it before ingesting anything, and it cannot be forged here. The
2167
+ * relay verifies it again against the session's assignment before sequencing — so what this hands
2168
+ * over is a claim the author made and cannot disown.
2169
+ *
2170
+ * ⚠️ **THE BYTES ARE PASSED THROUGH, NEVER REBUILT.** A signature is over the encoded bytes, and
2171
+ * the one measured cost of forgetting that on this exact structure was a daemon-local encoder
2172
+ * emitting a timestamp as float64 where the published one promotes to uint64 — same value,
2173
+ * different signed bytes, refused by everyone.
2174
+ */
2175
+ async witnessReceivedLeaf(node, sessionId, contentHash, leafKind, carried) {
2176
+ return this.submitLeaf(node, sessionId, contentHash, leafKind, null, carried);
2177
+ }
2178
+ /**
2179
+ * Submit a leaf hash of a given kind (0x00 message / 0x02 control) to the relay. The SEAL
2180
+ * ctrl leaf rides this path: two distinct-sender ctrl leaves in the relay's
2181
+ * log trigger the directory's FROST notarization (relay `#maybeProcessSeal`).
2182
+ *
2183
+ * ─── `contentBytes` — `DOD-M15-SEALWIRE-1` bullets 3+4, THE SENDER LEG ───────────────────────
2184
+ *
2185
+ * The SEAL leaf's own payload, carried alongside its hash. Without it the directory holds a
2186
+ * SHA-256 pre-image and nothing else, so the client's SIGNED `final_root` — the one value in the
2187
+ * whole seal that the relay cannot produce — is unrecoverable, and every root check the directory
2188
+ * can make compares the relay against itself.
2189
+ *
2190
+ * ⚠️ THIS PARAMETER PUTS LEAF CONTENT ON THE RELAY, AND THE RELAY IS THE PARTY THIS PROTOCOL
2191
+ * EXISTS TO KEEP CONTENT AWAY FROM (INV-3: a forwarding relay sees ciphertext).
2192
+ *
2193
+ * It is safe for a SEAL ctrl leaf and for nothing else. The payload is `[session_id, final_root,
2194
+ * close_timestamp, "PENDING"]` and the relay already knows all four — it assigned the session,
2195
+ * built the tree the root comes from, and stamped the leaf. Nothing is disclosed. That reasoning
2196
+ * stops dead at the next leaf kind: a `msg` leaf's content is the operator's plaintext and a `doc`
2197
+ * leaf's is their document.
2198
+ *
2199
+ * So both directions are REFUSED rather than tidied, and refused HERE rather than at the relay:
2200
+ *
2201
+ * - content on a non-ctrl leaf → the relay would refuse the whole frame, but only after the
2202
+ * operator's words had already crossed the wire to the party that must not have them, and the
2203
+ * refusal would destroy their send rather than protect it.
2204
+ * - a ctrl leaf with NO payload → this was the actual defect. `submitSealLeaf` computed the
2205
+ * payload, hashed it, and had nowhere to put it, so it was dropped. The seal still succeeded,
2206
+ * the relay still acked, and three hops later the directory reported `not_carried` and blamed
2207
+ * the relay's build version — for a value the client never sent. Four reviewed legs shipped
2208
+ * over that silence. A dropped argument now fails on the machine that dropped it.
2209
+ *
2210
+ * ⚠️ REQUIRED, AND `| null` RATHER THAN `?` — THE TYPE IS THE GUARD.
2211
+ *
2212
+ * I first wrote this optional and covered it with tests. Then I ran the revert test that mattered:
2213
+ * drop the argument at the one call site that must pass it, exactly reproducing the original
2214
+ * defect. **All five new tests stayed green.** An optional parameter makes the defect a silent,
2215
+ * type-legal omission — which is precisely how it shipped through four reviews the first time.
2216
+ *
2217
+ * Required means the omission is a COMPILE ERROR, caught by the gate on the machine that made it,
2218
+ * before any test runs. Every caller must now say what this leaf carries, and `submitMessageHash`
2219
+ * says `null` in one visible place instead of by saying nothing at all.
2220
+ */
2221
+ async submitLeaf(node, sessionId, contentHash, leafKind, contentBytes,
2222
+ /**
2223
+ * 034-CARRYLEAF — a leaf THIS AGENT DID NOT AUTHOR, carried on its author's behalf.
2224
+ *
2225
+ * Absent for every ordinary send, where this client builds and signs its own claim. Present
2226
+ * only when witnessing something received whose author never submitted it — see
2227
+ * `witnessReceivedLeaf`.
2228
+ */
2229
+ carried) {
2230
+ if (contentBytes !== null && leafKind !== LEAF_KIND_CTRL) {
2231
+ // Logged at ERROR and returned: a caller reaching this line is trying to hand the relay
2232
+ // operator content, and the log must carry it even if the caller swallows the result.
2233
+ this.#logger.error("session.relay.submit.content_not_permitted", {
2234
+ relayPeerId: this.#relayPeerId,
2235
+ leafKind,
2236
+ impact: "the submit was NOT sent. Only a SEAL ctrl leaf may carry its content to the relay; every other leaf kind's content belongs to the operator.",
2237
+ guidance: "Pass contentBytes only with LEAF_KIND_CTRL. If a new leaf kind genuinely needs to disclose its content to the relay, that is a protocol decision, not a call-site one.",
2238
+ });
2239
+ return { ok: false, reason: "content_not_permitted_for_leaf_kind" };
2240
+ }
2241
+ if (contentBytes === null && leafKind === LEAF_KIND_CTRL) {
2242
+ this.#logger.error("session.relay.submit.seal_payload_missing", {
2243
+ relayPeerId: this.#relayPeerId,
2244
+ impact: "the seal leaf was NOT sent. Sending it without its payload produces a certificate the directory cannot check against any participant's signed transcript — silently, and reported downstream as the RELAY being on an old build.",
2245
+ guidance: "A ctrl leaf on this path is a SEAL leaf; pass the encodeSealPayload bytes whose SHA-256(0x02 ‖ payload) is the contentHash argument.",
2246
+ });
2247
+ return { ok: false, reason: "seal_payload_not_carried" };
2248
+ }
2249
+ if (contentBytes !== null) {
2250
+ /**
2251
+ * ⚠️ THE BYTES MUST BE A SEAL PAYLOAD FOR THIS SESSION, AND THEY MUST HASH TO THE HASH BEING
2252
+ * SIGNED — review pass 2, MEDIUM-1 and HIGH-1. The kind check alone was not the property this
2253
+ * parameter's whole justification rests on.
2254
+ *
2255
+ * The justification is: *"the payload is [session_id, final_root, close_timestamp, "PENDING"]
2256
+ * and the relay already knows all four, so nothing is disclosed."* The code enforced
2257
+ * `leafKind === CTRL` and nothing else — so a caller passing a ctrl leaf with four kilobytes
2258
+ * of the operator's text would have transmitted it, and been refused only at the relay, AFTER
2259
+ * it crossed the wire to the party that must not have it. That is the precise harm the local
2260
+ * guard exists to prevent, and the relay learned this same lesson at its own review (H1 in
2261
+ * `relay-frames.ts`) one file over. I wrote the weaker version anyway.
2262
+ *
2263
+ * THE HASH BINDING IS THE MORE IMPORTANT HALF, and it closes a mutant that survived pass 1's
2264
+ * type hardening. Making the parameter required catches an OMITTED argument; it cannot catch a
2265
+ * SUBSTITUTED one. A caller that re-derives the payload instead of passing the one it hashed
2266
+ * — a second `encodeSealPayload` call, a fresh `Date.now()` — compiles, and the mismatch
2267
+ * surfaces at the directory as `seal_payload_unbound`, whose guidance reads *"someone between
2268
+ * them and here altered or fabricated the payload — the relay is the only party on that path.
2269
+ * Treat this as relay tampering, not a version mismatch."*
2270
+ *
2271
+ * **A client-side derivation slip would be published as a named accusation against a healthy
2272
+ * relay operator.** Checking it here makes it a local refusal on the machine that caused it.
2273
+ */
2274
+ const rederived = new Uint8Array(createHash("sha256").update(new Uint8Array([LEAF_KIND_CTRL])).update(contentBytes).digest());
2275
+ if (!Buffer.from(rederived).equals(Buffer.from(contentHash))) {
2276
+ this.#logger.error("session.relay.submit.seal_payload_unbound", {
2277
+ relayPeerId: this.#relayPeerId,
2278
+ impact: "the seal leaf was NOT sent. The payload does not hash to the content_hash this leaf signs, so the directory would have reported it as RELAY TAMPERING — a named accusation against a node that did nothing wrong.",
2279
+ guidance: "Pass the SAME bytes that produced contentHash. Re-deriving the payload at the call site produces a different close_timestamp and breaks the binding.",
2280
+ });
2281
+ return { ok: false, reason: "seal_payload_unbound" };
2282
+ }
2283
+ const decoded = decodeSealPayload(contentBytes);
2284
+ if (!decoded || !Buffer.from(decoded.session_id).equals(Buffer.from(sessionId))) {
2285
+ this.#logger.error("session.relay.submit.seal_payload_invalid", {
2286
+ relayPeerId: this.#relayPeerId,
2287
+ impact: "the submit was NOT sent. Only a SEAL payload for THIS session may be disclosed to the relay — arbitrary bytes on a ctrl leaf are still the operator's content, and a payload for another session is a replay.",
2288
+ guidance: decoded
2289
+ ? "The payload names a different session than the one being submitted."
2290
+ : "The bytes are not a decodable SEAL payload. Build them with encodeSealPayload.",
2291
+ });
2292
+ return { ok: false, reason: "seal_payload_invalid" };
2293
+ }
2294
+ }
2295
+ // Chain on the prior submit so only one is outstanding at a time (FIFO). The ack
2296
+ // carries no session_id, so concurrent submits on one stream would be ambiguous.
2297
+ const run = this.#submitChain.then(() => this.#doSubmit(node, sessionId, contentHash, leafKind, contentBytes, carried));
2298
+ // Keep the chain alive regardless of this submit's outcome.
2299
+ this.#submitChain = run.then(() => undefined, () => undefined);
2300
+ return run;
2301
+ }
2302
+ /**
2303
+ * DOD-FIRSTMSG-WITNESS-1: `session_not_found` is TRANSIENT, not terminal.
2304
+ *
2305
+ * The relay answers it when it does not hold the session YET — the assignment record is still
2306
+ * landing, or the counterparty recorded it and our own record raced ahead of the submit. Proven
2307
+ * from the live log: in all 23 first-message failures the `assignment.recorded` event lands
2308
+ * 5 ms – 2.1 s AFTER the rejected submit. The relay is reachable and answering; it is simply
2309
+ * not ready.
2310
+ *
2311
+ * Before this, the rejection was returned to `sendContent`, which logged
2312
+ * `session.relay.hash.submit.failed` and appended the leaf UNWITNESSED anyway. Nothing retried,
2313
+ * so the relay's counter never counted that message and the local record stayed exactly one
2314
+ * ahead for the life of the session. Because the bilateral certificate is rebuilt EXCLUSIVELY
2315
+ * from relay-witnessed leaves, the sealed receipt omitted the conversation's opening message —
2316
+ * and was issued regardless.
2317
+ *
2318
+ * Retrying re-presents the assignment first (idempotent — the relay answers `assignment_ok` on
2319
+ * `session_already_exists`), which is also what supplies the wait: re-recording is a full
2320
+ * round trip, so the retry cannot busy-spin against a relay that is still catching up.
2321
+ *
2322
+ * This does NOT collapse the two states the fix must tell apart:
2323
+ * - `session_not_found` — a reachable relay that does not hold the session yet → retry here.
2324
+ * - `relay_unavailable` / stream failures — a genuine outage → returned untouched, so
2325
+ * `sendContent` still degrades to an unwitnessed append and the inbox stays readable.
2326
+ * Any other rejection (`session_sealed`, `not_a_participant`, signature failures) is terminal
2327
+ * and returned as-is — retrying those would be pointless traffic masking a real state.
2328
+ */
2329
+ static #SESSION_NOT_FOUND_ATTEMPTS = 3;
2330
+ /**
2331
+ * DOD-M15-RELAYABUSE-1 review F1: how many times a throttled submit waits out the relay's stated
2332
+ * window before the refusal is surfaced. Three, because the window is a fixed sliding minute —
2333
+ * two waits clear any ordinary burst, and a third failure means something other than this
2334
+ * sender's own volume is going on, which the operator should hear about.
2335
+ */
2336
+ static #RATE_LIMITED_ATTEMPTS = 3;
2337
+ /** Used only when the relay names no window (an older relay, or a malformed value). */
2338
+ static #RATE_LIMITED_FALLBACK_MS = 5_000;
2339
+ /**
2340
+ * Ceiling on a single wait, so a relay reporting an implausible window cannot park a send
2341
+ * indefinitely — a hostile or misconfigured relay must not be able to stall a sender by
2342
+ * answering `retry_after_ms: 3600000`. Past this the send fails and says so.
2343
+ */
2344
+ static #RATE_LIMITED_MAX_WAIT_MS = 65_000;
2345
+ async #doSubmit(node, sessionId, contentHash, leafKind, contentBytes, carried) {
2346
+ const sessionIdHex = Buffer.from(sessionId).toString("hex");
2347
+ // Snapshotted BEFORE the first attempt, and it is the whole safety of this loop.
2348
+ //
2349
+ // The first-message race is BY DEFINITION a session we have not recorded yet. A session we DID
2350
+ // record and that the relay now reports missing is a DIFFERENT state: the relay destroys a
2351
+ // session on seal (relay-node.ts `confirmSeal`) and on idle sweep, and its store keeps NO
2352
+ // tombstone — `recordSession` re-creates any absent key fresh (`seq_counter: 0`, empty
2353
+ // leaf_log, status "active"). So `getSession` answers `session_not_found`, NOT `session_sealed`.
2354
+ // Re-presenting our still-valid directory-signed assignment there would silently RESURRECT a
2355
+ // sealed session on the relay: a ghost with an empty log, an idle timer and a delivery path,
2356
+ // created by a client submit retry. Two ctrl leaves on it would drive a second notarization
2357
+ // over a 1-leaf log for an already-certified session.
2358
+ //
2359
+ // The 2 post-seal failures in this defect's own evidence table are exactly that shape — they
2360
+ // reported `session_not_found`, indistinguishable at the wire from the race — which is why
2361
+ // this must be discriminated on OUR state, not on the relay's reason string.
2362
+ const recordedBefore = this.#sessions.get(sessionIdHex)?.recorded === true;
2363
+ let result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes, carried);
2364
+ for (let attempt = 1; attempt < _a.#SESSION_NOT_FOUND_ATTEMPTS
2365
+ && !recordedBefore
2366
+ && !result.ok
2367
+ && (result.reason === "session_not_found" || result.reason === "session_not_recorded")
2368
+ && !this.#closed; attempt++) {
2369
+ // Force the assignment to be re-presented: we never recorded this session, so the relay
2370
+ // genuinely does not hold it yet. A session with no assignment to present (direct/legacy)
2371
+ // re-submits without a record — still bounded, and it surfaces the same named failure
2372
+ // rather than hanging.
2373
+ const sess = this.#sessions.get(sessionIdHex);
2374
+ if (sess)
2375
+ sess.recorded = false;
2376
+ this.#logger.info("session.relay.submit.retry", {
2377
+ relayPeerId: this.#relayPeerId,
2378
+ sessionShort: sessionIdHex.slice(0, 16),
2379
+ attempt,
2380
+ reason: result.reason,
2381
+ });
2382
+ result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes, carried);
2383
+ }
2384
+ /**
2385
+ * DOD-M15-RELAYABUSE-1 review F1 — **A THROTTLE IS BACK-PRESSURE, NOT AN ERROR.** (Andre,
2386
+ * 2026-08-31: retry on the relay's own timing; surface only if the retry also fails.)
2387
+ *
2388
+ * Without this the relay's `rate_limited` fell into the caller's catch-all: one log line, and
2389
+ * `cello_send` returned `{ok:true, delivered:true}` for a message the relay had refused to
2390
+ * witness — on the parked path telling the operator it was *"sealed, witnessed and on its way"*.
2391
+ * The leaf went out unwitnessed with no sequence number, and the seal later covered a transcript
2392
+ * missing it.
2393
+ *
2394
+ * This is the ONE refusal that is safely retryable and self-clearing, and the relay tells us
2395
+ * exactly when. So we wait it out here, where the wait is invisible, rather than handing the
2396
+ * agent an error for a condition that resolves in under a minute. Bounded: if the window is
2397
+ * absent or implausible we fall back to a fixed wait, and after
2398
+ * `#RATE_LIMITED_ATTEMPTS` the refusal is returned and the caller surfaces it — Option 2 as
2399
+ * the fallback, not the first move.
2400
+ */
2401
+ for (let attempt = 1; attempt < _a.#RATE_LIMITED_ATTEMPTS
2402
+ && !result.ok
2403
+ && result.reason === "rate_limited"
2404
+ && !this.#closed; attempt++) {
2405
+ const waitMs = Math.min(result.retry_after_ms !== undefined ? result.retry_after_ms : _a.#RATE_LIMITED_FALLBACK_MS, _a.#RATE_LIMITED_MAX_WAIT_MS);
2406
+ this.#logger.info("session.relay.submit.throttled", {
2407
+ relayPeerId: this.#relayPeerId,
2408
+ sessionShort: sessionIdHex.slice(0, 16),
2409
+ attempt,
2410
+ waitMs,
2411
+ retryAfterMsFromRelay: result.retry_after_ms,
2412
+ impact: "the relay is throttling this sender; waiting out its stated window and resubmitting — the message is NOT lost and the operator is not told, because this clears on its own",
2413
+ });
2414
+ await new Promise((r) => setTimeout(r, waitMs));
2415
+ if (this.#closed)
2416
+ break;
2417
+ result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes, carried);
2418
+ }
2419
+ if (!result.ok && result.reason === "rate_limited") {
2420
+ // Option 2, the fallback: it did not clear within our budget, so the caller must hear it
2421
+ // rather than be told the message was witnessed.
2422
+ this.#logger.warn("session.relay.submit.throttle_persisted", {
2423
+ relayPeerId: this.#relayPeerId,
2424
+ sessionShort: sessionIdHex.slice(0, 16),
2425
+ attempts: _a.#RATE_LIMITED_ATTEMPTS,
2426
+ impact: "this message was NOT witnessed by the relay — it has no sequence number and will not appear in the notarized record",
2427
+ });
2428
+ }
2429
+ // The relay lost a session we had successfully recorded — sealed, idle-swept, or restarted.
2430
+ // Report THAT, rather than letting the caller read a bare `session_not_found` that reads like
2431
+ // the first-message race. Never re-present here: recreating it is the resurrection above.
2432
+ if (recordedBefore && !result.ok && result.reason === "session_not_found") {
2433
+ this.#logger.warn("session.relay.session.gone", {
2434
+ relayPeerId: this.#relayPeerId,
2435
+ sessionShort: sessionIdHex.slice(0, 16),
2436
+ guidance: "the relay no longer holds a session it had recorded (sealed, idle-swept, or restarted) — not re-presented, because that would recreate it with an empty leaf log",
2437
+ });
2438
+ return { ok: false, reason: "relay_session_gone" };
2439
+ }
2440
+ return result;
2441
+ }
2442
+ async #doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes, carried) {
2443
+ if (this.#closed)
2444
+ return { ok: false, reason: "relay_client_closed" };
2445
+ if (!(await this.#ensureConnected(node))) {
2446
+ /**
2447
+ * ─── OUR OWN DEAD CREDENTIAL IS NOT A RELAY OUTAGE — `DOD-M15-TOKENSTALE-1` ───────────────
2448
+ *
2449
+ * `#ensureConnected` answers with a BOOLEAN, so every reason a relay ever gave for refusing
2450
+ * us arrived here as `false` and left as `relay_unavailable`. That word is a claim about the
2451
+ * RELAY: unreachable, transient, someone else's, and the documented grounds on which a send
2452
+ * appends an unwitnessed leaf and still reports success.
2453
+ *
2454
+ * An expired online token is the opposite on all four counts, and the cost of the mislabel is
2455
+ * not cosmetic. Measured live 2026-09-10: an agent ran for over two days answering
2456
+ * `ok:true, delivered:true` to every send while the relay refused all thirteen of its
2457
+ * authentications. Nothing it said in that time could ever produce a receipt, and no surface
2458
+ * anywhere said so — because the one place that knew called it a relay problem.
2459
+ *
2460
+ * `#lastAuthRefusal` already held the truth (`cello status` reads it); the submit path simply
2461
+ * never looked. Only LOCAL refusals are promoted: a genuine relay fault keeps
2462
+ * `relay_unavailable`, so this is a split rather than a rename, and the caller's existing
2463
+ * degradation for a real outage is untouched.
2464
+ */
2465
+ const refusal = this.#lastAuthRefusal;
2466
+ if (refusal && isLocalCredentialRefusal(refusal.reason)) {
2467
+ /**
2468
+ * ⚠️ THE PROSE ONCE PER TRANSITION, THE REASON EVERY TIME — review F6.
2469
+ *
2470
+ * This condition lasts until a relogin by construction, and every send retries it. Logging
2471
+ * several hundred bytes of explanation on each one is the shape `96f3179b` measured four
2472
+ * commits earlier: a 176 MB log that was 95% one already-fixed defect repeating itself,
2473
+ * which hid the defect that produced it for eleven hours. The first occurrence carries
2474
+ * everything; the rest are countable without being unreadable.
2475
+ */
2476
+ const firstOfThisRun = this.#loggedCredentialRefusal !== refusal.reason;
2477
+ this.#loggedCredentialRefusal = refusal.reason;
2478
+ if (firstOfThisRun) {
2479
+ this.#logger.error("session.relay.submit.local_credential_refusal", {
2480
+ relayPeerId: this.#relayPeerId,
2481
+ reason: refusal.reason,
2482
+ impact: "the relay refused THIS AGENT'S credential, so nothing it sends can be witnessed and no " +
2483
+ "conversation can produce a receipt. This is a fault on this machine and it does not " +
2484
+ "clear on its own — it is NOT the relay being unreachable.",
2485
+ guidance: refusal.advice,
2486
+ });
2487
+ }
2488
+ else {
2489
+ this.#logger.warn("session.relay.submit.local_credential_refusal.again", {
2490
+ relayPeerId: this.#relayPeerId, reason: refusal.reason,
2491
+ });
2492
+ }
2493
+ return { ok: false, reason: refusal.reason };
2494
+ }
2495
+ return { ok: false, reason: "relay_unavailable" };
2496
+ }
2497
+ const sessionIdHex = Buffer.from(sessionId).toString("hex");
2498
+ // The relay records the session from the CLIENT-presented assignment. It MUST be recorded BEFORE
2499
+ // the first hash_submit — the relay rejects a submit for an unknown session. Idempotent + no-op when
2500
+ // there is no assignment to present (direct/persisted sessions). Runs inline on the submit chain (no re-chaining
2501
+ // — #doSubmit is already a chain link), and may reset the stream on failure, so capture #stream after.
2502
+ const recorded = await this.#doRecord(node, sessionIdHex);
2503
+ const sess = this.#sessions.get(sessionIdHex);
2504
+ if (!recorded && sess?.assignment) {
2505
+ // DOD-FIRSTMSG-WITNESS-1 AC3: do NOT send a hash_submit for a session we know the relay does
2506
+ // not hold. Previously this return value was discarded and the doomed frame went out anyway —
2507
+ // the line this defect's producer→consumer trace names as THE gap. Two sub-states, kept apart:
2508
+ // - recordRejected → TERMINAL (the relay refused the assignment as unverifiable). Retrying
2509
+ // cannot help and would storm the shared stream.
2510
+ // - anything else → the record is in flight or transiently failed. Retryable.
2511
+ // A session with NO assignment to present (direct/legacy) is not covered here: #doRecord
2512
+ // returns true for it, so it still submits exactly as before.
2513
+ if (sess.recordRejected)
2514
+ return { ok: false, reason: "relay_assignment_rejected" };
2515
+ // A TIMEOUT IS NOT "NOT READY YET". The retry below exists for a relay that has not finished
2516
+ // registering the session — it answers in milliseconds. A record that timed out means the relay
2517
+ // is not answering at all, and retrying spends HASH_SUBMIT_TIMEOUT_MS again per attempt on the
2518
+ // chain SHARED by every session this agent holds on this relay. Classifying it as unreachable
2519
+ // proceeds unwitnessed immediately, which is what AC2 asks for on an outage — the send is not
2520
+ // lost, and one sick relay cannot hold an operator's other conversations for half a minute.
2521
+ if (sess.recordTimedOut)
2522
+ return { ok: false, reason: "relay_unavailable" };
2523
+ return { ok: false, reason: "session_not_recorded" };
2524
+ }
2525
+ const stream = this.#stream;
2526
+ if (!stream)
2527
+ return { ok: false, reason: "relay_unavailable" };
2528
+ /**
2529
+ * ─── WHAT THIS SEND ACKNOWLEDGES — 033-ACKEMIT ───────────────────────────────────────────────
2530
+ *
2531
+ * This session's OWN high-water mark (NOT an agent-global one) — the relay's seq_counter is per
2532
+ * session and rejects `last_seen_seq > seq_counter` — AND the content hash at that position,
2533
+ * read from the one entry that holds both.
2534
+ */
2535
+ const lastSeen = this.#lastSeen.get(sessionIdHex);
2536
+ /**
2537
+ * ⚠️ **THIS COMMENT USED TO SAY "REFUSED, NOT DOWNGRADED", AND THE CODE UNDER IT DID REFUSE.**
2538
+ * It is rewritten rather than deleted because a comment asserting a refusal that no longer
2539
+ * happens is how the next reader comes to believe a guard exists where there is a fallback.
2540
+ *
2541
+ * There is no seed only when this session was registered with neither a genesis nor an
2542
+ * assignment to derive one from, AND nothing has been received on it. The claim that goes out
2543
+ * then is `last_seen_seq: 0` with no hash — "I have seen nothing of yours" — which is true and
2544
+ * asserts nothing about content, so it is not the unbacked number this unit exists to stop
2545
+ * signing. A claim that NAMES a position with no hash is the defect, and the receiving daemon
2546
+ * refuses exactly that.
2547
+ */
2548
+ /**
2549
+ * ⚠️ NOT FOR A CARRIED LEAF — 034-CARRYLEAF review F8. This branch describes THIS agent having
2550
+ * nothing to acknowledge, and on a counter-submit the acknowledgement inside the bytes is the
2551
+ * AUTHOR's, already made. Logging "this submit acknowledges nothing" about it would be false,
2552
+ * and this daemon's own seed is irrelevant to a claim it did not write.
2553
+ */
2554
+ /**
2555
+ * ⚠️ THE `session.relay.submit.unacknowledged` LOG THAT WAS HERE IS GONE, AND ITS ABSENCE IS
2556
+ * DELIBERATE — `DOD-M15-SELFCHAIN-1`.
2557
+ *
2558
+ * It fired on exactly the condition the refusal below fires on (no seed, not a carried leaf)
2559
+ * and told the operator "the leaf is witnessed as normal". That is now false: nothing is
2560
+ * witnessed, the submit is refused. Two lines about one condition, one of them wrong, is how a
2561
+ * reader ends up trusting the wrong one — so the refusal below is the only thing that speaks.
2562
+ */
2563
+ // The published encoder from protocol-types — the ONE definition of the field order, pinned by
2564
+ // `structure1-canonical.json` (v1) and `structure1-v2-canonical.json` (v2). A second local copy
2565
+ // lived here until 020-ACKHASH; it drifted, and the drift was invisible because both copies
2566
+ // "worked": it encoded a timestamp above 2^32-1 as a CBOR float64 while the published encoder
2567
+ // (and every other TBS builder in this package) promotes it to a uint64. Same value, different
2568
+ // signed bytes, and only the vector said which was canonical.
2569
+ //
2570
+ // `lastSeenHash` is passed on EVERY send, so every claim this daemon signs is v2 and binds to
2571
+ // content. Nothing here ever passes `undefined` — see the refusal above.
2572
+ /**
2573
+ * ⚠️ **A CARRIED LEAF IS SENT VERBATIM AND SIGNED BY NOBODY HERE — 034-CARRYLEAF.**
2574
+ *
2575
+ * When this agent is witnessing something it RECEIVED, the claim already exists: its author
2576
+ * built it, signed it, and put it on the content frame. Re-encoding it would change the signed
2577
+ * bytes and the relay would refuse a leaf that is perfectly valid. Signing it ourselves would be
2578
+ * worse — it would turn their statement into ours, which is the one thing that must never happen
2579
+ * to a record whose whole value is that each party's words are their own.
2580
+ *
2581
+ * So this branch takes the bytes as they arrived, and this agent's own acknowledgement state is
2582
+ * deliberately NOT consulted: `last_seen_seq` and `last_seen_hash` inside those bytes are the
2583
+ * AUTHOR's account of what THEY had seen, and they are not ours to restate.
2584
+ */
2585
+ /**
2586
+ * ─── BOTH CHAIN LINKS, UNCONDITIONALLY — `DOD-M15-SELFCHAIN-1` ───────────────────────────────
2587
+ *
2588
+ * `lastSeenHash` chains this sender to their COUNTERPARTY. `prevOwnHash` chains them to
2589
+ * THEMSELVES. Neither is optional: a claim carrying one link is not a shape this protocol has,
2590
+ * and `encodeStructure1` has no branch that emits one.
2591
+ *
2592
+ * ⚠️ THE CONDITIONAL SPREAD THAT USED TO BE HERE DEFEATED THE TYPE. Writing
2593
+ * `...(x ? { lastSeenHash: x } : {})` satisfies a REQUIRED field as far as the compiler is
2594
+ * concerned, so the one guard that should have made an unlinked claim impossible to write was
2595
+ * silently inert. Pass both by value; let `tsc` do its job.
2596
+ *
2597
+ * ⚠️ NO SEED MEANS NO SEND. The seed is this session's genesis — the agreed starting point both
2598
+ * links fall back to before anything has been said. A session registered with neither a genesis
2599
+ * nor an assignment to derive one from has no such point, and the honest outcome is to refuse
2600
+ * rather than sign a chain anchored to nothing. There is no degraded shape to fall back to and
2601
+ * deliberately so: an unlinked message is invisible until the conversation's order is disputed,
2602
+ * which is far too late for anyone to act on it.
2603
+ */
2604
+ const seed = lastSeen?.hash;
2605
+ const genesis = this.#genesis.get(sessionIdHex);
2606
+ if (!carried && !seed) {
2607
+ this.#logger.error("session.relay.submit.unchainable", {
2608
+ relayPeerId: this.#relayPeerId,
2609
+ session: sessionIdHex,
2610
+ impact: "this session has no recorded starting point on this machine, so a message sent on it " +
2611
+ "could not link to anything and its place in the conversation could never be proven. " +
2612
+ "The message was NOT sent. Restart the session so it is registered with its genesis.",
2613
+ });
2614
+ return { ok: false, reason: "session_unchainable" };
2615
+ }
2616
+ /**
2617
+ * This agent's own previous message, or the session genesis when it has not spoken here yet.
2618
+ * "I have not spoken" is a VALUE — derived per session, so it cannot be presented for a
2619
+ * different conversation — never an absent field.
2620
+ *
2621
+ * The in-memory map first, then the durable store for the restart case, then the genesis. See
2622
+ * `#ownChain` for why a missing store degrades durability rather than blocking the send.
2623
+ */
2624
+ let prevOwn;
2625
+ if (!carried) {
2626
+ /**
2627
+ * ⚠️ THE LAST FALLBACK IS THE GENESIS, NOT `seed` — and `seed` is what it used to be.
2628
+ *
2629
+ * `seed` is `#lastSeen`, which ADVANCES as the counterparty speaks. So the moment the
2630
+ * counterparty had said anything, a party sending their FIRST message linked to the
2631
+ * counterparty's message instead of to the session's starting point. The relay expects the
2632
+ * genesis for a sender's first leaf, so it refused — and then told the counterparty, who had
2633
+ * done nothing, that this side's chain was broken. Every two-party conversation died on its
2634
+ * second message and blamed the wrong party for it.
2635
+ */
2636
+ prevOwn = this.#ownChain.get(sessionIdHex)
2637
+ ?? this.#ownChainStore?.lastOwnHash(this.senderPubkeyHex, sessionIdHex)
2638
+ ?? genesis;
2639
+ if (!prevOwn) {
2640
+ /**
2641
+ * Unreachable while a seed exists — both come from the same registration — but stated as a
2642
+ * refusal rather than a non-null assertion. The one thing that must never happen here is
2643
+ * signing a self link chosen because it was the nearest value to hand.
2644
+ */
2645
+ this.#logger.error("session.relay.submit.unchainable", {
2646
+ relayPeerId: this.#relayPeerId,
2647
+ session: sessionIdHex,
2648
+ impact: "this session has an acknowledgement but no recorded starting point, so this agent's " +
2649
+ "own first message has nothing to link to. The message was NOT sent. Restart the " +
2650
+ "session so it is registered with its genesis.",
2651
+ });
2652
+ return { ok: false, reason: "session_unchainable" };
2653
+ }
2654
+ }
2655
+ /**
2656
+ * ⚠️ **A CARRIED LEAF IS SENT VERBATIM AND SIGNED BY NOBODY HERE — 034-CARRYLEAF.**
2657
+ *
2658
+ * When this agent is witnessing something it RECEIVED, the claim already exists: its author
2659
+ * built it, signed it, and put it on the content frame. Re-encoding it would change the signed
2660
+ * bytes and the relay would refuse a leaf that is perfectly valid. Signing it ourselves would be
2661
+ * worse — it would turn their statement into ours, which is the one thing that must never happen
2662
+ * to a record whose whole value is that each party's words are their own.
2663
+ *
2664
+ * So this branch takes the bytes as they arrived, and this agent's own chain state is
2665
+ * deliberately NOT consulted: both links inside those bytes are the AUTHOR's account, and they
2666
+ * are not ours to restate.
2667
+ */
2668
+ const structure1 = carried ? carried.structure1Cbor : encodeStructure1({
2669
+ contentHash,
2670
+ senderPubkey: this.#senderPubkey,
2671
+ sessionId,
2672
+ lastSeenSeq: lastSeen.seq,
2673
+ timestamp: Date.now(),
2674
+ lastSeenHash: seed,
2675
+ prevOwnHash: prevOwn,
2676
+ });
2677
+ const signature = carried ? carried.senderSignature : await this.#keyProvider.sign(structure1);
2678
+ const frame = encodeCbor({
2679
+ type: "hash_submit",
2680
+ session_id: sessionId,
2681
+ leaf_kind: leafKind,
2682
+ structure1_cbor: structure1,
2683
+ sender_signature: signature,
2684
+ /**
2685
+ * `DOD-M15-SEALWIRE-1` bullets 3+4 — the SEAL payload, and ONLY on a ctrl leaf.
2686
+ *
2687
+ * ⚠️ MY REASON FOR THE SPREAD WAS MEASURABLY WRONG, AND THE TRUE RISK IS THE OPPOSITE ONE —
2688
+ * review pass 2, MEDIUM-3, corrected rather than deleted.
2689
+ *
2690
+ * It said an explicit `content_bytes: undefined` *"encodes as a present CBOR key, and the
2691
+ * relay's guard refuses a present-but-unusable value by voiding the whole frame — that would
2692
+ * turn every ordinary message into a refused submit."* Measured through the production encoder:
2693
+ * the key IS emitted (0xf7), but it decodes back to `undefined`, so the relay's guard never
2694
+ * fires and the frame is **accepted with no payload**.
2695
+ *
2696
+ * So the mutation does not produce a loud federation-wide refusal. It produces a silent
2697
+ * `not_carried` at the directory — exactly the silent downgrade this whole unit exists to kill,
2698
+ * and a far worse outcome than the one I warned about. Writing the scarier consequence would
2699
+ * have sent the next reader hunting an availability bug instead of a mute one.
2700
+ *
2701
+ * The spread is still correct, and the ANCHOR test is what pins it: `"content_bytes" in frame`
2702
+ * is TRUE for the `undefined` mutant precisely because the key is present, so that assertion —
2703
+ * not the relay — is what catches this.
2704
+ *
2705
+ * `submitLeaf` has already established that this is set if and only if `leafKind` is ctrl, and
2706
+ * that the bytes are a SEAL payload for this session hashing to the signed `content_hash` —
2707
+ * every direction refused there, at ERROR, before anything reaches the wire.
2708
+ */
2709
+ ...(contentBytes !== null ? { content_bytes: contentBytes } : {}),
2710
+ });
2711
+ // Set the resolver synchronously (no await between the in-flight check and the set):
2712
+ // the submit chain guarantees no other submit runs concurrently, so #pendingAck is null.
2713
+ let resolveAck;
2714
+ const ackPromise = new Promise((r) => { resolveAck = r; });
2715
+ this.#pendingAck = resolveAck;
2716
+ this.#pendingAckSessionHex = sessionIdHex;
2717
+ // Remember this submit's sender-signed structure1_cbor so its ack can return the full
2718
+ // ordering record (the ack itself carries only the relay's structure2_cbor).
2719
+ this.#pendingStructure1 = structure1;
2720
+ this.#pendingSignature = signature;
2721
+ this.#pendingLeafKind = leafKind;
2722
+ try {
2723
+ stream.send(lp.encode.single(frame));
2724
+ }
2725
+ catch (err) {
2726
+ if (this.#pendingAck === resolveAck) {
2727
+ this.#pendingAck = null;
2728
+ this.#pendingAckSessionHex = null;
2729
+ this.#pendingStructure1 = null;
2730
+ this.#pendingSignature = null;
2731
+ this.#pendingLeafKind = null;
2732
+ }
2733
+ this.#logger.warn("session.relay.submit.send.failed", { relayPeerId: this.#relayPeerId, error: extractErrorMessage(err) });
2734
+ return { ok: false, reason: "relay_submit_send_failed" };
2735
+ }
2736
+ let timer;
2737
+ const timeout = new Promise((r) => {
2738
+ timer = setTimeout(() => r({ ok: false, reason: "relay_submit_timeout" }), HASH_SUBMIT_TIMEOUT_MS);
2739
+ });
2740
+ try {
2741
+ const result = await Promise.race([ackPromise, timeout]);
2742
+ // On timeout, reset the stream so a late ack can't settle a LATER submit (FIFO desync).
2743
+ if (!result.ok && result.reason === "relay_submit_timeout")
2744
+ this.#resetStream();
2745
+ /**
2746
+ * ─── ADVANCE THE SELF CHAIN, AND ONLY ON SUCCESS — `DOD-M15-SELFCHAIN-1` ──────────────────
2747
+ *
2748
+ * Recorded AFTER the relay acknowledged, never before. Advancing first and then failing to
2749
+ * send would leave the chain pointing at a message that never existed, and every later message
2750
+ * would be refused by the counterparty with a reason that names tampering — an outage
2751
+ * reported as an attack.
2752
+ *
2753
+ * A retry therefore re-reads the same predecessor, which is exactly right: a retransmission is
2754
+ * the same message, not the next one.
2755
+ *
2756
+ * NOT for a carried leaf. Those bytes are the AUTHOR's and this agent did not write them, so
2757
+ * they are no part of this agent's own chain.
2758
+ */
2759
+ if (result.ok && !carried) {
2760
+ // The map is the chain — always advanced. The store is its durability, advanced when wired.
2761
+ this.#ownChain.set(sessionIdHex, contentHash);
2762
+ try {
2763
+ this.#ownChainStore?.record(this.senderPubkeyHex, sessionIdHex, contentHash, Date.now());
2764
+ }
2765
+ catch (err) {
2766
+ this.#logger.error("session.selfchain.record.failed", {
2767
+ session: sessionIdHex,
2768
+ error: extractErrorMessage(err),
2769
+ impact: "this message was witnessed, but this agent could not record it as the link for its " +
2770
+ "next message — so the next one will chain to the wrong predecessor and the " +
2771
+ "counterparty will refuse it. Restart the session to re-anchor the chain.",
2772
+ });
2773
+ }
2774
+ }
2775
+ return result;
2776
+ }
2777
+ finally {
2778
+ clearTimeout(timer);
2779
+ if (this.#pendingAck === resolveAck) {
2780
+ this.#pendingAck = null;
2781
+ this.#pendingAckSessionHex = null;
2782
+ this.#pendingStructure1 = null;
2783
+ this.#pendingSignature = null;
2784
+ this.#pendingLeafKind = null;
2785
+ }
2786
+ }
2787
+ }
2788
+ /**
2789
+ * Advance this session's acknowledgement from a message that ARRIVED — 033-ACKEMIT review F1.
2790
+ *
2791
+ * ⚠️ **`#bumpLastSeen` used to have exactly one caller, inside the `leaf_deliver` handler, so the
2792
+ * acknowledgement tracked what the RELAY DELIVERED rather than what was RECEIVED.** On a direct
2793
+ * session that is a real difference: the content arrives peer-to-peer and the relay's copy of the
2794
+ * leaf follows separately, so until it did, this daemon signed an acknowledgement one message
2795
+ * behind what it had actually read — and on a session where delivery never came back at all, the
2796
+ * acknowledgement never moved.
2797
+ *
2798
+ * The order's own words are "the content hash of the last message this sender ACTUALLY RECEIVED".
2799
+ * This is the caller that makes that true: the receive path calls it as soon as a message has been
2800
+ * verified and ingested at a known canonical position.
2801
+ *
2802
+ * **THE POSITION IS STILL REQUIRED, and that is a real limit rather than an oversight.** The pair
2803
+ * is (position, content-at-position), and the relay refuses a `last_seen_seq` that runs ahead of
2804
+ * its counter — so a message that arrived with NO ordering record cannot be acknowledged by
2805
+ * position at all, whatever we hold of it. That case is the withheld-submit attack itself, and it
2806
+ * is closed by carrying the sender's signed leaf into the seal, not from here.
2807
+ */
2808
+ noteReceivedLeaf(sessionIdHex, relaySeq, contentHash) {
2809
+ this.#bumpLastSeen(sessionIdHex, relaySeq, contentHash);
2810
+ }
2811
+ /**
2812
+ * The POSITION and the CONTENT AT IT together — 033-ACKEMIT.
2813
+ *
2814
+ * ⚠️ **IT REPLACED `lastSeenSeq()`, WHICH IS DELETED RATHER THAN LEFT WIRED.** That accessor
2815
+ * returned the position alone, and `session-node-manager`'s unwitnessed content claim was its
2816
+ * only caller. Leaving it in place after this one took over would leave a second way to read half
2817
+ * of a pair that must be read whole: a `last_seen_seq` paired with a `last_seen_hash` for a
2818
+ * different message is worse than no acknowledgement at all, because it looks checkable and
2819
+ * fails.
2820
+ *
2821
+ * `undefined` means this session has no acknowledgement to make, which the caller must handle
2822
+ * rather than fill in.
2823
+ */
2824
+ lastSeenAck(sessionIdHex) {
2825
+ return this.#lastSeen.get(sessionIdHex);
2826
+ }
2827
+ /**
2828
+ * This agent's OWN last message on a session — what its next message's self link must name.
2829
+ *
2830
+ * ⚠️ EXPOSED BECAUSE THERE MUST BE ONE CHAIN, NOT TWO. The unwitnessed send path lives in
2831
+ * `session-node-manager` and was reading only the durable store, while the witnessed path reads
2832
+ * this in-memory map first. A session that mixed the two — which is every session where the relay
2833
+ * comes and goes — was walking two different chains, and the one that lagged produced a link the
2834
+ * counterparty refuses.
2835
+ *
2836
+ * `undefined` means this agent has not spoken on this session yet, which is the session GENESIS
2837
+ * and not an absence. The caller supplies it; this class does not guess.
2838
+ */
2839
+ lastOwnHash(sessionIdHex) {
2840
+ return this.#ownChain.get(sessionIdHex)
2841
+ ?? this.#ownChainStore?.lastOwnHash(this.senderPubkeyHex, sessionIdHex)
2842
+ ?? undefined;
2843
+ }
2844
+ /**
2845
+ * Record what this agent just sent on a path this client did not carry — the UNWITNESSED send.
2846
+ *
2847
+ * The two paths share one chain (see `lastOwnHash`), so a direct send has to advance it here too.
2848
+ * Without this, a conversation that ran while the relay was down advanced nothing, and the first
2849
+ * witnessed message after it linked to something long superseded.
2850
+ */
2851
+ noteOwnLeaf(sessionIdHex, contentHash) {
2852
+ this.#ownChain.set(sessionIdHex, contentHash);
2853
+ this.#ownChainStore?.record(this.senderPubkeyHex, sessionIdHex, contentHash, Date.now());
2854
+ }
2855
+ close() {
2856
+ this.#closed = true;
2857
+ this.#settlePending({ ok: false, reason: "relay_client_closed" });
2858
+ // Settle any in-flight record so #doRecord resolves promptly.
2859
+ {
2860
+ const r = this.#pendingRecord;
2861
+ this.#pendingRecord = null;
2862
+ if (r)
2863
+ r("closed");
2864
+ }
2865
+ const stream = this.#stream;
2866
+ this.#stream = null;
2867
+ if (stream) {
2868
+ try {
2869
+ void stream.close();
2870
+ }
2871
+ catch { /* best-effort */ }
2872
+ }
2873
+ }
2874
+ }
2875
+ _a = AgentRelayClient;
2876
+ //# sourceMappingURL=session-relay-client.js.map