@cosmicdrift/kumiko-framework 0.386.0 → 0.388.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 (468) hide show
  1. package/AGENTS.md +6 -1
  2. package/LICENSE +21 -57
  3. package/README.md +2 -2
  4. package/agents/examples/anonymous-access.md +1 -1
  5. package/agents/examples/apex-landing.md +204 -0
  6. package/agents/examples/api-worker-split.md +64 -0
  7. package/agents/examples/app-config.md +14 -0
  8. package/agents/examples/auth-mfa.md +115 -0
  9. package/agents/examples/env-composition.md +28 -1
  10. package/agents/examples/legal-pages.md +1 -1
  11. package/agents/examples/prod-boot-options.md +16 -0
  12. package/agents/examples/rate-limiting.md +48 -0
  13. package/agents/examples/record-detail-layout.md +692 -0
  14. package/agents/examples/seed-migration.md +212 -0
  15. package/agents/examples/wizard-form.md +941 -0
  16. package/agents/examples/writeform-section.md +548 -0
  17. package/agents/features.md +19 -2
  18. package/agents/widgets.md +40 -39
  19. package/dist/api/api-constants.js +2 -2
  20. package/dist/api/auth-middleware.js +7 -7
  21. package/dist/api/auth-rate-limiters.js +7 -3
  22. package/dist/api/auth-routes-mfa.js +1 -1
  23. package/dist/api/auth-routes-session.js +1 -1
  24. package/dist/api/auth-routes-shared.js +7 -3
  25. package/dist/api/auth-routes-signup.js +1 -1
  26. package/dist/api/client-error-route.js +8 -2
  27. package/dist/api/client-ip.js +3 -1
  28. package/dist/api/extra-route.js +1 -1
  29. package/dist/api/index.d.ts +3 -1
  30. package/dist/api/index.js +3 -1
  31. package/dist/api/pii-leak-guard.js +4 -2
  32. package/dist/api/readiness.js +5 -5
  33. package/dist/api/redis-sse-broker.js +8 -6
  34. package/dist/api/request-context.d.ts +1 -1
  35. package/dist/api/request-context.js +1 -1
  36. package/dist/api/routes.js +2 -2
  37. package/dist/api/server-boot-checks.d.ts +10 -0
  38. package/dist/api/server-boot-checks.js +157 -0
  39. package/dist/api/server-event-dispatcher.d.ts +26 -0
  40. package/dist/api/server-event-dispatcher.js +208 -0
  41. package/dist/api/server-extra-routes.d.ts +11 -0
  42. package/dist/api/server-extra-routes.js +203 -0
  43. package/dist/api/server-middleware.d.ts +20 -0
  44. package/dist/api/server-middleware.js +211 -0
  45. package/dist/api/server-routes.d.ts +19 -0
  46. package/dist/api/server-routes.js +293 -0
  47. package/dist/api/server-websocket-routes.d.ts +4 -0
  48. package/dist/api/server-websocket-routes.js +108 -0
  49. package/dist/api/server-wiring.d.ts +32 -0
  50. package/dist/api/server-wiring.js +236 -0
  51. package/dist/api/server.d.ts +10 -15
  52. package/dist/api/server.js +48 -1250
  53. package/dist/api/session-cookies.js +4 -4
  54. package/dist/api/sse-broker.js +1 -1
  55. package/dist/api/sse-route.d.ts +13 -13
  56. package/dist/api/sse-route.js +31 -27
  57. package/dist/auth/index.js +4 -4
  58. package/dist/auth/roles.d.ts +4 -4
  59. package/dist/auth/roles.js +24 -24
  60. package/dist/compliance/duration-spec.js +18 -20
  61. package/dist/compliance/index.js +2 -3
  62. package/dist/compliance/override-schema.d.ts +4 -4
  63. package/dist/compliance/override-schema.js +19 -22
  64. package/dist/compliance/profiles.d.ts +26 -29
  65. package/dist/compliance/profiles.js +62 -69
  66. package/dist/compliance/sub-processors.d.ts +28 -30
  67. package/dist/compliance/sub-processors.js +17 -21
  68. package/dist/consumer-cli.js +4 -4
  69. package/dist/crypto/blind-index.js +18 -17
  70. package/dist/crypto/ciphertext-pattern.js +1 -1
  71. package/dist/crypto/event-pii.js +3 -3
  72. package/dist/crypto/kek-source.d.ts +1 -1
  73. package/dist/crypto/kek-source.js +3 -2
  74. package/dist/crypto/kms-adapter.d.ts +1 -1
  75. package/dist/crypto/kms-adapter.js +4 -6
  76. package/dist/crypto/kms-wiring.d.ts +1 -2
  77. package/dist/crypto/kms-wiring.js +2 -2
  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.js +2 -6
  81. package/dist/crypto/pii-field-encryption.d.ts +5 -1
  82. package/dist/crypto/pii-field-encryption.js +7 -5
  83. package/dist/crypto/subject-resolver.d.ts +1 -1
  84. package/dist/crypto/subject-resolver.js +2 -2
  85. package/dist/db/aggregate-sql.d.ts +21 -0
  86. package/dist/db/aggregate-sql.js +155 -0
  87. package/dist/db/apply-entity-event.d.ts +6 -6
  88. package/dist/db/apply-entity-event.js +55 -55
  89. package/dist/db/blind-index-cleanup.js +7 -7
  90. package/dist/db/closed-connection.d.ts +9 -0
  91. package/dist/db/closed-connection.js +95 -0
  92. package/dist/db/collect-table-metas.js +10 -11
  93. package/dist/db/compound-types.d.ts +1 -1
  94. package/dist/db/compound-types.js +12 -12
  95. package/dist/db/cursor.js +3 -4
  96. package/dist/db/dialect.d.ts +2 -2
  97. package/dist/db/dialect.js +3 -3
  98. package/dist/db/distinct-on.d.ts +1 -1
  99. package/dist/db/driver-row-coercion.d.ts +2 -0
  100. package/dist/db/driver-row-coercion.js +186 -0
  101. package/dist/db/driver-value-preparation.d.ts +16 -0
  102. package/dist/db/driver-value-preparation.js +99 -0
  103. package/dist/db/eagerload.d.ts +3 -3
  104. package/dist/db/eagerload.js +21 -12
  105. package/dist/db/entity-field-encryption.d.ts +1 -0
  106. package/dist/db/entity-field-encryption.js +1 -1
  107. package/dist/db/entity-table-meta.d.ts +2 -2
  108. package/dist/db/entity-table-meta.js +105 -112
  109. package/dist/db/event-store-executor-context.d.ts +3 -2
  110. package/dist/db/event-store-executor-context.js +13 -13
  111. package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
  112. package/dist/db/event-store-executor-read-aggregate.js +112 -0
  113. package/dist/db/event-store-executor-read-filter.d.ts +31 -0
  114. package/dist/db/event-store-executor-read-filter.js +203 -0
  115. package/dist/db/event-store-executor-read-paging.d.ts +14 -0
  116. package/dist/db/event-store-executor-read-paging.js +99 -0
  117. package/dist/db/event-store-executor-read-rows.d.ts +6 -0
  118. package/dist/db/event-store-executor-read-rows.js +32 -0
  119. package/dist/db/event-store-executor-read-search.d.ts +28 -0
  120. package/dist/db/event-store-executor-read-search.js +221 -0
  121. package/dist/db/event-store-executor-read-sort.d.ts +41 -0
  122. package/dist/db/event-store-executor-read-sort.js +130 -0
  123. package/dist/db/event-store-executor-read-where.d.ts +24 -0
  124. package/dist/db/event-store-executor-read-where.js +81 -0
  125. package/dist/db/event-store-executor-read.d.ts +2 -9
  126. package/dist/db/event-store-executor-read.js +50 -704
  127. package/dist/db/event-store-executor-write-steps.d.ts +20 -0
  128. package/dist/db/event-store-executor-write-steps.js +100 -0
  129. package/dist/db/event-store-executor-write.js +401 -510
  130. package/dist/db/index.d.ts +6 -2
  131. package/dist/db/index.js +5 -1
  132. package/dist/db/list-aggregate-labels.d.ts +14 -0
  133. package/dist/db/list-aggregate-labels.js +26 -0
  134. package/dist/db/located-timestamp.d.ts +11 -11
  135. package/dist/db/located-timestamp.js +29 -30
  136. package/dist/db/migrate-generator.d.ts +1 -1
  137. package/dist/db/migrate-generator.js +97 -105
  138. package/dist/db/migrate-runner.js +29 -26
  139. package/dist/db/money.d.ts +6 -6
  140. package/dist/db/money.js +29 -27
  141. package/dist/db/queries/backfill-pii.js +5 -5
  142. package/dist/db/queries/ddl.js +1 -1
  143. package/dist/db/queries/es-ops.d.ts +1 -1
  144. package/dist/db/queries/es-ops.js +1 -1
  145. package/dist/db/queries/event-consumer.js +3 -3
  146. package/dist/db/queries/event-store.js +4 -2
  147. package/dist/db/queries/projection-rebuild.js +3 -3
  148. package/dist/db/queries/raw-sql.d.ts +2 -2
  149. package/dist/db/queries/raw-sql.js +2 -2
  150. package/dist/db/queries/shadow-swap.js +19 -19
  151. package/dist/db/query.d.ts +3 -16
  152. package/dist/db/query.js +8 -761
  153. package/dist/db/rebuild-marker.js +20 -21
  154. package/dist/db/reference-data.js +6 -1
  155. package/dist/db/replay-migration-sql.js +2 -2
  156. package/dist/db/schema-inspection.js +4 -5
  157. package/dist/db/table-builder.d.ts +1 -2
  158. package/dist/db/table-builder.js +137 -190
  159. package/dist/db/table-info.d.ts +10 -0
  160. package/dist/db/table-info.js +45 -0
  161. package/dist/db/temporal-value-guards.d.ts +6 -0
  162. package/dist/db/temporal-value-guards.js +15 -0
  163. package/dist/db/tenant-db-runner.d.ts +1 -0
  164. package/dist/db/tenant-db-runner.js +3 -0
  165. package/dist/db/tenant-db.d.ts +2 -2
  166. package/dist/db/tenant-db.js +48 -11
  167. package/dist/db/where-clause.d.ts +6 -0
  168. package/dist/db/where-clause.js +236 -0
  169. package/dist/derivatives/delete-stored-file.js +6 -2
  170. package/dist/derivatives/derivatives-context.js +1 -1
  171. package/dist/engine/boot-validator/access-roles.js +4 -3
  172. package/dist/engine/boot-validator/api-ext.js +14 -14
  173. package/dist/engine/boot-validator/boot-check.js +1 -1
  174. package/dist/engine/boot-validator/config-deps.js +7 -6
  175. package/dist/engine/boot-validator/detail-screens.js +4 -4
  176. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  177. package/dist/engine/boot-validator/entity-handler.js +77 -61
  178. package/dist/engine/boot-validator/form-derivations.js +35 -1
  179. package/dist/engine/boot-validator/gdpr-storage.js +4 -4
  180. package/dist/engine/boot-validator/i18n-keys.js +6 -4
  181. package/dist/engine/boot-validator/index.d.ts +2 -2
  182. package/dist/engine/boot-validator/index.js +22 -20
  183. package/dist/engine/boot-validator/nav.js +5 -5
  184. package/dist/engine/boot-validator/ownership.js +12 -9
  185. package/dist/engine/boot-validator/parent-ref.js +1 -1
  186. package/dist/engine/boot-validator/pii-retention.js +258 -187
  187. package/dist/engine/boot-validator/projection-list-screens.js +7 -7
  188. package/dist/engine/boot-validator/query-refs.js +1 -1
  189. package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
  190. package/dist/engine/boot-validator/screens-actions.js +23 -17
  191. package/dist/engine/boot-validator/screens-dashboard.js +2 -5
  192. package/dist/engine/boot-validator/screens-forms.js +12 -17
  193. package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
  194. package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
  195. package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
  196. package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
  197. package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
  198. package/dist/engine/boot-validator/screens-kind-entity-list.js +228 -0
  199. package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
  200. package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
  201. package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
  202. package/dist/engine/boot-validator/screens-kind-projection-list.js +52 -0
  203. package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
  204. package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
  205. package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
  206. package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
  207. package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
  208. package/dist/engine/boot-validator/screens-lists.js +26 -24
  209. package/dist/engine/boot-validator/screens.d.ts +0 -2
  210. package/dist/engine/boot-validator/screens.js +102 -869
  211. package/dist/engine/boot-validator/security-baseline.js +3 -2
  212. package/dist/engine/boot-validator/transfer-graph.js +2 -2
  213. package/dist/engine/boot-validator/workspaces.js +3 -3
  214. package/dist/engine/bridge-stub.js +3 -4
  215. package/dist/engine/build-app-schema.js +130 -170
  216. package/dist/engine/build-config-feature-schema.js +50 -50
  217. package/dist/engine/build-target.js +20 -23
  218. package/dist/engine/codemod/pipeline-codemod.js +20 -13
  219. package/dist/engine/config-helpers.js +4 -4
  220. package/dist/engine/constants.js +2 -2
  221. package/dist/engine/create-app.js +2 -2
  222. package/dist/engine/define-feature.js +21 -13
  223. package/dist/engine/define-handler.js +13 -11
  224. package/dist/engine/define-step.js +1 -1
  225. package/dist/engine/define-workflow.d.ts +5 -5
  226. package/dist/engine/define-workflow.js +6 -6
  227. package/dist/engine/entity-handlers.d.ts +13 -0
  228. package/dist/engine/entity-handlers.js +22 -34
  229. package/dist/engine/entity-list-runtime-options.d.ts +8 -0
  230. package/dist/engine/entity-list-runtime-options.js +18 -0
  231. package/dist/engine/event-def.js +2 -2
  232. package/dist/engine/extension-names.d.ts +16 -17
  233. package/dist/engine/extension-names.js +37 -38
  234. package/dist/engine/extensions/escape-hatch-usage.js +1 -1
  235. package/dist/engine/extensions/tenant-data.js +2 -2
  236. package/dist/engine/extensions/user-data.d.ts +23 -24
  237. package/dist/engine/factories.d.ts +14 -13
  238. package/dist/engine/factories.js +38 -37
  239. package/dist/engine/feature-ast/entity-field-types.js +1 -1
  240. package/dist/engine/feature-ast/extractors/handlers.js +2 -1
  241. package/dist/engine/feature-ast/extractors/hooks.js +1 -1
  242. package/dist/engine/feature-ast/extractors/round2.js +25 -6
  243. package/dist/engine/feature-ast/extractors/shared.d.ts +2 -3
  244. package/dist/engine/feature-ast/extractors/shared.js +2 -3
  245. package/dist/engine/feature-ast/parse.js +13 -13
  246. package/dist/engine/feature-ast/patch.js +4 -182
  247. package/dist/engine/feature-ast/pattern-change-schema.d.ts +3 -0
  248. package/dist/engine/feature-ast/pattern-change-schema.js +2 -1
  249. package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
  250. package/dist/engine/feature-ast/pattern-id-matchers.js +210 -0
  251. package/dist/engine/feature-ast/patterns.d.ts +11 -2
  252. package/dist/engine/feature-ast/patterns.js +43 -51
  253. package/dist/engine/feature-ast/render.js +50 -89
  254. package/dist/engine/feature-config-events-jobs.js +15 -12
  255. package/dist/engine/feature-entity-handlers.js +12 -5
  256. package/dist/engine/feature-manifest.d.ts +9 -9
  257. package/dist/engine/feature-manifest.js +6 -7
  258. package/dist/engine/feature-ui-extensions.d.ts +3 -3
  259. package/dist/engine/feature-ui-extensions.js +30 -22
  260. package/dist/engine/field-access.js +5 -6
  261. package/dist/engine/form-derivations.js +18 -7
  262. package/dist/engine/handler-field-references.js +1 -1
  263. package/dist/engine/index.d.ts +1 -0
  264. package/dist/engine/index.js +4 -4
  265. package/dist/engine/object-form.d.ts +1 -0
  266. package/dist/engine/object-form.js +14 -4
  267. package/dist/engine/ownership.js +3 -3
  268. package/dist/engine/parse-ref-target.js +5 -7
  269. package/dist/engine/pattern-library/index.js +2 -2
  270. package/dist/engine/pattern-library/library.js +2 -2
  271. package/dist/engine/pattern-library/types.js +3 -3
  272. package/dist/engine/pipeline.js +2 -2
  273. package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
  274. package/dist/engine/project-app-schema-for-roles.js +7 -5
  275. package/dist/engine/projection-helpers.js +9 -9
  276. package/dist/engine/qualified-name.js +2 -2
  277. package/dist/engine/registry-facade.js +1 -1
  278. package/dist/engine/registry-ingest.js +13 -13
  279. package/dist/engine/registry-state.js +17 -19
  280. package/dist/engine/registry-validate.js +12 -12
  281. package/dist/engine/run-in.js +2 -2
  282. package/dist/engine/run-pipeline.js +7 -0
  283. package/dist/engine/schema-builder.js +251 -233
  284. package/dist/engine/screen-filter-ops.js +17 -20
  285. package/dist/engine/screen-helpers.js +4 -3
  286. package/dist/engine/screen-kind.d.ts +7 -0
  287. package/dist/engine/screen-kind.js +1 -0
  288. package/dist/engine/state-machine.d.ts +12 -12
  289. package/dist/engine/state-machine.js +7 -7
  290. package/dist/engine/steps/_resolver-utils.js +4 -3
  291. package/dist/engine/steps/for-each.js +4 -4
  292. package/dist/engine/steps/unsafe-projection-delete.js +1 -1
  293. package/dist/engine/steps/unsafe-projection-upsert.js +4 -4
  294. package/dist/engine/steps/wait-for-event.js +4 -4
  295. package/dist/engine/steps/workflow-system-events.js +3 -3
  296. package/dist/engine/strip-disabled-features-from-app-schema.js +51 -14
  297. package/dist/engine/tier-resolver-extension.d.ts +29 -27
  298. package/dist/engine/tier-resolver-extension.js +26 -26
  299. package/dist/engine/transfer-adjacency.js +2 -2
  300. package/dist/engine/validate-projection-allowlist.js +9 -28
  301. package/dist/engine/walk-step-instances.d.ts +2 -0
  302. package/dist/engine/walk-step-instances.js +38 -0
  303. package/dist/engine/where-rule-lint.js +1 -1
  304. package/dist/engine/write-handler-qn-extract.js +1 -1
  305. package/dist/entrypoint/index.d.ts +4 -1
  306. package/dist/entrypoint/index.js +10 -4
  307. package/dist/env/index.d.ts +7 -6
  308. package/dist/env/index.js +10 -9
  309. package/dist/errors/docs-url.d.ts +1 -1
  310. package/dist/errors/docs-url.js +22 -2
  311. package/dist/errors/transition-details.js +7 -7
  312. package/dist/errors/write-error-info.d.ts +6 -6
  313. package/dist/errors/write-error-info.js +6 -6
  314. package/dist/es-ops/context.d.ts +9 -9
  315. package/dist/es-ops/context.js +29 -29
  316. package/dist/es-ops/index.js +7 -9
  317. package/dist/es-ops/operations-schema.js +4 -10
  318. package/dist/es-ops/runner.d.ts +11 -11
  319. package/dist/es-ops/runner.js +49 -53
  320. package/dist/es-ops/types.d.ts +55 -55
  321. package/dist/es-ops/types.js +11 -11
  322. package/dist/event-store/admin-api.js +7 -8
  323. package/dist/event-store/errors.js +1 -1
  324. package/dist/event-store/event-store.js +12 -6
  325. package/dist/event-store/rebuild-dead-letter.js +1 -1
  326. package/dist/files/bind-pending-file-refs.js +3 -3
  327. package/dist/files/create-file-ref-from-bytes.js +4 -1
  328. package/dist/files/feature.js +9 -10
  329. package/dist/files/file-handle.js +7 -9
  330. package/dist/files/file-ref-entity.js +15 -15
  331. package/dist/files/file-ref-table.js +7 -8
  332. package/dist/files/file-routes.d.ts +7 -0
  333. package/dist/files/file-routes.js +22 -13
  334. package/dist/files/in-memory-provider.js +8 -9
  335. package/dist/files/local-provider.js +18 -25
  336. package/dist/files/provider-resolver.d.ts +7 -7
  337. package/dist/files/resolve-content-type.js +43 -0
  338. package/dist/files/storage-tracking.js +2 -2
  339. package/dist/files/upload-ledger.js +7 -0
  340. package/dist/files/zip-stream.d.ts +12 -19
  341. package/dist/files/zip-stream.js +44 -63
  342. package/dist/i18n/required-surface-keys.d.ts +1 -1
  343. package/dist/i18n/required-surface-keys.js +188 -212
  344. package/dist/internal/stack-support.d.ts +2 -1
  345. package/dist/internal/stack-support.js +1 -1
  346. package/dist/jobs/job-runner-context.d.ts +32 -0
  347. package/dist/jobs/job-runner-context.js +305 -0
  348. package/dist/jobs/job-runner-execution.d.ts +7 -0
  349. package/dist/jobs/job-runner-execution.js +160 -0
  350. package/dist/jobs/job-runner-gates.d.ts +19 -0
  351. package/dist/jobs/job-runner-gates.js +198 -0
  352. package/dist/jobs/job-runner-helpers.d.ts +17 -3
  353. package/dist/jobs/job-runner-helpers.js +69 -4
  354. package/dist/jobs/job-runner-schedulers.d.ts +1 -1
  355. package/dist/jobs/job-runner-schedulers.js +3 -4
  356. package/dist/jobs/job-runner-scope.d.ts +44 -0
  357. package/dist/jobs/job-runner-scope.js +102 -0
  358. package/dist/jobs/job-runner-types.d.ts +12 -0
  359. package/dist/jobs/job-runner.d.ts +0 -1
  360. package/dist/jobs/job-runner.js +57 -669
  361. package/dist/lifecycle/lifecycle.js +3 -5
  362. package/dist/lifecycle/signal-handlers.js +5 -4
  363. package/dist/logging/utils.d.ts +2 -1
  364. package/dist/logging/utils.js +38 -10
  365. package/dist/migrations/index.js +1 -1
  366. package/dist/migrations/kumiko-drift.js +14 -13
  367. package/dist/migrations/pending-rebuilds.d.ts +21 -20
  368. package/dist/migrations/pending-rebuilds.js +27 -27
  369. package/dist/migrations/projection-table-index.d.ts +3 -3
  370. package/dist/migrations/projection-table-index.js +8 -8
  371. package/dist/observability/escape-hatch-report.d.ts +3 -0
  372. package/dist/observability/escape-hatch-report.js +12 -1
  373. package/dist/observability/metric-validator.js +1 -1
  374. package/dist/observability/metrics-handle.js +3 -3
  375. package/dist/pipeline/append-event-core.js +11 -2
  376. package/dist/pipeline/cascade-handler.d.ts +1 -1
  377. package/dist/pipeline/cascade-handler.js +165 -138
  378. package/dist/pipeline/dispatch-batch.js +2 -2
  379. package/dist/pipeline/dispatch-query.js +20 -13
  380. package/dist/pipeline/dispatch-shared.d.ts +14 -3
  381. package/dist/pipeline/dispatch-shared.js +67 -721
  382. package/dist/pipeline/dispatch-stream.js +1 -1
  383. package/dist/pipeline/dispatch-write.js +297 -190
  384. package/dist/pipeline/dispatcher-utils.d.ts +1 -1
  385. package/dist/pipeline/dispatcher-utils.js +1 -1
  386. package/dist/pipeline/event-consumer-state.js +2 -2
  387. package/dist/pipeline/event-dispatcher-admin.js +2 -1
  388. package/dist/pipeline/event-dispatcher-delivery.js +4 -6
  389. package/dist/pipeline/event-dispatcher.js +17 -28
  390. package/dist/pipeline/handler-context-auth.d.ts +9 -0
  391. package/dist/pipeline/handler-context-auth.js +54 -0
  392. package/dist/pipeline/handler-context-config.d.ts +14 -0
  393. package/dist/pipeline/handler-context-config.js +113 -0
  394. package/dist/pipeline/handler-context-db.d.ts +24 -0
  395. package/dist/pipeline/handler-context-db.js +149 -0
  396. package/dist/pipeline/handler-context-dispatch.d.ts +21 -0
  397. package/dist/pipeline/handler-context-dispatch.js +89 -0
  398. package/dist/pipeline/handler-context-events.d.ts +43 -0
  399. package/dist/pipeline/handler-context-events.js +237 -0
  400. package/dist/pipeline/handler-context-notify.d.ts +12 -0
  401. package/dist/pipeline/handler-context-notify.js +55 -0
  402. package/dist/pipeline/handler-context-observability.d.ts +7 -0
  403. package/dist/pipeline/handler-context-observability.js +21 -0
  404. package/dist/pipeline/handler-context-scope.d.ts +48 -0
  405. package/dist/pipeline/handler-context-scope.js +88 -0
  406. package/dist/pipeline/irreversible-operation-gate.js +1 -1
  407. package/dist/pipeline/lifecycle-pipeline.js +9 -9
  408. package/dist/pipeline/member-read-only-transaction.js +1 -3
  409. package/dist/pipeline/msp-rebuild.js +1 -1
  410. package/dist/pipeline/notify-scope.js +12 -1
  411. package/dist/pipeline/pre-handler-gates.js +20 -4
  412. package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
  413. package/dist/pipeline/pre-transaction-reservations.js +9 -3
  414. package/dist/pipeline/projection-rebuild.js +15 -13
  415. package/dist/pipeline/system-hooks.js +39 -41
  416. package/dist/pipeline/system-identity-switch.js +1 -3
  417. package/dist/pipeline/tenant-timezone-cache.js +1 -1
  418. package/dist/random/generate.d.ts +29 -29
  419. package/dist/random/generate.js +30 -30
  420. package/dist/random/words.js +20 -20
  421. package/dist/rate-limit/middleware.js +9 -5
  422. package/dist/redis/client.js +2 -2
  423. package/dist/redis/pubsub-signal.js +14 -17
  424. package/dist/schema-cli/apply-command.d.ts +2 -0
  425. package/dist/schema-cli/apply-command.js +97 -0
  426. package/dist/schema-cli/baseline-command.d.ts +2 -0
  427. package/dist/schema-cli/baseline-command.js +33 -0
  428. package/dist/schema-cli/generate-command.d.ts +2 -0
  429. package/dist/schema-cli/generate-command.js +90 -0
  430. package/dist/schema-cli/shared.d.ts +39 -0
  431. package/dist/schema-cli/shared.js +95 -0
  432. package/dist/schema-cli/status-command.d.ts +2 -0
  433. package/dist/schema-cli/status-command.js +73 -0
  434. package/dist/schema-cli/validate-boot-input.d.ts +5 -0
  435. package/dist/schema-cli/validate-boot-input.js +130 -0
  436. package/dist/schema-cli/validate-command.d.ts +2 -0
  437. package/dist/schema-cli/validate-command.js +130 -0
  438. package/dist/schema-cli.d.ts +2 -18
  439. package/dist/schema-cli.js +47 -510
  440. package/dist/search/index.js +4 -4
  441. package/dist/search/meilisearch-adapter.js +2 -1
  442. package/dist/search/purge-subject.js +4 -5
  443. package/dist/search/reindex-entity.js +6 -9
  444. package/dist/time/boot-tz-warning.d.ts +2 -2
  445. package/dist/time/boot-tz-warning.js +8 -8
  446. package/dist/time/iana.js +10 -10
  447. package/dist/time/index.js +9 -9
  448. package/dist/time/legacy-date.d.ts +2 -2
  449. package/dist/time/legacy-date.js +6 -8
  450. package/dist/time/polyfill.js +1 -1
  451. package/dist/time/tz-context.d.ts +1 -1
  452. package/dist/time/tz-context.js +1 -1
  453. package/dist/ui-types/app-schema.js +7 -7
  454. package/dist/ui-types/list-row-meta.js +4 -4
  455. package/dist/upgrade-cli.js +159 -130
  456. package/dist/utils/compare.js +1 -1
  457. package/package.json +5 -5
  458. package/src/changes.json +358 -0
  459. package/src/scripts/codemod/crypto-shredding-testing-move.ts +2 -2
  460. package/src/scripts/codemod/feature-layout.ts +10 -2
  461. package/src/scripts/codemod/migrate-cross-tenant.ts +1 -1
  462. package/src/scripts/codemod/migrate-open-to-all.ts +1 -1
  463. package/src/scripts/codemod/object-form.ts +41 -4
  464. package/src/scripts/codemod/package-merge.ts +58 -8
  465. package/src/scripts/codemod/pii-personal-migration.ts +192 -153
  466. package/src/scripts/codemod/testing-injection.ts +122 -42
  467. package/src/scripts/codemod/testing-move.ts +29 -7
  468. package/src/scripts/codemod/unprocessable-error-details-reason.ts +2 -2
