You Can't Launch What Hasn't Been Approved

dev.to

I designed resources because I was tired of reasoning about async I/O.

Every developer knows the ritual: open a connection, do work, close the connection. Handle the case where the open failed. Handle the case where you're writing to something that isn't ready yet. Handle the case where you close something twice. Add async to the mix and the state space multiplies — is the request in flight? Has the response arrived? Am I holding a promise or a result?

The logic is a state machine, and most developers could draw it on a whiteboard in thirty seconds. But the compiler can't see the whiteboard. The states live in a boolean field or an enum, the transitions live in runtime guards, and a caller who uses a resource in the wrong state gets an exception instead of a compile error.

I wanted the compiler to track that state in the program's model: whether a connection value represents an open channel, whether a request is still in flight, whether a response has arrived. This doesn't make the outside world infallible — networks still fail, files still disappear, other systems still change state — but it does mean your own code can't call an operation with a value in the wrong modeled state.

A Subspace Channel

Ruuk's resource keyword declares an entity with named states that the compiler tracks. Here's a subspace channel — the Enterprise's long-range communication link:

pub resource SubspaceChannel =
    state Idle
    state Open
    state Transmitting
    state Terminal
        state Closed
        state Failed
Enter fullscreen mode Exit fullscreen mode

An idle channel has type SubspaceChannel<Idle>. An open channel has type SubspaceChannel<Open>. These are different types — operations that expect one reject the other.

The Terminal grouping declares Closed and Failed as intentional endpoints: states the channel doesn't leave. The compiler won't warn about missing outgoing transitions from terminal states.

Operations declare which state they require using the subject role — a parameter role from the op vocabulary introduced earlier in the series, which marks the resource being acted on:

pub op openChannel =
    subject channel: SubspaceChannel<Idle>
    payload frequency: SubspaceFrequency
    outcomes =
        | Connected of SubspaceChannel<Open>
        | FrequencyBusy
        | OutOfRange

pub op send =
    subject channel: SubspaceChannel<Open>
    payload message: Message
    outcomes =
        | Transmitting of SubspaceChannel<Transmitting>
        | SendFailed of SubspaceChannel<Failed>

pub op awaitResponse =
    subject channel: SubspaceChannel<Transmitting>
    outcomes =
        | Received of response: Message, channel: SubspaceChannel<Open>
        | Timeout of SubspaceChannel<Open>
        | ConnectionLost of SubspaceChannel<Failed>

pub op closeChannel =
    subject channel: SubspaceChannel<Open>
    outcomes =
        | Closed of SubspaceChannel<Closed>
Enter fullscreen mode Exit fullscreen mode

send only accepts a SubspaceChannel<Open> and transitions it to Transmitting. While transmitting, you can't send another message — send requires Open, and you're holding Transmitting. You call awaitResponse, which returns the channel to Open on success or transitions to Failed on connection loss. The types enforce the async state flow: the compiler won't let you send on a channel that's still waiting for a reply, and it won't let you close a channel that's mid-transmission.

This is what I wanted: I/O where the compiler tracks the resource state your program is holding, and using that value in the wrong state is a type error rather than a runtime exception. The state machine is in the declaration, not in a developer's head — which means an agent working with the channel gets the same guardrails as a human.

From I/O to Workflows

I designed resources for I/O. What I didn't expect was how naturally the same mechanism models domain workflows.

Every significant entity on a starship has a lifecycle. An away mission is proposed, reviewed, approved, launched, and completed — or aborted. A repair order is filed, triaged, assigned, and resolved. A course change is plotted, verified, authorized, and executed. These aren't I/O resources, but they have the same structure: named states, valid transitions, operations that only make sense in certain states.

Most codebases model this with a status field and a runtime guard:

type MissionStatus =
    | Proposed
    | Approved
    | InProgress
    | Completed
    | Aborted

type Mission = {
    id: Guid
    name: String
    status: MissionStatus
    team: List<CrewMember>
    objective: String
}

let launchMission (mission: Mission) =
    if mission.status <> Approved then
        failwith "Can only launch approved missions"
    // ... proceed
Enter fullscreen mode Exit fullscreen mode

The guard works. But the compiler doesn't know that launchMission requires an approved mission — it accepts any Mission. A developer who calls launchMission with a proposed mission gets a runtime failure, not a compile error. And there's nothing in the type that tells a reader — or an agent modifying the code — what the valid states and transitions are.

With resource, the mission lifecycle works like the subspace channel:

pub resource Mission =
    state Proposed
    state Approved
    state InProgress
    state Terminal
        state Completed
        state Aborted
Enter fullscreen mode Exit fullscreen mode

Each state produces a different type. Mission<Proposed> and Mission<Approved> are as distinct to the compiler as String and Int. An operation that approves a mission only accepts the proposed state:

pub op approveMission =
    subject mission: Mission<Proposed>
    by captain: CrewMember
    outcomes =
        | Approved of Mission<Approved>
        | InsufficientIntelligence
        | CrewNotAvailable of missing: List<String>
Enter fullscreen mode Exit fullscreen mode

Now try launching a mission that hasn't been approved:

launchMission proposedMission by riker
-- Compile error: launchMission expects Mission<Approved>, got Mission<Proposed>
Enter fullscreen mode Exit fullscreen mode

Riker can't skip Picard's sign-off. Not because of a runtime check that someone might forget to add, but because the types don't permit it.

The performs Clause

The approveMission operation above constrains which state it accepts — only Mission<Proposed>. But it doesn't yet declare which state it produces. The performs clause makes the transition explicit:

pub op approveMission =
    subject mission: Mission<Proposed>
    by captain: CrewMember
    performs Mission.Proposed -> Mission.Approved
    outcomes =
        | Approved of Mission<Approved>
        | InsufficientIntelligence
        | CrewNotAvailable of missing: List<String>
Enter fullscreen mode Exit fullscreen mode

The performs clause tells the compiler two things: this operation starts with the resource in Proposed state, and on success, transitions it to Approved. The compiler validates both directions. If performs says Proposed -> Approved but the success outcome carries Mission<InProgress>, that's a compile error. If the subject is typed as Mission<InProgress> but performs says Proposed -> Approved, that's also a compile error. Declaration and type must agree.

Not every operation performs a state transition. Read-only operations omit performs:

pub op getMissionBriefing =
    subject mission: Mission<Approved>
    outcomes =
        | Briefing of MissionBriefing
Enter fullscreen mode Exit fullscreen mode

This requires a specific state — you can only get the briefing for an approved mission — without transitioning to a new state. The subject type still enforces the precondition.

The Full Mission Lifecycle

Here's the complete state machine with all operations declared:

pub resource Mission =
    state Proposed
    state Approved
    state InProgress
    state Terminal
        state Completed
        state Aborted

pub op proposeMission =
    payload objective: MissionObjective
    payload team: List<CrewMember>
    by officer: CrewMember
    outcomes =
        | Proposed of Mission<Proposed>
        | InvalidObjective of reason: String

pub op approveMission =
    subject mission: Mission<Proposed>
    by captain: CrewMember
    performs Mission.Proposed -> Mission.Approved
    outcomes =
        | Approved of Mission<Approved>
        | InsufficientIntelligence
        | CrewNotAvailable of missing: List<String>

pub op launchMission =
    subject mission: Mission<Approved>
    by firstOfficer: CrewMember
    performs Mission.Approved -> Mission.InProgress
    outcomes =
        | Launched of Mission<InProgress>
        | TransporterUnavailable
        | EnvironmentalHazard of hazard: String

pub op completeMission =
    subject mission: Mission<InProgress>
    performs Mission.InProgress -> Mission.Completed
    outcomes =
        | Completed of Mission<Completed>
        | ObjectivesIncomplete of remaining: List<String>

pub op abortMission =
    subject mission: Mission<InProgress>
    by officer: CrewMember
    performs Mission.InProgress -> Mission.Aborted
    outcomes =
        | Aborted of Mission<Aborted>
Enter fullscreen mode Exit fullscreen mode

A pipeline that processes a mission through its lifecycle:

proposeMission objective team by riker
|> on Proposed mission ->
    approveMission mission by picard
    |> on Approved approved ->
        launchMission approved by riker
        |> on Launched active ->
            completeMission active
            |> on Completed done ->
                log $"Mission {done.name} completed successfully."
            |> on ObjectivesIncomplete remaining ->
                log $"Objectives remaining: {remaining}. Continuing mission."
        |> on TransporterUnavailable ->
            log "Transporter offline. Scheduling shuttle departure."
        |> on EnvironmentalHazard hazard ->
            log $"Launch aborted: {hazard}. Revising mission parameters."
    |> on InsufficientIntelligence ->
        log "Insufficient intelligence for mission approval. Requesting sensor sweep."
    |> on CrewNotAvailable missing ->
        log $"Required crew unavailable: {missing}."
