Skip to content

Commit c5199e0

Browse files
committed
Merge branch 'feat/state-v1-freeze': crucible/state v1.0 freeze-ready RC
Resolves the v1.0 freeze-readiness gap analysis: parallel-region commit-path correctness (K1-K7, T1/T4/T5), frozen public surface (B1-B6, A1/A2, C1, D1/D2), analysis/evolution/conformance correctness with advisory banners, and resume-equivalence/determinism property tests. Not tagged; the v1.0.0 lock is pending human sign-off. Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
2 parents d2b148e + 81fef97 commit c5199e0

72 files changed

Lines changed: 6971 additions & 212 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

state/CHANGELOG.md

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,142 @@ A machine definition is treated as a schema: see the
99
counts as an additive (minor) versus breaking (major) change. Use the
1010
`state/evolution` package to classify a machine change and decide the bump.
1111

12+
## [Unreleased] — v1.0.0 release candidate
13+
14+
Freeze-ready, pending human sign-off. This release candidate resolves the v1.0
15+
freeze-readiness gap analysis: the parallel-region commit path now matches the
16+
documented semantics, the serialized IR round-trips losslessly, and the frozen
17+
public surface is locked. It is intentionally **not tagged**; the data model,
18+
serialized IR, context model, effect envelope, and emission-ordering contract are
19+
ready to freeze on sign-off. The `analysis`, `evolution`, `conformance`, and
20+
`verify` subpackages ship as advisory (not part of the frozen contract).
21+
22+
### Fixed
23+
24+
- A `Raise` declared on a region-internal transition is now delivered instead of
25+
silently dropped (kernel parallel-region commit path).
26+
- A region-internal transition targeting a same-region history pseudostate now
27+
resolves history correctly instead of leaving the pseudostate permanently stuck as
28+
an active leaf.
29+
- A compound nested inside a parallel region now emits its done event and fires
30+
`OnDone` when its leaf reaches final.
31+
- A parallel state that completes inside an enclosing compound now cascades that
32+
compound's `OnDone` (and any guarded completion transition) up the spine, instead
33+
of silently dropping it; the parallel's own `OnDone` still fires exactly once.
34+
- Region transition and entry actions now observe the threaded, exit-assign-folded
35+
context, consistent with the main commit path.
36+
- Eventless (`Always`) transitions now fire in every active parallel region and no
37+
longer collapse a sibling region's configuration.
38+
- Exiting a parallel state now runs every active region leaf's `OnExit` actions
39+
innermost-first, in declaration order; an event handled only by a sibling region
40+
of an outer parallel is now delivered through nested parallels correctly.
41+
- A panicking host action is recovered into a typed `ActionPanicError` instead of
42+
crashing `Fire`.
43+
- An event matched by a guard-failed candidate inside a region now bubbles to the
44+
parallel-state-level handler instead of causing the fire to fail silently.
45+
- The raised-event queue is reset when a macrostep errors, so a queued event no
46+
longer leaks into the next `Fire`.
47+
- Effects are emitted only on a fully successful `Fire`; a failed fire emits no
48+
effects (transactional effect emission on the main commit path).
49+
- `Snapshot`/`Restore` preserves bounded-history (`WithHistory(n)`) retention
50+
instead of reverting to unbounded.
51+
- `SnapshotActors` refuses a non-quiesced actor tree with a typed
52+
`NonQuiescentActorError` instead of silently dropping queued messages.
53+
- `Trace.AssignsApplied` is aggregated across all microsteps of a macrostep instead
54+
of reporting only the triggering microstep's assigns.
55+
- `Region`, `Invocation`, and `IOSpec` IR nodes now preserve unknown JSON fields and
56+
`Meta` losslessly, making the lossless round-trip guarantee true for every
57+
IR-reachable node type.
58+
- `GuardPanicError` and `AssignPanicError` now `Unwrap` to the recovered error, and
59+
`SnapshotError` exposes its cause, so `errors.As`/`errors.Is` can reach a wrapped
60+
sentinel through any of them.
61+
- `SchemaOf` returns an honest `SchemaAny` for interface, func, chan, and complex
62+
types instead of coercing them to `SchemaString`.
63+
- Builder cursor consumers (`When`, `Do`, `Assign`, `Raise`, `GoTo`, and friends)
64+
now panic with an actionable construction-time message when no transition is open,
65+
instead of silently dropping the call.
66+
- `analysis` honors wildcard (`OnAny`) edges and excludes forbidden (`Forbid` /
67+
`ForbidAny`) edges from reachability and dead-end analysis, removing false
68+
unreachable and dead-end findings.
69+
- `evolution` now detects transition order/priority, guard-operator structure,
70+
initial-child, history, context-schema, and eventless-edge changes, and classifies
71+
any unmodeled structural difference as breaking (fail-safe), closing silent
72+
under-reporting of behavior-changing edits.
73+
- `conformance` compares effects order-sensitively and payload-aware, and captures
74+
trace and final context, so it no longer passes a reordering or wrong-payload
75+
regression.
76+
- The pointer-context determinism diagnostic is advisory and is not rejected under
77+
`Quench(Strict())`; pointer context remains a supported escape hatch.
78+
79+
### Added
80+
81+
- Typed errors `ActionPanicError`, `RegionEscapeError`, `HistoryCrossRegionError`,
82+
and `NonQuiescentActorError`; `Quench` now rejects a region-escaping transition
83+
target and a cross-region history target at build time.
84+
- `WithRestoreInspector` and `WithRestoreLogger` restore options to re-attach
85+
observability after `Restore`.
86+
- `SchemaAny` schema kind; `Cause` field on `SnapshotError`.
87+
- A v1.0 interface-surface freeze: `ContextView` is sealed (crucible-only) while
88+
`Clock`, `ContextCodec`, `Snapshotter`, and `ActorInstance` are documented as
89+
frozen, host-implementable interfaces grown post-v1 via optional interfaces, with
90+
compile-time conformance assertions.
91+
- `analysis` finding kinds `KindUndefinedTarget` and `KindInternalError`.
92+
- `conformance` `WithSnapshotSink` option and `AssertEffectsPayloads`.
93+
- `verify` kind-specific `Finding` accessors (`IsReachable`, `Holds`, `Violated`,
94+
`Covered`).
95+
- An advisory pointer-context determinism diagnostic surfaced through Temper/Assay.
96+
97+
### Changed
98+
99+
- `WaitMode`, `HistoryType`, and `ActorKind` integer wire values are documented as
100+
frozen and append-only; `JournalRandom` is specified to ride
101+
`JournalEntry.Payload`.
102+
103+
### Documentation
104+
105+
- Stability banners: `analysis`, `evolution`, `conformance`, `verify`, and
106+
`verify/symbolic` are documented as advisory (not part of the frozen contract);
107+
`expr` documents its guard-expression semantics and deterministic evaluation
108+
environment as part of the v1.0 contract.
109+
- `verify` docs corrected to state that bounded simulation enumerates
110+
configurations (not traces), the covering suite is a structural guarantee, and
111+
only the holding verdict is exact.
112+
- Documented that `Instance` is not safe for concurrent use; that timer absolute
113+
deadlines are a host concern (`ResumeEffects` re-arms at the full declared delay
114+
while `durable` persists deadlines); the guard eval-error asymmetry (eventless
115+
guards fail closed, event-driven guards fail loud); the v1.0 stability scope; and
116+
the known limitation that entering a compound via initial descent onto a final
117+
leaf does not raise that compound's done event.
118+
- `Trace.PoliciesEvaluated` documented as reserved (always empty in v1.0).
119+
120+
### Tests
121+
122+
- Exhaustive symbolic op-table regression tests over every guard operator (the only
123+
unsoundness-capable code path).
124+
- Seeded property tests for snapshot resume-equivalence and parallel-region
125+
determinism.
126+
- Regression tests porting the parallel-region kernel probes: Raise-in-region,
127+
history-into-region, interior-compound done, sibling-region eventless and exit,
128+
nested-parallel delivery, and a simultaneous-region-completion pin.
129+
130+
### Deferred to a future release (with reason)
131+
132+
- Entering a compound via initial descent directly onto a final leaf does not raise
133+
that compound's done event (documented known limitation; additively fixable, but
134+
emission-changing, so deferred to an opt-in minor).
135+
- The transactional-effects fix is scoped to the main commit path; the region
136+
commit path retains partial-emit-on-error behavior at the freeze.
137+
- `verify.Finding.Reachable` polarity reshape (verify is advisory; additive
138+
kind-specific accessors are provided instead of a breaking field change).
139+
- `conformance` `Assertion.Expected`/`Event` payload reshape and auto-derived effect
140+
assertions in generated goldens (advisory; needs a host entity/codec, which would
141+
be a breaking change).
142+
- Snapshot-carried absolute timer deadlines (`Pending.TimerDeadlines`) — documented
143+
as a host concern in v1.0; additive in a later release.
144+
- Post-v1.0 performance items: lazy hot-path trace-string construction, ref-slice
145+
copy-on-write, trace-history bounding, and random-IR fixpoint and
146+
eventless-termination fuzzing.
147+
12148
## [1.0.0]
13149

