# dynamic fan-out (foreach over a runtime collection)

> A `subscribe … foreach` over a RUNTIME entity collection expands into one wait slot per element of the collection, resolved at state entry. Each instance can be pre-assigned to its own element (`Assignee = x`), and the whole fan-out is addressed by its base alias — the engine picks the instance for the acting principal.

<!-- id: workflow-fan-out-dynamic · area: workflow · stability: stable · html: https://osysharp.com/reference/workflow/fan-out-dynamic/ -->

## Summary        {#summary}
Where the [fan-out (foreach subscribe)](https://osysharp.com/reference/workflow/fan-out/) 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      {#signature}
```osy syntax
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    {#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 its `Candidates` admit.
- **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 whose `Candidates` admit the actor (the
  fallback pool). A principal in no instance's pool is **refused** (a security refusal — see [Candidates (slot)](https://osysharp.com/reference/workflow/candidates/)).
- **The element variable.** Inside `Candidates` and `Assignee` the 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](https://osysharp.com/reference/workflow/requires/) 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)](https://osysharp.com/reference/workflow/milestone/) `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       {#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:

```osy title="one slot per referee, with a 3-approval quorum" test app=workflow-fan-out-dynamic
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:

```osy title="each referee deposits on the base alias" syntax
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       {#see-also}
- [fan-out (foreach subscribe)](https://osysharp.com/reference/workflow/fan-out/) — the static literal-enum-list form (addressed by member name)
- [Candidates (slot)](https://osysharp.com/reference/workflow/candidates/) — the per-instance eligible pool (and the fail-closed refusal)
- [Requires — named preconditions, and the live checklist](https://osysharp.com/reference/workflow/requires/) — the state-level quorum that counts the recorded reviews
- [Assigned / Finished (milestones)](https://osysharp.com/reference/workflow/milestone/) — the per-element `Finished { Unfinished(Slot slot) { … } }` breach arm
