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:
- Honour it — implement paging for
limit/cursor, read enabled. Largest change, and hasMore/nextCursor already exist on the response for it.
- Narrow the declaration — remove
limit, cursor, enabled from the request schema so nothing advertises them (ADR-0049 enforce-or-remove), and add type.
- 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
Found while implementing #17416 (
GET /api/v1/packages/:idsilently 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
/packagesread surface, in both directions. Same defect class, same doors, same200.Direction 1 — declared and never read (this is #17416's class)
ListInstalledPackagesRequestSchema(packages/spec/src/api/package-api.zod.ts) declares four query parameters forGET /api/v1/packages:The serving door — the dispatcher's
/packagesdomain,handlePackagesRequest'sparts.length === 0 && m === 'GET'branch inpackages/runtime/src/domains/packages.ts— readsstatusonly out of those four.enabled,limitandcursorare never touched. The handler's own comment states it, so this is acknowledged in the code rather than hidden:Repro
Against any host serving the dispatcher's
/packagesdomain, with more than one package installed:GET /api/v1/packages?limit=1answers200with every row andhasMore: false.GET /api/v1/packages?enabled=falseanswers200with the enabled rows included.GET /api/v1/packages?cursor=anythinganswers200with 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
limitis the sharpest of the threeThe 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
limittrades 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 ahasMore: falsethat agrees with it.limitis 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:typeappears nowhere inListInstalledPackagesRequestSchema. 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.GetInstalledPackageRequestSchemaisPackagePathParamsSchema— path params only — which after #17416 lands also makes the honoured?version=onGET /packages/:idan 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
/packagesread 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 forlimit(implement paging, or narrow the declaration) is the same kind of decision as the answer fortype(declare it, or drop it).limit/cursor, readenabled. Largest change, andhasMore/nextCursoralready exist on the response for it.limit,cursor,enabledfrom the request schema so nothing advertises them (ADR-0049 enforce-or-remove), and addtype.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
?version=parameter on the by-id door and is being fixed there; this card is the list door's four parameters and the undeclared reads. GET /api/v1/packages/:id silently ignores ?version= — the only serving surface never reads it, and the handler that did was deleted with the REST twin #17416's PR names this card in its acceptance notes as the divergence it deliberately did not widen onto.?pageSize=5返回 200 + 空列表 #4134 (closed) — that isGET /data/:objectlowering an unknown query key into an implicit field filter, a route whose parameter set is genuinely open and gated one layer down against the object's field map. The/packageslist door has no such authority to defer to:limitandcursorhave no meaning except the one the schema declares.GET /api/v1/packages/:idswallows the same failed REGISTRY read — and answers a terminal404 RESOURCE_NOT_FOUNDfor it #11376 (closed), the registry-read swallow on the by-id door.hasMoreon the list response was filled in by finding(runtime): scoped/api/v1/environments/:id/packages[/:id]has no dispatcher door on aplugin-hono-server-only composition after #16628 (B′ follow-up to #14503) #16781 as a required key; it is hard-codedfalse, which is correct only for as long as the door serves one page.Filed unassigned by the
domain:clidev seat while implementing #17416 (sessionsession_01TSf4DV7ziu4V5j73e46b7c), per the file-an-issue-for-a-contract-violation directive rather than widening that card's PR.Generated by Claude Code