What happens
lib/steps.ts:867:
const limit = input.limit === undefined || input.limit === null
? items.length // absent → keep ALL
: Math.max(0, Math.floor(Number(input.limit) || 0)); // unparseable → keep NONE
Number(x) || 0 turns anything non-numeric into 0, and slice treats 0 as "keep zero
records". So:
limit |
records kept |
absent / null |
all (documented default) |
50 / "50" |
50 |
"" |
0 |
"fifty" |
0 |
false |
0 |
An unparseable limit produces the single most destructive outcome available, while an absent
one keeps everything. That asymmetry is the bug.
Why it is reachable
Step inputs are not validated against jsonSchema before the handler runs — executePipelineStep
calls resolveInputs then runRegistryTool (lib/pipeline.ts:288-289, 296-297), and
runRegistryTool performs no schema check. The declared limit: { type: "number" } is a hint to
the model, not a gate.
So an empty string reaches the handler whenever a $param resolves to blank — a trigger or
connection config with the limit field left empty, a config.params entry that isn't set, a
hand-written pipeline JSON with "limit": "".
Why it is bad
slice exists to sit in front of per-item connector calls — its own description: "cap a list
before a per-item connector call (enrich / forEach) so a long source can't turn into unbounded
spend." So it is placed mid-chain, exactly where emptying the list is invisible:
- Downstream
enrich / forEach / dedupe_upsert run over nothing.
- Every step succeeds. The run completes, not fails.
- The result is "0 leads", indistinguishable from "no businesses matched your search".
Nobody debugs a successful run that found nothing. The evidence is technically present — the step
returns dropped: items.length - out.length — but nothing surfaces it.
The rest of the file gets this right
Every other numeric input in steps.ts falls back to something non-destructive:
concurrency Number(...) || 4 maxPages Math.min(Number(...) || 5, 50)
extentKm Number(...) || 1 timeoutMs Math.min(Number(...) || 12000, 30000)
offset Number(...) || 0 maxTokens Number.isFinite(...) ? ... : 500
maxTokens even uses Number.isFinite, which is the correct test. slice.limit is the only one
whose fallback destroys data.
Secondary, milder instance: flatten's depth (:494) has the same shape — a bad value
becomes 0, silently no-opping the flatten rather than defaulting to the documented 1.
Suggested shape
- Use
Number.isFinite (as maxTokens does). A non-finite limit should either keep all —
matching the absent-value default — or fail the step with a clear message. Failing is
arguably better: a limit you asked for and didn't get is worth stopping over.
- Same for
flatten.depth (fall back to 1, not 0).
- Consider validating step inputs against the declared
jsonSchema in runRegistryTool, which
would close this class rather than these two instances. Larger change; worth its own discussion.
Verification
limit: "", "abc", false → keeps all (or fails), never silently zero.
limit: 0 explicitly → still keeps zero (that is a real request).
- A pipeline whose
$param limit is blank produces the same result as one with no limit at all.
What happens
lib/steps.ts:867:Number(x) || 0turns anything non-numeric into0, andslicetreats0as "keep zerorecords". So:
limitnull50/"50""""fifty"falseAn unparseable limit produces the single most destructive outcome available, while an absent
one keeps everything. That asymmetry is the bug.
Why it is reachable
Step inputs are not validated against
jsonSchemabefore the handler runs —executePipelineStepcalls
resolveInputsthenrunRegistryTool(lib/pipeline.ts:288-289, 296-297), andrunRegistryToolperforms no schema check. The declaredlimit: { type: "number" }is a hint tothe model, not a gate.
So an empty string reaches the handler whenever a
$paramresolves to blank — a trigger orconnection config with the limit field left empty, a
config.paramsentry that isn't set, ahand-written pipeline JSON with
"limit": "".Why it is bad
sliceexists to sit in front of per-item connector calls — its own description: "cap a listbefore a per-item connector call (enrich / forEach) so a long source can't turn into unbounded
spend." So it is placed mid-chain, exactly where emptying the list is invisible:
enrich/forEach/dedupe_upsertrun over nothing.Nobody debugs a successful run that found nothing. The evidence is technically present — the step
returns
dropped: items.length - out.length— but nothing surfaces it.The rest of the file gets this right
Every other numeric input in
steps.tsfalls back to something non-destructive:maxTokenseven usesNumber.isFinite, which is the correct test.slice.limitis the only onewhose fallback destroys data.
Secondary, milder instance:
flatten'sdepth(:494) has the same shape — a bad valuebecomes
0, silently no-opping the flatten rather than defaulting to the documented1.Suggested shape
Number.isFinite(asmaxTokensdoes). A non-finitelimitshould either keep all —matching the absent-value default — or fail the step with a clear message. Failing is
arguably better: a limit you asked for and didn't get is worth stopping over.
flatten.depth(fall back to1, not0).jsonSchemainrunRegistryTool, whichwould close this class rather than these two instances. Larger change; worth its own discussion.
Verification
limit: "","abc",false→ keeps all (or fails), never silently zero.limit: 0explicitly → still keeps zero (that is a real request).$paramlimit is blank produces the same result as one with no limit at all.