fix(spec): declare the assembled manifest stage on the package read API - #17517
Conversation
…age row Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
`GET /packages` and `GET /packages/:id` serve the row a package was installed with, and two stages reach that table through declared doors: `POST /packages` installs an authoring manifest (`objects` = glob patterns) and a `defineStack()` host installs the assembled body (`objects` = object definitions). Both responses typed every row at the authoring stage alone, so the shipped `defineStack()` path served a payload its own contract refused. Follow the #14242 ruling one layer up — declare the assembled stage rather than widen the authoring one. `@objectstack/spec/api` gains `AssembledInstalledPackageSchema` and `InstalledPackageAtEitherStageSchema`, a union over the two whole CLOSED declarations; `ManifestSchema` is untouched and a row belonging to neither stage is still refused. Both read rows in the runtime route ledger carry a `responseSchema` again, covered in both directions by the conformance suite that pinned the boundary. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
… docs Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 140 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 a36baa7b5f0cfad19926c9e93bdc91b271478647 && git checkout a36baa7b5f0cfad19926c9e93bdc91b271478647
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin edaf3b2f7832805d4d7c544fb88d696ff8056924 5253cc3729c94ae64060d31a4996b9934e4d74a0 && git checkout -B drift-repro edaf3b2f7832805d4d7c544fb88d696ff8056924 && git merge --no-ff 5253cc3729c94ae64060d31a4996b9934e4d74a0
node scripts/docs-audit/affected-docs.mjs --json edaf3b2f7832805d4d7c544fb88d696ff8056924
|
Seat answers both questions. Readings mine,
|
The package read API's assembled-stage declaration made `src/api/index.ts` reach the driver-config validators (`package-api.zod.ts` -> `stack.zod` -> `data/datasource.zod` -> `driver/config-registry.zod` -> `postgres.zod` -> the pg-grammar seam), so `./api` joined the set of entries whose ordinary bundles link `pg-connection-string`. A browser bundler resolves that file directly and fails on `Can't resolve 'fs'`. Applies the standing 2026-08-22 ruling on #11072 (Option A: declare the boundary in the exports map) to the newly affected entry — the same seam, the same `swapServerOnlyGrammarArm` swap and the same `dist/browser/` output the four existing entries use. No second mechanism, and the four existing entries are untouched. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
`check:doc-authoring` refuses internal issue ids in runtime string prose: a `note:` reaches authors, operators and generated surfaces, none of whom can resolve `#NNNN`. The earlier commit on this branch moved five such ids out of `//` comments and into the notes, taking the gate from green to red (5 file/id pairs above the shrink-only baseline). The ids go back to the adjacent `//` comment, which already carries them; every note keeps its substance and names the same mechanisms in prose. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
The exports map is published surface: browser consumers of `@objectstack/spec/api` now resolve a different build, and the postgres `url` refinement degrades there to its shape-only checks. Say so where the release notes will read it. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
Veto-window status: the stop condition did not fire. The ruling's grounds cover this entry.I asked the round to stop and report if browser consumers resolved anything other than the degradation #11072's ruling describes. It measured it concretely rather than restating the ruling, and the answer is inside the grounds:
⇒ Only the "is this a URL pg can open" arm stops answering. The fs-reading query-param refusal and the unix-socket short-circuit are kept, and Node side: Seat's own verification of the mechanics
Same seam, same swap, no second mechanism, and the four pre-existing entries are untouched. ⭐ The round also falsified my own reading rather than deferring to it — I said four entries carry the condition and its earlier report said five; it re-measured and confirmed four, with ⚖️ The veto window remains openThis rides #11072's ruling read as a standing rule ("the affected entries"), not an enumeration of the entries affected in August. If the maintainer reads it the other way, say so and this reverts in one commit — the diff is additive and the Node side is unchanged, so reverting costs nothing but the commit. ⛔ Still not landable: an at-tier contract review on this final head is commissioned and in flight. Generated by Claude Code |
Seat verdict — clause-② contract review at tier: PASS-WITH-ADVISORIES, zero must-fix.Head ⚖️ RULING COVERAGE: YES — and settled by measurement, not by my readingI put this under a veto window because I had read #11072's "the affected entries" as a standing rule rather than an August enumeration. The review did not take my word for it. It measured whether ⇒
⛔ One thing to fix before this enqueues — the changeset overstates what the widening buysThe changeset tells consumers they "see a union at This is inherited, not introduced — the PR does not touch ⛔ Not a must-fix on the code. It is a must-fix on the claim, which is cheaper to correct now than to correct in public later. Advisories carried forward (⛔ none blocking)
Also confirmed independently
⭐ The review also caught a trap in its own probe design: "does Next: the one-sentence changeset correction, then the four pre-checks re-taken on the new head, then enqueue. Generated by Claude Code |
…tically The changeset told consumers they "see a union at `manifest` and narrow at the point of use". Measured against the built `.d.ts`, narrowing away from the authoring branch buys no typed manifest: `AssembledPackageBodySchema` is annotated `z.ZodType<Record<string, unknown>, …>` in `stack.zod.ts`, so the assembled branch carries no field typing, and both a garbage manifest and the mixed-stage row the runtime union refuses typecheck clean. Replace the sentence with the measured account: the runtime parse now accepts both stages (the gain), a plain `.manifest.version` read yields `unknown`, narrowing toward the authoring branch restores `ManifestSchema` in full, and narrowing away from it yields `Record<string, unknown>`. The opacity is inherited from `stack.zod.ts` and untouched here. No code, schema, exports map or ledger row changes. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
|
Advisories 1 and 2 from the review are now filed, ⛔ not folded into this PR.
Both filed with no The remaining advisories (3, 4, 5) are recorded in the verdict above and need no card: the degradation-set wording is a precision note, the ⏳ Still not enqueued — a dev round is applying the one changeset-claim correction, and the four pre-checks are re-taken on the new head once it lands. Generated by Claude Code |
Changeset correction landed. Pre-checks ①②③ re-taken on the new head; ④ is running.New head
The correction is honest about the direction it cutsThe old sentence said consumers "narrow at the point of use". The new text keeps the real gain and names the limit, including the part that is least flattering:
⭐ That last clause is the one worth having in a changeset. A reader who only saw "it's a union now" would reasonably assume the compiler had their back on stage-correctness. It does not, and now the release note says so. Enqueue when ④ reports green, after clearing Generated by Claude Code |
Enqueued 20:39Z. Gate cleared on both carriers; all four pre-checks green on head
|
Fixes #17431
Clause-②: yes — this changes a PUBLISHED API type, and
needs:contract-reviewis hung on the card and on this PR. Widens. Details in the section of that name below.The defect, re-measured on
origin/mainbefore anything was writtenDriven against a real
SchemaRegistryand the real dispatcher door, both premises reproduce exactly:safeParse(body)GET /packagesobjects: ['./src/objects/*.object.yml'])GET /packagesdefineStack()(objects: [{ name, fields }])['data.packages.0.manifest.objects.0'],invalid_type,expected string, received objectGET /packages/:idGET /packages/:iddefineStack()['data.manifest.objects.0'], same codeExactly one issue at exactly that path, on both doors — a stage mismatch, not a general shape problem.
What this branch does
Follows the #14242 ruling one layer up — declare the assembled stage, never widen the authoring one.
ManifestSchemais untouched: stillstrictObject, still globs.@objectstack/spec/apigains two exports:AssembledInstalledPackageSchema— the assembled-stage counterpart ofInstalledPackageSchema, built from ArtifactPackageEntrySchema's body half cannot describe the payload the load path actually registers #14242's ownAssembledPackageBodySchemarather than a second transcription of it;InstalledPackageAtEitherStageSchema— a union over the two whole, closed stage declarations, bound to both read responses.Why a union, and why that is not #14242's rejected road C
#14242 bound the artifact's
packages[]to the assembled stage alone, and its stated reason is a property of that surface: "a glob in a compiled artifact names files nobody will read." The installed-packages table is not a compiled artifact — both stages reach it through declared doors:PackageInstallRequestSchemadeclaresmanifest: ManifestSchema(authoring), andPOST /packageshands that body straight toSchemaRegistry.installPackage, which stores a JSON projection of it;defineStack()host reaches the same table viaObjectQL.registerApp(assembled).So a read contract naming only the assembled stage would refuse a row this API's own install contract is declared to produce, and the glob pin in the conformance suite would go red. Naming only the authoring stage is the defect being closed.
Road C was a union inside a key (
objects: (string | ObjectDef)[]), which describes no stage and admits a mixed array. This union is over two complete closed declarations: every parse is a full parse of one coherent stage, and a row belonging to neither — a mixedobjectsarray among them — is refused by both branches. That refusal is pinned through the real door, so "it accepts both" cannot quietly become "it accepts anything".Call 1 — the assembled body has no JSON Schema · answered: A, unchanged
Binding
AssembledPackageBodySchemaverbatim made both response schemas vanish fromjson-schema/api/:Measured cause: of the assembled body's 55 shape members, exactly two have no JSON form —
functions(az.function()branch) andhooks(az.custom()branch). That is also whyArtifactPackageSchemaandObjectStackDefinitionSchemapublish no JSON Schema at all.This branch takes the remedy
build-schemas.tsitself calls preferred — "make it emit — narrow the unrepresentable member" — declaring those two keys asunknownon the read-API record body only. The grounding is the producer:SchemaRegistry.installPackagestorestoRecordManifest(manifest), a structural JSON projection that drops functions and exotic values, so the row is JSON by construction. The override set is measured, not hand-picked — established in both directions (omitfunctions, still fails on custom; omithooks, still fails on function; omit both, OK) and pinned key-by-key inpackage-api.test.ts, so a new non-serialisable collection reddens there naming itself instead of silently unpublishing two response schemas again.The seat kept A and refused B: retiring the two published JSON-Schema defs would trade two published schemas, their reference pages and an owed ADR-0087 D3 disposition for strictness about two keys a JSON record cannot carry in their declared shape anyway. The real repair is filed as #17518 and is out of scope here; when it lands, this override collapses to nothing and the key-by-key pin is what will say so.
What it costs, plainly: on this surface those two keys are accepted without being checked. Today both are refused outright while the door really can serve them, so the declaration moves from wrong to incomplete — never from checked to tolerant. Every other key,
objectsincluded, is checked at the assembled stage.Call 2 —
./apiis browser-reachable · answered: A, appliedDeclaring the assembled stage makes this entry's module graph reach the driver-config validators. The edge is one import, and the chain is short:
pg-connection-string'sparsestatically resolvesrequire('fs'), so before this commit./api— which declared nobrowsercondition — was exactly the file a browser bundler loads, and it linked the parser in both format halves.Per the seat's ruling this applies #11072 Option A (maintainer, 2026-08-22) to the newly affected entry: the ruling names "the affected entries" as a rule rather than an enumeration, and
./apiis now one. Same seam, sameswapServerOnlyGrammarArmswap, samedist/browser/output — no second mechanism, and the four entries that already carry the condition are untouched.The gate, in both directions
./apiwith nobrowsercondition./dist/api/index.mjsand./dist/api/index.jseach link the Node-only specifierpg-connection-stringbrowsercondition deleted from the exports mapThe dark control mutated the file on disk (the two
dist/browser/api/index.*target lines went 2 to 0, asserted rather than assumed), and restoration was verified by blob hash against theHEADblob plus an emptygit diff HEAD— not by agit checkoutexit code.The browser pass really builds rather than silently no-op'ing:
dist/browser/api/index.js1.80 MB,dist/browser/api/index.mjs1.77 MB.What browser consumers now resolve — measured, not restated
Both bundles were loaded and the same
AssembledInstalledPackageSchemaparsed the same payloads through each:config.urlpostgresql://h1:5432,h2:5433/db(multi-host, pg grammar arm)host=x dbname=y(libpq keyword/value, relative-to-base arm)postgresql://db.example.com/app?sslcert=/etc/x.pem/var/run/postgresql(unix-socket short-circuit)postgresql://db.example.com:5432/appThat is exactly the degradation the ruling describes and accepted: the postgres URL refinement falls back to the shape-only checks it already performs before
parse. The filesystem-reading query-parameter refusal and the unix-socket short-circuit are kept; only the "is this a URLpgcan open" arm answers "no findings". Datasource publish is a server-side act, so that arm never legitimately ran in a browser.Nothing else moved. The neighbouring refinements that are not swapped —
credentialFreeUrl,placeholderFree, and the object-level strict close — return byte-identical issue sets from both bundles.The Node side is untouched:
mainConfig's entry list is unchanged, the./apiimport/requirebranches are unchanged, and the node bundles are the same size before and after (dist/api/index.mjs1.77 MB,dist/api/index.js1.81 MB). Resolution was checked in both directions and both format halves:Correction for the record: an earlier revision of this body said five entries carry a
browsercondition, citing #11072's dev report which listed./cloud. Measured onorigin/mainand on this branch, it is four —.,./data,./system,./kernel— and./cloudis not an export at all../apiis the fifth, added here.Widens, narrows, or replaces — widens
InstalledPackageSchemaunchanged); payloads previously refused for their manifest stage now parse. Nothing that was accepted became refused.check:api-surfaceexit 0, and the surface diff is six added names with nothing removed or narrowed.ListInstalledPackagesResponse['data']['packages'][number]andGetInstalledPackageResponse['data']become a union, so code reading.manifest.versionoff one of them must narrow first. Measured blast radius in-tree: zero — nothing in this repo reads those two types for field access, andpackages/client'spackages.list/packages.getare bound to the kernelInstalledPackage, which this PR does not touch.unknowninstack.zod.ts(inherited, untouched here), so the union does not discriminate on that side. Measured against the built.d.ts, with a five-case dark control that all errored: a plain.manifest.versionread yieldsunknownwhere it used to yieldstring; narrowing toward the AUTHORING branch restoresManifestSchemain full (versiontypedstring,objectstyped as an array ofstring); narrowing away from it yields the opaque record — every manifest fieldunknown. In the assignment direction the assembled branch admits any object atmanifest, so a garbage manifest and the mixed-stage row both typecheck clean (tscexit 0) while the runtime union REFUSES both (measured: REFUSED / REFUSED, with the two real stages ACCEPTED as the positive control). The runtime parse is the win; the static side is one-sided.browsercondition is a new branch; Node resolution is unaffected and no existing target moved. For browser consumers it is the accept-set widening tabulated above, which is the consequence [seam→spec]@objectstack/spec@17.1.0statically importspg-connection-stringfrom six of its sixteen entry points, with nobrowserexport condition — objectui's site build is red onmain#11072's grounds weighed and accepted.check:adr-0087-registration --base origin/mainexits 0. It would be owed under Call 1's refused alternative, which is one reason that alternative was the maintainer's.Ledger rows
Both
/packagesread rows carry aresponseSchemaagain, legitimate only becausepackages-read-delete-response-conformance.test.tsdrives those handlers and parses what they answer on both authoring paths. Each note records what its declaration does not carry: every row also serveswritable, this door's own computed verdict, which a declared parse strips — asserted by name rather than fixed, the disposition itsDELETEsibling already carries.A later commit on this branch strips the internal tracker ids back out of those
note:strings.check:doc-authoringrefuses them there — a runtimenotereaches authors, operators and generated surfaces, none of whom can resolve a bare id — and the earlier revision had moved five id references out of//comments and into the strings, taking that gate from green to red. The ids are back in the adjacent comment, which is their prescribed home; every note keeps its substance.Face growth to report: the claim named the
GET /packagesrow. This also fillsGET /packages/:id, because the same declaration change lands onGetInstalledPackageResponseSchemaand leaving a row blank while its coverage exists reads as withholding. Same file, same defect class, same gate family, no new verification surface. The seat separately authorised the growth intopackages/spec/tsup.config.tsandpackages/spec/package.jsonfor Call 2.Verification
Final head
5253cc3729c94ae64060d31a4996b9934e4d74a0. Everything below was measured atc93e1f456a; the one commit since is the changeset-prose correction described above — no code, schema, exports map or ledger row moved — and its own gates are the last bullet. Heavy runs went throughscripts/pm/os-verify-lock.sh; every verdict below is read from that script's ownVERDICT command-exitline or from an exit code captured before any pipe.pnpm --filter @objectstack/spec build— exit 0 (146s, then 142s after the tsup change). Both JSON Schemas emit; the browser pass emitsdist/browser/api/for the first time.pnpm --filter @objectstack/spec test— exit 0, 470 files / 13282 tests passed.pnpm --filter @objectstack/spec typecheck— exit 0.pnpm --filter @objectstack/runtime typecheck— exit 0.pnpm --filter @objectstack/runtime exec vitest run src/route-ledger.conformance.test.ts src/domains/packages-read-delete-response-conformance.test.ts— exit 0, 23 passed. (A first attempt reported 2 failed / "no tests": an unbuilt dependency closure, so nothing ran. Re-run afterpnpm --filter '@objectstack/runtime^...' build.)pnpm --filter @objectstack/spec check:browser-reachable-entries— exit 0, with the dark control above.scripts/pm/dispatch-gates.mjs, reconciled with--rancarrying an exit code per family): 118 of 118 accounted for — 116 run green, 2 NOT MEASURED. The two arecheck:dual-build-cjs-loadsandcheck:type-check-debt, both exit 3 = PREREQUISITE NOT MET: they read built output for the whole workspace, which is CI's run, not a finding.check:lean-entry-closureandcheck:skill-exampleswere also exit 3 / prerequisite at first and were converted into real green measurements by building@objectstack/objectqland@objectstack/client-reactrespectively.pnpm lint(eslint . --no-inline-config, the whole repo, no narrowing) — exit 0.5253cc3729(changeset prose only):node scripts/check-changeset-no-major.mjs --base origin/mainexit 0 (and--self-testexit 0, 265 assertions),node scripts/check-empty-changeset.mjs --base origin/mainexit 0 (--self-testexit 0, 118 assertions),node scripts/check-changeset-fixed.mjsexit 0,check:doc-authoringexit 0 on both legs (self-test 16 batteries / 198 cases; guard 401 files, 44 published skill files, 15022 spec strings, and the 821-site sibling baseline with no growth),check:nul-bytesexit 0 (8291 files). Every exit code captured into a variable before any pipe.grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]') — no hits;check:nul-bytesexit 0.Authored in Claude Code session
session_01MkQhmuuJAVDjmeWNixwDDH(https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH).Generated by Claude Code