@cosmicdrift/kumiko-framework 0.209.1 → 0.210.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.
Files changed (35) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/entity-permalink-open.integration.test.ts +6 -1
  3. package/src/crypto/__tests__/blind-index.test.ts +1 -1
  4. package/src/crypto/__tests__/pii-field-encryption.test.ts +16 -10
  5. package/src/crypto/__tests__/subject-resolver.test.ts +9 -3
  6. package/src/db/__tests__/blind-index.integration.test.ts +1 -1
  7. package/src/db/__tests__/cursor.test.ts +26 -1
  8. package/src/db/__tests__/eagerload.integration.test.ts +3 -3
  9. package/src/db/__tests__/entity-table-meta-source.test.ts +4 -1
  10. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
  11. package/src/db/__tests__/event-store-executor-list.integration.test.ts +144 -1
  12. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -2
  13. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +1 -1
  14. package/src/db/cursor.ts +32 -0
  15. package/src/db/event-store-executor-read.ts +80 -9
  16. package/src/db/index.ts +2 -2
  17. package/src/db/pg-error.ts +7 -0
  18. package/src/db/queries/backfill-pii.ts +188 -18
  19. package/src/engine/__tests__/boot-validator-boot-check.test.ts +6 -1
  20. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +140 -140
  21. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +17 -5
  22. package/src/engine/__tests__/factories-personal.test.ts +130 -0
  23. package/src/engine/__tests__/field-access.test.ts +1 -23
  24. package/src/engine/__tests__/store-table.test.ts +4 -1
  25. package/src/engine/boot-validator/pii-retention.ts +22 -54
  26. package/src/engine/build-config-feature-schema.ts +9 -1
  27. package/src/engine/factories.ts +108 -30
  28. package/src/engine/field-access.ts +2 -13
  29. package/src/engine/index.ts +7 -1
  30. package/src/engine/types/index.ts +7 -1
  31. package/src/event-store/__tests__/backfill-pii.integration.test.ts +179 -2
  32. package/src/files/file-ref-entity.ts +2 -2
  33. package/src/search/__tests__/reindex-entity.integration.test.ts +1 -1
  34. package/src/search/__tests__/search-pii-derived-index.integration.test.ts +2 -2
  35. package/src/testing/shared-entities.ts +6 -3
@@ -20,12 +20,25 @@
20
20
  // second run reports 0 updates. One failing event does not abort the run;
21
21
  // failures are collected and reported (fail-loud at the caller).
22
22
  //
23
+ // Owner resolution for entity-lifecycle events (backfill-only — the live
24
+ // write path in event-store-executor-write.ts / subject-resolver.ts stays
25
+ // fail-closed and is untouched by this):
26
+ // 1. payload — the event section itself names the owner field.
27
+ // 2. projection ({@link PiiBackfillOptions.resolveOwnerFromProjection}) —
28
+ // pre-owner-field-addition events don't carry it; read it from the
29
+ // entity's current projection row instead.
30
+ // 3. erase ({@link PiiBackfillOptions.eraseUnresolvableSubjects}) — still
31
+ // unresolvable (hard-deleted aggregate, no projection) → PII_ERASED_SENTINEL.
32
+ // Both stages are opt-in and off by default; without them an unresolvable
33
+ // subject still fails loud into `failures`, as before.
34
+ //
23
35
  // Snapshots of touched aggregates are dropped (they may cache plaintext);
24
36
  // the next snapshotting load recreates them. AFTER a run, rebuild the
25
37
  // affected projections — applyEntityEvent materializes ciphertext AND the
26
38
  // blind-index columns, which keeps equality lookups (login by email) alive.
27
39
 
28
40
  import { asRawClient } from "../../bun-db";
41
+ import { quoteIdent } from "../../crypto/ciphertext-pattern";
29
42
  import { configuredEventPiiCatalog } from "../../crypto/event-pii";
30
43
  import type { KmsContext, LocalKeyKmsAdapter, SubjectId } from "../../crypto/kms-adapter";
31
44
  import { KeyErasedError } from "../../crypto/kms-adapter";
@@ -35,9 +48,16 @@ import {
35
48
  isPiiCiphertext,
36
49
  PII_ERASED_SENTINEL,
37
50
  } from "../../crypto/pii-field-encryption";
38
- import { collectPiiSubjectFields, resolveSubjectForField } from "../../crypto/subject-resolver";
51
+ import {
52
+ collectPiiSubjectFields,
53
+ resolveSubjectForField,
54
+ SubjectResolutionError,
55
+ } from "../../crypto/subject-resolver";
39
56
  import type { EntityDefinition, Registry, TenantId } from "../../engine/types";
