Osy#betaRun it on Osyrin: osyrin.com

Reference / Workflow

Tracks and Initial (the field a workflow drives)

Tracks = <Entity>.<Property>; Initial = <State>;

Names the enum field a workflow owns and the state a run starts in. No application code may write that field, and when the workflow autostarts it also supplies the field's starting value, so the entity declares no default.

stable5 examples compiled by CIworkflowauthoring

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:

using Osysharp.Workflow;

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:

using Osysharp.Workflow;

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.

One bound, and where it does not apply. A served app's commit waits up to 5 seconds for that first step. An entry that takes longer — it calls a model, fetches a page, does real work — is handed to the durable wf-start job and keeps running, and the commit returns without it: so a read on the next line can see the row from before its run entered. Under osy test there is no bound: a fixture's seed and a test (and the server calls its clicks make) always wait for the first step, so a busy machine cannot turn into a different test result — which also means no test will show you the bound. osy lint does: workflow-first-step-waits-on-a-slow-call names an autostarted workflow whose first step reaches the network or a model, directly or through anything it calls.

⚠ The first step is more than the first enter. It is the Start body, the initial state's enter, and the enter of every state that enter sends the run to with goto — the engine takes those moves in the same call. So moving a slow call into "the next state" behind a goto in enter does not take it out of the first step. What does is a state the run parks in and leaves by a route the platform runs later — here a clock due at once:

using Osysharp.Workflow;
using Osysharp.Http;

app Outbox {
  model "**/*.osy";
  use Osysharp.Http;
  use Osysharp.Workflow;
}

enum NoticeState { Queued, Sent, Held }

entity Notice {
  [MaxLength(200)] string To;
  NoticeState Status;
  security { allow read, create when IsAnonymous || IsAuthenticated; }
}

workflow NoticeDelivery {
  Tracks    = Notice.Status;
  Autostart = true;
  Initial   = Queued;

  state Queued {
    // Quick: only what can be decided here.
    enter { if (this.Item.To == "") { goto Held; } }
    // A clock due at once. Its route runs after the commit, so the commit never waits on the far end.
    Expire { Within = TimeSpan.Zero; Accrues = false; }
    on Expire {
      try {
        var r = Http.Post("https://notices.example/send", this.Item.To, "text/plain");
        if (!r.IsSuccess) { Log.Warning("notice to {To} refused: {Status}", this.Item.To, r.StatusCode); }
      }
      catch (Exception e) { Log.Error(e, "notice to {To} failed", this.Item.To); }
      goto Sent;
    }
  }

  terminal success Sent { }
  terminal cancel  Held { }
}

[Test]
void The_commit_returns_before_the_send_and_the_clock_makes_it() {
  Http.Post.Stub((url, body) => new HttpResponse { StatusCode = 202, Body = "" });
  var n = new Notice { To = "[email protected]" };
  UnitOfWork.Commit();
  Assert.Equal(NoticeState.Queued, Notice.Where(x => x.Id == n.Id).First().Status);   // parked, nothing sent yet

  Workflow.Settle(n);   // in a test, due clocks fire when you settle; in production the dispatcher fires them
  Assert.Equal(NoticeState.Sent, Notice.Where(x => x.Id == n.Id).First().Status);
}

The first enter cannot refuse the row#

The row is committed before the run starts, so nothing an autostarted enter{} does can take the row back. A throw in it does not fail the caller's UnitOfWork.Commit(): the row stays, the fault is logged as a warning (in-process autostart failed), and the durable job retries the start later. If the first step is a DECISION that can go either way — is the stock still there, is the slot still free — make it a state of its own that moves on to one outcome or the other, and let the caller read which after its commit:

workflow OrderLife {
  Tracks = Order.Status;
  Autostart = true;
  Initial = Placed;

  state Placed {
    enter {
      if (this.Item.Lines.Any(l => l.Variant.Stock < l.Quantity)) {
        this.Item.Problem = "Some of it has just sold.";
        goto Unavailable;
      }
      foreach (var l in this.Item.Lines) { l.Variant.Stock = l.Variant.Stock - l.Quantity; }
      goto Reserved;
    }
  }
  state Reserved { }
  terminal cancel Unavailable { }
}

string Place(Order order) {
  UnitOfWork.Commit();
  return order.Status == OrderStatus.Reserved ? "Reserved." : order.Problem;
}

The body writes as the system (What a workflow body may write), which is what lets an anonymous visitor's order take stock the visitor could never write directly. Two such entries racing for the last unit meet at ConcurrencyCheck: one commits and the other's start is retried, and the retry sees the stock as it now is.

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 it

To move a run, send it an event and let a route transition it.

A workflow's own handlers may assign the field, as above — its own field, on the row its run is bound to. A handler of a different workflow may not: that field belongs to the other workflow, so the compiler refuses it and names the owner. Raise the owner's event instead.

Nothing else writes it either — the platform refuses it at commit#

The compiler can only see the Osy# you compile. A tracked field is also guarded where every write lands, so the rule holds for writers that are not your source at all:

  • A page's client, or anyone sending requests by hand. Changing the field of an existing row through the data API is refused when the change is saved, even when the row's security { } lets that person update it. A customer allowed to edit their own order cannot move it to Shipped by sending Status = Shipped.
  • A new row cannot choose where it starts. It may leave the field out (or carry its declared default); any other value is refused. Where a row starts is Initial's decision.
  • Platform code with full privileges is no exception. Running as the system is not being the workflow.
  • Bulk statements — .Update(…) over a query, or an insert from a query — may not name the field at all, because they write rows no run is working on.

What does write it: the run bound to that row, when it enters a state (a start, a goto, a terminal); a handler of that same run assigning it; and a version upgrade moving the run to its new version's state. The refusal names the field, its workflow, and the event to raise instead:

'Stage' on 'Invoice' is owned by workflow 'ExpenseApproval' (Tracks = Invoice.Stage) — only that row's own run may
move it, and this write did not come from it. Raise one of 'ExpenseApproval''s events instead
(`ExpenseApproval.Raise<Event>(row, …)` or `Workflow.Raise(row, <Event>(…))`), and let its transition move the field.

So the field always means what it says: where that row's run has got to, and never whatever somebody wrote last.

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:

using Osysharp.Workflow;

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#

A starting state decided per row rather than fixed. Initial only ever names a state — Initial = <expression> is refused — so the choice goes in the Start body, which runs before the run enters Initial and may goto any state instead, with the whole row in scope. A goto names one state, so a choice among several is one branch each:

using Osysharp.Workflow;

enum ApprovalStage { Pending, Approved, Rejected }

entity Invoice {
  [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);

  Start {
    if (this.Item.Amount <= 500) { goto Approved; }   // a small one is approved on arrival; the rest start Pending
  }

  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"; }
}

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#

Related

subscribe

Declares that a workflow state waits on an event, and configures the wait — who may hold it, who may hand it on…

complete when (a state's own completion condition)

Declare, once on a state, the condition under which that state is finished and where the run goes next. It is…

Workflow.Run (start a workflow)

Start the workflow bound to an entity's type, on that entity. Bare — `Workflow.Run(order)` — is fire-and-forget: start…

An entity's lifecycle — a workflow that tracks its status, or one function per move

A row that moves through states, like an order going from placed to paid to shipped, has two good shapes. A workflow…