Skip to content

fix(core): v3 list actions accepted a filter shape that crashed them - #403

Merged
IgorShevchik merged 3 commits into
mainfrom
claude/text-tools-docs-refactor-mb9dj7
Aug 26, 2026
Merged

fix(core): v3 list actions accepted a filter shape that crashed them#403
IgorShevchik merged 3 commits into
mainfrom
claude/text-tools-docs-refactor-mb9dj7

Conversation

@IgorShevchik

Copy link
Copy Markdown
Collaborator

Two bugs found while measuring the TypeCallParams options for #279. Both non-breaking, both belong in 2.x rather than waiting for the 3.0.0 batch.

1. A documented filter shape crashed the v3 list actions

TypeCallParamsV3.filter accepts the v3 array of triples and the v2 object dialect, kept deliberately for backward compatibility. That union is right for a single call, which forwards the filter untouched.

It is wrong for callList / fetchList. Those emulate keyset pagination by appending [cursorIdKey, '>', cursor] to the filter on every page:

buildParams: cursor => ({ ...requestParams, filter: [...requestParams.filter, [cursorIdKey, '>', cursor]] })

An array is not a preference there — it is the only shape the mechanism can extend. So:

await b24.actions.v3.callList.make({ method: 'tasks.task.list', params: { filter: { '>id': 100 } } })
// TypeError: filter is not iterable  ← one page into the walk

…on a shape the public type allows and the documentation promises. Confirmed by running it, not by reading it:

$ node -e "const f = { '>id': 100 }; try { [...f] } catch (e) { console.log(e.message) }"
objFilter is not iterable

Fix. The two list actions narrow filter to TypeFilterV3 in their own options type, so the mistake becomes a compile error instead of a crash. A runtime guard catches the JavaScript caller's version of the same mistake and throws SdkError with JSSDK_ACTION_V3_LIST_FILTER_NOT_ARRAY and a message naming what to write instead.

Not a behaviour change anyone can be relying on: the path this closes could only ever throw. Five tests, and the guard is mutation-verified — removing it brings back filter is not iterable.

2. The v2 batch request envelope had no name

TypeHttp.call types its params as TypeCallParams, and the batch request rides through it. { halt, cmd } is neither a filter nor a select; it type-checks only because of the permissive index signature. BatchRequestEnvelopeV2 gives the shape a name at the site that builds it.

On the v3 side there is no envelope at all — the commands are the request body — so the cast there stays, now with a comment saying why rather than looking like an oversight.

Same class as the response-envelope mistyping fixed in #395, from the other direction.

Context: this settles #279's blocking item

While measuring, two things about #279 turned out to be stale, both recorded in a comment there:

  • Its stated justification — "params['filter'] = 123 compiles"is no longer true. docs(jsdoc) + types: fill @todo docs (#154) and type request-side filters (#153) #280 made TypeCallParamsV2/V3 an intersection, and in an intersection the declared property beats the inherited index signature. Both element and dotted access are already rejected.
  • Removing the index signature would reject the ordinary way the API is called, not an escape hatch. Counting the call params written across docs/content, skills/ and playgrounds/: id 31×, entityTypeId 16×, then fields, taskId, iblockId, PLACEMENT… against 14 filter. crm.item.get is { entityTypeId, id }.

The decision taken: keep the index signature, narrow its value type from any to unknown in 3.0.0 (measured cost: 3 sites, of which one is inside the deprecated callMethod that #277 deletes anyway, and the other two are the bug fixed here). Per-method parameter types are the only option that would catch a fitler typo, and that is a separate project rather than a checklist item.

With that settled, #279 stops blocking the 3.0.0 scope freeze.

Checks

  • pnpm run typecheck — all eight passes, 0 errors
  • jsSdk:unit + jsSdk:types + skills:unit
  • lint (1 pre-existing unrelated warning in docs/server/api/ai.post.ts), lint:md, docs-lint --strict, md-internal-links, check-api-reference-index
  • Portal-backed projects not run — no transport behaviour changed, and the one path that did change could previously only throw.

Docs

2.call-list-rest-api-ver3.md and 2.fetch-list-rest-api-ver3.md described params as Omit<TypeCallParams, 'pagination' | 'order'>, which is now stale, and neither mentioned that the object filter cannot work here. Both updated, with the reason rather than just the rule.

Refs #279

🤖 Generated with Claude Code

https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr


Generated by Claude Code

claude added 2 commits August 26, 2026 11:16
`TypeCallParamsV3.filter` accepts both the v3 array of triples and the v2 object
dialect, kept for backward compatibility. That union is right for a single
`call`, which forwards the filter untouched. It is wrong for `callList` and
`fetchList`, which emulate keyset pagination by appending
`[cursorIdKey, '>', cursor]` to the filter on every page — an array is not a
preference there, it is the only shape the mechanism can extend.

So `b24.actions.v3.callList.make({ filter: { '>id': 100 } })` — a shape the
public type allows and the documentation promises — threw `filter is not
iterable` from a spread, one page into the walk. Confirmed rather than reasoned
about: spreading a plain object throws `is not iterable` at runtime.

The two list actions now narrow `filter` to `TypeFilterV3` in their own options
type, so the mistake is a compile error rather than a crash, and a runtime guard
catches the JavaScript caller's version with a code and a message that names the
fix. This is not a behaviour change anyone can be relying on: the path it closes
could only ever throw.

Also names the v2 batch request envelope. `TypeHttp.call` types its params as
`TypeCallParams`, and the batch request rides through it — `{ halt, cmd }` is
neither a filter nor a select, and it type-checks only because of the permissive
index signature. `BatchRequestEnvelopeV2` gives that shape a name at the site
that builds it. On the v3 side there is no envelope at all — the commands are
the request body — so the cast there stays, with a comment saying why rather
than looking like an oversight. Same class as the response-envelope mistyping
fixed in #395, the other direction.

Found while measuring the `TypeCallParams` options for #279.

Refs #279

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr
QA proved the message test was passing vacuously. It used
`.catch(err => expect(...))` on a promise expected to reject — and when the
promise RESOLVES the callback never runs, no assertion executes, and vitest
reports green. Demonstrated by neutering the guard AND making the spread
object-tolerant so the call succeeds: the `rejects.toThrow` test above went red
while the `.catch` one stayed green. A test for an error message has to fail
when there is no error. Now `rejects.toMatchObject`, plus a second case pinning
that `fetchList` reports the same CODE and not merely the same class — without
the guard that path throws too, just a TypeError.

The guard itself shipped duplicated verbatim in both list actions. The repo has
a tested position against exactly that: `baseStage` existed three times and the
suite was pinning behaviour no shipped recipe had (#64a). One copy now, in
`_keyset-paginate.ts`, which both actions already import from.

Its doc comment also overclaimed. Both option types already narrow `filter` to
`TypeFilterV3`, so for a TypeScript caller the `asserts` signature adds nothing
the parameter type has not already done — it exists for the callers the types
cannot reach, JavaScript and `params as any`, and now says so. Recorded there
too: `callTail` / `fetchTail` do NOT need the guard, because they paginate
through the separate `cursor` parameter and forward `filter` untouched. That is
why the fix stops at two of the four v3 walkers, and it is worth writing down
rather than leaving the next reader to re-derive.

From the security review: `AjaxError` runs `requestInfo` through
`redactSensitiveParams`; `SdkError` has no equivalent step for its
`description`. Nothing keeps a credential out of it except callers not putting
one there. The guard honours that by construction, and the expectation now sits
on `SdkErrorDetails.description` next to the similar warning `originalError`
already carries, so it reaches every caller rather than this one site.

Docs: dropped a hard-coded "before v2.2.0" that presupposed a release number not
yet decided, and documented `BatchRequestEnvelopeV2` in `70.core-http.md` — it
is publicly exported, so leaving it undescribed made it a mystery symbol, and
`check-api-reference-index` cannot catch that because it checks value exports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr
…idated

CI caught what I did not: `docs-lint --strict` went red on nine pages whose
`audited:` stamp predates the JSDoc I added to `sdk-error.ts`. It passed locally
because the check reads `git log -1` on the cited source, and at the moment I
ran it that file was modified but not yet committed — so the lint still saw the
old date. The lesson is procedural: `docs-lint --strict` has to be re-run AFTER
committing, not before, or it reports on a state that no longer exists.

Checked rather than rubber-stamped. The change to `sdk-error.ts` was a comment
on `description`, no API movement, so the nine pages remain accurate and the
stamps are honest.

Two real gaps found while looking at them:

`JSSDK_ACTION_V3_LIST_FILTER_NOT_ARRAY` was missing from the code table in
`6.errors.md`. That page says codes are stable strings to match on, so shipping
a new one without listing it there leaves callers matching on something the
reference does not admit exists. Added, with the reason it fires and the note
that `callTail` / `fetchTail` are unaffected.

The same page now records that `SdkError`'s `description` is NOT redacted, while
`AjaxError`'s `requestInfo` is. It already explained the `originalError`
protection in detail, so the asymmetry belonged next to it — anyone constructing
an `SdkError` themselves needs to know the guarantee stops at `AjaxError`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr
@IgorShevchik
IgorShevchik merged commit da71a77 into main Aug 26, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants