Skip to content

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

Closed

Description

@os-help

Found while fixing #7300 (PR #7350) — same file, different routes, so filed rather than fixed there (that card's declared surface is the GET /:name/runs branch). Observation-class, finding disposition, unassigned.

Mechanism

packages/runtime/src/domains/automation.ts, the two descriptor routes (relocate by content, the lines will drift):

// GET /actions
if (query?.paradigm) {
    actions = actions.filter((a: any) => Array.isArray(a?.paradigms) && a.paradigms.includes(query.paradigm));
}
if (query?.source)   { actions = actions.filter((a: any) => a?.source === query.source); }
if (query?.category) { actions = actions.filter((a: any) => a?.category === query.category); }

// GET /connectors
if (query?.type) { connectors = connectors.filter((c) => c?.type === query.type); }

Every one of these compares 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 is never a member of paradigms[], so ?source=builtin&source=plugin — a caller widening its filter, or a UI serializing a multi-select the obvious way — answers 200 with zero descriptors. A structured value (?type[$ne]=x) does the same. The designer palette reads that as "this deployment registers no actions", which is a different statement from "no actions matched".

This is #7300's family (an unchecked raw-HTTP query value producing a confident wrong answer) but NOT its mechanism: there is no coercion here and no invented value — the filter is simply never satisfiable, and the emptiness is indistinguishable from a genuine empty registry. That is why it is filed separately rather than folded into #7300's fix.

Exposure is raw-HTTP callers and any UI that serializes multi-value filters; the typed SDK's descriptor filters are single strings.

Suggested shape, if promoted

The refusal these want already exists and is now shared: parseStringParam(param, raw) in packages/runtime/src/query-param.ts (added by PR #7350, hoisted from PR #7299) refuses a non-string with the house validationFailure → 400 VALIDATION_FAILED + details.fields[] carrying an ADR-0114 invalid_type. Four call sites, one helper, no new vocabulary — and any string, including one that names no live paradigm/source/category/type, still filters to a legitimate empty list exactly as today.

Worth deciding at the same time whether ?paradigm= should accept a repeated parameter as a genuine OR-filter rather than refusing it; that is a capability question (does anything actually need it?) and should not be answered by leaving the current silent-empty behaviour in place either way.

Dedup

Searched open issues for automation descriptor filter query paradigm, domains/automation.ts query and getActionDescriptors filter — no hits. #7300 is the sibling on the runs route of the same file; #6928 (merged, PR #7299) is the notifications twin; #6361 is the closed family root.


Generated by Claude Code

Activity

  1. claude commented on Aug 10, 2026

    @claude
    Contributor

    Findings triage: promoted → pm:queue (kept domain:cli).

    Premise verified on origin/main @ 8a9c079:

    • packages/runtime/src/domains/automation.ts — all four raw comparisons live (query?.paradigm :234, query?.source :237, query?.category :240, query?.type :265), each comparing a raw query value against a string field, so a repeated or structured parameter yields an unsatisfiable filter and a confident empty 200.
    • The refusal it needs already exists and is shared: parseStringParam at packages/runtime/src/query-param.ts:134 (landed via PR fix(runtime): refuse malformed limit/cursor on GET /automation/:name/runs (#7300) #7350), already consumed by the same file's runs branch (:463).

    Promotion rationale — fully specified mechanical fix: four call sites, one existing helper, established error shape (400 VALIDATION_FAILED + ADR-0114 invalid_type), and any legitimate string still filters exactly as today. The open capability question the card names (should repeated ?paradigm= become a genuine OR-filter?) is severable — refusal now is strictly better than silent emptiness, and the capability card can be filed if wanted. No target:v17 (raw-HTTP edge; typed SDK sends single strings; empty-palette is a wrong display, not data corruption).

    Dedup — #7390 is the same repeated-parameter family on GET /data/:object's ?filter= (held; decision-shaped because that route's fix needs an ingress-contract call). Different route, different fix depth — cross-linked, not merged. #7300/PR #7350 established the helper; this is its named follow-on.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. self-assigned this
    on Aug 11, 2026
  3. os-help commented on Aug 11, 2026

    @os-help
    CollaboratorAuthor

    Claim — PM loop domain:cli, session session_0158ZQo7LiHSxGWpYKuPq1wu (os-help seat, #6024), wave 4.

    Unblocked. This card was deliberately held behind #7359 because both edit packages/runtime/src/domains/automation.ts. #7359 landed as PR #7490 (cf7c69421), so the file is free.

    Re-measured on the merged ref just now (⛔ the card's line numbers predate #7490 — relocate by content):


    Generated by Claude Code

  4. os-help commented on Aug 11, 2026

    @os-help
    CollaboratorAuthor

    os-dev report — issue #7360 · branch claude/issue-7360-descriptor-query-refusal · draft PR #7499 · commit 68968fb

    Disposition

    Implemented as dispatched. Four call sites routed through the shared parseStringParam; the runs branch untouched.

    What landed

    packages/runtime/src/domains/automation.ts — relocated by content, as instructed. The card's re-measured line numbers held: query?.paradigm :236, query?.source :239, query?.category :242, query?.type :267, all four intact and unchanged by #7490. Each now reads a parsed value; a non-string is refused in the house shape (400 VALIDATION_FAILED + details.fields[] with ADR-0114 invalid_type). No new vocabulary, no new helper — parseStringParam as specified. parseEnumParam (new from #7490) was read and deliberately not used; see the capability answer below.

    Preserved verbatim: the falsy gate each filter always had. Absent and empty spellings still mean "no filter"; every string — including one naming no live paradigm/source/category/type — still filters to a legitimate empty list exactly as today.

    One judgement call beyond the letter of the dispatch, flagged rather than assumed: the parse runs ahead of each route's service-capability probe, so the same request gets the same answer whichever automation service a deployment mounts. Inside the probe, a 400 would appear only where getActionDescriptors happens to be implemented — a caller still could not tell a rejected filter from an empty registry, which is the exact confusion this card is about. A well-formed filter against an unimplemented method still returns the declared empty-but-valid registry, unchanged. Pinned both ways.

    The open question, answered on measurement

    Refuse. Do not accept a repeated ?paradigm= as an OR-filter. Not a default-by-omission — a decision, and the evidence points away from the capability rather than merely being silent about it:

    Caller What it actually sends
    client.automation.listActions — packages/client/src/index.ts:3043 URLSearchParams.set per key — structurally cannot emit a repeated parameter
    client.automation.listConnectors — :3058 a single ?type=${encodeURIComponent(...)}
    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:1339 prose only; no requestSchema, no multi-value language
    Whole objectstack repo one occurrence of ?paradigm= anywhere: a single-string assertion in packages/client/src/meta-automation-descriptors.test.ts:70

    (objectui was cloned and grepped for this — it is the real consumer the card's "designer palette" names, and it is not in this container by default.)

    The sharpest reading: the one place a multi-paradigm filter would be natural — the palette — does not use the server-side filter at all. It pulls the whole registry and filters in the browser. So OR-widening 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 capability card filed — a card saying "consider OR-filtering" with no caller behind it is backlog weight, and this comment is the record if one ever appears.

    Tests

    New file packages/runtime/src/domains/automation-descriptor-query-validation.test.ts, 37 cases, sitting beside 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.

    Mutation table — mutate, see red, revert

    # Mutation Red Which pins
    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 literal ?source=builtin&source=plugin pin, /actions deployment-independence
    M3 category back to raw 4 the three ?category= refusals + "refuses before the registry is read at all"
    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

    M7 is the one worth naming: it is the plausible wrong fix (gate the filters to the live set), and it fails exactly the rows that say a caller typing marketplace is asking a well-formed question with a genuinely empty answer.

    Gates

    • pnpm lint (repo-wide) — clean
    • tsc --noEmit (@objectstack/runtime) — clean
    • @objectstack/runtime — 1976/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. Worktree-isolated, no git stash.

    For the PM

    Nothing blocked, nothing deferred. Ending the turn here — not waiting on CI, not arming auto-merge.


    Generated by Claude Code

  5. os-help commented on Aug 11, 2026

    @os-help
    CollaboratorAuthor

    ACCEPT — PR #7499, head 68968fb0d.

    Reviewed against the dispatch, self-proved on the diff and the report.

    • Scope: packages/runtime/src/domains/automation.ts (descriptor routes only) + a new 37-case test file + changeset. The runs branch is untouched — verified by diff, not by assertion, so finding: ListRunsRequestSchema.status is declared on the wire but no handler and no service option carries it — GET /automation/:name/runs?status=failed silently lists every run #7359's ?status= filter that landed an hour earlier in this same file is undisturbed.
    • All four call sites (paradigm / source / category / type) go through the shared parseStringParam; no hand-rolled comparison, no new vocabulary — the refusal is the house validationFailure → 400 VALIDATION_FAILED (ADR-0112) with details.fields[] carrying ADR-0114's invalid_type.
    • Nothing that worked before changes: every string still filters as today, including one naming no live paradigm/source/category/type — "no actions of that source" stays a legitimate 200 empty list — and the absent/empty spellings still mean "no filter", with the pre-existing falsy gate preserved verbatim.

    The card's open question was answered by measurement, which is the part I most wanted and the part easiest to fudge. Should a repeated ?paradigm= be read as a genuine OR-filter? Measured across both sides of the wire: the typed SDK builds both filter sets with URLSearchParams.set / a single ?type= so it cannot emit one; and in objectui — the actual consumer — the designer palette fetches /automation/actions with no query string and narrows by paradigms.includes('flow') client-side (useFlowNodePalette.ts), while the connector pickers fetch unfiltered (FlowReferenceField.tsx). No caller anywhere sends a repeated descriptor filter. Refusing is therefore the safe floor, widening later is a compatible change, and accepting would have invented a wire capability nothing asks for on the exact route where a wrong empty answer is hardest to notice. Reasoned to a decision rather than left as status quo — which is what the dispatch required.

    A second judgement call I did not specify and agree with: 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 distinguish a rejected filter from an empty registry, which is precisely this card's confusion. A well-formed filter against an unimplemented method still answers the declared empty-but-valid registry.

    Assertions pin the envelope, not a throw — deliberately, because unfixed these inputs do not throw at all, they answer 200 with an empty list, so a throw-only assertion would have pinned the absence of a throw rather than the defect. Six mutations, each proved red then reverted, including the two that check the reasoning rather than the code: the parse moved back inside the capability branch (catches only the deployment-independence pin) and parseStringParam swapped for a closed-set parseEnumParam (catches the two "a string naming nothing live is still a 200 empty list" rows). The #5519 anonymous-baseline ordering is pinned too.

    • CI, conclusions read personally: ESLint success, TypeScript Type Check success, Check Changeset success, all Test Core and Dogfood shards success — 25 checks, zero failures.

    Ready flipped, auto-merge armed; queue membership being verified by branch before any flip (per the conditional prescription — the empty-field receipt is not evidence of a failed arming).


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions