Skip to content

The IR and the config/implementation split

Two authoring front-ends emit one shared IR.

The canonical form of a Crucible machine is not Go code. It is an IR: a pure data structure describing states and transitions. The Forge DSL is one front-end that emits this IR. A future visual editor would be another. Both produce the same artifact.

The IR is configuration: it describes structure and references behavior by name and params through a Ref; it never embeds an executable function. A transition might say “guard generousOrder” or “assign applyDiscount with {percent: 10}”, but the function itself lives elsewhere.

That elsewhere is the registry. The registry binds each name to a real Go implementation:

reg := state.NewRegistry[Order]().
Guard("generousOrder", func(g state.GuardCtx[Order]) bool {
return g.Entity.Subtotal >= 5000
}).
Assign("applyDiscount", func(a state.AssignCtx[Order]) Order {
a.Entity.Discount = a.Params["percent"].(int)
return a.Entity
})

Provide binds a loaded IR to a registry, producing a builder you can Quench:

ir, _ := state.LoadFromJSON[Status, Event, Order](data)
m := ir.Provide(reg).Quench()

When you author with the DSL directly, Forge carries its own registry: methods like .Guard, .Action, and .Reducer register behavior into the same palette the refs resolve against.

  • Lossless round-trip. ToJSON and LoadFromJSON serialize and rebuild the IR without loss, so a machine can be stored, versioned, diffed, and shipped as data independent of the binary that runs it.
  • Dual authoring. Code today, a visual editor tomorrow. The registry stays the single home for implementation, so the editor never needs to generate Go.
  • Inspectable structure. Static analysis, diagram rendering, and verification all operate on the IR alone, never on opaque closures.