@cello-protocol/daemon 0.0.167 → 0.0.169

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/daemon.d.ts.map +1 -1
  2. package/dist/daemon.js +115 -254
  3. package/dist/daemon.js.map +1 -1
  4. package/dist/document-amendment-store.d.ts +94 -46
  5. package/dist/document-amendment-store.d.ts.map +1 -1
  6. package/dist/document-amendment-store.js +217 -102
  7. package/dist/document-amendment-store.js.map +1 -1
  8. package/dist/document-delivery-transport.d.ts +41 -52
  9. package/dist/document-delivery-transport.d.ts.map +1 -1
  10. package/dist/document-delivery-transport.js +6 -156
  11. package/dist/document-delivery-transport.js.map +1 -1
  12. package/dist/document-frame-router.d.ts +13 -17
  13. package/dist/document-frame-router.d.ts.map +1 -1
  14. package/dist/document-frame-router.js +21 -93
  15. package/dist/document-frame-router.js.map +1 -1
  16. package/dist/document-handlers.d.ts +1 -1
  17. package/dist/document-handlers.d.ts.map +1 -1
  18. package/dist/document-handlers.js +471 -466
  19. package/dist/document-handlers.js.map +1 -1
  20. package/dist/document-handshake.d.ts +4 -1
  21. package/dist/document-handshake.d.ts.map +1 -1
  22. package/dist/document-handshake.js +3 -1
  23. package/dist/document-handshake.js.map +1 -1
  24. package/dist/document-inbound.d.ts +18 -5
  25. package/dist/document-inbound.d.ts.map +1 -1
  26. package/dist/document-inbound.js +94 -110
  27. package/dist/document-inbound.js.map +1 -1
  28. package/dist/document-layer.d.ts +28 -67
  29. package/dist/document-layer.d.ts.map +1 -1
  30. package/dist/document-layer.js +670 -565
  31. package/dist/document-layer.js.map +1 -1
  32. package/dist/document-lifecycle.d.ts +11 -157
  33. package/dist/document-lifecycle.d.ts.map +1 -1
  34. package/dist/document-lifecycle.js +26 -561
  35. package/dist/document-lifecycle.js.map +1 -1
  36. package/dist/document-publish.d.ts +13 -0
  37. package/dist/document-publish.d.ts.map +1 -1
  38. package/dist/document-publish.js +19 -11
  39. package/dist/document-publish.js.map +1 -1
  40. package/dist/document-reconcile-engine.d.ts +66 -0
  41. package/dist/document-reconcile-engine.d.ts.map +1 -0
  42. package/dist/document-reconcile-engine.js +225 -0
  43. package/dist/document-reconcile-engine.js.map +1 -0
  44. package/dist/document-reconcile-scheduler.d.ts +91 -0
  45. package/dist/document-reconcile-scheduler.d.ts.map +1 -0
  46. package/dist/document-reconcile-scheduler.js +153 -0
  47. package/dist/document-reconcile-scheduler.js.map +1 -0
  48. package/dist/document-rejection.d.ts +2 -0
  49. package/dist/document-rejection.d.ts.map +1 -1
  50. package/dist/document-rejection.js +5 -1
  51. package/dist/document-rejection.js.map +1 -1
  52. package/dist/document-store.d.ts +18 -220
  53. package/dist/document-store.d.ts.map +1 -1
  54. package/dist/document-store.js +95 -717
  55. package/dist/document-store.js.map +1 -1
  56. package/dist/initiate-session-handler.d.ts +5 -0
  57. package/dist/initiate-session-handler.d.ts.map +1 -1
  58. package/dist/initiate-session-handler.js +1 -0
  59. package/dist/initiate-session-handler.js.map +1 -1
  60. package/package.json +5 -5
  61. package/dist/delivery-sweep-bound.d.ts +0 -79
  62. package/dist/delivery-sweep-bound.d.ts.map +0 -1
  63. package/dist/delivery-sweep-bound.js +0 -109
  64. package/dist/delivery-sweep-bound.js.map +0 -1
  65. package/dist/document-ack-inbound.d.ts +0 -71
  66. package/dist/document-ack-inbound.d.ts.map +0 -1
  67. package/dist/document-ack-inbound.js +0 -210
  68. package/dist/document-ack-inbound.js.map +0 -1
  69. package/dist/document-control-notifier.d.ts +0 -107
  70. package/dist/document-control-notifier.d.ts.map +0 -1
  71. package/dist/document-control-notifier.js +0 -137
  72. package/dist/document-control-notifier.js.map +0 -1
  73. package/dist/document-delivery.d.ts +0 -229
  74. package/dist/document-delivery.d.ts.map +0 -1
  75. package/dist/document-delivery.js +0 -449
  76. package/dist/document-delivery.js.map +0 -1
  77. package/dist/document-join-store.d.ts +0 -80
  78. package/dist/document-join-store.d.ts.map +0 -1
  79. package/dist/document-join-store.js +0 -197
  80. package/dist/document-join-store.js.map +0 -1
@@ -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,86 +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
- `);
202
+ this.#db.exec(`DROP TABLE IF EXISTS document_amendments`);
203
+ this.#db.exec(DOCUMENT_ENTRIES_CREATE_SQL);
204
+ dropLegacyEpochColumn(this.#db, "document_entries");
283
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
+ ]);
284
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
+ }
285
224
  this.#db.exec(CREATE_REJECTIONS_RECEIVED_SQL);
286
- // Owned by DocumentLifecycle, created HERE too because `pendingDeliveries` references it and a
287
- // store used without the lifecycle module is a legitimate configuration. Both statements are
288
- // CREATE TABLE IF NOT EXISTS over the same definition, so whichever runs first wins and the
289
- // other is a no-op — the alternative is a query that throws on a missing table and takes an
290
- // entire delivery pass down with it.
291
- 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
+ `);
292
243
  this.#db.exec(CREATE_SNAPSHOTS_SQL);
293
- // COLUMN BIRTH. A daemon that already holds an envelope log must gain the column without
294
- // losing the log. `ALTER TABLE ... ADD COLUMN` throws when it is already there, which is the
295
- // guard — the same pattern `document-handshake.ts` uses, rather than a version number nobody
296
- // maintains.
297
- addColumnIfMissing(this.#db, this.#logger, {
298
- table: "document_envelopes",
299
- column: "abandoned_at",
300
- sql: "ALTER TABLE document_envelopes ADD COLUMN abandoned_at INTEGER",
301
- });
302
244
  // Reading the log in arrival order is the only access pattern that matters.
303
245
  this.#db.exec("CREATE INDEX IF NOT EXISTS idx_document_envelopes_order ON document_envelopes (owner_agent_id, document_id, log_index)");
304
246
  }
@@ -321,16 +263,6 @@ export class DocumentStore {
321
263
  .get(ownerAgentId, documentId);
322
264
  return r ? toDocumentRow(r) : null;
323
265
  }
324
- /**
325
- * Does this daemon hold ANY document, under any owner?
326
- *
327
- * Deliberately unscoped, and that is the point: it is the only question that can tell a delivery
328
- * sweep visiting zero agents apart from a daemon that simply has no documents. Without it the two
329
- * look identical in the log, which is what let an owner-key mismatch hide.
330
- */
331
- anyDocumentExists() {
332
- return this.#db.prepare("SELECT 1 FROM documents LIMIT 1").get() !== undefined;
333
- }
334
266
  listDocuments(ownerAgentId) {
335
267
  const rows = this.#db
336
268
  .prepare("SELECT * FROM documents WHERE owner_agent_id = ? ORDER BY created_at ASC")
@@ -367,14 +299,14 @@ export class DocumentStore {
367
299
  try {
368
300
  info = this.#db
369
301
  .prepare(`INSERT INTO document_envelopes
370
- (owner_agent_id, document_id, envelope_hash, sender_agent_id, doc_prev_hash, epoch_id,
371
- signature, state_vector, payload, kind, references_hash, sender_client_id,
372
- 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)
373
305
  SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
374
306
  COALESCE((SELECT MAX(log_index) + 1 FROM document_envelopes
375
307
  WHERE owner_agent_id = ? AND document_id = ?), 0)
376
308
  ON CONFLICT (owner_agent_id, document_id, envelope_hash) DO NOTHING`)
377
- .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);
378
310
  }
379
311
  catch (err) {
380
312
  const message = err instanceof Error ? err.message : String(err);
@@ -439,9 +371,9 @@ export class DocumentStore {
439
371
  documentId: q.documentId,
440
372
  senderAgentId: q.rejectedSenderAgentId,
441
373
  docPrevHash: q.rejectedDocPrevHash,
374
+ governanceParents: [],
442
375
  // EXEMPT from current-epoch stamping (M14B Entry 5): a stub is a SYNTHETIC verification
443
376
  // node — never on the wire, never replayed — and the chain walk checks hash linkage only.
444
- epochId: 0,
445
377
  signature: new Uint8Array(0),
446
378
  stateVector: new Uint8Array(0),
447
379
  payload: null,
@@ -555,10 +487,10 @@ export class DocumentStore {
555
487
  .prepare(`INSERT INTO document_quarantine
556
488
  (owner_agent_id, document_id, rejection_envelope_hash, rejected_envelope_hash,
557
489
  rejected_sender_agent_id, rejected_doc_prev_hash, payload, reason, detail,
558
- rule, limit_name, limit_value, limit_actual, created_at)
559
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
490
+ rule, limit_name, limit_value, limit_actual, rejection_wire, created_at)
491
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
560
492
  ON CONFLICT (owner_agent_id, document_id, rejection_envelope_hash) DO NOTHING`)
561
- .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);
562
494
  return Number(info.changes) > 0;
563
495
  }
564
496
  listQuarantined(ownerAgentId, documentId) {
@@ -584,6 +516,7 @@ export class DocumentStore {
584
516
  limitName: r["limit_name"] ?? undefined,
585
517
  limitValue: r["limit_value"] ?? undefined,
586
518
  limitActual: r["limit_actual"] ?? undefined,
519
+ rejectionWire: r["rejection_wire"] == null ? undefined : toU8(r["rejection_wire"]),
587
520
  createdAtMs: r["created_at"],
588
521
  }));
589
522
  }
