Repository navigation
Commit 5fb1746
feat(lint)!: refuse a flow CEL root the flow does not bind, naming current_user for the run-user aliases (#22609)
Fixes #22565
Clause-②: no (narrowing)
## What this does
`objectstack validate` now refuses a flow CEL root the flow does not
bind.
- Triage direction B (`6092499200`), with its two follow-up answers:
**A′** (`6094431558`), every entrance the stack declares is read; **S**
(`6094850599`), the runtime publish gate stands down for this judgment.
- ⛔ Not A: `service-automation`'s `celScope` is untouched.
- `user.id == "u1"`, `ctx.user.id == "u1"` and `os.user.id == "u1"` are
refused with a remedy naming `current_user`, plus its guard for a
user-less run.
- The wider family rides, as triage allowed: the same judge refuses any
root nothing binds (`foo.bar == 1`) at no extra cost. Its remedy names
the root, what could have bound it, and the nearest in-scope name.
- One judge covers every flow CEL site `validateStackExpressions`
visits:
- a node `config.condition` (the start trigger gate included) and an
edge `condition`;
- a `decision` branch `expression` and a screen field `visibleWhen`;
- the CEL value envelopes of `assignment` and of `create_record` /
`update_record` `fields`.
## Premise, measured at `99801d831f`
- **The door:** `validateStackExpressions` answered `[]` for an edge
`user.id == "u1"`, `ctx.user.id`, `os.user.id` and `foo.bar == 1`.
- **The run:** these were evaluated through a verbatim copy of
`celScope` over `ExpressionEngine`.
- `user`, `ctx`, `os`, `foo`, `input`, `params` and `data` each fault
`Unknown variable: X`.
- `current_user`, `record`, `previous` and `vars` evaluate, and a
comprehension or `cel.bind` variable binds.
- `$runId` does not parse.
## The bound set (one per flow)
- **Engine roots:** `record`, `previous`, `vars`, `current_user`.
- A `$` name has no CEL spelling, so the `$` roots stay the text-slot
rule's, and no second list of engine `$` names exists here.
- **The flow's own bindings:** `collectFlowVariableNames`, composed. It
covers declared variables, `outputVariable` / `errorVariable` /
`iteratorVariable` / `indexVariable`, assignment targets and node ids.
Two more are added: screen field names, and a `loop`'s default `item`.
- **Record fields:** every field, injected columns included, of every
object the stack declares. The run flattens whichever record its
entrance hands it.
- An object-less flow action hands only the row `id`, so `id` is bound
for a flow one launches.
## Where the door stands down (`provable: false`, with an `openedBy`
that names why)
- **A node whose run can bind a name the reader cannot see:**
- `script` and `connector_action` hand their code the live variable map;
- `wait`, `subflow` and `map` take a resume with `resumeAuthority:
'any'`, which folds the caller's bag in;
- a screen with no field list;
- any type outside the measured closed set.
- **An entrance the stack declares that hands the flow a record whose
keys are not in hand.** `flowCelEntrances` reads each entrance once per
stack, from the stack's own declarations:
- **a record trigger:** the start `config.objectName` names an
undeclared object;
- **a time-relative sweep:** the start `config.timeRelative.object`
(`TimeRelativeTriggerSchema`, its own key) names an undeclared object;
- **the inbound hook:** the trigger kind is `api`
(`resolveFlowTriggerKind`: `type: 'api'` or `config.triggerType:
'api'`). The hook hands the request body in as the record
(`trigger-api`);
- **an action:** an `ActionSchema` entry with `type: 'flow'` and
`target` set to this flow, in `actions[]` (its `objectName`) or in
`objects[].actions[]` (the owning object), on an undeclared object;
- **a `map` node:** `MapConfigSchema`'s `flowName` names this flow, and
its `itemObject` is undeclared or absent;
- **a parent that is itself open:** a `subflow` node
(`SubflowConfigSchema`'s `flowName`) or a `map` node names this flow
inside an open flow. This is read to a fixpoint.
- **The API run door needs no stand-down (measured):**
- Both REST trigger routes and the declarative endpoint build their
context with `buildAutomationContext`: `params` and `recordId`, no
`record`.
- The engine never loads a record from `object` + `recordId`, and a
schedule run hands no record.
- **The runtime publish gate (S):** on a flow write, this one judgment
stands down. Every other expression verdict there is unchanged, and a
pin shows one still refusing there.
- The gate's per-write snapshot (`buildRuntimeWriteSnapshotSet`) carries
the written flow alone, beside `objects` / `permissions` / `books` /
`datasets`, so no entrance is visible.
- The `openedBy` reason names that snapshot.
- #22636 carries the widening, a snapshot that carries flows and
actions. When it lands, this stand-down goes; nothing of it rides this
PR.
## Reach (the real judge at `e99141ce2a`)
| corpus | flows | refusals | opened by an entrance |
|:--|--:|--:|:--|
| `examples/**` (`app-crm`, `app-todo`, `app-multi-package`,
`app-showcase`) | 35 | 0 | 1: `showcase_inbound_task_webhook` (the
inbound hook, `type: 'api'`) |
| `packages/platform-objects` | 0 | 0 | — (declares no flows) |
| `objectstack-ai/hotcrm` at `f0afcbda07` (read-only clone, deleted
after) | 32 | 0 | 0 |
- No flow is opened by an action, a `map` / `subflow` parent, or a
trigger / sweep object.
- Every flow-launching action targets a declared object: app-crm 2,
app-showcase 1, hotcrm 14.
- The opened flow reads only `record.*` today; its webhook body keys are
arbitrary, so standing down is right.
## Pins (`packages/lint/src/validate-expressions.flow-cel-root.test.ts`)
- **The triage pins, unchanged:**
- edge `user.id == "u1"` is refused with the `current_user` remedy;
- the same condition passes on a flow declaring a variable `user`;
- the remedy `current_user.id == "u1"` passes.
- **Each alias spelling, each CEL site, and one control per binding
kind**, as before.
- **One pin per entrance**, each pairing the undeclared case (stands
down) with a declared control (still judged): record trigger,
time-relative sweep, inbound hook (by `type` and by `triggerType`),
action (a stack action and an object's own action; a non-flow action is
no entrance), object-less action (`id` bound), `map` (undeclared and
absent `itemObject`), and an open-parent `subflow` with a two-level
fixpoint.
- **The runtime stand-down**, through `runRuntimeAuthoringRules` on a
flow write:
- no unbound-root finding there, while the brace-trap verdict on another
edge still refuses there;
- the build door refuses the same root on the same flow.
## Ablations, from committed state `e99141ce2a`
Each ran through `scripts/ablation-replace.mjs`, with the anchor hit
once and the restore blob-equal to HEAD with `git diff HEAD` empty:
1. **The judge removed** (`const unbound = unboundFlowCelRoots(source,
rootScope);` → an empty list): the `user.id` triage pin goes red, `1
failed | 2 passed`, and both controls stay green.
2. **The entrance reader removed** (`flowCelEntrances(...)` → an empty
map): all 7 entrance pins go red, `7 failed | 1 passed`. The one pass
calls the reader directly.
3. **The runtime stand-down removed** (`runtimeGateWrite` → `false`):
the gate pin goes red, `1 failed | 2 passed`.
## Tests (through `scripts/pm/os-verify-lock.sh`, at `e99141ce2a`)
- `@objectstack/lint` (closure rebuilt):
- `vitest run`: 135 files, 6186 tests passed;
- `typecheck` (`tsc --noEmit` plus the test-layer program, whose
`src/**/*` includes the pin file): OK.
- `@objectstack/metadata-protocol` (the runtime publish door), against
the rebuilt lint `dist`: 224 files, 28314 tests passed (3 files and 19
tests skipped).
- `@objectstack/cli`, built: the 10 files that carry flows through the
authoring doors, 128 tests in all.
- 8 in the unit / integration projects (107 tests);
- the 2 nightly-tier e2e files under `OS_TEST_TIERS=nightly` (21 tests).
- `service-automation`: NOT MEASURED, reason: it does not depend on
`@objectstack/lint`.
## Gates (at `e99141ce2a`)
- `dispatch-gates --commands` (no paths) derived 91 families for the
final 8-path set: 90 run, all exit 0; 1 NOT MEASURED.
- `check:dual-build-cjs-loads` is the NOT MEASURED one, reason: no
whole-workspace build in this container.
- `--ran` with the coded record: "91 derived famil(ies) accounted for —
90 run, 1 NOT-MEASURED", 0 UNRUN.
- **Artifact rosters (49):** 46 run locally. The 3 PR-context guards
were wired to this PR after this body landed; the report carries all the
exit codes.
## Deviations (the seat accepted these in `6094065925`)
1. **Record fields cover every stack object's, not only the trigger
object's.** The run flattens whichever record its entrance hands it. A′
now reads those entrances, so the widening is paired with the entrance
stand-downs above.
2. **`packages/lint/src/lint-flow-patterns.test.ts` (#19939 pass 3's
surface) gains 5 lines in its `valueFlow` fixture**, declaring `source`
and `me`. Two pins asserted a clean build-door verdict for a fixture
that binds neither name. The hunk is disjoint from pass 3's.
3. **`docs/protocol-upgrade-guide.md` and `spec-changes.json` are not
regenerated.** Per both generators' headers, the PR stage compares no
committed copy.
4. **The four non-`$` engine roots are a local constant**, because the
spec's `FLOW_SCOPE_CLAIMED_IDENTIFIERS` is package-internal.
## Acceptance notes
- **Projection drift.** The committed projections lag the registry until
#22485. Noted, with no carrier.
- **Drift hazard.** The closed node-type set and the engine roots are
hand-measured. A parity pin in `service-automation` would catch an
executor that starts binding a new name. Noted, with no carrier.
- **Severity.** On a judged flow, a flow-variable near-miss of a trigger
field now reads as an `error`. On an open flow the advisory is unchanged
and still pinned.
## Kit
- `packages/lint/src/flow-cel-root-scope.ts`: the judge, the entrance
reader `flowCelEntrances`, and `perWriteSnapshotEntrance`.
- `packages/lint/src/validate-expressions.ts`: the calls at each flow
CEL site, the entrance index read once per stack, and the runtime-gate
stand-down.
- Fixtures that now declare the bindings they read:
`validate-expressions.test.ts` and `lint-flow-patterns.test.ts` (one
builder).
- The ADR-0087 D3 semantic entry `flow-cel-unbound-root-refused` (step
18) and its `STEP18_RATIONALE` fragment. Both now name the entrances and
the runtime gate's stand-down. `registry.ts` is regenerated.
- `.changeset/22565-flow-cel-unbound-root-refused.md`:
`@objectstack/lint` and `@objectstack/spec` at `major` (pre mode), with
the `registered` ADR-0087 marker, FROM → TO and the measured reach.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0ec4268 commit 5fb1746
8 files changed
Lines changed: 1276 additions & 6 deletions
File tree
- .changeset
- packages
- lint/src
- spec/src/migrations
- entries/semantic
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
0 commit comments