@loomwork/compliance 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +87 -2
- package/dist/SubjectIndex.d.ts +48 -0
- package/dist/SubjectIndex.d.ts.map +1 -0
- package/dist/SubjectIndex.js +90 -0
- package/dist/SubjectIndex.js.map +1 -0
- package/dist/eraseSubject.d.ts +540 -0
- package/dist/eraseSubject.d.ts.map +1 -0
- package/dist/eraseSubject.js +1294 -0
- package/dist/eraseSubject.js.map +1 -0
- package/dist/exportSubject.d.ts +164 -0
- package/dist/exportSubject.d.ts.map +1 -0
- package/dist/exportSubject.js +143 -0
- package/dist/exportSubject.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -3
- package/src/SubjectIndex.ts +132 -0
- package/src/eraseSubject.ts +1780 -0
- package/src/exportSubject.ts +326 -0
- package/src/index.ts +36 -0
|
@@ -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
|