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
29 changes: 29 additions & 0 deletions state/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,35 @@ ready to freeze on sign-off. The `analysis`, `evolution`, `conformance`, and
copy-on-write, trace-history bounding, and random-IR fixpoint and
eventless-termination fuzzing.

### Deferred additive API surface (post-1.0, non-breaking)

These are deliberate v1.0 boundaries, not oversights. Each lands later as a purely
additive change — a new builder method, a new option on an existing variadic tail,
or a new optional IR field — so adopting it never breaks a v1 caller. The freeze
locks today's surface precisely so these can be added without a major bump.

- Payload-carrying `Raise`: the raised internal-event queue is runtime-internal
today, so a transition raises an event without data. A future builder method
carries a payload, backed by an additive optional field on the IR transition;
the existing `Raise []E` slice is unchanged.
- Multi-target transitions: a single transition targets one state via `To`. A
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`.)
- 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.
- Finer Inspector cadence: the Inspector fires once per macrostep — that
once-per-macrostep cadence is the documented v1 contract. A future additive
`InspectKind` exposes per-microstep events for callers that want them; existing
inspectors keep their macrostep cadence.
- A metrics `Meter` seam: there is no metrics hook at v1. A future `WithMeter`
option attaches a meter through the existing option tail, alongside the structured
logger and inspector seams.

## [1.0.0]

The first stable release. The 0.2.0 to 1.0.0 step finalizes the breaking changes
Expand Down
16 changes: 16 additions & 0 deletions state/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,4 +417,20 @@
// Trace records a structured EventPayload alongside the human Event label so a
// recorded event stream replays the exact event — the journal/durable-execution
// seam the deterministic step makes sound.
//
// # Deferred additive surface
//
// Some capabilities are deliberately out of scope at v1.0 and reserved for a later
// release as purely ADDITIVE, non-breaking work — each arrives as a new builder
// method, a new option on an existing variadic tail, or a new optional IR field,
// so adopting it never breaks a v1 caller. These are intentional boundaries, not
// oversights: a payload-carrying raise, multi-target transitions (a .ToAll builder
// over an optional Targets field, leaving To single-target), a context.Context
// option on Verify/PlanPath/Cast/Restore and the snapshot codecs (RestoreActors
// already takes one), an opt-in to snapshot a non-quiesced actor tree (today
// SnapshotActors refuses with a typed error), a finer per-microstep Inspector
// cadence (the v1 contract fires the Inspector once per macrostep), and a metrics
// Meter seam alongside the logger and inspector seams. The frozen v1 surface is
// locked precisely so these land without a major bump; the module CHANGELOG tracks
// the full list.
package state
45 changes: 45 additions & 0 deletions state/interface_freeze_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package state

import "context"

// This file pins the v1.0 frozen interface surface with compile-time
// assertions. If a freeze is violated — a sealed interface gains a method its
// sole crucible implementer does not satisfy, or a host-implementable interface
Expand All @@ -21,3 +23,46 @@ var (
_ Snapshotter = (*actorAdapter[int, int, int])(nil)
_ ActorInstance = (*actorAdapter[int, int, int])(nil)
)

// ---------------------------------------------------------------------------
// v1.0 API signature freeze.
//
// The following compile-time assignments pin the EXACT signatures of the
// load-bearing public constructors and methods that the v1 promise covers. If
// any signature drifts — a parameter type changes, an option tail is dropped, a
// return type changes — the package stops compiling here. This is deliberate: a
// public signature change is a breaking change and must be a conscious decision,
// not an accident caught only downstream.
//
// HOW TO UPDATE (deliberately, additive only): these constructors and methods
// already end in a variadic functional-option tail, so a new capability arrives
// as a new option WITHOUT changing any signature below — no edit needed. Edit a
// pinned signature here only when intentionally making a breaking API change,
// which requires a major version bump.
//
// Only genuinely load-bearing surface is pinned; internal helpers are left free
// to evolve. Signatures are instantiated at concrete int/int/int (or string)
// type params, which does not change the shape being frozen.
var (
// Forge / ForgeFor — the two builder entry points.
_ func(string, ...ForgeOption) *Builder[int, int, int] = Forge[int, int, int]
_ func(string, ...ForgeOption) *Builder[string, string, int] = ForgeFor[int]

// Quench / Cast — builder -> machine -> instance.
_ func(*Builder[int, int, int], ...QuenchOption) *Machine[int, int, int] = (*Builder[int, int, int]).Quench
_ func(*Machine[int, int, int], int, ...CastOption[int]) *Instance[int, int, int] = (*Machine[int, int, int]).Cast

// Fire — the pure step, on Instance.
_ func(*Instance[int, int, int], context.Context, int, ...FireOption) FireResult[int] = (*Instance[int, int, int]).Fire

// IR serialization round-trip.
_ func([]byte, ...LoadOption) (*IR[int, int, int], error) = LoadFromJSON[int, int, int]
_ func(*Machine[int, int, int], ...ToJSONOption) ([]byte, error) = (*Machine[int, int, int]).ToJSON

// Palette — registry introspection.
_ func(*Registry[int]) []Descriptor = (*Registry[int]).Palette

// Visualization exporters.
_ func(*Machine[int, int, int], ...VizOption) string = (*Machine[int, int, int]).ToMermaid
_ func(*Machine[int, int, int], ...VizOption) string = (*Machine[int, int, int]).ToDOT
)
227 changes: 227 additions & 0 deletions state/wirefreeze_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,238 @@ package state_test

import (
"encoding/json"
"reflect"
"testing"

"github.com/stablekernel/crucible/state"
)

// fieldShape is one exported field of a serialized struct, captured as its Go
// field NAME and its full `json:` struct tag. Together these are the wire
// contract a recorded document depends on, so the frozen expectation pins both.
type fieldShape struct {
Name string
JSONTag string
}

// reflectShape walks the exported fields of a struct type and returns each
// field's name and its `json:` tag, in declaration order. Unexported fields
// (e.g. the `extra` round-trip buffer) carry no wire shape and are skipped, so
// the frozen expectation pins exactly the bytes that cross the wire.
func reflectShape(t *testing.T, v any) []fieldShape {
t.Helper()
rt := reflect.TypeOf(v)
if rt.Kind() != reflect.Struct {
t.Fatalf("reflectShape: %T is not a struct", v)
}
var out []fieldShape
for i := 0; i < rt.NumField(); i++ {
f := rt.Field(i)
if f.PkgPath != "" { // unexported: no wire shape
continue
}
out = append(out, fieldShape{Name: f.Name, JSONTag: f.Tag.Get("json")})
}
return out
}

// assertShape compares a serialized type's reflected exported-field shape against
// the frozen expectation, field by field, and fails on any rename, removal,
// reorder, retag, or unexpected addition.
func assertShape(t *testing.T, name string, got, want []fieldShape) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s field count = %d, want %d (frozen v1 wire shape)\n got: %+v\nwant: %+v",
name, len(got), len(want), got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("%s field[%d] = {Name:%q JSONTag:%q}, want {Name:%q JSONTag:%q} (frozen v1 wire shape)",
name, i, got[i].Name, got[i].JSONTag, want[i].Name, want[i].JSONTag)
}
}
}

// TestWireShape_Frozen is the v1.0 WIRE FREEZE guard for the serialized IR and
// palette structs. It reflects over each serialized type and pins the exact set,
// order, NAMES, and `json:` tags (including the omitempty flag) of its exported
// fields. A recorded document is parsed by these names and tags, so renaming,
// removing, reordering, or retagging any field is a breaking wire change and must
// break this test.
//
// HOW TO UPDATE (deliberately, additive only): appending a new optional field
// with an `omitempty` tag is a backward-compatible (minor) change — add the new
// {Name, JSONTag} entry to the END of the relevant want slice in the same order
// the struct declares it. Any rename/removal/reorder/retag is a breaking (major)
// change and the test SHOULD fail until the wire-format version is bumped
// deliberately. The `extra` unexported round-trip buffer carries no wire shape
// and is intentionally not pinned here.
func TestWireShape_Frozen(t *testing.T) {
t.Parallel()

// Generic IR types are instantiated at concrete string/string/any params; the
// type params do not change a field's name or json tag, only the element type.
type S = string
type E = string
type C = any

cases := []struct {
name string
zero any
want []fieldShape
}{
{
name: "IR",
zero: state.IR[S, E, C]{},
want: []fieldShape{
{"SchemaVersion", "schemaVersion,omitempty"},
{"ID", "id,omitempty"},
{"Name", "name"},
{"Version", "version,omitempty"},
{"Input", "input,omitempty"},
{"Output", "output,omitempty"},
{"Context", "context,omitempty"},
{"States", "states,omitempty"},
{"Initial", "initial"},
{"HasInitial", "hasInitial"},
{"Meta", "meta,omitempty"},
},
},
{
name: "State",
zero: state.State[S, E, C]{},
want: []fieldShape{
{"Name", "name"},
{"OwnedBy", "ownedBy,omitempty"},
{"Transitions", "transitions,omitempty"},
{"OnEntry", "onEntry,omitempty"},
{"OnExit", "onExit,omitempty"},
{"IsFinal", "isFinal,omitempty"},
{"OnDone", "onDone,omitempty"},
{"OnEntryAssign", "onEntryAssign,omitempty"},
{"OnExitAssign", "onExitAssign,omitempty"},
{"Children", "children,omitempty"},
{"InitialChild", "initialChild,omitempty"},
{"Regions", "regions,omitempty"},
{"HistoryType", "historyType,omitempty"},
{"HistoryDefault", "historyDefault,omitempty"},
{"Invoke", "invoke,omitempty"},
{"Parent", "-"},
{"Meta", "meta,omitempty"},
},
},
{
name: "Transition",
zero: state.Transition[S, E, C]{},
want: []fieldShape{
{"From", "from"},
{"To", "to"},
{"On", "on"},
{"Guards", "guards,omitempty"},
{"Effects", "effects,omitempty"},
{"WaitMode", "waitMode,omitempty"},
{"Assigns", "assigns,omitempty"},
{"GuardExpr", "guardExpr,omitempty"},
{"Internal", "internal,omitempty"},
{"EventLess", "eventLess,omitempty"},
{"After", "after,omitempty"},
{"Wildcard", "wildcard,omitempty"},
{"Forbidden", "forbidden,omitempty"},
{"Reenter", "reenter,omitempty"},
{"Raise", "raise,omitempty"},
{"SrcFile", "srcFile,omitempty"},
{"SrcLine", "srcLine,omitempty"},
{"Meta", "meta,omitempty"},
},
},
{
name: "Region",
zero: state.Region[S, E, C]{},
want: []fieldShape{
{"Name", "name"},
{"States", "states,omitempty"},
{"InitialChild", "initialChild,omitempty"},
{"Meta", "meta,omitempty"},
},
},
{
name: "Invocation",
zero: state.Invocation[S, E, C]{},
want: []fieldShape{
{"ID", "id,omitempty"},
{"Src", "src"},
{"Input", "input,omitempty"},
{"OnDone", "onDone"},
{"OnError", "onError"},
{"Kind", "kind,omitempty"},
{"SystemID", "systemId,omitempty"},
{"Meta", "meta,omitempty"},
},
},
{
name: "Ref",
zero: state.Ref{},
want: []fieldShape{
{"Name", "name"},
{"Params", "params,omitempty"},
{"Meta", "meta,omitempty"},
},
},
{
name: "ContextSchema",
zero: state.ContextSchema{},
want: []fieldShape{
{"Fields", "fields,omitempty"},
{"Meta", "meta,omitempty"},
},
},
{
name: "Descriptor",
zero: state.Descriptor{},
want: []fieldShape{
{"Kind", "kind"},
{"Name", "name"},
{"Description", "description,omitempty"},
{"Category", "category,omitempty"},
{"Examples", "examples,omitempty"},
{"Params", "params,omitempty"},
{"Reads", "reads,omitempty"},
{"Writes", "writes,omitempty"},
{"Binding", "binding,omitempty"},
},
},
{
name: "ParamSpec",
zero: state.ParamSpec{},
want: []fieldShape{
{"Name", "name"},
{"Type", "type"},
{"Required", "required,omitempty"},
{"Description", "description,omitempty"},
{"Default", "default,omitempty"},
{"Enum", "enum,omitempty"},
{"Examples", "examples,omitempty"},
},
},
{
name: "BindingSpec",
zero: state.BindingSpec{},
want: []fieldShape{
{"Transport", "transport,omitempty"},
{"Meta", "meta,omitempty"},
},
},
}

for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assertShape(t, tc.name, reflectShape(t, tc.zero), tc.want)
})
}
}

// TestEnumWireValues_Frozen pins the numeric wire value of every closed-int enum
// that serializes as a bare integer (WaitMode, HistoryType, ActorKind). These
// integers are part of the frozen v1.0 wire contract: a recorded document encodes
Expand Down
Loading