Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/19939-flow-value-slot-run-user-refused.md
Original file line number Diff line number Diff line change
@@ -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.<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.

Clause-②: no (narrowing)

<!-- 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. -->

**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.<path>}` 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.
34 changes: 23 additions & 11 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"]`.

<Callout type="warn">
**The failure modes to memorize:**
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)


---
Expand Down Expand Up @@ -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)


---
Expand Down
9 changes: 5 additions & 4 deletions examples/app-todo/src/flows/task.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
12 changes: 8 additions & 4 deletions packages/cli/src/commands/explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,15 +155,19 @@ export const SCHEMAS: Record<string, SchemaInfo> = {
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.<field>} reads the triggering record.
config: { objectName: 'project_task', triggerType: 'record-after-create',
condition: 'current_user != null' } },
// A filter value interpolates SINGLE braces: {record.<field>} 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' },
],
Expand Down
Loading
Loading