dsh-log-contract 0.3.10 → 0.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/CONTRACTS.md CHANGED
@@ -1,15 +1,20 @@
1
1
  # 契约规则目录(CONTRACTS)
2
2
 
3
+ > **自动生成**(2026-09-06 起):本文件由 `node scripts/gen-contracts-doc.mjs`
4
+ > 从 `lib/contracts.js` 的 `CONTRACT_RULES` 注册表生成——**勿手改**,规则只增不减,
5
+ > 新增规则后跑一次生成即同步(此前手工维护滞后 15+ 条,外部审计指出)。
6
+ >
3
7
  > DSH 会话日志契约的**可执行 spec**。每条规则在 `lib/checks.js`(逐事件判定)
4
8
  > 与 `lib/prewrite.js`(写前校验)中有对应实现;离线体检(`lib/validate.js`)
5
9
  > 逐条执行并在最后用官方 `foldSurface` 终验(S8)。
6
10
  >
7
11
  > 规则来源:`dsh-scale-audit-疑点记录.md`(59 条审计发现)+ `复盘-会话修复事故-20260825.md`
8
- > (三层契约)+ `@deepseek-ai/dsh-session@0.1.0-rc.7` 官方源码逐行核对。
12
+ > (三层契约)+ `@deepseek-ai/dsh-session@0.1.0-rc.7` 官方源码逐行核对
13
+ > (后续规则随官方版本演进追加:T3/T4 渲染层 = 1e99e1ff 复盘,T5 = 1f4d986e malformed)。
9
14
  >
10
15
  > 严重度:**error** = 违反即会话不可加载/写入被拒(fail-loud);**warning** = 合法但可疑。
11
16
 
12
- ## 规则索引
17
+ ## 规则索引(共 38 条)
13
18
 
14
19
  | id | 严重度 | 层级 | 规则 |
15
20
  |---|---|---|---|
@@ -20,6 +25,8 @@
20
25
  | R3 | error | persistence | chunk 行展开后成员 seq/time 安全 |
21
26
  | E1 | error | persistence | 每个事件携带非负安全整数 seq |
22
27
  | E2 | error | persistence | seq 严格连续(单写入者假设) |
28
+ | S9 | error | persistence | 文件物理序 seq 单调(多写入者交织现场特征) |
29
+ | I1 | error | engine | inbox seed 相对重放(fork 边界孤儿 spliced) |
23
30
  | E3 | error | persistence | type 必须在已知词汇表内(或带 ignorable 标记) |
24
31
  | E4 | error | persistence | data 与 surface 元数据必须 JSON 无损 |
25
32
  | E5 | error | persistence | 禁用遗留词汇 |
@@ -28,16 +35,27 @@
28
35
  | S2 | error | persistence | 非 surface 类型不得携带 surface 元数据 |
29
36
  | S3 | error | persistence | append 的 sourceEventSeqs 契约 |
30
37
  | S4 | error | persistence | replace 操作数与范围合法性 |
31
- | S5 | error | persistence | replace 的 sourceEventSeqs 必须完整覆盖被替换节点 |
38
+ | S5 | error | persistence | replace 的 sourceEventSeqs 必须完整覆盖被替换节点 |
32
39
  | S6 | error | persistence | sourceEventSeqs 自身约束 |
33
40
  | S7 | error | persistence | tool/result 替换仅允许单节点内容改写 |
34
- | S8 | error | persistence | 整日志 foldSurface 可重放(终验) |
41
+ | S8 | error | persistence | 整日志 foldSurface 可重放 |
42
+ | T1 | error | engine | token-meter 配对:assistant/message 与 step/end 必须匹配当前打开的 step/start |
43
+ | T2 | error | engine | token-meter 源引用:assistant/message 的 sourceEventSeqs 引用的 chunk 必须同 turn/step |
44
+ | T3 | error | engine | step 节点 key 唯一(同 turn 内 step/start 的 step 号不得复用) |
45
+ | T4 | error | engine | step/消息本体 turn 缺失(null/undefined)→ 渲染死循环 |
46
+ | T5 | error | engine | turn/end 必须带 data.reason.kind |
47
+ | E7 | warning | persistence | ignorable 未知 type 合法性(带被忽略标记的未知事件须有消费者) |
48
+ | Z3 | warning | framing | 空会话文件(有 header 无事件)显式报出 |
49
+ | P3 | warning | plugin | tool/call ↔ tool/result 配对完整性(考古 B1) |
50
+ | P4 | warning | plugin | tool/result 输出结构可解析(考古 B2) |
35
51
  | M1 | error | engine | turn/step 为 null 的 assistant/message 只能 replace,不能 append |
36
52
  | P1 | warning | plugin | marker id 前缀必须被识别 |
37
53
  | P2 | error | plugin | marker 自身 seq 不得出现在自身 shadowed 集 |
38
54
  | C1 | warning | concurrency | seq 缺口/倒退提示多写入者 |
39
55
  | Z1 | warning | framing | zstd 尾帧撕裂 |
40
56
  | Z2 | error | framing | zstd 帧解码失败 = 单帧全损 |
57
+ | W1 | error | engine | wire 流:tool 消息必须跟在带 tool-call 的 assistant 消息之后 |
58
+ | W2 | error | engine | wire 流:user 文本不得插在 tool_calls 与其 tool 结果之间 |
41
59
 
42
60
  ## 详细规则
43
61
 
@@ -83,6 +101,18 @@
83
101
  - **出处**: @deepseek-ai/dsh-session lib/index.js:398 (planSurfaceEvent "not contiguous");审计 S2/N6
84
102
  - **契约**: seq 必须从 0(或窗口 baseSeq)严格连续递增。缺口/倒退 = 违反单写入者假设(多实例共享存储并发写的痕迹),加载时直接 throw。
85
103
 
104
+ ### S9 — 文件物理序 seq 单调(多写入者交织现场特征)
105
+
106
+ - **层级**: persistence | **严重度**: error
107
+ - **出处**: 2026-08-28 实锤:526f1835 文件物理序 734056→733539→735470;单进程 appendCore 断言 seq==cursor+i 且按 id 串行化不可能写出
108
+ - **契约**: 按文件物理行序要求展开后事件 seq 严格单调递增。E2 在排序后检查(loadSessionLog 会 sort),物理序倒退被掩盖;S9 在排序前按行序检查,非单调 = 多写入者/旧光标回放交织的直接现场证据,加载会被拒。
109
+
110
+ ### I1 — inbox seed 相对重放(fork 边界孤儿 spliced)
111
+
112
+ - **层级**: engine | **严重度**: error
113
+ - **出处**: @deepseek-ai/dsh-agent lib/types/inbox.js:155-178 (apply/validate);2026-08-28 实锤:62c5b531/73ed35d8 fork 边界 removedCount=1 孤儿
114
+ - **契约**: 从 header.seedLength 起重放 agent/inbox/spliced,next-turn/next-step 双队列;start+removedCount 不得超过队列长、不得产生重复 pending id。fork 时"移除父待处理提示词"的 splice 假设父会话 inbox,子会话 seed 相对空 inbox 上非法 → resume 被拒(invalid persisted inbox splice)。
115
+
86
116
  ### E3 — type 必须在已知词汇表内(或带 ignorable 标记)
87
117
 
88
118
  - **层级**: persistence | **严重度**: error
@@ -155,6 +185,60 @@
155
185
  - **出处**: @deepseek-ai/dsh-session lib/index.js:444-455 (foldSurface);复盘"官方 foldSurface 不抛 = 通过"
156
186
  - **契约**: 终验:把全部事件按序喂给官方 foldSurface,不抛 = 持久化层通过。S1–S7 任何一条违反都会在此暴露。
157
187
 
188
+ ### T1 — token-meter 配对:assistant/message 与 step/end 必须匹配当前打开的 step/start
189
+
190
+ - **层级**: engine | **严重度**: error
191
+ - **出处**: @deepseek-ai/dsh-token-meter lib/index.js:566-625 (_foldEvent)
192
+ - **契约**: token meter 折叠要求 assistant/message 与 step/end 与打开的 step/start(turn/step 完全一致)匹配;违反即 /compact 与压力测量永久失败。retrace 的 turn-null 编辑/撤回 marker(空 assistant/message replace)命中此条——foldSurface 认可其合法性但 token meter 崩溃(M1 只约束 append 形态的盲区),压缩前需清理。
193
+
194
+ ### T2 — token-meter 源引用:assistant/message 的 sourceEventSeqs 引用的 chunk 必须同 turn/step
195
+
196
+ - **层级**: engine | **严重度**: error
197
+ - **出处**: @deepseek-ai/dsh-token-meter lib/index.js:634-650 (_estimateProviderAssistant,:645 belongs to another step)
198
+ - **契约**: token meter 重建 provider 输出时,逐条检查 assistant/message 的 sourceEventSeqs:指向 assistant/chunk 的引用必须与消息同 turn/step,且 seq 更早、不重复;跨 step 引用 → 官方抛 belongs to another step → 每次事件追加都重抛(consumedEvents 不前进)→ 刷屏压垮 host(2026-08-30 实测 526f1835 seq 936047 跨 step 7/8/9)。T1 只查 step 配对不查源引用,此条补盲区;修复用 fix --clip-crossstep。
199
+
200
+ ### T3 — step 节点 key 唯一(同 turn 内 step/start 的 step 号不得复用)
201
+
202
+ - **层级**: engine | **严重度**: error
203
+ - **出处**: 复盘 2026-09-02 1e99e1ff 白屏(修复线 session-3f9e4f12):客户端渲染节点 key = turn:step,冲突 → React 渲染死循环;工具 tools/check-step-keys.mjs
204
+ - **契约**: 客户端渲染消息列表从事件流构建节点,节点 key = data.turn:data.step。同 turn 内两个 step/start 的 step 号相同 → key 冲突 → React 渲染死循环 → 白屏/不展示(1e99e1ff:580034 step 95/1 vs 580037 编辑块 step 95/1;6924781d/97786207/4b149a4a 同型)。修复:同 turn 内 step 递增、整块重编号(含块内 chunk/tool/assistant)。
205
+
206
+ ### T4 — step/消息本体 turn 缺失(null/undefined)→ 渲染死循环
207
+
208
+ - **层级**: engine | **严重度**: error
209
+ - **出处**: 复盘 D8 1e99e1ff(2026-09-01):retrace 0.4.17 编辑块 turn:null;修复线 tools/check-null-turn.mjs 判致命
210
+ - **契约**: 客户端渲染状态机对 turn=null 的 step/start|step/end|assistant/message 无法归属任何 turn → 渲染死循环 → 白屏「载入历史」(1e99e1ff seq 580037-580039)。user/message 天然无 turn 不查;chunk 坐标可缺失不查。step/消息本体必须带真实 turn 号。
211
+
212
+ ### T5 — turn/end 必须带 data.reason.kind
213
+
214
+ - **层级**: engine | **严重度**: error
215
+ - **出处**: 官方 dsh-agent-loop lib/index.js:620(turn/end = {turn, reason:{kind}});1f4d986e malformed turn/end 事故(2026-09-02,修复线 check-turn-end-reason.mjs)
216
+ - **契约**: 官方 validation 强制 turn/end 的 data.reason.kind 存在(kind ∈ completed|max-tokens|blocked|aborted|error|interrupted)。缺失 = malformed → 官方 SessionPersistenceCorruptionError → 会话加载失败。1f4d986e:retrace 情形③信封 turn/end 漏 reason → 每次编辑后加载失败(已修 0.4.18)。
217
+
218
+ ### E7 — ignorable 未知 type 合法性(带被忽略标记的未知事件须有消费者)
219
+
220
+ - **层级**: persistence | **严重度**: warning
221
+ - **出处**: 反向挑刺 2026-09-09 T2(E3 ignorable 无合法性校验 = 后门)
222
+ - **契约**: 未知 type + ignorable:true 被读路径接纳但无人消费 = 静默垃圾。排除已知消费者白名单(retrace/marker、retrace/goal-marker、message-editor/ 前缀等 retrace 客户端消费的插件 marker)后,其余 ignorable 未知事件报 warning。
223
+
224
+ ### Z3 — 空会话文件(有 header 无事件)显式报出
225
+
226
+ - **层级**: framing | **严重度**: warning
227
+ - **出处**: 反向挑刺 2026-09-09 T3(36 条规则全来自有内容事故,空态无覆盖)
228
+ - **契约**: 有 header 但零事件 = 异常空会话(新建即空或写入未落盘)。空态不在任何有内容规则的覆盖下,显式 warning 供人判断。
229
+
230
+ ### P3 — tool/call ↔ tool/result 配对完整性(考古 B1)
231
+
232
+ - **层级**: plugin | **严重度**: warning
233
+ - **出处**: dsh-会话日志考古-插件任务与方法.md §2/§4.2(callId 配对,不可用"上一个 call"推断)
234
+ - **契约**: 每个 tool/call 的 data.callId 必须能在 tool/result 的 data.message.source.callId 中找到配对;孤儿 call(无 result)告警——中断/失败轮次可能产生孤儿(合法但要审计),考古提取将缺该输出。
235
+
236
+ ### P4 — tool/result 输出结构可解析(考古 B2)
237
+
238
+ - **层级**: plugin | **严重度**: warning
239
+ - **出处**: dsh-会话日志考古-插件任务与方法.md §2/§4.2(content 递归 text 结构)
240
+ - **契约**: tool/result 的 data.message.content 必须可递归解析(list[dict{type:text,text}] 或等价);不可解析片段 = 考古提取将漏数据。空 content(失败/无输出)合法。
241
+
158
242
  ### M1 — turn/step 为 null 的 assistant/message 只能 replace,不能 append
159
243
 
160
244
  - **层级**: engine | **严重度**: error
@@ -191,19 +275,15 @@
191
275
  - **出处**: 审计 N5:多帧单帧全损 → 整会话不可读
192
276
  - **契约**: 任一帧解码失败(磁盘 bitrot / 传输截断 / 并发写撕裂)即整会话不可读;帧越多,单帧损坏下丢失概率线性上升。
193
277
 
194
- ---
195
-
196
- ## 边界说明(诚实声明)
197
-
198
- 1. **本工具守护"持久化契约层"**。`#3632` 的"one log, two consumers, two verdicts"中,
199
- `agent/inbox/spliced` 孤儿在持久化层**是合法的**(官方 `foldSurface` 可通过)——
200
- 违规发生在**消费路径**(`sessionQuery`/UI 判不可读)。本工具的 `check` 会如实报 PASS,
201
- 不冒充能判消费路径契约;该层契约属另一类问题(可配合 dsh-retrace / 上游修复)。
202
- 2. **M1 是引擎层启发式规则**:`data.turn/step` null 的 `assistant/message` 以 append 进入
203
- surface 会触发客户端引擎崩溃(rt.js:6816)——依据是 2026-08-25 事故第 2 轮实证。
204
- 离线场景无法渲染客户端,故以 error 级保守拦截,避免事故重演。
205
- 3. **写前校验以"官方 foldSurface 不抛"为最终权威**:逐事件归因(S1–S7)负责定位,
206
- 官方重放(S8)负责背书;两套都绿才算通过。若官方实现更新导致判定漂移,
207
- 以官方为准并更新本 spec(本工具自己就是契约漂移的哨兵)。
208
- 4. **seq 严格连续是单写入者假设**(N6):离线 `check` 只能看到缺口/倒退的结果,
209
- 无法观测竞态本身;`C1` 给出解释性告警而非臆断。
278
+ ### W1 — wire 流:tool 消息必须跟在带 tool-call 的 assistant 消息之后
279
+
280
+ - **层级**: engine | **严重度**: error
281
+ - **出处**: 2026-08-27 实锤:MiMo 等严格端点对悬空 tool 直接 INVALID_REQUEST(Messages with role "tool" must be a response to a preceding message with "tool_calls");marker 遮蔽 assistant(tool_calls) 而未盖住 tool/result、或中断回合重放重复 tool/result 写在 marker 之后都会产生
282
+ - **契约**: surface 折叠顺序展开 wire 消息流:每个 role=tool 消息必须消费一个仍未满足的 assistant tool-call;不足 = 悬空(provider 拒绝)。常见来源:marker 范围漏盖 tool/result、重放重复事件。
283
+
284
+ ### W2 wire 流:user 文本不得插在 tool_calls 与其 tool 结果之间
285
+
286
+ - **层级**: engine **严重度**: error
287
+ - **出处**: OpenAI 兼容端点对 tool 消息顺序的严格校验;DSH 序列化器将混合 user 消息展开为 text 在前、tool-result 在后
288
+ - **契约**: 当仍有未满足的 assistant tool-call 时出现 user 文本消息,会产生 [assistant(tool_calls), user(text), tool] 序列,严格端点同样拒绝。
289
+
package/lib/checks.js CHANGED
@@ -7,11 +7,20 @@
7
7
  * 全部判定与 `@deepseek-ai/dsh-session@0.1.0-rc.7` 官方实现同语义,
8
8
  * 每条违规都挂 `lib/contracts.js` 中的规则 id 与官方源码出处。
9
9
  */
10
- import { foldSurface, isJsonValue, isSurfaceEligibleType, KNOWN_SESSION_EVENT_TYPES } from '@deepseek-ai/dsh-session';
10
+ import { isSurfaceEligibleType } from '@deepseek-ai/dsh-session';
11
+ import { isJsonValue } from './compat.js';
12
+ import { currentVocabulary, currentFold, V0_EVENT_TYPES } from './vocab.js';
11
13
  import { ruleById } from './contracts.js';
12
14
 
13
15
  export const SURFACE_TYPES = new Set(['user/message', 'assistant/message', 'tool/result']);
16
+ /** v3 新增 `system/message`(官方 `SURFACE_EVENT_TYPES`,0.1.5 `lib/index.js:149-154`)。 */
17
+ export const MODERN_SURFACE_TYPES = new Set([...SURFACE_TYPES, 'system/message']);
14
18
  export const CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks']);
19
+
20
+ /** 当前文件版本适用的 surface 候选类型集(v3 起含 system/message)。version = 被检文件版本。 */
21
+ export function currentSurfaceTypes(version) {
22
+ return version >= 3 ? MODERN_SURFACE_TYPES : SURFACE_TYPES;
23
+ }
15
24
  export const MARKER_PREFIXES = ['retrace', 'message-editor'];
16
25
 
17
26
  /** 构造一条违规记录。 */
@@ -35,15 +44,15 @@ export function isSafeInt(v) {
35
44
  return typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
36
45
  }
37
46
 
