Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / Workflow

Transitions — where this item may go next

<Wf>.For(item).Transitions // List<TransitionView> · Workflow.Raise(item, move)

One row per move this instance can make right now, with each arm's guard evaluated against it. A board offers only the lanes a card may actually reach instead of accepting a drop and having the engine refuse it afterwards — and `Workflow.Raise(item, move)` takes one of them by handing the row back.

stable1 example compiled by CIworkflowqueries

Summary#

<Wf>.For(item).Transitions is the answer to "where may this item go?", asked of one running instance and answered now — every guard re-evaluated against this item's current data.

It is a reflection of the graph the engine enforces and never a substitute for it. The deposit path re-checks everything, so a stale or spoofed read buys nothing; the value is that a screen can show the right buttons instead of finding out on the click.

Signature#

<Wf>.For(item).Transitions      // List<TransitionView>
Workflow.Raise(item, move)      // take one — `move` is a row this read returned

Description#

The row#

memberwhat it is
Eventthe event that fires this move — what you would deposit
Targetthe state it lands in, as an identifier — what code compares against. The current state when the arm has no goto (it runs a body and waits again)
TargetLabelthe same state as words: the tracked enum member's [Label], or its name when it declares none
Allowedis it open right now — this arm's guard against this item
Reasonwhy it is closed, for a human; null when open
NeedsInputdoes the event take arguments — a drop gesture cannot supply them, so a surface must open a form
Slotthe wait this move fills, or null when nothing is waiting for it

Closed moves are RETURNED, not hidden#

An arm whose guard does not hold comes back with Allowed = false and a Reason. A target the user cannot reach is worth showing as unavailable rather than omitting: "you may not move it there yet" is a different message from a board that silently has fewer lanes than the workflow does.

Slot — a wait you can fill, or a command you can issue#

These are different gestures and they need different affordances.

A move with a Slot answers a wait: somebody may hold it, it appears on a board, it has a Candidates gate and possibly an SLA. A move with no Slot is a command — most often one the workflow declares once, live in every non-terminal state:

workflow TicketFlow {
  on Cancel { goto Cancelled; }        // no slot waits for this; it is a menu item, not a lane
  …
}

Both are real moves and both appear here. Without Slot they arrive as indistinguishable rows and a page has to re-derive the workflow's shape to know which is which.

Target is an identifier; TargetLabel is the words#

A state's name is the tracked enum's member name, so the member's [Label] is the label every other surface already renders for that value — a board's lanes, a card, a form. TargetLabel brings it here.

enum TicketStatus {
  Open,
  [Label("Awaiting customer")] AwaitingCustomer,
}

Both fields exist because both are wanted, and they are not interchangeable: a screen shows TargetLabel and compares against Target. Rendering the identifier puts a machine spelling in front of a person; comparing against the label breaks the moment somebody adds a [Label].

Event has no counterpart, and that is a statement rather than a gap. An event is a declared name and carries no [Label], so there is nothing to fall back from. An app rendering m.Event directly is showing an identifier; labelling its own verbs is currently the only answer.

One row per EVENT, not per arm#

An event may declare several arms (on X { when (a) { goto A; } default { goto B; } }) and the engine fires the first whose guard holds. The read folds them the same way, so Target is the arm that would actually be taken right now — which is the question a caller is asking.

The same fold gives nearest scope wins: a state's own arm is considered before a workflow-level one for the same event, exactly as dispatch does.

What is NOT a move#

Complete, Expire and Deadline arms are engine-fired — a timer, or a requirement becoming satisfied. Nobody drops a card to make a deadline pass, so offering them would describe a UI that cannot exist. A run that is not Waiting (terminal, failed, cancelled) returns an empty list, which lets a board render "no moves" honestly.

How do I take one of the listed moves?#

Workflow.Raise(item, move) hands the row back rather than naming an event. Every other raise form names its event at compile time; this list is a runtime one, so passing the view is what keeps it honest — you can only take a move the engine itself listed for this instance.

Examples#

A card wall: the lanes this card may be dropped into, and the commands beside them.

enum CardStatus { Triage, Doing, Done, Cancelled }

[Principal] entity Person {
  [Required, MaxLength(80)] string Name;
  security { allow read when IsAuthenticated || IsAnonymous; }
}

entity Card {
  [Required, MaxLength(200)] string Title;
  CardStatus Status;
  bool Signed;
  security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}

workflow CardFlow {
  Tracks    = Card.Status;
  Autostart = true;
  Initial   = Triage;

  event Start();
  event Finish(string note);
  event Cancel();

  // Declared on the WORKFLOW: available in every non-terminal state, and no slot waits for it.
  on Cancel { goto Cancelled; }

  state Triage {
    subscribe Start();
    on Start { goto Doing; }
  }

  state Doing {
    subscribe Finish(string note);
    on Finish { when (this.Item.Signed) { goto Done; } }
  }

  terminal success Done { }
  terminal error   Cancelled { Message = "cancelled"; }
}

// What the buttons say. `TargetLabel` is for the person; `Target` is what the page compares against.
List<Osyrin.Workflow.TransitionView> Choices(Card card) {
  return CardFlow.For(card).Transitions.Where(t => t.Allowed).ToList();
}

// The lanes: moves that fill a wait, which is what a board drops into.
List<Osyrin.Workflow.TransitionView> Lanes(Card card) {
  return CardFlow.For(card).Transitions.Where(t => t.Slot != null).ToList();
}

// The menu: moves with nothing waiting for them.
List<Osyrin.Workflow.TransitionView> Commands(Card card) {
  return CardFlow.For(card).Transitions.Where(t => t.Slot == null).ToList();
}

// Taking one, by handing the row back.
void TakeFirstOpen(Card card) {
  var move = CardFlow.For(card).Transitions.Where(t => t.Allowed && !t.NeedsInput).First();
  Workflow.Raise(card, move);
}

Notes#

It is re-read, never stored. Guards are evaluated at the moment of the call, so a page that wants live buttons re-reads rather than caching — the same relationship a canPress policy has with the server that goes on enforcing it.

A live read over this wakes on the ITEM. A workflow read subscribes to the entity the run is FOR, never to its own row type — those rows are synthesised per call and nobody commits one. So a raise wakes it (the tracked property moves), and so do Claim / Release / Assign, which change no property on the item but signal it deliberately. Without that a hand-over left the buttons beside it answering from before.

Allowed is about the ARM's guard, not about you. Whether this caller may fill a particular wait is the slot's own Candidates, asked with <Wf>.For(item).<Slot>.Candidates(u).

See also#

Related

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…

Assign — handing a slot to a named colleague

Give a slot to somebody else. Claiming takes work for yourself and releasing puts it back in the pool; assigning is the…

Requires — named preconditions, and the live checklist

Named conditions that must hold before something may happen, declared on a state or on a slot — and readable as a live…

Acting on an inbox row (deposit, claim, release)

Answer a queued slot from the row itself. The event is named at the call site because a queue's rows are heterogeneous…

Candidates (slot)

Declares WHO may hold or satisfy a `subscribe` slot. `Candidates` is one expression surface that dispatches on its…

Workflow.WorkByItem&lt;T&gt; (one row per item — the board read)

One row per tracked ENTITY, where `Workflow.Work<T>()` is one per SLOT. A board, a queue and a "my work" screen are all…