Status: DRAFT. The classification, its lattice, and where the compiler places a durable step are probed — the first two through
osy model --json, the third read from the resolver that performs the lowering. What a crash actually does is runtime behaviour and is UNVERIFIED here; §9.7 names the guards.
§11.1 and §11.2 both refuse a C# construct "because a call can suspend and resume in a different process". This
section is that claim, stated properly. It is the reason the language has no out parameter and no async.
9.1 The premise
Normative. A call MUST NOT assume that the process which began it is the process which finishes it.
Everything below follows from that one sentence. An Osy# call may suspend — for a timer, for a human, for a child workflow — and resume later, elsewhere. So the stack frame is not a durable place: nothing may be written back into it (§11.1), and nothing may be scheduled against it (§11.2).
⭐ The interesting consequence is that a re-run is normal, not exceptional. A continuation-based engine replays nothing wholesale, but it does re-execute in two places: a statement is replayed after an in-process sub-call returns, and a statement that suspended is re-entered on resume. So "what does it cost to run this leaf twice" is a question the engine must be able to ask of every leaf in the program — which is what §9.2 is.
9.2 The durability classification
Every leaf the language provides carries one of three values.
| value | a re-run… | so it is |
|---|---|---|
| Deterministic | reproduces its value, and nothing escaped | free — never recorded |
| Nondeterministic | yields a different value, but nothing left the platform | memoized on the per-statement node memo |
| External | is a second real-world effect — a second charge, a second enqueue | given its own durable step |
Source: StdlibDurability, Platform.Core/Dsl/OsySharp/Stdlib/OsySharpStdlib.cs.
Normative. The three values form a total order — Deterministic < Nondeterministic < External — and the
durability of a construct MUST be the maximum over its parts. That is what makes the classification usable as a
lattice over whole programs rather than only over the leaves it was defined on.
⚠ External is about whose system rolls back, not about I/O. A commit to the application's own database is
Nondeterministic: the platform owns the transaction. File.WriteAllText is External: the filesystem does not
roll back because a later segment failed.
9.3 Asking a program what it costs
Normative. An implementation MUST report the classification, and MUST report a witness for it.
⚑ Probed 2026-08-27. For a function whose body is Security.RandomId() and then File.WriteAllText(path, id) —
a Nondeterministic leaf written first and an External leaf written second — osy model --json reports:
Archive direct: External durabilityVia = File.WriteAllText
Wrapper direct: Deterministic via = (none)
trans : External durabilityVia = Archive → File.WriteAllTextThree separable facts, each of which a reader would plausibly guess wrong:
- The lattice wins over source order.
Externalis reported although the nondeterministic call is written first. The value is a max, not a last-writer. durabilityViais a path, not a name. Through a call graph it readsArchive → File.WriteAllText— the route by which a plain-looking call reaches an egress. This is the field that makes the classification actionable rather than merely present.effectsandtransitiveare different answers and both are given.Wrappercalls nothing butArchive: it isDeterministicdirectly andExternaltransitively. A reader who consults only one of them will be wrong about half the functions in any real program.
9.4 Where a durable step goes — the leaf, never the function
Normative. An implementation MUST place the exactly-once boundary at the egress leaf, not at the enclosing function.
⭐ This is the load-bearing design decision of the whole section. If Notify(u) makes external calls A and then
B, and the whole of Notify is one coarse step, a crash between A and B re-runs A on resume — a double send. Each
egress is therefore its own step, and the deterministic orchestration between them re-runs freely, being a pure
function of the memoized results.
It is automatic. The resolver wraps every External leaf in a durable step during lowering. An author writes
Http.Get(url) and gets exactly-once without naming it. Workflow.Once("key", …) remains available for a step the
author wants to name or key, and an already-stepped leaf is not wrapped twice.
⚠ So the transitive lattice of §9.3 does not decide anything. It is an author-facing signal — "this plain-looking call reaches an egress". Step placement is a purely local decision at each leaf. Reading the lattice as the mechanism gets the model backwards, and is the likeliest misreading of this section.
A nondeterministic leaf gets no step, deliberately. DateTime.UtcNow and Guid.NewGuid ride the per-statement
node memo, which already serialises into the continuation. Giving each one its own out-of-band database write would
be a real cost for a guarantee they already hold. Only egress — where the second execution reaches someone else's
system — earns one.
⚑ An unclassified target defaults to External. A leaf missing from the durability table is treated as egress,
so an omission over-protects rather than under-protects. Informative: the platform has been bitten by the
resulting symptom more than once, which is itself the argument for the default being this way round.
9.5 A step wraps one thing
int Charge(int amount) { return Workflow.Once("charge", () => amount * 2); }int Charge(int amount) { return Workflow.Once("charge", () => { var a = amount; return a; }); }A step body is an expression. Statements go in a function, which the step then calls.
⚠ Informative, and honest about a rough edge: the refusal above is real and stable, but its diagnostic explains itself in terms of query lambdas ("a block can't lower to SQL in a query predicate") because block-bodied lambdas are refused language-wide from one site. At a durable-step call site that reason is not the applicable one. The refusal is right; the explanation is aimed elsewhere. Recorded as a §12 diagnostics concern.
9.6 await means park
Restating §11.2 from this side, because it only makes sense here: await Workflow.Run(…) does not yield a thread.
It parks the run durably — possibly for days — and resumes it in whatever process next picks it up. The keyword
is reused because the English reading is right; the mechanism is this engine, not a task scheduler.
9.7 What this section does NOT establish
UNVERIFIED, deliberately. Every claim above is about what the compiler classifies and lowers. None of it establishes what a crash does. Specifically unproven here:
- that a step's result survives a process restart and is not re-executed on resume;
- that a memoized nondeterministic value is the same value after resume;
- that a durable step commits out-of-band, so a crash before the enclosing segment commits still finds it.
Those need runtime tests. The guards that exist are AutoDurableLoweringTests (the leaf-level wrap),
WorkflowOnceDurableStepTests and WorkflowStepMemoIdentityTests (memo identity across a resume),
DurableFunctionRunnerTests (resume itself), and FunctionEffectsDurabilityTests with
OsySharpStdlibDurabilityTests (the classification and its rollup). A future revision MUST cite which of them
proves which of the three claims above, or drop the claim.
Next: §8 Security as a language rule — the other declaration-time rule, and the one with the most surface. Not started.