@cello-protocol/daemon 0.0.119 → 0.0.121

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.
@@ -0,0 +1,275 @@
1
+ /**
2
+ * DOD-DOC-ENGINE-1 — the daemon's Y.Doc lifecycle.
3
+ *
4
+ * The engine owns HOW to apply; the store (DOD-DOC-STORE-1) owns what to replay and in what
5
+ * order. `replay` takes whole envelope rows rather than payload bytes so it can tell a
6
+ * payload-free AUDIT record (a withdrawal or rejection, which is expected and skipped) from a
7
+ * payload-free PURGED update (an operation whose bytes are gone, which must refuse). That is the
8
+ * only thing `kind` is read for — `referencesEnvelopeHash` is pure audit at replay time, never
9
+ * read, because a withdrawal excludes nothing (§16.4; see `replay`).
10
+ *
11
+ * EVERY GUARD BELOW IS A RESPONSE TO A MEASUREMENT, not to a guess. DOD-DOC-FUZZ-1 fuzzed
12
+ * `Y.applyUpdate` and found:
13
+ *
14
+ * - Malformed input THROWS — so a wrapped apply genuinely contains it, and V1 needs no sandbox.
15
+ * - But the dangerous class is what Yjs ACCEPTS. An update whose dependencies the receiver
16
+ * lacks returns success, contributes nothing, and is RETAINED forever in
17
+ * `doc.store.pendingStructs` — a peer streams those until the daemon dies, and a try/catch
18
+ * sees only success. Hence the pending-set check after every apply.
19
+ * - An empty or one-byte update throws a lib0 DECODER error ("Unexpected end of array"), which
20
+ * names Yjs internals rather than a protocol fault. Hence a floor as well as a cap.
21
+ * - Yjs does not bound nesting depth at all, and the size cap bounds it poorly (~16 bytes per
22
+ * level, so roughly 65,000 levels fit in 1 MiB). Structural limits are DOD-DOC-GATE-1's job;
23
+ * the engine's contract is only that a bad update is a typed error, never a crash.
24
+ *
25
+ * ONE TYPED REASON PER FAILURE CLASS. A lib0 string like "Integer out of Range" describes where
26
+ * the decoder gave up, not what the peer did wrong, so it travels as `detail` and never as the
27
+ * reason an operator or a policy log sees.
28
+ */
29
+ import * as Y from "yjs";
30
+ /** Thrown by the *OrThrow variants and by `replay`, where returning a verdict would let a caller
31
+ * persist a state that was never fully applied. */
32
+ export class DocumentUpdateError extends Error {
33
+ reason;
34
+ detail;
35
+ constructor(reason, detail) {
36
+ super(detail ? `${reason}: ${detail}` : reason);
37
+ this.name = "DocumentUpdateError";
38
+ this.reason = reason;
39
+ this.detail = detail;
40
+ }
41
+ }
42
+ /** Pre-parse size cap — bytes are refused on LENGTH before Yjs is invoked at all. */
43
+ const MAX_UPDATE_BYTES = 1024 * 1024;
44
+ /**
45
+ * The minimum valid update. An empty encoded state is two bytes (`[0,0]`), measured — anything
46
+ * shorter throws a decoder error that says nothing about the protocol.
47
+ */
48
+ const MIN_UPDATE_BYTES = 2;
49
+ /** The single root text field a V1 text document uses. */
50
+ const TEXT_ROOT = "content";
51
+ export class DocumentEngine {
52
+ #logger;
53
+ constructor(logger) {
54
+ this.#logger = logger;
55
+ }
56
+ /** The pre-parse cap, exposed so callers and tests agree on one number. */
57
+ get maxUpdateBytes() {
58
+ return MAX_UPDATE_BYTES;
59
+ }
60
+ /**
61
+ * A fresh live document.
62
+ *
63
+ * Yjs mints its own random clientID and NOTHING here touches it (§14). Deriving it from agent
64
+ * identity, or persisting and restoring one, means two live docs can share it — and
65
+ * DOD-DOC-FUZZ-1 measured that outcome: the colliding writer silently wins, the honest client's
66
+ * update is accepted-and-dropped, and the document becomes a splice of two authors with an
67
+ * empty pending set and no error on any path.
68
+ */
69
+ createDocument(startingContent = "") {
70
+ const doc = new Y.Doc();
71
+ if (startingContent.length > 0)
72
+ doc.getText(TEXT_ROOT).insert(0, startingContent);
73
+ return doc;
74
+ }
75
+ /**
76
+ * Read the single text root a text-typed document uses.
77
+ *
78
+ * Named for the root it reads, not for "the document's content". `DocumentRow.documentType`
79
+ * admits markdown, json and xml; on the latter two the data does not live in this root, and an
80
+ * unnamed `readText` would return "" — indistinguishable from an empty document. Structured
81
+ * types get their own accessors when a unit needs them.
82
+ */
83
+ readTextRoot(doc) {
84
+ return doc.getText(TEXT_ROOT).toString();
85
+ }
86
+ insertIntoTextRoot(doc, index, text) {
87
+ doc.getText(TEXT_ROOT).insert(index, text);
88
+ }
89
+ /** The full state, or just what a peer holding `sinceStateVector` is missing (§7). */
90
+ encodeState(doc, sinceStateVector) {
91
+ return Y.encodeStateAsUpdate(doc, sinceStateVector);
92
+ }
93
+ encodeStateVector(doc) {
94
+ return Y.encodeStateVector(doc);
95
+ }
96
+ snapshot(doc) {
97
+ return { binary: Y.encodeStateAsUpdate(doc), stateVector: Y.encodeStateVector(doc) };
98
+ }
99
+ /**
100
+ * Materialize a document from a snapshot binary.
101
+ *
102
+ * The restored document mints a FRESH clientID — the binary carries the operations, never an
103
+ * identity to resume under. See `createDocument` for what sharing one costs.
104
+ */
105
+ restore(binary) {
106
+ const doc = new Y.Doc();
107
+ try {
108
+ Y.applyUpdate(doc, binary);
109
+ }
110
+ catch (err) {
111
+ // This is the one method that reads bytes off disk, and it used to be a bare applyUpdate —
112
+ // so a corrupt `document_snapshots` row surfaced as "Unexpected end of array", the exact
113
+ // lib0 string this module says must never be a reason. It named Yjs internals and pointed
114
+ // an operator at the wrong subsystem.
115
+ const detail = err instanceof Error ? err.message : String(err);
116
+ this.#logger.error("document.snapshot.malformed", { detail });
117
+ throw new DocumentUpdateError("document_snapshot_malformed", detail);
118
+ }
119
+ if (doc.store.pendingStructs !== null) {
120
+ // A snapshot that decodes but is incomplete would otherwise restore SHORT and silent.
121
+ this.#logger.error("document.snapshot.incomplete", {
122
+ missing: doc.store.pendingStructs.missing.size,
123
+ });
124
+ // Its OWN class. An incomplete snapshot is not "an incoming update depends on absent
125
+ // structs" — same symptom, different fault, and one reason per class is this module's rule.
126
+ throw new DocumentUpdateError("document_snapshot_incomplete", `snapshot is missing operations from ${doc.store.pendingStructs.missing.size} client(s)`);
127
+ }
128
+ return doc;
129
+ }
130
+ /**
131
+ * Apply one update, with every guard the fuzz pass motivated.
132
+ *
133
+ * Never throws and never leaves the document half-integrated: an update that cannot be fully
134
+ * resolved is applied to a THROWAWAY doc first, so the caller's document is untouched when the
135
+ * answer is no.
136
+ */
137
+ /**
138
+ * Apply one update to a caller's live document, leaving it untouched if the answer is no.
139
+ *
140
+ * The trial runs on a FRESH scratch document every time. Reusing one across calls is unsound:
141
+ * `Y.applyUpdate` MERGES, it does not reset, so a scratch doc seeded from an empty document
142
+ * still holds the previous trial's operations — and with that residue present, an update whose
143
+ * dependencies the target lacks reports an EMPTY pending set. The one guard that catches the
144
+ * accept class would go green on exactly the input it exists to refuse. (Measured, not
145
+ * reasoned: a shadow holding "AAA" re-seeded from an empty doc still reads "AAA".)
146
+ */
147
+ applyUpdate(doc, update) {
148
+ return this.#applyChecked(doc, new Y.Doc(), update);
149
+ }
150
+ #applyChecked(doc, shadow, update) {
151
+ if (update.length > MAX_UPDATE_BYTES) {
152
+ return this.#refuse("document_update_too_large", `${update.length} bytes exceeds the ${MAX_UPDATE_BYTES}-byte cap`, update);
153
+ }
154
+ if (update.length < MIN_UPDATE_BYTES) {
155
+ return this.#refuse("document_update_too_small", `${update.length} bytes is below the ${MIN_UPDATE_BYTES}-byte minimum for a Yjs update`, update);
156
+ }
157
+ // Trial it on scratch state first. Yjs has no atomic-apply mode, and an update that resolves
158
+ // only partially would otherwise leave the caller's document in a state nobody chose.
159
+ try {
160
+ Y.applyUpdate(shadow, Y.encodeStateAsUpdate(doc));
161
+ Y.applyUpdate(shadow, update);
162
+ }
163
+ catch (err) {
164
+ return this.#refuse("document_update_malformed", err instanceof Error ? err.message : String(err), update);
165
+ }
166
+ // THE ACCEPT CLASS. Yjs returned success — that is not evidence the update integrated. A
167
+ // non-empty pending set means it is waiting on structs that never arrived; admitting it would
168
+ // retain them indefinitely while contributing nothing (measured, DOD-DOC-FUZZ-1).
169
+ //
170
+ // PRECONDITION THIS IMPLIES: a sender's envelopes must be delivered in chain order. Yjs would
171
+ // otherwise BUFFER an out-of-order update and resolve it on the next state-vector exchange;
172
+ // this refuses instead. That is right for replay, where a gap means a corrupt log — a live
173
+ // receive path wanting buffer-and-re-request semantics needs its own entry point, not this one.
174
+ if (shadow.store.pendingStructs !== null) {
175
+ return this.#refuse("document_update_unresolved_dependencies", `depends on ${shadow.store.pendingStructs.missing.size} client(s) whose earlier operations are absent`, update);
176
+ }
177
+ Y.applyUpdate(doc, update);
178
+ return { ok: true };
179
+ }
180
+ /**
181
+ * Apply straight to `doc`, checking the pending set afterwards.
182
+ *
183
+ * No trial copy, because atomicity buys nothing here: `replay` throws on the first failure and
184
+ * the half-built document is discarded whole. Trialling each envelope would cost a full
185
+ * encode/decode of a growing document per envelope — the fold is O(n·|doc|) either way, but
186
+ * this halves the constant and removes the aliasing hazard entirely.
187
+ */
188
+ #applyDirect(doc, update) {
189
+ if (update.length > MAX_UPDATE_BYTES) {
190
+ return this.#refuse("document_update_too_large", `${update.length} bytes exceeds the ${MAX_UPDATE_BYTES}-byte cap`, update);
191
+ }
192
+ if (update.length < MIN_UPDATE_BYTES) {
193
+ return this.#refuse("document_update_too_small", `${update.length} bytes is below the ${MIN_UPDATE_BYTES}-byte minimum for a Yjs update`, update);
194
+ }
195
+ try {
196
+ Y.applyUpdate(doc, update);
197
+ }
198
+ catch (err) {
199
+ return this.#refuse("document_update_malformed", err instanceof Error ? err.message : String(err), update);
200
+ }
201
+ if (doc.store.pendingStructs !== null) {
202
+ return this.#refuse("document_update_unresolved_dependencies", `depends on ${doc.store.pendingStructs.missing.size} client(s) whose earlier operations are absent`, update);
203
+ }
204
+ return { ok: true };
205
+ }
206
+ /** Every refusal is logged, not merely returned — a return value nobody reads is not observable. */
207
+ #refuse(reason, detail, update) {
208
+ this.#logger.warn("document.update.refused", { reason, detail, bytes: update.length });
209
+ return { ok: false, reason, detail };
210
+ }
211
+ /** `applyUpdate` for callers that want the failure to be unmissable. */
212
+ applyUpdateOrThrow(doc, update) {
213
+ const res = this.applyUpdate(doc, update);
214
+ if (!res.ok)
215
+ throw new DocumentUpdateError(res.reason, res.detail);
216
+ }
217
+ /**
218
+ * Fold an ordered envelope log into a materialized state — the `ReplayFn` the store injects.
219
+ *
220
+ * **A WITHDRAWAL EXCLUDES NOTHING.** §16.4 is explicit: withdrawing rolls the change back with
221
+ * a *Yjs undo* and writes a record "beside the original envelope — marked withdrawn, never
222
+ * deleted, so the log stays intact". Rejection resolves the same way, by supersession —
223
+ * "inverses, not erasure" (§3.2). So the undo is itself an ordinary update in the log, and
224
+ * replay simply applies every payload in order.
225
+ *
226
+ * An earlier version of this method excluded the referenced envelope instead, which is
227
+ * unsound in a CRDT log and was measured to be so: Yjs operations are causally chained, so
228
+ * dropping any but the LAST envelope leaves every later one depending on structs that never
229
+ * arrive — the document rebuilds until the next daemon restart and is permanently unopenable
230
+ * after it. It also let ANY sender suppress ANY other sender's content by appending a
231
+ * payload-free row, since nothing upstream checks authorship of a reference. Both problems
232
+ * dissolve when the log is simply replayed, which is what the spec said to do.
233
+ *
234
+ * REFUSES rather than skipping. A payload that will not apply means the log is corrupt, and
235
+ * folding the rest would produce a document that reads as complete while missing operations —
236
+ * the silent divergence the whole two-layer design exists to prevent.
237
+ */
238
+ replay(envelopes) {
239
+ const doc = new Y.Doc();
240
+ let applied = 0;
241
+ for (const e of envelopes) {
242
+ if (e.payload === null) {
243
+ // A withdrawal or rejection RECORD legitimately carries no payload — it is audit, not
244
+ // content. An `update` row with no payload is different: it is a PURGED operation whose
245
+ // bytes are gone (§16.7-12), and folding around it would produce a document that reads
246
+ // as complete while missing an operation. Purge is V2, so this cannot happen yet — which
247
+ // is exactly why it must refuse now rather than become a silent skip later.
248
+ if (e.kind === "update") {
249
+ this.#logger.error("document.replay.failed", {
250
+ documentId: e.documentId,
251
+ envelopeHash: e.envelopeHash,
252
+ reason: "document_envelope_purged",
253
+ });
254
+ throw new DocumentUpdateError("document_envelope_purged", `envelope ${e.envelopeHash.slice(0, 16)}… is an update whose payload has been purged — ` +
255
+ `the document cannot be rebuilt without it`);
256
+ }
257
+ continue;
258
+ }
259
+ const res = this.#applyDirect(doc, e.payload);
260
+ if (!res.ok) {
261
+ this.#logger.error("document.replay.failed", {
262
+ documentId: e.documentId,
263
+ envelopeHash: e.envelopeHash,
264
+ reason: res.reason,
265
+ detail: res.detail,
266
+ });
267
+ throw new DocumentUpdateError(res.reason, `envelope ${e.envelopeHash.slice(0, 16)}…: ${res.detail ?? ""}`);
268
+ }
269
+ applied++;
270
+ }
271
+ this.#logger.info("document.replay.completed", { envelopes: envelopes.length, applied });
272
+ return { binary: Y.encodeStateAsUpdate(doc), stateVector: Y.encodeStateVector(doc) };
273
+ }
274
+ }
275
+ //# sourceMappingURL=document-engine.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"document-engine.js","sourceRoot":"","sources":["../src/document-engine.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,181 @@
1
+ /**
2
+ * DOD-DOC-STORE-1 — the daemon's three document tables (§16.7-12).
3
+ *
4
+ * TWO LAYERS, NOT ONE (§14). Storing only a merged Yjs binary would discard the signed envelope
5
+ * chain that makes a seal verifiable, so both are kept:
6
+ *
7
+ * `document_envelopes` — the IMMUTABLE log: signatures, provenance, the per-document chain.
8
+ * This is the truth.
9
+ * `document_snapshots` — a MATERIALIZATION for fast start. Disposable by construction:
10
+ * delete it and it rebuilds from the log, byte-identical.
11
+ *
12
+ * The distinction is load-bearing rather than stylistic. Live Yjs state deliberately does NOT
13
+ * survive a daemon restart — CELLO's invariant is daemon-up-is-CELLO-on — and that is only safe
14
+ * because the log makes rebuilding a lookup rather than an archaeology exercise.
15
+ *
16
+ * THIS MODULE PERSISTS; IT DOES NOT APPLY. Replay is INJECTED (`rebuildSnapshot`), so the store
17
+ * owns *what to replay and in what order* while the engine (DOD-DOC-ENGINE-1) owns *how to
18
+ * apply*. That keeps `yjs` out of this file's imports entirely and keeps the P0/P1 boundary
19
+ * honest — a store that quietly grew a Y.Doc would be the engine wearing a store's name.
20
+ *
21
+ * WHAT IS NEVER STORED HERE: a Yjs clientID. §14's one-line rule is "let Yjs mint its own per
22
+ * live Y.Doc; never derive it from agent identity, never persist and restore one." DOD-DOC-FUZZ-1
23
+ * measured the cost of getting this wrong — two live docs sharing a clientID means the colliding
24
+ * writer silently wins, the honest client's update is accepted-and-dropped, and the result is a
25
+ * splice of two authors with an EMPTY pending set and no error on any path. So the snapshot
26
+ * stores a binary and a state vector, and nothing that could be restored into a live document as
27
+ * an identity.
28
+ *
29
+ * KEYED ON STABLE IDS ONLY. `agent_id` and `document_id`, never `agent_name` — it is a mutable
30
+ * display label and reusable after retirement. The M7 session tables join on `agent_name`; that
31
+ * is a known defect (`DOD-AGENT-ID-JOINKEY-1`), not a precedent to copy.
32
+ */
33
+ import type { DaemonDatabase } from "./sqlcipher-db.js";
34
+ import type { Logger } from "./types.js";
35
+ /** Lifecycle states a document can hold (§3.5). `stalled` is DOD-DOC-REJECT-1's terminal state. */
36
+ export type DocumentStatus = "active" | "closed" | "killed" | "stalled";
37
+ /**
38
+ * What an envelope IS, as opposed to what it says. A withdrawal or a rejection is a NEW ROW that
39
+ * references an earlier envelope — never an edit to it. The log is append-only, so the only way
40
+ * to say "that one no longer counts" is to add a record saying so.
41
+ */
42
+ export type DocumentEnvelopeKind = "update" | "withdrawal" | "rejection";
43
+ export interface DocumentProperties {
44
+ /** V1 accepts only `authenticated` — the Tier-2 seam (§16.1). */
45
+ assurance_tier?: string;
46
+ /** V1 accepts only `false` — the Tier-2 seam (§16.1). */
47
+ schema_enforcement?: boolean;
48
+ append_only?: boolean;
49
+ [key: string]: unknown;
50
+ }
51
+ export interface DocumentRow {
52
+ documentId: string;
53
+ ownerAgentId: string;
54
+ peerAgentId: string;
55
+ documentType: string;
56
+ properties: DocumentProperties;
57
+ status: DocumentStatus;
58
+ createdAtMs: number;
59
+ }
60
+ export interface DocumentEnvelopeRow {
61
+ /** Content hash of the envelope — the stable identity, and the log's primary key. */
62
+ envelopeHash: string;
63
+ documentId: string;
64
+ /** WHO authored it. The chain is per-sender, so this is what partitions it. */
65
+ senderAgentId: string;
66
+ /** The sender's previous envelope for this document, or null at genesis (§9.1). */
67
+ docPrevHash: string | null;
68
+ /** Constant 0 in V1 and NEVER omitted — after a compaction an update that does not state its
69
+ * epoch cannot be verified unambiguously (§14, §16.1 seam). */
70
+ epochId: number;
71
+ signature: Uint8Array;
72
+ /** The sender's Yjs state vector at publish time (§7). */
73
+ stateVector: Uint8Array;
74
+ /** NULLABLE — a purged envelope keeps its hash and signature, proving it existed (§16.7-12). */
75
+ payload: Uint8Array | null;
76
+ kind: DocumentEnvelopeKind;
77
+ /** For a withdrawal or rejection: the envelope it concerns. */
78
+ referencesEnvelopeHash?: string | null;
79
+ createdAtMs: number;
80
+ /** Position in this document's log. Assigned on append; absent until then. */
81
+ logIndex?: number;
82
+ }
83
+ export interface DocumentSnapshot {
84
+ binary: Uint8Array;
85
+ stateVector: Uint8Array;
86
+ /**
87
+ * The `log_index` of the last envelope this snapshot reflects. §14 is explicit about why it is
88
+ * stored rather than derived: with it, "where does the snapshot sit relative to the log" is a
89
+ * lookup; without it, every rebuild works it out from scratch.
90
+ *
91
+ * **-1 means nothing has been applied.** The resume point is ALWAYS `lastAppliedIndex + 1`,
92
+ * so an empty log yields 0 and `getEnvelopesSince(0)` reads the whole log — the sentinel and
93
+ * the convention are chosen to agree.
94
+ */
95
+ lastAppliedIndex: number;
96
+ }
97
+ /** Thrown when a read path refuses to materialize over a chain that does not verify. */
98
+ export declare class DocumentChainError extends Error {
99
+ readonly reason: "document_chain_broken" | "document_chain_forked";
100
+ readonly detail: string;
101
+ constructor(reason: "document_chain_broken" | "document_chain_forked", detail: string);
102
+ }
103
+ export type ChainVerdict = {
104
+ ok: true;
105
+ } | {
106
+ ok: false;
107
+ reason: "document_chain_broken" | "document_chain_forked";
108
+ detail: string;
109
+ };
110
+ /**
111
+ * Injected by the engine: fold an ordered envelope list into a materialized state.
112
+ *
113
+ * Takes ROWS, not bare payloads. The engine has to distinguish an update from a withdrawal or a
114
+ * rejection to fold correctly — a withdrawn envelope's payload must not be applied — and it
115
+ * cannot do that from `Uint8Array[]`. The store supplies the WHOLE log in order — withdrawal and
116
+ * rejection records included, payload-free though they are; deciding what counts is the engine's
117
+ * call, not the store's.
118
+ */
119
+ export type ReplayFn = (envelopes: DocumentEnvelopeRow[]) => {
120
+ binary: Uint8Array;
121
+ stateVector: Uint8Array;
122
+ };
123
+ export declare class DocumentStore {
124
+ #private;
125
+ constructor(db: DaemonDatabase, logger: Logger);
126
+ createDocument(row: DocumentRow): void;
127
+ getDocument(ownerAgentId: string, documentId: string): DocumentRow | null;
128
+ listDocuments(ownerAgentId: string): DocumentRow[];
129
+ setDocumentStatus(ownerAgentId: string, documentId: string, status: DocumentStatus): void;
130
+ /**
131
+ * Append an envelope at the next log position.
132
+ *
133
+ * IMMUTABLE AT A HASH: the conflict clause is scoped to the envelope hash ALONE, so a
134
+ * re-delivery — or a peer replaying the same hash with different bytes — cannot overwrite what
135
+ * was recorded. Returns whether a new row was written, so the caller can tell a genuine append
136
+ * from a duplicate rather than inferring it.
137
+ *
138
+ * Scoped deliberately, not written as a bare `OR IGNORE`: that form suppresses CHECK, UNIQUE
139
+ * and NOT NULL as well, which on an append-only log means a malformed `kind` or a colliding
140
+ * `log_index` would be DROPPED and reported to the caller as an already-seen duplicate. Every
141
+ * constraint except the hash conflict must throw, or `false` means three different things the
142
+ * caller cannot tell apart.
143
+ */
144
+ appendEnvelope(ownerAgentId: string, envelope: DocumentEnvelopeRow): boolean;
145
+ /**
146
+ * Envelopes at or after `fromIndex`, in log order. This is what makes `lastAppliedIndex` the
147
+ * lookup §14 asks for: resume an incremental rebuild at `lastAppliedIndex + 1` without reading
148
+ * the whole log.
149
+ */
150
+ getEnvelopesSince(ownerAgentId: string, documentId: string, fromIndex: number): DocumentEnvelopeRow[];
151
+ getEnvelopeLog(ownerAgentId: string, documentId: string): DocumentEnvelopeRow[];
152
+ /**
153
+ * Verify each sender's `doc_prev_hash` chain independently.
154
+ *
155
+ * The log interleaves both parties' envelopes, so there is no single total order to check —
156
+ * §16.7-5 defines replay set-based per epoch for exactly this reason. What must hold is that
157
+ * every sender's own links form one unbroken chain from a single genesis.
158
+ *
159
+ * A break REFUSES and names the sender and the missing predecessor. Skipping the leaf and
160
+ * carrying on would leave a document that reads as complete while missing operations, which is
161
+ * the silent-divergence failure the chain exists to prevent.
162
+ *
163
+ * LINKAGE ONLY — the name says so deliberately. This verifies no signature and does not check
164
+ * that `envelope_hash` hashes the content; neither is possible here (no key, no encoder). A
165
+ * caller must not read `ok: true` as authenticity — that belongs to the engine.
166
+ */
167
+ verifyChainLinkage(ownerAgentId: string, documentId: string): ChainVerdict;
168
+ getSnapshot(ownerAgentId: string, documentId: string): DocumentSnapshot | null;
169
+ /** REPLACES — a snapshot is a cache of the log, not a second log. */
170
+ putSnapshot(ownerAgentId: string, documentId: string, snapshot: DocumentSnapshot): void;
171
+ deleteSnapshot(ownerAgentId: string, documentId: string): void;
172
+ /**
173
+ * Rebuild a snapshot from the envelope log alone, using the caller's replay function.
174
+ *
175
+ * Payload-stripped envelopes (purged, or withdrawal/rejection records that carry none)
176
+ * contribute nothing to replay but STILL COUNT toward `lastAppliedIndex` — otherwise the next
177
+ * incremental rebuild would start behind them and replay them forever.
178
+ */
179
+ rebuildSnapshot(ownerAgentId: string, documentId: string, replay: ReplayFn): DocumentSnapshot;
180
+ }
181
+ //# sourceMappingURL=document-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"document-store.d.ts","sourceRoot":"","sources":["../src/document-store.ts"],"names":[],"mappings":"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"}