…ce in the vendored Console), add a single-zod canary to build-console.sh, and key the release console cache on the spec zod range (objectstack-ai#21149)
Fixes objectstack-ai#21108
Clause-②: no
Moves the console pin past objectstack-ai/objectui#11353, so the
vendored Console bundles one zod instance again. Adds a single-zod
canary to `scripts/build-console.sh`, and keys `release.yml`'s Console
dist cache on the spec's declared zod range.
## Target: `31971ff1e28f`, the minimal containing commit
`.objectui-sha` moves `e420df310f5b` ->
`31971ff1e28f89cfc45f0c19bc5b05e443f28b79`. That is the merge commit of
objectui#11353 itself, which closed objectui#11327. objectui `main` was
`2124d0411175`, 2 commits further on, when I chose the target.
Why not `main`:
- The two later commits are objectui#11293 (standalone `object-kanban` /
`object-calendar` honour `page` navigation through the host's record
navigator) and objectui#11165 (a docs example and its test). Neither is
needed by this card.
- objectui#11293 rewrites `plugin-kanban/src/index.tsx` and
`plugin-calendar/src/index.tsx`. Nine asserting pin-citation anchors in
`packages/spec/src` read those two files. Taking it would turn this
pin's citation re-measure into a navigation-claim change outside this
card.
- The procedure (`docs/releases-maintenance.md`) says the target is
decided in the objectstack issue. The card's unlock criterion is "a
commit that contains objectui#11353". The minimal containing commit
meets it and drags in the least.
Containment was measured on a private full clone of objectui (`rev-parse
--is-shallow-repository` = false):
| question | command | exit |
|---|---|---|
| pin is the objectui#11353 merge | `gh api …/pulls/11353` answers
`merged: true`, `merge_commit_sha: 31971ff1e28f…` | — |
| pin is on objectui `main` | `merge-base --is-ancestor 31971ff1e28f
origin/main` | 0 |
| old pin is inside the new range | `--is-ancestor e420df310f5b
31971ff1e28f` | 0 |
| negative: new pin is not inside the old one | `--is-ancestor
31971ff1e28f e420df310f5b` | 1 |
| control for that negative (deeper commit, same target) |
`--is-ancestor db11afd4967c e420df310f5b` | 0 |
| objectui#11293 is NOT in this pin | `--is-ancestor 2124d0411175
31971ff1e28f` | 1 |
| control for that negative | `--is-ancestor e420df310f5b 31971ff1e28f`
| 0 |
The range `e420df310f5b..31971ff1e28f` is 44 commits.
## What changed
- **`.objectui-sha` and `.changeset/console-31971ff1e28f.md`.** Written
by `scripts/bump-objectui.sh 31971ff1e28f --no-commit` against the
private clone, so the shared shallow `../objectui` was never deepened.
The range has 56 releasing changesets, 12 declared breaking, and 3
commits carry no changeset.
- objectui#11353, the fix this bump exists for, is one of those 3. So I
added one lead sentence naming it; the digest would otherwise list it
only under "declared nowhere".
- The bump script's `adr-0087: TODO` placeholder is answered
`not-required (no-migration-prescription)`. It is worded after the
maintainer's ruling on the previous bump (objectstack-ai#20949) and fitted to this
diff, and it names all 12 upstream entries. Three of them mirror
ObjectStack keys that are already retired and registered here:
`ui/ChartConfig:aria`, `ui/Page:assignedProfiles` and `ui/Action:aria`.
The `object-form` / `object-map` / `object-gantt` / `object-chart`
entries move objectui's validator onto the `properties` bag that the
spec rows already declare. `showFilters` was never an ObjectStack key on
`object-grid`. Please confirm this disposition; see the open question in
the report on objectstack-ai#21108.
- **`sdui.manifest.json` + `scripts/sdui-manifest.record.json`.**
Produced by `node scripts/gen-sdui-manifest-node.mjs` over the tree
`build-console.sh` built at the pin: 107 components, `@object-ui/core`
17.6.0. Eleven components changed their published inputs: `object-form`,
`object-kanban`, `object-calendar`, `object-gantt`, `object-timeline`,
`object-map`, `record:quick_actions`, `element:text`,
`element:definition-list`, `element:repeater` and `action:button`.
- **`packages/sdui-parser/objectui-lockstep.json`.** Re-recorded with
`pnpm gen:sdui-lockstep` against the pin's own checkout: 214 grammar
lines, 25 codes, containment predicate `76c18fb95d1f`.
`check:sdui-lockstep` exits 0 with no port owed.
- **`scripts/build-console.sh`: the single-zod canary.** Details in the
next section.
- **`.github/workflows/release.yml`: the Console dist key.** Details
below.
- **The 49 asserting pin citations in `packages/spec/src`.** Re-measured
at the new pin (section below). This also regenerates
`packages/spec/src/migrations/registry.ts` (`gen:migration-registry`)
and `content/docs/references/ui/view.mdx` (`check:generated --fix`, the
one artifact it proved stale), and adds
`.changeset/objectui-pin-citations-31971ff1e28f.md` (`@objectstack/spec`
patch). The `FormField.span` describe and six migration-entry
descriptions name the pin, so the text change publishes.
- **`origin/main` merged twice.**
- First at `19cbfeba7d`: no file overlapped this diff, and no generated
artifact moved.
- Then `fbcc05f400`, at merge commit `8435a830eb`, through
`scripts/pm/os-regen-merge.sh`. There was one textual conflict, in two
docblocks of `packages/spec/src/ui/component.zod.ts`. main's objectstack-ai#21005 and
objectstack-ai#21141 are kept verbatim, and the pin citations were re-applied and
re-measured at `31971ff1e`. No regeneration commit is owed:
`check:generated` reports 15/15. That merge also moved the spec's zod to
`^4.6.5`; the console still bundles exactly one zod, `4.6.5`.
## The single-zod canary
It sits beside the existing bundle canary.
- It counts zod version literals across
`packages/console/dist/assets/*.js`. Each zod 4 instance carries one
version object (the `version` export of `zod/v4/core/versions`),
minified as `{major:4,minor:6,patch:5}`.
- It requires **exactly one** occurrence. It refuses two different
versions (the shipped defect), one version found twice (two instances),
and zero (a blind matcher, since the console always bundles zod).
- The same function runs standalone as `scripts/build-console.sh
--check-single-zod ASSETS_DIR`, which builds and writes nothing. That
standalone mode is how both directions below were measured.
| fixture | result |
|---|---|
| the real dist this PR's build produced at `31971ff1e28f` (2450
`assets/*.js`) | exit 0: `exactly one zod version literal
{major:4,minor:6,patch:5} in vendor-objectstack-UrN_Az-7.js` |
| the real `db11afd4967c` dist (the published two-zod split) | exit 1:
`2 different zod versions` —
`vendor-objectstack-CfLtdaY1.js:{major:4,minor:6,patch:1}`,
`vendor-zod-CHLW1iCr.js:{major:4,minor:4,patch:3}` |
| the new dist plus one chunk carrying `{major:4,minor:4,patch:3}` |
exit 1, two different versions |
| the new dist plus a second `{major:4,minor:6,patch:5}` chunk | exit 1:
`2 copies of ONE zod version literal` |
| the new dist with its one literal rewritten (`major` -> `MAJOR`; `grep
-o` count 1 -> 0 before, 0 -> 1 after) | exit 1: the blind-matcher
refusal |
| no argument | exit 2, usage |
The fixtures are copies in a scratch directory. No tracked file was
mutated.
## `release.yml`: the key carries the spec's declared zod range, not the
whole `package.json`
The card suggested `hashFiles('packages/spec/package.json')`. I went
with a narrower input:
- A new step reads `dependencies.zod` out of
`packages/spec/package.json`. It fails rather than emitting an empty or
`undefined` range.
- The restore, the `CONSOLE_DIST_CACHE_KEY` env and the save step all
spell the same key, with `-zod-RANGE` appended (today `-zod-^4.6.5`,
after the merge of `fbcc05f400`).
Why not hash the whole file: its `version` field moves on every Version
Packages merge, and that merge is the push that drives the publish lane.
Hashing it would make every publish a cache miss and a cold console
rebuild. The comment above the key records that design as deliberate.
The declared range is the input the build actually judges:
objectui#11353's parity assertion refuses a console zod outside it.
`release.yml` is not a governed surface. `GOVERNED_SURFACES` in
`scripts/pm/check-governed-merges.mjs` lists `docs/adr/**`,
`.claude/**`, `skills/**`, `AGENTS.md`, `CLAUDE.md` and
`docs/NORTH-STAR.md`. No workflow was run or dispatched.
## Pin-citation re-measure
Each record was handled the same way:
- Its anchors were mapped through the `e420df310f5b..31971ff1e28f` diff
of each cited file and re-read at the new pin.
- Block hashes (`git hash-object`) and corpus counts (`git grep -o -F`
over the tracked tree, 9912 files) were taken again with the method each
record names. The method reproduces every `e420df310` count exactly at
the old pin: 9800 files, `objectstack` 15352, `@objectstack/spec` 6024,
`timeout` 1293.
- Earlier hops stay as history.
- `check:objectui-pin-citations --verify-anchors`, run against the pin's
checkout, reports 49 asserting citations matching `31971ff1e`, and 7
anchor content assertions verified.
Records whose CLAIMS moved, not only their numbers. These want a human
read:
- **`object-calendar` `navigation`.** objectui#8652 declared the key on
`ObjectCalendarSchema`. That removes the one `as any` cast objectui#8651
had left: the ledger comment `1008-1025` is replaced by a declaration
note, and the read `1026` -> `1018` now says `schema.navigation ?? {
mode: 'drawer' }`.
- **`object-kanban` `navigation`.** The read is unchanged. It now
compiles through a declared `ObjectKanbanSchema.navigation` member
rather than `BaseSchema`'s index signature.
- **`object-timeline`.** objectui#8654 declared `navigation`. The read
lost its cast (`753-756` -> `793-796`), and both registrations gained a
`navigation` input. The `schema.*` read set is unchanged, because
`navigation` was already read and is already on this row.
- **`action:button`.** objectui#11168 slice 2 makes the `size` input
publish the five values the row declares. The `inputs` range grew
`414-551` -> `414-559`.
- **`record:quick_actions`.** objectui#10224 rewrote the fail-closed
docblock: the bar is replaced by the notice, not hidden. The cited gate
lines moved by 6 with their text byte-identical.
- **`object-tree` optionality count.** By the record's own method,
`plugin-map/src/index.tsx` now reads 4 `ElementDataSourceGate` hits, not
3. objectui#10859 batch 5 added a comment naming the gate, while the
wiring is still three lines. The record now says so.
- **Wrong when written.** The `object-timeline` data door's
start/end-binding anchor `621-622` began one line early, on the
`rawData.map` head, at `e420df310` too. It is re-pointed to `657-658`
and the record says why.
No key, default, enum member or export moves.
## Acceptance at the new pin
**Build.** `scripts/build-console.sh` ran at `31971ff1e28f` with this
tree's client and spec injected, under the shared verify lock: `VERDICT
command-exit 0`.
- The single-zod canary passed (above). `assert-console-spec-injection`
passed: 21 of 21 injected-only descriptions are present and all 8
published-only ones are absent.
- The dist is 63032 KB.
- Environment, not a product change: objectui's `jsdom` requires Node
`^22.22.2` under engine-strict, and this container's Node is 22.22.0. A
verified Node 22.22.2 was prepended to `PATH` for the console build
only.
- The build ran before the manifest regeneration, as the procedure
orders it. So that local dist ships the previous manifest bytes, and the
build itself prints the re-run step. CI's Console Pin Gate builds fresh.
**Boot.** `pnpm dev -- --fresh --ui --no-watch -p 41633` started the
showcase with its own ephemeral DB and the seeded admin.
`check:console-sha` reads `objectui@31971ff1e28f`. I drove it in
headless Chromium (`/opt/pw-browsers/chromium`) from `localhost`:
- **Studio "New package (writable base)"** (`/_console/studio`): the
dialog renders **Display name (`name`)**, **Package ID (`id`)** and
**Object namespace (`namespace`)**, plus Version, Type and Description.
- I filled `Zod Probe` / `com.acme.zodprobe` / `zodprobe` and clicked
Create package. The request was **`POST /api/v1/packages` -> 201**.
- The Studio opened `/_console/studio/com.acme.zodprobe/data`. `GET
/api/v1/packages/com.acme.zodprobe` answers 200 with `writable: true`.
- **Dashboard inspector**
(`/_console/apps/showcase_app/metadata/dashboard/showcase_ops_dashboard`):
the spec-derived form renders Columns, Gap, Refresh Interval Seconds,
Header, Show Title, Show Description and Actions. "Spec schema
unavailable" appears 0 times.
- **Report inspector** (`…/metadata/report/showcase_task_overview`):
Label, Report type, Description and Blocks render, with 0 "Spec schema
unavailable".
- **Control for that absence:** the string is in the shipped bundle
(`i18n-D40RWXZZ.js`, 3 occurrences), so 0 on screen is a reading, not a
missing probe.
- **Errors:** 0 page errors and 0 console errors across the drive.
- **Non-2xx:**
- `401 GET /api/v1/auth/get-session` before sign-in.
- `404 GET /api/v1/usage/storage`, as at the previous pin.
- `501 GET /api/v1/ai/usage`, with no AI provider configured.
- `404 GET /api/v1/meta/{dashboard,report}/NAME?state=draft`, the
no-draft probe of a code-loaded item.
- **Teardown:** only the 7 PIDs I recorded were stopped. The port is
free, and the `--fresh` tempdir went with the process.
## Gates and tests, at head `8435a830eb` (re-run in full after the
merge: 151 derived, 151 run, 0 NOT-MEASURED, 0 UNRUN)
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 151 commands, all run sequentially
with exit codes captured before any pipe.
- 150 exit 0.
- `pnpm check:pm-dispatch-gates` hit my 900 s runner timeout under
contention (exit 124). Re-run alone with no timeout, it passed: exit 0,
`dispatch-gates self-test: 1976 cases pass`, battery 1096.7 s.
- `dispatch-gates --ran` reconciliation: `151 derived, 151 run, 0
NOT-MEASURED, 0 UNRUN`.
- Highlights: `check:objectui-pin-citations` 49 matching,
`check-sdui-manifest` intact at the live pin, `check:sdui-lockstep` OK,
`check:console-sha`, `check:console-injection`,
`check-adr-0087-registration` (2 declaring changesets, each with a
disposition), `check:generated` (15 of 15 current), `check:docs` (227 in
sync), `check:migration-registry`, `check:bash32-floor` (32 shell
files), `check:nul-bytes`, `check-ci-filter-parity` and
`check:published-files`.
- `pnpm --filter @objectstack/spec test`: 591 files, 17368 passed and 1
todo, exit 0. `pnpm --filter @objectstack/spec typecheck`: exit 0.
- NOT MEASURED, by CI only: the Console Pin Gate job's own steps and the
workspace type-check lanes. The local equivalent of the Console Pin Gate
is the build above.
## Acceptance notes
- `scripts/check-console-injection.mjs`'s header still says
release.yml's key "hashes the pin and the build script". That sentence
is now incomplete, because the key also carries the spec's zod range.
The file is outside this card's file surface. It is a comment, and
nothing reads it.
- objectui#11353 landed with no objectui changeset, so objectui's own
CHANGELOG does not mention the zod fix. The console changeset here names
it.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Fixes #11327
What this does
Under
OBJECTSTACK_SPEC_DIST(the framework'sscripts/build-console.sh), the console bundle now carries exactly ONE zod instance. The maintainer's ruling asked for one instance, not two copies that happen to agree.pinZodToConsoleCopy(inscripts/vite-objectstack-spec-dist.ts, returned on the injection assingleZodPlugin): a pre-resolver (enforce: 'pre', hook filter on^zod(/|$)). It resolves every barezod/zod/...specifier through the rest of the resolver chain as ifpackages/app-shellhad imported it. That includes the imports in the injected spec's out-of-tree files. Exports-map subpaths and the import kind are still honoured; the only thing that changes is which install tree the lookup happens in. The build fails by name if a specifier stops resolving from the anchor, or resolves outside the anchor's zod.assertSpecZodParity(runs insideresolveSpecDistInjection, besideassertSpecDependenciesResolve): the build fails when the console's zod does not satisfy thezodrange the injected spec declares independenciesorpeerDependencies. The error names both. The range readerversionSatisfiesRangeis deliberately narrow and throws on any spelling it cannot read, the same trademinVersionOfmakes inscripts/check-spec-range-floors.mjs.assertSingleZodInstance(wired into every console build, injected or not): counts the zod package directories rendered into the emitted chunks. It fails on more than one, and the message names each copy's version, path, chunks and importers. Two copies of the same version also fail, because the guard counts instances, not versions. It also fails on zero, as a counter-probe, because a negative guard that cannot see zod would stay green forever.consoleZodFromis a required option ofresolveSpecDistInjection, so a caller cannot forget it.apps/console/vite.config.tspassesCONSOLE_ZOD_ANCHOR(packages/app-shell, the package whose metadata-admin*-schema.tsmodules callz.toJSONSchemaover spec schemas, and which declareszoditself). It registers the redirect only while the override is live and registers the guard always. A comment next toresolve.deduperecords whyzodis not listed there.Why not
resolve.dedupe(the suggested route, measured and rejected)Vite resolves a deduped package from the root. The console root (
apps/console) declares nozod, so that lookup finds nothing and Vite silently falls back to the importer's own copy. I measured this withzodadded todedupeon the real console config, usingOBJECTSTACK_SPEC_DISTandOBJECTSTACK_CLIENT_DISTpointed at a built/home/user/objectstack(ef1ed17f). The build emitted the same two copies, and thevendor-objectstackasset hash was byte-identical to the build without dedupe (vendor-objectstack-B_ZeTkdj.jsin both). A scratch Vite 8.2.2 project showed the mechanism directly: dedupe does reach out-of-root importers, but only when the root itself can resolve the package. When it can't, there is no warning.Measurements (objectui
5262f7dd3base; frameworkef1ed17f, spec 17.5.0 declareszod ^4.6.1)The figures below come from counting distinct zod version-object literals and
$ZodStringconstructor definitions inapps/console/dist/assets/*.js.$ZodStringdefs4.6.1(vendor-objectstack) +4.6.5(vendor-zod)zodinresolve.dedupe, injected4.6.54.6.5With the fix, the injected build's chunk layout matches the plain build's: zod sits in
vendor-objectstackand there is no separatevendor-zodchunk. Only the second copy produced that chunk.Verification (all at HEAD
e95a18845unless noted)pnpm exec vitest runover the touched test file plus the 14 other script and console test files that read the console Vite config: 15 files, 860 tests passed, exit 0. Files:vite-objectstack-spec-dist,vite-objectstack-client-dist,check-eager-closure-budget,console-vite-alias-closure-4925,scripts-type-check,side-effects-declaration-consistency,vite-declared-lazy-views,vite-ineffective-dynamic-imports,vite-types-zod-lazy,vitest-config-alias-targets-3944,vitest-config-type-check,check-changeset-presence,cross-file-line-citation-census,StudioRoute,SetupRoute.pnpm type-check:scripts: exit 0.--listFilesOnlyconfirms both the module and its test are in that program.pnpm type-check:vitest-config(tsc -b apps/console/tsconfig.node.json --force, which coversvite.config.ts): exit 0.vite build, plain and injected: both exit 0, one zod each (table above).no-explicit-anyit has at base.eslint.config.jsdeclares noparserOptions/project/projectService, so linting is not type-aware and this diff cannot move a verdict on any untouched file.check:control-bytes,check:new-line-citations(0 new),check:test-path-roots,check:pre-install-import-graph,check:phantom-deps,check-changeset-presence("no changeset is owed": no published source or contract changed), andcheck-governed-queue-guard --test(NOT GOVERNED).pnpm test, the turbopnpm lint, the console app-sourcetsc --noEmit, and e2e.Reverse verification (each leg through
ablation-replace.mjs, after commit; each restored with blob == HEAD and an emptygit diff HEAD). Every leg was predicted plain RED.vite.config.ts, injected console buildzod 4.6.1at the framework store andzod 4.6.5at objectui'senforce: 'pre'removedEnd to end, with the real injection (measured at
1ffe5fe77; the later commits change only comments and the guard's message text)getPackageSchema/getDashboardSchema/getReportSchema/getPageSchema/getListVariantSchema/getListColumnSchema, using the live config's alias table and itssingleZodPlugin, then runs the result in node.[...-schema]warnings. The package schema hasname,idandnamespace.mainthe split is latent: two instances that agree. Under this PR, the 4.4.3 shape is refused at build time by the parity check (pinned in the tests) instead of shipping.vite previewon port 5371, with/apiproxied to an isolatedobjectstack dev --seed-admin --fresh -p 4371of frameworkexamples/app-crm. Nothing was written topackages/console/distor.cache, the app-crm tree has no residual files, and both servers were stopped by PID.mdf-name,mdf-id,mdf-namespace,mdf-version,mdf-typeandmdf-descriptionwith the spec-derived help text. Create was enabled,POST /api/v1/packagesanswered 201, and the package was listed as Writable.pipeline_dashboardinspector: rendered spec-derived Layout fields (Columns / Gap / Refresh Interval Seconds) and 0 "Spec schema unavailable" banners.[...-schema]console messages. The only zod-bearing vendor chunk loaded wasvendor-objectstack-DjMuOTNe.js.viteserve) with the override set. The redirect also registers there, but only builds were measured.Acceptance notes
.objectui-shato a commit that contains this PR. At the current pindb11afd4967cthe console's zod is 4.4.3, and with this PR the parity check would correctly refuse the build against spec^4.6.1.scripts/build-console.sh, for example by counting distinct zod version literals inassets/*.js. The in-build guard already fails the build, so the canary is belt-and-braces.release.ymlconsole cache key, which today keys only on.objectui-shaand the script.packages/types/src/zod/**): this PR moves no workspace zod version and no lockfile entry. The new guard will fail any console build where objectui's own lockfile resolves zod twice.zodis intentionally absent fromresolve.dedupe(see above). A comment there points at the redirect and the guard.^22.22.2.engine-strict=truerefusedpnpm install, so I installed with--config.engine-strict=false. CI uses its own Node.Generated by Claude Code