Portal Community

The Shape

INodeResultBranches
├── Success : IReadOnlyList<INodeDataItem>
├── Error   : IReadOnlyList<INodeDataItem>?
└── Conditional : IReadOnlyDictionary<string, IReadOnlyList<INodeDataItem>>?

INodeDataItem
├── Data : IReadOnlyDictionary<string, object?>   ← a COPY of OutputData's top-level fields
├── SourceItemIndex : int
└── SourceBranchIndex : int

Build Site #1 — the Auto-Fill

Right after an executor's ExecuteInternalAsync returns, if it didn't set OutputBranches itself, the base class builds one automatically so expression resolution always has something to read:

// BaseNodeExecutor.Process.cs:65-73
if (ResultInfo.Result.OutputBranches is null
    && ResultInfo.Result.OutputData is { Count: > 0 })
{
    var data = ResultInfo.Result.OutputData
        .ToDictionary(k => k.Key, v => (object?)v.Value);
    ResultInfo.Result.OutputBranches = new NodeResultBranchesBuilder()
        .AddSuccess(data)
        .Build();
}

.ToDictionary(...) is a genuine shallow copy — a new outer Dictionary instance, though any nested reference-type values (lists, nested dictionaries) are still shared with the original OutputData.

Build Site #2 — the Sequencer's Own Copy

Separately, after the node fully returns, the orchestration sequencer builds a second, independent NodeResultBranches and stores it keyed by node key — this is the one actually consulted for {{node-X.output.field}} template resolution:

// ProcessElementExecutionSequencer.cs:312-316
// Also store for expression resolution ({{node-X.output.Y}} templates)
var branches = new NodeResultBranchesBuilder()
    .AddSuccess(nodeResult.OutputData.ToDictionary(k => k.Key, v => (object?)v.Value))
    .Build();
memory.StoreNodeResultBranches(currentElement.NodeKey, branches);
Why two builds of the same thing? Build #1 lives on the NodeExecutionResult object itself (useful for anything holding that specific result, e.g. the pinned-data short-circuit path). Build #2 is stored in ExecutionMemory keyed by node key, so any later node in the workflow can resolve {{someEarlierNode.output.field}} — not just whatever code happens to be holding that one result object. They're redundant with each other but serve different lookup paths. Neither shares an object reference with the original OutputData dictionary itself (both do a real .ToDictionary(...)), so this redundancy is not, by itself, a correctness bug — just duplicated memory and duplicated JSON if either gets serialized wholesale.

How to Read {{node-X.output}} Cleanly

NodeDataContextAdapter (Services/Expressions/Adapters/NodeDataContextAdapter.cs) is the clean, typed way expressions reach this data — it wraps memory.GetNodeResultBranches(nodeId) behind INodeResultData, exposing CurrentItem / Items / Branches. Prefer resolving a specific field off CurrentItem.Data rather than the whole Branches object — reading the whole object and re-embedding it somewhere is exactly the pattern that caused the pinned-data bug (see Pinned Data).