From ba371be7594b754dadee5c5f2f18b116e122f1cf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:46:45 +0000 Subject: [PATCH 01/14] feat(spec): a bulk action's confirmText is a template slot over count and its params Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .../src/automation/flow-text-slot-template.ts | 194 +++++++++++++++--- .../spec/src/shared/typed-expression-input.ts | 5 +- packages/spec/src/ui/bulk-action.zod.ts | 67 +++++- 3 files changed, 237 insertions(+), 29 deletions(-) diff --git a/packages/spec/src/automation/flow-text-slot-template.ts b/packages/spec/src/automation/flow-text-slot-template.ts index bbe4135ef7e..722a0e505b2 100644 --- a/packages/spec/src/automation/flow-text-slot-template.ts +++ b/packages/spec/src/automation/flow-text-slot-template.ts @@ -106,6 +106,15 @@ * `{{ v }}`. ⛔ The template engine does not learn `$User` * (or any new `$` root) instead — that would widen the flow's variable set * with no declaration behind it. + * + * ## A closed scope reads the same grammar (#24798) + * + * A template text slot outside a flow whose renderer binds a FIXED set of + * roots — today a bulk action's `confirmText`, over `count` and the def's + * params — is judged here too, by {@link closedScopeTemplateRefusal}: the same + * single-brace reading, the same hole grammar, the same root rule, with the + * scope's roots in place of the flow's open one. ⛔ Never a second reading of + * the dialect for such a slot; give it a {@link ClosedTemplateScope}. */ import { @@ -225,10 +234,12 @@ export const FLOW_NODE_TEXT_SLOTS: readonly FlowNodeTextSlot[] = [ /** * The text a text-slot value carries — the string itself, or the `source` of a * `{ dialect: 'template', source }` envelope (what the `tmpl` helper builds - * for the notify pair). Anything else carries no text this judge reads: the - * slot's own contract refuses its shape. + * for the notify pair, and what a parsed template slot holds). Anything else + * carries no text this judge reads: the slot's own contract refuses its shape. + * Exported (#24798) so a door judging a template slot outside a flow reads the + * slot the way this module does — one reading of the envelope, not two. */ -function textSlotSource(value: unknown): string | undefined { +export function templateSlotSource(value: unknown): string | undefined { if (typeof value === 'string') return value; if (value !== null && typeof value === 'object' && !Array.isArray(value)) { const envelope = value as { dialect?: unknown; source?: unknown }; @@ -266,17 +277,48 @@ const HOLE = /\{\{([^}]*)\}\}/g; const HOLE_PATH = /^[\w$.[\]]+$/; /** - * The `$` root of a variable path when the engine does not bind it, else - * `undefined`. The root is the path's first segment as the engine resolves it + * The root of a variable path — its first segment as the engine resolves it * (`[i]` read as `.i`), so `$User.Id`, `$User[0]` and `$User` all root at - * `$User`. + * `$User`, and `tier.label` at `tier`. + */ +function pathRoot(path: string): string | undefined { + return path.replace(/\[(\w+)\]/g, '.$1').split('.').find((segment) => segment !== ''); +} + +/** + * The `$` root of a variable path when the engine does not bind it, else + * `undefined`. */ function unboundEngineRoot(path: string): string | undefined { - const root = path.replace(/\[(\w+)\]/g, '.$1').split('.').find((segment) => segment !== ''); + const root = pathRoot(path); if (root === undefined || !root.startsWith('$') || ENGINE_VARIABLE_SET.has(root)) return undefined; return root; } +/** One `{{ }}` hole of a text whose path compiles, as written and as its trimmed path. */ +interface PathHole { + /** The hole as written (`{{ count | number }}`). */ + readonly text: string; + /** Its variable path, trimmed (`count`). */ + readonly path: string; +} + +/** + * Every hole of `text` whose path compiles ({@link HOLE_PATH}), in order — a + * formatter after `|` is not part of the path. A hole that does not compile is + * the compile step's to name, so it is not returned. + */ +function pathHoles(text: string): PathHole[] { + const out: PathHole[] = []; + for (const match of text.matchAll(HOLE)) { + const inner = match[1]!; + const pipe = inner.indexOf('|'); + const path = (pipe === -1 ? inner : inner.slice(0, pipe)).trim(); + if (HOLE_PATH.test(path)) out.push({ text: match[0], path }); + } + return out; +} + /** One `{{ }}` hole of a text slot whose root is a `$` name the engine does not bind. */ interface UnboundRootHole { /** The hole as written (`{{ $User.Id }}`). */ @@ -290,13 +332,9 @@ interface UnboundRootHole { /** Every hole of `text` that compiles as a path and roots at a `$` name the engine does not bind, in order. */ function unboundRootHoles(text: string): UnboundRootHole[] { const out: UnboundRootHole[] = []; - for (const match of text.matchAll(HOLE)) { - const inner = match[1]!; - const pipe = inner.indexOf('|'); - const path = (pipe === -1 ? inner : inner.slice(0, pipe)).trim(); - if (!HOLE_PATH.test(path)) continue; - const root = unboundEngineRoot(path); - if (root !== undefined) out.push({ text: match[0], path, root }); + for (const hole of pathHoles(text)) { + const root = unboundEngineRoot(hole.path); + if (root !== undefined) out.push({ ...hole, root }); } return out; } @@ -321,18 +359,24 @@ function unboundRootOf(token: TemplateToken): string | undefined { return token.kind === 'path' ? unboundEngineRoot(token.inner) : undefined; } +/** A flow text slot's spellable token: a path not rooted at a `$` name the engine does not bind. */ +function flowSpellable(token: TemplateToken): boolean { + return token.kind === 'path' && unboundRootOf(token) === undefined; +} + /** - * `text` with each single-brace PATH token written as a hole — the spelling a - * refusal prescribes for it. A path over a `$` root the engine does not bind - * stays as written: its hole would be refused too, so its remedy is - * {@link unboundRootRemedy}, never a rewrite. + * `text` with each single-brace token `spellable` admits written as a hole — + * the spelling a refusal prescribes for it. Every other token stays as + * written: its hole would be refused too, so its remedy is its own sentence, + * never a rewrite. In a flow text slot the spellable tokens are the PATH + * tokens not rooted at a `$` name the engine does not bind + * ({@link flowSpellable}); in a closed scope, the path tokens over a bound root. */ -function doubled(text: string, tokens: readonly TemplateToken[]): string { +function doubled(text: string, tokens: readonly TemplateToken[], spellable: (token: TemplateToken) => boolean): string { let out = ''; let at = 0; for (const token of tokens) { - const spellable = token.kind === 'path' && unboundRootOf(token) === undefined; - out += text.slice(at, token.index) + (spellable ? `{{ ${token.inner} }}` : token.text); + out += text.slice(at, token.index) + (spellable(token) ? `{{ ${token.inner} }}` : token.text); at = token.index + token.text.length; } return out + text.slice(at); @@ -377,8 +421,8 @@ function singleBraceRefusal(text: string): string | undefined { const tokens = singleBraceTokens(text); if (tokens.length === 0) return undefined; const parts: string[] = []; - if (tokens.some((token) => token.kind === 'path' && unboundRootOf(token) === undefined)) { - parts.push(`Write \`${text}\` as \`${doubled(text, tokens)}\`.`); + if (tokens.some(flowSpellable)) { + parts.push(`Write \`${text}\` as \`${doubled(text, tokens, flowSpellable)}\`.`); } const seen = new Set(); for (const token of tokens) { @@ -466,13 +510,113 @@ export function flowNodeTextSlotSources(nodeType: string, config: unknown): Flow if (slot.eachValue) { if (value === null || typeof value !== 'object' || Array.isArray(value)) continue; for (const [name, entry] of Object.entries(value as Record)) { - const source = textSlotSource(entry); + const source = templateSlotSource(entry); if (source !== undefined) out.push({ path: `${slot.key}.${name}`, label: slot.label, source }); } continue; } - const source = textSlotSource(value); + const source = templateSlotSource(value); if (source !== undefined) out.push({ path: slot.key, label: slot.label, source }); } return out; } + +// ─── A CLOSED scope (#24798) ──────────────────────────────────────────────── + +/** + * A template text slot whose holes read a CLOSED scope — a fixed set of roots + * the slot's renderer binds, and nothing else. A flow text slot's scope is + * open (any flow variable, any node output, so only a `$` root is judged); + * a closed slot binds a handful of names its contract declares, so any other + * root renders a blank fragment and is judged by name. + * + * Today one slot reads a closed scope: a bulk action's `confirmText` + * (`ui/bulk-action.zod.ts`, ADR-0032 Decisions 2–3), whose roots are + * `bulkConfirmTemplateRoots(def)` — `count` and the def's params by name. + */ +export interface ClosedTemplateScope { + /** The slot as a sentence names it, capitalised: ``A bulk action's `confirmText` ``. */ + readonly slot: string; + /** Every root a hole may name, in the order a refusal lists them. */ + readonly roots: readonly string[]; +} + +/** `roots`, each in backticks, joined for a sentence. */ +function rootList(roots: readonly string[]): string { + return roots.map((root) => `\`${root}\``).join(', '); +} + +/** The remedy for one single-brace token a closed scope cannot spell as a hole. */ +function closedUnspellableRemedy(token: TemplateToken, scope: ClosedTemplateScope): string { + if (token.kind === 'path') { + return ( + `\`${token.text}\` names \`${pathRoot(token.inner) ?? token.inner}\`, which this slot does not bind, so no hole ` + + `spells it: name one of ${rootList(scope.roots)}, or reword the sentence.` + ); + } + if (token.kind === 'expression') { + return ( + `\`${token.text}\` is logic, and a hole is a bound path with an optional whitelisted formatter ` + + '(`{{ count | number }}`), never logic (ADR-0032 §3): reword the sentence around a bound hole.' + ); + } + return ( + `\`${token.text}\` is not one of the roots this slot binds (${rootList(scope.roots)}), so no hole spells it: ` + + 'delete it or reword the sentence (a text slot has no escape for literal braces).' + ); +} + +/** + * Why `text` — the template of a slot that reads `scope` — is refused, or + * `undefined` when it carries neither a single-brace `{…}` token nor a hole + * over a root `scope` does not bind. + * + * The same reading as {@link textSlotTemplateRefusal}, over the same token and + * hole grammar: a single-brace token is found exactly as there (a token + * touching another brace is left to the compile step), and a hole's root is + * its path's first segment, a formatter not included. What differs is the + * scope: a path token over a bound root is prescribed its `{{ }}` spelling, + * any other token its own remedy, and every hole whose root is not in + * `scope.roots` is named. A hole that does not compile as a path (logic, an + * unknown formatter) is left to `validateExpression('template', …)`, which + * every door runs next. + */ +export function closedScopeTemplateRefusal(text: string, scope: ClosedTemplateScope): string | undefined { + const bound = new Set(scope.roots); + const spellable = (token: TemplateToken): boolean => { + if (token.kind !== 'path') return false; + const root = pathRoot(token.inner); + return root !== undefined && bound.has(root); + }; + const refusals: string[] = []; + + const tokens = singleBraceTokens(text); + if (tokens.length > 0) { + const parts = [ + `${scope.slot} reads \`{{ }}\` template holes (ADR-0032 §3), not the single-brace \`{…}\` dialect: a \`{…}\` ` + + 'token there is no placeholder and is shown as literal text.', + ]; + if (tokens.some(spellable)) parts.push(`Write \`${text}\` as \`${doubled(text, tokens, spellable)}\`.`); + const seen = new Set(); + for (const token of tokens) { + if (spellable(token) || seen.has(token.text)) continue; + seen.add(token.text); + parts.push(closedUnspellableRemedy(token, scope)); + } + refusals.push(parts.join(' ')); + } + + const unbound: string[] = []; + for (const hole of pathHoles(text)) { + const root = pathRoot(hole.path); + if (root === undefined || bound.has(root) || unbound.includes(hole.text)) continue; + unbound.push(hole.text); + } + if (unbound.length > 0) { + refusals.push( + `${scope.slot} binds only ${rootList(scope.roots)}, so a hole over any other root renders a blank fragment: ` + + `${unbound.map((hole) => `\`${hole}\``).join(', ')} — name one of those roots, or reword the sentence.`, + ); + } + return refusals.length === 0 ? undefined : refusals.join(' '); +} diff --git a/packages/spec/src/shared/typed-expression-input.ts b/packages/spec/src/shared/typed-expression-input.ts index 2c8a463eb68..8748f69a5a6 100644 --- a/packages/spec/src/shared/typed-expression-input.ts +++ b/packages/spec/src/shared/typed-expression-input.ts @@ -9,8 +9,9 @@ * ## ⛔ Package-internal — NOT a public export * * Deliberately absent from `shared/index.ts` and from the root barrel, like - * `./refinement-projection.ts`: its callers are `./expression.zod.ts` and the - * notify node config (`../automation/io-node-config.zod.ts`), both inside + * `./refinement-projection.ts`: its callers are `./expression.zod.ts`, the + * notify node config (`../automation/io-node-config.zod.ts`) and a bulk + * action's `confirmText` (`../ui/bulk-action.zod.ts`), all inside * `@objectstack/spec`, and a published factory with no consumer outside the * package would widen the public face for nothing. `api-surface/` and * `export-origins/` must not move for it. diff --git a/packages/spec/src/ui/bulk-action.zod.ts b/packages/spec/src/ui/bulk-action.zod.ts index 5074f748755..22dae1c38ec 100644 --- a/packages/spec/src/ui/bulk-action.zod.ts +++ b/packages/spec/src/ui/bulk-action.zod.ts @@ -3,7 +3,8 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; -import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; +import { EvaluatedExpressionInputSchema, ExpressionSchema } from '../shared/expression.zod'; +import { templateExpressionInput } from '../shared/typed-expression-input'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { FieldType } from '../data/field.zod'; @@ -329,6 +330,68 @@ export const BulkActionParamSchema = lazySchema(() => strictObject( })); export type BulkActionParam = z.input; +/** + * [#24798, ADR-0032 Decisions 2–3] The root a bulk `confirmText` hole names + * for the affected-record count — the number of selected records the run will + * cover (the dialog's own `rows.length`, after the def's `visible` narrowed + * the selection). The platform's default confirm sentence already says it; + * this is the spelling an authored sentence uses: `{{ count }}`. + */ +export const BULK_CONFIRM_COUNT_ROOT = 'count'; + +/** + * The roots a bulk action's `confirmText` holes may name — its CLOSED scope: + * {@link BULK_CONFIRM_COUNT_ROOT}, then each of the def's params by `name`, in + * declaration order. A hole over any other root renders a blank fragment, so + * the doors (`objectstack validate`, `objectstack compile`, the publish gate) + * name it through the one template-slot judge + * (`automation/flow-text-slot-template.ts`, `closedScopeTemplateRefusal`). + * + * The roots are BARE — `{{ tier }}`, never `{{ params.tier }}` — because that + * is how the flow text slots' scope spells its roots (a flow variable is + * `{{ v }}`, a node output `{{ lookup.result }}`, with no namespace in front), + * and how the shipped confirm sentences already spell them. `count` comes + * first and wins: a param named `count` is reachable only through the dialog + * field, never through a hole. + */ +export function bulkConfirmTemplateRoots(def: { readonly params?: unknown }): string[] { + const roots = [BULK_CONFIRM_COUNT_ROOT]; + if (!Array.isArray(def.params)) return roots; + for (const param of def.params) { + const name = (param as { name?: unknown } | null)?.name; + if (typeof name === 'string' && name.length > 0 && !roots.includes(name)) roots.push(name); + } + return roots; +} + +/** + * The bulk `confirmText` slot: ADR-0032's `template` dialect, the input + * `TemplateExpressionInputSchema` takes — a bare string is shorthand for + * `{ dialect: 'template', source }`, an envelope must declare + * `dialect: 'template'` — built from the package-internal constructor (as a + * notify node's `title` / `message` are) so its refusal prescribes this slot's + * own hole, `{{ count }}`, rather than `{{record.name}}`, a root it does not + * bind. + * + * ⚠️ One arm wider than a template slot, on purpose: a BLANK string parses as + * itself. `confirmText` was a plain `z.string()`, so `''` passed, and refusing + * it now is a narrowing no conversion carries — which ships as a warning in + * 18.x (the 「甲」 ruling), raised at the doors with the hole findings, and + * waits for protocol 19 to be refused here. + */ +const BULK_CONFIRM_TEXT_DIALECT_ONLY = + 'A bulk action\'s `confirmText` is a template-typed slot: it accepts a bare template string or an envelope ' + + 'declaring `dialect: \'template\'` only. Write `\'Update {{ count }} record(s)?\'` or ' + + '`{ dialect: \'template\', source: \'Update {{ count }} record(s)?\' }`.'; + +const BulkConfirmTextSchema = z.union([ + templateExpressionInput(ExpressionSchema, { + sourceRequired: BULK_CONFIRM_TEXT_DIALECT_ONLY, + dialectOnly: BULK_CONFIRM_TEXT_DIALECT_ONLY, + }), + z.string().refine((source) => source.trim() === ''), +], { error: () => BULK_CONFIRM_TEXT_DIALECT_ONLY }); + /** * Rich, schema-driven definition of one button in the multi-select bar. * @@ -400,7 +463,7 @@ export const BulkActionDefSchema = lazySchema(() => strictObject( execution: BulkActionExecutionSchema.optional().describe("For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead."), patch: z.record(z.string(), z.unknown()).optional().describe("For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog."), params: z.array(BulkActionParamSchema).optional().describe('Inputs collected once before the run. Omit to skip the params step and go straight to confirm.'), - confirmText: z.string().optional().describe('Confirmation text shown above the affected-record summary.'), + confirmText: BulkConfirmTextSchema.optional().describe("Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount | currency:EUR }}`, `{{ due | date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on {{ count }} account(s) to {{ tier }}?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate — a warning in 18.x, refused from protocol 19."), confirmLabel: z.string().optional().describe('Custom Confirm button label (default: "Run").'), visible: EvaluatedExpressionInputSchema.optional().describe('Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record.'), requiredPermissions: z.array(z.string()).optional().describe("[ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks."), From 377bc3fb274304433aa4ec8d6a49807c5537f228 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:52:18 +0000 Subject: [PATCH 02/14] test(spec): pin the bulk confirmText template slot, its roots and the closed-scope judge Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .../flow-text-slot-template.test.ts | 60 ++++++++++ packages/spec/src/ui/bulk-action.zod.ts | 4 +- .../spec/src/ui/bulk-confirm-template.test.ts | 111 ++++++++++++++++++ 3 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 packages/spec/src/ui/bulk-confirm-template.test.ts 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 671c1c78ed9..e8ad28a0969 100644 --- a/packages/spec/src/automation/flow-text-slot-template.test.ts +++ b/packages/spec/src/automation/flow-text-slot-template.test.ts @@ -15,7 +15,9 @@ import { describe, expect, it } from 'vitest'; import { FLOW_NODE_TEXT_SLOTS, TEXT_SLOT_TEMPLATE_REFUSAL, + closedScopeTemplateRefusal, flowNodeTextSlotSources, + templateSlotSource, textSlotTemplateRefusal, } from './flow-text-slot-template'; import { valueSlotTemplateRefusals } from './flow-value-slot-template'; @@ -244,6 +246,64 @@ describe('textSlotTemplateRefusal — a `{{ }}` hole over a `$` root the engine }); }); +describe('closedScopeTemplateRefusal — the same judge over a CLOSED scope (#24798)', () => { + const scope = { slot: "A bulk action's `confirmText`", roots: ['count', 'tier'] }; + + it('passes plain text, holes over bound roots, a formatter, an index and a sub-path', () => { + for (const text of ['Delete every selected record?', 'Update {{count}} to {{tier}}?', '{{ count | number }} rows', '{{ tier[0] }}', '{{ tier.label | upper }}']) { + expect(closedScopeTemplateRefusal(text, scope), text).toBeUndefined(); + } + }); + + it('names every hole over an unbound root once, a `$` root included, and the roots it binds', () => { + const message = closedScopeTemplateRefusal('{{ x }} {{ x }} {{ $User.Id }} {{ record.name }}', scope)!; + expect(message).toMatch(/^A bulk action's `confirmText` binds only `count`, `tier`/); + expect(message).toContain('`{{ x }}`, `{{ $User.Id }}`, `{{ record.name }}`'); + expect(message.match(/\{\{ x \}\}/g)).toHaveLength(1); + }); + + it('reads the single brace exactly as the flow judge does: a bound path is rewritten as its hole', () => { + const message = closedScopeTemplateRefusal('Update {count} to {tier}', scope)!; + expect(message.startsWith("A bulk action's `confirmText` reads `{{ }}` template holes (ADR-0032 §3)")).toBe(true); + expect(message).toContain('Write `Update {count} to {tier}` as `Update {{ count }} to {{ tier }}`.'); + // The same tokens the flow judge finds — a token touching a brace is the compile step's. + expect(closedScopeTemplateRefusal('Total {{ count }', scope)).toBeUndefined(); + expect(textSlotTemplateRefusal('Total {{ count }')).toBeUndefined(); + }); + + it('prescribes no rewrite for a token no bound hole spells — an unbound path, logic, a macro', () => { + const message = closedScopeTemplateRefusal('{foo} {count * 2} {NOW()}', scope)!; + expect(message).not.toContain('Write `'); + expect(message).toContain('`{foo}` names `foo`, which this slot does not bind'); + expect(message).toContain('`{count * 2}` is logic'); + expect(message).toContain('`{NOW()}` is not one of the roots this slot binds'); + }); + + it('judges both in one text, single brace first', () => { + const message = closedScopeTemplateRefusal('{count} and {{ y }}', scope)!; + expect(message.indexOf('reads `{{ }}`')).toBeLessThan(message.indexOf('binds only')); + }); + + it('leaves a hole that does not compile as a path to the compile step', () => { + expect(closedScopeTemplateRefusal('{{ count * 2 }}', scope)).toBeUndefined(); + }); + + it('control: the flow judge is unchanged — an open scope names no ordinary root', () => { + expect(textSlotTemplateRefusal('{{ x }} {{ record.name }}')).toBeUndefined(); + expect(textSlotTemplateRefusal('{{ $User.Id }}')).toBeDefined(); + }); +}); + +describe('templateSlotSource — one reading of a template slot value', () => { + it('reads a string, the source of a template envelope, and nothing else', () => { + expect(templateSlotSource('a {{ b }}')).toBe('a {{ b }}'); + expect(templateSlotSource({ dialect: 'template', source: 'a' })).toBe('a'); + expect(templateSlotSource({ dialect: 'cel', source: 'a' })).toBeUndefined(); + expect(templateSlotSource(['a'])).toBeUndefined(); + expect(templateSlotSource(null)).toBeUndefined(); + }); +}); + describe('flowNodeTextSlotSources — the text slots one node config carries', () => { it('reads the notify pair as a bare string or a template envelope, its link as a string, and nothing else on the node', () => { expect(flowNodeTextSlotSources('notify', { diff --git a/packages/spec/src/ui/bulk-action.zod.ts b/packages/spec/src/ui/bulk-action.zod.ts index 22dae1c38ec..a90c7f83eb7 100644 --- a/packages/spec/src/ui/bulk-action.zod.ts +++ b/packages/spec/src/ui/bulk-action.zod.ts @@ -389,7 +389,9 @@ const BulkConfirmTextSchema = z.union([ sourceRequired: BULK_CONFIRM_TEXT_DIALECT_ONLY, dialectOnly: BULK_CONFIRM_TEXT_DIALECT_ONLY, }), - z.string().refine((source) => source.trim() === ''), + // `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. + z.string().regex(/^\s*$/), ], { error: () => BULK_CONFIRM_TEXT_DIALECT_ONLY }); /** diff --git a/packages/spec/src/ui/bulk-confirm-template.test.ts b/packages/spec/src/ui/bulk-confirm-template.test.ts new file mode 100644 index 00000000000..dddd653cfd5 --- /dev/null +++ b/packages/spec/src/ui/bulk-confirm-template.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #24798 (ruling B) — a bulk action's `confirmText` is an ADR-0032 `template` + * slot over a CLOSED scope: `count` and the def's params by name. These pins + * hold the slot's accept set (a bare string is shorthand for the envelope, a + * holeless sentence still parses, a blank one still parses in 18.x) and the + * roots the spec declares. The holes themselves are judged at the doors + * through `closedScopeTemplateRefusal` (`automation/flow-text-slot-template.ts`, + * pinned beside the flow judge) — `@objectstack/lint`'s `validate-expressions` + * pins the door. + */ +import { describe, expect, it } from 'vitest'; + +import { BULK_CONFIRM_COUNT_ROOT, BulkActionDefSchema, bulkConfirmTemplateRoots } from './bulk-action.zod'; +import { closedScopeTemplateRefusal } from '../automation/flow-text-slot-template'; + +const def = (confirmText: unknown) => ({ + name: 'update_tier', + label: 'Update Tier', + operation: 'update', + confirmText, + params: [{ name: 'tier', type: 'select', options: [{ label: 'Gold', value: 'gold' }] }], +}); + +const parsed = (confirmText: unknown) => { + const r = BulkActionDefSchema.safeParse(def(confirmText)); + if (!r.success) throw new Error(r.error.issues.map((i) => i.message).join('\n')); + return r.data.confirmText; +}; + +const refusal = (confirmText: unknown) => { + const r = BulkActionDefSchema.safeParse(def(confirmText)); + return r.success ? undefined : r.error.issues; +}; + +describe('BulkActionDef.confirmText — a template slot (ADR-0032 Decisions 2–3)', () => { + it('reads a bare string as shorthand for `{ dialect: \'template\', source }` — the HotCRM sentence included', () => { + const source = 'Update tier on {{count}} account(s) to {{tier}}?'; + expect(parsed(source)).toEqual({ dialect: 'template', source }); + }); + + it('accepts the envelope itself', () => { + expect(parsed({ dialect: 'template', source: '{{ count | number }} selected' })) + .toEqual({ dialect: 'template', source: '{{ count | number }} selected' }); + }); + + it('still parses a stored sentence with no hole — as the template it now is, text unchanged', () => { + expect(parsed('Set these labels on every selected project?')) + .toEqual({ dialect: 'template', source: 'Set these labels on every selected project?' }); + }); + + it('still parses a blank string, verbatim: refusing it is a narrowing, a warning in 18.x', () => { + // `confirmText` was `z.string()`, so `''` passed. The shared template + // input refuses a blank source; this slot keeps the 18.x accept instead. + expect(parsed('')).toBe(''); + expect(parsed(' \t')).toBe(' \t'); + }); + + it('refuses a foreign-dialect envelope and a non-string with the slot\'s own sentence, prescribing `{{ count }}`', () => { + for (const value of [{ dialect: 'cel', source: 'record.amount > 1' }, { dialect: 'cron', source: '0 9 * * *' }, 42]) { + const issues = refusal(value); + expect(issues, JSON.stringify(value)).toHaveLength(1); + expect(issues![0]!.path).toEqual(['confirmText']); + expect(issues![0]!.code).toBe('invalid_union'); + expect(issues![0]!.message).toMatch(/^A bulk action's `confirmText` is a template-typed slot/); + expect(issues![0]!.message).toContain('{{ count }}'); + expect(issues![0]!.message).not.toContain('record.name'); + } + }); + + it('judges no hole at parse: an unbound hole and a single-brace token both parse (the doors name them)', () => { + expect(parsed('Update {{ x }}')).toEqual({ dialect: 'template', source: 'Update {{ x }}' }); + expect(parsed('Update {count}')).toEqual({ dialect: 'template', source: 'Update {count}' }); + }); + + it('keeps the `confirm` / `confirmmessage` near-misses pointing at `confirmText`', () => { + for (const key of ['confirm', 'confirmmessage']) { + const { confirmText: _drop, ...rest } = def('x'); + const r = BulkActionDefSchema.safeParse({ ...rest, [key]: 'Sure {{ count }}?' }); + expect(r.success, key).toBe(false); + const issue = r.success ? undefined : r.error.issues[0]; + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.message).toContain(`\`${key}\` → \`confirmText\``); + } + }); +}); + +describe('bulkConfirmTemplateRoots — the closed scope the spec declares', () => { + it('is `count`, then each param by name, in declaration order, bare', () => { + expect(BULK_CONFIRM_COUNT_ROOT).toBe('count'); + expect(bulkConfirmTemplateRoots({ params: [{ name: 'tier' }, { name: 'owner_id' }] })).toEqual(['count', 'tier', 'owner_id']); + }); + + it('is `count` alone on a def with no params', () => { + expect(bulkConfirmTemplateRoots({})).toEqual(['count']); + expect(bulkConfirmTemplateRoots({ params: 'nonsense' })).toEqual(['count']); + }); + + it('lets `count` win over a param of that name, and skips a nameless param', () => { + expect(bulkConfirmTemplateRoots({ params: [{ name: 'count' }, {}, null, { name: 'tier' }] })).toEqual(['count', 'tier']); + }); + + it('binds every hole the HotCRM sentences write, and nothing else', () => { + const roots = bulkConfirmTemplateRoots(def('x')); + const scope = { slot: "A bulk action's `confirmText`", roots }; + expect(closedScopeTemplateRefusal('Update tier on {{count}} account(s) to {{tier}}?', scope)).toBeUndefined(); + expect(closedScopeTemplateRefusal('将 {{count}} 个客户的等级更新为 {{tier}}?', scope)).toBeUndefined(); + expect(closedScopeTemplateRefusal('Transfer {{count}} account(s) to {{ owner }}?', scope)).toContain('`{{ owner }}`'); + }); +}); From ff111daa071e878505ba9d4f1ab5402488e8d02f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:53:34 +0000 Subject: [PATCH 03/14] feat(lint): validate, compile and the publish gate judge a bulk confirmText's holes, warnings in 18.x Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- packages/lint/src/authoring-rules.ts | 15 +++- packages/lint/src/validate-expressions.ts | 105 ++++++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 7e28c489059..37ae7e87aac 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -714,8 +714,21 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // entry-level change: it rides the object crossing above. MEASURED first: // 0 in-repo producers reference either family table, against 45 files // that reference an ordinary platform table. + // + // [#24798, ruling B] `view` joins, for ONE pass: a bulk action def's + // `confirmText`, now an ADR-0032 `template` slot over `count` and the + // def's params. On a view write `runStackExpressionPasses` runs that pass + // alone (by construction, as for an object write), and on an object write + // it also judges the object's own `listViews`. Every finding is a + // `warning` in 18.x — an advisory here, never a refusal — because every + // such sentence parsed before the slot was typed (the 「甲」 ruling). + // MEASURED first over the shipped corpus at `b42c621c05`: 6 bulk defs + // carry a `confirmText` (examples/app-showcase `project.view.ts`), none + // with a brace → 0 findings, against a warning for each of an unbound + // hole, a single-brace token, a hole holding logic and a blank sentence + // in `validate-expressions.test.ts`. surfaces: CLI_AND_RUNTIME, - runtimeTypes: ['flow', 'action', 'hook', 'object'], + runtimeTypes: ['flow', 'action', 'hook', 'object', 'view'], run: (stack, ctx) => runStackExpressionPasses(stack, { runtimeWriteType: ctx.runtimeWriteType }).map((i) => ({ severity: i.severity ?? 'error', diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 2f0c605228c..f6c4ca764e3 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -103,7 +103,12 @@ import { flowNodeTextSlotSources, textSlotTemplateRefusal, structuralConditionRefusal, + closedScopeTemplateRefusal, + templateSlotSource, } from '@objectstack/spec/automation'; +// [#24798] A bulk action's `confirmText` is a `template` slot over the closed +// scope the spec declares for it — judged below by the same template judge. +import { bulkConfirmTemplateRoots } from '@objectstack/spec/ui'; // [#15137] The `value`-role half. Same two published primitives the engine // composes at `registerFlow` (`AutomationEngine.valueEnvelopeRefusals`), in the // same order: the SHAPE rule lives in the spec's `FlowValueSlotSchema` (the @@ -1924,6 +1929,12 @@ export interface StackExpressionOptions { * while an object write carries no top-level actions, so the door locates * the same predicate at `object '…' · action …`. * + * [#24798] A sixth judges an object write too: the bulk `confirmText` + * pass over the object's own `listViews` (`bulkConfirmTextIssues`), warnings + * only in 18.x. On a `view` write that pass is the ONLY one, over the + * written view's `list` and `listViews` — every other pass reads a + * collection a view write does not carry. + * * The passes over the stack's OTHER collections — flows, the top-level * `actions`, sharing rules and hooks — read an empty list on an object * write. None of them is the object's own body, the object write's snapshot @@ -1960,6 +1971,11 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // an empty list under it, so the claim holds by construction rather than by // the shape of the snapshot the gate happens to build today. const objectWrite = options.runtimeWriteType === 'object'; + // [#24798] On a `view` write exactly ONE pass judges — the bulk `confirmText` + // pass over the written view's own list containers ({@link bulkConfirmTextIssues}); + // every other pass reads a collection a view write does not carry, so it + // returns before them by construction rather than by snapshot shape. + if (options.runtimeWriteType === 'view') return bulkConfirmTextIssues(stack, { views: true, objects: false }); const objects = recordsOf(stack.objects); const fieldIndex = buildFieldIndex(objects); const attachedOnReadIndex = buildAttachedOnReadIndex(objects); @@ -3362,5 +3378,94 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression issues.push(...kept); } + // [#24798] The bulk `confirmText` template slots: an object's own + // `listViews` on every run (an object write included — they are the + // object's own body), a view's list containers off an object write. + issues.push(...bulkConfirmTextIssues(stack, { views: !objectWrite, objects: true })); + + return issues; +} + +/** + * Why a finding on a bulk `confirmText` is a warning, said once at the end of + * each: `confirmText` was a plain string before it became a `template` slot, + * so every one of these sentences parsed — refusing it now is a narrowing no + * conversion carries, which ships as a warning in 18.x. + */ +const BULK_CONFIRM_TEXT_WARNING_ONLY = + 'A warning in 18.x: this text parsed before `confirmText` became a template slot, so it is refused only from ' + + 'protocol 19.'; + +/** The slot as {@link closedScopeTemplateRefusal}'s sentences name it. */ +const BULK_CONFIRM_TEXT_SLOT = "A bulk action's `confirmText`"; + +/** + * [#24798, ruling B] Judge every bulk action def's `confirmText` — the + * ADR-0032 `template` slot `ui/bulk-action.zod.ts` declares over a CLOSED + * scope, `bulkConfirmTemplateRoots(def)` (`count` and the def's params by + * name). The same two steps the flow text slots take above, in the same + * order, through the same judge: a single-brace token or a hole over an + * unbound root is named by `closedScopeTemplateRefusal` (the template judge's + * closed-scope entry), and a text carrying neither is compiled as the + * `template` it is (`validateExpression('template', …)`), which names a hole + * holding logic or an unknown formatter. A blank sentence is named too. + * + * ⚠️ Every finding is a `warning` in 18.x, never an `error`: each of these + * texts parsed before the slot was typed, so a refusal would be a narrowing no + * ADR-0087 conversion carries (the 「甲」 ruling) — its refusal waits for + * protocol 19. The containers walked are the ones the dispatch-contract rule + * walks: a view's `list` and each `listViews.`, and an object's + * `listViews.`. + */ +function bulkConfirmTextIssues(stack: AnyRec, scope: { views: boolean; objects: boolean }): ExprIssue[] { + const issues: ExprIssue[] = []; + const judgeContainer = (container: unknown, at: string): void => { + if (!container || typeof container !== 'object') return; + const defs = (container as AnyRec).bulkActionDefs; + if (!Array.isArray(defs)) return; + for (let di = 0; di < defs.length; di++) { + const def = defs[di] as AnyRec | undefined; + if (!def || typeof def !== 'object' || def.confirmText === undefined) continue; + const source = templateSlotSource(def.confirmText); + if (source === undefined) continue; // not a string or a template envelope: the parse refuses its shape + const name = typeof def.name === 'string' ? ` '${def.name}'` : ''; + const where = `${at} · bulkActionDefs[${di}]${name} confirmText`; + const warn = (message: string, quoted: string): void => { + issues.push({ where, message: `${message} ${BULK_CONFIRM_TEXT_WARNING_ONLY}`, source: quoted, severity: 'warning' }); + }; + if (source.trim() === '') { + warn( + `${BULK_CONFIRM_TEXT_SLOT} is blank, so the confirm step shows an empty sentence where the default one names ` + + 'the record count: write the sentence, or remove the key to show the default.', + source, + ); + continue; + } + const refusal = closedScopeTemplateRefusal(source, { slot: BULK_CONFIRM_TEXT_SLOT, roots: bulkConfirmTemplateRoots(def) }); + if (refusal !== undefined) { + warn(refusal, source); + continue; + } + for (const e of validateExpression('template', source).errors) warn(e.message, e.source); + } + }; + const judgeListViews = (listViews: unknown, owner: string): void => { + if (!listViews || typeof listViews !== 'object' || Array.isArray(listViews)) return; + for (const [key, lv] of Object.entries(listViews as AnyRec)) judgeContainer(lv, `${owner} · listViews.${key}`); + }; + if (scope.views) { + for (const [vi, view] of recordsOf(stack.views).entries()) { + if (!view || typeof view !== 'object') continue; + const viewName = typeof view.name === 'string' ? view.name : typeof view.object === 'string' ? view.object : `#${vi}`; + judgeContainer(view.list, `view '${viewName}' · list`); + judgeListViews(view.listViews, `view '${viewName}'`); + } + } + if (scope.objects) { + for (const [oi, obj] of recordsOf(stack.objects).entries()) { + if (!obj || typeof obj !== 'object') continue; + judgeListViews(obj.listViews, `object '${typeof obj.name === 'string' ? obj.name : `#${oi}`}'`); + } + } return issues; } From 3d09523dfdb39488db0e07980ced09ca950eb107 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:00:45 +0000 Subject: [PATCH 04/14] test(lint): pin the bulk confirmText warnings at the CLI pass and the view-write gate Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- ...date-expressions.bulk-confirm-text.test.ts | 131 ++++++++++++++++++ packages/lint/src/validate-expressions.ts | 17 ++- 2 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 packages/lint/src/validate-expressions.bulk-confirm-text.test.ts diff --git a/packages/lint/src/validate-expressions.bulk-confirm-text.test.ts b/packages/lint/src/validate-expressions.bulk-confirm-text.test.ts new file mode 100644 index 00000000000..0d9beecf73e --- /dev/null +++ b/packages/lint/src/validate-expressions.bulk-confirm-text.test.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #24798 (ruling B) — a bulk action's `confirmText` is an ADR-0032 `template` +// slot over the closed scope `bulkConfirmTemplateRoots(def)` declares (`count` +// and the def's params by name). `validateStackExpressions` — the rule +// `objectstack validate`, `objectstack compile` and the runtime publish gate +// all run — judges it through the template judge's closed-scope entry, then +// compiles it as a template. Every finding is a WARNING in 18.x: each of these +// sentences parsed before the slot was typed, so a refusal would be a +// narrowing no conversion carries; it waits for protocol 19. +// +// The gate half drives the REAL dispatch (`runRuntimeAuthoringRules`) with the +// three shapes a view write carries — the container's `list`, the flattened +// list overlay and the ViewItem record — because a `runtimeTypes` widening +// whose walk reads none of them is a no-op that reads as coverage. + +import { describe, expect, it } from 'vitest'; + +import { runStackExpressionPasses, validateStackExpressions } from './validate-expressions.js'; +import { runRuntimeAuthoringRules } from './runtime-gate.js'; + +const objects = [{ name: 'crm_account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } }]; + +const updateTier = (confirmText: unknown) => ({ + name: 'update_tier', + label: 'Update Tier', + operation: 'update', + confirmText, + params: [{ name: 'tier', type: 'select', options: [{ label: 'Gold', value: 'gold' }] }], +}); + +const stackWith = (confirmText: unknown) => ({ + objects, + views: [{ name: 'crm_account', object: 'crm_account', list: { type: 'grid', columns: ['name'], bulkActionDefs: [updateTier(confirmText)] } }], +}); + +const confirmIssues = (stack: Record) => + validateStackExpressions(stack).filter((i) => i.where.includes('confirmText')); + +describe('validateStackExpressions — a bulk confirmText is judged as the template slot it is (#24798)', () => { + it('passes the HotCRM sentences, a formatter, plain text — bare or as the parsed envelope', () => { + for (const text of [ + 'Update tier on {{count}} account(s) to {{tier}}?', + '将 {{count}} 个客户的等级更新为 {{tier}}?', + 'Delete {{ count | number }} account(s)?', + 'Set these labels on every selected project?', + ]) { + expect(confirmIssues(stackWith(text)), text).toEqual([]); + expect(confirmIssues(stackWith({ dialect: 'template', source: text })), text).toEqual([]); + } + }); + + it('warns on a hole over an unbound root, naming it and the roots the def binds', () => { + const [issue, ...rest] = confirmIssues(stackWith('Move {{count}} to {{ owner }}?')); + expect(rest).toEqual([]); + expect(issue!.severity).toBe('warning'); + expect(issue!.where).toBe("view 'crm_account' · list · bulkActionDefs[0] 'update_tier' confirmText"); + expect(issue!.message).toContain('binds only `count`, `tier`'); + expect(issue!.message).toContain('`{{ owner }}`'); + expect(issue!.message).toContain('protocol 19'); + expect(issue!.source).toBe('Move {{count}} to {{ owner }}?'); + }); + + it('warns on a single-brace token, prescribing its `{{ }}` spelling', () => { + const [issue] = confirmIssues(stackWith('Update {count} account(s) to {tier}?')); + expect(issue!.severity).toBe('warning'); + expect(issue!.message).toContain('Write `Update {count} account(s) to {tier}?` as `Update {{ count }} account(s) to {{ tier }}?`.'); + }); + + it('warns on a hole holding logic or an unknown formatter — the template compile step', () => { + for (const text of ['{{ count * 2 }} rows', '{{ count | shout }} rows']) { + const issues = confirmIssues(stackWith(text)); + expect(issues.length, text).toBeGreaterThan(0); + expect(issues.every((i) => i.severity === 'warning'), text).toBe(true); + } + }); + + it('warns on a blank sentence, which the parse still accepts in 18.x', () => { + const [issue] = confirmIssues(stackWith(' ')); + expect(issue!.severity).toBe('warning'); + expect(issue!.message).toMatch(/^A bulk action's `confirmText` is blank/); + }); + + it('raises no error at all: every bulk confirmText finding is a warning', () => { + const stack = stackWith('{x} {{ y }} {{ count * 2 }}'); + expect(validateStackExpressions(stack).filter((i) => i.severity === 'error')).toEqual([]); + }); + + it('judges an object\'s own listViews, and a def with no confirmText not at all', () => { + const stack = { + objects: [{ ...objects[0], listViews: { all: { type: 'grid', columns: ['name'], bulkActionDefs: [updateTier('{{ x }}'), { name: 'del', operation: 'delete' }] } } }], + }; + const issues = confirmIssues(stack); + expect(issues.map((i) => i.where)).toEqual(["object 'crm_account' · listViews.all · bulkActionDefs[0] 'update_tier' confirmText"]); + // The object write runs the pass over the object's own body too. + expect(runStackExpressionPasses(stack, { runtimeWriteType: 'object' }).filter((i) => i.where.includes('confirmText'))).toHaveLength(1); + }); + + it('on a view write runs that pass alone', () => { + const stack = { ...stackWith('{{ x }}'), flows: [{ name: 'f', nodes: [{ id: 'n', type: 'notify', config: { title: '{bad}' } }] }] }; + const issues = runStackExpressionPasses(stack, { runtimeWriteType: 'view' }); + expect(issues).toHaveLength(1); + expect(issues[0]!.where).toContain('confirmText'); + }); +}); + +describe('the runtime publish gate on a view write — an advisory, never a refusal, in 18.x (#24798)', () => { + const gate = (item: unknown) => runRuntimeAuthoringRules({ type: 'view', item, context: { objects } }); + const advisoriesOn = (item: unknown) => { + const { errors, advisories } = gate(item); + return { errors: errors.filter((f) => f.where.includes('confirmText')), advisories: advisories.filter((f) => f.where.includes('confirmText')) }; + }; + const shapes: Array<[string, (confirmText: unknown) => unknown]> = [ + ['a container\'s list', (c) => stackWith(c).views[0]], + ['the flattened list overlay', (c) => ({ name: 'crm_account.tier', object: 'crm_account', viewKind: 'list', type: 'grid', columns: ['name'], bulkActionDefs: [updateTier(c)] })], + ['the ViewItem record', (c) => ({ name: 'crm_account.tier', object: 'crm_account', viewKind: 'list', config: { type: 'grid', columns: ['name'], bulkActionDefs: [updateTier(c)] } })], + ]; + + for (const [label, item] of shapes) { + it(`names an unbound hole on ${label} as an advisory`, () => { + const { errors, advisories } = advisoriesOn(item('Move {{count}} to {{ owner }}?')); + expect(errors).toEqual([]); + expect(advisories, label).toHaveLength(1); + expect(advisories[0]!.message).toContain('`{{ owner }}`'); + }); + + it(`control: the HotCRM sentence on ${label} draws nothing`, () => { + expect(advisoriesOn(item('Update tier on {{count}} account(s) to {{tier}}?'))).toEqual({ errors: [], advisories: [] }); + }); + } +}); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index f6c4ca764e3..ec6ad9ece24 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -3413,9 +3413,9 @@ const BULK_CONFIRM_TEXT_SLOT = "A bulk action's `confirmText`"; * ⚠️ Every finding is a `warning` in 18.x, never an `error`: each of these * texts parsed before the slot was typed, so a refusal would be a narrowing no * ADR-0087 conversion carries (the 「甲」 ruling) — its refusal waits for - * protocol 19. The containers walked are the ones the dispatch-contract rule - * walks: a view's `list` and each `listViews.`, and an object's - * `listViews.`. + * protocol 19. The containers walked: a view's `list` and each + * `listViews.`, a standalone list view (the flattened overlay or the + * ViewItem record), and an object's `listViews.`. */ function bulkConfirmTextIssues(stack: AnyRec, scope: { views: boolean; objects: boolean }): ExprIssue[] { const issues: ExprIssue[] = []; @@ -3457,6 +3457,17 @@ function bulkConfirmTextIssues(stack: AnyRec, scope: { views: boolean; objects: for (const [vi, view] of recordsOf(stack.views).entries()) { if (!view || typeof view !== 'object') continue; const viewName = typeof view.name === 'string' ? view.name : typeof view.object === 'string' ? view.object : `#${vi}`; + // The two standalone shapes `PUT /api/v1/meta/view` carries and the + // publish gate snapshots as `views: [item]` — recognised as the list-view + // field rules recognise them (`validate-list-view-field-refs.ts`): the + // flattened list overlay (the `ListView` at the top level) and the + // ViewItem record (one level down, in `config`). Without them a view + // write would run this pass over nothing. + const config = view.config; + if (view.viewKind === 'list') { + const record = config !== null && typeof config === 'object' && !Array.isArray(config); + judgeContainer(record ? config : view, `view '${viewName}' (${record ? 'ViewItem record' : 'flattened list overlay'})`); + } judgeContainer(view.list, `view '${viewName}' · list`); judgeListViews(view.listViews, `view '${viewName}'`); } From 572bc660f1693ec3a3d0f7c2c3c9a41b94bb46aa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:09:06 +0000 Subject: [PATCH 05/14] chore(spec): regenerate api-surface, export-origins and reference docs for the bulk confirmText slot Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- content/docs/references/ui/bulk-action.mdx | 2 +- content/docs/references/ui/component.mdx | 2 +- content/docs/references/ui/view.mdx | 4 ++-- packages/spec/api-surface/automation.json | 3 +++ packages/spec/api-surface/ui.json | 2 ++ packages/spec/export-origins/automation.json | 3 +++ packages/spec/export-origins/ui.json | 2 ++ 7 files changed, 14 insertions(+), 4 deletions(-) diff --git a/content/docs/references/ui/bulk-action.mdx b/content/docs/references/ui/bulk-action.mdx index f7737caf091..9203abf4575 100644 --- a/content/docs/references/ui/bulk-action.mdx +++ b/content/docs/references/ui/bulk-action.mdx @@ -48,7 +48,7 @@ const result = BulkActionDefSchema.parse(data); | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | | **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | -| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate — a warning in 18.x, refused from protocol 19. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 6fc28aef0f3..6f9846c43e5 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -928,7 +928,7 @@ Sort field and direction pair | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | | **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | -| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate — a warning in 18.x, refused from protocol 19. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index c7d19516020..25bc4999505 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -1039,7 +1039,7 @@ View filter rule | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | | **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | -| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate — a warning in 18.x, refused from protocol 19. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. | @@ -1416,7 +1416,7 @@ View filter rule | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | | **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | -| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate — a warning in 18.x, refused from protocol 19. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. | diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 1103c92fa72..d43954f8997 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -70,6 +70,7 @@ "Checkpoint (type)", "CheckpointParsed (type)", "CheckpointSchema (const)", + "ClosedTemplateScope (interface)", "ConcurrencyPolicy (type)", "ConcurrencyPolicyParsed (type)", "ConcurrencyPolicySchema (const)", @@ -283,6 +284,7 @@ "builtinNodeConfigKeysJudged (function)", "canonicalApproverType (function)", "classifyFlowFilterOperandString (function)", + "closedScopeTemplateRefusal (function)", "collectFlowGraphs (function)", "defineActionDescriptor (function)", "defineFlow (function)", @@ -312,6 +314,7 @@ "resolveFlowTriggerKind (function)", "resolveScheduleOrganization (function)", "structuralConditionRefusal (function)", + "templateSlotSource (function)", "textSlotTemplateRefusal (function)", "validateControlFlow (function)", "valueSlotTemplateRefusals (function)" diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index c8211de9452..c302c6d1d30 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -62,6 +62,7 @@ "AssembledViewArtifactParsed (type)", "AssembledViewArtifactSchema (const)", "AssembledViewPartition (interface)", + "BULK_CONFIRM_COUNT_ROOT (const)", "BulkActionDef (type)", "BulkActionDefParsed (type)", "BulkActionDefSchema (const)", @@ -494,6 +495,7 @@ "anonymousFormIntakeSlug (function)", "anonymousFormIntakeSlugs (function)", "appForm (const)", + "bulkConfirmTemplateRoots (function)", "chartAggregateCategoryKey (function)", "chartAggregateResultKeys (function)", "chartAggregateValueKey (function)", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 4abe3af59b2..b6938167814 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -68,6 +68,7 @@ "Checkpoint": "src/automation/execution.zod.ts#Checkpoint (type)", "CheckpointParsed": "src/automation/execution.zod.ts#CheckpointParsed (type)", "CheckpointSchema": "src/automation/execution.zod.ts#CheckpointSchema (const)", + "ClosedTemplateScope": "src/automation/flow-text-slot-template.ts#ClosedTemplateScope (interface)", "ConcurrencyPolicy": "src/automation/execution.zod.ts#ConcurrencyPolicy (type)", "ConcurrencyPolicyParsed": "src/automation/execution.zod.ts#ConcurrencyPolicyParsed (type)", "ConcurrencyPolicySchema": "src/automation/execution.zod.ts#ConcurrencyPolicySchema (const)", @@ -277,6 +278,7 @@ "builtinNodeConfigKeysJudged": "src/automation/flow-node-config-refusals.ts#builtinNodeConfigKeysJudged (function)", "canonicalApproverType": "src/automation/approval.zod.ts#canonicalApproverType (function)", "classifyFlowFilterOperandString": "src/automation/flow-value-slot-template.ts#classifyFlowFilterOperandString (function)", + "closedScopeTemplateRefusal": "src/automation/flow-text-slot-template.ts#closedScopeTemplateRefusal (function)", "collectFlowGraphs": "src/automation/control-flow.zod.ts#collectFlowGraphs (function)", "defineActionDescriptor": "src/automation/node-executor.zod.ts#defineActionDescriptor (function)", "defineFlow": "src/automation/flow.zod.ts#defineFlow (function)", @@ -306,6 +308,7 @@ "resolveFlowTriggerKind": "src/automation/flow-trigger-kind.ts#resolveFlowTriggerKind (function)", "resolveScheduleOrganization": "src/automation/schedule-organization.zod.ts#resolveScheduleOrganization (function)", "structuralConditionRefusal": "src/automation/flow-node-expression-paths.ts#structuralConditionRefusal (function)", + "templateSlotSource": "src/automation/flow-text-slot-template.ts#templateSlotSource (function)", "textSlotTemplateRefusal": "src/automation/flow-text-slot-template.ts#textSlotTemplateRefusal (function)", "validateControlFlow": "src/automation/control-flow.zod.ts#validateControlFlow (function)", "valueSlotTemplateRefusals": "src/automation/flow-value-slot-template.ts#valueSlotTemplateRefusals (function)" diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index d77feb6e84c..ad631f70cf8 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -59,6 +59,7 @@ "AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)", "AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)", "AssembledViewPartition": "src/ui/assembled-views.zod.ts#AssembledViewPartition (interface)", + "BULK_CONFIRM_COUNT_ROOT": "src/ui/bulk-action.zod.ts#BULK_CONFIRM_COUNT_ROOT (const)", "BulkActionDef": "src/ui/bulk-action.zod.ts#BulkActionDef (type)", "BulkActionDefParsed": "src/ui/bulk-action.zod.ts#BulkActionDefParsed (type)", "BulkActionDefSchema": "src/ui/bulk-action.zod.ts#BulkActionDefSchema (const)", @@ -479,6 +480,7 @@ "anonymousFormIntakeSlug": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeSlug (function)", "anonymousFormIntakeSlugs": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeSlugs (function)", "appForm": "src/ui/app.form.ts#appForm (const)", + "bulkConfirmTemplateRoots": "src/ui/bulk-action.zod.ts#bulkConfirmTemplateRoots (function)", "chartAggregateCategoryKey": "src/ui/chart-aggregate.ts#chartAggregateCategoryKey (function)", "chartAggregateResultKeys": "src/ui/chart-aggregate.ts#chartAggregateResultKeys (function)", "chartAggregateValueKey": "src/ui/chart-aggregate.ts#chartAggregateValueKey (function)", From ad3856cda73a172c99e372f41c615cdc40db8ddf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:09:30 +0000 Subject: [PATCH 06/14] chore(changeset): bulk confirmText template slot Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .changeset/24798-bulk-confirm-template-slot.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/24798-bulk-confirm-template-slot.md diff --git a/.changeset/24798-bulk-confirm-template-slot.md b/.changeset/24798-bulk-confirm-template-slot.md new file mode 100644 index 00000000000..bfabfc59866 --- /dev/null +++ b/.changeset/24798-bulk-confirm-template-slot.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +--- + +feat(spec,lint): a bulk action's `confirmText` is a `template` slot over `count` and the action's params + +Clause-②: yes (widening) + +- **Before.** `bulkActionDefs[].confirmText` was a plain `z.string()` that declared no hole. A sentence such as `'Update tier on {{count}} account(s) to {{tier}}?'` parsed, nothing judged its holes, and the console showed them as literal text. +- **Now.** `confirmText` is an ADR-0032 `template` slot (Decisions 2–3). A bare string is shorthand for `{ dialect: 'template', source }`, and the envelope form is accepted too. Holes are `{{ path }}` and `{{ path | formatter[:arg] }}`, with formatters from the template engine's whitelist and no logic inside a hole. They read a closed scope, and `bulkConfirmTemplateRoots(def)` (`@objectstack/spec/ui`) declares its roots: `count` (`BULK_CONFIRM_COUNT_ROOT`, the number of records the run covers) and each of the def's params by `name`. The roots are written bare: `{{ tier }}`, never `{{ params.tier }}`. That matches how the flow text slots spell their roots and how shipped sentences already spell them, and `count` wins over a param of that name. A param hole renders the param's display value: the chosen option's `label` (labels joined with `', '` for a multi-value param), the picked record's display name for a lookup or user param, and the entered value otherwise, so number, date and currency values take a formatter. +- **Parsed shape.** A parsed `confirmText` is now the `{ dialect: 'template', source }` envelope rather than a string, as `titleFormat` and a notify node's `title` already are. A reader of the parsed or served def takes the text from `source` (`templateSlotSource` in `@objectstack/spec/automation` reads both forms). A stored sentence with no hole parses as before, its text unchanged. +- **Judged by the one template judge.** `objectstack validate`, `objectstack compile` and the runtime publish gate (now also on `view` writes, and on an object's own `listViews` at an object write) judge each bulk `confirmText` through `closedScopeTemplateRefusal`. That is the closed-scope entry of the judge the flow text slots use (`automation/flow-text-slot-template.ts`). A hole over an unbound root and a single-brace `{x}` token are named, the latter with its `{{ }}` spelling. A text carrying neither is then compiled as a template, which names a hole holding logic or an unknown formatter. +- **A warning in 18.x, refused in 19.** Each of those texts parsed while `confirmText` was a plain string, so refusing it now would be a narrowing that no ADR-0087 conversion carries. Every finding above is therefore a **warning**: an advisory at the publish gate, never a refusal. A blank `confirmText` (`''`) still parses, and the doors warn on it. Refusing all of these waits for protocol 19. +- **Unchanged.** The `confirm` / `confirmmessage` near-misses still name `confirmText`. Every other bulk-def key, the default confirm sentence and the flow text slots' verdicts are unchanged. From 394fc2f585e55633e0ea4aaa266389b6ae9bd351 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:25:24 +0000 Subject: [PATCH 07/14] test(spec): the bulk confirmText envelope in the confirm-params pin and the unknown-members ledger Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- packages/spec/src/ui/action-confirm-params-guard.test.ts | 4 +++- .../spec/src/ui/component-props-unknown-members.pin.test.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/ui/action-confirm-params-guard.test.ts b/packages/spec/src/ui/action-confirm-params-guard.test.ts index 16b14febb30..21bfc781ae2 100644 --- a/packages/spec/src/ui/action-confirm-params-guard.test.ts +++ b/packages/spec/src/ui/action-confirm-params-guard.test.ts @@ -195,8 +195,10 @@ describe('the pair guard is scoped to ActionSchema by SCHEMA BOUNDARY', () => { }); expect(result.success).toBe(true); + // [#24798] The bulk def's `confirmText` is a `template` slot: the bare + // string parses to its envelope, text unchanged. expect(result.success && result.data.confirmText) - .toBe('Set these labels on every selected project?'); + .toEqual({ dialect: 'template', source: 'Set these labels on every selected project?' }); expect(result.success && result.data.params?.length).toBe(1); }); diff --git a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts index efb930715c7..a9ecaf0c7ac 100644 --- a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts +++ b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts @@ -265,7 +265,9 @@ on(['action:group', 'action:menu'], ['actions[].visible.ast', 'actions[].disable on(['action:button', 'action:icon'], ['visible.ast', 'disabled.ast'], EXPRESSION_AST); on(['record:line_items'], ['columns[].readonlyWhen.ast', 'columns[].requiredWhen.ast'], EXPRESSION_AST); on(['object-master-detail-form'], ['details[].columns[].readonlyWhen.ast', 'details[].columns[].requiredWhen.ast'], EXPRESSION_AST); -on(['object-grid', 'list-view'], ['conditionalFormatting[].condition.ast', 'bulkActionDefs[].visible.ast'], EXPRESSION_AST); +// [#24798] A bulk def's `confirmText` is a `template` slot, so its envelope +// carries the same engine AST beside `source`. +on(['object-grid', 'list-view'], ['conditionalFormatting[].condition.ast', 'bulkActionDefs[].visible.ast', 'bulkActionDefs[].confirmText.ast'], EXPRESSION_AST); on(['object-kanban'], ['conditionalFormatting[].condition.ast'], EXPRESSION_AST); // The S-forms stage: an inline form field (`customFields[]`, and a section's // inline entry) and the form view's `{ field }` entry carry the three `*When` From aedea52b10f111e76f9489b5ed37e6e4177adc15 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:33:44 +0000 Subject: [PATCH 08/14] test(lint): table the bulk confirmText pass's receivers and the view gate's new rule Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .../lint/src/authoring-rule-wiring.test.ts | 3 + ...e.page-visibility-predicate-writes.test.ts | 3 + packages/lint/src/runtime-gate.test.ts | 5 ++ .../lint/src/validate-expressions.test.ts | 26 ++++++- packages/lint/src/validate-expressions.ts | 78 +++++++++++-------- 5 files changed, 81 insertions(+), 34 deletions(-) diff --git a/packages/lint/src/authoring-rule-wiring.test.ts b/packages/lint/src/authoring-rule-wiring.test.ts index 30beb2a6dee..ea36936cf69 100644 --- a/packages/lint/src/authoring-rule-wiring.test.ts +++ b/packages/lint/src/authoring-rule-wiring.test.ts @@ -505,7 +505,10 @@ describe('authoring-rule registry wiring (#4409)', () => { // suite runs exactly its two list-view FIELD members on a view snapshot // (`runtime-gate.view-writes.test.ts` pins that member surface). Both // are named here rather than folded into the family. + // `validateStackExpressions` (#24798) runs ONE pass on a view write — + // the bulk `confirmText` template slots — registered ahead of the rest. expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual([ + 'validateStackExpressions', 'validatePresetComparands', 'validateReferenceIntegrity', ...FAMILY, diff --git a/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts b/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts index c365bfc9b27..a5d163fb987 100644 --- a/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts +++ b/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts @@ -177,7 +177,10 @@ describe('#24491 — the page door gives the build\'s visibility-predicate verdi }); it('the `view` control: a view write is judged exactly as before', () => { + // `validateStackExpressions` joined for its bulk `confirmText` pass alone + // (#24798) — no visibility predicate is judged by it on a view write. expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual([ + 'validateStackExpressions', 'validatePresetComparands', 'validateReferenceIntegrity', ...FAMILY, diff --git a/packages/lint/src/runtime-gate.test.ts b/packages/lint/src/runtime-gate.test.ts index 491183859a4..b58bf486dbd 100644 --- a/packages/lint/src/runtime-gate.test.ts +++ b/packages/lint/src/runtime-gate.test.ts @@ -327,6 +327,9 @@ describe('the views[] visibility-predicate family at the runtime publish gate (# expect(runtimeGatedTypes()).toContain('view'); expect(stackKeyForType('view')).toBe('views'); expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual([ + // #24798 — the expression rule runs ONE pass on a view write: the bulk + // `confirmText` template slots, warnings only in 18.x. + 'validateStackExpressions', // #8793 — filter comparands, not predicates; registered ahead of the // family and dispatched for `view` because list-view filter rules are // one of the three shapes it judges. @@ -533,6 +536,7 @@ describe('the views[] visibility-predicate family at the runtime publish gate (# expect(result.advisories, JSON.stringify(result.advisories)).toEqual([]); // "clean" and "nothing ran" must stay distinguishable. expect(result.rulesRun).toEqual([ + 'validateStackExpressions', // #24798 — its bulk `confirmText` pass alone, clean here 'validatePresetComparands', // #8793 — dispatched for `view`, clean here 'validateReferenceIntegrity', // #9313 — its two list-view field members, clean here 'validateVisibilityPredicates', @@ -627,6 +631,7 @@ describe('the publish gate judges a schema-bound form at its own layer (#7815)', expect(result.advisories, JSON.stringify(result.advisories)).toEqual([]); // "clean" and "nothing ran" must stay distinguishable. expect(result.rulesRun).toEqual([ + 'validateStackExpressions', // #24798 — its bulk `confirmText` pass alone, clean here 'validatePresetComparands', // #8793 — dispatched for `view`, clean here 'validateReferenceIntegrity', // #9313 — its two list-view field members, clean here 'validateVisibilityPredicates', diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index cf3f006a361..d1e247f4c77 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -10,6 +10,7 @@ import { EVALUATED_EXPRESSION_SOURCE_REQUIRED, ExpressionInputSchema, ObjectStac import { FieldSchema, ObjectSchema, SelectOptionSchema } from '@objectstack/spec/data'; import { EvalUserSchema } from '@objectstack/spec/identity'; import { SharingRuleSchema } from '@objectstack/spec/security'; +import { BulkActionDefSchema } from '@objectstack/spec/ui'; // [#15137] The published refusal sentence a `value`-slot finding must lead // with — asserted from the spec's own export, never re-spelled in a test. import { @@ -3291,12 +3292,20 @@ const flowShape = () => { * the author a named replacement instead of a bare "unrecognized key" (#4343). * Reading a key to REJECT it is the opposite of reading a key to honour it. */ -const NOT_A_SINGLE_SHAPE = ['cfg', 'startCfg']; +// [#24798] `viewEntry` — one `views[]` entry as the bulk `confirmText` pass +// walks it — is a union too: a `defineView` container (`list` / `listViews`), +// a flattened list overlay (`viewKind: 'list'`, the list view's own keys at +// the top level) or a ViewItem record (`config`). Its reads are recognisers +// for those three shapes, mirrored from `validate-list-view-field-refs.ts`, and +// the bulk defs they reach are read off the tabled `bulkDef` receiver. +const NOT_A_SINGLE_SHAPE = ['cfg', 'startCfg', 'viewEntry']; const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: string; keys: () => string[] }> = [ { receiver: 'stack', - expected: ['actions', 'flows', 'hooks', 'objects', 'sharingRules'], + // `views` joined with #24798: the bulk `confirmText` pass walks the views' + // list containers. + expected: ['actions', 'flows', 'hooks', 'objects', 'sharingRules', 'views'], declaredBy: 'ObjectStackSchema', keys: () => Object.keys(ObjectStackSchema.shape), }, @@ -3305,7 +3314,9 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s // `validationRules` is absent — one of the five #5017 removed. // `attachedOnRead` joined with #22386 (#22211 ruling A): the declared read // attachments, whose block names join the field index. - expected: ['actions', 'attachedOnRead', 'fields', 'name', 'validations'], + // `listViews` joined with #24798: an object's own list views carry bulk + // defs whose `confirmText` is judged. + expected: ['actions', 'attachedOnRead', 'fields', 'listViews', 'name', 'validations'], declaredBy: 'ObjectSchema', keys: () => Object.keys(ObjectSchema.shape), }, @@ -3316,6 +3327,15 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s declaredBy: 'FieldSchema', keys: () => Object.keys(FieldSchema.shape), }, + { + // [#24798] One `bulkActionDefs[]` entry: its `confirmText` template slot, + // and its `name` for the location (its params are read by the spec's + // `bulkConfirmTemplateRoots`, not by name here). + receiver: 'bulkDef', + expected: ['confirmText', 'name'], + declaredBy: 'BulkActionDefSchema', + keys: () => shapeKeysOf(BulkActionDefSchema), + }, { receiver: 'rule', // `expression` / `predicate` / `formula` are absent — the chain that put diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index ec6ad9ece24..a85f497e84d 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -3399,6 +3399,12 @@ const BULK_CONFIRM_TEXT_WARNING_ONLY = /** The slot as {@link closedScopeTemplateRefusal}'s sentences name it. */ const BULK_CONFIRM_TEXT_SLOT = "A bulk action's `confirmText`"; +/** A blank sentence — `trim()`'s whitespace set is exactly `\s`. */ +const BLANK_TEXT = /^\s*$/; + +/** The label segment for a named list view (`listViews.`). */ +const LIST_VIEWS_LABEL = 'listViews'; + /** * [#24798, ruling B] Judge every bulk action def's `confirmText` — the * ADR-0032 `template` slot `ui/bulk-action.zod.ts` declares over a CLOSED @@ -3417,64 +3423,74 @@ const BULK_CONFIRM_TEXT_SLOT = "A bulk action's `confirmText`"; * `listViews.`, a standalone list view (the flattened overlay or the * ViewItem record), and an object's `listViews.`. */ -function bulkConfirmTextIssues(stack: AnyRec, scope: { views: boolean; objects: boolean }): ExprIssue[] { +function bulkConfirmTextIssues( + stack: AnyRec, + { views: judgeViews, objects: judgeObjects }: { views: boolean; objects: boolean }, +): ExprIssue[] { const issues: ExprIssue[] = []; + // Every metadata read below is a literal read off a receiver the #5017 + // meta-test tables (`stack`, `obj`, `bulkDef`, and `viewEntry`, a union of + // shapes): a `.length` or a label string spelling `listViews.` would read + // as one more receiver to it, so the loops index by hand and the label is + // joined from LIST_VIEWS_LABEL. const judgeContainer = (container: unknown, at: string): void => { if (!container || typeof container !== 'object') return; - const defs = (container as AnyRec).bulkActionDefs; - if (!Array.isArray(defs)) return; - for (let di = 0; di < defs.length; di++) { - const def = defs[di] as AnyRec | undefined; - if (!def || typeof def !== 'object' || def.confirmText === undefined) continue; - const source = templateSlotSource(def.confirmText); - if (source === undefined) continue; // not a string or a template envelope: the parse refuses its shape - const name = typeof def.name === 'string' ? ` '${def.name}'` : ''; - const where = `${at} · bulkActionDefs[${di}]${name} confirmText`; + const listed = (container as AnyRec).bulkActionDefs; + if (!Array.isArray(listed)) return; + let di = -1; + for (const bulkDef of listed as AnyRec[]) { + di += 1; + if (!bulkDef || typeof bulkDef !== 'object' || bulkDef.confirmText === undefined) continue; + const confirmSource = templateSlotSource(bulkDef.confirmText); + if (confirmSource === undefined) continue; // neither a string nor a template envelope: the parse refuses its shape + const label = typeof bulkDef.name === 'string' ? ` '${bulkDef.name}'` : ''; + const where = `${at} · bulkActionDefs[${di}]${label} confirmText`; const warn = (message: string, quoted: string): void => { issues.push({ where, message: `${message} ${BULK_CONFIRM_TEXT_WARNING_ONLY}`, source: quoted, severity: 'warning' }); }; - if (source.trim() === '') { + if (BLANK_TEXT.test(confirmSource)) { warn( `${BULK_CONFIRM_TEXT_SLOT} is blank, so the confirm step shows an empty sentence where the default one names ` + 'the record count: write the sentence, or remove the key to show the default.', - source, + confirmSource, ); continue; } - const refusal = closedScopeTemplateRefusal(source, { slot: BULK_CONFIRM_TEXT_SLOT, roots: bulkConfirmTemplateRoots(def) }); + const refusal = closedScopeTemplateRefusal(confirmSource, { slot: BULK_CONFIRM_TEXT_SLOT, roots: bulkConfirmTemplateRoots(bulkDef) }); if (refusal !== undefined) { - warn(refusal, source); + warn(refusal, confirmSource); continue; } - for (const e of validateExpression('template', source).errors) warn(e.message, e.source); + for (const e of validateExpression('template', confirmSource).errors) warn(e.message, e.source); } }; - const judgeListViews = (listViews: unknown, owner: string): void => { - if (!listViews || typeof listViews !== 'object' || Array.isArray(listViews)) return; - for (const [key, lv] of Object.entries(listViews as AnyRec)) judgeContainer(lv, `${owner} · listViews.${key}`); + const judgeListViews = (named: unknown, owner: string): void => { + if (!named || typeof named !== 'object' || Array.isArray(named)) return; + for (const [key, lv] of Object.entries(named as AnyRec)) judgeContainer(lv, `${owner} · ${LIST_VIEWS_LABEL}.${key}`); }; - if (scope.views) { - for (const [vi, view] of recordsOf(stack.views).entries()) { - if (!view || typeof view !== 'object') continue; - const viewName = typeof view.name === 'string' ? view.name : typeof view.object === 'string' ? view.object : `#${vi}`; + if (judgeViews) { + let vi = -1; + for (const viewEntry of recordsOf(stack.views)) { + vi += 1; + const viewName = typeof viewEntry.name === 'string' ? viewEntry.name : typeof viewEntry.object === 'string' ? viewEntry.object : `#${vi}`; // The two standalone shapes `PUT /api/v1/meta/view` carries and the // publish gate snapshots as `views: [item]` — recognised as the list-view // field rules recognise them (`validate-list-view-field-refs.ts`): the // flattened list overlay (the `ListView` at the top level) and the // ViewItem record (one level down, in `config`). Without them a view // write would run this pass over nothing. - const config = view.config; - if (view.viewKind === 'list') { - const record = config !== null && typeof config === 'object' && !Array.isArray(config); - judgeContainer(record ? config : view, `view '${viewName}' (${record ? 'ViewItem record' : 'flattened list overlay'})`); + if (viewEntry.viewKind === 'list') { + const record = viewEntry.config !== null && typeof viewEntry.config === 'object' && !Array.isArray(viewEntry.config); + judgeContainer(record ? viewEntry.config : viewEntry, `view '${viewName}' (${record ? 'ViewItem record' : 'flattened list overlay'})`); } - judgeContainer(view.list, `view '${viewName}' · list`); - judgeListViews(view.listViews, `view '${viewName}'`); + judgeContainer(viewEntry.list, `view '${viewName}' · list`); + judgeListViews(viewEntry.listViews, `view '${viewName}'`); } } - if (scope.objects) { - for (const [oi, obj] of recordsOf(stack.objects).entries()) { - if (!obj || typeof obj !== 'object') continue; + if (judgeObjects) { + let oi = -1; + for (const obj of recordsOf(stack.objects)) { + oi += 1; judgeListViews(obj.listViews, `object '${typeof obj.name === 'string' ? obj.name : `#${oi}`}'`); } } From 46876bc27e79719ddd5d014edf05d3db97fd5934 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:41:56 +0000 Subject: [PATCH 09/14] test(dogfood): classify the bulk confirmText template slot in the expression-conformance ledger Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .../qa/dogfood/test/expression-conformance.ledger.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 87301d384e0..0e88e1698e2 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -628,6 +628,16 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ covers: ['data/object.zod.ts:ObjectSchemaBase.titleFormat'], note: 'EXPERIMENTAL, and the state is a deliberate split between two questions. `packages/spec/liveness/object.json` classifies the KEY `live` with the note "objectui ({{record.field}} interpolation)" — that ledger asks whether anything READS the key, and the answer is yes. THIS ledger asks what EVALUATES the expression and under which fail policy, and the only interpolation site named is in the sibling repo objectui, which is not in this checkout: ⛔ NOT measured here, so it is not written into `enforcement` as if it had been. Marking the row `enforced` on a second-hand reading is exactly the invented cell this ledger exists to prevent; marking it `removed` would contradict a governed ledger that measured more than I could. Re-state as `enforced` when someone measures the objectui site — or as `removed` when ADR-0079 retires the key.', }, + { + // #24798 (ruling B, the spec half): `confirmText` became a `template` slot over a closed scope. + id: 'template-bulk-confirm-text', + summary: 'list-view bulk action confirm sentence (BulkActionDef.confirmText) — `{{ count }}` and `{{ }}` template holes over a closed scope', + dialect: 'template', mode: 'interpret', state: 'experimental', failPolicy: 'unevaluated', + enforcement: + 'PARSE ONLY in this repo — `ui/bulk-action.zod.ts` types the slot with `templateExpressionInput` (the input `TemplateExpressionInputSchema` takes, with refusals prescribing `{{ count }}`), normalizing a bare string to `{dialect:"template",source}` and, for 18.x, still accepting a blank string as written. NO EVALUATOR FOUND here: the sentence is drawn by the console\'s bulk dialog in the sibling objectui repo, not in this checkout, and filling its holes is the renderer half of the ruling. At build and at the publish gate `validateStackExpressions` judges it — `closedScopeTemplateRefusal` over the roots `bulkConfirmTemplateRoots(def)` declares (`count` and the def\'s params by name), then `validateExpression(\'template\', …)` — and every finding is a WARNING (an advisory at the gate), because each such text parsed while the slot was a plain string', + covers: ['ui/bulk-action.zod.ts:BulkActionDefSchema.confirmText'], + note: 'EXPERIMENTAL until the console fills the holes. The spec half declares the slot, its closed scope and the display-value rule a param hole renders; the console half — the dialog rendering the slot through the formula template engine, `count` first and the param roots second — lands in objectui, and this row is re-stated as `enforced` with the fail policy that renderer has once it is measured there. Until then a hole reaches the screen as literal text, as it did before the slot was typed.', + }, { id: 'template-notify-content', summary: 'flow `notify` node inline content (NotifyConfig.title, .message) — `{{ }}` template holes rendered per run by the formula template engine', From 247e44a61e0e77de1437bf9790aaa2c1572a500d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 17:26:29 +0000 Subject: [PATCH 10/14] fix(cli): os i18n extract seeds a bulk confirmText written as its template envelope Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- packages/cli/src/utils/i18n-extract.ts | 9 +++++++-- .../cli/test/i18n-bulk-action-coverage.test.ts | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 72b2eb71860..4c0e88d86ce 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -140,7 +140,7 @@ import { globalFilterKey, walkAddressedPageComponents, } from '@objectstack/spec/system'; -import { FLOW_REGION_SLOTS_BY_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation'; +import { FLOW_REGION_SLOTS_BY_TYPE, flowScreenFieldOptionKey, templateSlotSource } from '@objectstack/spec/automation'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { deriveFieldGroupLayout } from '@objectstack/spec/data'; import { expandViewContainer, InlineLocaleMapSchema } from '@objectstack/spec/ui'; @@ -477,7 +477,12 @@ function pushBulkActionDefs(out: ExpectedEntry[], viewRoot: string[], view: any, // nobody wrote. const authoredLabel = inlineText(def.label); pushDerived(out, [...base, 'label'], authoredLabel ?? humanizeFieldPath(defName), def.label, 'view', { objectName }); - pushOptional(out, [...base, 'confirmText'], def.confirmText, 'view', { objectName }); + // [#24798] `confirmText` is a `template` slot: a bare string or the + // `{ dialect: 'template', source }` envelope, and either one is the same + // authored sentence. Read it through the template judge's one reader, so + // the envelope form is seeded like the string instead of being recorded + // as an absent key. + pushOptional(out, [...base, 'confirmText'], templateSlotSource(def.confirmText), 'view', { objectName }); pushOptional(out, [...base, 'confirmLabel'], def.confirmLabel, 'view', { objectName }); if (!Array.isArray(def.params)) continue; for (const param of def.params) { diff --git a/packages/cli/test/i18n-bulk-action-coverage.test.ts b/packages/cli/test/i18n-bulk-action-coverage.test.ts index 3db1a21c655..85560f2a51c 100644 --- a/packages/cli/test/i18n-bulk-action-coverage.test.ts +++ b/packages/cli/test/i18n-bulk-action-coverage.test.ts @@ -102,6 +102,20 @@ describe('the extractor scaffolds a key for every string the selection bar draws expect(confirm?.inline).toBe('Set these labels on every selected project?'); }); + it('seeds a `confirmText` written as its template envelope exactly as the bare string (#24798)', () => { + // `confirmText` is a `template` slot, so `{ dialect: 'template', source }` + // is the same authored sentence; without reading the envelope it was + // recorded like an absent key, with nothing to translate. + const source = 'Set {{ count }} project(s)?'; + const seedOf = (confirmText: unknown) => bulkEntries(objectConfig({ + bulkActionDefs: [{ name: 'set_labels', operation: 'update', confirmText }], + })).find((e) => e.path.at(-1) === 'confirmText'); + const fromEnvelope = seedOf({ dialect: 'template', source }); + expect(fromEnvelope?.sourceValue).toBe(source); + expect(fromEnvelope?.inline).toBe(source); + expect(fromEnvelope).toEqual(seedOf(source)); + }); + it('records an unauthored optional key without seeding it', () => { // The other half of the contract above: the key exists so a bundle that // authors it is recognised, but nothing is scaffolded and coverage demands From 9fb508c8a01b4ebd2b5b4878f250df3226690ea7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 17:26:56 +0000 Subject: [PATCH 11/14] chore(changeset): os i18n extract reads the bulk confirmText envelope Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .changeset/24798-bulk-confirm-template-slot.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/24798-bulk-confirm-template-slot.md b/.changeset/24798-bulk-confirm-template-slot.md index bfabfc59866..f061f34bd7b 100644 --- a/.changeset/24798-bulk-confirm-template-slot.md +++ b/.changeset/24798-bulk-confirm-template-slot.md @@ -1,6 +1,7 @@ --- '@objectstack/spec': minor '@objectstack/lint': minor +'@objectstack/cli': minor --- feat(spec,lint): a bulk action's `confirmText` is a `template` slot over `count` and the action's params @@ -12,4 +13,5 @@ Clause-②: yes (widening) - **Parsed shape.** A parsed `confirmText` is now the `{ dialect: 'template', source }` envelope rather than a string, as `titleFormat` and a notify node's `title` already are. A reader of the parsed or served def takes the text from `source` (`templateSlotSource` in `@objectstack/spec/automation` reads both forms). A stored sentence with no hole parses as before, its text unchanged. - **Judged by the one template judge.** `objectstack validate`, `objectstack compile` and the runtime publish gate (now also on `view` writes, and on an object's own `listViews` at an object write) judge each bulk `confirmText` through `closedScopeTemplateRefusal`. That is the closed-scope entry of the judge the flow text slots use (`automation/flow-text-slot-template.ts`). A hole over an unbound root and a single-brace `{x}` token are named, the latter with its `{{ }}` spelling. A text carrying neither is then compiled as a template, which names a hole holding logic or an unknown formatter. - **A warning in 18.x, refused in 19.** Each of those texts parsed while `confirmText` was a plain string, so refusing it now would be a narrowing that no ADR-0087 conversion carries. Every finding above is therefore a **warning**: an advisory at the publish gate, never a refusal. A blank `confirmText` (`''`) still parses, and the doors warn on it. Refusing all of these waits for protocol 19. +- **`os i18n extract`.** A bulk `confirmText` written as the envelope is seeded exactly as the bare string: the extractor reads the slot's text through `templateSlotSource`, so the envelope form no longer records as an absent key with nothing to translate. - **Unchanged.** The `confirm` / `confirmmessage` near-misses still name `confirmText`. Every other bulk-def key, the default confirm sentence and the flow text slots' verdicts are unchanged. From f10bc73a0f515ba24f6b30c590b4e156baea867f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 08:59:35 +0000 Subject: [PATCH 12/14] fix(spec,lint): the bulk confirmText warning no longer promises a protocol-19 refusal; the changeset declares Metadata-protocol: added Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .../24798-bulk-confirm-template-slot.md | 3 ++- packages/lint/src/authoring-rules.ts | 5 +++-- ...date-expressions.bulk-confirm-text.test.ts | 7 +++---- packages/lint/src/validate-expressions.ts | 20 ++++++++++--------- packages/spec/src/ui/bulk-action.zod.ts | 8 ++++---- .../spec/src/ui/bulk-confirm-template.test.ts | 4 ++-- 6 files changed, 25 insertions(+), 22 deletions(-) diff --git a/.changeset/24798-bulk-confirm-template-slot.md b/.changeset/24798-bulk-confirm-template-slot.md index f061f34bd7b..bf0d7880ca8 100644 --- a/.changeset/24798-bulk-confirm-template-slot.md +++ b/.changeset/24798-bulk-confirm-template-slot.md @@ -7,11 +7,12 @@ feat(spec,lint): a bulk action's `confirmText` is a `template` slot over `count` and the action's params Clause-②: yes (widening) +Metadata-protocol: added - **Before.** `bulkActionDefs[].confirmText` was a plain `z.string()` that declared no hole. A sentence such as `'Update tier on {{count}} account(s) to {{tier}}?'` parsed, nothing judged its holes, and the console showed them as literal text. - **Now.** `confirmText` is an ADR-0032 `template` slot (Decisions 2–3). A bare string is shorthand for `{ dialect: 'template', source }`, and the envelope form is accepted too. Holes are `{{ path }}` and `{{ path | formatter[:arg] }}`, with formatters from the template engine's whitelist and no logic inside a hole. They read a closed scope, and `bulkConfirmTemplateRoots(def)` (`@objectstack/spec/ui`) declares its roots: `count` (`BULK_CONFIRM_COUNT_ROOT`, the number of records the run covers) and each of the def's params by `name`. The roots are written bare: `{{ tier }}`, never `{{ params.tier }}`. That matches how the flow text slots spell their roots and how shipped sentences already spell them, and `count` wins over a param of that name. A param hole renders the param's display value: the chosen option's `label` (labels joined with `', '` for a multi-value param), the picked record's display name for a lookup or user param, and the entered value otherwise, so number, date and currency values take a formatter. - **Parsed shape.** A parsed `confirmText` is now the `{ dialect: 'template', source }` envelope rather than a string, as `titleFormat` and a notify node's `title` already are. A reader of the parsed or served def takes the text from `source` (`templateSlotSource` in `@objectstack/spec/automation` reads both forms). A stored sentence with no hole parses as before, its text unchanged. - **Judged by the one template judge.** `objectstack validate`, `objectstack compile` and the runtime publish gate (now also on `view` writes, and on an object's own `listViews` at an object write) judge each bulk `confirmText` through `closedScopeTemplateRefusal`. That is the closed-scope entry of the judge the flow text slots use (`automation/flow-text-slot-template.ts`). A hole over an unbound root and a single-brace `{x}` token are named, the latter with its `{{ }}` spelling. A text carrying neither is then compiled as a template, which names a hole holding logic or an unknown formatter. -- **A warning in 18.x, refused in 19.** Each of those texts parsed while `confirmText` was a plain string, so refusing it now would be a narrowing that no ADR-0087 conversion carries. Every finding above is therefore a **warning**: an advisory at the publish gate, never a refusal. A blank `confirmText` (`''`) still parses, and the doors warn on it. Refusing all of these waits for protocol 19. +- **A warning, not a refusal.** Each of those texts parsed while `confirmText` was a plain string, so refusing it is a narrowing that no ADR-0087 conversion carries. This release ships every finding above as a **warning**: an advisory at the publish gate, never a refusal, so no command's exit code changes. A blank `confirmText` (`''`) still parses, and the doors warn on it. - **`os i18n extract`.** A bulk `confirmText` written as the envelope is seeded exactly as the bare string: the extractor reads the slot's text through `templateSlotSource`, so the envelope form no longer records as an absent key with nothing to translate. - **Unchanged.** The `confirm` / `confirmmessage` near-misses still name `confirmText`. Every other bulk-def key, the default confirm sentence and the flow text slots' verdicts are unchanged. diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 9b35d7f9080..81fb8c6608a 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -721,8 +721,9 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // def's params. On a view write `runStackExpressionPasses` runs that pass // alone (by construction, as for an object write), and on an object write // it also judges the object's own `listViews`. Every finding is a - // `warning` in 18.x — an advisory here, never a refusal — because every - // such sentence parsed before the slot was typed (the 「甲」 ruling). + // `warning` — an advisory here, never a refusal — because every such + // sentence parsed before the slot was typed; its refusal, a narrowing no + // conversion carries, is its own change (the amended 「甲」, 6303865025). // MEASURED first over the shipped corpus at `b42c621c05`: 6 bulk defs // carry a `confirmText` (examples/app-showcase `project.view.ts`), none // with a brace → 0 findings, against a warning for each of an unbound diff --git a/packages/lint/src/validate-expressions.bulk-confirm-text.test.ts b/packages/lint/src/validate-expressions.bulk-confirm-text.test.ts index 0d9beecf73e..0dcbceeeda2 100644 --- a/packages/lint/src/validate-expressions.bulk-confirm-text.test.ts +++ b/packages/lint/src/validate-expressions.bulk-confirm-text.test.ts @@ -5,9 +5,9 @@ // and the def's params by name). `validateStackExpressions` — the rule // `objectstack validate`, `objectstack compile` and the runtime publish gate // all run — judges it through the template judge's closed-scope entry, then -// compiles it as a template. Every finding is a WARNING in 18.x: each of these -// sentences parsed before the slot was typed, so a refusal would be a -// narrowing no conversion carries; it waits for protocol 19. +// compiles it as a template. Every finding is a WARNING: each of these +// sentences parsed before the slot was typed, so a refusal is a narrowing no +// conversion carries, and it is its own change, not this one. // // The gate half drives the REAL dispatch (`runRuntimeAuthoringRules`) with the // three shapes a view write carries — the container's `list`, the flattened @@ -57,7 +57,6 @@ describe('validateStackExpressions — a bulk confirmText is judged as the templ expect(issue!.where).toBe("view 'crm_account' · list · bulkActionDefs[0] 'update_tier' confirmText"); expect(issue!.message).toContain('binds only `count`, `tier`'); expect(issue!.message).toContain('`{{ owner }}`'); - expect(issue!.message).toContain('protocol 19'); expect(issue!.source).toBe('Move {{count}} to {{ owner }}?'); }); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 416e0ac3dac..b4e7ac6310b 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -2001,7 +2001,7 @@ export interface StackExpressionOptions { * * [#24798] A sixth judges an object write too: the bulk `confirmText` * pass over the object's own `listViews` (`bulkConfirmTextIssues`), warnings - * only in 18.x. On a `view` write that pass is the ONLY one, over the + * only. On a `view` write that pass is the ONLY one, over the * written view's `list` and `listViews` — every other pass reads a * collection a view write does not carry. * @@ -3474,12 +3474,12 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression /** * Why a finding on a bulk `confirmText` is a warning, said once at the end of * each: `confirmText` was a plain string before it became a `template` slot, - * so every one of these sentences parsed — refusing it now is a narrowing no - * conversion carries, which ships as a warning in 18.x. + * so every one of these sentences parsed — refusing it is a narrowing no + * conversion carries. This change ships the advisory; the refusal is its own + * change, not this one, and the sentence promises no release for it. */ const BULK_CONFIRM_TEXT_WARNING_ONLY = - 'A warning in 18.x: this text parsed before `confirmText` became a template slot, so it is refused only from ' - + 'protocol 19.'; + 'A warning, not a refusal: this text parsed before `confirmText` became a template slot.'; /** The slot as {@link closedScopeTemplateRefusal}'s sentences name it. */ const BULK_CONFIRM_TEXT_SLOT = "A bulk action's `confirmText`"; @@ -3501,10 +3501,12 @@ const LIST_VIEWS_LABEL = 'listViews'; * `template` it is (`validateExpression('template', …)`), which names a hole * holding logic or an unknown formatter. A blank sentence is named too. * - * ⚠️ Every finding is a `warning` in 18.x, never an `error`: each of these - * texts parsed before the slot was typed, so a refusal would be a narrowing no - * ADR-0087 conversion carries (the 「甲」 ruling) — its refusal waits for - * protocol 19. The containers walked: a view's `list` and each + * ⚠️ Every finding is a `warning` here, never an `error`: each of these + * texts parsed before the slot was typed, so a refusal is a narrowing no + * ADR-0087 conversion carries. Under the amended 「甲」 (record 6303865025) + * such a refusal ships in 18.x as a BREAKING minor, as its own change (the + * `validatePagePredicateBounds` precedent: advisory first, refusal after); + * this pass is the advisory. The containers walked: a view's `list` and each * `listViews.`, a standalone list view (the flattened overlay or the * ViewItem record), and an object's `listViews.`. */ diff --git a/packages/spec/src/ui/bulk-action.zod.ts b/packages/spec/src/ui/bulk-action.zod.ts index a90c7f83eb7..49e805485ab 100644 --- a/packages/spec/src/ui/bulk-action.zod.ts +++ b/packages/spec/src/ui/bulk-action.zod.ts @@ -375,9 +375,9 @@ export function bulkConfirmTemplateRoots(def: { readonly params?: unknown }): st * * ⚠️ One arm wider than a template slot, on purpose: a BLANK string parses as * itself. `confirmText` was a plain `z.string()`, so `''` passed, and refusing - * it now is a narrowing no conversion carries — which ships as a warning in - * 18.x (the 「甲」 ruling), raised at the doors with the hole findings, and - * waits for protocol 19 to be refused here. + * it is a narrowing no conversion carries. The doors raise it as a warning + * with the hole findings; refusing it here is its own change (the amended + * 「甲」, record 6303865025), not this one. */ const BULK_CONFIRM_TEXT_DIALECT_ONLY = 'A bulk action\'s `confirmText` is a template-typed slot: it accepts a bare template string or an envelope ' @@ -465,7 +465,7 @@ export const BulkActionDefSchema = lazySchema(() => strictObject( execution: BulkActionExecutionSchema.optional().describe("For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead."), patch: z.record(z.string(), z.unknown()).optional().describe("For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog."), params: z.array(BulkActionParamSchema).optional().describe('Inputs collected once before the run. Omit to skip the params step and go straight to confirm.'), - confirmText: BulkConfirmTextSchema.optional().describe("Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount | currency:EUR }}`, `{{ due | date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on {{ count }} account(s) to {{ tier }}?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate — a warning in 18.x, refused from protocol 19."), + confirmText: BulkConfirmTextSchema.optional().describe("Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount | currency:EUR }}`, `{{ due | date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on {{ count }} account(s) to {{ tier }}?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate, as a warning."), confirmLabel: z.string().optional().describe('Custom Confirm button label (default: "Run").'), visible: EvaluatedExpressionInputSchema.optional().describe('Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record.'), requiredPermissions: z.array(z.string()).optional().describe("[ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks."), diff --git a/packages/spec/src/ui/bulk-confirm-template.test.ts b/packages/spec/src/ui/bulk-confirm-template.test.ts index dddd653cfd5..c10f9194a4e 100644 --- a/packages/spec/src/ui/bulk-confirm-template.test.ts +++ b/packages/spec/src/ui/bulk-confirm-template.test.ts @@ -50,9 +50,9 @@ describe('BulkActionDef.confirmText — a template slot (ADR-0032 Decisions 2– .toEqual({ dialect: 'template', source: 'Set these labels on every selected project?' }); }); - it('still parses a blank string, verbatim: refusing it is a narrowing, a warning in 18.x', () => { + it('still parses a blank string, verbatim: refusing it is a narrowing, a warning at the doors', () => { // `confirmText` was `z.string()`, so `''` passed. The shared template - // input refuses a blank source; this slot keeps the 18.x accept instead. + // input refuses a blank source; this slot keeps the accept instead. expect(parsed('')).toBe(''); expect(parsed(' \t')).toBe(' \t'); }); From 84b1e35c8e7fc0fa73aa00c829b757a39485b7ff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 09:03:51 +0000 Subject: [PATCH 13/14] chore(spec): regenerate reference docs after the main merge and the confirmText wording Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- content/docs/references/ui/bulk-action.mdx | 2 +- content/docs/references/ui/component.mdx | 2 +- content/docs/references/ui/view.mdx | 40 +++++++++++----------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/content/docs/references/ui/bulk-action.mdx b/content/docs/references/ui/bulk-action.mdx index 9203abf4575..067a0e1abe2 100644 --- a/content/docs/references/ui/bulk-action.mdx +++ b/content/docs/references/ui/bulk-action.mdx @@ -48,7 +48,7 @@ const result = BulkActionDefSchema.parse(data); | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | | **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | -| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate — a warning in 18.x, refused from protocol 19. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate, as a warning. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 6f9846c43e5..10f481ec641 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -928,7 +928,7 @@ Sort field and direction pair | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | | **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | -| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate — a warning in 18.x, refused from protocol 19. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate, as a warning. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 25bc4999505..a3f5203fbf8 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -76,10 +76,10 @@ Add record entry point configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | optional (default: `true`) | Show the add record entry point | -| **position** | `Enum<'top' \| 'bottom' \| 'both'>` | optional (default: `"bottom"`) | Position of the add record button | -| **mode** | `Enum<'inline' \| 'form' \| 'modal'>` | optional (default: `"inline"`) | How to add a new record | -| **formView** | `string` | optional | Named form view to use when mode is "form" or "modal" | +| **enabled** | `boolean` | ✅ | Show the add record entry point — REQUIRED: `true` draws the Add button, `false` declares it off | +| **position** | `Enum<'top' \| 'bottom' \| 'both'>` | optional (default: `"top"`) | Position of the add record button: `top` (the toolbar, the default), `bottom` (under the list) or `both` | +| **mode** | `never` | optional | [REMOVED] `addRecord.mode` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — no renderer ever read it: the Add button always opens the create form, never an inline row or a mode of its own choosing. Delete the key; the create form opens the way the object list's New opens it. 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. | +| **formView** | `never` | optional | [REMOVED] `addRecord.formView` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — no renderer ever read it: the Add button opens the create form chosen by convention, never a named form. Delete the key, and to give the Add button its own form declare it as `formViews.create` on the object's view container (without one, the default form is used). 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. | --- @@ -806,7 +806,7 @@ Map view configuration | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | | **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. 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. | -| **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | +| **addRecord** | `{ enabled: boolean; position?: Enum<'top' \| 'bottom' \| 'both'> }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | @@ -1039,7 +1039,7 @@ View filter rule | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | | **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | -| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate — a warning in 18.x, refused from protocol 19. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate, as a warning. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. | @@ -1090,10 +1090,10 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | optional (default: `true`) | Show the add record entry point | -| **position** | `Enum<'top' \| 'bottom' \| 'both'>` | optional (default: `"bottom"`) | Position of the add record button | -| **mode** | `Enum<'inline' \| 'form' \| 'modal'>` | optional (default: `"inline"`) | How to add a new record | -| **formView** | `string` | optional | Named form view to use when mode is "form" or "modal" | +| **enabled** | `boolean` | ✅ | Show the add record entry point — REQUIRED: `true` draws the Add button, `false` declares it off | +| **position** | `Enum<'top' \| 'bottom' \| 'both'>` | optional (default: `"top"`) | Position of the add record button: `top` (the toolbar, the default), `bottom` (under the list) or `both` | +| **mode** | `never` | optional | [REMOVED] `addRecord.mode` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — no renderer ever read it: the Add button always opens the create form, never an inline row or a mode of its own choosing. Delete the key; the create form opens the way the object list's New opens it. 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. | +| **formView** | `never` | optional | [REMOVED] `addRecord.formView` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — no renderer ever read it: the Add button opens the create form chosen by convention, never a named form. Delete the key, and to give the Add button its own form declare it as `formViews.create` on the object's view container (without one, the default form is used). 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. | ### Nested Shape: `ListView.emptyState` @@ -1192,7 +1192,7 @@ View filter rule | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | | **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. 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. | -| **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | +| **addRecord** | `{ enabled: boolean; position?: Enum<'top' \| 'bottom' \| 'both'> }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | @@ -1416,7 +1416,7 @@ View filter rule | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | | **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | -| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate — a warning in 18.x, refused from protocol 19. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation text shown above the affected-record summary — 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 two kinds of root, written bare: `count`, the number of records the run covers, and each of the def's params by `name` (`{{ tier }}`, never `{{ params.tier }}`; `count` wins over a param of that name). A param hole renders that param's DISPLAY value: the chosen option's `label` for an option-bearing param (a multi-value param's labels joined with ', '), the picked record's display name for a `lookup` / user param, and the entered value otherwise, so a number, date or currency value takes a formatter (`{{ amount \| currency:EUR }}`, `{{ due \| date:long }}`). Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Update tier on `{{ count }}` account(s) to `{{ tier }}`?'. A hole over any other root, and a single-brace `{x}` token, are named at `objectstack validate`, `compile` and the publish gate, as a warning. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. | @@ -1467,10 +1467,10 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | optional (default: `true`) | Show the add record entry point | -| **position** | `Enum<'top' \| 'bottom' \| 'both'>` | optional (default: `"bottom"`) | Position of the add record button | -| **mode** | `Enum<'inline' \| 'form' \| 'modal'>` | optional (default: `"inline"`) | How to add a new record | -| **formView** | `string` | optional | Named form view to use when mode is "form" or "modal" | +| **enabled** | `boolean` | ✅ | Show the add record entry point — REQUIRED: `true` draws the Add button, `false` declares it off | +| **position** | `Enum<'top' \| 'bottom' \| 'both'>` | optional (default: `"top"`) | Position of the add record button: `top` (the toolbar, the default), `bottom` (under the list) or `both` | +| **mode** | `never` | optional | [REMOVED] `addRecord.mode` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — no renderer ever read it: the Add button always opens the create form, never an inline row or a mode of its own choosing. Delete the key; the create form opens the way the object list's New opens it. 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. | +| **formView** | `never` | optional | [REMOVED] `addRecord.formView` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — no renderer ever read it: the Add button opens the create form chosen by convention, never a named form. Delete the key, and to give the Add button its own form declare it as `formViews.create` on the object's view container (without one, the default form is used). 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. | ### Nested Shape: `ObjectListView.emptyState` @@ -1769,7 +1769,7 @@ Tab configuration for multi-tab view interface | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | | **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. 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. | -| **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | +| **addRecord** | `{ enabled: boolean; position?: Enum<'top' \| 'bottom' \| 'both'> }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | @@ -1855,7 +1855,7 @@ Tab configuration for multi-tab view interface | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | | **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. 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. | -| **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | +| **addRecord** | `{ enabled: boolean; position?: Enum<'top' \| 'bottom' \| 'both'> }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | @@ -2065,7 +2065,7 @@ This schema accepts one of the following structures: | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | | **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. 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. | -| **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | +| **addRecord** | `{ enabled: boolean; position?: Enum<'top' \| 'bottom' \| 'both'> }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | @@ -2244,7 +2244,7 @@ This schema accepts one of the following structures: | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | | **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. 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. | -| **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | +| **addRecord** | `{ enabled: boolean; position?: Enum<'top' \| 'bottom' \| 'both'> }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | From 00012f791c5d9f94053303b343c9b78fdfb3fcc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 06:47:41 +0000 Subject: [PATCH 14/14] fix(spec): compare a bulk confirmText with its packaged base by the slot's text The catalog overlay judged a bulk def's confirmText override with a string comparison. Once confirmText is a template slot, a served view and its packaged base may hold the parsed envelope, so an unchanged envelope lost its translation and a bare/parsed pair never matched. Both sides now go through templateSlotSource before valueOverridesPackagedBase. Claude-Session: https://claude.ai/code/session_01C8gLqHNqQC6zQzTicr4WNu Co-authored-by: Claude --- .../24798-bulk-confirm-template-slot.md | 3 +- .../rest/src/meta-view-container-i18n.test.ts | 16 +++++++++ .../i18n-resolver.object-list-views.test.ts | 10 ++++++ .../spec/src/system/i18n-resolver.test.ts | 36 +++++++++++++++++++ packages/spec/src/system/i18n-resolver.ts | 18 ++++++++-- 5 files changed, 80 insertions(+), 3 deletions(-) diff --git a/.changeset/24798-bulk-confirm-template-slot.md b/.changeset/24798-bulk-confirm-template-slot.md index bf0d7880ca8..6434b55cb8b 100644 --- a/.changeset/24798-bulk-confirm-template-slot.md +++ b/.changeset/24798-bulk-confirm-template-slot.md @@ -7,7 +7,7 @@ feat(spec,lint): a bulk action's `confirmText` is a `template` slot over `count` and the action's params Clause-②: yes (widening) -Metadata-protocol: added +Metadata-protocol: semantic - **Before.** `bulkActionDefs[].confirmText` was a plain `z.string()` that declared no hole. A sentence such as `'Update tier on {{count}} account(s) to {{tier}}?'` parsed, nothing judged its holes, and the console showed them as literal text. - **Now.** `confirmText` is an ADR-0032 `template` slot (Decisions 2–3). A bare string is shorthand for `{ dialect: 'template', source }`, and the envelope form is accepted too. Holes are `{{ path }}` and `{{ path | formatter[:arg] }}`, with formatters from the template engine's whitelist and no logic inside a hole. They read a closed scope, and `bulkConfirmTemplateRoots(def)` (`@objectstack/spec/ui`) declares its roots: `count` (`BULK_CONFIRM_COUNT_ROOT`, the number of records the run covers) and each of the def's params by `name`. The roots are written bare: `{{ tier }}`, never `{{ params.tier }}`. That matches how the flow text slots spell their roots and how shipped sentences already spell them, and `count` wins over a param of that name. A param hole renders the param's display value: the chosen option's `label` (labels joined with `', '` for a multi-value param), the picked record's display name for a lookup or user param, and the entered value otherwise, so number, date and currency values take a formatter. @@ -15,4 +15,5 @@ Metadata-protocol: added - **Judged by the one template judge.** `objectstack validate`, `objectstack compile` and the runtime publish gate (now also on `view` writes, and on an object's own `listViews` at an object write) judge each bulk `confirmText` through `closedScopeTemplateRefusal`. That is the closed-scope entry of the judge the flow text slots use (`automation/flow-text-slot-template.ts`). A hole over an unbound root and a single-brace `{x}` token are named, the latter with its `{{ }}` spelling. A text carrying neither is then compiled as a template, which names a hole holding logic or an unknown formatter. - **A warning, not a refusal.** Each of those texts parsed while `confirmText` was a plain string, so refusing it is a narrowing that no ADR-0087 conversion carries. This release ships every finding above as a **warning**: an advisory at the publish gate, never a refusal, so no command's exit code changes. A blank `confirmText` (`''`) still parses, and the doors warn on it. - **`os i18n extract`.** A bulk `confirmText` written as the envelope is seeded exactly as the bare string: the extractor reads the slot's text through `templateSlotSource`, so the envelope form no longer records as an absent key with nothing to translate. +- **Served translations.** The catalog overlay on a served view or an object's `listViews` compares a bulk `confirmText` with its packaged base by the slot's text, whichever form either side holds: an unchanged envelope still takes the catalog's sentence, and an edited one keeps the authored sentence. - **Unchanged.** The `confirm` / `confirmmessage` near-misses still name `confirmText`. Every other bulk-def key, the default confirm sentence and the flow text slots' verdicts are unchanged. diff --git a/packages/rest/src/meta-view-container-i18n.test.ts b/packages/rest/src/meta-view-container-i18n.test.ts index 575f140240a..4a42ed12c11 100644 --- a/packages/rest/src/meta-view-container-i18n.test.ts +++ b/packages/rest/src/meta-view-container-i18n.test.ts @@ -186,6 +186,22 @@ describe('#22614 — a view container is translated as a container', () => { ]); }); + it('translates an envelope-form bulk confirmText that matches its packaged base (#24798)', async () => { + const container = shipped({ + listViews: { + bulk: grid('Bulk', { + bulkActionDefs: [{ name: 'close_all', label: 'Close all', confirmText: { dialect: 'template', source: 'Sure?' } }], + }), + }, + }); + const [packagedBulk] = expandViewContainer(OBJ, container); + const protocol = protocolOf({ [OBJ]: container, [`${OBJ}.bulk`]: packagedBulk }); + const out = await read(structuredClone(container), 'zh-CN', protocol); + expect(out.listViews.bulk.bulkActionDefs).toEqual([ + { name: 'close_all', label: '全部关闭', confirmText: '确定关闭?' }, + ]); + }); + it("looks each view's packaged base up by its qualified name, so an explicit override beats the catalog", async () => { const packaged = shipped({ list: grid('All Notes'), listViews: { mine: grid('My Notes') } }); const [packagedDefault, packagedMine] = expandViewContainer(OBJ, packaged).sort((a, b) => a.name.localeCompare(b.name)); diff --git a/packages/spec/src/system/i18n-resolver.object-list-views.test.ts b/packages/spec/src/system/i18n-resolver.object-list-views.test.ts index c00b3370664..e93ec0b0ffd 100644 --- a/packages/spec/src/system/i18n-resolver.object-list-views.test.ts +++ b/packages/spec/src/system/i18n-resolver.object-list-views.test.ts @@ -252,6 +252,16 @@ describe('translateObject — embedded listViews: description and bulk-action co expect(def(out).params[0].label).toBe('备注'); }); + it('an envelope-form confirmText is judged by its source on the embedded view too (#24798)', () => { + const envelope = (source: string) => ({ dialect: 'template', source }); + const base = clone(WITH_COPY); + def(base).confirmText = envelope(def(base).confirmText); + expect(def(translate(clone(base), base)).confirmText).toBe('确定将所选账户标记为已复核吗?'); + const edited = clone(base); + def(edited).confirmText = envelope('Review {{ count }} accounts?'); + expect(def(translate(edited, base)).confirmText).toEqual(envelope('Review {{ count }} accounts?')); + }); + it('an edited description beats the catalog; the label beside it stays translated', () => { const doc = clone(WITH_COPY); doc.listViews.mine.description = 'Our accounts'; diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 1dc3df8da70..b9a2fe575be 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -5018,6 +5018,42 @@ describe('translateView — the catalog loses to an explicit override (ADR-0029 expect(def(out, 'skip').label).toBe('跳过'); }); + // #24798: `confirmText` is a template slot, so a served view and its + // packaged base may each hold the parsed `{ dialect: 'template', source }` + // envelope. Both sides are compared by their source, never by reference. + const envelope = (source: string) => ({ dialect: 'template' as const, source }); + const withEnvelope = (view: any): any => { + const out = clone(view); + const markDone = def(out, 'mark_done'); + markDone.confirmText = envelope(markDone.confirmText); + return out; + }; + + it('an unedited envelope-form confirmText still gets the catalog (#24798)', () => { + const base = withEnvelope(PACKAGED); + const out: any = translateView(clone(base), BUNDLE, { locale: 'zh-CN', packagedBase: base }); + expect(def(out, 'mark_done').confirmText).toBe('确定将所选任务标记为完成吗?'); + }); + + it('a diverged envelope-form confirmText keeps the authored sentence (#24798)', () => { + const base = withEnvelope(PACKAGED); + const doc = clone(base); + def(doc, 'mark_done').confirmText = envelope('Close {{ count }} tasks?'); + const out: any = translateView(doc, BUNDLE, { locale: 'zh-CN', packagedBase: base }); + expect(def(out, 'mark_done').confirmText).toEqual(envelope('Close {{ count }} tasks?')); + // The other strings on the def are judged on their own and stay translated. + expect(def(out, 'mark_done').label).toBe('标记完成'); + }); + + it('a bare string and an envelope with the same source are the same sentence (#24798)', () => { + // The served side bare, the packaged side parsed — and the other way round. + const parsedBase = withEnvelope(PACKAGED); + const fromBare: any = translateView(clone(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: parsedBase }); + expect(def(fromBare, 'mark_done').confirmText).toBe('确定将所选任务标记为完成吗?'); + const fromParsed: any = translateView(withEnvelope(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED }); + expect(def(fromParsed, 'mark_done').confirmText).toBe('确定将所选任务标记为完成吗?'); + }); + it('a def or param the package does not ship was authored after the fact and keeps its strings', () => { const doc = clone(PACKAGED); doc.config.bulkActionDefs.push({ name: 'added_def', label: 'Added by the org', operation: 'update', patch: {} }); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index e21445ef8d3..2956a32125a 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -76,6 +76,7 @@ import { mapFlowNodeList } from '../conversions/walk.js'; import { flowScreenFieldOptionKey } from '../automation/flow-screen-option-key.js'; import { pageComponentSlotPositions } from '../ui/component.zod.js'; +import { templateSlotSource } from '../automation/flow-text-slot-template.js'; import type { PlatformTranslationBundle, @@ -158,7 +159,11 @@ export interface BulkActionDefLike { /** `BulkActionDefSchema.name` — the `bulkActions` key. */ name?: string; label?: string; - confirmText?: string; + /** + * A `template` slot (#24798): the bare string, or the parsed + * `{ dialect: 'template', source }` envelope a served view carries. + */ + confirmText?: string | { dialect: 'template'; source: string }; confirmLabel?: string; params?: Array; [key: string]: unknown; @@ -958,7 +963,16 @@ function translateBulkActionDefs( : lookupBulkActionText(bundle, objectName, viewKey, defName, pick, opts); const label = text(packagedDef, 'label', def.label, (n) => n.label); - const confirmText = text(packagedDef, 'confirmText', def.confirmText, (n) => n.confirmText); + // `confirmText` is a template slot (#24798): the authored side and the + // packaged side may each be a bare string or the parsed envelope, so both + // are compared by their `source` — an unchanged envelope still takes the + // catalog, and a diverged one keeps the authored sentence. + const confirmText = text( + packagedDef ? { confirmText: templateSlotSource(packagedDef.confirmText) } : undefined, + 'confirmText', + templateSlotSource(def.confirmText), + (n) => n.confirmText, + ); const confirmLabel = text(packagedDef, 'confirmLabel', def.confirmLabel, (n) => n.confirmLabel); let params = def.params;