40
57
  import type { DbRunner } from "../connection";
58
+ import { resolveTableName } from "../entity-table-meta";
59
+ import { isUndefinedTable } from "../pg-error";
60
+ import { toSnakeCase } from "../table-builder";
41
61
 
42
62
  const LIFECYCLE_VERBS = ["created", "updated", "deleted", "restored", "forgotten"] as const;
43
63
 
@@ -51,6 +71,12 @@ export type PiiBackfillResult = {
51
71
  readonly updatedEvents: number;
52
72
  readonly encryptedFields: number;
53
73
  readonly erasedFields: number;
74
+ // Subset of encryptedFields + erasedFields whose owner came from stage 2
75
+ // (the payload itself lacked it).
76
+ readonly ownerFromProjection: number;
77
+ // Subset of erasedFields written because the subject stayed unresolvable
78
+ // through stage 3, not because the subject was already forgotten.
79
+ readonly erasedUnresolvable: number;
54
80
  readonly deletedSnapshots: number;
55
81
  readonly failures: readonly PiiBackfillFailure[];
56
82
  };
@@ -59,6 +85,12 @@ export type PiiBackfillOptions = {
59
85
  readonly batchSize?: number;
60
86
  // Scan + count only, write nothing.
61
87
  readonly dryRun?: boolean;
88
+ // Stage 2: fall back to the entity's projection row (by aggregate_id)
89
+ // when a lifecycle event's payload doesn't name the owner field.
90
+ readonly resolveOwnerFromProjection?: boolean;
91
+ // Stage 3: write PII_ERASED_SENTINEL for subjects still unresolvable
92
+ // after stage 1+2, instead of failing the event into `failures`.
93
+ readonly eraseUnresolvableSubjects?: boolean;
62
94
  };
63
95
 
64
96
  type EventRow = {
@@ -105,6 +137,8 @@ export async function backfillEventPiiEncryption(
105
137
  updatedEvents: 0,
106
138
  encryptedFields: 0,
107
139
  erasedFields: 0,
140
+ ownerFromProjection: 0,
141
+ erasedUnresolvable: 0,
108
142
  deletedSnapshots: 0,
109
143
  failures: [] as PiiBackfillFailure[],
110
144
  };
@@ -132,13 +166,17 @@ export async function backfillEventPiiEncryption(
132
166
  )) as ReadonlyArray<EventRow>;
133
167
  if (rows.length === 0) break;
134
168
 
