Workflow Node Format¶
This page documents the on-disk JSON shape of a node inside a stored workflow, and
how that JSON is normalized into the internal WorkflowNodeDTO used by the
orchestrator and compiler.
There are two representations of the same node:
- Stored JSON — the nested, camelCase structure the visual editor (React Flow / FlowDrop JS) produces. It is persisted verbatim on the workflow entity; nothing is transformed at save time.
WorkflowNodeDTO— a flattened, normalized read view built on demand when the engine needs to execute, compile, or validate a node.
The stored JSON is the single source of truth. The DTO is a one-way derived view — the API never serializes a node back through the DTO.
Internal API
WorkflowNodeDTO is marked @internal
(modules/flowdrop_workflow/src/DTO/WorkflowNodeDTO.php). The stored JSON
shape, however, is part of the contract with the editor and with workflow
import/export bundles.
Stored JSON shape¶
{
"id": "calculator.1", // see "id" below — "<metadata.id>.<n>"
"type": "universalNode", // constant, required by FlowDrop JS
"position": { "x": 100, "y": 200 },
"data": {
"label": "My Calculator", // user-facing label; defaults to node-type label
"config": { // USER-supplied configuration values
"operation": "multiply"
},
"metadata": { // DERIVED snapshot of the node type + plugin
"id": "calculator", // node type entity ID
"executor_plugin": "flowdrop_node_processor:calculator", // plugin ID
"name": "Calculator", // node type label
"config": { // resolved defaults
"operation": "add",
"precision": 2
},
"inputs": [ { "id": "values", "name": "Values", "dataType": "array" } ],
"outputs": [ { "id": "result", "name": "Result", "dataType": "number" } ]
}
}
}
Field reference¶
| Field | Source of truth | Notes |
|---|---|---|
id |
editor | Pattern: <data.metadata.id>.<number>, e.g. calculator.1. The number usually increments per node of that type on the canvas, but this is a convention only — nothing validates the pattern or enforces uniqueness of the counter. Treat id as an opaque unique key. |
type |
editor | Constant "universalNode". Required by FlowDrop JS — it is the React Flow component type, not the executor. Do not confuse with data.metadata.executor_plugin. |
position |
editor (drag / auto-layout) | Canvas coordinates { x, y }. Variable. Desirably a multiple of 20 — produced indirectly by manual drag (snap) or by auto-layout. Purely visual; has no effect on execution. |
data.label |
user | Display label. Defaults to the corresponding node type's label (data.metadata.name). User may override per node. |
data.config |
user | Configuration values for this node instance. User-supplied. The available keys and value constraints are, in principle, validatable because they are backed by the combination of the node type's settings and the node plugin's schema. |
data.metadata.id |
node type | The node type entity ID (bare ID). |
data.metadata.executor_plugin |
node type | The plugin ID (namespaced, e.g. flowdrop_node_processor:calculator). This is the actual executor. |
data.metadata.name |
node type | The node type label. |
data.metadata.config |
derived | The node type's config settings applied to the node plugin — i.e. resolved default config values. |
data.metadata.inputs |
derived | Input port definitions: the node type's port settings applied to the node plugin. |
data.metadata.outputs |
derived | Output port definitions: the node type's output and "exposed as tool" settings applied to the node plugin. |
data.config vs data.metadata.config
data.metadata.config holds the defaults snapshotted from the node type
at edit time. data.config holds the user's overrides for this specific
node instance. The effective configuration is the former merged with the
latter (overrides win) — see the DTO mapping below.
WorkflowNodeDTO mapping¶
WorkflowNodeDTO::fromArray()
(modules/flowdrop_workflow/src/DTO/WorkflowNodeDTO.php:144) flattens the nested
JSON into nine readonly properties:
| DTO property | Pulled from | Transformation |
|---|---|---|
id |
id |
pass-through |
typeId |
data.metadata.executor_plugin → data.metadata.id → type |
fallback chain; prefers the namespaced executor plugin ID |
label |
data.label → data.metadata.name → id |
first non-empty |
config |
array_merge(data.metadata.config, data.config) |
computed: defaults + user overrides |
metadata |
data.metadata |
whole object, pass-through |
position |
position |
coerced to { x: float, y: float }; null if absent |
inputs |
data.metadata.inputs |
pass-through |
outputs |
data.metadata.outputs |
pass-through |
rawData |
entire node | retained for reference |
typeId resolution gotcha
typeId prefers data.metadata.executor_plugin — the namespaced plugin
ID — and falls back to data.metadata.id (the bare node-type entity ID) and
finally type. Anything resolving a processor from a node must use the
namespaced plugin ID against the node-processor plugin manager; the bare
metadata.id will not resolve. (This is the same field-confusion class of
bug behind Note/trigger node filtering.)
Convenience accessors¶
getConfigValue(key, default)— single key from the mergedconfig.getMetadataValue(key, default)— single key frommetadata.hasTriggerInput()— true if any input port hasdataType === 'trigger'.isGateway()— true ifmetadata.type === 'gateway'.getCategory()—metadata.category, default'default'.
Serialization caveat¶
WorkflowNodeDTO::toArray() emits snake_case keys (type_id, …) and is a
debug/serialization aid only. It does not round-trip to the stored JSON
shape — storage uses the nested camelCase structure above. Persisting a node
always goes through the editor JSON, never toArray().
Source of truth and the trust boundary¶
The authoritative definition of a node at execution is the node type settings
plus the node processor plugin's parameter schema — never the stored JSON.
The only user-controlled, per-instance value is data.config.
Everything under data.metadata (executor_plugin, name, config defaults,
inputs, outputs) is a cache the editor produced. A stored workflow is
attacker-controllable — through the API, an imported bundle, or a tampered
config entity — so this cache must be treated as untrusted and must never be
an execution input.
The rules that follow from this:
- Re-resolve, don't trust. Given
data.metadata.id(the node type entity ID), the executor plugin, config defaults, and port definitions are all re-derived from the node type entity + plugin at run time. The execution path already does this for parameters:NodeRuntimeService::resolveParameters()reads the schema from$processor->getParameterSchema()and defaults from$nodeTypeEntity->getParameters(), thenParameterResolver::resolve()validates values against the schema. Any path that instead reads behavior fromdata.metadatais a trust gap to close. - Validate
data.configagainst the schema, per key. Unknown keys are rejected; known keys are checked against their type/enum/bounds. This should hold at save (so bad config never persists) as well as at execution (defense in depth, and to catch config that predates a schema change).ToolParameterScope(flowdrop_runtime) is the existing precedent:projectSchema()narrows the schema to allowed keys andfilterArgs()drops everything else.
Do not trust data.metadata for behavior
executor_plugin, port definitions, and config defaults read from
data.metadata can be forged. Resolve them from the node type entity keyed
by data.metadata.id. The port definitions are especially sensitive in
chat/tool contexts, where they shape what an LLM is told it can do and which
inputs flow in.
Why two shapes exist anyway¶
- The editor wants a self-contained node so it can render without a round
trip — hence the
data.metadatacache. The correct way to keep it fresh is to enrich it from the current node type on read, not to trust the stored copy. - The export/transport layer (marketplace, import bundles) wants a self-describing node so a target site can validate and render it before the node type is installed — the legitimate home for a materialized snapshot.
- The engine wants a flat, resolved, validated view — and gets it by re-resolving from the source of truth, not from the cache.