Summary#
A milestone puts a deadline on a slot's progress. There are two, by the two things that can be late:
Assigned — the slot must be picked up (claimed or pre-assigned) within Within — and Finished — the slot
must be satisfied (deposited into) within Within. Each has a breach arm that runs when its SLA lapses:
Unassigned { … } for Assigned, Unfinished { … } for Finished. A breach is not a failure by itself: an arm
with no goto runs its body and the wait lives on; an arm that gotos ends the wait.
Signature#
Assigned {
Within = <TimeSpan>; // must be picked up within this
Unassigned { <body> } // ran if still Unassigned at Within (may `goto`)
}
Finished {
Within = <TimeSpan>; // must be satisfied within this
Unfinished { <body> } // ran if not Satisfied at Within (may `goto`)
}Description#
A milestone is declared where its SLA belongs — on a slot (inside its subscribe … { }), on a state, or on
the workflow as a cascade default; nearest declaration wins (slot over state over workflow). A cascade default is
inherited only by pool slots — a slot that declares Candidates — since the SLA models pool pickup (Assigned) and
completion (Finished) and its body reads slot.Candidates; a bare system-event slot (subscribe Submit();) is not
SLA-tracked. A slot may override just one clause (e.g. Finished { Within = TimeSpan.FromDays(2); }) and inherit the
rest. Within is a TimeSpan expression over this.Item, so different instances can carry different SLAs (snapshot
them in Start { }).
When the SLA lapses the milestone breaches: its breach arm runs, a Breached event is written to the timeline,
and — if the arm gotos — the run transitions. A milestone that has already been met never breaches (an Assigned
milestone is met the moment the slot leaves Unassigned; a Finished milestone the moment the slot is Satisfied), so
a breach that fixes the problem (e.g. slot.Assign(lead)) also stops it recurring.
The ambient slot. Inside a milestone body the bare identifier slot is the slot the milestone hangs off:
slot.Assign(principal)— assign the slot to a principal (it becomes theirs; the wait continues).slot.Candidates(u)— evaluate the slot's ownCandidatespredicate against a principalu→ abool. It is the slot's eligibility rule, reusable in a query —Person.Single(u => slot.Candidates(u) && u.IsLead)finds the lead among the slot's candidates as one query.slot.Assignee/slot.Status/slot.IsUnassigned— read the slot's current holder / state.
Naming it. A breach arm may give the slot a name of its own — Unassigned(Slot approver) { … } — and then that
name is the slot throughout the body. It is the same one slot either way; naming it just reads better when the body
is about a person rather than a mechanism, and it matches how on Expire(PoStatus state) names what is ambient
there. The parameter is a Slot and there is exactly one of it.
Assigned {
Within = TimeSpan.FromHours(4);
Unassigned(Slot approver) { // `approver` IS the slot — `slot` is simply its default name
var lead = Person.Single(u => approver.Candidates(u) && u.IsLead);
approver.Assign(lead);
}
}Retries / Backoff / Exhausted — try again before giving up#
For a slot a MACHINE fills — a child workflow, an external callback — the useful answer to a missed deadline is often "try again", not "escalate":
Finished {
Within = TimeSpan.FromMinutes(5);
Retries = 3; // three further windows
Backoff = TimeSpan.FromMinutes(1); // …each one a minute after the last failed
Exhausted { slot.Release(); } // tried and gave up — hand it to the humans
Unfinished { goto NeedsAttention; } // …and this is where the run goes
}Each retry re-opens the SAME window (Within), delayed by Backoff. When the attempts run out, Exhausted { } runs
and then the breach arm decides where the run goes.
- A retry does not run the breach arm. The breach arm may
goto, so running it per attempt would move the run away on the first one and there would be no second attempt. The breach arm is the end of the story, not a step in it. - Every attempt is audited, so the timeline shows three breaches and an exhaustion rather than one long silence.
- The count is per owner. A slot that changes hands gives its new holder a fresh set of attempts, for the same reason they get a fresh budget: attempts burned by someone else are not a commitment they made.
- On a HUMAN slot you almost certainly want
Unassigned { … }instead. It lets you say who to escalate to and when to give up;Retries = Ncan only re-offer to the same pool on the same terms. It is allowed — three re-offers is odd, not wrong — but it is rarely what you meant.
Backoff takes either a plain time span — the same wait every time — or a retry policy, which
says how the wait GROWS and bounds it:
Finished {
Within = TimeSpan.FromMinutes(5);
// Three attempts, five minutes apart, then ten — but never more than an hour idle.
Backoff = Backoff.Exponential(TimeSpan.FromMinutes(5)).MaxAttempts(3).Cap(TimeSpan.FromHours(1));
Exhausted { slot.Release(); }
Unfinished { goto NeedsAttention; }
}⚠ Retries = N and a policy's .MaxAttempts(M) are two budgets for one thing, and a milestone declaring both is a
compile error. They also count differently — Retries is how many windows follow the first, MaxAttempts is how
many there are in total — so Retries = 2 and .MaxAttempts(3) say the same thing. Pick whichever reads better where
you are; see Backoff (retry policy).
enter { } — the success counterpart of the breach arm#
A milestone has two outcomes and both have a home. The breach arm runs when the SLA is missed; enter { } runs when
the milestone is REACHED — the slot got an owner (Assigned), or it was satisfied (Finished).
Assigned {
Within = TimeSpan.FromHours(4);
enter { Notify(slot.Assignee); } // it has an owner now — tell them
Unassigned { … } // …and this is what happens if it never did
}This is the only home for "tell the new owner", and that is why it exists: claiming a slot changes no workflow
state, so no state enter { } fires. Without it, an approval app has nowhere to put the most ordinary thing it
does.
- It runs on EVERY assignment, not just the first. A slot released and re-claimed enters
Assignedagain, and the new owner is told — the same reasoning that gives each holder their ownFinishedbudget. A hook that fired once would go quiet exactly when a slot changes hands, which is when someone most needs to hear. - The ambient
slotis bound, soslot.Assigneeis the person who just took it. - It may not
goto. Reaching a milestone is orthogonal to workflow state: assignment is about WHO, not about WHERE the run is. Side effects only — the compiler refuses agotohere. - It is available on
Finishedtoo (fires when the slot is satisfied), though that is often already covered by theon <Event>arm that handled the deposit.
Examples#
enum Decision { Approve, Reject }
enum OrderState { Review, Done }
[Principal]
entity Person {
[Required, MaxLength(200)] string Email;
security { allow read, create when IsAuthenticated; }
}
entity Order {
[Required, MaxLength(60)] string Reference;
[Required] Person Requester;
OrderState Status; // no default: the workflow owns this field
security { allow read, create, update when IsAuthenticated; }
}
workflow ReviewFlow {
Tracks = Order.Status;
Autostart = true;
Initial = Review;
event Approve(Decision decision);
state Review {
subscribe Approve(Decision decision) as Legal {
Candidates = u => u.Email != "";
Assigned {
Within = TimeSpan.FromHours(4);
Unassigned { }
}
}
on Approve(Decision decision) { goto Done; }
}
terminal success Done { }
}Breach ≠ failure — nobody picked up the pool slot, so on breach we widen it to the department lead and keep waiting:
subscribe Approve(Decision decision, string reason) as Legal {
Candidates = u => u.Department == Dept.Legal;
Assigned {
Within = TimeSpan.FromHours(4);
Unassigned { // no goto → keep waiting, now with an owner
var lead = Person.Single(u => slot.Candidates(u) && u.IsLead);
slot.Assign(lead);
}
}
}A claimed slot that goes overdue is fatal — the goto ends the wait:
Finished {
Within = TimeSpan.FromHours(8);
Unfinished { goto Escalated; }
}See also#
- subscribe — the slot a milestone hangs off (its
Candidates/Assignee) - Candidates (slot) — the same
Candidates(u)question OUTSIDE a milestone, where the run is named rather than ambient:<Wf>.For(item).<Slot>.Candidates(u) - Remind (milestone reminders) — nudges scheduled off a milestone before it breaches
- <span class="planned" title="this page is planned and not written yet">workflow-route</span> — the state-level
on … goto …routes a breachgotojoins - <span class="planned" title="this page is planned and not written yet">workflow-state</span> — the enclosing state and its
Expiredeadline