From 774c14f19254f13ee7fb0c00560cde9223b47425 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 03:19:15 +0000 Subject: [PATCH 1/8] feat(spec): refuse the date macros in flow value slots with their CEL string form The value-slot judge keeps no token now: `{TODAY()}` / `{TODAY() +- N}` / `{NOW()}` / `{NOW() +- N}` are refused, naming `isoDate(today())`, `isoDate(daysFromNow(N))` / `isoDate(daysAgo(N))`, `isoDatetime(now())` and `isoDatetime(addDays(now(), N))`, with the fractional, variable and unreadable offset edges named. Where an envelope is literal data (a string nested in an object or list literal, or a legacy assignment shape) the remedy names the whole-value CEL literal or the canonical map instead of an envelope at that position. The text-slot remedy and the step-18 D3 entries follow. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .../automation/builtin-node-config.test.ts | 11 +- .../src/automation/builtin-node-config.zod.ts | 10 +- .../src/automation/flow-template-token.ts | 113 ++++++++ .../flow-text-slot-template.test.ts | 29 +- .../src/automation/flow-text-slot-template.ts | 28 +- .../flow-value-slot-template.test.ts | 174 ++++++++++-- .../automation/flow-value-slot-template.ts | 257 +++++++++++++----- .../18.flow-text-slot-single-brace-refused.ts | 6 +- ...low-value-slot-template-dialect-refused.ts | 37 ++- packages/spec/src/migrations/registry.ts | 49 ++-- 10 files changed, 569 insertions(+), 145 deletions(-) diff --git a/packages/spec/src/automation/builtin-node-config.test.ts b/packages/spec/src/automation/builtin-node-config.test.ts index fcaa1ea8d7d..0f54b421739 100644 --- a/packages/spec/src/automation/builtin-node-config.test.ts +++ b/packages/spec/src/automation/builtin-node-config.test.ts @@ -466,11 +466,14 @@ describe('assignment value contract — a CEL envelope beside literals; the `{to list: ['a', 2], obj: { nested: 'x', source: 'not an envelope without a dialect' }, empty: '', - // The one `{…}` spelling the retirement keeps until CEL can write it - // (the run user is refused since #19939 pass 2: `current_user.id`). - due: '{TODAY() + 7}', }, }).success).toBe(true); + // No `{…}` spelling is kept (#19939 pass 3): a date macro is refused, and + // its CEL string form is the envelope that writes the same text. + expect(AssignmentConfigSchema.safeParse({ assignments: { due: '{TODAY() + 7}' } }).success).toBe(false); + expect(AssignmentConfigSchema.safeParse({ + assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' } }, + }).success).toBe(true); // An envelope-shaped object with a non-string `dialect` is a literal, as it always was. expect(AssignmentConfigSchema.safeParse({ assignments: { weird: { dialect: 1 } } }).success).toBe(true); // An empty node is valid (the descriptor declares no `required`). @@ -849,7 +852,7 @@ describe('CRUD `fields` value contract — the CEL value envelope beside literal it.each(configs)('%s PRESERVATION: every literal still parses, unchanged', (_type, schema, wrap) => { const fields = { subject: 'Follow up', // text - due_date: '{TODAY() + 7}', // a date macro — kept until CEL can write it + due_date: '2026-10-17', // an ISO date, written as it is (a date macro is refused, #19939) cel_looking_text: 'a + b', // a STRING is never CEL here n: 3, ok: true, nothing: null, empty: '', tags: ['a', 2, { dialect: 'cel' }], // arrays are data, envelope-shaped members included diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index 08bca20ad72..cc311d312d5 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -399,8 +399,8 @@ function celValueSlotSchema(description: string) { * spelling ({@link valueSlotTemplateRefusals} — every token measured lossy * under conversion, so none is rewritten, ADR-0087 D2). The run user is the * CEL scope's `current_user` (`{$User.Id}` is refused, naming - * `current_user.id`). One spelling CEL cannot write yet keeps its 17.x - * meaning until it can: the date macros (`{NOW()}`, `{TODAY() + 7}`). + * `current_user.id`), and a date macro is its CEL string form (`{TODAY() + 7}` + * is refused, naming `isoDate(daysFromNow(7))`). No `{…}` spelling is kept. * * The slot-neutral contract. The CRUD `fields` map's values take it * (`CreateRecordConfigSchema` / `UpdateRecordConfigSchema`, #19938), and it is @@ -412,7 +412,8 @@ function celValueSlotSchema(description: string) { export const FlowValueSlotSchema = celValueSlotSchema( 'A value: a CEL value envelope `{ dialect: \'cel\', source }` evaluated by the expression engine (the CEL stdlib ' + 'such as `joinNonEmpty` is reachable), or a literal written as it is — a `{…}` template token in a string is ' - + 'refused (the template dialect is retired from value slots; the date macros are kept for now)', + + 'refused (the template dialect is retired from value slots; a date macro is its CEL string form, ' + + '`isoDate(today())` / `isoDatetime(now())`)', ); export type FlowValueSlot = z.input; @@ -1082,7 +1083,8 @@ export type MapConfigParsed = z.infer; export const AssignmentValueSchema = celValueSlotSchema( 'Value the variable takes: a CEL value envelope `{ dialect: \'cel\', source }` evaluated by the expression engine ' + '(the CEL stdlib such as `joinNonEmpty` is reachable), or a literal written as it is — a `{…}` template token in ' - + 'a string is refused (the template dialect is retired from value slots; the date macros are kept for now)', + + 'a string is refused (the template dialect is retired from value slots; a date macro is its CEL string form, ' + + '`isoDate(today())` / `isoDatetime(now())`)', ); export type AssignmentValue = z.input; diff --git a/packages/spec/src/automation/flow-template-token.ts b/packages/spec/src/automation/flow-template-token.ts index 8b5be3d9cad..5ca227356df 100644 --- a/packages/spec/src/automation/flow-template-token.ts +++ b/packages/spec/src/automation/flow-template-token.ts @@ -219,6 +219,119 @@ export function runUserPathNeverResolved(text: string, write: (path: string) => ); } +/** The CEL spelling of a date macro ({@link celDateMacro}). */ +export interface CelDateMacro { + /** + * The CEL source whose value is the text the macro wrote: `isoDate(…)` for + * `TODAY()` (`YYYY-MM-DD`), `isoDatetime(…)` for `NOW()`, both on the UTC + * calendar the interpolator renders on. + */ + readonly source: string; + /** + * What the remedy must say beside {@link source} — set when the two do not + * write the same text for every input: an offset that is not a whole number, + * a variable offset, or an offset that is neither. + */ + readonly edge?: string; +} + +/** + * **The CEL spelling of a date macro** — `{TODAY()}`, `{NOW()}`, with an + * optional `± N` day offset — read the way `resolveToken` reads it: the offset + * is a number literal, else a variable looked up by its whole text, else 0. + * + * - `{TODAY()}` → `isoDate(today())`; `{TODAY() + N}` / `{TODAY() - N}` → + * `isoDate(daysFromNow(N))` / `isoDate(daysAgo(N))`; + * - `{NOW()}` → `isoDatetime(now())`; `{NOW() ± N}` → + * `isoDatetime(addDays(now(), ±N))`, which keeps the time of day where + * `daysFromNow` would land on midnight. + * + * Byte for byte with the interpolator for a whole number of days — the cell + * grid `stdlib-timestamp-text.test.ts` measures in `@objectstack/formula`, and + * the live parity pin beside `crud-fields-value-envelope.test.ts` in + * `service-automation` drives both engines over the same instants. The three + * edges where they part are named in {@link CelDateMacro.edge}: + * + * - **a fraction** (`{TODAY() - 1.5}`): the template added the offset to the + * day of the month and truncated the SUM, `addDays` truncates the OFFSET — + * the same day forward, one day apart backward once the day of the month + * passes the offset — and `daysFromNow` / `daysAgo` take a whole number, so + * `daysAgo(1.5)` is refused at build; + * - **a variable** (`{TODAY() + days}`): the template looked the offset up as + * one variable name and added 0 days, silently, when it found none or the + * value was not a number; CEL reads the path, an absent variable fails the + * run, and a value that is not a number is an invalid date `isoDate` refuses; + * - **neither** (`{TODAY() + 3d}`): the template added 0 days, silently. + * + * `undefined` for an `inner` that is not a date macro. + */ +export function celDateMacro(inner: string): CelDateMacro | undefined { + const match = DATE_MACRO.exec(inner.trim()); + if (!match) return undefined; + const [, fn, signText, offsetText] = match; + const today = fn === 'TODAY'; + const token = `{${inner.trim()}}`; + const base = today ? 'today()' : 'now()'; + const render = (instant: string): string => (today ? `isoDate(${instant})` : `isoDatetime(${instant})`); + const unsigned = today ? 'daysFromNow(7)' : 'addDays(now(), 7)'; + if (offsetText === undefined) return { source: render(base) }; + const sign = signText === '-' ? -1 : 1; + const asNumber = Number(offsetText); + if (!Number.isNaN(asNumber)) { + const days = sign * asNumber; + // `resolveToken` skips a zero offset (`if (offset)`), so the macro wrote today. + if (days === 0) return { source: render(base) }; + if (Number.isSafeInteger(days)) { + if (!today) return { source: render(`addDays(now(), ${days})`) }; + return { source: render(days < 0 ? `daysAgo(${-days})` : `daysFromNow(${days})`) }; + } + if (Number.isFinite(days)) { + const truncated = Math.trunc(days); + const whole = today ? (days < 0 ? 'daysAgo' : 'daysFromNow') : undefined; + // The day of the month is at least 1, so a fraction under one day moves + // back one day on every date, and a larger one only past that day. + const back = Math.ceil(-days); + const moved = days < 0 + ? `moved ${back} day${back === 1 ? '' : 's'} back` + + (-days > 1 ? ` once the day of the month passed ${-days} (fewer near its start)` : '') + + `, where \`addDays\` moves ${-truncated} back` + : `moved ${truncated} day${truncated === 1 ? '' : 's'} forward, as \`addDays\` does`; + return { + source: render(`addDays(${base}, ${days})`), + edge: + `\`${token}\` is not a whole number of days, the only offset the template documents: it added the offset ` + + `to the day of the month and truncated the sum, so it ${moved} by truncating the offset.` + + (whole ? ` \`${whole}(${Math.abs(days)})\` is refused at build: \`daysFromNow\` and \`daysAgo\` take a whole number.` : '') + + ' Write the whole number of days you mean.', + }; + } + return { + source: render(base), + edge: + `\`${token}\` has an offset that is not a finite number: the template's date came out invalid and the run ` + + `failed. Write the whole number of days you mean — \`${render(unsigned)}\` is seven days on.`, + }; + } + if (VARIABLE_PATH.test(offsetText)) { + return { + source: render(`addDays(${base}, ${sign < 0 ? '-' : ''}${celPath(offsetText)})`), + edge: + `The template looked \`${offsetText}\` up as one variable name${offsetText.includes('.') ? ', never walking a dotted path,' : ''} ` + + 'and added 0 days without a word when it found none or the value was not a number. CEL reads the path, an absent ' + + 'variable fails the run, and a value that is not a number makes `addDays` an invalid date, which ' + + `\`${today ? 'isoDate' : 'isoDatetime'}\` refuses. \`addDays\` truncates a fractional offset, where the ` + + 'template truncated the day of the month plus the offset.', + }; + } + return { + source: render(base), + edge: + `\`${offsetText}\` is neither a number nor a variable name, so the template added 0 days without a word — ` + + `\`${render(base)}\` writes what it wrote. For the offset you meant, write a whole number of days: ` + + `\`${render(unsigned)}\` is seven days on.`, + }; +} + /** * One lexeme of a template expression, in the order the alternatives are * tried: a quoted string (its content is never rewritten), a number, a diff --git a/packages/spec/src/automation/flow-text-slot-template.test.ts b/packages/spec/src/automation/flow-text-slot-template.test.ts index dbb01a338fb..3b25684878a 100644 --- a/packages/spec/src/automation/flow-text-slot-template.test.ts +++ b/packages/spec/src/automation/flow-text-slot-template.test.ts @@ -74,14 +74,25 @@ describe('textSlotTemplateRefusal — the one judge of the single brace in a tex expect(message).not.toContain('Write `'); }); - it('names an assignment whose value slot still reads it for a date macro', () => { - for (const token of ['{TODAY() + 7}', '{NOW()}']) { - const message = textSlotTemplateRefusal(`Due ${token}`)!; - expect(message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL), token).toBe(true); - expect(message, token).toContain(`assignments: { v: '${token}' }`); - // …and that value-slot spelling is one the value slot does keep (#19939). - expect(valueSlotTemplateRefusals(token), token).toEqual([]); - } + // #19939 pass 3: the value slots refuse the date macros too, so a date + // macro is computed with the CEL envelope of its string form — the one the + // value-slot refusal names — never with a value-slot spelling refused in turn. + it.each([ + ['{TODAY() + 7}', 'isoDate(daysFromNow(7))'], + ['{NOW()}', 'isoDatetime(now())'], + ])('names an assignment with the CEL envelope of a date macro\'s string form: %s', (token, source) => { + const message = textSlotTemplateRefusal(`Due ${token}`)!; + expect(message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL)).toBe(true); + expect(message).toContain(`assignments: { v: { dialect: 'cel', source: '${source}' } }`); + expect(message).toContain('`{{ v }}`'); + expect(message).not.toContain(`assignments: { v: '${token}' }`); + expect(valueSlotTemplateRefusals(token)).toHaveLength(1); + expect(valueSlotTemplateRefusals(token)[0]!.message).toContain(`source: '${source}' }`); + }); + + it('names where a date macro\'s string form parts from the template, as the value slot does', () => { + expect(textSlotTemplateRefusal('Due {TODAY() - 1.5}')).toContain('`daysAgo(1.5)` is refused at build'); + expect(textSlotTemplateRefusal('Due {TODAY() + days}')).toContain('added 0 days without a word'); }); // #19939 pass 2: the value slots refuse `{$User.*}`, so the run user's id is @@ -129,7 +140,7 @@ describe('textSlotTemplateRefusal — the one judge of the single brace in a tex it('names each unspellable token once, after the rewrite of the paths', () => { const message = textSlotTemplateRefusal('{name}: {NOW()} / {NOW()}')!; expect(message.indexOf('`{{ name }}: {NOW()} / {NOW()}`')).toBeGreaterThan(0); - expect(message.split("assignments: { v: '{NOW()}' }")).toHaveLength(2); + expect(message.split("assignments: { v: { dialect: 'cel', source: 'isoDatetime(now())' } }")).toHaveLength(2); }); }); diff --git a/packages/spec/src/automation/flow-text-slot-template.ts b/packages/spec/src/automation/flow-text-slot-template.ts index a8653a3a52c..673795e80af 100644 --- a/packages/spec/src/automation/flow-text-slot-template.ts +++ b/packages/spec/src/automation/flow-text-slot-template.ts @@ -56,15 +56,15 @@ * * ## No spelling is kept * - * Unlike the value slots, which keep the date macros because CEL cannot write - * them yet, a text slot keeps nothing: each has a remedy that renders the same - * text — compute it into a variable with an `assignment` node, whose value - * slot still reads the date macro, and write the variable as a hole. The run - * user's id is computed the same way, with the CEL value envelope - * `current_user.id` (the value slots refuse `{$User.*}` since #19939's second - * pass); every other `{$User.}` never resolved, and its remedy says so. - * So the single brace is deleted from the text slots whole, and the two - * dialects never share one string. + * A text slot keeps nothing: each token has a remedy that renders the same + * text — compute it into a variable with an `assignment` node's CEL value + * envelope, and write the variable as a hole. A date macro is its CEL string + * form there (`isoDate(today())`, `isoDatetime(now())`; the value slots refuse + * the macros since #19939's third pass), and the run user's id is + * `current_user.id` (refused there since its second); every other + * `{$User.}` never resolved, and its remedy says so. So the single + * brace is deleted from the text slots whole, and the two dialects never + * share one string. * * ## A `$` root is the engine's (#22477) * @@ -88,6 +88,7 @@ import { CEL_RUN_USER_ID, CEL_RUN_USER_ID_GUARDED, + celDateMacro, celExpression, isRunUserIdToken, runUserPathNeverResolved, @@ -299,11 +300,14 @@ function doubled(text: string, tokens: readonly TemplateToken[]): string { /** The remedy for one token no `{{ }}` hole can spell. */ function unspellableRemedy(token: TemplateToken): string { switch (token.kind) { - case 'date-macro': + case 'date-macro': { + const macro = celDateMacro(token.inner)!; return ( - `\`${token.text}\` is not a variable, so no hole spells it: compute it into a variable with an \`assignment\` ` - + `node, whose value slot still reads it (\`assignments: { v: '${token.text}' }\`), and write \`{{ v }}\` here.` + `\`${token.text}\` is not a variable, so no hole spells it: compute its ISO text into a variable with an ` + + `\`assignment\` node's CEL value envelope (\`assignments: { v: { dialect: 'cel', source: '${macro.source}' } }\`) ` + + 'and write `{{ v }}` here.' + (macro.edge ? ` ${macro.edge}` : '') ); + } case 'user': if (!isRunUserIdToken(token.inner)) return runUserPathNeverResolved(token.text, (path) => `\`{{ ${path} }}\``); return ( diff --git a/packages/spec/src/automation/flow-value-slot-template.test.ts b/packages/spec/src/automation/flow-value-slot-template.test.ts index 15803b11d9f..9ef5e8c193e 100644 --- a/packages/spec/src/automation/flow-value-slot-template.test.ts +++ b/packages/spec/src/automation/flow-value-slot-template.test.ts @@ -13,11 +13,19 @@ * `has()` can take it, arithmetic with every divisor a double * (`/ 100.0`), text with holes as one concatenation, a token that * resolves to nothing with the literal-text escape. - * 2. **Kept.** The date macros, which CEL cannot spell yet, are not refused — - * alone or beside another token. The run user (`{$User.*}`) is refused - * (#19939 pass 2): `{$User.Id}` names `current_user.id` and its guard for - * a user-less run, and every other `$User` path says it never resolved and - * names the read of the user record. + * 2. **Nothing is kept.** The run user (`{$User.*}`) is refused (#19939 + * pass 2): `{$User.Id}` names `current_user.id` and its guard for a + * user-less run, and every other `$User` path says it never resolved and + * names the read of the user record. The date macros are refused (pass 3) + * with their CEL string form — `isoDate(…)` / `isoDatetime(…)` — and the + * edges where it parts from the template: a fraction, a variable offset, an + * offset that is neither. A string mixing them with any other token is + * refused whole, with a remedy for every token. + * 2b. **Where an envelope is data, the remedy names none there.** A string + * inside an object or list literal is spelled inside a CEL literal that + * builds the whole value; a legacy `assignment` shape is moved into the + * canonical map (that each remedy evaluates is pinned through the engine in + * `service-automation`'s `assignment-value-envelope.test.ts`). * 3. **Controls.** A token-free literal, every non-string literal and a CEL * envelope pass; only value slots are judged (a `filter` value is not). * 4. **Every door's contract.** `FlowValueSlotSchema`, `AssignmentValueSchema` @@ -193,20 +201,142 @@ describe('a head the flow CEL scope claims is read through `vars`', () => { }); }); -describe('the one spelling CEL cannot write yet is KEPT — not refused', () => { +/** + * #19939 pass 3 — the date macros are refused with their CEL string form, the + * spelling #22277 gave the stdlib (`isoDate` / `isoDatetime`, UTC). That each + * one writes the bytes the template wrote, over the same instants, is pinned + * through both engines in `service-automation`'s + * `crud-fields-value-envelope.test.ts`. + */ +describe('the date macros are refused, each with its CEL string form', () => { it.each([ - '{NOW()}', - '{TODAY()}', - '{TODAY() + 7}', - '{TODAY() - 3}', - '{TODAY() + expirationDays}', - '{NOW() + 2}', - 'Due {TODAY()} for {name}', - // The kept-spelling leak: a run-user token beside a date macro rides the - // macro until the macros are retired too. - 'Due {TODAY()} by {$User.Id}', - ])('%s', (value) => { - expect(valueSlotTemplateRefusals(value)).toEqual([]); + ['{TODAY()}', 'isoDate(today())'], + ['{TODAY() + 7}', 'isoDate(daysFromNow(7))'], + ['{TODAY()+7}', 'isoDate(daysFromNow(7))'], + ['{ TODAY() - 3 }', 'isoDate(daysAgo(3))'], + ['{TODAY() + 0}', 'isoDate(today())'], + ['{NOW()}', 'isoDatetime(now())'], + ['{NOW() + 2}', 'isoDatetime(addDays(now(), 2))'], + ['{NOW() - 1}', 'isoDatetime(addDays(now(), -1))'], + ['{TODAY() + expirationDays}', 'isoDate(addDays(today(), expirationDays))'], + ['{TODAY() - record.grace_days}', 'isoDate(addDays(today(), -record.grace_days))'], + ['{NOW() + $error.days}', 'isoDatetime(addDays(now(), vars["$error"].days))'], + ])('%s → %s', (token, source) => { + const message = refusalOf(token); + expect(message).toContain(`Write \`${token}\` as { dialect: 'cel', source: '${source}' }`); + expect(message).toContain('on the UTC calendar'); + }); + + it('`{NOW() ± N}` keeps the time of day: the remedy says why it is not `daysFromNow`', () => { + expect(refusalOf('{NOW() + 2}')).toContain('where `daysFromNow` would land on midnight'); + expect(refusalOf('{NOW()}')).not.toContain('daysFromNow'); + }); + + it('a fractional offset: the template truncated the day sum, `addDays` the offset, and `daysAgo(1.5)` is refused at build', () => { + const back = refusalOf('{TODAY() - 1.5}'); + expect(back).toContain("source: 'isoDate(addDays(today(), -1.5))' }"); + expect(back).toContain('truncated the sum, so it moved 2 days back once the day of the month passed 1.5'); + expect(back).toContain('where `addDays` moves 1 back by truncating the offset'); + expect(back).toContain('`daysAgo(1.5)` is refused at build'); + expect(back).toContain('Write the whole number of days you mean.'); + const forward = refusalOf('{TODAY() + 1.5}'); + expect(forward).toContain('moved 1 day forward, as `addDays` does'); + expect(forward).toContain('`daysFromNow(1.5)` is refused at build'); + // `NOW()` names no `daysAgo` / `daysFromNow` — its spelling is `addDays` either way. + expect(refusalOf('{NOW() - 0.5}')).toContain('moved 1 day back, where `addDays` moves 0 back'); + expect(refusalOf('{NOW() - 0.5}')).not.toContain('is refused at build'); + }); + + it('a variable offset the template read as 0 when absent or not a number: CEL refuses both loudly', () => { + const message = refusalOf('{TODAY() + days}'); + expect(message).toContain('added 0 days without a word when it found none or the value was not a number'); + expect(message).toContain('an absent variable fails the run'); + expect(message).toContain('makes `addDays` an invalid date, which `isoDate` refuses'); + expect(message).not.toContain('dotted path'); + expect(refusalOf('{TODAY() + record.days}')).toContain('never walking a dotted path'); + }); + + it('an offset that is neither a number nor a variable: the template added 0 days, so the remedy writes today and says so', () => { + const message = refusalOf('{TODAY() + 3d}'); + expect(message).toContain("source: 'isoDate(today())' }"); + expect(message).toContain('`3d` is neither a number nor a variable name, so the template added 0 days without a word'); + }); + + it('text with holes: the macro is its string form in the concatenation, with its edge named', () => { + const message = refusalOf('Due {TODAY() + 7} for {name}'); + expect(message).toContain(`{ dialect: 'cel', source: "'Due ' + isoDate(daysFromNow(7)) + ' for ' + name" }`); + expect(refusalOf('Due {TODAY() - 1.5}')).toContain('`daysAgo(1.5)` is refused at build'); + }); + + // The kept-spelling leak: while the macros were kept, a run-user token + // beside one rode the macro and was kept whole. Nothing is kept now, so the + // string is refused, naming a remedy for both tokens. + it('the kept-spelling leak is closed: `Due {TODAY()} by {$User.Id}` is refused, with both tokens\' remedies', () => { + const message = refusalOf('Due {TODAY()} by {$User.Id}'); + expect(message).toContain(`{ dialect: 'cel', source: "'Due ' + isoDate(today()) + ' by ' + current_user.id" }`); + expect(message).toContain('`{TODAY()}` is `isoDate(today())`'); + expect(message).toContain("`(current_user != null ? current_user.id : '')`"); + }); + + it('the remedies name no tracker number', () => { + for (const token of ['{TODAY() - 1.5}', '{TODAY() + days}', '{TODAY() + 3d}', '{NOW() + 2}', 'Due {TODAY()} by {$User.Id}']) { + expect(refusalOf(token)).not.toMatch(/#\d/); + } + }); +}); + +/** + * #19939 pass 3 (carry from pass 2): where an envelope is literal DATA — a + * string inside an object or list literal, or either legacy `assignment` shape + * — the remedy prescribes no envelope at that position, because one written + * there is stored as the object it spells. That each named spelling evaluates + * to the template's value is pinned through the engine in + * `service-automation`'s `assignment-value-envelope.test.ts`. + */ +describe('where an envelope is literal data, the remedy names none there', () => { + it('a string inside an object literal: build the whole value as one CEL map literal', () => { + const [refusal] = flowNodeValueTemplateRefusals('assignment', { assignments: { o: { who: '{name}' } } }); + expect(refusal!.path).toBe('assignments.o.who'); + expect(refusal!.message).toContain('sits inside an object or list literal, where nothing evaluates'); + expect(refusal!.message).toContain(`{ dialect: 'cel', source: "{'who': name}" }`); + expect(refusal!.message).toContain('wrap each one in `dyn(…)`'); + // The spelling of the string itself is a bare CEL expression, never an envelope at that position. + expect(refusal!.message).toContain('Write `{name}` as `name`.'); + expect(refusal!.message).not.toContain("{ dialect: 'cel', source: 'name' }"); + }); + + it('a string inside a list literal, and deeper: the literal is built along the path', () => { + const refusals = flowNodeValueTemplateRefusals('create_record', { + objectName: 'q', fields: { tags: ['{x}'], payload: { meta: { note: 'for {name}' } } }, + }); + expect(refusals.map((r) => r.path)).toEqual(['fields.tags[0]', 'fields.payload.meta.note']); + expect(refusals[0]!.message).toContain("{ dialect: 'cel', source: '[x]' }"); + expect(refusals[1]!.message).toContain(`{ dialect: 'cel', source: "{'meta': {'note': 'for ' + name}}" }`); + expect(refusals[1]!.message).toContain("one CEL concatenation, `'for ' + name`"); + }); + + it.each([ + ['the legacy `assignments` array', { assignments: [{ variable: 'o', value: '{name}' }] }, 'assignments[0].value'], + ['the legacy bare config', { o: '{name}' }, 'o'], + ])('%s: move the assignments into the canonical map, where the envelope evaluates', (_label, config, path) => { + const [refusal] = flowNodeValueTemplateRefusals('assignment', config); + expect(refusal!.path).toBe(path); + expect(refusal!.message).toContain('evaluates nothing: an envelope written in it is stored as the object it spells'); + expect(refusal!.message).toContain('Move the node\'s assignments into the canonical map, `assignments: { … }`'); + expect(refusal!.message).toContain("Write `{name}` as { dialect: 'cel', source: 'name' }"); + }); + + it('a nested string in a legacy shape: both — the canonical map, and the whole value as a CEL literal', () => { + const [refusal] = flowNodeValueTemplateRefusals('assignment', { assignments: [{ variable: 'o', value: { who: '{$User.Id}' } }] }); + expect(refusal!.message).toContain('canonical map'); + expect(refusal!.message).toContain(`{ dialect: 'cel', source: "{'who': current_user.id}" }`); + }); + + it('control: the top-level value of a declared slot keeps the envelope remedy, with no literal-position lead', () => { + const [refusal] = flowNodeValueTemplateRefusals('assignment', { assignments: { o: '{name}' } }); + expect(refusal!.message).toContain("Write `{name}` as { dialect: 'cel', source: 'name' }"); + expect(refusal!.message).not.toContain('canonical map'); + expect(refusal!.message).not.toContain('sits inside'); }); }); @@ -317,7 +447,7 @@ describe('controls — what the judge never refuses', () => { it('a string at any depth of a literal is judged, located inside the value', () => { const refusals = valueSlotTemplateRefusals({ meta: { note: 'for {name}' }, list: ['ok', '{x}'], when: '{NOW()}' }); - expect(refusals.map((r) => r.path)).toEqual([['meta', 'note'], ['list', 1]]); + expect(refusals.map((r) => r.path)).toEqual([['meta', 'note'], ['list', 1], ['when']]); }); it('a self-referencing literal does not loop', () => { @@ -335,7 +465,8 @@ describe('every value-slot contract refuses the same set, at the value', () => { const refused = schema.safeParse('{record.amount}'); expect(refused.success).toBe(false); expect(refused.error!.issues[0]!.message.startsWith(VALUE_SLOT_TEMPLATE_REFUSAL)).toBe(true); - expect(schema.safeParse('{TODAY() + 7}').success).toBe(true); + expect(schema.safeParse('{TODAY() + 7}').success).toBe(false); + expect(schema.safeParse({ dialect: 'cel', source: 'isoDate(daysFromNow(7))' }).success).toBe(true); expect(schema.safeParse('literal text').success).toBe(true); }); @@ -360,6 +491,7 @@ describe('`flowNodeValueTemplateRefusals` — every value position of a node, lo expect(refusals.map((r) => [r.path, r.label])).toEqual([ ['fields.owner', `${nodeType} field value`], ['fields.tags[0]', `${nodeType} field value`], + ['fields.due', `${nodeType} field value`], ]); } }); @@ -373,7 +505,7 @@ describe('`flowNodeValueTemplateRefusals` — every value position of a node, lo const refusals = flowNodeValueTemplateRefusals('assignment', { assignments: [{ variable: 'total', value: '{amount}' }, { variable: 'today', value: '{TODAY()}' }], }); - expect(refusals.map((r) => r.path)).toEqual(['assignments[0].value']); + expect(refusals.map((r) => r.path)).toEqual(['assignments[0].value', 'assignments[1].value']); }); it('the legacy bare config — its top-level keys are the variables', () => { diff --git a/packages/spec/src/automation/flow-value-slot-template.ts b/packages/spec/src/automation/flow-value-slot-template.ts index 8ee782870cd..fedf0df3926 100644 --- a/packages/spec/src/automation/flow-value-slot-template.ts +++ b/packages/spec/src/automation/flow-value-slot-template.ts @@ -25,8 +25,8 @@ * * D2 lets a conversion rewrite only what maps LOSSLESSLY. Every token spelling * that occurs in authored flows was measured through the shipped interpolator - * AND the shipped CEL engine over the same variables, and each one has an - * input on which the two answers differ: + * AND the shipped CEL engine over the same variables, and each one but the + * plainest date macros (below) has an input on which the two answers differ: * * - a path (`{x}`, `{a.b}`, `{list.0}`) — CEL refuses an absent variable, * key or index where the template wrote nothing; @@ -34,14 +34,20 @@ * template rendered nothing; * - arithmetic (`{round(x * 100) / 100}`) — CEL divides two integers as * integers (`123.46` becomes `123`); - * - `{NOW()}` / `{TODAY()}` — CEL yields a Timestamp, not the ISO text; * - `{$User.Id}` — in a run with no user the template wrote nothing, while * `current_user` is `null` there, so `current_user.id` fails the run and - * its guarded form writes `null`. + * its guarded form writes `null`; + * - a date macro with an offset the template read as 0 — a variable it did + * not find, a value that is not a number, text that is neither — or a + * fraction, which the two engines truncate at different places. * * So no spelling is converted: each is refused with its remedy, and the author * judges the absent case the template used to decide silently. Only a token - * with no variable in it (`{100}`) maps losslessly, and none is authored. + * with no variable in it (`{100}`) maps losslessly, and none is authored — + * and the date macros with no offset or a whole-number one, whose CEL + * spelling (below) writes their text byte for byte. They are refused with + * that spelling like every other token: one dialect per slot, and the ruling + * for this retirement refuses rather than rewrites. * * ## The run user — `{$User.}` — is refused too, with two remedies * @@ -59,20 +65,31 @@ * refusal says so, and names the read of the user record by `current_user.id` * for an email or a name. `current_user` carries only what the run holds. * - * ## One spelling is KEPT, deliberately — not yet refused + * ## The date macros — `{NOW()}`, `{TODAY()}`, `± N` days — are refused too * - * A refusal must name what to write instead, and for one spelling CEL has - * nothing to name yet: the date macros — `{NOW()}`, `{TODAY()}`, with an - * optional `± N` day offset. CEL's `now()` / `today()` / `daysFromNow()` / - * `addDays()` yield a Timestamp, which reaches the data engine as a `Date` - * object rather than the ISO text the macro wrote, and CEL has no - * `string(timestamp)` to render one. + * The CEL stdlib writes a Timestamp as the text the macros wrote: `isoDate(t)` + * (`YYYY-MM-DD`) and `isoDatetime(t)` (`YYYY-MM-DDTHH:mm:ss.sssZ`), on the UTC + * calendar. So `{TODAY()}` is `isoDate(today())`, `{TODAY() + N}` / + * `{TODAY() - N}` are `isoDate(daysFromNow(N))` / `isoDate(daysAgo(N))`, + * `{NOW()}` is `isoDatetime(now())`, and `{NOW() ± N}` is + * `isoDatetime(addDays(now(), ±N))` — `daysFromNow` would land on midnight. + * The refusal names where the template and its spelling part: a fractional + * offset, a variable offset the template silently read as 0, and an offset + * that is neither (`celDateMacro` in the token module). No spelling is kept: + * a value slot reads no `{…}` token at all. * - * A string whose every token is one keeps its 17.x meaning; a string that - * mixes one with any other token keeps it too, because the other half could - * not be moved without it. Refusing it now would remove a capability with - * nothing to replace it. It is retired when CEL can spell it — a string form - * for a Timestamp. + * ## Where an envelope is literal data, the remedy names none + * + * An envelope evaluates only as the TOP-LEVEL value of a declared slot — the + * `assignments` map's values and the `fields` map's values. Two positions this + * judge reads hold data instead, as the executors read them: a string INSIDE + * an object or list literal (`fields.payload.note`, `assignments.o.who`), and + * either legacy `assignment` shape (an `assignments` ARRAY element's `value`, + * the bare config's keys). An envelope written there is stored as the object + * it spells, so the refusal there prescribes none: a nested string's remedy + * builds the WHOLE value as one envelope, a CEL map or list literal holding + * the string's CEL spelling at its place; a legacy shape's remedy moves the + * node's assignments into the canonical map, where an envelope evaluates. * * ## The token grammar is the interpolator's * @@ -88,6 +105,7 @@ import { CEL_RUN_USER_ID, CEL_RUN_USER_ID_GUARDED, TEMPLATE_TOKEN, + celDateMacro, celExpression, celHeadReadsThroughVars, celPath, @@ -96,7 +114,6 @@ import { templateTokenKind as tokenKind, templateTokensOf as tokensOf, type TemplateToken as Token, - type TemplateTokenKind as TokenKind, } from './flow-template-token'; /** @@ -110,9 +127,6 @@ export const VALUE_SLOT_TEMPLATE_REFUSAL = + '`{…}` token in it is refused rather than stored with its braces. Compute the value with a CEL value envelope, ' + '`{ dialect: \'cel\', source: \'…\' }`.'; -/** The kind CEL cannot spell yet, kept until it can (see the module docblock). */ -const KEPT_KINDS: ReadonlySet = new Set(['date-macro']); - /** A CEL single-quoted string literal. */ function celString(text: string): string { return `'${text.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; @@ -124,6 +138,18 @@ function envelopeOf(source: string): string { return `{ dialect: 'cel', source: ${quoted} }`; } +/** How a CEL source is named where no envelope may be written — a code span. */ +function spanOf(source: string): string { + return `\`${source}\``; +} + +/** + * How a remedy writes a CEL source: as an envelope where the position + * evaluates one, and as a bare CEL spelling inside the literal that builds the + * whole value where it does not ({@link literalPositionLead}). + */ +type Write = (source: string) => string; + /** * A `has()` guard for a path of keys — the absent-key remedy. `has()` takes a * field selection over names alone: an index anywhere in its argument @@ -147,83 +173,178 @@ const ABSENT_SENTENCE = * The remedy for `{$User.Id}` as a whole value: `current_user.id`, and for a * flow that can run without a user the guard, with what it changes. */ -function runUserIdRemedy(text: string): string { +function runUserIdRemedy(text: string, write: Write): string { return ( - `Write \`${text}\` as ${envelopeOf(CEL_RUN_USER_ID)}: \`current_user\` is the run's user. In a flow that can run ` + `Write \`${text}\` as ${write(CEL_RUN_USER_ID)}: \`current_user\` is the run's user. In a flow that can run ` + 'without a user (a schedule, or a record change made by a system write) `current_user` is `null` and that ' - + `read fails the run, so write ${envelopeOf(CEL_RUN_USER_ID_GUARDED)} there. The guarded form writes \`null\` ` + + `read fails the run, so write ${write(CEL_RUN_USER_ID_GUARDED)} there. The guarded form writes \`null\` ` + 'where the template wrote nothing, which on `update_record` clears a stored value the template left alone.' ); } +/** The remedy for a date macro as a whole value — its CEL string form, and where the two part. */ +function dateMacroRemedy(token: Token, write: Write): string { + const macro = celDateMacro(token.inner)!; + const today = /^TODAY/.test(token.inner); + const offset = /[+-]/.test(token.inner); + return ( + `Write \`${token.text}\` as ${write(macro.source)}: \`${today ? 'isoDate' : 'isoDatetime'}\` writes the ` + + `${today ? 'date' : 'timestamp'} as the ISO text the macro wrote, on the UTC calendar.` + + (!today && offset ? ' `addDays(now(), …)` keeps the time of day, where `daysFromNow` would land on midnight.' : '') + + (macro.edge ? ` ${macro.edge}` : '') + ); +} + /** How a text-with-holes remedy writes one token, or `undefined` for a hole it leaves out. */ function holeOf(token: Token): string | undefined { if (token.kind === 'path') return celPath(token.inner); if (token.kind === 'user') return isRunUserIdToken(token.inner) ? CEL_RUN_USER_ID : undefined; + if (token.kind === 'date-macro') return celDateMacro(token.inner)!.source; return `(${celExpression(token.inner)})`; } -/** The remedy for one string — the CEL spelling of what the template computed. */ -function remedyFor(value: string, tokens: readonly Token[]): string { - const whole = tokens.length === 1 && tokens[0]!.text === value ? tokens[0]! : undefined; +/** `value` with no token kind of its own as one string — the string, as text with holes. */ +function concatenationOf(value: string): string { + const parts: string[] = []; + let at = 0; + for (const match of value.matchAll(TEMPLATE_TOKEN)) { + const literal = value.slice(at, match.index); + if (literal) parts.push(celString(literal)); + const inner = match[1]!.trim(); + const hole = holeOf({ text: match[0], inner, kind: tokenKind(inner), index: match.index ?? 0 }); + if (hole !== undefined) parts.push(hole); + at = (match.index ?? 0) + match[0].length; + } + const tail = value.slice(at); + if (tail) parts.push(celString(tail)); + return parts.length > 0 ? parts.join(' + ') : "''"; +} + +/** The one token `value` is, when it is nothing but that token. */ +function wholeTokenOf(value: string, tokens: readonly Token[]): Token | undefined { + return tokens.length === 1 && tokens[0]!.text === value ? tokens[0] : undefined; +} + +/** + * The CEL source whose value is what the template wrote for `value` — the + * spelling a literal-data position holds inside the whole value's literal — + * or `undefined` for a `$User` path that never resolved, which has none. + */ +function celSourceOf(value: string, tokens: readonly Token[]): string | undefined { + if (tokens.some((t) => t.kind === 'unresolvable')) return celString(value); + const whole = wholeTokenOf(value, tokens); + if (!whole) return concatenationOf(value); + if (whole.kind === 'path') return celPath(whole.inner); + if (whole.kind === 'user') return isRunUserIdToken(whole.inner) ? CEL_RUN_USER_ID : undefined; + if (whole.kind === 'date-macro') return celDateMacro(whole.inner)!.source; + return celExpression(whole.inner); +} + +/** The remedy for one string — the CEL spelling of what the template computed, written by `write`. */ +function remedyFor(value: string, tokens: readonly Token[], write: Write): string { + const whole = wholeTokenOf(value, tokens); if (tokens.some((t) => t.kind === 'unresolvable')) { const junk = tokens.find((t) => t.kind === 'unresolvable')!; return ( `\`${junk.text}\` is neither a variable path nor an expression. If the braces are literal text, write the ` - + `value as a CEL string literal, ${envelopeOf(celString(value))}; otherwise compute it in a CEL envelope.` + + `value as a CEL string literal, ${write(celString(value))}; otherwise compute it in CEL.` ); } if (whole?.kind === 'path') { const guard = guardOf(whole.inner); return ( - `Write \`${whole.text}\` as ${envelopeOf(celPath(whole.inner))}. ${ABSENT_SENTENCE}` + `Write \`${whole.text}\` as ${write(celPath(whole.inner))}. ${ABSENT_SENTENCE}` + (guard ? `: \`${guard}\` (the guarded form writes \`null\`).` : '.') ); } if (whole?.kind === 'user') { return isRunUserIdToken(whole.inner) - ? runUserIdRemedy(whole.text) - : runUserPathNeverResolved(whole.text, (path) => envelopeOf(path)); + ? runUserIdRemedy(whole.text, write) + : runUserPathNeverResolved(whole.text, write); } + if (whole?.kind === 'date-macro') return dateMacroRemedy(whole, write); if (whole?.kind === 'expression') { return ( - `Write \`${whole.text}\` as ${envelopeOf(celExpression(whole.inner))}. Every division keeps a decimal operand: ` + `Write \`${whole.text}\` as ${write(celExpression(whole.inner))}. Every division keeps a decimal operand: ` + 'CEL divides two integers as integers, so `round(x * 100) / 100` drops the decimals where ' + '`round(x * 100) / 100.0` keeps them. ' + ABSENT_SENTENCE + '.' ); } // Text with holes: one CEL concatenation. A `$User` path other than the // id rendered nothing in every shipped run, so the concatenation leaves it - // out, and a sentence after it says what to read for the value it meant. - const parts: string[] = []; - let at = 0; - for (const match of value.matchAll(TEMPLATE_TOKEN)) { - const literal = value.slice(at, match.index); - if (literal) parts.push(celString(literal)); - const inner = match[1]!.trim(); - const hole = holeOf({ text: match[0], inner, kind: tokenKind(inner), index: match.index ?? 0 }); - if (hole !== undefined) parts.push(hole); - at = (match.index ?? 0) + match[0].length; - } - const tail = value.slice(at); - if (tail) parts.push(celString(tail)); + // out, and a sentence after it says what to read for the value it meant. A + // date macro is its string form, and where that parts from the template it + // says so. const sentences = [ - `\`${value}\` is text with holes: write it as one CEL concatenation, ` - + `${envelopeOf(parts.length > 0 ? parts.join(' + ') : "''")}. Wrap a ` + `\`${value}\` is text with holes: write it as one CEL concatenation, ${write(concatenationOf(value))}. Wrap a ` + 'hole that is not a string in `string(…)`, and one that may be null in `coalesce(…, \'\')` — the template ' + 'rendered null as nothing, and CEL refuses `+ null`.', ]; const seen = new Set(); for (const token of tokens) { - if (token.kind !== 'user' || seen.has(token.text)) continue; + if (seen.has(token.text)) continue; seen.add(token.text); + if (token.kind === 'user') { + sentences.push( + isRunUserIdToken(token.inner) + ? `\`${token.text}\` is \`${CEL_RUN_USER_ID}\`, the run's user. In a flow that can run without a user ` + + '`current_user` is `null` and that read fails the run, so write the hole as ' + + '`(current_user != null ? current_user.id : \'\')` there, which renders nothing where the template did.' + : `${runUserPathNeverResolved(token.text, spanOf)} The concatenation above leaves it out, ` + + 'as the template did.', + ); + } else if (token.kind === 'date-macro') { + const macro = celDateMacro(token.inner)!; + sentences.push( + `\`${token.text}\` is \`${macro.source}\`, the ISO text the macro wrote on the UTC calendar.` + + (macro.edge ? ` ${macro.edge}` : ''), + ); + } + } + return sentences.join(' '); +} + +/** A CEL map or list literal holding `source` at `path`, as the example of a whole value built in CEL. */ +function literalAlong(path: readonly (string | number)[], source: string): string { + let out = source; + for (let at = path.length - 1; at >= 0; at--) { + const segment = path[at]!; + out = typeof segment === 'number' ? `[${out}]` : `{${celString(segment)}: ${out}}`; + } + return out; +} + +/** + * What a refusal says first at a position where an envelope is literal data + * (see the module docblock): a legacy `assignment` shape, and a string inside + * an object or list literal. `undefined` at a position that evaluates one. + */ +function literalPositionLead( + value: string, + tokens: readonly Token[], + path: readonly (string | number)[], + legacy: boolean, +): string | undefined { + if (!legacy && path.length === 0) return undefined; + const sentences: string[] = []; + if (legacy) { + sentences.push( + 'This `assignment` shape — the legacy `assignments: [{ variable, value }]` array, or variables written as ' + + 'the config\'s own keys — evaluates nothing: an envelope written in it is stored as the object it spells. ' + + 'Move the node\'s assignments into the canonical map, `assignments: { … }`, whose values an envelope ' + + 'computes, and write this variable there' + (path.length === 0 ? ':' : '.'), + ); + } + if (path.length > 0) { + const source = celSourceOf(value, tokens); sentences.push( - isRunUserIdToken(token.inner) - ? `\`${token.text}\` is \`${CEL_RUN_USER_ID}\`, the run's user. In a flow that can run without a user ` - + '`current_user` is `null` and that read fails the run, so write the hole as ' - + '`(current_user != null ? current_user.id : \'\')` there, which renders nothing where the template did.' - : `${runUserPathNeverResolved(token.text, (path) => `\`${path}\``)} The concatenation above leaves it out, ` - + 'as the template did.', + `\`${value}\` sits inside an object or list literal, where nothing evaluates: an envelope written in its ` + + 'place is stored as the object it spells. Compute the whole value as one CEL envelope that builds it, a ' + + 'CEL map or list literal holding this string\'s CEL spelling at its place, with the value\'s other entries ' + + 'beside it' + + (source !== undefined ? ` — for this string alone, ${envelopeOf(literalAlong(path, source))}.` : '.') + + ' CEL holds every value of one map, and every element of one list, to one type: where they mix — a ' + + 'literal beside a computed value included — wrap each one in `dyn(…)`. The spelling:', ); } return sentences.join(' '); @@ -244,7 +365,9 @@ export interface ValueSlotTemplateOptions { /** * The position is one of the two legacy `assignment` shapes, where an * envelope-shaped object is a LITERAL (the ledger does not declare them, so - * nothing evaluates it) and its strings were interpolated like any other. + * nothing evaluates it) and its strings were interpolated like any other — + * so a refusal there names the canonical map, where an envelope evaluates, + * rather than an envelope at the position itself. * Default `false`: in a declared value slot a top-level envelope is an * expression, judged by the envelope rule and not here. */ @@ -257,23 +380,30 @@ export interface ValueSlotTemplateOptions { * any depth of an array or a plain object (a literal's strings were * interpolated too). A top-level envelope-shaped value is not judged here: it * is an expression, and `FlowValueSlotSchema`'s envelope rule owns it — unless - * {@link ValueSlotTemplateOptions.envelopeIsLiteral}. A string whose tokens - * include the kept spelling (a date macro) is not refused — see - * the module docblock. Cycle-safe: a flow built in code may hold a - * self-reference. + * {@link ValueSlotTemplateOptions.envelopeIsLiteral}. No token is kept: the + * date macros are refused with their CEL string form, like every other token + * (see the module docblock). A string inside an object or list literal, or in + * a legacy shape, sits where an envelope is data, so its remedy names the + * envelope that builds the whole value or the canonical map instead. + * Cycle-safe: a flow built in code may hold a self-reference. */ export function valueSlotTemplateRefusals( value: unknown, options: ValueSlotTemplateOptions = {}, ): ValueSlotTemplateRefusal[] { - if (!options.envelopeIsLiteral && isExpressionEnvelopeShaped(value)) return []; + const legacy = options.envelopeIsLiteral === true; + if (!legacy && isExpressionEnvelopeShaped(value)) return []; const out: ValueSlotTemplateRefusal[] = []; const seen = new Set(); const visit = (node: unknown, path: (string | number)[]): void => { if (typeof node === 'string') { const tokens = tokensOf(node); - if (tokens.length === 0 || tokens.some((t) => KEPT_KINDS.has(t.kind))) return; - out.push({ path, message: `${VALUE_SLOT_TEMPLATE_REFUSAL} ${remedyFor(node, tokens)}`, source: node }); + if (tokens.length === 0) return; + const lead = literalPositionLead(node, tokens, path, legacy); + const remedy = lead === undefined + ? remedyFor(node, tokens, envelopeOf) + : `${lead} ${remedyFor(node, tokens, path.length > 0 ? spanOf : envelopeOf)}`; + out.push({ path, message: `${VALUE_SLOT_TEMPLATE_REFUSAL} ${remedy}`, source: node }); return; } if (node === null || typeof node !== 'object' || seen.has(node)) return; @@ -316,7 +446,8 @@ function joinPath(prefix: string, inner: readonly (string | number)[]): string { * top-level keys are the variables. An envelope there is a literal, as it * always was, so its strings are judged like any literal's; a `{…}` token * there is refused like anywhere else, so the legacy shapes are no way around - * the retirement. + * the retirement — and its remedy moves the node's assignments into the + * canonical map, the one shape whose values an envelope computes. */ export function flowNodeValueTemplateRefusals(nodeType: string, config: unknown): FlowNodeValueTemplateRefusal[] { const out: FlowNodeValueTemplateRefusal[] = []; diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-text-slot-single-brace-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-text-slot-single-brace-refused.ts index 6e15d2f85c7..2ddc1a803a3 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-text-slot-single-brace-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-text-slot-single-brace-refused.ts @@ -18,9 +18,9 @@ export const entry: SemanticMigration = { 'a double-brace template hole, rendered by the formula template engine over the flow\'s variables: a variable ' + 'path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, ' + '{{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an ' - + 'assignment node — arithmetic and functions as a CEL value envelope, the date macros as the value-slot ' - + 'spelling that still reads them, the run user\'s id as the CEL value envelope current_user.id — and written as ' - + '{{ variable }}', + + 'assignment node — arithmetic and functions as a CEL value envelope, the date macros as the CEL value ' + + 'envelope of their string form (isoDate(today()), isoDatetime(now())), the run user\'s id as the CEL value ' + + 'envelope current_user.id — and written as {{ variable }}', reason: 'ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides ' + 'with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator ' diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts index 62d976bc5c1..fc35dbb35d6 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts @@ -4,9 +4,10 @@ import type { SemanticMigration } from '../../types.js'; // The template dialect leaves the flow value slots: one dialect for a computed // value, CEL. Semantic-only — every token spelling authored in flows was -// measured lossy under conversion, so no D2 conversion rewrites any of them, -// and the date macros CEL cannot write yet are kept. The run-user paths are -// refused too: the flow CEL scope binds current_user, the run's user or null. +// measured lossy under conversion, so no D2 conversion rewrites any of them. +// The run-user paths are refused too: the flow CEL scope binds current_user, +// the run's user or null. So are the date macros, with their CEL string form +// (isoDate / isoDatetime): no single-brace spelling is kept. export const entry: SemanticMigration = { id: 'flow-value-slot-template-dialect-refused', // No backticks in `surface` — build-upgrade-guide renders it inside a code @@ -15,7 +16,7 @@ export const entry: SemanticMigration = { 'flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the ' + 'legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a ' + 'string anywhere inside an array or object value, carrying a single-brace template token, the run-user ' - + 'paths beginning $User. included', + + 'paths beginning $User. and the date macros NOW() and TODAY() with an optional day offset included', replacement: 'a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path ' + '(record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read ' @@ -26,8 +27,15 @@ export const entry: SemanticMigration = { + 'writes null where the template wrote nothing, so on update_record it clears a stored value the template ' + 'left alone. Every other run-user path ($User.Email, $User.Name, …) never resolved in any shipped run: ' + 'current_user carries only what the run holds (id, positions, organizationId, isPlatformAdmin), and an email ' - + 'or a name is read from the user record by current_user.id (a get_record node on sys_user). A string with no ' - + 'token is the literal text it spells, and braces meant literally are a CEL string literal', + + 'or a name is read from the user record by current_user.id (a get_record node on sys_user). A date macro is ' + + 'its CEL string form on the UTC calendar: TODAY() is isoDate(today()), TODAY() + N and TODAY() - N are ' + + 'isoDate(daysFromNow(N)) and isoDate(daysAgo(N)), NOW() is isoDatetime(now()), and NOW() plus or minus N is ' + + 'isoDatetime(addDays(now(), N)) with N signed, which keeps the time of day where daysFromNow lands on ' + + 'midnight; a variable offset is isoDate(addDays(today(), days)). Where an envelope is literal data — a string ' + + 'inside an object or list value, or either legacy assignment shape — an envelope written in its place is ' + + 'stored as the object it spells, so the whole value is one envelope building a CEL map or list literal, and ' + + 'a legacy assignment moves into the assignments map. A string with no token is the literal text it spells, ' + + 'and braces meant literally are a CEL string literal', reason: 'The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no ' + 'conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index ' @@ -38,16 +46,21 @@ export const entry: SemanticMigration = { + 'and nothing in a run with no user (a schedule, a record change made by a system write); the flow CEL scope ' + 'binds current_user to the run\'s user and to null in such a run, never a pseudo-user, so current_user.id ' + 'fails there and its guarded form writes null. The other run-user paths read a user object no run carries, ' - + 'so they wrote nothing in every run. One spelling is kept with its old meaning, because CEL cannot write it ' - + 'yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has ' - + 'no string form for one). A flow carrying a refused value is refused at registration, by objectstack ' - + 'validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it.', + + 'so they wrote nothing in every run. The date macros wrote the ISO text of the UTC day or instant, which ' + + 'isoDate and isoDatetime write byte for byte for a whole number of days, but the template read an offset it ' + + 'could not use as 0 — a variable it did not find, a value that is not a number, text that is neither — ' + + 'where CEL fails the run, and it truncated a fractional offset after adding it to the day of the month, where ' + + 'addDays truncates the offset itself and daysFromNow and daysAgo refuse a fraction at build. A flow carrying ' + + 'a refused value is refused at registration, by objectstack validate and by the executor; a stored flow ' + + 'carrying one is skipped at boot with a warn naming it.', acceptanceCriteria: 'Run objectstack validate: it reports each refused value as expression-invalid at the node and the value\'s ' + 'path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be ' + 'absent, guard it (has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null for a variable) or ' + 'route around the node. For the run user, find which flows can run without one (a schedule, a record change ' + 'a system write can make): there, guard current_user.id, or skip the node with a start condition or a ' - + 'decision on current_user != null where an update_record must leave the stored value alone. Re-run the ' - + 'flow paths that write those fields and compare the stored values with the ones the template wrote.', + + 'decision on current_user != null where an update_record must leave the stored value alone. For a date ' + + 'macro with a variable offset, check the variable is always set to a number where the flow runs; write a ' + + 'fractional offset as the whole number of days meant. Re-run the flow paths that write those fields and ' + + 'compare the stored values with the ones the template wrote.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index a7e47728488..0c2ac308db4 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5776,8 +5776,10 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'absent key should write is the author\'s judgment. The `$User` paths are refused too: the flow CEL ' + 'scope binds `current_user`, the run\'s user or `null` in a run with none, so `{$User.Id}` is ' + '`current_user.id`, guarded where a flow can run without a user, and the other `$User` paths, which ' - + 'never resolved, name a read of the user record. The date macros keep their meaning until CEL can ' - + 'spell them. Its D3 record is the semantic entry `flow-value-slot-template-dialect-refused`.', + + 'never resolved, name a read of the user record. The date macros are refused too, with their CEL ' + + 'string form (`{TODAY() + 7}` is `isoDate(daysFromNow(7))`, `{NOW()}` is `isoDatetime(now())`), so no ' + + 'single-brace spelling is kept. Its D3 record is the semantic entry ' + + '`flow-value-slot-template-dialect-refused`.', }, { id: 'flow-write-node-stored-metadata-target-refused', @@ -14224,9 +14226,9 @@ const step18: MigrationStep = { 'a double-brace template hole, rendered by the formula template engine over the flow\'s variables: a variable ' + 'path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, ' + '{{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an ' - + 'assignment node — arithmetic and functions as a CEL value envelope, the date macros as the value-slot ' - + 'spelling that still reads them, the run user\'s id as the CEL value envelope current_user.id — and written as ' - + '{{ variable }}', + + 'assignment node — arithmetic and functions as a CEL value envelope, the date macros as the CEL value ' + + 'envelope of their string form (isoDate(today()), isoDatetime(now())), the run user\'s id as the CEL value ' + + 'envelope current_user.id — and written as {{ variable }}', reason: 'ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides ' + 'with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator ' @@ -14323,9 +14325,10 @@ const step18: MigrationStep = { }, // The template dialect leaves the flow value slots: one dialect for a computed // value, CEL. Semantic-only — every token spelling authored in flows was - // measured lossy under conversion, so no D2 conversion rewrites any of them, - // and the date macros CEL cannot write yet are kept. The run-user paths are - // refused too: the flow CEL scope binds current_user, the run's user or null. + // measured lossy under conversion, so no D2 conversion rewrites any of them. + // The run-user paths are refused too: the flow CEL scope binds current_user, + // the run's user or null. So are the date macros, with their CEL string form + // (isoDate / isoDatetime): no single-brace spelling is kept. { id: 'flow-value-slot-template-dialect-refused', // No backticks in `surface` — build-upgrade-guide renders it inside a code @@ -14334,7 +14337,7 @@ const step18: MigrationStep = { 'flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the ' + 'legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a ' + 'string anywhere inside an array or object value, carrying a single-brace template token, the run-user ' - + 'paths beginning $User. included', + + 'paths beginning $User. and the date macros NOW() and TODAY() with an optional day offset included', replacement: 'a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path ' + '(record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read ' @@ -14345,8 +14348,15 @@ const step18: MigrationStep = { + 'writes null where the template wrote nothing, so on update_record it clears a stored value the template ' + 'left alone. Every other run-user path ($User.Email, $User.Name, …) never resolved in any shipped run: ' + 'current_user carries only what the run holds (id, positions, organizationId, isPlatformAdmin), and an email ' - + 'or a name is read from the user record by current_user.id (a get_record node on sys_user). A string with no ' - + 'token is the literal text it spells, and braces meant literally are a CEL string literal', + + 'or a name is read from the user record by current_user.id (a get_record node on sys_user). A date macro is ' + + 'its CEL string form on the UTC calendar: TODAY() is isoDate(today()), TODAY() + N and TODAY() - N are ' + + 'isoDate(daysFromNow(N)) and isoDate(daysAgo(N)), NOW() is isoDatetime(now()), and NOW() plus or minus N is ' + + 'isoDatetime(addDays(now(), N)) with N signed, which keeps the time of day where daysFromNow lands on ' + + 'midnight; a variable offset is isoDate(addDays(today(), days)). Where an envelope is literal data — a string ' + + 'inside an object or list value, or either legacy assignment shape — an envelope written in its place is ' + + 'stored as the object it spells, so the whole value is one envelope building a CEL map or list literal, and ' + + 'a legacy assignment moves into the assignments map. A string with no token is the literal text it spells, ' + + 'and braces meant literally are a CEL string literal', reason: 'The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no ' + 'conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index ' @@ -14357,18 +14367,23 @@ const step18: MigrationStep = { + 'and nothing in a run with no user (a schedule, a record change made by a system write); the flow CEL scope ' + 'binds current_user to the run\'s user and to null in such a run, never a pseudo-user, so current_user.id ' + 'fails there and its guarded form writes null. The other run-user paths read a user object no run carries, ' - + 'so they wrote nothing in every run. One spelling is kept with its old meaning, because CEL cannot write it ' - + 'yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has ' - + 'no string form for one). A flow carrying a refused value is refused at registration, by objectstack ' - + 'validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it.', + + 'so they wrote nothing in every run. The date macros wrote the ISO text of the UTC day or instant, which ' + + 'isoDate and isoDatetime write byte for byte for a whole number of days, but the template read an offset it ' + + 'could not use as 0 — a variable it did not find, a value that is not a number, text that is neither — ' + + 'where CEL fails the run, and it truncated a fractional offset after adding it to the day of the month, where ' + + 'addDays truncates the offset itself and daysFromNow and daysAgo refuse a fraction at build. A flow carrying ' + + 'a refused value is refused at registration, by objectstack validate and by the executor; a stored flow ' + + 'carrying one is skipped at boot with a warn naming it.', acceptanceCriteria: 'Run objectstack validate: it reports each refused value as expression-invalid at the node and the value\'s ' + 'path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be ' + 'absent, guard it (has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null for a variable) or ' + 'route around the node. For the run user, find which flows can run without one (a schedule, a record change ' + 'a system write can make): there, guard current_user.id, or skip the node with a start condition or a ' - + 'decision on current_user != null where an update_record must leave the stored value alone. Re-run the ' - + 'flow paths that write those fields and compare the stored values with the ones the template wrote.', + + 'decision on current_user != null where an update_record must leave the stored value alone. For a date ' + + 'macro with a variable offset, check the variable is always set to a number where the flow runs; write a ' + + 'fractional offset as the whole number of days meant. Re-run the flow paths that write those fields and ' + + 'compare the stored values with the ones the template wrote.', }, // #21654 — the D3 entry for `FlowSchema`'s refusal of a write node aimed at a // stored-metadata table: the save-time half of #21624, which applies #21520's From a32d2af4529354731ebe8e893941f2153c39f9ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 03:22:47 +0000 Subject: [PATCH 2/8] test(service-automation): pin the date macros' cross-dialect parity, and the literal-position remedies through the engine The live parity pin runs the 17.x interpolator and the envelope each date-macro refusal names over the same instants (DST, month end, year end, leap day) and compares what reaches the store; the fraction, variable and unreadable-offset edges are pinned as the refusal names them. The literal-data positions (legacy assignment shapes, a string nested in a literal) are pinned through the engine: the old envelope remedy stores the object, the new one writes the template's value. Docblocks and the README stop saying the macros are kept. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .../services/service-automation/README.md | 29 ++- .../builtin/assignment-value-envelope.test.ts | 119 +++++++++++ .../crud-fields-value-envelope.test.ts | 195 +++++++++++++++++- .../src/builtin/crud-nodes.ts | 10 +- .../src/builtin/logic-nodes.test.ts | 17 +- .../src/builtin/logic-nodes.ts | 6 +- .../src/builtin/template.ts | 16 +- .../value-slot-template-grammar.test.ts | 36 ++-- 8 files changed, 383 insertions(+), 45 deletions(-) diff --git a/packages/services/service-automation/README.md b/packages/services/service-automation/README.md index 0c90e95ad52..43d6ea42b1f 100644 --- a/packages/services/service-automation/README.md +++ b/packages/services/service-automation/README.md @@ -191,12 +191,25 @@ a CEL value envelope, and text is a `{{ }}` template.** | Value slots — `create_record` / `update_record` `fields`, `assignment` values | CEL value envelope; a plain string is the literal text it spells | `{ dialect: 'cel', source: "'Follow up on ' + record.name" }`, `'open'` | | Text slots — `notify` `title` / `message`, `screen` `title` / `description`, `end` `message` | Template — `{{ }}` holes (ADR-0032 §3; a single-brace token is refused) | `'Deal won: {{ record.name }}'`, `'{{ record.amount \| currency }}'` | -A value slot refuses a `{…}` template token — `registerFlow()` and -`objectstack validate` name its CEL spelling — except the date macros -(`'{NOW()}'`, `'{TODAY()}'`, `'{TODAY() + 90}'`), which it still reads until CEL -can write them: CEL's `now()` / `today()` are timestamps, not the text the -macros write. Other value-like positions — a `filter`, `recipients`, an `http` -payload, `subflow.input` — still read the single-brace dialect (`{record.id}`). +A value slot refuses every `{…}` template token — `registerFlow()` and +`objectstack validate` name its CEL spelling. A date macro is its CEL string +form, on the UTC calendar: + +| You wrote | Write instead | +|:---|:---| +| `'{TODAY()}'` | `{ dialect: 'cel', source: 'isoDate(today())' }` | +| `'{TODAY() + 90}'` | `{ dialect: 'cel', source: 'isoDate(daysFromNow(90))' }` | +| `'{TODAY() - 3}'` | `{ dialect: 'cel', source: 'isoDate(daysAgo(3))' }` | +| `'{NOW()}'` | `{ dialect: 'cel', source: 'isoDatetime(now())' }` | +| `'{NOW() + 2}'` | `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), 2))' }` — `daysFromNow` would land on midnight | +| `'{TODAY() + days}'` | `{ dialect: 'cel', source: 'isoDate(addDays(today(), days))' }` — an absent `days`, or one that is not a number, now fails the run where the template silently added 0 | + +Inside an object or list value, and in the legacy `assignment` shapes, an +envelope is data: build the whole value as one envelope (a CEL map or list +literal, its mixed values wrapped in `dyn(…)`), or move the assignment into the +`assignments` map. Other value-like positions — a `filter`, `recipients`, an +`http` payload, `subflow.input` — still read the single-brace dialect +(`{record.id}`). The run's user is **`current_user`** in every flow CEL expression: `id`, `positions`, `organizationId`, `isPlatformAdmin`, nothing more. In a run with no @@ -211,8 +224,8 @@ user record by `current_user.id` with a `get_record` on `sys_user`. The two failure modes to memorize: 1. **A plain string in a value slot** — `due_date: 'TODAY() + 7'` or - `owner: 'current_user.id'` writes that text into the field. Write a date - macro in braces (`'{TODAY() + 7}'`), anything else as a CEL envelope. + `owner: 'current_user.id'` writes that text into the field. Write it as a + CEL envelope: `{ dialect: 'cel', source: 'isoDate(daysFromNow(7))' }`. 2. **Braces put *into* a condition** — `'{record.amount} > 500'`. Since #4336 conditions reject this loudly: `registerFlow()` / `objectstack validate` refuse the flow with a CEL error naming the reference. Before that they were diff --git a/packages/services/service-automation/src/builtin/assignment-value-envelope.test.ts b/packages/services/service-automation/src/builtin/assignment-value-envelope.test.ts index 27ffcada4b4..af59f037298 100644 --- a/packages/services/service-automation/src/builtin/assignment-value-envelope.test.ts +++ b/packages/services/service-automation/src/builtin/assignment-value-envelope.test.ts @@ -38,11 +38,13 @@ import { isExpressionEnvelopeShaped, ASSIGNMENT_VALUE_ENVELOPE_REFUSAL, VALUE_SLOT_TEMPLATE_REFUSAL, + flowNodeValueTemplateRefusals, } from '@objectstack/spec/automation'; import { EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec'; import { ExpressionEngine } from '@objectstack/formula'; import { AutomationEngine } from '../engine.js'; import { registerLogicNodes } from './logic-nodes.js'; +import { interpolate } from './template.js'; /** The thrown error, so a pin can assert its message substance rather than `toThrow()` alone. */ function catchError(fn: () => unknown): Error { @@ -425,3 +427,120 @@ describe('assignment value envelope — the legacy shapes are untouched (#15137 }))).toThrow(VALUE_SLOT_TEMPLATE_REFUSAL); }); }); + +/** + * [#19939 pass 3, carried from pass 2] **Where an envelope is literal data, + * the refusal names one that evaluates.** The value-slot judge used to print + * `{ dialect: 'cel', source: … }` at whatever position it refused a `{…}` + * token — and at an element of the legacy `assignments` ARRAY, at a key of the + * bare legacy config, and at a string nested inside an object literal, the + * executor reads that envelope as DATA: the prescribed metadata registered and + * stored the envelope object (the controls below). The remedy there now moves + * the assignment into the canonical map, or builds the whole value as one CEL + * literal — and each spelling it names, read off the refusal itself and put + * back, writes what the template wrote. + * + * The judge is fixed rather than the executor: an envelope-shaped object in + * those positions is data today (`the legacy shapes are untouched` above, and + * the CRUD test's nested-envelope pin), and evaluating it would change what an + * existing flow writes. + */ +describe('[#19939] where an envelope is literal data, the refusal names a spelling that evaluates', () => { + let engine: AutomationEngine; + beforeEach(() => { + engine = new AutomationEngine(createTestLogger()); + registerLogicNodes(engine, createCtx()); + }); + + /** `assignmentFlow` with `name` as an input. */ + function literalFlow(config: Record, outputs: string[]) { + const flow = assignmentFlow(config, outputs); + return { ...flow, variables: [{ name: 'name', type: 'text', isInput: true }, ...flow.variables] }; + } + const RUN = { params: { name: 'Ada' } } as any; + const VARIABLES = new Map([['name', 'Ada']]); + + /** The envelope source a refusal message names after `lead` — read off the message, never re-spelled. */ + function envelopeAfter(message: string, lead: string): string { + const at = message.indexOf(lead); + expect(at, message).toBeGreaterThanOrEqual(0); + const found = /\{ dialect: 'cel', source: (?:'([^']*)'|("(?:[^"\\]|\\.)*")) \}/.exec(message.slice(at)); + expect(found, message).not.toBeNull(); + return found![1] ?? (JSON.parse(found![2]!) as string); + } + + it.each([ + ['the legacy `assignments` array', { assignments: [{ variable: 'greeting', value: 'Hello {name}' }] }, 'config.assignments[0].value'], + ['the legacy bare config', { greeting: 'Hello {name}' }, 'config.greeting'], + ] as const)('%s: the remedy moves it into the canonical map, where the envelope evaluates', async (_label, config, at) => { + const message = catchError(() => engine.registerFlow('assign_flow', literalFlow(config, ['greeting']))).message; + expect(message).toContain(`assignment value at ${at}`); + expect(message).toContain('Move the node\'s assignments into the canonical map'); + const source = envelopeAfter(message, 'canonical map'); + expect(source).toBe("'Hello ' + name"); + + // Put back where the remedy says — the canonical map — it writes the template's value. + engine.registerFlow('assign_flow', literalFlow({ assignments: { greeting: { dialect: 'cel', source } } }, ['greeting'])); + const result = await engine.execute('assign_flow', RUN); + expect(result.success).toBe(true); + expect(result.output).toEqual({ greeting: interpolate('Hello {name}', VARIABLES, {} as any) }); + expect(result.output).toEqual({ greeting: 'Hello Ada' }); + }); + + it.each([ + ['the legacy `assignments` array', { assignments: [{ variable: 'greeting', value: { dialect: 'cel', source: "'Hello ' + name" } }] }], + ['the legacy bare config', { greeting: { dialect: 'cel', source: "'Hello ' + name" } }], + ] as const)('control — %s: the envelope the old remedy named there is stored as the object it spells', async (_label, config) => { + engine.registerFlow('assign_flow', literalFlow(config, ['greeting'])); + const result = await engine.execute('assign_flow', RUN); + expect(result.success).toBe(true); + expect(result.output).toEqual({ greeting: { dialect: 'cel', source: "'Hello ' + name" } }); + }); + + it('a string nested in an object literal: the whole value as one CEL map literal writes the template\'s object', async () => { + const value = { who: '{name}', meta: { note: 'for {name}' } }; + const refusals = flowNodeValueTemplateRefusals('assignment', { assignments: { o: value } }); + expect(refusals.map((r) => r.path)).toEqual(['assignments.o.who', 'assignments.o.meta.note']); + for (const refusal of refusals) { + expect(refusal.message).toContain('sits inside an object or list literal, where nothing evaluates'); + expect(refusal.message).toContain('wrap each one in `dyn(…)`'); + } + // Each refusal's literal holds its own string at its place; the two, side by + // side, are the whole value — a string beside a map, so each is wrapped in + // `dyn(…)` as the remedy says (unwrapped, CEL refuses the map at registration). + expect(envelopeAfter(refusals[0]!.message, 'for this string alone')).toBe("{'who': name}"); + expect(envelopeAfter(refusals[1]!.message, 'for this string alone')).toBe("{'meta': {'note': 'for ' + name}}"); + expect(() => engine.registerFlow('assign_flow', literalFlow({ + assignments: { o: { dialect: 'cel', source: "{'who': name, 'meta': {'note': 'for ' + name}}" } }, + }, ['o']))).toThrow(ASSIGNMENT_VALUE_ENVELOPE_REFUSAL); + const whole = "{'who': dyn(name), 'meta': dyn({'note': 'for ' + name})}"; + + engine.registerFlow('assign_flow', literalFlow({ assignments: { o: { dialect: 'cel', source: whole } } }, ['o'])); + const result = await engine.execute('assign_flow', RUN); + expect(result.success).toBe(true); + expect(result.output).toEqual({ o: interpolate(value, VARIABLES, {} as any) }); + expect(result.output).toEqual({ o: { who: 'Ada', meta: { note: 'for Ada' } } }); + + // Control: the envelope the old remedy named at the nested position is data. + const control = new AutomationEngine(createTestLogger()); + registerLogicNodes(control, createCtx()); + control.registerFlow('assign_flow', literalFlow({ assignments: { o: { who: { dialect: 'cel', source: 'name' } } } }, ['o'])); + const controlResult = await control.execute('assign_flow', RUN); + expect(controlResult.output).toEqual({ o: { who: { dialect: 'cel', source: 'name' } } }); + }); + + it('a map mixing types: the `dyn(…)` the remedy names is what makes it evaluate', async () => { + const message = flowNodeValueTemplateRefusals('assignment', { assignments: { o: { who: '{name}', n: 3 } } })[0]!.message; + expect(message).toContain('wrap each one in `dyn(…)`'); + // Without `dyn`, CEL refuses a map whose values differ in type — at registration. + expect(() => engine.registerFlow('assign_flow', literalFlow({ + assignments: { o: { dialect: 'cel', source: "{'who': name, 'n': 3}" } }, + }, ['o']))).toThrow(ASSIGNMENT_VALUE_ENVELOPE_REFUSAL); + engine.registerFlow('assign_flow', literalFlow({ + assignments: { o: { dialect: 'cel', source: "{'who': dyn(name), 'n': dyn(3)}" } }, + }, ['o'])); + const result = await engine.execute('assign_flow', RUN); + expect(result.success).toBe(true); + expect(result.output).toEqual({ o: interpolate({ who: '{name}', n: 3 }, VARIABLES, {} as any) }); + }); +}); diff --git a/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts b/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts index 375d8d4cdbe..9d7e7a99b6f 100644 --- a/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts +++ b/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts @@ -26,14 +26,19 @@ * slot-neutral sentence; the evaluator refuses the same set. * 4. **Fault loudly** — an envelope that parses but cannot evaluate on the * live values fails the run with its source, and writes nothing. + * 5. **The date macros' parity** (#19939 pass 3) — the live cross-dialect + * pin: for each date macro, the 17.x interpolator and the CEL envelope the + * refusal names run over the SAME instants, and the store receives the + * same bytes from both; the edges where they part are pinned as the + * refusal names them. */ -import { describe, it, expect } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { VALUE_ENVELOPE_REFUSAL, VALUE_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation'; +import { VALUE_ENVELOPE_REFUSAL, VALUE_SLOT_TEMPLATE_REFUSAL, valueSlotTemplateRefusals } from '@objectstack/spec/automation'; import { AutomationEngine } from '../engine.js'; import { registerCrudNodes } from './crud-nodes.js'; -import { interpolate } from './template.js'; +import { interpolate, interpolateString } from './template.js'; function makeLogger(): any { const l: any = { info() {}, warn() {}, error() {}, debug() {}, trace() {}, fatal() {} }; @@ -177,9 +182,9 @@ describe.each(NODE_TYPES)('%s `fields.*` — a CEL value envelope is EVALUATED ( }); describe.each(NODE_TYPES)('%s `fields.*` — every literal writes exactly what it wrote before', (nodeType) => { - it('literals, arrays, nested envelope-shaped JSON and the kept `{…}` spelling: byte-identical to the whole-map `interpolate()`', async () => { + it('literals, arrays and nested envelope-shaped JSON: byte-identical to the whole-map `interpolate()`', async () => { const fields = { - subject: '{TODAY() + 7}', // a date macro — kept until CEL can write it + subject: '2026-10-17', // an ISO date, as it is (a date macro is refused, #19939) total: 42, payload: { note: 'for the record', @@ -286,6 +291,38 @@ describe.each(NODE_TYPES)('%s `fields.*` — the retired `{…}` template dialec // The guarded form sends `null` — on `update_record` that clears the stored value. expect(writes0(userless.writes)).toHaveProperty('subject', null); }); + + // #19939 pass 3 (carry from pass 2): a string NESTED in an object literal sits + // where an envelope is data, so the refusal names the whole value built as a + // CEL map literal — and that spelling, put back in the slot, writes what the + // template wrote. The envelope at the nested position itself is stored as + // the object it spells (the reason the remedy names none there). + it('a string nested in an object literal: the whole-value CEL literal the refusal names writes what the template wrote', async () => { + const fields = { payload: { note: 'for {name}' } }; + const [refusal] = valueSlotTemplateRefusals(fields.payload); + const literal = /for this string alone, \{ dialect: 'cel', source: (?:'([^']*)'|("(?:[^"\\]|\\.)*")) \}/.exec(refusal!.message); + expect(literal, refusal!.message).not.toBeNull(); + const source = literal![1] ?? (JSON.parse(literal![2]!) as string); + expect(source).toBe("{'note': 'for ' + name}"); + expect(refusal!.message).not.toContain(`{ dialect: 'cel', source: "'for ' + name" }`); + + const before = interpolate(fields, new Map(Object.entries(PARAMS)), {} as any); + const remedied = await makeStack(); + remedied.automation.registerFlow('price_quote', writeFlow(nodeType, { payload: { dialect: 'cel', source } })); + const res = await run(remedied.automation); + expect(res.success, res.error).toBe(true); + expect(writes0(remedied.writes).payload).toEqual(before.payload); + expect(before.payload).toEqual({ note: 'for ada' }); + + // Why the remedy names no envelope at the nested position: one there is data. + const nestedEnvelope = await makeStack(); + nestedEnvelope.automation.registerFlow('price_quote', writeFlow(nodeType, { + payload: { note: { dialect: 'cel', source: "'for ' + name" } }, + })); + const res2 = await run(nestedEnvelope.automation); + expect(res2.success, res2.error).toBe(true); + expect(writes0(nestedEnvelope.writes).payload).toEqual({ note: { dialect: 'cel', source: "'for ' + name" } }); + }); }); /** The first row that reached the store. */ @@ -321,7 +358,7 @@ describe.each(NODE_TYPES)('%s `fields.*` — a malformed envelope is refused at registerCrudNodes(automation, { logger: makeLogger(), getService: () => undefined } as any); expect(() => automation.registerFlow('price_quote', writeFlow(nodeType, { total: { dialect: 'cel', source: 'price * 2' }, - subject: 'name', due: '{TODAY()}', n: 3, ok: true, nothing: null, payload: { dialect: 1 }, list: [{ dialect: 'cel' }], + subject: 'name', due: '2026-10-17', n: 3, ok: true, nothing: null, payload: { dialect: 1 }, list: [{ dialect: 'cel' }], }))).not.toThrow(); }); }); @@ -339,3 +376,149 @@ describe.each(NODE_TYPES)('%s `fields.*` — a value that cannot be computed fai expect(writes, 'ADR-0032 §1c: no silent default is written in its place').toEqual([]); }); }); + +/** + * [#19939 pass 3] **The live cross-dialect parity pin.** The date macros are + * refused in a value slot with their CEL string form (`isoDate` / `isoDatetime` + * in `@objectstack/formula`'s stdlib, UTC). `stdlib-timestamp-text.test.ts` + * there measures the CEL half against hand-written bytes; THIS pin is the one + * that runs both dialects live, in one test, over the same instants: the 17.x + * interpolator (`interpolateString`, the template's own resolver) and the + * envelope the spec's refusal names, evaluated by the engine and written + * through a real ObjectQL engine to the store. `Date` is frozen per instant, + * so both read the same `now`. + * + * The instants are the cells where a calendar mistake shows: a spring-forward + * and a fall-back DST transition day, the last millisecond of a month, a year + * end and a leap day. + */ +describe('the date macros — the envelope each refusal names writes the bytes the template wrote, over the same instants', () => { + afterEach(() => { vi.useRealTimers(); }); + + const INSTANTS = [ + '2026-03-08T09:30:00.000Z', + '2026-10-25T00:30:00.000Z', + '2026-01-31T23:59:59.999Z', + '2026-12-31T12:00:00.000Z', + '2028-02-29T00:00:00.000Z', + ]; + const VARS = { name: 'ada', days: 5 }; + const CONTEXT = { userId: 'u1', params: VARS } as any; + + /** A `create_record` writing `subject`, with `name` and `days` as inputs. */ + function macroFlow(subject: unknown) { + return { + name: 'macro_parity', label: 'Macro parity', type: 'autolaunched', + variables: [ + { name: 'name', type: 'text', isInput: true }, + { name: 'days', type: 'number', isInput: true }, + ], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'w', type: 'create_record', label: 'Write', config: { objectName: 'quote', fields: { subject } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'w' }, { id: 'e2', source: 'w', target: 'end' }], + } as any; + } + + /** The envelope source the refusal of `authored` names — read off the refusal itself, never re-spelled. */ + function remedyOf(authored: string): string { + const refusals = valueSlotTemplateRefusals(authored); + expect(refusals, authored).toHaveLength(1); + const found = /\{ dialect: 'cel', source: (?:'([^']*)'|("(?:[^"\\]|\\.)*")) \}/.exec(refusals[0]!.message.slice(VALUE_SLOT_TEMPLATE_REFUSAL.length)); + expect(found, refusals[0]!.message).not.toBeNull(); + return found![1] ?? (JSON.parse(found![2]!) as string); + } + + /** What the store receives for `subject` at `instant` — the remedy through the engine. */ + async function written(subject: unknown, instant: string): Promise { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(instant)); + const { automation, writes } = await makeStack(); + automation.registerFlow('macro_parity', macroFlow(subject)); + const res = await automation.execute('macro_parity', CONTEXT); + expect(res.success, res.error).toBe(true); + return writes0(writes).subject; + } + + /** What the 17.x interpolator wrote for `authored` at `instant`. */ + function templated(authored: string, instant: string): unknown { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(instant)); + return interpolateString(authored, new Map(Object.entries(VARS)), CONTEXT); + } + + it.each([ + ['{TODAY()}', 'isoDate(today())'], + ['{TODAY() + 7}', 'isoDate(daysFromNow(7))'], + ['{TODAY() - 3}', 'isoDate(daysAgo(3))'], + ['{NOW()}', 'isoDatetime(now())'], + ['{NOW() + 2}', 'isoDatetime(addDays(now(), 2))'], + ['{NOW() - 1}', 'isoDatetime(addDays(now(), -1))'], + ['{TODAY() + days}', 'isoDate(addDays(today(), days))'], + ['{TODAY() - days}', 'isoDate(addDays(today(), -days))'], + ['{NOW() + days}', 'isoDatetime(addDays(now(), days))'], + ['{TODAY() + 1.5}', 'isoDate(addDays(today(), 1.5))'], + ['{TODAY() + 3d}', 'isoDate(today())'], + ['Due {TODAY() + 7} for {name}', "'Due ' + isoDate(daysFromNow(7)) + ' for ' + name"], + ['Due {TODAY()} by {$User.Id}', "'Due ' + isoDate(today()) + ' by ' + current_user.id"], + ])('%s → %s', async (authored, source) => { + expect(remedyOf(authored)).toBe(source); + for (const instant of INSTANTS) { + const template = templated(authored, instant); + expect(typeof template, `${authored} @ ${instant}`).toBe('string'); + expect(await written({ dialect: 'cel', source }, instant), `${authored} @ ${instant}`).toBe(template); + } + }); + + // The edges the refusal names, each pinned as it names it. + it('a negative fraction: the template truncated the day sum, `addDays` truncates the offset — one day apart past the 1st', async () => { + const authored = '{TODAY() - 1.5}'; + const source = remedyOf(authored); + expect(source).toBe('isoDate(addDays(today(), -1.5))'); + // Past the day of the month the offset exceeds: the template moved 2 days back, `addDays` 1. + expect(templated(authored, '2026-03-08T09:30:00.000Z')).toBe('2026-03-06'); + expect(await written({ dialect: 'cel', source }, '2026-03-08T09:30:00.000Z')).toBe('2026-03-07'); + // On the 1st the template's sum truncates to 0, the last day of the month before: the two agree. + expect(templated(authored, '2026-03-01T09:30:00.000Z')).toBe('2026-02-28'); + expect(await written({ dialect: 'cel', source }, '2026-03-01T09:30:00.000Z')).toBe('2026-02-28'); + }); + + it('`daysAgo(1.5)` is refused at build — `daysFromNow` / `daysAgo` take a whole number', () => { + const automation = new AutomationEngine(makeLogger()); + registerCrudNodes(automation, { logger: makeLogger(), getService: () => undefined } as any); + expect(() => automation.registerFlow('macro_parity', macroFlow({ dialect: 'cel', source: 'isoDate(daysAgo(1.5))' }))) + .toThrow(VALUE_ENVELOPE_REFUSAL); + }); + + it('an offset variable the template read as 0 — not a number, or absent — fails the run loudly under CEL, and writes nothing', async () => { + const source = remedyOf('{TODAY() + days}'); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-03-08T09:30:00.000Z')); + for (const params of [{ name: 'ada', days: 'soon' }, { name: 'ada' }]) { + // The template moved 0 days, silently. + expect(interpolateString('{TODAY() + days}', new Map(Object.entries(params)), CONTEXT)).toBe('2026-03-08'); + const { automation, writes } = await makeStack(); + automation.registerFlow('macro_parity', macroFlow({ dialect: 'cel', source })); + const res = await automation.execute('macro_parity', { userId: 'u1', params } as any); + expect(res.success, JSON.stringify(params)).toBe(false); + expect(res.error).toContain(source); + expect(writes).toEqual([]); + } + }); + + it('the date macros are refused at registration, located, naming the same form', () => { + const automation = new AutomationEngine(makeLogger()); + registerCrudNodes(automation, { logger: makeLogger(), getService: () => undefined } as any); + let thrown: Error | undefined; + try { + automation.registerFlow('macro_parity', macroFlow('{TODAY() + 7}')); + } catch (err) { + thrown = err as Error; + } + expect(thrown?.message).toContain("node 'w' (create_record) create_record field value at config.fields.subject"); + expect(thrown?.message).toContain(VALUE_SLOT_TEMPLATE_REFUSAL); + expect(thrown?.message).toContain("{ dialect: 'cel', source: 'isoDate(daysFromNow(7))' }"); + }); +}); diff --git a/packages/services/service-automation/src/builtin/crud-nodes.ts b/packages/services/service-automation/src/builtin/crud-nodes.ts index 459d1f44c4b..3971b694a43 100644 --- a/packages/services/service-automation/src/builtin/crud-nodes.ts +++ b/packages/services/service-automation/src/builtin/crud-nodes.ts @@ -187,11 +187,11 @@ function writtenRowCount(result: unknown): number { * template dialect never reaches this point: the executor's * `parseNodeConfig` refuses it through `FlowValueSlotSchema` (the same * judge `registerFlow` and `objectstack validate` call), so a literal here - * carries no token — or only the one spelling CEL cannot write yet and - * the retirement keeps, the date macros (`{NOW()}`, `{TODAY() + 7}`), - * which `interpolate()` still resolves. On every other literal - * `interpolate()` is the identity. The run's `context` reaches the - * envelope too: it is what `current_user` is in the CEL scope. + * carries no token: the retirement keeps no spelling — the date macros + * are refused too, naming their CEL string form (`isoDate(today())`) — + * and `interpolate()` is the identity on every literal that reaches it. + * The run's `context` reaches the envelope too: it is what + * `current_user` is in the CEL scope. * * Before this, the executor handed the whole map to `interpolate()`, which * recursed into an envelope as plain data: a text or JSON column received the diff --git a/packages/services/service-automation/src/builtin/logic-nodes.test.ts b/packages/services/service-automation/src/builtin/logic-nodes.test.ts index 594c5da97b2..c6fd706f2c6 100644 --- a/packages/services/service-automation/src/builtin/logic-nodes.test.ts +++ b/packages/services/service-automation/src/builtin/logic-nodes.test.ts @@ -116,9 +116,12 @@ describe('assignment node — config-shape parity (Studio + examples)', () => { expect(variables.has('kept'), 'nothing is assigned once the node is refused').toBe(false); }); - it('[#19939] the one spelling CEL cannot write yet keeps resolving — a date macro; the run user is `current_user`', async () => { + it('[#19939] a date macro is its CEL string form; the run user is `current_user`', async () => { engine.registerFlow('assign_flow', assignmentFlow({ - assignments: { day: '{TODAY()}', who: { dialect: 'cel', source: 'current_user.id' } }, + assignments: { + day: { dialect: 'cel', source: 'isoDate(today())' }, + who: { dialect: 'cel', source: 'current_user.id' }, + }, }, ['day', 'who'])); const result = await engine.execute('assign_flow', { userId: 'usr_1' } as any); expect(result.success).toBe(true); @@ -127,6 +130,16 @@ describe('assignment node — config-shape parity (Studio + examples)', () => { expect(String(output.day)).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); + it('[#19939] refuses a date macro at registration, naming its CEL string form', () => { + let message = ''; + try { + engine.registerFlow('assign_flow', assignmentFlow({ assignments: { day: '{TODAY()}' } }, ['day'])); + } catch (err) { message = (err as Error).message; } + expect(message).toContain(VALUE_SLOT_TEMPLATE_REFUSAL); + expect(message).toContain("node 'assign' (assignment) assignment value at config.assignments.day"); + expect(message).toContain("{ dialect: 'cel', source: 'isoDate(today())' }"); + }); + it('[#19939] refuses `{$User.Id}` at registration, naming `current_user.id` and its guard', () => { let message = ''; try { diff --git a/packages/services/service-automation/src/builtin/logic-nodes.ts b/packages/services/service-automation/src/builtin/logic-nodes.ts index 12cdad79e48..9e11daff3bc 100644 --- a/packages/services/service-automation/src/builtin/logic-nodes.ts +++ b/packages/services/service-automation/src/builtin/logic-nodes.ts @@ -106,9 +106,9 @@ export function registerLogicNodes(engine: AutomationEngine, ctx: PluginContext) // any variable is set, by the one judge `registerFlow` and `objectstack // validate` call (`flowNodeValueTemplateRefusals`) — so the legacy // shapes are no way around it, and a flow that registered cannot be - // refused here. What reaches `interpolate()` below carries no token, or - // only the one the retirement keeps until CEL can spell it (the date - // macros); on everything else it is the identity. The run user is + // refused here. What reaches `interpolate()` below carries no token — + // the retirement keeps none; a date macro is refused with its CEL + // string form — so there it is the identity. The run user is // `current_user` in a CEL envelope, bound from the `context` this // executor passes to the evaluator. // diff --git a/packages/services/service-automation/src/builtin/template.ts b/packages/services/service-automation/src/builtin/template.ts index 20f7253ff6e..88ef558e983 100644 --- a/packages/services/service-automation/src/builtin/template.ts +++ b/packages/services/service-automation/src/builtin/template.ts @@ -34,11 +34,12 @@ * can be applied wholesale to a node's `config.filter` block and its other * value-like positions. The value slots (`fields.*`, `assignments.*`) no * longer read this dialect (#19939): a `{…}` token there is refused before it - * gets here, except the date macros, which CEL cannot spell yet - * (`@objectstack/spec/automation`'s `flow-value-slot-template.ts`); the run - * user there is the CEL scope's `current_user`. The `{$User.*}` branch below - * still answers the positions that keep this dialect (a `filter`, a `notify` - * `recipients` entry). Nor do + * gets here (`@objectstack/spec/automation`'s `flow-value-slot-template.ts`), + * each with its CEL spelling — the run user is the CEL scope's + * `current_user`, and a date macro is its CEL string form, `isoDate(today())` + * / `isoDatetime(now())` (the stdlib writes the bytes the date branch below + * writes). The `{$User.*}` and date branches below still answer the positions + * that keep this dialect (a `filter`, a `notify` `recipients` entry). Nor do * the TEXT slots — a notify `title` / `message`, a screen `title` / * `description`, a refusing `end` node's `message` (#22110, ADR-0032 D3): * they render ADR-0032 §3's `{{ }}` holes through the formula template engine, @@ -109,7 +110,10 @@ export class FlowExpressionFunctionError extends Error { * to string there; a string riding into `/ 100` would corrupt silently). * * `NOW()` / `TODAY()` are deliberately NOT in this table: they are whole-token - * date macros with their own `± N days` grammar, handled before this path. + * date macros with their own `± N days` grammar, handled before this path — + * and in a value slot, where this dialect is retired, CEL's `isoDate(today())` + * / `isoDatetime(now())` write the same text (the parity pin beside + * `crud-fields-value-envelope.test.ts`). */ const EXPRESSION_FUNCTION_ARITY: Record = { round: 1, diff --git a/packages/services/service-automation/src/builtin/value-slot-template-grammar.test.ts b/packages/services/service-automation/src/builtin/value-slot-template-grammar.test.ts index bf9e76c3111..6eb667d251d 100644 --- a/packages/services/service-automation/src/builtin/value-slot-template-grammar.test.ts +++ b/packages/services/service-automation/src/builtin/value-slot-template-grammar.test.ts @@ -7,13 +7,18 @@ * * The spec cannot import the interpolator, so it carries a copy of * `resolveToken`'s dispatch: date macro, then `$User.`, then a variable path, - * then an expression. If the two readings drifted, the judge would refuse a - * spelling the retirement keeps (a run-time capability lost with no remedy), - * or keep one it should refuse (the dialect surviving in a value slot). So the - * interpolator itself is driven over the same tokens the judge classifies: + * then an expression. If the two readings drifted, the judge would print the + * wrong remedy for a spelling (a date macro read as a path, a path read as an + * expression), or pass one it should refuse (the dialect surviving in a value + * slot). So the interpolator itself is driven over the same tokens the judge + * classifies: * - * - every KEPT spelling resolves through `interpolateString` to a value — the - * date macros to ISO text — and the judge refuses none of them; + * - every date macro (refused since #19939 pass 3) resolves through + * `interpolateString` to ISO text, and the judge refuses each with its CEL + * string form (`isoDate(…)` / `isoDatetime(…)`) — read as a date macro, + * never as a path or an expression (that each form writes the same bytes + * over the same instants is the parity pin in + * `crud-fields-value-envelope.test.ts`); * - the run user (`$User`, refused since #19939 pass 2) resolves through the * interpolator to the run's `userId` for `$User.Id` and to nothing for every * other path in a shipped run context, and the remedies the judge prints @@ -47,18 +52,19 @@ const VARIABLES = new Map([ ]); const CONTEXT = { userId: 'usr_1', user: { email: 'ada@example.com' } } as never; -describe('a spelling the retirement KEEPS resolves through the interpolator, and is not refused', () => { +describe('a date macro resolves through the interpolator to ISO text, and is REFUSED as a date macro', () => { it.each([ - ['{NOW()}', /^\d{4}-\d{2}-\d{2}T/], - ['{TODAY()}', /^\d{4}-\d{2}-\d{2}$/], - ['{TODAY() + 7}', /^\d{4}-\d{2}-\d{2}$/], - ['{TODAY() - days}', /^\d{4}-\d{2}-\d{2}$/], - ['{NOW() + 2}', /^\d{4}-\d{2}-\d{2}T/], - ])('%s — a date macro', (token, shape) => { + ['{NOW()}', /^\d{4}-\d{2}-\d{2}T/, 'isoDatetime(now())'], + ['{TODAY()}', /^\d{4}-\d{2}-\d{2}$/, 'isoDate(today())'], + ['{TODAY() + 7}', /^\d{4}-\d{2}-\d{2}$/, 'isoDate(daysFromNow(7))'], + ['{TODAY() - days}', /^\d{4}-\d{2}-\d{2}$/, 'isoDate(addDays(today(), -days))'], + ['{NOW() + 2}', /^\d{4}-\d{2}-\d{2}T/, 'isoDatetime(addDays(now(), 2))'], + ])('%s — a date macro', (token, shape, source) => { expect(String(interpolateString(token, VARIABLES, CONTEXT))).toMatch(shape); - expect(valueSlotTemplateRefusals(token)).toEqual([]); + const refusals = valueSlotTemplateRefusals(token); + expect(refusals).toHaveLength(1); + expect(refusals[0]!.message).toContain(`Write \`${token}\` as { dialect: 'cel', source: '${source}' }`); }); - }); describe('the run user — `{$User.*}` — is REFUSED, and its remedy reads what the interpolator read', () => { From 81397df284e35976644e07a42b81e93dd54fd369 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 03:37:54 +0000 Subject: [PATCH 3/8] feat(lint): value-slot hints ask the judge at the string's position, and the double-brace hint names the envelope in a value slot `flow-double-brace-interpolation` no longer prescribes `{record.title}` in a value slot, where the judge refuses it: it names the CEL envelope the judge writes for the holes the author meant. Both value-slot hints ask the judge at the string's own position, so a nested or legacy-shape string gets the spelling that evaluates there. The flows docs restate the date macros as refused, with their CEL string forms and edges. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- content/docs/automation/flows.mdx | 50 +++++--- packages/lint/src/lint-flow-patterns.test.ts | 100 +++++++++++++++- packages/lint/src/lint-flow-patterns.ts | 111 +++++++++++++----- ...date-expressions.fields-value-slot.test.ts | 15 ++- 4 files changed, 222 insertions(+), 54 deletions(-) diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index e03bf2500a7..3ebd952e9be 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -273,11 +273,30 @@ for some input, so the rewrite is yours to judge. | braces meant literally, `'{"a": 1}'` | `source: "'{\"a\": 1}'"` | a CEL string literal | | `'{$User.Id}'` | `source: 'current_user.id'` | `current_user` is the run's user, and `null` in a run with none (a schedule, a record change made by a system write), where this read fails the run. In a flow that can run without a user, write `current_user != null ? current_user.id : null`: it writes `null` where the template wrote nothing, which on `update_record` clears a stored value the template left alone — or skip the node on `current_user != null` | | `'{$User.Email}'`, `'{$User.Name}'`, any other `$User` path | read the user record by `current_user.id`: an `assignment` (`uid: { dialect: 'cel', source: 'current_user.id' }`), a `get_record` on `sys_user` with `filter: { id: '{uid}' }` and `outputVariable: 'me'`, then `source: 'me.email'` | these never resolved in any shipped run — they wrote nothing. `current_user` carries only what the run holds: `id`, `positions`, `organizationId`, `isPlatformAdmin` | - -One spelling **keeps** its meaning for now, because CEL cannot write it yet: -the date macros (`'{NOW()}'`, `'{TODAY() + 7}'` — CEL's `now()` / `today()` are -timestamps, not the ISO text the macros write, and there is no string form for -one). +| `'{TODAY()}'` | `source: 'isoDate(today())'` | none: `isoDate` writes the `YYYY-MM-DD` text the macro wrote, on the UTC calendar | +| `'{TODAY() + 7}'`, `'{TODAY() - 3}'` | `source: 'isoDate(daysFromNow(7))'`, `source: 'isoDate(daysAgo(3))'` | none for a whole number of days. A fraction (`'{TODAY() - 1.5}'`) is not: the template truncated the day of the month plus the offset, `addDays(today(), -1.5)` truncates the offset, so going back they land a day apart on most days of a month, and `daysAgo(1.5)` is refused at build. Write the whole number of days you mean | +| `'{NOW()}'` | `source: 'isoDatetime(now())'` | none: `isoDatetime` writes the `YYYY-MM-DDTHH:mm:ss.sssZ` text the macro wrote | +| `'{NOW() + 2}'`, `'{NOW() - 1}'` | `source: 'isoDatetime(addDays(now(), 2))'`, `source: 'isoDatetime(addDays(now(), -1))'` | none — `addDays` keeps the time of day, where `daysFromNow` would land on midnight | +| `'{TODAY() + days}'` | `source: 'isoDate(addDays(today(), days))'` | the template added 0 days, without a word, when `days` was absent or not a number; CEL fails the run on either. It also looked the offset up as one name, so `'{TODAY() + record.days}'` added 0 where CEL reads the path | +| `'Due {TODAY()} by {$User.Id}'` | `source: "'Due ' + isoDate(today()) + ' by ' + current_user.id"` | a string mixing a date macro with another token is refused whole, each token spelled as above | + +No spelling is kept: a value slot reads no `{…}` token at all. + +**Where an envelope is data, the remedy names none there.** An envelope +evaluates only as the top-level value of the `fields` map or the `assignments` +map. Inside an object or list value, and in the two legacy `assignment` shapes +(an `assignments: [{ variable, value }]` array, variables as the config's own +keys), it is stored as the object it spells — so the refusal there names what +does evaluate: + +- a string inside an object or list value: build the **whole value** as one + envelope, a CEL map or list literal — `payload: { note: 'for {name}' }` is + `payload: { dialect: 'cel', source: "{'note': 'for ' + name}" }`. CEL holds + every value of one map, and every element of one list, to one type, so where + they mix — a literal beside a computed value included — wrap each one in + `dyn(…)`: `"{'who': dyn(name), 'n': dyn(3)}"`; +- a legacy `assignment` shape: move the node's assignments into the + `assignments` map, where the envelope evaluates. [#19939]: https://github.com/objectstack-ai/objectstack/issues/19939 @@ -327,7 +346,7 @@ check. | `'Deal won: {record.name}'` | `'Deal won: {{ record.name }}'` | | `'Failed: {$error.message}'` | `'Failed: {{ $error.message }}'` | | `'Total {amount * 2}'`, `'{round(x)}'` | compute it into a variable (`assignments: { v: { dialect: 'cel', source: 'amount * 2' } }`), then `'Total {{ v }}'` | -| `'Due {TODAY() + 7}'` | compute it into a variable with an `assignment` node, whose value slot still reads that spelling (`assignments: { due: '{TODAY() + 7}' }`), then `'Due {{ due }}'` | +| `'Due {TODAY() + 7}'` | compute its ISO text into a variable with an `assignment` node's CEL value envelope (`assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' } }`), then `'Due {{ due }}'` | | `'By {$User.Id}'` | compute the run user's id into a variable with an `assignment` node's CEL value envelope (`assignments: { by: { dialect: 'cel', source: 'current_user.id' } }`), then `'By {{ by }}'` — guarded as `current_user != null ? current_user.id : null` in a flow that can run without a user | [#22110]: https://github.com/objectstack-ai/objectstack/issues/22110 @@ -347,8 +366,8 @@ check. title: { dialect: 'cel', source: "'Follow up on ' + record.name" }, assignee: { dialect: 'cel', source: 'has(record.owner) ? record.owner : null' }, estimate: { dialect: 'cel', source: 'round(record.amount * 0.15 * 100.0) / 100.0' }, - // a date macro — one of the two `{…}` spellings a value slot still reads - due_date: '{TODAY() + 7}', + // a date as its ISO text, on the UTC calendar — what `{TODAY() + 7}` wrote + due_date: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' }, status: 'open', // a literal }, }, @@ -1988,15 +2007,14 @@ value envelope. **Text** slots — a `notify` title or message, a screen title o description, a refusing `end` node's message — are `{{ }}` templates (see [text slots](#text-slots-read-double-brace-holes)). Single braces remain only on the value-like positions that hand a resolved value over (`recipients`, -`sourceId`, a `subflow` input, an `http` body, …) and in the date macros a value -slot still reads. +`sourceId`, a `subflow` input, an `http` body, …). | Where | Dialect | Write it like | Bindings | |:---|:---|:---|:---| | Start-node `condition` | **CEL** (bare, no braces) | `record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*`, `current_user` | | Edge `condition` | **CEL** (bare, no braces) | `record.status == 'open'` | same as above | | Decision-node `conditions[].expression` | **CEL** (bare, no braces) | `order_amount > 10000` | flow variables by name, `vars.*` and `current_user` | -| Field values and assignment values, as a **literal** | none — written as it is | `'open'`, `42`, `true`, `['a', 'b']` | — (a `{…}` template token is refused here since [#19939](https://github.com/objectstack-ai/objectstack/issues/19939), `{$User.*}` included, except the date macros `{NOW()}` / `{TODAY() ± N}`, which still resolve until CEL can write them) | +| Field values and assignment values, as a **literal** | none — written as it is | `'open'`, `42`, `true`, `['a', 'b']` | — (a `{…}` template token is refused here since [#19939](https://github.com/objectstack-ai/objectstack/issues/19939), `{$User.*}` and the date macros `{NOW()}` / `{TODAY() ± N}` included: a date is `isoDate(today())` / `isoDatetime(now())` in an envelope) | | Field values and assignment values, as a **CEL value envelope** | **CEL** (in an envelope) | `{ dialect: 'cel', source: 'round(price * 100.0) / 100.0' }` | flow variables by name, `vars.*` and `current_user` — the whole CEL stdlib (`joinNonEmpty`, …) | | Text slots — `notify` `title` / `message`, `screen` `title` / `description`, `end` `message` | **template** (`{{ }}` holes) | `'Deal won: {{ record.name }} ({{ record.amount \| currency }})'` | flow variables by name (`$`-named ones too: `{{ $error.message }}`) — a path and an optional formatter, never logic | @@ -2004,9 +2022,9 @@ A value slot takes either form, chosen by shape: an object naming a `dialect` is a CEL envelope, everything else is a literal. The `{token}` template dialect it used to read is retired: `objectstack validate`, `registerFlow` and the executor refuse a `{…}` token in a value slot with its CEL spelling (see *The -`{…}` template dialect is retired from value slots* above). The date macros are -kept until CEL can write them: CEL's `now()` / `today()` are timestamps, not the -strings the macros write. +`{…}` template dialect is retired from value slots* above). A date macro is its +CEL string form: `isoDate(today())` / `isoDatetime(now())` write the ISO text +the macros wrote, where a bare `today()` / `now()` would write a timestamp. Every CEL expression in a flow — a condition or a value envelope — sees **`current_user`**, the run's user: `current_user.id`, `current_user.positions`, @@ -2023,8 +2041,8 @@ and is read as `vars["current_user"]` / `vars["vars"]`. 1. **A computed value written as a string** — `due_date: 'TODAY() + 7'` or `amount: 'price * 2'` writes the literal text into the field. Compute it with - a CEL value envelope (`{ dialect: 'cel', source: 'price * 2' }`); the date - macros keep their braces for now: `'{TODAY() + 7}'`. + a CEL value envelope (`{ dialect: 'cel', source: 'price * 2' }`), a date as + its ISO text (`{ dialect: 'cel', source: 'isoDate(daysFromNow(7))' }`). 2. **Braces put *into* a condition** — `'{record.amount} > 500'`. Conditions fail loudly rather than silently, with an error that tells you to drop the braces. diff --git a/packages/lint/src/lint-flow-patterns.test.ts b/packages/lint/src/lint-flow-patterns.test.ts index 9352e9a1e2b..90f37712912 100644 --- a/packages/lint/src/lint-flow-patterns.test.ts +++ b/packages/lint/src/lint-flow-patterns.test.ts @@ -232,10 +232,14 @@ describe('lintFlowPatterns — wrong interpolation syntax (#1315)', () => { .toContain(FLOW_BARE_DOLLAR_REF); }); describe('does NOT flag (false-positive guards)', () => { - it('correct single-brace interpolation', () => { + // A single-brace token is neither a double brace nor a bare `$` reference, + // so neither #1315 rule fires on it. In a value slot like this one the + // token is refused anyway — by the value-slot judge (#19939), a different + // finding (`expression-invalid` at `objectstack validate`), not this rule's. + it('a single-brace token draws neither interpolation rule (the value-slot judge refuses it, a different finding)', () => { expect(rules(nodeFlow({ objectName: 'm', fields: { body: '{ai_reply}', t: 'Hi {record.name}' } }))).toEqual([]); }); - it('a braced $User reference', () => { + it('a braced $User token is not a BARE $ reference (the value-slot judge refuses it, a different finding)', () => { expect(rules(nodeFlow({ objectName: 'm', fields: { owner: '{$User.Id}' } }))).toEqual([]); }); it('a currency literal', () => { @@ -2694,12 +2698,104 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule } }); + // #19939 pass 3: the hint asks the judge AT the string's position, so + // where an envelope is literal data — a string nested in an object + // literal, a legacy `assignment` shape — it names the spelling that + // evaluates there, never an envelope at the position. + it('a reference nested in an object literal: the whole value built as one CEL literal, not an envelope at the position', () => { + const [f] = bareDollar(valueFlow('create_record', { objectName: 'task', fields: { payload: { who: '$source.id' } } })); + expect(f!.hint).toContain('sits inside an object or list literal, where nothing evaluates'); + expect(prescribedSources(f!.hint!)).toEqual(["{'who': source.id}"]); + expect(refusalsOf(valueFlow('create_record', { objectName: 'task', fields: { payload: CEL("{'who': source.id}") } }))) + .toEqual(NONE); + }); + + it('a reference in the legacy `assignments` array: the canonical map, where the envelope evaluates', () => { + const [f] = bareDollar(valueFlow('assignment', { assignments: [{ variable: 'v', value: '$User.Id' }] })); + expect(f!.hint).toContain('Move the node\'s assignments into the canonical map'); + const prescribed = prescribedSources(f!.hint!); + expect(prescribed).toEqual(['current_user.id', 'current_user != null ? current_user.id : null']); + for (const source of prescribed) { + expect(refusalsOf(valueFlow('assignment', { assignments: { v: CEL(source) } })), source).toEqual(NONE); + } + }); + it('control: outside the value and text slots the single brace still resolves, and the hint still names it', () => { const [f] = lintFlowPatterns(httpFlow({ ticket: '$source.id' })).filter((x) => x.rule === FLOW_BARE_DOLLAR_REF); expect(f!.hint).toContain('Wrap it and bind a variable: `{source.id}`'); expect(flowNodeValueTemplateRefusals('http', httpPushConfig({ ticket: '{source.id}' }))).toEqual([]); }); }); + + // #19939 pass 3 (carry from pass 2): `flow-double-brace-interpolation`'s + // hint said `Use {var} (e.g. {record.title})` in a value slot too — a + // spelling the value-slot judge refuses there. In a value slot it now names + // the CEL envelope the judge writes for the string with each `{{ }}` hole + // read as the token it means, asked at the string's position; elsewhere the + // single brace still resolves and the hint is unchanged. + describe('a double brace in a value slot', () => { + function valueFlow(type: string, config: Record) { + return { + flows: [{ + name: 'stamp_title', label: 'Stamp title', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'write', type, label: 'Write', config }, + { id: 'done', type: 'end', label: 'Done' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'write' }, { id: 'e2', source: 'write', target: 'done' }], + }], + }; + } + const doubleBrace = (stack: ReturnType) => + lintFlowPatterns(stack).filter((f) => f.rule === FLOW_DOUBLE_BRACE_INTERP); + function prescribedSources(hint: string): string[] { + const out: string[] = []; + for (const m of hint.matchAll(/\{ dialect: 'cel', source: (?:'([^']*)'|("(?:[^"\\]|\\.)*")) \}/g)) { + const source = m[1] ?? (JSON.parse(m[2]!) as string); + if (source !== '…') out.push(source); + } + return out; + } + function refusalsOf(stack: ReturnType) { + const node = stack.flows[0]!.nodes[1]!; + const parsed = FlowSchema.safeParse(stack.flows[0]); + return { + valueJudge: flowNodeValueTemplateRefusals(node.type, node.config).length, + buildDoor: validateStackExpressions(stack).filter((i) => i.severity === 'error').length, + contract: parsed.success ? 0 : parsed.error.issues.length, + lint: lintFlowPatterns(stack).filter((f) => f.rule === FLOW_BARE_DOLLAR_REF || f.rule === FLOW_DOUBLE_BRACE_INTERP).length, + }; + } + const NONE = { valueJudge: 0, buildDoor: 0, contract: 0, lint: 0 }; + + it.each([ + ['a whole hole', '{{ record.title }}', 'record.title'], + ['text with a hole', 'Re: {{ record.title }}', "'Re: ' + record.title"], + ])('%s: names the envelope for the hole it means, not `{record.title}`', (_label, text, source) => { + const [f] = doubleBrace(valueFlow('create_record', { objectName: 'task', fields: { subject: text } })); + expect(f!.message).toContain('the create_record field value reads a CEL value envelope, not a template'); + expect(f!.hint).not.toContain('Use `{var}`'); + expect(f!.hint).toContain('the value-slot refusal reads its outer braces as literal text'); + expect(prescribedSources(f!.hint!)).toEqual([source]); + expect(refusalsOf(valueFlow('create_record', { objectName: 'task', fields: { subject: CEL(source) } }))).toEqual(NONE); + }); + + it('control: the spelling it used to prescribe is refused in the same slot', () => { + expect(refusalsOf(valueFlow('create_record', { objectName: 'task', fields: { subject: '{record.title}' } })).valueJudge).toBe(1); + }); + + it('in an assignment value nested in a literal: the whole value as a CEL literal', () => { + const [f] = doubleBrace(valueFlow('assignment', { assignments: { o: { t: '{{ record.title }}' } } })); + expect(f!.message).toContain('the assignment value reads a CEL value envelope'); + expect(prescribedSources(f!.hint!)).toEqual(["{'t': record.title}"]); + }); + + it('control: outside the value and text slots the single brace still resolves, and the double-brace hint still names it', () => { + const [f] = lintFlowPatterns(httpFlow({ title: '{{ record.title }}' })).filter((x) => x.rule === FLOW_DOUBLE_BRACE_INTERP); + expect(f!.hint).toContain('Use `{var}` (e.g. `{record.title}`)'); + }); + }); }); it('still raises nothing for a correct single-brace payload', () => { diff --git a/packages/lint/src/lint-flow-patterns.ts b/packages/lint/src/lint-flow-patterns.ts index ee30b6ea9c2..6366250123b 100644 --- a/packages/lint/src/lint-flow-patterns.ts +++ b/packages/lint/src/lint-flow-patterns.ts @@ -162,7 +162,6 @@ import { flowNodeValueTemplateRefusals, textSlotTemplateRefusal, VALUE_SLOT_TEMPLATE_REFUSAL, - valueSlotTemplateRefusals, } from '@objectstack/spec/automation'; import type { FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automation'; // [#15429] The decision's `mode` contract, parsed here so `os validate` and @@ -731,12 +730,28 @@ function textSlotBareDollarHint(outsideHoles: string): string { } /** - * [#19939] The CEL spelling the spec's value-slot judge gives `token`, a - * `{…}` token standing alone as a value — its refusal without the rule - * sentence every refusal leads with, which the hint states once. + * Where a string sits in a value slot, for asking the spec's value-slot judge + * about other text at the same position: the node type, the config, and the + * key path to the string. */ -function valueSlotSpellingOf(token: string): string { - const message = valueSlotTemplateRefusals(token)[0]?.message ?? ''; +interface ValueSlotPosition { + readonly nodeType: string; + readonly config: unknown; + readonly path: readonly (string | number)[]; +} + +/** + * [#19939] The CEL spelling the spec's value-slot judge gives `text` put at + * `at` in place of the string there — its refusal without the rule sentence + * every refusal leads with, which the hint states once. Asked AT the position, + * never of the text standing alone: where an envelope is literal data (a string + * inside an object or list literal, a legacy `assignment` shape) the judge + * names the spelling that evaluates there, not an envelope at the position. + */ +function valueSlotSpellingAt(at: ValueSlotPosition, text: string): string { + const probed = valueSlotRefusalAt(at.nodeType, at.config, at.path); + const message = flowNodeValueTemplateRefusals(at.nodeType, withValueAt(at.config, at.path, text)) + .find((refusal) => refusal.path === probed?.path && refusal.source === text)?.message ?? ''; return message.startsWith(VALUE_SLOT_TEMPLATE_REFUSAL) ? message.slice(VALUE_SLOT_TEMPLATE_REFUSAL.length).trimStart() : message; @@ -759,7 +774,7 @@ function valueSlotSpellingOf(token: string): string { * - any other `$` root names a variable the flow binds itself, which is * written without the `$` (`$source.id` names `{source.id}`). */ -function valueSlotBareDollarHint(text: string): string { +function valueSlotBareDollarHint(text: string, at: ValueSlotPosition): string { const parts = [ 'A value slot reads a CEL value envelope, `{ dialect: \'cel\', source: \'…\' }`: a string in it is the literal ' + 'text it spells, so a bare `$` reference is stored as written.', @@ -771,13 +786,36 @@ function valueSlotBareDollarHint(text: string): string { parts.push( ownVariable ? `\`${ref}\` names \`${root}\`, which is not one of the flow engine's own \`$\` variables, so it reads ` - + `as the flow's own variable, written without the \`$\`: ${valueSlotSpellingOf(token)}` - : `\`${ref}\` reads as \`${token}\`: ${valueSlotSpellingOf(token)}`, + + `as the flow's own variable, written without the \`$\`: ${valueSlotSpellingAt(at, token)}` + : `\`${ref}\` reads as \`${token}\`: ${valueSlotSpellingAt(at, token)}`, ); } return parts.join(' '); } +/** Every `{{ path }}` hole in a string — what {@link valueSlotDoubleBraceHint} reads as the author's intent. */ +const DOUBLE_BRACE_HOLES = /\{\{\s*([\w$][\w$.\s]*?)\s*\}\}/g; + +/** + * [#19939] The hint for a `{{ }}` hole in a VALUE slot's string. A value slot + * reads the CEL value envelope and no template: the spec's value-slot judge + * refuses the string anyway, reading its outer braces as literal text (the + * template wrote `{` and `}` around the value). The author meant holes, so the + * hint names the envelope the judge writes for the string with each hole as + * the single-brace token it means — asked of the judge at the string's own + * position, never re-spelled, so the spelling evaluates where the string sits. + */ +function valueSlotDoubleBraceHint(text: string, at: ValueSlotPosition): string { + const meant = text.replace(DOUBLE_BRACE_HOLES, (_hole, path: string) => `{${path}}`); + return ( + 'A value slot reads a CEL value envelope, `{ dialect: \'cel\', source: \'…\' }`, and no template: a string in it ' + + 'is the literal text it spells, and `{{ }}` is no hole — the value-slot refusal reads its outer braces as ' + + `literal text. Written as the holes it means: ${valueSlotSpellingAt(at, meant)} Double-brace \`{{ }}\` is the ` + + 'template dialect of the text slots only — a notify `title` / `message`, a screen `title` / `description`, ' + + 'an `end` `message`.' + ); +} + /** Config keys whose string values are CEL predicates, not interpolated templates. */ const CEL_KEYS = new Set(['condition', 'expression', 'conditions']); @@ -825,21 +863,26 @@ function withValueAt(node: unknown, path: readonly (string | number)[], value: u return { ...rec, [head as string]: withValueAt(rec[head as string], rest, value) }; } -/** A path token no author writes — what {@link valueSlotLabelAt} puts at a position to ask the judge about it. */ +/** A path token no author writes — what {@link valueSlotRefusalAt} puts at a position to ask the judge about it. */ const VALUE_SLOT_PROBE = '{os_lint_value_slot_probe}'; /** - * [#19939] The label of the VALUE slot the string at `path` sits in (`create_record - * field value`, `assignment value`), or `undefined` when it sits in none. - * Asked of the spec's value-slot judge, never re-listed: the positions are - * the ledger's `value` slots plus the two legacy `assignment` shapes its - * executor still reads, normalised the way the executor normalises them, and - * the judge refuses a path token put at `path` exactly when it judges that - * position. + * [#19939] The judge's refusal of a probe token put at `path` — its `label` + * names the VALUE slot the string at `path` sits in (`create_record field + * value`, `assignment value`) and its `path` locates it the way the judge + * does — or `undefined` when it sits in none. Asked of the spec's value-slot + * judge, never re-listed: the positions are the ledger's `value` slots plus + * the two legacy `assignment` shapes its executor still reads, normalised the + * way the executor normalises them, and the judge refuses a path token put at + * `path` exactly when it judges that position. */ -function valueSlotLabelAt(nodeType: string, config: unknown, path: readonly (string | number)[]): string | undefined { +function valueSlotRefusalAt( + nodeType: string, + config: unknown, + path: readonly (string | number)[], +): { readonly label: string; readonly path: string } | undefined { const probed = withValueAt(config, path, VALUE_SLOT_PROBE); - return flowNodeValueTemplateRefusals(nodeType, probed).find((refusal) => refusal.source === VALUE_SLOT_PROBE)?.label; + return flowNodeValueTemplateRefusals(nodeType, probed).find((refusal) => refusal.source === VALUE_SLOT_PROBE); } /** Edge `label`, normalized (trimmed, lowercased) for branch matching. */ @@ -1881,28 +1924,34 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] { // double brace there is the spelling, and their own bare-`$` check // below prescribes the hole. // [#19939] Each string also carries its key path, so the bare-`$` hint - // can ask whether it sits in a value slot (see `valueSlotLabelAt`). + // can ask whether it sits in a value slot (see `valueSlotRefusalAt`). const templateConfig = withoutTextSlots(node.type, stripRegions(node.config, ownRegionKeys(node.type))); const strings: ConfigString[] = []; collectTemplateStrings(templateConfig, undefined, strings); for (const { value: str, path } of strings) { + // [#19939] In a value slot both hints name the CEL envelope the + // value-slot judge accepts, asked at the string's own position; + // everywhere else the single brace still resolves, and they name it. + const at: ValueSlotPosition = { nodeType: String(node.type), config: templateConfig, path }; + const valueSlot = DOUBLE_BRACE.test(str) || BARE_DOLLAR_REF.test(str) + ? valueSlotRefusalAt(at.nodeType, at.config, at.path)?.label + : undefined; if (DOUBLE_BRACE.test(str)) { findings.push({ where: nodeWhere, - message: `double-brace interpolation \`${str.trim().slice(0, 80)}\` — this flow node value uses SINGLE braces.`, - hint: - `Use \`{var}\` (e.g. \`{record.title}\`): this flow node value is a string template in which only ` + - `single-brace \`{…}\` tokens resolve and all other text is literal. Double-brace \`{{ }}\` is ` + - `the template dialect of the text slots only — a notify \`title\` / \`message\`, a screen ` + - `\`title\` / \`description\`, an \`end\` \`message\`.`, + message: valueSlot === undefined + ? `double-brace interpolation \`${str.trim().slice(0, 80)}\` — this flow node value uses SINGLE braces.` + : `double-brace interpolation \`${str.trim().slice(0, 80)}\` — the ${valueSlot} reads a CEL value envelope, not a template.`, + hint: valueSlot === undefined + ? `Use \`{var}\` (e.g. \`{record.title}\`): this flow node value is a string template in which only ` + + `single-brace \`{…}\` tokens resolve and all other text is literal. Double-brace \`{{ }}\` is ` + + `the template dialect of the text slots only — a notify \`title\` / \`message\`, a screen ` + + `\`title\` / \`description\`, an \`end\` \`message\`.` + : valueSlotDoubleBraceHint(str, at), rule: FLOW_DOUBLE_BRACE_INTERP, }); } if (BARE_DOLLAR_REF.test(str)) { - // [#19939] In a value slot the hint names the CEL envelope the - // value-slot judge accepts; everywhere else the single brace - // still resolves, and the hint names it. - const valueSlot = valueSlotLabelAt(String(node.type), templateConfig, path); findings.push({ where: nodeWhere, message: valueSlot === undefined @@ -1912,7 +1961,7 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] { ? `Wrap it and bind a variable: \`{source.id}\` (or \`{$User.Id}\` for the current user) — a flow ` + `node value is a string template in which only single-brace \`{…}\` tokens resolve and all ` + `other text is literal.` - : valueSlotBareDollarHint(str), + : valueSlotBareDollarHint(str, at), rule: FLOW_BARE_DOLLAR_REF, }); } diff --git a/packages/lint/src/validate-expressions.fields-value-slot.test.ts b/packages/lint/src/validate-expressions.fields-value-slot.test.ts index de425404017..a1a3c96dc3a 100644 --- a/packages/lint/src/validate-expressions.fields-value-slot.test.ts +++ b/packages/lint/src/validate-expressions.fields-value-slot.test.ts @@ -108,13 +108,14 @@ describe('`fields.*` value slot — the malformed envelope is a located error at expect(splitBySeverity(findings).errors).toHaveLength(1); }); - it.each(NODE_TYPES)('%s: a valid envelope, the run user as `current_user`, the kept spelling and literals are clean', (nodeType) => { + it.each(NODE_TYPES)('%s: a valid envelope, the run user as `current_user`, a date macro\'s CEL string form and literals are clean', (nodeType) => { expect(validate(nodeType, crud(nodeType, { total: { dialect: 'cel', source: 'round(price * 100) / 100.0' }, subject: { dialect: 'cel', source: "'Quote for ' + string(price)" }, label: 'Quote', owner: { dialect: 'cel', source: 'current_user.id' }, - due: '{TODAY() + 7}', + due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' }, + at: { dialect: 'cel', source: 'isoDatetime(addDays(now(), -1))' }, n: 3, ok: true, nothing: null, payload: { nested: { dialect: 'cel' } }, // a nested envelope is data }))).toEqual([]); @@ -135,12 +136,15 @@ describe('the retired template dialect — a `{…}` token in a value slot is a ['a function inside text', 'create_record', crud('create_record', { subject: 'Total: {max(price, 10)}' }), 'config.fields.subject', "\"'Total: ' + (max(price, 10))\""], ['a plain reference', 'update_record', crud('update_record', { subject: '{record.name}' }), 'config.fields.subject', "source: 'record.name'"], ['text with a reference hole', 'create_record', crud('create_record', { subject: 'Follow up on {record.name}' }), 'config.fields.subject', "\"'Follow up on ' + record.name\""], - ['a nested literal string', 'create_record', crud('create_record', { payload: { note: '{price}' } }), 'config.fields.payload.note', "source: 'price'"], + ['a nested literal string — the whole value as a CEL literal', 'create_record', crud('create_record', { payload: { note: '{price}' } }), 'config.fields.payload.note', "source: \"{'note': price}\""], ['the canonical assignment map', 'assignment', { assignments: { total: '{floor(price)}' } }, 'config.assignments.total', "source: 'floor(price)'"], ['the legacy assignment array', 'assignment', { assignments: [{ variable: 'total', value: '{price}' }] }, 'config.assignments[0].value', "source: 'price'"], ['the legacy bare assignment config', 'assignment', { total: '{price}' }, 'config.total', "source: 'price'"], ['the run user\'s id', 'update_record', crud('update_record', { subject: '{$User.Id}' }), 'config.fields.subject', "source: 'current_user != null ? current_user.id : null'"], ['another run-user path', 'create_record', crud('create_record', { subject: '{$User.Email}' }), 'config.fields.subject', 'never resolved in any shipped run'], + ['a date macro', 'create_record', crud('create_record', { due: '{TODAY() + 7}' }), 'config.fields.due', "source: 'isoDate(daysFromNow(7))'"], + ['a timestamp macro with an offset', 'assignment', { assignments: { at: '{NOW() + 2}' } }, 'config.assignments.at', "source: 'isoDatetime(addDays(now(), 2))'"], + ['a date macro beside the run user — once kept whole', 'update_record', crud('update_record', { subject: 'Due {TODAY()} by {$User.Id}' }), 'config.fields.subject', "\"'Due ' + isoDate(today()) + ' by ' + current_user.id\""], ]; it.each(REFUSED)('%s — rule `expression-invalid`, severity `error`, located, with the CEL spelling', (_what, nodeType, config, at, spelling) => { @@ -156,10 +160,11 @@ describe('the retired template dialect — a `{…}` token in a value slot is a expect(splitBySeverity(findings).errors).toHaveLength(1); }); + // No `{…}` spelling is kept since #19939's third pass: the date macros are + // refused above with their CEL string form (`isoDate` / `isoDatetime`). it.each([ - ['a date macro — CEL has no string form of a Timestamp yet', '{NOW()}'], - ['a date macro with an offset', '{TODAY() + 7}'], ['plain text', 'approved'], + ['a date macro\'s CEL string form', { dialect: 'cel', source: 'isoDate(today())' }], ])('says nothing for %s', (_why, value) => { for (const nodeType of NODE_TYPES) { expect(validate(nodeType, crud(nodeType, { v: value })), `${nodeType}: ${value}`).toEqual([]); From 90cbd5dbd8c35c2a5a3766e250a5ae311b2ca921 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 03:41:54 +0000 Subject: [PATCH 4/8] chore(spec): regenerate the builtin-node-config reference for the value-slot descriptions Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- content/docs/references/automation/builtin-node-config.mdx | 4 ++-- .../src/builtin/crud-fields-value-envelope.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx index af901bc3618..bb5158a74c6 100644 --- a/content/docs/references/automation/builtin-node-config.mdx +++ b/content/docs/references/automation/builtin-node-config.mdx @@ -135,7 +135,7 @@ CEL value envelope `{ dialect: 'cel', source }` — evaluated by the expression ## AssignmentValue -Value the variable takes: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expression engine (the CEL stdlib such as `joinNonEmpty` is reachable), or a literal written as it is — a `{…}` template token in a string is refused (the template dialect is retired from value slots; the date macros are kept for now) +Value the variable takes: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expression engine (the CEL stdlib such as `joinNonEmpty` is reachable), or a literal written as it is — a `{…}` template token in a string is refused (the template dialect is retired from value slots; a date macro is its CEL string form, `isoDate(today())` / `isoDatetime(now())`) --- @@ -180,7 +180,7 @@ Value the variable takes: a CEL value envelope `{ dialect: 'cel', source }` eval ## FlowValueSlot -A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expression engine (the CEL stdlib such as `joinNonEmpty` is reachable), or a literal written as it is — a `{…}` template token in a string is refused (the template dialect is retired from value slots; the date macros are kept for now) +A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expression engine (the CEL stdlib such as `joinNonEmpty` is reachable), or a literal written as it is — a `{…}` template token in a string is refused (the template dialect is retired from value slots; a date macro is its CEL string form, `isoDate(today())` / `isoDatetime(now())`) --- diff --git a/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts b/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts index 9d7e7a99b6f..e7c2c3ccd5f 100644 --- a/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts +++ b/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts @@ -217,7 +217,7 @@ describe.each(NODE_TYPES)('%s `fields.*` — the retired `{…}` template dialec const TEMPLATED: ReadonlyArray<[string, Record, string, string]> = [ ['a sole token', { total: '{price}' }, 'config.fields.total', "source: 'price'"], ['text with holes', { subject: 'Quote for {name} at {price}' }, 'config.fields.subject', `"'Quote for ' + name + ' at ' + price"`], - ['a string inside a literal', { payload: { note: 'for {name}' } }, 'config.fields.payload.note', `"'for ' + name"`], + ['a string inside a literal — the whole value as a CEL literal', { payload: { note: 'for {name}' } }, 'config.fields.payload.note', `"{'note': 'for ' + name}"`], ['a template expression', { total: '{round(price * 100) / 100}' }, 'config.fields.total', "source: 'round(price * 100) / 100.0'"], ]; From 7e9c438c562ec3e2f93e42d794e9ce673f3c2b54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 03:42:56 +0000 Subject: [PATCH 5/8] chore(changeset): the date macros leave the flow value slots (spec, service-automation major; lint patch) Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- ...939-flow-value-slot-date-macros-refused.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .changeset/19939-flow-value-slot-date-macros-refused.md diff --git a/.changeset/19939-flow-value-slot-date-macros-refused.md b/.changeset/19939-flow-value-slot-date-macros-refused.md new file mode 100644 index 00000000000..05931d5513d --- /dev/null +++ b/.changeset/19939-flow-value-slot-date-macros-refused.md @@ -0,0 +1,51 @@ +--- +'@objectstack/spec': major +'@objectstack/service-automation': major +'@objectstack/lint': patch +--- + +A flow VALUE slot now refuses the date macros as well — `{TODAY()}`, `{NOW()}`, with an optional `± N` day offset — so it reads no single-brace `{…}` token at all. Each is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL string form: `isoDate(today())`, `isoDate(daysFromNow(N))` / `isoDate(daysAgo(N))`, `isoDatetime(now())`, `isoDatetime(addDays(now(), N))`. + +Clause-②: no (narrowing) + + + +**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + +**Why.** The template dialect is retired from the value slots: one dialect per slot, with a remedy for every spelling. The date macros were the last spelling kept, because CEL had no string form for a timestamp: `today()` and `now()` reach the data engine as a `Date` object, not the ISO text the macros wrote. The CEL stdlib now has one — `isoDate(t)` (`YYYY-MM-DD`) and `isoDatetime(t)` (`YYYY-MM-DDTHH:mm:ss.sssZ`), on the UTC calendar the macros rendered on — so the macros can be refused with a remedy that writes the same text. + +**What is refused.** In every value slot — `create_record` / `update_record` `fields`, the `assignment` node's `assignments` map, and both legacy `assignment` shapes — a string carrying a date macro, alone or beside other text and tokens. A string that mixed a date macro with another token used to be kept whole, the run-user token included (`'Due {TODAY()} by {$User.Id}'`); it is refused now, with a remedy for every token. + +**Where the remedy parts from the template.** For no offset or a whole number of days the CEL form writes the template's text byte for byte, at every instant (measured live through both engines over DST transitions, month and year ends and a leap day). The refusal names the three places it does not: + +- a fractional offset: the template added it to the day of the month and truncated the sum, `addDays` truncates the offset itself — going back, a day apart on most days of a month — and `daysFromNow(1.5)` / `daysAgo(1.5)` are refused at build; +- a variable offset (`{TODAY() + days}`): the template looked the offset up as one variable name and added 0 days, without a word, when it found none or the value was not a number; CEL reads the path, and fails the run on an absent variable or a value that is not a number; +- an offset that is neither a number nor a variable name (`{TODAY() + 3d}`): the template added 0 days. + +**Where an envelope is literal data, the remedy names none there.** The refusal used to prescribe an envelope at whatever position it refused a token. Inside an object or list value, and in the legacy `assignment` shapes, the executor reads an envelope as data: the prescribed metadata registered and stored the envelope object. The refusal there now names what evaluates — the whole value built as one CEL map or list literal (mixed values each wrapped in `dyn(…)`), or the assignment moved into the `assignments` map. This holds for every token, not only the date macros. An envelope-shaped object in those positions is still data, unchanged. + +## FROM → TO + +| you wrote | write instead | what changes | +|:--|:--|:--| +| `'{TODAY()}'` | `{ dialect: 'cel', source: 'isoDate(today())' }` | nothing | +| `'{TODAY() + 7}'`, `'{TODAY() - 3}'` | `{ dialect: 'cel', source: 'isoDate(daysFromNow(7))' }`, `{ dialect: 'cel', source: 'isoDate(daysAgo(3))' }` | nothing for a whole number of days; a fraction truncates differently, and `daysFromNow` / `daysAgo` refuse one at build | +| `'{NOW()}'` | `{ dialect: 'cel', source: 'isoDatetime(now())' }` | nothing | +| `'{NOW() + 2}'`, `'{NOW() - 1}'` | `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), 2))' }`, `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), -1))' }` | nothing — `addDays` keeps the time of day, where `daysFromNow` would land on midnight | +| `'{TODAY() + days}'` | `{ dialect: 'cel', source: 'isoDate(addDays(today(), days))' }` | an absent `days`, or one that is not a number, fails the run where the template silently added 0 | +| `'Due {TODAY()} by {$User.Id}'` | `{ dialect: 'cel', source: "'Due ' + isoDate(today()) + ' by ' + current_user.id" }` | guard the run user where a flow can run without one | +| `payload: { due: '{TODAY()}' }` (a string inside an object value) | `payload: { dialect: 'cel', source: "{'due': isoDate(today())}" }` | an envelope written at `payload.due` would be stored as an object | +| a text slot's `assignments: { due: '{TODAY() + 7}' }`, then `'Due {{ due }}'` | `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' } }`, then `'Due {{ due }}'` | the text-slot remedy published earlier on this line computed the date through the value-slot spelling this change refuses | + +**The one-line fix: write a date macro as `isoDate(…)` / `isoDatetime(…)` in a CEL envelope, with a whole number of days.** + +**Who is affected, measured.** This repository's examples carry no date macro in a value slot; its own test fixtures and docs that did are migrated in this change. The census taken for this retirement's first change measured 15 date-macro value-slot sites in hotcrm at `c529de2` (8 `{NOW()}`, 3 `{TODAY()}`, 3 `{TODAY() + N}`, 1 `{TODAY() + var}`); they migrate there. Deployed metadata and other repositories were not measured here. + +**Still accepted, unchanged.** A CEL value envelope and every literal. The single-brace dialect, date macros included, keeps resolving where it still lives — a `filter` value, a `notify` `recipients` entry, an `http` body, a `subflow` input. The value-slot retirement's earlier entries on this line list the date macros as still accepted, and the text-slot entry computes a date through them; this entry supersedes those lines. + +### The kit + +- **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`) keep no token; the text-slot judge's date-macro remedy (`textSlotTemplateRefusal`) names the CEL envelope. `FlowValueSlotSchema` / `AssignmentValueSchema` say so in their descriptions. +- **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the date macros and name their forms) and `flow-text-slot-single-brace-refused` (its date remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. +- **`@objectstack/lint`.** `flow-double-brace-interpolation`'s hint in a value slot names the CEL envelope for the holes the author meant, where it named `{record.title}`, which the slot refuses; `flow-bare-dollar-reference`'s value-slot hint asks the judge at the string's own position, so a nested or legacy-shape string gets the spelling that evaluates there. +- **`@objectstack/service-automation`'s README.** Its *Expressions* section names the date macros' CEL forms. From 630c1faff358814a06382b21c90795b8121583f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 03:44:17 +0000 Subject: [PATCH 6/8] test(service-automation): the parity pin evaluates the spelling the judge prints, then names it Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .../src/builtin/crud-fields-value-envelope.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts b/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts index e7c2c3ccd5f..1633921810f 100644 --- a/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts +++ b/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts @@ -464,12 +464,16 @@ describe('the date macros — the envelope each refusal names writes the bytes t ['Due {TODAY() + 7} for {name}', "'Due ' + isoDate(daysFromNow(7)) + ' for ' + name"], ['Due {TODAY()} by {$User.Id}', "'Due ' + isoDate(today()) + ' by ' + current_user.id"], ])('%s → %s', async (authored, source) => { - expect(remedyOf(authored)).toBe(source); + // The parity is asked of the spelling the judge PRINTS, so a remedy that + // drifted from the template fails on the written bytes; the table only + // names which spelling that is. + const remedy = remedyOf(authored); for (const instant of INSTANTS) { const template = templated(authored, instant); expect(typeof template, `${authored} @ ${instant}`).toBe('string'); - expect(await written({ dialect: 'cel', source }, instant), `${authored} @ ${instant}`).toBe(template); + expect(await written({ dialect: 'cel', source: remedy }, instant), `${authored} → ${remedy} @ ${instant}`).toBe(template); } + expect(remedy).toBe(source); }); // The edges the refusal names, each pinned as it names it. From 31f20e025ebdc822ba5da11f5b9b336e70a94a61 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 05:17:52 +0000 Subject: [PATCH 7/8] chore(spec): regenerate the builtin-node-config reference on the merged tree Main moved the reference while this branch changed two value-slot descriptions; the os-regen driver kept one side, so it is regenerated from the merged sources (gen:schema, gen:docs). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN --- content/docs/references/automation/builtin-node-config.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx index bb5158a74c6..2aab8ef2a5b 100644 --- a/content/docs/references/automation/builtin-node-config.mdx +++ b/content/docs/references/automation/builtin-node-config.mdx @@ -148,7 +148,7 @@ Value the variable takes: a CEL value envelope `{ dialect: 'cel', source }` eval | :--- | :--- | :--- | :--- | | **objectName** | `string` | ✅ | Object to insert into | | **fields** | `Record` | optional | Field values to write on the new record: each key is a field name, each value a CEL value envelope or a literal | -| **outputVariable** | `string` | optional | Flow variable bound to the created record | +| **outputVariable** | `string` | optional | Flow variable bound to the created record — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` | --- @@ -195,7 +195,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr | **filter** | `Record` | optional | Field/value pairs to match (operator values like `{"$ne": null}` are preserved) | | **fields** | `string[]` | optional | Field projection — only these fields are read (default: all) | | **limit** | `number` | optional | Max records to return; >1 switches to a multi-record query | -| **outputVariable** | `string` | optional | Flow variable the result is bound to | +| **outputVariable** | `string` | optional | Flow variable the result is bound to — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` | --- @@ -212,7 +212,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr | **indexVariable** | `string` | optional | Optional variable holding the current index | | **itemObject** | `string` | optional | When items are records, the object they belong to (exposes each item as the child's record) | | **input** | `Record` | optional | Params passed to each item's subflow (interpolated per item) | -| **outputVariable** | `string` | optional | Each item's subflow output, collected in order | +| **outputVariable** | `string` | optional | Each item's subflow output, collected in order — bound to a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` | --- From 0593be8782462de0aa684fddb30a1a4a5809d665 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 05:24:16 +0000 Subject: [PATCH 8/8] fix(spec): the value-slot refusal's has() guard tests each step off vars, so it holds for an unbound variable A refused path named a guard on its last key, has(source.id) ? source.id : null, for "one that may be absent". has() evaluates everything but its last selection, so where the variable itself was never bound (an isInput variable the caller left out, with no default) that guard failed the run with Unknown variable: source. The judge now prints a chain off vars, which holds only the bound variables: has(vars.source) && has(vars.source.id) ? vars.source.id : null. flow-bare-dollar-reference's value-slot hint carries the judge's words, so it is fixed with it. Pinned through the engine (value-slot-template-grammar.test.ts: an unbound root, a null root, an absent key, a bound value, and a full run with an unsupplied isInput variable against the last-key control), in the judge's own test, and on the lint hint (four judges, 0 refusals). The D3 entry, the flows doc row and the changeset name the chain. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN --- ...939-flow-value-slot-date-macros-refused.md | 5 +- content/docs/automation/flows.mdx | 2 +- packages/lint/src/lint-flow-patterns.test.ts | 15 +++++ .../value-slot-template-grammar.test.ts | 58 ++++++++++++++++++- .../flow-value-slot-template.test.ts | 22 +++++-- .../automation/flow-value-slot-template.ts | 31 ++++++---- ...low-value-slot-template-dialect-refused.ts | 6 +- packages/spec/src/migrations/registry.ts | 6 +- 8 files changed, 120 insertions(+), 25 deletions(-) diff --git a/.changeset/19939-flow-value-slot-date-macros-refused.md b/.changeset/19939-flow-value-slot-date-macros-refused.md index 05931d5513d..fcf60bc4f1d 100644 --- a/.changeset/19939-flow-value-slot-date-macros-refused.md +++ b/.changeset/19939-flow-value-slot-date-macros-refused.md @@ -24,6 +24,8 @@ Clause-②: no (narrowing) **Where an envelope is literal data, the remedy names none there.** The refusal used to prescribe an envelope at whatever position it refused a token. Inside an object or list value, and in the legacy `assignment` shapes, the executor reads an envelope as data: the prescribed metadata registered and stored the envelope object. The refusal there now names what evaluates — the whole value built as one CEL map or list literal (mixed values each wrapped in `dyn(…)`), or the assignment moved into the `assignments` map. This holds for every token, not only the date macros. An envelope-shaped object in those positions is still data, unchanged. +**The `has()` guard a refusal names now holds for an absent variable.** For a path that may be absent, the refusal named a guard on its last key, `has(source.id) ? source.id : null`. `has()` evaluates everything but its last selection, so where the variable itself was never bound — an `isInput` variable the caller left out, with no default — that guard failed the run (`Unknown variable: source`). The guard now tests each step off `vars`, which holds only the variables the run has bound: `has(vars.source) && has(vars.source.id) ? vars.source.id : null` answers `null` there, and where a key on the way is absent or `null`. `flow-bare-dollar-reference`'s value-slot hint carries the same guard. The value-slot retirement's earlier entry on this line prints the last-key guard; this entry supersedes it. + ## FROM → TO | you wrote | write instead | what changes | @@ -35,6 +37,7 @@ Clause-②: no (narrowing) | `'{TODAY() + days}'` | `{ dialect: 'cel', source: 'isoDate(addDays(today(), days))' }` | an absent `days`, or one that is not a number, fails the run where the template silently added 0 | | `'Due {TODAY()} by {$User.Id}'` | `{ dialect: 'cel', source: "'Due ' + isoDate(today()) + ' by ' + current_user.id" }` | guard the run user where a flow can run without one | | `payload: { due: '{TODAY()}' }` (a string inside an object value) | `payload: { dialect: 'cel', source: "{'due': isoDate(today())}" }` | an envelope written at `payload.due` would be stored as an object | +| the guard an earlier refusal named, `has(source.id) ? source.id : null` | `has(vars.source) && has(vars.source.id) ? vars.source.id : null` | `null` where `source` was never bound, where the last-key guard failed the run | | a text slot's `assignments: { due: '{TODAY() + 7}' }`, then `'Due {{ due }}'` | `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' } }`, then `'Due {{ due }}'` | the text-slot remedy published earlier on this line computed the date through the value-slot spelling this change refuses | **The one-line fix: write a date macro as `isoDate(…)` / `isoDatetime(…)` in a CEL envelope, with a whole number of days.** @@ -47,5 +50,5 @@ Clause-②: no (narrowing) - **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`) keep no token; the text-slot judge's date-macro remedy (`textSlotTemplateRefusal`) names the CEL envelope. `FlowValueSlotSchema` / `AssignmentValueSchema` say so in their descriptions. - **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the date macros and name their forms) and `flow-text-slot-single-brace-refused` (its date remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. -- **`@objectstack/lint`.** `flow-double-brace-interpolation`'s hint in a value slot names the CEL envelope for the holes the author meant, where it named `{record.title}`, which the slot refuses; `flow-bare-dollar-reference`'s value-slot hint asks the judge at the string's own position, so a nested or legacy-shape string gets the spelling that evaluates there. +- **`@objectstack/lint`.** `flow-double-brace-interpolation`'s hint in a value slot names the CEL envelope for the holes the author meant, where it named `{record.title}`, which the slot refuses; `flow-bare-dollar-reference`'s value-slot hint asks the judge at the string's own position, so a nested or legacy-shape string gets the spelling that evaluates there, and carries the judge's step-by-step `has()` guard. - **`@objectstack/service-automation`'s README.** Its *Expressions* section names the date macros' CEL forms. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 63878245368..2388ac72597 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -265,7 +265,7 @@ for some input, so the rewrite is yours to judge. | you wrote | write instead | what changes | |:---|:---|:---| -| `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an **absent** variable or key where the template wrote nothing — guard one that may be absent: `has(record.owner) ? record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) | +| `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an **absent** variable or key where the template wrote nothing — guard one that may be absent a step at a time from `vars`, which holds only the variables the run has bound: `has(vars.record) && has(vars.record.owner) ? vars.record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`). A guard on the last key alone does not hold where the variable is absent: `has(source.id)` fails the run when `source` was never bound (an `isInput` variable the caller left out) | | `'{items.0}'` | `source: 'items[0]'` | an empty list fails the run | | `'{$error.message}'` | `source: 'vars["$error"].message'` | a `$`-named variable is read through `vars` | | `'{round(x * 100) / 100}'` | `source: 'round(x * 100) / 100.0'` | CEL divides two integers as integers: keep a decimal operand on every division | diff --git a/packages/lint/src/lint-flow-patterns.test.ts b/packages/lint/src/lint-flow-patterns.test.ts index 90f37712912..4ccae32627d 100644 --- a/packages/lint/src/lint-flow-patterns.test.ts +++ b/packages/lint/src/lint-flow-patterns.test.ts @@ -2720,6 +2720,21 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule } }); + // #19939 pass 3: the `has()` guard the hint carries for "one that may be + // absent" must hold where the VARIABLE is absent too: `has(source.id)` + // fails the run when `source` was never bound (an `isInput` variable the + // caller left out). The judge's guard tests each step off `vars`, and + // the hint carries the judge's words — that the guard answers `null` + // there is pinned through the engine in `service-automation`'s + // `value-slot-template-grammar.test.ts`. + it('the `has()` guard it carries tests each step off `vars`, and passes every judge as an envelope', () => { + const [f] = bareDollar(valueFlow('create_record', { objectName: 'task', fields: { who: '$source.id' } })); + const guard = 'has(vars.source) && has(vars.source.id) ? vars.source.id : null'; + expect(f!.hint).toContain(`\`${guard}\``); + expect(f!.hint).not.toContain('`has(source.id)'); + expect(refusalsOf(valueFlow('create_record', { objectName: 'task', fields: { who: CEL(guard) } }))).toEqual(NONE); + }); + it('control: outside the value and text slots the single brace still resolves, and the hint still names it', () => { const [f] = lintFlowPatterns(httpFlow({ ticket: '$source.id' })).filter((x) => x.rule === FLOW_BARE_DOLLAR_REF); expect(f!.hint).toContain('Wrap it and bind a variable: `{source.id}`'); diff --git a/packages/services/service-automation/src/builtin/value-slot-template-grammar.test.ts b/packages/services/service-automation/src/builtin/value-slot-template-grammar.test.ts index 6eb667d251d..9331a7257c7 100644 --- a/packages/services/service-automation/src/builtin/value-slot-template-grammar.test.ts +++ b/packages/services/service-automation/src/builtin/value-slot-template-grammar.test.ts @@ -35,12 +35,15 @@ * template — including a head variable named like an identifier CEL claims * for itself (`{list.0}` → `vars["list"][0]`, #22290), or one the flow * scope binds over it (`{vars.0}` → `vars["vars"][0]`), and inside an - * EXPRESSION token (`{int * 2}` → `vars["int"] * 2`). + * EXPRESSION token (`{int * 2}` → `vars["int"] * 2`) — and the `has()` + * guard it prints answers `null`, rather than failing the run, where the + * variable itself was never bound (#19939 pass 3). */ import { describe, expect, it } from 'vitest'; import { VALUE_SLOT_TEMPLATE_REFUSAL, valueSlotTemplateRefusals } from '@objectstack/spec/automation'; import { AutomationEngine } from '../engine.js'; +import { registerLogicNodes } from './logic-nodes.js'; import { interpolateString } from './template.js'; const VARIABLES = new Map([ @@ -196,11 +199,62 @@ describe('the remedy a refused path prints reads, through CEL, the value the int }); it('the guard is read off the refusal too, so each row above evaluates it where one is printed', () => { - expect(printedSpellings('{list.tags}')).toEqual(['vars["list"].tags', 'has(vars.list.tags) ? vars.list.tags : null']); + expect(printedSpellings('{list.tags}')).toEqual(['vars["list"].tags', 'has(vars.list) && has(vars.list.tags) ? vars.list.tags : null']); expect(printedSpellings('{list}')).toEqual(['vars["list"]', 'has(vars.list) ? vars.list : null']); expect(printedSpellings('{null.tags}')).toEqual(['vars["null"].tags']); }); + // The guard is for "one that may be absent" — the VARIABLE as well as a key. + // A variable the run never bound (an `isInput` one the caller left out, with + // no default: `seedDeclaredVariables` sets nothing for it) is absent from the + // scope, and `has()` evaluates everything but its last selection, so a guard + // on the last key alone, `has(source.id)`, fails the run there. The printed + // guard tests each step off `vars`, which holds only the bound variables. + it.each([ + ['{source.id}', 'source', { id: 'r1' }, 'r1'], + ['{record.owner.name}', 'record', { owner: { name: 'Ada' } }, 'Ada'], + ['{list.tags}', 'list', { tags: 'T' }, 'T'], + ['{x}', 'x', 7, 7], + ])('the printed guard of %s answers `null` where the variable, or a key on the way, is absent', (token, root, bound, value) => { + const guard = printedSpellings(token)[1]!; + expect(guard, token).toMatch(/^has\(vars\./); + const evaluate = (variables: Map) => + engine.evaluateValueEnvelope({ dialect: 'cel', source: guard }, variables, token); + expect(evaluate(new Map()), `${guard} — ${root} unbound`).toBeNull(); + expect(evaluate(new Map([[root, null]])), `${guard} — ${root} null`).toBeNull(); + if (typeof bound === 'object') expect(evaluate(new Map([[root, {}]])), `${guard} — a key absent`).toBeNull(); + const variables = new Map([[root, bound]]); + expect(interpolateString(token, variables, CONTEXT)).toEqual(value); + expect(evaluate(variables), `${guard} — bound`).toEqual(value); + }); + + it('through a run: an `isInput` variable the caller left out — the printed guard writes `null`, a guard on the last key fails the run', async () => { + const guard = printedSpellings('{source.id}')[1]!; + expect(guard).toBe('has(vars.source) && has(vars.source.id) ? vars.source.id : null'); + const run = async (source: string) => { + const flowEngine = new AutomationEngine(quiet); + registerLogicNodes(flowEngine, { logger: quiet, getService: () => undefined } as never); + flowEngine.registerFlow('guarded', { + name: 'guarded', label: 'Guarded', type: 'autolaunched', + variables: [{ name: 'source', type: 'object', isInput: true }, { name: 'who', type: 'text', isOutput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'assign', type: 'assignment', label: 'Assign', config: { assignments: { who: { dialect: 'cel', source } } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'assign' }, { id: 'e2', source: 'assign', target: 'end' }], + } as never); + return flowEngine.execute('guarded', { params: {} } as never); + }; + const guarded = await run(guard); + expect(guarded.success, guarded.error).toBe(true); + expect(guarded.output).toEqual({ who: null }); + // Control: the guard the refusal printed before — `has()` on the last key alone. + const lastKeyOnly = await run('has(source.id) ? source.id : null'); + expect(lastKeyOnly.success).toBe(false); + expect(lastKeyOnly.error).toContain('Unknown variable: source'); + }); + it('a later keyword segment, and an index in the middle of a path', () => { const variables = new Map([['record', { in: 'x', tags: { null: 'y' } }], ['rows', [{ name: 'r0' }]]]); expectReadingsAgree('{record.in}', variables, 'x'); diff --git a/packages/spec/src/automation/flow-value-slot-template.test.ts b/packages/spec/src/automation/flow-value-slot-template.test.ts index 9ef5e8c193e..9ef8c1c0808 100644 --- a/packages/spec/src/automation/flow-value-slot-template.test.ts +++ b/packages/spec/src/automation/flow-value-slot-template.test.ts @@ -73,10 +73,18 @@ describe('a value-slot string in the retired `{…}` dialect is refused, with th expect(message).toContain('the guarded form writes `null`'); }); - it('a dotted path: the same path in CEL, guarded on its last key', () => { + // A guard on the last key alone does not hold where the VARIABLE is absent: + // `has(source.id)` fails the run when `source` was never bound (`Unknown + // variable`). So the guard tests each step off `vars`, which holds only the + // bound variables. That it answers `null` there is pinned through the + // engine in `service-automation`'s `value-slot-template-grammar.test.ts`. + it('a dotted path: the same path in CEL, guarded a step at a time from `vars`', () => { const message = refusalOf('{record.assignee}'); expect(message).toContain("{ dialect: 'cel', source: 'record.assignee' }"); - expect(message).toContain('`has(record.assignee) ? record.assignee : null`'); + expect(message).toContain('`has(vars.record) && has(vars.record.assignee) ? vars.record.assignee : null`'); + expect(message).toContain('a step at a time from `vars`, which holds only the variables the run has bound'); + expect(message).not.toContain('`has(record.assignee)'); + expect(refusalOf('{a.b.c}')).toContain('`has(vars.a) && has(vars.a.b) && has(vars.a.b.c) ? vars.a.b.c : null`'); }); it('a numeric segment indexes the list: `userList.0` becomes `userList[0]`', () => { @@ -147,8 +155,8 @@ describe('a head CEL claims is read through `vars`, the route a `$`-named head t it('a bare claimed variable keeps its guard, selected off `vars`; a keyword cannot be selected, so it gets none', () => { expect(refusalOf('{list}')).toContain('`has(vars.list) ? vars.list : null`'); - expect(refusalOf('{list.tags}')).toContain('`has(vars.list.tags) ? vars.list.tags : null`'); - expect(refusalOf('{for.tags}')).toContain('`has(vars.for.tags) ? vars.for.tags : null`'); + expect(refusalOf('{list.tags}')).toContain('`has(vars.list) && has(vars.list.tags) ? vars.list.tags : null`'); + expect(refusalOf('{for.tags}')).toContain('`has(vars.for) && has(vars.for.tags) ? vars.for.tags : null`'); expect(refusalOf('{null}')).not.toContain('the guarded form'); expect(refusalOf('{in.tags}')).not.toContain('the guarded form'); }); @@ -174,7 +182,9 @@ describe('a head CEL claims is read through `vars`, the route a `$`-named head t expect(CEL_CLAIMED_IDENTIFIERS.has(ordinary)).toBe(false); expect(celPath(`${ordinary}.0.key`)).toBe(`${ordinary}[0].key`); } - expect(refusalOf('{record.assignee}')).toContain('`has(record.assignee) ? record.assignee : null`'); + // The path is read bare; its guard, like every head's, tests each step off `vars`. + expect(refusalOf('{record.assignee}')).toContain("source: 'record.assignee' }"); + expect(refusalOf('{record.assignee}')).toContain('`has(vars.record) && has(vars.record.assignee) ? vars.record.assignee : null`'); expect(celPath('$error.message')).toBe('vars["$error"].message'); }); }); @@ -197,7 +207,7 @@ describe('a head the flow CEL scope claims is read through `vars`', () => { expect(celPath(`${name}.0`)).toBe(`vars["${name}"][0]`); expect(celPath(`${name}.tags`)).toBe(`vars["${name}"].tags`); expect(refusalOf(`{${name}.0}`)).toContain(`source: 'vars["${name}"][0]' }`); - expect(refusalOf(`{${name}.tags}`)).toContain(`\`has(vars.${name}.tags) ? vars.${name}.tags : null\``); + expect(refusalOf(`{${name}.tags}`)).toContain(`\`has(vars.${name}) && has(vars.${name}.tags) ? vars.${name}.tags : null\``); }); }); diff --git a/packages/spec/src/automation/flow-value-slot-template.ts b/packages/spec/src/automation/flow-value-slot-template.ts index fedf0df3926..e403d51fd5c 100644 --- a/packages/spec/src/automation/flow-value-slot-template.ts +++ b/packages/spec/src/automation/flow-value-slot-template.ts @@ -107,7 +107,6 @@ import { TEMPLATE_TOKEN, celDateMacro, celExpression, - celHeadReadsThroughVars, celPath, isRunUserIdToken, runUserPathNeverResolved, @@ -151,23 +150,33 @@ function spanOf(source: string): string { type Write = (source: string) => string; /** - * A `has()` guard for a path of keys — the absent-key remedy. `has()` takes a - * field selection over names alone: an index anywhere in its argument - * (`has(a[0].b)`, `has(vars["$x"].y)`) is refused when it runs, so a path with - * a numeric segment, a `$`-named head or a keyword segment gets no guard. A - * bare variable, and a path whose head CEL or the flow scope claims - * ({@link celHeadReadsThroughVars}), is selected off `vars` - * (`has(vars.list.tags)`, `has(vars.vars.tags)`). + * A `has()` guard for a path of keys — the absent-variable and absent-key + * remedy. `has()` takes a field selection over names alone: an index anywhere + * in its argument (`has(a[0].b)`, `has(vars["$x"].y)`) is refused when it + * runs, so a path with a numeric segment, a `$`-named head or a keyword + * segment gets no guard. + * + * The guard tests one step at a time, from `vars`, which holds only the + * variables the run has bound: `has(vars.source) && has(vars.source.id) ? + * vars.source.id : null`. `has()` evaluates everything but its last selection, + * so `has(source.id)` fails the run (`Unknown variable`) when `source` was + * never bound — an `isInput` variable the caller left out, with no default — + * and `has(vars.source.id)` fails it too (`No such key`). The chain answers + * `null` there, and where a key on the way is absent, `null`, or not a map + * (`&&` stops at the first `false`). A path whose head CEL or the flow scope + * claims (`celHeadReadsThroughVars` in the token module) is selected off + * `vars` like any other (`has(vars.list) && has(vars.list.tags)`). */ function guardOf(path: string): string | undefined { const segments = path.split('.'); if (segments[0]!.startsWith('$') || segments.some((s) => /^\d+$/.test(s) || CEL_KEYWORDS.has(s))) return undefined; - const read = segments.length === 1 || celHeadReadsThroughVars(segments[0]!) ? `vars.${path}` : path; - return `has(${read}) ? ${read} : null`; + const steps = segments.map((_, at) => `vars.${segments.slice(0, at + 1).join('.')}`); + return `${steps.map((step) => `has(${step})`).join(' && ')} ? ${steps[steps.length - 1]} : null`; } const ABSENT_SENTENCE = - 'CEL refuses an absent variable or key where the template wrote nothing, so guard one that may be absent with `has()`'; + 'CEL refuses an absent variable or key where the template wrote nothing, so guard one that may be absent with ' + + '`has()`, a step at a time from `vars`, which holds only the variables the run has bound'; /** * The remedy for `{$User.Id}` as a whole value: `current_user.id`, and for a diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts index fc35dbb35d6..651acbca5b2 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts @@ -56,8 +56,10 @@ export const entry: SemanticMigration = { acceptanceCriteria: 'Run objectstack validate: it reports each refused value as expression-invalid at the node and the value\'s ' + 'path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be ' - + 'absent, guard it (has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null for a variable) or ' - + 'route around the node. For the run user, find which flows can run without one (a schedule, a record change ' + + 'absent, guard it a step at a time from vars, which holds only the variables the run has bound ' + + '(has(vars.x) ? vars.x : null, has(vars.source) && has(vars.source.id) ? vars.source.id : null; a guard on ' + + 'the last key alone, has(source.id), fails the run when source itself was never bound) or route around the ' + + 'node. For the run user, find which flows can run without one (a schedule, a record change ' + 'a system write can make): there, guard current_user.id, or skip the node with a start condition or a ' + 'decision on current_user != null where an update_record must leave the stored value alone. For a date ' + 'macro with a variable offset, check the variable is always set to a number where the flow runs; write a ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index a5fb43fde9d..7b24d4eaa4d 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -14439,8 +14439,10 @@ const step18: MigrationStep = { acceptanceCriteria: 'Run objectstack validate: it reports each refused value as expression-invalid at the node and the value\'s ' + 'path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be ' - + 'absent, guard it (has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null for a variable) or ' - + 'route around the node. For the run user, find which flows can run without one (a schedule, a record change ' + + 'absent, guard it a step at a time from vars, which holds only the variables the run has bound ' + + '(has(vars.x) ? vars.x : null, has(vars.source) && has(vars.source.id) ? vars.source.id : null; a guard on ' + + 'the last key alone, has(source.id), fails the run when source itself was never bound) or route around the ' + + 'node. For the run user, find which flows can run without one (a schedule, a record change ' + 'a system write can make): there, guard current_user.id, or skip the node with a start condition or a ' + 'decision on current_user != null where an update_record must leave the stored value alone. For a date ' + 'macro with a variable offset, check the variable is always set to a number where the flow runs; write a '