state/expr
import "github.com/stablekernel/crucible/state/expr"Package expr is crucible’s opt-in rich expression tier. It compiles guard source written in CEL (the Common Expression Language) against a machine’s ContextSchema, type-checks it at authoring time, and registers a CEL-backed guard binding the kernel evaluates synchronously inside the pure Fire step.
The tier is deliberately a separate Go module so the kernel (the state module) stays dependency-free: state never imports CEL, expr depends on state. A guard authored here is, to the kernel, an ordinary named-ref guard leaf — the kernel resolves it by name and calls it like any Go-func guard, never seeing CEL.
Two artifacts come out of one compile. The kernel-facing artifact is a guard binding registered under a name (so Fire can evaluate it). The tooling-facing artifact is a rich IR node — a named-ref leaf tagged Kind “rich” — plus the type-checked AST stored in a name-keyed sidecar (a Catalog) that travels in the IR’s machine-level Meta. Both are produced from the same compiled program, so the evaluated guard and the stored AST can never drift.
Determinism is a property of the environment: the env is built from the CEL standard library with no extension libraries and no host-declared functions, and the standard library contains no ambient or nondeterministic builtin (no now, no random) — so a compiled guard is a pure function of its context.
- Constants
- func Assign[C any](reg *state.Registry[C], name, source string, schema state.ContextSchema, opts …Option) error
- func EvalCheckedAST(checkedAST []byte, schema state.ContextSchema, entity any) (bool, error)
- func EvalLowered[S comparable](node state.GuardNode[S], schema state.ContextSchema, entity any) (bool, string, error)
- func Guard[S comparable, C any](reg *state.Registry[C], name, source string, schema state.ContextSchema, opts …Option) (state.GuardNode[S], error)
- func Lower[S comparable](node state.GuardNode[S], schema state.ContextSchema) (cel.Program, string, error)
- type Catalog
- type Option
- type RichEntry
Constants
Section titled “Constants”Dialect names the expression dialect a rich entry is authored in. Only CEL ships in v1; the field exists so the sidecar can carry a second dialect additively.
const Dialect = "cel"MetaKey is the reserved machine-level Meta key under which a rich-guard sidecar travels in the IR. The kernel never reads it; it round-trips verbatim through the IR envelope’s Meta like any other extension namespace, carrying the type-checked CEL ASTs that analysis and polyglot tooling consume.
const MetaKey = "crucible.expr/rich"func Assign
Section titled “func Assign”func Assign[C any](reg *state.Registry[C], name, source string, schema state.ContextSchema, opts ...Option) errorAssign compiles a CEL expression from source that evaluates to a map of context field updates, and registers it under name in reg as a CEL-backed assign reducer the kernel folds inside Fire. Reference it from a transition with the Assign verb (or a state with OnEntryAssign / OnExitAssign) exactly like a Go reducer.
Compilation and type-checking happen once, here, at authoring time — never inside Fire. The expression is checked against the schema-derived environment, where the context’s fields are bound as top-level variables by their JSON name (the same projection rich guards read). The result type must be a map keyed by string: each entry names a context field and supplies its new value, and authoring fails loudly otherwise. For example, over an order context:
expr.Assign(reg, "applyDiscount", `{"total": total * 0.9, "status": "discounted"}`, schema)At run time the reducer evaluates the expression against the prior context, then merges the resulting field updates onto a copy of that context (a shallow, top-level overlay) and returns the next context. The merge goes through the same JSON projection as the read path, so it is symmetric and language-neutral. Like every assign the reducer is total and pure: it emits no effect and cannot fail the step. A rare runtime evaluation error (the expression is already type-checked, so this is unusual) leaves the context unchanged.
The expression reads only the context, mirroring the rich-guard environment; reading the triggering event is a later, additive capability. With a Catalog option the type-checked AST is collected for tooling and polyglot transport, the same as for guards. The context type parameter C is the registry’s context type.
func EvalCheckedAST
Section titled “func EvalCheckedAST”func EvalCheckedAST(checkedAST []byte, schema state.ContextSchema, entity any) (bool, error)EvalCheckedAST rebuilds a program from stored canonical cel.dev/expr CheckedExpr bytes and evaluates it against a context, returning the boolean verdict. It is the proof that a stored rich AST is not an opaque blob but a working program — the same path a tooling or polyglot consumer reconstructs the guard through — and is the in-Go counterpart of the browser CEL evaluator that consumes the same bytes.
The AST is rebound against the schema-derived env so its variables resolve; an env whose variables do not match the AST’s declarations surfaces as an eval error.
func EvalLowered
Section titled “func EvalLowered”func EvalLowered[S comparable](node state.GuardNode[S], schema state.ContextSchema, entity any) (bool, string, error)EvalLowered lowers a Core guard tree to CEL and evaluates it against a context in one call, returning the boolean verdict and the CEL source it lowered to. It is the convenience the equivalence check uses to compare a lowered Core node against the kernel’s own Core evaluation, and a useful tool for a host that wants to run a Core guard through the CEL engine (for example, to preview it the way the browser will).
func Guard
Section titled “func Guard”func Guard[S comparable, C any](reg *state.Registry[C], name, source string, schema state.ContextSchema, opts ...Option) (state.GuardNode[S], error)Guard compiles a CEL guard from source against schema, registers it under name in reg as a CEL-backed guard binding the kernel evaluates inside Fire, and returns the rich IR node (a named-ref leaf tagged Kind “rich”) that references it.
Compilation happens once, here, at authoring time — never inside Fire. The source is parsed and type-checked against the schema-derived environment; a type error (an unknown field, a comparison CEL rejects, a non-bool result) fails authoring loudly rather than at evaluation. The single compiled program feeds both the registered binding (what the kernel calls) and, when a Catalog option is supplied, the stored type-checked AST (what tooling reads), so the evaluated guard and the stored AST cannot drift.
The returned node is an ordinary named-ref guard leaf as far as the kernel is concerned: drop it into a transition with WhenExpr, compose it with And/Or/Not, or reference it by name from a JSON-authored machine that Provides reg. The state type parameter S is the machine’s state type the returned node composes over; the context type parameter C is the registry’s context type.
func Lower
Section titled “func Lower”func Lower[S comparable](node state.GuardNode[S], schema state.ContextSchema) (cel.Program, string, error)Lower compiles a Core guard expression tree to an equivalent CEL program against schema, returning the compiled program and the CEL source it lowered to. It is the bridge that lets a Core guard — authored with the kernel’s dependency-free builder — evaluate through the same CEL engine a rich guard uses, and it is what the equivalence check exercises to prove the two tiers agree.
Core is a strict subset of CEL modulo one deliberate softening: Core compares integers and floats by coercing both to float64, whereas CEL rejects a mixed int/double comparison at type-check. Lowering closes that gap by injecting an explicit double() cast around an integer operand compared against a float, so the lowered expression type-checks and evaluates identically to Core’s numeric coercion. Raw rich CEL stays strict; only this lowering is coercion-lenient, and only by injecting casts the author could have written by hand.
Lowering is defined for the boolean spine (and/or/not), the typed compares (eq/ne/lt/le/gt/ge), membership (in), field refs, and literals — the whole Core vocabulary. A node outside that vocabulary (a named-ref leaf, stateIn, or an unknown op) has no Core-data meaning to lower and is reported as an error.
type Catalog
Section titled “type Catalog”Catalog is the name-keyed sidecar collector a host passes to Guard so each rich guard’s source and type-checked AST are recorded as the guard is authored. After authoring, Meta attaches the catalog to an IR’s machine-level Meta and LoadCatalog reads it back, so the rich ASTs travel with the machine through a JSON round-trip without the kernel ever inspecting them. The zero Catalog is not usable; build one with NewCatalog.
type Catalog struct { // contains filtered or unexported fields}func LoadCatalog
Section titled “func LoadCatalog”func LoadCatalog(meta map[string]any) (*Catalog, error)LoadCatalog reconstructs a Catalog from an IR’s machine-level Meta, reading the sidecar stored under MetaKey. A Meta with no rich sidecar yields an empty catalog and no error, so a machine that never used the rich tier loads cleanly. A malformed sidecar entry is reported so tampering or a version skew fails loudly.
func NewCatalog
Section titled “func NewCatalog”func NewCatalog() *CatalogNewCatalog returns an empty rich-guard catalog ready to collect entries.
func (*Catalog) Entry
Section titled “func (*Catalog) Entry”func (c *Catalog) Entry(name string) (RichEntry, bool)Entry returns the rich entry recorded under name and whether it exists.
func (*Catalog) Len
Section titled “func (*Catalog) Len”func (c *Catalog) Len() intLen reports how many rich guards the catalog holds.
func (*Catalog) Meta
Section titled “func (*Catalog) Meta”func (c *Catalog) Meta() map[string]anyMeta renders the catalog as the machine-level Meta value a host stores under MetaKey in the IR. The CheckedAST bytes are base64-encoded so the sidecar is a pure JSON value that survives the IR’s encoding/json round-trip unchanged. A host merges the returned single-entry map into the IR’s Meta.
func (*Catalog) Names
Section titled “func (*Catalog) Names”func (c *Catalog) Names() []stringNames returns the catalog’s guard names in sorted order, so tooling can enumerate the rich guards a machine declares deterministically.
type Option
Section titled “type Option”Option configures a Guard authoring call. Options follow the functional-options pattern so the rich tier gains capabilities additively without changing the Guard signature: required arguments stay positional, everything optional arrives as an Option.
type Option func(*config)func WithCatalog
Section titled “func WithCatalog”func WithCatalog(cat *Catalog) OptionWithCatalog records the authored guard’s source and type-checked AST into cat, the name-keyed sidecar a host later attaches to the IR’s Meta with Catalog.Meta. Without it, the guard is still compiled and registered for evaluation, but its AST is not collected for tooling or polyglot transport.
func WithCostLimit
Section titled “func WithCostLimit”func WithCostLimit(limit uint64) OptionWithCostLimit overrides the per-guard CEL evaluation cost ceiling. The default is generous for boolean predicates; lower it to tighten the bound on an untrusted or expensive expression.
type RichEntry
Section titled “type RichEntry”RichEntry is one rich guard’s serializable sidecar record: its source text (for round-trip and edit), its dialect, and the type-checked AST as canonical cel.dev/expr CheckedExpr bytes (the form a polyglot evaluator consumes). It is the data half of a rich guard — the behavior half is the binding registered under the same name.
type RichEntry struct { // Source is the original CEL source text the guard was authored from. Source string `json:"source"` // Dialect names the expression language; "cel" in v1. Dialect string `json:"dialect"` // CheckedAST is the type-checked AST serialized as canonical cel.dev/expr // CheckedExpr proto bytes. CheckedAST []byte `json:"checkedAST"`}Generated by gomarkdoc