Skip to content

Commit ff4447c

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-5265-save-receipt-truthful-wording
2 parents f4435b0 + 5ddce62 commit ff4447c

30 files changed

Lines changed: 2034 additions & 162 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": patch
4+
"@objectstack/lint": patch
5+
---
6+
7+
fix(lint,spec,objectql): 编写期表达式与 `highlightFields` 校验识别注册表注入的系统列 (#5378)
8+
9+
平台在每个业务对象上注入 `owner_id` / `created_at` / `organization_id` 等系统列,
10+
文档也把 `ownership: 'user'` 写作 "injects reassignable owner_id"。但编写期的两处
11+
校验只读**作者声明的** `fields`,于是注入列一律当作不存在:
12+
13+
- `validate-expressions.ts``buildFieldIndex``has(record.owner_id)` 直接
14+
报错 `unknown field owner_id`;
15+
- `highlightFields` 存在性检查对 `['owner_id']` 发出 "is not a field on this
16+
object — it is silently skipped by every consumer"。
17+
18+
也就是平台自己的 linter 否认平台自己的契约。结果是应用被迫**重声明系统列**才能通过
19+
编写期校验:hotcrm#548 为此在全部 12 个业务对象上显式声明了 `owner_id`(6 个对象曾
20+
`highlightFields` 警告,`contact_welcome` 触发器的 `has(record.owner_id)` 被硬
21+
拒)。这正是本项目视为缺陷的形状:能力已声明(列确实注入且有文档),但执行层不认。
22+
23+
**权威来源只有一份。** 新增纯派生 `resolveInjectedSystemColumns()`
24+
(`@objectstack/spec/data`)回答"这个对象带哪些系统列",并由 registry 的
25+
`applySystemFields()` **消费**它——沿用 #3786 为审计字段族确立的分工:spec 声明
26+
**有哪些**列,registry 拥有**每列长什么样**。lint 通过同一派生取答案,因此编写期
27+
判断与运行时注入不可能不一致(`@objectstack/lint` 的包契约是"只依赖 spec,绝不依赖
28+
运行时",此前它根本无法读到权威)。两个消费面共用同一判定,不各写一份。
29+
30+
**并入是按对象有条件的**,不是无条件放行整张系统列名单:`ownership: 'org' | 'none'`
31+
的对象没有 `owner_id`,那里的 `record.owner_id` 仍然是真错误并继续报;
32+
`tenancy.enabled: false``organization_id`;`systemFields: { audit: false }`
33+
审计四列;`systemFields: false` / `managedBy: 'better-auth'` 什么都不注入(只剩驱动
34+
提供的主键 `id`)。真正拼错的字段照旧被拒,并且注入列现在也进入 "did you mean?" 候选
35+
(`record.ownerid` → 提示 `owner_id`)。
36+
37+
被解析的注入列在诊断与补全语义上与授权字段等同;类型健全性与 null-guard 两个索引
38+
**刻意**仍只读声明字段,原因写在各自注释里:列的 `type` 与可空性属于 registry 的列
39+
定义,在 lint 侧另立一份就是本次要消灭的第二份副本,而 null-guard 喂的是会中断构建的
40+
判定,擅自并入会让今天能构建的 stack 变红。
41+
42+
注入行为本身零改动:`applySystemFields` 的输出在全条件矩阵上逐列不变(新增 parity
43+
pin 用实跑注入代码比对)。已显式重声明系统列的应用不受影响——重声明仍然合法,
44+
examples 三个 app 的 `os validate` 输出改动前后完全一致。
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
chore(objectql): retire `applyFormulaPlan`'s zero-caller `nowSnapshot` parameter and narrow its docstring to what actually holds (#5699)
6+
7+
`applyFormulaPlan` declared a fourth optional parameter `nowSnapshot?: Date`
8+
whose only effect was `nowSnapshot ?? new Date()`. Not one of its three call
9+
sites ever passed it — `find`, `findOne`, and the write-response hydration
10+
`hydrateWriteFormulas` added by #5504 — so the parameter went down the
11+
`new Date()` branch from birth. Dormant code, removed rather than archived: a
12+
parameter that looks live is worse than no parameter, because everyone
13+
reasoning from it concludes the caller can pin the instant, and one caller
14+
plainly should have.
15+
16+
No behaviour change (the removed branch was unreachable), no public API change
17+
(`applyFormulaPlan` is module-private and never exported).
18+
19+
The docstring claimed the eval context "mirrors `applyFieldDefaults`". Half of
20+
that was true — the same keys, so `formula` and `defaultValue` expressions share
21+
one vocabulary — and half was not: the two pin their own `now`.
22+
`applyFieldDefaults` is handed the insert's pre-write snapshot, while
23+
`applyFormulaPlan` reads the clock once per call, because a formula is evaluated
24+
when a record is materialized. So inside one `insert` a `NOW()` default and a
25+
`now()` formula observe two instants a driver round-trip apart (sub-millisecond
26+
in practice; across a second/day boundary they can land on different calendar
27+
days). The docstring now says so, and names #5699 as where making them share one
28+
instant would have to be argued — it would hand the write path a determinism
29+
guarantee the read path cannot have, which is a semantic decision, not a cleanup.
30+
31+
Adds the pins that the retired parameter's *appearance* was standing in for
32+
(`engine-write-formula-hydration.test.ts`): one snapshot per call shared by every
33+
row × every formula field, asserted by object identity on the eval context so a
34+
per-evaluation `new Date()` fails even when the milliseconds agree, on the write
35+
path and the read path alike; plus a tripwire that the default's instant and the
36+
formula's instant stay independently sourced.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): 退休登记按**确切 key** 判定 —— 无关簇的同名叶子不再让 tombstone 冒充「已登记」(#4659)
6+
7+
`scripts/build-schemas.ts` 的检查 (b)(`check:authorable-surface`)保证一件事:
8+
一个 authorable key 从 live 翻成 retired(`retiredKey()` 墓碑)时,这次退休必须
9+
被登记下来,否则 `spec-changes.json`(ADR-0087 D4)、生成的升级指南、`spec_changes`
10+
MCP 工具全是空的,消费者只能靠失败才知道。
11+
12+
它此前是这样判定「已登记」的:取 key 的**叶名**,拿去和**全部 major** 的所有
13+
conversion / migration `surface` 子句做 `endsWith('.' + name)` —— 完全不看 key
14+
属于哪个 def。于是任何一条无关登记,只要 surface 以同名叶子结尾,就把这个墓碑判为
15+
「已登记」:
16+
17+
- #4658 实测:`automation/Event:type` 被 tombstone、零 conversion,门禁全绿 ——
18+
命中的是 protocol 11 的 `flow-node-http-callout-rename`(`flow.node.type`),
19+
一个 flow 节点的 `type`,和状态机事件毫无关系。
20+
- #5509(ADR-0087 D2 `page-header-subtitle-alias`)登记
21+
`page.component.page-header.description` 之后,任何叶名为 `description` 的 key
22+
也进了同一个免检名单。`type` / `name` / `config` / `filter` / `schema` /
23+
`description` 都是 authorable 形状上最常见的叶子,这条保证对它们整体失效。
24+
25+
现在登记有了自己的表:
26+
27+
- **新增导出 `RETIRED_KEYS_BY_MAJOR`**(`@objectstack/spec`,
28+
`src/migrations/registry.ts`),值就是确切的 `` `${defKey}:${name}` `` 字符串 ——
29+
`authorable-surface.json` 怎么写它就怎么写,去掉 `[RETIRED]` 标记。
30+
- 检查 (b) 改为对这张表做**精确集合判定**:不再有 `endsWith`,不再取叶名,不再从
31+
相邻的 key 辐射过来。门禁失败时直接打印要粘贴的那一行和它该进哪个 major。
32+
- 新增检查 (b2):表里登记了一个**当前仍然 live** 的 key —— 一次没有任何东西消费的
33+
登记 —— 直接失败;它会替一次尚未发生的退休提前放行。登记了一个本次构建**已不再
34+
产出**的 key 则不是错误:墓碑满 ~2 个 major 之后由检查 (c) 放行其基线行,登记条目
35+
留下,这是预期稳态。
36+
37+
conversion 的 `surface` 保持散文形态、一个字没动:它面向作者、按作者书写元数据的
38+
形状表达(`flow.nodes[].outputSchema`),本来就无法可判定地映射回 def key。所以
39+
搬走的是机器事实,不是散文。一次退休仍然两样都要写:登记条目是**声明的凭据**,
40+
conversion 是**消费者照着做的处方**
41+
42+
不回填历史:检查 (b) 只在相对已提交基线的 live → retired ****跃迁上触发,而更早的
43+
墓碑在基线里已经是 `[RETIRED]`,不会再触发它。所以这张表读作「在确切-key 门禁下登记的
44+
退休」,不是「历史上的全部退休」—— 空表在 `main` 上实测全绿。

.claude/agents/os-dev.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,20 @@ Definition of done, in order:
168168
colour as information rather than as your verdict — every run after the label
169169
is exempt. Declaring the label in the PR body is not applying it: #5533 and
170170
#5538 each said so in prose and each still cost a PM hand-fix.
171+
- **Wait for CI to converge before you return the report — local green is not CI
172+
green.** Read the gate jobs' real conclusions on the PR (**ESLint** and
173+
**TypeScript Type Check**): this repo's family gates
174+
(`check:engine-double-contract`, `check:error-code-casing`,
175+
`check:route-envelope`, …) run *inside* the ESLint job, so one of them going red
176+
shows up there and nowhere in your local `pnpm test` output. A job still
177+
`in_progress` is not a pass, and the aggregate status is not the job's
178+
conclusion. #5584 reported on local green while its ESLint job had no
179+
conclusion yet; the job went red, the PR merged red anyway, and that red then
180+
rode main's merge ref into every later PR's ESLint job until #5615 hot-fixed it.
181+
A gate that goes red here is yours to fix in this task, not to report as done.
182+
This wait is **foreground polling** — the same legitimate blocking wait as
183+
`flock` in resource rule 1, and ⛔ never a background watcher you return from
184+
mid-task (resource rule 6 still binds).
171185
- Tear down anything you started (dev servers on random ports).
172186

