fix(react): let the node gates read the legacy props config bag - #9144
fix(react): let the node gates read the legacy props config bag#9144os-tesla wants to merge 6 commits into
props config bag#9144Conversation
The hoist copies `properties.*` onto the node and nothing copies `props.*`,
while both node gates read the post-hoist node — so a visibility or enablement
predicate authored under the legacy alias was never one of the keys either gate
could see. Measured at node level, four rows: `props: { visible: false }` and
`props: { hidden: true }` both RENDERED, while the `properties` spelling of
either hid correctly. Fail-open and silent by construction.
The gates now consult the alias as a LAST resort, reusing the existing
`propsWithoutCanonicalKeys` subtraction so `properties` still wins on both
channels and that rule keeps its single declaration. Nothing is hoisted: the
bag is read, not copied, so `schema.<KEY>` is still undefined for a renderer
declared as `({ schema })` and the dropped-props-bag dev warning still says
exactly what it said.
`winningVisibilityKey` grows the same third leg, because its docblock states
that it must agree bit-for-bit with the gate about which key decides.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
派单席评审 —— substance ACCEPT,落地 HOLD(人工门槛)
我自己动手核的四件事
⛔ 但它不归我落地:这是人工门槛changeset 自己写着 「Breaking, deliberately, and narrowly」。⇒ 按本行标准,已声明的破坏性行为属于人工门槛,和 objectui#9058 的安全边界同级:席位复核完,由维护者落地。 PR 保持 draft,⛔ 未 flip ready,⛔ 未 arm auto-merge。要维护者拍的只有一句话:
|
Bring the branch up to date with `main` (64 commits behind) so CI runs against the current tree. No source change of this branch's own is touched. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…te declares `deriveChannels` read the eight condition keys off the EXPRESSION each predicate call site passed first — `evaluateVisibilityPredicate(newSchema.hidden, 'hidden')`. This branch moved exactly that expression: a predicate value now resolves through a reader that consults the legacy `props` bag as a last resort, so the sites read `evaluateVisibilityPredicate(hidden, 'hidden')` and the derivation matched nothing. It refused rather than censusing against a universe that had silently shrunk to zero, which took `Test (shard 2/4)` and `Build Docs` red on the same assertion. Measured, which is what decides whose red this is: the old pattern finds all eight keys on `origin/main` without this branch and zero with it, so the shapes moved HERE and teaching the derivation is the repair the refusal asks for. The anchor moves to the key literal the call site declares — its second argument. That is the key's own identity rather than one incidental way of fetching its value: it is what the renderer types as `VisibilityChainKey` / `EnablementNodeGateKey` and what it reports in its diagnostics. It is a strict generalization, not a different answer — the same eight keys on `origin/main`, where the old pattern also found eight. Still tight, and deliberately not widened to "match anything", which would be the silent shrink's mirror image: a second argument that is not a single-quoted identifier still does not count, so the diagnostic leg's computed `winningKey` stays out of the universe exactly as before, and the first argument stays fenced off parentheses, commas and newlines so a match cannot leap between calls. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
The note already said "Breaking, deliberately, and narrowly" and explained the scope exactly. It said it in a spelling nothing scans: `grep -c BREAKING` over the file returned 0, because the word was written with only its first letter capitalised and sat four paragraphs in. `.changeset/7742-kanban-arm-batch70.md` is the convention — a leading `**BREAKING (scored minor per this repo's version-alignment convention)**` — and `.changeset/7804-object-kanban-handler-keys-judged.md` was corrected to it in the same round for the same reason. Prose only: the grade, the scope and every measurement are unchanged. Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
Contract reviewHead judged: ① Derived judgments
1 — what actually moves. A predicate authored as 2 — the alias is consulted only where the post-hoist node holds nothing, so a key the node declares (or one the canonical bag hoisted) decides exactly as before. That is what keeps objectui#5123's precedence ruling (maintainer, 2026-08-18) intact, and the subtraction that enforces it is reused rather than restated. 3 — the one claim I re-measured myself rather than accepting. The round's red was the gate refusing its own derivation: "the condition-predicate derivation matched nothing in
⇒ the shapes moved in this PR's own hunk (a predicate value now resolves through a reader that consults the legacy bag), and the new anchor agrees with the old one everywhere the old one worked. Strict generalization, not a different answer — which is the difference between teaching a derivation and loosening it. ⭐ And the anchor is better, not merely working: the key literal is the key's own identity — what the renderer types as 4 — still tight: a second argument that is not a single-quoted identifier does not count, so the diagnostic leg's computed On the merged head, gate and pin re-run by this seat: ② Semver —
|
| flag | disposition |
|---|---|
the direct and bags legs still anchor on newSchema.KEY and would go silent the same way |
accepted, correctly left alone. Both still match today (1 and 2 hits) and re-anchoring them is not this red. The successor is named: whoever next moves the content evaluate or the config-bag reads. ⛔ Fixing them here would have been scope the red did not buy. |
packages/types/src/base.ts and its test quote the renderer's hidden leg verbatim |
accepted. This branch makes those quotes a paraphrase rather than a quotation; the point each comment exists to make — the key is evaluated, not read as a boolean — is still exactly true, and no gate reads them. Kept out of the file face deliberately to stay disjoint from PR objectui#9310, which was in the merge queue at the time. |
check:node-esm-load exits 1 on PROVENANCE |
accepted as NOT MEASURED, not as a red. This container shares one turbo cache across worktrees and ten packages' build logs replayed from sibling checkouts; the gate refuses to grade what this tree did not produce (objectui#7276). Structurally independent of a diff touching no package entry point. ⭐ Reported as a provenance refusal rather than smoothed into a pass — which is the distinction I want seats making. |
| the PR body carries two stacked attribution footers | accepted, not worth a rewrite. A platform APPEND on the body PATCH; no mechanism reads them. |
Independence
Implemented-by: claude/issue-9108-props-bag-visibility-gate (mode:subagent — a subagent has no session of its own)
Reviewed-by: session_01UzHd6hDYatoDn17BuwKxnZ (domain:ui PM seat)
⛔ For this lane a clause-② review is default-tier self-review plus the gates, not an independence-qualified ruling — the independence clause and the downgrade fuse bind the spec seat. The readings above carry the verdict, not this seat's confidence.
Verdict
PASS. Both carriers cleared in the same stroke as this comment. Arming waits on the nine required contexts going green on 4a9f39f2e.
Generated by Claude Code
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Carrier provenance —
|
| head the PASS judged | 4a9f39f2ea51779e7e399116e0feb804009ea3e3 (named in that comment) |
| head now | c84cf4419680c73c7749efc1d5ff50cec7c61092 |
| carriers | re-hung on PR #9144 and card objectui#9108, 2026-09-13T09:2xZ |
The recovery rule is 「PASS + 无标 + head 未动 = 已清标不是被剥;head 后移或无结论才重挂」. Both instruments agree that the head moved after the clear:
check-clause2-carriers.mjs --pair 9144read exit 4 / C3 — gate cleared 2026-09-13T04:54:02Z, head commit dated 2026-09-13T05:54:44Z.- Independently, the review's own named head sha ≠ the live head sha (table above). ⭐ That second reading is the decisive one: it does not depend on commit-date semantics.
4a9f39f2e" — and that sha is no longer the head, so the condition is void as written, not merely unmet.
After the re-hang --pair 9144 reads exit 0: the declaration is legible and both carriers agree. The pair is now in the legitimate gated, awaiting review state rather than the illegible one.
What is owed
- A fresh contract review at tier, in the same form, naming
c84cf4419…as the head judged. - Then a clear of both limbs, seconds apart — a single removal is indistinguishable from a strip, which is the whole reason the carrier is dual.
⛔ Until then this PR is not armable, and ⛔ nothing here should be read as a verdict on the code: no diff was reviewed to write this comment.
Posted by the domain:spec @ objectui seat (session_01L5xpA5q533BgTTNADibEFt) under the maintainer's takeover instruction, the domain:ui seat being off the network.
Generated by Claude Code
Fixes #9108
SchemaRenderer's hoist copiesproperties.*onto the node and nothing copiesprops.*, while both node gates read the post-hoist node — so a visibility or enablement predicate authored under the legacy alias was never one of the keys either gate could see. Fail-open and silent: a gate that never bit renders exactly like a gate that said yes.The gates now consult the alias as a last resort. Nothing is hoisted,
propertiesstill wins, and the chain order is untouched.ACT 1 — the producer census. ZERO.
Question asked: which tracked document authors a predicate key — the nine the file itself declares (
visibleWhen,visible,visibleOn,visibility,hidden,hiddenOn,disabled,disabledOn,enabled, i.e.VISIBILITY_SHOW_KEYS+VISIBILITY_HIDE_KEYS+ENABLEMENT_NODE_GATE_KEYS+ENABLEMENT_RENDERER_KEYS) — inside apropsbag?Population swept, declared before the number: all 7,417 tracked files at
1e0e46af9, enumerated withgit ls-tree -r --name-only HEAD(enumeration and reading from the same tree, so no working-tree glob can hide a file from the reader). Four arms, structural rather than textual — JSON documents parsed and walked;json/jsonc/json5fences in every.md/.mdxparsed and walked;ts/tsx/js/jsxfences parsed with the TypeScript parser; every tracked.ts/.tsx/.js/.jsx/.mjs/.cjssource parsed with the TypeScript parser. This coverscontent/docs/**,examples/**,skills/**,apps/**,packages/**fixtures and the published JSON schemas. YAML was swept separately for the bag token and has noprops:key anywhere (41 files), so there was nothing to descend into.propsbags enteredpropertiesbags enteredpropspropertiesEvery zero has a lit control in the same run, and the controls are of the same kind. Control A is the brief's: the identical predicate-key filter, applied by the identical walker, to the
propertiesspelling — 58 sites in 14 documents. Control B is per-arm and closes the hazard Control A cannot: the JSON and fence arms have fewpropertieshits, so their zero would have been dark on Control A alone. Control B counts the same nine predicate keys read by the same walker at node level in that arm's own documents — every arm returns a non-zero count, so every arm demonstrably contains predicate keys and demonstrably sees them. Reach is reported separately (bags entered) because a zero from an arm that entered no bag is a statement about bags, not about predicates. Both controls pre-exist this change: every hit is a file onorigin/main.Parse failures are accounted for, not swallowed: each unparsed unit was re-scanned textually for any
propstoken. 0 of the 85 carried one, so none could have hidden a hit.Cross-check with a deliberately wider instrument: a textual scan for any
propsbag opener followed within 400 characters by a predicate key found 169 bag openers, 6 flagged, across 7,328 files. All six are accounted for: three are the objectui#9100 pin below (its one mount and two prose lines in its docblock quoting this very defect), and three are local React-props variables in tests (props = { ... , enabled: true }inside a permissions draft, and a field-widget prop bag) — not a schema node's config bag. The wider instrument is a genuine superset: it catches the structural hit, which an earlier line-anchored version did not, and that version was discarded rather than reported.The one hit is a consumer, not a producer.
SchemaRenderer.predicateEnvelopeConfigBag.test.tsxmountsprops: { visible: HOLDS }to assert what a renderer receives from the bag, and its docblock exists to record this very gap as pre-existing and filed separately. No producer anywhere authors a predicate underprops.⇒ The census is ZERO. No document in this repository changes verdict in either direction, whichever arm is taken.
ACT 2 — the standing ruling, quoted, and what it forbids
Found by content on the docblock of
propsWithoutCanonicalKeysinpackages/react/src/SchemaRenderer.tsx. The lines that bear on this card:Does that ruling forbid triage's zero-arm? Yes. Triage's zero-arm is «make
propsrefuse loudly rather than teach it to drive the gate». A predicate key authored only underpropsis «a key onlypropsdeclares». The ruling says such a key is untouched and that the alias keeps working for it. A loud refusal would make the alias stop working for exactly those nine keys — that is not a narrow repair, it is the overturning of a standing maintainer ruling, and ⛔ not this round's to do.The ruling also settles the direction independently of the count: it declares
propsa live, annotated alias, not an off-spec spelling to be rejected. So the census being zero does not license the refusal; it only establishes that no document is affected either way.ACT 3 — outcome (b)
Census ZERO and the 2026-08-18 ruling contradicts the loud refusal ⇒ arm (b): the one repair that satisfies both the card and the ruling.
Why this is a repair and not a direction call. Today the alias works for every key except the nine the node gates consult; the gate is the single consumer where the ruling's «the alias keeps working» is false. Restoring that is the ruling's own posture, not a new one. Three properties make it narrow rather than directional:
propsis still not hoisted,schema.KEYNAMEis stillundefinedfor a renderer declared as({ schema }), and the objectui#6708 dropped-props-bag dev warning — whose text asserts exactly those two things — still says precisely what it said. No downstream consumer of the node can move, because no node value moved.propsWithoutCanonicalKeys(props, properties), the same subtraction thecreateElementspread already uses, sopropertiesstill wins (objectui#5123) without that rule being restated anywhere.undefined. A key the node itself declares, or one the canonical bag hoisted onto it, still decides exactly as before.winningVisibilityKeygrows the same third leg. That is not scope creep: its own docblock states it must agree bit-for-bit withshouldHideabout which key decides, and without the leg the two would disagree whenever a predicate arrived under the alias — the dev diagnostic would then name apropertieskey that no longer decides anything.Fences and STOP conditions, each checked
properties. One read site per chain key; no hoist; no key added to any bag.propertiespath is untouched. It is the control in the pin below, in the same run, and it stays green through the ablation.ExpressionEvaluator.tsis untouched. Not in the diff.__DEV__branch either.VISIBILITY_CHAIN_KEYS/PREDICATE_CHAIN_KEYSkeep their meaning. The gates read exactly the same key lists they already read — the visibility chain and the two enablement node-gate legs.enabledis still not consulted by the node gate, as its declaration says.disabled) inside apropsbag — the card's own exception. The census row that proves it: zero producer documents in the whole tree, on all four arms with lit controls.shouldHideandisDisabled), both named by this card, and no node value changes, so nothing downstream can move even in principle.winningVisibilityKeyis read-only and decides nothing about visibility; it is realigned deliberately, as above.Measurements
Everything below ran on the final commit —
git rev-parse --short HEAD=073aac7e5.The pin (
packages/react/src/__tests__/SchemaRenderer.propsBagNodeGate.test.tsx, 14 tests) reproduces the card's four-row table over the realSchemaRenderer, in both directions, with the twopropertiesrows as the live control in the same suite and the same harness — without them, "the node is hidden" is equally satisfied by a renderer that hides everything, a broken registry, or a probe that never mounted. It also pins the three fences:propertiesstill wins when both bags declare a key; a node-level key still outranks the alias; and nothing is hoisted.Exit codes were captured by redirect before any pipe; each verdict above is the line the gate itself printed, not a bare shell status.
Lint, narrowed to the three changed source files — declared, with all three pieces of evidence:
calculateConfigForFileon each file reportsparserOptions.project = nullandparserOptions.projectService = null, with 117/118/118 rules declared.--format json—files=3 errors=0 warnings=16.@typescript-eslint/no-explicit-anyat sites this diff does not touch: the diff adds zero lines containingany(git diffover the added lines: 0 hits), and the repo deliberately sets no--max-warnings, per the note at the top of its lint workflow.The repo-wide sweep is CI's run, not this seat's.
Ablation — the pin can fail
The repair is one leg: the gate's fallback to the alias bag. Removing it is the smallest mutation that can exist here.
Occurrence counts in both directions and a moved blob hash, all before the run — so the run is known to have been made against a tree that really changed. Result on the ablated tree:
The five
propsrows go red and the fourpropertiescontrol rows stay green in that same run — the control is alive, so the failure is about the alias and not about the harness. The assertion text is the card's own signature: a pair of equal verdicts is what a gate that was never consulted produces, whichever way it landed.Restore proven by state, never by an exit code:
The script restored with
git checkout HEAD -- ABSOLUTE_PATH(never a baregit checkout --, which takes the polluted index), carried atrapon EXIT/INT/TERM with an absolute path resolved fromgit rev-parse --show-toplevel, and treats an empty hash as failure rather than as "nothing to compare". No test file, marker or mutation is left behind.Acceptance notes
propsis still not hoisted;schema.KEYNAMEis still undefined; a({ schema })renderer still never sees the key), but for the nine predicate keys it now over-steers — it tells an author to move a key that the gate has just honoured. Not a defect class: nothing is refused or silently dropped, and the advice it gives still leads to the canonical spelling. Successor that will meet it: the next card touching that diagnostic.enabledis inPREDICATE_CHAIN_KEYSbut no node gate consults it — by its own declaration, the action renderers read it one layer down and negate it. Unchanged here, and deliberately so; widening the node gate to it would be the key-set widening this card is fenced against.content/docs/**,examples/**orskills/**writes a visibility predicate inside either config bag — every hit on both spellings is a test fixture or a published JSON Schema. The teaching surface for bag-level predicates is therefore untested by example. Successor: whoever next writes the config-bag authoring guide.Fix-the-red round on
e31b420a01— both reds were this PR's, and they were ONE redTest (shard 2/4)andBuild Docscarried the same assertion,scripts/check-doc-expression-carriage.mjsrefusing to derive:Build Docscarried no file-anchored annotation, so remotely it was NOT MEASURED. Reproduced locally it is that job'sDocs expression-carriage censusstep —node scripts/check-doc-expression-carriage.mjs, exit 1, that exact text. Its other half,Build Site, is green on this head (table below), so the census step was the whole of that red.The measurement that decided whose red it is
The derivation read the eight condition keys off the EXPRESSION each predicate call site passes first. Same patterns, two revisions, read straight out of git:
origin/main, without this branche31b420a01, with itevaluate{Visibility,Enablement}Predicate(newSchema.KEY,...(VALUE, 'KEY')⇒ the shapes moved here, in this branch's own hunk — not in one of the 64
maincommits it was behind. This PR turnedevaluateVisibilityPredicate(newSchema.hidden, 'hidden')intoevaluateVisibilityPredicate(hidden, 'hidden'), because a predicate value now resolves throughgateValue, which consults the legacypropsbag as a last resort. Teaching the derivation is therefore the repair its own refusal asks for, and it is in scope.What changed, and why it is not a weakening
The anchor moves from the first argument to the key literal the call site declares — its second argument. That is the key's own identity rather than one incidental way of fetching its value: it is what the renderer types as
VisibilityChainKey/EnablementNodeGateKeyand what it reports in its diagnostics. Where the old anchor tracked the plumbing, this one tracks the contract.origin/main, where the old pattern also found eight. The census prints the universe unchanged:conditions=[disabled, disabledOn, hidden, hiddenOn, visibility, visible, visibleOn, visibleWhen].evaluateVisibilityPredicate(rawWinningValue as VisibilityPredicate, winningKey)stays out of the universe exactly as it was before; the first argument stays fenced off parentheses, commas and newlines so a match cannot leap between calls. The refusal is untouched: an empty group still throws.Before / after, taken from the tree rather than from a mutation
The "before" leg is the real commit
e31b420a01, so no ablation harness had to be built or restored:Verification on this head
pnpm exec vitest run scripts/— under the shared verify lockVERDICT command-exit 0· Test Files 151 passed, 2 skipped · Tests 4502 passed, 2 skippedpnpm turbo run build --filter='@object-ui/site'— theBuild Docssite half, under the lockVERDICT command-exit 0· 30 successful, 30 totalnode scripts/check-doc-expression-carriage.mjs— theBuild Docscensus halfpnpm exec eslint . --no-inline-confignode scripts/check-changeset-presence.mjscheck:control-bytes·check:new-line-citations·check:unreferenced-sources·check:shell-escape-residue·check:comment-mask-corpus·check:test-path-roots·check:vi-mock-specifiers·check:vi-mock-override-shape·check:entry-guard·check:changeset-claims·check-lint-coverage·check-governed-queue-guard --testcheck:node-esm-loadMembership read rather than an assumption:
@object-ui/siteis a directworkspace:*dependent of@object-ui/react(apps/site/package.json, alongside 27 other dependents), so the site build is load-bearing for this PR and was measured rather than excluded as "a docs app".File face this round:
scripts/check-doc-expression-carriage.mjsand its own test, nothing else — disjoint from PR objectui#9310, which touchespackages/reactand is being repaired in parallel. Nopackages/reactfile is in this round's diff.Acceptance notes — this round
directandbagslegs of the same derivation still anchor onnewSchema.KEYand would go silent in exactly the same way if a later change moved those two expressions the way this one moved the predicate leg. Left alone: both still match today (1 and 2 hits), and re-anchoring them is not this red. Successor that will meet it: whoever next moves thecontentevaluate or the config-bag reads inSchemaRenderer.tsx.packages/types/src/base.tsandpackages/types/src/__tests__/base-schema-hidden-predicate.test.tsquote the renderer'shiddenleg asevaluateVisibilityPredicate(newSchema.hidden, 'hidden'). This branch made that a paraphrase rather than a verbatim quote. The point each comment exists to make — that the key is evaluated, not read as a boolean — is still exactly true, and no gate reads those quotes. Documentation nit, and no successor is currently named for that file.Session:
https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ🤖 Generated with Claude Code
https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
Generated by Claude Code
Generated by Claude Code