@cello-protocol/daemon 0.0.168 → 0.0.170

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/dist/agent-id-migration.d.ts.map +1 -1
  2. package/dist/agent-id-migration.js +16 -0
  3. package/dist/agent-id-migration.js.map +1 -1
  4. package/dist/away-detection.d.ts +62 -15
  5. package/dist/away-detection.d.ts.map +1 -1
  6. package/dist/away-detection.js +77 -20
  7. package/dist/away-detection.js.map +1 -1
  8. package/dist/close-session-handler.d.ts.map +1 -1
  9. package/dist/close-session-handler.js +69 -3
  10. package/dist/close-session-handler.js.map +1 -1
  11. package/dist/daemon.d.ts.map +1 -1
  12. package/dist/daemon.js +406 -303
  13. package/dist/daemon.js.map +1 -1
  14. package/dist/delivery-open-registry.d.ts +92 -0
  15. package/dist/delivery-open-registry.d.ts.map +1 -0
  16. package/dist/delivery-open-registry.js +121 -0
  17. package/dist/delivery-open-registry.js.map +1 -0
  18. package/dist/document-amendment-store.d.ts +94 -46
  19. package/dist/document-amendment-store.d.ts.map +1 -1
  20. package/dist/document-amendment-store.js +217 -102
  21. package/dist/document-amendment-store.js.map +1 -1
  22. package/dist/document-delivery-transport.d.ts +51 -53
  23. package/dist/document-delivery-transport.d.ts.map +1 -1
  24. package/dist/document-delivery-transport.js +16 -157
  25. package/dist/document-delivery-transport.js.map +1 -1
  26. package/dist/document-frame-router.d.ts +13 -17
  27. package/dist/document-frame-router.d.ts.map +1 -1
  28. package/dist/document-frame-router.js +21 -93
  29. package/dist/document-frame-router.js.map +1 -1
  30. package/dist/document-handlers.d.ts +1 -1
  31. package/dist/document-handlers.d.ts.map +1 -1
  32. package/dist/document-handlers.js +462 -511
  33. package/dist/document-handlers.js.map +1 -1
  34. package/dist/document-handshake.d.ts +4 -1
  35. package/dist/document-handshake.d.ts.map +1 -1
  36. package/dist/document-handshake.js +3 -1
  37. package/dist/document-handshake.js.map +1 -1
  38. package/dist/document-inbound.d.ts +18 -5
  39. package/dist/document-inbound.d.ts.map +1 -1
  40. package/dist/document-inbound.js +94 -110
  41. package/dist/document-inbound.js.map +1 -1
  42. package/dist/document-layer.d.ts +49 -67
  43. package/dist/document-layer.d.ts.map +1 -1
  44. package/dist/document-layer.js +688 -565
  45. package/dist/document-layer.js.map +1 -1
  46. package/dist/document-lifecycle.d.ts +11 -157
  47. package/dist/document-lifecycle.d.ts.map +1 -1
  48. package/dist/document-lifecycle.js +26 -568
  49. package/dist/document-lifecycle.js.map +1 -1
  50. package/dist/document-publish.d.ts +13 -0
  51. package/dist/document-publish.d.ts.map +1 -1
  52. package/dist/document-publish.js +19 -11
  53. package/dist/document-publish.js.map +1 -1
  54. package/dist/document-reconcile-engine.d.ts +66 -0
  55. package/dist/document-reconcile-engine.d.ts.map +1 -0
  56. package/dist/document-reconcile-engine.js +225 -0
  57. package/dist/document-reconcile-engine.js.map +1 -0
  58. package/dist/document-reconcile-scheduler.d.ts +124 -0
  59. package/dist/document-reconcile-scheduler.d.ts.map +1 -0
  60. package/dist/document-reconcile-scheduler.js +226 -0
  61. package/dist/document-reconcile-scheduler.js.map +1 -0
  62. package/dist/document-rejection.d.ts +2 -0
  63. package/dist/document-rejection.d.ts.map +1 -1
  64. package/dist/document-rejection.js +5 -1
  65. package/dist/document-rejection.js.map +1 -1
  66. package/dist/document-store.d.ts +18 -247
  67. package/dist/document-store.d.ts.map +1 -1
  68. package/dist/document-store.js +95 -827
  69. package/dist/document-store.js.map +1 -1
  70. package/dist/inbound-sessions.d.ts +7 -0
  71. package/dist/inbound-sessions.d.ts.map +1 -1
  72. package/dist/inbound-sessions.js +27 -6
  73. package/dist/inbound-sessions.js.map +1 -1
  74. package/dist/initiate-session-handler.d.ts +5 -0
  75. package/dist/initiate-session-handler.d.ts.map +1 -1
  76. package/dist/initiate-session-handler.js +1 -0
  77. package/dist/initiate-session-handler.js.map +1 -1
  78. package/dist/notification-handlers.d.ts.map +1 -1
  79. package/dist/notification-handlers.js +39 -2
  80. package/dist/notification-handlers.js.map +1 -1
  81. package/dist/session-content-handlers.d.ts.map +1 -1
  82. package/dist/session-content-handlers.js +134 -8
  83. package/dist/session-content-handlers.js.map +1 -1
  84. package/dist/session-node-manager.d.ts +153 -8
  85. package/dist/session-node-manager.d.ts.map +1 -1
  86. package/dist/session-node-manager.js +1376 -35
  87. package/dist/session-node-manager.js.map +1 -1
  88. package/dist/types.d.ts +64 -2
  89. package/dist/types.d.ts.map +1 -1
  90. package/dist/types.js.map +1 -1
  91. package/package.json +5 -5
  92. package/dist/delivery-sweep-bound.d.ts +0 -79
  93. package/dist/delivery-sweep-bound.d.ts.map +0 -1
  94. package/dist/delivery-sweep-bound.js +0 -109
  95. package/dist/delivery-sweep-bound.js.map +0 -1
  96. package/dist/document-ack-inbound.d.ts +0 -71
  97. package/dist/document-ack-inbound.d.ts.map +0 -1
  98. package/dist/document-ack-inbound.js +0 -210
  99. package/dist/document-ack-inbound.js.map +0 -1
  100. package/dist/document-control-notifier.d.ts +0 -124
  101. package/dist/document-control-notifier.d.ts.map +0 -1
  102. package/dist/document-control-notifier.js +0 -176
  103. package/dist/document-control-notifier.js.map +0 -1
  104. package/dist/document-delivery.d.ts +0 -250
  105. package/dist/document-delivery.d.ts.map +0 -1
  106. package/dist/document-delivery.js +0 -608
  107. package/dist/document-delivery.js.map +0 -1
  108. package/dist/document-join-store.d.ts +0 -80
  109. package/dist/document-join-store.d.ts.map +0 -1
  110. package/dist/document-join-store.js +0 -197
  111. package/dist/document-join-store.js.map +0 -1
@@ -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
@@ -229,7 +186,7 @@ export function createDocumentLayer(deps) {
229
186
  // where it surfaced, in place of the refusal the publish path was already holding ready.
230
187
  let derived;
231
188
  try {
232
- derived = deriveArrangement(arrangementGenesisFromProposal(genesisRecord.envelope), amendments.chain(ownerAgentId, documentId), documentGovernancePolicy, verifySignature);
189
+ derived = deriveDocumentState(arrangementGenesisFromProposal(genesisRecord.envelope), amendments.chain(ownerAgentId, documentId), documentGovernancePolicy, verifySignature);
233
190
  }
234
191
  catch (err) {
235
192
  logger.error("document.holders.undecodable", {
@@ -245,96 +202,642 @@ export function createDocumentLayer(deps) {
245
202
  });
246
203
  return null;
247
204
  }
248
- 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];
249
212
  };
250
213
  /**
251
- * Who a control frame (`close`, `kill`) must be addressed to — DOD-MP-CONTROL-N-1.
252
- *
253
- * THIS LIVES HERE, not as a closure in the composition root, for the reason
254
- * `document-control-notifier.ts`'s own header records about itself: a closure in `daemon.ts` has
255
- * no test, so every fixture hand-copies it, and a hand-copy cannot disagree with the original.
256
- * The first draft of this WAS such a closure and had already drifted — it re-derived the
257
- * arrangement itself and so never emitted `document.holders.underivable`, leaving the control
258
- * path invisible to the exact log search meant to find it.
259
- *
260
- * Everyone EXCEPT the owner, because the owner is the one doing the ending. A chain that will
261
- * not derive is refused by name rather than falling back to the genesis `peerAgentId` — that
262
- * fallback is the original defect, and after a removal it aims the frame at precisely the
263
- * 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.
264
217
  */
