[READ-ONLY] Mirror of https://github.com/andrioid/statesman. xstate inspired schema compatible statechart library for Go
fsm go statecharts
0

Configure Feed

Select the types of activity you want to include in your feed.

schema: vendor machineSchema.json and validate $schema locally

Promote the Stately schema from the test-fixture schema/testdata/ to a
first-class schema/machineSchema.json (byte-preserving move; provenance kept
as a link to statelyai/schema). Docs (architecture, schema-subset) now link
the vendored copy instead of Stately's GitHub blob URL and drop the stale
internal/schema/ path.

Repoint every example machine.json's $schema at the vendored file by relative
path -- valid, offline, no network reference -- and add one to simple for
parity. The loader ignores $schema, so regeneration is a no-op.

Declare $schema (string) at the vendored schema's root so a $schema-carrying
document validates under additionalProperties:false; this matches the loader's
existing rule (root-only, nested rejected). A minimal, deliberate deviation
from upstream verbatim -- structure is untouched, and the schema pin test
expects it so a re-vendor that drops it fails loudly.

Add schema.TestVendoredSchemaSurface to guard the vendored file (nothing
embeds it). Verified: strict Draft-2020-12 validation passes for all four
examples.

+67 -11
+2
DECISIONS.md
··· 57 57 58 58 - **Sub-machine input seeding via `SetInitialContext`** — 2026-06-14 — m+git@andri.dk — Machine invokes need parent→child input (a wired-but-unseedable sub-machine is a toy). `MachineActor` now takes `newChild func() *Machine[CCtx,CEvt]` (a fresh, restart-safe child per spawn) and `input func(TCtx) CCtx`; it calls the new pre-start `Machine.SetInitialContext` to seed the child before Start — the machine analogue of a promise input mapper. Codegen emits the `<Src>Input` mapper for machine invokes too, so the coordinator's `InvestigateInput`/`FixInput` map its context into the child's. `MachineActor`'s signature changed (factory + input); the only callers are generated code. 59 59 - **CLI consumed as a `go tool` dependency** — 2026-06-14 — m+git@andri.dk — The `statesman` binary (`init`/`stub`/`generate`/`diagram`) is installed and run via Go 1.24+ tool dependencies: `go get -tool github.com/andrioid/statesman/cmd/statesman` pins it in the consumer's `go.mod`, and the scaffolded `gen.go` plus every `examples/*` `//go:generate` directive invoke `go tool statesman generate` rather than a `$PATH` binary from `go install`. Clean cutover — no `go install` fallback documented or generated. The repo dogfoods it with a `tool` directive in its own `go.mod`, which (via `go mod tidy`) also promotes `golang.org/x/tools` from indirect to a direct require, reflecting that codegen/`statesmantest` import it directly. Decided against CI for now, so the prior "non-negotiable CI gate" stays a local `go test -race` habit, not an enforced workflow. 60 + - **Vendored `machineSchema.json`; examples reference it locally** — 2026-06-14 — m+git@andri.dk — Promoted the Stately schema from the test-fixture `schema/testdata/machineSchema.json` to a first-class `schema/machineSchema.json` (byte-preserving move; provenance kept as a link to `statelyai/schema`). The docs (architecture, `schema-subset.md`) now link the vendored copy instead of Stately's GitHub *blob* URL (an HTML page, invalid as a `$schema` value), and fix the stale `internal/schema/...` path left over from making the package public. Every example machine.json's `$schema` points at the vendored file by relative path (`../../schema/machineSchema.json` etc.) — valid, offline, no network reference — and `simple` gains one for parity. The loader still accepts any root `$schema` string (Studio exports round-trip unchanged), so regeneration is a no-op. A `schema` pin test guards the vendored surface, since nothing embeds it. The `init` scaffold still emits no `$schema` (a consumer has no local vendored copy to point at). 61 + - **Vendored schema permits root `$schema`** — 2026-06-14 — m+git@andri.dk — Upstream Stately `machineSchema.json` is `additionalProperties:false` at the root and omits `$schema`, so a `$schema`-carrying document (every Stately Studio export, and now our examples) fails strict validation against it. The vendored copy adds the one root property upstream lacks — `$schema: {type: string}` — bringing the schema into agreement with the loader's existing rule (root `$schema` accepted, nested rejected: `TestNestedSchemaKeyRejected`). Deliberate, minimal deviation from verbatim; the `schema` pin test (`TestVendoredSchemaSurface`) expects it, so a re-vendor that silently drops it fails loudly. Structure (states/transitions/`$defs`) is untouched, so D2 "verbatim for structure" holds.
+1 -1
TODO.md
··· 81 81 - [x] **`docs/persistence-contract.md`** — durability window (observer-before- 82 82 publish, abort-on-write-failure), `OutboxIntent` wire format, EventLog 83 83 records, and the at-least-once startup recovery reconciliation (D8, D27, D42). 84 - - [x] Vendored `schema/testdata/machineSchema.json` + `order.json` 84 + - [x] Vendored `schema/machineSchema.json` + the `schema/testdata/order.json` 85 85 fixture (validates against the schema, gate 1). Rejection/feature fixtures 86 86 deferred to Phase 1 loader tests. 87 87
+6 -6
docs/schema-subset.md
··· 1 1 # statesman: Stately schema subset (normative) 2 2 3 - What v1 loads from `machine.json`, field by field, against the **actual** 4 - [`machineSchema.json`](https://github.com/statelyai/schema/blob/main/machineSchema.json) 5 - (vendored at `internal/schema/testdata/machineSchema.json`). Pins the boundary the 6 - [transition algorithm](./transition-algorithm.md) assumes (it defers JSON shape 7 - here) and the loader (`internal/schema`) implements. 3 + What v1 loads from `machine.json`, field by field, against the **actual** Stately 4 + [`machineSchema.json`](../schema/machineSchema.json) — vendored at 5 + `schema/machineSchema.json` (provenance: [statelyai/schema](https://github.com/statelyai/schema)). 6 + Pins the boundary the [transition algorithm](./transition-algorithm.md) assumes 7 + (it defers JSON shape here) and the loader (`schema`) implements. 8 8 9 9 > **Status:** normative (TODO.md Phase 0 gate). Grounded in the schema as fetched 10 10 > 2026-06-13 — a **purely structural** schema (no types, **no `context`**). ··· 58 58 | `meta` | `Meta` | **Loaded-but-ignored.** | 59 59 | `states` | `{ key: State }` | Child states. Presence (without `type`) ⇒ compound. | 60 60 | `version` | string | **Root only; loaded-but-ignored.** | 61 - | `$schema` | string | **Root only; loaded-but-ignored.** The JSON Schema meta-keyword Stately Studio emits so editors can suggest fields and validate; accepted for round-trip even though it is not a `machineSchema.json` property. | 61 + | `$schema` | string | **Root only; loaded-but-ignored.** The JSON Schema dialect/association meta-keyword Stately Studio (and our examples) emit so editors resolve and validate. Declared at the schema root — root-only, matching the loader — so a `$schema`-carrying document validates cleanly under `additionalProperties: false`. | 62 62 63 63 ## 4. Action / Guard / params 64 64
+1 -1
examples/issues/fix/fix.machine.json
··· 1 1 { 2 - "$schema": "https://raw.githubusercontent.com/statelyai/schema/refs/heads/main/machineSchema.json", 2 + "$schema": "../../../schema/machineSchema.json", 3 3 "description": "Sub-machine: edit -> test -> re-edit loop for one issue, bounded by an internal attempt budget.", 4 4 "id": "fix", 5 5 "initial": "editing",
+1 -1
examples/issues/investigate/investigate.machine.json
··· 1 1 { 2 - "$schema": "https://raw.githubusercontent.com/statelyai/schema/refs/heads/main/machineSchema.json", 2 + "$schema": "../../../schema/machineSchema.json", 3 3 "description": "Sub-machine: runs code analysis for one issue and leaves findings in its terminal context.", 4 4 "id": "investigate", 5 5 "initial": "analysing",
+1 -1
examples/issues/issues.machine.json
··· 1 1 { 2 - "$schema": "https://raw.githubusercontent.com/statelyai/schema/refs/heads/main/machineSchema.json", 2 + "$schema": "../../schema/machineSchema.json", 3 3 "description": "Automated GitHub issue triaging, investigation and fixing with help of invoked LLM agents from shell.", 4 4 "id": "issues", 5 5 "initial": "collecting",
+1
examples/simple/simple.machine.json
··· 1 1 { 2 + "$schema": "../../schema/machineSchema.json", 2 3 "id": "simple", 3 4 "initial": "giving", 4 5 "states": {
+49
schema/load_test.go
··· 1 1 package schema 2 2 3 3 import ( 4 + "encoding/json" 4 5 "os" 5 6 "path/filepath" 6 7 "strings" ··· 294 295 t.Fatal("nested $schema should be rejected, got nil error") 295 296 } 296 297 } 298 + 299 + // The vendored Stately machineSchema.json is the surface docs/schema-subset.md 300 + // pins the loader against. Nothing embeds it, so this is the only guard against 301 + // corruption/truncation and the place an upstream surface change shows up when 302 + // the file is re-vendored. 303 + func TestVendoredSchemaSurface(t *testing.T) { 304 + data, err := os.ReadFile("machineSchema.json") 305 + if err != nil { 306 + t.Fatalf("read vendored schema: %v", err) 307 + } 308 + var doc struct { 309 + Schema string `json:"$schema"` 310 + AdditionalProperties *bool `json:"additionalProperties"` 311 + Properties map[string]json.RawMessage `json:"properties"` 312 + Defs map[string]json.RawMessage `json:"$defs"` 313 + } 314 + if err := json.Unmarshal(data, &doc); err != nil { 315 + t.Fatalf("vendored schema is not valid JSON: %v", err) 316 + } 317 + if doc.Schema != "https://json-schema.org/draft/2020-12/schema" { 318 + t.Errorf("$schema dialect = %q, want draft 2020-12", doc.Schema) 319 + } 320 + if doc.AdditionalProperties == nil || *doc.AdditionalProperties { 321 + t.Error("root additionalProperties must be false (gate-1 unknown-field rejection)") 322 + } 323 + // The pinned top-level surface. "$schema" is the JSON Schema meta-keyword; we 324 + // permit it at the root (loader and schema agree: root-only) so Studio exports 325 + // and our own examples validate under additionalProperties:false. Every other 326 + // root key the loader reads must be declared here, and nothing else. 327 + wantProps := []string{ 328 + "$schema", "id", "description", "type", "target", "history", "entry", 329 + "exit", "initial", "on", "after", "always", "invoke", "meta", "states", 330 + "version", 331 + } 332 + for _, p := range wantProps { 333 + if _, ok := doc.Properties[p]; !ok { 334 + t.Errorf("vendored schema missing top-level property %q", p) 335 + } 336 + } 337 + if len(doc.Properties) != len(wantProps) { 338 + t.Errorf("vendored schema has %d top-level properties, want %d (surface drift — review)", len(doc.Properties), len(wantProps)) 339 + } 340 + for _, d := range []string{"Action", "Transition", "Guard", "Meta", "Invoke", "State"} { 341 + if _, ok := doc.Defs[d]; !ok { 342 + t.Errorf("vendored schema missing $def %q", d) 343 + } 344 + } 345 + }
+4
schema/testdata/machineSchema.json schema/machineSchema.json
··· 125 125 "states": { 126 126 "$ref": "#/$defs/__schema0" 127 127 }, 128 + "$schema": { 129 + "description": "JSON Schema dialect / association meta-keyword; permitted at the document root so Stately Studio exports (and statesman's own examples) validate. Root-only and ignored by the loader.", 130 + "type": "string" 131 + }, 128 132 "version": { 129 133 "type": "string" 130 134 }
+1 -1
statesman-architecture.md
··· 1 1 # statesman: Architecture 2 2 3 - A Go statechart runtime, inspired by xstate and anchored on Stately's [`machineSchema.json`](https://github.com/statelyai/schema/blob/main/machineSchema.json), optimized for **long-running backend workflows**, built on Go generics. 3 + A Go statechart runtime, inspired by xstate and anchored on Stately's [`machineSchema.json`](./schema/machineSchema.json) (vendored from [statelyai/schema](https://github.com/statelyai/schema)), optimized for **long-running backend workflows**, built on Go generics. 4 4 5 5 > **Status:** v1, implemented. Implementation sequencing and roadmap live in [`TODO.md`](./TODO.md); this document stays focused on design and patterns. 6 6