@@ -723,597 +656,46 @@ export class DocumentStore {
723
656
  */
724
657
  /**
725
658
  * The document's CURRENT epoch: the head of its recorded amendment chain, 0 at genesis.
726
- * Trustworthy for stamping because every append site validates (deriveArrangement) before
659
+ * Trustworthy for stamping because every append site rules on admissibility before
727
660
  * recording — a row in document_amendments is post-validation by invariant (M14B Entry 5).
728
661
  */
729
- /**
730
- * DOD-MP-REMOVE-1 — was THIS OWNER written out of the arrangement? DERIVED from the recorded
731
- * chain, never stored: a status column would need a CHECK-constraint rebuild on every operator
732
- * DB, and a stored flag can drift from the chain that actually governs. Forward-only by
733
- * construction — nothing here touches content.
734
- */
735
- removedFromArrangement(ownerAgentId, documentId) {
736
- return this.memberRemoved(ownerAgentId, documentId, ownerAgentId);
737
- }
738
- /** The same walk for ANY agent — the delivery worker asks it about the TARGET (F1). */
739
- memberRemoved(ownerAgentId, documentId, agentId) {
662
+ /** SYNC-P5 (review F6) — per-sender envelope counts without hauling payload blobs. */
663
+ envelopeCountsBySender(ownerAgentId, documentId) {
740
664
  const rows = this.#db
741
- .prepare(`SELECT received_bytes FROM document_amendments
665
+ .prepare(`SELECT sender_agent_id, COUNT(*) AS n FROM document_envelopes
742
666
  WHERE owner_agent_id = ? AND document_id = ?
743
- ORDER BY epoch_id ASC`)
667
+ GROUP BY sender_agent_id`)
744
668
  .all(ownerAgentId, documentId);
745
- // CONTAINED, for the same reason the arrangement read is: `decodeDocumentAmendment` throws
746
- // on bytes this build cannot read, and this runs once PER ROW inside `list` — so one
747
- // undecodable chain took down the operator's whole document list. Reporting not-removed
748
- // here is not a health claim: the same row carries `arrangementUnavailable` naming the
749
- // undecodable chain, so the surface says it cannot answer rather than answering wrongly.
750
- let verdict;
751
- try {
752
- verdict = walkMembership(rows.map((r) => decodeDocumentAmendment(new Uint8Array(r.received_bytes))), agentId);
753
- }
754
- catch (err) {
755
- this.#logger.error("document.membership.undecodable", {
756
- documentId,
757
- agentId,
758
- reason: err instanceof Error ? err.message : String(err),
759
- });
760
- return { removed: false, epochId: null };
761
- }
762
- return { removed: verdict.state === "removed", epochId: verdict.epochId };
763
- }
764
- currentDocumentEpoch(ownerAgentId, documentId) {
765
- const r = this.#db
766
- .prepare(`SELECT MAX(epoch_id) AS max_epoch FROM document_amendments
767
- WHERE owner_agent_id = ? AND document_id = ?`)
768
- .get(ownerAgentId, documentId);
769
- return r?.max_epoch ?? 0;
770
- }
771
- /**
772
- * Append an envelope AND seed its per-holder delivery rows in ONE transaction (review M5):
773
- * a crash between the two left an envelope in the log with zero rows, and the bilateral
774
- * backfill would re-seed only the genesis peer — the fan-out holders silently starved.
775
- * Returns the append verdict unchanged.
776
- */
777
- appendEnvelopeWithDeliveries(ownerAgentId, envelope, holderAgentIds, nowMs) {
778
- this.#db.exec("BEGIN IMMEDIATE");
779
- try {
780
- const appended = this.appendEnvelope(ownerAgentId, envelope);
781
- if (appended) {
782
- this.seedDeliveries(ownerAgentId, envelope.documentId, envelope.envelopeHash, holderAgentIds, nowMs);
783
- }
784
- this.#db.exec("COMMIT");
785
- return appended;
786
- }
787
- catch (err) {
788
- this.#db.exec("ROLLBACK");
789
- throw err;
790
- }
791
- }
792
- /** DOD-MP-FANOUT-1 — one pending row per CURRENT holder for a freshly published envelope. */
793
- seedDeliveries(ownerAgentId, documentId, envelopeHash, holderAgentIds, nowMs) {
794
- const insert = this.#db.prepare(`INSERT INTO document_deliveries
795
- (owner_agent_id, document_id, envelope_hash, holder_agent_id, created_at)
796
- VALUES (?, ?, ?, ?, ?)
797
- ON CONFLICT (owner_agent_id, document_id, envelope_hash, holder_agent_id) DO NOTHING`);
798
- for (const holder of holderAgentIds) {
799
- insert.run(ownerAgentId, documentId, envelopeHash, holder, nowMs);
800
- }
801
- }
802
- /**
803
- * DOD-MP-INVITE-FANOUT-1 — one owed row per holder who must learn about this amendment.
804
- *
805
- * The inviter and the subject are excluded by the CALLER, not here: this records what is owed,
806
- * and who is owed it is a governance question the handler has already derived.
807
- */
808
- seedAmendmentDeliveries(ownerAgentId, documentId, amendmentHash, holderAgentIds, nowMs) {
809
- const insert = this.#db.prepare(`INSERT INTO document_amendment_deliveries
810
- (owner_agent_id, document_id, amendment_hash, holder_agent_id, created_at)
811
- VALUES (?, ?, ?, ?, ?)
812
- ON CONFLICT (owner_agent_id, document_id, amendment_hash, holder_agent_id) DO NOTHING`);
813
- for (const holder of holderAgentIds) {
814
- insert.run(ownerAgentId, documentId, amendmentHash, holder, nowMs);
815
- }
816
- }
817
- /**
818
- * Amendments still owed to a holder, with the exact bytes to send. Joined to
819
- * `document_amendments` — the recorded chain IS the payload, so a redelivery can never send
820
- * something the local chain does not contain.
821
- */
822
- pendingAmendmentDeliveries(ownerAgentId, nowMs, opts = {}) {
823
- // HEAD-OF-LINE, PER (DOCUMENT, HOLDER) — and this is a correctness rule, not a nicety.
824
- //
825
- // Ordering by `created_at` among only the rows that are DUE inverts the chain: epoch N fails
826
- // once and takes a backoff, epoch N+1 is seeded due immediately, so the next pass sends N+1
827
- // alone. The receiver then refuses it with `document_amendment_chain_gap` — "an out-of-order
828
- // arrival is retried by its sender, never buffered silently" — and under the old ack-on-send
829
- // there was no retry, so N+1 was lost permanently and the one message that would have explained
830
- // it said the opposite. Proven by the unit review.
831
- //
832
- // So: order by the CHAIN's epoch, and never offer an amendment while an earlier one is still
833
- // outstanding for that same holder. Per (document, holder), never globally — one holder's stuck
834
- // chain must not hold up another's.
835
- const rows = this.#db
836
- .prepare(`SELECT holder_agent_id, document_id, amendment_hash, epoch_id, attempts, received_bytes
837
- FROM (
838
- SELECT d.holder_agent_id, d.document_id, d.amendment_hash, d.attempts,
839
- a.epoch_id, a.received_bytes,
840
- d.next_attempt_at,
841
- ROW_NUMBER() OVER (
842
- PARTITION BY d.document_id, d.holder_agent_id
843
- ORDER BY a.epoch_id ASC
844
- ) AS rn
845
- FROM document_amendment_deliveries d
846
- JOIN document_amendments a
847
- ON a.owner_agent_id = d.owner_agent_id
848
- AND a.document_id = d.document_id
849
- AND a.amendment_hash = d.amendment_hash
850
- WHERE d.owner_agent_id = ?
851
- AND d.acked_at IS NULL
852
- AND d.retired_at IS NULL
853
- )
854
- WHERE rn = 1
855
- AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
856
- ORDER BY document_id ASC, holder_agent_id ASC
857
- LIMIT ?`)
858
- .all(ownerAgentId, nowMs, opts.limit ?? 50);
859
- return rows.map((r) => ({
860
- holderAgentId: r["holder_agent_id"],
861
- documentId: r["document_id"],
862
- amendmentHash: r["amendment_hash"],
863
- epochId: r["epoch_id"] ?? 0,
864
- attempts: r["attempts"] ?? 0,
865
- bytes: new Uint8Array(r["received_bytes"]),
866
- }));
867
- }
868
- /**
869
- * Owed amendment rows with NO matching row in the recorded chain.
870
- *
871
- * The pending query INNER JOINs the chain, which is right — a redelivery must never send bytes
872
- * this daemon's own chain does not contain — but an inner join also makes such a row invisible
873
- * AND owed forever. Surfaced so the worker can say so instead of scanning past it every tick.
874
- */
875
- amendmentDeliveriesWithoutChainRow(ownerAgentId) {
876
- const rows = this.#db
877
- .prepare(`SELECT d.document_id, d.amendment_hash, d.holder_agent_id
878
- FROM document_amendment_deliveries d
879
- LEFT JOIN document_amendments a
880
- ON a.owner_agent_id = d.owner_agent_id
881
- AND a.document_id = d.document_id
882
- AND a.amendment_hash = d.amendment_hash
883
- WHERE d.owner_agent_id = ?
884
- AND d.acked_at IS NULL
885
- AND d.retired_at IS NULL
886
- AND a.amendment_hash IS NULL`)
887
- .all(ownerAgentId);
888
- return rows.map((r) => ({
889
- documentId: r["document_id"],
890
- amendmentHash: r["amendment_hash"],
891
- holderAgentId: r["holder_agent_id"],
892
- }));
893
- }
894
- /**
895
- * The bytes left this daemon. NOT a confirmation — see the `sent_at` note on the table.
896
- *
897
- * The row stays owed and is re-offered after `retryAfterMs`, because the only thing that has
898
- * happened is that their daemon received a frame. Whether it RECORDED it is a separate fact, and
899
- * the receiver can refuse.
900
- */
901
- markAmendmentSent(ownerAgentId, documentId, amendmentHash, holderAgentId, nowMs, retryAfterMs) {
902
- this.#db
903
- .prepare(`UPDATE document_amendment_deliveries
904
- SET sent_at = ?, next_attempt_at = ?
905
- WHERE owner_agent_id = ? AND document_id = ? AND amendment_hash = ? AND holder_agent_id = ?`)
906
- .run(nowMs, nowMs + retryAfterMs, ownerAgentId, documentId, amendmentHash, holderAgentId);
907
- }
908
- /** The holder demonstrably HAS it. Nothing further is owed for this (amendment, holder). */
909
- ackAmendmentDelivery(ownerAgentId, documentId, amendmentHash, holderAgentId, nowMs) {
910
- this.#db
911
- .prepare(`UPDATE document_amendment_deliveries
912
- SET acked_at = ?
913
- WHERE owner_agent_id = ? AND document_id = ? AND amendment_hash = ? AND holder_agent_id = ?`)
914
- .run(nowMs, ownerAgentId, documentId, amendmentHash, holderAgentId);
915
- }
916
- /**
917
- * PROOF BY EPOCH — settle every amendment a holder has demonstrably applied.
918
- *
919
- * There is no amendment ack frame, and inventing one is wire work. But there is already a fact on
920
- * the wire that PROVES receipt: a holder that acks an envelope authored at epoch E must hold every
921
- * amendment up to E, because the inbound epoch gate refuses anything whose epoch does not match
922
- * its own derived arrangement. So their ack of the content IS their ack of the governance that
923
- * made the content admissible.
924
- *
925
- * Returns how many rows it settled, so the caller can say so.
926
- */
927
- ackAmendmentsThroughEpoch(ownerAgentId, documentId, holderAgentId, epochId, nowMs) {
928
- const owed = this.#db
929
- .prepare(`SELECT d.amendment_hash
930
- FROM document_amendment_deliveries d
931
- JOIN document_amendments a
932
- ON a.owner_agent_id = d.owner_agent_id
933
- AND a.document_id = d.document_id
934
- AND a.amendment_hash = d.amendment_hash
935
- WHERE d.owner_agent_id = ? AND d.document_id = ? AND d.holder_agent_id = ?
936
- AND d.acked_at IS NULL AND d.retired_at IS NULL
937
- AND a.epoch_id <= ?`)
938
- .all(ownerAgentId, documentId, holderAgentId, epochId);
939
- for (const row of owed) {
940
- this.ackAmendmentDelivery(ownerAgentId, documentId, row.amendment_hash, holderAgentId, nowMs);
941
- }
942
- return owed.length;
943
- }
944
- /**
945
- * The holder is no longer in the chain. Distinct from acked ON PURPOSE: an ack here would make
946
- * the durable record claim a delivery that never happened.
947
- */
948
- retireAmendmentDelivery(ownerAgentId, documentId, amendmentHash, holderAgentId, nowMs) {
949
- this.#db
950
- .prepare(`UPDATE document_amendment_deliveries
951
- SET retired_at = ?
952
- WHERE owner_agent_id = ? AND document_id = ? AND amendment_hash = ? AND holder_agent_id = ?`)
953
- .run(nowMs, ownerAgentId, documentId, amendmentHash, holderAgentId);
954
- }
955
- /** Record one failed attempt and when to try again; returns THEIR attempt count. */
956
- recordAmendmentAttempt(ownerAgentId, documentId, amendmentHash, holderAgentId, nextAttemptAtMs) {
957
- this.#db
958
- .prepare(`UPDATE document_amendment_deliveries
959
- SET attempts = attempts + 1, next_attempt_at = ?
960
- WHERE owner_agent_id = ? AND document_id = ? AND amendment_hash = ? AND holder_agent_id = ?`)
961
- .run(nextAttemptAtMs, ownerAgentId, documentId, amendmentHash, holderAgentId);
962
- const row = this.#db
963
- .prepare(`SELECT attempts FROM document_amendment_deliveries
964
- WHERE owner_agent_id = ? AND document_id = ? AND amendment_hash = ? AND holder_agent_id = ?`)
965
- .get(ownerAgentId, documentId, amendmentHash, holderAgentId);
966
- return row?.attempts ?? 0;
967
- }
968
- /**
969
- * Everything due for delivery, PER HOLDER, joined to its envelope. The window is bounded PER
970
- * HOLDER (`ROW_NUMBER` over holder partitions) — one holder's backlog must never evict
971
- * another's rows from the pass, which is the no_peer starvation shape multiplied by N.
972
- */
973
- pendingHolderDeliveries(ownerAgentId, nowMs, opts = {}) {
974
- const limit = opts.perHolderLimit ?? 50;
975
- const rows = this.#db
976
- .prepare(
977
- // holder_attempts ALIASED: e.* carries the envelope row's LEGACY bilateral attempts
978
- // column, which would silently shadow the per-holder count in the result map.
979
- `SELECT d.holder_agent_id, d.attempts AS holder_attempts, d.document_id AS delivery_document_id, e.*
980
- FROM (
981
- SELECT *, ROW_NUMBER() OVER (
982
- PARTITION BY holder_agent_id
983
- ORDER BY created_at ASC, envelope_hash ASC
984
- ) AS rn
985
- FROM document_deliveries
986
- WHERE owner_agent_id = ?
987
- AND acked_at IS NULL AND abandoned_at IS NULL
988
- AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
989
- ) d
990
- JOIN document_envelopes e
991
- ON e.owner_agent_id = d.owner_agent_id
992
- AND e.document_id = d.document_id
993
- AND e.envelope_hash = d.envelope_hash
994
- WHERE d.rn <= ?
995
- ORDER BY d.holder_agent_id ASC, d.created_at ASC`)
996
- .all(ownerAgentId, nowMs, limit);
997
- return rows.map((r) => ({
998
- holderAgentId: r["holder_agent_id"],
999
- documentId: r["delivery_document_id"],
1000
- attempts: r["holder_attempts"] ?? 0,
1001
- envelope: toEnvelopeRow(r),
1002
- }));
1003
- }
1004
- /** Record one holder's attempt; returns THEIR attempt count. */
1005
- recordHolderAttempt(ownerAgentId, documentId, envelopeHash, holderAgentId, nextAttemptAtMs) {
1006
- this.#db
1007
- .prepare(`UPDATE document_deliveries
1008
- SET attempts = attempts + 1, next_attempt_at = ?
1009
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND holder_agent_id = ?`)
1010
- .run(nextAttemptAtMs, ownerAgentId, documentId, envelopeHash, holderAgentId);
1011
- const r = this.#db
1012
- .prepare(`SELECT attempts FROM document_deliveries
1013
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND holder_agent_id = ?`)
1014
- .get(ownerAgentId, documentId, envelopeHash, holderAgentId);
1015
- return r?.attempts ?? 0;
1016
- }
1017
- /** Settle one holder's confirmation. True exactly when THIS call settled it. */
1018
- ackHolderDelivery(ownerAgentId, documentId, envelopeHash, holderAgentId, nowMs) {
1019
- const r = this.#db
1020
- .prepare(`UPDATE document_deliveries
1021
- SET acked_at = ?, delivered_at = COALESCE(delivered_at, ?)
1022
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND holder_agent_id = ?
1023
- AND acked_at IS NULL AND abandoned_at IS NULL`)
1024
- .run(nowMs, nowMs, ownerAgentId, documentId, envelopeHash, holderAgentId);
1025
- return (r.changes ?? 0) > 0;
1026
- }
1027
- /**
1028
- * Retire EVERY outstanding row for one holder — our decision, announced by the caller.
1029
- * Returns the affected envelope hashes so the caller can reconcile the envelope-level record
1030
- * (an envelope whose every holder-row is settled-with-abandonment is itself abandoned).
1031
- */
1032
- abandonHolderDeliveries(ownerAgentId, documentId, holderAgentId, nowMs) {
1033
- const rows = this.#db
1034
- .prepare(`SELECT envelope_hash FROM document_deliveries
1035
- WHERE owner_agent_id = ? AND document_id = ? AND holder_agent_id = ?
1036
- AND acked_at IS NULL AND abandoned_at IS NULL`)
1037
- .all(ownerAgentId, documentId, holderAgentId);
1038
- this.#db
1039
- .prepare(`UPDATE document_deliveries
1040
- SET abandoned_at = ?
1041
- WHERE owner_agent_id = ? AND document_id = ? AND holder_agent_id = ?
1042
- AND acked_at IS NULL AND abandoned_at IS NULL`)
1043
- .run(nowMs, ownerAgentId, documentId, holderAgentId);
1044
- return rows.map((r) => r.envelope_hash);
669
+ return new Map(rows.map((r) => [r.sender_agent_id, r.n]));
1045
670
  }
1046
- /** Count one REAL send (content left us live or parked) toward this holder's ceiling. */
1047
- recordHolderSend(ownerAgentId, documentId, envelopeHash, holderAgentId) {
671
+ /** SYNC-P5 record what a party's exchange just showed us (display cache; see the table). */
672
+ recordPartyView(ownerAgentId, documentId, partyAgentId, view, nowMs) {
1048
673
  this.#db
1049
- .prepare(`UPDATE document_deliveries
1050
- SET sends = sends + 1
1051
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND holder_agent_id = ?`)
1052
- .run(ownerAgentId, documentId, envelopeHash, holderAgentId);
1053
- const r = this.#db
1054
- .prepare(`SELECT sends FROM document_deliveries
1055
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND holder_agent_id = ?`)
1056
- .get(ownerAgentId, documentId, envelopeHash, holderAgentId);
1057
- return r?.sends ?? 0;
1058
- }
1059
- /** One holder's copy LEFT the machine — the sent-vs-never-left fact, per holder. */
1060
- markHolderDelivered(ownerAgentId, documentId, envelopeHash, holderAgentId, nowMs) {
1061
- this.#db
1062
- .prepare(`UPDATE document_deliveries
1063
- SET delivered_at = COALESCE(delivered_at, ?)
1064
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND holder_agent_id = ?`)
1065
- .run(nowMs, ownerAgentId, documentId, envelopeHash, holderAgentId);
1066
- }
1067
- /** Has ANY holder's copy of this envelope left the machine? */
1068
- envelopeEverSent(ownerAgentId, envelopeHash) {
1069
- const r = this.#db
1070
- .prepare(`SELECT 1 FROM document_deliveries
1071
- WHERE owner_agent_id = ? AND envelope_hash = ? AND delivered_at IS NOT NULL
1072
- LIMIT 1`)
1073
- .get(ownerAgentId, envelopeHash);
1074
- return r !== undefined;
1075
- }
1076
- /** Reschedule one holder's retry WITHOUT counting an attempt — waiting is not sending. */
1077
- scheduleHolderRetry(ownerAgentId, documentId, envelopeHash, holderAgentId, nextAttemptAtMs) {
1078
- this.#db
1079
- .prepare(`UPDATE document_deliveries
1080
- SET next_attempt_at = ?
1081
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND holder_agent_id = ?`)
1082
- .run(nextAttemptAtMs, ownerAgentId, documentId, envelopeHash, holderAgentId);
1083
- }
1084
- /** Retire ONE (envelope, holder) row — the per-holder unacked ceiling's teeth. */
1085
- abandonSingleHolderDelivery(ownerAgentId, documentId, envelopeHash, holderAgentId, nowMs) {
1086
- this.#db
1087
- .prepare(`UPDATE document_deliveries
1088
- SET abandoned_at = ?
1089
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND holder_agent_id = ?
1090
- AND acked_at IS NULL AND abandoned_at IS NULL`)
1091
- .run(nowMs, ownerAgentId, documentId, envelopeHash, holderAgentId);
1092
- }
1093
- /**
1094
- * No holder still owes a confirmation on this envelope. TRUE with zero rows — the bilateral
1095
- * legacy case, where the envelope-row columns remain the record and this table has nothing to
1096
- * say.
1097
- */
1098
- envelopeFullySettled(ownerAgentId, envelopeHash) {
1099
- const r = this.#db
1100
- .prepare(`SELECT 1 FROM document_deliveries
1101
- WHERE owner_agent_id = ? AND envelope_hash = ?
1102
- AND acked_at IS NULL AND abandoned_at IS NULL
1103
- LIMIT 1`)
1104
- .get(ownerAgentId, envelopeHash);
1105
- return r === undefined;
1106
- }
1107
- /**
1108
- * Seed per-holder rows for LEGACY bilateral envelopes: anything this agent authored that is
1109
- * still unacknowledged on the envelope row and has no per-holder rows yet gets one for the
1110
- * genesis peer. Idempotent (the PK refuses duplicates); run per pass, cheap, and journaled —
1111
- * pre-fan-out backlogs are near-empty at alpha, this is insurance not migration.
1112
- */
1113
- backfillBilateralDeliveries(ownerAgentId, senderAgentId, nowMs) {
1114
- this.#db
1115
- .prepare(`INSERT INTO document_deliveries
1116
- (owner_agent_id, document_id, envelope_hash, holder_agent_id, attempts, created_at)
1117
- SELECT e.owner_agent_id, e.document_id, e.envelope_hash, d.peer_agent_id, e.attempts, ?
1118
- FROM document_envelopes e
1119
- JOIN documents d
1120
- ON d.owner_agent_id = e.owner_agent_id AND d.document_id = e.document_id
1121
- WHERE e.owner_agent_id = ? AND e.sender_agent_id = ?
1122
- AND e.acked_at IS NULL AND e.abandoned_at IS NULL AND e.kind = 'update'
1123
- AND NOT EXISTS (
1124
- SELECT 1 FROM document_deliveries dd
1125
- WHERE dd.owner_agent_id = e.owner_agent_id AND dd.envelope_hash = e.envelope_hash
1126
- )
1127
- ON CONFLICT (owner_agent_id, document_id, envelope_hash, holder_agent_id) DO NOTHING`)
1128
- .run(nowMs, ownerAgentId, senderAgentId);
1129
- }
1130
- /**
1131
- * THIS holder's settlement of THIS envelope — the per-acker twin of `envelopeSettlement`,
1132
- * with rejection attribution taken from the rejections-received rows' `from_agent_id`.
1133
- * Null when the holder has not answered (or has no row — the bilateral-legacy case, where the
1134
- * envelope-level read is the record).
1135
- */
1136
- 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) {
1137
686
  const r = this.#db
1138
- .prepare(`SELECT document_id, acked_at FROM document_deliveries
1139
- WHERE owner_agent_id = ? AND envelope_hash = ? AND holder_agent_id = ?`)
1140
- .get(ownerAgentId, envelopeHash, holderAgentId);
1141
- 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)
1142
691
  return null;
1143
692
  return {
1144
- 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),
1145
697
  };
1146
698
  }
1147
- /** Was a rejection of this envelope received FROM this specific holder? */
1148
- rejectionReceivedFromFor(ownerAgentId, documentId, envelopeHash, fromAgentId) {
1149
- const r = this.#db
1150
- .prepare(`SELECT 1 FROM document_rejections_received
1151
- WHERE owner_agent_id = ? AND document_id = ? AND rejected_envelope_hash = ?
1152
- AND from_agent_id = ?
1153
- LIMIT 1`)
1154
- .get(ownerAgentId, documentId, envelopeHash, fromAgentId);
1155
- return r !== undefined;
1156
- }
1157
- /**
1158
- * ONE deterministic envelope-level terminal rule (FANOUT-1 review M3), applied by every
1159
- * reconciliation site: once no holder is outstanding, the envelope is ACKED if at least one
1160
- * holder acked, ABANDONED only when nobody ever answered. Without one rule, identical final
1161
- * holder states landed as opposite envelope-level facts depending on event order — and
1162
- * `withdraw`'s "your peer holds it" answered differently for the same reality.
1163
- */
1164
- reconcileEnvelopeSettlement(ownerAgentId, documentId, envelopeHash, nowMs) {
1165
- if (!this.envelopeFullySettled(ownerAgentId, envelopeHash))
1166
- return;
1167
- const anyAcked = this.#db
1168
- .prepare(`SELECT 1 FROM document_deliveries
1169
- WHERE owner_agent_id = ? AND envelope_hash = ? AND acked_at IS NOT NULL
1170
- LIMIT 1`)
1171
- .get(ownerAgentId, envelopeHash);
1172
- if (anyAcked !== undefined) {
1173
- this.markAcked(ownerAgentId, documentId, envelopeHash, nowMs);
1174
- }
1175
- else {
1176
- const anyRows = this.#db
1177
- .prepare(`SELECT 1 FROM document_deliveries WHERE owner_agent_id = ? AND envelope_hash = ? LIMIT 1`)
1178
- .get(ownerAgentId, envelopeHash);
1179
- // Zero rows = bilateral legacy — the envelope-level columns are already the record.
1180
- if (anyRows !== undefined) {
1181
- this.markAbandoned(ownerAgentId, documentId, envelopeHash, nowMs);
1182
- }
1183
- }
1184
- }
1185
- /** Does this holder still owe any confirmation on this document? */
1186
- holderHasPending(ownerAgentId, documentId, holderAgentId) {
1187
- const r = this.#db
1188
- .prepare(`SELECT 1 FROM document_deliveries
1189
- WHERE owner_agent_id = ? AND document_id = ? AND holder_agent_id = ?
1190
- AND acked_at IS NULL AND abandoned_at IS NULL
1191
- LIMIT 1`)
1192
- .get(ownerAgentId, documentId, holderAgentId);
1193
- return r !== undefined;
1194
- }
1195
- pendingDeliveries(ownerAgentId, nowMs,
1196
- /**
1197
- * OUR OWN sender id on the wire — the author's pubkey hex (M14-D5).
1198
- *
1199
- * Kept as a separate parameter, and separately named, even though the daemon now scopes the
1200
- * store by that same pubkey hex so the two coincide. They are different FACTS: the owner key
1201
- * says whose local store this row is in, the sender id says who signed the envelope. An
1202
- * earlier version of the daemon scoped by agent name, and the mismatch returned nothing
1203
- * pending — every published update sitting in the log undelivered, with no error anywhere.
1204
- * Collapsing them into one argument makes that class of bug unrepresentable in the call and
1205
- * invisible in the query.
1206
- */
1207
- senderAgentId = ownerAgentId, limit = 100) {
1208
- const rows = this.#db
1209
- .prepare(`SELECT * FROM document_envelopes
1210
- WHERE owner_agent_id = ? AND sender_agent_id = ? AND acked_at IS NULL
1211
- -- ABANDONED rows are not pending. This is what actually stops the worker at the
1212
- -- unacked ceiling: the selection reads no document STATUS, so setting the document
1213
- -- stalled changed what the surface said and nothing about what the worker did, and one
1214
- -- envelope went out 74 times against a cap of 5.
1215
- AND abandoned_at IS NULL
1216
- -- UPDATES only. A withdrawal record is local audit — the update it concerns was never
1217
- -- delivered, so there is nothing for the peer to act on — and a rejection reaches the
1218
- -- peer through the rejection protocol, not this worker. Without the scope the worker
1219
- -- would ship both, and the withdrawal would arrive as a reference to an envelope the
1220
- -- peer has never seen.
1221
- AND kind = 'update'
1222
- -- An ENDED document does not deliver. A killed or closed document that kept shipping
1223
- -- would contradict the verb the operator just used, and the peer would receive updates
1224
- -- on a collaboration they were told had stopped.
1225
- AND EXISTS (
1226
- SELECT 1 FROM documents d
1227
- WHERE d.owner_agent_id = document_envelopes.owner_agent_id
1228
- AND d.document_id = document_envelopes.document_id
1229
- AND d.status NOT IN ('killed', 'closed')
1230
- )
1231
- AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
1232
- -- A WITHDRAWN update is not pending. Derived from the withdrawal record rather than a
1233
- -- flag on the row, so there is one fact in one place: without this the delivery worker
1234
- -- ships the very update the operator just withdrew.
1235
- --
1236
- -- The table is created by DocumentLifecycle, which may not have run — a store used
1237
- -- without it is a legitimate configuration — so the reference is guarded rather than
1238
- -- assumed. A missing table would otherwise throw here and take the whole delivery pass
1239
- -- down with it.
1240
- AND NOT EXISTS (
1241
- SELECT 1 FROM document_withdrawals w
1242
- WHERE w.owner_agent_id = document_envelopes.owner_agent_id
1243
- AND w.document_id = document_envelopes.document_id
1244
- AND w.envelope_hash = document_envelopes.envelope_hash
1245
- )
1246
- -- log_index is PER DOCUMENT, so it alone is not a total order across documents and the
1247
- -- bounded window could be filled by one document's backlog forever. The tiebreaks make
1248
- -- the window deterministic; the no-peer branch scheduling its rows is what stops one
1249
- -- document monopolising it.
1250
- ORDER BY log_index ASC, document_id ASC, envelope_hash ASC LIMIT ?`)
1251
- .all(ownerAgentId, senderAgentId, nowMs, limit);
1252
- return rows.map(toEnvelopeRow);
1253
- }
1254
- /** Record that the peer acknowledged. Idempotent — a redelivered ack must not move the clock. */
1255
- /**
1256
- * How many of our envelopes for this document were ABANDONED — the unacked ceiling fired and they
1257
- * will never be retried.
1258
- *
1259
- * Surfaced because an abandoned envelope leaves every pending counter, so a document that
1260
- * permanently dropped an update is otherwise indistinguishable from one that delivered everything.
1261
- */
1262
- abandonedCount(ownerAgentId, documentId) {
1263
- const row = this.#db
1264
- .prepare(`SELECT COUNT(*) AS n FROM document_envelopes
1265
- WHERE owner_agent_id = ? AND document_id = ? AND sender_agent_id = ? AND abandoned_at IS NOT NULL`)
1266
- .get(ownerAgentId, documentId, ownerAgentId);
1267
- return row?.n ?? 0;
1268
- }
1269
- /**
1270
- * Is this envelope SETTLED — the peer answered it, admitted or rejected?
1271
- *
1272
- * Keyed by envelope hash ALONE, without the document, because the caller waiting on it is the
1273
- * delivery transport, which holds a session open and knows the envelope it sent but has no reason
1274
- * to carry the document id through the wait. The hash is a sha256 over the envelope's own
1275
- * preimage, so it identifies one envelope across every document this owner holds.
1276
- */
1277
- isEnvelopeAcked(ownerAgentId, envelopeHash) {
1278
- return this.envelopeSettlement(ownerAgentId, envelopeHash) !== null;
1279
- }
1280
- /**
1281
- * HOW an envelope was settled, or null if the peer has not answered it.
1282
- *
1283
- * `admitted` is derived from whether a rejection was RECEIVED for it, because `acked_at` records
1284
- * only that the peer answered — a rejection is an ack for delivery purposes, so the two states
1285
- * share that column deliberately (see `DocumentAckInbound`). Reading the answer, not just its
1286
- * existence, is what lets the delivery worker report `delivered` and `rejected` truthfully rather
1287
- * than counting every answered envelope as still in flight.
1288
- */
1289
- envelopeSettlement(ownerAgentId, envelopeHash) {
1290
- const row = this.#db
1291
- .prepare(`SELECT document_id FROM document_envelopes
1292
- WHERE owner_agent_id = ? AND envelope_hash = ? AND acked_at IS NOT NULL LIMIT 1`)
1293
- .get(ownerAgentId, envelopeHash);
1294
- if (row?.document_id === undefined)
1295
- return null;
1296
- return { admitted: !this.rejectionReceivedFor(ownerAgentId, row.document_id, envelopeHash) };
1297
- }
1298
- /**
1299
- * WE GAVE UP — the unacked ceiling. Deliberately not `markAcked`: this records a decision of
1300
- * ours, and says nothing about whether the peer holds the envelope, because we do not know.
1301
- */
1302
- markAbandoned(ownerAgentId, documentId, envelopeHash, nowMs) {
1303
- const info = this.#db
1304
- .prepare(`UPDATE document_envelopes SET abandoned_at = ?
1305
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ?
1306
- AND acked_at IS NULL AND abandoned_at IS NULL`)
1307
- .run(nowMs, ownerAgentId, documentId, envelopeHash);
1308
- return Number(info.changes) > 0;
1309
- }
1310
- markAcked(ownerAgentId, documentId, envelopeHash, nowMs) {
1311
- const info = this.#db
1312
- .prepare(`UPDATE document_envelopes SET acked_at = ?, delivered_at = COALESCE(delivered_at, ?)
1313
- WHERE owner_agent_id = ? AND document_id = ? AND envelope_hash = ? AND acked_at IS NULL`)
1314
- .run(nowMs, nowMs, ownerAgentId, documentId, envelopeHash);
1315
- return Number(info.changes) > 0;
1316
- }
1317
699
  /** Record a rejection the PEER sent us. Returns whether a row was written (idempotent by leaf). */
1318
700
  recordRejectionReceived(ownerAgentId, row) {
1319
701
  const info = this.#db
@@ -1517,19 +899,15 @@ function toEnvelopeRow(r) {
1517
899
  documentId: r["document_id"],
1518
900
  senderAgentId: r["sender_agent_id"],
1519
901
  docPrevHash: r["doc_prev_hash"] ?? null,
1520
- epochId: r["epoch_id"],
1521
902
  signature: toU8(r["signature"]),
1522
903
  stateVector: toU8(r["state_vector"]),
1523
904
  payload: payload === null || payload === undefined ? null : toU8(payload),
905
+ governanceParents: JSON.parse(r["governance_parents"] ?? "[]"),
1524
906
  kind: r["kind"],
1525
907
  senderClientId: r["sender_client_id"] ?? null,
1526
908
  referencesEnvelopeHash: r["references_hash"] ?? null,
1527
909
  createdAtMs: r["created_at"],
1528
910
  logIndex: r["log_index"],
1529
- deliveredAtMs: r["delivered_at"] ?? null,
1530
- ackedAtMs: r["acked_at"] ?? null,
1531
- attempts: r["attempts"] ?? 0,
1532
- nextAttemptAtMs: r["next_attempt_at"] ?? null,
1533
911
  };
1534
912
  }
1535
913
  /**