Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

[finding] The anti-drift guard #5680 adds covers BaseSchema only — the ~17 sibling zod mirrors have nothing equivalent, and the class already has two confirmed instances #5684

Closed

Description

@os-sales

Filed by the domain:ui execution seat (session session_012u2pRjcqAYtoEjgr3wwhnK) on behalf of #5680's author (#4605), who hit the GitHub search rate limit at their reporting turn and declined to file without a dedupe search. Search since run: no duplicate.

The class

A zod mirror in packages/types/src/zod/ restates a TS declaration by hand. When the declaration widens, the mirror does not follow, and the result is declared ≠ enforced on a published surface: the validator rejects spellings the TS type and the live renderer both accept.

Nothing detects this. It is found only when someone trips over it.

Two confirmed instances, independently found:

instance what drifted
#4605 (fixed in #5680) BaseSchema's mirror declared visible/disabled as boolean and ariaLabel as string after the TS side widened — and the census turned out to be five keys, not the three the card named, once label/description were measured too
#5186 FormFieldSchema.validation's mirror (FieldConstraintsSchema) refused the object shape the TS contract declares legal, and admitted a flat shape the renderer never reads

What #5680 built, and what it does not cover

#5680 adds a derived parity construction: it reads the mirror's own .shape and compares each key against the declaration, so the next widening that forgets the file turns red with no key list to maintain. That last property is what makes it worth transplanting rather than re-deriving — a hand-maintained key list is the same artefact the drift keeps producing.

It guards BaseSchema only. The sibling mirrors — form, views, crud, app, blocks, and roughly a dozen more in the same directory — each mirror their own TS types with no equivalent.

Per #5680's author the construction is ~10 lines and transplantable per mirror.

Ask

Decide whether to transplant it across the directory, and in what order. Worth measuring first rather than assuming, because the mirrors are not uniform:

  • Which of the ~17 mirrors are hand-written restatements (in scope) versus already derived from or embedded by reference to @objectstack/spec (already safe)?
  • How many carry a live drift today? Each one found is a defect on a published surface, not just a missing guard — and both known instances were latent until measured.
  • Whether the construction generalises unchanged, or whether some mirrors need a variant (e.g. where the TS type is generic, or where the mirror deliberately diverges — Bind dashboard dateRange.defaultRange to the spec's DateRangeDefaultRange, and pin the preset label half #5670 documented one such deliberate case, complex.zod.ts's looser defaultRange, whose ledger entry says so explicitly). A deliberate divergence must stay possible, or the guard becomes a gate that forbids a decision the repo has already made.

Related but not the same: #2231 tracks unifying the hand-written zod with @objectstack/spec/ui outright, which would dissolve the class rather than guard it. If that lands broadly, this card narrows to whatever remains hand-written.

Filed unassigned, observation-class: finding, no pm:queue.

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 22, 2026
  2. huangyiirene commented on Aug 22, 2026

    @huangyiirene
    Collaborator

    Concentration-round first-touch (triage seat): promoted finding → pm:queue, Task M — measurement-first campaign, in the card's own order: (1) classify the ~17 mirrors (hand-written restatement vs derived/spec-referencing — only the former are in scope); (2) measure which carry a live drift today — each hit is a defect on a published surface and gets named in the PR, not just guarded; (3) transplant the ~10-line derived-parity construction per in-scope mirror. Hard clause from the card, adopted: deliberate divergences must stay expressible — the complex.zod.ts defaultRange case (#5670's ledger entry) is the template; a guard that forbids an already-made decision is a defect. Relationship to #2231 recorded: if spec unification lands broadly, this narrows to the remainder — check #2231's state at claim time and scope accordingly.


    Generated by Claude Code

  3. claude commented on Aug 24, 2026

    @claude
    Contributor

    Claimed by the domain:ui execution seat, session 01EuPCi56cnGyykygi3z9w4m, branch claude/issue-5684-zod-mirror-drift-guard.

    Claim-precedence check before writing code:

    • Comments read in full: 1 comment on this card (triage-seat promotion finding -> pm:queue, Task M). No prior dev claim comment from any session id.
    • git ls-remote --heads origin 'claude/issue-5684*' -> empty (no live branch).

    Scope per the dispatching seat: extend #5680's landed derived-parity guard to the sibling zod mirrors (one guard, one population), enumerate the mirrors with a control, name any live drift the guard catches, and list exclusions with reasons rather than silently omitting them.


    Generated by Claude Code


    Generated by Claude Code

  4. claude commented on Aug 24, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 5684,
      "status": "done",
      "branch": "claude/issue-5684-zod-mirror-drift-guard",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5929",
      "premise_still_valid": true,
      "summary": "The premise holds: #5680's construction covered BaseSchema only, the siblings had nothing equivalent, and the class is live. One count in the card needed correcting first — the population unit is a PAIR, not a file. packages/types/src/zod/ holds 216 exported consts across 17 files, which resolve to 163 registered mirror/declaration pairs plus 51 reasoned exclusions (214 + 2 barrel consts). #5680's construction is now applied to all 163 through one shared definition, not copied per mirror: base-schema-zod-mirror-parity.test.ts keeps its #4605 runtime pins and no longer restates the type-level part, and BaseSchema is one registered row. A runtime census reads the directory off disk and fails when an exported const is in neither map, so a mirror added later cannot join silently. Measured drift: 17 of 163 pairs, spanning strict widenings, disjoint vocabularies, optionality, spec-derived structure, and one DELIBERATE divergence (PageNodeSchema.pageType, documented at PageVisualizationAlias — the spec repudiates the five visualization names the TS side retains). They are ledgered with exact key sets as a two-way ratchet rather than corrected: each is a widen-the-mirror-or-narrow-the-declaration question that #4605 answered for BaseSchema only by measuring the renderer, and batch-widening 17 mirrors would be consumer-side tolerance (AGENTS.md #0.1) publishing spellings that render nothing. Filed as #5927 with both sides printed from the checker. Two name-derived pairings were phantoms and are the reason the enumeration carries a control: zod StylePropsSchema paired with TS StyleProps shares ZERO keys (filed #5928), and FieldConstraintsSchema mirrors FieldValidationRules — not the like-named legacy FieldConstraints — which reported 5 phantom drifted keys under the wrong pairing and reports none under the right one, so #5186's correction is holding exactly.",
      "tests": "All at 3d50d4c3b (pushed head; the ablation control leg confirmed `git diff HEAD --stat` empty at that sha). Exit codes captured BEFORE any pipe; verdict lines quoted as each gate printed them.\n\nGATES — `pnpm --filter @object-ui/types type-check` TYPECHECK_EXIT=0, script name echoed (`tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json`), so not a zero-match no-op. `pnpm exec vitest run --maxWorkers=2 packages/types/` VITEST_EXIT=0 -> 'Test Files 51 passed (51) / Tests 563 passed (563)'. `pnpm --filter @object-ui/types lint` LINT_EXIT=0 -> '253 problems (0 errors, 253 warnings)', all pre-existing, none in the new file. check-changeset-presence EXIT=0 -> 'Every one of them has an EMPTY frontmatter - declared as releasing nothing'. check-changeset-no-major EXIT=0 -> 'No changeset declares a `major` bump.' check-changeset-fixed EXIT=0 -> 'All workspace packages are in the changeset fixed group.' check-control-bytes EXIT=0 -> 'OK (scanned 4923 tracked text file(s); skipped 85 binary).' check-lint-coverage EXIT=0 -> 'lint coverage: 46/46 packages linted, 0 with outstanding errors'. check-type-check-coverage EXIT=0 -> '41/41 packages compile their tests'.\n\nLINT NARROWING, declared with its three pieces: (1) the universe came from eslint's own flat config (`eslint .` inside the package), not a guess; (2) `--format json` counted 112 files, 0 errors, 253 pre-existing warnings; (3) eslint.config.js configures NO type-aware linting (no projectService / parserOptions.project / project:), so no rule's verdict on a file outside this diff can depend on this diff. The repo-wide farm is CI's run.\n\nREBUILD / STALE-DIST: no rebuild was needed and that is structural, not luck. `pnpm --filter '@object-ui/types^...'` prints 'No projects matched the filters' — the package has ZERO workspace dependencies. The guard resolves ../zod/*.zod.js to sibling TypeScript SOURCE via tsc, and @objectstack/spec is an unmodified npm package (17.2.0). Nothing on the resolution path is a workspace dist/, so no mutation could be masked by one; legs A and B confirm it empirically by mutating .ts sources and being seen with no build step.\n\nMUTATION CONFIRMED ON DISK, never by an editor exit code: every leg ran through a python replace that ASSERTS exactly one anchor match, then grepped INJECTED text (expect 1) and REMOVED text (expect 0) separately, printing both counts before the measurement. The script carried `trap restore EXIT INT TERM`.\n\nREVERSE VERIFICATION — direction predicted before each run:\n- LEG A widen AlertSchema.variant (TS) +'info', mirror untouched. injected 1 / removed 0. PREDICTED red naming data-display.zod.ts#AlertSchema. OBSERVED TSC_EXIT=2, LedgerMismatch => \"data-display.zod.ts#AlertSchema\". MATCH.\n- LEG B widen TooltipSchema.delayDuration (TS) to number|string, different file and mirror. injected 1 / removed 0. PREDICTED red naming overlay.zod.ts#TooltipSchema. OBSERVED TSC_EXIT=2, LedgerMismatch => \"overlay.zod.ts#TooltipSchema\". MATCH.\n- LEG D ablate the construction so it always resolves never. injected 1 / removed 0. PREDICTED red but REVERSED — a blinded guard measures never for the 17 ledgered pairs and stops matching its own ledger. OBSERVED TSC_EXIT=2 and LedgerMismatch named all 17. MATCH. This is the discriminating leg: the guard cannot be neutered into silence.\n- LEG E blind the census reader (readdirSync -> []). injected 1 / removed 0. PREDICTED the non-vacuity test red, the other three vacuously green. OBSERVED VITEST_EXIT=1 with 2 failed / 2 passed — the non-vacuity test AND 'no map entry names a const that no longer exists' both went red, because an empty on-disk set makes every registry entry read as stale. DEVIATION FROM PREDICTION, reported not smoothed: a second independent channel against a blind reader that I did not predict.\n- CONTROL restored tree, TS and mirrors agree. `git diff HEAD --stat` EMPTY. PREDICTED green. OBSERVED TSC_EXIT=0, LedgerMismatch => never, NonOverlappingPair => never. MATCH.\nLegs that do not discriminate: none is silent under its mutation. A and B share one assertion, so the tsc message alone does not say WHICH mirror is at fault — the entry name comes from resolving the exported LedgerMismatch, which is why it is exported.\n\nTHE THREE FALSE-GREEN SPECIES, checked: (a) stale cross-package dist — structurally impossible here, proven above by the zero-workspace-dependency measurement rather than assumed; (b) blind instrument — LEG E proves the census reader can report before its silence is quoted anywhere; (c) phantom assertion — assertionEveryPairOverlaps caught a REAL one during development (StylePropsSchema/StyleProps, zero shared keys) and assertionNoVacuousEntry pins shape degeneration (never AND bare string) for all 163 entries at once, which is strictly stronger than the two hand-written pins #5680 carried. check-spec-symbol-derivation's green is cited NOWHERE as coverage: #5899 measured that it skips every non-exported declaration.",
      "open_questions": [
        {
          "question": "For the 17 ledgered drifts (#5927): when a mirror refuses what its TS declaration declares, which side is authoritative — widen the mirror to match, or narrow the declaration under ADR-0049 enforce-or-remove? The guard is landable and green either way; this decides the follow-up campaign's shape, and 6 of the 17 are DISJOINT vocabularies where one spelling is certainly dead.",
          "options": [
            "A - Widen the mirror to the declaration everywhere. Mechanical and fast. But it publishes spellings nothing renders wherever the TS side is the stale one, and for the disjoint pairs it yields the UNION of two vocabularies, keeping both the live and the dead spelling.",
            "B - Measure the renderer per key first, then correct whichever side is stale (mirror widening or ADR-0049 removal). This is what #4605 actually did for BaseSchema. Costs a per-key read of @object-ui/react; ~30 keys.",
            "C - Route the structural/spec-derived group (DashboardComponentSchema, DashboardWidgetSchema, FilterBuilderSchema, CRUDSchema, FormSchema.fields) into #2231's spec unification instead of correcting locally, and apply B to the rest."
          ],
          "recommendation": "C, then B for the remainder. Four axes. REAL BUSINESS NEED: the evidence must be measured, not assumed — for each key the question is who WRITES it and who READS it, and A answers neither; two of the pairs (ChatbotSchema.body, DataTableSchema.rowActions) do not differ in width but in MEANING, so no widening is even well-defined. LONG-TERM SOUNDNESS: A is the consumer-side-tolerance move AGENTS.md #0.1 forbids — widening a validator because a type disagrees is exactly the workaround that let #5099 and #5186 happen; B and C fix the producer or the spec. MAKING AI-WRITTEN METADATA HARD TO GET WRONG: this is the decisive axis. A makes every disjoint pair accept BOTH spellings, so an AI-authored app can write the dead one, pass validation, and render nothing — the failure mode is silent and the validator actively endorses it. B leaves exactly one legal spelling per slot, which is what 'declared = enforced' buys. STARTUP SCOPE DISCIPLINE: C keeps this from becoming a 30-key campaign by routing the five spec-derived pairs to #2231, which would dissolve them rather than patch them, and #5684's own card already records that relationship. PageNodeSchema.pageType stays ledgered under every option — it is a sanctioned divergence and correcting it would re-add spellings the spec repudiates."
        }
      ],
      "out_of_scope_findings": [
        "filed as #5927: 17 zod mirrors refuse spellings their TypeScript declarations declare — full measured table, both sides printed from the checker, grouped into strict widenings / disjoint vocabularies / optionality / spec-derived / deliberate divergence; needs a per-key widen-or-narrow ruling",
        "filed as #5928: zod `StylePropsSchema` ({className, style}) and TS `StyleProps` (a Tailwind vocabulary) share a name and ZERO keys — the naming asserts a mirror relationship that does not exist, and a name-derived pairing reported 5 keys of phantom drift on it"
      ]
    }

    Generated by Claude Code

  5. claude commented on Aug 24, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 5684,
      "status": "done",
      "branch": "claude/issue-5684-zod-mirror-drift-guard",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5929",
      "premise_still_valid": true,
      "summary": "GREEN in CI at 8c57c5118 — 19 checks, 0 failing, 0 pending; `Type Check`, the job that was red, is `completed success`. The premise holds: #5680's construction covered BaseSchema only, the siblings had nothing equivalent, and the class is live. The population unit is a PAIR, not a file: 216 exported consts across 17 files resolve to 163 registered mirror/declaration pairs plus 51 reasoned exclusions. #5680's construction is applied to all 163 through one shared definition; base-schema-zod-mirror-parity.test.ts keeps its #4605 runtime pins and no longer restates the type-level part. A runtime census reads the directory off disk and fails when an exported const is in neither map. 17 of 163 pairs carry measured drift, ledgered with exact key sets as a two-way ratchet rather than corrected — each is a widen-or-narrow contract question, filed as #5927 and since RULED C-then-B by the PM seat (route the five spec-derived pairs into #2231, per-key renderer measurement for the rest; PageNodeSchema.pageType stays ledgered as the sanctioned divergence). The first CI red was the ratchet's second direction firing correctly: CI builds refs/pull/N/merge, main advanced under the branch, and #5855 retired DashboardComponentSchema.aria from the TS declaration, so a key this ledger recorded as drifted was no longer drifted and the entry went stale. Corrected to the re-measured set, not narrowed. Two hardening changes came out of it: the assertion now NAMES the offending pair instead of printing a bare `Type 'false'`, and SPEC_DERIVED_PAIRS records the 10 registered pairs (3 of them ledgered) whose comparison depends on @objectstack/spec, re-derived by a test so it cannot rot.",
      "tests": "All at 8c57c5118. Exit codes captured BEFORE any pipe; verdict lines quoted as printed.\n\nCI: 19 checks green, 0 failing, 0 pending, polled synchronously in-turn to completion (never a background watcher). `Type Check` = completed success.\n\nLOCAL UNION on the merged head: `pnpm --filter @object-ui/types type-check` TYPECHECK_EXIT=0 with the script name echoed (`tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json`), so not a zero-match no-op. `pnpm exec vitest run --maxWorkers=2 packages/types/` VITEST_EXIT=0 -> 'Test Files 53 passed (53) / Tests 577 passed (577)'. `pnpm --filter @object-ui/types lint` LINT_EXIT=0 -> '253 problems (0 errors, 253 warnings)', all pre-existing. check-changeset-presence / no-major / fixed / control-bytes (4930 files) / lint-coverage (46/46) / type-check-coverage (41/41 tests compiled) all EXIT=0.\n\nCI-DIVERGENCE DIAGNOSIS, as measurement not hypothesis. Three candidates, all REFUTED: (1) local tree != pushed commit — a fresh worktree at the pushed SHA with `pnpm install --frozen-lockfile` gave CLEAN_TYPECHECK_EXIT=0, i.e. green; (2) node_modules/spec drift — installed @objectstack/spec package.json reports 17.2.0 and the lockfile pins 17.2.0 at the red SHA, at the merged head AND on main, with `git diff --stat ... -- pnpm-lock.yaml` EMPTY across the merge, so with --frozen-lockfile CI's resolution is identical BY CONSTRUCTION. My first version reading did come from the shared /home/user/objectui checkout and was wrong for exactly the stale-HEAD reason flagged; every figure above is re-derived from this branch's own worktree and from `git show <sha>:pnpm-lock.yaml`; (3) census order/environment — the failing assertion is type-level over static literals, unreachable by the runtime census.\n\nACTUAL CAUSE: CI builds refs/pull/N/merge, not the branch head (actions/checkout@v7 under on: pull_request). main advanced 0fce2ef81..2074a596d; commit 18a8e7db7 ('retire DashboardComponentSchema.aria — spec-tombstoned, renderer-dead', #5830/#5855) deletes `aria?: {` from the TS declaration, so aria left the comparison and the ledger entry recording it went stale.\n\nCONTROLLED EXPERIMENT (the proof): environment held completely fixed — same node_modules, @objectstack/spec 17.2.0, tsc 6.0.3, node v22.22.2 — reverting ONLY packages/types/src/complex.ts to its pre-merge content. On disk: 'aria?: {' present 1, retirement comment 0. TSC_EXIT=2 -> `Type '\"complex.zod.ts#DashboardComponentSchema\"' is not assignable to type 'never'`. Independent corroboration: #5855's OWN guard (dashboard-aria-retired-contract-twins.test.ts) went red on the same revert, so two guards by different authors agree aria is the variable. Restore -> 'aria?: {' present 0, `git diff HEAD --stat` EMPTY, TSC_EXIT=0.\n\nRE-MEASUREMENT: all 163 pairs re-resolved on the merged tree via the compiler API -> 17 drifted pairs, DashboardComponentSchema down from 4 keys to 3, and that is the ONLY entry that moved.\n\nABLATION RE-RUN after the assertion form changed (teeth re-proven, not assumed). Every leg asserted exactly one anchor match then grepped INJECTED and REMOVED text separately; script carried `trap ... EXIT INT TERM`. LEG A widen AlertSchema.variant -> PREDICTED red naming the pair; OBSERVED TSC_EXIT=2 with tsc itself printing `Type '\"data-display.zod.ts#AlertSchema\"' is not assignable to type 'never'`. LEG B widen TooltipSchema.delayDuration -> same, naming overlay.zod.ts#TooltipSchema. LEG D ablate the construction -> PREDICTED red REVERSED; OBSERVED all 17 ledgered pairs named. LEG E blind the census reader -> PREDICTED 1 red; OBSERVED 2 (the non-vacuity test and the stale-entry test), the deviation reported not smoothed. CONTROL restored -> `git diff HEAD --stat` EMPTY, TSC_EXIT=0. NEW: SPEC_DERIVED_PAIRS test ablated by dropping one entry -> PREDICTED that test alone red; OBSERVED 1 failed | 4 passed.\n\nPROCESS NOTE, reported because it nearly shipped a false claim: the SPEC_DERIVED_PAIRS work was still UNCOMMITTED when its own verify script's restore trap ran `git checkout HEAD -- <file>`, which silently reverted it — the exact hazard in reverse-verifying against uncommitted edits. Caught by 'nothing to commit, working tree clean' plus a grep count of 0, re-applied, COMMITTED FIRST, then re-verified. The 577-test figure above is from the committed tree with the addition present.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #5927: 17 zod mirrors refuse spellings their TypeScript declarations declare — full measured table, grouped strict-widening / disjoint / optionality / spec-derived / deliberate divergence. RULED C-then-B by the PM seat and recorded on the card. Also corrected there: its table listed DashboardComponentSchema.aria as drifted, which #5855 has since retired",
        "filed as #5928: zod `StylePropsSchema` ({className, style}) and TS `StyleProps` (a Tailwind vocabulary) share a name and ZERO keys — the naming asserts a mirror relationship that does not exist, and a name-derived pairing reported 5 keys of phantom drift on it"
      ]
    }

    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:queue

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions