Current section
Files
Jump to
Current section
Files
docs/architecture/docket-reducers-design.md
# Docket Reducers — v0.1.1 Contract Rationale
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.