From 4c885476117fc88b591adccb38e1aae018bc7cb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:09:28 +0000 Subject: [PATCH] fix(client): packages.install/enable/disable declare the bare row the only serving surface sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd --- .../client-packages-write-verbs-bare-row.md | 61 +++++ packages/client/src/client.test.ts | 10 +- packages/client/src/index.ts | 84 +++++-- .../src/packages-write-envelope.test.ts | 234 ++++++++++++++++++ .../client/src/return-type-precision.test.ts | 57 +++++ 5 files changed, 420 insertions(+), 26 deletions(-) create mode 100644 .changeset/client-packages-write-verbs-bare-row.md create mode 100644 packages/client/src/packages-write-envelope.test.ts diff --git a/.changeset/client-packages-write-verbs-bare-row.md b/.changeset/client-packages-write-verbs-bare-row.md new file mode 100644 index 0000000000..2e31d6d96c --- /dev/null +++ b/.changeset/client-packages-write-verbs-bare-row.md @@ -0,0 +1,61 @@ +--- +"@objectstack/client": minor +--- + +fix(client): `packages.install` / `enable` / `disable` declare the bare `InstalledPackage` row the only serving surface actually sends (#12034) + +**Accept-set narrowing on a published SDK (clause-②), and a false declaration +deleted.** No runtime change: the value each method resolves to is +byte-identical before and after. What moved is the DECLARED type — and unlike +its #11925 siblings this one was not merely erased, it was **wrong**. + +FROM → TO, all three methods: + +| method | declared before | declares now | +|---|---|---| +| `client.packages.install(manifest, opts?)` | `{ package: any; message?: string }` | `InstalledPackage` | +| `client.packages.enable(id)` | `{ package: any; message?: string }` | `InstalledPackage` | +| `client.packages.disable(id)` | `{ package: any; message?: string }` | `InstalledPackage` | + +No surface has ever emitted `{ package, message }` for these three. Each is +served by exactly one implementation — `runtime`'s `/packages` dispatcher domain +— and it answers `success(pkg)`, i.e. `{ success: true, data: }`, which +`unwrapResponse` strips to the bare row. `@objectstack/rest`'s registrar mounts +no twin for any of them (it mounts only `POST /packages/publish`, +`GET /packages`, `GET /packages/:id`, `DELETE /packages/:id`), so there was +never a question of which surface to match. + +**Migration — read the row, not `.package`.** Because the member was `any`, +the false read compiled and silently produced `undefined` at runtime: + +```ts +// BEFORE — compiled, and `pkg` was `undefined` at runtime +const pkg = (await client.packages.enable(id)).package; +const note = (await client.packages.install(manifest)).message; + +// AFTER — the response IS the row +const pkg = await client.packages.enable(id); +pkg.enabled; // the state the verb just changed +pkg.manifest.version; +``` + +A consumer stops compiling where it reads `.package` or `.message` off these +three results, or assigns the result somewhere `InstalledPackage` does not fit. +That break is the point: those call sites are already broken at runtime today +and the `any` is what hid it. The compiler is the channel that reaches every +affected consumer, and it is strictly more precise than a release note. + +**What deliberately did NOT change: `client.packages.get`.** It keeps +`{ package: any }`. That route is a real fork — the dispatcher answers the bare +row while the REST registrar answers `{ package: { ...row, source } }`, both +measured by driving each registrar — so no declaration is true on both surfaces. +Binding either member would harden a falsehood, which is the defect this change +removes for its neighbours. Making `get` bindable requires converging the two +PRODUCERS, a wire-behaviour change to two mounted surfaces; the measured +convergence cost is recorded on #12034 for that ruling. + +No ADR-0087 ledger entry: nothing here is a metadata surface. No Zod schema, no +`packages/spec` declaration and no stored representation changed — the phantom +members existed only in a TypeScript return annotation — so `objectstack migrate +meta` has nothing to rewrite. This is the disposition #11925 and #8140 recorded +for the same class of SDK return-type narrowing. diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index f0764846bd..3f70ca54f3 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -2509,9 +2509,15 @@ describe('HTTP error shaping — envelope normalisation', () => { describe('packages.install', () => { const MANIFEST = { id: 'com.acme.crm', name: 'Acme CRM', version: '1.0.0', type: 'app' }; + // [#12034] The body the ONLY serving surface actually sends: `success(pkg)`, + // i.e. the bare `InstalledPackage` row under `data`. These two cases assert + // the REQUEST and never read the response, so the fixture is inert either + // way — but it used to spell `data: { package: … }`, a body nothing emits, + // and a decoy fixture is how the next sweep concludes the envelope is real. + const INSTALLED_ROW = { manifest: MANIFEST, status: 'installed', enabled: true }; it('POSTs the manifest and omits `overwrite` unless requested', async () => { - const { client, fetchMock } = createMockClient({ success: true, data: { package: { manifest: MANIFEST } } }); + const { client, fetchMock } = createMockClient({ success: true, data: INSTALLED_ROW }); await client.packages.install(MANIFEST, { enableOnInstall: true }); expect(fetchMock).toHaveBeenCalledWith('http://localhost:3000/api/v1/packages', expect.any(Object)); @@ -2524,7 +2530,7 @@ describe('packages.install', () => { }); it('passes `overwrite: true` through for intentional upgrade / re-install', async () => { - const { client, fetchMock } = createMockClient({ success: true, data: { package: { manifest: MANIFEST } } }); + const { client, fetchMock } = createMockClient({ success: true, data: INSTALLED_ROW }); await client.packages.install(MANIFEST, { overwrite: true }); const body = JSON.parse(fetchMock.mock.calls[0][1].body); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 99326295c5..d83df55e4d 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1673,14 +1673,25 @@ export class ObjectStackClient { /** * Get a specific installed package by its ID (reverse domain identifier). * - * ⛔ [#11925] NOT bound, and the `{ package }` envelope is left exactly as - * it was — the two mounted surfaces answer this route with DIFFERENT - * envelopes, so no single declaration is true (#12034). `runtime`'s - * `/packages` domain sends `success(pkg)` — the bare row — while `rest`'s - * `GET {base}/packages/:id` sends `sendOk(res, { package: { ...pkg, - * source } })`, and the REST routes "shadow live dispatcher twins" only - * where a `package` service is registered. Binding the member here would - * harden a claim that is already false on one of the two. + * ⛔ [#11925 / #12034] STILL NOT bound, and the `{ package }` envelope is + * left exactly as it was. #12034 shipped its `install` / `enable` / + * `disable` neighbours (one producer each) and deliberately did NOT ship + * this one, because this route is a REAL fork with no single true type. + * Both bodies below were MEASURED by driving each registrar, not read off + * the source: + * + * dispatcher handlePackages('/', 'GET') + * -> { success: true, data: { id, manifest, enabled, status } } + * rest GET /api/v1/packages/:id + * -> { success: true, data: { package: { …row, source } } } + * + * `unwrapResponse` strips one envelope, so the post-unwrap value is the + * BARE row on the dispatcher and `{ package }` on REST. Binding either + * member here hardens a claim that is false on the other surface. Making + * it bindable means converging the two PRODUCERS — a wire-behaviour change + * to two mounted surfaces, above this card's authority, with a clause-② + * narrowing analysis of its own. The measured convergence cost is recorded + * on #12034 for that ruling. * * Its SCOPED twin `ScopedEnvironmentClient.packages.get` IS bound, because * only the REST registrar serves the scoped mount — one surface, one @@ -1695,14 +1706,25 @@ export class ObjectStackClient { /** * Install a new package from its manifest. * - * ⛔ [#11925] NOT bound. This method and its `enable` / `disable` - * neighbours declare `{ package; message? }`, and the ONLY surface that - * serves them — `runtime`'s `/packages` domain; `rest` mounts no twin for - * any of the three — answers `success(pkg)`, the bare row (#12034). The - * declared envelope is not merely erased, it is false, and the `any` - * member is what keeps that invisible. Correcting it is a response-shape - * decision with its own clause-② analysis, not the `any`-binding this card - * carries, so the shape is left untouched here. + * [#12034] Bound to `InstalledPackage` — the BARE row, no envelope. + * + * What this REPLACED was not an erasure but a FALSEHOOD: the declaration + * read `{ package: any; message?: string }`, a shape no surface has ever + * sent, and the `any` member is what kept that invisible — + * `(await client.packages.install(m)).package` compiled and was + * `undefined` at runtime. There is exactly ONE serving surface, so there + * was never a "which surface do we match" question: `rest`'s registrar + * mounts only `POST /packages/publish`, `GET /packages`, + * `GET /packages/:id` and `DELETE /packages/:id` (measured by driving + * `registerPackageRoutes` and enumerating what it mounted — this route is + * `NO_HANDLER` there), leaving `runtime`'s `/packages` domain alone to + * answer, and it answers `success(pkg)`: `{ success: true, data: }`, + * status 201. `message` is gone with the wrapper — no surface sends one. + * + * The wire fact is pinned end-to-end in + * `packages-write-envelope.test.ts` (the real dispatcher answering a real + * client call), and the DECLARATION in `return-type-precision.test.ts` — + * a runtime test cannot observe a return-type narrowing at all. * * By default the server rejects a manifest whose `id` is already * installed with **409 Conflict** (duplicate-id guard) instead of @@ -1712,7 +1734,7 @@ export class ObjectStackClient { install: async ( manifest: any, options?: { settings?: Record; enableOnInstall?: boolean; overwrite?: boolean }, - ) => { + ): Promise => { const route = this.getRoute('packages'); const res = await this.fetch(`${this.baseUrl}${route}`, { method: 'POST', @@ -1723,7 +1745,7 @@ export class ObjectStackClient { ...(options?.overwrite !== undefined ? { overwrite: options.overwrite } : {}), }), }); - return this.unwrapResponse<{ package: any; message?: string }>(res); + return this.unwrapResponse(res); }, /** @@ -1739,24 +1761,38 @@ export class ObjectStackClient { /** * Enable a disabled package. - */ - enable: async (id: string) => { + * + * [#12034] Bound to `InstalledPackage` — the BARE row, no envelope, for + * the reason spelled out on `install` above: one serving surface + * (`PATCH /packages/:id/enable` is `NO_HANDLER` on the REST registrar), + * and it answers `success(registry.enablePackage(id))`. The + * `{ package: any; message?: string }` this replaces was never emitted by + * anything. + */ + enable: async (id: string): Promise => { const route = this.getRoute('packages'); const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/enable`, { method: 'PATCH', }); - return this.unwrapResponse<{ package: any; message?: string }>(res); + return this.unwrapResponse(res); }, /** * Disable an installed package. - */ - disable: async (id: string) => { + * + * [#12034] Bound to `InstalledPackage` — the BARE row, no envelope, same + * single-producer argument as `install` / `enable` above + * (`PATCH /packages/:id/disable` is `NO_HANDLER` on the REST registrar). + * The dispatcher answers `success(registry.disablePackage(id))`, so the + * row comes back with `enabled: false` — the caller reads the row itself, + * never a `.package` member. + */ + disable: async (id: string): Promise => { const route = this.getRoute('packages'); const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/disable`, { method: 'PATCH', }); - return this.unwrapResponse<{ package: any; message?: string }>(res); + return this.unwrapResponse(res); }, /* [#3563 PR-4] Lifecycle beyond install/enable — these eleven routes diff --git a/packages/client/src/packages-write-envelope.test.ts b/packages/client/src/packages-write-envelope.test.ts new file mode 100644 index 0000000000..f1cb9d8ad5 --- /dev/null +++ b/packages/client/src/packages-write-envelope.test.ts @@ -0,0 +1,234 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12034 — shipping half] `client.packages.install` / `enable` / `disable` + * answer the BARE `InstalledPackage` row, and the `{ package; message? }` + * envelope they used to declare is emitted by nothing. + * + * ## What was wrong + * + * These three declared `{ package: any; message?: string }`. That is not an + * ERASED type — it is a FALSE one: no surface has ever sent that body. Because + * the member was `any`, + * + * (await client.packages.enable(id)).package + * + * compiled, and was `undefined` at runtime. The `any` is precisely what kept + * the falsehood invisible; a bound-but-wrong declaration is the shape an AI + * consumer reads as ground truth and writes against. + * + * ## Why there was no "which surface do we match" question + * + * `GET /packages/:id` genuinely forks between the two mounted surfaces, which + * is why its declaration is untouched (see `index.ts`, and the cost analysis on + * #12034). These three do not fork, because the REST registrar mounts no twin + * for any of them — MEASURED by driving `registerPackageRoutes` and + * enumerating what came back: + * + * ["POST /api/v1/packages/publish", "GET /api/v1/packages", + * "GET /api/v1/packages/:id", "DELETE /api/v1/packages/:id"] + * + * `POST /packages`, `PATCH /packages/:id/enable` and + * `PATCH /packages/:id/disable` are absent. One producer, therefore one true + * type, therefore nothing to choose between. + * + * ## Why this file is a WIRE test and not a type test + * + * The two halves are separate on purpose and neither substitutes for the other + * (`return-type-precision.test.ts` states the rule in its header): + * + * - a runtime test cannot observe a return-type narrowing at all — the value + * is identical whatever the declaration says. The DECLARATION half is + * pinned type-level in `return-type-precision.test.ts`. + * - a type test cannot observe whether the declaration is TRUE. That is this + * file's job, and it is why nothing here mocks a response body: a mock body + * would assert my own assumption about the producer, which is exactly the + * mistake that produced the false declaration in the first place. + * + * So the chain below is real end to end — a real `SchemaRegistry` from + * `@objectstack/objectql`, the real `HttpDispatcher` from `@objectstack/runtime` + * answering it, and the real `ObjectStackClient` (real `unwrapResponse`) + * reading the result. The only stand-in is the socket: `fetch` hands the + * request to the dispatcher in-process instead of over TCP, and hands back the + * dispatcher's own body untouched. + * + * --------------------------------------------------------------------------- + * Reverse verification, direction predicted BEFORE running + * --------------------------------------------------------------------------- + * Restoring `{ package: any; message?: string }` on the three methods leaves + * THIS file green — the wire value does not change — and turns + * `return-type-precision.test.ts` RED under `tsc`. That asymmetry is the whole + * reason both files exist; the ablation is recorded on the PR against the type + * half, which is the half a declaration change can move. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { HttpDispatcher } from '@objectstack/runtime'; +import { ObjectStackClient } from './index'; + +const BASE_URL = 'http://localhost:3000'; +const PACKAGES_PATH = '/api/v1/packages'; + +const MANIFEST = { + id: 'com.acme.crm', + name: 'Acme CRM', + version: '1.0.0', + type: 'app', + namespace: 'acme', +} as const; + +/** + * The caller the `/packages` domain gates on: ADR-0106 D4 read capabilities for + * the reads, `manage_metadata` for the writes. Anonymous is refused before any + * of this (a separate, already-pinned rule), so the envelope under test is only + * reachable with a resolved principal. + */ +const CONTEXT = (): any => ({ + request: {}, + environmentId: 'os-12034-envelope', + executionContext: { + userId: 'u_admin', + isSystem: false, + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + }, +}); + +/** + * The in-process socket. Everything either side of it is production code: the + * URL the client built goes in, the body the dispatcher produced comes back, + * and nothing in between rewrites a key. + */ +function dispatcherBackedClient(registry: SchemaRegistry) { + const kernel: any = { + context: { getService: (name: string) => (name === 'objectql' ? { registry } : null) }, + }; + const dispatcher = new HttpDispatcher(kernel); + const seen: Array<{ method: string; path: string; body: unknown }> = []; + + const fetchImpl = async (url: string, init: RequestInit = {}): Promise => { + const parsed = new URL(String(url)); + expect(parsed.pathname.startsWith(PACKAGES_PATH)).toBe(true); + const subPath = parsed.pathname.slice(PACKAGES_PATH.length); + const method = init.method ?? 'GET'; + const body = init.body ? JSON.parse(String(init.body)) : undefined; + seen.push({ method, path: subPath, body }); + + const result = await dispatcher.handlePackages( + subPath, + method, + body, + Object.fromEntries(parsed.searchParams), + CONTEXT(), + ); + const status = result.response?.status ?? 500; + const payload = result.response?.body; + return { + ok: status >= 200 && status < 300, + status, + statusText: String(status), + headers: new Headers(), + json: async () => payload, + }; + }; + + const client = new ObjectStackClient({ baseUrl: BASE_URL, fetch: fetchImpl as any }); + return { client, dispatcher, seen }; +} + +let osHome: string; +let previousOsHome: string | undefined; + +beforeAll(() => { + // `enable` / `disable` persist the operator's choice under OS_HOME. Point + // that at a scratch dir so the suite never writes to a real home. + previousOsHome = process.env.OS_HOME; + osHome = mkdtempSync(join(tmpdir(), 'os-12034-')); + process.env.OS_HOME = osHome; +}); + +afterAll(() => { + if (previousOsHome === undefined) delete process.env.OS_HOME; + else process.env.OS_HOME = previousOsHome; + rmSync(osHome, { recursive: true, force: true }); +}); + +describe('#12034 — the packages write verbs answer the bare row, not `{ package }`', () => { + it('install resolves to the row itself, and has no `package` member to read', async () => { + const registry = new SchemaRegistry(); + const { client, seen } = dispatcherBackedClient(registry); + + const installed = await client.packages.install(MANIFEST); + + // It reached the route the card is about. + expect(seen).toEqual([{ method: 'POST', path: '', body: { manifest: MANIFEST } }]); + + // THE LINE THAT WAS A LIE: the declaration promised `.package`. + expect((installed as any).package).toBeUndefined(); + expect((installed as any).message).toBeUndefined(); + + // What actually comes back is the row — identical to what the registry + // holds, which is the value the new declaration names. + expect(installed.manifest).toEqual(MANIFEST); + expect(installed).toEqual(registry.getPackage(MANIFEST.id)); + }); + + it('enable resolves to the row, carrying the flag it just set', async () => { + const registry = new SchemaRegistry(); + registry.installPackage(MANIFEST as any); + registry.disablePackage(MANIFEST.id); + const { client, seen } = dispatcherBackedClient(registry); + + const row = await client.packages.enable(MANIFEST.id); + + expect(seen).toEqual([{ method: 'PATCH', path: '/com.acme.crm/enable', body: undefined }]); + expect((row as any).package).toBeUndefined(); + expect((row as any).message).toBeUndefined(); + // The state the verb changed is read OFF THE ROW — the read the old + // declaration sent callers looking for under `.package`. + expect(row.enabled).toBe(true); + expect(row).toEqual(registry.getPackage(MANIFEST.id)); + }); + + it('disable resolves to the row, carrying the flag it just cleared', async () => { + const registry = new SchemaRegistry(); + registry.installPackage(MANIFEST as any); + const { client, seen } = dispatcherBackedClient(registry); + + const row = await client.packages.disable(MANIFEST.id); + + expect(seen).toEqual([{ method: 'PATCH', path: '/com.acme.crm/disable', body: undefined }]); + expect((row as any).package).toBeUndefined(); + expect((row as any).message).toBeUndefined(); + expect(row.enabled).toBe(false); + expect(row).toEqual(registry.getPackage(MANIFEST.id)); + }); + + /** + * The premise the three assertions above rest on. `unwrapResponse` strips + * exactly ONE `{ success, data }` envelope, so "the dispatcher sends + * `success(pkg)`" and "the caller receives `pkg`" are the same statement + * only while that stays true. Pinned here against the REAL dispatcher body + * rather than a written-out literal. + */ + it('the dispatcher wraps the row exactly once, and the client strips exactly that', async () => { + const registry = new SchemaRegistry(); + registry.installPackage(MANIFEST as any); + const { client, dispatcher } = dispatcherBackedClient(registry); + + const raw = await dispatcher.handlePackages('/com.acme.crm/enable', 'PATCH', undefined, {}, CONTEXT()); + const body: any = raw.response?.body; + + // The producer's own body: one envelope, the row under `data`, and no + // `package` key anywhere in it. + expect(Object.keys(body).sort()).toEqual(['data', 'meta', 'success']); + expect(body.success).toBe(true); + expect('package' in body.data).toBe(false); + + // …and the post-unwrap value the SDK hands the caller is that `data`. + expect(await client.packages.enable(MANIFEST.id)).toEqual(body.data); + }); +}); diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index 0348ecae2c..f79145b817 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -362,6 +362,62 @@ export async function returnTypePrecisionPins12038(): Promise { void wrongDiagnostics; } +/** + * [#12034 — shipping half] The three `packages` WRITE verbs, bound to the bare + * `InstalledPackage` row. + * + * These did not carry an ERASED type, they carried a FALSE one: + * `{ package: any; message?: string }`, a body no surface has ever emitted. + * The only serving surface is `runtime`'s `/packages` domain — the REST + * registrar mounts no twin for `POST /packages`, + * `PATCH /packages/:id/enable` or `PATCH /packages/:id/disable`, measured by + * driving `registerPackageRoutes` and enumerating what it mounted — and it + * answers `success(pkg)`. That the WIRE says so is pinned against the real + * dispatcher in `packages-write-envelope.test.ts`; that the DECLARATION says + * so can only be pinned here, for this file's standing reason. + * + * ⛔ `packages.get` is deliberately ABSENT from this list. It is the half of + * #12034 that was NOT shipped: its two mounted surfaces answer different + * envelopes (dispatcher `success(pkg)`, REST `sendOk(res, { package })`), so + * no declaration is true on both and binding either member would harden a + * falsehood — the very defect this function closes for its neighbours. Making + * it bindable requires converging the PRODUCERS, which is a wire-behaviour + * ruling of its own. + */ +export async function returnTypePrecisionPins12034(): Promise { + // ── direction 1: the bare row, on all three ────────────────────────── + expectTypeOf(await client.packages.install({ id: 'com.acme.crm', version: '1.0.0' })) + .toEqualTypeOf(); + expectTypeOf(await client.packages.enable('com.acme.crm')).toEqualTypeOf(); + expectTypeOf(await client.packages.disable('com.acme.crm')).toEqualTypeOf(); + + // ── direction 2: the read the false declaration invited must now FAIL ─ + // ⚠️ RED BEFORE, all three: while the member was `any`, `.package` was a + // legal read, so each suppression below went UNUSED and tsc reported + // TS2578. That unused-suppression signal IS the defect stated as a + // compile error — the whole point of the card is that + // `(await client.packages.enable(id)).package` compiled and was + // `undefined` at runtime. After the binding the row has no `package` key, + // the suppressions are used, and the reads are refused at the call site + // where a consumer would have written them. + // @ts-expect-error the install route answers the row; there is no `.package` + void (await client.packages.install({ id: 'com.acme.crm', version: '1.0.0' })).package; + // @ts-expect-error the enable route answers the row; there is no `.package` + void (await client.packages.enable('com.acme.crm')).package; + // @ts-expect-error the disable route answers the row; there is no `.package` + void (await client.packages.disable('com.acme.crm')).package; + + // Same direction for the `message` sibling the old envelope also promised + // and no surface sends. + // @ts-expect-error no surface sends a `message` alongside the row + void (await client.packages.enable('com.acme.crm')).message; + + // ── the UNSHIPPED half, pinned as unchanged ────────────────────────── + // Not evidence for this card — a guard that `get` is not "tidied up" into + // one of the two shapes while the fork is still open. + expectTypeOf(await client.packages.get('com.acme.crm')).toEqualTypeOf<{ package: any }>(); +} + /** * ⚠️ GREEN IN BOTH STATES — regression guards, recorded as such rather than * counted as evidence that this card's change was needed. Each pins a @@ -406,6 +462,7 @@ describe('client SDK return-type precision (#8140)', () => { expect(typeof searchResultIsNotTheGlobalSearchShape).toBe('function'); expect(typeof returnTypePrecisionPins11925).toBe('function'); expect(typeof returnTypePrecisionPins12038).toBe('function'); + expect(typeof returnTypePrecisionPins12034).toBe('function'); expect(typeof commitRollbackResponseIsNotTheVersionRollbackShape).toBe('function'); expect(typeof environmentIsNotTheCloudWireRow).toBe('function'); });