# Arrows

A relationship answers *"does the caller have a row linking them to this
resource?"* An **arrow** answers a different question: *"the caller has
authority over **another** object — does that authority **flow across a
foreign key** to this one?"* An event's announcements inherit the authority
of the event's organization; a folder's documents inherit the authority of
the folder; a child section inherits the authority of its ancestors.

An arrow is a declared **FK hop**: it names a foreign-key column on a `from`
table and the `to` table that FK resolves to. Referenced from a permission,
it resolves the row's FK to its target object and requires a permission
**there** — SpiceDB's `relation->permission`, lowered to SQL. Two emit
shapes: a single FK hop lowers to a subquery; a self-referential hop
(`folder.parentId -> folder`) lowers to a depth-bounded `WITH RECURSIVE` CTE.

## Declaring arrows

Arrows live in `quickback.config.ts` under `authz.arrows`, alongside
`authz.relationships` and `authz.permissions`:

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

export default defineConfig({
  // ...providers...
  authz: {
    arrows: {
      // single FK hop: an event row → the organization that owns it
      eventOrg:   { from: 'event',  fk: 'organizationId', to: 'organization' },
      // self-referential hierarchy: a folder → its parent folder
      folderTree: { from: 'folder', fk: 'parentId', to: 'folder', recursive: true },
    },
    permissions: {
      'org:admin':   { anyOf: [{ role: 'admin' }, { role: 'owner' }] },
      // org admins of an event's org inherit access to the event
      'event:view':  { anyOf: ['organizerOf', { arrowRef: 'eventOrg', permission: 'org:admin' }] },
      // anyone who can administer an ancestor folder can read this folder
      'folder:read': { anyOf: [{ arrowRef: 'folderTree', permission: 'org:admin' }] },
    },
  },
});
```

| Field | Meaning |
|---|---|
| `from` | Source table the FK column lives on. |
| `fk` | Foreign-key column on `from`. **For a non-recursive arrow this is the tenant column** the claim binds against (see below); for a recursive arrow it is the recursion pointer (`parentId`). |
| `to` | Target table the FK resolves to. A self-hop has `from === to`. |
| `recursive?` | Forces the recursive-CTE lowering. When `from === to` the compiler auto-detects recursion even if this is omitted. Default `false`. |
| `maxDepth?` | Per-arrow recursion bound (positive integer). Ignored for non-recursive arrows. Default is the global bound, `8`. |
| `tenantColumn?` | The tenant column the seed of a **recursive** arrow binds the org claim against — defaults to `organizationId`. Set it when the hierarchy table spells the column differently (e.g. `organisationId`). Ignored for non-recursive arrows. |

## Referencing an arrow from a permission

An arrow is never used directly. It is a **leaf** inside a
[permission](/define/permissions) expression, written as
`{ arrowRef: '<name>', permission: '<perm-at-target>' }`:

```typescript
'event:view': { anyOf: ['organizerOf', { arrowRef: 'eventOrg', permission: 'org:admin' }] },
```

Read it as: *"`event:view` holds if the caller is an `organizerOf` the
event, **or** if — hopping across `eventOrg` to the event's organization —
the caller has `org:admin` **there**."* The `permission` named on the leaf is
the permission required **at the target object**, and it participates in the
permission composition graph (resolution + cycle detection) exactly like a
`{ permissionRef }`.

**M2 target restriction.** The target permission must reduce to
**org-membership roles** (`admin` / `owner` / `member`) — directly or
through a `permissionRef` / `anyOf` / `allOf` of org roles. That's what lets
the hop bind to the caller's verified active-org claim (`ctx.activeOrgId`).
A target permission that references a relation, a scoped role, a pseudo-role,
a `not`, or a nested arrow is a **compile error** in M2 (never a silent
allow) — those richer targets are a documented fast-follow. So
`org:admin = anyOf(role('admin'), role('owner'))` is the canonical target.

## Using the permission in a firewall

The permission that contains the arrow is referenced from a resource's
[`firewall`](/define/firewall) with a `{ field, permission }`
arm, like any other named permission. `field` is the resource-side column the
lowered subquery constrains:

```typescript
// features/event-documents/event-documents.ts
firewall: {
  all: [
    { field: 'eventId', permission: 'event:view' },
    { field: 'deletedAt', isNull: true },
  ],
}
```

The arrow arm of `event:view` lowers to an FK-hop subquery on `eventId`; the
relationship arms (`organizerOf`) lower to relationship subqueries; the
`anyOf` OR's them under the null-safe empty-guard (`anyGuarded`) so an absent
claim **denies** rather than reading the whole table.

## Emit shape 1 — non-recursive (FK-hop subquery)

For `eventOrg = { from: 'event', fk: 'organizationId', to: 'organization' }`
with target permission `org:admin`, an `{ arrowRef: 'eventOrg' }` arm on a
resource constrained by `eventId` lowers to:

```sql
WHERE event_documents.eventId IN (
        SELECT event.id FROM event
        WHERE event.organizationId = ?   -- ? = ctx.activeOrgId (signed claim)
      )
```

In Drizzle terms:

```typescript
inArray(event_documents.eventId,
  db.select({ x: event.id }).from(event)
    .where(eq(event.organizationId, ctx.activeOrgId!)))
```

The seed of every non-recursive hop binds the **`fk` column** to the active-org
claim — so for a non-recursive arrow, **`fk` is the org/tenant column on
`from`**, not an unrelated FK. (A self-FK hierarchy is the recursive case
below; that's where `fk` is a recursion pointer and the tenant column is
separate.)

## Emit shape 2 — recursive (bounded `WITH RECURSIVE` CTE)

A self-referential arrow expresses **inherited authority down a hierarchy**:
a row is visible if the caller controls it directly **or** controls any of
its ancestors. For `folderTree = { from: 'folder', fk: 'parentId', to: 'folder', recursive: true }`
the arrow lowers to a recursive CTE bounded by `maxDepth`:

```sql
WITH RECURSIVE __arrow_closure(id, depth) AS (
  -- seed: the folders the caller directly controls (tenant-constrained)
  SELECT folder.id, 0 FROM folder
   WHERE folder.organizationId = ?            -- ? = ctx.activeOrgId
  UNION ALL
  -- step: walk parent → child, RE-constraining tenancy on every walked row
  SELECT folder.id, c.depth + 1
    FROM folder JOIN __arrow_closure c ON folder.parentId = c.id
   WHERE c.depth < 8 AND folder.organizationId = ?
)
SELECT id FROM __arrow_closure
```

Both D1 (SQLite) and Neon (Postgres) speak `WITH RECURSIVE`, so the same
arrow is valid on either provider. Drizzle interpolates the column/table
**objects** (dialect-correct identifiers) and binds the claim as a
**parameter** — never string-concatenated, so an arrow is never an injection
surface (the FK column and tables are compile-time identifiers; the only
runtime value is the signed org claim).

## Tenant isolation semantics

Arrows are tenant-safe by construction. Three guarantees, all enforced at
the SQL level:

- **The seed is tenant-constrained.** The CTE root (and the non-recursive
  hop's `WHERE`) binds the tenant column to `ctx.activeOrgId`. A caller only
  ever seeds the closure with rows in their own org.
- **The recursive arm re-constrains tenancy on *every* walked row** — not
  just the seed. The step is `JOIN __arrow_closure c ON folder.parentId = c.id`
  **`AND folder.organizationId = ctx.activeOrgId`**. A row that was reparented
  under an in-tenant parent but whose **own** org differs is excluded. This is
  defense-in-depth: cross-org inheritance must never happen, and the SQL
  enforces it regardless of what the write path allows. Strict per-row
  tenancy is the correct default for an org-membership arrow.
- **A missing claim fails closed.** When `ctx.activeOrgId` is null (anonymous
  or cross-tenant caller), the subquery short-circuits to `sql\`1 = 0\`` —
  a deny, never an unconstrained read and never an `undefined` bind.

## Bounded recursion

Recursion is **always** finite. The recursive arm's `WHERE c.depth < maxDepth`
caps the walk so a deep or cyclic hierarchy can never run away. The bound is
resolved with this precedence:

1. Per-permission override — `authz.permissionMaxDepth['<perm>']`.
2. Per-arrow `maxDepth`.
3. The global default, **8**.

```typescript
authz: {
  arrows: {
    sectionTree: { from: 'sections', fk: 'parentId', to: 'sections', recursive: true, maxDepth: 6 },
  },
  permissions: {
    'section:inTree': { anyOf: [{ arrowRef: 'sectionTree', permission: 'org:admin' }] },
  },
  // optional: raise the bound for one permission without touching the arrow
  permissionMaxDepth: { 'section:inTree': 16 },
}
```

An author who **explicitly opts out** of the bound — `unbounded: true` —
with no token-closure path is a **compile error**: there is no safe finite
SQL for unbounded recursion, so the compiler refuses to emit a
non-terminating CTE. The fix is to set a `maxDepth`. Quickback never silently
allows an unbounded traversal.

## Worked example — q-events

The `q-events` example ships both shapes.

A **recursive** self-FK on the `sections` feature. `sections.parentId`
points back at `sections`; access to an ancestor section confers access to
all descendants:

```typescript
// quickback.config.ts
authz: {
  arrows: {
    sectionTree: { from: 'sections', fk: 'parentId', to: 'sections', recursive: true, maxDepth: 6 },
  },
  permissions: {
    'org:admin':      { anyOf: [{ role: 'admin' }, { role: 'owner' }] },
    'section:inTree': { anyOf: [{ arrowRef: 'sectionTree', permission: 'org:admin' }] },
  },
}

// features/sections/sections.ts — the permission arm rides alongside the org scope
firewall: {
  all: [
    { field: 'organizationId', equals: 'ctx.activeOrgId' },
    { field: 'id', permission: 'section:inTree' },   // emits the tenant-gated WITH RECURSIVE CTE
    { field: 'deletedAt', isNull: true },
  ],
}
```

The org `equals` arm and the recursive arrow's per-row tenant re-constraint
are belt-and-suspenders — both pin the closure to the caller's org. The arrow
adds the *ancestor-authority* dimension on top of the flat org scope.

## Reference

```typescript
// authz.arrows.<name>
interface ArrowDefinition {
  from: string;          // source table the FK lives on
  fk: string;            // FK column (non-recursive: the tenant column; recursive: the parent pointer)
  to: string;            // target table the FK resolves to (self-hop: from === to)
  recursive?: boolean;   // force the recursive-CTE lowering (auto when from === to)
  maxDepth?: number;     // per-arrow recursion bound (default 8)
  tenantColumn?: string; // recursive-seed tenant column override (default 'organizationId')
}

// referenced from a permission as a leaf:
{ arrowRef: '<arrowName>', permission: '<permissionAtTarget>' }

// per-permission depth override (overrides per-arrow maxDepth + the global 8):
authz.permissionMaxDepth?: Record<string, number>
```

- Every `{ arrowRef }` in a permission must name a declared `authz.arrows[*]`
  entry, and the arrow's `fk` / `to` are checked against the real schema at
  compile time.
- The target `permission` must reduce to org-membership roles in M2.
- See [Permissions](/define/permissions) for the expression DSL
  the arrow leaf lives in, and [Firewall — named permissions](/define/firewall#named-permissions-permission)
  for how the resulting `{ permission }` arm sits inside a firewall. The
  compile-time analyzer reports the subquery / CTE depth of every arrow-bearing
  permission — see [Diagnostics](/define/diagnostics).
