Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / Workflow

Migrating runs that are still in flight

migration OrderFlow v2 -> v3 { on Submitted { keep; } }

A deploy that renames or removes a workflow state leaves runs parked in it with nowhere to stand. A workflow migration says, per state, what happens to those runs — `keep;` for one that needs nothing, `goto <State>;` to move it, `terminate` to end it — and what happened to the slots and deadlines it was waiting on.

stable2 examples compiled by CIworkflowdeploymigration

Summary#

A workflow run is not a request that finishes while you deploy — it is a thing that sits and waits, sometimes for days. So when a deploy renames a state, the runs parked in the old one are still parked there, and the new version has nowhere to put them.

A workflow migration answers that, state by state, in the same .migration a rename or removal already uses. It is checked when you deploy, against both versions, so a file that could not be applied stops the deploy — rather than surfacing days later as a run nobody can move.

Signature#

migration OrderFlow v2 -> v3 {
  on Submitted       { keep; }                       // reviewed, and nothing to do — say so
  on AwaitingPayment { goto Reviewing; }             // where a run parked here now stands
  on Abandoned       { terminate cancel "no route"; } // nowhere to go: end it, and say why
  on Fulfilling      { keep; reenter; }              // …and runs parked MID-BODY here pick up the new body

  on Escalated {                                     // ordinary Osy# over the run's own row, beside the verb
    this.Item.Priority = 3;
    goto Reviewing;
  }
}

Description#

Why the platform cannot guess#

Most of a version move needs no help: the new version's Submitted is the old version's Submitted, and a parked run is simply re-pointed at it. Everything that can be matched by name is matched by name — the state, each slot, each deadline — and you write nothing.

What cannot be guessed is what a rename or a removal MEANT. Waiting disappearing and Reviewing appearing looks exactly like Waiting disappearing, full stop, and the two want opposite handling. So a run parked in a state the new version does not declare is left where it is and reported, with the verb that would answer it. Writing the verb is how you say what you meant — there is no separate approval flag, here or in a schema migration.

Every parkable state must appear#

A workflow migration is exhaustive: every state a run can be sitting in has to have an on handler, including the ones nothing happened to. A state you left out and a state you never noticed read identically on the page, which is exactly the mistake this prevents — so keep; exists to make "I looked at this, and it needs nothing" something the file says out loud. Miss one and the deploy stops, naming the state.

keep; also asserts more than an identity goto would: it says the slots and the clocks on that state are untouched too.

keep;, goto and terminate are mutually exclusive: a handler decides ONE fate for a run parked there. A handler carrying two of them has not decided, and the deploy says so.

A run parked MID-BODY: reenter;#

The verbs above answer where a run stands. A run parked inside await Workflow.Run(…), await saga.Run(…) or a leg join has a second question to answer: it is halfway through a body, holding a call stack and local variables, and that position belongs to one version's tree.

By default such a run stays on the version it started under and finishes with that version's behaviour. That is usually what you want — "runs started under v1 keep v1's behaviour, new ones get v2" — and it is always the safer reading of a deploy, so it is what happens when a migration says nothing.

reenter; opts the state's mid-body runs into the new body:

migration OrderSaga v2 -> v3 {
  on Fulfilling { keep; reenter; }
}

At the run's next step boundary — when the child it is waiting on finishes — the new body runs from the top. Work already done is recognised where it is recorded: a Workflow.Once returns its memo, and a step whose child has finished takes that child's outcome. Execution passes through everything already satisfied and stops at the first thing that has not happened. Nothing is translated, and nothing is skipped.

reenter is orthogonal to position, which is why it composes instead of replacing. keep / goto / terminate say where the run lands; reenter says how it gets there. A handler still needs one of the three, so reenter; alone is refused — it never answered the question the exhaustiveness rule asks.

Two things reenter does not cover, both reported by name rather than guessed:

  • A park inside a loop. The run holds a materialized item list and a cursor into it, and "which steps completed" does not describe that — re-entering would restart the loop at its first item. Those runs finish where they are.
  • A body the new version has no counterpart for. Bodies are matched by where they are declared (Fulfilling.enter), so a renamed state or a removed hook reads as "that body is gone" — a rename only you can confirm, with goto.

Re-entry replays the ordinary statements between the steps. Assignments are idempotent and fine; creating a row is not, so creating one before a wait is a compile error. Put the creation in the step that already records its result — Workflow.Once("make-step-a", () => new StepA { … }) — and it happens exactly once however often the body re-enters. See Workflows that outlive the code that started them for the full replay rules.

Changing the run's own data#

A verb says where a run stands. Often that is only half of what the change meant: a run pushed from Escalated to Reviewing because the escalation route was retired usually needs its row to say so too.

So a handler can carry ordinary Osy# over this.Item, beside the verb:

migration OrderFlow v1 -> v2 {
  on Escalated {
    this.Item.Priority = 3;
    this.Item.Note = "escalation route retired in v2";
    goto Reviewing;
  }
  on Draft { keep; }
}

this.Item is the run's tracked row — the same this.Item every handler body of that workflow already has. Anything you can write about it in an enter {} you can write here.

The statements run FIRST, then the verb settles where the run stands. That is the only order the example reads in: this.Item.Priority = 3; goto Reviewing; is one instruction about a run that is still standing where its author said. Where the statements appear among the verbs makes no difference — they are collected in the order you wrote them and run as one block.

It is one transaction with the move. If a statement fails, nothing is written at all: the run keeps its version, its position and its row, and the failure is reported against that run like any other refusal. There is no state where half a migration has been applied.

Written against the version you are deploying, and checked when you deploy it. A property the new version does not declare stops the deploy, naming it — not three days later against a parked run nobody is watching.

What a body may not do

Three things, each because the body runs while the run is between versions — not executing, and holding its own lock inside the migration's transaction:

  • It cannot wait. No await Workflow.Run, no saga.Run, no join. There is nothing for a park to return to. If work in flight should pick up the new version's behaviour, that is reenter; and the workflow body.
  • It cannot use a durable step or a saga. Workflow.Once records itself against the run's step memo, and these statements belong to a version pair rather than to the run's execution — it would either collide with a real step or record one nothing can find again.
  • It cannot goto. The handler already says where the run lands, in a verb the platform can read without running anything. Two answers to one question is how they come to disagree.

Each of these is refused when you deploy, and each message names the form that does work.

Changing an event's SIGNATURE: map event#

A slot.CallbackUrl() is in somebody else's inbox. They have no account, no way to learn you redeployed, and nobody can recall the link — so the body that arrives next week is shaped for the version the URL was minted under.

If you change that event's parameters, say what the old shape means under the new one:

migration OrderFlow v1 -> v2 {
  map event Approve(decision, reason) -> Approve(decision);                    // a parameter dropped
  map event Submit(amount)            -> Submit(amount, source = "callback");  // an added one needs a value
  on AwaitingApproval { keep; }
}

It sits at the migration's top level, not inside an on, because a signature belongs to the EVENT: the same event can be waited on by slots in several states, and a per-state spelling would repeat itself and could contradict itself.

Parameters match by NAME, never by position — a migration is read a year later by somebody who was not there, and "the second one" is not a fact anybody can check. Write the old signature out on the left; it is checked against what that version actually declared, because it is the record of the contract the links already sent out were minted against. A parameter the old signature cannot supply takes a literal: source = "callback".

The event's name is the same on both sides. This verb maps a signature. A renamed event is a different event, and the slot that waits on it is re-pointed with rename slot.

It is REQUIRED when a signature moves

A deploy that changes an event's parameters and says nothing about it stops, naming the event and spelling the verb. There is no safe default: the platform cannot invent a value for a parameter that did not exist, and it cannot decide that a dropped one did not matter.

What happens when the link comes back

The token records the version it was minted under. When the body arrives, it is read against THAT version's signature and then walked forward through each deploy's mapping, in order, into the signature the run is on now.

Walked rather than composed on purpose: a failure can say which hop's contract broke, which is what you tell the third party still holding the URL. A hop that says nothing about the event passes the payload through unchanged — that is exactly the statement that its signature did not move.

What a goto moves#

goto <State>; re-points where the run is parked. It does not re-run anything: no enter body, no route, no code of yours. What moves with it is everything that names the run's position —

  • the run's current state, resolved against the version being deployed;
  • the tracked property on the entity (Tracks = Job.Stage), so a query, a grid or a page reads the same answer the run does. The state you send it to must be a member of that enum, or the deploy stops;
  • the wait the run is standing at: an open slot on the state it left becomes the same-named slot on the state it is sent to, so a claim, an assignment, or an outstanding callback URL keeps working across the move;
  • an entry on the run's timeline, so its history shows the move instead of skipping over it.

What does NOT move on its own is elapsed time. A clock that has been running goes on running — a migration re-points a run, it does not restart it. The one exception is a deadline whose BUDGET you changed, which you have to decide about; see Deadlines below.

A goto target must be a state of the version you are deploying.

Ending runs that have nowhere to go#

Sometimes there is no honest answer to "where does this run stand now" — the route it was waiting on is gone, and no state in the new version means what its old one meant. terminate <outcome> "<why>"; says that out loud:

on Abandoned { terminate cancel "the offline-payment route was removed in v3"; }

The run ends where it stands, on the version it is already on. Nothing is re-pointed, because a finished run never resolves its definition again and its history describes itself. It is left Completed, Cancelled or Failed to match the outcome you named — the same three a terminal state can produce — its deadlines are retired, and your sentence goes on its timeline, where whoever finds it tomorrow will look.

The reason is required. This is the one verb that ends work somebody was waiting on, and a run that stopped for no recorded reason is the kind of thing that gets escalated a week later with nobody able to answer it.

A migration pass reports ended runs SEPARATELY from moved ones. "We ended forty runs" is not a variety of "we moved forty runs", and you should never have to read the difference out of a state name.

Slots: the wait itself#

A state's on handler also says what happened to the slots that state waits on — a different question from where the run goes, so these sit beside keep/goto/terminate rather than instead of them.

on AwaitingApproval {
  keep;
  rename slot Payer -> Payee;      // the same wait, under a new name
  drop slot LegacyApprover;        // gone in the new version: cancel the claim, explicitly
}

A slot renamed past the automatic name-match needs rename slot, because from the outside a renamed slot and a removed one look identical. The verb is required in front of the names: a bare Payer -> Payee sitting next to drop slot X would leave the reader to work out which kind of thing is being changed.

drop slot cancels a live claim, which is why it has to be said rather than inferred. The slot is marked cancelled and its deadlines are retired — and its callback token is cleared, so a URL somebody is still holding stops working. That is the one place a minted callback URL is meant to stop working; everywhere else it survives a migration, because it is addressed to the run and the slot, not to a version.

Both are checked when you deploy: the slot has to exist under that name in the version being replaced, and a rename's new name has to exist on the state the run lands in. And a handler that drops every wait a state has, while saying the run stays there, is refused — that would park it where nothing could ever advance it, so it has to say where the run goes instead. The same check runs per run at drain time, for the case only the run knows: the other slots exist, but this particular run had already satisfied them.

Deadlines: the one thing that has no safe default#

A run parked at a wait is usually counting against a deadline — an Assigned race to take the work, a Finished budget to do it. Those are re-pointed for you, like everything else that can be matched: a deadline is identified by its kind on the slot it hangs off, so it survives a rename of either.

What is re-pointed is also re-read. If you changed the budget, the run gets the new one — a clock that pointed at the new declaration while still counting the old number would fire at a time no source anywhere states.

That leaves a question only you can answer, and it is the reason this verb exists:

on AwaitingApproval {
  keep;
  carry clock Finished on Payee;   // keep the time already spent, against the new budget
  // reset clock Finished on Payee;  // or: start the new budget from now
}

Suppose a run has spent three hours of a four-hour SLA and you shorten it to two. Carrying the elapsed time puts that run instantly past its deadline — deploy once, breach every live SLA at the same moment. Resetting hands it a fresh two hours and quietly forgives three that really passed, so a breach that happened stops being visible. Neither is a default anyone would want applied silently, so a deploy that moves a live budget and says nothing about it is refused, naming the timer, both budgets, and both spellings.

The kind is Assigned or Finished, and the slot is named as the version being replaced spells it — the same rule the slot verbs follow, because that is the only version a parked run's wait exists in.

You only write this when a budget MOVED. Everything else about a deadline is handled for you:

what you did to the declarationwhat happens to a parked run's timer
left it alonecarries — same budget, same accrued time
changed the budgetyou say: carry or reset. The deploy stops until you do.
moved it between a slot, its state and the workflowcarries. It is the same deadline, one level out
added onearms when the run reaches it, like any new run
removed itretired. The run keeps going with one fewer obligation

Moving a declaration between levels changes nothing. A Finished block on a slot, on its state, or on the workflow as a default is the same obligation written at three levels of reach; each slot takes the nearest one that applies to it, and that is settled when you compile. So tidying Finished { Within = 4h; } off three slots and onto the state they share is not a change any parked run can see, and needs no verb.

With one edge worth knowing, because it does not look like a deadline change at all. Only a pool slot — one that declares Candidates — inherits a deadline from its state or its workflow; a plain subscribe Submit(); is not SLA-tracked and takes nothing from above it. So if a slot was relying on an inherited SLA and you remove its Candidates, you have removed its deadline too, and parked runs have that timer retired. The report and the timeline both say so, but the line you edited was about who can claim the work.

Removing a deadline retires it, and needs no verb either. Unlike a budget that moved, deleting a declaration has only one reading: the promise was withdrawn. So the timer stops, the run carries on, and nothing breaches — a run that can no longer be late is not a run that failed. The alternative would be to refuse the move and strand somebody's work over a deadline you deliberately deleted.

