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:
- 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. - 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+exposeddescribe 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_defaulton save (seeded from the schema suggestion), - the
NodeTypeGeneratorfreezes the suggestion when it generates a node type from a plugin (divergent-only: only a FALSE default is written), update_10004freezes 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 required → MissingParameterException.
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 everyexposed_by_default: falseparameter. - Rows 5 & 7 (default-only): value can only ever be D4 → these are the rows
that throw on
requiredwhen the default is NULL.
connectablevsexposed— 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 readconnectableas "the author may wire this" (revealable) andexposedas "it is wired-and-live now." Since the sweep set nearly everythingconnectable:true,exposednow carries the decision thatconnectableused to. Shipconnectable:falseonly 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 + exposedparameter 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 skipresolveValue()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 assembledParameterBag, reopens all three paths.ParameterResolverSecretProvenanceTestpins 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_messageon 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.
exposedanswers "does this output port exist at all?" —exposed: falsemeans there is no such port.exposed_by_defaultis 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
inputport merge (mergeUnifiedInput) filters incoming keys byconnectableonly, notexposed. But the main-loopresolveValuere-gates onconnectable && exposed, so a hidden port's value is dropped there anyway — same outcome, two guard points. - Internal parameters (
__-prefixed): see internal-parameters.md.
Related documentation¶
- 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.