Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / Workflow

dynamic fan-out (foreach over a runtime collection)

subscribe Event(params) as Alias foreach (T x in this.Item.Some.Collection) { Assignee = x; Candidates = c => c == x || …; }

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.

stable1 example compiled by CIworkflowauthoring

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.Itemruntime 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 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)).
  • 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 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#

Related

fan-out (foreach subscribe)

One `subscribe` declaration that expands into MANY parallel wait slots — one per element of a collection. A fan-out…

subscribe

Declares that a workflow state waits on an event, and configures the wait — who may hold it, who may hand it on…

Requires — named preconditions, and the live checklist

Named conditions that must hold before something may happen, declared on a state or on a slot — and readable as a live…

Candidates (slot)

Declares WHO may hold or satisfy a `subscribe` slot. `Candidates` is one expression surface that dispatches on its…

Assigned / Finished (milestones)

A milestone puts an SLA on a slot's progress — Assigned (someone must PICK IT UP within Within) and Finished (it must…