From 5929100fa0d01bd5eb459242df4ef70bd9166219 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 13:12:11 +0000 Subject: [PATCH 1/3] feat(lint)!: refuse a bulk confirmText hole over an unbound root, a single-brace token, a hole holding logic and a blank sentence at validate, compile and the publish gate The bulk confirmText pass in validateStackExpressions now emits every finding as an error, as the flow text slots do, so `objectstack validate` and `compile` fail and the publish gate answers 422 INVALID_METADATA. The parse is unchanged: the blank-string arm stays, so a stored view keeps loading until its next save. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MrzJbTRTRRTVangBSZz6JR --- packages/lint/src/authoring-rules.ts | 19 +-- packages/lint/src/runtime-gate.test.ts | 2 +- ...date-expressions.bulk-confirm-text.test.ts | 54 ++++--- packages/lint/src/validate-expressions.ts | 42 +++--- .../protocol.runtime-authoring-gate.test.ts | 141 ++++++++++++++++++ .../test/expression-conformance.ledger.ts | 2 +- packages/spec/src/ui/bulk-action.zod.ts | 12 +- 7 files changed, 210 insertions(+), 62 deletions(-) diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 8725846c323..062d8f43fff 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -721,15 +721,16 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // `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` — 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 - // hole, a single-brace token, a hole holding logic and a blank sentence - // in `validate-expressions.test.ts`. + // it also judges the object's own `listViews`. #24798 shipped every + // finding as a `warning`, because every such sentence parsed before the + // slot was typed; #25205 makes each an `error`, so a view or object write + // carrying one is refused (a narrowing no conversion carries, shipped in + // 18.x as its own BREAKING minor under the amended 「甲」, 6303865025). + // MEASURED first over the shipped corpus at `b42c621c05` and again at + // `925fd0772c`: 6 bulk defs carry a `confirmText` (examples/app-showcase + // `project.view.ts`), none with a brace → 0 findings, against an error for + // each of an unbound hole, a single-brace token, a hole holding logic and + // a blank sentence in `validate-expressions.bulk-confirm-text.test.ts`. surfaces: CLI_AND_RUNTIME, runtimeTypes: ['flow', 'action', 'hook', 'object', 'view'], run: (stack, ctx) => diff --git a/packages/lint/src/runtime-gate.test.ts b/packages/lint/src/runtime-gate.test.ts index b58bf486dbd..c59ef8f3b38 100644 --- a/packages/lint/src/runtime-gate.test.ts +++ b/packages/lint/src/runtime-gate.test.ts @@ -328,7 +328,7 @@ describe('the views[] visibility-predicate family at the runtime publish gate (# 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. + // `confirmText` template slots, errors since #25205. 'validateStackExpressions', // #8793 — filter comparands, not predicates; registered ahead of the // family and dispatched for `view` because list-view filter rules are 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 0dcbceeeda2..93d2fa351c5 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,11 @@ // 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: 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. +// compiles it as a template. #24798 shipped every finding as a WARNING; #25205 +// makes each an ERROR (the amended 「甲」's BREAKING minor): the three doors +// refuse the text, and the publish gate answers its 422 (that door, through +// the real `saveMetaItem`, is pinned in metadata-protocol's +// `protocol.runtime-authoring-gate.test.ts`). // // The gate half drives the REAL dispatch (`runRuntimeAuthoringRules`) with the // three shapes a view write carries — the container's `list`, the flattened @@ -50,39 +52,41 @@ describe('validateStackExpressions — a bulk confirmText is judged as the templ } }); - it('warns on a hole over an unbound root, naming it and the roots the def binds', () => { + it('refuses 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!.severity).toBe('error'); 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!.source).toBe('Move {{count}} to {{ owner }}?'); }); - it('warns on a single-brace token, prescribing its `{{ }}` spelling', () => { + it('refuses a single-brace token, prescribing its `{{ }}` spelling', () => { const [issue] = confirmIssues(stackWith('Update {count} account(s) to {tier}?')); - expect(issue!.severity).toBe('warning'); + expect(issue!.severity).toBe('error'); 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', () => { + it('refuses 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); + expect(issues.every((i) => i.severity === 'error'), text).toBe(true); } }); - it('warns on a blank sentence, which the parse still accepts in 18.x', () => { + it('refuses a blank sentence, which the parse still accepts', () => { const [issue] = confirmIssues(stackWith(' ')); - expect(issue!.severity).toBe('warning'); + expect(issue!.severity).toBe('error'); expect(issue!.message).toMatch(/^A bulk action's `confirmText` is blank/); }); - it('raises no error at all: every bulk confirmText finding is a warning', () => { + it('raises no warning at all: every bulk confirmText finding is an error', () => { const stack = stackWith('{x} {{ y }} {{ count * 2 }}'); - expect(validateStackExpressions(stack).filter((i) => i.severity === 'error')).toEqual([]); + const issues = confirmIssues(stack); + expect(issues.length).toBeGreaterThan(0); + expect(issues.filter((i) => i.severity !== 'error')).toEqual([]); }); it('judges an object\'s own listViews, and a def with no confirmText not at all', () => { @@ -103,9 +107,9 @@ describe('validateStackExpressions — a bulk confirmText is judged as the templ }); }); -describe('the runtime publish gate on a view write — an advisory, never a refusal, in 18.x (#24798)', () => { +describe('the runtime publish gate on a view write — a refusal, never an advisory (#25205)', () => { const gate = (item: unknown) => runRuntimeAuthoringRules({ type: 'view', item, context: { objects } }); - const advisoriesOn = (item: unknown) => { + const findingsOn = (item: unknown) => { const { errors, advisories } = gate(item); return { errors: errors.filter((f) => f.where.includes('confirmText')), advisories: advisories.filter((f) => f.where.includes('confirmText')) }; }; @@ -116,15 +120,23 @@ describe('the runtime publish gate on a view write — an advisory, never a refu ]; 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(`refuses an unbound hole on ${label}`, () => { + const { errors, advisories } = findingsOn(item('Move {{count}} to {{ owner }}?')); + expect(advisories).toEqual([]); + expect(errors, label).toHaveLength(1); + expect(errors[0]!.message).toContain('`{{ owner }}`'); + }); + + it(`refuses a single-brace token and a blank sentence on ${label}`, () => { + for (const text of ['Update {count} account(s)?', '']) { + const { errors, advisories } = findingsOn(item(text)); + expect(advisories, JSON.stringify(text)).toEqual([]); + expect(errors, JSON.stringify(text)).toHaveLength(1); + } }); it(`control: the HotCRM sentence on ${label} draws nothing`, () => { - expect(advisoriesOn(item('Update tier on {{count}} account(s) to {{tier}}?'))).toEqual({ errors: [], advisories: [] }); + expect(findingsOn(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 b4e7ac6310b..b86ef1862f5 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -2000,8 +2000,8 @@ export interface StackExpressionOptions { * 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. On a `view` write that pass is the ONLY one, over the + * pass over the object's own `listViews` (`bulkConfirmTextIssues`), errors + * since #25205. 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. * @@ -3471,16 +3471,6 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression 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 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, 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,14 +3491,16 @@ 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` 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.`. + * [#25205] Every finding is an `error`, as the flow text slots' are: the three + * doors refuse the text, and the publish gate answers its 422. Each of these + * texts parsed before the slot was typed, so the refusal is a narrowing no + * ADR-0087 conversion carries; under the amended 「甲」 (record 6303865025) it + * ships in 18.x as its own BREAKING minor (the `validatePagePredicateBounds` + * precedent: advisory first, in #24798, refusal after). The parse is not + * narrowed: a stored view keeps loading, and its next save is judged here. + * 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, @@ -3532,11 +3524,11 @@ function bulkConfirmTextIssues( 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' }); + const refuse = (message: string, quoted: string): void => { + issues.push({ where, message, source: quoted, severity: 'error' }); }; if (BLANK_TEXT.test(confirmSource)) { - warn( + refuse( `${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.', confirmSource, @@ -3545,10 +3537,10 @@ function bulkConfirmTextIssues( } const refusal = closedScopeTemplateRefusal(confirmSource, { slot: BULK_CONFIRM_TEXT_SLOT, roots: bulkConfirmTemplateRoots(bulkDef) }); if (refusal !== undefined) { - warn(refusal, confirmSource); + refuse(refusal, confirmSource); continue; } - for (const e of validateExpression('template', confirmSource).errors) warn(e.message, e.source); + for (const e of validateExpression('template', confirmSource).errors) refuse(e.message, e.source); } }; const judgeListViews = (named: unknown, owner: string): void => { diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index 34935e51b91..ccd473a8847 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -4243,3 +4243,144 @@ describe('runtime authoring gate on OBJECT writes — a validations[] predicate } }); }); + +// #25205 — a list-view bulk action's `confirmText` is an ADR-0032 `template` +// slot over a closed scope (`count` and the def's params by name, #24798). A +// hole over an unbound root, a single-brace token, a hole holding logic and a +// blank sentence are refused at the runtime save door, as `os build` refuses +// them. #24798 shipped them as advisories (the door admitted the save and +// listed the finding); the amended 「甲」 (record 6303865025) ships the refusal +// in 18.x. Pinned through the REAL `saveMetaItem`: +// +// (a) the active save is refused — 422 `INVALID_METADATA`, the door's own +// envelope — naming the slot, and nothing lands; the draft promotion is +// refused the same way; +// (b) the door's finding IS the build's; +// (c) control: the HotCRM sentence saves, and the row lands; +// (d) the parse is not narrowed: a stored view carrying such a sentence (a +// blank one included) keeps loading, and its next save is judged. +// +// ⚠️ As above: this package reaches `@objectstack/lint` through its built +// `dist/`, so a rule edit is invisible here until +// `pnpm --filter @objectstack/lint build` has run. +describe('runtime authoring gate on VIEW writes — a bulk confirmText template hole (#25205)', () => { + const NAME = 'crm_account'; + const UNBOUND = 'Move {{count}} account(s) to {{ owner }}?'; + const HOTCRM = 'Update tier on {{count}} account(s) to {{tier}}?'; + const view = (confirmText: string) => ({ + name: NAME, + object: NAME, + list: { + type: 'grid', + columns: ['name'], + bulkActionDefs: [{ + name: 'update_tier', + label: 'Update Tier', + operation: 'update', + params: [{ name: 'tier', type: 'select', options: [{ label: 'Gold', value: 'gold' }] }], + confirmText, + }], + }, + }); + const account = { name: NAME, label: 'Account', fields: { name: { type: 'text', label: 'Name' } } }; + + function viewProtocol() { + const { engine, rows } = makeStubEngine(); + engine.registry.listItems = (type: string) => (type === 'object' ? [account] : []); + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any; + return { protocol, rows }; + } + const saveView = (protocol: any, item: unknown, extra: Record = {}) => + protocol.saveMetaItem({ type: 'view', name: NAME, item, ...extra }); + const activeViewRows = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'view' && r.state === 'active'); + const confirmIssues = (issues: any[] | undefined) => + (issues ?? []).filter((i: any) => String(i.where).includes('confirmText')); + + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + afterEach(() => { + warn.mockRestore(); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + + for (const [label, text] of [ + ['a hole over an unbound root', UNBOUND], + ['a single-brace token', 'Update {count} account(s)?'], + ['a hole holding logic', '{{ count * 2 }} account(s)?'], + ['a blank sentence', ''], + ] as const) { + it(`(a) REFUSES an active save of ${label}; nothing stored`, async () => { + const { protocol, rows } = viewProtocol(); + + const err = await saveView(protocol, view(text)).catch((e: any) => e); + + expect(err, 'the save resolved — the door still admits the sentence').toBeInstanceOf(Error); + expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const issues = confirmIssues(err.issues); + expect(issues, `issues: ${JSON.stringify(err.issues)}`).toHaveLength(1); + expect(issues[0]).toMatchObject({ rule: EXPRESSION_INVALID, severity: 'error' }); + expect(activeViewRows(rows)).toEqual([]); + }); + } + + it('(a) gates the draft → active promotion of the same view', async () => { + const { protocol, rows } = viewProtocol(); + await expect(saveView(protocol, view(UNBOUND), { mode: 'draft' })).resolves.toMatchObject({ success: true }); + + const err = await protocol.publishMetaItem({ type: 'view', name: NAME }).catch((e: any) => e); + + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(confirmIssues(err.issues)).toHaveLength(1); + expect(activeViewRows(rows)).toEqual([]); + }); + + it('(b) the door and `os build` give the SAME finding', async () => { + const { protocol } = viewProtocol(); + const err = await saveView(protocol, view(UNBOUND)).catch((e: any) => e); + const atDoor = confirmIssues(err?.issues); + + const stack = { objects: [account], views: [view(UNBOUND)] }; + const atBuild = confirmIssues(runAuthoringRules('build', { normalized: stack, parsed: stack }) + .filter((f) => f.severity === 'error')); + + expect(atBuild).toHaveLength(1); + expect(atDoor).toHaveLength(1); + for (const k of ['rule', 'where', 'message'] as const) { + expect(atDoor[0][k], `door and build disagree on '${k}'`).toBe(atBuild[0]![k]); + } + }); + + it('(c) CONTROL — the HotCRM sentence saves, and the row lands', async () => { + const { protocol, rows } = viewProtocol(); + + const res = await saveView(protocol, view(HOTCRM)); + + expect(res).toMatchObject({ success: true }); + expect(confirmIssues(res.advisories)).toEqual([]); + expect(activeViewRows(rows)).toHaveLength(1); + }); + + for (const [label, text] of [['an unbound hole', UNBOUND], ['a blank sentence', '']] as const) { + it(`(d) a stored view with ${label} keeps loading; its next save is refused, the row untouched`, async () => { + const { protocol, rows } = viewProtocol(); + // Stored before the crossing, seeded straight into the store because + // the save door now refuses this body. + const stored = view(text); + rows.set(keyOf({ type: 'view', name: NAME, organization_id: null, state: 'active' }), { + id: 'r_seed', type: 'view', name: NAME, organization_id: null, state: 'active', + metadata: JSON.stringify(stored), + }); + + // The boot path runs no authoring rule, and the parse still accepts the text. + await expect(protocol.loadMetaFromDb()).resolves.toMatchObject({ loaded: 1, errors: 0, invalid: 0 }); + + const err = await saveView(protocol, stored).catch((e: any) => e); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(JSON.parse(activeViewRows(rows)[0]!.metadata)).toEqual(stored); + }); + } +}); diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 0e88e1698e2..095e8641942 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -634,7 +634,7 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ 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', + '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 an ERROR (#25205): `objectstack validate` / `compile` fail and the publish gate answers 422, while the parse still accepts what it accepted (a blank string included), so a stored view keeps loading until its next save', 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.', }, diff --git a/packages/spec/src/ui/bulk-action.zod.ts b/packages/spec/src/ui/bulk-action.zod.ts index 49e805485ab..56e3f56d6f1 100644 --- a/packages/spec/src/ui/bulk-action.zod.ts +++ b/packages/spec/src/ui/bulk-action.zod.ts @@ -374,10 +374,12 @@ export function bulkConfirmTemplateRoots(def: { readonly params?: unknown }): st * 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 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. + * itself. `confirmText` was a plain `z.string()`, so `''` passed. The doors + * refuse it (#25205), with the hole findings, the same way and in the same + * release: every one of them is a door verdict over text that parses. The arm + * stays so the parse does not narrow: the boot load and the read path judge a + * stored view with this parse, and a stored blank sentence keeps loading and + * serving until its next save, which the doors judge. */ 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 +467,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, as a warning."), + 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 refused by name at `objectstack validate`, `compile` and the publish gate, as are a hole holding logic or an unknown formatter and a blank sentence."), 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 e644a80a207f6ecdf37a8bec2dc9ca769c14bed2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 13:19:44 +0000 Subject: [PATCH 2/3] docs(spec): regenerate the bulk confirmText references, and add the BREAKING minor changeset with its ADR-0087 disposition Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MrzJbTRTRRTVangBSZz6JR --- .changeset/25205-bulk-confirm-text-refusal.md | 28 +++++++++++++++++++ content/docs/references/ui/bulk-action.mdx | 2 +- content/docs/references/ui/component.mdx | 2 +- content/docs/references/ui/view.mdx | 4 +-- 4 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 .changeset/25205-bulk-confirm-text-refusal.md diff --git a/.changeset/25205-bulk-confirm-text-refusal.md b/.changeset/25205-bulk-confirm-text-refusal.md new file mode 100644 index 00000000000..fb2ca1aa3d9 --- /dev/null +++ b/.changeset/25205-bulk-confirm-text-refusal.md @@ -0,0 +1,28 @@ +--- +'@objectstack/lint': minor +'@objectstack/spec': minor +--- + +feat(lint)!: `objectstack validate`, `compile` and the publish gate refuse a bulk action's `confirmText` hole over an unbound root, a single-brace token, a hole holding logic and a blank sentence (#25205) + +Clause-②: no (narrowing: a bulk action's `confirmText` that names an unbound root, carries a single-brace token, holds logic in a hole or is blank is refused at `objectstack validate`, `compile` and the publish gate; it ships now under the amended 「甲」, #6015 `6303865025`) +Metadata-protocol: narrowed + +A list-view bulk action's `confirmText` is an ADR-0032 `template` slot over a closed scope, `count` and the def's params by name (#24798). `validateStackExpressions`, the rule all three doors run, judges each one through `closedScopeTemplateRefusal`, then compiles it as a template. Its findings were warnings. They are now errors, as the flow text slots' findings are. This supersedes the warning-only behaviour described for the same pass earlier in this release line; the finding no longer ends with "A warning, not a refusal". + +**BREAKING — what moves for consumers.** + +- FROM: a warning at `objectstack validate` and `compile`, and an entry on the publish gate's `advisories` channel while the view or object saved. TO: an error. Both commands fail, and an active `view` or `object` write answers `422 INVALID_METADATA` with an `expression-invalid` issue located at `bulkActionDefs[N] confirmText`. The item is not stored. This covers `saveMetaItem` in publish mode (Studio, REST `/meta`, MCP) and the promotion of a draft (`publishMetaItem`). +- The four refused texts: a hole over a root the def does not bind (`{{ owner }}` when the def binds `count` and `tier`), a single-brace `{x}` token, a hole holding logic or naming an unknown formatter (`{{ count * 2 }}`, `{{ count | shout }}`), and a blank sentence (`''`). + +**Fix (one line).** Write the hole over `count` or one of the def's params (`{{ count }}`, `{{ tier }}`), double a single-brace token as the finding prescribes, move logic out of the hole, or remove a blank `confirmText` so the default sentence shows. + +**Unchanged.** + +- The parse. `bulkActionDefs[].confirmText` accepts what it accepted, a blank string included (its second union arm stays). The boot load and the read path judge a stored view with this parse, so a stored view carrying such a sentence keeps loading and serving until it is next saved, and that save is judged. Saving as a draft is still allowed; publishing the draft is judged. +- A sentence with no finding draws nothing at any door, in either form (the bare string or the `{ dialect: 'template', source }` envelope). +- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. +- No public export, signature or schema shape moves. The `confirmText` describe now says the texts are refused. +- Measured before crossing, through the built `@objectstack/lint` at `925fd0772c`: 0 in-repo producers carry such a sentence. The 6 bulk defs with a `confirmText` in `examples/app-showcase` (`project.view.ts`) draw 0 findings, against 1 for a planted unbound hole. HotCRM at `1e71d5e` declares 3 bulk `confirmText` sentences on `account.view.ts` (`{{count}}`, and `{{tier}}` on the def whose param is `tier`), all over bound roots: 0 findings. Its other 9 of the 12 are translations of those 3 in its locale bundles, which this pass does not read. + + diff --git a/content/docs/references/ui/bulk-action.mdx b/content/docs/references/ui/bulk-action.mdx index 067a0e1abe2..17e5c3e32e3 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, as a warning. | +| **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 refused by name at `objectstack validate`, `compile` and the publish gate, as are a hole holding logic or an unknown formatter and a blank sentence. | | **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 10f481ec641..d69833958ae 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, as a warning. | +| **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 refused by name at `objectstack validate`, `compile` and the publish gate, as are a hole holding logic or an unknown formatter and a blank sentence. | | **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 a3f5203fbf8..0f9e38ce50a 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 \| { 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. | +| **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 refused by name at `objectstack validate`, `compile` and the publish gate, as are a hole holding logic or an unknown formatter and a blank sentence. | | **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 \| { 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. | +| **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 refused by name at `objectstack validate`, `compile` and the publish gate, as are a hole holding logic or an unknown formatter and a blank sentence. | | **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. | From 6a4c06637b95c94871cec630475fc6c7ebaa5473 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 14:06:24 +0000 Subject: [PATCH 3/3] test(dogfood): keep the tracker id out of the bulk confirmText ledger row's prose Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MrzJbTRTRRTVangBSZz6JR --- packages/qa/dogfood/test/expression-conformance.ledger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 095e8641942..ce3ae3a284d 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -634,7 +634,7 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ 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 an ERROR (#25205): `objectstack validate` / `compile` fail and the publish gate answers 422, while the parse still accepts what it accepted (a blank string included), so a stored view keeps loading until its next save', + '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 an ERROR: `objectstack validate` / `compile` fail and the publish gate answers 422, while the parse still accepts what it accepted (a blank string included), so a stored view keeps loading until its next save', 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.', },