14150
The first stable release. The 0.2.0 to 1.0.0 step finalizes the breaking changes

state/action_panic_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package state_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
"github.com/stablekernel/crucible/state"
9+
)
10+
11+
// apctx is a tiny entity for the action-panic regression tests.
12+
type apctx struct{}
13+
14+
// errActionBoom is a sentinel error a panicking action panics with, so the test
15+
// can assert errors.Is reaches it through ActionPanicError.Unwrap.
16+
var errActionBoom = errors.New("action boom")
17+
18+
// TestActionPanic_OnEntrySurfacesTyped asserts a panicking OnEntry action is
19+
// recovered into a *ActionPanicError on FireResult.Err rather than crashing Fire.
20+
func TestActionPanic_OnEntrySurfacesTyped(t *testing.T) {
21+
m := state.Forge[string, string, apctx]("ap-entry").
22+
Action("boom", func(state.ActionCtx[apctx]) (state.Effect, error) { panic("kaboom") }).
23+
State("from").
24+
State("to").OnEntry("boom").
25+
Transition("from").On("go").GoTo("to").
26+
Initial("from").
27+
Quench()
28+
29+
inst := m.Cast(apctx{}, state.WithInitialState("from"))
30+
res := inst.Fire(context.Background(), "go")
31+
32+
if res.Err == nil {
33+
t.Fatalf("want non-nil FireResult.Err from panicking OnEntry action")
34+
}
35+
var ap *state.ActionPanicError
36+
if !errors.As(res.Err, &ap) {
37+
t.Fatalf("want *ActionPanicError, got %T: %v", res.Err, res.Err)
38+
}
39+
if ap.ActionName != "boom" {
40+
t.Fatalf("ActionName = %q, want %q", ap.ActionName, "boom")
41+
}
42+
}
43+
44+
// TestActionPanic_TransitionActionSurfacesTyped asserts a panicking transition
45+
// action (Do) is recovered into a *ActionPanicError on FireResult.Err.
46+
func TestActionPanic_TransitionActionSurfacesTyped(t *testing.T) {
47+
m := state.Forge[string, string, apctx]("ap-trans").
48+
Action("boom", func(state.ActionCtx[apctx]) (state.Effect, error) { panic("kaboom") }).
49+
State("from").
50+
State("to").
51+
Transition("from").On("go").GoTo("to").Do("boom").
52+
Initial("from").
53+
Quench()
54+
55+
inst := m.Cast(apctx{}, state.WithInitialState("from"))
56+
res := inst.Fire(context.Background(), "go")
57+
58+
if res.Err == nil {
59+
t.Fatalf("want non-nil FireResult.Err from panicking transition action")
60+
}
61+
var ap *state.ActionPanicError
62+
if !errors.As(res.Err, &ap) {
63+
t.Fatalf("want *ActionPanicError, got %T: %v", res.Err, res.Err)
64+
}
65+
if ap.ActionName != "boom" {
66+
t.Fatalf("ActionName = %q, want %q", ap.ActionName, "boom")
67+
}
68+
}
69+
70+
// TestActionPanic_UnwrapsInnerError asserts that when an action panics with a
71+
// sentinel error value, errors.Is reaches that inner error through
72+
// ActionPanicError.Unwrap, and errors.As still reaches the *ActionPanicError.
73+
func TestActionPanic_UnwrapsInnerError(t *testing.T) {
74+
m := state.Forge[string, string, apctx]("ap-unwrap").
75+
Action("boom", func(state.ActionCtx[apctx]) (state.Effect, error) { panic(errActionBoom) }).
76+
State("from").
77+
State("to").
78+
Transition("from").On("go").GoTo("to").Do("boom").
79+
Initial("from").
80+
Quench()
81+
82+
inst := m.Cast(apctx{}, state.WithInitialState("from"))
83+
res := inst.Fire(context.Background(), "go")
84+
85+
var ap *state.ActionPanicError
86+
if !errors.As(res.Err, &ap) {
87+
t.Fatalf("want *ActionPanicError, got %T: %v", res.Err, res.Err)
88+
}
89+
if !errors.Is(res.Err, errActionBoom) {
90+
t.Fatalf("errors.Is did not reach errActionBoom through Unwrap: %v", res.Err)
91+
}
92+
}

