diff --git a/.changeset/expression-bindable-text-keys.md b/.changeset/expression-bindable-text-keys.md
new file mode 100644
index 0000000000..d80cde3605
--- /dev/null
+++ b/.changeset/expression-bindable-text-keys.md
@@ -0,0 +1,7 @@
+---
+"@objectstack/spec": minor
+---
+
+Declare the closed vocabulary of expression-bindable text keys (objectui#4795 Direction 1, spec half — #9599).
+
+`@objectstack/spec/ui` now exports `EXPRESSION_BINDABLE_TEXT_KEYS` (`title` / `label` / `value` / `description` — a closed enum per the 2026-08-17 maintainer ruling's terms, reopened 2026-08-18), the `ExpressionBindableTextKey` type and `ExpressionBindableTextKeySchema` Zod face, the per-component carriage map `EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT` (`statistic`: `label`/`value`/`description`, `card`: `title`/`description`, `button`: `label` — measured against the objectui renderers' read points at the `.objectui-sha` pin), and the runtime lookup `expressionBindableTextKeysFor(componentType)`. These are consumed by the objectui SchemaRenderer evaluation memo (the downstream half, riding objectui#4795) so the set of top-level text keys the memo evaluates is declared here once, never inferred or hard-coded as a twin list. Purely additive — no existing schema accepts or rejects anything new in this release.
diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx
index 1c4d5475fe..d52e165136 100644
--- a/content/docs/getting-started/quick-reference.mdx
+++ b/content/docs/getting-started/quick-reference.mdx
@@ -46,7 +46,7 @@ Core business logic and data modeling schemas.
| **[Postgres Driver](/docs/references/data/driver-postgres)** | `driver/postgres.zod.ts` | PostgresConfig | PostgreSQL configuration |
| **[Mongo Driver](/docs/references/data/driver-mongo)** | `driver/mongo.zod.ts` | MongoConfig | MongoDB configuration |
-## UI Protocol (11 of 16 schemas)
+## UI Protocol (11 of 17 schemas)
Presentation layer - views, forms, dashboards, and themes.
diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx
index e357f7fabb..13f0a57890 100644
--- a/content/docs/references/index.mdx
+++ b/content/docs/references/index.mdx
@@ -1,6 +1,6 @@
---
title: Protocol Reference
-description: Every schema published by @objectstack/spec — 1587 schemas across 14 protocol modules
+description: Every schema published by @objectstack/spec — 1588 schemas across 14 protocol modules
---
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -32,8 +32,8 @@ counts are sums of the rows they head. Regenerate with
| [Shared Protocol](/docs/references/shared) | 8 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 36 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
-| [UI Protocol](/docs/references/ui) | 16 | 161 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
-| **Total** | **198** | **1587** | 14 protocol modules |
+| [UI Protocol](/docs/references/ui) | 17 | 162 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
+| **Total** | **199** | **1588** | 14 protocol modules |
---
@@ -364,7 +364,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a
## UI Protocol
-**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 161 schemas**
+**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **17 pages, 162 schemas**
Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer.
@@ -378,6 +378,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay
| [`component.zod.ts`](/docs/references/ui/component) | `AIChatWindowProps`, `ElementButtonProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementTextInputProps`, `ElementTextProps`, `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGridProps`, `ObjectKanbanProps`, `ObjectMasterDetailFormProps`, `ObjectMetricProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordAlertAction`, `RecordAlertProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordHistoryProps`, `RecordPathProps`, `RecordQuickActionsProps`, `RecordReferenceRailProps`, `RecordRelatedListProps`, `ReferenceRailEntry` |
| [`dashboard.zod.ts`](/docs/references/ui/dashboard) | `Dashboard`, `DashboardHeader`, `DashboardHeaderAction`, `DashboardWidget`, `DashboardWidgetOptions`, `GlobalFilter`, `GlobalFilterOptionsFrom`, `WidgetActionType`, `WidgetColorVariant` |
| [`dataset.zod.ts`](/docs/references/ui/dataset) | `Dataset`, `DatasetDimension`, `DatasetMeasure`, `DerivedMeasureOp` |
+| [`expression-bindable-text-keys.zod.ts`](/docs/references/ui/expression-bindable-text-keys) | `ExpressionBindableTextKey` |
| [`i18n.zod.ts`](/docs/references/ui/i18n) | `AriaProps`, `I18nLabel`, `InlineLocaleMap` |
| [`notification.zod.ts`](/docs/references/ui/notification) | `NotificationPosition`, `NotificationSeverity`, `NotificationType` |
| [`page.zod.ts`](/docs/references/ui/page) | `ElementDataSource`, `InterfacePageConfig`, `Page`, `PageComponent`, `PageComponentType`, `PageRegion`, `PageType`, `PageVariable` |
diff --git a/content/docs/references/ui/expression-bindable-text-keys.mdx b/content/docs/references/ui/expression-bindable-text-keys.mdx
new file mode 100644
index 0000000000..cb17abba6c
--- /dev/null
+++ b/content/docs/references/ui/expression-bindable-text-keys.mdx
@@ -0,0 +1,102 @@
+---
+title: Expression Bindable Text Keys
+description: Expression Bindable Text Keys protocol schemas
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
+
+Expression-bindable text keys — the CLOSED vocabulary of top-level text keys
+a SchemaRenderer evaluation memo evaluates, and the per-component carriage
+map that says which component types carry which of them.
+
+## Provenance (not re-litigable here)
+
+objectui#4795 measured the hole: apart from `content`, no top-level text key
+on a rendered SDUI node was BOTH evaluated by the renderer's expression memo
+AND read back by the component renderer — `statistic.value: '${data.n}'`
+rendered the literal `${data.n}`, and the `props`-envelope workaround
+rendered blank. The 2026-08-17 maintainer ruling on that card deferred the
+fix (Direction 1) behind a restart condition and pre-defined its terms,
+verbatim: *"the key set is declared in `@objectstack/spec`/types as a closed
+enum, never inferred"*. On 2026-08-18 the maintainer ruled the restart
+condition met by product intent (a metadata-authored live dashboard is a
+basic requirement), reopening Direction 1 on those terms. This module is the
+spec half (objectstack#9599); the objectui half extends the evaluation memo
+to CONSUME these exports (it rides objectui#4795) instead of hard-coding a
+twin list.
+
+Related, and deliberately out of this module's reach:
+
+ - ⛔ Direction 2 (merging the `props` envelope into the node) is
+ permanently rejected (same 08-17 ruling) — nothing here re-legalizes the
+ envelope shape the objectui#4786 teaching rewrite retired.
+ - `content` is NOT a member. It already has its own evaluation leg in the
+ memo and its own read-back contract; adding it here would give one key
+ two declared evaluation paths.
+ - The `properties` / `props` config bags are evaluated per-value by their
+ own memo legs (objectui#4799 / #5122) — this vocabulary is only about
+ keys authored at the NODE'S TOP LEVEL.
+
+## The contract
+
+For a component type with a row in
+`EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT`, the renderer's
+evaluation memo evaluates exactly the listed keys (when the authored value
+is an expression-bearing string); every other key stays inert text. For a
+component type with NO row, the answer is the empty set — closed and
+mechanically answerable in both directions, never inferred from what a
+renderer happens to read.
+
+## Why these rows (measured, not inferred)
+
+Rows are a RECORD of what each component renderer already reads back from
+the node's top level, measured at the objectui pin (`.objectui-sha`
+`82a9417`, re-verified identical at objectui `origin/main` `6c68b13` on
+2026-08-18) — declaring a key a renderer does not read back would recreate
+the evaluated-but-blank half of the objectui#4795 table:
+
+ | type | renderer read points (top level, within the closed set) |
+ |-------------|----------------------------------------------------------|
+ | `statistic` | `schema.label`, `schema.value`, `schema.description` (`data-display/statistic.tsx`) |
+ | `card` | `schema.title`, `schema.description` (`layout/card.tsx`) |
+ | `button` | `schema.label` (`form/button.tsx`, `action/action-button.tsx`) |
+
+These are the measured motivating cases from objectui#4795 (dashboard
+workhorses). 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: form-control `value` is interactive state rather than display
+text, and each row is an accept-surface widening that should arrive with its
+own measurement, not ride this one (startup scope discipline). Adding a row
+is additive and spec-first; do it here, never as a renderer-side inference.
+
+
+**Source:** `packages/spec/src/ui/expression-bindable-text-keys.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { ExpressionBindableTextKeySchema } from '@objectstack/spec/ui';
+import type { ExpressionBindableTextKey } from '@objectstack/spec/ui';
+
+// Validate data
+const result = ExpressionBindableTextKeySchema.parse(data);
+```
+
+---
+
+## ExpressionBindableTextKey
+
+One of the closed set of expression-bindable text keys — the top-level node keys a SchemaRenderer evaluation memo may evaluate (objectui#4795 Direction 1; carriage per component type is EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT).
+
+### Allowed Values
+
+* `title`
+* `label`
+* `value`
+* `description`
+
+
+---
+
diff --git a/content/docs/references/ui/index.mdx b/content/docs/references/ui/index.mdx
index 5e371eab14..19cb0ebd07 100644
--- a/content/docs/references/ui/index.mdx
+++ b/content/docs/references/ui/index.mdx
@@ -14,6 +14,7 @@ This section contains all protocol schemas for the ui layer of ObjectStack.
+
diff --git a/content/docs/references/ui/meta.json b/content/docs/references/ui/meta.json
index af1834543f..5eedbea53a 100644
--- a/content/docs/references/ui/meta.json
+++ b/content/docs/references/ui/meta.json
@@ -12,6 +12,7 @@
"component",
"dashboard",
"dataset",
+ "expression-bindable-text-keys",
"report",
"---Interaction & Layout---",
"responsive",
diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json
index bccfee4611..972e7560f4 100644
--- a/packages/spec/api-surface/ui.json
+++ b/packages/spec/api-surface/ui.json
@@ -135,6 +135,8 @@
"DateRangePreset (type)",
"DerivedMeasureOp (const)",
"DerivedMeasureOpValue (type)",
+ "EXPRESSION_BINDABLE_TEXT_KEYS (const)",
+ "EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT (const)",
"ElementButtonPropsSchema (const)",
"ElementDataSource (type)",
"ElementDataSourceSchema (const)",
@@ -150,6 +152,8 @@
"ElementTextPropsSchema (const)",
"ExpandViewResult (interface)",
"ExpandedViewItem (interface)",
+ "ExpressionBindableTextKey (type)",
+ "ExpressionBindableTextKeySchema (const)",
"FieldWidgetProps (type)",
"FieldWidgetPropsParsed (type)",
"FieldWidgetPropsSchema (const)",
@@ -422,8 +426,10 @@
"diagnoseViewMetadata (function)",
"expandViewContainer (function)",
"expandViewContainerWithDiagnostics (function)",
+ "expressionBindableTextKeysFor (function)",
"isActionParamValuePresent (function)",
"isAggregatedViewContainer (function)",
+ "isExpressionBindableTextKey (function)",
"isRecordContextBlockType (function)",
"isViewContainerShaped (function)",
"normalizeFilterOperator (function)",
diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json
index 238ec96749..23c74a8f86 100644
--- a/packages/spec/export-origins/ui.json
+++ b/packages/spec/export-origins/ui.json
@@ -135,6 +135,8 @@
"DateRangePreset": "src/data/date-range-presets.ts#DateRangePreset (type)",
"DerivedMeasureOp": "src/ui/dataset.zod.ts#DerivedMeasureOp (const)",
"DerivedMeasureOpValue": "src/ui/dataset.zod.ts#DerivedMeasureOpValue (type)",
+ "EXPRESSION_BINDABLE_TEXT_KEYS": "src/ui/expression-bindable-text-keys.zod.ts#EXPRESSION_BINDABLE_TEXT_KEYS (const)",
+ "EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT": "src/ui/expression-bindable-text-keys.zod.ts#EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT (const)",
"ElementButtonPropsSchema": "src/ui/component.zod.ts#ElementButtonPropsSchema (const)",
"ElementDataSource": "src/ui/page.zod.ts#ElementDataSource (type)",
"ElementDataSourceSchema": "src/ui/page.zod.ts#ElementDataSourceSchema (const)",
@@ -150,6 +152,8 @@
"ElementTextPropsSchema": "src/ui/component.zod.ts#ElementTextPropsSchema (const)",
"ExpandViewResult": "src/ui/view.zod.ts#ExpandViewResult (interface)",
"ExpandedViewItem": "src/ui/view.zod.ts#ExpandedViewItem (interface)",
+ "ExpressionBindableTextKey": "src/ui/expression-bindable-text-keys.zod.ts#ExpressionBindableTextKey (type)",
+ "ExpressionBindableTextKeySchema": "src/ui/expression-bindable-text-keys.zod.ts#ExpressionBindableTextKeySchema (const)",
"FieldWidgetProps": "src/ui/widget.zod.ts#FieldWidgetProps (type)",
"FieldWidgetPropsParsed": "src/ui/widget.zod.ts#FieldWidgetPropsParsed (type)",
"FieldWidgetPropsSchema": "src/ui/widget.zod.ts#FieldWidgetPropsSchema (const)",
@@ -422,8 +426,10 @@
"diagnoseViewMetadata": "src/ui/view.zod.ts#diagnoseViewMetadata (function)",
"expandViewContainer": "src/ui/view.zod.ts#expandViewContainer (function)",
"expandViewContainerWithDiagnostics": "src/ui/view.zod.ts#expandViewContainerWithDiagnostics (function)",
+ "expressionBindableTextKeysFor": "src/ui/expression-bindable-text-keys.zod.ts#expressionBindableTextKeysFor (function)",
"isActionParamValuePresent": "src/ui/action-params.zod.ts#isActionParamValuePresent (function)",
"isAggregatedViewContainer": "src/ui/view.zod.ts#isAggregatedViewContainer (function)",
+ "isExpressionBindableTextKey": "src/ui/expression-bindable-text-keys.zod.ts#isExpressionBindableTextKey (function)",
"isRecordContextBlockType": "src/ui/react-blocks.ts#isRecordContextBlockType (function)",
"isViewContainerShaped": "src/ui/assembled-views.zod.ts#isViewContainerShaped (function)",
"normalizeFilterOperator": "src/ui/view.zod.ts#normalizeFilterOperator (function)",
diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json
index 7614d4352f..eb2b124e4e 100644
--- a/packages/spec/json-schema.manifest/ui.json
+++ b/packages/spec/json-schema.manifest/ui.json
@@ -60,6 +60,7 @@
"ui/ElementRecordPickerProps",
"ui/ElementTextInputProps",
"ui/ElementTextProps",
+ "ui/ExpressionBindableTextKey",
"ui/FormButtonConfig",
"ui/FormField",
"ui/FormFieldPublicPicker",
diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts
index 37e8950aff..a7accdcafe 100644
--- a/packages/spec/scripts/build-docs.ts
+++ b/packages/spec/scripts/build-docs.ts
@@ -544,7 +544,12 @@ const SECTION_GROUPS: Record
// #5697), and a reader who found the action declaration should find what a
// body receives in the same section rather than under "More".
{ section: 'Apps & Navigation', pages: ['app', 'page', 'view', 'action', 'action-params'] },
- { section: 'Visualization', pages: ['chart', 'dashboard', 'dataset', 'report', 'widget', 'component'] },
+ // `expression-bindable-text-keys` sits beside `component` deliberately: it
+ // is the SDUI rendering contract's evaluation vocabulary (objectui#4795
+ // Direction 1 — which top-level text keys the renderer's expression memo
+ // evaluates, per component type), and a reader who found the component
+ // prop schemas should find it in the same section rather than under "More".
+ { section: 'Visualization', pages: ['chart', 'dashboard', 'dataset', 'report', 'widget', 'component', 'expression-bindable-text-keys'] },
// `animation` / `dnd` / `keyboard` / `touch` / `offline` left this section at
// #4988: the five `ui/` interaction config modules were retired whole
// (ADR-0049 — no carrier key, nothing parsed them), and their generated
diff --git a/packages/spec/src/ui/expression-bindable-text-keys.test.ts b/packages/spec/src/ui/expression-bindable-text-keys.test.ts
new file mode 100644
index 0000000000..67b9479387
--- /dev/null
+++ b/packages/spec/src/ui/expression-bindable-text-keys.test.ts
@@ -0,0 +1,132 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Pins for the closed expression-bindable text-key vocabulary (objectui#4795
+ * Direction 1, spec half — objectstack#9599). The contract these tests hold:
+ *
+ * 1. The key set is CLOSED at the four ruled members — a fifth key appearing
+ * here must arrive through a maintainer ruling, and this pin is what makes
+ * that loud instead of a silent accept-surface widening.
+ * 2. The per-component map answers mechanically for EVERY type string —
+ * listed rows with their measured subsets, everything else with the empty
+ * set — and the runtime shape is frozen, because the objectui memo reads
+ * it at render time.
+ * 3. `content` stays OUT: it has its own evaluation leg in the memo, and one
+ * key must not have two declared evaluation paths.
+ */
+import { describe, it, expect } from 'vitest';
+import {
+ EXPRESSION_BINDABLE_TEXT_KEYS,
+ EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT,
+ ExpressionBindableTextKeySchema,
+ expressionBindableTextKeysFor,
+ isExpressionBindableTextKey,
+} from './expression-bindable-text-keys.zod';
+
+describe('EXPRESSION_BINDABLE_TEXT_KEYS — the closed enum', () => {
+ it('is exactly the four ruled keys, in the ruling’s order', () => {
+ expect(EXPRESSION_BINDABLE_TEXT_KEYS).toEqual([
+ 'title',
+ 'label',
+ 'value',
+ 'description',
+ ]);
+ });
+
+ it('the Zod face accepts each member and only the members', () => {
+ for (const key of EXPRESSION_BINDABLE_TEXT_KEYS) {
+ expect(ExpressionBindableTextKeySchema.safeParse(key).success).toBe(true);
+ }
+ // `content` is deliberately excluded (it has its own evaluation leg);
+ // `header` is a near-miss card key; casing is not forgiven.
+ for (const nonMember of ['content', 'header', 'Title', 'LABEL', '']) {
+ expect(ExpressionBindableTextKeySchema.safeParse(nonMember).success).toBe(
+ false
+ );
+ }
+ });
+
+ it('isExpressionBindableTextKey agrees with the enum in both directions', () => {
+ for (const key of EXPRESSION_BINDABLE_TEXT_KEYS) {
+ expect(isExpressionBindableTextKey(key)).toBe(true);
+ }
+ expect(isExpressionBindableTextKey('content')).toBe(false);
+ expect(isExpressionBindableTextKey('subtitle')).toBe(false);
+ });
+});
+
+describe('EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT — measured carriage', () => {
+ it('matches the renderer read points measured at the objectui pin', () => {
+ // data-display/statistic.tsx reads schema.label / .value / .description;
+ // layout/card.tsx reads schema.title / .description;
+ // form/button.tsx (and action/action-button.tsx) read schema.label.
+ expect(EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT).toEqual({
+ statistic: ['label', 'value', 'description'],
+ card: ['title', 'description'],
+ button: ['label'],
+ });
+ });
+
+ it('every declared key is a member of the closed enum, with no duplicates', () => {
+ for (const [type, keys] of Object.entries(
+ EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT
+ )) {
+ expect(keys.length, `row ${type}`).toBeGreaterThan(0);
+ expect(new Set(keys).size, `row ${type} has duplicates`).toBe(keys.length);
+ for (const key of keys) {
+ expect(
+ isExpressionBindableTextKey(key),
+ `row ${type} carries non-member key ${key}`
+ ).toBe(true);
+ }
+ }
+ });
+
+ it('the map and its rows are frozen — the contract cannot be mutated by a consumer', () => {
+ expect(Object.isFrozen(EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT)).toBe(
+ true
+ );
+ for (const keys of Object.values(
+ EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT
+ )) {
+ expect(Object.isFrozen(keys)).toBe(true);
+ }
+ });
+});
+
+describe('expressionBindableTextKeysFor — the mechanical per-type answer', () => {
+ it('returns the declared row for a listed type', () => {
+ expect(expressionBindableTextKeysFor('statistic')).toEqual([
+ 'label',
+ 'value',
+ 'description',
+ ]);
+ expect(expressionBindableTextKeysFor('card')).toEqual([
+ 'title',
+ 'description',
+ ]);
+ expect(expressionBindableTextKeysFor('button')).toEqual(['label']);
+ });
+
+ it('answers the empty set for every unlisted type — closed, never inferred', () => {
+ // `text` binds through its own `content` leg; `element:*` / `page:*`
+ // config rides the evaluated `properties` bag — none of them get rows
+ // inferred from what their renderers happen to read.
+ for (const type of ['text', 'element:text', 'page:card', 'alert', '']) {
+ const keys = expressionBindableTextKeysFor(type);
+ expect(keys).toEqual([]);
+ expect(Object.isFrozen(keys)).toBe(true);
+ }
+ });
+
+ it('prototype-chain names are not rows', () => {
+ for (const hostile of [
+ 'constructor',
+ '__proto__',
+ 'toString',
+ 'hasOwnProperty',
+ ]) {
+ expect(expressionBindableTextKeysFor(hostile)).toEqual([]);
+ }
+ });
+});
diff --git a/packages/spec/src/ui/expression-bindable-text-keys.zod.ts b/packages/spec/src/ui/expression-bindable-text-keys.zod.ts
new file mode 100644
index 0000000000..ff75acdce9
--- /dev/null
+++ b/packages/spec/src/ui/expression-bindable-text-keys.zod.ts
@@ -0,0 +1,140 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Expression-bindable text keys — the CLOSED vocabulary of top-level text keys
+ * a SchemaRenderer evaluation memo evaluates, and the per-component carriage
+ * map that says which component types carry which of them.
+ *
+ * ## Provenance (not re-litigable here)
+ *
+ * objectui#4795 measured the hole: apart from `content`, no top-level text key
+ * on a rendered SDUI node was BOTH evaluated by the renderer's expression memo
+ * AND read back by the component renderer — `statistic.value: '${data.n}'`
+ * rendered the literal `${data.n}`, and the `props`-envelope workaround
+ * rendered blank. The 2026-08-17 maintainer ruling on that card deferred the
+ * fix (Direction 1) behind a restart condition and pre-defined its terms,
+ * verbatim: *"the key set is declared in `@objectstack/spec`/types as a closed
+ * enum, never inferred"*. On 2026-08-18 the maintainer ruled the restart
+ * condition met by product intent (a metadata-authored live dashboard is a
+ * basic requirement), reopening Direction 1 on those terms. This module is the
+ * spec half (objectstack#9599); the objectui half extends the evaluation memo
+ * to CONSUME these exports (it rides objectui#4795) instead of hard-coding a
+ * twin list.
+ *
+ * Related, and deliberately out of this module's reach:
+ *
+ * - ⛔ Direction 2 (merging the `props` envelope into the node) is
+ * permanently rejected (same 08-17 ruling) — nothing here re-legalizes the
+ * envelope shape the objectui#4786 teaching rewrite retired.
+ * - `content` is NOT a member. It already has its own evaluation leg in the
+ * memo and its own read-back contract; adding it here would give one key
+ * two declared evaluation paths.
+ * - The `properties` / `props` config bags are evaluated per-value by their
+ * own memo legs (objectui#4799 / #5122) — this vocabulary is only about
+ * keys authored at the NODE'S TOP LEVEL.
+ *
+ * ## The contract
+ *
+ * For a component type with a row in
+ * {@link EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT}, the renderer's
+ * evaluation memo evaluates exactly the listed keys (when the authored value
+ * is an expression-bearing string); every other key stays inert text. For a
+ * component type with NO row, the answer is the empty set — closed and
+ * mechanically answerable in both directions, never inferred from what a
+ * renderer happens to read.
+ *
+ * ## Why these rows (measured, not inferred)
+ *
+ * Rows are a RECORD of what each component renderer already reads back from
+ * the node's top level, measured at the objectui pin (`.objectui-sha`
+ * `82a9417`, re-verified identical at objectui `origin/main` `6c68b13` on
+ * 2026-08-18) — declaring a key a renderer does not read back would recreate
+ * the evaluated-but-blank half of the objectui#4795 table:
+ *
+ * | type | renderer read points (top level, within the closed set) |
+ * |-------------|----------------------------------------------------------|
+ * | `statistic` | `schema.label`, `schema.value`, `schema.description` (`data-display/statistic.tsx`) |
+ * | `card` | `schema.title`, `schema.description` (`layout/card.tsx`) |
+ * | `button` | `schema.label` (`form/button.tsx`, `action/action-button.tsx`) |
+ *
+ * These are the measured motivating cases from objectui#4795 (dashboard
+ * workhorses). 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: form-control `value` is interactive state rather than display
+ * text, and each row is an accept-surface widening that should arrive with its
+ * own measurement, not ride this one (startup scope discipline). Adding a row
+ * is additive and spec-first; do it here, never as a renderer-side inference.
+ */
+
+import { z } from 'zod';
+
+/**
+ * The closed key vocabulary, per the 08-17 ruling's terms. Order is the
+ * ruling's own listing order; the enum is CLOSED — growing it is a maintainer
+ * decision (a new ruling), not a patch.
+ */
+export const EXPRESSION_BINDABLE_TEXT_KEYS = [
+ 'title',
+ 'label',
+ 'value',
+ 'description',
+] as const;
+
+export type ExpressionBindableTextKey =
+ (typeof EXPRESSION_BINDABLE_TEXT_KEYS)[number];
+
+/** Validating face of the closed vocabulary. */
+export const ExpressionBindableTextKeySchema = z
+ .enum(EXPRESSION_BINDABLE_TEXT_KEYS)
+ .describe(
+ 'One of the closed set of expression-bindable text keys — the top-level node keys a SchemaRenderer evaluation memo may evaluate (objectui#4795 Direction 1; carriage per component type is EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT).'
+ );
+
+export function isExpressionBindableTextKey(
+ key: string
+): key is ExpressionBindableTextKey {
+ return (EXPRESSION_BINDABLE_TEXT_KEYS as readonly string[]).includes(key);
+}
+
+/**
+ * Component type → the subset of {@link EXPRESSION_BINDABLE_TEXT_KEYS} its
+ * renderer both evaluates and reads back. Runtime-readable on purpose: the
+ * objectui memo consumes this map (via {@link expressionBindableTextKeysFor}),
+ * it does not keep a twin list. Frozen — the map IS the contract, and a
+ * consumer mutating its copy would fork it silently.
+ */
+export const EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT = Object.freeze({
+ statistic: Object.freeze(['label', 'value', 'description'] as const),
+ card: Object.freeze(['title', 'description'] as const),
+ button: Object.freeze(['label'] as const),
+}) satisfies Readonly<
+ Record
+>;
+
+const NO_EXPRESSION_BINDABLE_TEXT_KEYS: readonly ExpressionBindableTextKey[] =
+ Object.freeze([]);
+
+/**
+ * The mechanical per-component answer: which of the closed keys does the
+ * evaluation memo evaluate for `componentType`? Unlisted types get the frozen
+ * empty set — that IS the contract for them (closed, never inferred), not a
+ * tolerant fallback. Guarded with an own-property check so prototype-chain
+ * names (`'constructor'`, `'toString'`, …) arriving as author-controlled type
+ * strings cannot answer with a function off `Object.prototype`.
+ */
+export function expressionBindableTextKeysFor(
+ componentType: string
+): readonly ExpressionBindableTextKey[] {
+ if (
+ Object.prototype.hasOwnProperty.call(
+ EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT,
+ componentType
+ )
+ ) {
+ return EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT[
+ componentType as keyof typeof EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT
+ ];
+ }
+ return NO_EXPRESSION_BINDABLE_TEXT_KEYS;
+}
diff --git a/packages/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts
index 51bc61bf76..97ec9a2123 100644
--- a/packages/spec/src/ui/index.ts
+++ b/packages/spec/src/ui/index.ts
@@ -76,3 +76,9 @@ export * from './sharing.zod';
// validated dead slot is the more convincing lie (#4583). Absence and survival
// are both pinned in `interaction-config-retirement.test.ts`.
// ---------------------------------------------------------------------------
+
+// The closed expression-bindable text-key vocabulary (objectui#4795
+// Direction 1, spec half — #9599): the four keys a SchemaRenderer evaluation
+// memo may evaluate at a node's top level, plus the per-component carriage
+// map the objectui memo consumes instead of hard-coding a twin list.
+export * from './expression-bindable-text-keys.zod';