Summary#
A workflow run is not a request. It can sit waiting for an approval on Friday and be resumed on Tuesday, and in between you will have deployed. The code that finishes the run is not the code that started it.
Every durable workflow engine has to answer one question to make that safe: where is this run, said in terms the new code can understand? This page is that answer — the identity model underneath it, what the platform moves for you, what it asks you to decide, and what it refuses to guess.
Description#
What is a run's position, and why can a deploy move it?#
A run's position is more than "which state". Halfway through a body it is also: which step already ran, which child
run belongs to which step, which of several foreach items it is on, and what its local variables held. All of that
was recorded by one version of your code, and has to be read by the next one.
The failure mode is specific and expensive. If a position is recorded as where it sat — the twelfth node of a tree, the third call in a body — then editing anything above it moves it. A run resuming after that deploy looks for work it has already done, does not find it, and does it again. For a charge or a message, "again" is the whole problem the engine exists to prevent, and nothing announces it.
The principle: a position is made of names you chose#
Every part of a run's position is a name that appears in your source, so that editing the code around it cannot move it. Nothing in the list below is a number, an index, or an id minted by a compile:
| what has to be identified | what identifies it |
|---|---|
| which state the run is in | the state's name |
| which body it is running | where that body is declared — Approved.enter; for one arm of a route, the guard you wrote |
| which call it is inside | the callee's name |
| which park point it is sitting at | the label you wrote — await Workflow.Run("fulfil", order) |
| which durable step already ran | that step's label — Workflow.Once("charge", …) |
| which child run belongs to that step | the starting run, plus the step's label |
| an external call you did not label | the call itself — Resend.Send |
| which pass of a loop | the iteration number |
The last row is the exception that proves the rule: an iteration says which pass over the data, not which line of code. There is nothing in your source to name, so a number is the honest identity.
A when arm has no name, so its guard is its name. You never write a label on an arm, and it would be ceremony
to ask for one — so the arm a run is parked in is identified by the condition you wrote on it. Everything follows
from that, in both directions:
on Decide {
when (this.Item.Amount > 1000) { … } // identified as this condition,
when (this.Item.Amount > 100) { … } // and this one — not as "the first" and "the second"
}- Reorder the arms, or add one above them, and nothing moves. A run parked in the
> 1000arm resumes in the> 1000arm. Had the arm been "the first one", inserting a new arm above it would have quietly made a parked run resume in a branch it had never been in — carrying the memos of the branch it was in, so the steps it already ran would be treated as done. - Change a guard and you have changed which arm that is, so a run parked in it needs your word. It is reported the same way as a renamed body: the new version has no arm of that description, and the migration asks you to say where the run should go.
Where a name is not enough, the compiler says so — before a run exists#
A name identifies something only while it is unique in its scope. Two steps labelled "charge" in one body, two
unlabelled calls to Resend.Send, two calls to one durable helper, two arms of one route under the same guard,
two default arms — each is a position with no answer.
Those are compile errors, and when they fire is the entire point. The alternative is to notice at deploy time, against a run that is already parked — where adding a label cannot help that run, because the label was not there when it parked. That is a refusal nobody can act on, firing on every deploy forever. Caught while you are writing the code, each one costs a single string literal.
This is why the language asks for a few labels it could have derived. A derived default is not a name you chose: rename a local, or a workflow, and it changes silently, breaking the migration of every run already parked there.
Two kinds of waiting, two behaviours#
Where a run is waiting decides how a deploy treats it.
Waiting at a state — for an event, an approval, a timer. This is most runs most of the time. The run holds no call stack; its whole position is "in state X, with these slots open". A deploy moves it automatically: the state, each slot, and each live clock are re-pointed at the new version's declarations by matching names.
Parked mid-body — inside await Workflow.Run(…), await saga.Run(…), or a leg join, with a live call stack and
local variables. By default this run stays on the version it started under and finishes with that version's
behaviour.
That default is a decision, not an omission. "Runs started under v1 keep v1's behaviour; new ones get v2" is often exactly right, and always the safer reading of a deploy. When you want in-flight work to pick up the new body, say so per state:
migration OrderSaga v2 -> v3 {
on Fulfilling { keep; reenter; } // `keep` = stays in Fulfilling; `reenter` = and re-enters the new body
}reenter is orthogonal to position: keep / goto / terminate say where the run lands, reenter says how
it gets there. You still have to say where.
What re-entry actually does#
There is no cursor to translate — a cursor is a position in one version's tree. Instead, the new body runs from the top, and the work already done is recognised where it is recorded:
- a
Workflow.Oncestep finds its memo and returns the recorded result without running; - an
await Workflow.Run/saga.Runfinds its child by (starting run, step label), and if that child has finished, takes its outcome and carries on.
So execution passes straight through everything already satisfied and stops at the first thing that has not happened.
Completed steps are re-executed, not skipped, and the difference matters: running a finished saga.Run is what
puts its compensation back on the undo stack. A re-entry that jumped over it would leave step 1 uncompensated when
step 4 fails.
Two consequences to design for:
- Ordinary statements between the steps run again. Assignments are idempotent, so they are fine. Creating a row is not — so creating one before a wait is a compile error, and the fix is to put the creation in the step that already records its result: Work after the last wait is reached only once and needs nothing.
- Values from
DurableClock.NoworGuid.NewGuidare derived afresh. Those ride the parked cursor, which is the thing a version move discards. Anything that must not change is a durable step result, which is recorded out of band against a stable id and survives.
The replay-safe shape, in full — every line that must not happen twice is recorded:
enum OrderStatus { Placing, Placed, Failed }
enum StepStatus { Waiting, Done }
entity Order { [Required, MaxLength(20)] string Ref; OrderStatus Status = OrderStatus.Placing; }
entity Charge { [Required] Order Order; StepStatus Status = StepStatus.Waiting; }
int Bill(Order o) { return 1; }
workflow ChargeFlow {
Tracks = Charge.Status; Autostart = false; Initial = Waiting;
event Settled();
state Waiting { subscribe Settled(); on Settled { goto Done; } }
terminal success Done { }
}
workflow PlaceOrder {
Tracks = Order.Status; Autostart = false; Initial = Placing;
state Placing {
enter {
// Recorded, so a re-entry returns the same result instead of billing again.
Workflow.Once("bill", () => Bill(this.Item));
// Recorded too — otherwise a re-entry would create a SECOND Charge row. This is the compile rule.
var c = Workflow.Once("make-charge", () => new Charge { Order = this.Item });
// The park. On re-entry this finds the child it already started and takes its outcome.
await Workflow.Run("charge", c);
goto Placed;
}
}
terminal success Placed { }
terminal error Failed { Message = "could not place the order"; }
}What is refused, and why each refusal is honest#
A refusal leaves the run untouched on its old version, still working. It is a normal outcome of a deploy, not a failure of one — and each names the specific thing that could not be matched, never "this run is complicated".
| refused | why | what to do |
|---|---|---|
| a state the new version does not have | a rename and a removal look identical from outside | say which with goto or terminate |
| a slot that vanished | the run is holding a claim on it | rename slot A -> B; or drop slot A; |
| a deadline whose budget moved | carrying breaches every live SLA at once; resetting hides breaches that happened | carry clock or reset clock — there is no safe default |
| a fan-out whose shape changed | the run holds deposits against the old shape | let those runs finish |
a mid-body park, with no reenter | the default: it keeps the behaviour it started with | add reenter;, or let it finish, or cancel it |
| a mid-body park inside a loop | its item list and cursor are not describable as "which steps completed" | let it finish |
| a body the new version has no counterpart for | a renamed body is a rename only you can confirm | goto, or let it finish |
Several deploys while a run waits#
A run moving v1→v3 runs v1→v2's migration and then v2→v3's, in order — never a single composed jump. The verbs are
cumulative and order-dependent: a goto in the first hop decides which state the second hop's verbs even apply to.
Composing the endpoints would land the run in a state the first author had deliberately steered it away from.
Which migration statement do I want?#
- You changed a body's logic and want in-flight work to use it →
reenter;on that state. Ask first whether work already in progress should change behaviour mid-flight; often it should not. - You renamed a state →
goto <NewName>;. - You removed a state or an entire branch →
terminate <outcome> "<why>";. The message reaches the run's parent. - You changed an SLA budget →
carry clock(the time already spent counts) orreset clock(it is forgiven). - A state is unchanged →
keep;. Say it anyway: the compile requires every parkable state to be spoken for, so that a state you forgot is a build error rather than a stranded run. - You are not sure what is out there →
osy workflow-runslists what is still on an old version, andosy migrate --dry-rundecides everything and writes nothing.
The hard questions about deploys, answered#
The questions worth asking of any durable workflow engine, and where this one stands:
- Can a running workflow survive a deploy? Yes. Runs waiting at a state move automatically; runs parked mid-body keep their original behaviour unless you opt them into the new one.
- What happens when I edit a body a run is inside? Nothing, unless you ask. Its identity is names, not positions, so ordinary edits — inserting a line, reordering unrelated work, renaming a local — do not move it.
- Is there a class of edit that silently corrupts a running workflow? The ones that would are compile errors: two steps sharing a label, two unlabelled calls to the same external target, two calls to one durable helper, a row created before a wait. The design principle is that anything the platform cannot answer is refused while you are writing it, not while a run is parked on it.
- Do I have to reason about determinism? Only where it is real: durable step results are recorded, so they never change; a clock read taken before a park is re-derived after a version move. There is no replay-determinism error class to learn, because a run that has not migrated does not replay at all — it resumes from its cursor.
- Can I test what happens across a deploy? Yes, and it is first-class rather than a harness trick:
TestClock.Advancemoves time,[runas(P)]runs as a specific principal, and a test can compile v1, start a run, compile v2 with a migration, and assert where the run lands. - What is the operational surface?
osy workflow-runs(what is still on an old version),osy migrate --dry-run(what a deploy would do),osy cancel-runs(the abort). A deploy drains automatically; versions are retained while runs still hold them.
See also#
- Migrating runs that are still in flight — the verbs, in full, with the exhaustiveness rule and worked examples
- Moving runs onto the version you just deployed — the operator surface: seeing what is out there, dry runs, draining
- Step labels (naming a child run so it survives a new version) — the label that makes a park point findable across versions
- Workflow.Once (run a step at most once) — durable steps, their labels, and the memo a re-entry reads
- Deploying while workflows are running — what a version is, and which runs are still holding an old one open