From 40a95a587b5cf084d1862f4030d9eb9d643be835 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:05:53 +0000 Subject: [PATCH 1/5] fix(spec): a notify title/message refusal prescribes the single-brace spelling its renderer reads The shared template input's refusals prescribe '{{record.name}}', which the notify executor's flow interpolator leaves inside a stray pair of braces and the build's flow-double-brace-interpolation rule flags. The notify slots are now built with templateExpressionInput and refuse with sentences prescribing '{record.name}', kept in one constant; every other template slot keeps its sentence. The tmpl and TemplateExpressionInputSchema docblocks say which renderers read which braces. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../spec/src/automation/io-node-config.zod.ts | 89 +++++++++--- packages/spec/src/shared/expression.zod.ts | 131 ++++++++++++++---- 2 files changed, 176 insertions(+), 44 deletions(-) diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index ec3eb1259e9..7eeecaaed60 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -54,7 +54,7 @@ */ import { z } from 'zod'; -import { TemplateExpressionInputSchema } from '../shared/expression.zod'; +import { templateExpressionInput } from '../shared/expression.zod'; import { lazySchema } from '../shared/lazy-schema'; import { NON_BLANK_STRING } from '../shared/refinement-projection'; import { strictObject } from '../shared/strict-object'; @@ -117,12 +117,60 @@ const NOTIFY_KEY_GUIDANCE: Readonly> = { + 'dead link.', }; +/** + * The placeholder every notify `title` / `message` refusal prescribes — the + * ONE place it is written. The notify executor renders both slots with the + * flow's `interpolate()`, which substitutes single-brace `{token}` only (a + * `{{var}}` keeps its outer braces), and the build's + * `flow-double-brace-interpolation` rule flags a doubled brace on a flow node + * value; so this is the spelling both read today. The shared + * `TemplateExpressionInputSchema` prescribes `{{record.name}}` instead, which + * is why these two slots are built with `templateExpressionInput` and carry + * the sentences below (#22081). + * + * The 17.x prescription, ⛔ not an end-state ruling on braces: executing + * ADR-0032 D3 on the v18 line (#22110) flips the notify convention, and this + * constant is the prescription that flips with it. + */ +const NOTIFY_TEMPLATE_PLACEHOLDER = '{record.name}'; + +/** + * Why the notify prescription is single-brace, in the words every notify + * template refusal ends on. Spelled without a doubled brace, so the refusal + * carries no spelling the build would flag. + */ +const NOTIFY_TEMPLATE_RENDERER = + 'the notify executor interpolates single-brace `{token}` placeholders, and a doubled brace is not one — ' + + 'its inner `{token}` resolves and the outer braces stay in the sent text.'; + +/** + * The two sentences a notify `title` / `message` refuses a malformed value + * with — the shared template input's refusals (a blank bare string; a value + * that is neither a string nor a `template` envelope), naming the key and + * prescribing {@link NOTIFY_TEMPLATE_PLACEHOLDER}. + */ +function notifyTemplateRefusals(key: 'title' | 'message'): { sourceRequired: string; dialectOnly: string } { + const write = + `Write \`'${NOTIFY_TEMPLATE_PLACEHOLDER}'\` or \`{ dialect: 'template', source: '${NOTIFY_TEMPLATE_PLACEHOLDER}' }\`: ` + + NOTIFY_TEMPLATE_RENDERER; + return { + sourceRequired: + `A notify node's \`${key}\` needs a non-blank template: a bare string is shorthand for ` + + '`{ dialect: \'template\', source }`, and a blank one would normalize to an envelope with nothing to ' + + `interpolate. ${write}`, + dialectOnly: + `A notify node's \`${key}\` accepts a bare template string or an envelope declaring \`dialect: 'template'\` ` + + 'only: an envelope naming another dialect would validate and then have nothing to interpolate. ' + + write, + }; +} + /** * The refusal for a `title` / `message` template envelope that carries no * non-blank `source` — what the slot's executor renders. It names the key and - * the fix, and prescribes the slot's own placeholder spelling (`{token}`), not - * the `{{var}}` the shared template prose shows: this slot's renderer is the - * flow's `interpolate()`. + * the fix, and prescribes the slot's own placeholder spelling + * ({@link NOTIFY_TEMPLATE_PLACEHOLDER}), not the `{{var}}` the shared template + * prose shows: this slot's renderer is the flow's `interpolate()`. */ function notifyTemplateSourceRequired(key: 'title' | 'message'): string { const consequence = key === 'title' @@ -132,7 +180,7 @@ function notifyTemplateSourceRequired(key: 'title' | 'message'): string { `\`${key}\` is a template envelope with no non-blank \`source\`. The notify executor renders \`source\` — ` + 'interpolating its `{token}` placeholders per run — and has nothing to render from `ast` alone or from a ' + `blank \`source\`, so ${consequence}. Put the text in \`source\` ` - + `(\`{ dialect: 'template', source: 'Deal {record.name} won' }\`), or write it as a bare string.` + + `(\`{ dialect: 'template', source: 'Deal ${NOTIFY_TEMPLATE_PLACEHOLDER} won' }\`), or write it as a bare string.` ); } @@ -189,16 +237,19 @@ function notifyTemplateSourceRequired(key: 'title' | 'message'): string { * `notify-node.ts` for #7086: the previous wording ("every string-ish value * except `channels`") was stale for `topic` and `severity`, and it is what * makes closing the `severity` gate below safe. - * - `title` and `message` are TEMPLATE slots, typed with - * `TemplateExpressionInputSchema` like every other `template` slot in the - * dialect table (`shared/expression.zod.ts`): a bare string, or a - * `{ dialect: 'template', source }` envelope — what the `tmpl` helper - * builds. The parse normalizes the bare string to that envelope, so the - * executor reads one shape and interpolates its `source`; both spellings of - * one text render the same notification. The renderer here is the flow's - * `interpolate()`, so the placeholder spelling is its single-brace - * `{token}` (`{record.name}`). A `{{var}}` is not a placeholder in these two - * slots: the inner `{var}` resolves and the outer braces stay in the text. + * - `title` and `message` are TEMPLATE slots, taking the same input as every + * other `template` slot in the dialect table (`shared/expression.zod.ts`): + * a bare string, or a `{ dialect: 'template', source }` envelope — what the + * `tmpl` helper builds. The parse normalizes the bare string to that + * envelope, so the executor reads one shape and interpolates its `source`; + * both spellings of one text render the same notification. The renderer + * here is the flow's `interpolate()`, so the placeholder spelling is its + * single-brace `{token}` (`{record.name}`). A `{{var}}` is not a placeholder + * in these two slots: the inner `{var}` resolves and the outer braces stay + * in the text. So the input is built with `templateExpressionInput` rather + * than taken as `TemplateExpressionInputSchema`, whose refusals prescribe + * `{{record.name}}`: a malformed value here is refused with sentences that + * prescribe `{record.name}` ({@link NOTIFY_TEMPLATE_PLACEHOLDER}). * An envelope must carry a non-blank `source` (the `superRefine` below) — * the executor renders `source` and has nothing to render from `ast` alone, * which the shared schema's envelope arm would otherwise admit. @@ -224,10 +275,10 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ * template slot (see the docblock above). Required unless `template` is set * (the superRefine below owes one of the two). */ - title: TemplateExpressionInputSchema.optional() + title: templateExpressionInput(notifyTemplateRefusals('title')).optional() .describe('Notification title — a template: a bare string, or a `{ dialect: \'template\', source }` envelope (the `tmpl` helper) carrying the same text. It is interpolated per run with the flow\'s single-brace `{token}` placeholders (`{record.name}`); a `{{var}}` is not a placeholder here — its inner `{var}` resolves and the outer braces stay in the text. One text for every recipient (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive.'), /** Notification body (inline path only) — the same template input as `title`. */ - message: TemplateExpressionInputSchema.optional() + message: templateExpressionInput(notifyTemplateRefusals('message')).optional() .describe('Notification body — the same template input as `title` (a bare string or a `{ dialect: \'template\', source }` envelope), interpolated per run with single-brace `{token}` placeholders; not localizable. Only valid with inline `title`, never with `template`.'), /** * The localizable content path (#9205): name of a `sys_email_template` @@ -338,8 +389,8 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ }); } // The two template slots render `source` (see the docblock): the executor - // interpolates it per run and has no renderer for `ast`. The shared - // `TemplateExpressionInputSchema` is the persistence contract, so its + // interpolates it per run and has no renderer for `ast`. The shared template + // input (`templateExpressionInput`) is the persistence contract, so its // envelope arm admits an `ast`-only envelope and a whitespace `source` — // shapes that parse and then render nothing (a `title` failing every run, a // `message` going out empty). The slot states what its executor needs diff --git a/packages/spec/src/shared/expression.zod.ts b/packages/spec/src/shared/expression.zod.ts index c8dba102ed8..6667c300bd9 100644 --- a/packages/spec/src/shared/expression.zod.ts +++ b/packages/spec/src/shared/expression.zod.ts @@ -33,10 +33,12 @@ import { NON_BLANK_STRING, requiredOneOf } from './refinement-projection'; * registered `cron` engine has no caller outside that package. * * A TYPED slot — one declared with `CronExpressionInputSchema` or - * `TemplateExpressionInputSchema` — takes a bare, non-blank string (shorthand - * for its own dialect) or an envelope declaring that one dialect. An envelope - * naming any other dialect, and a blank string, are refused at the slot with - * one issue whose message names the dialect and the fix. Only the untyped + * `TemplateExpressionInputSchema`, or built with `templateExpressionInput` — + * takes a bare, non-blank string (shorthand for its own dialect) or an + * envelope declaring that one dialect. An envelope naming any other dialect, + * and a blank string, are refused at the slot with one issue whose message + * names the dialect and the fix; a template slot's fix is written in the + * placeholder spelling its renderer reads. Only the untyped * `ExpressionInputSchema` takes every declared dialect in envelope form. * * Those three are the whole list — it is exactly the `ExpressionDialect` enum @@ -282,6 +284,14 @@ export type TypedExpressionDialect = Extract> = { cron: @@ -299,6 +309,8 @@ export const TYPED_EXPRESSION_SOURCE_REQUIRED: Readonly> = { cron: @@ -334,18 +346,38 @@ export const TYPED_EXPRESSION_DIALECT_ONLY: Readonly(dialect: D) { +function typedExpressionStringArm(dialect: D, refusals: TypedExpressionRefusals) { return z.string() - .refine(NON_BLANK_STRING, { message: TYPED_EXPRESSION_SOURCE_REQUIRED[dialect] }) + .refine(NON_BLANK_STRING, { message: refusals.sourceRequired }) .transform((source) => ({ dialect, source })); } -function typedExpressionUnionParams(dialect: TypedExpressionDialect): { error: (issue: { input?: unknown }) => string } { +function typedExpressionUnionParams(refusals: TypedExpressionRefusals): { error: (issue: { input?: unknown }) => string } { + return { + error: (issue) => (typeof issue.input === 'string' ? refusals.sourceRequired : refusals.dialectOnly), + }; +} + +/** The two sentences one typed slot refuses with — see the two records above. */ +interface TypedExpressionRefusals { + /** Refuses a blank bare string. */ + readonly sourceRequired: string; + /** Refuses a foreign-dialect envelope and any value that is neither a string nor an envelope. */ + readonly dialectOnly: string; +} + +/** The dialect's own sentences, from the two records above. */ +function typedExpressionRefusals(dialect: TypedExpressionDialect): TypedExpressionRefusals { return { - error: (issue) => (typeof issue.input === 'string' - ? TYPED_EXPRESSION_SOURCE_REQUIRED[dialect] - : TYPED_EXPRESSION_DIALECT_ONLY[dialect]), + sourceRequired: TYPED_EXPRESSION_SOURCE_REQUIRED[dialect], + dialectOnly: TYPED_EXPRESSION_DIALECT_ONLY[dialect], }; } @@ -368,19 +400,49 @@ function typedExpressionUnionParams(dialect: TypedExpressionDialect): { error: ( * reaches no engine, and no grammar is restated here. */ export const CronExpressionInputSchema = z.union([ - typedExpressionStringArm('cron'), + typedExpressionStringArm('cron', typedExpressionRefusals('cron')), ExpressionSchema.safeExtend({ dialect: z.literal('cron') }), -], typedExpressionUnionParams('cron')); +], typedExpressionUnionParams(typedExpressionRefusals('cron'))); export type CronExpressionInput = z.input; +/** + * The template-typed input, refusing with the sentences it is given — the one + * constructor of {@link TemplateExpressionInputSchema} and of every template + * slot whose renderer reads a placeholder spelling other than `{{var}}`. + * + * The accept set is identical whatever the sentences: a bare, non-blank string + * (shorthand for `{ dialect: 'template', source }`) or an envelope declaring + * `dialect: 'template'`. Only the refusal text moves, because a refusal is the + * one place an author is told exactly what to write, and it must prescribe the + * spelling the slot's renderer reads — {@link TYPED_EXPRESSION_SOURCE_REQUIRED} + * prescribes `{{record.name}}`, which a single-brace renderer would leave + * inside a stray pair of braces. Pass `sourceRequired` for a blank bare string + * and `dialectOnly` for everything else, each ending in the slot's own + * prescription. + * + * Declared as a `const` arrow, not a `function`: the expression-surface census + * (`packages/qa/dogfood/test/expression-conformance.test.ts`) reads this name + * as a roster member, and a `const` binding is the roster-definition shape it + * skips. + */ +export const templateExpressionInput = (refusals: { + readonly sourceRequired: string; + readonly dialectOnly: string; +}) => z.union([ + typedExpressionStringArm('template', refusals), + ExpressionSchema.safeExtend({ dialect: z.literal('template') }), +], typedExpressionUnionParams(refusals)); + /** * Template-typed input shape: a bare, non-blank string is shorthand for * `{ dialect: 'template', source }`, and an envelope must declare * `dialect: 'template'` — a `cel` or `cron` envelope is refused at the slot, * naming the fix (`TYPED_EXPRESSION_DIALECT_ONLY.template`), as is a blank * string (`TYPED_EXPRESSION_SOURCE_REQUIRED.template`). Use this for - * notification subjects/bodies, titleFormat, prompt templates — anything with - * placeholder interpolation. + * titleFormat, prompt templates, and any template slot whose renderer reads + * `{{var}}`; a slot whose renderer reads another spelling takes the same input + * from {@link templateExpressionInput}, with refusals prescribing its own + * spelling (a notify node's `title` / `message` do — see the list below). * * No template syntax is judged at parse time: this schema judges the dialect * tag and non-emptiness and nothing else, so it declares no placeholder @@ -398,14 +460,19 @@ export type CronExpressionInput = z.input; * `@objectstack/metadata-protocol`). Both spellings resolve identically * there — single-brace `titleFormat` values are legal by construction, not * a grammar this schema failed to enforce. - * - * So write `{{var}}` unless the slot's renderer is known to normalize, and do - * not read either spelling as declared, preferred or rejected here. + * - A notify flow node's `title` / `message` read the other way: their + * renderer is the flow interpolator (`interpolate()` in + * `@objectstack/service-automation`), which substitutes single-brace `{var}` + * only — a `{{var}}` keeps its outer braces in the sent text, and the + * build's `flow-double-brace-interpolation` rule flags it on a flow node. + * Those two slots are built with {@link templateExpressionInput}, so their + * refusals prescribe `{record.name}` instead of this schema's `{{record.name}}`. + * + * So write the spelling the slot's renderer reads — `{{var}}` on this schema's + * slots, `{var}` on a notify node's — and do not read either spelling as + * declared, preferred or rejected here. */ -export const TemplateExpressionInputSchema = z.union([ - typedExpressionStringArm('template'), - ExpressionSchema.safeExtend({ dialect: z.literal('template') }), -], typedExpressionUnionParams('template')); +export const TemplateExpressionInputSchema = templateExpressionInput(typedExpressionRefusals('template')); export type TemplateExpressionInput = z.input; /** @@ -500,13 +567,27 @@ export const F = cel; export const P = cel; /** - * Tagged template — produces a Mustache-template Expression envelope. Use for - * notification subjects, prompt bodies, titleFormat strings, etc. Variable - * scope is the same as CEL (`{{record.x}}`, `{{os.user.id}}`). + * Tagged template — produces a `template`-dialect Expression envelope. Use for + * notification subjects and bodies, prompt bodies, titleFormat strings, etc. + * Variable scope is the same as CEL (`record.x`, `os.user.id`). + * + * It interpolates nothing and judges no placeholder spelling: the renderer + * that consumes the slot does, and the renderers do not read the same braces. + * Write the spelling the slot's renderer reads: + * + * - `{{record.x}}` — the `@objectstack/formula` template engine and the + * messaging, email and i18n renderers read double braces only, and leave a + * `{record.x}` in their output verbatim; + * - `{record.x}` — a notify flow node's `title` / `message` are rendered by the + * flow interpolator, which reads single braces only: a `{{record.x}}` keeps + * its outer braces in the sent text, and the build's + * `flow-double-brace-interpolation` rule flags it; + * - either — `titleFormat`'s renderers normalize `{{record.x}}` to `{record.x}`. */ export function tmpl(strings: TemplateStringsArray, ...values: unknown[]): EvaluatedExpression { // Templates do not get JSON.stringify on substitution — interpolation happens - // at evaluate time via `{{path}}` markers, so we keep raw substitutions here. + // at evaluate time, by the consuming renderer's placeholder markers (see the + // docblock), so we keep raw substitutions here. let out = strings[0] ?? ''; for (let i = 0; i < values.length; i++) { out += String(values[i]); From e4b3c7f8df9d87da3704e6347e583540a920b0a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:09:07 +0000 Subject: [PATCH 2/5] test(spec): pin the notify refusal's single-brace prescription, its lint round trip and the shared control Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../22081-notify-refusal-single-brace.md | 13 +++ packages/lint/src/lint-flow-patterns.test.ts | 71 +++++++++++++++- .../test/expression-conformance.test.ts | 8 ++ .../src/automation/io-node-config.test.ts | 83 ++++++++++++++++--- .../expression-dialect-docs.pin.test.ts | 47 +++++++++++ .../typed-expression-envelope-dialect.test.ts | 79 ++++++++++++++++++ 6 files changed, 290 insertions(+), 11 deletions(-) create mode 100644 .changeset/22081-notify-refusal-single-brace.md diff --git a/.changeset/22081-notify-refusal-single-brace.md b/.changeset/22081-notify-refusal-single-brace.md new file mode 100644 index 00000000000..a2e9a46e26f --- /dev/null +++ b/.changeset/22081-notify-refusal-single-brace.md @@ -0,0 +1,13 @@ +--- +"@objectstack/spec": patch +--- + +A notify flow node's `title` / `message` refusal now prescribes `'{record.name}'`, the single-brace spelling the notify executor reads. It used to prescribe the shared template sentence's `'{{record.name}}'`, which the build's `flow-double-brace-interpolation` rule then flagged on the same node and the notify renderer sent inside a stray pair of braces. + +Clause-②: no + +- Both slots take the same input as before: a bare, non-blank string or a `{ dialect: 'template', source }` envelope. Every value that parsed still parses, every value that was refused is still refused, with the same `invalid_union` code at the same path. Only the sentence changes. +- A blank bare string, a number, a non-template envelope and the like at `title` or `message` are refused with a sentence that names the key and ends `Write '{record.name}' or { dialect: 'template', source: '{record.name}' }`, and says why: the notify executor interpolates single-brace `{token}` placeholders, and a doubled brace keeps its outer braces in the sent text. The branch issue that `formatZodIssue` and the API error mapper print beneath it carries the same sentence, so no `{{…}}` prescription reaches the author on these two keys. The build's flow judge (`FlowSchema`, flow registration, `os validate`) quotes the new sentence. +- Every other template slot keeps its sentence, which still prescribes `'{{record.name}}'`. That covers `titleFormat`, the prompt template's `system` / `user`, and any slot typed `TemplateExpressionInputSchema`. `TYPED_EXPRESSION_SOURCE_REQUIRED.template` and `TYPED_EXPRESSION_DIALECT_ONLY.template` are unchanged. +- New export on `@objectstack/spec/shared`: `templateExpressionInput({ sourceRequired, dialectOnly })`. It builds the same template input as `TemplateExpressionInputSchema` but refuses with the sentences you give it, for a template slot whose renderer reads another placeholder spelling. `TemplateExpressionInputSchema` is now built with it and keeps its shared sentences. +- The `tmpl` docblock no longer calls the envelope "Mustache" or shows only `{{record.x}}`. It now says which renderers read which braces: `{{record.x}}` for the formula template engine and the messaging, email and i18n renderers, `{record.x}` for a notify node's `title` / `message`, and either for `titleFormat`. The `TemplateExpressionInputSchema` docblock and the generated expression reference page list the notify slots the same way. diff --git a/packages/lint/src/lint-flow-patterns.test.ts b/packages/lint/src/lint-flow-patterns.test.ts index 3beb907f3e2..0515327c7fe 100644 --- a/packages/lint/src/lint-flow-patterns.test.ts +++ b/packages/lint/src/lint-flow-patterns.test.ts @@ -1,7 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { TimeRelativeTriggerSchema, LoopConfigSchema, ParallelConfigSchema, TryCatchConfigSchema, HttpConfigSchema, FlowSchema } from '@objectstack/spec/automation'; +import { TimeRelativeTriggerSchema, LoopConfigSchema, ParallelConfigSchema, TryCatchConfigSchema, HttpConfigSchema, FlowSchema, NotifyConfigSchema } from '@objectstack/spec/automation'; +import { TYPED_EXPRESSION_DIALECT_ONLY, TYPED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec/shared'; // [#5659] The shared identity reduction, asserted beside the rule that consumes // it — the rule's verdict and the drivers' verdict are one object now. import { reduceFilterVerdict } from '@objectstack/spec/data'; @@ -2417,6 +2418,74 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule .filter((f) => f.rule === FLOW_DOUBLE_BRACE_INTERP); expect(fnds).toHaveLength(1); }); + + /** + * [#22081] The round trip an author makes after a refusal: write what it + * prescribes, then build. A notify node's `title` / `message` refusal + * (`NotifyConfigSchema`, `@objectstack/spec`) used to prescribe the shared + * template input's `{{record.name}}` — which this rule then flagged on the + * same node. Every spelling the refusal prescribes, read out of the refusal + * text itself rather than re-spelled here, must parse and draw no finding. + * The control is the shared sentence the notify slots used to answer with: + * its prescriptions still draw the finding, so this pin can fail. + */ + describe('a notify slot refusal prescribes only spellings this rule passes', () => { + function notifyFlow(key: 'title' | 'message', value: unknown) { + return { + flows: [{ + name: 'deal_won_notice', + label: 'Deal won notice', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'tell', type: 'notify', label: 'Tell owner', config: { recipients: ['u1'], title: 'Deal won', [key]: value } }, + { id: 'done', type: 'end', label: 'Done' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'tell' }, { id: 'e2', source: 'tell', target: 'done' }], + }], + }; + } + + /** The two spellings a template refusal prescribes — "Write `'X'` or `{ dialect: 'template', source: 'X' }`". */ + function prescribedIn(refusal: string): unknown[] { + const bare = /Write `'([^`']+)'`/.exec(refusal)?.[1]; + const envelope = /`\{ dialect: 'template', source: '([^`']+)' \}`/.exec(refusal)?.[1]; + return [bare, envelope === undefined ? undefined : { dialect: 'template', source: envelope }]; + } + + function doubleBraceFindings(key: 'title' | 'message', value: unknown) { + return lintFlowPatterns(notifyFlow(key, value)).filter((f) => f.rule === FLOW_DOUBLE_BRACE_INTERP); + } + + it.each(['title', 'message'] as const)('`%s`: each prescribed spelling parses and draws no `flow-double-brace-interpolation`', (key) => { + for (const refused of [' ', 42]) { + const parsed = NotifyConfigSchema.safeParse({ recipients: ['u1'], title: 'Deal won', [key]: refused }); + const refusal = parsed.success ? undefined : parsed.error.issues.find((i) => i.path[0] === key); + expect(refusal?.code, `${key} = ${JSON.stringify(refused)}`).toBe('invalid_union'); + const prescribed = prescribedIn(refusal!.message); + expect(prescribed.every((p) => p !== undefined), `no prescription read out of: ${refusal!.message}`).toBe(true); + for (const spelling of prescribed) { + const label = `${key} = ${JSON.stringify(spelling)}`; + const flow = notifyFlow(key, spelling).flows[0]; + const config = NotifyConfigSchema.safeParse(flow.nodes[1]!.config); + expect(config.success, `${label}: ${JSON.stringify(config.error?.issues)}`).toBe(true); + const whole = FlowSchema.safeParse(flow); + expect(whole.success, `${label}: ${JSON.stringify(whole.error?.issues)}`).toBe(true); + expect(doubleBraceFindings(key, spelling), label).toEqual([]); + } + } + }); + + it('control: the shared template sentence\'s prescriptions draw the finding on a notify node', () => { + for (const sentence of [TYPED_EXPRESSION_SOURCE_REQUIRED.template, TYPED_EXPRESSION_DIALECT_ONLY.template]) { + const prescribed = prescribedIn(sentence); + expect(prescribed.every((p) => p !== undefined), sentence).toBe(true); + for (const spelling of prescribed) { + expect(doubleBraceFindings('title', spelling), JSON.stringify(spelling)).toHaveLength(1); + } + } + }); + }); }); describe('flow-bare-dollar-reference', () => { diff --git a/packages/qa/dogfood/test/expression-conformance.test.ts b/packages/qa/dogfood/test/expression-conformance.test.ts index 3d089bb228d..0be8b143af2 100644 --- a/packages/qa/dogfood/test/expression-conformance.test.ts +++ b/packages/qa/dogfood/test/expression-conformance.test.ts @@ -105,6 +105,14 @@ const EXPRESSION_INPUT_SCHEMAS = [ 'SettingsVisibilityInputSchema', 'CronExpressionInputSchema', 'TemplateExpressionInputSchema', + // [#22081] The constructor `TemplateExpressionInputSchema` is built with: the + // same template input, refusing with the slot's own sentences. A notify + // node's `title` / `message` are typed with a call of it (their refusals + // prescribe the flow interpolator's `{record.name}`), so without this row + // both positions drop out of discovery and `template-notify-content` goes + // STALE — #7327's shape again. It is declared as a `const` arrow, which is + // the roster-definition shape `LOCAL_BINDING` skips. + 'templateExpressionInput', ]; /** * A roster (or alias) name as an IDENTIFIER, anywhere on the line — #17630. diff --git a/packages/spec/src/automation/io-node-config.test.ts b/packages/spec/src/automation/io-node-config.test.ts index a6d6dc0c1da..ac5f1bd39e6 100644 --- a/packages/spec/src/automation/io-node-config.test.ts +++ b/packages/spec/src/automation/io-node-config.test.ts @@ -18,6 +18,7 @@ import { describe, expect, it } from 'vitest'; import { HttpConfigSchema, NotifyConfigSchema } from './io-node-config.zod.js'; +import { flowNodeConfigRefusals } from './flow-node-config-refusals.js'; import { TYPED_EXPRESSION_DIALECT_ONLY, TYPED_EXPRESSION_SOURCE_REQUIRED, @@ -408,26 +409,88 @@ describe('NotifyConfigSchema — an unknown key is refused, not stripped', () => expect(envelope.data?.message).toEqual(expected); }); - it('still refuses a value that is neither a string nor a template envelope — one issue at the key', () => { + // A refusal is the one place an author is told exactly what to write, and + // an AI author writes it verbatim. These two slots' renderer is the flow + // interpolator, which reads single-brace `{token}` only, so their refusals + // prescribe `{record.name}` — never the shared template input's + // `{{record.name}}`, which the build's `flow-double-brace-interpolation` + // rule flags on this very node and the executor would send with a stray + // pair of braces (#22081). The lint round trip of each prescribed spelling + // is pinned in `@objectstack/lint` (`lint-flow-patterns.test.ts`). + const BARE_PRESCRIPTION = "`'{record.name}'`"; + const ENVELOPE_PRESCRIPTION = "`{ dialect: 'template', source: '{record.name}' }`"; + const BLANK = ['', ' ']; + const FOREIGN = [42, true, ['a'], { source: TEXT }, { dialect: 'cel', source: 'record.x' }]; + + /** Every message in the refusal's tree: the union's own, then each branch issue beneath it. */ + function messagesIn(issue: { message: string; errors?: ReadonlyArray> }): string[] { + const nested = (issue.errors ?? []).flat() as Array<{ message: string; errors?: ReadonlyArray> }>; + return [issue.message, ...nested.flatMap(messagesIn)]; + } + + it('refuses a blank bare string, or a value that is neither a string nor a template envelope, with one issue prescribing `{record.name}`', () => { for (const key of ['title', 'message'] as const) { - for (const value of [42, true, ['a'], { source: TEXT }, { dialect: 'cel', source: 'record.x' }]) { - const issues = issuesAt({ recipients: ['u1'], title: 'x', [key]: value }, key); - expect(issues, `${key} = ${JSON.stringify(value)}`).toEqual([ - { code: 'invalid_union', message: TYPED_EXPRESSION_DIALECT_ONLY.template }, - ]); + const sentences = new Map<'blank' | 'foreign', Set>([['blank', new Set()], ['foreign', new Set()]]); + for (const [kind, values] of [['blank', BLANK], ['foreign', FOREIGN]] as const) { + for (const value of values) { + const label = `${key} = ${JSON.stringify(value)}`; + const issues = issuesAt({ recipients: ['u1'], title: 'x', [key]: value }, key); + expect(issues.map((i) => i.code), label).toEqual(['invalid_union']); + const message = issues[0]!.message; + expect(message, label).toContain(`\`${key}\``); + expect(message, label).toContain(BARE_PRESCRIPTION); + expect(message, label).toContain(ENVELOPE_PRESCRIPTION); + sentences.get(kind)!.add(message); + } + } + // One sentence per kind, and the two kinds are told apart. + expect(sentences.get('blank')!.size, key).toBe(1); + expect(sentences.get('foreign')!.size, key).toBe(1); + expect([...sentences.get('blank')!][0]).not.toBe([...sentences.get('foreign')!][0]); + } + }); + + it('carries no doubled brace anywhere in the refusal — the branch issues the formatters expand included', () => { + // `formatZodIssue` and the wire mapper both expand an `invalid_union`'s + // branches beneath its own line, so a branch still naming the shared + // input's `{{record.name}}` would reach the author under the right one. + for (const key of ['title', 'message'] as const) { + for (const value of [...BLANK, ...FOREIGN]) { + const result = NotifyConfigSchema.safeParse({ recipients: ['u1'], title: 'x', [key]: value }); + const refusal = result.error!.issues.find((i) => i.path.length === 1 && i.path[0] === key)!; + const messages = messagesIn(refusal as unknown as { message: string }); + expect(messages.length, `${key} = ${JSON.stringify(value)}: the tree was not read`).toBeGreaterThan(0); + expect(messages.filter((m) => m.includes('{{')), `${key} = ${JSON.stringify(value)}`).toEqual([]); } } }); - it('refuses a blank bare string at the slot (the shared non-blank rule)', () => { + it('reaches the build with the same prescription — the flow judge `FlowSchema`, `registerFlow` and `os validate` share', () => { for (const key of ['title', 'message'] as const) { - for (const value of ['', ' ']) { - expect(issuesAt({ recipients: ['u1'], title: 'x', [key]: value }, key), `${key} = ${JSON.stringify(value)}`) - .toEqual([{ code: 'invalid_union', message: TYPED_EXPRESSION_SOURCE_REQUIRED.template }]); + for (const value of [' ', 42]) { + const refusals = flowNodeConfigRefusals('notify', { recipients: ['u1'], title: 'x', [key]: value }) + .filter((r) => r.path === key); + expect(refusals.map((r) => r.code), `${key} = ${JSON.stringify(value)}`).toEqual(['node-config-refused-by-contract']); + expect(refusals[0]!.message).toContain(BARE_PRESCRIPTION); + expect(refusals[0]!.message).not.toContain('{{'); } } }); + it('control — the shared template input, whose renderers read `{{var}}`, keeps prescribing `{{record.name}}`', () => { + // The notify slots took their own sentences; the shared one did not + // move. `typed-expression-envelope-dialect.test.ts` pins it at the slots + // that answer with it (`titleFormat`, the prompt template). + expect(TYPED_EXPRESSION_SOURCE_REQUIRED.template).toContain("`'{{record.name}}'`"); + expect(TYPED_EXPRESSION_DIALECT_ONLY.template).toContain("`'{{record.name}}'`"); + for (const key of ['title', 'message'] as const) { + const blank = issuesAt({ recipients: ['u1'], title: 'x', [key]: '' }, key)[0]!.message; + const foreign = issuesAt({ recipients: ['u1'], title: 'x', [key]: 42 }, key)[0]!.message; + expect(blank).not.toBe(TYPED_EXPRESSION_SOURCE_REQUIRED.template); + expect(foreign).not.toBe(TYPED_EXPRESSION_DIALECT_ONLY.template); + } + }); + it('refuses an envelope with no non-blank `source` — the executor renders `source` and nothing else', () => { // The shared envelope arm is the persistence contract and admits both of // these; this slot's executor cannot render either, so the slot refuses diff --git a/packages/spec/src/shared/expression-dialect-docs.pin.test.ts b/packages/spec/src/shared/expression-dialect-docs.pin.test.ts index e1d8ec13bc5..61cb805d6b9 100644 --- a/packages/spec/src/shared/expression-dialect-docs.pin.test.ts +++ b/packages/spec/src/shared/expression-dialect-docs.pin.test.ts @@ -93,3 +93,50 @@ describe('expression.zod.ts dialect table === ExpressionDialect', () => { expect(ExpressionDialect.safeParse('template').success).toBe(true); }); }); + +/** + * [#22081] The `tmpl` docblock says which renderers read which braces. + * + * `tmpl` is the helper an author reaches for on every template slot, and its + * docblock used to call the envelope "Mustache-template" and show only + * `{{record.x}}` — the spelling a notify node's `title` / `message` renderer + * (the flow interpolator) leaves inside a stray pair of braces, and the build's + * `flow-double-brace-interpolation` rule flags. The helper judges no spelling, + * so its docblock has to name the renderer for each one. + * + * ⛔ Scope: the relation, not the wording — which spelling is listed against + * which renderer. Rewording a bullet is free. + */ +describe('the `tmpl` docblock names the renderer behind each brace spelling', () => { + /** The `/** … *\/` block directly above `export function tmpl(`, one string per `- ` bullet. */ + function tmplBullets(): string[] { + const source = fs.readFileSync(SOURCE, 'utf8'); + const at = source.indexOf('export function tmpl('); + const block = source.slice(source.lastIndexOf('/**', at), at); + const bullets: string[] = []; + for (const line of block.split('\n')) { + const text = line.replace(/^\s*\*\s?/, ''); + if (text.startsWith('- ')) bullets.push(text.slice(2)); + else if (bullets.length > 0 && /^\s+\S/.test(text)) bullets[bullets.length - 1] += ` ${text.trim()}`; + } + return bullets; + } + + const bullets = tmplBullets(); + const doubled = bullets.find((b) => b.startsWith('`{{record.x}}`')); + const single = bullets.find((b) => b.startsWith('`{record.x}`')); + + it('finds a bullet for each spelling (anti-vacuity)', () => { + expect(bullets.length, 'no `- ` bullets parsed out of the `tmpl` docblock').toBeGreaterThan(0); + expect(doubled, 'no bullet opens with `{{record.x}}`').toBeDefined(); + expect(single, 'no bullet opens with `{record.x}`').toBeDefined(); + }); + + it('lists the double-brace renderers against `{{record.x}}`, and the notify slots against `{record.x}`', () => { + for (const renderer of ['messaging', 'email']) expect(doubled).toContain(renderer); + expect(doubled).not.toContain('notify'); + expect(single).toContain('notify'); + expect(single).toContain('`flow-double-brace-interpolation`'); + for (const renderer of ['messaging', 'email']) expect(single).not.toContain(renderer); + }); +}); diff --git a/packages/spec/src/shared/typed-expression-envelope-dialect.test.ts b/packages/spec/src/shared/typed-expression-envelope-dialect.test.ts index 27c0ae490a2..73194ea1eba 100644 --- a/packages/spec/src/shared/typed-expression-envelope-dialect.test.ts +++ b/packages/spec/src/shared/typed-expression-envelope-dialect.test.ts @@ -25,6 +25,7 @@ import { describe, expect, it } from 'vitest'; import { z } from 'zod'; +import { PromptTemplateSchema } from '../ai/model-registry.zod.js'; import { ObjectStackDefinitionSchema } from '../stack.zod.js'; import { CronExpressionInputSchema, @@ -32,6 +33,7 @@ import { TemplateExpressionInputSchema, TYPED_EXPRESSION_DIALECT_ONLY, TYPED_EXPRESSION_SOURCE_REQUIRED, + templateExpressionInput, type CronExpressionInput, type TemplateExpressionInput, type TypedExpressionDialect, @@ -231,3 +233,80 @@ describe('through `ObjectStackDefinitionSchema` — the stack-reachable typed sl expect(result.success, result.success ? '' : JSON.stringify(result.error.issues)).toBe(true); }); }); + +/** + * `templateExpressionInput` — the one constructor of the template-typed input, + * refusing with the sentences it is given (#22081). A template slot whose + * renderer reads a spelling other than `{{var}}` (a notify node's `title` / + * `message`, rendered by the flow interpolator) is built with it so its refusal + * prescribes that renderer's spelling; `TemplateExpressionInputSchema` is the + * same constructor with the shared `{{record.name}}` sentences. + * + * Pinned: the accept set does not move with the sentences, the given sentences + * are the ONLY refusal text — the branch issue the formatters expand included — + * and the slots that keep the shared input still prescribe `{{record.name}}`. + */ +describe('templateExpressionInput — the same input, refusing with the slot\'s own sentences', () => { + const OWN = { + sourceRequired: 'OWN source-required sentence. Write `\'{x}\'`.', + dialectOnly: 'OWN dialect-only sentence. Write `\'{x}\'`.', + }; + const own = templateExpressionInput(OWN); + + /** Every message in a refusal's tree: the union's own, then each branch issue beneath it. */ + function messagesIn(issue: { message: string; errors?: ReadonlyArray> }): string[] { + const nested = (issue.errors ?? []).flat() as Array<{ message: string; errors?: ReadonlyArray> }>; + return [issue.message, ...nested.flatMap(messagesIn)]; + } + + it('accepts exactly what `TemplateExpressionInputSchema` accepts, and parses it to the same value', () => { + for (const value of [ + '{x}', + '{{x}}', + ' padded ', + { dialect: 'template', source: '{x}', meta: { rationale: 'r' } }, + { dialect: 'template', ast: { kind: 'const' } }, + ]) { + expect(slotValue(own, value), JSON.stringify(value)).toEqual(slotValue(TemplateExpressionInputSchema, value)); + } + }); + + it('refuses what `TemplateExpressionInputSchema` refuses, with the given sentence by kind', () => { + for (const blank of ['', ' ']) { + expect(slotIssues(own, blank)).toEqual([{ code: 'invalid_union', path: 'slot', message: OWN.sourceRequired }]); + } + for (const foreign of [42, { source: 'x' }, { dialect: 'cel', source: 'x' }, { dialect: 'cron', source: '0 9 * * *' }]) { + expect(slotIssues(own, foreign), JSON.stringify(foreign)) + .toEqual([{ code: 'invalid_union', path: 'slot', message: OWN.dialectOnly }]); + } + // The persistence contract's own refusal is untouched by the sentences. + expect(slotIssues(own, { dialect: 'template' })).toEqual([{ code: 'custom', path: 'slot', message: NEITHER_SOURCE_NOR_AST }]); + }); + + it('the given sentences are the only refusal text — no branch issue carries the shared `{{record.name}}` sentence', () => { + // `formatZodIssue` and the wire mapper expand an `invalid_union`'s + // branches, so the string arm's own message reaches the author too. + for (const value of ['', ' ', 42, { dialect: 'cel', source: 'x' }]) { + const result = z.object({ slot: own }).safeParse({ slot: value }); + expect(result.success).toBe(false); + const messages = result.success ? [] : messagesIn(result.error.issues[0] as unknown as { message: string }); + expect(messages[0], JSON.stringify(value)).toBe(typeof value === 'string' ? OWN.sourceRequired : OWN.dialectOnly); + expect(messages.filter((m) => m.includes('{{')), JSON.stringify(value)).toEqual([]); + } + }); + + it('control: the slots that keep the shared input — whose renderers read `{{var}}` — still prescribe `{{record.name}}`', () => { + for (const sentence of [TYPED_EXPRESSION_SOURCE_REQUIRED.template, TYPED_EXPRESSION_DIALECT_ONLY.template]) { + expect(sentence).toContain("`'{{record.name}}'`"); + expect(sentence).toContain("`{ dialect: 'template', source: '{{record.name}}' }`"); + } + const prompt = (user: unknown) => PromptTemplateSchema.safeParse({ id: 'p', name: 'p', label: 'P', user }); + const blank = prompt(' '); + const foreign = prompt(42); + expect(blank.success || foreign.success).toBe(false); + expect(blank.success ? [] : blank.error.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message }))) + .toEqual([{ code: 'invalid_union', path: 'user', message: TYPED_EXPRESSION_SOURCE_REQUIRED.template }]); + expect(foreign.success ? [] : foreign.error.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message }))) + .toEqual([{ code: 'invalid_union', path: 'user', message: TYPED_EXPRESSION_DIALECT_ONLY.template }]); + }); +}); From 5a710d62319f223949886934e33a146c720d3d88 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:58:18 +0000 Subject: [PATCH 3/5] chore(spec): regenerate api-surface, export-origins and the expression reference page Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .changeset/22081-notify-refusal-single-brace.md | 4 ++-- content/docs/references/shared/expression.mdx | 10 ++++++---- packages/spec/api-surface/shared.json | 1 + packages/spec/export-origins/shared.json | 1 + 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.changeset/22081-notify-refusal-single-brace.md b/.changeset/22081-notify-refusal-single-brace.md index a2e9a46e26f..4f72c7df304 100644 --- a/.changeset/22081-notify-refusal-single-brace.md +++ b/.changeset/22081-notify-refusal-single-brace.md @@ -7,7 +7,7 @@ A notify flow node's `title` / `message` refusal now prescribes `'{record.name}' Clause-②: no - Both slots take the same input as before: a bare, non-blank string or a `{ dialect: 'template', source }` envelope. Every value that parsed still parses, every value that was refused is still refused, with the same `invalid_union` code at the same path. Only the sentence changes. -- A blank bare string, a number, a non-template envelope and the like at `title` or `message` are refused with a sentence that names the key and ends `Write '{record.name}' or { dialect: 'template', source: '{record.name}' }`, and says why: the notify executor interpolates single-brace `{token}` placeholders, and a doubled brace keeps its outer braces in the sent text. The branch issue that `formatZodIssue` and the API error mapper print beneath it carries the same sentence, so no `{{…}}` prescription reaches the author on these two keys. The build's flow judge (`FlowSchema`, flow registration, `os validate`) quotes the new sentence. +- A blank bare string, a number, a non-template envelope and the like at `title` or `message` are refused with a sentence that names the key, prescribes `'{record.name}'` or `{ dialect: 'template', source: '{record.name}' }`, and says why: the notify executor interpolates single-brace `{token}` placeholders, and a doubled brace keeps its outer braces in the sent text. The branch issue that `formatZodIssue` and the API error mapper print beneath it carries the same sentence, so no `{{…}}` prescription reaches the author on these two keys. The build's flow judge (`FlowSchema`, flow registration, `os validate`) quotes the new sentence. - Every other template slot keeps its sentence, which still prescribes `'{{record.name}}'`. That covers `titleFormat`, the prompt template's `system` / `user`, and any slot typed `TemplateExpressionInputSchema`. `TYPED_EXPRESSION_SOURCE_REQUIRED.template` and `TYPED_EXPRESSION_DIALECT_ONLY.template` are unchanged. - New export on `@objectstack/spec/shared`: `templateExpressionInput({ sourceRequired, dialectOnly })`. It builds the same template input as `TemplateExpressionInputSchema` but refuses with the sentences you give it, for a template slot whose renderer reads another placeholder spelling. `TemplateExpressionInputSchema` is now built with it and keeps its shared sentences. -- The `tmpl` docblock no longer calls the envelope "Mustache" or shows only `{{record.x}}`. It now says which renderers read which braces: `{{record.x}}` for the formula template engine and the messaging, email and i18n renderers, `{record.x}` for a notify node's `title` / `message`, and either for `titleFormat`. The `TemplateExpressionInputSchema` docblock and the generated expression reference page list the notify slots the same way. +- The `tmpl` docblock no longer calls the envelope "Mustache" or shows only `{{record.x}}`. It now says which renderers read which braces: `{{record.x}}` for the formula template engine and the messaging, email and i18n renderers, `{record.x}` for a notify node's `title` / `message`, and either for `titleFormat`. The `TemplateExpressionInputSchema` docblock lists the notify slots the same way, and the generated expression reference page names `templateExpressionInput` beside the two typed schemas and says a template slot's fix is written in the spelling its renderer reads. diff --git a/content/docs/references/shared/expression.mdx b/content/docs/references/shared/expression.mdx index 64d93f556bb..239d585599e 100644 --- a/content/docs/references/shared/expression.mdx +++ b/content/docs/references/shared/expression.mdx @@ -32,10 +32,12 @@ cron-typed slot is parsed and reaches no engine, and `@objectstack/formula`'s registered `cron` engine has no caller outside that package. A TYPED slot — one declared with `CronExpressionInputSchema` or -`TemplateExpressionInputSchema` — takes a bare, non-blank string (shorthand -for its own dialect) or an envelope declaring that one dialect. An envelope -naming any other dialect, and a blank string, are refused at the slot with -one issue whose message names the dialect and the fix. Only the untyped +`TemplateExpressionInputSchema`, or built with `templateExpressionInput` — +takes a bare, non-blank string (shorthand for its own dialect) or an +envelope declaring that one dialect. An envelope naming any other dialect, +and a blank string, are refused at the slot with one issue whose message +names the dialect and the fix; a template slot's fix is written in the +placeholder spelling its renderer reads. Only the untyped `ExpressionInputSchema` takes every declared dialect in envelope form. Those three are the whole list — it is exactly the `ExpressionDialect` enum diff --git a/packages/spec/api-surface/shared.json b/packages/spec/api-surface/shared.json index cf260910f15..2628bf70e29 100644 --- a/packages/spec/api-surface/shared.json +++ b/packages/spec/api-surface/shared.json @@ -126,6 +126,7 @@ "singularToPlural (function)", "strictUnknownKeyError (function)", "suggestFieldType (function)", + "templateExpressionInput (const)", "tmpl (function)", "unrecognisedMetaTypeRefusal (function)" ] diff --git a/packages/spec/export-origins/shared.json b/packages/spec/export-origins/shared.json index 33e5c6b4bba..6c420a98cf5 100644 --- a/packages/spec/export-origins/shared.json +++ b/packages/spec/export-origins/shared.json @@ -121,6 +121,7 @@ "singularToPlural": "src/meta-spelling/manifest-collection-spelling.ts#singularToPlural (function)", "strictUnknownKeyError": "src/shared/suggestions.zod.ts#strictUnknownKeyError (function)", "suggestFieldType": "src/shared/field-type-suggestion.ts#suggestFieldType (function)", + "templateExpressionInput": "src/shared/expression.zod.ts#templateExpressionInput (const)", "tmpl": "src/shared/expression.zod.ts#tmpl (function)", "unrecognisedMetaTypeRefusal": "src/meta-spelling/metadata-url-spelling.ts#unrecognisedMetaTypeRefusal (function)" } From 3653d9ca0a261d9e53fc115cb541cda62dabbb70 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 21:13:36 +0000 Subject: [PATCH 4/5] refactor(spec): keep the typed-input constructors package-internal The constructor the notify title/message share with TemplateExpressionInputSchema moves out of expression.zod.ts (which shared/index.ts re-exports) into shared/typed-expression-input.ts, which no barrel re-exports, so the public face does not widen. ExpressionSchema is passed in rather than imported, which keeps the module free of a runtime cycle. The changeset drops its export bullet. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../22081-notify-refusal-single-brace.md | 4 +- .../test/expression-conformance.test.ts | 18 ++- .../spec/src/automation/io-node-config.zod.ts | 19 ++- packages/spec/src/shared/expression.zod.ts | 125 ++++-------------- .../typed-expression-envelope-dialect.test.ts | 10 +- .../spec/src/shared/typed-expression-input.ts | 118 +++++++++++++++++ 6 files changed, 178 insertions(+), 116 deletions(-) create mode 100644 packages/spec/src/shared/typed-expression-input.ts diff --git a/.changeset/22081-notify-refusal-single-brace.md b/.changeset/22081-notify-refusal-single-brace.md index 4f72c7df304..401d7d08b0f 100644 --- a/.changeset/22081-notify-refusal-single-brace.md +++ b/.changeset/22081-notify-refusal-single-brace.md @@ -9,5 +9,5 @@ Clause-②: no - Both slots take the same input as before: a bare, non-blank string or a `{ dialect: 'template', source }` envelope. Every value that parsed still parses, every value that was refused is still refused, with the same `invalid_union` code at the same path. Only the sentence changes. - A blank bare string, a number, a non-template envelope and the like at `title` or `message` are refused with a sentence that names the key, prescribes `'{record.name}'` or `{ dialect: 'template', source: '{record.name}' }`, and says why: the notify executor interpolates single-brace `{token}` placeholders, and a doubled brace keeps its outer braces in the sent text. The branch issue that `formatZodIssue` and the API error mapper print beneath it carries the same sentence, so no `{{…}}` prescription reaches the author on these two keys. The build's flow judge (`FlowSchema`, flow registration, `os validate`) quotes the new sentence. - Every other template slot keeps its sentence, which still prescribes `'{{record.name}}'`. That covers `titleFormat`, the prompt template's `system` / `user`, and any slot typed `TemplateExpressionInputSchema`. `TYPED_EXPRESSION_SOURCE_REQUIRED.template` and `TYPED_EXPRESSION_DIALECT_ONLY.template` are unchanged. -- New export on `@objectstack/spec/shared`: `templateExpressionInput({ sourceRequired, dialectOnly })`. It builds the same template input as `TemplateExpressionInputSchema` but refuses with the sentences you give it, for a template slot whose renderer reads another placeholder spelling. `TemplateExpressionInputSchema` is now built with it and keeps its shared sentences. -- The `tmpl` docblock no longer calls the envelope "Mustache" or shows only `{{record.x}}`. It now says which renderers read which braces: `{{record.x}}` for the formula template engine and the messaging, email and i18n renderers, `{record.x}` for a notify node's `title` / `message`, and either for `titleFormat`. The `TemplateExpressionInputSchema` docblock lists the notify slots the same way, and the generated expression reference page names `templateExpressionInput` beside the two typed schemas and says a template slot's fix is written in the spelling its renderer reads. +- The `tmpl` docblock no longer calls the envelope "Mustache" or shows only `{{record.x}}`. It now says which renderers read which braces: `{{record.x}}` for the formula template engine and the messaging, email and i18n renderers, `{record.x}` for a notify node's `title` / `message`, and either for `titleFormat`. The `TemplateExpressionInputSchema` docblock lists the notify slots the same way, and the generated expression reference page says a template slot's fix is written in the spelling its renderer reads. +- No export is added, removed or renamed, and no type changes. The notify slots take the same input as `TemplateExpressionInputSchema` from a constructor that stays internal to the package. diff --git a/packages/qa/dogfood/test/expression-conformance.test.ts b/packages/qa/dogfood/test/expression-conformance.test.ts index 0be8b143af2..b8c816d1483 100644 --- a/packages/qa/dogfood/test/expression-conformance.test.ts +++ b/packages/qa/dogfood/test/expression-conformance.test.ts @@ -105,13 +105,17 @@ const EXPRESSION_INPUT_SCHEMAS = [ 'SettingsVisibilityInputSchema', 'CronExpressionInputSchema', 'TemplateExpressionInputSchema', - // [#22081] The constructor `TemplateExpressionInputSchema` is built with: the - // same template input, refusing with the slot's own sentences. A notify - // node's `title` / `message` are typed with a call of it (their refusals - // prescribe the flow interpolator's `{record.name}`), so without this row - // both positions drop out of discovery and `template-notify-content` goes - // STALE — #7327's shape again. It is declared as a `const` arrow, which is - // the roster-definition shape `LOCAL_BINDING` skips. + // [#22081] The package-internal constructors (`shared/typed-expression-input.ts`) + // the two typed schemas above are built with: the same input, refusing with + // the slot's own sentences. A notify node's `title` / `message` are typed + // with a call of `templateExpressionInput` (their refusals prescribe the flow + // interpolator's `{record.name}`), so without its row both positions drop out + // of discovery and `template-notify-content` goes STALE — #7327's shape + // again; `cronExpressionInput` is listed for the same reason ahead of a slot + // that needs it. Their definitions live in a plain `.ts` module this + // `.zod.ts` scan never reads, and the two `export const …Schema = …(…)` lines + // in `expression.zod.ts` are roster definitions `LOCAL_BINDING` skips. + 'cronExpressionInput', 'templateExpressionInput', ]; /** diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index 7eeecaaed60..9f53bb6cfb5 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -54,10 +54,13 @@ */ import { z } from 'zod'; -import { templateExpressionInput } from '../shared/expression.zod'; +import { ExpressionSchema } from '../shared/expression.zod'; import { lazySchema } from '../shared/lazy-schema'; import { NON_BLANK_STRING } from '../shared/refinement-projection'; import { strictObject } from '../shared/strict-object'; +// The package-internal constructor `TemplateExpressionInputSchema` itself is +// built with — never re-exported, so the notify sentences add no public surface. +import { templateExpressionInput, type TypedExpressionRefusals } from '../shared/typed-expression-input'; /** * What a rejected key on these contracts silently did before #4001 批 9. @@ -125,8 +128,9 @@ const NOTIFY_KEY_GUIDANCE: Readonly> = { * `flow-double-brace-interpolation` rule flags a doubled brace on a flow node * value; so this is the spelling both read today. The shared * `TemplateExpressionInputSchema` prescribes `{{record.name}}` instead, which - * is why these two slots are built with `templateExpressionInput` and carry - * the sentences below (#22081). + * is why these two slots take the same input from `templateExpressionInput` + * (`shared/typed-expression-input.ts`, package-internal) and carry the + * sentences below (#22081). * * The 17.x prescription, ⛔ not an end-state ruling on braces: executing * ADR-0032 D3 on the v18 line (#22110) flips the notify convention, and this @@ -149,7 +153,7 @@ const NOTIFY_TEMPLATE_RENDERER = * that is neither a string nor a `template` envelope), naming the key and * prescribing {@link NOTIFY_TEMPLATE_PLACEHOLDER}. */ -function notifyTemplateRefusals(key: 'title' | 'message'): { sourceRequired: string; dialectOnly: string } { +function notifyTemplateRefusals(key: 'title' | 'message'): TypedExpressionRefusals { const write = `Write \`'${NOTIFY_TEMPLATE_PLACEHOLDER}'\` or \`{ dialect: 'template', source: '${NOTIFY_TEMPLATE_PLACEHOLDER}' }\`: ` + NOTIFY_TEMPLATE_RENDERER; @@ -246,7 +250,8 @@ function notifyTemplateSourceRequired(key: 'title' | 'message'): string { * here is the flow's `interpolate()`, so the placeholder spelling is its * single-brace `{token}` (`{record.name}`). A `{{var}}` is not a placeholder * in these two slots: the inner `{var}` resolves and the outer braces stay - * in the text. So the input is built with `templateExpressionInput` rather + * in the text. So the input is built with `templateExpressionInput` (the + * package-internal constructor of `TemplateExpressionInputSchema`) rather * than taken as `TemplateExpressionInputSchema`, whose refusals prescribe * `{{record.name}}`: a malformed value here is refused with sentences that * prescribe `{record.name}` ({@link NOTIFY_TEMPLATE_PLACEHOLDER}). @@ -275,10 +280,10 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ * template slot (see the docblock above). Required unless `template` is set * (the superRefine below owes one of the two). */ - title: templateExpressionInput(notifyTemplateRefusals('title')).optional() + title: templateExpressionInput(ExpressionSchema, notifyTemplateRefusals('title')).optional() .describe('Notification title — a template: a bare string, or a `{ dialect: \'template\', source }` envelope (the `tmpl` helper) carrying the same text. It is interpolated per run with the flow\'s single-brace `{token}` placeholders (`{record.name}`); a `{{var}}` is not a placeholder here — its inner `{var}` resolves and the outer braces stay in the text. One text for every recipient (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive.'), /** Notification body (inline path only) — the same template input as `title`. */ - message: templateExpressionInput(notifyTemplateRefusals('message')).optional() + message: templateExpressionInput(ExpressionSchema, notifyTemplateRefusals('message')).optional() .describe('Notification body — the same template input as `title` (a bare string or a `{ dialect: \'template\', source }` envelope), interpolated per run with single-brace `{token}` placeholders; not localizable. Only valid with inline `title`, never with `template`.'), /** * The localizable content path (#9205): name of a `sys_email_template` diff --git a/packages/spec/src/shared/expression.zod.ts b/packages/spec/src/shared/expression.zod.ts index 6667c300bd9..55ab3ce07d6 100644 --- a/packages/spec/src/shared/expression.zod.ts +++ b/packages/spec/src/shared/expression.zod.ts @@ -5,6 +5,9 @@ import { z } from 'zod'; // item 2). Both rules below are DECLARED through it, so `json-schema/**` states // them instead of being silently wider than this file. import { NON_BLANK_STRING, requiredOneOf } from './refinement-projection'; +// The one constructor of a typed input — package-internal, never re-exported +// (see its module note for why `ExpressionSchema` is handed to it). +import { cronExpressionInput, templateExpressionInput, type TypedExpressionRefusals } from './typed-expression-input'; /** * # Expression Protocol @@ -33,12 +36,13 @@ import { NON_BLANK_STRING, requiredOneOf } from './refinement-projection'; * registered `cron` engine has no caller outside that package. * * A TYPED slot — one declared with `CronExpressionInputSchema` or - * `TemplateExpressionInputSchema`, or built with `templateExpressionInput` — - * takes a bare, non-blank string (shorthand for its own dialect) or an - * envelope declaring that one dialect. An envelope naming any other dialect, - * and a blank string, are refused at the slot with one issue whose message - * names the dialect and the fix; a template slot's fix is written in the - * placeholder spelling its renderer reads. Only the untyped + * `TemplateExpressionInputSchema`, or with the same input carrying its own + * refusal sentences (a notify node's `title` / `message`) — takes a bare, + * non-blank string (shorthand for its own dialect) or an envelope declaring + * that one dialect. An envelope naming any other dialect, and a blank string, + * are refused at the slot with one issue whose message names the dialect and + * the fix; a template slot's fix is written in the placeholder spelling its + * renderer reads. Only the untyped * `ExpressionInputSchema` takes every declared dialect in envelope form. * * Those three are the whole list — it is exactly the `ExpressionDialect` enum @@ -288,10 +292,11 @@ export type TypedExpressionDialect = Extract> = { cron: @@ -324,56 +329,13 @@ export const TYPED_EXPRESSION_DIALECT_ONLY: Readonly(dialect: D, refusals: TypedExpressionRefusals) { - return z.string() - .refine(NON_BLANK_STRING, { message: refusals.sourceRequired }) - .transform((source) => ({ dialect, source })); -} - -function typedExpressionUnionParams(refusals: TypedExpressionRefusals): { error: (issue: { input?: unknown }) => string } { - return { - error: (issue) => (typeof issue.input === 'string' ? refusals.sourceRequired : refusals.dialectOnly), - }; -} - -/** The two sentences one typed slot refuses with — see the two records above. */ -interface TypedExpressionRefusals { - /** Refuses a blank bare string. */ - readonly sourceRequired: string; - /** Refuses a foreign-dialect envelope and any value that is neither a string nor an envelope. */ - readonly dialectOnly: string; -} - -/** The dialect's own sentences, from the two records above. */ function typedExpressionRefusals(dialect: TypedExpressionDialect): TypedExpressionRefusals { return { sourceRequired: TYPED_EXPRESSION_SOURCE_REQUIRED[dialect], @@ -399,40 +361,9 @@ function typedExpressionRefusals(dialect: TypedExpressionDialect): TypedExpressi * the knowledge-refresh slot is `[EXPERIMENTAL — not enforced]` by design and * reaches no engine, and no grammar is restated here. */ -export const CronExpressionInputSchema = z.union([ - typedExpressionStringArm('cron', typedExpressionRefusals('cron')), - ExpressionSchema.safeExtend({ dialect: z.literal('cron') }), -], typedExpressionUnionParams(typedExpressionRefusals('cron'))); +export const CronExpressionInputSchema = cronExpressionInput(ExpressionSchema, typedExpressionRefusals('cron')); export type CronExpressionInput = z.input; -/** - * The template-typed input, refusing with the sentences it is given — the one - * constructor of {@link TemplateExpressionInputSchema} and of every template - * slot whose renderer reads a placeholder spelling other than `{{var}}`. - * - * The accept set is identical whatever the sentences: a bare, non-blank string - * (shorthand for `{ dialect: 'template', source }`) or an envelope declaring - * `dialect: 'template'`. Only the refusal text moves, because a refusal is the - * one place an author is told exactly what to write, and it must prescribe the - * spelling the slot's renderer reads — {@link TYPED_EXPRESSION_SOURCE_REQUIRED} - * prescribes `{{record.name}}`, which a single-brace renderer would leave - * inside a stray pair of braces. Pass `sourceRequired` for a blank bare string - * and `dialectOnly` for everything else, each ending in the slot's own - * prescription. - * - * Declared as a `const` arrow, not a `function`: the expression-surface census - * (`packages/qa/dogfood/test/expression-conformance.test.ts`) reads this name - * as a roster member, and a `const` binding is the roster-definition shape it - * skips. - */ -export const templateExpressionInput = (refusals: { - readonly sourceRequired: string; - readonly dialectOnly: string; -}) => z.union([ - typedExpressionStringArm('template', refusals), - ExpressionSchema.safeExtend({ dialect: z.literal('template') }), -], typedExpressionUnionParams(refusals)); - /** * Template-typed input shape: a bare, non-blank string is shorthand for * `{ dialect: 'template', source }`, and an envelope must declare @@ -441,8 +372,9 @@ export const templateExpressionInput = (refusals: { * string (`TYPED_EXPRESSION_SOURCE_REQUIRED.template`). Use this for * titleFormat, prompt templates, and any template slot whose renderer reads * `{{var}}`; a slot whose renderer reads another spelling takes the same input - * from {@link templateExpressionInput}, with refusals prescribing its own - * spelling (a notify node's `title` / `message` do — see the list below). + * from the package-internal constructor (`./typed-expression-input.ts`), with + * refusals prescribing its own spelling (a notify node's `title` / `message` + * do — see the list below). * * No template syntax is judged at parse time: this schema judges the dialect * tag and non-emptiness and nothing else, so it declares no placeholder @@ -465,14 +397,15 @@ export const templateExpressionInput = (refusals: { * `@objectstack/service-automation`), which substitutes single-brace `{var}` * only — a `{{var}}` keeps its outer braces in the sent text, and the * build's `flow-double-brace-interpolation` rule flags it on a flow node. - * Those two slots are built with {@link templateExpressionInput}, so their - * refusals prescribe `{record.name}` instead of this schema's `{{record.name}}`. + * Those two slots take this same input from the package-internal + * constructor (`./typed-expression-input.ts`), so their refusals prescribe + * `{record.name}` instead of this schema's `{{record.name}}`. * * So write the spelling the slot's renderer reads — `{{var}}` on this schema's * slots, `{var}` on a notify node's — and do not read either spelling as * declared, preferred or rejected here. */ -export const TemplateExpressionInputSchema = templateExpressionInput(typedExpressionRefusals('template')); +export const TemplateExpressionInputSchema = templateExpressionInput(ExpressionSchema, typedExpressionRefusals('template')); export type TemplateExpressionInput = z.input; /** diff --git a/packages/spec/src/shared/typed-expression-envelope-dialect.test.ts b/packages/spec/src/shared/typed-expression-envelope-dialect.test.ts index 73194ea1eba..9ca78355036 100644 --- a/packages/spec/src/shared/typed-expression-envelope-dialect.test.ts +++ b/packages/spec/src/shared/typed-expression-envelope-dialect.test.ts @@ -33,11 +33,11 @@ import { TemplateExpressionInputSchema, TYPED_EXPRESSION_DIALECT_ONLY, TYPED_EXPRESSION_SOURCE_REQUIRED, - templateExpressionInput, type CronExpressionInput, type TemplateExpressionInput, type TypedExpressionDialect, } from './expression.zod.js'; +import { templateExpressionInput } from './typed-expression-input.js'; const NEITHER_SOURCE_NOR_AST = 'Expression requires at least one of `source` or `ast`'; @@ -235,12 +235,14 @@ describe('through `ObjectStackDefinitionSchema` — the stack-reachable typed sl }); /** - * `templateExpressionInput` — the one constructor of the template-typed input, + * `templateExpressionInput` — the package-internal constructor of the template-typed input, * refusing with the sentences it is given (#22081). A template slot whose * renderer reads a spelling other than `{{var}}` (a notify node's `title` / * `message`, rendered by the flow interpolator) is built with it so its refusal * prescribes that renderer's spelling; `TemplateExpressionInputSchema` is the - * same constructor with the shared `{{record.name}}` sentences. + * same constructor with the shared `{{record.name}}` sentences. It is NOT on + * the public face: `check:api-surface` holds `api-surface/shared.json` to the + * exports `shared/index.ts` re-exports, and this module is not one of them. * * Pinned: the accept set does not move with the sentences, the given sentences * are the ONLY refusal text — the branch issue the formatters expand included — @@ -251,7 +253,7 @@ describe('templateExpressionInput — the same input, refusing with the slot\'s sourceRequired: 'OWN source-required sentence. Write `\'{x}\'`.', dialectOnly: 'OWN dialect-only sentence. Write `\'{x}\'`.', }; - const own = templateExpressionInput(OWN); + const own = templateExpressionInput(ExpressionSchema, OWN); /** Every message in a refusal's tree: the union's own, then each branch issue beneath it. */ function messagesIn(issue: { message: string; errors?: ReadonlyArray> }): string[] { diff --git a/packages/spec/src/shared/typed-expression-input.ts b/packages/spec/src/shared/typed-expression-input.ts new file mode 100644 index 00000000000..6222e3e7f5b --- /dev/null +++ b/packages/spec/src/shared/typed-expression-input.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The one constructor of a TYPED expression input — the shape + * `CronExpressionInputSchema` and `TemplateExpressionInputSchema` + * (`./expression.zod.ts`) are built from, and that a slot whose refusal must + * prescribe something else builds its own copy of. + * + * ## ⛔ 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 + * `@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. + * + * ## Why `ExpressionSchema` is a PARAMETER, not an import + * + * `./expression.zod.ts` builds its two typed schemas from this module, so a + * runtime import back into `./expression.zod.ts` would be a module cycle — the + * shape that crashed entries under `OS_EAGER_SCHEMAS=1` before + * (`./index.ts`'s note on `field-type-suggestion`). The caller hands the + * persistence envelope in, and this module imports from `./expression.zod.ts` + * as TYPES only (`import type` is erased, so no runtime edge exists). Each + * dialect's envelope arm is still spelled once, here. + */ + +import { z } from 'zod'; +import { NON_BLANK_STRING } from './refinement-projection'; +import type { ExpressionSchema, TypedExpressionDialect } from './expression.zod'; + +/** The two sentences one typed slot refuses with. */ +export interface TypedExpressionRefusals { + /** Refuses a blank bare string (empty or whitespace-only). */ + readonly sourceRequired: string; + /** Refuses a foreign-dialect envelope and any value that is neither a string nor an envelope. */ + readonly dialectOnly: string; +} + +/** + * The typed input for `dialect`, around its own envelope arm: a bare, non-blank + * string (shorthand for `{ dialect, source }`) or that envelope, refusing + * everything else with the sentences it is given. The two exported + * constructors below are the only callers; each builds its envelope arm with a + * CONCRETE `z.literal`, because `safeExtend` cannot check a literal over a + * generic dialect (`ZodLiteral` is not provably assignable to the + * `ExpressionDialect` key it narrows — TS2322, measured). + * + * The accept set is fixed by the dialect alone; `refusals` moves only the text. + * That is the point of taking them as an argument: a refusal is the one place + * an author is told exactly what to write, so it must prescribe the spelling + * the slot's renderer reads. The shared `template` sentence prescribes + * `{{record.name}}`, which the notify executor's single-brace interpolator + * would leave inside a stray pair of braces, so the notify `title` / `message` + * pass sentences prescribing `{record.name}` instead. + * + * The refusal shape is measured, not assumed (zod 4.4): a union reports the + * one arm that did not abort, else `invalid_union`. Both arms abort on a + * foreign value — the string arm is a pipe, whose transform aborts the arm on + * any issue, and the envelope arm's `z.literal` aborts on a foreign dialect — + * so every refusal is ONE `invalid_union` at the slot, and the union's own + * error map is where the message lives: `sourceRequired` for a string input, + * `dialectOnly` for everything else. A `.refine` on the envelope arm would + * surface as `custom` at `dialect` instead, but would leave the input TYPE, + * the JSON Schema and the generated reference page declaring every dialect on + * a typed slot; the literal keeps all four surfaces saying one thing. The one + * refusal `ExpressionSchema` carries — neither `source` nor `ast` — still + * surfaces on its own, with its own message: that arm does not abort on it. + * + * The string arm carries `sourceRequired` too, not only the union: the union's + * message is not the only text an author sees, because `formatZodIssue` and + * the wire mapper expand an `invalid_union`'s branches beneath it, so a branch + * message naming another slot's spelling would reach the author under the + * right one. + * + * The string arm's transform returns the narrowed `{ dialect, source }`, not + * the wide `Expression`: the parsed value of a typed slot must stay assignable + * to its own input type (`ObjectStackDefinitionSchema.parse` output is handed + * to validators typed with the input shape), and it is — a same-dialect + * envelope is both. + */ +function typedExpressionUnion( + dialect: D, + envelope: E, + refusals: TypedExpressionRefusals, +) { + return z.union([ + z.string() + .refine(NON_BLANK_STRING, { message: refusals.sourceRequired }) + .transform((source) => ({ dialect, source })), + envelope, + ], { + error: (issue: { input?: unknown }) => (typeof issue.input === 'string' ? refusals.sourceRequired : refusals.dialectOnly), + }); +} + +/** + * The cron-typed input, refusing with `refusals` — `CronExpressionInputSchema` + * is this with the shared cron sentences. + * + * @param expression - `ExpressionSchema` (see the module note for why it is passed in). + */ +export function cronExpressionInput(expression: typeof ExpressionSchema, refusals: TypedExpressionRefusals) { + return typedExpressionUnion('cron', expression.safeExtend({ dialect: z.literal('cron') }), refusals); +} + +/** + * The template-typed input, refusing with `refusals` — + * `TemplateExpressionInputSchema` is this with the shared `{{record.name}}` + * sentences, and a notify node's `title` / `message` are this with sentences + * prescribing `{record.name}`. + * + * @param expression - `ExpressionSchema` (see the module note for why it is passed in). + */ +export function templateExpressionInput(expression: typeof ExpressionSchema, refusals: TypedExpressionRefusals) { + return typedExpressionUnion('template', expression.safeExtend({ dialect: z.literal('template') }), refusals); +} From 681d77223e45405c820fbbf022b865986db8abc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 21:23:26 +0000 Subject: [PATCH 5/5] chore(spec): regenerate api-surface, export-origins and the expression reference page api-surface/ and export-origins/ return to the merge base byte for byte; the reference page no longer names the internal constructor. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- content/docs/references/shared/expression.mdx | 13 +++++++------ packages/spec/api-surface/shared.json | 1 - packages/spec/export-origins/shared.json | 1 - 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/content/docs/references/shared/expression.mdx b/content/docs/references/shared/expression.mdx index 239d585599e..77b63f30fff 100644 --- a/content/docs/references/shared/expression.mdx +++ b/content/docs/references/shared/expression.mdx @@ -32,12 +32,13 @@ cron-typed slot is parsed and reaches no engine, and `@objectstack/formula`'s registered `cron` engine has no caller outside that package. A TYPED slot — one declared with `CronExpressionInputSchema` or -`TemplateExpressionInputSchema`, or built with `templateExpressionInput` — -takes a bare, non-blank string (shorthand for its own dialect) or an -envelope declaring that one dialect. An envelope naming any other dialect, -and a blank string, are refused at the slot with one issue whose message -names the dialect and the fix; a template slot's fix is written in the -placeholder spelling its renderer reads. Only the untyped +`TemplateExpressionInputSchema`, or with the same input carrying its own +refusal sentences (a notify node's `title` / `message`) — takes a bare, +non-blank string (shorthand for its own dialect) or an envelope declaring +that one dialect. An envelope naming any other dialect, and a blank string, +are refused at the slot with one issue whose message names the dialect and +the fix; a template slot's fix is written in the placeholder spelling its +renderer reads. Only the untyped `ExpressionInputSchema` takes every declared dialect in envelope form. Those three are the whole list — it is exactly the `ExpressionDialect` enum diff --git a/packages/spec/api-surface/shared.json b/packages/spec/api-surface/shared.json index 2628bf70e29..cf260910f15 100644 --- a/packages/spec/api-surface/shared.json +++ b/packages/spec/api-surface/shared.json @@ -126,7 +126,6 @@ "singularToPlural (function)", "strictUnknownKeyError (function)", "suggestFieldType (function)", - "templateExpressionInput (const)", "tmpl (function)", "unrecognisedMetaTypeRefusal (function)" ] diff --git a/packages/spec/export-origins/shared.json b/packages/spec/export-origins/shared.json index 6c420a98cf5..33e5c6b4bba 100644 --- a/packages/spec/export-origins/shared.json +++ b/packages/spec/export-origins/shared.json @@ -121,7 +121,6 @@ "singularToPlural": "src/meta-spelling/manifest-collection-spelling.ts#singularToPlural (function)", "strictUnknownKeyError": "src/shared/suggestions.zod.ts#strictUnknownKeyError (function)", "suggestFieldType": "src/shared/field-type-suggestion.ts#suggestFieldType (function)", - "templateExpressionInput": "src/shared/expression.zod.ts#templateExpressionInput (const)", "tmpl": "src/shared/expression.zod.ts#tmpl (function)", "unrecognisedMetaTypeRefusal": "src/meta-spelling/metadata-url-spelling.ts#unrecognisedMetaTypeRefusal (function)" }