Skip to content

Commit 68adf75

Browse files
committed
docs(adr): ADR-0118 非用户 actor 的平台契约 —— null 表示、fail-closed、显式 isSystem (#4608)
四处现场各自发明「系统在操作」的表示法(#4556 哨兵字符串、#2991 缺省即特权、 #4560 令牌下沉存储、#3166 自愿声明),本 ADR 统一为:用户 lookup 列中系统 actor = null(不造魔法账号、不存哨兵);缺 actor 一律 fail-closed,isSystem 是唯一显式提权入口;系统上下文只在入口点构造、跨异步边界显式重建;框架令牌 永不下沉存储层;归因靠关联字段,不设 actor_kind。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PRJtkgUAaVG11FsJQbvZWA
1 parent 45a5787 commit 68adf75

1 file changed

Lines changed: 124 additions & 0 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# ADR-0118: 非用户 actor 的平台契约 —— null 表示、fail-closed、显式 `isSystem`
2+
3+
- **状态**: Accepted(2026-08-02,维护者按四轴框架裁决:平台长远合理性 / 防 AI 作者静默犯错 / 实际业务需求 / 创业阶段不扩边界)
4+
- **日期**: 2026-08-02
5+
- **关联**: ADR-0049(enforce-or-remove)、ADR-0078(no-silently-inert-metadata)、
6+
ADR-0092(better-auth 身份写守卫)、ADR-0103(`managedBy` 写策略与引擎写守卫)
7+
- **执行项**: [#4556](https://github.com/objectstack-ai/objectstack/issues/4556)
8+
[#2991](https://github.com/objectstack-ai/objectstack/issues/2991)
9+
[#4560](https://github.com/objectstack-ai/objectstack/issues/4560)
10+
[#3166](https://github.com/objectstack-ai/objectstack/issues/3166)
11+
- **动因**: 四处现场各自发明「系统在操作」的表示法;不统一,每个新 PR 会继续各自发明,
12+
而统一它们要动审计数据迁移——越晚越贵
13+
14+
## 背景
15+
16+
### 同一个缺失概念的四个影子
17+
18+
平台里大量写入没有人类用户在场:启动同步、迁移、定时任务、生命周期扫描、流程自动化、
19+
AI 工具的服务端调用。每到这种时刻,系统都要回答同一个问题:**这次操作的行为主体
20+
(actor)是谁?** 现状是四处各答各的:
21+
22+
| 现场 | 当前答案 | 毛病 |
23+
|---|---|---|
24+
| `sys_metadata_history.recorded_by`#4556| `lookup('sys_user')` 列里存哨兵字符串 `'system'`(声明:`packages/metadata-core/src/objects/sys-metadata-history.object.ts:156`;写入:`packages/metadata-protocol/src/sys-metadata-repository.ts:423,545`| 类型撒谎:声明是外键、存的不是 id,join 断;读侧被迫 `?? 'unknown'` 兜底(同文件 `:317``:824`|
25+
| AI `ToolExecutionContext`#2991| 契约曾把「缺 actor」文档化为 system 级缺省 | fall-open:忘传上下文 = 拿到最高权限。契约文本已改为 fail-closed(`packages/spec/src/contracts/ai-service.ts:394-404`),执行器逐一验证仍未闭合 |
26+
| SQL driver(#4560| `current_user` 框架令牌被原样发射成列 DEFAULT | 框架层概念泄漏进存储层:数据库自己往 user lookup 列写非 id |
27+
| 引擎 `isSystem`#3166| 特权内部写自愿声明 `isSystem`(如 `packages/objectql/src/lifecycle/lifecycle-service.ts:39``SYSTEM_CTX`#4441 写路径守卫的 isSystem 豁免) | 惯例成立但自愿:不声明也没人拦 |
28+
29+
### 为什么这是一个问题,不是四个
30+
31+
四个局部修法各自都能自洽(哨兵 / 缺省 / 令牌 / 标志),但它们硬化的是**四个略有出入的
32+
「系统」概念**。放任各修各的,平台会同时存在「null 代表系统」「缺省代表系统」「字符串
33+
`'system'` 代表系统」三种方言:
34+
35+
- 按类型编程的代码(含 AI 生成的解引用 / join)在方言边界上踩空;
36+
- 在本仓工作的 AI 开发 agent 学到的是互相矛盾的模式,每个新调用点是一次掷骰子;
37+
- 三种方言最终要统一时,动的是审计类数据的迁移——这类数据只增不改,越晚越贵。
38+
39+
这与 ADR-0049 / ADR-0078 点名的失效族同构:**表示法的静默分叉,比任何一种表示法本身
40+
更坏。**
41+
42+
### 业界坐标,与本仓不能照抄的原因
43+
44+
Salesforce 用 Automated Process 伪用户行,ServiceNow 用 system 用户行。**本仓不能抄**
45+
有一个仓库特有的硬约束:`sys_user``managedBy: 'better-auth'` 的表
46+
`packages/spec/src/data/object.zod.ts:824-831`,ADR-0092)——身份驱动拥有全部写路径,
47+
密码哈希、令牌签发、邀请流都从它走。往里播种一行「永不登录的伪用户」等于绕过身份驱动
48+
写身份表;且该账号从此要在用户列表、邀请流程、许可计数、权限选择器等**每一个**面被
49+
排除,漏一处就是 bug——可被引用进权限授予时,是安全 bug。
50+
51+
## 决策
52+
53+
### D1 表示法:用户 lookup 列中,系统 actor = `null`;不造魔法账号,不存哨兵
54+
55+
所有指向 `sys_user` 的 actor 类列(`recorded_by`、审计 actor 列及后续新增):
56+
57+
- 系统发起的写入存 **`null`**,列声明放宽为 optional/nullable,describe 明确
58+
「null = 系统发起(boot 同步 / 迁移 / 调度任务等)」;
59+
- **禁止**哨兵字符串(`'system'``'unknown'` 等一切非 id 值);
60+
- **禁止**在身份表播种系统伪用户(理由见上节);
61+
- **显示是渲染规则,不是数据**:UI 对系统写入行的空 actor 渲染「系统」(走 i18n),
62+
不落库。
63+
64+
### D2 授权语义:缺席 ≠ 系统;缺 actor 一律 fail-closed
65+
66+
-`actor` → 该用户的上下文(RLS 生效);
67+
- `isSystem: true` → 系统上下文(RLS 旁路),显式、可 grep 的提权;
68+
- **两者皆无 → 匿名上下文(RLS 开、什么都看不见),永远不是 system。**
69+
70+
该语义已在 AI 工具边界落为契约文本(`ai-service.ts:394-404`#2991 的契约面修复)。
71+
本条把它升为**全平台规则**:任何执行路径——工具执行器、REST 处理器、任务运行器——
72+
不得把「上下文缺失」解释为特权。「没有身份」永远不是授权。
73+
74+
### D3 系统上下文只在入口点显式构造,跨异步边界显式重建
75+
76+
- 合法入口点(穷举,新增须修订本 ADR):boot/seed、迁移、任务调度器、生命周期扫描
77+
`SYSTEM_CTX`)、流程引擎的内部推进。入口点构造 `isSystem: true` 一次,沿调用链
78+
传播;
79+
- 跨异步边界(队列、定时器、重启恢复)时,接收侧是新的入口点,**显式重建**系统上下文,
80+
不隐式继承——这正是审批恢复类缺陷(#4420 一族)教的课;
81+
- #3166 是本条的执行器:把「特权内部写必须显式声明 `isSystem`」从自愿惯例升为可检查
82+
契约(lint / 审计)。
83+
84+
### D4 框架令牌永不下沉到存储层
85+
86+
`current_user` 等框架令牌只在应用层解析为具体值(用户 id,或按 D1 为 `null`),
87+
**不得进入**列 DEFAULT、生成 SQL 或任何持久化形态(#4560)。存储层看到的只有 id 和
88+
null,永远没有令牌。
89+
90+
### D5 归因粒度:actor 二分;「哪个自动化」由关联字段回答
91+
92+
- actor 维度只有两类:某个用户 / 系统(`null`)。**不引入** `actor_kind` 枚举列;
93+
- 追溯「是哪个自动化干的」用既有关联字段(flow run id、job 名、来源上下文),不在
94+
actor 上重复表达——那是双源(#4535 一族的教训)。
95+
96+
### D6 范围与非目标
97+
98+
**适用**:所有 actor 类审计列;`ToolExecutionContext` 与各执行器;驱动的 DDL/DEFAULT
99+
生成;引擎写守卫的 `isSystem` 豁免路径。
100+
101+
**非目标**(按「不扩边界」轴明确排除):
102+
103+
- `owner_id` —— 业务归属,归 `ownership` 轴管(ADR-0117);系统不拥有业务记录;
104+
- 多态 actor 引用(`actor_type` + `actor_id`)—— 表达力扩展,无业务需求不做;
105+
- 为「区分多个非用户 actor」预留的任何结构 —— 见下方升级路径。
106+
107+
## 后果
108+
109+
- **破坏性**(已在 v17 `protocol:breaking` 清单内):
110+
- #4556 —— 存量 `'system'``null` 迁移 + 列放宽(历史上该列只存过这一个哨兵,
111+
语义等价,changeset 写明);
112+
- #2991 执行面 —— 依赖 fall-open 的调用方行为变化(本来就是漏洞形态)。
113+
- **非破坏**#4560 驱动修复;#3166 声明收紧;UI「系统」渲染规则。
114+
- **升级路径**:将来若出现区分多个非用户 actor 的真实业务需求,以**新增归因字段**
115+
(加法)满足,不推翻 null 表示。
116+
117+
## 执行项
118+
119+
| Issue | 落什么 |
120+
|---|---|
121+
| #4556 | D1:`recorded_by` 可空 + 写路径存 null + 存量迁移 + describe |
122+
| #2991 | D2:逐执行器验证 fail-closed 已按契约实现(契约文本已落) |
123+
| #4560 | D4:驱动停止发射令牌 DEFAULT |
124+
| #3166 | D3:`isSystem` 显式声明的可检查化 |

0 commit comments

Comments
 (0)