Summary#
Workflow.Inbox<T>() answers what is waiting for me. Workflow.Work<T>() answers what is
outstanding — every live slot of every run tracking T, whoever holds it.
They return the same row, and that is the point: an operator, the person who raised the item, and the pool that can pick it up all want the same facts, filtered differently.
Signature#
Workflow.Work<TrackedEntity>()No arguments. Filter, order and count it like any other list.
Description#
How do I get MY queue out of it?#
The inbox has a viewpoint built in — assigned to you, or you satisfy the slot's
Candidates. That is what stops it generalising: "where has my expense report got to" is
asked by someone who often cannot act on it at all. So the primitive is the unfiltered list, and each viewpoint is an
ordinary .Where(…):
Workflow.Work<Expense>() // an operations board — everything
Workflow.Work<Expense>().Where(r => r.Assignee == me) // what I am holding
Workflow.Work<Expense>().Where(r => r.SlaKind == SlaKind.Assigned && r.Remaining < TimeSpan.Zero) // late to respond
Workflow.Work<Expense>() // my submissions, wherever they are
.Include(r => r.Item).Include(r => r.Item.Requester) // …filtering THROUGH Item needs it loaded
.Where(r => r.Item.Requester == me)⚠ Filtering through Item needs Item included. The rows are already materialised, so a
.Where(…) over them runs in memory — an un-included reference has nothing to resolve. Include the hop you filter
on and Item itself. Fields on the row (Assignee, SlaKind, Remaining) need nothing.
Security is not what changed. Dropping the inbox filter drops a relevance question, not a permission one: whether you may see an item at all is decided by that entity's own declared read rules, on the same rows, either way. A requester who can only read their own expenses sees only their own — with no filter written.
Which SLA numbers does a row carry?#
| member | what it is |
|---|---|
Budget | the SLA's total allowance — the 4h |
Elapsed | how much is gone — the 1h32m |
Remaining | Budget - Elapsed, negative once breached |
SlaKind | which deadline these describe — Assigned (first response) or Finished (completion) |
BreachesAt | when it runs out |
foreach (var r in Workflow.Work<Ticket>().OrderBy(r => r.Remaining)) {
Log.Information($"{r.Item.Title}: {r.Elapsed} of {r.Budget} ({r.SlaKind})");
}SlaKind is not decoration. A slot can carry both an Assigned and a Finished
milestone — pick it up within 4h and close it within 24h are different promises. The row
describes the one that breaches soonest, which is the same clock BreachesAt reports, so a row is always about
one deadline rather than a blend of two. Without SlaKind, "1h32m of 4h" would not say which promise it measures.
Remaining goes negative on purpose. How far past is the thing an operator is looking for, and clamping at
zero would flatten the worst rows into the merely-due ones.
All five are null together when no clock governs the slot — absence, not a zero that would sort as though the budget were spent.
Elapsed is accrued, not wall-clock#
Under ServiceHours an SLA only advances during business hours. Elapsed counts the same
way, so a ticket raised on Friday afternoon does not burn its budget over the weekend — and Elapsed, Remaining
and BreachesAt on one row always agree with each other. A clock declared Accrues = false measures real time, and
its Elapsed follows it.
This is also why the numbers come from here rather than being computed in app code: the answer depends on the schedule the run is governed by, which is not a subtraction anyone can do from the outside.
Examples#
A support queue with a 4h first-response SLA and a 24h close, and the two reads an operations screen makes of it.
enum TicketStage { Open, Working, Closed }
[Principal] entity Agent {
[Required] [MaxLength(80)] string DisplayName;
[Required] [MaxLength(40)] string Team;
security {
allow read when IsAuthenticated;
allow create when IsAuthenticated;
}
}
entity Ticket {
[Required] [MaxLength(120)] string Title;
[Required] Agent Reporter;
TicketStage Stage;
security {
allow read, update when IsAuthenticated;
allow create when IsAuthenticated;
}
}
workflow TicketFlow {
Tracks = Ticket.Stage;
Autostart = true;
Initial = Open;
event Pick();
event Resolve(bool fixed);
state Open {
subscribe Pick();
on Pick { goto Working; }
}
state Working {
subscribe Resolve(bool fixed) as Support {
Candidates = u => u.Team == "Support";
Assigned { Within = TimeSpan.FromHours(4); } // first response
Finished { Within = TimeSpan.FromHours(24); } // close
}
on Support(bool fixed) { goto Closed; }
}
terminal success Closed { }
}
// Everything past its deadline, worst first — the operator's screen.
int OverdueCount() {
return Workflow.Work<Ticket>()
.Where(r => r.Remaining < TimeSpan.Zero)
.Count();
}
// The same read, one viewpoint narrower: where my own tickets have got to. Both Includes are needed — the filter
// navigates Item AND Item.Reporter, and an in-memory Where cannot resolve a reference that was never loaded.
int MySubmissions(Agent me) {
return Workflow.Work<Ticket>()
.Include(r => r.Item)
.Include(r => r.Item.Reporter)
.Where(r => r.Item.Reporter == me)
.Count();
}See also#
- Workflow.WorkByItem<T> (one row per item — the board read) — one row per ITEM rather than per slot: the BOARD read, with the clock that governs across an item's slots
- Workflow.Inbox<T> (what is waiting for me) — the same row, filtered to the asking principal
- Candidates (slot) — an operations board shows everyone's work, so use
<Wf>.For(r.Item).<Slot>.Candidates(u)to decide which rows the VIEWER can act on - Assigned / Finished (milestones) — where
Assigned/Finishedbudgets are declared - ServiceHours (SLA-accrual windows) — what makes
Elapsedbusiness hours - Workflow.Retarget (re-base the SLA clocks) — changing an SLA budget mid-run