From 13e780bb1e79a9c35336bc90275ba197c8e63ee7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:30:23 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat(spec):=20a=20greyed=20action=20says=20?= =?UTF-8?q?why=20=E2=80=94=20`disabledReason`=20beside=20`disabled`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ActionSchema gains an optional, display-only I18nLabel `disabledReason` (blank refused), translated at objects.OBJECT._actions.ACTION beside confirmText / successMessage, overlaid by translateAction and emitted by os i18n extract. Never a server admission input, never read as a predicate. Claude-Session: https://claude.ai/code/session_01WGufzfHuAwrecfjUDtkwcm Co-authored-by: Claude --- .changeset/26619-action-disabled-reason.md | 29 +++ content/docs/protocol/objectui/actions.mdx | 16 ++ content/docs/ui/translations.mdx | 2 +- packages/cli/src/utils/i18n-extract.ts | 7 + ...18n-extract-action-disabled-reason.test.ts | 134 ++++++++++++ .../lint/src/validate-expressions.test.ts | 17 ++ .../src/validate-translation-references.ts | 4 +- .../en.metadata-forms.generated.ts | 4 + .../es-ES.metadata-forms.generated.ts | 4 + .../ja-JP.metadata-forms.generated.ts | 4 + .../zh-CN.metadata-forms.generated.ts | 4 + packages/spec/liveness/action.json | 6 + packages/spec/src/system/i18n-resolver.ts | 22 +- packages/spec/src/system/translation.zod.ts | 7 + .../src/ui/action-disabled-reason.test.ts | 203 ++++++++++++++++++ packages/spec/src/ui/action.form.ts | 3 + packages/spec/src/ui/action.zod.ts | 49 +++++ 17 files changed, 506 insertions(+), 9 deletions(-) create mode 100644 .changeset/26619-action-disabled-reason.md create mode 100644 packages/cli/test/i18n-extract-action-disabled-reason.test.ts create mode 100644 packages/spec/src/ui/action-disabled-reason.test.ts diff --git a/.changeset/26619-action-disabled-reason.md b/.changeset/26619-action-disabled-reason.md new file mode 100644 index 00000000000..7cbf6b109a5 --- /dev/null +++ b/.changeset/26619-action-disabled-reason.md @@ -0,0 +1,29 @@ +--- +'@objectstack/spec': minor +'@objectstack/cli': minor +'@objectstack/platform-objects': minor +--- + +feat(spec): a greyed action says why — `disabledReason` beside `disabled` + +Clause-②: yes (widening) +Metadata-protocol: added + +`ActionSchema` gains one optional key, `disabledReason`: the sentence a greyed action shows to say why it is unavailable, beside the `disabled` predicate that greys it. An action that does not declare it parses exactly as before, and the console keeps its default reason, "Not available for this record". + +```ts +defineAction({ + name: 'submit_for_approval', + label: 'Submit for approval', + objectName: 'clm_contract', + disabled: "record.legal_review_status != 'approved'", + disabledReason: 'An approved legal review is required before the contract enters approval.', +}); +``` + +- **An `I18nLabel`.** A plain string or an inline locale map (`{ en, 'zh-CN' }`). A blank reason is refused at parse: `''`, a whitespace-only string, an empty map, or a map with a blank value. Omit the key to keep the default. `disabledMessage`, `disabledTooltip`, `disabledHint` and `disabledText` are renamed onto it. +- **Translated with the copy keys.** The slot is `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an action with no `objectName`), beside `confirmText` and `successMessage`. `translateAction` overlays it, and `os i18n extract` (`@objectstack/cli`) scaffolds it for an action that declares one and seeds nothing for one that does not. +- **Display only.** It does not change what `disabled` evaluates to and is never read as a condition: `os validate` checks `disabled`, not `disabledReason`. It is not access control either. A call that reaches the server anyway is refused by the action's own rules (its body or hook, `requiredPermissions`), never by this text. +- **Studio.** The action editor's Placement section gains a "Disabled Reason" row under "Disabled", translated in `@objectstack/platform-objects` for zh-CN, ja-JP and es-ES. + +Nothing renders the key yet. The console's greyed-action surfaces (the record header, the record section bar, the declared actions bar and the four `action:*` renderers) read it in the follow-up change. Until then the liveness ledger marks it `planned`, and an author declaring it gets the planned-property warning that says so. diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index 3da1cd14406..94bd11f4086 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -245,6 +245,7 @@ interface Action { visible?: ExpressionInput; // Visibility predicate (CEL) requiresFeature?: string; // Public auth feature flag gate — lowered into `visible` at parse time disabled?: boolean | ExpressionInput; // Disabled when TRUE (CEL) + disabledReason?: I18nLabel; // Why it is greyed — hover title + accessible description (display only) // Bulk bulkEnabled?: boolean; // Apply to multiple selected records @@ -333,6 +334,21 @@ disabled: "record.status != 'paid'" Predicates are **bare CEL** — `record.status == 'paid'`, not `${record.status == 'paid'}` and not `{record.status} == 'paid'` (in CEL, `{…}` is a map literal). A `${…}`-wrapped or brace-wrapped predicate is not evaluated as a boolean. +#### Saying why: `disabledReason` + +A greyed action says **why** with `disabledReason`, the sentence beside the `disabled` predicate. The console shows it as the button's hover title and its accessible description (a menu item shows it under its label). Without it the reason is the console's default, "Not available for this record". + +```yaml +name: submit_for_approval +label: Submit for approval +disabled: "record.legal_review_status != 'approved'" +disabledReason: An approved legal review is required before the contract enters approval. +``` + +- It is an `I18nLabel`: a plain string, translated at `objects.._actions..disabledReason` (`globalActions..disabledReason` for an action with no `objectName`), or an inline locale map. `os i18n extract` scaffolds the key. +- A blank reason (`''`, whitespace, or a map with a blank value) is refused at parse. Omit the key to keep the default. +- It is **display only**. It does not change what `disabled` evaluates to, nothing reads it as a condition, and it is not access control: a call that reaches the server anyway is refused by the action's own rules (its body or hook, `requiredPermissions`), never by this text. + #### Evaluation context Action predicates evaluate against the **record the action is attached to**. Both `record.` and the bare field name resolve to the current record's value: diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 40c9233391a..56b18df8b84 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -72,7 +72,7 @@ export default defineStack({ | Picklist option labels | `objects..fields..options.` | | View titles, descriptions, empty states | `objects.._views.` | | Bulk-action copy on a list view (button, confirm prompt, dialog fields) | `objects.._views..bulkActions..label` / `.confirmText` / `.confirmLabel` / `.params..label` / `.help` / `.placeholder` — a bulk param's hint is `help`, not `helpText` | -| Action labels, confirm text, success messages | `objects.._actions.` | +| Action labels, confirm text, success messages, disabled reasons | `objects.._actions.` | | Action result dialogs (title / description / acknowledge / field labels) | `objects.._actions..resultDialog` | | Form sections | `objects.._sections.
` | | Custom validation-rule messages | `objects.._validations..message` | diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index d17f41e4da5..3306d7558e5 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -84,12 +84,14 @@ * objects.._actions..description * objects.._actions..confirmText * objects.._actions..successMessage + * objects.._actions..disabledReason * objects.._actions..outcomeMessages. * objects.._actions..params..label / .helpText / .placeholder * objects.._actions..params..options. * objects.._actions..resultDialog.title / .description / .acknowledge * objects.._actions..resultDialog.fields. * globalActions..label / .description / .confirmText / .successMessage + * / .disabledReason * globalActions..outcomeMessages. * globalActions..params..* / .resultDialog.* (same shape as object actions) * apps..label / .description @@ -1449,6 +1451,7 @@ export function collectExpectedEntries( pushOptional(out, [...aroot, 'description'], action.description, 'action', { objectName }); pushOptional(out, [...aroot, 'confirmText'], actionCopy(action.confirmText), 'action', { objectName }); pushOptional(out, [...aroot, 'successMessage'], actionCopy(action.successMessage), 'action', { objectName }); + pushOptional(out, [...aroot, 'disabledReason'], action.disabledReason, 'action', { objectName }); pushActionOutcomeMessages(out, aroot, action, 'action', objectName); pushActionParams(out, ['objects', objectName, '_actions', aname], action, 'action', objectName); pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName); @@ -1542,6 +1545,10 @@ export function collectExpectedEntries( pushOptional(out, [...root, 'description'], action.description, kind, { objectName }); pushOptional(out, [...root, 'confirmText'], actionCopy(action.confirmText), kind, { objectName }); pushOptional(out, [...root, 'successMessage'], actionCopy(action.successMessage), kind, { objectName }); + // [#26619] OPTIONAL-not-derived like `description`: an action that gives no + // reason shows the console's own default sentence, so there is no gap to + // seed for it. + pushOptional(out, [...root, 'disabledReason'], action.disabledReason, kind, { objectName }); pushActionOutcomeMessages(out, root, action, kind, objectName); pushActionParams(out, root, action, kind, objectName); pushActionResultDialog(out, root, action, kind, objectName); diff --git a/packages/cli/test/i18n-extract-action-disabled-reason.test.ts b/packages/cli/test/i18n-extract-action-disabled-reason.test.ts new file mode 100644 index 00000000000..cdd58bfff1b --- /dev/null +++ b/packages/cli/test/i18n-extract-action-disabled-reason.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#26619 — the extractor half of `action.disabledReason`. +// +// The ruling puts the reason's translation slot under +// `objects.OBJECT._actions.ACTION` beside `confirmText` / `successMessage`, so +// `os i18n extract` must scaffold it; without that a zh-CN workspace shows an +// English reason on a Chinese button with no diagnostic. The last test walks +// ONE fixture action through all three surfaces — `ActionSchema` accepts it, +// the extractor emits its key, the translation schema accepts the bundle +// written at that key — because each half passing alone does not prove the +// addresses agree. + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage'; +import { ActionSchema } from '@objectstack/spec/ui'; +import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; + +const paths = (config: any) => collectExpectedEntries(config).map((e) => e.path.join('.')); + +const REASON = 'An approved legal review is required first.'; + +describe('the extractor emits action-level `disabledReason` (#26619)', () => { + it('emits it for a TOP-LEVEL object action that declares one', () => { + const keys = paths({ + actions: [{ name: 'submit_for_approval', objectName: 'clm_contract', label: 'Submit', disabledReason: REASON }], + }); + + expect(keys).toContain('objects.clm_contract._actions.submit_for_approval.disabledReason'); + }); + + it('emits it for a GLOBAL action that declares one', () => { + const keys = paths({ + actions: [{ name: 'leave_organization', label: 'Leave', disabledReason: 'You are the sole owner.' }], + }); + + expect(keys).toContain('globalActions.leave_organization.disabledReason'); + }); + + it('emits it for an action declared INLINE on the object', () => { + const keys = paths({ + objects: [ + { + name: 'clm_contract', + label: 'Contract', + actions: [{ name: 'submit_for_approval', label: 'Submit', disabledReason: REASON }], + }, + ], + }); + + expect(keys).toContain('objects.clm_contract._actions.submit_for_approval.disabledReason'); + }); + + it('seeds NOTHING for an action that declares none — the console has its own default reason', () => { + // Same shape as an undeclared `description`: `pushOptional` records a + // seed-less path, and nothing is gated as a translation gap. + const config = { + objects: [{ name: 'clm_contract', label: 'Contract', actions: [{ name: 'archive', label: 'Archive' }] }], + }; + const entries = collectExpectedEntries(config); + const root = 'objects.clm_contract._actions.archive'; + const at = (key: string) => entries.find((e) => e.path.join('.') === key); + + expect(at(`${root}.disabledReason`)?.inline).toBeUndefined(); + expect(at(`${root}.disabledReason`)?.sourceValue).toBeUndefined(); + + const { bundles } = extractTranslations(config, { defaultLocale: 'en', fill: 'default' }); + expect(bundles.en.objects.clm_contract._actions.archive).not.toHaveProperty('disabledReason'); + expect(computeI18nCoverage({ ...config, translations: [{ 'zh-CN': {} }] }).issues.map((i) => i.key)) + .not.toContain(`${root}.disabledReason`); + }); + + it('carries the declared text into the bundle, and gates it until it is translated', () => { + const config: any = { + objects: [ + { + name: 'clm_contract', + label: 'Contract', + actions: [{ name: 'submit_for_approval', label: 'Submit', disabledReason: REASON }], + }, + ], + translations: [ + { 'zh-CN': { objects: { clm_contract: { label: '合同', _actions: { submit_for_approval: { label: '送审' } } } } } }, + ], + }; + const key = 'objects.clm_contract._actions.submit_for_approval.disabledReason'; + + const entry = collectExpectedEntries(config).find((e) => e.path.join('.') === key); + expect(entry?.source).toBe('action'); + expect(entry?.inline).toBe(REASON); + + const { bundles } = extractTranslations(config, { defaultLocale: 'en', fill: 'default' }); + expect(bundles.en.objects.clm_contract._actions.submit_for_approval.disabledReason).toBe(REASON); + + expect(computeI18nCoverage(config).issues.map((i) => i.key)).toContain(key); + config.translations[0]['zh-CN'].objects.clm_contract._actions.submit_for_approval.disabledReason = + '需要一条通过的法务审查后才能送审。'; + expect(computeI18nCoverage(config).issues.filter((i) => i.source !== 'metadataForm')).toEqual([]); + }); +}); + +describe('one fixture action, all three surfaces (#26619 end-to-end)', () => { + const authored = { + name: 'submit_for_approval', + objectName: 'clm_contract', + label: 'Submit for approval', + target: 'submitForApproval', + disabled: "record.legal_review_status != 'approved'", + disabledReason: REASON, + }; + + it('parses as metadata, extracts to a key, and that key round-trips a bundle', () => { + const parsed = ActionSchema.safeParse(authored); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data.disabledReason).toBe(REASON); + + const { bundles } = extractTranslations({ actions: [authored] }, { + defaultLocale: 'en', + locales: ['zh-CN'], + fill: 'empty', + }); + const bundle: any = bundles['zh-CN']; + expect(bundle.objects.clm_contract._actions.submit_for_approval).toHaveProperty('disabledReason'); + + bundle.objects.clm_contract._actions.submit_for_approval.disabledReason = '需要一条通过的法务审查后才能送审。'; + const translated = ObjectTranslationDataSchema.safeParse(bundle.objects.clm_contract); + expect(translated.success).toBe(true); + expect(translated.success && translated.data._actions?.submit_for_approval?.disabledReason) + .toBe('需要一条通过的法务审查后才能送审。'); + + expect(paths({ actions: [authored] })).toContain('objects.clm_contract._actions.submit_for_approval.disabledReason'); + }); +}); diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 2a852ba58dd..de22f851dd1 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -926,6 +926,21 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { expect(ok).toHaveLength(0); }); + it('never reads `disabledReason` as a predicate — it is display copy (#26619)', () => { + // The same bare-field source that is an error on `disabled` above is a + // plain sentence on `disabledReason`: nothing is checked, nothing flagged. + const issues = validateStackExpressions({ + objects: [{ name: 'crm_lead', fields: { status: { type: 'select' } } }], + actions: [{ + name: 'park', + objectName: 'crm_lead', + disabled: 'record.status == "converted"', + disabledReason: 'status == "converted"', + }], + }); + expect(issues).toHaveLength(0); + }); + it('validates an action attached to an object (record scope = parent object)', () => { const issues = validateStackExpressions({ objects: [{ @@ -3372,6 +3387,8 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s { receiver: 'action', // `object` is absent — the action schema's canonical key is `objectName`. + // `disabledReason` is absent by contract (#26619): display copy beside + // `disabled`, never read as a condition. expected: ['disabled', 'name', 'objectName', 'visible'], declaredBy: 'ObjectStackSchema.actions[]', keys: () => shapeKeysOf(ObjectStackSchema.shape.actions), diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 7d96c4db3e8..a3540c8fe1d 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -2437,8 +2437,8 @@ interface ActionEntryContext { * dialog that declares none — so declaring the dialog is the whole condition. * * The leaf keys beside them (`label`, `description`, `confirmText`, - * `successMessage`) are prose on a node that resolved, read whenever the - * action is, as on the object branch. + * `successMessage`, `disabledReason`) are prose on a node that resolved, read + * whenever the action is, as on the object branch. * * ⛔ One vocabulary: a key naming a target the action does not declare is * `translation-target-unknown` at `params`' severity; an OPTION key keyed off diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 4858da00d3a..724537362fb 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1843,6 +1843,10 @@ export const enMetadataForms: NonNullable = { label: "Disabled", helpText: "CEL expression: disable when condition is true" }, + disabledReason: { + label: "Disabled Reason", + helpText: "Why the action is greyed, shown on hover and read by screen readers while Disabled is true (e.g. \"An approved legal review is required first.\"). Display only: the server still refuses the call on its own rules. Leave empty to show \"Not available for this record\"." + }, requiresFeature: { label: "Requires Feature", helpText: "Public auth feature flag gating this action. It is lowered into the `visible` predicate at parse time and stripped from the output, so no downstream consumer ever sees the key." diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 5e296dd3dc9..cd0b32209aa 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1843,6 +1843,10 @@ export const esESMetadataForms: NonNullable = label: "Deshabilitado", helpText: "Expresión CEL: desactivar cuando la condición sea true" }, + disabledReason: { + label: "Motivo de deshabilitado", + helpText: "Por qué la acción aparece atenuada; se muestra al pasar el cursor y lo leen los lectores de pantalla mientras Deshabilitado es true (p. ej., \"Primero se requiere una revisión legal aprobada.\"). Solo de presentación: el servidor sigue rechazando la llamada según sus propias reglas. Déjalo vacío para mostrar \"No disponible para este registro\"." + }, requiresFeature: { label: "Requiere función", helpText: "Indicador público de función de autenticación que condiciona esta acción. Se traslada al predicado `visible` durante el análisis y se elimina de la salida, así que ningún consumidor posterior llega a ver la clave." diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 9008e9c9c9c..20d8f7330c3 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1843,6 +1843,10 @@ export const jaJPMetadataForms: NonNullable = label: "無効", helpText: "CEL 式: 条件が true の場合に無効化" }, + disabledReason: { + label: "無効の理由", + helpText: "アクションがグレーアウトされている理由。「無効」が true の間、ホバー時に表示され、スクリーンリーダーにも読み上げられます(例:「承認済みの法務レビューが先に必要です。」)。表示専用で、サーバーは引き続き独自のルールで呼び出しを拒否します。空欄の場合は「このレコードでは利用できません」と表示されます。" + }, requiresFeature: { label: "必要な認証機能", helpText: "このアクションの表示可否を決める公開認証機能フラグ。解析時に `visible` の述語へ畳み込まれ、出力からは取り除かれるため、下流の利用側がこのキーを見ることはありません。" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 219ac9d6995..1a5a39cfea8 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1843,6 +1843,10 @@ export const zhCNMetadataForms: NonNullable = label: "禁用", helpText: "CEL 表达式:满足条件时禁用" }, + disabledReason: { + label: "禁用原因", + helpText: "动作置灰的原因,在“禁用”为 true 时悬停显示,并由屏幕阅读器读出(例如“需要一条通过的法务审查后才能送审。”)。仅用于展示:服务端仍按自身规则拒绝调用。留空则显示“对此记录不可用”。" + }, requiresFeature: { label: "所需认证特性", helpText: "用于控制该动作是否出现的公共认证特性开关。它在解析时被降解进 `visible` 断言并从输出中移除,因此下游消费方永远看不到这个键。" diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 70b6a516ea9..f560a50e958 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -151,6 +151,12 @@ "evidence": "objectui: components action-button/-group/-icon/-menu + app-shell DeclaredActionsBar + plugin-detail record-quick-actions — all six rendering surfaces gate the Button on the predicate", "note": "EVIDENCE CORRECTED 2026-07 (#1878 §3 recheck): previously cited the metadata-admin PREVIEW renderer, which only echoes the authored value. At that time the predicate was actually enforced on ONE of six surfaces (#1885 wired action:button alone) — the 'live' verdict was right for the wrong reason and hid a real silent no-op on the other five. objectui#2863 wired the rest; showcase specimen `showcase_archive_task` (#3643) dogfoods it." }, + "disabledReason": { + "status": "planned", + "verifiedAt": "2026-10-27", + "evidence": "packages/spec/src/system/i18n-resolver.ts#translateAction overlays the translated `objects.{o}._actions.{a}.disabledReason` (or `globalActions.{a}.disabledReason`) onto the served action; packages/cli/src/utils/i18n-extract.ts#collectExpectedEntries emits the key and packages/spec/src/system/translation.zod.ts declares the translation slot.", + "note": "PLANNED until the console half of #26619 lands (ruling 6442737825, letter A: spec PR first, console PR second). The framework half is live today — parse, blank refusal, translation slot, extract, translateAction overlay — but no renderer reads the key yet: the four greyed-action readers (ui/packages/components/src/renderers/layout/containers.tsx#disabledReasonFor, ui/packages/components/src/renderers/action/disabled-reason.tsx#useDisabledReason, ui/packages/plugin-detail/src/renderers/record-quick-actions.tsx, ui/packages/app-shell/src/views/DeclaredActionsBar.tsx) still show only `actions.notAvailableForRecord`. The console PR flips this row to `live` with those read points. Display only by contract: never a server admission input, never read as a predicate." + }, "ai": { "status": "live", "verifiedAt": "2026-09-15", diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 77823c09515..d267732212a 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -15,6 +15,7 @@ * objects.._actions..description * objects.._actions..confirmText * objects.._actions..successMessage + * objects.._actions..disabledReason * objects.._actions..outcomeMessages. * objects.._actions..params..label * objects.._actions..params..helpText @@ -46,8 +47,8 @@ * object-less action (no `objectName`) reads only: * * globalActions..label / .description / .confirmText / - * .successMessage / .outcomeMessages. / .params..* / - * .resultDialog.* + * .successMessage / .disabledReason / .outcomeMessages. / + * .params..* / .resultDialog.* * * That is the declaration `TranslationDataSchema.globalActions` makes: global * translations are for object-less actions. A `globalActions` entry that names @@ -222,6 +223,11 @@ export interface ActionLike { */ confirmText?: string | { dialect: 'template'; source: string }; successMessage?: string | { dialect: 'template'; source: string }; + /** + * `ActionSchema.disabledReason` (#26619) — the display-only reason a greyed + * action shows. Narrowed to `string` for the same reason `description` is. + */ + disabledReason?: string; /** * `ActionSchema.outcomeMessages` — success copy per handler `outcome`, * narrowed to `string` values for the same reason `successMessage` is. @@ -648,7 +654,7 @@ function actionTranslationNode( function lookupActionField( bundle: TranslationBundle | undefined, action: ActionLike, - field: 'label' | 'description' | 'confirmText' | 'successMessage', + field: 'label' | 'description' | 'confirmText' | 'successMessage' | 'disabledReason', opts?: ResolveOptions, ): string | undefined { if (!bundle) return undefined; @@ -1427,9 +1433,9 @@ export function translateView( /** * Apply the active locale to an action metadata document by overwriting - * `label`, `description`, `confirmText`, `successMessage`, each declared - * `outcomeMessages` entry, the `params[]` copy and the `resultDialog` copy with - * translated values when available. The + * `label`, `description`, `confirmText`, `successMessage`, `disabledReason`, + * each declared `outcomeMessages` entry, the `params[]` copy and the + * `resultDialog` copy with translated values when available. The * original document is not mutated; a shallow copy is returned. * * ## Why `description` and `params` are overlaid HERE @@ -1458,6 +1464,9 @@ export function translateAction( const description = lookupActionField(bundle, action, 'description', opts) ?? action.description; const confirmText = resolveActionConfirm(bundle, action, opts); const successMessage = resolveActionSuccess(bundle, action, opts); + // [#26619] Display-only copy, overlaid exactly like `description`: the + // bundle's sentence when there is one, else the authored value as written. + const disabledReason = lookupActionField(bundle, action, 'disabledReason', opts) ?? action.disabledReason; const outcomeMessages = resolveActionOutcomeMessages(bundle, action, opts); const resultDialog = resolveActionResultDialog(bundle, action, opts); const params = translateActionParams(action, bundle, opts); @@ -1467,6 +1476,7 @@ export function translateAction( ...(description !== undefined ? { description } : {}), ...(confirmText !== undefined ? { confirmText } : {}), ...(successMessage !== undefined ? { successMessage } : {}), + ...(disabledReason !== undefined ? { disabledReason } : {}), ...(outcomeMessages !== action.outcomeMessages ? { outcomeMessages } : {}), ...(resultDialog !== undefined ? { resultDialog } : {}), ...(params !== action.params ? { params } : {}), diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 525953111fe..02d48f03158 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -177,6 +177,8 @@ const actionTranslationSchema = (surface: string) => strictObject({ name: 'label', title: 'label', confirm: 'confirmText', confirmation: 'confirmText', confirmMessage: 'confirmText', success: 'successMessage', successText: 'successMessage', toast: 'successMessage', + disabledMessage: 'disabledReason', disabledTooltip: 'disabledReason', + disabledHint: 'disabledReason', disabledText: 'disabledReason', parameters: 'params', args: 'params', inputs: 'params', result: 'resultDialog', dialog: 'resultDialog', }, @@ -191,6 +193,9 @@ const actionTranslationSchema = (surface: string) => strictObject({ description: z.string().optional().describe('Translated action description — the explanatory line under the title in the action\'s param dialog'), confirmText: z.string().optional().describe('Translated confirmation prompt'), successMessage: z.string().optional().describe('Translated success toast/message'), + // `ActionSchema.disabledReason` (#26619) — the display-only reason a greyed + // action shows; overlaid by `translateAction` like the copy keys above. + disabledReason: z.string().optional().describe('Translated reason a greyed action shows while its `disabled` predicate is TRUE (hover title and accessible description)'), // `ActionSchema.outcomeMessages` (#21095) — one translated message per // declared outcome, keyed by the same snake_case outcome name, so a bundle // key that could never match a handler's `outcome` is refused here rather @@ -427,6 +432,7 @@ export const ObjectTranslationDataSchema = lazySchema(() => strictObject({ * objects.._actions..description * objects.._actions..confirmText * objects.._actions..successMessage + * objects.._actions..disabledReason * objects.._actions..outcomeMessages. * objects.._actions..params..label * objects.._actions..params..helpText @@ -943,6 +949,7 @@ const appTranslationDataShape = () => ({ * globalActions..description * globalActions..confirmText * globalActions..successMessage + * globalActions..disabledReason * globalActions..outcomeMessages. * globalActions..params..label * globalActions..params..helpText diff --git a/packages/spec/src/ui/action-disabled-reason.test.ts b/packages/spec/src/ui/action-disabled-reason.test.ts new file mode 100644 index 00000000000..373d1b86c97 --- /dev/null +++ b/packages/spec/src/ui/action-disabled-reason.test.ts @@ -0,0 +1,203 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#26619 — `disabledReason` on the action contract (ruling +// 6442737825, letter A). +// +// A greyed action could only say "Not available for this record". The ruled +// key is the sentence that says WHY, beside the `disabled` predicate that greys +// it: an I18nLabel, translated under `objects.OBJECT._actions.ACTION` with the +// copy keys, display only. These pins hold the four things the ruling names: +// the parse (both I18nLabel forms), the blank refusal, the translation slot and +// its overlay, and that the key is a sentence, never a condition. The extract +// half is pinned in `packages/cli/test/i18n-extract-action-disabled-reason.test.ts` +// and the lint half in `packages/lint/src/validate-expressions.test.ts`. + +import { describe, it, expect } from 'vitest'; +import { ActionSchema, InlineActionSchema } from './action.zod'; +import { ObjectTranslationDataSchema, TranslationDataSchema, type TranslationBundle } from '../system/translation.zod'; +import { translateAction } from '../system/i18n-resolver'; + +/** + * Minimum legal registered action, greyed by a predicate. `type` defaults to + * `script`, which needs a dispatch `target`. + */ +const base = { + name: 'submit_for_approval', + label: 'Submit for approval', + objectName: 'clm_contract', + target: 'submitForApproval', + disabled: "record.legal_review_status != 'approved'", +} as const; + +const REASON = 'An approved legal review is required before the contract enters approval.'; + +describe('ActionSchema.disabledReason — the parse', () => { + it('accepts a plain string and keeps it verbatim', () => { + const result = ActionSchema.safeParse({ ...base, disabledReason: REASON }); + + expect(result.success).toBe(true); + expect(result.success && result.data.disabledReason).toBe(REASON); + }); + + it('accepts the inline locale map, as every I18nLabel does', () => { + const localized = { en: REASON, 'zh-CN': '需要一条通过的法务审查后才能送审。' }; + const result = ActionSchema.safeParse({ ...base, disabledReason: localized }); + + expect(result.success).toBe(true); + expect(result.success && result.data.disabledReason).toEqual(localized); + }); + + it('is optional — an action without it parses, and the console keeps its default reason', () => { + const result = ActionSchema.safeParse(base); + + expect(result.success).toBe(true); + expect(result.success && 'disabledReason' in result.data).toBe(false); + }); + + it('is a sentence, never a condition — a predicate-looking value is not normalized', () => { + // `disabled` lowers a CEL string into its `{ dialect: 'cel' }` envelope at + // parse; `disabledReason` must come out as the same string it went in as, + // or something downstream would be invited to evaluate it. + const predicateLooking = "record.legal_review_status != 'approved'"; + const result = ActionSchema.safeParse({ ...base, disabledReason: predicateLooking }); + + expect(result.success).toBe(true); + expect(result.success && result.data.disabledReason).toBe(predicateLooking); + expect(result.success && result.data.disabled).toEqual( + expect.objectContaining({ dialect: 'cel', source: predicateLooking }), + ); + }); + + it('refuses every non-I18nLabel shape the `label` key refuses', () => { + for (const value of [42, null, ['a list'], { en: 7 }, { key: 'actions.reason' }] as unknown[]) { + const asLabel = ActionSchema.safeParse({ ...base, label: value }).success; + const asReason = ActionSchema.safeParse({ ...base, disabledReason: value }).success; + + expect({ value, asLabel, asReason }).toEqual({ value, asLabel: false, asReason: false }); + } + }); +}); + +describe('ActionSchema.disabledReason — the blank refusal', () => { + const refusal = (disabledReason: unknown) => { + const result = ActionSchema.safeParse({ ...base, disabledReason }); + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues.find((i) => i.path[0] === 'disabledReason'); + expect(issue?.code).toBe('custom'); + expect(issue?.path).toEqual(['disabledReason']); + return issue?.message ?? ''; + }; + + it('refuses the empty string, naming the default it would have replaced', () => { + const message = refusal(''); + expect(message).toMatch(/^a blank `disabledReason` would replace the console's default reason/); + expect(message).toContain('omit the key to keep the default'); + }); + + it('refuses a whitespace-only string — it renders exactly as blank', () => { + expect(refusal(' ')).toMatch(/^a blank `disabledReason`/); + }); + + it('refuses a locale map with a blank value, naming the locale', () => { + expect(refusal({ en: REASON, 'zh-CN': ' ' })).toMatch(/^the inline `disabledReason` map gives a blank reason for `zh-CN`/); + }); + + it('refuses an empty locale map — it gives no reason in any locale', () => { + expect(refusal({})).toMatch(/^an empty inline `disabledReason` map gives no reason in any locale/); + }); +}); + +describe('ActionSchema.disabledReason — the shape around it', () => { + it('renames the near-miss spellings onto the key', () => { + for (const nearMiss of ['disabledMessage', 'disabledTooltip', 'disabledHint', 'disabledText']) { + const result = ActionSchema.safeParse({ ...base, [nearMiss]: REASON }); + + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues[0]; + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.message).toContain(`\`${nearMiss}\` → \`disabledReason\``); + } + }); + + it('is not picked onto the INLINE action — that surface has no `disabled` to explain', () => { + const result = InlineActionSchema.safeParse({ + type: 'url', + target: '/docs', + label: 'Docs', + disabledReason: REASON, + }); + + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues[0]; + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.message).toContain('disabledReason'); + }); +}); + +describe('the translation slot and its overlay', () => { + it('accepts the key at the object-scoped address, beside confirmText / successMessage', () => { + const result = ObjectTranslationDataSchema.safeParse({ + _actions: { + submit_for_approval: { label: '送审', confirmText: '确认送审?', disabledReason: '需要一条通过的法务审查后才能送审。' }, + }, + }); + + expect(result.success).toBe(true); + expect(result.success && result.data._actions?.submit_for_approval?.disabledReason) + .toBe('需要一条通过的法务审查后才能送审。'); + }); + + it('accepts it at the globalActions address an object-less action reads', () => { + const result = TranslationDataSchema.safeParse({ + globalActions: { leave_organization: { disabledReason: '你是唯一的所有者,不能退出组织。' } }, + }); + + expect(result.success).toBe(true); + expect(result.success && result.data.globalActions?.leave_organization?.disabledReason) + .toBe('你是唯一的所有者,不能退出组织。'); + }); + + it('renames the same near-misses inside a bundle', () => { + const result = ObjectTranslationDataSchema.safeParse({ + _actions: { submit_for_approval: { disabledTooltip: '需要法务审查。' } }, + }); + + expect(result.success).toBe(false); + expect(result.success ? undefined : result.error.issues[0]?.message) + .toContain('`disabledTooltip` → `disabledReason`'); + }); + + const bundle: TranslationBundle = { + 'zh-CN': { + objects: { + clm_contract: { + _actions: { submit_for_approval: { disabledReason: '需要一条通过的法务审查后才能送审。' } }, + }, + }, + globalActions: { + leave_organization: { disabledReason: '你是唯一的所有者,不能退出组织。' }, + }, + }, + }; + const action = { name: 'submit_for_approval', label: 'Submit', objectName: 'clm_contract', disabledReason: REASON }; + + it('translateAction overlays the translated reason for the requested locale', () => { + expect(translateAction(action, bundle, { locale: 'zh-CN' }).disabledReason) + .toBe('需要一条通过的法务审查后才能送审。'); + }); + + it('reads an object-less action at globalActions', () => { + const leave = { name: 'leave_organization', label: 'Leave', disabledReason: 'You are the sole owner.' }; + expect(translateAction(leave, bundle, { locale: 'zh-CN' }).disabledReason) + .toBe('你是唯一的所有者,不能退出组织。'); + }); + + it('falls back to the authored reason when the locale has none', () => { + expect(translateAction(action, bundle, { locale: 'ja-JP' }).disabledReason).toBe(REASON); + }); + + it('adds no key to an action that declares none and has no translation', () => { + const bare = { name: 'archive', label: 'Archive', objectName: 'clm_contract' }; + expect('disabledReason' in translateAction(bare, bundle, { locale: 'zh-CN' })).toBe(false); + }); +}); diff --git a/packages/spec/src/ui/action.form.ts b/packages/spec/src/ui/action.form.ts index 97125cc2039..2057710da5b 100644 --- a/packages/spec/src/ui/action.form.ts +++ b/packages/spec/src/ui/action.form.ts @@ -163,6 +163,9 @@ export const actionForm = defineForm({ { field: 'component', helpText: 'How to render (button, icon, menu item)' }, { field: 'visible', widget: 'textarea', helpText: 'CEL expression: show only when condition is true' }, { field: 'disabled', widget: 'textarea', helpText: 'CEL expression: disable when condition is true' }, + // #26619 — an I18nLabel, the node `errorMessage` carries, so it copies + // that plain row and the renderer derives the same face. + { field: 'disabledReason', helpText: 'Why the action is greyed, shown on hover and read by screen readers while Disabled is true (e.g. "An approved legal review is required first."). Display only: the server still refuses the call on its own rules. Leave empty to show "Not available for this record".' }, // The enum derives: every member IS the public auth feature flag it // names, so an inline list would only restate them and could drift from // the registry the parse step resolves against. diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 245acf8f37c..61be7654b45 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -887,6 +887,45 @@ const ActionConditionInputSchema = z.union([z.boolean(), EvaluatedExpressionInpu */ const actionCopy = (key: 'confirmText' | 'successMessage') => actionCopyTemplateSlot(templateExpressionInput(ExpressionSchema, actionCopyRefusals(key, { localeMap: true })), key, { localeMap: true }); +/** + * [#26619] `disabledReason` — the sentence a greyed action shows to say WHY it + * is unavailable, beside the `disabled` predicate that greys it. + * + * An I18nLabel (a plain string translated at + * `objects.OBJECT._actions.ACTION.disabledReason`, or an inline locale map), + * so it takes the copy keys' translation route and not a predicate's. It is + * DISPLAY ONLY, and both halves of that are the contract: + * + * - it never enters the server's admission — a disabled action that is called + * anyway is refused by its own rules (a hook, `requiredPermissions`), never + * by this text, so a wrong reason misleads a reader and leaks nothing; + * - it is not an expression — no lint or evaluator reads it as a condition, + * and a predicate-looking value is just a sentence. + * + * A blank reason is refused, in either form: it would replace the console's + * default, "Not available for this record", with an empty tooltip. Omitting + * the key is how an author keeps that default. + */ +const DISABLED_REASON_BLANK = + 'a blank `disabledReason` would replace the console\'s default reason, "Not available for this ' + + 'record", with an empty tooltip. Write the sentence that says what is missing (e.g. "An ' + + 'approved legal review is required first."), or omit the key to keep the default.'; +const actionDisabledReason = () => I18nLabelSchema.superRefine((value, ctx) => { + if (typeof value === 'string') { + if (value.trim() === '') ctx.addIssue({ code: 'custom', message: DISABLED_REASON_BLANK }); + return; + } + const blank = Object.entries(value).filter(([, text]) => text.trim() === '').map(([tag]) => tag); + if (Object.keys(value).length === 0 || blank.length > 0) { + ctx.addIssue({ + code: 'custom', + message: blank.length > 0 + ? `the inline \`disabledReason\` map gives a blank reason for ${blank.map((tag) => `\`${tag}\``).join(', ')} — ${DISABLED_REASON_BLANK}` + : `an empty inline \`disabledReason\` map gives no reason in any locale — ${DISABLED_REASON_BLANK}`, + }); + } +}); + /** * The object half of {@link ActionSchema}, before its refinements. * @@ -916,6 +955,11 @@ const actionObject = () => strictObject({ success: 'successMessage', successText: 'successMessage', toast: 'successMessage', visibleWhen: 'visible', showWhen: 'visible', disabledWhen: 'disabled', + // [#26619] The words an author reaches for when naming the greyed + // action's explanation by the surface it lands on (a tooltip, a hint) or + // by the copy keys' `*Message` family. + disabledMessage: 'disabledReason', disabledTooltip: 'disabledReason', + disabledHint: 'disabledReason', disabledText: 'disabledReason', style: 'variant', color: 'variant', appearance: 'variant', placement: 'locations', location: 'locations', position: 'locations', verb: 'method', httpMethod: 'method', @@ -1542,6 +1586,11 @@ const actionObject = () => strictObject({ * gone entirely. */ disabled: ActionConditionInputSchema.optional().describe('Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled.'), + /** + * Why the action is greyed — the reason `disabled` gives the user. Display + * only; the contract is on {@link actionDisabledReason}. + */ + disabledReason: actionDisabledReason().optional().describe('Display-only reason a greyed action shows (hover title and accessible description) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused.'), /** * [ADR-0066 D4] System capabilities required to INVOKE this action — a From aa552cedceafc13cc7562e1879a7e698e2354802 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:39:58 +0000 Subject: [PATCH 2/6] fix(spec): project the disabledReason blank refusal into the JSON Schema; regenerate artifacts Claude-Session: https://claude.ai/code/session_01WGufzfHuAwrecfjUDtkwcm Co-authored-by: Claude --- content/docs/references/data/object.mdx | 1 + .../references/kernel/metadata-plugin.mdx | 1 + .../docs/references/system/translation.mdx | 5 +++ content/docs/references/ui/action.mdx | 1 + packages/spec/authorable-surface/ui.json | 1 + packages/spec/liveness/state-counts/action.md | 2 +- .../src/ui/action-disabled-reason.test.ts | 16 +++------- packages/spec/src/ui/action.zod.ts | 31 +++++++------------ 8 files changed, 27 insertions(+), 31 deletions(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 5694cab1198..ad8a18cbeac 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -499,6 +499,7 @@ const result = ApiMethod.parse(data); | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | +| **disabledReason** | `string \| Record` | optional | Display-only reason a greyed action shows (hover title and accessible description) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 9179ae0ded7..b1c8952ed26 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -342,6 +342,7 @@ const result = MetadataBulkResultSchema.parse(data); | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | +| **disabledReason** | `string \| Record` | optional | Display-only reason a greyed action shows (hover title and accessible description) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index d16708d3bf6..40c3be632e2 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -126,6 +126,7 @@ Translation data for a single field | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason a greyed action shows while its `disabled` predicate is TRUE (hover title and accessible description) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -225,6 +226,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason a greyed action shows while its `disabled` predicate is TRUE (hover title and accessible description) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -381,6 +383,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason a greyed action shows while its `disabled` predicate is TRUE (hover title and accessible description) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -599,6 +602,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason a greyed action shows while its `disabled` predicate is TRUE (hover title and accessible description) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -786,6 +790,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason a greyed action shows while its `disabled` predicate is TRUE (hover title and accessible description) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index faf96b23b22..a86bb1ff81f 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -56,6 +56,7 @@ const result = ActionSchema.parse(data); | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| … +2 more>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| 'transfer_ownership' \| 'remove_member' \| 'create_team' \| 'update_team' \| 'remove_team' \| … +4 more>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | +| **disabledReason** | `string \| Record` | optional | Display-only reason a greyed action shows (hover title and accessible description) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 76e4b088ddb..fbba5ebb228 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -18,6 +18,7 @@ "ui/Action:confirmText", "ui/Action:description", "ui/Action:disabled", + "ui/Action:disabledReason", "ui/Action:errorMessage", "ui/Action:execution", "ui/Action:icon", diff --git a/packages/spec/liveness/state-counts/action.md b/packages/spec/liveness/state-counts/action.md index d70333646f0..485c04b2451 100644 --- a/packages/spec/liveness/state-counts/action.md +++ b/packages/spec/liveness/state-counts/action.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `action` | 47 | 0 | 0 | 1 | 0 | 48 | +| `action` | 47 | 0 | 0 | 1 | 1 | 49 | diff --git a/packages/spec/src/ui/action-disabled-reason.test.ts b/packages/spec/src/ui/action-disabled-reason.test.ts index 373d1b86c97..f4692dc1feb 100644 --- a/packages/spec/src/ui/action-disabled-reason.test.ts +++ b/packages/spec/src/ui/action-disabled-reason.test.ts @@ -8,7 +8,10 @@ // it: an I18nLabel, translated under `objects.OBJECT._actions.ACTION` with the // copy keys, display only. These pins hold the four things the ruling names: // the parse (both I18nLabel forms), the blank refusal, the translation slot and -// its overlay, and that the key is a sentence, never a condition. The extract +// its overlay, and that the key is a sentence, never a condition. The blank +// refusal reaches the published JSON Schema too: it is `NON_BLANK_STRING`, and +// the build's dropped-refinement ledger refuses a refinement that does not +// project. The extract // half is pinned in `packages/cli/test/i18n-extract-action-disabled-reason.test.ts` // and the lint half in `packages/lint/src/validate-expressions.test.ts`. @@ -82,8 +85,7 @@ describe('ActionSchema.disabledReason — the blank refusal', () => { const refusal = (disabledReason: unknown) => { const result = ActionSchema.safeParse({ ...base, disabledReason }); expect(result.success).toBe(false); - const issue = result.success ? undefined : result.error.issues.find((i) => i.path[0] === 'disabledReason'); - expect(issue?.code).toBe('custom'); + const issue = result.success ? undefined : result.error.issues[0]; expect(issue?.path).toEqual(['disabledReason']); return issue?.message ?? ''; }; @@ -97,14 +99,6 @@ describe('ActionSchema.disabledReason — the blank refusal', () => { it('refuses a whitespace-only string — it renders exactly as blank', () => { expect(refusal(' ')).toMatch(/^a blank `disabledReason`/); }); - - it('refuses a locale map with a blank value, naming the locale', () => { - expect(refusal({ en: REASON, 'zh-CN': ' ' })).toMatch(/^the inline `disabledReason` map gives a blank reason for `zh-CN`/); - }); - - it('refuses an empty locale map — it gives no reason in any locale', () => { - expect(refusal({})).toMatch(/^an empty inline `disabledReason` map gives no reason in any locale/); - }); }); describe('ActionSchema.disabledReason — the shape around it', () => { diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 61be7654b45..311a0de6347 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -23,7 +23,8 @@ import { BulkActionExecutionSchema } from './bulk-action.zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { EvaluatedExpressionInputSchema, ExpressionSchema } from '../shared/expression.zod'; import { evaluatedExpressionUnionRefusal } from '../shared/evaluated-slot-union'; -import { I18nLabelSchema } from './i18n.zod'; +import { I18nLabelSchema, InlineLocaleMapSchema } from './i18n.zod'; +import { NON_BLANK_STRING } from '../shared/refinement-projection'; import { ACTION_CONFIRM_TEXT_TEMPLATE_DESCRIBE, ACTION_SUCCESS_MESSAGE_TEMPLATE_DESCRIBE, @@ -902,29 +903,21 @@ const actionCopy = (key: 'confirmText' | 'successMessage') => actionCopyTemplate * - it is not an expression — no lint or evaluator reads it as a condition, * and a predicate-looking value is just a sentence. * - * A blank reason is refused, in either form: it would replace the console's - * default, "Not available for this record", with an empty tooltip. Omitting - * the key is how an author keeps that default. + * A blank string is refused: it would replace the console's default, "Not + * available for this record", with an empty tooltip. Omitting the key is how + * an author keeps that default. The rule is {@link NON_BLANK_STRING}, so the + * published JSON Schema carries it too (`minLength` + a `\S` pattern) rather + * than accepting a document the parse refuses. The locale-map arm is + * `InlineLocaleMapSchema` unchanged — the I18nLabel every other copy key takes. */ const DISABLED_REASON_BLANK = 'a blank `disabledReason` would replace the console\'s default reason, "Not available for this ' + 'record", with an empty tooltip. Write the sentence that says what is missing (e.g. "An ' + 'approved legal review is required first."), or omit the key to keep the default.'; -const actionDisabledReason = () => I18nLabelSchema.superRefine((value, ctx) => { - if (typeof value === 'string') { - if (value.trim() === '') ctx.addIssue({ code: 'custom', message: DISABLED_REASON_BLANK }); - return; - } - const blank = Object.entries(value).filter(([, text]) => text.trim() === '').map(([tag]) => tag); - if (Object.keys(value).length === 0 || blank.length > 0) { - ctx.addIssue({ - code: 'custom', - message: blank.length > 0 - ? `the inline \`disabledReason\` map gives a blank reason for ${blank.map((tag) => `\`${tag}\``).join(', ')} — ${DISABLED_REASON_BLANK}` - : `an empty inline \`disabledReason\` map gives no reason in any locale — ${DISABLED_REASON_BLANK}`, - }); - } -}); +const actionDisabledReason = () => z.union([ + z.string().refine(NON_BLANK_STRING, { message: DISABLED_REASON_BLANK }), + InlineLocaleMapSchema, +]); /** * The object half of {@link ActionSchema}, before its refinements. From 1d2b025283f9c1a16c4fc33c743957b61320f53e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 15:01:26 +0000 Subject: [PATCH 3/6] test: cover disabledReason in the lint translation-shape table; type the cli test Claude-Session: https://claude.ai/code/session_01WGufzfHuAwrecfjUDtkwcm Co-authored-by: Claude --- packages/cli/test/i18n-extract-action-disabled-reason.test.ts | 4 ++-- packages/lint/src/validate-translation-references.test.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/i18n-extract-action-disabled-reason.test.ts b/packages/cli/test/i18n-extract-action-disabled-reason.test.ts index cdd58bfff1b..c8f34da7497 100644 --- a/packages/cli/test/i18n-extract-action-disabled-reason.test.ts +++ b/packages/cli/test/i18n-extract-action-disabled-reason.test.ts @@ -66,7 +66,7 @@ describe('the extractor emits action-level `disabledReason` (#26619)', () => { expect(at(`${root}.disabledReason`)?.sourceValue).toBeUndefined(); const { bundles } = extractTranslations(config, { defaultLocale: 'en', fill: 'default' }); - expect(bundles.en.objects.clm_contract._actions.archive).not.toHaveProperty('disabledReason'); + expect(bundles.en.objects?.clm_contract?._actions?.archive).not.toHaveProperty('disabledReason'); expect(computeI18nCoverage({ ...config, translations: [{ 'zh-CN': {} }] }).issues.map((i) => i.key)) .not.toContain(`${root}.disabledReason`); }); @@ -91,7 +91,7 @@ describe('the extractor emits action-level `disabledReason` (#26619)', () => { expect(entry?.inline).toBe(REASON); const { bundles } = extractTranslations(config, { defaultLocale: 'en', fill: 'default' }); - expect(bundles.en.objects.clm_contract._actions.submit_for_approval.disabledReason).toBe(REASON); + expect(bundles.en.objects?.clm_contract?._actions?.submit_for_approval.disabledReason).toBe(REASON); expect(computeI18nCoverage(config).issues.map((i) => i.key)).toContain(key); config.translations[0]['zh-CN'].objects.clm_contract._actions.submit_for_approval.disabledReason = diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 686ce4300f2..837615236d7 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -3947,6 +3947,7 @@ describe('validateTranslationReferences — keyed children of an action entry (# description: { kind: 'leaf-copy', why: 'prose' }, confirmText: { kind: 'leaf-copy', why: 'prose' }, successMessage: { kind: 'leaf-copy', why: 'prose' }, + disabledReason: { kind: 'leaf-copy', why: 'prose' }, outcomeMessages: { kind: 'reference-checked', host: 'outcome', From bdac02acfe23d57afe4a7441a837b2cf57f46710 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:12:29 +0000 Subject: [PATCH 4/6] fix(spec): say what disabledReason refuses and warn the author until the console reads it Contract review on the spec half: - The changeset and the actions page said an empty locale map or a blank map value is refused; only a blank string is. Both now say so. - The changeset said an author gets the planned-property warning, but the liveness row set no authorWarn. The row now carries authorWarn plus an authorHint, and a lint pin reads the warning off the shipped ledger. - The two .describe() texts no longer say the console shows the key yet. CI pins that move with the new key: the metadata-form catalog positive control (686 -> 687, the action form's new row) and the /meta/types action key set (47 -> 48, now sampled by name). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfHuAwrecfjUDtkwcm --- .changeset/26619-action-disabled-reason.md | 4 ++-- content/docs/protocol/objectui/actions.mdx | 2 +- content/docs/references/data/object.mdx | 2 +- .../references/kernel/metadata-plugin.mdx | 2 +- .../docs/references/system/translation.mdx | 10 ++++---- content/docs/references/ui/action.mdx | 2 +- .../lint/src/lint-liveness-properties.test.ts | 24 +++++++++++++++++++ ...l.meta-types-degenerate-derivation.test.ts | 6 +++-- ...ect-lifecycle-panel-echo-decisions.test.ts | 4 +++- packages/spec/liveness/action.json | 4 +++- packages/spec/src/system/translation.zod.ts | 2 +- packages/spec/src/ui/action.zod.ts | 2 +- 12 files changed, 47 insertions(+), 17 deletions(-) diff --git a/.changeset/26619-action-disabled-reason.md b/.changeset/26619-action-disabled-reason.md index 7cbf6b109a5..aaef8cbc497 100644 --- a/.changeset/26619-action-disabled-reason.md +++ b/.changeset/26619-action-disabled-reason.md @@ -21,9 +21,9 @@ defineAction({ }); ``` -- **An `I18nLabel`.** A plain string or an inline locale map (`{ en, 'zh-CN' }`). A blank reason is refused at parse: `''`, a whitespace-only string, an empty map, or a map with a blank value. Omit the key to keep the default. `disabledMessage`, `disabledTooltip`, `disabledHint` and `disabledText` are renamed onto it. +- **An `I18nLabel`.** A plain string or an inline locale map (`{ en, 'zh-CN' }`). A blank string is refused at parse: `''` or whitespace only. Omit the key to keep the default. The locale-map arm is the `InlineLocaleMapSchema` every `I18nLabel` takes, so a map is not checked for blank values. `disabledMessage`, `disabledTooltip`, `disabledHint` and `disabledText` are renamed onto it. - **Translated with the copy keys.** The slot is `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an action with no `objectName`), beside `confirmText` and `successMessage`. `translateAction` overlays it, and `os i18n extract` (`@objectstack/cli`) scaffolds it for an action that declares one and seeds nothing for one that does not. - **Display only.** It does not change what `disabled` evaluates to and is never read as a condition: `os validate` checks `disabled`, not `disabledReason`. It is not access control either. A call that reaches the server anyway is refused by the action's own rules (its body or hook, `requiredPermissions`), never by this text. - **Studio.** The action editor's Placement section gains a "Disabled Reason" row under "Disabled", translated in `@objectstack/platform-objects` for zh-CN, ja-JP and es-ES. -Nothing renders the key yet. The console's greyed-action surfaces (the record header, the record section bar, the declared actions bar and the four `action:*` renderers) read it in the follow-up change. Until then the liveness ledger marks it `planned`, and an author declaring it gets the planned-property warning that says so. +Nothing renders the key yet. The console's greyed-action surfaces (the record header, the record section bar, the declared actions bar and the four `action:*` renderers) read it in the follow-up change. Until then the liveness ledger marks it `planned` with `authorWarn`, so `os validate` warns an author who declares it that nothing shows it yet. diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index 94bd11f4086..7b534bbadf8 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -346,7 +346,7 @@ disabledReason: An approved legal review is required before the contract enters ``` - It is an `I18nLabel`: a plain string, translated at `objects.._actions..disabledReason` (`globalActions..disabledReason` for an action with no `objectName`), or an inline locale map. `os i18n extract` scaffolds the key. -- A blank reason (`''`, whitespace, or a map with a blank value) is refused at parse. Omit the key to keep the default. +- A blank string (`''` or whitespace only) is refused at parse. Omit the key to keep the default. - It is **display only**. It does not change what `disabled` evaluates to, nothing reads it as a condition, and it is not access control: a call that reaches the server anyway is refused by the action's own rules (its body or hook, `requiredPermissions`), never by this text. #### Evaluation context diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index ad8a18cbeac..5bac0c6bed7 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -499,7 +499,7 @@ const result = ApiMethod.parse(data); | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | -| **disabledReason** | `string \| Record` | optional | Display-only reason a greyed action shows (hover title and accessible description) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | +| **disabledReason** | `string \| Record` | optional | Display-only reason for a greyed action, for its hover title and accessible description while `disabled` evaluates TRUE (declared ahead of its console reader: no renderer shows it yet, #26619) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index b1c8952ed26..efc775be643 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -342,7 +342,7 @@ const result = MetadataBulkResultSchema.parse(data); | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | -| **disabledReason** | `string \| Record` | optional | Display-only reason a greyed action shows (hover title and accessible description) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | +| **disabledReason** | `string \| Record` | optional | Display-only reason for a greyed action, for its hover title and accessible description while `disabled` evaluates TRUE (declared ahead of its console reader: no renderer shows it yet, #26619) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 40c3be632e2..ea7757ee2d8 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -126,7 +126,7 @@ Translation data for a single field | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | -| **disabledReason** | `string` | optional | Translated reason a greyed action shows while its `disabled` predicate is TRUE (hover title and accessible description) | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet, #26619) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -226,7 +226,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | -| **disabledReason** | `string` | optional | Translated reason a greyed action shows while its `disabled` predicate is TRUE (hover title and accessible description) | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet, #26619) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -383,7 +383,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | -| **disabledReason** | `string` | optional | Translated reason a greyed action shows while its `disabled` predicate is TRUE (hover title and accessible description) | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet, #26619) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -602,7 +602,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | -| **disabledReason** | `string` | optional | Translated reason a greyed action shows while its `disabled` predicate is TRUE (hover title and accessible description) | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet, #26619) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -790,7 +790,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | -| **disabledReason** | `string` | optional | Translated reason a greyed action shows while its `disabled` predicate is TRUE (hover title and accessible description) | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet, #26619) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index a86bb1ff81f..3e42445539d 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -56,7 +56,7 @@ const result = ActionSchema.parse(data); | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| … +2 more>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| 'transfer_ownership' \| 'remove_member' \| 'create_team' \| 'update_team' \| 'remove_team' \| … +4 more>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | -| **disabledReason** | `string \| Record` | optional | Display-only reason a greyed action shows (hover title and accessible description) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | +| **disabledReason** | `string \| Record` | optional | Display-only reason for a greyed action, for its hover title and accessible description while `disabled` evaluates TRUE (declared ahead of its console reader: no renderer shows it yet, #26619) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 6c201e12679..e0e128b89d4 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -2314,3 +2314,27 @@ describe('report period and export (#26548 — planned until the report renderer expect(lintLivenessProperties(report())).toEqual([]); }); }); + +// ── #26619: a greyed action's reason is declared before the console reads it ── +// +// `disabledReason` is `planned` + `authorWarn` until the console's greyed-action +// readers show it. Read off the SHIPPED ledger, so the console PR's flip to +// `live` (which drops `authorWarn`) turns the warning half of this block red +// on purpose — rewrite it as a silence pin in that same edit. +describe('action disabled reason (#26619 — planned until the console reads it)', () => { + const action = (extra: Record = {}) => ({ + actions: [{ name: 'submit_for_approval', label: 'Submit for approval', objectName: 'clm_contract', disabled: "record.legal_review_status != 'approved'", ...extra }], + }); + + it('warns on the key, at the action, with the row\'s author hint', () => { + const findings = lintLivenessProperties(action({ disabledReason: 'An approved legal review is required first.' })); + expect(ruleOf(findings, 'disabledReason')).toBe('liveness-planned-property'); + const f = findings.find((x) => x.rule === 'liveness-planned-property')!; + expect(f.where).toContain('submit_for_approval'); + expect(f.hint).toMatch(/^Keep it, but nothing reads it yet: no console shows a greyed action's `disabledReason`/); + }); + + it('CONTROL — an action declaring no reason draws no warning', () => { + expect(lintLivenessProperties(action())).toEqual([]); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index b08b655c826..fefd90cba4c 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -253,10 +253,12 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no // major it retired in — at 18 they left the declared set with their // tombstones. The declared total shrinks by exactly those three; the // live sample below still holds every live key. - expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(47); + // [#26619] 47 → 48: `disabledReason` (why a greyed action is + // unavailable) joined the accepted set, and the sample names it. + expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(48); // A sample an author would actually address, and the one #17500's // repeater titles need a node to sit on. - for (const key of ['name', 'label', 'objectName', 'type', 'params', 'locations', 'outcomeMessages', 'requiresMembershipReach']) { + for (const key of ['name', 'label', 'objectName', 'type', 'params', 'locations', 'outcomeMessages', 'requiresMembershipReach', 'disabledReason']) { expect(Object.keys(properties)).toContain(key); } }); diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 5195f7863e7..a60ff2c7d9b 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1226,7 +1226,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 686 since #26548: the report form's "Period & export" section and its // `dateRange` / `exportOptions` composites — the section label and two // row labels, authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(686); + // 687 since #26619: the action form's `disabledReason` row (why a greyed + // action is unavailable) — one row label, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(687); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index f560a50e958..99cf4515203 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -154,8 +154,10 @@ "disabledReason": { "status": "planned", "verifiedAt": "2026-10-27", + "authorWarn": true, + "authorHint": "Keep it, but nothing reads it yet: no console shows a greyed action's `disabledReason`, so the button still says \"Not available for this record\" until the console's greyed-action readers ship.", "evidence": "packages/spec/src/system/i18n-resolver.ts#translateAction overlays the translated `objects.{o}._actions.{a}.disabledReason` (or `globalActions.{a}.disabledReason`) onto the served action; packages/cli/src/utils/i18n-extract.ts#collectExpectedEntries emits the key and packages/spec/src/system/translation.zod.ts declares the translation slot.", - "note": "PLANNED until the console half of #26619 lands (ruling 6442737825, letter A: spec PR first, console PR second). The framework half is live today — parse, blank refusal, translation slot, extract, translateAction overlay — but no renderer reads the key yet: the four greyed-action readers (ui/packages/components/src/renderers/layout/containers.tsx#disabledReasonFor, ui/packages/components/src/renderers/action/disabled-reason.tsx#useDisabledReason, ui/packages/plugin-detail/src/renderers/record-quick-actions.tsx, ui/packages/app-shell/src/views/DeclaredActionsBar.tsx) still show only `actions.notAvailableForRecord`. The console PR flips this row to `live` with those read points. Display only by contract: never a server admission input, never read as a predicate." + "note": "PLANNED until the console half of #26619 lands (ruling 6442737825, letter A: spec PR first, console PR second). The framework half is live today — parse, blank refusal, translation slot, extract, translateAction overlay — but no renderer reads the key yet: the four greyed-action readers (ui/packages/components/src/renderers/layout/containers.tsx#disabledReasonFor, ui/packages/components/src/renderers/action/disabled-reason.tsx#useDisabledReason, ui/packages/plugin-detail/src/renderers/record-quick-actions.tsx, ui/packages/app-shell/src/views/DeclaredActionsBar.tsx) still show only `actions.notAvailableForRecord`. `authorWarn` is set because an author who writes the key today sees the default sentence, not theirs. The console PR flips this row to `live` with those read points, and drops `authorWarn` / `authorHint` in that same edit (a warned `live` row reds this gate, #21127). Display only by contract: never a server admission input, never read as a predicate." }, "ai": { "status": "live", diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 02d48f03158..948c50eda8b 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -195,7 +195,7 @@ const actionTranslationSchema = (surface: string) => strictObject({ successMessage: z.string().optional().describe('Translated success toast/message'), // `ActionSchema.disabledReason` (#26619) — the display-only reason a greyed // action shows; overlaid by `translateAction` like the copy keys above. - disabledReason: z.string().optional().describe('Translated reason a greyed action shows while its `disabled` predicate is TRUE (hover title and accessible description)'), + disabledReason: z.string().optional().describe('Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet, #26619)'), // `ActionSchema.outcomeMessages` (#21095) — one translated message per // declared outcome, keyed by the same snake_case outcome name, so a bundle // key that could never match a handler's `outcome` is refused here rather diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 311a0de6347..0ff34e345b9 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -1583,7 +1583,7 @@ const actionObject = () => strictObject({ * Why the action is greyed — the reason `disabled` gives the user. Display * only; the contract is on {@link actionDisabledReason}. */ - disabledReason: actionDisabledReason().optional().describe('Display-only reason a greyed action shows (hover title and accessible description) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused.'), + disabledReason: actionDisabledReason().optional().describe('Display-only reason for a greyed action, for its hover title and accessible description while `disabled` evaluates TRUE (declared ahead of its console reader: no renderer shows it yet, #26619) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused.'), /** * [ADR-0066 D4] System capabilities required to INVOKE this action — a From 51685f13c9d8a610e4e0a1b90308945673989324 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:29:47 +0000 Subject: [PATCH 5/6] fix(spec): keep the issue id out of disabledReason's customer-facing text The two .describe() texts carried an issue id, which check-doc-authoring refuses in customer text; the adjacent comments keep it. The actions page now says the console does not show the key yet. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfHuAwrecfjUDtkwcm --- content/docs/protocol/objectui/actions.mdx | 2 +- content/docs/references/data/object.mdx | 2 +- content/docs/references/kernel/metadata-plugin.mdx | 2 +- content/docs/references/system/translation.mdx | 10 +++++----- content/docs/references/ui/action.mdx | 2 +- packages/spec/src/system/translation.zod.ts | 2 +- packages/spec/src/ui/action.zod.ts | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index 7b534bbadf8..752921ecb81 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -336,7 +336,7 @@ Predicates are **bare CEL** — `record.status == 'paid'`, not `${record.status #### Saying why: `disabledReason` -A greyed action says **why** with `disabledReason`, the sentence beside the `disabled` predicate. The console shows it as the button's hover title and its accessible description (a menu item shows it under its label). Without it the reason is the console's default, "Not available for this record". +A greyed action says **why** with `disabledReason`, the sentence beside the `disabled` predicate. It is meant for the button's hover title and its accessible description (a menu item, under its label). The console does not show it yet: until its greyed-action readers ship, every greyed action shows the console's default reason, "Not available for this record", and `os validate` warns an author who declares the key. ```yaml name: submit_for_approval diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 5bac0c6bed7..f5e77da2b99 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -499,7 +499,7 @@ const result = ApiMethod.parse(data); | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | -| **disabledReason** | `string \| Record` | optional | Display-only reason for a greyed action, for its hover title and accessible description while `disabled` evaluates TRUE (declared ahead of its console reader: no renderer shows it yet, #26619) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | +| **disabledReason** | `string \| Record` | optional | Display-only reason for a greyed action, for its hover title and accessible description while `disabled` evaluates TRUE (declared ahead of its console reader: no renderer shows it yet) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index efc775be643..5c3deceaa75 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -342,7 +342,7 @@ const result = MetadataBulkResultSchema.parse(data); | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | -| **disabledReason** | `string \| Record` | optional | Display-only reason for a greyed action, for its hover title and accessible description while `disabled` evaluates TRUE (declared ahead of its console reader: no renderer shows it yet, #26619) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | +| **disabledReason** | `string \| Record` | optional | Display-only reason for a greyed action, for its hover title and accessible description while `disabled` evaluates TRUE (declared ahead of its console reader: no renderer shows it yet) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index ea7757ee2d8..f6866a91d87 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -126,7 +126,7 @@ Translation data for a single field | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | -| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet, #26619) | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -226,7 +226,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | -| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet, #26619) | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -383,7 +383,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | -| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet, #26619) | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -602,7 +602,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | -| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet, #26619) | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -790,7 +790,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | -| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet, #26619) | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 3e42445539d..8954cc47ded 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -56,7 +56,7 @@ const result = ActionSchema.parse(data); | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| … +2 more>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| 'transfer_ownership' \| 'remove_member' \| 'create_team' \| 'update_team' \| 'remove_team' \| … +4 more>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | -| **disabledReason** | `string \| Record` | optional | Display-only reason for a greyed action, for its hover title and accessible description while `disabled` evaluates TRUE (declared ahead of its console reader: no renderer shows it yet, #26619) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | +| **disabledReason** | `string \| Record` | optional | Display-only reason for a greyed action, for its hover title and accessible description while `disabled` evaluates TRUE (declared ahead of its console reader: no renderer shows it yet) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 948c50eda8b..1da60b0bb44 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -195,7 +195,7 @@ const actionTranslationSchema = (surface: string) => strictObject({ successMessage: z.string().optional().describe('Translated success toast/message'), // `ActionSchema.disabledReason` (#26619) — the display-only reason a greyed // action shows; overlaid by `translateAction` like the copy keys above. - disabledReason: z.string().optional().describe('Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet, #26619)'), + disabledReason: z.string().optional().describe('Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet)'), // `ActionSchema.outcomeMessages` (#21095) — one translated message per // declared outcome, keyed by the same snake_case outcome name, so a bundle // key that could never match a handler's `outcome` is refused here rather diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 0ff34e345b9..eee6d0b25d5 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -1583,7 +1583,7 @@ const actionObject = () => strictObject({ * Why the action is greyed — the reason `disabled` gives the user. Display * only; the contract is on {@link actionDisabledReason}. */ - disabledReason: actionDisabledReason().optional().describe('Display-only reason for a greyed action, for its hover title and accessible description while `disabled` evaluates TRUE (declared ahead of its console reader: no renderer shows it yet, #26619) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused.'), + disabledReason: actionDisabledReason().optional().describe('Display-only reason for a greyed action, for its hover title and accessible description while `disabled` evaluates TRUE (declared ahead of its console reader: no renderer shows it yet) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused.'), /** * [ADR-0066 D4] System capabilities required to INVOKE this action — a From c9d6d356f73771753b20bde48dbef24d9120692e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:34:17 +0000 Subject: [PATCH 6/6] chore(spec): regenerate the reference docs on the merged tree Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfHuAwrecfjUDtkwcm --- .../references/kernel/metadata-plugin.mdx | 8 +++++-- .../docs/references/system/translation.mdx | 24 ++++++++++++------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 5c3deceaa75..6a233450284 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -179,7 +179,7 @@ const result = MetadataBulkResultSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'sharing_rule' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | +| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'sharing_rule' \| 'queue' \| 'assignment_rule' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | | **namespaces** | `string[]` | optional | Filter by namespaces | | **packageId** | `string` | optional | Filter by owning package | | **search** | `string` | optional | Full-text search query | @@ -251,6 +251,8 @@ const result = MetadataBulkResultSchema.parse(data); * `position` * `capability` * `sharing_rule` +* `queue` +* `assignment_rule` * `agent` * `tool` * `skill` @@ -264,7 +266,7 @@ const result = MetadataBulkResultSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| … +14 more>` | ✅ | Metadata type identifier | +| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| … +16 more>` | ✅ | Metadata type identifier | | **label** | `string` | ✅ | Display label for the metadata type | | **description** | `string` | optional | Description of the metadata type | | **filePatterns** | `string[]` | ✅ | Glob patterns to discover files of this type | @@ -306,6 +308,8 @@ const result = MetadataBulkResultSchema.parse(data); * `position` * `capability` * `sharing_rule` +* `queue` +* `assignment_rule` * `agent` * `tool` * `skill` diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index f6866a91d87..71981ff614c 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -66,6 +66,7 @@ Translation data for a single field | **help** | `string` | optional | Translated help text | | **placeholder** | `string` | optional | Translated placeholder text for form inputs | | **options** | `Record` | optional | Option value to translated label map | +| **inlineTitle** | `string` | optional | Translated inline master-detail grid title — overlays the field's own `inlineTitle` | --- @@ -90,7 +91,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -107,6 +108,7 @@ Translation data for a single field | **help** | `string` | optional | Translated help text | | **placeholder** | `string` | optional | Translated placeholder text for form inputs | | **options** | `Record` | optional | Option value to translated label map | +| **inlineTitle** | `string` | optional | Translated inline master-detail grid title — overlays the field's own `inlineTitle` | ### Nested Shape: `ObjectTranslationData._views[string]` @@ -178,7 +180,7 @@ Platform translation data — the per-app groups plus the platform-only `setting | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | -| **reports** | `Record` | optional | Report translations keyed by report name | +| **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | @@ -196,7 +198,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -256,6 +258,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated report label — overlays the report's own `label` | | **description** | `string` | optional | Translated report description — overlays the report's own `description` | +| **blocks** | `Record` | optional | Joined report block translations keyed by block name | ### Nested Shape: `PlatformTranslationData.pages[string]` @@ -336,7 +339,7 @@ Served translation data — the platform groups the browser reads, without the s | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | -| **reports** | `Record` | optional | Report translations keyed by report name | +| **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | @@ -353,7 +356,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -413,6 +416,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated report label — overlays the report's own `label` | | **description** | `string` | optional | Translated report description — overlays the report's own `description` | +| **blocks** | `Record` | optional | Joined report block translations keyed by block name | ### Nested Shape: `ServedTranslationData.pages[string]` @@ -555,7 +559,7 @@ Per-app translation data for objects, apps, and UI messages | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | -| **reports** | `Record` | optional | Report translations keyed by report name | +| **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | @@ -572,7 +576,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -632,6 +636,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated report label — overlays the report's own `label` | | **description** | `string` | optional | Translated report description — overlays the report's own `description` | +| **blocks** | `Record` | optional | Joined report block translations keyed by block name | ### Nested Shape: `TranslationData.pages[string]` @@ -733,7 +738,7 @@ One locale of translations — the `translation` metadata type | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | -| **reports** | `Record` | optional | Report translations keyed by report name | +| **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | @@ -760,7 +765,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -820,6 +825,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated report label — overlays the report's own `label` | | **description** | `string` | optional | Translated report description — overlays the report's own `description` | +| **blocks** | `Record` | optional | Joined report block translations keyed by block name | ### Nested Shape: `TranslationItem.pages[string]`