Cycle state machine for FlywheelRun (Flywheel V1.0 cockpit).
Single source of truth for cockpit mode (readonly ↔ editing),
promote-button gating, and rail-card vocabulary. Per V1 invariant
#3 — never derived from aggregator_outcome; the column is the
canonical signal. aggregator_outcome remains as the V0.6/V0.7
audit detail (per V1 invariant #7 — additive over renaming); V1.9
deprecates it at the Pydantic layer.
Allowed transitions (enforced in tests, not in DB):
DRAFT → AGGREGATING → PLAN_READY → APPLIED → SIM_RUNNING
→ (SIM_PASSED | SIM_FAILED) → PROMOTED → ROLLED_BACK
DRAFT | PLAN_READY | APPLIED | SIM_RUNNING
| SIM_PASSED | SIM_FAILED → DISMISSED
Lifecycle:
- DRAFT: row created at intent-confirm; no plan yet.
- AGGREGATING: aggregator running.
- PLAN_READY: FDE produced a PromptEditPlan; not applied.
- APPLIED: candidate VoiceAgentVersion written.
- SIM_RUNNING: simulator kicked off, awaiting webhook.
- SIM_PASSED / SIM_FAILED: webhook reported verdict.
- PROMOTED: candidate flipped to prod active_version_id.
- ROLLED_BACK: a prior promote was reverted in a later cycle.
- DISMISSED: operator closed the cycle without promoting.
Column-width note: the underlying VARCHAR is VARCHAR(32) (set
in the V1.0 migration via explicit ALTER COLUMN ... TYPE VARCHAR(32)), even though SQLAlchemy's Enum(native_enum=False)
auto-derives VARCHAR(11) from the longest member name
("aggregating"). Same drift pattern as AggregatorOutcome
after V0.7.1 (see migration d4a8b3c5e7f1). The next engineer
who runs alembic revision --autogenerate will see SQLAlchemy
propose narrowing back to 11 — the column comment on
FlywheelRun.status explains why the override is intentional.