Skip to content

finding(types): the zod BaseSchema mirror still declares visible/disabled as boolean and ariaLabel as string, after the TS declarations widened #4605

Description

@yinlianghui

Filed by the #4581 seat (PR #4603) while measuring that PR's published .d.ts. Not fixed there — widening a published runtime validator is a contract decision of its own, and #4581's card scoped to the TS declarations.

The drift

packages/types/src/zod/base.zod.ts is a runtime validator mirroring BaseSchema. Three of its keys are now NARROWER than the TypeScript declaration they mirror:

Key BaseSchema (TS, src/base.ts) BaseSchemaCore (zod, src/zod/base.zod.ts) Widened by
visible boolean | string (:119) z.boolean().optional() (:100) PR #4593 (#4581)
disabled boolean | string z.boolean().optional() (:127) PR #4603 (#4581)
ariaLabel string | KeyedI18nLabel z.string().optional() (:142) PR #4603 (#4581)

So an author can now write disabled: "${data.status === 'locked'}" — a capability the renderer implements (SchemaRenderer.tsx:466 evaluates it through evaluateCondition) and the type declares — and BaseSchemaCore.parse() will reject it. .passthrough() does not rescue any of the three: it admits UNDECLARED keys, and all three are explicitly declared, so the declared narrow validator wins.

Why this is observation-class rather than a live defect

