Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/22081-notify-refusal-single-brace.md
Original file line number Diff line number Diff line change
@@ -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, 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.
- 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.
11 changes: 7 additions & 4 deletions content/docs/references/shared/expression.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +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` — 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 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
Expand Down
71 changes: 70 additions & 1 deletion packages/lint/src/lint-flow-patterns.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
12 changes: 12 additions & 0 deletions packages/qa/dogfood/test/expression-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ const EXPRESSION_INPUT_SCHEMAS = [
'SettingsVisibilityInputSchema',
'CronExpressionInputSchema',
'TemplateExpressionInputSchema',
// [#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',
];
/**
* A roster (or alias) name as an IDENTIFIER, anywhere on the line — #17630.
Expand Down
83 changes: 73 additions & 10 deletions packages/spec/src/automation/io-node-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ReadonlyArray<unknown>> }): string[] {
const nested = (issue.errors ?? []).flat() as Array<{ message: string; errors?: ReadonlyArray<ReadonlyArray<unknown>> }>;
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<string>>([['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
Expand Down
Loading
Loading