来源:宣传视频车道 #147(一句话新模块·请假管理)实拍取证时发现。未改动任何平台代码,本单为只读取证 + 一次真实审批流程实跑。
标为 Feature + needs-user-decision(而非 bug)是刻意的——是否算缺陷、以及该往哪个方向修,请人类负责人裁定后再排开发,不要被自动流程直接当任务处理。
正文订正记录:观察时长由「3 分钟」收窄为**「2 分 47 秒」**(实际轮询跨度 16:53:52→16:56:39,命令在 3 分钟处被超时切断,最后一轮未走完),并补上关键限定「每次均为独立的服务端查询」——原文未写清这一点,而它才是「排除异步延迟」能成立的理由。详见评论区回查记录。
现象
一个用「审批流改状态 + 按状态共享」这个常见组合搭起来的模块:员工提交请假 → 主管在审批收件箱批准 → 记录变 approved。
批准之后,同团队的另一位员工看不到这条记录;而更早的几条已批准记录却看得到。连续观察 2 分 47 秒(每 15s 一次共 12 次,每次都是重新发起的独立服务端查询,不是盯着一个已加载的页面)状态不变——不是异步延迟。
⚠ 「每次都是独立查询」这个限定是必须的:该平台的列表/日历加载时取一次、不自动刷新,若观察方式是盯着一个已打开的页面,则「状态不变」什么也证明不了。本单的这 12 次是 curl 轮询(每轮一次全新的 POST /api/v1/data/<object>/query,带观察者自己的 cookie jar),没有页面、没有前端缓存,故该限定成立。
对该共享规则调一次 POST /api/v1/sharing/rules/<id>/evaluate(以 admin 身份)后,再发一次全新的查询即恢复正常:
{"matchedRecords":4,"expandedUsers":2,"grantsCreated":2}
该 evaluate 与其后的复核之间没有任何写操作,唯一的状态变更就是那次 evaluate;「恢复正常」也是靠一次全新的服务端查询看到的,不是页面自己变或刷新所致。
根因(代码级判定点,发行包 @objectstack/plugin-sharing/dist/index.mjs)
平台本来就支持逐记录实时物化:bindRuleHooks() 为每个有活跃规则的对象注册 afterInsert / afterUpdate / afterDelete(:5109 / :5125,priority 180),逐记录调用 service.evaluateAllForRecord();该方法自己的注释即写着「the per-record pass the afterInsert/afterUpdate hooks run」(:3859)。
但两个钩子的第一行就把系统写挡掉,且不做任何补偿:
if(ctx?.session?.isSystem){noteSystemWriteSkipped(objectName);return;}// :5110-5113 / :5126-5129跳过后只发一条 info 通知(:5059),原文逐字:
[sharing-rule] sharing materialisation skipped for isSystem writes; re-evaluate rules or restart to backfill
而审批回写恰恰是系统写:审批节点以平台身份写 approvalStatusField,且 lockRecord: true 决定了此时只有平台写能落地。
链条:批准 → 系统身份写状态字段 → 共享物化被跳过 → 团队看不见 → 直到有人手动 evaluate 或重启服务。
⚠ 影响面取决于观察者的角色(2026-08-31 补,见评论区)
本单现象只对「不持 viewAllRecords、依赖共享规则看他人已批准记录」的角色可观测。 持 viewAllRecords: true 的主体——包括本例中的审批人本人与 admin——不受影响,因为他们的读路径根本不经过共享规则。
这对复现有直接影响:用审批人或管理员视角去复现,会看不到问题。 必须用一个只能看自己、靠共享规则看他人的普通成员身份。
为什么值得单独看,而不是「用法不对」
- 不是设计上的批量物化:平台明明注册了实时钩子,只是不认系统写;
- 搭建方没有声明式手段规避:
spec/security/sharing.zod.ts 无相关字段;全包内无 OS_SHARING* 之类配置,也没有「让系统写参与重算」的选项; - 文档与测试零说明:
content/docs/、docs/、test/ 全仓检索不到「审批后何时可见」的任何说法,也搜不到那条通知语; - 影响面不限于请假:任何「审批改状态 + 按状态共享」的组合都会中招,而这恰是审批类模块最常见的形状。
待裁的方向(供参考,不预设结论)
- 系统写也参与逐记录物化(可能需要考虑批量导入/回填场景的性能与放大);
- 系统写跳过后入队补偿,异步补算;
- 保持现状但给出声明式开关(规则或对象上的字段),让搭建方按对象选择;
- 保持现状但把它写进文档,并让审批插件在这种组合下给出构建期告警。
邻近既有单(已查,非重复)
复现最小路径
- 建一个对象,声明
type:'approval' 节点 + approvalStatusField + lockRecord:true; - 建一条 criteria 共享规则,条件按该状态字段(如
status == 'approved'),收件人为某真实 position; - 以普通用户提交一条记录,以审批人身份走
POST /api/v1/approvals/requests/<id>/approve; - 以该 position 下的另一位用户——⚠ 必须是不持
viewAllRecords 的普通成员,且每次重新发起查询(不要盯着一个已加载的页面)——查询该对象 → 看不到刚批准的记录; - 对该规则调一次
evaluate,再重新查询一次 → 立即可见。
现象
一个用「审批流改状态 + 按状态共享」这个常见组合搭起来的模块:员工提交请假 → 主管在审批收件箱批准 → 记录变
approved。批准之后,同团队的另一位员工看不到这条记录;而更早的几条已批准记录却看得到。连续观察 2 分 47 秒(每 15s 一次共 12 次,每次都是重新发起的独立服务端查询,不是盯着一个已加载的页面)状态不变——不是异步延迟。
对该共享规则调一次
POST /api/v1/sharing/rules/<id>/evaluate(以 admin 身份)后,再发一次全新的查询即恢复正常:根因(代码级判定点,发行包
@objectstack/plugin-sharing/dist/index.mjs)平台本来就支持逐记录实时物化:
bindRuleHooks()为每个有活跃规则的对象注册afterInsert/afterUpdate/afterDelete(:5109/:5125,priority 180),逐记录调用service.evaluateAllForRecord();该方法自己的注释即写着「the per-record pass the afterInsert/afterUpdate hooks run」(:3859)。但两个钩子的第一行就把系统写挡掉,且不做任何补偿:
跳过后只发一条 info 通知(
:5059),原文逐字:而审批回写恰恰是系统写:审批节点以平台身份写
approvalStatusField,且lockRecord: true决定了此时只有平台写能落地。链条:批准 → 系统身份写状态字段 → 共享物化被跳过 → 团队看不见 → 直到有人手动
evaluate或重启服务。⚠ 影响面取决于观察者的角色(2026-08-31 补,见评论区)
本单现象只对「不持
viewAllRecords、依赖共享规则看他人已批准记录」的角色可观测。 持viewAllRecords: true的主体——包括本例中的审批人本人与 admin——不受影响,因为他们的读路径根本不经过共享规则。这对复现有直接影响:用审批人或管理员视角去复现,会看不到问题。 必须用一个只能看自己、靠共享规则看他人的普通成员身份。
为什么值得单独看,而不是「用法不对」
spec/security/sharing.zod.ts无相关字段;全包内无OS_SHARING*之类配置,也没有「让系统写参与重算」的选项;content/docs/、docs/、test/全仓检索不到「审批后何时可见」的任何说法,也搜不到那条通知语;待裁的方向(供参考,不预设结论)
邻近既有单(已查,非重复)
grant()skips the ADR-0111 D7 inert-grant guard entirely for SYSTEM callers, so the sharing-rule evaluator can still materialise rows no gate consults #8207grant()skips the ADR-0111 D7 inert-grant guard for SYSTEM callers(同样是 SYSTEM 路径,但方向相反:那条是系统调用绕过了守卫,本条是系统写被守卫挡掉)复现最小路径
type:'approval'节点 +approvalStatusField+lockRecord:true;status == 'approved'),收件人为某真实 position;POST /api/v1/approvals/requests/<id>/approve;viewAllRecords的普通成员,且每次重新发起查询(不要盯着一个已加载的页面)——查询该对象 → 看不到刚批准的记录;evaluate,再重新查询一次 → 立即可见。