@cosmicdrift/kumiko-framework 0.386.0 → 0.387.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 (288) hide show
  1. package/LICENSE +21 -57
  2. package/README.md +2 -2
  3. package/agents/widgets.md +39 -39
  4. package/dist/api/api-constants.js +2 -2
  5. package/dist/api/auth-middleware.js +7 -7
  6. package/dist/api/auth-rate-limiters.js +7 -3
  7. package/dist/api/auth-routes-mfa.js +1 -1
  8. package/dist/api/auth-routes-session.js +1 -1
  9. package/dist/api/auth-routes-shared.js +7 -3
  10. package/dist/api/auth-routes-signup.js +1 -1
  11. package/dist/api/client-ip.js +3 -1
  12. package/dist/api/extra-route.js +1 -1
  13. package/dist/api/pii-leak-guard.js +4 -2
  14. package/dist/api/readiness.js +5 -5
  15. package/dist/api/redis-sse-broker.js +8 -6
  16. package/dist/api/routes.js +2 -2
  17. package/dist/api/server.js +34 -33
  18. package/dist/api/sse-broker.js +1 -1
  19. package/dist/api/sse-route.d.ts +13 -13
  20. package/dist/api/sse-route.js +15 -15
  21. package/dist/auth/index.js +4 -4
  22. package/dist/auth/roles.d.ts +4 -4
  23. package/dist/auth/roles.js +24 -24
  24. package/dist/compliance/duration-spec.js +18 -20
  25. package/dist/compliance/index.js +2 -3
  26. package/dist/compliance/override-schema.d.ts +4 -4
  27. package/dist/compliance/override-schema.js +19 -22
  28. package/dist/compliance/profiles.d.ts +26 -29
  29. package/dist/compliance/profiles.js +62 -69
  30. package/dist/compliance/sub-processors.d.ts +28 -30
  31. package/dist/compliance/sub-processors.js +17 -21
  32. package/dist/consumer-cli.js +4 -4
  33. package/dist/crypto/blind-index.js +18 -17
  34. package/dist/crypto/ciphertext-pattern.js +1 -1
  35. package/dist/crypto/event-pii.js +3 -3
  36. package/dist/crypto/kek-source.d.ts +1 -1
  37. package/dist/crypto/kek-source.js +3 -2
  38. package/dist/crypto/kms-adapter.d.ts +1 -1
  39. package/dist/crypto/kms-adapter.js +4 -6
  40. package/dist/crypto/kms-wiring.d.ts +1 -2
  41. package/dist/crypto/kms-wiring.js +2 -2
  42. package/dist/crypto/pg-kms-adapter.js +2 -6
  43. package/dist/crypto/pii-field-encryption.js +4 -4
  44. package/dist/crypto/subject-resolver.d.ts +1 -1
  45. package/dist/crypto/subject-resolver.js +2 -2
  46. package/dist/db/apply-entity-event.d.ts +6 -6
  47. package/dist/db/apply-entity-event.js +55 -55
  48. package/dist/db/blind-index-cleanup.js +7 -7
  49. package/dist/db/collect-table-metas.js +10 -11
  50. package/dist/db/compound-types.d.ts +1 -1
  51. package/dist/db/compound-types.js +12 -12
  52. package/dist/db/cursor.js +3 -4
  53. package/dist/db/dialect.d.ts +2 -2
  54. package/dist/db/dialect.js +2 -2
  55. package/dist/db/eagerload.d.ts +3 -3
  56. package/dist/db/eagerload.js +21 -12
  57. package/dist/db/entity-table-meta.d.ts +2 -2
  58. package/dist/db/entity-table-meta.js +4 -4
  59. package/dist/db/event-store-executor-context.d.ts +1 -1
  60. package/dist/db/event-store-executor-context.js +13 -13
  61. package/dist/db/event-store-executor-read.js +27 -28
  62. package/dist/db/event-store-executor-write.js +65 -70
  63. package/dist/db/located-timestamp.d.ts +11 -11
  64. package/dist/db/located-timestamp.js +29 -30
  65. package/dist/db/migrate-generator.d.ts +1 -1
  66. package/dist/db/migrate-generator.js +26 -19
  67. package/dist/db/migrate-runner.js +25 -25
  68. package/dist/db/money.d.ts +6 -6
  69. package/dist/db/money.js +29 -27
  70. package/dist/db/queries/backfill-pii.js +5 -5
  71. package/dist/db/queries/ddl.js +1 -1
  72. package/dist/db/queries/es-ops.d.ts +1 -1
  73. package/dist/db/queries/es-ops.js +1 -1
  74. package/dist/db/queries/event-consumer.js +3 -3
  75. package/dist/db/queries/event-store.js +4 -2
  76. package/dist/db/queries/projection-rebuild.js +3 -3
  77. package/dist/db/queries/raw-sql.d.ts +2 -2
  78. package/dist/db/queries/raw-sql.js +2 -2
  79. package/dist/db/queries/shadow-swap.js +19 -19
  80. package/dist/db/query.d.ts +1 -1
  81. package/dist/db/query.js +6 -6
  82. package/dist/db/rebuild-marker.js +20 -21
  83. package/dist/db/replay-migration-sql.js +2 -2
  84. package/dist/db/schema-inspection.js +4 -5
  85. package/dist/db/table-builder.d.ts +1 -2
  86. package/dist/db/table-builder.js +82 -82
  87. package/dist/db/tenant-db.d.ts +2 -2
  88. package/dist/db/tenant-db.js +9 -2
  89. package/dist/derivatives/delete-stored-file.js +5 -2
  90. package/dist/derivatives/derivatives-context.js +1 -1
  91. package/dist/engine/boot-validator/access-roles.js +4 -3
  92. package/dist/engine/boot-validator/api-ext.js +14 -14
  93. package/dist/engine/boot-validator/config-deps.js +7 -6
  94. package/dist/engine/boot-validator/detail-screens.js +3 -3
  95. package/dist/engine/boot-validator/entity-handler.js +60 -61
  96. package/dist/engine/boot-validator/gdpr-storage.js +4 -4
  97. package/dist/engine/boot-validator/i18n-keys.js +1 -1
  98. package/dist/engine/boot-validator/index.d.ts +2 -2
  99. package/dist/engine/boot-validator/index.js +20 -20
  100. package/dist/engine/boot-validator/nav.js +5 -5
  101. package/dist/engine/boot-validator/ownership.js +12 -9
  102. package/dist/engine/boot-validator/parent-ref.js +1 -1
  103. package/dist/engine/boot-validator/pii-retention.js +50 -57
  104. package/dist/engine/boot-validator/projection-list-screens.js +7 -7
  105. package/dist/engine/boot-validator/query-refs.js +1 -1
  106. package/dist/engine/boot-validator/screens-actions.js +15 -16
  107. package/dist/engine/boot-validator/screens-forms.js +10 -11
  108. package/dist/engine/boot-validator/screens-lists.js +14 -15
  109. package/dist/engine/boot-validator/screens.js +34 -40
  110. package/dist/engine/boot-validator/security-baseline.js +3 -2
  111. package/dist/engine/boot-validator/transfer-graph.js +2 -2
  112. package/dist/engine/boot-validator/workspaces.js +3 -3
  113. package/dist/engine/bridge-stub.js +3 -4
  114. package/dist/engine/build-app-schema.js +75 -75
  115. package/dist/engine/build-config-feature-schema.js +50 -50
  116. package/dist/engine/build-target.js +20 -23
  117. package/dist/engine/codemod/pipeline-codemod.js +20 -13
  118. package/dist/engine/config-helpers.js +4 -4
  119. package/dist/engine/constants.js +2 -2
  120. package/dist/engine/create-app.js +2 -2
  121. package/dist/engine/define-feature.js +12 -11
  122. package/dist/engine/define-handler.js +12 -11
  123. package/dist/engine/define-step.js +1 -1
  124. package/dist/engine/define-workflow.d.ts +5 -5
  125. package/dist/engine/define-workflow.js +6 -6
  126. package/dist/engine/entity-handlers.js +13 -14
  127. package/dist/engine/event-def.js +2 -2
  128. package/dist/engine/extension-names.d.ts +16 -17
  129. package/dist/engine/extension-names.js +37 -38
  130. package/dist/engine/extensions/escape-hatch-usage.js +1 -1
  131. package/dist/engine/extensions/tenant-data.js +2 -2
  132. package/dist/engine/extensions/user-data.d.ts +23 -24
  133. package/dist/engine/factories.d.ts +14 -13
  134. package/dist/engine/factories.js +38 -37
  135. package/dist/engine/feature-ast/entity-field-types.js +1 -1
  136. package/dist/engine/feature-ast/extractors/handlers.js +1 -1
  137. package/dist/engine/feature-ast/extractors/hooks.js +1 -1
  138. package/dist/engine/feature-ast/extractors/shared.d.ts +2 -3
  139. package/dist/engine/feature-ast/extractors/shared.js +2 -3
  140. package/dist/engine/feature-ast/parse.js +13 -13
  141. package/dist/engine/feature-ast/patch.js +1 -1
  142. package/dist/engine/feature-ast/pattern-change-schema.js +1 -1
  143. package/dist/engine/feature-ast/patterns.d.ts +2 -2
  144. package/dist/engine/feature-ast/render.js +2 -2
  145. package/dist/engine/feature-config-events-jobs.js +15 -12
  146. package/dist/engine/feature-entity-handlers.js +9 -5
  147. package/dist/engine/feature-manifest.d.ts +9 -9
  148. package/dist/engine/feature-manifest.js +6 -7
  149. package/dist/engine/feature-ui-extensions.d.ts +3 -3
  150. package/dist/engine/feature-ui-extensions.js +30 -22
  151. package/dist/engine/field-access.js +5 -6
  152. package/dist/engine/index.d.ts +1 -0
  153. package/dist/engine/index.js +4 -4
  154. package/dist/engine/object-form.d.ts +1 -0
  155. package/dist/engine/object-form.js +14 -4
  156. package/dist/engine/ownership.js +3 -3
  157. package/dist/engine/parse-ref-target.js +5 -7
  158. package/dist/engine/pattern-library/index.js +2 -2
  159. package/dist/engine/pattern-library/library.js +2 -2
  160. package/dist/engine/pattern-library/types.js +3 -3
  161. package/dist/engine/pipeline.js +2 -2
  162. package/dist/engine/project-app-schema-for-roles.js +1 -1
  163. package/dist/engine/projection-helpers.js +9 -9
  164. package/dist/engine/qualified-name.js +2 -2
  165. package/dist/engine/registry-facade.js +1 -1
  166. package/dist/engine/registry-ingest.js +12 -12
  167. package/dist/engine/registry-state.js +17 -19
  168. package/dist/engine/registry-validate.js +12 -12
  169. package/dist/engine/run-in.js +2 -2
  170. package/dist/engine/run-pipeline.js +7 -0
  171. package/dist/engine/schema-builder.js +40 -38
  172. package/dist/engine/screen-filter-ops.js +17 -20
  173. package/dist/engine/screen-helpers.js +4 -3
  174. package/dist/engine/state-machine.d.ts +12 -12
  175. package/dist/engine/state-machine.js +7 -7
  176. package/dist/engine/steps/_resolver-utils.js +4 -3
  177. package/dist/engine/steps/for-each.js +4 -4
  178. package/dist/engine/steps/unsafe-projection-delete.js +1 -1
  179. package/dist/engine/steps/unsafe-projection-upsert.js +4 -4
  180. package/dist/engine/steps/wait-for-event.js +4 -4
  181. package/dist/engine/steps/workflow-system-events.js +3 -3
  182. package/dist/engine/tier-resolver-extension.d.ts +29 -27
  183. package/dist/engine/tier-resolver-extension.js +26 -26
  184. package/dist/engine/transfer-adjacency.js +2 -2
  185. package/dist/engine/validate-projection-allowlist.js +7 -27
  186. package/dist/engine/walk-step-instances.d.ts +2 -0
  187. package/dist/engine/walk-step-instances.js +38 -0
  188. package/dist/engine/where-rule-lint.js +1 -1
  189. package/dist/engine/write-handler-qn-extract.js +1 -1
  190. package/dist/entrypoint/index.js +3 -3
  191. package/dist/env/index.d.ts +7 -6
  192. package/dist/env/index.js +10 -9
  193. package/dist/errors/transition-details.js +7 -7
  194. package/dist/errors/write-error-info.d.ts +6 -6
  195. package/dist/errors/write-error-info.js +6 -6
  196. package/dist/es-ops/context.d.ts +9 -9
  197. package/dist/es-ops/context.js +28 -28
  198. package/dist/es-ops/index.js +7 -9
  199. package/dist/es-ops/operations-schema.js +4 -10
  200. package/dist/es-ops/runner.d.ts +11 -11
  201. package/dist/es-ops/runner.js +49 -53
  202. package/dist/es-ops/types.d.ts +55 -55
  203. package/dist/es-ops/types.js +11 -11
  204. package/dist/event-store/admin-api.js +6 -7
  205. package/dist/event-store/errors.js +1 -1
  206. package/dist/event-store/event-store.js +2 -2
  207. package/dist/event-store/rebuild-dead-letter.js +1 -1
  208. package/dist/files/feature.js +9 -10
  209. package/dist/files/file-handle.js +5 -6
  210. package/dist/files/file-ref-entity.js +15 -15
  211. package/dist/files/file-ref-table.js +7 -8
  212. package/dist/files/file-routes.d.ts +7 -0
  213. package/dist/files/file-routes.js +11 -5
  214. package/dist/files/in-memory-provider.js +8 -9
  215. package/dist/files/local-provider.js +18 -25
  216. package/dist/files/provider-resolver.d.ts +7 -7
  217. package/dist/files/storage-tracking.js +2 -2
  218. package/dist/files/zip-stream.d.ts +12 -19
  219. package/dist/files/zip-stream.js +44 -63
  220. package/dist/i18n/required-surface-keys.d.ts +1 -1
  221. package/dist/i18n/required-surface-keys.js +8 -8
  222. package/dist/jobs/job-runner-helpers.js +8 -2
  223. package/dist/jobs/job-runner-schedulers.d.ts +1 -1
  224. package/dist/jobs/job-runner-schedulers.js +3 -4
  225. package/dist/jobs/job-runner.js +13 -15
  226. package/dist/lifecycle/lifecycle.js +3 -5
  227. package/dist/lifecycle/signal-handlers.js +5 -4
  228. package/dist/logging/utils.d.ts +2 -1
  229. package/dist/logging/utils.js +38 -10
  230. package/dist/migrations/index.js +1 -1
  231. package/dist/migrations/kumiko-drift.js +14 -13
  232. package/dist/migrations/pending-rebuilds.d.ts +21 -20
  233. package/dist/migrations/pending-rebuilds.js +27 -27
  234. package/dist/migrations/projection-table-index.d.ts +3 -3
  235. package/dist/migrations/projection-table-index.js +8 -8
  236. package/dist/observability/metric-validator.js +1 -1
  237. package/dist/observability/metrics-handle.js +3 -3
  238. package/dist/pipeline/append-event-core.js +11 -2
  239. package/dist/pipeline/cascade-handler.d.ts +1 -1
  240. package/dist/pipeline/dispatch-batch.js +2 -2
  241. package/dist/pipeline/dispatch-query.js +10 -10
  242. package/dist/pipeline/dispatch-shared.d.ts +1 -0
  243. package/dist/pipeline/dispatch-shared.js +15 -17
  244. package/dist/pipeline/dispatch-stream.js +1 -1
  245. package/dist/pipeline/dispatch-write.js +3 -3
  246. package/dist/pipeline/dispatcher-utils.d.ts +1 -1
  247. package/dist/pipeline/dispatcher-utils.js +1 -1
  248. package/dist/pipeline/event-consumer-state.js +2 -2
  249. package/dist/pipeline/event-dispatcher-delivery.js +1 -1
  250. package/dist/pipeline/event-dispatcher.js +17 -28
  251. package/dist/pipeline/irreversible-operation-gate.js +1 -1
  252. package/dist/pipeline/lifecycle-pipeline.js +9 -9
  253. package/dist/pipeline/member-read-only-transaction.js +1 -3
  254. package/dist/pipeline/msp-rebuild.js +1 -1
  255. package/dist/pipeline/projection-rebuild.js +13 -12
  256. package/dist/pipeline/system-hooks.js +39 -41
  257. package/dist/pipeline/system-identity-switch.js +1 -3
  258. package/dist/pipeline/tenant-timezone-cache.js +1 -1
  259. package/dist/random/generate.d.ts +29 -29
  260. package/dist/random/generate.js +30 -30
  261. package/dist/random/words.js +20 -20
  262. package/dist/rate-limit/middleware.js +9 -5
  263. package/dist/redis/client.js +2 -2
  264. package/dist/redis/pubsub-signal.js +14 -17
  265. package/dist/schema-cli.js +116 -22
  266. package/dist/search/index.js +4 -4
  267. package/dist/search/purge-subject.js +4 -5
  268. package/dist/search/reindex-entity.js +6 -9
  269. package/dist/time/boot-tz-warning.d.ts +2 -2
  270. package/dist/time/boot-tz-warning.js +8 -8
  271. package/dist/time/iana.js +10 -10
  272. package/dist/time/index.js +9 -9
  273. package/dist/time/legacy-date.d.ts +2 -2
  274. package/dist/time/legacy-date.js +6 -8
  275. package/dist/time/polyfill.js +1 -1
  276. package/dist/time/tz-context.d.ts +1 -1
  277. package/dist/time/tz-context.js +1 -1
  278. package/dist/ui-types/app-schema.js +7 -7
  279. package/dist/ui-types/list-row-meta.js +4 -4
  280. package/dist/upgrade-cli.js +10 -9
  281. package/dist/utils/compare.js +1 -1
  282. package/package.json +4 -4
  283. package/src/changes.json +48 -0
  284. package/src/scripts/codemod/crypto-shredding-testing-move.ts +2 -2
  285. package/src/scripts/codemod/migrate-cross-tenant.ts +1 -1
  286. package/src/scripts/codemod/migrate-open-to-all.ts +1 -1
  287. package/src/scripts/codemod/pii-personal-migration.ts +17 -8
  288. package/src/scripts/codemod/unprocessable-error-details-reason.ts +2 -2
