diff --git a/.changeset/19939-flow-value-slot-run-user-refused.md b/.changeset/19939-flow-value-slot-run-user-refused.md new file mode 100644 index 00000000000..036a86acbcf --- /dev/null +++ b/.changeset/19939-flow-value-slot-run-user-refused.md @@ -0,0 +1,45 @@ +--- +'@objectstack/spec': major +'@objectstack/service-automation': major +'@objectstack/cli': patch +'@objectstack/lint': patch +--- + +A flow VALUE slot now refuses the run-user template token `{$User.}` as well, and every CEL expression in a flow sees **`current_user`**, the run's user. `{$User.Id}` in a `create_record` / `update_record` `fields` value or an `assignment` value is refused at `objectstack validate`, at `registerFlow` and by the executor, naming `current_user.id` and, for a flow that can run without a user, its guard; every other `$User` path is refused saying it never resolved. + +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 run user was the one path spelling kept, because the flow CEL scope bound no user: `current_user.id` passed `objectstack validate` and `registerFlow`, then failed the run with `Unknown variable: current_user`. The scope now binds it, so the token can be refused with a remedy that evaluates. + +**What `current_user` is.** In every flow CEL expression — a start-node `condition`, an edge `condition`, a `decision` condition, a screen field's `visibleWhen`, an `assignment` or `fields` value envelope — `current_user` is the run's user, built through `createEvalUser` from what the run context holds: `id` (the run's `userId`), `positions`, `organizationId` (the run's `tenantId`) and the derived `isPlatformAdmin`. No run carries an email or a name, so neither is bound. A run with no user — a schedule, a record change made by a system write — sees `null`, never a stand-in user: `current_user.id` then fails the run loudly, and `current_user != null` can guard it. A `runAs: 'system'` run sees the user that triggered it, as `{$User.Id}` did. A flow variable named `current_user` is shadowed by the binding, as one named `vars` is by the variables namespace; both are read as `vars["current_user"]` / `vars["vars"]`, and the refusal of a `{vars.…}` or `{current_user.…}` path token prints that spelling. + +**The expression remedy.** A refused `{…}` arithmetic token's CEL spelling now reads every variable path in it the way a lone path token's does — `{int * 2}` is `vars["int"] * 2`, `{items.0 * 2}` is `items[0] * 2` — instead of rewriting only its divisors, which printed envelopes that did not evaluate. + +**Still accepted, unchanged.** A CEL value envelope, every literal, and the date macros `{NOW()}` / `{TODAY() ± N}` (CEL has no string form for a Timestamp yet). A string that mixes a date macro with any other token, a `$User` path included, is still kept whole. `{$User.*}` keeps resolving where the single-brace dialect still lives — a `filter` value, a `notify` `recipients` entry. The value-slot retirement's entry earlier on this line lists `{$User.}` among the spellings still accepted, and the text-slot entries compute the run user through it; this entry supersedes those lines. + +## FROM → TO + +| you wrote | write instead | what changes | +|:--|:--|:--| +| `'{$User.Id}'` | `{ dialect: 'cel', source: 'current_user.id' }` | nothing when the run has a user | +| `'{$User.Id}'`, in a flow that can run without a user | `{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }` — or skip the node with `current_user != null` as a start condition or a `decision` | the guarded form writes `null` where the template wrote nothing, so on `update_record` it clears a stored value the template left alone | +| `'{$User.Email}'`, `'{$User.Name}'`, any other `$User` path | an `assignment` of `uid: { dialect: 'cel', source: 'current_user.id' }`, a `get_record` on `sys_user` with `filter: { id: '{uid}' }` and `outputVariable: 'me'`, then `{ dialect: 'cel', source: 'me.email' }` | these never resolved in any shipped run: they wrote nothing | +| `'Owner: {$User.Id}'` | `{ dialect: 'cel', source: "'Owner: ' + current_user.id" }` | in a user-less run, write the hole as `(current_user != null ? current_user.id : '')` | +| a text slot's `assignments: { by: '{$User.Id}' }`, then `'By {{ by }}'` | `assignments: { by: { dialect: 'cel', source: 'current_user.id' } }`, then `'By {{ by }}'` | the text-slot remedy published earlier on this line computed the run user through the value-slot spelling this change refuses | + +**The one-line fix: write `current_user.id` where you wrote `{$User.Id}`, and guard it where the flow can run without a user.** + +**Who is affected, measured.** This repository's two authored value-slot sites are migrated in this change: the `examples/app-todo` quick-add screen flow (a screen flow always has a user, so the bare read) and the `os explain flow` catalog example, an `update_record` under a record-after-create trigger, which a system write fires with no user; it now gates on `current_user != null` in its start condition, so the stored value is left alone there as before. `examples/app-showcase`'s `{$User.Id}` is a `notify` `recipients` entry, which is not a value slot, and is unchanged. Other repositories and deployed metadata were not measured here. + +### The kit + +- **The binding.** `AutomationEngine.celScope` (`@objectstack/service-automation`); `evaluateCondition` and `evaluateValueEnvelope` take the run context as an optional last argument, which every engine site and the `assignment`, `decision`, `create_record` and `update_record` executors pass. Without one, `current_user` is `null`. +- **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`); the text-slot judge's run-user remedy (`textSlotTemplateRefusal`) now computes the id with the CEL envelope. +- **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the run-user paths), `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused` (their run-user remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. +- **`@objectstack/cli`.** `os explain flow`'s example writes `current_user.id` and gates on `current_user != null`. +- **`@objectstack/lint`.** `flow-bare-dollar-reference`'s hint for a bare `$name.path` in a value slot names the CEL envelope the value-slot refusal writes for it — `current_user.id` and its guard for `$User.Id`, the variable the reference names for any other — where it named `{source.id}` and `{$User.Id}`, both refused there. A text slot's hint keeps the `{{ }}` hole, and every other position keeps the single brace. +- **`@objectstack/service-automation`'s README.** Its *Expressions* section states the value-slot envelope, `current_user`, and the date macros still read until CEL can write them. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index f8e8ec9641c..e03bf2500a7 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -271,11 +271,13 @@ for some input, so the rewrite is yours to judge. | `'{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(…, '')` | | 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` | -Two spellings **keep** their meaning for now, because CEL cannot write them yet: +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) and the run user (`'{$User.Id}'` — the flow's CEL scope binds no user). +one). [#19939]: https://github.com/objectstack-ai/objectstack/issues/19939 @@ -325,7 +327,8 @@ 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}'`, `'By {$User.Id}'` | 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 it into a variable with an `assignment` node, whose value slot still reads that spelling (`assignments: { due: '{TODAY() + 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 @@ -1990,21 +1993,30 @@ slot still reads. | Where | Dialect | Write it like | Bindings | |:---|:---|:---|:---| -| Start-node `condition` | **CEL** (bare, no braces) | `record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*` | +| 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, and `vars.*` | -| 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), except the date macros `{NOW()}` / `{TODAY() ± N}` and `{$User.*}`, which still resolve until CEL can write them) | -| 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, and `vars.*` — the whole CEL stdlib (`joinNonEmpty`, …) | +| 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 **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 | 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 and -`{$User.*}` are kept until CEL can write them: CEL's `now()` / `today()` are -timestamps, not the strings the macros write, and the flow's CEL scope binds no -user. +`{…}` 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. + +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`, +`current_user.organizationId`, `current_user.isPlatformAdmin`. It carries only +what the run holds, so there is no email or name on it (read the user record by +`current_user.id`). In a run with **no user** — a schedule, a record change made +by a system write — `current_user` is `null`, never a stand-in user: guard a read +with `current_user != null`, or skip the work with that start condition. A flow +variable named `current_user` or `vars` is shadowed by the scope's own binding +and is read as `vars["current_user"]` / `vars["vars"]`. **The failure modes to memorize:** diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx index 016c11e5687..b3d6846b05e 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 and `$User` paths 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; the date macros are kept for 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 and `$User` paths 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; the date macros are kept for now) --- diff --git a/examples/app-todo/src/flows/task.flow.ts b/examples/app-todo/src/flows/task.flow.ts index c6d25924506..2bbea46c51b 100644 --- a/examples/app-todo/src/flows/task.flow.ts +++ b/examples/app-todo/src/flows/task.flow.ts @@ -472,16 +472,17 @@ export const QuickAddTaskFlow: Flow = { // CEL value envelopes — the `{…}` template dialect is retired from // value slots. `subject` is a required screen field; the others may be // left empty, and CEL refuses an absent variable where the template - // wrote nothing, so they are guarded with `has()`. `{$User.Id}` is one - // of the two spellings the retirement keeps until CEL can write it (the - // flow CEL scope binds no user yet), so it stays as authored. + // wrote nothing, so they are guarded with `has()`. The owner is the + // run's user, `current_user` — a screen flow always has one, so the + // id is read bare (a flow that can run without a user guards it: + // `current_user != null ? current_user.id : null`). fields: { subject: { dialect: 'cel', source: 'subject' }, priority: { dialect: 'cel', source: 'has(vars.priority) ? vars.priority : null' }, due_date: { dialect: 'cel', source: 'has(vars.dueDate) ? vars.dueDate : null' }, category: { dialect: 'cel', source: 'has(vars.category) ? vars.category : null' }, status: 'not_started', - owner: '{$User.Id}', + owner: { dialect: 'cel', source: 'current_user.id' }, }, outputVariable: 'newTaskId', }, diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index 09b7564b4b6..eb0cfc8d0b0 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -155,15 +155,19 @@ export const SCHEMAS: Record = { nodes: [ // A record-change flow binds its object on the START node's config, // not at the flow top level. + // current_user is the acting user, or null when no user made the write + // (a system write): the start condition skips those runs, so the stored + // value is left alone there. { id: 'start', type: 'start', label: 'On Task Create', - config: { objectName: 'project_task', triggerType: 'record-after-create' } }, - // Values interpolate with SINGLE braces. {$User.Id} is the acting user; - // {record.} reads the triggering record. + config: { objectName: 'project_task', triggerType: 'record-after-create', + condition: 'current_user != null' } }, + // A filter value interpolates SINGLE braces: {record.} reads the + // triggering record. A field value is a CEL value envelope. { id: 'assign', type: 'update_record', label: 'Assign to Actor', config: { objectName: 'project_task', filter: { id: '{record.id}' }, - fields: { assigned_to: '{$User.Id}' }, + fields: { assigned_to: { dialect: 'cel', source: 'current_user.id' } }, } }, { id: 'done', type: 'end', label: 'Done' }, ], diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index f2b5cd09953..0ed9c8a9a3e 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -119,12 +119,12 @@ describe('os explain — schema catalog accuracy', () => { // required `id`/`label` were absent; // • `edges` is required — a graph with no edges was not expressible; // • the value `'$currentUser'` was a `$`-prefixed sentinel NO resolver in - // the repo recognises. The flow value dialect is brace-based, and the - // acting user is `{$User.Id}` (template.ts `resolveToken`, whose - // `$User.Id` branch returns `context.userId`). The neighbouring FILTER - // dialect's `{current_user_id}` is a different door and does NOT carry - // over: assignment/`fields` values go through plain `interpolate`, not - // `interpolateFilter`. + // the repo recognises. A `fields` value is a CEL value envelope, and the + // acting user there is `current_user.id` — the flow CEL scope binds + // `current_user` to the run's user, or `null` when the run has none, and + // the `{$User.Id}` template token is refused in a value slot (#19939). + // The neighbouring FILTER dialect's `{current_user_id}` is a different + // door and does NOT carry over. // // Parsing the sample against the real schema is the guard that cannot itself // drift — it re-derives the truth from the spec on every run, which is what @@ -161,8 +161,12 @@ describe('os explain — schema catalog accuracy', () => { ); }); - it('teaches the acting user as {$User.Id}, and no catalog example revives $currentUser (#14782)', () => { - expect(SCHEMAS.flow.example).toContain('{$User.Id}'); + it('teaches the acting user as current_user.id, never {$User.Id}, and no catalog example revives $currentUser (#14782, #19939)', () => { + expect(SCHEMAS.flow.example).toContain("fields: { assigned_to: { dialect: 'cel', source: 'current_user.id' } }"); + // The trigger runs without a user on a system write, so the example gates + // on one rather than clearing `assigned_to` there. + expect(SCHEMAS.flow.example).toContain("condition: 'current_user != null'"); + expect(SCHEMAS.flow.example).not.toContain('{$User.'); const entries = Object.entries(SCHEMAS) as Array<[string, { example: string }]>; for (const [key, info] of entries) { expect(info.example, `os explain ${key} example`).not.toContain('$currentUser'); diff --git a/packages/lint/src/lint-flow-patterns.test.ts b/packages/lint/src/lint-flow-patterns.test.ts index 43678697f56..9352e9a1e2b 100644 --- a/packages/lint/src/lint-flow-patterns.test.ts +++ b/packages/lint/src/lint-flow-patterns.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect } from 'vitest'; import { TimeRelativeTriggerSchema, LoopConfigSchema, ParallelConfigSchema, TryCatchConfigSchema, HttpConfigSchema, FlowSchema, NotifyConfigSchema, textSlotTemplateRefusal } from '@objectstack/spec/automation'; +// [#19939] The value-slot judge, asked by the `flow-bare-dollar-reference` pins +// below for the spelling its hint must carry and for the verdict on it. +import { flowNodeValueTemplateRefusals, VALUE_SLOT_TEMPLATE_REFUSAL, valueSlotTemplateRefusals } from '@objectstack/spec/automation'; import { TYPED_EXPRESSION_DIALECT_ONLY, TYPED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec/shared'; // [#5659] The shared identity reduction, asserted beside the rule that consumes // it — the rule's verdict and the drivers' verdict are one object now. @@ -2543,7 +2546,7 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule const refusal = textSlotTemplateRefusal('{{ $User.Id }}'); expect(refusal).toBeDefined(); expect(fnds[0].hint).toContain(refusal!); - expect(fnds[0].hint).toContain("assignments: { v: '{$User.Id}' }"); + expect(fnds[0].hint).toContain("assignments: { v: { dialect: 'cel', source: 'current_user.id' } }"); expect(fnds[0].hint).not.toContain('{{ $User.Id }}'); }); @@ -2561,6 +2564,141 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule expect(fnds[0].hint).toContain(textSlotTemplateRefusal('{{ $User.Id }}')!); expect(fnds[0].hint).not.toContain('{{ $User.Id }}'); }); + // [#19939] What the hint prescribes in a text slot, put back in the + // slot, is refused by no judge the slot has: the hole for an + // engine-bound reference, and for the run user the judge's own remedy — + // its id computed by an `assignment` node's CEL envelope, written as a hole. + it('each spelling it prescribes passes the text-slot judge and the build door with 0 refusals', () => { + const [errorRef] = notifyText('Failed: $error.message'); + const hole = /`(\{\{ [^`]* \}\})`/.exec(errorRef!.hint!)?.[1]; + expect(hole).toBe('{{ $error.message }}'); + const [userRef] = notifyText('Closed by $User.Id'); + const computed = /assignments: \{ v: \{ dialect: 'cel', source: '([^']*)' \} \}/.exec(userRef!.hint!)?.[1]; + expect(computed).toBe('current_user.id'); + const title = `Failed: ${hole}, closed by {{ v }}`; + expect(textSlotTemplateRefusal(title)).toBeUndefined(); + const stack = { + flows: [{ + name: 'fault_notice', label: 'Fault notice', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'who', type: 'assignment', label: 'Who', config: { assignments: { v: CEL(computed!) } } }, + { id: 'tell', type: 'notify', label: 'Tell', config: { recipients: ['u1'], title } }, + { id: 'done', type: 'end', label: 'Done' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'who' }, + { id: 'e2', source: 'who', target: 'tell' }, + { id: 'e3', source: 'tell', target: 'done' }, + ], + }], + }; + expect(validateStackExpressions(stack).filter((i) => i.severity === 'error')).toEqual([]); + expect(lintFlowPatterns(stack).filter((f) => f.rule === FLOW_BARE_DOLLAR_REF || f.rule === FLOW_DOUBLE_BRACE_INTERP)).toEqual([]); + }); + }); + + // [#19939] A value slot (`create_record` / `update_record` `fields.*`, + // `assignment` values) reads the CEL value envelope, and refuses every + // `{…}` token but the date macros — `{$User.*}` since the second pass. So + // the hint there names the envelope the spec's value-slot judge writes for + // the token the reference names, asked of the judge and never re-spelled, + // and each envelope it prescribes, put back in the slot, passes every + // judge the slot has: the value-slot judge, the build door, the flow + // contract and this rule. + describe('in a value slot', () => { + /** A start → write → end flow, the whole shape the build door judges. */ + function valueFlow(type: string, config: Record) { + return { + flows: [{ + name: 'stamp_owner', label: 'Stamp owner', 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 bareDollar = (stack: ReturnType) => + lintFlowPatterns(stack).filter((f) => f.rule === FLOW_BARE_DOLLAR_REF); + /** The value-slot judge's own CEL spelling of a `{…}` token — what the hint carries word for word. */ + const judgeSpelling = (token: string) => + valueSlotTemplateRefusals(token)[0]!.message.slice(VALUE_SLOT_TEMPLATE_REFUSAL.length).trim(); + /** Every concrete envelope source a hint prescribes; the `'…'` of its lead sentence is a placeholder. */ + 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; + } + /** The refusals of the slot's judges on `stack`'s write node. */ + 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('names the CEL envelope, not a `{…}` token the slot refuses (`fields: { owner: \'$User.Id\', who: \'$source.id\' }`)', () => { + const fnds = bareDollar(valueFlow('create_record', { objectName: 'task', fields: { owner: '$User.Id', who: '$source.id' } })); + expect(fnds).toHaveLength(2); + for (const f of fnds) { + expect(f.message).toContain('in the create_record field value'); + expect(f.hint).toContain('A value slot reads a CEL value envelope'); + expect(f.hint).not.toContain('Wrap it and bind a variable'); + } + const [owner, who] = fnds; + // The run user: the judge's remedy for `{$User.Id}` — `current_user.id`, and the guard. + expect(owner!.hint).toContain(judgeSpelling('{$User.Id}')); + expect(prescribedSources(owner!.hint!)).toEqual(['current_user.id', 'current_user != null ? current_user.id : null']); + // `$source` is no `$` variable the engine binds: the flow's own `source`, written without the `$`. + expect(who!.hint).toContain(judgeSpelling('{source.id}')); + expect(prescribedSources(who!.hint!)).toEqual(['source.id']); + }); + + it('reads a `$` root the engine binds as that variable (`$error.message`)', () => { + const [f] = bareDollar(valueFlow('update_record', { objectName: 'task', filter: { id: '{record.id}' }, fields: { note: '$error.message' } })); + expect(f!.message).toContain('in the update_record field value'); + expect(f!.hint).toContain(judgeSpelling('{$error.message}')); + expect(prescribedSources(f!.hint!)).toEqual(['vars["$error"].message']); + }); + + it.each([ + ['$User.Id', 'create_record', (v: unknown) => ({ objectName: 'task', fields: { v } })], + ['$source.id', 'create_record', (v: unknown) => ({ objectName: 'task', fields: { v } })], + ['$User.Email', 'create_record', (v: unknown) => ({ objectName: 'task', fields: { v } })], + ['$error.message', 'update_record', (v: unknown) => ({ objectName: 'task', filter: { id: '{record.id}' }, fields: { v } })], + ['$User.Id', 'assignment', (v: unknown) => ({ assignments: { v } })], + ] as const)('`%s` in the %s value slot: each envelope it prescribes passes every judge with 0 refusals', (ref, type, configOf) => { + const fnds = bareDollar(valueFlow(type, configOf(ref))); + expect(fnds).toHaveLength(1); + const prescribed = prescribedSources(fnds[0]!.hint!); + expect(prescribed.length, fnds[0]!.hint).toBeGreaterThan(0); + for (const source of prescribed) { + expect(refusalsOf(valueFlow(type, configOf(CEL(source)))), `${ref} → ${source}`).toEqual(NONE); + } + }); + + it('control: both spellings the hint used to prescribe are refused in the same slot', () => { + for (const old of ['{source.id}', '{$User.Id}']) { + expect(refusalsOf(valueFlow('create_record', { objectName: 'task', fields: { v: old } })).valueJudge, old).toBe(1); + } + }); + + 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([]); + }); }); }); diff --git a/packages/lint/src/lint-flow-patterns.ts b/packages/lint/src/lint-flow-patterns.ts index 5e57ef82a92..ee30b6ea9c2 100644 --- a/packages/lint/src/lint-flow-patterns.ts +++ b/packages/lint/src/lint-flow-patterns.ts @@ -159,7 +159,10 @@ import { collectFlowGraphs, FLOW_NODE_TEXT_SLOTS, flowNodeTextSlotSources, + 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 @@ -169,6 +172,9 @@ import { DecisionConfigSchema } from '@objectstack/spec/automation'; // driver-sql, driver-mongodb and driver-memory execute. This linter asks it // rather than hand-writing a fourth copy; see {@link filterCarriesNoCondition}. import { reduceFilterVerdict } from '@objectstack/spec/data'; +// [#19939] The lint's pinned mirror of the 17.x interpolator's token dispatch — +// here only to tell a bare run-user reference (`$User.Id`) from a variable. +import { classifyFlowTemplateToken } from './flow-template-grammar.js'; import { stripRegions, ownRegionKeys, REGION_SLOTS, MAX_REGION_DEPTH } from './flow-walk.js'; import { recordsOf } from './object-graph.js'; @@ -651,12 +657,21 @@ function scanFilterForDateEquality( } } -// Flow node VALUES interpolate with SINGLE braces (`{var}` / `{rec.field}` / -// `{$User.Id}`) — every config string EXCEPT the text slots (#22110, ADR-0032 -// D3: a notify `title` / `message`, a screen `title` / `description`, an `end` -// `message` — `FLOW_NODE_TEXT_SLOTS`), which render the `{{ }}` holes of the -// formula template dialect. Two wrong-syntax mistakes AI/human authors carry -// over between the two, or from other platforms: +// Three kinds of flow node config string, each with its own reading: +// - the VALUE slots (`create_record` / `update_record` `fields.*`, +// `assignment` values — #19939): a CEL value envelope computes the value, +// and a string is the literal text it spells; a `{…}` token there is +// refused at `error` by the build door (`validate-expressions`, the spec's +// one judge), except the date macros, which CEL cannot spell yet; +// - the TEXT slots (#22110, ADR-0032 D3: a notify `title` / `message`, a +// screen `title` / `description`, an `end` `message` — +// `FLOW_NODE_TEXT_SLOTS`): they render the `{{ }}` holes of the formula +// template dialect; +// - every other config string (a `filter`, `recipients`, an `http` payload, +// `subflow.input`, …): it keeps the SINGLE-brace dialect (`{var}` / +// `{rec.field}` / `{$User.Id}`). +// Two wrong-syntax mistakes AI/human authors carry over between them, or from +// other platforms: // - `{{ai_reply}}` — double-brace on a single-brace value. NOT flagged on a // text slot, where it is the spelling; the reverse // mistake there — a single-brace token on a text slot — @@ -664,16 +679,34 @@ function scanFilterForDateEquality( // (`validate-expressions`, the spec's one judge), so this // rule does not repeat it. // - `$source.id` — a `$`-prefixed reference written bare (resolves as a -// literal string), instead of `{source.id}` — or -// `{{ source.id }}` on a text slot. +// literal string). Its hint names what the judge of the +// slot it sits in accepts: the CEL envelope in a value +// slot, the `{{ }}` hole in a text slot, `{source.id}` +// elsewhere. const DOUBLE_BRACE = /\{\{\s*[\w$][\w$.\s]*\}\}/; // A `$Ident.field` not immediately inside a `{` (so `{$User.Id}` is NOT flagged). // Require a letter/_ after `$` so currency like `$5.00` is never matched. const BARE_DOLLAR_REF = /(?:^|[^{])\$[A-Za-z_]\w*\.[A-Za-z_]/; // Every such reference, whole (`$error.message`), at the same anchor — what a -// text slot's hint names, one hole or one remedy per reference. +// text slot's or a value slot's hint names, one remedy per reference. const BARE_DOLLAR_REFS = /(?:^|[^{])(\$[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+)/g; +/** The distinct bare `$name.path` references in `text`, in order. */ +function bareDollarRefsOf(text: string): string[] { + return [...new Set([...text.matchAll(BARE_DOLLAR_REFS)].map((m) => m[1]!))]; +} + +/** + * Whether the flow engine binds the `$` root of `ref` (`$error`, `$record`, …). + * Asked of the spec's text-slot judge, which admits a `{{ }}` hole over a `$` + * root exactly when the engine binds it — the one place that answers which + * `$` roots the engine binds (`flow-text-slot-template.ts`); a second list + * here would drift from it. + */ +function engineBindsRootOf(ref: string): boolean { + return textSlotTemplateRefusal(`{{ ${ref} }}`) === undefined; +} + /** * [#22477] The hint for bare `$name.path` references in a text slot's text * outside its holes. A reference whose hole the spec's text-slot judge admits @@ -684,10 +717,9 @@ const BARE_DOLLAR_REFS = /(?:^|[^{])(\$[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+)/g; * (`flow-text-slot-template.ts`), and a second list would drift from it. */ function textSlotBareDollarHint(outsideHoles: string): string { - const refs = [...new Set([...outsideHoles.matchAll(BARE_DOLLAR_REFS)].map((m) => m[1]!))]; const holes: string[] = []; const refusals: string[] = []; - for (const ref of refs) { + for (const ref of bareDollarRefsOf(outsideHoles)) { const refusal = textSlotTemplateRefusal(`{{ ${ref} }}`); if (refusal === undefined) holes.push(`\`{{ ${ref} }}\``); else refusals.push(`\`${ref}\` has no hole either: ${refusal}`); @@ -698,6 +730,54 @@ function textSlotBareDollarHint(outsideHoles: string): string { return parts.join(' '); } +/** + * [#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. + */ +function valueSlotSpellingOf(token: string): string { + const message = valueSlotTemplateRefusals(token)[0]?.message ?? ''; + return message.startsWith(VALUE_SLOT_TEMPLATE_REFUSAL) + ? message.slice(VALUE_SLOT_TEMPLATE_REFUSAL.length).trimStart() + : message; +} + +/** + * [#19939] The hint for bare `$name.path` references in a VALUE slot's string. + * A value slot reads the CEL value envelope, so each reference is prescribed + * the envelope the spec's value-slot judge writes for the `{…}` token the + * reference names — asked of the judge, never re-spelled here, so the hint + * and the build door's refusal cannot name two spellings. Which token a + * reference names is read the way a text slot reads it + * ({@link textSlotBareDollarHint}): + * + * - the run user (`$User.Id`, `$User.`) names `{$User.}`, whose + * remedy is `current_user.id`, guarded for a flow that can run without a + * user — or, for any other path, the read of the user record; + * - a `$` root the engine binds ({@link engineBindsRootOf}) names that + * variable (`{$error.message}`); + * - 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 { + 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.', + ]; + for (const ref of bareDollarRefsOf(text)) { + const ownVariable = classifyFlowTemplateToken(ref).kind !== 'user-context' && !engineBindsRootOf(ref); + const root = ref.split('.')[0]!; + const token = ownVariable ? `{${ref.slice(1)}}` : `{${ref}}`; + 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)}`, + ); + } + return parts.join(' '); +} + /** Config keys whose string values are CEL predicates, not interpolated templates. */ const CEL_KEYS = new Set(['condition', 'expression', 'conditions']); @@ -711,14 +791,55 @@ function withoutTextSlots(nodeType: unknown, config: unknown): unknown { return rest; } +/** One string leaf of a node config, with the key path that reaches it. */ +interface ConfigString { + readonly value: string; + readonly path: readonly (string | number)[]; +} + /** Collect every interpolated-template string value in a node config (skips CEL keys). */ -function collectTemplateStrings(value: unknown, key: string | undefined, out: string[]): void { +function collectTemplateStrings( + value: unknown, + key: string | undefined, + out: ConfigString[], + path: readonly (string | number)[] = [], +): void { if (key && CEL_KEYS.has(key)) return; - if (typeof value === 'string') { out.push(value); return; } - if (Array.isArray(value)) { for (const v of value) collectTemplateStrings(v, key, out); return; } + if (typeof value === 'string') { out.push({ value, path }); return; } + if (Array.isArray(value)) { value.forEach((v, i) => collectTemplateStrings(v, key, out, [...path, i])); return; } if (value && typeof value === 'object') { - for (const [k, v] of Object.entries(value as AnyRec)) collectTemplateStrings(v, k, out); + for (const [k, v] of Object.entries(value as AnyRec)) collectTemplateStrings(v, k, out, [...path, k]); + } +} + +/** `node` with `value` at `path` — a copy along the path only, so the author's config is never written. */ +function withValueAt(node: unknown, path: readonly (string | number)[], value: unknown): unknown { + if (path.length === 0) return value; + const [head, ...rest] = path; + if (Array.isArray(node)) { + const copy = [...node]; + copy[head as number] = withValueAt(node[head as number], rest, value); + return copy; } + const rec = (node ?? {}) as AnyRec; + 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. */ +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. + */ +function valueSlotLabelAt(nodeType: string, config: unknown, path: readonly (string | number)[]): string | undefined { + const probed = withValueAt(config, path, VALUE_SLOT_PROBE); + return flowNodeValueTemplateRefusals(nodeType, probed).find((refusal) => refusal.source === VALUE_SLOT_PROBE)?.label; } /** Edge `label`, normalized (trimmed, lowercased) for branch matching. */ @@ -1759,9 +1880,12 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] { // [#22110] And WITHOUT the node's text slots, which read `{{ }}`: a // double brace there is the spelling, and their own bare-`$` check // below prescribes the hole. - const strings: string[] = []; - collectTemplateStrings(withoutTextSlots(node.type, stripRegions(node.config, ownRegionKeys(node.type))), undefined, strings); - for (const str of strings) { + // [#19939] Each string also carries its key path, so the bare-`$` hint + // can ask whether it sits in a value slot (see `valueSlotLabelAt`). + 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) { if (DOUBLE_BRACE.test(str)) { findings.push({ where: nodeWhere, @@ -1775,13 +1899,20 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] { }); } 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: `\`${str.trim().slice(0, 80)}\` looks like a reference written as a literal — a bare \`$ref.field\` is NOT interpolated.`, - hint: - `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.`, + message: valueSlot === undefined + ? `\`${str.trim().slice(0, 80)}\` looks like a reference written as a literal — a bare \`$ref.field\` is NOT interpolated.` + : `\`${str.trim().slice(0, 80)}\` looks like a reference written as a literal — a bare \`$ref.field\` in the ${valueSlot} is NOT evaluated.`, + hint: valueSlot === undefined + ? `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), 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 b22d41aaee9..de425404017 100644 --- a/packages/lint/src/validate-expressions.fields-value-slot.test.ts +++ b/packages/lint/src/validate-expressions.fields-value-slot.test.ts @@ -22,9 +22,9 @@ * slot is a located `error` under the same rule id, led by * `VALUE_SLOT_TEMPLATE_REFUSAL` and naming the token's CEL spelling — in * every value slot and both legacy `assignment` shapes. It replaced the - * `warning` hint ruling D point 1 put there for 17.x. The two spellings - * CEL cannot write yet (date macros, `$User` paths) and non-value slots - * get nothing. + * `warning` hint ruling D point 1 put there for 17.x. A `$User` path is + * refused too, naming `current_user.id` (#19939 pass 2); the one spelling + * CEL cannot write yet (the date macros) and non-value slots get nothing. */ import { describe, expect, it } from 'vitest'; @@ -108,12 +108,12 @@ 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 two kept spellings and literals are clean', (nodeType) => { + it.each(NODE_TYPES)('%s: a valid envelope, the run user as `current_user`, the kept spelling 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: '{$User.Id}', + owner: { dialect: 'cel', source: 'current_user.id' }, due: '{TODAY() + 7}', n: 3, ok: true, nothing: null, payload: { nested: { dialect: 'cel' } }, // a nested envelope is data @@ -139,6 +139,8 @@ describe('the retired template dialect — a `{…}` token in a value slot is a ['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'], ]; it.each(REFUSED)('%s — rule `expression-invalid`, severity `error`, located, with the CEL spelling', (_what, nodeType, config, at, spelling) => { @@ -157,7 +159,6 @@ describe('the retired template dialect — a `{…}` token in a value slot is a it.each([ ['a date macro — CEL has no string form of a Timestamp yet', '{NOW()}'], ['a date macro with an offset', '{TODAY() + 7}'], - ['a `$User` path — the flow CEL scope binds no user yet', '{$User.Id}'], ['plain text', 'approved'], ])('says nothing for %s', (_why, value) => { for (const nodeType of NODE_TYPES) { diff --git a/packages/lint/src/validate-expressions.text-slot.test.ts b/packages/lint/src/validate-expressions.text-slot.test.ts index cadca1bf7c7..a942403c9f1 100644 --- a/packages/lint/src/validate-expressions.text-slot.test.ts +++ b/packages/lint/src/validate-expressions.text-slot.test.ts @@ -130,7 +130,7 @@ describe('`objectstack validate` — a flow text slot reads `{{ }}` holes (#2211 expect(findings, JSON.stringify(config)).toHaveLength(1); expect(findings[0]!.severity).toBe('error'); expect(findings[0]!.where).toContain(where); - expect(findings[0]!.message).toContain("assignments: { v: '{$User.Id}' }"); + expect(findings[0]!.message).toContain("assignments: { v: { dialect: 'cel', source: 'current_user.id' } }"); expect(findings[0]!.message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL)).toBe(false); } // Control: the engine-bound `$error` and an ordinary hole stay clean at the same door. diff --git a/packages/services/service-automation/README.md b/packages/services/service-automation/README.md index ebaff2576da..0c90e95ad52 100644 --- a/packages/services/service-automation/README.md +++ b/packages/services/service-automation/README.md @@ -180,24 +180,39 @@ This README deliberately does not keep a second copy of that per-node reference. ## Expressions -A flow mixes **two dialects**, and the rule is short: **every condition is CEL; -braces are for values.** +A flow's expressions follow its positions: **a condition is bare CEL, a value is +a CEL value envelope, and text is a `{{ }}` template.** | Where | Dialect | Write it like | |:---|:---|:---| | Start-node `condition` | CEL — bare, no braces | `record.amount > 500` | | Edge `condition` | CEL — bare, no braces | `record.status == 'open'` | | Decision `conditions[].expression` | CEL — bare, no braces | `order_amount > 10000` | -| Field values in `create_record` / `update_record` | Interpolation — braces required | `'Follow up on {record.name}'`, `'{TODAY() + 7}'` | +| 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 }}'` | -Value bindings: `{var}`, `{var.path}`, `{$User.Id}`, `{$User.Email}`, `{NOW()}`, -`{TODAY()}`, `{TODAY() + 90}`. +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}`). + +The run's user is **`current_user`** in every flow CEL expression: `id`, +`positions`, `organizationId`, `isPlatformAdmin`, nothing more. In a run with no +user (a schedule, a record change made by a system write) it is `null`, never a +stand-in user. So `{$User.Id}` is refused in a value slot: write +`{ dialect: 'cel', source: 'current_user.id' }`, or, in a flow that can run +without a user, `current_user != null ? current_user.id : null` (with no user it +writes `null`, which on `update_record` clears the stored value). Every other +`{$User.}` (`{$User.Email}`) never resolved and is refused too: read the +user record by `current_user.id` with a `get_record` on `sys_user`. The two failure modes to memorize: -1. **Braces missing in a field value** — `due_date: 'TODAY() + 7'` writes the - literal text into the field. Write `'{TODAY() + 7}'`. +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. 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/crud-fields-value-envelope.test.ts b/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts index 11baa104b1e..375d8d4cdbe 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 @@ -177,7 +177,7 @@ 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 two kept `{…}` spellings: byte-identical to the whole-map `interpolate()`', async () => { + it('literals, arrays, nested envelope-shaped JSON and the kept `{…}` spelling: byte-identical to the whole-map `interpolate()`', async () => { const fields = { subject: '{TODAY() + 7}', // a date macro — kept until CEL can write it total: 42, @@ -256,8 +256,44 @@ describe.each(NODE_TYPES)('%s `fields.*` — the retired `{…}` template dialec expect(writes[0]!.data.total).toBe(before.total); expect(writes[0]!.data.subject).toBe(before.subject); }); + + // #19939 pass 2 — `{$User.Id}` is refused; `current_user.id` writes the + // run user, the value the template wrote, and the guard writes `null` in a + // run with no user, where the template wrote nothing (the key absent). + it('`{$User.Id}` is refused; `current_user.id` writes the run user the template wrote, and the guard writes `null` without one', async () => { + const automation = new AutomationEngine(makeLogger()); + registerCrudNodes(automation, { logger: makeLogger(), getService: () => undefined } as any); + expect(() => automation.registerFlow('price_quote', writeFlow(nodeType, { subject: '{$User.Id}' }))) + .toThrow("{ dialect: 'cel', source: 'current_user.id' }"); + + const before = interpolate({ subject: '{$User.Id}' }, new Map(), { userId: 'u1' } as any); + const withUser = await makeStack(); + withUser.automation.registerFlow('price_quote', writeFlow(nodeType, { subject: { dialect: 'cel', source: 'current_user.id' } })); + const res = await run(withUser.automation); + expect(res.success, res.error).toBe(true); + expect(writes0(withUser.writes).subject).toBe(before.subject); + expect(before.subject).toBe('u1'); + + // A user-less run writes data only under an explicit `runAs: 'system'` (ADR-0049). + const userless = await makeStack(); + userless.automation.registerFlow('price_quote', { + ...writeFlow(nodeType, { subject: { dialect: 'cel', source: 'current_user != null ? current_user.id : null' } }), + runAs: 'system', + }); + const res2 = await userless.automation.execute('price_quote', { params: PARAMS } as any); + expect(res2.success, res2.error).toBe(true); + expect(interpolate({ subject: '{$User.Id}' }, new Map(), {} as any).subject).toBeUndefined(); + // The guarded form sends `null` — on `update_record` that clears the stored value. + expect(writes0(userless.writes)).toHaveProperty('subject', null); + }); }); +/** The first row that reached the store. */ +function writes0(writes: Array<{ data: Record }>): Record { + expect(writes.length).toBeGreaterThan(0); + return writes[0]!.data; +} + describe.each(NODE_TYPES)('%s `fields.*` — a malformed envelope is refused at registration, and by the evaluator', (nodeType) => { it.each(MALFORMED)('$label: registerFlow refuses it, located at the field and led by the slot-neutral sentence', ({ envelope }) => { const { automation } = { automation: new AutomationEngine(makeLogger()) }; diff --git a/packages/services/service-automation/src/builtin/crud-nodes.ts b/packages/services/service-automation/src/builtin/crud-nodes.ts index 94641f32745..459d1f44c4b 100644 --- a/packages/services/service-automation/src/builtin/crud-nodes.ts +++ b/packages/services/service-automation/src/builtin/crud-nodes.ts @@ -187,10 +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 two spellings CEL cannot write yet and - * the retirement keeps, the date macros (`{NOW()}`, `{TODAY() + 7}`) and - * `{$User.*}`, which `interpolate()` still resolves. On every other - * literal `interpolate()` is the identity. + * 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. * * 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 @@ -206,7 +207,7 @@ function resolveFieldValues( const out: Record = {}; for (const [key, value] of Object.entries(fields ?? {})) { out[key] = isExpressionEnvelopeShaped(value) - ? engine.evaluateValueEnvelope(value, variables, `fields.${key}`) + ? engine.evaluateValueEnvelope(value, variables, `fields.${key}`, context) : interpolate(value, variables, context); } return out; 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 9402bb325af..594c5da97b2 100644 --- a/packages/services/service-automation/src/builtin/logic-nodes.test.ts +++ b/packages/services/service-automation/src/builtin/logic-nodes.test.ts @@ -116,12 +116,25 @@ 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 two spellings CEL cannot write yet keep resolving — a date macro and `$User`', async () => { - engine.registerFlow('assign_flow', assignmentFlow({ assignments: { day: '{TODAY()}', who: '{$User.Id}' } }, ['day', 'who'])); + it('[#19939] the one spelling CEL cannot write yet keeps resolving — a date macro; the run user is `current_user`', async () => { + engine.registerFlow('assign_flow', assignmentFlow({ + assignments: { day: '{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); const output = result.output as Record; expect(output.who).toBe('usr_1'); expect(String(output.day)).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); + + it('[#19939] refuses `{$User.Id}` at registration, naming `current_user.id` and its guard', () => { + let message = ''; + try { + engine.registerFlow('assign_flow', assignmentFlow({ assignments: { who: '{$User.Id}' } }, ['who'])); + } 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.who"); + expect(message).toContain("{ dialect: 'cel', source: 'current_user.id' }"); + expect(message).toContain("{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }"); + }); }); diff --git a/packages/services/service-automation/src/builtin/logic-nodes.ts b/packages/services/service-automation/src/builtin/logic-nodes.ts index b8e9f2938ea..12cdad79e48 100644 --- a/packages/services/service-automation/src/builtin/logic-nodes.ts +++ b/packages/services/service-automation/src/builtin/logic-nodes.ts @@ -55,7 +55,7 @@ export function registerLogicNodes(engine: AutomationEngine, ctx: PluginContext) * and refuses a value outside the closed pair, or a `mode` beside a * non-empty `conditions` list, with the schema's own sentence. */ - async execute(node, variables, _context) { + async execute(node, variables, context) { const config = node.config as Record | undefined; const conditions = (config?.conditions ?? []) as Array<{ label: string; expression: string }>; if (conditions.length === 0) return { success: true }; @@ -82,7 +82,7 @@ export function registerLogicNodes(engine: AutomationEngine, ctx: PluginContext) // envelope of its own to carry the dialect — the decision // descriptor is deliberately schemaless — so the executor // supplies it. - if (engine.evaluateCondition({ dialect: 'cel', source: cond.expression }, variables)) { + if (engine.evaluateCondition({ dialect: 'cel', source: cond.expression }, variables, context)) { return { success: true, branchLabel: cond.label }; } } @@ -107,8 +107,10 @@ export function registerLogicNodes(engine: AutomationEngine, ctx: PluginContext) // 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 two the retirement keeps until CEL can spell them (the date - // macros, `{$User.*}`); on everything else it is the identity. + // 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 + // `current_user` in a CEL envelope, bound from the `context` this + // executor passes to the evaluator. // // [#15137] …with ONE exception, and only in the canonical map: a value // that is envelope-shaped (`isExpressionEnvelopeShaped` — a plain object @@ -197,7 +199,7 @@ export function registerLogicNodes(engine: AutomationEngine, ctx: PluginContext) // to a literal — but it cannot normally get this far: // `registerFlow` refuses the same set, derived from the // same call (`AutomationEngine.valueEnvelopeRefusals`). - variables.set(key, engine.evaluateValueEnvelope(value, variables, `assignments.${key}`)); + variables.set(key, engine.evaluateValueEnvelope(value, variables, `assignments.${key}`, context)); continue; } variables.set(key, interpolate(value, variables, context)); diff --git a/packages/services/service-automation/src/builtin/template.ts b/packages/services/service-automation/src/builtin/template.ts index db37cb84f0f..20f7253ff6e 100644 --- a/packages/services/service-automation/src/builtin/template.ts +++ b/packages/services/service-automation/src/builtin/template.ts @@ -34,8 +34,11 @@ * 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 and `$User` paths, which CEL cannot spell - * yet (`@objectstack/spec/automation`'s `flow-value-slot-template.ts`). Nor do + * 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 * 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, diff --git a/packages/services/service-automation/src/builtin/text-slot-template.test.ts b/packages/services/service-automation/src/builtin/text-slot-template.test.ts index c446e6672e3..75bf713106d 100644 --- a/packages/services/service-automation/src/builtin/text-slot-template.test.ts +++ b/packages/services/service-automation/src/builtin/text-slot-template.test.ts @@ -329,8 +329,38 @@ describe('#22477 — a text-slot hole may root only at a `$` variable the engine const refusal = registrationRefusal(engine, 'by_user', notifyFlow('by_user', { title: 'Closed', message: 'By {{ $User.Id }}' })); expect(refusal).toBeDefined(); expect(refusal).toContain("node 'notify' (notify) notify message at config.message"); - expect(refusal).toContain("assignments: { v: '{$User.Id}' }"); + expect(refusal).toContain("assignments: { v: { dialect: 'cel', source: 'current_user.id' } }"); // The renderer it no longer reaches: the hole resolves to nothing. expect(renderTextSlot('By {{ $User.Id }}', new Map([['userId', 'usr_7']]))).toBe('By '); }); + + // #19939 pass 2: the value slots refuse `{$User.Id}`, so the remedy above + // computes the run user with the CEL scope's `current_user` — and it + // renders the user, and nothing in a run with none (as the template did). + it('the remedy renders the run user: an assignment of `current_user.id`, then `By {{ v }}`', async () => { + const flow = (source: string) => ({ + name: 'by_user', label: 'by_user', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'who', type: 'assignment', label: 'Who', config: { assignments: { v: { dialect: 'cel', source } } } }, + { id: 'notify', type: 'notify', label: 'Notify', config: { recipients: ['user_1'], title: 'Closed', message: 'By {{ v }}' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'who' }, + { id: 'e2', source: 'who', target: 'notify' }, + { id: 'e3', source: 'notify', target: 'end' }, + ], + }); + const withUser = harness(); + withUser.engine.registerFlow('by_user', flow('current_user.id') as never); + expect((await withUser.engine.execute('by_user', ctx())).success).toBe(true); + expect(withUser.emitted[0]!.payload).toMatchObject({ body: 'By usr_7' }); + + const userless = harness(); + userless.engine.registerFlow('by_user', flow('current_user != null ? current_user.id : null') as never); + const noUser = { event: 'manual', object: 'account', record: ACME } as unknown as AutomationContext; + expect((await userless.engine.execute('by_user', noUser)).success).toBe(true); + expect(userless.emitted[0]!.payload).toMatchObject({ body: 'By ' }); + }); }); 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 f0fe28c8775..bf9e76c3111 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 @@ -13,8 +13,12 @@ * 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, `$User` to the run user — and the judge - * refuses none of them; + * date macros to ISO text — and the judge refuses none of them; + * - 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 + * evaluate through the CEL scope's `current_user` to the same answer — with + * a user, and, guarded, without one; * - 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 @@ -24,11 +28,13 @@ * 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). + * 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`). */ import { describe, expect, it } from 'vitest'; -import { valueSlotTemplateRefusals } from '@objectstack/spec/automation'; +import { VALUE_SLOT_TEMPLATE_REFUSAL, valueSlotTemplateRefusals } from '@objectstack/spec/automation'; import { AutomationEngine } from '../engine.js'; import { interpolateString } from './template.js'; @@ -53,12 +59,43 @@ describe('a spelling the retirement KEEPS resolves through the interpolator, and expect(valueSlotTemplateRefusals(token)).toEqual([]); }); - it.each([ - ['{$User.Id}', 'usr_1'], - ['{$User.Email}', 'ada@example.com'], - ])('%s — the run user', (token, value) => { - expect(interpolateString(token, VARIABLES, CONTEXT)).toBe(value); - expect(valueSlotTemplateRefusals(token)).toEqual([]); +}); + +describe('the run user — `{$User.*}` — is REFUSED, and its remedy reads what the interpolator read', () => { + const quiet = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, child: () => quiet } as never; + const engine = new AutomationEngine(quiet); + /** A run context the way the trigger doors build one: no `user` object, ever. */ + const SHIPPED = { userId: 'usr_1', positions: ['org_member'], tenantId: 'org_1' } as never; + const USERLESS = {} as never; + /** The CEL sources the refusal of `token` prints, in order — read after the rule sentence, whose own `'…'` is not a remedy. */ + const sources = (token: string): string[] => { + const message = (valueSlotTemplateRefusals(token)[0]?.message ?? '').slice(VALUE_SLOT_TEMPLATE_REFUSAL.length); + return [...message.matchAll(/\{ dialect: 'cel', source: '([^']*)' \}/g)].map((m) => m[1]!); + }; + + it('`{$User.Id}`: the bare remedy reads the run user, the guard reads `null` where the template read nothing', () => { + expect(valueSlotTemplateRefusals('{$User.Id}')).toHaveLength(1); + const [bare, guarded] = sources('{$User.Id}'); + expect([bare, guarded]).toEqual(['current_user.id', 'current_user != null ? current_user.id : null']); + // With a user: all three agree. + expect(interpolateString('{$User.Id}', VARIABLES, SHIPPED)).toBe('usr_1'); + expect(engine.evaluateValueEnvelope({ dialect: 'cel', source: bare! }, VARIABLES, 'w', SHIPPED)).toBe('usr_1'); + expect(engine.evaluateValueEnvelope({ dialect: 'cel', source: guarded! }, VARIABLES, 'w', SHIPPED)).toBe('usr_1'); + // Without one: the template read nothing, the bare read fails loudly, the guard reads `null`. + expect(interpolateString('{$User.Id}', VARIABLES, USERLESS)).toBeUndefined(); + expect(() => engine.evaluateValueEnvelope({ dialect: 'cel', source: bare! }, VARIABLES, 'w', USERLESS)).toThrow(/current_user\.id/); + expect(engine.evaluateValueEnvelope({ dialect: 'cel', source: guarded! }, VARIABLES, 'w', USERLESS)).toBeNull(); + }); + + it.each(['{$User.Email}', '{$User.Name}', '{$User.id}'])('%s never resolved in a shipped run context — and is refused saying so', (token) => { + expect(interpolateString(token, VARIABLES, SHIPPED)).toBeUndefined(); + expect(valueSlotTemplateRefusals(token)[0]!.message).toContain(`\`${token}\` never resolved in any shipped run`); + }); + + it('the user-record read it names starts from `current_user.id`, which evaluates', () => { + const [uid] = sources('{$User.Email}'); + expect(uid).toBe('current_user.id'); + expect(engine.evaluateValueEnvelope({ dialect: 'cel', source: uid! }, VARIABLES, 'w', SHIPPED)).toBe('usr_1'); }); }); @@ -176,4 +213,50 @@ describe('the remedy a refused path prints reads, through CEL, the value the int expect(printedSpellings('{$error.message}')).toEqual(['vars["$error"].message']); expectReadingsAgree('{$error.message}', VARIABLES, 'boom'); }); + + // The flow scope's own claims (`FLOW_SCOPE_CLAIMED_IDENTIFIERS` in the spec, + // measured from `celScope`): `vars` and `current_user` are bound AFTER the + // variables are spread, so the bare spelling reads the binding and only + // `vars["…"]` reads the variable. + it.each(['vars', 'current_user'])('a head variable named `%s` — the scope binds it over the variable', (name) => { + const value = ['first', { key: 'second' }]; + const variables = new Map([[name, value]]); + expectReadingsAgree(`{${name}.0}`, variables, 'first'); + expectReadingsAgree(`{${name}.1.key}`, variables, 'second'); + expectReadingsAgree(`{${name}.tags}`, new Map([[name, { tags: 'T' }]]), 'T'); + // The bare spelling does not read the variable — the binding answers it. + expect(() => engine.evaluateValueEnvelope({ dialect: 'cel', source: `${name}[0]` }, variables, name)).toThrow(); + }); +}); + +describe('an EXPRESSION token\'s remedy evaluates — every variable path in it is read by the path rule', () => { + const quiet = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, child: () => quiet } as never; + const engine = new AutomationEngine(quiet); + const remedyOf = (token: string): string => { + const message = valueSlotTemplateRefusals(token)[0]!.message; + const m = /Write `[^`]+` as \{ dialect: 'cel', source: (?:'([^']*)'|("(?:[^"\\]|\\.)*")) \}/.exec(message); + expect(m, message).not.toBeNull(); + return m![1] ?? (JSON.parse(m![2]!) as string); + }; + + it('`{int * 2}` — a head CEL claims: `vars["int"] * 2`, the value the interpolator computed', () => { + const variables = new Map([['int', 5]]); + expect(interpolateString('{int * 2}', variables, CONTEXT)).toBe(10); + expect(remedyOf('{int * 2}')).toBe('vars["int"] * 2'); + expect(engine.evaluateValueEnvelope({ dialect: 'cel', source: remedyOf('{int * 2}') }, variables, 'w')).toBe(10); + }); + + it('`{items.0 * 2}` — an index: `items[0] * 2`, which evaluates (the interpolator computed nothing for it)', () => { + const variables = new Map([['items', [3, 4]]]); + expect(interpolateString('{items.0 * 2}', variables, CONTEXT)).toBeUndefined(); + expect(remedyOf('{items.0 * 2}')).toBe('items[0] * 2'); + expect(engine.evaluateValueEnvelope({ dialect: 'cel', source: remedyOf('{items.0 * 2}') }, variables, 'w')).toBe(6); + }); + + it('a `$`-named head and a divisor in one expression', () => { + const variables = new Map([['$error', { code: 7 }]]); + expect(interpolateString('{$error.code / 2}', variables, CONTEXT)).toBe(3.5); + expect(remedyOf('{$error.code / 2}')).toBe('vars["$error"].code / 2.0'); + expect(engine.evaluateValueEnvelope({ dialect: 'cel', source: remedyOf('{$error.code / 2}') }, variables, 'w')).toBe(3.5); + }); }); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 4a2bcc50fae..d17b32e3e67 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -12,6 +12,7 @@ import type { } from '@objectstack/spec/automation'; import type { AutomationContext, AutomationResult, ResumeSignal, IAutomationService, RunListResult, ScreenSpec, ScreenFieldSpec, ConnectorSourcePullRequest, ConnectorSourcePullResult } from '@objectstack/spec/contracts'; import { RESUME_AUTHORITY_SERVICE } from '@objectstack/spec/contracts'; +import { createEvalUser, type EvalUserParsed } from '@objectstack/spec/identity'; import { validateScreenInputs, screenDeclaresInputContract, @@ -143,6 +144,34 @@ function unquoteLiteral(operand: string): string { return m ? (m[1] ?? m[2] ?? '') : operand; } +/** + * The value the flow CEL scope binds as `current_user` for a run (#19939 — + * the maintainer's ruling on its second pass, Q1 A and Q2 A): the run's + * `EvalUser` when the run has a user, else `null`. + * + * Built through `createEvalUser`, the one factory every surface builds the + * canonical user with (ADR-0068), from what the run context holds and + * nothing else: `id` from `userId`, `positions`, and `organizationId` from + * `tenantId`; `isPlatformAdmin` is derived from the positions there. No run + * context carries an email or a name, so neither is bound — a read of either + * is the user record's (`current_user.id`), not the run's. + * + * A run with no user — a schedule, a record change made by a system write, a + * caller that passed no context — answers `null`, never a pseudo-user + * (ADR-0118 D1, D4): `current_user.id` then fails the run loudly, and the + * author's guard `current_user != null ? current_user.id : null` can be + * written. + */ +function runUserOf(context: AutomationContext | undefined): EvalUserParsed | null { + const userId = context?.userId; + if (typeof userId !== 'string' || userId === '') return null; + return createEvalUser({ + id: userId, + positions: Array.isArray(context?.positions) ? context.positions : [], + ...(typeof context?.tenantId === 'string' ? { organizationId: context.tenantId } : {}), + }); +} + /** * The branch a `decision` node reports when it DECLARED `config.conditions` and * none of them matched — "fall through to the declared fallback". @@ -6384,7 +6413,7 @@ export class AutomationEngine implements IAutomationService { if (startCondition !== undefined && startCondition !== null && startCondition !== '') { const condExpr = typeof startCondition === 'string' ? { dialect: 'cel', source: startCondition } : startCondition; - if (!this.evaluateCondition(condExpr, variables)) { + if (!this.evaluateCondition(condExpr, variables, runContext)) { this.logger.debug(`Flow '${flowName}' skipped: start condition not met`); // `flowLabel` rides even here, unlike `successMessage` / // `summary`: it names the flow, it claims no work done, and @@ -8368,7 +8397,7 @@ export class AutomationEngine implements IAutomationService { const visibility = (field: ScreenFieldSpec): ScreenFieldVisibility => { try { - return this.evaluateCondition(String(field.visibleWhen), scope); + return this.evaluateCondition(String(field.visibleWhen), scope, run.context); } catch (err) { // #6499 — BOTH spliced pieces were uncontrolled: the author's // own `visibleWhen` source (metadata text of any shape) and @@ -11692,7 +11721,7 @@ export class AutomationEngine implements IAutomationService { if (nextNode) recordSkipped(nextNode, edge); continue; } - if (this.evaluateCondition(edge.condition!, variables)) { + if (this.evaluateCondition(edge.condition!, variables, context)) { anyConditionMet = true; if (nextNode) { await this.executeNode(nextNode, flow, variables, context, steps); @@ -11997,8 +12026,27 @@ export class AutomationEngine implements IAutomationService { * * Shared deliberately: a predicate and a value expression that disagreed * about what `rows` means would be two dialects wearing one name. + * + * ## `current_user` — the run's user, or `null` (#19939) + * + * The scope also binds `current_user`, ADR-0068's canonical user root, from + * the run context the caller passes ({@link runUserOf}): the run's + * `EvalUser` when the run has a user, and `null` when it has none — never + * a pseudo-user (ADR-0118 D1, D4). It carries only what the run holds: + * `id`, `positions`, `organizationId` and the `isPlatformAdmin` flag + * derived from them; no run carries an email or a name. This is what the + * retired `{$User.Id}` value-slot token is refused in favour of, so it + * reads the same context the interpolator's `$User.Id` branch reads. + * + * `vars` and `current_user` are bound AFTER the variables are spread, so + * each wins over a flow variable of the same name — which is then read as + * `vars["vars"]` / `vars["current_user"]` (the spec's + * `FLOW_SCOPE_CLAIMED_IDENTIFIERS`, measured from here). */ - private celScope(variables: Map): { extra: Record; record: Record } { + private celScope( + variables: Map, + context?: AutomationContext, + ): { extra: Record; record: Record } { const vars: Record = {}; for (const [key, value] of variables) { // Convert "step.result" keys into nested object paths. @@ -12012,7 +12060,7 @@ export class AutomationEngine implements IAutomationService { } cursor[segs[segs.length - 1]] = value; } - return { extra: { ...vars, vars }, record: vars }; + return { extra: { ...vars, vars, current_user: runUserOf(context) }, record: vars }; } /** @@ -12133,8 +12181,17 @@ export class AutomationEngine implements IAutomationService { * `assignments: [{ variable, value }]` array and the bare * `{ : }` config) are deliberately NOT declared, so an * envelope-shaped object there stays the literal object it always was. + * + * `context` is the run's: it decides what `current_user` is in the scope + * ({@link celScope}) — the run's user, or `null` when the run has none or + * no context is passed. Every executor passes the one it was handed. */ - evaluateValueEnvelope(envelope: { dialect?: string; source?: string; ast?: unknown }, variables: Map, where: string): unknown { + evaluateValueEnvelope( + envelope: { dialect?: string; source?: string; ast?: unknown }, + variables: Map, + where: string, + context?: AutomationContext, + ): unknown { const refusals = this.valueEnvelopeRefusals(envelope); if (refusals.length > 0) { throw new Error( @@ -12142,7 +12199,7 @@ export class AutomationEngine implements IAutomationService { ); } const source = envelope.source ?? ''; - const result = ExpressionEngine.evaluate({ dialect: 'cel', source }, this.celScope(variables)); + const result = ExpressionEngine.evaluate({ dialect: 'cel', source }, this.celScope(variables, context)); if (!result.ok) { // Reached by the shapes the two validators above cannot judge — an // `ast`-only envelope (`ExpressionSchema` accepts `source`-or-`ast`; @@ -12221,8 +12278,17 @@ export class AutomationEngine implements IAutomationService { * refuses it at authoring, and `structuralConditionRefusal` refuses it on * both structural slots, so it is refused here as well, through the same * shared constructor rather than a second rule. + * + * `context` is the run's: it decides what `current_user` is in a CEL + * predicate's scope ({@link celScope}) — the run's user, or `null` when + * the run has none or no context is passed. Every engine site and the + * `decision` executor pass the run's. */ - evaluateCondition(expression: string | { dialect?: string; source?: string; ast?: unknown }, variables: Map): boolean { + evaluateCondition( + expression: string | { dialect?: string; source?: string; ast?: unknown }, + variables: Map, + context?: AutomationContext, + ): boolean { const shapeRefusal = structuralConditionRefusal(expression); if (shapeRefusal) { // ADR-0032 §1d — the error carries its source. `structuralConditionRefusal` @@ -12262,7 +12328,7 @@ export class AutomationEngine implements IAutomationService { try { const result = ExpressionEngine.evaluate( { dialect: 'cel', source: exprStr }, - this.celScope(variables), + this.celScope(variables, context), ); // ADR-0032 §Decision 1c — NO silent fallback. A non-`ok` result is a // real fault (malformed predicate, or — pre build-validation — a diff --git a/packages/services/service-automation/src/flow-cel-current-user.test.ts b/packages/services/service-automation/src/flow-cel-current-user.test.ts new file mode 100644 index 00000000000..818a1696435 --- /dev/null +++ b/packages/services/service-automation/src/flow-cel-current-user.test.ts @@ -0,0 +1,211 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19939 pass 2] The flow CEL scope binds `current_user` — the maintainer's + * ruling on the second pass: + * + * - **Q1 A.** `current_user` is the run's `EvalUser` (id from `userId`, + * positions, organization from `tenantId`) when the run has a user, and + * `null` when it has none — never a pseudo-user (ADR-0118 D1, D4). + * - **Q2 A.** It carries only what the run holds: id, positions, + * organization, the platform-admin flag. No email, no name. + * + * Pinned with a user and without one, through every CEL site a flow has: an + * `assignment` value envelope, the start node's condition, an edge condition, + * a `decision` condition, and a screen field's `visibleWhen` on resume. Each + * reads the run's own context — the one the retired `{$User.Id}` read — so a + * `runAs: 'system'` run still sees the user that triggered it. + */ + +import { describe, expect, it } from 'vitest'; + +import { AutomationEngine } from './engine.js'; +import { installBuiltinNodes } from './builtin/index.js'; + +function silentLogger(): any { + return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } }; +} + +function makeEngine(): AutomationEngine { + const engine = new AutomationEngine(silentLogger()); + installBuiltinNodes(engine, { logger: silentLogger(), getService() { return undefined; } } as any); + return engine; +} + +/** A start → assignment → end flow whose one assignment is the CEL `source`, surfaced as output `v`. */ +function assignFlow(source: string, extra: Record = {}) { + return { + name: 'who', label: 'Who', type: 'autolaunched', + variables: [{ name: 'v', type: 'text', isOutput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'set', type: 'assignment', label: 'Set', config: { assignments: { v: { dialect: 'cel', source } } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'set' }, + { id: 'e2', source: 'set', target: 'end' }, + ], + ...extra, + } as any; +} + +const USER = { userId: 'usr_1', positions: ['org_admin', 'sales_rep'], tenantId: 'org_1' }; + +async function evaluate(source: string, context: Record, extra: Record = {}) { + const engine = makeEngine(); + engine.registerFlow('who', assignFlow(source, extra)); + return engine.execute('who', context as any); +} + +describe('`current_user` with a user — the run\'s EvalUser, and only what the run holds (Q1 A, Q2 A)', () => { + it('is the canonical EvalUser: id, positions, organizationId and the derived isPlatformAdmin', async () => { + const result = await evaluate('current_user', USER); + expect(result.success, result.error).toBe(true); + expect((result.output as { v: unknown }).v).toEqual({ + id: 'usr_1', + positions: ['org_admin', 'sales_rep'], + isPlatformAdmin: false, + organizationId: 'org_1', + }); + }); + + it('derives isPlatformAdmin from the positions, as `createEvalUser` does everywhere', async () => { + const result = await evaluate('current_user.isPlatformAdmin', { userId: 'usr_9', positions: ['platform_admin'] }); + expect((result.output as { v: unknown }).v).toBe(true); + }); + + it('carries no email and no name — no run context holds either', async () => { + const result = await evaluate("has(current_user.email) || has(current_user.name) ? 'yes' : 'no'", USER); + expect((result.output as { v: unknown }).v).toBe('no'); + }); + + it('leaves organizationId out when the run carries no tenant', async () => { + const result = await evaluate("has(current_user.organizationId) ? 'yes' : 'no'", { userId: 'usr_1' }); + expect((result.output as { v: unknown }).v).toBe('no'); + }); + + it('a `runAs: \'system\'` run still sees the user that triggered it — what `{$User.Id}` read', async () => { + const result = await evaluate('current_user.id', USER, { runAs: 'system' }); + expect(result.success, result.error).toBe(true); + expect((result.output as { v: unknown }).v).toBe('usr_1'); + }); +}); + +describe('`current_user` without a user — `null`, never a pseudo-user (Q1 A, ADR-0118 D1)', () => { + it.each([ + ['no context user at all', {}], + ['an empty user id', { userId: '' }], + ['a system run with no triggering user', { runAs: 'system' }], + ])('%s: the root is `null`', async (_what, context) => { + const result = await evaluate("current_user == null ? 'none' : 'someone'", context); + expect(result.success, result.error).toBe(true); + expect((result.output as { v: unknown }).v).toBe('none'); + }); + + it('a bare `current_user.id` fails the run loudly, naming the source — never a silent nothing', async () => { + const result = await evaluate('current_user.id', {}); + expect(result.success).toBe(false); + expect(result.error).toContain('current_user.id'); + }); + + it('the ruled guard writes `null`', async () => { + const result = await evaluate('current_user != null ? current_user.id : null', {}); + expect(result.success, result.error).toBe(true); + expect((result.output as { v: unknown }).v).toBeNull(); + }); +}); + +describe('every CEL site of a flow reads the run\'s user', () => { + it('the start condition gates on it — a user-less run is skipped, a user\'s runs', async () => { + const engine = makeEngine(); + const flow = assignFlow('current_user.id'); + flow.nodes[0].config = { condition: 'current_user != null' }; + engine.registerFlow('who', flow); + const skipped = await engine.execute('who', {} as any); + expect(skipped.output).toMatchObject({ skipped: true, reason: 'condition_not_met' }); + const ran = await engine.execute('who', USER as any); + expect((ran.output as { v: unknown }).v).toBe('usr_1'); + }); + + function branchFlow(kind: 'edge' | 'decision') { + const branch = kind === 'edge' + ? { + nodes: [] as unknown[], + edges: [ + { id: 'e1', source: 'start', target: 'admin', condition: "'org_admin' in current_user.positions" }, + { id: 'e2', source: 'start', target: 'other', condition: "!('org_admin' in current_user.positions)" }, + ], + } + : { + nodes: [{ + id: 'route', type: 'decision', label: 'Route', + config: { conditions: [{ label: 'admin', expression: "'org_admin' in current_user.positions" }] }, + }], + edges: [ + { id: 'e1', source: 'start', target: 'route' }, + { id: 'e2', source: 'route', target: 'admin', label: 'admin' }, + { id: 'e3', source: 'route', target: 'other', label: 'default' }, + ], + }; + return { + name: 'route', label: 'Route', type: 'autolaunched', + variables: [{ name: 'path', type: 'text', isOutput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + ...branch.nodes, + { id: 'admin', type: 'assignment', label: 'Admin', config: { assignments: { path: 'admin' } } }, + { id: 'other', type: 'assignment', label: 'Other', config: { assignments: { path: 'other' } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + ...branch.edges, + { id: 'ea', source: 'admin', target: 'end' }, + { id: 'eo', source: 'other', target: 'end' }, + ], + } as any; + } + + it.each(['edge', 'decision'] as const)('a %s condition branches on the run user\'s positions', async (kind) => { + const engine = makeEngine(); + engine.registerFlow('route', branchFlow(kind)); + const admin = await engine.execute('route', USER as any); + expect(admin.success, admin.error).toBe(true); + expect((admin.output as { path: unknown }).path).toBe('admin'); + const member = await engine.execute('route', { userId: 'usr_2', positions: ['org_member'] } as any); + expect((member.output as { path: unknown }).path).toBe('other'); + }); + + it('a screen field\'s `visibleWhen` reads the run\'s user on resume', async () => { + const engine = makeEngine(); + engine.registerFlow('ask', { + name: 'ask', label: 'Ask', type: 'screen', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'form', type: 'screen', label: 'Form', + config: { + fields: [{ + name: 'admin_note', label: 'Admin note', type: 'text', required: true, + visibleWhen: "'org_admin' in current_user.positions", + }], + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'form' }, + { id: 'e2', source: 'form', target: 'end' }, + ], + } as any); + // An admin sees the field, so its `required` holds. + const asAdmin = await engine.execute('ask', USER as any); + expect(asAdmin.status).toBe('paused'); + const refused = await engine.resume(asAdmin.runId!, { variables: {} }); + expect(refused.code).toBe('INVALID_SCREEN_INPUT'); + // A member does not, so the empty submission completes. + const asMember = await engine.execute('ask', { userId: 'usr_2', positions: ['org_member'] } as any); + const done = await engine.resume(asMember.runId!, { variables: {} }); + expect(done.success, done.error).toBe(true); + }); +}); diff --git a/packages/spec/src/automation/builtin-node-config.test.ts b/packages/spec/src/automation/builtin-node-config.test.ts index 9c76dd9a92a..fcaa1ea8d7d 100644 --- a/packages/spec/src/automation/builtin-node-config.test.ts +++ b/packages/spec/src/automation/builtin-node-config.test.ts @@ -466,9 +466,9 @@ 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 two `{…}` spellings the retirement keeps until CEL can write them. + // 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}', - by: '{$User.Id}', }, }).success).toBe(true); // An envelope-shaped object with a non-string `dialect` is a literal, as it always was. @@ -850,7 +850,6 @@ describe('CRUD `fields` value contract — the CEL value envelope beside literal const fields = { subject: 'Follow up', // text due_date: '{TODAY() + 7}', // a date macro — kept until CEL can write it - owner: '{$User.Id}', // the run user — kept until the flow CEL scope binds it 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 8b73af9fb3b..4252d6ed435 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -394,9 +394,10 @@ function celValueSlotSchema(description: string) { * C half of #11182 ruling D): a string anywhere in a literal that carries a * `{…}` token the interpolator would resolve is refused, with the token's CEL * spelling ({@link valueSlotTemplateRefusals} — every token measured lossy - * under conversion, so none is rewritten, ADR-0087 D2). Two spellings CEL - * cannot write yet keep their 17.x meaning until it can: the date macros - * (`{NOW()}`, `{TODAY() + 7}`) and the run user (`{$User.Id}`). + * 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}`). * * The slot-neutral contract. The CRUD `fields` map's values take it * (`CreateRecordConfigSchema` / `UpdateRecordConfigSchema`, #19938), and it is @@ -408,7 +409,7 @@ 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 and `$User` paths are kept for now)', + + 'refused (the template dialect is retired from value slots; the date macros are kept for now)', ); export type FlowValueSlot = z.input; @@ -1016,8 +1017,7 @@ 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 and `$User` paths are ' - + 'kept for now)', + + 'a string is refused (the template dialect is retired from value slots; the date macros are kept for 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 9b18b790d96..8b5be3d9cad 100644 --- a/packages/spec/src/automation/flow-template-token.ts +++ b/packages/spec/src/automation/flow-template-token.ts @@ -124,19 +124,44 @@ export const CEL_CLAIMED_IDENTIFIERS: ReadonlySet = new Set([ ...CEL_KEYWORDS, ]); +/** + * **Every identifier the flow CEL scope binds over a flow variable of the same + * name** — the flow runtime's claims, beside CEL's own + * ({@link CEL_CLAIMED_IDENTIFIERS}). `service-automation`'s + * `AutomationEngine.celScope` spreads the flow's variables at the top level + * and then binds these two after the spread, so each wins over a variable + * that shares its name: + * + * - `vars` — the variables namespace itself (`vars.x`, `vars["$error"]`), so + * for a variable named `vars`, `vars[0]` reads the namespace and fails + * `No such key: 0`; + * - `current_user` — the run's user (ADR-0068's canonical root), or `null` + * when the run has none, so for a variable named `current_user`, + * `current_user.name` reads the run user. + * + * {@link celPath} reads a path whose head is one of these through `vars` + * (`vars["vars"][0]`, `vars["current_user"].name`), the route a CEL-claimed + * head already takes. The spec cannot import the runtime, so this list is + * measured from `celScope`; `value-slot-template-grammar.test.ts` in that + * package evaluates both spellings for a variable of each name, so a name the + * scope starts binding without a line here reddens there. + */ +export const FLOW_SCOPE_CLAIMED_IDENTIFIERS: ReadonlySet = new Set(['vars', 'current_user']); + /** * 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}. + * no identifier spelling for one), one of {@link CEL_CLAIMED_IDENTIFIERS}, or + * one of {@link FLOW_SCOPE_CLAIMED_IDENTIFIERS}. */ export function celHeadReadsThroughVars(head: string): boolean { - return head.startsWith('$') || CEL_CLAIMED_IDENTIFIERS.has(head); + return head.startsWith('$') || CEL_CLAIMED_IDENTIFIERS.has(head) || FLOW_SCOPE_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"]`). + * `vars["$error"].message`, `vars["list"][0]`, `vars["vars"][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('.'); @@ -149,7 +174,105 @@ export function celPath(path: string): string { return out; } -/** A template expression as CEL: every integer divisor written as a double, so CEL divides as the template did. */ +/** + * The CEL spelling of the run user's id — what `{$User.Id}` read + * (`resolveToken` answers it with the run's `userId`). The flow CEL scope + * binds `current_user` to the run's `EvalUser`, and to `null` when the run has + * no user (ADR-0068, ADR-0118 D1, D4), so in a user-less run this read fails. + */ +export const CEL_RUN_USER_ID = 'current_user.id'; + +/** + * {@link CEL_RUN_USER_ID} guarded for a run with no user — a schedule, or a + * record change made by a system write. It answers `null` there, where the + * template answered nothing. + */ +export const CEL_RUN_USER_ID_GUARDED = 'current_user != null ? current_user.id : null'; + +/** + * Whether a `user` token reads the run user's id — `$User.Id`, the one + * `$User` path `resolveToken` answers from the run (its first segment is + * `Id`, and the rest is ignored). Every other path reads a user object no + * run carries. + */ +export function isRunUserIdToken(inner: string): boolean { + return inner.trim().slice('$User.'.length).split('.')[0] === 'Id'; +} + +/** + * Why a `$User.` token other than the id ({@link isRunUserIdToken}) has + * nothing to convert, and what to write for the value it meant: it never + * resolved, and `current_user` carries only what the run holds, so an email + * or a name is read from the user record by `current_user.id`. `write` is how + * the remedy's last step spells the record's field — an envelope in a value + * slot, a hole in a text slot. + */ +export function runUserPathNeverResolved(text: string, write: (path: string) => string): string { + return ( + `\`${text}\` never resolved in any shipped run: it read a user object no run carries, so the template ` + + 'wrote nothing here. `current_user` carries only what the run holds — `id`, ' + + '`positions`, `organizationId`, `isPlatformAdmin`. For the user\'s email or name, read the user record by ' + + `\`current_user.id\`: compute the id into a variable with an \`assignment\` node (\`assignments: { uid: ` + + `{ dialect: 'cel', source: '${CEL_RUN_USER_ID}' } }\`), read the record with a \`get_record\` node ` + + '(`objectName: \'sys_user\'`, `filter: { id: \'{uid}\' }`, `outputVariable: \'me\'`), and write ' + + `${write('me.email')} or ${write('me.name')}.` + ); +} + +/** + * One lexeme of a template expression, in the order the alternatives are + * tried: a quoted string (its content is never rewritten), a number, a + * variable path in the interpolator's own spelling ({@link VARIABLE_PATH}, + * numeric segments included), or any single other character. + */ +const EXPRESSION_LEXEME = + /(["'])(?:\\[\s\S]|(?!\1)[^\\])*\1|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|[A-Za-z_$][\w$]*(?:\.(?:[A-Za-z_$][\w$]*|\d+))*|[\s\S]/g; + +/** + * A template expression as CEL — what the interpolator computed, spelled so + * the CEL value envelope evaluates it: + * + * - every variable path is written by {@link celPath}'s rule, the one a + * lone path token gets: `int * 2` → `vars["int"] * 2` (a head CEL claims), + * `items.0 * 2` → `items[0] * 2` (an index), `$error.code + 1` → + * `vars["$error"].code + 1`. A name in call position (`round(`) is a + * function, a member selected off something else (`(x).y`) is not a head, + * and the keywords (`true`, `false`, `null`, `in`) are CEL's own, so each + * of those is left as written; + * - every integer divisor is written as a double (`/ 100` → `/ 100.0`), so + * CEL divides as the template did. + * + * Text inside a quoted string is never rewritten. + */ export function celExpression(inner: string): string { - return inner.replace(/\/\s*(\d+)(?![\d.])/g, '/ $1.0'); + const lexemes = inner.match(EXPRESSION_LEXEME) ?? []; + const significant = (from: number, step: 1 | -1): string | undefined => { + for (let at = from + step; at >= 0 && at < lexemes.length; at += step) { + if (!/^\s$/.test(lexemes[at]!)) return lexemes[at]; + } + return undefined; + }; + let out = ''; + for (let at = 0; at < lexemes.length; at++) { + const lexeme = lexemes[at]!; + if (lexeme === '/') { + const next = lexemes.findIndex((candidate, index) => index > at && !/^\s$/.test(candidate)); + if (next !== -1 && /^\d+$/.test(lexemes[next]!) && lexemes[next + 1] !== '.') { + out += `/ ${lexemes[next]}.0`; + at = next; + continue; + } + out += lexeme; + continue; + } + if (/^[A-Za-z_$]/.test(lexeme)) { + const isCall = significant(at, 1) === '('; + const isMember = significant(at, -1) === '.'; + const isKeyword = CEL_KEYWORDS.has(lexeme); + out += isCall || isMember || isKeyword ? lexeme : celPath(lexeme); + continue; + } + out += lexeme; + } + return out; } 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 729022270f8..dbb01a338fb 100644 --- a/packages/spec/src/automation/flow-text-slot-template.test.ts +++ b/packages/spec/src/automation/flow-text-slot-template.test.ts @@ -74,8 +74,8 @@ 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 or a run-user path', () => { - for (const token of ['{TODAY() + 7}', '{NOW()}', '{$User.Id}']) { + 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}' }`); @@ -84,6 +84,29 @@ describe('textSlotTemplateRefusal — the one judge of the single brace in a tex } }); + // #19939 pass 2: the value slots refuse `{$User.*}`, so the run user's id is + // computed with the CEL envelope the value-slot refusal names, never with a + // value-slot spelling that is refused in turn. + it('names an assignment with the CEL envelope `current_user.id` for the run user\'s id, and its guard', () => { + const message = textSlotTemplateRefusal('By {$User.Id}')!; + expect(message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL)).toBe(true); + expect(message).toContain("assignments: { v: { dialect: 'cel', source: 'current_user.id' } }"); + expect(message).toContain('`current_user != null ? current_user.id : null`'); + expect(message).toContain('`{{ v }}`'); + expect(message).not.toContain("assignments: { v: '{$User.Id}' }"); + expect(valueSlotTemplateRefusals('{$User.Id}')).toHaveLength(1); + }); + + it('says every other run-user path never resolved, and names the read of the user record', () => { + for (const token of ['{$User.Email}', '{$User.Name}']) { + const message = textSlotTemplateRefusal(`Contact ${token}`)!; + expect(message, token).toContain(`\`${token}\` never resolved in any shipped run`); + expect(message, token).toContain("objectName: 'sys_user'"); + expect(message, token).toContain('`{{ me.email }}`'); + expect(message, token).not.toContain(`assignments: { v: '${token}' }`); + } + }); + // A hole with one brace missing is not an old single-brace token: doubling it // would prescribe `{{{ amount }}` / `{{ amount }}}`, which the engine refuses // too. The judge stays silent and the compile step every door runs next names @@ -127,11 +150,9 @@ describe('textSlotTemplateRefusal — a `{{ }}` hole over a `$` root the engine // Not the single-brace rule: the author wrote a hole. expect(message!.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL)).toBe(false); const remedy = singleBraceRemedy('By {$User.Id}'); - expect(remedy).toContain("assignments: { v: '{$User.Id}' }"); + expect(remedy).toContain("assignments: { v: { dialect: 'cel', source: 'current_user.id' } }"); expect(remedy).toContain('`{{ v }}`'); expect(message!.endsWith(remedy)).toBe(true); - // …and the value-slot spelling that remedy names is one the value slot keeps. - expect(valueSlotTemplateRefusals('{$User.Id}')).toEqual([]); }); it('gives every `$User.` hole its single-brace remedy, formatter or not', () => { @@ -139,7 +160,7 @@ describe('textSlotTemplateRefusal — a `{{ }}` hole over a `$` root the engine ['{{$User.Email}}', '{$User.Email}'], ['Owner: {{ $User.Name | upper }}', '{$User.Name}'], ] as const) { - expect(textSlotTemplateRefusal(text), text).toContain(`assignments: { v: '${single}' }`); + expect(textSlotTemplateRefusal(text), text).toContain(`\`${single}\` never resolved in any shipped run`); } }); @@ -180,7 +201,7 @@ describe('textSlotTemplateRefusal — a `{{ }}` hole over a `$` root the engine const message = textSlotTemplateRefusal('{{ $User.Id }} and {{ $User.Id }} for {owner}')!; expect(message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL)).toBe(true); expect(message).toContain('`{{ $User.Id }} and {{ $User.Id }} for {{ owner }}`'); - expect(message.split("assignments: { v: '{$User.Id}' }")).toHaveLength(2); + expect(message.split("assignments: { v: { dialect: 'cel', source: 'current_user.id' } }")).toHaveLength(2); }); it('leaves a hole that does not compile as a path to the compile step', () => { @@ -241,7 +262,7 @@ describe('ScreenConfigSchema — its two text slots compose the judge', () => { const refused = ScreenConfigSchema.safeParse({ [key]: 'By {{ $User.Id }}' }); expect(refused.success, key).toBe(false); expect(refused.error?.issues.map((i) => [i.code, i.path.join('.')]), key).toEqual([['custom', key]]); - expect(refused.error?.issues[0]!.message, key).toContain("assignments: { v: '{$User.Id}' }"); + expect(refused.error?.issues[0]!.message, key).toContain("assignments: { v: { dialect: 'cel', source: 'current_user.id' } }"); } }); @@ -261,7 +282,7 @@ describe('the node contracts — `By {{ $User.Id }}` is refused at the schema (# const refused = NotifyConfigSchema.safeParse(config); expect(refused.success, key).toBe(false); expect(refused.error?.issues.map((i) => [i.code, i.path.join('.')]), key).toEqual([['custom', key]]); - expect(refused.error?.issues[0]!.message, key).toContain("assignments: { v: '{$User.Id}' }"); + expect(refused.error?.issues[0]!.message, key).toContain("assignments: { v: { dialect: 'cel', source: 'current_user.id' } }"); } }); @@ -269,7 +290,7 @@ describe('the node contracts — `By {{ $User.Id }}` is refused at the schema (# const refused = EndConfigSchema.safeParse({ outcome: 'refused', message: 'Refused by {{ $User.Id }}' }); expect(refused.success).toBe(false); expect(refused.error?.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['custom', 'message']]); - expect(refused.error?.issues[0]!.message).toContain("assignments: { v: '{$User.Id}' }"); + expect(refused.error?.issues[0]!.message).toContain("assignments: { v: { dialect: 'cel', source: 'current_user.id' } }"); }); it('control: `{{ $error.message }}` and `{{ record.name }}` still parse in every text slot', () => { diff --git a/packages/spec/src/automation/flow-text-slot-template.ts b/packages/spec/src/automation/flow-text-slot-template.ts index b022c9d7bcd..a8653a3a52c 100644 --- a/packages/spec/src/automation/flow-text-slot-template.ts +++ b/packages/spec/src/automation/flow-text-slot-template.ts @@ -56,12 +56,15 @@ * * ## No spelling is kept * - * Unlike the value slots, which keep the date macros and `{$User.*}` because - * CEL cannot write them yet, a text slot keeps nothing: each of those has a - * remedy that renders the same text — compute it into a variable with an - * `assignment` node, whose value slot still reads that spelling, and write the - * variable as a hole. So the single brace is deleted from the text slots - * whole, and the two dialects never share one string. + * 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 `$` root is the engine's (#22477) * @@ -75,13 +78,23 @@ * name the engine does not bind ({@link FLOW_ENGINE_VARIABLES}), and a * single-brace path token over one gets the same remedy instead of a * `{{ }}` rewrite that would be refused in turn. `{{ $User. }}` gets - * the very sentence `{$User.}` gets: compute it with an `assignment` - * node, then write `{{ v }}`. ⛔ The template engine does not learn `$User` + * the very sentence `{$User.}` gets: for `$User.Id`, compute + * `current_user.id` with an `assignment` node's CEL envelope, then write + * `{{ v }}`. ⛔ The template engine does not learn `$User` * (or any new `$` root) instead — that would widen the flow's variable set * with no declaration behind it. */ -import { celExpression, templateTokenKind, templateTokensOf, type TemplateToken } from './flow-template-token'; +import { + CEL_RUN_USER_ID, + CEL_RUN_USER_ID_GUARDED, + celExpression, + isRunUserIdToken, + runUserPathNeverResolved, + templateTokenKind, + templateTokensOf, + type TemplateToken, +} from './flow-template-token'; /** * The one sentence every refusal of a `{…}` token in a text slot leads with — @@ -287,11 +300,18 @@ function doubled(text: string, tokens: readonly TemplateToken[]): string { function unspellableRemedy(token: TemplateToken): string { switch (token.kind) { case 'date-macro': - case 'user': 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.` ); + case 'user': + if (!isRunUserIdToken(token.inner)) return runUserPathNeverResolved(token.text, (path) => `\`{{ ${path} }}\``); + return ( + `\`${token.text}\` is not a variable, so no hole spells it: compute the run user's id into a variable with an ` + + `\`assignment\` node's CEL value envelope (\`assignments: { v: { dialect: 'cel', source: '${CEL_RUN_USER_ID}' } }\`) ` + + 'and write `{{ v }}` here. In a flow that can run without a user `current_user` is `null` and that read ' + + `fails the run, so compute \`${CEL_RUN_USER_ID_GUARDED}\` there, which renders nothing where the template did.` + ); case 'expression': return ( `\`${token.text}\` is logic, and a hole is a variable path with an optional formatter (\`{{ v | number:2 }}\`), ` 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 001d6f67e44..15803b11d9f 100644 --- a/packages/spec/src/automation/flow-value-slot-template.test.ts +++ b/packages/spec/src/automation/flow-value-slot-template.test.ts @@ -13,8 +13,11 @@ * `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, - * are not refused — alone or beside another token. + * 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. * 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` @@ -33,7 +36,13 @@ import { FlowValueSlotSchema, UpdateRecordConfigSchema, } from './builtin-node-config.zod'; -import { CEL_CLAIMED_IDENTIFIERS, CEL_KEYWORDS, celPath } from './flow-template-token'; +import { + CEL_CLAIMED_IDENTIFIERS, + CEL_KEYWORDS, + FLOW_SCOPE_CLAIMED_IDENTIFIERS, + celExpression, + celPath, +} from './flow-template-token'; import { VALUE_SLOT_TEMPLATE_REFUSAL, flowNodeValueTemplateRefusals, @@ -153,7 +162,7 @@ describe('a head CEL claims is read through `vars`, the route a `$`-named head t }); 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']) { + for (const ordinary of ['items', 'record', 'timestamp', 'duration', 'dyn', 'lists', 'map_of', 'variables', 'user']) { expect(CEL_CLAIMED_IDENTIFIERS.has(ordinary)).toBe(false); expect(celPath(`${ordinary}.0.key`)).toBe(`${ordinary}[0].key`); } @@ -162,7 +171,29 @@ describe('a head CEL claims is read through `vars`, the route a `$`-named head t }); }); -describe('the two spellings CEL cannot write yet are KEPT — not refused', () => { +/** + * A head the FLOW scope binds over a variable of the same name — `vars` (the + * namespace) and `current_user` (the run user) — is read through `vars` too. + * That each printed spelling evaluates, and the bare one reads the binding + * instead, is pinned through the engine in `service-automation`'s + * `value-slot-template-grammar.test.ts`. + */ +describe('a head the flow CEL scope claims is read through `vars`', () => { + it('the claimed set is exactly `vars` and `current_user`, and neither is CEL\'s', () => { + expect([...FLOW_SCOPE_CLAIMED_IDENTIFIERS].sort()).toEqual(['current_user', 'vars']); + for (const name of FLOW_SCOPE_CLAIMED_IDENTIFIERS) expect(CEL_CLAIMED_IDENTIFIERS.has(name)).toBe(false); + }); + + it.each(['vars', 'current_user'])('%s', (name) => { + expect(celPath(name)).toBe(`vars["${name}"]`); + 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\``); + }); +}); + +describe('the one spelling CEL cannot write yet is KEPT — not refused', () => { it.each([ '{NOW()}', '{TODAY()}', @@ -170,15 +201,100 @@ describe('the two spellings CEL cannot write yet are KEPT — not refused', () = '{TODAY() - 3}', '{TODAY() + expirationDays}', '{NOW() + 2}', - '{$User.Id}', - '{$User.Email}', 'Due {TODAY()} for {name}', - 'Owner: {$User.Id}', + // 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([]); }); }); +/** + * #19939 pass 2 — the maintainer's ruling: Q1 A (`current_user` is the run's + * user, or `null` when it has none; `{$User.*}` refused with the bare read and + * the guard, and the guard's `update_record` consequence), Q2 A (every other + * `$User` path never resolved; read the user record by `current_user.id`). + */ +describe('the run user — `{$User.*}` is refused, with the ruled remedies', () => { + it('`{$User.Id}`: `current_user.id`, and for a user-less run the guard, with what it changes on `update_record`', () => { + const message = refusalOf('{$User.Id}'); + expect(message).toContain("Write `{$User.Id}` as { dialect: 'cel', source: 'current_user.id' }"); + expect(message).toContain("{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }"); + expect(message).toContain('The guarded form writes `null` where the template wrote nothing'); + expect(message).toContain('on `update_record` clears a stored value the template left alone'); + }); + + it.each(['{$User.Email}', '{$User.Name}', '{$User.id}', '{$User.Profile.title}'])( + '%s: it never resolved, and the email or name is read from the user record by `current_user.id`', + (token) => { + const message = refusalOf(token); + expect(message).toContain(`\`${token}\` never resolved in any shipped run`); + expect(message).toContain('`current_user` carries only what the run holds — `id`, `positions`, `organizationId`, `isPlatformAdmin`'); + expect(message).toContain("assignments: { uid: { dialect: 'cel', source: 'current_user.id' } }"); + expect(message).toContain("objectName: 'sys_user'"); + expect(message).toContain("{ dialect: 'cel', source: 'me.email' }"); + expect(message).not.toContain("Write `"); + }, + ); + + it('text with the run user\'s id: one concatenation reading `current_user.id`, and the hole\'s guard', () => { + const message = refusalOf('Owner: {$User.Id}'); + expect(message).toContain(`{ dialect: 'cel', source: "'Owner: ' + current_user.id" }`); + expect(message).toContain("`(current_user != null ? current_user.id : '')`"); + }); + + it('text with another `$User` path: the concatenation leaves it out, as the template did, and says what to read', () => { + const message = refusalOf('Contact {$User.Email} about {name}'); + expect(message).toContain(`{ dialect: 'cel', source: "'Contact ' + ' about ' + name" }`); + expect(message).toContain('`{$User.Email}` never resolved in any shipped run'); + expect(message).toContain('`me.email`'); + }); + + it('the remedies name no tracker number', () => { + for (const token of ['{$User.Id}', '{$User.Email}', 'Owner: {$User.Id}']) { + expect(refusalOf(token)).not.toMatch(/#\d/); + } + }); +}); + +/** + * The EXPRESSION remedy rewrites every variable path inside the expression by + * `celPath`'s rule — not only the divisors — so the printed envelope + * evaluates (that it does is pinned through the built engine in + * `service-automation`'s `value-slot-template-grammar.test.ts`). + */ +describe('an expression token\'s remedy reads each variable path the way a path token\'s does', () => { + it.each([ + ['{int * 2}', 'vars["int"] * 2'], + ['{items.0 * 2}', 'items[0] * 2'], + ['{$error.code + 1}', 'vars["$error"].code + 1'], + ['{round(list.0 * 100) / 100}', 'round(vars["list"][0] * 100) / 100.0'], + ['{vars.0 + 1}', 'vars["vars"][0] + 1'], + ['{record.in + 1}', 'record["in"] + 1'], + ])('%s → %s', (token, source) => { + expect(refusalOf(token)).toContain(`source: ${source.includes("'") ? JSON.stringify(source) : `'${source}'`} }`); + }); + + it('leaves a call, a keyword, a quoted string and a member of a call as written', () => { + expect(celExpression('max(price, 10)')).toBe('max(price, 10)'); + expect(celExpression('flag == true ? 1 : null')).toBe('flag == true ? 1 : null'); + expect(celExpression("name + ' int.0 / 2'")).toBe("name + ' int.0 / 2'"); + expect(celExpression('size(rows).int')).toBe('size(rows).int'); + }); + + it('keeps the divisor rule exactly: an integer divisor only, a double or a member left alone', () => { + expect(celExpression('price/100')).toBe('price/ 100.0'); + expect(celExpression('price / 100.5')).toBe('price / 100.5'); + expect(celExpression('price / 2e3')).toBe('price / 2e3'); + expect(celExpression('10 / 4')).toBe('10 / 4.0'); + }); + + it('a text hole holding an expression is rewritten the same way', () => { + expect(refusalOf('Total {int * 2}')).toContain(`source: "'Total ' + (vars[\\"int\\"] * 2)" }`); + }); +}); + describe('controls — what the judge never refuses', () => { it.each([ ['a token-free string', 'converted'], diff --git a/packages/spec/src/automation/flow-value-slot-template.ts b/packages/spec/src/automation/flow-value-slot-template.ts index 800b2296c27..8ee782870cd 100644 --- a/packages/spec/src/automation/flow-value-slot-template.ts +++ b/packages/spec/src/automation/flow-value-slot-template.ts @@ -35,30 +35,44 @@ * - 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}` — the flow CEL scope binds no user. + * - `{$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`. * * 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. * - * ## Two spellings are KEPT, deliberately — not yet refused + * ## The run user — `{$User.}` — is refused too, with two remedies * - * A refusal must name what to write instead, and for two spellings CEL has - * nothing to name yet: + * The flow CEL scope binds `current_user` (ADR-0068's canonical root): the + * run's `EvalUser` — `id`, `positions`, `organizationId`, `isPlatformAdmin` — + * when the run has a user, and `null` when it has none, never a pseudo-user + * (ADR-0118 D1, D4). So `{$User.Id}` is refused, naming `current_user.id`, + * and for a flow that can run without a user the guard + * `current_user != null ? current_user.id : null`, with what it changes: the + * guarded form writes `null` where the template wrote nothing, which on + * `update_record` clears a stored value the template left alone. * - * - 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 run user — `{$User.}`. The flow CEL scope binds no user, so - * `current_user.id` is an unknown variable in a flow. + * Every other `{$User.}` (`{$User.Email}`, `{$User.Name}`, …) read a + * user object no run carries, so it never resolved in any shipped run: its + * 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. * - * A string whose every token is one of these 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 them now would remove a capability - * with nothing to replace it. Each is retired when CEL can spell it — a string - * form for a Timestamp, and a user binding in the flow CEL scope. + * ## One spelling is KEPT, deliberately — not yet refused + * + * 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. + * + * 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. * * ## The token grammar is the interpolator's * @@ -70,11 +84,15 @@ import { isExpressionEnvelopeShaped, resolveFlowNodeValueSlots } from './flow-node-expression-paths'; import { - CEL_CLAIMED_IDENTIFIERS, CEL_KEYWORDS, + CEL_RUN_USER_ID, + CEL_RUN_USER_ID_GUARDED, TEMPLATE_TOKEN, celExpression, + celHeadReadsThroughVars, celPath, + isRunUserIdToken, + runUserPathNeverResolved, templateTokenKind as tokenKind, templateTokensOf as tokensOf, type TemplateToken as Token, @@ -92,8 +110,8 @@ 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 kinds CEL cannot spell yet, kept until it can (see the module docblock). */ -const KEPT_KINDS: ReadonlySet = new Set(['date-macro', 'user']); +/** 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 { @@ -111,19 +129,40 @@ function envelopeOf(source: string): string { * 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)`). + * 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)`). */ 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 || CEL_CLAIMED_IDENTIFIERS.has(segments[0]!) ? `vars.${path}` : path; + const read = segments.length === 1 || celHeadReadsThroughVars(segments[0]!) ? `vars.${path}` : path; return `has(${read}) ? ${read} : 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()`'; +/** + * 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 { + return ( + `Write \`${text}\` as ${envelopeOf(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\` ` + + 'where the template wrote nothing, which on `update_record` clears a stored value the template left alone.' + ); +} + +/** 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; + 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; @@ -141,6 +180,11 @@ function remedyFor(value: string, tokens: readonly Token[]): string { + (guard ? `: \`${guard}\` (the guarded form writes \`null\`).` : '.') ); } + if (whole?.kind === 'user') { + return isRunUserIdToken(whole.inner) + ? runUserIdRemedy(whole.text) + : runUserPathNeverResolved(whole.text, (path) => envelopeOf(path)); + } if (whole?.kind === 'expression') { return ( `Write \`${whole.text}\` as ${envelopeOf(celExpression(whole.inner))}. Every division keeps a decimal operand: ` @@ -148,23 +192,41 @@ function remedyFor(value: string, tokens: readonly Token[]): string { + '`round(x * 100) / 100.0` keeps them. ' + ABSENT_SENTENCE + '.' ); } - // Text with holes: one CEL concatenation. + // 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(); - parts.push(tokenKind(inner) === 'path' ? celPath(inner) : `(${celExpression(inner)})`); + 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 ( - `\`${value}\` is text with holes: write it as one CEL concatenation, ${envelopeOf(parts.join(' + '))}. Wrap a ` + const sentences = [ + `\`${value}\` is text with holes: write it as one CEL concatenation, ` + + `${envelopeOf(parts.length > 0 ? parts.join(' + ') : "''")}. 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`.' - ); + + '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; + seen.add(token.text); + 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.', + ); + } + return sentences.join(' '); } /** One refused string inside a value slot's value. */ @@ -196,7 +258,7 @@ export interface ValueSlotTemplateOptions { * 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 a kept spelling (a date macro, a `$User` path) is not refused — see + * 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. */ 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 ebc846fe420..6e15d2f85c7 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,8 +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 and the run-user paths ' - + 'as the value-slot spelling that still reads them — and written as {{ variable }}', + + '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 }}', 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-text-slot-unbound-dollar-root-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-text-slot-unbound-dollar-root-refused.ts index 7cbd85d3978..b9785d15440 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-text-slot-unbound-dollar-root-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-text-slot-unbound-dollar-root-refused.ts @@ -16,8 +16,10 @@ export const entry: SemanticMigration = { + '(message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a ' + 'dollar-named variable the flow engine does not bind, such as {{ $User.Id }}', replacement: - 'a variable the run has, written as a hole. The run user is computed first, with an assignment node whose ' - + 'value slot still reads the run-user path (assignments: { by: \'{$User.Id}\' }), then written as {{ by }}. ' + 'a variable the run has, written as a hole. The run user\'s id is computed first, with an assignment node ' + + 'whose CEL value envelope reads current_user, the run\'s user (assignments: { by: { dialect: \'cel\', source: ' + + '\'current_user.id\' } }), then written as {{ by }}; every other run-user path never resolved in any shipped ' + + 'run, and an email or a name is read from the user record by current_user.id. ' + 'A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch ' + 'errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine\'s own ' + 'variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, ' 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 ff6639f7171..62d976bc5c1 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 @@ -5,7 +5,8 @@ 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 and run-user paths CEL cannot write yet are kept. +// 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. export const entry: SemanticMigration = { id: 'flow-value-slot-template-dialect-refused', // No backticks in `surface` — build-upgrade-guide renders it inside a code @@ -13,28 +14,40 @@ export const entry: SemanticMigration = { 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', + + 'string anywhere inside an array or object value, carrying a single-brace template token, the run-user ' + + 'paths beginning $User. 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 ' + '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', + + 'as a double (round(x * 100) / 100.0), and text with holes is one concatenation (\'Hello \' + o.name). The ' + + 'run user\'s id, $User.Id, is current_user.id — current_user is the run\'s user, or null when the run has ' + + 'none — and in a flow that can run without a user it is current_user != null ? current_user.id : null, which ' + + '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', 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 ' + '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.', + + 'decision — the template decided it silently. The run user\'s id was the run\'s userId under the template, ' + + '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.', 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. Re-run the flow paths that write those fields and compare the stored values with ' - + 'the ones the template wrote.', + + '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.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index b9de1669e9c..2c7b509632d 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5773,9 +5773,11 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '`registerFlow`, `objectstack validate` and the executor alike — with the CEL spelling of each token. ' + 'No D2 conversion exists: every authored spelling was measured lossy (an absent key writes nothing ' + 'under the template and fails under CEL; CEL divides two integers as integers), so which value an ' - + 'absent key should write is the author\'s judgment. The date macros and the `$User` paths keep their ' - + 'meaning until CEL can spell them. Its D3 record is the semantic entry ' - + '`flow-value-slot-template-dialect-refused`.', + + '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`.', }, { id: 'flow-write-node-stored-metadata-target-refused', @@ -14222,8 +14224,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 and the run-user paths ' - + 'as the value-slot spelling that still reads them — and written as {{ variable }}', + + '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 }}', 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 ' @@ -14257,8 +14260,10 @@ const step18: MigrationStep = { + '(message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a ' + 'dollar-named variable the flow engine does not bind, such as {{ $User.Id }}', replacement: - 'a variable the run has, written as a hole. The run user is computed first, with an assignment node whose ' - + 'value slot still reads the run-user path (assignments: { by: \'{$User.Id}\' }), then written as {{ by }}. ' + 'a variable the run has, written as a hole. The run user\'s id is computed first, with an assignment node ' + + 'whose CEL value envelope reads current_user, the run\'s user (assignments: { by: { dialect: \'cel\', source: ' + + '\'current_user.id\' } }), then written as {{ by }}; every other run-user path never resolved in any shipped ' + + 'run, and an email or a name is read from the user record by current_user.id. ' + 'A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch ' + 'errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine\'s own ' + 'variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, ' @@ -14319,7 +14324,8 @@ 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 and run-user paths CEL cannot write yet are kept. + // 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. { id: 'flow-value-slot-template-dialect-refused', // No backticks in `surface` — build-upgrade-guide renders it inside a code @@ -14327,30 +14333,42 @@ const step18: MigrationStep = { 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', + + 'string anywhere inside an array or object value, carrying a single-brace template token, the run-user ' + + 'paths beginning $User. 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 ' + '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', + + 'as a double (round(x * 100) / 100.0), and text with holes is one concatenation (\'Hello \' + o.name). The ' + + 'run user\'s id, $User.Id, is current_user.id — current_user is the run\'s user, or null when the run has ' + + 'none — and in a flow that can run without a user it is current_user != null ? current_user.id : null, which ' + + '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', 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 ' + '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.', + + 'decision — the template decided it silently. The run user\'s id was the run\'s userId under the template, ' + + '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.', 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. Re-run the flow paths that write those fields and compare the stored values with ' - + 'the ones the template wrote.', + + '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.', }, // #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