Action Model
Every action shares the same identity and gating model. Only execute() differs per orchestrator.
OrchestratedAction (base protocol)
Every action conforms to OrchestratedAction, which establishes identity, priority, and a gate condition. The three concrete protocols — SequenceAction, PipelineAction, MonitorAction — refine it with their own execute() signature.
public protocol OrchestratedAction: Sendable {
var id: ActionID { get }
var priority: ActionPriority { get }
func shouldRun() async -> Bool
} Per-orchestrator specialization
public protocol SequenceAction: OrchestratedAction {
func execute() async
} public protocol PipelineAction: OrchestratedAction {
func execute(context: PipelineContext) async -> ActionResult
} public protocol MonitorAction: OrchestratedAction {
func execute() async
} ActionID
ActionID is a strongly-typed identifier. It's Hashable, Comparable, Sendable, and ExpressibleByStringLiteral — so you can use a plain string literal when assigning.
let id = ActionID("onboarding")
// ActionID is ExpressibleByStringLiteral
let id: ActionID = "onboarding"
// Comparable, Hashable, Sendable — works as Set/Dictionary key
var seen: Set<ActionID> = [id] Deduplication: orchestrators use the id to prevent duplicate registrations. Registering two actions with the same id logs a warning and keeps the first.
ActionPriority
Actions execute in priority order within a single evaluation. Lower raw values run first.
public enum ActionPriority: Int, Comparable, Sendable {
case critical = 0 // runs first
case high = 1
case medium = 2
case low = 3 // runs last
} Priority guide
| Priority | Use case | Examples |
|---|---|---|
.critical | Blocks all app usage | Force update, maintenance mode, terms acceptance |
.high | Core setup the user must complete | Onboarding, required permissions, migration |
.medium | Important but skippable | What's new, optional permissions, review prompt |
.low | Nice to have, non-intrusive | Tips, promotions, announcements |
struct ForceUpdateAction: SequenceAction {
let id: ActionID = "force-update"
let priority: ActionPriority = .critical // blocks all app usage
func shouldRun() async -> Bool { /* ... */ }
func execute() async { /* ... */ }
} Ties are broken by registration order: two actions with the same priority run in the order they were registered.
ActionResult (PipelineAction only)
PipelineAction.execute() returns an ActionResult so downstream consumers can observe which actions completed, which were skipped, and which failed. SequenceAction and MonitorAction have void return types — use throwing or state mutation for error signaling instead.
public enum ActionResult: Sendable {
case completed
case skipped // precondition failed during execution
case failed(String) // explicit failure with reason
} Result semantics
.completed— the action finished its work successfully.skipped— the action started running but a precondition it couldn't check inshouldRun()turned out to be unmet. Use for "started, then bailed out".failed(reason)— an error happened. The reason string is for logging and UI, not for control flow — branch on the case, not on the message
struct FetchPostsAction: PipelineAction {
let id: ActionID = "fetch-posts"
let priority: ActionPriority = .high
func shouldRun() async -> Bool { true }
func execute(context: PipelineContext) async -> ActionResult {
do {
let posts = try await api.fetchPosts()
context.set("posts", posts)
return .completed
} catch {
return .failed(error.localizedDescription)
}
}
} shouldRun() vs execute()
Keep shouldRun() cheap and idempotent. Orchestrators evaluate shouldRun() on every registered action concurrently before executing anything. If it does heavy work, you'll pay that cost per evaluation cycle — especially with MonitorOrchestrator, which re-evaluates on every tick.
Best Practices
- One responsibility per action. Don't combine "show onboarding" and "fetch profile" in one action — split them and let the orchestrator sequence them.
- Use stable IDs.
ActionIDis used for deduplication and logging; don't construct it from mutable state. - Keep actions
Sendable. The protocol requires it. Don't capture mutable state in stored properties without a lock. shouldRun()is a pure check. Never mutate state here — save that forexecute().- Actions should tolerate re-evaluation. Especially for
MonitorAction: the same action can be evaluated many times across the app's lifetime.