@cello-protocol/daemon 0.0.167 → 0.0.169

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 (80) hide show
  1. package/dist/daemon.d.ts.map +1 -1
  2. package/dist/daemon.js +115 -254
  3. package/dist/daemon.js.map +1 -1
  4. package/dist/document-amendment-store.d.ts +94 -46
  5. package/dist/document-amendment-store.d.ts.map +1 -1
  6. package/dist/document-amendment-store.js +217 -102
  7. package/dist/document-amendment-store.js.map +1 -1
  8. package/dist/document-delivery-transport.d.ts +41 -52
  9. package/dist/document-delivery-transport.d.ts.map +1 -1
  10. package/dist/document-delivery-transport.js +6 -156
  11. package/dist/document-delivery-transport.js.map +1 -1
  12. package/dist/document-frame-router.d.ts +13 -17
  13. package/dist/document-frame-router.d.ts.map +1 -1
  14. package/dist/document-frame-router.js +21 -93
  15. package/dist/document-frame-router.js.map +1 -1
  16. package/dist/document-handlers.d.ts +1 -1
  17. package/dist/document-handlers.d.ts.map +1 -1
  18. package/dist/document-handlers.js +471 -466
  19. package/dist/document-handlers.js.map +1 -1
  20. package/dist/document-handshake.d.ts +4 -1
  21. package/dist/document-handshake.d.ts.map +1 -1
  22. package/dist/document-handshake.js +3 -1
  23. package/dist/document-handshake.js.map +1 -1
  24. package/dist/document-inbound.d.ts +18 -5
  25. package/dist/document-inbound.d.ts.map +1 -1
  26. package/dist/document-inbound.js +94 -110
  27. package/dist/document-inbound.js.map +1 -1
  28. package/dist/document-layer.d.ts +28 -67
  29. package/dist/document-layer.d.ts.map +1 -1
  30. package/dist/document-layer.js +670 -565
  31. package/dist/document-layer.js.map +1 -1
  32. package/dist/document-lifecycle.d.ts +11 -157
  33. package/dist/document-lifecycle.d.ts.map +1 -1
  34. package/dist/document-lifecycle.js +26 -561
  35. package/dist/document-lifecycle.js.map +1 -1
  36. package/dist/document-publish.d.ts +13 -0
  37. package/dist/document-publish.d.ts.map +1 -1
  38. package/dist/document-publish.js +19 -11
  39. package/dist/document-publish.js.map +1 -1
  40. package/dist/document-reconcile-engine.d.ts +66 -0
  41. package/dist/document-reconcile-engine.d.ts.map +1 -0
  42. package/dist/document-reconcile-engine.js +225 -0
  43. package/dist/document-reconcile-engine.js.map +1 -0
  44. package/dist/document-reconcile-scheduler.d.ts +91 -0
  45. package/dist/document-reconcile-scheduler.d.ts.map +1 -0
  46. package/dist/document-reconcile-scheduler.js +153 -0
  47. package/dist/document-reconcile-scheduler.js.map +1 -0
  48. package/dist/document-rejection.d.ts +2 -0
  49. package/dist/document-rejection.d.ts.map +1 -1
  50. package/dist/document-rejection.js +5 -1
  51. package/dist/document-rejection.js.map +1 -1
  52. package/dist/document-store.d.ts +18 -220
  53. package/dist/document-store.d.ts.map +1 -1
  54. package/dist/document-store.js +95 -717
  55. package/dist/document-store.js.map +1 -1
  56. package/dist/initiate-session-handler.d.ts +5 -0
  57. package/dist/initiate-session-handler.d.ts.map +1 -1
  58. package/dist/initiate-session-handler.js +1 -0
  59. package/dist/initiate-session-handler.js.map +1 -1
  60. package/package.json +5 -5
  61. package/dist/delivery-sweep-bound.d.ts +0 -79
  62. package/dist/delivery-sweep-bound.d.ts.map +0 -1
  63. package/dist/delivery-sweep-bound.js +0 -109
  64. package/dist/delivery-sweep-bound.js.map +0 -1
  65. package/dist/document-ack-inbound.d.ts +0 -71
  66. package/dist/document-ack-inbound.d.ts.map +0 -1
  67. package/dist/document-ack-inbound.js +0 -210
  68. package/dist/document-ack-inbound.js.map +0 -1
  69. package/dist/document-control-notifier.d.ts +0 -107
  70. package/dist/document-control-notifier.d.ts.map +0 -1
  71. package/dist/document-control-notifier.js +0 -137
  72. package/dist/document-control-notifier.js.map +0 -1
  73. package/dist/document-delivery.d.ts +0 -229
  74. package/dist/document-delivery.d.ts.map +0 -1
  75. package/dist/document-delivery.js +0 -449
  76. package/dist/document-delivery.js.map +0 -1
  77. package/dist/document-join-store.d.ts +0 -80
  78. package/dist/document-join-store.d.ts.map +0 -1
  79. package/dist/document-join-store.js +0 -197
  80. package/dist/document-join-store.js.map +0 -1
@@ -11,11 +11,6 @@
11
11
  * and the log. The peer keeps what it holds, and that is said out loud: it is the one
