@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.
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +115 -254
- package/dist/daemon.js.map +1 -1
- package/dist/document-amendment-store.d.ts +94 -46
- package/dist/document-amendment-store.d.ts.map +1 -1
- package/dist/document-amendment-store.js +217 -102
- package/dist/document-amendment-store.js.map +1 -1
- package/dist/document-delivery-transport.d.ts +41 -52
- package/dist/document-delivery-transport.d.ts.map +1 -1
- package/dist/document-delivery-transport.js +6 -156
- package/dist/document-delivery-transport.js.map +1 -1
- package/dist/document-frame-router.d.ts +13 -17
- package/dist/document-frame-router.d.ts.map +1 -1
- package/dist/document-frame-router.js +21 -93
- package/dist/document-frame-router.js.map +1 -1
- package/dist/document-handlers.d.ts +1 -1
- package/dist/document-handlers.d.ts.map +1 -1
- package/dist/document-handlers.js +471 -466
- package/dist/document-handlers.js.map +1 -1
- package/dist/document-handshake.d.ts +4 -1
- package/dist/document-handshake.d.ts.map +1 -1
- package/dist/document-handshake.js +3 -1
- package/dist/document-handshake.js.map +1 -1
- package/dist/document-inbound.d.ts +18 -5
- package/dist/document-inbound.d.ts.map +1 -1
- package/dist/document-inbound.js +94 -110
- package/dist/document-inbound.js.map +1 -1
- package/dist/document-layer.d.ts +28 -67
- package/dist/document-layer.d.ts.map +1 -1
- package/dist/document-layer.js +670 -565
- package/dist/document-layer.js.map +1 -1
- package/dist/document-lifecycle.d.ts +11 -157
- package/dist/document-lifecycle.d.ts.map +1 -1
- package/dist/document-lifecycle.js +26 -561
- package/dist/document-lifecycle.js.map +1 -1
- package/dist/document-publish.d.ts +13 -0
- package/dist/document-publish.d.ts.map +1 -1
- package/dist/document-publish.js +19 -11
- package/dist/document-publish.js.map +1 -1
- package/dist/document-reconcile-engine.d.ts +66 -0
- package/dist/document-reconcile-engine.d.ts.map +1 -0
- package/dist/document-reconcile-engine.js +225 -0
- package/dist/document-reconcile-engine.js.map +1 -0
- package/dist/document-reconcile-scheduler.d.ts +91 -0
- package/dist/document-reconcile-scheduler.d.ts.map +1 -0
- package/dist/document-reconcile-scheduler.js +153 -0
- package/dist/document-reconcile-scheduler.js.map +1 -0
- package/dist/document-rejection.d.ts +2 -0
- package/dist/document-rejection.d.ts.map +1 -1
- package/dist/document-rejection.js +5 -1
- package/dist/document-rejection.js.map +1 -1
- package/dist/document-store.d.ts +18 -220
- package/dist/document-store.d.ts.map +1 -1
- package/dist/document-store.js +95 -717
- package/dist/document-store.js.map +1 -1
- package/dist/initiate-session-handler.d.ts +5 -0
- package/dist/initiate-session-handler.d.ts.map +1 -1
- package/dist/initiate-session-handler.js +1 -0
- package/dist/initiate-session-handler.js.map +1 -1
- package/package.json +5 -5
- package/dist/delivery-sweep-bound.d.ts +0 -79
- package/dist/delivery-sweep-bound.d.ts.map +0 -1
- package/dist/delivery-sweep-bound.js +0 -109
- package/dist/delivery-sweep-bound.js.map +0 -1
- package/dist/document-ack-inbound.d.ts +0 -71
- package/dist/document-ack-inbound.d.ts.map +0 -1
- package/dist/document-ack-inbound.js +0 -210
- package/dist/document-ack-inbound.js.map +0 -1
- package/dist/document-control-notifier.d.ts +0 -107
- package/dist/document-control-notifier.d.ts.map +0 -1
- package/dist/document-control-notifier.js +0 -137
- package/dist/document-control-notifier.js.map +0 -1
- package/dist/document-delivery.d.ts +0 -229
- package/dist/document-delivery.d.ts.map +0 -1
- package/dist/document-delivery.js +0 -449
- package/dist/document-delivery.js.map +0 -1
- package/dist/document-join-store.d.ts +0 -80
- package/dist/document-join-store.d.ts.map +0 -1
- package/dist/document-join-store.js +0 -197
- 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
|
-
#
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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
|
-
|
|
43
|
+
endedFor) {
|
|
150
44
|
this.#store = store;
|
|
151
45
|
this.#logger = logger;
|
|
152
|
-
this.#
|
|
153
|
-
this.#
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
484
|
-
`
|
|
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
|