@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
@@ -21,7 +21,6 @@
21
21
  * would be a default answer to "is this authentic", and both inbound paths refuse rather than
22
22
  * admit when it says no.
23
23
  */
24
- import { createHash } from "node:crypto";
25
24
  import { verify } from "@cello-protocol/crypto";
26
25
  import { DocumentStore } from "./document-store.js";
27
26
  import { DocumentEngine } from "./document-engine.js";
@@ -29,9 +28,7 @@ import { DocumentGate } from "./document-gate.js";
29
28
  import { screeningRule, SCREEN_RULE_ID } from "./document-screen.js";
30
29
  import { DocumentRejections } from "./document-rejection.js";
31
30
  import { DocumentInbound } from "./document-inbound.js";
32
- import { DocumentAckInbound } from "./document-ack-inbound.js";
33
31
  import { DocumentAmendmentStore } from "./document-amendment-store.js";
34
- import { DocumentJoinStore } from "./document-join-store.js";
35
32
  import { DocumentFrameRouter } from "./document-frame-router.js";
36
33
  import { LiveDocuments } from "./document-live-docs.js";
37
34
  import { DocumentLifecycle } from "./document-lifecycle.js";
@@ -41,8 +38,9 @@ import { projectDocumentText } from "./document-json.js";
41
38
  import { rootForDocumentType } from "./document-types.js";
42
39
  import { DocumentHandshake } from "./document-handshake.js";
43
40
  import { DocumentWritePath } from "./document-write-path.js";
44
- import { decodeDocumentRejection, buildDocumentRejectionTbs, documentRejectionHash, decodeDocumentProposalAck, buildDocumentProposalAckTbs, decodeDocumentControl, buildDocumentControlTbs, decodeDocumentUpdateEnvelope, buildDocumentUpdateTbs, documentEnvelopeHash, buildDocumentJoinAnswerTbs, encodeDocumentJoinAnswer, buildDocumentJoinOfferTbs, decodeDocumentJoinOffer, decodeDocumentAmendment, decodeDocumentProposal, encodeDocumentProposalAck, DOCUMENT_PROPOSAL_ACK_VERSION, MAX_PROPOSAL_REFUSAL_REASON_LENGTH, documentAmendmentHash, validateDocumentJoinOffer, documentGovernancePolicy, arrangementGenesisFromProposal, deriveArrangement, encodeDocumentAck, buildDocumentAckTbs, } from "@cello-protocol/protocol-types";
41
+ import { decodeDocumentRejection, buildDocumentRejectionTbs, documentRejectionHash, decodeDocumentProposalAck, buildDocumentProposalAckTbs, decodeDocumentUpdateEnvelope, encodeDocumentAmendment, decodeDocumentAmendment, decodeDocumentProposal, encodeDocumentProposal, documentIdFromProposal, buildDocumentProposalTbs, encodeDocumentProposalAck, DOCUMENT_PROPOSAL_ACK_VERSION, MAX_PROPOSAL_REFUSAL_REASON_LENGTH, documentAmendmentHash, documentGovernancePolicy, arrangementGenesisFromProposal, deriveDocumentState, deriveDocumentStateAt, checkEntryAdmissible, decodeDocumentReconcile, encodeDocumentReconcile, DOCUMENT_RECONCILE_EXCHANGE_VERSION, } from "@cello-protocol/protocol-types";
45
42
  import { LEAF_KIND_REJECT } from "./session-relay-client.js";
43
+ import { buildReconcileBlock, respondToReconcile, } from "./document-reconcile-engine.js";
46
44
  /**
47
45
  * The default `publicKeyFor`: a remote agent's id IS its K_local public key, hex-encoded (M14-D5).
48
46
  *
@@ -84,22 +82,16 @@ export function createDocumentLayer(deps) {
84
82
  const record = handshake.get(ownerAgentId, documentId);
85
83
  return record?.envelope.starting_content ?? null;
86
84
  });
87
- const lifecycle = new DocumentLifecycle(store, logger, { notifyPeer: deps.notifyPeer }, deps.rollback, undefined,
88
- // DOD-MP-CONTROL-N-1the inbound gate. `holdersFor` is declared below and resolved lazily,
89
- // the same shape `live` uses above; the gate is only ever called long after construction.
90
- // Wired HERE rather than left to the daemon so every consumer of the layer — including the
91
- // e2e fixture gets the real derivation and cannot silently keep the bilateral gate.
85
+ const lifecycle = new DocumentLifecycle(store, logger,
86
+ // SYNC-D8"was this owner written out?" answered by the same fold as everything else.
87
+ (ownerAgentId, documentId) => standingOf(ownerAgentId, documentId, ownerAgentId) === "removed",
88
+ // Review F2 "is it ended?" answered by the fold too; `derived: false` hands the pre-pivot
89
+ // bilateral record back to its column.
92
90
  (ownerAgentId, documentId) => {
93
- // LEGACY vs UNKNOWN, decided HERE because only this scope can tell them apart: no genesis
94
- // record means no chain at all — a bilateral document predating amendments, for which the
95
- // peer column IS the membership. A genesis record whose chain will not replay is a different
96
- // fact, and one this daemon must not paper over by assuming two parties.
97
- if (!handshake.get(ownerAgentId, documentId))
98
- return { kind: "legacy" };
99
- const holders = holdersFor(ownerAgentId, documentId);
100
- return holders === null
101
- ? { kind: "unknown", reason: "document_chain_underivable" }
102
- : { kind: "derived", holders };
91
+ const derived = reconcileReads(ownerAgentId).deriveState(documentId);
92
+ return derived.ok
93
+ ? { derived: true, ended: derived.state.ended }
94
+ : { derived: false, ended: null };
103
95
  });
104
96
  const notifications = new DocumentNotifications(store, logger);
105
97
  // THE FILE SURFACE. Built, tested and instantiated NOWHERE until now — the same defect the tool
@@ -134,68 +126,34 @@ export function createDocumentLayer(deps) {
134
126
  logger,
135
127
  verifySignature,
136
128
  liveDocFor: (ownerAgentId, documentId) => live.get(ownerAgentId, documentId),
137
- membershipOf: (ownerAgentId, documentId, agentId) => amendments.membershipOf(ownerAgentId, documentId, agentId),
138
- currentHolders: (ownerAgentId, documentId) => holdersFor(ownerAgentId, documentId),
139
- sign: deps.sign,
140
- });
141
- // WAITERS for `awaitAck`, keyed by owner + envelope. A Set per key because two callers may wait
142
- // on the same envelope (a redelivery racing the original), and dropping one of them would leave a
143
- // session held open for the full grace period with the answer already in hand.
144
- // KEYED BY ACKER TOO (FANOUT-1 review H1): an envelope-keyed waiter let ANY holder's ack —
145
- // including a redelivered one from an already-settled holder — resolve the grace wait for
146
- // whichever holder was being dialed, and the worker then settled the DIALED holder's row for
147
- // content that never reached them. §7-1's silent divergence, through the sender's own
148
- // bookkeeping.
149
- const ackWaiters = new Map();
150
- const waiterKey = (ownerAgentId, envelopeHash, ackerAgentId) => `${ownerAgentId}\u0000${envelopeHash}\u0000${ackerAgentId}`;
151
- const ackInbound = new DocumentAckInbound({
152
- store,
153
- rejections,
154
- logger,
155
- verifySignature,
156
- currentHolders: (o, d) => holdersFor(o, d),
157
- onSettled: (ownerAgentId, envelopeHash, ackerAgentId, admitted) => {
158
- const key = waiterKey(ownerAgentId, envelopeHash, ackerAgentId);
159
- const waiting = ackWaiters.get(key);
160
- if (!waiting)
161
- return;
162
- ackWaiters.delete(key);
163
- // The OUTCOME, not just the fact of one. A caller told only "answered" has to report the
164
- // envelope as still in flight, which is how `document.delivery.sweep { delivered: N }` came
165
- // to be permanently 0 — a sweep that delivered nothing looked identical to a healthy one.
166
- for (const wake of waiting)
167
- wake(admitted);
129
+ standingOf: (ownerAgentId, documentId, agentId) => standingOf(ownerAgentId, documentId, agentId),
130
+ // The CONTENT door: strictly participants (R17 invited seats receive, never author).
131
+ currentHolders: (ownerAgentId, documentId) => participantsFor(ownerAgentId, documentId),
132
+ // SYNC-G1 — the world an envelope's signed frontier names (R20's input for the causal gate).
133
+ deriveAtFrontier: (ownerAgentId, documentId, frontier) => {
134
+ const record = handshake.get(ownerAgentId, documentId);
135
+ if (!record)
136
+ return { ok: false, reason: "document_genesis_missing" };
137
+ try {
138
+ const at = deriveDocumentStateAt(arrangementGenesisFromProposal(record.envelope), amendments.chain(ownerAgentId, documentId), frontier, documentGovernancePolicy, verifySignature);
139
+ if (!at.ok)
140
+ return { ok: false, reason: at.reason, missing: at.missing };
141
+ return {
142
+ ok: true,
143
+ participants: at.state.participants,
144
+ invited: at.state.invited,
145
+ ended: at.state.ended,
146
+ };
147
+ }
148
+ catch (err) {
149
+ return {
150
+ ok: false,
151
+ reason: `document_chain_undecodable: ${err instanceof Error ? err.message : String(err)}`,
152
+ };
153
+ }
168
154
  },
155
+ sign: deps.sign,
169
156
  });
170
- const awaitAck = async (ownerAgentId, envelopeHash, expectedAckerAgentId, timeoutMs) => {
171
- // ALREADY SETTLED WINS — by THE HOLDER BEING DIALED (H1): the per-acker read first, the
172
- // envelope-level read only for the zero-row bilateral legacy.
173
- const already = store.holderSettlement(ownerAgentId, envelopeHash, expectedAckerAgentId) ??
174
- store.envelopeSettlement(ownerAgentId, envelopeHash);
175
- if (already)
176
- return already;
177
- const key = waiterKey(ownerAgentId, envelopeHash, expectedAckerAgentId);
178
- return new Promise((resolve) => {
179
- let timer;
180
- const wake = (admitted) => {
181
- clearTimeout(timer);
182
- resolve({ admitted });
183
- };
184
- const set = ackWaiters.get(key) ?? new Set();
185
- set.add(wake);
186
- ackWaiters.set(key, set);
187
- timer = setTimeout(() => {
188
- // DEREGISTER on the way out. A waiter left in the map for an envelope that is never acked
189
- // is a leak the length of the daemon's life, and `unref` is deliberately NOT used: the
190
- // grace period must be able to hold the process just as the seal it precedes does.
191
- const current = ackWaiters.get(key);
192
- current?.delete(wake);
193
- if (current && current.size === 0)
194
- ackWaiters.delete(key);
195
- resolve(null);
196
- }, timeoutMs);
197
- });
198
- };
199
157
  // The handshake verifies a proposal against its NAMED proposer — the same resolver, so a forged
200
158
  // proposal is refused before it can occupy a document_id (ON CONFLICT DO NOTHING makes the first
201
159
  // arrival's bytes permanent for that id).
@@ -204,7 +162,6 @@ export function createDocumentLayer(deps) {
204
162
  // path (`currentDocumentEpoch`) has run through DocumentStore since AMEND-1; these are the
205
163
  // WRITE path, and every append below validates first (the standing AMEND-1 condition).
206
164
  const amendments = new DocumentAmendmentStore(db, logger);
207
- const joins = new DocumentJoinStore(db, logger);
208
165
  /**
209
166
  * M14B / DOD-MP-FANOUT-1 — the CURRENT holders of a document, derived from genesis + the
210
167
  * recorded chain. Null when the document is unknown or its chain does not derive — NOT an
@@ -222,7 +179,22 @@ export function createDocumentLayer(deps) {
222
179
  });
223
180
  return null;
224
181
  }
225
- const derived = deriveArrangement(arrangementGenesisFromProposal(genesisRecord.envelope), amendments.chain(ownerAgentId, documentId), documentGovernancePolicy, verifySignature);
182
+ // CONTAINED. `chain()` decodes every stored amendment and THROWS on bytes this build cannot
183
+ // read, and this function's own contract is to return null when it cannot derive — so the throw
184
+ // escaped past every caller written against that contract. It reached the operator on
185
+ // `cello_doc_write` as a raw `Data read, but end of buffer not reached`: a CBOR library naming
186
+ // where it surfaced, in place of the refusal the publish path was already holding ready.
187
+ let derived;
188
+ try {
189
+ derived = deriveDocumentState(arrangementGenesisFromProposal(genesisRecord.envelope), amendments.chain(ownerAgentId, documentId), documentGovernancePolicy, verifySignature);
190
+ }
191
+ catch (err) {
192
+ logger.error("document.holders.undecodable", {
193
+ documentId,
194
+ reason: err instanceof Error ? err.message : String(err),
195
+ });
196
+ return null;
197
+ }
226
198
  if (!derived.ok) {
227
199
  logger.error("document.holders.underivable", {
228
200
  documentId,
@@ -230,96 +202,612 @@ export function createDocumentLayer(deps) {
230
202
  });
231
203
  return null;
232
204
  }
233
- return [...derived.arrangement.participants];
205
+ // PARTICIPANTS ∪ INVITED (P2 review F4/F5): this is the "who is seated" set — delivery
206
+ // targets, control-frame addressing, and the close agreement all reach every seat, because a
207
+ // peer whose consent entry is still in flight must neither be starved of the entries they
208
+ // will need nor ended around. An invited seat still cannot AUTHOR anything but its own
209
+ // consent/refusal — the CONTENT door checks `participantsFor`, and the fold enforces it for
210
+ // governance regardless of who we send to.
211
+ return [...derived.state.participants, ...derived.state.invited];
234
212
  };
235
213
  /**
236
- * Who a control frame (`close`, `kill`) must be addressed to — DOD-MP-CONTROL-N-1.
237
- *
238
- * THIS LIVES HERE, not as a closure in the composition root, for the reason
239
- * `document-control-notifier.ts`'s own header records about itself: a closure in `daemon.ts` has
240
- * no test, so every fixture hand-copies it, and a hand-copy cannot disagree with the original.
241
- * The first draft of this WAS such a closure and had already drifted — it re-derived the
242
- * arrangement itself and so never emitted `document.holders.underivable`, leaving the control
243
- * path invisible to the exact log search meant to find it.
244
- *
245
- * Everyone EXCEPT the owner, because the owner is the one doing the ending. A chain that will
246
- * not derive is refused by name rather than falling back to the genesis `peerAgentId` — that
247
- * fallback is the original defect, and after a removal it aims the frame at precisely the
248
- * removed holder.
214
+ * Strictly-consented participants the CONTENT-AUTHORSHIP set (R17: an invited seat may
215
+ * receive, never author). The inbound sender gate checks this; everything delivery-facing uses
216
+ * `holdersFor`, which also counts invited seats.
249
217
  */
