@cosmicdrift/kumiko-framework 0.145.1 → 0.146.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.145.1",
3
+ "version": "0.146.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -189,7 +189,7 @@
189
189
  "zod": "^4.4.3"
190
190
  },
191
191
  "devDependencies": {
192
- "@cosmicdrift/kumiko-dispatcher-live": "0.145.1",
192
+ "@cosmicdrift/kumiko-dispatcher-live": "0.146.0",
193
193
  "bun-types": "^1.3.13",
194
194
  "pino-pretty": "^13.1.3"
195
195
  },
@@ -124,17 +124,18 @@ describe("event-store-executor", () => {
124
124
  });
125
125
  });
126
126
 
127
- // Sensitive-field stripping: passwords/tokens/IBANs stay in the entity row
128
- // but MUST NOT land in the immutable event log (GDPR right-to-be-forgotten,
129
- // secrets-rotation, audit discoverability). Fields marked `sensitive: true`
130
- // are excluded from every event payload: create data, update changes,
131
- // update previous, delete previous, restore previous.
127
+ // Sensitive fields (#967): passwords/tokens/IBANs must be ciphertext-at-rest
128
+ // (boot validates sensitive pii | encrypted). The event log carries the
129
+ // table ciphertext replay reproduces the row byte-identically. Plaintext
130
+ // never lands in permanent history; the caller-facing event echo strips the
131
+ // field entirely (#820).
132
+ const SENSITIVE_TEST_KEY = Buffer.from("s3nS!t1vE.kP9xQ2@wN!vL$hR5yT8eU0").toString("base64");
132
133
  const sensitiveEntity = createEntity({
133
134
  table: "read_es_exec_sensitive",
134
135
  fields: {
135
136
  email: createTextField({ required: true }),
136
- passwordHash: createTextField({ sensitive: true }),
137
- apiToken: createTextField({ sensitive: true }),
137
+ passwordHash: createTextField({ sensitive: true, encrypted: true }),
138
+ apiToken: createTextField({ sensitive: true, encrypted: true }),
138
139
  },
139
140
  softDelete: true,
140
141
  });
@@ -143,6 +144,7 @@ const sensitiveTable = buildEntityTable("esExecSensitive", sensitiveEntity);
143
144
  describe("event-store-executor — sensitive fields", () => {
144
145
  const crud = createEventStoreExecutor(sensitiveTable, sensitiveEntity, {
145
146
  entityName: "esExecSensitive",
147
+ encryption: createTestEnvelopeCipher(SENSITIVE_TEST_KEY),
146
148
  });
147
149
 
148
150
  beforeAll(async () => {
@@ -172,26 +174,35 @@ describe("event-store-executor — sensitive fields", () => {
172
174
  };
173
175
  }
174
176
 
175
- test("create event payload excludes sensitive fields but entity row keeps them", async () => {
177
+ test("create event payload carries sensitive fields as table ciphertext; echo strips them", async () => {
176
178
  const result = await crud.create(
177
179
  { email: "s@test.de", passwordHash: "pw-hash-123", apiToken: "tok-abc" },
178
180
  adminUser,
179
181
  tdb,
180
182
  );
181
183
  if (!result.isSuccess) throw new Error("create failed");
182
- // Entity row: full data preserved.
184
+ // Caller-facing row: plaintext preserved.
183
185
  expect(result.data.data["passwordHash"]).toBe("pw-hash-123");
184
186
  expect(result.data.data["apiToken"]).toBe("tok-abc");
185
187
 
186
- // Event payload: sensitive stripped, public retained.
188
+ // Event payload: ciphertext, byte-identical to the table column (#967).
187
189
  const event = await lastEvent();
188
190
  expect(event.type).toBe("esExecSensitive.created");
189
191
  expect(event.payload["email"]).toBe("s@test.de");
190
- expect(event.payload["passwordHash"]).toBeUndefined();
191
- expect(event.payload["apiToken"]).toBeUndefined();
192
+ expect(event.payload["passwordHash"]).toBeDefined();
193
+ expect(event.payload["passwordHash"]).not.toBe("pw-hash-123");
194
+ const rawRows = (await asRawClient(testDb.db).unsafe(
195
+ `SELECT password_hash FROM read_es_exec_sensitive WHERE email = 's@test.de' LIMIT 1`,
196
+ )) as Array<{ password_hash: string }>;
197
+ expect(event.payload["passwordHash"]).toBe(rawRows[0]!.password_hash);
198
+
199
+ // Caller-facing event echo stays stripped (#820).
200
+ if (!result.data.event) throw new Error("no event echo");
201
+ expect(result.data.event.payload["passwordHash"]).toBeUndefined();
202
+ expect(result.data.event.payload["apiToken"]).toBeUndefined();
192
203
  });
193
204
 
194
- test("update event strips sensitive from BOTH changes and previous", async () => {
205
+ test("update event carries ciphertext in BOTH changes and previous", async () => {
195
206
  const created = await crud.create(
196
207
  { email: "u@test.de", passwordHash: "old-hash", apiToken: "old-tok" },
197
208
  adminUser,
@@ -215,16 +226,29 @@ describe("event-store-executor — sensitive fields", () => {
215
226
  previous: { email?: string; passwordHash?: string; apiToken?: string };
216
227
  }>();
217
228
  expect(event.type).toBe("esExecSensitive.updated");
218
- // Changes: email retained (public), passwordHash stripped.
229
+ // Changes: email plaintext (public), passwordHash ciphertext.
219
230
  expect(event.payload.changes.email).toBe("u2@test.de");
220
- expect(event.payload.changes.passwordHash).toBeUndefined();
221
- // Previous: email retained, passwordHash + apiToken stripped.
231
+ expect(event.payload.changes.passwordHash).toBeDefined();
232
+ expect(event.payload.changes.passwordHash).not.toBe("new-hash");
233
+ // Previous: email plaintext, passwordHash + apiToken ciphertext.
222
234
  expect(event.payload.previous.email).toBe("u@test.de");
223
- expect(event.payload.previous.passwordHash).toBeUndefined();
224
- expect(event.payload.previous.apiToken).toBeUndefined();
235
+ expect(event.payload.previous.passwordHash).toBeDefined();
236
+ expect(event.payload.previous.passwordHash).not.toBe("old-hash");
237
+ expect(event.payload.previous.apiToken).toBeDefined();
238
+ expect(event.payload.previous.apiToken).not.toBe("old-tok");
239
+
240
+ // Caller-facing event echo stays stripped (#820).
241
+ if (!result.data.event) throw new Error("no event echo");
242
+ const echoPayload = result.data.event.payload as {
243
+ changes: Record<string, unknown>;
244
+ previous: Record<string, unknown>;
245
+ };
246
+ expect(echoPayload.changes["passwordHash"]).toBeUndefined();
247
+ expect(echoPayload.previous["passwordHash"]).toBeUndefined();
248
+ expect(echoPayload.previous["apiToken"]).toBeUndefined();
225
249
  });
226
250
 
227
- test("delete event strips sensitive from previous", async () => {
251
+ test("delete event carries ciphertext in previous", async () => {
228
252
  const created = await crud.create(
229
253
  { email: "d@test.de", passwordHash: "pw", apiToken: "tk" },
230
254
  adminUser,
@@ -240,11 +264,13 @@ describe("event-store-executor — sensitive fields", () => {
240
264
  const event = await lastEvent<SensitivePrevious>();
241
265
  expect(event.type).toBe("esExecSensitive.deleted");
242
266
  expect(event.payload.previous.email).toBe("d@test.de");
243
- expect(event.payload.previous.passwordHash).toBeUndefined();
244
- expect(event.payload.previous.apiToken).toBeUndefined();
267
+ expect(event.payload.previous.passwordHash).toBeDefined();
268
+ expect(event.payload.previous.passwordHash).not.toBe("pw");
269
+ expect(event.payload.previous.apiToken).toBeDefined();
270
+ expect(event.payload.previous.apiToken).not.toBe("tk");
245
271
  });
246
272
 
247
- test("restore event strips sensitive from previous", async () => {
273
+ test("restore event carries ciphertext in previous", async () => {
248
274
  const created = await crud.create(
249
275
  { email: "r@test.de", passwordHash: "pw", apiToken: "tk" },
250
276
  adminUser,
@@ -261,8 +287,10 @@ describe("event-store-executor — sensitive fields", () => {
261
287
  const event = await lastEvent<SensitivePrevious>();
262
288
  expect(event.type).toBe("esExecSensitive.restored");
263
289
  expect(event.payload.previous.email).toBe("r@test.de");
264
- expect(event.payload.previous.passwordHash).toBeUndefined();
265
- expect(event.payload.previous.apiToken).toBeUndefined();
290
+ expect(event.payload.previous.passwordHash).toBeDefined();
291
+ expect(event.payload.previous.passwordHash).not.toBe("pw");
292
+ expect(event.payload.previous.apiToken).toBeDefined();
293
+ expect(event.payload.previous.apiToken).not.toBe("tk");
266
294
  });
267
295
  });
268
296
 
@@ -15,7 +15,7 @@
15
15
  // 5. Snapshot erneut nehmen
16
16
  // 6. deep-equal: identische Rows in identischer Reihenfolge
17
17
 
18
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
19
19
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
20
20
  import { createBooleanField, createEntity, createTextField, defineFeature } from "../../engine";
21
21
  import { createRegistry } from "../../engine/registry";
@@ -222,26 +222,33 @@ describe("implicit-projection / Live==Rebuild equivalence", () => {
222
222
  });
223
223
  });
224
224
 
225
- // Sensitive-Drift ist eine bekannte Welle-3-Lücke: das Event-Log strippt
226
- // sensitive-Felder VOR dem Append (GDPR-Annahme), die Live-Read-Tabelle
227
- // bekommt sie über den unstripped flatData, der Rebuild-Pfad nur den
228
- // stripped event.payload. Bei Schema-Rebuilds gehen sensitive Daten
229
- // verloren.
230
- //
231
- // Dieser Test pinst die Drift explizit: Live row hat das sensitive Feld,
232
- // Rebuild row hat NULL. Wenn Welle 3 das fixt (z.B. via separater
233
- // sensitive-Spalte oder verschlüsseltem Event-Payload), bricht der Test
234
- // und zwingt zu Aufmerksamkeit.
235
-
225
+ // Sensitive-Rebuild-Parität (#967): das Event-Log trägt für sensitive-Felder
226
+ // den Tabellen-Ciphertext (boot-validiert pii/encrypted) Live==Rebuild gilt
227
+ // damit auch für sensitive Spalten + Blind-Index. Einzige legitime Divergenz
228
+ // bleibt Crypto-Shredding: DEK erased bidx NULL, Wert unlesbar.
229
+
230
+ import {
231
+ computeBlindIndex,
232
+ configureBlindIndexKey,
233
+ configurePiiSubjectKms,
234
+ decodeBlindIndexKey,
235
+ decryptPiiFieldValues,
236
+ InMemoryKmsAdapter,
237
+ isPiiCiphertext,
238
+ resetBlindIndexKeyForTests,
239
+ resetPiiSubjectKmsForTests,
240
+ } from "../../crypto";
236
241
  import { asRawClient, selectMany } from "../../db/query";
237
242
 
238
243
  const sensitiveTable = "read_implicit_sensitive_users";
244
+ const SENSITIVE_BIDX_KEY_B64 = Buffer.alloc(32, 9).toString("base64");
245
+ const SENSITIVE_BIDX_KEY = decodeBlindIndexKey(SENSITIVE_BIDX_KEY_B64);
239
246
 
240
247
  const sensitiveEntity = createEntity({
241
248
  table: sensitiveTable,
242
249
  fields: {
243
250
  email: createTextField({ required: true }),
244
- apiKey: createTextField({ sensitive: true }),
251
+ apiKey: createTextField({ sensitive: true, pii: true, lookupable: true }),
245
252
  },
246
253
  });
247
254
 
@@ -250,8 +257,11 @@ const sensitiveFeature = defineFeature("implicitsensitive", (r) => {
250
257
  });
251
258
 
252
259
  const sensitiveEntityTable = buildEntityTable("sensitive-user", sensitiveEntity);
260
+ const sensitiveProjection = "implicitsensitive:projection:sensitive-user-entity";
261
+
262
+ describe("implicit-projection / sensitive Rebuild-Parität (#967)", () => {
263
+ let kms: InMemoryKmsAdapter;
253
264
 
254
- describe("implicit-projection / dokumentierte Sensitive-Drift", () => {
255
265
  beforeAll(async () => {
256
266
  await unsafeCreateEntityTable(testDb.db, sensitiveEntity, "sensitive-user");
257
267
  });
@@ -260,29 +270,46 @@ describe("implicit-projection / dokumentierte Sensitive-Drift", () => {
260
270
  await asRawClient(testDb.db).unsafe(
261
271
  `TRUNCATE ${sensitiveTable}, kumiko_events, kumiko_projections RESTART IDENTITY CASCADE`,
262
272
  );
273
+ kms = new InMemoryKmsAdapter();
274
+ configurePiiSubjectKms(kms);
275
+ configureBlindIndexKey(SENSITIVE_BIDX_KEY_B64);
263
276
  });
264
277
 
265
- test("Live schreibt sensitive-Felder, Rebuild lässt sie NULL (Welle-3-Roadmap)", async () => {
266
- const crud = createEventStoreExecutor(sensitiveEntityTable, sensitiveEntity, {
267
- entityName: "sensitive-user",
268
- });
278
+ afterEach(() => {
279
+ resetPiiSubjectKmsForTests();
280
+ resetBlindIndexKeyForTests();
281
+ });
269
282
 
270
- // 1. Live: create mit apiKey (sensitive). Read-Tabelle bekommt den
271
- // Wert direkt vom Live-Pfad (unstripped flatData).
283
+ const crud = createEventStoreExecutor(sensitiveEntityTable, sensitiveEntity, {
284
+ entityName: "sensitive-user",
285
+ });
286
+
287
+ async function rawSensitiveRow(id: string): Promise<Record<string, unknown>> {
288
+ const rows = await asRawClient(testDb.db).unsafe<Record<string, unknown>>(
289
+ `SELECT * FROM ${sensitiveTable} WHERE id = $1`,
290
+ [id],
291
+ );
292
+ const row = rows[0];
293
+ if (!row) throw new Error(`no row for ${id}`);
294
+ return row;
295
+ }
296
+
297
+ test("Live==Rebuild inkl. sensitive-Ciphertext + bidx (byte-gleiche Kopie aus dem Event)", async () => {
272
298
  const created = await crud.create(
273
299
  { email: "x@test.de", apiKey: "secret-token-abc" },
274
300
  adminUser,
275
301
  tdb,
276
302
  );
277
303
  if (!created.isSuccess) throw new Error("setup failed");
304
+ const id = String(created.data.id);
278
305
 
279
- const [liveRow] = await selectMany(testDb.db, sensitiveEntityTable, {
280
- id: created.data.id as string,
281
- });
282
- expect(liveRow?.["apiKey"]).toBe("secret-token-abc");
283
- expect(liveRow?.["email"]).toBe("x@test.de");
306
+ // Live-Row: Ciphertext + bidx, nie Klartext.
307
+ const liveRow = await rawSensitiveRow(id);
308
+ const liveCipher = liveRow["api_key"];
309
+ expect(isPiiCiphertext(liveCipher)).toBe(true);
310
+ expect(liveRow["api_key_bidx"]).toBe(computeBlindIndex(SENSITIVE_BIDX_KEY, "secret-token-abc"));
284
311
 
285
- // 2. Verifiziere dass das Event-Log das Feld NICHT enthält (stripped).
312
+ // Event-Payload trägt exakt den Tabellen-Ciphertext (byte-gleich).
286
313
  const { eventsTable } = await import("../../event-store");
287
314
  const [event] = await selectMany(
288
315
  testDb.db,
@@ -290,23 +317,75 @@ describe("implicit-projection / dokumentierte Sensitive-Drift", () => {
290
317
  { aggregateId: created.data.id },
291
318
  { orderBy: { col: "version", direction: "asc" } },
292
319
  );
293
- expect(event?.payload?.["apiKey"]).toBeUndefined();
320
+ expect(event?.payload?.["apiKey"]).toBe(liveCipher);
294
321
  expect(event?.payload?.["email"]).toBe("x@test.de");
322
+ expect(JSON.stringify(event?.payload)).not.toContain("secret-token-abc");
295
323
 
296
- // 3. Rebuild über die ImplicitProjection. Read-Tabelle wird aus
297
- // event.payload neu materialisiert — apiKey ist nicht im Log,
298
- // landet also als NULL/undefined in der rebuilt Row.
299
324
  const registry = createRegistry([sensitiveFeature]);
300
- await rebuildProjection("implicitsensitive:projection:sensitive-user-entity", {
301
- db: testDb.db,
302
- registry,
303
- });
325
+ await rebuildProjection(sensitiveProjection, { db: testDb.db, registry });
326
+
327
+ const rebuiltRow = await rawSensitiveRow(id);
328
+ expect(rebuiltRow["email"]).toBe("x@test.de");
329
+ expect(rebuiltRow["api_key"]).toBe(liveCipher);
330
+ expect(rebuiltRow["api_key_bidx"]).toBe(liveRow["api_key_bidx"]);
331
+ });
304
332
 
305
- const [rebuiltRow] = await selectMany(testDb.db, sensitiveEntityTable, {
306
- id: created.data.id as string,
333
+ test("DEK-Stabilität: Update re-encryptet mit demselben Subject-DEK — alter Event-Ciphertext bleibt lesbar", async () => {
334
+ const created = await crud.create(
335
+ { email: "y@test.de", apiKey: "first-secret" },
336
+ adminUser,
337
+ tdb,
338
+ );
339
+ if (!created.isSuccess) throw new Error("setup failed");
340
+ const updated = await crud.update(
341
+ { id: created.data.id, version: 1, changes: { apiKey: "second-secret" } },
342
+ adminUser,
343
+ tdb,
344
+ );
345
+ if (!updated.isSuccess) throw new Error("update failed");
346
+
347
+ // Created-Event (v1-Ciphertext) muss mit dem aktuellen DEK lesbar bleiben —
348
+ // eine DEK-Rotation beim Update würde das immutable Log unlesbar machen.
349
+ const { eventsTable } = await import("../../event-store");
350
+ const [createdEvent] = await selectMany(
351
+ testDb.db,
352
+ eventsTable,
353
+ { aggregateId: created.data.id },
354
+ { orderBy: { col: "version", direction: "asc" } },
355
+ );
356
+ const v1Cipher = createdEvent?.payload?.["apiKey"];
357
+ expect(isPiiCiphertext(v1Cipher)).toBe(true);
358
+ const decrypted = await decryptPiiFieldValues({ apiKey: v1Cipher }, ["apiKey"], kms, {
359
+ requestId: "test",
307
360
  });
308
- expect(rebuiltRow?.["email"]).toBe("x@test.de");
309
- // DAS ist die Drift: sensitive Feld ist nach Rebuild weg.
310
- expect(rebuiltRow?.["apiKey"]).toBeNull();
361
+ expect(decrypted["apiKey"]).toBe("first-secret");
362
+
363
+ // Rebuild spielt created+updated: bidx-Recompute entschlüsselt beide
364
+ // Ciphertexte — schlägt bei rotiertem DEK fehl statt still zu heilen.
365
+ const registry = createRegistry([sensitiveFeature]);
366
+ await rebuildProjection(sensitiveProjection, { db: testDb.db, registry });
367
+ const rebuiltRow = await rawSensitiveRow(String(created.data.id));
368
+ expect(rebuiltRow["api_key_bidx"]).toBe(computeBlindIndex(SENSITIVE_BIDX_KEY, "second-secret"));
369
+ });
370
+
371
+ test("Erase-Divergenz bleibt die einzige legitime: DEK erased → Rebuild bidx NULL, Wert unlesbar", async () => {
372
+ const created = await crud.create(
373
+ { email: "z@test.de", apiKey: "gone-after-forget" },
374
+ adminUser,
375
+ tdb,
376
+ );
377
+ if (!created.isSuccess) throw new Error("setup failed");
378
+ const id = String(created.data.id);
379
+
380
+ await kms.eraseKey({ kind: "user", userId: id });
381
+
382
+ const registry = createRegistry([sensitiveFeature]);
383
+ await rebuildProjection(sensitiveProjection, { db: testDb.db, registry });
384
+
385
+ const rebuiltRow = await rawSensitiveRow(id);
386
+ expect(rebuiltRow["api_key_bidx"]).toBeNull();
387
+ // Ciphertext-Bytes werden kopiert, sind aber ohne DEK unlesbar.
388
+ expect(isPiiCiphertext(rebuiltRow["api_key"])).toBe(true);
389
+ expect(rebuiltRow["api_key"]).not.toBe("gone-after-forget");
311
390
  });
312
391
  });
@@ -1,22 +1,18 @@
1
1
  // applyEntityEvent — die EINZIGE Schreib-Logik für r.entity-Tabellen aus
2
2
  // Stored-Events. Beide Aufrufer benutzen sie:
3
3
  //
4
- // - createEventStoreExecutor (live, im Write-TX) — übergibt ein
5
- // "live event" mit unstripped flatData/flatChanges als payload damit
6
- // sensitive Felder in der Read-Tabelle landen, das Event-Log selbst
7
- // bleibt aber stripped (siehe append-Site im Executor).
4
+ // - createEventStoreExecutor (live, im Write-TX) — übergibt das gerade
5
+ // appendete StoredEvent direkt; sensitive Felder liegen als Tabellen-
6
+ // Ciphertext im Payload (boot-validiert pii/encrypted, #967).
8
7
  // - rebuildProjection via ImplicitProjection (replay, im Rebuild-TX) —
9
- // übergibt das StoredEvent direkt; payload ist dort ohne sensitive,
10
- // was bei Rebuild akzeptiert wird (sensitive-Drift durch GDPR-Strip
11
- // ist als load-bearing Backlog-Item gepinnt — siehe
12
- // docs/plans/architecture/migrations.md Sektion "Backlog (Welle 3+)"
13
- // → "Sensitive-Field-Persistenz im Rebuild" für Optionen a/b/c).
8
+ // übergibt dasselbe StoredEvent; apply kopiert Ciphertext byte-gleich
9
+ // zurück, bidx wird unten neu berechnet.
14
10
  //
15
- // Live==Rebuild-Equivalence ist damit by-construction für alle Felder
16
- // die NICHT als sensitive markiert sind eine geänderte Schreib-Logik
17
- // muss nur an EINER Stelle gepflegt werden, kein Sync-Contract mehr.
18
- // Der load-bearing Test bleibt für non-sensitive-Drift in
19
- // db/__tests__/implicit-projection-equivalence.integration.ts.
11
+ // Live==Rebuild-Equivalence ist damit by-construction für ALLE Felder
12
+ // eine geänderte Schreib-Logik muss nur an EINER Stelle gepflegt werden,
13
+ // kein Sync-Contract mehr. Einzige legitime Divergenz: Crypto-Shredding
14
+ // (DEK erased Wert unlesbar, bidx NULL). Load-bearing Test:
15
+ // db/__tests__/implicit-projection-equivalence.integration.test.ts.
20
16
  //
21
17
  // Tenant-Isolation: applyEntityEvent erwartet einen rohen DbRunner (TX
22
18
  // oder pool), KEINEN TenantDb-Wrapper. Schutz kommt aus zwei Quellen:
@@ -304,11 +304,10 @@ export function createEventStoreExecutor(
304
304
  if (def !== undefined) fieldDefaults[name] = def;
305
305
  }
306
306
 
307
- // Pre-compute the set of sensitive field names once. Every event payload
308
- // (create data, update changes + previous, delete previous, restore
309
- // previous) strips these before writing to the immutable event log. Keeps
310
- // GDPR right-to-be-forgotten tractable only entity rows hold the
311
- // sensitive data, and entity rows can be deleted / re-encrypted.
307
+ // Pre-compute the set of sensitive field names once. The event log stores
308
+ // these fields as table ciphertext (boot validates sensitive ⇒ pii |
309
+ // encrypted, #967) the set only strips the caller-facing event echo so
310
+ // responses never carry the value (#820).
312
311
  const sensitiveFields = new Set<string>();
313
312
  for (const [name, field] of Object.entries(entity.fields)) {
314
313
  if ("sensitive" in field && field.sensitive === true) {
@@ -526,8 +525,9 @@ export function createEventStoreExecutor(
526
525
 
527
526
  // 1. Append event (same TX as the projection write — both must succeed
528
527
  // or both roll back; the dispatcher wraps both in one transaction).
529
- // Sensitive fields are stripped from the event payload; the entity
530
- // row below still receives the full data.
528
+ // flatData is already table ciphertext for pii/encrypted fields, so
529
+ // the immutable log never sees plaintext and replay reproduces the
530
+ // row byte-identically (#967).
531
531
  //
532
532
  // `expectedVersion: 0` heißt: stream existiert noch nicht. Bei
533
533
  // deterministic-aggregate-id-Patterns (z.B. uuidv5(tenantId|naturalKey))
@@ -542,7 +542,7 @@ export function createEventStoreExecutor(
542
542
  tenantId: streamTenantFor(user),
543
543
  expectedVersion: 0,
544
544
  type: entityEventName(entityName, "created"),
545
- payload: stripSensitive(flatData),
545
+ payload: flatData,
546
546
  metadata: buildEventMetadata(user),
547
547
  });
548
548
  } catch (e) {
@@ -573,9 +573,8 @@ export function createEventStoreExecutor(
573
573
  }
574
574
 
575
575
  // 2. Update projection via applyEntityEvent — derselbe Code-Pfad den
576
- // rebuildProjection für Replay nutzt Live==Rebuild by-construction.
577
- // Wir bauen ein "live event" mit unstripped flatData (damit sensitive
578
- // Felder in der Read-Tabelle landen, aber nicht im Event-Log).
576
+ // rebuildProjection für Replay nutzt, mit demselben StoredEvent →
577
+ // Live==Rebuild by-construction (#967).
579
578
  //
580
579
  // F8-Patch: app-level unique-violations (z.B. (tenantId, email)
581
580
  // auf User-Entity, (tenantId, slug) auf Article) werfen pg-23505
@@ -583,10 +582,9 @@ export function createEventStoreExecutor(
583
582
  // unhandled exception → 500 internal_error. Map auf
584
583
  // UniqueViolationError 409 damit Designer/Frontend einen sauberen
585
584
  // "duplicate" zeigen können statt cryptic "internal server error".
586
- const liveEvent = { ...event, payload: flatData };
587
585
  let result: Awaited<ReturnType<typeof applyEntityEvent>>;
588
586
  try {
589
- result = await applyEntityEvent(liveEvent, table, entity, db.raw);
587
+ result = await applyEntityEvent(event, table, entity, db.raw);
590
588
  } catch (e) {
591
589
  const mapped = tryMapUniqueViolation(e, entityName);
592
590
  if (mapped) return mapped;
@@ -718,11 +716,10 @@ export function createEventStoreExecutor(
718
716
  // previous value to decrement/undo when a parent-FK moves — without it
719
717
  // you'd have to snapshot-and-diff on every apply, and replays would
720
718
  // break. Storage cost is acceptable (rows are bounded), correctness is
721
- // not negotiable. Sensitive fields are stripped from BOTH halves so
722
- // they never reach the immutable event log. `previous` came from
723
- // loadById(), which decrypts re-encrypt it before it's persisted so
724
- // an `encrypted` field's plaintext doesn't land in the immutable log
725
- // (flatChanges is already ciphertext from encryptForStorage above).
719
+ // not negotiable. `previous` came from loadById(), which decrypts
720
+ // re-encrypt it before it's persisted so plaintext of pii/encrypted
721
+ // fields doesn't land in the immutable log (flatChanges is already
722
+ // ciphertext from encryptForStorage above).
726
723
  const event = await append(db.raw, {
727
724
  aggregateId: String(payload.id),
728
725
  aggregateType: entityName,
@@ -730,26 +727,23 @@ export function createEventStoreExecutor(
730
727
  expectedVersion: currentVersion,
731
728
  type: entityEventName(entityName, "updated"),
732
729
  payload: {
733
- changes: stripSensitive(flatChanges),
734
- previous: stripSensitive(await encryptForStorage(previous, user)),
730
+ changes: flatChanges,
731
+ previous: await encryptForStorage(previous, user),
735
732
  },
736
733
  metadata: buildEventMetadata(user),
737
734
  });
738
735
 
739
- // Live==Rebuild via applyEntityEvent: live-event mit unstripped
740
- // flatChanges damit sensitive Felder in der Read-Tabelle landen.
736
+ // Live==Rebuild via applyEntityEvent mit demselben StoredEvent —
737
+ // apply liest nur `changes`, und die sind live wie im Replay
738
+ // identischer Ciphertext (#967).
741
739
  //
742
740
  // F8-Patch: dasselbe unique-violation-handling wie im create-Pfad
743
741
  // — ein update das einen unique-Index verletzt (z.B. email-update
744
742
  // auf einen schon-existierenden Wert) wird mit 409 unique_violation
745
743
  // statt 500 internal_error rückgemeldet.
746
- const liveEvent = {
747
- ...event,
748
- payload: { changes: flatChanges, previous },
749
- };
750
744
  let result: Awaited<ReturnType<typeof applyEntityEvent>>;
751
745
  try {
752
- result = await applyEntityEvent(liveEvent, table, entity, db.raw);
746
+ result = await applyEntityEvent(event, table, entity, db.raw);
753
747
  } catch (e) {
754
748
  const mapped = tryMapUniqueViolation(e, entityName);
755
749
  if (mapped) return mapped;
@@ -837,17 +831,16 @@ export function createEventStoreExecutor(
837
831
  // Deletes carry the full pre-delete row as `previous`. That's what
838
832
  // projections and downstream consumers need to reverse any aggregates —
839
833
  // a `{}`-payload delete would make cross-aggregate projections impossible
840
- // to rebuild from the event log alone. Sensitive fields are stripped.
841
- // `existing` came from loadById(), which decrypts — re-encrypt before
842
- // persisting so an `encrypted` field's plaintext doesn't land in the
843
- // immutable log.
834
+ // to rebuild from the event log alone. `existing` came from loadById(),
835
+ // which decrypts — re-encrypt before persisting so plaintext doesn't
836
+ // land in the immutable log.
844
837
  const event = await append(db.raw, {
845
838
  aggregateId: String(payload.id),
846
839
  aggregateType: entityName,
847
840
  tenantId: streamTenantFor(user),
848
841
  expectedVersion: currentVersion,
849
842
  type: entityEventName(entityName, "deleted"),
850
- payload: { previous: stripSensitive(await encryptForStorage(existing, user)) },
843
+ payload: { previous: await encryptForStorage(existing, user) },
851
844
  metadata: buildEventMetadata(user),
852
845
  });
853
846
 
@@ -918,7 +911,7 @@ export function createEventStoreExecutor(
918
911
  type: entityEventName(entityName, "forgotten"),
919
912
  // Re-encrypt like delete(): `existing` came decrypted from loadById —
920
913
  // plaintext must not land in the immutable log, least of all on forget.
921
- payload: { previous: stripSensitive(await encryptForStorage(existing, user)) },
914
+ payload: { previous: await encryptForStorage(existing, user) },
922
915
  metadata: buildEventMetadata(user),
923
916
  });
924
917
 
@@ -992,14 +985,15 @@ export function createEventStoreExecutor(
992
985
  // Restore carries the soft-deleted snapshot as `previous` — mirror of
993
986
  // delete for symmetry. Projections that decremented on delete use
994
987
  // `previous` to re-increment on restore without re-querying the entity
995
- // table. Sensitive fields are stripped.
988
+ // table. `data` is the raw stored row — pii/encrypted fields are
989
+ // already ciphertext, no re-encrypt needed.
996
990
  const event = await append(db.raw, {
997
991
  aggregateId: String(payload.id),
998
992
  aggregateType: entityName,
999
993
  tenantId: streamTenantFor(user),
1000
994
  expectedVersion: currentVersion,
1001
995
  type: entityEventName(entityName, "restored"),
1002
- payload: { previous: stripSensitive(data) },
996
+ payload: { previous: data },
1003
997
  metadata: buildEventMetadata(user),
1004
998
  });
1005
999
 
@@ -117,6 +117,55 @@ describe("validateBoot — PII annotations", () => {
117
117
  expect(() => validateBoot([feature])).toThrow(/multiple subject-key annotations/);
118
118
  });
119
119
 
120
+ test("naked sensitive (no pii/encrypted) throws — event log needs ciphertext-at-rest (#967)", () => {
121
+ const feature = defineFeature("test", (r) => {
122
+ r.entity(
123
+ "vault",
124
+ createEntity({
125
+ fields: {
126
+ apiToken: createTextField({ sensitive: true }),
127
+ },
128
+ }),
129
+ );
130
+ });
131
+ expect(() => validateBoot([feature])).toThrow(/sensitive: true.*without ciphertext-at-rest/);
132
+ });
133
+
134
+ test("sensitive with pii subject annotation passes", () => {
135
+ const feature = defineFeature("test", (r) => {
136
+ r.entity(
137
+ "vault",
138
+ createEntity({
139
+ fields: {
140
+ apiToken: createTextField({ sensitive: true, pii: true }),
141
+ },
142
+ }),
143
+ );
144
+ });
145
+ expect(() => validateBoot([feature])).not.toThrow();
146
+ });
147
+
148
+ test("sensitive with encrypted passes", () => {
149
+ process.env["KUMIKO_SECRETS_MASTER_KEY_V1"] = Buffer.from(
150
+ "0123456789abcdef0123456789abcdef",
151
+ ).toString("base64");
152
+ try {
153
+ const feature = defineFeature("test", (r) => {
154
+ r.entity(
155
+ "vault",
156
+ createEntity({
157
+ fields: {
158
+ apiToken: createTextField({ sensitive: true, encrypted: true }),
159
+ },
160
+ }),
161
+ );
162
+ });
163
+ expect(() => validateBoot([feature])).not.toThrow();
164
+ } finally {
165
+ delete process.env["KUMIKO_SECRETS_MASTER_KEY_V1"];
166
+ }
167
+ });
168
+
120
169
  test("userOwned.ownerField pointing to non-existent field throws", () => {
121
170
  const feature = defineFeature("test", (r) => {
122
171
  r.entity(
@@ -80,6 +80,23 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
80
80
  }
81
81
  }
82
82
 
83
+ // sensitive-Felder liegen seit #967 als Tabellen-Ciphertext im Event-
84
+ // Log — ohne ciphertext-at-rest würde der Append Klartext in die
85
+ // immutable History schreiben.
86
+ const sensitiveFlags = field as {
87
+ readonly sensitive?: boolean;
88
+ readonly encrypted?: boolean;
89
+ }; // @cast-boundary schema-walk
90
+ if (
91
+ sensitiveFlags.sensitive === true &&
92
+ annotCount === 0 &&
93
+ sensitiveFlags.encrypted !== true
94
+ ) {
95
+ throw new Error(
96
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { sensitive: true } without ciphertext-at-rest. Since #967 the event log stores sensitive fields as table ciphertext — add a subject annotation (pii / userOwned / tenantOwned) or { encrypted: true }.`,
97
+ );
98
+ }
99
+
83
100
  // Substring-Suche/Sortierung auf Ciphertext ist prinzipbedingt
84
101
  // unmöglich — searchable würde Plaintext-Kopien in den Suchindex
85
102
  // schieben, sortable sortiert Base64-Blobs. Equality → lookupable.
@@ -12,13 +12,13 @@ export type FieldAccess = {
12
12
  readonly write?: OwnershipMap | readonly string[];
13
13
  };
14
14
 
15
- // `sensitive: true` — the field's value is excluded from event payloads
16
- // (create data, update changes/previous, delete/restore previous). The entity
17
- // row still stores it; only the immutable event-log won't. Use for data that
18
- // must never land in permanent history: password hashes, API tokens,
19
- // unhashed PII, bank details, tax IDs. The trade-off: event-replay and
20
- // custom projections cannot read sensitive field values. See
21
- // docs/plans/architecture/projections.md.
15
+ // `sensitive: true` — the field's value is excluded from caller-facing event
16
+ // echoes in write responses (#820) and MUST be ciphertext-at-rest: boot
17
+ // validation requires a subject annotation (pii / userOwned / tenantOwned)
18
+ // or `encrypted: true` (#967). Event payloads carry the table ciphertext,
19
+ // so replay reproduces the row byte-identically; plaintext never lands in
20
+ // permanent history. Use for password hashes, API tokens, bank details,
21
+ // tax IDs. See docs/plans/architecture/projections.md.
22
22
 
23
23
  // --- PII / Subject-Key Annotations (DSGVO Art. 17 — Crypto-Shredding) ---
24
24
  //