Files
MailUI4Agents/docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md
JianFeeeee 176c90272b 补充: 「差集」有**两个**成员(不是 §18.1 写的一个)+ 自愈守卫是**全有或全无** + 校正 43 的三个口径
pi 复现了我 §17 的两条撤回(含全量对照组 0 例外),并补上 §18 的机制
(迁移器只补一半)。我逐条核了他的数,全部成立;但**按他自己给的
那条方法机械算一遍**,发现 §18 把差集**说少了一个成员**,另有一处
自愈的适用条件说得太宽。本提交是他那封的**同一条方法的下一次应用**。

## 一、§18.1.1 差集是 2 个成员,不是一个

方法 = 「校验器要求 id」−「迁移器自愈 id」。机械枚举:
  VALIDATED: agent/inbox/spliced, assistant/message,
             session/title-llm-request, tool/result, user/message
  HEALED   : assistant/message, tool/result, user/message
  ⇒ 差集 = { agent/inbox/spliced, **session/title-llm-request** }

第二个成员同样"校验要 id、迁移器不补"(messageValue 经 exactRecord+
nonEmptyString(id);normalizeLegacyMessage 无此分支)。实测只剥它的 id:
  transformed → refuses ... session/title-llm-request 15 message lacks
                required member "id"
**同类、同后果**,但当前**潜伏**(全盘 109 个 v0 触发数 0 / 75 个文件带该事件)。

⇒ §18.2「只补 inserted 就够了」在当前数据上**仍然成立**,但成立的理由
**比 §18.1 写的窄**:不是差集只有一个成员,而是第二个恰好没被触发。
repair 脚本覆盖的是差集的 1/2 —— 若哪天 title-llm-request 丢 id,
**报错一模一样而脚本覆盖不到**。脚本头注释已写明该边界。

## 二、§18.1.2 自愈的前提是「全无」,不是「缺 id」

index.js:2179 的守卫是**全有或全无**:id/role/message 三个都不在才补。
实测(修好后的文件上只动一条 user/message):
  剥 id+role(真 v0 形状)→ OK(自愈)
  只剥 id(留 role)      → 拒绝
  只剥 role(留 id)      → 拒绝
真实数据能过,是因为 v0 的 87 条恰好全都没有 role。
⇒ 准确说法是"迁移器会给**完整的 v0 形状**补 id",半成品不在自愈范围。

## 三、校正「43」的三个口径(§18.2 表 + §12)

  · 被修的 v0 artifact        : 43 = 40 mail-* + 3 非邮件
  · --prefix mail- 验收候选   : 43 = mail-* 会话文件(含 v3)
  · mail-* 目录名             : 41
前两个都等于 43 **但不是同一个集合**(被修集合的 3 个非邮件,在验收
集合里换成 v3-only 的 mail-f8f9a840)。数字相同 ≠ 集合相同。
另核:.bak 共 80 个 v0(37 文件 ×2 + 6 ×1 = 80),去重后 43 —— 与 pi 一致。

## 四、我独立复现的最强对照(与 pi 一致)

从未修过的 v0 共 66 个:两档都 OK 37 / 两档都 FAIL 29(全是
subagent/descriptor v2)/**transformed OK & current FAIL 0**。
⇒ 那个组合确系测量产物。§18.3(id 只查 nonEmptyString)我读码确认。

未改动 pi 的 §18 正文;新增 18.1.1 / 18.1.2 与三处口径标注。
2026-09-19 12:47:32 +08:00

505 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# dsh 0.1.5 邮件通道全断:根因与修复(实测)
> 起因:pi 的「通道恢复测试」——新开线索 `name@path.new` 能否绕开损坏会话。
> 结论写在最前面:**能绕开,而且根因不是"旧数据不兼容",是一个一行的写入缺陷,
> 40 个损坏邮件会话全部可无损修复。**
## 0. 先回答 pi 的两个观察点
| 观察点 | 实测结果 |
|---|---|
| 这封 `.new` 是否失败 | **成功**。会话 `mail-f8f9a840-…` 于 11:18:26 新建,落盘 `session.v3.jsonl.zstd`,**不是** `already exists` |
| 失败是否说明 0.1.5 的 `create` 路径坏了 | 不是。`.new` 走 `create` 新 id,无冲突,**能写出** |
| 是否"只有旧会话读不了" | 方向对,但要更精确:**v0 会话里凡是 `agent/inbox/spliced` 缺 `id` 的都读不了**,与新旧无关——**这个缺陷今天还在写** |
## 1. 证据:新会话确实建出来了
journal(11:18:26,本次收信)里**没有** `already exists`,只有:
```
[dsh-mail-bridge] readSession(mail-f8f9a840-…) 抛错(按"不在磁盘"处理):
SessionQueryError: session "mail-f8f9a840-…" not found code=SESSION_QUERY_SESSION_NOT_FOUND
[dsh-mail-bridge] setup 回调触发 ...
```
`SESSION_QUERY_SESSION_NOT_FOUND`(尚未建)与 `already exists`(建冲突)是**两回事**。
磁盘核对:
```
~/.dsh/sessions/--home-program-agentmail--/mail-f8f9a840-…/
session.lock
session.v3.jsonl.zstd <- 新建成功,v3
```
## 2. 根因(不是"旧格式不兼容",是写入缺陷)
`~/.dsh/sessions/**` 共 110 个会话:**109 个 v0**(`session.jsonl.zstd`)+ 1 个 v3(上面这封新的)。
0.1.5-rc.2 读 v0 要跑官方迁移链 v0→v1→v2→v3,而卡在第一步:
```
@deepseek-ai/dsh-session-format-v0-to-v1 refuses this format v0 Session:
agent/inbox/spliced 5 inserted message lacks required member "id"
```
v0 解码器的 `messageValue()`(`dsh-session-format-v0-to-v1/lib/index.js:715-740`)要求
`inserted[]` 里每条消息都带 `id` **和** `role`;而实测旧版**只写了 `{content, source}`**。
对照当前写入方 `dsh-agent-loop/lib/index.js:206`:
```js
const event = this.session.append("agent/inbox/spliced", splice);
```
`splice.inserted` 直接沿用 inbox 里的消息对象,**未规范化 id/role**。
### 关键:这不是历史包袱,是**现在仍在发生**的写入缺陷
把这封**刚刚新建的 v3 会话**拿去校验:
```
[validation=transformed] READ OK
[validation=current] READ FAILED: seed user/message at index 10 lacks an identified message
```
其 `seq=5` 的 spliced 与 v0 **同形**:`inserted[0]` 的 keys 只有 `['content','source']`。
⇒ **v3 编解码器容忍缺 id,但"严格"校验不容忍**。
生产读路径 `dsh-session-persistence-jsonl/lib/index.js:983` 用的是
`validation: "transformed"`(不是 `"current"`,全仓无生产调用者),所以**今天不炸**;
但它埋着一颗雷:一旦有路径按"当前"档读这个新会话,就会以
`seed user/message at index 10 lacks an identified message` 失败。
**新建通道"现在能用"是"校验档位恰好宽松"的结果,不是"写对了"。**
## 3. 修复方案:只改一处,40/40 可无损复活
补 `agent/inbox/spliced` 的 `inserted[]` 缺的 `id`/`role`,**其余字节原样保留**。
⚠️ **范围必须精确**:很多 v0 会话里 `user/message` 也缺 id,但**顺手补它会适得其反**——
实测补了之后本来能读的会话反而变成 `released Session row N has seq gap`。
**只补 spliced 的 inserted,不要碰 user/message。**
### 逐层验证(都不是推断)
| 验证层 | 方法 | 结果 |
|---|---|---|
| 迁移链 | 官方 `sessionFormatCatalog.createRestore` 跑真实 v0 字节 | 修前 FAIL → 修后 OK |
| 严格档 | 同链 `validation:"current"`(**深拷贝**) | 修后 **OK(40/40)** —— 见 §17 的更正 |
| 生产档 | `recovery:"recoverable", validation:"transformed"` | 修后 OK |
| **端到端** | **`JsonlSessionPersistence.open(id,"read")` 真读盘** | **修前 FAIL → 修后 READ OK** |
| 批量 | 对全部 `mail-*` v0 会话跑真读路径 | **before 0 / after 33 可读**(agentmail 等 12 个 store) |
最硬的一条——修前报的错与 journal 里**逐字一致**,修后真的能读出来:
```
[BEFORE] stored log is corrupt: ... refuses this format v0 Session:
agent/inbox/spliced 5 inserted message lacks required member "id"
[AFTER ] READ OK — events returned: 5
```
### 汇总(pi 的 0/40 ↔ 我的 0/40,口径一致)
```
mail-* v0 会话: 40
修前可读: 0 <- 与 pi 的 0/40 完全对上
修后可读(生产档,逐会话重跑迁移): 40
```
pi 的「其它 34/28」也复现了,且能解释:28 个读不了的里
**29 处**是另一个独立缺陷 `subagent/descriptor 0 uses unsupported descriptor version 2`
(与邮件无关,不在本次修复范围)。
## 4. 直接回答:"`repair` 两帧方案值不值得做"
**值得,但比"两帧"更小。** 实际只需**一处**插入:
- 给 `inserted[]` 中缺 id 的消息补 `id` + `role: "user"`;
- **不要**同时补 `user/message`(会引入 `seq gap`)。
→ ⚠️ **后半句已作废**:见 §17。「补 user/message 会引入 seq gap」同样是
**同一批对象被验证器污染**造成的假象;深拷贝重测后,补与不补都 strict-ok。
32 个 `subagent/descriptor version 2` 是**另一个**问题,别混进同一个 repair。
## 5. ~~必须记一笔:`id` 是伪造的,会污染未来的严格校验~~(**已作废,见 §17**)
> ⚠️ **本节结论是错的,2026-09-19 下午用「深拷贝」重测后推翻。**
> 保留原文只为留痕:它正是 §10/§17 那个「校验会原地改写入参」陷阱的第一个受害者。
> 正确结论:伪造的 `id` **不会**污染严格校验,修后会话 **strict 也读得出来**。
~~补进去的 `id` 是**新造**的(`recovered-splice-<seq>-<n>`),原数据里没有。~~
~~证据:修后在**同样字节**上只换校验档,结果不同——~~
```
[recovered-splice / transformed] OK
[recovered-splice / current] FAILED: released Session row 35 has seq gap (expected 99, got 72)
```
⇒ ~~修出来的会话是**生产档可读、严格档不可读**。~~
**这段"证据"的真实成因**:先跑了一次 `transformed`(在**原对象**上),
验证器把补全的 `dt` 写回了那批对象;随后拿**同一批已被污染的对象**跑 `current`,
于是失败。换一批干净对象,两个档位都 OK。**是测量方法错,不是数据错。**
**这意味着:read-only 的修复(能读老线索)是稳的;但若要把它作为"可继续写入的活会话",
在 v3 严格校验下仍有风险。** 建议:
1. 先用它把**旧线索捞回来读**(低风险,立即见效);
2. 把写入侧的 `id`/`role` 规范化当成**真正的 bug 修**(`dsh-agent-loop` 落 spliced 前补全),
否则新建会话会**继续**埋同样的雷;
3. 长期正解是上游修迁移器:`id` 缺失时按确定性规则生成,并把该修复告知严格校验。
## 6. 不改上游代码的替代方案
若要"零改动"立即恢复通道,**`.new` 开新线索是有效的**(本次已证),
代价是:老线索仍需 repair 才能读回,且每条新线索都消耗一个会话 id。
两者不冲突,建议**先 repair 捞回老线索,同时继续用 `.new` 应急**。
## 7. 复现脚本与操作纪律
- 修复脚本:`scripts/repair-legacy-spliced-ids.mjs`(**默认 dry-run**)
- `node scripts/repair-legacy-spliced-ids.mjs --only mail-` —— 预演
- `--apply` 才写盘;**dsh.service 在跑时直接拒绝**(日志单写者)
- 写盘前逐会话重跑生产档迁移自检,不过就不写;原文件先 `.bak-<时间戳>`
- 本次操作纪律:**未停 dsh.service、未写盘、未回滚、未改上游代码**;
仅在 `/tmp` 与 `agentmail/.tmp` 做实验,已确认 `~/.dsh/sessions/` 下
`*.bak-*` 与 `*.repair-staged` 均为 0。
- 内存比 0.1.5 重要:先做 dry-run,停服,再 `--apply`,最后重启并看在途补投是否转正常。
## 8. 给下一位的三句话
1. **不是"旧数据不兼容"**——是 `agent/inbox/spliced` 的 `inserted[]` 缺 `id`/`role`,
且**当前版本仍在写**这个形状。
2. ~~**只补 spliced,别碰 `user/message`**——补后者会把可读会话变成 `seq gap`。~~
→ **已作废(§17)**:深拷贝重测后补 `user/message` **无害**(40/40 strict-ok);
当时看到 `seq gap` 是「校验污染了入参」的假象。
3. ~~**修出来的会话生产档可读、严格档不可读**~~ → **已作废(§17)**:
修后会话**两档都可读**(40/40)。
---
# 续篇(2026-09-19 下午):修复执行与「下一层」根因
上文 §2 的定位全部成立,本节记录**实际执行结果**与执行中暴露的两处新事实。
所有数字都来自 `scripts/verify-mail-sessions-readable.mjs`(**生产真读路径**
`JsonlSessionPersistence.open(id,"read")`),不是解码器口径的推断。
## 9. 执行结果
| 阶段 | 手段 | 结果 |
|---|---|---|
| v0 修复 | `repair-legacy-spliced-ids.mjs --apply`(离线窗口) | **40 个写入成功**(**mail-\* 子集**;全量实为 **43** = 40 + 3 非邮件,见 §18.2) |
| 数据完整性 | 逐文件「备份 vs 修复后」解压后 `cmp` | **逐字节相等**(只多出注入的 `id`/`role`) |
| 事件数 | 逐文件解压行数比对 | **40/40 一致**,零丢失 |
| 大小变化 | 例 `mail-d042cc4c` 25.2MB → 12.5MB | **纯重压缩**(单帧改 500 行/帧),非丢数据 |
| v3 修复 | `repair-v3-usermessage-ids.mjs --apply` | **2 个写入成功** |
| 真 mail-* 会话 | 最终验收(`--prefix mail-`) | **43/43 可读**(**会话文件**口径,与上一行的 43 **不是同一集合**,见 §18.2 表) |
反例留档:`~/.dsh/sessions/**` 仍有 3 个非邮件会话读不了,根因是
`subagent/descriptor ... unsupported descriptor version 2`,**与本问题无关**,
不要混进同一个 repair。
## 10. 执行中发现的第一个坑:验证器会**原地改写**入参
`repair-legacy-spliced-ids.mjs` 原先把同一批事件对象**既交给验证、又拿去写盘**。
实测 `sessionFormatCatalog.createRestore(...).decodeRow()` 会把补全后的
`dt` 数组**写回事件对象**(179 个事件里 5 个 `reasoning-chunks` 被改)。
后果:dry-run 说「40 个可修复」,`--apply` 却说「只修了 3 个、37 个自检失败」——
因为写出去的是**被验证器污染过的数据**,重读时报
`released Session row N has seq gap`。**验证一律在深拷贝上进行**(已修)。
这条的教训比它本身重要:**「同一个对象既用于校验又用于落盘」是个静默陷阱** ——
dry-run 与 apply 会给出不同结论,而两边看起来都"有据可依"。
## 11. 执行中发现的第二个坑(真正的「下一层」):桥不生成 message id
v0 修完后老线索可读了,但**下一封邮件进来立刻**抛:
```
[dsh-mail-bridge] new_mail 处理失败: message "undefined" is already pending
```
根因在**本仓**,不在 dsh:`plugins/dsh-mail-bridge/lib/message.js` 的
`userMessage()` 只产出 `{content, source}`。而 DSH 0.1.5 的 inbox 按
`message.id` 去重(`dsh-agent-loop/lib/index.js` 的投影 `apply()` 与 `mutate()`
各维护一个 `Set`,命中即抛 `message "${id}" is already pending`)。
id 全是 `undefined` ⇒ **第二条消息必挂**。
- 官方形状在 `@deepseek-ai/dsh-llm` 的 `createMessage()`:
`{id: brandString(randomUUID()), role, content, source}`;
- 同一份 dsh 里 **其它插件都用官方的 `createUserMessage()`**,只有这个桥手搓;
- 日志里最早的同类记录在 **2026-09-07**,累计 **50+ 次** —— 不是新 bug。
**为什么以前没炸**:v0 会话读不出来时,桥连 resume 都走不到,
`already exists` 先一步失败。修好读路径后,代码才第一次走到 followup。
## 12. 两条读路径为什么结论相反(回答 dsh 的判定请求)
dsh 观察到 `readSession()` 成功、`JsonlSessionPersistence.open()` 失败。
两者不是同一个东西:
| 路径 | 是否校验磁盘字节 | 结果 |
|---|---|---|
| `readSession()` → `SessionCorpus.load()` | **否**:`ctx.sessions.get(id)` 命中 live 会话时直接返回内存快照 | 成功 |
| `JsonlSessionPersistence.open()` | **是**:`validateStoredEvents → adoptSessionEvent → assertMessageEventShape`,要求每条 message 带非空 `id` | 失败 |
所以「`readSession` 成功」**不构成**「磁盘上的会话是好的」。
判定磁盘健康必须用 `open()` —— 这正是 `verify-mail-sessions-readable.mjs` 的职责。
另一个必须说清的点:dsh 报的 `seq 22171` 那条**不在 v0 里**(原始与修复后的 v0
都搜不到该 seq),它是 dsh 在 **11:43 resume 之后**新写进 v3 的注入消息
(`data` 只有 `{content, source}`,时间 03:43:22 = 本地 11:43:22)。
⇒ 它不是迁移产物,是**当时仍在跑的写入路径**的产物,与 §11 是同一个根因。
## 13. 修复内容(本仓,已部署)
- `plugins/dsh-mail-bridge/lib/message.js`:`userMessage()` 补 `id: randomUUID()`
与 `role: 'user'`;注释写清「为什么必须带 id」。
- `lib/message.d.ts`:接口同步。
- `test/message.test.mjs`:新增两条不变量 —— id 非空、两条消息 id 必须不同
(用官方 inbox 去重逻辑逐字复刻验证:修复前 `message "undefined" is already pending`,
修复后 20 封连投全唯一)。
- 部署:`deploy/redeploy-plugin.sh dsh`(快照 + 原子软链 + 重启 + 后置验证全绿)。
## 14. 验收(端到端,非推断)
重启后 journal 里同一会话从 `already exists` 变为:
```
12:01:21 [dsh-mail-bridge] 会话 mail-d042cc4c-… 已在磁盘上(cwd=未记录),改为 resume 续谈
```
并且该会话**持续增长**(22647 → 22685 事件),最新写入的 `user/message`
(seq 22656,12:01:28)带真实 UUID;修复上线后 `already pending` 计数为 **0**。
## 15. 给下一位的三句话(更新)
1. **两处根因,分属两侧**:dsh 侧 `agent/inbox/spliced.inserted[]` 缺 `id`(历史数据,
已修 40 个);本仓桥侧 `userMessage()` 不产 `id`(**仍在写**,已修 + 已部署)。
2. **判定磁盘健康只认 `open()`**:`readSession()` 走 live 内存快照,会掩盖磁盘损坏。
3. **校验与落盘不能共用同一批对象**:验证器会原地改写,dry-run/apply 会因此给出
相反结论。
## 16. 度量口径的一个坑:`--only` 是子串匹配
第一版验收用 `--only mail-`,得到「47 可读 / 3 不可读」。那 3 个**不是邮件会话**——
它们的目录名是普通 UUID,只是父目录是 `--home-program-agentmail--`,
而 `agentmail-` 里含子串 `mail-`,被误匹配进来。它们的错因是另一个独立缺陷
(`subagent/descriptor 0 uses unsupported descriptor version 2`)。
换成严格前缀 `--prefix mail-` 后的真实数字:
```
候选: 43 可读: 43 不可读: 0
```
⇒ **「真 mail-* 会话 43/43 可读,0 不可读」**。
度量口径本身也会制造假结论,所以脚本现在两个开关都提供:
`--only`(子串,跑路径片段)与 `--prefix`(目录名严格前缀,点名某类会话时用后者)。
## 17. 更正 §4 与 §5:那两条"风险"是**测量方法**造成的,不是数据性质
**触发**:pi 在回信里指出 §10 那个坑还有下半段 ——
「同一个对象既校验又落盘是个静默陷阱」。我据此把**自己的**两条结论重测了一遍,
**两条都推翻**。留档如下,因为它们恰好演示了同一个陷阱的第二次咬人。
### 17.1 更正一:伪造的 `id` 不会污染严格校验(§5 作废)
§5 的证据是「同样字节只换校验档,结果不同」。真实成因:
| 步骤 | 做法 | 结果 |
|---|---|---|
| ① | `transformed` 跑在**原对象**上 | OK(但验证器**已把 `dt` 写回原对象**) |
| ② | 拿**同一批被污染的对象**跑 `current` | FAIL: `seq gap` ← §5 引用的就是这条 |
正确测法(每次都给 `structuredClone`):
```
A) strict-first, fresh clone : OK
B) transformed on originals, then strict
on SAME objects : FAIL: row 35 seq gap ← 复现出那个假象
C) transformed(clone) then strict(clone) : transformed=OK strict=OK
```
并且**不换档位、只重跑**也是稳定的(同进程 3 次 × 3 个 OS 进程,strict 全 OK):
⇒ 这不是"不确定性",就是**入参被上一次校验改过**。
批量重测(深拷贝)**全部邮件会话**:
```
mailInjected strict-ok 40 strict-FAIL 0 (n=40) <- 修过的
otherUntouched strict-ok 37 strict-FAIL 0 (n=37) <- 从没修过的对照组
```
**从没修过的会话 strict 也全 OK** ⇒ 「strict 失败」与"伪造 id"无关。
### 17.2 更正二:补 `user/message` **无害**(§4/§8 那条作废)
同样用深拷贝重测 §4 那句「补 `user/message` 会把可读会话变成 `seq gap`」:
```
mail-* v0 需修复的会话: 40
spliced-only -> strict-ok: 40/40
spliced+both -> strict-ok: 40/40 <- 补 user/message 一样通过
```
更关键的是**迁移器本来就会给 `user/message` 合成 id**:
对**修复前的原始 v0**(25MB 那份)跑迁移,出来的 `user/message` 与 `inserted`
**带 id 386 条 / 缺 id 0 条**。
⇒ 「严格读路径要 id」与「别补 spliced」**并不冲突** —— id 是迁移器给的,
需要我们补的只有 `inserted[]`。pi 上封担心的"两个口径各要一份不同东西"**并不成立**。
### 17.3 结论(覆盖 §4/§5/§8)
1. 修复效果比原先说的**更好**:修后会话 **`transformed` 与 `current` 两档都可读**,
不只是"生产档可读";
2. 伪造 `id`(`recovered-splice-*`)**没有**留下严格校验隐患;
实测换成真 `randomUUID()` 或官方风格 id,两档同样 OK —— **id 的取值形式不影响结论**;
3. 真正该记的教训不是"数据有隐患",而是**"校验会改写入参,所以校验与落盘必须分对象"**——
它已经分别制造过 §10(dry-run 40 vs apply 3)和本节(假 `seq gap`)两次假结论。
4. 仍然成立的独立缺陷:`subagent/descriptor ... version 2`(29 个非邮件会话),与本问题无关。
## 18. 补上 §3/§4 一直没解释清的那句「为什么只补 `inserted`」
§3 说「只改一处」,但**从未说清为什么顶层 `user/message` 缺 id 就不用管**。
早先给的理由("补了会 seq gap")已被 §17 作废,于是这句话一度**没有理由**。
现在补上真正的机制——它是可推广的,不只是这一个 repair 的解释。
### 18.1 迁移器里有一处**不对称**
`dsh-session-format-v0-to-v1/lib/index.js` 里两个函数各管一半:
- **补 id 的**:`normalizeLegacyMessage()` 的 `switch` 只有 **3 个分支** ——
`user/message`、`assistant/message`、`tool/result`。
**没有 `agent/inbox/spliced` 分支**(全文件 `grep` 确认)。
⇒ 顶层消息缺 id,迁移器**自己会补**(`legacy-message:<sid>:<seq>`)。
- **校验 id 的**:`messageValue()` 对 `spliced.inserted[]` 里每条消息
**严格**要求 `id` + `role`(`nonEmptyString(message["id"], ...)`)。
⇒ **顶层缺 id 能自愈;`inserted[]` 缺 id 直接拒绝整条会话。**
这就是"只需补一处"的真正原因——不是取舍,是迁移器只补了一半。
#### 18.1.1 ★ 但「差集」有 **两个**成员,不是一个(dsh 2026-09-19 补)
§18.4 那条方法(**手工要补的 = 校验器要求 − 迁移器自愈**)是可以**机械算出来**的。
真按它算一遍,差集是 **2 个**,不是 1 个:
```
VALIDATED (messageValue 调用点所在事件): agent/inbox/spliced, assistant/message,
session/title-llm-request, tool/result, user/message
HEALED (normalizeLegacyMessage 分支): assistant/message, tool/result, user/message
>>> 差集 = { agent/inbox/spliced, session/title-llm-request }
```
**第二个成员 `session/title-llm-request` 同样是"校验要 id、迁移器不补"** ——
它走 `messageValue(value, …, version)`(`index.js:448`,经 `exactRecord` + `nonEmptyString(id)`),
而 `normalizeLegacyMessage()` 里**没有**它的分支(`assertTitleSources` 只做语义校验,不补 id)。
**实测(决定性)**:取一个真实带该事件的 v0,**只**剥掉它 `messages[]` 里的 `id`:
```
transformed(生产档)→ refuses this format v0 Session:
session/title-llm-request 15 message lacks required member "id"
```
⇒ 与 `spliced` **同类、同后果**(拒绝整条会话)。
**但目前是潜伏的**:全盘 109 个 v0 里,`title-llm-request` 消息缺 id 的有 **0 条**
(75 个文件带该事件,全都带 id)。所以 §18.2 的"只补 inserted 就够了"**在当前数据上成立**,
成立的理由却比 §18.1 写的窄:**不是因为差集只有一个成员,而是因为第二个成员恰好没被触发。**
★ 这条修正的是**结论的适用范围**,不是结论本身:
`repair-legacy-spliced-ids.mjs:101` 只处理 `agent/inbox/spliced`(差集的 1/2)。
若哪天某个 v0 的 `title-llm-request` 丢了 id,**同一个故障会以同一个报错复发,而 repair 覆盖不到**。
(`assistant/message`、`tool/result` 也缺 id 的实测为 0 条,且它们在**自愈**那一侧。)
#### 18.1.2 ★ 自愈的**前提是"全无"**,不是"缺 id"(dsh 2026-09-19 收紧)
§18.1 那句"顶层消息缺 id,迁移器**自己会补**"**说得太宽**。看 `index.js:2179` 的守卫:
```js
case "user/message":
if (Object.hasOwn(data,"id") || Object.hasOwn(data,"role") || Object.hasOwn(data,"message")
|| !Object.hasOwn(data,"content") || !Object.hasOwn(data,"source")) return event;
```
它是**全有或全无**的:只有 `id`、`role`、`message` **三个都不在**时,才补 `id`+`role`;
**任一在场就原样放过**(于是"有 role 缺 id"这种半成品**不会**被自愈,直接拒绝)。
**实测**(修好后的文件上,只动一条 `user/message`):
| 形状 | 结果 |
|---|---|
| `id`+`role` 都剥掉(**真 v0 形状**) | **OK**(被自愈) |
| 只剥 `id`(留着 `role`) | **拒绝**:`user/message 10 data lacks required member "id"` |
| 只剥 `role`(留着 `id`) | **拒绝** |
⇒ 真实数据能过,是因为 v0 的 `user/message` 恰好是"`id`/`role` 都没有"的**整块**形状
(实测 87 条**全部**没有 `role`)。**"迁移器会给顶层补 id" 的准确说法是
"迁移器会给**完整的 v0 形状**补 id"** —— 半成品不在自愈范围内。
### 18.2 实测(43 个文件,零例外)
> **计数口径(pi 2026-09-19 校正,我复核一致)**:全量是 **43**,不是 40。
> ```
> 40 个 mail-* + 3 个非邮件会话(session-016b4715 / session-2584924a / session-e10bd3e4)
> ```
> 那 3 个的错因**也是** `spliced.inserted` 缺 id(同一缺陷),被同一个脚本一起修了;
> 它们的仓库是 `--home-program-SlipOfNote--` 等非邮件 store。
> ⇒ 说「40/40」时指的是 **mail-* 子集**,说全量必须用 **43**。
> `.bak` 共 **80** 个 v0 备份(**37** 个文件有 2 份:03:36 那次中断 + 03:42 那次成功;
> **6** 个只有 1 份)⇒ `37×2 + 6×1 = 80`,**去重后才是 43**。
> (另有 2 个 `session.v3.jsonl.zstd.bak-*` 属 v3 修复,不计入这 80。)
```
样本(任一缺失): 43
其中顶层 user/message 也缺 id 的文件: 43(共缺 270 条)
spliced.inserted 共缺: 272 条
只补 inserted(**故意不碰** user/message)后跑 current 档:
产物里 user/message 仍缺 id 的文件数: 0 ← 迁移器全部自愈
```
⚠️ **"43" 这个数字在同一轮里有三个不同口径,别混用**(dsh 2026-09-19 实测):
| 口径 | 值 | 说明 |
|---|---|---|
| 被修过的 **v0 artifact** | **43** | 40 mail-* + 3 非邮件(§18.2 用的就是这个) |
| `--prefix mail-` 的**验收候选** | **43** | 43 个 mail-* **会话文件**(含 v3),见 §12 |
| mail-* 的**目录名** | **41** | 同名目录不在多 store 重复;另 1 个是 v3-only 目录 |
前两个都等于 43 **但不是同一个集合** —— 被修集合里那 3 个非邮件,在验收集合里换成
`mail-f8f9a840`(v3-only,**不在**被修集合里)。**数字相同 ≠ 集合相同。**
⇒ 「补 `user/message` 无害」(§17.2)与「只需补 `inserted`」(§3)
**两条都对,而且现在是同一件事的两面**:补了也无害,因为迁移器反正会覆盖成它自己的 id。
### 18.3 顺带:`id` 只要求「非空字符串」
`messageValue()` 对 id 的约束是 `nonEmptyString`,**没有格式校验**。
所以 `recovered-splice-<seq>-<n>` 这种确定性 id 完全合法——
这也从代码层面解释了 §17.1(伪造 id 不污染严格校验)。
### 18.4 给下一位的一句话
**判断"该补哪些字段"时,要同时读「校验器」和「迁移器」**:
校验器管"拒绝什么",迁移器管"自愈什么",
**真正需要手工补的是两者之差**(这里 = 校验器要求 − 迁移器自愈 = `inserted[]`)。
只读校验器会以为要全补;只读迁移器会以为不用补。
⚠️ 但**"差集"要算完整**(§18.1.1):按这条方法机械算出来的是
**{ `agent/inbox/spliced`, `session/title-llm-request` }** 两个成员,
不是一个。当前 repair 只覆盖了前者 —— 后者是**潜伏**的(磁盘上触发数 0)。
**方法给出的是"该补的集合",不是"这次补的那个文件"**:把差集**枚举完**再决定
要写几段代码,否则下一个成员触发时,报错一模一样而脚本覆盖不到。
★ 三条可复用(本节的另一半):
1. **"缺 id 会自愈" 要说成 "缺 id *且缺 role* 会自愈"** —— 自愈守卫常是**全有或全无**的
(§18.1.2 实测:留 `role` 只缺 `id` ⇒ 拒绝)。**别把"真实数据恰好长成的那个形状"
当成"自愈的判据"**。
2. **`nonEmptyString` 只查非空、不查格式**(§18.3)⇒
`recovered-splice-<seq>-<n>` 这种确定性 id 合法;**"没炸"不等于"校验器认可格式"**,
可能只是它根本没查格式。
3. **数字相同不等于集合相同**(§18.2 的表):同一轮里"43"有**三种口径**
(被修 artifact / 验收候选 / 目录名),前两个都恰好是 43 而是**不同集合**。
**报数时必须连着口径一起报**,否则审计会对着两个都叫 43 的东西各说各话。