@cosmicdrift/kumiko-framework 0.252.1 → 0.254.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.252.1",
3
+ "version": "0.254.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>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.252.1",
201
+ "@cosmicdrift/kumiko-types": "0.254.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.252.1",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.254.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -101,6 +101,7 @@ describe("computeBlindIndexValues", () => {
101
101
  ["email"],
102
102
  kms,
103
103
  { requestId: "test" },
104
+ { entityName: "bidx-user" },
104
105
  );
105
106
  const out = await computeBlindIndexValues({ email: stored["email"] }, ["email"]);
106
107
  expect(out["emailBidx"]).toBe(computeBlindIndex(TEST_KEY, "marc@example.com"));
@@ -116,6 +117,7 @@ describe("computeBlindIndexValues", () => {
116
117
  ["email"],
117
118
  kms,
118
119
  { requestId: "test" },
120
+ { entityName: "bidx-user" },
119
121
  );
120
122
  await kms.eraseKey({ kind: "user", userId: UUID_A });
121
123
  const out = await computeBlindIndexValues({ email: stored["email"] }, ["email"]);
@@ -23,6 +23,7 @@ import { collectPiiSubjectFields } from "../subject-resolver";
23
23
  const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
24
24
  const UUID_B = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000b";
25
25
  const KMS_CTX: KmsContext = { requestId: "test" };
26
+ const ENTITY_NAME = "pii-entity";
26
27
 
27
28
  const userLikeEntity = createEntity({
28
29
  fields: {
@@ -67,7 +68,9 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
67
68
  const fields = collectPiiSubjectFields(userLikeEntity);
68
69
  const row = { id: UUID_A, email: "marc@example.com", role: "admin" };
69
70
 
70
- const stored = await encryptPiiFieldValues(row, userLikeEntity, fields, kms, KMS_CTX);
71
+ const stored = await encryptPiiFieldValues(row, userLikeEntity, fields, kms, KMS_CTX, {
72
+ entityName: ENTITY_NAME,
73
+ });
71
74
  expect(isPiiCiphertext(stored["email"])).toBe(true);
72
75
  expect(String(stored["email"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${UUID_A}:`);
73
76
  expect(stored["role"]).toBe("admin");
@@ -88,6 +91,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
88
91
  ["email"],
89
92
  kms,
90
93
  KMS_CTX,
94
+ { entityName: ENTITY_NAME },
91
95
  );
92
96
  const dek = await kms.getKey({ kind: "user", userId: UUID_A });
93
97
  expect(dek.length).toBe(32);
@@ -101,6 +105,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
101
105
  ["body"],
102
106
  kms,
103
107
  KMS_CTX,
108
+ { entityName: ENTITY_NAME },
104
109
  );
105
110
  expect(String(stored["body"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${UUID_B}:`);
106
111
  });
@@ -113,7 +118,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
113
118
  ["brandColor"],
114
119
  kms,
115
120
  KMS_CTX,
116
- { tenantId: UUID_B },
121
+ { tenantId: UUID_B, entityName: ENTITY_NAME },
117
122
  );
118
123
  expect(String(stored["brandColor"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}tenant:${UUID_B}:`);
119
124
  });
@@ -124,6 +129,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
124
129
  const stored = await encryptPiiFieldValues(changes, commentEntity, ["body"], kms, KMS_CTX, {
125
130
  onlyKeys: ["body"],
126
131
  subjectSource: { id: UUID_A, body: "edited", authorId: UUID_B },
132
+ entityName: ENTITY_NAME,
127
133
  });
128
134
  expect(String(stored["body"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${UUID_B}:`);
129
135
  });
@@ -137,13 +143,16 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
137
143
  fields,
138
144
  kms,
139
145
  KMS_CTX,
146
+ { entityName: ENTITY_NAME },
140
147
  );
141
148
  await kms.eraseKey({ kind: "user", userId: UUID_A });
142
149
 
143
150
  const read = await decryptPiiFieldValues(stored, fields, kms, KMS_CTX);
144
151
  expect(read["email"]).toBe(PII_ERASED_SENTINEL);
145
152
 
146
- const reStored = await encryptPiiFieldValues(read, userLikeEntity, fields, kms, KMS_CTX);
153
+ const reStored = await encryptPiiFieldValues(read, userLikeEntity, fields, kms, KMS_CTX, {
154
+ entityName: ENTITY_NAME,
155
+ });
147
156
  expect(reStored["email"]).toBe(PII_ERASED_SENTINEL);
148
157
  });
