Skip to content

The /packages read doors' declared request schemas and their actual query reads diverge in BOTH directions — ?limit= and ?cursor= are declared and never read, ?type= is read and never declared #17667

Description

@claude

Found while implementing #17416 (GET /api/v1/packages/:id silently ignoring ?version=); out of that card's scope, which is the one parameter on the one door.

#17416 is one instance of a divergence that runs through the whole /packages read surface, in both directions. Same defect class, same doors, same 200.

Direction 1 — declared and never read (this is #17416's class)

ListInstalledPackagesRequestSchema (packages/spec/src/api/package-api.zod.ts) declares four query parameters for GET /api/v1/packages:

status: z.enum([...]).optional(),
enabled: z.boolean().optional(),
limit: z.number().int().min(1).max(100).default(50),
cursor: z.string().optional(),

The serving door — the dispatcher's /packages domain, handlePackagesRequest's parts.length === 0 && m === 'GET' branch in packages/runtime/src/domains/packages.ts — reads status only out of those four. enabled, limit and cursor are never touched. The handler's own comment states it, so this is acknowledged in the code rather than hidden:

It reads no limit and no cursor, so there is never a next page to announce and nextCursor (optional) stays absent.

Repro

Against any host serving the dispatcher's /packages domain, with more than one package installed:

  • GET /api/v1/packages?limit=1 answers 200 with every row and hasMore: false.
  • GET /api/v1/packages?enabled=false answers 200 with the enabled rows included.
  • GET /api/v1/packages?cursor=anything answers 200 with the first (only) page.

Expected: either the parameter is honoured, or the caller is told. Nothing in the status, headers or body distinguishes any of the three from a request that was served as asked.

Why limit is the sharpest of the three

The repo's own ingress rule names this exact parameter as the one whose silent drop is worst (AGENTS.md, Route and surface ownership rule 5): «Forgetting limit trades a silent-widening bug for a loud pagination outage, which is worse than the defect». Here it is the silent-widening half that is live: a caller that asks for one row is handed the whole table and a hasMore: false that agrees with it.

limit is also the one of the three carrying .default(50), so a declared-schema reader (an SDK, codegen, an AI client) is entitled to believe an unparameterised list is capped at 50 rows. It is not capped at all.

Direction 2 — read and never declared

The same branch filters on query?.type:

if (query?.type) {
    packages = packages.filter((p: any) => p.manifest?.type === query.type);
}

type appears nowhere in ListInstalledPackagesRequestSchema. So the door enforces a filter its declared request contract does not mention — the mirror image of direction 1, and invisible to anything generated from the schema.

The sibling doors have the same shape: ?overwrite= (POST /packages) and ?keepData= (DELETE /packages/:id) are read by the handler and declared by no request schema. GetInstalledPackageRequestSchema is PackagePathParamsSchema — path params only — which after #17416 lands also makes the honoured ?version= on GET /packages/:id an undeclared read.

Why this is one card and not five

The two directions are one question about one surface: what is the query-parameter contract of the /packages read doors, and which artefact states it. Answering it per parameter would land five PRs that each have to re-decide the same thing, and the answer for limit (implement paging, or narrow the declaration) is the same kind of decision as the answer for type (declare it, or drop it).

⚠️ It is a producer-side wire decision and wants a ruling, exactly as #17416 did. The three defensible answers per parameter:

  1. Honour it — implement paging for limit/cursor, read enabled. Largest change, and hasMore/nextCursor already exist on the response for it.
  2. Narrow the declaration — remove limit, cursor, enabled from the request schema so nothing advertises them (ADR-0049 enforce-or-remove), and add type.
  3. Refuse — declare the door's closed query-parameter set per the Route and surface ownership rule, so an unrecognised name gets a located 400.

Note that 1 and 2 are not interchangeable for limit: dropping a declared .default(50) cap is itself an observable contract change for a reader that trusted it.

Notes

Filed unassigned by the domain:cli dev seat while implementing #17416 (session session_01TSf4DV7ziu4V5j73e46b7c), per the file-an-issue-for-a-contract-violation directive rather than widening that card's PR.


Generated by Claude Code

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

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions