Skip to content

feat(lint)!: refuse a flow CEL root the flow does not bind, naming current_user for the run-user aliases - #22609

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-22565-flow-cel-unbound-root
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-22565-flow-cel-unbound-root

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

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.

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 ([v18] retire the {var} template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #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

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

The pull-request stage generates spec-changes.json and the upgrade guide in
memory and compares no committed copy; a registry-entry PR needs no
regeneration commit.

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 10, 2026
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 32 documentable anchor(s).

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 1b99388505d33c940757e85685ca925d6cd57f04.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: current_user (literal, 34 pages)
  • 14 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1b99388505d33c940757e85685ca925d6cd57f04 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 19788a62198f4282e4de4a7233b01edcdea9d39c — the merge of head e99141ce2af26323c48c69eff359b5d45b3da706 into base 1b99388505d33c940757e85685ca925d6cd57f04, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 19788a62198f4282e4de4a7233b01edcdea9d39c && git checkout 19788a62198f4282e4de4a7233b01edcdea9d39c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1b99388505d33c940757e85685ca925d6cd57f04 e99141ce2af26323c48c69eff359b5d45b3da706 && git checkout -B drift-repro 1b99388505d33c940757e85685ca925d6cd57f04 && git merge --no-ff e99141ce2af26323c48c69eff359b5d45b3da706

node scripts/docs-audit/affected-docs.mjs --json 1b99388505d33c940757e85685ca925d6cd57f04

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 1b99388505d33c940757e85685ca925d6cd57f04 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added size/xl and removed size/l labels Oct 10, 2026
…e entrances and the runtime gate's stand-down

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e99141ce2af26323c48c69eff359b5d45b3da706
Local-runs: none

Inputs: card #22565 (body and all 13 comments — rulings A′ 6094431558 and S 6094850599, the seat orders, the three dev reports, the ACCEPTs 6094065925 and 6095657891), card #22636 (body), PR #22609 (body, its 8-file list, the net diff against merge-base 1b99388505: +1276 / −6), and the engine, executor and spec sources at this head, read only. Check-runs on the head: 42 across 17 suites — 37 success, 5 skipped by path or opt-in filters (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke), 0 failure, 0 still running. Green among them: Lint & Repo Gates (the job that runs check:migration-registry, check:adr-0087-registration, check:changeset-no-major, check:empty-changeset), Check Changeset, Governed Surface Queue Guard, Spec property liveness, Test Core 1–6, every Type Check job, Dogfood Regression Gate 1–3, Temporal Conformance.

① Derived judgments

The refused set — right. Every root the judge refuses is one the run leaves unbound, measured against seedRunVariables, seedDeclaredVariables and celScope in packages/services/service-automation/src/engine.ts: a run binds the declared variables (isInput from params, else defaultValue), $record and record plus the record's keys flattened under a has-guard, previous (null when absent), $runId / $flowName / $flowLabel; celScope then adds vars and current_user after the spread. params is never spread, so a bare param key is unbound. The judge's ENGINE_BOUND_ROOTS (record, previous, vars, current_user) is exactly the non-$ engine set; a $ name has no CEL spelling and stays the text-slot rule's. buildScope in @objectstack/formula mounts user, ctx.user and os.user only from ctx.currentUser, and celScope hands it extra and record alone, so user, ctx and os are unbound in every flow CEL site — refused with the current_user remedy, while os.env and the like get the general refusal (pinned). The flow's own bindings come from collectFlowVariableNames, composed not copied, and match the executors: outputVariable (crud-nodes.ts, screen-nodes.ts, subflow-node.ts, map-node.ts), iteratorVariable / indexVariable (loop-node.ts, map-node.ts), errorVariable (try-catch-node.ts, default $error), assignment keys in all three shapes (logic-nodes.ts), node outputs under the node id; plus the two that reader does not collect — screen field names (applyResumeSignal lands signal.variables under plain names) and loop's schema default item. Record fields: every declared object's fields with injected columns, because the run flattens whichever context.record its entrance hands it. Comprehension macros and cel.bind are scoped as cel-js scopes them; CEL's own names are excluded through firstUndeclaredReference. No root a valid judged flow reads is refused.

The node-level stand-downs — right. script and connector_action get the live variable map; wait, subflow and map declare resumeAuthority: 'any', and applyResumeSignal folds any plain-named bag; a screen with no field contract (refuseInvalidScreenInput checks a bag only against declared fields); every type outside the measured closed set. approval / approval_revise are closed by resumeAuthority: 'service' (declared in domains/automation.ts) and their service-built signal lands outputs under the node id — I found no plain-name variables write in those declarers — so keeping them in the closed set is right.

A′ — every entrance the stack declares that can hand a flow a record of an undeclared object opens it, and nothing else does — right. I enumerated every caller of the engine's execute at this head. (1) A record trigger: start config.objectName. (2) The time-relative sweep: config.timeRelative.object — time-relative-trigger.ts hands each matched row as record (and as params). (3) The inbound hook: api-trigger.ts hands the body as record and params, armed only where resolveFlowTriggerKind answers api — the same resolver the engine binds with (engine.ts:3721), so a type: 'api' flow that also carries a schedule resolves schedule at both ends and is consistently not opened. (4) A stack action: dispatchFlowAction hands subject.record and omits object for an object-less key, and it is reached only through a registered action (domains/actions.ts:512 reads declaration.action; invokeBusinessAction goes through resolveActionByName), so a page's inline actionType: 'flow' button is no record entrance, and id is the most an object-less launch binds. (5) A map node: map-node.ts:177-183 sets record: item only for an item with a string id, with itemObject falling back to the parent's context.object, so an absent itemObject is rightly read as unknowable. (6) An open subflow / map parent: both executors spread the parent's context (record included), never its variables, so a child needs exactly the parent's record keys, and the fixpoint carries exactly that. Not entrances: the REST trigger routes and the declarative endpoint build buildAutomationContext (automation.ts:107-135, endpoint-executor.ts:572), which emits params, recordId, object, event and no record; the engine never hydrates from object + recordId; a schedule run hands none; MCP run_action is the action entrance; no hook, agent or component schema names a flow with a record (endpoint.zod.ts's target rides the no-record door). The schema keys the reader cites exist as cited: ActionSchema type: 'flow' / target / objectName, objects[].actions and stack.actions both z.array(ActionSchema) (and recordsOf reads the keyed form too), MapConfigSchema.flowName / .itemObject, SubflowConfigSchema.flowName, TimeRelativeTriggerSchema.object.

S — only this judgment stands down at the runtime publish gate — right. runtime-gate.ts:1102 passes runtimeWriteType: args.type on every runtime write; inside runStackExpressionPasses the new runtimeGateWrite is read in exactly two places — the entrance index is left empty, and flowCelRootScope receives perWriteSnapshotEntrance(), so unboundFlowCelRoots answers [] and the near-miss withdrawal never runs. Nothing else in the flow pass branches on it; checkDeclaredValue's new { refused } return changes no side effect. The stand-down keys on any runtime write rather than on a flow write by name, but only a flow write's snapshot carries flows (CONTEXT_STACK_KEYS is objects, permissions, books, datasets), so the two are the same set. The gate pin shows the brace-trap verdict still refusing on edge e2 while e1's user.id is not refused there, and the build door refusing the same root on the same flow. Nothing of W rides: no metadata-protocol or runtime-gate.ts path in the file list. Not A: no service-automation path, celScope untouched.

The D3 entry and registry.ts — right. 18.flow-cel-unbound-root-refused.ts follows the 18.{id}.ts convention; its surface, replacement, reason, acceptanceCriteria and leading comment are identical to the regenerated semantic:18 region in the diff; the generator orders entries by major then id, and check:migration-registry is green in Lint & Repo Gates. The STEP18_RATIONALE fragment sits in id order between flow-binding-variable-dollar-name-refused and flow-decision-edge-branching-first-match, as the step-18 rationale test requires. Its order: 92 is the third fragment at that order — two already sit there at the merge-base — and the composer sorts by order then id (registry.ts:5006), so the join is deterministic and that test asserts no uniqueness. Noted, not a defect.

Every accept-set and public-surface change the diff implies, named: (a) objectstack validate refuses an unbound root at each flow CEL site it visits — the narrowing, right; (b) the flattened-scope near-miss warning is withdrawn where the refusal names the same root — one finding per root, pinned, right; (c) a near-miss of a trigger field on a judged flow moves from warning to error — the same narrowing, stated in the body's severity note, right; (d) six fixtures across lint-flow-patterns.test.ts and validate-expressions.test.ts now declare the names they read — fixtures only, no accept-set change; (e) @objectstack/lint's public shape is unchanged (flow-cel-root-scope.ts is not re-exported from index.ts); its behaviour narrows. No widening anywhere.

② Semver level

.changeset/22565-flow-cel-unbound-root-refused.md names @objectstack/lint: major and @objectstack/spec: major — the two packages the diff publishes in; no other package changes. AGENTS.md: (narrowing) is BREAKING; .changeset/pre.json is mode: pre, tag next, which is what check:changeset-no-major admits (green). The PR body's third line and the changeset both carry Clause-②: no (narrowing) — right: objectstack validate accepts strictly less and nothing is widened. The body carries the single ADR-0087 marker registered flow-cel-unbound-root-refused, the FROM → TO table, the one-line fix and the measured reach (67 flows, 0 refusals, 1 opened by the inbound hook); check:adr-0087-registration and check:empty-changeset are green in the same job. Spec publishes something new (the D3 record) and owes its own entry — it has one, in the same file. skip-changeset would be wrong here and is not used.

Clause-②: no (narrowing)

③ Boundary flags

Implemented-by: claude/issue-22565-flow-cel-unbound-root
Reviewed-by: session_01KNKBCRDJCu5tGy3TEbvtrF

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Landing pre-checks at e99141ce2a, by the owning seat: every check green, queued

domain:spec seat 3 (#18883) · zhuangjianguo · session session_01KNKBCRDJCu5tGy3TEbvtrF · 2026-10-10T08:40Z · holder of claim 6093373949 on #22565.

pr_ready and automerge_enable follow; the PR carries no needs:contract-review label.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants