Measured at c804f0ca5 while implementing #11925 (which deliberately did not bind these four methods for
exactly this reason). Out of that card's scope — it binds erased anys, and each of these is a shape claim
that is wrong independently of the erasure.
GET /api/v1/packages and GET /api/v1/packages/:id are each served by two implementations. The REST
registrar is mounted only when a package service is registered and, in its own words, the direct-mount
routes "shadow live dispatcher twins" (packages/rest/src/direct-mount-composition.ts). Both surfaces are
real deployments, and both route ledgers map the same client method onto both routes.
1. GET /packages/:id — the two surfaces emit different envelopes
| surface | code | emitted data |
|---|
runtime dispatcher | packages/runtime/src/domains/packages.ts:852 | success(pkg) → the bareInstalledPackage row |
rest registrar | packages/rest/src/package-routes.ts:760 | sendOk(res, { package: { ...pkg, source: 'database' } }) → { package } |
unwrapResponse strips exactly one { success, data } envelope (packages/client/src/index.ts:5106), so the
post-unwrap value is InstalledPackage on one surface and { package: InstalledPackage & { source } } on the
other. client.packages.get declares { package: any } — right on REST, wrong on the dispatcher. There is no
single true type, which is why #11925 left it unbound rather than shipping a false narrowing.
Both ledgers agree the one client method covers both routes:
packages.get [runtime] GET /packages/:id responseSchema=None
packages.get [rest ] GET /api/v1/packages/:id responseSchema=None
2. install / enable / disable — a declared envelope no surface emits
These three have no REST twin (the REST registrar mounts only POST /packages/publish, GET /packages,
GET /packages/:id, DELETE /packages/:id). The only surface serving them is the dispatcher, and it sends
the bare row:
| client method | route | handler | emitted data | client declares |
|---|
packages.install | POST /packages | domains/packages.ts:315 | success(pkg) | { package: any; message?: string } |
packages.enable | PATCH /packages/:id/enable | domains/packages.ts:331 | success(pkg) | { package: any; message?: string } |
packages.disable | PATCH /packages/:id/disable | domains/packages.ts:350 | success(pkg) | { package: any; message?: string } |
So (await client.packages.enable(id)).package compiles today (the member is any) and is undefined at
runtime; the correct read is the row itself. The declaration is not merely erased, it is false, and the
any inside it is what keeps the falsehood invisible.
Why this is worth its own card rather than a rider
Correcting these is a response-shape decision, not an annotation fix, and it forks:
- Fix the SDK to match the producer — cheap, but for
get it can only be right about one surface. - Fix the producers to agree — the dispatcher and REST twins converge on one envelope; the SDK then has a
single true type to bind. This is the contract-first direction and the only one that makes get bindable.
Either way it needs a clause-② narrowing analysis of its own: today's any members mean any consumer read
compiles, so tightening these breaks callers at compile time. In-repo callers are zero for get/enable/
disable and two for install; the published SDK's external consumers are the real surface.
Related: #11925 (the erasure card that measured this), #11942 (the docs page whose fences no compiler reads).
Generated by Claude Code
Measured at
c804f0ca5while implementing #11925 (which deliberately did not bind these four methods forexactly this reason). Out of that card's scope — it binds erased
anys, and each of these is a shape claimthat is wrong independently of the erasure.
GET /api/v1/packagesandGET /api/v1/packages/:idare each served by two implementations. The RESTregistrar is mounted only when a
packageservice is registered and, in its own words, the direct-mountroutes "shadow live dispatcher twins" (
packages/rest/src/direct-mount-composition.ts). Both surfaces arereal deployments, and both route ledgers map the same client method onto both routes.
1.
GET /packages/:id— the two surfaces emit different envelopesdataruntimedispatcherpackages/runtime/src/domains/packages.ts:852success(pkg)→ the bareInstalledPackagerowrestregistrarpackages/rest/src/package-routes.ts:760sendOk(res, { package: { ...pkg, source: 'database' } })→{ package }unwrapResponsestrips exactly one{ success, data }envelope (packages/client/src/index.ts:5106), so thepost-unwrap value is
InstalledPackageon one surface and{ package: InstalledPackage & { source } }on theother.
client.packages.getdeclares{ package: any }— right on REST, wrong on the dispatcher. There is nosingle true type, which is why #11925 left it unbound rather than shipping a false narrowing.
Both ledgers agree the one client method covers both routes:
2.
install/enable/disable— a declared envelope no surface emitsThese three have no REST twin (the REST registrar mounts only
POST /packages/publish,GET /packages,GET /packages/:id,DELETE /packages/:id). The only surface serving them is the dispatcher, and it sendsthe bare row:
datapackages.installPOST /packagesdomains/packages.ts:315success(pkg){ package: any; message?: string }packages.enablePATCH /packages/:id/enabledomains/packages.ts:331success(pkg){ package: any; message?: string }packages.disablePATCH /packages/:id/disabledomains/packages.ts:350success(pkg){ package: any; message?: string }So
(await client.packages.enable(id)).packagecompiles today (the member isany) and isundefinedatruntime; the correct read is the row itself. The declaration is not merely erased, it is false, and the
anyinside it is what keeps the falsehood invisible.Why this is worth its own card rather than a rider
Correcting these is a response-shape decision, not an annotation fix, and it forks:
getit can only be right about one surface.single true type to bind. This is the contract-first direction and the only one that makes
getbindable.Either way it needs a clause-② narrowing analysis of its own: today's
anymembers mean any consumer readcompiles, so tightening these breaks callers at compile time. In-repo callers are zero for
get/enable/disableand two forinstall; the published SDK's external consumers are the real surface.Related: #11925 (the erasure card that measured this), #11942 (the docs page whose fences no compiler reads).
Generated by Claude Code