Versioning & determinism
Keeping in-flight runs replay-safe across code changes — workflow versions for breaking changes, the NonDeterminismError guard, the deterministic now/random/uuid sources, and ctx.patched for guarding an in-place change without a new version.
A durable run is replayed: when the engine resumes a suspended run, it re-executes the workflow body from the top and feeds each step its recorded checkpoint instead of running it again. For that to be correct, the body must take the same path it took originally — same steps, in the same order, at the same logical positions. The moment a code change shifts those positions, replay reads the wrong checkpoint into the wrong step. This page is about changing your workflow code without breaking the runs already in flight.
The non-determinism guard
The engine pairs each replayed step with the checkpoint recorded at that logical position. If the
name at a position no longer matches what was recorded — you inserted a step, removed one, or
reordered them — the engine throws a NonDeterminismError rather than silently corrupting the run.
// On replay, a checkpoint recorded as `charge` at this position but now named `refund` is caught:
// NonDeterminismError(runId, seq, expected: 'refund', recorded: 'charge')This is a guard, not a fix. It tells you a code change is incompatible with runs that started under the old code. The two tools below let you make the change anyway — safely.
Deterministic sources — ctx.now and ctx.sideEffect
The most common way to break replay is reading a value that changes every time the body runs. A raw
Date.now(), Math.random(), or crypto.randomUUID() returns a different value on each replay,
which silently corrupts a durable run — a timestamp captured into a step input one run won't match the
next. Use the context's deterministic capture instead; each records its value on the first run and
replays the same value afterwards:
@Workflow({ name: 'invoice', version: '1' })
export class InvoiceWorkflow {
constructor(private readonly invoices: InvoicesService) {}
async run(ctx: WorkflowCtx, order: Order) {
const issuedAt = await ctx.now(); // epoch ms — captured once, replayed verbatim
const nonce = await ctx.sideEffect(() => crypto.randomUUID()); // captured once
const sampled = (await ctx.sideEffect(() => Math.random())) < 0.1; // captured once
await ctx.step(this.invoices.issue, { order, issuedAt, nonce, sampled });
}
}ctx.now() returns the checkpointed timestamp as a number (for an ISO string:
new Date(await ctx.now()).toISOString()). ctx.sideEffect(fn) is the general form — it runs fn
once, checkpoints the result, and on replay returns the saved value without re-running fn.
Use it for any non-deterministic value you generate yourself: a UUID/ULID, a random sample, a config
or env read. Keep fn effectively pure — it produces a value; it is not a place for real work with
side effects. For that (a DB write, an API call), use a full ctx.step to an @Step handler, whose
whole result is checkpointed. Either way, never read the raw clock or RNG in the deterministic prefix
of the workflow body.
Workflow versions — for breaking changes
When a change genuinely alters a workflow's shape — new steps, reordered logic, a different control flow — bump the version. Register the new version alongside the old:
@Workflow({ name: 'checkout', version: '1' })
export class CheckoutWorkflowV1 {
async run(ctx: WorkflowCtx, order: Order) {
/* the original body */
}
}
@Workflow({ name: 'checkout', version: '2' })
export class CheckoutWorkflowV2 {
async run(ctx: WorkflowCtx, order: Order) {
/* the new body, with the breaking change */
}
}A run records the code version it started on (workflowVersion on the run). When the engine
resumes it, it replays against the same version it began on — so in-flight runs drain on the code
they started under, while new runs start on the latest version. This is skew protection: deploying v2
never breaks the v1 runs still in the system. Keep the old version registered until every run that
started under it has reached a terminal state, then remove it.
Starting a specific version
"Newest" is the default, not the only option. Pass version to start (or spawn as a child) the exact
version you mean:
await engine.start('checkout', order, runId, { version: '1' }); // runs the V1 body, not V2
await ctx.child('checkout', order, { childId: 'c1', version: '1' }); // same, from a workflow bodyReach for it when the caller records which version it ran — a catalog node, a pinned pipeline
definition, a reproducible re-run of an old job. A version that is not registered throws at
start, before any run row is created; it never quietly falls back to the newest, because a caller
that asked for checkout@1 and silently got checkout@2 has no way to notice. Pass a constant
from inside a workflow body: computing the version at call time would make the body non-deterministic,
exactly like reading the clock.
Pinning resolves against real registrations — @Workflow({ version }), engine.register,
engine.registerRemote, engine.remote({ version }). It is deliberately not available on the two
paths where the engine invents a registration because none exists: a child inheriting its remote
parent's routing, and convention routing to a live worker group of the same name. Both stamp a
placeholder version (the parent's, and '1'), which is not evidence that the worker has the body you
asked for — so a pinned start on those paths throws rather than pretending. To pin a cross-SDK
workflow, register it for real: engine.remote('processing', { group: 'processing', version: '2' }).
What version a convention-resolved run records
An unpinned start against a convention-resolved remote still has to write something into the
run row's workflowVersion, and whoever checks a pin after the fact reads that value. It used to be
the caller's own '1', stamped onto the synthetic run before the resolver ran and echoed straight
back — so a pin of '1' could never fail and any other value could never pass. A check that cannot
fail is worse than no check, because it reads as a guarantee.
The version now comes from the fleet: the descriptor advertisement for the group being resolved,
where a worker declares it (@worker.workflow("processing", version="2") in Python,
@Workflow({ version }) in TypeScript). Two live workers claiming different versions of one name
counts as undeclared rather than as a coin flip.
When nobody declares a version, the routing default is kept — refusing outright would make an
un-upgraded callee uncallable, and a callee should never have to change to be callable — and the
run is tagged version:undeclared:
const run = await engine.getRun(runId);
run.workflowVersion // '1' — the routing default, not an observation
run.tags.includes('version:undeclared') // true — so a pin check can say soRead that tag before treating workflowVersion as something a pin was checked against. It travels on
the run itself, so a retrospective check needs no second read, and it shows up in run listings and
byTag queries like any other tag. Resume is unaffected: replay is positional, so an in-flight
run stays on the version it began on however the fleet moves underneath it.
Versioning is the right tool when the change is structural and you're prepared to keep both bodies
around. For a small, surgical change, the version split is heavyweight — that's where ctx.patched
comes in.
Patching in place — ctx.patched(id)
ctx.patched(id) guards an in-place change without a new version. Wrap the changed code in a
branch on it:
@Workflow({ name: 'checkout', version: '1' })
export class CheckoutWorkflow {
constructor(
private readonly pricing: PricingService,
private readonly fraud: FraudService,
private readonly payments: PaymentsService,
) {}
async run(ctx: WorkflowCtx, order: Order) {
const quote = await ctx.step(this.pricing.quote, order);
if (await ctx.patched('add-fraud-check')) {
// New behaviour: runs that started AFTER this code shipped take this branch.
const risk = await ctx.step(this.fraud.score, { orderId: order.id });
if (risk.score > 0.9) throw new FatalError('high fraud risk', 'fraud');
}
await ctx.step(this.payments.charge, { orderId: order.id, amountCents: quote.total });
}
}1@Workflow({ name: 'checkout', version: '1' })2export class CheckoutWorkflow {3 constructor(4 private readonly pricing: PricingService,5 private readonly fraud: FraudService,6 private readonly payments: PaymentsService,7 ) {}8 9 async run(ctx: WorkflowCtx, order: Order) {10 const quote = await ctx.step(this.pricing.quote, order);11 12 if (await ctx.patched('add-fraud-check')) {13 // NEW branch — only runs that started after this shipped enter here14 const risk = await ctx.step(this.fraud.score, { orderId: order.id });15 if (risk.score > 0.9) throw new FatalError('high fraud risk', 'fraud');16 }17 18 await ctx.step(this.payments.charge, { orderId: order.id, amountCents: quote.total });19 }20}How it stays replay-safe:
- A fresh run (started after the patch shipped) hits
ctx.patched('add-fraud-check'), records apatch:add-fraud-checkmarker at that position, and returnstrue— it takes the new branch. - A run already recorded under the old code replays into this position and finds a real step
there (the step that, in the old code, came next) — not a marker. So
patchedrewinds the logical position, gives it back to that old step, and returnsfalse— the run keeps the old branch.
The marker is position-transparent for old runs: because it rewinds rather than consuming a position, it never shifts an in-flight run's recorded checkpoints. New runs get the new path; old runs finish on the old path; neither is corrupted. Once every run that started under the old code has drained, remove the guard and keep just the new branch — the marker for fully-new runs is harmless and the simplification is clean.
ctx.patched and workflow versions solve the same problem at different grain: a version is a whole
second body for a structural change, a patch is a one-line guard for a surgical one. Reach for a patch
when the change is small enough that maintaining two full bodies would be overkill.
Tooling
Spotting a raw Date.now(), Math.random(), or crypto.randomUUID() in a workflow body — or an
unguarded reorder — is exactly the kind of mistake a linter should catch before it ships. The
companion lint plugin flags non-deterministic calls inside workflow bodies and steers you to the
deterministic sources and ctx.patched. See Linting.
Durable webhooks
ctx.webhook() mints a durable callback handle with a deterministic token and a public url; hand the url to a third party inside a step, then await handle.wait() to suspend with zero compute until the callback arrives as engine.signal(token, body). ctx.task() is the general form for external work you deliver and complete yourself, with no callback URL.
Scheduling
Recurring workflows with ScheduledWorkflow — fixed intervals via everyMs or DST-aware cron via cron + timezone — fired each tick by the NestJS module's schedules option, started exactly once per window by an idempotent time-bucket run id.