diff --git a/state/CHANGELOG.md b/state/CHANGELOG.md index d502263..4e13be8 100644 --- a/state/CHANGELOG.md +++ b/state/CHANGELOG.md @@ -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 diff --git a/state/doc.go b/state/doc.go index 23f07e1..1d9064c 100644 --- a/state/doc.go +++ b/state/doc.go @@ -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 diff --git a/state/interface_freeze_test.go b/state/interface_freeze_test.go index 7a6c6a9..f0477f4 100644 --- a/state/interface_freeze_test.go +++ b/state/interface_freeze_test.go @@ -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 @@ -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 +) diff --git a/state/wirefreeze_test.go b/state/wirefreeze_test.go index 76259ec..81cd50a 100644 --- a/state/wirefreeze_test.go +++ b/state/wirefreeze_test.go @@ -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