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
5 changes: 3 additions & 2 deletions durable/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,11 @@ func (h *Handle[S, E, C]) RunService(ctx context.Context, id string) (state.Fire
return state.FireResult[S]{}, false, nil
}

res, ok := h.svc.Tick(ctx, id)
if !ok {
results := h.svc.Tick(ctx, id)
if len(results) == 0 {
return state.FireResult[S]{}, false, nil
}
res := results[0]

// The settle re-fired the invocation's onDone / onError, which may enter a state
// that arms a timer, starts another service, or spawns an actor. Absorb those
Expand Down
20 changes: 12 additions & 8 deletions examples/fooddelivery/rig.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,22 +162,26 @@ func (r *Rig) SettleRefund(ctx context.Context, amount int64) state.FireResult[S
// (executing authorizeFn) and routes its outcome, rather than settling it with a
// fixed token. It returns the resulting FireResult and whether a service ran.
func (r *Rig) RunAuthorization(ctx context.Context) (state.FireResult[Stage], bool) {
fr, ok := r.run.Tick(ctx, r.authorizeID())
if ok {
r.absorb(ctx, fr.Effects)
results := r.run.Tick(ctx, r.authorizeID())
if len(results) == 0 {
return state.FireResult[Stage]{}, false
}
return fr, ok
fr := results[0]
r.absorb(ctx, fr.Effects)
return fr, true
}

// RunRefund runs the real refund service synchronously through the runner (executing
// refundFn) and routes its outcome. It returns the resulting FireResult and whether a
// service ran.
func (r *Rig) RunRefund(ctx context.Context) (state.FireResult[Stage], bool) {
fr, ok := r.run.Tick(ctx, r.refundID())
if ok {
r.absorb(ctx, fr.Effects)
results := r.run.Tick(ctx, r.refundID())
if len(results) == 0 {
return state.FireResult[Stage]{}, false
}
return fr, ok
fr := results[0]
r.absorb(ctx, fr.Effects)
return fr, true
}

// RunKitchen steps the kitchen actor to its final state, so its completion re-fires
Expand Down
4 changes: 2 additions & 2 deletions source/statemachine/drive.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,15 +167,15 @@ func emitEffects(ctx context.Context, sink Sink, effects []state.Effect) error {
// errors.Is(err, source.ErrInvalidForState); otherwise it reports false and the
// caller treats the error as transient.
func classifyFire[K comparable, E any](err error, event E, from K) (*source.GuardRejection, bool) {
var invalid *state.ErrInvalidTransition
var invalid *state.InvalidTransitionError
if errors.As(err, &invalid) {
return &source.GuardRejection{
Event: fmt.Sprint(event),
State: invalid.From,
Err: err,
}, true
}
var guard *state.ErrGuardFailed
var guard *state.GuardFailedError
if errors.As(err, &guard) {
return &source.GuardRejection{
Event: fmt.Sprint(event),
Expand Down
2 changes: 1 addition & 1 deletion source/statemachine/drivefunc.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
// flows back to the binding.
//
// A nil error and a result whose Err is nil is a successful transition; a result
// carrying a [state.ErrInvalidTransition] or [state.ErrGuardFailed] is the
// carrying a [state.InvalidTransitionError] or [state.GuardFailedError] is the
// state-aware rejection. Returning a non-nil error (not the FireResult.Err) is a
// transient failure resolving the instance — a nak.
type FireFunc[K comparable, E comparable] func(ctx context.Context, key K, event E) (state.FireResult[K], error)
Expand Down
37 changes: 30 additions & 7 deletions state/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ representative hot-path numbers.
preserves unknown fields on nested nodes (machine, state, transition, `Ref`,
`GuardNode`) so a document written by a newer build round-trips losslessly
through an older one, and rejects only a higher *major* schema version (the
typed `*ErrUnsupportedSchema`). IR encoding is deterministic (stable key order)
typed `*UnsupportedSchemaError`). IR encoding is deterministic (stable key order)
so a definition hashes and diffs reproducibly.
- Closed-enum extension policy. Every IR enum that may grow (guard op, state
kind, param type, descriptor kind, effect kind) has a documented
Expand Down Expand Up @@ -96,7 +96,7 @@ representative hot-path numbers.
on `NewEffectRegistry`) decodes it back to a concrete effect. Per the
closed-enum extension policy, an unrecognized effect kind is preserved verbatim
on load (surfaced as `UnknownEffect`) and rejected only at dispatch
(`EffectRegistry.Dispatchable` returns the typed `*ErrUnknownEffectKind`), never
(`EffectRegistry.Dispatchable` returns the typed `*UnknownEffectKindError`), never
silently dropped or applied. Effects remain data the host applies; the kernel
does not execute them. The `Effect` alias stays `any`, so bare domain effects
are unaffected.
Expand Down Expand Up @@ -220,7 +220,7 @@ representative hot-path numbers.
- **Declarative invoke + service registry.** A state declares
`Invocation{ID, Src, Input, OnDone, OnError}`; service implementations bind by
name through `Registry.Service` / `Builder.Service`, parallel to guards and
actions. An unbound service ref fails `Quench` with the typed `*ErrUnboundRef`
actions. An unbound service ref fails `Quench` with the typed `*UnboundRefError`
(`Kind: "service"`), consistent with unbound guards/actions. Authored via the
DSL `Invoke(src, ...InvokeOption)` whose outcomes are options —
`WithInvokeOnDone` / `WithInvokeOnError` — alongside `WithInput`,
Expand Down Expand Up @@ -333,8 +333,8 @@ representative hot-path numbers.
(e.g. `And(Or(g1, g2), Not(g3))`). Evaluation short-circuits exactly like a
plain multi-guard transition: `And` stops at the first false, `Or` at the
first true. A failing composite reports the failing leaf(s) when cheap, else
the composite, preserving the typed `ErrGuardFailed`; a leaf panic still
surfaces as `ErrGuardPanic`.
the composite, preserving the typed `GuardFailedError`; a leaf panic still
surfaces as `GuardPanicError`.
- **`stateIn(state)`.** A first-class, config-aware built-in guard, true when
the instance's active configuration includes the named state (its active
leaves and their ancestor spine), so it is correct for atomic, compound, and
Expand Down Expand Up @@ -370,7 +370,7 @@ representative hot-path numbers.
transitions until the configuration is stable, recording each as a Trace
microstep. The internal queue is macrostep-local, so `Fire` stays pure. An
unhandled raised event is ignored; a non-settling raise/eventless cycle fails fast
with the typed `ErrMicrostepOverflow`.
with the typed `MicrostepOverflowError`.
- DSL also gains `Always()` to author eventless transitions directly (previously
IR-only). The wildcard target, forbidden marker, reenter flag, and raised-event
list serialize in the IR and round-trip losslessly through JSON; `raise` is
Expand Down Expand Up @@ -453,6 +453,29 @@ representative hot-path numbers.
option that makes `Verify` collect all failing requirements in one pass instead
of failing fast is now `Aggregate()`. Replace `WithAggregate()` with
`Aggregate()` at the call site.
- **BREAKING: `ServiceRunner.Tick` now returns `[]FireResult[S]` instead of
`(FireResult[S], bool)`.** The three host drivers now share one advance-verb
shape: `Scheduler.Tick`, `ActorSystem.Tick`, and `ServiceRunner.Tick` all
return `[]FireResult[S]`. `ServiceRunner.Tick` returns a one-element slice when
the service settled and an empty slice when the id is not in flight. A caller
that checked the old `ok` bool now checks `len(results) > 0` and reads
`results[0]` for the routed result.
- **BREAKING: the struct error types are renamed from the `Err*` prefix to the
idiomatic `*Error` suffix.** The `Err*` prefix is the Go convention for sentinel
error values, not for struct types a caller inspects with `errors.As`; these are
all struct types with no sentinel vars. `ErrInvalidTransition`, `ErrGuardFailed`,
`ErrGuardPanic`, `ErrAssignPanic`, `ErrPolicyDenied`, `ErrUndeclaredState`,
`ErrUnboundRef`, `ErrActionFailed`, `ErrMicrostepOverflow`, `ErrNoPath`,
`ErrNoInitialState`, `ErrUnknownBuiltin`, `ErrUnboundActor`, `ErrUnsupportedSchema`,
`ErrUnknownEffectKind`, and `MultiRegionErr` become `InvalidTransitionError`,
`GuardFailedError`, `GuardPanicError`, `AssignPanicError`, `PolicyDeniedError`,
`UndeclaredStateError`, `UnboundRefError`, `ActionFailedError`,
`MicrostepOverflowError`, `NoPathError`, `NoInitialStateError`,
`UnknownBuiltinError`, `UnboundActorError`, `UnsupportedSchemaError`,
`UnknownEffectKindError`, and `MultiRegionError`, matching the already-correct
`WaitTimeoutError`, `SnapshotError`, `SnapshotVersionError`, and `VerifyError`.
Behavior and fields are unchanged; update the type name at each `errors.As`
target, type switch, and struct literal.
- The determinism and ordering contract is now explicit and frozen: emission
order is exit → transition → entry across the cascade, declaration order within
a set, fixed parallel-region order, and the run-to-completion interleave for
Expand All @@ -461,7 +484,7 @@ representative hot-path numbers.

### Fixed

- `Cast` returns the typed `*ErrInvalidTransition` consistently for an event that
- `Cast` returns the typed `*InvalidTransitionError` consistently for an event that
matches no transition, including inside parallel regions, so a caller can
distinguish "no transition" from other failures uniformly.
- On-entry lifecycle effects (`after` / `invoke` / actor `spawn`) are now emitted
Expand Down
2 changes: 1 addition & 1 deletion state/actor.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ func evalActorBuiltinAction(a Ref) (Effect, error) {
id, _ := a.Params[stopActorIDParam].(string)
return StopActor{ID: id}, nil
default:
return nil, &ErrUnknownBuiltin{Name: a.Name}
return nil, &UnknownBuiltinError{Name: a.Name}
}
}

Expand Down
2 changes: 1 addition & 1 deletion state/actor_comms.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,6 @@ func evalCommBuiltinAction(a Ref) (Effect, error) {
systemID, _ := a.Params[sendToSystemIDParam].(string)
return ForwardEvent{TargetID: target, SystemID: systemID}, nil
default:
return nil, &ErrUnknownBuiltin{Name: a.Name}
return nil, &UnknownBuiltinError{Name: a.Name}
}
}
4 changes: 2 additions & 2 deletions state/actor_escalation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,8 +409,8 @@ func TestActorEscalation_UnboundSrc_NoOnError_Escalates(t *testing.T) {
if esc == nil {
t.Fatal("unbound-src spawn failure was swallowed; LastEscalation = nil")
}
var unbound *state.ErrUnboundActor
var unbound *state.UnboundActorError
if !errors.As(esc, &unbound) {
t.Fatalf("escalation cause is not *ErrUnboundActor: %v", esc)
t.Fatalf("escalation cause is not *UnboundActorError: %v", esc)
}
}
2 changes: 1 addition & 1 deletion state/actor_system.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ func (s *ActorSystem[S, E, C]) spawn(ctx context.Context, e SpawnActor) {
s.mu.Unlock()

if !ok {
s.routeError(ctx, e, &ErrUnboundActor{Name: e.Src.Name})
s.routeError(ctx, e, &UnboundActorError{Name: e.Src.Name})
return
}
inst, err := behavior(e.Input)
Expand Down
16 changes: 8 additions & 8 deletions state/assign_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ func TestAssign_ParallelRegionFolds(t *testing.T) {
}

// TestAssign_ParallelRegionPanicStopsCommit asserts a region reducer that panics
// surfaces as OutcomeAssignFailed / *ErrAssignPanic and stops the commit — it
// surfaces as OutcomeAssignFailed / *AssignPanicError and stops the commit — it
// must not silently no-op, which was the prior behavior on the parallel path.
func TestAssign_ParallelRegionPanicStopsCommit(t *testing.T) {
m := state.Forge[string, string, acct]("par-assign-panic").
Expand All @@ -309,9 +309,9 @@ func TestAssign_ParallelRegionPanicStopsCommit(t *testing.T) {
if res.Err == nil {
t.Fatal("panicking region reducer should fail the fire")
}
var ap *state.ErrAssignPanic
var ap *state.AssignPanicError
if !errors.As(res.Err, &ap) {
t.Fatalf("error = %v, want *ErrAssignPanic", res.Err)
t.Fatalf("error = %v, want *AssignPanicError", res.Err)
}
if ap.AssignName != "boom" {
t.Fatalf("assign name = %q, want boom", ap.AssignName)
Expand Down Expand Up @@ -386,9 +386,9 @@ func TestAssign_PanicStopsCommit(t *testing.T) {
if res.Err == nil {
t.Fatal("panicking reducer should fail the fire")
}
var ap *state.ErrAssignPanic
var ap *state.AssignPanicError
if !errors.As(res.Err, &ap) {
t.Fatalf("error = %v, want *ErrAssignPanic", res.Err)
t.Fatalf("error = %v, want *AssignPanicError", res.Err)
}
if ap.AssignName != "boom" {
t.Fatalf("assign name = %q, want boom", ap.AssignName)
Expand All @@ -399,7 +399,7 @@ func TestAssign_PanicStopsCommit(t *testing.T) {
}

// TestAssign_UnboundRefFailsQuench asserts a transition that wires an unregistered
// assign fails Quench with the typed *ErrUnboundRef (Kind "assign"), exactly like
// assign fails Quench with the typed *UnboundRefError (Kind "assign"), exactly like
// an unbound guard, action, or service.
func TestAssign_UnboundRefFailsQuench(t *testing.T) {
defer func() {
Expand All @@ -411,9 +411,9 @@ func TestAssign_UnboundRefFailsQuench(t *testing.T) {
if !ok {
t.Fatalf("panic value is not an error: %T", r)
}
var ub *state.ErrUnboundRef
var ub *state.UnboundRefError
if !errors.As(err, &ub) {
t.Fatalf("panic = %v, want *ErrUnboundRef", err)
t.Fatalf("panic = %v, want *UnboundRefError", err)
}
if ub.Kind != "assign" || ub.Name != "ghost" {
t.Fatalf("unbound ref = {%q, %q}, want {assign, ghost}", ub.Kind, ub.Name)
Expand Down
2 changes: 1 addition & 1 deletion state/binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ func (r *Registry[C]) bindGuard(name string, b GuardBinding[C]) {
// The binding is wired into the same name path Guard uses, so a guard registered
// this way is indistinguishable to the kernel from a Go-func guard — it resolves
// by name at Provide/Quench, evaluates synchronously inside the pure Fire step, and
// surfaces a panic as the same typed ErrGuardPanic. The binding's EvalGuard is
// surfaces a panic as the same typed GuardPanicError. The binding's EvalGuard is
// adapted to a GuardFn over the in-process context view so the fire-time fast path
// (which reads r.guards) finds it; the binding is also recorded on the parallel
// binding seam so a future out-of-process transport can swap it under the same name.
Expand Down
6 changes: 3 additions & 3 deletions state/binding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,17 +173,17 @@ func TestRegistry_AssignBindingRecorded(t *testing.T) {
}

// TestEvalAssign_UnboundRefFailsClosed asserts the defensive fire-time guard:
// an assign ref with no bound reducer surfaces as a typed *ErrAssignPanic and
// an assign ref with no bound reducer surfaces as a typed *AssignPanicError and
// leaves the context unchanged, rather than silently dropping the fold.
func TestEvalAssign_UnboundRefFailsClosed(t *testing.T) {
m := Forge[string, string, bindOrder]("x").State("a").Initial("a").Quench()
next, err := m.evalAssign(Ref{Name: "ghost"}, bindOrder{Amount: 3}, nil)
if err == nil {
t.Fatal("unbound assign ref should fail")
}
var ap *ErrAssignPanic
var ap *AssignPanicError
if !errors.As(err, &ap) || ap.AssignName != "ghost" {
t.Fatalf("error = %v, want *ErrAssignPanic{ghost}", err)
t.Fatalf("error = %v, want *AssignPanicError{ghost}", err)
}
if next.Amount != 3 {
t.Fatalf("context changed on unbound assign: %+v", next)
Expand Down
4 changes: 2 additions & 2 deletions state/coreexpr.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,12 +322,12 @@ func appendErr(errs []error, err error) []error {
// node) against the live context value. It reads each operand — a field-ref
// resolves the dotted path against the entity by reflection; a literal yields its
// constant — then compares them with the op's typed comparison. An unresolvable
// field path or an incomparable pair surfaces a typed ErrGuardPanic, matching how
// field path or an incomparable pair surfaces a typed GuardPanicError, matching how
// a named guard's failure surfaces, so a malformed Core guard fails the firing
// deterministically rather than silently passing.
func evalCorePredicate[S comparable, C any](g *GuardNode[S], entity C) (bool, error) {
guardErr := func(reason string) error {
return &ErrGuardPanic{GuardName: renderGuardExpr(g), Recovered: reason}
return &GuardPanicError{GuardName: renderGuardExpr(g), Recovered: reason}
}

switch g.Op {
Expand Down
38 changes: 19 additions & 19 deletions state/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@ func TestErrorMessages(t *testing.T) {
err error
want string
}{
{&state.ErrInvalidTransition{From: "Draft", Event: "Submit", Reason: "no match"}, "invalid transition"},
{&state.ErrInvalidTransition{From: "Draft", To: "Approved", Event: "Approve", Reason: "guards failed"}, "from \"Draft\" to \"Approved\""},
{&state.ErrGuardFailed{GuardName: "hasReviewer", Reason: "nil"}, "guard \"hasReviewer\" failed"},
{&state.ErrGuardPanic{GuardName: "g", Recovered: "boom"}, "panicked"},
{&state.ErrPolicyDenied{PolicyName: "p", Reason: "denied"}, "policy \"p\" denied"},
{&state.ErrUndeclaredState{State: "Ghost"}, "undeclared state \"Ghost\""},
{&state.ErrUnboundRef{Kind: "guard", Name: "g"}, "unbound guard ref \"g\""},
{&state.ErrActionFailed{ActionName: "a", TransitionName: "t", Cause: errors.New("x")}, "action \"a\""},
{&state.ErrNoPath{From: "A", To: "B"}, "no path from \"A\" to \"B\""},
{&state.ErrNoInitialState{Machine: "m"}, "no CurrentStateFn"},
{&state.MultiRegionErr{Errors: []error{errors.New("r1"), errors.New("r2")}}, "2 regions errored"},
{&state.InvalidTransitionError{From: "Draft", Event: "Submit", Reason: "no match"}, "invalid transition"},
{&state.InvalidTransitionError{From: "Draft", To: "Approved", Event: "Approve", Reason: "guards failed"}, "from \"Draft\" to \"Approved\""},
{&state.GuardFailedError{GuardName: "hasReviewer", Reason: "nil"}, "guard \"hasReviewer\" failed"},
{&state.GuardPanicError{GuardName: "g", Recovered: "boom"}, "panicked"},
{&state.PolicyDeniedError{PolicyName: "p", Reason: "denied"}, "policy \"p\" denied"},
{&state.UndeclaredStateError{State: "Ghost"}, "undeclared state \"Ghost\""},
{&state.UnboundRefError{Kind: "guard", Name: "g"}, "unbound guard ref \"g\""},
{&state.ActionFailedError{ActionName: "a", TransitionName: "t", Cause: errors.New("x")}, "action \"a\""},
{&state.NoPathError{From: "A", To: "B"}, "no path from \"A\" to \"B\""},
{&state.NoInitialStateError{Machine: "m"}, "no CurrentStateFn"},
{&state.MultiRegionError{Errors: []error{errors.New("r1"), errors.New("r2")}}, "2 regions errored"},
{&state.VerifyError{Failures: []state.RequirementFailure{{Name: "req"}}}, "verify failed"},
}
for _, c := range cases {
Expand Down Expand Up @@ -64,20 +64,20 @@ func TestOutcomeString(t *testing.T) {
// errors.As/Unwrap.
func TestErrActionFailed_Unwrap(t *testing.T) {
cause := errors.New("root")
err := error(&state.ErrActionFailed{ActionName: "a", Cause: cause})
err := error(&state.ActionFailedError{ActionName: "a", Cause: cause})
if !errors.Is(err, cause) {
t.Fatal("ErrActionFailed should unwrap to its cause")
t.Fatal("ActionFailedError should unwrap to its cause")
}
}

// TestMultiRegionErr_Unwrap asserts a typed region error is reachable through
// the aggregate via errors.As.
func TestMultiRegionErr_Unwrap(t *testing.T) {
inner := &state.ErrGuardFailed{GuardName: "g", Reason: "no"}
agg := error(&state.MultiRegionErr{Errors: []error{inner}})
var gf *state.ErrGuardFailed
inner := &state.GuardFailedError{GuardName: "g", Reason: "no"}
agg := error(&state.MultiRegionError{Errors: []error{inner}})
var gf *state.GuardFailedError
if !errors.As(agg, &gf) {
t.Fatal("MultiRegionErr should expose region errors to errors.As")
t.Fatal("MultiRegionError should expose region errors to errors.As")
}
}

Expand Down Expand Up @@ -157,9 +157,9 @@ func TestQuenchError_Message(t *testing.T) {
func TestVerify_UndeclaredState(t *testing.T) {
m := buildDocMachine()
err := m.Verify(DocState(99), &Document{})
var us *state.ErrUndeclaredState
var us *state.UndeclaredStateError
if !errors.As(err, &us) {
t.Fatalf("err = %v, want *ErrUndeclaredState", err)
t.Fatalf("err = %v, want *UndeclaredStateError", err)
}
}

Expand Down
Loading
Loading