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
File renamed without changes
49 changes: 0 additions & 49 deletions docs/src/content/docs/authoring/assay.md

This file was deleted.

49 changes: 49 additions & 0 deletions docs/src/content/docs/authoring/verify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
title: Verify
description: Verify that an externally-hydrated entity legally belongs in a given state.
sidebar:
order: 11
---

<!-- IMAGE-SLOT: verify-gate (a foundry inspector verifying an incoming ingot against a glowing requirement-template at the gate, rejecting a flawed casting) 16:9 -->
![Verify at the trust boundary](../../../assets/verify-gate.png)

When an entity arrives from outside, whether loaded from a store, deserialized off the wire, or rebuilt by a foreign system, you cannot trust that it actually *belongs* in the state it claims. **`Verify`** is the trust-boundary check: it runs a state's declarative requirements (its guards and invariants) against an entity *without firing a transition*, answering "is this entity legally in this state?"

```go
order := loadFromStore(id) // hydrated externally; claims to be Cooking

if err := machine.Verify(Cooking, order); err != nil {
return fmt.Errorf("order %s is not legally in Cooking: %w", id, err)
}
// Safe to resume from here.
```

By default `Verify` is **fail-fast**: it returns an `*VerifyError` carrying the first requirement that failed. To collect *every* violation in one pass, useful for reporting or validation UIs, pass `state.Aggregate()`:

```go
err := machine.Verify(Cooking, order, state.Aggregate())

var verifyErr *state.VerifyError
if errors.As(err, &verifyErr) {
for _, f := range verifyErr.Failures {
log.Printf("violation: %s: %s", f.Name, f.Reason)
}
}
```

The error type is uniform across both modes; only how many failures it carries differs.

```mermaid
stateDiagram-v2
[*] --> Hydrated
Hydrated --> Verify: external entity
Verify --> Resumed: requirements pass
Verify --> Rejected: VerifyError
note right of Verify
runs the state's guards/invariants,
fires nothing
end note
```

Use `Verify` wherever an entity crosses into your control before you resume driving it. It turns "I hope this object is valid" into a checked guarantee, without mutating the entity or advancing the machine.
2 changes: 1 addition & 1 deletion docs/src/content/docs/concepts/value-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Treating context as an immutable value, replaced and never edited, is what gives
value reproduces the same results, every time.
- **Durable execution.** A snapshot can be persisted, reloaded, and resumed
because nothing lives in hidden pointers or background goroutines.
- **Verification.** `Assay` can check an entity's legality against a state
- **Verification.** `Verify` can check an entity's legality against a state
because the entity *is* the value the guards see.

## The pointer escape hatch
Expand Down
6 changes: 3 additions & 3 deletions docs/src/content/docs/integrating/pointer-heavy-codebases.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ A value `C` is not a stylistic preference. It is what makes the kernel's guarant

### The boundary recipe

On the way in, when you hydrate an aggregate that *claims* to be in some state, use [`Assay`](/crucible/authoring/assay/) to check it is legally there before you resume driving it:
On the way in, when you hydrate an aggregate that *claims* to be in some state, use [`Verify`](/crucible/authoring/verify/) to check it is legally there before you resume driving it:

```go
order := repo.Load(ctx, id) // hydrated externally
view := order.project()

if err := machine.Assay(order.State(), view); err != nil {
if err := machine.Verify(order.State(), view); err != nil {
return fmt.Errorf("order %s not legal in %s: %w", id, order.State(), err)
}
```
Expand All @@ -58,7 +58,7 @@ repo.Save(ctx, order) // YOUR persistence, YOUR transaction
```mermaid
flowchart LR
L[load aggregate] --> P[project to value C]
P --> A[Assay legal?]
P --> A[Verify legal?]
A --> F[Fire]
F --> E[apply effects to aggregate]
E --> S[persist in your txn]
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/source/with-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ state, not a side table you have to operate.

## State-aware rejection is first-class

An event that is invalid for the current state is a guard (or Assay) rejection,
An event that is invalid for the current state is a guard (or Verify) rejection,
not an infra failure. The bridge classifies it as `Term` (poison) and routes it
to the [DLQ](/crucible/source/reliability/#dlq), distinct from a transient error
that becomes a `Nak` and retries. Offset-based libraries cannot tell these apart;
Expand Down
6 changes: 3 additions & 3 deletions docs/src/content/docs/start/foundry-vocabulary.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Foundry vocabulary
description: The lifecycle verbs (Forge, Temper, Quench, Cast, Fire, Assay) and what each one does.
description: The lifecycle verbs (Forge, Temper, Quench, Cast, Fire) and what each one does.
sidebar:
order: 3
---
Expand All @@ -19,19 +19,19 @@ authoring, an immutable definition, and a running instance.
| **Quench** | `b.Quench() *Machine` | Freezes the builder into an immutable `*Machine`. **Panics on misconfiguration** (unknown states, dangling refs). | Once, when the definition is complete. The `*Machine` is safe to share across goroutines. |
| **Cast** | `m.Cast(entity, opts...) *Instance` | Creates a running `*Instance` seeded with your context entity (by value). | Per entity you want to track. Cheap; cast freely. |
| **Fire** | `inst.Fire(ctx, event, opts...) FireResult` | Advances the instance. Returns `FireResult{NewState, Effects, Trace, Err}`. Performs **no IO**; effects are data. | Every time an event arrives. |
| **Assay** | `m.Assay(state, entity, opts...) error` | Verifies that an externally-built entity is *legally* in a given state, running the relevant guards. `FailFast` by default; `Aggregate()` collects all violations. | When an entity is reconstructed from storage or another system and you need to trust its state. |

## Plain verbs

Not everything earns a metaphor. These read literally:

| Verb | Signature (shape) | What it does |
| --- | --- | --- |
| `Verify` | `m.Verify(state, entity, opts...) error` | Checks that an externally-built entity is *legally* in a given state, running the relevant requirements. Fail-fast by default; `Aggregate()` collects all violations. |
| `PlanPath` | `m.PlanPath(from, to, entity, opts...) ([]E, error)` | Computes a sequence of events that would drive an entity from one state to another. |
| `Trace` | (field on `FireResult`) | The ordered record of what happened during a `Fire`: transitions, guards, regions. |
| `ToJSON` / `LoadFromJSON` | `m.ToJSON()` / `LoadFromJSON[S,E,C](b)` | Round-trip the canonical IR losslessly to and from JSON. |
| `ToMermaid` / `ToDOT` | `m.ToMermaid()` / `m.ToDOT()` | Render the machine as a diagram for docs or inspection. |

A typical session uses **Forge → (Temper) → Quench** once, then **Cast** and
**Fire** many times, with **Assay** at the edges where entities cross trust
**Fire** many times, with **Verify** at the edges where entities cross trust
boundaries.
2 changes: 1 addition & 1 deletion source/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func (e *DecodeError) Unwrap() error { return e.Err }
// decode failure to dead-letter with a single errors.Is(err, ErrPoison) check.
func (e *DecodeError) Is(target error) bool { return target == ErrPoison }

// GuardRejection wraps a guard/Assay rejection: a well-formed event that is not
// GuardRejection wraps a guard/Verify rejection: a well-formed event that is not
// legal for the target's current state. It is errors.Is / errors.As friendly via
// Unwrap and reports ErrInvalidForState from Is, so a guard rejection is
// recognized as state-invalid (and routed to dead-letter as a distinct, "wrong
Expand Down
2 changes: 1 addition & 1 deletion source/statemachine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ The ack comes only after a successful durable `Store.Save`
so redelivery is provably idempotent with no external dedup store. Make the id
extractor `WithEventID`.
- **State-aware rejection.** A `Fire` rejected because the event is illegal for
the current state (no transition, or a failing guard/`Assay`) returns
the current state (no transition, or a failing guard/`Verify`) returns
`source.Reject` (Term, `InvalidForState`) carrying a `*source.GuardRejection` —
distinct from a transient `Store`/infra error, which returns `source.Nak`
(Retryable).
Expand Down
2 changes: 1 addition & 1 deletion source/statemachine/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
// is the dedup key.
// - State-aware rejection. A [state.Instance.Fire] that fails because the
// event is illegal for the current state (no declared transition, or a
// failing guard/[state.Machine.Assay]) returns [source.Reject]
// failing guard/[state.Machine.Verify]) returns [source.Reject]
// (Term, classified InvalidForState) carrying a [*source.GuardRejection] —
// distinct from a transient [Store] or infrastructure error, which returns
// [source.Nak] (Retryable). "Wrong time" and "try again later" are
Expand Down
12 changes: 9 additions & 3 deletions state/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -435,8 +435,14 @@ representative hot-path numbers.
directly, or that parsed the type-name suffix of an `EffectsEmitted` label, must
update; type-switching on the effect structs is unaffected (the structs only
gained methods and tags).
- **BREAKING: the `Assay` option `WithAggregate` is renamed `Aggregate`.** The
option that makes `Assay` collect all failing requirements in one pass instead
- **BREAKING: the state trust-boundary check `Assay` is renamed `Verify`.** The
method `Machine.Assay`, its error type `AssayError`, and its option type
`AssayOption` become `Machine.Verify`, `VerifyError`, and `VerifyOption`. The
rename trades the foundry metaphor for a plain, discoverable verb; the
behavior and signatures are otherwise unchanged. Replace `Assay`, `AssayError`,
and `AssayOption` at the call site.
- **BREAKING: the `Verify` option `WithAggregate` is renamed `Aggregate`.** The
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.
- The determinism and ordering contract is now explicit and frozen: emission
Expand Down Expand Up @@ -485,7 +491,7 @@ Initial release of the pure state-machine kernel.

### Added

- Kernel core: `Forge`/`Temper`/`Quench`/`Cast`/`Fire`/`Assay` foundry API with
- Kernel core: `Forge`/`Temper`/`Quench`/`Cast`/`Fire`/`Verify` foundry API with
pure-function step semantics. Firing an event returns `(newState, effects,
trace)` with no IO.
- Serializable definition IR with lossless JSON round-trip; guards, actions, and
Expand Down
7 changes: 4 additions & 3 deletions state/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,11 @@ The lifecycle API uses a small "foundry" verb vocabulary. The noun stays plain
| `Quench` | Freeze the definition into an immutable `Machine`; binds refs. |
| `Cast` | Pour a running instance from the machine. |
| `Fire` | Send an event to an instance and advance it. |
| `Assay` | Check that an externally-built entity is legally in a given state. |

Operations that favor discoverability over metaphor stay plain: `PlanPath`,
`Requirements`, `Trace`, and the `To*` / `LoadFromJSON` serializers.
Operations that favor discoverability over metaphor stay plain: `Verify`,
`PlanPath`, `Requirements`, `Trace`, and the `To*` / `LoadFromJSON` serializers.

`Verify` checks that an externally-built entity is legally in a given state.

The public API follows the suite's functional-options convention: required
inputs stay positional; everything optional is a variadic option, so a
Expand Down
8 changes: 4 additions & 4 deletions state/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func TestErrorMessages(t *testing.T) {
{&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.AssayError{Failures: []state.RequirementFailure{{Name: "req"}}}, "assay failed"},
{&state.VerifyError{Failures: []state.RequirementFailure{{Name: "req"}}}, "verify failed"},
}
for _, c := range cases {
if got := c.err.Error(); !strings.Contains(got, c.want) {
Expand Down Expand Up @@ -153,10 +153,10 @@ func TestQuenchError_Message(t *testing.T) {
Quench()
}

// TestAssay_UndeclaredState covers the Assay early return on an unknown state.
func TestAssay_UndeclaredState(t *testing.T) {
// TestVerify_UndeclaredState covers the Verify early return on an unknown state.
func TestVerify_UndeclaredState(t *testing.T) {
m := buildDocMachine()
err := m.Assay(DocState(99), &Document{})
err := m.Verify(DocState(99), &Document{})
var us *state.ErrUndeclaredState
if !errors.As(err, &us) {
t.Fatalf("err = %v, want *ErrUndeclaredState", err)
Expand Down
10 changes: 5 additions & 5 deletions state/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@
// finalizer that binds refs and panics on misconfiguration.
// - Cast — pour a running instance from the machine.
// - Fire — send an event to an instance and advance it.
// - Assay — check that an externally-constructed entity is legally in a
// given state.
// - Verify — plain verb (favoring discoverability over metaphor): check that
// an externally-constructed entity is legally in a given state.
//
// Operations that favor discoverability over metaphor stay plain: PlanPath,
// Requirements, Trace, and the To*/LoadFromJSON serializers.
// Operations that favor discoverability over metaphor stay plain: Verify,
// PlanPath, Requirements, Trace, and the To*/LoadFromJSON serializers.
//
// # Context: assigns and value semantics
//
Expand Down Expand Up @@ -200,7 +200,7 @@
//
// The kernel implements the Forge/Temper/Quench build path, Cast/Fire pure step
// semantics with guards, actions, typed errors and an opt-in structured Trace,
// Assay/Requirements, PlanPath (BFS), FireSeq/FireEach batch helpers, and
// Verify/Requirements, PlanPath (BFS), FireSeq/FireEach batch helpers, and
// lossless ToJSON/LoadFromJSON/Provide round-trip.
//
// Hierarchical and orthogonal states extend the same surface: a state may
Expand Down
8 changes: 4 additions & 4 deletions state/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,17 +236,17 @@ func (e *MultiRegionErr) Error() string {
// Unwrap exposes the per-region errors for errors.As / errors.Is traversal.
func (e *MultiRegionErr) Unwrap() []error { return e.Errors }

// AssayError aggregates one or more failing requirements found by Assay.
type AssayError struct {
// VerifyError aggregates one or more failing requirements found by Verify.
type VerifyError struct {
Failures []RequirementFailure
}

func (e *AssayError) Error() string {
func (e *VerifyError) Error() string {
names := make([]string, 0, len(e.Failures))
for _, f := range e.Failures {
names = append(names, f.Name)
}
return fmt.Sprintf("crucible/state: assay failed: %s", strings.Join(names, ", "))
return fmt.Sprintf("crucible/state: verify failed: %s", strings.Join(names, ", "))
}

// ErrUnsupportedSchema is returned by LoadFromJSON when an IR document declares a
Expand Down
8 changes: 4 additions & 4 deletions state/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,13 @@ func ExampleMachine_ToJSON() {
// stable: true
}

// ExampleMachine_Assay checks an externally-built entity against a state's
// ExampleMachine_Verify checks an externally-built entity against a state's
// declarative requirements without firing a transition.
func ExampleMachine_Assay() {
func ExampleMachine_Verify() {
m := buildDocMachine()

missing := m.Assay(Approved, &Document{Status: Approved})
ok := m.Assay(Approved, &Document{Status: Approved, ReviewerID: strptr("rev-1")})
missing := m.Verify(Approved, &Document{Status: Approved})
ok := m.Verify(Approved, &Document{Status: Approved, ReviewerID: strptr("rev-1")})

fmt.Println("missing reviewer:", missing != nil)
fmt.Println("with reviewer:", ok)
Expand Down
4 changes: 2 additions & 2 deletions state/kernel.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const (
type WaitMode int

// Wait modes. SyncReply awaits a reply, FireAndForget emits and moves on, and
// ValidatePoll signals the consumer to poll the entity (re-running Assay) until
// ValidatePoll signals the consumer to poll the entity (re-running Verify) until
// it validates.
const (
SyncReply WaitMode = iota
Expand Down Expand Up @@ -508,7 +508,7 @@ type ActionCtx[C any] struct {
// ActionFn produces an effect (or error) for a transition.
type ActionFn[C any] func(ctx ActionCtx[C]) (Effect, error)

// Requirement is a declarative condition for a state, used by Assay.
// Requirement is a declarative condition for a state, used by Verify.
type Requirement[C any] struct {
Name string
Predicate func(C) bool
Expand Down
10 changes: 5 additions & 5 deletions state/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,16 +252,16 @@ func WithEventData(data any) FireOption {
}
}

// AssayOption configures Assay.
type AssayOption func(*assayConfig)
// VerifyOption configures Verify.
type VerifyOption func(*verifyConfig)

type assayConfig struct{ aggregate bool }
type verifyConfig struct{ aggregate bool }

// Aggregate makes Assay collect all failing requirements in one pass instead of
// Aggregate makes Verify collect all failing requirements in one pass instead of
// failing fast at the first. It is a pure directive option (it carries no value),
// so it drops the With prefix that value-carrying options keep — matching Strict
// and CollectAll.
func Aggregate() AssayOption { return func(c *assayConfig) { c.aggregate = true } }
func Aggregate() VerifyOption { return func(c *verifyConfig) { c.aggregate = true } }

// PlanOption configures PlanPath.
type PlanOption func(*planConfig)
Expand Down
10 changes: 5 additions & 5 deletions state/assay.go → state/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ func (m *Machine[S, E, C]) Requirements(s S) []Requirement[C] {
return append([]Requirement[C](nil), reqs...)
}

// Assay checks that an externally-constructed entity legally satisfies a
// Verify checks that an externally-constructed entity legally satisfies a
// state's declarative requirements, without firing. The default mode is
// fail-fast (the returned *AssayError carries the first failure); Aggregate
// fail-fast (the returned *VerifyError carries the first failure); Aggregate
// collects every failure in one pass. The error type is uniform across modes.
func (m *Machine[S, E, C]) Assay(s S, entity C, opts ...AssayOption) error {
cfg := assayConfig{}
func (m *Machine[S, E, C]) Verify(s S, entity C, opts ...VerifyOption) error {
cfg := verifyConfig{}
for _, o := range opts {
o(&cfg)
}
Expand All @@ -43,7 +43,7 @@ func (m *Machine[S, E, C]) Assay(s S, entity C, opts ...AssayOption) error {
}

if len(failures) > 0 {
return &AssayError{Failures: failures}
return &VerifyError{Failures: failures}
}
return nil
}
Loading
Loading