Skip to content

fix(hooks): 流程按钮按发起人校验岗位,系统免检收窄到无发起人的纯系统写入 - #33

Merged
baozhoutao merged 4 commits into
mainfrom
issue-28-position-guard
Sep 4, 2026
Merged

fix(hooks): 流程按钮按发起人校验岗位,系统免检收窄到无发起人的纯系统写入#33
baozhoutao merged 4 commits into
mainfrom
issue-28-position-guard

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

关联工作项 #28

问题

四个流程按钮(提交填报 / 审核通过 / 驳回 / 归档)是带 api.write 的 JS 动作体。平台 17.2.0 以「受信任」方式执行动作体,上下文是 { ...调用者上下文, isSystem: true } —— 发起人的 userId 原样保留(见 objectstack-ai/objectstack#2849)。于是「用户点了按钮」和「系统自己写」在 isSystem 这一位上完全一样,hasPosition() 第一行 if (isSystem(ctx) || !position) return true 把按钮路径整体放行:

  • 只持「人力审核」岗位的账号,能对「领导审批中」的填报单点「审核通过」并真的推进;
  • 部门填报人员能审批、能驳回、能归档;
  • 加减分「人力审核提出、人力负责人审批」的岗位分离在按钮路径上一并落空。

经 REST 直接 PATCH 不受影响(非系统上下文,岗位闸正常)—— 同一条规则在两条路径上是两个口径。

改法

判据从「有没有系统标记」换成「有没有发起人」:

isSystemWrite(ctx) = isSystem(ctx) && actorId(ctx) === null

真正的系统写入 —— 种子、脚本、hook 内部自动推进 —— 是没有发起人的;按钮路径永远带发起人。hasPosition 的免检条件一处收窄,sheet / check-task / bonus / adjustment 四个 hook 的岗位闸同时恢复,按钮路径与 REST 路径回到同一口径。

hook 内部自发的两处写入改用新增的 sysNoActor(ctx)(在 sudo() 基础上摘掉发起人,租户与事务信息原样保留):

  • 全部分公司确认后自动推进填报单;
  • 驳回时把核对任务重置为待核对。

这两处不是任何人点出来的,按发起人校验既没有对象也没有意义;而且节点审核岗位是按方案配置的(《设计方案》10.1 第 9 条「可改审核岗位」),不能指望「最后一个确认的人恰好持有该节点岗位」。

非岗位类的 isSystem 闸按「按钮动作体够不够得到」分两类处理,逐处在代码里写明理由:

分支 处理 理由
填报单直改 status、归档锁;加减分归档锁 / 已批准锁;数据调整归档锁 / 已批准锁 同口径收紧 按钮的动作体够得到
填报单新建保护 / 删除保护、填报明细提交后锁定、快照 / 审核记录 / 结果不可变 按原样保留 没有任何按钮到得了;收紧会打断发布生成填报单、调整落地写明细、归档写快照这些带发起人的系统写入(发布路径在 plan.hook.ts,本单禁触碰)

归档锁额外把 pending_action / action_reason 两个流程暂存字段排除在「改动」之外 —— 那是 after 阶段的清场写入,不是改数据。

未做与出口

按钮可见性未按岗位收敛(工作项范围 2):实测发现动作 visible 的 CEL 里 current_user.positions 装的是 auth 层角色(["user","org_member"]),不含业务岗位 —— 按岗位写的谓词对所有人静默判假,持有该岗位的人也看不到按钮。已按支线 B 上报平台 objectstack-ai/objectstack#15136(现象、最小复现、期望能力、平台版本),不在平台仓提交修复。按工作项正文的明文授权,保留 hook 拦截为唯一防线。

验证

  • pnpm verify 绿:validate ✓ / typecheck ✓ / test ✓(7 文件 113 条,原 90 + 新 23)/ i18n 529 key in sync(零元数据改动,无词条变化)。
  • 界面实测(软件公司档案,岗位账号,Playwright 1440×900 zh-CN):人力审核越节点审批被拒、部门填报人员审批 / 驳回 / 归档全被拒、分管领导审批通过正常、人力审核归档正常;加减分岗位分离的拒绝与成功两分支各有截图。
  • REST 同口径 7/7 PASS:提示文案与拒绝码与界面逐字相同。
  • 系统路径回归:scripts/software-flow.mjs 54/54 PASSscripts/e2e-flow.mjs 73/73 PASS,覆盖发布生成填报单、核对自动推进、驳回重置、加减分与调整落地重算、归档快照。

测试报告与需求符合度清单挂在 #28 评论;截图走 acceptance-evidence 分支 commit 70ae4a0f9fd7e8ed3a4cba153ac54f819005ad32

🤖 Generated with Claude Code

@baozhoutao

Copy link
Copy Markdown
Contributor Author

代码评审报告(os-project-dev-review)

档位:全量档(依据:改审核状态机唯一真值 src/hooks/sheet.hook.ts 与共享工具 src/hooks/util.ts,波及 bonus / adjustment / check-task 三个 hook = 高风险;调度员放行的是方案方向,代码须过全量核查)
评审输入:工作项 #28 全文、本 PR 全量 diff、需求符合度清单原文。未读开发方测试报告、开工前方案评论、交接评论与任何对话记录。
基线:a760651;评审对象 07d176c(6 文件,+385 −17)。

结论:不可合并


通用质量(/code-review 高档)

发现 1 条阻塞级正确性缺陷(F1)、1 条应修(F2)、4 条记录。除 F1 外,改动本身口径统一、注释到位:isSystemWrite 的定义(系统标记 无发起人)与平台实现完全对得上 —— 我在 @objectstack/runtime 17.2.0 里核到 buildActionExecutionContext(ec) = { ...ec, isSystem: true }(动作体沙箱)与 ObjectQL.recomputeSummariessystemCtx = { ...execCtx, isSystem: true },两者都保留发起人的 userId,buildUser 又以 userId == null 判定 ctx.user —— 所以「摘掉 userId 才是纯系统写入」这个判据在本平台版本上成立,sysNoActor 的做法也确实生效(sudo() 每次返回新的 ScopedContext,其 executionContext 是展开副本,改它不会污染调用方)。


专属核查(逐条)

1 改动面越界:✅ 无越界。
git diff --stat a760651..07d176c 恰为 6 个文件:src/hooks/{util,sheet,check-task,bonus,adjustment}.hook.ts + 新增 test/position-guard.test.ts。禁触碰面 src/hooks/plan.hook.tssrc/services/sharing-service.tssrc/security/src/apps/src/data/scripts/docs/README.mdCLAUDE.md 的 diff 全部为空;src/actions/src/objects/src/views/src/translations/ 也零改动(与清单「零元数据改动」一致)。bonus / adjustment 的改动各 2 处,只把归档锁与已批准锁的免检判据从 isSystem 换成 isSystemWrite,未碰其它规则;check-task 一处去早退、一处改写入上下文。dispute hook 未改合理:通读 src/hooks/dispute.hook.ts,其唯一的 isSystem 在 beforeInsert 的「方案已发布则冻结争议」上,不是岗位闸;两个争议按钮走 update,DisputeResolveHook 现状确实没有任何岗位判定 —— 本单只修失效的闸、不新增规则,口径正确。

2 降级对账:⚠️ 1 条 ✅ 未能复核通过(1.6),其余相符;⚠️ 1.3 的「不可达」判断成立;❌ 2.1/2.2 的平台受限成立。

  • 1.1 / 1.2 / 1.4 / 1.5 ✅ 逐条定位到代码并经运行期复现(见下「运行期复核」),属实。
  • 1.6「纯系统写入路径不能被误伤」的 ✅ 不成立 —— 被误伤的恰恰是 hook 自己在 after 阶段的清场写入,详见 F1。清单里引的 software-flow 54/54e2e 73/73 我都跑到过绿,但这是一条约 30% 概率的偶发失败,两份证据是真的、只是漏网,不按「自述失真」处理,按应修缺陷退回。
  • 新增 23 条单测是真断言,不是占位:viaButton = { isSystem: true, userId } 精确模拟了平台动作体上下文,越节点/非岗位四条断言 KPI_SHEET_POSITION,本节点三条断言状态推进与盖章,无发起人四条断言放行,另有 REST 与按钮同结论的对照条。但替身 fakeApi 没有 sudo,sys() / sysNoActor() 双双退化为它自己,于是 sysNoActor 的真实行为(摘 userId)和整个 after 阶段(清场写入、快照、留痕)零覆盖 —— F1 就是从这个洞里逃出去的,见 F2。
  • ⚠️ 1.3 的「四处非岗位类 isSystem 闸没有按钮动作体能到达」核实成立:通读 src/actions/index.ts,14 个动作体全部只做 repo.update({ id, … }),没有一个 insert / delete,且没有任何动作挂在 kpi_entry_line / kpi_snapshot / kpi_review_record / kpi_result 上。所以填报单新建保护(beforeInsert)、删除保护(beforeDelete)、明细提交后锁定与删除保护、三条不可变闸,按钮路径确实到不了;REST 路径仍走非系统上下文照拦(e2e T29/T30/T31 实测拒绝)。判断成立。
  • ❌ 2.1 / 2.2 的「平台能力受限」核实成立:平台 buildSession / buildActionSessionpositions 直接取 execCtx.positions(auth 层),与业务岗位表 sys_user_position 不是一回事 —— 这也正是 hasPosition 注释里写的「session.positions 只作描述、不作授权输入」。至于「能否退而用 record.status 推出所需岗位」:src/lib/workflow.tsrequiredPositionFor 取的是 steps.find(...).approver_position,即按方案可配,单靠 status 推不出(唯一例外是 archive 恒为人力审核,但仍缺「当前用户持有哪些业务岗位」这一半)。出口(只上报 action.visible 的 current_user.positions 装的是 auth 角色而非安全层岗位,按岗位收敛的按钮对所有人静默消失(17.2.0) objectstack#15136、保留 hook 拦截为唯一防线)与工作项范围 2 的明文授权一致。

3 三禁痕迹:✅ 无。
diff 不含 node_modules / 依赖目录 / patch 类文件;pnpm-lock.yaml 未动。sysNoActor 不是伪装用户、也不是绕过平台安全:它在 sudo() 之上摘掉发起人(降低归属、不提升权限),租户与事务原样保留,属正常用法;代价是这两类系统动作在审计与审核记录里 actor 为空(设计如此且已注释,记 R4)。唯一的姿势问题是它触到了平台内部属性,记 R1。

4 硬拍板落地:✅。
本单零元数据改动 → 无新增数字字段,四件套不适用;pnpm i18n:extract:check 报 529/529 in sync。用户可见文案只有既有的 KPI_SHEET_POSITION 三段式,一字未改(实测两条路径逐字相同)。开发方披露的两条既有文案缺陷我都实测确认存在,按 ⚪ 记录、不升级(见 R2 / R3)。


调用面抽查(逐调用点)

# 调用点 闸的判据 按钮动作体可达? REST 可达? 纯系统写入路径 结论
1 sheet.hook.ts:35-36 填报单新建保护 isSystem(保留)+ hasPosition('kpi_admin') 否(无 insert 型动作) 是,照拦 发布生成填报单(带发起人)→ 免检 ✅ 保留正确,发布未误伤(每轮实测发布成功)
2 sheet.hook.ts:88 流程岗位闸 hasPosition(收窄) ,已按发起人拦截 是,同一口径 核对全完成自动推进(sysNoActor)→ 免检 ✅ 无漏拦、无漏放行(B1–B5 / R1–R5 全对)
3 check-task.hook.ts:17,21 核对岗位闸 isSystemWrite + hasPosition ,已恢复拦截 驳回批量重置(sysNoActor)→ 免检 ✅ 实测驳回后三条任务全部回到待核对
4 bonus.hook.ts:79 登记/审批岗位分离 hasPosition (批准/否决) 无系统写 bonus 的路径 ✅ 实测自批被拒、人力负责人批准通过
5 bonus.hook.ts:71,84 归档锁/已批准锁 isSystemWrite(收窄) ✅ 无误伤
6 adjustment.hook.ts:69 审批岗位闸 hasPosition (批准并落地/否决) ✅ 实测两类调整落地正常
7 adjustment.hook.ts:48,61 归档锁/已批准锁 isSystemWrite(收窄) ✅ 无误伤
8 sheet.hook.ts:67 禁止直改 status isSystemWrite(收窄) 是,已拦截 无系统路径直写 status(清场写入不带 status) ✅ 无误伤
9 sheet.hook.ts:70 归档后数据锁 isSystemWrite(收窄)+ SCRATCH_FIELDS 白名单 归档 after 阶段自己的清场写入(sys(ctx),带发起人) 🔴 误伤,见 F1
10 sheet.hook.ts:231 填报单删除保护 isSystem(保留) 否(无 delete 型动作) 是,照拦 ✅ 保留正确
11 entry-line.hook.ts:27,66 明细锁定/删除保护 isSystem(保留) 否(明细无动作) 是,照拦(实测 422) 调整落地 sys(ctx) → 免检 ✅ 保留正确
12 immutable.hook.ts:12,25,38 快照/审核记录/结果 isSystem(保留) 否(这三个对象无动作) 是,照拦(实测 422) 归档快照回写、留痕、结果汇总 → 免检 ✅ 保留正确
13 dispute.hook.ts:17 发布后冻结争议 isSystem(保留,非岗位闸) 否(beforeInsert,两个争议按钮走 update) ✅ 未改合理
14 sysNoActor 两个调用点(check-task.hook.ts:63sheet.hook.ts:191) 都是 hook 内部自发写入 ✅ 实测自动推进与驳回重置均正常

补记(不阻塞):plan.hook.ts 的三处 isSystem 属禁触碰面、本单未改,现状不含岗位闸所以没有被绕过的东西;但「发布方案」按钮同样以受信任身份写入 —— 将来若在方案侧加岗位闸,必须同步用 isSystemWrite 口径,否则重蹈本单覆辙。


运行期复核(独立实例,端口 3120,独立库)

pnpm install --frozen-lockfilepnpm verify 绿(validate + typecheck + vitest 7 文件 113 条 + i18n 529/529)。

轮次 内容 结果
A software-flow.mjs(software 档案,全程用岗位账号) 54/54 PASS —— 发布、并行核对、争议、驳回重置、审批、加减分、两类调整、四维汇总、归档、数据范围全绿
B 按钮路径(POST /api/v1/actions/kpi_entry_sheet/…)六例 6/6 符合预期:人力审核对「领导审批中」点审核通过 被拒;部门填报人员点审核通过/驳回 被拒;部门填报人员对已通过单点归档 被拒;分管领导审批通过 放行;人力审核归档 放行
C REST 路径(PATCH pending_action)同六例 拒绝码与文案与按钮路径逐字相同(KPI_SHEET_POSITION)
D 连续归档 7 张已通过填报单(REST) 3 张失败:HTTP 422 KPI_SHEET_ARCHIVED,而 status 已是 archivedpending_action 卡在 archive快照 0 条、无归档审核记录
E e2e-flow.mjs(默认档案)本分支跑 2 轮 第 1 轮 T28「归档生成不可变快照」FAIL(snap=undefined,脚本随即中断);第 2 轮 73/73 PASS
F 对照组:同一临时克隆切到基线 a760651e2e-flow.mjs 73/73 PASS

D/E/F 合起来把 F1 钉死:基线不发生,本分支约 30% 概率发生。


发现清单

级别 位置 问题 处置出口
🔴 阻塞 src/hooks/sheet.hook.ts:70-75(归档锁)与 :165(after 阶段清场写入) 归档偶发半途失败,填报单被永久留在「已归档但没有快照」的状态。 after 阶段第一步 sys(ctx).object('kpi_entry_sheet').updateById(id, { pending_action: null, action_reason: null }) 带发起人 → isSystemWrite 为假 → 进归档锁分支。SCRATCH_FIELDS 只放行了 pending_action / action_reason,而平台内建 sys_stamp_audit_update(object: '*',priority 10,早于本 hook 的 100)会把 updated_at / updated_by 也写进同一个 ctx.input,不在白名单里 → touched 非空 → 抛 KPI_SHEET_ARCHIVED,清场写入之后的 writeReviewcreateSnapshotregenerateResults 全部不执行,请求以 422 返回给操作人。因为归档只能从「已通过」发起,重试会被状态机拒绝,该单无法自愈。基线上这条锁的免检是 isSystem,清场写入永远命中免检,所以是本次改动引入的回归。实测 7 次归档失败 3 次,e2e-flow T28 亦在本分支复现失败、在基线通过。修法方向:白名单枚举挡不住平台注入的字段,应改为「本次写入是否触到了业务字段」的判据(例如只在 inputSCRATCH_FIELDS 与平台审计字段之外的键时才判违规),或让 after 阶段的清场写入走 sysNoActor 退回原开发子 agent 原分支追加提交
🟡 应修 test/position-guard.test.ts:42-77(fakeApi) 替身没有 sudo,sys() / sysNoActor() 都退化成它自己,导致:① sysNoActor 摘发起人的真实行为零覆盖;② SheetAfterTransitionHook 整个 after 阶段零覆盖 —— 23 条用例全绿却让 F1 逃逸。随 F1 的修复补一条「归档后 after 阶段清场写入不被归档锁拦下」的用例(替身需提供 sudo() 与可观察的 executionContext) 同上
⚪ 记录 src/hooks/util.ts:47-56(sysNoActor) 依赖平台内部属性 ScopedContext.executionContext,且两条降级路径都静默:if (!api.sudo) return api 会返回未提权上下文;elevated.executionContext 不存在时返回带发起人的 sudo 上下文。任一情况发生,「全部分公司确认后自动推进」与「驳回重置核对任务」会转而按发起人校验岗位并被拦下,现象是流程无声卡住。我已在 objectql 17.2.0 源码核对当前形状成立,故本轮不算缺陷;建议加显式断言或在注释里钉住平台版本 留痕
⚪ 记录 src/hooks/sheet.hook.ts:89(本单未改的 fail 行) 归档被拒时提示里的节点名取自 transition()atStepDef,归档场景恒指向最后一个审批节点:对「已通过」单点归档被拒时显示「当前节点「领导审批」需要由对应岗位处理」,而实际要求的是人力审核岗位。按钮与 REST 两条路径均已实测复现。既有缺陷,本单范围 4 要求沿用原文案,不升级 留痕
⚪ 记录 按钮路径错误包装 动作体抛出的业务异常经平台沙箱包装后带 KpiError: 前缀(实测「审核通过 / 驳回 / 归档」四例),REST 路径没有。属平台侧包装;需要留意的是:本单之前按钮路径根本走不到岗位闸,所以这个前缀是本单之后才会在界面上出现 留痕
⚪ 记录 sysNoActor 的归属代价 摘掉发起人后,「自动推进」与「驳回重置」两类写入在审计日志与审核记录里 actor 为空,可追溯性改由 action_reason 文案承担。设计如此且已注释,记录备查 留痕

阻塞与应修各 1 条,未清零。结论:不可合并。

@baozhoutao

baozhoutao commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

评审退回修复(F1 阻塞 + F2 应修)

已在原分支 issue-28-position-guard 追加提交,状态与处理人未动。记录级 4 条按要求留痕不改。

分支已并到最新 main:git merge origin/main(01d3360,含数据范围那一单)—— 合并提交 1c61e36,无冲突。

修复点 ↔ 提交

修复点 提交 改到哪
🔴 F1 清场写入改用 sysNoActor —— 它是系统动作,不是任何人点出来的,应当落在 isSystemWrite 免检那一侧 cba7628 src/hooks/sheet.hook.ts:182
🔴 F1 归档锁的 touched 排除平台审计戳(新增 PLATFORM_STAMP_FIELDS = {created_at, created_by, updated_at, updated_by})—— 平台内建 hook 盖的戳不是用户改数据 cba7628 src/hooks/sheet.hook.ts:22-30, 84-89
🟡 F2 单测替身补 sudo() 语义 + 平台写入时序,并补 after 阶段用例 cba7628 test/position-guard.test.ts

成因确认:评审的判断与实测一致。sys_stamp_audit_update(object '*',priority 10)在本 hook(100)之前把 updated_at / updated_by 写进同一个 ctx.input;清场写入原本带发起人 → 进归档锁分支 → touched = ['updated_at','updated_by'] 非空 → KPI_SHEET_ARCHIVED,其后 writeReview / createSnapshot / regenerateResults 全不执行。两条修法缺一条都还会漏,所以两条都做了。

F2 的替身改到什么程度

原替身 fakeApi 没有 sudo,sys()sysNoActor() 双双 return api,于是 after 阶段的清场写入根本没穿过 hook 链 —— F1 就是从这条缝里逃走的。重写后:

  • FakeScopedContext 实现 sudo()(派生 {...ec, isSystem:true} 的新上下文),executionContext可写实例属性,仓库在 object() 调用的那一刻捕获上下文快照 —— 与平台 ScopedContext 一致,sysNoActor 的「先 sudo 再摘发起人」才测得出来;
  • FakeEngine 复刻平台写入时序:① 审计戳先于业务 hook 写进同一个 input;② hook 内部经 ctx.api 发起的写入会再穿一遍 hook 链,带着那次写入自己的上下文;
  • buildSession / buildUser 按平台口径实现(userId 为空时没有「当前用户」)。

新增 6 条用例(本文件 23 → 29 条),其中两条直接盯 F1:

  • 「人力审核归档:清场写入通过,审核记录与快照都落库」—— 断言 pending_action 已清空、kpi_review_recordaction='archive'to_status='archived'kpi_snapshot 恰好 1 条且 checksum 为 64 位十六进制、archived_by 是操作人;
  • 「清场写入到达 beforeUpdate 时确实是『无发起人的系统写入』」。

两半修复各被一条用例单独钉住(逐一实测过):

回退什么 单测结果
只回退 sysNoActor(清场写入改回 sys) 1 failed(「清场写入确实是无发起人的系统写入」)
只回退审计戳白名单 1 failed(「只带平台审计戳与流程暂存字段的写入放行」)
两条都回退(= 评审看到的基线写法) 3 failed,其中「人力审核归档:清场写入通过…」以 expected 'KPI_SHEET_ARCHIVED' to be null 复现 F1

三轮(实际四轮)结果

结果
pnpm verify ✅ 绿 —— validate ✓ / typecheck ✓ / test ✓ 139 passed(7 文件;本单的 position-guard.test.ts 29 条)/ i18n 529 key in sync
连续归档 11/11 全部成功:软件档案跑完 software-flow 后,把剩余 10 张「已通过」逐张归档,再复核 software-flow 归档的那张。每张都核对了「HTTP 200 + status=archived + pending_action/action_reason 已清空 + 快照恰好 1 条且 checksum 64 位 + 归档审核记录恰好 1 条且 to_status=archived」,零失败
software-flow.mjs(空库软件档案) 54/54 PASS
e2e-flow.mjs(默认档案,每轮独立空库) ✅ 第 1 轮 74/74、第 2 轮 74/74、第 3 轮 74/74、第 4 轮 74/74;第 4 轮完整留档,T28 归档生成不可变快照(含校验和、总分) PASS

连续归档明细:

待归档 10 张
PASS 归档 测试部     — http=200 status=archived pending=null 快照=1 归档留痕=1
PASS 归档 产品部     — http=200 status=archived pending=null 快照=1 归档留痕=1
PASS 归档 华东分公司 — http=200 status=archived pending=null 快照=1 归档留痕=1
PASS 归档 华南分公司 — http=200 status=archived pending=null 快照=1 归档留痕=1
PASS 归档 华北分公司 — http=200 status=archived pending=null 快照=1 归档留痕=1
PASS 归档 研发部     — http=200 status=archived pending=null 快照=1 归档留痕=1
PASS 归档 人力行政部 — http=200 status=archived pending=null 快照=1 归档留痕=1
PASS 归档 客户成功部 — http=200 status=archived pending=null 快照=1 归档留痕=1
PASS 归档 财务部     — http=200 status=archived pending=null 快照=1 归档留痕=1
PASS 归档 市场部     — http=200 status=archived pending=null 快照=1 归档留痕=1
PASS 复核已归档 销售部 — 快照=1 归档留痕=1
{"archived_ok":11,"archived_failed":0}

实例:端口 3113(起之前 lsof -i :3113 确认空闲),软件档案与默认档案各自独立空库,换档案前 rm -rf dist;收工已按 PID 停掉本会话起的全部 dev 进程,3113 已释放。

未扩围:记录级 4 条(sysNoActor 依赖平台内部属性、归档拒绝提示节点名指错、KpiError 前缀、sysNoActor 写入的记录 actor 为空)一律留痕不动。

@baozhoutao

Copy link
Copy Markdown
Contributor Author

代码评审报告 —— 复查轮(os-project-dev-review)

按轮次纪律只复查两个修复点 + 是否引入新问题,不重开全面评审,记录级 4 条不动。
复查对象:cba7628(修复提交),分支已并入 main 01d3360(合并提交 1c61e36)。全新临时 clone、端口 3120(起前 lsof 确认空闲)、每轮独立空库并 rm -rf dist,收工按 PID 停进程、删临时目录。

结论:可合并


① 🔴 F1 —— 已修复,复现不出来了

代码核对(两条都在,缺一不可):

  • src/hooks/sheet.hook.ts:182 清场写入由 api.object(...) 改为 sysNoActor(ctx).object(...) → 落在 isSystemWrite 免检侧;
  • src/hooks/sheet.hook.ts:22-30, 84-89 归档锁的 touched 新增排除 PLATFORM_STAMP_FIELDS

审计戳白名单没有顺带放开业务字段:集合恰为 {created_at, created_by, updated_at, updated_by} 四个平台戳,一个业务字段都没有。运行期五条探针(已归档单、人力审核账号、REST):

探针 结果
只写业务字段 remark 422 KPI_SHEET_ARCHIVED,数据未变 ✅
只写业务字段 total_score 422 KPI_SHEET_ARCHIVED,数据未变 ✅
戳与业务字段混写(updated_by + remark) 422 KPI_SHEET_ARCHIVED,数据未变 ✅(白名单不给夹带留缝)
只写 updated_by 200,updated_by 未被改成伪值、业务字段零变化
只写 created_by 200,created_by 未变、业务字段零变化

运行期复核(空库软件档案 + 默认档案):

结果
pnpm verify ✅ 绿 —— validate ✓ / typecheck ✓ / 139 passed(7 文件)/ i18n 529 in sync
software-flow.mjs(空库软件档案) 54/54 PASS
连续归档(人力审核岗位账号经 REST,逐张核对) 11/11 PASS,零失败。每张都验了「HTTP 200 + status=archived + pending_action/action_reason 已清空 + 快照恰好 1 条且 checksum 为 64 位十六进制 + 归档审核记录恰好 1 条且 to_status=archived
e2e-flow.mjs(默认档案,3 轮,每轮独立空库 + rm -rf dist) ✅ 74/74、74/74、74/74;三轮 T28 归档生成不可变快照 全 PASS
服务端日志扫描 三轮 e2e 实例日志 KPI_SHEET_ARCHIVED 0 次;软件档案实例的 3 次全部是上表三条应当被拒的探针,无一来自归档流程

上一轮同样的做法失败率约 30%(连续归档 7 张挂 3 张、e2e 两轮挂 1 轮 T28);本轮 14 次归档动作(11 张 + 3 轮 e2e)全部干净,无一例「已归档无快照」残留。F1 判定已消除

② 🟡 F2 —— 已修复,替身够真,新增用例确实会红

时序复刻核对(逐条比对平台源码):

  • FakeScopedContext.sudo() 派生 {...ec, isSystem:true}上下文,executionContext 是可写实例属性,object() 在调用那一刻捕获快照 —— 与 objectql 17.2.0 的 ScopedContext.sudo() / new ObjectRepository(name, this.executionContext, …) 一致,sysNoActor 的「先 sudo 再摘发起人」这才测得出来;
  • FakeEngine.update() 在业务 hook 之前把 updated_at / updated_by 写进同一个 input(复刻 sys_stamp_audit_update,object '*',priority 10 早于本 hook 的 100);
  • hook 内部经 ctx.api 的写入再穿一遍 hook 链,并带该次写入自己的上下文(beforeUpdate → 落库 → afterUpdate,previous 取写入时的现场);
  • buildSession / buildUser 按平台口径实现,userId 为空时没有「当前用户」——与平台 buildUseruserId == null 判定一致。

两处小差异,均不影响结论:替身的 object() 是拷贝上下文(平台按引用传),而 sysNoActorobject() 之前就改完了,行为等价;替身对无发起人的写入也盖 updated_by: null(平台此时不盖),只会让断言更严。

回退抽验(逐条实跑,跑完已还原,git diff 为空):

回退什么 实测
只回退 sysNoActor(清场写入改回 sys) 1 failed —— 「清场写入到达 beforeUpdate 时确实是『无发起人的系统写入』」
只回退审计戳白名单 1 failed —— 「只带平台审计戳与流程暂存字段的写入放行(戳不是『改动』)」
两条都回退(= 上一轮的写法) 3 failed,其中「人力审核归档:清场写入通过,审核记录与快照都落库」以 AssertionError: expected 'KPI_SHEET_ARCHIVED' to be null 原样复现 F1

与开发方自述完全一致。新增 6 条用例(23 → 29)是真断言,不是占位:归档用例断言 pending_action 已清空、审核记录 action='archive'to_status='archived'、快照恰好 1 条且 checksum 为 64 位十六进制、archived_by 为操作人。F2 判定已消除

③ 合入 main 后的交互 —— 未见问题

  • 改动面仍然干净:git diff --stat origin/main...HEAD 恰为原来那 6 个文件,禁触碰面零改动,也没有在合并里回退掉 main 的任何东西(src/security/src/services/scope-materialize.tssrc/apps/scripts/src/objects/ 均与 main 一致)。
  • sysNoActor 与新的 private 数据范围不冲突:它返回的是 sudo() 派生上下文(isSystem: true,只摘 userId,tenantId 原样保留),系统上下文本就绕过记录共享与行级范围,五个对象 OWD 收成 private 不影响它读写。两个调用点都在本轮实跑中走绿:software-flow T8f「3 家分公司全部确认后自动推进到人力审核中」(自动推进)、T9b「人力审核驳回 → 核对任务重置为待核对」(批量重置,三条任务全部回到 pending)。
  • 数据范围断言同步为绿:software-flow T17a–T17d(部门填报人员只见本部门、到人结果只见本人、核对人员只见本分公司、分管领导只见分管主体且越权读 404)全 PASS;e2e-flow 74 条(含 main 新增的共享规则用例)三轮全绿。
  • 上述 11 张归档全部由人力审核岗位账号经 REST 发起(非管理员),在 private OWD 下仍能正常读写目标单据与生成快照。

本轮新发现

级别 位置 说明
⚪ 记录 src/hooks/sheet.hook.ts:84-89 归档锁排除审计戳之后,一次只含审计戳字段的 PATCH 在已归档单上由 422 变成 200 的空操作(实测:仅 updated_at 被平台重新盖戳,updated_by/created_by/业务字段一律未变)。没有任何业务数据被改动,属修复的必然副作用,不阻塞;若日后想让已归档单对任何写入都显式拒绝,可在这条分支上另加「无业务字段亦拒绝」的判断

无新增 🔴 / 🟡。

阻塞与应修均已清零。结论:可合并。

@baozhoutao
baozhoutao merged commit f20269f into main Sep 4, 2026
1 check passed
@baozhoutao
baozhoutao deleted the issue-28-position-guard branch September 4, 2026 03:48
baozhoutao and others added 4 commits September 3, 2026 22:56
平台 17.2.0 执行动作体(按钮)用的上下文是「{ ...调用者上下文, isSystem: true }」,
发起人的 userId 原样保留(见 objectstack-ai/objectstack#2849)。于是「用户点了按钮」
和「系统自己写」在 isSystem 这一位上完全一样,以它为闸的岗位校验对按钮路径整体失效:
人力审核能越过领导审批节点通过,部门填报人员能审批和归档,加减分的岗位分离也一并落空。
经 REST 直接写入不受影响 —— 同一条规则在两条路径上是两个口径。

判据换成「有没有发起人」:isSystemWrite = 有系统标记且 actorId 为空。真正的系统写入
(种子、脚本、hook 内部自动推进)没有发起人,这一位把两者分得开。hasPosition 一处收口,
sheet / check-task / bonus / adjustment 四个 hook 的岗位闸同时恢复。

hook 内部自发的两处写入(全部分公司确认后自动推进填报单、驳回时重置核对任务)改用
sysNoActor —— 在 sudo 基础上摘掉发起人、保留租户与事务信息。它们不是任何人点出来的,
按发起人校验既没有对象也没有意义;而且节点审核岗位按方案可改(《设计方案》10.1 第 9 条),
不能指望「最后一个确认的人恰好持有该节点岗位」。

非岗位类的 isSystem 闸中,按钮动作体够得到的(加减分与数据调整的归档锁、已批准锁、
填报单的直改 status 与归档锁)同口径收紧;够不到的(填报单新建/删除保护、明细锁定、
快照与结果不可变)按原样保留并在代码里逐处写明理由 —— 收紧会打断发布、调整落地与
归档快照这些带发起人的系统写入。

按钮可见性未按岗位收敛:动作 visible 的 current_user.positions 装的是 auth 层角色,
拿不到业务岗位,已上报 objectstack-ai/objectstack#15136,hook 拦截为唯一防线。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
F1 —— 归档偶发半途失败,填报单永久卡在「已归档但无快照」:
after 阶段第一步 `updateById(id, {pending_action:null, action_reason:null})` 原本走
带发起人的 sys(ctx),isSystemWrite 为假,于是落进归档锁分支;而平台内建的
sys_stamp_audit_update(object '*',priority 10)在本 hook(100)之前把 updated_at /
updated_by 写进同一个 ctx.input,这两个字段不在 SCRATCH_FIELDS 白名单里,touched 非空
→ 抛 KPI_SHEET_ARCHIVED,其后的 writeReview / createSnapshot / regenerateResults 全不
执行,422 返回操作人;归档只能从「已通过」发起,重试被状态机拒绝,该单无法自愈。

两处一起改,缺一条都还会漏:
1. 清场写入改用 sysNoActor —— 它是系统动作,不是任何人点出来的,应当落在免检那一侧;
2. 归档锁的 touched 排除平台审计戳(created_at / created_by / updated_at / updated_by)
   —— 平台盖的戳不是用户改数据,不该被算成「改动」。

F2 —— 单测替身没有 sudo,sys() / sysNoActor() 双双退化成它自己,after 阶段与 sysNoActor
的真实行为零覆盖,F1 才得以在 23 条全绿的情况下逃逸。替身重写:
- FakeScopedContext 实现 sudo() 语义,executionContext 是可写实例属性(与平台一致),
  仓库在 object() 调用的一刻捕获上下文快照;
- FakeEngine 复刻平台写入时序:审计戳先于业务 hook 写同一个 input,且 hook 内部经
  ctx.api 发起的写入会再穿一遍 hook 链;
- 新增「归档 after 阶段清场 + 快照 + 审核记录」与「清场写入确实无发起人」两组用例,
  外加归档锁只锁业务字段的三条。两半修复各被一条用例单独钉住:只回退 sysNoActor 挂
  1 条,只回退审计戳白名单挂 1 条,两条都回退挂 3 条(已逐一实测)。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant