@loomwork/ui-lineage 0.0.0-stage → 0.1.0
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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +471 -2
- package/dist/conformance.d.ts +4 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +143 -0
- package/dist/conformance.js.map +1 -0
- package/dist/derive.d.ts +15 -0
- package/dist/derive.d.ts.map +1 -0
- package/dist/derive.js +35 -0
- package/dist/derive.js.map +1 -0
- package/dist/esm/conformance.js +140 -0
- package/dist/esm/conformance.js.map +1 -0
- package/dist/esm/derive.js +29 -0
- package/dist/esm/derive.js.map +1 -0
- package/dist/esm/events.js +101 -0
- package/dist/esm/events.js.map +1 -0
- package/dist/esm/index.js +12 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/lineage.js +1376 -0
- package/dist/esm/lineage.js.map +1 -0
- package/dist/esm/retention.js +73 -0
- package/dist/esm/retention.js.map +1 -0
- package/dist/esm/rootsFor.js +49 -0
- package/dist/esm/rootsFor.js.map +1 -0
- package/dist/esm/seen.js +733 -0
- package/dist/esm/seen.js.map +1 -0
- package/dist/esm/seenCrypto.js +197 -0
- package/dist/esm/seenCrypto.js.map +1 -0
- package/dist/esm/seenGc.js +122 -0
- package/dist/esm/seenGc.js.map +1 -0
- package/dist/esm/state.js +185 -0
- package/dist/esm/state.js.map +1 -0
- package/dist/esm/store.js +172 -0
- package/dist/esm/store.js.map +1 -0
- package/dist/events.d.ts +781 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +106 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -0
- package/dist/lineage.d.ts +394 -0
- package/dist/lineage.d.ts.map +1 -0
- package/dist/lineage.js +1383 -0
- package/dist/lineage.js.map +1 -0
- package/dist/retention.d.ts +45 -0
- package/dist/retention.d.ts.map +1 -0
- package/dist/retention.js +78 -0
- package/dist/retention.js.map +1 -0
- package/dist/rootsFor.d.ts +33 -0
- package/dist/rootsFor.d.ts.map +1 -0
- package/dist/rootsFor.js +52 -0
- package/dist/rootsFor.js.map +1 -0
- package/dist/seen.d.ts +375 -0
- package/dist/seen.d.ts.map +1 -0
- package/dist/seen.js +739 -0
- package/dist/seen.js.map +1 -0
- package/dist/seenCrypto.d.ts +72 -0
- package/dist/seenCrypto.d.ts.map +1 -0
- package/dist/seenCrypto.js +210 -0
- package/dist/seenCrypto.js.map +1 -0
- package/dist/seenGc.d.ts +73 -0
- package/dist/seenGc.d.ts.map +1 -0
- package/dist/seenGc.js +126 -0
- package/dist/seenGc.js.map +1 -0
- package/dist/state.d.ts +66 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +190 -0
- package/dist/state.js.map +1 -0
- package/dist/store.d.ts +93 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +177 -0
- package/dist/store.js.map +1 -0
- package/package.json +60 -3
- package/src/conformance.ts +148 -0
- package/src/derive.ts +57 -0
- package/src/events.ts +125 -0
- package/src/index.ts +79 -0
- package/src/lineage.ts +1655 -0
- package/src/retention.ts +118 -0
- package/src/rootsFor.ts +75 -0
- package/src/seen.ts +965 -0
- package/src/seenCrypto.ts +271 -0
- package/src/seenGc.ts +233 -0
- package/src/state.ts +268 -0
- package/src/store.ts +274 -0
|
@@ -0,0 +1,1376 @@
|
|
|
1
|
+
import { isKeyUnavailable, isPayloadShredded } from "@loomwork/event-runtime";
|
|
2
|
+
import { Provenance as ProvenanceSchema, Sensitivity, apply, applyAt, boundSummary, emptyLedger, experienceClassFloor, explainNode, foldProvenance, mergeReadPolicy, replay, } from "@loomwork/ui-core";
|
|
3
|
+
import { getExperience, putExperience, seal } from "@loomwork/ui-core/log";
|
|
4
|
+
import { authorOf, branchArtifact, revertArtifact } from "./derive";
|
|
5
|
+
import { EXPERIENCE_BRANCHED, EXPERIENCE_COMPOSED, EXPERIENCE_PROMOTED, EXPERIENCE_REVERTED, } from "./events";
|
|
6
|
+
import { retainedVersions, validateRetentionPolicy } from "./retention";
|
|
7
|
+
import { foldLineage, payloadRefusal } from "./state";
|
|
8
|
+
import { InMemoryLineageStore, LINEAGE_STREAM_PREFIX, LINEAGE_WORKFLOW_ID, } from "./store";
|
|
9
|
+
/**
|
|
10
|
+
* Thrown by the reads that return plain values (`forEachContentRef`, `pins` and so `rootsFor`) when
|
|
11
|
+
* a line's key is gone AND the host confirmed the erasure (`LineageOptions.confirmErased`). Those
|
|
12
|
+
* reads SKIP a confirmed-erased line (it contributes no roots), so this reaches a caller only from a
|
|
13
|
+
* single-line read; every method that returns a `LineageResult` (or `verify`'s findings) reports the
|
|
14
|
+
* same condition as a `refused` result with code `line-erased` instead of throwing.
|
|
15
|
+
*/
|
|
16
|
+
export class LineErasedError extends Error {
|
|
17
|
+
experienceId;
|
|
18
|
+
name = "LineErasedError";
|
|
19
|
+
code = "line-erased";
|
|
20
|
+
confirmed = true;
|
|
21
|
+
constructor(experienceId, options) {
|
|
22
|
+
super(`lineage "${experienceId}" is erased: its key is destroyed (confirmed by the host)`, options);
|
|
23
|
+
this.experienceId = experienceId;
|
|
24
|
+
Object.setPrototypeOf(this, LineErasedError.prototype);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The line's key is unavailable on this node, or its ciphertext / artifact does not open, and the
|
|
29
|
+
* erasure is NOT confirmed (no `confirmErased` hook, or it said no). Could be a destroyed key, a key
|
|
30
|
+
* this node does not hold, a tampered event, a drifted `subjectOf`, or an artifact written before an
|
|
31
|
+
* `EncryptingContentStore` was composed (`reason: "artifact-unsealed"`). Fail closed, but not an
|
|
32
|
+
* erasure: GC refuses rather than guess, and a read says so. `reason` is also the refusal's finding code.
|
|
33
|
+
*/
|
|
34
|
+
export class LineUnreadableError extends Error {
|
|
35
|
+
experienceId;
|
|
36
|
+
name = "LineUnreadableError";
|
|
37
|
+
code = "line-unreadable";
|
|
38
|
+
confirmed = false;
|
|
39
|
+
reason;
|
|
40
|
+
constructor(experienceId, options) {
|
|
41
|
+
super(`lineage "${experienceId}" is unreadable: its key is unavailable on this node or its ciphertext does not open; not confirmed erased`, options?.cause === undefined ? undefined : { cause: options.cause });
|
|
42
|
+
this.experienceId = experienceId;
|
|
43
|
+
this.reason = options?.reason ?? "line-unreadable";
|
|
44
|
+
Object.setPrototypeOf(this, LineUnreadableError.prototype);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Is `err` a read failure that makes a line unreadable? The kernel's stable predicates, never message text. */
|
|
48
|
+
const isUnreadableCause = (err) => isKeyUnavailable(err) || isPayloadShredded(err);
|
|
49
|
+
/** loom's "the blob exists but does not open although the key exists" (plaintext or damaged bytes). */
|
|
50
|
+
const isUndecryptable = (err) => err instanceof Error && err.name === "ContentUndecryptableError";
|
|
51
|
+
/**
|
|
52
|
+
* Classify a read failure of one line: `undefined` when it is not an unreadable-line failure
|
|
53
|
+
* (rethrow it), else a typed error. ONE rule: `line-erased` iff the host's `confirmErased` says so;
|
|
54
|
+
* `isPayloadShredded` alone is not confirmation. An undecryptable blob is never an erasure.
|
|
55
|
+
*/
|
|
56
|
+
export async function classifyLineFailure(experienceId, err, confirmErased) {
|
|
57
|
+
if (isUndecryptable(err))
|
|
58
|
+
return new LineUnreadableError(experienceId, { cause: err, reason: "artifact-unsealed" });
|
|
59
|
+
if (!isUnreadableCause(err))
|
|
60
|
+
return undefined;
|
|
61
|
+
let confirmed = false;
|
|
62
|
+
if (confirmErased !== undefined) {
|
|
63
|
+
try {
|
|
64
|
+
confirmed = (await confirmErased(experienceId)) === true;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
confirmed = false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return confirmed
|
|
71
|
+
? new LineErasedError(experienceId, { cause: err })
|
|
72
|
+
: new LineUnreadableError(experienceId, { cause: err });
|
|
73
|
+
}
|
|
74
|
+
const blockedFinding = (e) => e instanceof LineErasedError
|
|
75
|
+
? {
|
|
76
|
+
code: "line-erased",
|
|
77
|
+
severity: "error",
|
|
78
|
+
message: `lineage "${e.experienceId}" is erased: its key is destroyed (confirmed); nothing readable or writable`,
|
|
79
|
+
}
|
|
80
|
+
: {
|
|
81
|
+
code: e.reason,
|
|
82
|
+
severity: "error",
|
|
83
|
+
message: e.reason === "artifact-unsealed"
|
|
84
|
+
? `lineage "${e.experienceId}" is unreadable: an artifact blob did not open under the line's key (written before sealing was composed, or damaged); not an erasure`
|
|
85
|
+
: `lineage "${e.experienceId}" is unreadable: its key is unavailable on this node or its ciphertext does not open; not confirmed erased`,
|
|
86
|
+
};
|
|
87
|
+
const blocked = (e) => fromFinding(blockedFinding(e));
|
|
88
|
+
const BLOCKED_CODES = new Set([
|
|
89
|
+
"line-erased",
|
|
90
|
+
"line-unreadable",
|
|
91
|
+
"artifact-unsealed",
|
|
92
|
+
]);
|
|
93
|
+
const isBlockedRefusal = (r) => r.refusal.kind === "refused" && BLOCKED_CODES.has(r.refusal.finding.code);
|
|
94
|
+
const isErasedRefusal = (r) => r.refusal.kind === "refused" && r.refusal.finding.code === "line-erased";
|
|
95
|
+
const refused = (code, message) => ({
|
|
96
|
+
ok: false,
|
|
97
|
+
refusal: { kind: "refused", finding: { code, severity: "error", message } },
|
|
98
|
+
});
|
|
99
|
+
const fromFinding = (finding) => ({
|
|
100
|
+
ok: false,
|
|
101
|
+
refusal: { kind: "refused", finding },
|
|
102
|
+
});
|
|
103
|
+
/** Bound on re-reading after a lost append whose head did not move (a promotion slipped in). */
|
|
104
|
+
const MAX_TIP_RETRIES = 16;
|
|
105
|
+
/** Bound on how many source lines `verify` follows through branches (a branch of a branch of ...). */
|
|
106
|
+
const MAX_VERIFY_DEPTH = 32;
|
|
107
|
+
/** Whether `hash` is `ancestor` or reaches it through `parents` within the line (iterative, cycle-safe). */
|
|
108
|
+
function descendsFrom(state, hash, ancestor) {
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
const stack = [hash];
|
|
111
|
+
for (let h = stack.pop(); h !== undefined; h = stack.pop()) {
|
|
112
|
+
if (h === ancestor)
|
|
113
|
+
return true;
|
|
114
|
+
if (seen.has(h))
|
|
115
|
+
continue;
|
|
116
|
+
seen.add(h);
|
|
117
|
+
for (const p of state.versions.get(h)?.parents ?? [])
|
|
118
|
+
stack.push(p);
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Every `by` a write would record (the write's own and each op's) must satisfy core's `Provenance`,
|
|
124
|
+
* the schema the fold enforces on read. The fold stays strict; the REFUSAL is here, because the log
|
|
125
|
+
* has no delete and one event that does not fold bricks the line for every reader.
|
|
126
|
+
*/
|
|
127
|
+
function provenanceRefusal(by, ops) {
|
|
128
|
+
const bad = (what) => refused("provenance-invalid", `${what} is not a valid provenance (nothing was written)`);
|
|
129
|
+
if (!ProvenanceSchema.safeParse(by).success)
|
|
130
|
+
return bad("`by`");
|
|
131
|
+
if (ops !== undefined) {
|
|
132
|
+
if (!Array.isArray(ops))
|
|
133
|
+
return refused("ops-invalid", "ops must be an array");
|
|
134
|
+
for (const [i, r] of ops.entries())
|
|
135
|
+
if (typeof r !== "object" || r === null || !ProvenanceSchema.safeParse(r.by).success)
|
|
136
|
+
return bad(`op #${i}'s \`by\``);
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
/** Iterative (no recursion limit) walk for `{ __loomContent: string }` leaves. */
|
|
141
|
+
function walkContentRefs(root, visit) {
|
|
142
|
+
const stack = [root];
|
|
143
|
+
const seen = new Set();
|
|
144
|
+
while (stack.length > 0) {
|
|
145
|
+
const cur = stack.pop();
|
|
146
|
+
if (typeof cur !== "object" || cur === null || seen.has(cur))
|
|
147
|
+
continue;
|
|
148
|
+
seen.add(cur);
|
|
149
|
+
const a = cur.__loomContent;
|
|
150
|
+
if (typeof a === "string")
|
|
151
|
+
visit(a);
|
|
152
|
+
for (const v of Array.isArray(cur) ? cur : Object.values(cur))
|
|
153
|
+
stack.push(v);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Run `fn` over `items` with at most `limit` in flight; rejects on the first failure (nothing after it starts). */
|
|
157
|
+
async function mapLimit(items, limit, fn) {
|
|
158
|
+
const out = new Array(items.length);
|
|
159
|
+
let next = 0;
|
|
160
|
+
let failed = false;
|
|
161
|
+
const worker = async () => {
|
|
162
|
+
while (!failed && next < items.length) {
|
|
163
|
+
const i = next++;
|
|
164
|
+
try {
|
|
165
|
+
out[i] = await fn(items[i]);
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
failed = true;
|
|
169
|
+
throw e;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
const sameSet = (a, b) => a.length === b.length && [...a].sort().every((x, i) => x === [...b].sort()[i]);
|
|
177
|
+
export class Lineage {
|
|
178
|
+
events;
|
|
179
|
+
content;
|
|
180
|
+
now;
|
|
181
|
+
policy;
|
|
182
|
+
streamPrefix;
|
|
183
|
+
workflowId;
|
|
184
|
+
confirmErased;
|
|
185
|
+
constructor(opts) {
|
|
186
|
+
this.policy = mergeReadPolicy(opts.readPolicy);
|
|
187
|
+
this.events = opts.events;
|
|
188
|
+
this.content = opts.content;
|
|
189
|
+
this.now = opts.now ?? (() => new Date().toISOString());
|
|
190
|
+
this.confirmErased = opts.confirmErased;
|
|
191
|
+
const p = opts.events.streamPrefix;
|
|
192
|
+
if (opts.content.sealsContent === true &&
|
|
193
|
+
!(typeof p === "string" && p.length > 0))
|
|
194
|
+
throw new Error("Lineage: the content store seals per subject (sealsContent: true) but the events store does not expose a streamPrefix (use eventStoreLineageStore): the blobs could be sealed under a different subject than the line's events");
|
|
195
|
+
this.streamPrefix = typeof p === "string" && p.length > 0 ? p : LINEAGE_STREAM_PREFIX;
|
|
196
|
+
const w = opts.events.workflowId;
|
|
197
|
+
this.workflowId = typeof w === "string" && w.length > 0 ? w : LINEAGE_WORKFLOW_ID;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The `ContentAccess` of one line's artifact blobs: the line's STREAM id (the id its events live
|
|
201
|
+
* under) and the lineage workflow id. A content store that seals by owner (`EncryptingContentStore`)
|
|
202
|
+
* resolves it through the host's `subjectOf`, the same mapping its event store applies to the
|
|
203
|
+
* stream, so one mapping covers a line's events and blobs. A plain store ignores it.
|
|
204
|
+
*/
|
|
205
|
+
accessFor(experienceId) {
|
|
206
|
+
return { executionId: `${this.streamPrefix}${experienceId}`, workflowId: this.workflowId };
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Classify a read failure of `experienceId` with this lineage's `confirmErased` hook: a typed
|
|
210
|
+
* {@link LineErasedError} (confirmed) or {@link LineUnreadableError}, or `undefined` when `err` is
|
|
211
|
+
* not an unreadable-line failure. Readers built over this lineage (the change feed) use it.
|
|
212
|
+
*/
|
|
213
|
+
block(experienceId, err) {
|
|
214
|
+
return classifyLineFailure(experienceId, err, this.confirmErased);
|
|
215
|
+
}
|
|
216
|
+
/** Every record of a line; an unreadable line THROWS a {@link LineBlockedError}, anything else propagates. */
|
|
217
|
+
async readLine(experienceId) {
|
|
218
|
+
try {
|
|
219
|
+
return await this.events.read(experienceId);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
throw (await this.block(experienceId, err)) ?? err;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async confirms(experienceId) {
|
|
226
|
+
if (this.confirmErased === undefined)
|
|
227
|
+
return false;
|
|
228
|
+
try {
|
|
229
|
+
return (await this.confirmErased(experienceId)) === true;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* A write step failed with `err`: a typed refusal when the line is (now) unreadable, else rethrow.
|
|
237
|
+
* The re-read catches a key destroyed between `state()` and the append, whose own error is untyped.
|
|
238
|
+
*/
|
|
239
|
+
async failedWrite(experienceId, err) {
|
|
240
|
+
const b = await this.block(experienceId, err);
|
|
241
|
+
if (b !== undefined)
|
|
242
|
+
return blocked(b);
|
|
243
|
+
const after = await this.state(experienceId).catch(() => undefined);
|
|
244
|
+
if (after !== undefined && !after.ok && isBlockedRefusal(after))
|
|
245
|
+
return after;
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
/** The read policy bound at construction (empty when none); readers built over this lineage inherit it. */
|
|
249
|
+
get readPolicy() {
|
|
250
|
+
return this.policy;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Whether `other` is this lineage's own store, or may write under a stream prefix that overlaps its
|
|
254
|
+
* own (`a.startsWith(b) || b.startsWith(a)`: `x:` and `x:y:` collide). A `SeenLog` refuses such a
|
|
255
|
+
* store: a viewer's record must never share streams with the line it describes.
|
|
256
|
+
*
|
|
257
|
+
* FAIL-CLOSED: the vetted store's `streamPrefix` must be readable (the package's prefixed stores
|
|
258
|
+
* and `InMemoryLineageStore` are). A decorator that HIDES `streamPrefix` cannot be told apart from a
|
|
259
|
+
* colliding store, so it is refused: re-expose `streamPrefix` on the wrapper (copied from the inner
|
|
260
|
+
* store) if it is legitimate. The reverse is a documented limit: if THIS lineage's store is such a
|
|
261
|
+
* wrapper its prefix is assumed to be the default (`LINEAGE_STREAM_PREFIX`).
|
|
262
|
+
*/
|
|
263
|
+
sharesStreamsWith(other) {
|
|
264
|
+
if (other === this.events)
|
|
265
|
+
return true;
|
|
266
|
+
const prefixOf = (s) => {
|
|
267
|
+
const p = s.streamPrefix;
|
|
268
|
+
if (typeof p === "string" && p.length > 0)
|
|
269
|
+
return p;
|
|
270
|
+
return s instanceof InMemoryLineageStore ? null : undefined; // null: isolated by identity
|
|
271
|
+
};
|
|
272
|
+
// `other` is the store being vetted: with no readable prefix it cannot be proven disjoint, so it
|
|
273
|
+
// is refused. This lineage's OWN store may be a wrapper that hides its prefix (a counting or
|
|
274
|
+
// tracing decorator): it is assumed to use the package default.
|
|
275
|
+
const b = prefixOf(other);
|
|
276
|
+
if (b === undefined)
|
|
277
|
+
return true;
|
|
278
|
+
const a = prefixOf(this.events) ?? LINEAGE_STREAM_PREFIX;
|
|
279
|
+
if (a === null || b === null)
|
|
280
|
+
return false; // a distinct in-memory object shares nothing
|
|
281
|
+
return a.startsWith(b) || b.startsWith(a);
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Call `visit` with every content address any version of any line cites (artifacts, op payloads,
|
|
285
|
+
* anything stored as a content ref in an event), CONSERVATIVELY: any `__loomContent` string found
|
|
286
|
+
* anywhere in a payload counts, claim-check envelopes included. This is the lineage's half of the
|
|
287
|
+
* root set a content collector marks. One line is held in memory at a time. Throws if the store
|
|
288
|
+
* cannot enumerate its lines (`listStreams`): a collector must refuse then, never guess.
|
|
289
|
+
*/
|
|
290
|
+
async forEachContentRef(visit) {
|
|
291
|
+
if (this.events.listStreams === undefined)
|
|
292
|
+
throw new Error("Lineage.forEachContentRef: the LineageStore cannot enumerate its lines");
|
|
293
|
+
for await (const id of this.events.listStreams()) {
|
|
294
|
+
let records;
|
|
295
|
+
try {
|
|
296
|
+
records = await this.readLine(id);
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
if (err instanceof LineErasedError)
|
|
300
|
+
continue; // confirmed erased: no roots, its blobs are reclaimable
|
|
301
|
+
throw err; // unconfirmed: refuse rather than guess
|
|
302
|
+
}
|
|
303
|
+
for (const rec of records)
|
|
304
|
+
walkContentRefs(rec.payload, visit);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* U17 — the content addresses a collector must KEEP, as the `roots` of loom's `sweepUnpinnedContent`:
|
|
309
|
+
* for every line, the artifact of each version `policy` retains (`retainedVersions`: the newest N,
|
|
310
|
+
* the head, the promoted version, the genesis, every fork's source) plus every OTHER content ref the
|
|
311
|
+
* line's events cite (anything an op carries) plus every content ref found INSIDE a retained
|
|
312
|
+
* artifact's own bytes (a node whose props hold a media ref: the sweep does not read blobs, so
|
|
313
|
+
* nothing else would root it). The artifact ref of a version the policy does not keep is
|
|
314
|
+
* deliberately absent, so that blob becomes reclaimable.
|
|
315
|
+
*
|
|
316
|
+
* USE IT INSTEAD OF scanning the lineage's streams, never as well: a scanned stream pins every
|
|
317
|
+
* version's artifact for ever (a bare ref is load-bearing to loom's sweep), so pass the lineage's
|
|
318
|
+
* `LineageStore` to neither `eventStores` nor `snapshotStores`. Mind the consequence: a reclaimed
|
|
319
|
+
* version's event stays in the log, `get` of it reports the missing artifact, and `verify` of a line
|
|
320
|
+
* that has had versions reclaimed reports them (and the versions derived from them) until the line
|
|
321
|
+
* is verified from a retained checkpoint. Reclaim only versions nobody needs to re-open.
|
|
322
|
+
*
|
|
323
|
+
* THE ROOT SET MUST BE COMPLETE. With `reclaimOrphans: { completeScan: true }` loom erases every
|
|
324
|
+
* blob in the store that no root names. The lineage is only ONE user of its content store: pass
|
|
325
|
+
* the others through `extra` (a {@link SeenLog} sharing the store: `collectSeenRoots(log)`), and
|
|
326
|
+
* add any other lineage's `pins()` and loom's own execution scan to the same `roots`. A
|
|
327
|
+
* version a viewer's recorded view still names is kept via `extra.keepVersions`.
|
|
328
|
+
*
|
|
329
|
+
* RACES. A promote, a branch (and a seen-log record) TOUCH the artifact they newly cite
|
|
330
|
+
* (`ContentStore.touch`, SPI 1.4.0, ADR 0055 D6 amendment 2), so a sweep whose roots were computed
|
|
331
|
+
* a moment earlier still refuses to delete it. That also covers a fork made between this method's
|
|
332
|
+
* two passes. It needs a store with `touch`; loom's sweep refuses to erase through one without it.
|
|
333
|
+
*
|
|
334
|
+
* COST. Linear in the number of RETAINED, DISTINCT artifacts, and each one is read in full: the
|
|
335
|
+
* refs a version cites (media, blobs) live only inside its artifact's bytes, and neither the version
|
|
336
|
+
* event nor the store records which artifacts hold any, so there is nothing to skip on without
|
|
337
|
+
* reading. The reads are bounded, not avoided: a retention policy that keeps fewer versions reads
|
|
338
|
+
* fewer artifacts, artifacts shared by versions or forks are read once per call, and `extra.concurrency`
|
|
339
|
+
* sets how many of a line are in flight. Run it from the collector, not on a request path. (Skipping
|
|
340
|
+
* ref-free artifacts would need a "cites refs" flag stamped on the version event at write time: a
|
|
341
|
+
* lineage event-schema change, not done here.)
|
|
342
|
+
*
|
|
343
|
+
* Two passes, one line in memory at a time. Throws if the store cannot enumerate its lines, if
|
|
344
|
+
* any line fails to fold, if a retained artifact cannot be read for a reason other than being
|
|
345
|
+
* already gone, or on a malformed policy (a collector must refuse, never guess).
|
|
346
|
+
*/
|
|
347
|
+
async *pins(policy = {}, extra = {}) {
|
|
348
|
+
validateRetentionPolicy(policy);
|
|
349
|
+
const concurrency = extra.concurrency ?? 8;
|
|
350
|
+
if (!Number.isInteger(concurrency) || concurrency < 1)
|
|
351
|
+
throw new Error("Lineage.pins: concurrency must be a positive integer");
|
|
352
|
+
if (this.events.listStreams === undefined)
|
|
353
|
+
throw new Error("Lineage.pins: the LineageStore cannot enumerate its lines");
|
|
354
|
+
// Pass 1: the versions that must outlive their referrers: other lines' forks, and `extra`.
|
|
355
|
+
const kept = new Map();
|
|
356
|
+
const addKept = (experienceId, versionHash) => {
|
|
357
|
+
const set = kept.get(experienceId) ?? new Set();
|
|
358
|
+
kept.set(experienceId, set);
|
|
359
|
+
set.add(versionHash);
|
|
360
|
+
};
|
|
361
|
+
for (const v of extra.keepVersions ?? [])
|
|
362
|
+
addKept(v.experienceId, v.versionHash);
|
|
363
|
+
for await (const id of this.events.listStreams()) {
|
|
364
|
+
let records1;
|
|
365
|
+
try {
|
|
366
|
+
records1 = await this.readLine(id);
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
if (err instanceof LineErasedError)
|
|
370
|
+
continue; // confirmed erased: contributes nothing
|
|
371
|
+
throw err; // unconfirmed: refuse rather than guess
|
|
372
|
+
}
|
|
373
|
+
const fold = foldLineage(id, records1);
|
|
374
|
+
if (!fold.ok)
|
|
375
|
+
throw new Error(`Lineage.pins: "${id}" does not fold: ${fold.finding.message}`);
|
|
376
|
+
for (const v of fold.state.versions.values())
|
|
377
|
+
if (v.from !== undefined)
|
|
378
|
+
addKept(v.from.experienceId, v.from.versionHash);
|
|
379
|
+
}
|
|
380
|
+
// Pass 2: the roots.
|
|
381
|
+
const yielded = new Set();
|
|
382
|
+
const walked = new Set();
|
|
383
|
+
const pending = [];
|
|
384
|
+
const emit = (a) => {
|
|
385
|
+
if (yielded.has(a))
|
|
386
|
+
return;
|
|
387
|
+
yielded.add(a);
|
|
388
|
+
pending.push(a);
|
|
389
|
+
};
|
|
390
|
+
for await (const id of this.events.listStreams()) {
|
|
391
|
+
let records;
|
|
392
|
+
try {
|
|
393
|
+
records = await this.readLine(id);
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
if (err instanceof LineErasedError)
|
|
397
|
+
continue;
|
|
398
|
+
throw err;
|
|
399
|
+
}
|
|
400
|
+
const fold = foldLineage(id, records);
|
|
401
|
+
if (!fold.ok)
|
|
402
|
+
throw new Error(`Lineage.pins: "${id}" does not fold: ${fold.finding.message}`);
|
|
403
|
+
const { keep } = retainedVersions(fold.state, policy, kept.get(id));
|
|
404
|
+
// This line's roots are gathered locally and emitted once its artifacts read. If its blobs turn
|
|
405
|
+
// out to be confirmed erased (events readable, blobs gone), only its OWN version artifacts are
|
|
406
|
+
// dropped (they are the erased ciphertext, reclaimable by design); every other address its
|
|
407
|
+
// plain events cite (a shared blob an op points at) stays rooted, which is the conservative side.
|
|
408
|
+
const lineRoots = [];
|
|
409
|
+
const ownArtifacts = new Set();
|
|
410
|
+
for (const rec of records) {
|
|
411
|
+
const p = rec.payload;
|
|
412
|
+
// The artifact ref of a version event is pinned only if its version is retained.
|
|
413
|
+
const artifact = typeof p?.ref?.__loomContent === "string" ? p.ref.__loomContent : undefined;
|
|
414
|
+
if (artifact !== undefined)
|
|
415
|
+
ownArtifacts.add(artifact);
|
|
416
|
+
// (a PROMOTED event cites the same artifact; it counts as a citation of that version too)
|
|
417
|
+
const droppedVersion = typeof p?.versionHash === "string" && !keep.has(p.versionHash);
|
|
418
|
+
walkContentRefs(rec.payload, (a) => {
|
|
419
|
+
if (a === artifact && droppedVersion)
|
|
420
|
+
return;
|
|
421
|
+
lineRoots.push(a);
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
// Refs that live only inside a retained artifact's bytes. Artifacts are content-addressed, so
|
|
425
|
+
// versions (and forks) that share an artifact are read ONCE per call; a line's reads run with
|
|
426
|
+
// bounded concurrency and any failure but "missing" throws before this line yields anything.
|
|
427
|
+
const toWalk = [];
|
|
428
|
+
for (const h of keep) {
|
|
429
|
+
const v = fold.state.versions.get(h);
|
|
430
|
+
if (v === undefined)
|
|
431
|
+
continue;
|
|
432
|
+
const ref = v.ref;
|
|
433
|
+
// Marked walked only once this line's reads succeed (below): a line skipped as erased must not
|
|
434
|
+
// stop a later line that cites the same address from walking it.
|
|
435
|
+
if (walked.has(ref.__loomContent) ||
|
|
436
|
+
toWalk.some((r) => r.__loomContent === ref.__loomContent))
|
|
437
|
+
continue;
|
|
438
|
+
toWalk.push(ref);
|
|
439
|
+
}
|
|
440
|
+
let arts;
|
|
441
|
+
try {
|
|
442
|
+
arts = await mapLimit(toWalk, concurrency, async (ref) => {
|
|
443
|
+
let art;
|
|
444
|
+
try {
|
|
445
|
+
art = await getExperience(this.content, ref, this.accessFor(id));
|
|
446
|
+
}
|
|
447
|
+
catch (err) {
|
|
448
|
+
throw (await this.block(id, err)) ?? err;
|
|
449
|
+
}
|
|
450
|
+
if (art.ok)
|
|
451
|
+
return art.experience;
|
|
452
|
+
if (art.finding.code === "artifact-missing")
|
|
453
|
+
return undefined; // already gone: nothing inside to root
|
|
454
|
+
throw new Error(`Lineage.pins: "${id}" artifact ${ref.__loomContent}: ${art.finding.message}`);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
catch (err) {
|
|
458
|
+
if (!(err instanceof LineErasedError))
|
|
459
|
+
throw err;
|
|
460
|
+
// Confirmed erased: drop this line's own artifacts, keep what its events cite elsewhere.
|
|
461
|
+
for (const a of lineRoots)
|
|
462
|
+
if (!ownArtifacts.has(a))
|
|
463
|
+
emit(a);
|
|
464
|
+
while (pending.length > 0)
|
|
465
|
+
yield pending.shift();
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
for (const ref of toWalk)
|
|
469
|
+
walked.add(ref.__loomContent);
|
|
470
|
+
for (const a of lineRoots)
|
|
471
|
+
emit(a);
|
|
472
|
+
for (const exp of arts)
|
|
473
|
+
if (exp !== undefined)
|
|
474
|
+
walkContentRefs(exp, emit);
|
|
475
|
+
while (pending.length > 0)
|
|
476
|
+
yield pending.shift();
|
|
477
|
+
}
|
|
478
|
+
if (extra.keepAddresses !== undefined)
|
|
479
|
+
for await (const a of extra.keepAddresses) {
|
|
480
|
+
emit(a);
|
|
481
|
+
while (pending.length > 0)
|
|
482
|
+
yield pending.shift();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Tell the content store that `versionHashes` of `experienceId` were JUST re-cited (by a promote,
|
|
487
|
+
* a branch, a recorded view): `ContentStore.touch` on their artifacts, so a collector's
|
|
488
|
+
* conditional delete spares them (loom ADR 0055 D6 amendment 2). A store without `touch` is
|
|
489
|
+
* left alone (loom's sweep will not erase through one). Refuses when an artifact is already gone.
|
|
490
|
+
*/
|
|
491
|
+
async cite(experienceId, versionHashes) {
|
|
492
|
+
const touch = this.content.touch;
|
|
493
|
+
if (touch === undefined)
|
|
494
|
+
return { ok: true, value: undefined };
|
|
495
|
+
const s = await this.state(experienceId);
|
|
496
|
+
if (!s.ok)
|
|
497
|
+
return s;
|
|
498
|
+
const refs = [];
|
|
499
|
+
for (const h of versionHashes) {
|
|
500
|
+
const v = s.value.versions.get(h);
|
|
501
|
+
if (v === undefined)
|
|
502
|
+
return refused("version-unknown", `"${h}" is not a version of "${experienceId}"`);
|
|
503
|
+
refs.push(v.ref.__loomContent);
|
|
504
|
+
}
|
|
505
|
+
const missing = await touch.call(this.content, refs);
|
|
506
|
+
if (missing.length > 0)
|
|
507
|
+
return refused("artifact-missing", `no content at ${missing[0]} (already reclaimed)`);
|
|
508
|
+
return { ok: true, value: undefined };
|
|
509
|
+
}
|
|
510
|
+
// ── reads ───────────────────────────────────────────────────────────────────
|
|
511
|
+
/** The folded state of a line (empty state for an unknown one). A malformed log is a finding. */
|
|
512
|
+
async state(experienceId) {
|
|
513
|
+
let records;
|
|
514
|
+
try {
|
|
515
|
+
records = await this.readLine(experienceId);
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
if (err instanceof LineErasedError || err instanceof LineUnreadableError)
|
|
519
|
+
return blocked(err);
|
|
520
|
+
throw err;
|
|
521
|
+
}
|
|
522
|
+
const fold = foldLineage(experienceId, records);
|
|
523
|
+
return fold.ok ? { ok: true, value: fold.state } : fromFinding(fold.finding);
|
|
524
|
+
}
|
|
525
|
+
/** The latest version's hash, or null for a line with no version. */
|
|
526
|
+
async head(experienceId) {
|
|
527
|
+
const s = await this.state(experienceId);
|
|
528
|
+
return s.ok ? { ok: true, value: s.value.head } : s;
|
|
529
|
+
}
|
|
530
|
+
/** Every version, oldest first. */
|
|
531
|
+
async versions(experienceId) {
|
|
532
|
+
const s = await this.state(experienceId);
|
|
533
|
+
if (!s.ok)
|
|
534
|
+
return s;
|
|
535
|
+
return {
|
|
536
|
+
ok: true,
|
|
537
|
+
value: s.value.order.map((h) => s.value.versions.get(h)),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* `from` (default the head) and its ancestors WITHIN this line, newest first, following
|
|
542
|
+
* `parents`. A branch's first version stops the walk (its parent is in another line; see
|
|
543
|
+
* `VersionRecord.from`). Iterative with a visited set: a forged cycle cannot loop it.
|
|
544
|
+
*/
|
|
545
|
+
async walk(experienceId, from) {
|
|
546
|
+
const s = await this.state(experienceId);
|
|
547
|
+
if (!s.ok)
|
|
548
|
+
return s;
|
|
549
|
+
const start = from ?? s.value.head;
|
|
550
|
+
if (start === null)
|
|
551
|
+
return { ok: true, value: [] };
|
|
552
|
+
if (!s.value.versions.has(start))
|
|
553
|
+
return refused("version-unknown", `"${start}" is not a version of "${experienceId}"`);
|
|
554
|
+
const out = [];
|
|
555
|
+
const seen = new Set();
|
|
556
|
+
const stack = [start];
|
|
557
|
+
for (let h = stack.pop(); h !== undefined; h = stack.pop()) {
|
|
558
|
+
const v = s.value.versions.get(h);
|
|
559
|
+
if (v === undefined || seen.has(h))
|
|
560
|
+
continue;
|
|
561
|
+
seen.add(h);
|
|
562
|
+
out.push(v);
|
|
563
|
+
for (let i = v.parents.length - 1; i >= 0; i--)
|
|
564
|
+
stack.push(v.parents[i]);
|
|
565
|
+
}
|
|
566
|
+
return { ok: true, value: out };
|
|
567
|
+
}
|
|
568
|
+
/** One version's artifact, hash-verified (`getExperience`). */
|
|
569
|
+
async get(experienceId, versionHash) {
|
|
570
|
+
const s = await this.state(experienceId);
|
|
571
|
+
if (!s.ok)
|
|
572
|
+
return s;
|
|
573
|
+
return this.load(s.value, versionHash);
|
|
574
|
+
}
|
|
575
|
+
/** The latest version's artifact. */
|
|
576
|
+
async resolveLatest(experienceId) {
|
|
577
|
+
const s = await this.state(experienceId);
|
|
578
|
+
if (!s.ok)
|
|
579
|
+
return s;
|
|
580
|
+
if (s.value.head === null)
|
|
581
|
+
return refused("lineage-empty", `"${experienceId}" has no versions`);
|
|
582
|
+
return this.load(s.value, s.value.head);
|
|
583
|
+
}
|
|
584
|
+
/** The promoted (shown) version's artifact, or refused when nothing is promoted. */
|
|
585
|
+
async resolvePromoted(experienceId) {
|
|
586
|
+
const s = await this.state(experienceId);
|
|
587
|
+
if (!s.ok)
|
|
588
|
+
return s;
|
|
589
|
+
if (s.value.promoted === null)
|
|
590
|
+
return refused("nothing-promoted", `"${experienceId}" has no promoted version`);
|
|
591
|
+
return this.load(s.value, s.value.promoted);
|
|
592
|
+
}
|
|
593
|
+
async load(state, versionHash) {
|
|
594
|
+
const v = state.versions.get(versionHash);
|
|
595
|
+
if (v === undefined)
|
|
596
|
+
return refused("version-unknown", `"${versionHash}" is not a version of "${state.experienceId}"`);
|
|
597
|
+
let r;
|
|
598
|
+
try {
|
|
599
|
+
r = await getExperience(this.content, v.ref, this.accessFor(state.experienceId));
|
|
600
|
+
}
|
|
601
|
+
catch (err) {
|
|
602
|
+
const b = await this.block(state.experienceId, err);
|
|
603
|
+
if (b !== undefined)
|
|
604
|
+
return blocked(b);
|
|
605
|
+
throw err;
|
|
606
|
+
}
|
|
607
|
+
if (!r.ok)
|
|
608
|
+
return fromFinding(r.finding);
|
|
609
|
+
if (r.experience.versionHash !== versionHash ||
|
|
610
|
+
r.experience.experienceId !== state.experienceId)
|
|
611
|
+
return refused("artifact-mismatch", `the content at ${v.ref.__loomContent} is not version ${versionHash} of "${state.experienceId}"`);
|
|
612
|
+
return { ok: true, value: r.experience };
|
|
613
|
+
}
|
|
614
|
+
// ── provenance (U12) ────────────────────────────────────────────────────────
|
|
615
|
+
/**
|
|
616
|
+
* The per-node ledger of version `versionHash` (default the head): which recorded op created and
|
|
617
|
+
* last changed each node, derived from the log alone (no artifact is read, nothing is stored).
|
|
618
|
+
* A revert has the ledger of the version it restores; a branch starts from its source version's.
|
|
619
|
+
* Bounded: a chain of reverts or branches deeper than `MAX_VERIFY_DEPTH` ends in an unknown
|
|
620
|
+
* origin rather than recursing without limit.
|
|
621
|
+
*/
|
|
622
|
+
async provenance(experienceId, versionHash) {
|
|
623
|
+
const s = await this.state(experienceId);
|
|
624
|
+
if (!s.ok)
|
|
625
|
+
return s;
|
|
626
|
+
const target = versionHash ?? s.value.head;
|
|
627
|
+
if (target === null)
|
|
628
|
+
return refused("lineage-empty", `"${experienceId}" has no versions`);
|
|
629
|
+
if (!s.value.versions.has(target))
|
|
630
|
+
return refused("version-unknown", `"${target}" is not a version of "${experienceId}"`);
|
|
631
|
+
return { ok: true, value: await this.ledgerAt(s.value, target, 0) };
|
|
632
|
+
}
|
|
633
|
+
async ledgerAt(state, target, depth) {
|
|
634
|
+
const unknown = { ...emptyLedger(null), truncated: true };
|
|
635
|
+
if (depth > MAX_VERIFY_DEPTH)
|
|
636
|
+
return unknown;
|
|
637
|
+
const chain = [];
|
|
638
|
+
const seen = new Set();
|
|
639
|
+
let base = unknown;
|
|
640
|
+
for (let h = target; h !== undefined;) {
|
|
641
|
+
const v = state.versions.get(h);
|
|
642
|
+
if (v === undefined || seen.has(h))
|
|
643
|
+
break;
|
|
644
|
+
seen.add(h);
|
|
645
|
+
if (v.kind === "reverted" && v.revertedTo !== undefined) {
|
|
646
|
+
base = await this.ledgerAt(state, v.revertedTo, depth + 1);
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
649
|
+
if (v.kind === "branched" && v.from !== undefined) {
|
|
650
|
+
const src = await this.state(v.from.experienceId);
|
|
651
|
+
base =
|
|
652
|
+
src.ok && src.value.versions.has(v.from.versionHash)
|
|
653
|
+
? await this.ledgerAt(src.value, v.from.versionHash, depth + 1)
|
|
654
|
+
: unknown;
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
if (v.parents.length === 0) {
|
|
658
|
+
base = emptyLedger({ versionHash: v.versionHash, at: v.at, by: v.by });
|
|
659
|
+
chain.push(v);
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
chain.push(v);
|
|
663
|
+
h = v.parents[0];
|
|
664
|
+
}
|
|
665
|
+
return foldProvenance(base, chain.reverse());
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Why is `nodeId` in version `versionHash` (default the head), FOR `viewer`? The ledger of that
|
|
669
|
+
* version read through core's `explainNode`: unknown, restricted and unreadable all answer the
|
|
670
|
+
* same bare `{ available: false }`. The creating version's summary and the request that prompted
|
|
671
|
+
* it are shown only when the viewer may see the strictest class of the viewed version, of the
|
|
672
|
+
* version that wrote them, and of what the planner read (`summaryClass`); a version that cannot be
|
|
673
|
+
* read counts as `secret`. Performs no authorization: `viewer` is the host's claim.
|
|
674
|
+
*/
|
|
675
|
+
async explain(experienceId, nodeId, opts = {}) {
|
|
676
|
+
// The bound policy applies; a per-call value can only add to it.
|
|
677
|
+
const policy = mergeReadPolicy(this.policy, opts);
|
|
678
|
+
const s = await this.state(experienceId);
|
|
679
|
+
if (!s.ok)
|
|
680
|
+
return s;
|
|
681
|
+
const target = opts.versionHash ?? s.value.head;
|
|
682
|
+
if (target === null)
|
|
683
|
+
return refused("lineage-empty", `"${experienceId}" has no versions`);
|
|
684
|
+
const exp = await this.load(s.value, target);
|
|
685
|
+
if (!exp.ok)
|
|
686
|
+
return exp;
|
|
687
|
+
const ledger = await this.ledgerAt(s.value, target, 0);
|
|
688
|
+
const viewOpts = {
|
|
689
|
+
ledger,
|
|
690
|
+
...(opts.viewer !== undefined && { viewer: opts.viewer }),
|
|
691
|
+
...(opts.audience !== undefined && { audience: opts.audience }),
|
|
692
|
+
...(policy.projections !== undefined && { projections: policy.projections }),
|
|
693
|
+
...(policy.hydrationProjectionId !== undefined && {
|
|
694
|
+
hydrationProjectionId: policy.hydrationProjectionId,
|
|
695
|
+
}),
|
|
696
|
+
...(policy.declaredToolClasses !== undefined && {
|
|
697
|
+
declaredToolClasses: policy.declaredToolClasses,
|
|
698
|
+
}),
|
|
699
|
+
...(policy.undeclared !== undefined && { undeclared: policy.undeclared }),
|
|
700
|
+
};
|
|
701
|
+
// Ask first with no notes: an unknown, hidden or unreadable node answers `{ available: false }`
|
|
702
|
+
// HERE, before the writer is loaded, so the I/O a probe costs is the same for all three (a
|
|
703
|
+
// hidden node must not be told apart from an unknown id by how many blobs a call fetched).
|
|
704
|
+
const first = explainNode(exp.value, nodeId, viewOpts);
|
|
705
|
+
if (!first.available)
|
|
706
|
+
return { ok: true, value: first };
|
|
707
|
+
const entry = ledger.nodes.get(nodeId)?.created;
|
|
708
|
+
const wrote = entry?.versionHash ?? ledger.origin?.versionHash;
|
|
709
|
+
if (wrote === undefined)
|
|
710
|
+
return { ok: true, value: first };
|
|
711
|
+
const notes = new Map();
|
|
712
|
+
let extraFloor;
|
|
713
|
+
// The writer may live on ANOTHER line (a branch's ledger starts from its source's): resolve it
|
|
714
|
+
// across lines, bounded. Unresolvable = fail closed (the text it wrote cannot be classed).
|
|
715
|
+
const found = await this.findVersion(s.value, wrote);
|
|
716
|
+
if (found === undefined)
|
|
717
|
+
extraFloor = "secret";
|
|
718
|
+
else {
|
|
719
|
+
notes.set(wrote, {
|
|
720
|
+
...(found.record.summary !== undefined && { summary: found.record.summary }),
|
|
721
|
+
...(found.record.summaryClass !== undefined && {
|
|
722
|
+
summaryClass: found.record.summaryClass,
|
|
723
|
+
}),
|
|
724
|
+
});
|
|
725
|
+
if (found.record.summary !== undefined || entry?.prompt !== undefined) {
|
|
726
|
+
const art = await this.load(found.state, wrote);
|
|
727
|
+
extraFloor = art.ok
|
|
728
|
+
? experienceClassFloor(art.value, policy.projections, policy.declaredToolClasses, policy.undeclared)
|
|
729
|
+
: "secret";
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return {
|
|
733
|
+
ok: true,
|
|
734
|
+
value: explainNode(exp.value, nodeId, {
|
|
735
|
+
...viewOpts,
|
|
736
|
+
notes,
|
|
737
|
+
...(extraFloor !== undefined && { extraFloor }),
|
|
738
|
+
}),
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* The line state and record of version `hash`, on this line or on a line it was branched from
|
|
743
|
+
* (breadth-first through `branched` versions, at most `MAX_VERIFY_DEPTH` lines, each visited once).
|
|
744
|
+
*/
|
|
745
|
+
async findVersion(state, hash) {
|
|
746
|
+
const visited = new Set([state.experienceId]);
|
|
747
|
+
const queue = [state];
|
|
748
|
+
for (let i = 0; i < queue.length; i++) {
|
|
749
|
+
const st = queue[i];
|
|
750
|
+
const record = st.versions.get(hash);
|
|
751
|
+
if (record !== undefined)
|
|
752
|
+
return { state: st, record };
|
|
753
|
+
for (const v of st.versions.values()) {
|
|
754
|
+
const from = v.kind === "branched" ? v.from : undefined;
|
|
755
|
+
if (from === undefined || visited.has(from.experienceId))
|
|
756
|
+
continue;
|
|
757
|
+
if (visited.size >= MAX_VERIFY_DEPTH)
|
|
758
|
+
return undefined;
|
|
759
|
+
visited.add(from.experienceId);
|
|
760
|
+
const src = await this.state(from.experienceId);
|
|
761
|
+
if (src.ok)
|
|
762
|
+
queue.push(src.value);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return undefined;
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Several versions' artifacts from ONE fold of the line (`get` refolds the whole log per call).
|
|
769
|
+
* A version that is unknown or cannot be read maps to `undefined`.
|
|
770
|
+
*/
|
|
771
|
+
async getMany(experienceId, versionHashes) {
|
|
772
|
+
const s = await this.state(experienceId);
|
|
773
|
+
if (!s.ok)
|
|
774
|
+
return s;
|
|
775
|
+
const out = new Map();
|
|
776
|
+
for (const h of new Set(versionHashes)) {
|
|
777
|
+
const r = await this.load(s.value, h);
|
|
778
|
+
if (!r.ok && isBlockedRefusal(r))
|
|
779
|
+
return r;
|
|
780
|
+
out.set(h, r.ok ? r.value : undefined);
|
|
781
|
+
}
|
|
782
|
+
return { ok: true, value: out };
|
|
783
|
+
}
|
|
784
|
+
// ── writes ──────────────────────────────────────────────────────────────────
|
|
785
|
+
/** Begin a line: the genesis artifact becomes its first version. Refused when the line exists. */
|
|
786
|
+
async start(genesis, by) {
|
|
787
|
+
if (genesis.parents.length > 0)
|
|
788
|
+
return refused("genesis-has-parents", "a lineage starts from a parentless artifact");
|
|
789
|
+
return this.advance({
|
|
790
|
+
experienceId: genesis.experienceId,
|
|
791
|
+
expectedHead: null,
|
|
792
|
+
artifact: genesis,
|
|
793
|
+
ops: [],
|
|
794
|
+
by,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Advance the head to `artifact`, which `ops` produce from the version `expectedHead`.
|
|
799
|
+
*
|
|
800
|
+
* REFUSES, writing nothing, when: `expectedHead` is not the head (`head-moved`, carrying the real
|
|
801
|
+
* one — the caller re-reads and re-authors; nothing is ever silently overwritten); the artifact
|
|
802
|
+
* does not name `expectedHead` as its only parent; or replaying `ops` from the parent does not
|
|
803
|
+
* reproduce the artifact's content address (`ops-artifact-mismatch`), including an op the host
|
|
804
|
+
* will not allow or one declared against a different base. `ops` is empty only for the first
|
|
805
|
+
* version of a line. The artifact may arrive unsealed.
|
|
806
|
+
*/
|
|
807
|
+
async advance(input) {
|
|
808
|
+
const { experienceId, expectedHead, artifact, ops, by } = input;
|
|
809
|
+
const badBy = provenanceRefusal(by, ops);
|
|
810
|
+
if (badBy !== undefined)
|
|
811
|
+
return badBy;
|
|
812
|
+
const summary = boundSummary(input.summary);
|
|
813
|
+
if (input.summaryClass !== undefined && !Sensitivity.safeParse(input.summaryClass).success)
|
|
814
|
+
return refused("summary-class-invalid", "summaryClass is not a known data class");
|
|
815
|
+
const applyOpts = { allowDeclassify: input.allowDeclassify === true };
|
|
816
|
+
return this.commit(experienceId, expectedHead, async (_state, parent) => {
|
|
817
|
+
if (artifact.experienceId !== experienceId)
|
|
818
|
+
return refused("artifact-wrong-line", `the artifact is "${artifact.experienceId}", not "${experienceId}"`);
|
|
819
|
+
const want = expectedHead === null ? [] : [expectedHead];
|
|
820
|
+
if (!sameSet(artifact.parents, want))
|
|
821
|
+
return refused("artifact-parent-mismatch", `the artifact's parents ${JSON.stringify(artifact.parents)} are not ${JSON.stringify(want)}`);
|
|
822
|
+
let sealed;
|
|
823
|
+
try {
|
|
824
|
+
sealed = seal(artifact);
|
|
825
|
+
}
|
|
826
|
+
catch (err) {
|
|
827
|
+
return refused("artifact-unhashable", `the artifact cannot be hashed: ${msg(err)}`);
|
|
828
|
+
}
|
|
829
|
+
if (artifact.versionHash !== "" && artifact.versionHash !== sealed.versionHash)
|
|
830
|
+
return refused("artifact-tampered", `versionHash ${artifact.versionHash} does not match the artifact (${sealed.versionHash})`);
|
|
831
|
+
const check = parent === null ? checkGenesis(ops) : checkOps(parent, ops, sealed.versionHash, applyOpts);
|
|
832
|
+
if (check !== undefined)
|
|
833
|
+
return fromFinding(check);
|
|
834
|
+
// `meta` is not part of the address and the caller's claim about it is unverified, so the
|
|
835
|
+
// stored artifact's authorship is derived from the recorded provenance and the clock (as a
|
|
836
|
+
// revert's is), never taken from the argument.
|
|
837
|
+
const stored = {
|
|
838
|
+
...sealed,
|
|
839
|
+
meta: { author: authorOf(by), createdAt: this.now(), sourceKind: "compose" },
|
|
840
|
+
};
|
|
841
|
+
return {
|
|
842
|
+
ok: true,
|
|
843
|
+
value: {
|
|
844
|
+
artifact: stored,
|
|
845
|
+
type: EXPERIENCE_COMPOSED,
|
|
846
|
+
payload: (ref) => ({
|
|
847
|
+
experienceId,
|
|
848
|
+
versionHash: sealed.versionHash,
|
|
849
|
+
ref,
|
|
850
|
+
parents: sealed.parents,
|
|
851
|
+
ops,
|
|
852
|
+
by,
|
|
853
|
+
...(summary !== undefined && { summary }),
|
|
854
|
+
...(input.summaryClass !== undefined && { summaryClass: input.summaryClass }),
|
|
855
|
+
}),
|
|
856
|
+
},
|
|
857
|
+
};
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* The concurrent-author path: fold `ops` onto the head yourself. The first op is applied with
|
|
862
|
+
* `applyAt`, so one that declares a `baseVersion` other than the head is refused
|
|
863
|
+
* (`stale-base-version`) instead of landing on a surface its author never saw; then the result is
|
|
864
|
+
* committed through {@link advance}'s verification, so what is recorded is exactly what replays.
|
|
865
|
+
*/
|
|
866
|
+
async compose(input) {
|
|
867
|
+
const { experienceId, expectedHead, ops, by } = input;
|
|
868
|
+
const badBy = provenanceRefusal(by, ops);
|
|
869
|
+
if (badBy !== undefined)
|
|
870
|
+
return badBy;
|
|
871
|
+
const applyOpts = { allowDeclassify: input.allowDeclassify === true };
|
|
872
|
+
const s = await this.state(experienceId);
|
|
873
|
+
if (!s.ok)
|
|
874
|
+
return s;
|
|
875
|
+
if (s.value.head !== expectedHead)
|
|
876
|
+
return { ok: false, refusal: { kind: "head-moved", expectedHead, actualHead: s.value.head } };
|
|
877
|
+
const parent = await this.load(s.value, expectedHead);
|
|
878
|
+
if (!parent.ok)
|
|
879
|
+
return parent;
|
|
880
|
+
if (ops.length === 0)
|
|
881
|
+
return refused("empty-ops", "a composition needs at least one op");
|
|
882
|
+
const first = ops[0];
|
|
883
|
+
let cur = applyAt(parent.value, first.op, {
|
|
884
|
+
...applyOpts,
|
|
885
|
+
...(first.baseVersion !== undefined && { baseVersion: first.baseVersion }),
|
|
886
|
+
});
|
|
887
|
+
for (let i = 1; cur.ok && i < ops.length; i++)
|
|
888
|
+
cur = apply(cur.value, ops[i].op, applyOpts);
|
|
889
|
+
if (!cur.ok)
|
|
890
|
+
return fromFinding(cur.finding);
|
|
891
|
+
return this.advance({
|
|
892
|
+
experienceId,
|
|
893
|
+
expectedHead,
|
|
894
|
+
artifact: cur.value,
|
|
895
|
+
ops,
|
|
896
|
+
by,
|
|
897
|
+
allowDeclassify: input.allowDeclassify === true,
|
|
898
|
+
...(input.summary !== undefined && { summary: input.summary }),
|
|
899
|
+
...(input.summaryClass !== undefined && { summaryClass: input.summaryClass }),
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Restore the content of version `to` as a NEW version on top of `expectedHead` — history is
|
|
904
|
+
* never rewritten, and the revert itself can be reverted. Sticky classes the line learned since
|
|
905
|
+
* `to` are carried forward (never lowered; see derive.ts). `to` may be any version of the line
|
|
906
|
+
* except the head (which would restore nothing).
|
|
907
|
+
*/
|
|
908
|
+
async revert(input) {
|
|
909
|
+
const { experienceId, expectedHead, to, by } = input;
|
|
910
|
+
const badBy = provenanceRefusal(by);
|
|
911
|
+
if (badBy !== undefined)
|
|
912
|
+
return badBy;
|
|
913
|
+
return this.commit(experienceId, expectedHead, async (state, head) => {
|
|
914
|
+
if (head === null)
|
|
915
|
+
return refused("lineage-empty", `"${experienceId}" has no versions`);
|
|
916
|
+
if (to === expectedHead)
|
|
917
|
+
return refused("revert-to-head", "the head is already that version");
|
|
918
|
+
const target = await this.load(state, to);
|
|
919
|
+
if (!target.ok)
|
|
920
|
+
return target;
|
|
921
|
+
const sealed = sealed_(revertArtifact(head, target.value, { by, at: this.now() }));
|
|
922
|
+
if (!sealed.ok)
|
|
923
|
+
return sealed;
|
|
924
|
+
return {
|
|
925
|
+
ok: true,
|
|
926
|
+
value: {
|
|
927
|
+
artifact: sealed.value,
|
|
928
|
+
type: EXPERIENCE_REVERTED,
|
|
929
|
+
payload: (ref) => ({
|
|
930
|
+
experienceId,
|
|
931
|
+
versionHash: sealed.value.versionHash,
|
|
932
|
+
ref,
|
|
933
|
+
parents: sealed.value.parents,
|
|
934
|
+
revertedTo: to,
|
|
935
|
+
by,
|
|
936
|
+
}),
|
|
937
|
+
},
|
|
938
|
+
};
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
/** Undo: revert to the head's parent, as a new version. Redo is `revert` to the hash you left. */
|
|
942
|
+
async undo(input) {
|
|
943
|
+
const s = await this.state(input.experienceId);
|
|
944
|
+
if (!s.ok)
|
|
945
|
+
return s;
|
|
946
|
+
const headRec = s.value.head === null ? undefined : s.value.versions.get(s.value.head);
|
|
947
|
+
if (s.value.head !== input.expectedHead)
|
|
948
|
+
return {
|
|
949
|
+
ok: false,
|
|
950
|
+
refusal: { kind: "head-moved", expectedHead: input.expectedHead, actualHead: s.value.head },
|
|
951
|
+
};
|
|
952
|
+
const parent = headRec?.parents[0];
|
|
953
|
+
if (headRec === undefined || parent === undefined || !s.value.versions.has(parent))
|
|
954
|
+
return refused("nothing-to-undo", "the head has no earlier version in this line");
|
|
955
|
+
return this.revert({ ...input, to: parent });
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Fork version `from` of one line into a NEW line `experienceId`. The new line's first version
|
|
959
|
+
* has `from` as its (cross-line) parent and carries the sticky classes of the source line's head
|
|
960
|
+
* at the fork, so branching an old version never launders a class the source learned later.
|
|
961
|
+
* Refused when the new line already exists.
|
|
962
|
+
*/
|
|
963
|
+
async branch(input) {
|
|
964
|
+
const { from, experienceId, by } = input;
|
|
965
|
+
const badBy = provenanceRefusal(by);
|
|
966
|
+
if (badBy !== undefined)
|
|
967
|
+
return badBy;
|
|
968
|
+
if (experienceId === from.experienceId)
|
|
969
|
+
return refused("branch-same-line", "a branch is a NEW line; use revert within one");
|
|
970
|
+
const src = await this.state(from.experienceId);
|
|
971
|
+
if (!src.ok)
|
|
972
|
+
return src;
|
|
973
|
+
if (src.value.head === null)
|
|
974
|
+
return refused("lineage-empty", `"${from.experienceId}" has no versions`);
|
|
975
|
+
const carriedFrom = src.value.head;
|
|
976
|
+
const [source, carried] = await Promise.all([
|
|
977
|
+
this.load(src.value, from.versionHash),
|
|
978
|
+
this.load(src.value, carriedFrom),
|
|
979
|
+
]);
|
|
980
|
+
if (!source.ok)
|
|
981
|
+
return source;
|
|
982
|
+
if (!carried.ok)
|
|
983
|
+
return carried;
|
|
984
|
+
// The fork records its source by hash with no `put` of that artifact: touch the source (and the
|
|
985
|
+
// head whose classes it carries) BEFORE the append, so a concurrent sweep spares them.
|
|
986
|
+
const cited = await this.cite(from.experienceId, [from.versionHash, carriedFrom]);
|
|
987
|
+
if (!cited.ok)
|
|
988
|
+
return cited;
|
|
989
|
+
const committed = await this.commit(experienceId, null, async () => {
|
|
990
|
+
const sealed = sealed_(branchArtifact(source.value, carried.value, experienceId, { by, at: this.now() }));
|
|
991
|
+
if (!sealed.ok)
|
|
992
|
+
return sealed;
|
|
993
|
+
return {
|
|
994
|
+
ok: true,
|
|
995
|
+
value: {
|
|
996
|
+
artifact: sealed.value,
|
|
997
|
+
type: EXPERIENCE_BRANCHED,
|
|
998
|
+
payload: (ref) => ({
|
|
999
|
+
experienceId,
|
|
1000
|
+
versionHash: sealed.value.versionHash,
|
|
1001
|
+
ref,
|
|
1002
|
+
parents: sealed.value.parents,
|
|
1003
|
+
from,
|
|
1004
|
+
carriedFrom,
|
|
1005
|
+
by,
|
|
1006
|
+
}),
|
|
1007
|
+
},
|
|
1008
|
+
};
|
|
1009
|
+
});
|
|
1010
|
+
if (committed.ok)
|
|
1011
|
+
await this.cite(from.experienceId, [from.versionHash, carriedFrom]).catch(() => undefined);
|
|
1012
|
+
return committed;
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Mark version `versionHash` as the one the app SHOWS (an agent's ops yield a new version; showing
|
|
1016
|
+
* it is this separate act). Compare-and-set on the promoted pointer: `expectedPromoted` is what
|
|
1017
|
+
* the caller saw (null = nothing promoted yet).
|
|
1018
|
+
*/
|
|
1019
|
+
async promote(input) {
|
|
1020
|
+
const { experienceId, versionHash, expectedPromoted, by } = input;
|
|
1021
|
+
const badBy = provenanceRefusal(by);
|
|
1022
|
+
if (badBy !== undefined)
|
|
1023
|
+
return badBy;
|
|
1024
|
+
for (let attempt = 0; attempt < MAX_TIP_RETRIES; attempt++) {
|
|
1025
|
+
const s = await this.state(experienceId);
|
|
1026
|
+
if (!s.ok)
|
|
1027
|
+
return s;
|
|
1028
|
+
if (s.value.promoted !== expectedPromoted)
|
|
1029
|
+
return {
|
|
1030
|
+
ok: false,
|
|
1031
|
+
refusal: { kind: "promoted-moved", expectedPromoted, actualPromoted: s.value.promoted },
|
|
1032
|
+
};
|
|
1033
|
+
const art = await this.load(s.value, versionHash);
|
|
1034
|
+
if (!art.ok)
|
|
1035
|
+
return art;
|
|
1036
|
+
const v = s.value.versions.get(versionHash);
|
|
1037
|
+
const promotedPayload = { ref: v.ref, experienceId, versionHash, by };
|
|
1038
|
+
const badPayload = payloadRefusal(EXPERIENCE_PROMOTED, promotedPayload);
|
|
1039
|
+
if (badPayload !== undefined)
|
|
1040
|
+
return refused(badPayload.code, badPayload.message);
|
|
1041
|
+
// The promotion cites an artifact that already exists, with no `put`: touch it BEFORE the
|
|
1042
|
+
// append so a sweep that marked earlier cannot delete it, and AFTER to extend the window.
|
|
1043
|
+
const cited = await this.cite(experienceId, [versionHash]);
|
|
1044
|
+
if (!cited.ok)
|
|
1045
|
+
return cited;
|
|
1046
|
+
let out;
|
|
1047
|
+
try {
|
|
1048
|
+
out = await this.events.append(experienceId, s.value.sequence, {
|
|
1049
|
+
type: EXPERIENCE_PROMOTED,
|
|
1050
|
+
at: this.now(),
|
|
1051
|
+
payload: promotedPayload,
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
catch (err) {
|
|
1055
|
+
return this.failedWrite(experienceId, err);
|
|
1056
|
+
}
|
|
1057
|
+
if (out.ok) {
|
|
1058
|
+
await this.cite(experienceId, [versionHash]).catch(() => undefined); // best effort: the write is durable
|
|
1059
|
+
return { ok: true, value: { promoted: versionHash, sequence: out.sequence } };
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
return refused("lineage-contended", `could not append to "${experienceId}" after ${MAX_TIP_RETRIES} attempts`);
|
|
1063
|
+
}
|
|
1064
|
+
// ── verification ────────────────────────────────────────────────────────────
|
|
1065
|
+
/**
|
|
1066
|
+
* Rebuild a whole line from its log and compare it to what is stored — the consumer of
|
|
1067
|
+
* `EXPERIENCE_COMPOSED`. Starting from the genesis artifact (attested by its address alone: there
|
|
1068
|
+
* is nothing before it to replay from), each version is REBUILT, never read back from its blob:
|
|
1069
|
+
* a composed version by replaying its ops on the rebuilt parent, a revert or branch by recomputing
|
|
1070
|
+
* it from the rebuilt artifacts it names. The rebuilt content address must equal the logged one
|
|
1071
|
+
* and the stored blob must hash to it. Returns every finding; `[]` = the line is consistent.
|
|
1072
|
+
*
|
|
1073
|
+
* A branch is only as sound as its source: `verify` also verifies the source line up to the
|
|
1074
|
+
* version the fork carried classes from (`carriedFrom`), recursively (a visited set and a depth
|
|
1075
|
+
* bound stop a forged cycle), and reports what it finds on the branch, prefixed with the source
|
|
1076
|
+
* line; `carriedFrom` must be the forked version or a descendant of it on the source line. A
|
|
1077
|
+
* version whose parent could not be rebuilt is reported, not skipped silently. Ops replay with
|
|
1078
|
+
* `declassify*` ALLOWED: this checks that addresses reproduce, and the write path is the gate.
|
|
1079
|
+
*/
|
|
1080
|
+
async verify(experienceId) {
|
|
1081
|
+
return this.verifyLine(experienceId, null, new Set(), 0);
|
|
1082
|
+
}
|
|
1083
|
+
/** Verify `experienceId` from its start through version `upTo` (null = the whole line). */
|
|
1084
|
+
async verifyLine(experienceId, upTo, visited, depth) {
|
|
1085
|
+
const key = `${experienceId}@${upTo ?? ""}`;
|
|
1086
|
+
if (visited.has(key))
|
|
1087
|
+
return [];
|
|
1088
|
+
visited.add(key);
|
|
1089
|
+
const s = await this.state(experienceId);
|
|
1090
|
+
if (!s.ok)
|
|
1091
|
+
return [s.refusal.kind === "refused" ? s.refusal.finding : unreachable()];
|
|
1092
|
+
const findings = [];
|
|
1093
|
+
const rebuilt = new Map();
|
|
1094
|
+
const err = (code, message) => findings.push({ code, severity: "error", message });
|
|
1095
|
+
let order = s.value.order;
|
|
1096
|
+
if (upTo !== null) {
|
|
1097
|
+
const at = order.indexOf(upTo);
|
|
1098
|
+
if (at < 0)
|
|
1099
|
+
return [
|
|
1100
|
+
{
|
|
1101
|
+
code: "version-unknown",
|
|
1102
|
+
severity: "error",
|
|
1103
|
+
message: `"${upTo}" is not a version of "${experienceId}"`,
|
|
1104
|
+
},
|
|
1105
|
+
];
|
|
1106
|
+
order = order.slice(0, at + 1);
|
|
1107
|
+
}
|
|
1108
|
+
for (const hash of order) {
|
|
1109
|
+
const v = s.value.versions.get(hash);
|
|
1110
|
+
const stored = await this.load(s.value, hash);
|
|
1111
|
+
if (!stored.ok) {
|
|
1112
|
+
findings.push(stored.refusal.kind === "refused" ? stored.refusal.finding : unreachable());
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
if (!sameSet(stored.value.parents, v.parents))
|
|
1116
|
+
err("artifact-parent-mismatch", `version ${hash}: the stored artifact's parents differ from the log's`);
|
|
1117
|
+
let derived;
|
|
1118
|
+
if (v.kind === "composed") {
|
|
1119
|
+
const parent = v.parents[0] === undefined ? undefined : rebuilt.get(v.parents[0]);
|
|
1120
|
+
if (v.parents.length === 0) {
|
|
1121
|
+
if (v.ops.length > 0)
|
|
1122
|
+
err("ops-on-genesis", `version ${hash}: a genesis records ${v.ops.length} ops`);
|
|
1123
|
+
derived = stored.value;
|
|
1124
|
+
}
|
|
1125
|
+
else if (parent === undefined) {
|
|
1126
|
+
err("lineage-parent-unverified", `version ${hash}: its parent could not be rebuilt`);
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
else {
|
|
1130
|
+
// verify replays with declassify allowed (it checks addresses; the write path is the gate),
|
|
1131
|
+
// so a declassify that reached the log is SURFACED (warn) — a raw append bypasses that gate.
|
|
1132
|
+
if (recordsDeclassify(v.ops))
|
|
1133
|
+
findings.push({
|
|
1134
|
+
code: "declassify-op-present",
|
|
1135
|
+
severity: "warn",
|
|
1136
|
+
message: `version ${hash}: records a declassify op — confirm it was authorized when written`,
|
|
1137
|
+
});
|
|
1138
|
+
const f = checkOps(parent, v.ops, hash, VERIFY_APPLY);
|
|
1139
|
+
if (f !== undefined)
|
|
1140
|
+
err(f.code, `version ${hash}: ${f.message}`);
|
|
1141
|
+
derived = replayed(parent, v.ops, VERIFY_APPLY);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
else if (v.kind === "reverted") {
|
|
1145
|
+
const head = v.parents[0] === undefined ? undefined : rebuilt.get(v.parents[0]);
|
|
1146
|
+
const target = v.revertedTo === undefined ? undefined : rebuilt.get(v.revertedTo);
|
|
1147
|
+
if (head === undefined || target === undefined) {
|
|
1148
|
+
err("lineage-parent-unverified", `version ${hash}: the revert's head or target could not be rebuilt`);
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
const d = sealed_(revertArtifact(head, target, { by: v.by, at: v.at }));
|
|
1152
|
+
if (!d.ok) {
|
|
1153
|
+
err("artifact-unhashable", `version ${hash}: cannot recompute the revert`);
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1156
|
+
derived = d.value;
|
|
1157
|
+
if (derived.versionHash !== hash)
|
|
1158
|
+
err("revert-artifact-mismatch", `version ${hash}: recomputing the revert gives ${derived.versionHash}`);
|
|
1159
|
+
}
|
|
1160
|
+
else {
|
|
1161
|
+
const srcState = v.from === undefined ? undefined : await this.state(v.from.experienceId);
|
|
1162
|
+
// The source line's erasure is CONFIRMED (the host's hook), either because it refuses
|
|
1163
|
+
// `line-erased` or because its events were deleted too (the version is simply absent) and the
|
|
1164
|
+
// hook still confirms: the fork cannot be recomputed. Like a genesis it is attested by its
|
|
1165
|
+
// address alone (the stored blob hashed to `hash` when it was loaded above), and the gap is
|
|
1166
|
+
// surfaced as a warning. An UNCONFIRMED unreadable source is an error (below).
|
|
1167
|
+
const srcErased = v.from !== undefined &&
|
|
1168
|
+
srcState !== undefined &&
|
|
1169
|
+
(srcState.ok
|
|
1170
|
+
? !srcState.value.versions.has(v.from.versionHash) &&
|
|
1171
|
+
(await this.confirms(v.from.experienceId))
|
|
1172
|
+
: isErasedRefusal(srcState));
|
|
1173
|
+
if (srcErased) {
|
|
1174
|
+
findings.push({
|
|
1175
|
+
code: "branch-source-erased",
|
|
1176
|
+
severity: "warn",
|
|
1177
|
+
message: `version ${hash}: the source line "${v.from?.experienceId}" is erased, so this branch is attested by its content address only`,
|
|
1178
|
+
});
|
|
1179
|
+
rebuilt.set(hash, stored.value);
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
if (srcState !== undefined && !srcState.ok && isBlockedRefusal(srcState)) {
|
|
1183
|
+
const f = srcState.refusal.kind === "refused" ? srcState.refusal.finding : unreachable();
|
|
1184
|
+
err(f.code, `version ${hash}: source line "${v.from?.experienceId}" cannot be read and its erasure is not confirmed: ${f.message}`);
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
if (v.from === undefined ||
|
|
1188
|
+
v.carriedFrom === undefined ||
|
|
1189
|
+
srcState === undefined ||
|
|
1190
|
+
!srcState.ok) {
|
|
1191
|
+
err("lineage-parent-unverified", `version ${hash}: the branch's source line could not be read`);
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
if (depth >= MAX_VERIFY_DEPTH) {
|
|
1195
|
+
err("lineage-verify-depth", `version ${hash}: branches nest over ${MAX_VERIFY_DEPTH} lines deep`);
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
if (!descendsFrom(srcState.value, v.carriedFrom, v.from.versionHash)) {
|
|
1199
|
+
err("branch-carried-from-invalid", `version ${hash}: carriedFrom ${v.carriedFrom} is neither ${v.from.versionHash} nor a descendant of it on "${v.from.experienceId}"`);
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
for (const f of await this.verifyLine(v.from.experienceId, v.carriedFrom, visited, depth + 1))
|
|
1203
|
+
err(f.code, `version ${hash}: source line "${v.from.experienceId}": ${f.message}`);
|
|
1204
|
+
const [src, carried] = await Promise.all([
|
|
1205
|
+
this.load(srcState.value, v.from.versionHash),
|
|
1206
|
+
this.load(srcState.value, v.carriedFrom),
|
|
1207
|
+
]);
|
|
1208
|
+
if (!src.ok || !carried.ok) {
|
|
1209
|
+
err("lineage-parent-unverified", `version ${hash}: the branch's source versions could not be loaded`);
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
const d = sealed_(branchArtifact(src.value, carried.value, experienceId, { by: v.by, at: v.at }));
|
|
1213
|
+
if (!d.ok) {
|
|
1214
|
+
err("artifact-unhashable", `version ${hash}: cannot recompute the branch`);
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
derived = d.value;
|
|
1218
|
+
if (derived.versionHash !== hash)
|
|
1219
|
+
err("branch-artifact-mismatch", `version ${hash}: recomputing the branch gives ${derived.versionHash}`);
|
|
1220
|
+
}
|
|
1221
|
+
if (derived !== undefined && derived.versionHash === hash)
|
|
1222
|
+
rebuilt.set(hash, derived);
|
|
1223
|
+
else if (derived === undefined)
|
|
1224
|
+
err("lineage-parent-unverified", `version ${hash}: could not be rebuilt`);
|
|
1225
|
+
}
|
|
1226
|
+
return findings;
|
|
1227
|
+
}
|
|
1228
|
+
// ── internals ───────────────────────────────────────────────────────────────
|
|
1229
|
+
/**
|
|
1230
|
+
* The one write path: check `expectedHead` against the head, let `prepare` verify and build the
|
|
1231
|
+
* version, store its bytes, then append at the log's tip. A lost append re-reads: if the head is
|
|
1232
|
+
* still `expectedHead` (only a promotion slipped in) it retries at the new tip; otherwise it is
|
|
1233
|
+
* `head-moved`. The blob is written BEFORE the event and is content-addressed, so a lost race
|
|
1234
|
+
* leaves an unreferenced, harmless blob — never a log entry without bytes.
|
|
1235
|
+
*/
|
|
1236
|
+
async commit(experienceId, expectedHead, prepare) {
|
|
1237
|
+
let s = await this.state(experienceId);
|
|
1238
|
+
if (!s.ok)
|
|
1239
|
+
return s;
|
|
1240
|
+
const moved = (actual) => ({
|
|
1241
|
+
ok: false,
|
|
1242
|
+
refusal: { kind: "head-moved", expectedHead, actualHead: actual },
|
|
1243
|
+
});
|
|
1244
|
+
if (s.value.head !== expectedHead)
|
|
1245
|
+
return moved(s.value.head);
|
|
1246
|
+
let head = null;
|
|
1247
|
+
if (expectedHead !== null) {
|
|
1248
|
+
const loaded = await this.load(s.value, expectedHead);
|
|
1249
|
+
if (!loaded.ok)
|
|
1250
|
+
return loaded;
|
|
1251
|
+
head = loaded.value;
|
|
1252
|
+
}
|
|
1253
|
+
const prepared = await prepare(s.value, head);
|
|
1254
|
+
if (!prepared.ok)
|
|
1255
|
+
return prepared;
|
|
1256
|
+
const { artifact, type, payload } = prepared.value;
|
|
1257
|
+
// The write-side twin of the fold: an event the fold would refuse must never reach the log.
|
|
1258
|
+
const badPayload = payloadRefusal(type, payload({ __loomContent: "sha256:pending" }));
|
|
1259
|
+
if (badPayload !== undefined)
|
|
1260
|
+
return refused(badPayload.code, badPayload.message);
|
|
1261
|
+
// The blob goes in under THIS line's access (never the source line's, for a branch), so erasing
|
|
1262
|
+
// the owner of this line makes it unreadable, and erasing a source line does not touch it.
|
|
1263
|
+
let put;
|
|
1264
|
+
try {
|
|
1265
|
+
put = await putExperience(this.content, artifact, this.accessFor(experienceId));
|
|
1266
|
+
}
|
|
1267
|
+
catch (err) {
|
|
1268
|
+
return this.failedWrite(experienceId, err);
|
|
1269
|
+
}
|
|
1270
|
+
if (!put.ok)
|
|
1271
|
+
return fromFinding(put.finding);
|
|
1272
|
+
for (let attempt = 0; attempt < MAX_TIP_RETRIES; attempt++) {
|
|
1273
|
+
let out;
|
|
1274
|
+
try {
|
|
1275
|
+
out = await this.events.append(experienceId, s.value.sequence, {
|
|
1276
|
+
type,
|
|
1277
|
+
at: this.now(),
|
|
1278
|
+
payload: payload(put.ref),
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
catch (err) {
|
|
1282
|
+
return this.failedWrite(experienceId, err);
|
|
1283
|
+
}
|
|
1284
|
+
if (out.ok) {
|
|
1285
|
+
const after = await this.state(experienceId);
|
|
1286
|
+
if (!after.ok)
|
|
1287
|
+
return after;
|
|
1288
|
+
return {
|
|
1289
|
+
ok: true,
|
|
1290
|
+
value: {
|
|
1291
|
+
experience: put.experience,
|
|
1292
|
+
version: after.value.versions.get(put.experience.versionHash),
|
|
1293
|
+
},
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
s = await this.state(experienceId);
|
|
1297
|
+
if (!s.ok)
|
|
1298
|
+
return s;
|
|
1299
|
+
if (s.value.head !== expectedHead)
|
|
1300
|
+
return moved(s.value.head);
|
|
1301
|
+
}
|
|
1302
|
+
return refused("lineage-contended", `could not append to "${experienceId}" after ${MAX_TIP_RETRIES} attempts`);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
/** Why replaying `ops` on `parent` does NOT yield `expectedHash`, or undefined when it does. */
|
|
1306
|
+
function checkOps(parent, ops, expectedHash, applyOpts) {
|
|
1307
|
+
const f = (code, message) => ({ code, severity: "error", message });
|
|
1308
|
+
if (ops.length === 0)
|
|
1309
|
+
return f("empty-ops", "a version after the first records at least one op");
|
|
1310
|
+
for (const [i, r] of ops.entries())
|
|
1311
|
+
if (r.baseVersion !== undefined && r.baseVersion !== parent.versionHash)
|
|
1312
|
+
return f("stale-base-version", `op #${i} (${r.op.type}) was authored against ${r.baseVersion} but its parent is ${parent.versionHash}`);
|
|
1313
|
+
const out = replay(parent, ops.map((r) => r.op), applyOpts);
|
|
1314
|
+
if (!out.ok)
|
|
1315
|
+
return f("ops-refused", out.finding.message);
|
|
1316
|
+
const sealed = sealed_(out.value);
|
|
1317
|
+
if (!sealed.ok)
|
|
1318
|
+
return sealed.refusal.kind === "refused" ? sealed.refusal.finding : undefined;
|
|
1319
|
+
if (sealed.value.versionHash !== expectedHash)
|
|
1320
|
+
return f("ops-artifact-mismatch", `replaying the recorded ops from ${parent.versionHash} gives ${sealed.value.versionHash}, not ${expectedHash}: the ops do not produce this artifact`);
|
|
1321
|
+
return undefined;
|
|
1322
|
+
}
|
|
1323
|
+
function replayed(parent, ops, applyOpts) {
|
|
1324
|
+
const out = replay(parent, ops.map((r) => r.op), applyOpts);
|
|
1325
|
+
if (!out.ok)
|
|
1326
|
+
return undefined;
|
|
1327
|
+
const sealed = sealed_(out.value);
|
|
1328
|
+
return sealed.ok ? sealed.value : undefined;
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* `verify` checks ADDRESSES, not authority: the write path (`advance`/`compose`) is where a
|
|
1332
|
+
* `declassify*` op is gated, so a version that is already in the log replays with declassify
|
|
1333
|
+
* allowed. Refusing it here would report a legitimately host-authorized version as `ops-refused`.
|
|
1334
|
+
*/
|
|
1335
|
+
const VERIFY_APPLY = { allowDeclassify: true, legacyBind: true };
|
|
1336
|
+
/** Does any recorded op (or a prompt's materialized ops, nested) declassify? Iterative, bounded. */
|
|
1337
|
+
function recordsDeclassify(ops) {
|
|
1338
|
+
const stack = ops.map((r) => r.op);
|
|
1339
|
+
for (let seen = 0; stack.length > 0 && seen < 100_000; seen++) {
|
|
1340
|
+
const op = stack.pop();
|
|
1341
|
+
if (op === null || typeof op !== "object")
|
|
1342
|
+
continue;
|
|
1343
|
+
if (typeof op.type === "string" && op.type.startsWith("declassify"))
|
|
1344
|
+
return true;
|
|
1345
|
+
if (Array.isArray(op.materialized))
|
|
1346
|
+
stack.push(...op.materialized);
|
|
1347
|
+
}
|
|
1348
|
+
return false;
|
|
1349
|
+
}
|
|
1350
|
+
function checkGenesis(ops) {
|
|
1351
|
+
return ops.length === 0
|
|
1352
|
+
? undefined
|
|
1353
|
+
: {
|
|
1354
|
+
code: "ops-on-genesis",
|
|
1355
|
+
severity: "error",
|
|
1356
|
+
message: "the first version of a line has no parent to replay ops from; compose on top of it",
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
function sealed_(exp) {
|
|
1360
|
+
try {
|
|
1361
|
+
return { ok: true, value: seal(exp) };
|
|
1362
|
+
}
|
|
1363
|
+
catch (err) {
|
|
1364
|
+
return refused("artifact-unhashable", `the artifact cannot be hashed: ${msg(err)}`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
const msg = (err) => (err instanceof Error ? err.message : String(err));
|
|
1368
|
+
/** A `Refusal` that is not `refused` cannot come out of a read; keeps the narrowing total. */
|
|
1369
|
+
function unreachable() {
|
|
1370
|
+
return {
|
|
1371
|
+
code: "lineage-internal",
|
|
1372
|
+
severity: "error",
|
|
1373
|
+
message: "unexpected refusal kind on a read",
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
//# sourceMappingURL=lineage.js.map
|