Skip to content
Open
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
name: CI

# A draft PR runs only the gate, which fails until the PR is marked ready, so work
# under review spends no CI time. Marking it ready (ready_for_review) runs the full
# CI, as does every later push to a ready PR.
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, ready_for_review]

permissions:
contents: read
Expand All @@ -23,6 +27,7 @@ jobs:
# lint/test/vuln/coverage/bench jobs entirely; the `gate` job below still runs
# and is the single required status check, so such PRs stay mergeable.
changes:
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-latest
outputs:
code: ${{ steps.detect.outputs.code }}
Expand Down Expand Up @@ -597,6 +602,14 @@ jobs:
needs: [changes, lint, tests, sink, source, vuln, coverage, docs]
runs-on: ubuntu-latest
steps:
# On a draft every other job is skipped, and a skipped required check would
# count as passing for this commit. Fail instead, so a PR can't be marked ready
# and merged before its CI has run.
- name: Fail on a draft
if: ${{ github.event.pull_request.draft }}
run: |
echo "::error::Draft PR: CI runs when it's marked ready for review."
exit 1
- name: Fail if a required job did not pass
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
run: |
Expand Down
9 changes: 6 additions & 3 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
name: CodeQL

# Static analysis (SAST) over the Go sources. Runs on every PR and on pushes to
# the default branch, plus a weekly scheduled scan so newly published query
# packs catch existing code. Results land in the code-scanning dashboard.
# Static analysis (SAST) over the Go sources. Runs on every ready PR (not a draft;
# marking a draft ready runs it) and on pushes to the default branch, plus a weekly
# scheduled scan so newly published query packs catch existing code. Results land
# in the code-scanning dashboard.
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
types: [opened, synchronize, reopened, ready_for_review]
schedule:
- cron: '24 6 * * 1'

Expand All @@ -22,6 +24,7 @@ concurrency:
jobs:
analyze:
name: Analyze (Go)
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-latest
permissions:
# Required to upload CodeQL results to the code-scanning dashboard.
Expand Down
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,16 @@ Run `mage -l` to list everything. Each target iterates the suite's modules.

Before you push, **tests, lint, vet, and `govulncheck` must all pass.** The
quickest way to verify locally is `mage check`. CI runs the same gates across a
OS matrix on every PR.
OS matrix on every ready PR. A draft PR runs only the `gate` check, which fails until
the PR is marked ready for review; marking it ready runs the full CI.

Docs-only pull requests (changing only `*.md`, `docs/`, `LICENSE`, or `NOTICE`)
skip the Go matrix automatically and are gated solely by the aggregate `gate`
check, so a README tweak doesn't spend the full test run.

## Performance

Performance regressions fail the build. On every pull request, CI runs the
Performance regressions fail the build. On every ready pull request, CI runs the
`state` benchmarks on **both your branch head and the PR base, on the same
runner**, then `benchstat`-diffs the two. Running both refs on one machine
cancels machine-to-machine variance, so a genuine regression stands apart from
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/authoring/actions-and-effects.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func notifyCustomer(ctx state.ActionCtx[Order]) (state.Effect, error) {
}
```

The crucial discipline: **the kernel performs no IO**. An action returns an `Effect` (plain data) and the kernel collects every effect into the `FireResult`. Your host loop reads that slice and dispatches the real work. This keeps `Fire` deterministic and pure: the same context and event always yield the same effects, which is what makes the engine testable, replayable, and safe to run anywhere.
The crucial discipline: **the kernel performs no IO**. An action returns an `Effect` (plain data) and the kernel collects every effect into the `FireResult`. Your host loop reads that slice and dispatches the real work. `ctx.Ctx` is the context of the `Fire` running the action. Pass it on if an action, guard or reducer ever has to call back into the host that drives the instance, so the host can tell the call comes from inside a step. Don't branch on it: a replay runs the same callbacks under a different context. This keeps `Fire` deterministic and pure: the same context and event always yield the same effects, which is what makes the engine testable, replayable, and safe to run anywhere.

```mermaid
stateDiagram-v2
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/authoring/actors.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ sidebar:

