diff --git a/.changeset/19939-flow-value-slot-template-dialect-refused.md b/.changeset/19939-flow-value-slot-template-dialect-refused.md index 55120dc3ac8..8dd87ab6084 100644 --- a/.changeset/19939-flow-value-slot-template-dialect-refused.md +++ b/.changeset/19939-flow-value-slot-template-dialect-refused.md @@ -30,7 +30,7 @@ A string whose tokens include one of these is not refused. Text slots (`notify` | 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`) | -| `'{list.0}'` | `{ dialect: 'cel', source: 'list[0]' }` | an empty list fails the run | +| `'{items.0}'` | `{ dialect: 'cel', source: 'items[0]' }` | an empty list fails the run | | `'{$error.message}'` | `{ dialect: 'cel', source: 'vars["$error"].message' }` | a `$`-named variable is read through `vars` | | `'{round(x * 100) / 100}'` | `{ dialect: 'cel', source: 'round(x * 100) / 100.0' }` | CEL divides two integers as integers: keep a decimal operand on every division, or `123.46` becomes `123` | | `'Renewal — {contract.number}'` | `{ dialect: 'cel', source: "'Renewal — ' + contract.number" }` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` | diff --git a/.changeset/22290-value-slot-remedy-cel-claimed-head.md b/.changeset/22290-value-slot-remedy-cel-claimed-head.md new file mode 100644 index 00000000000..cec5d1e93a8 --- /dev/null +++ b/.changeset/22290-value-slot-remedy-cel-claimed-head.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': patch +--- + +A value-slot `{…}` refusal now names a CEL spelling that evaluates when the path's head variable is named like a CEL type or keyword (`{list.0}` → `vars["list"][0]`) + +Clause-②: no + +The refusal for a `{…}` path token in a flow value slot (`valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals`, shown by `objectstack validate`, `registerFlow` and the executors) printed the path as bare CEL. For a head variable named `list`, that remedy was `list[0]`, and CEL read `list` as its own type, not the variable: `objectstack validate` refused the remedy it had just suggested (`Cannot index type 'type' with type 'int'`), and the bare `{list}` remedy `list` passed validation and evaluated to the type instead of the variable's value. A head that CEL claims for itself is now read through the flow scope's `vars` map, the route a `$`-named head already took: `{list.0}` → `vars["list"][0]`, `{list}` → `vars["list"]`. + +The claimed names are read off the CEL implementation the formula engine builds (cel-js 8.0.0): the type identifiers `bool`, `bytes`, `double`, `int`, `list`, `map`, `null_type`, `string`, `type` and `uint`; the namespaces `cel`, `google` and `optional`; the reserved words, such as `for`, `if` and `var`; and the keywords `true`, `false`, `null` and `in`. `timestamp`, `duration` and `dyn` are functions there, not bindings, so a variable with one of those names already read correctly and is unchanged. A later path segment that is a keyword is indexed by name (`{record.in}` → `record["in"]`). The `has()` guard the refusal suggests is now printed only where `has()` accepts it. CEL refuses `has()` over an index at run time (`has(rows[0].name)`, `has(vars["list"].tags)`), so a path with an index gets no guard, and a claimed head is guarded as `has(vars.list.tags) ? vars.list.tags : null`. + +Ordinary heads (`record.owner`, `items[0]`) print the same remedy as before. Which strings are refused and which are kept is unchanged; only the remedy text changes. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 88d73d013fe..f8e8ec9641c 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -266,7 +266,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`) | -| `'{list.0}'` | `source: 'list[0]'` | an empty list fails the run | +| `'{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 | | `'Follow up on {record.name}'` | `source: "'Follow up on ' + record.name"` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` | diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index b5a2c6cb727..de8ab91a27d 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -976,7 +976,7 @@ AUTHOR-REACHABLE SURFACES: a saved report's `query.filter` (`sys_saved_report`) - **`flow-trigger-record-credential-masked`** — `the record and previous roots a record-change flow receives — a password or secret field, and an internal field, of the triggering record, on every object` → read a credential through a privileged binder — the flow credential channel for an http node's signing secret, or a privileged server-side read such as the engine's resolveSecretField — never off `record` or `previous`; on those roots a set credential-class field now reads as the mask `SECRET_MASK`, an unset one as null, and an `internal: true` field is absent - Why not automatic: ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged. - Done when: No flow reads a password, secret or internal field off its trigger record or previous values expecting the stored value; a flow that needs a credential obtains it through a privileged binder; a start or edge condition that compared such a field against a literal is rewritten to test whether it is set (not null). -- **`flow-value-slot-template-dialect-refused`** — `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` → 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, list[0]; a variable whose name starts with $ is read through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal +- **`flow-value-slot-template-dialect-refused`** — `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` → 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 through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal - Why not automatic: 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 wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them 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) and the run-user paths beginning $User. (the flow CEL scope binds no user). 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. - Done when: 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. Re-run the flow paths that write those fields and compare the stored values with the ones the template wrote. - **`flow-write-node-stored-metadata-target-refused`** — `a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body` → Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), where it is validated and its provenance is recorded. Delete the node, or point its `objectName` at the object the flow really means to write. Elevation (`runAs`, a system context) does not change this. 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 20c2f179c75..f0fe28c8775 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 @@ -18,11 +18,18 @@ * - every REFUSED spelling resolves through the variables (a path), computes * (an expression), or resolves to nothing — and the judge refuses each, * including the dispatch-order edges (`{$User}` with no path, `{NOW}` with - * no call, a padded `{ amount }`). + * no call, a padded `{ amount }`); + * - for a refused PATH, the remedy the judge prints reads the same value + * through this package's CEL evaluator (`evaluateValueEnvelope`: the + * author-time envelope check, then the built `@objectstack/formula` engine + * over the flow's real CEL scope) as the interpolator read from the + * template — including a head variable named like an identifier CEL claims + * for itself (`{list.0}` → `vars["list"][0]`, #22290). */ import { describe, expect, it } from 'vitest'; import { valueSlotTemplateRefusals } from '@objectstack/spec/automation'; +import { AutomationEngine } from '../engine.js'; import { interpolateString } from './template.js'; const VARIABLES = new Map([ @@ -100,3 +107,73 @@ describe('a spelling the retirement REFUSES is one the interpolator reads as tem expect(valueSlotTemplateRefusals('a {} b')).toEqual([]); }); }); + +describe('the remedy a refused path prints reads, through CEL, the value the interpolator read', () => { + const quiet = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, child: () => quiet } as never; + const engine = new AutomationEngine(quiet); + + /** The CEL spellings the one refusal of `token` names: the envelope's source, and the `has()` guard when it prints one. */ + function printedSpellings(token: string): string[] { + const refusals = valueSlotTemplateRefusals(token); + expect(refusals, `exactly one refusal for ${token}`).toHaveLength(1); + const message = refusals[0]!.message; + const envelope = /Write `[^`]+` as \{ dialect: 'cel', source: (?:'([^']*)'|("(?:[^"\\]|\\.)*")) \}/.exec(message); + expect(envelope, `an envelope remedy in: ${message}`).not.toBeNull(); + const guard = /: `(has\([^`]*)` \(the guarded form writes `null`\)/.exec(message); + return [envelope![1] ?? (JSON.parse(envelope![2]!) as string), ...(guard ? [guard[1]!] : [])]; + } + + /** Both readings of `token` over `variables`, and every printed spelling evaluated to the interpolator's value. */ + function expectReadingsAgree(token: string, variables: Map, value: unknown): void { + expect(interpolateString(token, variables, CONTEXT)).toEqual(value); + for (const source of printedSpellings(token)) { + expect(engine.evaluateValueEnvelope({ dialect: 'cel', source }, variables, token), source).toEqual(value); + } + } + + // Every identifier CEL claims before a flow variable can (`flow-template-token.ts` + // in the spec reads them off cel-js 8.0.0): the type identifiers, the namespace + // constants, the reserved words and the keywords. `__proto__` is claimed there + // too, and is left out of this list: the flow CEL scope is a plain object, where + // `__proto__` names the prototype rather than a key, so no CEL spelling reads a + // variable of that name. + it.each([ + 'bool', 'bytes', 'double', 'int', 'list', 'map', 'null_type', 'string', 'type', 'uint', + 'cel', 'google', 'optional', + 'as', 'break', 'const', 'continue', 'else', 'for', 'function', 'if', 'import', 'let', 'loop', 'namespace', + 'package', 'return', 'var', 'void', 'while', 'prototype', + 'true', 'false', 'null', 'in', + ])('a head variable named `%s`', (name) => { + const value = ['first', { key: 'second' }]; + const variables = new Map([[name, value]]); + expectReadingsAgree(`{${name}}`, variables, value); + expectReadingsAgree(`{${name}.0}`, variables, 'first'); + expectReadingsAgree(`{${name}.1.key}`, variables, 'second'); + expectReadingsAgree(`{${name}.tags}`, new Map([[name, { tags: 'T' }]]), 'T'); + }); + + 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}')).toEqual(['vars["list"]', 'has(vars.list) ? vars.list : null']); + expect(printedSpellings('{null.tags}')).toEqual(['vars["null"].tags']); + }); + + 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'); + expectReadingsAgree('{record.tags.null}', variables, 'y'); + expectReadingsAgree('{rows.0.name}', variables, 'r0'); + }); + + it.each(['items', 'timestamp', 'duration', 'dyn'])('control: an ordinary head `%s` is read bare, unchanged', (name) => { + const variables = new Map([[name, ['first', { key: 'second' }]]]); + expect(printedSpellings(`{${name}.0}`)).toEqual([`${name}[0]`]); + expectReadingsAgree(`{${name}.0}`, variables, 'first'); + expectReadingsAgree(`{${name}.1.key}`, variables, 'second'); + }); + + it('control: a `$`-named head is still read through `vars`', () => { + expect(printedSpellings('{$error.message}')).toEqual(['vars["$error"].message']); + expectReadingsAgree('{$error.message}', VARIABLES, 'boom'); + }); +}); diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 92f6134c2f1..f8e9dff57d7 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -2215,7 +2215,7 @@ }, { "surface": "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", - "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, list[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", + "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 through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", "migrationId": "flow-value-slot-template-dialect-refused", "toMajor": 18, "rationale": "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 wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them 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) and the run-user paths beginning $User. (the flow CEL scope binds no user). 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." @@ -5804,7 +5804,7 @@ }, { "surface": "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", - "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, list[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", + "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 through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", "migrationId": "flow-value-slot-template-dialect-refused", "toMajor": 18, "rationale": "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 wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them 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) and the run-user paths beginning $User. (the flow CEL scope binds no user). 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." diff --git a/packages/spec/src/automation/flow-template-token.ts b/packages/spec/src/automation/flow-template-token.ts index 0741344afb3..9b18b790d96 100644 --- a/packages/spec/src/automation/flow-template-token.ts +++ b/packages/spec/src/automation/flow-template-token.ts @@ -79,11 +79,73 @@ export function templateTokensOf(value: string): TemplateToken[] { return out; } -/** A template path as CEL: `a.b.0` → `a.b[0]`; a `$`-named variable is read through `vars`. */ +/** + * The CEL keywords an identifier-shaped path segment can collide with: the + * lexer reads each as a literal (`true`, `false`, `null`) or an operator + * (`in`), never as a name — so neither `in[0]` nor `record.in` reaches the + * variable or the key. Part of {@link CEL_CLAIMED_IDENTIFIERS}. + */ +export const CEL_KEYWORDS: ReadonlySet = new Set(['true', 'false', 'null', 'in']); + +/** + * **Every identifier CEL claims before a flow variable can** — a variable of + * one of these names is unreachable as a bare identifier, so {@link celPath} + * reads a path whose HEAD is one through the flow scope's `vars` map, the route + * it already takes for a `$`-named head (`{list.0}` → `vars["list"][0]`). + * + * Read off the CEL implementation the formula engine builds, + * `@marcbachmann/cel-js` 8.0.0 (what `@objectstack/formula` resolves), not off + * the CEL language definition — a set from memory would be the wrong set: the + * definition's `timestamp`, `duration` and `dyn` are functions here, not + * bindings, and a variable of those names reads fine. Four groups: + * + * - **the type identifiers** — `lib/registry.js`'s `TYPES`, which the + * registry binds as constants of type `type` when an environment is built + * (`for (const n in TYPES) this.registerConstant(n, 'type', TYPES[n])`): a + * variable named `list` evaluates to the CEL type `list`, and `list[0]` + * fails `Cannot index type 'type' with type 'int'`; + * - **the namespace constants** — `google` (`lib/functions.js`, the + * `google.protobuf.*` type names), `cel` (`lib/macros.js`, `cel.bind`) and + * `optional` (`lib/optional.js`, bound because the engine builds with + * `enableOptionalTypes: true`); + * - **the reserved words** — `lib/globals.js`'s `RESERVED`, which the parser + * refuses as an identifier (`Reserved identifier: for`); + * - **the keywords** — {@link CEL_KEYWORDS}. + * + * The first two groups are exactly what the built environment's + * `getDefinitions().variables` lists. Pinned name by name, through the built + * engine, by `value-slot-template-grammar.test.ts` in `service-automation`. + */ +export const CEL_CLAIMED_IDENTIFIERS: ReadonlySet = new Set([ + 'bool', 'bytes', 'double', 'int', 'list', 'map', 'null_type', 'string', 'type', 'uint', + 'cel', 'google', 'optional', + 'as', 'break', 'const', 'continue', 'else', 'for', 'function', 'if', 'import', 'let', 'loop', 'namespace', + 'package', 'return', 'var', 'void', 'while', '__proto__', 'prototype', + ...CEL_KEYWORDS, +]); + +/** + * Whether a path's head is read through `vars` — a `$`-named variable (CEL has + * no identifier spelling for one) or one of {@link CEL_CLAIMED_IDENTIFIERS}. + */ +export function celHeadReadsThroughVars(head: string): boolean { + return head.startsWith('$') || CEL_CLAIMED_IDENTIFIERS.has(head); +} + +/** + * A template path as CEL: `a.b.0` → `a.b[0]`. A head CEL cannot read as the + * variable is read through `vars` ({@link celHeadReadsThroughVars}: + * `vars["$error"].message`, `vars["list"][0]`), and a later segment that is a + * keyword is indexed by name (`record["in"]`). + */ export function celPath(path: string): string { const [head, ...rest] = path.split('.'); - let out = head!.startsWith('$') ? `vars["${head}"]` : head!; - for (const segment of rest) out += /^\d+$/.test(segment) ? `[${segment}]` : `.${segment}`; + let out = celHeadReadsThroughVars(head!) ? `vars["${head}"]` : head!; + for (const segment of rest) { + if (/^\d+$/.test(segment)) out += `[${segment}]`; + else if (CEL_KEYWORDS.has(segment)) out += `["${segment}"]`; + else out += `.${segment}`; + } return out; } 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 4689c06a384..001d6f67e44 100644 --- a/packages/spec/src/automation/flow-value-slot-template.test.ts +++ b/packages/spec/src/automation/flow-value-slot-template.test.ts @@ -8,8 +8,9 @@ * * 1. **Refused, each with its remedy.** Every token class the interpolator * resolves and CEL can spell is refused, led by the rule sentence and - * naming the CEL spelling: a path (`a.b`, `list[0]`, `vars["$error"]`) - * with its `has()` guard, arithmetic with every divisor a double + * naming the CEL spelling: a path (`a.b`, `items[0]`, `vars["$error"]`, + * `vars["list"][0]` for a head CEL claims) with its `has()` guard where + * `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 and `$User` paths, which CEL cannot spell yet, @@ -32,6 +33,7 @@ import { FlowValueSlotSchema, UpdateRecordConfigSchema, } from './builtin-node-config.zod'; +import { CEL_CLAIMED_IDENTIFIERS, CEL_KEYWORDS, celPath } from './flow-template-token'; import { VALUE_SLOT_TEMPLATE_REFUSAL, flowNodeValueTemplateRefusals, @@ -60,7 +62,7 @@ describe('a value-slot string in the retired `{…}` dialect is refused, with th expect(message).toContain('`has(record.assignee) ? record.assignee : null`'); }); - it('a numeric segment indexes the list: `list.0` becomes `list[0]`', () => { + it('a numeric segment indexes the list: `userList.0` becomes `userList[0]`', () => { expect(refusalOf('{userList.0}')).toContain("{ dialect: 'cel', source: 'userList[0]' }"); }); @@ -100,6 +102,66 @@ describe('a value-slot string in the retired `{…}` dialect is refused, with th }); }); +describe('a head CEL claims is read through `vars`, the route a `$`-named head takes', () => { + // The set, as `flow-template-token.ts` reads it off cel-js 8.0.0 — restated + // here so the pin fails when a name is added or dropped there unreviewed. + // That each printed spelling EVALUATES, with a variable of the name in scope, + // is pinned through the built engine in `service-automation`'s + // `value-slot-template-grammar.test.ts` (the spec cannot import the engine). + const CLAIMED = [ + 'bool', 'bytes', 'double', 'int', 'list', 'map', 'null_type', 'string', 'type', 'uint', + 'cel', 'google', 'optional', + 'as', 'break', 'const', 'continue', 'else', 'for', 'function', 'if', 'import', 'let', 'loop', 'namespace', + 'package', 'return', 'var', 'void', 'while', '__proto__', 'prototype', + 'true', 'false', 'null', 'in', + ]; + + it('the claimed set is exactly the enumerated one', () => { + expect([...CEL_CLAIMED_IDENTIFIERS].sort()).toEqual([...CLAIMED].sort()); + expect([...CEL_KEYWORDS].sort()).toEqual(['false', 'in', 'null', 'true']); + }); + + it.each(CLAIMED)('%s', (name) => { + expect(celPath(name)).toBe(`vars["${name}"]`); + expect(celPath(`${name}.0`)).toBe(`vars["${name}"][0]`); + expect(celPath(`${name}.1.key`)).toBe(`vars["${name}"][1].key`); + expect(refusalOf(`{${name}.0}`)).toContain(`source: 'vars["${name}"][0]' }`); + }); + + 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('{null}')).not.toContain('the guarded form'); + expect(refusalOf('{in.tags}')).not.toContain('the guarded form'); + }); + + it('a later keyword segment is indexed by name; every other later segment stays a field', () => { + expect(celPath('record.in')).toBe('record["in"]'); + expect(celPath('record.null.0')).toBe('record["null"][0]'); + expect(celPath('record.list.for')).toBe('record.list.for'); + expect(refusalOf('{record.true}')).not.toContain('the guarded form'); + }); + + it('an index anywhere in the path leaves it unguarded — `has()` refuses one at run time', () => { + expect(refusalOf('{rows.0.name}')).toContain("source: 'rows[0].name' }"); + expect(refusalOf('{rows.0.name}')).not.toContain('the guarded form'); + }); + + it('text with holes reads a claimed head the same way', () => { + expect(refusalOf('Hi {list.0}')).toContain(`source: "'Hi ' + vars[\\"list\\"][0]" }`); + }); + + it('controls: an ordinary head is unchanged, and so is a `$`-named head', () => { + for (const ordinary of ['items', 'record', 'timestamp', 'duration', 'dyn', 'lists', 'map_of', 'vars']) { + 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`'); + expect(celPath('$error.message')).toBe('vars["$error"].message'); + }); +}); + describe('the two spellings CEL cannot write yet are KEPT — not refused', () => { it.each([ '{NOW()}', diff --git a/packages/spec/src/automation/flow-value-slot-template.ts b/packages/spec/src/automation/flow-value-slot-template.ts index 46a4666aea2..800b2296c27 100644 --- a/packages/spec/src/automation/flow-value-slot-template.ts +++ b/packages/spec/src/automation/flow-value-slot-template.ts @@ -70,6 +70,8 @@ import { isExpressionEnvelopeShaped, resolveFlowNodeValueSlots } from './flow-node-expression-paths'; import { + CEL_CLAIMED_IDENTIFIERS, + CEL_KEYWORDS, TEMPLATE_TOKEN, celExpression, celPath, @@ -104,12 +106,18 @@ function envelopeOf(source: string): string { return `{ dialect: 'cel', source: ${quoted} }`; } -/** A `has()` guard for a path whose last segment is a key — the absent-key remedy. */ +/** + * 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 claims ({@link CEL_CLAIMED_IDENTIFIERS}), + * is selected off `vars` (`has(vars.list.tags)`). + */ function guardOf(path: string): string | undefined { const segments = path.split('.'); - const last = segments[segments.length - 1]!; - if (segments[0]!.startsWith('$') || /^\d+$/.test(last)) return undefined; - const read = segments.length === 1 ? `vars.${path}` : celPath(path); + if (segments[0]!.startsWith('$') || segments.some((s) => /^\d+$/.test(s) || CEL_KEYWORDS.has(s))) return undefined; + const read = segments.length === 1 || CEL_CLAIMED_IDENTIFIERS.has(segments[0]!) ? `vars.${path}` : path; return `has(${read}) ? ${read} : null`; } 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 2e98a1e6c90..ff6639f7171 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 @@ -16,7 +16,7 @@ export const entry: SemanticMigration = { + 'string anywhere inside an array or object value, carrying a single-brace template token', 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, list[0]; a variable whose name starts with $ is read ' + + '(record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read ' + 'through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written ' + 'as a double (round(x * 100) / 100.0), and text with holes is one concatenation (\'Hello \' + o.name). A ' + 'string with no token is the literal text it spells, and braces meant literally are a CEL string literal', diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index ed7eda14f48..7c50157324f 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -14280,7 +14280,7 @@ const step18: MigrationStep = { + 'string anywhere inside an array or object value, carrying a single-brace template token', 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, list[0]; a variable whose name starts with $ is read ' + + '(record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read ' + 'through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written ' + 'as a double (round(x * 100) / 100.0), and text with holes is one concatenation (\'Hello \' + o.name). A ' + 'string with no token is the literal text it spells, and braces meant literally are a CEL string literal',