Summary#
Workflow.WorkByItem<T>() returns one row per tracked entity that has a live run.
Workflow.Work<T>() returns one row per slot, and the difference is the difference between a
queue and a board.
An item usually waits on more than one thing at once. A ticket being worked might have a fix slot, a park slot and an escalation slot all open — so a board built straight on the slot read shows that ticket three times. Worse, the duplication passes every "are the tickets there" check you would think to write.
Signature#
Workflow.WorkByItem<Ticket>() // List<Osyrin.WorkItemRow_Ticket>Takes no arguments and filters like any query:
Workflow.WorkByItem<Ticket>().Where(r => r.EverBreached).OrderBy(r => r.BreachesAt)Description#
The row#
| member | what it is |
|---|---|
Item | the tracked entity, typed — navigable and .Include-able |
Assignee | who holds the item, resolved across all its slots |
OpenSlots | how many live waits it has |
EverBreached | has any promise on this run ever been missed |
SlaKind · Budget · Elapsed · Remaining · BreachesAt | the governing clock across the item's slots |
GoverningSlot | which slot PROMISED those numbers — null when the run's own clock governs (see below) |
RunId · WorkflowName | the run driving it |
The holder is resolved across ALL slots#
A card asks "who has this ticket", and any slot somebody holds answers it — the person who took the reply slot is working the ticket whether or not that slot is the one breaching soonest.
This is worth stating because the obvious shortcut is wrong in a way that is hard to see. Slots of one run routinely
share a breach instant — a state's Expire governs several of them, each computed separately — so "the governing
slot" is not a stable choice between them. An app that read the holder off the governing row got a card saying
"unassigned" while its own roster said the ticket was held.
The governing clock, and how ties break#
The SLA members describe the promise breaching soonest across the item's slots. Ties are the normal case, not an edge one, so the rule is fixed and stated rather than left to each app to discover:
- soonest
BreachesAt; - then a slot whose clock is its own rather than the run's;
- then a slot that somebody holds;
- then the slot's declaration order.
A slot with no promise of its own borrows the run's#
A slot that declares no milestone still reports an SLA — the run's: the state's Expire, or the instance
Deadline. That is deliberate, and it is why those two SlaKind members exist: a ticket waiting in a state whose
promise is ticking should say so, not report nothing.
⚠ But a borrowed clock is not that slot's promise, and every promise-less slot on a run borrows the SAME one.
So rule 2 exists: choosing between them by an instant they all share is choosing arbitrarily, and before it existed
the arbiter was declaration order — which a workflow-scope subscribe wins forever, because
it is declared before any state's slots. An always-open escalation hatch became what the board counted down, what
"take it" claimed, and what every verb on the page was aimed at, while the reply the item was actually waiting for
sat untouched. Nothing errored anywhere.
GoverningSlot is null when the run's own clock governs#
When the winning clock is the run's, no slot is named — because none promised it. Naming one would say "this
countdown is counting Bump" when it is counting the state's expiry, which Bump merely stands next to.
SlaKind says which run promise it is, so a screen can still say what it is counting.
⚑ This is also the difference between a wrong answer and a visible one. An app reading GoverningSlot as a
claim target now gets a null it has to handle, rather than a slot the caller cannot claim and a refusal two steps
later. Claiming is a different question — "which row may I take?" — and wants Workflow.Work<T>(),
which still reports the borrowed clock per slot exactly as before.
An item with no live clock still appears#
A parked, blocked or finished item is still on the board. It comes back with every SLA member null — absence, not a zero, which would sort as a fully-consumed budget and put the calmest rows where the worst ones belong.
EverBreached survives the clock being retired#
⚑ This is the member that makes a board correct, and the reason is not obvious. When a promise runs out the engine stamps the breach and retires the clock — so the item stops having a countdown at all. A board ordered by "has a live promise" therefore drops the one row everybody needs to see, at the exact moment it starts mattering, and sorts breached work to the bottom.
EverBreached is read from the workflow's own audit trail, so it is still true afterwards. An app
does not need to keep its own flag for this.
…but it does not survive the RUN ending
⚠ The clock and the run are two different horizons, and this member only outlives the first. WorkByItem returns
one row per live run — so when a breach arm ends the run (Unfinished { goto Expired; }, the ordinary shape for a
deadline that actually means something) the row disappears, and EverBreached goes with it. The board asks "was this
ever late?" in the same sweep that made the answer true, and gets nothing back at all.
That is not a bug in the read: a finished item has no live work, which is exactly what this read is for. It is a
reason to ask a different source. The audit trail has no such horizon — it is where
EverBreached came from in the first place, and it is still there when the run is over:
foreach (var a in Onboarding.For(i).Audit) {
if (a.Kind == AuditKind.Breached) { everBreached = true; }
if (a.Kind == AuditKind.Reminded) { nudges = nudges + 1; }
}So: read EverBreached off the row when the breach leaves the run open (a support ticket still owed an answer —
the case this member was built for), and off the trail when the breach ends it. A board that mixes finished and
unfinished items wants the trail, because only it answers for both.
Examples#
A whole desk, compiled: three waits open on one ticket, and a board that shows it once.
enum Stage { Working, Done }
[Principal] entity Agent {
[Required, MaxLength(80)] string Name;
security { allow read, create when IsAuthenticated; }
}
entity Ticket {
[Required, MaxLength(120)] string Subject;
Stage State;
security { allow read, create, update when IsAuthenticated; }
}
workflow TicketFlow {
Tracks = Ticket.State;
Autostart = true;
Initial = Working;
event Fix();
event Park();
event Escalate();
// Three things can happen to a ticket being worked. On a SLOT board that is three rows; here it is one card
// that knows it is waiting on three things.
state Working {
subscribe Fix() as FixIt { }
subscribe Park() as Parked { }
subscribe Escalate() as Raised { }
on Complete { goto Done; }
}
terminal success Done { }
}
// The board: one row per ticket, worst first. `EverBreached` leads because a breach retires the clock, so ordering
// on the live figures alone sends the worst row to the bottom.
List<Osyrin.WorkItemRow_Ticket> Board() {
return Workflow.WorkByItem<Ticket>()
.Include(r => r.Item)
.OrderBy(r => r.EverBreached ? 0 : 1)
.ThenBy(r => r.BreachesAt)
.ToList();
}
// "Waiting on more than one thing" — a question the slot board cannot ask at all.
int Stalled() { return Workflow.WorkByItem<Ticket>().Where(r => r.OpenSlots > 1).Count(); }Two more questions the same row answers. "What am I working on":
Workflow.WorkByItem<Ticket>().Where(r => r.Assignee == Session.CurrentUser.Id)"Waiting on more than one thing":
Workflow.WorkByItem<Ticket>().Where(r => r.OpenSlots > 1)See also#
- Workflow.Work<T> (everything outstanding) and its SLA numbers — one row per SLOT: the queue read, and where the SLA numbers are explained in full
- Workflow.Inbox<T> (what is waiting for me) — the same read filtered to the current principal's own work
- Transitions — where this item may go next — the lanes a card on this board may be dropped into
- ServiceHours (SLA-accrual windows) — why
Elapsedis accrued rather than wall-clock