Skip to content

Commit bd9165a

Browse files
committed
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 <joshua.temple@stablekernel.com>
1 parent beb2af2 commit bd9165a

4 files changed

Lines changed: 207 additions & 7 deletions

File tree

e2e/e2e_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@ import (
44
"context"
55
"errors"
66
"net"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
710
"testing"
811

912
"github.com/stablekernel/crucible/cluster"
1013
"github.com/stablekernel/crucible/durable"
1114
"github.com/stablekernel/crucible/state"
1215
"github.com/stablekernel/crucible/state/expr"
1316
"github.com/stablekernel/crucible/transport"
17+
"github.com/stablekernel/crucible/wasm"
1418
"google.golang.org/grpc"
1519
"google.golang.org/grpc/credentials/insecure"
1620
"google.golang.org/grpc/test/bufconn"
@@ -84,6 +88,119 @@ func TestE2E_DurableCELAssignSurvivesRecovery(t *testing.T) {
8488
}
8589
}
8690

91+
// ---- wasm ⊗ state ⊗ durable: a WASM-backed guard survives record/replay ----
92+
93+
type approvalOrder struct {
94+
Amount int64 `json:"amount"`
95+
Status string `json:"status"`
96+
}
97+
98+
// buildApprovalGuest compiles the approval guard guest to wasip1/wasm with the
99+
// standard Go toolchain (no committed binary, no TinyGo) and returns its bytes,
100+
// mirroring the wasm package's own guest build so the e2e joint compiles its
101+
// guard the same proven way.
102+
func buildApprovalGuest(t *testing.T) []byte {
103+
t.Helper()
104+
dir := t.TempDir()
105+
out := filepath.Join(dir, "approval.wasm")
106+
cmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", out, "./testdata/approvalguest")
107+
cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm")
108+
if buildOut, err := cmd.CombinedOutput(); err != nil {
109+
t.Fatalf("build approval guest: %v\n%s", err, buildOut)
110+
}
111+
b, err := os.ReadFile(out)
112+
if err != nil {
113+
t.Fatalf("read built guest: %v", err)
114+
}
115+
return b
116+
}
117+
118+
// approvalMachine forges an order machine whose approve transition is gated by a
119+
// WASM-backed guard (amount >= 100), authored through the full production flow
120+
// (Forge → ToJSON → LoadFromJSON → Provide) so the foreign-engine guard resolves
121+
// exactly like an in-tree one.
122+
func approvalMachine(t *testing.T, mod *wasm.Module) *state.Machine[string, string, approvalOrder] {
123+
t.Helper()
124+
reg := state.NewRegistry[approvalOrder]()
125+
node := wasm.Guard[string](reg, "approved", mod)
126+
127+
def := state.Forge[string, string, approvalOrder]("approval").
128+
Guard("approved", func(state.GuardCtx[approvalOrder]) bool { return false }). // stub, replaced by Provide
129+
State("pending").
130+
State("approved").
131+
Initial("pending").
132+
Transition("pending").On("approve").GoTo("approved").WhenExpr(node).
133+
Quench()
134+
135+
js, err := def.ToJSON()
136+
if err != nil {
137+
t.Fatalf("ToJSON: %v", err)
138+
}
139+
ir, err := state.LoadFromJSON[string, string, approvalOrder](js)
140+
if err != nil {
141+
t.Fatalf("LoadFromJSON: %v", err)
142+
}
143+
return ir.Provide(reg).Quench()
144+
}
145+
146+
// TestE2E_WASMGuardedDurableTransitionSurvivesRecovery drives a durable instance
147+
// through a transition gated by a WebAssembly-backed guard, then recovers it from
148+
// the store — proving a foreign-engine guard composes with the durable
149+
// record/replay seam: the approved order persists at approved and replays
150+
// deterministically, and a below-threshold order is blocked through the same
151+
// WASM evaluator. It is hermetic (the guest is built on demand with the Go wasm
152+
// toolchain) and deterministic (the guard is a pure predicate over context).
153+
func TestE2E_WASMGuardedDurableTransitionSurvivesRecovery(t *testing.T) {
154+
ctx := context.Background()
155+
mod, err := wasm.Compile(ctx, buildApprovalGuest(t))
156+
if err != nil {
157+
t.Fatalf("compile guard: %v", err)
158+
}
159+
t.Cleanup(func() { _ = mod.Close(ctx) })
160+
161+
m := approvalMachine(t, mod)
162+
store := durable.NewMemStore()
163+
runner := durable.NewRunner(m, store)
164+
165+
// An at-threshold order: the WASM guard admits it, the durable runner records
166+
// the transition, and recovery replays it to the same approved state.
167+
const okID = "order-ok"
168+
okH, err := runner.Start(ctx, okID, approvalOrder{Amount: 150, Status: "pending"}, state.WithInitialState("pending"))
169+
if err != nil {
170+
t.Fatalf("start ok order: %v", err)
171+
}
172+
if _, err = okH.Fire(ctx, "approve"); err != nil {
173+
t.Fatalf("fire approve: %v", err)
174+
}
175+
if got := okH.Instance().Current(); got != "approved" {
176+
t.Fatalf("WASM guard should admit amount 150; current=%q, want approved", got)
177+
}
178+
179+
rec, err := durable.Recover(ctx, m, store, okID)
180+
if err != nil {
181+
t.Fatalf("recover: %v", err)
182+
}
183+
if got := rec.Instance().Current(); got != "approved" {
184+
t.Fatalf("recovered state = %q, want approved (WASM-guarded transition replayed)", got)
185+
}
186+
187+
// A below-threshold order: the same WASM guard blocks it, so the transition is
188+
// rejected (a GuardFailedError) and the durable instance never leaves pending.
189+
const lowID = "order-low"
190+
lowH, err := runner.Start(ctx, lowID, approvalOrder{Amount: 50, Status: "pending"}, state.WithInitialState("pending"))
191+
if err != nil {
192+
t.Fatalf("start low order: %v", err)
193+
}
194+
_, err = lowH.Fire(ctx, "approve")
195+
var guardErr *state.GuardFailedError
196+
if !errors.As(err, &guardErr) {
197+
t.Fatalf("fire approve (low) error = %v, want a *state.GuardFailedError from the WASM guard", err)
198+
}
199+
if got := lowH.Instance().Current(); got != "pending" {
200+
t.Fatalf("WASM guard should block amount 50; current=%q, want pending", got)
201+
}
202+
}
203+
87204
// ---- cluster ⊗ transport ⊗ supervisor: supervised remote actor over gRPC ----
88205

89206
type pinger struct{}

e2e/go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ replace (
1313
github.com/stablekernel/crucible/state/expr => ../state/expr
1414
github.com/stablekernel/crucible/telemetry => ../telemetry
1515
github.com/stablekernel/crucible/transport => ../transport
16+
github.com/stablekernel/crucible/wasm => ../wasm
1617
)
1718

1819
require (
@@ -26,6 +27,7 @@ require (
2627
github.com/stablekernel/crucible/state/expr v0.0.0-00010101000000-000000000000
2728
github.com/stablekernel/crucible/telemetry v0.0.0
2829
github.com/stablekernel/crucible/transport v0.0.0-00010101000000-000000000000
30+
github.com/stablekernel/crucible/wasm v0.0.0-00010101000000-000000000000
2931
google.golang.org/grpc v1.81.1
3032
)
3133

@@ -34,10 +36,11 @@ require (
3436
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
3537
github.com/google/cel-go v0.28.1 // indirect
3638
github.com/kr/text v0.2.0 // indirect
39+
github.com/tetratelabs/wazero v1.12.0 // indirect
3740
go.yaml.in/yaml/v3 v3.0.4 // indirect
3841
golang.org/x/exp v0.0.0-20260209203927-2842357ff358 // indirect
3942
golang.org/x/net v0.51.0 // indirect
40-
golang.org/x/sys v0.42.0 // indirect
43+
golang.org/x/sys v0.45.0 // indirect
4144
golang.org/x/text v0.35.0 // indirect
4245
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
4346
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect

e2e/go.sum

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,18 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
2323
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
2424
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
2525
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
26+
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
27+
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
2628
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
2729
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
28-
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
29-
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
30+
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
31+
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
3032
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
3133
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
3234
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
3335
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
34-
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
35-
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
36+
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
37+
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
3638
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
3739
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
3840
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
4143
golang.org/x/exp v0.0.0-20260209203927-2842357ff358/go.mod h1:R3t0oliuryB5eenPWl3rrQxwnNM3WTwnsRZZiXLAAW8=
4244
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
4345
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
44-
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
45-
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
46+
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
47+
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
4648
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
4749
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
4850
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=

e2e/testdata/approvalguest/main.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
//go:build wasip1
2+
3+
// Command approvalguest is a WebAssembly guest implementing a Crucible behavior
4+
// guard over the JSON ABI: it reads a {"context": {"amount": N}} request and
5+
// returns {"ok": bool}, admitting an order whose amount is at or above the
6+
// approval threshold. The e2e wasm joint compiles it to wasip1/wasm on demand
7+
// (GOOS=wasip1 GOARCH=wasm, -buildmode=c-shared) and runs it through wazero, so a
8+
// guard whose truth lives in a foreign module gates a state-machine transition
9+
// exactly like an in-tree guard. No binary is committed; the test builds it.
10+
package main
11+
12+
import (
13+
"encoding/json"
14+
"unsafe"
15+
)
16+
17+
func main() {}
18+
19+
// approvalThreshold is the amount at or above which an order is approved. The
20+
// host test drives one order above it and one below it so both verdicts are
21+
// exercised through the WASM evaluator.
22+
const approvalThreshold = 100
23+
24+
// Fixed input and output buffers at stable linear-memory addresses, so the host
25+
// writes the request to alloc's pointer and reads the response from eval's
26+
// returned pointer without a real allocator — the convention the wasm package's
27+
// own reference guest uses.
28+
var (
29+
inBuf [16 << 10]byte
30+
outBuf [16 << 10]byte
31+
)
32+
33+
func ptrOf(p *byte) uint32 { return uint32(uintptr(unsafe.Pointer(p))) }
34+
35+
// alloc returns the address of the input buffer for the host to write size bytes
36+
// into. The buffer is fixed; size must not exceed it.
37+
//
38+
//nolint:unparam // ABI: alloc must accept the requested size even though the buffer is fixed.
39+
//go:wasmexport alloc
40+
func alloc(size uint32) uint32 {
41+
_ = size
42+
return ptrOf(&inBuf[0])
43+
}
44+
45+
// request is the guard envelope: the read-only context the guard evaluates.
46+
type request struct {
47+
Context struct {
48+
Amount int64 `json:"amount"`
49+
} `json:"context"`
50+
}
51+
52+
// response is the guard verdict envelope.
53+
type response struct {
54+
OK bool `json:"ok"`
55+
}
56+
57+
// eval reads the JSON request the host wrote at the input buffer, evaluates the
58+
// approval predicate amount >= threshold, writes the JSON response into the
59+
// output buffer, and returns a packed (outPtr<<32 | outLen). A malformed request
60+
// is fail-safe: it returns ok=false rather than erroring.
61+
//
62+
//go:wasmexport eval
63+
func eval(ptr, size uint32) uint64 {
64+
_ = ptr // input is always at inBuf; the host wrote it there via alloc.
65+
var req request
66+
if err := json.Unmarshal(inBuf[:size], &req); err != nil {
67+
return write(response{OK: false})
68+
}
69+
return write(response{OK: req.Context.Amount >= approvalThreshold})
70+
}
71+
72+
// write marshals the response into the output buffer and returns the packed
73+
// pointer and length the host unpacks.
74+
func write(resp response) uint64 {
75+
b, _ := json.Marshal(resp)
76+
n := copy(outBuf[:], b)
77+
return uint64(ptrOf(&outBuf[0]))<<32 | uint64(n)
78+
}

0 commit comments

Comments
 (0)