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