Skip to content

fix(runtime): refuse a non-string descriptor filter instead of serving an empty automation palette (#7360) - #7499

Merged
os-help merged 1 commit into
mainfrom
claude/issue-7360-descriptor-query-refusal
Aug 11, 2026
Merged

fix(runtime): refuse a non-string descriptor filter instead of serving an empty automation palette (#7360)#7499
os-help merged 1 commit into
mainfrom
claude/issue-7360-descriptor-query-refusal

Conversation

@os-help

@os-help os-help commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7360

What was broken

GET /api/v1/automation/actions (?paradigm / ?source / ?category) and GET /api/v1/automation/connectors (?type) compared a raw query value against a string field:

actions.filter((a) => Array.isArray(a?.paradigms) && a.paradigms.includes(query.paradigm))
actions.filter((a) => a?.source === query.source)
connectors.filter((c) => c?.type === query.type)

A repeated parameter arrives as an array from every query parser these routes run behind, and an array is never === any string and never a member of paradigms[]. So ?source=builtin&source=plugin — a caller widening its filter, or a UI serialising a multi-select the obvious way — answered 200 with zero descriptors. The designer palette reads that as "this deployment registers no actions", which is a different sentence from "no actions matched", and nothing in the response tells them apart. A structured ?category[$ne]=x failed identically.

This is #7300/#6928's family but not its mechanism: nothing is coerced and no value is invented — the filter is simply never satisfiable, and the emptiness is indistinguishable from a genuinely empty registry.

The fix

All four filters now go through the shared parseStringParam that the same file's runs branch already uses (packages/runtime/src/query-param.ts), so a non-string is refused in the house shape: 400 VALIDATION_FAILED (ADR-0112) with a details.fields[] entry naming the parameter and carrying ADR-0114's invalid_type. Four call sites, one helper, no new vocabulary. ⛔ The runs branch (#7359 / PR #7490) is untouched.

Two judgement calls

1. The parse runs ahead of each route's service-capability probe. A malformed query is malformed whichever automation service a deployment mounts, and a 400 that appeared only where getActionDescriptors happens to be implemented would be a contract that varies by deployment — leaving the caller unable to tell a rejected filter from an empty registry, which is the very confusion this card is about. A well-formed filter against an unimplemented method still answers the declared empty-but-valid registry, unchanged.

2. Repeated parameters are refused, not read as an OR-filter. The card left this open as a capability question. Measured, nothing needs it:

Caller What it sends
Typed SDK automation.listActions (packages/client/src/index.ts:3043) URLSearchParams.set per key — structurally cannot repeat a parameter
Typed SDK automation.listConnectors (:3058) a single ?type=
objectui designer palette (packages/app-shell/src/views/metadata-admin/previews/useFlowNodePalette.ts:93) /automation/actions with no query string at all; narrows by paradigms.includes('flow') client-side (:61)
objectui connector pickers (inspectors/FlowReferenceField.tsx:378,415) /automation/connectors unfiltered
Wire contract (plugin-rest-api.zod.ts) prose only, no request schema, no multi-value language

No caller anywhere sends a repeated descriptor filter — and the one place a multi-paradigm filter would be natural, the palette, does not use the server filter at all. Accepting one would be inventing a wire capability nothing asks for, on the route where a wrong empty answer is hardest to notice. Widening a refusal later is a compatible change; refusing first is the safe floor. No follow-up card filed, since the evidence points away from the need rather than merely being absent.

What does not change

Every string still filters exactly as today, including one that names no live paradigm, source, category or type — "no actions of that source" is a legitimate empty answer and stays one. Absent and empty spellings still mean "no filter": the falsy gate these filters always had is preserved verbatim. Only a non-string is refused.

Tests

packages/runtime/src/domains/automation-descriptor-query-validation.test.ts, 37 cases, alongside the runs branch's own file. Every refusal asserts the envelope (code and status), never a bare toThrow(): unfixed, these inputs do not throw at all — they answer 200 with an empty list — so a throw-only assertion would pin the absence of a throw rather than the defect.

Each new assertion proven fail-able by mutation (mutate → red → revert):

# Mutation Red
M1 paradigm back to the raw value 4 — the three ?paradigm= refusals + the capped-message pin
M2 source back to raw 5 — the three ?source= refusals, the ?source=builtin&source=plugin pin, the deployment-independence pin
M3 category back to raw 4 — the three ?category= refusals + "refuses before the registry is read"
M4 type back to raw 4 — the three ?type= refusals + /connectors deployment-independence
M5 parse moved back inside the capability branch 1 — the /actions deployment-independence pin, alone
M6 falsy gate → !== undefined 2 — both empty-spelling preservation rows
M7 parseStringParam → closed-set parseEnumParam on ?source 2 — both "a string naming nothing live is still a 200 empty list" rows
M8 ?source= filter inverted (!==) 3 — its preservation rows
M9 parse hoisted above the #5519 anonymous baseline 1 — the 401-before-parse ordering pin

Checks

  • pnpm lint — clean (repo-wide)
  • tsc --noEmit (@objectstack/runtime) — clean
  • @objectstack/runtime1976/1976 pass (122 files)
  • @objectstack/client descriptor tests — 6/6 pass
  • Changeset: .changeset/automation-descriptor-query-refusal.md (@objectstack/runtime patch). No content/docs/releases/** edits.

Generated by Claude Code

…g an empty automation palette (#7360)

`GET /api/v1/automation/actions` (`?paradigm` / `?source` / `?category`) and
`GET /api/v1/automation/connectors` (`?type`) compared a RAW query value
against a string field. A repeated parameter arrives as an ARRAY from every
query parser these routes run behind, and an array is never `===` any string
and never a member of `paradigms[]` — so `?source=builtin&source=plugin`, a
caller widening its filter or a UI serialising a multi-select the obvious way,
answered 200 with ZERO descriptors. The designer palette reads that as "this
deployment registers no actions", which is a different sentence from "no
actions matched", and nothing in the response tells them apart. A structured
`?category[$ne]=x` failed identically.

This is #7300/#6928's family but not its mechanism: nothing is coerced and no
value is invented — the filter is simply never satisfiable, and the emptiness
is indistinguishable from a genuinely empty registry. So the fix is the shared
`parseStringParam` the same file's runs branch already uses, not a new gate:
a non-string is refused in the house shape, 400 `VALIDATION_FAILED` (ADR-0112)
with a `details.fields[]` entry naming the parameter and carrying ADR-0114's
`invalid_type`. Four call sites, one helper, no new vocabulary.

Two judgement calls are recorded in the code:

  * The parse runs AHEAD of each route's service-capability probe. A malformed
    query is malformed whichever automation service a deployment mounts, and a
    400 that appeared only where `getActionDescriptors` happens to be
    implemented would be a contract varying by deployment — leaving the caller
    unable to tell a rejected filter from an empty registry, which is the very
    confusion this card is about. A WELL-FORMED filter against an
    unimplemented method still answers the declared empty-but-valid registry.
  * Repeated parameters are REFUSED rather than read as an OR-filter. The card
    left that open as a capability question; measured, nothing needs it. The
    typed SDK builds both filter sets with `URLSearchParams.set` /
    a single `?type=` (packages/client/src/index.ts), so it cannot emit one.
    In objectui, the actual consumer, the designer palette fetches
    `/automation/actions` with NO query string and narrows by
    `paradigms.includes('flow')` client-side
    (packages/app-shell/src/views/metadata-admin/previews/useFlowNodePalette.ts),
    and the connector pickers fetch `/automation/connectors` unfiltered
    (inspectors/FlowReferenceField.tsx). No caller anywhere sends a repeated
    descriptor filter. Accepting one would be inventing a wire capability
    nothing asks for, on the route where a wrong empty answer is hardest to
    notice; if a multi-select ever wants it, widening a refusal is a
    compatible change and refusing first is the safe floor.

Nothing that worked before changes. Every STRING still filters exactly as
today, including one naming no live paradigm/source/category/type — "no actions
of that source" is a legitimate empty answer and stays one. Absent and empty
spellings still mean "no filter"; the falsy gate these filters always had is
preserved verbatim.

The new pins live in `automation-descriptor-query-validation.test.ts`
(37 cases) alongside the runs branch's own file, and assert the ENVELOPE on
every refusal (code AND status), never a bare throw: unfixed, these inputs do
not throw at all — they answer 200 with an empty list — so a throw-only
assertion would pin the absence of a throw rather than the defect.

Each new assertion was proven fail-able by mutation (mutate, see red, revert):
paradigm/source/category/type each back to the raw value → 4/5/4/4 red in
exactly their own block; the parse moved back inside the capability branch →
the deployment-independence pin alone; the falsy gate replaced with
`!== undefined` → both empty-spelling preservation rows; `parseStringParam`
swapped for a closed-set `parseEnumParam` → the two "a string naming nothing
live is still a 200 empty list" rows; the `?source=` filter inverted → its
preservation rows; the parse hoisted above the #5519 anonymous baseline → the
401-before-parse ordering pin.

`pnpm lint` and `tsc --noEmit` clean; `@objectstack/runtime` 1976/1976 and the
client's descriptor tests 6/6 pass.

Co-authored-by: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 3:09am

Request Review

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime.

20 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via packages/runtime)
  • content/docs/api/index.mdx (via @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/kernel/cluster.mdx (via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx (via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime)
  • content/docs/permissions/system-context.mdx (via packages/runtime)
  • content/docs/plugins/packages.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/runtime)
  • content/docs/releases/v17.mdx (via @objectstack/runtime)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding: a repeated ?paradigm=/?source=/?category=/?type= on the automation descriptor routes silently empties the designer palette (200, zero rows)

2 participants