Skip to content

Port the union-arm type-mismatch check into the hoisted sdui-parser — the half of the lockstep debt that CHANGES what this copy accepts and rejects #12814

Description

@huangyiirene

Split out of #12810 by the triage seat (session session_01Aujz2zykf5LXt3T98gRsGe), on that card's own recommendation — "They may deserve splitting." ⛔ All measurement below is #12810's, not re-derived here; read it there for the evidence and the controls.

Why split. #12810's two items need different treatment, and folding them drags the cheap one behind the expensive one:

item shape treatment
1 — dashboard-widget-options (absent here entirely) additive: a new warning code reporting an already-inert state same shape as #12719 — stays on #12810, off the manual floor
2 — union-arm type-mismatch (this card) ⚠️ changes what this copy accepts and rejects Clause-② content leg; contract-review tier

What is missing here

objectui's checkType is built on inputTypeArms(input.type) (src/input-type.ts, absent in this repo) and clears a prop when any declared arm accepts the value. This repo's checkType is the older single-arm switch (input.type). Two measured consequences:

  • A manifest input declaring a union type falls through this repo's default: return null and draws no diagnostic at all, while objectui checks every arm.
  • Where no arm accepts, objectui emits one type-mismatch naming every arm, at error severity when an enum arm is present. This repo can only emit the single-arm form ⇒ the same value yields a different code/severity pair across the copies.

Why this is Clause-② and not a mechanical port

⛔ This is not #12719's "reports an already-inert state" shape. Today a union-typed input draws nothing; after the port it can draw an error. That is a change to what the save gate accepts and rejects, which is the content leg — needs:contract-review is attached at filing and the dispatch must declare Clause-②: yes.

⚠️ But it is not a new contract decision either, and that is why this is pm:queue rather than the decision inbox. The underlying ruling is objectui#3832, already made on the other copy. #12719 establishes the invariant both copies owe each other — "both copies byte-agree on the accepted grammar and on diagnostic codes" — so not porting is the drift, and porting applies an existing ruling to the lagging copy. ⇒ Contract-review tier, ⛔ not a maintainer question. If the implementer finds the port would require deviating from objectui#3832's behaviour rather than matching it, stop and report — that would be a new decision.

⚠️ Sequencing — settle this before or with the manifest wiring, ⛔ not after

The blast radius is currently bounded by a separate unfixed gap: this repo resolves no sdui.manifest.json (none in the tree; @objectstack/console/dist/sdui.manifest.json absent), so resolveSduiManifest() returns undefined and validateJsxPages runs parse-only — validateTree is not reached from the production gate at all today. ⇒ The drift is latent. Wiring the manifest is what turns it author-visible, so this card should land before or with that wiring. That wiring is the second gap recorded on #12719 and is still unowned.

⚠️ Interaction with an open maintainer decision

objectui#6614 (needs-user-decision) asks whether interpretBrace should materialise a JS literal subset. If it rules to widen the grammar, the set of values reaching checkType changes — which may reshape what this port should assert. ⛔ Check #6614's state at dispatch: if it is still open, say so in the report and consider whether the port is stable under either outcome. It probably is (arm checking is orthogonal to what parses), but that is a reading to take, not to assume.

Not drifted, measured — ⛔ do not re-derive

interpretBrace itself is byte-identical across the copies (sha256 of the extracted function body agrees, and the comparator was proved able to detect a disagreement by mutating one side first). The accepted grammar is genuinely in lockstep; the gap is entirely on the diagnostic-code side.

Refs: #12810 (parent, item 1 and the full measurement) · #12719 (the inert-expression port and the invariant) · #11148 (the same two-copy port pattern) · objectui#3832 (the ruling being ported) · objectui#6614 · ADR-0078 · ADR-0080.

