Summary#
A workflow run can last far longer than the gap between two deploys — an approval that waits three days, an onboarding saga that waits for a signature, a fan-out that waits on a hundred children. So a deploy will land while runs are partway through, and the question that decides whether your app is correct is: when that run wakes up, whose code does it execute?
Deploy with --new-version and the answer is its own. The deploy is frozen as a new app version; every run already
in flight goes on resolving and executing against the code and the data shape it started under, and every run started
after the deploy uses what you just deployed. Both versions run side by side until the older runs drain.
Signature#
osy compile --new-version // local
osyrin app compile --new-version // a deployed appDescription#
What a version is#
An app version is a frozen snapshot of what the app is: its entities, functions, workflows, screens, and the shape
of its data. Deploying with --new-version records one and makes it current. Nothing is copied and nothing is
duplicated — your rows stay exactly where they are, in one place, shared by every version. What is frozen is the
description of them.
That split is what makes the guarantee cheap. A run pinned to an older version reads and writes the same rows as everything else; it just reads them through the shape it was compiled against.
What a run in flight keeps#
A run that was already going when you deployed keeps:
- its logic — the body of the workflow, and of every function that workflow calls, exactly as it was written when the run started. Editing what a step does will not change what a run halfway through that step is going to do;
- its data shape — the properties it knew about. A property you removed in the new version is still there for that run, and its column is retained until no run needs it any more.
A run started after the deploy gets the new logic and the new shape. Nothing you do to source affects a run that has already begun.
When you need the flag#
- Long-running workflows. Anything that waits: an approval, a scheduled reminder, a saga waiting on a child, an external callback. The longer it waits, the more likely a deploy lands under it.
- Any change to a workflow, or to a function a workflow calls. Body edits count — changing what a step does is precisely the change a run in flight must not see.
You do not need it for a change nothing is waiting on. Deploying a new screen, a new report or an unrelated entity is an ordinary deploy.
When it happens without you asking#
A production app decides for itself: it freezes a version when the change would affect a run in flight, and
otherwise deploys in place. --new-version overrides that and always freezes one.
A development app never freezes a version unless you ask. That keeps the inner loop fast — you edit, compile, and
run against exactly what you last wrote. Pass --new-version when you specifically want to rehearse the upgrade: park
a run, deploy, and watch it finish on its old body.
Seeing what versions you have#
$ osy versions # local
$ osyrin app versions # a deployed appNewAdmin — application versions
╭─────────┬────────┬───────────┬──────────────────────────┬────────────────────────╮
│ Version │ Schema │ In flight │ Oldest run │ State │
├─────────┼────────┼───────────┼──────────────────────────┼────────────────────────┤
│ 1.0.0 │ app │ 2 │ 2026-07-25 16:47 (3h ago)│ held by runs in flight │
│ 1.1.0 │ app_v2 │ 0 │ — │ current │
╰─────────┴────────┴───────────┴──────────────────────────┴────────────────────────╯In flight is the number that matters. It is why a version is still there, and it is the only thing standing between
an old version and being cleaned up. Oldest run tells you how long you have been waiting for the last stragglers to
finish. Add --json for a machine-readable form.
Old versions clean themselves up#
You do not accumulate a version per deploy. Once nothing is running against a version any more, the next deploy
reclaims it, and versions shows it as reclaimed. Two versions are never touched:
- the current one, which new runs start on;
- any version with a run still in flight — dropping it would strand that run with nothing to resolve against, which is the exact failure versioning exists to prevent.
So the usual steady state is one version, plus however many are still finishing work. A long-waiting run is the normal
reason to see an older version hanging around, and the In flight column tells you which one.
Reclaiming a version also eventually reclaims the columns of properties you removed. Deleting a property does not drop its column straight away: runs on older versions may still be reading and writing it. Once every version that declared the property is gone, the column can go too — a separate, explicit step, because unlike a version schema a column holds the only copy of its data. Only columns whose removal your app actually recorded are taken; anything else that turns up on a table is reported to you and left alone.
Removing a property or an entity is itself a change you have to acknowledge — see Renaming and removing things that hold data.
The one change that is refused#
Changing the stored type, width or precision of a property that already exists is refused on an app with data, whether or not you version it. There is no shape a column can take that is simultaneously the old type for a run still pinned to it and the new type for the code you just deployed.
Evolve the type additively instead:
- add a new property of the new type — an additive change deploys cleanly and disturbs nothing;
- copy the data across;
- once every reader and writer uses the new property, remove the old one — a removal like any other, so it needs its one line of acknowledgement (see Renaming and removing things that hold data).
While both properties exist, a write to the old one is not reflected in the new one. Move all writes over before the final copy, or write to both during the transition — anything written to the old property in that window is lost.
Removing a required property in step 3 makes its retained data optional. It has to: the version you just deployed no longer knows about the property, so it has nothing to put there, and rows created from now on simply leave it empty. A run still pinned to an older version will therefore find that value missing on any row created after the removal, even though the version it was compiled against believes the property is always present. Finish the cutover — step 2 — before anything you care about starts depending on the old property being filled in.
Deploying is still one command#
Everything else a deploy does is unchanged when it freezes a version: your icons, artwork, control packages and per-environment configuration are part of the deploy and land with it, and the app's generated API description is refreshed to match what you just shipped.
Examples#
A workflow whose run can easily outlive a deploy — it waits for a human:
enum ApprovalStage { Pending, Approved, Rejected }
entity Invoice {
[Required, MaxLength(120)] string Description;
[Required] decimal Amount;
[MaxLength(200)] string? Outcome;
ApprovalStage Stage = ApprovalStage.Pending;
}
workflow ExpenseApproval {
Tracks = Invoice.Stage;
Initial = Pending;
event Decide();
state Pending {
subscribe Decide();
on Decide {
when (this.Item.Amount <= 500) {
this.Item.Outcome = "auto-approved under the original policy";
goto Approved;
}
default { goto Rejected; }
}
}
terminal success Approved { }
terminal success Rejected { }
}Now raise the auto-approval threshold to 1000, reword the outcome, and deploy:
osy compile --new-versionAn invoice already sitting in Pending when that deploy landed is still judged at 500 when the decision finally
arrives, and still writes "auto-approved under the original policy" — it finishes under the policy it entered. An
invoice submitted after the deploy is judged at 1000. Neither one had to know the other existed.
See also#
- Renaming and removing things that hold data — renaming or removing something that already holds rows
- Stopping runs after a bad deploy — stopping runs that are still executing a version you want rid of
- app.osy — what a deploy is built from
- Workflow.Run (start a workflow) — starting a child workflow and waiting for it
- Workflow.BeginSaga (a compensating saga scope) — long-running sagas, the runs most likely to span a deploy