Skip to content

client.packages declares envelopes two mounted surfaces do not emit: get diverges between them, and install/enable/disable declare { package } where the only serving surface sends the bare row #12034

Description

@os-zhuang

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

surfacecodeemitted data
runtime dispatcherpackages/runtime/src/domains/packages.ts:852success(pkg) → the bareInstalledPackage row
rest registrarpackages/rest/src/package-routes.ts:760sendOk(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 methodroutehandleremitted dataclient declares
packages.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)).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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions