Osy#betaa language · its runtime Osyrin · a hosted platform
Why Osy#Built for agentsAgents as declarationsWorkflows that waitRuns exactly onceSecure by defaultNothing to mockThe editor is the compilerUI in the languageDocuments are dataOne program

Why Osy# · Chapter 03 · Workflows

A process that waits three days for a person is still just source.

States, the events that move between them, who may raise each one, and what happens when a step waits for days. A workflow is a file you read top to bottom. The state is a column on your entity, and a run outlives the deploy that started it.

In practiceThe state is a column on your entity; a run outlives the deploy that started it.You stop building
  • a job scheduler
  • a queue
  • a state-machine library
  • retry logic
demo/wf-order-saga/model/order.osyverbatim — this file compiles
  state Paid {
    subscribe Ship(string tracking) as Fulfil {
      Candidates = u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Fulfillment);
    }
    on Fulfil(string tracking) {

01

The state is a column on your entity

A workflow does not carry a state of its own beside your data. It TRACKS a field you already declared — so "what stage is this order at" is one column, one index, and one thing to list on.

demo/wf-order-saga/model/order.osyverbatim — this file compiles
workflow OrderLifecycle {
1  Tracks    = Order.Status;
2  Autostart = true;
  Initial   = Placed;

3  event Pay();
  event Ship(string tracking);
  event Deliver();
4  [Authorize(u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Manager))]
  event Cancel();

  on Cancel {
    if (this.Item.Status == OrderStatus.Shipped) {
      var ret = Workflow.Once("make-return", () => new ReturnShipment { Order = this.Item });
5      await Workflow.Run("ret", ret);          // hold until the return workflow refunds
      goto Returning;
    } else {
      goto Cancelled;
    }
  }

  state Placed {
    subscribe Pay();
    on Pay { goto Paid; }
  }

  state Paid {
6    subscribe Ship(string tracking) as Fulfil {
7      Candidates = u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Fulfillment);
    }
8    on Fulfil(string tracking) {
      this.Item.Tracking = tracking;
      goto Shipped;
    }
  }

  state Shipped {
    subscribe Deliver();
    on Deliver { goto Delivered; }
  }

  terminal success Delivered { }
  terminal cancel  Cancelled { Message = "order cancelled"; }
9  terminal cancel  Returning { Message = "order cancelled after shipping — return + refund issued"; }
1

The workflow's state IS Order.Status. Not a parallel table joined to it. A screen that lists orders by stage is an ordinary query over an ordinary enum column.

2

A run begins when an Order row is created. Nothing starts it by hand, so there is no path where a row exists and its run does not.

3

The vocabulary of what can happen. An event may carry arguments — Ship(string tracking) — and those arguments are what the person doing the work is asked for.

4

Who may raise it, on the event itself. Not a check inside a handler that somebody can forget: an event a caller may not raise is an event they are never offered.

5

Awaiting a CHILD workflow parks this one. Durably: no process is held, and the order resumes when the return terminals — which may be minutes or a fortnight later. See Runs exactly once.

6

A slot — work offered to a set of people. This is the difference between a state machine and a workflow: the state says the order is paid, the slot says somebody has to ship it, and the slot is a row you can list, assign, chase and measure.

7

Who it is offered to, as a predicate over your own model. An inbox is then a query, not a feature.

8

The handler. goto moves the state — and moving it is the only thing that does, so the transition log is complete by construction.

9

Terminals are typed. success, cancel and error are three different endings, and a reporting screen that wants "how many failed" does not have to infer it from a status name.

02

Cancel from anywhere, and compensate

The on Cancel block sits outside every state, so it is reachable from all of them. What it does depends on where the order actually is — and cancelling something already shipped cannot just flip a flag, because the goods are gone.

on Cancel {
  if (this.Item.Status == OrderStatus.Shipped) {
    var ret = Workflow.Once("make-return", () => new ReturnShipment { Order = this.Item });
    await Workflow.Run("ret", ret);          // hold until the return workflow refunds
    goto Returning;
  } else {
    goto Cancelled;
  }
}

What happens when you change it

Workflow changes, and what they cost

Each of these is a thing you would do on a Tuesday. The verdict is the compiler's, not a convention's.

You do thisVerdictBecause
Add a statecompilesAdditive. Existing runs are in the states they were in; new ones can reach the new one.
Remove a state that runs are parked inrefusedRefused at deploy without a migration. Stranding live work is the failure this exists to prevent, so it is caught when you deploy rather than when somebody opens the order.
Rename the tracked enum memberrefusedRefused without a migration, and the migration is generated for you — the stored value is what every parked run resolves against.
Add an argument to an eventcompilesThe people who satisfy that slot are asked for one more thing. A one-click move is no longer offered for it, and the UI knows that without being told.
Tighten CandidatescompilesThe inbox narrows. It is a predicate over your model, so it narrows the same way any other query would.