Skip to content

Commit 6a06e3b

Browse files
feat(spec,service-automation)!: refuse {$User.*} in flow value slots, and bind current_user in the flow CEL scope (#19939 pass 2) (#22563)
Part of #19939 Clause-②: no (narrowing) Pass 2 of #19939: the run-user token `{$User.*}` leaves the flow value slots, and the flow CEL scope binds `current_user`. This executes the maintainer's ruling `6063191653` (Q1 A, Q2 A; maintainer 「同意」 2026-10-08). The date macros `{NOW()}` / `{TODAY() ± N}` stay with pass 3 (#19939 remains open), so this PR uses `Part of`. > Q1 — A. The flow CEL scope binds `current_user` to the run's `EvalUser` (id from `userId`, positions, organization from `tenantId`) when the run has a user, and to `null` when it has none, never a pseudo-user (ADR-0118 D1, D4). `{$User.*}` is refused in value slots at registration, the remedy naming `current_user.id`; for a flow that can run without a user the remedy names the guard `current_user != null ? current_user.id : null` and states that the guarded form writes `null` where the template wrote nothing, which on `update_record` clears a stored value the template left alone. The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended; no D2 conversion. > > Q2 — A. `current_user` carries only what the run holds: id, positions, organization, platform-admin flag. `{$User.Email}` and every `{$User.PATH}` other than `Id` retire with a remedy that says they never resolved in any shipped run and names the read of the user record through `current_user.id` for email or name. ## What lands **The binding (`@objectstack/service-automation`).** `AutomationEngine.celScope` binds `current_user` from the run context, through `createEvalUser`: `id` from `userId`, `positions`, `organizationId` from `tenantId`, and the derived `isPlatformAdmin`. A run with no user gets `null`. Every CEL site of a flow now receives the run context: the start-node condition (`runContext`), edge conditions, a screen field's `visibleWhen` on resume (`run.context`), and the `decision`, `assignment`, `create_record` and `update_record` executors. `evaluateCondition` and `evaluateValueEnvelope` take the context as an optional last argument, and without one `current_user` is `null`. `vars` and `current_user` are bound after the variables are spread, so each wins over a flow variable of the same name. **The refusal (`@objectstack/spec`).** `valueSlotTemplateRefusals` no longer keeps the `user` token kind; only the date macros are kept. - `{$User.Id}`: the refusal names `{ dialect: 'cel', source: 'current_user.id' }` and the guard `current_user != null ? current_user.id : null`, with its `update_record` consequence. - Every other `{$User.PATH}`: the refusal says it never resolved in any shipped run and names the read of the user record (`current_user.id` into a variable, a `get_record` on `sys_user`, then `me.email` / `me.name`). - Text with holes: the run user's id becomes `current_user.id` in the concatenation, with the hole's guard `(current_user != null ? current_user.id : '')`. Any other `$User` path is left out of the concatenation, as the template rendered nothing for it, followed by the Q2 sentence. - The text-slot judge's run-user remedy now computes the id with the CEL envelope. It previously named `assignments: { v: '{$User.Id}' }`, which this change refuses. **H4, the expression remedy (carry `6087210395`).** `celExpression` now tokenises the expression and writes every variable path by `celPath`'s rule, not only the divisors: `{int * 2}` → `vars["int"] * 2`, `{items.0 * 2}` → `items[0] * 2`. Calls, keywords, quoted strings and members of a call are left as written, and the divisor rule is byte-identical. **H5, the `vars` head (carry `6088133752`).** `FLOW_SCOPE_CLAIMED_IDENTIFIERS = { vars, current_user }`, measured from `celScope`. A path whose head is either name is printed through `vars`: `vars["vars"][0]`, `vars["current_user"].name`, with `has(vars.vars.tags)` guards. **The ledger.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended (surface, replacement, reason and acceptance criteria name `{$User.*}` and both remedies). The two text-slot entries, `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused`, are amended where their replacement computed the run user through the refused spelling. `registry.ts` was regenerated (`gen:migration-registry`), and the hand-kept step-18 rationale fragment for the value-slot entry was updated. No D2 conversion and no new entry. **Sites, docs, descriptions.** - `examples/app-todo/src/flows/task.flow.ts`: `owner` uses the bare `current_user.id`, because a screen flow always has a user. - `packages/cli/src/commands/explain.ts` (`os explain flow`): the example is an `update_record` under `record-after-create`, which a system write fires with no user. It now writes `current_user.id` and gates on `condition: 'current_user != null'`, so a user-less run leaves the stored value alone, as the template did. Its test is updated. - `examples/app-showcase/src/automation/flows/index.ts:1936` (`recipients: ['{record.assignee}', '{$User.Id}']`) is classified, not migrated. `recipients` is neither a text slot (#22110's `FLOW_NODE_TEXT_SLOTS`) nor a ledger value slot (the `value` role is only `assignment.assignments.*` and `create_record` / `update_record` `fields.*`). The judge does not refuse it, and the interpolator still resolves it. - `content/docs/automation/flows.mdx`: the FROM → TO table gains the two run-user rows, the text-slot table gains the CEL remedy, the dialect table names `current_user`, and a paragraph defines it (user-less `null`, no email or name, the shadowing of a variable named `current_user` / `vars`). - `FlowValueSlotSchema` / `AssignmentValueSchema` `.describe()`: "the date macros are kept for now". The references were regenerated (`gen:docs`, `builtin-node-config.mdx`). **Patch round 1 (seat answers `6092066429`): two remedies this card made false.** - `flow-bare-dollar-reference` (`packages/lint/src/lint-flow-patterns.ts`). The hint now depends on the slot the string sits in. In a value slot it names the CEL envelope the spec's value-slot judge writes for the referenced token: `current_user.id` and its guard for `$User.Id`, the `sys_user` read for any other `$User` path, `vars["$error"].message` for an engine-bound root, and the variable without the `$` for any other root. Whether a position is a value slot is asked of the judge with a probe token at the string's path, so there is no second list. In a text slot, and at every other position, the hint is unchanged. Each prescribed envelope, put back in its slot, passes the value-slot judge, `validateStackExpressions`, `FlowSchema` and the #1315 lint (0 findings each). - `packages/services/service-automation/README.md` "Expressions" (shipped in `files`) is restated as this pass leaves the dialect. A value slot takes the CEL envelope and reads a plain string as its literal text, `current_user` is the run user (null with no user), and `{$User.*}` is refused with its remedy. The date macros stay read until pass 3, and the other value-like positions keep the single brace. - The changeset gains `'@objectstack/lint': patch`. ## Measured premises (H1–H5, on `86bf9ed7d5`) - **H1.** `current_user` was not bound: `celScope` returned `{ ...vars, vars }` only, and neither #22110 nor #22319 bound it. Bound here per Q1 and pinned with a user and without one (`flow-cel-current-user.test.ts`). - **H2.** Two value-slot sites, migrated as above. The showcase `recipients` site is classified "neither". - **H3.** Built on the judge as #22315 left it. - **Riders.** The six rider positions (`subflow.input`, `map.input`, `script.inputs`, `screen.defaults`, a screen field's `defaultValue`, `http`) are value-like positions that keep the single-brace dialect by #22110's own classification. None is a ledger `value` slot, and none evaluates a CEL envelope, so a refusal there would have no remedy to name. They are not brought under the judge here (see Acceptance notes). - **Kept-spelling leak.** `{{ }}` does not apply to value slots. A run-user token beside a date macro (`'Due {TODAY()} by {$User.Id}'`) is still kept whole (pinned) until pass 3 retires the macros. - **#22319** had already landed on the base. `main` (`4638625e07`) was merged cleanly. - **H4.** Both measured cases are pinned with the evaluated remedy through `evaluateValueEnvelope`: `{int * 2}` evaluates to 10, the value the interpolator computed. `{items.0 * 2}` evaluates to 6; the interpolator computed nothing there. - **H5.** `vars` is a claimed head, with the reason and the pin. `current_user` joins it for the same reason, since this change binds it after the spread. ## Surface crossings (the claim's file list, and why each crossing was needed) The claim names the judge, the token module, `builtin/template.ts`, `engine.ts`, `validate-expressions.ts` (where the value-slot refusal surfaces), the three sites, the value-slot D3 entry and `registry.ts`, `flows.mdx` and one changeset, each with its tests. These files are outside that list. Each one was either needed for the change to work or would have stated something false after it: - **`service-automation/src/builtin/crud-nodes.ts`, `logic-nodes.ts`.** One argument each: the run context passed to `evaluateValueEnvelope` / `evaluateCondition`. Without it, `fields` / `assignments` envelopes and `decision` conditions would see `current_user` as `null` in every run. Their docblocks also said `{$User.*}` was kept. - **`spec/src/automation/flow-text-slot-template.ts`** (and its test). Its run-user remedy named `assignments: { v: '{$User.Id}' }`, a spelling this change refuses. - **`spec/src/automation/builtin-node-config.zod.ts`** (and its test), with the regenerated `content/docs/references/automation/builtin-node-config.mdx`. The two value-slot `.describe()` strings said the `$User` paths are kept. - **`18.flow-text-slot-single-brace-refused.ts`, `18.flow-text-slot-unbound-dollar-root-refused.ts`**, and the hand-kept step-18 rationale fragment in `registry.ts`. Their replacement text computed the run user through the refused spelling. - **Tests that pinned the kept spelling or the old remedy.** In lint: `lint-flow-patterns.test.ts` and `validate-expressions.text-slot.test.ts`. In service-automation: `text-slot-template.test.ts`, `logic-nodes.test.ts` and `crud-fields-value-envelope.test.ts`. One new test file: `flow-cel-current-user.test.ts`. `packages/lint` changes `lint-flow-patterns.ts` in patch round 1 (above), the files the seat's answer `6092066429` added, with `service-automation/README.md`. No governed surface is touched (`skills/**`, `.claude/**`, `docs/adr/**`, `AGENTS.md`). ## Tests and gates (head `caeaab982f`) Head is `caeaab982f`, a merge of `main` at `4638625e07`. The build is the dependency closures of service-automation, lint and cli (58 tasks), then the whole workspace minus docs (72 tasks). - `@objectstack/spec` local project: 635 files, 18974 passed and 1 todo. Repo project: 54 files, 915 passed. Both ran at `caeaab982f`. - `@objectstack/service-automation` full suite: 181 files, 2284 passed at `caeaab982f`. Typecheck exit 0, with `check:test-typecheck` OK. - `@objectstack/lint` full suite: 133 files, 6092 passed. `@objectstack/cli` unit `test/commands.test.ts` and `test/explain-rule-id.test.ts`: 62 passed. Typecheck exit 0 for spec, lint and cli. All of these ran at `a86164b899`; the only later commit changes three service-automation test files. - Gates: `dispatch-gates --commands` re-derived on the actual diff gives 122 commands, a superset of the 93 at dispatch (the added families include `check:engine-double-contract`, `check:type-check-coverage`, the docs families and `check:generated`). All 122 ran at `caeaab982f`, and `dispatch-gates --ran` reports 122 derived, 122 run, 0 NOT-MEASURED, 0 UNRUN. - Five exited 3 (PREREQUISITE NOT MET) on the first pass: `check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity`. All five read built output that did not exist yet. Each exited 0 after the workspace build. - `check:empty-changeset`: green. No pending changeset of another change is edited (see Acceptance notes). - Repo lint, narrowed and proven: - ① The population is `eslint . --no-inline-config` over `eslint.config.mjs`. - ② `--format json` over the 26 changed `.ts` files reports 26 linted, 0 ignored, 0 errors and 0 warnings. - ③ That config enables no type-aware linting (no `parserOptions.project`, no typed rules; `eslint.config.mjs` says so at its type-aware note), so this diff cannot move any verdict on a file it does not touch. The full `pnpm lint` is CI's. **Patch round 1 at `c5a343c32a`** (no merge of `main`; the PR reads mergeable): `@objectstack/lint` full suite 133 files, 6102 passed; lint typecheck exit 0. `dispatch-gates --commands` re-derives the same 122, all exit 0 at `c5a343c32a`, and `--ran` reports 0 NOT-MEASURED and 0 UNRUN. `check:published-readme-links`, `check:published-readme-exports`, `check:nul-bytes` and `check:doc-authoring` exit 0. Narrowed lint over the 27 changed `.ts` files: 0 errors, 0 warnings. Spec, service-automation and cli code is untouched by the round, so their `caeaab982f` readings stand. ## Ablations Three ablations, one per behaviour, each through `scripts/ablation-replace.mjs` in WRAP mode under the verify lock, from the committed state `caeaab982f`. Each mutation is verified on disk (anchor x1 → x0, blob changed), and each restore is proven blob == HEAD with an empty `git diff HEAD`. - **A1, the old judge branch.** `KEPT_KINDS` back to `['date-macro', 'user']`. `flow-value-slot-template.test.ts` goes red: 8 failed, 93 passed. Every run-user refusal pin fails. Restored to blob `8ee782870cd2`. - **A2, the divisor-only `celExpression`.** The old `inner.replace(/\/\s*(\d+).../g, '/ $1.0')` body put back. The same file goes red: 9 failed, 92 passed. The H4 pins `{int * 2}` and `{items.0 * 2}` fail, as do the call/keyword/quoted-string controls. Restored to blob `8b5be3d9cad1`. - **A3, the binding removed.** `celScope` back to `{ ...vars, vars }`. `flow-cel-current-user.test.ts` goes red: 13 failed, 1 passed. The one pass is the control that a bare `current_user.id` faults without a user, which holds unbound too. Restored to blob `d17b32e3e677`. - **A4, the old lint hint** (patch round 1, from committed `a7d0cb1b34`): the firing site's hint put back for every position. `lint-flow-patterns.test.ts` goes red, 7 failed and 191 passed, exactly the 7 value-slot pins. The text-slot pin and both controls stay green. Restored to blob `ee30b6ea9c22`. A1 and A2 resolve the judge from `src` (spec's own tests), and A3 resolves `engine.ts` by a relative import, so no `dist/` sits on any ablation's path. ## Acceptance notes Nothing here is filed. Each item is for the seat: - **Pending release notes this change makes false.** Three pending changesets still state what this change retires: - pass 1's `.changeset/19939-flow-value-slot-template-dialect-refused.md` lists `{$User.PATH}` as still accepted; - #22110's `.changeset/22110-flow-text-slot-double-brace.md` has the row `'By {$User.Id}'` → an `assignment` whose value slot still reads it; - #22477's `.changeset/22477-flow-text-slot-dollar-root-refused.md` has `assignments: { by: '{$User.Id}' }`. This PR's changeset states that it supersedes those lines, and it carries the corrected FROM → TO row. Correcting them in place is `check:empty-changeset`'s "DELIBERATE CORRECTION" path, which keeps the required gate red until a person confirms. That is a release decision, so it was not taken here. - **The other `current_user` aliases.** The ruling binds `current_user` only. `user.id`, `ctx.user.id` and `os.user.id` in a flow condition pass `validateStackExpressions` with 0 issues and fault at run time with `Unknown variable: user` / `ctx` / `os` (measured at `caeaab982f`). ADR-0068 mounts one EvalUser under all four names elsewhere. → filed as #22565. - **The six rider positions** (`subflow.input`, `map.input`, `script.inputs`, `screen.defaults`, a screen field's `defaultValue`, `http`) keep the single-brace dialect. None is a ledger value slot or evaluates a CEL envelope, so retiring the dialect there needs a value-slot declaration and executor evaluation per position. That is a separate decision, not this pass. - **The kept-spelling leak.** `'Due {TODAY()} by {$User.Id}'` is kept whole until pass 3 retires the date macros (pinned as kept). - **`skills/objectstack-automation/SKILL.md`** (Tier H; #22260 is closed, so the seat files a skills-lane card when this PR lands) still teaches `{$User.PATH}` as kept and the text-slot run-user remedy through the value slot. - **HotCRM's 5 `{$User.Id}` sites** (pass 1's census) are in another repository, on the seat's handover ledger. - **The judge's remedy at positions where an envelope is literal data** (carried to pass 3 on #19939). At an element of the legacy `assignments` array (`assignments[0].value`), and at a string nested inside an object literal in a value slot, the judge refuses `{$User.Id}` and names the CEL envelope. The prescribed metadata then passes the build door and `registerFlow`, and the run stores the envelope OBJECT as the value. The canonical map form evaluates correctly. This has held since pass 1 for every token at those positions. The lint hint inherits it by design, because it asks the judge, so one fix in the judge reaches both. - **`flow-double-brace-interpolation`'s hint in a value slot** says `Use {var} (e.g. {record.title})`, which the value-slot judge refuses. False since pass 1; the slot-aware remedy is a design choice. Carried to pass 3 with the item above. - Two #1315 false-positive guards in `lint-flow-patterns.test.ts` are titled as endorsing spellings the value-slot judge refuses ("correct single-brace interpolation", "a braced $User reference"). Their assertions hold; only the titles mislead. - **`Clause-②`.** The claim line reads `no`, and the dispatch prescribes `no (narrowing)`. This PR uses the dispatch's spelling, which is the same value with its direction arm stated. --- _Generated by [Claude Code](https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5495331 commit 6a06e3b

31 files changed

Lines changed: 1378 additions & 207 deletions
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@objectstack/spec': major
3+
'@objectstack/service-automation': major
4+
'@objectstack/cli': patch
5+
'@objectstack/lint': patch
6+
---
7+
8+
A flow VALUE slot now refuses the run-user template token `{$User.<path>}` 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.
9+
10+
Clause-②: no (narrowing)
11+
12+
<!-- adr-0087: not-required (already-registered flow-value-slot-template-dialect-refused, flow-text-slot-single-brace-refused, flow-text-slot-unbound-dollar-root-refused) The value-slot retirement's step-18 D3 entry, registered on this line before this change, is amended in this diff to refuse the run-user paths and name their remedies; the two text-slot entries are amended where their remedy computed the run user through the value-slot spelling this change refuses. No new D3 entry and no D2 conversion: a user-less run answers the template's nothing with null or a fault, which no conversion can make lossless. -->
13+
14+
**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.*`).
15+
16+
**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.
17+
18+
**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.
19+
20+
**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.
21+
22+
**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.<path>}` among the spellings still accepted, and the text-slot entries compute the run user through it; this entry supersedes those lines.
23+
24+
## FROM → TO
25+
26+
| you wrote | write instead | what changes |
27+
|:--|:--|:--|
28+
| `'{$User.Id}'` | `{ dialect: 'cel', source: 'current_user.id' }` | nothing when the run has a user |
29+
| `'{$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 |
30+
| `'{$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 |
31+
| `'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 : '')` |
32+
| 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 |
33+
34+
**The one-line fix: write `current_user.id` where you wrote `{$User.Id}`, and guard it where the flow can run without a user.**
35+
36+
**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.
37+
38+
### The kit
39+
40+
- **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`.
41+
- **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`); the text-slot judge's run-user remedy (`textSlotTemplateRefusal`) now computes the id with the CEL envelope.
42+
- **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.
43+
- **`@objectstack/cli`.** `os explain flow`'s example writes `current_user.id` and gates on `current_user != null`.
44+
- **`@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.
45+
- **`@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.