When a state needs to delegate work to a self-contained, concurrently-living unit, forge an **actor**: a child state machine the parent supervises through messages. Each actor has its own `(S, E, C)` types, runs independently, and reports back to the parent on completion.

An `ActorSystem` is the registry and supervisor. You bind a *behavior* (a factory that casts a fresh child instance) under a name, then a state invokes that name to spawn one.
An `ActorSystem` is the registry and supervisor. You bind a *behavior* (a factory that casts a fresh child instance) under a name, then a state invokes that name to spawn one. A behavior registered with `RegisterContext` also gets the context of the step spawning the actor, to pass to its child's `Cast` with `state.WithContext`.

```go
sys := state.NewActorSystem(orderInstance).
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/authoring/assigns.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Transition(Authorizing).On(Authorized).
Assign("recordHold")
```

The `AssignCtx` carries the prior `Entity`, the triggering `Event`, and the ref's static `Params`, so a reducer can fold event payloads into context (for example, folding a saga's refunded amount in via the `onDone` event).
The `AssignCtx` carries the prior `Entity`, the triggering `Event`, and the ref's static `Params`, so a reducer can fold event payloads into context (for example, folding a saga's refunded amount in via the `onDone` event). It also carries `Ctx`, the context of the `Fire` running the reducer, as guards and actions do in `GuardCtx` and `ActionCtx`. Don't branch on it: a replay runs the same callbacks under a different context.

```mermaid
stateDiagram-v2
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/authoring/guards.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Transition(Authorizing).On(Authorized).
GoTo(Active).Assign("recordHold")
```

Field predicates support `.Eq`, `.Ne`, `.Lt`, `.Le`, `.Gt`, `.Ge`, and `.In`, with operands built from `state.Str`, `state.Int`, `state.Float`, and `state.Bool`. `state.StateIn(s)` lets a guard depend on the active configuration. When a transition carries *both* `When` and `WhenExpr`, every named guard **and** the whole expression must evaluate true.
Field predicates support `.Eq`, `.Ne`, `.Lt`, `.Le`, `.Gt`, `.Ge`, and `.In`, with operands built from `state.Str`, `state.Int`, `state.Float`, and `state.Bool`. `state.StateIn(s)` lets a guard depend on the active configuration. A named guard also gets `Ctx`, the context of the `Fire` evaluating it, to pass on if it ever has to call back into the host driving the instance. Don't branch on it, its values, deadline or cancellation: a replay runs the same guards under a different context, and has to reach the same result. When a transition carries *both* `When` and `WhenExpr`, every named guard **and** the whole expression must evaluate true.

