Transition Planning

A state-transition write needs one fact the write executor cannot invent: the target state. Transition planning derives that fact — and the guard guarding it — directly from the declared StateMachine, so the target state is declared once (on the Transition) and never re-stated in a hand-written match action_name => new_status.

derive_transition_plan is pure: it reads a ServiceDef's state machine and one action, and returns a serializable TransitionPlan. It touches no database and runs no I/O.

derive_transition_plan

#![allow(unused)]
fn main() {
use ferro::{derive_transition_plan, TransitionPlan};

let plan: TransitionPlan = derive_transition_plan(&service_def, "submit")?;
// plan.action     == "submit"
// plan.event      == "submit"          (= ActionDef.transition_trigger)
// plan.to_state   == "submitted"       (= Transition.to — the single source of truth)
// plan.from_states == ["draft"]        (every Transition.from carrying this event)
// plan.guard      == None              (Transition.guard, if any)
}

The write target comes from one place: the Transition.to of the transition whose event matches the action's transition_trigger. A consumer write handler derives to_state from the plan rather than hard-coding it, which removes the duplicated transition map that previously drifted from the state machine.

TransitionPlan

#![allow(unused)]
fn main() {
pub struct TransitionPlan {
    pub action: String,          // ActionDef.name that produced this plan
    pub event: String,           // = ActionDef.transition_trigger; the StateMachine event name
    pub from_states: Vec<String>, // every Transition.from carrying this event (multi-source aware)
    pub to_state: String,        // Transition.to — the single target; replaces the hand-written match
    pub guard: Option<String>,   // Transition.guard, if any — re-checked live at execution
    pub effects: Vec<String>,    // Transition.actions ∪ ActionDef.effects
}
}

Multi-source events

An event can be triggered from more than one source state. A cancel event reachable from both draft and submitted produces a plan whose from_states carries both:

#![allow(unused)]
fn main() {
// transitions:
//   draft     --cancel--> cancelled
//   submitted --cancel--> cancelled
let plan = derive_transition_plan(&service_def, "cancel")?;
// plan.from_states == ["draft", "submitted"]
// plan.to_state    == "cancelled"
}

This is valid: every source converges on the same target, so the target is unambiguous.

Ambiguous fan-out is an error

If one event leads to different target states depending on the source, the target is not derivable from the event alone. This is reported as an error, never silently resolved:

#![allow(unused)]
fn main() {
// transitions:
//   draft     --close--> archived
//   submitted --close--> cancelled    // same event, different target
let result = derive_transition_plan(&service_def, "close");
// Err(Error::AmbiguousTransition { event: "close" })
}

An action that fans out to multiple targets must be split into distinct events.

Error modes

ErrorCondition
Error::ValidationNo action named action_name on the service
Error::NoTransitionTriggerThe action declares no transition_trigger
Error::NoStateMachineThe service has no state machine
Error::UndeclaredTransition { action, event }The trigger names an event no transition carries (the drift condition)
Error::AmbiguousTransition { event }The event fans out to more than one target state

Registration-time drift gate

The "declare once" guarantee is enforced at boot, not at runtime. ServiceDef::validate() runs the same derivation the runtime uses for every transition-triggering action. If any ActionDef.transition_trigger names a transition the state machine does not declare, validate() returns Err — so an executor that disagrees with the state machine fails at registration, before any request is served.

#![allow(unused)]
fn main() {
let result = service_def.validate();
// Err if any action.transition_trigger references an event the StateMachine
// does not declare, or if any such action derives an ambiguous transition.
}

Because validate() builds a TransitionPlan for every transition-triggering action, validate() accepting an action implies the derivation can build a plan for it. Drift between the two is structurally impossible: a transition_trigger MUST reference a declared transition.

Driving a write

A write handler combines transition planning with the write kernel. The handler derives the transition guard from the plan and hands it to dispatch_write; the executor derives to_state the same way, so the target state is never read from the request body:

#![allow(unused)]
fn main() {
use ferro::{derive_transition_plan, write::dispatch_write};

let plan = derive_transition_plan(svc, &action.name).ok();
let transition_guard = plan.as_ref().and_then(|p| p.guard.as_deref());

let outcome = dispatch_write(
    action,
    &inputs,
    tenant_id,         // from auth, never the body
    db,
    &dispatcher,
    transition_guard,  // derived from the StateMachine, not the request
    "web",
    #[cfg(feature = "confirmation")]
    false,
)
.await;
}

Reference

ItemDescription
derive_transition_plan(svc, action_name)Pure derivation of a TransitionPlan from the service's state machine
TransitionPlanThe derived transition facts: action, event, from_states, to_state, guard, effects
ServiceDef::validate()Returns Err if any transition_trigger names an undeclared or ambiguous transition (the drift gate)

See also

  • Write Kernel — executing the plan with guard re-eval, idempotency, audit, and override.
  • Service Projections — the ServiceDef, state machine, and actions the plan is derived from.