Repository navigation
feat(lint)!: refuse a flow CEL root the flow does not bind, naming current_user for the run-user aliases - #22609
Conversation
Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <noreply@anthropic.com>
…s read Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <noreply@anthropic.com>
…step 18) Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <noreply@anthropic.com>
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>
…; narrowing) Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <noreply@anthropic.com>
…ow-cel-unbound-root
📓 Docs Drift CheckThis PR changes 2 package(s): 22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…ow-cel-unbound-root
…t the runtime gate (S) Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <noreply@anthropic.com>
…e entrances and the runtime gate's stand-down Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #22565 (body and all 13 comments — rulings A′ ① Derived judgmentsThe refused set — right. Every root the judge refuses is one the run leaves unbound, measured against The node-level stand-downs — 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 S — only this judgment stands down at the runtime publish gate — right. The D3 entry and Every accept-set and public-surface change the diff implies, named: (a) ② Semver level
Clause-②: no (narrowing) ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Landing pre-checks at
|
Fixes #22565
Clause-②: no (narrowing)
What this does
objectstack validatenow refuses a flow CEL root the flow does not bind.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.service-automation'scelScopeis untouched.user.id == "u1",ctx.user.id == "u1"andos.user.id == "u1"are refused with a remedy namingcurrent_user, plus its guard for a user-less run.foo.bar == 1) at no extra cost. Its remedy names the root, what could have bound it, and the nearest in-scope name.validateStackExpressionsvisits:config.condition(the start trigger gate included) and an edgecondition;decisionbranchexpressionand a screen fieldvisibleWhen;assignmentand ofcreate_record/update_recordfields.Premise, measured at
99801d831fvalidateStackExpressionsanswered[]for an edgeuser.id == "u1",ctx.user.id,os.user.idandfoo.bar == 1.celScopeoverExpressionEngine.user,ctx,os,foo,input,paramsanddataeach faultUnknown variable: X.current_user,record,previousandvarsevaluate, and a comprehension orcel.bindvariable binds.$runIddoes not parse.The bound set (one per flow)
record,previous,vars,current_user.$name has no CEL spelling, so the$roots stay the text-slot rule's, and no second list of engine$names exists here.collectFlowVariableNames, composed. It covers declared variables,outputVariable/errorVariable/iteratorVariable/indexVariable, assignment targets and node ids. Two more are added: screen field names, and aloop's defaultitem.id, soidis bound for a flow one launches.Where the door stands down (
provable: false, with anopenedBythat names why)scriptandconnector_actionhand their code the live variable map;wait,subflowandmaptake a resume withresumeAuthority: 'any', which folds the caller's bag in;flowCelEntrancesreads each entrance once per stack, from the stack's own declarations:config.objectNamenames an undeclared object;config.timeRelative.object(TimeRelativeTriggerSchema, its own key) names an undeclared object;api(resolveFlowTriggerKind:type: 'api'orconfig.triggerType: 'api'). The hook hands the request body in as the record (trigger-api);ActionSchemaentry withtype: 'flow'andtargetset to this flow, inactions[](itsobjectName) or inobjects[].actions[](the owning object), on an undeclared object;mapnode:MapConfigSchema'sflowNamenames this flow, and itsitemObjectis undeclared or absent;subflownode (SubflowConfigSchema'sflowName) or amapnode names this flow inside an open flow. This is read to a fixpoint.buildAutomationContext:paramsandrecordId, norecord.object+recordId, and a schedule run hands no record.buildRuntimeWriteSnapshotSet) carries the written flow alone, besideobjects/permissions/books/datasets, so no entrance is visible.openedByreason names that snapshot.Reach (the real judge at
e99141ce2a)examples/**(app-crm,app-todo,app-multi-package,app-showcase)showcase_inbound_task_webhook(the inbound hook,type: 'api')packages/platform-objectsobjectstack-ai/hotcrmatf0afcbda07(read-only clone, deleted after)map/subflowparent, or a trigger / sweep object.record.*today; its webhook body keys are arbitrary, so standing down is right.Pins (
packages/lint/src/validate-expressions.flow-cel-root.test.ts)user.id == "u1"is refused with thecurrent_userremedy;user;current_user.id == "u1"passes.typeand bytriggerType), action (a stack action and an object's own action; a non-flow action is no entrance), object-less action (idbound),map(undeclared and absentitemObject), and an open-parentsubflowwith a two-level fixpoint.runRuntimeAuthoringRuleson a flow write:Ablations, from committed state
e99141ce2aEach ran through
scripts/ablation-replace.mjs, with the anchor hit once and the restore blob-equal to HEAD withgit diff HEADempty:const unbound = unboundFlowCelRoots(source, rootScope);→ an empty list): theuser.idtriage pin goes red,1 failed | 2 passed, and both controls stay green.flowCelEntrances(...)→ an empty map): all 7 entrance pins go red,7 failed | 1 passed. The one pass calls the reader directly.runtimeGateWrite→false): the gate pin goes red,1 failed | 2 passed.Tests (through
scripts/pm/os-verify-lock.sh, ate99141ce2a)@objectstack/lint(closure rebuilt):vitest run: 135 files, 6186 tests passed;typecheck(tsc --noEmitplus the test-layer program, whosesrc/**/*includes the pin file): OK.@objectstack/metadata-protocol(the runtime publish door), against the rebuilt lintdist: 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.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-loadsis the NOT MEASURED one, reason: no whole-workspace build in this container.--ranwith the coded record: "91 derived famil(ies) accounted for — 90 run, 1 NOT-MEASURED", 0 UNRUN.Deviations (the seat accepted these in
6094065925)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 itsvalueFlowfixture, declaringsourceandme. Two pins asserted a clean build-door verdict for a fixture that binds neither name. The hunk is disjoint from pass 3's.docs/protocol-upgrade-guide.mdandspec-changes.jsonare not regenerated. Per both generators' headers, the PR stage compares no committed copy.$engine roots are a local constant, because the spec'sFLOW_SCOPE_CLAIMED_IDENTIFIERSis package-internal.Acceptance notes
service-automationwould catch an executor that starts binding a new name. Noted, with no carrier.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 readerflowCelEntrances, andperWriteSnapshotEntrance.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.validate-expressions.test.tsandlint-flow-patterns.test.ts(one builder).flow-cel-unbound-root-refused(step 18) and itsSTEP18_RATIONALEfragment. Both now name the entrances and the runtime gate's stand-down.registry.tsis regenerated..changeset/22565-flow-cel-unbound-root-refused.md:@objectstack/lintand@objectstack/specatmajor(pre mode), with theregisteredADR-0087 marker, FROM → TO and the measured reach.Generated by Claude Code