Uh oh!
There was an error while loading. Please reload this page.
packages.install/enable/disable declare the bare row the only serving surface sends; get's fork measured, not converged - #13060
Conversation
… only serving surface sends
`client.packages.install`, `.enable` and `.disable` declared
`{ package: any; message?: string }` — a body no surface has ever emitted.
Each is served by exactly one implementation (`runtime`'s `/packages`
dispatcher domain; the REST registrar mounts no twin for any of the three)
and it answers `success(pkg)`, which `unwrapResponse` strips to the bare
`InstalledPackage` row. Because the member was `any`,
`(await client.packages.enable(id)).package` compiled and was `undefined` at
runtime; the `any` is what kept the falsehood invisible.
All three now declare `InstalledPackage`. `message` goes with the wrapper —
no surface sends one.
`client.packages.get` is deliberately untouched: it is a real fork (the
dispatcher answers the bare row, the REST registrar answers
`{ package: { ...row, source } }`), so no declaration is true on both
surfaces. Converging the two producers is a wire-behaviour ruling above this
change; the measured cost is recorded on the issue.
Two pins, because neither half can observe the other: the WIRE fact is driven
end-to-end against a real `SchemaRegistry` + real `HttpDispatcher` + real
client in `packages-write-envelope.test.ts`, and the DECLARATION is pinned
type-level in `return-type-precision.test.ts`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd📓 Docs Drift CheckThis PR changes 1 package(s): 15 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 — 15 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 247ea92dd2e5a833718f218e8bea345333257cb8 && git checkout 247ea92dd2e5a833718f218e8bea345333257cb8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9e8adf3f6b1dc0e7e90739a66e1f1ccd1aa9fe20 4c885476117fc88b591adccb38e1aae018bc7cb1 && git checkout -B drift-repro 9e8adf3f6b1dc0e7e90739a66e1f1ccd1aa9fe20 && git merge --no-ff 4c885476117fc88b591adccb38e1aae018bc7cb1
node scripts/docs-audit/affected-docs.mjs --json 9e8adf3f6b1dc0e7e90739a66e1f1ccd1aa9fe20
|
os-litant
commented
Aug 29, 2026
PM 复核 — ACCEPT,两处待裁都按你的建议
待裁一: |
Uh oh!
There was an error while loading. Please reload this page.
Part of #12034
Part of, not a closing keyword — deliberately, please read before merging. The dispatch instruction for this card asked for a closing reference. I have usedPart ofinstead, because the dispatching ruling also split this card in half and shipped only one half:install/enable/disableare corrected here, andgetis measured-only by explicit instruction. A closing keyword would retire the card on merge, and the unshippedgethalf — which the maintainer's Option-A ruling of 2026-08-26 does cover — would vanish from the open backlog, where inbox filters readopenonly. So this card remains open on purpose. If PM/maintainer would rather retire it here and carry thegetconvergence on a fresh card, swap this first line for a closing reference before merge.What shipped
client.packages.install,.enableand.disabledeclared{ package: any; message?: string }— a body no surface has ever emitted. This was not an erasure, it was a falsehood, and theanymember is what kept it invisible:(await client.packages.enable(id)).packagecompiled and wasundefinedat runtime.All three now declare
InstalledPackage, the bare row.messagegoes with the wrapper; no surface sends one.packages.install(manifest, opts?){ package: any; message?: string }InstalledPackagepackages.enable(id){ package: any; message?: string }InstalledPackagepackages.disable(id){ package: any; message?: string }InstalledPackageRe-located coordinates (the card's line numbers had drifted)
#13026 moved
packages/client/src/index.tsafter this card was written, so every coordinate was re-derived onorigin/mainatcf71d73f8rather than taken from the card:cf71d73f8unwrapResponsedefinitionclient/src/index.ts:5106:5560(+454)sendOk(res, { package … })rest/src/package-routes.ts:760:817(+57)install/enable/disableruntime/src/domains/packages.ts:315/331/350:315/:331/:350— unchangedgetruntime/src/domains/packages.ts:852:852— unchangedindex.ts:1712(install),:1772(enable),:1790(disable)Why there was no "which surface do we match" question
Measured by driving
registerPackageRoutesand enumerating what it actually mounted — not read off the source:One producer each, so one true type each. The dispatcher's own bodies, driven through
HttpDispatcher.handlePackages:unwrapResponsestrips exactly one envelope ⇒ the caller holds the row.⛔ The half that did NOT ship:
packages.getIts declaration is unchanged (
{ package: any }).getis a genuine fork with no single true type. Both bodies below were measured by driving each registrar:unwrapResponsevalueruntime/src/domains/packages.ts:852){"success":true,"data":{"id":…,"manifest":{…},"enabled":true,"status":"installed"}}rest/src/package-routes.ts:817){"success":true,"data":{"package":{…row,"source":"database"}}}{ package }:880){"success":true,"data":{"package":{…row,"source":"registry"}}}{ package }Measured convergence cost (for the ruling PM/maintainer still owe this half)
Converge to the bare row (the dispatcher shape; the maintainer's 2026-08-26 Option A):
sendOkcall atpackage-routes.ts:817plus one at:880, foldingsourceinto the row. The dispatcher is untouched.GET /api/v1/packages/:idreadingbody.data.package. In-repo that is zero call sites (see the census below — the only in-repo{ package }readers are of the SCOPED route, which is a different mount). Out-of-repo it is unmeasurable from here.client.packages.get's scoped twinScopedEnvironmentClient.packages.getis bound to{ package: InstalledPackage }(return-type-precision.test.ts:267) precisely because the scoped mount is REST-only.registerPackageRoutesmounts at both{base}/packagesand{base}/environments/:environmentId/packages, so converging the registrar's:idhandler changes the scoped route's wire too and that binding must move in the same PR. This is the single most expensive item and the card did not name it.listalready agrees on both surfaces, sogetwould be the last packages divergence.Converge to
{ package }(the REST shape):GET /packages/:id— but for consistency the family's othersuccess(pkg)sites (install,enable,disable,update) would want the same wrapper, i.e. 5 handlers, and it would undo this PR.client.packages.updatebinding from A fifth client-SDK erasure spelling no grep in #8140's census counted: 38 methods with NO return annotation, typed fromunwrapResponse< …any… >#11925 and the three bound here.Recommendation: converge to the bare row, with the scoped-twin binding moved in the same PR. Not done here — it changes the wire behaviour of two mounted surfaces and needs its own clause-② analysis, exactly as the dispatching ruling directed.
Consumer impact — clause-② narrowing
@objectstack/clientSDK's external consumers, which no in-repo scan can see. A reader of.packageoutside this repo compiles today and will not after this change. That break is the point of the card — the read is alreadyundefinedat runtime — but it will interrupt people, and the compiler is the channel that reaches them.In-repo census, re-measured for this PR (the card's numbers were NOT reused; the dispatching PM's own probe returned 0 for both test and control, i.e. a dead scan, so no baseline was inherited).
grep -rover the worktree excludingnode_modules/.git/dist/.turbo/build/.next— deliberately notgit grep, which reads tracked files only:ObjectStackClient.packages.*packages.install(client.test.ts:2520,:2533packages.get(packages.enable(packages.disable(Positive controls (same namespace, and none is a substring of any measured term, nor any measured term of it):
packages.list(packages.publish(packages.update(packages.uninstall(ObjectStackClient(scanner liveness)Controls are all non-zero, so the zeros above are readings rather than a dead scan.
Non-dot spellings were scanned too and found nothing: alias/destructure bindings of a
.packagesnamespace (regex overconst|let|var … = ….packages) — 0 in code; a line ending in.packages(cross-line chaining) — 0 in code; bracket accesspackages['install'|'get'|'enable'|'disable']— 0. An exhaustive enumeration of every.packages.member access in code files shows.packages.enableand.packages.disabledo not appear in compiled code at all — their only occurrences repo-wide are prose inpackages/client/README.mdandcontent/docs/api/client-sdk.mdx.Discounted hits, and why:
client.environments.packages.install(client.environments-namespace.test.ts:143) is a different namespace;scoped.packages.get(return-type-precision.test.ts:267,:297) is the SCOPED route, untouched;client.projects.packages.install(envId, …)in CHANGELOGs is historical;packages.get(name)inscripts/release-github-releases.mjsisMap.get;packages/services/service-package/README.md:53is a differentpackagesobject.Net compile-time impact inside this repo: zero. Both real
installcallers assert only the REQUEST and never read the response.Fixture triage
client.test.tsfed{ success: true, data: { package: { manifest: MANIFEST } } }to those two cases — a body nothing emits. The cases never read the response, so the fixture is inert either way, but a decoy fixture is how the next sweep concludes the envelope is real. Replaced withINSTALLED_ROW, the shape the producer actually sends. This is a bounded in-place fix of the same defect class, named here as required; it widens the file surface of this claim topackages/client/src/client.test.ts.Tests
Two pins, because neither half can observe the other — the rule
return-type-precision.test.tsstates in its own header:packages/client/src/packages-write-envelope.test.ts(new) — the WIRE fact. RealSchemaRegistry(@objectstack/objectql) → realHttpDispatcher(@objectstack/runtime) → realObjectStackClientwith the realunwrapResponse. ⛔ Nothing mocks a response body: a mock body would assert my own assumption about the producer, which is the mistake that produced the false declaration. The only stand-in is the socket.packages/client/src/return-type-precision.test.ts— the DECLARATION, type-level, because a runtime test cannot observe a return-type narrowing at all.NOT-MEASURED trap checked:
tsc -p tsconfig.test.json --listFilesconfirms all three test files andsrc/index.tsare inside the program (933 files) — so "typecheck is green" really does cover the new test code rather than merely being true about files it never read.Ablation — restoring the false declaration
Predicted before running:
tscRED on the type pins, wire test GREEN (a runtime test cannot see a declaration).Mutation confirmed on disk before any verdict was read — blob hash
d83df55e…→8d351cbe…, and the anchor counts moved with it (the false-envelope spellingpackage: any; message?: string2→5; thePromisereturn annotation namingInstalledPackage4→1; theunwrapResponsetype argument namingInstalledPackage4→1). No rebuild is in this ablation's path and none is claimed: both test files import./indexfrom source in the same package, andtsc -p tsconfig.test.jsoncompiles that source, so nothing resolves throughdist/.My prediction was incomplete and is corrected rather than glossed: I expected 7 errors in one file; there are 10 across two, because the wire test's row reads (
.manifest,.enabled) are themselves type-level evidence and go red under ablation even though that file stays green at runtime. That strengthens the pin.Restore proven by state, not by exit code:
git checkout HEAD --against an ABSOLUTE path (under anEXIT INT TERMtrap), then blob hash back tod83df55e…= the HEAD blob, andgit diff HEADempty for the file.Gates
Derived after the final commit with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(it read the change set itself from the merge base; answer asserted against this repo at4c8854761). All runs below are on4c8854761, and each verdict is the gate's own printed line, never a bare$?behind a pipe.check:route-envelopecheck:query-options-erasurecf71d73check:nul-bytescheck:published-filescheck-changeset-no-majormajorbumpcheck-empty-changesetcheck-adr-0087-registrationcheck:test-source-aliascheck:undeclared-dep-imports@objectstack/clientcheck:cross-package-test-inputscheck:engine-double-contractcheck:where-matchercheck:type-check-coverageLint — a DECLARED narrowing, not a skipped gate. Repo-wide
pnpm lintis CI's run. Locally the four touched files were linted with the repo's own invocation (eslint … --no-inline-config), and all three evidences are present:--format json— 4 files, 0 errors, 0 warnings, exit 0;eslint.config.mjswhich enables no type-aware linting for any file (noparserOptions.project, no typed@typescript-eslintrules), stated and positive-control-measured ateslint.config.mjs:327-335. Every rule is therefore file-local and this diff cannot move the verdict on any file it does not touch.NOT MEASURED locally (CI owns them):
check:type-check-debt --re-measure, which refuses on an unbuilt workspace closure and needs a fullturbo run build— the client holds 0 debt entries and its test layer compiles at 0 errors, so no ratchet movement is expected, but that is an expectation and not a measurement. The remaining derived families were left to CI under the standing repo-wide-scan rule. Part of this round ran on a box at load ~37 where a barenode -edid not return in 3 minutes; every result reported above was re-run to completion afterwards, and nothing killed by that starvation is reported as a verdict.Changeset — a note for the reviewer
minor, per the launch-window convention, and the body spells out FROM/TO and the break in prose. It deliberately does not carry the literal**BREAKINGmarker, socheck-adr-0087-registrationclassifies it non-breaking and asks for no ADR-0087 disposition. That is the repo's settled treatment of exactly this class —client-unannotated-return-erasure.md(#11925) andclient-precise-sdk-return-types.md(#8140) are both published-SDK return-type narrowings and both do the same — and it is honest here: nothing in this change is a metadata surface, so the ADR-0087 ledger (which feedsobjectstack migrate meta) has nothing to rewrite. Registering an entry would be noise in the upgrade path. Flagged rather than left silent, because the dispatch instruction asked for the breakingness to be explicit, and it is — in the changeset prose and in this PR body, just not through the token that drives that particular gate.Generated by Claude Code