@cello-protocol/daemon 0.0.120 → 0.0.122

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 (68) hide show
  1. package/dist/content-park.d.ts +1 -1
  2. package/dist/content-park.d.ts.map +1 -1
  3. package/dist/content-park.js +64 -12
  4. package/dist/content-park.js.map +1 -1
  5. package/dist/daemon.d.ts.map +1 -1
  6. package/dist/daemon.js +46 -32
  7. package/dist/daemon.js.map +1 -1
  8. package/dist/document-delivery-transport.d.ts +78 -0
  9. package/dist/document-delivery-transport.d.ts.map +1 -0
  10. package/dist/document-delivery-transport.js +109 -0
  11. package/dist/document-delivery-transport.js.map +1 -0
  12. package/dist/document-delivery.d.ts +130 -0
  13. package/dist/document-delivery.d.ts.map +1 -0
  14. package/dist/document-delivery.js +246 -0
  15. package/dist/document-delivery.js.map +1 -0
  16. package/dist/document-engine.d.ts +134 -0
  17. package/dist/document-engine.d.ts.map +1 -0
  18. package/dist/document-engine.js +281 -0
  19. package/dist/document-engine.js.map +1 -0
  20. package/dist/document-gate.d.ts +139 -0
  21. package/dist/document-gate.d.ts.map +1 -0
  22. package/dist/document-gate.js +465 -0
  23. package/dist/document-gate.js.map +1 -0
  24. package/dist/document-handshake.d.ts +88 -0
  25. package/dist/document-handshake.d.ts.map +1 -0
  26. package/dist/document-handshake.js +239 -0
  27. package/dist/document-handshake.js.map +1 -0
  28. package/dist/document-lifecycle.d.ts +104 -0
  29. package/dist/document-lifecycle.d.ts.map +1 -0
  30. package/dist/document-lifecycle.js +363 -0
  31. package/dist/document-lifecycle.js.map +1 -0
  32. package/dist/document-notify.d.ts +130 -0
  33. package/dist/document-notify.d.ts.map +1 -0
  34. package/dist/document-notify.js +313 -0
  35. package/dist/document-notify.js.map +1 -0
  36. package/dist/document-reachability.d.ts +42 -0
  37. package/dist/document-reachability.d.ts.map +1 -0
  38. package/dist/document-reachability.js +72 -0
  39. package/dist/document-reachability.js.map +1 -0
  40. package/dist/document-rejection.d.ts +224 -0
  41. package/dist/document-rejection.d.ts.map +1 -0
  42. package/dist/document-rejection.js +374 -0
  43. package/dist/document-rejection.js.map +1 -0
  44. package/dist/document-store.d.ts +269 -0
  45. package/dist/document-store.d.ts.map +1 -0
  46. package/dist/document-store.js +752 -0
  47. package/dist/document-store.js.map +1 -0
  48. package/dist/document-write-path.d.ts +84 -0
  49. package/dist/document-write-path.d.ts.map +1 -0
  50. package/dist/document-write-path.js +412 -0
  51. package/dist/document-write-path.js.map +1 -0
  52. package/dist/line-lcs.d.ts +51 -0
  53. package/dist/line-lcs.d.ts.map +1 -0
  54. package/dist/line-lcs.js +71 -0
  55. package/dist/line-lcs.js.map +1 -0
  56. package/dist/reconnect-drain.d.ts +15 -0
  57. package/dist/reconnect-drain.d.ts.map +1 -0
  58. package/dist/reconnect-drain.js +51 -0
  59. package/dist/reconnect-drain.js.map +1 -0
  60. package/dist/session-node-manager.d.ts +39 -0
  61. package/dist/session-node-manager.d.ts.map +1 -1
  62. package/dist/session-node-manager.js +152 -12
  63. package/dist/session-node-manager.js.map +1 -1
  64. package/dist/session-relay-client.d.ts +2 -0
  65. package/dist/session-relay-client.d.ts.map +1 -1
  66. package/dist/session-relay-client.js +16 -1
  67. package/dist/session-relay-client.js.map +1 -1
  68. package/package.json +6 -5
