@cello-protocol/daemon 0.0.132 → 0.0.133

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 (108) hide show
  1. package/dist/content-park.d.ts.map +1 -1
  2. package/dist/content-park.js +29 -11
  3. package/dist/content-park.js.map +1 -1
  4. package/dist/daemon.d.ts +11 -0
  5. package/dist/daemon.d.ts.map +1 -1
  6. package/dist/daemon.js +394 -17
  7. package/dist/daemon.js.map +1 -1
  8. package/dist/document-ack-inbound.d.ts +57 -0
  9. package/dist/document-ack-inbound.d.ts.map +1 -0
  10. package/dist/document-ack-inbound.js +174 -0
  11. package/dist/document-ack-inbound.js.map +1 -0
  12. package/dist/document-control-notifier.d.ts +61 -0
  13. package/dist/document-control-notifier.d.ts.map +1 -0
  14. package/dist/document-control-notifier.js +70 -0
  15. package/dist/document-control-notifier.js.map +1 -0
  16. package/dist/document-delivery-transport.d.ts +94 -0
  17. package/dist/document-delivery-transport.d.ts.map +1 -0
  18. package/dist/document-delivery-transport.js +179 -0
  19. package/dist/document-delivery-transport.js.map +1 -0
  20. package/dist/document-delivery.d.ts +181 -0
  21. package/dist/document-delivery.d.ts.map +1 -0
  22. package/dist/document-delivery.js +289 -0
  23. package/dist/document-delivery.js.map +1 -0
  24. package/dist/document-frame-router.d.ts +210 -0
  25. package/dist/document-frame-router.d.ts.map +1 -0
  26. package/dist/document-frame-router.js +396 -0
  27. package/dist/document-frame-router.js.map +1 -0
  28. package/dist/document-handlers.d.ts +47 -0
  29. package/dist/document-handlers.d.ts.map +1 -0
  30. package/dist/document-handlers.js +657 -0
  31. package/dist/document-handlers.js.map +1 -0
  32. package/dist/document-handshake.d.ts +156 -0
  33. package/dist/document-handshake.d.ts.map +1 -0
  34. package/dist/document-handshake.js +398 -0
  35. package/dist/document-handshake.js.map +1 -0
  36. package/dist/document-inbound.d.ts +91 -0
  37. package/dist/document-inbound.d.ts.map +1 -0
  38. package/dist/document-inbound.js +290 -0
  39. package/dist/document-inbound.js.map +1 -0
  40. package/dist/document-layer.d.ts +137 -0
  41. package/dist/document-layer.d.ts.map +1 -0
  42. package/dist/document-layer.js +255 -0
  43. package/dist/document-layer.js.map +1 -0
  44. package/dist/document-lifecycle.d.ts +125 -0
  45. package/dist/document-lifecycle.d.ts.map +1 -0
  46. package/dist/document-lifecycle.js +433 -0
  47. package/dist/document-lifecycle.js.map +1 -0
  48. package/dist/document-live-docs.d.ts +58 -0
  49. package/dist/document-live-docs.d.ts.map +1 -0
  50. package/dist/document-live-docs.js +126 -0
  51. package/dist/document-live-docs.js.map +1 -0
  52. package/dist/document-notify.d.ts +173 -0
  53. package/dist/document-notify.d.ts.map +1 -0
  54. package/dist/document-notify.js +438 -0
  55. package/dist/document-notify.js.map +1 -0
  56. package/dist/document-publish.d.ts +67 -0
  57. package/dist/document-publish.d.ts.map +1 -0
  58. package/dist/document-publish.js +149 -0
  59. package/dist/document-publish.js.map +1 -0
  60. package/dist/document-reachability.d.ts +42 -0
  61. package/dist/document-reachability.d.ts.map +1 -0
  62. package/dist/document-reachability.js +80 -0
  63. package/dist/document-reachability.js.map +1 -0
  64. package/dist/document-rejection.d.ts +240 -0
  65. package/dist/document-rejection.d.ts.map +1 -0
  66. package/dist/document-rejection.js +407 -0
  67. package/dist/document-rejection.js.map +1 -0
  68. package/dist/document-store.d.ts +154 -8
  69. package/dist/document-store.d.ts.map +1 -1
  70. package/dist/document-store.js +462 -4
  71. package/dist/document-store.js.map +1 -1
  72. package/dist/document-write-path.d.ts.map +1 -1
  73. package/dist/document-write-path.js +10 -43
  74. package/dist/document-write-path.js.map +1 -1
  75. package/dist/inbound-sessions.d.ts.map +1 -1
  76. package/dist/inbound-sessions.js +4 -0
  77. package/dist/inbound-sessions.js.map +1 -1
  78. package/dist/initiate-session-handler.d.ts +24 -1
  79. package/dist/initiate-session-handler.d.ts.map +1 -1
  80. package/dist/initiate-session-handler.js +35 -9
  81. package/dist/initiate-session-handler.js.map +1 -1
  82. package/dist/ipc-server.d.ts +11 -1
  83. package/dist/ipc-server.d.ts.map +1 -1
  84. package/dist/ipc-server.js +7 -1
  85. package/dist/ipc-server.js.map +1 -1
  86. package/dist/line-lcs.d.ts +51 -0
  87. package/dist/line-lcs.d.ts.map +1 -0
  88. package/dist/line-lcs.js +71 -0
  89. package/dist/line-lcs.js.map +1 -0
  90. package/dist/outbound-sessions.d.ts +2 -0
  91. package/dist/outbound-sessions.d.ts.map +1 -1
  92. package/dist/outbound-sessions.js +11 -1
  93. package/dist/outbound-sessions.js.map +1 -1
  94. package/dist/session-content-handlers.d.ts.map +1 -1
  95. package/dist/session-content-handlers.js +3 -2
  96. package/dist/session-content-handlers.js.map +1 -1
  97. package/dist/session-node-manager.d.ts +15 -0
  98. package/dist/session-node-manager.d.ts.map +1 -1
  99. package/dist/session-node-manager.js +188 -9
  100. package/dist/session-node-manager.js.map +1 -1
  101. package/dist/vocabulary.d.ts.map +1 -1
  102. package/dist/vocabulary.js +16 -0
  103. package/dist/vocabulary.js.map +1 -1
  104. package/dist/wire-content-hash.d.ts +27 -0
  105. package/dist/wire-content-hash.d.ts.map +1 -0
  106. package/dist/wire-content-hash.js +37 -0
  107. package/dist/wire-content-hash.js.map +1 -0
  108. package/package.json +5 -5
