Skip to content

The value model

Everything a process reads or writes is a data.Value: a locked, cloneable box around a Go value with one string type name. A plain scalar implements only Value; a value becomes structural by also implementing one of three optional capabilities — Collection (a list), Record (named fields), or Map (data-keyed entries). A structural value is navigable by path (order.items[0].price), and its kind is discovered by type assertion, never declared. This page is the developer reference for the Value interface, the four kinds, the concrete values.* types you construct, and the three tiers that can back a record.

The four kinds

A value's kind is the capability interface it implements — a value implements at most one structural capability (ADR-011 v.6 §2.9.1 / v.7 §2.9.7):

Kind Interface Package What it is Path step
scalar (none — Value only) pkg/model/data a leaf: string, int, bool, a wrapped struct treated whole — (leaf)
list data.Collection pkg/model/data ordered, homogeneous-by-construction elements, with a cursor [i]
record data.Record pkg/model/data string-keyed, heterogeneous, insertion-ordered fields (its keys are schema) .field
map data.Map pkg/model/data homogeneous values under NON-EMPTY string keys (its keys are data) ["key"]

Record vs Map is the keys question: a record's keys are its schema (a fixed field set), a map's keys are data (arbitrary run-time strings, so deletion is first-class). A scalar implements neither — it is a path leaf.

Value — the common interface

Every value, scalar or structural, satisfies data.Value:

type Value interface {
    Get(ctx context.Context) any        // copy of the value (collection: element at cursor)
    Update(context.Context, any) error  // set the value (collection: element at cursor)
    Lock()                              // guard an in-place update through a pointer
    Unlock()
    Type() string                       // string type name
    Clone() Value                       // deep copy (each instance gets its own)
}

Get/Update on a Collection act on the element at the current cursor and panic on an empty collection — use GetAt/SetAt for cursor-free index access. Clone is why an instance never shares mutable data with its snapshot or its siblings.

Typed extraction — data.As

When your code holds a bare data.Value (a reader result, a Record field, a data association's value), extract the payload with As — the canonical typed idiom — instead of a hand assertion on Get:

// avoid — a mismatch silently yields the zero value
amount, _ := v.Get(ctx).(int)

// prefer — a mismatch is an ordinary, self-identifying error
amount, err := data.As[int](ctx, v)
if err != nil {
    return fmt.Errorf("reading order amount: %w", err)
}
func As[T any](ctx context.Context, v Value) (T, error)

As rejects a nil Value and, on a type mismatch, returns an error naming both the held and the requested type ("As: value holds string, not int") — including interface types (data.As[fmt.Stringer]). It asserts the payload Get returns, so on a Collection that is the element at the cursor, on a Map the whole map[string]T entry set, on a Record the map[string]any of field payloads. When you already hold a concrete generic value (Variable[T], Array[T], Map[T]), prefer its T-suffix accessors (GetT, GetAtT, EntryT) — no assertion at all.

Collection — the list capability

Collection extends Value with ordered access, an iteration cursor, and index writes. The methods most code reaches for:

Method Role
Count() int length.
GetAt(ctx, index) (any, error) read element at index — cursor-free.
SetAt(ctx, index, value) error write at index: [0,len) replaces, len appends, >len errors — cursor-free (ADR-011 v.6 §2.9.3).
Add(ctx, value) error append to the end.
GetAll(ctx) []any every element.

The full cursor and mutation surface:

Method Role
Rewind() / GoTo(position) / Next(dir) move the iteration cursor.
Index() any / GetKeys() []any current index / all indices.
Insert(ctx, value, index) / Delete(ctx, index) insert at / remove at index.
Clear() drop all elements, reset the cursor.

Record — the named-fields capability

Record is the optional structural capability that makes a value navigable by .field. Its keys are its schema:

type Record interface {
    Value
    Keys() []string                                   // field names, insertion order
    Field(ctx context.Context, name string) (Value, error)  // ObjectNotFound if absent
    SetField(ctx context.Context, name string, v Value) error
}

SetField's shape enforcement is the implementation's own: the dynamic values.Record accepts a new field, while a reflection/codegen adapter over a native struct rejects an unknown name (see the three tiers).

Map — the data-keyed capability

Map is the dictionary capability (ADR-011 v.7 §2.9.7): homogeneous values under arbitrary run-time keys, navigable by ["key"], with deterministic sorted enumeration over Go's randomized map iteration and first-class deletion:

type Map interface {
    Value
    Keys() []string                                   // ascending (sorted) order
    Entry(ctx context.Context, key string) (any, error)   // ObjectNotFound if absent
    SetEntry(ctx context.Context, key string, value any) error  // upsert; empty key is an error
    DeleteEntry(ctx context.Context, key string) error    // ObjectNotFound if absent
}

Constructing values

The concrete implementations live in pkg/model/data/values; each is generic, so T fixes the element/field/entry type ([any] is the zero-setup form for engine-assembled data):

Kind Constructor Panicking twin
scalar values.NewVariable[T](value T) *Variable[T]
list values.NewArray[T](values ...T) *Array[T]
record values.NewRecord(fields ...RecordField) (*Record, error) values.MustRecord(...)
map values.NewMap[T](entries map[string]T) (*Map[T], error) values.MustMap[T](...)

A record field is a values.RecordField{Name, V}; the shorthand values.F(name, v) builds one. Every concrete type also exposes typed accessors alongside the any interface methods — Variable.GetT() T, Array.GetAtT(i) (T, error), Map.EntryT(key) (T, error), and GetP()/GetT() for pointer/value reads — so host code avoids any round-trips.

Here is the structural-data example building an order record — { id, total, items: [ {sku, price}, … ] }, a record of a scalar, a scalar, and a list of records:

item := func(sku string, price int) data.Value {
    return values.MustRecord(
        values.F("sku", values.NewVariable(sku)),
        values.F("price", values.NewVariable(price)))
}

order := values.MustRecord(
    values.F("id", values.NewVariable("A-1")),
    values.F("total", values.NewVariable(total)),
    values.F("items", values.NewArray[data.Value](
        item("widget", 50), item("gadget", 100))),
)

A Value becomes process data by wrapping it in a data.ItemDefinition (data.MustItemDefinition(order, …)) and hanging it on a data.Property — see Item definitions & item-aware elements.

Reading & writing by path

A structural value is addressed by a path — .field descends a record, [i] a list, ["key"] a map — resolved by the same seam that plain names use, so no consumer needs a special API. The structural-data service task reads a nested scalar straight through the narrow DataReader:

d, err := r.GetData("order.items[0].price")
// …
fmt.Printf("  ▶ order.items[0].price = %v\n", d.Value().Get(ctx))

and the exclusive gateway routes on order.total. For writes, values.SetPath(ctx, root, path, v) sets v at a path relative to root, auto-vivifying missing intermediate records/lists on a permissive dynamic target (a following .field creates a values.Record, a following [i] a values.Array); it lives in values, not data, precisely because it constructs those concrete types. An empty path is an error — a whole-value write is Value.Update. Paths, SchemaAt, Walk, and DiffValues are covered on Reading & writing by path.

Run it

Running examples/structural-data/ (total = 150, so the > 100 branch wins):

order.total = 150
  ▶ order.items[0].price = 50
  ▶ order.total > 100 → premium lane
✓ structural-data completed (Completed)

The three tiers

The kind is what a value can do; the tier is how a record answers those capabilities. All three are freely mixed and nested (a reflection-backed record may contain a dynamic one), and the resolver only ever sees the capability interfaces — it never knows which tier answers (ADR-011 v.6 §2.9.5):

Tier How you get it When Reflection
dynamic values.Record / values.Array / values.Map engine-assembled data, any depth, zero boilerplate none
reflection adapter adapters.Wrap(&yourStruct) wrap your own Go struct as a live record, zero boilerplate once per type, at first Wrap, off the execution path
codegen adapter go:generate a static adapter (pre-empts the reflection builder) per-type upgrade — a renamed field fails the build, not the run none

adapters.Wrap(ptr any) (data.Value, error) returns a live data.Record view over your struct — wrap, not convert — so every data seam consumes it through the ordinary capability interfaces. This is the one place the engine's anti-reflection stance is deliberately relaxed, and the relaxation is bounded to that once-per-type type walk. Native structs have their own page: Native Go structs; registering a custom factory is Custom Value type.

See also