@cello-protocol/daemon 0.0.211 → 0.0.212

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 (180) hide show
  1. package/dist/agent-admin-handlers.d.ts.map +1 -1
  2. package/dist/agent-admin-handlers.js +3 -0
  3. package/dist/agent-admin-handlers.js.map +1 -1
  4. package/dist/agent-handlers.d.ts.map +1 -1
  5. package/dist/agent-handlers.js +44 -0
  6. package/dist/agent-handlers.js.map +1 -1
  7. package/dist/agent-id-migration.d.ts.map +1 -1
  8. package/dist/agent-id-migration.js +11 -2
  9. package/dist/agent-id-migration.js.map +1 -1
  10. package/dist/assignment-verify.d.ts.map +1 -1
  11. package/dist/assignment-verify.js +2 -2
  12. package/dist/assignment-verify.js.map +1 -1
  13. package/dist/attendance-wiring.d.ts +2 -21
  14. package/dist/attendance-wiring.d.ts.map +1 -1
  15. package/dist/attendance-wiring.js +57 -251
  16. package/dist/attendance-wiring.js.map +1 -1
  17. package/dist/away-detection.d.ts +16 -21
  18. package/dist/away-detection.d.ts.map +1 -1
  19. package/dist/away-detection.js +34 -23
  20. package/dist/away-detection.js.map +1 -1
  21. package/dist/boot-agents.d.ts.map +1 -1
  22. package/dist/close-session-handler.d.ts.map +1 -1
  23. package/dist/close-session-handler.js +47 -1
  24. package/dist/close-session-handler.js.map +1 -1
  25. package/dist/co-attendance.d.ts +1 -1
  26. package/dist/co-attendance.js +1 -1
  27. package/dist/consortium-fingerprint.d.ts +115 -0
  28. package/dist/consortium-fingerprint.d.ts.map +1 -0
  29. package/dist/consortium-fingerprint.js +175 -0
  30. package/dist/consortium-fingerprint.js.map +1 -0
  31. package/dist/content-park.d.ts.map +1 -1
  32. package/dist/content-park.js +80 -0
  33. package/dist/content-park.js.map +1 -1
  34. package/dist/daemon-handle.d.ts +1 -1
  35. package/dist/daemon-handle.d.ts.map +1 -1
  36. package/dist/daemon-status-report.d.ts +10 -2
  37. package/dist/daemon-status-report.d.ts.map +1 -1
  38. package/dist/daemon-status-report.js +6 -3
  39. package/dist/daemon-status-report.js.map +1 -1
  40. package/dist/daemon.d.ts.map +1 -1
  41. package/dist/daemon.js +41 -13
  42. package/dist/daemon.js.map +1 -1
  43. package/dist/directory-bootstrap.d.ts +3 -1
  44. package/dist/directory-bootstrap.d.ts.map +1 -1
  45. package/dist/directory-bootstrap.js +3 -1
  46. package/dist/directory-bootstrap.js.map +1 -1
  47. package/dist/directory-connect.d.ts.map +1 -1
  48. package/dist/directory-connect.js +10 -2
  49. package/dist/directory-connect.js.map +1 -1
  50. package/dist/disconnect-cleanup.d.ts +7 -0
  51. package/dist/disconnect-cleanup.d.ts.map +1 -1
  52. package/dist/disconnect-cleanup.js +16 -1
  53. package/dist/disconnect-cleanup.js.map +1 -1
  54. package/dist/document-notify.d.ts +0 -1
  55. package/dist/document-notify.d.ts.map +1 -1
  56. package/dist/document-notify.js +0 -1
  57. package/dist/document-notify.js.map +1 -1
  58. package/dist/document-store.d.ts +0 -1
  59. package/dist/document-store.d.ts.map +1 -1
  60. package/dist/document-store.js +0 -1
  61. package/dist/document-store.js.map +1 -1
  62. package/dist/document-write-path.d.ts.map +1 -1
  63. package/dist/document-write-path.js +2 -9
  64. package/dist/document-write-path.js.map +1 -1
  65. package/dist/inbound-sessions.d.ts +3 -1
  66. package/dist/inbound-sessions.d.ts.map +1 -1
  67. package/dist/inbound-sessions.js +12 -4
  68. package/dist/inbound-sessions.js.map +1 -1
  69. package/dist/index.d.ts +3 -0
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +7 -0
  72. package/dist/index.js.map +1 -1
  73. package/dist/initiate-session-handler.d.ts.map +1 -1
  74. package/dist/initiate-session-handler.js +5 -1
  75. package/dist/initiate-session-handler.js.map +1 -1
  76. package/dist/park-envelope.d.ts +68 -0
  77. package/dist/park-envelope.d.ts.map +1 -1
  78. package/dist/park-envelope.js +86 -1
  79. package/dist/park-envelope.js.map +1 -1
  80. package/dist/park-recovery.d.ts +46 -0
  81. package/dist/park-recovery.d.ts.map +1 -1
  82. package/dist/park-recovery.js +100 -1
  83. package/dist/park-recovery.js.map +1 -1
  84. package/dist/relay-only.d.ts.map +1 -1
  85. package/dist/relay-only.js +7 -4
  86. package/dist/relay-only.js.map +1 -1
  87. package/dist/relay-receipt-store.d.ts +48 -17
  88. package/dist/relay-receipt-store.d.ts.map +1 -1
  89. package/dist/relay-receipt-store.js +77 -27
  90. package/dist/relay-receipt-store.js.map +1 -1
  91. package/dist/seal-escalation.d.ts.map +1 -1
  92. package/dist/seal-escalation.js +25 -0
  93. package/dist/seal-escalation.js.map +1 -1
  94. package/dist/seal-settle.d.ts +38 -0
  95. package/dist/seal-settle.d.ts.map +1 -0
  96. package/dist/seal-settle.js +33 -0
  97. package/dist/seal-settle.js.map +1 -0
  98. package/dist/session-assignment-parser.d.ts.map +1 -1
  99. package/dist/session-assignment-parser.js +5 -0
  100. package/dist/session-assignment-parser.js.map +1 -1
  101. package/dist/session-ceremony.d.ts +21 -14
  102. package/dist/session-ceremony.d.ts.map +1 -1
  103. package/dist/session-ceremony.js +25 -15
  104. package/dist/session-ceremony.js.map +1 -1
  105. package/dist/session-content-context.d.ts +11 -2
  106. package/dist/session-content-context.d.ts.map +1 -1
  107. package/dist/session-content-ingest.d.ts +0 -3
  108. package/dist/session-content-ingest.d.ts.map +1 -1
  109. package/dist/session-content-ingest.js +18 -111
  110. package/dist/session-content-ingest.js.map +1 -1
  111. package/dist/session-content-send.d.ts +5 -0
  112. package/dist/session-content-send.d.ts.map +1 -1
  113. package/dist/session-content-send.js +58 -0
  114. package/dist/session-content-send.js.map +1 -1
  115. package/dist/session-delivery-acks.d.ts +195 -0
  116. package/dist/session-delivery-acks.d.ts.map +1 -0
  117. package/dist/session-delivery-acks.js +674 -0
  118. package/dist/session-delivery-acks.js.map +1 -0
  119. package/dist/session-leaf-records.d.ts +19 -1
  120. package/dist/session-leaf-records.d.ts.map +1 -1
  121. package/dist/session-leaf-records.js +67 -1
  122. package/dist/session-leaf-records.js.map +1 -1
  123. package/dist/session-lifecycle.js +3 -3
  124. package/dist/session-lifecycle.js.map +1 -1
  125. package/dist/session-node-manager.d.ts +36 -21
  126. package/dist/session-node-manager.d.ts.map +1 -1
  127. package/dist/session-node-manager.js +99 -29
  128. package/dist/session-node-manager.js.map +1 -1
  129. package/dist/session-node-types.d.ts +18 -12
  130. package/dist/session-node-types.d.ts.map +1 -1
  131. package/dist/session-node-types.js +1 -9
  132. package/dist/session-node-types.js.map +1 -1
  133. package/dist/session-read-handlers.d.ts.map +1 -1
  134. package/dist/session-read-handlers.js +125 -4
  135. package/dist/session-read-handlers.js.map +1 -1
  136. package/dist/session-records.d.ts +17 -0
  137. package/dist/session-records.d.ts.map +1 -1
  138. package/dist/session-records.js +32 -0
  139. package/dist/session-records.js.map +1 -1
  140. package/dist/session-relay-client.d.ts +103 -6
  141. package/dist/session-relay-client.d.ts.map +1 -1
  142. package/dist/session-relay-client.js +369 -27
  143. package/dist/session-relay-client.js.map +1 -1
  144. package/dist/session-relay.d.ts +24 -2
  145. package/dist/session-relay.d.ts.map +1 -1
  146. package/dist/session-relay.js +21 -6
  147. package/dist/session-relay.js.map +1 -1
  148. package/dist/session-schema.d.ts.map +1 -1
  149. package/dist/session-schema.js +43 -0
  150. package/dist/session-schema.js.map +1 -1
  151. package/dist/session-seal-leaf-store.d.ts +6 -0
  152. package/dist/session-seal-leaf-store.d.ts.map +1 -1
  153. package/dist/session-seal-leaf-store.js +21 -4
  154. package/dist/session-seal-leaf-store.js.map +1 -1
  155. package/dist/session-seal.d.ts +12 -0
  156. package/dist/session-seal.d.ts.map +1 -1
  157. package/dist/session-seal.js +92 -3
  158. package/dist/session-seal.js.map +1 -1
  159. package/dist/session-views.d.ts +1 -0
  160. package/dist/session-views.d.ts.map +1 -1
  161. package/dist/session-views.js +81 -1
  162. package/dist/session-views.js.map +1 -1
  163. package/dist/signaling-connect.d.ts +1 -0
  164. package/dist/signaling-connect.d.ts.map +1 -1
  165. package/dist/signaling-connect.js +0 -1
  166. package/dist/signaling-connect.js.map +1 -1
  167. package/dist/status-handler.d.ts +9 -1
  168. package/dist/status-handler.d.ts.map +1 -1
  169. package/dist/status-handler.js +5 -2
  170. package/dist/status-handler.js.map +1 -1
  171. package/dist/test-handlers.js +4 -3
  172. package/dist/test-handlers.js.map +1 -1
  173. package/dist/trust-signal-store.d.ts +4 -2
  174. package/dist/trust-signal-store.d.ts.map +1 -1
  175. package/dist/trust-signal-store.js +4 -2
  176. package/dist/trust-signal-store.js.map +1 -1
  177. package/dist/types.d.ts +64 -0
  178. package/dist/types.d.ts.map +1 -1
  179. package/dist/types.js.map +1 -1
  180. package/package.json +5 -5
@@ -0,0 +1,674 @@
1
+ /**
2
+ * DOD-M15-DELIVERYACK-1 — the delivery acknowledgement, both directions.
3
+ *
4
+ * Split out of `session-content-ingest.ts` rather than added to it. The acknowledgement is now a
5
+ * signed artifact with its own five rules and its own evidence store, which is a subject of its
6
+ * own — and the ingest file sits on a `max-lines` ratchet whose whole purpose is that a feature
7
+ * lands as a module instead of as growth in a file nobody can read.
8
+ *
9
+ * ⚠️ NOTHING HERE TOUCHES THE TAMPER-EVIDENT RECORD. An acknowledgement takes no chain leaf, is
10
+ * never itself acknowledged, and its absence proves nothing. The evidence it produces is read by
11
+ * the sealed-receipt surface and by nothing else.
12
+ */
13
+ import * as lp from "it-length-prefixed";
14
+ import { signDeliveryAck, verifyDeliveryAck } from "@cello-protocol/crypto";
15
+ import { encodeCbor } from "@cello-protocol/protocol-types";
16
+ import { encodeParkedDeliveryAck, parkedDeliveryAckMailboxHash } from "./park-envelope.js";
17
+ import { CELLO_CONTENT_PROTOCOL_ID } from "@cello-protocol/transport";
18
+ import { extractErrorMessage } from "./error-message.js";
19
+ /**
20
+ * DOD-M15-DELIVERYACK-1 rule 2 — did THIS side send this message in THIS session?
21
+ *
22
+ * ⚠️ THIS USED TO ASK "are we still awaiting an acknowledgement for it", AND THAT CLOSED THE
23
+ * EVIDENCE WINDOW ON EXACTLY THE DISPUTES THE UNIT EXISTS TO SETTLE. `awaitingAck` is in memory and
24
+ * its entries live for the time-to-fallback — 20 seconds by default — after which the timer fires,
25
+ * the content parks, and the entry is deleted. So: A sends, B is offline or slow, A parks the
26
+ * message, B comes back, recovers it, ingests it and signs. A threw the acknowledgement away as
27
+ * `not_awaiting` and kept nothing. **The message that was hardest to deliver is the one A ends up
28
+ * with no proof for**, which is the opposite of the point. A daemon restart in between did the same.
29
+ *
30
+ * So the bind is now DURABLE: the sent-direction transcript row and its leaf, which say this side
31
+ * put these bytes on the wire in this session and survive both the timer and a restart. The
32
+ * in-memory map stays as a first answer for the ordinary case where the acknowledgement comes back
33
+ * in milliseconds and the durable rows may not be visible yet.
34
+ *
35
+ * It is still a bound the counterparty cannot spend: both sources are written only by our OWN
36
+ * sends, so the number of acknowledgements a session can record is the number of messages this side
37
+ * sent in it. What changed is that the window is the conversation rather than twenty seconds.
38
+ */
39
+ function weSentThisMessage(ctx, agentName, sessionId, contentHash) {
40
+ const hashHex = Buffer.from(contentHash).toString("hex");
41
+ const bySession = ctx.awaitingAck.get(ctx.sessionKey(agentName, sessionId));
42
+ if (bySession?.has(hashHex) === true)
43
+ return true;
44
+ return ctx.records.hasSentContentHash(agentName, sessionId, hashHex);
45
+ }
46
+ /**
47
+ * Send a SIGNED `persisted` delivery ACK back to the sender over the same
48
+ * /cello/content/1.0.0 protocol (AC-001).
49
+ *
50
+ * ⚠️ IT USED TO BE UNSIGNED, and the comment here used to justify that: "authentication is the
51
+ * Noise session channel, so the ACK carries no signature". That is true of the HOP and it dies
52
+ * with the connection — it left the sender holding nothing it could show a third party, which is
53
+ * what `DOD-M15-DELIVERYACK-1` exists to fix. Rewritten rather than deleted, because the old
54
+ * sentence is exactly why an auditor would conclude the frame is unsigned by design.
55
+ *
56
+ * What is signed says only *this machine received these bytes in this session*. Signed on INGEST,
57
+ * so it asserts nothing about attention and nothing about agreement.
58
+ *
59
+ * Still best-effort in the SEND: a failed ACK send is logged and the sender recovers via its
60
+ * TTF/recovery path rather than a thrown error here.
61
+ */
62
+ export async function sendDeliveryAck(ctx, agentName, sessionId, contentHash, correlationId) {
63
+ const entry = ctx.activeNodes.get(ctx.sessionKey(agentName, sessionId));
64
+ /**
65
+ * SIGN BEFORE THE STREAM IS OPENED — an unsignable acknowledgement must not be sent at all.
66
+ *
67
+ * A frame with no signature is discarded at the far end on the same path as a wrong one, so
68
+ * sending one would burn a stream slot to produce a refusal. Declining is LOUD rather than
69
+ * silent, for the reason the `session_node_gone` branch above is: no acknowledgement is this
70
+ * unit's own symptom, so every case where we knowingly do not send one has to say so.
71
+ */
72
+ const signer = ctx.keyProvider(agentName);
73
+ if (!signer) {
74
+ ctx.logger.error("content.delivery.ack.unsignable", {
75
+ agentName,
76
+ sessionId,
77
+ contentHash: Buffer.from(contentHash).toString("hex"),
78
+ reason: "no_identity_key",
79
+ impact: "this machine holds no identity key for the agent, so it cannot sign for the message it " +
80
+ "just received. The message IS ingested and readable here; what is missing is the proof " +
81
+ "the sender keeps, so from their side this message will look unacknowledged",
82
+ guidance: "This is a LOCAL fault, not anything your counterparty did. Check that the agent's " +
83
+ "identity key is present on this machine (cello_status), then restart the agent.",
84
+ correlationId,
85
+ });
86
+ return;
87
+ }
88
+ let ackSig;
89
+ try {
90
+ ackSig = await signDeliveryAck(signer, Buffer.from(sessionId, "hex"), contentHash);
91
+ }
92
+ catch (err) {
93
+ ctx.logger.error("content.delivery.ack.unsignable", {
94
+ agentName, sessionId,
95
+ contentHash: Buffer.from(contentHash).toString("hex"),
96
+ reason: "signing_failed",
97
+ error: extractErrorMessage(err),
98
+ impact: "the message is ingested and readable here, but no acknowledgement could be signed for " +
99
+ "it, so the sender will see it as unacknowledged",
100
+ guidance: "This is a LOCAL fault. Check the agent's identity key on this machine (cello_status) " +
101
+ "and restart the agent; nothing your counterparty did causes this.",
102
+ correlationId,
103
+ });
104
+ return;
105
+ }
106
+ // Held outside the try so the catch can retire a stream that was opened and then failed to
107
+ // write. Without it every failure leaks the OUTBOUND half of the stream the receiver-side
108
+ // `finally` retires — same defect, other end, other cap. See the note on #handleContentStream.
109
+ // Assigned IMMEDIATELY after newStream: anything between the two is a window where a throw
110
+ // leaks the stream because the catch cannot see it.
111
+ let ackStream;
112
+ /**
113
+ * ⚠️ NO LIVE SESSION NODE IS THE OFFLINE CASE, NOT A REASON TO GIVE UP — and this branch used to
114
+ * be a bare early return logged as `session_node_gone`.
115
+ *
116
+ * Measured on the live journey: a message recovered from the relay mailbox is ingested when the
117
+ * SENDER is down, so there is no session node and no stream to write to. The acknowledgement was
118
+ * abandoned right here — which meant the messages that had to be parked, the ones a sender most
119
+ * needs evidence about, came back reading as never acknowledged. Straight to the mailbox instead.
120
+ */
121
+ if (!entry) {
122
+ await parkDeliveryAck(ctx, agentName, sessionId, contentHash, ackSig, correlationId, "no_live_session_node");
123
+ return;
124
+ }
125
+ try {
126
+ const stream = await entry.node.newStream(entry.counterpartySessionPeerId, CELLO_CONTENT_PROTOCOL_ID);
127
+ ackStream = stream;
128
+ // Injected ACK-write failure — thrown from inside the try so it lands in exactly the catch a
129
+ // real reset lands in, and the whole downstream path (impair → abort → log) runs unmodified.
130
+ if (ctx.ackFaultRemaining > 0) {
131
+ ctx.ackFaultRemaining -= 1;
132
+ ctx.logger.warn("content.delivery.ack.fault.injected", { sessionId });
133
+ throw new Error("connection_lost: injected delivery-ack fault");
134
+ }
135
+ const frame = encodeCbor({
136
+ type: "content_delivery_ack",
137
+ session_id: sessionId,
138
+ content_hash: contentHash,
139
+ level: "persisted",
140
+ // DOD-M15-DELIVERYACK-1: what the sender can still show someone after the connection is gone.
141
+ ack_sig: ackSig,
142
+ correlation_id: correlationId,
143
+ });
144
+ stream.send(lp.encode.single(frame));
145
+ // NOT SWALLOWED, for the same reason the direct-send path stopped swallowing it: `close()`
146
+ // waits for the write buffer to drain, so a reset mid-flush throws HERE and that is exactly
147
+ // the case where the bytes never left. A swallowed close made two things happen at once —
148
+ // this log claimed the ACK went out while the sender's TTF fired and parked, and the abort in
149
+ // the catch below (the thing that frees the stream slot) became unreachable.
150
+ await stream.close();
151
+ // AFTER the close, because that is when it is true. The receiver-side counterpart to the
152
+ // sender's content.delivery.acked: B has acknowledged this content `persisted`, so the sender
153
+ // stops retrying/parking. Emitted for BOTH a normally delivered message AND a terminal-screen
154
+ // block (the block is a definitive receipt — the leaf is recorded, so the sender must stop) —
155
+ // and deliberately NOT for a transient hold.
156
+ ctx.logger.info("content.delivery.ack.sent", {
157
+ sessionId,
158
+ contentHash: Buffer.from(contentHash).toString("hex"),
159
+ correlationId,
160
+ });
161
+ // An agent that mostly LISTENS sends content rarely and ACKs constantly. Clearing only on the
162
+ // content path would leave exactly those sessions reporting a broken conversation forever
163
+ // after one bad ACK — the one-way door, on the other send path.
164
+ ctx.liveness.clearSessionImpairment(agentName, sessionId, "delivery_ack", correlationId);
165
+ }
166
+ catch (err) {
167
+ ctx.logger.warn("content.delivery.ack.send.failed", {
168
+ sessionId,
169
+ contentHash: Buffer.from(contentHash).toString("hex"),
170
+ error: extractErrorMessage(err),
171
+ // "Cannot write to a stream that is closed" names where the write died, never why. The
172
+ // why is almost always the per-protocol stream cap, and these two numbers are what turn
173
+ // that from a log-measurement session into a grep.
174
+ ...ctx.streamCensus(entry.node, entry.counterpartySessionPeerId),
175
+ correlationId,
176
+ });
177
+ // The ACK travels the same direct path as our own content, so a failure here is the same
178
+ // evidence: writes to this counterparty are not landing.
179
+ ctx.liveness.markSessionImpaired(agentName, sessionId, { cause: "delivery_ack", error: extractErrorMessage(err), correlationId });
180
+ if (ackStream !== undefined) {
181
+ try {
182
+ ackStream.abort(err instanceof Error ? err : new Error(String(err)));
183
+ }
184
+ catch { /* already gone */ }
185
+ }
186
+ /**
187
+ * DOD-M15-DELIVERYACK-1 unit 1 — THE DIRECT STREAM FAILED, SO THE RELAY'S MAILBOX CARRIES IT.
188
+ *
189
+ * This is the case the unit exists for and the one it used to lose. The ordinary reason this
190
+ * catch runs is that the sender's daemon is DOWN — we have their message, we have read it, and
191
+ * there is nobody on the other end of the stream. Before this, the acknowledgement simply
192
+ * evaporated and the sender came back holding no proof for a message they had in fact delivered.
193
+ *
194
+ * It rides the same store-and-forward path their content rides, sealed to them, with the
195
+ * discriminator inside the seal so the relay stays a blind custodian and needs no change. If the
196
+ * relay refuses or none is configured, we are exactly where the old code left us — which is why
197
+ * this is fire-and-forget and never turns a delivered message into a failure.
198
+ */
199
+ void parkDeliveryAck(ctx, agentName, sessionId, contentHash, ackSig, correlationId, "direct_send_failed");
200
+ }
201
+ }
202
+ /**
203
+ * Deposit an acknowledgement in the relay's per-recipient mailbox, addressed to the sender.
204
+ *
205
+ * ⚠️ THE MAILBOX SLOT IS A DERIVED HASH, NOT THE MESSAGE'S. Filing it under the hash it
206
+ * acknowledges would put it in the slot a parked copy of that very message occupies. See
207
+ * `parkedDeliveryAckMailboxHash`.
208
+ *
209
+ * ⚠️ AND IT IS NEVER ACKNOWLEDGED IN TURN. `parkContent` is being used for its transport only: the
210
+ * deposit produces no awaiting entry, no timer and no leaf, so nothing here can start a second
211
+ * conversation between two daemons about whether the first one arrived.
212
+ */
213
+ async function parkDeliveryAck(ctx, agentName, sessionId, contentHash, ackSig, correlationId,
214
+ /** Why the direct stream was not used — carried into the log so the two cases stay legible. */
215
+ why) {
216
+ const hashHex = Buffer.from(contentHash).toString("hex");
217
+ try {
218
+ const payload = encodeParkedDeliveryAck({ sessionIdHex: sessionId, contentHash, ackSig });
219
+ const slotHex = Buffer.from(parkedDeliveryAckMailboxHash(sessionId, contentHash)).toString("hex");
220
+ const attempt = await ctx.park.parkOutOfBand(agentName, sessionId, slotHex, payload);
221
+ if (attempt.outcome === "parked") {
222
+ ctx.logger.info("content.delivery.ack.parked", {
223
+ agentName, sessionId, contentHash: hashHex, correlationId, why,
224
+ impact: "the sender could not be reached directly, so the acknowledgement is waiting in the " +
225
+ "relay mailbox and reaches them when their agent is next online",
226
+ });
227
+ return;
228
+ }
229
+ /**
230
+ * NOT SILENT, and not an error either. The sender is simply left without this one piece of
231
+ * evidence — which is where they were before this unit existed. Said out loud because "no
232
+ * acknowledgement" is this unit's own symptom, so every case where one knowingly does not
233
+ * arrive has to be visible or it is indistinguishable from the defect.
234
+ */
235
+ ctx.logger.warn("content.delivery.ack.park.failed", {
236
+ agentName, sessionId, contentHash: hashHex, correlationId, why,
237
+ reason: attempt.outcome === "unconfigured" ? "no_relay_for_session" : (attempt.cause ?? "refused"),
238
+ impact: "the acknowledgement reached neither the sender directly nor the relay mailbox, so they " +
239
+ "will hold no proof for this message. Their message WAS received and is readable here; " +
240
+ "nothing about the conversation is affected",
241
+ guidance: "Nothing to do on this side. If the counterparty asks, the message is in your transcript " +
242
+ "and you can confirm receipt out of band.",
243
+ });
244
+ }
245
+ catch (err) {
246
+ ctx.logger.warn("content.delivery.ack.park.failed", {
247
+ agentName, sessionId, contentHash: hashHex, correlationId,
248
+ reason: extractErrorMessage(err),
249
+ impact: "the acknowledgement reached neither the sender directly nor the relay mailbox, so they " +
250
+ "will hold no proof for this message. Their message WAS received and is readable here",
251
+ });
252
+ }
253
+ }
254
+ /**
255
+ * DOD-M15-DELIVERYACK-1 — AN INBOUND ACKNOWLEDGEMENT IS ATTACKER-CONTROLLED INPUT.
256
+ *
257
+ * The client is open source and runs on the counterparty's machine, so they can send whatever
258
+ * they like here. Five properties, and all five are load-bearing:
259
+ *
260
+ * 1. **Verified against the session's RECORDED counterparty key**, never a key in the frame. The
261
+ * recorded key comes from what the operator asked for (initiator) or the directory-attested
262
+ * offer (responder) — `entry.counterpartyPubkey`. Checking a signature against a key carried
263
+ * inside its own frame proves only that somebody owns a keypair.
264
+ * ⚠️ Deliberately NARROWER than "either participant". The clause says an ack verifying under
265
+ * neither participant key is discarded; ours additionally discards one signed by US, which is
266
+ * the self-referential case — our own signature on our own message, stored in our own
267
+ * database, constrains nobody.
268
+ * 2. **Bound** — it must name a hash this side actually sent in this session and is still
269
+ * awaiting. The session is already pinned by the shared frame gate above.
270
+ * 3. **Inert** — it records evidence and resolves the sender's own fallback timer. It advances no
271
+ * sequence, touches no tree, takes no leaf, closes nothing, seals nothing.
272
+ * 4. **Idempotent and bounded** — the awaiting entry is consumed on the first accepted ack, so a
273
+ * duplicate finds nothing and does nothing, and the number of acks that can ever be recorded
274
+ * for a session is the number of messages this side sent in it. There is no counter an
275
+ * attacker can inflate because there is no per-ack allocation at all.
276
+ * 5. **Malformed fails exactly as missing does.** Both take this one discard path: the timer stays
277
+ * armed, no `content.delivery.acked` fires, nothing is recorded, and the message parks on TTF
278
+ * expiry exactly as it would have with no acknowledgement at all.
279
+ *
280
+ * 🚨 AND A DISCARD IS NOT A SECURITY EVENT. It does not freeze the session and must never feed a
281
+ * trust signal. A missing acknowledgement is usually innocent — the relay parked the content, the
282
+ * far daemon died between ordering and pull, a bounded queue dropped its oldest frame, a screener
283
+ * refused it. Reading absence as evasion is the defect, not the fix.
284
+ */
285
+ export function onDeliveryAck(ctx, resolveAwaitingAck, agentName, sessionId, contentHash, rawSig, correlationId) {
286
+ const hashHex = Buffer.from(contentHash).toString("hex");
287
+ // RULE 2 — bound. Cheapest check, and it is what bounds everything below: an ack naming a hash
288
+ // we never sent (or already recorded) allocates nothing and is gone here.
289
+ if (!weSentThisMessage(ctx, agentName, sessionId, contentHash)) {
290
+ ctx.logger.debug("content.delivery.ack.discarded", {
291
+ agentName, sessionId, contentHash: hashHex, correlationId,
292
+ reason: "not_sent_here",
293
+ impact: "an acknowledgement arrived naming a message this side has no record of sending in this " +
294
+ "session. Discarded; nothing is concluded from it",
295
+ });
296
+ return;
297
+ }
298
+ const entry = ctx.activeNodes.get(ctx.sessionKey(agentName, sessionId));
299
+ // RULE 1 — the key we check against is the session's own record, and the frame supplies none of it.
300
+ const counterparty = entry?.counterpartyPubkey;
301
+ const verdict = verifyDeliveryAck({
302
+ participantIdentityPublics: counterparty && /^[0-9a-fA-F]{64}$/.test(counterparty)
303
+ ? [new Uint8Array(Buffer.from(counterparty, "hex"))]
304
+ : [],
305
+ sessionId: new Uint8Array(Buffer.from(sessionId, "hex")),
306
+ contentHash,
307
+ // RULE 5 — a non-Uint8Array arrives at the verifier as ABSENT, so a present-but-wrong-typed
308
+ // field takes the missing path rather than a tolerance branch of its own.
309
+ signature: rawSig instanceof Uint8Array ? rawSig : undefined,
310
+ });
311
+ if (!verdict.ok) {
312
+ /**
313
+ * WHO HEARS THIS, and why the answer is only the log — asked because a refusal with no named
314
+ * surface is this milestone's most repeated defect and the exception has to be argued.
315
+ *
316
+ * There is no caller to answer: this arrives on an inbound stream, unrequested. And the AGENT
317
+ * must not be told, because telling it is the harm. "Your counterparty sent a bad
318
+ * acknowledgement" reads as an accusation, and the one thing this unit must never do is let an
319
+ * absent or unusable acknowledgement be read as evasion. The behavioural consequence the agent
320
+ * DOES see is already correct and already surfaced: the message stays awaiting and parks on the
321
+ * usual fallback, exactly as if nothing had arrived — which is the truth. The log keeps the
322
+ * forensic record for the operator who goes looking.
323
+ *
324
+ * ONCE PER MESSAGE, LOUDLY; after that quietly — the flag rides on the awaiting entry, so it
325
+ * is freed with it and an entry exists only for a message THIS side sent. See
326
+ * `AwaitingAckEntry.ackRefusalLogged` for why the budget is not something the other side can
327
+ * spend.
328
+ */
329
+ const awaiting = ctx.awaitingAck.get(ctx.sessionKey(agentName, sessionId))?.get(hashHex);
330
+ const firstForThisMessage = awaiting !== undefined && awaiting.ackRefusalLogged !== true;
331
+ if (awaiting)
332
+ awaiting.ackRefusalLogged = true;
333
+ ctx.logger[firstForThisMessage ? "warn" : "debug"]("content.delivery.ack.discarded", {
334
+ agentName, sessionId, contentHash: hashHex, correlationId,
335
+ reason: verdict.reason,
336
+ detail: verdict.detail,
337
+ impact: "this message is treated exactly as if no acknowledgement had arrived: it stays awaiting " +
338
+ "and will park on the usual fallback. Nothing about the counterparty is concluded",
339
+ });
340
+ return;
341
+ }
342
+ /**
343
+ * RULE 3 AND RULE 4 — evidence, and nothing else, exactly once.
344
+ *
345
+ * Recorded BEFORE the timer is resolved, so a write failure cannot leave the sender believing it
346
+ * holds a proof it does not. The WRITE is what makes this idempotent now that the bind is durable:
347
+ * a replay finds the row already there, `INSERT OR IGNORE` keeps the first one, and the caller is
348
+ * told nothing was written — so no second `content.delivery.acked`, no second log line, and a
349
+ * counterparty replaying an acknowledgement ten thousand times moves nothing at all.
350
+ *
351
+ * `rawSig` is a Uint8Array whenever the verdict is ok — the verifier refuses `undefined`.
352
+ */
353
+ const wrote = ctx.records.recordDeliveryAck(agentName, sessionId, hashHex, Buffer.from(verdict.signerPublic).toString("hex"), rawSig, correlationId);
354
+ if (!wrote) {
355
+ ctx.logger.debug("content.delivery.ack.discarded", {
356
+ agentName, sessionId, contentHash: hashHex, correlationId,
357
+ reason: "already_recorded",
358
+ impact: "this acknowledgement is one this side already holds. Discarded as a duplicate; the first " +
359
+ "one recorded stands and nothing changed",
360
+ });
361
+ return;
362
+ }
363
+ resolveAwaitingAck(agentName, sessionId, contentHash);
364
+ }
365
+ /**
366
+ * DOD-M15-DELIVERYACK-1 — keep the counterparty's signature that their machine received a message.
367
+ *
368
+ * Called ONLY after `verifyDeliveryAck` succeeded against the session's recorded counterparty key.
369
+ * `INSERT OR IGNORE` is the idempotence: the first acknowledgement recorded for a (session, hash)
370
+ * stands and a replay changes nothing.
371
+ *
372
+ * A write failure is LOUD and it is not fatal. The message was delivered; what is lost is this
373
+ * side's ability to prove it later, and the operator is the only one who can act on that.
374
+ */
375
+ export function storeDeliveryAck(db, logger, a) {
376
+ const { agentId, agentName, sessionId, contentHashHex, signerPubkeyHex, signature, correlationId } = a;
377
+ try {
378
+ const info = db
379
+ .prepare(`INSERT OR IGNORE INTO delivery_acks
380
+ (agent_id, session_id, content_hash_hex, signer_pubkey, signature, recorded_at)
381
+ VALUES (?, ?, ?, ?, ?, ?)`)
382
+ .run(agentId, sessionId, contentHashHex, signerPubkeyHex, Buffer.from(signature), Date.now());
383
+ const wrote = Number(info.changes) > 0;
384
+ if (wrote) {
385
+ logger.info("content.delivery.ack.recorded", {
386
+ agentName, sessionId, contentHash: contentHashHex, signerPubkey: signerPubkeyHex, correlationId,
387
+ });
388
+ }
389
+ return wrote;
390
+ }
391
+ catch (err) {
392
+ logger.error("content.delivery.ack.record.failed", {
393
+ agentName, sessionId, contentHash: contentHashHex, correlationId,
394
+ reason: extractErrorMessage(err),
395
+ impact: "the counterparty's signed acknowledgement for this message could not be stored, so the " +
396
+ "sealed receipt will report this message as not acknowledged even though it was. The " +
397
+ "message itself was delivered and nothing about the conversation is affected",
398
+ guidance: "This is a LOCAL storage fault. If it repeats, check free disk space and that the " +
399
+ "agent's database is writable; the acknowledgement cannot be re-requested afterwards.",
400
+ });
401
+ return false;
402
+ }
403
+ }
404
+ /**
405
+ * DOD-M15-DELIVERYACK-1 rule 2, at the database — did this side put these exact bytes on the wire in
406
+ * this session?
407
+ *
408
+ * The durable bind, reachable from both routes: the live inbound handler asks through
409
+ * `SessionRecords`, and the mailbox drain asks here directly because it holds a database handle and
410
+ * no manager. A sent-direction transcript row joined to its leaf is written by our own send path and
411
+ * survives the fallback timer and a restart, which is what lets an acknowledgement for a message
412
+ * that had to be parked and recovered still count. Nothing a counterparty sends creates a row here.
413
+ */
414
+ export function sentContentHashExists(db, agentId, sessionId, contentHashHex) {
415
+ const row = db
416
+ .prepare(`SELECT 1 AS present
417
+ FROM session_tree_leaves l
418
+ JOIN transcript t
419
+ ON t.agent_id = l.agent_id AND t.session_id = l.session_id AND t.sequence = l.leaf_index
420
+ WHERE l.agent_id = ? AND l.session_id = ? AND l.leaf_hash_hex = ? AND t.direction = 'sent'
421
+ LIMIT 1`)
422
+ .get(agentId, sessionId, contentHashHex);
423
+ return row !== undefined;
424
+ }
425
+ /**
426
+ * DOD-M15-DELIVERYACK-1 unit 1 — ACCEPT AN ACKNOWLEDGEMENT THAT CAME BACK THROUGH THE RELAY'S
427
+ * MAILBOX.
428
+ *
429
+ * This is the offline route: the counterparty read the message while this daemon was down, could not
430
+ * reach it directly, and parked the acknowledgement. It is held to the SAME five rules the live
431
+ * route is, because it is the same attacker-controlled input arriving by a different road — and to
432
+ * one more check the live route does not need, which has already run before this is called: the park
433
+ * envelope's own SEC-1 gate proved the DEPOSITOR is this session's counterparty.
434
+ *
435
+ * ⚠️ THE TWO CHECKS ARE INDEPENDENT AND BOTH ARE REQUIRED. The envelope gate says who put it in the
436
+ * mailbox; the signature below says who acknowledged the message. A relay holding the recipient's
437
+ * public key can deposit anything, and the envelope gate is what stops that — but a counterparty who
438
+ * rewrote their own daemon passes the envelope gate by construction, and only the signature check
439
+ * catches an acknowledgement they did not actually sign for this message.
440
+ *
441
+ * 🚨 INERT, exactly as the live route is. It records evidence. It appends no leaf, advances no
442
+ * sequence, touches no tree, and nothing about a refusal here says anything about the counterparty.
443
+ *
444
+ * ─── RULE 4's "rate-limited", on THIS route, and why there is no memo here ─────────────────────
445
+ *
446
+ * A refused entry is deliberately NOT confirm-deleted — a forgery must never be able to evict
447
+ * itself — so a bad acknowledgement is re-pulled on every drain, forever. The content path answers
448
+ * that with a bounded in-memory memo of what it already refused. This route does not need one, and
449
+ * the reason is three enforced bounds rather than an argument:
450
+ *
451
+ * 1. Only the COUNTERPARTY can put an entry here at all. The park envelope's SEC-1 gate runs
452
+ * first and refuses anyone else, the relay included.
453
+ * 2. The relay caps each recipient's mailbox — `#maxRecipientEntries` / `#maxRecipientBytes` in
454
+ * `file-content-store.ts`, with FIFO eviction inside the quota. So the number of entries that
455
+ * can be waiting is bounded by the operator's own mailbox size, not by the counterparty's
456
+ * patience.
457
+ * 3. The slot check above runs BEFORE any cryptography, so the cheap forgery — an entry filed
458
+ * anywhere but the derived slot — costs one SHA-256 and no signature verification.
459
+ *
460
+ * What is left is one Ed25519 verify per correctly-slotted, wrongly-signed entry per drain,
461
+ * multiplied by a mailbox the relay already bounds. That is a cost, and it is a bounded one; a memo
462
+ * would shave it at the price of a second piece of state whose key a counterparty influences.
463
+ */
464
+ export function acceptParkedDeliveryAck(a) {
465
+ const hashHex = Buffer.from(a.ack.contentHash).toString("hex");
466
+ const discard = (reason, impact) => {
467
+ a.logger.warn("content.delivery.ack.parked.discarded", {
468
+ agentName: a.agentName, sessionId: a.sessionId, contentHash: hashHex,
469
+ correlationId: a.correlationId, reason, impact,
470
+ });
471
+ return { ok: false, reason };
472
+ };
473
+ /**
474
+ 🚨 THE SLOT MUST BE THE DERIVED ONE, AND WITHOUT THIS CHECK THE DERIVATION PROTECTED NOBODY.
475
+
476
+ `parkedDeliveryAckMailboxHash` puts an acknowledgement in a slot no message's content hash can
477
+ occupy — for an HONEST depositor. A counterparty running a modified client picks the slot
478
+ freely: the park envelope's signature covers whatever slot they chose, so it authenticates. They
479
+ file an acknowledgement AT THE CONTENT HASH of a message they were supposed to deliver, the
480
+ relay files by (recipient, content_hash) and dedups, and the real content deposit is absorbed.
481
+ The message never arrives, nothing is refused, and every log line is clean — because the
482
+ acknowledgement itself is perfectly valid.
483
+
484
+ So the slot is checked against the derivation before anything else. It costs no cryptography,
485
+ which also makes it the right first gate for an entry that is re-pulled on every drain.
486
+ */
487
+ const expectedSlot = Buffer.from(parkedDeliveryAckMailboxHash(a.sessionId, a.ack.contentHash)).toString("hex");
488
+ if (a.mailboxSlotHex.toLowerCase() !== expectedSlot) {
489
+ return discard("slot_not_derived", "a parked acknowledgement was filed in a mailbox slot that is not the one its own contents " +
490
+ "derive. An acknowledgement sitting in a message's slot would absorb that message's delivery " +
491
+ "through the relay's own dedup, so it is discarded and the slot is left alone");
492
+ }
493
+ /**
494
+ * The session id INSIDE the seal must be the one the relay filed it under. The relay supplies the
495
+ * outer value and is in the threat model; the inner one is under the depositor's signature and
496
+ * the relay can neither read nor change it. Disagreement means one of them is lying, and there is
497
+ * no version of that where continuing is right.
498
+ */
499
+ if (a.ack.sessionIdHex.toLowerCase() !== a.sessionId.toLowerCase()) {
500
+ return discard("session_mismatch", "a parked acknowledgement names a different session inside its seal than the one the relay " +
501
+ "filed it under. Discarded rather than routed on either claim");
502
+ }
503
+ // RULE 2 — bound. Durable only: by the time a parked acknowledgement is drained, any live timer
504
+ // for that message is long gone, which is the whole reason this route exists.
505
+ if (!sentContentHashExists(a.db, a.agentId, a.sessionId, hashHex)) {
506
+ return discard("not_sent_here", "a parked acknowledgement named a message this side has no record of sending in this " +
507
+ "session. Discarded; nothing is concluded from it");
508
+ }
509
+ // RULE 1 — against the session's recorded key, never the one inside the payload.
510
+ const verdict = verifyDeliveryAck({
511
+ participantIdentityPublics: a.counterpartyPubkeyHex && /^[0-9a-fA-F]{64}$/.test(a.counterpartyPubkeyHex)
512
+ ? [new Uint8Array(Buffer.from(a.counterpartyPubkeyHex, "hex"))]
513
+ : [],
514
+ sessionId: new Uint8Array(Buffer.from(a.sessionId, "hex")),
515
+ contentHash: a.ack.contentHash,
516
+ signature: a.ack.ackSig,
517
+ });
518
+ if (!verdict.ok) {
519
+ return discard(verdict.reason, "a parked acknowledgement did not verify against this session's recorded counterparty key. " +
520
+ "It is treated exactly as if none had arrived, and nothing about the counterparty is concluded");
521
+ }
522
+ // RULE 3 and RULE 4 — evidence only, exactly once. The stored row is the idempotence: this route
523
+ // and the direct route write the same key, so an acknowledgement that arrived both ways lands once.
524
+ const wrote = storeDeliveryAck(a.db, a.logger, {
525
+ agentId: a.agentId,
526
+ agentName: a.agentName,
527
+ sessionId: a.sessionId,
528
+ contentHashHex: hashHex,
529
+ signerPubkeyHex: Buffer.from(verdict.signerPublic).toString("hex"),
530
+ signature: a.ack.ackSig,
531
+ correlationId: a.correlationId,
532
+ });
533
+ if (wrote) {
534
+ a.logger.info("content.delivery.ack.parked.recovered", {
535
+ agentName: a.agentName, sessionId: a.sessionId, contentHash: hashHex,
536
+ correlationId: a.correlationId,
537
+ impact: "an acknowledgement that could not be delivered while this daemon was down has been " +
538
+ "recovered from the relay mailbox; the proof for this message is now held locally",
539
+ });
540
+ }
541
+ return { ok: true };
542
+ }
543
+ /**
544
+ * DOD-M15-DELIVERYACK-1 — the three facts, per message this side SENT, each standing alone.
545
+ *
546
+ * ⚠️ READ THE ASYMMETRY BEFORE USING THIS. Each fact is present or absent on its own and a
547
+ * missing one is reported as missing and NOTHING MORE. There is deliberately no combined status,
548
+ * no derived verdict, and no count — a message can be delivered, read and acted on with no
549
+ * acknowledgement recorded here, because the acknowledgement can be lost exactly as the message
550
+ * can. Anything that renders absence as fault is a defect, not a display choice.
551
+ *
552
+ * ordered — the RELAY assigned it a position. Read from `relay_ack_receipts`, which holds
553
+ * the relay's own countersignature, scoped to THIS agent's pubkey so a loopback
554
+ * session's two ends never read each other's.
555
+ * delivered — the RELAY handed the bytes over. **This side holds no such evidence for any
556
+ * message today, on either path**: the relay answers the RECIPIENT on pickup and
557
+ * never tells the depositor. It is reported absent with that reason named rather
558
+ * than fabricated from "our own send succeeded", which would be this machine
559
+ * asserting a relay fact about itself. `069-ORDERPROOF` plus a pickup notice to
560
+ * the depositor is what fills it.
561
+ * acknowledged — the RECIPIENT's own signature, from `delivery_acks`.
562
+ */
563
+ export function readDeliveryFacts(db, logger, agentId, sessionId) {
564
+ /**
565
+ * ⚠️ AN EMPTY PUBKEY HERE WOULD BLANK EVERY `ordered` FACT IN THE RECEIPT, and silently.
566
+ *
567
+ * The relay receipts are scoped by this agent's own key, so `?? ""` would match no rows and every
568
+ * message would report `ordered: null` — indistinguishable from a relay that never countersigned
569
+ * anything. The caller has already resolved the agent id, so the row must exist; an empty value
570
+ * is a LOCAL fault and it says so rather than answering with a plausible emptiness.
571
+ */
572
+ const agentPubkey = db.prepare("SELECT k_local_pubkey FROM agents WHERE agent_id = ?").get(agentId)?.k_local_pubkey ?? "";
573
+ if (!agentPubkey) {
574
+ logger.error("session.delivery.facts.agent_pubkey_missing", {
575
+ agentId,
576
+ sessionId,
577
+ impact: "this receipt will report every message as NOT ordered by the relay, although the relay " +
578
+ "may well have ordered them — the relay's records are keyed by this agent's own public " +
579
+ "key and this machine could not find it. Read the absence of `ordered` here as unknown, " +
580
+ "not as a fact about the relay",
581
+ guidance: "This is a LOCAL fault: the agent row exists but carries no identity key. Check " +
582
+ "cello_status for this agent and restart it; nothing about the conversation is affected.",
583
+ });
584
+ }
585
+ /**
586
+ * ⚠️ THE ROW KEY IS THE CONTENT HASH, NOT THE SEQUENCE, AND IT HAD TO BE.
587
+ *
588
+ * The first version drove this list from `transcript WHERE direction = 'sent'` and joined out
589
+ * to the leaf for its hash. That reports nothing at all for a message whose leaf never landed —
590
+ * which is exactly the message a reader most needs to see, because a send that never got a
591
+ * position is one of the blameless ways a message goes missing. Keying on the hash means every
592
+ * message this side has ANY evidence about appears, and each of the three facts is then
593
+ * independently present or absent on it. `seq` joins in when this side holds a leaf and is null
594
+ * when it does not; a null there is the ordinary shape, not a fault.
595
+ */
596
+ const seqByHash = new Map();
597
+ for (const l of db
598
+ .prepare(`SELECT l.leaf_index AS seq, l.leaf_hash_hex AS hash_hex
599
+ FROM session_tree_leaves l
600
+ JOIN transcript t
601
+ ON t.agent_id = l.agent_id AND t.session_id = l.session_id AND t.sequence = l.leaf_index
602
+ WHERE l.agent_id = ? AND l.session_id = ? AND t.direction = 'sent'`)
603
+ .all(agentId, sessionId)) {
604
+ seqByHash.set(l.hash_hex, l.seq);
605
+ }
606
+ const receipts = new Map();
607
+ /**
608
+ * `relay_ack_receipts` is created by `RelayReceiptStore`'s constructor, not by the session
609
+ * schema, so on a daemon that has never opened a relay-witnessed session the table is simply
610
+ * absent. That is not a degraded read and nothing is substituted for it: no table means no
611
+ * relay ever countersigned a position here, which is the same fact as an empty table. The
612
+ * existence check is explicit rather than a swallowed `catch`, so a genuine SQL fault still
613
+ * throws instead of arriving as "nothing was ordered".
614
+ */
615
+ const relayTablePresent = db
616
+ .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'relay_ack_receipts'")
617
+ .get() !== undefined;
618
+ if (relayTablePresent) {
619
+ for (const r of db
620
+ .prepare(`SELECT hash_hex, relay_id, relay_timestamp, signature_hex
621
+ FROM relay_ack_receipts WHERE agent_pubkey = ? AND session_id = ?`)
622
+ .all(agentPubkey, sessionId)) {
623
+ receipts.set(r.hash_hex, r);
624
+ }
625
+ }
626
+ const acks = new Map();
627
+ for (const a of db
628
+ .prepare(`SELECT content_hash_hex, signer_pubkey, signature, recorded_at
629
+ FROM delivery_acks WHERE agent_id = ? AND session_id = ?`)
630
+ .all(agentId, sessionId)) {
631
+ acks.set(a.content_hash_hex, a);
632
+ }
633
+ const hashes = [...new Set([...seqByHash.keys(), ...receipts.keys(), ...acks.keys()])];
634
+ hashes.sort((x, y) => (seqByHash.get(x) ?? Number.MAX_SAFE_INTEGER) - (seqByHash.get(y) ?? Number.MAX_SAFE_INTEGER));
635
+ return hashes.map((hash) => {
636
+ const receipt = receipts.get(hash);
637
+ const ack = acks.get(hash);
638
+ return {
639
+ seq: seqByHash.get(hash) ?? null,
640
+ content_hash: hash,
641
+ ordered: receipt
642
+ ? {
643
+ asserted_by: "relay",
644
+ relay_id: receipt.relay_id,
645
+ relay_timestamp: receipt.relay_timestamp,
646
+ signature: receipt.signature_hex,
647
+ }
648
+ : null,
649
+ /**
650
+ * PRESENT IN SHAPE, ABSENT IN FACT — and it names who would assert it, exactly as the other
651
+ * two do. A reader mapping over this array can then tell "nobody holds this" from "the relay
652
+ * said no", which a bare `null` cannot. It is not inference: `held: false` is a statement
653
+ * about what THIS side has, never about the counterparty.
654
+ */
655
+ delivered: {
656
+ asserted_by: "relay",
657
+ held: false,
658
+ why_absent: "a relay tells the RECIPIENT when it hands content over and never tells the sender, so " +
659
+ "this side holds no relay-asserted delivery evidence for any message. It is left absent " +
660
+ "rather than filled in from this machine's own send having succeeded, which would be " +
661
+ "this machine asserting a relay's fact about itself",
662
+ },
663
+ acknowledged: ack
664
+ ? {
665
+ asserted_by: "recipient",
666
+ signer_pubkey: ack.signer_pubkey,
667
+ signature: Buffer.from(ack.signature).toString("hex"),
668
+ recorded_at: ack.recorded_at,
669
+ }
670
+ : null,
671
+ };
672
+ });
673
+ }
674
+ //# sourceMappingURL=session-delivery-acks.js.map