@@ -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
  }
@@ -42,8 +43,9 @@ const WRITE_ONLY_FORBIDDEN_FLAGS = [
42
43
  "filterable",
43
44
  "lookupable",
44
45
  ];
45
- function validateWriteOnlyField(featureName, entityName, fieldName, field) {
46
- const where = `[Feature ${featureName}] Field "${fieldName}" on entity "${entityName}"`;
46
+ function validateWriteOnlyField(input) {
47
+ const { field } = input;
48
+ const where = fieldLocation(input);
47
49
  if (field.type === "embedded") {
48
50
  for (const [subName, subField] of Object.entries(field.schema)) {
49
51
  if ("writeOnly" in subField && subField.writeOnly === true) {
@@ -81,8 +83,12 @@ const JSONB_PERSONAL_FORBIDDEN_FLAGS = [
81
83
  "lookupable",
82
84
  ];
83
85
  const CUSTOM_FIELDS_COLUMN = "customFields";
84
- function validatePersonalJsonbField(featureName, entityName, entity, fieldName, field) {
85
- 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);
86
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";
87
93
  const flags = field; // @cast-boundary schema-walk
88
94
  for (const flag of JSONB_PERSONAL_FORBIDDEN_FLAGS) {
@@ -100,188 +106,253 @@ function validatePersonalJsonbField(featureName, entityName, entity, fieldName,
100
106
  }
101
107
  // --- PII / Subject-Key Annotations + Retention validation ---
102
108
  //
103
- // Drei Klassen von Checks:
109
+ // Four classes of checks:
104
110
  //
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.
111
+ // 1. Mutual exclusion: each field may have only ONE subject annotation
112
+ // (pii / userOwned / tenantOwned). Multiple annotations are semantically
113
+ // contradictory because each field belongs to exactly one subject key.
108
114
  //
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.
115
+ // 2. Reference integrity: userOwned.ownerField must refer to an existing
116
+ // reference field that should target the user entity. This catches typos
117
+ // and refactors that removed a field during boot instead of at encryption.
113
118
  //
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).
119
+ // 3. Heuristic warnings: field names that typically contain PII
120
+ // (email, name, phone, body, etc.) without an annotation produce a boot
121
+ // warning. `{ personal: false, reason: "<reason>" }` suppresses it and
122
+ // records the reason in the audit.
117
123
  //
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.
124
+ // 4. Retention integrity: retention.reference, when set, must refer to an
125
+ // existing field or a framework timestamp. retention.strategy="blockDelete"
126
+ // without anonymizable fields is ineffective because user forget cannot
127
+ // remove anything, so it produces a warning.
122
128
  //
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.
129
+ // Encryption and decryption are handled elsewhere; this validation runs early
130
+ // so schema drift is detected during boot.
125
131
  export function validatePiiAndRetention(feature) {
126
132
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
127
- const fieldsByName = entity.fields;
128
- const personalOfOwnerFieldNames = ownerFieldNamesReferencedByPersonalOf(fieldsByName);
129
- for (const [fieldName, field] of Object.entries(fieldsByName)) {
130
- // ResolvedPiiFlags properties are type-level optional. On field defs
131
- // not extended via "& ResolvedPiiFlags" (Boolean, Money, Reference,
132
- // Embedded), property access returns undefined at runtime. TS
133
- // compile-time validation already rejected that case elsewhere →
134
- // the cast is safe.
135
- const annot = field; // @cast-boundary schema-walk
136
- const hasPii = Boolean(annot.pii);
137
- const hasUserOwned = Boolean(annot.userOwned);
138
- const hasTenantOwned = Boolean(annot.tenantOwned);
139
- const hasRecordOwned = Boolean(annot.recordOwned);
140
- const annotCount = (hasPii ? 1 : 0) +
141
- (hasUserOwned ? 1 : 0) +
142
- (hasTenantOwned ? 1 : 0) +
143
- (hasRecordOwned ? 1 : 0);
144
- if (annotCount > 1) {
145
- 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.`);
146
- }
147
- if ((field.type === "text" || field.type === "longText") &&
148
- annotCount === 0 &&
149
- !annot.allowPlaintext &&
150
- !annot.subjectRef) {
151
- 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
- // 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.
156
- if (annot.lookupable === true) {
157
- if (field.type !== "text") {
158
- 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".`);
159
- }
160
- if (annotCount === 0) {
161
- 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
- }
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.
167
- const sensitiveFlags = field; // @cast-boundary schema-walk
168
- if (sensitiveFlags.sensitive === true &&
169
- annotCount === 0 &&
170
- sensitiveFlags.encrypted !== true) {
171
- 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 }.`);
172
- }
173
- validateWriteOnlyField(feature.name, entityName, fieldName, field);
174
- // 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).
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
- // 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.
220
- if (ownerField.type === "reference") {
221
- const refTarget = ownerField.entity;
222
- const targetEntity = refTarget.includes(":") ? refTarget.split(":")[1] : refTarget;
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.`);
226
- }
227
- }
228
- }
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" }.
233
- const noAnnotation = annotCount === 0 && !annot.allowPlaintext;
234
- if (noAnnotation) {
235
- const lower = fieldName.toLowerCase();
236
- 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.`);
239
- }
240
- 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.`);
243
- }
244
- 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.`);
247
- }
248
- else if (PII_USER_REFERENCE_NAME_HINTS.has(lower) &&
249
- !annot.subjectRef &&
250
- !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.`);
253
- }
254
- }
255
- }
256
- // --- Entity-level retention ---
257
- const retention = entity.retention;
258
- if (retention) {
259
- 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.`);
262
- }
263
- if (retention.reference !== undefined) {
264
- const refName = retention.reference;
265
- if (!fieldsByName[refName] && !FRAMEWORK_TIMESTAMP_FIELDS.has(refName)) {
266
- const known = Object.keys(fieldsByName).sort().join(", ");
267
- const framework = [...FRAMEWORK_TIMESTAMP_FIELDS].sort().join(", ");
268
- throw new Error(`[Feature ${feature.name}] Entity "${entityName}" retention.reference "${refName}" does not exist. Known fields: ${known} — framework-managed timestamps also accepted: ${framework}`);
269
- }
270
- }
271
- if (retention.strategy === "blockDelete") {
272
- // blockDelete with no field that can carry `anonymize` is fine;
273
- // subjectRef-only entities are covered by the V3 EXT_USER_DATA guard
274
- // instead (#1622, #2336).
275
- const entityHasAnonymizableField = entityHasAnonymizableSubjectField(fieldsByName);
276
- const hasAnonymize = Object.values(fieldsByName).some((f) => {
277
- const a = f; // @cast-boundary schema-walk
278
- return Boolean(a.anonymize);
279
- });
280
- 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.`);
283
- }
284
- }
285
- }
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.`);
286
357
  }
287
358
  }
@@ -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) {
@@ -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 {};