Skip to content

fix(types): the import boundary keeps the descriptions it imports (objectui#9034) - #9086

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-9034-imported-defaults-describe
Sep 11, 2026
Merged

os-warren merged 2 commits into
mainfrom
claude/issue-9034-imported-defaults-describe

Conversation

@os-warren

@os-warren os-warren commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #9034

What was wrong

stripImportedDefaults dropped the .describe() of every schema it derived from @objectstack/spec.

A zod 4 description is not def state. .describe(d) stores { description: d } in z.globalRegistry — a WeakMap keyed by the node — and the description getter reads back through _zod.parent, a link only zod's own clone() sets. So neither of the boundary's two derivations carried it, for the same reason:

  • .removeDefault() returns the node's inner type. The protocol spells its guidance .default(v).describe(d), so d sat on the outer node that this arm discards.
  • cloneWithDef builds new Ctor({ ...def, ...patch }). It copies def faithfully — def.checks above all, which is what it exists for — and copies the description not at all, because the description was never in def.

The second one is the finding the card did not have. The walker's lazy arm already named description loss as the defect it guards against and named cloneWithDef as the guard; that sentence was half wrong — the clone rule saved def.checks and never saved the description. The comment is corrected in this diff rather than left standing.

Measurement, re-derived rather than trusted

Instrument: walk every schema-shaped export of every module subpath the spec publishes (read out of its own exports map), run the real stripImportedDefaults over each, and pair the two graphs node-by-node. Not a replay of the two operations — the function itself.

Command (both readings produced by the same instrument, before and after the diff):

pnpm exec vitest run packages/types/src/__tests__/imported-defaults-describe-9034.test.ts

Against @objectstack/spec 17.4.0 — 17 of 17 module subpaths loaded, 0 load failures, 1635 schema-shaped exports, 16120 distinct nodes visited:

population before after
described ZodDefault nodes 2024 2024
... description lost 2024 0
described container nodes rebuilt by the walk 1389 1389
... description lost 1364 0
reference-equal nodes (the identity property) 9665 9665

Restricted to the card's own scope — top-level object-shape members only — 1031 of 1031 described ZodDefault members lost it before, 0 after. The card filed 110 of 110; the direction is confirmed and the magnitude is larger, because that instrument walked a smaller set of exports. Nothing here contradicts the card's reading, it supersedes its denominator.

The strip invents no description either: the count of members that gained one they did not have is 0 before and 0 after.

The fix is one rule, not two

Both derivations now go through withDescriptionOf. It uses .describe() and not a write to _zod.parent, for a measured reason: .describe() clones, and on the .optional().default() branch — taken 267 times across this surface — .removeDefault() hands back a node that is already omissible, so the replacement is one of the spec's own objects. Carrying metadata by mutation there would relabel @objectstack/spec for every other consumer in the workspace, and every value assertion in the new test would still be green. It also deliberately carries the description and nothing else: z.globalRegistry.get(...) would also hand back id, and re-registering an id rewrites the registry's id map to point at this package's derivation.

The identity property is pinned, not assumed

A carry implemented by rebuilding nodes that did not need rebuilding would pass every value assertion above and break the property batch #90's reversibility argument rests on. So it is asserted directly: every export with nothing to strip still comes back reference-equal, and the reference-equal node count across the whole surface is unchanged at 9665.

Two exceptions are pinned by shape, so a new break cannot hide inside a matching count:

  1. The walker's deliberate lazy exception — it cannot answer "was anything stripped below me?" without forcing the getter, so it always rebuilds.
  2. A rest-less tuple. Pre-existing and not fixed here — see the out-of-scope note below.

Controls

Every count carries a control that fires; the ablation below is the control for the fix itself.

  • Firing control (ablation). With packages/types/src/zod/imported-defaults.ts reverted to its origin/main content and nothing else changed, the new test file goes 5 failed / 13 passed, and the five are exactly the description assertions. Restored with git checkout HEAD -- THEPATH; restoration proven by an empty git diff HEAD and an on-disk blob hash equal to the HEAD blob (15a63179), not by an exit code.
  • Absent before, present after, on a real imported member: ArtifactPackageEntrySchema.manifest.defaultDatasource carries "Default datasource for all objects in this package"; before the diff the stripped member reads undefined, after it reads the same string.
  • Identity control on a default-free described schema: reference-equal and description intact, before and after.
  • Negative control: the same traversal for a node type that cannot exist returns 0 — paired with the positive control above, which does fire, so it is a reading and not a second true negative.
  • Zod-fact pins: .removeDefault() dropping the description, and a raw new Ctor({...def}) rebuild dropping it, are each pinned. If a later zod propagates either, those go red and tell the next reader the carry is redundant instead of leaving them to re-derive it.

Out of scope, filed not ridden

The identity-property pin surfaced a second, unrelated defect in the same file: stripImportedDefaults rebuilds every rest-less tuple, whether or not anything beneath it changed. Zod spells "no rest element" as def.rest === null, and the tuple arm compares that against the undefined its own def.rest ? ... : undefined produces, so null === undefined is false and the arm always takes the rebuild branch.

Reproduced in two lines, and confirmed pre-existing by running it against the origin/main walker: stripImportedDefaults(z.tuple([z.number(), z.number()])) is not identity, while a tuple with a rest element and a plain object both are. It is a different defect class from description loss, and repairing it moves the reference identity of published mirror bindings — its own contract-surface change. Filed as #9088; carved out of the pin here by shape, with the carve-out itself asserted non-empty so it cannot pass vacuously once that issue lands.

Verification

check result
pnpm exec vitest run packages/types/ exit 0 — 174 files, 3422 tests passed
pnpm --filter @object-ui/types run type-check exit 0
pnpm exec eslint on both changed files exit 0
node scripts/check-changeset-presence.mjs exit 0
node scripts/check-changeset-no-major.mjs exit 0
check:control-bytes, check:spec-symbols, check:new-line-citations, check:esm-specifiers, check:self-import, check:unreferenced-sources, check:comment-mask-corpus exit 0 each
node scripts/check-governed-queue-guard.mjs --test NOT GOVERNED — 3 paths, none matched

Exit codes captured before any pipe.

Landing

⛔ Draft, carrying needs:contract-review to match the card's carrier. Landing is the seat's — not flipped ready, not enqueued, no auto-merge.


🤖 Generated with Claude Code

https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w


Generated by Claude Code


Generated by Claude Code

…jectui#9034)

`stripImportedDefaults` dropped the `.describe()` of every schema it derived.
A zod 4 description is registry state keyed by the node, not `def` state, so
neither derivation carried it: `.removeDefault()` returns the inner node, and
the protocol spells its guidance `.default(v).describe(d)` — `d` sat on the
outer node that was discarded; and `cloneWithDef` builds `new Ctor({...def})`,
which copies `def.checks` faithfully and the description not at all.

Measured across the published surface of @objectstack/spec 17.4.0: 2024 of 2024
described ZodDefault nodes and 1364 described container nodes arrived on this
side with no description. Both now go through one rule, `withDescriptionOf`,
and 0 are lost.

The identity property is untouched: reference-equal node count across the same
surface is 9665 before and 9665 after. The carry uses `.describe()` rather than
a write to `_zod.parent` because it must CLONE — on the `.optional().default()`
branch, taken 267 times, the replacement node is one of the spec's own objects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
…iled as

The carve-out in the identity-property pin referenced a placeholder number
while the finding was still unfiled. It is objectui#9088.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3491.2 KB 3512.7 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index-XHgkhHzQ.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.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
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) 26.08KB 7.56KB
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) 500.20KB 114.67KB
core (index.js) 8.28KB 3.31KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 208.91KB 57.80KB
fields (index.js) 247.14KB 62.34KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
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) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
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) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.50KB 19.97KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 131.21KB 34.62KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 251.53KB 65.20KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.51KB 40.89KB
plugin-grid (index.js) 211.56KB 57.50KB
plugin-kanban (index.js) 46.07KB 14.32KB
plugin-list (index.js) 112.52KB 27.64KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.54KB 3.31KB
plugin-view (index.js) 84.42KB 20.80KB
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.66KB 3.50KB
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) 83.34KB 27.61KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
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.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
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) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
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

Copy link
Copy Markdown
Collaborator Author

Parked for the night — ⛔ nothing is wrong with this PR.

The seat is winding down at the maintainer's instruction (「当前任务处理完就下班」, under a token constraint that also caused objectui#9091 and objectui#9092 to be withdrawn). This PR is Clause-②: yes, so it owes a ceiling-tier contract review, which is the most expensive act on the board — starting one now would contradict the reason the other two were pulled.

State, so the next seat spends nothing re-deriving it:

head 5131199465b75e1b2ebef107c07cbaa761d225a5
CI terminal — 35 check-runs, 0 non-green, reported total_count 35 == returned array length 35
carriers needs:contract-review on both PR and card objectui#9034 — correct for Clause-②: yes, ⛔ do not strip until a PASS is on record
owed one ceiling-tier review on this exact head, then strip both carriers, then land
⛔ blocked behind it objectui#9088 (rest-less tuple identity) touches the same file and must not be dispatched until this lands

⭐ One thing the reviewer should be given as a fact about the tree, ⛔ not as this seat's conclusion: the dev refuted the route this seat prescribed — cloneWithDef never carried a description, because zod 4.4.3 keeps descriptions in globalRegistry read through _zod.parent, a link only zod's own clone() sets. The seat's correction is recorded at objectui#9034 comment 5627459411.

