ADR-0036: Casts role palettes as playstyle¶
- Status: Proposed
- Kind: Aspirational
- Area: orchestration
- Date: 2026-07-09
- Relations: extends ADR-0033
Context¶
Casts supplies a closed built-in catalog of behavioral roles and modes, ordinary typed emission contracts, and pack overlays for model, effort, modes, and policy. It does not yet make role composition part of a play. Five problems require a target contract.
P1 — A play's collaboration style is implicit. Current CLI planning exposes the full built-in role catalog plus user profiles to the orchestrator. PlanningEngine instead defaults to a fixed five-role tuple unless a caller supplies another roster. Playbooks describe prompts and runtime options but do not declare the intended role composition as play data. lionagi/casts/pattern.py, lionagi/casts/catalog.py, lionagi/casts/pack.py, lionagi/engines/planning.py, and lionagi/cli/orchestrate/__init__.py are the current anchors.
P2 — Static routing cannot deliberately recruit a role. role_node_builder() resolves a SpawnRequest only against branches already in its role map. An unknown assignee fails closed. This preserves authority but gives an orchestrator no typed way to recruit a catalog role that was deliberately left outside the initial composition.
P3 — Behavioral role and delegation authority are conflated. A casts role may emit ordinary typed findings or verdicts and always retain escalation, yet should not necessarily grow a live operation graph. Role name alone must not imply spawn authority.
P4 — Prompt guidance can contradict the capability grant. Bare and fallback workers receive a leaf-only system prompt from lionagi/cli/orchestrate/_common.py. A later grant_spawn() appends the generic schema block produced by render_capabilities_prompt() but does not revoke the leaf sentence or explain the assignee and budget policy. Runtime enforcement wins, but valid behavior is needlessly hard for the worker to infer.
P5 — Dynamic recruitment creates authority and concurrency races. Role expansion must be limited separately from operation count, must authorize the requester from trusted graph metadata, and must ensure two concurrent requests for the same new role provision once and consume one unit.
Current source contracts that the target builds on are:
@dataclass(init=False, frozen=True, slots=True)
class Role(Pattern):
name: str
description: str
body: str = ""
emits: tuple = ()
artifact_defaults: dict | None = None
@dataclass(frozen=True, slots=True)
class RoleConfig:
model: str | None = None
effort: str | None = None
default_modes: tuple[str, ...] = ()
modes_allow: tuple[str, ...] = ()
active: bool = True
Role.load() and Mode.load() fail with the available canonical names; list_roles() and list_modes() enumerate the closed built-in modules. Pack active is an overlay for ordinary roster membership, not a per-play authority grant.
| Concern | Decision |
|---|---|
| Play composition | D1: every new play declares a non-empty typed role palette and expansion policy. |
| Initial planning and provisioning | D2: palette roles are assignable immediately; catalog roles outside it are expansion-only and pass one resolver. |
| Live role expansion | D3: an awaitable router authorizes, reserves, provisions, and atomically publishes new role routes. |
| Authority and budgets | D4: trusted requester identity, distinct-role budget, and operation spawn budget are separate and observable. |
| Prompt and enforcement agreement | D5: one authoritative delegation clause is composed and replaced on grant/revocation. |
| Checkpoint and compatibility | D6: palette state is persisted; legacy palette-free plays have a bounded compatibility path whose removal release is deferred. |
Out of scope:
- Operation admission after a candidate is built. ADR-0033 still owns cycle,
max_spawn, branch clone, edge, and dropped-admission behavior. - The contents of the built-in casts role and mode catalogs. This ADR selects from them; it does not add role definitions.
- Model-tier escalation. ADR-0038 owns stronger-route selection for an existing responsibility.
- Queue attempts and resident host recovery. ADR-0037 owns those bounds.
- Automatic recruitment from user profiles. Profiles are not a closed catalog and must be named in the initial palette.
sequenceDiagram
participant O as Orchestrator operation
participant R as PaletteRoleRouter
participant C as Casts catalog
participant P as Branch provisioner
participant K as Reactive admission
O->>R: SpawnRequest + trusted requester role
R->>C: validate outside-palette role
R->>R: authorize and reserve distinct-role unit
R->>P: provision branch outside palette lock
P-->>R: branch or failure
R->>R: atomically publish route or roll back
R->>K: candidate Operation
K-->>O: accepted node or dropped ledger entry Decision¶
D1 — A play declares a typed role palette¶
A role palette is part of playstyle. It is neither a fixed roster attached to a playbook kind nor ambient access to the full catalog.
The target contract (lionagi/orchestration/palette.py):
from typing import Literal, Protocol
from pydantic import BaseModel, Field, model_validator
RoleDisposition = Literal["emission_capable", "terminal"]
class PaletteRole(BaseModel):
role: str
disposition: RoleDisposition = "terminal"
modes: tuple[str, ...] = ()
class PaletteExpansionPolicy(BaseModel):
max_new_roles: int = Field(default=0, ge=0)
orchestrator_may_expand: bool = True
catalog: Literal["casts"] = "casts"
class PlayRolePalette(BaseModel):
roles: tuple[PaletteRole, ...]
expansion: PaletteExpansionPolicy = Field(
default_factory=PaletteExpansionPolicy
)
class PaletteRouteRejection(BaseModel):
assignee: str
reason: Literal[
"unknown_catalog_role",
"requester_not_authorized",
"palette_budget_exhausted",
"role_provision_failed",
]
detail: str = ""
Validation occurs before planning or worker-branch construction:
rolesmust contain at least one item.- Each
rolename is non-empty after stripping and is unique by canonical name. - Each role is either a built-in casts role or an explicitly configured user profile named in this initial palette.
- Every requested mode must exist in
list_modes()and pass that role's packmodes_allowrule. - Duplicate modes within one palette role are rejected rather than silently deduplicated.
max_new_rolesis non-negative by field validation.- Expansion source is exactly
casts; no path, module, or arbitrary provider is accepted.
Exact semantics:
- Empty palette or any invalid entry rejects configuration as one Pydantic validation failure. No worker branch, graph node, artifact directory, or budget reservation is created.
terminaldescribes delegation authority only. It does not change operation lifecycle, ordinary casts emission contracts, or universal escalation.emission_capablegrantsSpawnRequestemission and participation in graph growth under D3/D4. It does not bypass target-role or operation admission policy.max_new_roles=0is the default and makes every outside-palette request reject, even thoughorchestrator_may_expand=True. Expansion requires an explicit positive budget.- Pack
active=Falsemay exclude a role from an ambient roster but does not override an explicit initial palette entry. The play declaration is the scoped authority; pack policy and model resolution still must succeed.
Why this way: composition and authority become inspectable data. The closed catalog bounds automatic recruitment, while explicit initial entries retain the ability to use a profile without turning all profiles into an expansion source.
D2 — Palette-aware planning and initial provisioning¶
Planning sees two role sets with distinct meaning: immediately assignable palette roles and expansion-only casts roles.
The target planner input shape:
class PalettePlanningContext(BaseModel):
assignable_roles: tuple[str, ...]
expansion_only_roles: tuple[str, ...]
max_new_roles: int
orchestrator_may_expand: bool
Target module boundary:
lionagi/orchestration/
├── palette.py models, state, router protocol, validation
├── patterns.py static role builder and palette-aware planner adapter
└── prompts.py rendered palette roster and delegation clauses
lionagi/cli/orchestrate/
└── _orchestration.py branch/model/artifact provisioner injected into router
Exact semantics:
- Every initial palette role is provisioned at most once before graph execution and entered in the run's role map, even when no initial assignment uses it. This makes its declared authority and routing availability deterministic.
- Planning output naming an initial palette role proceeds through ordinary
TaskAssignmentvalidation. - Planning output naming a built-in expansion-only role is sent to the same palette resolver used for live requests before the existing unknown-assignee filter. A successful resolution provisions and publishes the role, then graph construction continues.
- Planning output naming an unknown catalog role rejects with
unknown_catalog_role; it is not silently reinterpreted as a profile. - Planning expansion is authorized as an orchestrator request. There is no model- supplied requester field in
TaskAssignment; the planner call site supplies the trusted role. - Empty or unusable planning output retains the owning planner's failure behavior; palette resolution does not fabricate an assignment.
- Ordinary workers can name only roles already present in the palette state. They cannot use a planning or live request to expand the authority set.
- A role admitted during planning consumes the same distinct-role budget as a role admitted during live execution.
Why this way: the planner can express deliberate recruitment without receiving ambient authority to every role. Using one resolver prevents planned and live expansion from acquiring different authorization or accounting rules.
D3 — Awaitable, race-safe live role routing¶
The current synchronous role_node_builder() remains the static-map implementation. Palette expansion uses a separate awaitable router because branch provisioning may perform provider and filesystem work.
The target contracts (lionagi/orchestration/palette.py):
class PaletteRoleRouter(Protocol):
async def __call__(
self,
request: SpawnRequest,
emitter: Operation,
requester_role: str,
) -> Operation | PaletteRouteRejection: ...
class PaletteSnapshot(BaseModel):
initial_roles: tuple[str, ...]
expanded_roles: tuple[str, ...] = ()
reserved_roles: tuple[str, ...] = ()
max_new_roles: int
consumed_new_roles: int = 0
class PaletteRuntimeState:
role_routes: dict[str, Branch]
lock: asyncio.Lock
in_flight: dict[str, asyncio.Future[Branch]]
snapshot: PaletteSnapshot
The runtime state is an implementation object; PaletteSnapshot is the persisted contract.
Routing algorithm:
- Normalize and read
request.assignee. An omitted assignee uses the existing emitter-role route and performs no role expansion. - Under the palette lock, return an existing role route immediately when present.
- Reject an outside role absent from the casts catalog.
- Reject unless
requester_role == "orchestrator"andorchestrator_may_expandis true. - If another request is provisioning the same role, capture its future, release the lock, and await that result without reserving another unit.
- Otherwise reject when
consumed + len(reserved_roles) >= max_new_roles. - Reserve the canonical role and publish one in-flight future; release the lock.
- Provision the branch outside the lock.
- Reacquire the lock. On success, publish the route, move the role from reserved to expanded, increment consumed once, and resolve all same-role waiters.
- On exception or cancellation, remove the reservation and in-flight entry, resolve waiters with the same failure, and return
role_provision_failed. - Build the child operation through the ordinary allowlisted role-operation builder and return it to ADR-0033 admission.
Exact semantics:
- Reusing an initial or previously expanded role consumes no new-role unit.
- Two concurrent requests for the same outside role share one provision attempt and consume at most one unit.
- Two concurrent requests for different roles may provision concurrently after each reserves a unit. The lock is never held across provider or filesystem I/O.
- Failed provisioning consumes no unit and publishes no role route or partially configured branch.
- A branch is added to the route map only after all model, tool, mode, policy, and artifact setup succeeds.
- Router exceptions are converted to
role_provision_failedwith bounded, non-sensitive detail; they do not escape as untyped executor errors. - The reactive executor awaits routing before entering its existing synchronous
_accept_node()lock. Palette locking and graph locking are never nested. - A trusted code caller using prebuilt
ReactiveExecutor.inject()bypasses the palette router, as it does today, but remains subject to graph admission. This is an operator API, not model authority.
Why this way: provisioning cannot safely occur while holding the graph's synchronous lock, and a synchronous router cannot represent it honestly. Separate reservation and commit phases bound authority while keeping slow work outside the critical section.
D4 — Trusted requester identity and two independent budgets¶
Palette expansion and operation spawning are different resources.
The target metadata and ledger contracts:
# Trusted graph-builder metadata, never copied from model output.
operation.metadata["requester_role"] = <canonical role name>
# Palette rejection appended to the existing reactive dropped_spawns list.
{
"reason": Literal[
"unknown_catalog_role",
"requester_not_authorized",
"palette_budget_exhausted",
"role_provision_failed",
],
"assignee": str,
"emitter_id": str | None,
"detail": str,
}
Exact semantics:
- The graph builder stamps
requester_rolefrom the branch/role route it created.SpawnRequest.assignee,reason, instruction text, and any structured context cannot establish requester identity. - Missing trusted requester metadata is unauthorized for outside-palette expansion. It is not treated as orchestrator by default.
max_new_rolescounts distinct roles added to the palette. ADR-0033max_spawncounts accepted operations. A request can consume a palette unit and later be rejected by graph cap or cycle admission; the role remains available because provisioning itself succeeded.- Conversely, an existing-role request consumes no palette unit but still consumes one ordinary spawn admission when accepted.
- Palette rejections join
dropped_spawnswith their specific reason. Existing reasons (builder_error,null_child,cycle,max_spawn_exceeded,duplicate) remain unchanged for their stages. - The default
max_new_roles=0is a least-authority default. No empirical reason selects another universal number, so plays choose their own explicit cap.
Why this way: a role route expands who can perform work; an operation expands how much work runs. Combining the counters would let many new roles hide behind one operation or let one role exhaust recruitment by doing ordinary work.
D5 — One authoritative delegation clause in the prompt¶
Capability prompts must agree with runtime enforcement without becoming the enforcement mechanism.
The target prompt contract (lionagi/orchestration/prompts.py):
def render_delegation_clause(
*,
role: str,
disposition: RoleDisposition,
allowed_assignees: tuple[str, ...],
may_expand_palette: bool,
remaining_new_roles: int,
remaining_spawns: int,
) -> str: ...
Exact semantics:
- A terminal role receives one leaf clause stating it cannot create graph work. It may still emit its ordinary role results and
EscalationRequest. - An emission-capable role receives one block naming spawn permission, currently allowed assignees, whether outside-palette expansion is available, both budget values, and the fact that rejected requests appear in the run ledger.
- The block replaces any existing leaf or delegation clause using stable markers; it does not append contradictory prose.
- Granting spawn installs both the runtime Operable and the rendered clause. Revocation removes the SpawnRequest grant and restores the terminal leaf clause.
- A changing remaining-budget value need not rewrite the system message after every spawn. The prompt states the value at turn construction and that runtime state is authoritative.
- Ignored or manipulated prompt text cannot expand authority. Schema validation, requester metadata, router policy, and both budgets remain enforcement.
Why this way: valid behavior should be discoverable. A single replaceable clause removes the current leaf-versus-grant contradiction while retaining hard checks below the prompt.
D6 — Persisted palette state and migration compatibility¶
Checkpoints record enough state to resume without resetting recruitment authority or charging the same role twice.
The persisted contract is PaletteSnapshot from D3 plus the original PlayRolePalette specification and ADR-0033's ordinary spawn counters.
Exact semantics:
- A checkpoint writes the declared initial palette, accepted expanded roles, current reservations, maximum and consumed distinct-role counts, and the operation spawn count in the same checkpoint boundary.
- A clean checkpoint contains no reservations. If a process stops with a reserved role, resume treats the reservation as uncommitted: it removes it and permits a fresh provision attempt without incrementing consumed count.
- Resume validates the saved palette against the current catalogs and pack before accepting new work. Missing roles or now-invalid modes fail resume explicitly; they are not silently dropped.
- Expanded roles are reconstructed into the route map once. Their saved membership is authoritative for budget accounting; reconstruction does not charge again.
- A changed play specification whose palette or maximum differs from the checkpoint is a resume conflict and requires a new run or an explicit migration.
- User profiles remain valid only when present in
initial_roles; an expanded role must resolve from the casts catalog.
DEFERRED — palette-free compatibility cutoff: legacy playbooks without a palette temporarily resolve to the current full planning roster and emit a deprecation warning. The removal release is not verified or decided in the current corpus. Implementers must set and document that compatibility window before making palette declarations mandatory; until then, new and migrated built-ins must declare palettes while legacy fallback remains explicit.
Why this way: a restart must not restore a larger authority set or a fresh budget. Persisting declarative and consumed state makes replay deterministic; a partially provisioned branch is treated as uncommitted work.
Consequences¶
- Plays can express narrow terminal pipelines, exploratory emitter-heavy teams, and mixed collaboration styles using the same objective.
- Delegation authority and recruitment spend become visible in data instead of being inferred from role names or prompt prose.
- Dynamic recruitment adds branch-provisioning failure, reservation cleanup, and two-dimensional budget accounting. These are observable and testable but materially expand orchestration state.
- A catalog role can remain unavailable because model, tools, modes, or policy cannot be resolved. Such failure occurs before route publication.
- Persisted runs gain a compatibility obligation for palette snapshots and resume conflicts.
- The design gives up ambient access to every role and prompt-only flexibility. A role named in prose cannot execute unless typed state and routing policy allow it.
- Reversing D1 or D4 is high cost because play data and checkpoint accounting depend on them. Changing prompt rendering is lower cost as long as runtime authority and stable markers remain.
Alternatives considered¶
Fixed role sets per playbook kind¶
This would be easy to validate and document. It lost because collaboration style is a property of a play instance: two plays using the same operational template may need a narrow review pair or a broad exploratory team. A fixed set would push variation back into prose or new playbook kinds.
Expose the full casts catalog to every run¶
This matches current CLI discoverability and eliminates recruitment failures. It lost because role availability becomes ambient authority and cost. A play cannot state or enforce a narrow collaboration boundary.
Use pack active as the palette¶
This would reuse an existing boolean. It lost because active is a reusable pack overlay, not per-play composition, and carries neither disposition, modes, nor a distinct-role expansion budget.
Infer spawn authority from role name¶
This would avoid RoleDisposition; orchestrators and coordinators could spawn, other roles could not. It lost because behavioral identity and delegation policy change independently. A researcher may be allowed to decompose one play and be a terminal specialist in another.
Let any worker recruit any catalog role¶
This would maximize adaptive collaboration. It lost because every emission-capable worker could expand the authority set and consume provider setup without an orchestrator decision. Ordinary workers are limited to already-published routes.
Provision every catalog role at startup¶
This would make routing synchronous and eliminate provision races. It lost because unused roles would still incur model, tool, prompt, branch, and artifact setup, and the runtime would possess more configured capability than the play declared.
Hold one lock across reservation and branch provisioning¶
This would make the algorithm easy to reason about. It lost because provider and filesystem work would serialize all palette requests and risk blocking unrelated role lookups. Reservation plus an in-flight future preserves correctness without holding the lock across I/O.
Reuse the synchronous role_node_builder() for expansion¶
This would avoid a second interface. It lost because the existing builder assumes a complete static role map and cannot await branch provisioning. Making it sometimes async would also break ADR-0033's current synchronous node-builder contract.
Combine max_new_roles with max_spawn¶
This would expose one intuitive budget. It lost because distinct authority growth and operation volume are not interchangeable. A reused role should not spend a new- role unit, and a newly provisioned role can exist even if its first operation loses graph admission.
Trust a model-emitted requester field¶
This would make routing self-contained. It lost because the requester could claim to be the orchestrator. Identity comes from the operation/branch construction path, not the payload being authorized.
Keep the generic capability schema block as the only prompt guidance¶
This would avoid prompt-specific rendering. It lost because schema alone does not resolve the current contradiction with the leaf instruction and does not explain assignee or budget policy.
Automatically expand user profiles¶
This would make custom workers as flexible as built-ins. It lost because profiles are not a bounded, centrally enumerable authority source. They remain available by explicit initial declaration.