Defining workflows

The *.workflow.tsx convention, workflow schemas, execute factories, checkpointed steps, child workflows, and replay rules.

A workflow file is discovered by suffix: *.workflow.tsx. It exports one workflow({...}) definition and a default factory that receives AppContext and returns the workflow executor.

Generated workflow RPC bindings preserve the literal workflow name and payload schema. Running voltro codegen therefore retains typed access to workflow starts and the other procedures in the same client.

Anatomy

// apps/api/workflows/notes.summarise.workflow.tsx
import { workflow, step } from '@voltro/workflow'
import { Effect, Schema } from 'effect'
import type { AppContext } from '@voltro/runtime'

export const SummariseNote = workflow({
  name: 'notes.summarise',
  payload: { noteId: Schema.String },
  success: Schema.Struct({ summary: Schema.String }),
  idempotencyKey: ({ noteId }) => `notes.summarise:${noteId}`,
})

const buildExecute = (ctx: AppContext) =>
  ({ noteId }: { noteId: string }, _executionId: string) =>
    Effect.gen(function* () {
      const note = yield* step({
        name: 'load-note',
        input: { noteId },
        success: Schema.Struct({ id: Schema.String, body: Schema.String }),
        execute: Effect.tryPromise(() => loadNote(ctx.store, noteId)),
      })

      const summary = yield* step({
        name: 'summarise-with-llm',
        input: { noteId },
        success: Schema.String,
        execute: summariseWithLlm(note.body),
      })

      yield* step({
        name: 'save-summary',
        input: { noteId },
        success: Schema.Void,
        execute: Effect.tryPromise(() => saveSummary(ctx.store, noteId, summary)),
      })

      return { summary }
    })

export default buildExecute

Two exports matter:

  • workflow({...}) — the durable definition. payload, success, optional error, and optional idempotencyKey are read by codegen and the runtime.
  • default — a build-execute factory, (ctx: AppContext) => (payload, executionId) => Effect. The CLI calls definition.toLayer(execute) during registration.

If the default export is not a function, discovery skips the file with a "missing default-export build-execute factory" warning.

Schema fields

export const ImportCustomers = workflow({
  name: 'customers.import',
  payload: {
    uploadId: Schema.String,
    dryRun: Schema.optional(Schema.Boolean),
  },
  success: Schema.Struct({
    imported: Schema.Number,
    skipped: Schema.Number,
  }),
  error: Schema.Union(InvalidCsv, ImportProviderDown),
  idempotencyKey: ({ uploadId }) => `customers.import:${uploadId}`,
  messages: {
    signals: {
      approval: Schema.Struct({ approved: Schema.Boolean }),
    },
    updates: {
      approve: {
        payload: Schema.Struct({ decision: Schema.Boolean }),
        success: Schema.Struct({ accepted: Schema.Boolean }),
      },
    },
  },
})

payload is the start input. success is the resolved output. error is the typed failure channel. idempotencyKey deduplicates concurrent or repeated starts with the same logical input; make it a pure function of the payload, because the execution id is derived from it. A key that varies per call (a timestamp, a random suffix) makes every start a new run, which is sometimes what you want, but never a deduplication. messages is optional codegen metadata; it emits WorkflowSignals, WorkflowUpdates, and WorkflowMessages type maps, while runtime validation still happens at awaitSignal(...) / awaitUpdate(...).

There are exactly two message channels: signals (fire-and-forget) and updates (synchronous, with a result). A queries channel was declarable until 0.34.0 and never had a send path — nothing could invoke one — so it is gone. To read a run's state, write an ordinary *.query.ts over _voltro_workflow_runs / _voltro_workflow_run_steps; to ask a running workflow something and get an answer, use updates.

Step boundaries

step({...}) wraps @effect/workflow's Activity.make. It is the checkpointed unit that is journaled by the workflow engine and recorded into _voltro_workflow_run_steps.

const customer = yield* step({
  name: 'fetch-customer',
  input: { customerId },
  success: Customer,
  execute: Effect.tryPromise(() => crm.fetchCustomer(customerId)),
})

The optional input field is not passed to the executor. It is persisted for inspection, truncated to a dashboard-safe size, and shown beside output/errors in the run timeline.

Plain Effect composition is still useful:

const normalised = normaliseCustomer(customer)
const enriched = yield* Effect.succeed(addDerivedFields(normalised))

