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..fcf60bc4f1d --- /dev/null +++ b/.changeset/19939-flow-value-slot-date-macros-refused.md @@ -0,0 +1,54 @@ +--- +'@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. + +**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 | +|:--|:--|:--| +| `'{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 | +| 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.** + +**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, 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 cb89f1210db..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 | @@ -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 }, }, @@ -1994,15 +2013,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 | @@ -2010,9 +2028,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`, @@ -2029,8 +2047,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/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx index e38d33d64b7..2aab8ef2a5b 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/lint/src/lint-flow-patterns.test.ts b/packages/lint/src/lint-flow-patterns.test.ts index 9352e9a1e2b..4ccae32627d 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,119 @@ 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); + } + }); + + // #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}`'); 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([]); 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..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 @@ -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', @@ -212,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'"], ]; @@ -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,153 @@ 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) => { + // 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: remedy }, instant), `${authored} → ${remedy} @ ${instant}`).toBe(template); + } + expect(remedy).toBe(source); + }); + + // 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..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 @@ -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 @@ -30,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([ @@ -47,18 +55,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', () => { @@ -190,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/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 0b64c985a8d..c1768cb1397 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -402,8 +402,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 @@ -415,7 +415,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; @@ -1089,7 +1090,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..9ef8c1c0808 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` @@ -65,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]`', () => { @@ -139,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'); }); @@ -166,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'); }); }); @@ -189,24 +207,146 @@ 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\``); }); }); -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 +457,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 +475,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 +501,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 +515,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..e403d51fd5c 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,15 +105,14 @@ import { CEL_RUN_USER_ID, CEL_RUN_USER_ID_GUARDED, TEMPLATE_TOKEN, + celDateMacro, celExpression, - celHeadReadsThroughVars, celPath, isRunUserIdToken, runUserPathNeverResolved, templateTokenKind as tokenKind, templateTokensOf as tokensOf, type TemplateToken as Token, - type TemplateTokenKind as TokenKind, } from './flow-template-token'; /** @@ -110,9 +126,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,106 +137,223 @@ 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}\``; +} + /** - * 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)`). + * 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-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 * 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 +374,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 +389,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 +455,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..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 @@ -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,23 @@ 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 ' + + '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. 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 4d51085210b..2fe773b0ed3 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5790,8 +5790,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', @@ -14301,9 +14303,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 ' @@ -14400,9 +14402,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 @@ -14411,7 +14414,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 ' @@ -14422,8 +14425,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 ' @@ -14434,18 +14444,25 @@ 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 ' + + '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. 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