fix(sdui-parser): refuse an authored type attribute on the html tier instead of letting it overwrite the component discriminator - #14274

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-13957-html-tier-type-collision
Sep 2, 2026
Merged

fix(sdui-parser): refuse an authored type attribute on the html tier instead of letting it overwrite the component discriminator#14274
os-zhuang merged 2 commits into
mainfrom
claude/issue-13957-html-tier-type-collision

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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 carries needs:contract-review from 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's type, so an authored type= attribute is a name collision with the envelope's own discriminator. parseElement used to build the node as { type: tag, ...props }props spread after — so the author's value won the slot, compile() returned the tree as-is, and validateTree then looked up manifest.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-attr error naming both the tag and the attribute. That single diagnostic replaces two outcomes:

authoredbeforenow
the value names another registered type (a flex element carrying type="grid")zero diagnosticsgrid resolved in the manifest, every check passed, and the page rendered a grid where the author wrote a flexrefused, naming both names
the value names nothing registered (an object-chart element carrying type="bar", the shape a react-tier author carries across)unknown-component naming "bar" — loud, but it reads as a missing plugin rather than as a bad proprefused, naming both names

The 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, parseElement now builds the node as { ...props, type: tag } — the ruled defense-in-depth half. ⚠️ 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.

Per the ruling: ⛔ no specType alias is introduced on the html tier (that rescue is the react runtime's and stays there), ⛔ no warning grace period, and ⛔ validate.ts's BASE_PROPS is untouchedtype belongs on that never-warn list for every other member, which is exactly why the remedy is at parse and not at the warning layer.

BaseSchema needed no change, so nothing here is handed to domain: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.

populationhow it was enumeratedelements carrying type=
A. real html-tier page sources — every kind:'html' / kind:'jsx' page object with a source literal, found by TS-AST walk over examples/**, content/**, packages/**, apps/**, docs/**, skills/**AST, not grep0
B. content/docs/** fenced snippets (they teach the shape even though nothing compiles them)every fence, every open tag11 hits, 0 html-tier
C. supplementary sweep — every other tracked .md/.mdx fence (skills/**, docs/**, package READMEs, blog)same scanner8 hits, 0 html-tier

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 in examples/app-showcasecapability-map, command-center-jsx, start-here — carry no type= 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.mdx stated the old behaviour in as many words ("a type attribute overwrites it"), which this change makes false. It now names the refusal and the diagnostic.

Why the diagnostic reuses forbidden-attr instead of minting a new code

This is a measured constraint, not a shortcut. scripts/check-sdui-lockstep.mjs holds 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-attr already carries exactly this shape — an attribute this tier refuses, named beside its element — and both copies stamp it. Gate verdict on this branch:

check:sdui-lockstep: OK — this copy is byte-identical to objectui@d8ec8d6d4f01 (2026-09-01T08:23:58+00:00)
over 214 grammar line(s) [blob 0131f27cf86d] and agrees on all 24 diagnostic code(s), across 7 non-test source(s).

⚠️ One consequence recorded rather than left implicit: objectui's renderer-side copy still accepts the attribute, so the two tiers now differ in strictness. The direction is the safe one — the save gate is stricter than the renderer, so a refused page never reaches a renderer that would misread it, which is the opposite of the save-clean-renders-inert failure the lockstep exists to prevent. Converging objectui's copy is filed separately rather than smuggled in here.

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 (no specType on 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 on docs/adr/** concurrently, and an appended tail keeps the conflict surface near zero.

scripts/adr-anchors/packages__sdui-parser__src__parse.ts.json is added so the next reader of parse.ts is 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 run7 files, 138 tests passed (7 of them new).
  • Ablation, from the committed tree, with the mutation proved on disk each leg (blob id vs HEAD:packages/sdui-parser/src/parse.ts, restore proved by git diff HEAD empty, absolute paths in the trap). No rebuild is involved: these tests import the parser through relative source specifiers, not through the package's exports, so nothing resolves to dist/.
    • refusal removed, new spread order kept → 5 red
    • refusal removed and old spread order restored → 6 red
    • refusal kept, old spread order restored → 8 green
      ⚠️ That third leg is the honest reading of the defense-in-depth half: the spread order is unobservable through the public API while the refusal stands, because the refused attribute never reaches props to 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 run on 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 --listFiles confirms the new test file is in the program (not excluded).
  • pnpm lint (repo-wide eslint . --no-inline-config) — clean, full scan, not narrowed.
  • The 68 gate familiesnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derives from the real change set, plus check:ratchet-remedy-authority, check:declared-population-live and check: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 saved turbo run test log (the gate's own text names this branch as NOT MEASURED)
    • check:dual-build-cjs-loads and check:type-check-debt --re-measure — need the whole workspace built (sdui-parser carries 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 unbuilt client-react/dist
    • scripts/pm/check-half-states.mjs — a network gate
  • check:sdui-lockstep, check:adr-anchors, check:doc-authoring, check:doc-security-posture, check:docs, check:doc-formula-expressions and every ratchet family re-ran green at final head 6dff387.

Generated by Claude Code


Generated by Claude Code

#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
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0974dadbaa4df2af1209daac21c366e4ef561a28 — the merge of head 6dff387193f1edfe70bf605a10fc5d3e32e1c1e9 into base 6eb8e3cc5022c4b1d0007962220444f4f947036d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-zhuang
os-zhuang marked this pull request as ready for review September 2, 2026 11:25
@os-zhuang
os-zhuang added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 0e68ed2Sep 2, 2026
43 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-13957-html-tier-type-collision branch September 2, 2026 12:23
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

html-tier page source: a type attribute overwrites the SDUI component discriminator — silently when the value names another registered type

3 participants

@os-zhuang@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(sdui-parser): refuse an authored type attribute on the html tier instead of letting it overwrite the component discriminator - #14274

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-13957-html-tier-type-collision
Sep 2, 2026
Merged

fix(sdui-parser): refuse an authored type attribute on the html tier instead of letting it overwrite the component discriminator#14274
os-zhuang merged 2 commits into
mainfrom
claude/issue-13957-html-tier-type-collision

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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 carries needs:contract-review from 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's type, so an authored type= attribute is a name collision with the envelope's own discriminator. parseElement used to build the node as { type: tag, ...props }props spread after — so the author's value won the slot, compile() returned the tree as-is, and validateTree then looked up manifest.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-attr error naming both the tag and the attribute. That single diagnostic replaces two outcomes:

authoredbeforenow
the value names another registered type (a flex element carrying type="grid")zero diagnosticsgrid resolved in the manifest, every check passed, and the page rendered a grid where the author wrote a flexrefused, naming both names
the value names nothing registered (an object-chart element carrying type="bar", the shape a react-tier author carries across)unknown-component naming "bar" — loud, but it reads as a missing plugin rather than as a bad proprefused, naming both names

The 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, parseElement now builds the node as { ...props, type: tag } — the ruled defense-in-depth half. ⚠️ 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.

Per the ruling: ⛔ no specType alias is introduced on the html tier (that rescue is the react runtime's and stays there), ⛔ no warning grace period, and ⛔ validate.ts's BASE_PROPS is untouchedtype belongs on that never-warn list for every other member, which is exactly why the remedy is at parse and not at the warning layer.

BaseSchema needed no change, so nothing here is handed to domain: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.

populationhow it was enumeratedelements carrying type=
A. real html-tier page sources — every kind:'html' / kind:'jsx' page object with a source literal, found by TS-AST walk over examples/**, content/**, packages/**, apps/**, docs/**, skills/**AST, not grep0
B. content/docs/** fenced snippets (they teach the shape even though nothing compiles them)every fence, every open tag11 hits, 0 html-tier
C. supplementary sweep — every other tracked .md/.mdx fence (skills/**, docs/**, package READMEs, blog)same scanner8 hits, 0 html-tier

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 in examples/app-showcasecapability-map, command-center-jsx, start-here — carry no type= 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.mdx stated the old behaviour in as many words ("a type attribute overwrites it"), which this change makes false. It now names the refusal and the diagnostic.

Why the diagnostic reuses forbidden-attr instead of minting a new code

This is a measured constraint, not a shortcut. scripts/check-sdui-lockstep.mjs holds 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-attr already carries exactly this shape — an attribute this tier refuses, named beside its element — and both copies stamp it. Gate verdict on this branch:

check:sdui-lockstep: OK — this copy is byte-identical to objectui@d8ec8d6d4f01 (2026-09-01T08:23:58+00:00)
over 214 grammar line(s) [blob 0131f27cf86d] and agrees on all 24 diagnostic code(s), across 7 non-test source(s).

⚠️ One consequence recorded rather than left implicit: objectui's renderer-side copy still accepts the attribute, so the two tiers now differ in strictness. The direction is the safe one — the save gate is stricter than the renderer, so a refused page never reaches a renderer that would misread it, which is the opposite of the save-clean-renders-inert failure the lockstep exists to prevent. Converging objectui's copy is filed separately rather than smuggled in here.

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 (no specType on 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 on docs/adr/** concurrently, and an appended tail keeps the conflict surface near zero.

scripts/adr-anchors/packages__sdui-parser__src__parse.ts.json is added so the next reader of parse.ts is 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 run7 files, 138 tests passed (7 of them new).
  • Ablation, from the committed tree, with the mutation proved on disk each leg (blob id vs HEAD:packages/sdui-parser/src/parse.ts, restore proved by git diff HEAD empty, absolute paths in the trap). No rebuild is involved: these tests import the parser through relative source specifiers, not through the package's exports, so nothing resolves to dist/.
    • refusal removed, new spread order kept → 5 red
    • refusal removed and old spread order restored → 6 red
    • refusal kept, old spread order restored → 8 green
      ⚠️ That third leg is the honest reading of the defense-in-depth half: the spread order is unobservable through the public API while the refusal stands, because the refused attribute never reaches props to 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 run on 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 --listFiles confirms the new test file is in the program (not excluded).
  • pnpm lint (repo-wide eslint . --no-inline-config) — clean, full scan, not narrowed.
  • The 68 gate familiesnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derives from the real change set, plus check:ratchet-remedy-authority, check:declared-population-live and check: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 saved turbo run test log (the gate's own text names this branch as NOT MEASURED)
    • check:dual-build-cjs-loads and check:type-check-debt --re-measure — need the whole workspace built (sdui-parser carries 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 unbuilt client-react/dist
    • scripts/pm/check-half-states.mjs — a network gate
  • check:sdui-lockstep, check:adr-anchors, check:doc-authoring, check:doc-security-posture, check:docs, check:doc-formula-expressions and every ratchet family re-ran green at final head 6dff387.

Generated by Claude Code


Generated by Claude Code

#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
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0974dadbaa4df2af1209daac21c366e4ef561a28 — the merge of head 6dff387193f1edfe70bf605a10fc5d3e32e1c1e9 into base 6eb8e3cc5022c4b1d0007962220444f4f947036d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-zhuang
os-zhuang marked this pull request as ready for review September 2, 2026 11:25
@os-zhuang
os-zhuang added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 0e68ed2Sep 2, 2026
43 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-13957-html-tier-type-collision branch September 2, 2026 12:23
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

html-tier page source: a type attribute overwrites the SDUI component discriminator — silently when the value names another registered type

3 participants

@os-zhuang@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(sdui-parser): refuse an authored type attribute on the html tier instead of letting it overwrite the component discriminator - #14274

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-13957-html-tier-type-collision
Sep 2, 2026
Merged

fix(sdui-parser): refuse an authored type attribute on the html tier instead of letting it overwrite the component discriminator#14274
os-zhuang merged 2 commits into
mainfrom
claude/issue-13957-html-tier-type-collision

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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 carries needs:contract-review from 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's type, so an authored type= attribute is a name collision with the envelope's own discriminator. parseElement used to build the node as { type: tag, ...props }props spread after — so the author's value won the slot, compile() returned the tree as-is, and validateTree then looked up manifest.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-attr error naming both the tag and the attribute. That single diagnostic replaces two outcomes:

authoredbeforenow
the value names another registered type (a flex element carrying type="grid")zero diagnosticsgrid resolved in the manifest, every check passed, and the page rendered a grid where the author wrote a flexrefused, naming both names
the value names nothing registered (an object-chart element carrying type="bar", the shape a react-tier author carries across)unknown-component naming "bar" — loud, but it reads as a missing plugin rather than as a bad proprefused, naming both names

The 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, parseElement now builds the node as { ...props, type: tag } — the ruled defense-in-depth half. ⚠️ 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.

Per the ruling: ⛔ no specType alias is introduced on the html tier (that rescue is the react runtime's and stays there), ⛔ no warning grace period, and ⛔ validate.ts's BASE_PROPS is untouchedtype belongs on that never-warn list for every other member, which is exactly why the remedy is at parse and not at the warning layer.

BaseSchema needed no change, so nothing here is handed to domain: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.

populationhow it was enumeratedelements carrying type=
A. real html-tier page sources — every kind:'html' / kind:'jsx' page object with a source literal, found by TS-AST walk over examples/**, content/**, packages/**, apps/**, docs/**, skills/**AST, not grep0
B. content/docs/** fenced snippets (they teach the shape even though nothing compiles them)every fence, every open tag11 hits, 0 html-tier
C. supplementary sweep — every other tracked .md/.mdx fence (skills/**, docs/**, package READMEs, blog)same scanner8 hits, 0 html-tier

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 in examples/app-showcasecapability-map, command-center-jsx, start-here — carry no type= 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.mdx stated the old behaviour in as many words ("a type attribute overwrites it"), which this change makes false. It now names the refusal and the diagnostic.

Why the diagnostic reuses forbidden-attr instead of minting a new code

This is a measured constraint, not a shortcut. scripts/check-sdui-lockstep.mjs holds 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-attr already carries exactly this shape — an attribute this tier refuses, named beside its element — and both copies stamp it. Gate verdict on this branch:

check:sdui-lockstep: OK — this copy is byte-identical to objectui@d8ec8d6d4f01 (2026-09-01T08:23:58+00:00)
over 214 grammar line(s) [blob 0131f27cf86d] and agrees on all 24 diagnostic code(s), across 7 non-test source(s).

⚠️ One consequence recorded rather than left implicit: objectui's renderer-side copy still accepts the attribute, so the two tiers now differ in strictness. The direction is the safe one — the save gate is stricter than the renderer, so a refused page never reaches a renderer that would misread it, which is the opposite of the save-clean-renders-inert failure the lockstep exists to prevent. Converging objectui's copy is filed separately rather than smuggled in here.

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 (no specType on 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 on docs/adr/** concurrently, and an appended tail keeps the conflict surface near zero.

scripts/adr-anchors/packages__sdui-parser__src__parse.ts.json is added so the next reader of parse.ts is 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 run7 files, 138 tests passed (7 of them new).
  • Ablation, from the committed tree, with the mutation proved on disk each leg (blob id vs HEAD:packages/sdui-parser/src/parse.ts, restore proved by git diff HEAD empty, absolute paths in the trap). No rebuild is involved: these tests import the parser through relative source specifiers, not through the package's exports, so nothing resolves to dist/.
    • refusal removed, new spread order kept → 5 red
    • refusal removed and old spread order restored → 6 red
    • refusal kept, old spread order restored → 8 green
      ⚠️ That third leg is the honest reading of the defense-in-depth half: the spread order is unobservable through the public API while the refusal stands, because the refused attribute never reaches props to 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 run on 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 --listFiles confirms the new test file is in the program (not excluded).
  • pnpm lint (repo-wide eslint . --no-inline-config) — clean, full scan, not narrowed.
  • The 68 gate familiesnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derives from the real change set, plus check:ratchet-remedy-authority, check:declared-population-live and check: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 saved turbo run test log (the gate's own text names this branch as NOT MEASURED)
    • check:dual-build-cjs-loads and check:type-check-debt --re-measure — need the whole workspace built (sdui-parser carries 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 unbuilt client-react/dist
    • scripts/pm/check-half-states.mjs — a network gate
  • check:sdui-lockstep, check:adr-anchors, check:doc-authoring, check:doc-security-posture, check:docs, check:doc-formula-expressions and every ratchet family re-ran green at final head 6dff387.

Generated by Claude Code


Generated by Claude Code

#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
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0974dadbaa4df2af1209daac21c366e4ef561a28 — the merge of head 6dff387193f1edfe70bf605a10fc5d3e32e1c1e9 into base 6eb8e3cc5022c4b1d0007962220444f4f947036d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-zhuang
os-zhuang marked this pull request as ready for review September 2, 2026 11:25
@os-zhuang
os-zhuang added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 0e68ed2Sep 2, 2026
43 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-13957-html-tier-type-collision branch September 2, 2026 12:23
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

html-tier page source: a type attribute overwrites the SDUI component discriminator — silently when the value names another registered type

3 participants

@os-zhuang@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(sdui-parser): refuse an authored type attribute on the html tier instead of letting it overwrite the component discriminator - #14274

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-13957-html-tier-type-collision
Sep 2, 2026
Merged

fix(sdui-parser): refuse an authored type attribute on the html tier instead of letting it overwrite the component discriminator#14274
os-zhuang merged 2 commits into
mainfrom
claude/issue-13957-html-tier-type-collision

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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 carries needs:contract-review from 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's type, so an authored type= attribute is a name collision with the envelope's own discriminator. parseElement used to build the node as { type: tag, ...props }props spread after — so the author's value won the slot, compile() returned the tree as-is, and validateTree then looked up manifest.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-attr error naming both the tag and the attribute. That single diagnostic replaces two outcomes:

authoredbeforenow
the value names another registered type (a flex element carrying type="grid")zero diagnosticsgrid resolved in the manifest, every check passed, and the page rendered a grid where the author wrote a flexrefused, naming both names
the value names nothing registered (an object-chart element carrying type="bar", the shape a react-tier author carries across)unknown-component naming "bar" — loud, but it reads as a missing plugin rather than as a bad proprefused, naming both names

The 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, parseElement now builds the node as { ...props, type: tag } — the ruled defense-in-depth half. ⚠️ 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.

Per the ruling: ⛔ no specType alias is introduced on the html tier (that rescue is the react runtime's and stays there), ⛔ no warning grace period, and ⛔ validate.ts's BASE_PROPS is untouchedtype belongs on that never-warn list for every other member, which is exactly why the remedy is at parse and not at the warning layer.

BaseSchema needed no change, so nothing here is handed to domain: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.

populationhow it was enumeratedelements carrying type=
A. real html-tier page sources — every kind:'html' / kind:'jsx' page object with a source literal, found by TS-AST walk over examples/**, content/**, packages/**, apps/**, docs/**, skills/**AST, not grep0
B. content/docs/** fenced snippets (they teach the shape even though nothing compiles them)every fence, every open tag11 hits, 0 html-tier
C. supplementary sweep — every other tracked .md/.mdx fence (skills/**, docs/**, package READMEs, blog)same scanner8 hits, 0 html-tier

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 in examples/app-showcasecapability-map, command-center-jsx, start-here — carry no type= 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.mdx stated the old behaviour in as many words ("a type attribute overwrites it"), which this change makes false. It now names the refusal and the diagnostic.

Why the diagnostic reuses forbidden-attr instead of minting a new code

This is a measured constraint, not a shortcut. scripts/check-sdui-lockstep.mjs holds 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-attr already carries exactly this shape — an attribute this tier refuses, named beside its element — and both copies stamp it. Gate verdict on this branch:

check:sdui-lockstep: OK — this copy is byte-identical to objectui@d8ec8d6d4f01 (2026-09-01T08:23:58+00:00)
over 214 grammar line(s) [blob 0131f27cf86d] and agrees on all 24 diagnostic code(s), across 7 non-test source(s).

⚠️ One consequence recorded rather than left implicit: objectui's renderer-side copy still accepts the attribute, so the two tiers now differ in strictness. The direction is the safe one — the save gate is stricter than the renderer, so a refused page never reaches a renderer that would misread it, which is the opposite of the save-clean-renders-inert failure the lockstep exists to prevent. Converging objectui's copy is filed separately rather than smuggled in here.

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 (no specType on 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 on docs/adr/** concurrently, and an appended tail keeps the conflict surface near zero.

scripts/adr-anchors/packages__sdui-parser__src__parse.ts.json is added so the next reader of parse.ts is 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 run7 files, 138 tests passed (7 of them new).
  • Ablation, from the committed tree, with the mutation proved on disk each leg (blob id vs HEAD:packages/sdui-parser/src/parse.ts, restore proved by git diff HEAD empty, absolute paths in the trap). No rebuild is involved: these tests import the parser through relative source specifiers, not through the package's exports, so nothing resolves to dist/.
    • refusal removed, new spread order kept → 5 red
    • refusal removed and old spread order restored → 6 red
    • refusal kept, old spread order restored → 8 green
      ⚠️ That third leg is the honest reading of the defense-in-depth half: the spread order is unobservable through the public API while the refusal stands, because the refused attribute never reaches props to 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 run on 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 --listFiles confirms the new test file is in the program (not excluded).
  • pnpm lint (repo-wide eslint . --no-inline-config) — clean, full scan, not narrowed.
  • The 68 gate familiesnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derives from the real change set, plus check:ratchet-remedy-authority, check:declared-population-live and check: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 saved turbo run test log (the gate's own text names this branch as NOT MEASURED)
    • check:dual-build-cjs-loads and check:type-check-debt --re-measure — need the whole workspace built (sdui-parser carries 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 unbuilt client-react/dist
    • scripts/pm/check-half-states.mjs — a network gate
  • check:sdui-lockstep, check:adr-anchors, check:doc-authoring, check:doc-security-posture, check:docs, check:doc-formula-expressions and every ratchet family re-ran green at final head 6dff387.

Generated by Claude Code


Generated by Claude Code

#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
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0974dadbaa4df2af1209daac21c366e4ef561a28 — the merge of head 6dff387193f1edfe70bf605a10fc5d3e32e1c1e9 into base 6eb8e3cc5022c4b1d0007962220444f4f947036d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-zhuang
os-zhuang marked this pull request as ready for review September 2, 2026 11:25
@os-zhuang
os-zhuang added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 0e68ed2Sep 2, 2026
43 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-13957-html-tier-type-collision branch September 2, 2026 12:23
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

html-tier page source: a type attribute overwrites the SDUI component discriminator — silently when the value names another registered type

3 participants

@os-zhuang@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(sdui-parser): refuse an authored type attribute on the html tier instead of letting it overwrite the component discriminator - #14274

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-13957-html-tier-type-collision
Sep 2, 2026
Merged

fix(sdui-parser): refuse an authored type attribute on the html tier instead of letting it overwrite the component discriminator#14274
os-zhuang merged 2 commits into
mainfrom
claude/issue-13957-html-tier-type-collision

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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 carries needs:contract-review from 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's type, so an authored type= attribute is a name collision with the envelope's own discriminator. parseElement used to build the node as { type: tag, ...props }props spread after — so the author's value won the slot, compile() returned the tree as-is, and validateTree then looked up manifest.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-attr error naming both the tag and the attribute. That single diagnostic replaces two outcomes:

authoredbeforenow
the value names another registered type (a flex element carrying type="grid")zero diagnosticsgrid resolved in the manifest, every check passed, and the page rendered a grid where the author wrote a flexrefused, naming both names
the value names nothing registered (an object-chart element carrying type="bar", the shape a react-tier author carries across)unknown-component naming "bar" — loud, but it reads as a missing plugin rather than as a bad proprefused, naming both names

The 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, parseElement now builds the node as { ...props, type: tag } — the ruled defense-in-depth half. ⚠️ 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.

Per the ruling: ⛔ no specType alias is introduced on the html tier (that rescue is the react runtime's and stays there), ⛔ no warning grace period, and ⛔ validate.ts's BASE_PROPS is untouchedtype belongs on that never-warn list for every other member, which is exactly why the remedy is at parse and not at the warning layer.

BaseSchema needed no change, so nothing here is handed to domain: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.

populationhow it was enumeratedelements carrying type=
A. real html-tier page sources — every kind:'html' / kind:'jsx' page object with a source literal, found by TS-AST walk over examples/**, content/**, packages/**, apps/**, docs/**, skills/**AST, not grep0
B. content/docs/** fenced snippets (they teach the shape even though nothing compiles them)every fence, every open tag11 hits, 0 html-tier
C. supplementary sweep — every other tracked .md/.mdx fence (skills/**, docs/**, package READMEs, blog)same scanner8 hits, 0 html-tier

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 in examples/app-showcasecapability-map, command-center-jsx, start-here — carry no type= 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.mdx stated the old behaviour in as many words ("a type attribute overwrites it"), which this change makes false. It now names the refusal and the diagnostic.

Why the diagnostic reuses forbidden-attr instead of minting a new code

This is a measured constraint, not a shortcut. scripts/check-sdui-lockstep.mjs holds 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-attr already carries exactly this shape — an attribute this tier refuses, named beside its element — and both copies stamp it. Gate verdict on this branch:

check:sdui-lockstep: OK — this copy is byte-identical to objectui@d8ec8d6d4f01 (2026-09-01T08:23:58+00:00)
over 214 grammar line(s) [blob 0131f27cf86d] and agrees on all 24 diagnostic code(s), across 7 non-test source(s).

⚠️ One consequence recorded rather than left implicit: objectui's renderer-side copy still accepts the attribute, so the two tiers now differ in strictness. The direction is the safe one — the save gate is stricter than the renderer, so a refused page never reaches a renderer that would misread it, which is the opposite of the save-clean-renders-inert failure the lockstep exists to prevent. Converging objectui's copy is filed separately rather than smuggled in here.

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 (no specType on 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 on docs/adr/** concurrently, and an appended tail keeps the conflict surface near zero.

scripts/adr-anchors/packages__sdui-parser__src__parse.ts.json is added so the next reader of parse.ts is 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 run7 files, 138 tests passed (7 of them new).
  • Ablation, from the committed tree, with the mutation proved on disk each leg (blob id vs HEAD:packages/sdui-parser/src/parse.ts, restore proved by git diff HEAD empty, absolute paths in the trap). No rebuild is involved: these tests import the parser through relative source specifiers, not through the package's exports, so nothing resolves to dist/.
    • refusal removed, new spread order kept → 5 red
    • refusal removed and old spread order restored → 6 red
    • refusal kept, old spread order restored → 8 green
      ⚠️ That third leg is the honest reading of the defense-in-depth half: the spread order is unobservable through the public API while the refusal stands, because the refused attribute never reaches props to 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 run on 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 --listFiles confirms the new test file is in the program (not excluded).
  • pnpm lint (repo-wide eslint . --no-inline-config) — clean, full scan, not narrowed.
  • The 68 gate familiesnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derives from the real change set, plus check:ratchet-remedy-authority, check:declared-population-live and check: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 saved turbo run test log (the gate's own text names this branch as NOT MEASURED)
    • check:dual-build-cjs-loads and check:type-check-debt --re-measure — need the whole workspace built (sdui-parser carries 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 unbuilt client-react/dist
    • scripts/pm/check-half-states.mjs — a network gate
  • check:sdui-lockstep, check:adr-anchors, check:doc-authoring, check:doc-security-posture, check:docs, check:doc-formula-expressions and every ratchet family re-ran green at final head 6dff387.

Generated by Claude Code


Generated by Claude Code

#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
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0974dadbaa4df2af1209daac21c366e4ef561a28 — the merge of head 6dff387193f1edfe70bf605a10fc5d3e32e1c1e9 into base 6eb8e3cc5022c4b1d0007962220444f4f947036d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-zhuang
os-zhuang marked this pull request as ready for review September 2, 2026 11:25
@os-zhuang
os-zhuang added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 0e68ed2Sep 2, 2026
43 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-13957-html-tier-type-collision branch September 2, 2026 12:23
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

html-tier page source: a type attribute overwrites the SDUI component discriminator — silently when the value names another registered type

3 participants

@os-zhuang@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(sdui-parser): refuse an authored type attribute on the html tier instead of letting it overwrite the component discriminator - #14274

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-13957-html-tier-type-collision
Sep 2, 2026
Merged

fix(sdui-parser): refuse an authored type attribute on the html tier instead of letting it overwrite the component discriminator#14274
os-zhuang merged 2 commits into
mainfrom
claude/issue-13957-html-tier-type-collision

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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 carries needs:contract-review from 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's type, so an authored type= attribute is a name collision with the envelope's own discriminator. parseElement used to build the node as { type: tag, ...props }props spread after — so the author's value won the slot, compile() returned the tree as-is, and validateTree then looked up manifest.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-attr error naming both the tag and the attribute. That single diagnostic replaces two outcomes:

authoredbeforenow
the value names another registered type (a flex element carrying type="grid")zero diagnosticsgrid resolved in the manifest, every check passed, and the page rendered a grid where the author wrote a flexrefused, naming both names
the value names nothing registered (an object-chart element carrying type="bar", the shape a react-tier author carries across)unknown-component naming "bar" — loud, but it reads as a missing plugin rather than as a bad proprefused, naming both names

The 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, parseElement now builds the node as { ...props, type: tag } — the ruled defense-in-depth half. ⚠️ 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.

Per the ruling: ⛔ no specType alias is introduced on the html tier (that rescue is the react runtime's and stays there), ⛔ no warning grace period, and ⛔ validate.ts's BASE_PROPS is untouchedtype belongs on that never-warn list for every other member, which is exactly why the remedy is at parse and not at the warning layer.

BaseSchema needed no change, so nothing here is handed to domain: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.

populationhow it was enumeratedelements carrying type=
A. real html-tier page sources — every kind:'html' / kind:'jsx' page object with a source literal, found by TS-AST walk over examples/**, content/**, packages/**, apps/**, docs/**, skills/**AST, not grep0
B. content/docs/** fenced snippets (they teach the shape even though nothing compiles them)every fence, every open tag11 hits, 0 html-tier
C. supplementary sweep — every other tracked .md/.mdx fence (skills/**, docs/**, package READMEs, blog)same scanner8 hits, 0 html-tier

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 in examples/app-showcasecapability-map, command-center-jsx, start-here — carry no type= 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.mdx stated the old behaviour in as many words ("a type attribute overwrites it"), which this change makes false. It now names the refusal and the diagnostic.

Why the diagnostic reuses forbidden-attr instead of minting a new code

This is a measured constraint, not a shortcut. scripts/check-sdui-lockstep.mjs holds 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-attr already carries exactly this shape — an attribute this tier refuses, named beside its element — and both copies stamp it. Gate verdict on this branch:

check:sdui-lockstep: OK — this copy is byte-identical to objectui@d8ec8d6d4f01 (2026-09-01T08:23:58+00:00)
over 214 grammar line(s) [blob 0131f27cf86d] and agrees on all 24 diagnostic code(s), across 7 non-test source(s).

⚠️ One consequence recorded rather than left implicit: objectui's renderer-side copy still accepts the attribute, so the two tiers now differ in strictness. The direction is the safe one — the save gate is stricter than the renderer, so a refused page never reaches a renderer that would misread it, which is the opposite of the save-clean-renders-inert failure the lockstep exists to prevent. Converging objectui's copy is filed separately rather than smuggled in here.

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 (no specType on 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 on docs/adr/** concurrently, and an appended tail keeps the conflict surface near zero.

scripts/adr-anchors/packages__sdui-parser__src__parse.ts.json is added so the next reader of parse.ts is 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 run7 files, 138 tests passed (7 of them new).
  • Ablation, from the committed tree, with the mutation proved on disk each leg (blob id vs HEAD:packages/sdui-parser/src/parse.ts, restore proved by git diff HEAD empty, absolute paths in the trap). No rebuild is involved: these tests import the parser through relative source specifiers, not through the package's exports, so nothing resolves to dist/.
    • refusal removed, new spread order kept → 5 red
    • refusal removed and old spread order restored → 6 red
    • refusal kept, old spread order restored → 8 green
      ⚠️ That third leg is the honest reading of the defense-in-depth half: the spread order is unobservable through the public API while the refusal stands, because the refused attribute never reaches props to 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 run on 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 --listFiles confirms the new test file is in the program (not excluded).
  • pnpm lint (repo-wide eslint . --no-inline-config) — clean, full scan, not narrowed.
  • The 68 gate familiesnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derives from the real change set, plus check:ratchet-remedy-authority, check:declared-population-live and check: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 saved turbo run test log (the gate's own text names this branch as NOT MEASURED)
    • check:dual-build-cjs-loads and check:type-check-debt --re-measure — need the whole workspace built (sdui-parser carries 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 unbuilt client-react/dist
    • scripts/pm/check-half-states.mjs — a network gate
  • check:sdui-lockstep, check:adr-anchors, check:doc-authoring, check:doc-security-posture, check:docs, check:doc-formula-expressions and every ratchet family re-ran green at final head 6dff387.

Generated by Claude Code


Generated by Claude Code

#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
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0974dadbaa4df2af1209daac21c366e4ef561a28 — the merge of head 6dff387193f1edfe70bf605a10fc5d3e32e1c1e9 into base 6eb8e3cc5022c4b1d0007962220444f4f947036d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-zhuang
os-zhuang marked this pull request as ready for review September 2, 2026 11:25
@os-zhuang
os-zhuang added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 0e68ed2Sep 2, 2026
43 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-13957-html-tier-type-collision branch September 2, 2026 12:23
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

html-tier page source: a type attribute overwrites the SDUI component discriminator — silently when the value names another registered type

3 participants

@os-zhuang@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(sdui-parser): refuse an authored type attribute on the html tier instead of letting it overwrite the component discriminator - #14274

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-13957-html-tier-type-collision
Sep 2, 2026
Merged

fix(sdui-parser): refuse an authored type attribute on the html tier instead of letting it overwrite the component discriminator#14274
os-zhuang merged 2 commits into
mainfrom
claude/issue-13957-html-tier-type-collision

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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 carries needs:contract-review from 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's type, so an authored type= attribute is a name collision with the envelope's own discriminator. parseElement used to build the node as { type: tag, ...props }props spread after — so the author's value won the slot, compile() returned the tree as-is, and validateTree then looked up manifest.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-attr error naming both the tag and the attribute. That single diagnostic replaces two outcomes:

authoredbeforenow
the value names another registered type (a flex element carrying type="grid")zero diagnosticsgrid resolved in the manifest, every check passed, and the page rendered a grid where the author wrote a flexrefused, naming both names
the value names nothing registered (an object-chart element carrying type="bar", the shape a react-tier author carries across)unknown-component naming "bar" — loud, but it reads as a missing plugin rather than as a bad proprefused, naming both names

The 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, parseElement now builds the node as { ...props, type: tag } — the ruled defense-in-depth half. ⚠️ 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.

Per the ruling: ⛔ no specType alias is introduced on the html tier (that rescue is the react runtime's and stays there), ⛔ no warning grace period, and ⛔ validate.ts's BASE_PROPS is untouchedtype belongs on that never-warn list for every other member, which is exactly why the remedy is at parse and not at the warning layer.

BaseSchema needed no change, so nothing here is handed to domain: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.

populationhow it was enumeratedelements carrying type=
A. real html-tier page sources — every kind:'html' / kind:'jsx' page object with a source literal, found by TS-AST walk over examples/**, content/**, packages/**, apps/**, docs/**, skills/**AST, not grep0
B. content/docs/** fenced snippets (they teach the shape even though nothing compiles them)every fence, every open tag11 hits, 0 html-tier
C. supplementary sweep — every other tracked .md/.mdx fence (skills/**, docs/**, package READMEs, blog)same scanner8 hits, 0 html-tier

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 in examples/app-showcasecapability-map, command-center-jsx, start-here — carry no type= 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.mdx stated the old behaviour in as many words ("a type attribute overwrites it"), which this change makes false. It now names the refusal and the diagnostic.

Why the diagnostic reuses forbidden-attr instead of minting a new code

This is a measured constraint, not a shortcut. scripts/check-sdui-lockstep.mjs holds 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-attr already carries exactly this shape — an attribute this tier refuses, named beside its element — and both copies stamp it. Gate verdict on this branch:

check:sdui-lockstep: OK — this copy is byte-identical to objectui@d8ec8d6d4f01 (2026-09-01T08:23:58+00:00)
over 214 grammar line(s) [blob 0131f27cf86d] and agrees on all 24 diagnostic code(s), across 7 non-test source(s).

⚠️ One consequence recorded rather than left implicit: objectui's renderer-side copy still accepts the attribute, so the two tiers now differ in strictness. The direction is the safe one — the save gate is stricter than the renderer, so a refused page never reaches a renderer that would misread it, which is the opposite of the save-clean-renders-inert failure the lockstep exists to prevent. Converging objectui's copy is filed separately rather than smuggled in here.

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 (no specType on 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 on docs/adr/** concurrently, and an appended tail keeps the conflict surface near zero.

scripts/adr-anchors/packages__sdui-parser__src__parse.ts.json is added so the next reader of parse.ts is 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 run7 files, 138 tests passed (7 of them new).
  • Ablation, from the committed tree, with the mutation proved on disk each leg (blob id vs HEAD:packages/sdui-parser/src/parse.ts, restore proved by git diff HEAD empty, absolute paths in the trap). No rebuild is involved: these tests import the parser through relative source specifiers, not through the package's exports, so nothing resolves to dist/.
    • refusal removed, new spread order kept → 5 red
    • refusal removed and old spread order restored → 6 red
    • refusal kept, old spread order restored → 8 green
      ⚠️ That third leg is the honest reading of the defense-in-depth half: the spread order is unobservable through the public API while the refusal stands, because the refused attribute never reaches props to 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 run on 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 --listFiles confirms the new test file is in the program (not excluded).
  • pnpm lint (repo-wide eslint . --no-inline-config) — clean, full scan, not narrowed.
  • The 68 gate familiesnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derives from the real change set, plus check:ratchet-remedy-authority, check:declared-population-live and check: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 saved turbo run test log (the gate's own text names this branch as NOT MEASURED)
    • check:dual-build-cjs-loads and check:type-check-debt --re-measure — need the whole workspace built (sdui-parser carries 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 unbuilt client-react/dist
    • scripts/pm/check-half-states.mjs — a network gate
  • check:sdui-lockstep, check:adr-anchors, check:doc-authoring, check:doc-security-posture, check:docs, check:doc-formula-expressions and every ratchet family re-ran green at final head 6dff387.

Generated by Claude Code


Generated by Claude Code

#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
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0974dadbaa4df2af1209daac21c366e4ef561a28 — the merge of head 6dff387193f1edfe70bf605a10fc5d3e32e1c1e9 into base 6eb8e3cc5022c4b1d0007962220444f4f947036d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-zhuang
os-zhuang marked this pull request as ready for review September 2, 2026 11:25
@os-zhuang
os-zhuang added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 0e68ed2Sep 2, 2026
43 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-13957-html-tier-type-collision branch September 2, 2026 12:23
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

html-tier page source: a type attribute overwrites the SDUI component discriminator — silently when the value names another registered type

3 participants

@os-zhuang@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(sdui-parser): refuse an authored type attribute on the html tier instead of letting it overwrite the component discriminator - #14274

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-13957-html-tier-type-collision
Sep 2, 2026
Merged

fix(sdui-parser): refuse an authored type attribute on the html tier instead of letting it overwrite the component discriminator#14274
os-zhuang merged 2 commits into
mainfrom
claude/issue-13957-html-tier-type-collision

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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 carries needs:contract-review from 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's type, so an authored type= attribute is a name collision with the envelope's own discriminator. parseElement used to build the node as { type: tag, ...props }props spread after — so the author's value won the slot, compile() returned the tree as-is, and validateTree then looked up manifest.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-attr error naming both the tag and the attribute. That single diagnostic replaces two outcomes:

authoredbeforenow
the value names another registered type (a flex element carrying type="grid")zero diagnosticsgrid resolved in the manifest, every check passed, and the page rendered a grid where the author wrote a flexrefused, naming both names
the value names nothing registered (an object-chart element carrying type="bar", the shape a react-tier author carries across)unknown-component naming "bar" — loud, but it reads as a missing plugin rather than as a bad proprefused, naming both names

The 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, parseElement now builds the node as { ...props, type: tag } — the ruled defense-in-depth half. ⚠️ 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.

Per the ruling: ⛔ no specType alias is introduced on the html tier (that rescue is the react runtime's and stays there), ⛔ no warning grace period, and ⛔ validate.ts's BASE_PROPS is untouchedtype belongs on that never-warn list for every other member, which is exactly why the remedy is at parse and not at the warning layer.

BaseSchema needed no change, so nothing here is handed to domain: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.

populationhow it was enumeratedelements carrying type=
A. real html-tier page sources — every kind:'html' / kind:'jsx' page object with a source literal, found by TS-AST walk over examples/**, content/**, packages/**, apps/**, docs/**, skills/**AST, not grep0
B. content/docs/** fenced snippets (they teach the shape even though nothing compiles them)every fence, every open tag11 hits, 0 html-tier
C. supplementary sweep — every other tracked .md/.mdx fence (skills/**, docs/**, package READMEs, blog)same scanner8 hits, 0 html-tier

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 in examples/app-showcasecapability-map, command-center-jsx, start-here — carry no type= 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.mdx stated the old behaviour in as many words ("a type attribute overwrites it"), which this change makes false. It now names the refusal and the diagnostic.

Why the diagnostic reuses forbidden-attr instead of minting a new code

This is a measured constraint, not a shortcut. scripts/check-sdui-lockstep.mjs holds 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-attr already carries exactly this shape — an attribute this tier refuses, named beside its element — and both copies stamp it. Gate verdict on this branch:

check:sdui-lockstep: OK — this copy is byte-identical to objectui@d8ec8d6d4f01 (2026-09-01T08:23:58+00:00)
over 214 grammar line(s) [blob 0131f27cf86d] and agrees on all 24 diagnostic code(s), across 7 non-test source(s).

⚠️ One consequence recorded rather than left implicit: objectui's renderer-side copy still accepts the attribute, so the two tiers now differ in strictness. The direction is the safe one — the save gate is stricter than the renderer, so a refused page never reaches a renderer that would misread it, which is the opposite of the save-clean-renders-inert failure the lockstep exists to prevent. Converging objectui's copy is filed separately rather than smuggled in here.

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 (no specType on 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 on docs/adr/** concurrently, and an appended tail keeps the conflict surface near zero.

scripts/adr-anchors/packages__sdui-parser__src__parse.ts.json is added so the next reader of parse.ts is 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 run7 files, 138 tests passed (7 of them new).
  • Ablation, from the committed tree, with the mutation proved on disk each leg (blob id vs HEAD:packages/sdui-parser/src/parse.ts, restore proved by git diff HEAD empty, absolute paths in the trap). No rebuild is involved: these tests import the parser through relative source specifiers, not through the package's exports, so nothing resolves to dist/.
    • refusal removed, new spread order kept → 5 red
    • refusal removed and old spread order restored → 6 red
    • refusal kept, old spread order restored → 8 green
      ⚠️ That third leg is the honest reading of the defense-in-depth half: the spread order is unobservable through the public API while the refusal stands, because the refused attribute never reaches props to 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 run on 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 --listFiles confirms the new test file is in the program (not excluded).
  • pnpm lint (repo-wide eslint . --no-inline-config) — clean, full scan, not narrowed.
  • The 68 gate familiesnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derives from the real change set, plus check:ratchet-remedy-authority, check:declared-population-live and check: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 saved turbo run test log (the gate's own text names this branch as NOT MEASURED)
    • check:dual-build-cjs-loads and check:type-check-debt --re-measure — need the whole workspace built (sdui-parser carries 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 unbuilt client-react/dist
    • scripts/pm/check-half-states.mjs — a network gate
  • check:sdui-lockstep, check:adr-anchors, check:doc-authoring, check:doc-security-posture, check:docs, check:doc-formula-expressions and every ratchet family re-ran green at final head 6dff387.

Generated by Claude Code


Generated by Claude Code

#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
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0974dadbaa4df2af1209daac21c366e4ef561a28 — the merge of head 6dff387193f1edfe70bf605a10fc5d3e32e1c1e9 into base 6eb8e3cc5022c4b1d0007962220444f4f947036d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-zhuang
os-zhuang marked this pull request as ready for review September 2, 2026 11:25
@os-zhuang
os-zhuang added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 0e68ed2Sep 2, 2026
43 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-13957-html-tier-type-collision branch September 2, 2026 12:23
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

html-tier page source: a type attribute overwrites the SDUI component discriminator — silently when the value names another registered type

3 participants

@os-zhuang@hotlong@claude