# Actions

Actions are custom API endpoints for business logic beyond CRUD operations. They enable workflows, integrations, and complex operations.

## Overview

Quickback supports two types of actions:

| Aspect | Record-Based | Standalone |
|--------|--------------|------------|
| Route | `{METHOD} /:id/{actionName}` | Custom `path` (required) |
| Record fetching | Automatic | None (`record` is `undefined`) |
| Firewall applied | Yes | No |
| Preconditions | Supported via `access.record` | Not applicable |
| Response types | JSON only | JSON, stream, file |
| Use case | Advance application, reject candidate | AI chat, bulk import from job board, webhooks |

Standalone actions are signalled by the presence of a `path:` field in the action config. Record-based actions omit `path` and bind to the feature's primary table.

## Where do actions live?

**One file per action.** Each action is its own module under the feature's
`actions/` directory:

```
features/applications/
├── applications.ts             ← defineTable (primary table)
├── candidates.ts               ← defineTable (sibling table)
├── actions/
│   ├── advance.ts              ← defineAction, binds to applications
│   ├── reject.ts               ← defineAction, binds to applications
│   ├── stats.ts                ← defineAction, standalone (has `path:`)
│   └── candidates/
│       └── disqualify.ts       ← defineAction, binds to candidates
└── lib/
    └── inputs.ts               ← shared schemas (optional, copied verbatim)
```

| Layout | Binds to |
|--------|----------|
| `actions/<name>.ts` | The feature's primary table (table file matching the feature name), or standalone if `path:` is set. |
| `actions/<table>/<name>.ts` | The sibling table file `<table>.ts` at the feature root. Standalone actions inside a subdir ignore the binding. |

The action's filename (sans `.ts`) is the action name and the URL segment.
Uniqueness is enforced per `(bound table, name)` pair — sibling-bound
actions on **different** tables may share a verb (`actions/episodes/publish.ts`
and `actions/shows/publish.ts` mount at `/episodes/:id/publish` and
`/shows/:id/publish` and compile cleanly). The compiler errors only when
two action files would mount at the same URL — for example, a flat
`actions/foo.ts` (binds to primary) plus `actions/<primary>/foo.ts`, or
two standalones with the same `(method, path)`. Standalone actions are
unique per `(method, path)`.

Tableless features have no top-level `*.ts` files; every action under
`actions/` must be standalone (have `path:`). Use this for utility endpoints
like webhooks, reports, or integrations that don't operate on a specific
record.

### Retired layouts

These are rejected at load time with migration guidance:

- **`actions.ts`** (bundled multi-action file)
- **`_feature.ts`** (shared schemas + non-table-bound actions)
- **`handlers/`** (separate handler-file directory)
- **`defineActions(table, {...})`** (the multi-action factory)
- **`defineActions(null, {...})`** (tableless variant)
- **`*-actions.ts` / `*.actions.ts`** filename patterns

For shared Zod schemas or helpers, put them under `<feature>/lib/` and import
from each action file. Files under `lib/` are copied verbatim into the
generated output.

## Defining Actions

```typescript
// features/applications/actions/advance.ts
import { z } from "zod";
import { eq } from "drizzle-orm";
import { defineAction } from "../.quickback/define-action";
import { applications } from "../applications";

export default defineAction({
  description: "Move an application forward in the pipeline.",
  input: z.object({
    nextStatus: z.enum(["screening", "interview", "offer"]),
    notes: z.string().max(2000).optional(),
  }),
  access: { roles: ["owner", "admin"] },
  transition: {
    field: "status",
    via: "nextStatus",
    fromTo: {
      applied: ["screening"],
      screening: ["interview"],
      interview: ["offer"],
    },
  },
  async execute({ db, record, input, whereTransition }) {
    const [updated] = await db
      .update(applications)
      .set({
        status: input.nextStatus,
        notes: input.notes ?? record.notes,
      })
      .where(whereTransition!(applications))
      .returning();
    return updated;
  },
});
```

> **`defineAction` import.** Import from `"../.quickback/define-action"` —
> the `.quickback/` directory is generated alongside your feature with a
> typed helper that infers `record` and `input` types from the rest of the
> action config. Audit fields (`createdAt`, `createdBy`, `modifiedAt`,
> `modifiedBy`) are hard-stamped by the audit DB wrapper on every
> `db.insert().values()` and `db.update().set()` — handlers don't pass
> them and can't override them.

### Configuration Options

| Option | Required | Description |
|--------|----------|-------------|
| `description` | Yes | Human-readable description of the action |
| `input` | Yes | Zod schema for request validation |
| `access` | Yes | Access control (roles, record conditions, or function) |
| `execute` | Yes | Inline async arrow function (`async (ctx) => { … }`) |
| `path` | Standalone only | Custom route path. Presence of `path` makes the action standalone. |
| `method` | No | HTTP method: GET, POST, PUT, PATCH, DELETE (default: POST). GET actions parse `input` from query params; all other methods parse from the JSON body. See [GET vs POST for input](#get-vs-post-for-input). |
| `responseType` | No | Response format: json, stream, file (default: json) |
| `sideEffects` | No | Hint for AI tools: `'sync'`, `'async'`, or `'fire-and-forget'` |
| `allowRawSql` | No | Explicit compile-time opt-in for raw SQL in execute code |
| `unsafe` | No | Unsafe raw DB mode. Object config (`reason`, `adminOnly`, `crossTenant`, `targetScope`). |
| `transition` | No | State-machine policy enforcing the from/to pair (record-based actions). See [Transitions](#transition-state-machine-policy) |
| `bulkVariant` | No | Auto-generate a `POST /:resource/batch/{action}` route alongside the per-record route (record-based actions). See [Bulk Variant](#bulk-variant) |
| `cms` | No | Pass-through metadata blob for CMS surfaces |

`execute` must be an `async (ctx) => { … }` arrow function. `async function`
declarations and non-async forms are rejected at parse time.

## Record-Based Actions

Record-based actions operate on an existing record. The record is automatically loaded and validated before your action executes.

**Route pattern:** `{METHOD} /:id/{actionName}`

```
POST /applications/:id/advance
POST /applications/:id/reject
POST /applications/:id/schedule-interview
```

### Runtime Flow

1. **Authentication** - User token is validated
2. **Record Loading** - The record is fetched by ID
3. **Firewall Check** - Ensures user can access this record
4. **Access Check** - Validates roles and preconditions
5. **Input Validation** - Request body validated against Zod schema
6. **Execution** - Your action handler runs
7. **Response** - Result is returned to client

### Example: Advance Application Stage

```typescript
// features/applications/actions/advance.ts
import { z } from "zod";
import { eq } from "drizzle-orm";
import { defineAction } from "../.quickback/define-action";
import { applications } from "../applications";

export default defineAction({
  description: "Move application to the next pipeline stage",
  input: z.object({
    stage: z.enum(["screening", "interview", "offer", "hired"]),
    notes: z.string().optional(),
  }),
  access: {
    roles: ["owner", "hiring-manager"],
    record: { stage: { notEquals: "rejected" } },
  },
  async execute({ db, record, input }) {
    const [updated] = await db
      .update(applications)
      .set({
        stage: input.stage,
        notes: input.notes ?? record.notes,
      })
      .where(eq(applications.id, record.id))
      .returning();
    return updated;
  },
});
```

### Request Example

```
POST /applications/app_123/advance
Content-Type: application/json

{
  "stage": "interview",
  "notes": "Strong technical background, moving to interview"
}
```

### Response Example

```json
{
  "data": {
    "id": "app_123",
    "stage": "interview",
    "notes": "Strong technical background, moving to interview"
  }
}
```

### Bulk variant

Set `bulkVariant: true` to auto-generate a bulk version of the action at
`POST /:resource/batch/{action}`. The compiler emits a second route alongside
the per-record one, looping the same access + transition + execute pipeline
over an array of ids. This is the answer to "I need to advance 50
applications at once" — you don't have to hand-roll a parallel standalone
action that duplicates the per-record logic.

```typescript
// features/applications/actions/advance.ts
export default defineAction({
  description: "Move an application forward in the pipeline.",
  input: z.object({ nextStatus: z.enum(["screening", "interview", "offer"]) }),
  access: { roles: ["owner", "admin"] },
  bulkVariant: true,
  transition: {
    field: "status",
    via: "nextStatus",
    fromTo: { applied: ["screening"], screening: ["interview"], interview: ["offer"] },
  },
  async execute({ db, record, input, whereTransition }) {
    const [updated] = await db
      .update(applications)
      .set({ status: input.nextStatus })
      .where(whereTransition!(applications))
      .returning();
    if (!updated) throw new TransitionLostError("status", record.status, input.nextStatus);
    return updated;
  },
});
```

**Request shape:**

```http
POST /applications/batch/advance
Content-Type: application/json

{
  "ids": ["app_001", "app_002", "app_003"],
  "input": { "nextStatus": "interview" },
  "failFast": false
}
```

The `input` is shared across the batch — the action's Zod schema is
validated once, not per id. For per-record input variation, use the
single-record endpoint (`POST /:id/{action}`) or write a standalone action.

**Response (207 Multi-Status when any record fails, 200 when clean):**

```json
{
  "success": [{ "id": "app_001", "status": "interview" }],
  "errors": [
    {
      "index": 1,
      "id": "app_002",
      "error": {
        "error": "Action not allowed for current state",
        "code": "ACCESS_ACTION_NOT_ALLOWED_FOR_STATE",
        "details": { "field": "status", "currentValue": "rejected" }
      }
    }
  ],
  "meta": {
    "total": 3,
    "succeeded": 2,
    "failed": 1,
    "failFast": false,
    "transactional": false
  }
}
```

**Per-record pipeline** mirrors the single-record route exactly — the same
firewall fetch, access reason split (403 vs 409), transition pre-check,
`whereTransition` factory, and `TransitionLostError` handling. Per-record
failures land in `errors[]` with their original index; successful records
land in `success[]` masked the same way the single-record response is.

**Transactional matrix** (same as `PATCH /batch`):

| Provider | failFast | transactional | Behavior |
|---|---|---|---|
| neon (Postgres via Hyperdrive) | `true` | `true` | `db.transaction()` wraps the loop; any throw rolls back all writes |
| (any) | `false` | `false` | Independent per-record outcomes, partial success |
| cloudflare-d1 | `true` | `false` | Fail-fast loop without rollback — earlier records stay committed (D1 has no `db.transaction`) |

**Constraints:**

- Record-based actions only. Standalone actions reject `bulkVariant: true`
  at compile time (no record context to loop over).
- `unsafe` actions reject `bulkVariant: true` in v1 — the cross-tenant
  per-record audit story is not yet wired through.
- Method is forced to POST regardless of the action's `method` setting. The
  semantic asymmetry (one row → many rows) makes inheriting GET/DELETE
  confusing.
- Max batch size is 100 ids per request.

## Standalone Actions

Standalone actions are independent endpoints that don't require a record context. Set a `path:` to declare the action standalone.

**Route pattern:** Custom `path`

```
POST /chat
GET /reports/summary
POST /webhooks/stripe
```

### Example: AI Chat with Streaming

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

export default defineAction({
  description: "Send a message to AI",
  path: "/chat",
  method: "POST",
  responseType: "stream",
  input: z.object({
    message: z.string().min(1).max(2000),
  }),
  access: { roles: ["recruiter", "hiring-manager"] },
  async execute({ input, c }) {
    // Stream response back to client...
    return c.newResponse(/* ReadableStream */);
  },
});
```

### Streaming Response Example

For actions with `responseType: 'stream'`:

```
Content-Type: text/event-stream

data: {"type": "start"}
data: {"type": "chunk", "content": "Hello"}
data: {"type": "chunk", "content": "! I'm"}
data: {"type": "chunk", "content": " here to help."}
data: {"type": "done"}
```

### Tableless Features

A feature directory with no top-level `*.ts` files is "tableless" — every
action under `actions/` must be standalone. Use this for utility endpoints
like reports, integrations, or webhooks that don't map to a specific
resource.

```
quickback/features/
└── reports/
    └── actions/
        ├── trial-balance.ts        ← standalone (has path:)
        └── profit-loss.ts          ← standalone (has path:)
```

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

export default defineAction({
  description: "Generate trial balance report",
  path: "/reports/trial-balance",
  method: "GET",
  input: z.object({
    startDate: z.string(),
    endDate: z.string(),
  }),
  access: { roles: ["admin", "accountant"] },
  async execute({ db, input }) {
    // ... build report
    return { rows: [] };
  },
});
```

### GET vs POST for input

GET actions read `input` from URL query parameters; every other method (POST,
PUT, PATCH, DELETE) reads from the JSON body. The Zod schema is the same in
both cases — the compiler picks the right parser based on `method`.

```typescript
// GET — input populated from ?q=…&limit=…
defineAction({
  method: "GET",
  path: "/search",
  input: z.object({
    q: z.string(),
    limit: z.coerce.number().int().positive().max(100).default(20),
  }),
  access: { roles: ["AUTHENTICATED"] },
  async execute({ input }) {
    // input is { q: string, limit: number } — fully typed.
  },
});
```

Repeated query keys (`?tag=a&tag=b`) come through as arrays automatically;
single values stay as strings. Use Zod's `.coerce.number()` /
`.coerce.boolean()` when you want typed primitives from the parsed strings.

**Use POST when input is structured.** GET requests cannot carry a JSON body
in any of the surfaces Quickback APIs are commonly called from:

- Browsers throw on `fetch(url, { method: 'GET', body })`.
- CDNs and caches key on URL+method; a GET with a body either gets the body
  stripped at the edge or cache-collides with semantically different requests.
- OpenAPI client generators (axios, openapi-fetch, openapi-typescript-codegen)
  refuse to emit GET-with-body, so the generated SDKs break.
- The MCP agent surface treats GET as parameter-only — agents calling a GET
  action will only see what the URL exposes.

If your action takes nested objects, arrays of objects, or large inputs that
don't serialize cleanly to a query string, set `method: "POST"`. POST doesn't
imply "this writes data" — it's the right method whenever the request body is
load-bearing. Use the response shape (`responseType: "json"`, or just the data
your handler returns) to convey read semantics; HTTP method choice is about
input transport, not action intent.

## Access Configuration

Access controls who can execute an action and under what conditions.

### Role-Based Access

```typescript
access: {
  roles: ["hiring-manager", "recruiter"]  // OR logic: user needs any of these roles
}
```

### Public Access

Use `roles: ["PUBLIC"]` for unauthenticated endpoints (contact forms, webhooks, public APIs). Every invocation is **mandatory audit logged** with IP address, input, result, and timing.

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

export default defineAction({
  description: "Public contact form submission",
  path: "/submit",
  input: z.object({
    name: z.string().min(1),
    email: z.string().email(),
    message: z.string().min(10),
  }),
  access: { roles: ["PUBLIC"] },
  async execute({ db, input }) {
    // ... persist submission
    return { ok: true };
  },
});
```

The wildcard `"*"` is not supported and will throw a compile-time error. Use `"PUBLIC"` explicitly.

### Record Conditions

For record-based actions, you can require the record to be in a specific state:

```typescript
access: {
  roles: ["hiring-manager"],
  record: {
    stage: { equals: "screening" }  // Precondition
  }
}
```

**Supported operators:**

| Operator | Description |
|----------|-------------|
| `equals` | Field must equal value |
| `notEquals` | Field must not equal value |
| `in` | Field must be one of the values |
| `notIn` | Field must not be one of the values |

### Context Substitution

Use `$ctx` to reference the current user's context:

```typescript
access: {
  record: {
    ownerId: { equals: "$ctx.userId" },
    orgId: { equals: "$ctx.orgId" }
  }
}
```

### OR/AND Combinations

```typescript
access: {
  or: [
    { roles: ["hiring-manager"] },
    {
      roles: ["recruiter"],
      record: { ownerId: { equals: "$ctx.userId" } }
    }
  ]
}
```

### Function Access

For complex logic, use an access function:

```typescript
access: async (ctx, record) => {
  return ctx.roles.includes('admin') || record.ownerId === ctx.userId;
}
```

### Relationship-roles in actions

**v0.19+** — standalone actions now support relationship-roles in `access.roles` directly. Declare the relationship under `authz.relationships` with optional `loads` / `exposeAs` for ctx hydration, and reference the role by name on the action:

```ts
// quickback.config.ts
authz: {
  relationships: {
    attendeeOf: {
      from: 'guests',
      subject: { column: 'userId', equals: 'ctx.userId' },
      resource: { column: 'eventId' },
      where: { status: 'confirmed' },
      loads: 'events',
      exposeAs: 'event',
    },
  },
  roles: { attendee: { via: 'attendeeOf' } },
}

// 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'] },
  execute: async ({ db, ctx, input }) => {
    // The resolver already verified attendance and loaded ctx.event.
    // No hand-rolled `SELECT … FROM guests WHERE …` needed.
  },
});
```

The action's surface (path params + input fields) must expose the relationship's `resource.column`. At v0.19 a missing key emits a `[quickback:authz]` warning and skips resolver emission; at v0.20 it becomes a compile error. See [Access — standalone actions](/compiler/definitions/access#standalone-actions-surface-keys-and-loads--exposeas-v019) for the full mechanism.

**Record-based actions** are NOT yet supported — the runtime evaluator fail-closes (403) on relationship arms reached without an inline gate. Until inline emission for record-based actions lands, either:

- Set `firewall: { exception: true }` on the action and gate manually in `execute()`, OR
- Apply the relationship at the resource's firewall layer (worked example below).

**Resource-layer workaround for record-based actions**: gate the action on BA roles only and apply the relationship at the resource layer via a firewall `via:` predicate:

```typescript
// quickback.config.ts
authz: {
  relationships: {
    guestOf: {
      from: 'event_guests',
      subject: { column: 'userId', equals: 'ctx.userId' },
      resource: { column: 'eventId' },
      where: { status: 'confirmed' },
    },
  },
}

// features/sessions/sessions.ts — firewall scopes rows; action gates on BA role
firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
  { field: 'eventId', via: 'guestOf' },         // ← row-level relationship check
],
actions: {
  comment: defineAction({
    access: { roles: ['member', 'admin'] },     // ← BA role only on the action
    input: z.object({ text: z.string() }),
    execute: async (input, ctx, db, record) => {
      // record is already filtered by the firewall — guest membership
      // is enforced before this handler runs.
      return db.insert(comments).values({ ... });
    },
  }),
}
```

This pattern composes cleanly: the firewall guarantees the caller has the relationship to the record being acted on; the action's access list keeps the role surface obvious.

See [Access — relationship authorization](/compiler/definitions/access#relationship-based-authorization) for the full picture, including `loads` / `exposeAs` and the `firewall: { exception: true }` escape hatch.

## Scoped Database

All actions receive a **scoped `db`** instance that automatically enforces security:

| Operation | Org Scoping | Owner Scoping | Soft Delete Filter | Auto-inject on INSERT |
|-----------|-------------|---------------|--------------------|-----------------------|
| `SELECT`  | `WHERE organizationId = ?` | `WHERE ownerId = ?` | `WHERE deletedAt IS NULL` | n/a |
| `INSERT`  | n/a | n/a | n/a | `organizationId`, `ownerId` from ctx |
| `UPDATE`  | `WHERE organizationId = ?` | `WHERE ownerId = ?` | `WHERE deletedAt IS NULL` | n/a |
| `DELETE`  | `WHERE organizationId = ?` | `WHERE ownerId = ?` | `WHERE deletedAt IS NULL` | n/a |

Scoping is duck-typed at runtime — tables with an `organizationId` column get org scoping, tables with `ownerId` get owner scoping, tables with `deletedAt` get soft delete filtering.

```typescript
async execute({ db, ctx, input }) {
  // This query automatically includes WHERE organizationId = ? AND deletedAt IS NULL
  const items = await db.select().from(applications).where(eq(applications.stage, 'interview'));

  // Inserts automatically include organizationId
  await db.insert(applications).values({ candidateId: input.candidateId, jobId: input.jobId, stage: 'applied' });

  return items;
}
```

**Not enforced** in scoped DB (by design):
- **Guards** — actions ARE the authorized way to modify guarded fields
- **Masking** — actions are backend code that may need raw data
- **Access** — already checked before action execution

### Unsafe Mode

Actions that need to bypass scoped DB filters (for example, platform-level support operations) can enable unsafe mode.

Use object form for explicit policy and mandatory audit metadata:

```typescript
// features/admin/actions/cross-tenant-report.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { applications } from "../../applications/applications";

export default defineAction({
  description: "Generate cross-org hiring report",
  path: "/admin/cross-tenant-report",
  input: z.object({ startDate: z.string() }),
  access: { roles: ["owner"] },
  unsafe: {
    reason: "Support investigation for enterprise customer",
    adminOnly: true,      // default true
    crossTenant: true,    // default true
    targetScope: "all",   // "all" | "organization"
  },
  async execute({ unsafeDb, input }) {
    // unsafeDb bypasses scoped filters
    const allOrgs = await unsafeDb.select().from(applications);
    return allOrgs;
  },
});
```

Unsafe cross-tenant actions are generated with:

- Better Auth authentication required (no unauthenticated path)
- platform admin gate (`ctx.userRole === "admin"`)
- mandatory audit logging on deny/success/error

`PUBLIC` actions also receive mandatory audit logging (same audit table), since unauthenticated endpoints are high-risk by nature.

Without unsafe mode, `unsafeDb` is `undefined` in the executor params.

### Raw SQL Policy

By default, the compiler rejects raw SQL in action code. Use Drizzle query-builder syntax whenever possible.

If a specific action must use raw SQL, opt in explicitly:

```typescript
// features/ledgers/actions/reconcile.ts
import { z } from "zod";
import { sql } from "drizzle-orm";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Run custom reconciliation query",
  input: z.object({}),
  access: { roles: ["owner"] },
  allowRawSql: true,
  async execute({ db }) {
    return db.execute(sql`select 1`);
  },
});
```

Without `allowRawSql: true`, compilation fails with a loud error pointing to the action and snippet.

The detector checks for SQL keywords in string arguments, so non-SQL method calls like `headers.get("X-Forwarded-For")` or `map.get("key")` will not trigger false positives.

## Sharing Code Between Actions

For Zod schemas, helpers, or constants used across multiple actions in a
feature, put them under `<feature>/lib/` and import from each action file.
Files under `lib/` are copied verbatim into the generated output.

```
features/applications/
├── applications.ts
├── actions/
│   ├── advance.ts
│   └── reject.ts
└── lib/
    ├── inputs.ts          ← shared Zod schemas
    └── transitions.ts     ← state-machine helpers
```

```typescript
// features/applications/lib/inputs.ts
import { z } from "zod";

export const advanceInput = z.object({
  nextStatus: z.enum(["screening", "interview", "offer"]),
  notes: z.string().max(2000).optional(),
});
```

```typescript
// features/applications/actions/advance.ts
import { defineAction } from "../.quickback/define-action";
import { advanceInput } from "../lib/inputs";

export default defineAction({
  description: "Move forward",
  input: advanceInput,
  access: { roles: ["owner"] },
  async execute({ db, record, input }) { /* … */ },
});
```

### Importing Tables

Action files import tables from their own feature or other features. The compiler generates alias files for each table, so you import by the table's file name:

```typescript
// features/applications/actions/advance.ts
import { eq, and } from "drizzle-orm";

// Same feature — import from parent directory using the table's file name
import { applications } from "../applications";
import { interviewScores } from "../interview-scores";

// Cross-feature — go up to the features directory, then into the other feature
import { candidates } from "../../candidates/candidates";
import { jobs } from "../../jobs/jobs";
```

**Path pattern from `features/{name}/actions/`:**
- Same feature table: `../{table-file-name}` (e.g., `../applications`)
- Other feature table: `../../{other-feature}/{table-file-name}` (e.g., `../../candidates/candidates`)
- Generated lib files: `../../../lib/{module}` (e.g., `../../../lib/realtime`, `../../../lib/webhooks`)

### Executor Parameters

```typescript
import type { Auth } from "better-auth";

interface ActionExecutorParams {
  db: DrizzleDB;           // Scoped database (auto-applies org/owner/soft-delete filters)
  unsafeDb?: DrizzleDB;    // Unscoped database (only available when unsafe mode is enabled)
  ctx: AppContext;         // User context (userId, roles, orgId)
  record?: TRecord;        // The record (record-based only, undefined for standalone)
  input: TInput;           // Validated input from Zod schema
  services: TServices;     // Configured integrations (billing, notifications, etc.)
  c: HonoContext;          // Raw Hono context for advanced use
  env: Env;                // Runtime bindings (Cloudflare env / process env). Alias for c.env.
  auth: Auth;              // Better Auth instance — call auth.api.getSession(...) etc. Lazy on Cloudflare.
  whereRecord?: (table) => SQL; // Pre-built WHERE: firewall + soft-delete + eq(table.id, record.id). Record-based actions only.
  whereTransition?: (table) => SQL; // whereRecord plus the transition's "from" precondition. Transition actions only.
}
```

#### `auth` — Better Auth instance

`auth` is the Better Auth instance, ready to call from inside an action
handler. Use it for things like minting an OAuth access token, fetching the
current session in a custom way, or invoking a Better Auth plugin method
that isn't already exposed by the generated middleware.

```typescript
// features/integrations/actions/connect-google-drive.ts
import { defineAction } from "../.quickback/define-action";
import { z } from "zod";

export default defineAction({
  description: "Get a fresh Google Drive access token for the signed-in user",
  input: z.object({}),
  access: { roles: ["AUTHENTICATED"] },
  async execute({ auth, ctx, c }) {
    // genericOAuth plugin method — needs a cast since the default-generic
    // `Auth` type doesn't carry plugin-specific API surface.
    const token = await (auth.api as any).getAccessToken({
      headers: c.req.raw.headers,
      body: { providerId: "google", userId: ctx.userId },
    });
    return { accessToken: token.accessToken };
  },
});
```

**Lazy on Cloudflare**: the generated handler emits a `Proxy` so
`createAuth(c.env, c.req.raw.cf)` only runs the first time you read a
property on `auth`. Actions that never touch `auth` pay zero construction
cost. On Bun/Node `auth` is the imported singleton (an IIFE that lazy-loads
the database adapter) wrapped in the same proxy shape — call sites are
identical across runtimes.

**Typing**: `auth` is typed as `Auth` from `better-auth`. The base API
(`getSession`, `signOut`, `signInEmail`, etc.) is fully typed. Plugin-only
methods (`getAccessToken` from `genericOAuth`, admin-plugin methods, etc.)
need a cast at the call site — the default-generic `Auth` doesn't infer
the project's specific plugin set.

#### `whereRecord` — explicit, scope-safe WHERE

For protected-field writes inside record-based actions, prefer
`whereRecord(table)` over `eq(table.id, record.id)`. It returns a Drizzle
expression that AND-merges the resource's firewall and soft-delete predicates
with the record id, so the WHERE is explicit even if a future refactor swaps
`db` back to an unscoped client:

```typescript
// features/applications/actions/advance.ts
import { defineAction } from "../.quickback/define-action";
import { applications } from "../applications";

export default defineAction({
  description: "Advance",
  input: /* … */,
  access: { roles: ["owner"] },
  async execute({ db, record, input, whereRecord }) {
    const [updated] = await db
      .update(applications)
      .set({
        status: input.nextStatus,
        notes: input.notes ?? record.notes,
      })
      .where(whereRecord!(applications))   // firewall + soft-delete + id, all in one
      .returning();
    return updated;
  },
});
```

The audit DB wrapper hard-stamps `modifiedAt`/`modifiedBy` on every
`.set()` call, so handlers don't write them. The same wrapper hard-stamps
`createdAt`/`createdBy`/`modifiedAt`/`modifiedBy` on every `.values()`.
Caller-supplied audit fields are dropped on the floor — the audit log
always reflects the real actor and the real moment.

`whereRecord` is undefined for standalone actions (they have no record), so
the parameter is typed as optional. `whereTransition` extends `whereRecord`
with the transition's "from" precondition for transition actions.

## HTTP API Reference

### Request Format

| Method | Input Source | Use Case |
|--------|--------------|----------|
| `GET` | Query parameters | Read-only operations, fetching data |
| `POST` | JSON body | Default, state-changing operations |
| `PUT` | JSON body | Full replacement operations |
| `PATCH` | JSON body | Partial updates |
| `DELETE` | JSON body | Deletion with optional payload |

### Response Formats

| Type | Content-Type | Use Case |
|------|--------------|----------|
| `json` | `application/json` | Standard API responses (default) |
| `stream` | `text/event-stream` | Real-time streaming (AI chat, live updates) |
| `file` | Varies | File downloads (reports, exports) |

### Error Codes

| Status | Description |
|--------|-------------|
| `400` | Invalid input / validation error (also `GUARD_FIELD_PROTECTED` for direct protected-field writes) |
| `401` | Not authenticated |
| `403` | Access role check failed (`ACCESS_ROLE_REQUIRED`) |
| `404` | Record not found (record-based actions) |
| `409` | Record is in the wrong state for this action — `access.record` predicate or `transition` policy failed (`ACCESS_ACTION_NOT_ALLOWED_FOR_STATE`) |
| `500` | Handler execution error |

### Validation Error Response

```json
{
  "error": "Invalid request data",
  "layer": "validation",
  "code": "VALIDATION_FAILED",
  "details": {
    "fields": {
      "amount": "Expected positive number"
    }
  },
  "hint": "Check the input schema for this action"
}
```

### Error Handling

**Option 1: Return a JSON error response** (recommended for most cases)

Since action handlers receive the Hono context (`c`), you can return error responses directly:

```typescript
async execute({ ctx, record, input, c }) {
  if (record.stage === 'hired') {
    return c.json({
      error: 'Cannot modify a hired application',
      code: 'ALREADY_HIRED',
      details: { currentStage: record.stage },
    }, 400);
  }
  // ... continue
}
```

**Option 2: Throw an ActionError**

```typescript
import { ActionError } from "@quickback/types";

async execute({ ctx, record, input }) {
  if (record.stage === 'hired') {
    throw new ActionError('Cannot modify a hired application', 'ALREADY_HIRED', 400, {
      currentStage: record.stage,
    });
  }
  // ... continue
}
```

The `ActionError` constructor signature is `(message, code, statusCode, details?)`.

## Protected Fields

Actions can modify fields that are protected from regular CRUD operations:

```typescript
// In the table file (defineTable config)
guards: {
  protected: {
    status: ["advance", "reject", "hire", "withdraw"],  // Only these actions can modify status
  }
}
```

This allows the `advance` action to set `status = "interview"` even though the field is protected from regular PATCH requests. Direct `PATCH /:id` attempts to set `status` are rejected by the guards layer with **400 GUARD_FIELD_PROTECTED**, and the field is omitted from the generated POST/PATCH body Zod schemas — clients cannot even send it past validation.

## Transition (state-machine policy)

For protected fields that follow a state machine — application status, invoice
lifecycle, order fulfilment — use `transition` on the action so the compiler
generates a runtime guard that enforces the *from/to pair*, not just the
current state. Without it, an `advance` action whose input enum allows
`["screening", "interview", "offer"]` and whose `access.record` allows
`status in ["applied", "screening", "interview"]` will happily run
`applied → offer` (a skip) or `interview → screening` (a regression).

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

export default defineAction({
  description: "Move an application forward in the pipeline.",
  input: z.object({ nextStatus: z.enum(["screening", "interview", "offer"]) }),
  access: { roles: ["owner", "admin"] },
  transition: {
    field: "status",
    via: "nextStatus",      // read target from input.nextStatus
    fromTo: {
      applied:   ["screening"],
      screening: ["interview"],
      interview: ["offer"],
    },
  },
  async execute({ db, record, input, whereTransition }) { /* … */ },
});
```

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

export default defineAction({
  description: "Finalize an offer.",
  input: z.object({}),
  access: { roles: ["owner"] },
  transition: {
    field: "status",
    to: "hired",            // fixed target — same value every call
    fromTo: { offer: ["hired"] },
  },
  async execute({ db, record, whereTransition }) { /* … */ },
});
```

| Field | Required | Description |
|---|---|---|
| `field` | Yes | Record column to validate (e.g. `"status"`) |
| `fromTo` | Yes | Map of current value → permitted next values. Keys also enumerate the legal source states. |
| `via` | Yes (or `to`) | Input key that carries the target value. Use for variable-target actions like `advance`. |
| `to` | Yes (or `via`) | Literal target value the action always writes. Use for fixed-target actions like `hire`/`reject`. |

Exactly one of `via` or `to` must be provided. The compiler rejects both at
validate time. When `to` is set, it must appear in some `fromTo[*]` array —
otherwise the action would be unreachable, which is almost certainly a bug.

### Wire-level errors

The compiled guard returns **409 `ACCESS_ACTION_NOT_ALLOWED_FOR_STATE`** with
the field, the current value, the requested target, and the allowed set:

```json
{
  "error": "Action not allowed for current record state",
  "layer": "access",
  "code": "ACCESS_ACTION_NOT_ALLOWED_FOR_STATE",
  "details": {
    "field": "status",
    "current": "interview",
    "target": "screening",
    "allowedTargets": ["offer"]
  },
  "hint": "From \"interview\", status can transition to: offer"
}
```

This is distinct from a role failure: if the caller has the wrong role they
get **403 `ACCESS_ROLE_REQUIRED`** as before. Splitting the two lets clients
tell *"you can't do this"* from *"you can't do this *yet*"* — the latter is
recoverable by the user, the former isn't.

The same 409 code is used when an `access.record` predicate (without
`transition`) fails — for example `record: { status: { equals: "pending" } }`
called against a paid invoice now returns 409 `ACCESS_ACTION_NOT_ALLOWED_FOR_STATE`,
not a misleading 403.

## Examples

### Bulk Import (Standalone, No Record)

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

export default defineAction({
  description: "Import candidates from job board CSV",
  path: "/candidates/import",
  input: z.object({
    data: z.array(z.object({
      email: z.string().email(),
      name: z.string(),
    })),
  }),
  access: { roles: ["hiring-manager", "recruiter"] },
  async execute({ db, input }) {
    const inserted = await db
      .insert(candidates)
      .values(input.data)
      .returning();
    return { imported: inserted.length };
  },
});
```
