From b1c239aacfe35a2c5aaa9b8810dc97f624381428 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 14:42:48 +0000 Subject: [PATCH 1/2] fix(react): refuse a node-gate predicate parked under `props` by name A node may spell its config bag `properties` (the spec spelling) or `props` (the annotated legacy alias). `SchemaRenderer` hoists `properties.*` onto the node; nothing copies `props.*`, and both node gates read the post-hoist node, so a predicate that arrived under the alias was never one of the keys either gate could see. Fail-open and silent by construction: a gate that never bit renders exactly like a gate that said yes. The maintainer ruling of 2026-09-13 closed the honour arm and ruled REFUSE: honouring the alias would have made eight predicate keys work while the rest stayed silently dropped, and partly working is harder to learn from than not working. So no verdict moves here. Nothing is hoisted, `schema.` stays undefined for a renderer declared as `({ schema })`, and every element receives the byte-identical props bag it received before. What changes is that the eight node-gate predicate keys are named on the console when they are parked under `props`, with the migration that fixes them. The reporter is `console.error` and carries no `__DEV__` gate: severity from `unevaluatedExpression.ts`, this repo's refusal tier, and always-on from the unresolvable-predicate leg that objectui#6038 took out of `__DEV__` because a node gate that has stopped biting in production must not be able to sit live and undiscovered. Rate-limited to one line per distinct authoring bug. One measured carve-out: `disabled` on a bag-reading `element:*` node is honoured by that renderer itself, so it is not refused there. The governed rule book moves with it: `skills/objectui/rules/protocol.md` said a key under `props` is "silently dropped", which becomes false for these eight keys the moment this lands. The WRONG/CORRECT classification is unchanged; only the symptom sentence moves. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa --- .changeset/9108-props-bag-loud-refusal.md | 11 + packages/react/src/SchemaRenderer.tsx | 60 +++- ...chemaRenderer.propsBagLoudRefusal.test.tsx | 315 ++++++++++++++++++ .../react/src/utils/propsBagDiagnostic.ts | 207 ++++++++++++ skills/objectui/rules/protocol.md | 9 +- 5 files changed, 598 insertions(+), 4 deletions(-) create mode 100644 .changeset/9108-props-bag-loud-refusal.md create mode 100644 packages/react/src/__tests__/SchemaRenderer.propsBagLoudRefusal.test.tsx diff --git a/.changeset/9108-props-bag-loud-refusal.md b/.changeset/9108-props-bag-loud-refusal.md new file mode 100644 index 0000000000..d06f84a476 --- /dev/null +++ b/.changeset/9108-props-bag-loud-refusal.md @@ -0,0 +1,11 @@ +--- +"@object-ui/react": patch +--- + +**A node-gate predicate parked under `props` is now REFUSED BY NAME on the console, instead of gating nothing in silence (objectui#9108).** + +A node may spell its config bag `properties` (the spec spelling) or `props` (the annotated legacy alias). `SchemaRenderer` hoists `properties.*` onto the node; nothing copies `props.*`, and both node gates read the post-hoist node — so a predicate that arrived under the 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 `properties: { visible: false }` and `properties: { hidden: true }` each hid correctly. Fail-**open** and silent by construction: a gate that never bit renders exactly like a gate that said yes, so no user and no screenshot can find it. + +**No verdict moves, and that is the ruled outcome rather than a limitation.** The alias is refused, ⛔ not honoured: nothing is hoisted, `schema.` stays undefined for a renderer declared as `({ schema })`, and every element receives the byte-identical props bag it received before. What changes is that the eight node-gate predicate keys (`visibleWhen` / `visible` / `visibleOn` / `visibility` / `hidden` / `hiddenOn` / `disabled` / `disabledOn`) are named on the console when they are parked there, with the migration that fixes them. The maintainer ruling of 2026-09-13 closed the honour arm (PR objectui#9144) on the ground that honouring the alias would have made *"8 predicate keys work while the rest stayed silently dropped — and partly working is harder to learn from than not working"*. + +The line is `console.error`, in development **and in production** — the same posture objectui#6038 gave the unresolvable-predicate report, because a node gate that has stopped biting in production is exactly the defect that must not be able to sit live and undiscovered. It is rate-limited to one line per distinct authoring bug for the lifetime of the page. One measured carve-out: `disabled` on a bag-reading `element:*` node is honoured by that renderer itself (`disabled={props.disabled || running}`), so it is not refused there. diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx index 99d226a18d..c367c810a1 100644 --- a/packages/react/src/SchemaRenderer.tsx +++ b/packages/react/src/SchemaRenderer.tsx @@ -31,7 +31,7 @@ import { usePageVariables } from './hooks/usePageVariables.js'; import { resolveKeyedI18nLabel } from './utils/i18n.js'; import { isConfigBag } from './utils/configBag.js'; import { reportUnevaluatedExpressions } from './utils/unevaluatedExpression.js'; -import { reportDroppedPropsBag } from './utils/propsBagDiagnostic.js'; +import { reportDroppedPropsBag, reportRefusedPropsPredicate } from './utils/propsBagDiagnostic.js'; import { expressionBindableTextKeysFor } from '@objectstack/spec/ui'; import { reportUnresolvableVisibilityPredicate, @@ -330,6 +330,25 @@ const PREDICATE_CHAIN_KEYS: ReadonlySet = new Set([ ...ENABLEMENT_RENDERER_KEYS, ]); +/** + * Every key a NODE GATE in this file actually consults, as ONE lookup for the + * objectui#9108 refusal below. DERIVED from the same two declarations the gates + * are built from, so a leg added to either chain is refused under `props` by the + * same edit that adds it. + * + * {@link PREDICATE_CHAIN_KEYS} minus {@link ENABLEMENT_RENDERER_KEYS}, and the + * subtraction is the whole reason this is a second derivation rather than a + * reuse: `enabled` is in that union because the config-bag evaluation loops + * flatten it, but NO gate here consults it - the action renderers read it one + * layer down off the schema and negate it. Refusing it here would state, of a + * key this file never asks about, that a gate in this file could not see it. + * Its own `props` drop is objectui#6708's subject and is reported there. + */ +const NODE_GATE_PREDICATE_KEYS: ReadonlySet = new Set([ + ...VISIBILITY_CHAIN_KEYS, + ...ENABLEMENT_NODE_GATE_KEYS, +]); + /** * Is this value the canonical CEL envelope — `{ dialect: 'cel', source }` — * that `@objectstack/spec` normalizes an authored predicate into? @@ -1423,6 +1442,45 @@ export const SchemaRenderer: ForwardRefExoticComponent< newSchema.props = newProps; } + /** + * REFUSE, by name, a node-gate predicate parked under the legacy `props` + * alias (objectui#9108, maintainer ruling 2026-09-13, verbatim 「同意」 on + * the `domain:spec` seat's recommendation). + * + * ## Sited HERE, immediately in front of the two gates + * + * This is the one point where the gates' own input is final: the + * `properties` hoist above has run, both config-bag evaluation loops have + * run, and neither gate has consulted anything yet. It is also the only + * placement that survives its own subject - the late diagnostics near + * `createElement` are downstream of `if (shouldHide) return null`, so a node + * that parks `visible` under `props` while ALSO hiding through the canonical + * spelling would never reach them, and the refusal would go missing on the + * one shape that carries both spellings at once. + * + * ## Read-only, and that is the ruled outcome rather than a limitation + * + * Nothing below changes. The gates still read the post-hoist node only, so + * every verdict, every hoisted value and every byte the element receives is + * what it was - the alias is REFUSED, not honoured. The opposite arm was + * built and closed (PR objectui#9144): honouring it would have made *"8 + * predicate keys work while the rest stayed silently dropped - and partly + * working is harder to learn from than not working"*. + * + * The bag handed over is {@link propsWithoutCanonicalKeys}'s, the SAME + * subtraction the outgoing props bag uses, so a key the canonical bag also + * declares is not reported as parked: there the author is already getting + * the canonical answer (objectui#5123). The key SET is + * {@link NODE_GATE_PREDICATE_KEYS}, derived from the two chain declarations + * above rather than re-listed here. + */ + reportRefusedPropsPredicate( + newSchema.type, + newSchema.id, + NODE_GATE_PREDICATE_KEYS, + propsWithoutCanonicalKeys(newSchema.props, newSchema.properties), + ); + // Evaluate visibility: visibleWhen / visible / visibleOn / visibility / hidden / hiddenOn const shouldHide = (() => { // `visibleWhen` is the single canonical conditional-visibility predicate diff --git a/packages/react/src/__tests__/SchemaRenderer.propsBagLoudRefusal.test.tsx b/packages/react/src/__tests__/SchemaRenderer.propsBagLoudRefusal.test.tsx new file mode 100644 index 0000000000..c7436fa68e --- /dev/null +++ b/packages/react/src/__tests__/SchemaRenderer.propsBagLoudRefusal.test.tsx @@ -0,0 +1,315 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#9108 - a node-gate predicate parked under `props` is REFUSED BY + * NAME, loudly: not honoured, and not silently dropped. + * + * ## What was measured, and why it could not be seen + * + * A node may spell its config bag `properties` (the spec spelling) or `props` + * (the annotated legacy alias). `SchemaRenderer`'s hoist copies `properties.*` + * onto the node; nothing copies `props.*`. Both node gates read the post-hoist + * node, so a predicate authored under the alias was never one of the keys + * either gate could see: + * + * | authored | observed | + * |----------------------------------|--------------------| + * | `props: { visible: false }` | the node RENDERED | + * | `props: { hidden: true }` | the node RENDERED | + * | `properties: { visible: false }` | correctly hidden | + * | `properties: { hidden: true }` | correctly hidden | + * + * Fail-OPEN and silent: a gate that never bit renders exactly like a gate that + * said yes, so no user and no screenshot can find it. + * + * ## Which half of that this suite pins + * + * BOTH, and they are not the same assertion. The ruling of 2026-09-13 closed + * the honour arm (PR objectui#9144) and ruled REFUSE, so: + * + * - the VERDICT rows below must read exactly as the defect table above - the + * alias still gates nothing. A suite that only pinned the console line + * would go green on a tree that had quietly started honouring the alias, + * which is the arm the maintainer refused; + * - the REFUSAL rows must name the key on the console. A suite that only + * pinned the verdicts would go green on today's silence, which is the + * defect. + * + * ## Both polarities, one harness, in one file + * + * Every verdict row is measured in BOTH directions - a truthy predicate and a + * falsy one - through the same probe in the same suite. A pair of EQUAL + * verdicts is the signature of a gate that was never consulted, whichever way + * it landed, and that pair is exactly what the `props` rows must still produce. + * The `properties` rows are the live control: "the node is hidden" is equally + * satisfied by a renderer that hides everything, by a broken registry, and by a + * probe that never mounted, so the control has to run here rather than be + * asserted elsewhere. + * + * ## Plain booleans on purpose + * + * objectui#9100 and objectui#9107 are about a CEL envelope being flattened on + * the way to the engine. This defect is present with a plain boolean and no + * expression anywhere, and it predates both repairs, so nothing here carries an + * envelope: an envelope would make a failure ambiguous between the two causes. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRenderer } from '../SchemaRenderer'; +import { SchemaRendererContext } from '../context/SchemaRendererContext'; +import { + REFUSED_PROPS_PREDICATE_PREFIX, + __resetRefusedPropsPredicateWarnings, +} from '../utils/propsBagDiagnostic'; + +/** A plain node type - nothing in this repo reads a config bag for it. */ +const TYPE = 'probe-9108'; + +/** + * A node type in the BAG-READING family. `readsPropsBag` keys on the + * `element:` prefix, so this exercises the family carve-out without pulling + * `@object-ui/components` into this package's test graph. + */ +const ELEMENT_TYPE = 'element:probe-9108'; + +/** + * Reports what a `schema`-reading renderer can see, so one probe answers both + * "did the gate bite?" and "was anything hoisted?". + */ +const Probe = (props: { schema?: Record; disabled?: unknown }) => ( +
+); + +function mount(schema: unknown) { + return render( + + + , + ); +} + +const rendered = (): boolean => screen.queryByTestId('probe') !== null; + +/** One authored bag, mounted twice - predicate `true` then `false`. */ +function pair( + bag: 'props' | 'properties', + key: string, + type: string = TYPE, +): { truthy: boolean; falsy: boolean } { + const once = (value: boolean): boolean => { + mount({ type, [bag]: { [key]: value } }); + const r = rendered(); + cleanup(); + return r; + }; + return { truthy: once(true), falsy: once(false) }; +} + +/** + * Only the lines THIS card emits. `console.error` is a shared channel - React + * and the schema validator both use it - so a spy read raw would pass on the + * wrong line and fail on an unrelated one. + */ +let errorSpy: ReturnType; +const refusals = (): string[] => + errorSpy.mock.calls + .map(args => String(args[0])) + .filter(line => line.startsWith(REFUSED_PROPS_PREDICATE_PREFIX)); + +beforeEach(() => { + ComponentRegistry.register(TYPE, Probe as never); + ComponentRegistry.register(ELEMENT_TYPE, Probe as never); + __resetRefusedPropsPredicateWarnings(); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + errorSpy.mockRestore(); + cleanup(); + ComponentRegistry.unregister?.(TYPE); + ComponentRegistry.unregister?.(ELEMENT_TYPE); +}); + +describe('objectui#9108 - the alias still gates NOTHING (the refused arm)', () => { + // SHOW polarity. The `props` rows must produce a pair of EQUAL verdicts - + // the signature of a gate that was never consulted. The `properties` rows + // are the control and must still discriminate. + it.each([ + ['visibleWhen'], + ['visible'], + ['visibleOn'], + ['visibility'], + ] as const)('props.%s does not gate, properties.%s does', key => { + expect(pair('props', key)).toEqual({ truthy: true, falsy: true }); + expect(pair('properties', key)).toEqual({ truthy: true, falsy: false }); + }); + + // HIDE polarity - the opposite direction, and the reason a one-polarity + // suite would have passed on half the defect. + it.each([['hidden'], ['hiddenOn']] as const)( + 'props.%s does not gate, properties.%s does', + key => { + expect(pair('props', key)).toEqual({ truthy: true, falsy: true }); + expect(pair('properties', key)).toEqual({ truthy: false, falsy: true }); + }, + ); + + // The enablement gate, same bag, quieter symptom: a greyed control is still + // on screen. `disabled` under `props` reaches the element as a React prop and + // is then overwritten by the gate's own `disabled`, which is `undefined` + // because the gate never saw the key. + it('props.disabled does not reach the enablement gate, properties.disabled does', () => { + mount({ type: TYPE, props: { disabled: true } }); + expect(screen.getByTestId('probe').getAttribute('data-disabled')).toBe('false'); + cleanup(); + mount({ type: TYPE, properties: { disabled: true } }); + expect(screen.getByTestId('probe').getAttribute('data-disabled')).toBe('true'); + }); + + // The fence the ruling is built on: the refusal READS the bag to report it. + // If this row ever flips, the alias has been hoisted and the honour arm the + // maintainer closed has come back in through a diagnostic. + it('nothing is hoisted - `schema.visible` stays undefined for the renderer', () => { + mount({ type: TYPE, props: { visible: true } }); + expect(screen.getByTestId('probe').getAttribute('data-node-visible')).toBe('undefined'); + cleanup(); + // The canonical spelling IS hoisted, as it always was - the control that + // proves the assertion above is reading a real attribute. + mount({ type: TYPE, properties: { visible: true } }); + expect(screen.getByTestId('probe').getAttribute('data-node-visible')).toBe('true'); + }); +}); + +describe('objectui#9108 - and it is refused BY NAME, loudly', () => { + it.each([ + ['visibleWhen'], + ['visible'], + ['visibleOn'], + ['visibility'], + ['hidden'], + ['hiddenOn'], + ['disabled'], + ['disabledOn'], + ] as const)('props.%s is named on the console', key => { + mount({ type: TYPE, id: 'n1', props: { [key]: true } }); + const lines = refusals(); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain(`\`${key}\``); + // The address, so the author can find the node rather than the key alone. + expect(lines[0]).toContain(`\`${TYPE}\``); + expect(lines[0]).toContain("id: 'n1'"); + }); + + // The ruling requires the refusal to carry a NAMED MIGRATION LINE rather than + // let half the keys quietly start working. + it('carries the migration line, naming both spellings that do work', () => { + mount({ type: TYPE, props: { visible: false } }); + const [line] = refusals(); + expect(line).toContain('MIGRATION:'); + expect(line).toContain('`properties`'); + expect(line).toContain('visibleWhen'); + expect(line).toContain('objectui#9108'); + }); + + // Two keys in one bag are one authoring mistake and get one line naming both. + it('names every parked key in one line', () => { + mount({ type: TYPE, props: { visible: false, hiddenOn: true } }); + const [line] = refusals(); + expect(refusals()).toHaveLength(1); + expect(line).toContain('`visible`'); + expect(line).toContain('`hiddenOn`'); + }); + + // Both halves of the rate limit, together: a test that pins only the first + // cannot tell a working dedupe from one that suppresses everything. + it('reports once per distinct authoring bug, and a second bug still reports', () => { + mount({ type: TYPE, props: { visible: false } }); + cleanup(); + mount({ type: TYPE, props: { visible: false } }); + expect(refusals()).toHaveLength(1); + cleanup(); + mount({ type: TYPE, id: 'other', props: { hidden: true } }); + expect(refusals()).toHaveLength(2); + }); +}); + +describe('objectui#9108 - what the refusal must stay SILENT about', () => { + it('the canonical spelling is not refused', () => { + mount({ type: TYPE, properties: { visible: false } }); + expect(refusals()).toEqual([]); + }); + + // objectui#5123, maintainer ruling 2026-08-18: `properties` wins on both + // channels, so a key BOTH bags declare is already answered canonically and + // nothing is parked. Measured in both directions, so a suite that reported + // nothing at all could not pass. + it('a key BOTH bags declare is answered by `properties`, and not refused', () => { + mount({ type: TYPE, props: { visible: true }, properties: { visible: false } }); + expect(rendered()).toBe(false); + expect(refusals()).toEqual([]); + cleanup(); + mount({ type: TYPE, props: { visible: false }, properties: { visible: true } }); + expect(rendered()).toBe(true); + expect(refusals()).toEqual([]); + }); + + it('a non-predicate key under `props` is not this card\'s subject', () => { + mount({ type: TYPE, props: { title: 'Customer Summary' } }); + expect(refusals()).toEqual([]); + }); + + // objectui#6752 / objectui#6760: a degenerate bag declares no key for either + // spelling, and must not have its shape reinterpreted here either - the + // refusal must not report nine keys named `0` … `8`. + it('a degenerate `props` refuses nothing', () => { + mount({ type: TYPE, props: 'not-a-bag' }); + expect(rendered()).toBe(true); + expect(refusals()).toEqual([]); + }); + + // `enabled` is in PREDICATE_CHAIN_KEYS because the config-bag loops flatten + // it, but no gate in SchemaRenderer consults it - the action renderers read + // it one layer down and negate it. Refusing it here would state, of a key + // this file never asks about, that a gate in this file could not see it. + it('`enabled` is not a node-gate key and is not refused here', () => { + mount({ type: TYPE, props: { enabled: false } }); + expect(refusals()).toEqual([]); + }); + + // Measured carve-out: `element:button` / `element:text-input` really do read + // `props.disabled` out of the bag (`disabled={props.disabled || running}`), + // so the author got the effect they asked for and a refusal would send them + // looking for a defect that is not on their screen. + it('`disabled` on a bag-reading node is honoured by the renderer, not refused', () => { + mount({ type: ELEMENT_TYPE, props: { disabled: true } }); + expect(refusals()).toEqual([]); + }); + + // ...and the carve-out is exactly one key wide. Nothing in this repo reads a + // visibility-chain key out of a config bag, so those rows stay refused on the + // bag-reading family too - if this row ever goes silent, the carve-out has + // widened from a measurement into a blanket. + it('a visibility key on a bag-reading node is still refused', () => { + mount({ type: ELEMENT_TYPE, props: { visible: false } }); + const lines = refusals(); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('`visible`'); + }); +}); diff --git a/packages/react/src/utils/propsBagDiagnostic.ts b/packages/react/src/utils/propsBagDiagnostic.ts index db7beaa9be..5469d86fba 100644 --- a/packages/react/src/utils/propsBagDiagnostic.ts +++ b/packages/react/src/utils/propsBagDiagnostic.ts @@ -255,3 +255,210 @@ export function reportDroppedPropsBag( console.warn(message); return message; } + +/* -------------------------------------------------------------------------- * + * objectui#9108 - the NODE-GATE half of the same bag + * -------------------------------------------------------------------------- */ + +/** + * Refusal: a NODE-GATE PREDICATE was parked under `props` (objectui#9108, + * maintainer ruling 2026-09-13, verbatim 「同意」 on the `domain:spec` seat's + * recommendation - REFUSE). + * + * ## The defect this refuses + * + * Measured at node level on `1e0e46af9`, four rows, both spellings and both + * polarities: + * + * { type: 'card', props: { visible: false } } -> the node RENDERED + * { type: 'card', props: { hidden: true } } -> the node RENDERED + * { type: 'card', properties: { visible: false } } -> correctly hidden + * { type: 'card', properties: { hidden: true } } -> correctly hidden + * + * `SchemaRenderer` hoists `properties.*` onto the node; NOTHING copies + * `props.*`. Both node gates read the post-hoist node, so a predicate that + * arrived under the alias is never one of the keys either gate can see. It is + * fail-OPEN and silent by construction - a gate that never bit renders exactly + * like a gate that said yes - so no user, no screenshot and no snapshot can + * find it. Only counting can. + * + * ## Why REFUSE and not HONOUR - this is a ruling, not a preference + * + * The opposite arm was implemented and CLOSED (PR objectui#9144). The ruling's + * decisive axis: honouring the alias would have made + * *"8 predicate keys work while the rest stayed silently dropped - and partly + * working is harder to learn from than not working"*, because the author + * generalises "`props` is fine" and collides again on the next key with the + * cause now further away. The three measured readings behind it, each with a + * control that fires: `@objectstack/spec` already refuses `props` by name + * (`unrecognized_keys ["props"]`); `skills/objectui/rules/protocol.md` teaches + * it as an ERROR rather than as a gap; and the producer census found ZERO + * authored `props` bags across `examples/**` and `content/docs/**` (controls: + * `visibleWhen` 9 files, `type` 488 files), so there is no capability to cut. + * + * ## What it deliberately does NOT do + * + * It changes NO verdict and NO rendered byte. Every gate answers exactly what + * it answered before, nothing is hoisted, `schema.` stays undefined for a + * renderer declared as `({ schema })`, and the bag reaches the element with the + * same keys it always did. The trap stops being SILENT; it does not stop being + * a trap, which is the same posture objectui#6708 took one card earlier and the + * only posture a diagnostic can take without becoming the honour arm the ruling + * refused. + * + * ## Why `console.error`, and why NOT dev-only + * + * Two established postures in this tree, and this leg needs a leg of each: + * + * - SEVERITY from `unevaluatedExpression.ts`, which is this repo's REFUSAL + * tier. Its neighbour {@link reportDroppedPropsBag} warns because it + * reports a value that was dropped; the ruling's word here is *refused*, + * and the sibling gate diagnostic states the split explicitly ("not the + * refusal `reportUnevaluatedExpressions` emits"). + * - ALWAYS-ON from `visibilityDiagnostic.ts`'s unresolvable-predicate leg, + * which the maintainer took out of `__DEV__` in objectui#6038 (ruling + * 2026-08-25, option B: "the silence is no longer an accepted property") + * on the ground that a node gate which has stopped biting in production is + * a class-1 defect that must not be able to sit live and undiscovered. + * This is that class in its widest form - the gate never bit at all, on + * every render - and the ruling's own confidence gap is about exactly the + * population a `__DEV__` gate would silence: *"The producer census covers + * this repository's `examples/**`, `content/docs/**` and skills corpus. + * It does NOT measure authored metadata in production. If stored documents + * out there do carry `props`, those authors are already suffering the + * silent drop today."* + * + * The rate limit that makes an always-on console line affordable is the same + * one objectui#6038 required: the dedupe below is keyed on the MESSAGE, so the + * ceiling is one line per distinct authoring bug for the lifetime of the page, + * not one line per render and not one per node. + */ +export const REFUSED_PROPS_PREDICATE_PREFIX = + '[ObjectUI] A node-gate predicate under `props` is REFUSED'; + +/** + * Node-gate predicate keys the BAG-READING family can still honour itself, so + * refusing them there would cry wolf. + * + * Measured on this tree, not guessed: across every renderer that reads a config + * bag ({@link readsPropsBag} - the five `element:*` `readProps()` sites plus + * `view:simple`), the only node-gate predicate key any of them reads out of the + * bag is `disabled`, in `elements.tsx` (`element:button`: + * `disabled={props.disabled || running}`) and `text-input.tsx` + * (`element:text-input`). No visibility-chain key is read from a bag by any + * renderer in this repo, and neither is `disabledOn`. + * + * So `{ type: 'element:button', props: { disabled: true } }` really does + * produce a disabled button - the node GATE did not bite, but the author got + * the effect they asked for, and a refusal there would send them looking for a + * defect that is not on their screen. Every other row stays refused on every + * family, including the `element:*` visibility rows, which nothing honours. + */ +const BAG_HONOURED_GATE_KEYS: ReadonlySet = new Set(['disabled']); + +/** + * Build the refusal message. Separate from the emit so a test can assert the + * words a developer is going to read, not merely that something was logged. + * + * Carries the MIGRATION LINE the ruling requires by name - *"the implementing + * round should pair the refusal with a named migration line in the diagnostic, + * not let half the keys quietly start working"*. It names the keys it refused + * and the two spellings that do work, and it names nothing else: there is no + * per-key canonical-alias table here, because inventing one would be a second + * declaration of ADR-0089's normalization competing with the spec's own. + */ +export function formatRefusedPropsPredicateMessage( + type: unknown, + id: unknown, + refusedKeys: readonly string[], +): string { + const keyList = refusedKeys.map(k => `\`${k}\``).join(', '); + const one = refusedKeys.length === 1; + return ( + `${REFUSED_PROPS_PREDICATE_PREFIX} - node ${describeAddress(type, id)}\n` + + ` ${one ? 'Key' : 'Keys'} parked under \`props\`: ${keyList}\n` + + '`props` is NOT an authoring surface for the node gates. `SchemaRenderer`\n' + + 'hoists `properties.*` onto the node and reads its visibility / enablement\n' + + 'gates from there; nothing copies `props.*`, so this predicate is never a key\n' + + `either gate can see. ${one ? 'It gates' : 'They gate'} nothing - the node renders and stays enabled\n` + + 'exactly as if the predicate said yes, which is why nothing on screen says so.\n' + + ` MIGRATION: move ${one ? 'it' : 'them'} out of \`props\` - onto the node itself\n` + + '(`{ "type": "card", "visibleWhen": ... }`, the spelling the spec declares), or\n' + + 'into the `properties` bag, which IS hoisted. The worked pair is in\n' + + '`skills/objectui/rules/protocol.md`. (objectui#9108)' + ); +} + +/** + * Which node-gate predicate keys of the PARKED `props` bag are refused, or + * `null` when there is nothing to say. + * + * `nodeGateKeys` is passed IN rather than declared here: `SchemaRenderer` owns + * the two chain declarations and derives this union from them, so a leg added + * to either chain is covered by the same edit that adds it and this module + * never grows a twin list to drift from them (AGENTS.md #9). + * + * `parkedPropsBag` is the value of + * `propsWithoutCanonicalKeys(schema.props, schema.properties)` - the same + * subtraction the outgoing bag and the objectui#6708 diagnostic already use, so + * the two cases stay un-confused exactly as they are there: a key BOTH bags + * declare has already been subtracted (objectui#5123: `properties` wins, the + * author is getting the canonical answer and nothing is parked), while a key + * only `props` declares survives into that bag and is precisely the one no gate + * can see. A degenerate `props` contributes no keys through that same function, + * so `props: 'not-a-bag'` refuses nothing here - its defect is a different + * question (objectui#6752). + */ +export function collectRefusedPropsPredicateKeys( + type: unknown, + nodeGateKeys: ReadonlySet, + parkedPropsBag: unknown, +): string[] | null { + if (!isConfigBag(parkedPropsBag)) return null; + const bagReader = readsPropsBag(type); + const refused = Object.keys(parkedPropsBag).filter( + key => nodeGateKeys.has(key) && !(bagReader && BAG_HONOURED_GATE_KEYS.has(key)), + ); + return refused.length > 0 ? refused : null; +} + +/** + * Reported messages, so a re-render - or a second node carrying the same + * authoring bug - does not repeat the line. Module state, exactly like + * {@link reportDroppedPropsBag}'s `Set` next door, and reset the same way for + * tests. + */ +const _refusedPropsPredicates = new Set(); + +/** + * Test-only reset for the dedupe above. Without it the second test to assert + * the same refusal reads the first test's dedupe entry and sees silence - a + * green run that checked nothing. + */ +export function __resetRefusedPropsPredicateWarnings(): void { + _refusedPropsPredicates.clear(); +} + +/** + * Reports a node-gate predicate parked under `props`, in DEVELOPMENT AND IN + * PRODUCTION - read {@link REFUSED_PROPS_PREDICATE_PREFIX}'s docblock for why + * this leg carries neither the `__DEV__` gate nor the `console.warn` severity + * of its neighbour in this module. + * + * Returns the message it emitted (or `null`) so a caller or a test can read the + * decision rather than infer it from a spy. + */ +export function reportRefusedPropsPredicate( + type: unknown, + id: unknown, + nodeGateKeys: ReadonlySet, + parkedPropsBag: unknown, +): string | null { + const refusedKeys = collectRefusedPropsPredicateKeys(type, nodeGateKeys, parkedPropsBag); + if (!refusedKeys) return null; + const message = formatRefusedPropsPredicateMessage(type, id, refusedKeys); + if (_refusedPropsPredicates.has(message)) return null; + _refusedPropsPredicates.add(message); + console.error(message); + return message; +} diff --git a/skills/objectui/rules/protocol.md b/skills/objectui/rules/protocol.md index 9c39edab32..2956046109 100644 --- a/skills/objectui/rules/protocol.md +++ b/skills/objectui/rules/protocol.md @@ -107,9 +107,12 @@ Every `ui:*` / `page:*` renderer reads its configuration off the node — `schema.title`, `schema.content`, `schema.value`, `schema.columns`. `SchemaRenderer` does **not** merge `schema.props` into the node; it spreads it as React props (`packages/react/src/SchemaRenderer.tsx`), which those renderers -ignore. A key parked under `props` is therefore silently dropped: the component -renders an empty frame, and the envelope itself lands in the DOM as the invalid -attribute `props="[object Object]"`. +ignore. A key parked under `props` is therefore dropped: the component renders +an empty frame, and the envelope itself lands in the DOM as the invalid +attribute `props="[object Object]"`. Silent for every key except the node-gate +predicates (`visibleWhen` / `visible` / `visibleOn` / `visibility` / `hidden` / +`hiddenOn` / `disabled` / `disabledOn`), which are refused by name on the +console, with the migration, instead of gating nothing (objectui#9108). **❌ WRONG — renders an empty card:** From 93452d167fa3ea69b00ef209e7ccd7ec1fba71fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 14:57:15 +0000 Subject: [PATCH 2/2] test(react): annotate the refusal spy readers so `noImplicitAny` holds `packages/react`'s `type-check` runs `tsc -p tsconfig.test.json`, which judges the pin file too; `errorSpy.mock.calls` gives its callbacks no contextual type, so the two readers were TS7006. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa --- .../src/__tests__/SchemaRenderer.propsBagLoudRefusal.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react/src/__tests__/SchemaRenderer.propsBagLoudRefusal.test.tsx b/packages/react/src/__tests__/SchemaRenderer.propsBagLoudRefusal.test.tsx index c7436fa68e..c7556f553a 100644 --- a/packages/react/src/__tests__/SchemaRenderer.propsBagLoudRefusal.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.propsBagLoudRefusal.test.tsx @@ -130,8 +130,8 @@ function pair( let errorSpy: ReturnType; const refusals = (): string[] => errorSpy.mock.calls - .map(args => String(args[0])) - .filter(line => line.startsWith(REFUSED_PROPS_PREDICATE_PREFIX)); + .map((args: unknown[]) => String(args[0])) + .filter((line: string) => line.startsWith(REFUSED_PROPS_PREDICATE_PREFIX)); beforeEach(() => { ComponentRegistry.register(TYPE, Probe as never);