The v0.1.1 contract extends reduction to the prior committed value so aggregate fields can accumulate across steps.
The contract change
v0.1 applied reducers only to resolve same-step write conflicts: the runtime folded the step's writes and replaced the committed value. Aggregates (chat histories, usage counters, merged metadata) need the reducer to fold the prior committed value as well:
new_value = reduce(reducer, current_committed_value, sorted_step_writes)last_value is the degenerate case that ignores current, so the v0.1
behavior is unchanged for every existing graph. This is the only semantic
extension in v0.1.1; everything else layered on it (built-in reducer types,
options, shorthand) is additive descriptor data on the wire.
Why the runtime owns reduction
Reduction lives in Docket.Reducer.reduce/3, a pure function called from
the update barrier (apply_state_writes) and from interrupt resolution.
Reducers are part of the determinism contract: replanning after a crash must
reproduce identical commits, which is why v0.1.1 ships only built-in reducers
and defers module-referenced custom reducers until a real host graph
exhausts the built-ins (every custom reducer hands the purity obligation to
the host and makes a module name durable graph content).
Decisions and their reasons
- List writes concatenate (
append/union). LangGraph's convention: a list write contributes elements, a scalar write contributes one element. This is type-ambiguous only when the field'sitemis itself a list type, so the compiler warns (:ambiguous_list_write) there instead of the contract forbidding list writes everywhere. - Natural zeros as effective defaults. Accumulating fields get
[]/%{}/0as the channel default during lowering when the field declares none. Rationale: nodes and guards should see an empty aggregate, not missing state, and the first reduction should fold into the zero — this keeps "first write" and "later writes" on one code path. An explicit field default acts as the base the first commit folds into, which is also whyfirst_valuetreats an explicit default as the value already being set. - Write validation is reducer-aware, the field schema stays committed
truth. A node writing to an
appendfield writes an item; validating the item against the field's list schema would always fail. Each reducer defines what one write validates against (Docket.Reducer.write_schema/3) while the field schema keeps describing the committed shape. Committed values are not re-validated after reduction — the compiler's reducer/schema pairing diagnostics (append/union⇒ list,sum⇒ numeric,merge⇒ map/object) guarantee the reduction preserves the committed type. - Interrupt resolutions flow through the reducer. Resolution already
wrote through
apply_state_writes, so resolving into anappendfield accumulates with no special case. This fell out of the v0.1 design rather than being added. - Union dedupes first-occurrence-wins.
unionis list-as-set: membership semantics, not upsert. A keyed replace-in-place reducer can be added later without changingunion.
Wire compatibility
Reducers already serialized as type + open opts; the new types and
options are additive, so v0.1 documents load unchanged and hash identically.