Uh oh!
There was an error while loading. Please reload this page.
fix(sdui-parser): refuse an authored type attribute on the html tier instead of letting it overwrite the component discriminator - #14274
Conversation
#13957) On a `kind:'html'` page the tag name IS the node's `type`, so a `type` attribute is a name collision with the envelope's discriminator. The parser now refuses it at parse time with one `forbidden-attr` diagnostic naming both the tag and the attribute, replacing two outcomes: the silent one (the value named another registered type, so the manifest resolved it and a different component rendered with zero diagnostics) and the misdirected one (the value named nothing registered, so `unknown-component` blamed the value and read as a missing plugin). `parseElement` also builds the node as `{ ...props, type: tag }` — defense in depth, correct only because the attribute is now refused loudly. `validate.ts`'s `BASE_PROPS` is untouched, no `specType` alias is introduced, and there is no warning grace period. Maintainer ruling 2026-09-01, recorded as an append-only amendment on ADR-0080. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
…behind the refusal
Measured by ablation on the committed tree: restoring `{ type: tag, ...props }`
while leaving the refusal in place keeps all eight tests green, because the
refused attribute never reaches `props`. Removing the refusal turns 5 red, and
removing both halves turns 6 red. Stated in the test file so a green suite is
not read as proof the spread order is load-bearing on its own.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0974dadbaa4df2af1209daac21c366e4ef561a28 && git checkout 0974dadbaa4df2af1209daac21c366e4ef561a28
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6eb8e3cc5022c4b1d0007962220444f4f947036d 6dff387193f1edfe70bf605a10fc5d3e32e1c1e9 && git checkout -B drift-repro 6eb8e3cc5022c4b1d0007962220444f4f947036d && git merge --no-ff 6dff387193f1edfe70bf605a10fc5d3e32e1c1e9
node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036d |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13957
⛔ DRAFT — a human merges this. The diff touches
docs/adr/**(a governed surface, AGENTS.md Prime Directive #14), so no agent seat marks it ready, queues it, or arms auto-merge. It also carriesneeds:contract-reviewfrom creation per the ruling's Clause-② (point 6): the html tier's accept-set narrows, and the director seat reviews and clears that label — not this seat, not the PM.What changed
On a
kind:'html'page the tag name is the node'stype, so an authoredtype=attribute is a name collision with the envelope's own discriminator.parseElementused to build the node as{ type: tag, ...props }—propsspread after — so the author's value won the slot,compile()returned the tree as-is, andvalidateTreethen looked upmanifest.components[node.type]: the value the author wrote, not the tag they wrote.The parser now refuses the attribute at parse time, with one
forbidden-attrerror naming both the tag and the attribute. That single diagnostic replaces two outcomes:flexelement carryingtype="grid")gridresolved in the manifest, every check passed, and the page rendered a grid where the author wrote a flexobject-chartelement carryingtype="bar", the shape a react-tier author carries across)unknown-componentnaming"bar"— loud, but it reads as a missing plugin rather than as a bad propThe silent row is the one that matters: it happened on the one tier whose stated premise is that unreviewed, AI-authored source is safe to accept.
Alongside the refusal,⚠️ Reversing the spread alone would have been a regression of its own (the authored value would then be discarded in silence, trading one silence for another); it is correct only because the attribute is refused loudly one function up.
parseElementnow builds the node as{ ...props, type: tag }— the ruled defense-in-depth half.Per the ruling: ⛔ no
specTypealias is introduced on the html tier (that rescue is the react runtime's and stays there), ⛔ no warning grace period, and ⛔validate.ts'sBASE_PROPSis untouched —typebelongs on that never-warn list for every other member, which is exactly why the remedy is at parse and not at the warning layer.BaseSchemaneeded no change, so nothing here is handed todomain:spec.Census (ruling point 1) — population zero
Ordered before the fix, because a loud refusal is only cheap if nothing in the tree is already writing the shape. Measured with a real open-tag reader (quotes and braces balanced), not a line regex — a
type=on the third line of a multi-line element is precisely the occurrence a line regex misses.type=kind:'html'/kind:'jsx'page object with asourceliteral, found by TS-AST walk overexamples/**,content/**,packages/**,apps/**,docs/**,skills/**content/docs/**fenced snippets (they teach the shape even though nothing compiles them).md/.mdxfence (skills/**,docs/**, package READMEs, blog)All 19 hits in B and C are react-tier (
ObjectChart,Block,TextField,Button,DesignerEditorWrapper) or plain-HTML illustration (input type="text"in the ObjectQL types page). The three real html-tier pages inexamples/app-showcase—capability-map,command-center-jsx,start-here— carry notype=attribute on any element. So there is no migration surface, and no source correction was needed.One documentation correction was needed and is included:
content/docs/ui/react-pages.mdxstated the old behaviour in as many words ("atypeattribute overwrites it"), which this change makes false. It now names the refusal and the diagnostic.Why the diagnostic reuses
forbidden-attrinstead of minting a new codeThis is a measured constraint, not a shortcut.
scripts/check-sdui-lockstep.mjsholds this copy's diagnostic-code set byte-equal to objectui's at the pinned revision, because objectui's copy runs in the renderer while this one runs in the save gate — a code on one side only is the dialect split that gate exists to catch (#12719). A new code here reds it with[code-drift] only here …, and the remedy would be an objectui port plus a pin bump, neither of which this card owns.forbidden-attralready carries exactly this shape — an attribute this tier refuses, named beside its element — and both copies stamp it. Gate verdict on this branch:ADR-0080 amendment (ruling point 5)
A single append-only section at the end of
docs/adr/0080-ai-authored-ui-jsx-source.md, quoting the 2026-09-01 ruling verbatim and untranslated, recording the three boundaries it drew (nospecTypeon this tier, no grace period, not at the warning layer) and the zero-population census. Kept minimal and append-only on purpose: #13556's repo-wide ADR anchor migration is in flight ondocs/adr/**concurrently, and an appended tail keeps the conflict surface near zero.scripts/adr-anchors/packages__sdui-parser__src__parse.ts.jsonis added so the next reader ofparse.tsis told which decision the refusal stands on (Prime Directive #13).Verification
Everything below was measured on this branch; the union re-ran at final head
6dff387.pnpm --filter @objectstack/sdui-parser exec vitest run— 7 files, 138 tests passed (7 of them new).HEAD:packages/sdui-parser/src/parse.ts, restore proved bygit diff HEADempty, absolute paths in the trap). No rebuild is involved: these tests import the parser through relative source specifiers, not through the package'sexports, so nothing resolves todist/.propsto be spread. The test file says so in as many words, so a green suite is not misread as proof the order is load-bearing on its own.pnpm --filter @objectstack/lint exec vitest runon the parser's consumer (validate-jsx-pages, its production witness, page-walk parity) — 3 files, 17 tests passed.pnpm --filter @objectstack/sdui-parser exec tsc --noEmit— clean, and--listFilesconfirms the new test file is in the program (not excluded).pnpm lint(repo-wideeslint . --no-inline-config) — clean, full scan, not narrowed.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackderives from the real change set, pluscheck:ratchet-remedy-authority,check:declared-population-liveandcheck:nul-bytes— all green except these, each NOT MEASURED locally for a stated prerequisite rather than red, all of them covered by CI:check-test-completeness— needs a savedturbo run testlog (the gate's own text names this branch as NOT MEASURED)check:dual-build-cjs-loadsandcheck:type-check-debt --re-measure— need the whole workspace built (sdui-parsercarries no debt-ledger entry, so the ratchet has nothing to say about it)check:skill-examples— reaches and clears the skills + docs surface (the one this diff edits) and then stops on an unbuiltclient-react/distscripts/pm/check-half-states.mjs— a network gatecheck:sdui-lockstep,check:adr-anchors,check:doc-authoring,check:doc-security-posture,check:docs,check:doc-formula-expressionsand every ratchet family re-ran green at final head6dff387.Generated by Claude Code
Generated by Claude Code