# Transitions

## Overview

Most state changes follow the same shape: check the row is in a legal state,
flip a column, stamp who/when, maybe cascade. Declare that shape on the table
and the compiler generates the action files — including `undo` inverses and
`onEnter` cascades.

Two declaration sites:

- **Table-level `transitions`** on `defineTable` — the compiler generates the
  actions. Start here.
- **Action-level `transition`** on `defineAction` — the same guards, stamps,
  idempotency, and compiler-applied writes, for an action you are writing
  anyway.

## Two lines per state change

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

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

export default defineTable(episodes, {
  transitions: {
    publish:       { field: "isPublished", from: false, to: true,  access: { roles: ["admin+"] } },
    revertToDraft: { field: "isPublished", from: true,  to: false, access: { roles: ["admin+"] } },
  },
});
```

The generated actions mount exactly where an authored file would
(`POST /episodes/:id/publish`, operationId `episodesPublish`, same MCP tool
name), ride the same OpenAPI/security-contract pipeline, and delete cleanly:
**an explicit `actions/<name>.ts` file always wins** (the compiler warns about
the shadowed transitions entry).

## Action-level extensions

All new keys are optional — a plain `{ field, fromTo, to|via }` transition
behaves exactly as before.

```typescript
transition: {
  field: "status",
  to: "confirmed",
  fromTo: { pending: ["confirmed"] },
  // v2:
  guard: {                                  // extra row preconditions
    status: "confirmed",                    // equality
    checkedInAt: { null: true },            // or { notNull: true }
    custom: (record) => record.eventId !== null,  // escape hatch; may throw ActionError
  },
  onIllegal: { code: "GUEST_NOT_CONFIRMED", status: 409, message: "Only confirmed guests can be checked in" },
  idempotent: "noop",                       // already-in-target → { success: true, unchanged: true }
  stamp: { at: "checkedInAt", by: "checkedInById" },  // now-ISO / ctx.userId
  clears: ["checkedOutAt", "checkedOutById"],         // nulled on transition
}
```

Semantics:

- **Guards** run against the fetched record *and* fold into the UPDATE's
  WHERE, so preconditions re-validate atomically at the SQL layer (an empty
  `RETURNING` maps to 409 `ACCESS_TRANSITION_LOST`). Guard failures return
  `onIllegal` (default 409 `TRANSITION_GUARD_FAILED`); on bulk twins they
  report per-id in the batch envelope.
- **Compiler-applied writes**: declaring `stamp`/`clears` (or
  `applyBefore: true`) makes the compiler execute the UPDATE itself before
  `execute` — and `execute` becomes optional. An omitted `execute` returns
  `{ success: true, <field>: <to>, <stamp.at>: <now> }`. Set
  `applyBefore: false` to keep the write in your own `execute` (today's
  behavior).
- **`field: null`** declares a pure stamp transition (check-in/check-out
  style) — guards + stamps with no state column.
- **`actx.now`**: every action's execute context now carries `now` — one ISO
  timestamp per invocation. Prefer it over `new Date().toISOString()`.

### On a feature-area route

A transition normally lives on a table-bound record action (`POST /:id/{action}`).
To attach the same compiler-owned transition to an explicit (`path:`) action mounted
under a [feature area](/define/areas) — a nested route like
`POST /events/:eventId/documents/:documentId/publish` — pair the `transition` with a
`record:` binding. The compiler binds the record id from the proven route param,
firewalls + area-matches the row, then runs the exact machinery above (guarded UPDATE,
noop short-circuit, guards, stamps) before the action's own `execute`. See
[Feature-area transition adapter](/define/actions/record-and-standalone#feature-area-transition-adapter).

## Table-level generation

{/* doc-compile: skip — three project-config prerequisites this fence cannot carry: the `member+`/`admin+` hierarchy suffixes need `auth: { roleHierarchy: [...] }`, the `onEnter` update step targets a cross-feature `eventGuests` table, and the `enqueue` step names a `COMMS_QUEUE` binding. Each lives in quickback.config.ts or a sibling feature; the gate compiles each fence as an isolated project against one fixed harness config. */}
```typescript
export default defineTable(guests, {
  // ...
  transitions: {
    checkIn: {
      field: null,                          // pure stamp transition
      guard: { status: "confirmed", checkedInAt: { null: true } },
      stamp: { at: "checkedInAt", by: "checkedInById" },
      undo: "cancelCheckIn",                // generates the inverse
      access: { roles: ["member+"] },
      bulkVariant: true,
    },
    cancel: {
      field: "status",
      fromTo: { published: ["cancelled"] },
      to: "cancelled",
      stamp: { at: "cancelledAt" },
      access: { roles: ["admin+"] },
      onEnter: [
        { update: "eventGuests",
          set: { status: "rescinded" },
          where: { eventId: "$record.id", status: { in: ["invited", "confirmed"] } } },
        { enqueue: { queue: "COMMS_QUEUE",
          message: { kind: "eventCancelled", eventId: "$record.id", actorId: "$ctx.userId" } } },
      ],
    },
  },
});
```

- **`undo: "<name>"`** generates the inverse action: the guard flips to
  `{ notNull: true }` on the stamp column, the SET nulls the stamp columns,
  and `from`/`to` reverse for field transitions.
- **`onEnter` steps** run in the generated `execute`, *after* the
  compiler-applied primary write (parent row first). `update` steps go
  through the scoped db — cross-feature targets resolve automatically and
  inherit the firewall. `enqueue` steps ride the after-commit effects queue.
  Substitution values are a strict allowlist — `$record.<col>`,
  `$ctx.userId`, `$ctx.activeOrgId`, `$ctx.<scope>.id` — always
  parameterized, never interpolated into SQL.
- **`access` is required** on every transition (fail-closed, like
  `defineAction`).
- Boolean `from` values match integer-boolean columns too (`from: false`
  matches both `false` and `0`).

### Not yet supported (fail loudly)

`guard.custom` at table level, `onEnter.enqueue` with `perRowOf` or function
messages, and via-style (input-driven) targets all error with a pointer to a
hand-written action. For manual fan-out enqueues, `sendQueueBatched` in
`lib/effects` chunks at the Cloudflare Queues 100-message cap.

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