@cosmicdrift/kumiko-framework 0.253.0 → 0.255.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 (29) hide show
  1. package/package.json +3 -3
  2. package/src/bun-db/__tests__/run-in-savepoint-if-supported.integration.test.ts +47 -0
  3. package/src/bun-db/query.ts +16 -10
  4. package/src/crypto/__tests__/blind-index.test.ts +2 -0
  5. package/src/crypto/__tests__/event-pii.test.ts +144 -11
  6. package/src/crypto/__tests__/pii-field-encryption.test.ts +29 -8
  7. package/src/crypto/__tests__/subject-key-record.test.ts +13 -3
  8. package/src/crypto/__tests__/subject-resolver.test.ts +139 -24
  9. package/src/crypto/event-pii.ts +16 -9
  10. package/src/crypto/index.ts +2 -0
  11. package/src/crypto/pii-field-encryption.ts +3 -3
  12. package/src/crypto/subject-resolver.ts +64 -3
  13. package/src/db/__tests__/eagerload.integration.test.ts +9 -3
  14. package/src/db/queries/backfill-pii.ts +10 -6
  15. package/src/engine/__tests__/projection-detail-tabs.test.ts +33 -2
  16. package/src/engine/boot-validator/__tests__/record-owned.test.ts +56 -0
  17. package/src/engine/boot-validator/index.ts +2 -0
  18. package/src/engine/boot-validator/query-output-columns.ts +4 -2
  19. package/src/engine/boot-validator/record-owned.ts +22 -0
  20. package/src/engine/boot-validator/screens.ts +7 -4
  21. package/src/engine/feature-config-events-jobs.ts +18 -5
  22. package/src/engine/index.ts +2 -0
  23. package/src/engine/types/index.ts +3 -0
  24. package/src/engine/types/screen.ts +1 -0
  25. package/src/event-store/__tests__/backfill-pii.integration.test.ts +133 -7
  26. package/src/event-store/event-store.ts +5 -1
  27. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +79 -0
  28. package/src/pipeline/dispatch-write.ts +28 -10
  29. package/src/ui-types/index.ts +3 -0
@@ -1,7 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createEntity, createTextField } from "../../engine/factories";
3
+ import type { TenantId } from "../../engine/types/identifiers";
3
4
  import {
4
5
  collectPiiSubjectFields,
6
+ resolveEventSubject,
5
7
  resolveSubjectForField,
6
8
  SubjectResolutionError,
7
9
  } from "../subject-resolver";