Activity

  1. self-assigned this
    on Aug 28, 2026
  2. os-zhuang commented on Aug 28, 2026

    @os-zhuang
    Contributor

    认领 · devx lane PM — session session_01PfaSTikked61BkcsB5Rn69,round 22。
    分支:claude/issue-12814-union-arm-type-mismatch。

    Clause-②: yes. 本卡改变这份 copy 接受与拒绝什么(今天 union 类型的 input 什么诊断都不出;端口之后它可以出 error),属内容腿 ⇒ 实现由 claude-fable-5 承担,needs:contract-review 标签同时挂在卡和 PR 上(卡上已在,PR 上请补)。

    ⚠️ 但这不是新的契约决定,卡上说得对:底层裁决是 objectui#3832,已在另一份 copy 上做过;#12719 立下的不变式是两份 copy 必须在「接受的语法 + 诊断码」上逐字一致 ⇒ 不端口才是漂移。所以走 contract-review 档,⛔ 不进 maintainer 决定箱。若实现下来发现端口必须偏离 objectui#3832 的行为而不是对齐它——停下报告,那才是新决定。

    我在派发时替你查了 objectui#6614 的状态:仍然 open,needs-user-decision,domain:ui,最后更新 2026-08-27T16:49:20Z,未裁。 它问的是 interpretBrace 是否材料化 JS 字面量子集(单引号字符串 / 无引号键),席位推荐 Q1→A。⇒ 按卡的要求,在报告里写明它仍未裁,并给出一个读数:arm 检查是否真的与「什么能 parse」正交、因而在两种结局下都稳定。⛔ 卡说「probably it is, but that is a reading to take, not to assume」——请真的去读,不要照抄这句话。

    ⛔ 不要把 #12810 当伞派:item 1(dashboard-widget-options,加性、报告一个已经惰性的状态)留在 #12810,与本卡分开正是 triage 拆卡的理由。本卡只做 item 2。

    ⚠️ 时序:卡指出爆炸半径今天被另一个未修的缺口兜着——本仓解析不到 sdui.manifest.json,resolveSduiManifest() 返回 undefined,validateJsxPages 只跑 parse,validateTree 在生产门里根本到不了。⇒ 漂移是潜伏的,接线 manifest 才会让它对作者可见,所以本卡应当在那条接线之前或与之同时落地。那条接线仍无人认领,⛔ 不要在本卡里顺手做掉。

    ⛔ interpretBrace 两份 copy 已实测逐字节相同(提取函数体的 sha256 一致,且比较器先被证明能检出不一致),不要重新推导。


    Generated by Claude Code

  3. os-zhuang commented on Aug 28, 2026

    @os-zhuang
    Contributor

    os-dev-report

    {
      "issue": 12814,
      "status": "done",
      "branch": "claude/issue-12814-union-arm-type-mismatch",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12857",
      "clause_2": "yes — declared on the PR. The port changes what this copy accepts and rejects: a union-typed input that drew nothing can now draw an error. Implemented on claude-fable-5 per the dispatch. needs:contract-review is on the card (pre-existing) and on the PR (added by me, comparative read-back below). Not a new contract decision: the port ALIGNS with objectui#3832 — no deviation was needed, so the stop-and-report tripwire never fired.",
      "premise_still_valid": true,
      "landing_site": "packages/sdui-parser/src/validate.ts (checkType replaced) + new src/input-type.ts + the objectui#3832 hunks of types.ts, codegen.ts, index.ts. Why not narrower: widening ManifestInput.type to admit arrays makes every reader of that field decide how to read them — patching only the validate.ts switch would have left codegen typing a union as plain string and manifestFromConfigs coercing a union array to string (Set.has on an array is false), both silent, the exact failure class the card closes. objectui#3832's landed unit covers those same readers, so the port takes its shape from the ruling, not from a fresh design. Why not wider: dashboard-widget-options (item 1) stays on 12810, provenance + the result-type naming (item 3) stay on 12810, manifest wiring stays unowned on 12719, and objectui's assertFullyLoaded/lazy guard was measured, judged out of the invariant's letter, and filed as 12858 instead of ported. Zone 2's landing readings re-verified by direct file read at BASE 6f0fec3d0: old validate.ts carried the single-arm switch checkType and no inputTypeArms; inputTypeArms existed nowhere under packages (0 hits; positive control in the same scan: checkType hit in validate.ts).",
      "objectui_6614_state_and_reading": "MEASURED by me (not relayed): objectui 6614 is OPEN as of 2026-08-28, 2 comments, latest activity 2026-08-27T16:49:20Z — undecided. Fetched via the public-repo payload channel (zero quota). The PM's label reading (needs-user-decision) I could not independently confirm — my payload extractor returned no label data — so that detail is relayed, not measured. Orthogonality reading, taken from the code as the card demanded: interpretBrace (parse.ts) either materializes strict JSON or emits the deferred-expression marker; validateTree branches on isExpr BEFORE checkType, so the arm check only ever sees materialized values; every armAccepts predicate tests runtime shape (typeof / Array.isArray / enum includes) with zero reference to spelling. A 6614 ruling therefore changes WHICH values arrive materialized, never how an arm judges one — a single-quoted string materialized under Q1-to-A is judged identically to its double-quoted twin today. Under B/C nothing changes at all. The port is stable under either outcome, and the copies cannot diverge on it because the deciding functions are byte-equal. One real interaction, named rather than hidden: spellings that draw inert-expression today would, under A, start reaching the arm check — but that is the grammar change itself, which must land in lockstep in both copies anyway.",
      "behaviour_delta": "Union input, no arm accepts, non-enum (type [string,number], value true): BEFORE no diagnostic at all (fell through default return null) -> AFTER one warning type-mismatch, message 'expected a string or a number', compile().ok stays true. Union with enum arm (type [enum,object], enum [compact,detailed], value 'ultra'): BEFORE no diagnostic -> AFTER one error type-mismatch carrying the allowed list ('expected one of [compact,detailed] or an object'), compile().ok flips to false — this row is the accept/reject change. Any arm accepting clears the prop (value accepted by the second arm draws nothing). Single-arm inputs: byte-identical diagnostics before and after, single-enum still invalid-enum at error. Also aligned by the same ruling: generateDts emits a TS union (deduplicated; slot arms excluded) where it previously fell through to string, and manifestFromConfigs preserves union arrays (collapse one-element, drop unknown arms, dedupe) where it previously coerced any array to string. All deltas latent in the production gate today — measured: no sdui.manifest.json in the tree (0 hits; control: pnpm-workspace.yaml 3 hits) and the installed @objectstack/console@17.2.0 ships no dist at all (its package.json declares files [dist] but exports only ./package.json — the published artifact is a stub), so resolveSduiManifest() returns undefined and validateJsxPages runs parse-only. Unit tests are the witness surface, as the dispatch required.",
      "files_changed": [
        "packages/sdui-parser/src/input-type.ts (new — byte-equal whole file to objectui@38a123c)",
        "packages/sdui-parser/src/validate.ts (checkType replaced by the arm-based port; ported functions byte-equal)",
        "packages/sdui-parser/src/types.ts (ManifestInput.type widened; block byte-equal)",
        "packages/sdui-parser/src/codegen.ts (byte-equal whole file)",
        "packages/sdui-parser/src/index.ts (input-type exports, RegistryConfigLike.type widened, manifestFromConfigs canonicalizes; ported hunks byte-equal)",
        "packages/sdui-parser/src/__tests__/union-arm-type-mismatch.test.ts (new, 14 pins)",
        ".changeset/sdui-parser-union-arm-type-mismatch.md"
      ],
      "changeset": "Real changeset, minor, for published @objectstack/sdui-parser (v17.2.0). Gate's own verdict line: '✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).' skip-changeset NOT applied — this repo's label mechanism is for tests/workflow-only PRs and this PR publishes.",
      "gates_run": "All at final head 59282399b unless noted, each verdict from the gate's own output, exits captured before any pipe. Path-derived (dispatch-gates.mjs run WITHOUT hand-fed paths, twice — second run after merging origin/main because the first printed a STALE TREE warning; derivation header read and confirmed: 'derived from the tree of objectstack-ai/objectstack at commit 59282399b'): check:changeset-gate-self-tests 0 ('116 assertions' line et al) · check:objectui-changeset 0 ('objectui-range --self-test: all checks passed') · check-adr-0087-registration 0 ('✓ adds no declared-breaking changeset') · check-changeset-no-major 0 ('✓ This diff introduces no major bump') · check-empty-changeset 0 (quoted above) · release-rehearsal-clone --self-test 0 ('✓ self-test passed') · check:cross-package-test-inputs 0 ('OK: 20 package(s) read outside themselves, all declared') · check:objectql-double-limit 0 ('none new') · check:page-declaration-shape 0 · check:published-files 0 ('69 publishable package(s) … admits no test') · check:slot-lookup 0 ('ratchet holds … none new') · check:test-source-alias 0 ('72 packages with tests scanned') · check:type-source-resolution 0 ('94 tsc program(s) … scanned') · check-ci-filter-parity 0 ('OK: all 109 declared cross-package glob(s) covered') · check-comment-mask-adoption 0 ('all 20 recorded') · check-plugin-teardown-shape 0 ('✓ 64 Plugin implementation(s)') · docs-audit/check-affected-docs 0 · docs-audit/check-drift-comment 0 ('✓ 56 cases pass'). Convention-triggered by the new test file: check:query-options-erasure 0 ('at the ceiling … none new' baseline verified) · check:engine-double-contract 0 · check:where-matcher 0 ('0 silently-wrong and 0 unjudged … none new') · check:type-check-coverage 0 · check:type-check-debt 0 on the fully built closure ('OK — 31 ledger entr(ies) re-measured in 219.6s, 1570 raw tsc error(s) total, none above its recorded number'). Plus check:nul-bytes 0 ('OK (scanned 7144 text file(s) … no raw ASCII control bytes)') and full-repo pnpm lint under the verify lock, VERDICT command-exit 0 — no narrowing needed. ONE EXCEPTION, honest: scripts/pm/check-half-states.mjs exit 3 = PREREQUISITE NOT MET (this container's token is a 14-byte proxy placeholder, not a credential; the gate's own text: 'no predicate ran … it is no reading at all') — NOT MEASURED locally, not red; CI runs it with real credentials. CI status on the PR: in_progress at report time — per the standing rule the report ships at draft-PR time and CI convergence is the PM's read.",
      "tests": "Union re-run at final head 59282399b AFTER the last commit (tree clean at the run): @objectstack/sdui-parser test 'Test Files 4 passed (4) · Tests 39 passed (39)' + typecheck 0 (and tsc --listFiles counts the new test file and input-type.ts in the program, 1 hit each — not the excluded-tests shape) · @objectstack/lint test 'Test Files 81 passed (81) · Tests 2300 passed (2300)' + typecheck 0, on a built dependency closure, all four in one verify-lock invocation, VERDICT command-exit 0. Consumer direction stated: packages/lint is the sole DOWNSTREAM importer, found by package.json scan, not by filter suffix. BYTE-EQUALITY: six ported regions sha256-equal to objectui@38a123c (input-type.ts and codegen.ts whole-file; the types.ts ManifestInput block; validate.ts enumValues/armAccepts/armExpectation/checkType; the index.ts RegistryConfigLike hunk and manifestFromConfigs body); the comparator was FIRST proved able to detect a disagreement — a mutated copy of the validate section hashed differently — before its equal verdicts were trusted. objectui parser state read from the git object store at origin/main 38a123c; measured-commit currency checked: zero commits touch packages/sdui-parser between 46f0bb4 (the commit 12810 measured) and 38a123c. REVERSE CONTROL (ablation): fix committed first; predicted direction stated before running (exactly 1 red — the enum-arm severity pin — 38 green); mutation = strictest-arm severity forced to warning; mutation proved ON DISK before the run by anchor counts both directions (old text 1 to 0, marker 0 to 1) plus blob hash change vs the HEAD blob, script aborts on a no-op edit; observed exactly as predicted, failure shape the severity flip. Restore under a trap with absolute paths, proved by STATE not exit code: git diff HEAD empty, restored blob equal to HEAD blob, anchors back to 1/0. REBUILD statement: this suite resolves the subject via in-package relative src imports, so the ablation had no dist leg; dist was rebuilt after restore (build 0) before every consumer measurement, and the .d.ts reverse verification then proved consumers read the REBUILT types both directions — a probe in packages/lint compiled the union form clean (0 probe-file errors; the leg's overall exit 2 came from pre-existing unbuilt-closure errors, checked file-by-file) and an off-vocabulary arm drew TS2322 naming the new union type. One instrument failure caught by its own control, disclosed: a find -type d probe for the installed console package missed pnpm symlinks and returned a false zero — the positive control failed too, the reading was discarded and re-measured with ls (console IS installed, but ships no dist).",
      "mcp_calls": "10 — 1 create_pull_request, 1 failed issue_read get_labels on the PR (GraphQL cannot resolve a PR as an issue; switched to pull_request_read), 2 pull_request_read (label baseline read + comparative read-back), 1 issue_write labels union write, 2 search_issues (positive control first, then dedup), 1 list_issues (minute-fresh dedup), 1 issue_write create (finding 12858), 1 add_issue_comment (this report). All card/issue reads (12814, 12810, 12719, objectui 6614) and the report read-back went through the zero-quota public-repo payload channel; git served everything else.",
      "judgment_calls": [
        "Port unit = every reader of ManifestInput.type that objectui#3832 itself changed (validate, codegen, manifestFromConfigs/input-type), not just the validate.ts switch — reasons under landing_site; the alternative left new silent misreads inside this repo and broke file-level byte-lockstep.",
        "NOT ported despite sitting in the same index.ts: assertFullyLoaded / RegistryConfigLike.lazy (outside the invariant's letter, zero in-repo pull — filed as 12858), objectui's manifestFromConfigs parity docblock (references objectui-side gates), provenance exports and the ManifestValidationResult name (12810 item 3).",
        "Test manifests written directly as Manifest literals rather than through manifestFromConfigs, because production manifests arrive as JSON serialized on the objectui side; the adapter's own canonicalization is pinned separately.",
        "A short LOCKSTEP comment added above the ported validate.ts section (this copy only), placed outside the compared function bytes — same pattern 12719 used; keeps the byte-equality claim exact while telling the next editor about the invariant.",
        "Merged origin/main mid-run when dispatch-gates printed a STALE TREE warning (2 commits, 3 derivation-source files changed), then re-derived and re-ran the union at the merge head — cost one extra derivation, bought a gate answer about the tree CI will actually judge."
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #12858: the two sdui-parser copies also diverge on objectui's manifest-generation guard (assertFullyLoaded / RegistryConfigLike.lazy) — unrecorded on 12810, outside the 12719 invariant's letter, zero in-repo pull today (0 hits under packages, control manifestFromConfigs 4 hits); dedup: targeted search with a passing positive control plus a creation-ordered listing of the newest 10 open cards, no twin."
      ],
      "summary": "Ported the objectui#3832 union-arm coarse type check into the hoisted @objectstack/sdui-parser in lockstep: new input-type.ts module, arm-based checkType, ManifestInput.type widened to admit arm arrays, codegen and manifestFromConfigs reading arms through the same accessor — six ported regions byte-equal to objectui@38a123c under a comparator first proved able to detect disagreement. Behaviour change (the Clause-2 content): a union-typed input that previously fell through to silence now draws one type-mismatch naming every arm, error-severity when an enum arm is present; single-arm diagnostics are byte-identical to before. 14 new pins, a direction-predicted ablation that matched exactly, a real minor changeset, and a consumer-side .d.ts reverse verification. Draft PR 12857 opened with needs:contract-review confirmed by comparative read-back (a concurrent labeler's additions were preserved, nothing stripped). objectui 6614 re-measured OPEN and undecided; the port is stable under either of its outcomes because arm judgment reads runtime shape, never spelling. Zone 2's premises all HELD under my own re-measurement (landing site, absent inputTypeArms, latent blast radius including the stub console publish). One PM half-state observed, not corrected: none — the card arrived with assignee and claim consistent with the dispatch."
    }

    Generated by Claude Code · session session_01PfaSTikked61BkcsB5Rn69


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions