@cosmicdrift/kumiko-framework 0.289.0 → 0.291.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 (68) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/field-access.integration.test.ts +7 -3
  3. package/src/__tests__/ownership-where-write-path.integration.test.ts +1 -1
  4. package/src/__tests__/ownership.integration.test.ts +1 -1
  5. package/src/__tests__/pii-personal-migration-report-codemod.test.ts +160 -0
  6. package/src/api/__tests__/server-boot-guards.test.ts +42 -0
  7. package/src/api/__tests__/server-error-logging.test.ts +168 -17
  8. package/src/api/request-context.ts +31 -0
  9. package/src/api/request-id-middleware.ts +2 -1
  10. package/src/api/routes.ts +35 -3
  11. package/src/api/server.ts +30 -4
  12. package/src/changes.json +69 -0
  13. package/src/crypto/__tests__/blind-index.test.ts +1 -1
  14. package/src/crypto/__tests__/event-pii.test.ts +110 -9
  15. package/src/crypto/__tests__/pii-field-encryption.test.ts +2 -2
  16. package/src/crypto/__tests__/subject-resolver.test.ts +25 -4
  17. package/src/crypto/subject-resolver.ts +25 -8
  18. package/src/db/__tests__/blind-index.integration.test.ts +1 -1
  19. package/src/db/__tests__/eagerload.integration.test.ts +12 -2
  20. package/src/db/__tests__/entity-field-encryption.test.ts +2 -2
  21. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
  22. package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +7 -2
  23. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +3 -1
  24. package/src/db/__tests__/event-store-executor.integration.test.ts +15 -5
  25. package/src/db/__tests__/list-filter-field-access.integration.test.ts +6 -2
  26. package/src/db/queries/shadow-swap.ts +35 -0
  27. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +32 -0
  28. package/src/engine/__tests__/boot-validator-boot-check.test.ts +1 -1
  29. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +148 -15
  30. package/src/engine/__tests__/boot-validator.test.ts +226 -0
  31. package/src/engine/__tests__/build-app-schema.test.ts +18 -0
  32. package/src/engine/__tests__/engine.test.ts +87 -0
  33. package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +6 -2
  34. package/src/engine/__tests__/factories-long-text.test.ts +6 -1
  35. package/src/engine/__tests__/form-money-currency-types.test.ts +90 -0
  36. package/src/engine/boot-validator/__tests__/record-owned.test.ts +12 -2
  37. package/src/engine/boot-validator/action-wiring.ts +2 -1
  38. package/src/engine/boot-validator/entity-handler.ts +44 -0
  39. package/src/engine/boot-validator/index.ts +7 -2
  40. package/src/engine/boot-validator/pii-retention.ts +8 -0
  41. package/src/engine/boot-validator/screens.ts +54 -12
  42. package/src/engine/create-app.ts +54 -0
  43. package/src/engine/feature-config-events-jobs.ts +19 -0
  44. package/src/engine/index.ts +2 -0
  45. package/src/engine/qualified-name.ts +9 -0
  46. package/src/engine/screen-helpers.ts +1 -0
  47. package/src/event-store/__tests__/backfill-pii.integration.test.ts +32 -8
  48. package/src/event-store/__tests__/event-attribution.integration.test.ts +186 -0
  49. package/src/event-store/event-store.ts +23 -2
  50. package/src/i18n/required-surface-keys.ts +1 -0
  51. package/src/jobs/__tests__/job-last-success.integration.test.ts +135 -0
  52. package/src/jobs/index.ts +7 -1
  53. package/src/jobs/job-runner.ts +104 -6
  54. package/src/logging/utils.ts +14 -1
  55. package/src/observability/index.ts +1 -0
  56. package/src/observability/standard-metrics.ts +20 -0
  57. package/src/pipeline/__tests__/blind-index-rebuild-guard.integration.test.ts +96 -0
  58. package/src/pipeline/active-membership.ts +10 -4
  59. package/src/pipeline/append-event-core.ts +2 -11
  60. package/src/pipeline/dispatch-shared.ts +17 -5
  61. package/src/pipeline/event-dispatcher-delivery.ts +15 -3
  62. package/src/pipeline/projection-rebuild.ts +7 -0
  63. package/src/schema-cli.ts +21 -0
  64. package/src/scripts/codemod/pii-personal-migration.ts +242 -2
  65. package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
  66. package/src/testing/__tests__/e2e-generator.test.ts +50 -0
  67. package/src/testing/e2e-generator.ts +4 -3
  68. package/src/ui-types/index.ts +2 -0
