Skip to content

feat(spec): the /packages doors declare the query parameters they execute, and retire the two they never did - #19364

Merged
os-sam merged 9 commits into
mainfrom
claude/issue-17667-packages-query-contract
Sep 20, 2026
Merged

os-sam merged 9 commits into
mainfrom
claude/issue-17667-packages-query-contract

Conversation

@os-sam

@os-sam os-sam commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

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 (GET /api/v1/notifications 从不解析它声明的请求 schema —— cursor 被静默丢弃(SDK 分页永远第一页),limit 默认 20 声明 vs 50 实现 #6361) and data.query.cursor ([P2] data: QueryAST declares 12 members no executor runs — the liveness ledger governs metadata types, not the request surface #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:generatedall 15 artifacts up to date, at the final head.
  • pnpm --filter @objectstack/spec test501 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 de8722127dc310542945489d) 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 GET /api/v1/notifications 从不解析它声明的请求 schema —— cursor 被静默丢弃(SDK 分页永远第一页),limit 默认 20 声明 vs 50 实现 #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 Add metamodel interfaces for ObjectQL/ObjectUI contract #1 of the unchanged ui sources emitted one enum-member ordering, build ✨ Set up Copilot instructions #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 (revert(spec): take back the declaration-text snapshot, restore the 27 signature hashes #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 shapeListExportJobsRequestSchema (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

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 9 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/authorable-defaults/api.json, packages/spec/authorable-surface/api.json, packages/spec/test-typecheck-debt.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/environment-routing.mdx (via /api/v1/packages (route, a path literal in PACKAGES_LIST_PAGINATION_REMOVED; a path literal in a comment in RETIRED_KEYS_BY_MAJOR; a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface; bridged from symbol ListInstalledPackagesRequestSchema — its route source's handler names it; bridged from symbol ListInstalledPackagesResponseSchema — its route source's handler names it))
  • content/docs/getting-started/examples.mdx (via /api/v1/packages (route, a path literal in PACKAGES_LIST_PAGINATION_REMOVED; a path literal in a comment in RETIRED_KEYS_BY_MAJOR; a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface; bridged from symbol ListInstalledPackagesRequestSchema — its route source's handler names it; bridged from symbol ListInstalledPackagesResponseSchema — its route source's handler names it))
  • content/docs/kernel/contracts/metadata-service.mdx (via /api/v1/packages (route, a path literal in PACKAGES_LIST_PAGINATION_REMOVED; a path literal in a comment in RETIRED_KEYS_BY_MAJOR; a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface; bridged from symbol ListInstalledPackagesRequestSchema — its route source's handler names it; bridged from symbol ListInstalledPackagesResponseSchema — its route source's handler names it), /api/v1/packages/:packageId (route, bridged from symbol GetInstalledPackageRequestSchema — its route source's handler names it; bridged from symbol UninstallPackageApiRequestSchema — its route source's handler names it))
  • content/docs/kernel/services-checklist.mdx (via /api/v1/packages (route, a path literal in PACKAGES_LIST_PAGINATION_REMOVED; a path literal in a comment in RETIRED_KEYS_BY_MAJOR; a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface; bridged from symbol ListInstalledPackagesRequestSchema — its route source's handler names it; bridged from symbol ListInstalledPackagesResponseSchema — its route source's handler names it))
  • content/docs/permissions/permission-sets.mdx (via /api/v1/packages (route, a path literal in PACKAGES_LIST_PAGINATION_REMOVED; a path literal in a comment in RETIRED_KEYS_BY_MAJOR; a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface; bridged from symbol ListInstalledPackagesRequestSchema — its route source's handler names it; bridged from symbol ListInstalledPackagesResponseSchema — its route source's handler names it))
  • content/docs/protocol/kernel/error-handling.mdx (via /api/v1/packages (route, a path literal in PACKAGES_LIST_PAGINATION_REMOVED; a path literal in a comment in RETIRED_KEYS_BY_MAJOR; a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface; bridged from symbol ListInstalledPackagesRequestSchema — its route source's handler names it; bridged from symbol ListInstalledPackagesResponseSchema — its route source's handler names it), /api/v1/packages/:packageId (route, bridged from symbol GetInstalledPackageRequestSchema — its route source's handler names it; bridged from symbol UninstallPackageApiRequestSchema — its route source's handler names it))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/packages (route, a path literal in PACKAGES_LIST_PAGINATION_REMOVED; a path literal in a comment in RETIRED_KEYS_BY_MAJOR; a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface; bridged from symbol ListInstalledPackagesRequestSchema — its route source's handler names it; bridged from symbol ListInstalledPackagesResponseSchema — its route source's handler names it))
  • content/docs/ui/apps.mdx (via /api/v1/packages (route, a path literal in PACKAGES_LIST_PAGINATION_REMOVED; a path literal in a comment in RETIRED_KEYS_BY_MAJOR; a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface; bridged from symbol ListInstalledPackagesRequestSchema — its route source's handler names it; bridged from symbol ListInstalledPackagesResponseSchema — its route source's handler names it))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via /api/v1/packages (route, a path literal in PACKAGES_LIST_PAGINATION_REMOVED; a path literal in a comment in RETIRED_KEYS_BY_MAJOR; a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface; bridged from symbol ListInstalledPackagesRequestSchema — its route source's handler names it; bridged from symbol ListInstalledPackagesResponseSchema — its route source's handler names it), /api/v1/packages/:packageId (route, bridged from symbol GetInstalledPackageRequestSchema — its route source's handler names it; bridged from symbol UninstallPackageApiRequestSchema — its route source's handler names it))
  • content/docs/releases/v17/17-4.mdx (via /api/v1/packages (route, a path literal in PACKAGES_LIST_PAGINATION_REMOVED; a path literal in a comment in RETIRED_KEYS_BY_MAJOR; a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface; bridged from symbol ListInstalledPackagesRequestSchema — its route source's handler names it; bridged from symbol ListInstalledPackagesResponseSchema — its route source's handler names it), /api/v1/packages/:packageId (route, bridged from symbol GetInstalledPackageRequestSchema — its route source's handler names it; bridged from symbol UninstallPackageApiRequestSchema — its route source's handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/authorable-defaults/api.json, packages/spec/authorable-surface/api.json, packages/spec/test-typecheck-debt.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b1d3945fb88db95826c0ad71266271d1602d68dcpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 2add408b57e8454157ee327b7748e562531ca9e0 — the merge of head 80937f5cffc34e2a5bc3a8d6f85661770577f2ea into base b1d3945fb88db95826c0ad71266271d1602d68dc, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2add408b57e8454157ee327b7748e562531ca9e0 && git checkout 2add408b57e8454157ee327b7748e562531ca9e0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b1d3945fb88db95826c0ad71266271d1602d68dc 80937f5cffc34e2a5bc3a8d6f85661770577f2ea && git checkout -B drift-repro b1d3945fb88db95826c0ad71266271d1602d68dc && git merge --no-ff 80937f5cffc34e2a5bc3a8d6f85661770577f2ea

node scripts/docs-audit/affected-docs.mjs --json b1d3945fb88db95826c0ad71266271d1602d68dc

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b1d3945fb88db95826c0ad71266271d1602d68dc → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-sam commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 80937f5cffc34e2a5bc3a8d6f85661770577f2ea

Isolated contract-review subagent for the domain:spec seat 3 loop, PR #19364 (draft, 11 files, +556/−28) on card #17667. Ruling 5651023067 (director seat, decision batch #126 item 1, maintainer 「同意」 2026-09-13, route 2, 1(2)·2A·3A·4B) is taken as binding and is not re-adjudicated here; routes 1 and 3 were refused there and nothing below asks for either. Every tree reading is against a linked review worktree checked out at 80937f5cffc34e2a5bc3a8d6f85661770577f2ea (merge-base with origin/main is 2277d1f; origin/main fetched at b1d3945 2026-09-20T13:37Z) and every CI reading is per check-run NAME on that head, newest run wins, read time stated. The subagent issued 74 tool requests over 15 assistant turns, counted by itself; it has no transcript of its own to grep against the tier constant's value, so no at-tier stamp control is written on the Served-tier: line and the tier declaration stands alone.

① Derived judgments

Ruling item 1 — the doors' declarations now say what the doors execute. Read side on origin/main b1d3945 (packages/runtime/src/domains/packages.ts): query?.type at :713, query?.overwrite at :822, readRequestedVersion(query?.version) at :1508, query?.keepData === 'true' || '1' at :1651. Declaration side at head (packages/spec/src/api/package-api.zod.ts): type: z.string().optional() on ListInstalledPackagesRequestSchema, version: z.string().optional() on GetInstalledPackageRequestSchema (now PackagePathParamsSchema.extend), keepData: z.boolean().optional() on UninstallPackageApiRequestSchema; each docblock is written from the handler's actual comparison (open string for type, exact-equality version with latest as the installed row, the two accepted keepData spellings). overwrite needed no edit (Zone 2 claim 2 verified): overwrite: z.boolean().optional() is on PackageInstallRequestSchema at merge-base 2277d1f and on origin/main (10 occurrences in the file, at :339 on main), landed by PR #19130, merged 2026-09-20T11:11:16Z (squash 596090e). The contract map binds all three (PackageApiContracts.listPackages/getPackage/uninstallPackage.input), pinned by the new test. No accept set moves.

Ruling item 3 — limit / cursor removed, .default(50) included. Both are retiredKey(PACKAGES_LIST_PAGINATION_REMOVED) tombstones (:258–259); the old z.number().int().min(1).max(100).default(50) line is gone. The prescription names the removed default specifically. hasMore stays z.boolean() with the constant-false reasoning recorded at the declaration; the door still returns the constant at packages.ts:745. authorable-defaults/api.json loses exactly api/ListInstalledPackagesRequest:limit = 50; authorable-surface/api.json gains the two [RETIRED] rows and the three new keys; api-surface/api.json and json-schema.manifest/api.json are name lists (no signatures) in which all three defs pre-existed, so their being untouched is correct. Changeset .changeset/17667-packages-query-contract.md: '@objectstack/spec': minor, BREAKING banner with the FROM → TO parse mapping, a paragraph headed on the removed default, Clause-②: yes, and the disposition marker adr-0087: registered packages-list-pagination-retired; Check Changeset green 13:33:49Z.

Ruling item 4 — STEP ZERO re-measured, and it did not stop the round (Zone 2 claim 1 holds). At head: the only cursor codec in packages/ is encodeStorageListCursor / decodeStorageListCursor in packages/spec/src/contracts/storage-service.ts (function definitions grepped tree-wide, non-test: exactly those two); its importers are that file, its test, and six files under packages/services/service-storage/ — zero under packages/runtime/src or packages/rest/src, and zero Cursor-named imports in those two trees at all. Lit controls on the same shape: parseIntegerParam is shared by packages/runtime/src/query-param.ts, domains/automation.ts, domains/notifications.ts (3 files), refuseUnknownQueryParams by packages/rest/src/query-allowlist.ts, rest-server.ts and a test (3 files). The response half agrees: nextCursor: is emitted (non-test, non-schema) only by the S3 and local storage adapters — no REST door mints a cursor — against a lit control of three hasMore: emit sites in door files. So the first conjunct of the stop (a platform-wide cursor convention) is false on its own. The second is false too: ListExportJobsRequestSchema (export.zod.ts:653–658, limit with .default(20) plus cursor), ListAiConversationsRequestSchema (protocol.zod.ts:2940–2943) and ListRunsRequestSchema (automation-api.zod.ts:515–527) still declare the pair, and GET /automation/:name/runs parses limit / cursor through the shared param helpers and answers hasMore: false as a constant (automation.ts:2616–2625). The seat has filed those as #19365. Proceeding was measured, not assumed.

Ruling item 2 and the closing keyword (Zone 2 claim 3 confirmed independently). On origin/main b1d3945, packages/runtime/src/domains/packages.ts: query?.enabled 0, control query?.status 1; the branch does not touch that file (git diff main..head on it: empty). No open PR implements it (35 open PRs listed; none names enabled on the packages door, and #19323 is the client either-stage change). So Part of #17667 is the correct closing line; Fixes would close a card with ruled item 2 undelivered. One thing the seat should know rather than discover: the body also contains the token Fixes #17667 — inside an inline code span, in the sentence that explains the override. The repo's own guard (scripts/check-partof-closing-keyword.mjs, measured live 2026-08-13: plain prose gains a closing link, fenced and inline targets gain none) strips code before scanning and its fixture is literally this sentence shape; the Part-of PR must not also close its card check is green (13:33:07Z). GraphQL closingIssuesReferences is not reachable from this session, so the platform-side reading rests on that measured arm, not on a fresh probe.

Retirement kit judged (Zone 2 claim 4): tombstones plus a D3 entry, no D2 conversion, no residue stage — correct, not a hole. A D2 conversion rewrites an authored source or a stored sys_metadata row; ListInstalledPackagesRequest is neither: references to ListInstalledPackagesRequestSchema outside packages/spec (non-test) are 0, against a lit control of ListRunsRequestSchema referenced from packages/runtime/src (3 files), and the first-party SDK's packages.list(filters?: { status?, type?, enabled? }) (packages/client/src/index.ts:2457) never carried limit or cursor. acceptRetiredDefaultResidue exists for a retired default that a parse already materialized into data (its docblock: the disposition of "the emitted default in already-parsed data"); with zero parse sites, no artifact ever carried limit: 50, so there is no residue population to accept, and the authorable-defaults row leaves with the key (check:authorable-surface green in CI, Type Check · source gates 13:35:37Z). The cited precedent stands: 17.api__ListNotificationsRequest__cursor.ts (#6361) with semantic entry 17.notification-list-cursor-retired.ts records the same "HTTP-only, NO D2 conversion" disposition, and src/conversions/registry.ts names neither ListNotificationsRequest nor ListInstalledPackagesRequest (control: pageName 14 hits). Registration at major 18 with a 17.5.0 guidance string matches 18.ui__ListView__pageName.ts / 18.security__ObjectPermission__allowPurge.ts at spec 17.4.0. No os migrate meta sentence in the prescription, correctly, since no conversion covers the surface. spec-changes.json (protocolVersion 17.0.0, no major-18 rows at all) and docs/protocol-upgrade-guide.md are unchanged for the same reason every other major-18 entry is absent from them; check:spec-changes and check:upgrade-guide are green in CI. check:migration-registry run locally at head: "registry.ts is current (232 semantic, 197 retired-key, 181 retired-def)", exit 0. Liveness ledger: 0 rows name ListInstalledPackages (control: liveness/api.json has rows); Spec property liveness green 13:33:37Z.

Ablation reproduced (Zone 2 claim 5). In the review worktree at 80937f5, the limit tombstone line was replaced with z.number().int().min(1).max(100).default(50) (anchor count 1 → 0, blob 4d3a558febe4b515), then vitest run --project local src/api/package-api.test.ts: exit 1, 2 failed / 68 passedaccepts a minimal request and materializes no window and refuses a retired limit with the prescription, not a bare unknown key, exactly the absence pin and the prescription pin. Baseline at head before the edit: 70 passed. Restored with git checkout HEAD -- and verified by blob identity (4d3a558f = HEAD: blob), empty git status --porcelain, empty git diff HEAD. Provenance note only: the dev's reported blob pair (de8722127dc310542945489d) is the file as of commits 526c4ac / 10dc9ff; the final commit 80937f5 changed 16+/7− lines of docblock in the same file, so the dev's ablation predates the final commit — this reading re-establishes it at the head under review.

Gates and CI (Zone 2 claim 6). CI on 80937f5cffc34e2a5bc3a8d6f85661770577f2ea, read 2026-09-20T14:01:36Z: all 35 check runs completed, 0 non-green, none in progress; Lint & Repo Gates success 14:00:11Z; Type Check · source gates (spec tsc --noEmit, check:generated --reconcile-only, check:spec-changes, check:upgrade-guide, check:export-origins, check:authorable-surface, check:docs, check:skill-refs, check:react-blocks, and the rest) success 13:35:37Z; Type Check · consumer gates (check:api-surface, check:exported-any, check:dual-source-exports, check:skill-examples, check:yaml-examples, check:react-declaration-parity --strict, i18n gates) success 13:43:04Z; Type Check · debt ledger (check:type-check-debt) success 13:41:03Z; Type Check · workspace (turbo typecheck, which runs spec's check:test-typecheck) success 13:49:29Z; TypeScript Type Check success 13:49:37Z; Build Core 13:41:23Z; Test Core 1/6–6/6 all success (last 13:50:48Z); Dogfood Regression Gate 13:43:54Z; Temporal Conformance (live PG + MySQL) 13:43:55Z; Governed Surface Queue Guard 13:33:42Z; Check Changeset 13:33:49Z; Spec property liveness 13:33:37Z; Part-of PR must not also close its card 13:33:07Z; Console Pin Gate and Packed-tarball smoke skipped. Of the dev's three NOT MEASURED gates, check:type-check-debt is exactly the green debt-ledger job and check:dual-build-cjs-loads runs in ci.yml; check-plugin-teardown-shape --self-test has no CI caller by that name and stays unmeasured here too. Locally at head: check:test-typecheck exit 0, check:migration-registry exit 0, the one test file 70/70. The dev's spec-suite count (501 files / 14662 tests) and the full check:generated aggregate were not re-run here; the per-gate CI jobs above cover the generated set gate by gate.

Zone 3 — the 4-line deletion from packages/spec/test-typecheck-debt.json is a real graduation, not a hidden regression. At merge-base the file imported GetInstalledPackageRequestSchema (:6) and UninstallPackageApiRequestSchema (:18) and used neither; the ledger carried one TS6133 per symbol. At head both are used by the new declaration tests (5 and 4 occurrences respectively). scripts/check-test-typecheck.mts is recorded-must-equal-measured: "a file reaches zero → red (graduated; delete the entry)", so keeping the entry would have been the red. Run locally at head: check:test-typecheck: OK — 53 file(s) / 257 error(s) / 142 pinned signature(s) held, exit 0 — the ledger minus this entry equals the measurement, so the file compiles clean under tsconfig.test.json and no new test-layer error arrived with the 87 added lines. CI Type Check · workspace agrees.

Zone 3 — the 8 hand-written docs pages: none documents pagination on the packages door, so none becomes false on merge. Each of content/docs/api/environment-routing.mdx, getting-started/examples.mdx, kernel/contracts/metadata-service.mdx, kernel/services-checklist.mdx, permissions/permission-sets.mdx, protocol/kernel/error-handling.mdx, protocol/kernel/http-protocol.mdx, ui/apps.mdx was scanned in a 12-line window after every /api/v1/packages / listPackages / packages.list / ListInstalledPackages mention for limit, cursor, nextCursor, hasMore, pagina…; control: the same window on the merge-base references/api/package-api.mdx lights on its limit, cursor and nextCursor rows (3 hits). Result across the 8: one hit, error-handling.mdx:2362, the inbound rate-limiter row of the error-shape table — not a pagination claim. Widened to all 296 hand-written .md/.mdx under content/docs (excluding references/ and releases/), skills/ and package READMEs: the same single false hit. What the pages do say stays true: examples.mdx:415 says GET /api/v1/packages "lists every" package (unpaginated), metadata-service.mdx:437 documents ?keepData=true, error-handling.mdx:420 documents ?version= on the by-id door — all now backed by the declaration. The three anchor-less changed files are ratchet and generated JSON with no documenting page to falsify. The two release-owned pages are read-only and out of scope.

Zone 3 — build-order wrong turn and the upstream deletion. packages/spec/api-surface-declarations/ is absent from head, from merge-base 2277d1f and from origin/main b1d3945 (git ls-tree count 0 on all three); the deletion is 2277d1f revert(spec): take back the declaration-text snapshot, restore the 27 signature hashes (#19024) on main; no commit in 2277d1f..80937f5 touches that directory and the PR's file list has no ui.txt. The branch does not carry it and nothing resurrects it. No api-surface-declarations reference remains in packages/spec/package.json.

② Semver level

minor on @objectstack/spec, with the BREAKING banner in the body. That is the ruling's item 5 (Clause-②: yes, declared conservatively, no arm) and the house form: the pending retire-list-view-page-mount.md retirement is spelled '@objectstack/spec': minor + BREAKING + adr-0087: registered, 23 pending spec minor changesets say BREAKING, zero say major, and check-changeset-no-major.mjs refuses a major bump. Declaring three executed filters widens the stated accept set; retiring two keys narrows it; the wire changes for none of them.

③ Boundary flags

  • nextCursor on ListInstalledPackagesResponseSchema is now unreachable by construction (hasMore constant false, no request-side way to ask) but stays declared z.string().optional() "Cursor for the next page". The ruling names the request pair and hasMore only, so it is not a defect of this PR and not widened here; the GET /api/v1/notifications 从不解析它声明的请求 schema —— cursor 被静默丢弃(SDK 分页永远第一页),limit 默认 20 声明 vs 50 实现 #6361 precedent retired api/ListNotificationsResponse:cursor alongside the request key, so the seat may want to fold this optional response residue into [finding] three sibling list doors carry the same declared-but-never-read limit/cursor shape that #17667 is retiring on /packages — export jobs, AI conversations and automation runs #19365 or file it (dedupe words: packages list response, nextCursor, never emitted, hasMore constant).
  • enabled is still declared-and-unread after this PR, recorded in the schema docblock and by Part of; the card stays open for the cli seat's sibling change. Nothing here closes it.
  • Governed surfaces: none of the 11 paths is under docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md or docs/NORTH-STAR.md; Governed Surface Queue Guard green. Size 584 changed lines, under the human-merge threshold. content/docs/references/api/package-api.mdx is the gen:docs output, not a hand edit (check:docs green).
  • Not measured here: the full spec vitest suite and the check:generated aggregate at head (covered gate by gate in CI as listed), check-plugin-teardown-shape --self-test, and any live-wire probe of the door (the ruling changes no runtime behaviour, and the PR touches no runtime file).
  • No label, assignee, draft flag, auto-merge or queue state was touched by this review; the review worktree was removed after the readings.

Implemented-by: claude/issue-17667-packages-query-contract
Reviewed-by: session_01HnRAeVTLJevtQ5iCPX6JSm

VERDICT: PASS


Generated by Claude Code

@os-sam
os-sam marked this pull request as ready for review September 20, 2026 14:05
@os-sam
os-sam enabled auto-merge September 20, 2026 14:06
@os-sam
os-sam added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit ada7012 Sep 20, 2026
44 checks passed
@os-sam
os-sam deleted the claude/issue-17667-packages-query-contract branch September 20, 2026 14:36
os-elon-musk pushed a commit that referenced this pull request Sep 20, 2026
… queue ahead of it

This step's first queue build ejected the PR that added it, and the reason was
neither the tree nor a crash: `exit 2` is this gate's own FINDINGS code, and the
findings were real citations written by somebody else.

MEASURED on queue entry `a7109d1f08`. A queue entry is built on the GROUP's
base, which carries the entries AHEAD of it in the queue and has not landed on
`main` yet. `merge-base origin/main HEAD` therefore lands at the PUBLISHED tip:
`231283a6e` at 14:30:14Z, while the group's base `8271c81425` reached `main`
only at 14:47:59Z. Everything between the two read as "added by this change" --
15 file(s) / 16 citations judged, 3 unresolvable, and all three written by the
two entries ahead: `#6361` twice from `ada701220` (#19364), `#18003` from
`8271c81425` (#19363). Against the group's own base the same tree judges 0
file(s). The Governed Surface Queue Guard, in the same build, read
`merge_group.base_sha` and correctly saw 1 commit and 178 changed lines.

Two halves, and the second is not cosmetic:

1. `lint.yml` declares the base -- `OS_GATE_MERGE_GROUP_BASE_SHA`, the name and
   the expression this file already uses for that fact. It renders empty on
   `pull_request` and `push`, where the ref guesses are CORRECT and are kept: a
   PR's merge ref already contains the main it was computed against.
   The step is not `if:`-skipped on `merge_group` -- this file asserts that
   every gate step here runs there.

2. The gate verifies the base resolves before handing it to `git diff`. It did
   not: an unresolvable `--base` threw `fatal: bad object` and exited 1, a
   failed read wearing a code that is neither the clean answer, the findings
   answer, nor the refusal. It now refuses with PREREQUISITE NOT MET (exit 3)
   and names every spelling tried and what to pass instead. Half 1 alone is
   inert -- the pre-change gate ignores the variable entirely -- and half 1 is
   what makes an unverified base reachable, so both are required.

Firing controls, on the real queue tree with `origin/main` pinned to the
published main of 14:30:14Z: ref guess -> exit 2 with the three findings,
reproducing the ejection; declared base -> exit 0; declared base absent from
the checkout -> exit 3; `--base` absent -> exit 3 (was: uncaught throw, exit 1).
`--self-test` covers all of it: 66 -> 73 cases, 7 batteries, and the
`diff-scope` battery floor moves 6 -> 13 so the new cases cannot stop running
unnoticed.

Also corrects the gate docblock sentence this wiring falsifies ("Neither is
installed here").

Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants