Versioning

Workflow definition versions, compatibility metadata, in-body patch markers, and the replay nondeterminism tripwire.

Long-running workflows can outlive a deploy. Voltro does not run old JavaScript forever; a resumed run executes the current code. Make that explicit by versioning the workflow definition.

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

export const ImportCustomers = workflow({
  name: 'customers.import',
  payload: { uploadId: Schema.String },
  success: Schema.Struct({ imported: Schema.Number }),
  idempotencyKey: ({ uploadId }) => uploadId,
  version: 3,
  compatibleWith: [2, 3],
  patches: ['split-validate-and-write'],
})

Voltro stores workflowVersion and workflowPatches on every _voltro_workflow_runs row when the run starts. The local devtools and Voltro Cloud dashboard show the version chip on run rows, so operators can spot old or incompatible runs during a deploy.

Compatibility

compatibleWith documents which run versions the current code can still resume. It is enforced: a resuming run whose stored workflowVersion is not listed is terminally failed with WorkflowVersionIncompatible.

That makes it a blunt instrument, and the bluntness is the point to understand before you reach for it. Bump version and leave the old one out, and every in-flight run on the old version dies. Do not bump, and those runs replay against the changed body with no protection at all. Neither is what you usually want — which is what patches is for.

Patch markers

A patch marker lets the body itself branch, so runs that started before a change finish on the old path while new runs take the new one. This is the middle option between "kill the in-flight runs" and "hope the replay works out".

import { patch, step, workflow } from '@voltro/workflow'

export const Charge = workflow({
  name: 'billing.charge',
  payload: { orderId: Schema.String },
  idempotencyKey: ({ orderId }) => `billing.charge:${orderId}`,
  patches: ['split-tax-calculation'],
})

export default () => (payload) =>
  Effect.gen(function* () {
    if (yield* patch('split-tax-calculation')) {
      const net = yield* step({ name: 'net-total', execute: computeNet(payload) })
      const tax = yield* step({ name: 'tax', execute: computeTax(payload) })
      return net + tax
    }
    return yield* step({ name: 'total', execute: computeTotal(payload) })
  })

The answer is pinned to the run, not to the deployed code. patches is stamped onto _voltro_workflow_runs.workflowPatches when the run starts and read back from that row on every resume, so:

  • a run started before you added the marker answers false for the rest of its life, however many times it replays;
  • a run started after answers true, and keeps answering true even if you later change the declaration.

That is what makes the branch deterministic across a redeploy. Outside a recorded workflow body — a unit test, a bare step() call — patch() is false, which is the pre-patch path.

Retiring a patch

Once no run predating the marker can still be in flight, delete the old branch and remove the entry from patches. Runs that stamped it keep the marker on their row for the audit trail; patch() simply stops being called.

The replay nondeterminism tripwire

Journal entries are keyed by step name, with no shape check. Edit a workflow body while runs are in flight and the engine replays the cached result for every name that still matches and freshly executes every name that does not. Nothing errors. A renamed step re-runs a side effect the run already performed; a removed step silently skips work the journal says was done.

Voltro watches for that. A run re-entering its body compares the steps it reaches against the steps it recorded on earlier attempts, and writes a nondeterminism-suspected event when they disagree:

Finding Means
unreached-step A step this run ran on an earlier attempt that the current code never reaches — renamed, removed, or moved behind a branch. Its journaled result is orphaned.
extra-step-occurrence A recorded step reached more times than it was ever recorded — typically a loop bound that changed under a live run.

The comparison is set membership plus a per-name count, never a total order: concurrent steps interleave differently on every attempt, so an order check would report correct code as broken.

It is an event, never a failure. The run keeps going and reaches its normal outcome. The checks sit on a best-effort recorder, so a lost step-row write is enough to make one fire — a false positive that killed a run would be worse than the divergence it suspects. Treat the event as "open this run and look", not as an outage.

The tripwire covers every path that replays an existing journal, including voltro workflows redrive. It is off on a first body entry (there is nothing to compare) and on a run with more than VOLTRO_WORKFLOW_REPLAY_SHAPE_LIMIT recorded steps (default 2000), where a truncated history would manufacture its own false positives.

Rules

  • Bump version only when old runs genuinely cannot continue — it kills them.
  • Reach for patches first for a body change that in-flight runs should not see.
  • Keep old payload decoders inside the workflow body only while their version remains compatible.
  • Prefer additive payload changes with defaults over breaking changes.
  • Use the dashboard version chip during deploys to find runs that started on an older contract.
  • Treat a nondeterminism-suspected event as a deploy that needed a patch marker and did not get one.