@cello-protocol/daemon 0.0.168 → 0.0.170

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 (111) hide show
  1. package/dist/agent-id-migration.d.ts.map +1 -1
  2. package/dist/agent-id-migration.js +16 -0
  3. package/dist/agent-id-migration.js.map +1 -1
  4. package/dist/away-detection.d.ts +62 -15
  5. package/dist/away-detection.d.ts.map +1 -1
  6. package/dist/away-detection.js +77 -20
  7. package/dist/away-detection.js.map +1 -1
  8. package/dist/close-session-handler.d.ts.map +1 -1
  9. package/dist/close-session-handler.js +69 -3
  10. package/dist/close-session-handler.js.map +1 -1
  11. package/dist/daemon.d.ts.map +1 -1
  12. package/dist/daemon.js +406 -303
  13. package/dist/daemon.js.map +1 -1
  14. package/dist/delivery-open-registry.d.ts +92 -0
  15. package/dist/delivery-open-registry.d.ts.map +1 -0
  16. package/dist/delivery-open-registry.js +121 -0
  17. package/dist/delivery-open-registry.js.map +1 -0
  18. package/dist/document-amendment-store.d.ts +94 -46
  19. package/dist/document-amendment-store.d.ts.map +1 -1
  20. package/dist/document-amendment-store.js +217 -102
  21. package/dist/document-amendment-store.js.map +1 -1
  22. package/dist/document-delivery-transport.d.ts +51 -53
  23. package/dist/document-delivery-transport.d.ts.map +1 -1
  24. package/dist/document-delivery-transport.js +16 -157
  25. package/dist/document-delivery-transport.js.map +1 -1
  26. package/dist/document-frame-router.d.ts +13 -17
  27. package/dist/document-frame-router.d.ts.map +1 -1
  28. package/dist/document-frame-router.js +21 -93
  29. package/dist/document-frame-router.js.map +1 -1
  30. package/dist/document-handlers.d.ts +1 -1
  31. package/dist/document-handlers.d.ts.map +1 -1
  32. package/dist/document-handlers.js +462 -511
  33. package/dist/document-handlers.js.map +1 -1
  34. package/dist/document-handshake.d.ts +4 -1
  35. package/dist/document-handshake.d.ts.map +1 -1
  36. package/dist/document-handshake.js +3 -1
  37. package/dist/document-handshake.js.map +1 -1
  38. package/dist/document-inbound.d.ts +18 -5
  39. package/dist/document-inbound.d.ts.map +1 -1
  40. package/dist/document-inbound.js +94 -110
  41. package/dist/document-inbound.js.map +1 -1
  42. package/dist/document-layer.d.ts +49 -67
  43. package/dist/document-layer.d.ts.map +1 -1
  44. package/dist/document-layer.js +688 -565
  45. package/dist/document-layer.js.map +1 -1
  46. package/dist/document-lifecycle.d.ts +11 -157
  47. package/dist/document-lifecycle.d.ts.map +1 -1
  48. package/dist/document-lifecycle.js +26 -568
  49. package/dist/document-lifecycle.js.map +1 -1
  50. package/dist/document-publish.d.ts +13 -0
  51. package/dist/document-publish.d.ts.map +1 -1
  52. package/dist/document-publish.js +19 -11
  53. package/dist/document-publish.js.map +1 -1
  54. package/dist/document-reconcile-engine.d.ts +66 -0
  55. package/dist/document-reconcile-engine.d.ts.map +1 -0
  56. package/dist/document-reconcile-engine.js +225 -0
  57. package/dist/document-reconcile-engine.js.map +1 -0
  58. package/dist/document-reconcile-scheduler.d.ts +124 -0
  59. package/dist/document-reconcile-scheduler.d.ts.map +1 -0
  60. package/dist/document-reconcile-scheduler.js +226 -0
  61. package/dist/document-reconcile-scheduler.js.map +1 -0
  62. package/dist/document-rejection.d.ts +2 -0
  63. package/dist/document-rejection.d.ts.map +1 -1
  64. package/dist/document-rejection.js +5 -1
  65. package/dist/document-rejection.js.map +1 -1
  66. package/dist/document-store.d.ts +18 -247
  67. package/dist/document-store.d.ts.map +1 -1
  68. package/dist/document-store.js +95 -827
  69. package/dist/document-store.js.map +1 -1
  70. package/dist/inbound-sessions.d.ts +7 -0
  71. package/dist/inbound-sessions.d.ts.map +1 -1
  72. package/dist/inbound-sessions.js +27 -6
  73. package/dist/inbound-sessions.js.map +1 -1
  74. package/dist/initiate-session-handler.d.ts +5 -0
  75. package/dist/initiate-session-handler.d.ts.map +1 -1
  76. package/dist/initiate-session-handler.js +1 -0
  77. package/dist/initiate-session-handler.js.map +1 -1
  78. package/dist/notification-handlers.d.ts.map +1 -1
  79. package/dist/notification-handlers.js +39 -2
  80. package/dist/notification-handlers.js.map +1 -1
  81. package/dist/session-content-handlers.d.ts.map +1 -1
  82. package/dist/session-content-handlers.js +134 -8
  83. package/dist/session-content-handlers.js.map +1 -1
  84. package/dist/session-node-manager.d.ts +153 -8
  85. package/dist/session-node-manager.d.ts.map +1 -1
  86. package/dist/session-node-manager.js +1376 -35
  87. package/dist/session-node-manager.js.map +1 -1
  88. package/dist/types.d.ts +64 -2
  89. package/dist/types.d.ts.map +1 -1
  90. package/dist/types.js.map +1 -1
  91. package/package.json +5 -5
  92. package/dist/delivery-sweep-bound.d.ts +0 -79
  93. package/dist/delivery-sweep-bound.d.ts.map +0 -1
  94. package/dist/delivery-sweep-bound.js +0 -109
  95. package/dist/delivery-sweep-bound.js.map +0 -1
  96. package/dist/document-ack-inbound.d.ts +0 -71
  97. package/dist/document-ack-inbound.d.ts.map +0 -1
  98. package/dist/document-ack-inbound.js +0 -210
  99. package/dist/document-ack-inbound.js.map +0 -1
  100. package/dist/document-control-notifier.d.ts +0 -124
  101. package/dist/document-control-notifier.d.ts.map +0 -1
  102. package/dist/document-control-notifier.js +0 -176
  103. package/dist/document-control-notifier.js.map +0 -1
  104. package/dist/document-delivery.d.ts +0 -250
  105. package/dist/document-delivery.d.ts.map +0 -1
  106. package/dist/document-delivery.js +0 -608
  107. package/dist/document-delivery.js.map +0 -1
  108. package/dist/document-join-store.d.ts +0 -80
  109. package/dist/document-join-store.d.ts.map +0 -1
  110. package/dist/document-join-store.js +0 -197
  111. package/dist/document-join-store.js.map +0 -1
@@ -30,9 +30,7 @@
30
30
  * display label and reusable after retirement. The M7 session tables join on `agent_name`; that
31
31
  * is a known defect (`DOD-AGENT-ID-JOINKEY-1`), not a precedent to copy.
32
32
  */
33
- import { addColumnIfMissing } from "./column-birth.js";
34
- import { DOCUMENT_AMENDMENTS_CREATE_SQL, walkMembership } from "./document-amendment-store.js";
35
- import { decodeDocumentAmendment } from "@cello-protocol/protocol-types";
33
+ import { DOCUMENT_ENTRIES_CREATE_SQL, dropLegacyEpochColumn, dropLegacyColumns, } from "./document-amendment-store.js";
36
34
  /** Thrown when a read path refuses to materialize over a chain that does not verify. */
37
35
  export class DocumentChainError extends Error {
38
36
  reason;
@@ -63,10 +61,12 @@ const CREATE_ENVELOPES_SQL = `
63
61
  envelope_hash TEXT NOT NULL,
64
62
  sender_agent_id TEXT NOT NULL,
65
63
  doc_prev_hash TEXT,
66
- epoch_id INTEGER NOT NULL,
67
64
  signature BLOB NOT NULL,
68
65
  state_vector BLOB NOT NULL,
69
66
  payload BLOB,
67
+ -- SYNC-G1: the author's governance frontier at authoring (JSON array of entry hashes) —
68
+ -- content's causal link to the governance that made it admissible. Inside the signed TBS.
69
+ governance_parents TEXT NOT NULL DEFAULT '[]',
70
70
  kind TEXT NOT NULL CHECK (kind IN ('update', 'withdrawal', 'rejection')),
71
71
  -- The Yjs clientID the sender SIGNED for (ENVELOPE-1 puts it inside the TBS). Recorded so the
72
72
  -- gate's authorship rule has a binding derived from authenticated data rather than from a seam
@@ -76,33 +76,6 @@ const CREATE_ENVELOPES_SQL = `
76
76
  references_hash TEXT,
77
77
  created_at INTEGER NOT NULL,
78
78
  log_index INTEGER NOT NULL,
79
- -- DELIVERY-1. Pending outbound is DERIVED from these columns rather than held in a queue:
80
- -- a queue in memory does not survive a restart, and a queue in its own table is a second
81
- -- source of truth that can disagree with the log about what was sent. "Unacknowledged
82
- -- envelopes I authored" is the whole definition, and it is a WHERE clause.
83
- -- TWO facts, and they are genuinely different: delivered_at is when the envelope LEFT (or was
84
- -- parked for an offline peer), acked_at is when the peer's daemon said it admitted or rejected
85
- -- it. An earlier version had delivered_at with no real writer — its only assignment was a
86
- -- COALESCE inside the ack, so it always equalled acked_at and the distinction was one the
87
- -- schema could not express. It has a writer now (markDelivered), so the distinction is real:
88
- -- "sent, awaiting confirmation" is exactly the state a store-and-forward transport leaves an
89
- -- envelope in, and an operator asking why something has not landed needs to tell it from
90
- -- "never sent".
91
- delivered_at INTEGER,
92
- acked_at INTEGER,
93
- -- A THIRD fact, and it is not either of the two above. abandoned_at is when WE STOPPED TRYING
94
- -- (the unacked ceiling) — a local decision, and NOT a claim about the peer.
95
- --
96
- -- It exists because the ceiling first stopped delivery by calling markAcked, and acked_at means
97
- -- "the peer's daemon said it admitted or rejected it". Overloading it made withdraw tell the
98
- -- operator "your peer holds it, so it cannot be withdrawn" about an envelope the peer may never
99
- -- have seen. Same class as any other false claim of confirmation, and a column is cheaper.
100
- abandoned_at INTEGER,
101
- -- How many times delivery has been attempted, and when the next attempt is due. On the row,
102
- -- because a backoff that resets on restart is not a backoff — a daemon restarting in a
103
- -- reconnect loop would hammer an unreachable peer at full rate forever.
104
- attempts INTEGER NOT NULL DEFAULT 0,
105
- next_attempt_at INTEGER,
106
79
  PRIMARY KEY (owner_agent_id, document_id, envelope_hash),
107
80
  -- A duplicate index would make ORDER BY log_index non-deterministic, and this log's entire
108
81
  -- value is deterministic replay. Two daemons on one DB file (the orphan-process case this
@@ -153,6 +126,10 @@ const CREATE_QUARANTINE_SQL = `
153
126
  limit_name TEXT,
154
127
  limit_value INTEGER,
155
128
  limit_actual INTEGER,
129
+ -- SYNC-R35: the EXACT signed refusal frame, so the refusal can travel by the ordinary
130
+ -- exchange (re-encoding from columns cannot reproduce the signed bytes). Nullable: rows
131
+ -- born before R35 hold no wire, and the exchange simply has nothing to attach for them.
132
+ rejection_wire BLOB,
156
133
  created_at INTEGER NOT NULL,
157
134
  PRIMARY KEY (owner_agent_id, document_id, rejection_envelope_hash),
158
135
  FOREIGN KEY (owner_agent_id, document_id) REFERENCES documents (owner_agent_id, document_id)
@@ -182,16 +159,6 @@ const CREATE_REJECTIONS_RECEIVED_SQL = `
182
159
  FOREIGN KEY (owner_agent_id, document_id) REFERENCES documents (owner_agent_id, document_id)
183
160
  );
184
161
  `;
185
- /** Mirrors `DocumentLifecycle`'s definition exactly — see the note at the exec site. */
186
- const CREATE_WITHDRAWALS_SQL = `
187
- CREATE TABLE IF NOT EXISTS document_withdrawals (
188
- owner_agent_id TEXT NOT NULL,
189
- document_id TEXT NOT NULL,
190
- envelope_hash TEXT NOT NULL,
191
- created_at INTEGER NOT NULL,
192
- PRIMARY KEY (owner_agent_id, document_id, envelope_hash)
193
- );
194
- `;
195
162
  const CREATE_SNAPSHOTS_SQL = `
196
163
  CREATE TABLE IF NOT EXISTS document_snapshots (
197
164
  owner_agent_id TEXT NOT NULL,
@@ -219,111 +186,61 @@ export class DocumentStore {
219
186
  this.#db = db;
220
187
  this.#logger = logger;
221
188
  this.#db.exec(CREATE_DOCUMENTS_SQL);
189
+ // SYNC-G1 birth-gated column (the consent-migration precedent): a database created before
190
+ // the causal-anchor column exists gains it here, defaulted to the empty frontier — CREATE
191
+ // IF NOT EXISTS cannot add columns, and a client-side migration that fails is unrecoverable
192
+ // on an operator machine, so the ALTER is guarded by the actual table shape.
193
+ const envelopeColumns = this.#db
194
+ .prepare(`PRAGMA table_info(document_envelopes)`)
195
+ .all();
196
+ if (envelopeColumns.length > 0 &&
197
+ !envelopeColumns.some((c) => c.name === "governance_parents")) {
198
+ this.#db.exec(`ALTER TABLE document_envelopes ADD COLUMN governance_parents TEXT NOT NULL DEFAULT '[]'`);
199
+ }
222
200
  // M14B / DOD-MP-AMEND-1 — the amendments table this store READS (currentDocumentEpoch);
223
201
  // DocumentAmendmentStore owns writes. Shared definition, whichever constructs first wins.
224
- this.#db.exec(DOCUMENT_AMENDMENTS_CREATE_SQL);
225
- // M14B / DOD-MP-FANOUT-1 — per-(envelope, holder) delivery state. The envelope row's
226
- // bilateral ack columns cannot carry N answers; this table can, and it is DERIVED
227
- // bookkeeping over the log — the envelope is the truth, a row here is one holder's
228
- // outstanding confirmation. Restart-survivable by construction.
229
- this.#db.exec(`
230
- CREATE TABLE IF NOT EXISTS document_deliveries (
231
- owner_agent_id TEXT NOT NULL,
232
- document_id TEXT NOT NULL,
233
- envelope_hash TEXT NOT NULL,
234
- holder_agent_id TEXT NOT NULL,
235
- delivered_at INTEGER,
236
- acked_at INTEGER,
237
- abandoned_at INTEGER,
238
- attempts INTEGER NOT NULL DEFAULT 0,
239
- -- SENDS is the ceiling's counter; ATTEMPTS is the schedule's. Conflating them was the
240
- -- old worker's defect: five quiet deferrals to an offline peer counted as five unacked
241
- -- sends, and the first real send after they returned abandoned the envelope.
242
- sends INTEGER NOT NULL DEFAULT 0,
243
- next_attempt_at INTEGER,
244
- created_at INTEGER NOT NULL,
245
- PRIMARY KEY (owner_agent_id, document_id, envelope_hash, holder_agent_id)
246
- );
247
- `);
248
- // DOD-MP-INVITE-FANOUT-1 — the admitting amendment gets the SAME durability a content edit
249
- // has. It used to be sent best-effort over direct transport: one shot, no row, no retry, no
250
- // restart survival, so a single failed send lost a membership change permanently while every
251
- // surface reported success. The governance act that decides who is a party to the document is
252
- // the last thing that should be less durable than a typo fix.
253
- //
254
- // A SEPARATE table rather than a `payload_kind` column on `document_deliveries`: that query is
255
- // the hot path every content edit rides, and ordering — the only reason to share a queue — is
256
- // preserved explicitly instead, by draining amendments FIRST in every pass (see
257
- // DocumentDelivery#run). A holder must apply the amendment before any edit authored at the new
258
- // epoch, or they refuse that edit as coming from a non-participant, which is the observed
259
- // symptom.
260
- this.#db.exec(`
261
- CREATE TABLE IF NOT EXISTS document_amendment_deliveries (
262
- owner_agent_id TEXT NOT NULL,
263
- document_id TEXT NOT NULL,
264
- amendment_hash TEXT NOT NULL,
265
- holder_agent_id TEXT NOT NULL,
266
- -- SENT is 'the bytes left this daemon'. ACKED is 'the holder demonstrably applied it'.
267
- -- Conflating them was the review's HIGH-2: a successful send means the frame reached their
268
- -- daemon, and the receiver can still REFUSE to record it (recordAmendment throws on a chain
269
- -- gap or a failed derivation, and the router logs it and answers nothing). Acking on that
270
- -- reproduced the original defect through the new machinery.
271
- sent_at INTEGER,
272
- acked_at INTEGER,
273
- -- RETIRED is not delivered. A holder the chain no longer contains is owed nothing, and
274
- -- recording that as an ack would make the durable record claim a delivery that never
275
- -- happened.
276
- retired_at INTEGER,
277
- attempts INTEGER NOT NULL DEFAULT 0,
278
- next_attempt_at INTEGER,
279
- created_at INTEGER NOT NULL,
280
- PRIMARY KEY (owner_agent_id, document_id, amendment_hash, holder_agent_id)
281
- );
282
- `);
283
- // DOD-MP-CONTROL-DURABLE-1 — close and kill get the same durability the amendment fan-out
284
- // gained. The notifier signed once, sent to each derived holder and persisted nothing, so a
285
- // holder offline at that instant never learned the document had ended and their copy stayed
286
- // open for good — while the operator's own surface reported the close as done.
287
- //
288
- // The BYTES are stored here, unlike the amendment queue which joins the recorded chain. A
289
- // control frame is not a chain event, so there is nothing to join; storing the signed frame is
290
- // the only way a redelivery sends exactly what was signed rather than re-minting it.
291
- this.#db.exec(`
292
- CREATE TABLE IF NOT EXISTS document_control_deliveries (
293
- owner_agent_id TEXT NOT NULL,
294
- document_id TEXT NOT NULL,
295
- verb TEXT NOT NULL,
296
- holder_agent_id TEXT NOT NULL,
297
- frame BLOB NOT NULL,
298
- sent_at INTEGER,
299
- acked_at INTEGER,
300
- retired_at INTEGER,
301
- sends INTEGER NOT NULL DEFAULT 0,
302
- attempts INTEGER NOT NULL DEFAULT 0,
303
- next_attempt_at INTEGER,
304
- created_at INTEGER NOT NULL,
305
- PRIMARY KEY (owner_agent_id, document_id, verb, holder_agent_id)
306
- );
307
- `);
202
+ this.#db.exec(`DROP TABLE IF EXISTS document_amendments`);
203
+ this.#db.exec(DOCUMENT_ENTRIES_CREATE_SQL);
204
+ dropLegacyEpochColumn(this.#db, "document_entries");
308
205
  this.#db.exec(CREATE_ENVELOPES_SQL);
206
+ dropLegacyEpochColumn(this.#db, "document_envelopes");
207
+ // SYNC-AC3 residue. P4 deleted the delivery worker and its ledgers but left DELIVERY-1's
208
+ // bookkeeping columns standing on the envelope log — no writer, no reader, and exactly the
209
+ // per-recipient DEBT the pivot exists to abolish ("X still owes Y this envelope"). Dead
210
+ // columns are not harmless here: the next author to see `next_attempt_at` in the schema
211
+ // reasonably concludes retry state belongs on the row and rebuilds the machine. Dropped on
212
+ // open, birth-gated like the epoch column beside it.
213
+ dropLegacyColumns(this.#db, "document_envelopes", [
214
+ "delivered_at", "acked_at", "abandoned_at", "attempts", "next_attempt_at",
215
+ ]);
309
216
  this.#db.exec(CREATE_QUARANTINE_SQL);
217
+ // SYNC-R35, birth-gated like the epoch drops: a database born earlier lacks the column.
218
+ {
219
+ const cols = this.#db.prepare(`PRAGMA table_info(document_quarantine)`).all();
220
+ if (!cols.some((c) => c.name === "rejection_wire")) {
221
+ this.#db.exec(`ALTER TABLE document_quarantine ADD COLUMN rejection_wire BLOB`);
222
+ }
223
+ }
310
224
  this.#db.exec(CREATE_REJECTIONS_RECEIVED_SQL);
311
- // Owned by DocumentLifecycle, created HERE too because `pendingDeliveries` references it and a
312
- // store used without the lifecycle module is a legitimate configuration. Both statements are
313
- // CREATE TABLE IF NOT EXISTS over the same definition, so whichever runs first wins and the
314
- // other is a no-op — the alternative is a query that throws on a missing table and takes an
315
- // entire delivery pass down with it.
316
- this.#db.exec(CREATE_WITHDRAWALS_SQL);
225
+ // D9 residue sweep (review F5): withdrawals have no reader or writer left.
226
+ this.#db.exec(`DROP TABLE IF EXISTS document_withdrawals`);
227
+ // SYNC-P5 (spec §9): the per-party DISPLAY CACHE last successful exchange and the
228
+ // position that party last claimed. Non-authoritative by construction: no correctness
229
+ // decision reads it (R44); it exists so `cello_doc_list` can say in_sync|behind|unseen
230
+ // without initiating an exchange per row.
231
+ this.#db.exec(`
232
+ CREATE TABLE IF NOT EXISTS document_party_view (
233
+ owner_agent_id TEXT NOT NULL,
234
+ document_id TEXT NOT NULL,
235
+ party_agent_id TEXT NOT NULL,
236
+ last_exchange_ms INTEGER NOT NULL,
237
+ gov_seqs TEXT NOT NULL,
238
+ content_counts TEXT NOT NULL,
239
+ refused TEXT NOT NULL,
240
+ PRIMARY KEY (owner_agent_id, document_id, party_agent_id)
241
+ );
242
+ `);
317
243
  this.#db.exec(CREATE_SNAPSHOTS_SQL);
318
- // COLUMN BIRTH. A daemon that already holds an envelope log must gain the column without
319
- // losing the log. `ALTER TABLE ... ADD COLUMN` throws when it is already there, which is the
320
- // guard — the same pattern `document-handshake.ts` uses, rather than a version number nobody
321
- // maintains.
322
- addColumnIfMissing(this.#db, this.#logger, {
323
- table: "document_envelopes",
324
- column: "abandoned_at",
325
- sql: "ALTER TABLE document_envelopes ADD COLUMN abandoned_at INTEGER",
326
- });
327
244
  // Reading the log in arrival order is the only access pattern that matters.
328
245
  this.#db.exec("CREATE INDEX IF NOT EXISTS idx_document_envelopes_order ON document_envelopes (owner_agent_id, document_id, log_index)");
329
246
  }
@@ -346,16 +263,6 @@ export class DocumentStore {
346
263
  .get(ownerAgentId, documentId);
347
264
  return r ? toDocumentRow(r) : null;
348
265
  }
349
- /**
350
- * Does this daemon hold ANY document, under any owner?
351
- *
352
- * Deliberately unscoped, and that is the point: it is the only question that can tell a delivery
353
- * sweep visiting zero agents apart from a daemon that simply has no documents. Without it the two
354
- * look identical in the log, which is what let an owner-key mismatch hide.
355
- */
356
- anyDocumentExists() {
357
- return this.#db.prepare("SELECT 1 FROM documents LIMIT 1").get() !== undefined;
358
- }
359
266
  listDocuments(ownerAgentId) {
360
267
  const rows = this.#db
361
268
  .prepare("SELECT * FROM documents WHERE owner_agent_id = ? ORDER BY created_at ASC")
@@ -392,14 +299,14 @@ export class DocumentStore {
392
299
  try {
393
300
  info = this.#db
394
301
  .prepare(`INSERT INTO document_envelopes
395
- (owner_agent_id, document_id, envelope_hash, sender_agent_id, doc_prev_hash, epoch_id,
396
- signature, state_vector, payload, kind, references_hash, sender_client_id,
397
- created_at, log_index)
302
+ (owner_agent_id, document_id, envelope_hash, sender_agent_id, doc_prev_hash,
303
+ signature, state_vector, payload, governance_parents, kind, references_hash,
304
+ sender_client_id, created_at, log_index)
398
305
  SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
399
306
  COALESCE((SELECT MAX(log_index) + 1 FROM document_envelopes
400
307
  WHERE owner_agent_id = ? AND document_id = ?), 0)
401
308
  ON CONFLICT (owner_agent_id, document_id, envelope_hash) DO NOTHING`)
402
- .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.senderClientId ?? null, envelope.createdAtMs, ownerAgentId, envelope.documentId);
309
+ .run(ownerAgentId, envelope.documentId, envelope.envelopeHash, envelope.senderAgentId, envelope.docPrevHash, Buffer.from(envelope.signature), Buffer.from(envelope.stateVector), envelope.payload === null ? null : Buffer.from(envelope.payload), JSON.stringify(envelope.governanceParents ?? []), envelope.kind, envelope.referencesEnvelopeHash ?? null, envelope.senderClientId ?? null, envelope.createdAtMs, ownerAgentId, envelope.documentId);
403
310
  }
404
311
  catch (err) {
405
312
  const message = err instanceof Error ? err.message : String(err);
@@ -464,9 +371,9 @@ export class DocumentStore {
464
371
  documentId: q.documentId,
465
372
  senderAgentId: q.rejectedSenderAgentId,
466
373
  docPrevHash: q.rejectedDocPrevHash,
374
+ governanceParents: [],
467
375
  // EXEMPT from current-epoch stamping (M14B Entry 5): a stub is a SYNTHETIC verification
468
376
  // node — never on the wire, never replayed — and the chain walk checks hash linkage only.
469
- epochId: 0,
470
377
  signature: new Uint8Array(0),
471
378
  stateVector: new Uint8Array(0),
472
379
  payload: null,
@@ -580,10 +487,10 @@ export class DocumentStore {
580
487
  .prepare(`INSERT INTO document_quarantine
581
488
  (owner_agent_id, document_id, rejection_envelope_hash, rejected_envelope_hash,
582
489
  rejected_sender_agent_id, rejected_doc_prev_hash, payload, reason, detail,
583
- rule, limit_name, limit_value, limit_actual, created_at)
584
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
490
+ rule, limit_name, limit_value, limit_actual, rejection_wire, created_at)
491
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
585
492
  ON CONFLICT (owner_agent_id, document_id, rejection_envelope_hash) DO NOTHING`)
586
- .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);
493
+ .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.rejectionWire ? Buffer.from(row.rejectionWire) : null, row.createdAtMs);
587
494
  return Number(info.changes) > 0;
588
495
  }
589
496
  listQuarantined(ownerAgentId, documentId) {
@@ -609,6 +516,7 @@ export class DocumentStore {
609
516
  limitName: r["limit_name"] ?? undefined,
610
517
  limitValue: r["limit_value"] ?? undefined,
611
518
  limitActual: r["limit_actual"] ?? undefined,
519
+ rejectionWire: r["rejection_wire"] == null ? undefined : toU8(r["rejection_wire"]),
612
520
  createdAtMs: r["created_at"],
613
521
  }));
614
522
  }
@@ -748,682 +656,46 @@ export class DocumentStore {
748
656
  */
749
657
  /**
750
658
  * The document's CURRENT epoch: the head of its recorded amendment chain, 0 at genesis.
751
- * Trustworthy for stamping because every append site validates (deriveArrangement) before
659
+ * Trustworthy for stamping because every append site rules on admissibility before
752
660
  * recording — a row in document_amendments is post-validation by invariant (M14B Entry 5).
753
661
  */
754
- /**
755
- * DOD-MP-REMOVE-1 — was THIS OWNER written out of the arrangement? DERIVED from the recorded
756
- * chain, never stored: a status column would need a CHECK-constraint rebuild on every operator
757
- * DB, and a stored flag can drift from the chain that actually governs. Forward-only by
758
- * construction — nothing here touches content.
759
- */
760
- removedFromArrangement(ownerAgentId, documentId) {
761
- return this.memberRemoved(ownerAgentId, documentId, ownerAgentId);
762
- }
763
- /** The same walk for ANY agent — the delivery worker asks it about the TARGET (F1). */
764
- memberRemoved(ownerAgentId, documentId, agentId) {
662
+ /** SYNC-P5 (review F6) — per-sender envelope counts without hauling payload blobs. */
663
+ envelopeCountsBySender(ownerAgentId, documentId) {
765
664
  const rows = this.#db
766
- .prepare(`SELECT received_bytes FROM document_amendments
665
+ .prepare(`SELECT sender_agent_id, COUNT(*) AS n FROM document_envelopes
767
666
  WHERE owner_agent_id = ? AND document_id = ?
768
- ORDER BY epoch_id ASC`)
667
+ GROUP BY sender_agent_id`)
769
668
  .all(ownerAgentId, documentId);
770
- // CONTAINED, for the same reason the arrangement read is: `decodeDocumentAmendment` throws
771
- // on bytes this build cannot read, and this runs once PER ROW inside `list` — so one
772
- // undecodable chain took down the operator's whole document list. Reporting not-removed
773
- // here is not a health claim: the same row carries `arrangementUnavailable` naming the
774
- // undecodable chain, so the surface says it cannot answer rather than answering wrongly.
775
- let verdict;
776
- try {
777
- verdict = walkMembership(rows.map((r) => decodeDocumentAmendment(new Uint8Array(r.received_bytes))), agentId);
778
- }
779
- catch (err) {
780
- this.#logger.error("document.membership.undecodable", {
781
- documentId,
782
- agentId,
783
- reason: err instanceof Error ? err.message : String(err),
784
- });
785
- return { removed: false, epochId: null };
786
- }
787
- return { removed: verdict.state === "removed", epochId: verdict.epochId };
788
- }
789
- currentDocumentEpoch(ownerAgentId, documentId) {
790
- const r = this.#db
791
- .prepare(`SELECT MAX(epoch_id) AS max_epoch FROM document_amendments
792
- WHERE owner_agent_id = ? AND document_id = ?`)
793
- .get(ownerAgentId, documentId);
794
- return r?.max_epoch ?? 0;
795
- }
796
- /**
797
- * Append an envelope AND seed its per-holder delivery rows in ONE transaction (review M5):
798
- * a crash between the two left an envelope in the log with zero rows, and the bilateral
799
- * backfill would re-seed only the genesis peer — the fan-out holders silently starved.
800
- * Returns the append verdict unchanged.
801
- */
802
- appendEnvelopeWithDeliveries(ownerAgentId, envelope, holderAgentIds, nowMs) {
803
- this.#db.exec("BEGIN IMMEDIATE");
804
- try {
805
- const appended = this.appendEnvelope(ownerAgentId, envelope);
806
- if (appended) {
807
- this.seedDeliveries(ownerAgentId, envelope.documentId, envelope.envelopeHash, holderAgentIds, nowMs);
808
- }
809
- this.#db.exec("COMMIT");
810
- return appended;
811
- }
812
- catch (err) {
813
- this.#db.exec("ROLLBACK");
814
- throw err;
815
- }
816
- }
817
- /** DOD-MP-FANOUT-1 — one pending row per CURRENT holder for a freshly published envelope. */
818
- seedDeliveries(ownerAgentId, documentId, envelopeHash, holderAgentIds, nowMs) {
819
- const insert = this.#db.prepare(`INSERT INTO document_deliveries
820
- (owner_agent_id, document_id, envelope_hash, holder_agent_id, created_at)
821
- VALUES (?, ?, ?, ?, ?)
822
- ON CONFLICT (owner_agent_id, document_id, envelope_hash, holder_agent_id) DO NOTHING`);
823
- for (const holder of holderAgentIds) {
824
- insert.run(ownerAgentId, documentId, envelopeHash, holder, nowMs);
825
- }
826
- }
827
- /** DOD-MP-CONTROL-DURABLE-1 — one owed row per holder who must learn this document ended. */
828
- seedControlDeliveries(ownerAgentId, documentId, verb, frame, holderAgentIds, nowMs) {
829
- // A FRESH CLOSE IS A FRESH DEBT. `DO NOTHING` meant that once a row was retired, re-running
830
- // the verb — the natural response to `document.control.unconfirmed`, and the thing the ERROR
831
- // effectively asks for — seeded nothing, sent once best-effort, and owed nothing. The operator's
832
- // only recourse restored the exact one-shot behaviour this unit removed, at the moment they
833
- // were acting on our own warning.
834
- const insert = this.#db.prepare(`INSERT INTO document_control_deliveries
835
- (owner_agent_id, document_id, verb, holder_agent_id, frame, created_at)
836
- VALUES (?, ?, ?, ?, ?, ?)
837
- ON CONFLICT (owner_agent_id, document_id, verb, holder_agent_id) DO UPDATE SET
838
- frame = excluded.frame,
839
- retired_at = NULL,
840
- sends = 0,
841
- attempts = 0,
842
- next_attempt_at = NULL
843
- WHERE document_control_deliveries.acked_at IS NULL`);
844
- for (const holder of holderAgentIds) {
845
- insert.run(ownerAgentId, documentId, verb, holder, Buffer.from(frame), nowMs);
846
- }
847
- }
848
- /** Control frames still owed, with the exact signed bytes to re-send. */
849
- pendingControlDeliveries(ownerAgentId, nowMs) {
850
- const rows = this.#db
851
- .prepare(`SELECT holder_agent_id, document_id, verb, sends, attempts, frame
852
- FROM document_control_deliveries
853
- WHERE owner_agent_id = ?
854
- AND acked_at IS NULL AND retired_at IS NULL
855
- AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
856
- ORDER BY created_at ASC`)
857
- .all(ownerAgentId, nowMs);
858
- return rows.map((r) => ({
859
- holderAgentId: r["holder_agent_id"],
860
- documentId: r["document_id"],
861
- verb: r["verb"],
862
- sends: r["sends"] ?? 0,
863
- attempts: r["attempts"] ?? 0,
864
- bytes: new Uint8Array(r["frame"]),
865
- }));
866
- }
867
- /** The frame left this daemon. Spends a SEND against the ceiling and schedules a re-offer. */
868
- markControlSent(ownerAgentId, documentId, verb, holderAgentId, nowMs, retryAfterMs) {
869
- this.#db
870
- .prepare(`UPDATE document_control_deliveries
871
- SET sent_at = ?, sends = sends + 1, next_attempt_at = ?
872
- WHERE owner_agent_id = ? AND document_id = ? AND verb = ? AND holder_agent_id = ?`)
873
- .run(nowMs, nowMs + retryAfterMs, ownerAgentId, documentId, verb, holderAgentId);
874
- const row = this.#db
875
- .prepare(`SELECT sends FROM document_control_deliveries
876
- WHERE owner_agent_id = ? AND document_id = ? AND verb = ? AND holder_agent_id = ?`)
877
- .get(ownerAgentId, documentId, verb, holderAgentId);
878
- return row?.sends ?? 0;
879
- }
880
- /**
881
- * A failed attempt: schedules the next try WITHOUT spending the send ceiling.
882
- *
883
- * The counters are separate for the reason the envelope path already learned the hard way —
884
- * conflating them meant quiet deferrals to an offline peer counted as sends, and the first real
885
- * send after they returned hit the ceiling and gave up.
886
- */
887
- recordControlAttempt(ownerAgentId, documentId, verb, holderAgentId, nextAttemptAtMs) {
888
- this.#db
889
- .prepare(`UPDATE document_control_deliveries
890
- SET attempts = attempts + 1, next_attempt_at = ?
891
- WHERE owner_agent_id = ? AND document_id = ? AND verb = ? AND holder_agent_id = ?`)
892
- .run(nextAttemptAtMs, ownerAgentId, documentId, verb, holderAgentId);
893
- const row = this.#db
894
- .prepare(`SELECT attempts FROM document_control_deliveries
895
- WHERE owner_agent_id = ? AND document_id = ? AND verb = ? AND holder_agent_id = ?`)
896
- .get(ownerAgentId, documentId, verb, holderAgentId);
897
- return row?.attempts ?? 0;
898
- }
899
- /**
900
- * Stop owing this frame. The reason is recorded in a DIFFERENT column on purpose: `acked` means
901
- * the holder is known to have it, `retired` means we stopped trying. A row that says acked when
902
- * nothing confirmed anything is the lie this milestone keeps having to remove.
903
- */
904
- settleControlDelivery(ownerAgentId, documentId, verb, holderAgentId, nowMs, reason) {
905
- const sql = reason === "acked"
906
- ? `UPDATE document_control_deliveries SET acked_at = ?
907
- WHERE owner_agent_id = ? AND document_id = ? AND verb = ? AND holder_agent_id = ?`
908
- : `UPDATE document_control_deliveries SET retired_at = ?
909
- WHERE owner_agent_id = ? AND document_id = ? AND verb = ? AND holder_agent_id = ?`;
910
- this.#db.prepare(sql).run(nowMs, ownerAgentId, documentId, verb, holderAgentId);
911
- }
912
- /**
913
- * DOD-MP-INVITE-FANOUT-1 — one owed row per holder who must learn about this amendment.
914
- *
915
- * The inviter and the subject are excluded by the CALLER, not here: this records what is owed,
916
- * and who is owed it is a governance question the handler has already derived.
917
- */
918
- seedAmendmentDeliveries(ownerAgentId, documentId, amendmentHash, holderAgentIds, nowMs) {
919
- const insert = this.#db.prepare(`INSERT INTO document_amendment_deliveries
920
- (owner_agent_id, document_id, amendment_hash, holder_agent_id, created_at)
921
- VALUES (?, ?, ?, ?, ?)
922
- ON CONFLICT (owner_agent_id, document_id, amendment_hash, holder_agent_id) DO NOTHING`);
923
- for (const holder of holderAgentIds) {
924
- insert.run(ownerAgentId, documentId, amendmentHash, holder, nowMs);
925
- }
926
- }
927
- /**
928
- * Amendments still owed to a holder, with the exact bytes to send. Joined to
929
- * `document_amendments` — the recorded chain IS the payload, so a redelivery can never send
930
- * something the local chain does not contain.
931
- */
932
- pendingAmendmentDeliveries(ownerAgentId, nowMs, opts = {}) {
933
- // HEAD-OF-LINE, PER (DOCUMENT, HOLDER) — and this is a correctness rule, not a nicety.
934
- //
935
- // Ordering by `created_at` among only the rows that are DUE inverts the chain: epoch N fails
936
- // once and takes a backoff, epoch N+1 is seeded due immediately, so the next pass sends N+1
937
- // alone. The receiver then refuses it with `document_amendment_chain_gap` — "an out-of-order
938
- // arrival is retried by its sender, never buffered silently" — and under the old ack-on-send
939
- // there was no retry, so N+1 was lost permanently and the one message that would have explained
940
- // it said the opposite. Proven by the unit review.
941
- //
942
- // So: order by the CHAIN's epoch, and never offer an amendment while an earlier one is still
943
- // outstanding for that same holder. Per (document, holder), never globally — one holder's stuck
944
- // chain must not hold up another's.
945
- const rows = this.#db
946
- .prepare(`SELECT holder_agent_id, document_id, amendment_hash, epoch_id, attempts, received_bytes
947
- FROM (
948
- SELECT d.holder_agent_id, d.document_id, d.amendment_hash, d.attempts,
949
- a.epoch_id, a.received_bytes,
950
- d.next_attempt_at,
951
- ROW_NUMBER() OVER (
952
- PARTITION BY d.document_id, d.holder_agent_id
953
- ORDER BY a.epoch_id ASC
954
- ) AS rn
955
- FROM document_amendment_deliveries d
956
- JOIN document_amendments a
957
- ON a.owner_agent_id = d.owner_agent_id
958
- AND a.document_id = d.document_id
959
- AND a.amendment_hash = d.amendment_hash
960
- WHERE d.owner_agent_id = ?
961
- AND d.acked_at IS NULL
962
- AND d.retired_at IS NULL
963
- )
964
- WHERE rn = 1
965
- AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
966
- ORDER BY document_id ASC, holder_agent_id ASC
967
- LIMIT ?`)
968
- .all(ownerAgentId, nowMs, opts.limit ?? 50);
969
- return rows.map((r) => ({
970
- holderAgentId: r["holder_agent_id"],
971
- documentId: r["document_id"],
972
- amendmentHash: r["amendment_hash"],
973
- epochId: r["epoch_id"] ?? 0,
974
- attempts: r["attempts"] ?? 0,
975
- bytes: new Uint8Array(r["received_bytes"]),
976
- }));
977
- }
978
- /**
979
- * Owed amendment rows with NO matching row in the recorded chain.
980
- *
981
- * The pending query INNER JOINs the chain, which is right — a redelivery must never send bytes
982
- * this daemon's own chain does not contain — but an inner join also makes such a row invisible
983
- * AND owed forever. Surfaced so the worker can say so instead of scanning past it every tick.
984
- */
985
- amendmentDeliveriesWithoutChainRow(ownerAgentId) {
986
- const rows = this.#db
987
- .prepare(`SELECT d.document_id, d.amendment_hash, d.holder_agent_id
988
- FROM document_amendment_deliveries d
989
- LEFT JOIN document_amendments a
990
- ON a.owner_agent_id = d.owner_agent_id
991
- AND a.document_id = d.document_id
992
- AND a.amendment_hash = d.amendment_hash
993
- WHERE d.owner_agent_id = ?
994
- AND d.acked_at IS NULL
995
- AND d.retired_at IS NULL
996
- AND a.amendment_hash IS NULL`)
997
- .all(ownerAgentId);
998
- return rows.map((r) => ({
999
- documentId: r["document_id"],
1000
- amendmentHash: r["amendment_hash"],
1001
- holderAgentId: r["holder_agent_id"],
1002
- }));
1003
- }
1004
- /**
1005
- * The bytes left this daemon. NOT a confirmation — see the `sent_at` note on the table.
1006
- *
1007
- * The row stays owed and is re-offered after `retryAfterMs`, because the only thing that has
1008
- * happened is that their daemon received a frame. Whether it RECORDED it is a separate fact, and
1009
- * the receiver can refuse.
1010
- */
1011
- markAmendmentSent(ownerAgentId, documentId, amendmentHash, holderAgentId, nowMs, retryAfterMs) {
1012
- this.#db
1013
- .prepare(`UPDATE document_amendment_deliveries
1014
- SET sent_at = ?, next_attempt_at = ?
1015
- WHERE owner_agent_id = ? AND document_id = ? AND amendment_hash = ? AND holder_agent_id = ?`)
1016
- .run(nowMs, nowMs + retryAfterMs, ownerAgentId, documentId, amendmentHash, holderAgentId);
669
+ return new Map(rows.map((r) => [r.sender_agent_id, r.n]));
1017
670
  }
1018
- /** The holder demonstrably HAS it. Nothing further is owed for this (amendment, holder). */
1019
- ackAmendmentDelivery(ownerAgentId, documentId, amendmentHash, holderAgentId, nowMs) {
671
+ /** SYNC-P5 record what a party's exchange just showed us (display cache; see the table). */
672
+ recordPartyView(ownerAgentId, documentId, partyAgentId, view, nowMs) {
1020
673
  this.#db
1021
- .prepare(`UPDATE document_amendment_deliveries
1022
- SET acked_at = ?
1023
- WHERE owner_agent_id = ? AND document_id = ? AND amendment_hash = ? AND holder_agent_id = ?`)
1024
- .run(nowMs, ownerAgentId, documentId, amendmentHash, holderAgentId);
1025
- }
1026
- /**
1027
- * PROOF BY EPOCH — settle every amendment a holder has demonstrably applied.
1028
- *
1029
- * There is no amendment ack frame, and inventing one is wire work. But there is already a fact on
1030
- * the wire that PROVES receipt: a holder that acks an envelope authored at epoch E must hold every
1031
- * amendment up to E, because the inbound epoch gate refuses anything whose epoch does not match
1032
- * its own derived arrangement. So their ack of the content IS their ack of the governance that
1033
- * made the content admissible.
1034
- *
1035
- * Returns how many rows it settled, so the caller can say so.
1036
- */
1037
- ackAmendmentsThroughEpoch(ownerAgentId, documentId, holderAgentId, epochId, nowMs) {
1038
- const owed = this.#db
1039
- .prepare(`SELECT d.amendment_hash
1040
- FROM document_amendment_deliveries d
1041
- JOIN document_amendments a
1042
- ON a.owner_agent_id = d.owner_agent_id
1043
- AND a.document_id = d.document_id
1044
- AND a.amendment_hash = d.amendment_hash
1045
- WHERE d.owner_agent_id = ? AND d.document_id = ? AND d.holder_agent_id = ?
1046
- AND d.acked_at IS NULL AND d.retired_at IS NULL
1047
- AND a.epoch_id <= ?`)
1048
- .all(ownerAgentId, documentId, holderAgentId, epochId);
1049
- for (const row of owed) {
1050
- this.ackAmendmentDelivery(ownerAgentId, documentId, row.amendment_hash, holderAgentId, nowMs);
1051
- }
1052
- return owed.length;
1053
- }
1054
- /**
1055
- * The holder is no longer in the chain. Distinct from acked ON PURPOSE: an ack here would make
1056
- * the durable record claim a delivery that never happened.
1057
- */
1058
- retireAmendmentDelivery(ownerAgentId, documentId, amendmentHash, holderAgentId, nowMs) {
1059
- this.#db
1060
- .prepare(`UPDATE document_amendment_deliveries
1061
- SET retired_at = ?
1062
- WHERE owner_agent_id = ? AND document_id = ? AND amendment_hash = ? AND holder_agent_id = ?`)
1063
- .run(nowMs, ownerAgentId, documentId, amendmentHash, holderAgentId);
1064
- }
1065
- /** Record one failed attempt and when to try again; returns THEIR attempt count. */
1066
- recordAmendmentAttempt(ownerAgentId, documentId, amendmentHash, holderAgentId, nextAttemptAtMs) {
1067
- this.#db
1068
- .prepare(`UPDATE document_amendment_deliveries
1069
- SET attempts = attempts + 1, next_attempt_at = ?
1070
- WHERE owner_agent_id = ? AND document_id = ? AND amendment_hash = ? AND holder_agent_id = ?`)
1071
- .run(nextAttemptAtMs, ownerAgentId, documentId, amendmentHash, holderAgentId);
1072
- const row = this.#db
1073
- .prepare(`SELECT attempts FROM document_amendment_deliveries
1074
- WHERE owner_agent_id = ? AND document_id = ? AND amendment_hash = ? AND holder_agent_id = ?`)
1075
- .get(ownerAgentId, documentId, amendmentHash, holderAgentId);
1076
- return row?.attempts ?? 0;
1077
- }
1078
- /**
1079
- * Everything due for delivery, PER HOLDER, joined to its envelope. The window is bounded PER
1080
- * HOLDER (`ROW_NUMBER` over holder partitions) — one holder's backlog must never evict
1081
- * another's rows from the pass, which is the no_peer starvation shape multiplied by N.
1082
- */
1083
- pendingHolderDeliveries(ownerAgentId, nowMs, opts = {}) {
1084
- const limit = opts.perHolderLimit ?? 50;
1085
- const rows = this.#db
1086
- .prepare(
1087
- // holder_attempts ALIASED: e.* carries the envelope row's LEGACY bilateral attempts
1088
- // column, which would silently shadow the per-holder count in the result map.
1089
- `SELECT d.holder_agent_id, d.attempts AS holder_attempts, d.document_id AS delivery_document_id, e.*
1090
- FROM (
1091
- SELECT *, ROW_NUMBER() OVER (
1092
- PARTITION BY holder_agent_id
1093
- ORDER BY created_at ASC, envelope_hash ASC
1094
- ) AS rn
1095
- FROM document_deliveries
1096
- WHERE owner_agent_id = ?
1097
- AND acked_at IS NULL AND abandoned_at IS NULL
1098
- AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
1099
- ) d
1100
- JOIN document_envelopes e
1101
- ON e.owner_agent_id = d.owner_agent_id
1102
- AND e.document_id = d.document_id
1103
- AND e.envelope_hash = d.envelope_hash
1104
- WHERE d.rn <= ?
1105
- ORDER BY d.holder_agent_id ASC, d.created_at ASC`)
1106
- .all(ownerAgentId, nowMs, limit);
1107
- return rows.map((r) => ({
1108
- holderAgentId: r["holder_agent_id"],
1109
- documentId: r["delivery_document_id"],
1110
- attempts: r["holder_attempts"] ?? 0,
1111
- envelope: toEnvelopeRow(r),
1112
- }));
1113
- }
1114
- /** Record one holder's attempt; returns THEIR attempt count. */
1115
- recordHolderAttempt(ownerAgentId, documentId, envelopeHash, holderAgentId, nextAttemptAtMs) {
1116
- this.#db
1117
- .prepare(`UPDATE document_deliveries
1118
- SET attempts = attempts + 1, next_attempt_at = ?
1119
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND holder_agent_id = ?`)
1120
- .run(nextAttemptAtMs, ownerAgentId, documentId, envelopeHash, holderAgentId);
1121
- const r = this.#db
1122
- .prepare(`SELECT attempts FROM document_deliveries
1123
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND holder_agent_id = ?`)
1124
- .get(ownerAgentId, documentId, envelopeHash, holderAgentId);
1125
- return r?.attempts ?? 0;
1126
- }
1127
- /** Settle one holder's confirmation. True exactly when THIS call settled it. */
1128
- ackHolderDelivery(ownerAgentId, documentId, envelopeHash, holderAgentId, nowMs) {
1129
- const r = this.#db
1130
- .prepare(`UPDATE document_deliveries
1131
- SET acked_at = ?, delivered_at = COALESCE(delivered_at, ?)
1132
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND holder_agent_id = ?
1133
- AND acked_at IS NULL AND abandoned_at IS NULL`)
1134
- .run(nowMs, nowMs, ownerAgentId, documentId, envelopeHash, holderAgentId);
1135
- return (r.changes ?? 0) > 0;
1136
- }
1137
- /**
1138
- * Retire EVERY outstanding row for one holder — our decision, announced by the caller.
1139
- * Returns the affected envelope hashes so the caller can reconcile the envelope-level record
1140
- * (an envelope whose every holder-row is settled-with-abandonment is itself abandoned).
1141
- */
1142
- abandonHolderDeliveries(ownerAgentId, documentId, holderAgentId, nowMs) {
1143
- const rows = this.#db
1144
- .prepare(`SELECT envelope_hash FROM document_deliveries
1145
- WHERE owner_agent_id = ? AND document_id = ? AND holder_agent_id = ?
1146
- AND acked_at IS NULL AND abandoned_at IS NULL`)
1147
- .all(ownerAgentId, documentId, holderAgentId);
1148
- this.#db
1149
- .prepare(`UPDATE document_deliveries
1150
- SET abandoned_at = ?
1151
- WHERE owner_agent_id = ? AND document_id = ? AND holder_agent_id = ?
1152
- AND acked_at IS NULL AND abandoned_at IS NULL`)
1153
- .run(nowMs, ownerAgentId, documentId, holderAgentId);
1154
- return rows.map((r) => r.envelope_hash);
1155
- }
1156
- /** Count one REAL send (content left us — live or parked) toward this holder's ceiling. */
1157
- recordHolderSend(ownerAgentId, documentId, envelopeHash, holderAgentId) {
1158
- this.#db
1159
- .prepare(`UPDATE document_deliveries
1160
- SET sends = sends + 1
1161
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND holder_agent_id = ?`)
1162
- .run(ownerAgentId, documentId, envelopeHash, holderAgentId);
1163
- const r = this.#db
1164
- .prepare(`SELECT sends FROM document_deliveries
1165
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND holder_agent_id = ?`)
1166
- .get(ownerAgentId, documentId, envelopeHash, holderAgentId);
1167
- return r?.sends ?? 0;
1168
- }
1169
- /** One holder's copy LEFT the machine — the sent-vs-never-left fact, per holder. */
1170
- markHolderDelivered(ownerAgentId, documentId, envelopeHash, holderAgentId, nowMs) {
1171
- this.#db
1172
- .prepare(`UPDATE document_deliveries
1173
- SET delivered_at = COALESCE(delivered_at, ?)
1174
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND holder_agent_id = ?`)
1175
- .run(nowMs, ownerAgentId, documentId, envelopeHash, holderAgentId);
1176
- }
1177
- /** Has ANY holder's copy of this envelope left the machine? */
1178
- envelopeEverSent(ownerAgentId, envelopeHash) {
1179
- const r = this.#db
1180
- .prepare(`SELECT 1 FROM document_deliveries
1181
- WHERE owner_agent_id = ? AND envelope_hash = ? AND delivered_at IS NOT NULL
1182
- LIMIT 1`)
1183
- .get(ownerAgentId, envelopeHash);
1184
- return r !== undefined;
1185
- }
1186
- /** Reschedule one holder's retry WITHOUT counting an attempt — waiting is not sending. */
1187
- scheduleHolderRetry(ownerAgentId, documentId, envelopeHash, holderAgentId, nextAttemptAtMs) {
1188
- this.#db
1189
- .prepare(`UPDATE document_deliveries
1190
- SET next_attempt_at = ?
1191
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND holder_agent_id = ?`)
1192
- .run(nextAttemptAtMs, ownerAgentId, documentId, envelopeHash, holderAgentId);
1193
- }
1194
- /** Retire ONE (envelope, holder) row — the per-holder unacked ceiling's teeth. */
1195
- abandonSingleHolderDelivery(ownerAgentId, documentId, envelopeHash, holderAgentId, nowMs) {
1196
- this.#db
1197
- .prepare(`UPDATE document_deliveries
1198
- SET abandoned_at = ?
1199
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND holder_agent_id = ?
1200
- AND acked_at IS NULL AND abandoned_at IS NULL`)
1201
- .run(nowMs, ownerAgentId, documentId, envelopeHash, holderAgentId);
1202
- }
1203
- /**
1204
- * No holder still owes a confirmation on this envelope. TRUE with zero rows — the bilateral
1205
- * legacy case, where the envelope-row columns remain the record and this table has nothing to
1206
- * say.
1207
- */
1208
- envelopeFullySettled(ownerAgentId, envelopeHash) {
1209
- const r = this.#db
1210
- .prepare(`SELECT 1 FROM document_deliveries
1211
- WHERE owner_agent_id = ? AND envelope_hash = ?
1212
- AND acked_at IS NULL AND abandoned_at IS NULL
1213
- LIMIT 1`)
1214
- .get(ownerAgentId, envelopeHash);
1215
- return r === undefined;
1216
- }
1217
- /**
1218
- * Seed per-holder rows for LEGACY bilateral envelopes: anything this agent authored that is
1219
- * still unacknowledged on the envelope row and has no per-holder rows yet gets one for the
1220
- * genesis peer. Idempotent (the PK refuses duplicates); run per pass, cheap, and journaled —
1221
- * pre-fan-out backlogs are near-empty at alpha, this is insurance not migration.
1222
- */
1223
- backfillBilateralDeliveries(ownerAgentId, senderAgentId, nowMs) {
1224
- this.#db
1225
- .prepare(`INSERT INTO document_deliveries
1226
- (owner_agent_id, document_id, envelope_hash, holder_agent_id, attempts, created_at)
1227
- SELECT e.owner_agent_id, e.document_id, e.envelope_hash, d.peer_agent_id, e.attempts, ?
1228
- FROM document_envelopes e
1229
- JOIN documents d
1230
- ON d.owner_agent_id = e.owner_agent_id AND d.document_id = e.document_id
1231
- WHERE e.owner_agent_id = ? AND e.sender_agent_id = ?
1232
- AND e.acked_at IS NULL AND e.abandoned_at IS NULL AND e.kind = 'update'
1233
- AND NOT EXISTS (
1234
- SELECT 1 FROM document_deliveries dd
1235
- WHERE dd.owner_agent_id = e.owner_agent_id AND dd.envelope_hash = e.envelope_hash
1236
- )
1237
- ON CONFLICT (owner_agent_id, document_id, envelope_hash, holder_agent_id) DO NOTHING`)
1238
- .run(nowMs, ownerAgentId, senderAgentId);
1239
- }
1240
- /**
1241
- * THIS holder's settlement of THIS envelope — the per-acker twin of `envelopeSettlement`,
1242
- * with rejection attribution taken from the rejections-received rows' `from_agent_id`.
1243
- * Null when the holder has not answered (or has no row — the bilateral-legacy case, where the
1244
- * envelope-level read is the record).
1245
- */
1246
- holderSettlement(ownerAgentId, envelopeHash, holderAgentId) {
674
+ .prepare(`INSERT INTO document_party_view
675
+ (owner_agent_id, document_id, party_agent_id, last_exchange_ms, gov_seqs,
676
+ content_counts, refused)
677
+ VALUES (?, ?, ?, ?, ?, ?, ?)
678
+ ON CONFLICT (owner_agent_id, document_id, party_agent_id) DO UPDATE SET
679
+ last_exchange_ms = excluded.last_exchange_ms,
680
+ gov_seqs = excluded.gov_seqs,
681
+ content_counts = excluded.content_counts,
682
+ refused = excluded.refused`)
683
+ .run(ownerAgentId, documentId, partyAgentId, nowMs, JSON.stringify(view.govSeqs), JSON.stringify(view.contentCounts), JSON.stringify(view.refused));
684
+ }
685
+ partyView(ownerAgentId, documentId, partyAgentId) {
1247
686
  const r = this.#db
1248
- .prepare(`SELECT document_id, acked_at FROM document_deliveries
1249
- WHERE owner_agent_id = ? AND envelope_hash = ? AND holder_agent_id = ?`)
1250
- .get(ownerAgentId, envelopeHash, holderAgentId);
1251
- if (!r || r.acked_at == null)
687
+ .prepare(`SELECT last_exchange_ms, gov_seqs, content_counts, refused FROM document_party_view
688
+ WHERE owner_agent_id = ? AND document_id = ? AND party_agent_id = ?`)
689
+ .get(ownerAgentId, documentId, partyAgentId);
690
+ if (!r)
1252
691
  return null;
1253
692
  return {
1254
- admitted: !this.rejectionReceivedFromFor(ownerAgentId, r.document_id, envelopeHash, holderAgentId),
693
+ lastExchangeMs: r.last_exchange_ms,
694
+ govSeqs: JSON.parse(r.gov_seqs),
695
+ contentCounts: JSON.parse(r.content_counts),
696
+ refused: JSON.parse(r.refused),
1255
697
  };
1256
698
  }
1257
- /** Was a rejection of this envelope received FROM this specific holder? */
1258
- rejectionReceivedFromFor(ownerAgentId, documentId, envelopeHash, fromAgentId) {
1259
- const r = this.#db
1260
- .prepare(`SELECT 1 FROM document_rejections_received
1261
- WHERE owner_agent_id = ? AND document_id = ? AND rejected_envelope_hash = ?
1262
- AND from_agent_id = ?
1263
- LIMIT 1`)
1264
- .get(ownerAgentId, documentId, envelopeHash, fromAgentId);
1265
- return r !== undefined;
1266
- }
1267
- /**
1268
- * ONE deterministic envelope-level terminal rule (FANOUT-1 review M3), applied by every
1269
- * reconciliation site: once no holder is outstanding, the envelope is ACKED if at least one
1270
- * holder acked, ABANDONED only when nobody ever answered. Without one rule, identical final
1271
- * holder states landed as opposite envelope-level facts depending on event order — and
1272
- * `withdraw`'s "your peer holds it" answered differently for the same reality.
1273
- */
1274
- reconcileEnvelopeSettlement(ownerAgentId, documentId, envelopeHash, nowMs) {
1275
- if (!this.envelopeFullySettled(ownerAgentId, envelopeHash))
1276
- return;
1277
- const anyAcked = this.#db
1278
- .prepare(`SELECT 1 FROM document_deliveries
1279
- WHERE owner_agent_id = ? AND envelope_hash = ? AND acked_at IS NOT NULL
1280
- LIMIT 1`)
1281
- .get(ownerAgentId, envelopeHash);
1282
- if (anyAcked !== undefined) {
1283
- this.markAcked(ownerAgentId, documentId, envelopeHash, nowMs);
1284
- }
1285
- else {
1286
- const anyRows = this.#db
1287
- .prepare(`SELECT 1 FROM document_deliveries WHERE owner_agent_id = ? AND envelope_hash = ? LIMIT 1`)
1288
- .get(ownerAgentId, envelopeHash);
1289
- // Zero rows = bilateral legacy — the envelope-level columns are already the record.
1290
- if (anyRows !== undefined) {
1291
- this.markAbandoned(ownerAgentId, documentId, envelopeHash, nowMs);
1292
- }
1293
- }
1294
- }
1295
- /** Does this holder still owe any confirmation on this document? */
1296
- holderHasPending(ownerAgentId, documentId, holderAgentId) {
1297
- const r = this.#db
1298
- .prepare(`SELECT 1 FROM document_deliveries
1299
- WHERE owner_agent_id = ? AND document_id = ? AND holder_agent_id = ?
1300
- AND acked_at IS NULL AND abandoned_at IS NULL
1301
- LIMIT 1`)
1302
- .get(ownerAgentId, documentId, holderAgentId);
1303
- return r !== undefined;
1304
- }
1305
- pendingDeliveries(ownerAgentId, nowMs,
1306
- /**
1307
- * OUR OWN sender id on the wire — the author's pubkey hex (M14-D5).
1308
- *
1309
- * Kept as a separate parameter, and separately named, even though the daemon now scopes the
1310
- * store by that same pubkey hex so the two coincide. They are different FACTS: the owner key
1311
- * says whose local store this row is in, the sender id says who signed the envelope. An
1312
- * earlier version of the daemon scoped by agent name, and the mismatch returned nothing
1313
- * pending — every published update sitting in the log undelivered, with no error anywhere.
1314
- * Collapsing them into one argument makes that class of bug unrepresentable in the call and
1315
- * invisible in the query.
1316
- */
1317
- senderAgentId = ownerAgentId, limit = 100) {
1318
- const rows = this.#db
1319
- .prepare(`SELECT * FROM document_envelopes
1320
- WHERE owner_agent_id = ? AND sender_agent_id = ? AND acked_at IS NULL
1321
- -- ABANDONED rows are not pending. This is what actually stops the worker at the
1322
- -- unacked ceiling: the selection reads no document STATUS, so setting the document
1323
- -- stalled changed what the surface said and nothing about what the worker did, and one
1324
- -- envelope went out 74 times against a cap of 5.
1325
- AND abandoned_at IS NULL
1326
- -- UPDATES only. A withdrawal record is local audit — the update it concerns was never
1327
- -- delivered, so there is nothing for the peer to act on — and a rejection reaches the
1328
- -- peer through the rejection protocol, not this worker. Without the scope the worker
1329
- -- would ship both, and the withdrawal would arrive as a reference to an envelope the
1330
- -- peer has never seen.
1331
- AND kind = 'update'
1332
- -- An ENDED document does not deliver. A killed or closed document that kept shipping
1333
- -- would contradict the verb the operator just used, and the peer would receive updates
1334
- -- on a collaboration they were told had stopped.
1335
- AND EXISTS (
1336
- SELECT 1 FROM documents d
1337
- WHERE d.owner_agent_id = document_envelopes.owner_agent_id
1338
- AND d.document_id = document_envelopes.document_id
1339
- AND d.status NOT IN ('killed', 'closed')
1340
- )
1341
- AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
1342
- -- A WITHDRAWN update is not pending. Derived from the withdrawal record rather than a
1343
- -- flag on the row, so there is one fact in one place: without this the delivery worker
1344
- -- ships the very update the operator just withdrew.
1345
- --
1346
- -- The table is created by DocumentLifecycle, which may not have run — a store used
1347
- -- without it is a legitimate configuration — so the reference is guarded rather than
1348
- -- assumed. A missing table would otherwise throw here and take the whole delivery pass
1349
- -- down with it.
1350
- AND NOT EXISTS (
1351
- SELECT 1 FROM document_withdrawals w
1352
- WHERE w.owner_agent_id = document_envelopes.owner_agent_id
1353
- AND w.document_id = document_envelopes.document_id
1354
- AND w.envelope_hash = document_envelopes.envelope_hash
1355
- )
1356
- -- log_index is PER DOCUMENT, so it alone is not a total order across documents and the
1357
- -- bounded window could be filled by one document's backlog forever. The tiebreaks make
1358
- -- the window deterministic; the no-peer branch scheduling its rows is what stops one
1359
- -- document monopolising it.
1360
- ORDER BY log_index ASC, document_id ASC, envelope_hash ASC LIMIT ?`)
1361
- .all(ownerAgentId, senderAgentId, nowMs, limit);
1362
- return rows.map(toEnvelopeRow);
1363
- }
1364
- /** Record that the peer acknowledged. Idempotent — a redelivered ack must not move the clock. */
1365
- /**
1366
- * How many of our envelopes for this document were ABANDONED — the unacked ceiling fired and they
1367
- * will never be retried.
1368
- *
1369
- * Surfaced because an abandoned envelope leaves every pending counter, so a document that
1370
- * permanently dropped an update is otherwise indistinguishable from one that delivered everything.
1371
- */
1372
- abandonedCount(ownerAgentId, documentId) {
1373
- const row = this.#db
1374
- .prepare(`SELECT COUNT(*) AS n FROM document_envelopes
1375
- WHERE owner_agent_id = ? AND document_id = ? AND sender_agent_id = ? AND abandoned_at IS NOT NULL`)
1376
- .get(ownerAgentId, documentId, ownerAgentId);
1377
- return row?.n ?? 0;
1378
- }
1379
- /**
1380
- * Is this envelope SETTLED — the peer answered it, admitted or rejected?
1381
- *
1382
- * Keyed by envelope hash ALONE, without the document, because the caller waiting on it is the
1383
- * delivery transport, which holds a session open and knows the envelope it sent but has no reason
1384
- * to carry the document id through the wait. The hash is a sha256 over the envelope's own
1385
- * preimage, so it identifies one envelope across every document this owner holds.
1386
- */
1387
- isEnvelopeAcked(ownerAgentId, envelopeHash) {
1388
- return this.envelopeSettlement(ownerAgentId, envelopeHash) !== null;
1389
- }
1390
- /**
1391
- * HOW an envelope was settled, or null if the peer has not answered it.
1392
- *
1393
- * `admitted` is derived from whether a rejection was RECEIVED for it, because `acked_at` records
1394
- * only that the peer answered — a rejection is an ack for delivery purposes, so the two states
1395
- * share that column deliberately (see `DocumentAckInbound`). Reading the answer, not just its
1396
- * existence, is what lets the delivery worker report `delivered` and `rejected` truthfully rather
1397
- * than counting every answered envelope as still in flight.
1398
- */
1399
- envelopeSettlement(ownerAgentId, envelopeHash) {
1400
- const row = this.#db
1401
- .prepare(`SELECT document_id FROM document_envelopes
1402
- WHERE owner_agent_id = ? AND envelope_hash = ? AND acked_at IS NOT NULL LIMIT 1`)
1403
- .get(ownerAgentId, envelopeHash);
1404
- if (row?.document_id === undefined)
1405
- return null;
1406
- return { admitted: !this.rejectionReceivedFor(ownerAgentId, row.document_id, envelopeHash) };
1407
- }
1408
- /**
1409
- * WE GAVE UP — the unacked ceiling. Deliberately not `markAcked`: this records a decision of
1410
- * ours, and says nothing about whether the peer holds the envelope, because we do not know.
1411
- */
1412
- markAbandoned(ownerAgentId, documentId, envelopeHash, nowMs) {
1413
- const info = this.#db
1414
- .prepare(`UPDATE document_envelopes SET abandoned_at = ?
1415
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ?
1416
- AND acked_at IS NULL AND abandoned_at IS NULL`)
1417
- .run(nowMs, ownerAgentId, documentId, envelopeHash);
1418
- return Number(info.changes) > 0;
1419
- }
1420
- markAcked(ownerAgentId, documentId, envelopeHash, nowMs) {
1421
- const info = this.#db
1422
- .prepare(`UPDATE document_envelopes SET acked_at = ?, delivered_at = COALESCE(delivered_at, ?)
1423
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND acked_at IS NULL`)
1424
- .run(nowMs, nowMs, ownerAgentId, documentId, envelopeHash);
1425
- return Number(info.changes) > 0;
1426
- }
1427
699
  /** Record a rejection the PEER sent us. Returns whether a row was written (idempotent by leaf). */
1428
700
  recordRejectionReceived(ownerAgentId, row) {
1429
701
  const info = this.#db
@@ -1627,19 +899,15 @@ function toEnvelopeRow(r) {
1627
899
  documentId: r["document_id"],
1628
900
  senderAgentId: r["sender_agent_id"],
1629
901
  docPrevHash: r["doc_prev_hash"] ?? null,
1630
- epochId: r["epoch_id"],
1631
902
  signature: toU8(r["signature"]),
1632
903
  stateVector: toU8(r["state_vector"]),
1633
904
  payload: payload === null || payload === undefined ? null : toU8(payload),
905
+ governanceParents: JSON.parse(r["governance_parents"] ?? "[]"),
1634
906
  kind: r["kind"],
1635
907
  senderClientId: r["sender_client_id"] ?? null,
1636
908
  referencesEnvelopeHash: r["references_hash"] ?? null,
1637
909
  createdAtMs: r["created_at"],
1638
910
  logIndex: r["log_index"],
1639
- deliveredAtMs: r["delivered_at"] ?? null,
1640
- ackedAtMs: r["acked_at"] ?? null,
1641
- attempts: r["attempts"] ?? 0,
1642
- nextAttemptAtMs: r["next_attempt_at"] ?? null,
1643
911
  };
1644
912
  }
1645
913
  /**