WORKFLOWS

State transitions,
built in.

A Quickback workflow is an Action plus a protected field plus a record-level condition. Define it once. The builder 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 builder 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 build 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 builder 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 builds 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 buildHand-rolledQuickback workflow
Block direct writes to statusValidation middleware, easy to forget on a new route.protected() on the column. The build refuses to emit a CRUD route that touches it.
Reject invalid transitionsIf/else in each handler, repeated logic, drift over timeaccess.record rule, declared once next to the Action
Role gate at every stepAuthorization middleware, separate from the state-machine codeaccess.roles on the same Action — one source of truth
Tenant scoping on transitionsManually thread orgId into every queryAuto-derived from q.scope(). Applied to every Action.
Same workflow, AI agent surfaceBuild & maintain a separate MCP server, keep tool defs in syncBuilds into MCP tools automatically. No drift possible.
Same workflow, admin UIBuild dashboards, build forms, recheck rules in client codeQuickback CMS auto-renders forms, buttons, and gates from the schema

It's not that you can't build this. It's that the build 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