@substrat-run/kernel 0.116.0 → 0.118.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/dist/capability.d.ts +220 -0
- package/dist/capability.d.ts.map +1 -0
- package/dist/capability.js +537 -0
- package/dist/capability.js.map +1 -0
- package/dist/check-key.d.ts +30 -0
- package/dist/check-key.d.ts.map +1 -0
- package/dist/check-key.js +37 -0
- package/dist/check-key.js.map +1 -0
- package/dist/denial-query.d.ts +32 -1
- package/dist/denial-query.d.ts.map +1 -1
- package/dist/denial-query.js +66 -28
- package/dist/denial-query.js.map +1 -1
- package/dist/index.d.ts +20 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -4
- package/dist/index.js.map +1 -1
- package/dist/job-run.d.ts +28 -0
- package/dist/job-run.d.ts.map +1 -1
- package/dist/job-run.js +37 -0
- package/dist/job-run.js.map +1 -1
- package/dist/outbox-event.d.ts +131 -0
- package/dist/outbox-event.d.ts.map +1 -0
- package/dist/outbox-event.js +185 -0
- package/dist/outbox-event.js.map +1 -0
- package/dist/permission-checker.d.ts +8 -1
- package/dist/permission-checker.d.ts.map +1 -1
- package/dist/permission-checker.js +18 -0
- package/dist/permission-checker.js.map +1 -1
- package/dist/permission-eval.d.ts +8 -0
- package/dist/permission-eval.d.ts.map +1 -1
- package/dist/permission-eval.js +164 -91
- package/dist/permission-eval.js.map +1 -1
- package/dist/platform-request-query.d.ts +58 -1
- package/dist/platform-request-query.d.ts.map +1 -1
- package/dist/platform-request-query.js +61 -1
- package/dist/platform-request-query.js.map +1 -1
- package/dist/platform-sweep.d.ts +123 -8
- package/dist/platform-sweep.d.ts.map +1 -1
- package/dist/platform-sweep.js +158 -34
- package/dist/platform-sweep.js.map +1 -1
- package/dist/row-decode.d.ts +107 -0
- package/dist/row-decode.d.ts.map +1 -0
- package/dist/row-decode.js +93 -0
- package/dist/row-decode.js.map +1 -0
- package/dist/scope-host.d.ts +211 -9
- package/dist/scope-host.d.ts.map +1 -1
- package/dist/scope-host.js +33 -0
- package/dist/scope-host.js.map +1 -1
- package/dist/scope-tuple-seat.d.ts +72 -0
- package/dist/scope-tuple-seat.d.ts.map +1 -0
- package/dist/scope-tuple-seat.js +93 -0
- package/dist/scope-tuple-seat.js.map +1 -0
- package/dist/subject-redaction.d.ts +273 -0
- package/dist/subject-redaction.d.ts.map +1 -0
- package/dist/subject-redaction.js +362 -0
- package/dist/subject-redaction.js.map +1 -0
- package/dist/system-switch.d.ts +108 -0
- package/dist/system-switch.d.ts.map +1 -0
- package/dist/system-switch.js +145 -0
- package/dist/system-switch.js.map +1 -0
- package/dist/timeline.d.ts.map +1 -1
- package/dist/timeline.js +109 -57
- package/dist/timeline.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The intent journal's half of a subject erasure (#1600) — and, by the same link, the
|
|
3
|
+
* job-run tables' (#1632, at the end of this file).
|
|
4
|
+
*
|
|
5
|
+
* `shredSubject` holds K-37's Tier-1 line — *the payload goes, the envelope stays* —
|
|
6
|
+
* and for a year that line reached exactly one table, `_substrat_outbox`. It is not the
|
|
7
|
+
* only place the spine keeps an event's payload. A CP-less host cannot run a connector
|
|
8
|
+
* (no directory, no credentials, no sanctioned egress), so each connector delivery
|
|
9
|
+
* becomes a `connector:<provider>` platform intent whose payload is the WHOLE
|
|
10
|
+
* `DomainEvent` — fat by design, because "the platform's handler needs everything the
|
|
11
|
+
* in-process handler would have been handed" (`connectorDispatchPayload`). That copy
|
|
12
|
+
* lands in `_substrat_platform_requests`, which nothing ever deletes: the retention is
|
|
13
|
+
* deliberate, `listPlatformRequestHistory` exists precisely so a settled row stays
|
|
14
|
+
* readable. So a name the redaction did not reach there survived in the live scope
|
|
15
|
+
* database, and in every export, backup and PITR window taken from it afterwards.
|
|
16
|
+
*
|
|
17
|
+
* **The rule this module implements is narrow on purpose:** an intent that carries a
|
|
18
|
+
* COPY OF AN EVENT is redacted exactly when the erasure redacts that event. Not "any
|
|
19
|
+
* payload mentioning the subject" — the outbox spares a `piiClass: 'none'` event even
|
|
20
|
+
* when it names the subject, and a copy judged more harshly than its original is an
|
|
21
|
+
* incoherent rule, not a stricter one. So the predicate below is the outbox's own
|
|
22
|
+
* predicate (`subject_id = ? AND pii_class != 'none'`) applied to whatever spine
|
|
23
|
+
* envelope the payload embeds, at whatever depth. `subjectId` and `piiClass` occur
|
|
24
|
+
* together only on `domainEventShape`, which is what makes the walk kind-agnostic
|
|
25
|
+
* rather than a guess about `connector:*`.
|
|
26
|
+
*
|
|
27
|
+
* What it therefore does NOT reach is an intent kind that carries a subject's PII
|
|
28
|
+
* WITHOUT carrying the classified event — an intent payload has no `piiClass` of its
|
|
29
|
+
* own, so the kernel has nothing to read. No kind does that today (kernel-design.md
|
|
30
|
+
* §13.1 limit 7 says so and names the walk), and a kind that started to would be
|
|
31
|
+
* inventing an unclassified PII store inside the spine.
|
|
32
|
+
*
|
|
33
|
+
* This is a kernel module rather than two copies of the same SQL for
|
|
34
|
+
* `platformRequestHistoryQuery`'s reason, sharpened: three surfaces answer one question
|
|
35
|
+
* from this table, and a privacy guarantee that holds on one adapter is not a guarantee.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* The one key a redacted intent payload carries, and nothing else in the system does.
|
|
39
|
+
*
|
|
40
|
+
* Underscore-prefixed on the `_substrat_*` habit: the marker is the platform's, not a
|
|
41
|
+
* field some vertical's payload could plausibly own.
|
|
42
|
+
*/
|
|
43
|
+
export declare const REDACTED_INTENT_MARKER = "_substratRedacted";
|
|
44
|
+
/**
|
|
45
|
+
* What replaces a redacted intent's payload.
|
|
46
|
+
*
|
|
47
|
+
* The column is `payload TEXT NOT NULL` on both adapters, so the outbox's
|
|
48
|
+
* `SET payload = NULL` cannot transfer and *what a redacted intent looks like* is a
|
|
49
|
+
* shape to choose rather than a null to write. Three things decide this one:
|
|
50
|
+
*
|
|
51
|
+
* - **Obviously redacted, never plausible data.** A reader who has the row in front of
|
|
52
|
+
* them must not have to know the kind's schema to see that the content is gone.
|
|
53
|
+
* - **It fails every handler's parse.** Each drain handler opens with
|
|
54
|
+
* `<kind>Payload.parse(request.payload)`, and no kind's schema admits an object whose
|
|
55
|
+
* only key is this marker. So a drain that reaches the row after the redaction settles
|
|
56
|
+
* loudly instead of executing a tombstone as if it were an instruction. What this does
|
|
57
|
+
* NOT close, and should not be read as closing, is the drain that had already READ the
|
|
58
|
+
* payload when the erasure landed: it DELIVERS what it read. That window is the one the
|
|
59
|
+
* outbox redaction has too — a consumer mid-dispatch holds the payload it was handed —
|
|
60
|
+
* and closing it wants a lock across the drain hop rather than a shape here. Its
|
|
61
|
+
* WRITEBACK is closed separately and is not part of that residue: `settlePlatformRequest`
|
|
62
|
+
* is a compare-and-set on `status = 'pending'` on both adapters, so a stale pass cannot
|
|
63
|
+
* undo the redaction or put a provider's reply — which can quote the person — back into
|
|
64
|
+
* `last_error`. The two halves were one sentence in the first cut of this file, which
|
|
65
|
+
* made an avoidable write look as unavoidable as the delivery.
|
|
66
|
+
* - **It keeps the pseudonymous key, exactly as the outbox row does.** The redacted
|
|
67
|
+
* outbox row still carries `subject_id`; §5.3's "pseudonymous keys and transaction
|
|
68
|
+
* facts remain" is the same sentence here. A row that is blank for no stated reason
|
|
69
|
+
* reads as corruption; this one says what happened to it and when.
|
|
70
|
+
*/
|
|
71
|
+
export declare function redactedIntentPayload(subjectId: string, at: string): string;
|
|
72
|
+
/**
|
|
73
|
+
* The note a redaction leaves on an intent that had already settled.
|
|
74
|
+
*
|
|
75
|
+
* `last_error` is overwritten rather than kept, and that is deliberate: it is free text
|
|
76
|
+
* a third party wrote about the delivery of this person's data — the surface #618 built
|
|
77
|
+
* precisely so a provider's own sentence ("…requires valid personal number field") is
|
|
78
|
+
* readable — so it is content about the subject, not envelope. An erasure that emptied
|
|
79
|
+
* `payload` and left a provider quoting the name two columns over would be the same bug
|
|
80
|
+
* this fixes, one column to the right.
|
|
81
|
+
*/
|
|
82
|
+
export declare const REDACTED_INTENT_NOTE = "redacted by subject erasure (#37) \u2014 what this intent said is gone; that it happened, and when, is not";
|
|
83
|
+
/**
|
|
84
|
+
* The note a redaction leaves on an intent that was still `pending`.
|
|
85
|
+
*
|
|
86
|
+
* A pending row is redacted like any other AND settled `failed` in the same statement,
|
|
87
|
+
* because the two halves of the alternative are both wrong: leaving it pending-and-intact
|
|
88
|
+
* keeps the name (the defect), and leaving it pending-and-redacted hands the drain a
|
|
89
|
+
* tombstone to execute. `failed` is the truthful terminal state — the delivery did not
|
|
90
|
+
* happen and now cannot — and the executor delivery behind it was already journaled as
|
|
91
|
+
* routed, so nothing re-routes the event to replace this row.
|
|
92
|
+
*/
|
|
93
|
+
export declare const CANCELLED_INTENT_NOTE = "cancelled by subject erasure (#37) \u2014 the payload was redacted before the drain reached it, so this intent never ran";
|
|
94
|
+
/** The row shape the redaction reads to decide. */
|
|
95
|
+
export interface PlatformRequestRedactionCandidate {
|
|
96
|
+
id: string;
|
|
97
|
+
payload: string;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* What one spine redaction moved, per table.
|
|
101
|
+
*
|
|
102
|
+
* Separate numbers rather than one sum, because the receipt reports them apart: how much
|
|
103
|
+
* was said about a person, how many copies of it were queued for a third party, and how
|
|
104
|
+
* many long-running jobs held one are different facts, and a DSAR answer that folded them
|
|
105
|
+
* together could not say any of them.
|
|
106
|
+
*
|
|
107
|
+
* It lives here rather than in an adapter because it is what the Durable-Object adapter
|
|
108
|
+
* hands back across its RPC, and the coordinator that reads it deliberately does not
|
|
109
|
+
* import the DO module (it would pull the whole scope class into a worker that only
|
|
110
|
+
* coordinates).
|
|
111
|
+
*/
|
|
112
|
+
export interface SubjectRedactionCounts {
|
|
113
|
+
events: number;
|
|
114
|
+
intents: number;
|
|
115
|
+
/**
|
|
116
|
+
* Job runs (#1632) whose record held a copy of a redacted event — in the run row, or in
|
|
117
|
+
* a step of its ledger. Counted per RUN, once, however many of its columns and steps
|
|
118
|
+
* were rewritten: "how many long-running jobs had this person's data" is the fact, and
|
|
119
|
+
* a count of rewritten cells would read larger than anything a DSAR answer could say.
|
|
120
|
+
*/
|
|
121
|
+
jobRuns: number;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* What a ScopeDO from after #1600 and before #1632 answers — the job-run count absent,
|
|
125
|
+
* because that host never looked at the job tables. The coordinator refuses it rather than
|
|
126
|
+
* reading the absence as zero (see `redactSubject`'s callers).
|
|
127
|
+
*/
|
|
128
|
+
export type LegacySubjectRedactionCounts = Omit<SubjectRedactionCounts, 'jobRuns'>;
|
|
129
|
+
/**
|
|
130
|
+
* The candidate read: every intent whose stored payload TEXT contains the subject id.
|
|
131
|
+
*
|
|
132
|
+
* A prefilter, not the decision — `intentPayloadCarriesSubject` decides. `instr` rather
|
|
133
|
+
* than `LIKE` because it is a literal substring search with no wildcard to escape, and
|
|
134
|
+
* the needle is the subject id as `JSON.stringify` would have written it into the
|
|
135
|
+
* payload (identical to the raw id for a `dataSubjectId`, which is a ULID and so has
|
|
136
|
+
* nothing to escape — spelled out anyway, since the host contract types the parameter
|
|
137
|
+
* `string`).
|
|
138
|
+
*
|
|
139
|
+
* Unindexed, and that is affordable: this runs once per staff-triggered erasure over one
|
|
140
|
+
* scope's journal, where the alternative is parsing every row's JSON in the host.
|
|
141
|
+
*/
|
|
142
|
+
export declare function platformRequestRedactionQuery(subjectId: string): {
|
|
143
|
+
sql: string;
|
|
144
|
+
params: string[];
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* The write. One statement per redacted row, so the payload, the note and the pending
|
|
148
|
+
* row's settlement are one atomic change rather than three that can half-land.
|
|
149
|
+
*
|
|
150
|
+
* Every SET expression is evaluated against the ORIGINAL row in SQLite, so both `CASE
|
|
151
|
+
* WHEN status = 'pending'` arms read the status as it was before this statement touched
|
|
152
|
+
* it — which is what lets one statement both re-word the note and change the status it
|
|
153
|
+
* branched on. `settled_at` is COALESCEd so a row that already settled keeps the instant
|
|
154
|
+
* it settled at; only a cancelled pending row is stamped now.
|
|
155
|
+
*
|
|
156
|
+
* **`result` and `last_failure` go too (#1632).** The first cut spared `result` as
|
|
157
|
+
* envelope, reasoning from a routed dispatch whose result is `{ eventId }`. But `result` is
|
|
158
|
+
* whatever the drain's handler returned, and a connector handler's return is the
|
|
159
|
+
* provider's answer about delivering THIS person's data — `last_error`'s reason, in the
|
|
160
|
+
* column beside it. So a result that exists becomes the same tombstone the payload does;
|
|
161
|
+
* a NULL result stays NULL, because a tombstone there would claim an answer nobody gave.
|
|
162
|
+
* `last_failure` carries no free text (`{origin, code, permission}`), and is nulled for a
|
|
163
|
+
* different reason: it is the ATTRIBUTION of `last_error`, and once `last_error` is our
|
|
164
|
+
* note, a kept `origin: 'provider'` would caption the platform's sentence as the
|
|
165
|
+
* provider's words. NULL is the contract's "unrecorded", which is now the truth.
|
|
166
|
+
*
|
|
167
|
+
* Params, in order: payload, cancelled-note, redacted-note, result-tombstone, at, id.
|
|
168
|
+
*/
|
|
169
|
+
export declare const PLATFORM_REQUEST_REDACTION_SQL = "UPDATE _substrat_platform_requests\n SET payload = ?,\n last_error = CASE WHEN status = 'pending' THEN ? ELSE ? END,\n last_failure = NULL,\n result = CASE WHEN result IS NULL THEN NULL ELSE ? END,\n settled_at = COALESCE(settled_at, ?),\n status = CASE WHEN status = 'pending' THEN 'failed' ELSE status END\n WHERE id = ?";
|
|
170
|
+
/** The bound parameters for `PLATFORM_REQUEST_REDACTION_SQL`, in its declared order. */
|
|
171
|
+
export declare function platformRequestRedactionParams(id: string, subjectId: string, at: string): [string, string, string, string, string, string];
|
|
172
|
+
/**
|
|
173
|
+
* Does this stored payload carry a copy of an event the erasure redacts?
|
|
174
|
+
*
|
|
175
|
+
* The outbox's predicate, walked over the payload's structure: an object carrying BOTH
|
|
176
|
+
* `subjectId` equal to this subject AND a `piiClass` other than `'none'` IS a spine
|
|
177
|
+
* envelope — those two field names occur together nowhere else — so wherever the spine
|
|
178
|
+
* has copied one into an intent, the copy inherits the original's redaction.
|
|
179
|
+
*
|
|
180
|
+
* Returns false for a payload that IS already a tombstone (so a re-run after a crash
|
|
181
|
+
* converges rather than re-stamping) and false for a payload that is not JSON at all,
|
|
182
|
+
* which nothing in the kernel can write but which a stricter answer would have to invent
|
|
183
|
+
* a meaning for.
|
|
184
|
+
*/
|
|
185
|
+
export declare function intentPayloadCarriesSubject(payloadText: string, subjectId: string): boolean;
|
|
186
|
+
/**
|
|
187
|
+
* The note a redaction leaves on a delivery of a redacted event that had failed.
|
|
188
|
+
*
|
|
189
|
+
* `_substrat_deliveries.error` is a consumer's or executor's own sentence about handling
|
|
190
|
+
* THIS event — a throw that quoted the payload it choked on reads exactly like the
|
|
191
|
+
* provider's `last_error` on an intent. Unlike every other copy in this file it needs no
|
|
192
|
+
* walk: the row is keyed by `event_id`, so the outbox's own predicate names it exactly.
|
|
193
|
+
*/
|
|
194
|
+
export declare const REDACTED_DELIVERY_NOTE = "redacted by subject erasure (#37) \u2014 what this delivery reported is gone; that it failed, and when, is not";
|
|
195
|
+
/**
|
|
196
|
+
* One statement: every failed delivery of an event the erasure redacts. Only a NON-NULL
|
|
197
|
+
* error is rewritten, because a non-null error is what MEANS dead-lettered or retrying
|
|
198
|
+
* (`deliveryState`); writing the note onto a delivered row would turn it into a dead one.
|
|
199
|
+
* Not counted on the receipt: it is text about an event `eventsRedacted` already counts,
|
|
200
|
+
* not another copy of it. Idempotent — a note is not rewritten with itself.
|
|
201
|
+
*
|
|
202
|
+
* Params: note, note, subject id.
|
|
203
|
+
*/
|
|
204
|
+
export declare const DELIVERY_ERROR_REDACTION_SQL = "UPDATE _substrat_deliveries\n SET error = ?\n WHERE error IS NOT NULL AND error != ?\n AND event_id IN (SELECT id FROM _substrat_outbox WHERE subject_id = ? AND pii_class != 'none')";
|
|
205
|
+
/**
|
|
206
|
+
* The note a redaction leaves on a job run, or on a step of its ledger, that had finished.
|
|
207
|
+
*
|
|
208
|
+
* Same reasoning as `REDACTED_INTENT_NOTE`: `last_error` is a sentence an external system
|
|
209
|
+
* wrote about work done on this person's data, so it is content, not envelope.
|
|
210
|
+
*/
|
|
211
|
+
export declare const REDACTED_JOB_NOTE = "redacted by subject erasure (#37) \u2014 what this job run held about the subject is gone; that it ran, and when, is not";
|
|
212
|
+
/**
|
|
213
|
+
* The note a redaction leaves on a job run that was still `running`.
|
|
214
|
+
*
|
|
215
|
+
* A running run is redacted AND settled `failed` in the same statement, for the pending
|
|
216
|
+
* intent's reason and one of its own. A run resumes from its payload, its cursor and the
|
|
217
|
+
* memo of its completed steps; once any of those is a tombstone, the next pass would be
|
|
218
|
+
* handed the tombstone AS its input — a step's memo is returned to the handler without
|
|
219
|
+
* running anything — and walk on from nonsense. `failed` is what the run now is.
|
|
220
|
+
*/
|
|
221
|
+
export declare const CANCELLED_JOB_NOTE = "stopped by subject erasure (#37) \u2014 this run held a copy of an erased subject's event, so it cannot resume from it";
|
|
222
|
+
/**
|
|
223
|
+
* How the job-run half talks to a scope database — one shape both adapters can satisfy
|
|
224
|
+
* in a line (`db.prepare(sql)` / `this.sql.exec(sql, …)`), so the reads, the predicate
|
|
225
|
+
* and the writes below are ONE implementation rather than two ports of it.
|
|
226
|
+
*/
|
|
227
|
+
export type RedactionSql = (sql: string, params: readonly (string | number | null)[]) => unknown[];
|
|
228
|
+
/**
|
|
229
|
+
* The job-run half of an erasure (#1632), shared by both adapters.
|
|
230
|
+
*
|
|
231
|
+
* **What decides membership is #1600's predicate, and nothing else.** A job run carries no
|
|
232
|
+
* `subject_id`: its payload is "ids and configuration", its cursor is opaque, and a step's
|
|
233
|
+
* result is whatever a HOST handler got back from an external system. The one link the
|
|
234
|
+
* kernel can read reliably is the one the intent journal uses — a spine envelope embedded
|
|
235
|
+
* at any depth, carrying this subject and a `piiClass` other than `none` — so a job-run
|
|
236
|
+
* copy is redacted exactly when the erasure redacts the event it copies. The candidate
|
|
237
|
+
* reads use `instr` over the text, as the intent read does; `intentPayloadCarriesSubject`
|
|
238
|
+
* decides.
|
|
239
|
+
*
|
|
240
|
+
* **What it therefore does NOT reach** is external output naming the person with no
|
|
241
|
+
* classified envelope around it — a step that returns a provider's contact record, a
|
|
242
|
+
* `last_error` quoting a name. There is nothing in such a row that says whose it is, and a
|
|
243
|
+
* substring match on the id would both erase on coincidence and miss the name itself.
|
|
244
|
+
* kernel-design.md §13.1 limit 8 states it; a declared subject on the run is the open
|
|
245
|
+
* design that would close it (#1632).
|
|
246
|
+
*
|
|
247
|
+
* Per column, for a matching run: `payload` and `cursor` become the tombstone where THEY
|
|
248
|
+
* match (the payload is `NOT NULL`, the cursor may be `NULL` and stays so), `last_error`
|
|
249
|
+
* becomes the note, and a `running` run is settled `failed`. For a matching step: `result`
|
|
250
|
+
* becomes the tombstone, `last_error` the note — and its RUN is settled too, because the
|
|
251
|
+
* memo would otherwise hand the tombstone to the next pass. Idempotent: a tombstone no
|
|
252
|
+
* longer matches, so a second erasure finds nothing.
|
|
253
|
+
*
|
|
254
|
+
* Returns the number of distinct runs touched.
|
|
255
|
+
*/
|
|
256
|
+
export declare function redactSubjectJobRuns(sql: RedactionSql, subjectId: string, at: string): number;
|
|
257
|
+
/**
|
|
258
|
+
* One matched step: its result becomes the tombstone, its error the note.
|
|
259
|
+
*
|
|
260
|
+
* Params: tombstone, note, run_id, step.
|
|
261
|
+
*/
|
|
262
|
+
export declare const JOB_STEP_REDACTION_SQL = "UPDATE _substrat_job_steps\n SET result = ?, last_error = ?\n WHERE run_id = ? AND step = ?";
|
|
263
|
+
/**
|
|
264
|
+
* One matched run. Every `CASE WHEN status = 'running'` reads the status as it was before
|
|
265
|
+
* this statement — SQLite evaluates each SET against the original row — which is what
|
|
266
|
+
* lets one statement pick the note and settle the status it branched on. `ended_at` is
|
|
267
|
+
* COALESCEd so a run that had already ended keeps the instant it ended.
|
|
268
|
+
*
|
|
269
|
+
* Params: payload-hit (0/1), tombstone, cursor-hit (0/1), tombstone, cancelled-note,
|
|
270
|
+
* redacted-note, at (ended_at), at (updated_at), id.
|
|
271
|
+
*/
|
|
272
|
+
export declare const JOB_RUN_REDACTION_SQL = "UPDATE _substrat_job_runs\n SET payload = CASE WHEN ? = 1 THEN ? ELSE payload END,\n cursor = CASE WHEN ? = 1 THEN ? ELSE cursor END,\n last_error = CASE WHEN status = 'running' THEN ? ELSE ? END,\n next_attempt_at = CASE WHEN status = 'running' THEN NULL ELSE next_attempt_at END,\n ended_at = COALESCE(ended_at, ?),\n updated_at = ?,\n status = CASE WHEN status = 'running' THEN 'failed' ELSE status END\n WHERE id = ?";
|
|
273
|
+
//# sourceMappingURL=subject-redaction.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"subject-redaction.d.ts","sourceRoot":"","sources":["../src/subject-redaction.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,sBAAsB,CAAC;AAE1D;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAI3E;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,+GACwE,CAAC;AAE1G;;;;;;;;;GASG;AACH,eAAO,MAAM,qBAAqB,6HACqF,CAAC;AAExH,mDAAmD;AACnD,MAAM,WAAW,iCAAiC;IAChD,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,MAAM,MAAM,4BAA4B,GAAG,IAAI,CAAC,sBAAsB,EAAE,SAAS,CAAC,CAAC;AAEnF;;;;;;;;;;;;GAYG;AACH,wBAAgB,6BAA6B,CAAC,SAAS,EAAE,MAAM,GAAG;IAChE,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB,CAOA;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,8BAA8B,qXAO3B,CAAC;AAEjB,wFAAwF;AACxF,wBAAgB,8BAA8B,CAC5C,EAAE,EAAE,MAAM,EACV,SAAS,EAAE,MAAM,EACjB,EAAE,EAAE,MAAM,GACT,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAGlD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,2BAA2B,CAAC,WAAW,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAS3F;AAmDD;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,mHAC0E,CAAC;AAE9G;;;;;;;;GAQG;AACH,eAAO,MAAM,4BAA4B,oMAG2D,CAAC;AAIrG;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,6HACyF,CAAC;AAExH;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,2HACuF,CAAC;AAEvH;;;;GAIG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,CAAC,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC,EAAE,KAAK,OAAO,EAAE,CAAC;AAEnG;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CA8C7F;AAED;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,sGAEF,CAAC;AAElC;;;;;;;;GAQG;AACH,eAAO,MAAM,qBAAqB,+dAQlB,CAAC"}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The intent journal's half of a subject erasure (#1600) — and, by the same link, the
|
|
3
|
+
* job-run tables' (#1632, at the end of this file).
|
|
4
|
+
*
|
|
5
|
+
* `shredSubject` holds K-37's Tier-1 line — *the payload goes, the envelope stays* —
|
|
6
|
+
* and for a year that line reached exactly one table, `_substrat_outbox`. It is not the
|
|
7
|
+
* only place the spine keeps an event's payload. A CP-less host cannot run a connector
|
|
8
|
+
* (no directory, no credentials, no sanctioned egress), so each connector delivery
|
|
9
|
+
* becomes a `connector:<provider>` platform intent whose payload is the WHOLE
|
|
10
|
+
* `DomainEvent` — fat by design, because "the platform's handler needs everything the
|
|
11
|
+
* in-process handler would have been handed" (`connectorDispatchPayload`). That copy
|
|
12
|
+
* lands in `_substrat_platform_requests`, which nothing ever deletes: the retention is
|
|
13
|
+
* deliberate, `listPlatformRequestHistory` exists precisely so a settled row stays
|
|
14
|
+
* readable. So a name the redaction did not reach there survived in the live scope
|
|
15
|
+
* database, and in every export, backup and PITR window taken from it afterwards.
|
|
16
|
+
*
|
|
17
|
+
* **The rule this module implements is narrow on purpose:** an intent that carries a
|
|
18
|
+
* COPY OF AN EVENT is redacted exactly when the erasure redacts that event. Not "any
|
|
19
|
+
* payload mentioning the subject" — the outbox spares a `piiClass: 'none'` event even
|
|
20
|
+
* when it names the subject, and a copy judged more harshly than its original is an
|
|
21
|
+
* incoherent rule, not a stricter one. So the predicate below is the outbox's own
|
|
22
|
+
* predicate (`subject_id = ? AND pii_class != 'none'`) applied to whatever spine
|
|
23
|
+
* envelope the payload embeds, at whatever depth. `subjectId` and `piiClass` occur
|
|
24
|
+
* together only on `domainEventShape`, which is what makes the walk kind-agnostic
|
|
25
|
+
* rather than a guess about `connector:*`.
|
|
26
|
+
*
|
|
27
|
+
* What it therefore does NOT reach is an intent kind that carries a subject's PII
|
|
28
|
+
* WITHOUT carrying the classified event — an intent payload has no `piiClass` of its
|
|
29
|
+
* own, so the kernel has nothing to read. No kind does that today (kernel-design.md
|
|
30
|
+
* §13.1 limit 7 says so and names the walk), and a kind that started to would be
|
|
31
|
+
* inventing an unclassified PII store inside the spine.
|
|
32
|
+
*
|
|
33
|
+
* This is a kernel module rather than two copies of the same SQL for
|
|
34
|
+
* `platformRequestHistoryQuery`'s reason, sharpened: three surfaces answer one question
|
|
35
|
+
* from this table, and a privacy guarantee that holds on one adapter is not a guarantee.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* The one key a redacted intent payload carries, and nothing else in the system does.
|
|
39
|
+
*
|
|
40
|
+
* Underscore-prefixed on the `_substrat_*` habit: the marker is the platform's, not a
|
|
41
|
+
* field some vertical's payload could plausibly own.
|
|
42
|
+
*/
|
|
43
|
+
export const REDACTED_INTENT_MARKER = '_substratRedacted';
|
|
44
|
+
/**
|
|
45
|
+
* What replaces a redacted intent's payload.
|
|
46
|
+
*
|
|
47
|
+
* The column is `payload TEXT NOT NULL` on both adapters, so the outbox's
|
|
48
|
+
* `SET payload = NULL` cannot transfer and *what a redacted intent looks like* is a
|
|
49
|
+
* shape to choose rather than a null to write. Three things decide this one:
|
|
50
|
+
*
|
|
51
|
+
* - **Obviously redacted, never plausible data.** A reader who has the row in front of
|
|
52
|
+
* them must not have to know the kind's schema to see that the content is gone.
|
|
53
|
+
* - **It fails every handler's parse.** Each drain handler opens with
|
|
54
|
+
* `<kind>Payload.parse(request.payload)`, and no kind's schema admits an object whose
|
|
55
|
+
* only key is this marker. So a drain that reaches the row after the redaction settles
|
|
56
|
+
* loudly instead of executing a tombstone as if it were an instruction. What this does
|
|
57
|
+
* NOT close, and should not be read as closing, is the drain that had already READ the
|
|
58
|
+
* payload when the erasure landed: it DELIVERS what it read. That window is the one the
|
|
59
|
+
* outbox redaction has too — a consumer mid-dispatch holds the payload it was handed —
|
|
60
|
+
* and closing it wants a lock across the drain hop rather than a shape here. Its
|
|
61
|
+
* WRITEBACK is closed separately and is not part of that residue: `settlePlatformRequest`
|
|
62
|
+
* is a compare-and-set on `status = 'pending'` on both adapters, so a stale pass cannot
|
|
63
|
+
* undo the redaction or put a provider's reply — which can quote the person — back into
|
|
64
|
+
* `last_error`. The two halves were one sentence in the first cut of this file, which
|
|
65
|
+
* made an avoidable write look as unavoidable as the delivery.
|
|
66
|
+
* - **It keeps the pseudonymous key, exactly as the outbox row does.** The redacted
|
|
67
|
+
* outbox row still carries `subject_id`; §5.3's "pseudonymous keys and transaction
|
|
68
|
+
* facts remain" is the same sentence here. A row that is blank for no stated reason
|
|
69
|
+
* reads as corruption; this one says what happened to it and when.
|
|
70
|
+
*/
|
|
71
|
+
export function redactedIntentPayload(subjectId, at) {
|
|
72
|
+
return JSON.stringify({
|
|
73
|
+
[REDACTED_INTENT_MARKER]: { reason: 'subject-erasure', subjectId, at },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The note a redaction leaves on an intent that had already settled.
|
|
78
|
+
*
|
|
79
|
+
* `last_error` is overwritten rather than kept, and that is deliberate: it is free text
|
|
80
|
+
* a third party wrote about the delivery of this person's data — the surface #618 built
|
|
81
|
+
* precisely so a provider's own sentence ("…requires valid personal number field") is
|
|
82
|
+
* readable — so it is content about the subject, not envelope. An erasure that emptied
|
|
83
|
+
* `payload` and left a provider quoting the name two columns over would be the same bug
|
|
84
|
+
* this fixes, one column to the right.
|
|
85
|
+
*/
|
|
86
|
+
export const REDACTED_INTENT_NOTE = 'redacted by subject erasure (#37) — what this intent said is gone; that it happened, and when, is not';
|
|
87
|
+
/**
|
|
88
|
+
* The note a redaction leaves on an intent that was still `pending`.
|
|
89
|
+
*
|
|
90
|
+
* A pending row is redacted like any other AND settled `failed` in the same statement,
|
|
91
|
+
* because the two halves of the alternative are both wrong: leaving it pending-and-intact
|
|
92
|
+
* keeps the name (the defect), and leaving it pending-and-redacted hands the drain a
|
|
93
|
+
* tombstone to execute. `failed` is the truthful terminal state — the delivery did not
|
|
94
|
+
* happen and now cannot — and the executor delivery behind it was already journaled as
|
|
95
|
+
* routed, so nothing re-routes the event to replace this row.
|
|
96
|
+
*/
|
|
97
|
+
export const CANCELLED_INTENT_NOTE = 'cancelled by subject erasure (#37) — the payload was redacted before the drain reached it, so this intent never ran';
|
|
98
|
+
/**
|
|
99
|
+
* The candidate read: every intent whose stored payload TEXT contains the subject id.
|
|
100
|
+
*
|
|
101
|
+
* A prefilter, not the decision — `intentPayloadCarriesSubject` decides. `instr` rather
|
|
102
|
+
* than `LIKE` because it is a literal substring search with no wildcard to escape, and
|
|
103
|
+
* the needle is the subject id as `JSON.stringify` would have written it into the
|
|
104
|
+
* payload (identical to the raw id for a `dataSubjectId`, which is a ULID and so has
|
|
105
|
+
* nothing to escape — spelled out anyway, since the host contract types the parameter
|
|
106
|
+
* `string`).
|
|
107
|
+
*
|
|
108
|
+
* Unindexed, and that is affordable: this runs once per staff-triggered erasure over one
|
|
109
|
+
* scope's journal, where the alternative is parsing every row's JSON in the host.
|
|
110
|
+
*/
|
|
111
|
+
export function platformRequestRedactionQuery(subjectId) {
|
|
112
|
+
return {
|
|
113
|
+
sql: 'SELECT id, payload FROM _substrat_platform_requests WHERE instr(payload, ?) > 0',
|
|
114
|
+
// Strip the quotes JSON.stringify adds and keep the escaped body — the exact run of
|
|
115
|
+
// characters the serialized payload holds.
|
|
116
|
+
params: [JSON.stringify(subjectId).slice(1, -1)],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The write. One statement per redacted row, so the payload, the note and the pending
|
|
121
|
+
* row's settlement are one atomic change rather than three that can half-land.
|
|
122
|
+
*
|
|
123
|
+
* Every SET expression is evaluated against the ORIGINAL row in SQLite, so both `CASE
|
|
124
|
+
* WHEN status = 'pending'` arms read the status as it was before this statement touched
|
|
125
|
+
* it — which is what lets one statement both re-word the note and change the status it
|
|
126
|
+
* branched on. `settled_at` is COALESCEd so a row that already settled keeps the instant
|
|
127
|
+
* it settled at; only a cancelled pending row is stamped now.
|
|
128
|
+
*
|
|
129
|
+
* **`result` and `last_failure` go too (#1632).** The first cut spared `result` as
|
|
130
|
+
* envelope, reasoning from a routed dispatch whose result is `{ eventId }`. But `result` is
|
|
131
|
+
* whatever the drain's handler returned, and a connector handler's return is the
|
|
132
|
+
* provider's answer about delivering THIS person's data — `last_error`'s reason, in the
|
|
133
|
+
* column beside it. So a result that exists becomes the same tombstone the payload does;
|
|
134
|
+
* a NULL result stays NULL, because a tombstone there would claim an answer nobody gave.
|
|
135
|
+
* `last_failure` carries no free text (`{origin, code, permission}`), and is nulled for a
|
|
136
|
+
* different reason: it is the ATTRIBUTION of `last_error`, and once `last_error` is our
|
|
137
|
+
* note, a kept `origin: 'provider'` would caption the platform's sentence as the
|
|
138
|
+
* provider's words. NULL is the contract's "unrecorded", which is now the truth.
|
|
139
|
+
*
|
|
140
|
+
* Params, in order: payload, cancelled-note, redacted-note, result-tombstone, at, id.
|
|
141
|
+
*/
|
|
142
|
+
export const PLATFORM_REQUEST_REDACTION_SQL = `UPDATE _substrat_platform_requests
|
|
143
|
+
SET payload = ?,
|
|
144
|
+
last_error = CASE WHEN status = 'pending' THEN ? ELSE ? END,
|
|
145
|
+
last_failure = NULL,
|
|
146
|
+
result = CASE WHEN result IS NULL THEN NULL ELSE ? END,
|
|
147
|
+
settled_at = COALESCE(settled_at, ?),
|
|
148
|
+
status = CASE WHEN status = 'pending' THEN 'failed' ELSE status END
|
|
149
|
+
WHERE id = ?`;
|
|
150
|
+
/** The bound parameters for `PLATFORM_REQUEST_REDACTION_SQL`, in its declared order. */
|
|
151
|
+
export function platformRequestRedactionParams(id, subjectId, at) {
|
|
152
|
+
const tombstone = redactedIntentPayload(subjectId, at);
|
|
153
|
+
return [tombstone, CANCELLED_INTENT_NOTE, REDACTED_INTENT_NOTE, tombstone, at, id];
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Does this stored payload carry a copy of an event the erasure redacts?
|
|
157
|
+
*
|
|
158
|
+
* The outbox's predicate, walked over the payload's structure: an object carrying BOTH
|
|
159
|
+
* `subjectId` equal to this subject AND a `piiClass` other than `'none'` IS a spine
|
|
160
|
+
* envelope — those two field names occur together nowhere else — so wherever the spine
|
|
161
|
+
* has copied one into an intent, the copy inherits the original's redaction.
|
|
162
|
+
*
|
|
163
|
+
* Returns false for a payload that IS already a tombstone (so a re-run after a crash
|
|
164
|
+
* converges rather than re-stamping) and false for a payload that is not JSON at all,
|
|
165
|
+
* which nothing in the kernel can write but which a stricter answer would have to invent
|
|
166
|
+
* a meaning for.
|
|
167
|
+
*/
|
|
168
|
+
export function intentPayloadCarriesSubject(payloadText, subjectId) {
|
|
169
|
+
let parsed;
|
|
170
|
+
try {
|
|
171
|
+
parsed = JSON.parse(payloadText);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
if (isRedactedPayload(parsed))
|
|
177
|
+
return false;
|
|
178
|
+
return carriesSubject(parsed, subjectId);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Is this payload one we already redacted?
|
|
182
|
+
*
|
|
183
|
+
* **Asked of the WHOLE payload, once, and never inside the walk (#1600 review).** The
|
|
184
|
+
* first cut short-circuited the walk at any object carrying the marker key, which made
|
|
185
|
+
* `{ _substratRedacted: false, event: <a real envelope> }` a payload the erasure stepped
|
|
186
|
+
* straight past, PII and all — and the comment beside it claimed the check was redundant
|
|
187
|
+
* belt-and-braces because a tombstone has no `piiClass`. That was true of the tombstone
|
|
188
|
+
* and false of everything else wearing its key. An intent payload is `unknown` and module
|
|
189
|
+
* code chooses it, so a marker that means "stop looking" must not be something a payload
|
|
190
|
+
* can merely CONTAIN.
|
|
191
|
+
*
|
|
192
|
+
* The invariant asked instead is un-forgeable in the only way that matters: *a payload
|
|
193
|
+
* that is nothing but a redaction tombstone is already redacted*. One top-level key, and
|
|
194
|
+
* that key's value says why. Anything beside the marker is not this, gets walked, and is
|
|
195
|
+
* judged on its own contents — which is exactly right, because something beside the
|
|
196
|
+
* marker is something the erasure might need to reach. Inner fields beyond `reason` are
|
|
197
|
+
* deliberately not pinned: a later tombstone that carries more would still be nothing but
|
|
198
|
+
* a tombstone, and hard-coding its shape here is how idempotency would quietly regress.
|
|
199
|
+
*/
|
|
200
|
+
function isRedactedPayload(parsed) {
|
|
201
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
202
|
+
return false;
|
|
203
|
+
const keys = Object.keys(parsed);
|
|
204
|
+
if (keys.length !== 1 || keys[0] !== REDACTED_INTENT_MARKER)
|
|
205
|
+
return false;
|
|
206
|
+
const marker = parsed[REDACTED_INTENT_MARKER];
|
|
207
|
+
return (typeof marker === 'object' &&
|
|
208
|
+
marker !== null &&
|
|
209
|
+
!Array.isArray(marker) &&
|
|
210
|
+
marker['reason'] === 'subject-erasure');
|
|
211
|
+
}
|
|
212
|
+
function carriesSubject(node, subjectId) {
|
|
213
|
+
if (Array.isArray(node))
|
|
214
|
+
return node.some((child) => carriesSubject(child, subjectId));
|
|
215
|
+
if (node === null || typeof node !== 'object')
|
|
216
|
+
return false;
|
|
217
|
+
const obj = node;
|
|
218
|
+
if (obj['subjectId'] === subjectId &&
|
|
219
|
+
typeof obj['piiClass'] === 'string' &&
|
|
220
|
+
obj['piiClass'] !== 'none') {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
return Object.values(obj).some((child) => carriesSubject(child, subjectId));
|
|
224
|
+
}
|
|
225
|
+
// -- the delivery journal's error text (#1632) ---------------------------------------
|
|
226
|
+
/**
|
|
227
|
+
* The note a redaction leaves on a delivery of a redacted event that had failed.
|
|
228
|
+
*
|
|
229
|
+
* `_substrat_deliveries.error` is a consumer's or executor's own sentence about handling
|
|
230
|
+
* THIS event — a throw that quoted the payload it choked on reads exactly like the
|
|
231
|
+
* provider's `last_error` on an intent. Unlike every other copy in this file it needs no
|
|
232
|
+
* walk: the row is keyed by `event_id`, so the outbox's own predicate names it exactly.
|
|
233
|
+
*/
|
|
234
|
+
export const REDACTED_DELIVERY_NOTE = 'redacted by subject erasure (#37) — what this delivery reported is gone; that it failed, and when, is not';
|
|
235
|
+
/**
|
|
236
|
+
* One statement: every failed delivery of an event the erasure redacts. Only a NON-NULL
|
|
237
|
+
* error is rewritten, because a non-null error is what MEANS dead-lettered or retrying
|
|
238
|
+
* (`deliveryState`); writing the note onto a delivered row would turn it into a dead one.
|
|
239
|
+
* Not counted on the receipt: it is text about an event `eventsRedacted` already counts,
|
|
240
|
+
* not another copy of it. Idempotent — a note is not rewritten with itself.
|
|
241
|
+
*
|
|
242
|
+
* Params: note, note, subject id.
|
|
243
|
+
*/
|
|
244
|
+
export const DELIVERY_ERROR_REDACTION_SQL = `UPDATE _substrat_deliveries
|
|
245
|
+
SET error = ?
|
|
246
|
+
WHERE error IS NOT NULL AND error != ?
|
|
247
|
+
AND event_id IN (SELECT id FROM _substrat_outbox WHERE subject_id = ? AND pii_class != 'none')`;
|
|
248
|
+
// -- the job-run tables (#1632) ------------------------------------------------------
|
|
249
|
+
/**
|
|
250
|
+
* The note a redaction leaves on a job run, or on a step of its ledger, that had finished.
|
|
251
|
+
*
|
|
252
|
+
* Same reasoning as `REDACTED_INTENT_NOTE`: `last_error` is a sentence an external system
|
|
253
|
+
* wrote about work done on this person's data, so it is content, not envelope.
|
|
254
|
+
*/
|
|
255
|
+
export const REDACTED_JOB_NOTE = 'redacted by subject erasure (#37) — what this job run held about the subject is gone; that it ran, and when, is not';
|
|
256
|
+
/**
|
|
257
|
+
* The note a redaction leaves on a job run that was still `running`.
|
|
258
|
+
*
|
|
259
|
+
* A running run is redacted AND settled `failed` in the same statement, for the pending
|
|
260
|
+
* intent's reason and one of its own. A run resumes from its payload, its cursor and the
|
|
261
|
+
* memo of its completed steps; once any of those is a tombstone, the next pass would be
|
|
262
|
+
* handed the tombstone AS its input — a step's memo is returned to the handler without
|
|
263
|
+
* running anything — and walk on from nonsense. `failed` is what the run now is.
|
|
264
|
+
*/
|
|
265
|
+
export const CANCELLED_JOB_NOTE = 'stopped by subject erasure (#37) — this run held a copy of an erased subject\'s event, so it cannot resume from it';
|
|
266
|
+
/**
|
|
267
|
+
* The job-run half of an erasure (#1632), shared by both adapters.
|
|
268
|
+
*
|
|
269
|
+
* **What decides membership is #1600's predicate, and nothing else.** A job run carries no
|
|
270
|
+
* `subject_id`: its payload is "ids and configuration", its cursor is opaque, and a step's
|
|
271
|
+
* result is whatever a HOST handler got back from an external system. The one link the
|
|
272
|
+
* kernel can read reliably is the one the intent journal uses — a spine envelope embedded
|
|
273
|
+
* at any depth, carrying this subject and a `piiClass` other than `none` — so a job-run
|
|
274
|
+
* copy is redacted exactly when the erasure redacts the event it copies. The candidate
|
|
275
|
+
* reads use `instr` over the text, as the intent read does; `intentPayloadCarriesSubject`
|
|
276
|
+
* decides.
|
|
277
|
+
*
|
|
278
|
+
* **What it therefore does NOT reach** is external output naming the person with no
|
|
279
|
+
* classified envelope around it — a step that returns a provider's contact record, a
|
|
280
|
+
* `last_error` quoting a name. There is nothing in such a row that says whose it is, and a
|
|
281
|
+
* substring match on the id would both erase on coincidence and miss the name itself.
|
|
282
|
+
* kernel-design.md §13.1 limit 8 states it; a declared subject on the run is the open
|
|
283
|
+
* design that would close it (#1632).
|
|
284
|
+
*
|
|
285
|
+
* Per column, for a matching run: `payload` and `cursor` become the tombstone where THEY
|
|
286
|
+
* match (the payload is `NOT NULL`, the cursor may be `NULL` and stays so), `last_error`
|
|
287
|
+
* becomes the note, and a `running` run is settled `failed`. For a matching step: `result`
|
|
288
|
+
* becomes the tombstone, `last_error` the note — and its RUN is settled too, because the
|
|
289
|
+
* memo would otherwise hand the tombstone to the next pass. Idempotent: a tombstone no
|
|
290
|
+
* longer matches, so a second erasure finds nothing.
|
|
291
|
+
*
|
|
292
|
+
* Returns the number of distinct runs touched.
|
|
293
|
+
*/
|
|
294
|
+
export function redactSubjectJobRuns(sql, subjectId, at) {
|
|
295
|
+
// The needle, spelled as `platformRequestRedactionQuery` spells it.
|
|
296
|
+
const needle = JSON.stringify(subjectId).slice(1, -1);
|
|
297
|
+
const tombstone = redactedIntentPayload(subjectId, at);
|
|
298
|
+
const runs = new Set();
|
|
299
|
+
const steps = sql('SELECT run_id, step, result FROM _substrat_job_steps WHERE instr(result, ?) > 0', [needle]);
|
|
300
|
+
for (const s of steps) {
|
|
301
|
+
if (!intentPayloadCarriesSubject(s.result, subjectId))
|
|
302
|
+
continue;
|
|
303
|
+
sql(JOB_STEP_REDACTION_SQL, [tombstone, REDACTED_JOB_NOTE, s.run_id, s.step]);
|
|
304
|
+
runs.add(s.run_id);
|
|
305
|
+
}
|
|
306
|
+
const candidates = sql(`SELECT id, payload, cursor FROM _substrat_job_runs
|
|
307
|
+
WHERE instr(payload, ?) > 0 OR instr(cursor, ?) > 0`, [needle, needle]);
|
|
308
|
+
const hits = new Map();
|
|
309
|
+
for (const r of candidates) {
|
|
310
|
+
const payload = intentPayloadCarriesSubject(r.payload, subjectId);
|
|
311
|
+
const cursor = r.cursor !== null && intentPayloadCarriesSubject(r.cursor, subjectId);
|
|
312
|
+
if (payload || cursor)
|
|
313
|
+
hits.set(r.id, { payload, cursor });
|
|
314
|
+
}
|
|
315
|
+
// A run reached only through a step is still rewritten: its note and its status are
|
|
316
|
+
// what stop the tombstoned memo being replayed.
|
|
317
|
+
for (const id of runs)
|
|
318
|
+
if (!hits.has(id))
|
|
319
|
+
hits.set(id, { payload: false, cursor: false });
|
|
320
|
+
for (const [id, hit] of hits) {
|
|
321
|
+
sql(JOB_RUN_REDACTION_SQL, [
|
|
322
|
+
hit.payload ? 1 : 0,
|
|
323
|
+
tombstone,
|
|
324
|
+
hit.cursor ? 1 : 0,
|
|
325
|
+
tombstone,
|
|
326
|
+
CANCELLED_JOB_NOTE,
|
|
327
|
+
REDACTED_JOB_NOTE,
|
|
328
|
+
at,
|
|
329
|
+
at,
|
|
330
|
+
id,
|
|
331
|
+
]);
|
|
332
|
+
runs.add(id);
|
|
333
|
+
}
|
|
334
|
+
return runs.size;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* One matched step: its result becomes the tombstone, its error the note.
|
|
338
|
+
*
|
|
339
|
+
* Params: tombstone, note, run_id, step.
|
|
340
|
+
*/
|
|
341
|
+
export const JOB_STEP_REDACTION_SQL = `UPDATE _substrat_job_steps
|
|
342
|
+
SET result = ?, last_error = ?
|
|
343
|
+
WHERE run_id = ? AND step = ?`;
|
|
344
|
+
/**
|
|
345
|
+
* One matched run. Every `CASE WHEN status = 'running'` reads the status as it was before
|
|
346
|
+
* this statement — SQLite evaluates each SET against the original row — which is what
|
|
347
|
+
* lets one statement pick the note and settle the status it branched on. `ended_at` is
|
|
348
|
+
* COALESCEd so a run that had already ended keeps the instant it ended.
|
|
349
|
+
*
|
|
350
|
+
* Params: payload-hit (0/1), tombstone, cursor-hit (0/1), tombstone, cancelled-note,
|
|
351
|
+
* redacted-note, at (ended_at), at (updated_at), id.
|
|
352
|
+
*/
|
|
353
|
+
export const JOB_RUN_REDACTION_SQL = `UPDATE _substrat_job_runs
|
|
354
|
+
SET payload = CASE WHEN ? = 1 THEN ? ELSE payload END,
|
|
355
|
+
cursor = CASE WHEN ? = 1 THEN ? ELSE cursor END,
|
|
356
|
+
last_error = CASE WHEN status = 'running' THEN ? ELSE ? END,
|
|
357
|
+
next_attempt_at = CASE WHEN status = 'running' THEN NULL ELSE next_attempt_at END,
|
|
358
|
+
ended_at = COALESCE(ended_at, ?),
|
|
359
|
+
updated_at = ?,
|
|
360
|
+
status = CASE WHEN status = 'running' THEN 'failed' ELSE status END
|
|
361
|
+
WHERE id = ?`;
|
|
362
|
+
//# sourceMappingURL=subject-redaction.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"subject-redaction.js","sourceRoot":"","sources":["../src/subject-redaction.ts"],"names":[],"mappings":"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"}
|