dsh-log-contract 0.1.0
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/LICENSE +21 -0
- package/README.md +163 -0
- package/bin/dsh-log-contract.mjs +165 -0
- package/docs/CONTRACTS.md +209 -0
- package/lib/checks.js +266 -0
- package/lib/contracts.js +267 -0
- package/lib/index.js +10 -0
- package/lib/log-reader.js +179 -0
- package/lib/prewrite.js +155 -0
- package/lib/validate.js +147 -0
- package/package.json +68 -0
package/lib/checks.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-log-contract · lib/checks.js
|
|
3
|
+
*
|
|
4
|
+
* 逐事件契约检查(共享层):离线体检(validate.js)与写前校验(prewrite.js)
|
|
5
|
+
* 复用同一套判定逻辑,保证"体检看到的问题 = 写入前拦下的问题"。
|
|
6
|
+
*
|
|
7
|
+
* 全部判定与 `@deepseek-ai/dsh-session@0.1.0-rc.7` 官方实现同语义,
|
|
8
|
+
* 每条违规都挂 `lib/contracts.js` 中的规则 id 与官方源码出处。
|
|
9
|
+
*/
|
|
10
|
+
import { foldSurface, isJsonValue, isSurfaceEligibleType, KNOWN_SESSION_EVENT_TYPES } from '@deepseek-ai/dsh-session';
|
|
11
|
+
import { ruleById } from './contracts.js';
|
|
12
|
+
|
|
13
|
+
export const SURFACE_TYPES = new Set(['user/message', 'assistant/message', 'tool/result']);
|
|
14
|
+
export const CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks']);
|
|
15
|
+
export const MARKER_PREFIXES = ['retrace', 'message-editor'];
|
|
16
|
+
|
|
17
|
+
/** 构造一条违规记录。 */
|
|
18
|
+
export function violation(id, eventOrLoc, message, extra = {}) {
|
|
19
|
+
const rule = ruleById(id);
|
|
20
|
+
const loc = eventOrLoc ?? {};
|
|
21
|
+
return {
|
|
22
|
+
id,
|
|
23
|
+
severity: rule?.severity ?? 'error',
|
|
24
|
+
layer: rule?.layer ?? 'unknown',
|
|
25
|
+
seq: typeof loc.seq === 'number' ? loc.seq : null,
|
|
26
|
+
lineNo: typeof loc.lineNo === 'number' ? loc.lineNo : null,
|
|
27
|
+
eventType: loc.eventType ?? null,
|
|
28
|
+
message,
|
|
29
|
+
source: rule?.source ?? null,
|
|
30
|
+
...extra,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isSafeInt(v) {
|
|
35
|
+
return typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 事件信封 shape:seq/type/time/data(E1/E3/E4/E5/E6)。 */
|
|
39
|
+
export function envelopeViolations(event, loc) {
|
|
40
|
+
const out = [];
|
|
41
|
+
if (!isSafeInt(event.seq)) {
|
|
42
|
+
out.push(violation('E1', loc, `事件 seq 缺失或非法(${String(event.seq)}),必须为非负安全整数`));
|
|
43
|
+
}
|
|
44
|
+
if (typeof event.type !== 'string') {
|
|
45
|
+
out.push(violation('E3', loc, `事件缺少 type 字符串(${String(event.type)})`));
|
|
46
|
+
} else if (!KNOWN_SESSION_EVENT_TYPES.has(event.type) && event.ignorable !== true) {
|
|
47
|
+
out.push(violation('E3', loc, `type "${event.type}" 不在已知词汇表内且未带 ignorable 标记(可能由更新版本的 harness 写入)`));
|
|
48
|
+
}
|
|
49
|
+
if (!isJsonValue(event.data)) {
|
|
50
|
+
out.push(violation('E4', loc, 'data 不是 lossless-JSON(函数/循环引用/非有限数等),写入热路径会拒绝'));
|
|
51
|
+
}
|
|
52
|
+
if (event.surfaceOp !== undefined && !isJsonValue(event.surfaceOp)) {
|
|
53
|
+
out.push(violation('E4', loc, 'surfaceOp 不是 lossless-JSON'));
|
|
54
|
+
}
|
|
55
|
+
if (event.sourceEventSeqs !== undefined && !isJsonValue(event.sourceEventSeqs)) {
|
|
56
|
+
out.push(violation('E4', loc, 'sourceEventSeqs 不是 lossless-JSON'));
|
|
57
|
+
}
|
|
58
|
+
if (event.type === 'request/header-delta') {
|
|
59
|
+
out.push(violation('E5', loc, '使用已删除的遗留格式 request/header-delta,写入即被拒'));
|
|
60
|
+
}
|
|
61
|
+
if (event.type === 'request/header' && event.data?.reason === 'fallback') {
|
|
62
|
+
out.push(violation('E5', loc, 'request/header 使用已删除的遗留 reason "fallback"'));
|
|
63
|
+
}
|
|
64
|
+
out.push(...messageShapeViolations(event, loc));
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 镜像官方 assertMessageEventShape(lib/index.js:1242-1266)。 */
|
|
69
|
+
export function messageShapeViolations(event, loc) {
|
|
70
|
+
const type = event.type;
|
|
71
|
+
if (type !== 'user/message' && type !== 'assistant/message' && type !== 'tool/result') return [];
|
|
72
|
+
const out = [];
|
|
73
|
+
const data = event.data;
|
|
74
|
+
const record = typeof data === 'object' && data !== null ? data : undefined;
|
|
75
|
+
const message = type === 'user/message' ? record : record?.message;
|
|
76
|
+
const shape = () => `(seq ${event.seq})消息形状`;
|
|
77
|
+
if (typeof message !== 'object' || message === null) {
|
|
78
|
+
out.push(violation('E6', loc, `${shape()}:缺少 message 对象`));
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
if (typeof message.id !== 'string' || message.id === '') {
|
|
82
|
+
out.push(violation('E6', loc, `${shape()}:id 必须为非空字符串`));
|
|
83
|
+
}
|
|
84
|
+
const expectedRole = type === 'assistant/message' ? 'assistant' : 'user';
|
|
85
|
+
if (message.role !== expectedRole) {
|
|
86
|
+
out.push(violation('E6', loc, `${shape()}:role 必须为 "${expectedRole}",实际 ${String(message.role)}`));
|
|
87
|
+
}
|
|
88
|
+
const source = message.source;
|
|
89
|
+
if (typeof source !== 'object' || source === null || typeof source.kind !== 'string' || source.kind === '') {
|
|
90
|
+
out.push(violation('E6', loc, `${shape()}:source.kind 缺失或非法`));
|
|
91
|
+
}
|
|
92
|
+
if (!Array.isArray(message.content)) {
|
|
93
|
+
out.push(violation('E6', loc, `${shape()}:content 必须为数组`));
|
|
94
|
+
}
|
|
95
|
+
if (type === 'assistant/message') {
|
|
96
|
+
if (source?.kind !== 'model' || typeof source.provider !== 'string' || source.provider === '' || typeof source.model !== 'string' || source.model === '') {
|
|
97
|
+
out.push(violation('E6', loc, `${shape()}:assistant/message 必须带 model source(provider/model 非空)`));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (type === 'tool/result') {
|
|
101
|
+
if (source?.kind !== 'tool' || typeof source.callId !== 'string' || source.callId === '') {
|
|
102
|
+
out.push(violation('E6', loc, `${shape()}:tool/result 必须带 tool source(callId 非空)`));
|
|
103
|
+
}
|
|
104
|
+
const content = message.content;
|
|
105
|
+
const block = Array.isArray(content) ? content[0] : undefined;
|
|
106
|
+
if (content?.length !== 1 || typeof block !== 'object' || block === null || block.type !== 'tool-result' || !Array.isArray(block.content)) {
|
|
107
|
+
out.push(violation('E6', loc, `${shape()}:必须恰含一个 tool-result block`));
|
|
108
|
+
} else if (block.toolCallId !== source?.callId) {
|
|
109
|
+
out.push(violation('E6', loc, `${shape()}:block.toolCallId 与 source.callId 不匹配`));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** replace 操作数精确形状(镜像 isReplaceOp,lib/index.js:300-303)。 */
|
|
116
|
+
export function isReplaceOp(op) {
|
|
117
|
+
return (
|
|
118
|
+
typeof op === 'object' && op !== null &&
|
|
119
|
+
Object.keys(op).length === 3 &&
|
|
120
|
+
Object.hasOwn(op, 'op') && Object.hasOwn(op, 'start') && Object.hasOwn(op, 'end') &&
|
|
121
|
+
op.op === 'replace' && isSafeInt(op.start) && isSafeInt(op.end)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 与官方同语义的 surface 增量重放,逐事件归因 S1–S7。
|
|
127
|
+
* @param {Array<{event:object, lineNo?:number}>} events 按日志顺序的事件(带 loc 包装)
|
|
128
|
+
*/
|
|
129
|
+
export function replaySurface(events) {
|
|
130
|
+
const violations = [];
|
|
131
|
+
const nodes = [];
|
|
132
|
+
let replaceGeneration = 0;
|
|
133
|
+
|
|
134
|
+
for (const { event, lineNo } of events) {
|
|
135
|
+
const loc = { seq: event.seq, lineNo, eventType: event.type };
|
|
136
|
+
const eligible = SURFACE_TYPES.has(event.type);
|
|
137
|
+
const op = event.surfaceOp;
|
|
138
|
+
const src = event.sourceEventSeqs;
|
|
139
|
+
|
|
140
|
+
if (!eligible) {
|
|
141
|
+
if (op !== undefined || src !== undefined) {
|
|
142
|
+
violations.push(violation('S2', loc, `非 surface 类型 "${event.type}" 不得携带 surfaceOp/sourceEventSeqs`));
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (op === undefined) {
|
|
147
|
+
violations.push(violation('S1', loc, `surface 候选类型 "${event.type}" 必须携带 surfaceOp 标记`));
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (op === 'append') {
|
|
152
|
+
violations.push(...provenanceViolations(event, loc, []));
|
|
153
|
+
nodes.push(event.seq);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!isReplaceOp(op)) {
|
|
158
|
+
violations.push(violation('S4', loc, 'replace 操作数必须精确为 {op:"replace", start, end}(start/end 为非负安全整数)'));
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const startIdx = nodes.indexOf(op.start);
|
|
162
|
+
const endIdx = nodes.indexOf(op.end);
|
|
163
|
+
if (startIdx === -1 || endIdx === -1) {
|
|
164
|
+
violations.push(violation('S4', loc, `replace 范围 ${op.start}..${op.end} 不在当前 surface 中(start 存在=${startIdx !== -1},end 存在=${endIdx !== -1})`));
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (startIdx > endIdx) {
|
|
168
|
+
violations.push(violation('S4', loc, `replace start ${op.start}(index ${startIdx})在 end ${op.end}(index ${endIdx})之后`));
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1);
|
|
172
|
+
violations.push(...provenanceViolations(event, loc, shadowedSeqs));
|
|
173
|
+
violations.push(...toolResultRewriteViolations(event, loc, shadowedSeqs));
|
|
174
|
+
nodes.splice(startIdx, endIdx - startIdx + 1, event.seq);
|
|
175
|
+
replaceGeneration += 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { violations, nodes, replaceGeneration };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 镜像官方 assertProvenance(lib/index.js:320-337)。 */
|
|
182
|
+
export function provenanceViolations(event, loc, shadowedSeqs) {
|
|
183
|
+
const out = [];
|
|
184
|
+
const raw = event.sourceEventSeqs;
|
|
185
|
+
const sources = new Set();
|
|
186
|
+
if (raw !== undefined) {
|
|
187
|
+
if (!Array.isArray(raw)) {
|
|
188
|
+
out.push(violation('S6', loc, `sourceEventSeqs 必须为数组(实际 ${typeof raw})`));
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
if (raw.length === 0 && event.type !== 'assistant/message') {
|
|
192
|
+
out.push(violation('S6', loc, 'sourceEventSeqs 不得为空(除 assistant/message 外)'));
|
|
193
|
+
}
|
|
194
|
+
let nonEarlier;
|
|
195
|
+
for (const source of raw) {
|
|
196
|
+
if (!isSafeInt(source)) {
|
|
197
|
+
out.push(violation('S6', loc, `sourceEventSeqs 必须稠密包含非负安全整数(非法值 ${String(source)})`));
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (sources.has(source)) {
|
|
201
|
+
out.push(violation('S6', loc, `sourceEventSeqs 不得重复(${source})`));
|
|
202
|
+
}
|
|
203
|
+
sources.add(source);
|
|
204
|
+
if (nonEarlier === undefined && source >= event.seq) nonEarlier = source;
|
|
205
|
+
}
|
|
206
|
+
if (nonEarlier !== undefined) {
|
|
207
|
+
out.push(violation('S6', loc, `sourceEventSeqs 必须引用更早事件:${nonEarlier} >= 当前 seq ${event.seq}`));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const missing = shadowedSeqs.filter((seq) => !sources.has(seq));
|
|
211
|
+
if (missing.length > 0) {
|
|
212
|
+
out.push(violation('S5', loc, `surface replace: sourceEventSeqs 必须覆盖每个被替换节点;缺失 ${missing.join(', ')}(共 ${missing.length} 个)`, { missingSeqs: missing }));
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 镜像官方 assertToolResultRewrite(lib/index.js:369-395)的判定核心。 */
|
|
218
|
+
export function toolResultRewriteViolations(event, loc, shadowedSeqs) {
|
|
219
|
+
if (event.type !== 'tool/result') return [];
|
|
220
|
+
const out = [];
|
|
221
|
+
if (shadowedSeqs.length !== 1) {
|
|
222
|
+
out.push(violation('S7', loc, `tool/result 替换必须恰好重写 1 个当前节点(实际 ${shadowedSeqs.length} 个)`));
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** M1 —— 客户端引擎层:turn/step 缺失(null)的 assistant/message 只能 replace。 */
|
|
228
|
+
export function engineViolations(event, loc) {
|
|
229
|
+
const out = [];
|
|
230
|
+
if (event.type === 'assistant/message' && event.surfaceOp === 'append') {
|
|
231
|
+
// turn/step 位于 event.data 层(实证:正常消息 data.turn/data.step 为数字,
|
|
232
|
+
// 插件 marker data.turn/data.step 为 null —— 复盘事故第 2 轮)
|
|
233
|
+
const turn = event.data?.turn;
|
|
234
|
+
const step = event.data?.step;
|
|
235
|
+
if (turn == null || step == null) {
|
|
236
|
+
out.push(violation('M1', loc, `assistant/message 以 append 进入 surface 但 data.turn/data.step 缺失(turn=${String(turn)}, step=${String(step)})——只能以 replace 承载(插件 marker 定义),append 会触发客户端引擎崩溃(rt.js:6816)`));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** P 层 —— 插件 marker 语义(retrace 等以 assistant/message replace 承载的 marker)。 */
|
|
243
|
+
export function pluginViolations(event, loc) {
|
|
244
|
+
const out = [];
|
|
245
|
+
const isMarkerReplace = event.type === 'assistant/message' && event.surfaceOp && event.surfaceOp !== 'append' && event.data?.editor !== undefined;
|
|
246
|
+
if (!isMarkerReplace) return out;
|
|
247
|
+
const id = event.data?.message?.id;
|
|
248
|
+
const known = typeof id === 'string' && MARKER_PREFIXES.some((p) => id.startsWith(`${p}-`));
|
|
249
|
+
if (!known) {
|
|
250
|
+
out.push(violation('P1', loc, `marker id "${String(id)}" 前缀不在已知列表(${MARKER_PREFIXES.join('/')}-)——改名后未登记遗留前缀,旧 marker 隐藏语义将断裂(软兼容丢失)`));
|
|
251
|
+
}
|
|
252
|
+
const src = event.sourceEventSeqs;
|
|
253
|
+
if (Array.isArray(src) && src.includes(event.seq)) {
|
|
254
|
+
out.push(violation('P2', loc, `marker 自身 seq ${event.seq} 出现在自身 sourceEventSeqs(shadowed 集)——自指语义错误(marker 节点被隐藏逻辑跳过,不应被自己隐藏)`));
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** 官方 foldSurface 终验;不抛返回折叠结果,抛则返回 { error }。 */
|
|
260
|
+
export function finalFold(events) {
|
|
261
|
+
try {
|
|
262
|
+
return { surface: foldSurface(events) };
|
|
263
|
+
} catch (err) {
|
|
264
|
+
return { error: err };
|
|
265
|
+
}
|
|
266
|
+
}
|
package/lib/contracts.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-log-contract · lib/contracts.js
|
|
3
|
+
*
|
|
4
|
+
* DSH 会话日志契约规则目录(spec)。
|
|
5
|
+
*
|
|
6
|
+
* 规则集来源:
|
|
7
|
+
* - `dsh-scale-audit-疑点记录.md`(59 条契约发现 / F1–F7 / N1–N6 / R1–R3)
|
|
8
|
+
* - `复盘-会话修复事故-20260825.md`(三层契约:持久化 / 客户端引擎 / 插件语义)
|
|
9
|
+
* - `@deepseek-ai/dsh-session@0.1.0-rc.7` 官方源码逐行核对(见每条 `source`)
|
|
10
|
+
*
|
|
11
|
+
* 每条规则只描述"契约是什么";具体判定逻辑在 `lib/validate.js`(离线体检)
|
|
12
|
+
* 与 `lib/prewrite.js`(写前校验)中按 id 实现。severity:
|
|
13
|
+
* - error —— 违反即会话不可加载 / 写入会被拒(fail-loud)
|
|
14
|
+
* - warning —— 合法但可疑(撕裂尾帧、未知 marker 前缀等)
|
|
15
|
+
* - info —— 事实性观察(压缩统计等)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const LAYER = {
|
|
19
|
+
PERSISTENCE: 'persistence', // 持久化层:日志能被官方解码器完整重放
|
|
20
|
+
ENGINE: 'engine', // 客户端引擎层:事件形状匹配客户端定义
|
|
21
|
+
PLUGIN: 'plugin', // 插件语义层:marker 隐藏语义
|
|
22
|
+
CONCURRENCY: 'concurrency', // 并发/写入者假设
|
|
23
|
+
FRAMING: 'framing', // zstd 帧结构
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const SEVERITY = { ERROR: 'error', WARNING: 'warning', INFO: 'info' };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 契约规则目录。id 前缀:
|
|
30
|
+
* - H header / 会话头
|
|
31
|
+
* - R 存储行 / chunk 行
|
|
32
|
+
* - E 事件信封 / seq / type 词汇表
|
|
33
|
+
* - S surface(模型可见面)不变量 —— 事故核心层
|
|
34
|
+
* - M 客户端引擎层
|
|
35
|
+
* - P 插件 marker 语义层
|
|
36
|
+
* - C 并发 / 写入者假设
|
|
37
|
+
* - Z zstd 帧结构
|
|
38
|
+
*/
|
|
39
|
+
export const CONTRACT_RULES = [
|
|
40
|
+
// ── H · header ──────────────────────────────────────────────────────────
|
|
41
|
+
{
|
|
42
|
+
id: 'H1',
|
|
43
|
+
title: '首行为合法 JSON 且 type=session',
|
|
44
|
+
layer: LAYER.PERSISTENCE,
|
|
45
|
+
severity: SEVERITY.ERROR,
|
|
46
|
+
source: '@deepseek-ai/dsh-session lib/index.js:1109-1126 (validateSessionHeader)',
|
|
47
|
+
description: '会话日志首行必须是可 JSON.parse 的对象,且 type 为 "session"。首行损坏 = 整个会话不可读。',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'H2',
|
|
51
|
+
title: 'header 版本与必填字段',
|
|
52
|
+
layer: LAYER.PERSISTENCE,
|
|
53
|
+
severity: SEVERITY.ERROR,
|
|
54
|
+
source: '@deepseek-ai/dsh-session lib/index.js:1110-1125',
|
|
55
|
+
description: 'header.version 必须为 0;id 为字符串;createdAt 为非负安全整数;cwd 若存在必须为绝对路径;origin 只能为 "subagent"。',
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
// ── R · 存储行 ──────────────────────────────────────────────────────────
|
|
59
|
+
{
|
|
60
|
+
id: 'R1',
|
|
61
|
+
title: '每行必须是合法 JSON',
|
|
62
|
+
layer: LAYER.PERSISTENCE,
|
|
63
|
+
severity: SEVERITY.ERROR,
|
|
64
|
+
source: '审计方法论(scan-seq-gaps.mjs);dsh-session-persistence-jsonl 读路径',
|
|
65
|
+
description: '非空行无法 JSON.parse = 损坏行。帧边界产生的空行是合法的(跳过)。',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'R2',
|
|
69
|
+
title: 'chunk 行必须满足精确信封形状',
|
|
70
|
+
layer: LAYER.PERSISTENCE,
|
|
71
|
+
severity: SEVERITY.ERROR,
|
|
72
|
+
source: '@deepseek-ai/dsh-session lib/index.js:922-971 (validateRow)',
|
|
73
|
+
description: 'text-chunks / reasoning-chunks / tool-call-chunks 行必须精确为 {type, seq0, time0, data},data 精确为 {turn, step, index, dt, texts|args}。损坏 = 整段 run 丢失且加载失败(fail-loud,无跳过逃生舱)。',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'R3',
|
|
77
|
+
title: 'chunk 行展开后成员 seq/time 安全',
|
|
78
|
+
layer: LAYER.PERSISTENCE,
|
|
79
|
+
severity: SEVERITY.ERROR,
|
|
80
|
+
source: '@deepseek-ai/dsh-session lib/index.js:964-969',
|
|
81
|
+
description: '展开后成员 seq 与 time 必须保持安全整数(seq0+len-1 与逐 gap 累加的 time 不溢出)。',
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
// ── E · 事件信封 ────────────────────────────────────────────────────────
|
|
85
|
+
{
|
|
86
|
+
id: 'E1',
|
|
87
|
+
title: '每个事件携带非负安全整数 seq',
|
|
88
|
+
layer: LAYER.PERSISTENCE,
|
|
89
|
+
severity: SEVERITY.ERROR,
|
|
90
|
+
source: '@deepseek-ai/dsh-session lib/index.js:295-298 (isEventSeq)、:1453-1459 (append 信封)',
|
|
91
|
+
description: '事件信封为 {type, seq, time, data, ...surfaceMetadata};seq 必须是非负安全整数。',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'E2',
|
|
95
|
+
title: 'seq 严格连续(单写入者假设)',
|
|
96
|
+
layer: LAYER.PERSISTENCE,
|
|
97
|
+
severity: SEVERITY.ERROR,
|
|
98
|
+
source: '@deepseek-ai/dsh-session lib/index.js:398 (planSurfaceEvent "not contiguous");审计 S2/N6',
|
|
99
|
+
description: 'seq 必须从 0(或窗口 baseSeq)严格连续递增。缺口/倒退 = 违反单写入者假设(多实例共享存储并发写的痕迹),加载时直接 throw。',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: 'E3',
|
|
103
|
+
title: 'type 必须在已知词汇表内(或带 ignorable 标记)',
|
|
104
|
+
layer: LAYER.PERSISTENCE,
|
|
105
|
+
severity: SEVERITY.ERROR,
|
|
106
|
+
source: '@deepseek-ai/dsh-session lib/index.js:1046-1049 (KNOWN_SESSION_EVENT_TYPES 注释)',
|
|
107
|
+
description: '词汇表外的 type 会被持久化读路径拒绝,除非事件带信封级 ignorable 标记(新版本 harness 写入的日志)。插件事件(如 retrace marker 以 assistant/message 承载)不在词汇表外——它们复用核心类型。',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: 'E4',
|
|
111
|
+
title: 'data 与 surface 元数据必须 JSON 无损',
|
|
112
|
+
layer: LAYER.PERSISTENCE,
|
|
113
|
+
severity: SEVERITY.ERROR,
|
|
114
|
+
source: '@deepseek-ai/dsh-session lib/index.js:1446-1450 (snapshotJsonValue 双快照)',
|
|
115
|
+
description: 'append 热路径对 data 与 surfaceMetadata 各做一次 lossless-JSON 全量校验;非 JSON 安全值(函数/循环引用/非有限数)写入前即被拒。',
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: 'E5',
|
|
119
|
+
title: '禁用遗留词汇',
|
|
120
|
+
layer: LAYER.PERSISTENCE,
|
|
121
|
+
severity: SEVERITY.ERROR,
|
|
122
|
+
source: '@deepseek-ai/dsh-session lib/index.js:1273-1277 (assertSupportedRequestHeader)',
|
|
123
|
+
description: 'request/header-delta 与 reason=fallback 的 request/header 是已删除的遗留格式,写入即被拒。',
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: 'E6',
|
|
127
|
+
title: '消息类事件消息形状',
|
|
128
|
+
layer: LAYER.PERSISTENCE,
|
|
129
|
+
severity: SEVERITY.ERROR,
|
|
130
|
+
source: '@deepseek-ai/dsh-session lib/index.js:1242-1266 (assertMessageEventShape)',
|
|
131
|
+
description: 'user/message、assistant/message、tool/result 必须携带具名 message(非空 id、正确 role、合法 source、content 数组;assistant 需 model source,tool/result 需 tool source 且 toolCallId 匹配)。',
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
// ── S · surface 不变量(事故核心层)────────────────────────────────────
|
|
135
|
+
{
|
|
136
|
+
id: 'S1',
|
|
137
|
+
title: 'surface 候选类型必须携带 surfaceOp',
|
|
138
|
+
layer: LAYER.PERSISTENCE,
|
|
139
|
+
severity: SEVERITY.ERROR,
|
|
140
|
+
source: '@deepseek-ai/dsh-session lib/index.js:312-317 (surfaceOpOf)',
|
|
141
|
+
description: 'user/message、assistant/message、tool/result 是 surface-eligible 类型,缺 surfaceOp 即违反契约。',
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: 'S2',
|
|
145
|
+
title: '非 surface 类型不得携带 surface 元数据',
|
|
146
|
+
layer: LAYER.PERSISTENCE,
|
|
147
|
+
severity: SEVERITY.ERROR,
|
|
148
|
+
source: '@deepseek-ai/dsh-session lib/index.js:307-311 (surfaceOpOf)',
|
|
149
|
+
description: '词汇表内非 surface-eligible 类型带 surfaceOp / sourceEventSeqs = 违反契约。',
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: 'S3',
|
|
153
|
+
title: 'append 的 sourceEventSeqs 契约',
|
|
154
|
+
layer: LAYER.PERSISTENCE,
|
|
155
|
+
severity: SEVERITY.ERROR,
|
|
156
|
+
source: '@deepseek-ai/dsh-session lib/index.js:401-407、:320-337 (assertProvenance)',
|
|
157
|
+
description: 'append 以空 shadowed 集校验:sourceEventSeqs 若携带必须满足 assertProvenance(数组、无重复、全部引用更早事件);任何违规即写入被拒。',
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: 'S4',
|
|
161
|
+
title: 'replace 操作数与范围合法性',
|
|
162
|
+
layer: LAYER.PERSISTENCE,
|
|
163
|
+
severity: SEVERITY.ERROR,
|
|
164
|
+
source: '@deepseek-ai/dsh-session lib/index.js:300-303 (isReplaceOp)、:339-350 (replacementRange)',
|
|
165
|
+
description: 'replace 必须是精确的 {op:"replace", start, end};start/end 必须存在于当前 surface 节点且 startIdx ≤ endIdx。',
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: 'S5',
|
|
169
|
+
title: 'replace 的 sourceEventSeqs 必须完整覆盖被替换节点',
|
|
170
|
+
layer: LAYER.PERSISTENCE,
|
|
171
|
+
severity: SEVERITY.ERROR,
|
|
172
|
+
source: '@deepseek-ai/dsh-session lib/index.js:335-336 (assertProvenance);复盘事故第 1 轮',
|
|
173
|
+
description: '★ 写前校验核心规则:sourceEventSeqs 必须包含每一个被替换(shadowed)的 surface 节点,缺一个 = 会话加载被拒(SessionPersistenceCorruptionError)。2026-08-25 事故第 1 轮(清空 sourceEventSeqs)正是违反此规则。',
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: 'S6',
|
|
177
|
+
title: 'sourceEventSeqs 自身约束',
|
|
178
|
+
layer: LAYER.PERSISTENCE,
|
|
179
|
+
severity: SEVERITY.ERROR,
|
|
180
|
+
source: '@deepseek-ai/dsh-session lib/index.js:320-333 (assertProvenance)',
|
|
181
|
+
description: 'sourceEventSeqs 存在时必须为数组、无重复、全部引用更早事件(< 当前 seq),且除 assistant/message 外不得为空。',
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
id: 'S7',
|
|
185
|
+
title: 'tool/result 替换仅允许单节点内容改写',
|
|
186
|
+
layer: LAYER.PERSISTENCE,
|
|
187
|
+
severity: SEVERITY.ERROR,
|
|
188
|
+
source: '@deepseek-ai/dsh-session lib/index.js:369-395 (assertToolResultRewrite)',
|
|
189
|
+
description: 'tool/result 的 replace 必须恰好重写 1 个当前节点、目标是 tool/result,且除 message.content 外不得改动任何字段。',
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
id: 'S8',
|
|
193
|
+
title: '整日志 foldSurface 可重放',
|
|
194
|
+
layer: LAYER.PERSISTENCE,
|
|
195
|
+
severity: SEVERITY.ERROR,
|
|
196
|
+
source: '@deepseek-ai/dsh-session lib/index.js:444-455 (foldSurface);复盘"官方 foldSurface 不抛 = 通过"',
|
|
197
|
+
description: '终验:把全部事件按序喂给官方 foldSurface,不抛 = 持久化层通过。S1–S7 任何一条违反都会在此暴露。',
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
// ── M · 客户端引擎层 ────────────────────────────────────────────────────
|
|
201
|
+
{
|
|
202
|
+
id: 'M1',
|
|
203
|
+
title: 'turn/step 为 null 的 assistant/message 只能 replace,不能 append',
|
|
204
|
+
layer: LAYER.ENGINE,
|
|
205
|
+
severity: SEVERITY.ERROR,
|
|
206
|
+
source: '复盘事故第 2 轮(rt.js:6816 崩溃);实证 data.turn/data.step:正常消息为数字、插件 marker 为 null',
|
|
207
|
+
description: 'data.turn/data.step 为 null 的 assistant/message(如插件 marker)只能以 replace 承载(走插件 marker 定义);作为 append 会落进核心 assistant-step 定义,因 turn=null 发布 location data 导致客户端引擎崩溃。',
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
// ── P · 插件 marker 语义层 ──────────────────────────────────────────────
|
|
211
|
+
{
|
|
212
|
+
id: 'P1',
|
|
213
|
+
title: 'marker id 前缀必须被识别',
|
|
214
|
+
layer: LAYER.PLUGIN,
|
|
215
|
+
severity: SEVERITY.WARNING,
|
|
216
|
+
source: 'retrace 插件 RENAME RULE(lib/client.js:29-44);复盘事故',
|
|
217
|
+
description: 'assistant/message 替换事件的 message.id 以 retrace- / message-editor- 为已知前缀。未知前缀 = 改名后未登记遗留前缀,旧 marker 的隐藏语义会断裂(软兼容丢失)。',
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
id: 'P2',
|
|
221
|
+
title: 'marker 自身 seq 不得出现在自身 shadowed 集',
|
|
222
|
+
layer: LAYER.PLUGIN,
|
|
223
|
+
severity: SEVERITY.ERROR,
|
|
224
|
+
source: 'retrace 插件 lib/client.js:393("event and never a surface node")',
|
|
225
|
+
description: 'marker 的 sourceEventSeqs(= shadowedSeqs,驱动 CSS 隐藏)不得包含 marker 自身 seq——marker 节点由隐藏逻辑跳过,出现在 shadowed 集属于自指语义错误。',
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
// ── C · 并发 / 写入者假设 ───────────────────────────────────────────────
|
|
229
|
+
{
|
|
230
|
+
id: 'C1',
|
|
231
|
+
title: 'seq 缺口/倒退提示多写入者',
|
|
232
|
+
layer: LAYER.CONCURRENCY,
|
|
233
|
+
severity: SEVERITY.WARNING,
|
|
234
|
+
source: '审计 N6:dsh-session-persistence-jsonl appendLines 无锁(:1200-1227),全仓无会话级排他锁',
|
|
235
|
+
description: '离线体检无法直接观测跨进程竞态,但 E2 暴露的缺口/倒退即是"≥2 个 Host 进程共享同一 session 目录并发写"的后果。单实例部署不触发。',
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
// ── Z · zstd 帧结构 ─────────────────────────────────────────────────────
|
|
239
|
+
{
|
|
240
|
+
id: 'Z1',
|
|
241
|
+
title: 'zstd 尾帧撕裂',
|
|
242
|
+
layer: LAYER.FRAMING,
|
|
243
|
+
severity: SEVERITY.WARNING,
|
|
244
|
+
source: '审计 N5 相关;帧扫描方法论',
|
|
245
|
+
description: '尾帧不完整(torn):可能正在写入(in-flight)或文件被截断。若这是唯一异常,通常可等待写入完成;若持续存在则是截断证据。',
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
id: 'Z2',
|
|
249
|
+
title: 'zstd 帧解码失败 = 单帧全损',
|
|
250
|
+
layer: LAYER.FRAMING,
|
|
251
|
+
severity: SEVERITY.ERROR,
|
|
252
|
+
source: '审计 N5:多帧单帧全损 → 整会话不可读',
|
|
253
|
+
description: '任一帧解码失败(磁盘 bitrot / 传输截断 / 并发写撕裂)即整会话不可读;帧越多,单帧损坏下丢失概率线性上升。',
|
|
254
|
+
},
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
/** 按 id 取规则。 */
|
|
258
|
+
export function ruleById(id) {
|
|
259
|
+
return CONTRACT_RULES.find((r) => r.id === id);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** 生成 docs/CONTRACTS.md 的目录行(供文档维护)。 */
|
|
263
|
+
export function ruleTableRows() {
|
|
264
|
+
return CONTRACT_RULES.map(
|
|
265
|
+
(r) => `| ${r.id} | ${r.severity} | ${r.layer} | ${r.title} |`,
|
|
266
|
+
).join('\n');
|
|
267
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-log-contract · lib/index.js
|
|
3
|
+
*
|
|
4
|
+
* 日志契约守护(DSH session log contract guard)。
|
|
5
|
+
* 公开 API:离线体检 + 写前校验 + 契约目录。
|
|
6
|
+
*/
|
|
7
|
+
export { loadSessionLog } from './log-reader.js';
|
|
8
|
+
export { validateSessionLog } from './validate.js';
|
|
9
|
+
export { createPreWriter, preWriterFromLog } from './prewrite.js';
|
|
10
|
+
export { CONTRACT_RULES, LAYER, SEVERITY, ruleById } from './contracts.js';
|