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. added
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 10, 2026
  2. 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

  3. added
    pm:queueReady for the PM dispatch loop
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 10, 2026
  4. self-assigned this
    on Aug 11, 2026
  5. 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

  6. 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

  7. 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

Labels

domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions