feat(spec): the /packages doors declare the query parameters they execute, and retire the two they never did - #19364
Conversation
…ckages-query-contract # Conflicts: # packages/spec/api-surface-declarations/api.txt
… by the sibling runtime change
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: Isolated contract-review subagent for the ① Derived judgmentsRuling item 1 — the doors' declarations now say what the doors execute. Read side on Ruling item 3 — 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 Ruling item 2 and the closing keyword (Zone 2 claim 3 confirmed independently). On 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 Ablation reproduced (Zone 2 claim 5). In the review worktree at Gates and CI (Zone 2 claim 6). CI on Zone 3 — the 4-line deletion from Zone 3 — the 8 hand-written docs pages: none documents pagination on the packages door, so none becomes false on merge. Each of Zone 3 — build-order wrong turn and the upstream deletion. ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
… 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>
Part of #17667
Clause-②: yes
Ruling of record: comment
5651023067— director seat, decision batch #126 item 1, maintainer 「同意」 (live PM chat 2026-09-13) to1(2)·2A·3A·4B. Route 2: the door's declaration and its reads are aligned. ⛔ Not re-adjudicated here.Part of, notFixes, and the reason is measured rather than cautious — see Why this does not close the card below. The dispatch asked forFixes #17667; that instruction is overridden by the standing rule that a merge which should not close a card usesPart ofand 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
/packagesis the only holdout, route 1 by reuse is re-priced and the taker stops. Measured in this worktree at81e12e1, 2026-09-20T12:05Z:encodeStorageListCursor/decodeStorageListCursor(packages/spec/src/contracts/storage-service.ts) — the storage-adapterlist()contract. Imports of it outsideservice-storageand its own contract file: zero. Imports of anyCursor-named symbol bypackages/runtime/src/**orpackages/rest/src/**: zero.parseIntegerParam(packages/runtime/src/query-param.ts) IS found shared across two dispatcher domains, andrefuseUnknownQueryParamsIS found shared across twopackages/restfiles. The search finds shared helpers when they exist./packagesis not the only holdout — it is one of four.ListExportJobsRequestSchema,ListAiConversationsRequestSchemaandListRunsRequestSchemaall declarelimitand/orcursor; none paginates.GET /automation/:name/runseven validatescursorat 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.api/ListNotificationsRequest:cursor(GET /api/v1/notifications 从不解析它声明的请求 schema ——cursor被静默丢弃(SDK 分页永远第一页),limit默认 20 声明 vs 50 实现 #6361) anddata.query.cursor([P2] data:QueryASTdeclares 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
GET /api/v1/packagestypemanifest.typeequalityListInstalledPackagesRequestSchemaGET /api/v1/packages/:idversionreadRequestedVersion(query?.version)GetInstalledPackageRequestSchemaDELETE /api/v1/packages/:idkeepDataUninstallPackageApiRequestSchemaPOST /api/v1/packagesoverwriteNo 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:
typeis an openz.string(), deliberately not an enum. The door comparesmanifest.type === query.typeon any non-empty value, andManifestSchema.typeis 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.versionis a plain string.latestmeans "the installed row" and is equivalent to omitting the key; comparison is exact string equality againstmanifest.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.keepDatais declared boolean, and the docblock records the two spellings the wire actually honours —keepData=trueandkeepData=1— and warns that anything else,keepData=yesincluded, reads as absent and DROPS the tables. Widening the door's own comparison would be a runtime change this declaration is not.overwritewas already discharged, by the PR that unblocked this cardThe 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 declaresoverwrite: z.boolean().optional()onPackageInstallRequestSchema, with a docblock that already names the?overwrite=truequery 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
limitandcursor,.default(50)includedBoth 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 atscerror 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 intomigrations/registry.tsbygen:migration-registry.packages-list-pagination-retired, carryingsurface/replacement/reason/acceptanceCriteriatospec-changes.json, the generated upgrade guide andos migrate meta.ui/ListView:pageNameandsecurity/ObjectPermission:allowPurgeconvention: the removal ships on the 17.x line as a minor, and the prescription lives at the major boundary wheremigrate metausers look. The guidance string says17.5.0, the shipping version, matchingview.pageName.sys_metadatarow, and this shape is HTTP-only — nobody authors aListInstalledPackagesRequestand nothing persists one. Theos migrate metahouse sentence is therefore correctly absent from the prescription; the pin only judges prescriptions that name the command.acceptRetiredDefaultResiduestage, 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. Theauthorable-defaults/api.jsonline simply leaves with the key —DEFAULT_CHANGES_BY_MAJORexcludes retirements by name, andcheck:authorable-surfaceaccepted it without one.liveness/api.jsonis theapiMETADATA type's ledger, not the specapi/category. Zero occurrences ofListInstalledPackagesin it.Ratchet readings, stated because their direction is route-dependent:
authorable-surface/api.jsongains two[RETIRED]rows and three new keys;authorable-defaults/api.jsonloses exactly the= 50line;api-surface/is unchanged, which is correct for a key-level narrowing on a surviving def.hasMoreis now true by construction, and the comment says sohasMorestays the constantfalseit already was. With nolimitand nocursorto 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 inpackages/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 —
enabledimplemented inpackages/runtime/src/domains/packages.ts, one filter line in the shapestatusalready has — is assigned by the ruling to the cli seat's sibling PR and is fenced off this dispatch. Measured on the mergedorigin/mainat2277d1f, 2026-09-20T13:30Z:query?.enabledoccurs 0 times in that file; control on the same file,query?.statusoccurs 1 time. Soenabledis 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-versionchange for #19120 and has merged; it is not theenabledsibling.Verification
Readings taken in this worktree; the gate union below was run after the final commit, at
80937f5.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--rancarrying each exit code captured before any pipe. The three NOT MEASURED are the gates' ownexit 3PREREQUISITE 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), andcheck-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-typecheckGRADUATEDsrc/api/package-api.test.ts: its two recordedTS6133unused-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.limittombstone was replaced on disk with its oldz.number().int().min(1).max(100).default(50)viascripts/ablation-replace.mjs, which proved the write landed (anchor 1 → 0, blobde8722127dc3→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 againstHEADand an emptygit diff HEAD, by the tool, not by an exit code.limit/cursoron this request shape outside the new registry entries: zero;packages.list(calls passing either: zero. Controls:overwriteIS found in the same spec file (10 hits) andpackages.list(IS found across five files by the same pattern shape. The first-party SDK already declareslist(filters?: { status, type, enabled })— nolimit, nocursor— 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-declarationsoutput 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 unchangeduisources emitted one enum-member ordering, build ✨ Set up Copilot instructions #2 emitted another; 184 lines ofapi-surface-declarations/ui.txtflipped 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/mainat2277d1freverted the whole declaration-text snapshot (revert(spec): take back the declaration-text snapshot, restore the 27 signature hashes #19024) and deletedapi-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.ListExportJobsRequestSchema(limitwith.default(20),cursor),ListAiConversationsRequestSchema(limit,cursor) andListRunsRequestSchema(limit,cursor, the last validated at the boundary and then ignored by the engine, withhasMore: falsehard-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./packagesdispatcher 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