|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * @module flow-cel-root-scope |
| 5 | + * |
| 6 | + * **The build door's judge of the ROOTS a flow CEL expression reads.** A flow |
| 7 | + * expression whose root identifier the run does not bind fails that run with |
| 8 | + * `Unknown variable: X` — measured on the flow scope the engine builds |
| 9 | + * (`AutomationEngine.celScope` over `seedRunVariables`' map): `user.id == "u1"`, |
| 10 | + * `ctx.user.id == "u1"`, `os.user.id == "u1"` and `foo.bar == 1` each fault, while |
| 11 | + * `objectstack validate` answered nothing for any of them. The three user |
| 12 | + * spellings are the trap: formulas, row-level security and the client bind the |
| 13 | + * run's user under them (`buildScope` in `@objectstack/formula`), and flow CEL |
| 14 | + * binds it as `current_user` only, so an author who writes the alias by habit |
| 15 | + * ships a flow that faults. This module refuses such a root at build, naming |
| 16 | + * `current_user` for the user spellings and the in-scope names for any other. |
| 17 | + * |
| 18 | + * ## One judge, every flow CEL site |
| 19 | + * |
| 20 | + * The caller (`validateStackExpressions`) hands it each flow CEL source it |
| 21 | + * already visits: a node's `config.condition` (the start node's is the trigger |
| 22 | + * gate), an edge's `condition`, a `decision` branch's `expression`, a `screen` |
| 23 | + * field's `visibleWhen`, and the CEL value envelopes of an `assignment` and of a |
| 24 | + * `create_record` / `update_record` `fields` map. All of them evaluate in the |
| 25 | + * one scope `celScope` builds, so one bound set per flow judges them all. |
| 26 | + * |
| 27 | + * ## The bound set — the flow's own static bindings |
| 28 | + * |
| 29 | + * - **the engine's roots**: `record` and `previous` (`seedRunVariables` binds |
| 30 | + * `previous` on every run, `null` on a create; `celScope` binds `record` to |
| 31 | + * the trigger record, or to the variables themselves when there is none), |
| 32 | + * `vars` and `current_user` (`celScope` binds both after the spread). A |
| 33 | + * `$`-named variable has no CEL spelling at all — `$runId == "x"` does not |
| 34 | + * parse — so the `$` names never reach this judge: they stay the text-slot |
| 35 | + * rule's (`flow-text-slot-template.ts`), and this module keeps no list of them; |
| 36 | + * - **every name the flow binds**: {@link collectFlowVariableNames} — declared |
| 37 | + * variables, `outputVariable` / `errorVariable` / `iteratorVariable` / |
| 38 | + * `indexVariable`, `assignment` targets, node ids — plus the two bindings that |
| 39 | + * reader does not collect: a `screen` field's `name` (a resume lands the |
| 40 | + * collected values under their plain names) and a `loop`'s default iterator |
| 41 | + * `item` when the node names none; |
| 42 | + * - **record fields**: the trigger record's fields are flattened to top-level |
| 43 | + * names. Every field of EVERY object the stack declares counts, with the |
| 44 | + * registry-injected columns — not only the trigger object's — because the run |
| 45 | + * flattens whichever record its entrance hands it: a flow action passes the |
| 46 | + * action's own record to any flow it starts, a `subflow` child inherits its |
| 47 | + * parent's record, and a `map` child gets each record item. A field the stack |
| 48 | + * declares anywhere is therefore a root this door cannot prove unbound. |
| 49 | + * |
| 50 | + * ## When the door cannot prove a root unbound, it stands down |
| 51 | + * |
| 52 | + * A false refusal of a working flow is worse than the trap, so the whole flow is |
| 53 | + * left unjudged when its run can bind a name the reader cannot see: |
| 54 | + * |
| 55 | + * - a node whose type is not one of {@link CLOSED_BINDER_NODE_TYPES}. The node |
| 56 | + * type namespace is open (ADR-0018), and the executors that hand out the LIVE |
| 57 | + * variable map are open by measurement: `script` (a registered function gets |
| 58 | + * `FlowFunctionContext.variables`) and `connector_action` |
| 59 | + * (`ConnectorActionContext.variables`) may write any name; `wait`, `subflow` |
| 60 | + * and `map` declare `resumeAuthority: 'any'`, and a resume folds the caller's |
| 61 | + * `variables` bag in under its plain names (`applyResumeSignal`); |
| 62 | + * - a `screen` with no input contract — an `object-form` screen, or one that |
| 63 | + * declares no fields — because `refuseInvalidScreenInput` checks a resume bag |
| 64 | + * only against a declared field list, so a contract-less screen folds any bag; |
| 65 | + * - a trigger object the stack does not declare (a platform object, another |
| 66 | + * package's): its row's keys are not in hand. |
| 67 | + * |
| 68 | + * ⚠️ The boundary that remains: a record of an object this stack does not |
| 69 | + * declare, handed in by a flow action, a parent `subflow` or a `map` item, can |
| 70 | + * carry a key the reader has never seen. Measured over the example apps and |
| 71 | + * hotcrm, no flow reads a bare root that is not its own binding, an engine root |
| 72 | + * or a field of its trigger object. |
| 73 | + */ |
| 74 | + |
| 75 | +import { firstUndeclaredReference, parseCelToAst, SCOPE_ROOTS } from '@objectstack/formula'; |
| 76 | +import { |
| 77 | + APPROVAL_NODE_TYPE, |
| 78 | + APPROVAL_REVISE_NODE_TYPE, |
| 79 | + LOOP_NODE_TYPE, |
| 80 | + PARALLEL_NODE_TYPE, |
| 81 | + TRY_CATCH_NODE_TYPE, |
| 82 | +} from '@objectstack/spec/automation'; |
| 83 | + |
| 84 | +import { collectFlowVariableNames, type FlowGraphLike, type FlowVariableHost } from './flow-variable-scope.js'; |
| 85 | +import { nearestName, recordsOf } from './object-graph.js'; |
| 86 | + |
| 87 | +type AnyRec = Record<string, unknown>; |
| 88 | + |
| 89 | +/** |
| 90 | + * The roots the flow engine binds on every run, whatever the flow declares: |
| 91 | + * `record` and `previous` (`seedRunVariables`, and `celScope`'s `record` arm), |
| 92 | + * `vars` and `current_user` (bound by `celScope` after the variables are spread). |
| 93 | + * Not the `$` names — those have no CEL spelling (module note). |
| 94 | + */ |
| 95 | +const ENGINE_BOUND_ROOTS: readonly string[] = ['record', 'previous', 'vars', 'current_user']; |
| 96 | + |
| 97 | +/** |
| 98 | + * The node types whose variable writes this reader models completely — every |
| 99 | + * name each one can bind is a key of its own `config` that |
| 100 | + * {@link collectFlowVariableNames} or {@link flowCelRootScope} reads, or its node |
| 101 | + * id. Measured from the executors: |
| 102 | + * |
| 103 | + * | type | binds | |
| 104 | + * |---|---| |
| 105 | + * | `start`, `end`, `decision`, `http`, `notify`, `delete_record` | nothing but its node id's outputs | |
| 106 | + * | `assignment` | its targets | |
| 107 | + * | `create_record`, `update_record`, `get_record` | `outputVariable` | |
| 108 | + * | `loop` | `iteratorVariable` (default `item`), `indexVariable` | |
| 109 | + * | `parallel` | nothing; its branches are graphs of their own | |
| 110 | + * | `try_catch` | `errorVariable` (default `$error`) | |
| 111 | + * | `screen` with an input contract | its fields' names, `outputVariable` | |
| 112 | + * | `approval`, `approval_revise` | nothing; their service resumes with outputs only (`resumeAuthority: 'service'`) | |
| 113 | + * |
| 114 | + * Every other type — a plugin's, and `script`, `connector_action`, `wait`, |
| 115 | + * `subflow`, `map` (module note) — makes the bound set unprovable. |
| 116 | + */ |
| 117 | +const CLOSED_BINDER_NODE_TYPES: ReadonlySet<string> = new Set([ |
| 118 | + 'start', |
| 119 | + 'end', |
| 120 | + 'decision', |
| 121 | + 'assignment', |
| 122 | + 'create_record', |
| 123 | + 'update_record', |
| 124 | + 'delete_record', |
| 125 | + 'get_record', |
| 126 | + 'http', |
| 127 | + 'notify', |
| 128 | + 'screen', |
| 129 | + LOOP_NODE_TYPE, |
| 130 | + PARALLEL_NODE_TYPE, |
| 131 | + TRY_CATCH_NODE_TYPE, |
| 132 | + APPROVAL_NODE_TYPE, |
| 133 | + APPROVAL_REVISE_NODE_TYPE, |
| 134 | +]); |
| 135 | + |
| 136 | +/** The run-user spellings formulas, RLS and the client accept and flow CEL does not bind. */ |
| 137 | +const RUN_USER_ALIAS_ROOTS: ReadonlySet<string> = new Set(['user', 'ctx', 'os']); |
| 138 | + |
| 139 | +/** What the door knows about one flow's CEL scope. */ |
| 140 | +export interface FlowCelRootScope { |
| 141 | + /** |
| 142 | + * Every root the run can bind, in the order a did-you-mean prefers them: the |
| 143 | + * trigger object's fields first (a flattened condition's commonest typo), then |
| 144 | + * the flow's own bindings, the engine's roots, and the other objects' fields. |
| 145 | + * Meaningful only when {@link provable}. |
| 146 | + */ |
| 147 | + readonly bound: ReadonlySet<string>; |
| 148 | + /** |
| 149 | + * `false` ⇒ the run can bind a name the reader cannot see, and no root of this |
| 150 | + * flow is judged. {@link openedBy} says why. |
| 151 | + */ |
| 152 | + readonly provable: boolean; |
| 153 | + /** The first reason the bound set is not provable, for tests and diagnostics. */ |
| 154 | + readonly openedBy?: string; |
| 155 | +} |
| 156 | + |
| 157 | +/** The flow-level slice the scope reads. */ |
| 158 | +export interface FlowCelRootHost extends FlowVariableHost { |
| 159 | + readonly nodes?: unknown; |
| 160 | +} |
| 161 | + |
| 162 | +/** One root a flow CEL source reads that the flow does not bind. */ |
| 163 | +export interface UnboundFlowCelRoot { |
| 164 | + /** The root identifier (`user`). */ |
| 165 | + readonly root: string; |
| 166 | + /** The member names read directly off it (`user.id` → `id`), in source order. */ |
| 167 | + readonly members: readonly string[]; |
| 168 | +} |
| 169 | + |
| 170 | +/** |
| 171 | + * The bound set of one flow, or the reason it cannot be proved. |
| 172 | + * |
| 173 | + * @param graphs every graph of the flow (`collectFlowGraphs`), so a region's |
| 174 | + * nodes count — one variable map serves the whole run. |
| 175 | + * @param triggerObject the start node's `objectName`, when it names one. |
| 176 | + * @param fieldIndex object name → its field names, injected columns included. |
| 177 | + */ |
| 178 | +export function flowCelRootScope( |
| 179 | + flow: FlowCelRootHost, |
| 180 | + graphs: readonly FlowGraphLike[], |
| 181 | + triggerObject: string | undefined, |
| 182 | + fieldIndex: ReadonlyMap<string, readonly string[]>, |
| 183 | +): FlowCelRootScope { |
| 184 | + const bound = new Set<string>(triggerObject !== undefined ? fieldIndex.get(triggerObject) ?? [] : []); |
| 185 | + for (const name of collectFlowVariableNames(flow, graphs)) bound.add(name); |
| 186 | + for (const name of ENGINE_BOUND_ROOTS) bound.add(name); |
| 187 | + let openedBy: string | undefined; |
| 188 | + const open = (reason: string): void => { openedBy ??= reason; }; |
| 189 | + |
| 190 | + for (const graph of graphs) { |
| 191 | + for (const node of recordsOf(graph.nodes)) { |
| 192 | + const type = typeof node.type === 'string' ? node.type : ''; |
| 193 | + const id = typeof node.id === 'string' ? node.id : '?'; |
| 194 | + if (!CLOSED_BINDER_NODE_TYPES.has(type)) { |
| 195 | + open(`node '${id}' (${type || 'no type'}) can bind names the reader cannot see`); |
| 196 | + continue; |
| 197 | + } |
| 198 | + const config = (node.config && typeof node.config === 'object' && !Array.isArray(node.config) |
| 199 | + ? node.config |
| 200 | + : {}) as AnyRec; |
| 201 | + if (type === LOOP_NODE_TYPE && !(typeof config.iteratorVariable === 'string' && config.iteratorVariable)) { |
| 202 | + // `LoopConfigSchema` defaults the iterator to `item`; a config read before |
| 203 | + // that parse carries no key, and the run still binds the default. |
| 204 | + bound.add('item'); |
| 205 | + } |
| 206 | + if (type === 'screen') { |
| 207 | + const fields = Array.isArray(config.fields) ? recordsOf(config.fields) : []; |
| 208 | + if (config.kind === 'object-form' || fields.length === 0) { |
| 209 | + open(`screen '${id}' declares no input contract, so a resume can bind any name`); |
| 210 | + continue; |
| 211 | + } |
| 212 | + for (const field of fields) { |
| 213 | + if (typeof field.name === 'string' && field.name) bound.add(field.name); |
| 214 | + } |
| 215 | + } |
| 216 | + } |
| 217 | + } |
| 218 | + |
| 219 | + if (triggerObject !== undefined && !fieldIndex.has(triggerObject)) { |
| 220 | + open(`trigger object '${triggerObject}' is not declared in this stack, so its fields are not in hand`); |
| 221 | + } |
| 222 | + for (const names of fieldIndex.values()) for (const name of names) bound.add(name); |
| 223 | + |
| 224 | + return openedBy === undefined ? { bound, provable: true } : { bound, provable: false, openedBy }; |
| 225 | +} |
| 226 | + |
| 227 | +/** cel-js's comprehension macros: the receiver calls that bind their first argument in the rest. */ |
| 228 | +const COMPREHENSION_MACROS: ReadonlySet<string> = new Set(['all', 'exists', 'exists_one', 'map', 'filter']); |
| 229 | + |
| 230 | +interface CelNode { |
| 231 | + readonly op: string; |
| 232 | + readonly args: unknown; |
| 233 | +} |
| 234 | + |
| 235 | +function isCelNode(value: unknown): value is CelNode { |
| 236 | + return !!value && typeof value === 'object' && !Array.isArray(value) && typeof (value as AnyRec).op === 'string'; |
| 237 | +} |
| 238 | + |
| 239 | +function boundIdentifier(value: unknown): string | undefined { |
| 240 | + return isCelNode(value) && value.op === 'id' && typeof value.args === 'string' ? value.args : undefined; |
| 241 | +} |
| 242 | + |
| 243 | +/** |
| 244 | + * The FREE root identifiers of a parsed CEL source, with the members read off |
| 245 | + * each. A name a comprehension macro (`all`, `exists`, `exists_one`, `map`, |
| 246 | + * `filter`) or `cel.bind` binds is free only outside the part it is bound in — |
| 247 | + * the same scoping cel-js applies (`lib/macros.js`). |
| 248 | + */ |
| 249 | +function freeRoots(ast: unknown): Map<string, string[]> { |
| 250 | + const out = new Map<string, string[]>(); |
| 251 | + const note = (name: string, member: string | undefined): void => { |
| 252 | + const members = out.get(name) ?? []; |
| 253 | + if (member !== undefined && !members.includes(member)) members.push(member); |
| 254 | + out.set(name, members); |
| 255 | + }; |
| 256 | + const walk = (node: unknown, scoped: ReadonlySet<string>, member?: string): void => { |
| 257 | + if (Array.isArray(node)) { |
| 258 | + for (const child of node) walk(child, scoped); |
| 259 | + return; |
| 260 | + } |
| 261 | + if (!isCelNode(node)) return; |
| 262 | + const args = node.args; |
| 263 | + if (node.op === 'id') { |
| 264 | + if (typeof args === 'string' && !scoped.has(args)) note(args, member); |
| 265 | + return; |
| 266 | + } |
| 267 | + if ((node.op === '.' || node.op === '.?') && Array.isArray(args)) { |
| 268 | + walk(args[0], scoped, typeof args[1] === 'string' ? args[1] : undefined); |
| 269 | + return; |
| 270 | + } |
| 271 | + if (node.op === 'rcall' && Array.isArray(args)) { |
| 272 | + const [name, receiver, list] = args as [unknown, unknown, unknown]; |
| 273 | + const variable = Array.isArray(list) ? boundIdentifier(list[0]) : undefined; |
| 274 | + if (typeof name === 'string' && COMPREHENSION_MACROS.has(name) && Array.isArray(list) && list.length >= 2 && variable) { |
| 275 | + walk(receiver, scoped); |
| 276 | + walk(list.slice(1), new Set([...scoped, variable])); |
| 277 | + return; |
| 278 | + } |
| 279 | + if (name === 'bind' && boundIdentifier(receiver) === 'cel' && Array.isArray(list) && list.length === 3 && variable) { |
| 280 | + walk(list[1], scoped); |
| 281 | + walk(list[2], new Set([...scoped, variable])); |
| 282 | + return; |
| 283 | + } |
| 284 | + walk(receiver, scoped); |
| 285 | + walk(list, scoped); |
| 286 | + return; |
| 287 | + } |
| 288 | + if (node.op === 'value') return; |
| 289 | + walk(args, scoped); |
| 290 | + }; |
| 291 | + walk(ast, new Set()); |
| 292 | + return out; |
| 293 | +} |
| 294 | + |
| 295 | +/** |
| 296 | + * Whether `name`, read as a bare identifier, is a VARIABLE the run must bind — |
| 297 | + * as opposed to a name CEL declares itself (a type such as `int`, the `cel` |
| 298 | + * namespace). Answered by the strict checker, which knows every such name; a |
| 299 | + * platform scope root (`user`, `ctx`, `os`, `data`, …) is declared there as a |
| 300 | + * convenience for other surfaces, and is a variable here. |
| 301 | + */ |
| 302 | +function isVariableReference(name: string): boolean { |
| 303 | + if ((SCOPE_ROOTS as readonly string[]).includes(name)) return true; |
| 304 | + return firstUndeclaredReference(name) === name; |
| 305 | +} |
| 306 | + |
| 307 | +/** |
| 308 | + * The roots `source` reads that `scope` does not bind, in source order — empty |
| 309 | + * when the scope is not provable, or when the source does not parse (the syntax |
| 310 | + * check owns that verdict: one broken expression, one finding). |
| 311 | + */ |
| 312 | +export function unboundFlowCelRoots(source: string, scope: FlowCelRootScope): UnboundFlowCelRoot[] { |
| 313 | + if (!scope.provable) return []; |
| 314 | + const ast = parseCelToAst(source); |
| 315 | + if (!ast) return []; |
| 316 | + const out: UnboundFlowCelRoot[] = []; |
| 317 | + for (const [root, members] of freeRoots(ast)) { |
| 318 | + if (scope.bound.has(root) || !isVariableReference(root)) continue; |
| 319 | + out.push({ root, members }); |
| 320 | + } |
| 321 | + return out; |
| 322 | +} |
| 323 | + |
| 324 | +/** |
| 325 | + * The refusal for one unbound root. |
| 326 | + * |
| 327 | + * A run-user spelling (`user`, `ctx.user`, `os.user`) is told to write |
| 328 | + * `current_user`; any other root is named with what the flow could have bound |
| 329 | + * it, and the nearest in-scope name when there is one. |
| 330 | + */ |
| 331 | +export function unboundFlowCelRootMessage(unbound: UnboundFlowCelRoot, scope: FlowCelRootScope): string { |
| 332 | + const { root, members } = unbound; |
| 333 | + const userSpelling = root === 'user' ? 'user' : RUN_USER_ALIAS_ROOTS.has(root) && members.includes('user') ? `${root}.user` : undefined; |
| 334 | + if (userSpelling !== undefined) { |
| 335 | + return ( |
| 336 | + `\`${userSpelling}\` is not bound in a flow expression, so the run fails with \`Unknown variable: ${root}\`: ` + |
| 337 | + 'a flow binds the run\'s user as `current_user` only — the `user`, `ctx.user` and `os.user` spellings that ' + |
| 338 | + 'formulas, row-level security and the client accept are not bound here. ' + |
| 339 | + `Write \`current_user\` in its place (\`${userSpelling}.id\` → \`current_user.id\`); for a flow that can run ` + |
| 340 | + 'without a user, guard it: `current_user != null ? current_user.id : null`.' |
| 341 | + ); |
| 342 | + } |
| 343 | + const near = nearestName(root, scope.bound); |
| 344 | + return ( |
| 345 | + `\`${root}\` is not bound in this flow's expression scope, so the run fails with \`Unknown variable: ${root}\`: ` + |
| 346 | + 'no variable, `outputVariable`, iterator, index or error variable, `assignment` target, node id or screen field ' + |
| 347 | + `of this flow is named \`${root}\`, no object in the stack declares a field \`${root}\`, and the engine binds ` + |
| 348 | + 'only `record`, `previous`, `vars` and `current_user`. ' + |
| 349 | + `Declare \`${root}\` as a flow variable or bind it with a node, or correct the name` + |
| 350 | + (near ? ` — did you mean \`${near}\`?` : '.') |
| 351 | + ); |
| 352 | +} |
0 commit comments