265
- const controlHolders = (ownerAgentId, documentId) => {
266
- // LEGACY FIRST, and for the same reason the settle path checks it first: a document with no
267
- // stored genesis proposal has NO CHAIN, so the peer column is not a fallback — it IS the
268
- // membership. Without this branch the send path refused the very condition the settle path
269
- // calls legacy, so no close frame ever left, neither side recorded the other's close, and such
270
- // a document could never be ended by agreement at all. Reachable two ways: documents proposed
271
- // before `recordOutgoing` shipped, and a crash between `createDocument` and `recordOutgoing`,
272
- // which run in that order.
273
- //
274
- // The two paths must agree about what "cannot answer" means. Their disagreeing is the whole
275
- // shape of this milestone's defects.
276
- const doc = store.getDocument(ownerAgentId, documentId);
277
- if (doc && !handshake.get(ownerAgentId, documentId)) {
278
- 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;
279
228
  }
280
- 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;
281
258
  try {
282
- derivedHolders = holdersFor(ownerAgentId, documentId);
259
+ chain = [...amendments.chain(ownerAgentId, documentId)];
283
260
  }
284
- catch (err) {
285
- // CONTAINED for the same reason `cello_doc_list` contains it: a chain this build cannot
286
- // decode THROWS, and an uncontained throw would take down the close/kill verb entirely
287
- // rather than reporting why the frame could not be addressed.
288
- return {
289
- ok: false,
290
- reason: `document_chain_undecodable: ${err instanceof Error ? err.message : String(err)}`,
291
- };
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";
292
270
  }
293
- // A chain that EXISTS and will not replay keeps the refusal — it may name holders we cannot
294
- // see, and addressing the genesis peer alone there is the original defect.
295
- if (derivedHolders === null)
296
- return { ok: false, reason: "document_holders_underivable" };
297
- 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
+ const seats = [...derived.state.participants, ...derived.state.invited];
315
+ // A HOLDER THAT NO LONGER HOLDS A SEAT HAS NOTHING TO EXCHANGE.
316
+ //
317
+ // Removal is forward-only: the copy and its history stay ours to read, but edits no longer
318
+ // publish outward and theirs no longer arrive. So there is, by derivation, nothing left to
319
+ // reconcile — and the peers say so, refusing with `terminal: true` and the words "there is
320
+ // nothing further to reconcile".
321
+ //
322
+ // Without this the sweep derived its targets from the OTHER seats and never asked whether we
323
+ // still occupied one, so a removed holder asked forever: measured 105 refusals against one
324
+ // document in 85 minutes, every one of them terminal, every one of them asked again.
325
+ //
326
+ // The cost is not the wasted dial. Each attempt opens a session and pushes ack frames, and
327
+ // every frame consumes a relay canonical position while the receiving tree does not advance
328
+ // with it. That is what drives a conversation's tree behind the relay counter and strands
329
+ // real messages behind an ordering gap that nothing fills.
330
+ if (!seats.includes(ownerAgentId))
331
+ continue;
332
+ for (const seat of seats) {
333
+ if (seat === ownerAgentId)
334
+ continue;
335
+ const docs = targets.get(seat);
336
+ if (docs)
337
+ docs.push(doc.documentId);
338
+ else
339
+ targets.set(seat, [doc.documentId]);
340
+ }
341
+ }
342
+ return targets;
298
343
  };
299
344
  /** Is this agent a CURRENT holder — the ack gate's membership question. */
300
345
  const isCurrentHolder = (ownerAgentId, documentId, agentId) => {
301
346
  const holders = holdersFor(ownerAgentId, documentId);
302
347
  return holders !== null && holders.includes(agentId);
303
348
  };
304
- /** Sign and send a join answer — best-effort, never a veto over the local decision. */
305
- const sendJoinAnswer = async (ownerAgentId, inviterAgentId, documentId, amendmentHash, accepted, reason) => {
349
+ const recordAmendmentImpl = (ownerAgentId, wire, nowMs) => {
350
+ // An entry reaching an EXISTING holder. The door refuses only what no future entry can
351
+ // ever make good — a broken collection binding, an unproven author, a failed signature
352
+ // (checkEntryAdmissible). Everything semantic is the FOLD's ruling at consumption, and a
353
+ // fold-void entry is still history (F4): bouncing it here would leave two holders holding
354
+ // different sets.
355
+ const env = decodeDocumentAmendment(wire);
356
+ const documentId = env.body.document_id;
357
+ if (!store.getDocument(ownerAgentId, documentId)) {
358
+ throw new Error(`document_unknown: no document ${documentId.slice(0, 16)}… for this agent`);
359
+ }
360
+ const record = handshake.get(ownerAgentId, documentId);
361
+ if (!record) {
362
+ throw new Error(`document_genesis_missing: ${documentId.slice(0, 16)}… has a row but no stored genesis ` +
363
+ `proposal to replay from`);
364
+ }
365
+ const admissible = checkEntryAdmissible(env, verifySignature);
366
+ if (!admissible.ok)
367
+ throw new Error(admissible.reason);
368
+ // THE STRANGER DOOR (SYNC-R18, interim until P3's full entitlement classes): a fold-void
369
+ // entry is history only when its author is KNOWN to this document — a genesis party or
370
+ // someone an EFFECTIVE admission names (which covers invited and removed authors, whose
371
+ // earlier work must still converge, R20). Effective, not merely held: a hostile holder can
372
+ // mint a self-signed, fold-void admission naming any key, and counting it would hand that
373
+ // key an unbounded license to grow every holder's entry set (review F4). A stranger's
374
+ // governance is refused by name, never stored.
375
+ const author = env.body.author_agent_id;
376
+ const genesisArr = arrangementGenesisFromProposal(record.envelope);
377
+ let authorKnown = genesisArr.proposerAgentId === author || genesisArr.peerAgentId === author;
378
+ if (!authorKnown) {
379
+ const held = amendments.chain(ownerAgentId, documentId);
380
+ const doorDerived = deriveDocumentState(genesisArr, held, documentGovernancePolicy, verifySignature);
381
+ if (!doorDerived.ok)
382
+ throw new Error(doorDerived.reason);
383
+ const inert = new Set([
384
+ ...doorDerived.state.voids.map((v) => v.hash),
385
+ ...doorDerived.state.excluded.map((e) => e.hash),
386
+ ]);
387
+ authorKnown = held.some((e) => e.body.kind === "add_holder" &&
388
+ e.body.subject_agent_id === author &&
389
+ !inert.has(Buffer.from(documentAmendmentHash(e.body)).toString("hex")));
390
+ }
391
+ if (!authorKnown) {
392
+ throw new Error(`document_author_stranger: ${author} is neither a genesis party nor named by any ` +
393
+ `effective admission — a stranger's governance is refused, not stored`);
394
+ }
395
+ const appended = amendments.append(ownerAgentId, documentId, wire, nowMs);
396
+ // Post-apply surfacing runs for EVERYTHING that just applied: the direct arrival, and
397
+ // every held entry this arrival promoted (review F2 — the held path silently dropped the
398
+ // removal notice and the lifecycle completion, reintroducing the stuck-`active` defect
399
+ // CLOSE-N-1 fixed, on exactly the out-of-order path the pending table exists for).
400
+ const surfaceApplied = (applied) => {
401
+ // DOD-MP-REMOVE-1 — a removal NAMING THIS AGENT is applied and SURFACED, not just
402
+ // stored: the row flips to `removed` (publishes refuse locally, naming the condition;
403
+ // the copy, the file, the history all remain — forward-only by doctrine), and the event
404
+ // is the operator's notice. Everyone else's arrangement changes are visible through
405
+ // list/inbox derivation; being written out of one is the change an operator must not
406
+ // miss.
407
+ if (applied.body.kind === "remove_holder" &&
408
+ applied.body.subject_agent_id === ownerAgentId) {
409
+ logger.warn("document.removed_from", {
410
+ documentId,
411
+ removedBy: applied.collection.required_signers.join(","),
412
+ });
413
+ }
414
+ // THE ARRIVING CONSENT IS THE JOIN ANSWER (SYNC-P3, the D5 replacement): an inviter's
415
+ // pending row settles from the entry itself — the subject's own signed yes or no — so
416
+ // the legacy answer frame carries nothing the record does not. Settle-once semantics
417
+ // SYNC-P4 (R27/R28, review F2): the stored status is a DISPLAY PROJECTION of the derived
418
+ // ending — recomputed on EVERY applied entry, in BOTH directions. One-way, kind-gated
419
+ // syncing let two holders converge on the fold while holding different status columns
420
+ // forever: a concurrently-authored admission re-opens the derivation (an invited seat
421
+ // blocks closure), and nothing ever wrote `active` back. The derivation is the truth; the
422
+ // column exists so the list surface does not re-derive every row, and no correctness gate
423
+ // reads it for endings anymore (canPublish/canAdmit ask the fold).
424
+ {
425
+ const derivedEnd = reconcileReads(ownerAgentId).deriveState(documentId);
426
+ if (derivedEnd.ok) {
427
+ const current = store.getDocument(ownerAgentId, documentId)?.status;
428
+ if (derivedEnd.state.ended !== null && current === "active") {
429
+ store.setDocumentStatus(ownerAgentId, documentId, derivedEnd.state.ended === "killed" ? "killed" : "closed");
430
+ logger.info("document.ended.derived", { documentId, ended: derivedEnd.state.ended });
431
+ }
432
+ else if (derivedEnd.state.ended === null &&
433
+ (current === "closed" || current === "killed")) {
434
+ store.setDocumentStatus(ownerAgentId, documentId, "active");
435
+ logger.info("document.reopened.derived", { documentId });
436
+ }
437
+ }
438
+ }
439
+ };
440
+ // An entry held for missing parents (R14) is recorded but NOT applied — its notices wait
441
+ // with it and fire on promotion. The store's `document.entry.held` event is the trace.
442
+ if (!appended.held)
443
+ surfaceApplied(env);
444
+ for (const promoted of appended.promoted)
445
+ surfaceApplied(promoted.envelope);
446
+ };
447
+ const rewriteFileImpl = async (ownerAgentId, inResponseTo) => {
448
+ if (!writePath)
449
+ return;
450
+ const env = decodeDocumentUpdateEnvelope(inResponseTo);
451
+ const document = store.getDocument(ownerAgentId, env.document_id);
452
+ if (!document)
453
+ return;
454
+ await writePath.materialize(ownerAgentId, env.document_id, document.documentType, live.get(ownerAgentId, env.document_id));
455
+ };
456
+ const noticeInboundUpdateImpl = (ownerAgentId, inResponseTo) => {
457
+ const env = decodeDocumentUpdateEnvelope(inResponseTo);
458
+ // COUNTED FROM THE LOG, not incremented blindly: envelopes redeliver, and a counter bumped on
459
+ // every arrival would drift upward on ordinary retries and tell the operator there is more to
460
+ // read than there is.
461
+ const unread = notifications.unreadFromPeer(ownerAgentId, env.document_id);
462
+ notifications.notice(ownerAgentId, env.document_id, unread, Date.now());
463
+ // DOD-DOC-WATCH-1 — the selective nudge.
464
+ //
465
+ // Matched against what changed since THIS agent last READ, not against this envelope. Per
466
+ // envelope re-fires on redelivery and on the peer's every keystroke; the net difference from
467
+ // the read mark asks the question an operator actually has — has the thing I am waiting on
468
+ // moved since I saw it — and self-cancels the moment they read.
469
+ //
470
+ // Wrapped whole: a notification is a courtesy on a path whose real job (admitting the peer's
471
+ // update) has already succeeded, and must never be able to fail it.
306
472
  try {
307
- const answer = {
308
- type: "document_join_answer",
309
- document_id: documentId,
310
- amendment_hash: amendmentHash,
311
- invitee_agent_id: ownerAgentId,
312
- accepted,
313
- refusal_reason: accepted ? null : reason,
314
- answered_at_ms: Date.now(),
315
- signature: new Uint8Array(0),
316
- };
317
- answer.signature = await deps.sign(ownerAgentId, buildDocumentJoinAnswerTbs(answer));
318
- await deps.sendFrame(ownerAgentId, inviterAgentId, encodeDocumentJoinAnswer(answer));
473
+ const watches = notifications.watches(ownerAgentId, env.document_id);
474
+ if (watches.length === 0)
475
+ return;
476
+ if (!notifications.nudgeOwed(ownerAgentId, env.document_id))
477
+ return;
478
+ const document = store.getDocument(ownerAgentId, env.document_id);
479
+ if (!document)
480
+ return;
481
+ const seen = notifications.lastSeen(ownerAgentId, env.document_id);
482
+ const after = projectDocumentText(live.get(ownerAgentId, env.document_id), document.documentType);
483
+ // NEVER READ is not "everything changed" for the purposes of a nudge — the agent has not
484
+ // established a baseline, so there is nothing it can be waiting on a change to.
485
+ if (seen === null || seen === after)
486
+ return;
487
+ // A text document has no key paths, so the only thing it can report is that it moved. That
488
+ // is why the whole-document watch has to be spelled `*` rather than implied.
489
+ const paths = rootForDocumentType(document.documentType) === "map"
490
+ ? changedKeyPaths(seen, after) ?? ["*"]
491
+ : ["*"];
492
+ const hits = matchWatchedPaths(watches, paths);
493
+ if (hits.length === 0)
494
+ return;
495
+ // THE AGENT'S OWN PATTERNS travel, never the changed paths. A changed path can carry a key
496
+ // the PEER named, and a doorbell body is an unscreened route into the agent's context —
497
+ // see `matchingWatches`. The precise field comes from cello_doc_diff, which IS screened.
498
+ const firedWatches = matchingWatches(watches, paths);
499
+ notifications.markNudged(ownerAgentId, env.document_id, Date.now());
500
+ logger.info("document.watch.nudged", {
501
+ documentId: env.document_id,
502
+ paths: hits.length,
503
+ watches: firedWatches.length,
504
+ });
505
+ deps.nudge?.(ownerAgentId, env.document_id, firedWatches);
319
506
  }
320
507
  catch (err) {
321
- logger.warn("document.join.answer_send_failed", {
322
- documentId,
323
- amendmentHash,
508
+ logger.warn("document.watch.nudge_failed", {
509
+ documentId: env.document_id,
324
510
  error: err instanceof Error ? err.message : String(err),
325
511
  });
326
512
  }
327
513
  };
328
- /** Re-send the STANDING decision for an already-decided offer a redelivery just matched. */
329
- const resendJoinAnswer = async (ownerAgentId, amendmentHash, inviterAgentId) => {
330
- const record = joins.get(ownerAgentId, amendmentHash);
331
- if (!record || record.state === "pending")
332
- return;
333
- await sendJoinAnswer(ownerAgentId, inviterAgentId, record.documentId, amendmentHash, record.state === "accepted", record.reason);
514
+ /** SYNC-P3 the reads the reconcile engine consumes, over THIS owner's stores. */
515
+ const reconcileReads = (ownerAgentId) => ({
516
+ deriveState: (documentId) => {
517
+ const record = handshake.get(ownerAgentId, documentId);
518
+ if (!record)
519
+ return { ok: false, reason: "document_genesis_missing" };
520
+ try {
521
+ return deriveDocumentState(arrangementGenesisFromProposal(record.envelope), amendments.chain(ownerAgentId, documentId), documentGovernancePolicy, verifySignature);
522
+ }
523
+ catch (err) {
524
+ return {
525
+ ok: false,
526
+ reason: `document_chain_undecodable: ${err instanceof Error ? err.message : String(err)}`,
527
+ };
528
+ }
529
+ },
530
+ watermarks: (documentId) => amendments.watermarks(ownerAgentId, documentId),
531
+ entriesByAuthorAfter: (documentId, author, afterSeq) => amendments.entriesByAuthorAfter(ownerAgentId, documentId, author, afterSeq),
532
+ envelopeLog: (documentId) => store.getEnvelopeLog(ownerAgentId, documentId),
533
+ refusedHashes: (documentId) => rejections.quarantined(ownerAgentId, documentId).map((q) => q.rejectedEnvelopeHash),
534
+ // SYNC-R35: the exact signed frames, from the quarantine — rows born before the column hold
535
+ // none, and the exchange simply has nothing to attach for them.
536
+ refusalRecords: (documentId) => rejections
537
+ .quarantined(ownerAgentId, documentId)
538
+ .map((q) => q.rejectionWire)
539
+ .filter((w) => w !== undefined),
540
+ standingOf: (documentId, agentId) => standingOf(ownerAgentId, documentId, agentId),
541
+ genesisBytes: (documentId) => {
542
+ const record = handshake.get(ownerAgentId, documentId);
543
+ return record ? new Uint8Array(encodeDocumentProposal(record.envelope)) : null;
544
+ },
545
+ removalClosure: (documentId, agentId) => {
546
+ // The LAST removal naming them, plus every ancestor — walked over the held entry set.
547
+ const chain = amendments.chain(ownerAgentId, documentId);
548
+ const byHash = new Map(chain.map((env) => [
549
+ Buffer.from(documentAmendmentHash(env.body)).toString("hex"),
550
+ env,
551
+ ]));
552
+ let removalHash = null;
553
+ for (const [hash, env] of byHash) {
554
+ if (env.body.kind === "remove_holder" && env.body.subject_agent_id === agentId) {
555
+ removalHash = hash;
556
+ }
557
+ }
558
+ if (!removalHash)
559
+ return null;
560
+ const wanted = new Set([removalHash]);
561
+ const queue = [removalHash];
562
+ while (queue.length > 0) {
563
+ const env = byHash.get(queue.pop());
564
+ if (!env)
565
+ continue;
566
+ for (const parent of env.body.parents) {
567
+ if (!wanted.has(parent)) {
568
+ wanted.add(parent);
569
+ queue.push(parent);
570
+ }
571
+ }
572
+ }
573
+ // Foldable order: parents before children — the chain read is already epoch/seq ordered.
574
+ return chain
575
+ .filter((env) => wanted.has(Buffer.from(documentAmendmentHash(env.body)).toString("hex")))
576
+ .map((env) => new Uint8Array(encodeDocumentAmendment(env)));
577
+ },
578
+ });
579
+ /**
580
+ * SYNC-P3 — one arriving reconcile frame, all three steps. Apply what it carries (governance
581
+ * entries FIRST, R12 — through the same causal door every amendment takes; then content
582
+ * envelopes through the same inbound gate every update takes), then answer: a version this
583
+ * build does not speak or a stranger gets the named refusal ON the frame; a peer that lacks
584
+ * something gets the difference; a converged exchange gets silence — which is what terminates
585
+ * it (R15: idempotence is the absence of a difference, not bookkeeping).
586
+ */
587
+ const handleReconcile = async (ownerAgentId, wire, senderAgentId, nowMs, correlationId) => {
588
+ const frame = decodeDocumentReconcile(wire);
589
+ if (frame.refusal) {
590
+ // The peer's answer to OUR exchange — a fact to surface, never to retry blindly.
591
+ logger.warn("document.reconcile.refused_by_peer", {
592
+ senderAgentId, reason: frame.refusal.reason, terminal: frame.refusal.terminal, correlationId,
593
+ });
594
+ // ACTED ON, not merely surfaced. The comment above has always said "never to retry blindly",
595
+ // and until now nothing downstream could tell a refusal from a success.
596
+ deps.onPeerRefusal?.(ownerAgentId, senderAgentId, frame.refusal.terminal);
597
+ return { ok: true };
598
+ }
599
+ for (const block of frame.documents) {
600
+ if (block.refusal) {
601
+ logger.warn("document.reconcile.refused_by_peer", {
602
+ documentId: block.document_id, senderAgentId,
603
+ reason: block.refusal.reason, terminal: block.refusal.terminal, correlationId,
604
+ });
605
+ deps.onPeerRefusal?.(ownerAgentId, senderAgentId, block.refusal.terminal);
606
+ }
607
+ }
608
+ if (frame.exchange_version !== DOCUMENT_RECONCILE_EXCHANGE_VERSION) {
609
+ const reason = `document_reconcile_version: you speak exchange version ${frame.exchange_version} and ` +
610
+ `this holder speaks ${DOCUMENT_RECONCILE_EXCHANGE_VERSION} — upgrade together; there is ` +
611
+ `no dual-speak mode`;
612
+ logger.warn("document.reconcile.version_refused", { senderAgentId, correlationId, reason });
613
+ await deps.sendFrame(ownerAgentId, senderAgentId, encodeDocumentReconcile({
614
+ type: "document_reconcile",
615
+ exchange_version: DOCUMENT_RECONCILE_EXCHANGE_VERSION,
616
+ documents: [],
617
+ refusal: { reason, terminal: false },
618
+ }));
619
+ return { ok: false, reason: "document_reconcile_version" };
620
+ }
621
+ const reads = reconcileReads(ownerAgentId);
622
+ const replyBlocks = [];
623
+ // One reply frame's payload allowance, shared across its blocks (review F3): headroom under
624
+ // the router's 2 MiB frame ceiling so positions, hashes, and CBOR overhead always fit.
625
+ const replyBudget = { remainingBytes: 1_500_000 };
626
+ for (const block of frame.documents) {
627
+ // THE NOTICE, RECEIVED (R25): a position for a document this holder does not hold, with
628
+ // no genesis attached, IS the invitation pointer — the frame already names the document
629
+ // and the session names the inviter. The answer is our EMPTY position ("I hold nothing;
630
+ // send everything"), and the peer's reply carries the genesis and the lot. Two empty
631
+ // hands stay silent, so nothing ping-pongs.
632
+ if (!block.genesis && !store.getDocument(ownerAgentId, block.document_id)) {
633
+ const peerClaimsAnything = block.governance.length > 0 || block.content.length > 0 ||
634
+ block.entries.length > 0 || block.envelopes.length > 0;
635
+ if (peerClaimsAnything) {
636
+ replyBlocks.push({
637
+ document_id: block.document_id,
638
+ governance: [], content: [], refused: [], entries: [], envelopes: [],
639
+ });
640
+ logger.info("document.reconcile.notice_received", {
641
+ documentId: block.document_id, viaAgentId: senderAgentId, correlationId,
642
+ });
643
+ }
644
+ continue;
645
+ }
646
+ // THE JOINER BOOTSTRAP (spec §4 — a joiner is simply very far behind): a block carrying
647
+ // the genesis for a document this holder does not have is the invitation arriving. The
648
+ // anchor is validated the way every genesis is — its hash IS the document id, its
649
+ // signature is the proposer's — and recording it is idempotent. The invitee then derives
650
+ // their own standing (invited) from the entries that follow; their ACCEPT is the ordinary
651
+ // consent-authoring accept.
652
+ if (block.genesis && !store.getDocument(ownerAgentId, block.document_id)) {
653
+ try {
654
+ const genesisEnv = decodeDocumentProposal(block.genesis);
655
+ if (documentIdFromProposal(genesisEnv) !== block.document_id) {
656
+ throw new Error("document_reconcile_genesis_mismatch: the carried genesis does not hash to the " +
657
+ "block's document id — the anchor cannot be swapped");
658
+ }
659
+ // THE ANCHOR IS VERIFIED, NOT TAKEN (P3 review F2): a proposal is only a proposal
660
+ // under its proposer's own signature — an unsigned or forged genesis stored here
661
+ // would attribute a document to someone who never made it, and hand any session
662
+ // peer an unlimited license to grow this store with fabricated documents.
663
+ if (!verifySignature(genesisEnv.proposer_agent_id, buildDocumentProposalTbs(genesisEnv), genesisEnv.signature)) {
664
+ throw new Error(`document_reconcile_genesis_unsigned: the carried genesis does not verify ` +
665
+ `against its named proposer ${genesisEnv.proposer_agent_id}`);
666
+ }
667
+ // AND IT MUST NAME US (F2's entitlement half): this holder bootstraps only a
668
+ // document it is a party to — the genesis peer, or the subject of an admission
669
+ // carried in the same block. Anything else is a stranger's document and is refused,
670
+ // not stored.
671
+ const namedAsGenesisPeer = genesisEnv.peer_agent_id === ownerAgentId;
672
+ const namedByAdmission = block.entries.some((entryWire) => {
673
+ try {
674
+ const env = decodeDocumentAmendment(entryWire);
675
+ return env.body.kind === "add_holder" && env.body.subject_agent_id === ownerAgentId;
676
+ }
677
+ catch {
678
+ return false;
679
+ }
680
+ });
681
+ if (!namedAsGenesisPeer && !namedByAdmission) {
682
+ throw new Error("document_reconcile_not_invited: the carried world names this holder nowhere — " +
683
+ "neither the genesis peer nor the subject of any carried admission; a document " +
684
+ "we are no party to is refused, not stored");
685
+ }
686
+ handshake.recordJoined(ownerAgentId, block.genesis, nowMs);
687
+ store.createDocument({
688
+ documentId: block.document_id,
689
+ ownerAgentId,
690
+ // The GENESIS FACT, not the messenger (F6): the peer column is the proposer's —
691
+ // whoever's session happened to carry the frame may be any holder (forwarding).
692
+ peerAgentId: genesisEnv.proposer_agent_id,
693
+ documentType: genesisEnv.document_type,
694
+ properties: genesisEnv.properties,
695
+ status: "active",
696
+ createdAtMs: nowMs,
697
+ });
698
+ logger.info("document.reconcile.joined", {
699
+ documentId: block.document_id, viaAgentId: senderAgentId, correlationId,
700
+ });
701
+ }
702
+ catch (err) {
703
+ logger.warn("document.reconcile.genesis_refused", {
704
+ documentId: block.document_id, senderAgentId, correlationId,
705
+ reason: err instanceof Error ? err.message : String(err),
706
+ });
707
+ continue;
708
+ }
709
+ }
710
+ // APPLY FIRST (R12 order: governance, then content). Both doors are the ordinary ones —
711
+ // idempotent, held-until-whole, refusal-recording — so a redelivered exchange changes
712
+ // nothing.
713
+ for (const entryWire of block.entries) {
714
+ try {
715
+ recordAmendmentImpl(ownerAgentId, entryWire, nowMs);
716
+ }
717
+ catch (err) {
718
+ logger.warn("document.reconcile.entry_refused", {
719
+ documentId: block.document_id, senderAgentId, correlationId,
720
+ reason: err instanceof Error ? err.message : String(err),
721
+ });
722
+ }
723
+ }
724
+ // SYNC-R35: the sender's SIGNED refusal records — verified against their named refuser
725
+ // and recorded as received rejections, exactly as a directly-sent frame would be. This is
726
+ // how a third holder wedged behind a refused hash learns its name and reason (F5/F8), and
727
+ // how the refusal keeps traveling once the refuser is gone. Idempotent by primary key.
728
+ for (const refusalWire of block.refusals ?? []) {
729
+ try {
730
+ const rej = decodeDocumentRejection(new Uint8Array(refusalWire));
731
+ if (rej.document_id !== block.document_id) {
732
+ throw new Error("document_refusal_document_mismatch: the record names another document");
733
+ }
734
+ if (!verifySignature(rej.rejecting_agent_id, buildDocumentRejectionTbs(rej), rej.signature)) {
735
+ throw new Error(`document_rejection_signature_invalid: the refusal claims to come from ` +
736
+ `${rej.rejecting_agent_id} but its signature does not verify against that agent`);
737
+ }
738
+ rejections.recordIncomingRejection(ownerAgentId, rej.document_id, {
739
+ rejectionEnvelopeHash: documentRejectionHash(rej),
740
+ rejectedEnvelopeHash: rej.rejected_envelope_hash,
741
+ reason: rej.reason,
742
+ detail: rej.detail,
743
+ fromAgentId: rej.rejecting_agent_id,
744
+ });
745
+ }
746
+ catch (err) {
747
+ logger.warn("document.reconcile.refusal_record_refused", {
748
+ documentId: block.document_id, senderAgentId, correlationId,
749
+ reason: err instanceof Error ? err.message : String(err),
750
+ });
751
+ }
752
+ }
753
+ for (const envWire of block.envelopes) {
754
+ const res = await inbound.receive(ownerAgentId, envWire, nowMs, correlationId);
755
+ if (res.ok && res.admitted) {
756
+ try {
757
+ noticeInboundUpdateImpl(ownerAgentId, envWire);
758
+ }
759
+ catch { /* the notice is a row; the content is already the truth */ }
760
+ void rewriteFileImpl(ownerAgentId, envWire).catch((err) => {
761
+ logger.warn("document.reconcile.rewrite_failed", {
762
+ correlationId,
763
+ reason: err instanceof Error ? err.message : String(err),
764
+ });
765
+ });
766
+ }
767
+ }
768
+ const answer = respondToReconcile(reads, senderAgentId, block, replyBudget);
769
+ // SYNC-P5 (spec §9): the block's position IS what this party last claimed — recorded as
770
+ // the display cache the list surface reads (in_sync|behind|unseen). AFTER the entitlement
771
+ // ruling (P5 review F9): a stranger's claimed position is not worth a row. Never consulted
772
+ // for correctness (R44).
773
+ if (!answer.block.refusal && store.getDocument(ownerAgentId, block.document_id)) {
774
+ store.recordPartyView(ownerAgentId, block.document_id, senderAgentId, {
775
+ govSeqs: Object.fromEntries(block.governance.map((g) => [g.author, g.seq])),
776
+ contentCounts: Object.fromEntries(block.content.map((c) => [c.author, c.count])),
777
+ refused: [...block.refused],
778
+ }, nowMs);
779
+ }
780
+ if (answer.block.refusal) {
781
+ // Per-document, on the block (review F4) — a batch never silences one document's no.
782
+ logger.warn("document.reconcile.refused", {
783
+ documentId: block.document_id, senderAgentId,
784
+ reason: answer.block.refusal.reason, terminal: answer.block.refusal.terminal,
785
+ correlationId,
786
+ });
787
+ replyBlocks.push(answer.block);
788
+ continue;
789
+ }
790
+ if (answer.truncated) {
791
+ // The byte budget cut the payload (review F3) — LOUD, never a silently-oversized frame
792
+ // the router would drop as unshaped. The exchange is idempotent: the peer's next
793
+ // initiate picks up from its advanced position.
794
+ logger.warn("document.reconcile.truncated", {
795
+ documentId: block.document_id, senderAgentId, correlationId,
796
+ });
797
+ }
798
+ // Refusal records RIDE replies; they never CAUSE one — counting them here would make any
799
+ // document with one standing refusal answer every exchange forever, and the exchange is
800
+ // terminated by silence. The wedge they exist to cure (F5) always involves a real
801
+ // difference, so they are on board whenever they matter.
802
+ const hasDifference = answer.block.entries.length > 0 || answer.block.envelopes.length > 0;
803
+ if (hasDifference || answer.peerAhead) {
804
+ replyBlocks.push(answer.block);
805
+ }
806
+ }
807
+ if (replyBlocks.length > 0) {
808
+ await deps.sendFrame(ownerAgentId, senderAgentId, encodeDocumentReconcile({
809
+ type: "document_reconcile",
810
+ exchange_version: DOCUMENT_RECONCILE_EXCHANGE_VERSION,
811
+ documents: replyBlocks,
812
+ }));
813
+ }
814
+ return { ok: true };
815
+ };
816
+ /** SYNC-P3 — step 1: send our position for these documents to a peer. */
817
+ // DOD-M12B-SHUTDOWN-1: THE CHOKE POINT for outbound reconciles. The scheduler is only one of the
818
+ // four callers — `nudgeSeats` and the two invite notices reach here directly, outside it — so
819
+ // gating the scheduler alone left three paths still dialling on the way out. `cello_doc_write`,
820
+ // `cello_doc_publish` and `cello_doc_invite` are all still SERVED during shutdown, because the
821
+ // IPC server is the last thing stopped, so this is not a theoretical window.
822
+ let reconcileStopped = false;
823
+ const initiateReconcile = async (ownerAgentId, peerAgentId, documentIds) => {
824
+ if (reconcileStopped)
825
+ return { ok: false, reason: "daemon_shutting_down" };
826
+ const reads = reconcileReads(ownerAgentId);
827
+ const blocks = documentIds.map((id) => buildReconcileBlock(reads, id));
828
+ const sent = await deps.sendFrame(ownerAgentId, peerAgentId, encodeDocumentReconcile({
829
+ type: "document_reconcile",
830
+ exchange_version: DOCUMENT_RECONCILE_EXCHANGE_VERSION,
831
+ documents: blocks,
832
+ }));
833
+ if (!sent.ok)
834
+ return { ok: false, reason: sent.reason ?? "document_reconcile_send_failed" };
835
+ logger.info("document.reconcile.initiated", { peerAgentId, documents: documentIds.length });
836
+ return { ok: true };
334
837
  };
335
838
  const router = new DocumentFrameRouter({
336
839
  inbound,
337
- ackInbound,
840
+ handleReconcile,
338
841
  logger,
339
842
  ownerKeyFor: deps.ownerKeyFor,
340
843
  recordProposal: (ownerAgentId, wire, nowMs) => {
@@ -373,174 +876,12 @@ export function createDocumentLayer(deps) {
373
876
  })();
374
877
  }
375
878
  },
376
- recordJoinOffer: (ownerAgentId, wire, nowMs) => {
377
- const offer = decodeDocumentJoinOffer(wire);
378
- // ADDRESSED TO US — the same multi-agent-daemon rule recordProposal enforces: consent
379
- // belongs to the agent the inviter named, and a throw here is contained by the router.
380
- if (offer.invitee_agent_id !== ownerAgentId) {
381
- throw new Error(`document_join_wrong_invitee: this offer is addressed to ${offer.invitee_agent_id}, ` +
382
- `not to ${ownerAgentId}`);
383
- }
384
- const validation = validateDocumentJoinOffer(offer, documentGovernancePolicy, verifySignature);
385
- if (validation.ok) {
386
- const stored = joins.recordIncoming(ownerAgentId, wire, validation.pendingAmendmentHash, { state: "pending" }, nowMs);
387
- // REDELIVERY OF A DECIDED OFFER RE-SENDS THE ANSWER. A lost answer was otherwise lost
388
- // forever: the invitee's decision stood, the inviter's row sat pending, and the
389
- // inviter's only lever — re-inviting, which redelivers this exact offer — reached a
390
- // DO-NOTHING insert. Now that redelivery is the recovery: the standing decision is
391
- // re-signed and travels again. Best-effort, like every answer.
392
- if (stored.state !== "pending") {
393
- void resendJoinAnswer(ownerAgentId, validation.pendingAmendmentHash, offer.inviter_agent_id);
394
- }
395
- return;
396
- }
397
- // A REFUSAL IS RECORDED — AND ANSWERED — ONLY WHEN IT IS AUTHENTICATED. The first cut
398
- // recorded every refusal under the admitting amendment's hash, and that was a standing
399
- // veto: any party holding the amendment bytes (every holder gets them in the fan-out)
400
- // could deliver a garbage offer FIRST, occupy the real settle key with a refused row, and
401
- // the genuine offer would arrive to a DO-NOTHING insert — the admin's admission silently
402
- // suppressed, both operators seeing nothing. So: an offer whose signature does not verify
403
- // against its named inviter THROWS (contained + logged, nothing recorded — the forged-
404
- // proposal treatment), and an authenticated refusal is recorded under the hash of ITS OWN
405
- // BYTES — visible to the operator, never able to occupy a real join's settle key.
406
- if (!verifySignature(offer.inviter_agent_id, buildDocumentJoinOfferTbs(offer), offer.signature)) {
407
- throw new Error(validation.reason);
408
- }
409
- const refusalKey = createHash("sha256").update(wire).digest("hex");
410
- joins.recordIncoming(ownerAgentId, wire, refusalKey, { state: "refused", reason: validation.reason }, nowMs);
411
- // BOTH ENDS get the sentence: the refusal answer travels back to the authenticated
412
- // inviter, settling their row with the reason — the version-mismatch sentence was written
413
- // for a human and was reaching a database column. The settle key is the admitting
414
- // amendment's hash when one is recoverable (the inviter's row lives under it).
415
- const last = offer.amendments[offer.amendments.length - 1];
416
- if (last !== undefined) {
417
- try {
418
- const settleKey = Buffer.from(documentAmendmentHash(decodeDocumentAmendment(last).body)).toString("hex");
419
- void sendJoinAnswer(ownerAgentId, offer.inviter_agent_id, offer.document_id, settleKey, false, validation.reason);
420
- }
421
- catch {
422
- // No recoverable settle key — the refusal stays visible locally and the inviter's
423
- // re-invite (which redelivers) will meet the same recorded refusal.
424
- }
425
- }
426
- },
427
- recordJoinAnswer: (ownerAgentId, wire, nowMs) => {
428
- const r = joins.recordAnswer(ownerAgentId, wire, verifySignature, nowMs);
429
- // A refusal is a verdict the router contains and reports — never a silent drop.
430
- if (!r.ok)
431
- throw new Error(r.reason);
432
- },
433
- recordAmendment: (ownerAgentId, wire, nowMs) => {
434
- // An amendment reaching an EXISTING holder. VALIDATE-BEFORE-APPEND (the AMEND-1 standing
435
- // condition): the whole chain, including this arrival, must replay through the real policy
436
- // before one byte lands in the table — a row in document_amendments is post-validation by
437
- // invariant, and epoch stamping rests on that.
438
- const env = decodeDocumentAmendment(wire);
439
- const documentId = env.body.document_id;
440
- if (!store.getDocument(ownerAgentId, documentId)) {
441
- throw new Error(`document_unknown: no document ${documentId.slice(0, 16)}… for this agent`);
442
- }
443
- const record = handshake.get(ownerAgentId, documentId);
444
- if (!record) {
445
- throw new Error(`document_genesis_missing: ${documentId.slice(0, 16)}… has a row but no stored genesis ` +
446
- `proposal to replay from`);
447
- }
448
- const derived = deriveArrangement(arrangementGenesisFromProposal(record.envelope), [...amendments.chain(ownerAgentId, documentId), env], documentGovernancePolicy, verifySignature);
449
- if (!derived.ok)
450
- throw new Error(derived.reason);
451
- amendments.append(ownerAgentId, documentId, wire, nowMs);
452
- // DOD-MP-REMOVE-1 — a removal NAMING THIS AGENT is applied and SURFACED, not just stored:
453
- // the row flips to `removed` (publishes refuse locally, naming the condition; the copy,
454
- // the file, the history all remain — forward-only by doctrine), and the event is the
455
- // operator's notice. Everyone else's arrangement changes are visible through list/inbox
456
- // derivation; being written out of one is the change an operator must not miss.
457
- if (env.body.kind === "remove_holder" && env.body.subject_agent_id === ownerAgentId) {
458
- // Applied by DERIVATION — the recorded chain IS the removal; publish gates and the list
459
- // overlay read it. This warn is the operator's notice.
460
- logger.warn("document.removed_from", {
461
- documentId,
462
- epochId: env.body.epoch_id,
463
- removedBy: env.collection.required_signers.join(","),
464
- });
465
- }
466
- // DOD-MP-CLOSE-N-1 — a membership change can COMPLETE an agreement. Removing the one holder
467
- // who had not closed leaves everyone who remains in agreement, and without this the document
468
- // stayed `active` forever reporting that it waited on nobody: control frames are fire-once
469
- // and never swept, so no later event would have settled it.
470
- if (env.body.kind === "remove_holder" || env.body.kind === "add_holder") {
471
- lifecycle.onMembershipChanged(ownerAgentId, documentId, env.body.kind === "remove_holder" ? (env.body.subject_agent_id ?? undefined) : undefined);
472
- }
473
- },
879
+ recordAmendment: recordAmendmentImpl,
474
880
  // `_nowMs` unused: the received-rejection row takes its clock where it is written, and the
475
881
  // rejection's own SIGNED timestamp is inside the envelope. A second clock read here would put a
476
882
  // third time on one event.
477
- rewriteFile: async (ownerAgentId, inResponseTo) => {
478
- if (!writePath)
479
- return;
480
- const env = decodeDocumentUpdateEnvelope(inResponseTo);
481
- const document = store.getDocument(ownerAgentId, env.document_id);
482
- if (!document)
483
- return;
484
- await writePath.materialize(ownerAgentId, env.document_id, document.documentType, live.get(ownerAgentId, env.document_id));
485
- },
486
- noticeInboundUpdate: (ownerAgentId, inResponseTo) => {
487
- const env = decodeDocumentUpdateEnvelope(inResponseTo);
488
- // COUNTED FROM THE LOG, not incremented blindly: envelopes redeliver, and a counter bumped on
489
- // every arrival would drift upward on ordinary retries and tell the operator there is more to
490
- // read than there is.
491
- const unread = notifications.unreadFromPeer(ownerAgentId, env.document_id);
492
- notifications.notice(ownerAgentId, env.document_id, unread, Date.now());
493
- // DOD-DOC-WATCH-1 — the selective nudge.
494
- //
495
- // Matched against what changed since THIS agent last READ, not against this envelope. Per
496
- // envelope re-fires on redelivery and on the peer's every keystroke; the net difference from
497
- // the read mark asks the question an operator actually has — has the thing I am waiting on
498
- // moved since I saw it — and self-cancels the moment they read.
499
- //
500
- // Wrapped whole: a notification is a courtesy on a path whose real job (admitting the peer's
501
- // update) has already succeeded, and must never be able to fail it.
502
- try {
503
- const watches = notifications.watches(ownerAgentId, env.document_id);
504
- if (watches.length === 0)
505
- return;
506
- if (!notifications.nudgeOwed(ownerAgentId, env.document_id))
507
- return;
508
- const document = store.getDocument(ownerAgentId, env.document_id);
509
- if (!document)
510
- return;
511
- const seen = notifications.lastSeen(ownerAgentId, env.document_id);
512
- const after = projectDocumentText(live.get(ownerAgentId, env.document_id), document.documentType);
513
- // NEVER READ is not "everything changed" for the purposes of a nudge — the agent has not
514
- // established a baseline, so there is nothing it can be waiting on a change to.
515
- if (seen === null || seen === after)
516
- return;
517
- // A text document has no key paths, so the only thing it can report is that it moved. That
518
- // is why the whole-document watch has to be spelled `*` rather than implied.
519
- const paths = rootForDocumentType(document.documentType) === "map"
520
- ? changedKeyPaths(seen, after) ?? ["*"]
521
- : ["*"];
522
- const hits = matchWatchedPaths(watches, paths);
523
- if (hits.length === 0)
524
- return;
525
- // THE AGENT'S OWN PATTERNS travel, never the changed paths. A changed path can carry a key
526
- // the PEER named, and a doorbell body is an unscreened route into the agent's context —
527
- // see `matchingWatches`. The precise field comes from cello_doc_diff, which IS screened.
528
- const firedWatches = matchingWatches(watches, paths);
529
- notifications.markNudged(ownerAgentId, env.document_id, Date.now());
530
- logger.info("document.watch.nudged", {
531
- documentId: env.document_id,
532
- paths: hits.length,
533
- watches: firedWatches.length,
534
- });
535
- deps.nudge?.(ownerAgentId, env.document_id, firedWatches);
536
- }
537
- catch (err) {
538
- logger.warn("document.watch.nudge_failed", {
539
- documentId: env.document_id,
540
- error: err instanceof Error ? err.message : String(err),
541
- });
542
- }
543
- },
883
+ rewriteFile: rewriteFileImpl,
884
+ noticeInboundUpdate: noticeInboundUpdateImpl,
544
885
  sendFrameToPeer: async (ownerAgentId, inResponseTo, bytes) => {
545
886
  // Addressed to whoever AUTHORED the envelope being answered, taken from the envelope itself
546
887
  // rather than from the document row: the row's peer and the envelope's sender are the same
@@ -556,53 +897,6 @@ export function createDocumentLayer(deps) {
556
897
  });
557
898
  }
558
899
  },
559
- sendAck: async (ownerAgentId, wire, outcome) => {
560
- const env = decodeDocumentUpdateEnvelope(wire);
561
- const ack = {
562
- type: "document_ack",
563
- // The wire version this build speaks. Inlined rather than imported because protocol-types
564
- // exports the domain and the codec but not the constant; the decoder pins it by value.
565
- ack_version: 1,
566
- document_id: env.document_id,
567
- envelope_hash: outcome.envelopeHash,
568
- acker_agent_id: ownerAgentId,
569
- admitted: outcome.admitted,
570
- ...(outcome.admitted ? {} : { rejection_reason: outcome.rejectionReason ?? "refused" }),
571
- acked_at_ms: Date.now(),
572
- signature: new Uint8Array(0),
573
- };
574
- ack.signature = await deps.sign(ownerAgentId, buildDocumentAckTbs(ack));
575
- // BEST-EFFORT, and it must be: an ack that could not be sent is not a reason to refuse
576
- // content we have already admitted. The sender redelivers on its own timer, and the
577
- // redelivery is acked too — which is the case this whole path exists to terminate.
578
- const sent = await deps.sendFrame(ownerAgentId, env.sender_agent_id, encodeDocumentAck(ack));
579
- if (!sent.ok) {
580
- logger.warn("document.ack.unsent", {
581
- documentId: env.document_id,
582
- envelopeHash: outcome.envelopeHash,
583
- reason: sent.reason,
584
- correlationId: outcome.correlationId,
585
- });
586
- }
587
- },
588
- recordControl: (ownerAgentId, wire, nowMs) => {
589
- const control = decodeDocumentControl(wire);
590
- // VERIFIED FIRST. A kill frame ends a collaboration; unsigned, anyone reaching the channel
591
- // could end any document between any two parties and each operator would believe the other
592
- // walked away.
593
- if (!verifySignature(control.sender_agent_id, buildDocumentControlTbs(control), control.signature)) {
594
- throw new Error(`document_control_signature_invalid: the ${control.verb} claims to come from ` +
595
- `${control.sender_agent_id} but its signature does not verify against that agent`);
596
- }
597
- const verdict = control.verb === "kill"
598
- ? lifecycle.recordPeerKill(ownerAgentId, control.document_id, control.sender_agent_id, nowMs)
599
- : lifecycle.recordPeerClose(ownerAgentId, control.document_id, control.sender_agent_id, nowMs);
600
- if (!verdict.ok) {
601
- // Refusals here are real: an unknown document, or a sender who is not this document's peer.
602
- // Thrown so the router reports them rather than recording an end nobody was entitled to.
603
- throw new Error(`${verdict.reason}: ${verdict.detail}`);
604
- }
605
- },
606
900
  recordProposalAck: (ownerAgentId, wire, _nowMs) => {
607
901
  const ack = decodeDocumentProposalAck(wire);
608
902
  // VERIFIED against the agent it names, before anything is written. A refusal ack makes the
@@ -645,213 +939,43 @@ export function createDocumentLayer(deps) {
645
939
  });
646
940
  },
647
941
  });
648
- /**
649
- * M14B / DOD-MP-JOIN-1 — the invitee's ACCEPT: consent becomes a held document.
650
- *
651
- * VALIDATE EVERYTHING, THEN MUTATE. The stored offer is re-validated at the moment of
652
- * consequence (validate-before-append, on the bytes that will be appended), and the whole
653
- * envelope-log snapshot is verified — every signature, every document binding — before one row
654
- * lands. A snapshot with one bad envelope refuses the accept naming it, because skipping it
655
- * silently would hand the joiner a document that reads complete and diverges from every other
656
- * holder (NO-SILENT-DROP).
657
- *
658
- * Historical log envelopes are accepted at the epoch their SIGNED bytes claim — the inbound
659
- * epoch gate is for live arrivals; a snapshot legitimately spans epochs 0..N.
660
- */
661
- const acceptJoin = async (ownerAgentId, amendmentHash, nowMs) => {
662
- const record = joins.get(ownerAgentId, amendmentHash);
663
- if (!record || record.role !== "invitee") {
664
- return {
665
- ok: false,
666
- reason: "join_unknown_offer",
667
- detail: `no join offer for this agent settles on ${amendmentHash.slice(0, 16)}…`,
668
- };
669
- }
670
- if (record.state !== "pending") {
671
- return {
672
- ok: false,
673
- reason: "join_already_decided",
674
- detail: `this offer was already ${record.state} — a consent decision is made once`,
675
- };
676
- }
677
- const validation = validateDocumentJoinOffer(record.offer, documentGovernancePolicy, verifySignature);
678
- if (!validation.ok) {
679
- // Recorded, not just returned: an offer that no longer validates is a settled fact.
680
- joins.decide(ownerAgentId, amendmentHash, false, validation.reason, nowMs);
681
- return { ok: false, reason: "join_offer_invalid", detail: validation.reason };
682
- }
683
- const documentId = record.documentId;
684
- // WHO MAY APPEAR IN THE SNAPSHOT: anyone who held the document at ANY epoch of the carried
685
- // chain — the genesis pair plus every add_holder subject (a removed holder's history stays
686
- // legal). Without this, a malicious inviter plants envelopes signed by keys they control
687
- // under identities that were never holders, and the joiner materializes content no
688
- // legitimate holder ever saw — silent divergence the live path's sender-is-peer check
689
- // would have refused.
690
- const everHeld = new Set([
691
- validation.genesis.proposer_agent_id,
692
- validation.genesis.peer_agent_id,
693
- ]);
694
- for (const amendment of validation.amendments) {
695
- if (amendment.body.kind === "add_holder" && amendment.body.subject_agent_id !== null) {
696
- everHeld.add(amendment.body.subject_agent_id);
697
- }
698
- }
699
- // The WHOLE snapshot verified before any mutation.
700
- const verified = [];
701
- for (let i = 0; i < record.offer.envelope_log.length; i++) {
702
- const bytes = record.offer.envelope_log[i];
703
- let env;
704
- try {
705
- env = decodeDocumentUpdateEnvelope(bytes);
706
- }
707
- catch (err) {
708
- return {
709
- ok: false,
710
- reason: "join_log_invalid",
711
- detail: `snapshot envelope ${i} does not decode: ${err instanceof Error ? err.message : String(err)}`,
712
- };
713
- }
714
- if (env.document_id !== documentId) {
715
- return {
716
- ok: false,
717
- reason: "join_log_invalid",
718
- detail: `snapshot envelope ${i} names document ${env.document_id.slice(0, 16)}…, not this one`,
719
- };
720
- }
721
- if (!verifySignature(env.sender_agent_id, buildDocumentUpdateTbs(env), env.signature)) {
722
- return {
723
- ok: false,
724
- reason: "join_log_invalid",
725
- detail: `snapshot envelope ${i} does not verify against its sender ${env.sender_agent_id}`,
726
- };
727
- }
728
- if (!everHeld.has(env.sender_agent_id)) {
729
- return {
730
- ok: false,
731
- reason: "join_log_invalid",
732
- detail: `snapshot envelope ${i} is signed by ${env.sender_agent_id}, who never held this ` +
733
- `document at any epoch of the carried chain — refused before anything materializes`,
734
- };
735
- }
736
- verified.push({
737
- envelopeHash: documentEnvelopeHash(env),
738
- documentId,
739
- senderAgentId: env.sender_agent_id,
740
- docPrevHash: env.doc_prev_hash,
741
- epochId: env.epoch_id,
742
- signature: env.signature,
743
- stateVector: env.state_vector,
744
- payload: env.update,
745
- kind: "update",
746
- referencesEnvelopeHash: null,
747
- createdAtMs: nowMs,
748
- });
749
- }
750
- // Mutations, in dependency order: genesis (LiveDocuments reads starting_content from it),
751
- // the chain (idempotent on redelivery), the row, the log, the live rebuild, the file.
752
- handshake.recordJoined(ownerAgentId, record.offer.genesis, nowMs);
753
- try {
754
- for (const bytes of record.offer.amendments) {
755
- amendments.append(ownerAgentId, documentId, bytes, nowMs);
756
- }
757
- }
758
- catch (err) {
759
- // TWO ADMINS INVITED THE SAME AGENT INDEPENDENTLY: each offer's chain is self-consistent
760
- // and both sat pending, but their admitting amendments rival at one epoch — the second
761
- // accept hits the store's fork refusal mid-append. Settled as refused with the conflict's
762
- // own reason rather than thrown: a raw exception left the row pending and every retry
763
- // rethrowing, a wedge the operator could not see past.
764
- const reason = err instanceof Error ? err.message : String(err);
765
- joins.decide(ownerAgentId, amendmentHash, false, reason, nowMs);
766
- return { ok: false, reason: "join_conflicting_admission", detail: reason };
767
- }
768
- if (!store.getDocument(ownerAgentId, documentId)) {
769
- store.createDocument({
770
- documentId,
771
- ownerAgentId,
772
- // The inviter is this holder's live counterpart until fan-out delivery (P2) — the
773
- // participants that MATTER derive from the amendment chain (Entry 9's decision).
774
- peerAgentId: record.inviterAgentId,
775
- documentType: validation.genesis.document_type,
776
- properties: validation.genesis.properties,
777
- status: "active",
778
- createdAtMs: nowMs,
779
- });
780
- }
781
- for (const row of verified)
782
- store.appendEnvelope(ownerAgentId, row);
783
- const doc = live.get(ownerAgentId, documentId);
784
- if (writePath) {
785
- await writePath.materialize(ownerAgentId, documentId, validation.genesis.document_type, doc);
786
- }
787
- joins.decide(ownerAgentId, amendmentHash, true, null, nowMs);
788
- const answer = {
789
- type: "document_join_answer",
790
- document_id: documentId,
791
- amendment_hash: amendmentHash,
792
- invitee_agent_id: ownerAgentId,
793
- accepted: true,
794
- refusal_reason: null,
795
- answered_at_ms: nowMs,
796
- signature: new Uint8Array(0),
797
- };
798
- answer.signature = await deps.sign(ownerAgentId, buildDocumentJoinAnswerTbs(answer));
799
- logger.info("document.join.accepted", { documentId, amendmentHash, applied: verified.length });
800
- return {
801
- ok: true,
802
- documentId,
803
- inviterAgentId: record.inviterAgentId,
804
- documentType: validation.genesis.document_type,
805
- applied: verified.length,
806
- answerBytes: encodeDocumentJoinAnswer(answer),
807
- };
808
- };
809
- /** The invitee's REFUSE — local and final; the signed answer travels best-effort. */
810
- const refuseJoin = async (ownerAgentId, amendmentHash, reason, nowMs) => {
811
- const record = joins.get(ownerAgentId, amendmentHash);
812
- if (!record || record.role !== "invitee") {
813
- return {
814
- ok: false,
815
- reason: "join_unknown_offer",
816
- detail: `no join offer for this agent settles on ${amendmentHash.slice(0, 16)}…`,
817
- };
818
- }
819
- const decided = joins.decide(ownerAgentId, amendmentHash, false, reason, nowMs);
820
- if (!decided.decided) {
821
- return {
822
- ok: false,
823
- reason: "join_already_decided",
824
- detail: `this offer was already ${decided.state} — a consent decision is made once`,
825
- };
826
- }
827
- const answer = {
828
- type: "document_join_answer",
829
- document_id: record.documentId,
830
- amendment_hash: amendmentHash,
831
- invitee_agent_id: ownerAgentId,
832
- accepted: false,
833
- refusal_reason: reason,
834
- answered_at_ms: nowMs,
835
- signature: new Uint8Array(0),
836
- };
837
- answer.signature = await deps.sign(ownerAgentId, buildDocumentJoinAnswerTbs(answer));
838
- logger.info("document.join.refused", { documentId: record.documentId, amendmentHash });
839
- return {
840
- ok: true,
841
- documentId: record.documentId,
842
- inviterAgentId: record.inviterAgentId,
843
- answerBytes: encodeDocumentJoinAnswer(answer),
844
- };
845
- };
846
942
  return {
943
+ /** DOD-M12B-SHUTDOWN-1 — see the interface. One-way, idempotent, and the single place that
944
+ * closes the whole class rather than one of the four callers. */
945
+ stopReconciling() { reconcileStopped = true; },
847
946
  amendments,
848
- joins,
849
947
  verifySignature,
850
948
  holdersFor,
851
- controlHolders,
852
949
  isCurrentHolder,
853
- acceptJoin,
854
- refuseJoin,
950
+ standingOf,
951
+ partySync,
952
+ sweepTargets,
953
+ initiateReconcile,
954
+ governanceFrontierFor: (ownerAgentId, documentId) => {
955
+ const derived = reconcileReads(ownerAgentId).deriveState(documentId);
956
+ if (!derived.ok) {
957
+ // NAMED, not collapsed to null (review F4): the derivation's own reason — a missing
958
+ // genesis, undecodable bytes, a fold refusal — is the thing the operator hunts for,
959
+ // and the old holders gate logged it while this gate ran first and said nothing.
960
+ logger.error("document.frontier.underivable", { documentId, reason: derived.reason });
961
+ return null;
962
+ }
963
+ return [...derived.state.frontier];
964
+ },
965
+ deriveEnded: (ownerAgentId, documentId) => {
966
+ const derived = reconcileReads(ownerAgentId).deriveState(documentId);
967
+ if (!derived.ok)
968
+ return null;
969
+ return {
970
+ ended: derived.state.ended,
971
+ // Who the ending still waits on: every participant without a close entry, AND every open
972
+ // invitation — an invited seat IS a seat (Entry 54), so a close cannot settle around it.
973
+ waitingOn: [
974
+ ...[...derived.state.participants].filter((participant) => !derived.state.closedBy.has(participant)),
975
+ ...derived.state.invited,
976
+ ],
977
+ };
978
+ },
855
979
  store,
856
980
  writePath,
857
981
  handshake,
@@ -861,15 +985,14 @@ export function createDocumentLayer(deps) {
861
985
  notifications,
862
986
  rejections,
863
987
  router,
864
- awaitAck,
865
- onDocumentFrame: (agentName, _sessionId, content, _senderPubkey, correlationId) =>
988
+ onDocumentFrame: (agentName, _sessionId, content, senderPubkey, correlationId) =>
866
989
  // `agentName` names the owning agent for the session; the router maps it to the owner KEY
867
990
  // that scopes the store. The session id and the sender's transport pubkey are unused: a
868
991
  // document is bound to its PEER by the handshake, not to whichever session carried the
869
992
  // frame, and the envelope's own signed `sender_agent_id` is what the inbound path checks
870
993
  // against that binding. Trusting the transport identity instead would let a frame arriving
871
994
  // on any session act on any document that session's peer happens to share.
872
- router.routeSync(agentName, content, Date.now(), correlationId ?? "frame"),
995
+ router.routeSync(agentName, content, Date.now(), correlationId ?? "frame", senderPubkey),
873
996
  };
874
997
  }
875
998
  //# sourceMappingURL=document-layer.js.map