‎content/docs/automation/flows.mdx‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -271,11 +271,13 @@ for some input, so the rewrite is yours to judge.
271271
| `'{round(x * 100) / 100}'` | `source: 'round(x * 100) / 100.0'` | CEL divides two integers as integers: keep a decimal operand on every division |
272272
| `'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(…, '')` |
273273
| braces meant literally, `'{"a": 1}'` | `source: "'{\"a\": 1}'"` | a CEL string literal |
274+
| `'{$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` |
275+
| `'{$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` |
274276

275-
Two spellings **keep** their meaning for now, because CEL cannot write them yet:
277+
One spelling **keeps** its meaning for now, because CEL cannot write it yet:
276278
the date macros (`'{NOW()}'`, `'{TODAY() + 7}'` — CEL's `now()` / `today()` are
277279
timestamps, not the ISO text the macros write, and there is no string form for
278-
one) and the run user (`'{$User.Id}'` — the flow's CEL scope binds no user).
280+
one).
279281

280282
[#19939]: https://github.com/objectstack-ai/objectstack/issues/19939
281283

@@ -325,7 +327,8 @@ check.
325327
| `'Deal won: {record.name}'` | `'Deal won: {{ record.name }}'` |
326328
| `'Failed: {$error.message}'` | `'Failed: {{ $error.message }}'` |
327329
| `'Total {amount * 2}'`, `'{round(x)}'` | compute it into a variable (`assignments: { v: { dialect: 'cel', source: 'amount * 2' } }`), then `'Total {{ v }}'` |
328-
| `'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 }}'` |
330+
| `'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 }}'` |
331+
| `'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 |
329332

330333
[#22110]: https://github.com/objectstack-ai/objectstack/issues/22110
331334

@@ -1990,21 +1993,30 @@ slot still reads.
19901993

19911994
| Where | Dialect | Write it like | Bindings |
19921995
|:---|:---|:---|:---|
1993-
| Start-node `condition` | **CEL** (bare, no braces) | `record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*` |
1996+
| Start-node `condition` | **CEL** (bare, no braces) | `record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*`, `current_user` |
19941997
| Edge `condition` | **CEL** (bare, no braces) | `record.status == 'open'` | same as above |
1995-
| Decision-node `conditions[].expression` | **CEL** (bare, no braces) | `order_amount > 10000` | flow variables by name, and `vars.*` |
1996-
| 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) |
1997-
| 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`, …) |
1998+
| Decision-node `conditions[].expression` | **CEL** (bare, no braces) | `order_amount > 10000` | flow variables by name, `vars.*` and `current_user` |
1999+
| 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) |
2000+
| 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`, …) |
19982001
| 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 |
19992002

20002003
A value slot takes either form, chosen by shape: an object naming a `dialect`
20012004
is a CEL envelope, everything else is a literal. The `{token}` template dialect
20022005
it used to read is retired: `objectstack validate`, `registerFlow` and the
20032006
executor refuse a `{…}` token in a value slot with its CEL spelling (see *The
2004-
`{…}` template dialect is retired from value slots* above). The date macros and
2005-
`{$User.*}` are kept until CEL can write them: CEL's `now()` / `today()` are
2006-
timestamps, not the strings the macros write, and the flow's CEL scope binds no
2007-
user.
2007+
`{…}` template dialect is retired from value slots* above). The date macros are
2008+
kept until CEL can write them: CEL's `now()` / `today()` are timestamps, not the
2009+
strings the macros write.
2010+
2011+
Every CEL expression in a flow — a condition or a value envelope — sees
2012+
**`current_user`**, the run's user: `current_user.id`, `current_user.positions`,
2013+
`current_user.organizationId`, `current_user.isPlatformAdmin`. It carries only
2014+
what the run holds, so there is no email or name on it (read the user record by
2015+
`current_user.id`). In a run with **no user** — a schedule, a record change made
2016+
by a system write — `current_user` is `null`, never a stand-in user: guard a read
2017+
with `current_user != null`, or skip the work with that start condition. A flow
2018+
variable named `current_user` or `vars` is shadowed by the scope's own binding
2019+
and is read as `vars["current_user"]` / `vars["vars"]`.
20082020

20092021
<Callout type="warn">
20102022
**The failure modes to memorize:**

‎content/docs/references/automation/builtin-node-config.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ CEL value envelope `{ dialect: 'cel', source }` — evaluated by the expression
135135

136136
## AssignmentValue
137137

138-
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)
138+
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)
139139

140140

141141
---
@@ -180,7 +180,7 @@ Value the variable takes: a CEL value envelope `{ dialect: 'cel', source }` eval
180180

181181
## FlowValueSlot
182182

183-
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)
183+
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)
184184

185185

186186
---

‎examples/app-todo/src/flows/task.flow.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -472,16 +472,17 @@ export const QuickAddTaskFlow: Flow = {
472472
// CEL value envelopes — the `{…}` template dialect is retired from
473473
// value slots. `subject` is a required screen field; the others may be
474474
// left empty, and CEL refuses an absent variable where the template
475-
// wrote nothing, so they are guarded with `has()`. `{$User.Id}` is one
476-
// of the two spellings the retirement keeps until CEL can write it (the
477-
// flow CEL scope binds no user yet), so it stays as authored.
475+
// wrote nothing, so they are guarded with `has()`. The owner is the
476+
// run's user, `current_user` — a screen flow always has one, so the
477+
// id is read bare (a flow that can run without a user guards it:
478+
// `current_user != null ? current_user.id : null`).
478479
fields: {
479480
subject: { dialect: 'cel', source: 'subject' },
480481
priority: { dialect: 'cel', source: 'has(vars.priority) ? vars.priority : null' },
481482
due_date: { dialect: 'cel', source: 'has(vars.dueDate) ? vars.dueDate : null' },
482483
category: { dialect: 'cel', source: 'has(vars.category) ? vars.category : null' },
483484
status: 'not_started',
484-
owner: '{$User.Id}',
485+
owner: { dialect: 'cel', source: 'current_user.id' },
485486
},
486487
outputVariable: 'newTaskId',
487488
},

‎packages/cli/src/commands/explain.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,15 +155,19 @@ export const SCHEMAS: Record<string, SchemaInfo> = {
155155
nodes: [
156156
// A record-change flow binds its object on the START node's config,
157157
// not at the flow top level.
158+
// current_user is the acting user, or null when no user made the write
159+
// (a system write): the start condition skips those runs, so the stored
160+
// value is left alone there.
158161
{ id: 'start', type: 'start', label: 'On Task Create',
159-
config: { objectName: 'project_task', triggerType: 'record-after-create' } },
160-
// Values interpolate with SINGLE braces. {$User.Id} is the acting user;
161-
// {record.<field>} reads the triggering record.
162+
config: { objectName: 'project_task', triggerType: 'record-after-create',
163+
condition: 'current_user != null' } },
164+
// A filter value interpolates SINGLE braces: {record.<field>} reads the
165+
// triggering record. A field value is a CEL value envelope.
162166
{ id: 'assign', type: 'update_record', label: 'Assign to Actor',
163167
config: {
164168
objectName: 'project_task',
165169
filter: { id: '{record.id}' },
166-
fields: { assigned_to: '{$User.Id}' },
170+
fields: { assigned_to: { dialect: 'cel', source: 'current_user.id' } },
167171
} },
168172
{ id: 'done', type: 'end', label: 'Done' },
169173
],

0 commit comments

Comments
 (0)