Skip to content

[bug] slice with an unparseable limit silently drops every record, and the run still reports success #243

Description

@serge-ivo

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions