Found while implementing the objectui half of the expression-bindable text keys (objectui#4795, consuming this module; PR objectstack-ai/objectui#6981). Filing rather than fixing: the module's own docblock says a row is "additive and spec-first; do it here, never as a renderer-side inference", so this is not the renderer's to decide.
The gap
src/ui/expression-bindable-text-keys.zod.ts declares rows for statistic, card and button. The docblock justifies the omissions as follows (verbatim):
Other registered renderers also read keys from this closed set at the top level (alert/empty/dialog title+description, badge label, form inputs' value/label, …) — those rows are deliberately NOT declared yet
text is not in that list of known-omitted readers, and it should be. packages/components/src/renderers/basic/text.tsx in objectui renders:
{schema.content || schema.value}
So value on a text node is a top-level read-back site — the exact property the rows are described as recording ("a RECORD of what each component renderer already reads back from the node's top level"). It reads back, it is in the closed key vocabulary, and it has no row, so expressionBindableTextKeysFor('text') returns the empty set and the memo leaves it inert.
Why it matters more than the other omissions
The omitted rows the docblock names are unused shapes. This one is actively taught. A census of objectui's authored corpus (736 JSON documents plus json doc fences, 2747 typed nodes) found 23 occurrences of type: "text" with a ${…} expression in value — the single largest class of expression-bearing top-level keys in the repo, concentrated in the platform's own expression guide:
content/docs/guide/expressions.md type=text value='Hello, ${user.name}!'
content/docs/guide/expressions.md type=text value='${user.firstName}'
content/docs/guide/expressions.md type=text value='${user.address.city}'
content/docs/guide/expressions.md type=text value='Total: ${price * quantity}'
content/docs/guide/architecture.md type=text value='Welcome, ${user.firstName} ${user.lastName}!'
… 18 more
Every one of those renders the literal ${user.name} on screen today. This is the same silent-literal shape objectui#4795 was filed for, on the surface most likely to be copied by a human or an AI following the docs.
Why this blocks something concrete
objectui#4795's second ruled half is a build-time rejection of ${…} in keys outside the declared set. Its scope is ambiguous precisely here:
- Reject by key name (the ruling's literal wording) —
text.value is accepted, because value is in the closed four. It then keeps rendering a literal, so the ruling's stated goal ("no more … literal ${data.n} reaching users") is missed on its largest class.
- Reject by carriage map (what the memo will actually evaluate for that type) —
text.value is rejected, which fails 23 of the platform's own documented examples.
A text → value row would dissolve that tension for the largest class: the examples become correct rather than either broken-and-accepted or broken-and-rejected.
Options
- Add the row
text: ['value']. Additive, spec-first, matches the measured read point, and makes the existing documented examples work. Cost: it widens the accept surface, and text already has content as its evaluated channel — so text would carry two evaluated text keys with content winning the ||.
- Declare
text.value deliberately out, and record it in the docblock's known-omitted list. Then objectui's docs must stop teaching text.value with expressions (a docs change of ~23 occurrences), and the build-time gate can reject it with a clear conscience.
- Leave as-is — but then the omission is undocumented, which is what makes it a finding rather than a decision already taken.
Recommendation: (1) or (2), not (3) — either is defensible, but the current state has the docs and the declaration disagreeing with no record that anyone weighed it. (2) is the more scope-disciplined answer if content is considered the intended channel for text; (1) is the lower-friction answer for existing authors. Maintainer's call — it changes the accepted authoring surface either way.
Provenance
Measured on objectui origin/main 7a5da1414 with @objectstack/spec@17.2.0 installed. The renderer read point and the corpus counts are both reproducible from that tree; the census script is recorded in PR objectstack-ai/objectui#6981's discussion.
Found while implementing the objectui half of the expression-bindable text keys (objectui#4795, consuming this module; PR objectstack-ai/objectui#6981). Filing rather than fixing: the module's own docblock says a row is "additive and spec-first; do it here, never as a renderer-side inference", so this is not the renderer's to decide.
The gap
src/ui/expression-bindable-text-keys.zod.tsdeclares rows forstatistic,cardandbutton. The docblock justifies the omissions as follows (verbatim):textis not in that list of known-omitted readers, and it should be.packages/components/src/renderers/basic/text.tsxin objectui renders:So
valueon atextnode is a top-level read-back site — the exact property the rows are described as recording ("a RECORD of what each component renderer already reads back from the node's top level"). It reads back, it is in the closed key vocabulary, and it has no row, soexpressionBindableTextKeysFor('text')returns the empty set and the memo leaves it inert.Why it matters more than the other omissions
The omitted rows the docblock names are unused shapes. This one is actively taught. A census of objectui's authored corpus (736 JSON documents plus
jsondoc fences, 2747 typed nodes) found 23 occurrences oftype: "text"with a${…}expression invalue— the single largest class of expression-bearing top-level keys in the repo, concentrated in the platform's own expression guide:Every one of those renders the literal
${user.name}on screen today. This is the same silent-literal shape objectui#4795 was filed for, on the surface most likely to be copied by a human or an AI following the docs.Why this blocks something concrete
objectui#4795's second ruled half is a build-time rejection of
${…}in keys outside the declared set. Its scope is ambiguous precisely here:text.valueis accepted, becausevalueis in the closed four. It then keeps rendering a literal, so the ruling's stated goal ("no more … literal${data.n}reaching users") is missed on its largest class.text.valueis rejected, which fails 23 of the platform's own documented examples.A
text→valuerow would dissolve that tension for the largest class: the examples become correct rather than either broken-and-accepted or broken-and-rejected.Options
text: ['value']. Additive, spec-first, matches the measured read point, and makes the existing documented examples work. Cost: it widens the accept surface, andtextalready hascontentas its evaluated channel — sotextwould carry two evaluated text keys withcontentwinning the||.text.valuedeliberately out, and record it in the docblock's known-omitted list. Then objectui's docs must stop teachingtext.valuewith expressions (a docs change of ~23 occurrences), and the build-time gate can reject it with a clear conscience.Recommendation: (1) or (2), not (3) — either is defensible, but the current state has the docs and the declaration disagreeing with no record that anyone weighed it. (2) is the more scope-disciplined answer if
contentis considered the intended channel fortext; (1) is the lower-friction answer for existing authors. Maintainer's call — it changes the accepted authoring surface either way.Provenance
Measured on objectui
origin/main7a5da1414with@objectstack/spec@17.2.0installed. The renderer read point and the corpus counts are both reproducible from that tree; the census script is recorded in PR objectstack-ai/objectui#6981's discussion.