@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,1294 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EraseCoordinator = exports.ControlStreamKeyError = exports.ContentCopiesRemainError = exports.SenderRedactionUnsupportedError = exports.ArchivedCopiesRemainError = exports.defaultSenderMatches = exports.ERASURE_STREAM = void 0;
4
+ exports.claimCheckRefsOf = claimCheckRefsOf;
5
+ exports.trustedClaimCheckRefsOf = trustedClaimCheckRefsOf;
6
+ const node_crypto_1 = require("node:crypto");
7
+ const event_runtime_1 = require("@loomwork/event-runtime");
8
+ const replay_vm_1 = require("@loomwork/replay-vm");
9
+ const snapshot_runtime_1 = require("@loomwork/snapshot-runtime");
10
+ const SubjectIndex_1 = require("./SubjectIndex");
11
+ // ── T3-R3/R4.5 — subject erasure (GDPR Art. 17, right to be forgotten) ─────────
12
+ //
13
+ // Erasure destroys a subject's crypto key so its sensitive fields become unrecoverable
14
+ // (K10.2). The one hard rule (R4.5): NEVER shred a fold that is still in flight — a live
15
+ // execution reading the just-destroyed key would fail. So erasure is liveness-gated:
16
+ // • all of a subject's executions terminal → shred now.
17
+ // • any still live → PARK the request (durable ERASURE_PENDING on a
18
+ // control stream) and complete it automatically
19
+ // when the last live execution terminates.
20
+ // Parking is honest (the request is recorded, not silently dropped) and survives a restart
21
+ // (the control stream folds back to the pending set).
22
+ /** Reserved control stream carrying deferred-erasure facts (ERASURE_PENDING). */
23
+ exports.ERASURE_STREAM = "__loom_erasure__";
24
+ const TERMINAL_STATUS = new Set([
25
+ "completed",
26
+ "failed",
27
+ "cancelled",
28
+ "continued",
29
+ ]);
30
+ const TERMINAL_STATUS_OF_EVENT = new Map([
31
+ ["EXECUTION_COMPLETED", "completed"],
32
+ ["EXECUTION_FAILED", "failed"],
33
+ ["EXECUTION_CANCELLED", "cancelled"],
34
+ ["EXECUTION_CONTINUED", "continued"],
35
+ ]);
36
+ const TERMINAL_EVENTS = new Set([
37
+ "EXECUTION_COMPLETED",
38
+ "EXECUTION_FAILED",
39
+ "EXECUTION_CANCELLED",
40
+ "EXECUTION_CONTINUED",
41
+ ]);
42
+ /** The default: the sender's `actor` is the subject id (and, in a tenant partition, the tenant agrees). */
43
+ const defaultSenderMatches = (subjectId, tenant, sender) => sender.actor === subjectId && (tenant === SubjectIndex_1.DEFAULT_TENANT || sender.tenant === tenant);
44
+ exports.defaultSenderMatches = defaultSenderMatches;
45
+ /**
46
+ * The staging prefix an EARLIER build used to rewrite a segment list (read, stage, delete, re-add). The
47
+ * rewrite is now one atomic `ArchiveStore.replaceSegments` (SPI 1.1.0) and nothing writes a stage any
48
+ * more; {@link EraseCoordinator.recoverRedactions} still restores one such a build left behind.
49
+ */
50
+ const REDACT_STAGE = "__loom_redact__:";
51
+ /** The control-stream event types of the erasure's archive work (intent first, then the completion). */
52
+ const ARCHIVE_DELETE_INTENDED = "ERASURE_ARCHIVE_DELETE_INTENDED";
53
+ const ARCHIVE_DELETED = "ERASURE_ARCHIVE_DELETED";
54
+ const CONTENT_DELETE_INTENDED = "ERASURE_CONTENT_DELETE_INTENDED";
55
+ const CONTENT_DELETED = "ERASURE_CONTENT_DELETED";
56
+ const SENDER_REDACTION_INTENDED = "ERASURE_SENDER_REDACTION_INTENDED";
57
+ const SENDER_REDACTED = "ERASURE_SENDER_REDACTED";
58
+ /**
59
+ * Closes `ERASURE_PENDING` rows once their subject is erased: `{ pendingIds }`, the ids of the rows it
60
+ * closes (never a subject). Without it every park ever written was reloaded as parked after a restart,
61
+ * and a later execution of an erased subject was erased automatically when it ended.
62
+ */
63
+ const PENDING_CLEARED = "ERASURE_PENDING_CLEARED";
64
+ /** ADR 0143 D5: how many distinct citing executions per address the ownership proof is tried under. */
65
+ const MAX_CITERS_PER_ADDRESS = 16;
66
+ /** How many times a redaction of one key retries a concurrent writer's conflict before it gives up loudly. */
67
+ const REDACT_RETRIES = 16;
68
+ /**
69
+ * Erasure cannot complete while a plaintext archive still holds the subject's segments and offers no
70
+ * way to delete them. Thrown BEFORE the key is destroyed (nothing was shredded).
71
+ */
72
+ class ArchivedCopiesRemainError extends Error {
73
+ streams;
74
+ retryable = false;
75
+ constructor(streams) {
76
+ super(`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.`);
77
+ this.streams = streams;
78
+ this.name = "ArchivedCopiesRemainError";
79
+ Object.setPrototypeOf(this, ArchivedCopiesRemainError.prototype);
80
+ }
81
+ }
82
+ exports.ArchivedCopiesRemainError = ArchivedCopiesRemainError;
83
+ /**
84
+ * Erasure cannot complete: a plaintext archive holds other chains' archived inbox envelopes naming the
85
+ * subject as their sender, and it cannot rewrite a segment atomically (`replaceSegments`, ArchiveStore SPI
86
+ * 1.1.0), so the identity could not be redacted. Thrown BEFORE the key is destroyed (nothing was shredded).
87
+ */
88
+ class SenderRedactionUnsupportedError extends Error {
89
+ streams;
90
+ retryable = false;
91
+ constructor(streams) {
92
+ super(`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.`);
93
+ this.streams = streams;
94
+ this.name = "SenderRedactionUnsupportedError";
95
+ Object.setPrototypeOf(this, SenderRedactionUnsupportedError.prototype);
96
+ }
97
+ }
98
+ exports.SenderRedactionUnsupportedError = SenderRedactionUnsupportedError;
99
+ /**
100
+ * Erasure cannot complete: the subject's logs cite claim-checked blobs (ADR 0140) in a content store that
101
+ * erasure cannot delete from, or cannot prove other executions do not cite. Thrown BEFORE the key is
102
+ * destroyed (nothing was shredded).
103
+ */
104
+ class ContentCopiesRemainError extends Error {
105
+ count;
106
+ retryable = false;
107
+ constructor(count, why) {
108
+ super(`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.`);
109
+ this.count = count;
110
+ this.name = "ContentCopiesRemainError";
111
+ Object.setPrototypeOf(this, ContentCopiesRemainError.prototype);
112
+ }
113
+ }
114
+ exports.ContentCopiesRemainError = ContentCopiesRemainError;
115
+ /** Every claim-check envelope ref `value` cites (iterative, cycle-safe; the `ref` leaf is not descended). */
116
+ function claimCheckRefsOf(value, into = new Set()) {
117
+ const stack = [value];
118
+ const seen = new WeakSet();
119
+ while (stack.length > 0) {
120
+ const current = stack.pop();
121
+ if (typeof current !== "object" || current === null || seen.has(current))
122
+ continue;
123
+ seen.add(current);
124
+ const env = current;
125
+ const address = env.__loomClaimCheck === true ? env.ref?.__loomContent : undefined;
126
+ if (typeof address === "string")
127
+ into.add(address);
128
+ for (const [key, item] of Object.entries(current)) {
129
+ if (typeof address === "string" && key === "ref")
130
+ continue;
131
+ stack.push(item);
132
+ }
133
+ }
134
+ return into;
135
+ }
136
+ /**
137
+ * ADR 0140 D11 - the claim-check refs an execution cites at a TRUSTED position: the top-level `output`
138
+ * of a recorded `STEP_COMPLETED` (through a taint wrapper). An envelope the opt-in preset wrote sits
139
+ * exactly there; one NESTED inside a tool's result is tool-authored data and may be forged, so it is not
140
+ * evidence that anyone cites the blob.
141
+ */
142
+ function trustedClaimCheckRefsOf(events, into = new Set()) {
143
+ let sawStep = false;
144
+ for (const e of events) {
145
+ if (e.eventType !== "STEP_COMPLETED")
146
+ continue;
147
+ sawStep = true;
148
+ let out = e.payload?.output;
149
+ if (typeof out === "object" &&
150
+ out !== null &&
151
+ out.__loomTainted !== undefined) {
152
+ out = out.value;
153
+ }
154
+ if (typeof out !== "object" || out === null)
155
+ continue;
156
+ const env = out;
157
+ const address = env.__loomClaimCheck === true ? env.ref?.__loomContent : undefined;
158
+ if (typeof address === "string")
159
+ into.add(address);
160
+ }
161
+ return { refs: into, sawStep };
162
+ }
163
+ /**
164
+ * An erasure met an OPEN control-stream intent written under a `controlStreamKey` this coordinator
165
+ * does not hold, so it cannot tell whether the intent is this subject's. Fail closed: pass the key
166
+ * that wrote it in `previousControlStreamKeys` (`keyId` names it) and retry.
167
+ */
168
+ class ControlStreamKeyError extends Error {
169
+ keyId;
170
+ intentId;
171
+ constructor(keyId, intentId) {
172
+ super(`erasure control stream: open intent ${intentId} was written under controlStreamKey ${keyId}, which this coordinator does not hold; pass that key in previousControlStreamKeys and retry`);
173
+ this.keyId = keyId;
174
+ this.intentId = intentId;
175
+ this.name = "ControlStreamKeyError";
176
+ }
177
+ }
178
+ exports.ControlStreamKeyError = ControlStreamKeyError;
179
+ const MIN_CONTROL_KEY_BYTES = 16;
180
+ const defaultWarn = (message) => {
181
+ console.warn(`[loom] ${message}`);
182
+ };
183
+ /**
184
+ * Coordinates liveness-gated subject erasure and the auto-completion of parked requests.
185
+ * Feed `handleEvent` the event stream (e.g. from `defineConfig`'s `onEvent`) so a parked
186
+ * erasure completes the moment the subject's last live execution terminates.
187
+ */
188
+ class EraseCoordinator {
189
+ options;
190
+ // Parked requests keyed by `tenant\x00subject` (T3-R2.4 — two tenants may share a subject
191
+ // id; they must not collide into one park entry). Authoritative live view; also durable.
192
+ parked = new Set();
193
+ // Parked requests restored from a `subjectRef` row (`controlStreamKey`): only the digest is durable.
194
+ parkedRefs = new Set();
195
+ // The control-stream keys (`controlStreamKey` first, then the previous ones) and their fingerprints.
196
+ controlKeys;
197
+ constructor(options) {
198
+ this.options = options;
199
+ const keys = [
200
+ ...(options.controlStreamKey !== undefined ? [options.controlStreamKey] : []),
201
+ ...(options.previousControlStreamKeys ?? []),
202
+ ];
203
+ if (options.controlStreamKey === undefined && keys.length > 0) {
204
+ throw new TypeError("previousControlStreamKeys needs a controlStreamKey: new rows are written under the current key");
205
+ }
206
+ for (const key of keys) {
207
+ const bytes = typeof key === "string" ? Buffer.byteLength(key) : key.byteLength;
208
+ if (bytes < MIN_CONTROL_KEY_BYTES) {
209
+ throw new TypeError(`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`);
210
+ }
211
+ }
212
+ this.controlKeys = keys.map((key) => ({
213
+ key,
214
+ id: (0, node_crypto_1.createHmac)("sha256", key).update("loom-erasure-key-id").digest("hex").slice(0, 16),
215
+ }));
216
+ }
217
+ /** Best-effort: repopulate the parked set from the durable control stream (restart recovery). */
218
+ async loadParked() {
219
+ // The parked set FIRST: it is what the key-window guard reads, so a failure of the archive recovery
220
+ // below must never leave it empty (that would fail open).
221
+ const control = await this.options.eventStore.read(exports.ERASURE_STREAM);
222
+ const cleared = clearedPendingIds(control);
223
+ for (const event of control) {
224
+ if (event.eventType === "ERASURE_PENDING" && !cleared.has(event.id)) {
225
+ const p = event.payload;
226
+ if (typeof p.subjectRef === "string") {
227
+ this.parkedRefs.add(p.subjectRef);
228
+ if (!this.controlKeys.some((k) => k.id === p.keyId)) {
229
+ (this.options.onWarn ?? defaultWarn)(`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)`);
230
+ }
231
+ }
232
+ else if (typeof p.subjectId === "string") {
233
+ this.parked.add(parkKey(p.tenant ?? SubjectIndex_1.DEFAULT_TENANT, p.subjectId));
234
+ }
235
+ }
236
+ }
237
+ // ADR 0139 N1: a stage an earlier build left (a crash mid-redaction) is restored at BOOT, not at the
238
+ // next erasure that happens to scan the archive. A failing archive (down, unreadable) is LOUD but not
239
+ // fatal here: recovery stays pending, to be retried by `recoverRedactions` (the operator verb
240
+ // `app.recoverArchiveRedactions`) and by the next redaction.
241
+ try {
242
+ await this.recoverRedactions();
243
+ }
244
+ catch (err) {
245
+ (this.options.onWarn ?? defaultWarn)(`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`);
246
+ }
247
+ }
248
+ /**
249
+ * ADR 0139 D22 (owner ruling 2026-10-31) - REINSTATE an erased message sender: the explicit, RECORDED
250
+ * operator verb that lets a sender erased by {@link eraseSubject} send again, under a NEW handle (their
251
+ * old messages stay erased and unlinkable to it). Without it a send from an erased sender keeps throwing
252
+ * `SenderErasedError`. Journaled on the control stream: `SENDER_REINSTATE_INTENDED` first, then
253
+ * `SENDER_REINSTATED` with the keyed non-identifying `ref`, the epoch, who (`options.by`) and when;
254
+ * NEVER the plaintext identity. Resolves with whether anything was reinstated (`false`: the sender is
255
+ * not erased, or never sent). Throws unless the coordinator was given `reinstateSender`.
256
+ */
257
+ async reinstateSender(actor, tenant = SubjectIndex_1.DEFAULT_TENANT, options = {}) {
258
+ const reinstate = this.options.reinstateSender;
259
+ if (reinstate === undefined) {
260
+ throw new TypeError("reinstateSender needs the crypto layer's default sender handles: pass `reinstateSender` to the EraseCoordinator (defineConfig wires it from the crypto event store)");
261
+ }
262
+ const by = options.by ?? "operator";
263
+ const intentId = await this.appendControl("SENDER_REINSTATE_INTENDED", {
264
+ ...(tenant !== SubjectIndex_1.DEFAULT_TENANT && { tenant }),
265
+ by,
266
+ at: Date.now(),
267
+ });
268
+ const result = await reinstate(actor, tenant);
269
+ await this.appendControl("SENDER_REINSTATED", {
270
+ ...(tenant !== SubjectIndex_1.DEFAULT_TENANT && { tenant }),
271
+ intentId,
272
+ by,
273
+ at: Date.now(),
274
+ reinstated: result.reinstated,
275
+ ...(result.ref !== undefined && { ref: result.ref }),
276
+ ...(result.epoch !== undefined && { epoch: result.epoch }),
277
+ });
278
+ return result;
279
+ }
280
+ /** Request erasure of a subject within a tenant. Shreds now if all executions are terminal, else parks. */
281
+ async eraseSubject(subjectId, tenant = SubjectIndex_1.DEFAULT_TENANT, options = {}) {
282
+ const executions = this.options.index.executionsOf(subjectId, tenant);
283
+ if (executions.length === 0) {
284
+ return { subjectId, outcome: "unknown", erased: [], pending: [] };
285
+ }
286
+ const shredParked = options.parked === "shred";
287
+ if (shredParked && this.options.cancel === undefined) {
288
+ throw new TypeError('eraseSubject({ parked: "shred" }) cancels each parked owner before shredding: pass `cancel` to the EraseCoordinator (defineConfig wires it)');
289
+ }
290
+ const proof = this.proofFor(options.force);
291
+ let nonTerminal = await this.liveExecutions(executions, proof);
292
+ // `defer`: every non-terminal execution blocks. `shred`: only a RUNNING one does.
293
+ let live = shredParked
294
+ ? nonTerminal.filter((x) => x.status !== "suspended").map((x) => x.id)
295
+ : nonTerminal.map((x) => x.id);
296
+ let parkedOwners = [];
297
+ if (shredParked && live.length === 0 && nonTerminal.length > 0) {
298
+ // No owner may be left stuck: cancel every parked one FIRST (the log is still readable and
299
+ // the fence is the runtime's own). A failure, or an owner that is not terminal afterwards,
300
+ // defers the subject.
301
+ parkedOwners = nonTerminal.map((x) => x.id);
302
+ try {
303
+ for (const id of parkedOwners)
304
+ await this.options.cancel(id, "erased");
305
+ }
306
+ catch {
307
+ // fall through to the recheck: whatever did not become terminal blocks the shred
308
+ }
309
+ nonTerminal = await this.liveExecutions(executions, proof);
310
+ live = nonTerminal.map((x) => x.id);
311
+ }
312
+ if (live.length === 0) {
313
+ // Resolved BEFORE the key is destroyed: a decrypting store cannot read these logs afterwards.
314
+ const chains = await this.finishedChains(executions, proof);
315
+ // ADR 0138 / 0139: a plaintext archive that cannot delete would outlive the erasure - refuse BEFORE
316
+ // the key is destroyed.
317
+ await this.assertArchivesErasable(subjectId, tenant, executions, chains);
318
+ // ADR 0140: the claim-checked blobs the subject's logs cite, collected (and the intent journaled)
319
+ // BEFORE the key is destroyed - afterwards the logs are unreadable.
320
+ const content = await this.planContentDeletion(subjectId, tenant, executions, proof);
321
+ if (options.force !== undefined) {
322
+ await this.options.shred(subjectId, executions, { force: options.force });
323
+ }
324
+ else {
325
+ await this.options.shred(subjectId, executions);
326
+ }
327
+ await this.options.eraseSender?.(subjectId, tenant);
328
+ await this.purgeInboxStreams(chains);
329
+ const archived = await this.deleteArchivedCopies(subjectId, tenant, executions, chains);
330
+ const contentDeleted = await this.finishContentDeletion(subjectId, tenant, content);
331
+ const redactions = await this.redactSenderCopies(subjectId, tenant);
332
+ await this.clearPending(subjectId, tenant);
333
+ this.unpark(subjectId, tenant);
334
+ return {
335
+ subjectId,
336
+ outcome: "erased",
337
+ erased: executions,
338
+ pending: [],
339
+ ...(archived !== undefined && { archivedCopiesDeleted: archived }),
340
+ ...(contentDeleted !== undefined && { contentDeleted }),
341
+ ...(redactions.length > 0 && { senderRedactions: redactions }),
342
+ ...(shredParked && { parkedShredded: parkedOwners }),
343
+ };
344
+ }
345
+ // R4.5 — a live fold is in flight; record the request durably and defer (unless the caller owns
346
+ // the retry: `record: false` writes nothing and registers nothing).
347
+ if (options.record !== false && !this.isParked(subjectId, tenant)) {
348
+ this.parked.add(parkKey(tenant, subjectId));
349
+ await this.appendPending(subjectId, tenant);
350
+ }
351
+ return { subjectId, outcome: "parked", erased: [], pending: live };
352
+ }
353
+ /** Complete a parked erasure when the subject's last live execution terminates. */
354
+ async handleEvent(event) {
355
+ if (!TERMINAL_EVENTS.has(event.eventType))
356
+ return;
357
+ if (this.parked.size === 0 && this.parkedRefs.size === 0)
358
+ return;
359
+ const subjectId = this.options.index.subjectOfExecution(event.executionId);
360
+ if (subjectId === undefined)
361
+ return;
362
+ const tenant = this.options.index.tenantOfExecution(event.executionId) ?? SubjectIndex_1.DEFAULT_TENANT;
363
+ if (!this.isParked(subjectId, tenant))
364
+ return;
365
+ const executions = this.options.index.executionsOf(subjectId, tenant);
366
+ if ((await this.liveExecutions(executions, this.options.destroyed)).length > 0)
367
+ return; // still blocked
368
+ const chains = await this.finishedChains(executions, this.options.destroyed);
369
+ await this.assertArchivesErasable(subjectId, tenant, executions, chains);
370
+ const content = await this.planContentDeletion(subjectId, tenant, executions, this.options.destroyed);
371
+ await this.options.shred(subjectId, executions);
372
+ await this.options.eraseSender?.(subjectId, tenant);
373
+ await this.purgeInboxStreams(chains);
374
+ await this.deleteArchivedCopies(subjectId, tenant, executions, chains);
375
+ await this.finishContentDeletion(subjectId, tenant, content);
376
+ await this.redactSenderCopies(subjectId, tenant);
377
+ await this.clearPending(subjectId, tenant);
378
+ this.unpark(subjectId, tenant);
379
+ }
380
+ /**
381
+ * Close the subject's open `ERASURE_PENDING` rows (either form) now that it is erased, so a restart
382
+ * does not park it again. Written LAST: a crash before it leaves the request parked, and completing
383
+ * it again is idempotent. Names the rows, never the subject.
384
+ */
385
+ async clearPending(subjectId, tenant) {
386
+ const control = await this.options.eventStore.read(exports.ERASURE_STREAM);
387
+ const cleared = clearedPendingIds(control);
388
+ const pendingIds = control
389
+ .filter((e) => e.eventType === "ERASURE_PENDING" &&
390
+ !cleared.has(e.id) &&
391
+ this.rowNames(e.payload, subjectId, tenant))
392
+ .map((e) => e.id);
393
+ if (pendingIds.length > 0)
394
+ await this.appendControl(PENDING_CLEARED, { pendingIds });
395
+ }
396
+ /** Whether a request to erase the subject is parked (registered in memory or restored from either row form). */
397
+ isParked(subjectId, tenant) {
398
+ if (this.parked.has(parkKey(tenant, subjectId)))
399
+ return true;
400
+ return this.subjectRefs(subjectId, tenant).some((ref) => this.parkedRefs.has(ref));
401
+ }
402
+ unpark(subjectId, tenant) {
403
+ this.parked.delete(parkKey(tenant, subjectId));
404
+ for (const ref of this.subjectRefs(subjectId, tenant))
405
+ this.parkedRefs.delete(ref);
406
+ }
407
+ /**
408
+ * The keyed digests a control row may name the subject by: one per held key, the current key's first
409
+ * (`[]` without `controlStreamKey`). Each part is length-prefixed, so no (tenant, subject) pair can
410
+ * be spelled as another.
411
+ */
412
+ subjectRefs(subjectId, tenant) {
413
+ const input = `loom-erasure-subject:${tenant.length}:${tenant}:${subjectId.length}:${subjectId}`;
414
+ return this.controlKeys.map(({ key }) => `hmac-sha256:${(0, node_crypto_1.createHmac)("sha256", key).update(input).digest("hex")}`);
415
+ }
416
+ /** How a control row names the subject: the current key's digest and its id, or (no key) the plaintext id and tenant. */
417
+ subjectFields(subjectId, tenant) {
418
+ const current = this.controlKeys[0];
419
+ if (current !== undefined) {
420
+ return { subjectRef: this.subjectRefs(subjectId, tenant)[0], keyId: current.id };
421
+ }
422
+ return { subjectId, ...(tenant !== SubjectIndex_1.DEFAULT_TENANT && { tenant }) };
423
+ }
424
+ /** Whether a control row names the subject, in either form (a plaintext row predates the key). */
425
+ rowNames(payload, subjectId, tenant) {
426
+ const p = payload;
427
+ if (p === null || typeof p !== "object")
428
+ return false;
429
+ if (typeof p.subjectRef === "string") {
430
+ return this.subjectRefs(subjectId, tenant).includes(p.subjectRef);
431
+ }
432
+ return p.subjectId === subjectId && (p.tenant ?? SubjectIndex_1.DEFAULT_TENANT) === tenant;
433
+ }
434
+ /**
435
+ * ADR 0139 D12 - a PLAINTEXT archive holds other chains' reserved segments naming the erased subject as
436
+ * the sender. Each is rewritten with the identity replaced by an erased marker (the segment digest masks
437
+ * the sender, so it still verifies; the segment's sender digest is recomputed in the same swap) through
438
+ * ONE atomic `ArchiveStore.replaceSegments` per key (ADR 0139 N1: a trim on another node that appends a
439
+ * segment after this read makes the swap fail, and the key is re-read and retried - never lost, never
440
+ * restored over). The INTENT (the keys) is journaled first (`ERASURE_SENDER_REDACTION_INTENDED`) and the
441
+ * completion after (`ERASURE_SENDER_REDACTED`), so a crash between the two is finished by the retry from
442
+ * the intent and a redaction is never unrecorded. Cost: ONE pass over the archive's reserved keys per
443
+ * erasure (a `listExecutions` prefix walk and a `read` of each key; not indexed - an erasure is rare).
444
+ * A live, not yet archived envelope keeps its sender until it is trimmed (a plaintext store has no key to
445
+ * shred; the sender is sealed by default in a sealing deployment, ADR 0139 N6).
446
+ */
447
+ async redactSenderCopies(subjectId, tenant) {
448
+ const ref = (0, node_crypto_1.randomBytes)(8).toString("hex"); // one stable non-identifying reference per erased sender
449
+ await this.recoverRedactions(); // (a stage an earlier build left; a no-op on a clean archive)
450
+ const open = await this.openIntent(SENDER_REDACTION_INTENDED, SENDER_REDACTED, subjectId, tenant);
451
+ // 1. PLAN: one pass over the reserved keys, reading each once.
452
+ const plan = [];
453
+ for (const archive of this.plaintextArchives()) {
454
+ if (archive.replaceSegments === undefined)
455
+ continue; // refused before the shred if it held a copy
456
+ for (const key of await this.reservedArchiveKeys(archive)) {
457
+ const segments = await archive.read(key);
458
+ const changed = this.redactedSegments(segments, subjectId, tenant, ref).changed;
459
+ if (changed > 0) {
460
+ plan.push({
461
+ archive: archive,
462
+ key,
463
+ segments,
464
+ changed,
465
+ });
466
+ }
467
+ }
468
+ }
469
+ const intended = open?.payload?.redactions;
470
+ if (plan.length === 0 && open === undefined)
471
+ return [];
472
+ // 2. INTENT first (what is about to be rewritten), unless an interrupted attempt already journaled it.
473
+ const intentId = open?.id ??
474
+ (await this.appendIntent(SENDER_REDACTION_INTENDED, subjectId, tenant, {
475
+ redactions: plan.map((p) => ({ streamId: p.key, envelopes: p.changed })),
476
+ }));
477
+ // 3. APPLY: one atomic swap per key; a concurrent append makes it conflict, and the key is re-read.
478
+ const done = new Map();
479
+ for (const item of plan) {
480
+ let segments = item.segments;
481
+ for (let attempt = 0;; attempt++) {
482
+ const next = this.redactedSegments(segments, subjectId, tenant, ref);
483
+ if (next.changed === 0)
484
+ break; // another writer redacted it already
485
+ try {
486
+ await item.archive.replaceSegments(item.key, segments.map(event_runtime_1.headerOf), next.segments);
487
+ done.set(item.key, next.changed);
488
+ this.options.onArchiveKeyChanged?.(item.key);
489
+ break;
490
+ }
491
+ catch (err) {
492
+ if (!(err instanceof event_runtime_1.ArchiveConflictError) || attempt + 1 >= REDACT_RETRIES)
493
+ throw err;
494
+ segments = await item.archive.read(item.key);
495
+ }
496
+ }
497
+ }
498
+ // 4. COMPLETION: the planned redactions, plus any key this pass found beyond the intent.
499
+ const all = new Map();
500
+ for (const r of intended ?? [])
501
+ all.set(r.streamId, r.envelopes);
502
+ for (const p of plan)
503
+ all.set(p.key, Math.max(all.get(p.key) ?? 0, done.get(p.key) ?? p.changed));
504
+ const redactions = [...all].map(([streamId, envelopes]) => ({ streamId, envelopes }));
505
+ await this.appendCompletion(SENDER_REDACTED, subjectId, tenant, intentId, { redactions });
506
+ return redactions;
507
+ }
508
+ /** `segments` with the matching senders replaced by the erased marker, and the sender digest recomputed. */
509
+ redactedSegments(segments, subjectId, tenant, ref) {
510
+ const matches = this.options.senderMatches ?? exports.defaultSenderMatches;
511
+ let changed = 0;
512
+ const out = segments.map((seg) => {
513
+ let here = 0;
514
+ const envelopes = seg.envelopes.map((e) => {
515
+ const sender = e.payload?.sender;
516
+ if (e.eventType !== "INBOX_MESSAGE" ||
517
+ typeof sender !== "object" ||
518
+ sender === null ||
519
+ !matches(subjectId, tenant, sender)) {
520
+ return e;
521
+ }
522
+ here++;
523
+ return {
524
+ ...e,
525
+ payload: { ...e.payload, sender: (0, event_runtime_1.senderErasedMarker)(ref) },
526
+ };
527
+ });
528
+ if (here === 0)
529
+ return seg;
530
+ changed += here;
531
+ return { ...seg, envelopes, senderDigest: (0, event_runtime_1.senderDigestOf)(envelopes) };
532
+ });
533
+ return { segments: out, changed };
534
+ }
535
+ /** Every reserved key (inbox, quarantine, late run) the archive holds, by prefix walk. */
536
+ async reservedArchiveKeys(archive) {
537
+ const keys = [];
538
+ for (const prefix of ["__loom_inbox__:", "__loom_quarantine__:", "__loom_late__:"]) {
539
+ let cursor = prefix.slice(0, -1);
540
+ for (;;) {
541
+ const page = await archive.listExecutions({ cursor, limit: 200 });
542
+ let past = false;
543
+ for (const key of page.executionIds) {
544
+ if (!key.startsWith(prefix)) {
545
+ if (key < prefix)
546
+ continue;
547
+ past = true;
548
+ break;
549
+ }
550
+ keys.push(key);
551
+ }
552
+ if (past || page.nextCursor === undefined || page.nextCursor === cursor)
553
+ break;
554
+ cursor = page.nextCursor;
555
+ }
556
+ }
557
+ return keys;
558
+ }
559
+ /**
560
+ * Restore what an EARLIER build's staged rewrite left behind (a `__loom_redact__:<key>` whose original
561
+ * is gone is copied back; a stage whose original is whole is dropped). The current rewrite is one atomic
562
+ * `replaceSegments` and leaves no stage, so this is a no-op on a clean archive. Run at boot (`loadParked`)
563
+ * and exposed as an operator verb (`app.recoverArchiveRedactions`). Returns the keys restored.
564
+ */
565
+ async recoverRedactions() {
566
+ const restored = [];
567
+ for (const archive of this.plaintextArchives()) {
568
+ const del = archive.deleteExecution?.bind(archive);
569
+ if (del === undefined)
570
+ continue;
571
+ let cursor = REDACT_STAGE.slice(0, -1);
572
+ for (;;) {
573
+ const page = await archive.listExecutions({ cursor, limit: 200 });
574
+ let past = false;
575
+ for (const stage of page.executionIds) {
576
+ if (!stage.startsWith(REDACT_STAGE)) {
577
+ if (stage < REDACT_STAGE)
578
+ continue;
579
+ past = true;
580
+ break;
581
+ }
582
+ const key = stage.slice(REDACT_STAGE.length);
583
+ if ((await archive.headers(key)).length === 0) {
584
+ for (const seg of await archive.read(stage))
585
+ await archive.append(key, seg);
586
+ restored.push(key);
587
+ this.options.onArchiveKeyChanged?.(key);
588
+ }
589
+ await del(stage);
590
+ }
591
+ if (past || page.nextCursor === undefined || page.nextCursor === cursor)
592
+ break;
593
+ cursor = page.nextCursor;
594
+ }
595
+ }
596
+ return restored;
597
+ }
598
+ /**
599
+ * The open intent of `intentType` for the subject: journaled and not yet completed by a `completionType`
600
+ * event naming it (`intentId`). The latest one, or `undefined`.
601
+ */
602
+ async openIntent(intentType, completionType, subjectId, tenant) {
603
+ const events = await this.options.eventStore.read(exports.ERASURE_STREAM);
604
+ const done = new Set();
605
+ for (const e of events) {
606
+ if (e.eventType === completionType) {
607
+ const id = e.payload?.intentId;
608
+ if (typeof id === "string")
609
+ done.add(id);
610
+ }
611
+ }
612
+ let found;
613
+ let foreign;
614
+ for (const e of events) {
615
+ if (e.eventType !== intentType || done.has(e.id))
616
+ continue;
617
+ if (this.rowNames(e.payload, subjectId, tenant))
618
+ found = e;
619
+ else {
620
+ const p = e.payload;
621
+ const keyId = typeof p?.keyId === "string" ? p.keyId : "(none)";
622
+ if (typeof p?.subjectRef === "string" && !this.controlKeys.some((k) => k.id === keyId)) {
623
+ foreign = { keyId, intentId: e.id };
624
+ }
625
+ }
626
+ }
627
+ // An open keyed intent this coordinator cannot attribute might be this subject's: never re-plan past it.
628
+ if (found === undefined && foreign !== undefined) {
629
+ throw new ControlStreamKeyError(foreign.keyId, foreign.intentId);
630
+ }
631
+ return found;
632
+ }
633
+ async appendIntent(type, subjectId, tenant, body) {
634
+ return this.appendControl(type, { ...this.subjectFields(subjectId, tenant), ...body });
635
+ }
636
+ async appendCompletion(type, subjectId, tenant, intentId, body) {
637
+ await this.appendControl(type, {
638
+ ...this.subjectFields(subjectId, tenant),
639
+ ...(intentId !== undefined && { intentId }),
640
+ ...body,
641
+ });
642
+ }
643
+ async appendControl(type, payload) {
644
+ const prior = await this.options.eventStore.read(exports.ERASURE_STREAM);
645
+ const id = `erasure-${prior.length}`;
646
+ await this.options.eventStore.append({
647
+ id,
648
+ workflowId: exports.ERASURE_STREAM,
649
+ executionId: exports.ERASURE_STREAM,
650
+ eventType: type,
651
+ sequence: prior.length,
652
+ timestamp: 0,
653
+ payload,
654
+ });
655
+ return id;
656
+ }
657
+ /** The plaintext archives (`sealsSegments` is not `true`); a sealing one is erased by the shred. */
658
+ plaintextArchives() {
659
+ return (this.options.archives ?? []).filter((a) => a.sealsSegments !== true);
660
+ }
661
+ /** Every archive key holding the subject's copies: its executions' compaction segments and its chains' reserved segments. */
662
+ async archivedKeys(archive, executions, chains) {
663
+ const held = [];
664
+ for (const id of executions) {
665
+ if ((await archive.headers(id)).length > 0)
666
+ held.push(id);
667
+ }
668
+ const reserved = [];
669
+ for (const { chainId } of chains) {
670
+ reserved.push(...(await (0, event_runtime_1.archivedReservedStreams)(archive, chainId)));
671
+ }
672
+ return { executions: held, reservedStreams: reserved };
673
+ }
674
+ async assertArchivesErasable(subjectId, tenant, executions, chains) {
675
+ for (const archive of this.plaintextArchives()) {
676
+ if (archive.deleteExecution === undefined) {
677
+ const keys = await this.archivedKeys(archive, executions, chains);
678
+ const all = [...keys.executions, ...keys.reservedStreams];
679
+ if (all.length > 0)
680
+ throw new ArchivedCopiesRemainError(all);
681
+ }
682
+ // ADR 0139 N5: an archive that cannot swap a segment atomically cannot redact the subject's identity
683
+ // out of OTHER chains' archived envelopes. Refuse BEFORE the shred when it holds any (the scan is the
684
+ // price of finding out; an archive that can redact is scanned once, after the shred).
685
+ if (archive.replaceSegments === undefined) {
686
+ const held = [];
687
+ for (const key of await this.reservedArchiveKeys(archive)) {
688
+ const segments = await archive.read(key);
689
+ if (this.redactedSegments(segments, subjectId, tenant, "").changed > 0)
690
+ held.push(key);
691
+ }
692
+ if (held.length > 0)
693
+ throw new SenderRedactionUnsupportedError(held);
694
+ }
695
+ }
696
+ }
697
+ // ── ADR 0140 - claim-checked blobs ──────────────────────────────────────────────
698
+ /** Does the content store seal per subject (so the shred already erased its blobs)? */
699
+ sealsContent() {
700
+ return this.options.content?.sealsContent === true;
701
+ }
702
+ /** Every claim-check ref one execution cites: its live log, its archive segments and its snapshot. */
703
+ async refsOfExecution(executionId, proof, out,
704
+ /**
705
+ * `true` (OTHER executions' citations): count only TRUSTED positions (see
706
+ * {@link trustedClaimCheckRefsOf}); a snapshot's refs count only when the execution has no recorded
707
+ * step left to vouch for them (history gone: the snapshot is the sole evidence, so fail closed).
708
+ */
709
+ trustedOnly = false,
710
+ /**
711
+ * ADR 0143: where each address was first cited (the citing execution + its workflow), so a sealing
712
+ * store can be asked whether the address is OWNED by that citer's subject.
713
+ */
714
+ citedBy) {
715
+ const events = await this.readErasedOk(executionId, proof);
716
+ // With `citedBy`, collect THIS execution's refs separately so EVERY distinct citer of an address is
717
+ // recorded (not only the first): the ownership proof may hold under any of the subject's executions.
718
+ const into = citedBy === undefined ? out : new Set();
719
+ let workflowId = events[0]?.workflowId;
720
+ let sawStep = false;
721
+ const visit = (list) => {
722
+ if (!trustedOnly) {
723
+ for (const e of list)
724
+ claimCheckRefsOf(e.payload, into);
725
+ }
726
+ else if (trustedClaimCheckRefsOf(list, into).sawStep) {
727
+ sawStep = true;
728
+ }
729
+ };
730
+ visit(events);
731
+ for (const archive of this.options.archives ?? []) {
732
+ try {
733
+ if ((await archive.headers(executionId)).length === 0)
734
+ continue;
735
+ for (const seg of await archive.read(executionId)) {
736
+ workflowId ??= seg.envelopes[0]?.workflowId;
737
+ visit(seg.envelopes);
738
+ }
739
+ }
740
+ catch (err) {
741
+ // A sealed segment whose key is already gone cites nothing anyone can read.
742
+ if (!(0, event_runtime_1.isPayloadShredded)(err) && !(0, event_runtime_1.isKeyUnavailable)(err))
743
+ throw err;
744
+ }
745
+ }
746
+ const read = await this.readSnapshotErasedOk(executionId, events, proof);
747
+ if (read.snapshot !== null && !read.opaque && (!trustedOnly || !sawStep)) {
748
+ workflowId ??= read.snapshot.state.workflowId;
749
+ claimCheckRefsOf(read.snapshot.state, into);
750
+ }
751
+ if (citedBy !== undefined) {
752
+ for (const a of into) {
753
+ out.add(a);
754
+ const citers = citedBy.get(a) ?? [];
755
+ citedBy.set(a, citers);
756
+ // Bounded: a proof is tried per citer, so a pathological fan-in cannot make erasure quadratic.
757
+ if (citers.length < MAX_CITERS_PER_ADDRESS &&
758
+ !citers.some((c) => c.executionId === executionId)) {
759
+ citers.push({ executionId, workflowId: workflowId ?? "" });
760
+ }
761
+ }
762
+ }
763
+ }
764
+ /**
765
+ * BEFORE the shred: collect the claim-check refs the subject's executions cite, drop every one another
766
+ * (non-erased) execution still cites, refuse when the remainder cannot be deleted, and journal the INTENT
767
+ * (`ERASURE_CONTENT_DELETE_INTENDED`: the addresses and the touch cutoff). A retry after a crash finds the
768
+ * open intent and finishes it. `undefined`: nothing to do (no content store sealing, no refs, no intent).
769
+ */
770
+ async planContentDeletion(subjectId, tenant, executions, proof) {
771
+ const open = await this.openIntent(CONTENT_DELETE_INTENDED, CONTENT_DELETED, subjectId, tenant);
772
+ const mine = new Set();
773
+ const citedBy = new Map();
774
+ for (const id of executions)
775
+ await this.refsOfExecution(id, proof, mine, false, citedBy);
776
+ if (mine.size === 0 && open === undefined)
777
+ return undefined;
778
+ if (this.sealsContent()) {
779
+ // ADR 0143: a store that seals each blob under a subject's key. The SHRED erases the subject's own
780
+ // blobs (no plaintext copy exists to remove). But a log CITES addresses it never put (an input, a
781
+ // resume payload, another execution's result): citation is not ownership. Each cited address is
782
+ // PROVEN owned by the subject (the store decrypts it under the subject's key and discards the
783
+ // plaintext) before it is planned for deletion; a blob that does not prove is never deleted and is
784
+ // journaled as foreign/retained. Erasure still RECORDS what it covered (the intent, BEFORE the shred;
785
+ // the completion after) and deletes the now-unreadable ciphertext where the store can.
786
+ const prior = open?.payload ?? {};
787
+ // The journaled intent names the foreign/unprovable addresses too, so a retry after the shred (when
788
+ // every proof now throws PayloadShreddedError) neither reclassifies nor double counts them.
789
+ const known = new Set([
790
+ ...(prior.addresses ?? []),
791
+ ...(prior.foreignAddresses ?? []),
792
+ ...(prior.unprovableAddresses ?? []),
793
+ ]);
794
+ const owned = new Set(prior.addresses ?? []);
795
+ const foreign = new Set(prior.foreignAddresses ?? []);
796
+ const unprovable = new Set(prior.unprovableAddresses ?? []);
797
+ const fresh = [...mine].filter((a) => !known.has(a));
798
+ const content = this.options.content;
799
+ if (fresh.length > 0 && typeof content?.ownedBy !== "function") {
800
+ throw new ContentCopiesRemainError(fresh.length, "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)");
801
+ }
802
+ for (const address of fresh) {
803
+ // The putter may be ANY execution of the subject that cites the address (the compliance subject
804
+ // can span executions with distinct crypto subjects): owned if the proof holds under any of them.
805
+ let isOwned = false;
806
+ let sawShredded = false;
807
+ for (const access of citedBy.get(address) ?? []) {
808
+ try {
809
+ isOwned = await content.ownedBy({ __loomContent: address }, access);
810
+ }
811
+ catch (err) {
812
+ // The key is already destroyed (a re-erasure): ownership under that key is unprovable. Any other
813
+ // failure (a key merely absent from this node) is loud: nothing is shredded.
814
+ if (!(0, event_runtime_1.isPayloadShredded)(err))
815
+ throw err;
816
+ sawShredded = true;
817
+ }
818
+ if (isOwned)
819
+ break;
820
+ }
821
+ if (isOwned)
822
+ owned.add(address);
823
+ else if (sawShredded)
824
+ unprovable.add(address);
825
+ else
826
+ foreign.add(address);
827
+ }
828
+ const foreignCount = Math.max(prior.foreign ?? 0, foreign.size);
829
+ const unprovableCount = Math.max(prior.unprovable ?? 0, unprovable.size);
830
+ const retained = foreignCount + unprovableCount;
831
+ const addresses = [...owned];
832
+ const intentId = open?.id ??
833
+ (await this.appendIntent(CONTENT_DELETE_INTENDED, subjectId, tenant, {
834
+ addresses,
835
+ retained,
836
+ foreign: foreignCount,
837
+ unprovable: unprovableCount,
838
+ foreignAddresses: [...foreign],
839
+ unprovableAddresses: [...unprovable],
840
+ sealed: true,
841
+ }));
842
+ return {
843
+ intentId,
844
+ addresses,
845
+ retained,
846
+ foreign: foreignCount,
847
+ unprovable: unprovableCount,
848
+ sealed: true,
849
+ };
850
+ }
851
+ const content = this.options.content;
852
+ const store = this.options.eventStore;
853
+ const enumerable = store.listExecutionsPage !== undefined || store.listExecutions !== undefined;
854
+ if (mine.size > 0) {
855
+ if (content === undefined) {
856
+ throw new ContentCopiesRemainError(mine.size, "no content store is wired to the erasure");
857
+ }
858
+ if (typeof content.delete !== "function") {
859
+ throw new ContentCopiesRemainError(mine.size, "the content store cannot delete");
860
+ }
861
+ if (typeof content.deleteIfUntouchedSince !== "function" ||
862
+ typeof content.touchClock !== "function") {
863
+ throw new ContentCopiesRemainError(mine.size, "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)");
864
+ }
865
+ if (!enumerable) {
866
+ throw new ContentCopiesRemainError(mine.size, "the event store cannot be enumerated, so erasure cannot tell whether another execution still cites them");
867
+ }
868
+ }
869
+ const openAddresses = open?.payload?.addresses ?? [];
870
+ if (mine.size === 0 &&
871
+ openAddresses.length > 0 &&
872
+ (content === undefined ||
873
+ typeof content.deleteIfUntouchedSince !== "function" ||
874
+ typeof content.touchClock !== "function")) {
875
+ throw new ContentCopiesRemainError(openAddresses.length, "the content store lacks the conditional delete (deleteIfUntouchedSince/touchClock) the journaled intent needs");
876
+ }
877
+ // The blobs another, non-erased execution still cites STAY (a deduplicated identical blob).
878
+ const cutoff = content?.touchClock !== undefined ? await content.touchClock() : undefined;
879
+ const stillCited = new Set();
880
+ if (mine.size > 0) {
881
+ const own = new Set(executions);
882
+ const ids = new Set();
883
+ for await (const id of (0, event_runtime_1.iterateExecutionIds)(store))
884
+ ids.add(id);
885
+ for (const archive of this.options.archives ?? []) {
886
+ let cursor;
887
+ for (;;) {
888
+ const page = await archive.listExecutions({
889
+ ...(cursor !== undefined && { cursor }),
890
+ limit: 500,
891
+ });
892
+ for (const id of page.executionIds)
893
+ ids.add(id);
894
+ if (page.nextCursor === undefined || page.nextCursor === cursor)
895
+ break;
896
+ cursor = page.nextCursor;
897
+ }
898
+ }
899
+ for (const id of ids) {
900
+ if (own.has(id) || id === exports.ERASURE_STREAM)
901
+ continue;
902
+ const cited = new Set();
903
+ await this.refsOfExecution(id, proof, cited, true);
904
+ for (const a of cited)
905
+ if (mine.has(a))
906
+ stillCited.add(a);
907
+ if (stillCited.size === mine.size)
908
+ break;
909
+ }
910
+ }
911
+ const addresses = new Set();
912
+ const prior = open?.payload;
913
+ for (const a of prior?.addresses ?? [])
914
+ addresses.add(a);
915
+ for (const a of mine)
916
+ if (!stillCited.has(a))
917
+ addresses.add(a);
918
+ const retained = stillCited.size;
919
+ const intentId = open?.id ??
920
+ (addresses.size > 0 || retained > 0
921
+ ? await this.appendIntent(CONTENT_DELETE_INTENDED, subjectId, tenant, {
922
+ addresses: [...addresses],
923
+ retained,
924
+ ...(cutoff !== undefined && { cutoff }),
925
+ })
926
+ : undefined);
927
+ return {
928
+ ...(intentId !== undefined && { intentId }),
929
+ addresses: [...addresses],
930
+ retained,
931
+ ...(prior?.cutoff !== undefined
932
+ ? { cutoff: Math.min(prior.cutoff, cutoff ?? prior.cutoff) }
933
+ : cutoff !== undefined
934
+ ? { cutoff }
935
+ : {}),
936
+ };
937
+ }
938
+ /** AFTER the shred: delete the planned blobs, then journal the completion naming the intent. */
939
+ async finishContentDeletion(subjectId, tenant, plan) {
940
+ if (plan === undefined || plan.intentId === undefined)
941
+ return undefined;
942
+ const content = this.options.content;
943
+ let deleted = 0;
944
+ if (plan.sealed === true) {
945
+ // The shred already made every blob unreadable. Reclaim the ciphertext where the store can delete.
946
+ for (const address of plan.addresses) {
947
+ if (typeof content?.delete !== "function")
948
+ break;
949
+ await content.delete({ __loomContent: address });
950
+ deleted++;
951
+ }
952
+ const foreign = plan.foreign ?? 0;
953
+ const unprovable = plan.unprovable ?? 0;
954
+ const retained = foreign + unprovable;
955
+ await this.appendCompletion(CONTENT_DELETED, subjectId, tenant, plan.intentId, {
956
+ deleted,
957
+ retained,
958
+ sealed: plan.addresses.length,
959
+ foreign,
960
+ unprovable,
961
+ });
962
+ return { deleted, retained, sealed: plan.addresses.length, foreign, unprovable };
963
+ }
964
+ for (const address of plan.addresses) {
965
+ const ref = { __loomContent: address };
966
+ if (content === undefined)
967
+ break; // planned only when a store was wired
968
+ if (plan.cutoff === undefined || content.deleteIfUntouchedSince === undefined) {
969
+ // Planned only with the conditional delete (planContentDeletion refuses otherwise); an intent
970
+ // finished against a store that lost it must not fall back to an unconditional delete.
971
+ throw new ContentCopiesRemainError(plan.addresses.length, "the content store no longer offers deleteIfUntouchedSince/touchClock, so the journaled deletion cannot be finished safely");
972
+ }
973
+ // A concurrent re-put (another subject's identical blob) bumped the touch time: spared.
974
+ if (await content.deleteIfUntouchedSince(ref, plan.cutoff))
975
+ deleted++;
976
+ else
977
+ plan.retained++;
978
+ }
979
+ await this.appendCompletion(CONTENT_DELETED, subjectId, tenant, plan.intentId, {
980
+ deleted,
981
+ retained: plan.retained,
982
+ });
983
+ return { deleted, retained: plan.retained };
984
+ }
985
+ /**
986
+ * ADR 0138 / 0139 - the physical half of erasure for the ARCHIVES: delete the subject's compaction
987
+ * segments and its chains' reserved-stream segments from every plaintext archive. ADR 0139 N4: the
988
+ * INTENT (the keys) is journaled BEFORE anything is deleted (`ERASURE_ARCHIVE_DELETE_INTENDED`) and the
989
+ * completion after (`ERASURE_ARCHIVE_DELETED`, naming the intent), so a crash between the delete and the
990
+ * record is finished by the retry from the intent: a deletion is never unrecorded. Idempotent: a retry
991
+ * with nothing left and no open intent records nothing. Over a sealing archive the shred has already made
992
+ * the segments unreadable, so nothing is deleted.
993
+ */
994
+ async deleteArchivedCopies(subjectId, tenant, executions, chains) {
995
+ const deleted = { executions: [], reservedStreams: [] };
996
+ // 1. Finish an interrupted attempt from its journaled intent.
997
+ const open = await this.openIntent(ARCHIVE_DELETE_INTENDED, ARCHIVE_DELETED, subjectId, tenant);
998
+ const pending = new Set();
999
+ if (open !== undefined) {
1000
+ const p = open.payload;
1001
+ for (const id of p.executions ?? [])
1002
+ deleted.executions.push(id);
1003
+ for (const id of p.reservedStreams ?? [])
1004
+ deleted.reservedStreams.push(id);
1005
+ for (const id of [...deleted.executions, ...deleted.reservedStreams])
1006
+ pending.add(id);
1007
+ }
1008
+ // 2. What is held now.
1009
+ const fresh = { executions: [], reservedStreams: [] };
1010
+ for (const archive of this.plaintextArchives()) {
1011
+ if (archive.deleteExecution === undefined)
1012
+ continue; // asserted before the shred
1013
+ const keys = await this.archivedKeys(archive, executions, chains);
1014
+ for (const id of keys.executions)
1015
+ if (!pending.has(id))
1016
+ fresh.executions.push(id);
1017
+ for (const id of keys.reservedStreams)
1018
+ if (!pending.has(id))
1019
+ fresh.reservedStreams.push(id);
1020
+ }
1021
+ if (open === undefined && fresh.executions.length === 0 && fresh.reservedStreams.length === 0) {
1022
+ return undefined;
1023
+ }
1024
+ deleted.executions.push(...fresh.executions);
1025
+ deleted.reservedStreams.push(...fresh.reservedStreams);
1026
+ // 3. INTENT first (only when this pass found something new), then delete, then the completion.
1027
+ const intentId = open?.id ??
1028
+ (await this.appendIntent(ARCHIVE_DELETE_INTENDED, subjectId, tenant, {
1029
+ executions: deleted.executions,
1030
+ reservedStreams: deleted.reservedStreams,
1031
+ }));
1032
+ for (const archive of this.plaintextArchives()) {
1033
+ const del = archive.deleteExecution?.bind(archive);
1034
+ if (del === undefined)
1035
+ continue;
1036
+ for (const id of [...deleted.executions, ...deleted.reservedStreams]) {
1037
+ await del(id); // idempotent: an absent key is a no-op
1038
+ this.options.onArchiveKeyChanged?.(id);
1039
+ }
1040
+ }
1041
+ await this.appendCompletion(ARCHIVE_DELETED, subjectId, tenant, intentId, { ...deleted });
1042
+ return deleted;
1043
+ }
1044
+ /**
1045
+ * ADR 0110 follow-up — erasure reaches payloads still in flight in a RESERVED inbox stream.
1046
+ *
1047
+ * A chain's inbox (`__loom_inbox__:<chainId>`, and with per-topic streams `…#<hash>` and `…#reg`) holds undelivered messages for its owner. The
1048
+ * runtime deletes it when the chain's head turns terminal, but a crash between a sender's append
1049
+ * and its re-read can leave an orphan, and until it is reclaimed it holds the subject's data.
1050
+ * Once the subject is erased (every execution of it terminal, so nothing can consume) the stream
1051
+ * of each execution that is the HEAD of its chain (terminal and not `continued`: a continued
1052
+ * execution's successor owns the stream) is deleted. Idempotent, and a no-op for a store that
1053
+ * cannot delete or an execution whose stream is already gone.
1054
+ *
1055
+ * This is the physical half. The cryptographic half is the key: the crypto plugin seals inbox
1056
+ * payloads under the OWNER's subject, so `shred` makes a payload unreadable even where a stream
1057
+ * cannot be deleted (a consumer then receives the `{ $shredded: true }` marker, never the raw
1058
+ * value).
1059
+ */
1060
+ async purgeInboxStreams(chains) {
1061
+ const remove = this.options.eventStore.deleteExecution?.bind(this.options.eventStore);
1062
+ if (remove === undefined)
1063
+ return;
1064
+ for (const { streams: { legacy, registry, topics, quarantine }, } of chains) {
1065
+ // ADR 0110 D10b — EVERY stream of the chain: its per-topic streams, the legacy one and, last,
1066
+ // the registry that names the topic streams (a crash midway leaves them findable).
1067
+ for (const stream of topics)
1068
+ await remove(stream);
1069
+ await remove(legacy);
1070
+ // ADR 0130 residuals D9: the chain's quarantine copies are in-flight payloads too.
1071
+ await remove(quarantine);
1072
+ if (registry !== undefined)
1073
+ await remove(registry);
1074
+ }
1075
+ }
1076
+ /**
1077
+ * The inbox streams of the chains whose HEAD is among `executions`: terminal and not `continued`
1078
+ * (a continued execution's successor owns the chain's inbox and is judged on its own events).
1079
+ * Folded, and each chain's registry read, while the logs are still readable, so a decrypting store
1080
+ * has not yet lost its key.
1081
+ */
1082
+ async finishedChains(executions, proof) {
1083
+ // (a store that cannot delete streams still has chains whose ARCHIVED segments erasure removes)
1084
+ const chains = new Set();
1085
+ for (const executionId of executions) {
1086
+ const folded = await this.foldExecution(executionId, proof);
1087
+ if (folded === undefined)
1088
+ continue;
1089
+ if (!TERMINAL_STATUS.has(folded.status) || folded.status === "continued")
1090
+ continue;
1091
+ if (folded.head === false)
1092
+ continue; // terminal, but not PROVEN a chain head: keep its streams
1093
+ chains.add((0, event_runtime_1.chainIdOfFirst)(folded.first, executionId));
1094
+ }
1095
+ const streams = [];
1096
+ const store = this.options.eventStore;
1097
+ const view = {
1098
+ read: (id) => this.readErasedOk(id, proof),
1099
+ // The registry may miss a topic stream (a sender that died before it registered): the prefix
1100
+ // scan is the backstop, so erasure reaches an unregistered stream too.
1101
+ ...(store.listExecutionsPage !== undefined && {
1102
+ listExecutionsPage: store.listExecutionsPage.bind(store),
1103
+ }),
1104
+ };
1105
+ for (const chainId of chains) {
1106
+ streams.push({ chainId, streams: await (0, event_runtime_1.inboxStreamsOfChainDeep)(view, chainId) });
1107
+ }
1108
+ return streams;
1109
+ }
1110
+ /** The window proof for one call: the operator's per-call `force` asserts it, else the configured proof. */
1111
+ proofFor(force) {
1112
+ return force === "key-destroyed-elsewhere" ? () => true : this.options.destroyed;
1113
+ }
1114
+ /** Subjects with a deferred erasure still awaiting completion (sorted, deduped across tenants). */
1115
+ pendingSubjects() {
1116
+ const out = new Set([...this.parked].map(subjectOfKey));
1117
+ if (this.parkedRefs.size > 0) {
1118
+ // A restored digest names its subject only through the index (one HMAC per indexed subject).
1119
+ for (const tenant of this.options.index.tenants()) {
1120
+ for (const subject of this.options.index.subjects(tenant)) {
1121
+ if (this.subjectRefs(subject, tenant).some((r) => this.parkedRefs.has(r))) {
1122
+ out.add(subject);
1123
+ }
1124
+ }
1125
+ }
1126
+ }
1127
+ return [...out].sort();
1128
+ }
1129
+ /** The subset of `executions` that are not yet terminal (a live fold reading the key), with status. */
1130
+ async liveExecutions(executions, proof) {
1131
+ const live = [];
1132
+ for (const executionId of executions) {
1133
+ const folded = await this.foldExecution(executionId, proof);
1134
+ if (folded === undefined)
1135
+ continue; // deleted by retention — nothing to protect
1136
+ if (!TERMINAL_STATUS.has(folded.status))
1137
+ live.push({ id: executionId, status: folded.status });
1138
+ }
1139
+ return live;
1140
+ }
1141
+ /**
1142
+ * The log, or — when its subject is ALREADY shredded (a direct `shredSubject`, a retry, or the
1143
+ * window between the key's destruction and its tombstone) — the redacted view the typed error
1144
+ * carries: status and tip are plaintext, so liveness and the
1145
+ * chain's head can still be judged, and a re-run of the erasure stays idempotent.
1146
+ */
1147
+ async readErasedOk(executionId, proof) {
1148
+ try {
1149
+ return await this.options.eventStore.read(executionId);
1150
+ }
1151
+ catch (err) {
1152
+ const redacted = err?.redacted;
1153
+ if ((0, event_runtime_1.isPayloadShredded)(err) && redacted) {
1154
+ return redacted;
1155
+ }
1156
+ // The ShredIncomplete window (ADR 0130 D7): the key is destroyed but the tombstone append
1157
+ // failed, so no PAYLOAD_SHREDDED exists yet and the read is `KeyUnavailableError`. Its
1158
+ // redacted view (plaintext status and tip) lets the retry judge liveness and complete, but
1159
+ // ONLY when the log records this subject's durable erase intent: without it the node merely
1160
+ // lacks the key (wrong key, partitioned key store) and the error is rethrown.
1161
+ const window = await (0, event_runtime_1.erasureWindowView)(err, proof);
1162
+ if (window !== undefined)
1163
+ return [...window];
1164
+ throw err;
1165
+ }
1166
+ }
1167
+ /**
1168
+ * The latest checkpoint, or — in the destroy window — its at-rest form. A sealing snapshot store
1169
+ * throws `KeyUnavailableError` for a sealed checkpoint whose key is destroyed while no
1170
+ * `PAYLOAD_SHREDDED` exists yet (the crash between `keys.destroy` and the tombstones), and
1171
+ * `PayloadShreddedError` once a shred is recorded. Both are handled exactly like the event read
1172
+ * (`readErasedOk`) and `shredSubject` (`loadLatestSealed`): the proven shred reads sealed, the
1173
+ * unavailable key only when the log holds this subject's erase intent AND `proof` shows the key
1174
+ * store destroyed it. Anything else (a key merely missing, no intent) is rethrown: real key loss
1175
+ * is never masked. `opaque` is set when the sealed state carries no plaintext status.
1176
+ */
1177
+ async readSnapshotErasedOk(executionId, events, proof) {
1178
+ const store = this.options.snapshotStore;
1179
+ if (store === undefined)
1180
+ return { snapshot: null, opaque: false };
1181
+ try {
1182
+ return { snapshot: await store.loadLatest(executionId), opaque: false };
1183
+ }
1184
+ catch (err) {
1185
+ const sealedRead = store.loadLatestSealed?.bind(store);
1186
+ const inWindow = (0, event_runtime_1.isPayloadShredded)(err) || (await (0, event_runtime_1.isErasureWindow)(err, events, proof));
1187
+ if (!inWindow || sealedRead === undefined)
1188
+ throw err;
1189
+ const snapshot = await sealedRead(executionId);
1190
+ const opaque = typeof snapshot?.state?.status !== "string";
1191
+ return { snapshot, opaque, ...((0, event_runtime_1.isKeyUnavailable)(err) && { subjectId: err.subjectId }) };
1192
+ }
1193
+ }
1194
+ /** One execution's folded status and its first event, or `undefined` when its log is gone. */
1195
+ async foldExecution(executionId, proof) {
1196
+ const events = await this.readErasedOk(executionId, proof);
1197
+ const first = events[0];
1198
+ if (first === undefined)
1199
+ return undefined;
1200
+ const read = await this.readSnapshotErasedOk(executionId, events, proof);
1201
+ const snapshot = read.snapshot;
1202
+ // Fill a blank legacy identity; a NON-blank one naming another execution is a store or
1203
+ // tenant-scoping defect, refused as everywhere else a fold is seeded (ADR 0106).
1204
+ if (snapshot !== null) {
1205
+ // The row's index columns are plaintext even when its state is sealed: always refuse a
1206
+ // checkpoint of another execution (a store or tenant-scoping defect, ADR 0106).
1207
+ if (snapshot.executionId !== undefined && snapshot.executionId !== executionId) {
1208
+ throw new TypeError(`snapshot store returned a checkpoint of "${snapshot.executionId}" for "${executionId}"`);
1209
+ }
1210
+ if (!read.opaque)
1211
+ (0, snapshot_runtime_1.assertSnapshotIdentity)(snapshot.state, executionId, first.workflowId ?? "");
1212
+ }
1213
+ if (read.opaque && snapshot !== null) {
1214
+ // A whole-state sealed checkpoint whose key is gone: its status is unreadable.
1215
+ // (1) The log is not compacted: gap-free from 0 AND reaching the checkpoint (compaction
1216
+ // keeps only sequence 0 of the prefix). Then ignore the checkpoint and fold the WHOLE log by
1217
+ // its plaintext event types, the no-snapshot path: continued, suspended and a re-open after
1218
+ // the intent all read as they are.
1219
+ const last0 = events[events.length - 1];
1220
+ if (events.every((e, i) => e.sequence === i) &&
1221
+ last0 !== undefined &&
1222
+ last0.sequence >= snapshot.sequence) {
1223
+ return { status: (0, replay_vm_1.foldEvents)(events, { executionId }).status, first };
1224
+ }
1225
+ // (2) Compacted: only the tail above the checkpoint is left. Judge it by the rule
1226
+ // `shredSubject` uses (`isQuiescent`). A chain HEAD is claimed only when the tail itself shows
1227
+ // a non-`continued` terminal kind; anything less keeps the inbox streams (the key's
1228
+ // destruction already makes their payloads unreadable).
1229
+ const tail = events.filter((e) => e.sequence > snapshot.sequence);
1230
+ // The intent is looked for under THIS subject (the one whose key is gone), as shredSubject does.
1231
+ const quiescent = (0, event_runtime_1.isQuiescent)(tail, undefined, (0, event_runtime_1.recordsErasureIntent)(events, read.subjectId));
1232
+ const last = [...tail].reverse().find((e) => (0, event_runtime_1.isAnsweringKind)(e));
1233
+ const status = !quiescent
1234
+ ? (0, replay_vm_1.foldEvents)(tail, { executionId }).status
1235
+ : last?.eventType === "EXECUTION_SUSPENDED"
1236
+ ? "suspended"
1237
+ : last === undefined
1238
+ ? "completed" // quiescent on the intent alone: terminal for liveness, head unproven
1239
+ : (TERMINAL_STATUS_OF_EVENT.get(last.eventType) ?? "completed");
1240
+ const head = last !== undefined && status !== "continued" && TERMINAL_STATUS.has(status);
1241
+ return { status, first, head };
1242
+ }
1243
+ const state = snapshot !== null
1244
+ ? (0, replay_vm_1.foldEvents)(events.filter((e) => e.sequence > snapshot.sequence),
1245
+ // A pre-ADR-0106 checkpoint may carry a blank identity: fill it, as the kernel does.
1246
+ // A pre-1.21.0 snapshot may read `failed` for a caught step failure: repair it
1247
+ // (ADR 0105), or a live execution would read terminal and be shredded.
1248
+ {
1249
+ ...(0, replay_vm_1.normalizeSnapshotState)(snapshot.state),
1250
+ executionId: snapshot.state.executionId || executionId,
1251
+ workflowId: snapshot.state.workflowId || (first.workflowId ?? ""),
1252
+ })
1253
+ : (0, replay_vm_1.foldEvents)(events, { executionId });
1254
+ return { status: state.status, first };
1255
+ }
1256
+ // `prior.length` is the next sequence only because a reserved stream is never compacted:
1257
+ // `LogLifecycle.compact` refuses one (ADR 0106).
1258
+ async appendPending(subjectId, tenant) {
1259
+ const prior = await this.options.eventStore.read(exports.ERASURE_STREAM);
1260
+ await this.options.eventStore.append({
1261
+ id: `erasure-${prior.length}`,
1262
+ workflowId: exports.ERASURE_STREAM,
1263
+ executionId: exports.ERASURE_STREAM,
1264
+ eventType: "ERASURE_PENDING",
1265
+ sequence: prior.length,
1266
+ timestamp: 0, // deterministic; the fact of the request is what matters
1267
+ payload: this.subjectFields(subjectId, tenant),
1268
+ });
1269
+ }
1270
+ }
1271
+ exports.EraseCoordinator = EraseCoordinator;
1272
+ /** The ids of the `ERASURE_PENDING` rows an `ERASURE_PENDING_CLEARED` closed. */
1273
+ function clearedPendingIds(control) {
1274
+ const out = new Set();
1275
+ for (const e of control) {
1276
+ if (e.eventType !== PENDING_CLEARED)
1277
+ continue;
1278
+ const ids = e.payload?.pendingIds;
1279
+ if (Array.isArray(ids))
1280
+ for (const id of ids)
1281
+ if (typeof id === "string")
1282
+ out.add(id);
1283
+ }
1284
+ return out;
1285
+ }
1286
+ // `tenant\x00subject` — NUL separates the two id spaces (never appears in a real id), so a
1287
+ // tenant/subject boundary can't be spoofed by crafting an id that contains the separator.
1288
+ function parkKey(tenant, subject) {
1289
+ return `${tenant}\x00${subject}`;
1290
+ }
1291
+ function subjectOfKey(key) {
1292
+ return key.slice(key.indexOf("\x00") + 1);
1293
+ }
1294
+ //# sourceMappingURL=eraseSubject.js.map