From 418ccf86e71a54773cee84db92a63260fb75ebc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:12:40 +0000 Subject: [PATCH 1/3] feat(lint)!: refuse an action's copy-template findings at validate, compile and the publish gate (WIP) Claude-Session: https://claude.ai/code/session_01WbVg72GQsLxcNHWW17FJZA Co-authored-by: Claude --- packages/lint/src/authoring-rules.ts | 10 ++- .../validate-action-copy-templates.test.ts | 80 +++++++++++++++---- .../src/validate-action-copy-templates.ts | 48 ++++++++--- packages/lint/src/validate-expressions.ts | 12 +-- .../test/expression-conformance.ledger.ts | 6 +- .../flow-text-slot-template.test.ts | 14 ++++ .../src/automation/flow-text-slot-template.ts | 25 ++++++ .../spec/src/ui/action-copy-template.test.ts | 12 ++- packages/spec/src/ui/action-copy-template.ts | 57 ++++++++++--- packages/spec/src/ui/index.ts | 6 +- 10 files changed, 214 insertions(+), 56 deletions(-) diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 3d69e6f50cb..c536ad88053 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -758,9 +758,13 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // `runStackExpressionPasses` runs that pass alone, over the written page's // action rows and inline actions; an action or object write judges the // written action's own `confirmText` / `successMessage` in the same pass. - // Every finding is a `warning` (an advisory, never a refusal), because - // every such sentence parsed while the keys were plain strings; the - // refusal is its own card. + // #25680 makes each finding an `error`, so an action, object or page write + // carrying one is refused (a narrowing no conversion carries, shipped in + // 18.x as its own BREAKING minor, as #25205 did for the bulk slot). One + // stays a `warning`: a `${…}` token the console's runner still fills, in a + // sentence with no `{{ }}` hole. MEASURED first over the shipped corpus at + // `6021b5e837`: 0 findings across examples/**, platform-objects and + // plugin-approvals, against one for a planted unbound hole. surfaces: CLI_AND_RUNTIME, runtimeTypes: ['flow', 'action', 'hook', 'object', 'view', 'page'], run: (stack, ctx) => diff --git a/packages/lint/src/validate-action-copy-templates.test.ts b/packages/lint/src/validate-action-copy-templates.test.ts index d93c1fc1151..122f7aadbfc 100644 --- a/packages/lint/src/validate-action-copy-templates.test.ts +++ b/packages/lint/src/validate-action-copy-templates.test.ts @@ -6,10 +6,11 @@ // and `record`, `errorMessage` over nothing. `validateStackExpressions` — the // rule `objectstack validate`, `objectstack compile` and the runtime publish // gate all run — judges every sentence through the template judge's -// closed-scope entry, then compiles it as a template. Every finding is a -// WARNING: each of these texts parsed while the keys were plain strings, and -// refusing them is its own card. So a pin here asserts `severity: 'warning'` -// and, at the publish gate, an advisory with no error. +// closed-scope entry, then compiles it as a template. Every finding is an +// ERROR (#25680), so the publish gate refuses the write; one stays a WARNING, +// a `${…}` token the console's runner still fills in a sentence with no +// `{{ }}` hole (`ACTION_COPY_DOLLAR_TRANSITION_ROOTS`). So a pin here asserts +// the severity per finding and, at the publish gate, which channel it rides. // // The gate half drives the REAL dispatch (`runRuntimeAuthoringRules`) with a // page write and an action write, because a `runtimeTypes` widening whose @@ -55,33 +56,51 @@ describe('validateStackExpressions — a registered action\'s copy is judged as } }); - it('names a `${…}` token with a WARNING prescribing its `{{ }}` hole, never `${{ … }}`', () => { + it('names a `${…}` token the runner still fills with a WARNING prescribing its `{{ }}` hole, never `${{ … }}`', () => { const [issue, ...rest] = copyIssues({ objects, actions: [invite({ successMessage: 'Invitation sent to ${result.email}' })] }); expect(rest).toEqual([]); expect(issue!.severity).toBe('warning'); expect(issue!.where).toBe("stack · action 'invite' successMessage"); expect(issue!.message).toContain('Write `Invitation sent to ${result.email}` as `Invitation sent to {{ result.email }}`.'); expect(issue!.message).not.toContain('${{'); - expect(issue!.message).toMatch(/A warning, not a refusal/); + const [confirm] = copyIssues({ objects, actions: [invite({ confirmText: 'Invite ${record.name}?' })] }); + expect(confirm!.severity).toBe('warning'); }); - it('names a hole over a root the runner does not bind — `result` in a confirmText', () => { + it('refuses a `${…}` token the runner shows raw — off its transitional roots, beside a `{{ }}` hole, or beside a `{x}`', () => { + for (const patch of [ + // `composeSuccessMessage` substitutes `${result.*}` alone. + { successMessage: 'Sent for ${record.name}' }, + // A sentence with a `{{ }}` hole is filled by the template engine alone. + { successMessage: 'Sent to {{ result.email }} at ${result.at}' }, + // The `${…}` evaluator does not bind the static `params`. + { confirmText: 'Run for ${params.tier}?', params: { tier: 'gold' } }, + { confirmText: 'Invite ${record.name} as {role}?' }, + ]) { + const issues = copyIssues({ objects, actions: [invite(patch)] }); + expect(issues.length, JSON.stringify(patch)).toBe(1); + expect(issues[0]!.severity, JSON.stringify(patch)).toBe('error'); + expect(issues[0]!.message).not.toMatch(/A warning, not a refusal/); + } + }); + + it('refuses a hole over a root the runner does not bind — `result` in a confirmText', () => { const [issue] = copyIssues({ objects, actions: [invite({ confirmText: 'Send to {{ result.email }}?' })] }); - expect(issue!.severity).toBe('warning'); + expect(issue!.severity).toBe('error'); expect(issue!.message).toContain("An action's `confirmText` binds only `record`"); expect(issue!.message).toContain('`{{ result.email }}`'); }); it('judges each value of a locale map at its own location', () => { const issues = copyIssues({ objects, actions: [invite({ successMessage: { en: 'Sent', 'zh-CN': '已发送 {result.email}' } })] }); - expect(issues.map((i) => i.where)).toEqual(["stack · action 'invite' successMessage.zh-CN"]); + expect(issues.map((i) => [i.where, i.severity])).toEqual([["stack · action 'invite' successMessage.zh-CN", 'error']]); }); - it('names logic in a hole, an unknown formatter and a blank sentence — all warnings', () => { - for (const successMessage of ['{{ result.count * 2 }} sent', '{{ result.email | shout }}', ' ']) { + it('refuses a single-brace token, logic in a hole, an unknown formatter and a blank sentence — all errors', () => { + for (const successMessage of ['{result.email} sent', '{{ result.count * 2 }} sent', '{{ result.email | shout }}', '', ' ']) { const issues = copyIssues({ objects, actions: [invite({ successMessage })] }); expect(issues.length, successMessage).toBeGreaterThan(0); - expect(issues.every((i) => i.severity === 'warning'), successMessage).toBe(true); + expect(issues.every((i) => i.severity === 'error'), successMessage).toBe(true); } }); @@ -117,7 +136,17 @@ describe('validateStackExpressions — a page action row\'s copy (#24814)', () = ]); expect(issues[0]!.message).toContain("binds only `record`, so a hole over any other root"); expect(issues[1]!.message).toContain('binds no root, so it fills no hole'); - expect(issues.every((i) => i.severity === 'warning')).toBe(true); + expect(issues.every((i) => i.severity === 'error')).toBe(true); + }); + + it('refuses a `${…}` token in an errorMessage: the runner shows it as written', () => { + const issues = copyIssues({ + objects, + pages: [pageWith([{ type: 'action:button', properties: { label: 'Run', actionType: 'script', target: 'run', errorMessage: 'Failed for ${record.name}' } }])], + }); + expect(issues.map((i) => [i.where, i.severity])).toEqual([ + ["page 'account_home'.regions[0].components[0] (action:button) · errorMessage", 'error'], + ]); }); it('judges each action:group / action:menu member, an action:icon, and an inline `properties.action`', () => { @@ -138,6 +167,7 @@ describe('validateStackExpressions — a page action row\'s copy (#24814)', () = ]); expect(issues[0]!.message).toContain('as `Run {{ record.name }}?`'); expect(issues[2]!.message).toMatch(/^An action's `confirmText` is blank/); + expect(issues.map((i) => i.severity)).toEqual(['warning', 'error', 'error', 'warning']); }); it('on a page write runs that pass alone', () => { @@ -152,16 +182,32 @@ describe('validateStackExpressions — a page action row\'s copy (#24814)', () = }); }); -describe('the runtime publish gate — an advisory, never a refusal (#24814)', () => { +describe('the runtime publish gate — a refusal, but for the runner\'s transitional `${…}` (#24814, #25680)', () => { const findings = (type: string, item: unknown) => { const { errors, advisories } = runRuntimeAuthoringRules({ type, item, context: { objects } }); const copy = (f: { where: string }) => /confirmText|successMessage|errorMessage/.test(f.where); return { errors: errors.filter(copy), advisories: advisories.filter(copy) }; }; + const row = (properties: Record) => + pageWith([{ type: 'action:button', properties: { label: 'Run', actionType: 'script', target: 'run', ...properties } }]); + + it('a page write carrying a single-brace, an unbound or a blank confirmText answers one error and no advisory', () => { + for (const confirmText of ['Delete {record.name}?', 'Delete {{ result.name }}?', '']) { + const { errors, advisories } = findings('page', row({ confirmText })); + expect(errors, confirmText).toHaveLength(1); + expect(errors[0]!.where, confirmText).toContain('confirmText'); + expect(advisories, confirmText).toEqual([]); + } + }); + + it('an action and an object write carrying an unbound successMessage answer one error', () => { + const action = invite({ successMessage: 'Sent to {{ owner.email }}' }); + expect(findings('action', action).errors).toHaveLength(1); + expect(findings('object', { ...objects[0], actions: [action] }).errors).toHaveLength(1); + }); - it('a page write carrying a `${…}` confirmText answers one advisory and no error', () => { - const page = pageWith([{ type: 'action:button', properties: { label: 'Run', actionType: 'script', target: 'run', confirmText: 'Delete ${record.name}?' } }]); - const { errors, advisories } = findings('page', page); + it('a page write carrying a transitional `${…}` confirmText answers one advisory and no error', () => { + const { errors, advisories } = findings('page', row({ confirmText: 'Delete ${record.name}?' })); expect(errors).toEqual([]); expect(advisories).toHaveLength(1); expect(advisories[0]!.message).toContain('as `Delete {{ record.name }}?`'); diff --git a/packages/lint/src/validate-action-copy-templates.ts b/packages/lint/src/validate-action-copy-templates.ts index 20e6b0cb04a..dcede89c7bb 100644 --- a/packages/lint/src/validate-action-copy-templates.ts +++ b/packages/lint/src/validate-action-copy-templates.ts @@ -27,15 +27,21 @@ * `ACTION_ERROR_TEMPLATE_ROOTS` (none — the runner shows an `errorMessage` as * written). * - * ⚠️ Every finding is a WARNING: an advisory at the publish gate, never a - * refusal, so no command's exit code changes. Each of these texts parsed while - * the keys were plain strings; refusing them is a narrowing no ADR-0087 - * conversion carries, and it ships as its own card, as the bulk slot's did. + * Every finding is an ERROR (#25680): `objectstack validate` and `compile` + * fail, and the publish gate answers its 422. Each of these texts parsed while + * the keys were plain strings, and it still parses (the slot keeps its blank + * arm), so a stored row keeps loading until its next save; the save is judged. + * One finding stays a WARNING: a `${…}` token the console's runner still fills + * in its transitional reading — a sentence with no `{{ }}` hole whose every + * token is `${…}` over a root `ACTION_COPY_DOLLAR_TRANSITION_ROOTS` names for + * the key (`dollarOnlyTokenRoots`). Refusing it waits for the runner to retire + * that reading; any other `${…}` token is shown raw, so it is an error. */ import { validateExpression } from '@objectstack/formula'; -import { closedScopeTemplateRefusal, templateSlotSource } from '@objectstack/spec/automation'; +import { closedScopeTemplateRefusal, dollarOnlyTokenRoots, templateSlotSource } from '@objectstack/spec/automation'; import { + ACTION_COPY_DOLLAR_TRANSITION_ROOTS, ACTION_ERROR_TEMPLATE_ROOTS, ACTION_SUCCESS_TEMPLATE_ROOTS, actionConfirmTemplateRoots, @@ -63,8 +69,9 @@ const CONTAINER_COMPONENTS: ReadonlySet = new Set(['action:group', 'acti /** A blank sentence — `trim()`'s whitespace set is exactly `\s`. */ const BLANK_TEXT = /^\s*$/; -/** What every finding ends with: it is advice, and why. */ -const ADVISORY = ' A warning, not a refusal: this text parsed while the key was a plain string.'; +/** What the one finding that stays a warning ends with: why it is advice, for now. */ +const TRANSITIONAL = ' A warning, not a refusal, for now: the console\'s runner still fills a `${…}` token over this ' + + 'root in a sentence with no `{{ }}` hole.'; /** What a blank sentence costs, per key, at the console's runner. */ const BLANK_COST: Record = { @@ -75,6 +82,17 @@ const BLANK_COST: Record = { + 'sentence, or remove the key.', }; +/** + * Whether `text` is the one shape the runner still reads in its `${…}` + * dialect for `key`: no `{{ }}` hole, and every token a `${…}` over a root it + * fills there. + */ +function isTransitionalDollar(key: CopyKey, text: string): boolean { + const roots = dollarOnlyTokenRoots(text); + const fills = ACTION_COPY_DOLLAR_TRANSITION_ROOTS[key]; + return roots !== undefined && roots.every((root) => fills.includes(root)); +} + /** The roots one key's holes may name, for the action that carries it. */ function rootsOf(key: CopyKey, action: AnyRec): readonly string[] { if (key === 'confirmText') return actionConfirmTemplateRoots(action); @@ -114,19 +132,23 @@ function judgeAction( for (const { at, text } of sentencesOf(action[key], localeMap)) { const located = `${where} ${key}${at}`; const slot = `An action's \`${key}\``; - const warn = (message: string, quoted: string): void => { - issues.push({ where: located, message: `${message}${ADVISORY}`, source: quoted, severity: 'warning' }); + const refuse = (message: string, quoted: string): void => { + issues.push({ where: located, message, source: quoted, severity: 'error' }); }; if (BLANK_TEXT.test(text)) { - warn(`${slot} is blank, ${BLANK_COST[key]}`, text); + refuse(`${slot} is blank, ${BLANK_COST[key]}`, text); continue; } const refusal = closedScopeTemplateRefusal(text, { slot, roots: rootsOf(key, action) }); if (refusal !== undefined) { - warn(refusal, text); + if (isTransitionalDollar(key, text)) { + issues.push({ where: located, message: `${refusal}${TRANSITIONAL}`, source: text, severity: 'warning' }); + } else { + refuse(refusal, text); + } continue; } - for (const e of validateExpression('template', text).errors) warn(e.message, e.source); + for (const e of validateExpression('template', text).errors) refuse(e.message, e.source); } } } @@ -142,7 +164,7 @@ export interface ActionCopyTemplateScope { } /** - * Every warning an action's copy draws across the collections `scope` names + * Every finding an action's copy draws across the collections `scope` names * (see the module note). An action declared both top-level and on its object * (`defineStack` merges one onto the other) is judged once, at the first place * it is met — the de-duplication the expression pass's `checkAction` applies. diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 588c2fea5d1..bcd65d49e9c 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -2008,9 +2008,10 @@ export interface StackExpressionOptions { * written view's `list` and `listViews` — every other pass reads a * collection a view write does not carry. * - * [#24814] An action's copy pass (`actionCopyTemplateIssues`, warnings only) - * judges an object write's own `actions[]`; on a `page` write it is the - * ONLY pass, over the written page's action rows and inline actions. + * [#24814] An action's copy pass (`actionCopyTemplateIssues`, errors since + * #25680 but for the runner's transitional `${…}` reading) judges an object + * write's own `actions[]`; on a `page` write it is the ONLY pass, over the + * written page's action rows and inline actions. * * The passes over the stack's OTHER collections — flows, the top-level * `actions`, sharing rules and hooks — read an empty list on an object @@ -2055,7 +2056,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression if (options.runtimeWriteType === 'view') return bulkConfirmTextIssues(stack, { views: true, objects: false }); // [#24814] On a `page` write exactly ONE pass judges too — the action copy // pass over the written page's action rows and inline actions - // (`validate-action-copy-templates.ts`), whose findings are warnings. + // (`validate-action-copy-templates.ts`), whose findings are errors (#25680). if (options.runtimeWriteType === 'page') { return actionCopyTemplateIssues(stack, { actions: false, objects: false, pages: true }); } @@ -3482,7 +3483,8 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression issues.push(...bulkConfirmTextIssues(stack, { views: !objectWrite, objects: true })); // [#24814] The action copy template slots: an object's own `actions[]` on // every run (an object write included), the top-level `actions` and the - // pages' action rows off an object write. Warnings only. + // pages' action rows off an object write. Errors since #25680, but for the + // runner's transitional `${…}` reading, which stays a warning. issues.push(...actionCopyTemplateIssues(stack, { actions: !objectWrite, objects: true, pages: !objectWrite })); return issues; diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index df6c5f4f465..f692bf36401 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -646,14 +646,14 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ summary: 'action confirm sentence (Action.confirmText, and the page action rows\' `confirmText`) — `{{ record.* }}` holes, and `{{ params.* }}` beside static values', dialect: 'template', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', enforcement: - 'PARSE, measured here: `ui/action-copy-template.ts` `actionCopyTemplateSlot` around `templateExpressionInput`, built at each declaring file, normalizes a bare string to `{dialect:"template",source}`, keeps a blank string as written and (on `Action`) the inline locale map. BUILD-TIME GATE, measured here: lint/validate-action-copy-templates.ts `actionCopyTemplateIssues`, run by `validateStackExpressions` at `objectstack validate` / `compile` and the publish gate (`action`, `object` and `page` writes), judges each sentence with `closedScopeTemplateRefusal` over `actionConfirmTemplateRoots(action)` and then `validateExpression(\'template\', …)` — every finding a WARNING. EVALUATOR: console (objectui) `ActionRunner.confirmMessage` → `fillTemplateHoles` → @objectstack/formula `templateEngine.evaluate` over `record` (the dispatched row, else the context record) and `params` (the static values), read at the pinned `.objectui-sha`; a sentence the engine refuses is shown as written, an unbound hole prints empty, and a sentence with no `{{ }}` hole keeps the transitional `${…}` reading', + 'PARSE, measured here: `ui/action-copy-template.ts` `actionCopyTemplateSlot` around `templateExpressionInput`, built at each declaring file, normalizes a bare string to `{dialect:"template",source}`, keeps a blank string as written and (on `Action`) the inline locale map. BUILD-TIME GATE, measured here: lint/validate-action-copy-templates.ts `actionCopyTemplateIssues`, run by `validateStackExpressions` at `objectstack validate` / `compile` and the publish gate (`action`, `object` and `page` writes), judges each sentence with `closedScopeTemplateRefusal` over `actionConfirmTemplateRoots(action)` and then `validateExpression(\'template\', …)` — every finding an ERROR (the publish gate answers 422), but for a `${…}` token over a root `ACTION_COPY_DOLLAR_TRANSITION_ROOTS` names in a sentence with no `{{ }}` hole, which the runner still fills and the doors name as a WARNING. EVALUATOR: console (objectui) `ActionRunner.confirmMessage` → `fillTemplateHoles` → @objectstack/formula `templateEngine.evaluate` over `record` (the dispatched row, else the context record) and `params` (the static values), read at the pinned `.objectui-sha`; a sentence the engine refuses is shown as written, an unbound hole prints empty, and a sentence with no `{{ }}` hole keeps the transitional `${…}` reading', covers: [ 'ui/action.zod.ts:actionObject.confirmText', 'ui/component.zod.ts:ActionButtonPropsSchema.confirmText', 'ui/component.zod.ts:ActionIconPropsSchema.confirmText', 'ui/component.zod.ts:actionContainerMemberShape.confirmText', ], - note: 'Soft on both faces — a refused sentence reads as written and an unbound root as a blank fragment — which is why the doors name both ahead of time. Warnings, not refusals: each such sentence parsed while the key was a plain string, so the refusal is its own card. ⛔ NOT MEASURED HERE: the runner was read at the pin, not run, and the `-log` half of `fail-soft-log` (no log line was found on either face).', + note: 'Soft on both faces — a refused sentence reads as written and an unbound root as a blank fragment — which is why the doors name both ahead of time. Refused at the doors, not at the parse: each such sentence parsed while the key was a plain string, so a stored one keeps loading until its next save. ⛔ NOT MEASURED HERE: the runner was read at the pin, not run, and the `-log` half of `fail-soft-log` (no log line was found on either face).', }, { id: 'template-action-success-message', @@ -674,7 +674,7 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ summary: 'page action row failure toast (the rows\' `errorMessage`) — a `template` slot that binds no root', dialect: 'template', mode: 'interpret', state: 'experimental', failPolicy: 'unevaluated', enforcement: - 'PARSE ONLY in this repo — the rows type the key with the same `template` slot as `template-action-confirm-text`, over `ACTION_ERROR_TEMPLATE_ROOTS`, which is EMPTY. NO EVALUATOR FOUND: the console\'s action runner shows an authored `errorMessage` as written (its envelope\'s `source`) and fills no hole, read at the pinned `.objectui-sha`. At build and at the publish gate the action copy pass names every hole and every `${…}` / `{…}` token in it as literal text, a WARNING', + 'PARSE ONLY in this repo — the rows type the key with the same `template` slot as `template-action-confirm-text`, over `ACTION_ERROR_TEMPLATE_ROOTS`, which is EMPTY. NO EVALUATOR FOUND: the console\'s action runner shows an authored `errorMessage` as written (its envelope\'s `source`) and fills no hole, read at the pinned `.objectui-sha`. At build and at the publish gate the action copy pass names every hole and every `${…}` / `{…}` token in it as literal text, an ERROR', covers: [ 'ui/component.zod.ts:ActionButtonPropsSchema.errorMessage', 'ui/component.zod.ts:ActionIconPropsSchema.errorMessage', diff --git a/packages/spec/src/automation/flow-text-slot-template.test.ts b/packages/spec/src/automation/flow-text-slot-template.test.ts index 79323e8a84b..866940360ad 100644 --- a/packages/spec/src/automation/flow-text-slot-template.test.ts +++ b/packages/spec/src/automation/flow-text-slot-template.test.ts @@ -16,6 +16,7 @@ import { FLOW_NODE_TEXT_SLOTS, TEXT_SLOT_TEMPLATE_REFUSAL, closedScopeTemplateRefusal, + dollarOnlyTokenRoots, flowNodeTextSlotSources, templateSlotSource, textSlotTemplateRefusal, @@ -463,3 +464,16 @@ describe('the node contracts — `By {{ $User.Id }}` is refused at the schema (# } }); }); + +describe('dollarOnlyTokenRoots — the shape the action runner still reads as `${…}` (#25680)', () => { + it('answers the roots when every token is a `${…}` path and no `{{ }}` hole is present', () => { + expect(dollarOnlyTokenRoots('Delete ${record.name}?')).toEqual(['record']); + expect(dollarOnlyTokenRoots('Sent to ${result.email} for ${record.name}')).toEqual(['result', 'record']); + }); + + it('answers undefined for anything else: no token, a `{{ }}` hole, a bare `{x}`, or logic', () => { + for (const text of ['Delete this?', 'Sent to {{ result.email }} at ${result.at}', 'Run ${record.name} as {role}', '${a + b}', '']) { + expect(dollarOnlyTokenRoots(text), text).toBeUndefined(); + } + }); +}); diff --git a/packages/spec/src/automation/flow-text-slot-template.ts b/packages/spec/src/automation/flow-text-slot-template.ts index e12c4385173..830be775a94 100644 --- a/packages/spec/src/automation/flow-text-slot-template.ts +++ b/packages/spec/src/automation/flow-text-slot-template.ts @@ -693,3 +693,28 @@ export function closedScopeTemplateRefusal(text: string, scope: ClosedTemplateSc } return refusals.length === 0 ? undefined : refusals.join(' '); } + +/** + * The roots of `text`'s `${…}` tokens when they are the only thing + * {@link closedScopeTemplateRefusal} could name in it — no `{{ }}` hole, and + * every single-brace token a `${…}` path token — else `undefined`. + * + * [#25680] That is the one shape the console's action runner still reads in + * its transitional `${…}` dialect (a sentence with no `{{ }}` hole), so a door + * judging an action's copy can tell it apart from text the runner shows raw. + * Which of these roots the runner fills is the slot's fact, not this reader's + * (`ACTION_COPY_DOLLAR_TRANSITION_ROOTS` in `ui/action-copy-template.ts`). + */ +export function dollarOnlyTokenRoots(text: string): string[] | undefined { + if (/\{\{[^}]*\}\}/.test(text)) return undefined; + const tokens = singleBraceTokens(text); + if (tokens.length === 0) return undefined; + const roots: string[] = []; + for (const token of tokens) { + if (token.kind !== 'path' || !isDollarToken(text, token)) return undefined; + const root = pathRoot(token.inner); + if (root === undefined) return undefined; + roots.push(root); + } + return roots; +} diff --git a/packages/spec/src/ui/action-copy-template.test.ts b/packages/spec/src/ui/action-copy-template.test.ts index 96b6e770cce..7127ca945f4 100644 --- a/packages/spec/src/ui/action-copy-template.test.ts +++ b/packages/spec/src/ui/action-copy-template.test.ts @@ -6,7 +6,8 @@ * on the page action rows, `confirmText` and `successMessage` on * `ActionSchema`. These pins hold each slot's accept set — a bare string is * shorthand for the envelope, a holeless sentence still parses, a blank one - * still parses (the widening keeps that arm), and `ActionSchema` keeps its + * still parses (the arm is kept so a stored one loads; the doors refuse it, + * #25680), and `ActionSchema` keeps its * inline locale map — and the roots the spec declares from the runner. The * holes are judged at the doors through `closedScopeTemplateRefusal` * (`automation/flow-text-slot-template.test.ts` pins the judge) and @@ -16,6 +17,7 @@ import { describe, expect, it } from 'vitest'; import { ACTION_CONFIRM_PARAMS_ROOT, + ACTION_COPY_DOLLAR_TRANSITION_ROOTS, ACTION_COPY_RECORD_ROOT, ACTION_ERROR_TEMPLATE_ROOTS, ACTION_SUCCESS_RESULT_ROOT, @@ -49,7 +51,7 @@ describe('the page action rows\' copy keys are template slots', () => { expect(ok('Delete {{ record.name }}?')).toEqual({ dialect: 'template', source: 'Delete {{ record.name }}?' }); expect(ok({ dialect: 'template', source: 'Done' })).toEqual({ dialect: 'template', source: 'Done' }); // A `${…}` sentence parsed while the key was a string, and still does: - // the doors name it with a warning. + // the doors judge it (a warning only while the runner still fills it). expect(ok('Delete ${record.name}?')).toEqual({ dialect: 'template', source: 'Delete ${record.name}?' }); expect(ok('')).toBe(''); expect(ok(' ')).toBe(' '); @@ -112,4 +114,10 @@ describe('the roots are what the runner binds', () => { expect([...ACTION_SUCCESS_TEMPLATE_ROOTS]).toEqual(['result', 'record']); expect([...ACTION_ERROR_TEMPLATE_ROOTS]).toEqual([]); }); + + it('the `${…}` tokens the runner still fills: `${record.*}` in a confirmText, `${result.*}` in a successMessage', () => { + expect([...ACTION_COPY_DOLLAR_TRANSITION_ROOTS.confirmText]).toEqual(['record']); + expect([...ACTION_COPY_DOLLAR_TRANSITION_ROOTS.successMessage]).toEqual(['result']); + expect([...ACTION_COPY_DOLLAR_TRANSITION_ROOTS.errorMessage]).toEqual([]); + }); }); diff --git a/packages/spec/src/ui/action-copy-template.ts b/packages/spec/src/ui/action-copy-template.ts index 03b1515c87f..86cffc70e22 100644 --- a/packages/spec/src/ui/action-copy-template.ts +++ b/packages/spec/src/ui/action-copy-template.ts @@ -37,13 +37,24 @@ import { InlineLocaleMapSchema } from './i18n.zod'; * `closedScopeTemplateRefusal` (`automation/flow-text-slot-template.ts`), and * then compile it as a template. ⛔ Never a second reading of the dialect. * - * ## Widening only: the blank arm, and warnings at the doors + * ## The doors refuse; the parse keeps the blank arm * * Each key was a plain string (`ActionSchema`'s an `I18nLabel`), so a blank - * sentence and a sentence carrying a `${…}` or `{x}` token parsed. Both still - * parse: the slot keeps a blank-string arm, and the doors' findings are - * WARNINGS — an advisory at the publish gate, never a refusal. Refusing them - * is a narrowing no ADR-0087 conversion carries, so it is its own card. + * sentence and a sentence carrying a `${…}` or `{x}` token parsed. They still + * parse: the slot keeps a blank-string arm, because the boot load and the read + * path judge a stored row with this parse, and dropping the arm would make a + * stored blank sentence an invalid row on load, a narrowing wider than the + * doors. The doors refuse instead (#25680): a blank sentence, a hole over a + * root the key does not bind, a single-brace `{x}` token, a hole holding logic + * or an unknown formatter, and a `${…}` token the runner shows raw are + * ERRORS, so `objectstack validate` and `compile` fail and the publish gate + * answers its 422. A stored row keeps loading until its next save, and that + * save is judged. + * + * One finding stays a WARNING: a `${…}` token the runner still fills (the + * next section), in a sentence with no `{{ }}` hole, over a root + * {@link ACTION_COPY_DOLLAR_TRANSITION_ROOTS} names. Refusing it waits for + * the runner to retire that reading. * * ## The `${…}` spelling * @@ -56,6 +67,23 @@ import { InlineLocaleMapSchema } from './i18n.zod'; * its `$` and prescribes the `{{ }}` hole in its place. */ +/** + * The roots whose `${…}` tokens the console's action runner still fills, per + * key, in a sentence with no `{{ }}` hole — measured at objectui + * `ActionRunner` (the pinned `.objectui-sha`): `confirmMessage` evaluates a + * holeless `confirmText` through its `${…}` expression evaluator over the + * runner's context, whose `record` is the record the action runs against; + * `composeSuccessMessage` substitutes `${result.*}` alone in a holeless + * `successMessage` (any other `${…}` is left as written); an `errorMessage` is + * shown as written. A door names a `${…}` token over one of these roots as a + * warning and every other one as an error (the module note). + */ +export const ACTION_COPY_DOLLAR_TRANSITION_ROOTS: Readonly> = Object.freeze({ + confirmText: Object.freeze(['record']), + successMessage: Object.freeze(['result']), + errorMessage: Object.freeze([]), +}); + /** The root a `confirmText` / `successMessage` hole names for the record the action runs against. */ export const ACTION_COPY_RECORD_ROOT = 'record'; @@ -125,9 +153,9 @@ export function actionCopyRefusals(key: ActionCopyKey, options: { localeMap?: bo * The slot one copy key declares, around its `template` input: ADR-0032's * `template` dialect, the input `TemplateExpressionInputSchema` takes — a bare * string is shorthand for `{ dialect: 'template', source }`, an envelope must - * declare `dialect: 'template'` — then a blank-string arm, so the parse does - * not narrow (the module note); then, on `ActionSchema`, the inline locale map - * its `I18nLabel` took. + * declare `dialect: 'template'` — then a blank-string arm, so a stored blank + * sentence still loads while the doors refuse it (the module note); then, on + * `ActionSchema`, the inline locale map its `I18nLabel` took. * * The `template` input itself is built at each declaring `.zod.ts` file * (`templateExpressionInput(ExpressionSchema, actionCopyRefusals(key))`), not @@ -143,6 +171,7 @@ export function actionCopyTemplateSlot( const { dialectOnly: refusal } = actionCopyRefusals(key, options); // `trim()`'s whitespace set is exactly `\s` (ECMA-262), so this arm takes the blank strings // `NON_BLANK_STRING` refuses — as a `pattern` the JSON Schema carries, not a dropped refinement. + // Kept so a stored blank sentence still loads; the doors refuse it (#25680, the module note). const blank = z.string().regex(/^\s*$/); return options.localeMap === true ? z.union([template, blank, InlineLocaleMapSchema], { error: () => refusal }) @@ -155,7 +184,13 @@ const GRAMMAR = + 'holds no logic (no CEL, no arithmetic).'; const DOORS = - 'at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves.'; + 'and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one ' + + 'loads until its next save.'; + +/** What the confirm and success describes add: the one transitional `${…}` reading the doors only warn about. */ +const transition = (root: string) => + ` A \`\${${root}.*}\` token in a sentence with no \`{{ }}\` hole, which the console's runner still fills, is named ` + + 'as a warning instead.'; /** `confirmText`'s describe: its roots, by where the action's static values live. */ export const ACTION_CONFIRM_TEXT_TEMPLATE_DESCRIBE = @@ -165,7 +200,7 @@ export const ACTION_CONFIRM_TEXT_TEMPLATE_DESCRIBE = + 'is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values ' + `are collected after the confirmation. ${GRAMMAR} Example: 'Delete {{ record.name }}?'. A hole over any other ` + 'root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an ' - + `unknown formatter, and a blank sentence are named ${DOORS}`; + + `unknown formatter, and a blank sentence are named ${DOORS}${transition('record')}`; /** `successMessage`'s describe. */ export const ACTION_SUCCESS_MESSAGE_TEMPLATE_DESCRIBE = @@ -174,7 +209,7 @@ export const ACTION_SUCCESS_MESSAGE_TEMPLATE_DESCRIBE = + 'the action\'s handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and ' + `\`record\`, the record the action ran against. ${GRAMMAR} Example: 'Invitation sent to {{ result.email }}'. A ` + 'hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole ' - + `holding logic or an unknown formatter, and a blank sentence are named ${DOORS}`; + + `holding logic or an unknown formatter, and a blank sentence are named ${DOORS}${transition('result')}`; /** `errorMessage`'s describe: a slot that fills no hole. */ export const ACTION_ERROR_MESSAGE_TEMPLATE_DESCRIBE = diff --git a/packages/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts index 6a57f625c41..a61f3b19cd5 100644 --- a/packages/spec/src/ui/index.ts +++ b/packages/spec/src/ui/index.ts @@ -51,9 +51,11 @@ export { pageForm } from './page.form'; export * from './action.zod'; // [#24814] The CLOSED scope of an action's copy — `confirmText`, // `successMessage`, `errorMessage` — as the console's runner binds it: the -// roots the doors judge each sentence against. The slot factories stay -// package-internal. +// roots the doors judge each sentence against, and (#25680) the roots whose +// `${…}` tokens the runner still fills, which the doors only warn about. The +// slot factories stay package-internal. export { + ACTION_COPY_DOLLAR_TRANSITION_ROOTS, ACTION_COPY_RECORD_ROOT, ACTION_CONFIRM_PARAMS_ROOT, ACTION_SUCCESS_RESULT_ROOT, From 950fa3c3ad1562952d23b39d80aefdee0ba355d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:22:39 +0000 Subject: [PATCH 2/3] feat(lint)!: door pins, changeset and regenerated spec artifacts for the action copy refusal Claude-Session: https://claude.ai/code/session_01WbVg72GQsLxcNHWW17FJZA Co-authored-by: Claude --- .changeset/25680-action-copy-refusal.md | 31 ++++ content/docs/references/data/object.mdx | 4 +- .../references/kernel/metadata-plugin.mdx | 4 +- content/docs/references/ui/action.mdx | 8 +- content/docs/references/ui/component.mdx | 28 ++-- .../protocol.runtime-authoring-gate.test.ts | 136 ++++++++++++++++++ packages/spec/api-surface/automation.json | 1 + packages/spec/api-surface/ui.json | 1 + packages/spec/export-origins/automation.json | 1 + packages/spec/export-origins/ui.json | 1 + 10 files changed, 193 insertions(+), 22 deletions(-) create mode 100644 .changeset/25680-action-copy-refusal.md diff --git a/.changeset/25680-action-copy-refusal.md b/.changeset/25680-action-copy-refusal.md new file mode 100644 index 00000000000..b641759eaf3 --- /dev/null +++ b/.changeset/25680-action-copy-refusal.md @@ -0,0 +1,31 @@ +--- +'@objectstack/lint': minor +'@objectstack/spec': minor +--- + +feat(lint)!: `objectstack validate`, `compile` and the publish gate refuse an action's `confirmText` / `successMessage` / `errorMessage` that is blank, names an unbound root, carries a single-brace token, holds logic in a hole, or carries a `${…}` token the runner shows raw (#25680) + +Clause-②: no (narrowing: an action's copy sentence that is blank, holes an unbound root, carries a single-brace or raw-shown `${…}` token, or holds logic in a hole is refused at `objectstack validate`, `compile` and the publish gate; it ships now under the amended 「甲」, #6015 `6303865025`, as #25205 did for the bulk slot) +Metadata-protocol: narrowed + +An action's person-facing copy is an ADR-0032 `template` slot over the roots the console's runner binds (#24814): `confirmText` over `record` (and `params` beside static values), `successMessage` over `result` and `record`, `errorMessage` over none. That covers a page action row (`action:button`, `action:icon`, an `action:group` / `action:menu` member), a page component's inline `properties.action`, and a registered action (`stack.actions[]`, an object's `actions[]`). `validateStackExpressions`, the rule all three doors run, judges each sentence through `closedScopeTemplateRefusal`, then compiles it as a template. Its findings were warnings. They are now errors, all but one. This supersedes the warning-only behaviour #24814 described earlier in this release line; a refused finding no longer ends with "A warning, not a refusal". + +**BREAKING — what moves for consumers.** + +- FROM: a warning at `objectstack validate` and `compile`, and an entry on the publish gate's `advisories` channel while the action, object or page saved. TO: an error. Both commands fail, and an active `action`, `object` or `page` write answers `422 INVALID_METADATA` with an `expression-invalid` issue located at the key (`page 'P'.regions[0].components[1] (action:button) · confirmText`, `stack · action 'A' successMessage`, `… successMessage.zh-CN` for one value of a locale map). The item is not stored. This covers `saveMetaItem` in publish mode (Studio, REST `/meta`, MCP) and the promotion of a draft (`publishMetaItem`). +- The refused texts: a blank sentence (`''`, `' '`); a hole over a root the key does not bind (`{{ result.name }}` in a `confirmText`, any hole in an `errorMessage`); a single-brace `{x}` token; a hole holding logic or naming an unknown formatter (`{{ record.count * 2 }}`, `{{ result.email | shout }}`); and a `${…}` token the runner shows as written — one in an `errorMessage`, one in a sentence that also has a `{{ }}` hole or a `{x}` token, one over `params` in a `confirmText`, or one over any root but `result` in a `successMessage`. + +**Fix (one line).** Write the hole the finding prescribes (`Delete ${record.name}?` → `Delete {{ record.name }}?`), name a root the key binds, double a single-brace token, move logic out of the hole, or remove a blank key so the runner's default shows. + +**Still a warning.** A `${…}` token the console's runner still fills in its transitional reading: in a sentence with no `{{ }}` hole and no other token, `${record.*}` in a `confirmText` and `${result.*}` in a `successMessage` (`ACTION_COPY_DOLLAR_TRANSITION_ROOTS`, measured at objectui `ActionRunner` at the pinned `.objectui-sha`, which fills them there). It is named with its `{{ }}` spelling and ends "A warning, not a refusal, for now". Refusing it waits for the runner to retire that reading. + +**Unchanged.** + +- The parse. Each copy key accepts what it accepted, a blank string included (the slot's blank-string arm stays). The boot load and the read path judge a stored row with this parse, so a stored page or action carrying a refused sentence keeps loading and serving until it is next saved, and that save is judged. Saving as a draft is still allowed; publishing the draft is judged. +- `ActionSchema.errorMessage` and `outcomeMessages` are not in this family and draw nothing. +- A sentence with no finding draws nothing at any door, in any form (the bare string, the `{ dialect: 'template', source }` envelope, an inline locale map). +- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. +- No schema shape moves. `@objectstack/spec` adds two exports: `ACTION_COPY_DOLLAR_TRANSITION_ROOTS` (`@objectstack/spec/ui`) and `dollarOnlyTokenRoots` (`@objectstack/spec/automation`). The three copy describes now say the texts are refused. +- Measured before crossing, through the built `@objectstack/lint` at `6021b5e837`: 0 in-repo producers carry such a sentence. `examples/app-crm`, `app-showcase` (23 registered `successMessage`s, 29 pages), `app-todo` (2 `confirmText`, 16 `successMessage`) and `app-multi-package`, `@objectstack/platform-objects` (12 `confirmText`, 48 `successMessage`) and `@objectstack/plugin-approvals` (9 `successMessage`) draw 0 findings, against 1 for a planted unbound hole. HotCRM at `a4b3560` declares 1 action `confirmText` and 19 action `successMessage`s; judged through the same built pass, all 20 draw 0 findings, against an error for a planted `{count}`. + + diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index b6d62e750fe..7302d4f9277 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -492,8 +492,8 @@ const result = ApiMethod.parse(data); | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | | **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. | -| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. | -| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. | +| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | | **outcomeMessages** | `Record>` | optional | Success copy per handler outcome, for type:'api' and type:'script' actions: keys are the snake_case `outcome` values the handler returns in its success payload (e.g. archived, already_archived), values the message shown for that outcome. Each message may interpolate $`{result.*}`, the scope `onSuccess.navigate` declares. An outcome with no entry here falls back to `successMessage`, then to the default text. Not allowed beside `resultDialog` (which suppresses the success toast) or `operation: 'update'` (no handler, so no outcome). | | **errorMessage** | `string \| Record` | optional | Error message to show when the action fails (overrides the raw error). | | **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 98e9a531950..a1885168117 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -332,8 +332,8 @@ const result = MetadataBulkResultSchema.parse(data); | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | | **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. | -| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. | -| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. | +| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | | **outcomeMessages** | `Record>` | optional | Success copy per handler outcome, for type:'api' and type:'script' actions: keys are the snake_case `outcome` values the handler returns in its success payload (e.g. archived, already_archived), values the message shown for that outcome. Each message may interpolate $`{result.*}`, the scope `onSuccess.navigate` declares. An outcome with no entry here falls back to `successMessage`, then to the default text. Not allowed beside `resultDialog` (which suppresses the success toast) or `operation: 'update'` (no handler, so no outcome). | | **errorMessage** | `string \| Record` | optional | Error message to show when the action fails (overrides the raw error). | | **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index aa48a2c13d3..c47e8f8e0d8 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -46,8 +46,8 @@ const result = ActionSchema.parse(data); | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | | **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. | -| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. | -| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. | +| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | | **outcomeMessages** | `Record>` | optional | Success copy per handler outcome, for type:'api' and type:'script' actions: keys are the snake_case `outcome` values the handler returns in its success payload (e.g. archived, already_archived), values the message shown for that outcome. Each message may interpolate $`{result.*}`, the scope `onSuccess.navigate` declares. An outcome with no entry here falls back to `successMessage`, then to the default text. Not allowed beside `resultDialog` (which suppresses the success toast) or `operation: 'update'` (no handler, so no outcome). | | **errorMessage** | `string \| Record` | optional | Error message to show when the action fails (overrides the raw error). | | **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution | @@ -345,8 +345,8 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **method** | `Enum<'POST' \| 'PATCH' \| 'PUT' \| 'DELETE'>` | optional | HTTP method for type:"api" actions. Defaults to POST. | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | | **bodyExtra** | `Record` | optional | Static request-body fields for a type:"api" action, merged last (overrides user params). `{{page.}}` tokens are resolved by the runtime. This — not `params` — is where a payload goes. | -| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. | -| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. | +| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | | **errorMessage** | `string \| Record` | optional | Error message to show when the action fails (overrides the raw error). | | **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution | | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index c89cd9c6990..1f3720a53d4 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -45,9 +45,9 @@ const result = ActionButtonPropsSchema.parse(data); | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | | **operation** | `Enum<'update'>` | optional | Declarative single-record write: `ActionSchema`'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row's values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row's `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape`, `recordIdField` and `onSuccess` are refused, and `list_toolbar` is not a location | | **patch** | `never` | optional | [REMOVED] An `action:button`'s `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | -| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | -| **errorMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action fails, in place of the raw error — a `template` slot (ADR-0032) that binds no root: a bare string is shorthand for `{ dialect: 'template', source }`, and the text is shown as written, so a `{{ }}` hole, a `${…}` or a `{x}` token in it is literal text. Each of those, and a blank sentence, is named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. | +| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. | +| **errorMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action fails, in place of the raw error — a `template` slot (ADR-0032) that binds no root: a bare string is shorthand for `{ dialect: 'template', source }`, and the text is shown as written, so a `{{ }}` hole, a `${…}` or a `{x}` token in it is literal text. Each of those, and a blank sentence, is named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **refreshAfter** | `boolean` | optional | Refresh the surrounding data after the action runs | | **undoable** | `boolean` | optional | Offer an Undo affordance after an update action | | **recordIdField** | `string` | optional | Row field whose value identifies the record the action acts on | @@ -103,9 +103,9 @@ const result = ActionButtonPropsSchema.parse(data); | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | | **operation** | `Enum<'update'>` | optional | Declarative single-record write: `ActionSchema`'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row's values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row's `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, and `list_toolbar` is not a location | | **patch** | `never` | optional | [REMOVED] An `action:group` or `action:menu` member's `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | -| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | -| **errorMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action fails, in place of the raw error — a `template` slot (ADR-0032) that binds no root: a bare string is shorthand for `{ dialect: 'template', source }`, and the text is shown as written, so a `{{ }}` hole, a `${…}` or a `{x}` token in it is literal text. Each of those, and a blank sentence, is named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. | +| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. | +| **errorMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action fails, in place of the raw error — a `template` slot (ADR-0032) that binds no root: a bare string is shorthand for `{ dialect: 'template', source }`, and the text is shown as written, so a `{{ }}` hole, a `${…}` or a `{x}` token in it is literal text. Each of those, and a blank sentence, is named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **refreshAfter** | `boolean` | optional | Refresh the surrounding data after the action runs | | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Action locations, forwarded to the runner — an `action:group` with a `location` draws only the members that list it, and the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | @@ -139,9 +139,9 @@ const result = ActionButtonPropsSchema.parse(data); | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | | **operation** | `Enum<'update'>` | optional | Declarative single-record write: `ActionSchema`'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row's values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row's `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, and `list_toolbar` is not a location | | **patch** | `never` | optional | [REMOVED] An `action:icon`'s `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | -| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | -| **errorMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action fails, in place of the raw error — a `template` slot (ADR-0032) that binds no root: a bare string is shorthand for `{ dialect: 'template', source }`, and the text is shown as written, so a `{{ }}` hole, a `${…}` or a `{x}` token in it is literal text. Each of those, and a blank sentence, is named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. | +| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. | +| **errorMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action fails, in place of the raw error — a `template` slot (ADR-0032) that binds no root: a bare string is shorthand for `{ dialect: 'template', source }`, and the text is shown as written, so a `{{ }}` hole, a `${…}` or a `{x}` token in it is literal text. Each of those, and a blank sentence, is named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **refreshAfter** | `boolean` | optional | Refresh the surrounding data after the action runs | | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Action locations, forwarded to the runner — the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | @@ -193,9 +193,9 @@ const result = ActionButtonPropsSchema.parse(data); | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | | **operation** | `Enum<'update'>` | optional | Declarative single-record write: `ActionSchema`'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row's values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row's `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, and `list_toolbar` is not a location | | **patch** | `never` | optional | [REMOVED] An `action:group` or `action:menu` member's `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | -| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | -| **errorMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action fails, in place of the raw error — a `template` slot (ADR-0032) that binds no root: a bare string is shorthand for `{ dialect: 'template', source }`, and the text is shown as written, so a `{{ }}` hole, a `${…}` or a `{x}` token in it is literal text. Each of those, and a blank sentence, is named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. | +| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. | +| **errorMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action fails, in place of the raw error — a `template` slot (ADR-0032) that binds no root: a bare string is shorthand for `{ dialect: 'template', source }`, and the text is shown as written, so a `{{ }}` hole, a `${…}` or a `{x}` token in it is literal text. Each of those, and a blank sentence, is named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **refreshAfter** | `boolean` | optional | Refresh the surrounding data after the action runs | | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Action locations, forwarded to the runner — an `action:group` with a `location` draws only the members that list it, and the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | @@ -233,8 +233,8 @@ const result = ActionButtonPropsSchema.parse(data); | **method** | `Enum<'POST' \| 'PATCH' \| 'PUT' \| 'DELETE'>` | optional | HTTP method for type:"api" actions. Defaults to POST. | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | | **bodyExtra** | `Record` | optional | Static request-body fields for a type:"api" action, merged last (overrides user params). `{{page.}}` tokens are resolved by the runtime. This — not `params` — is where a payload goes. | -| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. | -| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. | +| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | | **errorMessage** | `string \| Record` | optional | Error message to show when the action fails (overrides the raw error). | | **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution | | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index dd257b31bdf..127244708c2 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -4386,6 +4386,142 @@ describe('runtime authoring gate on VIEW writes — a bulk confirmText template } }); +// #25680 — an action's person-facing copy (`confirmText`, `successMessage`, +// `errorMessage` on a page action row; `confirmText` / `successMessage` on a +// registered action) is an ADR-0032 `template` slot over the roots the +// console's runner binds (#24814). #24814 shipped its findings as advisories; +// this card refuses them at the runtime save door, as `os build` refuses them, +// all but one: a `${…}` token the runner still fills in a sentence with no +// `{{ }}` hole stays an advisory. Pinned through the REAL `saveMetaItem`: +// +// (a) an active page save carrying a refused sentence is refused — 422 +// `INVALID_METADATA` — naming the slot, and nothing lands; an action +// write is refused the same way; the draft promotion is refused too; +// (b) the door's finding IS the build's; +// (c) control: the transitional `${…}` sentence saves with one advisory; +// (d) the parse is not narrowed: a stored page carrying a blank or an +// unbound sentence keeps loading, and its next save is judged. +// +// ⚠️ As above: this package reaches `@objectstack/lint` through its built +// `dist/`, so a rule edit is invisible here until +// `pnpm --filter @objectstack/lint build` has run. +describe('runtime authoring gate on PAGE and ACTION writes — an action\'s copy template (#25680)', () => { + const NAME = 'deals_home'; + const UNBOUND = 'Delete {{ result.name }}?'; + const page = (confirmText: string) => ({ + name: NAME, + label: 'Deals', + type: 'app', + regions: [{ name: 'main', components: [ + { type: 'action:button', properties: { label: 'Run', actionType: 'script', target: 'run', confirmText } }, + ] }], + }); + const savePage = (protocol: any, item: unknown, extra: Record = {}) => + protocol.saveMetaItem({ type: 'page', name: NAME, item, ...extra }); + const activePageRows = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'page' && r.state === 'active'); + const copyIssues = (issues: any[] | undefined) => + (issues ?? []).filter((i: any) => /confirmText|successMessage|errorMessage/.test(String(i.where))); + + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + afterEach(() => { + warn.mockRestore(); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + + for (const [label, text] of [ + ['a hole over an unbound root', UNBOUND], + ['a single-brace token', 'Delete {record.name}?'], + ['a hole holding logic', 'Delete {{ record.count * 2 }}?'], + ['a blank sentence', ''], + ] as const) { + it(`(a) REFUSES an active page save of ${label}; nothing stored`, async () => { + const { protocol, rows } = makeProtocol(); + + const err = await savePage(protocol, page(text)).catch((e: any) => e); + + expect(err, 'the save resolved — the door still admits the sentence').toBeInstanceOf(Error); + expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const issues = copyIssues(err.issues); + expect(issues, `issues: ${JSON.stringify(err.issues)}`).toHaveLength(1); + expect(issues[0]).toMatchObject({ rule: EXPRESSION_INVALID, severity: 'error' }); + expect(activePageRows(rows)).toEqual([]); + }); + } + + it('(a) REFUSES an active action save whose successMessage names an unbound root', async () => { + const { protocol, rows } = makeProtocol(); + const action = { name: 'invite', label: 'Invite', type: 'api', target: '/api/v1/invite', successMessage: 'Sent to {{ owner.email }}' }; + + const err = await protocol.saveMetaItem({ type: 'action', name: 'invite', item: action }).catch((e: any) => e); + + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(copyIssues(err.issues)).toHaveLength(1); + expect(Array.from(rows.values()).filter((r) => r.type === 'action' && r.state === 'active')).toEqual([]); + }); + + it('(a) gates the draft → active promotion of the same page', async () => { + const { protocol, rows } = makeProtocol(); + await expect(savePage(protocol, page(UNBOUND), { mode: 'draft' })).resolves.toMatchObject({ success: true }); + + const err = await protocol.publishMetaItem({ type: 'page', name: NAME }).catch((e: any) => e); + + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(copyIssues(err.issues)).toHaveLength(1); + expect(activePageRows(rows)).toEqual([]); + }); + + it('(b) the door and `os build` give the SAME finding', async () => { + const { protocol } = makeProtocol(); + const err = await savePage(protocol, page(UNBOUND)).catch((e: any) => e); + const atDoor = copyIssues(err?.issues); + + const stack = { pages: [page(UNBOUND)] }; + const atBuild = copyIssues(runAuthoringRules('build', { normalized: stack, parsed: stack }) + .filter((f) => f.severity === 'error')); + + expect(atBuild).toHaveLength(1); + expect(atDoor).toHaveLength(1); + for (const k of ['rule', 'where', 'message'] as const) { + expect(atDoor[0][k], `door and build disagree on '${k}'`).toBe(atBuild[0]![k]); + } + }); + + it('(c) CONTROL — the transitional `${record.*}` sentence saves with one advisory, and the row lands', async () => { + const { protocol, rows } = makeProtocol(); + + const res = await savePage(protocol, page('Delete ${record.name}?')); + + expect(res).toMatchObject({ success: true }); + expect(copyIssues(res.advisories)).toHaveLength(1); + expect(activePageRows(rows)).toHaveLength(1); + }); + + for (const [label, text] of [['an unbound hole', UNBOUND], ['a blank sentence', '']] as const) { + it(`(d) a stored page with ${label} keeps loading; its next save is refused, the row untouched`, async () => { + const { protocol, rows } = makeProtocol(); + // Stored before the crossing, seeded straight into the store because + // the save door now refuses this body. + const stored = page(text); + rows.set(keyOf({ type: 'page', name: NAME, organization_id: null, state: 'active' }), { + id: 'r_seed', type: 'page', name: NAME, organization_id: null, state: 'active', + metadata: JSON.stringify(stored), + }); + + // The boot path runs no authoring rule, and the parse still accepts the text. + await expect(protocol.loadMetaFromDb()).resolves.toMatchObject({ loaded: 1, errors: 0, invalid: 0 }); + + const err = await savePage(protocol, stored).catch((e: any) => e); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(JSON.parse(activePageRows(rows)[0]!.metadata)).toEqual(stored); + }); + } +}); + // ───────────────────────────────────────────────────────────────────────────── // #25234 refusal 12 — the HOST fact the door judges membership-key ownership // with. `assertRuntimeAuthoringRules` reads the registered membership diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 48fb6a35e89..8b295d8aa16 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -293,6 +293,7 @@ "defineFlow (function)", "defineWebhook (function)", "describeMissingScheduleOrganization (function)", + "dollarOnlyTokenRoots (function)", "exportConstructsToBpmn (function)", "findRegionEntry (function)", "flowFilterOperands (function)", diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 8042db8169d..3dccc6b976c 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -3,6 +3,7 @@ "entry": "./ui", "exports": [ "ACTION_CONFIRM_PARAMS_ROOT (const)", + "ACTION_COPY_DOLLAR_TRANSITION_ROOTS (const)", "ACTION_COPY_RECORD_ROOT (const)", "ACTION_ERROR_TEMPLATE_ROOTS (const)", "ACTION_LOCATIONS (const)", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 027c2491c3a..5b741e9bd12 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -287,6 +287,7 @@ "defineFlow": "src/automation/flow.zod.ts#defineFlow (function)", "defineWebhook": "src/automation/webhook.zod.ts#defineWebhook (function)", "describeMissingScheduleOrganization": "src/automation/schedule-organization.zod.ts#describeMissingScheduleOrganization (function)", + "dollarOnlyTokenRoots": "src/automation/flow-text-slot-template.ts#dollarOnlyTokenRoots (function)", "exportConstructsToBpmn": "src/automation/bpmn-mapping.ts#exportConstructsToBpmn (function)", "findRegionEntry": "src/automation/control-flow.zod.ts#findRegionEntry (function)", "flowFilterOperands": "src/automation/flow-node-expression-paths.ts#flowFilterOperands (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 6946b57ef5c..f8853aa66fb 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -3,6 +3,7 @@ "entry": "./ui", "exports": { "ACTION_CONFIRM_PARAMS_ROOT": "src/ui/action-copy-template.ts#ACTION_CONFIRM_PARAMS_ROOT (const)", + "ACTION_COPY_DOLLAR_TRANSITION_ROOTS": "src/ui/action-copy-template.ts#ACTION_COPY_DOLLAR_TRANSITION_ROOTS (const)", "ACTION_COPY_RECORD_ROOT": "src/ui/action-copy-template.ts#ACTION_COPY_RECORD_ROOT (const)", "ACTION_ERROR_TEMPLATE_ROOTS": "src/ui/action-copy-template.ts#ACTION_ERROR_TEMPLATE_ROOTS (const)", "ACTION_LOCATIONS": "src/ui/action.zod.ts#ACTION_LOCATIONS (const)", From f03deb5fd2e31334b86c2d27c7578fcc262df33d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:45:15 +0000 Subject: [PATCH 3/3] chore(changeset): carry the Ruling-ref line in the action copy refusal changeset Claude-Session: https://claude.ai/code/session_01WbVg72GQsLxcNHWW17FJZA Co-authored-by: Claude --- .changeset/25680-action-copy-refusal.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/25680-action-copy-refusal.md b/.changeset/25680-action-copy-refusal.md index b641759eaf3..97013e5d715 100644 --- a/.changeset/25680-action-copy-refusal.md +++ b/.changeset/25680-action-copy-refusal.md @@ -7,6 +7,7 @@ feat(lint)!: `objectstack validate`, `compile` and the publish gate refuse an ac Clause-②: no (narrowing: an action's copy sentence that is blank, holes an unbound root, carries a single-brace or raw-shown `${…}` token, or holds logic in a hole is refused at `objectstack validate`, `compile` and the publish gate; it ships now under the amended 「甲」, #6015 `6303865025`, as #25205 did for the bulk slot) Metadata-protocol: narrowed +Ruling-ref: `6365736632` (on #24814, the refusal half); triage `6386348015` An action's person-facing copy is an ADR-0032 `template` slot over the roots the console's runner binds (#24814): `confirmText` over `record` (and `params` beside static values), `successMessage` over `result` and `record`, `errorMessage` over none. That covers a page action row (`action:button`, `action:icon`, an `action:group` / `action:menu` member), a page component's inline `properties.action`, and a registered action (`stack.actions[]`, an object's `actions[]`). `validateStackExpressions`, the rule all three doors run, judges each sentence through `closedScopeTemplateRefusal`, then compiles it as a template. Its findings were warnings. They are now errors, all but one. This supersedes the warning-only behaviour #24814 described earlier in this release line; a refused finding no longer ends with "A warning, not a refusal".