[
  {
    "version": "0.209.1",
    "type": "breaking",
    "title": "Job runs no longer go through the event store; read_job_runs table renamed to store_job_runs (fw#2243).",
    "detail": "Job runs (jobRun) no longer go through the event store. Every job execution used to append a run-started + run-completed/run-failed event replayed through two inline projections — in the busiest apps this was ~99% of all events ever written, for data nothing else replays or subscribes to. onJobStart/onJobComplete/onJobFailed now write straight into the (renamed) store_job_runs / store_job_run_logs tables, with a new daily jobs:job:retention-cleanup job (retentionDays, default 30) purging old rows so the tables don't grow forever.",
    "migration": "Breaking for raw-SQL consumers: the table is renamed read_job_runs → store_job_runs (store_job_run_logs is unchanged). The migration drops read_job_runs outright — old run history is not preserved, it was operational/debug data, not a system of record. Apps that only use the shipped job-runs-screen/jobs:query:* handlers are unaffected; apps with a raw SQL dependency on read_job_runs need a follow-up on their side."
  },
  {
    "version": "0.201.0",
    "type": "breaking",
    "title": "IdempotencyGuard.check()/.store() gain a discriminated result + token param on top of the 0.198.0 signature (fw#2139).",
    "detail": "Fixes two idempotency-lock races that could let a duplicate request re-run a write handler or silently overwrite a fresher cached result. `waitTimeoutMs` (how long a duplicate request waits for the in-flight one) is now clamped to always exceed `pendingTtlSeconds` (the in-progress lock's own TTL) — previously the defaults (30s lock vs. 25s wait) let a retry give up and re-execute the handler while the original call was still legitimately running. `IdempotencyGuard.store()` now does an atomic compare-and-swap against the exact lock token the calling run acquired (Redis EVAL) instead of an unconditional SET, so a stale, slow-finishing run can no longer stomp the result a reclaiming run already persisted after the lock expired. `IdempotencyGuard.check()` now returns a discriminated `{ status: \"cached\", result }` / `{ status: \"acquired\", token }` union instead of `string | null`, and `store()` takes the acquired token as a new parameter.",
    "migration": "Layered on top of the 0.198.0 signature change: check() is now check(tenantId, userId, requestId) returning { status: \"cached\", result } | { status: \"acquired\", token }; store() is now store(tenantId, userId, requestId, token). Both call sites in this repo (dispatch-batch.ts, the dispatcher test mock) are already updated; any code outside this repo calling IdempotencyGuard directly needs the same update."
  },
  {
    "version": "0.201.0",
    "type": "breaking",
    "title": "GET /files/:id now sniffs bytes and serves svg/txt/csv/json/md as application/octet-stream instead of inline (fw#2140).",
    "detail": "GET /files/:id served the stored mimeType as Content-Type without verifying it against the file's actual bytes — a client can declare any MIME at upload time, so an attacker could upload real HTML/SVG content and have it served back with a trusted-looking Content-Type from the app origin, enabling stored XSS. Uploads themselves are still accepted regardless of declared MIME (this is unchanged); the fix hardens serving instead. The download route now sniffs the file's magic bytes and only serves the sniffed Content-Type inline when it matches a known-safe binary signature (png/jpeg/gif/webp/pdf) AND matches the declared MIME from upload. Anything else — including a genuine mismatch, or file types with no reliable binary signature such as svg/txt/csv/json/md — is now served as application/octet-stream. This also adds X-Content-Type-Options: nosniff to GET /files/:id, which previously had none.",
    "migration": "Breaking for consumers that render uploaded svg/txt/csv/json/md files inline (e.g. an <img src> pointing at GET /files/:id): those now download as application/octet-stream instead of rendering. Route such content through a purpose-built safe viewer if inline rendering is required."
  },
  {
    "version": "0.198.0",
    "type": "breaking",
    "title": "IdempotencyGuard.check/.store signature changed to (tenantId, userId, requestId); SqlExpression is branded (fw#2049).",
    "detail": "Security hardening (audit \"Welle 2\"): closes a request-supplied-JSON-can-forge-raw-SQL path and a cross-tenant idempotency-cache collision. `SqlExpression` is now branded — only the `sql` template tag and `sql.raw(...)` produce a value the query layer recognizes as raw SQL; an object literal built by hand (`{ kind: \"sql-expr\", sql: ..., params: ... }`) is no longer treated as raw SQL and gets bound as an ordinary JSON parameter instead, surfacing as a broken query rather than a silent vulnerability. `IdempotencyGuard.check`/`.store` moved from `(requestId)` to `(tenantId, userId, requestId)` so the idempotency cache can no longer be hit across tenants/users by an attacker who guesses or replays a requestId; the Redis key format changed from `${prefix}${requestId}` to `${prefix}${tenantId}:${userId}:${requestId}` with no compatibility shim.",
    "migration": "Replace any hand-built SqlExpression object literal with the `sql` tag or `sql.raw(...)`. Any custom IdempotencyGuard implementation, or code calling `.check`/`.store` directly (outside the dispatcher's own runBatch, which already updated), needs the new (tenantId, userId, requestId) signature. On deploy, in-flight idempotent retries older than the request's own retry window may execute a second time — same as a first-ever request, not a correctness issue, just not a cache hit."
  },
  {
    "version": "0.198.0",
    "type": "breaking",
    "title": "event-store-executor.list() now throws 422 search_adapter_not_wired instead of returning unfiltered results (fw#2032).",
    "detail": "event-store-executor.list() silently dropped payload.search when no SearchAdapter was wired (neither at build time via options.searchAdapter nor at runtime via runtimeOptions.searchAdapter) — the list came back unfiltered, indistinguishable from a real search result. Now throws UnprocessableError (code: \"unprocessable\", details.reason: \"search_adapter_not_wired\", details.entity) instead.",
    "migration": "Breaking for consumers whose entities are searchable but have no SearchAdapter wired: a search request that used to silently no-op now returns a 422. Wire a SearchAdapter (e.g. Meilisearch) for the entity, or stop marking the field/screen searchable."
  },
  {
    "version": "0.198.0",
    "type": "breaking",
    "title": "NavIconKey closed union replaces icon?: string on nav/config-mask definitions (fw#2055).",
    "detail": "NavDefinition.icon, ContentCollectionDefinition.nav.icon, ScreenNavSugar.icon and ConfigMask.icon were all icon?: string — any typo (icon: \"seting\") compiled fine and silently fell back to a dot in the sidebar. New NavIconKey union (@cosmicdrift/kumiko-types/nav-icon, re-exported from @cosmicdrift/kumiko-framework/{engine,ui-types}) types all four against the closed set of keys the web renderer actually registers, so an unregistered icon key is now a compile error at the r.nav()/r.screen({ nav })/config-mask call site instead of a missing icon at runtime. packages/renderer-web's NAV_ICONS map is checked against the same union via `as const satisfies Record<NavIconKey, …>`, so the type and the map can no longer drift.",
    "migration": "Breaking for any app that passes an icon key outside the vocabulary in packages/types/src/nav-icon.ts — such a call site will fail to compile after this bump. Fix the typo or add the missing key to both NavIconKey and renderer-web's NAV_ICONS map in the same change."
  },
  {
    "version": "0.193.0",
    "type": "breaking",
    "title": "Image fields get named derived variants; ImageFieldDef/ImagesFieldDef.thumbnails removed (fw#1973).",
    "detail": "createImageField now accepts variants: Record<string, VariantSpec> — boot-validated named derived-image specs, served via GET /api/files/:id/variant/:name behind the same tenant + access guard as the download. A request carries only a NAME, never a spec, so no caller can drive an arbitrary render. The edit-form preview loads the first declared variant instead of the original.",
    "migration": "ImageFieldDef.thumbnails / ImagesFieldDef.thumbnails are removed — the flag was never read by anything. Replace any reliance on it with a declared variants entry."
  },
  {
    "version": "0.189.0",
    "type": "breaking",
    "title": "createDateField now backs a real Postgres DATE column, round-trips as Temporal.PlainDate (fw#1924).",
    "detail": "type:\"date\" fields were silently aliased onto the same instant()/TIMESTAMPTZ column as type:\"timestamp\": reads returned a full ISO instant (\"2026-03-15T00:00:00Z\"), writes expected a bare \"yyyy-mm-dd\" string bound to a timestamptz column through the session's TimeZone — both directions were timezone-dependent for what is meant to be a pure calendar-day value. A date field now serializes as \"2026-03-15\" (Temporal.PlainDate's own toJSON()); a non-form client that Instant-parses a date field's JSON value now throws. Write shape is unchanged (bare \"yyyy-mm-dd\").",
    "migration": "Managed (event-sourced projection) tables: the generator emits DROP TABLE + CREATE TABLE and replays from the event log automatically — factor in replay cost for entities with a large event history. Unmanaged (store_*, direct-write) tables: the generator emits an in-place ALTER TABLE … ALTER COLUMN … TYPE date USING (col AT TIME ZONE 'UTC')::date, anchored explicitly at UTC — do not hand-write a bare ALTER COLUMN … TYPE date without USING, which falls back to Postgres's session-TimeZone-dependent implicit cast."
  },
  {
    "version": "0.177.0",
    "type": "breaking",
    "title": "createMoneyField's amount now converts to/from minor-unit BIGINT storage (fw#1767).",
    "detail": "flattenMoney/rehydrateMoney used to pass the API amount straight into the BIGINT column without the minor-unit (cents) conversion the column's own doc comment always claimed. A decimal amount (e.g. 56799.16) crashed the insert (float into bigint); a plain integer major-unit amount (e.g. 45000 meaning €450.00) was silently stored as 45000 minor units — 100× too small on read-back.",
    "migration": "amount is now always major units (ordinary decimal, e.g. 56799.16) on both write and read — DB storage stays exact-integer cents automatically, no caller change needed for that direction. If you already wrote createMoneyField data under the old (unconverted) semantics, multiply stored amounts by 100 before upgrading, or reconcile after — no known production deployment currently persists money-typed data (verified solon and phronexsis are both pre-launch before this merged)."
  },
  {
    "version": "0.167.0",
    "type": "breaking",
    "title": "resetEntityFieldEncryptionCacheForTests / resetEventPiiCatalogForTests moved to /testing (fw#1631).",
    "detail": "Test-only reset helpers with no owning feature: resetEntityFieldEncryptionCacheForTests left the /db barrel, resetEventPiiCatalogForTests left /crypto. The functions did not move, only their export path.",
    "migration": "Import both from \"@cosmicdrift/kumiko-framework/testing\" instead of \"/db\" and \"/crypto\". Relative deep-imports of the defining module are unaffected."
  },
  {
    "version": "0.167.0",
    "type": "breaking",
    "title": "Six identity-sensitive error classes moved from kumiko-types into kumiko-framework (fw#1616).",
    "detail": "VersionConflictError, IdempotentAppendConflictError and ArchivedStreamError now live in /event-store, KeyErasedError, KeyNotFoundError and KeyAlreadyExistsError in /crypto — the public paths callers already import from. With no classes left in it, kumiko-types is a plain dependency again instead of a peerDependency, which closes the changesets cycle that escalated every minor release to a major.",
    "migration": "Only affects direct imports from the removed @cosmicdrift/kumiko-types/event-store-errors subpath: import from @cosmicdrift/kumiko-framework/event-store or /crypto instead. Apps importing from the framework paths need no change."
  },
  {
    "version": "0.167.0",
    "type": "fix",
    "title": "hono range raised to ^4.12.27 — security floor for the HTTP layer (fw#1634).",
    "detail": "Carries the fixes for three advisories: cross-request data disclosure in hono/jsx (context not isolated per request), server-side XSS via a JSX escaping bypass in cx(), and a dropped repeated request header in the API-Gateway v1 adapter. The old ^4.12.18 allowed the patched versions, but the lockfile sat on 4.12.25 — the range now states the floor instead of relying on resolution luck."
  },
  {
    "version": "0.165.2",
    "type": "improvement",
    "title": "buildEntityTableMeta renamed to deriveEntityTableMeta (fw#1208).",
    "detail": "The old name read like the unmanaged escape hatch (defineUnmanagedTable). Unmanaged builders now reject the reserved read_ table-name prefix. The deprecated alias still works."
  },
  {
    "version": "0.165.1",
    "type": "fix",
    "title": "isSafeHref decodes HTML character references before its scheme check (fw#1551).",
    "detail": "javascript&colon;alert(1) and java&Tab;script:alert(1) slipped through because neither contains a literal colon for the pre-decode regex, while the browser decodes the entity back into an executable javascript: URL on click. Affects renderSafeMarkdown (page-render) and the renderer-web Link primitive."
  },
  {
    "version": "0.165.1",
    "type": "improvement",
    "title": "createNumberField accepts max (fw#1573).",
    "detail": "Mirrored from min; the schema-builder applies Zod .max() at the write boundary so integer CRUD rejects values that would overflow a Postgres integer instead of failing at insert time."
  }
]