@@ -0,0 +1,433 @@
1
+ /**
2
+ * DOD-DOC-LIFECYCLE-1 — the verbs (§3.5 + §16.4).
3
+ *
4
+ * Three ways a document can end, and they are not interchangeable. Conflating any two of them
5
+ * makes a promise the protocol cannot keep:
6
+ *
7
+ * close BILATERAL. Both sides ack and the document is complete by agreement. One side's
8
+ * close is a REQUEST — treating it as a conclusion would tell this operator the
9
+ * collaboration ended while the peer is still writing into it.
10
+ * kill UNILATERAL. Stop accepting and publishing, notify the peer, and KEEP the local copy
11
+ * and the log. The peer keeps what it holds, and that is said out loud: it is the one
12
+ * thing an operator is most likely to assume a kill undoes, and the one thing it
13
+ * cannot.
14
+ * withdraw ONE UNDELIVERED update. A local rollback plus a withdrawal record BESIDE the
15
+ * original — marked, never deleted, because a hole in an append-only log is
16
+ * indistinguishable from tampering. Once the peer has it, withdrawal is refused rather
17
+ * than faked.
18
+ *
19
+ * ── THE KILL SWITCH (§16.7-11) ────────────────────────────────────────────────────────────────
20
+ *
21
+ * A platform-paused agent refuses OUTBOUND publishes loudly, still admits INBOUND mechanically,
22
+ * and suppresses notifications. The asymmetry is deliberate. Refusing inbound would surface the
23
+ * pause to the peer as a protocol fault and force a rejection round for something that is not
24
+ * their doing; refusing outbound silently would leave the operator writing into a document that is
25
+ * going nowhere, with their work piling up locally and no sign anything is wrong.
26
+ */
27
+ const CREATE_LIFECYCLE_SQL = `
28
+ CREATE TABLE IF NOT EXISTS document_closes (
29
+ owner_agent_id TEXT NOT NULL,
30
+ document_id TEXT NOT NULL,
31
+ -- WHO closed, not how many closes there were. Counting would let one party close a document
32
+ -- unilaterally by asking twice, which is precisely the bilateral guarantee gone.
33
+ closed_by TEXT NOT NULL,
34
+ created_at INTEGER NOT NULL,
35
+ PRIMARY KEY (owner_agent_id, document_id, closed_by)
36
+ );
37
+
38
+ -- WITHDRAWALS live here, NOT in document_envelopes, and that placement is the fix for three
39
+ -- separate defects rather than a filing preference.
40
+ --
41
+ -- 1. CHAIN. A withdrawal is local-only by design: it is never delivered (the update it concerns
42
+ -- was never delivered, so the peer has nothing to act on). As an envelope it still advanced
43
+ -- our per-sender chain, so our NEXT update chained onto a node the peer will never hold and
44
+ -- the peer refused it with document_chain_broken — sending an operator to the chain layer for
45
+ -- a withdrawal-scoping bug, and leaving the document unopenable after their next restart.
46
+ -- Chaining it to the last DELIVERABLE envelope instead would fork our chain, since the next
47
+ -- update claims the same predecessor. It cannot be a node in that chain at all.
48
+ -- 2. CRYPTO. As an envelope it needed a signature and a state vector, and it had neither of its
49
+ -- own — the first version copied the ORIGINAL's, putting a real Ed25519 signature made over a
50
+ -- different record onto a permanent append-only row. document-rejection.ts states the rule
51
+ -- this violated in writing: required, never fabricated.
52
+ -- 3. REPLAY. An envelope row is something rebuildSnapshot must reason about; an audit row is not.
53
+ --
54
+ -- Same shape as document_quarantine, for the same reason: audit that must survive, must not be
55
+ -- replayed, and must not be chained.
56
+ CREATE TABLE IF NOT EXISTS document_withdrawals (
57
+ owner_agent_id TEXT NOT NULL,
58
+ document_id TEXT NOT NULL,
59
+ envelope_hash TEXT NOT NULL,
60
+ created_at INTEGER NOT NULL,
61
+ PRIMARY KEY (owner_agent_id, document_id, envelope_hash)
62
+ );
63
+
64
+ CREATE TABLE IF NOT EXISTS agent_platform_pause (
65
+ agent_id TEXT NOT NULL PRIMARY KEY,
66
+ paused INTEGER NOT NULL,
67
+ updated_at INTEGER NOT NULL
68
+ );
69
+ `;
70
+ export class DocumentLifecycle {
71
+ #store;
72
+ #logger;
73
+ #notifier;
74
+ /**
75
+ * Our own wire sender id for an agent. M14-D5 makes it the pubkey hex, which is NOT the local
76
+ * owner key this store is otherwise keyed by — and passing the wrong one here reports every
77
+ * pending update as delivered.
78
+ */
79
+ #senderAgentId;
80
+ #rollback;
81
+ constructor(store, logger, notifier,
82
+ /**
83
+ * Undo one envelope's operations on the LIVE document, as inverses. Injected because the live
84
+ * `Y.Doc` and its UndoManager belong to the engine, not here — and REQUIRED, because a default
85
+ * that quietly did nothing would restore exactly the defect this argument exists to fix.
86
+ */
87
+ rollback, senderAgentId = (id) => id) {
88
+ this.#store = store;
89
+ this.#logger = logger;
90
+ this.#notifier = notifier;
91
+ this.#rollback = rollback;
92
+ this.#senderAgentId = senderAgentId;
93
+ this.#store.rawDb.exec(CREATE_LIFECYCLE_SQL);
94
+ }
95
+ list(ownerAgentId, nowMs) {
96
+ // Every pending envelope regardless of schedule: the operator is asking "what has not reached
97
+ // my peer", not "what is due for a retry in the next few seconds".
98
+ const pending = this.#store.pendingDeliveries(ownerAgentId, Number.MAX_SAFE_INTEGER, this.#senderAgentId(ownerAgentId));
99
+ const pendingByDocument = new Map();
100
+ for (const e of pending) {
101
+ const c = pendingByDocument.get(e.documentId) ?? { total: 0, sent: 0 };
102
+ c.total += 1;
103
+ // SPLIT, because "sent, awaiting confirmation" and "never left this machine" are different
104
+ // things to tell an operator asking why their work has not landed — and the column that
105
+ // records the difference had a writer but no reader, which is how it got deleted once.
106
+ if (e.deliveredAtMs != null)
107
+ c.sent += 1;
108
+ pendingByDocument.set(e.documentId, c);
109
+ }
110
+ void nowMs;
111
+ return this.#store.listDocuments(ownerAgentId).map((d) => ({
112
+ documentId: d.documentId,
113
+ peerAgentId: d.peerAgentId,
114
+ documentType: d.documentType,
115
+ assuranceTier: "authenticated",
116
+ epochId: 0,
117
+ status: d.status,
118
+ pendingDeliveries: pendingByDocument.get(d.documentId)?.total ?? 0,
119
+ pendingSent: pendingByDocument.get(d.documentId)?.sent ?? 0,
120
+ pendingUnsent: (pendingByDocument.get(d.documentId)?.total ?? 0) -
121
+ (pendingByDocument.get(d.documentId)?.sent ?? 0),
122
+ closePending: this.#hasClosed(ownerAgentId, d.documentId, ownerAgentId) &&
123
+ !this.#hasClosed(ownerAgentId, d.documentId, d.peerAgentId),
124
+ }));
125
+ }
126
+ /** Our half of a bilateral close. Completes only when the peer's half is also on record. */
127
+ async close(ownerAgentId, documentId, nowMs) {
128
+ const doc = this.#store.getDocument(ownerAgentId, documentId);
129
+ if (!doc) {
130
+ return { ok: false, reason: "document_unknown", detail: `no document ${documentId.slice(0, 16)}…` };
131
+ }
132
+ this.#recordClose(ownerAgentId, documentId, ownerAgentId, nowMs);
133
+ const notified = await this.#notifier.notifyPeer(documentId, "close");
134
+ if (!notified.ok) {
135
+ // RAISED to error, and RETURNED, matching `kill`. This was a warn whose outcome went nowhere:
136
+ // the caller answered `{ok: true, status: "active"}`, which is indistinguishable from "sent
137
+ // fine, they have not answered yet". Control frames are fire-once — not in the log, never
138
+ // swept — so a close the peer never received means the document can never settle, and neither
139
+ // operator has anything to look at.
140
+ this.#logger.error("document.close.peer_not_notified", { documentId, reason: notified.reason });
141
+ }
142
+ this.#settleClose(ownerAgentId, documentId, doc.peerAgentId);
143
+ this.#logger.info("document.close.requested", { documentId, peerNotified: notified.ok });
144
+ return { ok: true, peerNotified: notified.ok };
145
+ }
146
+ /** The peer's half, arriving over the session. */
147
+ recordPeerClose(ownerAgentId, documentId, peerAgentId, nowMs) {
148
+ const doc = this.#store.getDocument(ownerAgentId, documentId);
149
+ if (!doc) {
150
+ // Without this the row is written for a document that does not exist — there is no foreign
151
+ // key on this table — and `setDocumentStatus` updates zero rows and returns silently.
152
+ return {
153
+ ok: false,
154
+ reason: "document_unknown",
155
+ detail: `no document ${documentId.slice(0, 16)}… for this agent`,
156
+ };
157
+ }
158
+ if (peerAgentId !== doc.peerAgentId) {
159
+ // THE BILATERAL GUARANTEE. The closer id came from the caller and was written as `closed_by`
160
+ // and settled against ITSELF, so any second distinct string — a stale contact id, a
161
+ // pubkey-vs-name mismatch, a hostile session — plus our own close flipped the document to
162
+ // closed. The whole point of recording WHO closed is defeated if who is never checked.
163
+ this.#logger.warn("document.close.not_peer", {
164
+ documentId,
165
+ claimedBy: peerAgentId,
166
+ peerAgentId: doc.peerAgentId,
167
+ });
168
+ return {
169
+ ok: false,
170
+ reason: "document_close_not_peer",
171
+ detail: `${peerAgentId} is not this document's peer (${doc.peerAgentId}), so their close is not ` +
172
+ `the other half of a bilateral close`,
173
+ };
174
+ }
175
+ this.#recordClose(ownerAgentId, documentId, doc.peerAgentId, nowMs);
176
+ this.#settleClose(ownerAgentId, documentId, doc.peerAgentId);
177
+ this.#logger.info("document.close.peer_requested", { documentId, peerAgentId: doc.peerAgentId });
178
+ return { ok: true };
179
+ }
180
+ /**
181
+ * The peer KILLED the document. Their half of the unilateral end.
182
+ *
183
+ * Terminal immediately, and there is no reciprocal step: a kill is one-sided by definition, which
184
+ * is what separates it from a close. Continuing to publish afterwards would send updates to a
185
+ * party who has stopped listening — refused at their end forever, with nothing on this screen
186
+ * explaining why.
187
+ *
188
+ * The SENDER IS CHECKED against the document's peer for the same reason `recordPeerClose` checks
189
+ * it: without that, any string plus a valid-looking frame ends someone else's document.
190
+ */
191
+ recordPeerKill(ownerAgentId, documentId, peerAgentId, nowMs) {
192
+ const doc = this.#store.getDocument(ownerAgentId, documentId);
193
+ if (!doc) {
194
+ return {
195
+ ok: false,
196
+ reason: "document_unknown",
197
+ detail: `no document ${documentId.slice(0, 16)}… for this agent`,
198
+ };
199
+ }
200
+ if (peerAgentId !== doc.peerAgentId) {
201
+ this.#logger.warn("document.kill.not_peer", {
202
+ documentId,
203
+ claimedBy: peerAgentId,
204
+ peerAgentId: doc.peerAgentId,
205
+ });
206
+ return {
207
+ ok: false,
208
+ reason: "document_kill_not_peer",
209
+ detail: `${peerAgentId} is not this document's peer (${doc.peerAgentId}), so their kill is not theirs to make`,
210
+ };
211
+ }
212
+ void nowMs;
213
+ if (doc.status !== "active" && doc.status !== "stalled") {
214
+ // ONLY FROM ACTIVE, the same guard `#settleClose` carries and for the mirror reason. The
215
+ // transport WILL redeliver, and nothing bounds a control frame's freshness — `sent_at_ms` is
216
+ // signed and never checked. Unconditional, a kill arriving after a bilateral close rewrote a
217
+ // settled agreement as a unilateral end.
218
+ this.#logger.info("document.kill.peer_requested.ignored", {
219
+ documentId,
220
+ status: doc.status,
221
+ });
222
+ return { ok: true };
223
+ }
224
+ this.#store.setDocumentStatus(ownerAgentId, documentId, "killed");
225
+ this.#logger.info("document.kill.peer_requested", { documentId, peerAgentId: doc.peerAgentId });
226
+ return { ok: true };
227
+ }
228
+ /**
229
+ * Unilateral end. Local, and deliberately not contingent on the peer hearing about it — a
230
+ * decision to stop that depends on the other party being online is not a decision to stop.
231
+ */
232
+ async kill(ownerAgentId, documentId, nowMs) {
233
+ const doc = this.#store.getDocument(ownerAgentId, documentId);
234
+ if (!doc) {
235
+ return { ok: false, reason: "document_unknown", detail: `no document ${documentId.slice(0, 16)}…` };
236
+ }
237
+ void nowMs;
238
+ this.#store.setDocumentStatus(ownerAgentId, documentId, "killed");
239
+ const notified = await this.#notifier.notifyPeer(documentId, "kill");
240
+ if (!notified.ok) {
241
+ // Reported, not swallowed: a kill the peer never heard about leaves them publishing into a
242
+ // document that will never answer, and the operator is the one who needs to know that.
243
+ this.#logger.error("document.kill.peer_not_notified", {
244
+ documentId,
245
+ peerAgentId: doc.peerAgentId,
246
+ reason: notified.reason,
247
+ });
248
+ }
249
+ this.#logger.info("document.killed", { documentId, peerNotified: notified.ok });
250
+ return {
251
+ ok: true,
252
+ peerNotified: notified.ok,
253
+ note: "this document no longer accepts or publishes updates, and your local copy and log are " +
254
+ "retained. Your peer keeps what it holds — a kill stops the collaboration, it does not " +
255
+ "retract content they already have.",
256
+ };
257
+ }
258
+ /**
259
+ * Withdraw ONE undelivered update: a withdrawal record beside the original.
260
+ *
261
+ * Every refusal here is the same shape — say no rather than produce a record that claims
262
+ * something untrue about the log.
263
+ */
264
+ withdraw(ownerAgentId, documentId, envelopeHash, nowMs) {
265
+ const log = this.#store.getEnvelopeLog(ownerAgentId, documentId);
266
+ const original = log.find((e) => e.envelopeHash === envelopeHash);
267
+ if (!original) {
268
+ // A withdrawal record pointing at nothing is worse than a refusal: it is a permanent claim
269
+ // in an append-only store about an envelope that never existed.
270
+ return {
271
+ ok: false,
272
+ reason: "document_envelope_unknown",
273
+ detail: `no envelope ${envelopeHash.slice(0, 16)}… in this document's log`,
274
+ };
275
+ }
276
+ if (original.senderAgentId !== ownerAgentId) {
277
+ return {
278
+ ok: false,
279
+ reason: "document_not_author",
280
+ detail: `envelope ${envelopeHash.slice(0, 16)}… was authored by ${original.senderAgentId}, not by you`,
281
+ };
282
+ }
283
+ if (original.ackedAtMs != null) {
284
+ // The honest refusal. Withdrawing a delivered update would tell the operator their content
285
+ // was retracted while the peer is holding it — the promise this module refuses to make.
286
+ return {
287
+ ok: false,
288
+ reason: "document_already_delivered",
289
+ detail: `envelope ${envelopeHash.slice(0, 16)}… has already been delivered and acknowledged — ` +
290
+ `your peer holds it, so it cannot be withdrawn. Publish a superseding update instead.`,
291
+ };
292
+ }
293
+ // THE LOCAL ROLLBACK — the half that was missing. Writing only the record left the original in
294
+ // the log WITH its payload, and replay applies every update payload in order, so the withdrawn
295
+ // text stayed in the operator's own document and came back on every rebuild. The operator was
296
+ // told their update was withdrawn while their file still contained it.
297
+ //
298
+ // Rolled back as INVERSES through the same undo path a rejection uses, never by dropping the
299
+ // payload: our own later work may be causally stacked on these operations, and REJECT-1
300
+ // measured what removing them costs — everything after stays pending forever and the document
301
+ // silently loses the legitimate work. The inverse enters the log on the next ordinary publish,
302
+ // computed from the live document, which is also why neither the original nor the inverse is
303
+ // ever delivered: the peer holds neither, and the next publish carries the net effect.
304
+ const rolledBack = this.#rollback(ownerAgentId, documentId, envelopeHash);
305
+ if (!rolledBack.ok) {
306
+ return {
307
+ ok: false,
308
+ reason: "document_withdraw_rollback_failed",
309
+ detail: `the local rollback did not happen (${rolledBack.reason}), so nothing was withdrawn — ` +
310
+ `reporting success here would tell you your update was retracted while your file still ` +
311
+ `contains it`,
312
+ };
313
+ }
314
+ const info = this.#store.rawDb
315
+ .prepare(`INSERT INTO document_withdrawals (owner_agent_id, document_id, envelope_hash, created_at)
316
+ VALUES (?, ?, ?, ?)
317
+ ON CONFLICT (owner_agent_id, document_id, envelope_hash) DO NOTHING`)
318
+ .run(ownerAgentId, documentId, envelopeHash, nowMs);
319
+ if (Number(info.changes) === 0) {
320
+ // Already withdrawn. Reported rather than inferred — the earlier version ignored the write's
321
+ // outcome and returned success for a no-op.
322
+ return {
323
+ ok: false,
324
+ reason: "document_already_withdrawn",
325
+ detail: `envelope ${envelopeHash.slice(0, 16)}… was already withdrawn`,
326
+ };
327
+ }
328
+ this.#logger.info("document.withdrawn", { documentId, envelopeHash });
329
+ return { ok: true };
330
+ }
331
+ // ─── the kill switch (§16.7-11) ───────────────────────────────────────────
332
+ setPlatformPaused(agentId, paused, nowMs) {
333
+ this.#store.rawDb
334
+ .prepare(`INSERT INTO agent_platform_pause (agent_id, paused, updated_at) VALUES (?, ?, ?)
335
+ ON CONFLICT (agent_id) DO UPDATE SET paused = excluded.paused, updated_at = excluded.updated_at`)
336
+ // Hard-wired to 0 before, so every row read 1970 — on the KILL SWITCH, where "when was this
337
+ // agent paused by the platform" is the audit fact of the whole feature. The tell was that it
338
+ // was the only method in the class taking no clock.
339
+ .run(agentId, paused ? 1 : 0, nowMs);
340
+ this.#logger.warn("agent.platform_pause.changed", { agentId, paused });
341
+ }
342
+ isPlatformPaused(agentId) {
343
+ const r = this.#store.rawDb
344
+ .prepare("SELECT paused FROM agent_platform_pause WHERE agent_id = ?")
345
+ .get(agentId);
346
+ return (r?.paused ?? 0) === 1;
347
+ }
348
+ /** Outbound. Refused loudly while paused, and while the document has ended. */
349
+ canPublish(ownerAgentId, documentId) {
350
+ if (this.isPlatformPaused(ownerAgentId)) {
351
+ return {
352
+ ok: false,
353
+ reason: "agent_platform_paused",
354
+ detail: `this agent is paused by the platform, so nothing can be published. Your work is kept ` +
355
+ `locally and will publish once the agent is unpaused.`,
356
+ };
357
+ }
358
+ const doc = this.#store.getDocument(ownerAgentId, documentId);
359
+ if (!doc) {
360
+ return { ok: false, reason: "document_unknown", detail: `no document ${documentId.slice(0, 16)}…` };
361
+ }
362
+ if (doc.status === "closed") {
363
+ return { ok: false, reason: "document_closed", detail: "this document was closed by agreement" };
364
+ }
365
+ if (doc.status === "killed") {
366
+ return { ok: false, reason: "document_killed", detail: "this document was ended locally" };
367
+ }
368
+ if (doc.status === "stalled") {
369
+ // REJECT-1 stalls a document after its retry rounds. A stalled document has stopped
370
+ // converging, so publishing into it is the same lie as publishing into a killed one — and
371
+ // two partial gates that each know half the terminal states is how a caller ends up wrong
372
+ // about the other half.
373
+ return {
374
+ ok: false,
375
+ reason: "document_stalled",
376
+ detail: "this document stopped accepting updates after repeated rejections",
377
+ };
378
+ }
379
+ return { ok: true };
380
+ }
381
+ /** Inbound. A pause does NOT refuse it — see the header. */
382
+ canAdmit(ownerAgentId, documentId) {
383
+ const doc = this.#store.getDocument(ownerAgentId, documentId);
384
+ if (!doc) {
385
+ return { ok: false, reason: "document_unknown", detail: `no document ${documentId.slice(0, 16)}…` };
386
+ }
387
+ if (doc.status === "killed") {
388
+ return { ok: false, reason: "document_killed", detail: "this document was ended locally" };
389
+ }
390
+ if (doc.status === "closed") {
391
+ return { ok: false, reason: "document_closed", detail: "this document was closed by agreement" };
392
+ }
393
+ if (doc.status === "stalled") {
394
+ return {
395
+ ok: false,
396
+ reason: "document_stalled",
397
+ detail: "this document stopped accepting updates after repeated rejections",
398
+ };
399
+ }
400
+ return { ok: true };
401
+ }
402
+ shouldNotify(agentId) {
403
+ return !this.isPlatformPaused(agentId);
404
+ }
405
+ // ─── internals ────────────────────────────────────────────────────────────
406
+ #recordClose(ownerAgentId, documentId, closedBy, nowMs) {
407
+ this.#store.rawDb
408
+ .prepare(`INSERT INTO document_closes (owner_agent_id, document_id, closed_by, created_at)
409
+ VALUES (?, ?, ?, ?)
410
+ ON CONFLICT (owner_agent_id, document_id, closed_by) DO NOTHING`)
411
+ .run(ownerAgentId, documentId, closedBy, nowMs);
412
+ }
413
+ #hasClosed(ownerAgentId, documentId, who) {
414
+ const r = this.#store.rawDb
415
+ .prepare(`SELECT 1 AS present FROM document_closes
416
+ WHERE owner_agent_id = ? AND document_id = ? AND closed_by = ?`)
417
+ .get(ownerAgentId, documentId, who);
418
+ return r?.present === 1;
419
+ }
420
+ #settleClose(ownerAgentId, documentId, peerAgentId) {
421
+ // ONLY FROM ACTIVE. Unconditional, a peer close arriving after a unilateral kill overwrote
422
+ // `killed` with `closed` — the operator's own decision replaced by "closed by agreement", which
423
+ // is a different fact and the one they did not choose. Same for `stalled`.
424
+ if (this.#store.getDocument(ownerAgentId, documentId)?.status !== "active")
425
+ return;
426
+ if (this.#hasClosed(ownerAgentId, documentId, ownerAgentId) &&
427
+ this.#hasClosed(ownerAgentId, documentId, peerAgentId)) {
428
+ this.#store.setDocumentStatus(ownerAgentId, documentId, "closed");
429
+ this.#logger.info("document.closed", { documentId });
430
+ }
431
+ }
432
+ }
433
+ //# sourceMappingURL=document-lifecycle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"document-lifecycle.js","sourceRoot":"","sources":["../src/document-lifecycle.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * DOD-DOC-INBOUND-2 — the live `Y.Doc` cache.
3
+ *
4
+ * The inbound path needs a document to apply an update to, and it must be THE document — the same
5
+ * object every subsequent update sees. Materializing a fresh one per frame would make each update
6
+ * land on an empty doc and converge with nothing.
7
+ *
8
+ * ── WHY THIS IS A UNIT AND NOT A `Map` IN THE COMPOSITION ROOT ────────────────────────────────
9
+ *
10
+ * Three properties, and each is a defect if it goes the other way:
11
+ *
12
+ * RESTORED FROM THE LOG, not created empty. A daemon restart must not lose the document — the
13
+ * log is what makes it survivable, and a cache that starts blank silently discards everything
14
+ * the operator and their peer have written. `rebuildSnapshot` verifies the chain before replaying,
15
+ * so a document that cannot be rebuilt REFUSES rather than coming back partial.
16
+ *
17
+ * BOUNDED. A daemon attends many agents over a long life and a `Y.Doc` holds the whole document
18
+ * plus its history. An unbounded map is a leak whose symptom is the daemon dying days later with
19
+ * no obvious cause — the shape `session-node-manager.ts` already had to fix once for received
20
+ * content. Eviction is safe here in a way it usually is not: the log is the truth, so an evicted
21
+ * document is rebuilt on next use rather than lost.
22
+ *
23
+ * EVICTION IS NEVER SILENT DATA LOSS, which is only true because the two above hold together. If
24
+ * eviction existed without log-backed restore it would be exactly the silent loss it looks like.
25
+ */
26
+ import * as Y from "yjs";
27
+ import type { DocumentStore } from "./document-store.js";
28
+ import type { DocumentEngine } from "./document-engine.js";
29
+ import type { Logger } from "./types.js";
30
+ /**
31
+ * How many documents stay resident. Small on purpose: an operator collaborates on a handful at a
32
+ * time, and the cost of a miss is a rebuild from the log, not a failure.
33
+ */
34
+ export declare const LIVE_DOC_CACHE_SIZE = 32;
35
+ export declare class LiveDocuments {
36
+ #private;
37
+ constructor(store: DocumentStore, engine: DocumentEngine, logger: Logger, startingContentFor?: (ownerAgentId: string, documentId: string) => Uint8Array | null);
38
+ /**
39
+ * The live document, restored from the log on a miss.
40
+ *
41
+ * THROWS rather than returning an empty document when the log cannot be rebuilt. An empty doc
42
+ * here would be applied to, published from, and would converge the peer's real content away — a
43
+ * silent whole-document loss originating in a cache miss.
44
+ *
45
+ * PRECISION, measured 2026-08-05: that covers a log that EXISTS and does not verify. An id this
46
+ * daemon has never heard of has no log to fail on, so it comes back as a legitimately empty
47
+ * document. Harmless only because every caller checks `documents` first — the operator surface
48
+ * refuses with `document_unknown`, and the inbound path refuses before materializing anything.
49
+ * A future caller that skips that check gets an empty document and no signal, so the check is
50
+ * part of the contract rather than an incidental habit of the current callers.
51
+ */
52
+ get(ownerAgentId: string, documentId: string): Y.Doc;
53
+ /** Drop one document — after a kill or close, where keeping it resident buys nothing. */
54
+ release(ownerAgentId: string, documentId: string): void;
55
+ /** Resident count, for tests and for an operator-facing surface that wants to show it. */
56
+ size(): number;
57
+ }
58
+ //# sourceMappingURL=document-live-docs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"document-live-docs.d.ts","sourceRoot":"","sources":["../src/document-live-docs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC;AACzB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAEzC;;;GAGG;AACH,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC,qBAAa,aAAa;;gBAWtB,KAAK,EAAE,aAAa,EACpB,MAAM,EAAE,cAAc,EACtB,MAAM,EAAE,MAAM,EACd,kBAAkB,GAAE,CAAC,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,UAAU,GAAG,IAAiB;IAQlG;;;;;;;;;;;;;OAaG;IACH,GAAG,CAAC,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,CAAC,CAAC,GAAG;IA0CpD,yFAAyF;IACzF,OAAO,CAAC,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI;IAQvD,0FAA0F;IAC1F,IAAI,IAAI,MAAM;CAiBf"}
@@ -0,0 +1,126 @@
1
+ /**
2
+ * DOD-DOC-INBOUND-2 — the live `Y.Doc` cache.
3
+ *
4
+ * The inbound path needs a document to apply an update to, and it must be THE document — the same
5
+ * object every subsequent update sees. Materializing a fresh one per frame would make each update
6
+ * land on an empty doc and converge with nothing.
7
+ *
8
+ * ── WHY THIS IS A UNIT AND NOT A `Map` IN THE COMPOSITION ROOT ────────────────────────────────
9
+ *
10
+ * Three properties, and each is a defect if it goes the other way:
11
+ *
12
+ * RESTORED FROM THE LOG, not created empty. A daemon restart must not lose the document — the
13
+ * log is what makes it survivable, and a cache that starts blank silently discards everything
14
+ * the operator and their peer have written. `rebuildSnapshot` verifies the chain before replaying,
15
+ * so a document that cannot be rebuilt REFUSES rather than coming back partial.
16
+ *
17
+ * BOUNDED. A daemon attends many agents over a long life and a `Y.Doc` holds the whole document
18
+ * plus its history. An unbounded map is a leak whose symptom is the daemon dying days later with
19
+ * no obvious cause — the shape `session-node-manager.ts` already had to fix once for received
20
+ * content. Eviction is safe here in a way it usually is not: the log is the truth, so an evicted
21
+ * document is rebuilt on next use rather than lost.
22
+ *
23
+ * EVICTION IS NEVER SILENT DATA LOSS, which is only true because the two above hold together. If
24
+ * eviction existed without log-backed restore it would be exactly the silent loss it looks like.
25
+ */
26
+ /**
27
+ * How many documents stay resident. Small on purpose: an operator collaborates on a handful at a
28
+ * time, and the cost of a miss is a rebuild from the log, not a failure.
29
+ */
30
+ export const LIVE_DOC_CACHE_SIZE = 32;
31
+ export class LiveDocuments {
32
+ #store;
33
+ #engine;
34
+ #logger;
35
+ /** Insertion-ordered, so the oldest key is the eviction candidate. */
36
+ #live = new Map();
37
+ /** Epoch zero for a document, from its stored proposal. See `get`. */
38
+ #startingContentFor;
39
+ constructor(store, engine, logger, startingContentFor = () => null) {
40
+ this.#store = store;
41
+ this.#engine = engine;
42
+ this.#logger = logger;
43
+ this.#startingContentFor = startingContentFor;
44
+ }
45
+ /**
46
+ * The live document, restored from the log on a miss.
47
+ *
48
+ * THROWS rather than returning an empty document when the log cannot be rebuilt. An empty doc
49
+ * here would be applied to, published from, and would converge the peer's real content away — a
50
+ * silent whole-document loss originating in a cache miss.
51
+ *
52
+ * PRECISION, measured 2026-08-05: that covers a log that EXISTS and does not verify. An id this
53
+ * daemon has never heard of has no log to fail on, so it comes back as a legitimately empty
54
+ * document. Harmless only because every caller checks `documents` first — the operator surface
55
+ * refuses with `document_unknown`, and the inbound path refuses before materializing anything.
56
+ * A future caller that skips that check gets an empty document and no signal, so the check is
57
+ * part of the contract rather than an incidental habit of the current callers.
58
+ */
59
+ get(ownerAgentId, documentId) {
60
+ // `\0` as the ESCAPE, not a literal NUL byte. Written raw, git classifies the whole file as
61
+ // binary — `git diff` reports `Bin 4508 -> 5084` and shows nothing — so every change to this
62
+ // file is invisible to code review. Caught by a reviewer who had to strip the bytes to read it.
63
+ const key = `${ownerAgentId}\0${documentId}`;
64
+ const hit = this.#live.get(key);
65
+ if (hit) {
66
+ // Refresh recency: delete and re-insert so the Map's insertion order is a true LRU rather
67
+ // than a first-in-first-out queue that evicts the document being actively edited.
68
+ this.#live.delete(key);
69
+ this.#live.set(key, hit);
70
+ return hit;
71
+ }
72
+ const snapshot = this.#store.rebuildSnapshot(ownerAgentId, documentId, (rows) => this.#engine.replay(rows));
73
+ const doc = this.#engine.restore(snapshot.binary);
74
+ // EPOCH ZERO FIRST, and it is not in the envelope log.
75
+ //
76
+ // A document's starting content is agreed in the PROPOSAL — both sides apply the same bytes, so
77
+ // neither has to author it and there is no first envelope carrying it. That is correct on the
78
+ // wire and it left the content living only in the `Y.Doc` the propose/accept handler happened to
79
+ // be holding: restart the daemon and the document came back EMPTY, on both sides, with the row
80
+ // still present and the log still valid. An operator would open a document they had been
81
+ // working in and find nothing there — and then write into it, publishing the deletion of
82
+ // everything the peer still had.
83
+ //
84
+ // Re-applied here rather than logged at accept time because the proposal is already stored and
85
+ // `document_id` is the hash of it: taking epoch zero from the proposal is deterministic on both
86
+ // sides forever, while an envelope written at accept would be one side's authored operation and
87
+ // the two would not match.
88
+ //
89
+ // Applying to a rebuilt doc is safe and idempotent — a Yjs update already present is a no-op,
90
+ // which is the property the whole replay depends on.
91
+ const starting = this.#startingContentFor(ownerAgentId, documentId);
92
+ if (starting)
93
+ this.#engine.applyUpdate(doc, starting);
94
+ this.#live.set(key, doc);
95
+ this.#evictIfNeeded();
96
+ return doc;
97
+ }
98
+ /** Drop one document — after a kill or close, where keeping it resident buys nothing. */
99
+ release(ownerAgentId, documentId) {
100
+ const key = `${ownerAgentId}\0${documentId}`;
101
+ const doc = this.#live.get(key);
102
+ if (!doc)
103
+ return;
104
+ this.#live.delete(key);
105
+ doc.destroy();
106
+ }
107
+ /** Resident count, for tests and for an operator-facing surface that wants to show it. */
108
+ size() {
109
+ return this.#live.size;
110
+ }
111
+ #evictIfNeeded() {
112
+ while (this.#live.size > LIVE_DOC_CACHE_SIZE) {
113
+ const oldest = this.#live.keys().next();
114
+ if (oldest.done === true)
115
+ return;
116
+ const doc = this.#live.get(oldest.value);
117
+ this.#live.delete(oldest.value);
118
+ // DESTROYED, not just dropped. A Y.Doc holds observers; releasing the reference without
119
+ // destroying it leaves them attached and the memory reachable, which is the leak this cache
120
+ // exists to prevent wearing a different shape.
121
+ doc?.destroy();
122
+ this.#logger.debug("document.live.evicted", { resident: this.#live.size });
123
+ }
124
+ }
125
+ }
126
+ //# sourceMappingURL=document-live-docs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"document-live-docs.js","sourceRoot":"","sources":["../src/document-live-docs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAOH;;;GAGG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,EAAE,CAAC;AAEtC,MAAM,OAAO,aAAa;IACf,MAAM,CAAgB;IACtB,OAAO,CAAiB;IACxB,OAAO,CAAS;IACzB,sEAAsE;IAC7D,KAAK,GAAG,IAAI,GAAG,EAAiB,CAAC;IAE1C,sEAAsE;IAC7D,mBAAmB,CAAkE;IAE9F,YACE,KAAoB,EACpB,MAAsB,EACtB,MAAc,EACd,qBAAsF,GAAG,EAAE,CAAC,IAAI;QAEhG,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC;QACpB,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC;QACtB,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC;QACtB,IAAI,CAAC,mBAAmB,GAAG,kBAAkB,CAAC;IAChD,CAAC;IAED;;;;;;;;;;;;;OAaG;IACH,GAAG,CAAC,YAAoB,EAAE,UAAkB;QAC1C,4FAA4F;QAC5F,6FAA6F;QAC7F,gGAAgG;QAChG,MAAM,GAAG,GAAG,GAAG,YAAY,KAAK,UAAU,EAAE,CAAC;QAC7C,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAChC,IAAI,GAAG,EAAE,CAAC;YACR,0FAA0F;YAC1F,kFAAkF;YAClF,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YACvB,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;YACzB,OAAO,GAAG,CAAC;QACb,CAAC;QAED,MAAM,QAAQ,GAAG,IAAI,CAAC,MAAM,CAAC,eAAe,CAAC,YAAY,EAAE,UAAU,EAAE,CAAC,IAAI,EAAE,EAAE,CAC9E,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAC1B,CAAC;QACF,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QAClD,uDAAuD;QACvD,EAAE;QACF,gGAAgG;QAChG,8FAA8F;QAC9F,iGAAiG;QACjG,+FAA+F;QAC/F,yFAAyF;QACzF,yFAAyF;QACzF,iCAAiC;QACjC,EAAE;QACF,+FAA+F;QAC/F,gGAAgG;QAChG,gGAAgG;QAChG,2BAA2B;QAC3B,EAAE;QACF,8FAA8F;QAC9F,qDAAqD;QACrD,MAAM,QAAQ,GAAG,IAAI,CAAC,mBAAmB,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;QACpE,IAAI,QAAQ;YAAE,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;QACtD,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QACzB,IAAI,CAAC,cAAc,EAAE,CAAC;QACtB,OAAO,GAAG,CAAC;IACb,CAAC;IAED,yFAAyF;IACzF,OAAO,CAAC,YAAoB,EAAE,UAAkB;QAC9C,MAAM,GAAG,GAAG,GAAG,YAAY,KAAK,UAAU,EAAE,CAAC;QAC7C,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAChC,IAAI,CAAC,GAAG;YAAE,OAAO;QACjB,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QACvB,GAAG,CAAC,OAAO,EAAE,CAAC;IAChB,CAAC;IAED,0FAA0F;IAC1F,IAAI;QACF,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC;IACzB,CAAC;IAED,cAAc;QACZ,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,mBAAmB,EAAE,CAAC;YAC7C,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC;YACxC,IAAI,MAAM,CAAC,IAAI,KAAK,IAAI;gBAAE,OAAO;YACjC,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YACzC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YAChC,wFAAwF;YACxF,4FAA4F;YAC5F,+CAA+C;YAC/C,GAAG,EAAE,OAAO,EAAE,CAAC;YACf,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,uBAAuB,EAAE,EAAE,QAAQ,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;QAC7E,CAAC;IACH,CAAC;CACF"}