Skip to content

fix(flows): 记录变更流以平台身份运行,补齐调度流早已declare的 runAs (#684) - #713

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-684-flows-runas-system
Aug 5, 2026
Merged

yinlianghui merged 1 commit into
mainfrom
claude/issue-684-flows-runas-system

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #684

前提核对(先于实现)

按 origin/main 0899b4f 逐条核对,issue 的核心前提成立,但范围被高估了,这里如实记录:

  • ✅ 7 个 record-change flow 确实都没有 runAs 声明;contact-welcome.flow.ts 里的 runAs grep 命中只是注释。10 个 schedule flow + case-actions.flow.ts 的 CloseCaseFlow 已声明。PR fix: bug 批次修复(PM 派发循环)— #606 #673 #610 #548 #678 #650 已落地 #677 今天改过全部 flow 文件,行号已变,本 PR 基于合并后的代码。
  • ✅ 平台侧的 build-time lint flow-runas-unscoped 不覆盖 record-change 这一形态(userLessTriggerKind() 只认 schedule / time-relative / api),引擎注释里也写明「only knowable at run time」。所以这个不变量只能由 app 侧自己守住 —— 与「不做平台侧 workaround」的约束一致,本 PR 只改 hotcrm 元数据与测试。
  • ❌ 「七个 record-change flow 全部暴露」不成立。 实测(17.0.0-rc.2,真实 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):

flow 无用户运行(修改前) 无用户运行(修改后)
case_escalation get_case = failure,case 仍是 status: 'new' 全绿,case 变 escalated,1 条通知
opportunity_approval get_opportunity = failure,manager_review 从未到达 get_opportunity = success(随后停在 approval 节点,见下)
lead_assignment sla_hot 被拒,next_followup_date 为空、0 条通知 SLA 落库 + 1 条告警

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 的分叉。

真实被拒的三个:

  1. case_escalation(+ case_escalation_on_create) —— 升级是服务级策略,不是写入者自己的编辑。抬升同时修掉一个真实场景:坐席可以合法「建案」而不一定持有升级生命周期字段(is_escalated / escalated_date / status)的编辑权。这也让它和 case_sla_monitor 站到同一基准上 —— 后者在调度里做同一件升级,早就是 runAs: 'system'。
  2. opportunity_approval(+ twin) —— 抬升在这里不只是可接受,而是必须。这道闸的存在就是为了约束提交者,用提交者自己的作用域去评估它方向就是反的;而且 mark_approved / mark_rejected 要往一条被 approval 节点 lockRecord: true 锁住的记录上盖 approval_status,只有平台写入落得下去。approval 节点本身也已经是以平台身份开请求、再回挂决策人做归属。
  3. lead_assignment —— SLA 戳是平台对刚触发 trigger 那一行的路由决策,不是提交销售自己的编辑;线索来自 web-to-lead / CSV 导入 / 伙伴集成 / seed,本来就不带 session。

实测未被拒的四个(contact_welcome、task_urgent_alert、opportunity_won_alert、case_csat_followup) 仍然声明,理由写在各自文件里,并且明确写清今天它是 inert 的:

  • 它们都是 notify-only,没有数据节点,所以拒绝够不着;声明不改变任何当下可观测的行为,也没有数据操作可供作用域约束,抬升成本为零。
  • 之所以还是声明:要记录下来的决定是「record-change 自动化以平台身份运行」,而不是「这个 flow 恰好现在没有数据节点」。哪天加了数据节点(或加了 start 节点的 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 死在第一个数据节点、记录纹丝未动 —— 这样上面的断言不可能因为「什么都没产出」而空转通过。
  • 还钉了一条实测事实:notify-only 的四个 flow 剥不剥 runAs 都照常投递。这样既不会有人去「修」一个根本不存在的破损,将来它真的开始被拒时也会在这里叫出来。

反向验证(方向在跑之前就定好:剥掉声明 → 三道守卫同时转红):从 lead-assignment.flow.ts 删掉 runAs: 'system' 后实际结果 —

× lead_assignment stamps the SLA on an integration-written lead
× lead_assignment declares runAs:"system"
× every record-change flow explains its elevation in the source
AssertionError: expected '[runAs] refusing a data operation (ev…' not to contain '[runAs]'
Tests  3 failed | 71 passed (74)

随后已还原。

test/runtime-coverage.test.ts 的 PENDING_FLOWS 清空了一项:新用例把 case_csat_followup 真跑进引擎(跑到挂起点,也正是它执行身份被决定的地方),该 ledger 的「no stale entries」用例本身要求移除 —— 不是顺手改的无关测试。

全量验证:

pnpm typecheck   → tsc --noEmit,无输出
pnpm validate    → ✓ Validation passed (956ms)   (5 条 warning 均为存量:approval 空审批人槽位 / campaign_member 字段组)
pnpm lint        → 13 warning(s), 14 suggestion(s)  存量,无新增
pnpm hygiene     → ✓ source hygiene clean
pnpm build       → ✓ Build complete,Logic: 24 Flows
pnpm test        → Test Files  55 passed (55)
                   Tests  1338 passed | 1 skipped (1339)

node -e 级别的控制字符自查也已做:grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f]' 扫过全部改动文件,clean。

范围与约束

越界发现

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

… 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
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hotcrm Ignored Ignored Aug 5, 2026 2:28pm

Request Review

@github-actions github-actions Bot added ci/cd CI plumbing and the verification pipeline backend Server-side behaviour — hooks, flows, actions labels Aug 5, 2026
@yinlianghui
yinlianghui marked this pull request as ready for review August 5, 2026 14:31
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 5, 2026
Merged via the queue into main with commit 5928289 Aug 5, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Server-side behaviour — hooks, flows, actions ci/cd CI plumbing and the verification pipeline

Projects

None yet

2 participants