Skip to content

Commit f0a7a35

Browse files
committed
docs(client): say what the either-stage union buys TODAY, and pin the type-level gap (#17536)
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<string, unknown> (union).manifest.objects unknown { ...authoringRow, manifest: { bogus: 1, objects: 'not-even-an-array' } } against the union AND against Awaited<ReturnType<typeof client.packages.get>> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
1 parent 39ba6e6 commit f0a7a35

3 files changed

Lines changed: 131 additions & 36 deletions

File tree

‎.changeset/17536-client-packages-read-doors-either-stage.md‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,31 @@ Clause-②: yes
1212

1313
`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`.
1414

15-
**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.
15+
**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.
1616

17-
**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.
17+
**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<string, unknown>`, 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<Record<string, unknown>, …>` 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.
1818

19-
**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.
19+
**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:
20+
21+
```ts
22+
import { AssembledInstalledPackageSchema } from '@objectstack/spec/api';
23+
import { InstalledPackageSchema } from '@objectstack/spec/kernel';
24+
25+
const row = await client.packages.get(id);
26+
const parsed = AssembledInstalledPackageSchema.safeParse(row);
27+
if (parsed.success) {
28+
// parsed.data.manifest — the ASSEMBLED stage, object definitions
29+
} else {
30+
const authoring = InstalledPackageSchema.parse(row);
31+
// authoring.manifest.objects — the AUTHORING stage, glob strings
32+
}
33+
```
34+
35+
⛔ 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.)
36+
37+
In this repository the whole consumer cost is zero sites outside `packages/client` itself: no other workspace package calls either read member.
38+
39+
**The WRITE members did not move** and stay declared at the authoring stage — all four of them: `install`, `enable`, `disable`, `update` still answer `Promise<InstalledPackage>`. `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.
2040

2141
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.
2242

‎packages/client/src/index.ts‎

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -127,12 +127,15 @@ import {
127127
// `ListInstalledPackagesResponseSchema.packages` is
128128
// `z.array(InstalledPackageAtEitherStageSchema)` and
129129
// `GetInstalledPackageResponseSchema.data` is that same schema
130-
// (`spec/src/api/package-api.zod.ts`) — a union of the two CLOSED manifest
131-
// stages, authoring (`InstalledPackageSchema`) and assembled
132-
// (`AssembledInstalledPackageSchema`), never a tolerant shape. The client is a
133-
// CONSUMER of that contract, so the widest value those doors are declared to
134-
// answer is what they are declared to return here. The WRITE methods on the
135-
// same object keep `InstalledPackage`: PR #17517 moved the read doors alone.
130+
// (`spec/src/api/package-api.zod.ts`) — a union over the two manifest stages,
131+
// authoring (`InstalledPackageSchema`) and assembled
132+
// (`AssembledInstalledPackageSchema`), each a closed RUNTIME declaration. The
133+
// client is a CONSUMER of that contract, so the widest value those doors are
134+
// declared to answer is what they are declared to return here. ⚠️ What the
135+
// published TYPE admits is wider than what the runtime parse accepts — the
136+
// measurement, and what a caller does about it, are on `packages.list` below
137+
// (#19324). The WRITE methods on the same object keep `InstalledPackage`:
138+
// PR #17517 moved the read doors alone.
136139
InstalledPackageAtEitherStage,
137140
} from '@objectstack/spec/api';
138141
import type {
@@ -2477,13 +2480,46 @@ export class ObjectStackClient {
24772480
* (Prime Directive #12), so the consumer's declaration is what moves.
24782481
*
24792482
* ⚠️ Clause-② widening, and what it costs a caller: the element is a union
2480-
* of two whole stages that differ in ONE key, `manifest`. Every other member
2481-
* of the row — `id`, `name`, `version`, `status`, `enabled`, … — is common
2482-
* to both branches and reads exactly as before. A caller that reaches INTO
2483-
* `manifest` separates the stages first (`Array.isArray(pkg.manifest.objects)
2484-
* ? … : …`, or a `packages/spec` parse), because the authoring stage's
2485-
* `objects` are GLOB STRINGS and the assembled stage's are object
2486-
* DEFINITIONS. No runtime behaviour changes; the values were always these.
2483+
* of two stages that differ in ONE key, `manifest`. Every other member of
2484+
* the row — `id`, `name`, `version`, `status`, `enabled`, … — is common to
2485+
* both branches and reads exactly as before, so a caller that reads only
2486+
* those members needs no change at all. No runtime behaviour changes; the
2487+
* values were always these.
2488+
*
2489+
* ⚠️ Reaching INTO `manifest` is where the cost is, and the TYPE will not
2490+
* do the narrowing for you. On the assembled branch `manifest` is declared
2491+
* `Record<string, unknown>`, so a member read off the union —
2492+
* `pkg.manifest.objects` — is `unknown` (measured with `tsc` against the
2493+
* published declarations, not inferred from the schemas). Narrow by PARSING
2494+
* the row with a `packages/spec` schema and reading the parse's output:
2495+
*
2496+
* ```ts
2497+
* const parsed = AssembledInstalledPackageSchema.safeParse(pkg); // spec/api
2498+
* if (parsed.success) {
2499+
* // parsed.data.manifest — the ASSEMBLED stage, object definitions
2500+
* } else {
2501+
* const authoring = InstalledPackageSchema.parse(pkg); // spec/kernel
2502+
* // authoring.manifest.objects — the AUTHORING stage, glob strings
2503+
* }
2504+
* ```
2505+
*
2506+
* ⛔ Do NOT narrow with `Array.isArray(pkg.manifest.objects)`, or with any
2507+
* other structural guess. It separates the stages on NEITHER level: at the
2508+
* type level `pkg.manifest` is the same union inside both branches of that
2509+
* `if`, and at runtime BOTH stages' `objects` are arrays —
2510+
* `z.array(z.string())` at the authoring stage against `z.array(ObjectSchema)`
2511+
* at the assembled one. (Measured: a row carrying no `objects` at all parses
2512+
* as either stage, which is the right answer for it — the key such a guess
2513+
* would read is not there.)
2514+
*
2515+
* ⚠️ The RUNTIME half is the strict one, and the asymmetry is a KNOWN GAP
2516+
* rather than a design: `InstalledPackageAtEitherStageSchema.safeParse()`
2517+
* refuses a `manifest` belonging to neither stage, while that same row
2518+
* COMPILES against this declaration. Tracked as #19324, whose root cause is
2519+
* the deliberate `z.ZodType<Record<string, unknown>, …>` annotation at
2520+
* `packages/spec/src/stack.zod.ts:1283` (#14513 — TS7056 and a
2521+
* declaration-chunk ceiling); ⛔ not something this declaration can fix, and
2522+
* ⛔ not a licence to relax either runtime branch to match the type.
24872523
*/
24882524
list: async (filters?: { status?: string; type?: string; enabled?: boolean }): Promise<{ packages: InstalledPackageAtEitherStage[]; total: number }> => {
24892525
const route = this.getRoute('packages');

‎packages/client/src/return-type-precision.test.ts‎

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -591,13 +591,15 @@ export async function returnTypePrecisionPins12034(): Promise<void> {
591591
// `withWritableVerdict(qlService, toPackageResponse(pkg))`, so this pin and
592592
// the element type on `list` are two readings of one producer.
593593
//
594-
// ⚠️ [#17536] Which is why these two READ members moved and the three WRITE
595-
// members above did not. One producer expression serves `list` and `get`, and
596-
// PR #17517 declared what it serves — `InstalledPackageAtEitherStageSchema`
597-
// — on both read doors. The install / enable / disable members answer the row
598-
// their own request contract produced (`PackageInstallRequestSchema` declares
599-
// `manifest: ManifestSchema`, the AUTHORING stage), so their declaration is
600-
// unchanged and this asymmetry is the measurement, not an oversight.
594+
// ⚠️ [#17536] Which is why these two READ members moved and the WRITE
595+
// members above did not — all four of them (`install`, `enable`, `disable`,
596+
// `update`) still answer `Promise<InstalledPackage>`. One producer expression
597+
// serves `list` and `get`, and PR #17517 declared what it serves —
598+
// `InstalledPackageAtEitherStageSchema` — on both read doors. The install
599+
// member answers the row its own request contract produced
600+
// (`PackageInstallRequestSchema` declares `manifest: ManifestSchema`, the
601+
// AUTHORING stage), so its declaration is unchanged and this asymmetry is the
602+
// measurement, not an oversight.
601603
expectTypeOf(await client.packages.get('com.acme.crm'))
602604
.toEqualTypeOf<InstalledPackageAtEitherStage>();
603605
expectTypeOf(await scoped.packages.get('com.acme.crm'))
@@ -676,17 +678,28 @@ declare const assembledRow: AssembledInstalledPackage;
676678
*
677679
* Direction 1 asserts the declared return now ADMITS an assembled-stage row.
678680
* That assertion is only evidence if an assembled row was REFUSED before, so the
679-
* refusal is pinned in the same breath: the three WRITE members did not move,
681+
* refusal is pinned in the same breath: the four WRITE members did not move,
680682
* and the `@ts-expect-error` on `install` below is exactly the assignment
681683
* direction 1 makes. If `AssembledInstalledPackage` were assignable to
682684
* `InstalledPackage` after all, that suppression would go UNUSED and tsc would
683685
* report TS2578 — so the pair fails loudly instead of passing vacuously.
684686
*
685-
* Direction 2 asserts what the widening did NOT buy. The door's element is a
686-
* union of two CLOSED stages, never a tolerant shape, and a `manifest` belonging
687-
* to neither is refused by both branches. That suppression is what reddens if
688-
* anyone ever "widens" these members to `any` / `unknown` / an open shape to
689-
* make a payload fit.
687+
* Direction 2 asserts what the widening did NOT buy, and it is narrower than it
688+
* looks: the suppressed assignment gives `manifest` a STRING PRIMITIVE, which
689+
* both branches of the union refuse, so the suppression is USED. That is the
690+
* line that reddens (TS2578, unused directive) if these members are ever
691+
* "widened" to `any` / `unknown` to make a payload fit.
692+
*
693+
* ⛔ It does NOT measure object-shaped tolerance, and at this head there is
694+
* some: on the assembled branch `manifest` is declared `Record<string, unknown>`
695+
* (the deliberate annotation at `packages/spec/src/stack.zod.ts:1283`, #14513),
696+
* so an object `manifest` belonging to NEITHER stage compiles against these
697+
* members. The runtime is the half that is correct —
698+
* `InstalledPackageAtEitherStageSchema.safeParse()` refuses that same row, and
699+
* that refusal is pinned beside its producer in
700+
* `packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts`.
701+
* The type-level gap is #19324's to close; the third pin below records it as the
702+
* behaviour it is, so the day it closes this file says so.
690703
*
691704
* ## Ablation, measured rather than asserted
692705
*
@@ -727,30 +740,56 @@ export function installedPackageEitherStagePins17536(): void {
727740
// @ts-expect-error the install door answers the AUTHORING stage; an assembled row is not one
728741
const installRefusesAssembled: Awaited<ReturnType<typeof client.packages.install>> = assembledRow;
729742

730-
// ── direction 2: two closed stages, not a tolerant shape ─────────────────
731-
// GREEN IN BOTH STATES — the second control. A `manifest` belonging to
732-
// NEITHER stage is refused by both branches of the union, so it is refused by
733-
// the declaration. This is the line that reddens
734-
// (TS2578, unused suppression) if these members are ever widened to `any`,
735-
// `unknown`, or an open shape.
743+
// ── direction 2: a manifest that is not an OBJECT AT ALL is refused ─────
744+
// GREEN IN BOTH STATES — the second control, and this is the whole of what
745+
// it measures: a STRING PRIMITIVE is refused by both branches of the union,
746+
// so the suppression is USED. It reddens (TS2578, unused suppression) if
747+
// these members are ever widened to `any` or `unknown`. ⛔ It says nothing
748+
// about an object-shaped `manifest` belonging to neither stage — that one
749+
// compiles today, and is pinned as such directly below.
736750
//
737751
// ⚠️ The suppression sits on the PROPERTY, not on the `const`. Measured: tsc
738752
// reports this mismatch at the offending member of the object literal, so a
739753
// directive on the declaration line covers the wrong line and goes unused —
740754
// TS2578, a red gate that says nothing about the union.
741755
const neitherStage: Awaited<ReturnType<typeof client.packages.get>> = {
742756
...authoringRow,
743-
// @ts-expect-error neither manifest stage admits a string; the union is over two closed stages
757+
// @ts-expect-error a manifest must at least be an object; neither branch of the union admits a string
744758
manifest: 'com.acme.crm@1.0.0',
745759
};
746760

761+
// ── the KNOWN GAP, pinned as the behaviour it IS (#19324) ─────────────
762+
// ⛔ NOT a guarantee — a measurement, written down so it cannot change in
763+
// silence. `AssembledInstalledPackage['manifest']` is
764+
// `Record<string, unknown>` (from the deliberate
765+
// `z.ZodType<Record<string, unknown>, …>` annotation at
766+
// `packages/spec/src/stack.zod.ts:1283`, #14513 — TS7056 and a
767+
// declaration-chunk ceiling), so the assembled branch admits ANY object and
768+
// the assignment below COMPILES at this head. Measured with `tsc` against the
769+
// published declarations; the runtime disagrees and is the correct half:
770+
// `InstalledPackageAtEitherStageSchema.safeParse()` answers `success: false`
771+
// for this very row.
772+
//
773+
// ⚠️ There is deliberately no `@ts-expect-error` here. The day #19324 types
774+
// the assembled body, tsc reds on THIS line — and that red is the
775+
// notification this pin exists to deliver: read it as "the gap closed", then
776+
// delete this block and tighten the `manifest` guidance on
777+
// `ObjectStackClient.packages.list` in `index.ts`, which sends callers
778+
// through a `packages/spec` parse precisely because the type cannot carry
779+
// the narrowing today.
780+
const objectToleranceGap19324: Awaited<ReturnType<typeof client.packages.get>> = {
781+
...authoringRow,
782+
manifest: { bogus: 1, objects: 'not-even-an-array' },
783+
};
784+
747785
void getAdmitsAssembled;
748786
void scopedGetAdmitsAssembled;
749787
void listAdmitsAssembled;
750788
void scopedListAdmitsAssembled;
751789
void getStillAdmitsAuthoring;
752790
void installRefusesAssembled;
753791
void neitherStage;
792+
void objectToleranceGap19324;
754793
}
755794

756795
/**

0 commit comments

Comments
 (0)