Skip to content

Commit e99141c

Browse files
committed
chore(spec,changeset): the refusal is objectstack validate's; name the entrances and the runtime gate's stand-down
Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <noreply@anthropic.com>
1 parent cbc00f3 commit e99141c

3 files changed

Lines changed: 48 additions & 26 deletions

File tree

‎.changeset/22565-flow-cel-unbound-root-refused.md‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,36 @@
33
'@objectstack/spec': major
44
---
55

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.
6+
A flow CEL expression that reads a root the flow does not bind is refused at `objectstack validate`. 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.
77

88
Clause-②: no (narrowing)
99

1010
<!-- adr-0087: registered flow-cel-unbound-root-refused -->
1111

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).
12+
**BREAKING**: an accept-set narrowing on a published authoring surface (`objectstack validate`'s `expression-invalid` rule).
1313

1414
**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.
1515

1616
**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:
1717

1818
- the engine's roots: `record`, `previous`, `vars`, `current_user`;
1919
- 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.
20+
- a field (or a registry-injected column) of any object the stack declares — the run flattens whichever record its entrance hands it (an object-less flow action hands only the row `id`).
2121

2222
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.
2323

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.
24+
**Not judged — the door stands down where the run can bind a name it cannot see.**
25+
26+
- **A node:** 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, or a node type outside the builtin set.
27+
- **An entrance the stack declares that hands the flow a record whose keys are not in hand:**
28+
- a record trigger (`config.objectName`) or a time-relative sweep (`config.timeRelative.object`) on an object the stack does not declare;
29+
- an `api` trigger, whose inbound hook hands the request body in as the record;
30+
- a `type: 'flow'` action launching the flow on an undeclared object;
31+
- a `map` node feeding the flow whose `itemObject` is undeclared or absent;
32+
- a `subflow` or `map` parent that is itself such a flow.
33+
- **The runtime publish gate:** a flow write there is not judged for unbound roots yet. Its per-write snapshot carries no actions or other flows, so it cannot see those entrances. Every other expression verdict at that door is unchanged.
34+
35+
A `$` name has no CEL spelling (`$runId == "x"` does not parse), so the `$` roots stay the text-slot rule's.
2536

2637
## FROM → TO
2738

@@ -34,9 +45,9 @@ The user spellings are told to write `current_user`, with the guard for a flow t
3445

3546
**The one-line fix: write `current_user` for the run user, and bind or correct every other root the refusal names.**
3647

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.
48+
**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. One flow of the 67 is left unjudged by an entrance (`showcase_inbound_task_webhook`, an `api` trigger). Deployed metadata and other repositories were not measured.
3849

3950
### The kit
4051

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.
52+
- **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, and one entrance index per stack (`flowCelEntrances`).
4253
- **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.

‎packages/spec/src/migrations/entries/semantic/18.flow-cel-unbound-root-refused.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,17 @@ export const entry: SemanticMigration = {
3030
+ 'and the build door, which judged a flow expression\'s syntax only, passed it. The run-user spellings are '
3131
+ 'the reachable case: formulas, row-level security and the client bind the run\'s user under user, ctx.user '
3232
+ 'and os.user as well, and flow CEL does not, because a top-level user would collide with a variable or a '
33-
+ 'lookup field of that name. objectstack validate and the runtime publish gate now refuse such a root, naming '
34-
+ 'current_user for the user spellings and the in-scope names for any other. A flow whose run can bind a name '
35-
+ 'the reader cannot see is not judged: one with a script or connector_action node, a wait, subflow or map node '
36-
+ 'a resume can fold a bag into, a screen with no field list, a plugin node type, or a trigger object the stack '
37-
+ 'does not declare. No D2 conversion exists: none of these roots ever evaluated in a flow, so there is no '
38-
+ 'behaviour to carry over, and a run without a user needs a guard only the author can choose.',
33+
+ 'lookup field of that name. objectstack validate now refuses such a root, naming current_user for the user '
34+
+ 'spellings and the in-scope names for any other. A flow whose run can bind a name the reader cannot see is not '
35+
+ 'judged: one with a script or connector_action node, a wait, subflow or map node a resume can fold a bag into, '
36+
+ 'a screen with no field list, or a plugin node type; and one an entrance the stack declares hands a record '
37+
+ 'whose keys are not in hand: a record trigger or time-relative sweep on an object the stack does not declare, '
38+
+ 'an api trigger (the inbound hook hands the request body in as the record), a flow action on an undeclared '
39+
+ 'object, a map node whose itemObject is undeclared or absent, or a subflow or map parent that is itself such a '
40+
+ 'flow. The runtime publish gate does not give this verdict yet: it judges a flow write without the stack\'s '
41+
+ 'actions and other flows, so it cannot see those entrances. No D2 conversion exists: none of these roots ever '
42+
+ 'evaluated in a flow, so there is no behaviour to carry over, and a run without a user needs a guard only the '
43+
+ 'author can choose.',
3944
acceptanceCriteria:
4045
'Run objectstack validate: it reports each refused root as expression-invalid at the flow, the node or edge and '
4146
+ 'the slot, naming the root and its remedy. Replace each run-user spelling with current_user, guarded where the '

‎packages/spec/src/migrations/registry.ts‎

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5701,14 +5701,15 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [
57015701
id: 'flow-cel-unbound-root-refused',
57025702
order: 92,
57035703
text:
5704-
'It also refuses, at `objectstack validate` and the runtime publish gate, a flow CEL root the flow does not '
5705-
+ 'bind — in a trigger gate, an edge, a `decision` branch, a screen field `visibleWhen` or a CEL value '
5706-
+ 'envelope. Such a root fails every run that reaches it with an unknown-variable fault, and the build used '
5707-
+ 'to pass it. The run-user spellings formulas, row-level security and the client accept (`user`, '
5708-
+ '`ctx.user`, `os.user`) are the reachable case: flow CEL binds the run\'s user as `current_user` only, and '
5709-
+ 'the refusal names it. A flow whose run can bind a name the reader cannot see is left unjudged, and no D2 '
5710-
+ 'conversion exists: none of these roots ever evaluated in a flow. Its D3 record is the semantic entry '
5711-
+ '`flow-cel-unbound-root-refused`.',
5704+
'It also refuses, at `objectstack validate`, a flow CEL root the flow does not bind — in a trigger gate, an '
5705+
+ 'edge, a `decision` branch, a screen field `visibleWhen` or a CEL value envelope. Such a root fails every run '
5706+
+ 'that reaches it with an unknown-variable fault, and the build used to pass it. The run-user spellings '
5707+
+ 'formulas, row-level security and the client accept (`user`, `ctx.user`, `os.user`) are the reachable case: '
5708+
+ 'flow CEL binds the run\'s user as `current_user` only, and the refusal names it. A flow whose run can bind a '
5709+
+ 'name the reader cannot see is left unjudged — through a node, or through an entrance the stack declares that '
5710+
+ 'hands it a record whose keys are not in hand — and so, for now, is every flow write at the runtime publish '
5711+
+ 'gate, whose snapshot carries no actions or other flows. No D2 conversion exists: none of these roots ever '
5712+
+ 'evaluated in a flow. Its D3 record is the semantic entry `flow-cel-unbound-root-refused`.',
57125713
},
57135714
{
57145715
id: 'flow-decision-edge-branching-first-match',
@@ -13818,12 +13819,17 @@ const step18: MigrationStep = {
1381813819
+ 'and the build door, which judged a flow expression\'s syntax only, passed it. The run-user spellings are '
1381913820
+ 'the reachable case: formulas, row-level security and the client bind the run\'s user under user, ctx.user '
1382013821
+ 'and os.user as well, and flow CEL does not, because a top-level user would collide with a variable or a '
13821-
+ 'lookup field of that name. objectstack validate and the runtime publish gate now refuse such a root, naming '
13822-
+ 'current_user for the user spellings and the in-scope names for any other. A flow whose run can bind a name '
13823-
+ 'the reader cannot see is not judged: one with a script or connector_action node, a wait, subflow or map node '
13824-
+ 'a resume can fold a bag into, a screen with no field list, a plugin node type, or a trigger object the stack '
13825-
+ 'does not declare. No D2 conversion exists: none of these roots ever evaluated in a flow, so there is no '
13826-
+ 'behaviour to carry over, and a run without a user needs a guard only the author can choose.',
13822+
+ 'lookup field of that name. objectstack validate now refuses such a root, naming current_user for the user '
13823+
+ 'spellings and the in-scope names for any other. A flow whose run can bind a name the reader cannot see is not '
13824+
+ 'judged: one with a script or connector_action node, a wait, subflow or map node a resume can fold a bag into, '
13825+
+ 'a screen with no field list, or a plugin node type; and one an entrance the stack declares hands a record '
13826+
+ 'whose keys are not in hand: a record trigger or time-relative sweep on an object the stack does not declare, '
13827+
+ 'an api trigger (the inbound hook hands the request body in as the record), a flow action on an undeclared '
13828+
+ 'object, a map node whose itemObject is undeclared or absent, or a subflow or map parent that is itself such a '
13829+
+ 'flow. The runtime publish gate does not give this verdict yet: it judges a flow write without the stack\'s '
13830+
+ 'actions and other flows, so it cannot see those entrances. No D2 conversion exists: none of these roots ever '
13831+
+ 'evaluated in a flow, so there is no behaviour to carry over, and a run without a user needs a guard only the '
13832+
+ 'author can choose.',
1382713833
acceptanceCriteria:
1382813834
'Run objectstack validate: it reports each refused root as expression-invalid at the flow, the node or edge and '
1382913835
+ 'the slot, naming the root and its remedy. Replace each run-user spelling with current_user, guarded where the '

0 commit comments

Comments
 (0)