Skip to content

Commit 5c5b67f

Browse files
os-steveclaude
andauthored
fix(spec): the /packages list-door tombstone enumerates all three filters that door reads (#19407) (#19595)
Fixes #19407 Clause-②: no ## The defect, and the one thing that moves `limit` and `cursor` on `ListInstalledPackagesRequestSchema` are `retiredKey()` tombstones that both raise `PACKAGES_LIST_PAGINATION_REMOVED`. That prescription enumerated the serving door's filters as `status` / `type`. The door reads a **third**, `enabled`. ⭐ **Measured from the serving code, not from the card.** `handlePackagesRequest`'s `parts.length === 0 && m === 'GET'` branch, `packages/runtime/src/domains/packages.ts` on `origin/main` at `1f69917c5c`, reads exactly three query keys and no others: | key | read point | effect | | --- | --- | --- | | `enabled` | `readEnabledFilter(query?.enabled)` (:828), applied at :845 | `packageCountsAsEnabled(p) === enabled.value` | | `status` | `if (query?.status)` (:837) | `p.status === query.status` | | `type` | `if (query?.type)` (:840) | `p.manifest?.type === query.type` | No `limit`, no `cursor`, no fourth key. The card's framing holds: the enumeration named two of three. The `enabled` leg is PR #19405's; it is merged, so the premise is live and settled. ## Incomplete, ⛔ not falsified — and the diff is shaped by that The sentence's load-bearing claim — *no page was ever withheld and no continuation token was ever minted*, the argument for why `limit` / `cursor` were removable at all — **stays true**, because `enabled` filters rows and does not paginate. Nor did the sentence ever assert that `enabled` was unavailable: it enumerated, it did not exclude. ⇒ **only the enumeration moves.** The removability argument, the `.default(50)` passage, the `hasMore` passage and the closing cursor-design sentence are **byte-identical**. Proven by reassembling the concatenated literal before and after: the whole 986 → 1009 character string differs in exactly two places. ``` FROM … the serving door filters on `status` / `type` and then returns every remaining row … … Filter with `status` and `type` instead of asking for a window. TO … the serving door filters on `status` / `type` / `enabled` and then returns every remaining row … … Filter with `status`, `type` and `enabled` instead of asking for a window. ``` ## Every carrier of the sentence, and which one changed A sibling card exists because a near-identical sentence shipped in two entries and only one was repaired, so the sweep is recorded in full. Multiline regex sweep over every tracked file, five spellings, at `1f69917c5c`: | carrier | kind | changed here | | --- | --- | --- | | `packages/spec/src/api/package-api.zod.ts` — `PACKAGES_LIST_PAGINATION_REMOVED` | source of the raised string | ✅ yes | | `packages/spec/src/migrations/entries/semantic/18.packages-list-pagination-retired.ts` — `replacement` and `reason` | ADR-0087 D3 entry | ✅ yes | | `packages/spec/src/migrations/entries/retired-keys/18.api__ListInstalledPackagesRequest__limit.ts` | record comment | ✅ yes | | `packages/spec/src/migrations/registry.ts` (3 sites) | GENERATED mirror of the two above | ✅ regenerated, ⛔ never hand-edited | | `content/docs/references/api/package-api.mdx` (2 rows) | GENERATED from the spec | ✅ regenerated | | `packages/runtime/src/domains/packages.ts:873` | comment | already correct — #19405 wrote it as `status` / `type` / `enabled` | | `docs/protocol-upgrade-guide.md`, `packages/spec/spec-changes.json` | generated | do **not** carry the sentence; `check:upgrade-guide` and `check:spec-changes` both exit 0 | | `packages/*/CHANGELOG.md` | published | do **not** carry it — spec is at 17.4.0 and the tombstone names the unreleased 17.5.0, so nothing has shipped yet | | `.changeset/17667-packages-query-contract.md:14` | unreleased changeset | ⛔ **not changed** — outside this card's declared file surface. See acceptance notes. | | `.changeset/scoped-packages-dispatcher-door.md:13` | unreleased changeset | ⛔ **not changed** — same reason. | After this PR, zero carriers of the old enumeration remain under `packages/` or `content/`; the only two left in the tree are the two `.changeset/` entries above. ## One bounded in-place repair, declared `packages/spec/src/api/package-api.zod.ts` also carried a paragraph on `ListInstalledPackagesRequestSchema` reading **「`enabled` is still declared here and still unread by that door … the symmetry above is TRUE of `status` / `type` / `limit` / `cursor` and PENDING for `enabled`」**. That is **falsified** — not merely incomplete — by the same runtime change, and it sat forty lines from the tombstone this PR repairs, so shipping one without the other would land a self-contradicting file. It is inside this card's declared file surface, it is the same defect class from the same cause, no other in-flight branch holds the file, and it discharges on the condition the paragraph itself names (「The same ruling closes it (item 2 …) in the runtime, which is a different file and a different PR」). It is flipped to a closed record, keeping its guardrail against deleting `enabled`. Reviewers who want it split out: say so and it comes out. ## Pinning ⛔ No new pin. The repo's rule is that prescription prose is not pinned unless a consumer parses its exact text, and none does. The existing pin (`packages/spec/src/api/package-api.test.ts:88`) anchors the message's stable prefix (`` `limit` / `cursor` were removed from GET /api/v1/packages ``), not the enumeration, so it holds unchanged — and it still proves the tombstone is raised rather than a bare unrecognized-key issue. A mechanical pin tying the enumeration to the door's real read set would have to reach across packages into the runtime, which is a new verification surface and its own card. ## Verification — measured at `a755473aac` Gates the card named explicitly: ``` pnpm --filter @objectstack/spec run check:migration-registry :: exit 0 pnpm --filter @objectstack/spec run check:spec-changes :: exit 0 pnpm --filter @objectstack/spec run check:upgrade-guide :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 ``` Derived family, `scripts/pm/dispatch-gates.mjs --commands` over this branch's own change set (⛔ not a hand-written list), reconciled with `--ran`: ``` 104 derived famil(ies) accounted for — 103 run, 1 NOT-MEASURED, 0 UNRUN ``` The one NOT MEASURED is `pnpm check:dual-build-cjs-loads`, **exit 3 — PREREQUISITE NOT MET**, not a finding: it reads built output for the whole monorepo (26 packages with no `dist`). That is a repo-level scan CI owns. Everything else exits 0, including `check:generated` (all 15 artifacts current), `check:docs`, `check:api-surface`, `check:authorable-surface` and `check:nul-bytes`. Tests and types for the affected package — the public surface is byte-unchanged (`check:api-surface` exit 0), so no importer owes a run: ``` pnpm --filter @objectstack/spec test :: 509 test files, 14895 tests passed, 1 todo pnpm --filter @objectstack/spec typecheck :: exit 0 ``` Lint is a **proven narrowing**, not a skipped gate. eslint over the six changed files: exit 0, 0 errors. ① Population read from eslint's own config, not guessed — the `.md` and `.mdx` files report *File ignored because no matching configuration was supplied*, so four of six are in the lint population. ② Count read from `--format json`: 6 entries, 4 linted, 0 errors. ③ Invariance, quoted from `eslint.config.mjs`: *this repo runs one `eslint.config.mjs`, which never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file, test or not* ⇒ this diff cannot move the verdict on any file it does not touch. CI still runs the full sweep. Changeset: `patch`, and the publication was **measured, not assumed** — the new enumeration is present in four files under the package's declared `files[]` (`dist/api/index.js`, `dist/browser/api/index.{js,mjs}`) and in `src/api/package-api.zod.ts`, which `files[]` ships via `src/**/*.zod.ts`; positive control `Filter by enabled state` reads 8. So `skip-changeset` does not apply. ## Acceptance notes — ⛔ not fixed here 1. **Two unreleased changesets still carry the two-filter enumeration**, and both publish into `@objectstack/spec` / `@objectstack/runtime` 17.5.0 — the same release this repair ships in. `.changeset/17667-packages-query-contract.md:14` (*the serving door filters on `status` / `type` and then returns every remaining row*) and `.changeset/scoped-packages-dispatcher-door.md:13` (*this door applies its `status` / `type` filters and returns every remaining row*). Both are outside this card's declared file surface (`packages/spec/src/migrations/`, `packages/spec/src/api/`), so they are reported rather than widened into. ⚠️ This has a **deadline**: the release that consumes a changeset deletes it and publishes the sentence, after which a factual error is amendable only by a dedicated docs-only PR against the released `CHANGELOG.md` entry. Cheapest moment to repair them is before that release. 2. **`ListInstalledPackagesResponseSchema.nextCursor` is still declared and never emitted.** Recorded as an observation only: the retirement deliberately left the response half alone, and the `hasMore` comment argues why. Named here so the next reader of this declaration does not mistake it for something this card left half-done. Authored in Claude Code session `session_01AmH9bKvGoLjiY86Q4Z3og2` (https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2). --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 236cec1 commit 5c5b67f

6 files changed

Lines changed: 56 additions & 18 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): the `/packages` list-door tombstone enumerates all three filters that door reads (#19407)
6+
7+
`limit` and `cursor` on `ListInstalledPackagesRequestSchema` both raise
8+
`PACKAGES_LIST_PAGINATION_REMOVED`, and that prescription enumerated the serving
9+
door's filters as `status` / `type`. The door reads a **third**, `enabled`
10+
(`readEnabledFilter`, `packages/runtime/src/domains/packages.ts`), so the
11+
prescription named two of three and pointed an upgrader at a narrower answer
12+
than the route actually offers.
13+
14+
**Incomplete, not wrong — and only the enumeration moves.** The sentence's
15+
load-bearing claim, *no page was ever withheld and no continuation token was
16+
ever minted*, is untouched and stays true: `enabled` filters rows, it does not
17+
paginate. The removability argument, the `.default(50)` passage and the
18+
`hasMore` passage are byte-identical. Nor did the sentence ever assert that
19+
`enabled` was unavailable — it enumerated, it did not exclude — so nothing here
20+
reverses a claim.
21+
22+
```
23+
FROM … the serving door filters on `status` / `type` and then returns every
24+
remaining row …
25+
… Filter with `status` and `type` instead of asking for a window.
26+
27+
TO … the serving door filters on `status` / `type` / `enabled` and then
28+
returns every remaining row …
29+
… Filter with `status`, `type` and `enabled` instead of asking for a window.
30+
```
31+
32+
The same repair lands on every carrier of the sentence inside the spec: the
33+
tombstone string, and the ADR-0087 D3 semantic entry
34+
`packages-list-pagination-retired` in both its `replacement` (what to use
35+
instead) and its `reason` (what the door reads). The generated migration
36+
registry and the generated reference page follow from the repo's own
37+
generators.
38+
39+
No accept set moves, no key is added or removed, and no type changes: `limit`
40+
and `cursor` stay `retiredKey()` tombstones typed `never`, and `enabled` was
41+
already declared and already published as a filter on this request.

‎content/docs/references/api/package-api.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -402,8 +402,8 @@ List installed packages request
402402
| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional | Filter by package status |
403403
| **enabled** | `boolean` | optional | Filter by enabled state |
404404
| **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. |
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` / `enabled` 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`, `type` and `enabled` 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` / `enabled` 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`, `type` and `enabled` 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. |
407407

408408

409409
---

‎packages/spec/src/api/package-api.zod.ts‎

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -199,15 +199,15 @@ export type InstalledPackageAtEitherStageParsed = z.infer<typeof InstalledPackag
199199
const PACKAGES_LIST_PAGINATION_REMOVED =
200200
'`limit` / `cursor` were removed from GET /api/v1/packages in @objectstack/spec 17.5.0 '
201201
+ '(ADR-0049 enforce-or-remove) — both were declared here and read by nothing: the '
202-
+ 'serving door filters on `status` / `type` and then returns every remaining row, so '
202+
+ 'serving door filters on `status` / `type` / `enabled` and then returns every remaining row, so '
203203
+ 'no page was ever withheld and no continuation token was ever minted. `limit` also '
204204
+ 'declared `.default(50)`, so a reader of the published schema was entitled to believe '
205205
+ 'an unparameterised list is capped at 50 rows; it has never been capped at all, and '
206206
+ 'nothing parses a query string through this schema, so that default has never been '
207207
+ 'stamped onto anything. Delete the key. This route is NOT paginated — it answers the '
208208
+ 'whole installed set, which is a bounded table of tens of rows, and `hasMore` on the '
209209
+ 'response is a constant `false` that is now true by construction. Filter with '
210-
+ '`status` and `type` instead of asking for a window. A first-class package cursor, if '
210+
+ '`status`, `type` and `enabled` instead of asking for a window. A first-class package cursor, if '
211211
+ 'one is ever designed, will be a response-minted opaque token, not this key.';
212212

213213
/**
@@ -221,14 +221,11 @@ const PACKAGES_LIST_PAGINATION_REMOVED =
221221
* was executed and undeclared, `limit` / `cursor` were declared and never
222222
* executed.
223223
*
224-
* ⚠️ ONE key is not there yet, and it is recorded rather than glossed:
225-
* **`enabled` is still declared here and still unread by that door.** The same
226-
* ruling closes it (item 2 — one filter line, the shape `status` already has)
227-
* in the runtime, which is a different file and a different PR, so the
228-
* symmetry above is TRUE of `status` / `type` / `limit` / `cursor` and PENDING
229-
* for `enabled`. ⛔ Do not read this docblock as saying the divergence is
230-
* fully closed, and ⛔ do not close it by deleting `enabled` — the ruling
231-
* chose to implement that one, not to retire it.
224+
* ⭐ The `enabled` leg is closed too, so the symmetry above holds for every
225+
* key without exception: item 2 of the same ruling made that door read
226+
* `enabled` (`readEnabledFilter`, the filter line beside the `status` one).
227+
* ⛔ Do not "close" it the other way by deleting `enabled` — the ruling
228+
* chose to implement that key, not to retire it, and the door reads it.
232229
*
233230
* ⛔ Never add a key here that the door does not read. A declared-and-ignored
234231
* query parameter fails undetectably: the caller is answered `200` with the

‎packages/spec/src/migrations/entries/retired-keys/18.api__ListInstalledPackagesRequest__limit.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
// `api/package-api.zod.ts`.
88
//
99
// `GET /api/v1/packages` declared a window it has never applied. The serving
10-
// door filters on `status` / `type` and returns every remaining row, so a
10+
// door filters on `status` / `type` / `enabled` and returns every remaining row, so a
1111
// caller asking for one row was handed the whole table together with a
1212
// `hasMore: false` that agreed with it — the silent-widening half of the
1313
// ingress rule that names this exact parameter as the one whose drop is worst.

‎packages/spec/src/migrations/entries/semantic/18.packages-list-pagination-retired.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const entry: SemanticMigration = {
1111
+ 'GET /api/v1/packages declared by ListInstalledPackagesRequestSchema. The same entry '
1212
+ 'covers the limit default: the request schema no longer declares default(50)',
1313
replacement:
14-
'the `status` and `type` filters — this route answers the whole installed set and has '
14+
'the `status`, `type` and `enabled` filters — this route answers the whole installed set and has '
1515
+ 'no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted '
1616
+ 'one, so no caller holds a value to carry over, and the response `nextCursor` it '
1717
+ 'would have paired with was never emitted. Callers that looped on it were re-reading '
@@ -25,7 +25,7 @@ export const entry: SemanticMigration = {
2525
+ 'item 1, maintainer 「同意」 2026-09-13, route 2 of three; routes 1 — build paging — '
2626
+ 'and 3 — refuse unknown names — were considered and refused). `limit` and `cursor` '
2727
+ 'were declared on the request and honoured on neither: the serving door filters on '
28-
+ '`status` and `type` and then returns every remaining row, and no emit site has ever '
28+
+ '`status`, `type` and `enabled` and then returns every remaining row, and no emit site has ever '
2929
+ 'written the response half `nextCursor`. `limit` is the sharper of the two because '
3030
+ "the repo's own ingress rule names it as the parameter whose silent drop is worst, "
3131
+ 'and it is the silent-WIDENING half that was live: a caller asking for one row was '

‎packages/spec/src/migrations/registry.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10180,7 +10180,7 @@ const step18: MigrationStep = {
1018010180
+ 'GET /api/v1/packages declared by ListInstalledPackagesRequestSchema. The same entry '
1018110181
+ 'covers the limit default: the request schema no longer declares default(50)',
1018210182
replacement:
10183-
'the `status` and `type` filters — this route answers the whole installed set and has '
10183+
'the `status`, `type` and `enabled` filters — this route answers the whole installed set and has '
1018410184
+ 'no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted '
1018510185
+ 'one, so no caller holds a value to carry over, and the response `nextCursor` it '
1018610186
+ 'would have paired with was never emitted. Callers that looped on it were re-reading '
@@ -10194,7 +10194,7 @@ const step18: MigrationStep = {
1019410194
+ 'item 1, maintainer 「同意」 2026-09-13, route 2 of three; routes 1 — build paging — '
1019510195
+ 'and 3 — refuse unknown names — were considered and refused). `limit` and `cursor` '
1019610196
+ 'were declared on the request and honoured on neither: the serving door filters on '
10197-
+ '`status` and `type` and then returns every remaining row, and no emit site has ever '
10197+
+ '`status`, `type` and `enabled` and then returns every remaining row, and no emit site has ever '
1019810198
+ 'written the response half `nextCursor`. `limit` is the sharper of the two because '
1019910199
+ "the repo's own ingress rule names it as the parameter whose silent drop is worst, "
1020010200
+ 'and it is the silent-WIDENING half that was live: a caller asking for one row was '
@@ -13363,7 +13363,7 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly<Record<number, readonly string[]>>
1336313363
// `api/package-api.zod.ts`.
1336413364
//
1336513365
// `GET /api/v1/packages` declared a window it has never applied. The serving
13366-
// door filters on `status` / `type` and returns every remaining row, so a
13366+
// door filters on `status` / `type` / `enabled` and returns every remaining row, so a
1336713367
// caller asking for one row was handed the whole table together with a
1336813368
// `hasMore: false` that agreed with it — the silent-widening half of the
1336913369
// ingress rule that names this exact parameter as the one whose drop is worst.

0 commit comments

Comments
 (0)