Invariant:每个包都有自己的守卫
你以为不变量检查是核心Session模块的事。错。CI gate强制packages/*/下每个包都必须有src/invariant.ts companion,注册为Cordis companion plugin。
不变量检查如果只放在 Session 核心模块里,很快就会漏掉边缘包的漂移:核心包能检查 seq 递增、turn 配对,却管不到每个业务包自己的运行时约束。
所以 Harness 要求每个包自带守卫。CI gate 强制:packages/*/ 下的每个包,package.json 必须有 ./invariant export,files 字段必须包含 lib/invariant.js,peerDependencies 必须有 @deepseek-ai/dsh-invariants,src/invariant.ts 文件必须存在。唯一例外是文件开头写了 No runtime invariant:,明确解释为什么这个包不需要运行时守卫。
不变量不是集中式的审计员,是每个模块自己带的保安。
CI gate:invariant是强制的,不是可选的
scripts/package-invariants.ts在CI里跑,它遍历所有packages,做四项检查:
- package.json的exports字段有没有”./invariant”入口
- package.json的files字段有没有包含”lib/invariant.js”
- package.json的peerDependencies有没有”@deepseek-ai/dsh-invariants”
- src/invariant.ts文件是否存在
如果任何一项不满足,CI直接失败,不准合并。唯一例外是src/invariant.ts第一行写了// No runtime invariant: 后面跟解释原因——比如纯类型包、纯常量包确实不需要运行时检查。
Invariant通过@deepseek-ai/dsh-invariants服务注册为Cordis companion plugin。Cordis组合时,每个包的invariant自动挂载到对应服务上,不需要手动import调用。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
cat "$repo/scripts/package-invariants.ts" | head -60
你会看到四项硬检查的逻辑,没有例外路径。
Session Invariant:两阶段验证
核心Session包的invariant是一个范本,它分两阶段执行:
pre-commit(internal/dispatch阶段): 纯函数验证事件本身的合法性,在事件apply到状态之前执行。这阶段不修改状态,只返回true/false。检查:
- event.seq比最后一个seq大1(严格递增,不能跳号)
- turn/step事件的编号配对逻辑
- 不能在开放turn外发另一个turn/start
- step必须在开放turn内
- tool/result必须有先验的tool/call(repair合成的除外)
- request/header等turn内事件不能在turn外发送
post-commit(session/event应用后): 验证transition后的状态合法性。事件已经apply,检查状态是否仍然一致。这阶段能发现pre-commit看不到的跨事件不一致。
Seed validation也走同样检查:Session创建时从seed events构造,不是直接信任seed数据——seed也必须通过invariant验证。
flowchart TD
A[Cordis组合阶段] --> B[每个包注册invariant plugin]
C[Event进入Session] --> D[pre-commit纯函数验证]
D -->|失败| E[拒绝append]
D -->|通过| F[apply event到状态]
F --> G[post-commit状态验证]
G -->|失败| H[状态不一致,panic]
G -->|通过| I[事件落盘]
J[CI阶段] --> K[package-invariants检查每个包]
K -->|缺invariant| L[CI失败]
J --> M[verify-runtime-closure BFS]
M -->|peer缺失| L
J --> N[typecheck + build + E2E全套]
style E fill:#8b0000,stroke:#fff,color:#fff
style H fill:#8b0000,stroke:#fff,color:#fff
style L fill:#8b0000,stroke:#fff,color:#fff
其他gate:不止invariant
CI里不只有package-invariants一个检查。scripts/run-gates.ts跑全套gate:
verify-runtime-closure: BFS遍历包依赖图,从入口包开始,检查所有peerDependencies是否都能在workspace中找到。防止你声明了peer依赖但实际上没安装,或者某个包偷偷依赖了没声明的包。这是构建时的依赖闭包验证,不是运行时的。
client-bundle-purity: 检查客户端bundle里没有泄漏服务器端代码——比如fs、child_process、credentials这些不能出现在web客户端里。防止你不小心把Node.js专属代码import到前端,导致构建出的客户端在浏览器里崩溃。
typecheck + build + E2E全套: 当然还有基础的TypeScript类型检查、所有包构建成功、E2E测试通过。
Invariant、Catalog和生成式文档防漂移
文档会过时,但代码不会。为了防止”文档说X、代码做Y”的漂移问题,Harness用生成式Catalog——文档不是人手写的,是脚本从代码生成的:
- gen-persistence-catalog:扫描所有事件类型,生成持久化格式文档
- gen-tool-catalog:扫描所有工具定义,生成工具目录文档
- gen-client-catalog:扫描客户端API,生成客户端API文档
这些Catalog在CI里跑,如果生成结果和仓库里的文档不一致,CI失败——你要么改代码,要么重新生成文档提交,不能让文档和代码漂移。
每个Catalog都是SourceEvidence的好来源——你可以信任Catalog里写的事件字段、工具参数,因为它们是直接从代码AST生成的,不是人凭记忆写的。
容易踩的坑
坑一:invariant是”开发环境检查”可以关。 不是。invariant在生产环境也执行。CI强制你写invariant不是为了调试,是为了运行时的正确性保证。关掉invariant就等于拆掉了每个模块门口的保安。
坑二:集中式审计员比分布式守卫好。 集中式检查意味着你要知道所有模块的内部规则。每个包自己的invariant知道自己的不变量——credentials包知道什么是合法密钥,sandbox包知道什么是合法权限升级,不用别人告诉它们。
坑三:peer依赖缺失只会在运行时报错。 verify-runtime-closure在构建时就用BFS检查了依赖闭包,不会等到运行时require失败才发现缺失peer。
坑四:文档可以手写更新。 手写文档一定会漂移。用脚本从代码生成Catalog,CI强制检查一致性,这是唯一能长期防漂移的方法。
坑五:“我的包很简单不需要invariant”。 哪怕是纯工具函数包,如果你声明了不需要invariant,必须在文件第一行写清楚理由。没有例外,没有”太简单所以不用写”的豁免。
每个包自带守卫解决了”模块自己证明自己合法”的问题。但安全不是单一层面的事——Credentials、Permission、Sandbox、Network是四层独立防御。下一章讲安全分层。