WORKFLOWS

State transitions,
compiled.

A Quickback workflow is an Action plus a protected field plus a record-level condition. Define it once. The compiler enforces who can transition, when, and which fields can change at each step.

See the shape

The shape of a workflow

Three primitives, composed in your schema file. Each one already exists in Quickback. Together, they describe a state machine the compiler can enforce — without you wiring middleware, validators, or guards by hand.

Primitive 1

Action

A typed transition: advance, submit, approveInvoice. Has an input schema, an access rule, and an execute handler that returns the new state.

Primitive 2

Protected field

A column the client can never set directly — like stage or status. The only way it changes is through an Action's return value. The compiler refuses to emit a CRUD route that touches it.

Primitive 3

Record-level condition

A precondition tied to the row itself — { stage: { not: 'paid' } }. Determines which Actions are even callable on the current state.

Roles say who. Record conditions say when. The Action's execute says what the next state is. Protected fields make sure no one else can write it.

Worked example

Invoice approval

Four states. Three transitions. Different roles allowed at each step. Different fields allowed to change. Written as one schema file.

The state machine

draft — submit → submitted — approve → approved — pay → paid

A manager approves. A finance role pays. Nobody edits status directly.

quickback/features/invoices/invoices.ts
import { feature, q, z } from "@quickback/compiler";

export default feature("invoices", {
  columns: {
    id:             q.id(),
    amountCents:    q.int().required(),
    vendor:         q.text().required(),
    status:         q.enum(["draft", "submitted", "approved", "paid"]).default("draft").protected(),
    approvedBy:     q.text().optional().protected(),
    paidAt:         q.datetime().optional().protected(),
    organizationId: q.scope("organization"),
  },

  // Only "draft" fields are client-writable. status / approvedBy / paidAt
  // are .protected() — they can't be set by CRUD, only by an Action.
  guards: {
    createable: ["amountCents", "vendor"],
    updatable:  ["amountCents", "vendor"],
  },

  actions: {
    submit: {
      description: "Submit a draft invoice for approval.",
      access: {
        roles:  ["member+"],
        record: { status: { equals: "draft" } },
      },
      execute: async () => ({ status: "submitted" }),
    },

    approve: {
      description: "Approve a submitted invoice.",
      access: {
        roles:  ["manager+"],
        record: { status: { equals: "submitted" } },
      },
      execute: async ({ ctx }) => ({
        status:     "approved",
        approvedBy: ctx.user.id,
      }),
    },

    pay: {
      description: "Mark an approved invoice as paid.",
      input: z.object({ externalRef: z.string() }),
      access: {
        roles:  ["finance"],
        record: { status: { equals: "approved" } },
      },
      execute: async () => ({
        status: "paid",
        paidAt: new Date().toISOString(),
      }),
    },
  },
});

What the compiler enforces — for free

  • PATCH /invoices/:id rejects any payload that touches status, approvedBy, or paidAt
  • Calling approve on a draft invoice returns a structured precondition error — not a 500
  • A member role calling approve hits the access gate before the handler runs
  • Tenant scope (q.scope("organization")) is auto-applied to every transition — no cross-tenant approval possible
  • The same workflow ships as a REST endpoint, an MCP tool, and an admin UI button — same checks every time

One workflow. Every surface.

Each Action in your workflow compiles into a typed REST endpoint, a tool in your MCP server, and a button in the Quickback CMS. Same access rule, same record condition, same protected-field guarantees — across every caller.

Your support team approves invoices from the dashboard. Your AP automation calls the same Action via the API. Your AI agent triggers it as an MCP tool. None of them can skip a state.

How an Action ships everywhere

vs. rolling your own

What you'd build Hand-rolled Quickback workflow
Block direct writes to status Validation middleware, easy to forget on a new route .protected() on the column. Compiler refuses to emit a CRUD route that touches it.
Reject invalid transitions If/else in each handler, repeated logic, drift over time access.record rule, declared once next to the Action
Role gate at every step Authorization middleware, separate from the state-machine code access.roles on the same Action — one source of truth
Tenant scoping on transitions Manually thread orgId into every query Auto-derived from q.scope(). Applied to every Action.
Same workflow, AI agent surface Build & maintain a separate MCP server, keep tool defs in sync Compiles to MCP tools automatically. No drift possible.
Same workflow, admin UI Build dashboards, build forms, recheck rules in client code Quickback CMS auto-renders forms, buttons, and gates from the schema

It's not that you can't build this. It's that the compiler refuses to let it drift.

Try it

Scaffold a Hono API with a workflow in under 60 seconds. Add the MCP target with one flag. Add the CMS with one command. Same definitions, every surface, every state guarded.

npx @quickback-dev/cli create cloudflare my-app
How the policy engine works