Skip to content

Parameter resolution truth table

How the runtime decides where a node parameter's value comes from, and how a node's outputs are filtered, given the many flags that contribute.

The decision looks like a flat combination of N booleans (2^N rows), but the code is a short-circuiting priority cascade fed by a handful of derived values. That collapses the combinatorics to a few meaningful rows. Read this as two layers:

  1. Layer 1 — derive the intermediates (connectable, configurable, required, exposed, default) from the raw sources. Each has its own default polarity — this is the part that trips people up.
  2. Layer 2 — run the cascade that consumes those intermediates.

Implemented in:

  • ParameterResolver::resolve() / resolveValue() — inputs (modules/flowdrop_runtime/src/Service/ParameterResolver.php)
  • NodeRuntimeService::filterUnexposedOutputs() — outputs (modules/flowdrop_runtime/src/Service/Runtime/NodeRuntimeService.php)
  • PortExposure::isExposed() — effective exposure (src/Utility/PortExposure.php)

Related: port-configuration.md, internal-parameters.md.

Axes are not peers. connectable + configurable + exposed describe an input; outputs are a separate direction with their own filter. And a hidden fourth input axis, internal (__-prefixed names), overrides almost everything.


Raw sources

tag source
P plugin schema properties[name]default, x-exposed-by-default, validation
E node-type config parameters[name]connectable, configurable, required, exposed_by_default, default
I instance data.config (workflow values) — including the reserved ports override
R runtime inputs (values arriving on wired edges)

Exposure is config-only at execution time: the plugin schema's x-exposed-by-default (in P) is a form-time suggestion, never read at runtime. Execution reads E['exposed_by_default'] and the instance ports override. For that to be correct, the suggestion must be materialized into config at every authoring point:

  • the node-type form writes exposed_by_default on save (seeded from the schema suggestion),
  • the NodeTypeGenerator freezes the suggestion when it generates a node type from a plugin (divergent-only: only a FALSE default is written),
  • update_10004 freezes it once for node types that predate the key.

Seam to know about: if a plugin gains a new parameter after its node type is already installed — and neither the form is re-saved nor a migration runs — config carries no key for it, so runtime treats it as exposed regardless of the plugin's x-exposed-by-default: false. The three materialization points above are what keep the schema suggestion and the runtime default in agreement; a port that never passes through one of them falls back to exposed.


Layer 1 — derived values

D1 — the three gate flags — fail-closed (default FALSE)

Identical shape for connectable, configurable, required (E[flag] ?? FALSE):

E[flag] derived
true TRUE
false FALSE
absent FALSE

D2 — exposedByDefault — fail-open (default TRUE) ⚠ opposite polarity

(bool) (E['exposed_by_default'] ?? TRUE):

E['exposed_by_default'] derived
true TRUE
false FALSE
absent TRUE

This asymmetry is why hiding a port must be written explicitly (exposed_by_default: false): an absent key means exposed, so a port is never silently hidden.

Why the polarity differs (it's a BC scar, not a principle). The three gate flags are capabilities you grant — safe to default off. Exposure defaults on only because in v1 every port was visible; flipping it to fail-closed would hide every legacy and third-party port on upgrade. After the shipped config sweep set exposed_by_default: false almost everywhere, exposure is in practice opt-in too — the fail-open default now only ever applies to un-materialized config (legacy installs, third-party node types, the seam above). Read it as "all four are opt-in; fail-open is the legacy fallback," not as a rule to cargo-cult onto new flags.

D3 — effective exposed = instance override ?? D2

PortExposure::isExposed(configPorts, INPUT, name, D2):

instance config.ports override exposedByDefault (D2) → effective exposed
exposed: true (ignored) TRUE
exposed: false (ignored) FALSE
(absent) FALSE FALSE
(absent) TRUE TRUE

exposed is only meaningful when connectable = TRUE — a non-connectable parameter has no input port to expose.

D4 — effective default — entity-wins chain, ?? semantics

E['default'] ?? P['default'] ?? NULL:

E['default'] P['default'] derived
set — incl. 0, false, '' E value
null / absent set P value
null / absent null / absent NULL

?? only falls through on null/absent. An entity default of 0, false, or '' is a real value and wins over the schema default. A NULL result here is exactly what trips requiredMissingParameterException.

One-way consequence: config can override a schema default to any concrete value, but cannot override it back to "no default" — setting E['default'] to null is indistinguishable from absent, so the schema default re-emerges. To express "no default," the schema must not declare one.

Polarity summary (the trap in one place)

axis absent-key means mental model
connectable / configurable / required denied fail-closed — opt in
exposed_by_default (input & output) exposed fail-open — opt out
default (entity vs schema) entity wins, null falls through override chain

Layer 2 — the input cascade

value source =
  1. RUNTIME  if  (internal  OR  (connectable AND exposed))  AND runtime-input present
  2. CONFIG   if  (NOT internal)  AND configurable  AND workflow-value present
  3. DEFAULT  otherwise           // D4: entity default ?? schema default
then: if source is CONFIG or DEFAULT → substitute ${{ secrets.NAME }}   // never RUNTIME
then: if required AND final value === NULL → throw MissingParameterException

Eligibility by flags

exposed shown as when connectable = FALSE (no port to expose). "Eligible" means the source may supply the value; the cascade picks the first eligible source that actually has a value, else DEFAULT.

internal connectable exposed configurable Runtime Config Default
T
F T T T
F T T F
F T F T
F T F F (default-only)
F F T
F F F (default-only)
  • Row 1 (internal): bypasses connectable/exposed/configurable. Runtime wins if present, else default. Never config.
  • Row 4 (connectable:T, exposed:F): the v2 semantic — wired-but-hidden is not runtime-overridable. A runtime/LLM value is ignored; the author's config wins. This is what the shipped-config sweep produces for every exposed_by_default: false parameter.
  • Rows 5 & 7 (default-only): value can only ever be D4 → these are the rows that throw on required when the default is NULL.

connectable vs exposed — mind the erosion. At runtime Row 4 (connectable:true, exposed:false) and Row 6 (connectable:false) are identical: no override, config wins, default fills. They differ only in authoring — a hidden-connectable port can be revealed into a live wire by toggling exposure; a non-connectable one never can. So read connectable as "the author may wire this" (revealable) and exposed as "it is wired-and-live now." Since the sweep set nearly everything connectable:true, exposed now carries the decision that connectable used to. Ship connectable:false only for a parameter that must never be a port.

Secret substitution — which source, not which parameter

A value may contain ${{ secrets.NAME }}, replaced with a credential read through the Key module. Substitution is gated on the source the cascade picked, not on the parameter: sources 2 (CONFIG) and 3 (DEFAULT) are substituted, source 1 (RUNTIME) never is.

That asymmetry is the security property. Everything reaching source 1 is untrusted — an upstream node's output, a tool parameter an LLM chose, a webhook payload — so resolving there would let anyone who can get a string into the graph read any secret the site holds.

cascade source substituted? why
1 RUNTIME no not author-controlled
2 CONFIG yes the author typed it
3 DEFAULT yes node-type / plugin schema, admin-controlled

Consequences worth knowing:

  • Row 4 of the table above is the reliable place for a secret. On a connectable + exposed parameter a runtime value wins over config, so an author's reference there may simply go unused (it is not leaked — the runtime string is returned unsubstituted). Put secrets on parameters that are not runtime-overridable.
  • Internal (__) params and dynamic input ports are never substituted. Both reach the bag by routes that bypass the ordinary flag checks — internal params always accept runtime input, and dynamic ports skip resolveValue() entirely — so both are treated as untrusted, always.
  • resolveValue() returns [value, source] for exactly this reason. Anything that collapses that back to a bare value, or moves substitution to the assembled ParameterBag, reopens all three paths. ParameterResolverSecretProvenanceTest pins them.
  • An unresolvable reference fails the node rather than substituting empty.
  • Resolved values are kept out of the per-parameter debug log, and scrubbed back out of job input_data / output_data / error_message on write.

Outputs — separate direction, separate mechanism

Outputs are not resolved from sources; they are filtered after execution (filterUnexposedOutputs). A declared output port's value is stripped from the result when not effectively exposed.

Two different questions, don't conflate them. exposed answers "does this output port exist at all?"exposed: false means there is no such port. exposed_by_default is nothing more than the default value of the instance-level exposure toggle — one flag, one meaning, resolved instance override → type default → TRUE. The canvas is the contract: a port whose resolved exposure is FALSE does not exist on that node instance — not wireable (validator R7), not delivering, not persisted — exactly as the input side treats it.

Output-side derivations:

derived formula polarity
output is-a-port E_out['exposed'] ?? TRUE fail-open
output exposedByDefault E_out['exposed_by_default'] ?? TRUE fail-open
output effective exposed instance ports[outputs] override ?? above

Filter outcome (pinned by the conformance fixture ports-output-stripping.yml and NodeRuntimeServiceOutputExposureTest):

E_out['exposed'] config.ports[outputs] override exposed_by_default (config) Outcome
false (port doesn't exist) (ignored) (ignored) STRIPPED
true / absent exposed: false (ignored) STRIPPED
true / absent exposed: true (ignored) KEPT
true / absent (absent) false STRIPPED — hidden means hidden, both directions
true / absent (absent) true / absent KEPT
(not declared in output schema — dynamic / reserved) KEPT (passes through)

Control outputs (ReservedName::CONTROL_OUTPUTS, e.g. state_update) are never filtered — exposure governs wireable data, not the engine's own flow channel.


Carve-outs

  • Dynamic ports (names not in the plugin schema) are forwarded straight from runtime inputs at the end of resolve() — they skip the exposure gate entirely.
  • Unified input port merge (mergeUnifiedInput) filters incoming keys by connectable only, not exposed. But the main-loop resolveValue re-gates on connectable && exposed, so a hidden port's value is dropped there anyway — same outcome, two guard points.
  • Internal parameters (__-prefixed): see internal-parameters.md.
  • Specification Registry — every language and runtime rule with its test status. D1–D4 here are its GR-CFG section; the registry cross-references this table rather than restating it.