@loomwork/compliance 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,326 @@
1
+ import type { EventEnvelope, ExecutionState } from "@loomwork/core";
2
+ import {
3
+ type DestructionProof,
4
+ type EventStore,
5
+ erasureWindowView,
6
+ isPayloadShredded,
7
+ } from "@loomwork/event-runtime";
8
+ import { foldEvents } from "@loomwork/replay-vm";
9
+ import type { SubjectIndex } from "./SubjectIndex";
10
+ import { claimCheckRefsOf } from "./eraseSubject";
11
+
12
+ // ── T3-R2/R3 — subject access & portability (GDPR Art. 15/20) ──────────────────
13
+ //
14
+ // An access/portability request is a fold: gather the subject's executions (from the
15
+ // SubjectIndex), read each execution's log, fold it to state. Event-sourcing makes the
16
+ // authoritative answer already-present — this just assembles it into a portable shape.
17
+ //
18
+ // Note on erased subjects: if a field was crypto-shredded, reading through a default
19
+ // EncryptingEventStore THROWS (G1). Export a partially-erased subject through a store in
20
+ // tolerant mode (`onShredded: "tolerate"`, K10-R4) — the shredded fields come back as
21
+ // `{ $shredded }` and the export honestly shows what remains rather than failing.
22
+
23
+ /**
24
+ * A range of an execution's ORIGINAL log that neither the surviving log nor the compaction archive
25
+ * holds as events (structurally `LogLifecycle.archiveCoverage`'s `UnarchivedRange`). `"legacy"`:
26
+ * removed by a compaction that predates the archive; only its folded checkpoint remains
27
+ * (`legacyCheckpoints`, when the archive preserved it). `"missing"`: an archiving compaction removed
28
+ * it and no archive segment holds it now (a wiped, swapped or truncated archive).
29
+ */
30
+ export interface UnarchivedRange {
31
+ readonly from: number;
32
+ readonly through: number;
33
+ readonly why: "legacy" | "missing";
34
+ }
35
+
36
+ /**
37
+ * ADR 0140 - one claim-checked tool output a subject's log cites, RESOLVED from the content store: the log
38
+ * holds only `{ ref, size, digest }`, so an access/portability export that returned the envelope alone
39
+ * would omit the data. `value` is the decoded JSON; `bytesBase64` a stream-typed (non-JSON) blob;
40
+ * `missing` a blob the store no longer holds (erased or reclaimed).
41
+ */
42
+ export interface ClaimCheckedBlob {
43
+ /** The blob's content address. */
44
+ address: string;
45
+ size?: number;
46
+ digest?: unknown;
47
+ value?: unknown;
48
+ bytesBase64?: string;
49
+ missing?: true;
50
+ /**
51
+ * ADR 0143 D5/D8: the log CITES this address, but a sealing store could not prove the subject owns the
52
+ * blob (another subject's, absent, or corrupt): it is NOT the subject's data, so nothing of it (not even
53
+ * the cited size) is reported. Never present beside `value`/`bytesBase64`.
54
+ */
55
+ foreign?: true;
56
+ }
57
+
58
+ /** One execution's contribution to a subject export. */
59
+ export interface SubjectExecutionExport {
60
+ executionId: string;
61
+ workflowId: string;
62
+ status: ExecutionState["status"];
63
+ /** Folded state — the Art. 15 "access" view (the current data held about the subject). */
64
+ state: ExecutionState;
65
+ /** Raw event log — the Art. 20 "portability" view (machine-readable). Omitted if not requested. */
66
+ events?: EventEnvelope[];
67
+ /**
68
+ * ADR 0140 - the claim-checked tool outputs this execution's events cite, resolved (see
69
+ * {@link ClaimCheckedBlob}). Present only when `content` was given and the log cites any.
70
+ */
71
+ claimChecked?: ClaimCheckedBlob[];
72
+ /**
73
+ * ADR 0138 - checkpoints of a log compacted BEFORE the compaction archive existed (their removed
74
+ * events survive only as a folded state). Present only when such an execution is exported.
75
+ */
76
+ legacyCheckpoints?: unknown[];
77
+ /**
78
+ * ADR 0139 - the reserved streams (inbox, topic streams, quarantine copies) of the chain this execution
79
+ * heads, with the envelopes (ids, timestamps, sender metadata) a trim or delete MOVED to the archive,
80
+ * and every range nothing can return. Present only when the archive holds such a stream.
81
+ */
82
+ reservedStreams?: Array<{
83
+ streamId: string;
84
+ events: EventEnvelope[];
85
+ unarchived: UnarchivedRange[];
86
+ }>;
87
+ /**
88
+ * ADR 0138 - EVERY range of this execution's original log the export could not return as events
89
+ * (see {@link UnarchivedRange}); `[]` is a complete log. Present whenever an `archive` was given, so
90
+ * a data subject (or their processor) can see each gap instead of a silently shorter export. The
91
+ * `events` and `state` of an execution with a non-empty list are INCOMPLETE for those ranges.
92
+ * Absent only when the execution was read through the crypto-shred erasure window (its log is
93
+ * unreadable by design) or no archive was configured.
94
+ */
95
+ unarchived?: UnarchivedRange[];
96
+ /**
97
+ * `true` when this execution was read through the crypto-shred erasure window (the key is
98
+ * destroyed, the tombstone not yet written): the export is the redacted view and `unarchived` is
99
+ * absent BECAUSE of that, not because the archive is complete.
100
+ */
101
+ erasureWindow?: true;
102
+ }
103
+
104
+ /** A subject's full data export across every execution concerning them. */
105
+ export interface SubjectExport {
106
+ subjectId: string;
107
+ executions: SubjectExecutionExport[];
108
+ }
109
+
110
+ export interface ExportSubjectOptions {
111
+ index: SubjectIndex;
112
+ eventStore: Pick<EventStore, "read">;
113
+ /**
114
+ * ADR 0138 - the compaction archive's audit read (`LogLifecycle`). Compaction MOVES events out of the
115
+ * log into the archive, and an Art. 15/20 export that read only the surviving log would omit the
116
+ * subject's data and fold a compacted log wrongly. Given this, each execution is read as
117
+ * `archive ∪ log` (and folded from that). A range only a pre-archive checkpoint remembers is
118
+ * returned in `legacyCheckpoints`, and every range the archive and log together cannot show is
119
+ * listed in each execution's `unarchived` (never silently dropped).
120
+ */
121
+ archive?: {
122
+ archiveCoverage(
123
+ executionId: string
124
+ ): Promise<{ readonly unarchived: readonly UnarchivedRange[] }>;
125
+ readWithArchive(
126
+ executionId: string,
127
+ opts?: { readonly allowIncomplete?: boolean }
128
+ ): Promise<EventEnvelope[]>;
129
+ readLegacyCheckpoints?(executionId: string): Promise<unknown[]>;
130
+ /**
131
+ * ADR 0139 - the reserved streams of the chain this execution heads (inbox, topic streams,
132
+ * quarantine copies) with the envelopes a trim or delete MOVED to the archive, and every range
133
+ * nothing can return. `LogLifecycle.readReservedArchive`.
134
+ */
135
+ readReservedArchive?(executionId: string): Promise<
136
+ Array<{
137
+ streamId: string;
138
+ events: EventEnvelope[];
139
+ unarchived: readonly UnarchivedRange[];
140
+ }>
141
+ >;
142
+ };
143
+ /**
144
+ * ADR 0140 - the content store claim-checked tool outputs were offloaded to. Given, each execution's
145
+ * export resolves the blobs its events cite (`claimChecked`), so the export is the data, not just refs.
146
+ */
147
+ content?: {
148
+ get(
149
+ ref: { __loomContent: string },
150
+ access?: { executionId: string; workflowId: string }
151
+ ): Promise<Uint8Array | null>;
152
+ /** A sealing store's ownership proof (ADR 0143 D5): asked before a cited blob is opened. */
153
+ ownedBy?(
154
+ ref: { __loomContent: string },
155
+ access: { executionId: string; workflowId: string }
156
+ ): Promise<boolean>;
157
+ };
158
+ /** Include the raw event log per execution (Art. 20 portability). Default `true`. */
159
+ includeEvents?: boolean;
160
+ /** T3-R2.4 — scope the export to a tenant (default: the single partition). */
161
+ tenant?: string;
162
+ /**
163
+ * ADR 0130 D7 (round 5) - proof that the key store destroyed the subject's key
164
+ * (`(s) => keys.wasDestroyed(s)`). A PROOF function only: returning `true` for a key that was
165
+ * NOT destroyed in the authoritative store produces a false erasure view, so never stub it (use
166
+ * the per-call {@link ExportSubjectOptions.force} for the operator's assertion). Without it, a stream whose key is unavailable is
167
+ * REFUSED (`KeyUnavailableError`) even if the log records an erase intent: a dangling intent
168
+ * (the key still lives elsewhere) is not an erasure.
169
+ */
170
+ destroyed?: DestructionProof;
171
+ /**
172
+ * ADR 0130 D7 (round 6) - OPERATOR OVERRIDE, per call (mirrors `ShredOptions.force`): asserts the
173
+ * key was destroyed in the authoritative key store, for a store that cannot prove it. A wrong
174
+ * assertion exports a redacted view of a subject whose key lives on.
175
+ */
176
+ force?: "key-destroyed-elsewhere";
177
+ }
178
+
179
+ /**
180
+ * Assemble a data-subject export (Art. 15 access + Art. 20 portability): every execution the
181
+ * {@link SubjectIndex} attributes to `subjectId` (within `tenant`), each folded to its current
182
+ * state and (by default) accompanied by its raw log. Executions are emitted in the index's
183
+ * deterministic (sorted) order. An unknown subject yields an empty `executions` array — not an
184
+ * error. Scoping to a tenant is what keeps one tenant's `"alice"` out of another's export.
185
+ */
186
+ export async function exportSubject(
187
+ subjectId: string,
188
+ options: ExportSubjectOptions
189
+ ): Promise<SubjectExport> {
190
+ const includeEvents = options.includeEvents ?? true;
191
+ const executionIds = options.index.executionsOf(subjectId, options.tenant);
192
+ const executions: SubjectExecutionExport[] = [];
193
+ for (const executionId of executionIds) {
194
+ let events: EventEnvelope[];
195
+ let unarchived: UnarchivedRange[] | undefined;
196
+ let erasureWindow = false;
197
+ try {
198
+ events =
199
+ options.archive !== undefined
200
+ ? await options.archive.readWithArchive(executionId, { allowIncomplete: true })
201
+ : await options.eventStore.read(executionId);
202
+ if (options.archive !== undefined) {
203
+ unarchived = [...(await options.archive.archiveCoverage(executionId)).unarchived];
204
+ }
205
+ } catch (err) {
206
+ // The ShredIncomplete window (key destroyed, tombstone not yet written): the redacted view,
207
+ // only with the log's durable erase intent. A node that lacks the key is not an erasure.
208
+ const window = await erasureWindowView(
209
+ err,
210
+ options.force === "key-destroyed-elsewhere" ? () => true : options.destroyed
211
+ );
212
+ if (window === undefined) throw err;
213
+ events = [...window];
214
+ erasureWindow = true;
215
+ unarchived = undefined;
216
+ }
217
+ if (events.length === 0) continue; // deleted (retention) — nothing left to export
218
+ // Seed the fold with identity — foldEvents takes executionId/workflowId from `initial`,
219
+ // not the events, so an unseeded fold would carry empty identity fields.
220
+ const workflowId = events[0]?.workflowId ?? "";
221
+ const state = foldEvents(events, { executionId, workflowId });
222
+ const legacy = (await options.archive?.readLegacyCheckpoints?.(executionId)) ?? [];
223
+ // (not through the erasure window: the log is unreadable by design there)
224
+ const reserved = erasureWindow
225
+ ? []
226
+ : ((await options.archive?.readReservedArchive?.(executionId)) ?? []);
227
+ const claimChecked =
228
+ options.content === undefined
229
+ ? []
230
+ : await resolveBlobs(options.content, events, { executionId, workflowId });
231
+ executions.push({
232
+ executionId,
233
+ workflowId,
234
+ status: state.status,
235
+ state,
236
+ ...(includeEvents && { events }),
237
+ ...(claimChecked.length > 0 && { claimChecked }),
238
+ ...(legacy.length > 0 && { legacyCheckpoints: legacy }),
239
+ ...(reserved.length > 0 && {
240
+ reservedStreams: reserved.map((r) => ({ ...r, unarchived: [...r.unarchived] })),
241
+ }),
242
+ ...(unarchived !== undefined && { unarchived }),
243
+ ...(erasureWindow && { erasureWindow: true as const }),
244
+ });
245
+ }
246
+ return { subjectId, executions };
247
+ }
248
+
249
+ /** Resolve every claim-check envelope the events cite (deduped by address), in first-seen order. */
250
+ async function resolveBlobs(
251
+ content: NonNullable<ExportSubjectOptions["content"]>,
252
+ events: readonly EventEnvelope[],
253
+ // ADR 0143: the execution the blobs belong to, so a store that seals per subject opens them with its key.
254
+ access: { executionId: string; workflowId: string }
255
+ ): Promise<ClaimCheckedBlob[]> {
256
+ const meta = new Map<string, { size?: number; digest?: unknown; codec?: string }>();
257
+ for (const e of events) collectEnvelopes(e.payload, meta);
258
+ const out: ClaimCheckedBlob[] = [];
259
+ for (const [address, m] of meta) {
260
+ let bytes: Uint8Array | null;
261
+ try {
262
+ // A log may CITE a blob it never put (an input, a resume payload, a child's result): over a sealing
263
+ // store the subject must PROVE ownership before the blob is opened or reported as theirs.
264
+ if (
265
+ typeof content.ownedBy === "function" &&
266
+ !(await content.ownedBy({ __loomContent: address }, access))
267
+ ) {
268
+ out.push({ address, foreign: true });
269
+ continue;
270
+ }
271
+ bytes = await content.get({ __loomContent: address }, access);
272
+ } catch (err) {
273
+ // A sealed blob whose subject key was destroyed is ERASED (the redacted view shows it as missing);
274
+ // a key merely absent from this node, or an unopenable OWNED blob, stays loud: never garbage, never a lie.
275
+ if (!isPayloadShredded(err)) throw err;
276
+ bytes = null;
277
+ }
278
+ const base = {
279
+ address,
280
+ ...(m.size !== undefined && { size: m.size }),
281
+ ...(m.digest !== undefined && { digest: m.digest }),
282
+ };
283
+ if (bytes === null) {
284
+ out.push({ ...base, missing: true });
285
+ } else if (m.codec === undefined || m.codec === "json") {
286
+ out.push({ ...base, value: JSON.parse(new TextDecoder().decode(bytes)) as unknown });
287
+ } else {
288
+ out.push({ ...base, bytesBase64: Buffer.from(bytes).toString("base64") });
289
+ }
290
+ }
291
+ return out;
292
+ }
293
+
294
+ function collectEnvelopes(
295
+ value: unknown,
296
+ into: Map<string, { size?: number; digest?: unknown; codec?: string }>
297
+ ): void {
298
+ const addresses = claimCheckRefsOf(value);
299
+ if (addresses.size === 0) return;
300
+ const stack: unknown[] = [value];
301
+ const seen = new WeakSet<object>();
302
+ while (stack.length > 0) {
303
+ const cur = stack.pop();
304
+ if (typeof cur !== "object" || cur === null || seen.has(cur)) continue;
305
+ seen.add(cur);
306
+ const env = cur as {
307
+ __loomClaimCheck?: unknown;
308
+ ref?: { __loomContent?: unknown };
309
+ size?: unknown;
310
+ digest?: unknown;
311
+ codec?: unknown;
312
+ };
313
+ const address = env.__loomClaimCheck === true ? env.ref?.__loomContent : undefined;
314
+ if (typeof address === "string" && !into.has(address)) {
315
+ into.set(address, {
316
+ ...(typeof env.size === "number" && { size: env.size }),
317
+ ...(env.digest !== undefined && { digest: env.digest }),
318
+ ...(typeof env.codec === "string" && { codec: env.codec }),
319
+ });
320
+ }
321
+ for (const [k, item] of Object.entries(cur)) {
322
+ if (typeof address === "string" && k === "ref") continue;
323
+ stack.push(item);
324
+ }
325
+ }
326
+ }
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ export {
2
+ DEFAULT_TENANT,
3
+ SubjectIndex,
4
+ type SubjectIndexOptions,
5
+ type SubjectResolver,
6
+ type TenantResolver,
7
+ } from "./SubjectIndex";
8
+ export {
9
+ exportSubject,
10
+ type ExportSubjectOptions,
11
+ type ClaimCheckedBlob,
12
+ type SubjectExecutionExport,
13
+ type SubjectExport,
14
+ type UnarchivedRange,
15
+ } from "./exportSubject";
16
+ export {
17
+ ArchivedCopiesRemainError,
18
+ ContentCopiesRemainError,
19
+ ControlStreamKeyError,
20
+ claimCheckRefsOf,
21
+ trustedClaimCheckRefsOf,
22
+ SenderRedactionUnsupportedError,
23
+ defaultSenderMatches,
24
+ EraseCoordinator,
25
+ ERASURE_STREAM,
26
+ type ArchivedCopiesDeleted,
27
+ type ContentCopiesDeleted,
28
+ type ErasableContent,
29
+ type EraseCoordinatorOptions,
30
+ type ErasableArchive,
31
+ type SenderMatcher,
32
+ type SenderRedaction,
33
+ type EraseSubjectOptions,
34
+ type EraseSubjectResult,
35
+ type Shredder,
36
+ } from "./eraseSubject";