# Firewall - Data Isolation

The firewall generates WHERE clauses automatically to isolate data by user, organization, or team.

## Basic Usage

The cleanest pattern is `q.scope()` — declare the tenant column once and the firewall is auto-derived:

```typescript
// features/candidates/candidates.ts
import { feature, q } from '@quickback/compiler';

export default feature('candidates', {
  columns: {
    id:             q.id(),
    name:           q.text().required(),
    email:          q.text().required(),
    organizationId: q.scope('organization'),  // Auto-derives the firewall
    ...q.audit(),
    ...q.softDelete(),
  },
  // `email` is detected as sensitive and every detected column needs an
  // explicit decision — `false` records "reviewed, not sensitive here".
  masking: { email: false },
  // No firewall: block needed — auto-derived as
  //   [{ field: 'organizationId', equals: 'ctx.activeOrgId' },
  //    { field: 'deletedAt', isNull: true }]
  // ... guards, crud, read
});
```

If you need to declare the firewall explicitly, two forms are accepted — pick whichever reads better:

**Named-scope** (compact, default for simple overrides):

```typescript
firewall: { organization: { column: 'tenant_id' } }
firewall: { owner: { column: 'account_user_id' } }
firewall: { exception: true }   // opt out of tenant scoping entirely
```

**Predicate array** (explicit, required for `in`-lists or custom non-scope predicates):

```typescript
firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
  { field: 'deletedAt', isNull: true },
]
```

Both shapes compile to the same canonical predicate array internally — the named-scope object is coerced at parse time. Pick by readability.

## Auto-detected column synonyms

When you omit the `firewall:` block entirely, the compiler scans the schema for one of these isolation columns and auto-derives the predicates:

| Scope | Accepted column names | Bound to |
|---|---|---|
| Organization | `organizationId`, `organisationId`, `orgId`, `organization`, `organisation`, `org` | `ctx.activeOrgId` |
| Owner | `ownerId` | `ctx.userId` |
| Team | `teamId` | `ctx.activeTeamId` |

Organization detection accepts **six spellings** — American (`organizationId` / `orgId` / `organization` / `org`) and British (`organisationId` / `organisation`). The first one found in the schema source is used as the predicate field. Detection matches either the JS key (`organizationId`) or the SQL name (`organization_id`).

### `ownerId` vs `userId`

`ownerId` is auto-detected as the owner column: the compiler firewalls reads with `WHERE ownerId = ctx.userId` and auto-stamps the column on insert.

`userId` is **not** auto-detected on feature tables. It is a Better Auth convention on Better Auth's own tables (`session`, `account`, `member`, `passkey`), and on a feature table it usually means *a user this row references* (member, contact, invitee) rather than *the row's owner*.

What the compiler does when it sees `userId` on a feature table:

| Situation | Result |
|---|---|
| `userId` is the only candidate column (no `organizationId` / `ownerId` / `teamId`) | **Compile error** — the table has no isolation column |
| `userId` alongside a detected isolation column | **Warning** — `userId` is neither auto-stamped on insert nor auto-scoped on read |

Two escapes, both explicit:

```typescript
// 1. Rename the column to ownerId — it becomes the auto-detected owner scope.

// 2. Or declare it, and keep the name:
firewall: { owner: { column: 'userId' } }
// equivalently, in predicate-array form:
firewall: [{ field: 'userId', equals: 'ctx.userId' }]
```

An explicit predicate on a custom-named column is honoured **on that table** via its own generated `buildFirewallConditions` helper; it does not enter the project-global owner scope rules, so other tables that happen to share the column name are unaffected.

If `userId` is genuinely informational, rename it to something that says so (`linkedUserId`, `assigneeId`, `inviteeId`) and the warning goes away.

Do **not** turn that column into a SQL FK to Better Auth. `.references(() => users.id)` (or `user`) is illegal across split D1s (`AUTH_DB` vs `DB`) and deploy fails with `Generate features database migrations`. Store it as `q.text().required()`. See [Schema → References](/define/schema).

## Predicate Shape

