Pinned Data
A real bug, found and fixed this session, that only makes sense once you understand every piece covered on the previous seven pages. If you've read this far, this is the payoff.
What Pinning Is
Studio lets you "pin" a node's output — freeze a specific result so re-running the workflow during testing doesn't re-execute that node, it just replays the frozen data. The frozen JSON is stored on ElementDefinition.PinnedData and read back by PinnedDataReader.Read(...), which short-circuits the node executor entirely:
// ProcessElementExecutor.cs:106-134
var pinnedBranches = PinnedDataReader.Read(elementContext.ElementDefinition?.PinnedData, _logger);
if (pinnedBranches is not null)
{
var pinnedOutputData = ExtractOutputData(pinnedBranches);
elementContext.OutputData = pinnedOutputData;
elementContext.ParentThreadContext?.Memory?.StoreNodeResultBranches(
elementContext.ProcessElementKey, pinnedBranches);
// ... executor is skipped entirely
return new NodeExecutionResult
{
IsSuccess = true,
OutputData = pinnedOutputData,
OutputBranches = pinnedBranches,
OutputPortKey = ExecutionConstants.OutputPorts.Main
};
}
The Reader's Two Formats
PinnedDataReader.Read tries two JSON shapes, in order:
- Typed format (
PinnedDataDto) — the shape Studio is expected to write: a top-level{ Success: [...], Error: [...], Conditional: {...} }, matching Output Branches' own shape. - Legacy fallback — if the typed shape doesn't parse or has no content, the whole JSON blob gets deserialized as one flat
Dictionary<string, object?>and wrapped as a single record.
The Bug
If a node gets pinned while its own data already carries something descended from an earlier pin or branch (very plausible during iterative "run → tweak config → run again" testing — exactly the workflow this whole guide traces), the pinned JSON is really a serialized whole NodeExecutionResult: sibling OutputData + OutputBranches properties, per Node Execution Result. That doesn't match the typed DTO's top-level shape, so it fell through to the legacy path — which then treated the entire blob, literal "OutputBranches" key included, as one flat record's field data.
JsonNamingPolicy.CamelCase (used elsewhere in this engine's serializers) only renames real C# properties during serialization — it never touches a Dictionary<string, object>'s literal string keys. So a key like "OutputBranches" (PascalCase, matching the C# property name it was serialized from) survives verbatim through any dictionary-shaped JSON round-trip. That's exactly the fingerprint that exposed this bug: an error path showing PascalCase segments (OutputBranches.Success.Data...) inside JSON otherwise serialized with a camelCase naming policy — proof the nested content wasn't being freshly serialized through the current call, but was an already-serialized blob from somewhere else, embedded as-is.
Each re-pin captured the whole previous result — including its own nested OutputBranches from the pin before that — and nested it one level deeper. Left unguarded, this repeats every re-pin until JsonSerializerOptions.MaxDepth = 32 is exceeded during state finalization, throwing:
A possible object cycle was detected. This can either be due to a cycle or if the
object depth is larger than the maximum allowed depth of 32.
Path: $.outputData.OutputBranches.Success.Data.OutputBranches.Success.Data.
OutputBranches.Success.Data.OutputBranches.Success.Data.OutputBranches.Success.Data.
Because that finalization call runs inside a finally block in ProcessElementExecutionSequencer.cs, the exception propagated up and aborted the entire node/workflow — even though the actual business logic (e.g. a Data Mapping node) had already completed successfully.
The Fix
PinnedDataReader.cs now detects this exact shape before accepting the legacy-fallback dictionary as record data — if it sees both OutputData and OutputBranches keys together, it unwraps to the innermost real OutputData instead (capped at 8 levels as a hard backstop), logging a warning that the node should be re-pinned for a clean snapshot:
private static Dictionary<string, object?> UnwrapNestedResult(Dictionary<string, object?> dict, ILogger logger)
{
var seen = 0;
while (dict.TryGetValue("OutputData", out var outputDataValue)
&& dict.ContainsKey("OutputBranches")
&& outputDataValue is JsonElement { ValueKind: JsonValueKind.Object } outputDataElement)
{
var inner = JsonSerializer.Deserialize<Dictionary<string, object?>>(outputDataElement.GetRawText(), _options);
if (inner is null) break;
dict = inner;
seen++;
if (seen >= 8) { logger.LogWarning(/* stopped unwrapping early */); break; }
}
return dict;
}
Two independent defense-in-depth changes were also made in ExecutionStateSerializer.cs, in case a similar shape ever surfaces from a different source: ReferenceHandler.IgnoreCycles on the serializer options (handles a genuine live reference cycle, if one exists elsewhere), and SerializeFinalDataState now degrades gracefully instead of re-throwing from inside that finally block — a persistence/audit-trail failure should never abort a node that already succeeded.