FI
ForgeOrchestrator

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

SequenceAction
public protocol SequenceAction: OrchestratedAction {
    func execute() async
}
PipelineAction
public protocol PipelineAction: OrchestratedAction {
    func execute(context: PipelineContext) async -> ActionResult
}
MonitorAction
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 in shouldRun() 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. ActionID is 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 for execute().
  • Actions should tolerate re-evaluation. Especially for MonitorAction: the same action can be evaluated many times across the app's lifetime.