Osy#betaRun it on Osyrin: osyrin.com

Reference / Workflow

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

workflow OrderLife { Tracks = Order.Status; Initial = Placed; state Placed { subscribe Pay(); on Pay { goto Paid; } } } · deny update Status except via Ship, Deliver;

A row that moves through states, like an order going from placed to paid to shipped, has two good shapes. A workflow with `Tracks = Order.Status` owns the field: only its transitions move it, a state can carry a deadline, and `OrderLife.For(order).Transitions` lists the moves a screen may offer. When no step waits on a clock or a person, write one function per move, each assigning the field itself, and name them all in `deny update Status except via Ship, Deliver;`. Don't write one `Advance(string to, …)` that takes the target state as text.

stable2 examples compiled by CIworkflowpatternsecuritylifecycle

Summary#

Many entities have a status that moves through states in a fixed order. An order goes from placed to paid to shipped to delivered, and may be cancelled or lapse on the way. Two shapes handle this well, and both let you say which moves are legal in one place:

  • A workflow that tracks the field. Tracks = Order.Status; hands the field to the workflow. Each state lists the events it accepts and where each one leads. Nothing else may write the field, a state can carry a deadline, and OrderLife.For(order).Transitions gives you the legal next moves for buttons. Use this when a step waits on a person or a clock.
  • One function per move. Ship(parcel, tracking) and Deliver(parcel) each check the current state, then assign the field themselves. deny update Status except via Ship, Deliver; names every function that may change it. Use this for a short, immediate lifecycle with nothing to wait for.

Avoid the third shape: one Advance(string orderId, string to, …) that takes the target state as text, looks it up by name, and takes the arguments of every move. Its callers fill the arguments they don't need with "", and a misspelt state name either fails or, if the lookup falls back to a member, moves the row somewhere real.

Signature#

workflow <Entity>Life {
  Tracks    = <Entity>.<StatusField>;
  Autostart = true;
  Initial   = <FirstState>;
  event <Move>(<what the move needs>);
  state <State> { subscribe <Move>(…); on <Move> { goto <NextState>; } }
  terminal success <End> { }
}

// …or, with nothing to wait for:
deny update <StatusField> except via <Move1>, <Move2>;
[Server, Authorize(<policy>)] void <Move1>(<Entity> row, <what it needs>) { …check…; row.<StatusField> = <Enum>.<Next>; }

Description#

Which shape do I want?#

the lifecyclewrite
a step waits on a person, a payment, a reply, or a time limit ("lapses after 48 hours")a workflow tracking the field
a screen should show only the moves this row can make nowa workflow: <Wf>.For(row).Transitions lists them
every move happens at once, in one request, with no waitingone function per move, named in except via
the moves share a rule ("only from Packed")a helper that checks and returns a reason, called by each move. A helper may not write the field: except via excepts only the named function's own assignments

Both shapes are enforced on the server. A workflow's field is written only by its transitions. With except via, the field is written only by the functions the rule names, so a browser, an import, or any other function trying to set Status is refused.

Why not one function that takes the state as a string?#

It looks economical, but it moves three jobs into the caller:

  • The move is chosen by text. Advance(id, "Shiped", …) compiles. A lookup that falls back to a member, such as return OrderState.Cancelled; after a loop over the names, turns the typo into a real state. Use Enum.Parse<OrderState>(text), which refuses an unknown name (see Enum.Parse<T> and Enum.TryParse<T> — text into a member). Better, take an OrderState parameter, or write one function per move, so a wrong name cannot be written.
  • The signature can't say what each move needs. Shipping needs a carrier and a tracking number, a refund needs an amount, and cancelling needs neither. One signature for all of them means every call passes "" and 0 for the rest, and nothing tells the caller which blanks matter.
  • The legal-move table is written by hand. (from == Paid && to == Accepted) || … is the graph a workflow already has, without its deadlines or Transitions.

The linter reports each of these: correctness-enum-name-with-a-silent-default, correctness-call-padded-for-another-move, and, as a suggestion, workflow-lifecycle-written-by-hand.

"Only one function may change the status"#

If you want the database to enforce the status, you don't need a single function to do it. except via accepts several names, and the rule is just as tight with many: deny update Status except via Pay, Ship, Deliver, Cancel; lists every way the field changes, where a reader of the entity will see it. Each named function must make the assignment itself, so the compiler refuses a name whose helper does the writing. The refusal names the helper and shows this shape.

Examples#

A workflow that owns the order's status#

using Osysharp.Workflow;

enum OrderStatus { Placed, Paid, Shipped, Delivered, Lapsed, Cancelled }

entity Order {
  [Required, MaxLength(40)] string Reference;
  OrderStatus Status;                       // no default: the workflow supplies it
  [MaxLength(80)] string? Tracking;
  security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}

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

  event Pay();
  event Ship(string tracking);
  event Deliver();
  event Cancel();

  state Placed {
    Expire = TimeSpan.FromDays(2);          // unpaid for two days: the clock moves it
    subscribe Pay();
    subscribe Cancel();
    on Pay    { goto Paid; }
    on Cancel { goto Cancelled; }
    on Expire { goto Lapsed; }
  }
  state Paid {
    subscribe Ship(string tracking);
    on Ship(string tracking) {
      this.Item.Tracking = tracking;
      goto Shipped;
    }
  }
  state Shipped {
    subscribe Deliver();
    on Deliver { goto Delivered; }
  }
  terminal success Delivered { }
  terminal cancel  Lapsed    { Message = "not paid in time"; }
  terminal cancel  Cancelled { Message = "cancelled before payment"; }
}

