Skip to main content

Mutations

std::Mutation is the language-level boundary for provider-backed writes. The important rule is that Rank does not execute a provider mutation at the moment you call the effect binding. Instead, mutation work is split into fulfillment and commit so the program can stay deterministic during ordinary evaluation and only perform external I/O when a host explicitly realizes a top-level Mutation::commit(...) descriptor.

Why plan and commit are separate

Rank keeps provider mutations explicit for three reasons:

  • fulfilled mutation values can feed later mutation payloads before anything is committed
  • hosts must admit provider capabilities and exact mutation export names before any external write runs
  • commit results need one projection point so the final output can include success, rejection, unknown outcomes, and receipt metadata in an ordinary document shape

That is why Mutation::Effect<T>, Mutation::plan(...), and Mutation::commit(...) are separate surfaces instead of one eager mutate(...) function.

Lifecycle

1. Declare an effect binding

Mutation::Effect<T> marks a required external write whose fulfillment payload has type T. The annotated binding must be a direct call to a provider mutation export.

use std::Mutation

Entry = Object {
id: string,
label: string,
}

writeEntry: Mutation::Effect<Entry> = my_provider::actions::write<Entry> {
table: `entries`,
}

Values like table above are declaration-time configuration. The actual payload for the write is provided later when the effect binding is called.

2. Fulfill effects during ordinary evaluation

Calling an effect binding fulfills one required write. That call still does not commit provider I/O. It produces a commit-local value that may feed later mutation inputs:

use std::Mutation

Entry = Object {
id: string,
label: string,
}

TaggedEntry = Object {
id: string,
tag: string,
label: string,
}

writeEntry: Mutation::Effect<Entry> = my_provider::actions::write<Entry> {
table: `entries`,
}

writeTaggedEntry: Mutation::Effect<TaggedEntry> = my_provider::actions::write<TaggedEntry> {
table: `entry-tags`,
}

createdEntry = writeEntry({
id: `1`,
label: `alpha`,
})

taggedEntry = writeTaggedEntry({
id: createdEntry.id,
tag: `fresh`,
label: `entry-${createdEntry.label}`,
})

The key distinction is:

  • createdEntry is the fulfilled payload value, not provider receipt metadata
  • that value is commit-local, so it can feed later mutation inputs
  • it must not escape into ordinary public output or drive control flow such as the conditional condition ? whenTrue : whenFalse expression or match

3. Build a plan

Mutation::plan(...) collects required operations into one opaque plan descriptor:

use std::Mutation

Entry = Object {
id: string,
active: bool,
}

writeUser: Mutation::Effect<Entry> = my_provider::actions::write<Entry> {
table: `users`,
}

writeAudit: Mutation::Effect<Entry> = my_provider::actions::write<Entry> {
table: `audit`,
}

plan = Mutation::plan({
createdUser: writeUser({ id: `1`, active: true }),
auditUser: writeAudit({ id: `2`, active: false }),
})

Each field label becomes one typed report.operations.<label> entry inside the projector passed to Mutation::commit(...).

If a plan is never consumed by Mutation::commit(...), no side effect runs.

4. Commit the plan and project the outcomes

Mutation::commit(...) turns a plan into std::Emit<T>. Its projector receives a Mutation::CommitReport<Ops> with aggregate status plus one typed per-operation result:

use std::Mutation

Entry = Object {
id: string,
active: bool,
}

writeUser: Mutation::Effect<Entry> = my_provider::actions::write<Entry> {
table: `users`,
}

plan = Mutation::plan({
createdUser: writeUser({ id: `1`, active: true })
})

pub main = ||
Mutation::commit(
plan,
|report| {
status: report.status,
createdUser: report.operations.createdUser,
},
)

report.status is the aggregate commit status for the whole plan. report.operations.createdUser is a Mutation::Result<Value, Receipt> for that specific operation label.

The projector must return one ordinary emit-compatible document value. Returning another std::Emit<T> from inside the projector is rejected for the same reason nested Emit::manifest(...) descriptors are rejected elsewhere.

When does the side effect actually happen?

This is the practical rule to remember:

  • declaring Mutation::Effect<T> is static
  • calling an effect binding fulfills a required write, but still does not execute provider I/O
  • Mutation::plan(...) is pure and opaque
  • the actual external write happens only when a host realizes a top-level Mutation::commit(...) descriptor

In practice that means:

  • rank check and editor diagnostics never commit provider mutations
  • rank src/main.rank commits only when pub main returns top-level Mutation::commit(...)
  • rank serve can commit when a handler returns Mutation::commit(...) as the top-level HTTP::Response.body

The explicit commit boundary exists so Rank can keep ordinary evaluation deterministic while still making side effects visible, typed, and host-admitted.

Result shapes

Mutation::Result<Value, Receipt> is the per-operation discriminated union:

  • Mutation::Succeeded<Value, Receipt> = Object { status: succeeded, value: Value, receipt: Receipt }
  • Mutation::Rejected = Object { status: rejected, error: Object { code: string, message: string }, notes?: [string] }
  • Mutation::Unknown = Object { status: unknown, message: string, notes?: [string] }
  • Mutation::NotStarted = Object { status: not-started }

Mutation::CommitReport<Ops> is the aggregate object passed to the projector:

  • Mutation::CommitReport<Ops> = Object { status: succeeded|rejected|unknown, operations: Ops }

Mutation::Receipt<R> names the provider receipt metadata type retained on successful outcomes. Effect calls do not return Mutation::Receipt<R> directly during ordinary compute.

not-started appears on individual operations when an earlier dependency prevented that write from running. The aggregate report.status collapses the overall commit to succeeded, rejected, or unknown.

Rules and constraints

  • effect bindings must be fulfilled exactly once on every reachable code path
  • fulfilled values may be read, transformed, and assembled into later mutation payloads
  • commit-local fulfilled values must not appear in ordinary pub outputs or in ordinary external-input reads such as Env, File::Read, or HTTP::Fetch
  • commit-local fulfilled values must not drive conditional expressions, match, or any other construct that changes program topology
  • fulfilling an effect is always scalar; automatic lifting does not fan one effect out across lists or objects
  • See Standard library for the raw std::Mutation surface reference.
  • See Output and manifests for how std::Emit<T> descriptors behave at the top level.
  • See AWS provider for a provider-specific end-to-end example with host admission and rank.toml setup.