@@ -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,9 +7,19 @@
7
7
  import { describe, expect, test } from "bun:test";
8
8
  import { defineFeature } from "../../define-feature";
9
9
  import { createEntity, createTextField } from "../../factories";
10
- import type { FeatureDefinition } from "../../types";
10
+ import type { FeatureDefinition, TextFieldDef } from "../../types";
11
11
  import { validateRecordOwnedSubjects } from "../record-owned";
12
12
 
13
+ // Presence/absence of the annotation is the test variable here; after #2810
14
+ // the factory can no longer produce the unannotated shape.
15
+ const unannotatedText: TextFieldDef = {
16
+ type: "text",
17
+ maxLength: 200,
18
+ required: false,
19
+ searchable: false,
20
+ sortable: false,
21
+ };
22
+
13
23
  function featureWith(
14
24
  idType: "serial" | "uuid" | undefined,
15
25
  withRecordOwnedField: boolean,
@@ -23,7 +33,7 @@ function featureWith(
23
33
  fields: {
24
34
  body: withRecordOwnedField
25
35
  ? createTextField({ personal: { of: "id" }, find: "none" })
26
- : createTextField(),
36
+ : { ...unannotatedText },
27
37
  },
28
38
  }),
29
39
  );
@@ -3,6 +3,7 @@ import {
3
3
  isFieldsEditSection,
4
4
  normalizeEditField,
5
5
  normalizeListColumn,
6
+ sectionFieldSpecs,
6
7
  } from "../screen-helpers";
7
8
  import type {
8
9
  EditFieldSpec,
@@ -125,7 +126,7 @@ function validateEditLayoutNoFunctions(
125
126
  continue;
126
127
  }
127
128
  if (!isFieldsEditSection(section)) continue;
128
- for (const fieldSpec of section.fields) {
129
+ for (const fieldSpec of sectionFieldSpecs(section)) {
129
130
  validateEditFieldNoFunctions(featureName, screenId, screenType, fieldSpec);
130
131
  }
131
132
  }
@@ -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
  }
@@ -52,6 +52,7 @@ import { collectClaimKeys, validateOwnershipRules } from "./ownership";
52
52
  import { validateParentRefs } from "./parent-ref";
53
53
  import { validatePiiAndRetention } from "./pii-retention";
54
54
  import {
55
+ buildQueryHandlerMap,
55
56
  validateProjectionListScreens,
56
57
  validateRelatedListSectionQueries,
57
58
  } from "./projection-list-screens";
@@ -187,6 +188,10 @@ export function validateBoot(
187
188
  }
188
189
  }
189
190
 
191
+ // Registered query-handler QNs, built once for the per-feature reference
192
+ // checks below (a reference field's `optionsQuery` may target any feature).
193
+ const queryHandlerQns = buildQueryHandlerMap(features);
194
+
190
195
  let hasEncryptedFields = false;
191
196
  let hasFileFields = false;
192
197
 
@@ -197,10 +202,10 @@ export function validateBoot(
197
202
  validatePiiAndRetention(feature);
198
203
  validateRecordOwnedSubjects(feature);
199
204
  validateApiExposureMatching(feature, allExposedApis, featureMap);
200
- validateEmbeddedFields(feature, featureMap);
205
+ validateEmbeddedFields(feature, featureMap, queryHandlerQns);
201
206
  validateMultiSelectFields(feature);
202
207
  validateImageVariants(feature);
203
- validateReferenceFields(feature, featureMap);
208
+ validateReferenceFields(feature, featureMap, queryHandlerQns);
204
209
  validateTransitions(feature);
205
210
  validateExtensionUsages(feature, extensionProviders);
206
211
  validateExtendSchemaCollisions(feature);
@@ -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
  }
@@ -16,6 +16,7 @@ import {
16
16
  normalizeEditField,
17
17
  normalizeListColumn,
18
18
  resolveNavParentScreen,
19
+ sectionFieldSpecs,
19
20
  } from "../screen-helpers";
20
21
  import type { EntityDefinition, FeatureDefinition, FieldDefinition } from "../types";