12
12
  * thing an operator is most likely to assume a kill undoes, and the one thing it
13
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
14
  * ── THE KILL SWITCH (§16.7-11) ────────────────────────────────────────────────────────────────
20
15
  *
21
16
  * A platform-paused agent refuses OUTBOUND publishes loudly, still admits INBOUND mechanically,
@@ -24,55 +19,7 @@
24
19
  * their doing; refusing outbound silently would leave the operator writing into a document that is
25
20
  * going nowhere, with their work piling up locally and no sign anything is wrong.
26
21
  */
27
- /**
28
- * "Did every holder hear it?" — and NO on an empty map.
29
- *
30
- * `[].every(…)` is `true`, so folding an empty fan-out with `every` alone reports that everybody
31
- * was notified precisely when nobody was. The notifier refuses a holder-less document by name, so
32
- * this is the second line rather than the first, but the vacuous-truth reading is the one that
33
- * would surface to an operator as a clean close that told no one.
34
- */
35
- function everyHolderNotified(ok, holders) {
36
- const told = Object.values(holders ?? {});
37
- return ok && told.length > 0 && told.every((v) => v);
38
- }
39
22
  const CREATE_LIFECYCLE_SQL = `
40
- CREATE TABLE IF NOT EXISTS document_closes (
41
- owner_agent_id TEXT NOT NULL,
42
- document_id TEXT NOT NULL,
43
- -- WHO closed, not how many closes there were. Counting would let one party close a document
44
- -- unilaterally by asking twice, which is precisely the bilateral guarantee gone.
45
- closed_by TEXT NOT NULL,
46
- created_at INTEGER NOT NULL,
47
- PRIMARY KEY (owner_agent_id, document_id, closed_by)
48
- );
49
-
50
- -- WITHDRAWALS live here, NOT in document_envelopes, and that placement is the fix for three
51
- -- separate defects rather than a filing preference.
52
- --
53
- -- 1. CHAIN. A withdrawal is local-only by design: it is never delivered (the update it concerns
54
- -- was never delivered, so the peer has nothing to act on). As an envelope it still advanced
55
- -- our per-sender chain, so our NEXT update chained onto a node the peer will never hold and
56
- -- the peer refused it with document_chain_broken — sending an operator to the chain layer for
57
- -- a withdrawal-scoping bug, and leaving the document unopenable after their next restart.
58
- -- Chaining it to the last DELIVERABLE envelope instead would fork our chain, since the next
59
- -- update claims the same predecessor. It cannot be a node in that chain at all.
60
- -- 2. CRYPTO. As an envelope it needed a signature and a state vector, and it had neither of its
61
- -- own — the first version copied the ORIGINAL's, putting a real Ed25519 signature made over a
62
- -- different record onto a permanent append-only row. document-rejection.ts states the rule
63
- -- this violated in writing: required, never fabricated.
64
- -- 3. REPLAY. An envelope row is something rebuildSnapshot must reason about; an audit row is not.
65
- --
66
- -- Same shape as document_quarantine, for the same reason: audit that must survive, must not be
67
- -- replayed, and must not be chained.
68
- CREATE TABLE IF NOT EXISTS document_withdrawals (
69
- owner_agent_id TEXT NOT NULL,
70
- document_id TEXT NOT NULL,
71
- envelope_hash TEXT NOT NULL,
72
- created_at INTEGER NOT NULL,
73
- PRIMARY KEY (owner_agent_id, document_id, envelope_hash)
74
- );
75
-
76
23
  CREATE TABLE IF NOT EXISTS agent_platform_pause (
77
24
  agent_id TEXT NOT NULL PRIMARY KEY,
78
25
  paused INTEGER NOT NULL,
@@ -82,359 +29,24 @@ const CREATE_LIFECYCLE_SQL = `
82
29
  export class DocumentLifecycle {
83
30
  #store;
84
31
  #logger;
85
- #notifier;
86
- /**
87
- * Our own wire sender id for an agent. M14-D5 makes it the pubkey hex, which is NOT the local
88
- * owner key this store is otherwise keyed by — and passing the wrong one here reports every
89
- * pending update as delivered.
90
- */
91
- #senderAgentId;
92
- #rollback;
93
- #currentHolders;
94
- /**
95
- * May this sender end this document? Derived membership is the WHOLE gate when the chain
96
- * derives — a joined third holder's close is as admissible as the genesis peer's, and a removed
97
- * holder's is not admissible at all. The peer column stands in only when the chain cannot
98
- * answer. Mirrors `document-inbound.ts`'s sender gate deliberately; two different answers to
99
- * "is this agent a party to this document" is how the two paths drift apart.
100
- *
101
- * TWO KINDS OF "CANNOT ANSWER", and they get different answers because only one of them is
102
- * legacy. A null derivation is the pre-amendment bilateral document — the peer column stands in,
103
- * which NARROWS who may act and is the same rule the update path uses. A THROW is bytes this
104
- * build cannot read, which is not a legacy document but an unknown one: this gate FAILS CLOSED
105
- * rather than admitting the genesis peer, who by then may be a holder the chain removed. The
106
- * operator's own `kill` is unaffected — it is local and needs no gate.
107
- */
108
- #senderMayEnd(ownerAgentId, documentId, senderAgentId, peerAgentId) {
109
- const v = this.#holderVerdict(ownerAgentId, documentId);
110
- if (v.kind === "derived")
111
- return v.holders.includes(senderAgentId);
112
- if (v.kind === "legacy")
113
- return senderAgentId === peerAgentId;
114
- // UNKNOWN fails closed. Admitting the genesis peer here would admit a party the chain may have
115
- // removed — and the chain is precisely what we could not read. The operator's own `kill` is
116
- // unaffected: it is local and needs no gate.
117
- this.#logger.warn("document.end.holders_unknown", { documentId, reason: v.reason });
118
- return false;
119
- }
120
- /** The injected derivation, with its throw contained and named. */
121
- #holderVerdict(ownerAgentId, documentId) {
122
- try {
123
- return this.#currentHolders(ownerAgentId, documentId);
124
- }
125
- catch (err) {
126
- // `holdersFor` decodes every stored amendment and THROWS on bytes this build cannot read.
127
- // Uncontained, that throw escaped the row, escaped the map, and the operator asking "what
128
- // documents do I have" got a raw CBOR decoder string naming no document — the regression
129
- // already fixed once in `arrangementFor`, re-opened through this call site.
130
- const reason = err instanceof Error ? err.message : String(err);
131
- this.#logger.error("document.holders.undecodable", { documentId, reason });
132
- return { kind: "unknown", reason };
133
- }
134
- }
135
- constructor(store, logger, notifier,
136
- /**
137
- * Undo one envelope's operations on the LIVE document, as inverses. Injected because the live
138
- * `Y.Doc` and its UndoManager belong to the engine, not here — and REQUIRED, because a default
139
- * that quietly did nothing would restore exactly the defect this argument exists to fix.
140
- */
141
- rollback, senderAgentId = (id) => id,
32
+ #removedFor;
33
+ #endedFor;
34
+ constructor(store, logger,
35
+ /** SYNC-D8 "was this owner written out?", answered by the layer's one fold derivation. */
36
+ removedFor,
142
37
  /**
143
- * The inbound authorization gate (DOD-MP-CONTROL-N-1). Defaults to "cannot answer", which
144
- * routes to the `peerAgentId` check the pre-multiplayer behaviour so a caller that has not
145
- * been wired yet is bilateral-correct rather than open. It is NOT defaulted to a permissive
146
- * answer: a membership gate whose default admits everyone is worse than no gate, because it
147
- * reads as one.
38
+ * SYNC-P4 review F2 "is this document ended?", answered by the fold, never the status
39
+ * column. `derived: false` means the chain does not derive (legacy bilateral, undecodable
40
+ * bytes); ONLY there does the stored column stand in, because for a pre-pivot document the
41
+ * column IS the record of its ending.
148
42
  */
149
- currentHolders = () => ({ kind: "legacy" })) {
43
+ endedFor) {
150
44
  this.#store = store;
151
45
  this.#logger = logger;
152
- this.#notifier = notifier;
153
- this.#rollback = rollback;
154
- this.#senderAgentId = senderAgentId;
155
- this.#currentHolders = currentHolders;
46
+ this.#removedFor = removedFor;
47
+ this.#endedFor = endedFor;
156
48
  this.#store.rawDb.exec(CREATE_LIFECYCLE_SQL);
157
49
  }
158
- list(ownerAgentId, nowMs) {
159
- // Every pending envelope regardless of schedule: the operator is asking "what has not reached
160
- // my peer", not "what is due for a retry in the next few seconds".
161
- const pending = this.#store.pendingDeliveries(ownerAgentId, Number.MAX_SAFE_INTEGER, this.#senderAgentId(ownerAgentId));
162
- const pendingByDocument = new Map();
163
- for (const e of pending) {
164
- const c = pendingByDocument.get(e.documentId) ?? { total: 0, sent: 0 };
165
- c.total += 1;
166
- // SPLIT, because "sent, awaiting confirmation" and "never left this machine" are different
167
- // things to tell an operator asking why their work has not landed. FANOUT-1: the fact
168
- // moved to the per-holder rows — "sent" means it reached AT LEAST ONE holder's wire.
169
- if (e.deliveredAtMs != null || this.#store.envelopeEverSent(ownerAgentId, e.envelopeHash))
170
- c.sent += 1;
171
- pendingByDocument.set(e.documentId, c);
172
- }
173
- void nowMs;
174
- return this.#store.listDocuments(ownerAgentId).map((d) => ({
175
- documentId: d.documentId,
176
- peerAgentId: d.peerAgentId,
177
- documentType: d.documentType,
178
- // DOD-MP-REMOVE-1 — display overlay, derived: a removed holder's row still says active in
179
- // the table (removal is a chain fact, not a stored flag), and a list that said "active"
180
- // would be the surface claiming more than forward-only allows.
181
- ...(this.#store.removedFromArrangement(ownerAgentId, d.documentId).removed
182
- ? { removed: true }
183
- : {}),
184
- assuranceTier: "authenticated",
185
- epochId: this.#store.currentDocumentEpoch(ownerAgentId, d.documentId),
186
- status: d.status,
187
- pendingDeliveries: pendingByDocument.get(d.documentId)?.total ?? 0,
188
- pendingSent: pendingByDocument.get(d.documentId)?.sent ?? 0,
189
- pendingUnsent: (pendingByDocument.get(d.documentId)?.total ?? 0) -
190
- (pendingByDocument.get(d.documentId)?.sent ?? 0),
191
- abandonedDeliveries: this.#store.abandonedCount(ownerAgentId, d.documentId),
192
- // "I have closed and we are still waiting on someone" — ALL of them, not just the genesis
193
- // peer (DOD-MP-CLOSE-N-1). Against the peer alone this read "not pending" the moment the
194
- // genesis pair agreed, while a joiner had not answered and the document was still open.
195
- closePending: this.#closePendingFor(ownerAgentId, d.documentId, d.peerAgentId),
196
- }));
197
- }
198
- /** Our half of a bilateral close. Completes only when the peer's half is also on record. */
199
- async close(ownerAgentId, documentId, nowMs) {
200
- const doc = this.#store.getDocument(ownerAgentId, documentId);
201
- if (!doc) {
202
- return { ok: false, reason: "document_unknown", detail: `no document ${documentId.slice(0, 16)}…` };
203
- }
204
- this.#recordClose(ownerAgentId, documentId, ownerAgentId, nowMs);
205
- const notified = await this.#notifier.notifyPeer(documentId, "close");
206
- if (!notified.ok) {
207
- // RAISED to error, and RETURNED, matching `kill`. This was a warn whose outcome went nowhere:
208
- // the caller answered `{ok: true, status: "active"}`, which is indistinguishable from "sent
209
- // fine, they have not answered yet". Control frames are fire-once — not in the log, never
210
- // swept — so a close the peer never received means the document can never settle, and neither
211
- // operator has anything to look at.
212
- this.#logger.error("document.close.peer_not_notified", {
213
- documentId,
214
- reason: notified.reason,
215
- detail: notified.detail ?? "",
216
- });
217
- }
218
- this.#settleClose(ownerAgentId, documentId);
219
- const holdersNotified = notified.ok ? notified.holdersNotified : {};
220
- const holderFailures = notified.ok ? notified.holderFailures : {};
221
- // EVERY current holder, not "the send returned ok" — with N holders a partial fan-out is the
222
- // normal failure and one boolean cannot describe it. The map is the precise answer; this stays
223
- // as the summary a caller can branch on.
224
- const allNotified = everyHolderNotified(notified.ok, holdersNotified);
225
- this.#logger.info("document.close.requested", { documentId, peerNotified: allNotified, holdersNotified });
226
- // THE REASON TRAVELS. It used to stop here, so one guidance string covered a transport failure,
227
- // a missing signing key and a wiring fault — and it told the operator to wait for their peer,
228
- // which only helps for the first of the three.
229
- return {
230
- ok: true,
231
- peerNotified: allNotified,
232
- holdersNotified,
233
- holderFailures,
234
- ...(notified.ok ? {} : { notifyReason: notified.reason, notifyDetail: notified.detail }),
235
- };
236
- }
237
- /** The peer's half, arriving over the session. */
238
- recordPeerClose(ownerAgentId, documentId, peerAgentId, nowMs) {
239
- const doc = this.#store.getDocument(ownerAgentId, documentId);
240
- if (!doc) {
241
- // Without this the row is written for a document that does not exist — there is no foreign
242
- // key on this table — and `setDocumentStatus` updates zero rows and returns silently.
243
- return {
244
- ok: false,
245
- reason: "document_unknown",
246
- detail: `no document ${documentId.slice(0, 16)}… for this agent`,
247
- };
248
- }
249
- if (!this.#senderMayEnd(ownerAgentId, documentId, peerAgentId, doc.peerAgentId)) {
250
- // THE PARTY GUARANTEE. The closer id came from the caller and was written as `closed_by`
251
- // and settled against ITSELF, so any second distinct string — a stale contact id, a
252
- // pubkey-vs-name mismatch, a hostile session — plus our own close flipped the document to
253
- // closed. The whole point of recording WHO closed is defeated if who is never checked.
254
- //
255
- // The check is now MEMBERSHIP, not identity-with-one-peer: a joiner is a party and their
256
- // close must count, a removed holder is not and theirs must not.
257
- this.#logger.warn("document.close.not_holder", {
258
- documentId,
259
- claimedBy: peerAgentId,
260
- peerAgentId: doc.peerAgentId,
261
- });
262
- return {
263
- ok: false,
264
- reason: "document_close_not_peer",
265
- detail: `${peerAgentId} does not currently hold this document, so their close is not one of the ` +
266
- `halves that can settle it`,
267
- };
268
- }
269
- // RECORDED AGAINST THE SENDER, not against `doc.peerAgentId`. Writing the peer column here
270
- // credited a joiner's close to the genesis peer — the wrong name on the record, and with two
271
- // joiners closing it would settle twice against one identity.
272
- this.#recordClose(ownerAgentId, documentId, peerAgentId, nowMs);
273
- this.#settleClose(ownerAgentId, documentId);
274
- this.#logger.info("document.close.peer_requested", { documentId, peerAgentId });
275
- return { ok: true };
276
- }
277
- /**
278
- * The peer KILLED the document. Their half of the unilateral end.
279
- *
280
- * Terminal immediately, and there is no reciprocal step: a kill is one-sided by definition, which
281
- * is what separates it from a close. Continuing to publish afterwards would send updates to a
282
- * party who has stopped listening — refused at their end forever, with nothing on this screen
283
- * explaining why.
284
- *
285
- * The SENDER IS CHECKED against the document's peer for the same reason `recordPeerClose` checks
286
- * it: without that, any string plus a valid-looking frame ends someone else's document.
287
- */
288
- recordPeerKill(ownerAgentId, documentId, peerAgentId, nowMs) {
289
- const doc = this.#store.getDocument(ownerAgentId, documentId);
290
- if (!doc) {
291
- return {
292
- ok: false,
293
- reason: "document_unknown",
294
- detail: `no document ${documentId.slice(0, 16)}… for this agent`,
295
- };
296
- }
297
- if (!this.#senderMayEnd(ownerAgentId, documentId, peerAgentId, doc.peerAgentId)) {
298
- this.#logger.warn("document.kill.not_holder", {
299
- documentId,
300
- claimedBy: peerAgentId,
301
- peerAgentId: doc.peerAgentId,
302
- });
303
- return {
304
- ok: false,
305
- reason: "document_kill_not_peer",
306
- detail: `${peerAgentId} does not currently hold this document, so their kill is not theirs to make`,
307
- };
308
- }
309
- void nowMs;
310
- if (doc.status !== "active" && doc.status !== "stalled") {
311
- // ONLY FROM ACTIVE, the same guard `#settleClose` carries and for the mirror reason. The
312
- // transport WILL redeliver, and nothing bounds a control frame's freshness — `sent_at_ms` is
313
- // signed and never checked. Unconditional, a kill arriving after a bilateral close rewrote a
314
- // settled agreement as a unilateral end.
315
- this.#logger.info("document.kill.peer_requested.ignored", {
316
- documentId,
317
- status: doc.status,
318
- });
319
- return { ok: true };
320
- }
321
- this.#store.setDocumentStatus(ownerAgentId, documentId, "killed");
322
- this.#logger.info("document.kill.peer_requested", { documentId, peerAgentId: doc.peerAgentId });
323
- return { ok: true };
324
- }
325
- /**
326
- * Unilateral end. Local, and deliberately not contingent on the peer hearing about it — a
327
- * decision to stop that depends on the other party being online is not a decision to stop.
328
- */
329
- async kill(ownerAgentId, documentId, nowMs) {
330
- const doc = this.#store.getDocument(ownerAgentId, documentId);
331
- if (!doc) {
332
- return { ok: false, reason: "document_unknown", detail: `no document ${documentId.slice(0, 16)}…` };
333
- }
334
- void nowMs;
335
- this.#store.setDocumentStatus(ownerAgentId, documentId, "killed");
336
- const notified = await this.#notifier.notifyPeer(documentId, "kill");
337
- if (!notified.ok) {
338
- // Reported, not swallowed: a kill the peer never heard about leaves them publishing into a
339
- // document that will never answer, and the operator is the one who needs to know that.
340
- this.#logger.error("document.kill.peer_not_notified", {
341
- documentId,
342
- peerAgentId: doc.peerAgentId,
343
- reason: notified.reason,
344
- detail: notified.detail ?? "",
345
- });
346
- }
347
- const holdersNotified = notified.ok ? notified.holdersNotified : {};
348
- const holderFailures = notified.ok ? notified.holderFailures : {};
349
- const allNotified = everyHolderNotified(notified.ok, holdersNotified);
350
- this.#logger.info("document.killed", { documentId, peerNotified: allNotified, holdersNotified });
351
- return {
352
- ok: true,
353
- peerNotified: allNotified,
354
- holdersNotified,
355
- holderFailures,
356
- note: "this document no longer accepts or publishes updates, and your local copy and log are " +
357
- "retained. Your peer keeps what it holds — a kill stops the collaboration, it does not " +
358
- "retract content they already have.",
359
- // Same reason `close` carries it: a local signing fault and an absent peer are opposite
360
- // problems and only one of them is fixed by waiting.
361
- ...(notified.ok ? {} : { notifyReason: notified.reason, notifyDetail: notified.detail }),
362
- };
363
- }
364
- /**
365
- * Withdraw ONE undelivered update: a withdrawal record beside the original.
366
- *
367
- * Every refusal here is the same shape — say no rather than produce a record that claims
368
- * something untrue about the log.
369
- */
370
- withdraw(ownerAgentId, documentId, envelopeHash, nowMs) {
371
- const log = this.#store.getEnvelopeLog(ownerAgentId, documentId);
372
- const original = log.find((e) => e.envelopeHash === envelopeHash);
373
- if (!original) {
374
- // A withdrawal record pointing at nothing is worse than a refusal: it is a permanent claim
375
- // in an append-only store about an envelope that never existed.
376
- return {
377
- ok: false,
378
- reason: "document_envelope_unknown",
379
- detail: `no envelope ${envelopeHash.slice(0, 16)}… in this document's log`,
380
- };
381
- }
382
- if (original.senderAgentId !== ownerAgentId) {
383
- return {
384
- ok: false,
385
- reason: "document_not_author",
386
- detail: `envelope ${envelopeHash.slice(0, 16)}… was authored by ${original.senderAgentId}, not by you`,
387
- };
388
- }
389
- if (original.ackedAtMs != null) {
390
- // The honest refusal. Withdrawing a delivered update would tell the operator their content
391
- // was retracted while the peer is holding it — the promise this module refuses to make.
392
- return {
393
- ok: false,
394
- reason: "document_already_delivered",
395
- detail: `envelope ${envelopeHash.slice(0, 16)}… has already been delivered and acknowledged — ` +
396
- `your peer holds it, so it cannot be withdrawn. Publish a superseding update instead.`,
397
- };
398
- }
399
- // THE LOCAL ROLLBACK — the half that was missing. Writing only the record left the original in
400
- // the log WITH its payload, and replay applies every update payload in order, so the withdrawn
401
- // text stayed in the operator's own document and came back on every rebuild. The operator was
402
- // told their update was withdrawn while their file still contained it.
403
- //
404
- // Rolled back as INVERSES through the same undo path a rejection uses, never by dropping the
405
- // payload: our own later work may be causally stacked on these operations, and REJECT-1
406
- // measured what removing them costs — everything after stays pending forever and the document
407
- // silently loses the legitimate work. The inverse enters the log on the next ordinary publish,
408
- // computed from the live document, which is also why neither the original nor the inverse is
409
- // ever delivered: the peer holds neither, and the next publish carries the net effect.
410
- const rolledBack = this.#rollback(ownerAgentId, documentId, envelopeHash);
411
- if (!rolledBack.ok) {
412
- return {
413
- ok: false,
414
- reason: "document_withdraw_rollback_failed",
415
- detail: `the local rollback did not happen (${rolledBack.reason}), so nothing was withdrawn — ` +
416
- `reporting success here would tell you your update was retracted while your file still ` +
417
- `contains it`,
418
- };
419
- }
420
- const info = this.#store.rawDb
421
- .prepare(`INSERT INTO document_withdrawals (owner_agent_id, document_id, envelope_hash, created_at)
422
- VALUES (?, ?, ?, ?)
423
- ON CONFLICT (owner_agent_id, document_id, envelope_hash) DO NOTHING`)
424
- .run(ownerAgentId, documentId, envelopeHash, nowMs);
425
- if (Number(info.changes) === 0) {
426
- // Already withdrawn. Reported rather than inferred — the earlier version ignored the write's
427
- // outcome and returned success for a no-op.
428
- return {
429
- ok: false,
430
- reason: "document_already_withdrawn",
431
- detail: `envelope ${envelopeHash.slice(0, 16)}… was already withdrawn`,
432
- };
433
- }
434
- this.#logger.info("document.withdrawn", { documentId, envelopeHash });
435
- return { ok: true };
436
- }
437
- // ─── the kill switch (§16.7-11) ───────────────────────────────────────────
438
50
  setPlatformPaused(agentId, paused, nowMs) {
439
51
  this.#store.rawDb
440
52
  .prepare(`INSERT INTO agent_platform_pause (agent_id, paused, updated_at) VALUES (?, ?, ?)
@@ -465,24 +77,31 @@ export class DocumentLifecycle {
465
77
  if (!doc) {
466
78
  return { ok: false, reason: "document_unknown", detail: `no document ${documentId.slice(0, 16)}…` };
467
79
  }
468
- if (doc.status === "closed") {
80
+ // THE FOLD RULES ENDINGS (review F2): the status column is a display projection that can lag
81
+ // the derivation (a concurrently-arriving admission re-opens a closure). Only a document whose
82
+ // chain does not derive — the pre-pivot bilateral record — is judged by its column.
83
+ const ending = this.#endedFor(ownerAgentId, documentId);
84
+ const ended = ending.derived
85
+ ? ending.ended
86
+ : doc.status === "closed" || doc.status === "killed"
87
+ ? doc.status
88
+ : null;
89
+ if (ended === "closed") {
469
90
  return { ok: false, reason: "document_closed", detail: "this document was closed by agreement" };
470
91
  }
471
- if (doc.status === "killed") {
92
+ if (ended === "killed") {
472
93
  return { ok: false, reason: "document_killed", detail: "this document was ended locally" };
473
94
  }
474
95
  // DOD-MP-REMOVE-1, forward-only — DERIVED from the amendment chain, never a stored flag:
475
96
  // the copy is theirs (reading, the file, the history all remain), but publishing into an
476
97
  // arrangement that no longer includes them would only be refused by every holder, so it is
477
98
  // refused here first, naming the actual condition and the epoch it happened at.
478
- const membership = this.#store.removedFromArrangement(ownerAgentId, documentId);
479
- if (membership.removed) {
99
+ if (this.#removedFor(ownerAgentId, documentId)) {
480
100
  return {
481
101
  ok: false,
482
102
  reason: "document_removed",
483
- detail: `you were removed from this document's arrangement at epoch ${membership.epochId} ` +
484
- `your copy and its history remain yours, but new edits no longer publish to the ` +
485
- `other holders`,
103
+ detail: `you were removed from this document's arrangement your copy and its history remain ` +
104
+ `yours, but new edits no longer publish to the other holders`,
486
105
  };
487
106
  }