/// The buttons a screen shows: only the moves this order can make now.
List<string> NextMoves(Order order) =>
  OrderLife.For(order).Transitions.Where(t => t.Allowed).Select(t => t.Event).ToList();

[Test]
void An_order_moves_only_along_its_workflow() {
  var order = new Order { Reference = "A-1" };
  UnitOfWork.Commit();
  Assert.Equal(OrderStatus.Placed, order.Status);
  Assert.False(NextMoves(order).Contains("Ship"));     // not offered before it is paid

  OrderLife.RaisePay(order);
  Workflow.Settle(order);
  Assert.True(NextMoves(order).Contains("Ship"));

  OrderLife.RaiseShip(order, "TRK-9");
  Workflow.Settle(order);
  Assert.Equal(OrderStatus.Shipped, order.Status);
  Assert.Equal("TRK-9", order.Tracking);
}

[Test]
void An_unpaid_order_lapses_on_its_own() {
  var order = new Order { Reference = "A-2" };
  UnitOfWork.Commit();
  TestClock.Advance(TimeSpan.FromDays(3));
  Workflow.Settle(order);
  Assert.Equal(OrderStatus.Lapsed, order.Status);
}

One function per move, when nothing waits#

enum ParcelStatus { Packed, Shipped, Delivered, SentBack }

[Principal] entity Clerk {
  [Unique, MaxLength(80)] string Name;
  security { allow read when IsAuthenticated; }
}

entity Parcel {
  [Required, MaxLength(40)] string Reference;
  ParcelStatus Status = ParcelStatus.Packed;
  [MaxLength(80)] string Tracking = "";
  security {
    allow read, create, update when IsAuthenticated;
    deny update Status except via Ship, Deliver, SendBack;   // every way the status changes, in one place
  }
}

/// The rule the moves share. It only checks and never writes, so it needs no exception of its own.
string NotIn(Parcel parcel, ParcelStatus expected) =>
  parcel.Status == expected ? "" : $"{parcel.Reference} is {parcel.Status.Label}, not {expected.Label}.";

/// Each move takes only what it needs and assigns the status itself.
[Server, Authorize(IsAuthenticated)]
void Ship(Parcel parcel, string tracking) {
  var why = NotIn(parcel, ParcelStatus.Packed);
  if (why != "") { throw new Exception(why); }
  parcel.Tracking = tracking;
  parcel.Status = ParcelStatus.Shipped;
}

[Server, Authorize(IsAuthenticated)]
void Deliver(Parcel parcel) {
  var why = NotIn(parcel, ParcelStatus.Shipped);
  if (why != "") { throw new Exception(why); }
  parcel.Status = ParcelStatus.Delivered;
}

[Server, Authorize(IsAuthenticated)]
void SendBack(Parcel parcel) {
  var why = NotIn(parcel, ParcelStatus.Shipped);
  if (why != "") { throw new Exception(why); }
  parcel.Status = ParcelStatus.SentBack;
}

/// Not one of the moves, so its write is refused.
void Shortcut(Parcel parcel) { parcel.Status = ParcelStatus.Delivered; }

[TestFixture]
void Depot() {
  new Clerk { Name = "Ida" };
  new Parcel { Reference = "P-1" };
}

principal Ida => Clerk.Single(c => c.Name == "Ida");

[Test(Depot)]
[runas(Ida)]
void A_parcel_changes_status_only_through_its_moves() {
  var parcel = Parcel.Single(p => p.Reference == "P-1");
  Assert.Denied(() => Shortcut(parcel));
  Ship(parcel, "TRK-1");
  Deliver(parcel);
  UnitOfWork.Commit();
  Assert.Equal(ParcelStatus.Delivered, Parcel.Single(p => p.Reference == "P-1").Status);
}

[Test(Depot)]
[runas(Ida)]
void A_move_from_the_wrong_state_is_refused() {
  var parcel = Parcel.Single(p => p.Reference == "P-1");
  Assert.Throws(() => Deliver(parcel));               // not shipped yet
}

The shop kit's OrderLifecycle (osy source OrderLifecycle) is a full-size example of the workflow shape: payment by webhook, a hold that lapses, picking, packing and refunds, all on one tracked Order.Status.

See also#

Related

Tracks and Initial (the field a workflow drives)

Names the enum field a workflow owns and the state a run starts in. No application code may write that field, and when…

Transitions — where this item may go next

One row per move this instance can make right now, with each arm's guard evaluated against it. A board offers only the…

Raising a workflow event

Raise a typed event on the run bound to an entity, from anywhere — an ordinary server function, a webhook handler, a…

Workflows (the run that outlives the request)

A `workflow` is a run that survives the process it started in — it parks, waits days for a person or a timer, and…

deny … except via

Names the ONLY functions whose own code may change something a deny withholds: `deny update UnitsLeft except via…

Enum.Parse<T> and Enum.TryParse<T> — text into a member

Enum.Parse<OrderState>("Paid") turns a member's name into the member, exactly as in C#; an unknown name throws an…