```typescript
type FirewallPredicate =
  | { field: string; equals: string; optional?: boolean; nullable?: true } // column = ctx.* (or literal)
  | { field: string; isNull: true }                       // column IS NULL
  | { field: string; in: string[] | string; optional?: boolean } // column IN (...)
  | { field: string; via: string }                        // column IN (relationship subquery)
  | { field?: string; permission: string }                // named authz.permissions reference (v0.35+)
  | { all: FirewallPredicate[] }                          // AND group (v0.35+)
  | { any: FirewallPredicate[] }                           // OR group  (v0.35+)
  | { exception: true };                                  // opt out of tenant scoping

type FirewallConfig = ReadonlyArray<FirewallPredicate>;

// Resource top-level (sibling to `firewall:`)
firewallErrorMode?: 'reveal' | 'hide';
```

The top-level array is AND'd together. `firewallErrorMode` lives at the resource top level — it's a presentation-layer choice (403 vs 404), not a query-shape one.

## Boolean groups: `all` / `any` (v0.35+)

A flat array can only express AND. To grant access through **either** of two independent paths — "an org member **OR** an event attendee" — nest an `any` group. The top-level array stays sugar for `all`, so every existing firewall is byte-identical.

```typescript
firewall: {
  any: [
    { field: 'organizationId', equals: 'ctx.activeOrgId' }, // org-member grant
    { field: 'eventId',        equals: 'ctx.scope.event'  }, // attendee grant (token-verified)
  ],
}
```

Null-safe scope semantics make this safe (these apply **inside `all`/`any` groups**, opt-in via the new syntax):

- A **scope** arm (`ctx.activeOrgId`, `ctx.activeTeamId`, `ctx.scope.*`) whose claim is **absent never binds `undefined`** (the classic 500 / "match every row" trap). In an `any` group an absent arm **drops**; if **every** arm drops, the group denies with `sql\`1 = 0\`` (the empty-guard) — never a full-table read. In an `all` group an absent required scope **denies** the group.
- Mark an arm `{ optional: true }` to **drop-instead-of-deny** when absent inside an `all` group (the teamless rest-of-org slice — `ctx.activeTeamId` is implicitly optional).
- **Owner** (`ctx.userId`) and **soft-delete** (`isNull deletedAt`) are always present / always AND'd — never relaxed.

> **`ctx.activeTeamId` is implicitly optional only while another tenant arm
> bounds the query.** Dropping the team filter is safe when an org (or owner,
> `via:`, `permission`, or a binding group) arm still constrains the rows —
> that is what makes it "the teamless rest-of-org slice".
>
> When **team is the only tenant arm**, there is no rest-of-org slice to fall
> back to, so an absent claim denies with ``sql`1 = 0` `` instead of dropping.
> Otherwise every predicate would evaluate to `undefined`, the firewall would
> return no `WHERE` at all, and any authenticated caller would read every
> tenant's rows.
>
> This matters because `ctx.activeTeamId` is absent in ordinary
> configurations: the Better Auth teams plugin is opt-in, and the external auth
> provider does not supply the claim at all. Note that soft-delete and literal
> predicates (`kind = 'public'`) are **row filters, not tenant boundaries** —
> they do not count as a co-existing arm.
>
> A table with a `teamId` column and no `organizationId` gets team-only
> isolation from auto-detection, without any explicit `firewall` block.
>
> **You will usually see this as a compile error rather than a silent deny.**
> When the teams sub-plugin is off, `ctx.activeTeamId` can never be populated,
> so a team-only firewall would deny *every* request — the compiler rejects
> that configuration up front and tells you to either enable teams
> (`auth: { plugins: { organization: { teams: { enabled: true } } } }`, which
> requires object-form plugin config) or give the resource a bindable tenant
> scope. The runtime deny remains the backstop for the case the compiler can't
> see, such as teams being enabled while a particular caller has not selected
> a team.


`nullable: true` is a separate database-schema opt-in, supported only on an
owner predicate whose source is exactly `ctx.userId`. It allows the persisted
owner column to be NULL; NULL rows still do not match the owner arm because SQL
equality never matches NULL. It does not make the request claim optional.

```typescript
// (org OR event) AND owner AND not-deleted
firewall: {
  all: [
    { any: [
      { field: 'organizationId', equals: 'ctx.activeOrgId' },
      { field: 'eventId',        equals: 'ctx.scope.event'  },
    ] },
    { field: 'ownerId', equals: 'ctx.userId' },
  ],
}
```

The `ctx.scope.*` arms are how **relationship-derived scopes** (attendees, shuttle drivers, vendors) reach event-scoped rows without org membership — see [Scopes & capability grants](/define/scopes).

## Relationship-based scoping (`via:`)

For relationship authorization — "this user can see rows X if they have a confirmed `event_guests` row pointing at X" — declare the relationship under `authz.relationships` in `quickback.config.ts`, then reference it from `firewall:` with a `via:` predicate. These relationships (and the named roles/permissions built on them) are defined once, centrally — see [Centrally defined access rules](/define/authz-roles#centrally-defined-access-rules).

```typescript
// quickback.config.ts
authz: {
  relationships: {
    guestOf: {
      from: 'event_guests',                                 // table name
      subject: { column: 'userId', equals: 'ctx.userId' },  // caller side
      resource: { column: 'eventId' },                      // resource side
      where: { status: 'confirmed' },                       // optional row filter
    },
  },
}

// features/sessions/sessions.ts
firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
  { field: 'eventId', via: 'guestOf' },
]
```

Compiles to:

```sql
WHERE sessions.organizationId = ?
  AND sessions.eventId IN (
        SELECT eventId FROM event_guests
        WHERE userId = ?
          AND status = 'confirmed'
          AND <event_guests' own firewall — tenant scope + soft-delete>
      )
```

Key properties:

- **Firewall stays the outermost AND.** A guest of an event in another tenant is still blocked by your tenant predicate. Relationship-based scoping never bypasses tenant isolation.
- **The relationship table's own firewall is AND'd into the subquery.** Soft-deleting a `event_guests` row revokes access immediately; cross-tenant relationship rows can't grant access.
- **Anonymous callers fail closed.** `ctx.userId` is null-guarded; the subquery short-circuits to `WHERE 1 = 0` for unauthenticated requests.
- **The relationship table must be tenant-scoped** (`firewall: { exception: true }` is rejected at compile time on relationship tables).
- **The relationship table needs its own `defineTable`.** That `<event_guests' own firewall>` line in the SQL above is the table's generated firewall helper — a bare Drizzle export has none, so a `from:` table with no `export default defineTable(...)` is a compile error naming the table.

See [Access — relationships](/define/relationships#authzrelationships) for the full `authz.relationships` shape and role-binding patterns.

## Named permissions (`{ permission }`)

A `via:` arm references **one** relationship. When the same grant rule —
"organizer **or** confirmed attendee" — recurs across several resources,
name it **once** under `authz.permissions` and reference it from any
firewall with a `{ permission }` arm. See [Permissions](/define/permissions)
for the full DSL.

```typescript
// quickback.config.ts
authz: {
  relationships: { organizerOf: { /* … */ }, attendeeOf: { /* … */ } },
  permissions: {
    'event:view': { anyOf: ['organizerOf', 'attendeeOf'] },
  },
}

// features/sessions/sessions.ts
firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
  { field: 'eventId', permission: 'event:view' },
]
```

The compiler lowers `{ permission }` to the same `all` / `any` relationship
subqueries the expression names — `anyOf` becomes an OR of subqueries,
`allOf` an AND. `field` is the resource-side column those subqueries
constrain (the same column you'd put on a `via:` arm).

A permission referenced from a firewall can also resolve an
[**arrow**](/define/arrows) — an `{ arrowRef, permission }`
leaf that traverses a foreign key to inherit authority from a parent object
("org admins of the event's org can see its announcements", "anyone who
administers an ancestor folder can read this one"). An arrow leaf lowers to
an FK-hop subquery (non-recursive) or a bounded `WITH RECURSIVE` CTE
(self-FK hierarchy), tenant-constrained at every row.

**M1 limit — firewall arms must be SQL-pushable.** A firewall runs as a
WHERE clause, so a permission referenced from one may only resolve to
relationship subqueries. A permission whose expression contains a
**claim-site leaf** (`{ role }`, `{ scopeRole }`, `{ pseudoRole }`) or a
`not` is rejected at compile time when referenced from a firewall — those
shapes are valid in the DSL but reserved for future claim/runtime
enforcement sites. Use a role gate on `read.access` / `crud.*.access` for
membership checks.

The compile-time analyzer additionally flags a referenced permission that can
never be satisfied, an arm shadowed by a broader one, and the SQL / CTE cost
of each permission — see [Diagnostics](/define/diagnostics).

## Named tenant anchors (`{ tenant }`)

The tenant predicate `{ field: 'organizationId', equals: 'ctx.activeOrgId' }` tends to be repeated on every table. Declare it **once** as a named anchor and reference it by name:

```typescript
// quickback.config.ts
authz: {
  tenants: {
    org: { column: 'organizationId', equals: 'ctx.activeOrgId' },
  },
}

// any firewall — top level or inside all/any groups:
firewall: [
  { tenant: 'org' },                       // → { field: 'organizationId', equals: 'ctx.activeOrgId' }
  { field: 'deletedAt', isNull: true },
]

// per-site COLUMN override (the claim side is never overridable):
firewall: [{ field: 'orgId', tenant: 'org' }]
```

The anchor's `equals` must be a **verified ctx claim**: `ctx.activeOrgId` | `ctx.userId` | `ctx.scope.<kind>` | `ctx.scope.<kind>.<subKey>`. `ctx.activeTeamId` (drop-when-absent semantics stay a per-resource decision) and legacy aliases are rejected at declaration time — an anchor can never bind a request-derived value. A `{ tenant }` reference carrying its own `equals` is a compile error (one source of truth); an unknown anchor name is a compile error listing the declared anchors.

Expansion happens at compile time, before rendering — the emitted SQL is byte-identical to writing the predicate inline. Auth-DB read views accept the same anchors as a string: [`tenant: 'org'`](/platform/realtime/live-views#auth-db-includes-source-auth) — with one extra constraint: an anchor referenced from an auth-view include must bind a single-segment session claim (`ctx.<claim>`); scope-principal claims (`ctx.scope.*`) are firewall-lane bindings and are rejected there.

## Named gate references (`{ rule }`)

A [gate](/define/authz-roles#named-gates-authzrules) that declares `firewall` lanes can be stamped onto a resource by name:

```typescript
// quickback.config.ts
authz: {
  rules: {
    eventRow: {
      who: { roles: ['owner', 'admin', 'member'] },
      firewall: [{ any: [
        { tenant: 'org' },                                // org staff
        { field: 'eventId', equals: 'ctx.scope.event' },  // signed scope claim
      ] }],
    },
  },
}

// the resource:
firewall: [{ rule: 'eventRow' }]
// composes with the resource's own lanes (top-level AND):
firewall: [{ rule: 'eventRow' }, { field: 'kind', equals: 'ctx.scope.event.kind' }]
```

Semantics — **AND-compose, top-level only, a reference can only narrow**:

- The gate's lanes splice into the resource's **top-level AND array** in place. They never replace the resource's own lanes, and composition can only *narrow* the row set.
- `{ rule }` is **not** valid inside an `{ all }` / `{ any }` group — a gate's firewall is one-or-more AND lanes, and splicing a multi-lane gate into an `any` group would OR-flatten it, admitting rows in **other tenants** that merely match one lane. This is a type error at authoring time and a compile error at coerce time.
- A gate's firewall may not contain `{ exception: true }` (a named bundle can never smuggle a firewall opt-out) or another `{ rule }` (gates don't nest — expansion is single-step, so cycles are impossible by construction).
- A gate with **no** `firewall` arm referenced from `firewall:` is a compile error — a who/record-only gate would silently contribute zero row lanes while the `record:` filter only ever applies at access sites.

The splice expands before validation and rendering, so the emitted `buildFirewallConditions` helper is byte-identical to writing the lanes inline — same groups, same absent-claim `1 = 0` guards, same `via:` subqueries.

## Context Sources

When `equals` references the request context, use these standard sources:

| Source | Description |
|--------|-------------|
| `ctx.userId` | Current authenticated user's ID |
| `ctx.activeOrgId` | User's active organization ID |
| `ctx.activeTeamId` | User's active team ID |
| `ctx.scope.<kind>` / `ctx.scope.<kind>.<subKey>` | A verified scope-principal claim (see [Scopes](/define/scopes)) |
| `ctx.<claim>.<subKey>` | A [feature-area](/define/areas) context anchor — a row the area/relationship middleware loaded from a proven route param and exposed on `ctx` (e.g. `ctx.event.id`, `ctx.event.organizationId`). Present only on the area-claimed route; renders null-safe (deny-when-absent) inside `all`/`any` groups |

These values are populated by the auth middleware (or, for scope/area anchors, the area middleware) from the request. Any predicate where `equals` starts with `ctx.` is treated as server-derived: the column lands in `GUARDS_CONFIG.systemManaged` (clients cannot submit it) and is **auto-populated on create / changeset-root insert / upsert** from that context source — a raw-Drizzle selected-event fence like `{ field: 'eventId', equals: 'ctx.event.id' }` stamps `eventId` from `ctx.event.id` the same way `q.scope('organization')` stamps `organizationId`.

**Only conjunctive arms auto-populate.** A `ctx.*`-bound column is auto-populated only when its arm is **conjunctive** — reachable through the top-level array or a nested `all:` group, so it holds for every admitted row. A column bound *only* through an `any:` branch is disjunctive and lane-dependent (e.g. an attendee's own `eventPersonId`, which must come from a **proven** scope claim, not the org lane), so the compiler does **not** guess a create value for it. Give such a column its own population source — declare it with [`q.scope()` / `q.stamp()`](/define/schema#qstamp--proven-principal-identity-columns) (a proven-identity column force-stamps from the scope claim), let it inherit through a changeset `inherit`/`fk`, or write it in an authored action. A `NOT NULL` disjunctive-only column with none of these has no create path and will hit the database `NOT NULL` constraint on insert.

**The named tenant columns are the exception.** `organizationId`, `ownerId` and `teamId` still auto-populate from inside an `any:` group — the org lane is what makes a disjunctive firewall usable for ordinary staff writes, and removing that would break every project that composes an org arm with a scope arm. When more than one arm binds the *same* tenant column, the **canonical claim wins**: an arm bound to `ctx.activeOrgId` supplies the create value in preference to one bound to `ctx.scope.<kind>.<subKey>`, because a scope claim is present only for the scope lane. Arm order does not matter — `OR` is commutative, and the value written must not depend on the order you happened to declare them in.

When the *only* arm binding a tenant column is scope-bound, that claim is the stamp source, read null-safe; a caller without the claim gets the existing `403 ORG_REQUIRED` rather than a write with an undefined tenant.

## Auto-Derivation

When the `firewall:` block is omitted, the compiler scans the schema for known scope columns and synthesises the predicate array:

| Column found | Predicate emitted |
|--------------|-------------------|
| `organizationId` / `organisationId` / `orgId` / `organization` / `organisation` / `org` | `{ field: '...', equals: 'ctx.activeOrgId' }` |
| `ownerId` | `{ field: 'ownerId', equals: 'ctx.userId' }` |
| `teamId` | `{ field: 'teamId', equals: 'ctx.activeTeamId' }` |
| `deletedAt` (present whenever the resource soft-deletes) | `{ field: 'deletedAt', isNull: true }` — appended automatically whenever the table carries the column, regardless of which firewall shape you wrote |
| `userId` | **Nothing.** Not an auto-detected owner column — see [`ownerId` vs `userId`](#ownerid-vs-userid) |

`q.scope(kind)` is the canonical way to declare the column — auto-derivation works the same way for plain `q.text().required()` or Drizzle `text(...).notNull()` columns named to match the convention.

**Rules:**
- Exactly one isolation column → auto-derived.
- Zero columns AND no PUBLIC route → compile-time error ("missing isolation column"). If the only candidate is `userId`, the error says so specifically.
- Two or more (e.g. `organizationId` + `ownerId`) → compile-time error; declare `firewall:` explicitly.

## What `{ exception: true }` Actually Does

`{ exception: true }` disables **tenant scoping only** — the generated `buildFirewallConditions()` emits no tenant `WHERE` clauses. It does **not** affect:

- **Audit columns.** `createdAt`, `modifiedAt`, `createdBy`, `modifiedBy` are on every table (declared with `...q.audit()`), and `deletedAt` / `deletedBy` are on every table whose resource soft-deletes (declared with `...q.softDelete()`). Disable the whole mechanism globally with `compiler.features.auditFields: false`.
- **Soft-delete filtering.** If the table has a `deletedAt` column — which it does unless both `crud.delete.mode` and `crud.batchDelete.mode` are `'hard'` — the firewall still emits `isNull(<table>.deletedAt)` so soft-deleted records stay hidden. To opt out on an exception resource, switch the resource to hard deletes or set `compiler.features.auditFields: false` globally.

So `firewall: [{ exception: true }]` on the default configuration returns `isNull(<table>.deletedAt)` from `buildFirewallConditions()` — no tenant filter, soft-delete filter still applied.

## Sysadmin Escape Hatch

When `cms: { sysadmin: true }` is set on the project (introduced in v0.15.0), every `buildFirewallConditions()` emits a runtime branch that drops tenant scopes for `ctx.userRole === 'sysadmin'` callers while keeping the soft-delete predicate:

```typescript
// Generated for an org-scoped table when cms.sysadmin: true
export function buildFirewallConditions(ctx: AppContext): SQL | undefined {
  if (ctx.userRole === 'sysadmin') {
    return isNull(invoices.deletedAt);  // tenant predicate dropped, soft-delete kept
  }
  return and(
    eq(invoices.organizationId, ctx.activeOrgId!),
    isNull(invoices.deletedAt),
  );
}
```

The escape branch is **only** emitted when:

- `cms.sysadmin: true` is set on the project config
- The table has at least one tenant-scope predicate (org / owner / team / `via:` relationship). Tables with no tenant scopes already return what a sysadmin would see, so the runtime check would be dead code.
- The firewall does not declare `{ exception: true }` (no tenant scopes to drop).

**Sysadmin is distinct from appmanager.** `userRole === 'appmanager'` callers still go through the firewall normally — only `userRole === 'sysadmin'` triggers the bypass. Appmanager is the platform control-plane tier, expressed with plain `userRole` checks; sysadmin is the opt-in cross-tenant DB-admin tier. See [CMS Configuration](/configure#cms-configuration).

If your project doesn't set `cms.sysadmin: true`, the escape branch is never emitted and the role value is functionally inert.

## Common Patterns

```typescript
// Public/system table - no tenant filtering (soft-delete still applied)
firewall: [{ exception: true }]

// Organization-scoped (auto-derived when organizationId is present — block can be omitted)
firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
  { field: 'deletedAt', isNull: true },
]

// User-owned personal data
firewall: [
  { field: 'ownerId', equals: 'ctx.userId' },
  { field: 'deletedAt', isNull: true },
]

// Multi-tenant + per-team scoping
firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
  { field: 'teamId', equals: 'ctx.activeTeamId' },
  { field: 'deletedAt', isNull: true },
]

// Custom ctx field
firewall: [
  { field: 'workspaceId', equals: 'ctx.activeWorkspaceId' },
]

// Status-gated (literal value, not ctx-derived — does NOT add to systemManaged)
firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
  { field: 'status', in: ['active', 'pending'] },
]
```

## Error Responses

When a record isn't found behind the firewall (wrong org, soft-deleted, or genuinely doesn't exist), the error response depends on `firewallErrorMode`:

### Reveal Mode (Default)

Returns **403** with a structured error:

```json
{
  "error": "Record not found or not accessible",
  "layer": "firewall",
  "code": "FIREWALL_NOT_FOUND",
  "hint": "Check the record ID and your organization membership"
}
```

This is developer-friendly and helps debug access issues during development.

### Hide Mode

Returns **404** with a generic error:

```json
{
  "error": "Not found",
  "code": "NOT_FOUND"
}
```

Use `firewallErrorMode: 'hide'` for security-hardened deployments where you don't want attackers to distinguish between "record exists but you can't access it" and "record doesn't exist":

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

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

export default defineTable(records, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  firewallErrorMode: 'hide',  // Opaque 404s prevent record enumeration
});
```

### Foreign-Key Referential Checks

Generated `POST /` and `POST /batch` handlers validate every foreign-key column against the **target table's** firewall before inserting. If a record references a row that the caller can't see (wrong org, soft-deleted, or non-existent), the request fails with:

```json
{
  "error": "Referenced jobs row not found",
  "code": "FK_NOT_FOUND",
  "layer": "validation",
  "field": "jobId"
}
```

This closes a cross-tenant injection vector: even if an attacker knows a foreign tenant's row ID, they cannot attach a child record to it, and they cannot trigger a cross-tenant cascade delete. The check uses the target table's `buildXFirewallConditions(ctx)` helper, so it honors whatever firewall predicates the target declared (organization, owner, team, custom). FKs to tables with `{ exception: true }` get a plain existence check (still verifies the row exists, doesn't tenant-scope it).

The check runs after guards but before the insert, so it appears as `layer: "validation"` in error responses. Status code is `400`, not `404` — returning `404` would leak existence of out-of-scope rows.

### Pre-Record ID-Probe Defense

Independent of `firewallErrorMode`, generated `/:id` handlers run a
**role-only access check before** the firewall query (see
[Access ordering](/define/access#evaluation-order)). A caller
without the right role gets 403 before the database is touched, so they
can't distinguish "row exists out-of-scope" from "row doesn't exist" via
response timing or status differences.

`firewallErrorMode` controls what happens **after** the role check passes
but the firewall hides the row — i.e., when an authorized caller asks for
a record in another tenant. Use `'hide'` to make that case indistinguishable
from a non-existent ID.

## Rules

- Every resource MUST have at least one tenant predicate OR `{ exception: true }` OR a PUBLIC route
- `{ exception: true }` cannot be combined with tenant predicates in the same array

## Why Can't You Mix `{ exception: true }` with Tenant Predicates?

They represent opposite intentions:
- `{ exception: true }` = "Don't generate tenant `WHERE` clauses, data is public/global"
- Tenant predicates = "Generate tenant `WHERE` clauses to isolate data"

Combining them would be contradictory — you can't both isolate and not isolate. (Note: soft-delete filtering is independent of this decision, see above.)

## Handling "Some Public, Some Private" Data

If you need records that are sometimes public and sometimes scoped, you have two options:

### Option 1: Two Separate Tables

Split into two tables - one public, one scoped:

```typescript
// features/job-templates/template-library.ts - Public job templates
import { q, defineTable } from '@quickback/compiler';

export const templateLibrary = q.table('templateLibrary', {
  id:    q.id(),
  title: q.text().required(),
  body:  q.text().required(),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(templateLibrary, {
  firewall: [{ exception: true }],
  // ...
});

// features/job-templates/custom-templates.ts - Org's custom job templates
import { q, defineTable } from '@quickback/compiler';

export const customTemplates = q.table('customTemplates', {
  id:      q.id(),
  title:   q.text().required(),
  body:    q.text().required(),
  ownerId: q.text().required(),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(customTemplates, {
  firewall: [{ field: 'ownerId', equals: 'ctx.userId' }],
  // ...
});
```

### Option 2: Use Access Control Instead

Keep tenant scope but make access permissive, then control visibility via `access`:

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

export const jobs = q.table('jobs', {
  id:             q.id(),
  title:          q.text().required(),
  isPublic:       q.bool().default(false).required(),
  organizationId: q.scope('organization'),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(jobs, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  read: {
    // Anyone in the org can list — visibility differences are handled
    // via record-level access conditions on /:id
    access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] },
  },
  // Use record conditions to allow public jobs OR owned jobs on GET /:id
  // (single-record GET inherits read.access, with optional record-level overrides)
  // ...
});
```

### Which to Choose?

| Scenario | Recommendation |
|----------|----------------|
| Truly global data (app config, public job templates) | `exception: true` in separate resource |
| "Public within org" but still org-isolated | Ownership scope + permissive access rules |
| Recruiter can toggle job postings public/private | Ownership scope + `visibility` field + access conditions |