488
107
  if (doc.status === "stalled") {
@@ -508,162 +127,8 @@ export class DocumentLifecycle {
508
127
  }
509
128
  return { ok: true };
510
129
  }
511
- /** Inbound. A pause does NOT refuse it — see the header. */
512
- canAdmit(ownerAgentId, documentId) {
513
- const doc = this.#store.getDocument(ownerAgentId, documentId);
514
- if (!doc) {
515
- return { ok: false, reason: "document_unknown", detail: `no document ${documentId.slice(0, 16)}…` };
516
- }
517
- if (doc.status === "killed") {
518
- return { ok: false, reason: "document_killed", detail: "this document was ended locally" };
519
- }
520
- if (doc.status === "closed") {
521
- return { ok: false, reason: "document_closed", detail: "this document was closed by agreement" };
522
- }
523
- if (doc.status === "stalled") {
524
- return {
525
- ok: false,
526
- reason: "document_stalled",
527
- // NAMES BOTH CAUSES, because `stalled` has two and this text asserted one of them.
528
- //
529
- // It is set by REJECT-1 after the peer's gate refuses repeatedly, AND by the delivery
530
- // worker's unacked ceiling — where the peer's daemon never answered at all. Those are
531
- // opposite subsystems. An operator hitting the second was told to go and read rejection
532
- // reasons, which do not exist for it, and the shipped skill sent them to a `cello_doc_list`
533
- // field that does not exist either.
534
- detail: "this document stopped accepting updates. Two things set that state and they need " +
535
- "different actions: the peer's gate REFUSED your updates repeatedly (look for " +
536
- "document.rejection.received), or their daemon never CONFIRMED them at all (look for " +
537
- "document.delivery.unacked_limit — that one may be a local fault, not theirs)",
538
- };
539
- }
540
- return { ok: true };
541
- }
542
130
  shouldNotify(agentId) {
543
131
  return !this.isPlatformPaused(agentId);
544
132
  }
