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

Reference / Workflow

Candidates (slot)

Candidates = <principal => bool> | <() => List<Principal>>;

Declares WHO may hold or satisfy a `subscribe` slot. `Candidates` is one expression surface that dispatches on its return type: a `principal => bool` PREDICATE selects the eligible pool by a rule; a `() => List<Principal>` COMPUTATION returns the eligible set outright, computed off the run's data (`this.Item` is in scope). A principal not eligible is refused when they try to claim or deposit. Returning anything else is a compile error.

stable2 examples compiled by CIworkflowauthoringsecurity

Summary#

Candidates on a subscribe slot declares WHO may hold or satisfy that slot. It is a single expression surface that the compiler dispatches on by return type:

  • returns bool → a predicate (u => u.Team == Team.Support): the eligible pool is every principal for whom the rule holds.
  • returns List<Principal> → a computation (() => Agent.Where(u => u.Region == this.Item.Region && u.OnCall).ToList()): the eligible set is exactly the list you return, computed fresh off the run's data.

Either way a principal who is not eligible is refused when they try to claim or deposit. Returning any other type (a scalar, a single principal) is a compile error. This is who-may-HOLD authorization — a different question from [Authorize], which is who-may-RAISE an event.

Signature#

subscribe <Event>() as <Alias> {
  Candidates = <principal> => <predicate>;              // a bool predicate  → the pool by a rule
  // — or —
  Candidates = () => <expression returning List<Principal>>;   // a computation → the eligible set
}

Description#

A slot with Candidates is a pool slot: work that any eligible principal may pick up. The gate is a membership check in both forms — "is this principal one of the eligible set?" — evaluated fail-closed every time a principal tries to claim the slot or deposit into it.

The predicate form — a rule#

The predicate form is a single-parameter lambda whose parameter is the principal being tested, typed as the app's [Principal] entity. this.Item (the tracked entity) is in scope, so the rule can compare the principal to the item — a role test, an ownership test, a four-eyes exclusion:

subscribe Decide() as Legal {
  Candidates = u => u.Department == Dept.Legal && u != this.Item.Requester;   // four-eyes
}

Reading a sibling slot — cross-slot four-eyes#

A rule often has to exclude whoever already acted, not whoever raised the item. Name the sibling slot by its as alias and read who holds it:

enum PoStage { Review, Done }

[Principal] entity Person {
  [Required, MaxLength(80)] string Name;
  security { allow read, create when IsAuthenticated; }
}

entity Po {
  [Required, MaxLength(120)] string Title;
  PoStage Stage;
  security { allow read, create, update when IsAuthenticated; }
}

workflow PoApproval {
  Tracks    = Po.Stage;
  Autostart = true;
  Initial   = Review;

  event Approve();

  state Review {
    subscribe Approve() as First { }

    subscribe Approve() as Second {
      After      = [First];                    // First is satisfied before this opens…
      Candidates = u => u != First.Assignee;   // …so its assignee is who really approved
    }

    on Complete { goto Done; }
  }

  terminal success Done { }
}

Three members read off a sibling: .Assignee (the principal holding it, or nothing while it is unheld), .Status, and .IsUnassigned. They answer for this run, so each item in flight is judged against its own history rather than against a rule written once for all of them.

Order is part of the rule, so make it explicit. The gate is evaluated when someone tries to claim or deposit, and a slot nobody holds yet has no assignee to exclude — so u != First.Assignee admits everyone until First is taken. After is what makes the exclusion mean what it reads like: with it, the second slot does not exist to anybody until the first is satisfied.

A FANNED-OUT sibling is refused, because that alias names one slot per element and the read cannot say which. Answering with an arbitrary instance would admit exactly the people the other instances exclude — a wrong allow, which is the one direction an authorization rule must never fail in. Compare against a slot that names exactly one, or decide it in the route arm once the fan-out is satisfied.

The computation form — the set#

The computation form returns the eligible set as a List<Principal> — arbitrary Osy# that queries or assembles the list. this.Item is ambiently in scope, so the set is computed against the run's own data. The primary shape is a zero-parameter lambda; a named function that returns a list works too. It is computed fresh, on demand each time the gate runs — never stored or materialised:

subscribe Handle() as Owner {
  Candidates = () => Agent.Where(u => u.Region == this.Item.Region && u.OnCall).ToList();
}

Use the computation form when eligibility is a query over data rather than a rule over one principal — "the on-call agents in this ticket's region", "everyone on the account team for this order" — especially when the set depends on relationships the item points at.

