Skip to content

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:

  1. 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.
  2. 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_plugindata.metadata.idtype fallback chain; prefers the namespaced executor plugin ID
label data.labeldata.metadata.nameid 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 merged config.
  • getMetadataValue(key, default) — single key from metadata.
  • hasTriggerInput() — true if any input port has dataType === 'trigger'.
  • isGateway() — true if metadata.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(), then ParameterResolver::resolve() validates values against the schema. Any path that instead reads behavior from data.metadata is a trust gap to close.
  • Validate data.config against 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 and filterArgs() 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.metadata cache. 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.