21
22
  import { metricField } from "../types";
@@ -577,10 +578,59 @@ function validateFormFieldsMap(
577
578
  `\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`,
578
579
  );
579
580
  }
581
+ if (ftype === "money") {
582
+ validateFormMoneyCurrency(featureName, screenId, context, fname, fdef);
583
+ }
580
584
  }
581
585
  return fieldNames;
582
586
  }
583
587
 
588
+ // Fail-closed currency-source gate (fw#2839), the runtime half of the
589
+ // narrowed `FormFieldDefinition` — an untyped JS consumer has no compiler to
590
+ // stop it. An inline form screen has no entity, so a money field there can't
591
+ // borrow `entity.defaultCurrency`: without a declared source the renderer
592
+ // seeds a bare `0` and the handler's zod schema rejects the submit. Entity
593
+ // money fields are exempt — create-app already refuses an entity that holds
594
+ // money without a `defaultCurrency`.
595
+ function validateFormMoneyCurrency(
596
+ featureName: string,
597
+ screenId: string,
598
+ context: string,
599
+ fieldName: string,
600
+ fdef: unknown,
601
+ ): void {
602
+ const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) money field "${fieldName}"`;
603
+ const validForms =
604
+ `Declare \`currency: { kind: "literal", code: "EUR" }\` for a fixed currency, or ` +
605
+ `\`currency: { kind: "tenant" }\` to take the tenant-settings bundle's per-tenant currency.`;
606
+ // @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
607
+ const currency = (fdef as { currency?: { kind?: unknown; code?: unknown } | null }).currency;
608
+ if (currency === undefined) {
609
+ throw new Error(
610
+ `${where} must declare where its currency comes from — this screen has no entity whose ` +
611
+ `\`defaultCurrency\` it could inherit, so the form would seed a bare \`0\` that the ` +
612
+ `handler's schema rejects. ${validForms}`,
613
+ );
614
+ }
615
+ if (typeof currency !== "object" || currency === null) {
616
+ throw new Error(`${where} has a non-object \`currency\`. ${validForms}`);
617
+ }
618
+ const kind = currency.kind;
619
+ if (kind === "literal") {
620
+ const code = currency.code;
621
+ if (typeof code !== "string" || code.trim() === "") {
622
+ throw new Error(
623
+ `${where} declares \`currency: { kind: "literal" }\` with an empty or non-string \`code\`. ` +
624
+ `Pass the ISO code, e.g. { kind: "literal", code: "EUR" }.`,
625
+ );
626
+ }
627
+ } else if (kind !== "tenant") {
628
+ throw new Error(
629
+ `${where} declares an unknown currency kind ${JSON.stringify(kind)}. ${validForms}`,
630
+ );
631
+ }
632
+ }
633
+
584
634
  // `allowEmptySections` mirrors `validateFormFieldsMap`'s `allowEmpty` — the
585
635
  // caller only ever passes true together with an actually-empty fields map
586
636
  // (an input-less secretMint declares BOTH `fields: {}` and
@@ -608,15 +658,6 @@ function validateFieldsXorGroups(
608
658
  }
609
659
  }
610
660
 
611
- function flattenFieldsOrGroups(section: {
612
- readonly fields: readonly EditFieldSpec[];
613
- readonly groups?: readonly { readonly fields: readonly EditFieldSpec[] }[];
614
- }): readonly EditFieldSpec[] {
615
- return section.groups !== undefined
616
- ? section.groups.flatMap((group) => group.fields)
617
- : section.fields;
618
- }
619
-
620
661
  function validateFormLayoutSections(
621
662
  featureName: string,
622
663
  screenId: string,
@@ -657,7 +698,7 @@ function validateFormLayoutSections(
657
698
  );
658
699
  }
659
700
  validateFieldsXorGroups(`[Feature ${featureName}] Screen "${screenId}" (${context})`, section);
660
- for (const fieldSpec of flattenFieldsOrGroups(section)) {
701
+ for (const fieldSpec of sectionFieldSpecs(section)) {
661
702
  const normalized = normalizeEditField(fieldSpec);
662
703
  if (!fieldNames.has(normalized.field)) {
663
704
  throw new Error(
@@ -1273,6 +1314,7 @@ export function validateScreens(
1273
1314
  `(writeForm) has zero fields — drop the section or add fields to it.`,
1274
1315
  );
1275
1316
  }
1317
+ // kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
1276
1318
  for (const f of section.fields) {
1277
1319
  const fieldName = normalizeEditField(f).field;
1278
1320
  if (section.fieldDefs[fieldName] === undefined) {
@@ -1420,7 +1462,7 @@ export function validateScreens(
1420
1462
  `[Feature ${feature.name}] Screen "${screenId}" (configEdit)`,
1421
1463
  section,
1422
1464
  );
1423
- for (const fieldSpec of flattenFieldsOrGroups(section)) {
1465
+ for (const fieldSpec of sectionFieldSpecs(section)) {
1424
1466
  const normalized = normalizeEditField(fieldSpec);
1425
1467
  if (!fieldNames.has(normalized.field)) {
1426
1468
  throw new Error(
@@ -1837,7 +1879,7 @@ export function validateScreens(
1837
1879
  `[Feature ${feature.name}] Screen "${screenId}" (entityEdit)`,
1838
1880
  section,
1839
1881
  );
1840
- for (const fieldSpec of flattenFieldsOrGroups(section)) {
1882
+ for (const fieldSpec of sectionFieldSpecs(section)) {
1841
1883
  const normalized = normalizeEditField(fieldSpec);
1842
1884
  if (!fieldNames.has(normalized.field)) {
1843
1885
  throw new Error(
@@ -1,3 +1,4 @@
1
+ import type { ScreenDefinition } from "@cosmicdrift/kumiko-types/screen";
1
2
  import { type ValidateBootOptions, validateBoot } from "./boot-validator";
2
3
  import { dedupeFeatures } from "./dedupe-features";
3
4
  import { createRegistry } from "./registry";
@@ -20,6 +21,37 @@ export type App = {
20
21
  currencies: readonly string[];
21
22
  };
22
23
 
24
+ // Every field map an entity-less inline form screen renders: actionForm's and
25
+ // secretMint's own fields, plus a secretMint's separate `confirm` step.
26
+ function inlineFormFieldMaps(
27
+ screen: ScreenDefinition,
28
+ ): readonly Readonly<Record<string, unknown>>[] {
29
+ if (screen.type === "actionForm") return [screen.fields];
30
+ if (screen.type !== "secretMint") return [];
31
+ return screen.confirm !== undefined ? [screen.fields, screen.confirm.fields] : [screen.fields];
32
+ }
33
+
34
+ // `currency: { kind: "literal", code }` (fw#2839) is only meaningful for a
35
+ // code the app knows — a typo would otherwise render and submit amounts in a
36
+ // currency no formatter or rate table covers.
37
+ function validateLiteralCurrencyCode(
38
+ where: string,
39
+ field: unknown,
40
+ currencies: readonly string[],
41
+ ): void {
42
+ // @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
43
+ const shape = field as { type?: unknown; currency?: { kind?: unknown; code?: unknown } };
44
+ if (shape.type === "money" && shape.currency?.kind === "literal") {
45
+ const code = shape.currency.code;
46
+ if (typeof code !== "string" || !currencies.includes(code)) {
47
+ throw new Error(
48
+ `${where} declares currency: { kind: "literal", code: ${JSON.stringify(code)} } which is ` +
49
+ `not in the currencies list. Available: ${currencies.join(", ")}`,
50
+ );
51
+ }
52
+ }
53
+ }
54
+
23
55
  export function createApp(config: AppConfig): App {
24
56
  const features = dedupeFeatures(config.features);
25
57
  const validRoles = new Set(config.roles);
@@ -108,6 +140,28 @@ export function createApp(config: AppConfig): App {
108
140
  `Entity "${entityName}" in feature "${feature.name}" has money fields but no defaultCurrency. Set defaultCurrency on the entity definition.`,
109
141
  );
110
142
  }
143
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
144
+ validateLiteralCurrencyCode(
145
+ `Entity "${entityName}" in feature "${feature.name}", money field "${fieldName}"`,
146
+ field,
147
+ currencies,
148
+ );
149
+ }
150
+ }
151
+ // A money field on an entity-less form screen names its own currency
152
+ // source (fw#2839) — a literal code has to be one the app actually knows,
153
+ // same rule the entity `defaultCurrency` check above applies.
154
+ for (const [screenId, screen] of Object.entries(feature.screens ?? {})) {
155
+ const inlineFields = inlineFormFieldMaps(screen);
156
+ for (const fields of inlineFields) {
157
+ for (const [fieldName, field] of Object.entries(fields)) {
158
+ validateLiteralCurrencyCode(
159
+ `Screen "${screenId}" in feature "${feature.name}", money field "${fieldName}"`,
160
+ field,
161
+ currencies,
162
+ );
163
+ }
164
+ }
111
165
  }
112
166
  }
113
167
 
@@ -30,6 +30,17 @@ import type {
30
30
  TranslationsDef,
31
31
  } from "./types";
32
32
 
33
+ // A payload field that still holds null/undefined after parsing — the case
34
+ // where an event-PII owner field yields no subject at append time (fw#2776).
35
+ // A `.default(...)` accepts undefined but parses to a value, so it is not
36
+ // absentable.
37
+ function isAbsentable(field: ZodType): boolean {
38
+ return [null, undefined].some((candidate) => {
39
+ const parsed = field.safeParse(candidate);
40
+ return parsed.success && (parsed.data === null || parsed.data === undefined);
41
+ });
42
+ }
43
+
33
44
  // Builds config/secrets/claims/events/jobs/notifications registrar methods.
34
45
  export function buildConfigEventsJobsMethods<TName extends string>(
35
46
  state: FeatureBuilderState,
@@ -130,6 +141,14 @@ export function buildConfigEventsJobsMethods<TName extends string>(
130
141
  );
131
142
  }
132
143
  }
144
+ const owner = shape?.[normalized.ownerField];
145
+ if (normalized.whenAbsent === undefined && owner !== undefined && isAbsentable(owner)) {
146
+ throw new Error(
147
+ `[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" is owned by "${normalized.ownerField}", which the payload schema allows to be null/undefined. ` +
148
+ `Declare what happens then: { personal: { of: "${normalized.ownerField}", whenAbsent: "tenant" } } encrypts under the envelope tenant key, ` +
149
+ `whenAbsent: "plaintext" acknowledges that the value ships unencrypted and cannot be crypto-shredded (fw#2776).`,
150
+ );
151
+ }
133
152
  }
134
153
  }
135
154
 
@@ -13,6 +13,7 @@ export {
13
13
  export {
14
14
  collectWriteHandlerQns,
15
15
  SECURITY_BASELINE_FEATURE_NAMES,
16
+ type ValidateBootOptions,
16
17
  validateAppCustomScreenWriteQns,
17
18
  validateBoot,
18
19
  } from "./boot-validator";
@@ -266,6 +267,7 @@ export {
266
267
  isWriteFormEditSection,
267
268
  normalizeEditField,
268
269
  normalizeListColumn,
270
+ sectionFieldSpecs,
269
271
  } from "./screen-helpers";
270
272
  export type { TransitionGraph } from "./state-machine";
271
273
  export { defineTransitions, guardTransition } from "./state-machine";
@@ -130,6 +130,15 @@ export function isKebabSegment(name: string): boolean {
130
130
  return QN_SEGMENT.test(name);
131
131
  }
132
132
 
133
+ // Owning scope of a qualified name — the segment before the first ":".
134
+ // Deliberately lenient where parseQn() throws: callers here hold names that
135
+ // may be unqualified (system consumers, ad-hoc jobs) and want undefined, not
136
+ // an exception.
137
+ export function qnScope(qualifiedName: string): string | undefined {
138
+ const idx = qualifiedName.indexOf(":");
139
+ return idx > 0 ? qualifiedName.slice(0, idx) : undefined;
140
+ }
141
+
133
142
  // Build a fully-qualified entity name from a feature name + QN type + short
134
143
  // name, running both names through toKebab first. This is the canonical
135
144
  // "how the registry qualifies things" helper — both createRegistry and
@@ -112,6 +112,7 @@ export type FieldsOrGroupsSection = {
112
112
  // one runs after the fields-XOR-groups check, collectors run without it and must
113
113
  // not drop a source (fw#2986).
114
114
  export function sectionFieldSpecs(section: FieldsOrGroupsSection): readonly EditFieldSpec[] {
115
+ // kumiko-lint-ignore section-fields-raw this IS the union reader — the groups source is added on the same line
115
116
  return [...section.fields, ...(section.groups?.flatMap((group) => group.fields) ?? [])];
116
117
  }
117
118
 
@@ -42,7 +42,7 @@ const BIDX_KEY = Buffer.alloc(32, 5).toString("base64");
42
42
  const contactEntity = createEntity({
43
43
  fields: {
44
44
  email: createTextField({ required: true, personal: "self", find: "exact" }),
45
- displayName: createTextField(),
45
+ displayName: createTextField({ personal: false, reason: "test_fixture" }),
46
46
  },
47
47
  });
48
48
  const contactTable = buildEntityTable("contact", contactEntity);
@@ -56,12 +56,20 @@ const crmFeature = defineFeature("crm", (r) => {
56
56
  // hand-written, so a camelCase event name (tenantNote → tenant-note) can
57
57
  // never drift from what the catalog is actually keyed by (fw#2801).
58
58
  let PING_EVENT_TYPE: string;
59
+ let PING_TENANT_EVENT_TYPE: string;
60
+ const pingSchema = z.object({
61
+ targetId: z.string().nullable(),
62
+ address: z.string().nullable(),
63
+ });
59
64
  const mailerFeature = defineFeature("mailer", (r) => {
60
- PING_EVENT_TYPE = r.defineEvent(
61
- "ping",
62
- z.object({ targetId: z.string().nullable(), address: z.string().nullable() }),
63
- { piiFields: { address: { subjectField: "targetId" } } },
64
- ).name;
65
+ // targetId is nullable, so the stance has to say what happens without an
66
+ // owner — the two events below cover both declarations (fw#2776).
67
+ PING_EVENT_TYPE = r.defineEvent("ping", pingSchema, {
68
+ piiFields: { address: { personal: { of: "targetId", whenAbsent: "plaintext" } } },
69
+ }).name;
70
+ PING_TENANT_EVENT_TYPE = r.defineEvent("pingTenant", pingSchema, {
71
+ piiFields: { address: { personal: { of: "targetId", whenAbsent: "tenant" } } },
72
+ }).name;
65
73
  });
66
74
 
67
75
  // fw#2801: custom events declaring a tenant/self subject — no entity,
@@ -82,7 +90,7 @@ const signalsFeature = defineFeature("signals", (r) => {
82
90
  // absent from an old event's payload, unlike contact.email (self-subject via row id).
83
91
  const noteEntity = createEntity({
84
92
  fields: {
85
- authorId: createTextField(),
93
+ authorId: createTextField({ personal: false, reason: "test_fixture" }),
86
94
  body: createTextField({ personal: { of: "authorId" }, find: "none" }),
87
95
  },
88
96
  });
@@ -272,7 +280,7 @@ describe("backfillEventPiiEncryption", () => {
272
280
  >;
273
281
  expect(erased["address"]).toBe(PII_ERASED_SENTINEL);
274
282
 
275
- // No subject → no key to shred; stays plaintext (documented rollout gap).
283
+ // No subject and whenAbsent: "plaintext" → declared, stays plaintext.
276
284
  const system = (await loadAggregate(testDb.db, p3, TENANT))[0]?.payload as Record<
277
285
  string,
278
286
  unknown
@@ -280,6 +288,22 @@ describe("backfillEventPiiEncryption", () => {
280
288
  expect(system["address"]).toBe("ops@x.com");
281
289
  });
282
290
 
291
+ test('an absent owner with whenAbsent: "tenant" backfills under the tenant key (fw#2776)', async () => {
292
+ const p4 = generateId();
293
+ await appendPlain(p4, "ping", PING_TENANT_EVENT_TYPE, {
294
+ targetId: null,
295
+ address: "ops@x.com",
296
+ });
297
+
298
+ armKms();
299
+ const result = await backfillEventPiiEncryption(testDb.db, registry);
300
+ expect(result.failures).toEqual([]);
301
+
302
+ const row = (await loadAggregate(testDb.db, p4, TENANT))[0]?.payload as Record<string, unknown>;
303
+ expect(isPiiCiphertext(row["address"])).toBe(true);
304
+ expect(String(row["address"])).toContain(`tenant:${TENANT}`);
305
+ });
306
+
283
307
  test("idempotent: second run updates nothing; dryRun writes nothing", async () => {
284
308
  const c1 = generateId();
285
309
  await appendPlain(c1, "contact", "contact.created", { id: c1, email: "a@x.com" });