|
| 1 | +--- |
| 2 | +'@objectstack/lint': major |
| 3 | +'@objectstack/spec': major |
| 4 | +--- |
| 5 | + |
| 6 | +A flow CEL expression that reads a root the flow does not bind is refused at `objectstack validate` and at the runtime publish gate. The run fails such an expression with `Unknown variable: X` (X being the root) on every evaluation, and the build door used to pass it. The reachable case is the run user: `user.id == "u1"`, `ctx.user.id == "u1"` and `os.user.id == "u1"` — the spellings formulas, row-level security and the client accept — are refused, naming `current_user`, the one spelling flow CEL binds. |
| 7 | + |
| 8 | +Clause-②: no (narrowing) |
| 9 | + |
| 10 | +<!-- adr-0087: registered flow-cel-unbound-root-refused --> |
| 11 | + |
| 12 | +**BREAKING**: an accept-set narrowing on a published authoring surface (`objectstack validate`, and the runtime publish gate's `expression-invalid` rule for a flow write). |
| 13 | + |
| 14 | +**Why.** Flow CEL evaluates in one scope per run: the flow's variables spread to top level, the trigger record's fields flattened beside them, and `record`, `previous`, `vars` and `current_user` bound by the engine. Formulas, row-level security and the client also bind the run's user as `user`, `ctx.user` and `os.user`; flow CEL does not, because a top-level `user` would collide with a variable or a lookup field of that name. So an author who wrote the alias by habit shipped a flow that faulted at its first evaluation, and nothing warned. |
| 15 | + |
| 16 | +**What is refused.** At every flow CEL site the build door reads — a node's `config.condition` (the start node's trigger gate included), an edge `condition`, a `decision` branch `expression`, a screen field `visibleWhen`, and the CEL value envelopes of an `assignment` and of a `create_record` / `update_record` `fields` map — a root that is none of: |
| 17 | + |
| 18 | +- the engine's roots: `record`, `previous`, `vars`, `current_user`; |
| 19 | +- a name the flow binds: a declared variable, an `outputVariable`, an `iteratorVariable` (a `loop`'s default `item` included), an `indexVariable`, an `errorVariable`, an `assignment` target, a node id, a screen field's `name`; |
| 20 | +- a field (or a registry-injected column) of any object the stack declares — the run flattens whichever record its entrance hands it. |
| 21 | + |
| 22 | +The user spellings are told to write `current_user`, with the guard for a flow that can run without a user; any other root is named with what could have bound it, and the nearest in-scope name. The error is `expression-invalid`, `error`. Where the flattened-scope near-miss advisory ("`X` is not a field of … If `X` is a flow variable this is safe to ignore") named the same root, the refusal replaces it. |
| 23 | + |
| 24 | +**Not judged — the door stands down where the run can bind a name it cannot see.** A flow carrying a `script` or `connector_action` node (each hands its code the live variable map), a `wait`, `subflow` or `map` node (a resume folds the caller's `variables` bag in under their plain names), a screen with no field list, a node type outside the builtin set, or a trigger object the stack does not declare. A `$` name has no CEL spelling (`$runId == "x"` does not parse), so the `$` roots stay the text-slot rule's. |
| 25 | + |
| 26 | +## FROM → TO |
| 27 | + |
| 28 | +| you wrote | write instead | |
| 29 | +|:--|:--| |
| 30 | +| `condition: 'user.id == "u1"'` | `condition: 'current_user.id == "u1"'` (`current_user != null && current_user.id == "u1"` where the flow can run without a user) | |
| 31 | +| `condition: 'ctx.user.id == record.owner'` | `condition: 'current_user.id == record.owner'` | |
| 32 | +| `assignments: { who: { dialect: 'cel', source: 'os.user.id' } }` | `assignments: { who: { dialect: 'cel', source: 'current_user.id' } }` | |
| 33 | +| `condition: 'deals.size() > 0'` with nothing binding `deals` | declare it (`variables: [{ name: 'deals', type: 'list' }]`) or bind it with a node (`outputVariable: 'deals'`) | |
| 34 | + |
| 35 | +**The one-line fix: write `current_user` for the run user, and bind or correct every other root the refusal names.** |
| 36 | + |
| 37 | +**Who is affected, measured.** The last published spec line, 17.x, judged a flow expression's syntax only, so it accepts every refused source. Measured with the judge over every flow CEL site: the example apps (`app-crm`, `app-todo`, `app-multi-package`, `app-showcase`: 35 flows, 45 sites), `packages/platform-objects` (no flows) and `objectstack-ai/hotcrm` at `f0afcbda07` (32 flows, 65 sites) — 0 refusals; every root those flows read is an engine root, a binding of the flow's own or a field of its trigger object. Deployed metadata and other repositories were not measured. |
| 38 | + |
| 39 | +### The kit |
| 40 | + |
| 41 | +- **The judge.** `flow-cel-root-scope.ts` in `@objectstack/lint`, called by `validateStackExpressions` at each flow CEL site. One bound set per flow, read once like the shadowing pass's variable set. |
| 42 | +- **The ledger.** The D3 semantic entry `flow-cel-unbound-root-refused` (protocol 18). There is no D2 conversion: none of these roots ever evaluated in a flow, so there is no behaviour to carry over. |
0 commit comments