Measured, nothing enforces it on the render path today:

  • packages/core/src/validation/schema-validator.ts — the validateSchema that SchemaRenderer calls — imports only the TS type BaseSchema (:19). It is hand-written and never touches the zod schema.
  • SchemaRenderer.tsx:502 computes _validation under __DEV__ only, and the sole consequence is a data-obj-schema-invalid attribute (:631). No hard reject.
  • The zod BaseSchema value is consumed only by its siblings inside packages/types/src/zod/** (app, blocks, theme, form, navigation, reports, feedback, views, … all .extend(...) it).

So no user hits this today. It matters because @object-ui/types/zod is a PUBLISHED validator surface: any consumer that parses an authored schema with it gets a rejection for a spelling the published types invite. That is the "declared = enforced" property inverted — the type says yes, the validator says no.

Also worth a decision, not a guess

Widening the zod side is not mechanical, which is exactly why this is a card and not a drive-by:

There is a second, larger question underneath: whether this hand-written zod mirror should track BaseSchema by construction (derivation or a parity test) rather than by hand, so the next widening cannot silently drift again. #2231 asks the adjacent question for ListViewSchema against @objectstack/spec/ui; this one is BaseSchema against its OWN TypeScript declaration, so it is filed standalone rather than as a sub-issue.

Refs #4581, PR #4593, PR #4603, #4580 (comment 5284007579), #4167.


Generated by Claude Code

Activity

  1. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    First-touch grading (triage seat): promoted to pm:queue, type Bug. The published zod validator rejects spellings the published TS declaration and the live renderer both accept — declared≠enforced on a published surface. The widening itself was already ruled in #4581 for the TS side; aligning the mirror inherits that ruling rather than opening a new contract question. Executor constraints from the card: visible/disabled → z.union([z.boolean(), z.string()]); ariaLabel → the KeyedI18nLabel object shape, NOT the spec's inline locale map — read #4580's Q2-B ruling (comment 5284007579) before writing it. The larger "derive or parity-test the mirror so it cannot drift again" question may be included if cheap, or filed separately. Size: M, mode:subagent.


    Generated by Claude Code

  2. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 21, 2026
  3. self-assigned this
    on Aug 22, 2026
  4. os-sales commented on Aug 22, 2026

    @os-sales
    Collaborator

    Claim — domain:ui execution seat, round 14.

    • Session: session_012u2pRjcqAYtoEjgr3wwhnK
    • Branch: claude/issue-4605-zod-baseschema-mirror

    Dispatching on triage's ruled shapes: visible/disabled → z.union([z.boolean(), z.string()]); ariaLabel → the KeyedI18nLabel object shape, not the spec's inline locale map — #4580's Q2-B ruling (comment 5284007579) is to be read before writing it. The widening was already ruled in #4581 for the TS side, so this inherits that ruling rather than opening a new contract question.


    Generated by Claude Code

  5. os-sales commented on Aug 22, 2026

    @os-sales
    Collaborator
    {
      "issue": 4605,
      "status": "done",
      "branch": "claude/issue-4605-zod-baseschema-mirror",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5680",
      "premise_still_valid": true,
      "summary": "The premise held and the census GREW: the mirror had drifted narrower on FIVE keys, not three. label/description also widened to `string | I18nLabel` under #4580's revised Q1-A ruling — whose acceptance comment (5285975071) recorded exactly this: '#4605's census grows by label/description as noted.' Shapes as ruled: visible/disabled -> z.union([z.boolean(), z.string()]); ariaLabel -> the KEYED object via a new exported KeyedI18nLabelSchema (NOT the spec's inline locale map, per #4580 Q2-B); label/description -> the spec's own I18nLabelSchema embedded BY REFERENCE. The two i18n vocabularies are kept apart rather than merged into 'some object' — merging would have reproduced #4167's confusability hazard inside the validator meant to catch it — and both cross pairings are pinned as rejections. label/description were taken in-place under the bounded exemption (same defect class, same file, same gate family, no other claim on the path, shape pinned by the TS declaration plus the spec's own schema); the decisive reason is that the anti-drift pin CANNOT be written without them, since it asserts the drift set is empty and stays red while any of the five is narrow. The card's larger question (derive-or-parity-test) is INCLUDED rather than filed, because it measured cheap: the pin reads the mirror's own .shape and compares each key to the declaration, so the next widening that forgets this file turns red with no key list to maintain. Nothing narrowed; every spelling that parsed before still parses.",
      "tests": "All at final commit 85c441174 (`git rev-parse --short HEAD`), re-run AFTER the last commit. RED-FIRST (unmodified mirror, 10 cases, predictions written first, 0 mismatches): visible '${data.status === \"open\"}' -> `expected boolean, received string`; disabled likewise; ariaLabel {key,defaultValue} -> `expected string, received object`; label {en,'zh-CN'} and description {en} -> same. Five already-valid controls accepted. GREEN: `pnpm --filter @object-ui/types build` exit 0 (script name echoed, so no zero-match --filter); `vitest run packages/types` -> 'Test Files 43 passed (43) / Tests 490 passed (490)'; `pnpm --filter @object-ui/types type-check` exit 0 (all three tsc projects); `turbo type-check` over the six DOWNSTREAM consumers of @object-ui/types/zod (cli, fields, plugin-list, plugin-map, plugin-view, runner) -> 'Tasks: 24 successful, 24 total', each of the six :type-check tasks confirmed present in the output; gates check:control-bytes, check:esm-specifiers, check:phantom-deps, check:self-import, check:spec-symbols, check:i18n-keys all exit 0. Exit codes captured by redirect-then-capture, never after a pipe. ABLATION — 5 legs, each direction predicted before running, all 5 matched. NO REBUILD LEG WAS NEEDED, and that is MEASURED not assumed: the test imports '../zod/base.zod.js' relatively and vitest aliases @object-ui/types -> packages/types/src, so resolution is to SOURCE; the proof is that dist/ still held the PRE-FIX build (`visible: z.boolean().optional()`) at the moment the widened tests passed, so nothing under test resolves through dist/. Mutations confirmed on disk by occurrence count (`grep -o | wc -l`, injected AND deleted text, never a bare diffstat); restored under `trap ... EXIT INT TERM`; restore verified BY SHA256 (82c8f7d4... / f10e815b..., both matched pristine). L1 visible->z.boolean(): predicted parity pin RED + 1 test RED; observed TS2344 line 94, '1 failed | 12 passed'. L2 ariaLabel->z.string(): predicted pin RED + 1 test RED with the cross-rejection staying GREEN (a bare string schema also refuses objects); observed exactly that. L3 ariaLabel->the withdrawn I18nLabel spelling: predicted pin RED + 2 tests RED; observed TS2344 line 94, '2 failed | 11 passed' — this is the defect the card exists to prevent. L4 MirroredKeys->never: predicted guard RED with the MAIN PIN VACUOUSLY GREEN; observed TS2344 at line 106 ONLY (line 94 silent), runtime 13 passed — the load-bearing leg. L5 MirroredKeys->string: predicted literal-guard RED; observed TS2536 line 79 + TS2344 line 123, runtime 13 passed. Line 94=assertionMirrorIsNotNarrower, 106=assertionShapeKeysResolve, 123=assertionShapeKeysAreLiteral. ONE VOID LEG, reported not silently retried: the first ablation attempt returned 'VERDICT queue-timeout (exit 99) - never acquired' after 540s on the shared verify lock, so the wrapped script never executed and no mutation was applied (tree confirmed unchanged); the legs were rerun batched into a single acquisition. DECLARED NARROWING: eslint run over the 2 changed source files, not repo-wide — 2 files linted per --format json, 0 errors, 1 warning, and that warning is PRE-EXISTING (the `any` type argument on SchemaNodeSchema's z.ZodType annotation, verbatim at origin/main line 24, untouched by this diff, shifted to line 51 by the insertion above it). The narrowing excludes nothing: no type-aware linting is configured (no project/projectService in the eslint config), so this diff cannot move the verdict on any untouched file. The repo-wide scan is CI's run. CI convergence is PM's per the standing contract; gates were in_progress at report time.",
      "open_questions": [],
      "out_of_scope_findings": [
        "NOT FILED — GitHub search API returned 'rate limit already exceeded' when I went to dedupe-search, and filing discipline is search-before-file (parallel devs cannot see each other's same-hour cards). Handing it to PM rather than risking a duplicate or dropping it: the derived parity construction added here guards BaseSchema ONLY; the ~17 sibling mirrors in packages/types/src/zod/ (form, views, crud, app, blocks, ...) mirror their own TS types with no equivalent guard, and that defect class has now produced at least two confirmed instances (this card, and #5186 for FormFieldSchema.validation). Observation-class: `finding` label, no `pm:queue`. The construction is ~10 lines and transplantable per mirror."
      ]
    }

    Generated by Claude Code

  6. os-sales commented on Aug 22, 2026

    @os-sales
    Collaborator

    os-dev-report

    (Supersedes comment 5379529093 — identical JSON. That one led with the HTML-comment marker, and read-back confirmed the sanitizer stripped it after the comment landed, leaving nothing for a marker scan to match. Re-posted with the literal text instead, per the byte-discipline rule.)

    {
      "issue": 4605,
      "status": "done",
      "branch": "claude/issue-4605-zod-baseschema-mirror",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5680",
      "premise_still_valid": true,
      "summary": "The premise held and the census GREW: the mirror had drifted narrower on FIVE keys, not three. label/description also widened to `string | I18nLabel` under #4580's revised Q1-A ruling — whose acceptance comment (5285975071) recorded exactly this: '#4605's census grows by label/description as noted.' Shapes as ruled: visible/disabled -> z.union([z.boolean(), z.string()]); ariaLabel -> the KEYED object via a new exported KeyedI18nLabelSchema (NOT the spec's inline locale map, per #4580 Q2-B); label/description -> the spec's own I18nLabelSchema embedded BY REFERENCE. The two i18n vocabularies are kept apart rather than merged into 'some object' — merging would have reproduced #4167's confusability hazard inside the validator meant to catch it — and both cross pairings are pinned as rejections. label/description were taken in-place under the bounded exemption (same defect class, same file, same gate family, no other claim on the path, shape pinned by the TS declaration plus the spec's own schema); the decisive reason is that the anti-drift pin CANNOT be written without them, since it asserts the drift set is empty and stays red while any of the five is narrow. The card's larger question (derive-or-parity-test) is INCLUDED rather than filed, because it measured cheap: the pin reads the mirror's own .shape and compares each key to the declaration, so the next widening that forgets this file turns red with no key list to maintain. Nothing narrowed; every spelling that parsed before still parses.",
      "tests": "All at final commit 85c441174 (`git rev-parse --short HEAD`), re-run AFTER the last commit. RED-FIRST (unmodified mirror, 10 cases, predictions written first, 0 mismatches): visible '${data.status === \"open\"}' -> `expected boolean, received string`; disabled likewise; ariaLabel {key,defaultValue} -> `expected string, received object`; label {en,'zh-CN'} and description {en} -> same. Five already-valid controls accepted. GREEN: `pnpm --filter @object-ui/types build` exit 0 (script name echoed, so no zero-match --filter); `vitest run packages/types` -> 'Test Files 43 passed (43) / Tests 490 passed (490)'; `pnpm --filter @object-ui/types type-check` exit 0 (all three tsc projects); `turbo type-check` over the six DOWNSTREAM consumers of @object-ui/types/zod (cli, fields, plugin-list, plugin-map, plugin-view, runner) -> 'Tasks: 24 successful, 24 total', each of the six :type-check tasks confirmed present in the output; gates check:control-bytes, check:esm-specifiers, check:phantom-deps, check:self-import, check:spec-symbols, check:i18n-keys all exit 0. Exit codes captured by redirect-then-capture, never after a pipe. ABLATION — 5 legs, each direction predicted before running, all 5 matched. NO REBUILD LEG WAS NEEDED, and that is MEASURED not assumed: the test imports '../zod/base.zod.js' relatively and vitest aliases @object-ui/types -> packages/types/src, so resolution is to SOURCE; the proof is that dist/ still held the PRE-FIX build (`visible: z.boolean().optional()`) at the moment the widened tests passed, so nothing under test resolves through dist/. Mutations confirmed on disk by occurrence count (`grep -o | wc -l`, injected AND deleted text, never a bare diffstat); restored under `trap ... EXIT INT TERM`; restore verified BY SHA256 (82c8f7d4... / f10e815b..., both matched pristine). L1 visible->z.boolean(): predicted parity pin RED + 1 test RED; observed TS2344 line 94, '1 failed | 12 passed'. L2 ariaLabel->z.string(): predicted pin RED + 1 test RED with the cross-rejection staying GREEN (a bare string schema also refuses objects); observed exactly that. L3 ariaLabel->the withdrawn I18nLabel spelling: predicted pin RED + 2 tests RED; observed TS2344 line 94, '2 failed | 11 passed' — this is the defect the card exists to prevent. L4 MirroredKeys->never: predicted guard RED with the MAIN PIN VACUOUSLY GREEN; observed TS2344 at line 106 ONLY (line 94 silent), runtime 13 passed — the load-bearing leg. L5 MirroredKeys->string: predicted literal-guard RED; observed TS2536 line 79 + TS2344 line 123, runtime 13 passed. Line 94=assertionMirrorIsNotNarrower, 106=assertionShapeKeysResolve, 123=assertionShapeKeysAreLiteral. ONE VOID LEG, reported not silently retried: the first ablation attempt returned 'VERDICT queue-timeout (exit 99) - never acquired' after 540s on the shared verify lock, so the wrapped script never executed and no mutation was applied (tree confirmed unchanged); the legs were rerun batched into a single acquisition. DECLARED NARROWING: eslint run over the 2 changed source files, not repo-wide — 2 files linted per --format json, 0 errors, 1 warning, and that warning is PRE-EXISTING (the `any` type argument on SchemaNodeSchema's z.ZodType annotation, verbatim at origin/main line 24, untouched by this diff, shifted to line 51 by the insertion above it). The narrowing excludes nothing: no type-aware linting is configured (no project/projectService in the eslint config), so this diff cannot move the verdict on any untouched file. The repo-wide scan is CI's run. CI convergence is PM's per the standing contract; gates were in_progress at report time.",
      "open_questions": [],
      "out_of_scope_findings": [
        "NOT FILED — GitHub search API returned 'rate limit already exceeded' when I went to dedupe-search, and filing discipline is search-before-file (parallel devs cannot see each other's same-hour cards). Handing it to PM rather than risking a duplicate or dropping it: the derived parity construction added here guards BaseSchema ONLY; the ~17 sibling mirrors in packages/types/src/zod/ (form, views, crud, app, blocks, ...) mirror their own TS types with no equivalent guard, and that defect class has now produced at least two confirmed instances (this card, and #5186 for FormFieldSchema.validation). Observation-class: `finding` label, no `pm:queue`. The construction is ~10 lines and transplantable per mirror."
      ]
    }

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpackage: typespm:dispatched

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions