# Defining Actions

## Defining Actions

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

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(),
  }),
  output: z.object({
    id: z.string(),
    status: z.enum(["screening", "interview", "offer"]),
  }),
  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. It infers `record` and Zod `input`, exposes the generated
> `Services`, types `c` as a Hono request context, binds `whereRecord` and
> `whereTransition` to the feature table, and checks the value returned by
> `execute` against `output` when you declare one. Scoped
> `db` and interactive `tx` are schema-aware on D1 and Neon Hyperdrive;
> HTTP/WebSocket Neon keeps its dynamic database escape hatch. `AppContext`
> also retains its open index for namespace-hydrated fields. 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.

> **Your editor is typed too.** `quickback compile` writes the same typed
> helpers into `quickback/features/<feature>/.quickback/` (plus a
> `quickback/tsconfig.json`), so the import above resolves — and typechecks —
> in the tree you actually edit, not just in the generated output. The helper
> also **re-exports the feature's table** typed with the generated schema, so
> compiler-managed columns (`createdAt`, `modifiedBy`, `deletedAt`,
> `organizationId`) are visible on it without casts:
>
> ```typescript
> import { defineAction, applications } from "../.quickback/define-action";
> ```
>
> Prefer this single import over the older
> `import { applications } from "../applications"` — that path resolves to
> your authored file at edit time, which has no named export and none of the
> injected columns. The helper files are regenerated on every compile; keep
> `quickback/features/*/.quickback/` in `.gitignore` (new projects get this
> automatically).

### Local action-schema harvest

Before sending a compile request, the CLI loads each action in an isolated
local bundle and converts `action.input` plus an optional `action.output` with
Zod 4's `z.toJSONSchema`. It does not call `execute`; the hosted compiler never
evaluates project code. The output contract feeds OpenAPI and
`Api.<feature>.actions.<action>.Output` in `quickback.gen.ts`.

**Every action input must produce a schema.** Since
v0.63.0 a failed harvest fails the compile, naming the exact action, whether
bundling or evaluation failed, and the underlying local error. Output remains
optional.

**Why it fails instead of degrading.** The compiler's static fallback parser
reads your Zod as text and can only produce `type` and `properties`. It never
emits `required` or `additionalProperties` — so a fallback request schema looks
fully typed in `openapi.json` and the MCP tool list while enforcing nothing:
generated clients treat every required field as optional, and unknown keys pass.
Constructs it cannot parse at all degrade further, to a bare `{}` with no
`properties`. Neither is visible in a diff.

`--allow-degraded-schemas` restores the old degrade-and-continue behavior for a
single compile. It still prints every action that fell back and why, and the
compiler warns again with the count and sampled paths.

Bare imports are resolved from the generated runtime's configured
`build.outputDir/node_modules` first. If definitions live in `quickback/` and
the generated Worker lives in `src/`, install runtime dependencies in `src/`
as usual; no project-root dependency symlink is required. Shared schema or DTO
modules may also declare top-level Drizzle selection objects. Quickback
replaces authored table exports with inert proxies during this schema-only
evaluation, so reading `records.id` to build a projection does not execute
application or database logic. A schema **derived** from a table enumerates
nothing under that substitution — see
[Declaring action outputs](#declaring-action-outputs).

### Declaring action outputs

`output:` is optional, and omitting it degrades quietly: the operation's
OpenAPI success response becomes `{}` and
`Api.<feature>.actions.<action>.Output` becomes `unknown`. The compile still
succeeds — it warns, naming how many actions are affected.

**Declare the output shape as a literal Zod schema.** Do not derive it from a
table:

```typescript
// Silently produces an EMPTY contract — do not do this.
output: z.object({ tickets: z.array(createSelectSchema(ticketTypes)) })

// Documents the response.
output: z.object({
  tickets: z.array(z.object({ id: z.string(), name: z.string(), priceCents: z.number() })),
})
```

The harvest substitutes inert proxies for table modules, so anything that
derives a schema by enumerating a table's columns — `createSelectSchema` and
every equivalent — enumerates **zero** columns. It does not throw. It returns a
valid, empty object schema, which is then published as the documented success
response. The compiler warns when a declared `output:` resolves to an empty
object, but the warning is the only signal; the compile passes and the spec
looks fine.

The proxy substitution has one exception, and it is not a supported escape
hatch: a table module that also contains a `z.object(...)` is bundled and
evaluated for real, so a derivation in that file happens to work. Whether it
works depends on unrelated content in the table file — declare the shape
instead.

**`output` describes the success path only.** Error responses never pass
through it: throw
[`ActionError`](/api/actions#error-handling) and the route returns
`{ error, code, details }` with your status code — that error envelope is part
of the generated contract already, so do not fold error variants into
`output`. The one exception is an endpoint whose *success* body is
deliberately a status report (a webhook receiver acknowledging
`{ received: false, reason }`, say) — that is a union of success shapes, and
declaring it as `z.discriminatedUnion(...)` in `output` is correct. Do not
return an error object (or a `Response`) from `execute` to signal failure —
see [Error Handling](/api/actions#error-handling) for what actually happens.

Parameterized standalone paths use Hono's `:param` syntax in authored source,
for example `path: "/events/:eventId/check-in"`. The harvest planner converts
those segments to the OpenAPI `{param}` form before applying the schema, so
completeness uses the same route key as the emitted OpenAPI and MCP
surfaces. The same normalization applies when a record resource path contains
namespace parameters.

### Configuration Options

| Option | Required | Description |
|--------|----------|-------------|
| `description` | Yes | Human-readable description of the action |
| `input` | Yes | Zod schema for request validation |
| `output` | No | Portable Zod schema for the JSON success response. Generates action output types and constrains `execute`'s result. |
| `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. Lives on a record-based action, or on a standalone action paired with a `record:` binding. See [Transitions](/define/transitions) |
| `record` | Standalone only | Feature-area transition adapter binding: `{ table, idFrom: "path.<param>", matchScope? }`. Binds + firewalls the record a `transition` operates on from a proven route param. See [Feature-area transition adapter](#feature-area-transition-adapter) |
| `bulkVariant` | No | Auto-generate a `POST /:resource/batch/{action}` route alongside the per-record route (record-based actions). See [Bulk Variant](#bulk-variant) |
| `webhook` | No | Standalone only. Compile-declared inbound signature verification (`standard-webhooks`, `hmac-sha256`, or `aws-sns`) that runs on the raw bytes **before** parse/access — see [Inbound webhooks](/platform/webhooks/inbound) |
| `idempotency` | No | `'dedupe'` — PUBLIC-action opt-in for the `Idempotency-Key` header with dedupe-only semantics (duplicate ⇒ `409` reference; the cached body is never served to a second caller). Standalone actions with a **static** path only (a `:param` path is a compile error — the allowlist matches literal request paths) and the action's access must admit `PUBLIC`. Authenticated routes accept the header automatically — see [API contract](/platform/api-contract) |
| `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.

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

## Refs (scoped foreign-key inputs)

The most common action preamble — load an input id, 404 if missing, assert it
belongs to the caller's event/org — becomes declarative:

{/* doc-compile: skip — the `refs:` block is the point, and every ref names a sibling table (`agendaItems`, `guests`, `locations`) plus the feature's own primary table. Declaring four tables here would bury the twelve lines the section is about; the whole-file shape lives in the record-action example above. */}
```typescript
// features/agenda/actions/assignGuest.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Assign a guest to an agenda item, optionally at a specific location.",
  access: { roles: ["admin", "member"] },
  input: z.object({ agendaItemId: z.string(), guestId: z.string(), locationId: z.string().optional() }),
  refs: {
    agendaItemId: { table: "agendaItems", as: "agendaItem",
                    matchScope: "event",                       // row.eventId === ctx.event.id
                    notFound: { code: "ITEM_NOT_FOUND" } },    // status defaults to 404
    guestId:      { table: "guests", as: "guest",
                    matchWith: { ref: "agendaItem", on: "eventId" },  // cross-input parenthood
                    mismatch: { code: "EVENT_MISMATCH", status: 400 } },
    locationId:   { table: "locations", as: "location", optional: true, matchScope: "event" },
  },
  execute: async ({ input, agendaItem, guest, location }) => { /* … */ },
});
```

- Loads run **through the scoped db** before `execute` — the org firewall and
  soft-delete filter apply by construction, so a cross-org id 404s with zero
  hand-written tenancy checks. All loads batch into one `Promise.all`.
- `matchScope: "<scope>"` asserts `row.<scope>Id === ctx.<scope>.id` and
  fail-closes (500) if the scope object isn't hydrated. The explicit form
  `{ column, equals: "ctx.activeOrgId" }` is also accepted.
- `matchWith` compares a column against an earlier-declared ref — declaration
  order defines resolution order; forward references are compile errors.
- Ref keys must exist on the inline `z.object` input with agreeing
  optionality; `as` names can't shadow reserved context keys. Injected rows
  are the **unmasked** DB rows (masking remains a read-projection concern).

