Docket Reducers — v0.1.1 Contract Rationale

Copy Markdown View Source

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's item is 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 [] / %{} / 0 as 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 why first_value treats 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 append field 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 an append field accumulates with no special case. This fell out of the v0.1 design rather than being added.
  • Union dedupes first-occurrence-wins. union is list-as-set: membership semantics, not upsert. A keyed replace-in-place reducer can be added later without changing union.

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.