250
- const controlHolders = (ownerAgentId, documentId) => {
251
- // LEGACY FIRST, and for the same reason the settle path checks it first: a document with no
252
- // stored genesis proposal has NO CHAIN, so the peer column is not a fallback — it IS the
253
- // membership. Without this branch the send path refused the very condition the settle path
254
- // calls legacy, so no close frame ever left, neither side recorded the other's close, and such
255
- // a document could never be ended by agreement at all. Reachable two ways: documents proposed
256
- // before `recordOutgoing` shipped, and a crash between `createDocument` and `recordOutgoing`,
257
- // which run in that order.
258
- //
259
- // The two paths must agree about what "cannot answer" means. Their disagreeing is the whole
260
- // shape of this milestone's defects.
261
- const doc = store.getDocument(ownerAgentId, documentId);
262
- if (doc && !handshake.get(ownerAgentId, documentId)) {
263
- return { ok: true, holders: [doc.peerAgentId] };
218
+ const participantsFor = (ownerAgentId, documentId) => {
219
+ const genesisRecord = handshake.get(ownerAgentId, documentId);
220
+ if (!genesisRecord)
221
+ return null;
222
+ let derived;
223
+ try {
224
+ derived = deriveDocumentState(arrangementGenesisFromProposal(genesisRecord.envelope), amendments.chain(ownerAgentId, documentId), documentGovernancePolicy, verifySignature);
225
+ }
226
+ catch {
227
+ return null;
264
228
  }
265
- let derivedHolders;
229
+ if (!derived.ok)
230
+ return null;
231
+ return [...derived.state.participants];
232
+ };
233
+ /**
234
+ * SYNC-D8 — the ONE derivation of an agent's standing in a document, from the fold. The old
235
+ * old linear membership walk was a second derivation that could disagree with
236
+ * the fold about who is seated; every consumer now asks this.
237
+ *
238
+ * - participant / invited: seated, per R17.
239
+ * - removed: not seated, and an APPLIED remove_holder entry names them (fold-void removals
240
+ * do not count — a void contributed nothing, F4).
241
+ * - stranger: not seated, never removed.
242
+ * - unknown: the chain does not derive (no genesis, undecodable bytes) — callers must not
243
+ * treat this as any of the other four.
244
+ */
245
+ const standingOf = (ownerAgentId, documentId, agentId) => {
246
+ const derived = reconcileReads(ownerAgentId).deriveState(documentId);
247
+ if (!derived.ok)
248
+ return "unknown";
249
+ if (derived.state.participants.has(agentId))
250
+ return "participant";
251
+ if (derived.state.invited.has(agentId))
252
+ return "invited";
253
+ const inert = new Set([
254
+ ...derived.state.voids.map((v) => v.hash),
255
+ ...derived.state.excluded.map((e) => e.hash),
256
+ ]);
257
+ let chain;
266
258
  try {
267
- derivedHolders = holdersFor(ownerAgentId, documentId);
259
+ chain = [...amendments.chain(ownerAgentId, documentId)];
268
260
  }
269
- catch (err) {
270
- // CONTAINED for the same reason `cello_doc_list` contains it: a chain this build cannot
271
- // decode THROWS, and an uncontained throw would take down the close/kill verb entirely
272
- // rather than reporting why the frame could not be addressed.
273
- return {
274
- ok: false,
275
- reason: `document_chain_undecodable: ${err instanceof Error ? err.message : String(err)}`,
276
- };
261
+ catch {
262
+ return "unknown";
263
+ }
264
+ for (const env of chain) {
265
+ if (env.body.kind !== "remove_holder" || env.body.subject_agent_id !== agentId)
266
+ continue;
267
+ const hash = Buffer.from(documentAmendmentHash(env.body)).toString("hex");
268
+ if (!inert.has(hash))
269
+ return "removed";
277
270
  }
278
- // A chain that EXISTS and will not replay keeps the refusal — it may name holders we cannot
279
- // see, and addressing the genesis peer alone there is the original defect.
280
- if (derivedHolders === null)
281
- return { ok: false, reason: "document_holders_underivable" };
282
- return { ok: true, holders: derivedHolders.filter((p) => p !== ownerAgentId).sort() };
271
+ return "stranger";
272
+ };
273
+ /**
274
+ * SYNC-R46 / spec §9 — one party's sync state, from the DISPLAY CACHE against our own
275
+ * positions. ONE implementation for the list surface and the sweep's believed-current
276
+ * suppression (R43) — two copies of "are they behind" is two daemons disagreeing about who
277
+ * needs an exchange. Never a correctness input (R44).
278
+ */
279
+ const partySync = (ownerAgentId, documentId, partyAgentId) => {
280
+ const view = store.partyView(ownerAgentId, documentId, partyAgentId);
281
+ if (!view)
282
+ return { sync: "unseen", lastSyncedAtMs: null };
283
+ let behind = false;
284
+ for (const [author, mark] of amendments.watermarks(ownerAgentId, documentId)) {
285
+ if (mark.seq > (view.govSeqs[author] ?? 0))
286
+ behind = true;
287
+ }
288
+ for (const [author, count] of store.envelopeCountsBySender(ownerAgentId, documentId)) {
289
+ if (count > (view.contentCounts[author] ?? 0))
290
+ behind = true;
291
+ }
292
+ const blockedBy = view.refused.length === 0
293
+ ? undefined
294
+ : view.refused.find((hash) => store.getEnvelopeLog(ownerAgentId, documentId).some((row) => row.docPrevHash === hash));
295
+ return {
296
+ sync: behind ? "behind" : "in_sync",
297
+ lastSyncedAtMs: view.lastExchangeMs,
298
+ ...(blockedBy ? { blockedBy } : {}),
299
+ };
300
+ };
301
+ /**
302
+ * SYNC-P5 (R43) — every (party → shared ACTIVE documents) pair one owner's sweep considers.
303
+ * Seats come from the FOLD, per document; an ended or underivable document contributes
304
+ * nothing, which is what lets a converged-and-ended document reach quiescence.
305
+ */
306
+ const sweepTargets = (ownerAgentId) => {
307
+ const targets = new Map();
308
+ for (const doc of store.listDocuments(ownerAgentId)) {
309
+ if (doc.status !== "active")
310
+ continue;
311
+ const derived = reconcileReads(ownerAgentId).deriveState(doc.documentId);
312
+ if (!derived.ok || derived.state.ended !== null)
313
+ continue;
314
+ for (const seat of [...derived.state.participants, ...derived.state.invited]) {
315
+ if (seat === ownerAgentId)
316
+ continue;
317
+ const docs = targets.get(seat);
318
+ if (docs)
319
+ docs.push(doc.documentId);
320
+ else
321
+ targets.set(seat, [doc.documentId]);
322
+ }
323
+ }
324
+ return targets;
283
325
  };
284
326
  /** Is this agent a CURRENT holder — the ack gate's membership question. */
285
327
  const isCurrentHolder = (ownerAgentId, documentId, agentId) => {
286
328
  const holders = holdersFor(ownerAgentId, documentId);
287
329
  return holders !== null && holders.includes(agentId);
288
330
  };
289
- /** Sign and send a join answer — best-effort, never a veto over the local decision. */
290
- const sendJoinAnswer = async (ownerAgentId, inviterAgentId, documentId, amendmentHash, accepted, reason) => {
331
+ const recordAmendmentImpl = (ownerAgentId, wire, nowMs) => {
332
+ // An entry reaching an EXISTING holder. The door refuses only what no future entry can
333
+ // ever make good — a broken collection binding, an unproven author, a failed signature
334
+ // (checkEntryAdmissible). Everything semantic is the FOLD's ruling at consumption, and a
335
+ // fold-void entry is still history (F4): bouncing it here would leave two holders holding
336
+ // different sets.
337
+ const env = decodeDocumentAmendment(wire);
338
+ const documentId = env.body.document_id;
339
+ if (!store.getDocument(ownerAgentId, documentId)) {
340
+ throw new Error(`document_unknown: no document ${documentId.slice(0, 16)}… for this agent`);
341
+ }
342
+ const record = handshake.get(ownerAgentId, documentId);
343
+ if (!record) {
344
+ throw new Error(`document_genesis_missing: ${documentId.slice(0, 16)}… has a row but no stored genesis ` +
345
+ `proposal to replay from`);
346
+ }
347
+ const admissible = checkEntryAdmissible(env, verifySignature);
348
+ if (!admissible.ok)
349
+ throw new Error(admissible.reason);
350
+ // THE STRANGER DOOR (SYNC-R18, interim until P3's full entitlement classes): a fold-void
351
+ // entry is history only when its author is KNOWN to this document — a genesis party or
352
+ // someone an EFFECTIVE admission names (which covers invited and removed authors, whose
353
+ // earlier work must still converge, R20). Effective, not merely held: a hostile holder can
354
+ // mint a self-signed, fold-void admission naming any key, and counting it would hand that
355
+ // key an unbounded license to grow every holder's entry set (review F4). A stranger's
356
+ // governance is refused by name, never stored.
357
+ const author = env.body.author_agent_id;
358
+ const genesisArr = arrangementGenesisFromProposal(record.envelope);
359
+ let authorKnown = genesisArr.proposerAgentId === author || genesisArr.peerAgentId === author;
360
+ if (!authorKnown) {
361
+ const held = amendments.chain(ownerAgentId, documentId);
362
+ const doorDerived = deriveDocumentState(genesisArr, held, documentGovernancePolicy, verifySignature);
363
+ if (!doorDerived.ok)
364
+ throw new Error(doorDerived.reason);
365
+ const inert = new Set([
366
+ ...doorDerived.state.voids.map((v) => v.hash),
367
+ ...doorDerived.state.excluded.map((e) => e.hash),
368
+ ]);
369
+ authorKnown = held.some((e) => e.body.kind === "add_holder" &&
370
+ e.body.subject_agent_id === author &&
371
+ !inert.has(Buffer.from(documentAmendmentHash(e.body)).toString("hex")));
372
+ }
373
+ if (!authorKnown) {
374
+ throw new Error(`document_author_stranger: ${author} is neither a genesis party nor named by any ` +
375
+ `effective admission — a stranger's governance is refused, not stored`);
376
+ }
377
+ const appended = amendments.append(ownerAgentId, documentId, wire, nowMs);
378
+ // Post-apply surfacing runs for EVERYTHING that just applied: the direct arrival, and
379
+ // every held entry this arrival promoted (review F2 — the held path silently dropped the
380
+ // removal notice and the lifecycle completion, reintroducing the stuck-`active` defect
381
+ // CLOSE-N-1 fixed, on exactly the out-of-order path the pending table exists for).
382
+ const surfaceApplied = (applied) => {
383
+ // DOD-MP-REMOVE-1 — a removal NAMING THIS AGENT is applied and SURFACED, not just
384
+ // stored: the row flips to `removed` (publishes refuse locally, naming the condition;
385
+ // the copy, the file, the history all remain — forward-only by doctrine), and the event
386
+ // is the operator's notice. Everyone else's arrangement changes are visible through
387
+ // list/inbox derivation; being written out of one is the change an operator must not
388
+ // miss.
389
+ if (applied.body.kind === "remove_holder" &&
390
+ applied.body.subject_agent_id === ownerAgentId) {
391
+ logger.warn("document.removed_from", {
392
+ documentId,
393
+ removedBy: applied.collection.required_signers.join(","),
394
+ });
395
+ }
396
+ // THE ARRIVING CONSENT IS THE JOIN ANSWER (SYNC-P3, the D5 replacement): an inviter's
397
+ // pending row settles from the entry itself — the subject's own signed yes or no — so
398
+ // the legacy answer frame carries nothing the record does not. Settle-once semantics
399
+ // SYNC-P4 (R27/R28, review F2): the stored status is a DISPLAY PROJECTION of the derived
400
+ // ending — recomputed on EVERY applied entry, in BOTH directions. One-way, kind-gated
401
+ // syncing let two holders converge on the fold while holding different status columns
402
+ // forever: a concurrently-authored admission re-opens the derivation (an invited seat
403
+ // blocks closure), and nothing ever wrote `active` back. The derivation is the truth; the
404
+ // column exists so the list surface does not re-derive every row, and no correctness gate
405
+ // reads it for endings anymore (canPublish/canAdmit ask the fold).
406
+ {
407
+ const derivedEnd = reconcileReads(ownerAgentId).deriveState(documentId);
408
+ if (derivedEnd.ok) {
409
+ const current = store.getDocument(ownerAgentId, documentId)?.status;
410
+ if (derivedEnd.state.ended !== null && current === "active") {
411
+ store.setDocumentStatus(ownerAgentId, documentId, derivedEnd.state.ended === "killed" ? "killed" : "closed");
412
+ logger.info("document.ended.derived", { documentId, ended: derivedEnd.state.ended });
413
+ }
414
+ else if (derivedEnd.state.ended === null &&
415
+ (current === "closed" || current === "killed")) {
416
+ store.setDocumentStatus(ownerAgentId, documentId, "active");
417
+ logger.info("document.reopened.derived", { documentId });
418
+ }
419
+ }
420
+ }
421
+ };
422
+ // An entry held for missing parents (R14) is recorded but NOT applied — its notices wait
423
+ // with it and fire on promotion. The store's `document.entry.held` event is the trace.
424
+ if (!appended.held)
425
+ surfaceApplied(env);
426
+ for (const promoted of appended.promoted)
427
+ surfaceApplied(promoted.envelope);
428
+ };
429
+ const rewriteFileImpl = async (ownerAgentId, inResponseTo) => {
430
+ if (!writePath)
431
+ return;
432
+ const env = decodeDocumentUpdateEnvelope(inResponseTo);
433
+ const document = store.getDocument(ownerAgentId, env.document_id);
434
+ if (!document)
435
+ return;
436
+ await writePath.materialize(ownerAgentId, env.document_id, document.documentType, live.get(ownerAgentId, env.document_id));
437
+ };
438
+ const noticeInboundUpdateImpl = (ownerAgentId, inResponseTo) => {
439
+ const env = decodeDocumentUpdateEnvelope(inResponseTo);
440
+ // COUNTED FROM THE LOG, not incremented blindly: envelopes redeliver, and a counter bumped on
441
+ // every arrival would drift upward on ordinary retries and tell the operator there is more to
442
+ // read than there is.
443
+ const unread = notifications.unreadFromPeer(ownerAgentId, env.document_id);
444
+ notifications.notice(ownerAgentId, env.document_id, unread, Date.now());
445
+ // DOD-DOC-WATCH-1 — the selective nudge.
446
+ //
447
+ // Matched against what changed since THIS agent last READ, not against this envelope. Per
448
+ // envelope re-fires on redelivery and on the peer's every keystroke; the net difference from
449
+ // the read mark asks the question an operator actually has — has the thing I am waiting on
450
+ // moved since I saw it — and self-cancels the moment they read.
451
+ //
452
+ // Wrapped whole: a notification is a courtesy on a path whose real job (admitting the peer's
453
+ // update) has already succeeded, and must never be able to fail it.
291
454
  try {
292
- const answer = {
293
- type: "document_join_answer",
294
- document_id: documentId,
295
- amendment_hash: amendmentHash,
296
- invitee_agent_id: ownerAgentId,
297
- accepted,
298
- refusal_reason: accepted ? null : reason,
299
- answered_at_ms: Date.now(),
300
- signature: new Uint8Array(0),
301
- };
302
- answer.signature = await deps.sign(ownerAgentId, buildDocumentJoinAnswerTbs(answer));
303
- await deps.sendFrame(ownerAgentId, inviterAgentId, encodeDocumentJoinAnswer(answer));
455
+ const watches = notifications.watches(ownerAgentId, env.document_id);
456
+ if (watches.length === 0)
457
+ return;
458
+ if (!notifications.nudgeOwed(ownerAgentId, env.document_id))
459
+ return;
460
+ const document = store.getDocument(ownerAgentId, env.document_id);
461
+ if (!document)
462
+ return;
463
+ const seen = notifications.lastSeen(ownerAgentId, env.document_id);
464
+ const after = projectDocumentText(live.get(ownerAgentId, env.document_id), document.documentType);
465
+ // NEVER READ is not "everything changed" for the purposes of a nudge — the agent has not
466
+ // established a baseline, so there is nothing it can be waiting on a change to.
467
+ if (seen === null || seen === after)
468
+ return;
469
+ // A text document has no key paths, so the only thing it can report is that it moved. That
470
+ // is why the whole-document watch has to be spelled `*` rather than implied.
471
+ const paths = rootForDocumentType(document.documentType) === "map"
472
+ ? changedKeyPaths(seen, after) ?? ["*"]
473
+ : ["*"];
474
+ const hits = matchWatchedPaths(watches, paths);
475
+ if (hits.length === 0)
476
+ return;
477
+ // THE AGENT'S OWN PATTERNS travel, never the changed paths. A changed path can carry a key
478
+ // the PEER named, and a doorbell body is an unscreened route into the agent's context —
479
+ // see `matchingWatches`. The precise field comes from cello_doc_diff, which IS screened.
480
+ const firedWatches = matchingWatches(watches, paths);
481
+ notifications.markNudged(ownerAgentId, env.document_id, Date.now());
482
+ logger.info("document.watch.nudged", {
483
+ documentId: env.document_id,
484
+ paths: hits.length,
485
+ watches: firedWatches.length,
486
+ });
487
+ deps.nudge?.(ownerAgentId, env.document_id, firedWatches);
304
488
  }
305
489
  catch (err) {
306
- logger.warn("document.join.answer_send_failed", {
307
- documentId,
308
- amendmentHash,
490
+ logger.warn("document.watch.nudge_failed", {
491
+ documentId: env.document_id,
309
492
  error: err instanceof Error ? err.message : String(err),
310
493
  });
311
494
  }
312
495
  };
313
- /** Re-send the STANDING decision for an already-decided offer a redelivery just matched. */
314
- const resendJoinAnswer = async (ownerAgentId, amendmentHash, inviterAgentId) => {
315
- const record = joins.get(ownerAgentId, amendmentHash);
316
- if (!record || record.state === "pending")
317
- return;
318
- await sendJoinAnswer(ownerAgentId, inviterAgentId, record.documentId, amendmentHash, record.state === "accepted", record.reason);
496
+ /** SYNC-P3 the reads the reconcile engine consumes, over THIS owner's stores. */
497
+ const reconcileReads = (ownerAgentId) => ({
498
+ deriveState: (documentId) => {
499
+ const record = handshake.get(ownerAgentId, documentId);
500
+ if (!record)
501
+ return { ok: false, reason: "document_genesis_missing" };
502
+ try {
503
+ return deriveDocumentState(arrangementGenesisFromProposal(record.envelope), amendments.chain(ownerAgentId, documentId), documentGovernancePolicy, verifySignature);
504
+ }
505
+ catch (err) {
506
+ return {
507
+ ok: false,
508
+ reason: `document_chain_undecodable: ${err instanceof Error ? err.message : String(err)}`,
509
+ };
510
+ }
511
+ },
512
+ watermarks: (documentId) => amendments.watermarks(ownerAgentId, documentId),
513
+ entriesByAuthorAfter: (documentId, author, afterSeq) => amendments.entriesByAuthorAfter(ownerAgentId, documentId, author, afterSeq),
514
+ envelopeLog: (documentId) => store.getEnvelopeLog(ownerAgentId, documentId),
515
+ refusedHashes: (documentId) => rejections.quarantined(ownerAgentId, documentId).map((q) => q.rejectedEnvelopeHash),
516
+ // SYNC-R35: the exact signed frames, from the quarantine — rows born before the column hold
517
+ // none, and the exchange simply has nothing to attach for them.
518
+ refusalRecords: (documentId) => rejections
519
+ .quarantined(ownerAgentId, documentId)
520
+ .map((q) => q.rejectionWire)
521
+ .filter((w) => w !== undefined),
522
+ standingOf: (documentId, agentId) => standingOf(ownerAgentId, documentId, agentId),
523
+ genesisBytes: (documentId) => {
524
+ const record = handshake.get(ownerAgentId, documentId);
525
+ return record ? new Uint8Array(encodeDocumentProposal(record.envelope)) : null;
526
+ },
527
+ removalClosure: (documentId, agentId) => {
528
+ // The LAST removal naming them, plus every ancestor — walked over the held entry set.
529
+ const chain = amendments.chain(ownerAgentId, documentId);
530
+ const byHash = new Map(chain.map((env) => [
531
+ Buffer.from(documentAmendmentHash(env.body)).toString("hex"),
532
+ env,
533
+ ]));
534
+ let removalHash = null;
535
+ for (const [hash, env] of byHash) {
536
+ if (env.body.kind === "remove_holder" && env.body.subject_agent_id === agentId) {
537
+ removalHash = hash;
538
+ }
539
+ }
540
+ if (!removalHash)
541
+ return null;
542
+ const wanted = new Set([removalHash]);
543
+ const queue = [removalHash];
544
+ while (queue.length > 0) {
545
+ const env = byHash.get(queue.pop());
546
+ if (!env)
547
+ continue;
548
+ for (const parent of env.body.parents) {
549
+ if (!wanted.has(parent)) {
550
+ wanted.add(parent);
551
+ queue.push(parent);
552
+ }
553
+ }
554
+ }
555
+ // Foldable order: parents before children — the chain read is already epoch/seq ordered.
556
+ return chain
557
+ .filter((env) => wanted.has(Buffer.from(documentAmendmentHash(env.body)).toString("hex")))
558
+ .map((env) => new Uint8Array(encodeDocumentAmendment(env)));
559
+ },
560
+ });
561
+ /**
562
+ * SYNC-P3 — one arriving reconcile frame, all three steps. Apply what it carries (governance
563
+ * entries FIRST, R12 — through the same causal door every amendment takes; then content
564
+ * envelopes through the same inbound gate every update takes), then answer: a version this
565
+ * build does not speak or a stranger gets the named refusal ON the frame; a peer that lacks
566
+ * something gets the difference; a converged exchange gets silence — which is what terminates
567
+ * it (R15: idempotence is the absence of a difference, not bookkeeping).
568
+ */
569
+ const handleReconcile = async (ownerAgentId, wire, senderAgentId, nowMs, correlationId) => {
570
+ const frame = decodeDocumentReconcile(wire);
571
+ if (frame.refusal) {
572
+ // The peer's answer to OUR exchange — a fact to surface, never to retry blindly.
573
+ logger.warn("document.reconcile.refused_by_peer", {
574
+ senderAgentId, reason: frame.refusal.reason, terminal: frame.refusal.terminal, correlationId,
575
+ });
576
+ return { ok: true };
577
+ }
578
+ for (const block of frame.documents) {
579
+ if (block.refusal) {
580
+ logger.warn("document.reconcile.refused_by_peer", {
581
+ documentId: block.document_id, senderAgentId,
582
+ reason: block.refusal.reason, terminal: block.refusal.terminal, correlationId,
583
+ });
584
+ }
585
+ }
586
+ if (frame.exchange_version !== DOCUMENT_RECONCILE_EXCHANGE_VERSION) {
587
+ const reason = `document_reconcile_version: you speak exchange version ${frame.exchange_version} and ` +
588
+ `this holder speaks ${DOCUMENT_RECONCILE_EXCHANGE_VERSION} — upgrade together; there is ` +
589
+ `no dual-speak mode`;
590
+ logger.warn("document.reconcile.version_refused", { senderAgentId, correlationId, reason });
591
+ await deps.sendFrame(ownerAgentId, senderAgentId, encodeDocumentReconcile({
592
+ type: "document_reconcile",
593
+ exchange_version: DOCUMENT_RECONCILE_EXCHANGE_VERSION,
594
+ documents: [],
595
+ refusal: { reason, terminal: false },
596
+ }));
597
+ return { ok: false, reason: "document_reconcile_version" };
598
+ }
599
+ const reads = reconcileReads(ownerAgentId);
600
+ const replyBlocks = [];
601
+ // One reply frame's payload allowance, shared across its blocks (review F3): headroom under
602
+ // the router's 2 MiB frame ceiling so positions, hashes, and CBOR overhead always fit.
603
+ const replyBudget = { remainingBytes: 1_500_000 };
604
+ for (const block of frame.documents) {
605
+ // THE NOTICE, RECEIVED (R25): a position for a document this holder does not hold, with
606
+ // no genesis attached, IS the invitation pointer — the frame already names the document
607
+ // and the session names the inviter. The answer is our EMPTY position ("I hold nothing;
608
+ // send everything"), and the peer's reply carries the genesis and the lot. Two empty
609
+ // hands stay silent, so nothing ping-pongs.
610
+ if (!block.genesis && !store.getDocument(ownerAgentId, block.document_id)) {
611
+ const peerClaimsAnything = block.governance.length > 0 || block.content.length > 0 ||
612
+ block.entries.length > 0 || block.envelopes.length > 0;
613
+ if (peerClaimsAnything) {
614
+ replyBlocks.push({
615
+ document_id: block.document_id,
616
+ governance: [], content: [], refused: [], entries: [], envelopes: [],
617
+ });
618
+ logger.info("document.reconcile.notice_received", {
619
+ documentId: block.document_id, viaAgentId: senderAgentId, correlationId,
620
+ });
621
+ }
622
+ continue;
623
+ }
624
+ // THE JOINER BOOTSTRAP (spec §4 — a joiner is simply very far behind): a block carrying
625
+ // the genesis for a document this holder does not have is the invitation arriving. The
626
+ // anchor is validated the way every genesis is — its hash IS the document id, its
627
+ // signature is the proposer's — and recording it is idempotent. The invitee then derives
628
+ // their own standing (invited) from the entries that follow; their ACCEPT is the ordinary
629
+ // consent-authoring accept.
630
+ if (block.genesis && !store.getDocument(ownerAgentId, block.document_id)) {
631
+ try {
632
+ const genesisEnv = decodeDocumentProposal(block.genesis);
633
+ if (documentIdFromProposal(genesisEnv) !== block.document_id) {
634
+ throw new Error("document_reconcile_genesis_mismatch: the carried genesis does not hash to the " +
635
+ "block's document id — the anchor cannot be swapped");
636
+ }
637
+ // THE ANCHOR IS VERIFIED, NOT TAKEN (P3 review F2): a proposal is only a proposal
638
+ // under its proposer's own signature — an unsigned or forged genesis stored here
639
+ // would attribute a document to someone who never made it, and hand any session
640
+ // peer an unlimited license to grow this store with fabricated documents.
641
+ if (!verifySignature(genesisEnv.proposer_agent_id, buildDocumentProposalTbs(genesisEnv), genesisEnv.signature)) {
642
+ throw new Error(`document_reconcile_genesis_unsigned: the carried genesis does not verify ` +
643
+ `against its named proposer ${genesisEnv.proposer_agent_id}`);
644
+ }
645
+ // AND IT MUST NAME US (F2's entitlement half): this holder bootstraps only a
646
+ // document it is a party to — the genesis peer, or the subject of an admission
647
+ // carried in the same block. Anything else is a stranger's document and is refused,
648
+ // not stored.
649
+ const namedAsGenesisPeer = genesisEnv.peer_agent_id === ownerAgentId;
650
+ const namedByAdmission = block.entries.some((entryWire) => {
651
+ try {
652
+ const env = decodeDocumentAmendment(entryWire);
653
+ return env.body.kind === "add_holder" && env.body.subject_agent_id === ownerAgentId;
654
+ }
655
+ catch {
656
+ return false;
657
+ }
658
+ });
659
+ if (!namedAsGenesisPeer && !namedByAdmission) {
660
+ throw new Error("document_reconcile_not_invited: the carried world names this holder nowhere — " +
661
+ "neither the genesis peer nor the subject of any carried admission; a document " +
662
+ "we are no party to is refused, not stored");
663
+ }
664
+ handshake.recordJoined(ownerAgentId, block.genesis, nowMs);
665
+ store.createDocument({
666
+ documentId: block.document_id,
667
+ ownerAgentId,
668
+ // The GENESIS FACT, not the messenger (F6): the peer column is the proposer's —
669
+ // whoever's session happened to carry the frame may be any holder (forwarding).
670
+ peerAgentId: genesisEnv.proposer_agent_id,
671
+ documentType: genesisEnv.document_type,
672
+ properties: genesisEnv.properties,
673
+ status: "active",
674
+ createdAtMs: nowMs,
675
+ });
676
+ logger.info("document.reconcile.joined", {
677
+ documentId: block.document_id, viaAgentId: senderAgentId, correlationId,
678
+ });
679
+ }
680
+ catch (err) {
681
+ logger.warn("document.reconcile.genesis_refused", {
682
+ documentId: block.document_id, senderAgentId, correlationId,
683
+ reason: err instanceof Error ? err.message : String(err),
684
+ });
685
+ continue;
686
+ }
687
+ }
688
+ // APPLY FIRST (R12 order: governance, then content). Both doors are the ordinary ones —
689
+ // idempotent, held-until-whole, refusal-recording — so a redelivered exchange changes
690
+ // nothing.
691
+ for (const entryWire of block.entries) {
692
+ try {
693
+ recordAmendmentImpl(ownerAgentId, entryWire, nowMs);
694
+ }
695
+ catch (err) {
696
+ logger.warn("document.reconcile.entry_refused", {
697
+ documentId: block.document_id, senderAgentId, correlationId,
698
+ reason: err instanceof Error ? err.message : String(err),
699
+ });
700
+ }
701
+ }
702
+ // SYNC-R35: the sender's SIGNED refusal records — verified against their named refuser
703
+ // and recorded as received rejections, exactly as a directly-sent frame would be. This is
704
+ // how a third holder wedged behind a refused hash learns its name and reason (F5/F8), and
705
+ // how the refusal keeps traveling once the refuser is gone. Idempotent by primary key.
706
+ for (const refusalWire of block.refusals ?? []) {
707
+ try {
708
+ const rej = decodeDocumentRejection(new Uint8Array(refusalWire));
709
+ if (rej.document_id !== block.document_id) {
710
+ throw new Error("document_refusal_document_mismatch: the record names another document");
711
+ }
712
+ if (!verifySignature(rej.rejecting_agent_id, buildDocumentRejectionTbs(rej), rej.signature)) {
713
+ throw new Error(`document_rejection_signature_invalid: the refusal claims to come from ` +
714
+ `${rej.rejecting_agent_id} but its signature does not verify against that agent`);
715
+ }
716
+ rejections.recordIncomingRejection(ownerAgentId, rej.document_id, {
717
+ rejectionEnvelopeHash: documentRejectionHash(rej),
718
+ rejectedEnvelopeHash: rej.rejected_envelope_hash,
719
+ reason: rej.reason,
720
+ detail: rej.detail,
721
+ fromAgentId: rej.rejecting_agent_id,
722
+ });
723
+ }
724
+ catch (err) {
725
+ logger.warn("document.reconcile.refusal_record_refused", {
726
+ documentId: block.document_id, senderAgentId, correlationId,
727
+ reason: err instanceof Error ? err.message : String(err),
728
+ });
729
+ }
730
+ }
731
+ for (const envWire of block.envelopes) {
732
+ const res = await inbound.receive(ownerAgentId, envWire, nowMs, correlationId);
733
+ if (res.ok && res.admitted) {
734
+ try {
735
+ noticeInboundUpdateImpl(ownerAgentId, envWire);
736
+ }
737
+ catch { /* the notice is a row; the content is already the truth */ }
738
+ void rewriteFileImpl(ownerAgentId, envWire).catch((err) => {
739
+ logger.warn("document.reconcile.rewrite_failed", {
740
+ correlationId,
741
+ reason: err instanceof Error ? err.message : String(err),
742
+ });
743
+ });
744
+ }
745
+ }
746
+ const answer = respondToReconcile(reads, senderAgentId, block, replyBudget);
747
+ // SYNC-P5 (spec §9): the block's position IS what this party last claimed — recorded as
748
+ // the display cache the list surface reads (in_sync|behind|unseen). AFTER the entitlement
749
+ // ruling (P5 review F9): a stranger's claimed position is not worth a row. Never consulted
750
+ // for correctness (R44).
751
+ if (!answer.block.refusal && store.getDocument(ownerAgentId, block.document_id)) {
752
+ store.recordPartyView(ownerAgentId, block.document_id, senderAgentId, {
753
+ govSeqs: Object.fromEntries(block.governance.map((g) => [g.author, g.seq])),
754
+ contentCounts: Object.fromEntries(block.content.map((c) => [c.author, c.count])),
755
+ refused: [...block.refused],
756
+ }, nowMs);
757
+ }
758
+ if (answer.block.refusal) {
759
+ // Per-document, on the block (review F4) — a batch never silences one document's no.
760
+ logger.warn("document.reconcile.refused", {
761
+ documentId: block.document_id, senderAgentId,
762
+ reason: answer.block.refusal.reason, terminal: answer.block.refusal.terminal,
763
+ correlationId,
764
+ });
765
+ replyBlocks.push(answer.block);
766
+ continue;
767
+ }
768
+ if (answer.truncated) {
769
+ // The byte budget cut the payload (review F3) — LOUD, never a silently-oversized frame
770
+ // the router would drop as unshaped. The exchange is idempotent: the peer's next
771
+ // initiate picks up from its advanced position.
772
+ logger.warn("document.reconcile.truncated", {
773
+ documentId: block.document_id, senderAgentId, correlationId,
774
+ });
775
+ }
776
+ // Refusal records RIDE replies; they never CAUSE one — counting them here would make any
777
+ // document with one standing refusal answer every exchange forever, and the exchange is
778
+ // terminated by silence. The wedge they exist to cure (F5) always involves a real
779
+ // difference, so they are on board whenever they matter.
780
+ const hasDifference = answer.block.entries.length > 0 || answer.block.envelopes.length > 0;
781
+ if (hasDifference || answer.peerAhead) {
782
+ replyBlocks.push(answer.block);
783
+ }
784
+ }
785
+ if (replyBlocks.length > 0) {
786
+ await deps.sendFrame(ownerAgentId, senderAgentId, encodeDocumentReconcile({
787
+ type: "document_reconcile",
788
+ exchange_version: DOCUMENT_RECONCILE_EXCHANGE_VERSION,
789
+ documents: replyBlocks,
790
+ }));
791
+ }
792
+ return { ok: true };
793
+ };
794
+ /** SYNC-P3 — step 1: send our position for these documents to a peer. */
795
+ const initiateReconcile = async (ownerAgentId, peerAgentId, documentIds) => {
796
+ const reads = reconcileReads(ownerAgentId);
797
+ const blocks = documentIds.map((id) => buildReconcileBlock(reads, id));
798
+ const sent = await deps.sendFrame(ownerAgentId, peerAgentId, encodeDocumentReconcile({
799
+ type: "document_reconcile",
800
+ exchange_version: DOCUMENT_RECONCILE_EXCHANGE_VERSION,
801
+ documents: blocks,
802
+ }));
803
+ if (!sent.ok)
804
+ return { ok: false, reason: sent.reason ?? "document_reconcile_send_failed" };
805
+ logger.info("document.reconcile.initiated", { peerAgentId, documents: documentIds.length });
806
+ return { ok: true };
319
807
  };
320
808
  const router = new DocumentFrameRouter({
321
809
  inbound,
322
- ackInbound,
810
+ handleReconcile,
323
811
  logger,
324
812
  ownerKeyFor: deps.ownerKeyFor,
325
813
  recordProposal: (ownerAgentId, wire, nowMs) => {
@@ -358,174 +846,12 @@ export function createDocumentLayer(deps) {
358
846
  })();
359
847
  }
360
848
  },
361
- recordJoinOffer: (ownerAgentId, wire, nowMs) => {
362
- const offer = decodeDocumentJoinOffer(wire);
363
- // ADDRESSED TO US — the same multi-agent-daemon rule recordProposal enforces: consent
364
- // belongs to the agent the inviter named, and a throw here is contained by the router.
365
- if (offer.invitee_agent_id !== ownerAgentId) {
366
- throw new Error(`document_join_wrong_invitee: this offer is addressed to ${offer.invitee_agent_id}, ` +
367
- `not to ${ownerAgentId}`);
368
- }
369
- const validation = validateDocumentJoinOffer(offer, documentGovernancePolicy, verifySignature);
370
- if (validation.ok) {
371
- const stored = joins.recordIncoming(ownerAgentId, wire, validation.pendingAmendmentHash, { state: "pending" }, nowMs);
372
- // REDELIVERY OF A DECIDED OFFER RE-SENDS THE ANSWER. A lost answer was otherwise lost
373
- // forever: the invitee's decision stood, the inviter's row sat pending, and the
374
- // inviter's only lever — re-inviting, which redelivers this exact offer — reached a
375
- // DO-NOTHING insert. Now that redelivery is the recovery: the standing decision is
376
- // re-signed and travels again. Best-effort, like every answer.
377
- if (stored.state !== "pending") {
378
- void resendJoinAnswer(ownerAgentId, validation.pendingAmendmentHash, offer.inviter_agent_id);
379
- }
380
- return;
381
- }
382
- // A REFUSAL IS RECORDED — AND ANSWERED — ONLY WHEN IT IS AUTHENTICATED. The first cut
383
- // recorded every refusal under the admitting amendment's hash, and that was a standing
384
- // veto: any party holding the amendment bytes (every holder gets them in the fan-out)
385
- // could deliver a garbage offer FIRST, occupy the real settle key with a refused row, and
386
- // the genuine offer would arrive to a DO-NOTHING insert — the admin's admission silently
387
- // suppressed, both operators seeing nothing. So: an offer whose signature does not verify
388
- // against its named inviter THROWS (contained + logged, nothing recorded — the forged-
389
- // proposal treatment), and an authenticated refusal is recorded under the hash of ITS OWN
390
- // BYTES — visible to the operator, never able to occupy a real join's settle key.
391
- if (!verifySignature(offer.inviter_agent_id, buildDocumentJoinOfferTbs(offer), offer.signature)) {
392
- throw new Error(validation.reason);
393
- }
394
- const refusalKey = createHash("sha256").update(wire).digest("hex");
395
- joins.recordIncoming(ownerAgentId, wire, refusalKey, { state: "refused", reason: validation.reason }, nowMs);
396
- // BOTH ENDS get the sentence: the refusal answer travels back to the authenticated
397
- // inviter, settling their row with the reason — the version-mismatch sentence was written
398
- // for a human and was reaching a database column. The settle key is the admitting
399
- // amendment's hash when one is recoverable (the inviter's row lives under it).
400
- const last = offer.amendments[offer.amendments.length - 1];
401
- if (last !== undefined) {
402
- try {
403
- const settleKey = Buffer.from(documentAmendmentHash(decodeDocumentAmendment(last).body)).toString("hex");
404
- void sendJoinAnswer(ownerAgentId, offer.inviter_agent_id, offer.document_id, settleKey, false, validation.reason);
405
- }
406
- catch {
407
- // No recoverable settle key — the refusal stays visible locally and the inviter's
408
- // re-invite (which redelivers) will meet the same recorded refusal.
409
- }
410
- }
411
- },
412
- recordJoinAnswer: (ownerAgentId, wire, nowMs) => {
413
- const r = joins.recordAnswer(ownerAgentId, wire, verifySignature, nowMs);
414
- // A refusal is a verdict the router contains and reports — never a silent drop.
415
- if (!r.ok)
416
- throw new Error(r.reason);
417
- },
418
- recordAmendment: (ownerAgentId, wire, nowMs) => {
419
- // An amendment reaching an EXISTING holder. VALIDATE-BEFORE-APPEND (the AMEND-1 standing
420
- // condition): the whole chain, including this arrival, must replay through the real policy
421
- // before one byte lands in the table — a row in document_amendments is post-validation by
422
- // invariant, and epoch stamping rests on that.
423
- const env = decodeDocumentAmendment(wire);
424
- const documentId = env.body.document_id;
425
- if (!store.getDocument(ownerAgentId, documentId)) {
426
- throw new Error(`document_unknown: no document ${documentId.slice(0, 16)}… for this agent`);
427
- }
428
- const record = handshake.get(ownerAgentId, documentId);
429
- if (!record) {
430
- throw new Error(`document_genesis_missing: ${documentId.slice(0, 16)}… has a row but no stored genesis ` +
431
- `proposal to replay from`);
432
- }
433
- const derived = deriveArrangement(arrangementGenesisFromProposal(record.envelope), [...amendments.chain(ownerAgentId, documentId), env], documentGovernancePolicy, verifySignature);
434
- if (!derived.ok)
435
- throw new Error(derived.reason);
436
- amendments.append(ownerAgentId, documentId, wire, nowMs);
437
- // DOD-MP-REMOVE-1 — a removal NAMING THIS AGENT is applied and SURFACED, not just stored:
438
- // the row flips to `removed` (publishes refuse locally, naming the condition; the copy,
439
- // the file, the history all remain — forward-only by doctrine), and the event is the
440
- // operator's notice. Everyone else's arrangement changes are visible through list/inbox
441
- // derivation; being written out of one is the change an operator must not miss.
442
- if (env.body.kind === "remove_holder" && env.body.subject_agent_id === ownerAgentId) {
443
- // Applied by DERIVATION — the recorded chain IS the removal; publish gates and the list
444
- // overlay read it. This warn is the operator's notice.
445
- logger.warn("document.removed_from", {
446
- documentId,
447
- epochId: env.body.epoch_id,
448
- removedBy: env.collection.required_signers.join(","),
449
- });
450
- }
451
- // DOD-MP-CLOSE-N-1 — a membership change can COMPLETE an agreement. Removing the one holder
452
- // who had not closed leaves everyone who remains in agreement, and without this the document
453
- // stayed `active` forever reporting that it waited on nobody: control frames are fire-once
454
- // and never swept, so no later event would have settled it.
455
- if (env.body.kind === "remove_holder" || env.body.kind === "add_holder") {
456
- lifecycle.onMembershipChanged(ownerAgentId, documentId, env.body.kind === "remove_holder" ? (env.body.subject_agent_id ?? undefined) : undefined);
457
- }
458
- },
849
+ recordAmendment: recordAmendmentImpl,
459
850
  // `_nowMs` unused: the received-rejection row takes its clock where it is written, and the
460
851
  // rejection's own SIGNED timestamp is inside the envelope. A second clock read here would put a
461
852
  // third time on one event.
462
- rewriteFile: async (ownerAgentId, inResponseTo) => {
463
- if (!writePath)
464
- return;
465
- const env = decodeDocumentUpdateEnvelope(inResponseTo);
466
- const document = store.getDocument(ownerAgentId, env.document_id);
467
- if (!document)
468
- return;
469
- await writePath.materialize(ownerAgentId, env.document_id, document.documentType, live.get(ownerAgentId, env.document_id));
470
- },
471
- noticeInboundUpdate: (ownerAgentId, inResponseTo) => {
472
- const env = decodeDocumentUpdateEnvelope(inResponseTo);
473
- // COUNTED FROM THE LOG, not incremented blindly: envelopes redeliver, and a counter bumped on
474
- // every arrival would drift upward on ordinary retries and tell the operator there is more to
475
- // read than there is.
476
- const unread = notifications.unreadFromPeer(ownerAgentId, env.document_id);
477
- notifications.notice(ownerAgentId, env.document_id, unread, Date.now());
478
- // DOD-DOC-WATCH-1 — the selective nudge.
479
- //
480
- // Matched against what changed since THIS agent last READ, not against this envelope. Per
481
- // envelope re-fires on redelivery and on the peer's every keystroke; the net difference from
482
- // the read mark asks the question an operator actually has — has the thing I am waiting on
483
- // moved since I saw it — and self-cancels the moment they read.
484
- //
485
- // Wrapped whole: a notification is a courtesy on a path whose real job (admitting the peer's
486
- // update) has already succeeded, and must never be able to fail it.
487
- try {
488
- const watches = notifications.watches(ownerAgentId, env.document_id);
489
- if (watches.length === 0)
490
- return;
491
- if (!notifications.nudgeOwed(ownerAgentId, env.document_id))
492
- return;
493
- const document = store.getDocument(ownerAgentId, env.document_id);
494
- if (!document)
495
- return;
496
- const seen = notifications.lastSeen(ownerAgentId, env.document_id);
497
- const after = projectDocumentText(live.get(ownerAgentId, env.document_id), document.documentType);
498
- // NEVER READ is not "everything changed" for the purposes of a nudge — the agent has not
499
- // established a baseline, so there is nothing it can be waiting on a change to.
500
- if (seen === null || seen === after)
501
- return;
502
- // A text document has no key paths, so the only thing it can report is that it moved. That
503
- // is why the whole-document watch has to be spelled `*` rather than implied.
504
- const paths = rootForDocumentType(document.documentType) === "map"
505
- ? changedKeyPaths(seen, after) ?? ["*"]
506
- : ["*"];
507
- const hits = matchWatchedPaths(watches, paths);
508
- if (hits.length === 0)
509
- return;
510
- // THE AGENT'S OWN PATTERNS travel, never the changed paths. A changed path can carry a key
511
- // the PEER named, and a doorbell body is an unscreened route into the agent's context —
512
- // see `matchingWatches`. The precise field comes from cello_doc_diff, which IS screened.
513
- const firedWatches = matchingWatches(watches, paths);
514
- notifications.markNudged(ownerAgentId, env.document_id, Date.now());
515
- logger.info("document.watch.nudged", {
516
- documentId: env.document_id,
517
- paths: hits.length,
518
- watches: firedWatches.length,
519
- });
520
- deps.nudge?.(ownerAgentId, env.document_id, firedWatches);
521
- }
522
- catch (err) {
523
- logger.warn("document.watch.nudge_failed", {
524
- documentId: env.document_id,
525
- error: err instanceof Error ? err.message : String(err),
526
- });
527
- }
528
- },
853
+ rewriteFile: rewriteFileImpl,
854
+ noticeInboundUpdate: noticeInboundUpdateImpl,
529
855
  sendFrameToPeer: async (ownerAgentId, inResponseTo, bytes) => {
530
856
  // Addressed to whoever AUTHORED the envelope being answered, taken from the envelope itself
531
857
  // rather than from the document row: the row's peer and the envelope's sender are the same
@@ -541,53 +867,6 @@ export function createDocumentLayer(deps) {
541
867
  });
542
868
  }
543
869
  },
544
- sendAck: async (ownerAgentId, wire, outcome) => {
545
- const env = decodeDocumentUpdateEnvelope(wire);
546
- const ack = {
547
- type: "document_ack",
548
- // The wire version this build speaks. Inlined rather than imported because protocol-types
549
- // exports the domain and the codec but not the constant; the decoder pins it by value.
550
- ack_version: 1,
551
- document_id: env.document_id,
552
- envelope_hash: outcome.envelopeHash,
553
- acker_agent_id: ownerAgentId,
554
- admitted: outcome.admitted,
555
- ...(outcome.admitted ? {} : { rejection_reason: outcome.rejectionReason ?? "refused" }),
556
- acked_at_ms: Date.now(),
557
- signature: new Uint8Array(0),
558
- };
559
- ack.signature = await deps.sign(ownerAgentId, buildDocumentAckTbs(ack));
560
- // BEST-EFFORT, and it must be: an ack that could not be sent is not a reason to refuse
561
- // content we have already admitted. The sender redelivers on its own timer, and the
562
- // redelivery is acked too — which is the case this whole path exists to terminate.
563
- const sent = await deps.sendFrame(ownerAgentId, env.sender_agent_id, encodeDocumentAck(ack));
564
- if (!sent.ok) {
565
- logger.warn("document.ack.unsent", {
566
- documentId: env.document_id,
567
- envelopeHash: outcome.envelopeHash,
568
- reason: sent.reason,
569
- correlationId: outcome.correlationId,
570
- });
571
- }
572
- },
573
- recordControl: (ownerAgentId, wire, nowMs) => {
574
- const control = decodeDocumentControl(wire);
575
- // VERIFIED FIRST. A kill frame ends a collaboration; unsigned, anyone reaching the channel
576
- // could end any document between any two parties and each operator would believe the other
577
- // walked away.
578
- if (!verifySignature(control.sender_agent_id, buildDocumentControlTbs(control), control.signature)) {
579
- throw new Error(`document_control_signature_invalid: the ${control.verb} claims to come from ` +
580
- `${control.sender_agent_id} but its signature does not verify against that agent`);
581
- }
582
- const verdict = control.verb === "kill"
583
- ? lifecycle.recordPeerKill(ownerAgentId, control.document_id, control.sender_agent_id, nowMs)
584
- : lifecycle.recordPeerClose(ownerAgentId, control.document_id, control.sender_agent_id, nowMs);
585
- if (!verdict.ok) {
586
- // Refusals here are real: an unknown document, or a sender who is not this document's peer.
587
- // Thrown so the router reports them rather than recording an end nobody was entitled to.
588
- throw new Error(`${verdict.reason}: ${verdict.detail}`);
589
- }
590
- },
591
870
  recordProposalAck: (ownerAgentId, wire, _nowMs) => {
592
871
  const ack = decodeDocumentProposalAck(wire);
593
872
  // VERIFIED against the agent it names, before anything is written. A refusal ack makes the
@@ -630,213 +909,40 @@ export function createDocumentLayer(deps) {
630
909
  });
631
910
  },
632
911
  });
633
- /**
634
- * M14B / DOD-MP-JOIN-1 — the invitee's ACCEPT: consent becomes a held document.
635
- *
636
- * VALIDATE EVERYTHING, THEN MUTATE. The stored offer is re-validated at the moment of
637
- * consequence (validate-before-append, on the bytes that will be appended), and the whole
638
- * envelope-log snapshot is verified — every signature, every document binding — before one row
639
- * lands. A snapshot with one bad envelope refuses the accept naming it, because skipping it
640
- * silently would hand the joiner a document that reads complete and diverges from every other
641
- * holder (NO-SILENT-DROP).
642
- *
643
- * Historical log envelopes are accepted at the epoch their SIGNED bytes claim — the inbound
644
- * epoch gate is for live arrivals; a snapshot legitimately spans epochs 0..N.
645
- */
646
- const acceptJoin = async (ownerAgentId, amendmentHash, nowMs) => {
647
- const record = joins.get(ownerAgentId, amendmentHash);
648
- if (!record || record.role !== "invitee") {
649
- return {
650
- ok: false,
651
- reason: "join_unknown_offer",
652
- detail: `no join offer for this agent settles on ${amendmentHash.slice(0, 16)}…`,
653
- };
654
- }
655
- if (record.state !== "pending") {
656
- return {
657
- ok: false,
658
- reason: "join_already_decided",
659
- detail: `this offer was already ${record.state} — a consent decision is made once`,
660
- };
661
- }
662
- const validation = validateDocumentJoinOffer(record.offer, documentGovernancePolicy, verifySignature);
663
- if (!validation.ok) {
664
- // Recorded, not just returned: an offer that no longer validates is a settled fact.
665
- joins.decide(ownerAgentId, amendmentHash, false, validation.reason, nowMs);
666
- return { ok: false, reason: "join_offer_invalid", detail: validation.reason };
667
- }
668
- const documentId = record.documentId;
669
- // WHO MAY APPEAR IN THE SNAPSHOT: anyone who held the document at ANY epoch of the carried
670
- // chain — the genesis pair plus every add_holder subject (a removed holder's history stays
671
- // legal). Without this, a malicious inviter plants envelopes signed by keys they control
672
- // under identities that were never holders, and the joiner materializes content no
673
- // legitimate holder ever saw — silent divergence the live path's sender-is-peer check
674
- // would have refused.
675
- const everHeld = new Set([
676
- validation.genesis.proposer_agent_id,
677
- validation.genesis.peer_agent_id,
678
- ]);
679
- for (const amendment of validation.amendments) {
680
- if (amendment.body.kind === "add_holder" && amendment.body.subject_agent_id !== null) {
681
- everHeld.add(amendment.body.subject_agent_id);
682
- }
683
- }
684
- // The WHOLE snapshot verified before any mutation.
685
- const verified = [];
686
- for (let i = 0; i < record.offer.envelope_log.length; i++) {
687
- const bytes = record.offer.envelope_log[i];
688
- let env;
689
- try {
690
- env = decodeDocumentUpdateEnvelope(bytes);
691
- }
692
- catch (err) {
693
- return {
694
- ok: false,
695
- reason: "join_log_invalid",
696
- detail: `snapshot envelope ${i} does not decode: ${err instanceof Error ? err.message : String(err)}`,
697
- };
698
- }
699
- if (env.document_id !== documentId) {
700
- return {
701
- ok: false,
702
- reason: "join_log_invalid",
703
- detail: `snapshot envelope ${i} names document ${env.document_id.slice(0, 16)}…, not this one`,
704
- };
705
- }
706
- if (!verifySignature(env.sender_agent_id, buildDocumentUpdateTbs(env), env.signature)) {
707
- return {
708
- ok: false,
709
- reason: "join_log_invalid",
710
- detail: `snapshot envelope ${i} does not verify against its sender ${env.sender_agent_id}`,
711
- };
712
- }
713
- if (!everHeld.has(env.sender_agent_id)) {
714
- return {
715
- ok: false,
716
- reason: "join_log_invalid",
717
- detail: `snapshot envelope ${i} is signed by ${env.sender_agent_id}, who never held this ` +
718
- `document at any epoch of the carried chain — refused before anything materializes`,
719
- };
720
- }
721
- verified.push({
722
- envelopeHash: documentEnvelopeHash(env),
723
- documentId,
724
- senderAgentId: env.sender_agent_id,
725
- docPrevHash: env.doc_prev_hash,
726
- epochId: env.epoch_id,
727
- signature: env.signature,
728
- stateVector: env.state_vector,
729
- payload: env.update,
730
- kind: "update",
731
- referencesEnvelopeHash: null,
732
- createdAtMs: nowMs,
733
- });
734
- }
735
- // Mutations, in dependency order: genesis (LiveDocuments reads starting_content from it),
736
- // the chain (idempotent on redelivery), the row, the log, the live rebuild, the file.
737
- handshake.recordJoined(ownerAgentId, record.offer.genesis, nowMs);
738
- try {
739
- for (const bytes of record.offer.amendments) {
740
- amendments.append(ownerAgentId, documentId, bytes, nowMs);
741
- }
742
- }
743
- catch (err) {
744
- // TWO ADMINS INVITED THE SAME AGENT INDEPENDENTLY: each offer's chain is self-consistent
745
- // and both sat pending, but their admitting amendments rival at one epoch — the second
746
- // accept hits the store's fork refusal mid-append. Settled as refused with the conflict's
747
- // own reason rather than thrown: a raw exception left the row pending and every retry
748
- // rethrowing, a wedge the operator could not see past.
749
- const reason = err instanceof Error ? err.message : String(err);
750
- joins.decide(ownerAgentId, amendmentHash, false, reason, nowMs);
751
- return { ok: false, reason: "join_conflicting_admission", detail: reason };
752
- }
753
- if (!store.getDocument(ownerAgentId, documentId)) {
754
- store.createDocument({
755
- documentId,
756
- ownerAgentId,
757
- // The inviter is this holder's live counterpart until fan-out delivery (P2) — the
758
- // participants that MATTER derive from the amendment chain (Entry 9's decision).
759
- peerAgentId: record.inviterAgentId,
760
- documentType: validation.genesis.document_type,
761
- properties: validation.genesis.properties,
762
- status: "active",
763
- createdAtMs: nowMs,
764
- });
765
- }
766
- for (const row of verified)
767
- store.appendEnvelope(ownerAgentId, row);
768
- const doc = live.get(ownerAgentId, documentId);
769
- if (writePath) {
770
- await writePath.materialize(ownerAgentId, documentId, validation.genesis.document_type, doc);
771
- }
772
- joins.decide(ownerAgentId, amendmentHash, true, null, nowMs);
773
- const answer = {
774
- type: "document_join_answer",
775
- document_id: documentId,
776
- amendment_hash: amendmentHash,
777
- invitee_agent_id: ownerAgentId,
778
- accepted: true,
779
- refusal_reason: null,
780
- answered_at_ms: nowMs,
781
- signature: new Uint8Array(0),
782
- };
783
- answer.signature = await deps.sign(ownerAgentId, buildDocumentJoinAnswerTbs(answer));
784
- logger.info("document.join.accepted", { documentId, amendmentHash, applied: verified.length });
785
- return {
786
- ok: true,
787
- documentId,
788
- inviterAgentId: record.inviterAgentId,
789
- documentType: validation.genesis.document_type,
790
- applied: verified.length,
791
- answerBytes: encodeDocumentJoinAnswer(answer),
792
- };
793
- };
794
- /** The invitee's REFUSE — local and final; the signed answer travels best-effort. */
795
- const refuseJoin = async (ownerAgentId, amendmentHash, reason, nowMs) => {
796
- const record = joins.get(ownerAgentId, amendmentHash);
797
- if (!record || record.role !== "invitee") {
798
- return {
799
- ok: false,
800
- reason: "join_unknown_offer",
801
- detail: `no join offer for this agent settles on ${amendmentHash.slice(0, 16)}…`,
802
- };
803
- }
804
- const decided = joins.decide(ownerAgentId, amendmentHash, false, reason, nowMs);
805
- if (!decided.decided) {
806
- return {
807
- ok: false,
808
- reason: "join_already_decided",
809
- detail: `this offer was already ${decided.state} — a consent decision is made once`,
810
- };
811
- }
812
- const answer = {
813
- type: "document_join_answer",
814
- document_id: record.documentId,
815
- amendment_hash: amendmentHash,
816
- invitee_agent_id: ownerAgentId,
817
- accepted: false,
818
- refusal_reason: reason,
819
- answered_at_ms: nowMs,
820
- signature: new Uint8Array(0),
821
- };
822
- answer.signature = await deps.sign(ownerAgentId, buildDocumentJoinAnswerTbs(answer));
823
- logger.info("document.join.refused", { documentId: record.documentId, amendmentHash });
824
- return {
825
- ok: true,
826
- documentId: record.documentId,
827
- inviterAgentId: record.inviterAgentId,
828
- answerBytes: encodeDocumentJoinAnswer(answer),
829
- };
830
- };
831
912
  return {
832
913
  amendments,
833
- joins,
834
914
  verifySignature,
835
915
  holdersFor,
836
- controlHolders,
837
916
  isCurrentHolder,
838
- acceptJoin,
839
- refuseJoin,
917
+ standingOf,
918
+ partySync,
919
+ sweepTargets,
920
+ initiateReconcile,
921
+ governanceFrontierFor: (ownerAgentId, documentId) => {
922
+ const derived = reconcileReads(ownerAgentId).deriveState(documentId);
923
+ if (!derived.ok) {
924
+ // NAMED, not collapsed to null (review F4): the derivation's own reason — a missing
925
+ // genesis, undecodable bytes, a fold refusal — is the thing the operator hunts for,
926
+ // and the old holders gate logged it while this gate ran first and said nothing.
927
+ logger.error("document.frontier.underivable", { documentId, reason: derived.reason });
928
+ return null;
929
+ }
930
+ return [...derived.state.frontier];
931
+ },
932
+ deriveEnded: (ownerAgentId, documentId) => {
933
+ const derived = reconcileReads(ownerAgentId).deriveState(documentId);
934
+ if (!derived.ok)
935
+ return null;
936
+ return {
937
+ ended: derived.state.ended,
938
+ // Who the ending still waits on: every participant without a close entry, AND every open
939
+ // invitation — an invited seat IS a seat (Entry 54), so a close cannot settle around it.
940
+ waitingOn: [
941
+ ...[...derived.state.participants].filter((participant) => !derived.state.closedBy.has(participant)),
942
+ ...derived.state.invited,
943
+ ],
944
+ };
945
+ },
840
946
  store,
841
947
  writePath,
842
948
  handshake,
@@ -846,15 +952,14 @@ export function createDocumentLayer(deps) {
846
952
  notifications,
847
953
  rejections,
848
954
  router,
849
- awaitAck,
850
- onDocumentFrame: (agentName, _sessionId, content, _senderPubkey, correlationId) =>
955
+ onDocumentFrame: (agentName, _sessionId, content, senderPubkey, correlationId) =>
851
956
  // `agentName` names the owning agent for the session; the router maps it to the owner KEY
852
957
  // that scopes the store. The session id and the sender's transport pubkey are unused: a
853
958
  // document is bound to its PEER by the handshake, not to whichever session carried the
854
959
  // frame, and the envelope's own signed `sender_agent_id` is what the inbound path checks
855
960
  // against that binding. Trusting the transport identity instead would let a frame arriving
856
961
  // on any session act on any document that session's peer happens to share.
857
- router.routeSync(agentName, content, Date.now(), correlationId ?? "frame"),
962
+ router.routeSync(agentName, content, Date.now(), correlationId ?? "frame", senderPubkey),
858
963
  };
859
964
  }
860
965
  //# sourceMappingURL=document-layer.js.map