Repository navigation
fix(lint)!: flow CEL record is bound only where an entrance hands the flow a record (#22677) - #22730
Conversation
…he flow a record `record` leaves the build door's always-bound roots. Since #22642 the run binds it only to the record its entrance handed it (or a flow's own `record` variable), so `objectstack validate` passed a `record.X` that faults at run time with `Unknown variable: record`. `flowCelEntrances` now also reads which flows an entrance hands any record (a record trigger, a time-relative sweep, the inbound hook, a `type: 'flow'` action, a `map` node with an itemObject, and a parent handed one, to a fixpoint) and binds `record` there; everywhere else `record.X` is refused, naming the `vars` remedy. The runtime publish gate's stand-down is unchanged. The pending #22565 note and the D3 entry's reason stop calling `record` engine-bound; the registry is regenerated. Lint fixtures that read `record.X` with a start objectName and no record trigger get their `record-*` trigger. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <[email protected]>
…ow-cel-record-entrance
📓 Docs Drift CheckThis PR changes 2 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
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 6847343d94af3ef6ae58ea7cf2b7eefd1ca1dd16 && git checkout 6847343d94af3ef6ae58ea7cf2b7eefd1ca1dd16
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e5899a67d641aed9387707511bbbbe6a64d25c5b 0e51b65d0952e5e1be69a5bd05f215722e2648c9 && git checkout -B drift-repro e5899a67d641aed9387707511bbbbe6a64d25c5b && git merge --no-ff 0e51b65d0952e5e1be69a5bd05f215722e2648c9
node scripts/docs-audit/affected-docs.mjs --json e5899a67d641aed9387707511bbbbe6a64d25c5b
|
…head `.changeset/22642-flow-cel-record-binding.md` said `objectstack validate` does not refuse an entrance-less `record` read yet. With `record` now entrance-derived at the build door it does, so that one sentence is corrected (a DELIBERATE CORRECTION of a pending note, per seat order 6102049574). The 22677 note's closing line, which pointed at the old sentence, is dropped. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <[email protected]>
Contract reviewServed-tier: PR #22730 (card #22677), read at 2026-10-10T21:05Z. Inputs: the card's body and every comment (triage ① Derived judgmentsThe refused set —
The runtime gate's S stand-down still covers The refusal text. Right: a The D3 entry Fixtures. 9 Reach. 0 newly refused over Check-runs on this head, at this reading. Concluded green: Build Core, Dogfood Regression Gate (1/3), Dogfood Verify CLI, Governed Surface Queue Guard, Spec property liveness, Type Check · source gates, Type Check · debt ledger, Check Documentation Links, Flag docs affected by code changes, the four claim and closing guards, Check PR Size, Auto Label, filter. Concluded red: Check Changeset (twice) — its log names exactly ② Semver level
③ Boundary flagsDELIBERATE CORRECTION class — confirmed, both notes, sentence by sentence.
Dev deviations the ACCEPT records — each judged:
Out-of-scope findings:
The two open questions:
Nothing else flagged: no governed surface in the file list, no Implemented-by: VERDICT: PASS Generated by Claude Code |
Landing pre-checks at
|
Fixes #22677
Clause-②: no (narrowing)
What
recordleaves the build door's always-bound roots (ENGINE_BOUND_ROOTSinpackages/lint/src/flow-cel-root-scope.ts). Since PR #22674 (#22642) the run bindsrecordonly to the record its entrance handed it, or to a flow's ownrecordvariable, soobjectstack validatepassed arecord.Xthat faults at run time withUnknown variable: record. Now:flowCelEntrancesreads a second set beside "opened": the flows an entrance hands ANY record.recordgoes into those flows' bound set.recordvariable (or assigns one) binds it throughcollectFlowVariableNames, as any other name.record.Xis refused asexpression-invalid,error, with arecord-specific message that names the remedy: read the variable by its name or throughvars(record.statusbecomesstatus/vars.status), or give the flow the entrance that hands it its record.previous,varsandcurrent_userstay engine roots. The generic refusal's closing clause now says so, and adds thatrecordis bound when an entrance hands the run one.perWriteSnapshotEntrance) is unchanged and coversrecordtoo, until lint(runtime gate): a flow write's per-write snapshot carries no actions and no other flows, so a flow CEL root rule cannot see the entrances that hand a flow its record (W, from #22565) #22636.validate-expressions.tsis unchanged.The two sets, measured on the engine at
f66fdc7973recordbound)record_change(startconfig.triggerTypeis arecord-*string)config.objectNameis undeclaredtime_relativeconfig.timeRelative.objectis undeclaredapitype: 'flow'action targeting the flowmapnode naming the flowconfig.itemObjectis setitemObjectis undeclared or absentsubflow/mapparentconfig.objectNamealone, a REST trigger route, a declared endpoint, a cron scheduleSources:
seedRunVariablesbindscontext.recordasrecordand nothing else does.deriveTriggerBindingroutesrecord_changeonly for arecord-*triggerType.subflow-node.ts/map-node.tsspread the parent's context.dispatchFlowActionalways passes a record.A parent's own
recordVARIABLE is not handed on, because a child gets its parent's context, not its variables. That case is pinned.previoushas no such problem on main:seedRunVariablessets it on every run (context?.previous ?? null), so it is never unbound. It stays inENGINE_BOUND_ROOTS.Corrected pending release notes: the DELIBERATE CORRECTION class
check-empty-changesetis red by design, naming exactly two pending, unreleased notes that this PR changes the behaviour of:.changeset/22565-flow-cel-unbound-root-refused.md(automation: a flow CEL expression may name the run user asuser,ctx.useroros.user;objectstack validatepasses it and the run faultsUnknown variable, because flow CEL binds onlycurrent_user#22565);.changeset/22642-flow-cel-record-binding.md(automation: with norecordvariable bound, flow CELrecordis the variables map itself, sorecord.KEYsilently reads a variable named KEY #22642).Each is corrected in place, and neither may be restored from the base.
.changeset/22565-flow-cel-unbound-root-refused.md: threerecordsentences, verbatimrecord,previous,varsandcurrent_userbound by the engine."recordbound to the record the run was handed (where an entrance hands it one), andprevious,varsandcurrent_userbound by the engine."record,previous,vars,current_user;"previous,vars,current_user;"record, where an entrance the stack declares hands the flow a record — a record trigger (arecord-*triggerType), a time-relative sweep, the inbound hook, atype: 'flow'action, amapnode with anitemObject, or asubflow/mapparent handed one — or where the flow binds a variable namedrecord;"recordon a flow an entrance hands a record, a binding of the flow's own or a field of its trigger object.".changeset/22642-flow-cel-record-binding.md: one sentence, verbatim, nothing else in the file (seat order6102049574)objectstack validatedoes not refuse such arecordread yet; the run fails it, naming the root and the source."objectstack validaterefuses such arecordread too, naming the root (see@objectstack/lint's note)."The D3 entry
The landed D3 entry
flow-cel-unbound-root-refusedgets a text-only change toreason, and nothing else in the entry changes:registry.tsis still tracked, and it was regenerated withgen:migration-registry(a 4-line diff in the same region).New changeset and ADR-0087 disposition
.changeset/22677-flow-cel-record-entrance.md:@objectstack/lint: major. That is the pre-mode level automation: a flow CEL expression may name the run user asuser,ctx.useroros.user;objectstack validatepasses it and the run faultsUnknown variable, because flow CEL binds onlycurrent_user#22565's note took for the same rule's narrowing;check-changeset-no-majorexempts it in pre mode.Clause-②: no (narrowing).not-required (already-registered flow-cel-unbound-root-refused, flow-cel-record-variables-alias-retired). No new ledger entry is owed:recordread on a flow no entrance hands a record is a root the flow does not bind, which is exactly the surface of the unreleased D3 entryflow-cel-unbound-root-refused, and its correctedreasonnow says so;recordvariable bound, flow CELrecordis the variables map itself, sorecord.KEYsilently reads a variable named KEY #22642's own D3 entry;The first version of this note ended with a line saying the #22642 note's sentence no longer held. That line was dropped at
0e51b65d09, once the #22642 sentence itself was corrected.Fixture census, taken before any fixture was edited
These are the fixtures whose flow reads
record.Xwith no record entrance. Each is a start node withconfig.objectNameand norecord-*trigger, except thelint-flow-patternsone, which has neither.Lint suite with only the judge change: 34 failed in 3 files.
packages/lint/src/validate-expressions.test.ts: 29 tests, 9 fixtures. The start node at each line below gottriggerType: 'record-after-update'(text only)::47passes a clean stack;:81unknown record field;:478dotted spelling;:2760theflowWithhelper for the region tests;:3125null-guard flow conditions. Its null-guard 闸门(#4763)只覆盖了校验规则与 hook 条件 —— action / flow 条件两面待定,formula 面待判 #4811 comment "the engine binds both roots unconditionally" is corrected to "a record-triggered run binds both roots";:4031withCondition, 15 tests of Author-time expression validator is blind to registry-injected system columns —record.owner_id/created_at/organization_idare rejected as unknown fields #5378;:4456flowWithof service-automation:evaluateConditionanswers a silentfalsefor a non-string predicate, and a non-stringconfig.conditionregisters clean #15662, 4 tests;:4623flowWithof lint:objectstack validatereports nothing for exactly the blankconfig.conditionthatregisterFlownow refuses — and a test pins that silence #17495, 2 tests;:4754stackWithof validateStackExpressions throws on a non-record entry of an object'sfields:list — an inline cast the asArray sweeps could not see #15742, 2 tests.packages/lint/src/lint-flow-patterns.test.ts:2787:valueFlow, 2 tests. It prescribesrecord.titleon an autolaunched flow with no entrance. It became arecord_changeflow on a declaredtaskobject, so the judgment stays live rather than standing down.packages/lint/src/validate-expressions.flow-cel-root.test.ts: 3 of automation: a flow CEL expression may name the run user asuser,ctx.useroros.user;objectstack validatepasses it and the run faultsUnknown variable, because flow CEL binds onlycurrent_user#22565's own pins:record(a new control reads it on a record-triggered flow);type(vars);@objectstack/metadata-protocol(226 files, built closure, lintdistcarrying the new judge): 0. Its flows go through the runtime gate, which stands down.validate: 0. That is 5unitfiles and 3integrationfiles that spawn the source CLI.Reach with the real judge
validateStackExpressionsandflowCelEntrancesfrom this worktree. Positive control: a no-entrance flow that readsrecord.statusis counted, with 1 refusal.recordexamples/**(app-crm,app-todo,app-multi-package,app-showcase)record_change, 1time_relative, 1api)packages/platform-objects(source scan)objectstack-ai/hotcrmat1d7148b(read-only sparse clone, deleted after)record_change)No shipped example refuses, and no example was changed.
Pins and ablations
The new block in
validate-expressions.flow-cel-root.test.tspins:validaterefusesrecord.Xwith no entrance (where, the message head, and thevarsremedy);objectNamealone is refused;subflowchild of a record-handing parent passes, including two levels;subflowchild of a non-handing parent is refused;recordvariable is not handed on;mapwithitemObject, and a declaredrecordvariable;boundper entrance;record.Xon a flow write whilevalidaterefuses it on the same flow.Ablations ran through
scripts/ablation-replace.mjsat641f940df7, with the fix committed. Each one restored blob-equal to HEAD (1f19fd39e079), withgit diff HEADempty.'record'put back inENGINE_BOUND_ROOTS: 5 failed / 72 passed. All 5 are the refusal-side pins (the refusal,objectNamealone, the red subflow control, the parent's variable, the runtime gate pin).nameswithouthanded): 8 failed / 69 passed. These are the record-trigger andsubflowcontrols, plus the time-relative, both action andmapcontrols, the reader pin, and the record-trigger row of the binding-kind controls. The declared-variable control stays green, as it should.Verification
Run at
8a65474075(the merge oforigin/main762db996adthroughos-regen-merge.sh) unless stated.@objectstack/lintsuite: 135 files, 6318 passed;typecheckexit 0 (tsc --noEmitandcheck:test-typecheckOK). Lint and its typecheck were also green at641f940df7.@objectstack/spec:check:migration-registry: registry current;check:generated: all 15 artifacts current, after aspecbuild. The first run, against a staledist, said PREREQUISITE NOT MET, so it does not count.@objectstack/metadata-protocolsuite, with its closure built: 223 files passed and 3 skipped, 28101 tests passed. CLI flow-validate files:--project unit, 5 files, 78 passed;--project integration(they spawn the source CLI), 3 files, 15 passed. Both were measured on the pre-merge tree. The judge source is byte-identical at8a65474075, and the merge touched neither package's flow path.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 91 commands at8a65474075, and 89 ran:check-empty-changesetexit 1 — the DELIBERATE CORRECTION above, red by design.--ranreconcile: 91 derived, 89 run, 2 unrun. Those 2 are NOT MEASURED by order:check:dual-build-cjs-loadsneeds a whole-workspace build;check:type-check-debtis--re-measure.check:published-readme-exportsexit 3 PREREQUISITE NOT MET: 7 unbuilt packages, none touched here. NOT MEASURED.8a65474075:check-closing-target-claim,check-partof-closing-keywordandcheck-single-claim-pathseach exit 0.Patch round at
0e51b65d09, a text-only change to the two.changeset/files named bygit diff --name-only 8a65474075 0e51b65d09. No code changed, so no suite was re-run:node scripts/check-empty-changeset.mjs --base origin/main: exit 1, naming exactly.changeset/22565-flow-cel-unbound-root-refused.mdand.changeset/22642-flow-cel-record-binding.md. This is the DELIBERATE CORRECTION class, red by design;node scripts/check-changeset-no-major.mjs --base origin/main: exit 0 (themajoris exempt in pre mode);node scripts/check-changeset-fixed.mjs: exit 0;pnpm check:nul-bytes: exit 0.Acceptance notes
opened-set gap. Arecord-*trigger with no startconfig.objectNamemakes the record-change trigger register its hook with no object, so the hook fires on any object's write. Such a run can be handed a record whose keys are not in hand, yetflowCelEntrancesopens a flow only whenobjectNameis named and undeclared. This is a read-only inference with no public-door reproduction. Carrier: lint(runtime gate): a flow write's per-write snapshot carries no actions and no other flows, so a flow CEL root rule cannot see the entrances that hand a flow its record (W, from #22565) #22636, the same judge's next card.recordvariable bindsrecordat the build door, as every declared variable does (collectFlowVariableNames; automation: a flow CEL expression may name the run user asuser,ctx.useroros.user;objectstack validatepasses it and the run faultsUnknown variable, because flow CEL binds onlycurrent_user#22565's judge-wide design). At run time a declared variable is bound only with a supplied input or adefaultValue(seedDeclaredVariables).user,ctx.useroros.user;objectstack validatepasses it and the run faultsUnknown variable, because flow CEL binds onlycurrent_user#22565 note, the D3reasonand the registry's step text is left as it is (seat order6102049574, question 2 answered A). automation: with norecordvariable bound, flow CELrecordis the variables map itself, sorecord.KEYsilently reads a variable named KEY #22642's own D3 entry,flow-cel-record-variables-alias-retired, carriesrecord's behaviour change with its FROM → TO.Generated by Claude Code