state/actor.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,20 @@ import "context"
3030
// verbatim; ActorKindMachine marks the invocation as spawning a child MACHINE
3131
// actor, so entering the owning state emits a SpawnActor effect instead of a
3232
// StartService effect, and the host's ActorSystem (not a ServiceRunner) runs it.
33+
//
34+
// It serializes as a bare omitempty integer, so its numeric values are part of
35+
// the FROZEN v1.0 wire contract — a recorded Invocation encodes its kind by the
36+
// integer. The mapping is append-only: existing values may never be reordered or
37+
// repurposed; a new actor kind may only be added with the next unused integer.
38+
// The frozen value -> meaning mapping is:
39+
//
40+
// 0 = ActorKindService (the invoked-services default, a host-run unit of work)
41+
// 1 = ActorKindMachine (invoke a child machine as an actor)
3342
type ActorKind int
3443

3544
// Actor kinds. ActorKindService is the invoked-services default (a host-run unit
36-
// of work); ActorKindMachine invokes a child machine as an actor.
45+
// of work); ActorKindMachine invokes a child machine as an actor. The integers
46+
// are a frozen, append-only wire contract (see ActorKind).
3747
const (
3848
ActorKindService ActorKind = iota
3949
ActorKindMachine
@@ -275,6 +285,12 @@ type actorAdapter[S comparable, E comparable, C any] struct {
275285
inst *Instance[S, E, C]
276286
output func(*Instance[S, E, C]) any
277287
pending []Effect
288+
// fireErr holds the FireResult.Err from the most recent DeliverFire, so the
289+
// ActorSystem's guarded step can settle a child whose fire failed (e.g. a
290+
// recovered action/guard/assign panic surfaced as a typed error) as a failure
291+
// rather than silently swallowing it. It is read once via FireErr and cleared
292+
// at the start of each DeliverFire.
293+
fireErr error
278294
}
279295

280296
// NewActor adapts a Cast child *Instance into an ActorInstance an ActorSystem can
@@ -326,11 +342,13 @@ func isActorEffect(eff Effect) bool {
326342
// ChildEffects and reports whether the child reached its final state plus its
327343
// output.
328344
func (a *actorAdapter[S, E, C]) DeliverFire(ctx context.Context, event any) (bool, any) {
345+
a.fireErr = nil
329346
ev, ok := event.(E)
330347
if !ok {
331348
return a.inst.InFinal(), a.outputIfDone()
332349
}
333350
res := a.inst.Fire(ctx, ev)
351+
a.fireErr = res.Err
334352
for _, eff := range res.Effects {
335353
if isActorEffect(eff) {
336354
a.pending = append(a.pending, eff)
@@ -340,6 +358,14 @@ func (a *actorAdapter[S, E, C]) DeliverFire(ctx context.Context, event any) (boo
340358
return done, a.outputIfDone()
341359
}
342360

361+
// FireErr returns the FireResult.Err from the most recent DeliverFire, or nil
362+
// when that step fired cleanly. The ActorSystem reads it (via the unexported
363+
// fireErrer interface) to settle a child whose fire failed — for instance a host
364+
// action that panicked and was recovered into a typed ActionPanicError — as a
365+
// failure that routes onError or escalates, rather than swallowing it. It is not
366+
// part of the public ActorInstance contract.
367+
func (a *actorAdapter[S, E, C]) FireErr() error { return a.fireErr }
368+
343369
// ChildEffects returns and drains the buffered child actor effects — the
344370
// SpawnActor / StopActor lifecycle effects and the SendTo / SendParent /
345371
// RespondToSender / ForwardEvent communication effects the child emitted — so the

state/actor_escalation.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package state
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
)
78

@@ -230,9 +231,32 @@ func deliverFireGuarded(ctx context.Context, inst ActorInstance, event any) (don
230231
}
231232
}()
232233
done, output = inst.DeliverFire(ctx, event)
234+
// A clean Go step may still have returned a FireResult.Err: the kernel now
235+
// recovers a panicking host action/guard/assign into a typed error rather than
236+
// letting it unwind, so a child failure surfaces here instead of as a Go panic.
237+
// Treat it as a failure so it settles (routing onError or escalating). A panic
238+
// recovered into an *ActionPanicError is re-rendered as an *ErrActorPanic
239+
// carrying the original recovered value, preserving the panic-failure surface.
240+
if fe, ok := inst.(fireErrer); ok {
241+
if err := fe.FireErr(); err != nil {
242+
var ap *ActionPanicError
243+
if errors.As(err, &ap) {
244+
return false, nil, &ErrActorPanic{Value: ap.Recovered}
245+
}
246+
return false, nil, err
247+
}
248+
}
233249
return done, output, nil
234250
}
235251

252+
// fireErrer is the optional interface a backing ActorInstance implements to expose
253+
// the FireResult.Err of its most recent DeliverFire, so deliverFireGuarded can
254+
// settle a child whose fire failed without that error having been a Go panic. The
255+
// in-process actorAdapter implements it; a host's own ActorInstance need not.
256+
type fireErrer interface {
257+
FireErr() error
258+
}
259+
236260
// inspectActorEscalated feeds one actor-escalation inspection event to the system's
237261
// inspector when one is registered. Like the other inspect helpers it short-circuits
238262
// on the nil-inspector default and must be called without the system mutex held.

0 commit comments

Comments
 (0)