12 KiB
Core State Mapping and Merge Semantics
This document records the intended direction for wf_core mapping and state
updates before the next round of core implementation work.
Why This Exists
The current core model is intentionally explicit:
- graph data moves between steps through workflow state
- nodes declare their own input and output contracts
- graph use-sites map between graph data and node-local data
- merge behavior belongs to workflow state, not to node implementations
That model is still right. The next pressure comes from structured tools and future nested execution:
- real tools often accept deep payloads such as
user.nameandjob.title - real tools often return deep payloads such as
job.wage - wrappers should not need extra runtime nodes for pure boundary wiring
- future native subgraphs and parallel foreach both need a stronger commit model
- nested state reducers can become a differentiator over systems that only merge whole top-level values
Canonical Binding Rule
The graph-facing side of a binding is a graph path. The node-facing side is a node-local path. Core stores node use-site wiring as explicit lists of binding objects, not as path-keyed maps.
NodeUse.input:
path binding: graph source path -> node-local input path
value binding: JSON value -> node-local input path
NodeUse.output:
node-local output path -> graph state destination path
Examples:
{
"input": [
{
"target": "user.name",
"path": "state.person.name"
},
{
"target": "mode",
"value": "fast"
}
],
"output": [
{
"source": "job.wage",
"target": "state.job.wage"
}
]
}
Whole-object mapping remains valid:
{
"input": [
{
"target": "user",
"path": "state.person"
}
],
"output": [
{
"source": "user",
"target": "state.person"
}
]
}
Whole-payload mapping uses the local root path ".":
{
"input": [
{
"target": ".",
"path": "state.rates"
}
],
"output": [
{
"source": ".",
"target": "state.rates"
}
]
}
Deprecated compatibility inputs are still accepted at model-parse boundaries:
in_map, input_values, and out_map. Validated NodeUse models store and
dump only canonical input and output bindings. wf_authoring exposes the
same canonical binding lists and keeps in_map, input_values, and out_map
only as deprecated Python-builder sugar that compiles into canonical bindings.
Interrupts follow the same pattern with different field names: request uses
canonical input bindings to build the public interrupt payload, and resume
uses canonical output bindings to write resume payload fields back into state.
Legacy interrupt request_map and out_map inputs are accepted only as
parse compatibility and should not be written by new plans.
Explicitness Rules
Node-local writes must not overlap
NodeUse.input constructs node input payloads. Its target node-local paths must
be pairwise non-overlapping.
Valid:
[
{
"target": "user.name",
"path": "state.person.name"
},
{
"target": "user.email",
"path": "state.person.email"
}
]
Invalid:
[
{
"target": "user",
"path": "state.person"
},
{
"target": "user.name",
"path": "state.person.name"
}
]
The invalid form would require implicit object patch precedence. Authors must choose either whole-object mapping or explicit child mapping.
State writes must not overlap in one commit
NodeUse.output mutates workflow state. Its target graph paths must be
pairwise non-overlapping inside one logical commit.
Invalid:
[
{
"source": "user",
"target": "state.person"
},
{
"source": "user.name",
"target": "state.person.name"
}
]
The target model rejects these writes before mutating state.
Read overlap is allowed
Overlap is forbidden on write targets, not read sources. It is valid to read both a whole object and one child into separate destinations when no constructed target overlaps.
Required Mapped Paths
Mapped paths are assertions by the workflow author.
- a missing graph source path in an input path binding is a runtime error
- a missing node-local output path in an output binding is a runtime error
- an explicit
nullvalue is different from a missing path - optional/default behavior must be modeled explicitly later, not inferred from a missing path
State Patch Commit
A successful step should produce a logical state patch before state is mutated:
- resolve all mapped output paths
- ensure every required output path exists
- ensure destination state paths do not overlap
- prepare the complete write set
- stage the write set on a copy
- validate affected declared state schemas
- commit the write set according to state merge rules
This preserves the existing “no partial state commit before success” rule and creates a reusable boundary for:
- ordinary node completion
- serial foreach iteration completion
- future parallel foreach result combination
- future subgraph completion
State validation happens against the staged state before the original state is mutated. Runtime validates the exact destination schema when declared, declared ancestor schemas that could reject the write, and declared descendant schemas that exist after a parent replacement. This lets strict object schemas reject bad partial writes without committing half a patch.
State Declarations and Merge Rules
State merge behavior is attached to declared exact state paths. The canonical
schema shape is ordinary JSON Schema. reducer is a wf_core extension keyword
on property schemas; JSON Schema validators ignore it, while wf_core validates
and uses it for state writes.
{
"type": "object",
"properties": {
"person": {
"type": "object",
"properties": {
"name": { "type": "string", "reducer": "wf.std.replace" },
"tags": { "type": "array", "reducer": "wf.std.append" }
}
},
"profile": {
"type": "object",
"reducer": "wf.std.merge_object"
}
}
}
Deprecated fields state declarations are still accepted at parse boundaries:
{
"fields": {
"person.name": { "type": "string", "reducer": "wf.std.replace" }
}
}
Validated StateSchema models store and dump the canonical JSON Schema shape.
StateSchema.field_map() compiles an internal exact-path index for runtime
reducer lookup.
wf_authoring keeps authored schemas nested for humans and LLM clients, and
injects state metadata such as reducer into the generated JSON Schema
properties. For example, a Pydantic person: Person field can produce nested
properties for person.name and person.tags without forcing the author to
spell those paths manually.
Exact-path ownership
Merge behavior belongs only to the exact declared state path being written.
- no ancestor inheritance
- no descendant declarations altering parent writes
- writing
state.profileuses only the rule forprofile - writing
state.profile.avataruses only the rule forprofile.avatar
If a path is undeclared, it defaults to replace, as undeclared state does
today.
Built-in strategies
Existing built-in reducers remain distinct:
wf.std.replacewf.std.appendwf.std.maxwf.std.merge_objectwf.std.set_union
wf.std.merge_object means shallow object merge at the exact destination path, similar
to dict.update or operator.or_. It is not a recursive deep merge.
If recursive merge is ever needed, it should be explicit rather than hidden
inside merge_object.
Reducers also declare a sibling-write policy for concurrent barriers:
exclusive: valid for ordinary writes, rejected when multiple sibling lineages write the same state path at one barrier.mergeable: allowed for same-path sibling writes; the barrier still replays writes in deterministic item-index order.
wf.std.replace is exclusive. The other built-in reducers are currently
mergeable.
Future Reducers
Reducers are a capability family, similar to reusable node specs:
- named
- source-owned
- inspectable
- dependency-trackable
State fields reference reducers declaratively. String reducer names are accepted
as shorthand for unconfigured reducers; configured reducers use a structural
ref plus JSON-compatible config. Workflow artifacts do not embed arbitrary
Python callables.
StateField(
type="integer",
reducer={
"ref": {"source": "wf.std", "capability_key": "modulo_add"},
"config": {"modulus": 10},
},
)
Reducers should be pure:
current_value, incoming_value -> merged_value
They should not receive node ids, frame ids, loop indexes, timestamps, or other runtime context. If behavior depends on workflow context, that is business logic and belongs in nodes or graph structure.
Reducers are meant to remove write boilerplate, not absorb domain decisions. If a node needs to decide whether to increment two counters, reset both counters, reset one counter, or preserve one counter, that decision belongs in the node or in an explicit graph branch. A reducer should only describe how a declared state path combines the node's write with the current state value.
This distinction matters for fragmented outputs. It is valid for a node to emit
a delta if the node is explicitly a delta-producing node, such as
countdown_delta = -1 written through wf.std.add. It is a smell if a node
returns artificial fragments only to trigger reducer behavior while hiding the
actual domain operation. In that case prefer a clearer node output, an explicit
shaping node, or a default map on the node spec that is still validated at the
use site.
Examples a future reducer library could support:
maxmodulo_addwith configuration such as modulus10
Implementation Phases
Phase 1: Nested node-local mappings
- allow nested node-local paths on canonical input binding targets
- allow nested node-local paths on canonical output binding sources
- reject overlapping write targets
- commit node output through a validated state patch
- validate top-level node-local roots statically; validate deeper shape when the existing node schema makes that practical
This phase is implemented in core. It helps wrappers and structured tools while preserving explicit state merge behavior.
Phase 2: Nested declared state paths
Implemented in core:
- exact nested state path declarations
- merge strategies resolved by exact destination path only
- undeclared paths remain
replace merge_objectremains shallow
Phase 3: Reducer capabilities
Implemented in core:
- state metadata references named reducers instead of merge strategies
- built-ins are registered as
wf.std.replace,wf.std.append, andwf.std.merge_object - runtime resolves reducer names before state writes
Still future:
- source-owned reducer specs beyond the built-ins
- reducer dependency references at deployment/platform level
Phase 4: Core features that depend on this foundation
- native subgraphs should reuse the same mapping and patch semantics at graph boundaries
- parallel foreach should combine validated patches and reject conflicts unless exact-path merge rules permit combination
Non-Goals for the First Implementation
- direct node-to-node data wires outside workflow state
- implicit object patching from overlapping mapped paths
- recursive deep merge hidden inside
merge_object - arbitrary Python reducer callables stored in workflow models
- shipping native subgraphs or parallel foreach as part of nested mapping work