Workflows
Work that outlives the request, the deploy, and the isolate that started it.
A workflow is orchestration whose only progress is what its store recorded.
Driving one means running the body from the top with every recorded step
returning its result instead of running again — which is what lets a run survive
a deploy, and why the body must be deterministic and reach the outside world
only through step.
export const advisory = workflow({
name: "advisory",
permission: "runs:start",
args: advisoryArgs,
returns: briefId,
handler: async (step, args) => {
const snapshot = await step.run("snapshot:v1", () => snapshotAccount(args));
const brief = await step.run("advise:v1", () => advise(snapshot));
await step.sleep("settle:v1", 60_000);
return step.run("publish:v1", () => publishBrief(brief));
},
});Start one with startWorkflow(name, args, identity, runId). The runId is the
caller's idempotency key: calling twice with the same one drives the same run
rather than creating a second, which is what makes a retried cron tick or a
redelivered queue message safe.
Rules
- Step IDs must carry a version (
snapshot:v1) and are immutable once a version has run. Renaming, reordering, or changing what a step means needs a new workflowversion; a run started under an older one is refused rather than driven by code that disagrees with it. - Nothing nondeterministic in the body. No
Date.now(), noMath.random(), no barefetch— usestep.now()and put effects inside named steps. - A step's
returnsschema is what validates its recorded output on replay. Without one, a value that crossed storage is trusted unchecked.
Interrupts
step.interrupt() stops the run until somebody answers. The interrupt is one
durable row and its resolution is a conditional transaction, so concurrent
answers produce exactly one winner and the rest are told conflict.
const decision = await step.interrupt("approve:v1", { kind: "refund", request }, approvalSchema);resolvedBy is taken from the verified identity and never from the payload —
who approved something is the one field an operator UI must not get to choose.
Who may start one
Starting a run is an authorization decision even though driving one cannot be: a run spends money, calls tools, and can ask a person to approve an effect. Tenant membership is not an answer to it.
createTenantObject({
permissions,
workflows: { registry, startPermission: "runs:start", resolvePermission: "runs:approve" },
});Both permissions are required, and so is a permissions table — declaring
workflows without one is a build error rather than a default that lets everyone
through. A workflow naming its own permission is checked against that instead
of the floor. A permission no role can hold is refused at construction, since
its runtime symptom is every caller denied, silently.
Where a run lives
Runs, steps, and interrupts are ordinary tenant rows, so spread workflowTables
into defineSchema() — omitting them is a build error rather than a workflow
that starts, writes nowhere, and reports success. Because they are ordinary
rows, a dashboard watching a run updates through the same reactive machinery a
mutation uses, and the object's own alarm drives whatever is due.
A run also carries the trace of the call that started it, so a workflow that sleeps for three days resumes still attached to that story. Replayed steps record nothing. → Tracing
Stopping a run
await tenant.cancelWorkflowRun(runId, identity, "superseded by a newer brief");A budget bounds what a run may spend; nothing bounds a run somebody simply no longer wants. Cancelling is the only way out of one that is not waiting on an interrupt.
It is idempotent, and safe on a run that already finished — cancelling a completed run reports what it was rather than rewriting its result. Any open interrupt is closed first, so nobody can answer an approval afterwards and restart a run that was stopped.
A run being driven right now cannot be interrupted from another isolate. It is marked here and stops at its next checkpoint: the step it is inside finishes, and no further step begins.