545
- // ─── internals ────────────────────────────────────────────────────────────
546
- #recordClose(ownerAgentId, documentId, closedBy, nowMs) {
547
- this.#store.rawDb
548
- .prepare(`INSERT INTO document_closes (owner_agent_id, document_id, closed_by, created_at)
549
- VALUES (?, ?, ?, ?)
550
- ON CONFLICT (owner_agent_id, document_id, closed_by) DO NOTHING`)
551
- .run(ownerAgentId, documentId, closedBy, nowMs);
552
- }
553
- #hasClosed(ownerAgentId, documentId, who) {
554
- const r = this.#store.rawDb
555
- .prepare(`SELECT 1 AS present FROM document_closes
556
- WHERE owner_agent_id = ? AND document_id = ? AND closed_by = ?`)
557
- .get(ownerAgentId, documentId, who);
558
- return r?.present === 1;
559
- }
560
- /**
561
- * A document is complete BY AGREEMENT, so every current holder must have said it — DOD-MP-CLOSE-N-1.
562
- *
563
- * This settled on the owner plus `doc.peerAgentId`. With three holders that flipped the document
564
- * to `closed` as soon as TWO of them had closed, while the third was still editing — which is
565
- * exactly the failure this unit's own header forbids: "One side's close is a REQUEST — treating
566
- * it as a conclusion would tell this operator the collaboration ended while the peer is still
567
- * writing into it." Two of three is a conclusion drawn from a request.
568
- *
569
- * Ruled under M14B-PROCEDURE §3a as the least-reversal-risk reading. Requiring ALL is the
570
- * conservative direction: it never claims an agreement that does not exist, and it is identical
571
- * to the old behaviour for a two-party document. Loosening later (say, an admin closing for
572
- * everyone) only widens what settles and strands nothing; shipping the loose rule first would
573
- * leave documents already marked closed that never were, and no migration can un-say that.
574
- *
575
- * A chain that will not derive REFUSES to settle. It does NOT fall back to the genesis pair:
576
- * that fallback re-creates this unit's own defect silently — a three-holder document settling on
577
- * two, with the surface showing `closed` and nothing saying the derivation failed. The inbound
578
- * gate's null branch NARROWS (fewer parties may act) and `controlHolders`' refuses by name; the
579
- * same syntax here would WIDEN, which is the opposite safety direction.
580
- */
581
- /**
582
- * Who must agree before this document is complete. Null means REFUSE — never a smaller set.
583
- *
584
- * The throw is contained here rather than at each call site because `holdersFor` decodes every
585
- * stored amendment and throws on bytes this build cannot read, and an uncontained throw took the
586
- * ENTIRE `cello_doc_list` down — the operator asking "what documents do I have" got a raw CBOR
587
- * decoder string naming no document. That exact regression was fixed once already in
588
- * `arrangementFor`; this unit re-opened it through a different call site.
589
- */
590
- #mustAgree(ownerAgentId, documentId, peerAgentId) {
591
- const v = this.#holderVerdict(ownerAgentId, documentId);
592
- // LEGACY is the one stand-in that is not a fallback: no chain exists, so the genesis pair IS
593
- // the whole membership. `unknown` gets no such courtesy — a chain we cannot read may hold any
594
- // number of holders, and settling on two of them is the defect this unit removes.
595
- if (v.kind === "legacy")
596
- return [ownerAgentId, peerAgentId];
597
- if (v.kind === "unknown")
598
- return null;
599
- const holders = v.holders;
600
- // `[].every()` is TRUE and `[].some()` is FALSE — an empty set would settle instantly on
601
- // nobody's agreement in one function and report "waiting on nobody" in the other. A derived
602
- // arrangement always contains its proposer, so this is the second line rather than the first;
603
- // it is here because the vacuous reading of this idiom already had to be closed once in this file.
604
- if (holders.length === 0)
605
- return null;
606
- return holders;
607
- }
608
- /** Have WE closed while at least one current holder has not? The list row's question. */
609
- #closePendingFor(ownerAgentId, documentId, peerAgentId) {
610
- if (!this.#hasClosed(ownerAgentId, documentId, ownerAgentId))
611
- return false;
612
- const mustAgree = this.#mustAgree(ownerAgentId, documentId, peerAgentId);
613
- // CANNOT PROVE the rest have closed — so we are still waiting. "Still waiting" is the safe
614
- // answer here and "nothing pending" is not: the latter tells an operator a document needs
615
- // nothing from anyone when in fact nothing can ever settle it.
616
- if (mustAgree === null)
617
- return true;
618
- return mustAgree.some((h) => !this.#hasClosed(ownerAgentId, documentId, h));
619
- }
620
- /**
621
- * Settle if EVERY current holder has now said it — idempotent, `active`-only, safe to call from
622
- * anywhere the answer could have changed. That includes a MEMBERSHIP change: removing the one
623
- * holder who had not closed leaves everyone who remains in agreement, and without re-evaluating
624
- * here the document stayed `active` forever with `closePending` reading false — open, waiting on
625
- * nobody, and unsettleable, because control frames are fire-once and never swept.
626
- */
627
- #settleClose(ownerAgentId, documentId, peerAgentId) {
628
- // ONLY FROM ACTIVE. Unconditional, a peer close arriving after a unilateral kill overwrote
629
- // `killed` with `closed` — the operator's own decision replaced by "closed by agreement", which
630
- // is a different fact and the one they did not choose. Same for `stalled`.
631
- if (this.#store.getDocument(ownerAgentId, documentId)?.status !== "active")
632
- return;
633
- const peer = peerAgentId ?? this.#store.getDocument(ownerAgentId, documentId)?.peerAgentId ?? "";
634
- const mustAgree = this.#mustAgree(ownerAgentId, documentId, peer);
635
- if (mustAgree === null) {
636
- this.#logger.warn("document.close.holders_underivable", { documentId });
637
- return;
638
- }
639
- if (mustAgree.every((h) => this.#hasClosed(ownerAgentId, documentId, h))) {
640
- this.#store.setDocumentStatus(ownerAgentId, documentId, "closed");
641
- // The epoch travels: `mustAgree` is derived per daemon from an eventually-consistent chain,
642
- // so two holders can legitimately reach this line with different-sized sets. Without the
643
- // epoch their logs cannot be reconciled.
644
- this.#logger.info("document.closed", {
645
- documentId,
646
- agreedBy: mustAgree.length,
647
- epochId: this.#store.currentDocumentEpoch(ownerAgentId, documentId),
648
- });
649
- }
650
- }
651
- /**
652
- * A membership amendment landed — the agreement may now be complete, or a re-admitted holder's
653
- * stale close may need clearing. Called from the layer after any chain append.
654
- */
655
- onMembershipChanged(ownerAgentId, documentId, removedAgentId) {
656
- if (removedAgentId !== undefined) {
657
- // FORWARD-ONLY, applied to the close record too. `document_closes` is keyed by identity with
658
- // no epoch, so a holder who closed, was removed, and was later re-admitted would have their
659
- // OLD close counted against their NEW tenure — this unit's headline failure, one epoch
660
- // removed. Dropping the row on removal is the cheaper half of that fix and matches the
661
- // forward-only doctrine: their past participation is not carried into a future one.
662
- this.#store.rawDb
663
- .prepare(`DELETE FROM document_closes WHERE owner_agent_id = ? AND document_id = ? AND closed_by = ?`)
664
- .run(ownerAgentId, documentId, removedAgentId);
665
- }
666
- this.#settleClose(ownerAgentId, documentId);
667
- }
668
133
  }
669
134
  //# sourceMappingURL=document-lifecycle.js.map