Skip to content

fix(console): an injected spec shares the console's one zod instance (objectui#11327) - #11353

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-11327-console-single-zod
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-11327-console-single-zod

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #11327

What this does

Under OBJECTSTACK_SPEC_DIST (the framework's scripts/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 (in scripts/vite-objectstack-spec-dist.ts, returned on the injection as singleZodPlugin): a pre-resolver (enforce: 'pre', hook filter on ^zod(/|$)). It resolves every bare zod / zod/... specifier through the rest of the resolver chain as if packages/app-shell had 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 inside resolveSpecDistInjection, beside assertSpecDependenciesResolve): the build fails when the console's zod does not satisfy the zod range the injected spec declares in dependencies or peerDependencies. The error names both. The range reader versionSatisfiesRange is deliberately narrow and throws on any spelling it cannot read, the same trade minVersionOf makes in scripts/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.
  • consoleZodFrom is a required option of resolveSpecDistInjection, so a caller cannot forget it.
  • apps/console/vite.config.ts passes CONSOLE_ZOD_ANCHOR (packages/app-shell, the package whose metadata-admin *-schema.ts modules call z.toJSONSchema over spec schemas, and which declares zod itself). It registers the redirect only while the override is live and registers the guard always. A comment next to resolve.dedupe records why zod is 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 no zod, so that lookup finds nothing and Vite silently falls back to the importer's own copy. I measured this with zod added to dedupe on the real console config, using OBJECTSTACK_SPEC_DIST and OBJECTSTACK_CLIENT_DIST pointed at a built /home/user/objectstack (ef1ed17f). The build emitted the same two copies, and the vendor-objectstack asset hash was byte-identical to the build without dedupe (vendor-objectstack-B_ZeTkdj.js in 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 5262f7dd3 base; framework ef1ed17f, spec 17.5.0 declares zod ^4.6.1)

The figures below come from counting distinct zod version-object literals and $ZodString constructor definitions in apps/console/dist/assets/*.js.

build zod version literals $ZodString defs
base, injected 2: 4.6.1 (vendor-objectstack) + 4.6.5 (vendor-zod) 2
base + zod in resolve.dedupe, injected 2, byte-identical to the row above 2
this PR, injected 1: 4.6.5 1
this PR, plain (no override) 1: 4.6.5 1

With the fix, the injected build's chunk layout matches the plain build's: zod sits in vendor-objectstack and there is no separate vendor-zod chunk. Only the second copy produced that chunk.

Verification (all at HEAD e95a18845 unless noted)

  • pnpm exec vitest run over 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. --listFilesOnly confirms both the module and its test are in that program.
  • pnpm type-check:vitest-config (tsc -b apps/console/tsconfig.node.json --force, which covers vite.config.ts): exit 0.
  • Console vite build, plain and injected: both exit 0, one zod each (table above).
  • eslint on the three touched files: 0 errors. The test file has 5 warnings, the same 5 no-explicit-any it has at base.
    • Population: the repo-root flat config; JSON output covers 3 files, none ignored.
    • Invariance: eslint.config.js declares no parserOptions / project / projectService, so linting is not type-aware and this diff cannot move a verdict on any untouched file.
  • Light gates, all exit 0: 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), and check-governed-queue-guard --test (NOT GOVERNED).
  • Declared to CI, not run locally: full pnpm test, the turbo pnpm lint, the console app-source tsc --noEmit, and e2e.

Reverse verification (each leg through ablation-replace.mjs, after commit; each restored with blob == HEAD and an empty git diff HEAD). Every leg was predicted plain RED.

leg mutation result
A1 redirect unregistered in vite.config.ts, injected console build build exit 1, guard: "carries 2 zod instances" naming zod 4.6.1 at the framework store and zod 4.6.5 at objectui's
A2 parity call removed 3 red: refuses-older-zod, peerDependencies, config-anchored parity
A3 guard threshold raised from "more than 1" to "more than 99" copies 2 red: two-copy build, same-version twin
A4 enforce: 'pre' removed 2 red: redirect build (Vite's resolver answers first), config wiring
A5 counter-probe disabled 1 red: no-zod build

End to end, with the real injection (measured at 1ffe5fe77; the later commits change only comments and the guard's message text)

  • Node probe through the console's own resolution. It bundles the real metadata-admin getPackageSchema / getDashboardSchema / getReportSchema / getPageSchema / getListVariantSchema / getListColumnSchema, using the live config's alias table and its singleZodPlugin, then runs the result in node.
    • With the redirect: 1 zod directory (4.6.5), and all six schemas derived (property counts 25 / 21 / 21 / 24 / 50 / 14) with no [...-schema] warnings. The package schema has name, id and namespace.
    • Control without the redirect: 2 zod directories (4.6.5 + 4.6.1). It still derives all six, because zod 4.6.5 happens to walk 4.6.1's schemas. At the reported pin (4.4.3) that same walk threw.
    • So at today's main the 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.
  • Browser (Chromium). I served the injected build with vite preview on port 5371, with /api proxied to an isolated objectstack dev --seed-admin --fresh -p 4371 of framework examples/app-crm. Nothing was written to packages/console/dist or .cache, the app-crm tree has no residual files, and both servers were stopped by PID.
    • Studio, "New package (writable base)": the dialog rendered mdf-name, mdf-id, mdf-namespace, mdf-version, mdf-type and mdf-description with the spec-derived help text. Create was enabled, POST /api/v1/packages answered 201, and the package was listed as Writable.
    • CRM pipeline_dashboard inspector: rendered spec-derived Layout fields (Columns / Gap / Refresh Interval Seconds) and 0 "Spec schema unavailable" banners.
    • 0 [...-schema] console messages. The only zod-bearing vendor chunk loaded was vendor-objectstack-DjMuOTNe.js.
    • Not driven in the browser: a report surface, because app-crm has no report. It is covered by the node probe above.
  • Not measured: the dev server (vite serve) with the override set. The redirect also registers there, but only builds were measured.

Acceptance notes

  • Framework follow-up (objectstack-ai/objectstack, NOT in this PR):
    • Bump .objectui-sha to a commit that contains this PR. At the current pin db11afd4967c the console's zod is 4.4.3, and with this PR the parity check would correctly refuse the build against spec ^4.6.1.
    • Add a single-zod canary to scripts/build-console.sh, for example by counting distinct zod version literals in assets/*.js. The in-build guard already fails the build, so the canary is belt-and-braces.
    • Add the spec's zod (or the spec manifest) to the release.yml console cache key, which today keys only on .objectui-sha and the script.
  • Pre-registered siblings objectui#11070 and objectui#10859 (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.
  • zod is intentionally absent from resolve.dedupe (see above). A comment there points at the redirect and the guard.
  • Environment deviation: this container runs Node 22.22.0, and jsdom 30 declares ^22.22.2. engine-strict=true refused pnpm install, so I installed with --config.engine-strict=false. CI uses its own Node.

Generated by Claude Code

claude added 4 commits October 1, 2026 06:19
…(objectui#11327)

Under OBJECTSTACK_SPEC_DIST the injected spec's bare `zod` import resolved
from the framework's own install tree, so the console bundled two zod
instances and the metadata-admin schema modules' `z.toJSONSchema` threw
over the spec's schemas (the Studio's New Package dialog lost its fields).

`resolve.dedupe` cannot fix it: Vite resolves a deduped package from the
root, `apps/console` declares no zod, and the lookup silently falls back to
the importer's copy (measured: same two copies, byte-identical output).

- `pinZodToConsoleCopy`: a pre-resolver the injection registers, pinning
  every bare `zod` / `zod/*` specifier to the copy `packages/app-shell`
  resolves.
- `assertSpecZodParity`: the build fails, naming both, when that copy is
  outside the zod range the injected spec declares.
- `assertSingleZodInstance`: every console build fails when its emitted
  chunks carry more than one zod package directory, or none (counter-probe).

Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7
Co-authored-by: Claude <noreply@anthropic.com>
…ses read (objectui#11327)

Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7
Co-authored-by: Claude <noreply@anthropic.com>
… title (objectui#11327)

Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7
Co-authored-by: Claude <noreply@anthropic.com>
…g, not that it always fires (objectui#11327)

At the reported console pin (zod 4.4.3 over a spec built with 4.6.1) the
walk threw; a later console zod walked the same schemas without throwing.
The comments and the guard's message now say that, so the one-instance rule
reads as the ruling it is rather than as a claim every split crashes.

Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 1 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/6681-declared-lazy-marketplace-routes.md

  • names apps/console/vite.config.ts → apps/console/vite.config.ts — edited by this change

    Both marketplace chunks were held by CHUNK CO-TENANCY, not by any import of the page: rolldown had parked components/SuggestedBindingsPanel.tsx (statically imported by the eager views/studio-design/StudioDesignSurface.tsx) in the first, and console/marketplace/InstalledListWidget.tsx (bare-imported by the package barrel for its SDUI registration) in the second. An app-shell-eager-leaves group in apps/console/vite.config.ts isolates those co-tenants so the three declared-lazy pages chunk by their own dynamic-only reachability. The console eager closure moves 3180.2 KB → 3171.5 KB gzipped (−8,888 bytes, 48 → 45 eager chunks) with the three per-chunk ceilings unmoved.

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Compared the checked-out tree with 770cc5ba4 (merge-base with origin/main): 3 file(s) changed outside .changeset/, read against 1897 pending declaration(s) that publish a body (2510 pending in total). · run

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 330 chunks) 3585.5 KB 3607.4 KB
Main entry chunk (gzip) 150.4 KB 350 KB
Entry file index-lTUlmhMS.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.88KB 6.25KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.17KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.13KB 7.95KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 571.28KB 136.83KB
core (index.js) 10.00KB 3.96KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 229.96KB 63.80KB
fields (index.js) 261.05KB 66.23KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.49KB 9.23KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 40.55KB 11.41KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.86KB 5.00KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.52KB 2.26KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.01KB 3.93KB
plugin-calendar (index.js) 52.98KB 15.39KB
plugin-charts (index.js) 84.09KB 22.93KB
plugin-chatbot (index.js) 198.22KB 46.97KB
plugin-dashboard (index.js) 139.34KB 37.30KB
plugin-designer (index.js) 216.54KB 44.64KB
plugin-detail (index.js) 244.79KB 64.47KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 173.70KB 44.63KB
plugin-gantt (index.js) 173.11KB 43.10KB
plugin-grid (index.js) 231.53KB 63.58KB
plugin-kanban (index.js) 49.32KB 15.48KB
plugin-list (index.js) 116.63KB 28.97KB
plugin-map (index.js) 23.54KB 7.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.04KB 12.21KB
plugin-timeline (index.js) 33.91KB 10.00KB
plugin-tree (index.js) 11.20KB 3.89KB
plugin-view (index.js) 90.32KB 22.76KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 119.55KB 39.23KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 6.06KB 2.68KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 21.42KB 7.05KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 3.19KB 1.62KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.26KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 21.59KB 7.71KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 07:32
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 07:33
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 31971ff Oct 1, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-11327-console-single-zod branch October 1, 2026 07:47
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants