@cosmicdrift/kumiko-framework 0.363.0 → 0.365.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 (281) hide show
  1. package/AGENTS.md +338 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +446 -0
  4. package/agents/examples/anonymous-access.md +288 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +319 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +315 -0
  20. package/agents/examples/custom-handlers.md +444 -0
  21. package/agents/examples/dashboard-stats.md +229 -0
  22. package/agents/examples/delivery-notifications.md +432 -0
  23. package/agents/examples/designer-demo.md +86 -0
  24. package/agents/examples/e2e-generator.md +211 -0
  25. package/agents/examples/embedded-entity-form.md +598 -0
  26. package/agents/examples/embedded.md +649 -0
  27. package/agents/examples/encrypted-tenant-config.md +414 -0
  28. package/agents/examples/error-contract.md +535 -0
  29. package/agents/examples/event-sourcing.md +1287 -0
  30. package/agents/examples/feature-toggles.md +274 -0
  31. package/agents/examples/field-access.md +271 -0
  32. package/agents/examples/files-post-processing.md +433 -0
  33. package/agents/examples/i18n.md +166 -0
  34. package/agents/examples/idempotency.md +179 -0
  35. package/agents/examples/inbound-mail-triage.md +315 -0
  36. package/agents/examples/lane-routing.md +246 -0
  37. package/agents/examples/ledger-banking.md +215 -0
  38. package/agents/examples/ledger-invoicing.md +236 -0
  39. package/agents/examples/legal-pages.md +393 -0
  40. package/agents/examples/lifecycle-hooks.md +368 -0
  41. package/agents/examples/managed-config.md +428 -0
  42. package/agents/examples/managed-pages.md +353 -0
  43. package/agents/examples/notes-history-basic.md +346 -0
  44. package/agents/examples/ownership.md +404 -0
  45. package/agents/examples/pdf-audit-export.md +306 -0
  46. package/agents/examples/pipeline-basics.md +792 -0
  47. package/agents/examples/public-share-token.md +351 -0
  48. package/agents/examples/rate-limiting.md +211 -0
  49. package/agents/examples/realtime-sse.md +187 -0
  50. package/agents/examples/record-detail-layout.md +297 -0
  51. package/agents/examples/reference-data.md +151 -0
  52. package/agents/examples/relations.md +280 -0
  53. package/agents/examples/screens-nav.md +353 -0
  54. package/agents/examples/search.md +233 -0
  55. package/agents/examples/secrets-demo.md +417 -0
  56. package/agents/examples/security-baseline.md +187 -0
  57. package/agents/examples/seed-migration.md +99 -0
  58. package/agents/examples/session-revocation.md +250 -0
  59. package/agents/examples/state-machine.md +920 -0
  60. package/agents/examples/tags-basic.md +401 -0
  61. package/agents/examples/tenant-isolation.md +173 -0
  62. package/agents/examples/tenant-settings.md +275 -0
  63. package/agents/examples/tier-admin.md +347 -0
  64. package/agents/examples/timezones.md +366 -0
  65. package/agents/examples/user-data-rights.md +418 -0
  66. package/agents/examples/user-profile.md +226 -0
  67. package/agents/examples/webhook-step.md +597 -0
  68. package/agents/examples/wizard-form.md +379 -0
  69. package/agents/examples/workflow-engine.md +1731 -0
  70. package/agents/examples/writeform-section.md +300 -0
  71. package/agents/examples.md +75 -0
  72. package/agents/features.md +768 -0
  73. package/agents/widgets.md +181 -0
  74. package/dist/api/auth-middleware.d.ts +2 -0
  75. package/dist/api/auth-middleware.js +7 -0
  76. package/dist/api/auth-routes.d.ts +0 -1
  77. package/dist/api/auth-routes.js +49 -46
  78. package/dist/api/extra-route.d.ts +3 -0
  79. package/dist/api/index.d.ts +2 -2
  80. package/dist/api/index.js +1 -1
  81. package/dist/api/routes.js +46 -17
  82. package/dist/api/server.d.ts +4 -3
  83. package/dist/api/server.js +39 -10
  84. package/dist/api/session-cookies.d.ts +1 -0
  85. package/dist/api/session-cookies.js +20 -5
  86. package/dist/api/sse-route.js +16 -1
  87. package/dist/bun-db/connection.js +3 -16
  88. package/dist/bun-db/index.d.ts +1 -1
  89. package/dist/bun-db/index.js +1 -1
  90. package/dist/bun-db/query.d.ts +15 -3
  91. package/dist/bun-db/query.js +76 -24
  92. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  93. package/dist/crypto/owner-transition-rekey.js +7 -3
  94. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  95. package/dist/crypto/pii-field-encryption.js +9 -2
  96. package/dist/crypto/subject-resolver.d.ts +1 -0
  97. package/dist/crypto/subject-resolver.js +7 -0
  98. package/dist/db/api.d.ts +4 -3
  99. package/dist/db/apply-entity-event.js +26 -2
  100. package/dist/db/blind-index-cleanup.js +2 -1
  101. package/dist/db/bun-provider.js +9 -18
  102. package/dist/db/collect-table-metas.js +11 -7
  103. package/dist/db/dialect.d.ts +1 -1
  104. package/dist/db/dialect.js +2 -0
  105. package/dist/db/eagerload.js +4 -3
  106. package/dist/db/event-store-executor-context.d.ts +6 -6
  107. package/dist/db/event-store-executor-context.js +25 -22
  108. package/dist/db/event-store-executor-read.js +31 -16
  109. package/dist/db/event-store-executor-write.d.ts +1 -1
  110. package/dist/db/event-store-executor-write.js +84 -63
  111. package/dist/db/index.d.ts +2 -1
  112. package/dist/db/index.js +2 -1
  113. package/dist/db/lock-guard.d.ts +4 -0
  114. package/dist/db/lock-guard.js +14 -0
  115. package/dist/db/migrate-generator.js +1 -1
  116. package/dist/db/migrate-runner.d.ts +1 -0
  117. package/dist/db/migrate-runner.js +64 -38
  118. package/dist/db/queries/backfill-pii.js +63 -25
  119. package/dist/db/queries/event-consumer.d.ts +6 -6
  120. package/dist/db/queries/event-consumer.js +2 -1
  121. package/dist/db/queries/event-store-transfer.js +15 -4
  122. package/dist/db/queries/event-store.d.ts +1 -1
  123. package/dist/db/queries/event-store.js +11 -10
  124. package/dist/db/queries/pii-backfill-state.js +3 -2
  125. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  126. package/dist/db/queries/postgres-array-literal.js +6 -0
  127. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  128. package/dist/db/queries/projection-rebuild.js +3 -2
  129. package/dist/db/queries/shadow-swap.d.ts +1 -1
  130. package/dist/db/queries/shadow-swap.js +12 -8
  131. package/dist/db/query.d.ts +1 -1
  132. package/dist/db/query.js +1 -1
  133. package/dist/db/retired-framework-tables.js +22 -4
  134. package/dist/db/table-builder.js +6 -2
  135. package/dist/db/tenant-db-runner.d.ts +4 -0
  136. package/dist/db/tenant-db-runner.js +17 -0
  137. package/dist/db/tenant-db.d.ts +4 -0
  138. package/dist/db/tenant-db.js +50 -5
  139. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  140. package/dist/derivatives/delete-stored-file.js +9 -0
  141. package/dist/derivatives/derivatives-context.js +1 -1
  142. package/dist/derivatives/index.d.ts +1 -1
  143. package/dist/derivatives/index.js +1 -1
  144. package/dist/engine/boot-validator/access-declarations.js +2 -2
  145. package/dist/engine/boot-validator/api-ext.js +3 -3
  146. package/dist/engine/boot-validator/boot-check.js +1 -1
  147. package/dist/engine/boot-validator/config-deps.js +13 -13
  148. package/dist/engine/boot-validator/detail-screens.js +1 -1
  149. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  150. package/dist/engine/boot-validator/entity-handler.js +87 -35
  151. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  152. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  153. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  154. package/dist/engine/boot-validator/nav.js +1 -1
  155. package/dist/engine/boot-validator/pii-retention.js +8 -8
  156. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  157. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  158. package/dist/engine/boot-validator/query-refs.js +14 -2
  159. package/dist/engine/boot-validator/screens.d.ts +2 -1
  160. package/dist/engine/boot-validator/screens.js +153 -95
  161. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  162. package/dist/engine/dedupe-features.js +31 -4
  163. package/dist/engine/define-handler.d.ts +2 -2
  164. package/dist/engine/define-handler.js +8 -1
  165. package/dist/engine/entity-handlers.js +14 -7
  166. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  167. package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
  168. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  169. package/dist/engine/feature-ast/render.js +10 -2
  170. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  171. package/dist/engine/feature-entity-handlers.js +12 -3
  172. package/dist/engine/handler-field-references.d.ts +21 -2
  173. package/dist/engine/handler-field-references.js +55 -17
  174. package/dist/engine/index.d.ts +3 -3
  175. package/dist/engine/index.js +1 -1
  176. package/dist/engine/schema-builder.js +2 -1
  177. package/dist/engine/screen-helpers.d.ts +1 -0
  178. package/dist/engine/screen-helpers.js +2 -2
  179. package/dist/engine/soft-delete-cleanup.js +10 -3
  180. package/dist/engine/steps/_read-source.js +5 -2
  181. package/dist/engine/steps/retry.js +2 -1
  182. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  183. package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
  184. package/dist/engine/template-variables.d.ts +3 -0
  185. package/dist/engine/template-variables.js +3 -0
  186. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  187. package/dist/engine/tier-resolver-extension.js +6 -0
  188. package/dist/engine/types/index.d.ts +1 -1
  189. package/dist/entrypoint/index.d.ts +2 -2
  190. package/dist/env/_zod-introspect.d.ts +1 -0
  191. package/dist/errors/classes.js +1 -0
  192. package/dist/errors/docs-url.js +6 -6
  193. package/dist/errors/index.d.ts +1 -1
  194. package/dist/errors/index.js +1 -1
  195. package/dist/errors/kumiko-error.d.ts +2 -0
  196. package/dist/errors/kumiko-error.js +2 -0
  197. package/dist/errors/subject-erased.d.ts +1 -0
  198. package/dist/errors/subject-erased.js +6 -0
  199. package/dist/errors/write-error-info.js +2 -0
  200. package/dist/event-store/admin-api.js +3 -2
  201. package/dist/event-store/errors.d.ts +9 -0
  202. package/dist/event-store/errors.js +20 -0
  203. package/dist/event-store/event-store.d.ts +6 -0
  204. package/dist/event-store/event-store.js +28 -6
  205. package/dist/event-store/index.d.ts +1 -1
  206. package/dist/event-store/index.js +1 -1
  207. package/dist/event-store/tenant-db-append.js +2 -2
  208. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  209. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  210. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  211. package/dist/files/bind-pending-file-refs.js +3 -3
  212. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  213. package/dist/files/bind-referenced-files.write.js +22 -13
  214. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  215. package/dist/files/create-file-ref-from-bytes.js +67 -4
  216. package/dist/files/create-file-ref.d.ts +24 -1
  217. package/dist/files/create-file-ref.js +67 -25
  218. package/dist/files/dispose-removed-file-refs.js +21 -6
  219. package/dist/files/feature.d.ts +1 -0
  220. package/dist/files/feature.js +2 -1
  221. package/dist/files/file-handle.d.ts +1 -1
  222. package/dist/files/file-handle.js +12 -0
  223. package/dist/files/file-routes.js +10 -6
  224. package/dist/files/index.d.ts +3 -3
  225. package/dist/files/index.js +2 -2
  226. package/dist/files/load-save-file-refs.d.ts +10 -0
  227. package/dist/files/load-save-file-refs.js +47 -0
  228. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  229. package/dist/files/purge-expired-file-ref-bytes.js +46 -21
  230. package/dist/files/resolve-content-type.js +40 -4
  231. package/dist/files/storage-tracking.js +5 -4
  232. package/dist/files/types.js +5 -8
  233. package/dist/files/zip-entry-names.d.ts +1 -0
  234. package/dist/files/zip-entry-names.js +46 -0
  235. package/dist/jobs/index.d.ts +1 -1
  236. package/dist/jobs/job-runner.d.ts +5 -1
  237. package/dist/jobs/job-runner.js +124 -37
  238. package/dist/observability/escape-hatch-report.d.ts +2 -1
  239. package/dist/observability/escape-hatch-report.js +48 -7
  240. package/dist/observability/index.d.ts +1 -1
  241. package/dist/observability/index.js +1 -1
  242. package/dist/observability/metrics-handle.d.ts +1 -0
  243. package/dist/observability/metrics-handle.js +7 -0
  244. package/dist/observability/standard-metrics.js +4 -4
  245. package/dist/pipeline/cascade-handler.js +5 -2
  246. package/dist/pipeline/dispatch-batch.js +79 -31
  247. package/dist/pipeline/dispatch-query.d.ts +2 -1
  248. package/dist/pipeline/dispatch-query.js +6 -6
  249. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  250. package/dist/pipeline/dispatch-shared.js +136 -48
  251. package/dist/pipeline/dispatch-stream.js +10 -4
  252. package/dist/pipeline/dispatch-write.js +34 -22
  253. package/dist/pipeline/dispatcher.js +3 -1
  254. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  255. package/dist/pipeline/event-dispatcher.js +11 -9
  256. package/dist/pipeline/event-retention.d.ts +1 -0
  257. package/dist/pipeline/event-retention.js +7 -1
  258. package/dist/pipeline/index.d.ts +1 -0
  259. package/dist/pipeline/index.js +1 -0
  260. package/dist/pipeline/notify-scope.js +3 -1
  261. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  262. package/dist/pipeline/pre-transaction-reservations.js +29 -9
  263. package/dist/rate-limit/bucket.d.ts +3 -3
  264. package/dist/rate-limit/bucket.js +14 -4
  265. package/dist/redis/cache-sync-bus.js +9 -3
  266. package/dist/schema-cli.js +12 -1
  267. package/dist/search/meilisearch-adapter.js +36 -9
  268. package/dist/stack/db.d.ts +2 -2
  269. package/dist/stack/test-stack.js +2 -1
  270. package/dist/testing/index.d.ts +1 -0
  271. package/dist/testing/index.js +1 -0
  272. package/dist/testing/multipart-helper.d.ts +6 -0
  273. package/dist/testing/multipart-helper.js +1 -1
  274. package/dist/testing/zip-fixture.d.ts +1 -0
  275. package/dist/testing/zip-fixture.js +33 -0
  276. package/dist/ui-types/card-column-roles.js +4 -2
  277. package/dist/ui-types/index.d.ts +1 -1
  278. package/dist/ui-types/index.js +1 -1
  279. package/dist/upgrade-cli.js +46 -13
  280. package/package.json +7 -5
  281. package/src/changes.json +512 -8
package/src/changes.json CHANGED
@@ -1,4 +1,508 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "improvement",
5
+ "title": "ctx.forTenant is available read-only in the beforeTransaction phase",
6
+ "detail": "A beforeTransaction phase can now resolve ctx.forTenant(tenantId) for its own tenant and, as a SystemAdmin, for a foreign tenant, to read rows or build tenant-scoped inputs such as uploads. The returned db denies every write; a foreign tenant stays gated by the SystemAdmin check. reserveBeforeTransaction, perCommand and in-transaction handlers are unchanged."
7
+ },
8
+ {
9
+ "version": "0.365.0",
10
+ "type": "breaking",
11
+ "title": "Boot checks from the review round 1 release reject configurations that used to boot",
12
+ "detail": "These checks shipped as fixes without migration notes. Each one throws for a configuration that was accepted before:\n- a where-rule subquery on a table whose columns cannot be derived\n- a drawer prefill that targets a sensitive field or a field the form does not render\n- two secret fields with the same id\n- access.personalData \"public-intake\" combined with rateLimit: { disabled: true }\n- a recordOwned field on an entity with idType \"serial\"\n- dedupeFeatures for same-named features with different registrations\nThe agent-tools manifest check in buildToolCatalog throws when the catalog is built for a request, not at boot.",
13
+ "migration": "Boot every app against the new version before rolling it out, and send one agent tool request if the app mounts agent-tools. Give where-rule subqueries a table with derivable columns, prefill only rendered non-sensitive fields, make secret field ids unique, give public-intake handlers a rateLimit that is not disabled, switch recordOwned entities to idType \"uuid\" or change the field's personal stance, and rename or align duplicated features."
14
+ },
15
+ {
16
+ "version": "0.365.0",
17
+ "type": "breaking",
18
+ "title": "Boot rejects visibleWhen queries with required input, non-sql index where clauses, lockedWhen on read-restricted fields and same-named features with a different surface",
19
+ "detail": "These checks used to be absent or silent and now stop the app at boot:\n- A screen or dashboard panel visibleWhen that names a query whose input schema does not accept an empty payload. The client gate runs it with an empty payload, so the screen stayed hidden.\n- An entity index where that is not a sql expression. It used to be dropped, which built a full (unique) index.\n- A lockedWhen field that declares access.read.\n- Two features with the same name whose screens, navs, jobs, events, translations, config keys, notifications, metrics or hooks differ. The second one used to be dropped.",
20
+ "migration": "Boot each app once against the new version. For a visibleWhen query, make its input fields optional or point visibleWhen at an input-free query. Write index where clauses with sql`...`. Remove access.read from the lockedWhen field or lock on a field every role can read. Give differing same-named features distinct names or make their surfaces identical."
21
+ },
22
+ {
23
+ "version": "0.365.0",
24
+ "type": "fix",
25
+ "title": "Bun.SQL provider parity fixes for DB_PROVIDER=bun",
26
+ "detail": "Array params bind as Postgres array literals, transaction handles are detected correctly, Instant params are stored at millisecond precision, integer params reject fractions, and migrations hold their advisory lock on one pinned connection. LISTEN/NOTIFY uses Bun.SQL listen() on its own connection, which the pool idle timeout does not close, so the Bun provider no longer opens a postgres-js peer."
27
+ },
28
+ {
29
+ "version": "0.365.0",
30
+ "type": "fix",
31
+ "title": "cardColumnRoles skips hideOnNarrow images",
32
+ "detail": "The first image column that is not hideOnNarrow becomes the card preview; narrow-hidden image columns neither become the preview nor the title."
33
+ },
34
+ {
35
+ "version": "0.365.0",
36
+ "type": "fix",
37
+ "title": "Console-spy tests only count the messages of the code under test",
38
+ "detail": "Unit tests that asserted a silent console.warn/error ran in a process shared with other test files and picked up unrelated async warnings. They now filter the spied calls to the messages the tested code produces. No runtime change."
39
+ },
40
+ {
41
+ "version": "0.365.0",
42
+ "type": "improvement",
43
+ "title": "AGENTS.md covers the gaps from the agent-docs eval; new dashboard-stats recipe",
44
+ "detail": "AGENTS.md now explains personalData \"tenant-members\" for openToAll writes, personal { of: \"id\" } for records that are the person, counting with list filter and totalCount instead of filtering one page, the dashboard stat panels, that schema:generate needs a name and that a newly mounted bundled feature needs a migration, and that r.crud needs write.access and read.access. The new dashboard-stats recipe shows a dashboard with a stat-group and a stat backed by one counting query. No runtime change."
45
+ },
46
+ {
47
+ "version": "0.365.0",
48
+ "type": "fix",
49
+ "title": "Same-named features that differ in screens, navs, jobs, events or hooks no longer merge silently",
50
+ "detail": "dedupeFeatures now compares the keys of screens, navs, jobs, events, translations, config keys, notifications, metrics and hooks in addition to entities and handlers, so a second same-named feature with different surface throws instead of being dropped."
51
+ },
52
+ {
53
+ "version": "0.365.0",
54
+ "type": "fix",
55
+ "title": "Moved file ids are not disposed",
56
+ "detail": "disposeRemovedFileRefs now collects the file ids still stored in any file field of the saved record before soft-deleting removed ones, so an id moved from one field to another stays readable."
57
+ },
58
+ {
59
+ "version": "0.365.0",
60
+ "type": "fix",
61
+ "title": "A .docx upload must contain a word/ entry, not just the bytes \"word/\"",
62
+ "detail": "validateFileContent now reads the ZIP central directory and requires an entry name that starts with word/. ZIPs with entries such as password/a.txt or xl/word/sheet.xml, or the bytes appearing anywhere in the file, are rejected as content_mismatch."
63
+ },
64
+ {
65
+ "version": "0.365.0",
66
+ "type": "improvement",
67
+ "title": "zipWithEntries test helper in @cosmicdrift/kumiko-framework/testing",
68
+ "detail": "Builds a minimal ZIP with a real central directory for upload tests. Hand-made docx fixtures that only had a local header and the word/ bytes no longer pass the content check; build them with zipWithEntries(\"word/document.xml\") instead."
69
+ },
70
+ {
71
+ "version": "0.365.0",
72
+ "type": "fix",
73
+ "title": "Executor update and delete with writePurpose erasure, and forget, write the erased sentinel for subjects whose key is gone",
74
+ "detail": "Re-encrypting a pre-KMS plaintext row after eraseKey threw subject-erased, so the erase flows could not finish their own follow-up writes. Update and delete with writePurpose erasure, and forget always, now replace the PII fields of an erased subject in changes and previous with the sentinel; fields of other subjects in the same row stay encrypted. Without writePurpose and for restore the subject-erased conflict stays. The forget-subject door close and the forget cleanup pass writePurpose erasure to the user lifecycle update."
75
+ },
76
+ {
77
+ "version": "0.365.0",
78
+ "type": "improvement",
79
+ "title": "Error docsUrl falls back to the framework page of the error code for uncovered app reasons",
80
+ "detail": "An error whose details.reason is an app reason not covered by errorDocs, such as the not_found reason of every NotFoundError, now carries docsUrl pointing at the framework page of its error code (for example /errors/not_found) instead of no docsUrl. Reasons covered by errorDocs keep the app link; framework reasons are unchanged."
81
+ },
82
+ {
83
+ "version": "0.365.0",
84
+ "type": "fix",
85
+ "title": "Escape-hatch audits are collected for the whole stream",
86
+ "detail": "Dispatched streams run inside the escape-hatch audit scope for the full lifetime of the stream, and settled pending audits are dropped from the scope, so writes made while a stream is open are no longer missed."
87
+ },
88
+ {
89
+ "version": "0.365.0",
90
+ "type": "fix",
91
+ "title": "Soft-delete cleanup and hardDelete retention delete the bytes of handed-over files",
92
+ "detail": "A tenant handover moves a fileRef row to the new tenant, but its storageKey keeps the previous tenant's prefix. The soft-delete cleanup and the data-retention hardDelete went through `ctx.files.ref()` and `ctx.files.list()`, which reject keys outside the job's tenant, so the bytes and derivatives of such files were never deleted and the soft-delete cleanup kept the row forever. Both now delete through the new `deleteFileRefBytes(files, row, context)` from `@cosmicdrift/kumiko-framework/derivatives`, built on the new `FileContext.listFromFileRef(row)` (the row's key plus its derivatives, under the same trust as `refFromFileRef`). Form-draft discard deletes draft files through `refFromFileRef` as well."
93
+ },
94
+ {
95
+ "version": "0.365.0",
96
+ "type": "fix",
97
+ "title": "Removing a file that was moved to another file field of the same record deletes its fileRef",
98
+ "detail": "The fileRef stayed bound to the field it was first uploaded to. Once it was moved to a second field and then removed from that field, the dispose step skipped it and the file stayed readable. Dispose now accepts a binding to any file field of the same record."
99
+ },
100
+ {
101
+ "version": "0.365.0",
102
+ "type": "fix",
103
+ "title": "MFA verify without a switch-tenant gate fails at boot, and the gate result carries the session roles",
104
+ "detail": "createAuthRoutes throws when mfaVerifyHandler is set without switchTenantMfaGateHandler, and runDevApp and runProdApp check that the gate handler is registered before wiring, so a missing gate can no longer let switch-tenant skip MFA. The gate handler returns the roles with its clear result and the route mints the target session from them. assertRetiredCookieDomains is no longer exported, and the auth-session signup variant is composed from SignupOutcome. Setups that set mfaVerifyHandler must also register the switch-tenant gate handler."
105
+ },
106
+ {
107
+ "version": "0.365.0",
108
+ "type": "fix",
109
+ "title": "fetchForWriting reads one consistent stream version, nested ctx.write calls run in order, ctx.notify works through ctx.query",
110
+ "detail": "fetchForWriting derives the stream version from the same events it loads instead of a second read. Parallel ctx.write calls on one transaction are serialized, so a failing write rolls back only its own savepoint and no longer takes a concurrent successful write with it. ctx.notify called inside a handler reached through ctx.query is deferred onto the outer write's after-commit list instead of being delivered before that write commits. ctx.notify snapshots its options when it is called, so later mutation by the handler does not change the delivered notification. The JSDoc of AggregateStreamHandle.appendOne now states that it must not be mixed with other appends to the same aggregate in one handler."
111
+ },
112
+ {
113
+ "version": "0.365.0",
114
+ "type": "fix",
115
+ "title": "registerEntityCrud merges per-verb agent hints into write.agent and read.agent",
116
+ "detail": "agents[verb] used to replace write.agent or read.agent wholesale, so write: { agent: { expose: true } } with agents: { delete: { risk: \"high\" } } dropped expose on delete. The two are now merged with the per-verb value winning. An escapeHatch update/delete/restore on a row whose tenantId is not a valid TenantId throws an InternalError instead of silently appending to the operator's own tenant stream."
117
+ },
118
+ {
119
+ "version": "0.365.0",
120
+ "type": "breaking",
121
+ "title": "Entity list and detail handlers with escapeHatch must declare SystemAdmin-only access",
122
+ "detail": "defineEntityListHandler, defineEntityDetailHandler and registerEntityCrud read options with escapeHatch now throw at definition unless access is { roles: [\"SystemAdmin\"] }, like write handlers already did. A cross-tenant read reachable by any tenant role leaked foreign rows.",
123
+ "migration": "Give every list/detail handler that declares escapeHatch access: { roles: [\"SystemAdmin\"] }, or drop the escapeHatch if the handler should stay tenant-scoped."
124
+ },
125
+ {
126
+ "version": "0.365.0",
127
+ "type": "fix",
128
+ "title": "Upload route creates the fileRef row in its own transaction, cleans up bytes on failure and excludes system files from unclaimedUploadReaders",
129
+ "detail": "The file routes and the API server use createProjectingTenantDb so projections on fileRef.created run. When the row insert or a projection throws, the upload route rolls the row back, deletes the stored bytes and answers 500. Files uploaded by the system user are no longer readable through unclaimedUploadReaders."
130
+ },
131
+ {
132
+ "version": "0.365.0",
133
+ "type": "fix",
134
+ "title": "Cron schedulers of a running job runner survive orphan cleanup by an older runner, and stop() waits for a timed-out boot gate",
135
+ "detail": "The runner re-stamps its own cron schedulers in the orphan-cleanup seen hash every quarter of the grace period (one hset for all ids, cleared in stop()), so a mixed-version window longer than an hour no longer lets an older runner delete a scheduler a live runner holds. The stamps are written before the schedulers are upserted. A boot gate that exceeds its timeout is abandoned, not aborted: its handler keeps running, and stop() now waits for it (bounded) before closing connections."
136
+ },
137
+ {
138
+ "version": "0.365.0",
139
+ "type": "fix",
140
+ "title": "buildServer throws at boot when a handler declares additionalRateLimits but no RateLimitResolver can be built",
141
+ "detail": "Without a Redis client or context.rateLimit every call to such a handler (auth-email-password signup and token requests, waitlist submit) answered 500 at request time. buildServer now throws at boot and names the handlers.",
142
+ "migration": "Wire a Redis client on context.redis (or pass context.rateLimit). For the waitlist without Redis, pass emailRateLimits: []."
143
+ },
144
+ {
145
+ "version": "0.365.0",
146
+ "type": "fix",
147
+ "title": "PII backfill keeps ownerless events under the tenant key and re-keys values left under a previous owner",
148
+ "detail": "With resolveOwnerFromProjection the projection owner is now used only when the event does not carry the owner key at all; an explicit null owner resolves through whenAbsent like the live write path. The owner re-key stage (rekeyTenantKeyedOwnerFields) also moves user-keyed values to the projection's current owner when that is another user, so after an owner change X to Y that predates re-keying on transition, forgetting Y erases the value. Known divergence: a row that went ownerless, then owned by X, then ownerless again keeps its created-event value under the tenant key, while the live path keeps it with X."
149
+ },
150
+ {
151
+ "version": "0.365.0",
152
+ "type": "improvement",
153
+ "title": "pruneEvents batchSize and _notifyAfterCommitFactory on the handler context",
154
+ "detail": "pruneEvents takes an optional batchSize that deletes the lowest event ids first, so callers can loop until deletedCount is below the batch size with one short transaction per batch. The handler context exposes _notifyAfterCommitFactory, a notify factory bound to the dispatcher's job runner for use after the transaction has committed."
155
+ },
156
+ {
157
+ "version": "0.365.0",
158
+ "type": "improvement",
159
+ "title": "requireTierResolverPlugin narrows a tier-resolver registration or fails the boot",
160
+ "detail": "requireTierResolverPlugin(usage) returns the TierResolverPlugin of a tenantTierResolver registration and throws \"tier-resolver registration options must provide a build() factory\" otherwise. runProdApp, the worker boot and runDevApp share it, so the boot error is identical in all three entry points."
161
+ },
162
+ {
163
+ "version": "0.365.0",
164
+ "type": "improvement",
165
+ "title": "Back-dated seed writes fail with a typed SeedTimeOrderError",
166
+ "detail": "append() throws the exported SeedTimeOrderError (aggregateType, aggregateId, version, seedCreatedAt, predecessorCreatedAt) when a back-dated event would precede its stream predecessor, so seeders can tell it from other failures without parsing the message. The check now runs inside the insert transaction, so a concurrent live append can no longer land between check and insert."
167
+ },
168
+ {
169
+ "version": "0.365.0",
170
+ "type": "fix",
171
+ "title": "kumiko-upgrade --resolve rejects --dry-run, and the installed version is found from workspace members and prerelease lockfile pins",
172
+ "detail": "kumiko-upgrade --resolve --dry-run used to write the marker anyway; it now exits with an error and writes nothing. The installed version is now looked up from the working directory up to the repo root (the first directory with .git or a package.json with workspaces), so running inside a workspace member of a hoisted-linker monorepo finds it, while a parent workspace outside the repo is still ignored. bun.lock pins like 0.364.0-next.1 resolve to their x.y.z core instead of being dropped."
173
+ },
174
+ {
175
+ "version": "0.365.0",
176
+ "type": "fix",
177
+ "title": "Non-sql index where throws instead of building a full index",
178
+ "detail": "buildEntityTable no longer drops a malformed EntityIndexDef.where silently, which produced a full (unique) index that blocked reuse of soft-deleted values. moneyAmount is typed as a bigint column."
179
+ },
180
+ {
181
+ "version": "0.365.0",
182
+ "type": "improvement",
183
+ "title": "Job handlers get ctx.metricsFor like request handlers",
184
+ "detail": "JobContext now exposes ctx.metricsFor(featureName) with the same contract as HandlerContext, so shared code can count under one stable feature name from jobs too. Unregistered names stay a silent no-op."
185
+ },
186
+ {
187
+ "version": "0.365.0",
188
+ "type": "fix",
189
+ "title": "A failed scheduler seen stamp at start no longer lets an older runner remove fresh cron schedulers for good",
190
+ "detail": "When the initial seen stamp failed, a concurrent older runner could delete the new runner's cron schedulers as orphans, and the periodic refresh only re-stamped them. The refresh now stamps and upserts the lane's cron schedulers again until both succeeded once."
191
+ },
192
+ {
193
+ "version": "0.365.0",
194
+ "type": "fix",
195
+ "title": "Job runner start survives a failed scheduler-seen stamp",
196
+ "detail": "The initial scheduler-seen stamp is best-effort like the periodic refresh and the orphan cleanup: a Redis error is logged as a warning and the runner still starts and registers its cron schedulers."
197
+ },
198
+ {
199
+ "version": "0.365.0",
200
+ "type": "fix",
201
+ "title": "lockedWhen on a field with access.read fails at boot",
202
+ "detail": "Roles that cannot read the lock field receive records without it, and the client then showed every record as locked. validateLockedWhen now rejects a lock field that declares access.read."
203
+ },
204
+ {
205
+ "version": "0.365.0",
206
+ "type": "breaking",
207
+ "title": "moneyAmount returns ColumnBuilder<bigint> instead of ColumnBuilder<number>",
208
+ "detail": "The column was always created with bigint mode, so the driver returned a bigint while the type said number. The type now matches the runtime value. Code that assigned the column value to a number or did number arithmetic on it no longer typechecks.",
209
+ "migration": "Treat values read from a moneyAmount column as bigint (for example compare with 0n, or convert explicitly with Number() where the amount is known to fit). No schema change."
210
+ },
211
+ {
212
+ "version": "0.365.0",
213
+ "type": "improvement",
214
+ "title": "The pdf-audit-export recipe uploads the PDF before the transaction",
215
+ "detail": "The shipped pdf-audit-export example now renders and uploads the PDF in a beforeTransaction phase with uploadFileBytesBeforeTransaction and creates the fileRef row in the handler with createFileRefFromUploadedBytes, so the upload holds no pool connection. No runtime change."
216
+ },
217
+ {
218
+ "version": "0.365.0",
219
+ "type": "fix",
220
+ "title": "The pii-personal-migration codemod skips .d.ts files in migration mode too",
221
+ "detail": "An earlier release note said the codemod now skips .d.ts files in --report-stance mode. That mode already skipped them. What changed is that migration mode skips .d.ts files as well, matching --report-stance."
222
+ },
223
+ {
224
+ "version": "0.365.0",
225
+ "type": "fix",
226
+ "title": "No false narrowing warning for where.tenantId given as { in } or { eq }",
227
+ "detail": "The read.findOne and read.findMany steps unwrap operator-form tenant filters ({ in: [...] } and { eq }) before comparing them with the caller's tenant scope, and stay silent for operator forms they do not understand."
228
+ },
229
+ {
230
+ "version": "0.365.0",
231
+ "type": "improvement",
232
+ "title": "custom-handlers and tags-basic recipes follow the coding rules",
233
+ "detail": "The custom-handlers recipe parses the detail row with Zod instead of casts and filters counter:active on the server (filter + totalCount) instead of in JS over one page. The tags-basic test isolates tests by tenant instead of raw DELETE statements. No runtime change."
234
+ },
235
+ {
236
+ "version": "0.365.0",
237
+ "type": "improvement",
238
+ "title": "Recipe integration tests isolate per tenant instead of raw DELETE FROM resets",
239
+ "detail": "The recipe integration tests that agents learn from no longer reset tables with raw DELETE FROM statements. Each test now runs in its own tenant with its own users, or uses unique ids. The kumiko-schema-check header comment is now English. No runtime change."
240
+ },
241
+ {
242
+ "version": "0.365.0",
243
+ "type": "fix",
244
+ "title": "Rebuilding an entity with a personal file or image field no longer fails on ciphertext from old backfill events",
245
+ "detail": "The earlier PII backfill encrypted file and image uuid values in stored events. Replaying those created and updated events wrote the ciphertext into the uuid column and failed with invalid input syntax. applyEntityEvent now decrypts such values through the subject KMS, writes null for an erased subject, and throws a clear InternalError when no KMS is configured. Table rows were never affected because Postgres rejects the ciphertext on a uuid column."
246
+ },
247
+ {
248
+ "version": "0.365.0",
249
+ "type": "fix",
250
+ "title": "SVG and HTML fragments are not resolved as application/xml",
251
+ "detail": "For uploads without a concrete content type, markup without an XML declaration is only treated as application/xml when its root element is not a known HTML or SVG element (namespace prefixes ignored). SVG logos and HTML snippets no longer route to XML ingest providers."
252
+ },
253
+ {
254
+ "version": "0.365.0",
255
+ "type": "fix",
256
+ "title": "EventStoreExecutor.restore returns a subject_erased conflict instead of throwing",
257
+ "detail": "When the stored row has to be re-encrypted for a subject whose key was erased, restore now returns a failed WriteResult with the subject_erased ConflictError. The row stays soft-deleted and no restored event is appended. Code that called restore directly and caught the exception checks isSuccess instead."
258
+ },
259
+ {
260
+ "version": "0.365.0",
261
+ "type": "fix",
262
+ "title": "Retired-table scan handles DROP TABLE lists, schema-qualified names and RENAME",
263
+ "detail": "The scan of prior migrations for retired framework tables now marks every table in a DROP TABLE comma list as dropped, ignores a schema prefix such as \"public\".\"table\", and treats ALTER TABLE ... RENAME TO as dropping the old and creating the new name."
264
+ },
265
+ {
266
+ "version": "0.365.0",
267
+ "type": "fix",
268
+ "title": "kumiko-schema status reports connection errors; kumiko-schema-check knows the scaffold layout",
269
+ "detail": "schema status now prints a one-line error with a hint and exits 1 when the database is unreachable or missing, instead of an uncaught stacktrace (the URL is never printed). kumiko-schema-check exits 0 with a pointer to kumiko-schema validate for apps whose kumiko/schema.ts derives ENTITY_METAS from APP_FEATURES. New scaffolds create the app database in docker-compose."
270
+ },
271
+ {
272
+ "version": "0.365.0",
273
+ "type": "fix",
274
+ "title": "Retention and erasure writes skip the select-option rule",
275
+ "detail": "Framework system-user updates that name a writePurpose (retention, erasure) are no longer blocked by conditional select options. The null-clear comparison helper isUnchangedValue moved to engine/screen-helpers and is exported from ui-types so the renderer form and the executor share one definition."
276
+ },
277
+ {
278
+ "version": "0.365.0",
279
+ "type": "fix",
280
+ "title": "Mid-stream token expiry returns 401 unauthenticated",
281
+ "detail": "A stream dispatched with a token that expires while the stream is open now ends with UnauthenticatedError (401) instead of AccessDeniedError (403), so clients show the session-ended hint. Access revocation mid-stream still returns 403 access_denied."
282
+ },
283
+ {
284
+ "version": "0.365.0",
285
+ "type": "fix",
286
+ "title": "A where.tenantId of { in: [...] } or { eq } narrows TenantDb reads instead of being ignored",
287
+ "detail": "TenantDb.readWhere only understood a plain id or an array. An operator form fell back to the full read scope (own tenant plus reference rows), so { in: [ownTenant] } still returned SYSTEM reference rows. It now unwraps { in } and { eq } with the same helper the read steps use for their narrowing warning. Ids outside the caller's scope are still dropped and never widen the read."
288
+ },
289
+ {
290
+ "version": "0.365.0",
291
+ "type": "fix",
292
+ "title": "tenantlessAnonymous is blocked by an X-Tenant header only, not by the kumiko_tenant cookie",
293
+ "detail": "An earlier release note said a tenantlessAnonymous handler never runs when the client supplies X-Tenant or kumiko_tenant. Only the X-Tenant header blocks it. An ambient kumiko_tenant cookie does not, because the tenant resolver ignores the cookie on this path anyway."
294
+ },
295
+ {
296
+ "version": "0.365.0",
297
+ "type": "fix",
298
+ "title": "Test-stack rate limiting follows appContext.redis when rateLimitNowMs is set",
299
+ "detail": "With rateLimitNowMs the test stack builds its own rate-limit resolver; it now uses the redis instance passed via the app context instead of always the internal test redis, so buckets prefilled or cleared through the app redis take effect."
300
+ },
301
+ {
302
+ "version": "0.365.0",
303
+ "type": "fix",
304
+ "title": "The ZIP plausibility check picks the end record whose comment length reaches the end of the file",
305
+ "detail": "The scan took the last end-of-central-directory signature, so a signature inside the archive comment could point the check at forged entry names. A candidate now only counts when its offset plus 22 bytes plus its comment length equals the file length. A docx upload with bytes appended after the end record is therefore rejected as content_mismatch."
306
+ },
307
+ {
308
+ "version": "0.364.0",
309
+ "type": "fix",
310
+ "title": "A throwing command in a batch without a database connection releases its cap reservation",
311
+ "detail": "The sequential fallback of dispatchBatch (no DB connection) did not release the reserved capacity when a command threw instead of returning a failure result. It now releases the reservation before rethrowing."
312
+ },
313
+ {
314
+ "version": "0.364.0",
315
+ "type": "improvement",
316
+ "title": "Write handlers can declare a beforeTransaction phase, and file bytes can be uploaded before the transaction",
317
+ "detail": "defineWriteHandler accepts beforeTransaction(event, ctx), which the dispatcher runs after the pre-handler gates and any reserveBeforeTransaction and before the transaction opens, so a slow external call holds no pool connection. Its data reaches the handler as event.prepared; a failure result or throw rejects the write, and its release runs whenever the transaction does not commit. ctx.write, appendEvent, notify and the other write surfaces throw inside the phase, and a handler with the phase cannot run as a nested ctx.write. uploadFileBytesBeforeTransaction writes the bytes in that phase and createFileRefFromUploadedBytes creates the fileRef row in the handler; the release keeps the bytes when the row exists. Original files whose row never committed stay in storage, no job sweeps them."
318
+ },
319
+ {
320
+ "version": "0.364.0",
321
+ "type": "improvement",
322
+ "title": "Boot-validator and guard messages name the fix",
323
+ "detail": "Boot-validator and guard messages that only described the problem now also say what to do about it. The German configEdit and retention messages are in English. Validation and guard behavior is unchanged."
324
+ },
325
+ {
326
+ "version": "0.364.0",
327
+ "type": "improvement",
328
+ "title": "CacheSyncBus.publish takes { remoteOnly } to skip same-process listeners",
329
+ "detail": "`publish(topic, message, { remoteOnly: true })` delivers only to other processes. Use it when the publisher has already applied the change locally and a local fanout would trigger a redundant reload."
330
+ },
331
+ {
332
+ "version": "0.364.0",
333
+ "type": "fix",
334
+ "title": "changes.json no longer ships paths into private repositories",
335
+ "detail": "Older changelog entries named file paths and a docs path of private repositories. They now name only the repository. The changelog guard rejects a `<private-repo>/<path>` reference or a github.com link into a private repository."
336
+ },
337
+ {
338
+ "version": "0.364.0",
339
+ "type": "fix",
340
+ "title": "coerceRow no longer claims the raw driver row already has the converted shape",
341
+ "detail": "coerceRow now accepts a Record<string, unknown> driver row and returns the explicitly requested row type, with the single cast at that boundary. The raw event-consumer, pending-event and projection-rebuild queries return untyped rows instead of a caller-chosen generic. The eventMigrations JSDoc and the expand/contract guide now state that pii and encrypted fields reach transforms as field-bound ciphertext and must not be renamed or derived from."
342
+ },
343
+ {
344
+ "version": "0.364.0",
345
+ "type": "fix",
346
+ "title": "collectTableMetas rejects two features registering the same table name",
347
+ "detail": "Two entities or storeTables with the same table name produced duplicate metas that only failed later in `schema generate` with a message about a hand-added meta. collectTableMetas now throws at the first registration with both origins, so setupTestStack fails the same way. The duplicate-name error of the migration generator no longer assumes a hand-added meta."
348
+ },
349
+ {
350
+ "version": "0.364.0",
351
+ "type": "fix",
352
+ "title": "System-cron escape hatch audits are deduplicated per target and repeat daily",
353
+ "detail": "The per-process dedup of declared system-cron hatches keyed only on handler, kind and tenant, so a run writing several global tables was audited for the first table only, and a worker running longer than the audit retention left no event. The key now includes the target and the process audits again after 24 hours."
354
+ },
355
+ {
356
+ "version": "0.364.0",
357
+ "type": "improvement",
358
+ "title": "A detail() cache hit with an ownership predicate costs one database statement",
359
+ "detail": "The ownership re-check and the live stream-version read for a cached detail row now run as a single query, so a cache hit is no more expensive than a cache miss for entities with SQL ownership rules."
360
+ },
361
+ {
362
+ "version": "0.364.0",
363
+ "type": "fix",
364
+ "title": "A later cookieDomain or secureCookies:false no longer leaves stale __Host- auth and csrf cookies behind",
365
+ "detail": "expireLegacySessionCookieVariants covers both directions. With unprefixed names and secure cookies, setAuthCookies and clearSessionCookies now also expire __Host-kumiko_auth and __Host-kumiko_csrf as Secure, Path=/, host-only. Before, the browser kept sending the stale __Host-kumiko_csrf in X-CSRF-Token and every write failed with csrf_token_mismatch until it expired."
366
+ },
367
+ {
368
+ "version": "0.364.0",
369
+ "type": "fix",
370
+ "title": "Expired fileRef purge no longer stalls on rows that keep failing",
371
+ "detail": "The soft-delete cleanup always selected the oldest 500 expired fileRefs, so 500 permanently failing rows (unsafe key, missing list permission) blocked every newer expired fileRef of that tenant. A run now walks the expired rows page by page along (deletedAt, id) and ends on an empty page; failed rows are retried on the next run."
372
+ },
373
+ {
374
+ "version": "0.364.0",
375
+ "type": "fix",
376
+ "title": "Extra routes outside /api get the request body cap for every entry",
377
+ "detail": "extraRoutes with entry anonymous or user outside /api now run behind the maxRequestBytes body limit like signature routes already did, so an unauthenticated caller can no longer stream an oversized body into a public form or callback route."
378
+ },
379
+ {
380
+ "version": "0.364.0",
381
+ "type": "fix",
382
+ "title": "FileContext.refFromFileRef reads files whose fileRef moved to another tenant",
383
+ "detail": "A tenant handover moves the fileRef row but not the bytes, so the storageKey keeps the previous tenant's prefix and the tenant-bound `ctx.files.ref(key)` rejected it. FileContext gains `refFromFileRef({ storageKey })` for keys read from a fileRef row the caller has already tenant-checked: it skips the tenant-prefix check and still rejects unsafe keys. The variant route and ctx.derivatives.variant use it, so variants of handed-over files render again. `ref(key)` keeps the prefix check for freely supplied keys."
384
+ },
385
+ {
386
+ "version": "0.364.0",
387
+ "type": "improvement",
388
+ "title": "A save with file fields loads its fileRef rows once",
389
+ "detail": "The tenant check and the pending-upload binding of a save each read every named fileRef row inside the write transaction. They now share one query over the ids, restricted to the writer's tenant and live rows. Values that are not UUIDs are still skipped."
390
+ },
391
+ {
392
+ "version": "0.364.0",
393
+ "type": "improvement",
394
+ "title": "Agent guide and generated indexes ship in the framework package",
395
+ "detail": "The framework package now contains AGENTS.md plus an agents/ directory with generated feature, widget and example indexes, built from feature-manifest.json, the renderer-web exports and samples/recipes. The new agent-docs-drift guard in kumiko check fails when the committed files differ from the generator output. New apps get an AGENTS.md and a CLAUDE.md that point at the packaged guide."
396
+ },
397
+ {
398
+ "version": "0.364.0",
399
+ "type": "breaking",
400
+ "title": "Handler field references support business keys and array fields",
401
+ "migration": "handlerFieldReference(schema, field) now returns { entity, by, multiple } instead of the entity string. Replace reference with reference.entity; skip or handle fields where by is set (value is a business key, not an id) or multiple is true (value is an array)."
402
+ },
403
+ {
404
+ "version": "0.364.0",
405
+ "type": "fix",
406
+ "title": "An empty jsonText match list no longer returns every row",
407
+ "detail": "A where filter `{ jsonText: [] }` on a jsonb column used to add no condition, so the query returned all rows. It now matches no rows, like `in: []`. A `null` or non-object entry in the list throws the descriptive jsonText error instead of a bare TypeError."
408
+ },
409
+ {
410
+ "version": "0.364.0",
411
+ "type": "improvement",
412
+ "title": "Facet field docs name the reference exception",
413
+ "detail": "The docs of projectionList.facets and relatedList.facets said every facet field must be a declared column. A facet of type \"reference\" is exempt: it filters by id through the bound query's filters and needs a registered entity instead. Behavior is unchanged."
414
+ },
415
+ {
416
+ "version": "0.364.0",
417
+ "type": "fix",
418
+ "title": "list() reads rows and stream versions in one statement",
419
+ "detail": "The entity list read the rows first and the stream versions in a second statement, so a concurrent write could pair old row data with a newer version, and an update built on that version would pass the optimistic lock. The stream version is now a correlated subquery of the list statement, the same way detail() reads it."
420
+ },
421
+ {
422
+ "version": "0.364.0",
423
+ "type": "fix",
424
+ "title": "Existing tenant indexes pick up searchable fields added to the registry default",
425
+ "detail": "The lazy default configuration now also applies to an index whose stored searchable, filterable and sortable attributes are a strict subset of the current default (an older default after a deploy added fields). Indexes holding any attribute outside the current default are treated as an explicit configure() and stay untouched, as before."
426
+ },
427
+ {
428
+ "version": "0.364.0",
429
+ "type": "breaking",
430
+ "title": "Writes without an open transaction run as their own command; ctx.db writes there throw",
431
+ "detail": "Write handlers accept transaction \"perCommand\". Such a handler runs without a transaction of its own, so every ctx.write and ctx.writeAs it issues runs as its own top-level command with its own transaction, gates and afterCommit hooks. A perCommand handler cannot run as a nested ctx.write, cannot be part of a multi-command batch (422 per_command_handler_in_batch) and cannot combine with beforeTransaction or reserveBeforeTransaction (boot error). ctx.write and ctx.writeAs from stream handlers and top-level query handlers now take the same route, so afterCommit hooks of the called write, which never fired before, fire now. ctx.db writes (entity executor, insertOne, updateMany, deleteMany, db.global() writes) and event appends (appendEvent, unsafeAppendEvent, tryAppendEvent, fetchForWriting, archiveStream, restoreStream, snapshotAggregate) throw in stream handlers, top-level query handlers, perCommand handlers and in beforeTransaction and reserveBeforeTransaction. ctx.dbOutsideTransaction and ctx.outsideTransaction stay available, and ctx.db.unsafeRaw stays open as the audited escape hatch.",
432
+ "migration": "Stream and query code that writes through ctx.db or an executor bound to ctx.db calls a write handler through ctx.write or ctx.writeAs instead, or writes deliberately through ctx.dbOutsideTransaction. Check code that relied on afterCommit hooks of a write called from a stream or query handler not firing. bookCapUsage from a stream or query handler needs outsideTransaction true or a command. runInOwnTransaction(ctx.db, ...) stays locked in these contexts; runInOwnTransaction(ctx.dbOutsideTransaction, ...) is the way to a deliberate own transaction. Measured consumers: one stream handler (AI agent turn: conversation, activity and tool-call records through an executor on ctx.db, plus cap booking) and one query handler (AI text run: cap booking on ctx.db, log via ctx.write). No other consumer app has stream or query writes. The beforeTransaction/reserveBeforeTransaction lock affects no released code."
433
+ },
434
+ {
435
+ "version": "0.364.0",
436
+ "type": "fix",
437
+ "title": "decryptForRead and eager-loaded refs decrypt file, image, files and images fields that hold legacy ciphertext",
438
+ "detail": "Only the encrypt side skips file-reference fields. The decrypt side uses collectPiiSubjectFields again and passes plaintext through unchanged, so rows written by the old backfill, which encrypted the file uuid, are read as plaintext instead of failing with pii_ciphertext_leak."
439
+ },
440
+ {
441
+ "version": "0.364.0",
442
+ "type": "fix",
443
+ "title": "Cascade deletes honour the record-lock exemptions and ignore soft-deleted children",
444
+ "detail": "The cascade hook blocked a parent delete on a locked child even when the executor delete had skipped the lock guard (unsafeSkipLockGuard, a framework system write with a writePurpose, forget). DeleteContext now carries lockGuardBypassed and the hook follows it. Soft-deleted children no longer count as locked."
445
+ },
446
+ {
447
+ "version": "0.364.0",
448
+ "type": "fix",
449
+ "title": "reserveBeforeTransaction contexts now reject writes that would escape the transaction",
450
+ "detail": "The context passed to reserveBeforeTransaction now throws on ctx.write, writeAs, event appends, scheduleAfterCommit, notify and jobRunner, like the beforeTransaction phase does. dbOutsideTransaction, outsideTransaction and forTenant stay available because reservations deliberately write outside the transaction."
451
+ },
452
+ {
453
+ "version": "0.364.0",
454
+ "type": "fix",
455
+ "title": "Restore no longer writes plaintext PII from pre-KMS rows into the event log",
456
+ "detail": "Restore now decrypts the soft-deleted row and re-encrypts it like delete before storing previous, so rows written before the subject KMS was configured no longer leak plaintext personal fields into the restored event. Restoring such a row for an erased subject now fails like delete."
457
+ },
458
+ {
459
+ "version": "0.364.0",
460
+ "type": "fix",
461
+ "title": "bindReferencedFiles binds only refs of the record's tenant in one bulk load",
462
+ "detail": "File refs for a page are loaded with a single query restricted to the writer's tenant and live rows; system-owned refs are not bound to records."
463
+ },
464
+ {
465
+ "version": "0.364.0",
466
+ "type": "fix",
467
+ "title": "Boot validator covers option, prefill, emptyState, anonymous rate-limit and searchable-access gaps",
468
+ "detail": "Rejects optionsQuery combined with optionsAvailabilityQuery, invalid dashboard valueFormat kinds, from-today panels without ranges or brush, params on emptyState navigate/drawer, unknown submitPrefilled keys, per-user rate limits on tenantless anonymous handlers, unknown conditional select option values, and searchable fields (including embedded sub-fields) the reader cannot access."
469
+ },
470
+ {
471
+ "version": "0.364.0",
472
+ "type": "fix",
473
+ "title": "Malformed requestId or batch command shapes return 400 instead of failing later",
474
+ "detail": "The dispatch and batch routes parse requestId and each command before dispatching, returning a validation error for malformed shapes, and the JSON body reader no longer relies on a type assertion."
475
+ },
476
+ {
477
+ "version": "0.364.0",
478
+ "type": "fix",
479
+ "title": "files/images maxCount is validated on write",
480
+ "detail": "The generated write schema applies .max(maxCount) to files and images fields, so payloads with more ids than allowed are rejected with a validation error."
481
+ },
482
+ {
483
+ "version": "0.364.0",
484
+ "type": "fix",
485
+ "title": "render/parse roundtrip keeps secrets valid when writeRoles is empty",
486
+ "detail": "renderSecret and renderSecretNamespace omit writeRoles when the list is empty so the rendered source parses back to the same definition."
487
+ },
488
+ {
489
+ "version": "0.364.0",
490
+ "type": "fix",
491
+ "title": "A stale auth cookie on the SSE stream ends the web session with a session-ended frame",
492
+ "detail": "With anonymousAccess, a stale or revoked auth cookie downgrades /api/sse to an anonymous 200, so the web client never noticed the session end and live updates stopped silently. The stream now opens with a session-ended frame carrying the rejection code and expires the auth cookies; the renderer-web live-events client calls onSessionEnded once for it."
493
+ },
494
+ {
495
+ "version": "0.364.0",
496
+ "type": "fix",
497
+ "title": "Unit tests no longer leak a console.warn spy into later files",
498
+ "detail": "The search payload precedence tests spy on console.warn per test and restore it, so later files in the same --no-isolate worker start with the real console.warn. The mail editor missing-language test waits for the placeholder rail instead of reading it before the validate query settled."
499
+ },
500
+ {
501
+ "version": "0.364.0",
502
+ "type": "fix",
503
+ "title": "Calling transaction() on a tx or savepoint handle fails with an InternalError",
504
+ "detail": "`RawClient.begin` is now typed optional, and `transaction()` checks it like `runInNewTransaction` does, so a nested call raises an InternalError (\"runner has no begin()\") instead of a TypeError. The migrate runner requires `begin` on its client and rejects handles without it before running any SQL."
505
+ },
2
506
  {
3
507
  "version": "0.363.0",
4
508
  "type": "improvement",
@@ -123,7 +627,7 @@
123
627
  "type": "breaking",
124
628
  "title": "Unbound uploads are private to the uploader (security)",
125
629
  "detail": "The default file access guard denies every operation on a file with no entityType or no entityId unless the caller is the uploader or has a role in privilegedRoles; the file routes answer 404 as for any denied file. Before, files without entityType were readable by the whole tenant and pending uploads until a record claimed them. bindPendingFileRefs now also runs for updates and binds the file ids named in the update's changes for file, image, files and images fields (same entityType and fieldName, entityId null, insertedById = caller), so createFileRefFromBytes with attachTo but no entityId plus a following update write attaches the file to the record.",
126
- "migration": "Code that shows a file without entityType to other users (tenant logo, avatar, mail attachment, import preview, AI-agent output) must upload it with entityType and fieldName of a file field on a record and save the record, so readers of that record can read it; or keep it uploader-only, run the read as a role in privilegedRoles, or set a custom accessGuard in the files feature options. Apps that set accessGuard keep their own decision. Code that reads another user's pending upload before the record is saved must bind the file first or run as the uploader. Known case: solon's document upload (`document/web/upload-file.ts`) posts without entityType and the later document record (created by the ingest job, not the uploader) stores the id in its `fileId` field, so other users no longer read the original; solon must upload with entityType and fieldName of `fileId` and bind the file when the record is created, because a different creator cannot claim the pending upload."
630
+ "migration": "Code that shows a file without entityType to other users (tenant logo, avatar, mail attachment, import preview, AI-agent output) must upload it with entityType and fieldName of a file field on a record and save the record, so readers of that record can read it; or keep it uploader-only, run the read as a role in privilegedRoles, or set a custom accessGuard in the files feature options. Apps that set accessGuard keep their own decision. Code that reads another user's pending upload before the record is saved must bind the file first or run as the uploader. Known case: solon's document upload posts without entityType and the later document record (created by the ingest job, not the uploader) stores the id in its `fileId` field, so other users no longer read the original; solon must upload with entityType and fieldName of `fileId` and bind the file when the record is created, because a different creator cannot claim the pending upload."
127
631
  },
128
632
  {
129
633
  "version": "0.361.0",
@@ -156,7 +660,7 @@
156
660
  "type": "breaking",
157
661
  "title": "Schema generate rejects a table listed twice in ENTITY_METAS",
158
662
  "detail": "When ENTITY_METAS contained the same tableName twice, the migration diff kept only the last definition and compared it against the previous snapshot entry, so a new index was written to .snapshot.json but no CREATE INDEX reached the migration SQL. kumiko-schema generate, kumiko-schema validate and programmatic generateMigration now throw (generate and validate exit 1 with a message naming every duplicated table) before any file is written.",
159
- "migration": "Remove hand-added metas for tables a feature already registers from kumiko/schema.ts. Known: phronexsis kumiko/schema.ts appends IN_APP_MESSAGES_META next to collectTableMetas(FEATURES); drop it (and its comment), then run kumiko-schema generate — the existing in_app_messages_tenant_user_created_idx comes out as CREATE INDEX IF NOT EXISTS (no-op) and the two legacy indexes as commented review drops. The duplicate in_app_messages entry in .snapshot.json disappears with that generate."
663
+ "migration": "Remove hand-added metas for tables a feature already registers from kumiko/schema.ts. Known: phronexsis's schema file appends IN_APP_MESSAGES_META next to collectTableMetas(FEATURES); drop it (and its comment), then run kumiko-schema generate — the existing in_app_messages_tenant_user_created_idx comes out as CREATE INDEX IF NOT EXISTS (no-op) and the two legacy indexes as commented review drops. The duplicate in_app_messages entry in .snapshot.json disappears with that generate."
160
664
  },
161
665
  {
162
666
  "version": "0.361.0",
@@ -182,7 +686,7 @@
182
686
  "type": "breaking",
183
687
  "title": "Secure host-only session cookies use the __Host- prefix",
184
688
  "detail": "Auth, csrf and tenant cookies are named __Host-kumiko_auth, __Host-kumiko_csrf and __Host-kumiko_tenant when they are written Secure and without a Domain (the tenant cookie is always host-only). With auth.cookieDomain or without Secure the auth and csrf names stay kumiko_auth and kumiko_csrf. New auth option secureCookies forces Secure (default NODE_ENV === \"production\"). Login deletes the legacy unprefixed host-only cookies, logout and deleteTenantCookie delete the prefixed and the legacy names. The server reads only the resolved names, so a request that carries only the legacy kumiko_auth is anonymous. AUTH_COOKIE_NAME, CSRF_COOKIE_NAME and TENANT_COOKIE_NAME are removed from the framework and dispatcher-live; the base names and sessionCookieName() are exported from @cosmicdrift/kumiko-types/session-cookies and @cosmicdrift/kumiko-framework/api, getSessionCookies(c) returns the resolved names of a request. The testing package's CSRF_COOKIE_NAME stays and resolves to the unprefixed name (e2e servers run without Secure).",
185
- "migration": "Every user logs in once after the deploy, because the old kumiko_auth cookie is no longer read. Custom clients, proxies or scripts that read or forward the cookies by literal name break: read the names through sessionCookieName() / resolveSessionCookieNames() (types package) or getSessionCookies(c) on the server, and handle both the prefixed and the plain name in browser code (readCsrfToken() already does). Replace imports of AUTH_COOKIE_NAME, CSRF_COOKIE_NAME and TENANT_COOKIE_NAME with AUTH_COOKIE_BASE, CSRF_COOKIE_BASE and TENANT_COOKIE_BASE plus sessionCookieName(); e2e helpers that imported CSRF_COOKIE_NAME from @cosmicdrift/kumiko-dispatcher-live import it from @cosmicdrift/kumiko-testing/e2e instead (known: solon e2e/_helpers/api.ts and e2e/session-ended.spec.ts, kumiko-enterprise ai-agent-live-transcription.integration.test.ts uses AUTH_COOKIE_NAME). A reverse proxy that strips or rewrites cookie names must pass the __Host- names unchanged. Staging or preview deployments over plain http need no change (Secure is off there); set auth.secureCookies (also accepted by runProdApp) explicitly when TLS terminates before the app and NODE_ENV is not \"production\". A standalone createAuthRoutes mount outside buildServer must also mount sessionCookiesMiddleware with the same resolved names, otherwise the readers fall back to the domain-less default."
689
+ "migration": "Every user logs in once after the deploy, because the old kumiko_auth cookie is no longer read. Custom clients, proxies or scripts that read or forward the cookies by literal name break: read the names through sessionCookieName() / resolveSessionCookieNames() (types package) or getSessionCookies(c) on the server, and handle both the prefixed and the plain name in browser code (readCsrfToken() already does). Replace imports of AUTH_COOKIE_NAME, CSRF_COOKIE_NAME and TENANT_COOKIE_NAME with AUTH_COOKIE_BASE, CSRF_COOKIE_BASE and TENANT_COOKIE_BASE plus sessionCookieName(); e2e helpers that imported CSRF_COOKIE_NAME from @cosmicdrift/kumiko-dispatcher-live import it from @cosmicdrift/kumiko-testing/e2e instead (known: solon's e2e helpers and specs, and a kumiko-enterprise integration test that uses AUTH_COOKIE_NAME). A reverse proxy that strips or rewrites cookie names must pass the __Host- names unchanged. Staging or preview deployments over plain http need no change (Secure is off there); set auth.secureCookies (also accepted by runProdApp) explicitly when TLS terminates before the app and NODE_ENV is not \"production\". A standalone createAuthRoutes mount outside buildServer must also mount sessionCookiesMiddleware with the same resolved names, otherwise the readers fall back to the domain-less default."
186
690
  },
187
691
  {
188
692
  "version": "0.360.0",
@@ -254,7 +758,7 @@
254
758
  "type": "breaking",
255
759
  "title": "Entity PII fields need whenAbsent: \"tenant\" when the owner field is nullable",
256
760
  "detail": "personal: { of: \"<owner>\", whenAbsent: \"tenant\" } makes rows without an owner editable, deletable and forgettable with an active KMS, without plaintext PII. The subject is resolved in this order: the owner on the previous row, the owner the same event sets, then the tenant key (tenant:<tenantId>). Before, an update, delete or forget of an ownerless row failed with SubjectResolutionError (HTTP 500) once a KMS was configured. backfill-pii follows the same chain and checks the projection row for the owner before it falls back to the tenant key; an updated event whose previous.owner is null but whose changes.owner is set no longer loses the owner. Entity fields have no plaintext fallback, so \"tenant\" is the only value. bundled jobs: cron and system runs (triggeredById null) now store payload, error and log messages as ciphertext under the system tenant key instead of plaintext.",
257
- "migration": "Boot now aborts for a personal: { of: \"<owner>\" } field whose owner field is not required and that declares no whenAbsent: [Feature X] Field \"f\" on entity \"e\" is personal: { of: \"ownerId\" }, but \"ownerId\" is not required — rows without an owner would fail to encrypt (SubjectResolutionError, HTTP 500) on update, delete and forget. Declare { personal: { of: \"ownerId\", whenAbsent: \"tenant\" } } to encrypt those rows under the tenant key, or make \"ownerId\" required: true. Entity fields have no plaintext fallback (fw#3654). Fix: add whenAbsent: \"tenant\", or make the owner field required: true if every row has one. whenAbsent on personal: { of: \"id\" } throws at declaration. Known consumer sites: kumiko-enterprise kumiko-credit entity.ts and bauspar-entity.ts (ownerUserId), ai-agent entity.ts (authorId, ownerId) and attachment-store.ts (authorId), ai-conversation entity.ts (authorId), pattern-storage entity.ts (authorId), offlot-app src/features/lead/schema/entity.ts (enquiryId). Existing plaintext job-run rows of cron runs stay readable; new ones are ciphertext."
761
+ "migration": "Boot now aborts for a personal: { of: \"<owner>\" } field whose owner field is not required and that declares no whenAbsent: [Feature X] Field \"f\" on entity \"e\" is personal: { of: \"ownerId\" }, but \"ownerId\" is not required — rows without an owner would fail to encrypt (SubjectResolutionError, HTTP 500) on update, delete and forget. Declare { personal: { of: \"ownerId\", whenAbsent: \"tenant\" } } to encrypt those rows under the tenant key, or make \"ownerId\" required: true. Entity fields have no plaintext fallback (fw#3654). Fix: add whenAbsent: \"tenant\", or make the owner field required: true if every row has one. whenAbsent on personal: { of: \"id\" } throws at declaration. Known consumers: kumiko-enterprise (entities with an ownerUserId, authorId or ownerId owner field) and offlot-app (a lead entity with enquiryId). Existing plaintext job-run rows of cron runs stay readable; new ones are ciphertext."
258
762
  },
259
763
  {
260
764
  "version": "0.357.0",
@@ -336,7 +840,7 @@
336
840
  "version": "0.354.0",
337
841
  "type": "breaking",
338
842
  "title": "enrichWithReferences and enrichRowWithReferences require a viewer { user, parentVisibility }",
339
- "migration": "Custom handlers pass `{ user: query.user, parentVisibility: { entities: ctx.registry.getAllEntities() } }` as the fifth argument. No direct callers are known in the workspace apps. Apps can now point reference fields at team- or row-scoped entities (for example solon unitId/propertyId)."
843
+ "migration": "Custom handlers pass `{ user: query.user, parentVisibility: { entities: ctx.registry.getAllEntities() } }` as the fifth argument. No direct callers are known in the workspace apps. Apps can now point reference fields at team- or row-scoped entities (for example solon's unitId and propertyId)."
340
844
  },
341
845
  {
342
846
  "version": "0.354.0",
@@ -658,7 +1162,7 @@
658
1162
  "type": "breaking",
659
1163
  "title": "Key Manager slots are required, LEGACY_SLOTS default removed",
660
1164
  "detail": "Key Manager slots are required: no more LEGACY_SLOTS default\n`resolvePlatformKeks`, `resolveKmsWiringAsync` and `requireKmsWiringAsync` no longer fall back to the three platform slots; `slots` is a required option. `runSchemaCli` called with `features` now requires `kmsSlots` and exits 1 with a hint before any DB connect or migration apply when it is missing. `kmsSlots: []` stays valid for apps that wire no Key Manager slots. The scaffolded `bin/kumiko.ts` passes `kmsSlots` derived from the composed env schema.",
661
- "migration": "Pass `slots` (for example `kmsSlotsOf(<app>ComposedEnv.schema)`) to `resolvePlatformKeks`, `resolveKmsWiringAsync` and `requireKmsWiringAsync`. In `bin/kumiko.ts`, `runSchemaCli(..., { features })` needs `kmsSlots: kmsSlotsOf(<app>ComposedEnv.schema)` (or `[]` when the app wires no Key Manager slots. With `[]` every `*_CIPHERTEXT` env var is ignored, so an app that ships a ciphertext must pass its slots); without it the CLI exits 1 before applying migrations.\nWorkspace state at release: show-pony `bin/kumiko.ts` and its docs copy `kumiko-platform/apps/docs/_samples/show-pony/bin/kumiko.ts` still call `runSchemaCli` with `features` only. publicstatus, solon, offlot-app, money-horse, phronexsis and kumiko-studio already pass `kmsSlots` / `slots`."
1165
+ "migration": "Pass `slots` (for example `kmsSlotsOf(<app>ComposedEnv.schema)`) to `resolvePlatformKeks`, `resolveKmsWiringAsync` and `requireKmsWiringAsync`. In `bin/kumiko.ts`, `runSchemaCli(..., { features })` needs `kmsSlots: kmsSlotsOf(<app>ComposedEnv.schema)` (or `[]` when the app wires no Key Manager slots. With `[]` every `*_CIPHERTEXT` env var is ignored, so an app that ships a ciphertext must pass its slots); without it the CLI exits 1 before applying migrations.\nWorkspace state at release: show-pony `bin/kumiko.ts` and its copy in the kumiko-platform docs samples still call `runSchemaCli` with `features` only. publicstatus, solon, offlot-app, money-horse, phronexsis and kumiko-studio already pass `kmsSlots` / `slots`."
662
1166
  },
663
1167
  {
664
1168
  "version": "0.342.0",
@@ -2065,7 +2569,7 @@
2065
2569
  "type": "breaking",
2066
2570
  "title": "TenantDataHookCtx/UserDataHookCtx.db is a tenant-filtered TenantDb; unfiltered hook access needs r.useExtension({ escapeHatch }) (fw#2914 Zugang 3).",
2067
2571
  "detail": "EXT_TENANT_DATA `destroy` hooks (`TenantDataHookCtx.db`) and EXT_USER_DATA `export`/`delete` hooks (`UserDataHookCtx.db`) no longer receive the unfiltered `DbRunner` that was bound to the extension's owning handler. Both are now a `TenantDb` in \"tenant\" mode, built via the public `createTenantDb(...)`, bound to the hook's `tenantId`: reads/writes go through `ctx.db.selectMany/fetchOne/insertOne/updateMany/deleteMany`, already scoped to that tenant plus `SYSTEM_TENANT_ID` reference rows. `r.useExtension(EXT_USER_DATA | EXT_TENANT_DATA, entityName, { ... })` gains an `escapeHatch?: { reason: string }` option, validated non-empty at registration time (throws at boot for an empty/whitespace reason). Declaring it grants `ctx.db.unsafeRaw(reason)` inside that entity's hooks — same reason string — which returns the real, unfiltered `DbRunner`; without it, `ctx.db.unsafeRaw(reason)` throws `AccessDeniedError`. Every granted use reports an `\"unsafe-raw\"` escape-hatch audit event (`{ handler: \"userData:<entity>\"|\"tenantData:<entity>\", kind, reason, tenantId, actor }`) through the caller's `_escapeHatchAuditSink` when wired, else a `security:escape-hatch-used` warn-log — same #2861 pattern PR #2934 already applied to the job runner, and the same one fw#2914's earlier JobContext.db and read-step changes already applied elsewhere in the framework. `run-forget-cleanup.ts`/`run-user-export.ts` (user-data-rights) thread `escapeHatchAuditSink`/`actor` from their owning `r.job`'s `ctx._escapeHatchAuditSink`/`ctx.systemUser.id` down to each hook's `TenantDb`, so a declared use is attributed to the real job run, not `\"<unattributed>\"`.\n`asRawClient(ctx.db)` inside a hook now throws immediately: `asRawClient` rejects a `TenantDb`-shaped argument (\"received a tenant-scoped TenantDb (ctx.db). Raw SQL helpers do not apply the tenant filter — use a ctx.db.<method>, or ctx.db.unsafeRaw(reason) with escapeHatch: { reason } ...\"). The framework's `DbRunner`-typed free functions (`selectMany(db, table, where)`, `fetchOne`, `insertOne`, `updateMany`, `deleteMany`, `createTenantDb(db, tenantId, mode)`, etc. from `@cosmicdrift/kumiko-framework/bun-db`/`/db`) now reject `ctx.db` as their first argument at compile time too — `TenantDb` does not structurally satisfy `DbRunner` (no `.unsafe`/`.begin`).\nA table built via `createEntity`+`buildEntityTable`+`r.entity(...)` carries the pre-existing `EXECUTOR_ONLY` brand, so `ctx.db.insertOne/updateMany/deleteMany(table, ...)` against it is a compile error by design (\"a managed projection is only writable through the executor\"); a hook that needs to write such a table directly (bulk hard-delete, jsonb key removal, an UPDATE expression `updateMany`'s literal-value `set` can't express) needs the `escapeHatch` + `ctx.db.unsafeRaw(reason)` route, filtering by tenant/owner in the SQL text itself — the escape hatch removes automatic tenant-filtering, not the hook author's responsibility to filter. A table built via `defineUnmanagedTable` carries no such brand and stays writable through `ctx.db`'s typed methods directly.\nThis PR's own `custom-fields` user-data-rights wiring (`wire-user-data-rights.ts`) uses the escape hatch for exactly this reason: `selectCustomFieldsHostRows` runs against a dynamic host table name unknown at compile time, so it can't use `ctx.db`'s typed methods. CI's `AST-Guards (shared runner)` step (`@cosmicdriftgamestudio/kumiko-guards@^0.36.0`, pinned) does not yet recognize this call shape — `isEscapeHatchDeclaredFunction` in `guard-escape-hatch-declared.ts` only matches a property/method literally named `handler`, or a direct call to `hook`/`writeHandler`/`queryHandler`/`streamHandler`; `r.useExtension(EXT_USER_DATA, entityName, { export: exportHook, escapeHatch })` matches neither, because `useExtension` isn't in `ESCAPE_HATCH_CALL_METHODS` and the hook property here is `export`/`delete`, never `handler`. This also isn't fixable by baselining from kumiko-framework: `security-baselines/<repo>.json` ships inside the pinned `@cosmicdriftgamestudio/kumiko-guards` npm package itself, and `run-guards.ts`'s own comment states it is rewritten only by \"the infra baseline-maintenance run over all repos on main\" — never by a consuming repo's local run, and never persisted through a consuming repo's `bun install`. The fix belongs in the guard's owning repo (presumably `infra`, per this workspace's root `CLAUDE.md`): add `useExtension` to `ESCAPE_HATCH_CALL_METHODS`, or otherwise recognize the framework's actual hook-property names (`export`/`delete`/`destroy`), then release a new `@cosmicdriftgamestudio/kumiko-guards` version and bump the range in kumiko-framework's `package.json`. Until that lands, this PR's `AST-Guards (shared runner)` CI check stays red on this one finding; every other check is green.",
2068
- "migration": "A hook that only reads/writes its own tenant through `ctx.db` method calls (`ctx.db.selectMany/fetchOne/insertOne/updateMany/deleteMany`) needs no change — it now gets the same tenant filter every other `TenantDb` consumer already had. A hook that treated `ctx.db` as a raw connection (`asRawClient(ctx.db)`, `ctx.db` passed as the first argument to a free-function `selectMany`/`fetchOne`/`insertOne`/`updateMany`/`deleteMany`/`createTenantDb`, a cast to `DbRunner`/`DbConnection`, or `\"raw\" in db ? db.raw : db`) must add `escapeHatch: { reason: \"<why>\" }` to its `r.useExtension(EXT_USER_DATA | EXT_TENANT_DATA, entityName, { ... })` registration and fetch the runner explicitly with `ctx.db.unsafeRaw(\"<why>\")` (same reason string) at the point of use. Prefer converting to `ctx.db`'s typed methods first — reach for `unsafeRaw` only when the target table is `EXECUTOR_ONLY`-branded (a managed `r.entity(...)` table; typed writes reject it by design — see the `EXECUTOR_ONLY`/`WritableTable` note in Detail) or the SQL needs an expression the typed API can't produce (e.g. a jsonb `-` key-removal `UPDATE`). No codemod — cross-hook raw-SQL usage is expected to be rare enough, and tenant-filter-relevant enough, to review by hand per hook. Hand-built `TenantDataHookCtx`/`UserDataHookCtx` objects in tests need `db: createTenantDb(conn, tenantId, \"tenant\", undefined, undefined, undefined, { unsafeRaw: { reason: \"<why>\" } })` instead of a plain connection.\n\nAudit at release — kumiko-enterprise, 7 files across 5 packages, none touched by this PR (out of scope; must be migrated in kumiko-enterprise itself before it bumps its pinned `@cosmicdrift/kumiko-framework`/`@cosmicdrift/kumiko-types` version, or `tsc` fails):\n- `packages/kumiko-credit/src/hooks/userdata-hook-shared.ts` (`forgetMatchingRows`/`anonymizeMatchingRows`, shared by bauspar + credit's delete hooks): `createTenantDb(ctx.db, ctx.tenantId, \"system\")` and `selectMany(ctx.db, table, where)` both take `ctx.db` as a raw-`DbRunner`-typed argument — needs `escapeHatch` on both entities' `r.useExtension(...)` plus `createTenantDb(ctx.db.unsafeRaw(\"<why>\"), ctx.tenantId, \"system\")`; the `selectMany` call can drop the free-function form entirely and become `ctx.db.selectMany(table, where)` (already tenant-scoped to `ctx.tenantId`, no need for \"system\" mode there).\n- `packages/kumiko-credit/src/hooks/bauspar.userdata-hook.ts` and `.../credit.userdata-hook.ts`: each has its own inline `selectMany(ctx.db, table, { ... })` (export hook) — same fix, `ctx.db.selectMany(table, { ... })`.\n- `packages/ai-agent/src/hooks/ai-agent-turn.userdata-hook.ts`, `packages/ai-conversation/src/hooks/designer-conversation-turn.userdata-hook.ts`, `packages/pattern-storage/src/hooks/pattern-file.userdata-hook.ts`, `packages/prompt-store/src/hooks/prompt-template-version.userdata-hook.ts`: each has the same `createTenantDb(ctx.db, ctx.tenantId, \"system\")` + two `selectMany(ctx.db, table, { ... })` inline pair as `userdata-hook-shared.ts` above — same fix per file.\n\n`packages/ai-foundation/src/providers/provenance.ts` (`recordAiCall`) and `packages/ai-pipeline/src/steps/shared.ts` (`recordStepProvenance`) were checked and need NO change: neither registers an EXT_USER_DATA/EXT_TENANT_DATA hook (they run off `HandlerContext`/`WorkflowPipelineCtx`, unrelated to this PR — that context's `.db` was already `TenantDb` from an earlier fw#2914 change), and both already use exactly the pattern this PR expects from hooks too: `withUnsafeRawGrant(ctx.dbOutsideTransaction ?? ctx.db, { reason: PROVENANCE_UNSAFE_RAW_REASON }).unsafeRaw(reason)`.\n\nGuard-version gap: once each of these 7 files declares `escapeHatch` on its `r.useExtension(...)` registration, expect the same `AST-Guards (shared runner)` failure this PR hits on `wire-user-data-rights.ts` (see Detail above) — the currently-pinned `@cosmicdriftgamestudio/kumiko-guards@^0.36.0` doesn't recognize `escapeHatch` on a `useExtension` call. That guard bump has to land (and kumiko-enterprise's pinned guards range has to move) before or alongside this migration, not after."
2572
+ "migration": "A hook that only reads/writes its own tenant through `ctx.db` method calls (`ctx.db.selectMany/fetchOne/insertOne/updateMany/deleteMany`) needs no change — it now gets the same tenant filter every other `TenantDb` consumer already had. A hook that treated `ctx.db` as a raw connection (`asRawClient(ctx.db)`, `ctx.db` passed as the first argument to a free-function `selectMany`/`fetchOne`/`insertOne`/`updateMany`/`deleteMany`/`createTenantDb`, a cast to `DbRunner`/`DbConnection`, or `\"raw\" in db ? db.raw : db`) must add `escapeHatch: { reason: \"<why>\" }` to its `r.useExtension(EXT_USER_DATA | EXT_TENANT_DATA, entityName, { ... })` registration and fetch the runner explicitly with `ctx.db.unsafeRaw(\"<why>\")` (same reason string) at the point of use. Prefer converting to `ctx.db`'s typed methods first — reach for `unsafeRaw` only when the target table is `EXECUTOR_ONLY`-branded (a managed `r.entity(...)` table; typed writes reject it by design — see the `EXECUTOR_ONLY`/`WritableTable` note in Detail) or the SQL needs an expression the typed API can't produce (e.g. a jsonb `-` key-removal `UPDATE`). No codemod — cross-hook raw-SQL usage is expected to be rare enough, and tenant-filter-relevant enough, to review by hand per hook. Hand-built `TenantDataHookCtx`/`UserDataHookCtx` objects in tests need `db: createTenantDb(conn, tenantId, \"tenant\", undefined, undefined, undefined, { unsafeRaw: { reason: \"<why>\" } })` instead of a plain connection.\n\nAudit at release: kumiko-enterprise has seven user-data hook files across five packages that take `ctx.db` as a raw `DbRunner` argument (`createTenantDb(ctx.db, ctx.tenantId, \"system\")` and free-function `selectMany(ctx.db, table, where)`). Each needs `escapeHatch` on its `r.useExtension(...)` plus `createTenantDb(ctx.db.unsafeRaw(\"<why>\"), ctx.tenantId, \"system\")`, and the reads become `ctx.db.selectMany(table, where)` (already tenant-scoped, no \"system\" mode needed). They must be migrated in kumiko-enterprise itself before it bumps its pinned `@cosmicdrift/kumiko-framework`/`@cosmicdrift/kumiko-types` version, or `tsc` fails.\n\nGuard-version gap: a consumer that declares `escapeHatch` on a `r.useExtension(...)` registration needs a `@cosmicdriftgamestudio/kumiko-guards` release newer than `^0.36.0`; the shared AST guard run of older versions does not recognize it. Bump the guards range before or alongside this migration, not after."
2069
2573
  },
2070
2574
  {
2071
2575
  "version": "0.279.0",
@@ -2196,7 +2700,7 @@
2196
2700
  "type": "fix",
2197
2701
  "title": "Projection/MSP rebuild aborts instead of silently dropping row level security from the live table (fw#2907).",
2198
2702
  "detail": "New `assertLiveTableHasNoRowLevelSecurity(tx, tableName, projectionName?)` in `db/queries/shadow-swap.ts` checks `pg_class.relrowsecurity`/`relforcerowsecurity` and `pg_policy` (public schema) for the live table before the shadow swap. `rebuildProjection` and `rebuildMultiStreamProjection` now call it before `buildShadowTable`, alongside the existing `assertLiveColumnsMatchMeta` fence, and `swapShadowIntoLive` re-checks it again right before the cutover under the fence. Previously the shadow, built from `EntityTableMeta` (which carries no RLS/policy information), would swap over a live table with RLS enabled/forced or any policy and silently drop all of it. `replayMigrationsDir`'s shape-neutral ALTER-clause recognizer (`db/replay-migration-sql.ts`) now also accepts `FORCE`/`NO FORCE ROW LEVEL SECURITY` instead of throwing \"unparsed table-DDL statement\".",
2199
- "migration": "No action for tables without RLS. A table with RLS enabled/forced or any policy can no longer be rebuilt online — rebuild fails with the table name; drop the RLS/policies or keep the table out of rebuild (see kumiko-platform docs/plans/rls-evaluation.md)."
2703
+ "migration": "No action for tables without RLS. A table with RLS enabled/forced or any policy can no longer be rebuilt online — rebuild fails with the table name; drop the RLS/policies or keep the table out of rebuild."
2200
2704
  },
2201
2705
  {
2202
2706
  "version": "0.271.0",