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 returnedDescription#
The row#
| member | what it is |
|---|---|
Event | the event that fires this move — what you would deposit |
Target | the 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) |
TargetLabel | the same state as words: the tracked enum member's [Label], or its name when it declares none |
Allowed | is it open right now — this arm's guard against this item |
Reason | why it is closed, for a human; null when open |
NeedsInput | does the event take arguments — a drop gesture cannot supply them, so a surface must open a form |
Slot | the 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#
- Candidates (slot) — whether the viewer may fill a wait, which
Alloweddoes not answer - Assign — handing a slot to a named colleague — handing a wait to a named colleague once you know which slot it is
- Requires — named preconditions, and the live checklist — what must hold before a move can be taken, as a live checklist
- Workflow.WorkByItem<T> (one row per item — the board read) — the board this read draws the lanes for
- Workflow.Run (start a workflow) — raising an event by name, when the author knows it at compile time