Generates type-safe Go state machines for Kubernetes operators from a YAML spec. Invalid state transitions become compile errors, and transition signatures force idempotency keys to be obtained before state changes.
The OCI Model Cache operator runs on a
sextant-generated state machine — its spec is
modelcache.sextant.yaml
and the generated package is drift-checked in CI, so the committed code can
never silently diverge from the spec.
flowchart LR
spec["modelcache.sextant.yaml<br/>(declarative spec)"]
gen["sextant generate"]
out["8 generated .go files<br/>states · transitions · calculator<br/>SSA · metrics · OTel"]
op["operator reconciler<br/>(handwritten)"]
drift["CI drift test<br/>regenerate + byte-compare"]
spec --> gen --> out --> op
drift -. "fails on any diff,<br/>either direction" .-> out
spec -.-> drift
Why
Operator reconcilers degrade into phase-string soup: if status.Phase == "Resolving" scattered across a thousand-line Reconcile, transitions that
forget to set fields, retries that duplicate external API calls. Sextant moves
those invariants into the type system:
state := calculator.Calculate(resource) // reconstructs typed state from Status
switch s := state.(type) {
case statemachine.ModelCachePending:
// s.Resolved(...) and s.CacheHit(...) exist.
// s.SyncComplete(...) does not — that transition isn't legal from
// Pending, so calling it is a compile error, not a 3am incident.
case statemachine.ModelCacheResolving:
...
}
Three ideas carry the design:
Sealed interfaces. Every state implements a private interface method, so the state set is closed — a
switchover states is exhaustive, and only generated transitions can produce new states.Idempotency by construction. Transitions that record external actions take the external system's ID as a parameter:
// 1. Call external API → get jobName // 2. s.JobCreated(jobName) → get new state // 3. Persist via SSAPatch → if this fails, next reconcile retries safely func (s ModelCacheResolving) JobCreated(syncjobname string) ModelCacheSyncingYou cannot transition first and call the API second — the signature demands the evidence up front.
Level-triggered calculation. State is recomputed from
Statusevery reconcile by a pure calculator (no network, no side effects), so the machine recovers from crashes, conflicts, and stale caches the same way Kubernetes itself does: by looking at what is, not what happened.
Usage
# Validate a spec (add --xstate to emit XState JSON for visualization)
sextant validate myresource.sextant.yaml
# Generate
sextant generate myresource.sextant.yaml \
-o ./internal/statemachine --package statemachine \
--module github.com/you/operator --api github.com/you/operator/api/v1alpha1
In this repo the generated package carries a go:generate directive, so
regeneration is:
go generate ./projects/operators/oci-model-cache/internal/statemachine
The spec
apiVersion: sextant.jomcgi.dev/v1alpha1
kind: StateMachine
metadata:
name: ModelCache
group: oci-model-cache.jomcgi.dev
version: v1alpha1
fieldGroups: # reusable field bundles embedded into states
resolveResult:
resolvedRef: string
digest: string? # "?" = optional at group level; see `requires`
...
states:
- name: Resolving
requeue: 10s
fieldGroups: [resolveResult]
- name: Ready
requeue: 6h
fieldGroups: [resolveResult]
requires: [digest] # optional in the group, mandatory in THIS state
- name: Failed
error: true
requeue: 5m
fieldGroups: [errorInfo]
transitions:
- from: Resolving
to: Syncing
action: JobCreated
params:
- syncJobName: string # idempotency key, forced into the signature
- from: Failed
to: Pending
action: Retry
guard: retryable # guarded transitions return *State (nil = refused)
guards:
retryable:
condition: "!s.Permanent"
The full ModelCache machine renders like this — and this exact diagram is
also emitted by the generator into the generated Go (embedDiagram: true),
so the docs in the code can't go stale either:
stateDiagram-v2
[*] --> Pending
Pending --> Resolving: Resolved
Pending --> Ready: CacheHit
Resolving --> Syncing: JobCreated
Syncing --> Ready: SyncComplete
Pending --> Failed: MarkFailed
Resolving --> Failed: MarkFailed
Syncing --> Failed: MarkFailed
Failed --> Pending: Retry [retryable]
Ready --> Pending: Resync
Semantics worth knowing:
- Field-group carry-forward — when source and target states share a field
group and the transition's params don't populate it, the generated code
copies it from the source state (
ResolveResult: s.ResolveResult), so data survives intermediate hops without hand-written plumbing. - Retry policy — with an
errorHandlingblock you get retry-count exponential backoff with jitter. Without one, retryability is purely guard-driven andRetryBackoff()follows the state'srequeueinterval. - Deletion — states marked
deletion: truegenerate a graceful-degradation deletion path. With none, deletion passes through to normal calculation and Kubernetes GC handles removal. - An Unknown error state is always generated: unrecognized or corrupted
Statusreconstructs toUnknown(withReset()for recovery) instead of misbehaving as a guessed state.
Generated output
One spec produces eight files (per machine M, in m_*.go): phase constants,
state types + sealed interface, the level-triggered calculator, transition
methods, a visitor for exhaustive handling, OTel observability hooks,
Prometheus metrics, and Server-Side Apply status helpers (SSAPatch clears
managedFields — the API server rejects SSA patches that include them).
Output is deterministic (sorted fields, stable ordering), which is what makes the CI drift test possible: it regenerates from the spec and fails on any byte difference, in either direction. Generated code never gets hand-fixed — the spec or the templates do.
Layout
cmd/sextant/ # CLI (generate, validate, --xstate export)
pkg/schema/ # YAML DSL parsing + validation
pkg/codegen/ # text/template-based generator (templates/*.tmpl)
pkg/xstate/ # XState JSON interchange for diagram tooling