Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 33 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,14 @@ stability label.

| Module | What it is | Status |
| --------------------- | -------------------------------------------------------------------------------- | ------------ |
| `state` | Full-featured, domain-agnostic statechart engine. Stdlib-only, no IO. | experimental |
| `state/analysis` | Static model-checking and path enumeration over a machine's IR. | experimental |
| `state/evolution` | Diffs two machine definitions and classifies the SemVer bump. | experimental |
| `state/conformance` | Reusable harness for driving golden scenarios against a machine. | experimental |
| `state/expr` | Rich expression tier: CEL-backed guards type-checked against the context schema. | experimental |
| `state` | Full-featured, domain-agnostic statechart engine. Stdlib-only, no IO. | v1.0.0 |
| `state/analysis` | Static model-checking and path enumeration over a machine's IR. | advisory |
| `state/evolution` | Diffs two machine definitions and classifies the SemVer bump. | advisory |
| `state/conformance` | Reusable harness for driving golden scenarios against a machine. | advisory |
| `state/verify` | Decides behavioral properties of a machine and returns a witness event sequence. | advisory |
| `state/expr` | Rich expression tier: CEL-backed guards type-checked against the context schema. | stable contract (v0.1.0) |
| `gen` | Eject codegen: turn a machine's IR into typed Go stub source and a registry wiring. | v0.1.0 |
| `cmd/crucible` | Headless IR CLI: lint, render, diff, validate, and eject a machine's serialized IR. | v0.1.0 |
| `telemetry` | Vendor-neutral tracing/metrics interface for the IO modules. Stdlib-only. | experimental |
| `telemetry/slog` | `log/slog` adapter for the telemetry interface. | experimental |
| `telemetry/otel` | OpenTelemetry adapter for the telemetry interface. | experimental |
Expand All @@ -127,16 +130,27 @@ in-memory `source/memsource` test source, each experimental.

## Status

Early and evolving. `state` is a complete, embeddable statechart engine, covering
hierarchical, parallel, and final states, history, guard combinators, delayed
transitions, invoked services, an actor model, snapshots, and JSON
(de)serialization, backed by its `analysis`, `evolution`, and `conformance`
packages. `telemetry`, `sink`, and `source` (with all their adapters, codecs, and
`state` is released at **v1.0.0**: a complete, embeddable statechart engine
covering hierarchical, parallel, and final states, history, guard combinators,
delayed transitions, invoked services, an actor model, snapshots, and JSON
(de)serialization. Its public contract is frozen under v1 SemVer. The
`analysis`, `evolution`, `conformance`, and `verify` subpackages ship inside
v1.0 but are **advisory**: they produce diagnostics, and their surfaces sit
outside the frozen contract and may change in a minor release. `state/expr` is a
separate module pinned at v0.1.0 whose expression *semantics* are a committed,
stable contract even though the module version is pre-1.0.

The IR tools `gen` (eject codegen) and `cmd/crucible` (the IR CLI) are released
at **v0.1.0**, versioned independently of `state` and free to move at their own
pace.

The remaining modules are still evolving and may change before they reach v1:
`telemetry`, `sink`, and `source` (with all their adapters, codecs, and
middleware) are released and documented, as are the host-side runtimes over the
kernel: `durable` (durable execution), `cluster` (distribution and live
migration), `transport` (the gRPC network transport for cluster), and `wasm`
(polyglot behaviors). `broker` is planned. Treat every API as experimental until
it reaches v1.
(polyglot behaviors). `broker` is planned. Treat those modules as experimental
until each reaches its own v1.

## Roadmap

Expand All @@ -162,12 +176,16 @@ and forcing nothing third-party on the consumer:

A small set of tools works the IR directly:

- [x] **IR CLI** (`cmd/crucible`): headless IR tooling for CI. Lint reachability and
nondeterminism, render diagrams, diff and validate, and classify version diffs
straight from a machine's serialized IR, no behavior bound.
- [x] **Eject codegen** (`gen`): turn a machine's IR into typed Go stub source. Each
referenced behavior becomes a panic-bodied stub typed to the exact engine signature,
plus a `Provide` function that wires them against the registry, so the host fills in
bodies against a contract the compiler already checks.
- [ ] **Visual editor** _(planned)_: a browser workbench over the IR. Author, simulate,
and inspect machines, with reachability and version-diff overlays from the existing
`analysis` and `evolution` packages.
- [ ] **IR CLI** _(exploring)_: headless IR tooling for CI. Lint reachability and
nondeterminism, render diagrams, and classify version diffs straight from a machine's
IR.

Durable state and event persistence is tracked separately with the `durable`
runtime, not here.
Expand Down
13 changes: 11 additions & 2 deletions cmd/crucible/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
# Changelog

All notable changes to the crucible CLI are documented here. This module is
All notable changes to the crucible CLI are documented here. The format is based
on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this module
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). It is
versioned independently of the `state` module.

## 0.1.0
## [Unreleased]

## [0.1.0] - 2026-06-13

Initial release.

### Added

- `lint` runs static analysis over an IR and exits non-zero on findings.
- `render` emits a Mermaid or DOT diagram of a machine.
- `diff` classifies the changes between two IRs and recommends a semver bump.
- `validate` confirms an IR loads and assembles.
- `eject` generates typed Go behavior stubs from an IR.
- `version` (and `-version`) prints the CLI version.
- Commands read an IR file path or `-` for stdin.

[Unreleased]: https://github.com/stablekernel/crucible/compare/cmd/crucible/v0.1.0...HEAD
[0.1.0]: https://github.com/stablekernel/crucible/releases/tag/cmd/crucible/v0.1.0
6 changes: 6 additions & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ export default defineConfig({
{ label: 'Integrating', items: [{ autogenerate: { directory: 'integrating' } }] },
],
},
{
label: 'Tooling',
// The headless IR CLI and the eject codegen: reason about and
// scaffold from a machine's serialized IR without running it.
items: [{ autogenerate: { directory: 'tooling' } }],
},
{
label: 'Reference',
// Generated API reference for every module by `tools/docsgen`
Expand Down
6 changes: 3 additions & 3 deletions docs/src/content/docs/concepts/machine-and-instance.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ goroutines and reuse for the lifetime of your process. You forge and quench it
once:

```go
m := state.Forge[Gate, Signal, Turnstile]("turnstile").
Initial(Locked).
Transition(Locked).On(Coin).GoTo(Unlocked).
m := state.ForgeFor[Turnstile]("turnstile").
Initial("Locked").
Transition("Locked").On("Coin").GoTo("Unlocked").
Quench() // *Machine, immutable, share freely
```

Expand Down
26 changes: 8 additions & 18 deletions docs/src/content/docs/start/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,38 +31,28 @@ import (
"github.com/stablekernel/crucible/state"
)

type Gate string // S
type Signal string // E
type Turnstile struct{ Coins int } // C

const (
Locked Gate = "Locked"
Unlocked Gate = "Unlocked"
)

const (
Coin Signal = "Coin"
Push Signal = "Push"
)

func main() {
// Forge a builder, declare states + transitions, then Quench to freeze
// the definition into an immutable *Machine. Quench panics on misconfig.
m := state.Forge[Gate, Signal, Turnstile]("turnstile").
Initial(Locked).
Transition(Locked).On(Coin).GoTo(Unlocked).
Transition(Unlocked).On(Push).GoTo(Locked).
// State and event identifiers here are plain strings, so ForgeFor fixes
// S and E to string and leaves only the context type to spell.
m := state.ForgeFor[Turnstile]("turnstile").
Initial("Locked").
Transition("Locked").On("Coin").GoTo("Unlocked").
Transition("Unlocked").On("Push").GoTo("Locked").
Quench()

// Cast an instance around an entity value.
inst := m.Cast(Turnstile{})

// Fire advances the instance and returns a FireResult. It performs NO IO.
// NewState is the next state, Effects is data for the caller to dispatch.
res := inst.Fire(context.Background(), Coin)
res := inst.Fire(context.Background(), "Coin")
fmt.Println(res.NewState) // Unlocked

res = inst.Fire(context.Background(), Push)
res = inst.Fire(context.Background(), "Push")
fmt.Println(res.NewState) // Locked
}
```
Expand Down
23 changes: 8 additions & 15 deletions docs/src/content/docs/start/ingest-drive-emit.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,26 +31,19 @@ A toy turnstile that emits an `Opened` effect when it unlocks. The effect is pur
data; the machine performs no IO.

```go
type Gate string // S
type Signal string // E
type Turnstile struct{ Coins int } // C

const (
Locked Gate = "Locked"
Unlocked Gate = "Unlocked"
)

const Coin Signal = "Coin"

type Opened struct{ Coins int }

machine := state.Forge[Gate, Signal, Turnstile]("turnstile").
// State and event identifiers are plain strings, so ForgeFor fixes S and E to
// string and leaves only the context type to spell.
machine := state.ForgeFor[Turnstile]("turnstile").
// An action returns an effect (pure data) for the transition to emit.
Action("announceOpen", func(a state.ActionCtx[Turnstile]) (state.Effect, error) {
return Opened{Coins: a.Entity.Coins}, nil
}).
Initial(Locked).
Transition(Locked).On(Coin).GoTo(Unlocked).Do("announceOpen").
Initial("Locked").
Transition("Locked").On("Coin").GoTo("Unlocked").Do("announceOpen").
Quench()
```

Expand All @@ -72,11 +65,11 @@ manifold := sink.NewManifold(sink.WithOutlets(sink.OutletFunc(
)))

// Durable instance state for the bridge (in-memory for a single process).
store := statemachine.NewMemStore[string, Gate, Signal, Turnstile]()
store := statemachine.NewMemStore[string, string, string, Turnstile]()

// Route a message to its instance key and the event to fire.
router := func(m source.Message) (string, Signal, error) {
return m.Headers().Get("turnstile-id"), Coin, nil
router := func(m source.Message) (string, string, error) {
return m.Headers().Get("turnstile-id"), "Coin", nil
}

handler := statemachine.Drive(machine, store, router,
Expand Down
130 changes: 130 additions & 0 deletions docs/src/content/docs/tooling/cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
---
title: The crucible CLI
description: A headless command-line tool over a machine's serialized IR — lint, render, diff, validate, and eject, without running any behavior.
sidebar:
order: 1
---

`crucible` is a headless command-line tool over a machine's intermediate
representation (IR). It lints, renders, diffs, validates, and ejects a serialized
IR JSON document without running a single transition. It is the tooling face of
the [serialization split](../../serialization/overview/): structure is data, so a
tool can reason about a machine's shape from the IR alone.

Every command reads an IR JSON file path, or `-` to read from stdin. So a
machine written in Go becomes a CI gate: emit its IR with `ToJSON`, write it to a
file, and hand that file to the CLI.

```go
m, err := state.ForgeFor[OrderContext]("order").
// ... states and transitions ...
Quench()
if err != nil {
return err
}

ir, err := m.ToJSON()
if err != nil {
return err
}
return os.WriteFile("order.json", ir, 0o644)
```

## Install

The CLI lives in the `cmd/crucible` module, versioned independently of `state`.
Build it from source with the Go toolchain:

```sh
go install github.com/stablekernel/crucible/cmd/crucible@latest
```

Or build a local binary from a checkout:

```sh
go build -o crucible ./cmd/crucible
```

## Behavior-free operation

A serialized IR carries behavior references by name only; the kernel binds those
names to real implementations when it assembles a machine. The commands that need
an assembled machine (`lint`, `render`, `validate`) do not have the host's real
guards, actions, reducers, and services. They register a deterministic no-op stub
for every referenced name first, so the machine assembles from its structure
alone. The stubs never run — no instance is cast and no event is fired — so the
structural view is exactly what the IR describes.

## Commands

### lint

```sh
crucible lint order.json
```

Runs every [static analysis](../../analysis/overview/) check and prints the
findings. Exits non-zero when the analysis reports any finding, so it gates CI.

### render

```sh
crucible render order.json -format mermaid
crucible render order.json -format dot | dot -Tsvg -o order.svg
```

Renders the machine as a Mermaid `stateDiagram-v2` (the default) or as Graphviz
DOT. `-format` selects between `mermaid` and `dot`; the flag may appear after the
IR path. Output is text — pipe the DOT through Graphviz for an SVG.

### diff

```sh
crucible diff order-v1.json order-v2.json
```

Classifies the changes between two serialized IRs, prints the recommended semver
bump (`major`, `minor`, or `patch`), and lists the breaking and additive changes
separately. This is the [evolution](../../analysis/evolution/) check on the
command line: treat a machine definition as a schema and let the diff decide the
bump.

### validate

```sh
crucible validate order.json
```

Confirms the IR loads and assembles cleanly. A malformed JSON document or a
structural defect exits non-zero with a message on stderr; a clean machine prints
`ok: <name>` and exits zero. It is the well-formedness gate.

### eject

```sh
crucible eject order.json -package order -o behaviors.go
```

Generates typed Go behavior stubs for every referenced guard, action, reducer,
and service, plus a `Provide` function that registers them against a
`state.Registry`. `-package` sets the generated package name (default `machine`),
and `-o` writes to a file (default stdout). See [Eject](../eject/) for what the
generated file contains and how to fill it in.

### version

```sh
crucible version
crucible -version
```

Prints the CLI version.

## Exit codes

- `0` success
- `1` runtime or load error, and lint findings
- `2` usage error

A non-zero `lint` or `diff` makes the CLI a drop-in CI gate: a failing analysis
or an unexpected breaking change fails the build.
Loading