HighX
Server

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 workflow version; 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(), no Math.random(), no bare fetch — use step.now() and put effects inside named steps.
  • A step's returns schema 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.

On this page