fix(client): the four packages READ members declare the stage their door is declared at (#17536) - #19323
Conversation
…r is declared at `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
…trol 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
…ient-either-stage-widening
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6c5485d1ad793432fba20565ada522864d1ac826 && git checkout 6c5485d1ad793432fba20565ada522864d1ac826
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 13d52947d81aca235133ee9619d80723a7c63348 4fe43c0c0f9f8687d77886777ff9d786d504c47a && git checkout -B drift-repro 13d52947d81aca235133ee9619d80723a7c63348 && git merge --no-ff 4fe43c0c0f9f8687d77886777ff9d786d504c47a
node scripts/docs-audit/affected-docs.mjs --json 13d52947d81aca235133ee9619d80723a7c63348
|
|
Served-tier: CONTRACT_REVIEW_TIER Contract reviewHead ① Derived judgmentsScope — count right, only READ members moved. At head, Public-surface movement, each pinned. Test controls — they can fail, measured. Ablation reproduced: The open finding #19324 holds — driven, not relayed — and it reaches into this diff's text. Probes at head, published declarations:
Root cause is deeper than the cast #19324 names: What that does to the diff. The widening itself is implemented faithfully — the four declarations now name the type the two doors are declared at, which is what the ruling ordered. But four passages in the diff claim what the published type does not deliver, and the same PR's own acceptance notes record the erosion:
During the launch window the changeset body is, in the gate's own words, «the only signal there is», and it ships as ② Semver level and changeset
③ Boundary flags
Remedy (narrow, text-only, no
|
… 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
…17536) 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 21e6b98 — 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
…ient-either-stage-widening
Patch round pushed — the review's FAIL was text-only, and so is the fixNew head The four passages named in the review of record (
Taken up as well: the optional pin. Two text defects of the same class were corrected in passing, both in these three files: the import-site comment repeated the "never a tolerant shape" overclaim, and the WRITE-member count read "three" in three places when four members stayed ( Gates at the new head: all 58 derived families run with each exit code captured to disk before any pipe and reconciled with Full report, including the verbatim replacement text for every passage: Generated by Claude Code |
|
Served-tier: CONTRACT_REVIEW_TIER Contract reviewHead-sha: PR #19323 · card #17536 · branch Notation: angle brackets are written as square brackets throughout ( Where every reading was taken: a private worktree at ① Derived judgmentsScope — the load-bearing claim holds: text-only, plus one pin. Public surface, measured with an exact-type predicate (an
Every shipped measurement re-driven — each one matched:
The two in-passing corrections — in scope. (a) The import-site comment in Two precision notes, non-blocking — no shipped sentence is false, but a reader should know exactly what is measured:
② Semver level
③ Boundary flags
Implemented-by: claude/issue-17536-client-either-stage-widening VERDICT: PASS — the four declarations are exactly what the ruling ordered and what the doors are declared at; every measurement the shipped text now makes was re-driven at this head and matched; the level is the launch-window convention correctly applied with both carriers present; the two in-passing corrections are within the diff's own prose. Two non-blocking precision notes are recorded in ① for the next touch. Generated by Claude Code |
Clause-② provenance — at-tier review of record PASS at head
|
| record | comment 5750018962, 2026-09-20T13:12:40Z |
| head judged | 4fe43c0c0f9f8687d77886777ff9d786d504c47a |
| verdict | PASS |
| carriers | needs:contract-review removed from card #17536 and PR #19323 in that order; check-clause2-carriers.mjs --pair 19323 exits 0, row C6-RECORD naming 5750018962 on this head |
Why this record exists at all, and why the previous clear did not survive
The gate was cleared once already, at 11:58:08Z. Then the head moved 39ba6e6171 → 4fe43c0c0f. check-clause2-carriers --pair 19323 caught that as exit 4 / row C3, in its own words: "the review that cleared this gate judged a different tree, so the clear no longer covers what would land."
⭐ A review of record binds to the head it judged — not to the PR. The re-hang is the seat's act (the checker refuses to write a gate label, correctly: hanging or clearing one from a checker would be 自查放行). Carriers were re-hung at 12:48Z and a fresh isolated reviewer was commissioned against the new head.
Precondition ① was read from the record itself
The checker states plainly that it reports the record's existence, ⛔ never its verdict — "whether it reads PASS is precondition ① of the landing check and stays human." So it was read directly: first line is Served-tier: CONTRACT_REVIEW_TIER (the constant name, ⛔ not a model id), ## Contract review present, head sha as a code span, line-initial Implemented-by: and Reviewed-by:, no model identifier anywhere in 13737 bytes, and **VERDICT: PASS**.
Tier
CONTRACT_REVIEW_TIER = claude-fable-5-1, re-derived from scripts/pm/dispatch-gates.mjs:11930 at origin/main rather than recalled. This seat serves claude-opus-5 on both session_context.model and last_served_model ⇒ under tier ⇒ the review was commissioned as an isolated at-tier subagent and ⛔ was not self-reviewed. The reviewer was instructed to re-drive every reading rather than repeat the implementer's numbers; it reports doing so in its own worktree, and its ablation independently reproduced ten errors (TS2344 ×5, TS2322 ×5, zero TS2578) — the same count the patch round measured.
⚠️ One correction to the record, not affecting the verdict
The record's ③ disposition reports the PR body's Ablation paragraph and Gates sha as stale. They were, when the reviewer read them — but they were patched at ~13:00Z, before the record posted at 13:12:40Z. Verified on the live body just now: **ten** errors present, **nine** errors absent, fixed blob anchor c12b554d20 present, moving anchor adf4b18777 absent, at 4fe43c0c0f present, at 39ba6e6171 absent.
⇒ ⛔ nothing is owed on that item; a later reader should not chase it. The reviewer itself scoped it correctly as the seat's, not a landing blocker.
Two non-blocking findings the record raised — carried, ⛔ not silently dropped
Both are recorded on card #17536 rather than actioned here. The reviewer judged both non-blocking with reasoning, and ⛔ this seat serves under tier and does not overturn an at-tier judgement to add a round.
- the pin-file docblock says a refusal is "pinned beside its producer" — the cited test pins the class (a neither-stage row with a mixed
objectsarray), ⛔ not the specific literal whose runtime refusal is measured but pinned nowhere; - the worked example's assembled branch reads as a type statement where it is only a runtime one — post-
safeParse,parsed.data.manifestis stillRecord<string, unknown>and.objectsstillunknown, because the [finding] AssembledInstalledPackage.manifest erodes to an index-signature type in the published .d.ts, so the assembled arm absorbs the authoring arm #19324 gap survives the parse.
⭐ Both are gated on the same future moment, and the gap pin's own comment already names it: when #19324 closes, tsc reds on that pin, and whoever answers that red tightens this guidance. ⇒ that is the right carrier for both.
Generated by Claude Code
Fixes #17536
Clause-②: yes
The four
packagesREAD members of@objectstack/client—ObjectStackClient.packages.list/.getand theirScopedEnvironmentClienttwins — returnedInstalledPackage, the AUTHORING manifest stage, while both read doors have been declared at EITHER stage since PR #17517. A response the server is declared able to send was one this SDK's own types said could not arrive.packages/specis the one contract between producers and consumers (Prime Directive #12) andpackages/clientis a consumer of it, so the consumer's declaration is what moves. All four now declareInstalledPackageAtEitherStagefrom@objectstack/spec/api.Direction ruled by triage at
5735293092, taking ① widen the client. ⛔ Narrowing the read door was excluded by rule, not by preference: AGENTS.md rule 13 makes reversing PR #17517's landed decision a new ADR, which isdomain:spec's work and not this card's. ⛔ Nothing underpackages/specis touched here.The semver level, and the reading behind it
major, stop and report rather than land. It was measured, both legs, on this branch.majoron@objectstack/clientnode scripts/check-changeset-no-major.mjs --base origin/mainfixed(lockstep) group, so a singlemajorpromotes the ENTIRE monorepo… During the launch window ship breaking changes asminorinstead."minoron@objectstack/client(what this PR carries)⇒ This does not need a
major; it needs aminorplus the two carriers that stand in for the level during the launch window.check-changeset-no-major.mjs's own header states the convention and its end condition: "During the launch window it is NOT the carrier… The mandatory information carriers for breaking-ness in the meantime are the BREAKING banner the author writes in the changeset body and the ADR-0087 migration-ledger disposition… THIS GUARD IS WHAT GETS DISARMED AT GA." The window is open on this tree: there is no.changeset/pre.json, so the RC exemption is not in force and the guard is armed — which is what the exit-1 leg above demonstrates rather than assumes.⛔ The level was not lowered to quiet a gate, and the breaking-ness was not dropped to reach a lower level. Both carriers are present and both were driven:
node scripts/check-adr-0087-registration.mjs --base origin/mainexits 0 and reports[BREAKING] not-required (no-migration-prescription).type-surface-onlyis deliberately NOT claimed, and the changeset writes out why it is unavailable on the merits: that category is for a published TYPE-surface NARROWING moving off an erased type, and at the merge base all four members carried a concrete annotation.Under strict semver this is breaking, and the changeset says so in words. What the launch-window convention decides is only which field carries that fact.
What a consumer pays, measured
The element is a union of two whole, CLOSED stages 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 before, so code that touches only those needs no change. Code that reaches INTOmanifestseparates the stages first: the authoring stage'sobjectsare glob STRINGS, the assembled stage's are object DEFINITIONS, and the compiler now says so at the call site instead of letting a glob-shaped read compile against a row carrying definitions.In this repository the consumer cost is zero sites outside
packages/clientitself. No other workspace package calls either read member; the only in-tree mentions arepackages/client/README.md(a bareawait client.packages.list();with no member read, type-checked green bycheck:skill-examplesagainst the rebuilt declarations) and the pins in this PR.The three WRITE members did not move.
install/enable/disableanswer the row their own request contract produced —PackageInstallRequestSchemadeclaresmanifest: ManifestSchema— and PR #17517 moved the read doors alone. That asymmetry is a measurement, not an oversight, and it is pinned.Tests — type-level, with a control that can fail
packages/client/src/return-type-precision.test.tsis the only place a return-type move CAN be pinned: nothing about the runtime values changed, so a runtime test is green either way.packages.installis a used@ts-expect-error. IfAssembledInstalledPackagewere assignable toInstalledPackageafter all, that suppression would go unused (TS2578) and direction 1 would be exposed as passing vacuously;manifestbelonging to NEITHER stage is still refused, so this line reddens if anyone ever "widens" these members toany,unknownor an open shape.Ablation (restore
packages/client/src/index.tsto blobc12b554d20— the blob it carried before21e6b9887c, a fixed anchor rather than the movingorigin/main, keep the test file, runtsc --noEmit -p tsconfig.test.json): exit 2, ten errors — the fivetoEqualTypeOfpins naming the union (TS2344) and five TS2322: the four direction-1 assignments plus the new #19324 gap pin. Zero TS2578 — both controls stay USED in both states. The restore was verified by blob identity againstHEADwithgit diff HEADempty; both@ts-expect-errorcontrols stay USED in the ablated run, which is why they are labelled GREEN IN BOTH STATES rather than offered as evidence.Gates
Derived from the actual diff with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon the merged head, every exit code captured to disk before any pipe, and reconciled with--ran:Two of the 58 first answered
PREREQUISITE NOT MET(check:skill-examples,check:dual-build-cjs-loads), which is NOT MEASURED and not a pass: both read built output and the packages had nodist. They were built and re-run, and both then exited 0 on the merits —check:skill-examplestype-checks 258 prose examples across three surfaces, including the client SDK's 23 blocks, against the rebuilt declarations.origin/mainwas merged once (e3b3cdd2df) before this PR opened; build state was refreshed and the whole derived set plus the lint union were re-run on the merged head.Acceptance notes
packages/spec, reported rather than filed or fixed — this lane does not touch that package and does not file cards. Measured on this branch, and it bears on how much the union actually buys a consumer:AssembledInstalledPackage'smanifestresolves to an index-signature type (Recordofstringtounknown) in the PUBLISHED TypeScript type, not to the assembled body's declared shape. The cause is the cast inpackages/spec/src/api/package-api.zod.ts:AssembledPackageRecordBodySchemais built on(AssembledPackageBodySchema as unknown as z.ZodObject(z.ZodRawShape)), whosez.inputis an index signature. Driven withtsc:InstalledPackageIS assignable toAssembledInstalledPackage(an authoring manifest satisfies an index signature), so the assembled arm absorbs the authoring arm at the type level, and a member read off the assembled stage'smanifestarrives asunknownrather than its declared type. The runtime Zod parse is unaffected — the schema still checks the assembled body member by member, exactly as its own docblock says. This is the same failure familycheck:exported-anyexists for ("the snapshot records that an export exists, never what it resolves to"), reached by a different spelling. Dedupe words:AssembledPackageRecordBodySchema,ZodRawShape,package-api.zod,assembled manifest type erosion,z.input index signature.noted, not filed:
@objectstack/clientre-exports neitherInstalledPackagenorInstalledPackageAtEitherStage, so a consumer writing an explicit annotation reaches into@objectstack/spec/apifor it. That is unchanged by this PR — the SDK has never re-exported the package row — and adding a re-export would be a new published export on a package this card is only correcting. Carrier: none; no queued PR or lane touches this surface.noted, not filed: the stale comment triage measured on
packages.get(it claimedGetInstalledPackageResponseSchemaisdata: InstalledPackageSchema) is corrected in this PR rather than filed, per that ruling's explicit instruction. It is corrected in place with the reading that falsified it, not deleted.Generated by Claude Code
Generated by Claude Code