Automatic is not the same as invisible. A retired deadline is recorded in three places: osy migrate reports how many stopped and which, the run's own timeline gets a Retired entry naming the promise and the budget it was running under, and compile --generate-migration says so in the file when it is writing one anyway. It is its own timeline kind rather than a deadline "moved to zero", because zero would mean breached this instant — the opposite of what happened, and a timeline is read long after anyone remembers the deploy.

A reminder removed from a milestone that still exists is the same thing one level in: that nudge stops, and the SLA it hung off keeps running.

osy compile --generate-migration spots a moved budget exactly and still will not choose for you: it writes the move and both spellings, commented out, so the file does not deploy until you uncomment one.

Fan-out slots: keep the form#

A slot that fans out — one wait per element — migrates like any other, with its per-element slots carried across. What it cannot survive is a change of FORM: fanning out over a fixed list of enum members and fanning out over a runtime collection are addressed differently (by the member's name, and by whoever is acting), so the keys a parked run's slots carry mean nothing to the other. Nothing can translate them — a member name is not a row.

So a deploy that flips the form, or that makes a plain wait fan out (or the reverse), refuses the runs holding those slots, naming the slot and which way it changed. They stay on the version they were parked on, where they still work. Keep the form for runs in flight, or end them deliberately with terminate.

Several deploys at once, in order#

A run can be several versions behind — it was parked while three deploys went out. Each version PAIR carries its own instruction, and they are applied in order, one hop at a time: v1's migration first, then v2's, then v3's.

That is not a detail. The verbs are cumulative: a goto in the first hop decides which state the second hop's handlers apply to at all. A run parked in Waiting, with v1 -> v2 saying goto Reviewing; and v2 -> v3 saying goto Approved;, lands in Approved — not in Waiting, and not nowhere. A deploy that carried no migration is simply a hop with nothing to say, so a mixed history needs nothing special from you.

The instruction is kept by the platform when you deploy, so it is still there when the run finally drains — which may be long after the deploy that carried the file. Your .migration stays where it belongs: in source control.

migration OrderFlow v2 -> v3 names the workflow and the version numbers this file spans. They are checked: a file written for one pair of versions refuses to be applied to a different one, the same guard a schema migration's from/to gives you. osy versions shows which version an app is on.

Examples#

The deployed version, with a run parked in AwaitingPayment:

enum Stage { Draft, AwaitingPayment, Done }

entity Order {
  [MaxLength(60)] string? Reference;
  Stage Stage = Stage.Draft;
}

workflow OrderFlow {
  Tracks = Order.Stage; Autostart = false; Initial = Draft;
  event Submit();
  event Settle();
  state Draft           { subscribe Submit(); on Submit { goto AwaitingPayment; } }
  state AwaitingPayment { subscribe Settle(); on Settle { goto Done; } }
  terminal success Done { }
}

The version you are about to deploy, where that state has been renamed:

enum Stage { Draft, AwaitingSettlement, Done }

entity Order {
  [MaxLength(60)] string? Reference;
  Stage Stage = Stage.Draft;
}

workflow OrderFlow {
  Tracks = Order.Stage; Autostart = false; Initial = Draft;
  event Submit();
  event Settle();
  state Draft              { subscribe Submit(); on Submit { goto AwaitingSettlement; } }
  state AwaitingSettlement { subscribe Settle(); on Settle { goto Done; } }
  terminal success Done { }
}

The .migration that carries the parked runs across, one line per state:

migration OrderFlow v2 -> v3 {
  on Draft           { keep; }
  on AwaitingPayment { goto AwaitingSettlement; }
}

A route the new version removed, whose parked runs are ended rather than left waiting on an event nobody will ever raise:

migration OrderFlow v2 -> v3 {
  on Draft     { keep; }
  on Abandoned { terminate cancel "the offline-payment route was removed in v3"; }
}

What you see when a state is left out:

'OrderFlow' v2 -> v3 does not say what happens to a run parked in 'Abandoned'. Every parkable state must appear —
say `keep;` where nothing is needed, so a reviewed state and a missed one never look alike.

See also#

Related

Renaming and removing things that hold data

Renaming an entity or property, and removing one, are changes to something that already holds rows. Say what you meant…

Deploying while workflows are running

A workflow run can outlive the deploy that started it. Deploying with --new-version freezes the code and data shape the…

Step labels (naming a child run so it survives a new version)

Every child workflow you AWAIT carries a label — a literal string you choose, naming that step. Awaiting parks the run…

Tracks and Initial (the field a workflow drives)

Names the enum field a workflow owns and the state a run starts in. No application code may write that field, and when…