Summary#
A workflow drives one field on one entity: the field that says where a row has got to. Tracks names that field
and Initial names the state a fresh run begins in.
Together they hand the field over. From that point it is the workflow's, not the application's — it moves only by transition, and reading it anywhere tells you the truth about the run. When the workflow starts with the row, it supplies the field's opening value too.
Signature#
workflow <Name> {
Tracks = <Entity>.<Property>;
Initial = <State>;
…
}The property is an enum-typed member of the tracked entity, and every state and terminal in the workflow must be a member of that enum — so the field's type is the workflow's vocabulary of states.
Description#
When the workflow starts with the row, the field needs no default#
An Autostart = true workflow begins the moment its row exists, so Initial is the field's value from the start
and the entity does not restate it:
enum ApprovalStage { Pending, Approved, Rejected }
entity Invoice {
[Required] [MaxLength(120)] string Description;
[Required] decimal Amount;
ApprovalStage Stage;
security { allow create, read, update when IsAuthenticated || IsAnonymous; }
}
workflow ExpenseApproval {
Tracks = Invoice.Stage;
Autostart = true;
Initial = Pending;
event Decide(bool approved);
state Pending {
subscribe Decide(bool approved);
on Decide(bool approved) {
when (approved) { goto Approved; }
default { goto Rejected; }
}
}
terminal success Approved { }
terminal error Rejected { Message = "rejected"; }
}A new Invoice reads back Pending with nothing in the source saying so twice.
This matters more than it looks. Elsewhere in the language a bare enum member is required — the platform will not invent a value for it, because the first member of an enum is a position, not a decision, and reordering the members would silently change what every new row means. A tracked field is the one case where a real value is genuinely available: the workflow declared it.
WHEN does it start, exactly?#
"Begins the moment its row exists" is precise, not a loose way of saying "soon": an Autostart = true workflow's
initial enter{} runs synchronously, inside the same commit that created the row — before the next statement
after UnitOfWork.Commit() executes, in the SAME function, with no pump or wait of any kind. A read immediately
after that commit already sees whatever the enter{} body wrote:
enum TicketState { Open, Closed }
entity Ticket {
[MaxLength(80)] string Title;
TicketState Status;
security { allow read, create when IsAnonymous || IsAuthenticated; }
}
entity AuditEntry {
[Required, MaxLength(80)] string Note;
security { allow read, create when IsAnonymous || IsAuthenticated; }
}
workflow TicketFlow {
Tracks = Ticket.Status;
Autostart = true;
Initial = Open;
event Close();
state Open {
enter { new AuditEntry { Note = "opened" }; }
on Close { goto Closed; }
}
terminal success Closed { }
}
[Test]
void AutostartedEnterBody_RunsInTheSameCommit_NoPumpNeeded() {
new Ticket { Title = "t" };
UnitOfWork.Commit();
// No `Workflow.Settle(...)` here — the enter{} body has already run, in the commit above.
Assert.Equal(1, AuditEntry.Count());
}⚠ This is a deliberate platform guarantee, not an implementation detail that happens to hold today. In
production, autostart is ALSO delivered by a durable wf-start job the platform enqueues at commit — the backstop
for a row committed by a path that is not Osy# (a REST write, an import) and for recovering a start this process
died during. But an Osy# UnitOfWork.Commit() drives the autostart pass itself before returning, specifically so
"create a row, then ask about it" reads the way it looks. Starting the same run twice is a no-op by design, so the
durable job racing (or duplicating) the in-process start is harmless.
Workflow.Settle(entity) is a TEST-ONLY primitive for a different problem: draining DUE, ALREADY-SCHEDULED work
— timers, reminders, a milestone's deadline — deterministically, without a real clock to wait on. It has no
counterpart in production code, where that same work is drained by the durable dispatcher on its own schedule. Reach
for it in a test that needs to observe a timer fire or a deadline expire; an autostarted enter{} needs no Settle
at all, because it has already run by the time UnitOfWork.Commit() returns.
A caller with no Osy# session can still trigger an autostart — a webhook, a scheduled tick, a REST write — just
on the durable job's timing rather than in the same instant: the row lands, the write's own commit finishes (with no
run attached yet), and the durable wf-start job picks it up and starts the run shortly after. What is guaranteed
synchronous is specifically an Osy# UnitOfWork.Commit(); every other write path gets the same eventual guarantee,
on the dispatcher's cadence rather than in-line.
Restating it is refused#
Writing the default anyway is a compile error, not a redundancy the compiler tolerates:
ApprovalStage Stage = ApprovalStage.Pending; // 'Stage' on 'Invoice' is owned by workflow 'ExpenseApproval' …The reason is drift. Two copies of one fact stay in step only while someone keeps them there, and the copy a reader
believes is the one in the entity — so the day Initial changes and the declaration does not, the source says
something false and nothing reports it. One statement of the fact cannot disagree with itself.
The field is read-only to application code#
No function, action, or object initializer may assign a tracked field; a workflow's own handlers may. This is the same ownership seen from the other side — if application code could set the field, the state would no longer mean "where this run has got to", it would mean "whatever was written last".
invoice.Stage = ApprovalStage.Approved; // refused — the workflow drives itTo move a run, send it an event and let a route transition it.
When the run starts later, the field's earlier value is yours to state#
Initial is where the run begins, not where the row begins. Those are the same moment only when the workflow
autostarts. If it does not — Autostart = false, or a condition that is not yet true — the row exists for a while
with no run attached, and what the field reads in that window is a fact only you know:
enum ReportStage { Draft, InApproval, Approved, Rejected }
entity Report {
[Required] [MaxLength(120)] string Title;
DateTime? SubmittedAt;
ReportStage Stage = ReportStage.Draft;
security { allow create, read, update when IsAuthenticated || IsAnonymous; }
}
workflow ReportApproval {
Tracks = Report.Stage;
Autostart = this.Item.SubmittedAt != null; // starts on submit, not on create
Initial = InApproval;
event Decide(bool approved);
state InApproval {
subscribe Decide(bool approved);
on Decide(bool approved) {
when (approved) { goto Approved; }
default { goto Rejected; }
}
}
terminal success Approved { }
terminal error Rejected { Message = "rejected"; }
}Draft is a member of the tracked enum but not a state the workflow ever occupies — it is where a report sits before
the workflow has anything to do. Here the default is required, and it is not refused: the workflow is not claiming to
supply it.
So the question "does my tracked field need a default?" has one answer: does the workflow start when the row is created? If yes, the workflow supplies the value and you must not restate it. If no, it is yours.
Examples#
An Initial that is decided per row rather than fixed — the field then starts wherever the expression lands when the
run begins, and a declared default is legal for the same reason as above:
workflow ExpenseApproval {
Tracks = Invoice.Stage;
Initial = this.Item.Amount > 500 ? ApprovalStage.Pending : ApprovalStage.Approved;
…
}Notes#
Every state must be a member of the tracked enum. A state the enum does not name is a compile error — the two declarations are one vocabulary, so they cannot drift apart either.
Autostart decides when a run begins — and therefore whether the field's opening value is the workflow's to
supply or yours to declare. That is the one thing to carry away from this page.
See also#
- subscribe — declaring what a state waits for
- complete when (a state's own completion condition) — leaving a state on a condition rather than an event
- Workflow.Run (start a workflow) — starting child work from a state