# v0.20 – v0.29

## v0.29.0 — May 19, 2026

### Opt-in `id` auto-injection (`compiler.injectId: 'auto'`)

Quickback's generated CRUD routes, FK extractor, relationship resolver, and `/:id` mount points all assume every feature table has an `id` primary key column. Authoring a table without one — common when porting an existing schema, or when a domain genuinely uses a compound or non-`id` PK — produced silent failures: routes that 404, batch operations that targeted the wrong rows, FK lookups that returned empty.

v0.29 adds an opt-in flag that auto-injects an `id` column into every feature table that doesn't already declare one. Same mechanism as the existing `createdAt`/`modifiedAt` injection — early-pass, idempotent, dialect-aware.

```ts
// quickback.config.ts
compiler: {
  injectId: 'auto',   // off by default; opt in per project
}
```

When `'auto'`:
- Tables with no `.primaryKey()` declared get `id: text("id").primaryKey().$defaultFn(() => createId())` (or the matching shape for `dbConfig.generateId` — `serial`, `uuid`, `prefixed`).
- Tables that already declare an `id` column (in any shape) are no-op — the injector is idempotent.
- Tables with a `.primaryKey()` on a non-`id` column raise a clear compile error that names the offending column and points at the v0.30 follow-up which will rewrite custom PKs to unique indexes automatically. v0.29 surfaces the conflict; v0.30 fixes it.

Default stays `false` so existing projects don't get migration churn. Opt in when you want to lean on every-table-has-id semantics across your schema.

### Why this is opt-in (and what v0.30 changes)

The "every table has `id`" assumption is load-bearing across 68+ sites in the compiler — every CRUD emitter, the FK regex (`.references(() => target.id)`), the relationship resolver hydration block, the `/:id` route mounts. v0.29 only injects the column; v0.30 will close the remaining gaps:

- FK extraction migrates from `.id` regex to schema-aware metadata lookup, so tables with non-`id` PKs participate in FK display + relationship resolution.
- The PK→unique-index rewrite lands — tables with explicit non-`id` PKs auto-keep their uniqueness as a constraint while gaining the auto-injected `id`.
- The flag flips to default-on; opting out becomes the rare path.

v0.29 ships the foundation so projects can opt in today without waiting for the full structural pass.

## v0.28.0 — May 19, 2026

### INTERNAL pseudo-role: server-only schemas, unforgeable from HTTP

v0.27 had five pseudo-roles — `PUBLIC`, `AUTHENTICATED`, `USER`, `ADMIN`, `SYSADMIN` — and one structural gap: there was no way to mark a schema as "the server enumerates this; no user-facing route should ever read across tenants." Push subscriptions, personal access tokens, OAuth tokens, broadcast job tables — anywhere the server needs full visibility but users should only see their own row. The workaround was `firewall: { exception: true }` plus an out-of-band convention, which left auth on the schema looking incidental.

v0.28 adds **`INTERNAL`** as the sixth pseudo-role. Schemas with `roles: ['INTERNAL']` are reachable only by `AppContext` values where `ctx.internal === true` — a flag the HTTP auth middleware **never** sets. No session, JWT, header, query parameter, or body field populates it. The flag is reserved for compiler-emitted server-internal entry points (queue consumers, cron workers, server-side helpers that bypass HTTP entirely).

{/* doc-compile: skip — changelog excerpt; the entry is about the INTERNAL access node, and the Drizzle table it configures was never part of the entry */}
```ts
// features/comms/push-subscriptions.ts
export default defineTable(push_subscriptions, {
  firewall: [{ field: 'ownerId', equals: 'ctx.userId' }],
  // User reads their own subscriptions via the standard CRUD route.
  read: { access: { roles: ['USER'] } },
  // Server enumerates ALL subscriptions for a broadcast — INTERNAL only.
  crud: {
    delete: { access: { roles: ['INTERNAL'] } },
  },
});
```

The split-surface pattern is the recommended shape: a user-facing access node uses `USER` / `AUTHENTICATED` / a real role; a server-only access node uses `INTERNAL`. Mixing INTERNAL with a user-facing pseudo-role on the **same** access node is a compile error — it almost always means the developer is confused about which side of the trust boundary they're on. The error names the cohabiting markers explicitly and points at the fix (split into separate access blocks).

The runtime stamping (queue consumer ctx, cron worker ctx) lands in a follow-up; v0.28 ships the declarative surface so projects can mark schemas accurately today. Until the stamping ships, server-internal code uses `unsafeDb` to enumerate INTERNAL surfaces — the security guarantee is in place because no HTTP-derived context can ever carry `ctx.internal === true`.

### Reserved-name fix: SYSADMIN and INTERNAL added to ALWAYS_RESERVED

`SYSADMIN` was previously omitted from the `ALWAYS_RESERVED` set in `authz-schema.ts`. The runtime validator rejected `roles: ['SYSADMIN']` when `cms.sysadmin: true` wasn't set, but the **shadow-declaration path** — user code writing `authz.roles.SYSADMIN: { roles: [...] }` to silently change the pseudo-role's meaning — wasn't blocked. v0.28 closes that gap by adding both `SYSADMIN` and `INTERNAL` to the unconditionally-reserved set.

### Per-table OWNER_SCOPE_RULES: explicit owner predicates no longer over-scope

When a user declared a non-canonical owner predicate on one table — e.g. `firewall: [{ field: 'linkedUserId', equals: 'ctx.userId' }]` — that column got promoted into the **project-global** `OWNER_SCOPE_RULES` list. Any other table in the project that happened to have a `linkedUserId` column (even as a plain FK reference, not for scoping) had the same `WHERE linkedUserId = ctx.userId` injected on every read, update, and delete. Silent over-scoping that surfaced only when a query returned the wrong rows.

v0.28 narrows the global list to the **canonical owner column** — the one matching `authDefaults.owner.column` (default `ownerId`). Explicit predicates on custom-named columns are honored on their declaring table via that table's own `buildFirewallConditions` helper, but stay per-table. Reads on other tables with the same column name are unaffected.

{/* doc-compile: skip — changelog excerpt; two resource configs in one fence to show the cross-table over-scoping bug, neither table declared */}
```ts
// Before v0.28: linkedUserId on `comments` ALSO got the WHERE
// because `event_organizers` declared an explicit owner predicate.
export default defineTable(event_organizers, {
  firewall: [{ field: 'linkedUserId', equals: 'ctx.userId' }],
});
export default defineTable(comments, {
  // comments.linkedUserId is just an FK reference; we don't want it scoped.
  firewall: [{ field: 'eventId', equals: 'ctx.activeOrgId' }],
});
```

In v0.28, `event_organizers` queries get the per-table `linkedUserId = ctx.userId` predicate as before; `comments` queries are no longer mis-scoped.

If you genuinely want the same column to scope multiple tables (rare), declare the explicit firewall predicate on each table. The implicit "every table with this column gets scoped" behavior was the bug — explicit-per-table is the cure.

## v0.27.0 — May 19, 2026

### Narrow `ctx.<exposeAs>` typing for namespace-scoped actions

Since v0.23 the namespace middleware has hydrated `ctx.<exposeAs>` with the loaded resource row before each action runs. The static type of that field stayed `any` — runtime safety was solid, but autocomplete and typo-catching were missed opportunities.

v0.27 ships the second half: one **per-namespace `defineAction` helper** emitted alongside the standard one. Importing from the narrowed helper retypes `ctx.<exposeAs>` inside `execute({ ctx })` to the loaded row's `$inferSelect` shape. Nothing about discovery, routing, or runtime behavior changes — this is purely TypeScript ergonomics.

```ts title="features/feeds/actions/feedMoments.ts"
import { z } from 'zod';

// Before — ctx.event is `any`
// import { defineAction } from '../.quickback/define-action';

// After — ctx.event is `typeof events.$inferSelect`
import { defineAction } from '../.quickback/define-action.event-scope';

export default defineAction({
  description: 'Post a moment to an event feed.',
  path: '/event/:eventId/feed-moments',
  method: 'POST',
  input: z.object({ body: z.string() }),
  access: { roles: ['attendee'] },
  async execute({ ctx, input }) {
    ctx.event.id          // ✓ string, autocomplete works
    ctx.event.startsAt    // ✓ Date
    ctx.event.idd         // ✗ TS2339 — typo caught at compile time
  },
});
```

#### Where the file lives

The compiler emits one narrowed helper per `(feature, namespace)` pair where the feature owns at least one action whose `path:` falls under the namespace prefix. Filename is `define-action.<kebab(namespaceName)>.ts` (`eventScope` → `define-action.event-scope.ts`), sibling to the standard `define-action.ts`:

```text
src/features/feeds/.quickback/
├── define-action.ts              # generic — standard fallback
└── define-action.event-scope.ts  # ctx.event narrowed
```

A feature contributing to multiple namespaces gets one helper per namespace. Each action picks the import that matches its path.

#### Opt-in, no breaking change

Actions that keep their existing `import { defineAction } from '../.quickback/define-action'` continue to compile unchanged. The `AppContext` index signature still admits `ctx.event` at type `any`; runtime hydration is unaffected. Flip imports per action at your own pace.

#### Compile-time advisory

When `quickback compile` sees a namespace-scoped action still importing the generic helper, it logs a one-line `[quickback:typing]` advisory pointing at the narrowed path:

```text
[quickback:typing] action "feedMoments" in feature "feeds" lives under
  namespace "eventScope" — switch its import to
  `../.quickback/define-action.event-scope` for narrow ctx.<exposeAs> typing.
  (Runtime behavior is unchanged; the narrowed helper only refines TS types.)
```

Non-blocking — same family as `[quickback:authz]`.

#### Side fix

Repaired a latent off-by-one in the inlined `AppContext` import path that landed in the v0.26 helper prelude but hadn't been exercised against a recompiled project yet (`"../../lib/types"` → `"../../../lib/types"`). New compiles produce a helper whose `AppContext` import actually resolves.

#### Out of scope

Non-namespace standalone actions with relationship-based access keep their existing `(ctx.event as typeof events.$inferSelect)` body cast. Same per-relationship helper pattern would apply if the demand surfaces — defer until requested.

## v0.26.0 — May 19, 2026

### Bulk-grant namespace lanes: org admins and team members in one line

v0.25 widened `via:` to accept an array of relationship lanes — attendeeOf OR organizerOf, first match wins. That covers per-row authorization paths. v0.26 adds the missing piece: **bulk-grant lanes** for the "you work here, so you see everything in your scope" case. Org admins, team members, and other tenant-scoped staff no longer need a per-event relationship row to be authorized for a namespace.

```ts
authz: {
  namespaces: {
    eventScope: {
      prefix: '/event/:eventId',
      via: [
        'attendeeOf',                       // per-event relationship
        'organizerOf',                      // per-event relationship
        { roles: ['admin', 'owner'] },      // org-role bulk grant
        { team: true },                     // active-team-membership bulk grant
      ],
    },
  },
}
```

Lanes resolve in declaration order, first match wins. Per-row relationships and bulk-grant guards compose freely.

#### What each bulk-grant lane does

- **`{ roles: [...] }`** — passes when the caller's BA org-role for the namespace's loaded resource includes any listed role. The check is in-memory and short-circuits the relationship loop entirely. The loads-table firewall (scoped by `ctx.activeOrgId`) does the tenant isolation; this lane just adds the role precondition on top.
- **`{ team: true }`** — passes when `ctx.activeTeamId` is set. The loads-table firewall (which scopes by team for team-scoped tables) does the actual `events.teamId === ctx.activeTeamId` match during hydration. If the firewall returns no row, the request gets a contextual 404 explaining the row isn't visible to the current session — same ambiguous-by-design phrasing the firewall has always used to prevent cross-tenant ID probing.

#### Why this matters

Until v0.26, the only way to authorize an org admin for a namespace was to write `access.or: [{ via: 'attendeeOf' }, { roles: ['admin', 'owner'] }]` on every individual action under it. That OR-arm shim worked but defeated the purpose of namespaces — the whole point of hoisting the gate was to write the auth rule once. Now you do.

#### Rich deny payloads

The 403 response when every lane misses lists every path the caller could have taken — relationship names, role names, and `"team-membership"` for `{ team: true }` lanes — in the `required` field. A developer hitting an unexpected 403 sees the full set of authorization options they need to satisfy, not a generic "Insufficient permissions" wall.

The 404 from hydration (when the loads-table firewall filters the request out) now includes a hint explaining that the row may exist out of scope, and a `details` field with the resource name and key, so a developer working in their own environment has enough to debug.

#### Compile-time guards

- **Pure bulk-grant namespaces** (no relationship lane at all) are accepted as long as `loads:` + `exposeAs:` are declared directly on the namespace. With at least one relationship lane present, those fields are inherited from the relationship — declare them on the namespace itself only when no relationship lane is available to anchor hydration.
- **Heterogeneous `loads:` between relationship lanes is rejected** — `ctx.<exposeAs>` must always hydrate the same table regardless of which lane matched.
- **`namespace.loads` that disagrees with the inherited value is rejected** with a clear "drop one or align the other" message.

## v0.25.0 — May 19, 2026

### Multi-lane namespace gates: more than one way to be authorized

v0.24 lets you hoist a namespace gate to the project level via `authz.namespaces.<name>.via: 'attendeeOf'` — one relationship-resolver middleware running once per request for every action under `/event/:eventId/*`. That works when there's exactly one way to be authorized for the resource. It breaks the moment you have **two**: an attendee row and a per-event organizer row, say. The shim today is to OR the second check inside every individual action's `access` block, which puts you back to writing the gate over and over.

v0.25 widens `via:` to accept an array of relationship lanes. The middleware tries each in declaration order, and the first one whose subject row matches authorizes the request and hydrates `ctx.<exposeAs>`.

```ts
authz: {
  relationships: {
    attendeeOf: {
      from: 'guests',
      subject: { column: 'linkedUserId', equals: 'ctx.userId' },
      resource: { column: 'eventId' },
      where: { status: 'confirmed' },
      loads: 'events',
      exposeAs: 'event',
    },
    // Per-event named organizers. The subject can be a user OUTSIDE
    // the event's organization — e.g. a freelance event manager.
    organizerOf: {
      from: 'eventOrganizers',
      subject: { column: 'linkedUserId', equals: 'ctx.userId' },
      resource: { column: 'eventId' },
      loads: 'events',
      exposeAs: 'event',
    },
  },
  namespaces: {
    eventScope: {
      prefix: '/event/:eventId',
      via: ['attendeeOf', 'organizerOf'],   // first match wins
    },
  },
}
```

The thing this actually unlocks isn't "two checks instead of one." It's **per-event grants for users outside the owning organization** — external co-organizers, freelance event managers, partner staff. They don't need a `member` row in the event's org; they just need a row in `event_organizers` for that specific event. The horizontal axis (org / team membership) stays for "staff at this company"; the new vertical axis (per-event relationships) handles "specifically connected to this event," regardless of payroll.

#### Backward compatible

The string form keeps working — `via: 'attendeeOf'` is sugar for `via: ['attendeeOf']`. No existing namespaces break; opt into multi-lane by switching to the array form when you actually need it.

#### Compile-time guards

The validator rejects three things at parse time:

- **Undeclared lane names** — every entry in the array must resolve to an `authz.relationships[*]` declaration.
- **Lanes that disagree on `loads:` or `exposeAs:`** — if attendeeOf loads `events` as `ctx.event` and organizerOf loads `agendaItems` as `ctx.agendaItem`, downstream handlers can't predict what's in `ctx.<exposeAs>`. Lanes in one namespace must hydrate the same shape.
- **Empty arrays** — at least one lane is required (otherwise the namespace gate is meaningless).

#### Follow-up: v0.26 bulk-grant lanes (shipped same day — see above)

The full 4-lane shape adds heterogeneous `via:` arrays accepting `{ roles: ['admin', 'owner'] }` and `{ team: true }` entries, so org admins and team members get the gate without writing relationship rows. v0.25 was the foundation; [v0.26](#v0260--may-19-2026) lifted the existing per-action `access.or: [..., { roles: [...] }]` shim into the namespace and landed same-day.

## v0.24.0 — May 19, 2026

### Cross-feature namespaces: one hoisted gate, many features

v0.23 shipped `defineTable({ namespace: { prefix, via } })` — a hoisted relationship-resolver middleware that gates every standalone action in *one feature's* Hono app. That covers the 95% case where a domain concept (events) and the actions under it all live in the same feature directory.

v0.24 lifts the namespace to the project level so it can span multiple features. A new `authz.namespaces` block on `quickback.config.ts` declares a path-prefix gate that any feature's standalone action can participate in by matching the prefix in its `path:`. One synthetic routes file, one resolver mount, one relationship match per request — regardless of how many feature directories contribute actions.

{/* doc-compile: skip — changelog excerpt; a config fragment plus two deliberately-bare actions whose point is "no gate boilerplate", so description/input/access are elided on purpose */}
```ts
// quickback.config.ts
authz: {
  relationships: {
    attendeeOf: {
      from: 'guests',
      subject: { column: 'userId', equals: 'ctx.userId' },
      resource: { column: 'eventId' },
      loads: 'events',
      exposeAs: 'event',
    },
  },
  namespaces: {
    eventScope: { prefix: '/event/:eventId', via: 'attendeeOf' },
  },
}

// features/feeds/actions/feed-moments.ts  (cross-feature)
export default defineAction({
  path: '/event/:eventId/feed-moments',
  method: 'POST',
  // No gate boilerplate. ctx.event is typed.
});

// features/rsvp/actions/respond.ts  (different feature, same namespace)
export default defineAction({
  path: '/event/:eventId/respond',
  method: 'POST',
});
```

The compiler emits one `src/routes/__namespace-event-scope.routes.ts` mounted at `/api/v1/event/:eventId/*`. The file imports each contributing feature's `actions` map under a distinct alias (`feedsActions`, `rsvpActions`), runs the `attendeeOf` resolver once per request at the head of the file, and dispatches to the matching action handler.

#### Discovery is path-prefix-only

No new `namespace: 'eventScope'` field on `defineAction`. Actions opt in by matching `path:` against a declared namespace prefix — same model the table-level v0.23 form uses, just lifted to the project level. This means actions move in/out of the namespace based on their path strings, which is the simplest discovery story and matches today's convention (filenames and paths are already the discoverable surface).

#### Source-feature stays the action's home for metadata

OpenAPI tags, MCP tool grouping, and CMS form discovery continue to bucket each action under its source feature. Only the *runtime routes file* moves to the synthetic location — the feed-moments action is still tagged "feeds" in OpenAPI, the response shape doesn't change, and your generated SDK reads identically. The URL conveys the namespace membership without forcing every layer of the compiler to re-anchor the action.

#### Worker-root mount order

Synthetic namespace routes files register at priority `20` — ahead of per-feature mounts (priority `0`/`10`). `sortRoutesBySpecificity` keeps the order stable, and the namespace prefix's path parameter makes it sort more specific than `/api/v1/<feature>` anyway, so the middleware fires first for every request under its prefix.

#### Compile-time guardrails

The same v0.23 invariants apply at the project level, with two additions:

- **`via:` must point at a declared `authz.relationships[*]` entry**, and that relationship must declare `loads + exposeAs` — without `loads` there's nothing for the middleware to hydrate, and a namespace that only gates without hydrating is indistinguishable from a per-action access rule.
- **`prefix:` must contain at least one `:param` segment** — middleware runs before input parsing, so the relationship key comes from `c.req.param()`.
- **Same-name conflict** between `defineTable({ namespace })` and `authz.namespaces.<sameName>` is rejected. Pick one declaration site.
- **Ambiguous prefixes** like `/a/:x` and `/a/:y` (same shape, different `:param` names) error at compile time. Strict super/sub-set relationships (e.g. `/event/:e` and `/event/:e/admin`) are fine — the harvester routes each action to the longest matching prefix.
- **Unused namespace warning** emits `[quickback:authz]` when no standalone action's path matches a declared prefix. Not a hard error — the namespace may be staged ahead of the actions.

#### Picking the right declaration site

The table-level v0.23 form is now the **single-feature shorthand**. The project-level v0.24 form is **canonical for cross-feature**.

- **Use `defineTable({ namespace })`** when every action under the prefix lives in the same feature directory. The namespace travels with the table that owns it; nothing to add to `quickback.config.ts`.
- **Use `authz.namespaces`** when the namespace spans features. The declaration sits next to your `authz.relationships` and `authz.roles` — the canonical home for cross-cutting authz config.

The two forms can't coexist for the same namespace name. If a v0.23 table-level namespace is starting to attract cross-feature actions, lift it to `authz.namespaces` — same shape, just at the project level, and the matching paths in other features will join automatically.

#### Migration

Zero breaking changes. Nothing in v0.23 needs to change for v0.24 to land. The new field is opt-in; existing projects pick it up the moment they declare an `authz.namespaces` entry.

The harvester runs after Zod schema validation but before route generation, so unused or misconfigured namespaces surface immediately as errors or warnings rather than as silently-broken routes.

#### Fixes shipped alongside

The first two were load-bearing for v0.23 namespaces to actually work — projects upgrading from a pre-fix v0.23 will see crashes/compile errors disappear on the v0.24 recompile.

- **No more dual emission of namespace-claimed standalone routes.** Pre-fix, an action with a cross-feature path like `/event/:eventId/agenda/list-my-agenda` emitted into both `src/index.ts` (without the namespace resolver) and the synthetic file (with it). The plain root copy in `src/index.ts` won every URL match and `ctx.<exposeAs>` was never hydrated — every request 500'd with `Cannot read properties of undefined (reading 'id')`. `extractStandaloneActions` now skips namespace-claimed actions; the synthetic namespace file is the sole emission.
- **Stripped-path validator no longer rejects namespace-claimed actions.** The synthetic file mounts at `/api/v1${namespace.prefix}` and strips the prefix from each action's local path. The per-action relationship-resolver was validating the *stripped* path for `:eventId`, finding nothing, and rejecting every namespace-mounted action with "action's surface exposes neither a path parameter ':eventId' nor an input field 'eventId'". `planRelationshipResolver` now receives the namespace's `via` and skips its surface-key check entirely — the hoisted middleware is the single source of truth.
- **Broadcaster DO migration tag consistent across `realtime.mode`.** Inline-mode emission was already going through `qb-do-${className}` + `new_sqlite_classes`. Separate-mode was still hardcoded `tag = "v1"` + `new_classes`, so flipping `realtime: { mode }` produced different migration tags for the same DO class and tripped CF error 10074 ("class already depended on") on cross-mode deploys. Both modes now emit `tag = "qb-do-Broadcaster"` + `new_sqlite_classes`.
- **Batch handler id arrays type-derive from the input parser.** Pre-fix, `validIds`, `recordMap`, and `validEntries` in bulk-delete / bulk-update / bulk-upsert were hardcoded `string[]` / `Map<string, any>` even when the table's PK column was `integer("id")` — 274× TS2769 in projects mixing string and integer PKs. Handlers now declare `type IdT = typeof ids[number]` (or `typeof records[number]['id']`) and use it for the local id types. Strings stay strings, integers stay integers, no emitter knowledge of the PK column type required.
- **`ctx` in action handlers is now `AppContext`, not `any`.** Both `StandaloneExecuteParams.ctx` and `RecordExecuteParams.ctx` in the generated `<feature>/.quickback/define-action.ts` are typed `AppContext`. AppContext keeps its `[key: string]: any` index signature so hydrated namespace keys (`ctx.event`, `ctx.<exposeAs>`) stay accessible without TS2339; named fields (`userId`, `activeOrgId`, `roles`, `authenticated`, `user`) get autocomplete and type-checking. Cleared ~49× TS2339 in generated projects.
- **`c.get('ctx') as AppContext` in every emitted route handler.** Pre-fix, `const ctx = c.get('ctx')` returned `any`, so `ctx.roles?.some(r => …)` left the callback parameter implicitly `any` — 16× TS7006. Now `ctx.roles` is `string[] | undefined`, `r` is `string`, no callback annotation needed.

#### Deferred (tracked, not blocking)

- Per-namespace narrow typing of `ctx.<exposeAs>` (so `ctx.event` resolves to `typeof events.$inferSelect` instead of `any`). The compiler emits a typed cast at the `execute()` call site in the routes file today, which type-checks the call but not the handler body. Full narrow typing requires per-namespace helper emission (one `define-action.<namespace>.ts` per namespace, or a generic param threading through the action config). Tracked separately.

---

## v0.23.0 — May 19, 2026

### Authz relationships: compile-time guarantees, less boilerplate, hoisted middleware

This release closes the gap between what `authz.relationships` *promised* in v0.19 and what it actually enforced. Every documented invariant is now a real compile-time check; every per-action ergonomic friction has a fix. Net effect: relationship-based authorization feels native — declare the relationship once, use the role anywhere, the compiler does the wiring.

#### Compile-time guarantees (the authz `firewall` parity)

The org/owner/team firewall has always been compile-time-checked: a routed resource without a scope fails the compile, full stop. As of this release, `authz.relationships` carries the same guarantees:

- **Bad `via:` names fail at parse time.** A typo in a firewall predicate (`firewall: [{ field: 'eventId', via: 'guestOff' }]`) used to surface deep inside the firewall generator after most validation had passed. It's now caught alongside the `authz.roles` `via:` validation, same layer, same error format.
- **Relationship-from tables can't opt out.** The doc at `define/types/authz.ts:73-74` always said a relationship `from:` table with `firewall: { exception: true }` "refuses to compile" — that's now enforced. Without this check, every `via:` subquery that pivots through an exception-marked table loses tenant isolation and can return rows from other tenants.
- **`allowResourceMigration` is now mandatory** when the relationship's `resource.column` is on a writable update endpoint. A guest doing `PATCH /messages/:id { eventId: <other-event-id> }` to migrate a row out of their access scope used to slip past the runtime — it now fails compile with a three-option remediation (set the flag, mark the column immutable, or drop it from the guards.updatable allowlist).
- **`via: string[]` in firewall predicates.** "Member of attendees OR speakers" no longer requires inventing a composite role just to use it once in a firewall — `firewall: [{ field: 'eventId', via: ['attendeeOf', 'speakerOf'] }]` lowers to an OR-combined subquery.

#### Less boilerplate: auto-derivation + realtime resolver

- **`resource.column` auto-derives from FKs.** Omit it and the compiler scans the relationship's `from:` table for foreign-key columns, drops the `subject.column`, and picks the remaining FK. One match → auto; zero or many → error with the candidate list. Pattern mirrors the existing firewall auto-detection.
- **`realtime.requiredRoles` accepts authz roles.** Previously a relationship-role in `realtime.requiredRoles` fell through with a misleading "unknown role" error. The compiler now flattens declared authz roles into their composing Better Auth roles before the realtime ticket-handshake sees them. Relationship-only roles (no static BA fallback) emit a `[quickback:authz]` warning and fail-close at handshake — the row-stream firewall continues to enforce the relationship dimension on the data side.

#### Record-based actions can finally use relationship-roles

The v0.19 fail-closed hardening had an unintended side effect: a record-based action with `access: { roles: ['attendee'] }` (where `attendee` was a relationship-role) 403'd every caller, including the legitimate ones. The runtime helper couldn't evaluate `relationship:` arms, so it fail-closed correctly — but the inline match expression that *would* evaluate the relationship was never wired up for record-based actions.

This release routes record-based actions with relationship arms through the same inline access generator the resource CRUD path already uses. `roles: ['attendee']` on `/applications/:id/recap` now actually checks whether the caller is an attendee, with the loaded record in scope. Non-relationship access trees keep the runtime path unchanged.

#### Typed `ctx.<exposeAs>` in action handlers

When a relationship declares `loads: 'events', exposeAs: 'event'`, the runtime hydrates `ctx.event` with the loaded row — but the static type was always `any`, so handlers had to write `const event = ctx.event as typeof events.$inferSelect`. As of this release, when an action's access references **exactly one** relationship with `loads + exposeAs`, the compiler emits the cast for you at the execute() call site. Handlers read `ctx.event` with the right shape; no per-handler boilerplate.

Multi-arm OR actions (referencing two relationships with hydration) keep the `any` shape — we can't statically know which arm matched.

#### New: `defineNamespace` — one mount, one resolver, many actions

The marquee Wave 3 change. Today's pattern for "five standalone actions under `/event/:eventId/*` all gated by `attendeeOf`" requires writing the same inline relationship resolver in each action — and pays the resolver cost (one subquery, one row-load) on every request, multiplied by N actions. The new `namespace` field on `defineTable` hoists the resolver into a single Hono middleware mount:

{/* doc-compile: skip — changelog excerpt; firewall/crud are elided to `...` and the action is deliberately bare to show "no relationship-gate boilerplate" */}
```ts
// features/events/events.ts
export default defineTable(events, {
  firewall: [...],
  crud: { ... },
  namespace: {
    prefix: '/event/:eventId',   // absolute URL path the mount covers
    via: 'attendeeOf',            // relationship gate
  },
});

// features/events/actions/post-announcement.ts
export default defineAction({
  path: '/event/:eventId/announcements',
  method: 'POST',
  // No relationship-gate boilerplate. ctx.event is typed
  // `typeof events.$inferSelect` inside execute().
});
```

The compiler:
1. Emits one `app.use('/:eventId/*', async (c, next) => { /* relationship resolver */; await next(); })` at the head of the feature's standalone-routes file (path-prefix stripping handles the feature mount).
2. Skips the inline relationship resolver in every per-action handler under the prefix — the middleware already ran it.
3. Hydrates `ctx.event` once per request and threads the typed cast into every namespace action's execute() call.

**Validation guardrails:**
- `namespace.via` must point at a declared `authz.relationships[*]` entry.
- The referenced relationship must declare `loads + exposeAs` — without `loads` there's nothing for the middleware to hydrate, and the namespace gate would be indistinguishable from a per-action access rule.
- `namespace.prefix` must contain at least one `:param` segment — the middleware reads the relationship key from `c.req.param()`, not from request body (body parsing hasn't run at middleware time).

**Scope (v1):**
- Namespace lives within a single feature's Hono `app`; cross-feature shared namespaces are deferred to v2.
- One namespace per `defineTable` declaration; multiple namespaces in a feature mount as siblings.
- For namespace actions whose access references additional relationships beyond the namespace's via, the inline resolver still fires for those — namespace handles only its own.

#### Migration

Nothing in v0.20 → v0.23 breaks existing code that compiles cleanly. The new errors fire only against patterns that were already latent bugs:

- A `via:` typo that previously surfaced as a deep codegen error now surfaces at parse time, same message.
- A `firewall: { exception: true }` on a relationship-from table now errors; this configuration was always documented as unsupported.
- A relationship's `resource.column` writable on the resource's UPDATE endpoint now errors unless `allowResourceMigration: true` is set; previously this was a runtime escape hatch.
- Record-based actions with `roles: ['<relationship-role>']` previously 403'd every caller; they now work as the v0.19 docs originally promised.

#### Why this matters

Authz relationships are how Quickback expresses "the caller is allowed to touch this resource because of a row in *that* table" — guest-of-event, member-of-organization, scorer-of-interview. Until now, the feature worked but had sharp edges: typos surfaced late, the documented exception-not-allowed constraint wasn't enforced, and the namespace pattern (which most apps with nested resources end up wanting) required repeating the same gate in every action. This release brings authz to parity with the org/owner/team firewall: declare it once, the compiler enforces it everywhere, the runtime emits exactly one query per request.

---

## v0.22.0 — May 19, 2026

### Quickback Stack is Cloudflare-only: one runtime, two databases, one auth provider

We've focused the platform on the stack we actually believe in: **Cloudflare Workers + Hono + Better Auth**, with **Cloudflare D1** or **Neon** as the database. Providers and modes that targeted non-Cloudflare backends are gone. The result is a smaller concept set, fewer footguns, and clearer docs.

#### What's been removed

- **Runtimes**: `bun` and `supabase-edge` are no longer supported. The only runtime is `cloudflare`. Local development for compiled projects has always used `wrangler dev` — that's unchanged.
- **Databases**: `supabase`, `libsql`, `better-sqlite3`, `bun-sqlite` are removed. Use `cloudflare-d1` for SQLite at the edge or `neon` for PostgreSQL via Hyperdrive.
- **Auth providers**: `supabase-auth` is removed. `better-auth` is the default; `external` (Cloudflare service binding) remains for advanced setups.
- **Template**: `template: 'nextjs'` is removed from the public config type. It was never fully implemented. The only template is `'hono'`. If you need a Next.js frontend, bring it as a separate deploy and call the Quickback API, or drop a static export into the `apps:` mount.
- **Quickback for Supabase RLS** product line is retired. The compiler no longer emits Supabase Row Level Security policies. The `/products/quickback-supabase-rls/` and `/for/supabase-users/` pages have been removed from the marketing site, and the `compiler/targets/supabase-rls/` docs section has been deleted.

#### Account UI: one delivery mode

The standalone `quickback-better-auth-account-ui` npm package has been **deprecated** and the upstream GitHub repo (`Kardoe-com/quickback-better-auth-account-ui`) has been **archived**. Account UI is now exclusively delivered the way 99% of projects already use it: the compiler builds it from source and bundles it into your Worker.

- `account: { domain: 'auth.example.com', ... }` in `quickback.config.ts` — that's the entire integration story.
- The compiler builds the SPA inside Docker with your branding and feature flags baked in, emits hostname-based routing, and wires cross-subdomain cookies automatically.
- The three standalone delivery-mode docs (`stack/account-ui/standalone`, `library-usage`, `worker`) are removed; the remaining pages explain the compiler-bundled flow.

If you previously deployed the standalone Account UI Worker against a Quickback backend, switch to `account: { domain }` in the config — same end result, one less deploy.

#### `VITE_AUTH_ROUTE` hardcoded to Quickback

The `VITE_AUTH_ROUTE` env var (which used to switch between `quickback` and `better-auth` routing dialects) is gone. The auth-client always uses Quickback's namespaced routes (`/auth/v1`, `/api/v1`, `/storage/v1`). Generic Better Auth backends are no longer a supported target. Removing the var also closes a known footgun where setting the wrong value silently 404'd every auth call.

#### New: every config option is now discoverable

When you bootstrap a project, the kitchen-sink marker block in `quickback.config.ts` lists every option Quickback supports — with descriptions and sensible defaults — commented out for you to copy above the marker as needed. Three top-level options were missing from this discovery surface and are now included:

- **`apps`** — mount your own prebuilt SPA on its own hostname (`apps: { www: { domain: 'www.example.com', aliasDomains: ['example.com'] } }`). Drop the build at `quickback/apps/<name>/` and the compiler serves it from your Worker alongside the API. Reserved names: `cms`, `account`, `public`.
- **`security`** — HTTP security headers (CSP, COOP, COEP, HSTS, Referrer-Policy, Permissions-Policy, X-Frame-Options). Set `security: false` to suppress everything; defaults are sensible for the embedded SPAs.
- **`authz`** — relationship-based authorization roles. Declare joins between the caller and a resource column, then bind role names to those joins for use in `access: { roles: [...] }`.

#### Migration

Projects that compile cleanly against v0.21 and use any of these providers will hit a clear error at compile time:

```
Unknown database provider: "supabase". Available: cloudflare-d1, neon
```

To migrate:

1. **From Supabase**: switch `providers.database` to `cloudflare-d1` or `neon`, and `providers.auth` to `better-auth`. Your `defineTable()` definitions transfer as-is; the security DSL (firewall, access, guards, masking) compiles to Better Auth + Drizzle queries instead of RLS policies.
2. **From Bun runtime / `bun-sqlite`**: switch `providers.runtime` to `cloudflare` and `providers.database` to `cloudflare-d1`. Local dev becomes `wrangler dev`.
3. **From libsql / Turso**: switch to `cloudflare-d1` or `neon`. Both run natively from Cloudflare Workers via the right binding.
4. **From `template: 'nextjs'`**: deploy Next.js separately and call the Quickback API, or drop a static export into `apps:`.

#### Why this matters

The Quickback Stack is the Supabase alternative for Cloudflare. Every Quickback project ships a Hono API, an Account SPA, a CMS SPA, optional realtime/storage/queues — all built from one config and bundled into one Worker. Keeping that promise focused is more valuable than supporting deployment targets that don't share the runtime.

---

## v0.21.0 — May 19, 2026

### Auto-firewall column swap: `ownerId` is now canonical on feature tables, `userId` warns

The compiler's default auto-firewall column for feature tables flips from `userId` to `ownerId`. This closes a long-standing footgun where a `userId` column meaning "a user this row references" (a member, a contact, an invitee) got silently clobbered with `ctx.userId` on insert, because the wrapper matched purely on column name across every table in the project.

**What changed in the compiler**

- `BETTER_AUTH_DEFAULTS.owner.column`: `'userId'` → `'ownerId'`. Schemas with an `ownerId` column auto-resolve to `firewall: [{ field: 'ownerId', equals: 'ctx.userId' }]` and have `ownerId` auto-stamped on insert.
- The generated `OWNER_SCOPE_RULES` in `src/lib/scoped-db.ts` is seeded with `ownerId` instead of `userId`. Schemas that don't explicitly name `userId` in a `firewall:` block no longer trigger the global `userId`-stamping rule — the most common collision goes away without renames.
- A new compile-time warning fires when a feature table carries both an auto-resolved isolation column AND a `userId` column. The warning recommends renaming the column to `linkedUserId` (or similar) if it's informational, or declaring `firewall: [{ field: 'userId', equals: 'ctx.userId' }]` if it really is the access boundary on that table.
- The targeted compile-time error for "the only candidate column is `userId`" now points users at `ownerId` and the explicit-firewall escape hatch. The previous error wording (which assumed `ownerId` was business-logic) is removed.

**What stayed the same**

- Better Auth's own internal tables (`session`, `account`, `member`, `passkey`, `subscriptions`) still use `userId` by convention. They aren't feature tables and don't pass through the auto-firewall.
- Explicit `firewall: [{ field: 'userId', equals: 'ctx.userId' }]` declarations continue to work and are still recognized as the "owner" pattern in the generated comment header.
- The `USER` pseudo-role validator behaviour is unchanged; only the error-message wording flips to lead with `ownerId` and list `userId` as the explicit fallback.
- Audit-wrapper (`createdBy` / `modifiedBy`) behavior is untouched.

**Migration**

Projects that auto-firewalled on a `userId` column will see a hard error on next compile telling them which table is affected, with two suggested fixes:

1. **Recommended**: rename the column to `ownerId` in the schema. Cleanest semantics — readers immediately understand the column is access-control.
2. **Explicit firewall**: keep the `userId` column and add `firewall: [{ field: 'userId', equals: 'ctx.userId' }]` to the resource. The auto-stamping and auto-scoping still happen; you've just opted in by name.

Projects that have a `userId` column meaning "a user this row references" no longer need the `linkedUserId` rename workaround — auto-stamping won't touch the column unless you name it in a firewall block. The new warning makes this audit-discoverable.

**Caveat (deferred)**: explicit `firewall: { owner: { column: 'X' } }` declarations still populate the project-global `OWNER_SCOPE_RULES` list, so a hand-named owner column on one resource will still match by name on every other table that happens to have a column called `X`. A future release will make scope-rule resolution per-table rather than project-global. The warning text calls this out so callers know what to expect.

---

## v0.20.0 — May 19, 2026

### CMS + Account SPAs: shadcn base-ui migration, central Tailwind config

Both first-party SPAs are now on the shadcn **base-ui** variants (`@base-ui/react`) instead of the Radix-based primitives, applied via shadcn preset **`bdvxIkMq`** (style `base-mira`, `baseColor: zinc`, `iconLibrary: remixicon`, Inter Variable font). The migration also collapses the previously duplicated per-SPA theme + `components.json` into a single source at `packages/ui/`.

#### Single source of truth in `packages/ui/`

Both SPAs were independently declaring the same `@theme` / `:root` / `.dark` token blocks in their own `app.css`, and each had its own `components.json` pointing at non-existent local component directories. That's gone:

- `packages/ui/styles/theme.css` — the only place tokens live. Includes the base-mira palette, sidebar + chart tokens, and the Inter font import.
- `packages/ui/components.json` — the only shadcn config in the repo. Future `pnpm dlx shadcn@latest add <name>` runs from `packages/ui/`.
- `packages/cms/app/app.css` and `packages/account/app/app.css` reduce to two imports plus SPA-specific layers (CMS keeps its AG Grid + richtext-display overrides). `packages/{cms,account}/components.json` deleted.

#### Components, icons, deps

- All 22 shadcn primitives regenerated against `@base-ui/react` (`@radix-ui/*` removed everywhere). `Drawer` stays on `vaul` — base-ui has no Drawer primitive.
- Icons migrated from `lucide-react` to `@remixicon/react` line variants across ~100 source files (`Plus → RiAddLine`, `ChevronDown → RiArrowDownSLine`, `X → RiCloseLine`, etc.). `lucide-react` removed from all `package.json` + `deps-spa-*` files.
- `pnpm.overrides` pin `@types/react ^19.2.0` workspace-wide to resolve the duplicate-React-types conflict introduced by `@base-ui/react`'s bundled type peer.
- Per-SPA deps and the Docker build mirrors (`apps/compiler/deps-spa-{cms,account}-package.json`) both gain `@base-ui/react`, `@remixicon/react`, `@fontsource-variable/inter`, and bump `shadcn` to `^4.7.0`.

#### Backwards-compat `asChild` shim

Base-ui replaces Radix's `asChild` boolean with a `render={<X />}` prop. To avoid rewriting ~44 caller sites, the shadcn-generated wrappers for `Button`, `DropdownMenuTrigger`, `DropdownMenuItem`, and `PopoverTrigger` carry an `asChild?: boolean` prop that delegates to base-ui's `render` API when set:

```tsx
// Caller code keeps working as-is:
<Button asChild>
  <Link to="/foo">Click</Link>
</Button>
```

`ResponsiveModal` survived the migration unchanged — it composes the higher-level Dialog + Drawer wrappers (preserved public API), not the underlying primitives.

#### Caller-side API changes worth noting

Base-ui ships some real behavioural changes the Radix variants didn't:

- `<Select onValueChange>` now receives `(value: string | null, eventDetails) => void` (was `(value: string) => void`). Existing `Dispatch<SetStateAction<string>>` setters reject `null` — wrap callbacks defensively: `onValueChange={(v) => v != null && setX(v)}`.
- `<DropdownMenuContent forceMount>` removed (base-ui has no `forceMount`). Use base-ui's `keepMounted` on the Popup/Positioner if you need to preserve unmounted state.
- Component sub-names shifted (`Dialog.Content` → `Dialog.Popup`, `Dialog.Overlay` → `Dialog.Backdrop`). Data attributes are now `data-open` / `data-closed` (no more `data-state="open"`).

#### Adding a new primitive after this release

```bash
cd packages/ui
pnpm dlx shadcn@latest add <component> --overwrite --yes
# Sweep generated `@/` imports to relative paths — the SPAs' vite-tsconfig-paths
# plugin doesn't see packages/ui's @/* mapping at bundle time:
sed -i.bak \
  -e 's|"@/lib/utils"|"../lib/utils"|g' \
  -e 's|"@/components/|"./|g' \
  -e 's|"@/hooks/|"../hooks/|g' \
  components/<component>.tsx && rm components/<component>.tsx.bak
```

If the new primitive pulls in fresh deps, mirror them into `apps/compiler/deps-spa-{cms,account}-package.json` per the SPA build pipeline or Docker SPA builds will fail.

---

