@loomwork/ui-lineage 0.0.0-stage → 0.1.1

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/README.md CHANGED
@@ -1,3 +1,472 @@
1
- # Temporary Holding Version
1
+ # @loomwork/ui-lineage
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Experience **lineage runtime**: what remembers which artifact of an experience is current, who
4
+ won a race to change it, and how to go back. `@loomwork/ui-core` can fold ops onto an artifact and hash
5
+ it; it cannot say which artifact is the head, refuse a stale author, undo, or notice that a stored
6
+ `ops` list disagrees with the artifact it claims to produce (ops are not in the content address).
7
+ This package is that memory. It is **optional** (an app using only core keeps working) and
8
+ **server-side** (an event log plus a content store: never import it from a client bundle;
9
+ `hydration/clientEntry.test.ts` names it among the packages a client graph must not reach).
10
+
11
+ ## Install / import
12
+
13
+ ```jsonc
14
+ // package.json of a consumer checked out beside this repo (the packages are not published)
15
+ "@loomwork/ui-lineage": "link:../loom-ui/packages/lineage"
16
+ ```
17
+
18
+ Entry point: `@loomwork/ui-lineage` (server-side only; Node, CommonJS and ESM).
19
+
20
+ ## Usage
21
+
22
+ ```ts
23
+ import { Lineage, InMemoryLineageStore, eventStoreLineageStore } from "@loomwork/ui-lineage";
24
+
25
+ const lineage = new Lineage({
26
+ events: eventStoreLineageStore(loomEventStore), // or new InMemoryLineageStore()
27
+ content: loomContentStore, // the ContentStore putExperience already uses
28
+ });
29
+ const v1 = await lineage.start(genesis(...), by); // first version
30
+ const v2 = await lineage.compose({ experienceId, expectedHead, ops, by });
31
+ const r = await lineage.revert({ experienceId, expectedHead, to, by }); // a NEW version
32
+ const f = await lineage.branch({ from: { experienceId, versionHash }, experienceId: "fork", by });
33
+ await lineage.verify(experienceId); // Finding[]; [] = consistent
34
+ ```
35
+
36
+ **Bind the host's read policy once.** `new Lineage({ ..., readPolicy })` takes a core `ReadPolicy`
37
+ (`{ declaredToolClasses, projections, hydrationProjectionId, undeclared }`); `explain` applies it
38
+ with no per-call options (a per-call value may only ADD, never lower), and a `SeenLog` or
39
+ `LineageChangeFeed` built over the lineage inherits it (`lineage.readPolicy`). Build the table with
40
+ `declaredToolClassesFrom(capabilities)` from `@loomwork/ui-planner`, the same `CapabilityRef`s the
41
+ planner reads:
42
+
43
+ ```ts
44
+ const lineage = new Lineage({ events, content, readPolicy: {
45
+ declaredToolClasses: declaredToolClassesFrom(capabilities),
46
+ undeclared: "deny", // optional: a tool the table omits is unreadable until declared
47
+ } });
48
+ ```
49
+
50
+ ## Retention: which versions to keep (U17)
51
+
52
+ Every version stores its whole artifact, and a scanned lineage stream pins every one of them for
53
+ ever (loom's `sweepUnpinnedContent` treats a bare ref as load-bearing). `lineage.pins(policy,
54
+ extra?)` is the replacement root set: an async iterable of the content addresses a collector must
55
+ keep, to be passed as `roots` while the lineage's `LineageStore` is NOT passed as an `eventStore`.
56
+ `retainedVersions(state, policy, branchSources)` is the pure policy: the newest `keepLast` (default
57
+ 20; `Infinity` keeps all), the head, the promoted version, the genesis (`keepGenesis`), `keepSince`,
58
+ a host `keep(version)` predicate, and always every version another line branched from. The artifact
59
+ ref of every other version is left out, so that blob becomes reclaimable; every other ref a line's
60
+ events cite (anything an op carries) stays pinned, and so does every content ref found INSIDE a
61
+ retained artifact's bytes (the sweep does not read blobs). It refuses (throws) over a store that
62
+ cannot enumerate its lines, a line that does not fold, or a retained artifact it cannot read.
63
+ **Cost.** `pins` reads each distinct retained artifact once per call (deduplicated by content
64
+ address across lines and versions; artifacts embed their parents and line, so distinct versions
65
+ seldom share one and the dedupe is a safety net, the real saving is concurrency), with at most
66
+ `extra.concurrency` (default 8) reads of one line in flight; a read error other than "already gone"
67
+ throws before that line yields. Reads are O(retained versions), at `concurrency` per line; a lower
68
+ `keepLast` shrinks it directly. A malformed policy THROWS (NaN, negative or fractional `keepLast`; a
69
+ `keepSince` that is not an ISO-8601 date/time string, a valid `Date` or epoch ms): a deletion policy
70
+ never coerces.
71
+
72
+ **Build `roots` with `rootsFor`, always.** `rootsFor({ lineages: [{ lineage, policy }, ...],
73
+ seenLogs?, allowUnreadable?, extra? })` returns the ONE async iterable of addresses loom's
74
+ `sweepUnpinnedContent({ roots: () => rootsFor(...), completeScan: true })` needs: every lineage's
75
+ `pins(policy)`, every seen log's sealed value blobs, the versions each seen log's views still read
76
+ (kept by the lineage that log is built over, which must be listed), and your `extra` (app tables;
77
+ sync or async iterable), deduplicated. It validates every policy and the wiring up front, and fails
78
+ closed: any source throwing makes the iterator throw before it yields anything, so a sweep never
79
+ erases against a partial set. Passing a single lineage's `pins()` to a store several lineages share
80
+ erases the others' artifacts; do not hand-compose it.
81
+
82
+ ```ts
83
+ await sweepUnpinnedContent({
84
+ /* ... */
85
+ roots: () => rootsFor({ lineages: [{ lineage: a, policy }, { lineage: b }], seenLogs: [seen] }),
86
+ reclaimOrphans: { completeScan: true },
87
+ });
88
+ ```
89
+
90
+ **The root set must be complete.** With `reclaimOrphans: { completeScan: true }` loom erases every
91
+ blob in the store that no root names, so `roots` must come from EVERY user of the store: this
92
+ lineage's `pins`, every other lineage's `pins`, a `SeenLog` sharing the store, and loom's own
93
+ execution scan. For a seen log, `collectSeenRoots` (which `rootsFor` calls) adds its sealed value
94
+ blobs and the versions its live views still read (it throws if anything in the live window was
95
+ unreadable, rather than return an incomplete set). Which collector: with loom's sweep owning the
96
+ store, use `pins` + `collectSeenRoots`; with ONE collector owning the store,
97
+ `collectSeenGarbage(log, { lineageRetention: policy, ... })` does both in one pass. Never run the
98
+ two against the same store with different roots.
99
+
100
+ **Races.** `promote`, `branch` and `SeenLog.record` cite an artifact that already exists, with no
101
+ `put`, so each `touch`es it first (`ContentStore.touch`, SPI 1.4.0; loom ADR 0055 D6 amendment 2): a
102
+ sweep whose roots were computed a moment earlier then refuses to delete it, including a fork made
103
+ between `pins`' two passes. A store without `touch` is left alone, and loom's sweep refuses to erase
104
+ through one, so use a store that implements it.
105
+
106
+ Known limit: reclaiming a version leaves its event in the log, so `get` of it reports the missing
107
+ artifact and `verify` of a line with reclaimed versions reports them (and what derives from them),
108
+ because `verify` replays from the genesis. Reclaim only versions nobody needs to re-open. Verifying
109
+ from a retained checkpoint (a tombstone event so the fold knows a version was reclaimed on purpose)
110
+ is the follow-up; it is loom-ui code and needs no loom change.
111
+
112
+ ## Design
113
+
114
+ **Storage is two loom seams.** Artifact bytes go in a `ContentStore` (`putExperience`, unchanged).
115
+ Version and promotion events go in a `LineageStore`: an append-only, per-line, sequenced log whose
116
+ `append(line, expectedSequence, record)` is a compare-and-set on the tip. That is exactly what
117
+ loom's `EventStore` already guarantees (`(executionId, sequence)` uniqueness), so
118
+ `eventStoreLineageStore` adapts it (one reserved stream `__loom_ui_lineage__:<experienceId>` per
119
+ line; fencing, tenancy and sharding decorators wrap the store as usual). `InMemoryLineageStore` is
120
+ the reference, and `checkLineageStoreConformance(make)` is the kit any backend must pass (returns
121
+ findings, never throws).
122
+
123
+ **Head and compare-and-set.** A line's head is the latest version. Every write names the head it was
124
+ authored against (`expectedHead`; `null` = the line must be empty). A lost race is a *typed result*
125
+ (`{ kind: "head-moved", expectedHead, actualHead }`), never an overwrite or a throw; the caller
126
+ re-reads and re-authors. `compose` applies its first op with core's `applyAt`, so an op that
127
+ declares a `baseVersion` other than the head is refused (`stale-base-version`). A blob is written
128
+ before its event and is content-addressed, so a lost race leaves an unreferenced blob, never an
129
+ event without bytes.
130
+
131
+ **Events.** `EXPERIENCE_COMPOSED { ref, parents, ops }` (core's convention, now with a producer, a
132
+ consumer and a validator; plus `experienceId`, `versionHash`, `by`), `EXPERIENCE_REVERTED`,
133
+ `EXPERIENCE_BRANCHED`, and `EXPERIENCE_PROMOTED` (the version the app *shows*; its own CAS pointer,
134
+ `expectedPromoted`). `foldLineage` is pure and total and checks structure: dense sequences, one
135
+ version per address, every version's `parents` naming the head it was appended on.
136
+
137
+ **Verification closes the ops-vs-artifact gap.** `advance`/`compose` refuse (writing nothing) any
138
+ version whose recorded ops, replayed from the parent, do not reproduce the artifact's content
139
+ address (`ops-artifact-mismatch`), whose ops are refused by `apply` (a recorded `declassify*` needs
140
+ the host's per-call `allowDeclassify`), whose `baseVersion` is not the parent, or that records no
141
+ ops after the first version. `verify(experienceId)` is the consumer: it REBUILDS the whole line from
142
+ the log (never from the blobs: a composed version by replay on the rebuilt parent, a revert or
143
+ branch recomputed from the rebuilt artifacts it names) and compares addresses and stored blobs. The
144
+ genesis is attested by its address alone. A branch is only as sound as its source, so `verify` also
145
+ verifies the source line up to the version the fork carried classes from (recursively, bounded) and
146
+ reports its findings on the branch; `carriedFrom` must be the forked version or a descendant of it.
147
+ `verify` replays with `declassify*` allowed: it checks that addresses reproduce, and the write path
148
+ is the gate.
149
+
150
+ **Revert, undo, branch are new versions.** Nothing is rewritten. `revert` restores an older
151
+ artifact's content as a version whose parent is the head (and can itself be reverted: that is redo).
152
+ `undo` is a revert to the head's parent. `branch` forks a version into a NEW line (its first
153
+ version's parent is the source version, in another line). Revert and branch are deterministic
154
+ functions of two artifacts, which is what lets `verify` recompute them.
155
+
156
+ **Sticky classes are carried forward (fail-closed).** A revert or branch adopts an artifact older
157
+ than the line, and the line has learned more since. Both fold the classes of the artifact the line
158
+ had reached (the head, or the source head at the fork) into the adopted one with core's
159
+ `carryClasses`: `strictest`, never lower, and including classes only that artifact's *live* nodes
160
+ carried (they would vanish with the nodes). So a restored or forked version can be stricter than it
161
+ was, never laxer than the line knew; handle, tool and projection classes are all carried. A trusted
162
+ `declassify*` is an ordinary composed version; reverting past it re-raises the class (the safe
163
+ direction).
164
+
165
+ **Concurrent authors.** Two authors composing against the same head: one wins, the other gets
166
+ `head-moved` with the winner's hash. A promotion slipping between a writer's read and append retries
167
+ at the new tip (bounded) because the head did not move.
168
+
169
+ ## Trust and operations
170
+
171
+ **The log is untrusted input.** Anyone with append access to a stream wrote what `foldLineage`
172
+ reads, so the fold is total: a record that is null, throws when read, records more than
173
+ `MAX_OPS_PER_VERSION` ops, or nests an op's node (or `prompt` ops) past the caps is a
174
+ `lineage-event-invalid` finding, never a stack overflow or an exception out of every read of that
175
+ line.
176
+
177
+ **Lineage performs NO authorization.** `by` (on a version and on each recorded operation) is an
178
+ unverified caller claim; nothing here checks that the caller may act as that principal or touch that
179
+ line. `start` accepts ANY genesis, so a host that lets callers supply artifacts can have classes
180
+ laundered by copying an artifact with its sticky class maps stripped into a new line. The host must
181
+ gate who may `start`/`advance`/`branch`/`promote` and what artifacts it passes. `allowDeclassify` is
182
+ a per-call argument of `advance`/`compose` (there is no instance-level default): pass it only from
183
+ code that authored the ops itself. Stored `meta` is derived from `by` and the clock (as a revert's
184
+ is), not taken from the artifact argument.
185
+
186
+ **Branching.** `branch` reads the source line and then writes the new one (no lock across the two):
187
+ a source that advances in between is fine, because the fork records the head it carried classes from
188
+ (`carriedFrom`). Classes the source learns AFTER the fork do not reach the branch, which is a
189
+ different line from then on.
190
+
191
+ **Reserved stream namespace.** `eventStoreLineageStore` writes `__loom_ui_lineage__:<experienceId>`,
192
+ inside loom's `RESERVED_STREAM_PREFIX` (`__loom_`), so enumerators of real executions skip lineage
193
+ streams (a prefix outside it would be mistaken for an execution). Streams are never compacted, so a
194
+ long-lived line grows without bound.
195
+
196
+ **Erasure (crypto-shred).** A line holds personal data: op props and labels, the `summary`, `by`.
197
+ A reserved stream is not an execution, so no default maps it to a person; the host does, and then
198
+ erasing the owner makes the line unreadable:
199
+
200
+ 1. In the `subjectOf` you give loom's `EncryptingEventStore` (`@loomwork/plugin-crypto`), map the stream
201
+ id (`__loom_ui_lineage__:<experienceId>`, or your `streamPrefix`) to the owner's subject.
202
+ 2. Call `registerLineageSensitivePaths(encryptingEventStore)` once at composition (the paths are
203
+ `LINEAGE_SENSITIVE_PATHS`: `ops`, `summary`, `by` on a composed event, `by` on the others;
204
+ identifiers, hashes and refs stay plain so the log still folds structurally).
205
+ 3. Back the `Lineage` with an `EncryptingContentStore` over the SAME `KeyStore` and the SAME
206
+ `subjectOf`. The lineage passes every artifact `put`/`get` a `ContentAccess` of
207
+ `{ executionId: <the line's stream id>, workflowId: <the lineage workflow id> }`, so the one
208
+ mapping covers events and blobs. A blob's address is scoped to its subject: the same bytes in two
209
+ lines with different owners get different refs. Nothing here compares refs across lines (a version
210
+ is identified by `versionHash`).
211
+ 4. Give the `Lineage` a `confirmErased` hook (below), so a destroyed key reads as an erasure and not
212
+ as an unreadable line.
213
+ 5. Erase the owner the normal way: `app.eraseSubject`, or loom's `shredSubject` with ALL of the
214
+ owner's executions. The lineage stream needs nothing extra: it shares the owner's key, and a
215
+ reserved stream is never tombstoned. Pass `executionIds: []` ONLY for an owner with no executions
216
+ at all: a key destroyed that way leaves any execution sealed under it without a tombstone, and a
217
+ later shred can no longer write one (loom `plugins/crypto/README.md`, A6). Do not call
218
+ `keys.destroy` directly for an owner who has executions either.
219
+
220
+ **Erased versus unreadable.** loom reads "the key is not on this node" and "the key was destroyed"
221
+ the same way for a sealed event (`KeyUnavailableError`), and reports a sealed event that will not
222
+ open under a PRESENT key (a tampered row, a `subjectOf` that drifted) as `KeyUnavailableError` too, so
223
+ none of those can be told apart from the read alone. `isPayloadShredded` alone is not confirmation
224
+ either. So the package separates them with ONE rule: a line is `line-erased` only when the host says so,
225
+
226
+ ```ts
227
+ new Lineage({
228
+ events, content,
229
+ // the KeyStore recorded the destruction of this line's owner key
230
+ confirmErased: async (experienceId) =>
231
+ (await keys.wasDestroyed?.(subjectOf({ executionId: `__loom_ui_lineage__:${experienceId}` }))) === true,
232
+ });
233
+ ```
234
+
235
+ and every other read that hits `isKeyUnavailable` / `isPayloadShredded` is **`line-unreadable`** ("the
236
+ line's key is unavailable on this node or its ciphertext does not open; not confirmed erased"). A
237
+ blob that exists but does not open although the key exists (plaintext written before sealing was
238
+ composed, or damaged bytes: `ContentUndecryptableError`) is `line-unreadable` with the distinct
239
+ finding code `artifact-unsealed`, and is never an erasure. A throwing hook counts as "not confirmed".
240
+ Both outcomes are fail-closed, and writes are refused either way. `LineErasedError` (confirmed) and
241
+ `LineUnreadableError` (`confirmed: false`, `reason`) are the two error classes of the plain-value reads.
242
+ `@loomwork/ui-changes` inherits the hook for its `ChangeFeed` (it classifies through `lineage.block`); a
243
+ head signal is built over a store, so it takes its own `confirmErased` option.
244
+
245
+ Afterwards a read of that line refuses with `{ kind: "refused", finding: { code: "line-erased" } }`
246
+ (or `line-unreadable`): `state`, `head`, `versions`, `walk`, `get`, `getMany`, `resolveLatest`,
247
+ `resolvePromoted`, `provenance`, `explain` and `cite`; `verify` returns a single such finding. A
248
+ write (`start`, `advance`, `compose`, `revert`, `undo`, `promote`, `branch`) on such a line refuses
249
+ with the same code; the owner's key is never re-minted. A key destroyed between the read and the
250
+ append is re-checked on the append's failure and returns the typed refusal. Recognition is by
251
+ `isKeyUnavailable` / `isPayloadShredded` (`@loomwork/event-runtime`), never by message text.
252
+
253
+ **GC.** The plain-value reads (`forEachContentRef`, `pins`, so `rootsFor` and `collectSeenGarbage`)
254
+ SKIP a line whose erasure is confirmed: it contributes no roots, so its sealed blobs become
255
+ reclaimable, which is the point of erasure. An unconfirmed `line-unreadable` line makes them throw
256
+ `LineUnreadableError` (and `collectSeenGarbage` return `gc-roots-unavailable`): guessing could delete
257
+ live blobs. Without a `confirmErased` hook GC therefore stays refused after any erasure.
258
+
259
+ Erasure is scoped to the owner. A branch reads its source with the SOURCE line's access and writes its
260
+ own first artifact under the TARGET line's, so a line branched from an erased line keeps reading. A
261
+ branch COPIES the source's content into the target line under the TARGET owner's key: it is NOT
262
+ erased with the source owner (erase the target's owner too if it must go). Its `verify` can no longer
263
+ recompute the fork from the erased source, so it attests that first version by its content address
264
+ alone and reports a `branch-source-erased` WARNING, only when the source's erasure is confirmed (also
265
+ when the host deleted the source's events too); an unconfirmed unreadable source is an ERROR finding
266
+ (`line-unreadable`). Versions after it verify normally.
267
+
268
+ **What stays plaintext after erasure.** Only `ops`, `summary` and `by` are sealed. `versionHash`,
269
+ `parents`, `revertedTo`, `carriedFrom`, `from` and `experienceId` stay plain so the log folds, and
270
+ `versionHash` is a plain SHA-256 of the artifact: after erasure a guessed artifact can be CONFIRMED
271
+ against it. Do not put personal data in `experienceId`, `streamPrefix` or `workflowId`.
272
+
273
+ **Seen records are separate.** `SeenLog` calls `content.put` WITHOUT a `ContentAccess`, so it cannot
274
+ share a sealing `EncryptingContentStore` and needs its own store. Erasing a line's owner does not
275
+ erase viewers' seen records of that line: those have their own per-viewer erasure (`eraseViewer`), and
276
+ `SeenLog.read` on an erased line returns `restricted: true` with no entries.
277
+
278
+ **Enabling sealing on an existing deployment.** What was written before an `EncryptingContentStore`
279
+ (or `registerLineageSensitivePaths`) was composed stays plaintext, and reads through a sealing store
280
+ as `line-unreadable` / `artifact-unsealed`, never as erased. There is no `reseal` helper: a version's
281
+ event records the artifact's ref, and an append-only log cannot be rewritten to a new ref without
282
+ new events (a new version, which changes the line's history). Seal from the first write: start a new
283
+ store and re-create the lines, or keep a plain store for the old lines.
284
+
285
+ A `Lineage` fails closed at construction when its content store declares `sealsContent: true` but its
286
+ events store does not expose a `streamPrefix` (use `eventStoreLineageStore`): the blobs could
287
+ otherwise be sealed under a different subject than the events.
288
+
289
+ A `describe` of Bob's branched event whose BASE is an erased line refuses (the base cannot be read);
290
+ that edge is a known limitation.
291
+
292
+ Genericity: nothing here names a consumer.
293
+
294
+ ## Change delivery
295
+
296
+ `compose({ ..., summary })` records what the author said about a change (a planner's
297
+ `PlanResult.summary`) on the `EXPERIENCE_COMPOSED` event: untrusted model text, bounded and stripped
298
+ (`boundSummary`) on the way in and again when the fold reads it. It is not part of the content
299
+ address. `VersionRecord.summary` carries it. The feed, the unread cursor and the head signals that
300
+ consume the log live in [`@loomwork/ui-changes`](../changes/README.md).
301
+
302
+ ## Provenance and what a viewer saw (U12)
303
+
304
+ `lineage.provenance(experienceId, versionHash?)` is the per-node ledger of a version (see core's
305
+ `foldProvenance`), derived from the recorded ops alone: no artifact is read and nothing is stored. A
306
+ revert has the ledger of the version it restores; a branch starts from its source version's (depth
307
+ bounded; beyond it the origin is unknown and the ledger says it was truncated). `lineage.explain(
308
+ experienceId, nodeId, { viewer, audience?, versionHash?, projections? })` is core's `explainNode` over that
309
+ ledger, plus the notes the line holds: the creating version's `summary` / `summaryClass`, with the
310
+ creating version's own class floor added (a class can be lowered between versions; a version that
311
+ cannot be read counts as `secret`). Stamp the planner's decision on the ops with
312
+ `@loomwork/ui-planner`'s `plannerProvenance(result)` so a node links back to the recorded model call.
313
+
314
+ Every `by` a write records (the write's own and each op's) is checked against core's `Provenance`
315
+ before the append and refused `provenance-invalid` (any other payload the fold would refuse is
316
+ `event-payload-invalid`): the fold stays strict, and the log has no delete, so the refusal is on the
317
+ write side. `plannerProvenance` omits a decision id outside 1-256 characters rather than stamp one.
318
+ `explain` resolves the creating version across lines (a branch's ledger starts from its source's,
319
+ bounded), and a writer it cannot find counts as `secret`; it asks the viewer-safe question first, so
320
+ a hidden node and an unknown id cost the same reads. `source.lastChanged` names the latest op that
321
+ bound, remapped or declassified a provider, so a viewer cleared for the node can learn that a
322
+ declassify happened (not what the class was): accepted, it is the audit trail.
323
+
324
+ **`SeenLog` records what a viewer was shown.** (`new SeenLog({ ..., readPolicy })` binds a read
325
+ policy for `record` and `read`, joined with the lineage's.) `record({ experienceId, viewerId,
326
+ snapshot, viewer })` takes a core `snapshotSeen` snapshot, does not trust it (the version must be on
327
+ the line; entries are re-validated, restamped against that version's artifact, dropped when the
328
+ recording viewer may not see them, and re-capped), writes the value bytes to the `ContentStore`
329
+ (content-addressed) and appends a manifest (`VIEW_RECORDED`: the version, per source a class stamp,
330
+ state, status and a SEALED content ref) to a per-(experience, viewer) stream. `read({ ..., reader
331
+ })` returns the views newest first (`until` = as of a time), redacted for the READER before any blob
332
+ is fetched. An identical consecutive view is not re-recorded.
333
+
334
+ **A broadcast surface needs its own `viewerId`.** Seen records are keyed by `(experienceId,
335
+ viewerId)` and carry no audience marker, so a wallboard recorded under a person's id would
336
+ interleave with that person's personal views (and `read` would show them as one history). Give a
337
+ `"broadcast"` surface a `viewerId` of its own (for example `wallboard:<surface>`), and pass the
338
+ compile result's `viewer` (the no-clearance effective viewer) as `viewer`
339
+ (or `audience: "broadcast"`, which `record` and `read` take to act as a viewer with no clearance).
340
+
341
+ *Why a separate log, and not the lineage stream or the hydration store.* A lineage line is the
342
+ shared history of an experience with one compare-and-set tip that every writer contends for and
343
+ every reader (the change feed, fanned out to every viewer) folds; a view is per-viewer, frequent and
344
+ carries data, so recording it there would put every open of a surface in a race with every writer,
345
+ mix one viewer's classified values into a log all readers fold, and change what `verify` of the line
346
+ must explain. The hydration store is a latest-value cache for the surface being drawn (evicts,
347
+ projected to a client, no history): it cannot say what a viewer saw on Tuesday.
348
+
349
+ *Erasure is crypto-shredding.* A plain SHA-256 in an append-only log confirms any low-entropy value
350
+ ("did this viewer see `42`?" is one hash away) and the log has no delete, so each entry's content
351
+ ref is stored ENCRYPTED (AES-256-GCM, node:crypto) under a per-(experience, viewer) data key, and
352
+ the manifest digest is an HMAC under that key, not a hash. The value bytes stay in the
353
+ `ContentStore` under their plain content address (identical values dedupe across viewers and with
354
+ the lineage). The key lives in a host-supplied `SeenKeyStore` (`getOrCreate` / `get` / `destroy`;
355
+ `InMemorySeenKeyStore` is the non-durable reference, `checkSeenKeyStoreConformance` the kit; a loom
356
+ `@loomwork/plugin-crypto` `KeyStore` satisfies it structurally, so a KMS-backed one can be passed as
357
+ is). `eraseViewer({ experienceId, viewerId })` destroys the key and then appends a `VIEWER_ERASED`
358
+ tombstone (idempotent; a rerun completes a half-done erasure): afterwards `read` returns `{ erased:
359
+ true, views: [] }`, `record` refuses (`seen-viewer-erased`), and nothing left in the stream is
360
+ decryptable. The ciphertext is bound as associated data to (experience, viewer, record sequence,
361
+ entry index), so a ref cannot be moved between records, entries or viewers (even under one shared
362
+ key); a ref that fails to open (tampered, transplanted, wrong key) is withheld (`restricted`), never
363
+ read as plaintext. A missing or failing key store refuses a `record` and a `read`
364
+ (`seen-key-unavailable`): fail closed. Why the key lives here and not in `shredSubject`: loom's
365
+ shredder refuses the reserved `__loom_*` streams (a logged loom follow-up), and a seen log has to
366
+ live in one; `encryptValue` has no associated data, which this needs, so the primitive is local (the
367
+ `KeyStore` SHAPE is reused). The sealed payload is `{ref, bytes}` padded to a constant length, so a
368
+ `seen` entry's value size is hidden too (neither a field nor the ciphertext length separates `true`
369
+ from `false` from a short enum). What an erased stream STILL reveals: that the viewer opened the
370
+ experience; the sources each view named (handles / projection ids and paths), node ids, class
371
+ stamps, delivery statuses, the version hash and record times; and, for an `oversize` / `over-budget`
372
+ entry only, a power-of-two size class of its measured size (never the exact byte count: there the
373
+ size class IS the recorded fact). It reveals no value, no address and no size of a recorded value. A
374
+ value blob stays readable in the `ContentStore` until the collector below reclaims it.
375
+ `SeenKeyStore` contract: after `destroy` NO layer (including a warm cache) may serve the key;
376
+ create-if-absent is atomic (concurrent `getOrCreate` of one subject return one key); `record`
377
+ re-reads the key just before it appends and refuses (`seen-viewer-erased`) if it is gone.
378
+ `eraseViewer` performs no authorization (erasing a pair that never existed writes a tombstone), so
379
+ the host decides who may erase what. `eraseViewerEverywhere({ viewerId })` is the per-data-subject
380
+ form: it enumerates the seen streams (`listStreams`; refused with `seen-enumeration-unavailable`
381
+ without it) and erases that viewer in every experience, returning per-experience results (`complete`
382
+ only when all succeeded; idempotent and resumable). Each failed result's `refusal` is a lineage
383
+ `Refused`, so it now also serializes `kind` and `finding` keys beside the original fields: additive,
384
+ nothing removed. Coverage is the streams that exist when enumeration runs: a stream created
385
+ afterwards (a new view during the erase) is not covered, so the host serializes erasure against new
386
+ views for that subject or reruns; an orphan key with no stream (crash between `getOrCreate` and the
387
+ first append) is not destroyed, and holds no data.
388
+
389
+ *Reclaiming blobs: `collectSeenGarbage(log, { graceMs, ownsContentStore: true })`.* MARK-AND-SWEEP,
390
+ not a refcount (there is no transaction across the event log and the `ContentStore`, so a counter
391
+ drifts and a crash between "append" and "increment" would delete a live value). MARK: every content
392
+ address the lineage cites (any `__loomContent` in any event of any line, via
393
+ `Lineage.forEachContentRef`) plus every DECRYPTABLE ref inside every viewer's retention window
394
+ (`SeenLog.forEachLiveRef`) plus the host's `extraRoots`. SWEEP: page the store (`listPage`) and
395
+ delete every unmarked blob with the store's atomic `deleteIfUntouchedSince(ref, cutoff)`, `cutoff =
396
+ touchClock() at mark start - graceMs`. A blob touched at or after the cutoff (written during the
397
+ pass, or younger than `graceMs`, including a dedup'd re-put) is never swept: that is what protects a
398
+ value whose manifest is not appended yet; `graceMs` must exceed the longest a `record()` can take
399
+ between putting a blob and appending its manifest. A crash in that gap leaks the blob until the
400
+ first pass past grace and never loses a cited value. Erased viewers and rolled-off views mark
401
+ nothing, so their unique blobs are swept while a blob another viewer or the lineage still cites
402
+ survives. A pass is idempotent and safe to rerun after a crash. It FAILS CLOSED before deleting
403
+ anything if the root set cannot be established (a `LineageStore` without `listStreams`, a key-store
404
+ failure, ANYTHING UNREADABLE in a live window: a stream key that does not round-trip, a record that
405
+ fails to parse (a newer writer's shape during a rolling upgrade), a sealed ref that fails to open
406
+ under a PRESENT key: refusal `gc-unreadable-record` with counts, overridden only by
407
+ `allowUnreadable: true`, which means "sweep what those cite"; an ABSENT key is an erased viewer, not
408
+ unreadable, a `ContentStore` without the SPI 1.3.0 set `listPage` + `deleteIfUntouchedSince` +
409
+ `touchClock`, more than `maxMarked` live addresses). Memory: one string per distinct live blob
410
+ (`maxMarked`, default 5M, refuses rather than grow), one stream at a time, pages of `pageSize`.
411
+ `ownsContentStore: true` is an assertion you make: the sweep deletes what it cannot root, so the
412
+ store must hold nothing the lineage, the seen logs and `extraRoots` do not name. No loom or
413
+ `ContentStore` SPI change was needed: SPI 1.3.0 already carries the touch time.
414
+
415
+ *Retention rolls off, it does not refuse.* `retention: { maxViews = 10_000, maxAgeMs?, maxSkewMs =
416
+ 300_000 }`. Reads apply `maxAgeMs` exactly; the two irreversible age actions (a persisted watermark,
417
+ the collector) are conservative by `maxSkewMs`, so a writer whose clock runs ahead by up to that
418
+ much cannot roll off or sweep a view still inside the window by true time. With out-of-order
419
+ timestamps within `maxSkewMs` at the `maxViews` cap, the conservative window can differ from the
420
+ exact window's newest-N, in the direction of keeping MORE. A new view ALWAYS records. The log is
421
+ append-only, so roll-off is a watermark: each view carries the `floor` in force when written and a
422
+ `VIEWS_RETAINED` record raises it (about once per `maxViews/10` roll-offs); `read` and the collector
423
+ apply the window themselves (floor, age, newest `maxViews`), so correctness never depends on a
424
+ watermark having been written. Records at or below the floor are neither read nor marked, so their
425
+ blobs are reclaimed by the collector. Cost: `record` reads the LAST record only (tip, floor and the
426
+ last digest all ride on it) plus the window when a watermark is due; `read` and the collector read
427
+ `readAfter(floor)`, i.e. the window, never the log's lifetime. That needs the optional
428
+ `LineageStore.readTail` / `readAfter` (both reference stores and `eventStoreLineageStore` implement
429
+ them; a store without them falls back to a full `read`, O(log) per call). The rolled-off records
430
+ stay in the raw log as ciphertext (destroyed with the viewer's key).
431
+
432
+ Operations: the stream lives in its own reserved namespace (`eventStoreLineageStore(store,
433
+ seenStoreOptions)` writes `__loom_ui_seen__:<experience>/<viewer>`), so the notes on reserved
434
+ streams above apply (loom's `shredSubject` refuses them, which is why the seen log carries its own
435
+ key store). `LineageStore` gained three OPTIONAL methods: `readTail`, `readAfter` and `listStreams`
436
+ (the collector's enumeration; `eventStoreLineageStore` implements it over the SPI's
437
+ `listExecutionsPage` / `listExecutions`, which DO surface reserved streams, found by prefix).
438
+ `viewerId` and `viewer` are the host's claims: this package authorizes nothing.
439
+
440
+ Reference example (for `loom-examples`, not generic to one app): record views for two viewers who
441
+ saw the same value, erase one (`eraseViewer`), show the raw stream holds no readable address, show
442
+ `read` says `erased` while the other viewer's identical value still reads, run `collectSeenGarbage`
443
+ and show the erased viewer's unique blob is swept while the shared one and the lineage's artifacts
444
+ survive, then roll a viewer past `maxViews` and show the oldest views fall out of `read` and the
445
+ next sweep reclaims their blobs.
446
+
447
+ Seen-record details. An entry is judged by what its nodes actually bind: every node it lists must
448
+ bind exactly its `source`, an entry with no nodes is withheld, and `record` refuses a snapshot that
449
+ names one source twice (`seen-duplicate-source`); a read carries each value ref by the entry's
450
+ position in the record, never by source. A read hashes the bytes the content store returns and
451
+ compares them to the address in the manifest; a mismatch is state `corrupt` (no value). Trust
452
+ boundary: a manifest ref points at whatever the store holds under that address, so write access to
453
+ the `ContentStore` (or the seen stream) is write access to what a viewer is later shown, exactly as
454
+ for the lineage's artifacts; this package does not prove a blob was written by the stream that names
455
+ it. `record` refuses a snapshot or record that a read could not parse (`seen-snapshot-invalid`,
456
+ `seen-record-invalid`) rather than append a permanently malformed one. A `read` folds the lineage
457
+ once for all the versions it touches; `record` folds it once (O(line length) per write). An
458
+ identical consecutive view is not re-recorded, so the kept record carries the FIRST view's `at`. The
459
+ seen stream's prefix must not overlap the lineage's (`Lineage.sharesStreamsWith` compares both ways,
460
+ `a.startsWith(b) || b.startsWith(a)`, and `SeenLog` throws on a shared store or an overlapping
461
+ prefix). The vetted store must expose a readable `streamPrefix` (or be an `InMemoryLineageStore`): a
462
+ decorator that HIDES it cannot be told apart from a colliding store, so it is refused; re-expose
463
+ `streamPrefix` on a legitimate wrapper. If the LINEAGE's own store is such a wrapper its prefix is
464
+ assumed to be the default. And `eventStoreLineageStore` throws for a `streamPrefix` not under
465
+ `__loom_ui_` or a malformed `workflowId`.
466
+
467
+ ## See also
468
+
469
+ - [../core/README.md](../core/README.md) — the Experience Model and `./log` persistence this package
470
+ versions.
471
+ - [../changes/README.md](../changes/README.md) — change delivery over the lineage events.
472
+ - [../../README.md](../../README.md) — root overview.
@@ -0,0 +1,4 @@
1
+ import type { Finding } from "@loomwork/ui-core";
2
+ import type { LineageStore } from "./store";
3
+ export declare function checkLineageStoreConformance(make: () => LineageStore | Promise<LineageStore>): Promise<Finding[]>;
4
+ //# sourceMappingURL=conformance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conformance.d.ts","sourceRoot":"","sources":["../src/conformance.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAc5C,wBAAsB,4BAA4B,CAChD,IAAI,EAAE,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,GAC/C,OAAO,CAAC,OAAO,EAAE,CAAC,CAkIpB"}
@@ -0,0 +1,143 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.checkLineageStoreConformance = checkLineageStoreConformance;
4
+ // ── The LineageStore conformance kit ──────────────────────────────────────────
5
+ // Returns findings (a test asserts the list is empty), never throws: a store that throws on a case
6
+ // is reported as a finding, like `checkPlannerConformance`. Each case uses a fresh line id, so one
7
+ // `make()` store serves them all — but a backend that cannot isolate lines is exactly what the
8
+ // "lines are independent" case catches.
9
+ const rec = (n) => ({
10
+ type: "EXPERIENCE_COMPOSED",
11
+ payload: { n },
12
+ at: "2026-10-06T00:00:00.000Z",
13
+ });
14
+ async function checkLineageStoreConformance(make) {
15
+ const findings = [];
16
+ const fail = (code, message) => findings.push({ code, severity: "error", message });
17
+ const store = await make();
18
+ const cases = [
19
+ [
20
+ "an unknown line reads empty",
21
+ async () => {
22
+ const r = await store.read("c-empty");
23
+ if (r.length !== 0)
24
+ fail("read-unknown", `an unknown line read ${r.length} records`);
25
+ },
26
+ ],
27
+ [
28
+ "append at tip 0 wins sequence 1; the sequence is dense",
29
+ async () => {
30
+ const a = await store.append("c-seq", 0, rec(1));
31
+ const b = await store.append("c-seq", 1, rec(2));
32
+ if (!a.ok || a.sequence !== 1 || !b.ok || b.sequence !== 2)
33
+ fail("append-sequence", `sequential appends returned ${JSON.stringify([a, b])}`);
34
+ const r = await store.read("c-seq");
35
+ if (r.map((x) => x.sequence).join() !== "1,2")
36
+ fail("read-order", `read returned sequences ${r.map((x) => x.sequence).join()}`);
37
+ },
38
+ ],
39
+ [
40
+ "a stale expectedSequence is refused with the real tip and writes nothing",
41
+ async () => {
42
+ await store.append("c-stale", 0, rec(1));
43
+ const s = await store.append("c-stale", 0, rec(2));
44
+ if (s.ok || s.tip !== 1)
45
+ fail("stale-append", `a stale append returned ${JSON.stringify(s)}`);
46
+ const ahead = await store.append("c-stale", 5, rec(3));
47
+ if (ahead.ok || ahead.tip !== 1)
48
+ fail("gap-append", `an append past the tip (a gap) returned ${JSON.stringify(ahead)}`);
49
+ const r = await store.read("c-stale");
50
+ if (r.length !== 1)
51
+ fail("refused-wrote", `a refused append left ${r.length} records`);
52
+ },
53
+ ],
54
+ [
55
+ "of N concurrent appends at one tip exactly one wins",
56
+ async () => {
57
+ const outs = await Promise.all(Array.from({ length: 8 }, (_, i) => store.append("c-race", 0, rec(i))));
58
+ const wins = outs.filter((o) => o.ok).length;
59
+ if (wins !== 1)
60
+ fail("race", `${wins} of 8 concurrent appends at the same tip won`);
61
+ const r = await store.read("c-race");
62
+ if (r.length !== 1)
63
+ fail("race-log", `the raced line holds ${r.length} records`);
64
+ },
65
+ ],
66
+ [
67
+ "lines are independent",
68
+ async () => {
69
+ await store.append("c-iso-a", 0, rec(1));
70
+ const b = await store.append("c-iso-b", 0, rec(2));
71
+ if (!b.ok)
72
+ fail("isolation", "an append to a second line was refused by the first line's tip");
73
+ if ((await store.read("c-iso-a")).length !== 1)
74
+ fail("isolation", "a line holds another's record");
75
+ },
76
+ ],
77
+ [
78
+ "payloads round-trip and neither side aliases the log",
79
+ async () => {
80
+ const payload = { nested: { list: [1, 2, { k: "v" }] } };
81
+ await store.append("c-copy", 0, {
82
+ type: "EXPERIENCE_COMPOSED",
83
+ payload,
84
+ at: "2026-10-06T00:00:00.000Z",
85
+ });
86
+ payload.nested.list.push(3);
87
+ const first = await store.read("c-copy");
88
+ if (JSON.stringify(first[0]?.payload) !==
89
+ JSON.stringify({ nested: { list: [1, 2, { k: "v" }] } }))
90
+ fail("copy-in", "mutating a payload after append changed the stored record");
91
+ first[0]?.payload.nested.list.push(9);
92
+ const second = await store.read("c-copy");
93
+ if (JSON.stringify(second[0]?.payload).includes("9"))
94
+ fail("copy-out", "mutating a read result changed the stored record");
95
+ if (second[0]?.type !== "EXPERIENCE_COMPOSED" ||
96
+ second[0]?.at !== "2026-10-06T00:00:00.000Z")
97
+ fail("fields", "type or at did not round-trip");
98
+ },
99
+ ],
100
+ [
101
+ "optional readTail / readAfter / listStreams agree with read (when implemented)",
102
+ async () => {
103
+ for (let i = 0; i < 5; i++)
104
+ await store.append("c-opt", i, rec(i));
105
+ const all = await store.read("c-opt");
106
+ const seqs = (r) => r.map((x) => x.sequence).join();
107
+ if (store.readTail !== undefined) {
108
+ if (seqs(await store.readTail("c-opt", 2)) !== "4,5")
109
+ fail("read-tail", "readTail(2) did not return the newest two records in order");
110
+ if (seqs(await store.readTail("c-opt", 99)) !== seqs(all))
111
+ fail("read-tail", "readTail past the length did not return the whole line");
112
+ if ((await store.readTail("c-opt-none", 3)).length !== 0)
113
+ fail("read-tail", "readTail of an unknown line was not empty");
114
+ }
115
+ if (store.readAfter !== undefined) {
116
+ if (seqs(await store.readAfter("c-opt", 3)) !== "4,5")
117
+ fail("read-after", "readAfter(3) did not return the records past sequence 3");
118
+ if (seqs(await store.readAfter("c-opt", 0, 2)) !== "1,2")
119
+ fail("read-after", "readAfter(0, 2) did not honour the limit");
120
+ if ((await store.readAfter("c-opt", 5)).length !== 0)
121
+ fail("read-after", "readAfter at the tip was not empty");
122
+ }
123
+ if (store.listStreams !== undefined) {
124
+ const ids = [];
125
+ for await (const id of store.listStreams())
126
+ ids.push(id);
127
+ if (!ids.includes("c-opt") || new Set(ids).size !== ids.length)
128
+ fail("list-streams", "listStreams missed a line or listed one twice");
129
+ }
130
+ },
131
+ ],
132
+ ];
133
+ for (const [name, run] of cases) {
134
+ try {
135
+ await run();
136
+ }
137
+ catch (err) {
138
+ fail("store-threw", `${name}: ${err instanceof Error ? err.message : String(err)}`);
139
+ }
140
+ }
141
+ return findings;
142
+ }
143
+ //# sourceMappingURL=conformance.js.map