Summary#
Where the fan-out (foreach subscribe) over a literal enum list is fixed at compile time and addressed by member name,
a fan-out over a runtime entity collection (foreach (User u in this.Item.Topic.Referees)) is resolved at
state entry: the engine evaluates the collection and materializes one runtime slot per element. The element
is an entity, so each instance can be pre-assigned to its own element (Assignee = u) and its Candidates
predicate can reference that element (c => c == u || c == this.Item.Topic.BackupReferee). Because the participants
are not known until run time, the fan-out has no statically-named instances — it is addressed by its base alias,
and the engine selects the instance for the acting principal.
Signature#
subscribe <Event>(<typed params>) as <Alias>
foreach (<EntityType> <var> in <this.Item…runtime collection>) {
Assignee = <var>; // pre-assign each instance to its element
Candidates = <principal> => <predicate using var>; // the eligible pool for that instance
}Description#
The collection expression may be any runtime entity collection reachable from this.Item (a navigation, a
sub-collection). It must be a collection whose element type matches the foreach variable type — a
foreach (User u in …) must iterate a collection of User, or the workflow does not compile. The literal-enum form
and this dynamic form share the same foreach grammar; the compiler distinguishes them by the collection
expression (a literal enum-member list is static, anything else is dynamic).
- One slot per element, resolved once at state entry. The engine evaluates the collection when the state is entered and creates a slot for each element. The set of participants is a snapshot — adding a row to the collection afterwards does not grow a new slot for the current run.
- Per-element key = the element's Id. Each instance is tagged with its element entity's Id (not a member name).
- Pre-assignment. With
Assignee = <var>, each instance opens Assigned to its element; without it the instance opens Unassigned and is claimed/deposited by whoever itsCandidatesadmit. - Actor-based addressing. A driver names the fan-out by its base alias
(
PeerReview.For(paper).Referees.Review(Decision.Approve)), and the engine routes the deposit to the instance assigned to the acting principal — or, failing that, the instance whoseCandidatesadmit the actor (the fallback pool). A principal in no instance's pool is refused (a security refusal — see Candidates (slot)). - The element variable. Inside
CandidatesandAssigneethe loop variable is the element for THAT instance, so each slot gates and pre-assigns to its own referee.
Pair a dynamic fan-out with a state-level Requires — named preconditions, and the live checklist to express a quorum over a set whose size is
unknown at compile time ("3 approving reviews"), counting DATA rows recorded by the deposit arm. A per-element
breach arm (Assigned / Finished (milestones) Finished { Unfinished(Slot slot) { … } }) receives the specific instance that
broke, so a late referee's slot can be reassigned to a backup without disturbing the others.
Examples#
One referee slot per element of this.Item.Topic.Referees, each pre-assigned, with a 3-approval quorum and a
breach that reassigns an unfinished slot to the backup:
enum Decision { Approve, Reject }
enum PaperStatus { Refereeing, Decided, Withdrawn }
// Everything the workflow reaches through — declared here because the example uses all of it.
[Principal]
entity User {
[Required, MaxLength(200)] string Email;
Topic Topic; // the back-reference `[ForeignKey(Topic)] User[] Referees` points at
security { allow read, create when IsAuthenticated; }
}
entity Topic {
[Required, MaxLength(120)] string Name;
[ForeignKey(Topic)] User[] Referees;
User BackupReferee;
security { allow read, create when IsAuthenticated; }
}
entity Paper {
[Required, MaxLength(200)] string Title;
[Required] Topic Topic;
PaperStatus Status; // no default: the workflow owns this field
[ForeignKey(Paper)] Review[] Reviews;
security { allow read, create, update when IsAuthenticated; }
}
entity Review {
[Required] Paper Paper;
User Referee;
Decision Decision;
security { allow read, create when IsAuthenticated; }
}
workflow PeerReview {
Tracks = Paper.Status;
Autostart = true;
Initial = Refereeing;
event Review(Decision decision);
state Refereeing {
Expire = TimeSpan.FromDays(60);
// one slot per referee, resolved at state entry, each pre-assigned to its element
subscribe Review(Decision decision) as Referees foreach (User u in this.Item.Topic.Referees) {
Assignee = u;
Candidates = c => c == u || c == this.Item.Topic.BackupReferee; // the fallback pool
Finished {
Within = TimeSpan.FromDays(14);
Unfinished(Slot slot) { slot.Assign(this.Item.Topic.BackupReferee); } // no goto → keep waiting
}
}
// recording a review always works — it is just DATA
on Review(Decision decision, Slot slot) {
new Review { Paper = this.Item, Referee = slot.Assignee, Decision = decision };
}
// the state completes when 3 approvals hold — the rest never have to answer
Requires {
Quorum { Must = this.Item.Reviews.Count(r => r.Decision == Decision.Approve) >= 3;
Message = "Three approving reviews are required."; }
}
on Complete { goto Decided; }
on Expire { goto Withdrawn; }
}
terminal success Decided { }
terminal cancel Withdrawn { Message = "not enough referees responded"; }
}Driving it — a referee deposits on their own instance by the base alias; the engine picks the instance by the acting principal:
runas (Ravi) { PeerReview.For(paper).Referees.Review(Decision.Approve); }
runas (Bina) { PeerReview.For(paper).Referees.Review(Decision.Approve); }
runas (Cora) { PeerReview.For(paper).Referees.Review(Decision.Approve); }
// three approvals ⇒ Decided. A principal in no referee pool is refused.See also#
- fan-out (foreach subscribe) — the static literal-enum-list form (addressed by member name)
- Candidates (slot) — the per-instance eligible pool (and the fail-closed refusal)
- Requires — named preconditions, and the live checklist — the state-level quorum that counts the recorded reviews
- Assigned / Finished (milestones) — the per-element
Finished { Unfinished(Slot slot) { … } }breach arm