@loomwork/compliance 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.
@@ -0,0 +1,1780 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
2
+ import type { EventEnvelope, ExecutionState } from "@loomwork/core";
3
+ import {
4
+ ArchiveConflictError,
5
+ type ArchiveSegment,
6
+ type ArchiveStore,
7
+ type DestructionProof,
8
+ type EventStore,
9
+ type InboxStreams,
10
+ archivedReservedStreams,
11
+ chainIdOfFirst,
12
+ erasureWindowView,
13
+ headerOf,
14
+ inboxStreamsOfChainDeep,
15
+ isAnsweringKind,
16
+ isErasureWindow,
17
+ isKeyUnavailable,
18
+ isPayloadShredded,
19
+ isQuiescent,
20
+ iterateExecutionIds,
21
+ recordsErasureIntent,
22
+ senderDigestOf,
23
+ senderErasedMarker,
24
+ } from "@loomwork/event-runtime";
25
+ import { foldEvents, normalizeSnapshotState } from "@loomwork/replay-vm";
26
+ import { assertSnapshotIdentity } from "@loomwork/snapshot-runtime";
27
+ import { DEFAULT_TENANT, type SubjectIndex } from "./SubjectIndex";
28
+
29
+ // ── T3-R3/R4.5 — subject erasure (GDPR Art. 17, right to be forgotten) ─────────
30
+ //
31
+ // Erasure destroys a subject's crypto key so its sensitive fields become unrecoverable
32
+ // (K10.2). The one hard rule (R4.5): NEVER shred a fold that is still in flight — a live
33
+ // execution reading the just-destroyed key would fail. So erasure is liveness-gated:
34
+ // • all of a subject's executions terminal → shred now.
35
+ // • any still live → PARK the request (durable ERASURE_PENDING on a
36
+ // control stream) and complete it automatically
37
+ // when the last live execution terminates.
38
+ // Parking is honest (the request is recorded, not silently dropped) and survives a restart
39
+ // (the control stream folds back to the pending set).
40
+
41
+ /** Reserved control stream carrying deferred-erasure facts (ERASURE_PENDING). */
42
+ export const ERASURE_STREAM = "__loom_erasure__";
43
+
44
+ const TERMINAL_STATUS = new Set<ExecutionState["status"]>([
45
+ "completed",
46
+ "failed",
47
+ "cancelled",
48
+ "continued",
49
+ ]);
50
+ const TERMINAL_STATUS_OF_EVENT = new Map<string, ExecutionState["status"]>([
51
+ ["EXECUTION_COMPLETED", "completed"],
52
+ ["EXECUTION_FAILED", "failed"],
53
+ ["EXECUTION_CANCELLED", "cancelled"],
54
+ ["EXECUTION_CONTINUED", "continued"],
55
+ ]);
56
+ const TERMINAL_EVENTS = new Set([
57
+ "EXECUTION_COMPLETED",
58
+ "EXECUTION_FAILED",
59
+ "EXECUTION_CANCELLED",
60
+ "EXECUTION_CONTINUED",
61
+ ]);
62
+
63
+ /**
64
+ * A compaction / inbox archive (ADR 0138 / 0139) erasure must reckon with. A PLAINTEXT archive
65
+ * (`sealsSegments` not `true`) holds the subject's moved envelopes in the clear, so a key's destruction
66
+ * does not reach them: erasure DELETES the subject's segments from it (`deleteExecution`). A SEALING
67
+ * archive (`EncryptingArchiveStore`, same KeyStore as the log) is erased by the shred itself.
68
+ */
69
+ export type ErasableArchive = Pick<ArchiveStore, "headers" | "listExecutions" | "read" | "append"> &
70
+ Partial<Pick<ArchiveStore, "deleteExecution" | "replaceSegments">> & {
71
+ readonly sealsSegments?: unknown;
72
+ };
73
+
74
+ /**
75
+ * ADR 0140 - the content store the opt-in agent claim-check offloads tool outputs to. Over a PLAINTEXT store
76
+ * (`sealsContent` not `true`) a key's destruction does not reach a blob, so erasure DELETES the blobs the
77
+ * subject's envelopes cite (those no other, non-erased execution still cites: ADR 0120 owner-set
78
+ * semantics) and records it (`ERASURE_CONTENT_DELETE_INTENDED` then `ERASURE_CONTENT_DELETED`). A store
79
+ * that seals each blob under its owning subject's key (ADR 0143) is erased by the shred itself; the erasure
80
+ * still journals the blobs it covered and deletes the unreadable ciphertext where the store can.
81
+ */
82
+ export interface ErasableContent {
83
+ delete(ref: { __loomContent: string }): Promise<void>;
84
+ /** Optional (ContentStore SPI 1.3.0): a conditional delete that spares a blob re-put since `cutoffMs`. */
85
+ deleteIfUntouchedSince?(ref: { __loomContent: string }, cutoffMs: number): Promise<boolean>;
86
+ touchClock?(): Promise<number>;
87
+ readonly sealsContent?: unknown;
88
+ /**
89
+ * ADR 0143 D5 - the ownership proof a SEALING store offers: does the blob open under the subject of
90
+ * `access` (decrypt and discard)? Erasure deletes only blobs it proves the subject owns; a blob the log
91
+ * merely CITES (a resume payload, an input, another execution's result) is never deleted.
92
+ */
93
+ ownedBy?(
94
+ ref: { __loomContent: string },
95
+ access: { executionId: string; workflowId: string }
96
+ ): Promise<boolean>;
97
+ }
98
+
99
+ /** What an erasure removed from the content store (recorded durably as `ERASURE_CONTENT_DELETED`). */
100
+ export interface ContentCopiesDeleted {
101
+ /** Blobs deleted. */
102
+ deleted: number;
103
+ /** Blobs the subject's envelopes cited that another, non-erased execution still cites (kept). */
104
+ retained: number;
105
+ /**
106
+ * ADR 0143: over a store that SEALS per subject, how many blobs the shred made unreadable (all the
107
+ * subject's cited blobs; `deleted` of them were also physically removed). Absent over a plaintext store.
108
+ */
109
+ sealed?: number;
110
+ /**
111
+ * ADR 0143 D5: over a sealing store, how many cited blobs erasure could NOT prove the subject owns (another
112
+ * subject's blob the log merely cites, an absent one, or one whose key was already destroyed). They are
113
+ * counted in `retained`, never deleted, and never reported as the subject's data.
114
+ */
115
+ foreign?: number;
116
+ /**
117
+ * ADR 0143 D5: over a sealing store, how many cited blobs ownership could not be PROVED for because the
118
+ * proving key is already destroyed (a re-erasure after a direct shred, or a citation added after the
119
+ * intent). Counted in `retained`, never deleted: the ciphertext is unreadable but this erasure cannot say
120
+ * it is the subject's, so it does not claim it.
121
+ */
122
+ unprovable?: number;
123
+ }
124
+
125
+ /** What an erasure removed from the plaintext archives (recorded durably as `ERASURE_ARCHIVE_DELETED`). */
126
+ export interface ArchivedCopiesDeleted {
127
+ /** Executions whose ADR 0138 compaction segments were deleted. */
128
+ executions: string[];
129
+ /** Reserved streams (inbox, topic, registry, quarantine, late runs; ADR 0139) whose archived segments were deleted. */
130
+ reservedStreams: string[];
131
+ }
132
+
133
+ /** One archived reserved stream whose sender fields erasure redacted (ADR 0139 D12). */
134
+ export interface SenderRedaction {
135
+ streamId: string;
136
+ /** How many archived envelopes had the erased subject's identity replaced by `{ erased: true }`. */
137
+ envelopes: number;
138
+ }
139
+
140
+ /** Does `sender` identify the erased subject? */
141
+ export type SenderMatcher = (
142
+ subjectId: string,
143
+ tenant: string,
144
+ sender: { actor?: string; tenant?: string; onBehalfOf?: string }
145
+ ) => boolean;
146
+
147
+ /** The default: the sender's `actor` is the subject id (and, in a tenant partition, the tenant agrees). */
148
+ export const defaultSenderMatches: SenderMatcher = (subjectId, tenant, sender) =>
149
+ sender.actor === subjectId && (tenant === DEFAULT_TENANT || sender.tenant === tenant);
150
+
151
+ /**
152
+ * The staging prefix an EARLIER build used to rewrite a segment list (read, stage, delete, re-add). The
153
+ * rewrite is now one atomic `ArchiveStore.replaceSegments` (SPI 1.1.0) and nothing writes a stage any
154
+ * more; {@link EraseCoordinator.recoverRedactions} still restores one such a build left behind.
155
+ */
156
+ const REDACT_STAGE = "__loom_redact__:";
157
+
158
+ /** The control-stream event types of the erasure's archive work (intent first, then the completion). */
159
+ const ARCHIVE_DELETE_INTENDED = "ERASURE_ARCHIVE_DELETE_INTENDED";
160
+ const ARCHIVE_DELETED = "ERASURE_ARCHIVE_DELETED";
161
+ const CONTENT_DELETE_INTENDED = "ERASURE_CONTENT_DELETE_INTENDED";
162
+ const CONTENT_DELETED = "ERASURE_CONTENT_DELETED";
163
+ const SENDER_REDACTION_INTENDED = "ERASURE_SENDER_REDACTION_INTENDED";
164
+ const SENDER_REDACTED = "ERASURE_SENDER_REDACTED";
165
+ /**
166
+ * Closes `ERASURE_PENDING` rows once their subject is erased: `{ pendingIds }`, the ids of the rows it
167
+ * closes (never a subject). Without it every park ever written was reloaded as parked after a restart,
168
+ * and a later execution of an erased subject was erased automatically when it ended.
169
+ */
170
+ const PENDING_CLEARED = "ERASURE_PENDING_CLEARED";
171
+
172
+ /** ADR 0143 D5: how many distinct citing executions per address the ownership proof is tried under. */
173
+ const MAX_CITERS_PER_ADDRESS = 16;
174
+
175
+ /** How many times a redaction of one key retries a concurrent writer's conflict before it gives up loudly. */
176
+ const REDACT_RETRIES = 16;
177
+
178
+ /**
179
+ * Erasure cannot complete while a plaintext archive still holds the subject's segments and offers no
180
+ * way to delete them. Thrown BEFORE the key is destroyed (nothing was shredded).
181
+ */
182
+ export class ArchivedCopiesRemainError extends Error {
183
+ readonly retryable = false;
184
+ constructor(readonly streams: readonly string[]) {
185
+ super(
186
+ `refusing to erase: a plaintext archive holds the subject's archived copies (${streams.slice(0, 5).join(", ")}${streams.length > 5 ? ` (+${streams.length - 5} more)` : ""}) and has no deleteExecution, so erasure could not remove them (a key's destruction does not reach plaintext). Use an archive that can delete (the in-memory, SQLite and Postgres archives can), or seal the archive under the event store's KeyStore (EncryptingArchiveStore). Nothing was shredded.`
187
+ );
188
+ this.name = "ArchivedCopiesRemainError";
189
+ Object.setPrototypeOf(this, ArchivedCopiesRemainError.prototype);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Erasure cannot complete: a plaintext archive holds other chains' archived inbox envelopes naming the
195
+ * subject as their sender, and it cannot rewrite a segment atomically (`replaceSegments`, ArchiveStore SPI
196
+ * 1.1.0), so the identity could not be redacted. Thrown BEFORE the key is destroyed (nothing was shredded).
197
+ */
198
+ export class SenderRedactionUnsupportedError extends Error {
199
+ readonly retryable = false;
200
+ constructor(readonly streams: readonly string[]) {
201
+ super(
202
+ `refusing to erase: a plaintext archive holds the subject's identity as the sender of archived inbox envelopes (${streams.slice(0, 5).join(", ")}${streams.length > 5 ? ` (+${streams.length - 5} more)` : ""}) and has no replaceSegments (ArchiveStore SPI 1.1.0), so erasure could not redact it. Use an archive that can (the in-memory, SQLite and Postgres archives can), or seal the archive and the senders under the event store's KeyStore (EncryptingArchiveStore). Nothing was shredded.`
203
+ );
204
+ this.name = "SenderRedactionUnsupportedError";
205
+ Object.setPrototypeOf(this, SenderRedactionUnsupportedError.prototype);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Erasure cannot complete: the subject's logs cite claim-checked blobs (ADR 0140) in a content store that
211
+ * erasure cannot delete from, or cannot prove other executions do not cite. Thrown BEFORE the key is
212
+ * destroyed (nothing was shredded).
213
+ */
214
+ export class ContentCopiesRemainError extends Error {
215
+ readonly retryable = false;
216
+ constructor(
217
+ readonly count: number,
218
+ why: string
219
+ ) {
220
+ super(
221
+ `refusing to erase: the subject's executions cite ${count} claim-checked blob(s) in a PLAINTEXT content store and ${why}, so a key's destruction would leave the subject's tool outputs behind. Nothing was shredded.`
222
+ );
223
+ this.name = "ContentCopiesRemainError";
224
+ Object.setPrototypeOf(this, ContentCopiesRemainError.prototype);
225
+ }
226
+ }
227
+
228
+ /** Every claim-check envelope ref `value` cites (iterative, cycle-safe; the `ref` leaf is not descended). */
229
+ export function claimCheckRefsOf(value: unknown, into: Set<string> = new Set()): Set<string> {
230
+ const stack: unknown[] = [value];
231
+ const seen = new WeakSet<object>();
232
+ while (stack.length > 0) {
233
+ const current = stack.pop();
234
+ if (typeof current !== "object" || current === null || seen.has(current)) continue;
235
+ seen.add(current);
236
+ const env = current as { __loomClaimCheck?: unknown; ref?: { __loomContent?: unknown } | null };
237
+ const address = env.__loomClaimCheck === true ? env.ref?.__loomContent : undefined;
238
+ if (typeof address === "string") into.add(address);
239
+ for (const [key, item] of Object.entries(current)) {
240
+ if (typeof address === "string" && key === "ref") continue;
241
+ stack.push(item);
242
+ }
243
+ }
244
+ return into;
245
+ }
246
+
247
+ /**
248
+ * ADR 0140 D11 - the claim-check refs an execution cites at a TRUSTED position: the top-level `output`
249
+ * of a recorded `STEP_COMPLETED` (through a taint wrapper). An envelope the opt-in preset wrote sits
250
+ * exactly there; one NESTED inside a tool's result is tool-authored data and may be forged, so it is not
251
+ * evidence that anyone cites the blob.
252
+ */
253
+ export function trustedClaimCheckRefsOf(
254
+ events: readonly { eventType: string; payload: unknown }[],
255
+ into: Set<string> = new Set()
256
+ ): { refs: Set<string>; sawStep: boolean } {
257
+ let sawStep = false;
258
+ for (const e of events) {
259
+ if (e.eventType !== "STEP_COMPLETED") continue;
260
+ sawStep = true;
261
+ let out = (e.payload as { output?: unknown } | null)?.output;
262
+ if (
263
+ typeof out === "object" &&
264
+ out !== null &&
265
+ (out as { __loomTainted?: unknown }).__loomTainted !== undefined
266
+ ) {
267
+ out = (out as { value?: unknown }).value;
268
+ }
269
+ if (typeof out !== "object" || out === null) continue;
270
+ const env = out as { __loomClaimCheck?: unknown; ref?: { __loomContent?: unknown } | null };
271
+ const address = env.__loomClaimCheck === true ? env.ref?.__loomContent : undefined;
272
+ if (typeof address === "string") into.add(address);
273
+ }
274
+ return { refs: into, sawStep };
275
+ }
276
+
277
+ /**
278
+ * Destroy a subject's erasure key across the given executions. Wire this to the crypto
279
+ * plugin's `shredSubject` (bound to the KeyStore + raw store + snapshot store). MUST be
280
+ * idempotent — a re-invocation for an already-erased subject must not double-tombstone.
281
+ * `shredSubject` is (ADR 0106): it skips every log already carrying the subject's
282
+ * `PAYLOAD_SHREDDED` and writes the rest. Do NOT guard on key existence — a key can be gone
283
+ * while a tombstone append failed, and that guard would never retry it.
284
+ */
285
+ export type Shredder = (
286
+ subjectId: string,
287
+ executionIds: readonly string[],
288
+ /** The per-call operator assertion of {@link EraseSubjectOptions.force}, when given. */
289
+ options?: { force?: "key-destroyed-elsewhere" }
290
+ ) => Promise<void>;
291
+
292
+ export interface EraseCoordinatorOptions {
293
+ index: SubjectIndex;
294
+ /**
295
+ * `deleteExecution` (optional) lets erasure reach the RESERVED inbox streams of the subject's
296
+ * finished executions (ADR 0110 follow-up): see {@link EraseCoordinator}. A store without it
297
+ * cannot delete a stream, and a payload still in flight in one is then erased only by the key
298
+ * (the crypto plugin seals inbox payloads under the owner's subject).
299
+ */
300
+ eventStore: Pick<EventStore, "read" | "append"> &
301
+ Partial<Pick<EventStore, "deleteExecution" | "listExecutionsPage" | "listExecutions">>;
302
+ shred: Shredder;
303
+ /**
304
+ * ADR 0138 / 0139 - the archives that may hold the subject's moved envelopes (`defineConfig` wires the
305
+ * compaction archive, which is also the inbox archive). Over a PLAINTEXT archive erasure DELETES the
306
+ * subject's compaction segments and their chains' reserved-stream segments after the shred and records
307
+ * what it deleted; it never reports `erased` while plaintext copies remain. A sealing archive needs
308
+ * nothing: the key's destruction makes its segments unreadable.
309
+ */
310
+ archives?: readonly ErasableArchive[];
311
+ /**
312
+ * ADR 0140 - the content store claim-checked tool outputs were offloaded to (`defineConfig` wires
313
+ * `execution.claimCheck.store`). Over a PLAINTEXT store erasure collects the envelope refs the subject's
314
+ * executions cite (live logs, archive segments, snapshots) BEFORE the shred, then deletes each one no other
315
+ * non-erased execution still cites, journaling the intent first. Refused before the shred when blobs are
316
+ * cited and this is absent, cannot delete, or the event store cannot be enumerated.
317
+ */
318
+ content?: ErasableContent;
319
+ /**
320
+ * ADR 0139 D12 - which archived inbox envelopes name the erased subject as their SENDER (default:
321
+ * `sender.actor === subjectId`, and `sender.tenant === tenant` in a tenant partition). Over a PLAINTEXT
322
+ * archive erasure rewrites those envelopes' `payload.sender` to `{ erased: true }` in the OTHER chains'
323
+ * archived reserved segments (their digest masks the sender, so it stays valid) and journals
324
+ * `ERASURE_SENDER_REDACTED`. A SEALING archive needs nothing: the sender is sealed under the SENDER's
325
+ * subject (`senderSubjectOf` on the crypto stores) and the shred erases it.
326
+ */
327
+ senderMatches?: SenderMatcher;
328
+ /**
329
+ * ADR 0139 N6 - erase the subject as a message SENDER in the crypto layer (destroy the key their identity
330
+ * is sealed under, and the seed their handle derives from). Called after `shred`, with the subject id as
331
+ * the actor and the tenant partition; idempotent. `defineConfig` wires it from the crypto event store's
332
+ * `eraseSender`. Without it a sealed sender is erased by whatever the app's `shred` destroys.
333
+ */
334
+ eraseSender?: (subjectId: string, tenant: string) => Promise<unknown>;
335
+ /**
336
+ * ADR 0139 D22 - reinstate an erased sender in the crypto layer (a fresh seed, so a new handle). Backs
337
+ * {@link EraseCoordinator.reinstateSender}; `defineConfig` wires it from the crypto event store's
338
+ * `reinstateSender`.
339
+ */
340
+ reinstateSender?: (
341
+ actor: string,
342
+ tenant: string
343
+ ) => Promise<{ reinstated: boolean; ref?: string; epoch?: number }>;
344
+ /** Where a recoverable, non-fatal failure is reported (default: `console.warn`). `defineConfig` wires `onWarn`. */
345
+ onWarn?: (message: string) => void;
346
+ /**
347
+ * ADR 0139 N1 - told after erasure deleted or rewrote an archive key, so a runtime that remembers the
348
+ * key's last archived sequence (`WorkflowRuntime.invalidateInboxArchive`) forgets it. `defineConfig`
349
+ * wires it.
350
+ */
351
+ onArchiveKeyChanged?: (key: string) => void;
352
+ /**
353
+ * ADR 0130 D7 (round 5) - proof that the key store destroyed a subject's key
354
+ * (`(s) => keys.wasDestroyed(s)`). A PROOF function only: returning `true` for a key that was
355
+ * NOT destroyed in the authoritative store produces a FALSE erasure record, so never stub it.
356
+ * `defineConfig` wires it from the crypto key store's `wasDestroyed` by default. Required to
357
+ * resume an erasure in the ShredIncomplete window: the log proves INTENT, not destruction, so
358
+ * without it a retry that reads `KeyUnavailableError` is refused rather than reported as erased.
359
+ * For a key store that cannot prove destruction, the operator asserts it per call with
360
+ * {@link EraseSubjectOptions.force}.
361
+ */
362
+ destroyed?: DestructionProof;
363
+ /**
364
+ * Cancel one PARKED execution under the coordinator's own authority (a system / compliance
365
+ * grant, never a caller's), journaled with the given reason (`"erased"`), through the runtime's
366
+ * normal fenced cancel. REQUIRED for `eraseSubject(..., { parked: "shred" })`: that mode leaves
367
+ * no owner stuck, so every parked owner is moved to a terminal state BEFORE its subject is
368
+ * shredded. Resolves `true` when the cancel was performed; a rejection, or an owner that is not
369
+ * terminal afterwards (it woke up and is running), defers the subject exactly as `"defer"`
370
+ * does. `defineConfig` wires it to `runtime.cancel(id, reason, runtime.systemOptions())`.
371
+ */
372
+ cancel?: (executionId: string, reason: string) => Promise<boolean>;
373
+ /**
374
+ * A SECRET (HMAC-SHA256 key) that keeps subject ids off the reserved control stream. Every row the
375
+ * coordinator writes that names a subject (`ERASURE_PENDING`, and the `ERASURE_*_INTENDED` /
376
+ * `ERASURE_*_DELETED` / `ERASURE_SENDER_REDACTED` crash-recovery intents) then carries
377
+ * `subjectRef` (a keyed digest of the tenant and the subject) INSTEAD of `subjectId` and `tenant`,
378
+ * and recovery matches a row by recomputing the digest. Without it those rows name the subject in
379
+ * plaintext, permanently (the stream is append-only), so two subjects of one principal erased
380
+ * together sit side by side.
381
+ *
382
+ * Keep it SECRET: whoever holds it can test a candidate id against a row (it is keyed because
383
+ * subject ids are guessable, so a bare hash would not hide them). At least 16 bytes, else the
384
+ * constructor throws. Each keyed row also carries `keyId`, a fingerprint of the key that wrote it.
385
+ * To ROTATE, list the old key in {@link previousControlStreamKeys} until no request it wrote is
386
+ * open. An open intent written under a key the coordinator does not hold (rotated away, or the
387
+ * key removed) FAILS the erasure with {@link ControlStreamKeyError} rather than re-planning without
388
+ * it, because after the shred the subject's logs are unreadable and the intent is the only record
389
+ * of what is still to delete; a parked row under such a key is reported through `onWarn` at
390
+ * `loadParked`. Rows written in plaintext before the key was set are still read.
391
+ */
392
+ controlStreamKey?: string | Uint8Array;
393
+ /**
394
+ * Former {@link controlStreamKey}s, still used to RECOGNISE rows they wrote (never to write).
395
+ * Keep one here until no erasure it recorded is parked or interrupted.
396
+ */
397
+ previousControlStreamKeys?: ReadonlyArray<string | Uint8Array>;
398
+ /**
399
+ * ADR 0106 — where compaction checkpoints live. A compacted execution's log no longer holds
400
+ * its prefix (`[0]` after compacting through the tip), so folding the log alone reads a
401
+ * TERMINAL execution as still running and a parked erasure would wait for it forever. With
402
+ * this, liveness folds on the latest snapshot. Pass it whenever the log can be compacted.
403
+ */
404
+ snapshotStore?: {
405
+ loadLatest(
406
+ executionId: string
407
+ ): Promise<{ executionId?: string; sequence: number; state: ExecutionState } | null>;
408
+ /**
409
+ * Optional (SnapshotStore SPI 1.1.0): the latest snapshot as stored at rest, readable after the
410
+ * subject's key is destroyed. Used ONLY in the destroy window (see `foldExecution`), so a
411
+ * sealing store (`EncryptingSnapshotStore`) does not make the erasure's retry throw
412
+ * `KeyUnavailableError`. Without it that retry cannot read a sealed checkpoint and rethrows.
413
+ */
414
+ loadLatestSealed?(
415
+ executionId: string
416
+ ): Promise<{ executionId?: string; sequence: number; state: ExecutionState } | null>;
417
+ };
418
+ }
419
+
420
+ export interface EraseSubjectResult {
421
+ subjectId: string;
422
+ /** `erased` — key destroyed now; `parked` — deferred (live execution); `unknown` — no records. */
423
+ outcome: "erased" | "parked" | "unknown";
424
+ /** Executions whose data is now shredded (all of the subject's, when erased). */
425
+ erased: string[];
426
+ /**
427
+ * What the erasure deleted from plaintext archives (ADR 0138 / 0139); present when it deleted anything.
428
+ * The same fact is journaled on the control stream as `ERASURE_ARCHIVE_DELETED`.
429
+ */
430
+ archivedCopiesDeleted?: ArchivedCopiesDeleted;
431
+ /** Claim-checked blobs (ADR 0140) the erasure deleted from a plaintext content store; present when it deleted or kept any it found. */
432
+ contentDeleted?: ContentCopiesDeleted;
433
+ /** Archived reserved segments of OTHER chains whose sender fields naming the subject were redacted (plaintext archives). */
434
+ senderRedactions?: SenderRedaction[];
435
+ /** Live executions blocking completion (a parked request completes when these terminate). */
436
+ pending: string[];
437
+ /**
438
+ * `parked: "shred"` only — the executions that were PARKED (suspended, waiting for an approval, a
439
+ * message or a timer) when their subject was shredded. They are not finished: see
440
+ * {@link EraseSubjectOptions.parked} for what one experiences when it resumes.
441
+ */
442
+ parkedShredded?: string[];
443
+ }
444
+
445
+ export interface EraseSubjectOptions {
446
+ /**
447
+ * What to do with a subject whose executions are not all terminal.
448
+ *
449
+ * - `"defer"` (**default**): any execution that is not terminal (a `suspended` one included) is
450
+ * LIVE: the request is recorded durably (`ERASURE_PENDING`) and completes when the last one
451
+ * terminates. A flow parked forever (a chat agent on `receive`) therefore blocks erasure
452
+ * forever; the result is an honest `parked` naming it.
453
+ * - `"shred"` (opt-in): shred NOW when every non-terminal execution is PARKED (`suspended`).
454
+ * An execution that is `running` (or `pending`) still defers: its fold is in flight and would
455
+ * read the key as it is destroyed, which is the one hard rule (R4.5). The window between the
456
+ * check and the key's destruction is the shredder's to close: a parked execution that a timer,
457
+ * a send or a resume wakes in it becomes a running one whose reads fail (below).
458
+ *
459
+ * Leaves NO stuck owner. Each parked (`suspended`) owner is first CANCELLED under the
460
+ * coordinator's own authority ({@link EraseCoordinatorOptions.cancel}, reason `"erased"`, the
461
+ * same fenced path a normal cancel takes), and only when every owner is terminal is the subject
462
+ * shredded. The owners are therefore terminal (`cancelled`) afterwards, so the terminal-head
463
+ * purge of the reserved inbox streams also runs for them, which removes plaintext inbox payloads
464
+ * written before crypto was enabled (these survive the key's destruction) too. If a cancel
465
+ * fails, or an owner woke up and is running by then, nothing is shredded and the subject
466
+ * defers as `"defer"` does (owners already cancelled stay cancelled: the request to erase
467
+ * them was made). Requires {@link EraseCoordinatorOptions.cancel}; without it the call throws.
468
+ *
469
+ * An owner shredded some other way (a direct `shredSubject` call) can still be cancelled:
470
+ * `cancel` folds the redacted view of its log. `resume` of it rejects with the typed
471
+ * `PayloadShreddedError` and appends nothing; so does `loadState` (the strict, authoritative
472
+ * read; its error carries the `redacted` events for operator tooling).
473
+ */
474
+ parked?: "defer" | "shred";
475
+ /**
476
+ * Whether a `parked` outcome is RECORDED (default `true`). When recorded, the request is journaled
477
+ * on the reserved control stream (`ERASURE_PENDING`, never cleared, because the stream is
478
+ * append-only; it names the subject in plaintext unless `controlStreamKey` is set) and the coordinator completes it automatically when the
479
+ * subject's last live execution terminates, across a restart.
480
+ *
481
+ * `record: false` reports `parked` (with `pending`) WITHOUT appending `ERASURE_PENDING` and
482
+ * WITHOUT registering the auto-completion: the subject id is not written to the control stream by
483
+ * a park. The caller owns the retry, and must re-call `eraseSubject` until it returns `erased`
484
+ * (nothing completes it otherwise; `pendingSubjects()` does not list it). Use it when the control
485
+ * stream must not link subject ids, e.g. a principal keyed under a pseudonymous subject and under
486
+ * its own id, both erased.
487
+ *
488
+ * With `parked: "shred"`, the suspended owners are cancelled BEFORE a running one defers the
489
+ * subject; with `record: false` nothing durable then says an erasure is owed, so a caller that
490
+ * stops retrying leaves them cancelled and the subject unerased.
491
+ *
492
+ * It governs the PARK only. A request already recorded by an earlier call stays recorded (and
493
+ * still auto-completes). The `erased` path still journals the subject on the control stream when
494
+ * it deletes plaintext archive copies, claim-checked content or sender copies (the crash-recovery
495
+ * intents); `controlStreamKey` keeps the id out of those rows.
496
+ */
497
+ record?: boolean;
498
+ /**
499
+ * ADR 0130 D7 (round 6) - OPERATOR OVERRIDE, per call (mirrors `ShredOptions.force`). Asserts
500
+ * that the subject's key was destroyed in the authoritative key store, for a store that cannot
501
+ * prove it (no `wasDestroyed`). It lets a retry in the ShredIncomplete window complete, and is
502
+ * passed through to the shredder. A wrong assertion writes a FALSE erasure record while the key
503
+ * lives on.
504
+ */
505
+ force?: "key-destroyed-elsewhere";
506
+ }
507
+
508
+ /**
509
+ * An erasure met an OPEN control-stream intent written under a `controlStreamKey` this coordinator
510
+ * does not hold, so it cannot tell whether the intent is this subject's. Fail closed: pass the key
511
+ * that wrote it in `previousControlStreamKeys` (`keyId` names it) and retry.
512
+ */
513
+ export class ControlStreamKeyError extends Error {
514
+ constructor(
515
+ readonly keyId: string,
516
+ readonly intentId: string
517
+ ) {
518
+ super(
519
+ `erasure control stream: open intent ${intentId} was written under controlStreamKey ${keyId}, which this coordinator does not hold; pass that key in previousControlStreamKeys and retry`
520
+ );
521
+ this.name = "ControlStreamKeyError";
522
+ }
523
+ }
524
+
525
+ const MIN_CONTROL_KEY_BYTES = 16;
526
+
527
+ const defaultWarn = (message: string): void => {
528
+ console.warn(`[loom] ${message}`);
529
+ };
530
+
531
+ /**
532
+ * Coordinates liveness-gated subject erasure and the auto-completion of parked requests.
533
+ * Feed `handleEvent` the event stream (e.g. from `defineConfig`'s `onEvent`) so a parked
534
+ * erasure completes the moment the subject's last live execution terminates.
535
+ */
536
+ export class EraseCoordinator {
537
+ // Parked requests keyed by `tenant\x00subject` (T3-R2.4 — two tenants may share a subject
538
+ // id; they must not collide into one park entry). Authoritative live view; also durable.
539
+ private readonly parked = new Set<string>();
540
+ // Parked requests restored from a `subjectRef` row (`controlStreamKey`): only the digest is durable.
541
+ private readonly parkedRefs = new Set<string>();
542
+
543
+ // The control-stream keys (`controlStreamKey` first, then the previous ones) and their fingerprints.
544
+ private readonly controlKeys: ReadonlyArray<{ key: string | Uint8Array; id: string }>;
545
+
546
+ constructor(private readonly options: EraseCoordinatorOptions) {
547
+ const keys = [
548
+ ...(options.controlStreamKey !== undefined ? [options.controlStreamKey] : []),
549
+ ...(options.previousControlStreamKeys ?? []),
550
+ ];
551
+ if (options.controlStreamKey === undefined && keys.length > 0) {
552
+ throw new TypeError(
553
+ "previousControlStreamKeys needs a controlStreamKey: new rows are written under the current key"
554
+ );
555
+ }
556
+ for (const key of keys) {
557
+ const bytes = typeof key === "string" ? Buffer.byteLength(key) : key.byteLength;
558
+ if (bytes < MIN_CONTROL_KEY_BYTES) {
559
+ throw new TypeError(
560
+ `controlStreamKey must be a secret of at least ${MIN_CONTROL_KEY_BYTES} bytes (got ${bytes}): a short key lets anyone test guessed subject ids against the control stream`
561
+ );
562
+ }
563
+ }
564
+ this.controlKeys = keys.map((key) => ({
565
+ key,
566
+ id: createHmac("sha256", key).update("loom-erasure-key-id").digest("hex").slice(0, 16),
567
+ }));
568
+ }
569
+
570
+ /** Best-effort: repopulate the parked set from the durable control stream (restart recovery). */
571
+ async loadParked(): Promise<void> {
572
+ // The parked set FIRST: it is what the key-window guard reads, so a failure of the archive recovery
573
+ // below must never leave it empty (that would fail open).
574
+ const control = await this.options.eventStore.read(ERASURE_STREAM);
575
+ const cleared = clearedPendingIds(control);
576
+ for (const event of control) {
577
+ if (event.eventType === "ERASURE_PENDING" && !cleared.has(event.id)) {
578
+ const p = event.payload as {
579
+ subjectId?: string;
580
+ tenant?: string;
581
+ subjectRef?: string;
582
+ keyId?: string;
583
+ };
584
+ if (typeof p.subjectRef === "string") {
585
+ this.parkedRefs.add(p.subjectRef);
586
+ if (!this.controlKeys.some((k) => k.id === p.keyId)) {
587
+ (this.options.onWarn ?? defaultWarn)(
588
+ `erasure coordinator: parked request ${event.id} was written under controlStreamKey ${String(p.keyId)}, which this coordinator does not hold; it will not auto-complete until that key is passed in previousControlStreamKeys (or the erasure is re-issued)`
589
+ );
590
+ }
591
+ } else if (typeof p.subjectId === "string") {
592
+ this.parked.add(parkKey(p.tenant ?? DEFAULT_TENANT, p.subjectId));
593
+ }
594
+ }
595
+ }
596
+ // ADR 0139 N1: a stage an earlier build left (a crash mid-redaction) is restored at BOOT, not at the
597
+ // next erasure that happens to scan the archive. A failing archive (down, unreadable) is LOUD but not
598
+ // fatal here: recovery stays pending, to be retried by `recoverRedactions` (the operator verb
599
+ // `app.recoverArchiveRedactions`) and by the next redaction.
600
+ try {
601
+ await this.recoverRedactions();
602
+ } catch (err) {
603
+ (this.options.onWarn ?? defaultWarn)(
604
+ `erasure coordinator: restoring interrupted archive redactions failed (${err instanceof Error ? err.message : String(err)}); the parked erasures were loaded, and the recovery stays pending - retry it with recoverArchiveRedactions() once the archive is reachable`
605
+ );
606
+ }
607
+ }
608
+
609
+ /**
610
+ * ADR 0139 D22 (owner ruling 2026-10-31) - REINSTATE an erased message sender: the explicit, RECORDED
611
+ * operator verb that lets a sender erased by {@link eraseSubject} send again, under a NEW handle (their
612
+ * old messages stay erased and unlinkable to it). Without it a send from an erased sender keeps throwing
613
+ * `SenderErasedError`. Journaled on the control stream: `SENDER_REINSTATE_INTENDED` first, then
614
+ * `SENDER_REINSTATED` with the keyed non-identifying `ref`, the epoch, who (`options.by`) and when;
615
+ * NEVER the plaintext identity. Resolves with whether anything was reinstated (`false`: the sender is
616
+ * not erased, or never sent). Throws unless the coordinator was given `reinstateSender`.
617
+ */
618
+ async reinstateSender(
619
+ actor: string,
620
+ tenant: string = DEFAULT_TENANT,
621
+ options: { by?: string } = {}
622
+ ): Promise<{ reinstated: boolean; ref?: string; epoch?: number }> {
623
+ const reinstate = this.options.reinstateSender;
624
+ if (reinstate === undefined) {
625
+ throw new TypeError(
626
+ "reinstateSender needs the crypto layer's default sender handles: pass `reinstateSender` to the EraseCoordinator (defineConfig wires it from the crypto event store)"
627
+ );
628
+ }
629
+ const by = options.by ?? "operator";
630
+ const intentId = await this.appendControl("SENDER_REINSTATE_INTENDED", {
631
+ ...(tenant !== DEFAULT_TENANT && { tenant }),
632
+ by,
633
+ at: Date.now(),
634
+ });
635
+ const result = await reinstate(actor, tenant);
636
+ await this.appendControl("SENDER_REINSTATED", {
637
+ ...(tenant !== DEFAULT_TENANT && { tenant }),
638
+ intentId,
639
+ by,
640
+ at: Date.now(),
641
+ reinstated: result.reinstated,
642
+ ...(result.ref !== undefined && { ref: result.ref }),
643
+ ...(result.epoch !== undefined && { epoch: result.epoch }),
644
+ });
645
+ return result;
646
+ }
647
+
648
+ /** Request erasure of a subject within a tenant. Shreds now if all executions are terminal, else parks. */
649
+ async eraseSubject(
650
+ subjectId: string,
651
+ tenant: string = DEFAULT_TENANT,
652
+ options: EraseSubjectOptions = {}
653
+ ): Promise<EraseSubjectResult> {
654
+ const executions = this.options.index.executionsOf(subjectId, tenant);
655
+ if (executions.length === 0) {
656
+ return { subjectId, outcome: "unknown", erased: [], pending: [] };
657
+ }
658
+ const shredParked = options.parked === "shred";
659
+ if (shredParked && this.options.cancel === undefined) {
660
+ throw new TypeError(
661
+ 'eraseSubject({ parked: "shred" }) cancels each parked owner before shredding: pass `cancel` to the EraseCoordinator (defineConfig wires it)'
662
+ );
663
+ }
664
+ const proof = this.proofFor(options.force);
665
+ let nonTerminal = await this.liveExecutions(executions, proof);
666
+ // `defer`: every non-terminal execution blocks. `shred`: only a RUNNING one does.
667
+ let live = shredParked
668
+ ? nonTerminal.filter((x) => x.status !== "suspended").map((x) => x.id)
669
+ : nonTerminal.map((x) => x.id);
670
+ let parkedOwners: string[] = [];
671
+ if (shredParked && live.length === 0 && nonTerminal.length > 0) {
672
+ // No owner may be left stuck: cancel every parked one FIRST (the log is still readable and
673
+ // the fence is the runtime's own). A failure, or an owner that is not terminal afterwards,
674
+ // defers the subject.
675
+ parkedOwners = nonTerminal.map((x) => x.id);
676
+ try {
677
+ for (const id of parkedOwners) await this.options.cancel!(id, "erased");
678
+ } catch {
679
+ // fall through to the recheck: whatever did not become terminal blocks the shred
680
+ }
681
+ nonTerminal = await this.liveExecutions(executions, proof);
682
+ live = nonTerminal.map((x) => x.id);
683
+ }
684
+ if (live.length === 0) {
685
+ // Resolved BEFORE the key is destroyed: a decrypting store cannot read these logs afterwards.
686
+ const chains = await this.finishedChains(executions, proof);
687
+ // ADR 0138 / 0139: a plaintext archive that cannot delete would outlive the erasure - refuse BEFORE
688
+ // the key is destroyed.
689
+ await this.assertArchivesErasable(subjectId, tenant, executions, chains);
690
+ // ADR 0140: the claim-checked blobs the subject's logs cite, collected (and the intent journaled)
691
+ // BEFORE the key is destroyed - afterwards the logs are unreadable.
692
+ const content = await this.planContentDeletion(subjectId, tenant, executions, proof);
693
+ if (options.force !== undefined) {
694
+ await this.options.shred(subjectId, executions, { force: options.force });
695
+ } else {
696
+ await this.options.shred(subjectId, executions);
697
+ }
698
+ await this.options.eraseSender?.(subjectId, tenant);
699
+ await this.purgeInboxStreams(chains);
700
+ const archived = await this.deleteArchivedCopies(subjectId, tenant, executions, chains);
701
+ const contentDeleted = await this.finishContentDeletion(subjectId, tenant, content);
702
+ const redactions = await this.redactSenderCopies(subjectId, tenant);
703
+ await this.clearPending(subjectId, tenant);
704
+ this.unpark(subjectId, tenant);
705
+ return {
706
+ subjectId,
707
+ outcome: "erased",
708
+ erased: executions,
709
+ pending: [],
710
+ ...(archived !== undefined && { archivedCopiesDeleted: archived }),
711
+ ...(contentDeleted !== undefined && { contentDeleted }),
712
+ ...(redactions.length > 0 && { senderRedactions: redactions }),
713
+ ...(shredParked && { parkedShredded: parkedOwners }),
714
+ };
715
+ }
716
+ // R4.5 — a live fold is in flight; record the request durably and defer (unless the caller owns
717
+ // the retry: `record: false` writes nothing and registers nothing).
718
+ if (options.record !== false && !this.isParked(subjectId, tenant)) {
719
+ this.parked.add(parkKey(tenant, subjectId));
720
+ await this.appendPending(subjectId, tenant);
721
+ }
722
+ return { subjectId, outcome: "parked", erased: [], pending: live };
723
+ }
724
+
725
+ /** Complete a parked erasure when the subject's last live execution terminates. */
726
+ async handleEvent(event: EventEnvelope): Promise<void> {
727
+ if (!TERMINAL_EVENTS.has(event.eventType)) return;
728
+ if (this.parked.size === 0 && this.parkedRefs.size === 0) return;
729
+ const subjectId = this.options.index.subjectOfExecution(event.executionId);
730
+ if (subjectId === undefined) return;
731
+ const tenant = this.options.index.tenantOfExecution(event.executionId) ?? DEFAULT_TENANT;
732
+ if (!this.isParked(subjectId, tenant)) return;
733
+ const executions = this.options.index.executionsOf(subjectId, tenant);
734
+ if ((await this.liveExecutions(executions, this.options.destroyed)).length > 0) return; // still blocked
735
+ const chains = await this.finishedChains(executions, this.options.destroyed);
736
+ await this.assertArchivesErasable(subjectId, tenant, executions, chains);
737
+ const content = await this.planContentDeletion(
738
+ subjectId,
739
+ tenant,
740
+ executions,
741
+ this.options.destroyed
742
+ );
743
+ await this.options.shred(subjectId, executions);
744
+ await this.options.eraseSender?.(subjectId, tenant);
745
+ await this.purgeInboxStreams(chains);
746
+ await this.deleteArchivedCopies(subjectId, tenant, executions, chains);
747
+ await this.finishContentDeletion(subjectId, tenant, content);
748
+ await this.redactSenderCopies(subjectId, tenant);
749
+ await this.clearPending(subjectId, tenant);
750
+ this.unpark(subjectId, tenant);
751
+ }
752
+
753
+ /**
754
+ * Close the subject's open `ERASURE_PENDING` rows (either form) now that it is erased, so a restart
755
+ * does not park it again. Written LAST: a crash before it leaves the request parked, and completing
756
+ * it again is idempotent. Names the rows, never the subject.
757
+ */
758
+ private async clearPending(subjectId: string, tenant: string): Promise<void> {
759
+ const control = await this.options.eventStore.read(ERASURE_STREAM);
760
+ const cleared = clearedPendingIds(control);
761
+ const pendingIds = control
762
+ .filter(
763
+ (e) =>
764
+ e.eventType === "ERASURE_PENDING" &&
765
+ !cleared.has(e.id) &&
766
+ this.rowNames(e.payload, subjectId, tenant)
767
+ )
768
+ .map((e) => e.id);
769
+ if (pendingIds.length > 0) await this.appendControl(PENDING_CLEARED, { pendingIds });
770
+ }
771
+
772
+ /** Whether a request to erase the subject is parked (registered in memory or restored from either row form). */
773
+ private isParked(subjectId: string, tenant: string): boolean {
774
+ if (this.parked.has(parkKey(tenant, subjectId))) return true;
775
+ return this.subjectRefs(subjectId, tenant).some((ref) => this.parkedRefs.has(ref));
776
+ }
777
+
778
+ private unpark(subjectId: string, tenant: string): void {
779
+ this.parked.delete(parkKey(tenant, subjectId));
780
+ for (const ref of this.subjectRefs(subjectId, tenant)) this.parkedRefs.delete(ref);
781
+ }
782
+
783
+ /**
784
+ * The keyed digests a control row may name the subject by: one per held key, the current key's first
785
+ * (`[]` without `controlStreamKey`). Each part is length-prefixed, so no (tenant, subject) pair can
786
+ * be spelled as another.
787
+ */
788
+ private subjectRefs(subjectId: string, tenant: string): string[] {
789
+ const input = `loom-erasure-subject:${tenant.length}:${tenant}:${subjectId.length}:${subjectId}`;
790
+ return this.controlKeys.map(
791
+ ({ key }) => `hmac-sha256:${createHmac("sha256", key).update(input).digest("hex")}`
792
+ );
793
+ }
794
+
795
+ /** How a control row names the subject: the current key's digest and its id, or (no key) the plaintext id and tenant. */
796
+ private subjectFields(subjectId: string, tenant: string): Record<string, string> {
797
+ const current = this.controlKeys[0];
798
+ if (current !== undefined) {
799
+ return { subjectRef: this.subjectRefs(subjectId, tenant)[0]!, keyId: current.id };
800
+ }
801
+ return { subjectId, ...(tenant !== DEFAULT_TENANT && { tenant }) };
802
+ }
803
+
804
+ /** Whether a control row names the subject, in either form (a plaintext row predates the key). */
805
+ private rowNames(payload: unknown, subjectId: string, tenant: string): boolean {
806
+ const p = payload as { subjectId?: unknown; tenant?: unknown; subjectRef?: unknown } | null;
807
+ if (p === null || typeof p !== "object") return false;
808
+ if (typeof p.subjectRef === "string") {
809
+ return this.subjectRefs(subjectId, tenant).includes(p.subjectRef);
810
+ }
811
+ return p.subjectId === subjectId && (p.tenant ?? DEFAULT_TENANT) === tenant;
812
+ }
813
+
814
+ /**
815
+ * ADR 0139 D12 - a PLAINTEXT archive holds other chains' reserved segments naming the erased subject as
816
+ * the sender. Each is rewritten with the identity replaced by an erased marker (the segment digest masks
817
+ * the sender, so it still verifies; the segment's sender digest is recomputed in the same swap) through
818
+ * ONE atomic `ArchiveStore.replaceSegments` per key (ADR 0139 N1: a trim on another node that appends a
819
+ * segment after this read makes the swap fail, and the key is re-read and retried - never lost, never
820
+ * restored over). The INTENT (the keys) is journaled first (`ERASURE_SENDER_REDACTION_INTENDED`) and the
821
+ * completion after (`ERASURE_SENDER_REDACTED`), so a crash between the two is finished by the retry from
822
+ * the intent and a redaction is never unrecorded. Cost: ONE pass over the archive's reserved keys per
823
+ * erasure (a `listExecutions` prefix walk and a `read` of each key; not indexed - an erasure is rare).
824
+ * A live, not yet archived envelope keeps its sender until it is trimmed (a plaintext store has no key to
825
+ * shred; the sender is sealed by default in a sealing deployment, ADR 0139 N6).
826
+ */
827
+ private async redactSenderCopies(subjectId: string, tenant: string): Promise<SenderRedaction[]> {
828
+ const ref = randomBytes(8).toString("hex"); // one stable non-identifying reference per erased sender
829
+ await this.recoverRedactions(); // (a stage an earlier build left; a no-op on a clean archive)
830
+ const open = await this.openIntent(
831
+ SENDER_REDACTION_INTENDED,
832
+ SENDER_REDACTED,
833
+ subjectId,
834
+ tenant
835
+ );
836
+ // 1. PLAN: one pass over the reserved keys, reading each once.
837
+ const plan: Array<{
838
+ archive: ErasableArchive & Required<Pick<ErasableArchive, "replaceSegments">>;
839
+ key: string;
840
+ segments: ArchiveSegment[];
841
+ changed: number;
842
+ }> = [];
843
+ for (const archive of this.plaintextArchives()) {
844
+ if (archive.replaceSegments === undefined) continue; // refused before the shred if it held a copy
845
+ for (const key of await this.reservedArchiveKeys(archive)) {
846
+ const segments = await archive.read(key);
847
+ const changed = this.redactedSegments(segments, subjectId, tenant, ref).changed;
848
+ if (changed > 0) {
849
+ plan.push({
850
+ archive: archive as (typeof plan)[number]["archive"],
851
+ key,
852
+ segments,
853
+ changed,
854
+ });
855
+ }
856
+ }
857
+ }
858
+ const intended = (open?.payload as { redactions?: SenderRedaction[] } | undefined)?.redactions;
859
+ if (plan.length === 0 && open === undefined) return [];
860
+ // 2. INTENT first (what is about to be rewritten), unless an interrupted attempt already journaled it.
861
+ const intentId =
862
+ open?.id ??
863
+ (await this.appendIntent(SENDER_REDACTION_INTENDED, subjectId, tenant, {
864
+ redactions: plan.map((p) => ({ streamId: p.key, envelopes: p.changed })),
865
+ }));
866
+ // 3. APPLY: one atomic swap per key; a concurrent append makes it conflict, and the key is re-read.
867
+ const done = new Map<string, number>();
868
+ for (const item of plan) {
869
+ let segments = item.segments;
870
+ for (let attempt = 0; ; attempt++) {
871
+ const next = this.redactedSegments(segments, subjectId, tenant, ref);
872
+ if (next.changed === 0) break; // another writer redacted it already
873
+ try {
874
+ await item.archive.replaceSegments(item.key, segments.map(headerOf), next.segments);
875
+ done.set(item.key, next.changed);
876
+ this.options.onArchiveKeyChanged?.(item.key);
877
+ break;
878
+ } catch (err) {
879
+ if (!(err instanceof ArchiveConflictError) || attempt + 1 >= REDACT_RETRIES) throw err;
880
+ segments = await item.archive.read(item.key);
881
+ }
882
+ }
883
+ }
884
+ // 4. COMPLETION: the planned redactions, plus any key this pass found beyond the intent.
885
+ const all = new Map<string, number>();
886
+ for (const r of intended ?? []) all.set(r.streamId, r.envelopes);
887
+ for (const p of plan)
888
+ all.set(p.key, Math.max(all.get(p.key) ?? 0, done.get(p.key) ?? p.changed));
889
+ const redactions = [...all].map(([streamId, envelopes]) => ({ streamId, envelopes }));
890
+ await this.appendCompletion(SENDER_REDACTED, subjectId, tenant, intentId, { redactions });
891
+ return redactions;
892
+ }
893
+
894
+ /** `segments` with the matching senders replaced by the erased marker, and the sender digest recomputed. */
895
+ private redactedSegments(
896
+ segments: readonly ArchiveSegment[],
897
+ subjectId: string,
898
+ tenant: string,
899
+ ref: string
900
+ ): { segments: ArchiveSegment[]; changed: number } {
901
+ const matches = this.options.senderMatches ?? defaultSenderMatches;
902
+ let changed = 0;
903
+ const out = segments.map((seg) => {
904
+ let here = 0;
905
+ const envelopes = seg.envelopes.map((e) => {
906
+ const sender = (e.payload as { sender?: unknown } | null)?.sender;
907
+ if (
908
+ e.eventType !== "INBOX_MESSAGE" ||
909
+ typeof sender !== "object" ||
910
+ sender === null ||
911
+ !matches(subjectId, tenant, sender as never)
912
+ ) {
913
+ return e;
914
+ }
915
+ here++;
916
+ return {
917
+ ...e,
918
+ payload: { ...(e.payload as object), sender: senderErasedMarker(ref) },
919
+ };
920
+ });
921
+ if (here === 0) return seg;
922
+ changed += here;
923
+ return { ...seg, envelopes, senderDigest: senderDigestOf(envelopes) };
924
+ });
925
+ return { segments: out, changed };
926
+ }
927
+
928
+ /** Every reserved key (inbox, quarantine, late run) the archive holds, by prefix walk. */
929
+ private async reservedArchiveKeys(archive: ErasableArchive): Promise<string[]> {
930
+ const keys: string[] = [];
931
+ for (const prefix of ["__loom_inbox__:", "__loom_quarantine__:", "__loom_late__:"]) {
932
+ let cursor: string | undefined = prefix.slice(0, -1);
933
+ for (;;) {
934
+ const page = await archive.listExecutions({ cursor, limit: 200 });
935
+ let past = false;
936
+ for (const key of page.executionIds) {
937
+ if (!key.startsWith(prefix)) {
938
+ if (key < prefix) continue;
939
+ past = true;
940
+ break;
941
+ }
942
+ keys.push(key);
943
+ }
944
+ if (past || page.nextCursor === undefined || page.nextCursor === cursor) break;
945
+ cursor = page.nextCursor;
946
+ }
947
+ }
948
+ return keys;
949
+ }
950
+
951
+ /**
952
+ * Restore what an EARLIER build's staged rewrite left behind (a `__loom_redact__:<key>` whose original
953
+ * is gone is copied back; a stage whose original is whole is dropped). The current rewrite is one atomic
954
+ * `replaceSegments` and leaves no stage, so this is a no-op on a clean archive. Run at boot (`loadParked`)
955
+ * and exposed as an operator verb (`app.recoverArchiveRedactions`). Returns the keys restored.
956
+ */
957
+ async recoverRedactions(): Promise<string[]> {
958
+ const restored: string[] = [];
959
+ for (const archive of this.plaintextArchives()) {
960
+ const del = archive.deleteExecution?.bind(archive);
961
+ if (del === undefined) continue;
962
+ let cursor: string | undefined = REDACT_STAGE.slice(0, -1);
963
+ for (;;) {
964
+ const page = await archive.listExecutions({ cursor, limit: 200 });
965
+ let past = false;
966
+ for (const stage of page.executionIds) {
967
+ if (!stage.startsWith(REDACT_STAGE)) {
968
+ if (stage < REDACT_STAGE) continue;
969
+ past = true;
970
+ break;
971
+ }
972
+ const key = stage.slice(REDACT_STAGE.length);
973
+ if ((await archive.headers(key)).length === 0) {
974
+ for (const seg of await archive.read(stage)) await archive.append(key, seg);
975
+ restored.push(key);
976
+ this.options.onArchiveKeyChanged?.(key);
977
+ }
978
+ await del(stage);
979
+ }
980
+ if (past || page.nextCursor === undefined || page.nextCursor === cursor) break;
981
+ cursor = page.nextCursor;
982
+ }
983
+ }
984
+ return restored;
985
+ }
986
+
987
+ /**
988
+ * The open intent of `intentType` for the subject: journaled and not yet completed by a `completionType`
989
+ * event naming it (`intentId`). The latest one, or `undefined`.
990
+ */
991
+ private async openIntent(
992
+ intentType: string,
993
+ completionType: string,
994
+ subjectId: string,
995
+ tenant: string
996
+ ): Promise<EventEnvelope | undefined> {
997
+ const events = await this.options.eventStore.read(ERASURE_STREAM);
998
+ const done = new Set<string>();
999
+ for (const e of events) {
1000
+ if (e.eventType === completionType) {
1001
+ const id = (e.payload as { intentId?: unknown } | null)?.intentId;
1002
+ if (typeof id === "string") done.add(id);
1003
+ }
1004
+ }
1005
+ let found: EventEnvelope | undefined;
1006
+ let foreign: { keyId: string; intentId: string } | undefined;
1007
+ for (const e of events) {
1008
+ if (e.eventType !== intentType || done.has(e.id)) continue;
1009
+ if (this.rowNames(e.payload, subjectId, tenant)) found = e;
1010
+ else {
1011
+ const p = e.payload as { subjectRef?: unknown; keyId?: unknown } | null;
1012
+ const keyId = typeof p?.keyId === "string" ? p.keyId : "(none)";
1013
+ if (typeof p?.subjectRef === "string" && !this.controlKeys.some((k) => k.id === keyId)) {
1014
+ foreign = { keyId, intentId: e.id };
1015
+ }
1016
+ }
1017
+ }
1018
+ // An open keyed intent this coordinator cannot attribute might be this subject's: never re-plan past it.
1019
+ if (found === undefined && foreign !== undefined) {
1020
+ throw new ControlStreamKeyError(foreign.keyId, foreign.intentId);
1021
+ }
1022
+ return found;
1023
+ }
1024
+
1025
+ private async appendIntent(
1026
+ type: string,
1027
+ subjectId: string,
1028
+ tenant: string,
1029
+ body: object
1030
+ ): Promise<string> {
1031
+ return this.appendControl(type, { ...this.subjectFields(subjectId, tenant), ...body });
1032
+ }
1033
+
1034
+ private async appendCompletion(
1035
+ type: string,
1036
+ subjectId: string,
1037
+ tenant: string,
1038
+ intentId: string | undefined,
1039
+ body: object
1040
+ ): Promise<void> {
1041
+ await this.appendControl(type, {
1042
+ ...this.subjectFields(subjectId, tenant),
1043
+ ...(intentId !== undefined && { intentId }),
1044
+ ...body,
1045
+ });
1046
+ }
1047
+
1048
+ private async appendControl(type: string, payload: object): Promise<string> {
1049
+ const prior = await this.options.eventStore.read(ERASURE_STREAM);
1050
+ const id = `erasure-${prior.length}`;
1051
+ await this.options.eventStore.append({
1052
+ id,
1053
+ workflowId: ERASURE_STREAM,
1054
+ executionId: ERASURE_STREAM,
1055
+ eventType: type,
1056
+ sequence: prior.length,
1057
+ timestamp: 0,
1058
+ payload,
1059
+ });
1060
+ return id;
1061
+ }
1062
+
1063
+ /** The plaintext archives (`sealsSegments` is not `true`); a sealing one is erased by the shred. */
1064
+ private plaintextArchives(): ErasableArchive[] {
1065
+ return (this.options.archives ?? []).filter((a) => a.sealsSegments !== true);
1066
+ }
1067
+
1068
+ /** Every archive key holding the subject's copies: its executions' compaction segments and its chains' reserved segments. */
1069
+ private async archivedKeys(
1070
+ archive: ErasableArchive,
1071
+ executions: readonly string[],
1072
+ chains: readonly FinishedChain[]
1073
+ ): Promise<{ executions: string[]; reservedStreams: string[] }> {
1074
+ const held: string[] = [];
1075
+ for (const id of executions) {
1076
+ if ((await archive.headers(id)).length > 0) held.push(id);
1077
+ }
1078
+ const reserved: string[] = [];
1079
+ for (const { chainId } of chains) {
1080
+ reserved.push(...(await archivedReservedStreams(archive, chainId)));
1081
+ }
1082
+ return { executions: held, reservedStreams: reserved };
1083
+ }
1084
+
1085
+ private async assertArchivesErasable(
1086
+ subjectId: string,
1087
+ tenant: string,
1088
+ executions: readonly string[],
1089
+ chains: readonly FinishedChain[]
1090
+ ): Promise<void> {
1091
+ for (const archive of this.plaintextArchives()) {
1092
+ if (archive.deleteExecution === undefined) {
1093
+ const keys = await this.archivedKeys(archive, executions, chains);
1094
+ const all = [...keys.executions, ...keys.reservedStreams];
1095
+ if (all.length > 0) throw new ArchivedCopiesRemainError(all);
1096
+ }
1097
+ // ADR 0139 N5: an archive that cannot swap a segment atomically cannot redact the subject's identity
1098
+ // out of OTHER chains' archived envelopes. Refuse BEFORE the shred when it holds any (the scan is the
1099
+ // price of finding out; an archive that can redact is scanned once, after the shred).
1100
+ if (archive.replaceSegments === undefined) {
1101
+ const held: string[] = [];
1102
+ for (const key of await this.reservedArchiveKeys(archive)) {
1103
+ const segments = await archive.read(key);
1104
+ if (this.redactedSegments(segments, subjectId, tenant, "").changed > 0) held.push(key);
1105
+ }
1106
+ if (held.length > 0) throw new SenderRedactionUnsupportedError(held);
1107
+ }
1108
+ }
1109
+ }
1110
+
1111
+ // ── ADR 0140 - claim-checked blobs ──────────────────────────────────────────────
1112
+
1113
+ /** Does the content store seal per subject (so the shred already erased its blobs)? */
1114
+ private sealsContent(): boolean {
1115
+ return this.options.content?.sealsContent === true;
1116
+ }
1117
+
1118
+ /** Every claim-check ref one execution cites: its live log, its archive segments and its snapshot. */
1119
+ private async refsOfExecution(
1120
+ executionId: string,
1121
+ proof: DestructionProof | undefined,
1122
+ out: Set<string>,
1123
+ /**
1124
+ * `true` (OTHER executions' citations): count only TRUSTED positions (see
1125
+ * {@link trustedClaimCheckRefsOf}); a snapshot's refs count only when the execution has no recorded
1126
+ * step left to vouch for them (history gone: the snapshot is the sole evidence, so fail closed).
1127
+ */
1128
+ trustedOnly = false,
1129
+ /**
1130
+ * ADR 0143: where each address was first cited (the citing execution + its workflow), so a sealing
1131
+ * store can be asked whether the address is OWNED by that citer's subject.
1132
+ */
1133
+ citedBy?: Map<string, { executionId: string; workflowId: string }[]>
1134
+ ): Promise<void> {
1135
+ const events = await this.readErasedOk(executionId, proof);
1136
+ // With `citedBy`, collect THIS execution's refs separately so EVERY distinct citer of an address is
1137
+ // recorded (not only the first): the ownership proof may hold under any of the subject's executions.
1138
+ const into = citedBy === undefined ? out : new Set<string>();
1139
+ let workflowId = events[0]?.workflowId;
1140
+ let sawStep = false;
1141
+ const visit = (list: readonly { eventType: string; payload: unknown }[]): void => {
1142
+ if (!trustedOnly) {
1143
+ for (const e of list) claimCheckRefsOf(e.payload, into);
1144
+ } else if (trustedClaimCheckRefsOf(list, into).sawStep) {
1145
+ sawStep = true;
1146
+ }
1147
+ };
1148
+ visit(events);
1149
+ for (const archive of this.options.archives ?? []) {
1150
+ try {
1151
+ if ((await archive.headers(executionId)).length === 0) continue;
1152
+ for (const seg of await archive.read(executionId)) {
1153
+ workflowId ??= seg.envelopes[0]?.workflowId;
1154
+ visit(seg.envelopes);
1155
+ }
1156
+ } catch (err) {
1157
+ // A sealed segment whose key is already gone cites nothing anyone can read.
1158
+ if (!isPayloadShredded(err) && !isKeyUnavailable(err)) throw err;
1159
+ }
1160
+ }
1161
+ const read = await this.readSnapshotErasedOk(executionId, events, proof);
1162
+ if (read.snapshot !== null && !read.opaque && (!trustedOnly || !sawStep)) {
1163
+ workflowId ??= read.snapshot.state.workflowId;
1164
+ claimCheckRefsOf(read.snapshot.state, into);
1165
+ }
1166
+ if (citedBy !== undefined) {
1167
+ for (const a of into) {
1168
+ out.add(a);
1169
+ const citers = citedBy.get(a) ?? [];
1170
+ citedBy.set(a, citers);
1171
+ // Bounded: a proof is tried per citer, so a pathological fan-in cannot make erasure quadratic.
1172
+ if (
1173
+ citers.length < MAX_CITERS_PER_ADDRESS &&
1174
+ !citers.some((c) => c.executionId === executionId)
1175
+ ) {
1176
+ citers.push({ executionId, workflowId: workflowId ?? "" });
1177
+ }
1178
+ }
1179
+ }
1180
+ }
1181
+
1182
+ /**
1183
+ * BEFORE the shred: collect the claim-check refs the subject's executions cite, drop every one another
1184
+ * (non-erased) execution still cites, refuse when the remainder cannot be deleted, and journal the INTENT
1185
+ * (`ERASURE_CONTENT_DELETE_INTENDED`: the addresses and the touch cutoff). A retry after a crash finds the
1186
+ * open intent and finishes it. `undefined`: nothing to do (no content store sealing, no refs, no intent).
1187
+ */
1188
+ private async planContentDeletion(
1189
+ subjectId: string,
1190
+ tenant: string,
1191
+ executions: readonly string[],
1192
+ proof: DestructionProof | undefined
1193
+ ): Promise<
1194
+ | {
1195
+ intentId?: string;
1196
+ addresses: string[];
1197
+ retained: number;
1198
+ cutoff?: number;
1199
+ sealed?: true;
1200
+ foreign?: number;
1201
+ unprovable?: number;
1202
+ }
1203
+ | undefined
1204
+ > {
1205
+ const open = await this.openIntent(CONTENT_DELETE_INTENDED, CONTENT_DELETED, subjectId, tenant);
1206
+ const mine = new Set<string>();
1207
+ const citedBy = new Map<string, { executionId: string; workflowId: string }[]>();
1208
+ for (const id of executions) await this.refsOfExecution(id, proof, mine, false, citedBy);
1209
+ if (mine.size === 0 && open === undefined) return undefined;
1210
+ if (this.sealsContent()) {
1211
+ // ADR 0143: a store that seals each blob under a subject's key. The SHRED erases the subject's own
1212
+ // blobs (no plaintext copy exists to remove). But a log CITES addresses it never put (an input, a
1213
+ // resume payload, another execution's result): citation is not ownership. Each cited address is
1214
+ // PROVEN owned by the subject (the store decrypts it under the subject's key and discards the
1215
+ // plaintext) before it is planned for deletion; a blob that does not prove is never deleted and is
1216
+ // journaled as foreign/retained. Erasure still RECORDS what it covered (the intent, BEFORE the shred;
1217
+ // the completion after) and deletes the now-unreadable ciphertext where the store can.
1218
+ const prior =
1219
+ (open?.payload as
1220
+ | {
1221
+ addresses?: string[];
1222
+ foreign?: number;
1223
+ unprovable?: number;
1224
+ foreignAddresses?: string[];
1225
+ unprovableAddresses?: string[];
1226
+ }
1227
+ | undefined) ?? {};
1228
+ // The journaled intent names the foreign/unprovable addresses too, so a retry after the shred (when
1229
+ // every proof now throws PayloadShreddedError) neither reclassifies nor double counts them.
1230
+ const known = new Set([
1231
+ ...(prior.addresses ?? []),
1232
+ ...(prior.foreignAddresses ?? []),
1233
+ ...(prior.unprovableAddresses ?? []),
1234
+ ]);
1235
+ const owned = new Set(prior.addresses ?? []);
1236
+ const foreign = new Set(prior.foreignAddresses ?? []);
1237
+ const unprovable = new Set(prior.unprovableAddresses ?? []);
1238
+ const fresh = [...mine].filter((a) => !known.has(a));
1239
+ const content = this.options.content;
1240
+ if (fresh.length > 0 && typeof content?.ownedBy !== "function") {
1241
+ throw new ContentCopiesRemainError(
1242
+ fresh.length,
1243
+ "the sealing content store offers no ownedBy(ref, access), so erasure cannot prove which cited blobs the subject owns and which another subject's log merely cites (a sealing store without the proof is refused rather than guessed at)"
1244
+ );
1245
+ }
1246
+ for (const address of fresh) {
1247
+ // The putter may be ANY execution of the subject that cites the address (the compliance subject
1248
+ // can span executions with distinct crypto subjects): owned if the proof holds under any of them.
1249
+ let isOwned = false;
1250
+ let sawShredded = false;
1251
+ for (const access of citedBy.get(address) ?? []) {
1252
+ try {
1253
+ isOwned = await (content as Required<Pick<ErasableContent, "ownedBy">>).ownedBy(
1254
+ { __loomContent: address },
1255
+ access
1256
+ );
1257
+ } catch (err) {
1258
+ // The key is already destroyed (a re-erasure): ownership under that key is unprovable. Any other
1259
+ // failure (a key merely absent from this node) is loud: nothing is shredded.
1260
+ if (!isPayloadShredded(err)) throw err;
1261
+ sawShredded = true;
1262
+ }
1263
+ if (isOwned) break;
1264
+ }
1265
+ if (isOwned) owned.add(address);
1266
+ else if (sawShredded) unprovable.add(address);
1267
+ else foreign.add(address);
1268
+ }
1269
+ const foreignCount = Math.max(prior.foreign ?? 0, foreign.size);
1270
+ const unprovableCount = Math.max(prior.unprovable ?? 0, unprovable.size);
1271
+ const retained = foreignCount + unprovableCount;
1272
+ const addresses = [...owned];
1273
+ const intentId =
1274
+ open?.id ??
1275
+ (await this.appendIntent(CONTENT_DELETE_INTENDED, subjectId, tenant, {
1276
+ addresses,
1277
+ retained,
1278
+ foreign: foreignCount,
1279
+ unprovable: unprovableCount,
1280
+ foreignAddresses: [...foreign],
1281
+ unprovableAddresses: [...unprovable],
1282
+ sealed: true,
1283
+ }));
1284
+ return {
1285
+ intentId,
1286
+ addresses,
1287
+ retained,
1288
+ foreign: foreignCount,
1289
+ unprovable: unprovableCount,
1290
+ sealed: true,
1291
+ };
1292
+ }
1293
+ const content = this.options.content;
1294
+ const store = this.options.eventStore;
1295
+ const enumerable = store.listExecutionsPage !== undefined || store.listExecutions !== undefined;
1296
+ if (mine.size > 0) {
1297
+ if (content === undefined) {
1298
+ throw new ContentCopiesRemainError(mine.size, "no content store is wired to the erasure");
1299
+ }
1300
+ if (typeof content.delete !== "function") {
1301
+ throw new ContentCopiesRemainError(mine.size, "the content store cannot delete");
1302
+ }
1303
+ if (
1304
+ typeof content.deleteIfUntouchedSince !== "function" ||
1305
+ typeof content.touchClock !== "function"
1306
+ ) {
1307
+ throw new ContentCopiesRemainError(
1308
+ mine.size,
1309
+ "the content store has no deleteIfUntouchedSince/touchClock (ContentStore SPI 1.3.0), so a blob another execution cites after this plan could be deleted while cited (an unconditional delete of a shared-addressable blob is never made)"
1310
+ );
1311
+ }
1312
+ if (!enumerable) {
1313
+ throw new ContentCopiesRemainError(
1314
+ mine.size,
1315
+ "the event store cannot be enumerated, so erasure cannot tell whether another execution still cites them"
1316
+ );
1317
+ }
1318
+ }
1319
+ const openAddresses = (open?.payload as { addresses?: string[] } | undefined)?.addresses ?? [];
1320
+ if (
1321
+ mine.size === 0 &&
1322
+ openAddresses.length > 0 &&
1323
+ (content === undefined ||
1324
+ typeof content.deleteIfUntouchedSince !== "function" ||
1325
+ typeof content.touchClock !== "function")
1326
+ ) {
1327
+ throw new ContentCopiesRemainError(
1328
+ openAddresses.length,
1329
+ "the content store lacks the conditional delete (deleteIfUntouchedSince/touchClock) the journaled intent needs"
1330
+ );
1331
+ }
1332
+ // The blobs another, non-erased execution still cites STAY (a deduplicated identical blob).
1333
+ const cutoff = content?.touchClock !== undefined ? await content.touchClock() : undefined;
1334
+ const stillCited = new Set<string>();
1335
+ if (mine.size > 0) {
1336
+ const own = new Set(executions);
1337
+ const ids = new Set<string>();
1338
+ for await (const id of iterateExecutionIds(store)) ids.add(id);
1339
+ for (const archive of this.options.archives ?? []) {
1340
+ let cursor: string | undefined;
1341
+ for (;;) {
1342
+ const page = await archive.listExecutions({
1343
+ ...(cursor !== undefined && { cursor }),
1344
+ limit: 500,
1345
+ });
1346
+ for (const id of page.executionIds) ids.add(id);
1347
+ if (page.nextCursor === undefined || page.nextCursor === cursor) break;
1348
+ cursor = page.nextCursor;
1349
+ }
1350
+ }
1351
+ for (const id of ids) {
1352
+ if (own.has(id) || id === ERASURE_STREAM) continue;
1353
+ const cited = new Set<string>();
1354
+ await this.refsOfExecution(id, proof, cited, true);
1355
+ for (const a of cited) if (mine.has(a)) stillCited.add(a);
1356
+ if (stillCited.size === mine.size) break;
1357
+ }
1358
+ }
1359
+ const addresses = new Set<string>();
1360
+ const prior = open?.payload as { addresses?: string[]; cutoff?: number } | undefined;
1361
+ for (const a of prior?.addresses ?? []) addresses.add(a);
1362
+ for (const a of mine) if (!stillCited.has(a)) addresses.add(a);
1363
+ const retained = stillCited.size;
1364
+ const intentId =
1365
+ open?.id ??
1366
+ (addresses.size > 0 || retained > 0
1367
+ ? await this.appendIntent(CONTENT_DELETE_INTENDED, subjectId, tenant, {
1368
+ addresses: [...addresses],
1369
+ retained,
1370
+ ...(cutoff !== undefined && { cutoff }),
1371
+ })
1372
+ : undefined);
1373
+ return {
1374
+ ...(intentId !== undefined && { intentId }),
1375
+ addresses: [...addresses],
1376
+ retained,
1377
+ ...(prior?.cutoff !== undefined
1378
+ ? { cutoff: Math.min(prior.cutoff, cutoff ?? prior.cutoff) }
1379
+ : cutoff !== undefined
1380
+ ? { cutoff }
1381
+ : {}),
1382
+ };
1383
+ }
1384
+
1385
+ /** AFTER the shred: delete the planned blobs, then journal the completion naming the intent. */
1386
+ private async finishContentDeletion(
1387
+ subjectId: string,
1388
+ tenant: string,
1389
+ plan:
1390
+ | {
1391
+ intentId?: string;
1392
+ addresses: string[];
1393
+ retained: number;
1394
+ cutoff?: number;
1395
+ sealed?: true;
1396
+ foreign?: number;
1397
+ unprovable?: number;
1398
+ }
1399
+ | undefined
1400
+ ): Promise<ContentCopiesDeleted | undefined> {
1401
+ if (plan === undefined || plan.intentId === undefined) return undefined;
1402
+ const content = this.options.content;
1403
+ let deleted = 0;
1404
+ if (plan.sealed === true) {
1405
+ // The shred already made every blob unreadable. Reclaim the ciphertext where the store can delete.
1406
+ for (const address of plan.addresses) {
1407
+ if (typeof content?.delete !== "function") break;
1408
+ await content.delete({ __loomContent: address });
1409
+ deleted++;
1410
+ }
1411
+ const foreign = plan.foreign ?? 0;
1412
+ const unprovable = plan.unprovable ?? 0;
1413
+ const retained = foreign + unprovable;
1414
+ await this.appendCompletion(CONTENT_DELETED, subjectId, tenant, plan.intentId, {
1415
+ deleted,
1416
+ retained,
1417
+ sealed: plan.addresses.length,
1418
+ foreign,
1419
+ unprovable,
1420
+ });
1421
+ return { deleted, retained, sealed: plan.addresses.length, foreign, unprovable };
1422
+ }
1423
+ for (const address of plan.addresses) {
1424
+ const ref = { __loomContent: address };
1425
+ if (content === undefined) break; // planned only when a store was wired
1426
+ if (plan.cutoff === undefined || content.deleteIfUntouchedSince === undefined) {
1427
+ // Planned only with the conditional delete (planContentDeletion refuses otherwise); an intent
1428
+ // finished against a store that lost it must not fall back to an unconditional delete.
1429
+ throw new ContentCopiesRemainError(
1430
+ plan.addresses.length,
1431
+ "the content store no longer offers deleteIfUntouchedSince/touchClock, so the journaled deletion cannot be finished safely"
1432
+ );
1433
+ }
1434
+ // A concurrent re-put (another subject's identical blob) bumped the touch time: spared.
1435
+ if (await content.deleteIfUntouchedSince(ref, plan.cutoff)) deleted++;
1436
+ else plan.retained++;
1437
+ }
1438
+ await this.appendCompletion(CONTENT_DELETED, subjectId, tenant, plan.intentId, {
1439
+ deleted,
1440
+ retained: plan.retained,
1441
+ });
1442
+ return { deleted, retained: plan.retained };
1443
+ }
1444
+
1445
+ /**
1446
+ * ADR 0138 / 0139 - the physical half of erasure for the ARCHIVES: delete the subject's compaction
1447
+ * segments and its chains' reserved-stream segments from every plaintext archive. ADR 0139 N4: the
1448
+ * INTENT (the keys) is journaled BEFORE anything is deleted (`ERASURE_ARCHIVE_DELETE_INTENDED`) and the
1449
+ * completion after (`ERASURE_ARCHIVE_DELETED`, naming the intent), so a crash between the delete and the
1450
+ * record is finished by the retry from the intent: a deletion is never unrecorded. Idempotent: a retry
1451
+ * with nothing left and no open intent records nothing. Over a sealing archive the shred has already made
1452
+ * the segments unreadable, so nothing is deleted.
1453
+ */
1454
+ private async deleteArchivedCopies(
1455
+ subjectId: string,
1456
+ tenant: string,
1457
+ executions: readonly string[],
1458
+ chains: readonly FinishedChain[]
1459
+ ): Promise<ArchivedCopiesDeleted | undefined> {
1460
+ const deleted: ArchivedCopiesDeleted = { executions: [], reservedStreams: [] };
1461
+ // 1. Finish an interrupted attempt from its journaled intent.
1462
+ const open = await this.openIntent(ARCHIVE_DELETE_INTENDED, ARCHIVE_DELETED, subjectId, tenant);
1463
+ const pending = new Set<string>();
1464
+ if (open !== undefined) {
1465
+ const p = open.payload as Partial<ArchivedCopiesDeleted>;
1466
+ for (const id of p.executions ?? []) deleted.executions.push(id);
1467
+ for (const id of p.reservedStreams ?? []) deleted.reservedStreams.push(id);
1468
+ for (const id of [...deleted.executions, ...deleted.reservedStreams]) pending.add(id);
1469
+ }
1470
+ // 2. What is held now.
1471
+ const fresh: ArchivedCopiesDeleted = { executions: [], reservedStreams: [] };
1472
+ for (const archive of this.plaintextArchives()) {
1473
+ if (archive.deleteExecution === undefined) continue; // asserted before the shred
1474
+ const keys = await this.archivedKeys(archive, executions, chains);
1475
+ for (const id of keys.executions) if (!pending.has(id)) fresh.executions.push(id);
1476
+ for (const id of keys.reservedStreams) if (!pending.has(id)) fresh.reservedStreams.push(id);
1477
+ }
1478
+ if (open === undefined && fresh.executions.length === 0 && fresh.reservedStreams.length === 0) {
1479
+ return undefined;
1480
+ }
1481
+ deleted.executions.push(...fresh.executions);
1482
+ deleted.reservedStreams.push(...fresh.reservedStreams);
1483
+ // 3. INTENT first (only when this pass found something new), then delete, then the completion.
1484
+ const intentId =
1485
+ open?.id ??
1486
+ (await this.appendIntent(ARCHIVE_DELETE_INTENDED, subjectId, tenant, {
1487
+ executions: deleted.executions,
1488
+ reservedStreams: deleted.reservedStreams,
1489
+ }));
1490
+ for (const archive of this.plaintextArchives()) {
1491
+ const del = archive.deleteExecution?.bind(archive);
1492
+ if (del === undefined) continue;
1493
+ for (const id of [...deleted.executions, ...deleted.reservedStreams]) {
1494
+ await del(id); // idempotent: an absent key is a no-op
1495
+ this.options.onArchiveKeyChanged?.(id);
1496
+ }
1497
+ }
1498
+ await this.appendCompletion(ARCHIVE_DELETED, subjectId, tenant, intentId, { ...deleted });
1499
+ return deleted;
1500
+ }
1501
+
1502
+ /**
1503
+ * ADR 0110 follow-up — erasure reaches payloads still in flight in a RESERVED inbox stream.
1504
+ *
1505
+ * A chain's inbox (`__loom_inbox__:<chainId>`, and with per-topic streams `…#<hash>` and `…#reg`) holds undelivered messages for its owner. The
1506
+ * runtime deletes it when the chain's head turns terminal, but a crash between a sender's append
1507
+ * and its re-read can leave an orphan, and until it is reclaimed it holds the subject's data.
1508
+ * Once the subject is erased (every execution of it terminal, so nothing can consume) the stream
1509
+ * of each execution that is the HEAD of its chain (terminal and not `continued`: a continued
1510
+ * execution's successor owns the stream) is deleted. Idempotent, and a no-op for a store that
1511
+ * cannot delete or an execution whose stream is already gone.
1512
+ *
1513
+ * This is the physical half. The cryptographic half is the key: the crypto plugin seals inbox
1514
+ * payloads under the OWNER's subject, so `shred` makes a payload unreadable even where a stream
1515
+ * cannot be deleted (a consumer then receives the `{ $shredded: true }` marker, never the raw
1516
+ * value).
1517
+ */
1518
+ private async purgeInboxStreams(chains: readonly FinishedChain[]): Promise<void> {
1519
+ const remove = this.options.eventStore.deleteExecution?.bind(this.options.eventStore);
1520
+ if (remove === undefined) return;
1521
+ for (const {
1522
+ streams: { legacy, registry, topics, quarantine },
1523
+ } of chains) {
1524
+ // ADR 0110 D10b — EVERY stream of the chain: its per-topic streams, the legacy one and, last,
1525
+ // the registry that names the topic streams (a crash midway leaves them findable).
1526
+ for (const stream of topics) await remove(stream);
1527
+ await remove(legacy);
1528
+ // ADR 0130 residuals D9: the chain's quarantine copies are in-flight payloads too.
1529
+ await remove(quarantine);
1530
+ if (registry !== undefined) await remove(registry);
1531
+ }
1532
+ }
1533
+
1534
+ /**
1535
+ * The inbox streams of the chains whose HEAD is among `executions`: terminal and not `continued`
1536
+ * (a continued execution's successor owns the chain's inbox and is judged on its own events).
1537
+ * Folded, and each chain's registry read, while the logs are still readable, so a decrypting store
1538
+ * has not yet lost its key.
1539
+ */
1540
+ private async finishedChains(
1541
+ executions: readonly string[],
1542
+ proof: DestructionProof | undefined
1543
+ ): Promise<FinishedChain[]> {
1544
+ // (a store that cannot delete streams still has chains whose ARCHIVED segments erasure removes)
1545
+ const chains = new Set<string>();
1546
+ for (const executionId of executions) {
1547
+ const folded = await this.foldExecution(executionId, proof);
1548
+ if (folded === undefined) continue;
1549
+ if (!TERMINAL_STATUS.has(folded.status) || folded.status === "continued") continue;
1550
+ if (folded.head === false) continue; // terminal, but not PROVEN a chain head: keep its streams
1551
+ chains.add(chainIdOfFirst(folded.first, executionId));
1552
+ }
1553
+ const streams: FinishedChain[] = [];
1554
+ const store = this.options.eventStore;
1555
+ const view = {
1556
+ read: (id: string) => this.readErasedOk(id, proof),
1557
+ // The registry may miss a topic stream (a sender that died before it registered): the prefix
1558
+ // scan is the backstop, so erasure reaches an unregistered stream too.
1559
+ ...(store.listExecutionsPage !== undefined && {
1560
+ listExecutionsPage: store.listExecutionsPage.bind(store),
1561
+ }),
1562
+ };
1563
+ for (const chainId of chains) {
1564
+ streams.push({ chainId, streams: await inboxStreamsOfChainDeep(view, chainId) });
1565
+ }
1566
+ return streams;
1567
+ }
1568
+
1569
+ /** The window proof for one call: the operator's per-call `force` asserts it, else the configured proof. */
1570
+ private proofFor(force: "key-destroyed-elsewhere" | undefined): DestructionProof | undefined {
1571
+ return force === "key-destroyed-elsewhere" ? () => true : this.options.destroyed;
1572
+ }
1573
+
1574
+ /** Subjects with a deferred erasure still awaiting completion (sorted, deduped across tenants). */
1575
+ pendingSubjects(): string[] {
1576
+ const out = new Set([...this.parked].map(subjectOfKey));
1577
+ if (this.parkedRefs.size > 0) {
1578
+ // A restored digest names its subject only through the index (one HMAC per indexed subject).
1579
+ for (const tenant of this.options.index.tenants()) {
1580
+ for (const subject of this.options.index.subjects(tenant)) {
1581
+ if (this.subjectRefs(subject, tenant).some((r) => this.parkedRefs.has(r))) {
1582
+ out.add(subject);
1583
+ }
1584
+ }
1585
+ }
1586
+ }
1587
+ return [...out].sort();
1588
+ }
1589
+
1590
+ /** The subset of `executions` that are not yet terminal (a live fold reading the key), with status. */
1591
+ private async liveExecutions(
1592
+ executions: readonly string[],
1593
+ proof: DestructionProof | undefined
1594
+ ): Promise<Array<{ id: string; status: ExecutionState["status"] }>> {
1595
+ const live: Array<{ id: string; status: ExecutionState["status"] }> = [];
1596
+ for (const executionId of executions) {
1597
+ const folded = await this.foldExecution(executionId, proof);
1598
+ if (folded === undefined) continue; // deleted by retention — nothing to protect
1599
+ if (!TERMINAL_STATUS.has(folded.status))
1600
+ live.push({ id: executionId, status: folded.status });
1601
+ }
1602
+ return live;
1603
+ }
1604
+
1605
+ /**
1606
+ * The log, or — when its subject is ALREADY shredded (a direct `shredSubject`, a retry, or the
1607
+ * window between the key's destruction and its tombstone) — the redacted view the typed error
1608
+ * carries: status and tip are plaintext, so liveness and the
1609
+ * chain's head can still be judged, and a re-run of the erasure stays idempotent.
1610
+ */
1611
+ private async readErasedOk(
1612
+ executionId: string,
1613
+ proof: DestructionProof | undefined
1614
+ ): Promise<EventEnvelope[]> {
1615
+ try {
1616
+ return await this.options.eventStore.read(executionId);
1617
+ } catch (err) {
1618
+ const redacted = (err as { name?: unknown; redacted?: EventEnvelope[] } | null)?.redacted;
1619
+ if (isPayloadShredded(err) && redacted) {
1620
+ return redacted;
1621
+ }
1622
+ // The ShredIncomplete window (ADR 0130 D7): the key is destroyed but the tombstone append
1623
+ // failed, so no PAYLOAD_SHREDDED exists yet and the read is `KeyUnavailableError`. Its
1624
+ // redacted view (plaintext status and tip) lets the retry judge liveness and complete, but
1625
+ // ONLY when the log records this subject's durable erase intent: without it the node merely
1626
+ // lacks the key (wrong key, partitioned key store) and the error is rethrown.
1627
+ const window = await erasureWindowView(err, proof);
1628
+ if (window !== undefined) return [...window];
1629
+ throw err;
1630
+ }
1631
+ }
1632
+
1633
+ /**
1634
+ * The latest checkpoint, or — in the destroy window — its at-rest form. A sealing snapshot store
1635
+ * throws `KeyUnavailableError` for a sealed checkpoint whose key is destroyed while no
1636
+ * `PAYLOAD_SHREDDED` exists yet (the crash between `keys.destroy` and the tombstones), and
1637
+ * `PayloadShreddedError` once a shred is recorded. Both are handled exactly like the event read
1638
+ * (`readErasedOk`) and `shredSubject` (`loadLatestSealed`): the proven shred reads sealed, the
1639
+ * unavailable key only when the log holds this subject's erase intent AND `proof` shows the key
1640
+ * store destroyed it. Anything else (a key merely missing, no intent) is rethrown: real key loss
1641
+ * is never masked. `opaque` is set when the sealed state carries no plaintext status.
1642
+ */
1643
+ private async readSnapshotErasedOk(
1644
+ executionId: string,
1645
+ events: readonly EventEnvelope[],
1646
+ proof: DestructionProof | undefined
1647
+ ): Promise<{
1648
+ snapshot: { executionId?: string; sequence: number; state: ExecutionState } | null;
1649
+ opaque: boolean;
1650
+ subjectId?: string;
1651
+ }> {
1652
+ const store = this.options.snapshotStore;
1653
+ if (store === undefined) return { snapshot: null, opaque: false };
1654
+ try {
1655
+ return { snapshot: await store.loadLatest(executionId), opaque: false };
1656
+ } catch (err) {
1657
+ const sealedRead = store.loadLatestSealed?.bind(store);
1658
+ const inWindow = isPayloadShredded(err) || (await isErasureWindow(err, events, proof));
1659
+ if (!inWindow || sealedRead === undefined) throw err;
1660
+ const snapshot = await sealedRead(executionId);
1661
+ const opaque =
1662
+ typeof (snapshot?.state as { status?: unknown } | undefined)?.status !== "string";
1663
+ return { snapshot, opaque, ...(isKeyUnavailable(err) && { subjectId: err.subjectId }) };
1664
+ }
1665
+ }
1666
+
1667
+ /** One execution's folded status and its first event, or `undefined` when its log is gone. */
1668
+ private async foldExecution(
1669
+ executionId: string,
1670
+ proof: DestructionProof | undefined
1671
+ ): Promise<
1672
+ { status: ExecutionState["status"]; first: EventEnvelope; head?: boolean } | undefined
1673
+ > {
1674
+ const events = await this.readErasedOk(executionId, proof);
1675
+ const first = events[0];
1676
+ if (first === undefined) return undefined;
1677
+ const read = await this.readSnapshotErasedOk(executionId, events, proof);
1678
+ const snapshot = read.snapshot;
1679
+ // Fill a blank legacy identity; a NON-blank one naming another execution is a store or
1680
+ // tenant-scoping defect, refused as everywhere else a fold is seeded (ADR 0106).
1681
+ if (snapshot !== null) {
1682
+ // The row's index columns are plaintext even when its state is sealed: always refuse a
1683
+ // checkpoint of another execution (a store or tenant-scoping defect, ADR 0106).
1684
+ if (snapshot.executionId !== undefined && snapshot.executionId !== executionId) {
1685
+ throw new TypeError(
1686
+ `snapshot store returned a checkpoint of "${snapshot.executionId}" for "${executionId}"`
1687
+ );
1688
+ }
1689
+ if (!read.opaque) assertSnapshotIdentity(snapshot.state, executionId, first.workflowId ?? "");
1690
+ }
1691
+ if (read.opaque && snapshot !== null) {
1692
+ // A whole-state sealed checkpoint whose key is gone: its status is unreadable.
1693
+ // (1) The log is not compacted: gap-free from 0 AND reaching the checkpoint (compaction
1694
+ // keeps only sequence 0 of the prefix). Then ignore the checkpoint and fold the WHOLE log by
1695
+ // its plaintext event types, the no-snapshot path: continued, suspended and a re-open after
1696
+ // the intent all read as they are.
1697
+ const last0 = events[events.length - 1];
1698
+ if (
1699
+ events.every((e, i) => e.sequence === i) &&
1700
+ last0 !== undefined &&
1701
+ last0.sequence >= snapshot.sequence
1702
+ ) {
1703
+ return { status: foldEvents(events, { executionId }).status, first };
1704
+ }
1705
+ // (2) Compacted: only the tail above the checkpoint is left. Judge it by the rule
1706
+ // `shredSubject` uses (`isQuiescent`). A chain HEAD is claimed only when the tail itself shows
1707
+ // a non-`continued` terminal kind; anything less keeps the inbox streams (the key's
1708
+ // destruction already makes their payloads unreadable).
1709
+ const tail = events.filter((e) => e.sequence > snapshot.sequence);
1710
+ // The intent is looked for under THIS subject (the one whose key is gone), as shredSubject does.
1711
+ const quiescent = isQuiescent(tail, undefined, recordsErasureIntent(events, read.subjectId));
1712
+ const last = [...tail].reverse().find((e) => isAnsweringKind(e));
1713
+ const status = !quiescent
1714
+ ? foldEvents(tail, { executionId }).status
1715
+ : last?.eventType === "EXECUTION_SUSPENDED"
1716
+ ? "suspended"
1717
+ : last === undefined
1718
+ ? "completed" // quiescent on the intent alone: terminal for liveness, head unproven
1719
+ : (TERMINAL_STATUS_OF_EVENT.get(last.eventType) ?? "completed");
1720
+ const head = last !== undefined && status !== "continued" && TERMINAL_STATUS.has(status);
1721
+ return { status, first, head };
1722
+ }
1723
+ const state =
1724
+ snapshot !== null
1725
+ ? foldEvents(
1726
+ events.filter((e) => e.sequence > snapshot.sequence),
1727
+ // A pre-ADR-0106 checkpoint may carry a blank identity: fill it, as the kernel does.
1728
+ // A pre-1.21.0 snapshot may read `failed` for a caught step failure: repair it
1729
+ // (ADR 0105), or a live execution would read terminal and be shredded.
1730
+ {
1731
+ ...normalizeSnapshotState(snapshot.state),
1732
+ executionId: snapshot.state.executionId || executionId,
1733
+ workflowId: snapshot.state.workflowId || (first.workflowId ?? ""),
1734
+ }
1735
+ )
1736
+ : foldEvents(events, { executionId });
1737
+ return { status: state.status, first };
1738
+ }
1739
+
1740
+ // `prior.length` is the next sequence only because a reserved stream is never compacted:
1741
+ // `LogLifecycle.compact` refuses one (ADR 0106).
1742
+ private async appendPending(subjectId: string, tenant: string): Promise<void> {
1743
+ const prior = await this.options.eventStore.read(ERASURE_STREAM);
1744
+ await this.options.eventStore.append({
1745
+ id: `erasure-${prior.length}`,
1746
+ workflowId: ERASURE_STREAM,
1747
+ executionId: ERASURE_STREAM,
1748
+ eventType: "ERASURE_PENDING",
1749
+ sequence: prior.length,
1750
+ timestamp: 0, // deterministic; the fact of the request is what matters
1751
+ payload: this.subjectFields(subjectId, tenant),
1752
+ });
1753
+ }
1754
+ }
1755
+
1756
+ /** The ids of the `ERASURE_PENDING` rows an `ERASURE_PENDING_CLEARED` closed. */
1757
+ function clearedPendingIds(control: readonly EventEnvelope[]): Set<string> {
1758
+ const out = new Set<string>();
1759
+ for (const e of control) {
1760
+ if (e.eventType !== PENDING_CLEARED) continue;
1761
+ const ids = (e.payload as { pendingIds?: unknown } | null)?.pendingIds;
1762
+ if (Array.isArray(ids)) for (const id of ids) if (typeof id === "string") out.add(id);
1763
+ }
1764
+ return out;
1765
+ }
1766
+
1767
+ /** A chain whose head the subject owned, with the live streams of its inbox. */
1768
+ interface FinishedChain {
1769
+ chainId: string;
1770
+ streams: InboxStreams;
1771
+ }
1772
+
1773
+ // `tenant\x00subject` — NUL separates the two id spaces (never appears in a real id), so a
1774
+ // tenant/subject boundary can't be spoofed by crafting an id that contains the separator.
1775
+ function parkKey(tenant: string, subject: string): string {
1776
+ return `${tenant}\x00${subject}`;
1777
+ }
1778
+ function subjectOfKey(key: string): string {
1779
+ return key.slice(key.indexOf("\x00") + 1);
1780
+ }