fix(client): bind packages.get to the bare InstalledPackage row on both the global and scoped clients - #17419
Conversation
…th clients (#12034) `client.packages.get` declared `{ package: any }` and its environment-scoped twin declared `{ package: InstalledPackage }`. Neither body is emitted by any surface: the runtime dispatcher's `/packages` domain has been the single implementation of `GET /packages/:id` since #16628 removed the REST registrar's read routes, and it answers the bare row — `success(withWritableVerdict(qlService, toPackageResponse(pkg)))`, the same expression it maps over every `list` row. Maintainer ruling of 2026-09-09 (Option A) converged this route on the bare row. The producer half arrived by subtraction rather than convergence, so this change is SDK-side only. Also corrects the rationale pinned beside the scoped binding, which reasoned from a REST mount that #16628 deleted, and moves the scoped method's direction-2 evidence to the pin function of this card, where it is red before the fix rather than green in both states. Clause-②: yes — a `.package` read compiles today and will not after this. Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude <noreply@anthropic.com>
…ckages-get-single-type
📓 Docs Drift CheckThis PR changes 1 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 14 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 ec75520080113d36fcb0012ca11e3c2a93548d17 && git checkout ec75520080113d36fcb0012ca11e3c2a93548d17
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 93447bbadf5c5f87d180bc5c007b727026eb71d0 3543e521e0c5af2355648126f95983ad0b4f0805 && git checkout -B drift-repro 93447bbadf5c5f87d180bc5c007b727026eb71d0 && git merge --no-ff 3543e521e0c5af2355648126f95983ad0b4f0805
node scripts/docs-audit/affected-docs.mjs --json 93447bbadf5c5f87d180bc5c007b727026eb71d0
|
Fixes #12034
Binds
packages.getto the bareInstalledPackagerow on both the globalObjectStackClientandScopedEnvironmentClient, replacing a{ package }envelope that no mounted surface emits.Implements the maintainer's Option A ruling of 2026-09-09 (verbatim 「同意」), recorded on the card. The whole remaining card ships here, hence a closing keyword — unlike PR #13060, which correctly used
Part ofwhen onlyinstall/enable/disableshipped.Clause-②: yes
The ruling's three items, as delivered
sendOksites foldsourceinto the rowScopedEnvironmentClient.packages.getbinding and its pin move in the same PRclient.packages.getbinds the single true typeItem 1 was delivered by another card, and I verified that rather than assuming it
The ruling named
packages/rest/src/package-routes.ts:817and:880. That file is 506 lines — those offsets are off the end of it. PR #16628 (card #14503, merged 2026-09-08) removed the REST read routes outright instead of converging them.Verified on the merge base rather than taken from the card:
registerPackageRoutesbuilds exactly one route and hands that same array tomountDirectRoutes, which is also what it returns as the description of what it mounted —so
POST /packages/publishis the registrar's whole surface, and the only{ package }left in that file belongs to the publish response, a different client method this card does not touch. ⇒ The two-surface fork is gone, the dispatcher is the sole implementation, and it already sends the shape Option A ruled for. This card was SDK-side only.Why the type is now bindable, and why it is this type
The dispatcher's
/packagesdomain builds the detail body and everylistrow with one expression:GET /packages/:idreturns it directly;GET /packagesmaps it over the collection. So bindinggettoInstalledPackagemakes two doors of one domain agree — it is not a fresh claim about either.packages.listhas declaredInstalledPackage[]since #11925, and@objectstack/spec's ownGetInstalledPackageResponseSchemadeclaresdata: InstalledPackageSchemaand always has. The declaration was the only thing out of step.The scoped method is the sharper half. Its rationale in source read: "only the REST registrar serves the scoped path — so the
{ package }envelope declared here is the one that route actually sends." #16628 falsified it. The mount is still there (direct-mount-composition.tsregisters the registrar at both{base}/packagesand{base}/environments/:environmentId/packages) — it simply serves no read any more. What serves the scoped path is the dispatcher via the@objectstack/honocatch-all, which strips the/environments/:environmentIdprefix and is, perhttp-dispatcher.ts, the only entry that handsdispatch()a still-scoped path. ⇒ Between #16628 and this PR the scoped declaration described a body emitted nowhere, with a realInstalledPackagebehind the member rather than anany— so.packagereads there looked type-safe and returnedundefined. That stale rationale is corrected here too; leaving it would pin a false explanation next to a corrected binding.Evidence — the pins are red before and green after
A runtime test cannot observe a return-type narrowing, so the evidence is type-level. Ablation: fix committed first, then
packages/client/src/index.tsrestored to the merge base with the pin file kept, mutation proven on disk by blob hash (25f5c960…== BASE, != HEAD), restored under anEXIT/INT/TERMtrap and verified byte-identical afterwards (git diff HEADempty, on-disk hash == HEAD blob).npx tsc -p tsconfig.test.json --noEmit, same command both legs:return-type-precision.test.tsThe five:
The two TS2578 rows are the point. The guard that previously sat beside the scoped binding was labelled "GREEN IN BOTH STATES — regression guard, not red-before evidence", and its claim ("the scoped detail route answers
{ package }, not the bare row") is now false. It is not reworded in place: a line that is red before this card cannot also be green in both states of #11925, and that paragraph is the record of #11925's ablation. It is removed with that reasoning written down, and the scoped method's direction-2 evidence moves toreturnTypePrecisionPins12034, beside the three siblings that fail identically.These suppressions are compiled, not phantom.
tsc --noEmiton the package's main config does not include this file (--listFilescount: 0);tsconfig.test.jsondoes (count: 1), andpnpm --filter @objectstack/client typecheckruns both it andcheck:test-typecheck.Blast radius
In-repo callers: zero. Re-measured rather than cited from the card, whose numbers came from a probe where test and control both returned 0. Using
grep -r(notgit grep, which reads only tracked files) over.ts/.tsx/.mts/.mjs/.js/.mdx:packages.gethas 22 hits, every one of them the declarations themselves, their pins, the route-ledger row, or an unrelatedMap.get()on a local namedpackagesin build scripts. Positive controls fired —packages.list17,packages.install17,metadata.list37 — and a fabricatedpackages.zzznosuchreturned 0, so the zero is a reading and not a dead scan.Pinned sibling: zero. objectui at the pinned
.objectui-sha53ded82bfwas cloned and grepped: no SDKpackages.*callers (controlclient.= 433 hits). The Console Pin Gate is not at risk.Documentation examples: covered.
check:skill-examplestype-checks 258 prose blocks including 23 on the client SDK surface; a.packageread in any of them would have gone red. It did not.(await client.packages.get(id)).packagecompiles today and will not after this. That is the card's purpose — the read was alreadyundefinedat runtime on every surface that serves this route — but it is a genuine compile-time break for anyone who wrote it, and "0 in-repo callers" must not be read as "no impact".Changeset
minor, notmajor(check-changeset-no-majorblocks major; precedent:client-unannotated-return-erasure.md#11925,client-precise-sdk-return-types.md#8140). It spells FROM → TO and carries the migration line: read the row directly, not.package.Acceptance notes
GET /api/v1/packages/:idsilently ignores?version=. The scoped SDK method still sends the parameter; the dispatcher branch that serves the route never readsquery; the REST handler that did read it (with a repeated-parameter400guard) went out with fix(rest): the dispatcher's /packages domain is the one implementation of the package read and delete routes (#14503) #16628. Filed rather than repaired here: the remedy is a wire decision with three defensible answers, not a mechanical change.cloud.environments.packages.{install,get,enable,disable}(packages/client/src/index.ts) still declare{ package: any }against/api/v1/cloud/environments/:id/packages/.... That is a different surface, served from thecloudrepo, and nothing in this repo can measure what it emits — so the shape claim is unverified from here rather than known-false. Successor: whoever holds the cloud-side route ledger.responseSchemawork as unblocked by this binding, not as part of it. Thepackages.*rows inpackages/runtime/src/route-ledger.tsstill carry noresponseSchema; that is follow-on scope.versionparameter on the scoped signature is deliberately untouched — request-side, and GET /api/v1/packages/:id silently ignores ?version= — the only serving surface never reads it, and the handler that did was deleted with the REST twin #17416's subject.Verification
pnpm --filter '@objectstack/client^...' build— exit 0pnpm build— 73 successful, 73 totalpnpm --filter @objectstack/client typecheck— exit 0, incl.check:test-typecheck(0 files / 0 errors in debt)pnpm --filter @objectstack/client test— 41 files, 492 tests, all passeddispatch-gates --ranreconciles 60 derived / 60 run / 0 NOT-MEASURED (a derived zero — every family recorded an exit code and none is 3)pnpm lint(eslint . --no-inline-config, whole repo, no narrowing) — exit 0, cleanAll of the above were re-run on the final head
3543e521(aftergit merge origin/mainand a full rebuild): the ratchet familiescheck:type-check-coverage,check:type-check-debt,check:dts-closure,check:published-files,check:dual-build-cjs-loadsandcheck:skill-examplesall exit 0 there, as do the client package's typecheck and its 492 tests. The gate family set was re-derived after the merge and is byte-identical to the pre-merge derivation — 60 families, none added, none removed.Three gates first answered
PREREQUISITE NOT MET(check:skill-examplesexit 1,check:dual-build-cjs-loadsexit 3,check:type-check-debtexit 3) because they read builtdist/and the workspace was not fully built. That is "nothing was measured", not a red gate — each says so in its own output. All three were re-run afterpnpm buildand are exit 0.🤖 Generated with Claude Code
https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt
Generated by Claude Code