You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(spec): the /packages doors declare the query parameters they execute, and retire the two they never did (#19364)
Part of #17667
Clause-②: yes
Ruling of record: comment `5651023067` — director seat, decision batch
#126 item 1, maintainer 「同意」 (live PM chat 2026-09-13) to
`1(2)·2A·3A·4B`. **Route 2**: the door's declaration and its reads are
aligned. ⛔ Not re-adjudicated here.
`Part of`, not `Fixes`, and the reason is measured rather than cautious
— see **Why this does not close the card** below. The dispatch asked for
`Fixes#17667`; that instruction is overridden by the standing rule that
a merge which should not close a card uses `Part of` and names the half
it leaves. Flagged rather than silently chosen.
## STEP ZERO first — the ruling's own precondition did NOT stop the work
Ruling item 4 makes the dispatch's first act a stop condition: if a
platform-wide cursor convention already exists and `/packages` is the
only holdout, route 1 **by reuse** is re-priced and the taker stops.
Measured in this worktree at `81e12e1`, 2026-09-20T12:05Z:
- **No shared pagination helper reaches any REST list door.** The only
cursor codec in the tree is `encodeStorageListCursor` /
`decodeStorageListCursor`
(`packages/spec/src/contracts/storage-service.ts`) — the
storage-**adapter** `list()` contract. Imports of it outside
`service-storage` and its own contract file: **zero**. Imports of any
`Cursor`-named symbol by `packages/runtime/src/**` or
`packages/rest/src/**`: **zero**.
- **Lit controls on the same greps, so those zeros are readable.**
`parseIntegerParam` (`packages/runtime/src/query-param.ts`) IS found
shared across two dispatcher domains, and `refuseUnknownQueryParams` IS
found shared across two `packages/rest` files. The search finds shared
helpers when they exist.
- **`/packages` is not the only holdout — it is one of four.**
`ListExportJobsRequestSchema`, `ListAiConversationsRequestSchema` and
`ListRunsRequestSchema` all declare `limit` and/or `cursor`; none
paginates. `GET /automation/:name/runs` even validates `cursor` at the
boundary and then returns `{ runs, hasMore: false }`, with its own
comment recording that "today's engine ignores the option entirely" —
the same shape as this door, one domain over.
- **The platform's travel is the other way.**
`api/ListNotificationsRequest:cursor` (#6361) and `data.query.cursor`
(#4286) were both retired before this one.
⇒ the stop condition is false in both of its conjuncts. Proceeding to
items 1 and 3 was measured, not assumed.
## Ruling item 1 — declare what the doors already execute
| door | parameter | read site | now declared on |
|---|---|---|---|
| `GET /api/v1/packages` | `type` | list branch, `manifest.type`
equality | `ListInstalledPackagesRequestSchema` |
| `GET /api/v1/packages/:id` | `version` |
`readRequestedVersion(query?.version)` |
`GetInstalledPackageRequestSchema` |
| `DELETE /api/v1/packages/:id` | `keepData` | uninstall branch |
`UninstallPackageApiRequestSchema` |
| `POST /api/v1/packages` | `overwrite` | install branch | **already
declared — see below** |
No accept set moves: the doors served all four before and serve them
identically now.
Each declaration is measured from the handler's actual read, not from
the card:
- **`type`** is an open `z.string()`, deliberately not an enum. The door
compares `manifest.type === query.type` on any non-empty value, and
`ManifestSchema.type` is no shared vocabulary — a narrower declaration
would state a rejection this wire does not perform. An unmatched value
is not an error; it selects nothing.
- **`version`** is a plain string. `latest` means "the installed row"
and is equivalent to omitting the key; comparison is exact string
equality against `manifest.version`, with no semver-range semantics, and
the id is resolved first so an unknown id keeps its existing 404
wording. All of that is in the key's docblock so the next reader does
not have to open the runtime.
- **`keepData`** is declared boolean, and the docblock records the two
spellings the wire actually honours — `keepData=true` and `keepData=1` —
and warns that anything else, `keepData=yes` included, reads as absent
and DROPS the tables. Widening the door's own comparison would be a
runtime change this declaration is not.
### ⚠️ Premise drift: `overwrite` was already discharged, by the PR that
unblocked this card
The card's body (2026-09-11) lists `?overwrite=` as read-and-undeclared.
That is no longer true. PR #19130 merged 2026-09-20T11:11:16Z — the same
landing this card had been serialised behind — and it declares
`overwrite: z.boolean().optional()` on `PackageInstallRequestSchema`,
with a docblock that already names the `?overwrite=true` query spelling.
One quarter of ruling item 1 needed nothing. **No edit was made for
it**, deliberately: re-declaring it would have been churn, and the
existing declaration is better than one written from the card.
## Ruling item 3 — retire `limit` and `cursor`, `.default(50)` included
Both keys are `retiredKey()` tombstones, not deletions. The schema is
not `.strict()`, so a bare deletion makes Zod silently strip whatever a
generated client keeps sending — a clean parse and a parameter that
never takes effect, which is this card's own defect moved one layer down
(ADR-0104). Writing either key is now a `tsc` error and a parse error
carrying the prescription.
The prescription names the removed default specifically, because that is
the load-bearing half: a reader of the published schema was entitled to
believe an unparameterised list is capped at 50 rows, and it has never
been capped at all.
**The retirement kit, and the two entries it deliberately does NOT
have.** Precedent hunted and followed:
`api/ListNotificationsRequest:cursor` (#6361) is the same shape one
route over — an HTTP-only request key retired with a tombstone and a D3
semantic entry. Zone 2 flagged this precedent as unverified by the seat;
it exists, and this change copies it.
- `RETIRED_KEYS_BY_MAJOR[18]` — two entries, one file each, generated
into `migrations/registry.ts` by `gen:migration-registry`.
- D3 semantic entry `packages-list-pagination-retired`, carrying
`surface` / `replacement` / `reason` / `acceptanceCriteria` to
`spec-changes.json`, the generated upgrade guide and `os migrate meta`.
- Registered at **18, not 17**, per the `ui/ListView:pageName` and
`security/ObjectPermission:allowPurge` convention: the removal ships on
the 17.x line as a minor, and the prescription lives at the major
boundary where `migrate meta` users look. The guidance string says
`17.5.0`, the shipping version, matching `view.pageName`.
- **No D2 conversion**, and the asymmetry is the point: a conversion
rewrites an authored source or a stored `sys_metadata` row, and this
shape is HTTP-only — nobody authors a `ListInstalledPackagesRequest` and
nothing persists one. The `os migrate meta` house sentence is therefore
correctly absent from the prescription; the pin only judges
prescriptions that name the command.
- **No `acceptRetiredDefaultResidue` stage**, for the same reason one
layer along. That helper exists for a retired default the published
toolchain materialized into built artifacts. Nothing has ever parsed
this schema, so the `.default(50)` reached no artifact and there is no
residue population. The `authorable-defaults/api.json` line simply
leaves with the key — `DEFAULT_CHANGES_BY_MAJOR` excludes retirements by
name, and `check:authorable-surface` accepted it without one.
- **No liveness-ledger row** to touch: `liveness/api.json` is the `api`
METADATA type's ledger, not the spec `api/` category. Zero occurrences
of `ListInstalledPackages` in it.
**Ratchet readings, stated because their direction is route-dependent:**
`authorable-surface/api.json` gains two `[RETIRED]` rows and three new
keys; `authorable-defaults/api.json` loses exactly the `= 50` line;
`api-surface/` is unchanged, which is correct for a key-level narrowing
on a surviving def.
### `hasMore` is now true by construction, and the comment says so
`hasMore` stays the constant `false` it already was. With no `limit` and
no `cursor` to ask with, nothing can request a page, so there is never a
next one to announce. That is recorded at the response declaration — the
return site itself is in `packages/runtime/src/domains/packages.ts`,
which this dispatch is fenced off — with an explicit warning against
"fixing" the constant back into a computed value before a request-side
way to ask exists. Pinned by a test.
## Why this does not close the card
Ruling item 2 — `enabled` implemented in
`packages/runtime/src/domains/packages.ts`, one filter line in the shape
`status` already has — is assigned by the ruling to the **cli seat's
sibling PR** and is fenced off this dispatch. Measured on the merged
`origin/main` at `2277d1f`, 2026-09-20T13:30Z: `query?.enabled` occurs
**0** times in that file; control on the same file, `query?.status`
occurs **1** time. So `enabled` is still declared-and-unread after this
PR, which is one live instance of the very class this card names.
That is recorded in the schema docblock rather than glossed, and it is
why the closing line is `Part of`. PR #19326, which held that file
during dispatch, turns out to be the manifest-`version` change for
#19120 and has merged; it is not the `enabled` sibling.
## Verification
Readings taken in this worktree; the gate union below was run after the
final commit, at `80937f5`.
- **Gate family, derived from the real changed paths**
(`scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, re-derived after the merge): **107 derived
· 104 run green · 3 NOT MEASURED · 0 UNRUN · 0 red**, reconciled with
`--ran` carrying each exit code captured before any pipe. The three NOT
MEASURED are the gates' own `exit 3` PREREQUISITE NOT MET:
`check:dual-build-cjs-loads` (wants a whole-repo build),
`check:type-check-debt` (a re-measure, which exits 3 by design and is a
maintainer's act to act on), and `check-plugin-teardown-shape
--self-test` (wants an unshallow checkout). None is a finding.
- `pnpm --filter @objectstack/spec check:generated` — **all 15 artifacts
up to date**, at the final head.
- `pnpm --filter @objectstack/spec test` — **501 files / 14662 tests
passed**. `pnpm --filter @objectstack/spec typecheck` — clean.
- `check:test-typecheck` GRADUATED `src/api/package-api.test.ts`: its
two recorded `TS6133` unused-import errors are gone because the new
tests use both symbols, so the shrink-only ledger entry is deleted in
this PR, as that ratchet requires.
- **Reverse verification (one-off, not left in the tree).** The `limit`
tombstone was replaced on disk with its old
`z.number().int().min(1).max(100).default(50)` via
`scripts/ablation-replace.mjs`, which proved the write landed (anchor 1
→ 0, blob `de8722127dc3` → `10542945489d`) before running anything.
Direction observed: **red**, 2 failed / 68 passed — both the
prescription pin and the absence pin fire. Restore verified by blob
identity against `HEAD` and an empty `git diff HEAD`, by the tool, not
by an exit code.
- **Absence sweep, tree-scoped, with lit controls.** Authoring sites for
`limit` / `cursor` on this request shape outside the new registry
entries: **zero**; `packages.list(` calls passing either: **zero**.
Controls: `overwrite` IS found in the same spec file (10 hits) and
`packages.list(` IS found across five files by the same pattern shape.
The first-party SDK already declares `list(filters?: { status, type,
enabled })` — no `limit`, no `cursor` — so unlike #6361 there is no
shipped producer to delete alongside the key.
## Acceptance notes
Out of scope, noted and deliberately not filed:
- **`gen:api-surface-declarations` output was not stable across builds
of identical sources**, and it cost this run a wrong turn worth
recording. Build #1 of the unchanged `ui` sources emitted one
enum-member ordering, build #2 emitted another; 184 lines of
`api-surface-declarations/ui.txt` flipped between them, and a single
control build at BASE reproduced BASE — which made one sample look like
proof that my diff caused the churn. It did not. The correct reading
needed three builds. **This finding has no surviving consumer**:
`origin/main` at `2277d1f` reverted the whole declaration-text snapshot
(#19024) and deleted `api-surface-declarations/` along with its gate,
which is also the merge conflict this branch resolved by accepting the
deletion. Successor: none. Recorded here rather than filed because the
artefact and the gate that read it no longer exist.
- **Three sibling list doors carry the same declared-not-honoured
pagination shape** — `ListExportJobsRequestSchema` (`limit` with
`.default(20)`, `cursor`), `ListAiConversationsRequestSchema` (`limit`,
`cursor`) and `ListRunsRequestSchema` (`limit`, `cursor`, the last
validated at the boundary and then ignored by the engine, with `hasMore:
false` hard-coded). This is a reproducible contract divergence of
exactly this card's class, on doors this card does not name, and the
handback report carries it with dedupe words for the seat to file. ⛔ Not
filed from here and ⛔ not widened onto this PR.
- The `/packages` dispatcher domain declares no closed query-parameter
set, so an unrecognised name is still dropped rather than refused. That
is route 3, which the ruling considered and refused; noted so a later
reader does not read this PR as having taken it. Successor: whoever
converts the dispatcher domains per the incremental ingress lane.
Landing waits for the seat: this PR is a contract-review carrier and the
seat handles both the carrier and the at-tier review. Nothing here flips
ready, enqueues, arms auto-merge, requests review, or writes a label or
assignee.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: content/docs/references/api/package-api.mdx
+10-3Lines changed: 10 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -135,11 +135,14 @@ Installed package row whose manifest is the assembled package body
135
135
136
136
## GetInstalledPackageRequest
137
137
138
+
Get installed package request
139
+
138
140
### Properties
139
141
140
142
| Property | Type | Required | Description |
141
143
| :--- | :--- | :--- | :--- |
142
144
|**packageId**|`string`| ✅ | Package identifier |
145
+
|**version**|`string`| optional | Scope the read to this exact installed version; `latest` or omitted reads the installed row |
143
146
144
147
145
148
---
@@ -398,8 +401,9 @@ List installed packages request
398
401
| :--- | :--- | :--- | :--- |
399
402
|**status**|`Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>`| optional | Filter by package status |
400
403
|**enabled**|`boolean`| optional | Filter by enabled state |
401
-
|**limit**|`integer`| optional (default: `50`) | Maximum number of packages to return |
402
-
|**cursor**|`string`| optional | Cursor for pagination |
404
+
|**type**|`string`| optional | Filter by the installed manifest's `type` — exact match, unmatched values select nothing |
405
+
| **limit** | `never` | optional | [REMOVED] `limit` / `cursor` were removed from GET /api/v1/packages in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — both were declared here and read by nothing: the serving door filters on `status` / `type` and then returns every remaining row, so no page was ever withheld and no continuation token was ever minted. `limit` also declared `.default(50)`, so a reader of the published schema was entitled to believe an unparameterised list is capped at 50 rows; it has never been capped at all, and nothing parses a query string through this schema, so that default has never been stamped onto anything. Delete the key. This route is NOT paginated — it answers the whole installed set, which is a bounded table of tens of rows, and `hasMore` on the response is a constant `false` that is now true by construction. Filter with `status` and `type` instead of asking for a window. A first-class package cursor, if one is ever designed, will be a response-minted opaque token, not this key. |
406
+
| **cursor** | `never` | optional | [REMOVED] `limit` / `cursor` were removed from GET /api/v1/packages in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — both were declared here and read by nothing: the serving door filters on `status` / `type` and then returns every remaining row, so no page was ever withheld and no continuation token was ever minted. `limit` also declared `.default(50)`, so a reader of the published schema was entitled to believe an unparameterised list is capped at 50 rows; it has never been capped at all, and nothing parses a query string through this schema, so that default has never been stamped onto anything. Delete the key. This route is NOT paginated — it answers the whole installed set, which is a bounded table of tens of rows, and `hasMore` on the response is a constant `false` that is now true by construction. Filter with `status` and `type` instead of asking for a window. A first-class package cursor, if one is ever designed, will be a response-minted opaque token, not this key. |
403
407
404
408
405
409
---
@@ -447,7 +451,7 @@ List installed packages response
0 commit comments