169
+ const projectionOwnersByType = await loadProjectionOwners(rows);
170
+
135
171
  for (const row of rows) {
136
172
  result.scannedEvents++;
137
173
  try {
138
- const outcome = await transformEvent(row);
174
+ const outcome = await transformEvent(row, projectionOwnersByType.get(row.aggregate_type));
139
175
  if (outcome === null) continue;
140
176
  result.encryptedFields += outcome.encrypted;
141
177
  result.erasedFields += outcome.erased;
178
+ result.ownerFromProjection += outcome.ownerFromProjection;
179
+ result.erasedUnresolvable += outcome.erasedUnresolvable;
142
180
  if (!options.dryRun) {
143
181
  await raw.unsafe(`UPDATE "kumiko_events" SET "payload" = $1::jsonb WHERE "id" = $2`, [
144
182
  outcome.payload,
@@ -148,9 +186,15 @@ export async function backfillEventPiiEncryption(
148
186
  result.updatedEvents++;
149
187
  touchedAggregates.add(row.aggregate_id);
150
188
  } catch (e) {
189
+ const reason = e instanceof Error ? e.message : String(e);
151
190
  result.failures.push({
152
191
  eventId: String(row.id),
153
- reason: e instanceof Error ? e.message : String(e),
192
+ reason:
193
+ e instanceof SubjectResolutionError
194
+ ? `${reason} (retry with { resolveOwnerFromProjection: true } to resolve the owner ` +
195
+ "from the entity's projection table, and/or { eraseUnresolvableSubjects: true } to " +
196
+ "erase fields whose subject stays unresolvable)"
197
+ : reason,
154
198
  });
155
199
  }
156
200
  }
@@ -170,10 +214,57 @@ export async function backfillEventPiiEncryption(
170
214
 
171
215
  return result;
172
216
 
217
+ async function loadProjectionOwners(
218
+ rows: readonly EventRow[],
219
+ ): Promise<ReadonlyMap<string, ReadonlyMap<string, Record<string, unknown>>>> {
220
+ const byType = new Map<string, ReadonlyMap<string, Record<string, unknown>>>();
221
+ if (!options.resolveOwnerFromProjection) return byType;
222
+
223
+ const idsByType = new Map<string, Set<string>>();
224
+ for (const row of rows) {
225
+ if (!entityTargets.has(row.aggregate_type)) continue;
226
+ const ids = idsByType.get(row.aggregate_type) ?? new Set<string>();
227
+ ids.add(row.aggregate_id);
228
+ idsByType.set(row.aggregate_type, ids);
229
+ }
230
+
231
+ for (const [aggregateType, ids] of idsByType) {
232
+ const target = entityTargets.get(aggregateType);
233
+ if (!target) continue;
234
+ const tableName = resolveTableName(aggregateType, target.entity, undefined);
235
+ const ownersById = new Map<string, Record<string, unknown>>();
236
+ try {
237
+ const projectionRows = (await raw.unsafe(
238
+ `SELECT * FROM ${quoteIdent(tableName)} WHERE "id" = ANY($1::uuid[])`,
239
+ [[...ids]],
240
+ )) as ReadonlyArray<Record<string, unknown>>;
241
+ for (const projectionRow of projectionRows) {
242
+ const id = projectionRow["id"];
243
+ if (typeof id === "string") {
244
+ ownersById.set(id, projectionRowToCamel(target.entity, projectionRow));
245
+ }
246
+ }
247
+ } catch (e) {
248
+ // Entity never mounted/rebuilt (no projection table yet) — stage 3
249
+ // still gets a chance; this batch just contributes no owners.
250
+ if (!isUndefinedTable(e)) throw e;
251
+ }
252
+ byType.set(aggregateType, ownersById);
253
+ }
254
+ return byType;
255
+ }
256
+
173
257
  async function transformEvent(
174
258
  row: EventRow,
175
- ): Promise<{ payload: Record<string, unknown>; encrypted: number; erased: number } | null> {
176
- const counters = { encrypted: 0, erased: 0 };
259
+ projectionOwners: ReadonlyMap<string, Record<string, unknown>> | undefined,
260
+ ): Promise<{
261
+ payload: Record<string, unknown>;
262
+ encrypted: number;
263
+ erased: number;
264
+ ownerFromProjection: number;
265
+ erasedUnresolvable: number;
266
+ } | null> {
267
+ const counters = { encrypted: 0, erased: 0, ownerFromProjection: 0, erasedUnresolvable: 0 };
177
268
  const payload = structuredClone(row.payload);
178
269
 
179
270
  const catalogFields = eventCatalog.get(row.type);
@@ -187,6 +278,21 @@ export async function backfillEventPiiEncryption(
187
278
  } else {
188
279
  const target = entityTargets.get(row.aggregate_type);
189
280
  if (!target || !isLifecycleEventOf(row.type, row.aggregate_type)) return null;
281
+ await applyEntityLifecycleFields(target, projectionOwners?.get(row.aggregate_id));
282
+ }
283
+
284
+ if (counters.encrypted === 0 && counters.erased === 0) return null;
285
+ return { payload, ...counters };
286
+
287
+ function bump(outcome: FieldOutcome): void {
288
+ if (outcome === "encrypted") counters.encrypted++;
289
+ if (outcome === "erased") counters.erased++;
290
+ }
291
+
292
+ async function applyEntityLifecycleFields(
293
+ target: { readonly entity: EntityDefinition; readonly piiFields: readonly string[] },
294
+ projectionOwnerRow: Record<string, unknown> | undefined,
295
+ ): Promise<void> {
190
296
  const sections = lifecycleSections(payload);
191
297
  for (const section of sections) {
192
298
  // Update-changes may carry a pii field without its owner field —
@@ -198,25 +304,39 @@ export async function backfillEventPiiEncryption(
198
304
  ...section,
199
305
  };
200
306
  for (const field of target.piiFields) {
201
- const subject = resolveSubjectForField(target.entity, field, subjectSource, {
307
+ const value = section[field];
308
+ if (value === null || value === undefined) continue;
309
+ if (typeof value !== "string") continue;
310
+ // Already-ciphertext/sentinel fields must never enter owner
311
+ // resolution — an unresolvable owner on an already-handled field
312
+ // must stay untouched, not get erased by stage 3.
313
+ if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL) continue;
314
+
315
+ const resolution = resolveOwnerSubject(
316
+ target.entity,
317
+ field,
318
+ subjectSource,
319
+ projectionOwnerRow,
202
320
  // @cast-boundary db-read — tenant_id column is the branded TenantId
203
- tenantId: row.tenant_id as TenantId,
204
- });
205
- if (subject === null) continue;
206
- const outcome = await encryptField(section, field, subject);
321
+ row.tenant_id as TenantId,
322
+ );
323
+ if (resolution.kind === "unannotated") continue;
324
+ if (resolution.kind === "unresolved") {
325
+ if (options.eraseUnresolvableSubjects) {
326
+ section[field] = PII_ERASED_SENTINEL;
327
+ counters.erased++;
328
+ counters.erasedUnresolvable++;
329
+ continue;
330
+ }
331
+ throw resolution.error;
332
+ }
333
+ if (resolution.viaProjection) counters.ownerFromProjection++;
334
+ const outcome = await encryptField(section, field, resolution.subject);
207
335
  bump(outcome);
208
336
  }
209
337
  }
210
338
  }
211
339
 
212
- if (counters.encrypted === 0 && counters.erased === 0) return null;
213
- return { payload, ...counters };
214
-
215
- function bump(outcome: FieldOutcome): void {
216
- if (outcome === "encrypted") counters.encrypted++;
217
- if (outcome === "erased") counters.erased++;
218
- }
219
-
220
340
  async function encryptField(
221
341
  section: Record<string, unknown>,
222
342
  field: string,
@@ -255,6 +375,56 @@ export async function backfillEventPiiEncryption(
255
375
  }
256
376
  }
257
377
 
378
+ type OwnerSubjectResolution =
379
+ | { readonly kind: "unannotated" }
380
+ | { readonly kind: "unresolved"; readonly error: SubjectResolutionError }
381
+ | { readonly kind: "resolved"; readonly subject: SubjectId; readonly viaProjection: boolean };
382
+
383
+ // Stage 1 (payload) then stage 2 (projection row, when supplied) — stage 3
384
+ // (erase) is the caller's call, made from the "unresolved" outcome.
385
+ function resolveOwnerSubject(
386
+ entity: EntityDefinition,
387
+ field: string,
388
+ subjectSource: Record<string, unknown>,
389
+ projectionOwnerRow: Record<string, unknown> | undefined,
390
+ tenantId: TenantId,
391
+ ): OwnerSubjectResolution {
392
+ try {
393
+ const subject = resolveSubjectForField(entity, field, subjectSource, { tenantId });
394
+ return subject === null
395
+ ? { kind: "unannotated" }
396
+ : { kind: "resolved", subject, viaProjection: false };
397
+ } catch (e) {
398
+ if (!(e instanceof SubjectResolutionError)) throw e;
399
+ if (projectionOwnerRow) {
400
+ try {
401
+ const augmented = { ...subjectSource, ...projectionOwnerRow };
402
+ const subject = resolveSubjectForField(entity, field, augmented, { tenantId });
403
+ if (subject !== null) return { kind: "resolved", subject, viaProjection: true };
404
+ } catch (e2) {
405
+ if (!(e2 instanceof SubjectResolutionError)) throw e2;
406
+ }
407
+ }
408
+ return { kind: "unresolved", error: e };
409
+ }
410
+ }
411
+
412
+ // Projection columns are snake_case (author_id); event-payload/subject
413
+ // fields are camelCase (authorId) — forward-map from entity.fields like
414
+ // reindexEntity's rowToState, so any owner field resolves generically
415
+ // instead of hand-listing column names per entity.
416
+ function projectionRowToCamel(
417
+ entity: EntityDefinition,
418
+ row: Record<string, unknown>,
419
+ ): Record<string, unknown> {
420
+ const camel: Record<string, unknown> = {};
421
+ for (const fieldName of Object.keys(entity.fields)) {
422
+ const snake = toSnakeCase(fieldName);
423
+ if (Object.hasOwn(row, snake)) camel[fieldName] = row[snake];
424
+ }
425
+ return camel;
426
+ }
427
+
258
428
  function isLifecycleEventOf(eventType: string, aggregateType: string): boolean {
259
429
  if (!eventType.startsWith(`${aggregateType}.`)) return false;
260
430
  const verb = eventType.slice(aggregateType.length + 1);
@@ -25,7 +25,12 @@ const requiresUserDataHook = (features: readonly { readonly name: string }[]) =>
25
25
 
26
26
  const promptStore = () =>
27
27
  defineFeature("prompt-store", (r) => {
28
- const promptFields = { text: createTextField({ pii: true }) };
28
+ const promptFields = {
29
+ text: createTextField({
30
+ personal: "self",
31
+ find: "none",
32
+ }),
33
+ };
29
34
  r.entity("prompt", createEntity({ fields: promptFields }));
30
35
  r.bootCheck(({ features }) => {
31
36
  // Conditional on this feature's own shape (has a pii field), closed