@cosmicdrift/kumiko-framework 0.387.0 → 0.389.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 (375) hide show
  1. package/AGENTS.md +9 -1
  2. package/agents/examples/anonymous-access.md +1 -1
  3. package/agents/examples/apex-landing.md +204 -0
  4. package/agents/examples/api-worker-split.md +64 -0
  5. package/agents/examples/app-config.md +14 -0
  6. package/agents/examples/auth-mfa.md +115 -0
  7. package/agents/examples/env-composition.md +28 -1
  8. package/agents/examples/golden-path.md +13 -1
  9. package/agents/examples/legal-pages.md +1 -1
  10. package/agents/examples/lifecycle-hooks.md +55 -2
  11. package/agents/examples/list-table-options.md +466 -0
  12. package/agents/examples/pdf-audit-export.md +4 -2
  13. package/agents/examples/prod-boot-options.md +16 -0
  14. package/agents/examples/rate-limiting.md +48 -0
  15. package/agents/examples/record-detail-layout.md +692 -0
  16. package/agents/examples/seed-migration.md +212 -0
  17. package/agents/examples/state-machine.md +3 -2
  18. package/agents/examples/tenant-isolation.md +73 -1
  19. package/agents/examples/wizard-form.md +941 -0
  20. package/agents/examples/writeform-section.md +548 -0
  21. package/agents/examples.md +1 -0
  22. package/agents/features.md +19 -2
  23. package/agents/widgets.md +2 -0
  24. package/dist/api/api-constants.d.ts +1 -0
  25. package/dist/api/api-constants.js +2 -0
  26. package/dist/api/auth-routes-shared.d.ts +2 -0
  27. package/dist/api/auth-routes-shared.js +5 -0
  28. package/dist/api/client-error-route.js +10 -5
  29. package/dist/api/entity-read-access.d.ts +2 -1
  30. package/dist/api/entity-read-access.js +14 -0
  31. package/dist/api/export/export-cells-reference.d.ts +9 -0
  32. package/dist/api/export/export-cells-reference.js +21 -0
  33. package/dist/api/export/export-cells.d.ts +11 -0
  34. package/dist/api/export/export-cells.js +136 -0
  35. package/dist/api/export/export-ciphertext-guard.d.ts +2 -0
  36. package/dist/api/export/export-ciphertext-guard.js +27 -0
  37. package/dist/api/export/export-columns.d.ts +29 -0
  38. package/dist/api/export/export-columns.js +85 -0
  39. package/dist/api/export/export-errors.d.ts +4 -0
  40. package/dist/api/export/export-errors.js +14 -0
  41. package/dist/api/export/export-route.d.ts +4 -0
  42. package/dist/api/export/export-route.js +74 -0
  43. package/dist/api/export/export-rows.d.ts +13 -0
  44. package/dist/api/export/export-rows.js +62 -0
  45. package/dist/api/export/export-screen.d.ts +12 -0
  46. package/dist/api/export/export-screen.js +47 -0
  47. package/dist/api/export/export-xlsx-writer.d.ts +4 -0
  48. package/dist/api/export/export-xlsx-writer.js +21 -0
  49. package/dist/api/extra-route.d.ts +4 -0
  50. package/dist/api/index.d.ts +3 -1
  51. package/dist/api/index.js +3 -1
  52. package/dist/api/pii-leak-guard.d.ts +3 -0
  53. package/dist/api/pii-leak-guard.js +3 -2
  54. package/dist/api/request-context.d.ts +1 -1
  55. package/dist/api/request-context.js +1 -1
  56. package/dist/api/routes.d.ts +16 -0
  57. package/dist/api/routes.js +2 -2
  58. package/dist/api/server-boot-checks.d.ts +10 -0
  59. package/dist/api/server-boot-checks.js +157 -0
  60. package/dist/api/server-event-dispatcher.d.ts +26 -0
  61. package/dist/api/server-event-dispatcher.js +208 -0
  62. package/dist/api/server-extra-routes.d.ts +11 -0
  63. package/dist/api/server-extra-routes.js +206 -0
  64. package/dist/api/server-middleware.d.ts +20 -0
  65. package/dist/api/server-middleware.js +211 -0
  66. package/dist/api/server-routes.d.ts +19 -0
  67. package/dist/api/server-routes.js +299 -0
  68. package/dist/api/server-websocket-routes.d.ts +4 -0
  69. package/dist/api/server-websocket-routes.js +108 -0
  70. package/dist/api/server-wiring.d.ts +32 -0
  71. package/dist/api/server-wiring.js +238 -0
  72. package/dist/api/server.d.ts +10 -15
  73. package/dist/api/server.js +48 -1251
  74. package/dist/api/session-cookies.js +4 -4
  75. package/dist/api/sse-route.d.ts +2 -1
  76. package/dist/api/sse-route.js +27 -13
  77. package/dist/config/index.d.ts +3 -1
  78. package/dist/crypto/owner-transition-rekey.d.ts +5 -1
  79. package/dist/crypto/owner-transition-rekey.js +1 -1
  80. package/dist/crypto/pg-kms-adapter.d.ts +1 -0
  81. package/dist/crypto/pg-kms-adapter.js +12 -18
  82. package/dist/crypto/pii-field-encryption.d.ts +5 -1
  83. package/dist/crypto/pii-field-encryption.js +3 -1
  84. package/dist/db/aggregate-sql.d.ts +23 -0
  85. package/dist/db/aggregate-sql.js +165 -0
  86. package/dist/db/closed-connection.d.ts +9 -0
  87. package/dist/db/closed-connection.js +88 -0
  88. package/dist/db/connection.d.ts +4 -5
  89. package/dist/db/connection.js +29 -20
  90. package/dist/db/dialect.js +1 -1
  91. package/dist/db/distinct-on.d.ts +1 -1
  92. package/dist/db/driver-row-coercion.d.ts +2 -0
  93. package/dist/db/driver-row-coercion.js +186 -0
  94. package/dist/db/driver-value-preparation.d.ts +16 -0
  95. package/dist/db/driver-value-preparation.js +101 -0
  96. package/dist/db/entity-field-encryption.d.ts +1 -0
  97. package/dist/db/entity-field-encryption.js +1 -1
  98. package/dist/db/entity-table-meta.js +103 -110
  99. package/dist/db/event-store-executor-context.d.ts +2 -1
  100. package/dist/db/event-store-executor-read-aggregate-currencies.d.ts +17 -0
  101. package/dist/db/event-store-executor-read-aggregate-currencies.js +40 -0
  102. package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
  103. package/dist/db/event-store-executor-read-aggregate.js +143 -0
  104. package/dist/db/event-store-executor-read-filter.d.ts +31 -0
  105. package/dist/db/event-store-executor-read-filter.js +206 -0
  106. package/dist/db/event-store-executor-read-paging.d.ts +14 -0
  107. package/dist/db/event-store-executor-read-paging.js +99 -0
  108. package/dist/db/event-store-executor-read-rows.d.ts +6 -0
  109. package/dist/db/event-store-executor-read-rows.js +32 -0
  110. package/dist/db/event-store-executor-read-search.d.ts +28 -0
  111. package/dist/db/event-store-executor-read-search.js +221 -0
  112. package/dist/db/event-store-executor-read-sort.d.ts +41 -0
  113. package/dist/db/event-store-executor-read-sort.js +130 -0
  114. package/dist/db/event-store-executor-read-where.d.ts +24 -0
  115. package/dist/db/event-store-executor-read-where.js +81 -0
  116. package/dist/db/event-store-executor-read.d.ts +2 -9
  117. package/dist/db/event-store-executor-read.js +48 -701
  118. package/dist/db/event-store-executor-write-atomic.d.ts +4 -0
  119. package/dist/db/event-store-executor-write-atomic.js +41 -0
  120. package/dist/db/event-store-executor-write-steps.d.ts +17 -0
  121. package/dist/db/event-store-executor-write-steps.js +95 -0
  122. package/dist/db/event-store-executor-write.js +405 -509
  123. package/dist/db/event-store-executor.js +2 -1
  124. package/dist/db/executor-managed-tables.js +1 -1
  125. package/dist/db/index.d.ts +7 -3
  126. package/dist/db/index.js +6 -2
  127. package/dist/db/list-aggregate-labels.d.ts +14 -0
  128. package/dist/db/list-aggregate-labels.js +26 -0
  129. package/dist/db/migrate-generator.js +71 -86
  130. package/dist/db/migrate-runner.js +7 -4
  131. package/dist/db/pg-error.js +26 -21
  132. package/dist/db/queries/backfill-pii.js +3 -6
  133. package/dist/db/queries/database-now.js +2 -1
  134. package/dist/db/queries/shadow-swap.js +2 -3
  135. package/dist/db/query.d.ts +16 -15
  136. package/dist/db/query.js +76 -804
  137. package/dist/db/reference-data.js +19 -2
  138. package/dist/db/statement-timeout.js +2 -1
  139. package/dist/db/table-builder.js +113 -166
  140. package/dist/db/table-info.d.ts +10 -0
  141. package/dist/db/table-info.js +45 -0
  142. package/dist/db/temporal-value-guards.d.ts +6 -0
  143. package/dist/db/temporal-value-guards.js +15 -0
  144. package/dist/db/tenant-db-runner.d.ts +1 -0
  145. package/dist/db/tenant-db-runner.js +3 -0
  146. package/dist/db/tenant-db-upsert.d.ts +5 -0
  147. package/dist/db/tenant-db-upsert.js +91 -0
  148. package/dist/db/tenant-db.js +50 -10
  149. package/dist/db/where-clause.d.ts +6 -0
  150. package/dist/db/where-clause.js +236 -0
  151. package/dist/derivatives/delete-stored-file.js +2 -1
  152. package/dist/engine/boot-validator/boot-check.js +1 -1
  153. package/dist/engine/boot-validator/bundled-lists-table.d.ts +2 -0
  154. package/dist/engine/boot-validator/bundled-lists-table.js +36 -0
  155. package/dist/engine/boot-validator/detail-screens.js +2 -2
  156. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  157. package/dist/engine/boot-validator/entity-handler.js +17 -0
  158. package/dist/engine/boot-validator/entity-list-aggregate-options.d.ts +3 -0
  159. package/dist/engine/boot-validator/entity-list-aggregate-options.js +23 -0
  160. package/dist/engine/boot-validator/form-derivations.js +35 -1
  161. package/dist/engine/boot-validator/i18n-keys.js +5 -3
  162. package/dist/engine/boot-validator/index.js +2 -0
  163. package/dist/engine/boot-validator/list-bulk-actions.d.ts +3 -0
  164. package/dist/engine/boot-validator/list-bulk-actions.js +49 -0
  165. package/dist/engine/boot-validator/list-column-layout.d.ts +8 -0
  166. package/dist/engine/boot-validator/list-column-layout.js +41 -0
  167. package/dist/engine/boot-validator/list-table-aggregate-options.d.ts +24 -0
  168. package/dist/engine/boot-validator/list-table-aggregate-options.js +145 -0
  169. package/dist/engine/boot-validator/list-table-aggregate-query.d.ts +10 -0
  170. package/dist/engine/boot-validator/list-table-aggregate-query.js +31 -0
  171. package/dist/engine/boot-validator/list-table-export-options.d.ts +1 -0
  172. package/dist/engine/boot-validator/list-table-export-options.js +27 -0
  173. package/dist/engine/boot-validator/list-table-options.d.ts +3 -0
  174. package/dist/engine/boot-validator/list-table-options.js +77 -0
  175. package/dist/engine/boot-validator/list-table-reject-unknown-keys.d.ts +1 -0
  176. package/dist/engine/boot-validator/list-table-reject-unknown-keys.js +7 -0
  177. package/dist/engine/boot-validator/pii-retention.js +233 -155
  178. package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
  179. package/dist/engine/boot-validator/projection-list-screens.js +3 -13
  180. package/dist/engine/boot-validator/related-list-table-rules.d.ts +4 -0
  181. package/dist/engine/boot-validator/related-list-table-rules.js +42 -0
  182. package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
  183. package/dist/engine/boot-validator/screens-actions.js +8 -1
  184. package/dist/engine/boot-validator/screens-dashboard.js +2 -5
  185. package/dist/engine/boot-validator/screens-forms.js +2 -6
  186. package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
  187. package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
  188. package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
  189. package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
  190. package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
  191. package/dist/engine/boot-validator/screens-kind-entity-list.js +235 -0
  192. package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
  193. package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
  194. package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
  195. package/dist/engine/boot-validator/screens-kind-projection-list.js +56 -0
  196. package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
  197. package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
  198. package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
  199. package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
  200. package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
  201. package/dist/engine/boot-validator/screens-lists.js +24 -11
  202. package/dist/engine/boot-validator/screens.d.ts +0 -2
  203. package/dist/engine/boot-validator/screens.js +99 -860
  204. package/dist/engine/build-app-schema.d.ts +2 -1
  205. package/dist/engine/build-app-schema.js +65 -97
  206. package/dist/engine/build-config-feature-schema.js +6 -1
  207. package/dist/engine/define-feature.js +13 -2
  208. package/dist/engine/define-handler.js +1 -0
  209. package/dist/engine/entity-handlers.d.ts +13 -0
  210. package/dist/engine/entity-handlers.js +10 -21
  211. package/dist/engine/entity-list-runtime-options.d.ts +8 -0
  212. package/dist/engine/entity-list-runtime-options.js +18 -0
  213. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  214. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  215. package/dist/engine/feature-ast/extractors/index.js +1 -1
  216. package/dist/engine/feature-ast/extractors/round1.d.ts +2 -1
  217. package/dist/engine/feature-ast/extractors/round1.js +6 -0
  218. package/dist/engine/feature-ast/extractors/round2.js +25 -6
  219. package/dist/engine/feature-ast/index.d.ts +1 -1
  220. package/dist/engine/feature-ast/parse.js +3 -1
  221. package/dist/engine/feature-ast/patch.d.ts +2 -0
  222. package/dist/engine/feature-ast/patch.js +5 -182
  223. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -245
  224. package/dist/engine/feature-ast/pattern-change-schema.js +10 -163
  225. package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
  226. package/dist/engine/feature-ast/pattern-id-matchers.js +211 -0
  227. package/dist/engine/feature-ast/pattern-id-schema.d.ts +251 -0
  228. package/dist/engine/feature-ast/pattern-id-schema.js +166 -0
  229. package/dist/engine/feature-ast/patterns.d.ts +14 -1
  230. package/dist/engine/feature-ast/patterns.js +44 -51
  231. package/dist/engine/feature-ast/render.js +52 -87
  232. package/dist/engine/feature-builder-state.d.ts +1 -0
  233. package/dist/engine/feature-builder-state.js +1 -0
  234. package/dist/engine/feature-entity-handlers.js +3 -0
  235. package/dist/engine/feature-manifest.d.ts +1 -0
  236. package/dist/engine/feature-manifest.js +1 -0
  237. package/dist/engine/field-access.d.ts +1 -0
  238. package/dist/engine/field-access.js +12 -0
  239. package/dist/engine/form-derivations.js +18 -7
  240. package/dist/engine/handler-field-references.js +1 -1
  241. package/dist/engine/index.d.ts +2 -2
  242. package/dist/engine/index.js +1 -1
  243. package/dist/engine/pattern-library/library.js +2 -1
  244. package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
  245. package/dist/engine/pattern-library/static-schemas.js +9 -0
  246. package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
  247. package/dist/engine/project-app-schema-for-roles.js +6 -4
  248. package/dist/engine/registry-ingest.js +1 -1
  249. package/dist/engine/schema-builder.js +228 -212
  250. package/dist/engine/screen-filter-ops.js +2 -0
  251. package/dist/engine/screen-helpers.js +1 -1
  252. package/dist/engine/screen-kind.d.ts +7 -0
  253. package/dist/engine/screen-kind.js +1 -0
  254. package/dist/engine/strip-disabled-features-from-app-schema.js +58 -17
  255. package/dist/engine/types/feature.d.ts +1 -0
  256. package/dist/engine/types/index.d.ts +4 -3
  257. package/dist/engine/types/screen.d.ts +1 -1
  258. package/dist/engine/types/screen.js +1 -1
  259. package/dist/engine/validate-projection-allowlist.js +2 -1
  260. package/dist/entrypoint/index.d.ts +6 -1
  261. package/dist/entrypoint/index.js +10 -1
  262. package/dist/errors/docs-url.d.ts +1 -1
  263. package/dist/errors/docs-url.js +22 -2
  264. package/dist/es-ops/context.js +1 -1
  265. package/dist/event-store/admin-api.js +1 -1
  266. package/dist/event-store/event-store.d.ts +1 -0
  267. package/dist/event-store/event-store.js +17 -8
  268. package/dist/event-store/provenance-append.js +1 -1
  269. package/dist/files/bind-pending-file-refs.js +3 -3
  270. package/dist/files/create-file-ref-from-bytes.js +7 -1
  271. package/dist/files/create-file-ref.d.ts +3 -0
  272. package/dist/files/create-file-ref.js +11 -2
  273. package/dist/files/file-handle.js +2 -3
  274. package/dist/files/file-routes.d.ts +2 -0
  275. package/dist/files/file-routes.js +40 -10
  276. package/dist/files/provider-resolver.d.ts +1 -0
  277. package/dist/files/provider-resolver.js +1 -1
  278. package/dist/files/record-write-check.d.ts +6 -0
  279. package/dist/files/record-write-check.js +13 -0
  280. package/dist/files/resolve-content-type.js +43 -0
  281. package/dist/files/upload-ledger.d.ts +2 -2
  282. package/dist/files/upload-ledger.js +11 -4
  283. package/dist/i18n/required-surface-keys.js +189 -208
  284. package/dist/internal/stack-support.d.ts +2 -1
  285. package/dist/internal/stack-support.js +1 -1
  286. package/dist/internal/testing.js +1 -1
  287. package/dist/jobs/job-runner-context.d.ts +32 -0
  288. package/dist/jobs/job-runner-context.js +305 -0
  289. package/dist/jobs/job-runner-execution.d.ts +7 -0
  290. package/dist/jobs/job-runner-execution.js +160 -0
  291. package/dist/jobs/job-runner-gates.d.ts +19 -0
  292. package/dist/jobs/job-runner-gates.js +198 -0
  293. package/dist/jobs/job-runner-helpers.d.ts +17 -3
  294. package/dist/jobs/job-runner-helpers.js +61 -2
  295. package/dist/jobs/job-runner-scope.d.ts +44 -0
  296. package/dist/jobs/job-runner-scope.js +102 -0
  297. package/dist/jobs/job-runner-types.d.ts +12 -0
  298. package/dist/jobs/job-runner.d.ts +0 -1
  299. package/dist/jobs/job-runner.js +50 -657
  300. package/dist/observability/escape-hatch-report.d.ts +3 -0
  301. package/dist/observability/escape-hatch-report.js +21 -3
  302. package/dist/pipeline/after-rollback-sink.d.ts +6 -0
  303. package/dist/pipeline/after-rollback-sink.js +32 -0
  304. package/dist/pipeline/cascade-handler.js +165 -138
  305. package/dist/pipeline/dispatch-batch.js +8 -1
  306. package/dist/pipeline/dispatch-query.js +10 -3
  307. package/dist/pipeline/dispatch-shared.d.ts +13 -3
  308. package/dist/pipeline/dispatch-shared.js +62 -710
  309. package/dist/pipeline/dispatch-write.js +296 -189
  310. package/dist/pipeline/event-dispatcher-admin.js +2 -1
  311. package/dist/pipeline/event-dispatcher-delivery.js +6 -6
  312. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  313. package/dist/pipeline/event-dispatcher.js +6 -9
  314. package/dist/pipeline/handler-context-auth.d.ts +9 -0
  315. package/dist/pipeline/handler-context-auth.js +54 -0
  316. package/dist/pipeline/handler-context-config.d.ts +14 -0
  317. package/dist/pipeline/handler-context-config.js +113 -0
  318. package/dist/pipeline/handler-context-db.d.ts +24 -0
  319. package/dist/pipeline/handler-context-db.js +149 -0
  320. package/dist/pipeline/handler-context-dispatch.d.ts +23 -0
  321. package/dist/pipeline/handler-context-dispatch.js +101 -0
  322. package/dist/pipeline/handler-context-events.d.ts +41 -0
  323. package/dist/pipeline/handler-context-events.js +239 -0
  324. package/dist/pipeline/handler-context-notify.d.ts +12 -0
  325. package/dist/pipeline/handler-context-notify.js +55 -0
  326. package/dist/pipeline/handler-context-observability.d.ts +7 -0
  327. package/dist/pipeline/handler-context-observability.js +21 -0
  328. package/dist/pipeline/handler-context-scope.d.ts +48 -0
  329. package/dist/pipeline/handler-context-scope.js +88 -0
  330. package/dist/pipeline/notify-scope.js +12 -1
  331. package/dist/pipeline/pre-handler-gates.js +20 -4
  332. package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
  333. package/dist/pipeline/pre-transaction-reservations.js +9 -3
  334. package/dist/pipeline/projection-rebuild.js +2 -1
  335. package/dist/pipeline/system-hooks.d.ts +3 -1
  336. package/dist/pipeline/system-hooks.js +55 -6
  337. package/dist/pipeline/write-result-masking.js +13 -10
  338. package/dist/schema-cli/apply-command.d.ts +2 -0
  339. package/dist/schema-cli/apply-command.js +97 -0
  340. package/dist/schema-cli/baseline-command.d.ts +2 -0
  341. package/dist/schema-cli/baseline-command.js +33 -0
  342. package/dist/schema-cli/generate-command.d.ts +2 -0
  343. package/dist/schema-cli/generate-command.js +90 -0
  344. package/dist/schema-cli/shared.d.ts +39 -0
  345. package/dist/schema-cli/shared.js +95 -0
  346. package/dist/schema-cli/status-command.d.ts +2 -0
  347. package/dist/schema-cli/status-command.js +73 -0
  348. package/dist/schema-cli/validate-boot-input.d.ts +5 -0
  349. package/dist/schema-cli/validate-boot-input.js +130 -0
  350. package/dist/schema-cli/validate-command.d.ts +2 -0
  351. package/dist/schema-cli/validate-command.js +130 -0
  352. package/dist/schema-cli.d.ts +2 -18
  353. package/dist/schema-cli.js +47 -604
  354. package/dist/search/meilisearch-adapter.js +2 -1
  355. package/dist/search/reindex-entity.js +15 -5
  356. package/dist/ui-types/index.d.ts +2 -2
  357. package/dist/ui-types/index.js +1 -1
  358. package/dist/upgrade-changelog.d.ts +2 -0
  359. package/dist/upgrade-changelog.js +14 -0
  360. package/dist/upgrade-cli.d.ts +3 -12
  361. package/dist/upgrade-cli.js +171 -197
  362. package/dist/upgrade-marker-parse.d.ts +32 -0
  363. package/dist/upgrade-marker-parse.js +73 -0
  364. package/package.json +9 -6
  365. package/src/changes.json +555 -0
  366. package/src/scripts/codemod/codemod-support.ts +1 -1
  367. package/src/scripts/codemod/db-factory.ts +1 -1
  368. package/src/scripts/codemod/feature-layout.ts +10 -2
  369. package/src/scripts/codemod/object-form.ts +41 -4
  370. package/src/scripts/codemod/package-merge.ts +58 -8
  371. package/src/scripts/codemod/pii-personal-migration.ts +175 -145
  372. package/src/scripts/codemod/testing-injection.ts +122 -42
  373. package/src/scripts/codemod/testing-move.ts +29 -7
  374. package/dist/engine/provider-plugin-context.d.ts +0 -3
  375. package/dist/engine/provider-plugin-context.js +0 -28
