# Feature Areas - Hierarchical Authz Grouping

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](/configure/domains)).

## Do you need one?

An area is [rung 3](/define#the-authorization-ladder) — reach for it only when a subtree shares
a URL prefix **and** a per-row admission gate. Most apps never need one.

| You want | Use |
|---|---|
| Every row scoped to the caller's tenant | Nothing — `organizationId: q.scope('organization')` already does it. **Not an area.** |
| Routes grouped under a shared path, no per-row gate | A `path` on each feature |
| A shared role/rule vocabulary, no route prefix | Root [`authz.roles`](/define/authz-roles#centrally-defined-access-rules) — or an area with no `prefix` |
| Everything under `/project/:projectId` gated by "is the caller on this project?", with the project row hydrated onto `ctx` | **An 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](/define#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'`](#containment-and-the-route-self-marker), 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](#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`

```typescript
// 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
> &lt;name&gt; 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:
>
> | Lane | Passes 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:

{/* doc-compile: skip — cross-fence dependency: the relative `path: './tasks/add'` and the `projectStaff` role both resolve against the `_area.ts` in the fence directly above, and the area is the whole point of the example. The gate compiles each fence as an isolated project, so this feature has no ancestor area to inherit a prefix from. */}
```typescript
// 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](/define/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`:

```typescript
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

| Symptom | Cause | Fix |
|---|---|---|
| *"lane &lt;name&gt; 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 prefix | `via:` 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](#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-scoped | Membership 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](#narrow-ctxexposeas-typing-v027)), 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.

{/* doc-compile: skip — cross-fence dependency: `namespace.via: 'attendeeOf'` must resolve against `authz.relationships`, which is declared in the `quickback.config.ts` fence in the next section. The gate compiles each fence as an isolated project against a fixed harness config, so the lane can never be declared here. */}
```typescript
// 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`.

```typescript
// 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:

```typescript
// 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.

```typescript
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.

```typescript
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`](/define/scopes#acceptscope--accept-an-incoming-scope-claim-as-proof-of-entry).

### 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](#narrow-ctxexposeas-typing-v027)).
- 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.

```ts title="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)>.ts` — `eventScope` 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:

```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 `[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.

```text
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.

