From b6bac1c35b27cb866df3b1ad2ead920b28dbef7d Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Fri, 5 Jun 2026 12:08:53 -0400 Subject: [PATCH 01/10] ci: broaden docs-deploy paths and lint tools/docsgen The docs-deploy trigger only watched state/**, so godoc changes in the other documented modules never redeployed the published API reference. Broaden the paths filter to every module whose godoc feeds the generated Reference section, plus docs/ and the generator itself. Add a lint step for tools/docsgen, the one in-workspace module the lint job omitted (examples/sinkflow and examples/sourcedrive already lint in the sink and source reusable workflows). Signed-off-by: Joshua Temple --- .github/workflows/ci.yml | 3 +++ .github/workflows/docs-deploy.yml | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b25949f..3a545e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,6 +137,9 @@ jobs: - name: golangci-lint (source/statemachine) working-directory: source/statemachine run: go run "$GOLANGCI_LINT" run --config "$GITHUB_WORKSPACE/.golangci.yml" ./... + - name: golangci-lint (tools/docsgen) + working-directory: tools/docsgen + run: go run "$GOLANGCI_LINT" run --config "$GITHUB_WORKSPACE/.golangci.yml" ./... # The state-machine race-test matrix lives in a reusable workflow so its legs # render as a single collapsible "state machine tests / …" tree in the checks diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 1d5dafe..fddd2a2 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -1,14 +1,24 @@ name: Deploy docs # Publishes the Starlight docs site to GitHub Pages on every push to main that -# touches the docs source, the suite's Go source (so regenerated API/diagrams -# stay current — see the DS2 TODO below), or this workflow. +# touches the docs source, the suite's Go source (so the regenerated API +# reference and Mermaid diagrams stay current), the generator itself, or this +# workflow. Every module whose godoc feeds the generated Reference section is +# listed so a godoc change in any of them redeploys the site, not just state. on: push: branches: [main] paths: - 'docs/**' + - 'tools/docsgen/**' - 'state/**' + - 'sink/**' + - 'source/**' + - 'telemetry/**' + - 'durable/**' + - 'cluster/**' + - 'transport/**' + - 'wasm/**' - '.github/workflows/docs-deploy.yml' # Least-privilege: read the repo, mint an OIDC token for Pages, write the From 26e4c74dbb308773132975fc321a106502b17ef2 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Fri, 5 Jun 2026 12:11:28 -0400 Subject: [PATCH 02/10] test: cover docsgen post-processing and document the public modules Add telemetry, durable, cluster, transport, and wasm to the docsgen reference list so the generated API reference covers the now-public modules; the site builds with the five new pages. Add table-driven tests for the regex post-processing (normalizeGodoc, unescapeGodoc, collapseEscapes, yamlString), which had no coverage, taking all four to 100 percent. Signed-off-by: Joshua Temple --- tools/docsgen/reference.go | 25 ++++- tools/docsgen/reference_test.go | 187 ++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 tools/docsgen/reference_test.go diff --git a/tools/docsgen/reference.go b/tools/docsgen/reference.go index c7f4385..70fe4e0 100644 --- a/tools/docsgen/reference.go +++ b/tools/docsgen/reference.go @@ -30,8 +30,9 @@ type referencePackage struct { } // referencePackages enumerates the public API surface documented in the -// Reference section, in sidebar order. state and state/expr are separate Go -// modules; the rest are packages within the state module. +// Reference section, in sidebar order. Each entry's mod is the Go module the +// gomarkdoc CLI runs in and pkg is the package within it; the list spans the +// state, sink, source, telemetry, durable, cluster, transport, and wasm modules. var referencePackages = []referencePackage{ { mod: "state", pkg: ".", slug: "state", title: "state", order: 1, @@ -129,6 +130,26 @@ var referencePackages = []referencePackage{ mod: "source/statemachine", pkg: ".", slug: "source-statemachine", title: "source/statemachine", order: 24, desc: "The state-to-source bridge: an inbound message drives a transition and the ack is tied to the durable transition.", }, + { + mod: "telemetry", pkg: ".", slug: "telemetry", title: "telemetry", order: 25, + desc: "The vendor-neutral tracing and metrics interface every Crucible module observes through, with zero-alloc no-op defaults.", + }, + { + mod: "durable", pkg: ".", slug: "durable", title: "durable", order: 26, + desc: "The host-side durable-execution runtime: record the nondeterministic seams of a running instance and replay them deterministically.", + }, + { + mod: "cluster", pkg: ".", slug: "cluster", title: "cluster", order: 27, + desc: "The host-side distribution runtime: address and drive a child machine on another node, with supervision and live migration.", + }, + { + mod: "transport", pkg: ".", slug: "transport", title: "transport", order: 28, + desc: "A gRPC network transport for the cluster runtime, carrying actor deliver and spawn operations between nodes.", + }, + { + mod: "wasm", pkg: ".", slug: "wasm", title: "wasm", order: 29, + desc: "Run Crucible behaviors authored as WebAssembly modules over a serializable JSON ABI through the pure-Go wazero runtime.", + }, } // generateReference renders each documented package to a Starlight Markdown diff --git a/tools/docsgen/reference_test.go b/tools/docsgen/reference_test.go new file mode 100644 index 0000000..5f0d1c5 --- /dev/null +++ b/tools/docsgen/reference_test.go @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import "testing" + +// TestNormalizeGodoc covers the regex post-processing that turns raw gomarkdoc +// output into a Starlight-ready page body: the DO NOT EDIT banner is dropped, +// the duplicate package-name H1 is removed (the frontmatter title becomes the +// page heading), git-derived source links are stripped for determinism, +// cosmetic backslash-escaping is collapsed, and surrounding blank lines are +// trimmed. The title argument is unused today and passed through unchanged, so +// the cases vary only the raw body. +func TestNormalizeGodoc(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + want string + }{ + { + name: "drops do-not-edit banner and leading blank lines", + raw: "\n\nBody text.", + want: "Body text.\n", + }, + { + name: "removes the leading package H1", + raw: "# state\n\nThe kernel.", + want: "The kernel.\n", + }, + { + name: "banner then H1 both removed", + raw: "\n# sink\n\nFan-out.", + want: "Fan-out.\n", + }, + { + name: "strips a gomarkdoc view-source link", + raw: "Doc line []() trailing.", + want: "Doc line trailing.\n", + }, + { + name: "unescapes cosmetic body punctuation escapes", + raw: "Forge\\(\\) builds a machine \\- the entry point\\.", + want: "Forge() builds a machine - the entry point.\n", + }, + { + name: "collapses a double-escaped dash inside a heading to plain text", + raw: "## Forge \\\\\\- entry\n\nbody", + want: "## Forge - entry\n\nbody\n", + }, + { + name: "leaves load-bearing inline markdown escapes in body intact", + // A backslash before * or _ carries emphasis meaning, so the body pass + // must not touch it. + raw: "Use \\*literal\\* not emphasis.", + want: "Use \\*literal\\* not emphasis.\n", + }, + { + name: "trims trailing whitespace and guarantees a single trailing newline", + raw: "Just a line.\n\n\n", + want: "Just a line.\n", + }, + { + name: "empty input yields a lone newline", + raw: "", + want: "\n", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := normalizeGodoc(tc.raw, "ignored-title"); got != tc.want { + t.Fatalf("normalizeGodoc(%q) = %q, want %q", tc.raw, got, tc.want) + } + }) + } +} + +// TestUnescapeGodoc covers the two-pass unescaper directly: every escaped +// punctuation character in a heading line is unescaped (godoc headings are +// plain text), while body text is unescaped only before punctuation with no +// inline-Markdown meaning, so emphasis, links, code, and table escapes survive. +func TestUnescapeGodoc(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + { + name: "heading unescapes all punctuation including markdown-significant chars", + in: "### A \\* B \\_ C \\[ D", + want: "### A * B _ C [ D", + }, + { + name: "body keeps emphasis and link escapes, drops cosmetic ones", + in: "keep \\* and \\[ but drop \\( and \\,", + want: "keep \\* and \\[ but drop ( and ,", + }, + { + name: "heading double-escape collapses fully", + in: "# title \\\\\\.", + want: "# title .", + }, + { + name: "no escapes is a no-op", + in: "plain heading\nand body", + want: "plain heading\nand body", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := unescapeGodoc(tc.in); got != tc.want { + t.Fatalf("unescapeGodoc(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestCollapseEscapes covers the fixed-point reducer used by both unescape +// passes: it applies the matcher repeatedly until the text stops changing, so a +// chain of backslashes before a target character collapses to the bare +// character in one call. +func TestCollapseEscapes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + {name: "single escape", in: `a\-b`, want: "a-b"}, + {name: "double escape collapses fully", in: `a\\-b`, want: "a-b"}, + {name: "triple escape collapses fully", in: `a\\\-b`, want: "a-b"}, + {name: "unrelated backslash untouched", in: `a\*b`, want: `a\*b`}, + {name: "no backslash", in: "ab", want: "ab"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := collapseEscapes(tc.in, godocBodyEscape); got != tc.want { + t.Fatalf("collapseEscapes(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestYAMLString covers the double-quoted YAML scalar rendering used for +// frontmatter values: backslash and double-quote are the only two characters +// that need escaping inside a double-quoted scalar, and the result is always +// wrapped in quotes so indicator characters (a leading colon, symbol, etc.) +// parse as a single string rather than as a mapping. +func TestYAMLString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + {name: "plain text is quoted", in: "hello world", want: `"hello world"`}, + {name: "value with a colon stays a single scalar", in: "a: b", want: `"a: b"`}, + {name: "double quotes are escaped", in: `say "hi"`, want: `"say \"hi\""`}, + {name: "backslashes are escaped", in: `a\b`, want: `"a\\b"`}, + { + name: "backslash before quote escapes both, in source order", + in: `a\"b`, + want: `"a\\\"b"`, + }, + {name: "empty string is an empty quoted scalar", in: "", want: `""`}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := yamlString(tc.in); got != tc.want { + t.Fatalf("yamlString(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} From cbda2309188d979517cfb2cda135f48cf7bd6b08 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Fri, 5 Jun 2026 12:12:50 -0400 Subject: [PATCH 03/10] fix: derive shipment current state from the entity, not a pending stub The sourcedrive and e2e fulfillment machines hardcoded CurrentStateFn to return pending, so a shipment cast from an in-flight value always restarted at pending and defeated the resume/seek contract. Record the lifecycle stage on the entity and derive the current state from it, defaulting to pending only for a fresh or unset shipment. Add a resume-from-recorded-stage test. Signed-off-by: Joshua Temple --- e2e/source_test.go | 19 ++++++++++++++--- examples/sourcedrive/sourcedrive.go | 20 +++++++++++++++--- examples/sourcedrive/sourcedrive_test.go | 27 ++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/e2e/source_test.go b/e2e/source_test.go index 517e5cc..31ea68b 100644 --- a/e2e/source_test.go +++ b/e2e/source_test.go @@ -23,9 +23,22 @@ import ( // through the Store seam without any core importing another. // shipment is the entity a fulfillment instance advances. Funds gates the pay -// transition so a guard rejection (invalid-for-state) is reachable. +// transition so a guard rejection (invalid-for-state) is reachable. Stage +// records the lifecycle state reached; CurrentStateFn reads it so a shipment +// cast from an in-flight value resumes at its real state, not at pending. type shipment struct { - Funds bool `json:"funds"` + Funds bool `json:"funds"` + Stage string `json:"stage"` +} + +// currentStage derives a shipment's current state for CurrentStateFn. A nil +// entity (a fresh cast with no record) or an empty Stage starts at pending; +// otherwise the recorded stage is honored so resume lands on the real state. +func currentStage(s *shipment) string { + if s == nil || s.Stage == "" { + return "pending" + } + return s.Stage } // shipEvent is the decoded inbound command carried in each message's JSON body. @@ -51,7 +64,7 @@ func fulfillmentMachine() *state.Machine[string, string, *shipment] { State("shipped"). State("delivered"). Initial("pending"). - CurrentStateFn(func(*shipment) string { return "pending" }). + CurrentStateFn(currentStage). Transition("pending").On("pay").GoTo("shipped").When("funded"). Transition("shipped").On("deliver").GoTo("delivered"). Quench(state.Strict()) diff --git a/examples/sourcedrive/sourcedrive.go b/examples/sourcedrive/sourcedrive.go index 1e4a491..111221d 100644 --- a/examples/sourcedrive/sourcedrive.go +++ b/examples/sourcedrive/sourcedrive.go @@ -14,9 +14,23 @@ import ( // Shipment is the entity each fulfillment instance carries. Funds gates the pay // transition, so an unfunded pay is a state-aware rejection rather than a -// transient error. +// transient error. Stage records the lifecycle state the shipment has reached; +// CurrentStateFn reads it so a shipment cast from a record already in flight +// resumes at its real state instead of restarting at pending. type Shipment struct { - Funds bool `json:"funds"` + Funds bool `json:"funds"` + Stage string `json:"stage"` +} + +// currentStage derives a shipment's current lifecycle state for CurrentStateFn. +// A nil entity (a fresh cast with no record yet) or an empty Stage means the +// instance has not advanced, so it starts at pending; otherwise the recorded +// stage is honored so resume and seek land on the real state. +func currentStage(s *Shipment) string { + if s == nil || s.Stage == "" { + return "pending" + } + return s.Stage } // Command is the JSON body of an inbound message: the event to fire against the @@ -59,7 +73,7 @@ func NewFulfillment() *Fulfillment { State("shipped"). State("delivered"). Initial("pending"). - CurrentStateFn(func(*Shipment) string { return "pending" }). + CurrentStateFn(currentStage). Transition("pending").On("pay").GoTo("shipped").When("funded"). Transition("shipped").On("deliver").GoTo("delivered"). Quench(state.Strict()) diff --git a/examples/sourcedrive/sourcedrive_test.go b/examples/sourcedrive/sourcedrive_test.go index 2be2b53..bdf692b 100644 --- a/examples/sourcedrive/sourcedrive_test.go +++ b/examples/sourcedrive/sourcedrive_test.go @@ -348,6 +348,33 @@ func waitForSettle(t *testing.T, ledger *memsource.Ledger, n int) { } } +// TestCurrentStateFn_ResumesFromRecordedStage proves CurrentStateFn derives the +// instance's state from the entity rather than pinning every cast to pending: a +// shipment carrying a stage resumes there, and an unset (or absent) stage starts +// at pending. This is the resume/seek contract the bridge relies on when casting +// an entity that already advanced. +func TestCurrentStateFn_ResumesFromRecordedStage(t *testing.T) { + f := sourcedrive.NewFulfillment() + + tests := []struct { + name string + stage string + want string + }{ + {name: "unset stage starts at pending", stage: "", want: "pending"}, + {name: "recorded shipped resumes shipped", stage: "shipped", want: "shipped"}, + {name: "recorded delivered resumes delivered", stage: "delivered", want: "delivered"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + inst := f.Machine.Cast(&sourcedrive.Shipment{Funds: true, Stage: tc.stage}) + if got := inst.Current(); got != tc.want { + t.Fatalf("Current() = %q, want %q", got, tc.want) + } + }) + } +} + // failingInlet is a source.Inlet whose Subscribe always fails, to drive Run's // subscribe-error path. type failingInlet struct{ err error } From 1d49fbefedaa11e17be8c748259047f348f1df58 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Fri, 5 Jun 2026 12:15:50 -0400 Subject: [PATCH 04/10] test: inject dispatch polyglot seams instead of mutating package state The equivalence harness reached into package-level newModel and driveAuthorizedFn vars, which two sub-tests would race on if they ran in parallel. Carry the seams on a polyglotDeps value the exported entry point fills from productionDeps and the tests fill with their own fakes, so no run shares mutable state. The model-build and drive error tests now run with t.Parallel and the suite stays race-clean. Signed-off-by: Joshua Temple --- examples/dispatch/polyglot.go | 42 +++++++++++++++++++++--------- examples/dispatch/polyglot_test.go | 40 +++++++++++++++------------- 2 files changed, 51 insertions(+), 31 deletions(-) diff --git a/examples/dispatch/polyglot.go b/examples/dispatch/polyglot.go index 8607d03..d4c5478 100644 --- a/examples/dispatch/polyglot.go +++ b/examples/dispatch/polyglot.go @@ -89,15 +89,25 @@ var generousIsolatingOrders = []PolyglotCase{ {Name: "frugal", Order: fooddelivery.Order{Subtotal: 3000, Tip: 1500, Priority: "standard"}}, } -// newModel and driveAuthorizedFn are indirection seams the harness routes its model -// construction and order driving through, so a test can inject a failure to exercise the -// error paths that the real CEL and WASM models — which build and drive cleanly — never -// take. Production code always uses the real [fooddelivery.NewModel] and -// [driveAuthorized]; only tests reassign them. -var ( - newModel = fooddelivery.NewModel - driveAuthorizedFn = driveAuthorized -) +// polyglotDeps are the model-construction and order-driving seams the +// equivalence harness routes through, so a test can inject a failure to exercise +// the error paths that the real CEL and WASM models — which build and drive +// cleanly — never take. They are carried on a value rather than package globals +// so each test supplies its own, and no two runs share mutable state; that keeps +// the harness safe to drive from parallel sub-tests. Production always uses +// [productionDeps] (the real [fooddelivery.NewModel] and [driveAuthorized]). +type polyglotDeps struct { + newModel func(...fooddelivery.Option) (*state.Machine[fooddelivery.Stage, fooddelivery.Signal, fooddelivery.Order], error) + driveAuthorize func(context.Context, *state.Machine[fooddelivery.Stage, fooddelivery.Signal, fooddelivery.Order], fooddelivery.Order) (orderOutcome, error) +} + +// productionDeps returns the real seams the exported harness runs with. +func productionDeps() polyglotDeps { + return polyglotDeps{ + newModel: fooddelivery.NewModel, + driveAuthorize: driveAuthorized, + } +} // RunPolyglotEquivalence builds two order models that differ only in the engine // computing the generous-order guard — the default CEL model and a WebAssembly model @@ -107,7 +117,13 @@ var ( // throughout. It errors if the WASM module fails to compile or build, or if either // model fails to build or drive — nothing is swallowed. func RunPolyglotEquivalence(ctx context.Context, wasmBytes []byte) (PolyglotReport, error) { - celModel, err := newModel() + return runPolyglotEquivalence(ctx, wasmBytes, productionDeps()) +} + +// runPolyglotEquivalence is the harness core, parameterized over its seams so a +// test injects fakes through deps without mutating any shared state. +func runPolyglotEquivalence(ctx context.Context, wasmBytes []byte, deps polyglotDeps) (PolyglotReport, error) { + celModel, err := deps.newModel() if err != nil { return PolyglotReport{}, fmt.Errorf("dispatch: build CEL model: %w", err) } @@ -118,7 +134,7 @@ func RunPolyglotEquivalence(ctx context.Context, wasmBytes []byte) (PolyglotRepo } defer func() { _ = mod.Close(ctx) }() - wasmModel, err := newModel(fooddelivery.WithGenerousGuard(wasmGenerousGuard(mod))) + wasmModel, err := deps.newModel(fooddelivery.WithGenerousGuard(wasmGenerousGuard(mod))) if err != nil { return PolyglotReport{}, fmt.Errorf("dispatch: build wasm model: %w", err) } @@ -126,11 +142,11 @@ func RunPolyglotEquivalence(ctx context.Context, wasmBytes []byte) (PolyglotRepo report := PolyglotReport{Cases: make([]PolyglotCase, 0, len(generousIsolatingOrders))} sawAdmit, sawReject, allAgree := false, false, true for _, tc := range generousIsolatingOrders { - celOutcome, err := driveAuthorizedFn(ctx, celModel, tc.Order) + celOutcome, err := deps.driveAuthorize(ctx, celModel, tc.Order) if err != nil { return PolyglotReport{}, fmt.Errorf("dispatch: drive CEL model for %q: %w", tc.Name, err) } - wasmOutcome, err := driveAuthorizedFn(ctx, wasmModel, tc.Order) + wasmOutcome, err := deps.driveAuthorize(ctx, wasmModel, tc.Order) if err != nil { return PolyglotReport{}, fmt.Errorf("dispatch: drive wasm model for %q: %w", tc.Name, err) } diff --git a/examples/dispatch/polyglot_test.go b/examples/dispatch/polyglot_test.go index 9e1b198..e767fae 100644 --- a/examples/dispatch/polyglot_test.go +++ b/examples/dispatch/polyglot_test.go @@ -105,32 +105,34 @@ func TestRunPolyglotEquivalence_RejectsModuleWithoutABI(t *testing.T) { // whether building the CEL model or the WASM model — surfaces through the harness rather // than being swallowed. The model builder seam is injected to fail. func TestRunPolyglotEquivalence_ModelBuildErrors(t *testing.T) { + t.Parallel() wasmBytes := buildGenerousGuest(t) sentinel := errors.New("model build boom") t.Run("CEL model build fails", func(t *testing.T) { - restore := newModel - newModel = func(...fooddelivery.Option) (*state.Machine[fooddelivery.Stage, fooddelivery.Signal, fooddelivery.Order], error) { + t.Parallel() + deps := productionDeps() + deps.newModel = func(...fooddelivery.Option) (*state.Machine[fooddelivery.Stage, fooddelivery.Signal, fooddelivery.Order], error) { return nil, sentinel } - t.Cleanup(func() { newModel = restore }) - if _, err := RunPolyglotEquivalence(context.Background(), wasmBytes); !errors.Is(err, sentinel) { + if _, err := runPolyglotEquivalence(context.Background(), wasmBytes, deps); !errors.Is(err, sentinel) { t.Fatalf("expected the model build error to surface; got %v", err) } }) t.Run("WASM model build fails", func(t *testing.T) { - restore := newModel + t.Parallel() + real := productionDeps() calls := 0 - newModel = func(opts ...fooddelivery.Option) (*state.Machine[fooddelivery.Stage, fooddelivery.Signal, fooddelivery.Order], error) { + deps := real + deps.newModel = func(opts ...fooddelivery.Option) (*state.Machine[fooddelivery.Stage, fooddelivery.Signal, fooddelivery.Order], error) { calls++ if calls == 1 { // the CEL model builds; the WASM model (second call) fails. - return restore() + return real.newModel() } return nil, sentinel } - t.Cleanup(func() { newModel = restore }) - if _, err := RunPolyglotEquivalence(context.Background(), wasmBytes); !errors.Is(err, sentinel) { + if _, err := runPolyglotEquivalence(context.Background(), wasmBytes, deps); !errors.Is(err, sentinel) { t.Fatalf("expected the WASM model build error to surface; got %v", err) } }) @@ -139,32 +141,34 @@ func TestRunPolyglotEquivalence_ModelBuildErrors(t *testing.T) { // TestRunPolyglotEquivalence_DriveErrors confirms a drive failure on either engine // surfaces through the harness, exercising both per-case error paths via the drive seam. func TestRunPolyglotEquivalence_DriveErrors(t *testing.T) { + t.Parallel() wasmBytes := buildGenerousGuest(t) sentinel := errors.New("drive boom") t.Run("CEL drive fails", func(t *testing.T) { - restore := driveAuthorizedFn - driveAuthorizedFn = func(context.Context, *state.Machine[fooddelivery.Stage, fooddelivery.Signal, fooddelivery.Order], fooddelivery.Order) (orderOutcome, error) { + t.Parallel() + deps := productionDeps() + deps.driveAuthorize = func(context.Context, *state.Machine[fooddelivery.Stage, fooddelivery.Signal, fooddelivery.Order], fooddelivery.Order) (orderOutcome, error) { return outcomeBlocked, sentinel } - t.Cleanup(func() { driveAuthorizedFn = restore }) - if _, err := RunPolyglotEquivalence(context.Background(), wasmBytes); !errors.Is(err, sentinel) { + if _, err := runPolyglotEquivalence(context.Background(), wasmBytes, deps); !errors.Is(err, sentinel) { t.Fatalf("expected the CEL drive error to surface; got %v", err) } }) t.Run("WASM drive fails", func(t *testing.T) { - restore := driveAuthorizedFn + t.Parallel() + real := productionDeps() calls := 0 - driveAuthorizedFn = func(ctx context.Context, m *state.Machine[fooddelivery.Stage, fooddelivery.Signal, fooddelivery.Order], o fooddelivery.Order) (orderOutcome, error) { + deps := real + deps.driveAuthorize = func(ctx context.Context, m *state.Machine[fooddelivery.Stage, fooddelivery.Signal, fooddelivery.Order], o fooddelivery.Order) (orderOutcome, error) { calls++ if calls == 1 { // the CEL drive succeeds; the WASM drive (second call) fails. - return restore(ctx, m, o) + return real.driveAuthorize(ctx, m, o) } return outcomeBlocked, sentinel } - t.Cleanup(func() { driveAuthorizedFn = restore }) - if _, err := RunPolyglotEquivalence(context.Background(), wasmBytes); !errors.Is(err, sentinel) { + if _, err := runPolyglotEquivalence(context.Background(), wasmBytes, deps); !errors.Is(err, sentinel) { t.Fatalf("expected the WASM drive error to surface; got %v", err) } }) From feac746d4e8fd0c130b3d5560d7d27075d39bb66 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Fri, 5 Jun 2026 12:15:57 -0400 Subject: [PATCH 05/10] test: cover startActiveOrder submit and run-service error paths startActiveOrder's submit-fire and run-authorize-service error branches were unexercised. Add two failing-store tests that fail the Append at each step, raising the helper's coverage from 71 to 86 percent; the remaining gap is the defensive no-service-in-flight guard, which the normal flow never reaches. Signed-off-by: Joshua Temple --- examples/dispatch/durable_internal_test.go | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/examples/dispatch/durable_internal_test.go b/examples/dispatch/durable_internal_test.go index aed21e6..46d140e 100644 --- a/examples/dispatch/durable_internal_test.go +++ b/examples/dispatch/durable_internal_test.go @@ -55,6 +55,43 @@ func TestStartActiveOrder_ExistingInstance(t *testing.T) { } } +// TestStartActiveOrder_SubmitError covers the submit-fire error branch: Start +// succeeds, then the Submit step's Append fails, so startActiveOrder surfaces a +// wrapped submit-order error rather than returning a half-started handle. +func TestStartActiveOrder_SubmitError(t *testing.T) { + ctx := context.Background() + // No Appends allowed: the first recorded step (Submit) fails. Loads are open so + // Start's existence probe succeeds. + store := &failingStore{Store: durable.NewMemStore(), allowLoads: 5, allowAppends: 0} + + _, _, err := startActiveOrder(ctx, store, durable.InstanceID("order-submit-fail"), + durableOptions(state.NewFakeClock(fixedClockStart))) + if err == nil { + t.Fatal("expected a submit error from the failing store, got nil") + } + if !strings.Contains(err.Error(), "submit order") { + t.Fatalf("error = %v, want a submit-order message", err) + } +} + +// TestStartActiveOrder_RunServiceError covers the run-authorize-service error +// branch: Start and the Submit step succeed, then the authorize service's Append +// fails, so startActiveOrder surfaces a wrapped run-authorize-service error. +func TestStartActiveOrder_RunServiceError(t *testing.T) { + ctx := context.Background() + // One Append allowed (Submit); the authorize service's record then fails. + store := &failingStore{Store: durable.NewMemStore(), allowLoads: 5, allowAppends: 1} + + _, _, err := startActiveOrder(ctx, store, durable.InstanceID("order-service-fail"), + durableOptions(state.NewFakeClock(fixedClockStart))) + if err == nil { + t.Fatal("expected a run-authorize-service error from the failing store, got nil") + } + if !strings.Contains(err.Error(), "run authorize service") { + t.Fatalf("error = %v, want a run-authorize-service message", err) + } +} + // failingStore wraps a durable.Store and fails Load / History after a configured // number of successful Loads, so the harness's recovery and reconstruction error // branches are exercised without disturbing the live recording run (whose only Load From 5b8de2207c43739f5df2a6276516e3bde79be82f Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Fri, 5 Jun 2026 12:18:00 -0400 Subject: [PATCH 06/10] test: cover fooddelivery refund and restore error branches Add direct coverage for refundFn's nothing-to-refund branch (no authorization hold), RunRefund's no-service-armed branch, and RestoreRig's restore-error branch (an undeclared-state snapshot). refundFn and RunRefund reach 100 percent; the lone RestoreRig gap is the unreachable NewModel failure. Signed-off-by: Joshua Temple --- examples/fooddelivery/refund_internal_test.go | 49 +++++++++++++++++++ examples/fooddelivery/unit_test.go | 32 ++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 examples/fooddelivery/refund_internal_test.go diff --git a/examples/fooddelivery/refund_internal_test.go b/examples/fooddelivery/refund_internal_test.go new file mode 100644 index 0000000..7d3f083 --- /dev/null +++ b/examples/fooddelivery/refund_internal_test.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 + +package fooddelivery + +import ( + "context" + "testing" + + "github.com/stablekernel/crucible/state" +) + +// TestRefundFn covers the refund service body directly, including its +// nothing-to-refund error branch: a refund attempted against an order with no +// authorization hold has nothing to reverse and must fail loudly rather than +// returning a bogus amount. The happy path (an order carrying a hold reverses +// subtotal+tip) is also asserted so both arms are exercised in one place. +func TestRefundFn(t *testing.T) { + t.Parallel() + + t.Run("no hold is an error", func(t *testing.T) { + t.Parallel() + out, err := refundFn(context.Background(), state.ServiceCtx[Order]{ + Entity: Order{Subtotal: 5000, Tip: 1500}, + }) + if err == nil { + t.Fatalf("refundFn with no AuthHold = (%v, nil), want an error", out) + } + if out != nil { + t.Fatalf("refundFn error path returned %v, want nil amount", out) + } + }) + + t.Run("held authorization reverses subtotal plus tip", func(t *testing.T) { + t.Parallel() + out, err := refundFn(context.Background(), state.ServiceCtx[Order]{ + Entity: Order{Subtotal: 5000, Tip: 1500, AuthHold: "tok-1"}, + }) + if err != nil { + t.Fatalf("refundFn with a hold returned error: %v", err) + } + amount, ok := out.(int64) + if !ok { + t.Fatalf("refundFn returned %T, want int64", out) + } + if amount != 6500 { + t.Fatalf("refund amount = %d, want 6500 (subtotal+tip)", amount) + } + }) +} diff --git a/examples/fooddelivery/unit_test.go b/examples/fooddelivery/unit_test.go index 596cc43..122fdb7 100644 --- a/examples/fooddelivery/unit_test.go +++ b/examples/fooddelivery/unit_test.go @@ -134,6 +134,38 @@ func TestRig_RestoreAndAccessors(t *testing.T) { } } +// TestRig_RunRefundNoService covers RunRefund's no-service-running branch: with +// no refund service armed (the order was never canceled), the Tick finds nothing +// to run and RunRefund reports false rather than fabricating a result. +func TestRig_RunRefundNoService(t *testing.T) { + ctx := context.Background() + rig, err := fooddelivery.NewRig(fooddelivery.WithOrder(fooddelivery.Order{Subtotal: 5000, Tip: 1500, Priority: "fast"})) + if err != nil { + t.Fatalf("NewRig: %v", err) + } + rig.Submit(ctx) + rig.SettleAuthorization(ctx, true) // active order, but no refund ever armed + + if fr, ran := rig.RunRefund(ctx); ran { + t.Fatalf("RunRefund with no armed refund service = ran, fr=%+v; want not ran", fr) + } +} + +// TestRestoreRig_RestoreError covers RestoreRig's restore-error branch: a +// snapshot naming a state the freshly forged machine does not declare cannot be +// restored, so RestoreRig surfaces the error rather than returning a half-built +// rig. +func TestRestoreRig_RestoreError(t *testing.T) { + bad := state.Snapshot[fooddelivery.Stage, fooddelivery.Signal, fooddelivery.Order]{ + Machine: "order", + Current: fooddelivery.Stage(-1), // not a declared state + Configuration: []fooddelivery.Stage{fooddelivery.Stage(-1)}, + } + if _, err := fooddelivery.RestoreRig(bad); err == nil { + t.Fatal("RestoreRig with an undeclared-state snapshot = nil error, want a restore failure") + } +} + // TestStage_StringFallback covers the String fallback arms for out-of-range values. func TestStage_StringFallback(t *testing.T) { if got := fooddelivery.Stage(-1).String(); got != "Stage?" { From d60ad093a73564de835e7c7b55510306b364ae70 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Fri, 5 Jun 2026 12:19:05 -0400 Subject: [PATCH 07/10] test: cover sinkflow FlakyOutlet unregistered-payload branch The flaky outlet returned sink.ErrUnregistered for a non-Transition payload but nothing exercised it. Add a direct test for the unregistered-payload guard and the induced-failure error message, taking the example to full coverage. Signed-off-by: Joshua Temple --- examples/sinkflow/sinkflow_test.go | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/examples/sinkflow/sinkflow_test.go b/examples/sinkflow/sinkflow_test.go index 4800b53..7d8028c 100644 --- a/examples/sinkflow/sinkflow_test.go +++ b/examples/sinkflow/sinkflow_test.go @@ -4,14 +4,45 @@ package sinkflow_test import ( "context" + "errors" "log/slog" "sync" "testing" + csink "github.com/stablekernel/crucible/sink" + "github.com/stablekernel/crucible/sink/bridge" + "github.com/stablekernel/crucible/examples/sinkflow" "github.com/stablekernel/crucible/telemetry" ) +// TestFlakyOutletSinkRejectsUnregisteredPayload covers FlakyOutlet.Sink's +// non-Transition branch: the bridge always hands it a bridge.Transition, but the +// outlet defends against a misregistered transformer by returning +// sink.ErrUnregistered for any other payload type. A matching-event Transition +// then exercises the induced-failure branch, whose error renders a non-empty +// message. +func TestFlakyOutletSinkRejectsUnregisteredPayload(t *testing.T) { + t.Parallel() + out := &sinkflow.FlakyOutlet{FailOnEvent: sinkflow.Dispatch} + + if err := out.Sink(context.Background(), "not a transition"); !errors.Is(err, csink.ErrUnregistered) { + t.Fatalf("Sink(non-transition) = %v, want ErrUnregistered", err) + } + + err := out.Sink(context.Background(), bridge.Transition{Machine: "order", Event: sinkflow.Dispatch}) + if err == nil { + t.Fatal("Sink of the failing event = nil, want an induced failure") + } + if err.Error() == "" { + t.Fatal("induced failure rendered an empty error message") + } + + if got := len(out.Delivered); got != 0 { + t.Fatalf("a rejected and a failed Sink should record nothing; Delivered=%d", got) + } +} + // --- recording telemetry + logging seams for the assertions ----------------- type ctxKey struct{} From 9cfad7613ef26e7797e730757c48dcc968b579c8 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Fri, 5 Jun 2026 12:20:47 -0400 Subject: [PATCH 08/10] test: cover the magefiles benchmark gate parser benchGate, which mirrors the CI bench-gate awk to keep local and CI verdicts in step, had no tests. Add a table-driven test over the benchstat CSV format covering the gated sec/op and allocs/op regressions, the non-gated B/op metric, skipped new/removed/geomean rows, and the invalid-threshold error, taking benchGate to full coverage. Signed-off-by: Joshua Temple --- magefiles/bench_gate_test.go | 115 +++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 magefiles/bench_gate_test.go diff --git a/magefiles/bench_gate_test.go b/magefiles/bench_gate_test.go new file mode 100644 index 0000000..daf1bac --- /dev/null +++ b/magefiles/bench_gate_test.go @@ -0,0 +1,115 @@ +//go:build mage + +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "strings" + "testing" +) + +// TestBenchGate covers the benchstat-CSV parser that decides whether a benchmark +// regressed past the allowed head/base ratio. The CSV groups data rows under a +// per-metric units row (sec/op, allocs/op, or the non-gated B/op); each data row +// is ",,,,,,

", so column 2 is the base +// and column 4 the head value. The cases exercise the gated-regression, +// within-threshold, non-gated-metric, new/removed-benchmark, and malformed-row +// branches against the fixed default threshold. +func TestBenchGate(t *testing.T) { + t.Parallel() + + const threshold = "1.20" + + tests := []struct { + name string + csv string + wantErr bool + }{ + { + name: "sec/op within threshold passes", + csv: ",sec/op,CI,sec/op,CI,vs base,P\nBenchFire,100,±1%,110,±1%,+10%,0.01\n", + wantErr: false, + }, + { + name: "sec/op past threshold regresses", + csv: ",sec/op,CI,sec/op,CI,vs base,P\nBenchFire,100,±1%,130,±1%,+30%,0.01\n", + wantErr: true, + }, + { + name: "allocs/op within threshold passes", + csv: ",allocs/op,CI,allocs/op,CI,vs base,P\nBenchFire,10,±0%,11,±0%,+10%,0.01\n", + wantErr: false, + }, + { + name: "B/op is reported but never gated", + csv: ",B/op,CI,B/op,CI,vs base,P\nBenchFire,100,±1%,1000,±1%,+900%,0.01\n", + wantErr: false, + }, + { + name: "new benchmark (empty base) is skipped", + csv: ",sec/op,CI,sec/op,CI,vs base,P\nBenchNew,,,130,±1%,~,0.01\n", + wantErr: false, + }, + { + name: "removed benchmark (empty head) is skipped", + csv: ",sec/op,CI,sec/op,CI,vs base,P\nBenchGone,130,±1%,,,~,0.01\n", + wantErr: false, + }, + { + name: "geomean summary row is skipped", + csv: ",sec/op,CI,sec/op,CI,vs base,P\ngeomean,100,±1%,200,±1%,+100%,0.01\n", + wantErr: false, + }, + { + name: "zero base is skipped (no ratio)", + csv: ",sec/op,CI,sec/op,CI,vs base,P\nBenchZero,0,±1%,130,±1%,~,0.01\n", + wantErr: false, + }, + { + name: "data row before any units row is ignored", + csv: "BenchOrphan,100,±1%,130,±1%,+30%,0.01\n", + wantErr: false, + }, + { + name: "blank and preamble lines are ignored", + csv: "goos: darwin\n\n,sec/op,CI,sec/op,CI,vs base,P\nBenchFire,100,±1%,110,±1%,+10%,0.01\n", + wantErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := benchGate(tc.csv, threshold) + if (err != nil) != tc.wantErr { + t.Fatalf("benchGate() error = %v, wantErr = %v", err, tc.wantErr) + } + }) + } +} + +// TestBenchGate_AllocsRegression isolates the allocs/op regression assertion so +// the gated path for the second metric is covered explicitly: 6/4 = 1.5 exceeds +// the 1.20 threshold, so the gate must fail. +func TestBenchGate_AllocsRegression(t *testing.T) { + t.Parallel() + csv := ",allocs/op,CI,allocs/op,CI,vs base,P\nBenchAlloc,4,±0%,6,±0%,+50%,0.01\n" + if err := benchGate(csv, "1.20"); err == nil { + t.Fatal("benchGate(allocs/op 4->6) = nil, want a regression error") + } +} + +// TestBenchGate_InvalidThreshold covers the threshold-parse error branch: a +// non-numeric threshold cannot be a ratio, so benchGate reports it rather than +// silently passing. +func TestBenchGate_InvalidThreshold(t *testing.T) { + t.Parallel() + err := benchGate("", "not-a-number") + if err == nil { + t.Fatal("benchGate with a non-numeric threshold = nil, want an error") + } + if !strings.Contains(err.Error(), "invalid threshold") { + t.Fatalf("error = %v, want an invalid-threshold message", err) + } +} From eefba9c69d251c749bf3922f336fa4a9164a83b1 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Fri, 5 Jun 2026 12:21:26 -0400 Subject: [PATCH 09/10] docs: document SimplePaths combinatorial blowup and bounding guidance SimplePaths always terminates but enumerates a number of acyclic paths that grows combinatorially with graph branching, with no depth or count bound. Spell out the cost in godoc and point callers at ShortestPaths or a pruned model for dense definitions; this is the lighter honest fix than adding a bound option to a function that currently takes none. Signed-off-by: Joshua Temple --- state/analysis/paths.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/state/analysis/paths.go b/state/analysis/paths.go index 72240f9..da24c54 100644 --- a/state/analysis/paths.go +++ b/state/analysis/paths.go @@ -136,6 +136,16 @@ func ShortestPaths[S comparable, E comparable, C any](m *state.Machine[S, E, C]) // Paths to each target are returned in a deterministic order: discovered by a // declaration-order depth-first walk, then sorted by length and by their event // sequence so the set is reproducible. +// +// Cost: enumeration always terminates (a simple path never repeats a state), but +// the number of simple paths is combinatorial in the graph's branching, not +// linear in its size. A densely connected machine — many states each reachable +// from many others — can have a number of simple paths that grows factorially +// with the state count, so SimplePaths can allocate a very large result and run +// for a long time on such a model. There is no built-in depth or count bound. Use +// it on bounded, sparsely connected definitions (the typical hand-authored +// statechart); for a dense or generated model prefer ShortestPaths, or enumerate +// against a copy of the machine pruned to the states and transitions of interest. func SimplePaths[S comparable, E comparable, C any](m *state.Machine[S, E, C]) (map[string][]Path, error) { g, err := buildGraph(m) if err != nil { From 0b41781481c6c741a8fbcc467853542f03ef02e9 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Fri, 5 Jun 2026 12:25:56 -0400 Subject: [PATCH 10/10] test: add a wasm-guarded durable transition e2e joint Add the missing wasm cross-module joint: a state machine whose approve transition is gated by a WebAssembly-backed guard, driven through the durable runner and recovered from the store. It proves a foreign-engine guard composes with durable record/replay (the admitted order replays to approved, a below-threshold order is blocked through the same evaluator). The guest is built on demand with the Go wasm toolchain, so the test stays hermetic and deterministic. Signed-off-by: Joshua Temple --- e2e/e2e_test.go | 117 +++++++++++++++++++++++++++++ e2e/go.mod | 5 +- e2e/go.sum | 14 ++-- e2e/testdata/approvalguest/main.go | 78 +++++++++++++++++++ 4 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 e2e/testdata/approvalguest/main.go diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 92f8e66..bb9b2f6 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -4,6 +4,9 @@ import ( "context" "errors" "net" + "os" + "os/exec" + "path/filepath" "testing" "github.com/stablekernel/crucible/cluster" @@ -11,6 +14,7 @@ import ( "github.com/stablekernel/crucible/state" "github.com/stablekernel/crucible/state/expr" "github.com/stablekernel/crucible/transport" + "github.com/stablekernel/crucible/wasm" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/test/bufconn" @@ -84,6 +88,119 @@ func TestE2E_DurableCELAssignSurvivesRecovery(t *testing.T) { } } +// ---- wasm ⊗ state ⊗ durable: a WASM-backed guard survives record/replay ---- + +type approvalOrder struct { + Amount int64 `json:"amount"` + Status string `json:"status"` +} + +// buildApprovalGuest compiles the approval guard guest to wasip1/wasm with the +// standard Go toolchain (no committed binary, no TinyGo) and returns its bytes, +// mirroring the wasm package's own guest build so the e2e joint compiles its +// guard the same proven way. +func buildApprovalGuest(t *testing.T) []byte { + t.Helper() + dir := t.TempDir() + out := filepath.Join(dir, "approval.wasm") + cmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", out, "./testdata/approvalguest") + cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm") + if buildOut, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build approval guest: %v\n%s", err, buildOut) + } + b, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read built guest: %v", err) + } + return b +} + +// approvalMachine forges an order machine whose approve transition is gated by a +// WASM-backed guard (amount >= 100), authored through the full production flow +// (Forge → ToJSON → LoadFromJSON → Provide) so the foreign-engine guard resolves +// exactly like an in-tree one. +func approvalMachine(t *testing.T, mod *wasm.Module) *state.Machine[string, string, approvalOrder] { + t.Helper() + reg := state.NewRegistry[approvalOrder]() + node := wasm.Guard[string](reg, "approved", mod) + + def := state.Forge[string, string, approvalOrder]("approval"). + Guard("approved", func(state.GuardCtx[approvalOrder]) bool { return false }). // stub, replaced by Provide + State("pending"). + State("approved"). + Initial("pending"). + Transition("pending").On("approve").GoTo("approved").WhenExpr(node). + Quench() + + js, err := def.ToJSON() + if err != nil { + t.Fatalf("ToJSON: %v", err) + } + ir, err := state.LoadFromJSON[string, string, approvalOrder](js) + if err != nil { + t.Fatalf("LoadFromJSON: %v", err) + } + return ir.Provide(reg).Quench() +} + +// TestE2E_WASMGuardedDurableTransitionSurvivesRecovery drives a durable instance +// through a transition gated by a WebAssembly-backed guard, then recovers it from +// the store — proving a foreign-engine guard composes with the durable +// record/replay seam: the approved order persists at approved and replays +// deterministically, and a below-threshold order is blocked through the same +// WASM evaluator. It is hermetic (the guest is built on demand with the Go wasm +// toolchain) and deterministic (the guard is a pure predicate over context). +func TestE2E_WASMGuardedDurableTransitionSurvivesRecovery(t *testing.T) { + ctx := context.Background() + mod, err := wasm.Compile(ctx, buildApprovalGuest(t)) + if err != nil { + t.Fatalf("compile guard: %v", err) + } + t.Cleanup(func() { _ = mod.Close(ctx) }) + + m := approvalMachine(t, mod) + store := durable.NewMemStore() + runner := durable.NewRunner(m, store) + + // An at-threshold order: the WASM guard admits it, the durable runner records + // the transition, and recovery replays it to the same approved state. + const okID = "order-ok" + okH, err := runner.Start(ctx, okID, approvalOrder{Amount: 150, Status: "pending"}, state.WithInitialState("pending")) + if err != nil { + t.Fatalf("start ok order: %v", err) + } + if _, err = okH.Fire(ctx, "approve"); err != nil { + t.Fatalf("fire approve: %v", err) + } + if got := okH.Instance().Current(); got != "approved" { + t.Fatalf("WASM guard should admit amount 150; current=%q, want approved", got) + } + + rec, err := durable.Recover(ctx, m, store, okID) + if err != nil { + t.Fatalf("recover: %v", err) + } + if got := rec.Instance().Current(); got != "approved" { + t.Fatalf("recovered state = %q, want approved (WASM-guarded transition replayed)", got) + } + + // A below-threshold order: the same WASM guard blocks it, so the transition is + // rejected (a GuardFailedError) and the durable instance never leaves pending. + const lowID = "order-low" + lowH, err := runner.Start(ctx, lowID, approvalOrder{Amount: 50, Status: "pending"}, state.WithInitialState("pending")) + if err != nil { + t.Fatalf("start low order: %v", err) + } + _, err = lowH.Fire(ctx, "approve") + var guardErr *state.GuardFailedError + if !errors.As(err, &guardErr) { + t.Fatalf("fire approve (low) error = %v, want a *state.GuardFailedError from the WASM guard", err) + } + if got := lowH.Instance().Current(); got != "pending" { + t.Fatalf("WASM guard should block amount 50; current=%q, want pending", got) + } +} + // ---- cluster ⊗ transport ⊗ supervisor: supervised remote actor over gRPC ---- type pinger struct{} diff --git a/e2e/go.mod b/e2e/go.mod index 93ac0d9..d1359fe 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -13,6 +13,7 @@ replace ( github.com/stablekernel/crucible/state/expr => ../state/expr github.com/stablekernel/crucible/telemetry => ../telemetry github.com/stablekernel/crucible/transport => ../transport + github.com/stablekernel/crucible/wasm => ../wasm ) require ( @@ -26,6 +27,7 @@ require ( github.com/stablekernel/crucible/state/expr v0.0.0-00010101000000-000000000000 github.com/stablekernel/crucible/telemetry v0.0.0 github.com/stablekernel/crucible/transport v0.0.0-00010101000000-000000000000 + github.com/stablekernel/crucible/wasm v0.0.0-00010101000000-000000000000 google.golang.org/grpc v1.81.1 ) @@ -34,10 +36,11 @@ require ( github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/google/cel-go v0.28.1 // indirect github.com/kr/text v0.2.0 // indirect + github.com/tetratelabs/wazero v1.12.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20260209203927-2842357ff358 // indirect golang.org/x/net v0.51.0 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect diff --git a/e2e/go.sum b/e2e/go.sum index 651b60f..4452037 100644 --- a/e2e/go.sum +++ b/e2e/go.sum @@ -23,16 +23,18 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -41,8 +43,8 @@ golang.org/x/exp v0.0.0-20260209203927-2842357ff358 h1:kpfSV7uLwKJbFSEgNhWzGSL47 golang.org/x/exp v0.0.0-20260209203927-2842357ff358/go.mod h1:R3t0oliuryB5eenPWl3rrQxwnNM3WTwnsRZZiXLAAW8= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= diff --git a/e2e/testdata/approvalguest/main.go b/e2e/testdata/approvalguest/main.go new file mode 100644 index 0000000..e85ef0f --- /dev/null +++ b/e2e/testdata/approvalguest/main.go @@ -0,0 +1,78 @@ +//go:build wasip1 + +// Command approvalguest is a WebAssembly guest implementing a Crucible behavior +// guard over the JSON ABI: it reads a {"context": {"amount": N}} request and +// returns {"ok": bool}, admitting an order whose amount is at or above the +// approval threshold. The e2e wasm joint compiles it to wasip1/wasm on demand +// (GOOS=wasip1 GOARCH=wasm, -buildmode=c-shared) and runs it through wazero, so a +// guard whose truth lives in a foreign module gates a state-machine transition +// exactly like an in-tree guard. No binary is committed; the test builds it. +package main + +import ( + "encoding/json" + "unsafe" +) + +func main() {} + +// approvalThreshold is the amount at or above which an order is approved. The +// host test drives one order above it and one below it so both verdicts are +// exercised through the WASM evaluator. +const approvalThreshold = 100 + +// Fixed input and output buffers at stable linear-memory addresses, so the host +// writes the request to alloc's pointer and reads the response from eval's +// returned pointer without a real allocator — the convention the wasm package's +// own reference guest uses. +var ( + inBuf [16 << 10]byte + outBuf [16 << 10]byte +) + +func ptrOf(p *byte) uint32 { return uint32(uintptr(unsafe.Pointer(p))) } + +// alloc returns the address of the input buffer for the host to write size bytes +// into. The buffer is fixed; size must not exceed it. +// +//nolint:unparam // ABI: alloc must accept the requested size even though the buffer is fixed. +//go:wasmexport alloc +func alloc(size uint32) uint32 { + _ = size + return ptrOf(&inBuf[0]) +} + +// request is the guard envelope: the read-only context the guard evaluates. +type request struct { + Context struct { + Amount int64 `json:"amount"` + } `json:"context"` +} + +// response is the guard verdict envelope. +type response struct { + OK bool `json:"ok"` +} + +// eval reads the JSON request the host wrote at the input buffer, evaluates the +// approval predicate amount >= threshold, writes the JSON response into the +// output buffer, and returns a packed (outPtr<<32 | outLen). A malformed request +// is fail-safe: it returns ok=false rather than erroring. +// +//go:wasmexport eval +func eval(ptr, size uint32) uint64 { + _ = ptr // input is always at inBuf; the host wrote it there via alloc. + var req request + if err := json.Unmarshal(inBuf[:size], &req); err != nil { + return write(response{OK: false}) + } + return write(response{OK: req.Context.Amount >= approvalThreshold}) +} + +// write marshals the response into the output buffer and returns the packed +// pointer and length the host unpacks. +func write(resp response) uint64 { + b, _ := json.Marshal(resp) + n := copy(outBuf[:], b) + return uint64(ptrOf(&outBuf[0]))<<32 | uint64(n) +}