Rust's Enum-Driven Deployment State Machine and the Typed Rollback Transition
Encoding the un-promotion path so a bad model cannot stay live.
§ IFrame
Today's Ops lesson named a discipline held by convention. A deployment is in some state: a candidate watching live traffic in shadow, a model carrying real traffic, a regressed model just pulled back. The operator promotes forward and, when a model goes bad, rolls back to a prior pinned version. The whole thing works as long as everyone remembers the rules: do not roll back when there is no prior version; do not promote a candidate that is already live; do not fire the reverse gate from a state that has nothing behind it.
Convention is the part that fails at 3 a.m. The rule that "you cannot roll back without a pinned prior version" is true, and a tired operator under incident pressure will violate it anyway, and the violation will be a runtime error in the worst possible window. The question this lesson asks is whether the rule can stop being a convention and start being a fact the compiler enforces.
Rust's answer is the enum. Not the C enum, a name for an integer, but the Rust enum: a sum type where each variant carries its own data, and where the compiler refuses to let you read a variant's data without first proving you are in that variant. An enum that models the deployment lifecycle makes the illegal transition not a bug you catch in review but a program that does not compile.
§ IILanguage Idiom
A Rust enum is a closed set of alternatives, and each alternative may carry data of its own shape. Programming Rust calls these variants, and the chapter on enums and patterns makes the load the enum carries plain: the variant and its data are inseparable, and the only way to reach the data is to match the variant. This is the property the deployment state machine needs.
Model the lifecycle as one enum with a handful of variants. A Candidate variant carries the candidate version and the shadow-eval window it is accumulating. A Live variant carries the serving version and the prior version it could fall back to. A RolledBack variant carries the version now live and the version that was pulled. Each state holds exactly the data that state needs and no more. A Live model knows its fallback because the type says so; a Candidate has no fallback field at all, because a candidate in shadow is not serving anything to fall back from.
The discipline that makes this work is exhaustive matching. When the operator writes a transition as a match over the enum, the compiler checks that every variant is handled. Add a new lifecycle state next quarter and every match that forgot it fails to compile, pointing at the exact place the new state was not considered. The convention "handle every case" becomes a thing the build will not skip.
Ownership carries the second half. Programming Rust's treatment of moves is the mechanism: when a transition consumes the old state by value and returns the new one, the old state is moved out and gone. The compiler will not let a stale Live value linger after the rollback that consumed it. There is no copy of the pre-rollback state for a confused code path to read. The move is the un-promotion made physical in the type system: the regressed Live is consumed, and what comes back is a RolledBack that cannot pretend the regression never happened.
§ IIICode Worked Example
Start with the pinned version. The Ops lesson insisted the live model is always referenced by an explicit version, never by "latest." In Rust that insistence is a newtype: a version is its own type, not a bare string, so a function that wants a model version cannot be handed an arbitrary label by accident.
#[derive(Clone, Debug, PartialEq)]
struct ModelVersion(String);
impl ModelVersion {
fn new(v: &str) -> Option<ModelVersion> {
if v.starts_with('v') && v[1..].chars().all(|c| c.is_ascii_digit()) {
Some(ModelVersion(v.to_string()))
} else {
None
}
}
}
The smart constructor rejects anything that is not a real version label, so model:latest cannot enter the system as a version at all. This is the newtype-plus-smart-constructor pattern from the 06-04 lesson, reused: a primitive wrapped so its invariant holds everywhere it travels.
Now the lifecycle. Each state is a variant, and each variant carries only the data that state can have.
enum Deployment {
Candidate {
version: ModelVersion,
shadow_eval_score: f64,
},
Live {
serving: ModelVersion,
fallback: ModelVersion,
},
RolledBack {
serving: ModelVersion,
pulled: ModelVersion,
},
}
The shape encodes the Ops discipline directly. A Candidate has a shadow score and no fallback, because it serves nothing. A Live model carries its fallback, so rollback always has a target. A RolledBack records both the version now live and the one pulled, which is the first line of the regression postmortem written into the type.
The forward transition consumes a candidate and a current live model and produces a new live state, with the displaced version becoming the new fallback. The reverse transition is the one that matters most.
impl Deployment {
fn rollback(self) -> Result<Deployment, Deployment> {
match self {
Deployment::Live { serving, fallback } => Ok(Deployment::RolledBack {
serving: fallback,
pulled: serving,
}),
other => Err(other),
}
}
}
Read what the compiler enforces here. rollback takes self by value, so the old state is moved in and consumed; there is no surviving copy of the regressed Live after the call. It can only produce a RolledBack from a Live, because only the Live variant carries a fallback to roll back to. A Candidate handed to rollback returns the Err arm unchanged, because rolling back something that was never live is not a real operation, and the type makes that obvious. The Ops rule "you cannot roll back without a pinned prior version" is now structural: a state with no fallback field cannot reach the Ok arm, full stop.
Draining state for a model being gracefully retired. The moment that variant joins the enum, this match stops compiling until Draining is handled. The compiler walks the operator to every transition that forgot the new state. No new lifecycle state slips through on the strength of someone remembering to update every code path.One more move, the eval-driven trigger. The Ops lesson fired rollback on a sustained regression signal, not a single blip. That patience is a guard on the caller, not the type, so the trigger reads the eval stream and only calls rollback once the signal holds.
fn should_roll_back(window: &[f64], floor: f64) -> bool {
!window.is_empty() && window.iter().all(|&score| score < floor)
}
The function returns true only when every score in the sustained window sits below the prior model's floor. One bad point with good neighbors does not trip it. The type machine guarantees the rollback is legal; this guard guarantees it is warranted.
§ IVConnection to Today's Ops Lesson
The Ops lesson named three capabilities: model pinning, the reverse gate, and the regression postmortem. Each maps to one feature of the Rust code above. Pinning is the ModelVersion newtype, which makes a version a thing you can name and refuses the moving latest pointer. The reverse gate is rollback, a transition that can only fire from Live and consumes the regressed state so it cannot be read again. The postmortem's first fact, what served and what was pulled, is the data the RolledBack variant is built to carry.
The deeper refraction is the word unrepresentable. The Ops lesson worried about a rollback fired from a state with no prior version, a 3 a.m. surprise. In the Rust model that surprise is not a guarded runtime case; it is a program that does not exist, because the only variant rollback can succeed from is the one that carries a fallback. The Ops discipline said "do not do this." The Rust type says "this cannot be written." That is the gap between a convention and a guarantee.
§ VPrior-Lesson Reach
The 2026-05-29 lesson modeled order lifecycles with the type-state pattern, where each state is a distinct type and illegal transitions are missing methods. This lesson uses the enum complement. Type-state spreads states across types and is unbeatable when the states have different capabilities and you want each handed around independently. The enum holds all states in one type and is the better tool when you need exhaustive matching across the whole lifecycle and a single value that can be any state. The deployment machine wants the second: one Deployment value, every state reachable, every transition checked for completeness.
The 2026-06-04 lesson built the newtype-and-smart-constructor pattern for capital-at-risk numbers. The ModelVersion here is that pattern applied to an identifier rather than a quantity. The invariant differs, a valid version label rather than a non-negative notional, but the shape is identical: wrap the primitive, validate at construction, and the rest of the program can trust the type.
The 2026-06-01 lesson built composable eval pipelines with trait objects and async streams. The should_roll_back guard consumes exactly the score stream that pipeline emits. The eval pipeline is the sensor; this lesson's enum is the actuator. Rust lets the sensor's output type and the actuator's input type be checked against each other at compile time, so a malformed eval signal cannot quietly drive a rollback.
§ VIClosing
A deployment lifecycle held by convention works until the night it does not. Rust offers to move part of the discipline from the operator's memory into the compiler's checks. The enum makes each lifecycle state a variant carrying only its own data; the move semantics consume the regressed state so no stale copy survives the rollback; the exhaustive match guarantees no new state ships unhandled; the newtype makes the pinned version a thing the type system can hold.
The discipline is not unique to Rust in principle. Any language with real sum types can approximate it. Rust's combination of mandatory exhaustive matching, move-by-default ownership, and non-transparent newtypes makes the deployment state machine the language's natural shape, and lets the compiler be the first reviewer of the rollback path nobody runs until it is urgent.
Examine well. Reflect on this. The rollback you never tested is the one the type system should have refused to let you write wrong.
Fajr 2026-06-15 — Dev lesson; first Rust × α crossing of cycle-2; enum-as-state-machine refraction of the Ops un-promotion path.