From 21e6b9887c90ed6b1a17d0d5df3b2525df9e8347 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 10:14:43 +0000 Subject: [PATCH 1/5] fix(client): declare the two packages read doors at the stage the door is declared at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages.get` / `packages.list` — global and environment-scoped, four read members in all — returned `InstalledPackage`, the AUTHORING manifest stage, while `ListInstalledPackagesResponseSchema.packages` and `GetInstalledPackageResponseSchema.data` have been declared `InstalledPackageAtEitherStageSchema` since PR #17517. A response the server is declared able to send was one this SDK's own types said could not arrive. `packages/spec` is the one contract and `packages/client` is a consumer of it, so the consumer's declaration moves. The three WRITE members keep `InstalledPackage`: their own request contract declares `manifest: ManifestSchema`, and PR #17517 moved the read doors alone. Also corrects a comment measured stale on the same member: it claimed `GetInstalledPackageResponseSchema` is `data: InstalledPackageSchema`. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf --- packages/client/src/index.ts | 101 +++++++++++--- .../client/src/return-type-precision.test.ts | 132 +++++++++++++++++- 2 files changed, 208 insertions(+), 25 deletions(-) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index c12b554d20a..1234362ed55 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -123,6 +123,17 @@ import { PackageExportManifest, ReassignOrphanedMetadataResponse, DuplicatePackageResponse, + // [#17536] The element the two `/packages` READ doors are declared to serve. + // `ListInstalledPackagesResponseSchema.packages` is + // `z.array(InstalledPackageAtEitherStageSchema)` and + // `GetInstalledPackageResponseSchema.data` is that same schema + // (`spec/src/api/package-api.zod.ts`) — a union of the two CLOSED manifest + // stages, authoring (`InstalledPackageSchema`) and assembled + // (`AssembledInstalledPackageSchema`), never a tolerant shape. The client is a + // CONSUMER of that contract, so the widest value those doors are declared to + // answer is what they are declared to return here. The WRITE methods on the + // same object keep `InstalledPackage`: PR #17517 moved the read doors alone. + InstalledPackageAtEitherStage, } from '@objectstack/spec/api'; import type { ApprovalRequestRow, @@ -2453,8 +2464,28 @@ export class ObjectStackClient { * deliberately NOT declared here — the dispatcher rows have no such key, so * declaring it would be false on that surface. #8140 bound the identical * scoped sibling (`ScopedEnvironmentClient.packages.list`) the same way. - */ - list: async (filters?: { status?: string; type?: string; enabled?: boolean }): Promise<{ packages: InstalledPackage[]; total: number }> => { + * + * ⚠️ [#17536] The ROW type moved, the envelope did not. #11925 bound the + * element to `InstalledPackage` — the AUTHORING stage — and PR #17517 then + * declared the door at EITHER stage: + * `ListInstalledPackagesResponseSchema.packages` is + * `z.array(InstalledPackageAtEitherStageSchema)`. That left two declarations + * one layer apart disagreeing, with this one under-declaring: a row a + * `defineStack()` host installed carries the ASSEMBLED body, the door is + * declared able to serve it, and this SDK's own types said it could not + * arrive. The contract is `packages/spec` and this is a consumer of it + * (Prime Directive #12), so the consumer's declaration is what moves. + * + * ⚠️ Clause-② widening, and what it costs a caller: the element is a union + * of two whole stages that differ in ONE key, `manifest`. Every other member + * of the row — `id`, `name`, `version`, `status`, `enabled`, … — is common + * to both branches and reads exactly as before. A caller that reaches INTO + * `manifest` separates the stages first (`Array.isArray(pkg.manifest.objects) + * ? … : …`, or a `packages/spec` parse), because the authoring stage's + * `objects` are GLOB STRINGS and the assembled stage's are object + * DEFINITIONS. No runtime behaviour changes; the values were always these. + */ + list: async (filters?: { status?: string; type?: string; enabled?: boolean }): Promise<{ packages: InstalledPackageAtEitherStage[]; total: number }> => { const route = this.getRoute('packages'); const params = new URLSearchParams(); if (filters?.status) params.set('status', filters.status); @@ -2463,7 +2494,7 @@ export class ObjectStackClient { const qs = params.toString(); const url = `${this.baseUrl}${route}${qs ? '?' + qs : ''}`; const res = await this.fetch(url); - return this.unwrapResponse<{ packages: InstalledPackage[]; total: number }>(res); + return this.unwrapResponse<{ packages: InstalledPackageAtEitherStage[]; total: number }>(res); }, /** @@ -2489,29 +2520,44 @@ export class ObjectStackClient { * * That is the SAME projection its `list` neighbour maps over every row * (`packages/runtime/src/domains/packages.ts` — one expression, two - * doors), and `list` is already declared `InstalledPackage[]` directly - * above. This binding therefore makes two doors of one domain agree - * rather than making a new claim about either. `packages/spec` has - * declared the same thing all along and was never the fork's casualty: - * `GetInstalledPackageResponseSchema` is `data: InstalledPackageSchema`, - * the bare row. + * doors), and `list` carries the identical element type directly above. + * This binding therefore makes two doors of one domain agree rather than + * making a new claim about either. + * + * ⚠️ [#17536] The sentence that closed this paragraph was measured STALE + * and is corrected rather than deleted, because what it claimed is what + * made the authoring-stage binding below look settled. It read: + * «`packages/spec` has declared the same thing all along and was never the + * fork's casualty: `GetInstalledPackageResponseSchema` is `data: + * InstalledPackageSchema`, the bare row.» The first half still holds — the + * door has always answered the BARE row, no envelope. The second half + * stopped being true when PR #17517 landed: that schema's `data` now reads + * `InstalledPackageAtEitherStageSchema`, the union of the two closed + * manifest stages. It was a COMMENT and never a binding, which is why the + * disagreement it describes could sit here unread by any gate. * * `source` stays undeclared because there is no longer anything that * emits it on this route; `writable` stays undeclared for the reason * `list` leaves it undeclared. * - * ⚠️ Clause-② narrowing. `{ package: any }` is what let + * ⚠️ Clause-② narrowing (#12034). `{ package: any }` is what let * `(await client.packages.get(id)).package` compile, and on the only * surface that has served this route since #16628 it was `undefined` at * runtime — the falsehood was invisible precisely because the member was * `any`. Callers read the row itself. Pinned in * `return-type-precision.test.ts`, which is the only place it CAN be * pinned: a runtime test cannot observe a return-type narrowing at all. + * + * ⚠️ Clause-② widening (#17536), the LATER and opposite move on the same + * member: the row is declared at whichever stage it was installed at, for + * the reason and at the cost written out on `list` above. Both directions + * are pinned in the same file, and for the same reason — only a + * compile-time assertion can observe either. */ - get: async (id: string): Promise => { + get: async (id: string): Promise => { const route = this.getRoute('packages'); const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}`); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, /** @@ -7896,9 +7942,19 @@ export class ScopedEnvironmentClient { * package tests. */ packages = { - list: async (): Promise<{ packages: InstalledPackage[]; total: number }> => { + /** + * ⚠️ [#17536] Clause-② widening, in step with the global twin + * `client.packages.list`. This path and that one reach the SAME door: the + * `@objectstack/hono` catch-all hands `dispatch()` the still-scoped path, + * `dispatch()` strips the `/environments/:environmentId` prefix, and the + * `/packages` domain answers. One door cannot be declared at two different + * element types by two SDK members, so both move together — see + * `ObjectStackClient.packages.list` for the door reading, the Prime + * Directive #12 argument and what the union costs a caller. + */ + list: async (): Promise<{ packages: InstalledPackageAtEitherStage[]; total: number }> => { const res = await this.parent._fetch(this.url('/packages')); - return this.parent._unwrap<{ packages: InstalledPackage[]; total: number }>(res); + return this.parent._unwrap<{ packages: InstalledPackageAtEitherStage[]; total: number }>(res); }, /** * [#11925 bound it · #12034 corrected the shape] The BARE row, no @@ -7926,22 +7982,29 @@ export class ScopedEnvironmentClient { * site) — and the `/packages` domain answers * `success(withWritableVerdict(qlService, toPackageResponse(pkg)))`. That * is the identical projection its `list` neighbour above maps over, which - * is why `list` already declares `InstalledPackage[]` and needed no + * is why `list` carries the identical element type and needed no * correction here. * - * ⚠️ Clause-② narrowing, and the sharper of the two: this member was - * `InstalledPackage`, not `any`, so `(await scoped.packages.get(id)) + * ⚠️ Clause-② narrowing (#12034), and the sharper of the two: this member + * was `InstalledPackage`, not `any`, so `(await scoped.packages.get(id)) * .package` compiled with a REAL type behind it and was `undefined` at * runtime. * + * ⚠️ Clause-② widening (#17536), the later and opposite move: the row is + * declared at whichever manifest stage it was installed at, because that is + * what `GetInstalledPackageResponseSchema.data` has been declared as since + * PR #17517. Same door as the global twin, same element type, same cost to + * a caller that reaches into `manifest` — written out once on + * `ObjectStackClient.packages.list`. + * * `version` is unchanged and deliberately not touched by this card — it * is a request-side question, and it is a live one: see the acceptance * notes on #12034. */ - get: async (id: string, version?: string): Promise => { + get: async (id: string, version?: string): Promise => { const qs = version ? `?version=${encodeURIComponent(version)}` : ''; const res = await this.parent._fetch(this.url(`/packages/${encodeURIComponent(id)}${qs}`)); - return this.parent._unwrap(res); + return this.parent._unwrap(res); }, }; diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index 8ed2d396e45..b9c4e822409 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -66,6 +66,13 @@ import type { OrganizationWire, } from './index'; import type { SearchAllResponse } from '@objectstack/spec/api'; +// [#17536] The two READ doors' declared element, and the branch of it that made +// the client's authoring-stage declaration wrong. Both are imported as TYPES: +// the pins below are the only thing that can observe a return-type move. +import type { + AssembledInstalledPackage, + InstalledPackageAtEitherStage, +} from '@objectstack/spec/api'; import type { AnalyticsMetadataResponse, AnalyticsSqlResponse, @@ -193,8 +200,12 @@ export async function returnTypePrecisionPins(): Promise { // ── shape class 6: the ScopedEnvironmentClient MIRROR carries the same types ─ expectTypeOf(await scoped.automation.getFlow('flow_a')).toEqualTypeOf(); expectTypeOf(await scoped.automation.getRun('flow_a', 'run_1')).toEqualTypeOf(); + // ⚠️ [#17536] The ELEMENT moved off `InstalledPackage` (the authoring stage) + // onto the union the door is declared at. The envelope #8140 pinned here is + // untouched; see `installedPackageEitherStagePins17536` below for the door + // reading and the two directions that measure it. expectTypeOf(await scoped.packages.list()).toEqualTypeOf<{ - packages: InstalledPackage[]; + packages: InstalledPackageAtEitherStage[]; total: number; }>(); @@ -303,8 +314,11 @@ export async function returnTypePrecisionPins11925(): Promise { // send `{ packages, total: packages.length }`. The REST rows also carry a // `source` discriminator the dispatcher rows lack, so it stays undeclared // — the same treatment #8140 gave the scoped sibling directly above. + // ⚠️ [#17536] The ELEMENT moved, the envelope did not — this pin is still + // #11925's statement that both mounted surfaces agree on + // `{ packages, total }`, and that half is unchanged. expectTypeOf(await client.packages.list()).toEqualTypeOf<{ - packages: InstalledPackage[]; + packages: InstalledPackageAtEitherStage[]; total: number; }>(); @@ -332,7 +346,14 @@ export async function returnTypePrecisionPins11925(): Promise { // sees it — and answers the bare row. ⇒ "one surface and one shape" is // still true; the shape is the ROW, and #12034 moved the declaration to // match it. - expectTypeOf(await scoped.packages.get('com.acme.crm')).toEqualTypeOf(); + // + // ⚠️ [#17536] And the ROW's own declaration moved once more, at the stage + // axis rather than the envelope axis: `GetInstalledPackageResponseSchema` + // has read `data: InstalledPackageAtEitherStageSchema` since PR #17517. "One + // surface and one shape" is STILL true — the shape is the row at whichever + // manifest stage it was installed at. + expectTypeOf(await scoped.packages.get('com.acme.crm')) + .toEqualTypeOf(); // ── direction 2: a WRONG shape must now be rejected ─────────────────── // ⚠️ Only ONE of the two below was red before #11925's change, and the @@ -568,9 +589,19 @@ export async function returnTypePrecisionPins12034(): Promise { // The two the ruling added. Same row, same projection: the `/packages` // domain builds the detail body and every `list` row with ONE expression, // `withWritableVerdict(qlService, toPackageResponse(pkg))`, so this pin and - // the `InstalledPackage[]` on `list` are two readings of one producer. - expectTypeOf(await client.packages.get('com.acme.crm')).toEqualTypeOf(); - expectTypeOf(await scoped.packages.get('com.acme.crm')).toEqualTypeOf(); + // the element type on `list` are two readings of one producer. + // + // ⚠️ [#17536] Which is why these two READ members moved and the three WRITE + // members above did not. One producer expression serves `list` and `get`, and + // PR #17517 declared what it serves — `InstalledPackageAtEitherStageSchema` + // — on both read doors. The install / enable / disable members answer the row + // their own request contract produced (`PackageInstallRequestSchema` declares + // `manifest: ManifestSchema`, the AUTHORING stage), so their declaration is + // unchanged and this asymmetry is the measurement, not an oversight. + expectTypeOf(await client.packages.get('com.acme.crm')) + .toEqualTypeOf(); + expectTypeOf(await scoped.packages.get('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 @@ -616,6 +647,94 @@ export async function returnTypePrecisionPins12034(): Promise { void (await scoped.packages.get('com.acme.crm')).package; } +declare const authoringRow: InstalledPackage; +declare const assembledRow: AssembledInstalledPackage; + +/** + * [#17536] The four `/packages` READ members declare the stage the DOOR is + * declared at, not the stage they happened to be bound to first. + * + * ## What was wrong + * + * PR #17517 declared both read doors at EITHER manifest stage — + * `ListInstalledPackagesResponseSchema.packages` is + * `z.array(InstalledPackageAtEitherStageSchema)` and + * `GetInstalledPackageResponseSchema.data` is that same schema. This SDK went on + * declaring `InstalledPackage`, the AUTHORING stage, on all four read members. + * ⇒ A response the server is declared able to send was one this client's own + * types said could not arrive. `packages/spec` is the one contract and this is a + * consumer of it (Prime Directive #12), so the consumer's declaration moves. + * + * ## Why these pins are type-level, like every other pin in this file + * + * Nothing about the VALUES changed. A `defineStack()` host has been installing + * assembled-manifest rows into the same table the whole time, and both read + * doors have been serving them. Only the declaration moved, so only a + * compile-time assertion can observe it — a runtime test is green either way. + * + * ## The two directions, and the control that makes direction 1 mean something + * + * Direction 1 asserts the declared return now ADMITS an assembled-stage row. + * That assertion is only evidence if an assembled row was REFUSED before, so the + * refusal is pinned in the same breath: the three WRITE members did not move, + * and the `@ts-expect-error` on `install` below is exactly the assignment + * direction 1 makes. If `AssembledInstalledPackage` were assignable to + * `InstalledPackage` after all, that suppression would go UNUSED and tsc would + * report TS2578 — so the pair fails loudly instead of passing vacuously. + * + * Direction 2 asserts what the widening did NOT buy. The door's element is a + * union of two CLOSED stages, never a tolerant shape, and a `manifest` belonging + * to neither is refused by both branches. That suppression is what reddens if + * anyone ever "widens" these members to `any` / `unknown` / an open shape to + * make a payload fit. + */ +export function installedPackageEitherStagePins17536(): void { + // ── direction 1: the assembled stage is ADMITTED, on all four read members ─ + // RED BEFORE, all four: the declared element was `InstalledPackage`, whose + // `manifest` is `ManifestSchema` (the authoring stage), and an assembled body + // is not assignable to it. + const getAdmitsAssembled: Awaited> = assembledRow; + const scopedGetAdmitsAssembled: Awaited> = assembledRow; + const listAdmitsAssembled: Awaited>['packages'][number] = + assembledRow; + const scopedListAdmitsAssembled: Awaited< + ReturnType + >['packages'][number] = assembledRow; + + // GREEN IN BOTH STATES — regression guard, not red-before evidence. The move + // is a WIDENING: the authoring stage stays admitted. A "fix" that swapped one + // stage for the other rather than declaring the union reddens here. + const getStillAdmitsAuthoring: Awaited> = authoringRow; + + // ── the control: the WRITE members did not move ────────────────────────── + // `POST /packages` declares `manifest: ManifestSchema` on its REQUEST + // contract, so the row it answers is the authoring stage and stays declared + // as one. This suppression is also the lit control for direction 1 above: it + // is the same assignment, and it is USED precisely because an assembled row + // is refused by an `InstalledPackage` annotation. + // @ts-expect-error the install door answers the AUTHORING stage; an assembled row is not one + const installRefusesAssembled: Awaited> = assembledRow; + + // ── direction 2: two closed stages, not a tolerant shape ───────────────── + // A `manifest` belonging to NEITHER stage is refused by both branches of the + // union, so it is refused by the declaration. This is the line that reddens + // (TS2578, unused suppression) if these members are ever widened to `any`, + // `unknown`, or an open shape. + // @ts-expect-error neither manifest stage admits a string; the union is over two closed stages + const neitherStage: Awaited> = { + ...authoringRow, + manifest: 'com.acme.crm@1.0.0', + }; + + void getAdmitsAssembled; + void scopedGetAdmitsAssembled; + void listAdmitsAssembled; + void scopedListAdmitsAssembled; + void getStillAdmitsAuthoring; + void installRefusesAssembled; + void neitherStage; +} + /** * [#12104 — the in-repo half] The SIXTH erasure spelling: no return annotation * and `return res.json()`, whose published type came from `lib.dom`'s @@ -1248,6 +1367,7 @@ describe('client SDK return-type precision (#8140)', () => { expect(typeof returnTypePrecisionPins11925).toBe('function'); expect(typeof returnTypePrecisionPins12038).toBe('function'); expect(typeof returnTypePrecisionPins12034).toBe('function'); + expect(typeof installedPackageEitherStagePins17536).toBe('function'); expect(typeof returnTypePrecisionPins12104).toBe('function'); expect(typeof returnTypePrecisionPins14312).toBe('function'); expect(typeof returnTypePrecisionPins15451).toBe('function'); From 135c6d03871336cacc2a5037a3435a9518dcbd2b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 10:29:10 +0000 Subject: [PATCH 2/5] test(client): pin the either-stage read declaration, with its lit control Direction 1 pins that all four read members now admit an assembled-stage row; the `@ts-expect-error` on `packages.install` is the same assignment against the unmoved write door and is what makes direction 1 non-vacuous. Direction 2 pins that the union is two CLOSED stages: a `manifest` belonging to neither is still refused, so a later widening to `any` / `unknown` reddens here. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf --- packages/client/src/return-type-precision.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index b9c4e822409..6a822c207e2 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -720,9 +720,14 @@ export function installedPackageEitherStagePins17536(): void { // union, so it is refused by the declaration. This is the line that reddens // (TS2578, unused suppression) if these members are ever widened to `any`, // `unknown`, or an open shape. - // @ts-expect-error neither manifest stage admits a string; the union is over two closed stages + // + // ⚠️ The suppression sits on the PROPERTY, not on the `const`. Measured: tsc + // reports this mismatch at the offending member of the object literal, so a + // directive on the declaration line covers the wrong line and goes unused — + // TS2578, a red gate that says nothing about the union. const neitherStage: Awaited> = { ...authoringRow, + // @ts-expect-error neither manifest stage admits a string; the union is over two closed stages manifest: 'com.acme.crm@1.0.0', }; From 8fa0d5a1f8c94c285a3c8a1ece8327c32b48a864 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 10:34:49 +0000 Subject: [PATCH 3/5] chore(changeset): the client read-door widening (#17536) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf --- ...client-packages-read-doors-either-stage.md | 24 +++++++++++++++++ .../client/src/return-type-precision.test.ts | 27 ++++++++++++++----- 2 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 .changeset/17536-client-packages-read-doors-either-stage.md diff --git a/.changeset/17536-client-packages-read-doors-either-stage.md b/.changeset/17536-client-packages-read-doors-either-stage.md new file mode 100644 index 00000000000..812cc1c98d7 --- /dev/null +++ b/.changeset/17536-client-packages-read-doors-either-stage.md @@ -0,0 +1,24 @@ +--- +"@objectstack/client": minor +--- + +fix(client): the four `packages` READ members declare the stage their door is declared at (#17536) + +Clause-②: yes + +**BREAKING** for TypeScript consumers — a published TYPE-surface WIDENING, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition below, never by the level). No runtime behaviour changes, and none is possible here: only a declaration moved, and the values these four methods resolve to are the values they have always resolved to. + +`ObjectStackClient.packages.list` / `.get` and their `ScopedEnvironmentClient` twins returned `InstalledPackage` — the AUTHORING manifest stage, imported from `@objectstack/spec/kernel`. Since PR #17517 both read doors have been declared at EITHER stage: `ListInstalledPackagesResponseSchema.packages` is `z.array(InstalledPackageAtEitherStageSchema)` and `GetInstalledPackageResponseSchema.data` is that same schema. ⇒ A response the server is declared able to send was one this SDK's own types said could not arrive. + +`packages/spec` is the one contract between producers and consumers, and `packages/client` is a consumer of it, so the consumer's declaration is what moves. All four now declare `InstalledPackageAtEitherStage` from `@objectstack/spec/api`. + +**What the union is, and what it is not.** It is a union of two whole, closed stages — `InstalledPackageSchema` and `AssembledInstalledPackageSchema` — that differ in exactly one key, `manifest`. Every other member of the row (`id`, `name`, `version`, `status`, `enabled`, `installedAt`, …) is common to both branches and reads exactly as it did. A row belonging to neither stage is refused by both branches and therefore by the declaration; ⛔ this is not a tolerant shape and must never be relaxed into one. + +**What a consumer does.** Code that reads only the common members needs no change at all. Code that reaches INTO `manifest` separates the two stages first, because the authoring stage's `objects` are GLOB STRINGS while the assembled stage's are object DEFINITIONS — the compiler now says so at the call site instead of letting a glob-shaped read compile against a row that carries definitions. In this repository the whole consumer cost is zero sites outside `packages/client` itself: no other workspace package calls either read member. + +**The three WRITE members did not move** and stay declared at the authoring stage. `install` / `enable` / `disable` answer the row their own request contract produced — `PackageInstallRequestSchema` declares `manifest: ManifestSchema` — and PR #17517 moved the read doors alone. That asymmetry is the measurement, not an oversight, and it is pinned. + +The type is reached the same way `InstalledPackage` always was, from `@objectstack/spec` rather than re-exported here: this SDK has never re-exported the package row, and this change does not start. + + diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index 6a822c207e2..b02593401dc 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -687,6 +687,15 @@ declare const assembledRow: AssembledInstalledPackage; * to neither is refused by both branches. That suppression is what reddens if * anyone ever "widens" these members to `any` / `unknown` / an open shape to * make a payload fit. + * + * ## Ablation, measured rather than asserted + * + * Restore `packages/client/src/index.ts` to `origin/main` (`adf4b18777`), keep + * this file, run `tsc --noEmit -p tsconfig.test.json`: exit 2, **nine** errors — + * the five `toEqualTypeOf` pins that name the union (TS2344, here and in the + * three blocks above) and the four direction-1 assignments below (TS2322). Both + * `@ts-expect-error` lines stay USED in that run, which is what makes them + * controls rather than evidence: they are labelled GREEN IN BOTH STATES below. */ export function installedPackageEitherStagePins17536(): void { // ── direction 1: the assembled stage is ADMITTED, on all four read members ─ @@ -707,17 +716,21 @@ export function installedPackageEitherStagePins17536(): void { const getStillAdmitsAuthoring: Awaited> = authoringRow; // ── the control: the WRITE members did not move ────────────────────────── - // `POST /packages` declares `manifest: ManifestSchema` on its REQUEST - // contract, so the row it answers is the authoring stage and stays declared - // as one. This suppression is also the lit control for direction 1 above: it - // is the same assignment, and it is USED precisely because an assembled row - // is refused by an `InstalledPackage` annotation. + // GREEN IN BOTH STATES — a control, not red-before evidence, and that is + // exactly its job. `POST /packages` declares `manifest: ManifestSchema` on + // its REQUEST contract, so the row it answers is the authoring stage and + // stays declared as one. This suppression is the same assignment direction 1 + // makes, against the member that did NOT move: it is USED precisely because + // an assembled row is refused by an `InstalledPackage` annotation, so if the + // two were assignable after all it would go unused (TS2578) and direction 1 + // would be shown to have been passing vacuously. // @ts-expect-error the install door answers the AUTHORING stage; an assembled row is not one const installRefusesAssembled: Awaited> = assembledRow; // ── direction 2: two closed stages, not a tolerant shape ───────────────── - // A `manifest` belonging to NEITHER stage is refused by both branches of the - // union, so it is refused by the declaration. This is the line that reddens + // GREEN IN BOTH STATES — the second control. A `manifest` belonging to + // NEITHER stage is refused by both branches of the union, so it is refused by + // the declaration. This is the line that reddens // (TS2578, unused suppression) if these members are ever widened to `any`, // `unknown`, or an open shape. // From f0a7a35e6339ecf8872130b5e98ff0b9b5634436 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 12:15:38 +0000 Subject: [PATCH 4/5] docs(client): say what the either-stage union buys TODAY, and pin the type-level gap (#17536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The at-tier contract review of PR #19323 FAILed this diff on its TEXT, not its types: four passages asserted a stage discrimination and a closed shape the published declaration measurably does not provide. The declarations stay exactly as ruled; the prose around them is corrected, and the gap it used to hide is pinned. Measured at this head with `tsc` against the built spec, in a private worktree: AssembledInstalledPackage['manifest'] Record (union).manifest.objects unknown { ...authoringRow, manifest: { bogus: 1, objects: 'not-even-an-array' } } against the union AND against Awaited> compiles `if (Array.isArray(pkg.manifest.objects))` same union in BOTH branches manifest: 'com.acme.crm@1.0.0' (a string) refused by both branches runtime control, built spec: InstalledPackageAtEitherStageSchema.safeParse(bogusRow).success false ... .safeParse(rowWithNoObjects) against each stage schema both true So: the runtime parse is strict, the TYPE is tolerant of any object manifest, and `Array.isArray` separates the stages on neither level — at runtime both stages' `objects` are arrays. - changeset: drops the "not a tolerant shape" and "the compiler now says so" claims; states the runtime/type asymmetry, names #19324 and its root cause (`packages/spec/src/stack.zod.ts:1283`, #14513), and replaces the prescribed discriminator with a worked `packages/spec` parse. - `ObjectStackClient.packages.list` docblock: same correction, at the door a consumer actually reads; `Array.isArray` is now the documented wrong answer. - the import-site comment: the closedness belongs to the RUNTIME declaration. - direction-2 test docblock: says what that pin measures — a string primitive — and what it does not. - new pin `objectToleranceGap19324`: the object tolerance recorded as the behaviour it is, with no suppression, so tsc reds on it the day #19324 closes. - the WRITE-member count was wrong in three places: four members stayed, not three (`install`, `enable`, `disable`, `update`). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf --- ...client-packages-read-doors-either-stage.md | 26 +++++- packages/client/src/index.ts | 62 ++++++++++++--- .../client/src/return-type-precision.test.ts | 79 ++++++++++++++----- 3 files changed, 131 insertions(+), 36 deletions(-) diff --git a/.changeset/17536-client-packages-read-doors-either-stage.md b/.changeset/17536-client-packages-read-doors-either-stage.md index 812cc1c98d7..bed084fd43a 100644 --- a/.changeset/17536-client-packages-read-doors-either-stage.md +++ b/.changeset/17536-client-packages-read-doors-either-stage.md @@ -12,11 +12,31 @@ Clause-②: yes `packages/spec` is the one contract between producers and consumers, and `packages/client` is a consumer of it, so the consumer's declaration is what moves. All four now declare `InstalledPackageAtEitherStage` from `@objectstack/spec/api`. -**What the union is, and what it is not.** It is a union of two whole, closed stages — `InstalledPackageSchema` and `AssembledInstalledPackageSchema` — that differ in exactly one key, `manifest`. Every other member of the row (`id`, `name`, `version`, `status`, `enabled`, `installedAt`, …) is common to both branches and reads exactly as it did. A row belonging to neither stage is refused by both branches and therefore by the declaration; ⛔ this is not a tolerant shape and must never be relaxed into one. +**What the union is.** It is a union over the two manifest stages — `InstalledPackageSchema` and `AssembledInstalledPackageSchema` — which differ in exactly one key, `manifest`. Every other member of the row (`id`, `name`, `version`, `status`, `enabled`, `installedAt`, …) is common to both branches and reads exactly as it did, so code that reads only those members needs no change at all. -**What a consumer does.** Code that reads only the common members needs no change at all. Code that reaches INTO `manifest` separates the two stages first, because the authoring stage's `objects` are GLOB STRINGS while the assembled stage's are object DEFINITIONS — the compiler now says so at the call site instead of letting a glob-shaped read compile against a row that carries definitions. In this repository the whole consumer cost is zero sites outside `packages/client` itself: no other workspace package calls either read member. +**Where the RUNTIME and the TYPE disagree, measured at this head.** The runtime schema is the strict half: `InstalledPackageAtEitherStageSchema.safeParse(row)` answers `success: false` for a row whose `manifest` belongs to neither stage — measured on a `{ bogus: 1, objects: 'not-even-an-array' }` manifest and on an empty `{}` one. The published TYPE is NOT that strict: on the assembled branch `manifest` is declared `Record`, so both of those same rows COMPILE against the declared return type. ⛔ Do not read this widening as a type-level guarantee about `manifest` — the guarantee is the parse's. The type-level tolerance is a known gap, tracked as **#19324**; its root cause is the deliberate `z.ZodType, …>` annotation at `packages/spec/src/stack.zod.ts:1283` (#14513 — TS7056 and a declaration-chunk ceiling), and it is ⛔ not this change's to fix. It is recorded as a pin in `packages/client/src/return-type-precision.test.ts`, which reddens the day the gap closes. -**The three WRITE members did not move** and stay declared at the authoring stage. `install` / `enable` / `disable` answer the row their own request contract produced — `PackageInstallRequestSchema` declares `manifest: ManifestSchema` — and PR #17517 moved the read doors alone. That asymmetry is the measurement, not an oversight, and it is pinned. +**What a consumer does, concretely.** A member read off `manifest` on the union arrives as `unknown` (measured: `pkg.manifest.objects` is `unknown`). ⇒ a caller that reaches INTO `manifest` narrows by PARSING the row with a `packages/spec` schema and reading the parse's output: + +```ts +import { AssembledInstalledPackageSchema } from '@objectstack/spec/api'; +import { InstalledPackageSchema } from '@objectstack/spec/kernel'; + +const row = await client.packages.get(id); +const parsed = AssembledInstalledPackageSchema.safeParse(row); +if (parsed.success) { + // parsed.data.manifest — the ASSEMBLED stage, object definitions +} else { + const authoring = InstalledPackageSchema.parse(row); + // authoring.manifest.objects — the AUTHORING stage, glob strings +} +``` + +⛔ Do NOT narrow with `Array.isArray(pkg.manifest.objects)`, or with any other structural guess. It separates the stages on NEITHER level: at the type level `pkg.manifest` is the same union inside both branches of that `if`, and at runtime BOTH stages' `objects` are arrays — `z.array(z.string())` at the authoring stage against `z.array(ObjectSchema)` at the assembled one. (Measured: a row carrying no `objects` at all parses as either stage, which is the right answer for it — the key such a guess would read is not there.) + +In this repository the whole consumer cost is zero sites outside `packages/client` itself: no other workspace package calls either read member. + +**The WRITE members did not move** and stay declared at the authoring stage — all four of them: `install`, `enable`, `disable`, `update` still answer `Promise`. `install` answers the row its own request contract produced (`PackageInstallRequestSchema` declares `manifest: ManifestSchema`), and PR #17517 moved the read doors alone. That asymmetry is the measurement, not an oversight, and it is pinned. The type is reached the same way `InstalledPackage` always was, from `@objectstack/spec` rather than re-exported here: this SDK has never re-exported the package row, and this change does not start. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 1234362ed55..7f819e89ae5 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -127,12 +127,15 @@ import { // `ListInstalledPackagesResponseSchema.packages` is // `z.array(InstalledPackageAtEitherStageSchema)` and // `GetInstalledPackageResponseSchema.data` is that same schema - // (`spec/src/api/package-api.zod.ts`) — a union of the two CLOSED manifest - // stages, authoring (`InstalledPackageSchema`) and assembled - // (`AssembledInstalledPackageSchema`), never a tolerant shape. The client is a - // CONSUMER of that contract, so the widest value those doors are declared to - // answer is what they are declared to return here. The WRITE methods on the - // same object keep `InstalledPackage`: PR #17517 moved the read doors alone. + // (`spec/src/api/package-api.zod.ts`) — a union over the two manifest stages, + // authoring (`InstalledPackageSchema`) and assembled + // (`AssembledInstalledPackageSchema`), each a closed RUNTIME declaration. The + // client is a CONSUMER of that contract, so the widest value those doors are + // declared to answer is what they are declared to return here. ⚠️ What the + // published TYPE admits is wider than what the runtime parse accepts — the + // measurement, and what a caller does about it, are on `packages.list` below + // (#19324). The WRITE methods on the same object keep `InstalledPackage`: + // PR #17517 moved the read doors alone. InstalledPackageAtEitherStage, } from '@objectstack/spec/api'; import type { @@ -2477,13 +2480,46 @@ export class ObjectStackClient { * (Prime Directive #12), so the consumer's declaration is what moves. * * ⚠️ Clause-② widening, and what it costs a caller: the element is a union - * of two whole stages that differ in ONE key, `manifest`. Every other member - * of the row — `id`, `name`, `version`, `status`, `enabled`, … — is common - * to both branches and reads exactly as before. A caller that reaches INTO - * `manifest` separates the stages first (`Array.isArray(pkg.manifest.objects) - * ? … : …`, or a `packages/spec` parse), because the authoring stage's - * `objects` are GLOB STRINGS and the assembled stage's are object - * DEFINITIONS. No runtime behaviour changes; the values were always these. + * of two stages that differ in ONE key, `manifest`. Every other member of + * the row — `id`, `name`, `version`, `status`, `enabled`, … — is common to + * both branches and reads exactly as before, so a caller that reads only + * those members needs no change at all. No runtime behaviour changes; the + * values were always these. + * + * ⚠️ Reaching INTO `manifest` is where the cost is, and the TYPE will not + * do the narrowing for you. On the assembled branch `manifest` is declared + * `Record`, so a member read off the union — + * `pkg.manifest.objects` — is `unknown` (measured with `tsc` against the + * published declarations, not inferred from the schemas). Narrow by PARSING + * the row with a `packages/spec` schema and reading the parse's output: + * + * ```ts + * const parsed = AssembledInstalledPackageSchema.safeParse(pkg); // spec/api + * if (parsed.success) { + * // parsed.data.manifest — the ASSEMBLED stage, object definitions + * } else { + * const authoring = InstalledPackageSchema.parse(pkg); // spec/kernel + * // authoring.manifest.objects — the AUTHORING stage, glob strings + * } + * ``` + * + * ⛔ Do NOT narrow with `Array.isArray(pkg.manifest.objects)`, or with any + * other structural guess. It separates the stages on NEITHER level: at the + * type level `pkg.manifest` is the same union inside both branches of that + * `if`, and at runtime BOTH stages' `objects` are arrays — + * `z.array(z.string())` at the authoring stage against `z.array(ObjectSchema)` + * at the assembled one. (Measured: a row carrying no `objects` at all parses + * as either stage, which is the right answer for it — the key such a guess + * would read is not there.) + * + * ⚠️ The RUNTIME half is the strict one, and the asymmetry is a KNOWN GAP + * rather than a design: `InstalledPackageAtEitherStageSchema.safeParse()` + * refuses a `manifest` belonging to neither stage, while that same row + * COMPILES against this declaration. Tracked as #19324, whose root cause is + * the deliberate `z.ZodType, …>` annotation at + * `packages/spec/src/stack.zod.ts:1283` (#14513 — TS7056 and a + * declaration-chunk ceiling); ⛔ not something this declaration can fix, and + * ⛔ not a licence to relax either runtime branch to match the type. */ list: async (filters?: { status?: string; type?: string; enabled?: boolean }): Promise<{ packages: InstalledPackageAtEitherStage[]; total: number }> => { const route = this.getRoute('packages'); diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index b02593401dc..252550295d6 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -591,13 +591,15 @@ export async function returnTypePrecisionPins12034(): Promise { // `withWritableVerdict(qlService, toPackageResponse(pkg))`, so this pin and // the element type on `list` are two readings of one producer. // - // ⚠️ [#17536] Which is why these two READ members moved and the three WRITE - // members above did not. One producer expression serves `list` and `get`, and - // PR #17517 declared what it serves — `InstalledPackageAtEitherStageSchema` - // — on both read doors. The install / enable / disable members answer the row - // their own request contract produced (`PackageInstallRequestSchema` declares - // `manifest: ManifestSchema`, the AUTHORING stage), so their declaration is - // unchanged and this asymmetry is the measurement, not an oversight. + // ⚠️ [#17536] Which is why these two READ members moved and the WRITE + // members above did not — all four of them (`install`, `enable`, `disable`, + // `update`) still answer `Promise`. One producer expression + // serves `list` and `get`, and PR #17517 declared what it serves — + // `InstalledPackageAtEitherStageSchema` — on both read doors. The install + // member answers the row its own request contract produced + // (`PackageInstallRequestSchema` declares `manifest: ManifestSchema`, the + // AUTHORING stage), so its declaration is unchanged and this asymmetry is the + // measurement, not an oversight. expectTypeOf(await client.packages.get('com.acme.crm')) .toEqualTypeOf(); expectTypeOf(await scoped.packages.get('com.acme.crm')) @@ -676,17 +678,28 @@ declare const assembledRow: AssembledInstalledPackage; * * Direction 1 asserts the declared return now ADMITS an assembled-stage row. * That assertion is only evidence if an assembled row was REFUSED before, so the - * refusal is pinned in the same breath: the three WRITE members did not move, + * refusal is pinned in the same breath: the four WRITE members did not move, * and the `@ts-expect-error` on `install` below is exactly the assignment * direction 1 makes. If `AssembledInstalledPackage` were assignable to * `InstalledPackage` after all, that suppression would go UNUSED and tsc would * report TS2578 — so the pair fails loudly instead of passing vacuously. * - * Direction 2 asserts what the widening did NOT buy. The door's element is a - * union of two CLOSED stages, never a tolerant shape, and a `manifest` belonging - * to neither is refused by both branches. That suppression is what reddens if - * anyone ever "widens" these members to `any` / `unknown` / an open shape to - * make a payload fit. + * Direction 2 asserts what the widening did NOT buy, and it is narrower than it + * looks: the suppressed assignment gives `manifest` a STRING PRIMITIVE, which + * both branches of the union refuse, so the suppression is USED. That is the + * line that reddens (TS2578, unused directive) if these members are ever + * "widened" to `any` / `unknown` to make a payload fit. + * + * ⛔ It does NOT measure object-shaped tolerance, and at this head there is + * some: on the assembled branch `manifest` is declared `Record` + * (the deliberate annotation at `packages/spec/src/stack.zod.ts:1283`, #14513), + * so an object `manifest` belonging to NEITHER stage compiles against these + * members. The runtime is the half that is correct — + * `InstalledPackageAtEitherStageSchema.safeParse()` refuses that same row, and + * that refusal is pinned beside its producer in + * `packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts`. + * The type-level gap is #19324's to close; the third pin below records it as the + * behaviour it is, so the day it closes this file says so. * * ## Ablation, measured rather than asserted * @@ -727,12 +740,13 @@ export function installedPackageEitherStagePins17536(): void { // @ts-expect-error the install door answers the AUTHORING stage; an assembled row is not one const installRefusesAssembled: Awaited> = assembledRow; - // ── direction 2: two closed stages, not a tolerant shape ───────────────── - // GREEN IN BOTH STATES — the second control. A `manifest` belonging to - // NEITHER stage is refused by both branches of the union, so it is refused by - // the declaration. This is the line that reddens - // (TS2578, unused suppression) if these members are ever widened to `any`, - // `unknown`, or an open shape. + // ── direction 2: a manifest that is not an OBJECT AT ALL is refused ───── + // GREEN IN BOTH STATES — the second control, and this is the whole of what + // it measures: a STRING PRIMITIVE is refused by both branches of the union, + // so the suppression is USED. It reddens (TS2578, unused suppression) if + // these members are ever widened to `any` or `unknown`. ⛔ It says nothing + // about an object-shaped `manifest` belonging to neither stage — that one + // compiles today, and is pinned as such directly below. // // ⚠️ The suppression sits on the PROPERTY, not on the `const`. Measured: tsc // reports this mismatch at the offending member of the object literal, so a @@ -740,10 +754,34 @@ export function installedPackageEitherStagePins17536(): void { // TS2578, a red gate that says nothing about the union. const neitherStage: Awaited> = { ...authoringRow, - // @ts-expect-error neither manifest stage admits a string; the union is over two closed stages + // @ts-expect-error a manifest must at least be an object; neither branch of the union admits a string manifest: 'com.acme.crm@1.0.0', }; + // ── the KNOWN GAP, pinned as the behaviour it IS (#19324) ───────────── + // ⛔ NOT a guarantee — a measurement, written down so it cannot change in + // silence. `AssembledInstalledPackage['manifest']` is + // `Record` (from the deliberate + // `z.ZodType, …>` annotation at + // `packages/spec/src/stack.zod.ts:1283`, #14513 — TS7056 and a + // declaration-chunk ceiling), so the assembled branch admits ANY object and + // the assignment below COMPILES at this head. Measured with `tsc` against the + // published declarations; the runtime disagrees and is the correct half: + // `InstalledPackageAtEitherStageSchema.safeParse()` answers `success: false` + // for this very row. + // + // ⚠️ There is deliberately no `@ts-expect-error` here. The day #19324 types + // the assembled body, tsc reds on THIS line — and that red is the + // notification this pin exists to deliver: read it as "the gap closed", then + // delete this block and tighten the `manifest` guidance on + // `ObjectStackClient.packages.list` in `index.ts`, which sends callers + // through a `packages/spec` parse precisely because the type cannot carry + // the narrowing today. + const objectToleranceGap19324: Awaited> = { + ...authoringRow, + manifest: { bogus: 1, objects: 'not-even-an-array' }, + }; + void getAdmitsAssembled; void scopedGetAdmitsAssembled; void listAdmitsAssembled; @@ -751,6 +789,7 @@ export function installedPackageEitherStagePins17536(): void { void getStillAdmitsAuthoring; void installRefusesAssembled; void neitherStage; + void objectToleranceGap19324; } /** From 531e1225d6fc1a034af6cbd9124a1daa8f4ed6e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 12:17:25 +0000 Subject: [PATCH 5/5] test(client): re-measure the ablation with the #19324 gap pin in it (#17536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding the gap pin changes the count the docblock states, so the count is re-driven rather than left stale: index.ts restored to blob `c12b554d20` (the blob it carried before 21e6b9887c — a fixed anchor, not a moving `origin/main`), `tsc --noEmit -p tsconfig.test.json` → exit 2, TEN errors (TS2344 x5, TS2322 x5) and 0 TS2578, so both controls stay used in both states. The restore leg was proved by hash: the file came back to `7f819e89ae` with `git diff HEAD` empty. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf --- .../client/src/return-type-precision.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index 252550295d6..78185c990b5 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -703,12 +703,17 @@ declare const assembledRow: AssembledInstalledPackage; * * ## Ablation, measured rather than asserted * - * Restore `packages/client/src/index.ts` to `origin/main` (`adf4b18777`), keep - * this file, run `tsc --noEmit -p tsconfig.test.json`: exit 2, **nine** errors — - * the five `toEqualTypeOf` pins that name the union (TS2344, here and in the - * three blocks above) and the four direction-1 assignments below (TS2322). Both - * `@ts-expect-error` lines stay USED in that run, which is what makes them - * controls rather than evidence: they are labelled GREEN IN BOTH STATES below. + * Restore `packages/client/src/index.ts` to the blob it carried BEFORE this card + * — `git show 21e6b9887c^:packages/client/src/index.ts`, blob `c12b554d20`, a + * fixed anchor rather than a moving `origin/main` — keep this file, run + * `tsc --noEmit -p tsconfig.test.json`: exit 2, **ten** errors — the five + * `toEqualTypeOf` pins that name the union (TS2344, here and in the three blocks + * above), the four direction-1 assignments below (TS2322), and the #19324 gap + * pin below (TS2322 — with the authoring stage restored its `objects` value is + * refused; ⚠️ that is NOT the tolerance it records at head, so read its own + * comment for what it measures). Both `@ts-expect-error` lines stay USED in that + * run (0 TS2578), which is what makes them controls rather than evidence: they + * are labelled GREEN IN BOTH STATES below. */ export function installedPackageEitherStagePins17536(): void { // ── direction 1: the assembled stage is ADMITTED, on all four read members ─