But it is not a durable activity boundary by itself. If it does external I/O, writes to storage, charges a card, sends an email, or calls an LLM, put that work inside step.

Deterministic replay

The workflow body can be replayed by the engine. The structure of checkpointed steps must be stable for the same payload.

OK:

const note = yield* step({ name: 'load-note', success: Note, execute: loadNote(noteId) })

if (note.archived) {
  yield* step({ name: 'notify-archived', execute: notifyArchived(note.id) })
} else {
  yield* step({ name: 'summarise', execute: summarise(note.body) })
}

The branch depends on a checkpointed value.

Not OK:

if (Math.random() > 0.5) {
  yield* step({ name: 'a', execute: doA })
} else {
  yield* step({ name: 'b', execute: doB })
}

If time, randomness, or external state influences structure, capture it in a step first:

const choice = yield* step({
  name: 'choose-branch',
  success: Schema.Boolean,
  execute: Effect.sync(() => Math.random() > 0.5),
})

Parallel steps

Steps with no data dependency run concurrently with plain Effect.all — no special API:

const [jira, github] = yield* Effect.all(
  [
    step({ name: 'fetch-jira',   success: JiraIssues,  execute: fetchJira(projectKey) }),
    step({ name: 'fetch-github', success: GithubPrs,   execute: fetchGithub(repo) }),
  ],
  { concurrency: 'unbounded' },
)

Both steps journal independently, and the durable guarantees hold across the join:

  • The steps genuinely overlap — one is not secretly serialized behind the other.
  • On a retry or an operator redrive, a parallel step that already completed replays from its journal; only the sibling that failed re-executes.

Both properties are pinned by a contract test against the real engine (workflowParallelSteps.integration.test.ts), so an engine upgrade that broke either would go red rather than quietly serializing your fan-out.

Name each parallel step distinctly — the name is the journal key, and two concurrent steps sharing one name would share one checkpoint. Deterministic-replay rules apply unchanged: the set of steps started must be stable for the same payload.

Starting from the client

Codegen synthesises an RPC for each workflow. To start it from React, use useWorkflow(...); the call returns a run handle immediately and the durable work continues in the workflow engine:

import { useWorkflow, useWorkflowRun } from '@voltro/client'

const startImport = useWorkflow<{ uploadId: string }>('app', 'customers.import')

const run = await startImport.start({ uploadId })
const { run: liveRun } = useWorkflowRun('app', run.id)

Use liveRun.status and liveRun.output to render progress/result state. Waiting for the success payload is explicit on the server with ctx.workflows.wait(...); UI code should usually subscribe to the run row instead of blocking the interaction.

The handle's status says what the start did: running (accepted without waiting for completion), succeeded (a blocking start observed successful completion), queued / dropped / skipped (flow control deferred or refused it), or replayed — a non-blocking passthrough start found a finished run under the same idempotency key and addresses that old run. A waited replay observes the original execution: success returns succeeded, failure rejects with its original Cause. A handle is not a live status probe; use poll or wait for current state. Choose keys per unit of work (tour:${rowId}:${updatedAt}), not merely per subject (tour:${rowId}).

Starts inside a mutation

Inside a mutation, ctx.workflows.start(...) and child(...) return status: 'queued', executionId: null, and deferral.mode: 'transaction'. This is a provisional receipt until the business transaction commits, not a claim that the engine has started. Its id / deferral.intentId addresses a row in _voltro_workflow_pending, not an execution or run row.

The intent is written through the same transaction as the domain data. Rollback removes both; a retried transaction retains only its committed intent. No in-process post-commit callback is required to discover it: the existing admission drainer in dev/serve reads committed work after restart. No app-owned outbox handler is needed. A future { at } remains the earliest arrival time. Declared controls are evaluated when the drainer processes the start; it may defer, collapse, drop or skip it. A queued receipt does not promise a new run.

Queue payloads use the schema's encoded form. The drainer decodes before evaluating controls or submitting to the engine; for batches it decodes each batch.item before assembling the workflow payload. This preserves transforms such as DateFromString and NumberFromString across a database/restart boundary. A stored value that no longer decodes is dead-lettered with a redacted diagnostic; it does not prevent an independent valid intent from being drained.

Use voltro workflows flow to inspect pending intent IDs, attempts, errors and retained dead letters. Do not pass the pending ID to useWorkflowRun as if it were an execution ID. wait: true and run(...) are refused inside the transaction. Queued-handle waiting is not yet implemented; engine admission and external effects remain outside the business transaction, not exactly-once I/O.

Low-level hosts replace makePostCommitWorkflowFacade with makeTransactionalWorkflowFacade(base, enqueueStart). The callback must write the supplied validated, already encoded start through the same transaction, returning { intentId, dueAt }; registering a callback to write later defeats the contract. Do not encode that value a second time. Hosts using the lower-level admission store directly supply encodeWorkflowPayload and decodeWorkflowPayload when their payload schemas transform values. Omitting these codecs declares a JSON-only payload contract, not automatic schema conversion.

Who may start it — guards:, openAccess: or internal: true

The start rpc is a wire surface exactly like a mutation's, so a workflow decides who may start it the same way — and one that decides nothing is refused at boot under security.defaultDeny (on by default), by voltro dev, by voltro serve and by voltro doctor as a preflight. Before this, workflow() had no guards: at all: every discovered workflow was startable by any socket, anonymous sessions included.

import { workflow } from '@voltro/workflow/define'
import { Schema } from 'effect'

export const ExportTeam = workflow({
  name: 'team.export',
  payload: { teamId: Schema.String },
  idempotencyKey: ({ teamId }) => `team.export:${teamId}`,
  // a relationship guard reads its id from the START PAYLOAD
  guards: [{ action: 'export', resourceType: 'team', resource: (p) => p.teamId }],
})

export const ConfirmSignup = workflow({
  name: 'signup.confirm',
  payload: { token: Schema.String },
  idempotencyKey: ({ token }) => `signup.confirm:${token}`,
  openAccess: 'public signup confirmation — the payload carries only the token being confirmed',
})

export const NightlyRollup = workflow({
  name: 'reports.nightly',
  payload: { day: Schema.String },
  idempotencyKey: ({ day }) => `reports.nightly:${day}`,
  internal: true,   // no start rpc — a schedule or server code starts it
})

There are exactly three answers, and they are not the same claim:

  • guards: — the mutation vocabulary, verbatim: { scope } checks the caller's scopes, { action, resourceType, resource } checks a relationship to a row named in the payload (the workflow's input is its start payload). Checked once, at the start call, before the run row is written or the engine is asked for anything. The run then executes as the subject that started it — the guard decides whether it may begin; row filters decide what it may read.
  • openAccess: — anyone who can open the socket may start it, and why. A reason, never a boolean; an empty string is refused at definition time.
  • internal: true — no start rpc. The codegen emits no client entry (so useWorkflow('reports.nightly') is a type error) and neither boot path binds a start handler; ctx.workflows.start, a schedule: block, an event trigger and a parent workflow still start it. It does not hide the workflow from the run-observation surface: once an app declares any workflow at all, both boot paths bind the built-in run/cancel/signal procedures, because those act on runs that already exist rather than starting one. An internal workflow's runs therefore stay inspectable — and whether a caller may DRIVE one of them is the separate controls: decision below, which an internal workflow needs just as much as a wire-startable one. This is the right answer for most schedule- or trigger-driven workflows. It cannot be combined with openAccess — there is no wire to decide about.

guards: [] is refused (it reads as protected and enforces nothing), as is guards together with openAccess. A workflow that declares none of the three is listed by voltro doctor with its file — the same set, from the same function, the boot refuses on.

The start runs inside the plugin interceptWorkflow chain, so the audit plugin records it, rate limiting counts it, billing's entitlement gate covers it, and rbac publishes the caller's role scopes before the guard is asked — which is what lets a guard on a role-derived scope pass at all. See Plugin contract.

Who may drive a run — controls:

Starting a workflow and driving one of its runs are different authorities, so they are declared separately. The approval pattern is what makes the difference concrete: the customer who may start an order must not be the one who signals its own approval, and the manager who may approve places no orders at all.

export default workflow({
  name: 'orders.fulfil',
  internal: true,                                    // server code starts it
  controls: { guards: [{ scope: 'orders:approve' }] }, // …and who may drive a run
  payload: { orderId: Schema.String },
  success: Schema.String,
})

controls: decides cancel, signal, update and resume — the built-in procedures behind useWorkflow(...). It takes the same vocabulary as guards:, and a resource-scoped guard reads its id from the run's start payload, so one resource: function serves the start and the controls:

controls: { guards: [{ action: 'approve', resourceType: 'order', resource: (p) => p.orderId }] }

controls: { openAccess: '<why>' } says anyone who may see the run may drive it, with the reason in source — right when a run belongs to the caller who started it and cancelling it affects nobody else.

A workflow that declares no controls: cannot be driven over the wire at all (a system subject and a caller holding admin:full aside). That is the default because the alternative hands a decision your app has already guarded to a procedure your app never wrote: an approval mutation with { scope: 'orders:approve' } is only the way in while __voltro.workflow.signal is not a second, unguarded one. The refusal names the missing declaration.

Two boundaries worth keeping straight:

  • Reading is separate. The run feeds (useWorkflowRuns and friends) are scoped by tenant, not by controls: — a caller may watch a run it may not drive.
  • Cancelling a run that has not started yet (cancelQueued, for a start parked by flow control) is authorised by the receipt id the start returned, plus the tenant. It does not consult controls:: there is no run and no payload to check a resource guard against.

Child workflows

Inside a workflow body, start child runs through ctx.workflows.child(...). The child gets its own durable run, step history, events, cancellation controls, and dashboard detail page. Voltro persists the parent execution id and parent-close policy before submitting the child start, so the relationship survives cross-runner execution in a cluster.

const child = yield* Effect.promise(() =>
  ctx.workflows.child('documents.embed', { documentId }, {
    parentClosePolicy: 'cancel',
  }),
)

const result = yield* Effect.promise(() => ctx.workflows.wait(child))

Fan out with normal Effect concurrency:

const children = yield* Effect.all(
  documents.map((doc) =>
    Effect.promise(() =>
      ctx.workflows.child('documents.embed', { documentId: doc.id }, {
        parentClosePolicy: 'abandon',
      }),
    ),
  ),
  { concurrency: 8 },
)

Parent-close policies:

Policy Behavior
cancel Default. Interrupt open children when the parent closes.
terminate Interrupt open children and record a hard parent-close action.
abandon Leave the child running when the parent closes.

The dashboard shows child runs, their parent execution id, and the selected policy. ctx.workflows.wait(child) accepts the run handle directly when the parent needs the child's success/failure snapshot.

Running on a cron — workflow({ schedule })

A workflow whose only trigger is a clock can declare the cron on itself, instead of a separate *.cron.tsx file with a workflow: target:

export default workflow({
  name: 'reports.nightly',
  payload: Schema.Struct({ day: Schema.String }),
  idempotencyKey: (p) => `nightly:${p.day}`,
  schedule: {
    cron: '0 3 * * *',
    timezone: 'Europe/Berlin',
    payload: ({ scheduledAt }) => ({ day: scheduledAt.toISOString().slice(0, 10) }),
    onOverlap: 'skip',
  },
})

This is sugar, not a second scheduler: at boot it lowers into a real schedule named workflow:<name> on the same coordinated cron engine every defineSchedule uses — same exactly-once claims, same run rows, same Schedules panel, same voltro schedule run / backfill verbs.

What the declaration adds is overlap vocabulary about the workflow run (Temporal Schedules' names):

  • onOverlap: 'skip' (default) — a firing stands down while the previous firing's run is still going.
  • 'buffer' — firings serialize behind the running one; none is lost.
  • 'cancelOther' — the new firing cancels the still-running previous run (only runs this schedule started — a manually-started run of the same workflow is never touched), then starts fresh.

The synthesized firing awaits the run to completion — that is what makes skip/buffer bind on the run's duration rather than on the milliseconds it takes to enqueue one, and it is why the firing watchdog (schedule.maxRuntime) defaults to 24 hours here instead of a plain schedule's 30 minutes. Set it above your slowest expected run. A failed run fails the firing, so a nightly job that dies every night is red in the schedule ledger, not a wall of green.

payload is a value or a function of the firing — a function receives { scheduledAt }, so a backfilled firing computes against its slot, not "now". backfill: and everything else about missed firings work exactly as on a plain schedule — see Overlap & backfill.

If the workflow also declares a deferring control (debounce, concurrency, …), a scheduled start passes the same admission gate as any other start — a deferred firing has nothing to await and hands the run to the admission queue.

Tenant and subject

Workflow starts persist the starter's trace id, source, parent execution id, parent-close policy — and their identity, never their authority — in _voltro_workflow_start_contexts. Whichever runner first executes the workflow loads that context before building the executor AppContext.

The guarantee: identity is persisted, authority is re-resolved at resume.

  • Identity (type, id, tenantId, metadata) is written and read through the same stripping function the session cookie mints through. It has to survive: the tenant scope reads tenantId, the run row is attributed to id, and a plugin service resolving a per-user credential reads metadata. A workflow started by tenant A still acts on tenant A's rows in three days' time.
  • Authority comes from your auth.resolveScopes on every execution attempt, with ctx.origin === 'workflow'. Wire no resolver and a resumed run has no scopes — fail-closed, and the same default a cookie-authenticated request has.
  • An explicitly recorded system start runs as SYSTEM_SUBJECT without your user-authority resolver. A missing row or missing identity refuses execution; it never grants system authority. Framework starts without a request persist an explicit system identity before engine submission.

Delayed and flow-control-queued starts pass the stored caller context through the same start facade when admitted: tenant identity, trace, source and parent-close metadata are preserved. The queue's tenantId is derived from that identity; admission group keys are still the keys declared by the application.

The first start claims the execution ID through the database's unique key. A retry with the same identity and parent binding is accepted without replacing the original trace/source. A different workflow, tenant, user, identity metadata, parent execution or parent-close policy is refused before engine submission. There is no process-local context cache: each execution attempt reads storage.

A failed write refuses submission; a failed read, missing row, null identity or malformed identity refuses execution before building the executor context. Look for workflow.startContext.persist failed or workflow.startContext.resolve failed in API logs, using workflow name and execution ID. The reason distinguishes identity conflicts and missing data without logging identity values. Repair storage access or restore the verified original identity before retrying; never delete a row or invent a system identity to bypass a refusal.

Context bindings have no automatic TTL. Run history may expire while the engine still remembers an execution; deleting its binding would let another caller claim the same ID. Keep these rows with the engine state, including in backups and restores. Storage grows by one binding per execution; coordinated engine-and-binding cleanup is not provided. Remove VOLTRO_WORKFLOW_START_CONTEXTS_TTL_HOURS and any custom retention policy for this table. This does not make engine submission part of the business transaction.

The column used to hold the whole Subject, scopes included. A role removed on Monday was still asserted by Thursday's resume, out of a row nothing re-validated, on a path with no request, no cookie and no expiry. Rows written by an older build are stripped on read, so a resumed run cannot re-assert authority that was persisted before this changed.

Still include tenant/user ids that the business process must enforce in payload, validate them in the first step, and scope store reads/writes deliberately. Payload data is replay-safe and makes authorization decisions auditable across retries and deploys.

Calling an HTTP API from a step

The framework's HttpClient is available inside a workflow executor — the same one handlers yield*, with the same SSRF allowlist and the same automatic traceparent propagation. yield* it in a step:

import { HttpClient, HttpClientRequest } from '@effect/platform'

export const executor = defineWorkflowExecutor(syncInvoice, (payload) =>
  Effect.gen(function* () {
    const remote = yield* step({ name: 'fetch-invoice' }, () =>
      Effect.gen(function* () {
        const client = yield* HttpClient.HttpClient
        const res = yield* client.execute(
          HttpClientRequest.get(`https://billing.example.com/invoices/${payload.invoiceId}`),
        )
        return yield* res.json
      }),
    )
    yield* step({ name: 'persist' }, () => database.invoices.update(payload.invoiceId, remote))
  }),
)

Two things follow from where it sits:

  • Wrap the call in a step. The result is then checkpointed, so a retry or a resume after a deploy replays the recorded response instead of calling the remote again. A bare yield* outside a step re-issues the request on every replay — which for a payment capture or an email send is the difference between once and several times.
  • It is the SSRF-guarded client. Requests to internal targets are refused by the same policy handlers get; a workflow is not a way around it. Configure the allowlist once in app.config.ts under http — dev and serve read the same key, so the policy cannot differ between them.

Reaching for fetch instead loses both: no allowlist, no trace propagation, and nothing tying the call to the step that made it.

Anti-patterns

  • Using input in workflow({...}). The current API is payload.
  • Assuming every yield* is a persisted step. Use step({...}) for checkpoints.
  • Starting workflows through useMutation(...). Use useWorkflow(...); workflows are durable runs, not optimistic writes.
  • Branching step structure on randomness or live external state. Capture the value in a step first.
  • Long-lived workflows without payload versioning. A run resumed after a deploy uses the current workflow code.