Skip to content

Commit ada7012

Browse files
Samclaude
andauthored
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>
1 parent 3270826 commit ada7012

11 files changed

Lines changed: 556 additions & 28 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): the `/packages` doors declare the query parameters they execute, and stop declaring the two they never did (#17667)
6+
7+
`GET /api/v1/packages` diverged from its own declared request contract in BOTH
8+
directions, on the same door, with the same `200`. This aligns the declaration
9+
with the reads, per the maintainer-approved ruling of 2026-09-13 (decision batch
10+
#126 item 1, route 2 of three).
11+
12+
**BREAKING** — `limit` and `cursor` no longer parse on
13+
`ListInstalledPackagesRequestSchema`, and `limit`'s `.default(50)` is gone with
14+
them. Both were declared here and read by nothing: the serving door filters on
15+
`status` / `type` and then returns every remaining row, so no page was ever
16+
withheld and no continuation token was ever minted. The response half's
17+
`nextCursor` has never been emitted, so a caller looping "until the cursor runs
18+
out" re-read the first and only page forever, with no error and no `400`.
19+
20+
```
21+
FROM ListInstalledPackagesRequestSchema.parse({})
22+
-> { limit: 50 } // a cap the server has never applied
23+
ListInstalledPackagesRequestSchema.parse({ limit: 1, cursor: 'x' })
24+
-> { limit: 1, cursor: 'x' } // both dropped on the wire, 200, every row
25+
26+
TO ListInstalledPackagesRequestSchema.parse({})
27+
-> {} // no window is declared, because none exists
28+
ListInstalledPackagesRequestSchema.parse({ limit: 1 })
29+
-> throws: '`limit` / `cursor` were removed from GET /api/v1/packages in
30+
@objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) …'
31+
```
32+
33+
**Read the removed default, not just the removed key.** `limit` carried
34+
`.default(50)`, so a reader of the published schema — an SDK, codegen, an AI
35+
client — was entitled to believe an unparameterised list is capped at 50 rows.
36+
It has never been capped at all. Nothing parses a query string through this
37+
schema, so that default has never been stamped onto anything; there is nothing
38+
to send instead and nothing to restore. **A client that sized a buffer or a
39+
page control to the declared 50 should size it to the installed set instead** —
40+
which is a bounded table of tens of rows, which is also why paging was removed
41+
rather than implemented.
42+
43+
Both keys are `retiredKey()` tombstones rather than deletions: the schema is not
44+
`.strict()`, so a bare deletion would have made Zod silently strip whatever a
45+
generated client kept sending — a clean parse and a parameter that never takes
46+
effect, which is this defect re-created one layer down (ADR-0104). Writing
47+
either key is now a `tsc` error and a parse error carrying the prescription.
48+
49+
**The other direction, and nothing on the wire changes for it.** Three query
50+
parameters the doors already executed were declared by no request schema, so
51+
they were invisible to anything generated from the contract:
52+
53+
| door | parameter | now declared on |
54+
|---|---|---|
55+
| `GET /api/v1/packages` | `type` — exact match against `manifest.type` | `ListInstalledPackagesRequestSchema` |
56+
| `GET /api/v1/packages/:id` | `version` — exact installed-version scope; `latest` reads the installed row | `GetInstalledPackageRequestSchema` |
57+
| `DELETE /api/v1/packages/:id` | `keepData` — keep object tables, remove metadata only | `UninstallPackageApiRequestSchema` |
58+
59+
No accept set moves: the doors served all three before and serve them
60+
identically now. `overwrite`, the fourth parameter the ruling named, was already
61+
declared on `PackageInstallRequestSchema` and needed nothing.
62+
63+
**`hasMore` stays the constant `false` it already was, and is now true by
64+
construction rather than by coincidence**: with no `limit` and no `cursor` to
65+
ask with, nothing can request a page, so there is never a next one to announce.
66+
67+
Clause-②: yes
68+
69+
<!-- adr-0087: registered packages-list-pagination-retired -->

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,14 @@ Installed package row whose manifest is the assembled package body
135135

136136
## GetInstalledPackageRequest
137137

138+
Get installed package request
139+
138140
### Properties
139141

140142
| Property | Type | Required | Description |
141143
| :--- | :--- | :--- | :--- |
142144
| **packageId** | `string` | ✅ | Package identifier |
145+
| **version** | `string` | optional | Scope the read to this exact installed version; `latest` or omitted reads the installed row |
143146

144147

145148
---
@@ -398,8 +401,9 @@ List installed packages request
398401
| :--- | :--- | :--- | :--- |
399402
| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional | Filter by package status |
400403
| **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. |
403407

404408

405409
---
@@ -447,7 +451,7 @@ List installed packages response
447451
| **packages** | `({ manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … } \| … +1 more)[]` | ✅ | Installed packages |
448452
| **total** | `integer` | optional | Total matching packages |
449453
| **nextCursor** | `string` | optional | Cursor for the next page |
450-
| **hasMore** | `boolean` | ✅ | Whether more packages are available |
454+
| **hasMore** | `boolean` | ✅ | Whether more packages are available — this door serves one page, so always `false` |
451455

452456

453457
---
@@ -971,11 +975,14 @@ Resolve dependencies response
971975

972976
## UninstallPackageApiRequest
973977

978+
Uninstall package request
979+
974980
### Properties
975981

976982
| Property | Type | Required | Description |
977983
| :--- | :--- | :--- | :--- |
978984
| **packageId** | `string` | ✅ | Package identifier |
985+
| **keepData** | `boolean` | optional | Preserve object tables and remove metadata only; on the wire, `?keepData=true` or `?keepData=1` |
979986

980987

981988
---

‎packages/spec/authorable-defaults/api.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@
9292
"api/ListFlowsRequest:limit = 50",
9393
"api/ListImportJobsRequest:limit = 50",
9494
"api/ListImportJobsRequest:offset = 0",
95-
"api/ListInstalledPackagesRequest:limit = 50",
9695
"api/ListRunsRequest:limit = 20",
9796
"api/LoginRequest:type = \"email\"",
9897
"api/MetadataBulkRegisterRequest:continueOnError = false",

‎packages/spec/authorable-surface/api.json‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,7 @@
757757
"api/GetFlowResponse:meta",
758758
"api/GetFlowResponse:success",
759759
"api/GetInstalledPackageRequest:packageId",
760+
"api/GetInstalledPackageRequest:version",
760761
"api/GetInstalledPackageResponse:data",
761762
"api/GetInstalledPackageResponse:error",
762763
"api/GetInstalledPackageResponse:meta",
@@ -1029,10 +1030,11 @@
10291030
"api/ListImportJobsRequest:offset",
10301031
"api/ListImportJobsRequest:status",
10311032
"api/ListImportJobsResponse:jobs",
1032-
"api/ListInstalledPackagesRequest:cursor",
1033+
"api/ListInstalledPackagesRequest:cursor [RETIRED]",
10331034
"api/ListInstalledPackagesRequest:enabled",
1034-
"api/ListInstalledPackagesRequest:limit",
1035+
"api/ListInstalledPackagesRequest:limit [RETIRED]",
10351036
"api/ListInstalledPackagesRequest:status",
1037+
"api/ListInstalledPackagesRequest:type",
10361038
"api/ListInstalledPackagesResponse:data",
10371039
"api/ListInstalledPackagesResponse:error",
10381040
"api/ListInstalledPackagesResponse:meta",
@@ -1719,6 +1721,7 @@
17191721
"api/UndoImportJobResponse:object",
17201722
"api/UndoImportJobResponse:restored",
17211723
"api/UndoImportJobResponse:success",
1724+
"api/UninstallPackageApiRequest:keepData",
17221725
"api/UninstallPackageApiRequest:packageId",
17231726
"api/UninstallPackageApiResponse:data",
17241727
"api/UninstallPackageApiResponse:error",

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

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,29 +43,109 @@ describe('PackagePathParamsSchema', () => {
4343
// ==========================================
4444

4545
describe('ListInstalledPackagesRequestSchema', () => {
46-
it('should accept minimal request with defaults', () => {
46+
// [#17667] These two cases used to pin `limit`'s `.default(50)` and a
47+
// round-tripped `cursor`. They are REPLACED rather than respelled: they
48+
// pinned exactly the branch route 2 deleted, so keeping them in any form
49+
// would have meant re-asserting a contract that no longer exists.
50+
it('accepts a minimal request and materializes no window', () => {
4751
const result = ListInstalledPackagesRequestSchema.parse({});
48-
expect(result.limit).toBe(50);
4952
expect(result.status).toBeUndefined();
5053
expect(result.enabled).toBeUndefined();
54+
expect(result.type).toBeUndefined();
55+
// ⭐ There is no default to apply any more: the door has never capped this
56+
// list, so an omitted request declares no window rather than a fictional
57+
// 50-row one.
58+
expect(result).not.toHaveProperty('limit');
5159
});
5260

53-
it('should accept full request', () => {
61+
it('accepts every filter the serving door actually executes', () => {
5462
const result = ListInstalledPackagesRequestSchema.parse({
5563
status: 'installed',
5664
enabled: true,
57-
limit: 20,
58-
cursor: 'abc123',
65+
type: 'app',
5966
});
6067
expect(result.status).toBe('installed');
6168
expect(result.enabled).toBe(true);
62-
expect(result.limit).toBe(20);
63-
expect(result.cursor).toBe('abc123');
69+
expect(result.type).toBe('app');
6470
});
6571

6672
it('should reject invalid status', () => {
6773
expect(() => ListInstalledPackagesRequestSchema.parse({ status: 'running' })).toThrow();
6874
});
75+
76+
// ── #17667 retirement pins: the prescription, and the absence ────────────
77+
//
78+
// The NEGATIVE half. A bare `.toThrow()` would be satisfied by any refusal,
79+
// including the generic unrecognized-key issue a plain deletion produces —
80+
// which is exactly the silent-ish failure the tombstone exists to replace.
81+
// So the assertion is the prescription text itself. The `s` flag is house
82+
// style: the message is one long string and matchers span its clauses.
83+
it.each(['limit', 'cursor'])('refuses a retired `%s` with the prescription, not a bare unknown key', (key) => {
84+
const result = ListInstalledPackagesRequestSchema.safeParse({ [key]: key === 'limit' ? 20 : 'abc123' });
85+
expect(result.success).toBe(false);
86+
const issue = result.error!.issues.find((i) => i.path.join('.') === key);
87+
expect(issue, `must fault on the \`${key}\` path`).toBeDefined();
88+
expect(issue!.message).toMatch(/`limit` \/ `cursor` were removed from GET \/api\/v1\/packages/s);
89+
expect(issue!.message).toMatch(/removed .*in @objectstack\/spec 17\.5\.0 \(ADR-0049 enforce-or-remove\)/s);
90+
// The `.default(50)` is named specifically — a reader who trusted the cap
91+
// is the consumer this retirement owes an explanation to.
92+
expect(issue!.message).toMatch(/`\.default\(50\)`/s);
93+
expect(issue!.message).toMatch(/Delete the key\./s);
94+
});
95+
96+
// The POSITIVE half. Absence still parses — the retirement removed a
97+
// declaration, not the ability to call the route without one.
98+
it('parses clean when neither retired key is sent', () => {
99+
const result = ListInstalledPackagesRequestSchema.safeParse({ status: 'installed' });
100+
expect(result.success).toBe(true);
101+
expect(result.data).not.toHaveProperty('cursor');
102+
});
103+
104+
// [#17667] `hasMore` is a constant `false` that is now true BY CONSTRUCTION:
105+
// with no request-side way to ask for a page, there can be no next one. This
106+
// pins the response half against a future author "fixing" the constant back
107+
// into a computed value without restoring a way to ask.
108+
it('declares a response that can honestly report one page', () => {
109+
const parsed = ListInstalledPackagesResponseSchema.parse({
110+
success: true,
111+
data: { packages: [], total: 0, hasMore: false },
112+
});
113+
expect(parsed.data.hasMore).toBe(false);
114+
expect(parsed.data).not.toHaveProperty('nextCursor');
115+
});
116+
});
117+
118+
// ==========================================
119+
// [#17667] Executed-but-undeclared query parameters, now declared
120+
// ==========================================
121+
122+
describe('the /packages doors declare the query parameters they execute (#17667)', () => {
123+
it('GET /packages/:id declares the `?version=` scope it honours', () => {
124+
const parsed = GetInstalledPackageRequestSchema.parse({ packageId: 'com.acme.crm', version: '1.2.3' });
125+
expect(parsed.packageId).toBe('com.acme.crm');
126+
expect(parsed.version).toBe('1.2.3');
127+
// `latest` is a literal the door treats as "the installed row" — it is a
128+
// plain string here, deliberately NOT a dist-tag or semver-range grammar.
129+
expect(GetInstalledPackageRequestSchema.parse({ packageId: 'p', version: 'latest' }).version).toBe('latest');
130+
// Omitted stays omitted: the by-id read is unscoped without it.
131+
expect(GetInstalledPackageRequestSchema.parse({ packageId: 'p' }).version).toBeUndefined();
132+
});
133+
134+
it('DELETE /packages/:id declares the `?keepData=` option it honours', () => {
135+
const parsed = UninstallPackageApiRequestSchema.parse({ packageId: 'com.acme.crm', keepData: true });
136+
expect(parsed.packageId).toBe('com.acme.crm');
137+
expect(parsed.keepData).toBe(true);
138+
// Omitted is the destructive default — storage goes with the metadata.
139+
expect(UninstallPackageApiRequestSchema.parse({ packageId: 'p' }).keepData).toBeUndefined();
140+
});
141+
142+
it('binds each declaration to the door that executes it', () => {
143+
// The contract map is what SDKs and codegen read; a declaration that is
144+
// right in the file and unbound in the map is invisible to both.
145+
expect(PackageApiContracts.listPackages.input).toBe(ListInstalledPackagesRequestSchema);
146+
expect(PackageApiContracts.getPackage.input).toBe(GetInstalledPackageRequestSchema);
147+
expect(PackageApiContracts.uninstallPackage.input).toBe(UninstallPackageApiRequestSchema);
148+
});
69149
});
70150

71151
// ==========================================

0 commit comments

Comments
 (0)