```mermaid
stateDiagram-v2
Expand Down
30 changes: 27 additions & 3 deletions state/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,33 @@ counts as an additive (minor) versus breaking (major) change. Use the

- **`FireEventData`** reports the payload a `WithEventData` option carries, so a host
that records a Fire can persist it. It's additive, so the next release is v1.1.0.
- **Callbacks get the step's context.** `GuardCtx`, `ActionCtx` and `AssignCtx` have a new
`Ctx` field: the context the `Fire` was given, so a callback can pass it to any call it
makes back into the host driving the instance. Outside a `Fire`, such as during
`PlanPath`, it's `context.Background()`, never nil. Callbacks see the context as
the middleware chain passes it down. The in-process bindings pass their `EvalGuard`,
`EvalAction` and `EvalAssign` context through. Don't branch on the context: a
replay runs the same callbacks under a different one.
- **`WithContext`** sets the context `Cast` gives the guards, actions and reducers that
settle the initial state: its values, without its cancellation or deadline, since
`Cast` settles to completion.
- **`ActorSystem.RegisterContext`** registers a `ContextActorBehavior`, an actor factory
that gets the context of the step spawning or restoring the actor, so it can `Cast`
its child `WithContext`. A child's own callbacks already get the context its
delivery carries.

### Changed

- Requires Go 1.27.3.
- **A guard registered with `BindGuard` gets the Fire's context** in `EvalGuard`, where it
used to get a background one. An error it returns after that context ended no longer
counts as a false verdict: the Fire fails with the context's error at once. Before,
another transition could be chosen and its actions and reducers run under the ended
context, until the next cancellation check rolled the Fire back. The Fire's error
names the guard and wraps the binding's own error, on event-driven and eventless
transitions alike. A Fire whose context ended while its eventless
transitions' guards ran now fails, whether or not one was chosen, where it could
succeed before.

## [1.0.0] — 2026-06-13

Expand Down Expand Up @@ -710,9 +733,10 @@ locks today's surface precisely so these can be added without a major bump.
future `.ToAll(...)` builder, backed by an optional `Targets` IR field, expresses
a fan-out; `To` keeps its current single-target meaning.
- `context.Context` on `Verify` / `PlanPath` / `Cast` / `Restore` and the snapshot
codecs: these take no `context.Context` at v1. A future `WithContext` option on
each existing option tail threads cancellation and deadlines without changing a
signature. (`RestoreActors` already takes a `context.Context`.)
codecs: these take no `context.Context` at v1 for cancellation or deadlines. A
future option threads them without changing a signature. (`RestoreActors` already
takes a `context.Context`. Unreleased: `Cast`'s `WithContext` gives the callbacks that
settle its initial state a context to pass on, but doesn't make `Cast` cancellable.)
- Relaxing `SnapshotActors`' non-quiesced refusal: it refuses to snapshot a
non-quiesced actor system by design. A future `SnapshotActorsOption` lets a host
opt into a defined non-quiesced capture; the strict refusal stays the default.
Expand Down
2 changes: 1 addition & 1 deletion state/actor_snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ func (s *ActorSystem[S, E, C]) restoreActor(ctx context.Context, snap actorSnaps
if !ok {
return nil
}
inst, err := behavior(snap.Input)
inst, err := behavior(ctx, snap.Input)
if err != nil {
return &SnapshotError{Op: "restore", State: snap.ID, Reason: "actor re-spawn failed: " + err.Error(), Cause: err}
}
Expand Down
26 changes: 21 additions & 5 deletions state/actor_system.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ import (
// WithInitialState derived from input.
type ActorBehavior func(input map[string]any) (ActorInstance, error)

// ContextActorBehavior is an ActorBehavior that also gets the context of the step
// spawning the actor (the parent's Fire, or the host call that absorbed the spawn), or
// of the RestoreActors call restoring it. A
// behavior that Casts its child passes it on with WithContext, so the callbacks that
// settle the child's initial state see it too. Register one with RegisterContext.
type ContextActorBehavior func(ctx context.Context, input map[string]any) (ActorInstance, error)

// ActorInstance is a running child actor as the ActorSystem sees it, with the
// child's own (S, E, C) generic parameters erased. A host obtains one by wrapping
// a Cast child *Instance with NewActor; the deterministic test driver and the
Expand Down Expand Up @@ -116,8 +123,9 @@ const parentActorID = "\x00parent"
// ActorSystem is the reusable host-driver that turns the kernel's SpawnActor /
// StopActor effects into running child-machine actors, owns each actor's mailbox,
// routes delivered events into mailboxes, steps actors via Fire, and re-fires the
// parent's onDone / onError when a child completes or fails. It is
// concurrency-safe. Construct one per parent instance with NewActorSystem, then
// parent's onDone / onError when a child completes or fails. Its own state is
// concurrency-safe, but settling a child Fires the parent Instance, which is not:
// drive the system and the parent from one goroutine at a time. Construct one per parent instance with NewActorSystem, then
// Register the child-machine behaviors that resolve SpawnActor Src refs; drive it
// by passing each Fire's effects (and the parent's StartEffects) to Absorb, and
// step actors with Deliver / Step.
Expand All @@ -130,7 +138,7 @@ type ActorSystem[S comparable, E comparable, C any] struct {
parent *Instance[S, E, C]

mu sync.Mutex
palette map[string]ActorBehavior
palette map[string]ContextActorBehavior
actors map[string]*runningActor[E]
bySystem map[string]string // systemId -> id

Expand Down Expand Up @@ -232,7 +240,7 @@ func (s *ActorSystem[S, E, C]) inspectorRef() Inspector {
func NewActorSystem[S comparable, E comparable, C any](parent *Instance[S, E, C]) *ActorSystem[S, E, C] {
return &ActorSystem[S, E, C]{
parent: parent,
palette: map[string]ActorBehavior{},
palette: map[string]ContextActorBehavior{},
actors: map[string]*runningActor[E]{},
bySystem: map[string]string{},
}
Expand All @@ -243,6 +251,14 @@ func NewActorSystem[S comparable, E comparable, C any](parent *Instance[S, E, C]
// analog of Registry.Service: a host registers each child machine it can spawn.
// Registering returns the system for chaining.
func (s *ActorSystem[S, E, C]) Register(src string, behavior ActorBehavior) *ActorSystem[S, E, C] {
return s.RegisterContext(src, func(_ context.Context, input map[string]any) (ActorInstance, error) {
return behavior(input)
})
}

// RegisterContext is Register for a behavior that gets the spawning step's context.
// Registering a src again, with either method, replaces its behavior.
func (s *ActorSystem[S, E, C]) RegisterContext(src string, behavior ContextActorBehavior) *ActorSystem[S, E, C] {
s.mu.Lock()
defer s.mu.Unlock()
s.palette[src] = behavior
Expand Down Expand Up @@ -323,7 +339,7 @@ func (s *ActorSystem[S, E, C]) spawn(ctx context.Context, e SpawnActor) {
s.routeError(ctx, e, &UnboundActorError{Name: e.Src.Name})
return
}
inst, err := behavior(e.Input)
inst, err := behavior(ctx, e.Input)
if err != nil {
s.routeError(ctx, e, err)
return
Expand Down
12 changes: 10 additions & 2 deletions state/assign.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ type AssignCtx[C any] struct {
Entity C
Event any
Params map[string]any
// Ctx is the context of the Fire running this callback, or one carrying the values
// of the Cast's (see WithContext) while it settles the initial state; context.Background() when there's
// none, such as a Cast without WithContext. It is never nil. Pass it to any call the callback makes
// back into the host that drives this instance, so the host can tell the call
// comes from inside a step. Don't branch on it, its values, deadline or
// cancellation: a replay runs the same callbacks under a different context, and must
// reach the same result.
Ctx context.Context
}

// AssignFn is the sole context writer: a total pure reducer producing the next
Expand Down Expand Up @@ -80,8 +88,8 @@ func inProcessAssign[C any](fn AssignFn[C]) AssignBinding[C] {
// EvalAssign reads the prior context from the request's context view and invokes
// the wrapped AssignFn, returning the new context. The view is read-only; the
// reducer receives the context by value and its return is the only output.
func (b assignFnBinding[C]) EvalAssign(_ context.Context, req AssignRequest[C]) (AssignResult[C], error) {
func (b assignFnBinding[C]) EvalAssign(ctx context.Context, req AssignRequest[C]) (AssignResult[C], error) {
entity, _ := req.Context.Raw().(C)
next := b.fn(AssignCtx[C]{Entity: entity, Event: req.Event, Params: req.Params})
next := b.fn(AssignCtx[C]{Entity: entity, Event: req.Event, Params: req.Params, Ctx: nonNil(ctx)})
return AssignResult[C]{Context: next}, nil
}
33 changes: 25 additions & 8 deletions state/binding.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package state

import "context"
import (
"context"
"fmt"
)

// This file makes behavior invocation a binding rather than a bare Go func
// pointer. A binding turns a serializable request envelope into a serializable
Expand Down Expand Up @@ -96,9 +99,9 @@ func inProcessGuard[C any](fn GuardFn[C]) GuardBinding[C] {

// EvalGuard reads the live entity from the request's context view and invokes the
// wrapped GuardFn. The view is read-only; the func receives the entity by value.
func (b guardFnBinding[C]) EvalGuard(_ context.Context, req GuardRequest[C]) (GuardResult, error) {
func (b guardFnBinding[C]) EvalGuard(ctx context.Context, req GuardRequest[C]) (GuardResult, error) {
entity, _ := req.Context.Raw().(C)
return GuardResult{OK: b.fn(GuardCtx[C]{Entity: entity, Params: req.Params})}, nil
return GuardResult{OK: b.fn(GuardCtx[C]{Entity: entity, Params: req.Params, Ctx: nonNil(ctx)})}, nil
}

// actionFnBinding is the default in-process ActionBinding: it adapts an ActionFn.
Expand All @@ -114,9 +117,9 @@ func inProcessAction[C any](fn ActionFn[C]) ActionBinding[C] {
// EvalAction invokes the wrapped ActionFn and lifts its single effect into the
// ActionResult envelope. Actions emit effects only; context is written through an
// assign, not here.
func (b actionFnBinding[C]) EvalAction(_ context.Context, req ActionRequest[C]) (ActionResult, error) {
func (b actionFnBinding[C]) EvalAction(ctx context.Context, req ActionRequest[C]) (ActionResult, error) {
entity, _ := req.Context.Raw().(C)
eff, err := b.fn(ActionCtx[C]{Entity: entity, Params: req.Params})
eff, err := b.fn(ActionCtx[C]{Entity: entity, Params: req.Params, Ctx: nonNil(ctx)})
if err != nil {
return ActionResult{}, err
}
Expand Down Expand Up @@ -176,18 +179,32 @@ func (r *Registry[C]) bindGuard(name string, b GuardBinding[C]) {
// (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.
//
// EvalGuard is called with a background context and the in-process context view;
// EvalGuard is called with the context of the Fire evaluating the guard (the
// GuardCtx.Ctx the kernel hands the adapter) and the in-process context view;
// an error it returns is treated as a false verdict, matching how a Go guard that
// cannot decide yields false rather than transitioning. An optional Describe option
// cannot decide yields false rather than transitioning. The exception is an error
// returned once that context has ended: the Fire then fails with the context's error,
// as it would at its next cancellation check, rather than take another transition.
// "That context" is the one the guard got: the Fire's, as the middleware chain passed
// it down, so a middleware that substitutes a context it cancels can end a Fire this
// way although the Fire's own context is live. An optional Describe option
// adds palette metadata exactly as Guard does.
func (r *Registry[C]) BindGuard(name string, b GuardBinding[C], opts ...DescribeOption) *Registry[C] {
r.guards[name] = func(gctx GuardCtx[C]) bool {
res, err := b.EvalGuard(context.Background(), GuardRequest[C]{
ctx := nonNil(gctx.Ctx)
res, err := b.EvalGuard(ctx, GuardRequest[C]{
Name: name,
Params: gctx.Params,
Context: newInProcessView(gctx.Entity),
})
if err != nil {
// A binding that failed once the Fire's context ended has no verdict. Reading it
// as false would choose another transition and run its callbacks under the ended
// context before the Fire's next cancellation check rolled it back. Surface the
// context's error instead, so the Fire stops here.
if cerr := ctx.Err(); cerr != nil {
panic(guardCanceled{err: fmt.Errorf("%w (guard %q: %w)", cerr, name, err)})
}
return false
}
return res.OK
Expand Down
Loading
Loading