@@ -38,31 +40,50 @@ const brandingEntity = createEntity({
38
40
 
39
41
  const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
40
42
  const UUID_B = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000b";
43
+ const ENTITY_NAME = "resolver-entity";
41
44
 
42
45
  describe("resolveSubjectForField", () => {
43
46
  test("pii: true → the entity row itself is the user subject", () => {
44
- const subject = resolveSubjectForField(userLikeEntity, "email", { id: UUID_A });
47
+ const subject = resolveSubjectForField(
48
+ userLikeEntity,
49
+ "email",
50
+ { id: UUID_A },
51
+ {
52
+ entityName: ENTITY_NAME,
53
+ },
54
+ );
45
55
  expect(subject).toEqual({ kind: "user", userId: UUID_A });
46
56
  });
47
57
 
48
58
  test("pii self-subject stringifies serial ids", () => {
49
- const subject = resolveSubjectForField(userLikeEntity, "email", { id: 42 });
59
+ const subject = resolveSubjectForField(
60
+ userLikeEntity,
61
+ "email",
62
+ { id: 42 },
63
+ {
64
+ entityName: ENTITY_NAME,
65
+ },
66
+ );
50
67
  expect(subject).toEqual({ kind: "user", userId: "42" });
51
68
  });
52
69
 
53
70
  test("userOwned → subject comes from the owner reference field", () => {
54
- const subject = resolveSubjectForField(commentEntity, "body", {
55
- id: UUID_A,
56
- authorId: UUID_B,
57
- });
71
+ const subject = resolveSubjectForField(
72
+ commentEntity,
73
+ "body",
74
+ { id: UUID_A, authorId: UUID_B },
75
+ { entityName: ENTITY_NAME },
76
+ );
58
77
  expect(subject).toEqual({ kind: "user", userId: UUID_B });
59
78
  });
60
79
 
61
80
  test("tenantOwned → subject from the row's tenantId column", () => {
62
- const subject = resolveSubjectForField(brandingEntity, "brandColor", {
63
- id: UUID_A,
64
- tenantId: UUID_B,
65
- });
81
+ const subject = resolveSubjectForField(
82
+ brandingEntity,
83
+ "brandColor",
84
+ { id: UUID_A, tenantId: UUID_B },
85
+ { entityName: ENTITY_NAME },
86
+ );
66
87
  expect(subject).toEqual({ kind: "tenant", tenantId: UUID_B });
67
88
  });
68
89
 
@@ -71,37 +92,44 @@ describe("resolveSubjectForField", () => {
71
92
  brandingEntity,
72
93
  "brandColor",
73
94
  { id: UUID_A },
74
- { tenantId: UUID_B },
95
+ { tenantId: UUID_B, entityName: ENTITY_NAME },
75
96
  );
76
97
  expect(subject).toEqual({ kind: "tenant", tenantId: UUID_B });
77
98
  });
78
99
 
79
100
  test("unannotated field → null (stays plaintext)", () => {
80
- expect(resolveSubjectForField(userLikeEntity, "role", { id: UUID_A })).toBeNull();
101
+ expect(
102
+ resolveSubjectForField(userLikeEntity, "role", { id: UUID_A }, { entityName: ENTITY_NAME }),
103
+ ).toBeNull();
81
104
  });
82
105
 
83
106
  test("empty owner reference throws instead of silently falling back to plaintext", () => {
84
- expect(() => resolveSubjectForField(commentEntity, "body", { id: UUID_A })).toThrow(
85
- SubjectResolutionError,
86
- );
107
+ expect(() =>
108
+ resolveSubjectForField(commentEntity, "body", { id: UUID_A }, { entityName: ENTITY_NAME }),
109
+ ).toThrow(SubjectResolutionError);
87
110
  });
88
111
 
89
112
  test("tenantOwned without any tenant scope throws", () => {
90
- expect(() => resolveSubjectForField(brandingEntity, "brandColor", { id: UUID_A })).toThrow(
91
- SubjectResolutionError,
92
- );
113
+ expect(() =>
114
+ resolveSubjectForField(
115
+ brandingEntity,
116
+ "brandColor",
117
+ { id: UUID_A },
118
+ { entityName: ENTITY_NAME },
119
+ ),
120
+ ).toThrow(SubjectResolutionError);
93
121
  });
94
122
 
95
123
  test("pii row without id throws", () => {
96
- expect(() => resolveSubjectForField(userLikeEntity, "email", {})).toThrow(
97
- SubjectResolutionError,
98
- );
124
+ expect(() =>
125
+ resolveSubjectForField(userLikeEntity, "email", {}, { entityName: ENTITY_NAME }),
126
+ ).toThrow(SubjectResolutionError);
99
127
  });
100
128
 
101
129
  test("unknown field name throws", () => {
102
- expect(() => resolveSubjectForField(userLikeEntity, "nope", { id: UUID_A })).toThrow(
103
- SubjectResolutionError,
104
- );
130
+ expect(() =>
131
+ resolveSubjectForField(userLikeEntity, "nope", { id: UUID_A }, { entityName: ENTITY_NAME }),
132
+ ).toThrow(SubjectResolutionError);
105
133
  });
106
134
  });
107
135
 
@@ -112,3 +140,90 @@ describe("collectPiiSubjectFields", () => {
112
140
  expect(collectPiiSubjectFields(brandingEntity)).toEqual(["brandColor"]);
113
141
  });
114
142
  });
