@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.
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +115 -254
- package/dist/daemon.js.map +1 -1
- package/dist/document-amendment-store.d.ts +94 -46
- package/dist/document-amendment-store.d.ts.map +1 -1
- package/dist/document-amendment-store.js +217 -102
- package/dist/document-amendment-store.js.map +1 -1
- package/dist/document-delivery-transport.d.ts +41 -52
- package/dist/document-delivery-transport.d.ts.map +1 -1
- package/dist/document-delivery-transport.js +6 -156
- package/dist/document-delivery-transport.js.map +1 -1
- package/dist/document-frame-router.d.ts +13 -17
- package/dist/document-frame-router.d.ts.map +1 -1
- package/dist/document-frame-router.js +21 -93
- package/dist/document-frame-router.js.map +1 -1
- package/dist/document-handlers.d.ts +1 -1
- package/dist/document-handlers.d.ts.map +1 -1
- package/dist/document-handlers.js +471 -466
- package/dist/document-handlers.js.map +1 -1
- package/dist/document-handshake.d.ts +4 -1
- package/dist/document-handshake.d.ts.map +1 -1
- package/dist/document-handshake.js +3 -1
- package/dist/document-handshake.js.map +1 -1
- package/dist/document-inbound.d.ts +18 -5
- package/dist/document-inbound.d.ts.map +1 -1
- package/dist/document-inbound.js +94 -110
- package/dist/document-inbound.js.map +1 -1
- package/dist/document-layer.d.ts +28 -67
- package/dist/document-layer.d.ts.map +1 -1
- package/dist/document-layer.js +670 -565
- package/dist/document-layer.js.map +1 -1
- package/dist/document-lifecycle.d.ts +11 -157
- package/dist/document-lifecycle.d.ts.map +1 -1
- package/dist/document-lifecycle.js +26 -561
- package/dist/document-lifecycle.js.map +1 -1
- package/dist/document-publish.d.ts +13 -0
- package/dist/document-publish.d.ts.map +1 -1
- package/dist/document-publish.js +19 -11
- package/dist/document-publish.js.map +1 -1
- package/dist/document-reconcile-engine.d.ts +66 -0
- package/dist/document-reconcile-engine.d.ts.map +1 -0
- package/dist/document-reconcile-engine.js +225 -0
- package/dist/document-reconcile-engine.js.map +1 -0
- package/dist/document-reconcile-scheduler.d.ts +91 -0
- package/dist/document-reconcile-scheduler.d.ts.map +1 -0
- package/dist/document-reconcile-scheduler.js +153 -0
- package/dist/document-reconcile-scheduler.js.map +1 -0
- package/dist/document-rejection.d.ts +2 -0
- package/dist/document-rejection.d.ts.map +1 -1
- package/dist/document-rejection.js +5 -1
- package/dist/document-rejection.js.map +1 -1
- package/dist/document-store.d.ts +18 -220
- package/dist/document-store.d.ts.map +1 -1
- package/dist/document-store.js +95 -717
- package/dist/document-store.js.map +1 -1
- package/dist/initiate-session-handler.d.ts +5 -0
- package/dist/initiate-session-handler.d.ts.map +1 -1
- package/dist/initiate-session-handler.js +1 -0
- package/dist/initiate-session-handler.js.map +1 -1
- package/package.json +5 -5
- package/dist/delivery-sweep-bound.d.ts +0 -79
- package/dist/delivery-sweep-bound.d.ts.map +0 -1
- package/dist/delivery-sweep-bound.js +0 -109
- package/dist/delivery-sweep-bound.js.map +0 -1
- package/dist/document-ack-inbound.d.ts +0 -71
- package/dist/document-ack-inbound.d.ts.map +0 -1
- package/dist/document-ack-inbound.js +0 -210
- package/dist/document-ack-inbound.js.map +0 -1
- package/dist/document-control-notifier.d.ts +0 -107
- package/dist/document-control-notifier.d.ts.map +0 -1
- package/dist/document-control-notifier.js +0 -137
- package/dist/document-control-notifier.js.map +0 -1
- package/dist/document-delivery.d.ts +0 -229
- package/dist/document-delivery.d.ts.map +0 -1
- package/dist/document-delivery.js +0 -449
- package/dist/document-delivery.js.map +0 -1
- package/dist/document-join-store.d.ts +0 -80
- package/dist/document-join-store.d.ts.map +0 -1
- package/dist/document-join-store.js +0 -197
- package/dist/document-join-store.js.map +0 -1
package/dist/document-layer.js
CHANGED
|
@@ -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,
|
|
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,
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
//
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
259
|
+
chain = [...amendments.chain(ownerAgentId, documentId)];
|
|
268
260
|
}
|
|
269
|
-
catch
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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.
|
|
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
|
-
/**
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
463
|
-
|
|
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
|
-
|
|
839
|
-
|
|
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
|
-
|
|
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
|