@cosmicdrift/kumiko-framework 0.290.0 → 0.292.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 (42) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/pii-personal-migration-report-codemod.test.ts +160 -0
  3. package/src/api/__tests__/server-error-logging.test.ts +168 -17
  4. package/src/api/request-context.ts +3 -0
  5. package/src/api/request-id-middleware.ts +2 -1
  6. package/src/api/routes.ts +35 -3
  7. package/src/changes.json +63 -0
  8. package/src/crypto/__tests__/event-pii.test.ts +110 -9
  9. package/src/crypto/__tests__/subject-resolver.test.ts +23 -2
  10. package/src/crypto/subject-resolver.ts +25 -8
  11. package/src/db/queries/shadow-swap.ts +35 -0
  12. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +122 -0
  13. package/src/engine/__tests__/boot-validator-projection-list.test.ts +93 -0
  14. package/src/engine/__tests__/boot-validator.test.ts +226 -0
  15. package/src/engine/__tests__/build-app-schema.test.ts +18 -0
  16. package/src/engine/__tests__/engine.test.ts +87 -0
  17. package/src/engine/__tests__/form-money-currency-types.test.ts +90 -0
  18. package/src/engine/boot-validator/entity-handler.ts +44 -0
  19. package/src/engine/boot-validator/index.ts +10 -4
  20. package/src/engine/boot-validator/pii-retention.ts +8 -0
  21. package/src/engine/boot-validator/projection-list-screens.ts +52 -2
  22. package/src/engine/boot-validator/screens.ts +50 -0
  23. package/src/engine/create-app.ts +54 -0
  24. package/src/engine/extension-names.ts +10 -0
  25. package/src/engine/feature-config-events-jobs.ts +19 -0
  26. package/src/engine/index.ts +3 -0
  27. package/src/engine/screen-helpers.ts +1 -0
  28. package/src/engine/system-user.ts +3 -5
  29. package/src/event-store/__tests__/backfill-pii.integration.test.ts +30 -6
  30. package/src/files/provider-resolver.ts +9 -2
  31. package/src/i18n/required-surface-keys.ts +1 -0
  32. package/src/jobs/__tests__/job-last-success.integration.test.ts +135 -0
  33. package/src/jobs/index.ts +7 -1
  34. package/src/jobs/job-runner.ts +94 -4
  35. package/src/logging/utils.ts +14 -1
  36. package/src/observability/index.ts +1 -0
  37. package/src/observability/standard-metrics.ts +20 -0
  38. package/src/pipeline/__tests__/blind-index-rebuild-guard.integration.test.ts +96 -0
  39. package/src/pipeline/projection-rebuild.ts +7 -0
  40. package/src/schema-cli.ts +21 -0
  41. package/src/scripts/codemod/pii-personal-migration.ts +242 -2
  42. package/src/ui-types/list-row-meta.ts +4 -1
@@ -2506,6 +2506,116 @@ describe("boot-validator", () => {
2506
2506
  );
2507
2507
  });
2508
2508
 