|> on InvalidObjective reason ->
    log $"Mission objective rejected: {reason}."
Enter fullscreen mode Exit fullscreen mode

The nesting mirrors the state progression. You can only enter the launchMission block with a Mission<Approved> because approveMission only produces one on its Approved arm. The types flow through the pipeline, and the compiler verifies every step. Each level handles its own outcomes completely — exhaustive handling from article 3 applies at every level.

Whole-Program State Machine Validation

The performs clauses across all operations define the state machine's transition graph. The compiler builds this graph and validates it for structural correctness.

Orphan state detection. A state is orphan if no operation transitions into it. If you declare a Suspended state on Mission but no performs clause has -> Mission.Suspended, the compiler warns: that state is unreachable. Either a performs clause is missing, the state is mislabeled, or it should be removed.

Dead-end state detection. A state is a dead-end if it has no outgoing transitions and isn't declared terminal. If Mission<Approved> had no operations with performs Mission.Approved -> ..., missions would get stuck — once approved, they could never progress. The compiler flags this.

Performs/subject consistency. Every performs From -> To clause must match its subject parameter type. If the subject is Mission<InProgress> but performs says Proposed -> Approved, the compiler rejects the mismatch.

Performs/outcome consistency. Every success outcome of an operation with performs From -> To must carry the resource typed as the target state. If performs says Proposed -> Approved but the outcome carries Mission<InProgress>, that's a compile error.

Together, these four checks make the state machine self-consistent and verifiable without running any code. The compiler catches the structural errors — orphan states, dead ends, mismatched transitions — that would otherwise surface as unreachable code paths, stuck entities, or confused runtime behavior in production.

Projections with State

Projections from the previous article interact naturally with typestate. A projection can require a specific state:

type MissionBriefing = Mission<Approved> only {
    name; objective; team; approvedBy; approvedAt
}

type MissionReport = Mission<Completed> only {
    name; objective; team; completedAt; findings
}
Enter fullscreen mode Exit fullscreen mode

MissionBriefing can only be constructed from a Mission<Approved>. Passing a Mission<Proposed> is a type mismatch. MissionReport requires Mission<Completed>. The state requirement is encoded in the projection, not in a runtime check.

This addresses a subtle problem: some fields only have meaningful values in certain states. The approvedBy field on a mission is empty before approval. A projection that requires Mission<Approved> says, at the type level, that approval data is part of the value being projected. The findings field is empty before completion. A projection that requires Mission<Completed> says the same thing about mission findings.

What Compounds

A pattern has emerged across these articles. op started as a way to enforce error handling under schedule pressure; it turned out to produce exhaustive compiler verification. Projections started as DTO shorthand; they turned out to produce provable access boundaries. Resources started as a way to reason about I/O; they turned out to model enterprise workflows with compiler-verified state machines. Each feature was designed for practical relief and arrived at structural correctness.

What I didn't anticipate is how they compound. An op whose subject is a resource in a specific state, whose payload is a projection of that resource — that single declaration encodes what data the operation can see, what state the resource must be in, what transitions are valid, and what outcomes the caller must handle. The compiler verifies all of it. Runtime guards, permission filters, tests, and code review still matter, but they usually operate as separate practices. These language features are designed to compose inside one declaration.

The first article argued that agentic coding needs a three-party model: humans define intent, agents write implementations, compilers verify structure. This is where that model becomes concrete. An agent modifying the mission workflow doesn't need to discover the state machine by reading runtime guards scattered across the codebase. The resource declaration and performs clauses are the state machine — declarative, complete, and compiler-verified. An agent that tries to skip from Proposed directly to InProgress gets a compile error. The compiler is the third party — verifying structure that neither the human nor the agent needs to hold in working memory.

What's Next

Operations declare what can happen and what comes back. Projections control what each operation can see. Typestate controls when each operation can run. But real procedures chain multiple operations together, and when step five fails, steps one through four may need to be undone.

The next article introduces sagas — multi-step workflows where each forward step is declared alongside its compensation. If beaming an away team down succeeds but establishing contact fails, the team needs to be beamed back up. The saga declaration reads like a mission briefing: each step, what undoes it, and what happens when the sequence breaks.

Ruuk is in alpha. If these ideas resonate, give ruuk a spin; follow along on GitHub and weigh in on the discussions.

Source: dev.to

arrow_back Back to News