Crucible State Machine — JSON, Mermaid & DOT #5
joshua-temple
started this conversation in
State Machine
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
The JSON IR is canonical, not an export. It is a co-equal, first-class authoring format and the interchange format — the same data the
ForgeDSL produces and a future UI emits. A machine authored in Go and a machine loaded from JSON (then bound viaProvide) are the same machine. This is the back half of the config/implementation split: the IR carries structure + named references with params; behavior lives in a host registry and is bound at load time.Crucible serializes three shapes and renders two diagram formats:
ToJSON/LoadFromJSON+Provide).Firecalls.ToMermaid) and DOT (ToDOT) — visualizations derived from the IR.Schemas are versioned (
"schemaVersion": 1). Reserved drop-in fields from the parity matrix appear as empty arrays /false/"none"so future tooling can detect future additions without a schema bump.Machine IR JSON
The canonical definition, with every state keyed by name (O(1) lookup, uniqueness enforced by the JSON object, stable diffs). Guards and effects are named references with params — never serialized closures:
{ "schemaVersion": 1, "id": "document", "initial": "Draft", "states": { "Draft": { "ownedBy": "Author", "isFinal": false, "onEntry": [], "onExit": [], "onDone": [], "transitions": [ { "on": "Submit", "to": "Submitted", "guards": [], "effects": [ {"ref": "emit", "params": {"event": "submitted"}} ], "waitMode": "SyncReply", "internal": false, "eventLess": false, "after": null, "srcFile": "document/machine.go", "srcLine": 84 } ], "policies": [], "historyType": "none", "invoke": [], "regions": [], "parent": null, "children": [] }, "Approved": { "ownedBy": "Reviewer", "transitions": [ { "on": "Publish", "to": "Published", "guards": [{"ref": "hasReviewer", "params": {}}], "effects": [{"ref": "emit", "params": {"event": "published"}}], "waitMode": "SyncReply" } ] } }, "metadata": { "stateType": "DocState", "eventType": "DocEvent", "contextType": "*Document", "kernelVersion": "v0.1.0" } }Notable schema decisions:
{"ref": "name", "params": {...}}.paramsis serializable JSON and is what makes one engineer-authored guard reusable across transitions — and what a UI palette presents. The host registry bindsrefto func at load time. This is the line that makes the IR lossless: structure is data, behavior is referenced.onEntry/onExit/onDone(named-ref arrays),isFinal,internal,eventLess, andafter(a declarative{"delay": "30s", "to": "Expired"}object,nullwhen absent — the kernel emits aScheduleAftereffect; the host owns the timer).waitModeis a 3-value string enum:"SyncReply"|"FireAndForget"|"ValidatePoll". It is metadata the kernel stores and the consumer acts on:SyncReplyawaits a reply,FireAndForgetemits and moves on, andValidatePoll(for non-request-reply effects) has the consumer poll the entity — re-runningAssayagainst the expected state — until it validates or times out."none":historyType("none"),invoke([]). They are inert in v1 but already in the schema, so landing them later is additive — no schema bump.parent, each parent carries itschildren. The writer computes both; round-trip validates they agree.srcFile/srcLineare captured from the builder for "defined at machine.go:84" tooltips, and can be stripped viaToJSON(state.WithoutSrcPos())for stable goldens. (UI-authored IR simply omits them.)Lossless round-trip — the v1-core identity
Round-trip is a v1-core guarantee, not a convenience:
Forge -> ToJSON -> LoadFromJSON -> Providereconstructs a machine that is identical to the original on structure and behavior bindings. The structure travels in the JSON; the behavior is re-bound by name from the same host registry.The registry is the bridge between data (names) and code (functions). The DSL registers names via
.Guard(...)/.Action(...);Provideresolves the same names from aRegistry. (Optional code generation can emit the registry skeleton + state/event name maps for you.)Scenario JSON
A scenario describes what to do — fire this sequence against this machine from this starting state — plus declarative assertions:
{ "schemaVersion": 1, "machineId": "document", "initialState": "Draft", "initialEntity": {"_goType": "*Document", "data": {"id": "doc-1", "reviewerId": null}}, "events": [ {"event": "Submit", "context": {}}, {"event": "Approve", "context": {"reviewerId": "rev-1"}}, {"event": "Publish", "context": {}} ], "assertions": [ {"type": "FinalState", "expected": "Published"}, {"type": "EffectsEmitted", "expected": ["Submitted", "Approved", "Published"]}, {"type": "TraceLength", "expected": 3}, {"type": "NoErrors", "expected": true} ] }The v1 assertion set —
FinalState,EffectsEmitted(order-insensitive),TraceLength,NoErrors— covers seed-pattern tests and authored scenarios. Assertions are descriptions, not predicates:RunAgainstfires every event, builds aScenarioResultwith each assertion marked pass/fail, and lets the caller decide whether a failure is fatal.Trace JSON
What happened during one
Fire(or a history of them):{ "timestamp": "2026-05-27T12:34:56.789Z", "duration": "12.345ms", "fromState": "Draft", "event": "Submit", "toState": "Submitted", "guardEvals": [], "selectedTransition": {"from": "Draft", "on": "Submit", "to": "Submitted"}, "effectsEmitted": [{"ref": "emit", "params": {"event": "submitted"}, "goType": "*docevents.Submitted", "err": null}], "outcome": "Success", "err": null, "microsteps": [] }refname +params(plus the resolved Go type), mirroring the IR — so a trace is replayable against the same registry.durationis a Go-style duration string (human-readable in diffs, lossless round-trip).errisnullon success or a structured object whosetypenames the typed-error variant — tooling renders typed errors without re-implementingerrors.As.outcomeis a string enum matching the kernel'sOutcomenames.microstepscarries the run-to-completion / eventless steps and per-region completions (v1); empty for a single macrostep.Merged batch trace (FireSeq / FireEach)
A batch (
FireSeq/FireEach, see the Kernel Core discussion) returns aBatchResultwhoseTraceis a single merged, ordered Trace spanning every step — not an array of separate traces. It is the same Trace shape as above, with each step's macrostep folded into the orderedmicrostepslist (one microstep perFirein the batch, in execution order), and the top-levelfromState/toStatespanning the whole run (first state in, last state out). Because it is just a Trace, it serializes, replays, and renders exactly like a single-Firetrace — a batch reads as one coherent timeline, and the per-step results stay individually available inBatchResult.Steps.The Trace is the unifying primitive. It renders past state, and it is also the substrate a scenario builder edits to author new runs — the round-trip the Phase 2 Visualizer is built on.
Mermaid
ToMermaid()emits GitHub-renderablestateDiagram-v2:stateDiagram-v2 [*] --> Draft Draft --> Submitted: Submit Submitted --> Approved: Approve [RequireReviewer] Submitted --> Draft: RequestChanges Approved --> Published: Publish Draft --> Archived: Archive Published --> [*] Archived --> [*]Event [Guard1, Guard2]).state X { ... }block; orthogonal regions use the--divider with prefixed member names to avoid collisions.classDefcolor-coding (statediagram-v2 has no true swim lanes; a richer UI draws those).(From, On, To)), so repeated calls are byte-identical.DOT
ToDOT()emits GraphViz for richer SVG rendering — slides, docs sites, and large machines where Mermaid becomes unreadable (a 30+ state HSM). Superstates becomesubgraph cluster_X; owners encode as nodefillcolor;rankdir=LRreads well for lifecycles.Both renderers are pure functions of the machine graph.
Static analyzer & codegen
Machine.Analyze() []Diagnostic, plus a standalone binary) walks the in-memory graph and reports unreachable states, undeclared transition targets, guardless ambiguity, dead effects, owner mismatches, unresolved refs, and missing-current-state-fn. It re-runs theQuenchlints so JSON-loaded machines get the same checks. (This is whatTemper()runs.)Provideneeds, and committed Mermaid diagrams underdocs/. No machine is required to use it.What this enables
A committed
machine.jsonregenerated viago generategives reviewers a machine-readable, human-readable diff of structural graph changes (see the Evolution Guide discussion). Because the IR is canonical and behavior is named-ref + params, a future visual editor authors machines by emitting this same IR against the host's registry palette — no kernel change, no second source of truth (see the Phase 2 Visualizer discussion).Crucible State Machine series: Overview & Roadmap · Kernel Core · HSM · Path Planning · JSON / Mermaid / DOT · Evolution Guide · Conformance · Phase 2
Beta Was this translation helpful? Give feedback.
All reactions