@@ -3,23 +3,23 @@ import { tableNameOf, } from "../ownership.js";
3
3
  import { SYSTEM_USER_ID } from "../system-user.js";
4
4
  import { SYSTEM_TENANT_ID } from "../types/identifiers.js";
5
5
  import { assertQualifiedWhereFragment, tableColumnSqlNames } from "../where-rule-lint.js";
6
- // --- Ownership rule validation (H.2) ---
6
+ // --- Ownership rule validation ---
7
7
  //
8
8
  // Walks every entity.access and every field.access map, resolves each
9
9
  // FromRule against the cross-feature claim registry, and confirms the
10
10
  // referenced column exists on the entity. Catches typos, renames, and
11
11
  // cross-feature-claim-removal scenarios at boot instead of at request time.
12
12
  //
13
- // fw#2639: also probes every `{ kind: "where" }` rule against the where-rule
13
+ // Also probes every `{ kind: "where" }` rule against the where-rule
14
14
  // lint (see ../where-rule-lint.ts) so a fail-open unqualified-column bug
15
15
  // fails the boot instead of waiting for a request from the right role.
16
16
  //
17
17
  // Field-level `access.read` rejects where-rules too: filterFieldsForRead
18
18
  // evaluates it in memory and userCanReadFieldRow fails closed on a where-rule.
19
19
  //
20
- // fw#2626: `access.write` maps reject `{ kind: "where" }` outright. A
21
- // where-rule is raw SQL and the write path never reaches SQL — it evaluates
22
- // rules in memory against the concrete row (userCanCreateFieldRow /
20
+ // `access.write` maps reject `{ kind: "where" }` outright. A where-rule is
21
+ // raw SQL and the write path never reaches SQL — it evaluates rules in memory
22
+ // against the concrete row (userCanCreateFieldRow /
23
23
  // userCanWriteFieldRow), and a create has no row to run a predicate against
24
24
  // at all. Such a rule can therefore only ever deny, so the boot fails instead
25
25
  // of shipping an access map that silently locks the role out.
@@ -49,9 +49,8 @@ export function collectClaimKeys(features) {
49
49
  // corpora go in as undefined — a three-feature stack genuinely does not know
50
50
  // which roles and claims the whole app declares, and answering that question
51
51
  // from a slice produces false "unknown role"/"unknown claim" errors. The
52
- // subset-invariant checks (#2639 where-lint, #2626 where-on-write, unknown
53
- // column) run in full: those hold or fail per feature, whatever else is
54
- // mounted.
52
+ // subset-invariant checks (where-lint, where-on-write, unknown column) run in
53
+ // full: those hold or fail per feature, whatever else is mounted.
55
54
  export function validateOwnershipBoot(features) {
56
55
  for (const feature of features) {
57
56
  validateOwnershipRules(feature, undefined, undefined);
@@ -258,12 +257,16 @@ export function probeWhereRule(rule, roleName, probe, scope, featureName) {
258
257
  return;
259
258
  assertQualifiedWhereFragment(fragment.sqlText, probe.columns, `${scope} (role "${roleName}", feature: "${featureName}")`);
260
259
  }
260
+ // Array.isArray does not narrow a readonly array out of a union.
261
+ function isRoleList(access) {
262
+ return Array.isArray(access);
263
+ }
261
264
  // Embedded sub-fields are filtered in memory too (filterFieldsForRead), so a
262
265
  // where-rule there is just as unevaluable as on a top-level field.
263
266
  function rejectWhereInEmbeddedRead(schema, parentScope, featureName) {
264
267
  for (const [subName, sub] of Object.entries(schema)) {
265
268
  const map = sub.access?.read;
266
- if (!map || Array.isArray(map))
269
+ if (!map || isRoleList(map))
267
270
  continue;
268
271
  for (const [roleName, rule] of Object.entries(map)) {
269
272
  if (rule !== "all" && rule.kind === "where") {
@@ -1,5 +1,5 @@
1
1
  import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
2
- // --- Parent-ref boot validation (fw#2766) ---
2
+ // --- Parent-ref boot validation ---
3
3
  //
4
4
  // Join-row entities (EntityDefinition.parentRef) derive both the write-path
5
5
  // deny and the read-path SQL gate from one declaration. This is the boot-time
@@ -1,16 +1,17 @@
1
+ import { createFallbackLogger } from "../../logging/utils.js";
1
2
  import { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./entity-handler.js";
2
- // Framework-managed Timestamp-Spalten — dürfen als retention.reference
3
- // genutzt werden auch wenn nicht in entity.fields deklariert.
3
+ const log = createFallbackLogger("kumiko:boot");
4
+ // Framework-managed timestamp fields may be used as retention.reference
5
+ // even when they are not declared in entity.fields.
4
6
  const FRAMEWORK_TIMESTAMP_FIELDS = new Set([
5
7
  "createdAt",
6
8
  "updatedAt",
7
9
  "lastSeenAt",
8
10
  "deletedAt",
9
11
  ]);
10
- // Erlaubtes Format fuer retention.keepFor — Zahlen + Suffix (h/d/w/m/y).
11
- // Echtes Parsen kommt mit dem Cleanup-Job in Sprint 2; Boot-Validator
12
- // macht nur den Sanity-Check damit Tippfehler ("30days") frueh sichtbar
13
- // werden statt erst beim ersten Cleanup-Run.
12
+ // Allowed format for retention.keepFor: a number followed by a suffix (h/d/w/m/y).
13
+ // The cleanup job performs the actual parsing; the boot validator only checks
14
+ // the format so typos ("30days") become visible before the first cleanup run.
14
15
  const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
15
16
  const FILE_FIELD_TYPES = new Set([
16
17
  "file",
@@ -19,7 +20,7 @@ const FILE_FIELD_TYPES = new Set([
19
20
  "images",
20
21
  ]);
21
22
  // Excludes subjectRef: its `personal: "ref"` union member structurally
22
- // forbids `anonymize` (packages/types/src/fields.ts) — #2336.
23
+ // forbids `anonymize` (packages/types/src/fields.ts).
23
24
  function hasAnonymizableSubjectField(annot) {
24
25
  return Boolean(annot.pii || annot.userOwned || annot.tenantOwned || annot.recordOwned);
25
26
  }
@@ -100,28 +101,28 @@ function validatePersonalJsonbField(featureName, entityName, entity, fieldName,
100
101
  }
101
102
  // --- PII / Subject-Key Annotations + Retention validation ---
102
103
  //
103
- // Drei Klassen von Checks:
104
+ // Four classes of checks:
104
105
  //
105
- // 1. Mutual exclusion: pro Field nur EINE der drei Subject-Annotations
106
- // (pii / userOwned / tenantOwned). Mehr ist semantisch widersprüchlich
107
- // weil pro Field genau ein Subject-Key gehört.
106
+ // 1. Mutual exclusion: each field may have only ONE subject annotation
107
+ // (pii / userOwned / tenantOwned). Multiple annotations are semantically
108
+ // contradictory because each field belongs to exactly one subject key.
108
109
  //
109
- // 2. Reference-Integrity: userOwned.ownerField muss auf ein existierendes
110
- // reference-Field zeigen (das auf user-Entity zeigen sollte). Erkennt
111
- // Tippfehler und Drop-Refactorings beim Boot statt beim ersten
112
- // Encrypt-Aufruf.
110
+ // 2. Reference integrity: userOwned.ownerField must refer to an existing
111
+ // reference field that should target the user entity. This catches typos
112
+ // and refactors that removed a field during boot instead of at encryption.
113
113
  //
114
- // 3. Heuristik-Warnings: Field-Namen die typischerweise PII enthalten
115
- // (email, name, phone, body, etc.) ohne Annotation → Boot-Warning.
116
- // Mit `{ personal: false, reason: "<reason>" }` unterdrückbar (geht in Audit).
114
+ // 3. Heuristic warnings: field names that typically contain PII
115
+ // (email, name, phone, body, etc.) without an annotation produce a boot
116
+ // warning. `{ personal: false, reason: "<reason>" }` suppresses it and
117
+ // records the reason in the audit.
117
118
  //
118
- // 4. Retention-Integrity: retention.reference (wenn gesetzt) muss auf
119
- // ein bestehendes Field zeigen (oder Framework-Timestamp). retention.
120
- // strategy="blockDelete" ohne anonymize-Felder ist sinnlos — User-
121
- // Forget kann nichts machen, Warning.
119
+ // 4. Retention integrity: retention.reference, when set, must refer to an
120
+ // existing field or a framework timestamp. retention.strategy="blockDelete"
121
+ // without anonymizable fields is ineffective because user forget cannot
122
+ // remove anything, so it produces a warning.
122
123
  //
123
- // Encrypt/Decrypt-Mechanik landet in Sprint 3 (crypto-shredding); diese
124
- // Validation greift schon ab Sprint 0 damit Schema-Drift früh auffällt.
124
+ // Encryption and decryption are handled elsewhere; this validation runs early
125
+ // so schema drift is detected during boot.
125
126
  export function validatePiiAndRetention(feature) {
126
127
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
127
128
  const fieldsByName = entity.fields;
@@ -150,9 +151,9 @@ export function validatePiiAndRetention(feature) {
150
151
  !annot.subjectRef) {
151
152
  throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" (type "${field.type}") declares no personal stance. Build it with createTextField / createLongTextField and { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false }; false additionally needs { reason: "..." } stating why the value is not personal data.`);
152
153
  }
153
- // lookupable (#818): nur text + nur MIT Subject-Annotation. Ohne
154
- // Subject bleibt der Wert eh Klartext — der Blind-Index wäre eine
155
- // zweite (deterministische!) Kopie ohne Nutzen.
154
+ // lookupable: only text fields with a subject annotation are supported. Without
155
+ // a subject, the value remains plaintext, so a blind index would be a
156
+ // second deterministic copy without any benefit.
156
157
  if (annot.lookupable === true) {
157
158
  if (field.type !== "text") {
158
159
  throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares find: "exact" (or "fuzzy") but has type "${field.type}" — blind-index equality lookups only apply to text fields. Use a text field or set find: "none".`);
@@ -161,9 +162,9 @@ export function validatePiiAndRetention(feature) {
161
162
  throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares find: "exact" (or "fuzzy") without a subject annotation (personal: "self" / "tenant" / { of: "<field>" }). Plaintext fields don't need a blind index — add personal or set find: "none".`);
162
163
  }
163
164
  }
164
- // sensitive-Felder liegen seit #967 als Tabellen-Ciphertext im Event-
165
- // Log — ohne ciphertext-at-rest würde der Append Klartext in die
166
- // immutable History schreiben.
165
+ // Sensitive fields are stored as table ciphertext in the event log; without
166
+ // ciphertext at rest, appending the event would write plaintext to the
167
+ // immutable history.
167
168
  const sensitiveFlags = field; // @cast-boundary schema-walk
168
169
  if (sensitiveFlags.sensitive === true &&
169
170
  annotCount === 0 &&
@@ -172,11 +173,10 @@ export function validatePiiAndRetention(feature) {
172
173
  }
173
174
  validateWriteOnlyField(feature.name, entityName, fieldName, field);
174
175
  // Sorting reads the projection column — that stays ciphertext, so
175
- // sortable + subject annotation stays a boot-fail. searchable has
176
- // been allowed since #1610: the search consumer decrypts into the
177
- // derived index and forget purges those docs (see
178
- // createSearchEventConsumer). sensitive + searchable stays forbidden
179
- // (never searchable).
176
+ // sortable + subject annotation stays a boot-fail. searchable is allowed:
177
+ // the search consumer decrypts into the derived index and forget purges
178
+ // those docs (see createSearchEventConsumer). sensitive + searchable stays
179
+ // forbidden (never searchable).
180
180
  {
181
181
  const flags = field; // @cast-boundary schema-walk
182
182
  if (annotCount > 0 && flags.sortable === true) {
@@ -214,42 +214,37 @@ export function validatePiiAndRetention(feature) {
214
214
  if (ownerField.required !== true && whenAbsent === undefined) {
215
215
  throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" is personal: { of: "${ownerName}" }, but "${ownerName}" is not required — rows without an owner would fail to encrypt (SubjectResolutionError, HTTP 500) on update, delete and forget. Declare { personal: { of: "${ownerName}", whenAbsent: "tenant" } } to encrypt those rows under the tenant key, whenAbsent: "self" to encrypt them under the row's own record key (shreddable per record), or make "${ownerName}" required: true. Entity fields have no plaintext fallback (fw#3654).`);
216
216
  }
217
- // Soft-Warning wenn das reference-target nicht offensichtlich user
218
- // ist — custom subject-entities (HR-Mitarbeiter, Patient) sind
219
- // erlaubt, müssen aber bewusste Wahl sein.
217
+ // Warn when the reference target is not obviously a user. Custom subject
218
+ // entities (such as HR employees or patients) are allowed but should
219
+ // be an intentional choice.
220
220
  if (ownerField.type === "reference") {
221
221
  const refTarget = ownerField.entity;
222
222
  const targetEntity = refTarget.includes(":") ? refTarget.split(":")[1] : refTarget;
223
223
  if (targetEntity !== "user") {
224
- // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
225
- console.warn(`[kumiko:boot] [Feature ${feature.name}] personal.of "${ownerName}" on entity "${entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`);
224
+ log.warn(`[Feature ${feature.name}] personal.of "${ownerName}" on entity "${entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`);
226
225
  }
227
226
  }
228
227
  }
229
- // PII-Heuristik: nur wenn keine Annotation gesetzt UND kein
230
- // allowPlaintext-Marker. Ergibt false positives auf Geschäftsdaten
231
- // mit personenartigem Namen (z.B. company.legalName) — Author
232
- // unterdrückt mit { personal: false, reason: "is_business_data" }.
228
+ // PII heuristic: only when no annotation or allowPlaintext marker is set.
229
+ // Business data with person-like names (for example, company.legalName)
230
+ // can produce false positives; authors can suppress them with
231
+ // { personal: false, reason: "is_business_data" }.
233
232
  const noAnnotation = annotCount === 0 && !annot.allowPlaintext;
234
233
  if (noAnnotation) {
235
234
  const lower = fieldName.toLowerCase();
236
235
  if (FILE_FIELD_TYPES.has(field.type)) {
237
- // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
238
- console.warn(`[kumiko:boot] [Feature ${feature.name}] File field "${fieldName}" on entity "${entityName}" has no personal annotation. A forget with strategy "delete" then only severs the uploader link and keeps the binary. If the file holds personal data, mark it { personal: "self" } or { personal: { of: "<ownerField>" } }. If it is business data, set { personal: false, reason: "is_business_data" } to silence.`);
236
+ log.warn(`[Feature ${feature.name}] File field "${fieldName}" on entity "${entityName}" has no personal annotation. A forget with strategy "delete" then only severs the uploader link and keeps the binary. If the file holds personal data, mark it { personal: "self" } or { personal: { of: "<ownerField>" } }. If it is business data, set { personal: false, reason: "is_business_data" } to silence.`);
239
237
  }
240
238
  else if (PII_DIRECT_NAME_HINTS.has(lower)) {
241
- // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
242
- console.warn(`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a PII-typical name but no personal annotation. If this is PII, mark it { personal: "self", find: ... }. If business data, set { personal: false, reason: "is_business_data" } to silence.`);
239
+ log.warn(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a PII-typical name but no personal annotation. If this is PII, mark it { personal: "self", find: ... }. If business data, set { personal: false, reason: "is_business_data" } to silence.`);
243
240
  }
244
241
  else if (PII_USER_OWNED_NAME_HINTS.has(lower)) {
245
- // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
246
- console.warn(`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-content-typical name but no personal annotation. If this contains user-generated content, mark it { personal: { of: "<authorIdField>" }, find: ... }. If business data, set { personal: false, reason: "..." } to silence.`);
242
+ log.warn(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-content-typical name but no personal annotation. If this contains user-generated content, mark it { personal: { of: "<authorIdField>" }, find: ... }. If business data, set { personal: false, reason: "..." } to silence.`);
247
243
  }
248
244
  else if (PII_USER_REFERENCE_NAME_HINTS.has(lower) &&
249
245
  !annot.subjectRef &&
250
246
  !personalOfOwnerFieldNames.has(fieldName)) {
251
- // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
252
- console.warn(`[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.`);
247
+ log.warn(`[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.`);
253
248
  }
254
249
  }
255
250
  }
@@ -257,8 +252,7 @@ export function validatePiiAndRetention(feature) {
257
252
  const retention = entity.retention;
258
253
  if (retention) {
259
254
  if (!KEEP_FOR_PATTERN.test(retention.keepFor)) {
260
- // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
261
- console.warn(`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.keepFor="${retention.keepFor}" has an invalid format. Expected: <number><h|d|w|m|y> (e.g. "30d", "10y", "6m"). The cleanup job cannot parse anything else; fix keepFor.`);
255
+ log.warn(`[Feature ${feature.name}] Entity "${entityName}" retention.keepFor="${retention.keepFor}" has an invalid format. Expected: <number><h|d|w|m|y> (e.g. "30d", "10y", "6m"). The cleanup job cannot parse anything else; fix keepFor.`);
262
256
  }
263
257
  if (retention.reference !== undefined) {
264
258
  const refName = retention.reference;
@@ -271,15 +265,14 @@ export function validatePiiAndRetention(feature) {
271
265
  if (retention.strategy === "blockDelete") {
272
266
  // blockDelete with no field that can carry `anonymize` is fine;
273
267
  // subjectRef-only entities are covered by the V3 EXT_USER_DATA guard
274
- // instead (#1622, #2336).
268
+ // instead.
275
269
  const entityHasAnonymizableField = entityHasAnonymizableSubjectField(fieldsByName);
276
270
  const hasAnonymize = Object.values(fieldsByName).some((f) => {
277
271
  const a = f; // @cast-boundary schema-walk
278
272
  return Boolean(a.anonymize);
279
273
  });
280
274
  if (entityHasAnonymizableField && !hasAnonymize) {
281
- // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
282
- console.warn(`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function. Once keepFor expires, the data-retention cron anonymizes nothing and the row keeps its PII. Add { anonymize: () => null } or () => "[ANONYMIZED]" to PII fields.`);
275
+ log.warn(`[Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function. Once keepFor expires, the data-retention cron anonymizes nothing and the row keeps its PII. Add { anonymize: () => null } or () => "[ANONYMIZED]" to PII fields.`);
283
276
  }
284
277
  }
285
278
  }
@@ -10,7 +10,7 @@ import { getZodObjectShape } from "./zod-shape.js";
10
10
  // reaches into feature.entities[screen.entity] — a projectionList has no
11
11
  // entity, so sharing the function would mean threading a discriminated
12
12
  // union through every entity-bound helper it calls.
13
- // Exported for query-refs.ts (fw#2178) — one source for the QN-derivation
13
+ // Exported for query-refs.ts — one source for the QN-derivation
14
14
  // logic instead of duplicating it in both boot-validators.
15
15
  export function buildQueryHandlerMap(features) {
16
16
  const out = new Map();
@@ -29,7 +29,7 @@ function schemaAccepts(schema, key) {
29
29
  return shape !== undefined && key in shape;
30
30
  }
31
31
  // sortable/paginated are derived by buildAppSchema from the query's Zod
32
- // schema (fw#2165) — there is no separate wire type from the author-facing
32
+ // schema — there is no separate wire type from the author-facing
33
33
  // ProjectionListScreenDefinition, so hand-authoring them would otherwise be
34
34
  // silently overwritten with no signal to the author. Reject outright.
35
35
  function rejectHandAuthoredDerivedFlags(prefix, screen) {
@@ -67,7 +67,7 @@ function validateOneProjectionListScreen(feature, screen, queryHandlers, feature
67
67
  validateProjectionListFilterSchemaAcceptance(prefix, screen, schema);
68
68
  validateProjectionListFacetsSchemaAcceptance(prefix, screen, schema);
69
69
  }
70
- // filter (fw#2224) sends `payload.filter` — same 422 footgun as facets below
70
+ // filter sends `payload.filter` — same 422 footgun as facets below
71
71
  // if the handler's schema doesn't accept it.
72
72
  function validateProjectionListFilterSchemaAcceptance(prefix, screen, schema) {
73
73
  // skip: no filter declared, or the schema already accepts it — nothing to reject.
@@ -77,11 +77,11 @@ function validateProjectionListFilterSchemaAcceptance(prefix, screen, schema) {
77
77
  `schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","lte","gte","in"]), ` +
78
78
  `value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`);
79
79
  }
80
- // facets (fw#2224) send `payload.filters` — same failure mode as search/sort
81
- // (fw#2165): definePagedQueryHandler doesn't auto-merge params into the
80
+ // facets send `payload.filters` — same failure mode as search/sort:
81
+ // definePagedQueryHandler doesn't auto-merge params into the
82
82
  // handler's own Zod schema, so a declared facet would 422 on every query
83
83
  // unless the author added `filters` themselves.
84
- // A dateRange facet (fw#3104) sends its two bounds as the top-level payload
84
+ // A dateRange facet sends its two bounds as the top-level payload
85
85
  // keys it names in `params`, not as a `filters` entry — so the query has to
86
86
  // accept exactly those keys. Catches the facet pointed at a field the query
87
87
  // can't narrow by, which would otherwise 422 on the first date the user picks.
@@ -203,7 +203,7 @@ function validateRelatedListGroupBy(prefix, section) {
203
203
  }
204
204
  // relatedList sections declare search/facets the same way a projectionList
205
205
  // screen does, and hit the same 422 footgun: definePagedQueryHandler doesn't
206
- // auto-merge params into the handler's own Zod schema (fw#2165/#2224), so an
206
+ // auto-merge params into the handler's own Zod schema, so an
207
207
  // undeclared param would fail every query instead of failing at boot.
208
208
  // Reachable on projectionDetail only (screens.ts rejects relatedList
209
209
  // elsewhere), but the layout walk stays uniform so lifting that restriction
@@ -25,7 +25,7 @@ function checkVisibleWhenQueryRef(queryHandlers, query, buildPrefix) {
25
25
  "Make the query's input fields optional or use a query without required input.");
26
26
  }
27
27
  // relatedList is currently only reachable on projectionDetail — screens.ts
28
- // rejects it outright on entityEdit/actionForm/configEdit (fw#2166). Walking
28
+ // rejects it outright on entityEdit/actionForm/configEdit. Walking
29
29
  // the shared EditLayout uniformly here (instead of excluding those three
30
30
  // types) keeps this collector correct if that restriction is ever lifted.
31
31
  function checkEditLayoutQueryRefs(queryHandlers, featureName, screenId, screenType, layout) {
@@ -7,7 +7,7 @@ export function isRowClickAction(action) {
7
7
  return (action.kind === "navigate" || action.kind === "drawer") && action.rowClick === true;
8
8
  }
9
9
  // entityList and projectionList both allow a rowAction to double as the
10
- // row-body click target (rowClick: true, fw#1708/#2164) — at most one per
10
+ // row-body click target (rowClick: true) — at most one per
11
11
  // screen, or the renderer can't tell which one should fire.
12
12
  export function validateAtMostOneRowClick(featureName, screenId, screenType, rowActions) {
13
13
  const rowClickActions = rowActions.filter(isRowClickAction);
@@ -30,18 +30,17 @@ export function readsNavigateParamsAsFormPrefill(target, explicitEntityId, sourc
30
30
  return false;
31
31
  return sourceScreenEntity === undefined || target.entity !== sourceScreenEntity;
32
32
  }
33
- // Tier 2.7e navigate rowAction → target-screen params validity. Shared by
34
- // entityList and projectionList (framework#1708) — projectionList has no
33
+ // Navigate rowAction → target-screen params validity. Shared by
34
+ // entityList and projectionList — projectionList has no
35
35
  // `screen.entity`, so there's no same-entity row["id"] auto-fill case: any
36
36
  // entityEdit target without an explicit entityId reaches create there.
37
37
  //
38
- // dashboard targets (framework#1708 follow-up, commit 4e0d6cb26) also read
39
- // URL search params — but only for the single `filter.id` a dashboard
40
- // declares (useFilterParams in dashboard-body.tsx seeds its value from
41
- // `nav.searchParams[filter.id]`). A dashboard with no `filter` has nowhere
42
- // for the value to land, and a params extractor whose keys don't include
43
- // the filter's id would silently miss it — both are boot errors instead of
44
- // a silently-empty dashboard.
38
+ // Dashboard targets also read URL search params — but only for the single
39
+ // `filter.id` a dashboard declares (useFilterParams in dashboard-body.tsx
40
+ // seeds its value from `nav.searchParams[filter.id]`). A dashboard with no
41
+ // `filter` has nowhere for the value to land, and a params extractor whose
42
+ // keys don't include the filter's id would silently miss it — both are boot
43
+ // errors instead of a silently-empty dashboard.
45
44
  export function validateRowActionNavigateParams(featureName, screenId, screenType, screenEntity, action, target, actionLabel = "rowAction") {
46
45
  // skip: not a navigate-with-params action — nothing to validate here.
47
46
  if (action.kind !== "navigate" || action.params === undefined)
@@ -122,7 +121,7 @@ export function validateActionHasIcon(featureName, screenId, screenType, context
122
121
  `its kebab segments via the default icon map (resolveActionIcon).`);
123
122
  }
124
123
  }
125
- // Tier 2.7e-3: pin an action's declarative field references against the entity
124
+ // Pin an action's declarative field references against the entity
126
125
  // fields — a typo in pick/map source fields or visible.field would otherwise
127
126
  // silently yield `undefined` in the payload or permanently wrong visibility
128
127
  // (same "typo only surfaces on click" class as navigate/handler). Exported for
@@ -206,9 +205,9 @@ export function validateScreenNavTarget(featureName, screenId, screenKind, field
206
205
  // validateScreens below), the drawer mounts the target inline using this
207
206
  // feature's schema, so a cross-feature reference could never actually
208
207
  // render. Two distinct failure messages: dangling reference vs. wrong
209
- // screen type. Shared by ToolbarAction (fw#2225) and RowAction (fw#2710)
210
- // drawer variants — same target-resolution rule, only the label in the
211
- // error message ("toolbarAction" vs. "rowAction" vs. "action") differs.
208
+ // screen type. Shared by ToolbarAction and RowAction drawer variants —
209
+ // same target-resolution rule, only the label in the error message
210
+ // ("toolbarAction" vs. "rowAction" vs. "action") differs.
212
211
  export function validateDrawerTargetAction(featureName, screenId, screenKind, actionLabel, action, screens) {
213
212
  const target = screens[action.screen];
214
213
  if (target === undefined) {
@@ -337,8 +336,8 @@ export function resolveRowActionNavigateTarget(featureName, screenId, screenType
337
336
  // redirect is either a short screen id (same-feature, e.g. "item-list") or
338
337
  // a fully-qualified cross-feature QN (`<feature>:screen:<id>`) — the
339
338
  // renderer strips the latter to the short id (lastSegment) when
340
- // navigating, which the nav-router resolves app-wide (#1946). The object
341
- // form (fw#2670) carries the same target under `screen` plus the payload
339
+ // navigating, which the nav-router resolves app-wide. The object
340
+ // form carries the same target under `screen` plus the payload
342
341
  // field `idFrom`. Shared by actionForm/secretMint (validateInlineFormNavTargets)
343
342
  // and entityEdit — same rule, same error message, one place to keep them in sync.
344
343
  export function validateRedirectTarget(feature, screenId, screenKind, redirect, lookups,
@@ -4,7 +4,7 @@ import { isExtensionEditSection, isWriteFormEditSection, normalizeEditField, sec
4
4
  import { validateFormDerivations, validateFormDerivationsEnforced } from "./form-derivations.js";
5
5
  import { isSensitiveOrPasswordField, validateIdlessSuccessLink, validateRedirectTarget, validateScreenNavTarget, } from "./screens-actions.js";
6
6
  // A field type in NO_WIDGET_FIELD_TYPES renders read-only on the auto-wired
7
- // entityEdit path (#1925) — a required field the user can never fill would
7
+ // entityEdit path — a required field the user can never fill would
8
8
  // block every save. Only the statically-resolvable case is caught here: a
9
9
  // literal `required: true` (screen-spec override or entity-level default).
10
10
  // A dynamic FieldCondition depends on runtime form values and can't be
@@ -95,7 +95,7 @@ export function validateNoWidgetRequiredField(featureName, screenId, entityDef,
95
95
  if (fieldDef === undefined || !NO_WIDGET_FIELD_TYPES.includes(fieldDef.type))
96
96
  return;
97
97
  // Embedded LIST fields (`multiple: true`) get their own EmbeddedListField
98
- // grid widget (#1838) — only plain (non-list) embedded has no widget.
98
+ // grid widget — only plain (non-list) embedded has no widget.
99
99
  const isEmbeddedList = fieldDef.type === "embedded" && fieldDef.multiple === true;
100
100
  // skip: list variant has a widget — not the no-widget case this guard targets.
101
101
  if (isEmbeddedList)
@@ -115,8 +115,7 @@ export function validateNoWidgetRequiredField(featureName, screenId, entityDef,
115
115
  }
116
116
  // Shared by projectionDetail and entityEdit: a tab strip needs something to
117
117
  // label each tab with and a stable id per tab — the id anchors the ?tab=
118
- // param on projectionDetail and the jump-to-erroring-tab logic on entityEdit
119
- // (fw#3134).
118
+ // param on projectionDetail and the jump-to-erroring-tab logic on entityEdit.
120
119
  export function validateTabSections(featureName, screenId, screenType, sections) {
121
120
  if (sections.length < 2) {
122
121
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "tabs" but only ` +
@@ -149,7 +148,7 @@ export function validateTabSections(featureName, screenId, screenType, sections)
149
148
  export function validateWizardLayout(featureName, screenId, screenType, layout, featureMap) {
150
149
  // entityEdit renders tabs like wizard steps: every section stays mounted,
151
150
  // submit validates across all of them, and a field error jumps to the tab
152
- // holding it (fw#3134). The other three have no such jump — actionForm and
151
+ // holding it. The other three have no such jump — actionForm and
153
152
  // secretMint are one-shot forms, configEdit fires a write per field — so a
154
153
  // required field on a hidden tab would still block their submit silently.
155
154
  if (layout.mode === "tabs") {
@@ -201,7 +200,7 @@ export function validateWizardLayout(featureName, screenId, screenType, layout,
201
200
  //
202
201
  // Field-level renderer QN strings (cross-feature `component:` references)
203
202
  // are NOT validated here — the r.uiComponent registry that would resolve
204
- // them ships in M4/M5. Until then those are kept opaque on purpose.
203
+ // them ships later. Until then those are kept opaque on purpose.
205
204
  // Shared by the mint step's own handler (screen.handler) and its optional
206
205
  // confirm step (confirm.handler) — both are a bare write-handler QN with the
207
206
  // same registration requirement.
@@ -226,7 +225,7 @@ function validateWriteHandlerRegistered(featureName, screenId, context, handler,
226
225
  // secretMint's own `confirm` step — a second, independent inline form on the
227
226
  // same screen — can reuse the same checks as its `fields`/`layout` pair.
228
227
  // `allowEmpty` is only ever true for a secretMint's own mint step (an
229
- // input-less mint, fw#2838): the secret is server-generated and the mint
228
+ // input-less mint): the secret is server-generated and the mint
230
229
  // step is just its submit button — actionForm and the confirm step always
231
230
  // require at least one field.
232
231
  function validateFormFieldsMap(featureName, screenId, context, fields, allowEmpty) {
@@ -330,7 +329,7 @@ export function validateFormSelectOptions(featureName, screenId, context, fieldN
330
329
  }
331
330
  }
332
331
  }
333
- // Fail-closed currency-source gate (fw#2839), the runtime half of the
332
+ // Fail-closed currency-source gate, the runtime half of the
334
333
  // narrowed `FormFieldDefinition` — an untyped JS consumer has no compiler to
335
334
  // stop it. An inline form screen has no entity, so a money field there can't
336
335
  // borrow `entity.defaultCurrency`: without a declared source the renderer
@@ -454,7 +453,7 @@ function validateInlineFormNavTargets(feature, screenId, screen, kind, lookups)
454
453
  export function validateInlineFormScreen(feature, screenId, screen, kind, allWriteHandlerQns, lookups, featureMap) {
455
454
  validateWriteHandlerRegistered(feature.name, screenId, kind, screen.handler, allWriteHandlerQns);
456
455
  // Only a secretMint's own mint step may skip fields/sections entirely
457
- // (input-less mint, fw#2838) — actionForm always needs at least one field.
456
+ // (input-less mint) — actionForm always needs at least one field.
458
457
  const allowEmptyMintForm = kind === "secretMint";
459
458
  const fieldNames = validateFormFieldsMap(feature.name, screenId, kind, screen.fields, allowEmptyMintForm);
460
459
  validateFormLayoutSections(feature.name, screenId, kind, screen.layout, fieldNames, screen.fields, allowEmptyMintForm && fieldNames.size === 0);
@@ -472,14 +471,14 @@ export function validateInlineFormScreen(feature, screenId, screen, kind, allWri
472
471
  }
473
472
  validateInlineFormNavTargets(feature, screenId, screen, kind, lookups);
474
473
  }
475
- // SecretMintConfirmStep (fw#2838): an optional proof-of-receipt form rendered
474
+ // SecretMintConfirmStep: an optional proof-of-receipt form rendered
476
475
  // on the reveal card in place of the bare acknowledge button (TOTP enrollment:
477
476
  // scan the code, then enter one). Unlike the mint step, confirm always needs
478
477
  // at least one field — a step whose only job is proving receipt with no input
479
478
  // would be meaningless — and its layout is a single plain form, never a
480
479
  // wizard/tabs flow and never a persisted draft: a one-time reveal-confirm is
481
480
  // not a multi-step flow, and persisting a draft of it would re-open the leak
482
- // fw#2548 closed (a lingering copy of the just-revealed secret in storage).
481
+ // (a lingering copy of the just-revealed secret in storage).
483
482
  export function validateSecretMintConfirm(feature, screenId, screen, allWriteHandlerQns) {
484
483
  const confirm = screen.confirm;
485
484
  // skip: the screen declares no confirm step — nothing to validate.
@@ -258,24 +258,23 @@ export function assertRefTargetRegistered(prefix, subject, refTarget, currentFea
258
258
  .join(", ") || "(none)"}.`);
259
259
  }
260
260
  }
261
- // Facets (fw#2224) — unlike filter, a field inventory IS available here: the
261
+ // Facets — unlike filter, a field inventory IS available here: the
262
262
  // declared columns. A facet on a field with no column is almost always a
263
263
  // typo (the user never sees the field anywhere), so this is hard-checked
264
- // rather than just documented. Shared by projectionList and relatedList
265
- // (fw#2740) — `prefix` carries the caller's own screen/section message lead-in.
264
+ // rather than just documented. Shared by projectionList and relatedList —
265
+ // `prefix` carries the caller's own screen/section message lead-in.
266
266
  //
267
- // `type: "reference"` is exempt from the same-name-column rule (fw akte-
268
- // bedienkonzept-2): it filters by an id field (e.g. "propertyId") but
269
- // displays a different, human-readable column (e.g. "propertyLabel") — a
270
- // column named after the filter field would almost never exist, and
271
- // requiring one anyway would force apps to add a column no design calls
272
- // for just to satisfy this check. Its own `entity` is mandatory on the type
273
- // (TS, not just this validator) and is resolved below — that explicit,
274
- // checked declaration is this facet type's field inventory, filling the
275
- // same "not just a typo" role the column-name check plays for select/
276
- // boolean facets. select/boolean facets keep the column requirement
277
- // unchanged: both filter and display the same field, so a facet with no
278
- // matching column is still almost always a typo.
267
+ // `type: "reference"` is exempt from the same-name-column rule: it filters
268
+ // by an id field (e.g. "propertyId") but displays a different,
269
+ // human-readable column (e.g. "propertyLabel") — a column named after the
270
+ // filter field would almost never exist, and requiring one anyway would
271
+ // force apps to add a column no design calls for just to satisfy this check.
272
+ // Its own `entity` is mandatory on the type (TS, not just this validator) and
273
+ // is resolved below — that explicit, checked declaration is this facet type's
274
+ // field inventory, filling the same "not just a typo" role the column-name
275
+ // check plays for select/boolean facets. select/boolean facets keep the
276
+ // column requirement unchanged: both filter and display the same field, so a
277
+ // facet with no matching column is still almost always a typo.
279
278
  export function validateListFacets(prefix, facets, columns, currentFeatureName, featureMap) {
280
279
  const columnFieldNames = new Set(columns.map((col) => normalizeListColumn(col).field));
281
280
  const seenFacetFields = new Set();