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:
createdEntryis 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 : whenFalseexpression ormatch
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 checkand editor diagnostics never commit provider mutationsrank src/main.rankcommits only whenpub mainreturns top-levelMutation::commit(...)rank servecan commit when a handler returnsMutation::commit(...)as the top-levelHTTP::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
puboutputs or in ordinary external-input reads such asEnv,File::Read, orHTTP::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
Related pages
- See Standard library for the raw
std::Mutationsurface 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.tomlsetup.