Repository navigation
fix(flows): 记录变更流以平台身份运行,补齐调度流早已declare的 runAs (#684) - #713
Merged
Merged
Conversation
… user (#684) A flow's `runAs` decides who its data operations execute as. Under the schema default 'user', a run that resolved no trigger user has no identity to scope to, so the engine refuses its data operations rather than run them unscoped (ADR-0049, #1888, #3760). "No trigger user" is not a schedule-only condition: a record-change flow is fired by a WRITE, and a write made without a session — seed loading, an integration, or another runAs:'system' flow's own write — carries no user into the run it triggers. Ten scheduled flows already declared runAs:'system'; all seven record-change flows were left on the default. Measured on 17.0.0-rc.2 by driving the real engine with no trigger user: case_escalation and opportunity_approval (and their insert-time twins) die at their first data node, and lead_assignment dies at its SLA stamp. The opportunity_approval case is a governance hole — a machine-created deal never opened an approval request at all. All seven now declare runAs:'system' with a per-flow rationale in source, including why elevating the user-driven runs is correct for each. The four notify-only flows were measured NOT refused today; that measurement is recorded in-file rather than papered over. Pinned two ways: actions-flows-integrity enumerates record-change flows from the compiled stack and requires the declaration plus an authored rationale; flow-record-change runs the engine user-less and reproduces the pre-fix refusal on demand so the assertions cannot pass by producing nothing. Fixes #684 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHrPAGEgFDoHjphqYG4BMa
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
yinlianghui
marked this pull request as ready for review
August 5, 2026 14:31
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #684
前提核对(先于实现)
按 origin/main
0899b4f逐条核对,issue 的核心前提成立,但范围被高估了,这里如实记录:runAs声明;contact-welcome.flow.ts里的runAsgrep 命中只是注释。10 个 schedule flow +case-actions.flow.ts的CloseCaseFlow已声明。PR fix: bug 批次修复(PM 派发循环)— #606 #673 #610 #548 #678 #650 已落地 #677 今天改过全部 flow 文件,行号已变,本 PR 基于合并后的代码。flow-runas-unscoped不覆盖 record-change 这一形态(userLessTriggerKind()只认 schedule / time-relative / api),引擎注释里也写明「only knowable at run time」。所以这个不变量只能由 app 侧自己守住 —— 与「不做平台侧 workaround」的约束一致,本 PR 只改 hotcrm 元数据与测试。AutomationEngine,不带 trigger user)只有 3 个真的被拒:case_escalation、opportunity_approval(各含其 insert-time twin)、lead_assignment。拒绝守卫的作用面是DATA_NODE_TYPES= get/create/update/delete record;notify节点直接调messaging.emit,不经过resolveRunDataContext。case_csat_followup潜在会被拒」也不成立。 我把它跑通到 P1D wait 挂起、再 resume 过 wait,notify正常投递,全程没有[runAs]。三个真实失败的实测输出(
summary.nodes):case_escalationget_case=failure,case 仍是status: 'new'escalated,1 条通知opportunity_approvalget_opportunity=failure,manager_review从未到达get_opportunity=success(随后停在 approval 节点,见下)lead_assignmentsla_hot被拒,next_followup_date为空、0 条通知opportunity_approval修改后停在manager_review,报的是No executor registered for node type 'approval'—— 这是 in-memory harness 没装@objectstack/plugin-approvals,不是 runAs 问题,测试里显式断言了这一点,免得后来人误读。逐 flow 的授权判断(不做无差别套用)
声明
runAs: 'system'会连带抬升用户驱动的那些 run(今天走的是健康的 scoped-as-user 路径),所以逐个论证。共同的结构性事实:这 7 个 flow 的每一个数据节点都以{record.id}为键,也就是刚刚触发 trigger 的那一行 —— 用户作用域换不来任何有意义的限制(记录已经在 run 手里),只会多出一种失败模式。没有任何一个 flow 依赖触发用户的受限作用域,因此不存在需要 needs_decision 的分叉。真实被拒的三个:
case_escalation(+case_escalation_on_create) —— 升级是服务级策略,不是写入者自己的编辑。抬升同时修掉一个真实场景:坐席可以合法「建案」而不一定持有升级生命周期字段(is_escalated/escalated_date/status)的编辑权。这也让它和case_sla_monitor站到同一基准上 —— 后者在调度里做同一件升级,早就是runAs: 'system'。opportunity_approval(+ twin) —— 抬升在这里不只是可接受,而是必须。这道闸的存在就是为了约束提交者,用提交者自己的作用域去评估它方向就是反的;而且mark_approved/mark_rejected要往一条被 approval 节点lockRecord: true锁住的记录上盖approval_status,只有平台写入落得下去。approval 节点本身也已经是以平台身份开请求、再回挂决策人做归属。lead_assignment—— SLA 戳是平台对刚触发 trigger 那一行的路由决策,不是提交销售自己的编辑;线索来自 web-to-lead / CSV 导入 / 伙伴集成 / seed,本来就不带 session。实测未被拒的四个(
contact_welcome、task_urgent_alert、opportunity_won_alert、case_csat_followup) 仍然声明,理由写在各自文件里,并且明确写清今天它是 inert 的:config.expand—— 那正是 notify 节点自己的报错信息推荐的补救手段,且走同一条受守卫的读取路径),继承到的是一个已经论证过的抬升,而不是在生产里撞出来的一次拒绝。case_csat_followup还多一层:它的 run 由close_case(本身就是runAs: 'system')派生,所以从 Close Case 按钮来的 run 天生无用户;而且它跨 P1D 挂起后才恢复,wait 之后那一段将来加的任何数据节点,用的都是挂起时携带的身份 —— 常见路径上就是「没有」。注释风格沿用 10 个调度流的写法(共享段 + 每个 flow 自己的「这里为什么 load-bearing」),按 record-change 的语义改写。
测试
两处钉死,一处是枚举、一处是行为:
test/actions-flows-integrity.test.ts—— 从编译后的 stack 枚举 record-change flow(不是手维护的 import 列表),要求每个都声明runAs: 'system',并且声明行上方带 >= 3 行的作者论证。新 flow 一注册就被覆盖,这是唯一不会腐坏的枚举方式。附带一个 canary,防止type改名后断言在空列表上空转。test/flow-record-change.test.ts—— 用真实引擎、不带userId跑真实 flow(harness 的trigger()恒定注入user_1,那正是验收确认健康的那条路径,所以这些用例直接调engine.execute)。并且把修改前的形态按需重现(剥掉runAs),断言它以[runAs] refusing a data operation死在第一个数据节点、记录纹丝未动 —— 这样上面的断言不可能因为「什么都没产出」而空转通过。runAs都照常投递。这样既不会有人去「修」一个根本不存在的破损,将来它真的开始被拒时也会在这里叫出来。反向验证(方向在跑之前就定好:剥掉声明 → 三道守卫同时转红):从
lead-assignment.flow.ts删掉runAs: 'system'后实际结果 —随后已还原。
test/runtime-coverage.test.ts的PENDING_FLOWS清空了一项:新用例把case_csat_followup真跑进引擎(跑到挂起点,也正是它执行身份被决定的地方),该 ledger 的「no stale entries」用例本身要求移除 —— 不是顺手改的无关测试。全量验证:
node -e级别的控制字符自查也已做:grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f]'扫过全部改动文件,clean。范围与约束
@objectstack/* 17.0.0-rc.2,未做任何版本改动。src/flows/demo-bootstrap.flow.ts(demo_bootstrap 的认领扫会把 owner 未显式设置的用户新建记录改判给第一个用户 —— rep 刚建的报价 10 分钟内被静默没收 #708 在飞)、调度流、screen 流。content/docs/releases/。.changeset/record-change-flows-run-as-system.md。越界发现
re-entrancy / loop-breaker 那条(#507 形态)不在本 PR 修。搜索后确认 #701 已经开着且已交叉引用 #684,所以按归档纪律没有另开重复 issue,只在 #701 追加了一条本次实现带出的新交互事实:修完 #684 之后,这两个 flow 的自身写入变成 system 写入,重入分派因此不再带用户 —— 而在 #684 之前,
[runAs]拒绝碰巧在 seed / 系统写入路径上充当了重入的第二道防线(代价是这些 flow 在该路径上压根跑不起来)。#684 正当地移除了这个副产品,于是 loop-breaker 成为这两个 flow 的唯一防线,#701 的风险论据不降反升。引擎护栏activeRecordFlows以flowName::recordId为键、与runAs无关,护栏本身行为未变。Generated by Claude Code