173187
**Reverse verification — decide the expected direction BEFORE you run it.**

.claude/skills/pm-dispatch/SKILL.md

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ protocol is identical.)
107107

108108
## Operational notes(实测坑位)
109109

110-
队列与平台层的九条实测结论。共同点:**判据取命令的输出,不取 API 字段的字面值,
110+
队列与平台层的十二条实测结论。共同点:**判据取命令的输出,不取 API 字段的字面值,
111111
也不取本地工作树的现状,更不取「看起来相邻」的两行日志** —— 每一条都是在这一步上
112112
咬过人之后写下来的。
113113

@@ -242,6 +242,69 @@ draft、解绑 `Fixes`,免得一个错结论继续被当作已立案的事实引
242242
8 条讲过它的覆盖面)—— 两条 **issue** 描述同一个问题,没有任何门禁能看见,
243243
查重只能发生在立单前、你自己手里。
244244

245+
**10. 合并前认门禁 job 的结论,不认聚合读数 —— advisory 门禁红着合并会毒化全仓。**
246+
PR #5584 的新测试文件触发 `check:engine-double-contract`(这一族门禁挂在 **ESLint
247+
job** 里),该 job 19:53Z 结论 `failure`,而 PR 在红了 **19 分钟后照常过队合并**
248+
合并后,`main` 上这条红被 merge ref 带进**每一个后续 PR 的 ESLint job**,#5601
249+
直接中招;热修 #5615 才解除,治理侧另立 #5617。两句纪律:
250+
251+
- 合并前必须确认**承载门禁族的 job**(本仓即 ESLint、TypeScript Type Check ——
252+
`check:engine-double-contract` / `check:error-code-casing` /
253+
`check:route-envelope` 等都跑在 ESLint job 内)已达 **`completed: success`**,
254+
而不是「暂时还没出现 failure」。step 7 的 ACCEPT 那句 *once every check on the PR
255+
is green* 要读成「每个门禁 job 的**结论**已出且为 success」,`in_progress`
256+
不算数。
257+
- 「队列会把关」只对 **required** 检查成立。一条不在 required 集里的 advisory
258+
门禁,merge queue 的 merge_group 检查集同样看不见它 —— 它红着合并进 main 就是
259+
**共享损伤**,**任何车道发现都要立即止血 + 立单**(#5615 止血 / #5617 治理即
260+
此形)。#5617 是本条的治理半边(required 集怎么配),与本条互补:配置面归它,
261+
流程面归这里,两边都到位才关掉这个失效面。
262+
263+
**11. dev 子代理自己死了 ≠ 维护者中止 —— 不得据推断立一道谁也不敢退的门。** #5085
264+
dev 子代理零推送、零分支就没了(子代理正常的 `/compact`/中断死法,step 6 的探活与
265+
step 5「Handing off an interrupted dev」讲的就是它)。前任 PM 把它**推断**
266+
「维护者手动中止」,设了「是否重派等维护者示意」的门:该门从 08-05 07:00Z 立到
267+
08-06 02:42Z 解除,**近 20 小时**压着一个 p0-邻近的真 bug,直到维护者本人确认
268+
「没有中止」才发现是误判。两句纪律:
269+
270+
- 子代理消失(零推送 / 零分支 / 无报告)是**子代理的正常死法**,按 step 4 的
271+
**stale-claim reclaim** 处理(先探活 / SendMessage 复活,复活不成再回收重派),
272+
⛔ 不得推断为维护者意图。
273+
- 「维护者中止」只在有**显式信号**的记录时才成立 —— 维护者原话,或宿主明确回报
274+
*stopped by the user*。同一条 #5085 上两种都出过:08-05 07:00Z 那次是推断
275+
(误判,门压近 20 小时),08-06 04:12Z 那次是宿主信号(真中止,维护者两分钟后
276+
示意重派、门即解除)。**判据是信号,不是症状**:两次的症状(零推送、无分支)
277+
完全一样。没有显式信号就当死认领回收,⛔ 不要立一道没有重启条件的门 —— 那次
278+
的门只写在「认领解除」评论里、标签退回了 `pm:queue`,于是队列视图显示可派发而
279+
谁也不敢派,比 `pm:on-hold` 更隐蔽(状态机根本读不到它)。真需要 hold 就照
280+
状态模型办:`pm:on-hold` + 带**重启条件**的评论成对落地(「A hold without a
281+
restart condition is a state nobody can ever legally exit」)。
282+
283+
**12. 判「正文被 sanitizer 截断」必须双读取 —— 单一读法的尾部缺失先算读取端截断。**
284+
#5148 / #5149(2026-08-05,分诊座位)与 #5164(cli 车道 PM)被判为「正文已被 GitHub
285+
sanitizer 截断,不可分诊/派发」,据此挂起并要求原作者重贴。事后以两种读法复核 ——
286+
REST 取 `body`(原文 4321 / 5183 / 4181 字符)+ 取 `body_html`(渲染版)—— **三条
287+
正文都完整**,`<object>` / `<id>` 一类占位符全部落在行内代码或围栏内、未被吞。
288+
三条判读均不成立,真因是**读取端(工具输出)截断**被误读成 issue 端截断;代价是
289+
三条 issue 各白停摆 1–2 天(#5148 是有脚本化复现的可入队缺陷,#5149 / #5164
290+
应当尽早进维护者决策箱的裁决卡),外加三张打给作者的假工单。两句纪律:
291+
292+
- 判截断前必须**双读取**,`body_html` 要带 full 媒体类型才拿得到:
293+
294+
```bash
295+
curl -s "https://api.github.com/repos/<owner>/<repo>/issues/<n>" \
296+
-H 'Accept: application/vnd.github.full+json' # .body 原文 + .body_html 渲染版
297+
```
298+
299+
**两者在同一处断掉**才算 issue 端截断;任何单一读法的尾部缺失都先假定是读取端
300+
截断(工具输出上限、分页、`[:N]` 切片)。这与 notes 6「零命中必须用一个确定存在
301+
的邻近词反查」是同一条纪律的另一半 —— **缺失类读数在下结论前都要先证伪「扫描器
302+
坏了」这个解释**
303+
- step 0 的 **Repair first****停摆指令**,成本由作者承担,所以它的判据必须比
304+
其它分类更硬:误判一次的代价是一条可入队缺陷躺一天,外加一条打给作者的假工单。
305+
已发出的重贴指令若事后证伪,**要在同一处公开作废**(同 notes 7:诊断结论一旦
306+
公开发出又被推翻,更正要发在同样公开的位置)。
307+
245308
## Multi-repo coordination (backend / frontend / cloud)
246309

247310
The product spans three repos with a fixed dependency direction:
@@ -1129,7 +1192,7 @@ attached.
11291192
下面的停摆纠偏处理「带任务中状态的通知到了」;这一条处理更隐蔽的另一半:
11301193
**通知根本不来**。宿主进程重启会把运行中的 subagent 连同其完成通知一起
11311194
静默杀掉 —— 2026-08-05 实测,五个「在飞」dev 里三个(#5050/#5515/#5483)
1132-
已死数小时,批次视图仍显示 5/5,实际吞吐 2/5,零信号。规程三条:
1195+
已死数小时,批次视图仍显示 5/5,实际吞吐 2/5,零信号。规程五条:
11331196

11341197
- 每次巡检(定时器唤醒、轮间隙)对**每个已派发且尚无远程分支/PR**
11351198
dev 发一次状态询问(SendMessage,措辞「回一段简报后继续干活」,不改变
@@ -1140,6 +1203,17 @@ attached.
11401203
优先用它;resume 不可用时才走接手协议。
11411204
- 判据永远取正向证据(远程分支、PR、报告、探活回包),⛔ 绝不把「还没
11421205
收到失败通知」读作「还在跑」。
1206+
- **定时器重挂是每次巡检的第一动作,不是最后一个**(维护者 2026-08-06
1207+
授权)。巡检执行到一半被打断(穿插提问、事件风暴、会话中断)时,排在
1208+
末尾的重挂会整个丢失,守夜链就此断裂 —— 2026-08-06 实测:一次漏挂让
1209+
四连灭批静默了 ~100 分钟而不是探活门槛设计的 ≤45 分钟。先挂后查,链条
1210+
对中断免疫;挂错了间隔可以在本轮末尾用 delete_trigger + 重挂修正,但
1211+
「没挂」无法被本轮以外的任何机制补救。
1212+
- **批量在飞期间,主巡检间隔不得长于 45 分钟**(同一授权)。探活门槛是
1213+
45 分钟,巡检间隔一旦超过它,门槛就成了写在纸上的数字 —— 最坏情形下
1214+
一个派发后即死的 agent 要等到下一轮巡检才被发现,静默窗口 = 巡检间隔,
1215+
而非门槛值。在飞清零的待命期可放宽到 60-70 分钟;有任何 dev 在飞即收紧
1216+
回 ≤45,灭批频发期(如宿主重启风暴)进一步压到 20-30 分钟。
11431217

11441218
**A stalled subagent is this half's most common failure, and it never
11451219
self-heals.** When a dev stops mid-task reasoning that "a background watcher will
@@ -1206,6 +1280,12 @@ against the report's own claims:
12061280
plainly unrelated to the issue.
12071281
- Test evidence in the report shows the actual commands and passing output,
12081282
not a bare "tests pass".
1283+
- **报告到达 ≠ CI 收敛。** arm auto-merge / 入队前**亲核门禁 job 的结论** —— 不止
1284+
`pull_request_read get_status` 那个聚合读数,要看 ESLint 与 TypeScript Type Check
1285+
这两个具体 job 的 `conclusion` 已为 `success`(门禁族都跑在它们里面,Operational
1286+
notes 10)。dev 可能在自己的 ESLint 还没出结论时就交了「本地绿」的报告 ——
1287+
#5584 的 advisory 红就是这样漏过复核、红着合并进 main 的;os-dev 定义侧已要求
1288+
「PR 开出后等 CI 收敛再交报告」,本条是它在复核侧的对账。
12091289
- The diff plausibly satisfies the issue's acceptance criteria.
12101290
- **收益穿过它必经的那道边界之后还在吗?** 判据(不是每单都做):这批工作的价值主张
12111291
是否**依赖某个下游组件如实转发** —— HTTP 错误信封、序列化、日志汇聚、跨进程传输。

0 commit comments

Comments
 (0)