# Record and Standalone Actions

## 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. **Input Validation** - Request body validated against Zod schema
3. **Role Gate** - The access roles are checked *before* the record is
   fetched (v0.46+), so a wrong-role caller gets a uniform
   `403 ACCESS_ROLE_REQUIRED` whether or not the record exists — the
   response can't be used to probe for record ids
4. **Record Loading** - The record is fetched by ID **through the firewall**
   (a cross-tenant or missing id is a standard firewall miss)
5. **Access Check** - Record preconditions (`access.record`) and transition
   policy are validated against the loaded record
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, applications } from "../.quickback/define-action";

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
import { z } from "zod";
import { defineAction, applications, TransitionLostError } from "../.quickback/define-action";

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.

The generated OpenAPI document and MCP tools reuse that exact action schema
in both places: as the single-record request body and as the bulk wrapper's
nested `input` property. Quickback considers the action's
schema harvest complete only after both operations are patched. This also
holds when the table filename and declared table name use different casing or
separators (for example, `event-guests.ts` exporting `event_guests`); the
operations follow the table's declared resource path.

**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 |
|---|---|---|---|
| tx-supporting (postgres, neon over websocket) | `true` | `true` | `db.transaction()` wraps the loop; any throw rolls back all writes |
| (any) | `false` | `false` | Independent per-record outcomes, partial success |
| cloudflare-d1, neon over http (Cloudflare default) | `true` | `false` | Fail-fast loop without rollback — earlier records stay committed (the driver has no `db.transaction`; `drizzle-orm/neon-http` throws at runtime) |

**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
```

Paths are absolute (`/...`). Inside a [feature area](/define/areas)
with a mounted prefix, two more forms apply:

- **Relative** — `path: './tasks/add'` resolves against the area's prefix
  (`/project/:projectId/tasks/add`). Plain descending segments only; `.` / `..` segments are
  rejected at compile time.
- **Self-gated** — an absolute path *outside* the area's prefix opts the action out of the
  area's admission gate and MUST carry `route: 'self'` (fail-closed; the marker is rejected
  where it would be dead). The action is then gated solely by its own `access:` and listed in
  `quickback/authz-manifest.json`.

An action's `access:` and `anchor:` may reference vocabulary declared by an ancestor area —
and never vocabulary of an area the feature is not inside (compile error).

### Feature-area transition adapter

A record [state transition](/define/transitions) normally lives on a
table-bound record action (`POST /:id/{action}`). When your clients call a nested
**feature-area** route instead, you can attach the same compiler-owned transition to
an explicit (`path:`) action by adding a `record:` binding. The binding is what lets a
`transition` ride a standalone action — without it, `transition` on a `path:` action is
a compile error.

```typescript
// features/events/event_documents.ts
import { q, defineTable } from "@quickback/compiler";

export const event_documents = q.table("event_documents", {
  id:             q.id(),
  eventId:        q.text().required(),
  status:         q.text().default("draft").required(),
  publishedAt:    q.text().optional(),
  organizationId: q.scope("organization"),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(event_documents, {
  firewall: [{ field: "organizationId", equals: "ctx.activeOrgId" }],
});

// features/events/event_documents/actions/publishEventDocument.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  path: "/events/:eventId/documents/:documentId/publish",
  method: "POST",
  description: "Publish one completed event document.",
  input: z.object({ idempotencyKey: z.string().min(8) }),
  access: { roles: ["member+"] },
  // The compiler — not the action body — binds and firewalls the record.
  record: {
    table: "event_documents",
    idFrom: "path.documentId",   // MUST be a route param, never a body field
    matchScope: "event",          // row.eventId === ctx.event.id (area-proven)
  },
  transition: {
    field: "status",
    to: "published",
    fromTo: { draft: ["published"] },
    idempotent: "noop",
    stamp: { at: "publishedAt" },
  },
  async execute({ record, invalidate }) {
    // `record` is already bound, firewalled, area-matched, and transitioned.
    // Own the response projection + invalidations here.
    return { success: true as const, document: record };
  },
});
```

Security semantics (all enforced by the compiler, fail-closed):

- The record id is bound from the **proven route param** named in `idFrom` (`path.<param>`);
  a request-body source is rejected at compile time.
- The row is loaded through the **scoped db**, so the table's full firewall
  (org / owner / team + soft-delete) applies — a cross-tenant id is a `404`, never a leak.
- `matchScope` asserts the row belongs to the **area-proven** scope (e.g. `:eventId`
  hydrated onto `ctx.event`); an unhydrated scope fails closed (`500`), and a row under a
  different scope is a `404` (no cross-scope existence oracle).
- The transition keeps its **TOCTOU-safe guarded UPDATE** — the from-state is re-validated
  in the `WHERE`, and a lost race returns `409` `ACCESS_TRANSITION_LOST`.
- Area admission **and** the action's own `access:` still AND-compose; the adapter widens neither.
- `idempotent: "noop"` short-circuits **before** `execute`, so a no-op never fires the
  action's invalidations. Existing record actions and table-level transitions are unchanged.

### 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/
        ├── trialBalance.ts         ← standalone (has path:)
        └── profitLoss.ts           ← standalone (has path:)
```

Action filenames are the action's identifier and must be valid camelCase — a
hyphen is a compile error. The URL in `path:` is independent and may be kebab-case.

```typescript
// features/reports/actions/trialBalance.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
// features/search/actions/search.ts — input populated from ?q=…&limit=…
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Full-text search across the workspace, newest match first.",
  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.