149
158
 
@@ -158,6 +167,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
158
167
  ["email"],
159
168
  kms,
160
169
  KMS_CTX,
170
+ { entityName: ENTITY_NAME },
161
171
  ),
162
172
  ).rejects.toThrow(/erased/);
163
173
  });
@@ -181,8 +191,11 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
181
191
  ["email"],
182
192
  kms,
183
193
  KMS_CTX,
194
+ { entityName: ENTITY_NAME },
184
195
  );
185
- const twice = await encryptPiiFieldValues(once, userLikeEntity, ["email"], kms, KMS_CTX);
196
+ const twice = await encryptPiiFieldValues(once, userLikeEntity, ["email"], kms, KMS_CTX, {
197
+ entityName: ENTITY_NAME,
198
+ });
186
199
  expect(twice["email"]).toBe(once["email"]);
187
200
  });
188
201
 
@@ -194,6 +207,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
194
207
  ["email"],
195
208
  kms,
196
209
  KMS_CTX,
210
+ { entityName: ENTITY_NAME },
197
211
  );
198
212
  expect(stored["email"]).toBeNull();
199
213
 
@@ -203,7 +217,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
203
217
  ["email"],
204
218
  kms,
205
219
  KMS_CTX,
206
- { onlyKeys: ["role"] },
220
+ { onlyKeys: ["role"], entityName: ENTITY_NAME },
207
221
  );
208
222
  expect(skipped["email"]).toBe("a@b.c");
209
223
  });
@@ -211,7 +225,9 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
211
225
  test("non-string pii value throws", async () => {
212
226
  const kms = new InMemoryKmsAdapter();
213
227
  await expect(
214
- encryptPiiFieldValues({ id: UUID_A, email: 42 }, userLikeEntity, ["email"], kms, KMS_CTX),
228
+ encryptPiiFieldValues({ id: UUID_A, email: 42 }, userLikeEntity, ["email"], kms, KMS_CTX, {
229
+ entityName: ENTITY_NAME,
230
+ }),
215
231
  ).rejects.toThrow(/must be a string/);
216
232
  });
217
233
 
@@ -223,6 +239,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
223
239
  ["email"],
224
240
  kmsA,
225
241
  KMS_CTX,
242
+ { entityName: ENTITY_NAME },
226
243
  );
227
244
  const kmsB = new InMemoryKmsAdapter();
228
245
  await expect(decryptPiiFieldValues(stored, ["email"], kmsB, KMS_CTX)).rejects.toBeInstanceOf(
@@ -248,7 +265,9 @@ describe("tenant-owned field encryption (kumiko-platform#457)", () => {
248
265
  expect(fields).toEqual(["iban"]);
249
266
 
250
267
  const row = { id: UUID_A, tenantId: UUID_B, iban: "DE89370400440532013000" };
251
- const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX);
268
+ const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX, {
269
+ entityName: ENTITY_NAME,
270
+ });
252
271
  expect(isPiiCiphertext(stored["iban"])).toBe(true);
253
272
  expect(String(stored["iban"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}tenant:${UUID_B}:`);
254
273
 
@@ -266,7 +285,9 @@ describe("tenant-owned field encryption (kumiko-platform#457)", () => {
266
285
  });
267
286
  const fields = collectPiiSubjectFields(brandingWithAccess);
268
287
  const row = { id: UUID_A, tenantId: UUID_B, iban: "DE89370400440532013000" };
269
- const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX);
288
+ const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX, {
289
+ entityName: ENTITY_NAME,
290
+ });
270
291
 
271
292
  // Erasure is subject-keyed, not field-flag-keyed — kms.eraseKey doesn't
272
293
  // know or care about the field's `personal` annotation, just the subject.
@@ -76,9 +76,9 @@ describe("record subject key round-trip", () => {
76
76
 
77
77
  describe("resolveSubjectForField: recordOwned fail-closed", () => {
78
78
  test("recordOwned field without opts.entityName throws", () => {
79
- expect(() => resolveSubjectForField(recordEntity, "body", { id: UUID_A })).toThrow(
80
- SubjectResolutionError,
81
- );
79
+ expect(() =>
80
+ resolveSubjectForField(recordEntity, "body", { id: UUID_A }, { entityName: "" }),
81
+ ).toThrow(SubjectResolutionError);
82
82
  });
83
83
 
84
84
  test("recordOwned field with row missing id throws", () => {
@@ -38,31 +38,50 @@ const brandingEntity = createEntity({
38
38
 
39
39
  const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
40
40
  const UUID_B = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000b";
41
+ const ENTITY_NAME = "resolver-entity";
41
42
 
42
43
  describe("resolveSubjectForField", () => {
43
44
  test("pii: true → the entity row itself is the user subject", () => {
44
- const subject = resolveSubjectForField(userLikeEntity, "email", { id: UUID_A });
45
+ const subject = resolveSubjectForField(
46
+ userLikeEntity,
47
+ "email",
48
+ { id: UUID_A },
49
+ {
50
+ entityName: ENTITY_NAME,
51
+ },
52
+ );
45
53
  expect(subject).toEqual({ kind: "user", userId: UUID_A });
46
54
  });
47
55
 
48
56
  test("pii self-subject stringifies serial ids", () => {
49
- const subject = resolveSubjectForField(userLikeEntity, "email", { id: 42 });
57
+ const subject = resolveSubjectForField(
58
+ userLikeEntity,
59
+ "email",
60
+ { id: 42 },
61
+ {
62
+ entityName: ENTITY_NAME,
63
+ },
64
+ );
50
65
  expect(subject).toEqual({ kind: "user", userId: "42" });
51
66
  });
52
67
 
53
68
  test("userOwned → subject comes from the owner reference field", () => {
54
- const subject = resolveSubjectForField(commentEntity, "body", {
55
- id: UUID_A,
56
- authorId: UUID_B,
57
- });
69
+ const subject = resolveSubjectForField(
70
+ commentEntity,
71
+ "body",
72
+ { id: UUID_A, authorId: UUID_B },
73
+ { entityName: ENTITY_NAME },
74
+ );
58
75
  expect(subject).toEqual({ kind: "user", userId: UUID_B });
59
76
  });
60
77
 
61
78
  test("tenantOwned → subject from the row's tenantId column", () => {
62
- const subject = resolveSubjectForField(brandingEntity, "brandColor", {
63
- id: UUID_A,
64
- tenantId: UUID_B,
65
- });
79
+ const subject = resolveSubjectForField(
80
+ brandingEntity,
81
+ "brandColor",
82
+ { id: UUID_A, tenantId: UUID_B },
83
+ { entityName: ENTITY_NAME },
84
+ );
66
85
  expect(subject).toEqual({ kind: "tenant", tenantId: UUID_B });
67
86
  });
68
87
 
@@ -71,37 +90,44 @@ describe("resolveSubjectForField", () => {
71
90
  brandingEntity,
72
91
  "brandColor",
73
92
  { id: UUID_A },
74
- { tenantId: UUID_B },
93
+ { tenantId: UUID_B, entityName: ENTITY_NAME },
75
94
  );
76
95
  expect(subject).toEqual({ kind: "tenant", tenantId: UUID_B });
77
96
  });
78
97
 
79
98
  test("unannotated field → null (stays plaintext)", () => {
80
- expect(resolveSubjectForField(userLikeEntity, "role", { id: UUID_A })).toBeNull();
99
+ expect(
100
+ resolveSubjectForField(userLikeEntity, "role", { id: UUID_A }, { entityName: ENTITY_NAME }),
101
+ ).toBeNull();
81
102
  });
82
103
 
83
104
  test("empty owner reference throws instead of silently falling back to plaintext", () => {
84
- expect(() => resolveSubjectForField(commentEntity, "body", { id: UUID_A })).toThrow(
85
- SubjectResolutionError,
86
- );
105
+ expect(() =>
106
+ resolveSubjectForField(commentEntity, "body", { id: UUID_A }, { entityName: ENTITY_NAME }),
107
+ ).toThrow(SubjectResolutionError);
87
108
  });
88
109
 
89
110
  test("tenantOwned without any tenant scope throws", () => {
90
- expect(() => resolveSubjectForField(brandingEntity, "brandColor", { id: UUID_A })).toThrow(
91
- SubjectResolutionError,
92
- );
111
+ expect(() =>
112
+ resolveSubjectForField(
113
+ brandingEntity,
114
+ "brandColor",
115
+ { id: UUID_A },
116
+ { entityName: ENTITY_NAME },
117
+ ),
118
+ ).toThrow(SubjectResolutionError);
93
119
  });
94
120
 
95
121
  test("pii row without id throws", () => {
96
- expect(() => resolveSubjectForField(userLikeEntity, "email", {})).toThrow(
97
- SubjectResolutionError,
98
- );
122
+ expect(() =>
123
+ resolveSubjectForField(userLikeEntity, "email", {}, { entityName: ENTITY_NAME }),
124
+ ).toThrow(SubjectResolutionError);
99
125
  });
100
126
 
101
127
  test("unknown field name throws", () => {
102
- expect(() => resolveSubjectForField(userLikeEntity, "nope", { id: UUID_A })).toThrow(
103
- SubjectResolutionError,
104
- );
128
+ expect(() =>
129
+ resolveSubjectForField(userLikeEntity, "nope", { id: UUID_A }, { entityName: ENTITY_NAME }),
130
+ ).toThrow(SubjectResolutionError);
105
131
  });
106
132
  });
107
133
 
@@ -160,7 +160,7 @@ export interface EncryptPiiOptions {
160
160
  // carry a pii field without its ownerField — the merged row still has it).
161
161
  readonly subjectSource?: Record<string, unknown>;
162
162
  // Canonical registry entity name — required to resolve recordOwned fields.
163
- readonly entityName?: string;
163
+ readonly entityName: string;
164
164
  }
165
165
 
166
166
  export async function encryptPiiFieldValues(
@@ -169,7 +169,7 @@ export async function encryptPiiFieldValues(
169
169
  piiFields: readonly string[],
170
170
  kms: LocalKeyKmsAdapter,
171
171
  kmsCtx: KmsContext,
172
- opts: EncryptPiiOptions = {},
172
+ opts: EncryptPiiOptions,
173
173
  ): Promise<Record<string, unknown>> {
174
174
  if (piiFields.length === 0) return row;
175
175
  const only = opts.onlyKeys ? new Set(opts.onlyKeys) : null;
@@ -187,8 +187,8 @@ export async function encryptPiiFieldValues(
187
187
  throw new Error(`PII field "${name}" must be a string, got ${typeof value}`);
188
188
  }
189
189
  const subject = resolveSubjectForField(entity, name, subjectSource, {
190
+ entityName: opts.entityName,
190
191
  ...(opts.tenantId !== undefined && { tenantId: opts.tenantId }),
191
- ...(opts.entityName !== undefined && { entityName: opts.entityName }),
192
192
  });
193
193
  // skip: collectPiiSubjectFields only yields annotated fields — null is unreachable, kept as a type guard
194
194
  if (subject === null) continue;
@@ -21,7 +21,7 @@ export interface ResolveSubjectOptions {
21
21
  readonly tenantId?: TenantId;
22
22
  // Canonical source is the registry entity name (executor `entityName` /
23
23
  // event `aggregate_type`) — required to resolve recordOwned fields.
24
- readonly entityName?: string;
24
+ readonly entityName: string;
25
25
  }
26
26
 
27
27
  function nonEmptyString(value: unknown): string | null {
@@ -101,7 +101,7 @@ export function resolveSubjectForField(
101
101
  entity: EntityDefinition,
102
102
  fieldName: string,
103
103
  row: Record<string, unknown>,
104
- opts: ResolveSubjectOptions = {},
104
+ opts: ResolveSubjectOptions,
105
105
  ): SubjectId | null {
106
106
  const field = entity.fields[fieldName];
107
107
  if (!field) throw new SubjectResolutionError(fieldName, "field is not defined on the entity");
@@ -147,9 +147,14 @@ beforeAll(async () => {
147
147
  email: "grace@acme.test",
148
148
  iban: "DE12345",
149
149
  };
150
- const piiEncrypted = await encryptPiiFieldValues(plainContact, contactEntity, ["email"], kms, {
151
- requestId: "test",
152
- });
150
+ const piiEncrypted = await encryptPiiFieldValues(
151
+ plainContact,
152
+ contactEntity,
153
+ ["email"],
154
+ kms,
155
+ { requestId: "test" },
156
+ { entityName: "contact" },
157
+ );
153
158
  const encrypted = await encryptEntityFieldValues(
154
159
  piiEncrypted,
155
160
  collectEncryptedFieldNames(contactEntity),
@@ -170,6 +175,7 @@ beforeAll(async () => {
170
175
  ["email"],
171
176
  kms,
172
177
  { requestId: "test" },
178
+ { entityName: "owned-contact" },
173
179
  );
174
180
  const ownedEncrypted = await encryptEntityFieldValues(
175
181
  ownedPiiEncrypted,
@@ -6,8 +6,12 @@
6
6
  // them in place, per field, under the owning subject's DEK:
7
7
  //
8
8
  // - entity lifecycle events (<entity>.created/updated/deleted/forgotten/
9
- // restored) for every entity with PII subject annotations
10
- // - custom events from the event-PII catalog (r.defineEvent piiFields)
9
+ // restored) for every entity with PII subject annotations — generic over
10
+ // subject kind (user/tenant/record, fw#2790), since it goes through the
11
+ // same collectPiiSubjectFields/resolveSubjectForField the live write
12
+ // path uses
13
+ // - custom events from the event-PII catalog (r.defineEvent piiFields) —
14
+ // user-subject only, see the note at its call site (fw#2801)
11
15
  //
12
16
  // Already-forgotten subjects must NOT get a fresh key minted for their old
13
17
  // plaintext — three erased-detection layers write [[erased]] instead:
@@ -282,6 +286,8 @@ export async function backfillEventPiiEncryption(
282
286
  for (const [field, spec] of Object.entries(catalogFields)) {
283
287
  const subjectId = payload[spec.subjectField];
284
288
  if (typeof subjectId !== "string" || subjectId.length === 0) continue;
289
+ // Catalog entries only ever resolve a user subject — a tenant/record
290
+ // subject in a custom event is not backfillable today (fw#2801).
285
291
  const outcome = await encryptField(payload, field, { kind: "user", userId: subjectId });
286
292
  bump(outcome);
287
293
  }
@@ -0,0 +1,56 @@
1
+ // fw#2809 — a recordOwned field is encrypted under `record:<entity>:<id>`
2
+ // (kms-adapter-types.ts) and forgetSubject's subjectIdSchema validates that
3
+ // id as a UUID (crypto-shredding/handlers/forget-subject.write.ts). An
4
+ // idType: "serial" entity would encrypt such a field but could never satisfy
5
+ // a forget-subject request for it — the boot guard catches that combination
6
+ // before it ships.
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { defineFeature } from "../../define-feature";
10
+ import { createEntity, createTextField } from "../../factories";
11
+ import type { FeatureDefinition } from "../../types";
12
+ import { validateRecordOwnedSubjects } from "../record-owned";
13
+
14
+ function featureWith(
15
+ idType: "serial" | "uuid" | undefined,
16
+ withRecordOwnedField: boolean,
17
+ ): FeatureDefinition {
18
+ return defineFeature("notes", (r) => {
19
+ r.entity(
20
+ "note",
21
+ createEntity({
22
+ table: "fw2809_guard_notes",
23
+ idType,
24
+ fields: {
25
+ body: withRecordOwnedField
26
+ ? createTextField({ personal: { of: "id" }, find: "none" })
27
+ : createTextField(),
28
+ },
29
+ }),
30
+ );
31
+ });
32
+ }
33
+
34
+ describe("validateRecordOwnedSubjects", () => {
35
+ test("idType: 'serial' + recordOwned field fails the boot, naming entity and field", () => {
36
+ const feature = featureWith("serial", true);
37
+ expect(() => validateRecordOwnedSubjects(feature)).toThrow(/Feature notes/);
38
+ expect(() => validateRecordOwnedSubjects(feature)).toThrow(/"note"/);
39
+ expect(() => validateRecordOwnedSubjects(feature)).toThrow(/"body"/);
40
+ });
41
+
42
+ test("idType: 'uuid' + recordOwned field boots fine", () => {
43
+ const feature = featureWith("uuid", true);
44
+ expect(() => validateRecordOwnedSubjects(feature)).not.toThrow();
45
+ });
46
+
47
+ test("idType omitted (default uuid) + recordOwned field boots fine", () => {
48
+ const feature = featureWith(undefined, true);
49
+ expect(() => validateRecordOwnedSubjects(feature)).not.toThrow();
50
+ });
51
+
52
+ test("idType: 'serial' without any recordOwned field boots fine", () => {
53
+ const feature = featureWith("serial", false);
54
+ expect(() => validateRecordOwnedSubjects(feature)).not.toThrow();
55
+ });
56
+ });
@@ -52,6 +52,7 @@ import {
52
52
  } from "./projection-list-screens";
53
53
  import { validateQueryOutputColumns } from "./query-output-columns";
54
54
  import { validateQueryRefs } from "./query-refs";
55
+ import { validateRecordOwnedSubjects } from "./record-owned";
55
56
  import {
56
57
  collectScreenQns,
57
58
  collectScreensByShortId,
@@ -174,6 +175,7 @@ export function validateBoot(
174
175
  if (validateEncryptedFields(feature)) hasEncryptedFields = true;
175
176
  if (validateFileFields(feature)) hasFileFields = true;
176
177
  validatePiiAndRetention(feature);
178
+ validateRecordOwnedSubjects(feature);
177
179
  validateApiExposureMatching(feature, allExposedApis, featureMap);
178
180
  validateEmbeddedFields(feature, featureMap);
179
181
  validateMultiSelectFields(feature);
@@ -0,0 +1,22 @@
1
+ import type { FeatureDefinition } from "../types";
2
+ import type { ResolvedPiiFlags } from "../types/fields";
3
+
4
+ // A recordOwned field is encrypted under `record:<entity>:<id>` (kms-adapter-types.ts)
5
+ // and forgetSubject's subjectIdSchema requires that id to be a UUID
6
+ // (crypto-shredding/handlers/forget-subject.write.ts) — an entity with
7
+ // idType: "serial" would encrypt the field but could never satisfy a
8
+ // forget-subject request for it.
9
+ export function validateRecordOwnedSubjects(feature: FeatureDefinition): void {
10
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
11
+ if (entity.idType !== "serial") continue;
12
+
13
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
14
+ const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk
15
+ if (annot.recordOwned !== true) continue;
16
+
17
+ throw new Error(
18
+ `[Feature ${feature.name}] entity "${entityName}" field "${fieldName}" is recordOwned but the entity declares idType: "serial" — the record subject key is "record:${entityName}:<id>" and forgetSubject validates that id as a UUID, so this field would be encrypted but could never be shredded. Set idType: "uuid" on entity "${entityName}", or give field "${fieldName}" a different personal stance (personal: "self" / "tenant" / { of: "<field>" }).`,
19
+ );
20
+ }
21
+ }
22
+ }
@@ -20,6 +20,7 @@ import {
20
20
  isPiiCiphertext,
21
21
  KeyNotFoundError,
22
22
  PgKmsAdapter,
23
+ PII_CIPHERTEXT_PREFIX,
23
24
  PII_ERASED_SENTINEL,
24
25
  } from "../../crypto";
25
26
  import { applyEntityEvent } from "../../db/apply-entity-event";
@@ -72,6 +73,20 @@ const notesFeature = defineFeature("notes", (r) => {
72
73
  r.entity("note", noteEntity);
73
74
  });
74
75
 
76
+ // personal: { of: "id" } → recordOwned (kumiko-framework#2786): the row
77
+ // itself is the subject, not a user/tenant reachable via an owner field.
78
+ // fw#2790 — this fixture proves the pre-existing generic backfill already
79
+ // covers the record subject kind for lifecycle events, no new code needed.
80
+ const surveyEntity = createEntity({
81
+ fields: {
82
+ answer: createTextField({ personal: { of: "id" }, find: "none" }),
83
+ },
84
+ });
85
+ const surveyTable = buildEntityTable("survey", surveyEntity);
86
+ const surveyFeature = defineFeature("survey", (r) => {
87
+ r.entity("survey", surveyEntity);
88
+ });
89
+
75
90
  let testDb: TestDb;
76
91
  let registry: Registry;
77
92
  let kms: InMemoryKmsAdapter;
@@ -96,10 +111,11 @@ async function appendPlain(
96
111
 
97
112
  beforeAll(async () => {
98
113
  testDb = await createTestDb();
99
- registry = createRegistry([crmFeature, mailerFeature, notesFeature]);
114
+ registry = createRegistry([crmFeature, mailerFeature, notesFeature, surveyFeature]);
100
115
  await createEventsTable(testDb.db);
101
116
  await unsafeCreateEntityTable(testDb.db, contactEntity, "contact");
102
117
  await unsafeCreateEntityTable(testDb.db, noteEntity, "note");
118
+ await unsafeCreateEntityTable(testDb.db, surveyEntity, "survey");
103
119
  });
104
120
 
105
121
  afterAll(async () => {
@@ -111,6 +127,7 @@ beforeEach(async () => {
111
127
  await raw.unsafe(`TRUNCATE "kumiko_events" RESTART IDENTITY`);
112
128
  await raw.unsafe(`TRUNCATE "${contactTable.tableName}"`);
113
129
  await raw.unsafe(`TRUNCATE "${noteTable.tableName}"`);
130
+ await raw.unsafe(`TRUNCATE "${surveyTable.tableName}"`);
114
131
  // Plaintext era: NO KMS while the legacy events are appended.
115
132
  resetPiiSubjectKmsForTests();
116
133
  resetBlindIndexKeyForTests();
@@ -590,6 +607,104 @@ describe("backfillEventPiiEncryption: owner-resolution chain (fw#2266)", () => {
590
607
  });
591
608
  });
592
609
 
610
+ // fw#2790 — "Altbestand: Freitext-Klartext in kumiko_events". #2596 added
611
+ // the recordOwned ("record") subject kind for row-scoped free text with no
612
+ // user/tenant owner. Nothing in this file's resolution path (subject-
613
+ // resolver.ts / pii-field-encryption.ts) special-cases subject kind, so the
614
+ // expectation is that this backfill already covers it for lifecycle events
615
+ // with zero new code — these tests pin that down rather than assume it.
616
+ describe("backfillEventPiiEncryption: record subject (kumiko-framework#2786)", () => {
617
+ test("encrypts a lifecycle payload under record:<entity>:<id>, not user:<id>", async () => {
618
+ const s1 = generateId();
619
+ await appendPlain(s1, "survey", "survey.created", { id: s1, answer: "plaintext answer" });
620
+
621
+ armKms();
622
+ const result = await backfillEventPiiEncryption(testDb.db, registry);
623
+
624
+ expect(result.failures).toEqual([]);
625
+ expect(result.updatedEvents).toBe(1);
626
+ expect(result.encryptedFields).toBe(1);
627
+ // Neither owner-resolution fallback is needed: the subject is the row's
628
+ // own id, always present on the event — stage 2/3 stay unused.
629
+ expect(result.ownerFromProjection).toBe(0);
630
+ expect(result.erasedUnresolvable).toBe(0);
631
+
632
+ const events = await loadAggregate(testDb.db, s1, TENANT);
633
+ const created = events[0]?.payload as Record<string, unknown>;
634
+ expect(isPiiCiphertext(created["answer"])).toBe(true);
635
+ // Exact prefix, not just "contains": the entity segment must be the
636
+ // registry name forget-subject resolves ("survey"), not the table name
637
+ // or a divergent aggregate_type — a wrong segment here would make the
638
+ // ciphertext unreachable by record-forget (fw#2790).
639
+ expect(String(created["answer"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}record:survey:${s1}:`);
640
+ expect(String(created["answer"])).not.toContain(`user:${s1}`);
641
+ });
642
+
643
+ test("encrypts changes+previous on an update the same way", async () => {
644
+ const s2 = generateId();
645
+ await appendPlain(s2, "survey", "survey.created", { id: s2, answer: "first" });
646
+ await appendPlain(
647
+ s2,
648
+ "survey",
649
+ "survey.updated",
650
+ { changes: { answer: "second" }, previous: { id: s2, answer: "first" } },
651
+ 1,
652
+ );
653
+
654
+ armKms();
655
+ const result = await backfillEventPiiEncryption(testDb.db, registry);
656
+ expect(result.failures).toEqual([]);
657
+ expect(result.updatedEvents).toBe(2);
658
+
659
+ const events = await loadAggregate(testDb.db, s2, TENANT);
660
+ const updated = events[1]?.payload as {
661
+ changes: Record<string, unknown>;
662
+ previous: Record<string, unknown>;
663
+ };
664
+ expect(String(updated.changes["answer"])).toStartWith(
665
+ `${PII_CIPHERTEXT_PREFIX}record:survey:${s2}:`,
666
+ );
667
+ expect(String(updated.previous["answer"])).toStartWith(
668
+ `${PII_CIPHERTEXT_PREFIX}record:survey:${s2}:`,
669
+ );
670
+ });
671
+
672
+ test("a pre-KMS-forgotten row gets [[erased]], not a fresh record key", async () => {
673
+ const s3 = generateId();
674
+ await appendPlain(s3, "survey", "survey.created", { id: s3, answer: "secret" });
675
+ await appendPlain(
676
+ s3,
677
+ "survey",
678
+ "survey.forgotten",
679
+ { previous: { id: s3, answer: "secret" } },
680
+ 1,
681
+ );
682
+
683
+ armKms();
684
+ const result = await backfillEventPiiEncryption(testDb.db, registry);
685
+
686
+ expect(result.failures).toEqual([]);
687
+ expect(result.erasedFields).toBe(2);
688
+ const events = await loadAggregate(testDb.db, s3, TENANT);
689
+ const created = events[0]?.payload as Record<string, unknown>;
690
+ expect(created["answer"]).toBe(PII_ERASED_SENTINEL);
691
+ const forgotten = events[1]?.payload as { previous: Record<string, unknown> };
692
+ expect(forgotten.previous["answer"]).toBe(PII_ERASED_SENTINEL);
693
+ });
694
+
695
+ test("idempotent: a second run touches nothing", async () => {
696
+ const s4 = generateId();
697
+ await appendPlain(s4, "survey", "survey.created", { id: s4, answer: "once" });
698
+
699
+ armKms();
700
+ const first = await backfillEventPiiEncryption(testDb.db, registry);
701
+ expect(first.updatedEvents).toBe(1);
702
+ const second = await backfillEventPiiEncryption(testDb.db, registry);
703
+ expect(second.updatedEvents).toBe(0);
704
+ expect(second.failures).toEqual([]);
705
+ });
706
+ });
707
+
593
708
  describe("backfillEventPiiEncryption: raw jsonb column type (fw#2253)", () => {
594
709
  // The UPDATE used to wrap outcome.payload in JSON.stringify before handing
595
710
  // it to the ::jsonb cast — Bun.SQL already serializes objects, so the
@@ -13,7 +13,10 @@
13
13
  // are the ceiling.
14
14
  //
15
15
  // Runs isolated in the `event-store-perf` CI job (test:integration:perf:eventstore,
16
- // #1940) — gate on p95 for typical latency; p99 keeps a separate tail budget for checkpoint/fsync spikes.
16
+ // #1940) — gate on p95 for typical latency; p99 keeps a separate tail budget for
17
+ // residual scheduling noise. CI's Postgres container runs with fsync/synchronous_commit/
18
+ // full_page_writes off (#2798) — it exists only to be measured and thrown away, so these
19
+ // numbers aren't comparable to a default-config local container.
17
20
 
18
21
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
19
22
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
@@ -98,12 +101,14 @@ describe("event-store performance — Gate A", () => {
98
101
  const p50 = percentile(samples, 0.5);
99
102
  const p95 = percentile(samples, 0.95);
100
103
  const p99 = percentile(samples, 0.99);
104
+ const over30 = samples.filter((s) => s >= 30).length;
105
+ const top5 = samples.slice(-5).map((s) => s.toFixed(1));
101
106
  console.log(
102
- ` Write-latency: p50=${p50.toFixed(2)}ms, p95=${p95.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=200)`,
107
+ ` Write-latency: p50=${p50.toFixed(2)}ms, p95=${p95.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=200) over30=${over30} top5=${top5}`,
103
108
  );
104
109
 
105
110
  expect(p95).toBeLessThan(30);
106
- // Tail budget: absorbs cold-checkpoint/fsync spikes (observed up to 102ms at p50=1.2ms) while still catching order-of-magnitude regressions.
111
+ // Tail budget: absorbs residual runner scheduling noise while still catching order-of-magnitude regressions.
107
112
  expect(p99).toBeLessThan(250);
108
113
  });
109
114