@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/dist/seen.js ADDED
@@ -0,0 +1,739 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.seenStoreOptions = exports.SeenLog = exports.seenStreamKey = exports.UNREADABLE_EXAMPLES = exports.DEFAULT_MAX_SKEW_MS = exports.ViewRecordPayload = exports.MAX_SEEN_READ = exports.DEFAULT_MAX_SEEN_VIEWS = exports.SEEN_WORKFLOW_ID = exports.SEEN_STREAM_PREFIX = exports.VIEWER_ERASED = exports.VIEWS_RETAINED = exports.VIEW_RECORDED = void 0;
4
+ exports.sizeClass = sizeClass;
5
+ const plugin_content_1 = require("@loomwork/plugin-content");
6
+ const ui_core_1 = require("@loomwork/ui-core");
7
+ const zod_1 = require("zod");
8
+ const seenCrypto_1 = require("./seenCrypto");
9
+ const store_1 = require("./store");
10
+ // ── The record of what a viewer SAW (U12) ─────────────────────────────────────
11
+ // WHERE IT LIVES, and why not the two obvious places:
12
+ //
13
+ // * NOT the lineage stream. A lineage line is the SHARED, append-only history of an experience: one
14
+ // compare-and-set tip per line, folded by every reader (the change feed fans it out to every
15
+ // viewer), and its fold is total and structural. A view is per-viewer, high-rate, and carries
16
+ // DATA. Appending views there would make every open of a surface contend with every writer of a
17
+ // version for the same tip, put one viewer's classified values in a log every reader of the line
18
+ // folds, and let a view record change what `verify` of the line has to explain.
19
+ // * NOT the hydration store. That is a latest-value cache for the surface being drawn: it holds
20
+ // the CURRENT value of a handle, evicts, is projected to a client, and keeps no history, so it
21
+ // cannot say what a viewer saw last Tuesday.
22
+ //
23
+ // So: a SEPARATE, per-(experience, viewer) log in a reserved namespace of the same `LineageStore`
24
+ // seam (one stream per viewer: no cross-viewer contention, isolation by key) whose events are
25
+ // MANIFESTS. A manifest names the version viewed and, per data source, a class stamp, a delivery
26
+ // state and — for a value that fit the caps — a SEALED content reference. The VALUE BYTES live in the
27
+ // ContentStore under their plain content address (identical values dedupe across viewers and with the
28
+ // lineage). Nothing here is part of any artifact or content address.
29
+ //
30
+ // ERASURE (crypto-shredding). A plain SHA-256 in an append-only log confirms any low-entropy value
31
+ // ("did this viewer see `42`?" is one hash away), and a log has no delete. So each entry's ref is
32
+ // stored ENCRYPTED (AES-256-GCM) under a per-(experience, viewer) data key held in a host-supplied
33
+ // `SeenKeyStore`, and the manifest digest is a keyed HMAC, not a hash. Erasing a viewer destroys the
34
+ // key: nothing left in the stream is decryptable and no readable address remains. The ciphertext is
35
+ // bound to (experience, viewer, record sequence, entry index) as associated data, so a ref cannot be
36
+ // transplanted between records or viewers. What the sealed payload holds is `{ref, bytes}`, padded
37
+ // to a constant length: the value's SIZE is inside the seal too, so neither a field nor the
38
+ // ciphertext length distinguishes `true` from `false` from a short enum.
39
+ //
40
+ // WHAT AN ERASED STREAM STILL REVEALS (precisely): that the viewer opened the experience; the
41
+ // sources each view named (handles / projection ids and paths); node ids; class stamps; delivery
42
+ // statuses; the version hash; record times; and, for an `oversize` / `over-budget` entry only, a
43
+ // power-of-two SIZE CLASS (never the exact byte count), because there the size class IS the recorded fact. It
44
+ // reveals NO value, no content address, and no size of a value that was recorded as `seen`.
45
+ //
46
+ // VALUE BLOBS are reclaimed by a MARK-AND-SWEEP collector (`collectSeenGarbage`, seenGc.ts), never a
47
+ // refcount: there is no transaction across the event log and the ContentStore, so a counter drifts,
48
+ // and a crash between "append" and "increment" would delete a live value. Roots are re-derived from
49
+ // the logs on every pass.
50
+ //
51
+ // RETENTION. A viewer's log is bounded by roll-off, not refusal: new views ALWAYS record, and the
52
+ // oldest fall out of a `{ maxViews, maxAgeMs }` window. The log is append-only, so roll-off is a
53
+ // watermark: every view record carries the `floor` in force when written, and a `VIEWS_RETAINED`
54
+ // record raises it; records at or below the floor are not read and not marked by the collector.
55
+ // The tip, floor and last digest come from the LAST record alone, and a read costs the size of the
56
+ // window (`readAfter(floor)`), not the age of the log.
57
+ //
58
+ // Reads are viewer-safe: the entries are redacted against the recorded version's artifact with
59
+ // core's `redactSeen` BEFORE any blob is fetched (a viewer is never even handed a restricted ref),
60
+ // and a stamp that is missing or unknown is withheld.
61
+ exports.VIEW_RECORDED = "VIEW_RECORDED";
62
+ /** A retention watermark: every record at or below `floor` has rolled off. */
63
+ exports.VIEWS_RETAINED = "VIEWS_RETAINED";
64
+ /** The viewer was erased (written AFTER the key is destroyed; a rerun completes it). */
65
+ exports.VIEWER_ERASED = "VIEWER_ERASED";
66
+ /** The reserved-stream namespace a viewer's record lives under (distinct from the lineage's). */
67
+ exports.SEEN_STREAM_PREFIX = "__loom_ui_seen__:";
68
+ exports.SEEN_WORKFLOW_ID = "loom-ui.seen";
69
+ /** Default most views a viewer's log keeps readable; older ones roll off (never a refusal). */
70
+ exports.DEFAULT_MAX_SEEN_VIEWS = 10_000;
71
+ /** Most records one read returns. */
72
+ exports.MAX_SEEN_READ = 500;
73
+ const MAX_APPEND_RETRIES = 16;
74
+ const MAX_ID = 256;
75
+ const MAX_VIEWS_CAP = 1_000_000;
76
+ const SourceSchema = zod_1.z.discriminatedUnion("kind", [
77
+ zod_1.z.object({ kind: zod_1.z.literal("handle"), handle: zod_1.z.string().min(1).max(512) }),
78
+ zod_1.z.object({
79
+ kind: zod_1.z.literal("projection"),
80
+ id: zod_1.z.string().min(1).max(512),
81
+ path: zod_1.z.string().min(1).max(1024),
82
+ version: zod_1.z.string().min(1).max(512),
83
+ }),
84
+ ]);
85
+ // `class` is left a plain string here: an unknown stamp must reach `redactSeen` and be WITHHELD there
86
+ // (the record is untrusted input), not make the whole record unparseable.
87
+ const EntrySchema = zod_1.z.object({
88
+ source: SourceSchema,
89
+ nodeIds: zod_1.z.array(zod_1.z.string().min(1).max(512)).min(1).max(ui_core_1.MAX_SEEN_NODE_IDS),
90
+ class: zod_1.z.string().max(32),
91
+ state: zod_1.z.enum(["seen", "absent", "oversize", "over-budget", "unreadable"]),
92
+ status: zod_1.z.string().max(64).optional(),
93
+ /** Only for `oversize` / `over-budget` (where the size is the recorded fact). A `seen` entry's size lives inside `sealed`. */
94
+ bytes: zod_1.z.number().int().nonnegative().optional(),
95
+ /** `{ref, bytes}`, constant-length padded and AEAD-sealed under the viewer's data key (see the header). Never a plain address. */
96
+ sealed: zod_1.z.string().min(1).max(1024).optional(),
97
+ });
98
+ exports.ViewRecordPayload = zod_1.z.object({
99
+ experienceId: zod_1.z.string().min(1).max(MAX_ID),
100
+ versionHash: zod_1.z.string().min(1).max(256),
101
+ viewerId: zod_1.z.string().min(1).max(MAX_ID),
102
+ /** The clearance the viewer held when shown the surface (informational: reads use the READER's). */
103
+ clearance: zod_1.z.string().max(32).optional(),
104
+ entries: zod_1.z.array(EntrySchema).max(ui_core_1.MAX_SEEN_SOURCES),
105
+ omitted: zod_1.z.number().int().nonnegative(),
106
+ restricted: zod_1.z.boolean(),
107
+ /** Keyed digest (HMAC under the viewer's key) of the manifest's meaning: an identical consecutive view is not re-recorded. */
108
+ digest: zod_1.z.string().min(1).max(128),
109
+ /** The retention floor in force when this was written (see the header). */
110
+ floor: zod_1.z.number().int().nonnegative().optional(),
111
+ });
112
+ /** The smallest power of two >= `n` (0 stays 0): the only size an `oversize` / `over-budget` entry stores. */
113
+ function sizeClass(n) {
114
+ if (!Number.isFinite(n) || n <= 0)
115
+ return 0;
116
+ return 2 ** Math.ceil(Math.log2(n));
117
+ }
118
+ /** Default tolerated clock skew between writers (5 minutes). */
119
+ exports.DEFAULT_MAX_SKEW_MS = 300_000;
120
+ /** Records read from the tail to find the floor in force (a malformed tip carries the last good one). */
121
+ const FLOOR_SCAN = 8;
122
+ /** Cap on the offenders named per kind in {@link SeenUnreadable.examples}. */
123
+ exports.UNREADABLE_EXAMPLES = 16;
124
+ const refused = (code, message) => ({
125
+ ok: false,
126
+ refusal: {
127
+ kind: "refused",
128
+ finding: { code, severity: "error", message },
129
+ code,
130
+ message,
131
+ },
132
+ });
133
+ /**
134
+ * Why a snapshot's version could not be loaded or re-cited, said accurately: it is not a version of
135
+ * the line (`seen-version-unknown`), its artifact was reclaimed (`seen-artifact-missing`), or anything
136
+ * else (`seen-version-unreadable`, with the underlying code). Nothing is recorded in any case.
137
+ */
138
+ const versionRefusal = (refusal) => {
139
+ const finding = refusal.kind === "refused"
140
+ ? refusal.finding
141
+ : { code: refusal.kind, message: "the line moved while recording" };
142
+ if (finding.code === "version-unknown")
143
+ return refused("seen-version-unknown", "the snapshot's version is not a version of this line");
144
+ if (finding.code === "artifact-missing")
145
+ return refused("seen-artifact-missing", "the snapshot's version exists but its artifact is gone (already reclaimed); nothing is recorded");
146
+ return refused("seen-version-unreadable", `the snapshot's version could not be loaded (${finding.code}): ${finding.message}`);
147
+ };
148
+ const seenStreamKey = (experienceId, viewerId) => `${encodeURIComponent(experienceId)}/${encodeURIComponent(viewerId)}`;
149
+ exports.seenStreamKey = seenStreamKey;
150
+ const sourceKey = (s) => {
151
+ const o = s;
152
+ if (typeof o !== "object" || o === null)
153
+ return undefined;
154
+ return JSON.stringify(o.kind === "handle"
155
+ ? ["h", o.handle]
156
+ : o.kind === "projection"
157
+ ? ["p", o.id, o.path, o.version]
158
+ : null);
159
+ };
160
+ const validId = (v) => typeof v === "string" && v.length > 0 && v.length <= MAX_ID;
161
+ /**
162
+ * The floor in force: the last well-formed floor among the tail records (a malformed record carries
163
+ * none, so the scan goes back past it rather than resetting to 0 and undoing retention). Beyond the
164
+ * scan there is nothing to go on: 0, which keeps more, never less.
165
+ */
166
+ const floorOf = (tail) => {
167
+ for (let i = tail.length - 1; i >= 0; i--) {
168
+ const rec = tail[i];
169
+ if (rec.type !== exports.VIEW_RECORDED && rec.type !== exports.VIEWS_RETAINED)
170
+ continue;
171
+ const f = rec.payload?.floor;
172
+ if (Number.isSafeInteger(f) && f >= 0 && f < rec.sequence)
173
+ return f;
174
+ }
175
+ return 0;
176
+ };
177
+ /** The (experience, viewer) a stream key names, or `undefined` if it does not round-trip `seenStreamKey`. */
178
+ const parseStreamKey = (key) => {
179
+ const slash = key.indexOf("/");
180
+ if (slash < 0)
181
+ return undefined;
182
+ try {
183
+ const experienceId = decodeURIComponent(key.slice(0, slash));
184
+ const viewerId = decodeURIComponent(key.slice(slash + 1));
185
+ return (0, exports.seenStreamKey)(experienceId, viewerId) === key ? { experienceId, viewerId } : undefined;
186
+ }
187
+ catch {
188
+ return undefined;
189
+ }
190
+ };
191
+ class SeenLog {
192
+ events;
193
+ content;
194
+ lineage;
195
+ keys;
196
+ maxViews;
197
+ maxAgeMs;
198
+ maxSkewMs;
199
+ slack;
200
+ now;
201
+ policy;
202
+ constructor(opts) {
203
+ this.policy = (0, ui_core_1.mergeReadPolicy)(opts.lineage.readPolicy, opts.readPolicy);
204
+ // A record is per-viewer data; it must never share streams with the lineage it describes (a
205
+ // shared prefix would put a viewer's manifests among the line's versions, and make an
206
+ // experienceId that spells a stream key read as one). An unreadable prefix cannot be proven
207
+ // disjoint, so it is refused too (see `Lineage.sharesStreamsWith`).
208
+ if (opts.lineage.sharesStreamsWith(opts.events) ||
209
+ opts.events.streamPrefix === store_1.LINEAGE_STREAM_PREFIX)
210
+ throw new Error("SeenLog: `events` must be a store of its own with a readable `streamPrefix` that does not overlap the lineage's: use seenStoreOptions");
211
+ const maxViews = opts.retention?.maxViews ?? exports.DEFAULT_MAX_SEEN_VIEWS;
212
+ const maxAgeMs = opts.retention?.maxAgeMs;
213
+ if (!Number.isSafeInteger(maxViews) || maxViews < 1 || maxViews > MAX_VIEWS_CAP)
214
+ throw new Error(`SeenLog: retention.maxViews must be an integer in 1..${MAX_VIEWS_CAP}`);
215
+ if (maxAgeMs !== undefined && (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0))
216
+ throw new Error("SeenLog: retention.maxAgeMs must be a positive number of milliseconds");
217
+ const maxSkewMs = opts.retention?.maxSkewMs ?? exports.DEFAULT_MAX_SKEW_MS;
218
+ if (!Number.isFinite(maxSkewMs) || maxSkewMs < 0)
219
+ throw new Error("SeenLog: retention.maxSkewMs must be a non-negative number of milliseconds");
220
+ if (typeof opts.keys?.getOrCreate !== "function")
221
+ throw new Error("SeenLog: `keys` (a SeenKeyStore) is required");
222
+ this.events = opts.events;
223
+ this.content = opts.content;
224
+ this.lineage = opts.lineage;
225
+ this.keys = opts.keys;
226
+ this.maxViews = maxViews;
227
+ this.maxAgeMs = maxAgeMs;
228
+ this.maxSkewMs = maxSkewMs;
229
+ this.slack = Math.max(1, Math.ceil(maxViews / 10));
230
+ this.now = opts.now ?? (() => new Date().toISOString());
231
+ }
232
+ /** The content store value blobs live in (for the collector). */
233
+ get contentStore() {
234
+ return this.content;
235
+ }
236
+ /** The lineage the records name versions of (for the collector's roots). */
237
+ get lineageOf() {
238
+ return this.lineage;
239
+ }
240
+ // ── log windows (bounded by retention, not by the age of the log) ───────────
241
+ async lastRecords(key, n) {
242
+ if (this.events.readTail !== undefined)
243
+ return this.events.readTail(key, n);
244
+ const all = await this.events.read(key);
245
+ return all.slice(Math.max(0, all.length - n));
246
+ }
247
+ async after(key, floor) {
248
+ if (this.events.readAfter !== undefined)
249
+ return this.events.readAfter(key, floor);
250
+ return (await this.events.read(key)).filter((r) => r.sequence > floor);
251
+ }
252
+ /** The live views (oldest first) of a window: after the floor, inside the age limit, newest `maxViews`. */
253
+ liveViews(window, conservative = false) {
254
+ let views = window.filter((r) => r.type === exports.VIEW_RECORDED);
255
+ if (this.maxAgeMs !== undefined) {
256
+ // the collector (irreversible) keeps everything inside `maxAgeMs + maxSkewMs`; a read is exact
257
+ const cutoff = Date.parse(this.now()) - this.maxAgeMs - (conservative ? this.maxSkewMs : 0);
258
+ views = views.filter((r) => Date.parse(r.at) >= cutoff);
259
+ }
260
+ return views.length > this.maxViews ? views.slice(views.length - this.maxViews) : views;
261
+ }
262
+ /**
263
+ * Record that `viewerId` was shown `snapshot` (built with core's `snapshotSeen`). The snapshot is
264
+ * not trusted: its version must be a version of the line, its entries are re-validated, re-capped
265
+ * and RESTAMPED against that version's artifact (a stamp laxer than the artifact says is raised; an
266
+ * entry the recording viewer may not see is dropped), and values are measured again. An identical
267
+ * consecutive view records nothing (`recorded: false`). A new view ALWAYS records: when the
268
+ * viewer's window is full, the oldest views roll off (see the header). Refuses an erased viewer.
269
+ * Performs no authorization: `viewerId` and `viewer` are the host's claims.
270
+ */
271
+ async record(input) {
272
+ const { experienceId, viewerId, snapshot } = input;
273
+ if (!validId(experienceId) || !validId(viewerId))
274
+ return refused("seen-id-invalid", "experienceId and viewerId are 1-256 character strings");
275
+ if (typeof snapshot !== "object" || snapshot === null || !Array.isArray(snapshot.entries))
276
+ return refused("seen-snapshot-invalid", "the snapshot is not a snapshot");
277
+ if (!Number.isSafeInteger(snapshot.omitted) || snapshot.omitted < 0)
278
+ return refused("seen-snapshot-invalid", "`omitted` is not a non-negative integer");
279
+ if (snapshot.entries.length > ui_core_1.MAX_SEEN_SOURCES)
280
+ return refused("seen-too-many-sources", `a snapshot holds at most ${ui_core_1.MAX_SEEN_SOURCES} sources`);
281
+ const sourceKeys = new Set();
282
+ for (const e of snapshot.entries) {
283
+ const k = sourceKey(e?.source);
284
+ if (k !== undefined && sourceKeys.has(k))
285
+ return refused("seen-duplicate-source", "a snapshot names one source twice");
286
+ if (k !== undefined)
287
+ sourceKeys.add(k);
288
+ }
289
+ if (snapshot.experienceId !== experienceId)
290
+ return refused("seen-wrong-line", "the snapshot is of another experience");
291
+ const key = (0, exports.seenStreamKey)(experienceId, viewerId);
292
+ // Gate on erasure BEFORE any blob is written, and obtain the data key (fail closed: no key, no record).
293
+ const gate = await this.lastRecords(key, 1);
294
+ if (gate[0]?.type === exports.VIEWER_ERASED)
295
+ return refused("seen-viewer-erased", "this viewer was erased; nothing more is recorded for them");
296
+ let dataKey;
297
+ try {
298
+ dataKey = (0, seenCrypto_1.deriveSeenKeys)(await this.keys.getOrCreate((0, seenCrypto_1.seenKeySubject)(key)));
299
+ }
300
+ catch {
301
+ return refused("seen-key-unavailable", "the viewer's data key is unavailable (erased, or the key store failed): nothing is recorded");
302
+ }
303
+ const art = await this.lineage.get(experienceId, snapshot.versionHash);
304
+ if (!art.ok)
305
+ return versionRefusal(art.refusal);
306
+ // The record will cite this version for as long as the view is in retention: tell the store it
307
+ // was just re-cited (touch, no put) so a collector racing this write spares the artifact.
308
+ const cited = await this.lineage.cite(experienceId, [snapshot.versionHash]);
309
+ if (!cited.ok)
310
+ return versionRefusal(cited.refusal);
311
+ // Restamp + drop what the recording viewer may not see, with core's one redactor.
312
+ const checked = (0, ui_core_1.redactSeen)(snapshot.entries, art.value, {
313
+ ...(input.viewer !== undefined && { viewer: input.viewer }),
314
+ ...(input.audience !== undefined && { audience: input.audience }),
315
+ ...(0, ui_core_1.mergeReadPolicy)(this.policy, input),
316
+ });
317
+ let total = 0;
318
+ const shapes = [];
319
+ const refs = [];
320
+ for (const e of checked.entries) {
321
+ const shape = EntrySchema.omit({ sealed: true }).safeParse({
322
+ source: e.source,
323
+ nodeIds: e.nodeIds,
324
+ class: e.class,
325
+ state: e.state,
326
+ ...(e.status !== undefined && { status: e.status }),
327
+ // a `seen` entry's size is sealed with its ref, never stored in the clear
328
+ // `oversize` / `over-budget`: only a power-of-two size class survives, never the exact count
329
+ ...(e.bytes !== undefined && e.state !== "seen" && { bytes: sizeClass(e.bytes) }),
330
+ });
331
+ if (!shape.success)
332
+ return refused("seen-entry-invalid", "a snapshot entry is malformed");
333
+ let ref;
334
+ if (e.state === "seen") {
335
+ let bytes;
336
+ try {
337
+ bytes = (0, plugin_content_1.encodeValue)(e.value);
338
+ }
339
+ catch {
340
+ return refused("seen-value-unencodable", "a seen value is not encodable");
341
+ }
342
+ if (bytes.length > ui_core_1.MAX_SEEN_VALUE_BYTES)
343
+ return refused("seen-value-too-large", `a value is over ${ui_core_1.MAX_SEEN_VALUE_BYTES} bytes`);
344
+ total += bytes.length;
345
+ if (total > ui_core_1.MAX_SEEN_TOTAL_BYTES)
346
+ return refused("seen-over-budget", `the snapshot is over ${ui_core_1.MAX_SEEN_TOTAL_BYTES} bytes`);
347
+ // Blob BEFORE manifest: a crash between the two leaks a blob (reclaimed by the next sweep
348
+ // past its grace period) and never loses a live value. A re-put of a held blob bumps its
349
+ // touch time, which is what protects a value another record is about to cite.
350
+ const put = await this.content.put(bytes);
351
+ if (put.__loomContent.length > seenCrypto_1.MAX_SEALED_ADDRESS_CHARS)
352
+ return refused("seen-address-invalid", "the content store returned an unexpectedly long address");
353
+ ref = { ref: put, bytes: bytes.length };
354
+ }
355
+ shapes.push(shape.data);
356
+ refs.push(ref);
357
+ }
358
+ const body = {
359
+ experienceId,
360
+ versionHash: snapshot.versionHash,
361
+ viewerId,
362
+ ...((0, ui_core_1.effectiveViewer)(input.viewer, input.audience)?.clearance !== undefined && {
363
+ clearance: String((0, ui_core_1.effectiveViewer)(input.viewer, input.audience)?.clearance),
364
+ }),
365
+ entries: shapes.map((s, i) => ({ ...s, ...(refs[i] !== undefined && { ref: refs[i] }) })),
366
+ omitted: snapshot.omitted,
367
+ restricted: snapshot.restricted === true || checked.restricted,
368
+ };
369
+ const digest = (0, seenCrypto_1.keyedDigest)(dataKey.mac, JSON.stringify(body));
370
+ for (let attempt = 0; attempt < MAX_APPEND_RETRIES; attempt++) {
371
+ const recent = await this.lastRecords(key, FLOOR_SCAN);
372
+ const last = recent[recent.length - 1];
373
+ if (last?.type === exports.VIEWER_ERASED)
374
+ return refused("seen-viewer-erased", "this viewer was erased; nothing more is recorded for them");
375
+ const tip = last?.sequence ?? 0;
376
+ const floor = floorOf(recent);
377
+ const lastView = [...recent].reverse().find((r) => r.type === exports.VIEW_RECORDED);
378
+ if (lastView !== undefined &&
379
+ lastView.sequence > floor &&
380
+ lastView.payload?.digest === digest)
381
+ return { ok: true, value: { recorded: false, sequence: lastView.sequence } };
382
+ const sequence = tip + 1;
383
+ const payload = {
384
+ ...body,
385
+ entries: shapes.map((s, i) => {
386
+ const ref = refs[i];
387
+ return {
388
+ ...s,
389
+ ...(ref !== undefined && {
390
+ sealed: (0, seenCrypto_1.sealRef)(dataKey.enc, (0, seenCrypto_1.refAad)(experienceId, viewerId, sequence, i), ref.ref, ref.bytes),
391
+ }),
392
+ };
393
+ }),
394
+ digest,
395
+ floor,
396
+ };
397
+ // The record must parse as what a READ will parse: one that does not would be counted
398
+ // `malformed` forever (the log has no delete), so it is refused here.
399
+ if (!exports.ViewRecordPayload.safeParse(payload).success)
400
+ return refused("seen-record-invalid", "the record would not be readable (a field is out of bounds)");
401
+ // Re-read the key IMMEDIATELY before the append: an erasure that landed since `getOrCreate`
402
+ // (a destroyed key, or a tombstone not yet written) must not be followed by a sealed record.
403
+ let current;
404
+ try {
405
+ current = await this.keys.get((0, seenCrypto_1.seenKeySubject)(key));
406
+ }
407
+ catch {
408
+ return refused("seen-key-unavailable", "the viewer's data key is unavailable (erased, or the key store failed): nothing is recorded");
409
+ }
410
+ if (current === null)
411
+ return refused("seen-viewer-erased", "this viewer was erased; nothing more is recorded for them");
412
+ const out = await this.events.append(key, tip, {
413
+ type: exports.VIEW_RECORDED,
414
+ at: this.now(),
415
+ payload,
416
+ });
417
+ if (out.ok) {
418
+ await this.rollOff(key, out.sequence, floor);
419
+ return { ok: true, value: { recorded: true, sequence: out.sequence } };
420
+ }
421
+ }
422
+ return refused("seen-contended", "could not append after repeated lost races");
423
+ }
424
+ /**
425
+ * Retention: append a watermark when enough has aged out. Best effort (a lost race or a failure
426
+ * only defers it: reads and the collector apply the window themselves, so correctness never
427
+ * depends on a watermark having been written). `slack` batches the writes: one watermark per
428
+ * `slack` roll-offs, not one per view.
429
+ */
430
+ async rollOff(key, tip, floor) {
431
+ try {
432
+ const countTrigger = tip - floor > this.maxViews + this.slack;
433
+ // The persisted (irreversible) age watermark is conservative by `maxSkewMs`: a writer whose
434
+ // clock runs ahead cannot roll off views still inside the window by true time (see SeenRetention).
435
+ const cutoff = this.maxAgeMs === undefined
436
+ ? undefined
437
+ : Date.parse(this.now()) - this.maxAgeMs - this.maxSkewMs;
438
+ let ageTrigger = false;
439
+ if (cutoff !== undefined) {
440
+ const oldest = (this.events.readAfter !== undefined
441
+ ? await this.events.readAfter(key, floor, 1)
442
+ : await this.after(key, floor))[0];
443
+ ageTrigger = oldest !== undefined && Date.parse(oldest.at) < cutoff;
444
+ }
445
+ if (!countTrigger && !ageTrigger)
446
+ return;
447
+ // The window is read only when a watermark may be due (about once per `slack` views), so the
448
+ // amortized cost per record is O(window / slack), and the floor is exact: it leaves `maxViews`
449
+ // VIEWS (not records) readable.
450
+ const window = await this.after(key, floor);
451
+ const views = window.filter((r) => r.type === exports.VIEW_RECORDED);
452
+ let next = floor;
453
+ if (views.length > this.maxViews)
454
+ next = views[views.length - this.maxViews - 1].sequence;
455
+ let byAge = floor;
456
+ if (cutoff !== undefined)
457
+ for (const r of window)
458
+ if (Date.parse(r.at) < cutoff)
459
+ byAge = Math.max(byAge, r.sequence);
460
+ else
461
+ break;
462
+ // age-only roll-off is batched too (one watermark per `slack` expired views, not one per view)
463
+ if (byAge - floor >= this.slack || (countTrigger && byAge > floor))
464
+ next = Math.max(next, byAge);
465
+ if (next > floor)
466
+ await this.events.append(key, tip, {
467
+ type: exports.VIEWS_RETAINED,
468
+ at: this.now(),
469
+ payload: { floor: Math.min(next, tip) },
470
+ });
471
+ }
472
+ catch {
473
+ /* deferred to the next record */
474
+ }
475
+ }
476
+ /**
477
+ * Erase a viewer: destroy their data key (the irreversible step), then write a tombstone. After
478
+ * it, the stream holds no decryptable ref, `read` reports `erased: true`, and `record` refuses.
479
+ * Idempotent and safe to rerun after a crash (a rerun completes whichever half is missing). The
480
+ * value BLOBS the viewer cited are reclaimed by the next `collectSeenGarbage` pass (unless another
481
+ * viewer or the lineage still cites them). The stream's structure stays (see the header).
482
+ * AUTHORIZATION IS THE HOST'S: erasing a (experience, viewer) pair that never existed still
483
+ * writes a tombstone stream (and permanently bars that viewer from recording there), so the host
484
+ * decides who may erase what; this method performs no check.
485
+ */
486
+ async eraseViewer(input) {
487
+ const { experienceId, viewerId } = input;
488
+ if (!validId(experienceId) || !validId(viewerId))
489
+ return refused("seen-id-invalid", "experienceId and viewerId are 1-256 character strings");
490
+ const key = (0, exports.seenStreamKey)(experienceId, viewerId);
491
+ try {
492
+ await this.keys.destroy((0, seenCrypto_1.seenKeySubject)(key));
493
+ }
494
+ catch {
495
+ return refused("seen-key-unavailable", "the key store failed to destroy the key: rerun the erasure");
496
+ }
497
+ for (let attempt = 0; attempt < MAX_APPEND_RETRIES; attempt++) {
498
+ const last = (await this.lastRecords(key, 1))[0];
499
+ if (last?.type === exports.VIEWER_ERASED)
500
+ return { ok: true, value: { erased: true } };
501
+ const out = await this.events.append(key, last?.sequence ?? 0, {
502
+ type: exports.VIEWER_ERASED,
503
+ at: this.now(),
504
+ payload: { experienceId, viewerId },
505
+ });
506
+ if (out.ok)
507
+ return { ok: true, value: { erased: true } };
508
+ }
509
+ return refused("seen-erase-incomplete", "the key is destroyed (the viewer is unreadable) but the tombstone was not written: rerun the erasure");
510
+ }
511
+ /**
512
+ * Erase ONE data subject across every experience: enumerate the seen streams (`listStreams`;
513
+ * refused with `seen-enumeration-unavailable` if the store cannot), and erase `viewerId`'s stream in
514
+ * each experience (see {@link eraseViewer}, including its authorization note). Idempotent and
515
+ * resumable: a rerun skips what is erased and completes what is not. A per-experience failure does
516
+ * not stop the others; `complete` is true only when every experience was erased.
517
+ *
518
+ * COVERAGE: only streams that EXIST when enumeration runs are erased. A stream created after
519
+ * that point (a new view by this viewer during the erase) is not covered: the host must serialize
520
+ * erasure against new views for the subject, or rerun. An orphan key with no stream (a crash
521
+ * between `getOrCreate` and the first append) is not destroyed by this enumeration; it holds no
522
+ * data (a key and nothing sealed under it).
523
+ */
524
+ async eraseViewerEverywhere(input) {
525
+ const { viewerId } = input;
526
+ if (!validId(viewerId))
527
+ return refused("seen-id-invalid", "viewerId is a 1-256 character string");
528
+ if (this.events.listStreams === undefined)
529
+ return refused("seen-enumeration-unavailable", "the store cannot enumerate its streams, so a viewer cannot be erased across experiences");
530
+ const experiences = [];
531
+ try {
532
+ for await (const key of this.events.listStreams()) {
533
+ const parsed = parseStreamKey(key);
534
+ if (parsed !== undefined && parsed.viewerId === viewerId)
535
+ experiences.push(parsed.experienceId);
536
+ }
537
+ }
538
+ catch {
539
+ return refused("seen-enumeration-unavailable", "listing the seen streams failed: rerun");
540
+ }
541
+ const results = [];
542
+ for (const experienceId of experiences) {
543
+ const r = await this.eraseViewer({ experienceId, viewerId });
544
+ results.push(r.ok ? { experienceId, erased: true } : { experienceId, erased: false, refusal: r.refusal });
545
+ }
546
+ return { ok: true, value: { results, complete: results.every((r) => r.erased) } };
547
+ }
548
+ /**
549
+ * What `viewerId` was shown, newest first (at most `limit`, default 20, hard cap
550
+ * `MAX_SEEN_READ`), as seen by `reader`: entries the reader may not see are withheld (and
551
+ * `restricted` says only that something was), BEFORE any value blob is fetched. Only views inside
552
+ * the retention window are returned. A malformed record is counted in `malformed`, never thrown.
553
+ * `until` (ISO time) keeps records at or before it: the view as of then. An erased viewer (key
554
+ * destroyed or tombstoned) returns `{ erased: true, views: [] }`: nothing is decryptable, so
555
+ * nothing is shown.
556
+ */
557
+ async read(input) {
558
+ const { experienceId, viewerId } = input;
559
+ if (!validId(experienceId) || !validId(viewerId))
560
+ return refused("seen-id-invalid", "experienceId and viewerId are 1-256 character strings");
561
+ const limit = Math.max(1, Math.min(exports.MAX_SEEN_READ, Number.isInteger(input.limit) ? input.limit : 20));
562
+ const key = (0, exports.seenStreamKey)(experienceId, viewerId);
563
+ const tail = await this.lastRecords(key, FLOOR_SCAN);
564
+ const tipRec = tail[tail.length - 1];
565
+ if (tipRec === undefined)
566
+ return { ok: true, value: { views: [], malformed: 0, erased: false } };
567
+ let master;
568
+ try {
569
+ master = await this.keys.get((0, seenCrypto_1.seenKeySubject)(key));
570
+ }
571
+ catch {
572
+ return refused("seen-key-unavailable", "the key store failed: nothing is shown");
573
+ }
574
+ if (tipRec.type === exports.VIEWER_ERASED || master === null)
575
+ return { ok: true, value: { views: [], malformed: 0, erased: true } };
576
+ const dataKey = (0, seenCrypto_1.deriveSeenKeys)(master);
577
+ let malformed = 0;
578
+ const picked = [];
579
+ const live = this.liveViews(await this.after(key, floorOf(tail)));
580
+ for (let i = live.length - 1; i >= 0 && picked.length < limit; i--) {
581
+ const rec = live[i];
582
+ if (input.until !== undefined && rec.at > input.until)
583
+ continue;
584
+ const parsed = exports.ViewRecordPayload.safeParse(rec.payload);
585
+ if (!parsed.success ||
586
+ parsed.data.experienceId !== experienceId ||
587
+ parsed.data.viewerId !== viewerId) {
588
+ malformed += 1;
589
+ continue;
590
+ }
591
+ picked.push({ rec, p: parsed.data });
592
+ }
593
+ // The line is folded ONCE for the whole read (not once per distinct version).
594
+ const got = await this.lineage.getMany(experienceId, picked.map(({ p }) => p.versionHash));
595
+ const artifacts = got.ok ? got.value : new Map();
596
+ const views = [];
597
+ for (const { rec, p } of picked) {
598
+ const shown = (0, ui_core_1.redactSeen)(p.entries.map((e) => ({
599
+ source: e.source,
600
+ nodeIds: e.nodeIds,
601
+ class: e.class,
602
+ state: e.state,
603
+ ...(e.status !== undefined && { status: e.status }),
604
+ ...(e.bytes !== undefined && { bytes: e.bytes }),
605
+ })), artifacts.get(p.versionHash), {
606
+ ...(input.reader !== undefined && { viewer: input.reader }),
607
+ ...(input.audience !== undefined && { audience: input.audience }),
608
+ ...(0, ui_core_1.mergeReadPolicy)(this.policy, input),
609
+ });
610
+ // A ref belongs to the entry at its POSITION in the record (`indexes`), never to a source:
611
+ // two entries may not be told apart by what they name.
612
+ let restricted = p.restricted || shown.restricted;
613
+ const resolved = [];
614
+ for (const [k, e] of shown.entries.entries()) {
615
+ if (e.state !== "seen") {
616
+ resolved.push(e);
617
+ continue;
618
+ }
619
+ const idx = shown.indexes[k];
620
+ const sealed = p.entries[idx]?.sealed;
621
+ if (sealed === undefined) {
622
+ resolved.push({ ...e, state: "absent" });
623
+ continue;
624
+ }
625
+ const opened = this.openRef(dataKey.enc, experienceId, viewerId, rec.sequence, idx, sealed);
626
+ if (opened === undefined) {
627
+ // tampered, transplanted, or sealed under another key: withheld, never a plaintext fallback
628
+ restricted = true;
629
+ continue;
630
+ }
631
+ const { ref, bytes: size } = opened;
632
+ const bytes = await this.content.get(ref);
633
+ if (bytes === undefined || bytes === null) {
634
+ // erased or lost: the value is gone, the fact that it was seen is not
635
+ resolved.push({ ...e, state: "absent" });
636
+ continue;
637
+ }
638
+ if ((0, plugin_content_1.contentAddress)(bytes) !== ref.__loomContent) {
639
+ // the store handed back bytes that are not what the manifest named
640
+ resolved.push({ ...e, state: "corrupt" });
641
+ continue;
642
+ }
643
+ try {
644
+ resolved.push({ ...e, value: (0, plugin_content_1.decodeValue)(bytes), bytes: size });
645
+ }
646
+ catch {
647
+ restricted = true;
648
+ }
649
+ }
650
+ views.push({
651
+ sequence: rec.sequence,
652
+ at: rec.at,
653
+ versionHash: p.versionHash,
654
+ entries: resolved,
655
+ restricted,
656
+ omitted: p.omitted,
657
+ });
658
+ }
659
+ return { ok: true, value: { views, malformed, erased: false } };
660
+ }
661
+ openRef(enc, experienceId, viewerId, sequence, index, sealed) {
662
+ return (0, seenCrypto_1.openRef)(enc, (0, seenCrypto_1.refAad)(experienceId, viewerId, sequence, index), sealed);
663
+ }
664
+ /**
665
+ * Call `visit` with the content address of every DECRYPTABLE ref inside every viewer's retention
666
+ * window (kept conservatively: views inside `maxAgeMs + maxSkewMs`; with out-of-order timestamps within `maxSkewMs` at the `maxViews` cap this window can differ from the exact read window's newest-N, in the direction of keeping MORE): the seen log's half of the
667
+ * collector's root set. Erased viewers (key ABSENT: the shred path) and rolled-off views
668
+ * contribute nothing, which is exactly what lets their unique blobs be swept. THROWS if the store
669
+ * cannot enumerate its streams or the key store fails (an unreadable root is not an absent one).
670
+ *
671
+ * Returns what it could NOT read inside the live window (a stream key that does not round-trip, a
672
+ * record that fails to parse, a sealed ref that fails to open under a PRESENT key). Those are not
673
+ * absent either: whatever they cite is unrooted here, so a collector must refuse to sweep on a
674
+ * non-zero count (see `collectSeenGarbage`).
675
+ *
676
+ * `visitVersion` (optional) is called with the (experience, version) of every live view: the seen log
677
+ * READS that version's artifact from the lineage, so a collector that reclaims old versions must
678
+ * keep it (see `collectSeenRoots`).
679
+ */
680
+ async forEachLiveRef(visit, visitVersion) {
681
+ if (this.events.listStreams === undefined)
682
+ throw new Error("SeenLog.forEachLiveRef: the store cannot enumerate its streams");
683
+ let streams = 0;
684
+ let records = 0;
685
+ let refs = 0;
686
+ const ex = { streams: [], records: [], refs: [] };
687
+ const note = (list, what) => {
688
+ if (list.length < exports.UNREADABLE_EXAMPLES)
689
+ list.push(what);
690
+ };
691
+ for await (const key of this.events.listStreams()) {
692
+ const id = parseStreamKey(key);
693
+ if (id === undefined) {
694
+ streams += 1;
695
+ note(ex.streams, key);
696
+ continue;
697
+ }
698
+ const { experienceId, viewerId } = id;
699
+ const tail = await this.lastRecords(key, FLOOR_SCAN);
700
+ const tipRec = tail[tail.length - 1];
701
+ if (tipRec === undefined || tipRec.type === exports.VIEWER_ERASED)
702
+ continue;
703
+ const master = await this.keys.get((0, seenCrypto_1.seenKeySubject)(key)); // a throw propagates: abort the pass
704
+ if (master === null)
705
+ continue; // erased (key absent): nothing here is a root, by design
706
+ const { enc } = (0, seenCrypto_1.deriveSeenKeys)(master);
707
+ for (const rec of this.liveViews(await this.after(key, floorOf(tail)), true)) {
708
+ const parsed = exports.ViewRecordPayload.safeParse(rec.payload);
709
+ if (!parsed.success ||
710
+ parsed.data.experienceId !== experienceId ||
711
+ parsed.data.viewerId !== viewerId) {
712
+ records += 1;
713
+ note(ex.records, `${key}@${rec.sequence}`);
714
+ continue;
715
+ }
716
+ visitVersion?.(experienceId, parsed.data.versionHash);
717
+ for (const [i, e] of parsed.data.entries.entries()) {
718
+ if (e.sealed === undefined)
719
+ continue;
720
+ const opened = this.openRef(enc, experienceId, viewerId, rec.sequence, i, e.sealed);
721
+ if (opened === undefined) {
722
+ refs += 1;
723
+ note(ex.refs, `${key}@${rec.sequence}#${i}`);
724
+ }
725
+ else
726
+ visit(opened.ref.__loomContent);
727
+ }
728
+ }
729
+ }
730
+ return { streams, records, refs, examples: ex };
731
+ }
732
+ }
733
+ exports.SeenLog = SeenLog;
734
+ /** Convenience: the manifest stream a `SeenLog` over an `EventStore` should use. */
735
+ exports.seenStoreOptions = {
736
+ streamPrefix: exports.SEEN_STREAM_PREFIX,
737
+ workflowId: exports.SEEN_WORKFLOW_ID,
738
+ };
739
+ //# sourceMappingURL=seen.js.map