Return-type dispatch and errors#

The compiler decides the form from the resolved return type: bool → predicate, List<Principal> → computation. A Candidates that returns anything else is rejected at compile time:

`Candidates` must return either `bool` (a `principal => predicate`) or `List<Principal>`
(a computation returning the eligible set) — got 'Agent'.

Fail-closed#

In both forms the gate refuses when it cannot positively establish membership: no acting principal, no [Principal] entity, an unresolvable principal, or an empty computed set → not a candidate. A refused claim or deposit throws and is recorded on the workflow's audit timeline; the entity does not move.

May this person claim? Asking the rule yourself#

A screen that offers a Claim button has to know whether the viewer may claim — and the only honest answer is the slot's own rule. Ask it:

enum PoStage { Draft, Review, Done }
enum Dept { Legal, Finance }

[Principal] entity Person {
  [Required, MaxLength(80)] string Name;
  Dept Department = Dept.Legal;
  security { allow read, create when IsAuthenticated; }
}

entity Po {
  [Required, MaxLength(120)] string Title;
  PoStage Stage;
  [Required] Person Requester;
  security { allow read, create, update when IsAuthenticated; }
}

workflow PoApproval {
  Tracks    = Po.Stage;
  Autostart = true;
  Initial   = Draft;

  event Submit();
  event Approve();

  state Draft { subscribe Submit(); on Submit { goto Review; } }

  state Review {
    subscribe Approve() as Legal {
      // Four-eyes: a Legal approver, and never the person who raised it.
      Candidates = u => u.Department == Dept.Legal && u != this.Item.Requester;
    }
    on Legal { goto Done; }
  }

  terminal success Done { }
}

// What a screen asks before it draws a Claim button — the slot's OWN rule, not a second copy of it.
bool MayClaimLegal(Po po, Person who) {
  return PoApproval.For(po).Legal.Candidates(who);
}

It inlines the declared predicate, so there is one expression of the rule rather than two. That matters more than it sounds: a page that re-types the rule drifts from the slot silently, and always in the worse direction — offering a button the deposit then refuses, or hiding one that would have been accepted. Because it inlines rather than calling the engine, it also lowers into the surrounding read, so it is legal in a live var and in a client-rendered page.

Inside a milestone body the same question is slot.Candidates(u), where the run is ambient rather than named. Both forms answer identically, and both accept either declaration form — against a computation the call becomes a membership test over the computed set.

On a FANNED-OUT slot, naming the instance is what binds the fan-out variable. A predicate like Candidates = u => u.Hat == h reads the loop variable, so .Architect.Candidates(u) and .Security.Candidates(u) ask two different questions from one declaration — the variable substitutes to that instance's own element.

A DYNAMIC fan-out is refused, because its slots have no static names — they are addressed at run time by the acting principal, so there is no single slot for the question to be about. Use Workflow.Inbox<T>() there, which answers it for the caller.

A slot that declares no Candidates is REFUSED here, not answered true. Such a slot admits everyone, so the question has no content, and a caller guarding on a constant is guarding on nothing:

slot 'Legal' declares no `Candidates`, so every principal is eligible and `.Candidates(...)` has nothing to answer.
Drop the check, or declare `Candidates` on the slot.

The check is a courtesy, never the gate. Authorization happens at the deposit, server-side, whatever the screen drew — so a page that offers the wrong button is a cosmetic bug rather than a security one. That is the right direction, and it is why this may be used freely in UI.

Examples#

Eligibility as a rule — only a Legal approver who is not the requester (four-eyes):

subscribe Decide() as Legal {
  Candidates = u => u.Department == Dept.Legal && u != this.Item.Requester;
}

Eligibility as a computed set — the on-call agents in the ticket's region:

subscribe Handle() as Owner {
  Candidates = () => Agent.Where(u => u.Region == this.Item.Region && u.OnCall).ToList();
}

See also#

  • subscribe — the subscribe slot Candidates lives on
  • [Authorize] (event)[Authorize] on an event: who may RAISE (contrast with who may HOLD here)
  • Assign — handing a slot to a named colleagueReassign: who may MOVE a slot. A third question again, and eligibility to hold work is deliberately not authority over it — a principal Candidates admits still cannot take a slot off its holder

Related

subscribe

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

[Authorize] (event)

Gates WHO may raise a workflow event. `[Authorize]` on an event is a `principal => bool` predicate over the acting…

Assign — handing a slot to a named colleague

Give a slot to somebody else. Claiming takes work for yourself and releasing puts it back in the pool; assigning is the…

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…