143
+
144
+ const EVENT_TENANT = "6b2f4a0e-1c9d-4f3a-9d2e-0000000000e1" as TenantId;
145
+
146
+ describe("resolveEventSubject (fw#2801)", () => {
147
+ test("personal: { of } → user subject from the named payload field", () => {
148
+ const subject = resolveEventSubject(
149
+ "note",
150
+ { personal: { of: "authorId" } },
151
+ { authorId: UUID_A },
152
+ { tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
153
+ );
154
+ expect(subject).toEqual({ kind: "user", userId: UUID_A });
155
+ });
156
+
157
+ test("personal: { of } with a missing owner value → null, no throw (system-triggered event)", () => {
158
+ const subject = resolveEventSubject(
159
+ "note",
160
+ { personal: { of: "authorId" } },
161
+ { authorId: null },
162
+ { tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
163
+ );
164
+ expect(subject).toBeNull();
165
+ });
166
+
167
+ test('personal: "tenant" → subject from the envelope tenantId', () => {
168
+ const subject = resolveEventSubject(
169
+ "note",
170
+ { personal: "tenant" },
171
+ {},
172
+ { tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
173
+ );
174
+ expect(subject).toEqual({ kind: "tenant", tenantId: EVENT_TENANT });
175
+ });
176
+
177
+ test('personal: "self" → record subject from the envelope aggregateType/aggregateId', () => {
178
+ const subject = resolveEventSubject(
179
+ "note",
180
+ { personal: "self" },
181
+ {},
182
+ { tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
183
+ );
184
+ expect(subject).toEqual({ kind: "record", entity: "note", id: UUID_B });
185
+ });
186
+
187
+ test('personal: "tenant" with an empty envelope tenantId throws and names the pii field, not "tenant"', () => {
188
+ expect(() =>
189
+ resolveEventSubject(
190
+ "note",
191
+ { personal: "tenant" },
192
+ {},
193
+ { tenantId: "" as TenantId, aggregateType: "note", aggregateId: UUID_B },
194
+ ),
195
+ ).toThrow(/field "note"/);
196
+ });
197
+
198
+ test('personal: "self" with an empty envelope aggregateId throws and names the pii field, not "self"', () => {
199
+ expect(() =>
200
+ resolveEventSubject(
201
+ "note",
202
+ { personal: "self" },
203
+ {},
204
+ { tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: "" },
205
+ ),
206
+ ).toThrow(/field "note"/);
207
+ });
208
+
209
+ test('personal: "self" with an aggregateType that violates the record-entity pattern throws — mint must never outrun shred (fw#2801)', () => {
210
+ expect(() =>
211
+ resolveEventSubject(
212
+ "note",
213
+ { personal: "self" },
214
+ {},
215
+ { tenantId: EVENT_TENANT, aggregateType: "v2.digest", aggregateId: UUID_B },
216
+ ),
217
+ ).toThrow(/field "note".*v2\.digest/s);
218
+ });
219
+
220
+ test("legacy subjectField normalizes to the same user subject as personal: { of }", () => {
221
+ const subject = resolveEventSubject(
222
+ "note",
223
+ { subjectField: "authorId" },
224
+ { authorId: UUID_A },
225
+ { tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
226
+ );
227
+ expect(subject).toEqual({ kind: "user", userId: UUID_A });
228
+ });
229
+ });
@@ -12,9 +12,10 @@
12
12
  // silent no-op (forgetting the option) is now either an explicit "none" or a
13
13
  // missing subject KMS at encrypt-time, both visible states, not a gap.
14
14
 
15
+ import type { EventPiiFields } from "@cosmicdrift/kumiko-types/handlers";
15
16
  import { requestContext } from "../api/request-context";
16
- import type { EventPiiFields } from "../engine/types/handlers";
17
17
  import { configuredPiiSubjectKms, encryptPiiValueForSubject } from "./pii-field-encryption";
18
+ import { type EventSubjectEnvelope, resolveEventSubject } from "./subject-resolver";
18
19
 
19
20
  export type EventPiiCatalog = ReadonlyMap<string, EventPiiFields>;
20
21
 
@@ -35,14 +36,20 @@ export function resetEventPiiCatalogForTests(): void {
35
36
  catalog = new Map();
36
37
  }
37
38
 
38
- // Encrypts catalogued payload fields under the owning user's DEK. No-op when
39
- // the event type is uncatalogued or no subject KMS is configured (plaintext
40
- // rollout mode — the hard boot gate governs whether that is acceptable).
41
- // A null/absent subject field (system cron runs, recipient-less skip
42
- // attempts) leaves the value plaintext: there is no user key to shred.
39
+ // Encrypts catalogued payload fields under the declared subject's DEK
40
+ // (user/tenant/self — resolveEventSubject, fw#2801). No-op when the event
41
+ // type is uncatalogued or no subject KMS is configured (plaintext rollout
42
+ // mode — the hard boot gate governs whether that is acceptable). A
43
+ // user-subject field with no resolvable owner (system cron runs,
44
+ // recipient-less skip attempts) leaves the value plaintext: there is no
45
+ // user key to shred. This is the ONLY live-write encrypt path — the backfill
46
+ // catalog branch (`backfill-pii.ts`) resolves through the same
47
+ // resolveEventSubject so a field never ends up encrypted under different
48
+ // subjects depending on which path wrote it.
43
49
  export async function encryptEventPayloadPii(
44
50
  eventType: string,
45
51
  payload: Record<string, unknown>,
52
+ envelope: EventSubjectEnvelope,
46
53
  ): Promise<Record<string, unknown>> {
47
54
  const piiFields = catalog.get(eventType);
48
55
  if (!piiFields) return payload;
@@ -58,11 +65,11 @@ export async function encryptEventPayloadPii(
58
65
  `Event "${eventType}" piiFields."${field}" must be a string payload field, got ${typeof value}`,
59
66
  );
60
67
  }
61
- const subjectId = payload[spec.subjectField];
62
- if (typeof subjectId !== "string" || subjectId.length === 0) continue;
68
+ const subject = resolveEventSubject(field, spec, payload, envelope);
69
+ if (subject === null) continue;
63
70
  const encrypted = await encryptPiiValueForSubject(
64
71
  kms,
65
- { kind: "user", userId: subjectId },
72
+ subject,
66
73
  value,
67
74
  { requestId: requestContext.get()?.requestId ?? "append-event" },
68
75
  field,
@@ -24,12 +24,14 @@ export {
24
24
  type KmsContext,
25
25
  type KmsHealth,
26
26
  type LocalKeyKmsAdapter,
27
+ RECORD_ENTITY_PATTERN,
27
28
  type RemoteCryptoKmsAdapter,
28
29
  type SubjectDek,
29
30
  type SubjectId,
30
31
  type SubjectKey,
31
32
  subjectIdFromKey,
32
33
  subjectIdToKey,
34
+ subjectKeyForRecord,
33
35
  subjectKeyForTenant,
34
36
  subjectKeyForUser,
35
37
  } from "./kms-adapter";
@@ -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;
@@ -1,7 +1,8 @@
1
+ import { type EventPiiSubject, normalizeEventPiiSubject } from "@cosmicdrift/kumiko-types/handlers";
1
2
  import type { EntityDefinition } from "../engine/types/fields";
2
3
  import type { TenantId } from "../engine/types/identifiers";
3
4
  import { isSelfPiiField } from "./is-self-pii-field";
4
- import type { SubjectId } from "./kms-adapter";
5
+ import { RECORD_ENTITY_PATTERN, type SubjectId } from "./kms-adapter";
5
6
 
6
7
  // Thrown when a field IS pii-annotated but the row can't name its subject —
7
8
  // that must surface as an error, not fall back to plaintext.
@@ -21,7 +22,7 @@ export interface ResolveSubjectOptions {
21
22
  readonly tenantId?: TenantId;
22
23
  // Canonical source is the registry entity name (executor `entityName` /
23
24
  // event `aggregate_type`) — required to resolve recordOwned fields.
24
- readonly entityName?: string;
25
+ readonly entityName: string;
25
26
  }
26
27
 
27
28
  function nonEmptyString(value: unknown): string | null {
@@ -101,7 +102,7 @@ export function resolveSubjectForField(
101
102
  entity: EntityDefinition,
102
103
  fieldName: string,
103
104
  row: Record<string, unknown>,
104
- opts: ResolveSubjectOptions = {},
105
+ opts: ResolveSubjectOptions,
105
106
  ): SubjectId | null {
106
107
  const field = entity.fields[fieldName];
107
108
  if (!field) throw new SubjectResolutionError(fieldName, "field is not defined on the entity");
@@ -125,6 +126,66 @@ export function resolveSubjectForField(
125
126
  return null;
126
127
  }
127
128
 
129
+ // Envelope facts an event's own aggregate stream always carries — unlike a
130
+ // row's tenantId/id columns, these are structural, so a missing/empty value
131
+ // here is a caller bug, not a normal "no subject to shred" case.
132
+ export interface EventSubjectEnvelope {
133
+ readonly tenantId: TenantId;
134
+ readonly aggregateType: string;
135
+ readonly aggregateId: string;
136
+ }
137
+
138
+ // Same resolver for the live-append path (encryptEventPayloadPii) and the
139
+ // backfill catalog path (backfillEventPiiEncryption) — the reason the two
140
+ // can never encrypt the same field under different subjects. "user" returns
141
+ // null on a missing owner (no key to shred for system-triggered events);
142
+ // "tenant"/"self" throw instead, since their envelope facts are structural.
143
+ export function resolveEventSubject(
144
+ fieldName: string,
145
+ spec: EventPiiSubject,
146
+ payload: Record<string, unknown>,
147
+ envelope: EventSubjectEnvelope,
148
+ ): SubjectId | null {
149
+ const normalized = normalizeEventPiiSubject(spec);
150
+
151
+ if (normalized.kind === "user") {
152
+ const userId = nonEmptyString(payload[normalized.ownerField]);
153
+ return userId === null ? null : { kind: "user", userId };
154
+ }
155
+
156
+ if (normalized.kind === "tenant") {
157
+ if (nonEmptyString(envelope.tenantId) === null) {
158
+ throw new SubjectResolutionError(fieldName, "event envelope tenantId is empty");
159
+ }
160
+ return { kind: "tenant", tenantId: envelope.tenantId };
161
+ }
162
+
163
+ if (normalized.kind === "self") {
164
+ if (
165
+ nonEmptyString(envelope.aggregateType) === null ||
166
+ nonEmptyString(envelope.aggregateId) === null
167
+ ) {
168
+ throw new SubjectResolutionError(
169
+ fieldName,
170
+ "event envelope aggregateType/aggregateId is empty",
171
+ );
172
+ }
173
+ // aggregate_type is a free-form text column (events-schema.ts) — reject
174
+ // here rather than mint a record key that subjectIdSchema (forget-
175
+ // subject.write.ts) would later refuse to shred.
176
+ if (!RECORD_ENTITY_PATTERN.test(envelope.aggregateType)) {
177
+ throw new SubjectResolutionError(
178
+ fieldName,
179
+ `event aggregateType "${envelope.aggregateType}" does not match ${RECORD_ENTITY_PATTERN} — not a shreddable record subject`,
180
+ );
181
+ }
182
+ return { kind: "record", entity: envelope.aggregateType, id: envelope.aggregateId };
183
+ }
184
+
185
+ const exhaustiveCheck: never = normalized;
186
+ throw new Error(`Unhandled normalized event PII subject: ${JSON.stringify(exhaustiveCheck)}`);
187
+ }
188
+
128
189
  function hasSubjectAnnotation(field: EntityDefinition["fields"][string]): boolean {
129
190
  return (
130
191
  ("userOwned" in field && field.userOwned !== undefined) ||
@@ -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,
@@ -11,7 +11,8 @@
11
11
  // same collectPiiSubjectFields/resolveSubjectForField the live write
12
12
  // path uses
13
13
  // - custom events from the event-PII catalog (r.defineEvent piiFields) —
14
- // user-subject only, see the note at its call site (fw#2801)
14
+ // user/tenant/self subjects, via the same resolveEventSubject the live
15
+ // append path uses (fw#2801)
15
16
  //
16
17
  // Already-forgotten subjects must NOT get a fresh key minted for their old
17
18
  // plaintext — three erased-detection layers write [[erased]] instead:
@@ -59,6 +60,7 @@ import {
59
60
  } from "../../crypto/pii-field-encryption";
60
61
  import {
61
62
  collectPiiSubjectFields,
63
+ resolveEventSubject,
62
64
  resolveSubjectForField,
63
65
  SubjectResolutionError,
64
66
  } from "../../crypto/subject-resolver";
@@ -284,11 +286,13 @@ export async function backfillEventPiiEncryption(
284
286
  const catalogFields = eventCatalog.get(row.type);
285
287
  if (catalogFields) {
286
288
  for (const [field, spec] of Object.entries(catalogFields)) {
287
- const subjectId = payload[spec.subjectField];
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).
291
- const outcome = await encryptField(payload, field, { kind: "user", userId: subjectId });
289
+ const subject = resolveEventSubject(field, spec, payload, {
290
+ tenantId: row.tenant_id as TenantId, // @cast-boundary db-read — tenant_id column is the branded TenantId
291
+ aggregateType: row.aggregate_type,
292
+ aggregateId: row.aggregate_id,
293
+ });
294
+ if (subject === null) continue;
295
+ const outcome = await encryptField(payload, field, subject);
292
296
  bump(outcome);
293
297
  }
294
298
  } else {
@@ -132,6 +132,37 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
132
132
  expect(() => validateBoot([feature])).toThrow(/metric "balance" has no entry in fieldLabels/);
133
133
  });
134
134
 
135
+ test("object-form metric with its own label needs no fieldLabels entry", () => {
136
+ const feature = defineFeature("app", (r) => {
137
+ r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
138
+ access: { openToAll: true },
139
+ });
140
+ r.screen({
141
+ id: "rent-detail",
142
+ type: "projectionDetail",
143
+ query: "app:query:rent:detail",
144
+ layout: { sections: [{ title: "s", fields: ["description"] }] },
145
+ metrics: [{ field: "balance", label: "rent.balance" }],
146
+ });
147
+ });
148
+ expect(() => validateBoot([feature])).not.toThrow();
149
+ });
150
+
151
+ test("object-form metric with neither its own label nor a fieldLabels entry throws", () => {
152
+ const feature = defineFeature("app", (r) => {
153
+ r.screen({
154
+ id: "rent-detail",
155
+ type: "projectionDetail",
156
+ query: "app:query:rent:detail",
157
+ layout: { sections: [{ title: "s", fields: ["description"] }] },
158
+ metrics: [{ field: "balance" }],
159
+ });
160
+ });
161
+ expect(() => validateBoot([feature])).toThrow(
162
+ /metric "balance" has no entry in fieldLabels and no own "label"/,
163
+ );
164
+ });
165
+
135
166
  test("valid tabs + header + metrics declaration boots cleanly", () => {
136
167
  const feature = defineFeature("app", (r) => {
137
168
  r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
@@ -142,8 +173,8 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
142
173
  type: "projectionDetail",
143
174
  query: "app:query:rent:detail",
144
175
  header: { title: "name", subtitle: "address", status: "state" },
145
- metrics: ["balance", "overdueDays"],
146
- fieldLabels: { balance: "rent.balance", overdueDays: "rent.overdueDays" },
176
+ metrics: ["balance", { field: "overdueDays", label: "rent.overdueDays" }],
177
+ fieldLabels: { balance: "rent.balance" },
147
178
  layout: {
148
179
  mode: "tabs",
149
180
  sections: [
@@ -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);
@@ -12,6 +12,7 @@ import type {
12
12
  QueryHandlerDef,
13
13
  ScreenDefinition,
14
14
  } from "../types";
15
+ import { metricField } from "../types";
15
16
  import { buildQueryHandlerMap } from "./projection-list-screens";
16
17
  import { getZodObjectShape, getZodRowShape } from "./zod-shape";
17
18
 
@@ -130,11 +131,12 @@ function checkProjectionDetailOutputFields(
130
131
  if (screen.header.status !== undefined) checkHeaderField("status", screen.header.status);
131
132
  }
132
133
  for (const metric of screen.metrics ?? []) {
134
+ const field = metricField(metric);
133
135
  checkFieldExists(
134
136
  recordShape,
135
- metric,
137
+ field,
136
138
  () =>
137
- `${prefix} metrics references field "${metric}" which is not present in query "${screen.query}"'s outputSchema.`,
139
+ `${prefix} metrics references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`,
138
140
  );
139
141
  }
140
142
  }
@@ -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
+ }