Found while fixing card 16111 (the flow-template rules' warning half). Not fixed there: it changes the reach of two DIFFERENT rule ids, so it earns its own changeset line and its own review rather than riding along.
The defect
packages/lint/src/lint-flow-patterns.ts:1562 runs a recursive template-string scan over a region-stripped view of each node's config:
const strings: string[] = [];
collectTemplateStrings(stripRegions(node.config), undefined, strings);
stripRegions is called with ONE argument, so it strips the flat union of every config key that holds a region on any node type — body, try, catch, branches. But body is loop's region slot and the canonical request-payload key on an http node (packages/spec/src/automation/io-node-config.zod.ts says so in as many words: "body IS canonical on an http node"). So config.body is deleted from every node's view before this scan reads it, and the whole of an http node's request payload is invisible to:
flow-double-brace-interp — a {{ ... }} token in a payload. Flow node values use SINGLE braces; the double-brace form is the formula dialect and is never interpolated, so it ships to the endpoint as the literal text {{ ... }}.
flow-bare-dollar-ref — a bare $ref.field written as a literal in a payload, likewise never interpolated.
http-nodes.ts interpolates the raw config wholesale (interpolate(raw, variables, context)), so a payload is exactly where an uninterpolated token reaches a real outbound request.
The same union also hides a non-loop node's try / catch / branches keys, if any node type ever uses those names as ordinary config.
Why it is not the card-16111 fix
Card 16111 changed WalkedFlowNode.localConfig to strip only the slots the node's own type declares, and deliberately left stripRegions' DEFAULT argument as the flat union so this caller's behaviour did not change under it. That was scope control, not a verdict that the union is right here — it is the identical defect, one call site over.
The fix
node.type is already in scope at that call site (the line above builds nodeWhere from it):
collectTemplateStrings(stripRegions(node.config, ownRegionKeys(node.type)), undefined, strings);
ownRegionKeys is exported from ./flow-walk.js as of card 16111. Once this call site passes its own slots, the flat-union default has no callers left and should be removed, so the next caller cannot inherit the trap: make regionKeys a required parameter.
Expected blast radius
New findings only where a {{ }} or bare $ref.field sits in a previously-hidden key. Both rules' severities are unchanged. Worth measuring against the example apps the way card 16111 did — examples/app-showcase has http nodes with body payloads inside a parallel branch and a try_catch try.
Verification this needs
- Pins on an
http node payload carrying each of the two token shapes, at top level and nested in a region.
- The double-count negative control: a token in a
loop body must still be reported ONCE, on the node that carries it, not also against the container. That is what the strip exists for, and it is the direction that breaks if the fix over-corrects to stripping nothing.
Found while fixing card 16111 (the flow-template rules'
warninghalf). Not fixed there: it changes the reach of two DIFFERENT rule ids, so it earns its own changeset line and its own review rather than riding along.The defect
packages/lint/src/lint-flow-patterns.ts:1562runs a recursive template-string scan over a region-stripped view of each node's config:stripRegionsis called with ONE argument, so it strips the flat union of every config key that holds a region on any node type —body,try,catch,branches. Butbodyisloop's region slot and the canonical request-payload key on anhttpnode (packages/spec/src/automation/io-node-config.zod.tssays so in as many words: "bodyIS canonical on anhttpnode"). Soconfig.bodyis deleted from every node's view before this scan reads it, and the whole of anhttpnode's request payload is invisible to:flow-double-brace-interp— a{{ ... }}token in a payload. Flow node values use SINGLE braces; the double-brace form is the formula dialect and is never interpolated, so it ships to the endpoint as the literal text{{ ... }}.flow-bare-dollar-ref— a bare$ref.fieldwritten as a literal in a payload, likewise never interpolated.http-nodes.tsinterpolates the raw config wholesale (interpolate(raw, variables, context)), so a payload is exactly where an uninterpolated token reaches a real outbound request.The same union also hides a non-
loopnode'stry/catch/brancheskeys, if any node type ever uses those names as ordinary config.Why it is not the card-16111 fix
Card 16111 changed
WalkedFlowNode.localConfigto strip only the slots the node's own type declares, and deliberately leftstripRegions' DEFAULT argument as the flat union so this caller's behaviour did not change under it. That was scope control, not a verdict that the union is right here — it is the identical defect, one call site over.The fix
node.typeis already in scope at that call site (the line above buildsnodeWherefrom it):ownRegionKeysis exported from./flow-walk.jsas of card 16111. Once this call site passes its own slots, the flat-union default has no callers left and should be removed, so the next caller cannot inherit the trap: makeregionKeysa required parameter.Expected blast radius
New findings only where a
{{ }}or bare$ref.fieldsits in a previously-hidden key. Both rules' severities are unchanged. Worth measuring against the example apps the way card 16111 did —examples/app-showcasehashttpnodes withbodypayloads inside aparallelbranch and atry_catchtry.Verification this needs
httpnode payload carrying each of the two token shapes, at top level and nested in a region.loopbody must still be reported ONCE, on the node that carries it, not also against the container. That is what the strip exists for, and it is the direction that breaks if the fix over-corrects to stripping nothing.