Quickback Docs

Feature Areas - Hierarchical Authz Grouping

Group features under an area folder with _area.ts. The area declares a route prefix, admission lanes, and an authz vocabulary that only its own subtree may reference.

Group related features under one area folder. The area's _area.ts file declares a route prefix, admission lanes, and an authorization vocabulary — relationships, roles, rules, tenant anchors, scope kinds — that every feature inside the folder inherits by name. Features outside the folder cannot reference any of it: the compiler fails the build.

Naming note: this concept is an area. domain in Quickback always means a DNS custom domain (see custom domains).

Do you need one?

An area is rung 3 — reach for it only when a subtree shares a URL prefix and a per-row admission gate. Most apps never need one.

You wantUse
Every row scoped to the caller's tenantNothing — organizationId: q.scope('organization') already does it. Not an area.
Routes grouped under a shared path, no per-row gateA path on each feature
A shared role/rule vocabulary, no route prefixRoot authz.roles — or an area with no prefix
Everything under /project/:projectId gated by "is the caller on this project?", with the project row hydrated onto ctxAn area

An area is not multi-tenancy

The gated thing is a row inside a tenant — this project, this event, this location. Your tenant boundary is the Better Auth organization and the firewall, and it is already in place before any area runs. An area named after your tenant (workspace/, team/, tenant/) is a sign that tenancy is being modeled twice — see Tenancy is not yours to model.

Why

Cross-cutting authz for a concept like a project, an event, or a location used to live in the root quickback.config.ts and was wired to its consumers by a path-prefix string across a flat feature list. An area makes the grouping structural: the folder is the boundary, the _area.ts is the single declaration site, and the compiler enforces that the vocabulary never leaks outside the subtree.

Layout

quickback/features/
├── project/                   ← AREA (has _area.ts)
│   ├── _area.ts               ← defineArea({...})
│   ├── projects.ts            ← the area folder may itself be a feature
│   ├── project_collaborators.ts
│   ├── tasks/                 ← child feature
│   │   ├── tasks.ts
│   │   └── actions/addTask.ts
│   └── chat/                  ← child feature
│       └── actions/postMessage.ts
└── billing/                   ← flat feature (unchanged)

The gate here is collaboratorOf — a row in your project_collaborators table. That's what makes it an area: the answer isn't in ctx.roles already.

Folder membership is the whole opt-in, and it is all-or-nothing. Every feature in the folder inherits the prefix and the gate; there is no per-feature or per-table marker to opt out. To keep a feature flat, put it outside the area folder. The one per-action escape is route: 'self', and it exists for actions that must live in the subtree yet answer off-prefix — a collection route or a create, which by definition has no row to be related to yet. See Where create goes.

Reserved sub-directories (actions/, lib/, pages/) are never child features. A directory without _area.ts is a leaf feature — its unreserved subdirectories are not loaded, and a subdirectory containing defineTable(...) / defineAction(...) fails the compile (silent route/table disappearance after a mis-move is the failure mode areas must never introduce). Feature leaf names stay globally unique — they key src/features/<name>/ in the output, so moving a feature into an area changes nothing about its generated identity.

_area.ts

// features/project/_area.ts
import { defineArea } from '@quickback/compiler';

export default defineArea({
  // Namespace identity — drives the emitted routes file name and errors.
  name: 'projectScope',

  // Inherited route mount + admission gate. Optional: an area with no
  // prefix contributes shared vocabulary only. A prefix REQUIRES at least
  // one `via` lane (a mounted prefix with no gate would be ungated), and at
  // least one `:param` segment — the gate runs as middleware, before any
  // request body is parsed, so `c.req.param()` is the only place it can read
  // the resource key from. `prefix: '/project'` is a compile error.
  prefix: '/project/:projectId',
  via: ['collaboratorOf', { roles: ['admin', 'owner'] }],
  mintScope: true,
  acceptScope: 'project',

  // Area-owned vocabulary — the EXACT same shapes as the root authz blocks.
  relationships: {
    collaboratorOf: {
      from: 'project_collaborators',
      subject: { column: 'collaboratorUserId', equals: 'ctx.userId' },
      resource: { column: 'projectId' },
      where: { status: 'active' },
      loads: 'projects',
      exposeAs: 'project',
    },
  },
  roles: {
    projectStaff: { or: [{ via: 'collaboratorOf' }, { roles: ['admin', 'owner'] }] },
  },
  scopes: {
    project: {
      requestField: 'projectId',
      roles: { collaborator: { via: 'collaboratorOf', subject: 'both' } },
    },
  },
  // Named gates + tenant anchors work too — same shapes as authz.rules /
  // authz.tenants, visible to this subtree only.
});

A bare string in `via:` is a relationship name — never a role name

via: ['collaboratorOf'] names a relationship declared in relationships:. Roles go in an object lane: via: [{ roles: ['admin', 'owner'] }], and those are Better Auth org roles, not your composed roles: entries. A relationship name that isn't declared fails with "lane <name> is not declared in authz.relationships"; a role name in the string position fails the same way, because the compiler has no reason to think you meant a role.

via: accepts exactly three lane shapes:

LanePasses when
'collaboratorOf'a row in the named relationship links the caller to the :param row
{ roles: ['admin'] }the caller's Better Auth org role on the row's tenant matches
{ team: true }ctx.activeTeamId is set (the loads-table firewall does the row check)

A descendant feature references everything by bare name — no imports, no root declaration:

// features/project/tasks/actions/addTask.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Add a task to the project the area gate already proved.",
  path: './tasks/add',                    // relative → /project/:projectId/tasks/add
  input: z.object({ title: z.string().min(1) }),
  access: { roles: ['projectStaff'] },    // resolves against the area
  async execute({ db, input, ctx }) { /* … */ },
});

The visibility contract

An area's names are visible to its own subtree only (the area folder, its features, and nested areas). The compiler checks every reference channel — access roles: arrays (including named gates and both spellings of scoped roles: bare aliases and scope:<kind>:<role>), action access and anchor:, firewall via: arms, ctx.scope.<kind> accessors, { rule } and { tenant } firewall references, auth-view tenant: includes, realtime.requiredRoles, table-level namespace.via lanes, and other _area.ts nodes. Referencing an area name from outside is always a compile error:

Feature "billing" ... references role "projectStaff" declared by area "projectScope"
(features/project/_area.ts), but this site is not inside that area. ... move the
referencing code under the declaring area, or lift the declaration to root authz if it
is genuinely global.

Two structural rules keep the fold sound:

  • No overrides (v1). Re-declaring an inherited name — in any class, including scope kinds, scoped-role aliases, and namespace names — is a compile error. Declare each name in exactly one place; the fix is always to delete the duplicate, keeping the declaration in the deepest area that owns it. There is no merge, no shadowing, and no override mechanism. (overrides: is a reserved key name for a future release — it does nothing today and is not the fix.)
  • Declaration-site rule. Root authz bodies (roles, rules, tenants, scopes, namespaces) may reference root-declared names only — a root definition is visible everywhere and would launder an area name out of its subtree. An area's bodies may reference root ∪ ancestors ∪ self.

One deliberate exception: root-config namespace-identity wiring — today exactly realtime.wsTicket.namespace — may name an area's namespace. It attaches the area's complete gate (its full lane set) and constructs no new grant. Such consumers are listed in the manifest under the owning area.

Containment and the route: 'self' marker

route: 'self' marks one standalone action, inside the area folder, whose path: is outside the area's prefix. It is a label on a path you already chose — not a switch that moves a route or exempts a feature. Outside those exact conditions the compiler rejects it: on an action whose path is inside the prefix (the gate applies; the marker is dead), and in a feature with no ancestor area that mounts a prefix (there is nothing to opt out of).

The area's prefix is an audited surface:

  • A feature outside the area may not declare an action path (or a table-level namespace prefix) under the area's prefix — compile error.
  • A project-level authz.namespaces entry the area does not own — root-declared, or another area's — may not carve a prefix at or under the area's prefix. Namespace claiming is longest-prefix-wins, so such an entry would silently re-gate the area's descendant actions with its own via lanes — compile error. (A descendant feature's table-level namespace inside the prefix is legal; the manifest attributes its claimed actions to that namespace, not to the area.)
  • A descendant action whose absolute path is outside the prefix opts out of the area's admission gate and is gated solely by its own access:. That must be explicit: mark the action route: 'self', or the compile fails (a typo'd prefix must never silently self-gate). The marker is equally rejected where it is dead — on in-prefix actions and in features outside any mounted area. Every self-gated action is listed in the manifest.

Relative paths (path: './…') resolve against the area prefix. Dot segments (. / ..) are rejected outright — the resolved route must stay under the prefix.

Where create goes

An area gate answers "is the caller related to this row?" — so it cannot gate the action that creates the row. POST /conversations has no :conversationId to prove anything about. Same for the collection list: GET /conversations is a query across rows, not a request about one.

Split by cardinality, not by feature:

features/conversations/
├── _area.ts               prefix: '/conversations/:conversationId'
│                          via: ['participant']
├── conversations.ts       the table — resource path '/conversations'
│                          (collection + create; NOT under the prefix)
├── startConversation.ts   path: '/conversations', route: 'self'
└── messages/              everything per-conversation — gated by the area
    └── messages.ts        path: './messages' → /conversations/:conversationId/messages

startConversation stays in the folder, so it still reads the area's relationships and roles by bare name, and route: 'self' says out loud that it is gated by its own access: only — { roles: ['member'] }, or whatever "may open a conversation" means in your app. Its job is to insert the row and the conversation_members row that makes the caller a participant; every route under the prefix works from there.

Two constraints fall out of the same split:

  • The area prefix must carry a :param. prefix: '/conversations' is a compile error — the gate runs as middleware, before the body is parsed, so c.req.param() is the only key it can read.
  • A generated table router under the prefix needs a relationship lane. The compiler mounts a table's CRUD behind an area gate only when the area has one; a bulk-grant-only via: can't anchor the resolver, and the table is rejected rather than mounted unscoped. A table whose path sits outside the prefix (like conversations above) is unaffected.

Scoping the collection itself — "don't list conversations I'm not in" — is firewall work on the table, not area work.

Nested areas

Areas nest to arbitrary depth. A nested area may declare additional vocabulary freely. A nested area may not mount its own prefix under an ancestor that already mounts one: per-level nested admission is unlinked (a caller related to child instance S under parent A would be admitted at /a/A/s/S even when S belongs to parent B), and Quickback refuses to compile an unlinked gate. Mount one prefix per chain, or keep the nested area vocabulary-only.

The capability floor: requires: ['feature-areas']

An area-tree project must declare the marker in quickback.config.ts:

export default defineConfig({
  // ...
  requires: ['feature-areas'],
});

Any CLI — including one that predates areas — passthrough-executes the config, so the marker always reaches the compiler. A current compiler that sees the marker but no areas payload rejects the compile with an upgrade message instead of silently compiling a project whose nested features an old CLI never loaded (which would otherwise generate destructive migrations). In the other direction, a current CLI refuses output from a compiler that predates areas.

The authz manifest

Area projects emit quickback/authz-manifest.json on every compile — the audit inventory the flat config can no longer answer from one file: the area tree, every folded name and its declaring file, per-area member features, areaGatedActions vs selfGatedActions, and any root-config namespace consumers. Review it in PRs the way you review the security reports; the generated AGENTS.md also gains a feature-areas section so coding agents place new features correctly.

What belongs at root

Root authz remains the home for genuinely global vocabulary — names used across areas or by flat features. Everything scoped to one concept belongs in that concept's _area.ts. When in doubt: if only one folder's features reference it, it belongs in that folder's area.

Common mistakes

SymptomCauseFix
"lane <name> is not declared in authz.relationships"A role name in the string position of via:.Bare strings name relationships only. Roles go in { roles: [...] } — and those are Better Auth org roles, not your composed roles:.
"…has at least one RELATIONSHIP lane… this one does not" on a table under the prefixvia: is bulk-grant-only ({ roles } / { team }), which can't anchor the resolver.Add a relationship lane, or move the table's path outside the prefix. Wrapping a relationship-backed role in { roles: [...] } does not make it a relationship lane.
"prefix … must contain at least one :param segment"The prefix names a collection (/conversations).The gate is per-row: /conversations/:conversationId. Collection and create routes belong outside the prefix — see Where create goes.
"already declared at …" / "Overrides are not supported in v1"The same name declared in two places.Delete one. Keep it in the deepest area that owns it. overrides: is reserved and does nothing.
A feature in the folder should not be area-scopedMembership is the folder — no per-feature opt-out exists.Move it out of the folder, or (single action only) give it an off-prefix path: and route: 'self'.
"path is OUTSIDE that prefix … mark it explicitly with route: 'self'"Intentional off-prefix action, or a typo in the path.If intentional, add route: 'self'. If not, fix the path — this error exists so a typo can't silently self-gate.

Namespace mounts (hoisted relationship middleware)

When several standalone actions share a path prefix and the same relationship gate — /event/:eventId/announce, /event/:eventId/cancel, /event/:eventId/respond — declaring the gate per-action means writing the same roles: ['attendee'] over and over AND paying the relationship-resolver cost N times per request (one subquery + one row-load, multiplied by the number of actions).

Namespace mounts hoist the resolver into one Hono middleware that runs once per request. Every action under the prefix skips the inline gate; ctx.<exposeAs> is hydrated by the middleware before the action's execute() runs. With the per-namespace helper (v0.27+, below), that field is also statically typed as the loaded row.

When to use roles vs. relationships

A user is authorized for a row when any of the following is true:

  • they have a per-row relationship to it (e.g. an attendee row, a named organizer row), or
  • they have a role on the row's tenant (org admin/owner of the row's organizationId), or
  • they have team membership matching the row's teamId.

Use relationships when membership is specific to this row — external collaborators who aren't in your org, attendees, anyone connected to the resource by virtue of data rather than payroll. Use roles / team when staff at your org/team should see everything in their scope by default.

The two compose at the action level (access.or accepts both relationship-roles and tenant roles) and — as of v0.25 — at the namespace level too: namespace.via accepts an array of lanes, first match wins.

Rule of thumb. If the answer is "yes if you work here," reach for org/team roles. If the answer is "yes if you have something to do with this row," reach for a per-row relationship. Reach for both together — at the namespace or via access.or — when an action should let either side in.

Two declaration sites depending on whether the participating actions live in one feature or many:

Single feature: defineTable({ namespace }) (v0.23+)

Use when every action under the prefix lives in the same feature directory. The namespace travels with the table that owns it — no addition to quickback.config.ts needed.

// features/events/events.ts
import { q, defineTable } from '@quickback/compiler';

export const events = q.table('events', {
  id:             q.id(),
  name:           q.text().required(),
  organizationId: q.scope('organization'),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(events, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  namespace: {
    prefix: '/event/:eventId',   // absolute URL path
    via: 'attendeeOf',            // relationship gate (must exist in authz.relationships)
  },
});

// features/events/actions/postAnnouncement.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Post an announcement to everyone attending the event.",
  path: '/event/:eventId/announcements',
  method: 'POST',
  input: z.object({ body: z.string().max(2000) }),
  // No `via:` gate boilerplate — the namespace already ran the resolver, so
  // `access` only names roles and ctx.event is typed inside execute().
  access: { roles: ['admin', 'member'] },
  async execute({ db, input, ctx }) { /* … */ },
});

The compiler emits one app.use(...) on the events feature's Hono app. Every standalone action in features/events/actions/ whose path starts with /event/:eventId/ joins the namespace automatically.

Multi-lane via (v0.25+) and bulk-grant lanes (v0.26+) work identically on the table-level declaration — via: accepts the same shapes documented below for the project-level form.

Cross-feature: authz.namespaces (v0.24+)

Use when the namespace spans multiple features. The declaration sits next to your other authz config in quickback.config.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' },
  },
}

With the namespace declared, any standalone action whose path: matches the prefix joins the gate — regardless of which feature owns it:

// features/feeds/actions/feedMoments.ts  (in one feature)
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Publish a moment to the event's live feed.",
  path: '/event/:eventId/feed-moments',
  method: 'POST',
  input: z.object({ caption: z.string().max(280), photoId: z.string().optional() }),
  access: { roles: ['admin', 'member'] },
  async execute({ db, input, ctx }) { /* … */ },
});

// features/rsvp/actions/respond.ts  (in a different feature)
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Record the caller's RSVP for the event.",
  path: '/event/:eventId/respond',
  method: 'POST',
  input: z.object({ response: z.enum(['yes', 'no', 'maybe']) }),
  access: { roles: ['admin', 'member'] },
  async execute({ db, input, ctx }) { /* … */ },
});

The compiler emits one synthetic src/routes/__namespace-event-scope.routes.ts that imports each contributing feature's actions map under a distinct alias, mounts the resolver middleware once, and dispatches to the matching action. Worker-root mount order ensures the middleware fires before any per-feature route handler under the prefix.

Multi-lane via (v0.25+) — first match wins

When more than one relationship can authorize a caller for the namespaced resource, pass via: as an array. The middleware tries each lane in declaration order and stops at the first match — ctx.<exposeAs> is hydrated from whichever lane fired.

authz: {
  relationships: {
    attendeeOf: {
      from: 'guests',
      subject: { column: 'linkedUserId', equals: 'ctx.userId' },
      resource: { column: 'eventId' },
      where: { status: 'confirmed' },
      loads: 'events',
      exposeAs: 'event',
    },
    // Named per-event organizers (potentially EXTERNAL — these users may
    // not be a member of the event's org. That's the point: lets you grant
    // event-level access to freelancers / partner staff without adding
    // them to the org's member list.)
    organizerOf: {
      from: 'eventOrganizers',
      subject: { column: 'linkedUserId', equals: 'ctx.userId' },
      resource: { column: 'eventId' },
      loads: 'events',
      exposeAs: 'event',
    },
  },
  namespaces: {
    eventScope: {
      prefix: '/event/:eventId',
      via: ['attendeeOf', 'organizerOf'],
    },
  },
}

Hydration is per matched lane (v0.37+): each lane fills ctx.<its own exposeAs> from its own loads: table, so lanes may load different tables into different keys. Both lanes above name events / event, so ctx.event is populated whichever fires. A lane declaring neither loads: nor exposeAs: is authorization-only — it opens the gate and hydrates nothing.

Bulk-grant lanes (v0.26+)

Relationship lanes cover "specifically connected to this row." Bulk-grant lanes cover "anyone with this role / team membership in the row's tenant" — the case where an org admin or a team member should see every event in their scope without writing per-event relationship rows.

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

Each bulk-grant lane:

  • { roles: [...] } — passes when the caller's BA org-role for the loaded resource's tenant includes any listed role. Short-circuits the relationship loop entirely; the role check is in-memory and cheap. Use for the "company admin sees everything" case.
  • { team: true } — passes when ctx.activeTeamId is set. The loads-table firewall does the actual events.teamId === ctx.activeTeamId check during hydration. Use when the resource is team-scoped and you want active-team membership to grant access.

Both bulk-grant lanes piggyback on the namespace's relationship lane(s) for hydration — they don't fetch a separate per-row subject; they just gate access and let the loads-table firewall handle the tenant scoping. At least one relationship lane is required alongside bulk-grant lanes in v0.26 (to anchor loads: + exposeAs:).

When a request misses every lane, the 403 deny payload lists every authorization path the caller could have taken — relationship names, role names, and "team-membership" for { team: true } lanes — so a developer debugging an unexpected 403 sees the full surface they need to satisfy.

A namespace can also fold the scope machinery into this same gate: mintScope: true mints a scope token on a matched userId lane, and acceptScope: '<kind>' accepts an already-minted scope claim as proof of entry — one handler under the prefix serves both a Better-Auth user and a sessionless scope principal. See Scopes → acceptScope.

Discovery is path-prefix-only

Actions opt in by matching path: against a declared namespace prefix. There's no namespace: 'foo' field on defineAction — the path string is the discoverable surface, consistent with how path: already determines an action's URL.

Requirements (enforced at compile time)

Both forms share the same shape checks:

  • via: accepts one of three shapes. A single relationship name (v0.23+), an array of relationship names (v0.25+, first match wins), or — as of v0.26 — an array mixing relationship names with bulk-grant guards ({ roles: [...] }, { team: true }). A bare string is always a relationship name. There is no string form for a role: { roles: [...] } is a bulk-grant lane against Better Auth org roles, even when the name you put in it is a composed role defined via a relationship.
  • Each relationship lane declares loads + exposeAs, or neither. Both → the lane hydrates ctx.<exposeAs> from loads when it matches. Neither → an authorization-only lane. Exactly one is a compile error.
  • Bulk-grant lanes inherit loads: + exposeAs: from sibling relationship lanes. Pure bulk-grant namespaces (no relationship lane at all) are also accepted — set loads: + exposeAs: directly on the namespace declaration so hydration has somewhere to anchor.
  • prefix: must contain at least one :param segment (e.g. /event/:eventId). The middleware reads the relationship key from c.req.param() because no request body has been parsed at middleware time.
  • One declaration per namespace name. The same name in both defineTable({ namespace }) and authz.namespaces is rejected. The project-level form is canonical for cross-feature; the table-level form is the single-feature shorthand.

Project-level only:

  • Ambiguous prefixes like /a/:x and /a/:y (same shape, different :param names) are rejected. Strict super/sub-set relationships (/event/:e vs /event/:e/admin) are fine — the harvester routes each action to the longest matching prefix.
  • Unused namespace emits a [quickback:authz] warning when no action's path matches the declared prefix. Not a hard error — the namespace may be staged ahead of the actions.

What namespace actions get for free

Inside a namespace action's execute():

  • ctx.<exposeAs> is populated by the middleware with the loaded resource row. Statically any by default — narrowed to typeof <loadsTable>.$inferSelect when the action's import points at the per-namespace helper (see below).
  • Tenant context (ctx.activeOrgId / ctx.activeTeamId) is stamped from the matched relationship row when its from table carries those columns. Downstream scoped queries work without further setup.
  • The action's own access: block (additional roles, userRole, record predicates, OTHER relationship arms) still runs after the namespace gate — namespace handles entry, per-action gates further.

Narrow ctx.<exposeAs> typing (v0.27+)

The namespace middleware hydrates ctx.<exposeAs> at runtime — that's been true since v0.23. The static type of that field, though, is any via the AppContext index signature: useful for not blocking access, but a missed opportunity for autocomplete and typo-catching.

v0.27 emits one per-namespace defineAction helper alongside the standard <feature>/.quickback/define-action.ts. Importing from the narrowed helper retypes ctx.<exposeAs> inside execute({ ctx }) to the loaded row's $inferSelect shape. Nothing else changes — runtime behavior, action discovery, and the path: namespace match are identical.

features/feeds/actions/feedMoments.ts
// One-line flip per namespace-scoped action:
import { z } from 'zod';
import { defineAction } from '../.quickback/define-action.event-scope';
//                                          └──────┬──────┘
//                                       kebab(namespaceName)

export default defineAction({
  description: "Publish a moment to the event's live 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
    ctx.event.startsAt      // ✓ Date — autocomplete works
    ctx.event.organizerId   // ✓ narrows on the field's declared type

    ctx.event.idd           // ✗ TS2339 — typo caught at compile time
  },
});

The compiler emits one 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)>.tseventScope becomes define-action.event-scope.ts. Helpers live next to the standard define-action.ts in <feature>/.quickback/, so the relative import is the same shape as every other action file in your project.

Opt-in, not breaking

Actions that keep their existing import { defineAction } from '../.quickback/define-action' continue to compile unchanged — ctx.<exposeAs> is any via the AppContext index signature, which is exactly the v0.23–v0.26 behavior. You 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 emits a one-line advisory pointing at the narrowed path:

[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 [quickback:typing] family as [quickback:authz]. The advisory only mentions actions the compiler can be confident about (namespace-claimed actions whose source still imports the generic helper).

Multi-namespace features

A single feature can contribute actions to several namespaces — say, an interviews feature with actions under both interviewScope and eventScope. The compiler emits one narrowed helper per namespace the feature participates in, and each action picks the import that matches its path.

src/features/interviews/.quickback/
├── define-action.ts                   # generic — standard fallback
├── define-action.interview-scope.ts   # ctx.interview typed
└── define-action.event-scope.ts       # ctx.event typed

Why per-namespace and not per-action

A defineAction helper that's narrow to one exposed key (the namespace's exposeAs) is enough — every action under a namespace sees the same hydrated key. The alternative (per-action emit) duplicates the same prelude N times for the same shape; per-namespace keeps emission proportional to the number of distinct hydration shapes in the project.

On this page