Checked State Machines
A MACHINE defines pure functions over explicit instance and event data. Use it for payment lifecycles, approval policies, connection protocols and other finite sets of guarded transitions.…
Checked state machines
A MACHINE defines pure functions over explicit instance and event data. Use it
for payment lifecycles, approval policies, connection protocols and other finite
sets of guarded transitions. Calling STEP proposes a next instance; ordinary
formulas, reducers and rules decide what to retain.
CHOICE OrderEvent = Pay | Ship
order = MACHINE
STATE pending, paid, shipped
EVENT OrderEvent
INITIAL pending
FINAL shipped
MATCH UNIQUE
UNHANDLED ERROR
TRANSITION payment: pending ON OrderEvent.Pay() -> paid
TRANSITION dispatch: paid ON OrderEvent.Ship() -> shipped
END
initial = order.INITIAL()
attempt = order.STEP(initial, OrderEvent.Pay())
report = order.REPORT(initial, OrderEvent.Pay())
MACHINE order ... END is the equivalent declaration spelling. The definition
binds INITIAL, STEP, REPORT, CHECK, RESTORE, REPLAY and TRACE. It creates no instance, actor,
worker, timer or event subscription. These members use the existing compiled
execution path and can be imported, aliased, or passed as ordinary callables.
Local declarations also work in DO blocks, including named function bodies.
Context, events and transitions
EVENT names an algebraic CHOICE; nullary constructors use parentheses in
patterns and values. Payload fields use ordinary types. Concrete generic CHOICE
applications use the existing nominal type system. CONTEXT declares a closed
record of fields; omitting it gives an empty context and a zero-argument INITIAL.
EMITS optionally names the CHOICE family of emitted values.
CHOICE PaymentEvent = Pay(amount: number) | Cancel(reason: string)
CHOICE PaymentNotice = Recorded(amount: number) | Cancelled(reason: string)
payment = MACHINE
STATE pending, paid, cancelled
EVENT PaymentEvent
CONTEXT {total: number, received: number}
EMITS PaymentNotice
INITIAL pending
FINAL paid, cancelled
MATCH UNIQUE
UNHANDLED ERROR
INVARIANT positive_total: context.total > 0
INVARIANT valid_received: context.received >= 0 AND context.received <= context.total
TRANSITION partial: pending ON PaymentEvent.Pay(amount)
WHEN amount > 0 AND context.received + amount < context.total -> pending
WITH {received: context.received + amount}
EMIT PaymentNotice.Recorded(amount)
TRANSITION settle: pending ON PaymentEvent.Pay(amount)
WHEN amount > 0 AND context.received + amount = context.total -> paid
WITH {received: context.received + amount}
EMIT PaymentNotice.Recorded(amount)
TRANSITION cancel: pending ON PaymentEvent.Cancel(reason) -> cancelled
EMIT PaymentNotice.Cancelled(reason)
END
initial = payment.INITIAL({total: 120, received: 0})
attempt = payment.STEP(initial, PaymentEvent.Pay(40))
Declarations precede transitions. State names and transition/invariant labels
must be unique within their respective namespaces, ignoring case. Every source,
target, INITIAL and FINAL name must exist. A transition cannot leave a FINAL
state. Structurally unreachable states produce an advisory diagnostic; guards
are not treated as proof of reachability. * as a source matches every non-final
state; _ as the event pattern
matches every admitted event. Constructor payload patterns bind names or discard
fields with _.
Guards and patches see context, state, event, and their pattern bindings.
Emission expressions additionally see next_context and next_state. Invariants
see only context and state. Pattern bindings cannot shadow these contextual
names. Varying data belongs in context or the event. Referenced constants and
helpers must be immutable, deterministic, pure and acyclic; a local declaration
cannot capture changing enclosing function arguments into its definition.
WITH is a literal field patch. Every right-hand side reads the old context,
so {a: context.b, b: context.a} exchanges two values atomically. Unmentioned
fields retain their values. Arbitrary nested expressions, including DO, use
their ordinary expression grammar.
Evaluation order and failure
Every STEP follows this order:
- Validate the instance identity, state, complete context and event payload.
- Check incoming invariants.
- Evaluate eligible guards in authored order. FIRST stops at its first true guard. UNIQUE evaluates all eligible guards, then rejects multiple matches.
- Evaluate the selected patch against the old context and validate its fields.
- Check invariants against the proposed state and context.
- Evaluate emissions in clause order, then return the complete Step.
A guard or invariant must return a boolean. An absent guard is true. A false guard is a non-match; an error is a rejection. No update or emission runs before UNIQUE ambiguity has been resolved. A failed update, invariant or emission returns no proposed next instance and no successful emission prefix.
UNHANDLED ERROR rejects an event with no matching transition.
UNHANDLED IGNORE returns the unchanged instance and an empty emitted List.
This policy also applies in final states. Invalid input and failed incoming
invariants are rejected under both policies. A successful self-loop is a
transition, even when the context is unchanged.
EMIT value contributes one correctly typed value. EMIT EACH values requires
a native List, for example VECTOR(Notice.Recorded(1), Notice.Recorded(2)).
Arrays, wrong families, invalid payloads and oversized lists are refused.
Emissions are data; delivery remains an explicit operation of their consumer.
Values and reports
| Operation | Value |
|---|---|
m.INITIAL(context) |
Validated {definition, state, context} or a Grid error |
m.INITIAL() without CONTEXT |
The same instance shape with an empty context |
m.STEP(instance, event) |
Native Result<Step, MachineError> |
m.REPORT(instance, event) |
A report containing the same Result and its trace |
m.CHECK(value) |
Result<Instance, MachineError> without taking an event |
m.RESTORE(state, context) |
Checked reconstruction for the current definition |
m.REPLAY(instance, events, policy) |
Result<Replay, MachineError> with progress and ordered emissions |
m.TRACE(instance, events, policy) |
The same replay result, also retaining step reports |
Known argument shapes and types are checked during compilation, including extra context fields. Dynamic inputs are checked again when the operation runs. These checks remain active in both loose and strict models.
A Step contains next, status and a native List emitted. Status is
:transitioned or :ignored. A MachineError contains text code and message,
a symbol phase, Option-valued transition and invariant labels, and a bounded
Option-valued cause retaining an underlying error's code and message.
REPORT has schema grid.machine.report.v1, definition, symbol status,
outcome, evaluated_transitions, matched_transitions, Option-valued
selected_transition, and boolean matching_complete. It performs one step
attempt. The evaluated list contains only structurally eligible guards actually
visited. A guard error leaves matching incomplete and no transition selected.
STEP does not construct these report lists.
Use ordinary native-result matching to choose a rejection policy:
next = MATCH(attempt,
OK(step) -> step.next,
ERR(problem) -> initial
)
Checking, restoring and migrating an instance
m.CHECK(value) returns OK(instance) or ERR(MachineError) without taking an
event. It checks bounded data, exact instance shape, definition identity, declared
state, complete context types and incoming invariants. Its input accepts unknown
data, so it is also useful at a deserialization boundary. Empty replay applies
the same checks.
m.RESTORE(state, context) constructs an instance for this definition and runs
the same validation. Without a CONTEXT declaration, use m.RESTORE(state).
Declared final and structurally unreachable states can be restored. Unknown
states and invalid context are refused.
Restoration makes migration explicit:
admitted = current.CHECK(saved)
migrated = current.RESTORE(saved.state, {
total: saved.context.total,
received: saved.context.paid
})
CHECK never accepts an old definition identity automatically. RESTORE deliberately adopts the current definition after the caller chooses the new state and context. Neither operation loads storage, commits state or emits notices.
Bounded replay and trace
Use m.REPLAY(instance, events, policy) to apply a native List of events, and
m.TRACE(instance, events, policy) when the individual reports are useful. Both
return a native Result and require an explicit :stop or :continue policy.
attempts = VECTOR(PaymentEvent.Pay(40), PaymentEvent.Pay(100), PaymentEvent.Pay(80))
replay = payment.REPLAY(initial, attempts, :continue)
trace = payment.TRACE(initial, attempts, :stop)
A successful replay result contains:
| Field | Meaning |
|---|---|
next |
Last successfully accepted instance |
status |
:stopped when :stop encounters a rejection, otherwise :complete |
processed |
Number of attempts, including rejected events |
remaining |
Number of events left unattempted |
rejected |
Number of rejected attempts |
emitted |
Accepted emissions in event order, then clause order |
failures |
Ordered {index, error} records; indexes start at one |
reports |
TRACE only: one REPORT for each attempted event |
With :stop, a semantic rejection returns OK with status: :stopped, the
accepted prefix and the failing attempt, even when that was the last event.
With :continue, the instance remains unchanged for that attempt and subsequent
events are processed. An ignored event
counts as a successful attempt. A completed replay can therefore have failures;
inspect rejected or failures when success of every event is required.
Invalid starting instances, invalid policies, malformed sequence data and budget
exhaustion return top-level ERR. They never return a truncated prefix as a
successful result. Event type errors follow the chosen rejection policy. Replay
is pure: returning its final instance does not commit it or deliver emissions.
Ordinary REDUCE and SCAN remain available for custom policies.
Replay accepts at most 4,096 events. Each STEP retains its existing limits. The complete retained prefix must fit the normal 1 MiB data budget, depth/work bounds and a 65,536-element bound on each accumulated List. TRACE retains reports as well as emissions, so it can exhaust the data budget earlier. The stopped suffix does not execute STEP or REPORT; bounded input validation still inspects the supplied sequence.
Conservative predicate diagnostics
Admission warns about proven FIRST shadowing, witnessed UNIQUE overlaps and uncovered non-final state/event cases. Incoming invariants constrain witnesses; wildcard and constructor-specific transitions retain their authored priority. Warnings do not change execution or make partial machines invalid.
Analysis shares the bounded predicate machinery used by Decisions. It recognizes boolean predicates, finite numeric comparisons and supported boolean composition. Equivalent supported predicates can also prove FIRST shadowing; payload-binding renames do not hide that equivalence. Unused context and payload fields do not expand the case space. Unsupported helper calls, complex data and numeric representations stay unknown. Unknown predicates participate conservatively in shadow proofs and cannot establish an overlap or coverage witness. Uninterpreted primitive fields can supply a type witness when the guard and invariants are proven independently of their values. Analysis never evaluates user code. It considers at most 4,096 cases per state/event combination and one million work units per definition, retains at most 16 notices, and reports analysis exhaustion. Absence of warnings is not a proof of total coverage or reachability.
State ownership and replacement
For local reactive ownership, a single STATE record can retain an instance and its latest report. This rule keeps the instance after rejection:
input incoming = PaymentEvent.Pay(40)
STATE current = {
instance: payment.INITIAL({total: 120, received: 0}),
last: NONE()
}
WHEN incoming THEN
current = DO
report = payment.REPORT(current.instance, incoming)
next = MATCH(report.outcome,
OK(step) -> step.next,
ERR(problem) -> current.instance
)
{instance: next, last: SOME(report)}
END
END
The ordinary rule owns the commit. Its latest report is not a durable event log.
Finite replay uses REPLAY or TRACE with an explicit policy. Ordinary REDUCE
and SCAN support custom accumulation and retention policies.
Instances carry a semantic definition identity computed during compilation.
Formatting, comments and unrelated model edits do not change it; state/type
contracts, policies, transition order and referenced helper semantics do.
A different definition rejects an old instance with
MACHINE_DEFINITION_MISMATCH. Reset or migrate explicitly. Equal identity never
bypasses validation of restored data.
Hierarchy, parallel regions, implicit internal-event queues, timed durable instances and delivery guarantees are separate extensions. Ordinary workflow, transaction and scheduler owners remain responsible for those operations.
Bounds and implementation
A definition admits at most 256 states, 256 event constructors, 256 context
fields, 256 invariants and 4,096 transitions. A transition admits at most 64
emission clauses; a successful step emits at most 1,024 elements. Selected data
admission is bounded to 1 MiB, depth 64 and one million inspected nodes, with at
most 4,096 compiled type nodes. Compilation also bounds candidate expansion to
262,144 entries and generated expressions to 262,144 nodes / 64 MiB. The complete
Step and report each fit the data byte bound; oversized reports return
MACHINE_REPORT_BUDGET. Existing expression and operation limits also
apply. Exceeding a bound fails closed and never truncates a successful List.
The compiler builds state/event candidate lists and generates ordinary compiled callables, lexical bindings, records and Results. Machines and decisions share the ordered guard selector. Closed data contracts are static metadata operands; validation does not deserialize a definition, hash it at each step, or create a runtime registry. Type, invariant and identity checks are part of the selected operation's cost. Legacy CHOICE interfaces retain the ordinary compiler's gradual static representation; their complete event and payload contracts are still checked at the machine boundary. Concrete nominal CHOICE applications retain their static family types.
Canonical examples: payment, approval, and connection protocol.