@@ -0,0 +1,752 @@
1
+ /**
2
+ * DOD-DOC-STORE-1 — the daemon's three document tables (§16.7-12).
3
+ *
4
+ * TWO LAYERS, NOT ONE (§14). Storing only a merged Yjs binary would discard the signed envelope
5
+ * chain that makes a seal verifiable, so both are kept:
6
+ *
7
+ * `document_envelopes` — the IMMUTABLE log: signatures, provenance, the per-document chain.
8
+ * This is the truth.
9
+ * `document_snapshots` — a MATERIALIZATION for fast start. Disposable by construction:
10
+ * delete it and it rebuilds from the log, byte-identical.
11
+ *
12
+ * The distinction is load-bearing rather than stylistic. Live Yjs state deliberately does NOT
13
+ * survive a daemon restart — CELLO's invariant is daemon-up-is-CELLO-on — and that is only safe
14
+ * because the log makes rebuilding a lookup rather than an archaeology exercise.
15
+ *
16
+ * THIS MODULE PERSISTS; IT DOES NOT APPLY. Replay is INJECTED (`rebuildSnapshot`), so the store
17
+ * owns *what to replay and in what order* while the engine (DOD-DOC-ENGINE-1) owns *how to
18
+ * apply*. That keeps `yjs` out of this file's imports entirely and keeps the P0/P1 boundary
19
+ * honest — a store that quietly grew a Y.Doc would be the engine wearing a store's name.
20
+ *
21
+ * WHAT IS NEVER STORED HERE: a Yjs clientID. §14's one-line rule is "let Yjs mint its own per
22
+ * live Y.Doc; never derive it from agent identity, never persist and restore one." DOD-DOC-FUZZ-1
23
+ * measured the cost of getting this wrong — two live docs sharing a clientID means the colliding
24
+ * writer silently wins, the honest client's update is accepted-and-dropped, and the result is a
25
+ * splice of two authors with an EMPTY pending set and no error on any path. So the snapshot
26
+ * stores a binary and a state vector, and nothing that could be restored into a live document as
27
+ * an identity.
28
+ *
29
+ * KEYED ON STABLE IDS ONLY. `agent_id` and `document_id`, never `agent_name` — it is a mutable
30
+ * display label and reusable after retirement. The M7 session tables join on `agent_name`; that
31
+ * is a known defect (`DOD-AGENT-ID-JOINKEY-1`), not a precedent to copy.
32
+ */
33
+ /** Thrown when a read path refuses to materialize over a chain that does not verify. */
34
+ export class DocumentChainError extends Error {
35
+ reason;
36
+ detail;
37
+ constructor(reason, detail) {
38
+ super(`${reason}: ${detail}`);
39
+ this.name = "DocumentChainError";
40
+ this.reason = reason;
41
+ this.detail = detail;
42
+ }
43
+ }
44
+ const CREATE_DOCUMENTS_SQL = `
45
+ CREATE TABLE IF NOT EXISTS documents (
46
+ owner_agent_id TEXT NOT NULL,
47
+ document_id TEXT NOT NULL,
48
+ peer_agent_id TEXT NOT NULL,
49
+ document_type TEXT NOT NULL,
50
+ properties TEXT NOT NULL,
51
+ status TEXT NOT NULL CHECK (status IN ('active', 'closed', 'killed', 'stalled')),
52
+ created_at INTEGER NOT NULL,
53
+ PRIMARY KEY (owner_agent_id, document_id)
54
+ );
55
+ `;
56
+ const CREATE_ENVELOPES_SQL = `
57
+ CREATE TABLE IF NOT EXISTS document_envelopes (
58
+ owner_agent_id TEXT NOT NULL,
59
+ document_id TEXT NOT NULL,
60
+ envelope_hash TEXT NOT NULL,
61
+ sender_agent_id TEXT NOT NULL,
62
+ doc_prev_hash TEXT,
63
+ epoch_id INTEGER NOT NULL,
64
+ signature BLOB NOT NULL,
65
+ state_vector BLOB NOT NULL,
66
+ payload BLOB,
67
+ kind TEXT NOT NULL CHECK (kind IN ('update', 'withdrawal', 'rejection')),
68
+ references_hash TEXT,
69
+ created_at INTEGER NOT NULL,
70
+ log_index INTEGER NOT NULL,
71
+ -- DELIVERY-1. Pending outbound is DERIVED from these columns rather than held in a queue:
72
+ -- a queue in memory does not survive a restart, and a queue in its own table is a second
73
+ -- source of truth that can disagree with the log about what was sent. "Unacknowledged
74
+ -- envelopes I authored" is the whole definition, and it is a WHERE clause.
75
+ -- TWO facts, and they are genuinely different: delivered_at is when the envelope LEFT (or was
76
+ -- parked for an offline peer), acked_at is when the peer's daemon said it admitted or rejected
77
+ -- it. An earlier version had delivered_at with no real writer — its only assignment was a
78
+ -- COALESCE inside the ack, so it always equalled acked_at and the distinction was one the
79
+ -- schema could not express. It has a writer now (markDelivered), so the distinction is real:
80
+ -- "sent, awaiting confirmation" is exactly the state a store-and-forward transport leaves an
81
+ -- envelope in, and an operator asking why something has not landed needs to tell it from
82
+ -- "never sent".
83
+ delivered_at INTEGER,
84
+ acked_at INTEGER,
85
+ -- How many times delivery has been attempted, and when the next attempt is due. On the row,
86
+ -- because a backoff that resets on restart is not a backoff — a daemon restarting in a
87
+ -- reconnect loop would hammer an unreachable peer at full rate forever.
88
+ attempts INTEGER NOT NULL DEFAULT 0,
89
+ next_attempt_at INTEGER,
90
+ PRIMARY KEY (owner_agent_id, document_id, envelope_hash),
91
+ -- A duplicate index would make ORDER BY log_index non-deterministic, and this log's entire
92
+ -- value is deterministic replay. Two daemons on one DB file (the orphan-process case this
93
+ -- repo has been bitten by) would otherwise each compute the same next index and both insert.
94
+ UNIQUE (owner_agent_id, document_id, log_index),
95
+ -- An audit record carries no content, and replay SKIPS payload-free non-update rows on that
96
+ -- basis. Enforce it here rather than relying on the convention: if a withdrawal ever carried
97
+ -- a payload and were later purged, replay would skip it and rebuild SHORT — the exact
98
+ -- divergence the purged-update refusal exists to prevent.
99
+ CHECK (kind = 'update' OR payload IS NULL),
100
+ -- Rows cannot exist for a document that was never created. The reference store makes the
101
+ -- same call in writing: an unscoped row must be impossible to write, not merely discouraged
102
+ -- by a query convention every future caller has to remember.
103
+ FOREIGN KEY (owner_agent_id, document_id) REFERENCES documents (owner_agent_id, document_id)
104
+ );
105
+ `;
106
+ /**
107
+ * Quarantined updates (DOD-DOC-REJECT-1). A separate table, deliberately:
108
+ *
109
+ * `document_envelopes` cannot hold them — its CHECK forbids a payload on a non-update row — and
110
+ * putting the refused bytes in as an `update` row would be worse, because `replay` applies every
111
+ * payload in order and does not honour references. The refused content would come back on every
112
+ * rebuild, which is the opposite of quarantine.
113
+ *
114
+ * So the log stays clean and replay is untouched, while the bytes a `0x05` leaf references
115
+ * actually survive a restart — which is the whole point of "held, never discarded" (§3.2).
116
+ */
117
+ const CREATE_QUARANTINE_SQL = `
118
+ CREATE TABLE IF NOT EXISTS document_quarantine (
119
+ owner_agent_id TEXT NOT NULL,
120
+ document_id TEXT NOT NULL,
121
+ rejected_envelope_hash TEXT NOT NULL,
122
+ -- The 0x05 leaf THIS row belongs to. It is the PK because one refused envelope can be refused
123
+ -- MORE THAN ONCE, for different reasons, across retry rounds — the rejection protocol's normal
124
+ -- path. Keyed on the REFUSED envelope instead, the second round's bytes, reason, rule and limit
125
+ -- were dropped by ON CONFLICT DO NOTHING with no log line, and the stall message then told the
126
+ -- operator "the most recent reason was" and printed the OLDEST. One 0x05 leaf, one row.
127
+ rejection_envelope_hash TEXT NOT NULL,
128
+ -- The refused envelope's OWN chain link and author, so verifyChainLinkage can bridge across
129
+ -- it. The refused payload is deliberately never written to the log (see the header), which
130
+ -- would otherwise leave the peer's supersession chaining onto a hash that is nowhere.
131
+ rejected_sender_agent_id TEXT NOT NULL,
132
+ rejected_doc_prev_hash TEXT,
133
+ payload BLOB NOT NULL,
134
+ reason TEXT NOT NULL,
135
+ detail TEXT,
136
+ rule TEXT,
137
+ limit_name TEXT,
138
+ limit_value INTEGER,
139
+ limit_actual INTEGER,
140
+ created_at INTEGER NOT NULL,
141
+ PRIMARY KEY (owner_agent_id, document_id, rejection_envelope_hash),
142
+ FOREIGN KEY (owner_agent_id, document_id) REFERENCES documents (owner_agent_id, document_id)
143
+ );
144
+ `;
145
+ /**
146
+ * Rejections we RECEIVED (§3.2 "both sides"). A separate table from `document_quarantine` because
147
+ * the two hold different facts: quarantine holds bytes WE refused, this holds the peer's refusal of
148
+ * bytes we authored — we hold no payload of theirs to quarantine.
149
+ *
150
+ * It is durable rather than a log line because everything an operator needs on the publishing side
151
+ * depends on it surviving a restart: why their work was refused, and how many rounds remain before
152
+ * the document stalls. Without a row, the sending side counted its OWN rejections — which on a pure
153
+ * publisher is zero forever — so the retry bound existed only on the side that never loops.
154
+ */
155
+ const CREATE_REJECTIONS_RECEIVED_SQL = `
156
+ CREATE TABLE IF NOT EXISTS document_rejections_received (
157
+ owner_agent_id TEXT NOT NULL,
158
+ document_id TEXT NOT NULL,
159
+ rejection_envelope_hash TEXT NOT NULL,
160
+ rejected_envelope_hash TEXT NOT NULL,
161
+ from_agent_id TEXT NOT NULL,
162
+ reason TEXT NOT NULL,
163
+ detail TEXT,
164
+ created_at INTEGER NOT NULL,
165
+ PRIMARY KEY (owner_agent_id, document_id, rejection_envelope_hash),
166
+ FOREIGN KEY (owner_agent_id, document_id) REFERENCES documents (owner_agent_id, document_id)
167
+ );
168
+ `;
169
+ /** Mirrors `DocumentLifecycle`'s definition exactly — see the note at the exec site. */
170
+ const CREATE_WITHDRAWALS_SQL = `
171
+ CREATE TABLE IF NOT EXISTS document_withdrawals (
172
+ owner_agent_id TEXT NOT NULL,
173
+ document_id TEXT NOT NULL,
174
+ envelope_hash TEXT NOT NULL,
175
+ created_at INTEGER NOT NULL,
176
+ PRIMARY KEY (owner_agent_id, document_id, envelope_hash)
177
+ );
178
+ `;
179
+ const CREATE_SNAPSHOTS_SQL = `
180
+ CREATE TABLE IF NOT EXISTS document_snapshots (
181
+ owner_agent_id TEXT NOT NULL,
182
+ document_id TEXT NOT NULL,
183
+ binary BLOB NOT NULL,
184
+ state_vector BLOB NOT NULL,
185
+ last_applied_index INTEGER NOT NULL,
186
+ PRIMARY KEY (owner_agent_id, document_id),
187
+ FOREIGN KEY (owner_agent_id, document_id) REFERENCES documents (owner_agent_id, document_id)
188
+ );
189
+ `;
190
+ export class DocumentStore {
191
+ #db;
192
+ #logger;
193
+ /**
194
+ * The underlying handle, for modules that own their OWN tables alongside this one
195
+ * (DocumentLifecycle). Deliberately not a licence to query this store's tables from outside —
196
+ * every one of them has a method here, and a second query path is a second set of rules about
197
+ * scoping that nobody remembers to keep in step.
198
+ */
199
+ get rawDb() {
200
+ return this.#db;
201
+ }
202
+ constructor(db, logger) {
203
+ this.#db = db;
204
+ this.#logger = logger;
205
+ this.#db.exec(CREATE_DOCUMENTS_SQL);
206
+ this.#db.exec(CREATE_ENVELOPES_SQL);
207
+ this.#db.exec(CREATE_QUARANTINE_SQL);
208
+ this.#db.exec(CREATE_REJECTIONS_RECEIVED_SQL);
209
+ // Owned by DocumentLifecycle, created HERE too because `pendingDeliveries` references it and a
210
+ // store used without the lifecycle module is a legitimate configuration. Both statements are
211
+ // CREATE TABLE IF NOT EXISTS over the same definition, so whichever runs first wins and the
212
+ // other is a no-op — the alternative is a query that throws on a missing table and takes an
213
+ // entire delivery pass down with it.
214
+ this.#db.exec(CREATE_WITHDRAWALS_SQL);
215
+ this.#db.exec(CREATE_SNAPSHOTS_SQL);
216
+ // Reading the log in arrival order is the only access pattern that matters.
217
+ this.#db.exec("CREATE INDEX IF NOT EXISTS idx_document_envelopes_order ON document_envelopes (owner_agent_id, document_id, log_index)");
218
+ }
219
+ // ─── documents ────────────────────────────────────────────────────────────
220
+ createDocument(row) {
221
+ this.#db
222
+ .prepare(
223
+ // Scoped to the identity conflict only — a bare OR IGNORE would also swallow the status
224
+ // CHECK, leaving the caller believing a document exists that was never stored, and the
225
+ // failure would surface later as a foreign-key error on the first append.
226
+ `INSERT INTO documents
227
+ (owner_agent_id, document_id, peer_agent_id, document_type, properties, status, created_at)
228
+ VALUES (?, ?, ?, ?, ?, ?, ?)
229
+ ON CONFLICT (owner_agent_id, document_id) DO NOTHING`)
230
+ .run(row.ownerAgentId, row.documentId, row.peerAgentId, row.documentType, JSON.stringify(row.properties), row.status, row.createdAtMs);
231
+ }
232
+ getDocument(ownerAgentId, documentId) {
233
+ const r = this.#db
234
+ .prepare("SELECT * FROM documents WHERE owner_agent_id = ? AND document_id = ?")
235
+ .get(ownerAgentId, documentId);
236
+ return r ? toDocumentRow(r) : null;
237
+ }
238
+ listDocuments(ownerAgentId) {
239
+ const rows = this.#db
240
+ .prepare("SELECT * FROM documents WHERE owner_agent_id = ? ORDER BY created_at ASC")
241
+ .all(ownerAgentId);
242
+ return rows.map(toDocumentRow);
243
+ }
244
+ setDocumentStatus(ownerAgentId, documentId, status) {
245
+ this.#db
246
+ .prepare("UPDATE documents SET status = ? WHERE owner_agent_id = ? AND document_id = ?")
247
+ .run(status, ownerAgentId, documentId);
248
+ }
249
+ // ─── the append-only envelope log ─────────────────────────────────────────
250
+ /**
251
+ * Append an envelope at the next log position.
252
+ *
253
+ * IMMUTABLE AT A HASH: the conflict clause is scoped to the envelope hash ALONE, so a
254
+ * re-delivery — or a peer replaying the same hash with different bytes — cannot overwrite what
255
+ * was recorded. Returns whether a new row was written, so the caller can tell a genuine append
256
+ * from a duplicate rather than inferring it.
257
+ *
258
+ * Scoped deliberately, not written as a bare `OR IGNORE`: that form suppresses CHECK, UNIQUE
259
+ * and NOT NULL as well, which on an append-only log means a malformed `kind` or a colliding
260
+ * `log_index` would be DROPPED and reported to the caller as an already-seen duplicate. Every
261
+ * constraint except the hash conflict must throw, or `false` means three different things the
262
+ * caller cannot tell apart.
263
+ */
264
+ appendEnvelope(ownerAgentId, envelope) {
265
+ // The log index is computed INSIDE the insert, not read first and passed in. A read-then-write
266
+ // is safe within one process (this API is synchronous throughout), but two daemons on one DB
267
+ // file — the orphan-process case this repo has hit before — would each read the same MAX and
268
+ // each insert it. A duplicate index makes ORDER BY log_index non-deterministic, and
269
+ // deterministic replay is this log's entire purpose. The UNIQUE constraint is the backstop.
270
+ let info;
271
+ try {
272
+ info = this.#db
273
+ .prepare(`INSERT INTO document_envelopes
274
+ (owner_agent_id, document_id, envelope_hash, sender_agent_id, doc_prev_hash, epoch_id,
275
+ signature, state_vector, payload, kind, references_hash, created_at, log_index)
276
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
277
+ COALESCE((SELECT MAX(log_index) + 1 FROM document_envelopes
278
+ WHERE owner_agent_id = ? AND document_id = ?), 0)
279
+ ON CONFLICT (owner_agent_id, document_id, envelope_hash) DO NOTHING`)
280
+ .run(ownerAgentId, envelope.documentId, envelope.envelopeHash, envelope.senderAgentId, envelope.docPrevHash, envelope.epochId, Buffer.from(envelope.signature), Buffer.from(envelope.stateVector), envelope.payload === null ? null : Buffer.from(envelope.payload), envelope.kind, envelope.referencesEnvelopeHash ?? null, envelope.createdAtMs, ownerAgentId, envelope.documentId);
281
+ }
282
+ catch (err) {
283
+ const message = err instanceof Error ? err.message : String(err);
284
+ // SQLite says "FOREIGN KEY constraint failed" and nothing else — not which document, not
285
+ // which owner, not that a `documents` row is missing. That is the message an operator meets
286
+ // when an envelope arrives before its document exists, so it has to name its own cause.
287
+ if (message.includes("FOREIGN KEY")) {
288
+ throw new Error(`document_envelope_unscoped: no document ${envelope.documentId.slice(0, 16)}… exists for ` +
289
+ `owner ${ownerAgentId.slice(0, 16)}… — create the document before appending to its log`);
290
+ }
291
+ throw err;
292
+ }
293
+ return Number(info.changes) > 0;
294
+ }
295
+ /**
296
+ * Envelopes at or after `fromIndex`, in log order. This is what makes `lastAppliedIndex` the
297
+ * lookup §14 asks for: resume an incremental rebuild at `lastAppliedIndex + 1` without reading
298
+ * the whole log.
299
+ */
300
+ getEnvelopesSince(ownerAgentId, documentId, fromIndex) {
301
+ const rows = this.#db
302
+ .prepare(`SELECT * FROM document_envelopes
303
+ WHERE owner_agent_id = ? AND document_id = ? AND log_index >= ?
304
+ ORDER BY log_index ASC`)
305
+ .all(ownerAgentId, documentId, fromIndex);
306
+ return rows.map(toEnvelopeRow);
307
+ }
308
+ getEnvelopeLog(ownerAgentId, documentId) {
309
+ const rows = this.#db
310
+ .prepare(`SELECT * FROM document_envelopes
311
+ WHERE owner_agent_id = ? AND document_id = ? ORDER BY log_index ASC`)
312
+ .all(ownerAgentId, documentId);
313
+ return rows.map(toEnvelopeRow);
314
+ }
315
+ /**
316
+ * Verify each sender's `doc_prev_hash` chain independently.
317
+ *
318
+ * The log interleaves both parties' envelopes, so there is no single total order to check —
319
+ * §16.7-5 defines replay set-based per epoch for exactly this reason. What must hold is that
320
+ * every sender's own links form one unbroken chain from a single genesis.
321
+ *
322
+ * A break REFUSES and names the sender and the missing predecessor. Skipping the leaf and
323
+ * carrying on would leave a document that reads as complete while missing operations, which is
324
+ * the silent-divergence failure the chain exists to prevent.
325
+ *
326
+ * LINKAGE ONLY — the name says so deliberately. This verifies no signature and does not check
327
+ * that `envelope_hash` hashes the content; neither is possible here (no key, no encoder). A
328
+ * caller must not read `ok: true` as authenticity — that belongs to the engine.
329
+ */
330
+ verifyChainLinkage(ownerAgentId, documentId) {
331
+ const log = this.getEnvelopeLog(ownerAgentId, documentId);
332
+ // BRIDGE THE REFUSED ENVELOPES. A refused update's payload is never written to the log — that
333
+ // is what makes the refusal real across a restart — but the peer does not know it was refused
334
+ // when it authors the next envelope, so its supersession chains onto a hash the log does not
335
+ // contain. Without the bridge that reads as `document_chain_broken`, the document refuses to
336
+ // rebuild, and the operator is sent to debug the chain layer for a rejection-protocol event.
337
+ //
338
+ // These stubs exist ONLY for verification. They carry no payload and are never returned by
339
+ // `getEnvelopeLog`, so replay cannot see them and the refused content cannot come back.
340
+ const stubs = this.listQuarantined(ownerAgentId, documentId).map((q) => ({
341
+ envelopeHash: q.rejectedEnvelopeHash,
342
+ documentId: q.documentId,
343
+ senderAgentId: q.rejectedSenderAgentId,
344
+ docPrevHash: q.rejectedDocPrevHash,
345
+ epochId: 0,
346
+ signature: new Uint8Array(0),
347
+ stateVector: new Uint8Array(0),
348
+ payload: null,
349
+ kind: "rejection",
350
+ referencesEnvelopeHash: null,
351
+ createdAtMs: q.createdAtMs,
352
+ }));
353
+ // One refused envelope can carry several quarantine rows (one per retry round), and the chain
354
+ // has exactly one node for it.
355
+ const seenStub = new Set();
356
+ const bySender = new Map();
357
+ for (const stub of stubs) {
358
+ if (seenStub.has(stub.envelopeHash))
359
+ continue;
360
+ seenStub.add(stub.envelopeHash);
361
+ const list = bySender.get(stub.senderAgentId);
362
+ if (list)
363
+ list.push(stub);
364
+ else
365
+ bySender.set(stub.senderAgentId, [stub]);
366
+ }
367
+ for (const e of log) {
368
+ const list = bySender.get(e.senderAgentId);
369
+ if (list)
370
+ list.push(e);
371
+ else
372
+ bySender.set(e.senderAgentId, [e]);
373
+ }
374
+ for (const [sender, envelopes] of bySender) {
375
+ const present = new Set(envelopes.map((e) => e.envelopeHash));
376
+ const genesis = envelopes.filter((e) => e.docPrevHash === null);
377
+ if (genesis.length !== 1) {
378
+ return {
379
+ ok: false,
380
+ reason: "document_chain_forked",
381
+ detail: `sender ${sender.slice(0, 16)}… has ${genesis.length} genesis envelopes for document ` +
382
+ `${documentId.slice(0, 16)}… — a chain has exactly one`,
383
+ };
384
+ }
385
+ // Every link must resolve. Checked first so a missing predecessor reports as BROKEN rather
386
+ // than as the unreachability it would also cause.
387
+ for (const e of envelopes) {
388
+ if (e.docPrevHash !== null && !present.has(e.docPrevHash)) {
389
+ return {
390
+ ok: false,
391
+ reason: "document_chain_broken",
392
+ detail: `sender ${sender.slice(0, 16)}… envelope ${e.envelopeHash.slice(0, 16)}… links to ` +
393
+ `${e.docPrevHash.slice(0, 16)}…, which is absent from the log and is not among this ` +
394
+ `document's refused envelopes either — so this is a gap in the chain itself, not a ` +
395
+ `rejection`,
396
+ };
397
+ }
398
+ }
399
+ // REACHABILITY, not structural heuristics. Walk forward from the one genesis and require
400
+ // the walk to cover every envelope this sender authored. That single check subsumes the
401
+ // duplicate-genesis case, the duplicate-predecessor fork, AND every cycle shape — including
402
+ // a disjoint cycle sitting alongside a perfectly good genesis chain, which counting roots
403
+ // and predecessors cannot see. `doc_prev_hash` is peer-controlled, so this is a hostile
404
+ // input path and the verifier is the only thing between a crafted log and a persisted
405
+ // snapshot.
406
+ const childOf = new Map();
407
+ for (const e of envelopes) {
408
+ if (e.docPrevHash === null)
409
+ continue;
410
+ const siblings = childOf.get(e.docPrevHash);
411
+ if (siblings)
412
+ siblings.push(e);
413
+ else
414
+ childOf.set(e.docPrevHash, [e]);
415
+ }
416
+ const reached = new Set();
417
+ let cursor = genesis[0];
418
+ while (cursor) {
419
+ if (reached.has(cursor.envelopeHash))
420
+ break; // defensive; a cycle cannot include genesis
421
+ reached.add(cursor.envelopeHash);
422
+ const children = childOf.get(cursor.envelopeHash) ?? [];
423
+ if (children.length > 1) {
424
+ return {
425
+ ok: false,
426
+ reason: "document_chain_forked",
427
+ detail: `sender ${sender.slice(0, 16)}… has ${children.length} envelopes claiming predecessor ` +
428
+ `${cursor.envelopeHash.slice(0, 16)}… — a chain branches nowhere`,
429
+ };
430
+ }
431
+ cursor = children[0];
432
+ }
433
+ if (reached.size !== envelopes.length) {
434
+ return {
435
+ ok: false,
436
+ reason: "document_chain_forked",
437
+ detail: `sender ${sender.slice(0, 16)}… has ${envelopes.length - reached.size} envelopes ` +
438
+ `unreachable from its genesis for document ${documentId.slice(0, 16)}… — a detached ` +
439
+ `cycle or branch, not a chain`,
440
+ };
441
+ }
442
+ }
443
+ return { ok: true };
444
+ }
445
+ // ─── quarantine (held, never discarded) ───────────────────────────────────
446
+ /**
447
+ * Hold a refused update's bytes. Idempotent per REJECTION (per 0x05 leaf), not per refused
448
+ * envelope — one envelope may be refused across several retry rounds and each round's reason,
449
+ * rule and limit are distinct facts an operator needs.
450
+ *
451
+ * Returns whether a row was written, so a caller never has to infer it. The previous signature
452
+ * returned void and the conflict clause silently dropped every round after the first.
453
+ */
454
+ holdQuarantined(ownerAgentId, row) {
455
+ const info = this.#db
456
+ .prepare(`INSERT INTO document_quarantine
457
+ (owner_agent_id, document_id, rejection_envelope_hash, rejected_envelope_hash,
458
+ rejected_sender_agent_id, rejected_doc_prev_hash, payload, reason, detail,
459
+ rule, limit_name, limit_value, limit_actual, created_at)
460
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
461
+ ON CONFLICT (owner_agent_id, document_id, rejection_envelope_hash) DO NOTHING`)
462
+ .run(ownerAgentId, row.documentId, row.rejectionEnvelopeHash, row.rejectedEnvelopeHash, row.rejectedSenderAgentId, row.rejectedDocPrevHash, Buffer.from(row.payload), row.reason, row.detail ?? null, row.rule ?? null, row.limitName ?? null, row.limitValue ?? null, row.limitActual ?? null, row.createdAtMs);
463
+ return Number(info.changes) > 0;
464
+ }
465
+ listQuarantined(ownerAgentId, documentId) {
466
+ const rows = this.#db
467
+ .prepare(
468
+ // rowid breaks the tie. Several rounds can land inside one millisecond, and `created_at`
469
+ // alone then leaves the order to SQLite — which is what an operator is shown as "the most
470
+ // recent reason". Insertion order is the real answer and rowid is it.
471
+ `SELECT * FROM document_quarantine
472
+ WHERE owner_agent_id = ? AND document_id = ?
473
+ ORDER BY created_at ASC, rowid ASC`)
474
+ .all(ownerAgentId, documentId);
475
+ return rows.map((r) => ({
476
+ documentId: r["document_id"],
477
+ rejectionEnvelopeHash: r["rejection_envelope_hash"],
478
+ rejectedEnvelopeHash: r["rejected_envelope_hash"],
479
+ rejectedSenderAgentId: r["rejected_sender_agent_id"],
480
+ rejectedDocPrevHash: r["rejected_doc_prev_hash"] ?? null,
481
+ payload: toU8(r["payload"]),
482
+ reason: r["reason"],
483
+ detail: r["detail"] ?? undefined,
484
+ rule: r["rule"] ?? undefined,
485
+ limitName: r["limit_name"] ?? undefined,
486
+ limitValue: r["limit_value"] ?? undefined,
487
+ limitActual: r["limit_actual"] ?? undefined,
488
+ createdAtMs: r["created_at"],
489
+ }));
490
+ }
491
+ /**
492
+ * Release every entry for a refused envelope once its superseding update has been admitted.
493
+ * Returns whether anything was released. Keyed by the REFUSED envelope rather than the rejection
494
+ * leaf, because admitting the supersession resolves all of that envelope's rounds at once.
495
+ */
496
+ releaseQuarantined(ownerAgentId, documentId, rejectedEnvelopeHash) {
497
+ const info = this.#db
498
+ .prepare(`DELETE FROM document_quarantine
499
+ WHERE owner_agent_id = ? AND document_id = ? AND rejected_envelope_hash = ?`)
500
+ .run(ownerAgentId, documentId, rejectedEnvelopeHash);
501
+ return Number(info.changes) > 0;
502
+ }
503
+ /** The rejecting agent's most recent envelope for this document, for chain linkage. */
504
+ lastEnvelopeHashBySender(ownerAgentId, documentId, senderAgentId) {
505
+ const r = this.#db
506
+ .prepare(`SELECT envelope_hash FROM document_envelopes
507
+ WHERE owner_agent_id = ? AND document_id = ? AND sender_agent_id = ?
508
+ ORDER BY log_index DESC LIMIT 1`)
509
+ .get(ownerAgentId, documentId, senderAgentId);
510
+ return r?.envelope_hash ?? null;
511
+ }
512
+ // ─── delivery (DELIVERY-1) ────────────────────────────────────────────────
513
+ /**
514
+ * Envelopes this agent authored that the peer has not acknowledged, and whose next attempt is
515
+ * due. DERIVED — there is no queue. Survives a restart because the log does.
516
+ *
517
+ * Scoped to `senderAgentId = ownerAgentId`: an envelope we RECEIVED is not ours to deliver, and
518
+ * without the scope a receiver would helpfully re-send the sender's own updates back at it.
519
+ */
520
+ pendingDeliveries(ownerAgentId, nowMs, limit = 100) {
521
+ const rows = this.#db
522
+ .prepare(`SELECT * FROM document_envelopes
523
+ WHERE owner_agent_id = ? AND sender_agent_id = ? AND acked_at IS NULL
524
+ -- UPDATES only. A withdrawal record is local audit — the update it concerns was never
525
+ -- delivered, so there is nothing for the peer to act on — and a rejection reaches the
526
+ -- peer through the rejection protocol, not this worker. Without the scope the worker
527
+ -- would ship both, and the withdrawal would arrive as a reference to an envelope the
528
+ -- peer has never seen.
529
+ AND kind = 'update'
530
+ -- An ENDED document does not deliver. A killed or closed document that kept shipping
531
+ -- would contradict the verb the operator just used, and the peer would receive updates
532
+ -- on a collaboration they were told had stopped.
533
+ AND EXISTS (
534
+ SELECT 1 FROM documents d
535
+ WHERE d.owner_agent_id = document_envelopes.owner_agent_id
536
+ AND d.document_id = document_envelopes.document_id
537
+ AND d.status NOT IN ('killed', 'closed')
538
+ )
539
+ AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
540
+ -- A WITHDRAWN update is not pending. Derived from the withdrawal record rather than a
541
+ -- flag on the row, so there is one fact in one place: without this the delivery worker
542
+ -- ships the very update the operator just withdrew.
543
+ --
544
+ -- The table is created by DocumentLifecycle, which may not have run — a store used
545
+ -- without it is a legitimate configuration — so the reference is guarded rather than
546
+ -- assumed. A missing table would otherwise throw here and take the whole delivery pass
547
+ -- down with it.
548
+ AND NOT EXISTS (
549
+ SELECT 1 FROM document_withdrawals w
550
+ WHERE w.owner_agent_id = document_envelopes.owner_agent_id
551
+ AND w.document_id = document_envelopes.document_id
552
+ AND w.envelope_hash = document_envelopes.envelope_hash
553
+ )
554
+ -- log_index is PER DOCUMENT, so it alone is not a total order across documents and the
555
+ -- bounded window could be filled by one document's backlog forever. The tiebreaks make
556
+ -- the window deterministic; the no-peer branch scheduling its rows is what stops one
557
+ -- document monopolising it.
558
+ ORDER BY log_index ASC, document_id ASC, envelope_hash ASC LIMIT ?`)
559
+ .all(ownerAgentId, ownerAgentId, nowMs, limit);
560
+ return rows.map(toEnvelopeRow);
561
+ }
562
+ /** Mark an attempt: bumps the counter and schedules the next one. Returns the new count. */
563
+ recordDeliveryAttempt(ownerAgentId, documentId, envelopeHash, nextAttemptAtMs) {
564
+ this.#db
565
+ .prepare(`UPDATE document_envelopes
566
+ SET attempts = attempts + 1, next_attempt_at = ?
567
+ WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ?`)
568
+ .run(nextAttemptAtMs, ownerAgentId, documentId, envelopeHash);
569
+ const r = this.#db
570
+ .prepare(`SELECT attempts FROM document_envelopes
571
+ WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ?`)
572
+ .get(ownerAgentId, documentId, envelopeHash);
573
+ return r?.attempts ?? 0;
574
+ }
575
+ /** Record that the envelope left. Not an ack — the peer has not answered yet. */
576
+ markDelivered(ownerAgentId, documentId, envelopeHash, nowMs) {
577
+ const info = this.#db
578
+ .prepare(`UPDATE document_envelopes SET delivered_at = COALESCE(delivered_at, ?)
579
+ WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ?`)
580
+ .run(nowMs, ownerAgentId, documentId, envelopeHash);
581
+ return Number(info.changes) > 0;
582
+ }
583
+ /** Record that the peer acknowledged. Idempotent — a redelivered ack must not move the clock. */
584
+ markAcked(ownerAgentId, documentId, envelopeHash, nowMs) {
585
+ const info = this.#db
586
+ .prepare(`UPDATE document_envelopes SET acked_at = ?, delivered_at = COALESCE(delivered_at, ?)
587
+ WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND acked_at IS NULL`)
588
+ .run(nowMs, nowMs, ownerAgentId, documentId, envelopeHash);
589
+ return Number(info.changes) > 0;
590
+ }
591
+ /** Record a rejection the PEER sent us. Returns whether a row was written (idempotent by leaf). */
592
+ recordRejectionReceived(ownerAgentId, row) {
593
+ const info = this.#db
594
+ .prepare(`INSERT INTO document_rejections_received
595
+ (owner_agent_id, document_id, rejection_envelope_hash, rejected_envelope_hash,
596
+ from_agent_id, reason, detail, created_at)
597
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
598
+ ON CONFLICT (owner_agent_id, document_id, rejection_envelope_hash) DO NOTHING`)
599
+ .run(ownerAgentId, row.documentId, row.rejectionEnvelopeHash, row.rejectedEnvelopeHash, row.fromAgentId, row.reason, row.detail ?? null, row.createdAtMs);
600
+ return Number(info.changes) > 0;
601
+ }
602
+ /** How many rejections this document has RECEIVED — the publishing side's retry round. */
603
+ countRejectionsReceived(ownerAgentId, documentId) {
604
+ const r = this.#db
605
+ .prepare(`SELECT COUNT(*) AS n FROM document_rejections_received
606
+ WHERE owner_agent_id = ? AND document_id = ?`)
607
+ .get(ownerAgentId, documentId);
608
+ return r?.n ?? 0;
609
+ }
610
+ /** The most recently received rejection, for the reason an operator is shown on a stall. */
611
+ latestRejectionReceived(ownerAgentId, documentId) {
612
+ const r = this.#db
613
+ .prepare(`SELECT reason, detail, from_agent_id FROM document_rejections_received
614
+ WHERE owner_agent_id = ? AND document_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1`)
615
+ .get(ownerAgentId, documentId);
616
+ if (!r)
617
+ return null;
618
+ return {
619
+ reason: r["reason"],
620
+ detail: r["detail"] ?? undefined,
621
+ fromAgentId: r["from_agent_id"],
622
+ };
623
+ }
624
+ /**
625
+ * How many rejection records this document carries, PER REJECTING AGENT — the retry round.
626
+ *
627
+ * Scoped by author, because a mutual exchange puts both directions' 0x05 leaves in one document
628
+ * log. Counting across senders conflated them, so two peers each rejecting once read as round 2
629
+ * and the document stalled at half the intended rounds.
630
+ */
631
+ countRejections(ownerAgentId, documentId, rejectingAgentId) {
632
+ const r = this.#db
633
+ .prepare(`SELECT COUNT(*) AS n FROM document_envelopes
634
+ WHERE owner_agent_id = ? AND document_id = ? AND kind = 'rejection'
635
+ AND sender_agent_id = ?`)
636
+ .get(ownerAgentId, documentId, rejectingAgentId);
637
+ return r?.n ?? 0;
638
+ }
639
+ // ─── the snapshot (disposable) ────────────────────────────────────────────
640
+ getSnapshot(ownerAgentId, documentId) {
641
+ const r = this.#db
642
+ .prepare("SELECT * FROM document_snapshots WHERE owner_agent_id = ? AND document_id = ?")
643
+ .get(ownerAgentId, documentId);
644
+ if (!r)
645
+ return null;
646
+ return {
647
+ binary: toU8(r["binary"]),
648
+ stateVector: toU8(r["state_vector"]),
649
+ lastAppliedIndex: r["last_applied_index"],
650
+ };
651
+ }
652
+ /** REPLACES — a snapshot is a cache of the log, not a second log. */
653
+ putSnapshot(ownerAgentId, documentId, snapshot) {
654
+ this.#db
655
+ .prepare(`INSERT OR REPLACE INTO document_snapshots
656
+ (owner_agent_id, document_id, binary, state_vector, last_applied_index)
657
+ VALUES (?, ?, ?, ?, ?)`)
658
+ .run(ownerAgentId, documentId, Buffer.from(snapshot.binary), Buffer.from(snapshot.stateVector), snapshot.lastAppliedIndex);
659
+ }
660
+ deleteSnapshot(ownerAgentId, documentId) {
661
+ this.#db
662
+ .prepare("DELETE FROM document_snapshots WHERE owner_agent_id = ? AND document_id = ?")
663
+ .run(ownerAgentId, documentId);
664
+ }
665
+ /**
666
+ * Rebuild a snapshot from the envelope log alone, using the caller's replay function.
667
+ *
668
+ * Payload-stripped envelopes (purged, or withdrawal/rejection records that carry none)
669
+ * contribute nothing to replay but STILL COUNT toward `lastAppliedIndex` — otherwise the next
670
+ * incremental rebuild would start behind them and replay them forever.
671
+ */
672
+ rebuildSnapshot(ownerAgentId, documentId, replay) {
673
+ // VERIFY BEFORE REPLAY. Rebuilding over an unverified chain is the exact silent divergence
674
+ // the chain exists to prevent: a log with a missing predecessor folds into a state that is
675
+ // quietly short of operations, and putSnapshot would then persist it as authoritative.
676
+ // Refusing is loud; a short document is not.
677
+ const verdict = this.verifyChainLinkage(ownerAgentId, documentId);
678
+ if (!verdict.ok) {
679
+ this.#logger.warn("document.chain.broken", {
680
+ documentId,
681
+ reason: verdict.reason,
682
+ detail: verdict.detail,
683
+ });
684
+ throw new DocumentChainError(verdict.reason, verdict.detail);
685
+ }
686
+ const log = this.getEnvelopeLog(ownerAgentId, documentId);
687
+ // The WHOLE log, in order — including withdrawal and rejection records, which carry no
688
+ // payload. The engine needs those rows to tell an expected payload-free AUDIT record from a
689
+ // PURGED update whose bytes are gone; filtering here would hide that distinction and make the
690
+ // row-shaped signature pointless. The store decides ORDER; the engine decides WHAT COUNTS.
691
+ const { binary, stateVector } = replay(log);
692
+ this.#logger.info("document.snapshot.rebuilt", {
693
+ documentId,
694
+ envelopes: log.length,
695
+ withPayload: log.filter((e) => e.payload !== null).length,
696
+ });
697
+ // The LAST ROW'S log_index, not the array length. They coincide only while indices are dense,
698
+ // which is an accident of how they are assigned rather than a guarantee.
699
+ return { binary, stateVector, lastAppliedIndex: log.at(-1)?.logIndex ?? -1 };
700
+ }
701
+ }
702
+ function toDocumentRow(r) {
703
+ return {
704
+ documentId: r["document_id"],
705
+ ownerAgentId: r["owner_agent_id"],
706
+ peerAgentId: r["peer_agent_id"],
707
+ documentType: r["document_type"],
708
+ properties: JSON.parse(r["properties"]),
709
+ status: r["status"],
710
+ createdAtMs: r["created_at"],
711
+ };
712
+ }
713
+ function toEnvelopeRow(r) {
714
+ const payload = r["payload"];
715
+ return {
716
+ envelopeHash: r["envelope_hash"],
717
+ documentId: r["document_id"],
718
+ senderAgentId: r["sender_agent_id"],
719
+ docPrevHash: r["doc_prev_hash"] ?? null,
720
+ epochId: r["epoch_id"],
721
+ signature: toU8(r["signature"]),
722
+ stateVector: toU8(r["state_vector"]),
723
+ payload: payload === null || payload === undefined ? null : toU8(payload),
724
+ kind: r["kind"],
725
+ referencesEnvelopeHash: r["references_hash"] ?? null,
726
+ createdAtMs: r["created_at"],
727
+ logIndex: r["log_index"],
728
+ deliveredAtMs: r["delivered_at"] ?? null,
729
+ ackedAtMs: r["acked_at"] ?? null,
730
+ attempts: r["attempts"] ?? 0,
731
+ nextAttemptAtMs: r["next_attempt_at"] ?? null,
732
+ };
733
+ }
734
+ /**
735
+ * Normalize a SQLite BLOB (Buffer / Uint8Array / ArrayBuffer) to a Uint8Array.
736
+ *
737
+ * REFUSES anything else rather than substituting an empty array. This is the read path for
738
+ * `signature`, `state_vector` and `payload`: returning `new Uint8Array()` for an unexpected type
739
+ * would hand back a ZERO-LENGTH SIGNATURE as if it were the stored one, and the failure would
740
+ * surface downstream as "signature invalid" — sending an operator to the crypto layer for what
741
+ * is a storage-read defect. Nothing legitimate reaches this branch; `null` is handled by callers.
742
+ */
743
+ function toU8(v) {
744
+ if (v instanceof Uint8Array)
745
+ return v;
746
+ if (Buffer.isBuffer(v))
747
+ return new Uint8Array(v);
748
+ if (v instanceof ArrayBuffer)
749
+ return new Uint8Array(v);
750
+ throw new Error(`document_store_blob_decode_failed: expected a BLOB, got ${v === null ? "null" : typeof v}`);
751
+ }
752
+ //# sourceMappingURL=document-store.js.map