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