# Moving runs onto the version you just deployed

> Deploying a new version does not move the runs already in flight — they keep executing the version they started under. migrate moves them forward, applying whatever each deploy's migration said, and workflow-runs shows you what is still behind.

<!-- id: project-migrating-runs · area: project · stability: stable · html: https://osysharp.com/reference/project/migrating-runs/ -->

## Summary        {#summary}
A run in flight keeps executing the version it started under. That is the guarantee that makes deploying safe while
work is in progress — and it means a deploy leaves runs behind, on purpose.

A deploy moves them forward for you, as its last act. `migrate` is how you finish the job afterwards — for runs a
deploy would not move until you authored a verb for them, for runs parked mid-body, and for a deploy you deliberately
told not to drain. It applies whatever the deploys in between said should happen to each run. `workflow-runs` shows
you what is still behind before you do it, and `--dry-run` shows you exactly what would happen without doing anything.

## Signature      {#signature}
```console
$ osy workflow-runs                # what is in flight, and what is behind
$ osy migrate --dry-run            # decide everything, write nothing
$ osy migrate                      # move them
```

## Description    {#description}

### Seeing what is behind   {#listing}
`versions` answers "why is this app still carrying six schemas" — a count of runs per version. That is the right
question for reclaiming storage and the wrong one after a deploy, because a count cannot tell forty healthy runs from
forty stragglers nobody can move.

`workflow-runs` names the work instead:

```console
$ osy workflow-runs
╭────────────┬──────────────────┬─────────┬──────┬───────────────┬────────╮
│ workflow   │ state            │ status  │ runs │ version       │ oldest │
├────────────┼──────────────────┼─────────┼──────┼───────────────┼────────┤
│ OrderFlow  │ AwaitingPayment  │ waiting │    6 │ 1.2.0 (behind)│ 11d    │
│ OrderFlow  │ Draft            │ waiting │   41 │ 1.3.0         │ 2h     │
╰────────────┴──────────────────┴─────────┴──────┴───────────────┴────────╯
```

Rows that are BEHIND come first and are called out, because they are the ones you can act on.

### The drift `migrate` cannot see: an older revision     {#not-in-current-source}
There is a second way a run drifts from your source, and it is invisible to everything above — because the run never
goes *behind* at all.

While you are developing, a compile edits the current version in place. Change a workflow and the platform mints a new
**revision** of it and keeps the old one, so a run that is parked mid-flight goes on resolving through the revision it
started under. That is deliberate and it works: the run resumes and finishes normally, even if you deleted the state
it is sitting in.

What it means, though, is that the run is executing a definition **you can no longer read**. And since the run is on
the current version, `migrate` correctly reports that there is nothing to move — an answer that is true and reads like
a clean bill of health.

So `workflow-runs` says it outright, and sorts those rows above even the stragglers:

```console
$ osy workflow-runs
╭────────────┬───────────────────────────────────┬─────────┬──────┬─────────┬────────╮
│ workflow   │ state                             │ status  │ runs │ version │ oldest │
├────────────┼───────────────────────────────────┼─────────┼──────┼─────────┼────────┤
│ OrderFlow  │ Draft (not in current source)     │ waiting │    3 │ 1.3.0   │ 20m    │
╰────────────┴───────────────────────────────────┴─────────┴──────┴─────────┴────────╯
Some runs are parked on a state your current source no longer declares. They still run — each keeps the workflow
revision it started under — but they are executing a definition you can no longer read. `migrate` will not report
them: they are already on the current version. Re-add the state, or cancel those runs (`osy cancel-runs`).
```

⚠ A **deploy** does not have this shape: it mints a new app version, and a migration then has to say where every
parked run goes before it can move one. This is a development-loop phenomenon, which is exactly why it needed saying
out loud — it is the one place a run and its source part company with nothing stopping it.

### Moving them   {#migrate}
`migrate` takes every run that is not on the current version, one at a time, under that run's own lock. For each it
applies the [Migrating runs that are still in flight](https://osysharp.com/reference/workflow/migration/) each deploy authored — hop by hop, in order, so a run several versions behind gets
every author's decision rather than a guess made from the endpoints.

A pass has three outcomes and they are reported separately, because they are not variations of each other:

- **moved** — the ordinary case. The run is now resolving the current version's code.
- **ended** — an authored `terminate` stopped it, on the version it was already on, with the reason somebody wrote at
  the time. You are seeing the effect of a decision made weeks ago; the message is the thing to read.
- **refused** — nothing could be decided safely, so nothing was done to that run. The reason names what could not be
  matched and the verb that would answer it. The run is untouched and can still finish on its own version.

### Always dry-run first   {#dry-run}
`--dry-run` decides everything and writes nothing. It is not a partial answer or an estimate — it is the same pass,
with the writes withheld, so the report is exactly what a real run would produce.

That matters most for the two outcomes you cannot undo by re-running: an ended run is ended, and a moved run has left
the version it was on. Reading the report first costs a few seconds.

### Refusals are a normal outcome, not an error   {#refusals}
A refusal means a state, a slot or a deadline in the new version could not be matched by name, and nothing in a
migration file said what to do about it. The run keeps working: it stays where it is, on the version that still
declares everything it needs, and can be completed by whoever is holding it.

The fix is a [Migrating runs that are still in flight](https://osysharp.com/reference/workflow/migration/) in the next deploy, and the refusal names the verb. `compile --generate-migration`
writes the file for you — including a `keep;` for every state nothing happened to, and a placeholder that will not
deploy for anything it could not decide.

### What a move does to a run's deadlines   {#deadlines}
Every live deadline the run holds is re-pointed at the new version's declaration AND re-read against it, so a budget
you changed takes effect on runs that were already waiting. A budget you did not change carries silently, and so does
a declaration you merely moved between a slot, its state and the workflow — that is the same deadline written at a
different level of reach.

Changing a live budget is the one case that cannot be settled here, because it has to be settled at DEPLOY time: the
deploy refuses until the migration says `carry clock <Kind> on <Slot>;` (keep the time already spent) or
`reset clock <Kind> on <Slot>;` (start the new budget from now). See [Migrating runs that are still in flight](https://osysharp.com/reference/workflow/migration/). So by the time you run
`osy migrate`, that decision has already been made and written down.

**A deadline you REMOVED is retired, and the pass tells you.** Deleting a declaration has one reading — the promise
was withdrawn — so the timer stops, the run moves normally, and nothing breaches. A run that can no longer be late is
not a run that failed, and refusing the move would strand somebody's work over a deadline you deliberately deleted.

```console
$ osy migrate
40 run(s) moved:
  OrderFlow  AwaitingApproval  app_v2 → app_v3
1 deadline(s) RETIRED — the new version no longer declares them:
  OrderFlow Finished on Payee — 40 run(s); they keep going, with one fewer obligation. Nothing breached.
```

It is grouped by deadline, not listed per run: the useful number is *which promise stopped, for how many*. Each run
also carries a `Retired` entry on its own timeline, so it is still explicable months later — `osy inspect` and the
Admin Runs tab both read it.

### A deploy already does this for you   {#deploy-drain}
**Since 2026-08-08 a deploy moves the runs it strands, as its last act** — so in the ordinary case you never run this
command at all. A deploy is exactly the moment runs get left behind (they were parked when the version changed under
them), and a follow-up command an operator has to remember is one that does not get run on a scheduled or automated
deploy.

What the deploy reports is what this command would have: how many moved, how many an authored `terminate` ended, and
— as **warnings on the compile** — anything it would not move, grouped by reason. A refusal never fails the deploy:
the version is already minted and live by the time the runs are moved, so a refusal is an outcome to read, not an
error to retry.

It also means an old version is reclaimed by the **same** deploy that drains it, rather than the next one: the runs
move first, and the version garbage collector then finds nothing holding the old schema open.

So you run `osy migrate` for the cases the deploy could not finish:

- you have **authored the verbs** a previous deploy's refusals asked for and want to move those runs now, without
  waiting for another deploy;
- a run was parked **mid-body** (an awaited child or a saga step) and could not be moved yet;
- you deployed with the drain **turned off** deliberately — see below.

### And it keeps trying afterwards   {#retry}
A deploy's drain is one pass, and a single pass does not always finish: a run can fail to move because something else
was holding its data, an old parent can start an old child while the pass is running, and a very large backlog can
outlast the pass's own backstop. So the platform re-runs the pass on a schedule, per app, with no configuration and no
cron of yours. It is the same pass this command runs — there is one answer to "where does this run go", not one for
you and a different one for the background.

**It knows the difference between "not yet" and "not ever", and that is the point.** A run refused because a state has
no counterpart, or because its author has not written `reenter;`, will be refused identically for ever — coming back
in an hour cannot help it. Those refusals are reported once and then counted, and they do not make the sweep hurry.
Only something that could genuinely go differently next time — a run that errored mid-move — brings the next pass
forward.

The practical consequence: **you do not have to babysit a deploy's refusals.** Author the verb whenever you get to it
and deploy; if instead the problem was transient, it will already have cleared itself. Nothing accumulates silently —
`osy workflow-runs` still shows exactly what is behind.

### Deploying now and draining later   {#deferred-drain}
The drain takes each run's own lock and re-bases its clocks, so on an app with thousands of runs parked it is real
work. A deploy can skip it and leave the runs for a controlled window; nothing is lost by waiting, because a parked
run keeps executing against the version it started on — that is the whole point of the versioning model. The only
cost is that the old schemas stay held until you run `osy migrate`.

## Examples       {#examples}
The usual sequence after a deploy that renamed a state:

```console
$ osy workflow-runs
# → 6 OrderFlow runs in AwaitingPayment, on the previous version

$ osy migrate --dry-run
Dry run — nothing was written.
6 run(s) moved:
  OrderFlow  AwaitingSettlement  app  →  app_v2

$ osy migrate
6 run(s) moved.
```

And a pass with something to look at:

```console
$ osy migrate
4 run(s) moved:
  OrderFlow  Draft  app_v2 → app_v3
1 run(s) ENDED by an authored `terminate`:
  OrderFlow in Abandoned on app_v2 → cancel: the offline-payment route was removed in v3
2 run(s) could not be moved:
  OrderFlow on app_v2: slot 'Payer' of state 'AwaitingPayment' has no match in 'app_v3' — it was
  removed or renamed. An authored migration must re-point it (`rename slot Payer -> <New>;`) or drop
  it (`drop slot Payer;`), which cancels its claim explicitly.
```

## See also       {#see-also}
- [Workflows that outlive the code that started them](https://osysharp.com/reference/workflow/change-over-time/) — the model these commands operate on: how a run's position survives a deploy
- [Migrating runs that are still in flight](https://osysharp.com/reference/workflow/migration/) — the file that says where a parked run goes, and how it is generated
- [Deploying while workflows are running](https://osysharp.com/reference/project/app-versions/) — what a version is, and which runs are holding one open
- [Stopping runs after a bad deploy](https://osysharp.com/reference/project/cancel-runs/) — the other way out: stop runs on a bad version rather than move them
- [Renaming and removing things that hold data](https://osysharp.com/reference/project/renaming-and-removing/) — the same file's other half, about the DATA a change touches
