Repository navigation
fix(service-automation)!: flow CEL record is the record the run was handed, or unbound - #22674
Conversation
…handed, or unbound celScope handed the formula engine `record: vars`, so a run with no record in hand bound `record` to its own variables and `record.assignee` read a variable named `assignee`. The builder now binds no `record` slot: `record` resolves through the variable spread, bound when an entrance handed the run a record (seedRunVariables) or the flow binds a `record` variable, and faulting `Unknown variable: record` otherwise. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…and the remedy Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…ow-cel-record-binding
📓 Docs Drift CheckThis PR changes 2 package(s): ⛔ 3 release-owned page(s) 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 77856eb1144cbb646cc3b6c31f74a0eb67636e83 && git checkout 77856eb1144cbb646cc3b6c31f74a0eb67636e83
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6a3fe2517b1cc06b0042e3a841db24cf4e257fd0 e4977aa6d2ec2164d36ec3f0d8a073bea5d9ef85 && git checkout -B drift-repro 6a3fe2517b1cc06b0042e3a841db24cf4e257fd0 && git merge --no-ff e4977aa6d2ec2164d36ec3f0d8a073bea5d9ef85
node scripts/docs-audit/affected-docs.mjs --json 6a3fe2517b1cc06b0042e3a841db24cf4e257fd0
|
…ow-cel-record-binding
…y regenerated The changeset's FROM -> TO remedy for flow CEL `record.X` on a run with no record is a prescription for metadata authors, so it lives in the ADR-0087 ledger. Semantic-only: whether a run holds a record depends on the entrances that start the flow, which the flow alone does not show. registry.ts is regenerated by gen:migration-registry, not edited by hand. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…ow-cel-record-binding
Contract reviewServed-tier: Read at 2026-10-10T11:37Z, read-only: card #22642 (body and all five comments: triage Check-runs on ① Derived judgmentsEach accept-set or surface change the diff implies, judged against triage
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #22642
Clause-②: no (narrowing)
In flow CEL,
recordis now the record the run was handed, or unbound. It is never the run's variables map.AutomationEngine.celScopehanded the formula enginerecord: vars.buildScopeassignsextraafterrecord, so arecordvariable already won. With no record in hand, though, CELrecordwas the variables map, andrecord.assigneesilently read a flow variable namedassignee. The builder now passes norecordslot.recordresolves through the variable spread like any other name:seedRunVariablesbindscontext.recordasrecord), or when the flow binds arecordvariable itself;record.XfaultsUnknown variable: record, with the source, as every other unbound root does.Bare names (
assignee) andvars.assigneeresolve as before. The diff is one statement and its docblock inpackages/services/service-automation/src/engine.ts, one new test file, and the changeset.The open decision: this PR is NOT ready until it is settled
check-adr-0087-registrationis red on one cause: the changeset claimsregistered flow-cel-record-variables-alias-retired, and that D3 entry does not exist yet. I stopped before editing the migrations registry, as the dispatch asks. The two routes and the evidence are below. The full analysis is in theos-dev-reportcomment on #22642.hasMigrationPrescriptionon this changeset answerstrue; the control,.changeset/15206-managed-content-sealed.md, which validly holdsno-migration-prescription, answersfalse. Sono-migration-prescriptionis refused, and an honestregisteredneeds a ledger entry. PR feat(lint)!: refuse a flow CEL root the flow does not bind, naming current_user for the run-user aliases #22609 (landed) took the same route at zero measured reach.packages/spec, outside this claim's surface:packages/spec/src/migrations/entries/semantic/18.flow-cel-record-variables-alias-retired.ts;pnpm --filter @objectstack/spec gen:migration-registry:packages/spec/src/migrations/registry.ts.no-migration-prescription. This leaves a breaking runtime narrowing with no FROM → TO, against AGENTS.md's Post-Task Checklist step 3 and the dispatch.Step 1: the census (measured before any edit)
I used a static TypeScript-AST pass that executes nothing from the corpus. It reads every flow CEL slot:
condition,expression,visibleWhen, and the{ dialect: 'cel', source }value envelopes (tagged templates included). Sources it cannot resolve statically are listed and resolved by hand, never dropped. Recall control: every file carrying start-node text yielded a flow literal (0 misses on both trees).record0ec4268972:examples/**,packages/platform-objects(no flows),packages/qa/dogfoodshowcase_inbound_task_webhook, anapihook; a dogfoodrecord-after-updateflow)objectstack-ai/hotcrmatf0afcbda07(src/,test/)vars.*only)record_changeReach: zero, so no flow is rewritten. One cron string read as an
expressionwas excluded from the 51. Deployed metadata and other repositories were not measured.The entrance map: how each door hands a record today
Every entrance hands its record through
AutomationContext.record, andseedRunVariablesbinds it asrecordand$record. Nothing else bindsrecordexcept the flow's own variables.trigger-record-changetrigger-scheduletime-relative-trigger.tstrigger-apitype: 'flow'action (REST/actions, MCPrun_action)idruntimedispatchFlowAction/loadActionSubjectRecordsubflowparentcontext.record(the child context spreads the parent's)subflow-node.tsmapitemid; otherwise the parent'smap-node.tsbuildAutomationContext;schedule-trigger.tsPins and the ablation
packages/services/service-automation/src/flow-cel-record-binding.test.tshas 17 cases, throughregisterFlow+executeand the two primitives:record.assignee, and the card'shas(record.assignee) ? record.assignee : null, with no record and anassigneevariable: the run fails (success: false,status: 'failed'), namingUnknown variable: recordand the source, and never answersu9. An edge predicate andevaluateValueEnvelope/evaluateConditionrefuse it too.record.assigneefrom its record over a same-named variable. Bareassigneeandvars.assigneestill read the variable. A flow-declaredrecordvariable is read.subflowchild reads its parent's record, and a record-less parent hands none; amapitem with anidis the child's record, and an id-less item leaves the parent's.Ablation (
scripts/ablation-replace.mjs, WRAP mode, run from the committed state):record: varsput back. The anchor hit 1 → 0, and the blob went69bb14b848c5→0451cf389cbd. Result: 5 failed / 12 passed, exactly the five no-record pins (both value pins, the edge predicate, the primitives, and the record-less subflow parent). Controls and entrances stayed green. Restore proven: the blob after restore equals HEAD (69bb14b848c5),git diff HEADis empty, andgit status --porcelainshows 0 lines.Verification
Every reading below was taken at HEAD
e87a793ce6(this branch merged withorigin/main5fb1746611) unless it says otherwise. Each exit status was captured before any pipe.turbo run build --filter='@objectstack/service-automation...' --concurrency=1): 30/30 tasks, exit 0.@objectstack/service-automationtests (vitest run --maxWorkers=2): 184 files, 2348 passed, exit 0. The new file alone: 17/17.@objectstack/service-automationtypecheck (tsc --noEmitandcheck:test-typecheck): exit 0.tsc --listFilescounts the new test in the program (1 hit; 184 test files; 0error TS).@objectstack/spectest:repo: 54 files, 915 passed, exit 0. Its repo tests walk the tree, which includes the new file and the edited docblock.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths): 65 commands. Reconciled with--ranand exit codes recorded: 65 derived, 64 run, 1 NOT-MEASURED, 0 unrun.node scripts/check-adr-0087-registration.mjs --base origin/mainexits 1 because the claimed idflow-cel-record-variables-alias-retireddoes not exist in the registries;pnpm check:dual-build-cjs-loadsexits 3 with PREREQUISITE NOT MET, because it needs every package'sdistand only theservice-automationclosure is built here. Declared narrowing: the one package this diff changes loads underrequire(node -e "require('./dist/index.cjs')"inpackages/services/service-automation, exit 0,AutomationEngineis a function). The full gate is CI's.dist-reading gates (check:dts-closure,check:sourcemap-no-sources-content) swept the 30 built packages, which is theservice-automationclosure.Acceptance notes
packages/lint/src/flow-cel-root-scope.ts(landed in feat(lint)!: refuse a flow CEL root the flow does not bind, naming current_user for the run-user aliases #22609) listsrecordinENGINE_BOUND_ROOTS, the always-bound set. After this lands,recordis entrance-derived, soobjectstack validatestill passes arecord.Xthat now faults at run time when the flow has no record entrance. That PR's D3 entryflow-cel-unbound-root-refusedalso saysrecordis "bound by the engine". This PR does not touchpackages/lintor that entry, per the dispatch.{var}template dialect (loop/mapcollection, text slots) is interpolated from the variables map, not evaluated throughcelScope, so this change does not reach it. What{record.x}does on a run with no record was not measured here.Seat's append: patch round 1 (head
e4977aa6d2)Appended by
domain:servicesseat 1 (session_013j5gkUCpqQiti4GgPqqmnt) at 2026-10-10T11:23Z, from the dev's round-1 report on #22642. The open decision above is settled: A. The seat answered in-seat (6096468599, which also amends the claim's file surface), and the spec lane was told on #6017 (6096471320). The changeset is unchanged: it keeps its FROM → TO remedy andregistered flow-cel-record-variables-alias-retired.packages/spec/src/migrations/entries/semantic/18.flow-cel-record-variables-alias-retired.ts, in the shape of its siblingflow-cel-unbound-root-refused.record.X, or barerecord, on a run that holds no record, where X names a flow variable. The slots are node and edgecondition, a decision branchexpression, a screen fieldvisibleWhen, and theassignmentandcreate_record/update_recordvalue envelopes.vars.X.conversionIdsand norelevantWhen.packages/spec/src/migrations/registry.tsis regenerated bygen:migration-registryonly (+39, −0).check:migration-registryexits 0 (418 semantic).bash scripts/pm/os-regen-merge.sh:d9ebbee173, from origin/mainee3ae0360d;e4977aa6d2, from origin/main6a3fe2517b, which carries feat(spec,service-storage,client)!: one upload-scope vocabulary for the upload requests, the sys_file select, the upload doors and the SDK (#22470) #22647's registry entry. The post-merge regeneration reproduced the merged bytes exactly.e4977aa6d2. The delta against origin/main6a3fe2517bis 5 files, +418 / −2.Gate exits at
e4977aa6d2, each captured before any pipe:check-adr-0087-registration --base origin/main: exit 0 (registered, new here).check:migration-registry: exit 0.check:generated: exit 0, all 15 current.service-automation: 184 files, 2348 passed. Typecheck exit 0.migrations.test.tslives): 642 files, 19180 passed.test:repo: 54 files, 915 passed.check:dual-build-cjs-loadsis included.Generated by Claude Code