⚠️ If the head moves for any reason, the review is owed on the new head.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

UN-PARKED — the maintainer asked for existing work to be followed through to merge (「现有的任务跟进到合并」), so the ceiling-tier contract review is running now on head 5131199465b75e1b2ebef107c07cbaa761d225a5.

⛔ The parking note above (5627663594) no longer describes what is happening, and is left in place rather than deleted so the record shows the decision changing. Everything factual in it still holds: CI terminal 35/35 with 0 non-green, both carriers correctly hold needs:contract-review, and objectui#9088 stays blocked behind this PR.

Next: adopt the verdict verbatim or void it entirely — ⛔ never rewrite one — then, on a PASS, strip both carriers together and land by squash.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review record — ADOPTED VERBATIM. Ceiling-tier (CONTRACT_REVIEW_TIER) ruling by a context-isolated review subagent, fed only card objectui#9034 and this PR. ⛔ Per the charter the seat has exactly two moves on a subagent verdict — adopt verbatim or void entirely — so nothing below is rewritten, abridged or polished.


Contract review

PR: #9086 — "fix(types): the import boundary keeps the descriptions it imports (objectui#9034)"
Head under review: 5131199465b75e1b2ebef107c07cbaa761d225a5
Card: #9034
Prior ruling on this PR: none (reviews list empty; PR comments are the bundle bot and the seat's "parked" note)
Tier: ceiling, domain:spec@objectui seat

① What the card asks, and whether the diff delivers exactly that

The card asks for one thing: stripImportedDefaults must stop dropping the .describe() of imported @objectstack/spec schemas, with (per the dispatch brief on the card) a re-derived population measurement, a pin of the identity property, and a firing control for the fix.

The true three-dot diff (git diff --stat f1190b0786e2ea4002d36774be04b54b7a82c343..HEAD, the merge-base; the API's base.sha 16fc4cf is main's tip and carries unrelated ObjectFieldInspector commits) is exactly three files, +554/−6:

  • packages/types/src/zod/imported-defaults.ts — adds withDescriptionOf (:149–150), routes cloneWithDef through it (:156–159), routes the default arm through it (:243–248), corrects the lazy arm comment that previously credited cloneWithDef with a description carry it never had (:198–201), and extends the function docblock (:333–340).
  • packages/types/src/__tests__/imported-defaults-describe-9034.test.ts — 491 lines, 18 tests.
  • .changeset/9034-imported-defaults-keep-describe.md — @object-ui/types: patch.

Nothing outside the ask is in the diff. content/docs/releases/ is untouched (git diff --name-only … | grep -c content/docs/releases → 0). The second commit is a one-line issue-number correction in the test. The TypeScript face (T → T) is unchanged; the zod face now carries descriptions; the docblocks and changeset say so; the direction is toward the protocol. The diff delivers what the card asks, and no more.

The card's own framing — that the file already had a working "clone rule" against description loss — does not hold, and the PR is right to say so. I verified it from zod 4.4.3 source rather than from the PR (② below): cloneWithDef constructs new Ctor({...def, ...patch}), and a constructed node has no _zod.parent, so it has no description however faithfully def is copied. The card's proposed one-line fix in the default arm alone would have left every rebuilt container undescribed (1,364 of them; ablation E3).

② What I re-derived myself

Environment. Fresh clone in my scratchpad (git clone --no-checkout → directory pre-existed from an earlier review subagent of this same session, reflog: clone at 16fc4cf then checkout of another PR head; I ran git fetch origin 5131199465b75e1b2ebef107c07cbaa761d225a5 exit 0, git checkout 5131199465b75e1b2ebef107c07cbaa761d225a5 exit 0; git status --short | wc -l → 0; git rev-parse --is-shallow-repository → false). Installed deps (never mutated): node -e 'require("./packages/types/node_modules/zod/package.json").version' → 4.4.3; spec → 17.4.0; git diff 16fc4cf…..HEAD -- pnpm-lock.yaml | wc -l → 0, so the install matches the head's lockfile. Blob hashes of the subject file: merge-base aa76ce98b8227f736164cbfe73cc17041452e672, head 15a63179f4848bd2dbc9d02e40f1a1cafb478d2d.

The zod 4.4.3 facts, read from source (not from the PR).

  • node_modules/zod/v4/classic/schemas.js:166–170: describe(description) { const cl = this.clone(); core.globalRegistry.add(cl, { description }); return cl; }
  • :192–197: the description getter is core.globalRegistry.get(inst)?.description.
  • node_modules/zod/v4/core/util.js:261–266: clone(inst, def, params) sets cl._zod.parent = inst only when !def || params?.parent.
  • node_modules/zod/v4/core/registries.js:30–41: get() inherits through _zod.parent and deletes id; :9–16: add() sets _idmap silently (overwrites, no throw) — so the new docblock's "re-registering an id rewrites the registry's _idmap entry" (:144–147) is accurate for 4.4.3.
  • classic/schemas.js:1147–1148: removeDefault = unwrap = () => inst._zod.def.innerType — the inner node itself, no clone, no parent.
  • Empirical (my probe P6): z.string().default('x').describe('DESC').removeDefault().description → undefined; new Ctor({...def}).description → undefined with no parent; .describe() sets a parent; 'description' in def → false.

So the stated fact holds: a new Ctor({...def}) rebuild cannot carry a description, and .removeDefault() discards the node that carried it.

The PR's test and the surrounding suite, at head.

  • pnpm exec vitest run packages/types/src/__tests__/imported-defaults-describe-9034.test.ts → exit 0, 18 passed.
  • pnpm exec vitest run packages/types/ → exit 0, 174 files / 3422 tests passed.
  • pnpm --filter @object-ui/types run type-check → exit 0.
  • pnpm exec eslint packages/types/src/zod/imported-defaults.ts packages/types/src/__tests__/imported-defaults-describe-9034.test.ts → exit 0.
  • CI on the head (pull_request_read get_check_runs): total_count 35 == returned length 35; 32 success, 3 skipped (Test (coverage), Test (coverage shard), dependabot), 0 non-green; all completed.

The census, re-derived with my own instrument (scratch vitest file, deleted afterwards; base-version walker copied beside it so both walkers run in one process; MEASURE_OUT=… pnpm exec vitest run packages/types/src/__tests__/review-9034.scratch.test.ts → exit 0). 17/17 subpaths loaded, 0 failures, 1,635 schema-shaped exports.

(a) A verbatim replica of the PR's pairing (test.ts:234–271) reproduces the PR's table exactly: base 16,120 visited / 9,665 reference-equal / 2,024 described ZodDefault all lost / 267 already-optional / 1,389 rebuilt-described, 1,364 lost, 25 kept; head 2,024 kept, 0 lost / 1,389 kept, 0 lost / 9,665. The numbers are real and reproducible with that instrument.

(b) But that instrument misaligns. At test.ts:254, if (ad?.type === 'optional' && ad.innerType) pair(inner, ad.innerType, …) decides the branch by the output's type. On the .optional().default() spelling the output is a ZodOptional in both before and after, so it pairs the spec's ZodOptional(T) against walk(T); child labels then never match and recursion stops. Measured: 394 misaligned pairs, 394 of which stopped recursion. Probe P1 shows it concretely: on z.object({k: z.object({j: z.string().default('x').describe('J')}).optional().default({}).describe('K')}) the PR's pairing sees 1 of 2 described defaults (the head walker keeps both).

(c) Corrected pairing (branch decided by optin of the inner node): base 16,193 visited / 10,079 reference-equal / 2,028 described ZodDefault, all lost / 394 already-optional / 2,110 bare / 1,394 rebuilt-described, 1,369 lost; head 0 described defaults lost, 0 rebuilt containers lost, 0 type mismatches, 0 unexpected shapes. Reference-equal count under corrected pairing moves 10,079 → 10,071; every one of the 8 is under a ZodDefault node (the deliberate describe-clone on the already-optional branch, which the PR discloses), none in a clean subtree. The PR's conclusion survives correct measurement; its denominators are lower bounds by 4/5/5.

(d) The 25 "kept" before the fix. All 25 paths are …states{}~lazy.{entry,exit,on,initial,meta(optional).aiInstructions} under five root exports reaching StateMachineSchema. Probed: the record's value is a z.lazy whose getter is fresh per call (getter() !== getter() → true); the state object itself has defaults beneath and is rebuilt by both walkers; but the five members have no default beneath and come back by identity within their own fresh graph. So before/after were two independently constructed spec nodes with the same string. The dev's explanation is correct: an artifact of pairing across a fresh getter, not a survival and not a rebuild.

(e) The spec's graph is not mutated. Snapshot of 33,556 reachable nodes (type, description, full registry meta, child identities) before any walk vs after both walkers ran over every root: 0 mutated, 0 new nodes. Probe P2 on the already-optional branch: the output is a clone whose _zod.parent is the spec's own inner node; the inner has no description, no parent, and is not in the registry. (Caveat: my snapshot does not force lazy getters, so nodes reachable only through them are outside it.)

(f) Identity carve-outs, as the PR's test computes them: 1,635 roots; 676 with a default; 959 clean; 15 behind z.lazy (0 returned by identity — the arm always rebuilds, as documented); 3 holding a rest-less tuple (data#FieldOperatorsSchema, data#RangeOperatorSchema, ui#ListMapConfigSchema — the three named in #9088, all 3 rebuilt); 941 plain; 0 plain exports rebuilt; rebuilt clean = 18 = 15 + 3. Both carve-outs are non-empty and exact. #9088 exists, is open, and describes the same mechanism and the same three exports; def.rest is null on a rest-less tuple (P4), and the non-identity is present under the base walker too — pre-existing, as claimed.

(g) Card-scope and "invented" counts. Top-level object-shape members, deduplicated by object identity: 1,237 object exports / 8,132 members / 1,181 ZodDefault members / 980 described → 980 lost under base, 0 under head; 1,210 undescribed members → 0 gained a description under either walker. The PR body's 1,256 / 8,314 / 1,234 / 1,031 came from a scratch instrument that was deleted; I could not reproduce those exact figures (different dedup), only the direction and the 100 % → 0 % result.

(h) Protocol-side spelling. Over the 22 ESM files in the spec dist: cat <files> | grep -o '\.describe(' | wc -l → 27,847; '\.default(' → 5,003; '\.meta(' → 418; '\.register(' → 6; globalRegistry → 0; single-line .describe("…").default( → 0. On the reachable surface, 54 nodes carry registry meta other than description (title 27, default 18, externalVocabulary 6, format 3, xRef 2, xExpression 2); 0 of those are rebuilt containers; 11 are ZodDefault nodes, and all 11 lose that meta under both walkers (title ×9: Mongo/Mysql/Postgres host/port, Postgres schema, Sqlite/SqliteWasm filename; externalVocabulary ×2: AuthConfigSchema.session.expiresIn, DisasterRecoveryPlanSchema.failover.dns.ttl). Through the emitter the card names: z.toJSONSchema(PostgresConfigSchema).properties.host → {default, description, title, type}; head → {description, type}; base → {type}.

(i) Inner-described spelling. 50 ZodDefault nodes have a described inner (32 with a different string), e.g. ui#PageSchema.type: outer "Page type", inner enum with its own longer description. After the head strip the outer optional carries "Page type" and the spec's own enum sits beneath it by identity with its description intact — no loss, no invention.

Ablations (each: pnpm exec vitest run packages/types/src/__tests__/imported-defaults-describe-9034.test.ts; each restored with git checkout HEAD -- packages/types/src/zod/imported-defaults.ts, restoration proven by on-disk git hash-object == 15a63179… YES and git diff HEAD -- <file> | wc -c → 0):

  • E1 full revert to blob aa76ce98… (0 withDescriptionOf lines): exit 1, 5 failed / 13 passed — exactly the five description assertions (:342, :353, :360, :411, :416). Matches the PR's ablation claim.
  • E2 default-arm carry removed only (out = next;): exit 1, 3 failed (:342, :360, :411).
  • E3 cloneWithDef carry removed only: exit 1, 2 failed (:353, :416). Each half is independently pinned.
  • E4 object arm always rebuilds: exit 1, 3 failed (:378, :431, :448). The identity pin fires.
  • E5 carry by writing _zod.parent instead of .describe() clone: exit 1, 2 failed (:360, :416). The non-mutation pin fires.
  • E6 always z.optional(inner) on the default arm (needless re-wrap): exit 0, 18 passed — not caught by the new file; E6b the same mutation against imported-defaults-8317, zod-mirror-authors-no-defaults-7735, spec-subschema-parity, zod-mirror-parity: exit 1, 5 failed / 179 passed (spec-subschema-parity: "… keeps its shape, arms and checks" ×5). Caught elsewhere in the suite.

Docblock precision probe. Of the 267 described already-optional defaults, the replacement is literally the spec's own inner object 257 times under base; under head 0 are the spec's object and 257 are describe-clones whose parent is that object; 10 have a rebuilt inner.

Sibling by construction. packages/types/src/strict-authoring-face.ts:180–183 has the identical new Ctor({...def, ...patch}) helper; probed: deriveStrictAuthoringSchema(z.object({k: z.string().describe('LEAF')}).describe('CONTAINER')) rebuilds and returns a node with no description (leaf keeps 'LEAF').

Not measured (an unmeasured thing is not a clean thing):

  • The exact figures in the PR body that came from the deleted scratch instrument (1,256/8,314/1,234/1,031; the "negative control"; the "0 invented" row) — only re-derived under my own dedup (g).
  • Spec versions other than 17.4.0.
  • The repo check:* scripts in the PR's verification table (control-bytes, spec-symbols, new-line-citations, esm-specifiers, self-import, unreferenced-sources, comment-mask-corpus, governed-queue-guard) — not rerun locally; I relied on the corresponding green CI check runs on this head.
  • Nodes reachable only through lazy getters are outside my mutation snapshot (e).
  • Consumers outside this repository.

③ Findings

  1. Non-blocking — the census pairing has a blind spot at every .optional().default() node. imported-defaults-describe-9034.test.ts:254 decides the default arm's branch by the output's def.type; on the already-optional branch both before and after are ZodOptional, so it pairs ZodOptional(T) with walk(T), no child label matches, and the subtree beneath is never measured — 394 sites, 394 stops. Consequences: the population is under-counted (2,024 → ≥ 2,028 described defaults; 1,389/1,364 → 1,394/1,369 containers; ~73–322 nodes), the "9,665 unchanged" invariance is partly by construction (the 8-node movement under corrected pairing sits entirely under ZodDefault nodes and is the disclosed describe-clone), and the "0 lost after" assertion at :411–418 could not have seen a loss under those 394 subtrees. The fix is correct there anyway (my corrected pairing: 0 lost). Repair: branch on optinOf(bd.innerType) === 'optional' and pair (inner, after) in that case. The hard-coded prose figures at imported-defaults.ts:237–238 and test.ts:8–11 are instrument-relative and should read as lower bounds.

  2. Non-blocking — a residual narrowing outside the card's ask: registry meta other than description. 11 ZodDefault nodes on the published surface carry title (9) or externalVocabulary (2) via .meta(), and the boundary drops them before and after this PR; it is visible through z.toJSONSchema on PostgresConfigSchema.host. The new docblock enshrines "⛔ The description and nothing else" (imported-defaults.ts:144–147) with a rationale about id, but id occurs 0 times in the spec's registry meta on this surface while title occurs 27 times — the sentence defends against an absent key and is silent about the present ones. The card asks for .describe() only and the PR does not narrow further, so this is not a defect of the diff; it is the same defect class, pre-existing, and must be filed as its own card (as finding(types): stripImportedDefaults rebuilds every rest-less tuple — def.rest is null, compared against undefined, so the identity property breaks #9088 was). The docblock sentence should either name the real trade-off or be narrowed to "never id".

  3. Non-blocking — figures stated as measured whose instrument is not in the tree. The PR body's card-scope rows (1,256 / 8,314 / 1,234 / 1,031 of 1,031), the "negative control", and the "0 invented" row come from a scratch instrument the dev deleted; the PR body presents "both readings produced by the same instrument" (the committed test), but the committed test does not compute those rows. My re-derivation (identity-dedup) gives 1,237 / 8,132 / 1,181 / 980 of 980 → 0, invented 0/0: direction and magnitude confirmed, exact figures not reproducible.

  4. Non-blocking — a test whose name over-claims its body. test.ts:483–490, "the spec's own graph still carries every default AND every description", checks hasDefault on the first 200 carriers only and never reads a description. My 33,556-node snapshot (type, description, meta, children) found 0 mutations, so the property holds; the assertion should either check descriptions or drop the words from its name.

  5. Non-blocking — docblock precision on "267". imported-defaults.ts:139–140 and test.ts:45–46 say the replacement "IS one of the spec's own objects … 267 times"; measured 257 of 267 (10 have a rebuilt inner). The carry runs 267 times on that branch; the object identity claim is true for 257.

  6. Non-blocking — the new file does not pin needless re-wrapping under a default. Ablation E6 (always z.optional(inner)) passes all 18; the only guard for subtrees under a default is the coarse > 0.5 ratio at test.ts:480, not the "9,665" the PR body cites. spec-subschema-parity.test.ts catches it (5 red), so the suite as a whole pins it — recorded so that pin is not treated as redundant.

  7. Non-blocking — sibling defect, out of scope. strict-authoring-face.ts:180–183 has the same new Ctor({...def, ...patch}) helper and its derivation drops the description of every node it rebuilds (measured). File separately; do not ride here.

Also for the card's record, not the PR's: the card's statement "none is spelled .describe().default()" is false (50 inner-described ZodDefault nodes, 32 with differing strings), harmlessly — both strings survive the strip.

No blocking finding. The fix is one rule applied at both loss sites, the pin reddens on full revert (5) and on each half alone (3 / 2), the identity property holds for every plain clean export (941 / 941), the two carve-outs are exact and non-vacuous, the spec's shared graph is unmutated, the changeset is a patch that says what the diff does, and the diff contains nothing the card did not ask for.

Independence pair

Implemented-by: branch `claude/issue-9034-imported-defaults-describe` (a mode:subagent dev is recorded by its BRANCH, having no session of its own)
Reviewed-by: session_01Jmxdo7bmeqCQHLSfmLVX9w (the domain:spec@objectui seat), ruling produced by a context-isolated ceiling-tier review subagent

Same-session test, applied literally to what the lines carry: the Implemented-by line carries a branch name; the Reviewed-by line carries a session id. A branch is not a session; they do not resolve to the same session. Result: not SELF-REVIEW; the ruling stands. Noted for the record and disregarded as evidence per instruction: both commits on the branch carry a Claude-Session: trailer naming the reviewing seat's session — a signature is a constant, not a reading.

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Landing provenance — PR #9086 (card objectui#9034)

Reviewed and landed head 5131199465b75e1b2ebef107c07cbaa761d225a5. Gate checkout: objectstack clone os-fresh2, origin/main = 3ef96b47120a….

① contract review — PASS ON RECORD. Clause-②: yes ⇒ a ceiling-tier review was owed and performed. Record 5628764306, adopted verbatim: VERDICT: PASS, no blocking findings, seven non-blocking in ③. The reviewer applied the same-session test literally and stated that independence rests on context isolation, ⛔ not on distinct sessions, and that the Claude-Session: trailers were given no evidentiary weight.

⭐ The review refuted the card's own framing, and upheld the dev for refuting the seat's. The card (and my dispatch brief) said the file already had a working "clone rule" against description loss. It did not: cloneWithDef builds new Ctor({...def, ...patch}), which has no _zod.parent, and zod 4.4.3 reads description only through that link. The reviewer re-derived this from zod source rather than from the PR, and notes the card's proposed one-line fix would have left 1,364 rebuilt containers undescribed.

② gates — check-clause2-carriers --pair 9086 0 ("both carriers agree") · check-governed-merges --pr objectstack-ai/objectui#9086 0 (NOT governed) · check-widening-tells --declaration yes 0, and ⛔ that one decides nothing by construction: "the claim declares Clause-②: yes, which this gate never blocks — a yes already routes to contract review, so a tell on top of it decides nothing." ⇒ cited as inapplicable, ⛔ not as evidence.

③ checks. 35 check-runs, all completed, 0 non-green, reported total_count 35 == returned array length 35.

charter provenance. Seven governing files byte-identical between ad715aca57… and 3ef96b47120a…: 7 / 7 present-and-identical, against an unfiltered control diff of 106 files / +9745 / -393.

⚠️ Two non-blocking findings that are worth reading before the next change to this file

  • Finding 1 — the census pairing in the shipped test has a blind spot at every .optional().default() node: it branches on the output's type, so 394 sites pair ZodOptional(T) against walk(T) and stop recursing. ⇒ the population figures are lower bounds (2,024 → ≥2,028; 1,389/1,364 → 1,394/1,369), and the "0 lost after" assertion could not have seen a loss beneath those 394 subtrees. ⭐ The reviewer ran a corrected pairing and the fix is still 0-lost, so the conclusion survives; the instrument is what is narrow.
  • Finding 3 — the PR body's card-scope rows came from a scratch instrument the dev deleted, while the body presents them as produced by the committed test. Direction and magnitude reproduce; the exact figures do not.

Neither blocks. Both are recorded because a figure stated as measured, whose instrument is not in the tree, is the class this lane keeps filing cards about.

carriers — STRIPPED, both together. needs:contract-review removed from this PR and card objectui#9034 in one stroke, now that the review has cleared. Findings 2 and 7 (registry meta other than description, and the sibling helper in strict-authoring-face.ts) are routed to their own card rather than ridden here — the reviewer's instruction, and the same trade objectui#9088 took.

Landing by squash through the merge queue.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants