# Access - Role & Condition-Based Access Control

Define who can perform read and write operations and under what conditions.

## Basic Usage

```typescript
// features/applications/applications.ts
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { defineTable } from '@quickback/compiler';

export const applications = sqliteTable('applications', {
  id: text('id').primaryKey(),
  candidateId: text('candidate_id').notNull(),
  jobId: text('job_id').notNull(),
  stage: text('stage').notNull(),
  notes: text('notes'),
  organizationId: text('organization_id').notNull(),
});

export default defineTable(applications, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  guards: { createable: ["candidateId", "jobId", "notes"], updatable: ["notes"] },
  read: {
    access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] },
  },
  create: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
  update: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
  delete: { access: { roles: ["owner", "hiring-manager"] } },
});
```

## Role Hierarchy

If your project uses a tiered role system (e.g., `member` < `admin` < `owner`), you can define a hierarchy in your config and use the `+` suffix to mean "this role and above":

```typescript
// quickback.config.ts
export default defineConfig({
  name: "my-app",
  auth: {
    roleHierarchy: ['member', 'admin', 'owner'],  // lowest → highest
  },
  // ...
});
```

Then in your resource definitions:

```typescript
read: { access: { roles: ["member+"] } },   // member, admin, owner
create: { access: { roles: ["admin+"] } },     // admin, owner
delete: { access: { roles: ["owner"] } },      // owner only (no +)
```

The `+` suffix expands at compile time — `"member+"` becomes `["member", "admin", "owner"]` in the generated code. You can mix hierarchical and exact roles: `roles: ["member+", "finance"]` expands to `["member", "admin", "owner", "finance"]`.

- Roles without `+` are exact matches (no expansion)
- Pseudo-roles (`PUBLIC`, `AUTHENTICATED`, `USER`, `ADMIN`, `SYSADMIN`) cannot use the `+` suffix — they're terminal markers
- Using `+` with a role not in the hierarchy throws a compile error
- Using `+` without configuring `auth.roleHierarchy` throws a compile error

## Configuration Options

```typescript
interface Access {
  // Org-membership roles (OR logic — user needs at least one)
  // Matched against ctx.roles. Use "role+" suffix for hierarchy expansion.
  roles?: string[];

  // User-table role (OR logic). Matched against ctx.userRole from the
  // user.role column — independent of org membership. See userRole below.
  userRole?: string[];

  // Record-level conditions
  record?: {
    [field: string]: FieldCondition;
  };

  // Combinators
  or?: Access[];
  and?: Access[];
}
```

## `userRole` vs `roles`

`roles` and `userRole` check **two different fields** and are often confused — get this wrong and you'll grant access too broadly.

| Primitive | Checks | Source | Example values |
|-----------|--------|--------|----------------|
| `roles`   | `ctx.roles` | Org membership (multi-tenant) or single-tenant mirror of `user.role` | `"owner"`, `"admin"`, `"member"` |
| `userRole` | `ctx.userRole` | The `user.role` column (Better Auth admin plugin) | `"user"`, `"admin"` |

In multi-tenant mode, **`roles: ["admin"]` means "org admin" — not a platform-wide admin.** An org-admin of Acme Corp has no elevated rights across other orgs. If you want to let Better Auth system admins through regardless of org membership (e.g., for support/ops), use `userRole: ["admin"]`.

In single-tenant mode (`features.organizations: false`), `ctx.roles` mirrors `user.role`, so `roles: ["admin"]` and `userRole: ["admin"]` behave the same. Using `userRole` in single-tenant code is still preferred — it documents intent and keeps the gate correct if you later enable orgs.

### When to use `userRole`

```typescript
// Platform admin can see SSNs on every customer record, regardless of org
masking: {
  ssn: { type: 'ssn', show: { userRole: ['admin'] } },
}

// Support staff (user.role === 'admin') OR the org owner can refund
access: {
  or: [
    { userRole: ['admin'] },
    { roles: ['owner'] },
  ],
}

// CMS-internal resource: only platform admins
read: { access: { userRole: ['admin'] } },
create: { access: { userRole: ['admin'] } },
update: { access: { userRole: ['admin'] } },
delete: { access: { userRole: ['admin'] } },
```

When `roles` and `userRole` both appear in a single `Access` node, they're combined with AND — the user must satisfy both. Use `or:` to express "one or the other".

`userRole` doesn't expand with the `+` hierarchy suffix — user-table roles are flat (typically just `"user"` and `"admin"`). If you've added custom user-table roles via Better Auth, list them explicitly.

### Pseudo-roles

Quickback ships five UPPERCASE role markers that are **not backed by a Better Auth role column** — they're system-level shortcuts handled directly by the compiler. The casing carries the boundary: UPPERCASE = pseudo-role (no plugin context required), lowercase = real role from a plugin's table (e.g. `members.role` from the org plugin).

| Role | Auth gate | Compile-time requirement | Runtime check |
|------|-----------|-------------------------|---------------|
| `PUBLIC` | bypassed | none — explicit opt-in for unauthenticated access | always passes (auth skipped) |
| `AUTHENTICATED` | required | none | `ctx.authenticated === true` |
| `USER` | required | resource firewall **must** scope by `userId` | `ctx.authenticated` AND (`ctx.userRole` is unset OR `ctx.userRole === "user"`) |
| `ADMIN` | required | Better Auth admin plugin **must** be enabled | `ctx.authenticated && ctx.userRole === "admin"` |
| `SYSADMIN` | required | `cms: { sysadmin: true }` **must** be set on the project | `ctx.authenticated && ctx.userRole === "sysadmin"` |

You can mix pseudo-roles with real roles in the same list — they're OR'd together.

#### `PUBLIC` — unauthenticated access

Use `roles: ["PUBLIC"]` for public-facing endpoints like contact forms, public listings, or webhooks. Skips both the auth gate and the role check.

```typescript
access: { roles: ["PUBLIC"] }
```

**Important:**
- `PUBLIC` skips authentication and role checks — anyone can call the endpoint
- **`PUBLIC` is the explicit, uppercase opt-in for unauthenticated access.** It works on every access node — reads, top-level writes (`create`/`update`/`delete`/`upsert`), views, and actions. The compiler trusts the marker; row-level safety comes from the firewall, masking, and (for token-gated endpoints like RSVP confirm, magic-link redemption, List-Unsubscribe-Post, webhook ingest) your handler's own validation. Anonymous write endpoints typically pair `roles: ["PUBLIC"]` with `firewall: { exception: true }` on the resource so the row predicate doesn't reject every unauthenticated caller.
- **Firewall still applies** — data is scoped by the firewall (organization, owner, team)
- Every `PUBLIC` action invocation is **mandatory audit logged** to the security audit table (IP address, input, result, timing)
- The wildcard `"*"` is **not supported** — using it throws a compile-time error
- Tables with no isolation columns and any `PUBLIC` route do not require `firewall: [{ exception: true }]`

For org-scoped tables, `PUBLIC` routes still filter by organization. Since the user isn't authenticated, the organization ID must be passed as a query parameter:

```
GET /api/v1/listings?organizationId=org_abc123
```

The firewall WHERE clause is always enforced. If no `organizationId` is provided, the API returns a `400` with code `ORG_REQUIRED`.

```typescript
// Public listing page — anyone can browse, but scoped to an org
export default defineTable(listings, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  read: { access: { roles: ["PUBLIC"] } },              // Public browsing + detail
  create: { access: { roles: ["admin", "member"] } }, // Auth required to create
});
```

#### `AUTHENTICATED` — any signed-in user

Use `roles: ["AUTHENTICATED"]` for endpoints that any logged-in user can hit, regardless of role or org membership. No plugin context required.

```typescript
// Public events feed — any signed-in user can list/read; anonymous gets 401
read: { access: { roles: ["AUTHENTICATED"] } }
```

#### `USER` — per-user records

Use `roles: ["USER"]` for resources where each user only sees their own records. **Requires the firewall to scope by `userId`** — the role marker is the access half of the pair, the firewall is the row-filter half.

```typescript
// "My todos" — each caller sees only their own rows
export default defineTable(todos, {
  firewall: [{ field: 'userId', equals: 'ctx.userId' }],
  read: { access: { roles: ["USER"] } },
  create: { access: { roles: ["USER"] } },
  update: { access: { roles: ["USER"] } },
  delete: { access: { roles: ["USER"] } },
});
```

If you forget the `userId` firewall predicate, the compiler throws:

> `roles: ["USER"]` requires the resource to be scoped per-user. Add a `userId` column (auto-scoped) or an explicit owner predicate. If you want any signed-in user to read all records, use `roles: ["AUTHENTICATED"]` instead.

When the Better Auth admin plugin is enabled, `USER` matches `user.role === "user"` specifically — admins are excluded and should use `["USER", "ADMIN"]` if the same route should serve both.

#### `ADMIN` — Better Auth admin-plugin admins

Use `roles: ["ADMIN"]` for system-administrator endpoints. **Requires the Better Auth admin plugin** so `user.role` exists. Compiles to `ctx.userRole === "admin"`.

```typescript
// Platform-admin-only audit log
read: { access: { roles: ["ADMIN"] } }
```

Without the admin plugin you get a compile error pointing you to either enable the plugin or use lowercase `roles: ["admin"]` (the org-membership admin role).

#### `SYSADMIN` — cross-tenant DB-admin tier

Use `roles: ["SYSADMIN"]` for endpoints that should only be reachable by platform sysadmins — the cross-tenant DB-admin tier introduced in v0.15.0. Compiles to `ctx.userRole === "sysadmin"`. **Requires `cms: { sysadmin: true }` on the project config**; without that flag, using `SYSADMIN` fails the compile so the role can't sit as dead code.

```typescript
// Cross-tenant audit dashboard, sysadmin-only
read: { access: { roles: ["SYSADMIN"] } }
```

`ADMIN` and `SYSADMIN` are **independent** at the evaluator level — `ADMIN` admits `userRole === 'admin'` only, `SYSADMIN` admits `userRole === 'sysadmin'` only. Routes that need both write `access: { roles: ['ADMIN', 'SYSADMIN'] }` explicitly. The one place sysadmin gets implicit extra powers is **the firewall escape hatch**: when `cms.sysadmin: true`, every `buildFirewallConditions` emits an `if (ctx.userRole === 'sysadmin') return undefined;` (or just the soft-delete predicate when present) at the top — so sysadmins see every tenant's rows, not just their own org's. That escape hatch does NOT fire for `userRole === 'admin'`; admins still go through the firewall like everyone else.

This separation exists so Better Auth admin-plugin powers (user management, impersonation) and DB-admin powers (cross-tenant data inspection) stay decoupled. A user can have `user.role === 'admin'` without DB-level cross-tenant access; a user can have `user.role === 'sysadmin'` without org-membership context. See [CMS Configuration](/compiler/config#cms-configuration) for the `cms.sysadmin` flag, the cross-tenant `/auth/v1/admin/list-organizations` endpoint, and the SPA's "All organizations" sentinel.

#### Mixing pseudo-roles with real roles

Pseudo-roles and lowercase real roles compose with OR:

```typescript
// Record owner OR a system admin can read
read: { access: { roles: ["USER", "ADMIN"] } }

// Org owner OR system admin
delete: { access: { roles: ["owner", "ADMIN"] } }
```

> Note: today, an `ADMIN` match still goes through the `userId` firewall scope on USER-scoped resources — admins see only records they own on those routes. Use `firewall: { exception: true }` on a separate admin-only route if you need cross-user visibility.

#### Quick reference

```typescript
// Unauthenticated public
access: { roles: ["PUBLIC"] }

// Any signed-in user, no scoping
access: { roles: ["AUTHENTICATED"] }

// Per-user records (requires userId firewall scope)
access: { roles: ["USER"] }

// System admin (requires admin plugin)
access: { roles: ["ADMIN"] }

// Org-membership roles with hierarchy expansion
access: { roles: ["member+"] }     // expands to ["member", "admin", "owner"]
access: { roles: ["admin+"] }      // expands to ["admin", "owner"]
access: { roles: ["admin"] }       // exact match (no expansion)
```

```typescript
// Field conditions - value can be string | number | boolean
type FieldCondition =
  | { equals: value | '$ctx.userId' | '$ctx.activeOrgId' }
  | { notEquals: value }
  | { in: value[] }
  | { notIn: value[] }
  | { lessThan: number }
  | { greaterThan: number }
  | { lessThanOrEqual: number }
  | { greaterThanOrEqual: number };
```

## Relationship-based authorization

Beyond Better Auth roles (org membership, user-table role) and pseudo-roles, Quickback supports **relationship-based authorization roles** — names that bind to a declared relationship between the caller and a resource. Use these when access should follow a domain-table membership ("user is a confirmed guest of this event") rather than an org/admin gate.

### `authz.relationships`

Declare relationships once in `quickback.config.ts`:

```typescript
// quickback.config.ts
import { defineConfig } from '@quickback/compiler';

export default defineConfig({
  // ...providers...
  authz: {
    relationships: {
      guestOf: {
        from: 'event_guests',                                 // table name string
        subject: { column: 'userId', equals: 'ctx.userId' },  // caller side
        resource: { column: 'eventId' },                      // resource side
        where: { status: 'confirmed' },                       // optional filter
      },
    },
    roles: {
      guest: { via: 'guestOf' },
      // Composite: any guest OR an org owner/admin
      eventStaff: {
        or: [
          { via: 'guestOf' },
          { roles: ['admin', 'owner'] },
        ],
      },
    },
  },
});
```

### Using relationship-roles in resources

Resource definitions reference declared roles by name in `roles: [...]` arrays exactly like BA roles or pseudo-roles. The compiler rewrites the access tree at build time so the runtime route does an inline relationship lookup against the declared table.

**Today (v1) this works on:**

| Surface | Status | Notes |
|---|---|---|
| `firewall:` `via:` predicate | ✓ Supported | Subquery composes as outermost AND |
| `crud.*.access` `roles: ['guest']` | ✓ Supported | Inline async lookup per request |
| `read.access` / `read.views.*.access` `roles: ['guest']` | ✓ Supported | Same inline path |
| `masking.<field>.show` `roles: ['guest']` | ⚠ Refused at compile | v1 limitation — async/batched mask functions deferred |
| **Standalone** `actions.<name>.access` `roles: ['guest']` | ✓ Supported (v0.19+) | Inline gate; surface must expose `resource.column` as path param or input field |
| **Record-based** `actions.<name>.access` `roles: ['guest']` | ⚠ Fail-closed at runtime | Runtime helper denies; inline emission is a planned follow-up. Use `firewall: { exception: true }` or move the check into `execute()` until then |

For the surfaces that are not yet supported, the compiler refuses at build time with a clear error pointing to a workaround. The runtime evaluator fails closed on relationship arms regardless, so silent grants are impossible.

### Standalone actions: surface keys and `loads` / `exposeAs` (v0.19+)

When a standalone action's `access.roles` references a relationship-role, the compiler emits a runtime gate between input validation and `execute()`. The gate reads the relationship's `resource.column` value from the action surface — either a path parameter (`:eventId`) or an input field (`input.eventId`). If neither is present, the compiler:

- **v0.19** — emits a `[quickback:authz]` warning and skips resolver emission. The action's own hand-rolled gate (if any) keeps the route working at runtime.
- **v0.20** — hard compile error. Set `firewall: { exception: true }` to opt out (cross-tenant/admin routes only).

Pair the relationship with `loads` + `exposeAs` to hydrate the loaded resource onto `ctx`:

```typescript
authz: {
  relationships: {
    attendeeOf: {
      from: 'guests',
      subject: { column: 'userId', equals: 'ctx.userId' },
      resource: { column: 'eventId' },
      where: { status: 'confirmed' },
      loads: 'events',           // ← v0.19+: load matching event row
      exposeAs: 'event',         //   ← onto ctx.event for the action body
    },
  },
  roles: {
    attendee: { via: 'attendeeOf' },
  },
}
```

The resolver (v0.20):

1. **Cheap in-memory role check** first — `ctx.roles?.includes('admin')` and friends. If the caller has a qualifying role, this short-circuits and no DB queries fire for the access gate.
2. **Firewall-gated relationship match** if the role check failed — `SELECT { organizationId, teamId } FROM guests WHERE userId=ctx.userId AND eventId=input.eventId AND status='confirmed'` with the `guests` table's full firewall AND-applied. If neither role nor relationship qualifies → 403.
3. **Tenant stamping from the matched row** — for any tenant column the `from` table carries (`organizationId`, `teamId`), the resolver stamps `ctx.activeOrgId ??= matchedRow.organizationId` (and similar for team). This is **in-memory mutation of the request-scoped ctx only** — no JWT remint, no session-record write, no persistent grant.
4. **Firewall-gated load of the loaded resource** — `SELECT * FROM events WHERE id = :eventId AND buildEventsFirewallConditions(ctx, db)`. The events firewall is applied as normal; it succeeds for attendees because tenant was just stamped from the relationship row.
5. Assigns the loaded row to `ctx.event` for the action body.

**No firewall bypass anywhere.** Both queries run with their respective tables' full firewalls. The relationship row's existence is the proof that the caller is authorized; its tenant columns are the source of the stamped claim.

### Conditional tenant preconditions (v0.20+)

Standalone actions in features with org/team-scoped tables previously emitted unconditional preconditions:

```ts
if (!ctx.activeOrgId) return c.json(AccessErrors.noOrg(), 403);
if (!ctx.activeTeamId) return c.json(AccessErrors.noTeam(), 403);
```

These ran **before** the relationship resolver, which 403'd attendees whose sessions don't carry an active tenant claim (the canonical event-attendee shape).

v0.20 makes these preconditions **conditional on relationship supply**. If the access tree contains a relationship arm whose `from` table carries the matching tenant column, the precondition is skipped — the resolver runs first and stamps the value. Pseudo-role short-circuits (PUBLIC / AUTHENTICATED / USER / ADMIN / SYSADMIN) and `firewall: { exception: true }` still bypass the preconditions as before.

Typical event-attendee setup (`guests` table carries `organizationId`): the org precondition is dropped on every action whose access references `attendee` or `eventStaff`. Org admins still get `ctx.activeOrgId` from their session as today; attendees get it from the relationship row, mid-request. Both paths converge on a valid tenant claim before `execute()` runs.

### Requirement on the relationship's `from` table

For the v0.20 tenant-stamping to work, the relationship's `from` table must carry tenant columns:

- For org stamping: an `organizationId` column (or whatever spelling the project's auth-provider defaults pick).
- For team stamping: a `teamId` column.

When the `from` table has **neither**, the compiler emits a `[quickback:authz]` warning at compile time:

```
Relationship "X" at authz.relationships.X has no organization or team columns
on its `from` table ("guests"). Action "Y" will compile, but downstream
scoped-db queries inside execute() may 403 because ctx.activeOrgId /
activeTeamId cannot be stamped from the match row. Either add the tenant
column to "guests", or set firewall: { exception: true } on the action.
```

The relationship match still works (existence check), but no tenant gets stamped — so the conventional precondition still fires and the action still 403s for callers without a session-resident tenant claim. Add the tenant column (or denormalize it onto the relationship table during writes) to enable the stamping path.

The body can then trust `ctx.event` and drop the hand-rolled `SELECT … FROM guests WHERE …` boilerplate:

```ts
// features/chat/actions/getInbox.ts
export default defineAction({
  path: '/chat/get-inbox',
  input: z.object({ eventId: z.number().int().positive() }),
  access: { roles: ['admin', 'member', 'attendee'] }, // attendee is a relationship-role
  execute: async ({ db, ctx, input }) => {
    // No hand-rolled attendance check needed — the resolver already ran.
    // ctx.event is the loaded events row. Typed as `any` here because the
    // hand-rolled `roles: ['attendee']` action gates per-action, not per-
    // namespace — see "Narrow ctx.<exposeAs> typing" below for the typed
    // import flip available when the same hydration runs through a
    // namespace mount.
    const event = ctx.event as typeof events.$inferSelect;
    // … domain logic only.
  },
});
```

`loads` and `exposeAs` are co-required (set both or neither). `exposeAs` must be a valid JS identifier and must not collide with built-in `ctx` fields (`userId`, `activeOrgId`, `activeTeamId`, `db`, etc.).

### Action firewall escape hatch (v0.19+)

`defineAction({ firewall: { exception: true } })` opts the action out of the inherited session-key preconditions (`ctx.activeOrgId` / `ctx.activeTeamId` requirements) AND the relationship resolver. The handler receives `unsafeDb` for raw Drizzle access. Use for cross-tenant or admin routes whose surface intentionally doesn't name a session/relationship key:

```ts
defineAction({
  path: '/admin/cross-tenant-report',
  firewall: { exception: true },
  unsafe: { reason: 'cross-tenant audit', adminOnly: true, targetScope: 'all' },
  access: { roles: ['ADMIN'] },
  execute: async ({ unsafeDb, ctx }) => { /* cross-org query */ },
});
```

`firewall: { exception: true }` is the declarative twin of `unsafe: true` — they're conceptually distinct: `firewall.exception` opts out of the gate-emission pillars; `unsafe` signals audit/cross-tenant intent (and still applies for logging). Most cross-tenant actions set both.

### Recommended pattern: gate at the firewall layer

Most of the time, relationship-based row visibility is what you want, and putting it at the **firewall** layer is the cleanest expression. The firewall is row-filter; the access layer is route/role-filter; let each do its job:

```typescript
// features/sessions/sessions.ts
firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
  { field: 'eventId', via: 'guestOf' },          // ← row visibility
],
read: {
  access: { roles: ['admin', 'member'] },          // ← who can hit the route
  views: {
    cms:   { access: { roles: ['admin', 'member'] }, fields: [...] },
    guest: { access: { roles: ['member', 'admin'] }, fields: [...] },
  },
},
```

A guest who's in your org as `member` can hit the endpoint and the firewall filters them down to events they're guests of. Cross-tenant access is impossible because the org filter is the outermost AND.

### Cross-tenant "my events" dashboards are NOT this

A common adjacent pattern — "logged-in user wants `GET /my-events` returning events they're a guest of *across all tenants*" — is **not** solved by relationship-roles. The tenant firewall is enforced at the outermost AND, so a guest of an event in another tenant remains correctly blocked.

For cross-tenant aggregation, use one of:

- **User-scoped firewall**: `firewall: { owner: { column: 'userId' } }` — a flat per-user table.
- **A standalone action that walks the relationship table itself** — see worked example below.

Don't reach for relationship-roles first and then get confused when the tenant firewall blocks. The two patterns serve different problems.

#### Worked example: `GET /my-events` standalone action

The cleanest expression of the cross-tenant attendee dashboard is a standalone action with a custom path that does its own join against the relationship table. The action is gated by `roles: ['AUTHENTICATED']` (any signed-in user can hit it; per-row scoping is the query itself), and the SQL filters by `ctx.userId` so each caller sees exactly the events they're a guest of:

```ts
// features/dashboard/actions/myEvents.ts
import { z } from 'zod';
import { eq, and, inArray } from 'drizzle-orm';
import { defineAction } from '../.quickback/define-action';
import { events } from '../../events/events';
import { guests } from '../../guests/guests';

export default defineAction({
  description: "List events the caller is a confirmed guest of, across all tenants.",
  path: '/my-events',
  method: 'GET',
  input: z.object({}),
  // Any signed-in user. Row visibility comes from the query, not the role.
  access: { roles: ['AUTHENTICATED'] },
  async execute({ db, ctx }) {
    // 1. Find every event the caller has a confirmed guest row for.
    const guestRows = await db
      .select({ eventId: guests.eventId })
      .from(guests)
      .where(
        and(
          eq(guests.userId, ctx.userId!),
          eq(guests.status, 'confirmed'),
          // guests.organizationId is NOT filtered here — that's the whole
          // point of the cross-tenant lane. The relationship row's org is
          // ambient information that this action explicitly traverses.
        )
      );

    if (guestRows.length === 0) return { events: [] };

    // 2. Pull the matching events. NOTE: this query also bypasses
    //    `events.organizationId` scope — by design — so explicitly filter
    //    out soft-deleted rows yourself if your `events` table uses them.
    const eventIds = guestRows.map((r) => r.eventId);
    const rows = await db
      .select()
      .from(events)
      .where(inArray(events.id, eventIds));

    return { events: rows };
  },
});
```

Why this is a standalone action and not a CRUD route:

- **Tenant firewall would block it.** The default `firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }]` on `events` is correct for every other read — only the cross-tenant dashboard wants to step outside it, and that's a deliberate, explicit step taken in handler code.
- **The join is the access control.** A relationship-role at the firewall layer can't express "filter by this user's relationships, ignoring the org context." The handler does that join directly.
- **Audit-logged automatically.** Standalone actions are mandatorily audit-logged in the runtime — the cross-tenant traversal is recorded with `ctx.userId`, IP, and timing.

Two important guardrails when you write one of these:

1. **Always filter by `ctx.userId` in the relationship lookup.** If you accept a `userId` from the request body and trust it, you've built an IDOR. The example above uses `ctx.userId!` directly so the only events returned are the caller's.
2. **You take responsibility for soft-delete and other invariants the firewall would otherwise apply.** Add the `deletedAt` filter, status checks, and any other row-level predicates by hand. The action is OUTSIDE the firewall pipeline by design — the cost is that you can't lean on the auto-applied predicates.

If you find yourself writing more than one of these per project, that's usually a signal to revisit the schema (could a user-owned `userEvents` materialized view replace the join?) rather than a sign Quickback should ship a higher-level abstraction. Cross-tenant lanes are intentionally explicit.

### Compile-time refusals (consolidated)

The validator and resolver reject:

- Unknown key in `authz` block (typo guard — `realtionships` would otherwise silently produce a permanently-denied role).
- Reserved role-name (auth-provider-derived). Names like `guest`, `speaker`, `attendee` are free; `admin`/`owner`/`member` are reserved when the BA org plugin is on.
- Cycle in role composition (`a → b → a`).
- Relationship `from` references unknown table.
- Relationship-role used on a resource missing the `resource.column`.
- Relationship table with `firewall: { exception: true }` (would let cross-tenant grants slip through).
- `lowering: 'session'` (reserved for v2 — staleness/invalidation ergonomics not yet shipped).
- `+` hierarchy suffix on a relationship-role name (terminal, like pseudo-roles).
- Relationship-roles in `masking.show` / `actions.access` (deferred surfaces — see the table above).

## 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.

```typescript
// features/events/events.ts
export default defineTable(events, {
  firewall: [...],
  crud: { ... },
  namespace: {
    prefix: '/event/:eventId',   // absolute URL path
    via: 'attendeeOf',            // relationship gate (must exist in authz.relationships)
  },
});

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

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/feed-moments.ts  (in one feature)
export default defineAction({
  path: '/event/:eventId/feed-moments',
  method: 'POST',
});

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

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'],
    },
  },
}
```

All relationship lanes in a namespace **must agree on `loads:` + `exposeAs:`** — that's what guarantees `ctx.event` has the same shape regardless of which lane matched. The compiler rejects heterogeneous lanes at parse time.

### 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.

### 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 }`).
- **Every relationship lane must declare `loads + exposeAs`.** Without `loads` there's nothing for the middleware to hydrate; the namespace gate would be indistinguishable from a per-action access rule.
- **All relationship lanes in one namespace must agree on `loads:` + `exposeAs:`.** Otherwise `ctx.<exposeAs>` would change shape depending on which lane matched.
- **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/feed-moments.ts"
// One-line flip per namespace-scoped action:
import { defineAction } from '../.quickback/define-action.event-scope';
//                                          └──────┬──────┘
//                                       kebab(namespaceName)

export default defineAction({
  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.

## Read & Write Configuration

Reads (`GET /` and `GET /:id`) live under `read:`; mutations live under top-level `create:`, `update:`, `delete:`, and `upsert:`. See [Read](/compiler/definitions/read) for the full read pipeline reference.

```typescript
read: {
  // LIST + GET - GET /resource and GET /resource/:id
  access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] },
  pageSize: 25,        // Default page size
  maxPageSize: 100,    // Client can't exceed this
  views: {              // Per-view field projections
    summary: {
      fields: ['id', 'candidateId', 'jobId', 'stage'],
      access: { roles: ["interviewer"] },
    },
  },
}

create: {
  // CREATE - POST /resource
  access: { roles: ["owner", "hiring-manager", "recruiter"] },
  defaults: {           // Default values for new records
    stage: 'applied',
  },
},

update: {
  // UPDATE - PATCH /resource/:id
  access: {
    or: [
      { roles: ["hiring-manager", "recruiter"] },
      { roles: ["interviewer"], record: { stage: { equals: "interview" } } }
    ]
  },
},

delete: {
  // DELETE - DELETE /resource/:id
  access: { roles: ["owner", "hiring-manager"] },
  mode: "soft",  // 'soft' (default) or 'hard'
},

upsert: {
  // PUT - PUT /resource/:id (only when generateId: false + guards: false)
  access: { roles: ["hiring-manager", "sync-service"] },
}
```

## Evaluation Order

Generated `/:id` and batch handlers evaluate access in this fixed order:

1. **Auth gate** — 401 if unauthenticated
2. **Pre-record access** — role-only check (skips `access.record` predicates)
3. **Firewall query** — `WHERE` clause filters by ownership
4. **Post-record access** — full check including record-level predicates
5. **Masking** — applied to the response

Step 2 closes the 404-vs-403 ID-probe channel: an unauthorized caller without
a matching role gets 403 before the database is touched, so they can't tell
apart "row exists out-of-scope" from "row doesn't exist." A caller with the
right role but the wrong tenant gets 403 by default (firewall reveals via
the access layer); set
[`firewallErrorMode: 'hide'`](/compiler/definitions/firewall) to return an
opaque 404 instead.

Function-form access (`access: async (ctx, record) => ...`) is opaque to
the pre-record phase and always passes there; it's fully evaluated post-record.

## List Filtering (Query Parameters)

The LIST endpoint automatically supports filtering via query params:

```
GET /jobs?status=open                         # Exact match
GET /jobs?salaryMin.gt=50000                  # Greater than
GET /jobs?salaryMin.gte=50000                 # Greater than or equal
GET /jobs?salaryMax.lt=200000                 # Less than
GET /jobs?salaryMax.lte=200000                # Less than or equal
GET /jobs?status.ne=closed                    # Not equal
GET /jobs?title.like=Engineer                 # Pattern match (LIKE %value%)
GET /jobs?status.in=open,draft                # IN clause
```

| Operator | Query Param | SQL Equivalent |
|----------|-------------|----------------|
| Equals | `?field=value` | `WHERE field = value` |
| Not equals | `?field.ne=value` | `WHERE field != value` |
| Greater than | `?field.gt=value` | `WHERE field > value` |
| Greater or equal | `?field.gte=value` | `WHERE field >= value` |
| Less than | `?field.lt=value` | `WHERE field < value` |
| Less or equal | `?field.lte=value` | `WHERE field <= value` |
| Pattern match | `?field.like=value` | `WHERE field LIKE '%value%'` |
| In list | `?field.in=a,b,c` | `WHERE field IN ('a','b','c')` |

## Sorting & Pagination

```
GET /jobs?sort=createdAt&order=desc   # Sort by field
GET /jobs?limit=25&offset=50          # Pagination
```

- **Default limit**: 50
- **Max limit**: 100 (or `maxPageSize` if configured)
- **Default order**: `asc`

## Delete Modes

```typescript
delete: {
  access: { roles: ["owner", "hiring-manager"] },
  mode: "soft",  // Sets deletedAt/deletedBy, record stays in DB
}

delete: {
  access: { roles: ["owner", "hiring-manager"] },
  mode: "hard",  // Permanent deletion from database
}
```

## Context Variables

Use `$ctx.` prefix to reference context values in conditions:

```typescript
// User can only view their own records
access: {
  record: { userId: { equals: "$ctx.userId" } }
}

// Nested path support for complex context objects
access: {
  record: { ownerId: { equals: "$ctx.user.id" } }
}
```

### AppContext Reference

| Property | Type | Description |
|----------|------|-------------|
| `$ctx.userId` | `string` | Current authenticated user's ID |
| `$ctx.activeOrgId` | `string` | User's active organization ID |
| `$ctx.activeTeamId` | `string \| null` | User's active team ID (if applicable) |
| `$ctx.roles` | `string[]` | User's roles in current context |
| `$ctx.isAnonymous` | `boolean` | Whether user is anonymous |
| `$ctx.user` | `object` | Full user object from auth provider |
| `$ctx.user.id` | `string` | User ID (nested path example) |
| `$ctx.user.email` | `string` | User's email address |
| `$ctx.{property}` | `any` | Any custom context property |

## Function-Based Access

For complex access logic that can't be expressed declaratively, use a function:

```typescript
update: {
  access: async (ctx, record) => {
    // Custom logic - return true to allow, false to deny
    if (ctx.roles.includes('admin')) return true;
    if (record.ownerId === ctx.userId) return true;

    // Check custom business logic
    const membership = await checkTeamMembership(ctx.userId, record.teamId);
    return membership.canEdit;
  }
}
```

Function access receives:
- `ctx`: The full AppContext object
- `record`: The record being accessed (for get/update/delete operations)