38
- /** 事件信封 shape:seq/type/time/data(E1/E3/E4/E5/E6)。 */
39
- export function envelopeViolations(event, loc) {
47
+ /** 事件信封 shape:seq/type/time/data(E1/E3/E4/E5/E6)。version = 被检文件版本。 */
48
+ export function envelopeViolations(event, loc, version) {
40
49
  const out = [];
41
50
  if (!isSafeInt(event.seq)) {
42
51
  out.push(violation('E1', loc, `事件 seq 缺失或非法(${String(event.seq)}),必须为非负安全整数`));
43
52
  }
44
53
  if (typeof event.type !== 'string') {
45
54
  out.push(violation('E3', loc, `事件缺少 type 字符串(${String(event.type)})`));
46
- } else if (!KNOWN_SESSION_EVENT_TYPES.has(event.type) && event.ignorable !== true) {
55
+ } else if (!currentVocabulary(version).has(event.type) && event.ignorable !== true) {
47
56
  out.push(violation('E3', loc, `type "${event.type}" 不在已知词汇表内且未带 ignorable 标记(可能由更新版本的 harness 写入)`));
48
57
  }
49
58
  if (!isJsonValue(event.data)) {
@@ -112,7 +121,7 @@ export function messageShapeViolations(event, loc) {
112
121
  return out;
113
122
  }
114
123
 
115
- /** replace 操作数精确形状(镜像 isReplaceOp,lib/index.js:300-303)。 */
124
+ /** replace 操作数精确形状(镜像旧格式 isReplaceOp,lib/index.js:300-303)。 */
116
125
  export function isReplaceOp(op) {
117
126
  return (
118
127
  typeof op === 'object' && op !== null &&
@@ -122,6 +131,29 @@ export function isReplaceOp(op) {
122
131
  );
123
132
  }
124
133
 
134
+ /**
135
+ * 按**被检文件版本**归一化 replace 操作数到内部 `{start,end}`。
136
+ *
137
+ * 一手依据:官方 replace 字段在 v3 改名——旧格式(v0/v1/v2)`{op,start,end}`,
138
+ * v3 `{op,startSeq,endSeq}`(`dsh-session-format-v2-to-v3/lib/index.js:361-371`)。
139
+ * 用错形状会得 null,由调用方归因为 S4(真实违规)。
140
+ *
141
+ * @param {unknown} op 待归一化的 surfaceOp
142
+ * @returns {{start:number,end:number}|null}
143
+ */
144
+ export function normalizeReplaceOp(op, version) {
145
+ if (typeof op !== 'object' || op === null || Array.isArray(op)) return null;
146
+ if (Object.keys(op).length !== 3 || op.op !== 'replace') return null;
147
+ if (version >= 3) {
148
+ return Object.hasOwn(op, 'startSeq') && Object.hasOwn(op, 'endSeq') && isSafeInt(op.startSeq) && isSafeInt(op.endSeq)
149
+ ? { start: op.startSeq, end: op.endSeq }
150
+ : null;
151
+ }
152
+ return Object.hasOwn(op, 'start') && Object.hasOwn(op, 'end') && isSafeInt(op.start) && isSafeInt(op.end)
153
+ ? { start: op.start, end: op.end }
154
+ : null;
155
+ }
156
+
125
157
  /**
126
158
  * S9 —— 文件物理序 seq 单调(2026-08-30 事故固化;交接书 L2)。
127
159
  *
@@ -157,14 +189,14 @@ export function physicalOrderViolations(rows) {
157
189
  * 与官方同语义的 surface 增量重放,逐事件归因 S1–S7。
158
190
  * @param {Array<{event:object, lineNo?:number}>} events 按日志顺序的事件(带 loc 包装)
159
191
  */
160
- export function replaySurface(events) {
192
+ export function replaySurface(events, version) {
161
193
  const violations = [];
162
194
  const nodes = [];
163
195
  let replaceGeneration = 0;
164
196
 
165
197
  for (const { event, lineNo } of events) {
166
198
  const loc = { seq: event.seq, lineNo, eventType: event.type };
167
- const eligible = SURFACE_TYPES.has(event.type);
199
+ const eligible = currentSurfaceTypes(version).has(event.type);
168
200
  const op = event.surfaceOp;
169
201
  const src = event.sourceEventSeqs;
170
202
 
@@ -185,18 +217,19 @@ export function replaySurface(events) {
185
217
  continue;
186
218
  }
187
219
 
188
- if (!isReplaceOp(op)) {
189
- violations.push(violation('S4', loc, 'replace 操作数必须精确为 {op:"replace", start, end}(start/end 为非负安全整数)'));
220
+ const normalized = normalizeReplaceOp(op, version);
221
+ if (normalized === null) {
222
+ violations.push(violation('S4', loc, 'replace 操作数必须精确为 {op:"replace", start, end}(v0/v1/v2)或 {op:"replace", startSeq, endSeq}(v3),且端点为非负安全整数'));
190
223
  continue;
191
224
  }
192
- const startIdx = nodes.indexOf(op.start);
193
- const endIdx = nodes.indexOf(op.end);
225
+ const startIdx = nodes.indexOf(normalized.start);
226
+ const endIdx = nodes.indexOf(normalized.end);
194
227
  if (startIdx === -1 || endIdx === -1) {
195
- violations.push(violation('S4', loc, `replace 范围 ${op.start}..${op.end} 不在当前 surface 中(start 存在=${startIdx !== -1},end 存在=${endIdx !== -1})`));
228
+ violations.push(violation('S4', loc, `replace 范围 ${normalized.start}..${normalized.end} 不在当前 surface 中(start 存在=${startIdx !== -1},end 存在=${endIdx !== -1})`));
196
229
  continue;
197
230
  }
198
231
  if (startIdx > endIdx) {
199
- violations.push(violation('S4', loc, `replace start ${op.start}(index ${startIdx})在 end ${op.end}(index ${endIdx})之后`));
232
+ violations.push(violation('S4', loc, `replace start ${normalized.start}(index ${startIdx})在 end ${normalized.end}(index ${endIdx})之后`));
200
233
  continue;
201
234
  }
202
235
  const shadowedSeqs = nodes.slice(startIdx, endIdx + 1);
@@ -287,10 +320,19 @@ export function pluginViolations(event, loc) {
287
320
  return out;
288
321
  }
289
322
 
290
- /** 官方 foldSurface 终验;不抛返回折叠结果,抛则返回 { error }。 */
291
- export function finalFold(events) {
323
+ /** 折叠终验:**按文件版本**择路(1.3 第二半 + 2026-09-14 复核补强)。
324
+ *
325
+ * - v3 文件 → 运行时导出的官方 `foldSurface`(与运行时同语义);
326
+ * - v0/v1/v2 文件 → 本地 `legacyFoldSurface`(rc.7 `foldSurface` 逐条移植)。
327
+ *
328
+ * 为什么旧格式不能借用官方实现的 `violations` 字段:官方 `foldSurface` **只抛不报**,
329
+ * 旧口径下这里曾回退到 `replaySurface` 的 violations,但**没有任何调用方读它**
330
+ * (validate.js / prewrite.js 只看 `folded.error`)⇒ S8 在旧格式上永不触发——这正是
331
+ * 2026-09-14 六条失败里四条的真因。现在两条路径都是"抛错 = 拒绝"的同一种契约。 */
332
+ export function finalFold(events, version) {
333
+ const fold = currentFold(version);
292
334
  try {
293
- return { surface: foldSurface(events) };
335
+ return { surface: fold(events) };
294
336
  } catch (err) {
295
337
  return { error: err };
296
338
  }
@@ -387,6 +429,35 @@ export function turnEndReasonViolations(events) {
387
429
  return out
388
430
  }
389
431
 
432
+ /**
433
+ * E7 —— ignorable 未知 type 合法性(2026-09-09 反向挑刺 T2 增量)。
434
+ *
435
+ * 盲区:E3 对"未知 type + ignorable:true"直接放行(容忍更新版本 harness 写入),
436
+ * 但 ignorable 标记无合法性校验 = 后门——被读路径接纳却无人消费的未知事件 =
437
+ * 静默垃圾(违 R3 精神)。补:未知 type + ignorable + **不在已知消费者白名单** →
438
+ * warning"有被忽略的未知事件"。
439
+ *
440
+ * 已知消费者(不算垃圾,排除):retrace 家族 marker(retrace/marker、retrace/goal-marker,
441
+ * neutralize/插件产物,retrace 客户端识别消费)与旧前缀 message-editor。
442
+ * 判定:type ∉ KNOWN && ignorable && 非已知插件前缀 → warning。
443
+ */
444
+ const KNOWN_IGNORABLE_CONSUMERS = new Set(['retrace/marker', 'retrace/goal-marker'])
445
+ function isKnownIgnorableType(type) {
446
+ if (typeof type !== 'string') return false
447
+ if (KNOWN_IGNORABLE_CONSUMERS.has(type)) return true
448
+ return type.startsWith('message-editor/') || type.startsWith('retrace/')
449
+ }
450
+ export function ignorableTypeViolations(events, version) {
451
+ const out = []
452
+ for (const { event, lineNo } of events) {
453
+ if (typeof event?.type !== 'string' || event.ignorable !== true) continue
454
+ if (currentVocabulary(version).has(event.type)) continue
455
+ if (isKnownIgnorableType(event.type)) continue
456
+ out.push(violation('E7', { seq: event.seq, lineNo, eventType: event.type }, `type "${event.type}" 不在已知词汇表且带 ignorable 标记、无已知消费者——被读路径接纳但无人消费 = 静默垃圾(ignorable 后门;若你的插件消费它,登记到 KNOWN_IGNORABLE_CONSUMERS)`))
457
+ }
458
+ return out
459
+ }
460
+
390
461
  /**
391
462
  * P3 —— tool/call ↔ tool/result 配对完整性(考古任务书 B1)。
392
463
  * 每个 tool/call 的 `data.callId` 必须能在 tool/result 的
@@ -396,7 +467,7 @@ export function turnEndReasonViolations(events) {
396
467
  export function toolPairingViolations(events) {
397
468
  const out = [];
398
469
  const calls = new Map(); // callId → { command, loc }
399
- const results = new Set();
470
+ const results = new Map(); // callId → loc(双向:孤儿 result 也要指认,2026-09-09 反向挑刺 T1)
400
471
  for (const { event, lineNo } of events) {
401
472
  if (event.type === 'tool/call') {
402
473
  const callId = event.data?.callId;
@@ -410,7 +481,7 @@ export function toolPairingViolations(events) {
410
481
  }
411
482
  } else if (event.type === 'tool/result') {
412
483
  const callId = event.data?.message?.source?.callId;
413
- if (typeof callId === 'string' && callId !== '') results.add(callId);
484
+ if (typeof callId === 'string' && callId !== '') results.set(callId, { seq: event.seq, lineNo, eventType: event.type });
414
485
  }
415
486
  }
416
487
  for (const [callId, { command, loc }] of calls) {
@@ -418,6 +489,14 @@ export function toolPairingViolations(events) {
418
489
  out.push(violation('P3', loc, `tool/call ${callId}(命令 ${command || '(未知)'})没有配对的 tool/result——孤儿调用(中断/失败未落结果),考古提取将缺该输出`));
419
490
  }
420
491
  }
492
+ // 双向(反向挑刺 T1 增量):孤儿 result = result 无对应 tool/call。
493
+ // 折叠后 wire 流中无主 tool 消息 = provider 拒绝风险(W1/W2 同族、不同层);
494
+ // 与孤儿 call 同为 warning 级——合法场景(中断/修复产物)不破坏日志。
495
+ for (const [callId, loc] of results) {
496
+ if (!calls.has(callId)) {
497
+ out.push(violation('P3', loc, `tool/result ${callId} 没有配对的 tool/call——孤儿结果(无主 tool 消息,wire 流 provider 拒绝风险;中断/修复产物合法但要审计),考古配对将无法归属`));
498
+ }
499
+ }
421
500
  return out;
422
501
  }
423
502
 
@@ -648,20 +727,21 @@ export function deriveWireMessage(event) {
648
727
  * @param events - 展开后的完整事件流(含 chunk 展开)。
649
728
  * @returns 违规列表。
650
729
  */
651
- export function wireViolations(events) {
730
+ export function wireViolations(events, version) {
652
731
  const out = [];
653
732
  // 先折叠 surface(append 入列;replace 移除 [start..end] 并将 marker 自身入列)
654
733
  const nodes = [];
655
734
  const bySeq = new Map();
656
735
  for (const { event, lineNo } of events) {
657
736
  bySeq.set(event.seq, { event, lineNo });
658
- if (!SURFACE_TYPES.has(event.type)) continue;
737
+ if (!currentSurfaceTypes(version).has(event.type)) continue;
659
738
  const op = event.surfaceOp;
660
739
  if (op === 'append') {
661
740
  nodes.push(event.seq);
662
- } else if (op && isReplaceOp(op)) {
663
- const s = nodes.indexOf(op.start);
664
- const e = nodes.indexOf(op.end);
741
+ } else if (op !== undefined) {
742
+ const normalized = normalizeReplaceOp(op, version);
743
+ const s = normalized === null ? -1 : nodes.indexOf(normalized.start);
744
+ const e = normalized === null ? -1 : nodes.indexOf(normalized.end);
665
745
  if (s !== -1 && e !== -1 && s <= e) nodes.splice(s, e - s + 1, event.seq);
666
746
  else nodes.push(event.seq);
667
747
  }
@@ -703,3 +783,39 @@ export function wireViolations(events) {
703
783
  }
704
784
  return out;
705
785
  }
786
+
787
+ /**
788
+ * G · 迁移预检(payload 层 + 类型层)——**官方迁移会不会拒**,与"本工具能否读取/折叠"是
789
+ * **两个独立维度**(见 contracts.js 的 LAYER.MIGRATION 段)。
790
+ *
791
+ * 只覆盖已取得一手出处的两条(G1 descriptor version / G2 unknown historical type);
792
+ * 官方迁移规则集更宽(turn/start 未闭合、继承切点、title messageSeqs、stored-log corrupt…),
793
+ * 未覆盖部分由 `migrationVerdict().coverage.uncovered` 显式列出——**不许**把未覆盖当通过。
794
+ *
795
+ * 范围:两条都只约束 **v0 源文件**(官方 v0→v1 迁移的规则;v1/v2 源走别的迁移代码)。
796
+ * severity 固定 warning、layer 固定 migration ⇒ 不进 ok / loadable / resumable / compactable。
797
+ *
798
+ * @param {Array<{event:object, lineNo?:number}>} events 已展开事件(带 loc 包装)
799
+ * @param {number} version 被检文件版本
800
+ */
801
+ export function migrationPrecheckViolations(events, version) {
802
+ const out = [];
803
+ if (version !== 0) return out;
804
+ for (const { event, lineNo } of events) {
805
+ if (!event || typeof event !== 'object') continue;
806
+ if (event.type === 'subagent/descriptor') {
807
+ const dv = event.data?.version;
808
+ if (dv !== 3) {
809
+ out.push(violation('G1', { seq: event.seq, lineNo, eventType: event.type },
810
+ `subagent/descriptor data.version=${JSON.stringify(dv)}(官方 v0→v1 迁移要求 === 3)—— 升级到当前格式时官方会拒绝:subagent/descriptor ${event.seq} uses unsupported descriptor version ${String(dv)}`));
811
+ }
812
+ }
813
+ if (typeof event.type === 'string' && !V0_EVENT_TYPES.has(event.type)) {
814
+ // 官方对"unknown historical event"一律拒(原文 even when ignorable);E3 的 ignorable
815
+ // 豁免只作用于**读取路径**,这里不改它,只在迁移维度表达。
816
+ out.push(violation('G2', { seq: event.seq, lineNo, eventType: event.type },
817
+ `type "${event.type}" 不在官方 v0 dispositions 内${event.ignorable === true ? '(即使 ignorable:true)' : ''}—— 官方迁移会拒:format v0 contains unknown historical event type`));
818
+ }
819
+ }
820
+ return out;
821
+ }