@@ -43,8 +43,9 @@ const WRITE_ONLY_FORBIDDEN_FLAGS = [
43
43
  "filterable",
44
44
  "lookupable",
45
45
  ];
46
- function validateWriteOnlyField(featureName, entityName, fieldName, field) {
47
- const where = `[Feature ${featureName}] Field "${fieldName}" on entity "${entityName}"`;
46
+ function validateWriteOnlyField(input) {
47
+ const { field } = input;
48
+ const where = fieldLocation(input);
48
49
  if (field.type === "embedded") {
49
50
  for (const [subName, subField] of Object.entries(field.schema)) {
50
51
  if ("writeOnly" in subField && subField.writeOnly === true) {
@@ -82,8 +83,12 @@ const JSONB_PERSONAL_FORBIDDEN_FLAGS = [
82
83
  "lookupable",
83
84
  ];
84
85
  const CUSTOM_FIELDS_COLUMN = "customFields";
85
- function validatePersonalJsonbField(featureName, entityName, entity, fieldName, field) {
86
- const where = `[Feature ${featureName}] Field "${fieldName}" on entity "${entityName}"`;
86
+ function validatePersonalJsonbField(input) {
87
+ const { entity, fieldName, field } = input;
88
+ // skip: only personal jsonb fields are stored as a ciphertext scalar
89
+ if (field.type !== "jsonb" || input.subjectAnnotationCount === 0)
90
+ return;
91
+ const where = fieldLocation(input);
87
92
  const reason = "a personal jsonb field is stored as a ciphertext string, so nothing inside it can be indexed, filtered, sorted, searched or merged";
88
93
  const flags = field; // @cast-boundary schema-walk
89
94
  for (const flag of JSONB_PERSONAL_FORBIDDEN_FLAGS) {
@@ -125,156 +130,229 @@ function validatePersonalJsonbField(featureName, entityName, entity, fieldName,
125
130
  // so schema drift is detected during boot.
126
131
  export function validatePiiAndRetention(feature) {
127
132
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
128
- const fieldsByName = entity.fields;
129
- const personalOfOwnerFieldNames = ownerFieldNamesReferencedByPersonalOf(fieldsByName);
130
- for (const [fieldName, field] of Object.entries(fieldsByName)) {
131
- // ResolvedPiiFlags properties are type-level optional. On field defs
132
- // not extended via "& ResolvedPiiFlags" (Boolean, Money, Reference,
133
- // Embedded), property access returns undefined at runtime. TS
134
- // compile-time validation already rejected that case elsewhere →
135
- // the cast is safe.
136
- const annot = field; // @cast-boundary schema-walk
137
- const hasPii = Boolean(annot.pii);
138
- const hasUserOwned = Boolean(annot.userOwned);
139
- const hasTenantOwned = Boolean(annot.tenantOwned);
140
- const hasRecordOwned = Boolean(annot.recordOwned);
141
- const annotCount = (hasPii ? 1 : 0) +
142
- (hasUserOwned ? 1 : 0) +
143
- (hasTenantOwned ? 1 : 0) +
144
- (hasRecordOwned ? 1 : 0);
145
- if (annotCount > 1) {
146
- throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has multiple subject-key annotations (personal: "self" / "tenant" / { of: "<field>" }). Pick one — each field belongs to exactly one subject.`);
147
- }
148
- if ((field.type === "text" || field.type === "longText") &&
149
- annotCount === 0 &&
150
- !annot.allowPlaintext &&
151
- !annot.subjectRef) {
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.`);
153
- }
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.
157
- if (annot.lookupable === true) {
158
- if (field.type !== "text") {
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".`);
160
- }
161
- if (annotCount === 0) {
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".`);
163
- }
164
- }
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.
168
- const sensitiveFlags = field; // @cast-boundary schema-walk
169
- if (sensitiveFlags.sensitive === true &&
170
- annotCount === 0 &&
171
- sensitiveFlags.encrypted !== true) {
172
- throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { sensitive: true } without ciphertext-at-rest. Since #967 the event log stores sensitive fields as table ciphertext — add a subject annotation (personal: "self" / "tenant" / { of: "<field>" }) or { encrypted: true }.`);
173
- }
174
- validateWriteOnlyField(feature.name, entityName, fieldName, field);
175
- // Sorting reads the projection column — that stays ciphertext, so
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
- {
181
- const flags = field; // @cast-boundary schema-walk
182
- if (annotCount > 0 && flags.sortable === true) {
183
- throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines a subject-key annotation with { sortable: true } — sorting reads the projection column, which is ciphertext at rest. For equality lookups use find: "exact"; drop sortable or keep the field plaintext (personal: false).`);
184
- }
185
- if (flags.sensitive === true && flags.searchable === true) {
186
- throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines { sensitive: true } with { searchable: true } — sensitive means ciphertext at rest, never searchable, stripped from event echoes — authorized readers can still read it back (passwords, tokens, tax IDs). Subject-annotated identity fields may be searchable (#1610); sensitive fields may not. To never return the value at all, add { writeOnly: true }.`);
187
- }
188
- }
189
- if (field.type === "jsonb" && annotCount > 0) {
190
- validatePersonalJsonbField(feature.name, entityName, entity, fieldName, field);
191
- }
192
- if (annot.userOwned) {
193
- const ownerName = annot.userOwned.ownerField;
194
- if (!ownerName || typeof ownerName !== "string") {
195
- throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has personal: { of: ... } without an owner field name. Pass the name of the owner field: personal: { of: "<ownerField>" }.`);
196
- }
197
- const ownerField = fieldsByName[ownerName];
198
- if (!ownerField) {
199
- const known = Object.keys(fieldsByName).sort().join(", ");
200
- throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" references personal.of "${ownerName}" but no such field exists. Known fields: ${known}`);
201
- }
202
- // Text is accepted alongside reference: the ES-framework carries
203
- // user ids as plain text columns throughout the bundled features
204
- // (user-session.userId, invitation.invitedBy) — there is no
205
- // relational FK to point a reference at. Self-referencing
206
- // ownerField (the field's own value IS the owner id) rides on this.
207
- if (ownerField.type !== "reference" && ownerField.type !== "text") {
208
- throw new Error(`[Feature ${feature.name}] personal.of "${ownerName}" on entity "${entityName}" must be a reference or text (userId) field, got type "${ownerField.type}"`);
209
- }
210
- const { whenAbsent } = annot.userOwned;
211
- if (whenAbsent !== undefined && whenAbsent !== "tenant" && whenAbsent !== "self") {
212
- throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares personal.whenAbsent "${String(whenAbsent)}" — entity fields only support whenAbsent: "tenant" or "self" (no plaintext fallback for stored PII). Remove whenAbsent or set it to "tenant" (tenant key) or "self" (the row's own record key).`);
213
- }
214
- if (ownerField.required !== true && whenAbsent === undefined) {
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
- }
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
- if (ownerField.type === "reference") {
221
- const refTarget = ownerField.entity;
222
- const targetEntity = refTarget.includes(":") ? refTarget.split(":")[1] : refTarget;
223
- if (targetEntity !== "user") {
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.`);
225
- }
226
- }
227
- }
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" }.
232
- const noAnnotation = annotCount === 0 && !annot.allowPlaintext;
233
- if (noAnnotation) {
234
- const lower = fieldName.toLowerCase();
235
- if (FILE_FIELD_TYPES.has(field.type)) {
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.`);
237
- }
238
- else if (PII_DIRECT_NAME_HINTS.has(lower)) {
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.`);
240
- }
241
- else if (PII_USER_OWNED_NAME_HINTS.has(lower)) {
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.`);
243
- }
244
- else if (PII_USER_REFERENCE_NAME_HINTS.has(lower) &&
245
- !annot.subjectRef &&
246
- !personalOfOwnerFieldNames.has(fieldName)) {
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.`);
248
- }
249
- }
250
- }
251
- // --- Entity-level retention ---
252
- const retention = entity.retention;
253
- if (retention) {
254
- if (!KEEP_FOR_PATTERN.test(retention.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.`);
256
- }
257
- if (retention.reference !== undefined) {
258
- const refName = retention.reference;
259
- if (!fieldsByName[refName] && !FRAMEWORK_TIMESTAMP_FIELDS.has(refName)) {
260
- const known = Object.keys(fieldsByName).sort().join(", ");
261
- const framework = [...FRAMEWORK_TIMESTAMP_FIELDS].sort().join(", ");
262
- throw new Error(`[Feature ${feature.name}] Entity "${entityName}" retention.reference "${refName}" does not exist. Known fields: ${known} — framework-managed timestamps also accepted: ${framework}`);
263
- }
264
- }
265
- if (retention.strategy === "blockDelete") {
266
- // blockDelete with no field that can carry `anonymize` is fine;
267
- // subjectRef-only entities are covered by the V3 EXT_USER_DATA guard
268
- // instead.
269
- const entityHasAnonymizableField = entityHasAnonymizableSubjectField(fieldsByName);
270
- const hasAnonymize = Object.values(fieldsByName).some((f) => {
271
- const a = f; // @cast-boundary schema-walk
272
- return Boolean(a.anonymize);
273
- });
274
- if (entityHasAnonymizableField && !hasAnonymize) {
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.`);
276
- }
277
- }
278
- }
133
+ validateFieldAnnotations(feature.name, entityName, entity);
134
+ validateEntityRetention(feature.name, entityName, entity);
135
+ }
136
+ }
137
+ // Order matters: the first failing rule decides the boot error, and warnings
138
+ // are emitted in this order.
139
+ const FIELD_RULES = [
140
+ rejectMultipleSubjectAnnotations,
141
+ rejectTextWithoutPersonalStance,
142
+ rejectInvalidLookupable,
143
+ rejectSensitiveWithoutCiphertextAtRest,
144
+ validateWriteOnlyField,
145
+ rejectCiphertextSortOrSensitiveSearch,
146
+ validatePersonalJsonbField,
147
+ validatePersonalOfOwner,
148
+ warnOnUnannotatedPersonalName,
149
+ ];
150
+ function validateFieldAnnotations(featureName, entityName, entity) {
151
+ const personalOfOwnerFieldNames = ownerFieldNamesReferencedByPersonalOf(entity.fields);
152
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
153
+ const flags = field; // @cast-boundary schema-walk
154
+ const input = {
155
+ featureName,
156
+ entityName,
157
+ entity,
158
+ fieldName,
159
+ field,
160
+ flags,
161
+ subjectAnnotationCount: countSubjectAnnotations(flags),
162
+ personalOfOwnerFieldNames,
163
+ };
164
+ for (const rule of FIELD_RULES)
165
+ rule(input);
166
+ }
167
+ }
168
+ function countSubjectAnnotations(flags) {
169
+ return [flags.pii, flags.userOwned, flags.tenantOwned, flags.recordOwned].filter(Boolean).length;
170
+ }
171
+ function fieldLocation(input) {
172
+ return `[Feature ${input.featureName}] Field "${input.fieldName}" on entity "${input.entityName}"`;
173
+ }
174
+ function rejectMultipleSubjectAnnotations(input) {
175
+ if (input.subjectAnnotationCount > 1) {
176
+ throw new Error(`${fieldLocation(input)} has multiple subject-key annotations (personal: "self" / "tenant" / { of: "<field>" }). Pick one — each field belongs to exactly one subject.`);
177
+ }
178
+ }
179
+ function rejectTextWithoutPersonalStance(input) {
180
+ const { field, flags } = input;
181
+ if ((field.type === "text" || field.type === "longText") &&
182
+ input.subjectAnnotationCount === 0 &&
183
+ !flags.allowPlaintext &&
184
+ !flags.subjectRef) {
185
+ throw new Error(`${fieldLocation(input)} (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.`);
186
+ }
187
+ }
188
+ // lookupable: only text fields with a subject annotation are supported. Without
189
+ // a subject, the value remains plaintext, so a blind index would be a
190
+ // second deterministic copy without any benefit.
191
+ function rejectInvalidLookupable(input) {
192
+ // skip: only lookupable fields carry a blind index to validate
193
+ if (input.flags.lookupable !== true)
194
+ return;
195
+ if (input.field.type !== "text") {
196
+ throw new Error(`${fieldLocation(input)} declares find: "exact" (or "fuzzy") but has type "${input.field.type}" — blind-index equality lookups only apply to text fields. Use a text field or set find: "none".`);
197
+ }
198
+ if (input.subjectAnnotationCount === 0) {
199
+ throw new Error(`${fieldLocation(input)} 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".`);
200
+ }
201
+ }
202
+ // Sensitive fields are stored as table ciphertext in the event log; without
203
+ // ciphertext at rest, appending the event would write plaintext to the
204
+ // immutable history.
205
+ function rejectSensitiveWithoutCiphertextAtRest(input) {
206
+ const { flags } = input;
207
+ if (flags.sensitive === true && input.subjectAnnotationCount === 0 && flags.encrypted !== true) {
208
+ throw new Error(`${fieldLocation(input)} declares { sensitive: true } without ciphertext-at-rest. Since #967 the event log stores sensitive fields as table ciphertext — add a subject annotation (personal: "self" / "tenant" / { of: "<field>" }) or { encrypted: true }.`);
209
+ }
210
+ }
211
+ // Sorting reads the projection column — that stays ciphertext, so
212
+ // sortable + subject annotation stays a boot-fail. searchable is allowed:
213
+ // the search consumer decrypts into the derived index and forget purges
214
+ // those docs (see createSearchEventConsumer). sensitive + searchable stays
215
+ // forbidden (never searchable).
216
+ function rejectCiphertextSortOrSensitiveSearch(input) {
217
+ const { flags } = input;
218
+ if (input.subjectAnnotationCount > 0 && flags.sortable === true) {
219
+ throw new Error(`${fieldLocation(input)} combines a subject-key annotation with { sortable: true } — sorting reads the projection column, which is ciphertext at rest. For equality lookups use find: "exact"; drop sortable or keep the field plaintext (personal: false).`);
220
+ }
221
+ if (flags.sensitive === true && flags.searchable === true) {
222
+ throw new Error(`${fieldLocation(input)} combines { sensitive: true } with { searchable: true } — sensitive means ciphertext at rest, never searchable, stripped from event echoes — authorized readers can still read it back (passwords, tokens, tax IDs). Subject-annotated identity fields may be searchable (#1610); sensitive fields may not. To never return the value at all, add { writeOnly: true }.`);
223
+ }
224
+ }
225
+ function validatePersonalOfOwner(input) {
226
+ const personalOf = input.flags.userOwned;
227
+ // skip: only personal: { of } fields reference an owner field
228
+ if (!personalOf)
229
+ return;
230
+ const ownerName = personalOf.ownerField;
231
+ if (!ownerName || typeof ownerName !== "string") {
232
+ throw new Error(`${fieldLocation(input)} has personal: { of: ... } without an owner field name. Pass the name of the owner field: personal: { of: "<ownerField>" }.`);
233
+ }
234
+ const ownerField = requireOwnerField(input, ownerName);
235
+ rejectOwnerWithoutRowFallback(input, ownerName, ownerField, personalOf);
236
+ warnOnNonUserOwnerReference(input, ownerName, ownerField);
237
+ }
238
+ function requireOwnerField(input, ownerName) {
239
+ const fieldsByName = input.entity.fields;
240
+ const ownerField = fieldsByName[ownerName];
241
+ if (!ownerField) {
242
+ const known = Object.keys(fieldsByName).sort().join(", ");
243
+ throw new Error(`${fieldLocation(input)} references personal.of "${ownerName}" but no such field exists. Known fields: ${known}`);
244
+ }
245
+ // Text is accepted alongside reference: the ES-framework carries
246
+ // user ids as plain text columns throughout the bundled features
247
+ // (user-session.userId, invitation.invitedBy) — there is no
248
+ // relational FK to point a reference at. Self-referencing
249
+ // ownerField (the field's own value IS the owner id) rides on this.
250
+ if (ownerField.type !== "reference" && ownerField.type !== "text") {
251
+ throw new Error(`[Feature ${input.featureName}] personal.of "${ownerName}" on entity "${input.entityName}" must be a reference or text (userId) field, got type "${ownerField.type}"`);
252
+ }
253
+ return ownerField;
254
+ }
255
+ function rejectOwnerWithoutRowFallback(input, ownerName, ownerField, personalOf) {
256
+ const { whenAbsent } = personalOf;
257
+ if (whenAbsent !== undefined && whenAbsent !== "tenant" && whenAbsent !== "self") {
258
+ throw new Error(`${fieldLocation(input)} declares personal.whenAbsent "${String(whenAbsent)}" — entity fields only support whenAbsent: "tenant" or "self" (no plaintext fallback for stored PII). Remove whenAbsent or set it to "tenant" (tenant key) or "self" (the row's own record key).`);
259
+ }
260
+ if (ownerField.required !== true && whenAbsent === undefined) {
261
+ throw new Error(`${fieldLocation(input)} 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).`);
262
+ }
263
+ }
264
+ // Custom subject entities (such as HR employees or patients) are allowed but
265
+ // should be an intentional choice.
266
+ function warnOnNonUserOwnerReference(input, ownerName, ownerField) {
267
+ // skip: text owner fields carry a user id by convention, there is no target to check
268
+ if (ownerField.type !== "reference")
269
+ return;
270
+ const refTarget = ownerField.entity;
271
+ const targetEntity = refTarget.includes(":") ? refTarget.split(":")[1] : refTarget;
272
+ if (targetEntity !== "user") {
273
+ log.warn(`[Feature ${input.featureName}] personal.of "${ownerName}" on entity "${input.entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`);
274
+ }
275
+ }
276
+ // First match wins. Business data with person-like names (for example,
277
+ // company.legalName) can produce false positives; authors can suppress them
278
+ // with { personal: false, reason: "is_business_data" }.
279
+ const UNANNOTATED_NAME_WARNINGS = [
280
+ {
281
+ applies: (input) => FILE_FIELD_TYPES.has(input.field.type),
282
+ message: (input) => `[Feature ${input.featureName}] File field "${input.fieldName}" on entity "${input.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.`,
283
+ },
284
+ {
285
+ applies: (_input, lowerName) => PII_DIRECT_NAME_HINTS.has(lowerName),
286
+ message: (input) => `${fieldLocation(input)} 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.`,
287
+ },
288
+ {
289
+ applies: (_input, lowerName) => PII_USER_OWNED_NAME_HINTS.has(lowerName),
290
+ message: (input) => `${fieldLocation(input)} 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.`,
291
+ },
292
+ {
293
+ applies: (input, lowerName) => PII_USER_REFERENCE_NAME_HINTS.has(lowerName) &&
294
+ !input.flags.subjectRef &&
295
+ !input.personalOfOwnerFieldNames.has(input.fieldName),
296
+ message: (input) => `${fieldLocation(input)} 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, "${input.entityName}", …) for Art.17 coverage — this warning is the only boot-time check for that, registering the hook is not enforced. Or { personal: { of: "${input.fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`,
297
+ },
298
+ ];
299
+ function warnOnUnannotatedPersonalName(input) {
300
+ // skip: an annotation or an explicit plaintext reason already states the field's stance
301
+ if (input.subjectAnnotationCount > 0 || input.flags.allowPlaintext)
302
+ return;
303
+ const lowerName = input.fieldName.toLowerCase();
304
+ const warning = UNANNOTATED_NAME_WARNINGS.find((candidate) => candidate.applies(input, lowerName));
305
+ if (warning)
306
+ log.warn(warning.message(input));
307
+ }
308
+ const RETENTION_RULES = [
309
+ warnOnInvalidKeepFor,
310
+ rejectUnknownRetentionReference,
311
+ warnOnBlockDeleteWithoutAnonymize,
312
+ ];
313
+ function validateEntityRetention(featureName, entityName, entity) {
314
+ const { retention } = entity;
315
+ // skip: entities without a retention policy have nothing to validate
316
+ if (!retention)
317
+ return;
318
+ const input = {
319
+ featureName,
320
+ entityName,
321
+ fieldsByName: entity.fields,
322
+ retention,
323
+ };
324
+ for (const rule of RETENTION_RULES)
325
+ rule(input);
326
+ }
327
+ function warnOnInvalidKeepFor({ featureName, entityName, retention }) {
328
+ if (!KEEP_FOR_PATTERN.test(retention.keepFor)) {
329
+ log.warn(`[Feature ${featureName}] 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.`);
330
+ }
331
+ }
332
+ function rejectUnknownRetentionReference(input) {
333
+ const { featureName, entityName, fieldsByName } = input;
334
+ const refName = input.retention.reference;
335
+ // skip: no explicit retention.reference to resolve
336
+ if (refName === undefined)
337
+ return;
338
+ if (!fieldsByName[refName] && !FRAMEWORK_TIMESTAMP_FIELDS.has(refName)) {
339
+ const known = Object.keys(fieldsByName).sort().join(", ");
340
+ const framework = [...FRAMEWORK_TIMESTAMP_FIELDS].sort().join(", ");
341
+ throw new Error(`[Feature ${featureName}] Entity "${entityName}" retention.reference "${refName}" does not exist. Known fields: ${known} — framework-managed timestamps also accepted: ${framework}`);
342
+ }
343
+ }
344
+ // blockDelete with no field that can carry `anonymize` is fine;
345
+ // subjectRef-only entities are covered by the V3 EXT_USER_DATA guard instead.
346
+ function warnOnBlockDeleteWithoutAnonymize(input) {
347
+ const { featureName, entityName, fieldsByName } = input;
348
+ // skip: only blockDelete relies on anonymize once keepFor expires
349
+ if (input.retention.strategy !== "blockDelete")
350
+ return;
351
+ const hasAnonymize = Object.values(fieldsByName).some((f) => {
352
+ const a = f; // @cast-boundary schema-walk
353
+ return Boolean(a.anonymize);
354
+ });
355
+ if (entityHasAnonymizableSubjectField(fieldsByName) && !hasAnonymize) {
356
+ log.warn(`[Feature ${featureName}] 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.`);
279
357
  }
280
358
  }
@@ -1,4 +1,4 @@
1
- import { type FeatureDefinition, type QueryHandlerDef } from "../types/index.ts";
1
+ import type { FeatureDefinition, QueryHandlerDef } from "../types/index.ts";
2
2
  export declare function buildQueryHandlerMap(features: readonly FeatureDefinition[]): ReadonlyMap<string, QueryHandlerDef>;
3
3
  export declare function validateProjectionListScreens(features: readonly FeatureDefinition[]): void;
4
4
  export declare function validateRelatedListSectionQueries(features: readonly FeatureDefinition[]): void;
@@ -1,8 +1,8 @@
1
1
  import { isListQueryWithoutSearchableFields, resolveEntityBehindListQuery, } from "../entity-list-searchability.js";
2
2
  import { QnTypes, qualifyEntityName } from "../qualified-name.js";
3
3
  import { isEditLayoutScreen } from "../screen-helpers.js";
4
- import { relatedListGroupHeaderLabel, relatedListGroupKey, } from "../types/index.js";
5
4
  import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens.js";
5
+ import { validateListTableAggregateQuery } from "./list-table-aggregate-query.js";
6
6
  import { findEntity } from "./parent-ref.js";
7
7
  import { getZodObjectShape } from "./zod-shape.js";
8
8
  // Sibling to entity-list-screens.ts rather than an extension of it:
@@ -66,6 +66,7 @@ function validateOneProjectionListScreen(feature, screen, queryHandlers, feature
66
66
  }
67
67
  validateProjectionListFilterSchemaAcceptance(prefix, screen, schema);
68
68
  validateProjectionListFacetsSchemaAcceptance(prefix, screen, schema);
69
+ validateListTableAggregateQuery({ prefix, ...screen, handler, featureMap });
69
70
  }
70
71
  // filter sends `payload.filter` — same 422 footgun as facets below
71
72
  // if the handler's schema doesn't accept it.
@@ -190,17 +191,6 @@ function validateRelatedListParentFilter(prefix, section, schema, handler, featu
190
191
  `entity behind query "${section.query}". Known fields: ${Object.keys(entity.fields).sort().join(", ")}.`);
191
192
  }
192
193
  }
193
- // A collapsed group without a header has no toggle, so its rows could never be opened.
194
- function validateRelatedListGroupBy(prefix, section) {
195
- const groupBy = section.groupBy;
196
- const collapsedKey = groupBy?.collapsedWhen === undefined ? undefined : relatedListGroupKey(groupBy.collapsedWhen);
197
- if (groupBy !== undefined &&
198
- collapsedKey !== undefined &&
199
- relatedListGroupHeaderLabel(groupBy, collapsedKey) === undefined) {
200
- throw new Error(`${prefix}: groupBy.collapsedWhen "${collapsedKey}" names a group without a header; ` +
201
- `set groupBy.label or groupBy.labels["${collapsedKey}"], or drop collapsedWhen.`);
202
- }
203
- }
204
194
  // relatedList sections declare search/facets the same way a projectionList
205
195
  // screen does, and hit the same 422 footgun: definePagedQueryHandler doesn't
206
196
  // auto-merge params into the handler's own Zod schema, so an
@@ -217,7 +207,7 @@ export function validateRelatedListSectionQueries(features) {
217
207
  validateRelatedListSearchable(prefix, section, schema);
218
208
  validateRelatedListFacets(prefix, section, schema);
219
209
  validateRelatedListParentFilter(prefix, section, schema, handler, featureMap);
220
- validateRelatedListGroupBy(prefix, section);
210
+ validateListTableAggregateQuery({ prefix, ...section, handler, featureMap });
221
211
  };
222
212
  for (const feature of features) {
223
213
  for (const screen of Object.values(feature.screens)) {
@@ -0,0 +1,4 @@
1
+ /** Grouping rules of a relatedList section or entityList.expandableRow, before the shared table checks. */
2
+ export declare function validateRelatedListGroupingRules(prefix: string, section: {
3
+ readonly table?: unknown;
4
+ }, isExpandableRow: boolean): void;
@@ -0,0 +1,42 @@
1
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
+ const EXPANDABLE_ROW_TABLE_KEYS = new Set(["grouping"]);
3
+ const EXPANDABLE_ROW_REJECTED_GROUPING_KEYS = ["userChoice", "fields"];
4
+ // `groupBy` grouped the loaded page on the client; it is gone, and a screen
5
+ // still carrying it fails at boot instead of silently losing its groups.
6
+ function rejectRemovedGroupBy(prefix, section) {
7
+ if (Object.hasOwn(section, "groupBy")) {
8
+ throw new Error(`${prefix} uses groupBy, which was removed. Use table.grouping: { field, label?, labels?, collapsedWhen? }; ` +
9
+ `groups and counts come from the server, so the bound query must accept aggregate.`);
10
+ }
11
+ }
12
+ function rejectExpandableRowTableOptions(prefix, table) {
13
+ if (isPlainObject(table)) {
14
+ rejectUnsupportedTableKeys(prefix, table);
15
+ rejectGroupingChoice(prefix, table["grouping"]);
16
+ }
17
+ }
18
+ function rejectUnsupportedTableKeys(prefix, table) {
19
+ const unsupported = Object.keys(table).filter((key) => !EXPANDABLE_ROW_TABLE_KEYS.has(key));
20
+ if (unsupported.length > 0) {
21
+ throw new Error(`${prefix} supports only table.grouping, not table.${unsupported.join(", table.")}. ` +
22
+ `Put the other table options on the entityList screen itself.`);
23
+ }
24
+ }
25
+ function rejectGroupingChoice(prefix, grouping) {
26
+ const choiceKey = isPlainObject(grouping)
27
+ ? EXPANDABLE_ROW_REJECTED_GROUPING_KEYS.find((key) => Object.hasOwn(grouping, key))
28
+ : undefined;
29
+ if (choiceKey !== undefined) {
30
+ throw new Error(`${prefix} does not support table.grouping.${choiceKey}: an expanded row has no view menu. ` +
31
+ `Set a fixed table.grouping.field.`);
32
+ }
33
+ }
34
+ /** Grouping rules of a relatedList section or entityList.expandableRow, before the shared table checks. */
35
+ export function validateRelatedListGroupingRules(prefix, section, isExpandableRow) {
36
+ rejectRemovedGroupBy(prefix, section);
37
+ if (isExpandableRow && Object.hasOwn(section, "bulkActions")) {
38
+ throw new Error(`${prefix} does not support bulkActions. Put bulkActions on the entityList screen itself.`);
39
+ }
40
+ if (isExpandableRow)
41
+ rejectExpandableRowTableOptions(prefix, section.table);
42
+ }
@@ -64,4 +64,5 @@ export type NavigateTargetLookups = {
64
64
  }>;
65
65
  };
66
66
  export declare function validateNavigateActionTab(featureName: string, screenId: string, screenType: NavigateSourceScreenType, actionLabel: string, action: RowAction, lookups: NavigateTargetLookups): void;
67
+ export declare function assertWriteHandlerRegistered(featureName: string, subject: string, handler: string, allWriteHandlerQns: ReadonlySet<string>): void;
67
68
  export {};
@@ -202,7 +202,7 @@ export function validateScreenNavTarget(featureName, screenId, screenKind, field
202
202
  }
203
203
  // kind:"drawer" resolves same-feature only — unlike navigate/redirect,
204
204
  // which the runtime router resolves app-wide (see the comment on
205
- // validateScreens below), the drawer mounts the target inline using this
205
+ // buildScreenValidationContext in screens.ts), the drawer mounts the target inline using this
206
206
  // feature's schema, so a cross-feature reference could never actually
207
207
  // render. Two distinct failure messages: dangling reference vs. wrong
208
208
  // screen type. Shared by ToolbarAction and RowAction drawer variants —
@@ -485,3 +485,10 @@ export function validateNavigateActionTab(featureName, screenId, screenType, act
485
485
  return;
486
486
  resolveRowActionNavigateTarget(featureName, screenId, screenType, actionLabel, action, lookups.allScreenQns, lookups.navTargetShortIds, lookups.screensByShortId, lookups.detailForScreens);
487
487
  }
488
+ export function assertWriteHandlerRegistered(featureName, subject, handler, allWriteHandlerQns) {
489
+ if (!allWriteHandlerQns.has(handler)) {
490
+ throw new Error(`[Feature ${featureName}] ${subject} handler "${handler}" ` +
491
+ `is not a registered write-handler. Check the QN spelling (expected ` +
492
+ `"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
493
+ }
494
+ }
@@ -1,6 +1,6 @@
1
1
  import { DASHBOARD_MIN_REFRESH_SECONDS } from "@cosmicdrift/kumiko-types/screen";
2
2
  import { normalizeListColumn } from "../screen-helpers.js";
3
- import { isRowClickAction, resolveRowActionNavigateTarget, resolveScreenTargetQn, validateActionHasIcon, validateClientActionName, validateRowActionNavigateParams, } from "./screens-actions.js";
3
+ import { assertWriteHandlerRegistered, isRowClickAction, resolveRowActionNavigateTarget, resolveScreenTargetQn, validateActionHasIcon, validateClientActionName, validateRowActionNavigateParams, } from "./screens-actions.js";
4
4
  import { assertRefTargetRegistered, validateColumnRendererForm } from "./screens-lists.js";
5
5
  // Panel-driven, no entity — structural checks (unique panel ids, non-empty
6
6
  // queries/columns/valueField); the query contracts (stat record, points
@@ -68,10 +68,7 @@ export function validateDashboardActions(featureName, screenId, screen, allWrite
68
68
  throw new Error(`${where} sets redirect — a dashboard action stays on the dashboard and refetches ` +
69
69
  `after success. Remove redirect.`);
70
70
  }
71
- if (!allWriteHandlerQns.has(action.handler)) {
72
- throw new Error(`${where} handler "${action.handler}" is not a registered write-handler. Check the QN ` +
73
- `spelling (expected "<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
74
- }
71
+ assertWriteHandlerRegistered(featureName, `Screen "${screenId}" (dashboard) action "${action.id}"`, action.handler, allWriteHandlerQns);
75
72
  }
76
73
  }
77
74
  }
@@ -2,7 +2,7 @@ import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs } from "@cosmicdrift/kumik
2
2
  import { isKebabSegment } from "../qualified-name.js";
3
3
  import { isExtensionEditSection, isWriteFormEditSection, normalizeEditField, sectionFieldSpecs, } from "../screen-helpers.js";
4
4
  import { validateFormDerivations, validateFormDerivationsEnforced } from "./form-derivations.js";
5
- import { isSensitiveOrPasswordField, validateIdlessSuccessLink, validateRedirectTarget, validateScreenNavTarget, } from "./screens-actions.js";
5
+ import { assertWriteHandlerRegistered, 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
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
@@ -208,11 +208,7 @@ function validateWriteHandlerRegistered(featureName, screenId, context, handler,
208
208
  if (!handler || typeof handler !== "string") {
209
209
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) has empty or non-string handler. Set handler to a non-empty handler name.`);
210
210
  }
211
- if (!allWriteHandlerQns.has(handler)) {
212
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) handler "${handler}" ` +
213
- `is not a registered write-handler. Check the QN spelling (expected ` +
214
- `"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
215
- }
211
+ assertWriteHandlerRegistered(featureName, `Screen "${screenId}" (${context})`, handler, allWriteHandlerQns);
216
212
  }
217
213
  // Every field entry must carry a `type` discriminator. An author typo
218
214
  // (`title: { required: true }` without a type) would otherwise let
@@ -0,0 +1,3 @@
1
+ import type { ConfigEditScreenDefinition } from "../types/screen.ts";
2
+ import { type ScreenValidationContext } from "./screens-kind-shared.ts";
3
+ export declare function validateConfigEditScreen(ctx: ScreenValidationContext, screenId: string, screen: ConfigEditScreenDefinition): void;