Skip to content

Commit 15ec50e

Browse files
fix(spec): a notify title / message refusal prescribes the single-brace {record.name} its renderer reads (#22124)
Fixes #22081 Clause-②: no A notify flow node's `title` / `message` refusal now prescribes `'{record.name}'`, the single-brace spelling the notify executor reads. It used to answer with the shared template sentence, which prescribes `'{{record.name}}'`. The build's `flow-double-brace-interpolation` rule then flagged that spelling on the same node, and the notify renderer sent it inside a stray pair of braces. Every other template slot keeps its sentence. Triage grade `6039475060`, as amended by `6042027032` and `6042955917`. Rework round 1 (`6046879490`) applied: `@objectstack/spec`'s public face does not move. ## What changed - **Per-slot prescription, not a both-conventions sentence.** The refusing site can know the slot. The notify `title` / `message` are declared in `NotifyConfigSchema` (`packages/spec/src/automation/io-node-config.zod.ts`). That declaration can carry its own sentences, so the ruling's both-conventions fallback is not needed. The union's message comes from the schema built at the slot. The build's flow judge (`flowNodeConfigRefusals`, which `FlowSchema`, `registerFlow` and `os validate` share) quotes that same message. - **A package-internal constructor**, `packages/spec/src/shared/typed-expression-input.ts`. `shared/index.ts` and the root barrel do not re-export it; `refinement-projection.ts` is the precedent. It holds `cronExpressionInput(ExpressionSchema, refusals)` and `templateExpressionInput(ExpressionSchema, refusals)` over one private union builder. The accept set is fixed by the dialect, and the refusal sentences are an argument. `CronExpressionInputSchema` and `TemplateExpressionInputSchema` are built from it with their shared sentences. `TYPED_EXPRESSION_SOURCE_REQUIRED` and `TYPED_EXPRESSION_DIALECT_ONLY` are byte-unchanged. The notify slots are built from it with their own sentences. - **Cycle avoidance.** `expression.zod.ts` imports the module at runtime, so the module must not import `expression.zod.ts` back. It imports `ExpressionSchema` and `TypedExpressionDialect` as TYPES only (`import type`, erased), and the caller passes `ExpressionSchema` in. Each dialect's envelope arm is still spelled once, inside the module. - **Why two constructors, not one generic.** A single generic `typedExpressionInput(expression, dialect, …)` was measured first. Its `expression.safeExtend({ dialect: z.literal(dialect) })` fails type-checking, because a `ZodLiteral` over a generic dialect `D` is not provably assignable to the `ExpressionDialect` key it narrows (TS2322). So each constructor narrows with a concrete literal. - **Public face measured unchanged.** The declarations tsc emits for `CronExpressionInputSchema`, `TemplateExpressionInputSchema` and their input types are byte-identical at the merge base and at HEAD (26 lines each, `tsc` exit 0 both). `expression.zod.d.ts` exports the same 31 names, and neither constructor appears in it. - **The string arm carries the slot's sentence too, not only the union.** `formatZodIssue` and the API error mapper expand an `invalid_union`'s branches beneath its own line. A branch that still named the shared sentence would print `'{{record.name}}'` under the right prescription. The leg-2 ablation below measures this. - **The notify sentences** (`notifyTemplateRefusals(key)`, `io-node-config.zod.ts`) name the key and prescribe `'{record.name}'` or `{ dialect: 'template', source: '{record.name}' }`. They also say why: the notify executor interpolates single-brace `{token}` placeholders, and a doubled brace keeps its outer braces. They are spelled with no doubled brace at all. - **Docblocks.** The `tmpl` docblock no longer says "Mustache-template" or shows only `{{record.x}}`. It now has one bullet per spelling, naming the renderer behind each: `{{record.x}}` for the formula template engine and the messaging, email and i18n renderers; `{record.x}` for a notify node's `title` / `message`, rendered by the flow interpolator; either for `titleFormat`. The `TemplateExpressionInputSchema` docblock gains the notify bullet. Its closing advice changes from "write `{{var}}` unless the renderer normalizes" to "write the spelling the slot's renderer reads". - **Regenerated:** `content/docs/references/shared/expression.mdx`. The module header now says a typed slot may carry its own refusal sentences, and it does not name the constructor. `packages/spec/api-surface/**` and `packages/spec/export-origins/**` are byte-identical to the merge base: `git diff --stat 54ace18 HEAD -- packages/spec/api-surface packages/spec/export-origins` prints nothing. ## The one place the notify prescription lives `NOTIFY_TEMPLATE_PLACEHOLDER = '{record.name}'` in `packages/spec/src/automation/io-node-config.zod.ts`. Both notify refusal sentences read it, and so does the existing blank-envelope `source` refusal (`notifyTemplateSourceRequired`, byte-identical output). When #22110 flips the notify convention on the v18 line, this constant is the prescription that flips with it. The slot `.describe()` prose explains the renderer and is rewritten by that card in any case. Per amendment `6042955917`, this card does not wait on #22110. ## Measurements Before (`a543e244f`, read from `packages/spec/src` with `tsx`; reproduces the filer's readings on `d4680d2820`): - `NotifyConfigSchema` `title` = `' '` / `''`: `invalid_union`, `TYPED_EXPRESSION_SOURCE_REQUIRED.template`, ending `Write '{{record.name}}' or { dialect: 'template', source: '{{record.name}}' }`. `title` = `42` or a `cel` envelope: `invalid_union`, `TYPED_EXPRESSION_DIALECT_ONLY.template`, same ending. `message` gives the same. `flowNodeConfigRefusals('notify', …)` quotes it (`node-config-refused-by-contract`), and a doubled brace appears in every refusal tree. - `lintFlowPatterns`: both shared prescriptions draw one `flow-double-brace-interpolation` each on a notify `title`. This is now the control leg of the lint pin below, and it is green on this branch. After (HEAD `681d77223`): the same eight notify cases answer `invalid_union` with the notify sentence. `'{record.name}'` is prescribed, and no doubled brace appears in the union message, in any branch issue, or in the flow judge's message. The shared schema's two sentences are unchanged. The probe output is byte-identical to round 0's, so moving the constructor changed no refusal. ## Pins - `packages/spec/src/automation/io-node-config.test.ts`: refusal `code` plus prescribed spelling plus named key, for blank values and for non-template values on both keys; no doubled brace anywhere in the issue tree; the flow judge quotes the prescription; control: the shared sentences still prescribe `'{{record.name}}'`. - `packages/spec/src/shared/typed-expression-envelope-dialect.test.ts`: `templateExpressionInput`, imported from the internal module, accepts exactly what `TemplateExpressionInputSchema` accepts and parses to the same value; it refuses with the given sentence by kind, and those sentences are the only refusal text; control: `PromptTemplate.user`, which keeps the shared input, still refuses with the `'{{record.name}}'` sentences. The existing `objects.0.titleFormat` pin stays. Keeping the constructor off the public face is held by `check:api-surface`, which fails on any added export. - `packages/spec/src/shared/expression-dialect-docs.pin.test.ts`: the `tmpl` docblock has a `{{record.x}}` bullet naming messaging and email (not notify), and a `{record.x}` bullet naming notify and `flow-double-brace-interpolation` (not messaging or email). The pin checks which renderer goes with which spelling, not the wording. - `packages/lint/src/lint-flow-patterns.test.ts`: lint round trip. Each spelling the notify refusal prescribes is read out of the refusal text, not re-typed in the test. Each one parses through `NotifyConfigSchema` and `FlowSchema` and draws no `flow-double-brace-interpolation`. Control: the shared sentence's two prescriptions each draw the finding. - `packages/qa/dogfood/test/expression-conformance.test.ts`: `cronExpressionInput` and `templateExpressionInput` join the census roster. Without them, `NotifyConfigSchema.title` / `.message` would drop out of discovery and `template-notify-content` would go STALE. Both positions stay `head`-discovered. The constructors' own definitions live in a plain `.ts` module that the `.zod.ts` scan never reads. Ablation (one-time, at `681d77223`; `scripts/ablation-replace.mjs` wrap mode under a restore trap; spec tests resolve `src` through relative imports, so no rebuild was needed): - Leg 1: `NOTIFY_TEMPLATE_PLACEHOLDER` set back to `'{{record.name}}'` (anchor 1 → 0, blob `9f53bb6cfb5c` → `05ffd58adbd3`). `io-node-config.test.ts`: 3 failed / 33 passed. Restored: blob == HEAD, `git diff HEAD` empty. - Leg 2: the internal module's string arm carries a doubled-brace sentence instead of `refusals.sourceRequired` (blob `6222e3e7f5be` → `0b6ae37053ed`). 2 failed / 73 passed, both "no doubled brace in the tree" pins. Restored: blob == HEAD, `git diff HEAD` empty. The trap re-verified both blobs. - The lint round trip was not ablated by rebuilding `@objectstack/spec`. Its control leg shows the rule flags the shared prescriptions on the same flow shape. ## Local verification (HEAD `681d77223`) Each run went through the shared verify lock; the verdict line is quoted. - `pnpm --filter '@objectstack/lint...' build`: dependency closure plus lint, `VERDICT command-exit 0`. - `pnpm --filter @objectstack/spec test`: 622 files, 18587 passed, 1 todo. - `pnpm --filter @objectstack/spec typecheck`: exit 0. The test layer holds its debt ledger: 52 files, 246 errors, 135 pinned signatures, no new ones. - `pnpm --filter @objectstack/lint test`: 123 files, 5680 passed. The round-trip pin alone (`-t "notify slot refusal"`): 3 passed. - `pnpm --filter @objectstack/lint typecheck`: exit 0. Test layer: 2 files, 6 errors held. - `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/expression-conformance.test.ts`: 7 passed. The first attempt answered `Failed to resolve entry for package "@objectstack/verify"`, an unbuilt prerequisite, so it was NOT MEASURED. It was re-run after the gate pass had built the workspace. - `pnpm --filter @objectstack/spec check:generated`: `All 15 generated artifacts are up to date` after `gen:api-surface`, `gen:export-origins` and `gen:docs`. Those three were the only artifacts it proved stale. The first two now regenerate to the merge base's bytes. - `eslint --no-inline-config --format json` over the 8 touched TS files: 8 files, 0 errors, 0 warnings, exit 0. Every file resolves a config (`--print-config`). `eslint.config.mjs` sets no `parserOptions.project` / `projectService`, so the linting is not type-aware and a verdict on an untouched file cannot move with this diff. This is a narrowing; the repo-wide `pnpm lint` is CI's. Session: `session_01RPo7FUd6bSnAfkWMAKi848` (PM dispatch, claim `6044705682`). Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 109 commands at `681d77223`, run one at a time with each exit code captured before any pipe and reconciled with `--ran`: 109 of 109 exit 0. Three of them first answered exit 3, PREREQUISITE NOT MET, because their packages were unbuilt: `check:skill-examples`, `check:dual-build-cjs-loads` and `check:lean-entry-closure`. They were re-run once `check:type-check-debt`'s re-measure had built the workspace, and each exited 0. `--ran` with the final coded record: `109 derived famil(ies) accounted for — 109 run, 0 NOT-MEASURED (a DERIVED zero — all 109 recorded an exit code and none of them is 3)`. Not measured locally (declared to CI): `@objectstack/service-automation`. Its notify tests assert `refused at \`title\`` and the executor's contract wording, never the template sentence. Also the whole-workspace type-check lanes, the path-scheduled CI jobs, and the repo-wide `pnpm lint`. `origin/main` (`54ace18c6`) is merged in with a merge commit via `scripts/pm/os-regen-merge.sh`. Main moved one `merge=os-regen` path, `scripts/platform-object-tenancy-census.json`, which this branch never edited. The merge brought no other overlap with this diff. ## Acceptance notes - Triage's control pin names "an email or messaging template slot". No spec slot of either kind is typed with the template input: `EmailTemplateDefinition.subject` / `bodyHtml` are `z.string()`, and messaging declares none. The control is taken at the two slot families that keep the shared input, `PromptTemplate.system` / `user` (described as `{{var}}`) and `Object.titleFormat` (renderers take either). - `packages/lint/scripts/check-doc-formula-expressions.mjs`, `EXPRESSION_SLOT_TYPES` tripwire, re-read after the move: still dormant. The tripwire fires only on a property assignment that carries `@example` JSDoc tags and whose initializer names one of the four public schema names. `NotifyConfigSchema` carries zero `@example` tags, and its `title` / `message` initializers (`templateExpressionInput(ExpressionSchema, …)`) name none of the four. So it is blind to those two slots as before the move, and nothing is there for it to see. Noted, not filed. - `service-automation/src/builtin/notify-node.ts` has a comment that still calls the two slots `TemplateExpressionInputSchema`. The input is identical; only the constructor's sentences differ. Comment only, outside `domain:spec`, noted. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cdeabec commit 15ec50e

10 files changed

Lines changed: 563 additions & 87 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
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.
6+
7+
Clause-②: no
8+
9+
- 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.
10+
- 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.
11+
- 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.
12+
- 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.
13+
- 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.

‎content/docs/references/shared/expression.mdx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,13 @@ cron-typed slot is parsed and reaches no engine, and `@objectstack/formula`'s
3232
registered `cron` engine has no caller outside that package.
3333

3434
A TYPED slot — one declared with `CronExpressionInputSchema` or
35-
`TemplateExpressionInputSchema` — takes a bare, non-blank string (shorthand
36-
for its own dialect) or an envelope declaring that one dialect. An envelope
37-
naming any other dialect, and a blank string, are refused at the slot with
38-
one issue whose message names the dialect and the fix. Only the untyped
35+
`TemplateExpressionInputSchema`, or with the same input carrying its own
36+
refusal sentences (a notify node's `title` / `message`) — takes a bare,
37+
non-blank string (shorthand for its own dialect) or an envelope declaring
38+
that one dialect. An envelope naming any other dialect, and a blank string,
39+
are refused at the slot with one issue whose message names the dialect and
40+
the fix; a template slot's fix is written in the placeholder spelling its
41+
renderer reads. Only the untyped
3942
`ExpressionInputSchema` takes every declared dialect in envelope form.
4043

4144
Those three are the whole list — it is exactly the `ExpressionDialect` enum

‎packages/lint/src/lint-flow-patterns.test.ts‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { TimeRelativeTriggerSchema, LoopConfigSchema, ParallelConfigSchema, TryCatchConfigSchema, HttpConfigSchema, FlowSchema } from '@objectstack/spec/automation';
4+
import { TimeRelativeTriggerSchema, LoopConfigSchema, ParallelConfigSchema, TryCatchConfigSchema, HttpConfigSchema, FlowSchema, NotifyConfigSchema } from '@objectstack/spec/automation';
5+
import { TYPED_EXPRESSION_DIALECT_ONLY, TYPED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec/shared';
56
// [#5659] The shared identity reduction, asserted beside the rule that consumes
67
// it — the rule's verdict and the drivers' verdict are one object now.
78
import { reduceFilterVerdict } from '@objectstack/spec/data';
@@ -2417,6 +2418,74 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule
24172418
.filter((f) => f.rule === FLOW_DOUBLE_BRACE_INTERP);
24182419
expect(fnds).toHaveLength(1);
24192420
});
2421+
2422+
/**
2423+
* [#22081] The round trip an author makes after a refusal: write what it
2424+
* prescribes, then build. A notify node's `title` / `message` refusal
2425+
* (`NotifyConfigSchema`, `@objectstack/spec`) used to prescribe the shared
2426+
* template input's `{{record.name}}` — which this rule then flagged on the
2427+
* same node. Every spelling the refusal prescribes, read out of the refusal
2428+
* text itself rather than re-spelled here, must parse and draw no finding.
2429+
* The control is the shared sentence the notify slots used to answer with:
2430+
* its prescriptions still draw the finding, so this pin can fail.
2431+
*/
2432+
describe('a notify slot refusal prescribes only spellings this rule passes', () => {
2433+
function notifyFlow(key: 'title' | 'message', value: unknown) {
2434+
return {
2435+
flows: [{
2436+
name: 'deal_won_notice',
2437+
label: 'Deal won notice',
2438+
type: 'autolaunched',
2439+
nodes: [
2440+
{ id: 'start', type: 'start', label: 'Start' },
2441+
{ id: 'tell', type: 'notify', label: 'Tell owner', config: { recipients: ['u1'], title: 'Deal won', [key]: value } },
2442+
{ id: 'done', type: 'end', label: 'Done' },
2443+
],
2444+
edges: [{ id: 'e1', source: 'start', target: 'tell' }, { id: 'e2', source: 'tell', target: 'done' }],
2445+
}],
2446+
};
2447+
}
2448+
2449+
/** The two spellings a template refusal prescribes — "Write `'X'` or `{ dialect: 'template', source: 'X' }`". */
2450+
function prescribedIn(refusal: string): unknown[] {
2451+
const bare = /Write `'([^`']+)'`/.exec(refusal)?.[1];
2452+
const envelope = /`\{ dialect: 'template', source: '([^`']+)' \}`/.exec(refusal)?.[1];
2453+
return [bare, envelope === undefined ? undefined : { dialect: 'template', source: envelope }];
2454+
}
2455+
2456+
function doubleBraceFindings(key: 'title' | 'message', value: unknown) {
2457+
return lintFlowPatterns(notifyFlow(key, value)).filter((f) => f.rule === FLOW_DOUBLE_BRACE_INTERP);
2458+
}
2459+
2460+
it.each(['title', 'message'] as const)('`%s`: each prescribed spelling parses and draws no `flow-double-brace-interpolation`', (key) => {
2461+
for (const refused of [' ', 42]) {
2462+
const parsed = NotifyConfigSchema.safeParse({ recipients: ['u1'], title: 'Deal won', [key]: refused });
2463+
const refusal = parsed.success ? undefined : parsed.error.issues.find((i) => i.path[0] === key);
2464+
expect(refusal?.code, `${key} = ${JSON.stringify(refused)}`).toBe('invalid_union');
2465+
const prescribed = prescribedIn(refusal!.message);
2466+
expect(prescribed.every((p) => p !== undefined), `no prescription read out of: ${refusal!.message}`).toBe(true);
2467+
for (const spelling of prescribed) {
2468+
const label = `${key} = ${JSON.stringify(spelling)}`;
2469+
const flow = notifyFlow(key, spelling).flows[0];
2470+
const config = NotifyConfigSchema.safeParse(flow.nodes[1]!.config);
2471+
expect(config.success, `${label}: ${JSON.stringify(config.error?.issues)}`).toBe(true);
2472+
const whole = FlowSchema.safeParse(flow);
2473+
expect(whole.success, `${label}: ${JSON.stringify(whole.error?.issues)}`).toBe(true);
2474+
expect(doubleBraceFindings(key, spelling), label).toEqual([]);
2475+
}
2476+
}
2477+
});
2478+
2479+
it('control: the shared template sentence\'s prescriptions draw the finding on a notify node', () => {
2480+
for (const sentence of [TYPED_EXPRESSION_SOURCE_REQUIRED.template, TYPED_EXPRESSION_DIALECT_ONLY.template]) {
2481+
const prescribed = prescribedIn(sentence);
2482+
expect(prescribed.every((p) => p !== undefined), sentence).toBe(true);
2483+
for (const spelling of prescribed) {
2484+
expect(doubleBraceFindings('title', spelling), JSON.stringify(spelling)).toHaveLength(1);
2485+
}
2486+
}
2487+
});
2488+
});
24202489
});
24212490

24222491
describe('flow-bare-dollar-reference', () => {

‎packages/qa/dogfood/test/expression-conformance.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,18 @@ const EXPRESSION_INPUT_SCHEMAS = [
105105
'SettingsVisibilityInputSchema',
106106
'CronExpressionInputSchema',
107107
'TemplateExpressionInputSchema',
108+
// [#22081] The package-internal constructors (`shared/typed-expression-input.ts`)
109+
// the two typed schemas above are built with: the same input, refusing with
110+
// the slot's own sentences. A notify node's `title` / `message` are typed
111+
// with a call of `templateExpressionInput` (their refusals prescribe the flow
112+
// interpolator's `{record.name}`), so without its row both positions drop out
113+
// of discovery and `template-notify-content` goes STALE — #7327's shape
114+
// again; `cronExpressionInput` is listed for the same reason ahead of a slot
115+
// that needs it. Their definitions live in a plain `.ts` module this
116+
// `.zod.ts` scan never reads, and the two `export const …Schema = …(…)` lines
117+
// in `expression.zod.ts` are roster definitions `LOCAL_BINDING` skips.
118+
'cronExpressionInput',
119+
'templateExpressionInput',
108120
];
109121
/**
110122
* A roster (or alias) name as an IDENTIFIER, anywhere on the line — #17630.

‎packages/spec/src/automation/io-node-config.test.ts‎

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import { describe, expect, it } from 'vitest';
1919

2020
import { HttpConfigSchema, NotifyConfigSchema } from './io-node-config.zod.js';
21+
import { flowNodeConfigRefusals } from './flow-node-config-refusals.js';
2122
import {
2223
TYPED_EXPRESSION_DIALECT_ONLY,
2324
TYPED_EXPRESSION_SOURCE_REQUIRED,
@@ -408,26 +409,88 @@ describe('NotifyConfigSchema — an unknown key is refused, not stripped', () =>
408409
expect(envelope.data?.message).toEqual(expected);
409410
});
410411

411-
it('still refuses a value that is neither a string nor a template envelope — one issue at the key', () => {
412+
// A refusal is the one place an author is told exactly what to write, and
413+
// an AI author writes it verbatim. These two slots' renderer is the flow
414+
// interpolator, which reads single-brace `{token}` only, so their refusals
415+
// prescribe `{record.name}` — never the shared template input's
416+
// `{{record.name}}`, which the build's `flow-double-brace-interpolation`
417+
// rule flags on this very node and the executor would send with a stray
418+
// pair of braces (#22081). The lint round trip of each prescribed spelling
419+
// is pinned in `@objectstack/lint` (`lint-flow-patterns.test.ts`).
420+
const BARE_PRESCRIPTION = "`'{record.name}'`";
421+
const ENVELOPE_PRESCRIPTION = "`{ dialect: 'template', source: '{record.name}' }`";
422+
const BLANK = ['', ' '];
423+
const FOREIGN = [42, true, ['a'], { source: TEXT }, { dialect: 'cel', source: 'record.x' }];
424+
425+
/** Every message in the refusal's tree: the union's own, then each branch issue beneath it. */
426+
function messagesIn(issue: { message: string; errors?: ReadonlyArray<ReadonlyArray<unknown>> }): string[] {
427+
const nested = (issue.errors ?? []).flat() as Array<{ message: string; errors?: ReadonlyArray<ReadonlyArray<unknown>> }>;
428+
return [issue.message, ...nested.flatMap(messagesIn)];
429+
}
430+
431+
it('refuses a blank bare string, or a value that is neither a string nor a template envelope, with one issue prescribing `{record.name}`', () => {
412432
for (const key of ['title', 'message'] as const) {
413-
for (const value of [42, true, ['a'], { source: TEXT }, { dialect: 'cel', source: 'record.x' }]) {
414-
const issues = issuesAt({ recipients: ['u1'], title: 'x', [key]: value }, key);
415-
expect(issues, `${key} = ${JSON.stringify(value)}`).toEqual([
416-
{ code: 'invalid_union', message: TYPED_EXPRESSION_DIALECT_ONLY.template },
417-
]);
433+
const sentences = new Map<'blank' | 'foreign', Set<string>>([['blank', new Set()], ['foreign', new Set()]]);
434+
for (const [kind, values] of [['blank', BLANK], ['foreign', FOREIGN]] as const) {
435+
for (const value of values) {
436+
const label = `${key} = ${JSON.stringify(value)}`;
437+
const issues = issuesAt({ recipients: ['u1'], title: 'x', [key]: value }, key);
438+
expect(issues.map((i) => i.code), label).toEqual(['invalid_union']);
439+
const message = issues[0]!.message;
440+
expect(message, label).toContain(`\`${key}\``);
441+
expect(message, label).toContain(BARE_PRESCRIPTION);
442+
expect(message, label).toContain(ENVELOPE_PRESCRIPTION);
443+
sentences.get(kind)!.add(message);
444+
}
445+
}
446+
// One sentence per kind, and the two kinds are told apart.
447+
expect(sentences.get('blank')!.size, key).toBe(1);
448+
expect(sentences.get('foreign')!.size, key).toBe(1);
449+
expect([...sentences.get('blank')!][0]).not.toBe([...sentences.get('foreign')!][0]);
450+
}
451+
});
452+
453+
it('carries no doubled brace anywhere in the refusal — the branch issues the formatters expand included', () => {
454+
// `formatZodIssue` and the wire mapper both expand an `invalid_union`'s
455+
// branches beneath its own line, so a branch still naming the shared
456+
// input's `{{record.name}}` would reach the author under the right one.
457+
for (const key of ['title', 'message'] as const) {
458+
for (const value of [...BLANK, ...FOREIGN]) {
459+
const result = NotifyConfigSchema.safeParse({ recipients: ['u1'], title: 'x', [key]: value });
460+
const refusal = result.error!.issues.find((i) => i.path.length === 1 && i.path[0] === key)!;
461+
const messages = messagesIn(refusal as unknown as { message: string });
462+
expect(messages.length, `${key} = ${JSON.stringify(value)}: the tree was not read`).toBeGreaterThan(0);
463+
expect(messages.filter((m) => m.includes('{{')), `${key} = ${JSON.stringify(value)}`).toEqual([]);
418464
}
419465
}
420466
});
421467

422-
it('refuses a blank bare string at the slot (the shared non-blank rule)', () => {
468+
it('reaches the build with the same prescription — the flow judge `FlowSchema`, `registerFlow` and `os validate` share', () => {
423469
for (const key of ['title', 'message'] as const) {
424-
for (const value of ['', ' ']) {
425-
expect(issuesAt({ recipients: ['u1'], title: 'x', [key]: value }, key), `${key} = ${JSON.stringify(value)}`)
426-
.toEqual([{ code: 'invalid_union', message: TYPED_EXPRESSION_SOURCE_REQUIRED.template }]);
470+
for (const value of [' ', 42]) {
471+
const refusals = flowNodeConfigRefusals('notify', { recipients: ['u1'], title: 'x', [key]: value })
472+
.filter((r) => r.path === key);
473+
expect(refusals.map((r) => r.code), `${key} = ${JSON.stringify(value)}`).toEqual(['node-config-refused-by-contract']);
474+
expect(refusals[0]!.message).toContain(BARE_PRESCRIPTION);
475+
expect(refusals[0]!.message).not.toContain('{{');
427476
}
428477
}
429478
});
430479

480+
it('control — the shared template input, whose renderers read `{{var}}`, keeps prescribing `{{record.name}}`', () => {
481+
// The notify slots took their own sentences; the shared one did not
482+
// move. `typed-expression-envelope-dialect.test.ts` pins it at the slots
483+
// that answer with it (`titleFormat`, the prompt template).
484+
expect(TYPED_EXPRESSION_SOURCE_REQUIRED.template).toContain("`'{{record.name}}'`");
485+
expect(TYPED_EXPRESSION_DIALECT_ONLY.template).toContain("`'{{record.name}}'`");
486+
for (const key of ['title', 'message'] as const) {
487+
const blank = issuesAt({ recipients: ['u1'], title: 'x', [key]: '' }, key)[0]!.message;
488+
const foreign = issuesAt({ recipients: ['u1'], title: 'x', [key]: 42 }, key)[0]!.message;
489+
expect(blank).not.toBe(TYPED_EXPRESSION_SOURCE_REQUIRED.template);
490+
expect(foreign).not.toBe(TYPED_EXPRESSION_DIALECT_ONLY.template);
491+
}
492+
});
493+
431494
it('refuses an envelope with no non-blank `source` — the executor renders `source` and nothing else', () => {
432495
// The shared envelope arm is the persistence contract and admits both of
433496
// these; this slot's executor cannot render either, so the slot refuses

0 commit comments

Comments
 (0)