# Scopes & Capability Grants

Org membership answers *"which tenant is this caller in?"* Scopes answer a different question: *"this caller isn't an org member, but they're a **confirmed attendee of event E** — let them see exactly E's rows, as exactly the role they hold, and nothing else."*

A **scope** is a tenant axis whose value is established by a **relationship you prove once** (at `POST /scope/v1/enter`) and then carry in a signed JWT claim — verified cryptographically on every later request with **no database round-trip**. It's the REST/JWT twin of the realtime ws-ticket.

## When to use it

Reach for a scope when a principal must touch a bounded slice of an instance **without being an org member**:

- **Event attendee** — reads their event's announcements / sessions, not the org.
- **Shuttle driver** — sees only the passengers on *their* bus (a row slice), can DM them.
- **F&B vendor** — reads dietary *counts*, zero attendee rows.
- **Visiting nurse / carrier / contractor** — one patient's meds, one carrier's shipments.

Each is *event-scoped* but a **distinct least-privilege role**. Org members are unaffected — they keep their normal tenant access.

Most scope principals are signed-in users who prove a relationship at [`/scope/v1/enter`](#2-enter-the-scope). But some have **no account at all** — an attendee who arrives by email + invite code, a kiosk/handout code, a partner token. For those, an action proves the principal its own way and [`ctx.mintScope`](#sessionless-scope-mint--ctxmintscope) mints the same scope, no Better Auth user required.

## The model

### 1. Declare relationships + scopes

A scope **kind** (`event`) declares named **roles**, each conferred by a relationship whose `from` row carries the role (and optionally a **sub-key** for row slicing).

Scope kinds declare at root `authz` (visible everywhere) or inside a
[feature area](/define/areas)'s `_area.ts` — then the kind, its bare
role aliases, the canonical `scope:<kind>:<role>` spellings, and `ctx.scope.<kind>` accessors
are all visible to that area's subtree only (any outside reference is a compile error):

```typescript
// quickback.config.ts
authz: {
  relationships: {
    attendeeOf:      { from: 'guests', subject: { column: 'linkedUserId', equals: 'ctx.userId' }, resource: { column: 'eventId' }, where: { status: 'confirmed' } },
    organizerOf:     { from: 'staff',  subject: { column: 'linkedUserId', equals: 'ctx.userId' }, resource: { column: 'eventId' }, where: { role: 'organizer' } },
    shuttleDriverOf: { from: 'staff',  subject: { column: 'linkedUserId', equals: 'ctx.userId' }, resource: { column: 'eventId' }, where: { role: 'shuttleDriver' } },
  },
  scopes: {
    event: {
      requestField: 'eventId',                 // the body field the client sends to /enter
      roles: {
        attendee:      { via: 'attendeeOf' },
        organizer:     { via: 'organizerOf' },
        shuttleDriver: { via: 'shuttleDriverOf', subKeys: ['shuttleId'] }, // row-slice sub-key
      },
    },
  },
}
```

### 2. Enter the scope

```http
POST /scope/v1/enter      (session OR existing JWT auth)
{ "eventId": "evt_123" }
```

The server probes **every declared role** for the proposed instance (one DB touch), and mints the proven roles + sub-keys into a signed JWT `scope` claim, returned via the `set-auth-token` header and the body:

```jsonc
// a shuttle driver who is also a confirmed guest
"scope": { "event": { "id": "evt_123", "roles": ["attendee", "shuttleDriver"], "shuttleId": "shA", "sctMs": "m1700000000000" } }
```

`sctMs` is on **every** entry: the server's proof timestamp for that kind (epoch milliseconds, written `m<ms>`) — see [Revocation & TTL](#revocation--ttl). The examples below elide it to keep the key under discussion in view.

The client sends that JWT as `Authorization: Bearer …` on subsequent requests. The auth middleware verifies the signature and sets `ctx.scope` — **no DB**. The claim is carried forward on every authed call (rolling refresh) for up to **15 minutes from the last actual relationship proof** — the mint stamps a proof timestamp that the refresh carries forward verbatim and checks against the carry-forward cap. Past the cap the refresh stops, the token expires within one TTL, and the client re-proves via `enter` (or any namespace route that mints) to restart the window.

The proof timestamp is **per scope kind**. A mint proves the kind it was asked for and carries every other kind forward *with the stamp it already had* (`sctMs` on the entry, epoch milliseconds written `m<ms>`), so entering one relationship never extends — or resurrects — another. Re-proving `event` restarts `event`'s window and nothing else.

> **Invariant:** the client only *proposes* the instance id (from the URL). The server proves membership before signing. No client-asserted scope is ever trusted.

### 3. Gate the pillars

Scoped roles are referenced everywhere a pillar takes a role as the namespaced identifier **`scope:<kind>:<role>`**:

```typescript
// features/guests/guests.ts — the manifest, row-sliced + PII-masked
export default feature('guests', {
  columns: {
    id:             q.id(),
    nameAtInvite:   q.text().required(),
    email:          q.text().required(),
    eventId:        q.text().required(),
    shuttleId:      q.text().optional(),
    pickupLocation: q.text().optional(),
    organizationId: q.scope('organization'),
    ...q.audit(),
    ...q.softDelete(),
  },
  // org staff see the whole event; a shuttle driver sees only their bus
  firewall: {
    any: [
      { field: 'organizationId', equals: 'ctx.activeOrgId' },
      { all: [
        { field: 'eventId',   equals: 'ctx.scope.event' },
        { field: 'shuttleId', equals: 'ctx.scope.event.shuttleId' }, // sub-key row slice
      ] },
    ],
  },
  masking: {
    email: { type: 'email', show: { roles: ['admin', 'scope:event:organizer'] } }, // hidden from the driver
  },
  read: {
    views: {
      manifest: {
        fields: ['id', 'nameAtInvite', 'shuttleId', 'pickupLocation'],
        access: { roles: ['admin', 'scope:event:organizer', 'scope:event:shuttleDriver'] },
      },
    },
  },
});
```

| Pillar | How it reads the scoped role |
|--------|------------------------------|
| **Firewall** (rows) | `all`/`any` arms keyed on `ctx.scope.<kind>` (+ sub-keys) — see [Firewall](/define/firewall) |
| **Access** (verbs/views) | `access.roles: ['scope:event:shuttleDriver']` on CRUD, actions, and views |
| **Masking** (fields) | `masking.<col>.show.roles` includes / excludes `scope:event:<role>` |

## The org / scope split (security)

**Scoped roles never merge into `ctx.roles`.** A `scope:*` identifier matches **only** the verified scope claim (`ctx.scope.<kind>.roles`); a bare org role (`admin`, `member`) matches **only** `ctx.roles`. The compiler enforces the split so an externally-provisioned `shuttleDriver` can **never** satisfy an `admin` gate by string coincidence, and a tampered scope claim can't impersonate an org role. This is the precondition for admitting limited outside users at all.

## Sub-keys (row slicing)

A role's `subKeys` name columns on the relationship's `from` row that are projected into the claim. A firewall arm then slices on them:

```typescript
{ field: 'shuttleId', equals: 'ctx.scope.event.shuttleId' }
```

Because sub-key arms compose with `all`, an **absent** sub-key denies (`sql\`1 = 0\``) rather than widening — a driver can never see *all* shuttles by simply lacking the claim. The scope accessor is always emitted null-safe (`ctx.scope?.event?.shuttleId`), so an org caller who never entered the scope hits a clean deny, not a 500.

### Set-valued sub-keys

A driver may cover **more than one** bus. Append a `[]` marker to the
sub-key name to project **every** matched `from` row's value into the claim
as a `string[]`:

```typescript
roles: {
  shuttleDriver: { via: 'shuttleDriverOf', subKeys: ['shuttleId[]'] }, // set-valued
}
```

The `[]` is a marker only — it's stripped from the column name (the
relationship's `from` row still reads `shuttleId`). At `/scope/v1/enter` the
proven rows union their values, so the claim carries an array:

```jsonc
"scope": { "event": { "id": "evt_123", "roles": ["shuttleDriver"], "shuttleId": ["shA", "shC"] } }
```

The matching firewall arm — written exactly as for the scalar case
(`{ field: 'shuttleId', equals: 'ctx.scope.event.shuttleId' }`) — is
**rewritten by the compiler** to an `inArray` row-slice:

```sql
WHERE shuttle_id IN (ctx.scope?.event?.shuttleId ?? [])
```

The `?? []` default makes an absent claim lower to `inArray(col, [])` — a
clean DENY, never a malformed SQL fragment or a 500. A scalar sub-key (no
`[]` marker) is untouched and keeps lowering to `eq`. You write the same
`equals` arm either way; declaring `shuttleId[]` vs `shuttleId` in the scope
role is the only switch.

## Auto-stamping the scope column on insert (`q.scope`)

`q.scope('organization')` auto-stamps the tenant column on write, the same
way `q.scope('owner')` stamps `ownerId`. A relationship-derived kind works
identically: `q.scope('event')` auto-stamps the column from the **verified
scope claim** (`ctx.scope.event.id`) on insert — no need to pass `eventId`
in the request body:

```typescript
export const eventNotes = q.table('event_notes', {
  id:             q.id(),
  eventId:        q.scope('event'),         // INSERT autofill from ctx.scope.event.id
  body:           q.text().required(),
  organizationId: q.scope('organization'),  // INSERT autofill from ctx.activeOrgId
  ...q.audit(),
  ...q.softDelete(),
});
```

The stamp is **insert-only** and null-safe — the generated CREATE handler
writes `eventId: ctx.scope?.event?.id`, so it never binds `undefined`. It
does **not** add a read-time `WHERE` filter (that's the firewall's job; a
read scoped to `eventId = ctx.scope.event.id` would be wrong here). Infra
kinds map to their session fields (`organization` → `ctx.activeOrgId`,
`owner` → `ctx.userId`, `team` → `ctx.activeTeamId`); any other kind maps to
`ctx.scope.<kind>.id`.

### Stamping a proven *identity* claim (`q.stamp`)

`q.scope('event')` stamps the **tenant** id (`ctx.scope.event.id`). To stamp a
different **proven claim** off the same verified scope — the caller's own
`subjectId` (the conferring row's PK) or a scalar sub-key — use
[`q.stamp`](/define/schema#qstamp--proven-principal-identity-columns):

```typescript
authorId: q.stamp({ fromScope: 'event', claim: 'subjectId' }),
```

Every proven scope carries `id`, `subjectId`, and any projected sub-keys; a
`q.stamp` column reads one of those. Unlike `q.scope` it is **never** a firewall
arm (identity is not a tenant scope), and it force-stamps **only** for scope-only
principals — an org member/admin/owner writes it via the org path. A scope-only
caller missing the claim gets a fail-closed **403**, never a `NULL` row.

## Namespace scope-claim refresh

A scope is normally minted at `POST /scope/v1/enter`. When you also mount a
[namespace](/define/areas#namespace-mounts-hoisted-relationship-middleware)
that gates `/event/:eventId/*` on the **same** relationship, the namespace
resolver can refresh `ctx.scope.<kind>` in memory for the duration of that
request — so an attendee hitting `/event/evt_123/announce` gets
`ctx.scope.event = { id: 'evt_123', roles: ['attendee'] }` without a separate
`enter` round-trip. The refresh runs **after** the gate (the gate proves the
relationship; the refresh stamps the proven role from the path param).

This is a strictly-guarded convenience, and it stamps **per matched lane**. The
resolver records which `via:` lane opened the gate (first-match-wins) and stamps
**only that lane's** scope role — an attendee who matched `attendeeOf` gets
`roles: ['attendee']`; an organizer who matched `organizerOf` gets
`roles: ['organizer']`. A caller can never receive a role they didn't prove this
request.

- **Relationship lanes** each confer their own scope role, stamped only when that exact lane matched — so a **multi-lane `via:`** (e.g. `via: ['attendeeOf', 'organizerOf']`) refreshes the matched lane's role, not an ambiguous union.
- A **bulk-grant lane** (`{ roles: [...] }` or `{ team: true }`) confers **no** scope role — it opens the gate via the caller's org role *without* proving any relationship, so there is nothing to stamp (stamping one would forge it). The gate still passes; the caller is simply authorized by their org role.

The refresh described so far is in-memory and single-request. To also hand the
caller a **persisted** scope token, opt the namespace into `mintScope` — see
below.

### Gotcha: a namespace only emits its resolver when it claims a standalone action

This is the single most common surprise (it has tripped up a customer): **a
namespace emits its resolver — and therefore the scope refresh and the
[`mintScope`](#mintscope--mint-the-scope-token-from-the-namespace-resolver)
mint — only when at least one STANDALONE action's `path:` falls under the
namespace prefix.**

A standalone action declares its own `path:` (e.g.
`path: '/event/:eventId/home'`), so the compiler can match it against the
namespace prefix and hoist it into the synthetic namespace routes file.
**Record-based actions are different** — they mount under their resource's CRUD
routes (`/api/v1/guests/:id/checkin`), which live *outside* the
`/event/:eventId` prefix. They never match the prefix, so they never get
claimed.

A namespace that contains **only** record-based actions is therefore **inert**:
nothing matches the prefix, no synthetic routes file is emitted, and so there
is **no resolver, no scope refresh, and no mint** — even though the namespace
is declared and validates cleanly. The middleware you expected to gate
`/event/:eventId/*` simply isn't there.

The fix is to give the namespace at least one standalone action under the
prefix. In `q-events`, `eventHome` exists precisely for this — a standalone
`GET /event/:eventId/home` that pulls the resolver (and the §5.3 mint) into
existence:

{/* doc-compile: skip — project-config prerequisite: the `manager+` gate needs `manager` in `auth.roleHierarchy`, and the gate compiles every fence against one fixed harness config whose roles are member/admin/owner. The file is otherwise whole. */}

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

export default defineAction({
  description: "Return the event home payload for the caller's proven scope.",
  path: '/event/:eventId/home',   // ← standalone path UNDER the namespace prefix
  method: 'GET',
  input: z.object({}),
  access: { roles: ['scope:event:attendee', 'scope:event:organizer', 'manager+'] },
  async execute({ db, ctx, c }) { /* … */ },
});
```

With that action present, the `eventScope` resolver is emitted, runs the
`attendeeOf` probe once per `/event/:eventId/*` request, stamps
`ctx.scope.event`, and (because the namespace sets `mintScope: true`) mints the
scope token. Without a standalone action under the prefix, none of that fires.

## `mintScope` — mint the scope token from the namespace resolver

A scope is normally minted by an explicit `POST /scope/v1/enter` round-trip.
Setting **`mintScope: true`** on a namespace folds that mint into the
namespace resolver: on a successful relationship probe the resolver MINTS a
persisted scope JWT and returns it via the `set-auth-token` header — so a
native client hitting `/event/:eventId/*` gets a scope token **without ever
calling `/scope/v1/enter`**.

```typescript
// quickback.config.ts
authz: {
  relationships: {
    attendeeOf: {
      from: 'guests',
      subject: { column: 'linkedUserId', equals: 'ctx.userId' },
      resource: { column: 'eventId' },
      where: { status: 'confirmed' },
      loads: 'guests',
      exposeAs: 'guest',
    },
  },
  namespaces: {
    eventScope: {
      prefix: '/event/:eventId',
      via: ['attendeeOf'],   // one or more lanes — mints the matched lane's role
      mintScope: true,       // ← fold the scope-token mint into the resolver
    },
  },
}
```

A confirmed attendee then hits the standalone action under the prefix:

```http
GET /api/v1/event/evt_123/home
Authorization: Bearer <existing-jwt>

200 OK
set-auth-token: <new-scoped-jwt>     ← carries scope.event.roles = ["attendee"]
```

The client stores the `set-auth-token` value and sends it as the bearer on
every subsequent `/event/:eventId/*` request. The JWT carries
`scope.event = { id: 'evt_123', roles: ['attendee'] }`, so the scoped-role
access checks (`scope:event:attendee`) pass with no DB touch and no second
`enter` call.

`mintScope` is **opt-in per namespace** and defaults to `false`. With it off,
the resolver does the in-memory `ctx.scope.<kind>` refresh only (the section
above); subsequent requests must still call `/scope/v1/enter` for a persisted
token.

### Per-matched-lane minting (forging safety)

The mint stamps the scope role of **whichever lane matched**, gated on the
first-match-wins probe — the *same* per-lane discipline as the in-memory stamp,
applied here with higher stakes: a minted token **persists for its whole TTL**,
so it can only ever carry the role the caller actually proved this request.

- A **multi-lane `via:`** (e.g. `via: ['attendeeOf', 'organizerOf']`) mints the matched lane's role — `attendeeOf` → `scope.event.roles = ['attendee']`, `organizerOf` → `['organizer']`. The resolver records which lane opened the gate, so there is no ambiguity.
- A **bulk-grant lane** (`{ roles: [...] }` or `{ team: true }`) mints **no** scope role — it opens the gate via the caller's org role without proving a relationship. The gate passes; the caller carries their org role, not a minted scope role.

> The realtime [ws-ticket](/platform/realtime/durable-objects#namespace-backed-tickets-per-matched-lane) can authorize through this same namespace `via:`, issuing a per-matched-lane WebSocket ticket — so the socket gate and the REST gate stay identical.

In every suppressed case the gate still authorizes the request; only the mint
(and the in-memory refresh) are skipped, and those callers obtain a scope
token through `/scope/v1/enter` as usual. Flipping `mintScope: true` can never
forge a scope role — the guard, not the flag, is the gate.

### TTL and best-effort semantics

- **TTL is the scope-token TTL** — `auth.jwt.expiresIn` (default 180s), **clamped to a hard ≤180s ceiling** at every signing site that emits a scope claim: both proven mints (`/scope/v1/enter`, the namespace resolver) *and* the middleware's rolling refresh. A longer-lived scope token would widen the post-revocation replay window past the spec bound, so the clamp is non-negotiable — an `auth.jwt.expiresIn` above the ceiling applies to plain tokens but never survives into a scope-bearing one.
- **The mint is gated by the same proven-relationship guard as the in-memory stamp.** It runs *after* the gate has granted access, reading the instance id from the proven path param (`:eventId`) — never from request input — so the minted `scope:<kind>:<role>` is backed by exactly the proof `/scope/v1/enter` requires. The token is **byte-identical** to one `/scope/v1/enter` would produce: same shared mint helper, same carry-forward of any *other* verified scope kinds already on the incoming token (the freshly-proven kind takes precedence; siblings survive the re-mint).
- **The mint is best-effort.** A mint failure (e.g. a missing signing secret) **never fails the request** — the in-memory stamp already authorized it; the caller simply doesn't receive a refreshed token on that response and can fall back to `/scope/v1/enter`.

## `acceptScope` — accept an incoming scope claim as proof of entry

`mintScope` is the resolver *minting* a scope claim after a `ctx.userId` lane
matches. **`acceptScope: '<kind>'` is the inverse: it lets the same resolver
*accept* an already-minted `ctx.scope.<kind>` claim as its own proof** — so one
`/event/:eventId/*` handler serves **both** the Better-Auth user (userId
relationship probe) **and** the sessionless scope principal (verified scope
JWT, no account). It's the exact fast-path the realtime
[ws-ticket](/platform/realtime/durable-objects) already has, ported to REST.

Without it, every scope-principal-facing namespaced action needs a **parallel
PUBLIC twin** (a separate handler with a hand-rolled `requireScope` check and
raw DB writes that bypass scoped-db) — double the surface, and "someone forgot
the twin" becomes a standing bug class. `acceptScope` retires the twin.

```typescript
// quickback.config.ts
authz: {
  relationships: {
    inspectorOf: {
      from: 'site_inspectors',
      subject: { column: 'inspectorUserId', equals: 'ctx.userId' },
      resource: { column: 'locationId' },
      where: { status: 'active' },
      loads: 'locations',
      exposeAs: 'location',
    },
  },
  scopes: {
    location: {
      requestField: 'locationId',
      roles: {
        // supplied-reachable: a signed-in user AND a sessionless principal
        // converge on ONE scope:location:inspector.
        inspector: { via: 'inspectorOf', subKeys: ['inspectorId'], subject: 'both' },
      },
    },
  },
  namespaces: {
    locationScope: {
      prefix: '/location/:locationId',
      via: ['inspectorOf'],
      acceptScope: 'location',   // ← honor an incoming ctx.scope.location claim
      // mintScope: true,        // ← compose freely: a userId caller also re-mints
    },
  },
}
```

A sessionless principal that already minted a token (via
[`ctx.mintScope`](#sessionless-scope-mint--ctxmintscope) or `/scope/v1/enter`)
then hits the namespaced action directly, no second round-trip:

```http
GET /api/v1/location/loc_42/summary
Authorization: Bearer <scope-only-jwt carrying scope.location = { id: 'loc_42', roles: ['inspector'] }>

200 OK
```

The resolver reads `ctx.scope.location`, confirms its `id` equals the `:locationId`
param, sees the `inspector` role a supplied-reachable lane confers, and admits
the request — no `ctx.userId`, no DB relationship probe. `ctx.location` is
hydrated through its own firewall exactly as it is for the userId caller.

### Two locks (both required, fail-closed)

`acceptScope` is **named-string only** — always the explicit kind, never a bare
`true` (there's no inference from the `:param`). Default absent → the resolver
**never** admits a scope principal (unchanged behavior). Beyond naming the kind,
the compiler enforces a second lock:

1. **`acceptScope: '<kind>'`** opts *this namespace's* REST surface in, **and**
2. at least one relationship lane of the namespace confers a `<kind>` role
   declared **`subject: 'supplied' | 'both'`** (supplied-reachable).

A `subject: 'user'` role can be *minted* or *refreshed* by the namespace but can
**never be accepted** from a token — a user-only role must be proven by a live
`ctx.userId` probe. If `acceptScope` names a kind whose every conferring lane is
`subject: 'user'`, the **compiler errors** rather than emit a dead fast-path.
The compiler also checks the kind is declared and its `requestField` equals the
prefix's first `:param`.

### Forging safety

A scope principal can't use `acceptScope` to reach anything its token doesn't
already authorize:

- **The claim is verified, not asserted** — `ctx.scope` is populated only from a
  JWT the auth middleware verified; the action can't write it.
- **Named kind, not any-kind** — the fast-path reads `ctx.scope['<kind>']` for
  the declared kind (stricter than the ws-ticket's any-key loop); a colliding
  foreign-kind id can't open the gate.
- **Instance pinned twice** — `entry.id === :param` in the gate, *and*
  `eq(loads.pk, :param)` in hydration. The principal enters exactly the instance
  its claim proved.
- **Role → lane is exact and supplied-restricted** — each accept arm is one
  `(role, lane)` pair with `subject !== 'user'`; an `inspector` claim matches
  `inspectorOf`, never a sibling lane.
- **No new enforcement surface** — firewall, masking, `access` arms, and
  scoped-db all read the same `ctx.scope` / `ctx.activeOrgId` they read for a
  userId caller. New proof source, zero new enforcement surface.

The tenant (`ctx.activeOrgId`) is sourced from the pinned-loaded resource
(`ctx.<exposeAs>.organizationId`) — parity with the userId caller, not a new
grant. Fine-grained "own rows only" stays the resource author's job via a
firewall arm on `ctx.scope.<kind>.subjectId`, the **same** arm that already
serves the userId principal.

### Writes: audit fields for a userless principal

A scope principal has no `ctx.userId`, so the scoped-db **audit wrapper** — which
stamps `created_by` / `modified_by` — can't record a user. Rather than force a
parallel raw-`db` write path (which would re-scatter the exact duplication
`acceptScope` removes), the wrapper stamps the principal's synthetic, audit-only
id — the token's `sub`, `scope:<kind>:<subjectId>` — into those columns:

```ts
// A verified scope principal's write, through the normal scoped db:
await db.insert(vendorCheckins).values({ eventId, note });
// → created_by = modified_by = "scope:event:guest_abc123"
```

The `scope:` prefix self-describes the actor (which kind, which subject), so no
separate "actor type" column is needed. This is the write-side companion to the
read fast-path: **one handler body serves both a Better-Auth user and the
sessionless principal, on reads and writes alike.**

It stays **fail-closed**: the relaxation fires *only* for `ctx.scopePrincipal
=== true`. A write that reaches the wrapper with no `userId` and no scope
principal (genuine auth-context loss) still throws — the audit layer never
silently logs an unauthenticated write. For a real system-side write outside any
request, use the unwrapped `db` (`createDb`) directly, as before.

## Sessionless scope mint — `ctx.mintScope`

`POST /scope/v1/enter` and the namespace resolver both prove the relationship by
**`ctx.userId`** — so they require a Better Auth account. But a legitimate
principal often has none: an **event attendee who arrives by email + invite
code**, holding an opaque session token you validate yourself, who must never
get a global account. The shape is general — magic links, kiosk/handout codes,
partner-issued API keys, expiring share links that need row-level scope.

For these, prove the relationship **your own way** inside an action, then ask
Quickback to mint a disciplined scope token from the row you proved.

### 1. Opt the role into supplied-subject proof

Add `subject` to the scope role (default `'user'`):

```typescript
authz: {
  scopes: {
    event: {
      requestField: 'eventId',
      roles: {
        // 'both' → the SAME scope:event:attendee for a logged-in user
        // (via /enter) AND a sessionless invite-holder (via ctx.mintScope).
        attendee: { via: 'attendeeOf', subKeys: ['guestId'], subject: 'both' },
      },
    },
  },
}
```

- **`'user'`** (default) — `/scope/v1/enter` only, proven by `subject.column = ctx.userId`. Unchanged.
- **`'supplied'`** — sessionless only. `ctx.mintScope` mints it; `/enter` won't (there's no user to probe).
- **`'both'`** — either path. A user-attendee and a sessionless invite-attendee converge on **one** `scope:event:attendee` — one firewall arm, one masking rule, both principals.

This opt-in is the single fail-closed switch: without `'supplied'` or `'both'`, no relationship is mintable by a supplied id.

### 2. Mint from the proven row

`ctx.mintScope` is hydrated on `ctx` in **any action** when a `subject:
'supplied' | 'both'` role is declared (including **PUBLIC** actions — the
canonical caller has no token yet). The action proves the principal however it
likes, then hands Quickback **only the conferring row's primary key**:

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

export default defineAction({
  description: "Redeem an invite code and mint the caller's event scope token.",
  path: '/enter-event',
  method: 'POST',
  input: z.object({ code: z.string() }),
  access: { roles: ['PUBLIC'] },                 // unauthenticated entry
  async execute({ ctx, input }) {
    const guestRowId = await validateInviteCode(ctx, input.code);  // YOUR proof, YOUR table
    const { token } = await ctx.mintScope({ via: 'attendeeOf', subject: guestRowId });
    return { token };  // client sends it as `Authorization: Bearer …` thereafter
  },
});
```

You assert **nothing** about the scope. Quickback re-reads the `attendeeOf` row
by primary key and takes the role, the resource id (`eventId`), the sub-keys,
and the subject id **off that row** — so a buggy or hostile action can't forge
`organizer`, can't pick a different event, and can't widen the slice. The only
thing delegated to you is *which row* — exactly the opaque-credential check that
has to be custom code.

| What you supply | What Quickback reads off the row |
|---|---|
| `via` (a declared `supplied`/`both` relationship) + `subject` (the `from`-row PK you proved) | the role, `id` (resource), sub-keys, and `subjectId` — none caller-asserted |

The `where:` filter runs on the mint probe exactly as at `/enter` — a cancelled
or pending row confers nothing, so flipping a guest's `status` revokes them
within one mint cycle (**≤180s**). Re-prove by calling `ctx.mintScope` again
(your own refresh endpoint does exactly that). The by-PK probe does **not** apply
the relationship's tenant firewall (a sessionless principal has no
`activeOrgId` yet — the proven row *is* what establishes scope), but matching by
primary key + the `where:` filter keeps it bounded and fail-closed.

### 3. The scope-only token

`ctx.mintScope` signs a **scope-only** JWT — it carries the `scope` claim and
nothing else. There is **no user identity**: the auth middleware builds a context
with **`ctx.userId` undefined**, so every user-, org-, and owner-gated path
**fails closed automatically** (an owner firewall compares against `undefined`
→ matches nothing; org-role gates see empty `ctx.roles`). `AUTHENTICATED`
**admits** a scope principal past the auth gate — it's the wrapper over session
*and* scope callers (see [Access → Pseudo-roles](/define/access),
where `SESSION` and `SCOPED` distinguish the two) — but a user/org/owner
**firewall** then filters it to **zero rows** (no `ctx.userId`, no `ctx.activeOrgId`),
so it reaches nothing it shouldn't. `USER` is likewise gate-permissive but
firewall-excluded: it mandates a `userId` scope the principal can't satisfy. To
serve a scope principal actual rows, use `scope:<kind>:<role>` gates +
`ctx.scope.<kind>` firewall arms (or `SCOPED` to admit any scope-token caller as
a tier).

Everything downstream — REST firewall, masking, scoped-role access, the realtime
ws-ticket — then treats the sessionless attendee **identically** to a
user-proven one. There is no new enforcement surface; the principal is just
`scope:event:attendee`.

### `subjectId` — scoping to the person, not just the instance

A user-proven scope scopes self-data (an attendee's travel, dietary, todos) by
`ctx.userId`. A sessionless attendee has no `ctx.userId`, so the **proven
subject's identity rides in the claim** as a reserved **`subjectId`** — the
conferring row's primary key:

```jsonc
"scope": { "event": { "id": "evt_123", "roles": ["attendee"], "subjectId": "gst_42" } }
```

Scope self-data on it with one firewall arm:

```typescript
{ field: 'guestId', equals: 'ctx.scope.event.subjectId' }
```

`subjectId` is populated on **both** proof paths — `/scope/v1/enter` projects
the matched row's PK too — so the arm above is **one arm that serves both** a
logged-in attendee and a sessionless one. (It is distinct from
`ctx.scope.event.id`, which is the *instance* — the event.)

### Realtime

A scope-only token authorizes the realtime
[ws-ticket](/platform/realtime/durable-objects) directly off its proven
`ctx.scope` claim — no `ctx.userId` re-probe. The sessionless attendee joins the
same per-event rooms (and per-attendee rooms keyed on `subjectId`) as a
user-attendee, carrying the same scope role on the ticket. So the REST gate and
the socket gate stay identical for accountless principals too.

## Revocation & TTL

The scope claim is trusted between proofs — it is never re-checked against the database while it rides. Two bounds keep that window finite:

- **The TTL** (default 180s, `auth.jwt.expiresIn`, clamped ≤180s on scope-bearing tokens) bounds an *idle* client: a token that stops being used dies within one TTL.
- **The carry-forward cap** (15 minutes) bounds an *active* client: the rolling refresh re-signs the claim on every authed call, but only while its proof timestamp (stamped exclusively by the proven mint sites) is younger than the cap. The cap is applied **per scope kind** — a kind whose own proof is past it is dropped from the re-signed claim, so re-proving one relationship cannot roll another forward. Past the cap the refresh refuses, the token expires, and the next `enter` (or namespace-route mint) re-probes the relationship — which won't re-mint a revoked role.

So removing a guest/staff row takes effect within **cap + one TTL** for a client that keeps polling, and within one TTL once it goes idle. Two ways to tighten that:

- **`auth.jwt.revocationCheck: 'kv'`** (v0.45+, Cloudflare runtime) — sub-TTL revocation: updating or deleting a conferring relationship row stamps a revocation timestamp in KV, and the bearer fast-path rejects a carried scope claim whose proof is at or before it — no same-second gap. Each kind is compared against **its own** proof time (`sctMs` on the entry; older tokens without one fall back to the token-level `sct`/`sctMs`), so a mint of one kind can't carry a revoked kind past its marker. Neither the refresh nor a re-mint ever substitutes its own clock for a proof it didn't take. The check fails closed — a scope claim whose revocation state can't be read is not trusted either. See [auth config](/configure/auth).
- **`via` arms** — for tables where any caching is too wide, gate with a `via` arm instead of a bare `ctx.scope.<kind>` arm: `via` emits a live subquery against the relationship table on every request, immune to a stale claim entirely.

## Reference

```typescript
// AppContext (generated lib/types.ts)
ctx.scope?: Record<string, { id: string; roles?: string[]; subjectId?: string; [subKey: string]: string | string[] | undefined }>;

// authz.scopes.<kind>
{
  requestField: string;                                  // body field carrying the instance id
  roles: Record<string, {
    via: string;
    subKeys?: string[];                                  // 'col' → eq, 'col[]' → inArray
    subject?: 'user' | 'supplied' | 'both';              // who selects the conferring row (default 'user')
  }>;
}

// ctx.mintScope — present in actions when a `subject: 'supplied' | 'both'` role is declared
ctx.mintScope(opts: { via: string; subject: string; setAuthToken?: boolean }):
  Promise<{ token: string; scope: { id: string; roles: string[]; subjectId?: string } }>;
```

- `requestField` must equal every role-relationship's `resource.column`.
- Each relationship's `from` table must exist and ship a generated firewall (it can't be `exception`).
- A `subKeys` entry ending in `[]` is **set-valued** (lowers to `inArray`, minted as `string[]`); without the marker it's scalar (`eq`, minted as `string`).
- `id`, `roles`, `subjectId`, and `sctMs` are **reserved** on the claim entry — the mint owns them, so a `subKeys` entry of that name is a compile error rather than a row value overwriting proven state.
- `subject` defaults to `'user'`. `'supplied'`/`'both'` enable [`ctx.mintScope`](#sessionless-scope-mint--ctxmintscope) (sessionless mint); `'supplied'` is **not** mintable via `/scope/v1/enter`.
- `ctx.mintScope({ via, subject })` mints a **scope-only** token (no user identity, `ctx.userId` undefined) from the `from`-row whose PK is `subject` — reading the role, resource id, sub-keys, and `subjectId` off that row.
- `subjectId` is the conferring row's primary key, populated on **both** proof paths — scope self-data on `ctx.scope.<kind>.subjectId`.
- `q.scope('<kind>')` auto-stamps the column from `ctx.scope.<kind>.id` on insert.
- Generated route: `POST /scope/v1/enter` (mounted only when `authz.scopes` is declared).