2509
+ // fw#2839: an actionForm has no entity, so a money field there can't
2510
+ // inherit entity.defaultCurrency — undeclared, the renderer seeds a bare
2511
+ // `0` and the handler's zod schema rejects the submit.
2512
+ describe("money field currency source (fw#2839)", () => {
2513
+ const moneyForm = (currency?: unknown) =>
2514
+ makeFeature({
2515
+ fields: { amount: { type: "money", ...(currency !== undefined && { currency }) } },
2516
+ sections: [{ title: "Payment", fields: ["amount"] }],
2517
+ });
2518
+
2519
+ test("money field ohne currency → Throw, nennt Screen und Feld", () => {
2520
+ expect(() => validateBoot([moneyForm()])).toThrow(
2521
+ /Screen "approve-invoice" \(actionForm\) money field "amount" must declare where its currency comes from/,
2522
+ );
2523
+ });
2524
+
2525
+ test("Fehlermeldung nennt beide gültigen Formen wörtlich", () => {
2526
+ expect(() => validateBoot([moneyForm()])).toThrow(
2527
+ /currency: \{ kind: "literal", code: "EUR" \}[\s\S]*currency: \{ kind: "tenant" \}/,
2528
+ );
2529
+ });
2530
+
2531
+ test("currency: { kind: 'literal', code } → kein Throw", () => {
2532
+ expect(() => validateBoot([moneyForm({ kind: "literal", code: "EUR" })])).not.toThrow();
2533
+ });
2534
+
2535
+ test("currency: { kind: 'tenant' } → kein Throw", () => {
2536
+ expect(() => validateBoot([moneyForm({ kind: "tenant" })])).not.toThrow();
2537
+ });
2538
+
2539
+ test("literal mit leerem code → Throw", () => {
2540
+ expect(() => validateBoot([moneyForm({ kind: "literal", code: " " })])).toThrow(
2541
+ /empty or non-string `code`/,
2542
+ );
2543
+ });
2544
+
2545
+ test("unbekannter kind → Throw", () => {
2546
+ expect(() => validateBoot([moneyForm({ kind: "entityDefault" })])).toThrow(
2547
+ /unknown currency kind "entityDefault"/,
2548
+ );
2549
+ });
2550
+
2551
+ test("non-money Felder bleiben unberührt", () => {
2552
+ expect(() => validateBoot([makeFeature()])).not.toThrow();
2553
+ });
2554
+
2555
+ // The check sits in validateFormFieldsMap, the walk actionForm,
2556
+ // secretMint and secretMint's confirm step all share.
2557
+ function mintFeature(
2558
+ fields: Record<string, unknown>,
2559
+ confirmFields?: Record<string, unknown>,
2560
+ ) {
2561
+ return defineFeature("shop", (r) => {
2562
+ r.writeHandler({
2563
+ name: "token:mint",
2564
+ schema: { _type: "stub" } as never,
2565
+ handler: async () => ({ isSuccess: true, data: {} }) as never,
2566
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
2567
+ });
2568
+ r.screen({
2569
+ id: "mint-token",
2570
+ type: "secretMint",
2571
+ handler: "shop:write:token:mint",
2572
+ fields: fields as never,
2573
+ layout: { sections: [{ title: "Mint", fields: Object.keys(fields) }] as never },
2574
+ reveal: { fields: [{ field: "token", label: "Token" }] },
2575
+ ...(confirmFields !== undefined && {
2576
+ confirm: {
2577
+ handler: "shop:write:token:mint",
2578
+ fields: confirmFields as never,
2579
+ layout: {
2580
+ sections: [{ title: "Confirm", fields: Object.keys(confirmFields) }] as never,
2581
+ },
2582
+ },
2583
+ }),
2584
+ });
2585
+ });
2586
+ }
2587
+
2588
+ test("secretMint money field ohne currency → Throw", () => {
2589
+ expect(() => validateBoot([mintFeature({ fee: { type: "money" } })])).toThrow(
2590
+ /Screen "mint-token" \(secretMint\) money field "fee" must declare where its currency comes from/,
2591
+ );
2592
+ });
2593
+
2594
+ test("secretMint confirm-Step money field ohne currency → Throw", () => {
2595
+ expect(() =>
2596
+ validateBoot([
2597
+ mintFeature(
2598
+ { fee: { type: "money", currency: { kind: "literal", code: "EUR" } } },
2599
+ { topUp: { type: "money" } },
2600
+ ),
2601
+ ]),
2602
+ ).toThrow(
2603
+ /Screen "mint-token" \(secretMint confirm\) money field "topUp" must declare where its currency comes from/,
2604
+ );
2605
+ });
2606
+
2607
+ test("secretMint mit deklarierten Quellen in beiden Steps → kein Throw", () => {
2608
+ expect(() =>
2609
+ validateBoot([
2610
+ mintFeature(
2611
+ { fee: { type: "money", currency: { kind: "literal", code: "EUR" } } },
2612
+ { topUp: { type: "money", currency: { kind: "tenant" } } },
2613
+ ),
2614
+ ]),
2615
+ ).not.toThrow();
2616
+ });
2617
+ });
2618
+
2509
2619
  test("layout.sections leer → Throw", () => {
2510
2620
  expect(() => validateBoot([makeFeature({ sections: [] })])).toThrow(
2511
2621
  /has an empty sections list/,
@@ -3846,6 +3956,122 @@ describe("boot-validator", () => {
3846
3956
  );
3847
3957
  });
3848
3958
 
3959
+ test("reference optionsQuery naming a registered handler → kein Throw (fw#2780)", () => {
3960
+ const features = [
3961
+ defineFeature("shop", (r) => {
3962
+ r.entity(
3963
+ "customer",
3964
+ createEntity({
3965
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
3966
+ }),
3967
+ );
3968
+ stubListHandler(r, "customer");
3969
+ r.queryHandler({
3970
+ name: "customer:options",
3971
+ schema: z.object({}),
3972
+ handler: async () => ({ rows: [] }) as never,
3973
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
3974
+ });
3975
+ r.entity(
3976
+ "order",
3977
+ createEntity({
3978
+ fields: {
3979
+ customerId: {
3980
+ type: "reference",
3981
+ entity: "customer",
3982
+ optionsQuery: "shop:query:customer:options",
3983
+ },
3984
+ },
3985
+ }),
3986
+ );
3987
+ }),
3988
+ ];
3989
+ expect(() => validateBoot(features)).not.toThrow();
3990
+ });
3991
+
3992
+ test("reference optionsQuery auf unregistrierten Handler → Throw (fw#2780)", () => {
3993
+ const features = [
3994
+ defineFeature("shop", (r) => {
3995
+ r.entity(
3996
+ "customer",
3997
+ createEntity({
3998
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
3999
+ }),
4000
+ );
4001
+ stubListHandler(r, "customer");
4002
+ r.entity(
4003
+ "order",
4004
+ createEntity({
4005
+ fields: {
4006
+ customerId: {
4007
+ type: "reference",
4008
+ entity: "customer",
4009
+ optionsQuery: "shop:query:customer:typo",
4010
+ },
4011
+ },
4012
+ }),
4013
+ );
4014
+ }),
4015
+ ];
4016
+ expect(() => validateBoot(features)).toThrow(
4017
+ /Reference field "customerId" on entity "order" declares optionsQuery "shop:query:customer:typo" which is not a registered query-handler/,
4018
+ );
4019
+ });
4020
+
4021
+ test("leerer optionsQuery → Throw (fw#2780)", () => {
4022
+ const features = [
4023
+ defineFeature("shop", (r) => {
4024
+ r.entity(
4025
+ "customer",
4026
+ createEntity({
4027
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
4028
+ }),
4029
+ );
4030
+ stubListHandler(r, "customer");
4031
+ r.entity(
4032
+ "order",
4033
+ createEntity({
4034
+ fields: {
4035
+ customerId: { type: "reference", entity: "customer", optionsQuery: "" },
4036
+ },
4037
+ }),
4038
+ );
4039
+ }),
4040
+ ];
4041
+ expect(() => validateBoot(features)).toThrow(/has an empty optionsQuery/);
4042
+ });
4043
+
4044
+ test("reference sub-field optionsQuery auf unregistrierten Handler → Throw mit parent.child-Pfad (fw#2780)", () => {
4045
+ const features = [
4046
+ defineFeature("shop", (r) => {
4047
+ r.entity(
4048
+ "product",
4049
+ createEntity({
4050
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
4051
+ }),
4052
+ );
4053
+ stubListHandler(r, "product");
4054
+ r.entity(
4055
+ "invoice",
4056
+ createEntity({
4057
+ fields: {
4058
+ lines: createEmbeddedListField({
4059
+ productId: {
4060
+ type: "reference",
4061
+ entity: "product",
4062
+ optionsQuery: "shop:query:product:typo",
4063
+ },
4064
+ }),
4065
+ },
4066
+ }),
4067
+ );
4068
+ }),
4069
+ ];
4070
+ expect(() => validateBoot(features)).toThrow(
4071
+ /Reference field "lines\.productId" on entity "invoice" declares optionsQuery "shop:query:product:typo"/,
4072
+ );
4073
+ });
4074
+
3849
4075
  test("reference sub-field labelField referencing an unknown field → Throw with the parent.child field path", () => {
3850
4076
  const features = [
3851
4077
  defineFeature("shop", (r) => {
@@ -644,6 +644,24 @@ describe("buildAppSchema", () => {
644
644
  expect(JSON.parse(JSON.stringify(projected))).toEqual(projected);
645
645
  });
646
646
 
647
+ test("money field's `currency: { kind: 'literal', code }` declaration survives the projection (fw#2839)", () => {
648
+ const entity = {
649
+ defaultCurrency: "USD",
650
+ fields: { price: { type: "money", currency: { kind: "literal", code: "USD" } } },
651
+ } as unknown as EntityDefinition;
652
+
653
+ const f = defineFeature("ent", (r) => {
654
+ r.entity("thing", entity);
655
+ });
656
+ const app = buildAppSchema(createRegistry([f]));
657
+ const projected = app.features[0]!.entities["thing"] as unknown as {
658
+ fields: Record<string, Record<string, unknown>>;
659
+ };
660
+
661
+ expect(projected.fields["price"]?.["currency"]).toEqual({ kind: "literal", code: "USD" });
662
+ expect(JSON.parse(JSON.stringify(projected))).toEqual(projected);
663
+ });
664
+
647
665
  test("text format, timestamp locatedBy, file accept/maxSize, image variants and decimal scale survive the projection", () => {
648
666
  // Regression: all of these are read by the edit view-model (password
649
667
  // masking, wall-clock input, upload constraints, preview variant, derived
@@ -909,6 +909,45 @@ describe("createRegistry", () => {
909
909
  ]);
910
910
  });
911
911
 
912
+ test("optionsQuery leaves searchable/sortable on the labelField column (fw#2780)", () => {
913
+ const feature = defineFeature("crm", (r) => {
914
+ r.entity(
915
+ "customer",
916
+ createEntity({
917
+ table: "Customers",
918
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
919
+ }),
920
+ );
921
+ r.queryHandler("customer:options", z.object({}), async () => [], {
922
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
923
+ });
924
+ r.entity(
925
+ "order",
926
+ createEntity({
927
+ table: "Orders",
928
+ fields: {
929
+ customerId: {
930
+ type: "reference",
931
+ entity: "customer",
932
+ labelField: "name",
933
+ optionsQuery: "crm:query:customer:options",
934
+ searchable: true,
935
+ sortable: true,
936
+ },
937
+ },
938
+ }),
939
+ );
940
+ });
941
+
942
+ const registry = createRegistry([feature]);
943
+ expect(registry.getSortableReferences("order")).toEqual([
944
+ { fieldName: "customerId", targetEntityName: "customer", labelField: "name" },
945
+ ]);
946
+ expect(registry.getSearchableReferences("order")).toEqual([
947
+ { fieldName: "customerId", targetEntityName: "customer", labelField: "name" },
948
+ ]);
949
+ });
950
+
912
951
  test("throws at boot when a sortable reference field has no explicit labelField (fw#2741)", () => {
913
952
  const feature = defineFeature("crm", (r) => {
914
953
  r.entity(
@@ -1204,6 +1243,54 @@ describe("createApp", () => {
1204
1243
  );
1205
1244
  });
1206
1245
 
1246
+ // fw#2839: a literal currency source is only meaningful for a code the app
1247
+ // knows — a typo would otherwise render and submit an unformattable amount.
1248
+ function literalCurrencyFeature(code: string) {
1249
+ return defineFeature("test", (r) => {
1250
+ r.entity(
1251
+ "invoice",
1252
+ createEntity({
1253
+ table: "Invoices",
1254
+ defaultCurrency: "EUR",
1255
+ fields: { total: { type: "money", currency: { kind: "literal", code } } },
1256
+ } as never),
1257
+ );
1258
+ });
1259
+ }
1260
+
1261
+ test("rejects currency: { kind: 'literal' } with a code outside the currencies list", () => {
1262
+ expect(() =>
1263
+ createApp({ roles: ["Admin"], features: [literalCurrencyFeature("EURO")] }),
1264
+ ).toThrow(/code: "EURO" \} which is not in the currencies list/);
1265
+ });
1266
+
1267
+ test("accepts currency: { kind: 'literal' } with a code from the currencies list", () => {
1268
+ expect(() =>
1269
+ createApp({ roles: ["Admin"], features: [literalCurrencyFeature("EUR")] }),
1270
+ ).not.toThrow();
1271
+ });
1272
+
1273
+ test("rejects an unknown literal code on an actionForm screen field too", () => {
1274
+ const feature = defineFeature("test", (r) => {
1275
+ r.writeHandler({
1276
+ name: "invoice:pay",
1277
+ schema: { _type: "stub" } as never,
1278
+ handler: async () => ({ isSuccess: true, data: {} }) as never,
1279
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
1280
+ });
1281
+ r.screen({
1282
+ id: "invoice-pay",
1283
+ type: "actionForm",
1284
+ handler: "test:write:invoice:pay",
1285
+ fields: { amount: { type: "money", currency: { kind: "literal", code: "EURO" } } } as never,
1286
+ layout: { sections: [{ title: "Pay", fields: ["amount"] }] } as never,
1287
+ });
1288
+ });
1289
+ expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
1290
+ /Screen "invoice-pay" in feature "test", money field "amount"/,
1291
+ );
1292
+ });
1293
+
1207
1294
  // hasMoneyField used to only look at top-level fields, so an entity whose
1208
1295
  // only money lives inside an embedded-list sub-schema (e.g. invoice lines
1209
1296
  // with no top-level money field) slipped past this check — its cells and
@@ -0,0 +1,90 @@
1
+ // fw#2839: the type half of the fail-closed currency-source gate. This PR
2
+ // ships the break in one release instead of the #2810 warn-then-throw ladder,
3
+ // on the premise that a consumer sees it from the COMPILER at bump time, not
4
+ // only from a boot error in production. These `@ts-expect-error`s are that
5
+ // premise: each turns into a test failure the moment the narrowing stops
6
+ // catching an undeclared money field in one of the three forms an author
7
+ // writes a screen in.
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import type {
11
+ ActionFormScreenDefinition,
12
+ ScreenDefinition,
13
+ SecretMintScreenDefinition,
14
+ } from "@cosmicdrift/kumiko-types/screen";
15
+
16
+ const layout = { sections: [{ title: "Payment", fields: ["amount"] }] };
17
+
18
+ describe("money field on an entity-less form screen is a compile error without `currency`", () => {
19
+ test("annotated as the ScreenDefinition union", () => {
20
+ const screen: ScreenDefinition = {
21
+ id: "pay",
22
+ type: "actionForm",
23
+ handler: "billing:write:pay",
24
+ // @ts-expect-error — money needs a declared currency source on an
25
+ // entity-less form; if this compiles, FormFieldDefinition stopped
26
+ // narrowing and the break would only surface at boot.
27
+ fields: { amount: { type: "money", required: true } },
28
+ layout,
29
+ };
30
+ expect(screen.type).toBe("actionForm");
31
+ });
32
+
33
+ test("annotated as ActionFormScreenDefinition", () => {
34
+ const screen: ActionFormScreenDefinition = {
35
+ id: "pay",
36
+ type: "actionForm",
37
+ handler: "billing:write:pay",
38
+ // @ts-expect-error — see above.
39
+ fields: { amount: { type: "money", required: true } },
40
+ layout,
41
+ };
42
+ expect(screen.id).toBe("pay");
43
+ });
44
+
45
+ test("contextually typed through an r.screen(...)-shaped parameter", () => {
46
+ const register = (def: ScreenDefinition): string => def.id;
47
+ expect(
48
+ register({
49
+ id: "pay",
50
+ type: "actionForm",
51
+ handler: "billing:write:pay",
52
+ // @ts-expect-error — see above.
53
+ fields: { amount: { type: "money", required: true } },
54
+ layout,
55
+ }),
56
+ ).toBe("pay");
57
+ });
58
+
59
+ test("secretMint's own fields are narrowed the same way", () => {
60
+ const screen: SecretMintScreenDefinition = {
61
+ id: "mint",
62
+ type: "secretMint",
63
+ handler: "billing:write:mint",
64
+ // @ts-expect-error — see above.
65
+ fields: { amount: { type: "money", required: true } },
66
+ layout,
67
+ reveal: { fields: [{ field: "token", label: "Token" }] },
68
+ };
69
+ expect(screen.type).toBe("secretMint");
70
+ });
71
+ });
72
+
73
+ describe("declared currency sources compile", () => {
74
+ test("literal and tenant are both accepted", () => {
75
+ const screen: ScreenDefinition = {
76
+ id: "pay",
77
+ type: "actionForm",
78
+ handler: "billing:write:pay",
79
+ fields: {
80
+ amount: { type: "money", required: true, currency: { kind: "literal", code: "EUR" } },
81
+ fee: { type: "money", currency: { kind: "tenant" } },
82
+ },
83
+ layout: { sections: [{ title: "Payment", fields: ["amount", "fee"] }] },
84
+ };
85
+ expect(Object.keys(screen.type === "actionForm" ? screen.fields : {})).toEqual([
86
+ "amount",
87
+ "fee",
88
+ ]);
89
+ });
90
+ });
@@ -7,6 +7,7 @@ import type {
7
7
  EntityDefinition,
8
8
  FeatureDefinition,
9
9
  MultiSelectFieldDef,
10
+ QueryHandlerDef,
10
11
  } from "../types";
11
12
 
12
13
  export const FILE_FIELD_TYPES = new Set(["file", "image", "files", "images"]);
@@ -429,13 +430,16 @@ function isValidEmbeddedDecimalScale(scale: number): boolean {
429
430
  // 3) Query handler `<feature>:query:<entity>:list` is registered — the
430
431
  // renderer fires it on Combobox open, so a missing handler crashes the
431
432
  // Combobox at runtime.
433
+ // 4) optionsQuery (if set) names a registered query handler.
432
434
  function validateReferenceTarget(
433
435
  entityName: string,
434
436
  fieldPath: string,
435
437
  refString: string,
436
438
  labelField: string | undefined,
439
+ optionsQuery: string | undefined,
437
440
  feature: FeatureDefinition,
438
441
  featureMap: ReadonlyMap<string, FeatureDefinition>,
442
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
439
443
  ): void {
440
444
  const target = parseRefTarget(refString, feature.name);
441
445
  const targetFeature = featureMap.get(target.featureName);
@@ -489,6 +493,40 @@ function validateReferenceTarget(
489
493
  `different label/entity.`,
490
494
  );
491
495
  }
496
+ // The check above still applies when optionsQuery is set: optionsQuery only
497
+ // replaces the picker's option list, while list cells keep resolving the
498
+ // UUID through `<targetFeature>:query:<targetEntity>:list`
499
+ // (use-reference-lookup).
500
+ validateReferenceOptionsQuery(entityName, fieldPath, optionsQuery, feature, queryHandlers);
501
+ }
502
+
503
+ // fw#2780: the picker may source its options from a query handler instead of
504
+ // the referenced entity's table. A typo'd QN would only surface as a 404 on
505
+ // first Combobox open, so it is pinned at boot — same treatment as
506
+ // `DashboardFilterDefinition.optionsQuery` (boot-validator/query-refs.ts).
507
+ function validateReferenceOptionsQuery(
508
+ entityName: string,
509
+ fieldPath: string,
510
+ optionsQuery: string | undefined,
511
+ feature: FeatureDefinition,
512
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
513
+ ): void {
514
+ // skip: a field without optionsQuery keeps the entity-table option source.
515
+ if (optionsQuery === undefined) return;
516
+ if (optionsQuery.length === 0) {
517
+ throw new Error(
518
+ `[Feature ${feature.name}] Reference field "${fieldPath}" on entity "${entityName}" ` +
519
+ `has an empty optionsQuery. Drop the property or name a query handler.`,
520
+ );
521
+ }
522
+ if (!queryHandlers.has(optionsQuery)) {
523
+ throw new Error(
524
+ `[Feature ${feature.name}] Reference field "${fieldPath}" on entity "${entityName}" ` +
525
+ `declares optionsQuery "${optionsQuery}" which is not a registered query-handler. ` +
526
+ `Check the QN spelling (expected "<feature>:query:<short>") and that the handler ` +
527
+ `is declared via r.queryHandler(...).`,
528
+ );
529
+ }
492
530
  }
493
531
 
494
532
  // Tier 2.7e-3 + Cross-Feature: ReferenceFieldDef validation for top-level
@@ -498,6 +536,7 @@ function validateReferenceTarget(
498
536
  export function validateReferenceFields(
499
537
  feature: FeatureDefinition,
500
538
  featureMap: ReadonlyMap<string, FeatureDefinition>,
539
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
501
540
  ): void {
502
541
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
503
542
  for (const [fieldName, field] of Object.entries(entity.fields)) {
@@ -507,8 +546,10 @@ export function validateReferenceFields(
507
546
  fieldName,
508
547
  field.entity,
509
548
  field.labelField,
549
+ field.optionsQuery,
510
550
  feature,
511
551
  featureMap,
552
+ queryHandlers,
512
553
  );
513
554
  }
514
555
  }
@@ -517,6 +558,7 @@ export function validateReferenceFields(
517
558
  export function validateEmbeddedFields(
518
559
  feature: FeatureDefinition,
519
560
  featureMap: ReadonlyMap<string, FeatureDefinition>,
561
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
520
562
  ): void {
521
563
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
522
564
  for (const [fieldName, field] of Object.entries(entity.fields)) {
@@ -554,8 +596,10 @@ export function validateEmbeddedFields(
554
596
  `${fieldName}.${subName}`,
555
597
  subField.entity,
556
598
  subField.labelField,
599
+ subField.optionsQuery,
557
600
  feature,
558
601
  featureMap,
602
+ queryHandlers,
559
603
  );
560
604
  }
561
605
  }
@@ -1,5 +1,6 @@
1
1
  import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
2
2
  import { dedupeFeatures } from "../dedupe-features";
3
+ import { FILE_STORAGE_PROVIDER_ENV } from "../extension-names";
3
4
  import { QnTypes, qualifyEntityName } from "../qualified-name";
4
5
  import type { FeatureDefinition } from "../types";
5
6
  import { validateAccessDeclarations } from "./access-declarations";
@@ -52,6 +53,7 @@ import { collectClaimKeys, validateOwnershipRules } from "./ownership";
52
53
  import { validateParentRefs } from "./parent-ref";
53
54
  import { validatePiiAndRetention } from "./pii-retention";
54
55
  import {
56
+ buildQueryHandlerMap,
55
57
  validateProjectionListScreens,
56
58
  validateRelatedListSectionQueries,
57
59
  } from "./projection-list-screens";
@@ -187,6 +189,10 @@ export function validateBoot(
187
189
  }
188
190
  }
189
191
 
192
+ // Registered query-handler QNs, built once for the per-feature reference
193
+ // checks below (a reference field's `optionsQuery` may target any feature).
194
+ const queryHandlerQns = buildQueryHandlerMap(features);
195
+
190
196
  let hasEncryptedFields = false;
191
197
  let hasFileFields = false;
192
198
 
@@ -197,10 +203,10 @@ export function validateBoot(
197
203
  validatePiiAndRetention(feature);
198
204
  validateRecordOwnedSubjects(feature);
199
205
  validateApiExposureMatching(feature, allExposedApis, featureMap);
200
- validateEmbeddedFields(feature, featureMap);
206
+ validateEmbeddedFields(feature, featureMap, queryHandlerQns);
201
207
  validateMultiSelectFields(feature);
202
208
  validateImageVariants(feature);
203
- validateReferenceFields(feature, featureMap);
209
+ validateReferenceFields(feature, featureMap, queryHandlerQns);
204
210
  validateTransitions(feature);
205
211
  validateExtensionUsages(feature, extensionProviders);
206
212
  validateExtendSchemaCollisions(feature);
@@ -273,9 +279,9 @@ export function validateBoot(
273
279
  validateEntityFieldEncryptionAvailable();
274
280
  }
275
281
 
276
- if (hasFileFields && !process.env["FILE_STORAGE_PROVIDER"]) {
282
+ if (hasFileFields && !process.env[FILE_STORAGE_PROVIDER_ENV]) {
277
283
  throw new Error(
278
- "FILE_STORAGE_PROVIDER environment variable is required (file/image fields in use)",
284
+ `${FILE_STORAGE_PROVIDER_ENV} environment variable is required (file/image fields in use)`,
279
285
  );
280
286
  }
281
287
 
@@ -197,6 +197,14 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
197
197
  console.warn(
198
198
  `[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no personal annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { personal: "ref" } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) for Art.17 coverage — this warning is the only boot-time check for that, registering the hook is not enforced. Or { personal: { of: "${fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`,
199
199
  );
200
+ } else if (!annot.subjectRef && (field.type === "text" || field.type === "longText")) {
201
+ // Deprecation step towards #2810, where a text field without a
202
+ // stance becomes a boot error. Only text/longText: the other field
203
+ // types keep `personal` optional.
204
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
205
+ console.warn(
206
+ `[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" (type "${field.type}") declares no personal stance. Text fields will require one — this warning becomes a boot error in a future release (#2810). Declare { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false }; "false" additionally needs { reason: "..." } stating why the value is not personal data.`,
207
+ );
200
208
  }
201
209
  }
202
210
  }