Claim Layer 在我写下它们之前抓住的五个 bug
五个 claim 阻止了本会流入生产的 bug 的具体瞬间 — 每一个都以不同的方式发生
claim layer 十天前发布。从那以后我一直在一个真实项目上用它 — 不是手册里那个示例支付应用,而是带着依赖、第三方库,以及一个写出大部分实现的 AI agent 的真实代码。这篇文章不是讲 claim 是什么(手册已经讲过了)。它讲的是 claim 抓住了什么 — 五个 claim 阻止了一个本会流入生产的 bug 的具体瞬间,每一个都以不同的方式发生。重点不是"claim 在理论上很漂亮"。重点是:这里有五个我不必在 2 AM 调试的 diff。
1. 开始读文件的依赖
一个 JSON 库。我用 jsonlib 用了半年 — 它把字节解析成一棵树,仅此而已。我的 Build.sj 写的是:
static final String[] capabilities = {
"myapp", "net, fs.read",
"jsonlib", "",
};
jsonlib 没有持有任何授权。刻意保持 pure。然后我把依赖从 0.3.2 升到 0.4.0 — 一个补丁版本号,semver 兼容,changelog 写着"添加了 schema 校验"。构建停住了:
error[E_CAP_DENIED]: package `jsonlib` performs `fs.read`
(native sj_FileInputStream_open) but holds no `fs.read` grant.
Grants for jsonlib: (none). Add "jsonlib", "fs.read" to Build.sj
capabilities, or remove the call.
schema 校验 — 通过从文件系统加载 schema 来做。一个会读文件的 JSON 库。claim layer 把一个静默的行为变化变成了一行 review 问题:我想要我的 JSON 解析器碰文件系统吗? 我不想。我把版本钉回 0.3.2,并给库作者开了一个 issue。它抓住的 bug 不在我的代码里 — 而是在别人的代码里,通过一个看起来很常规的依赖升级传进来。
如果没有 claim:这个库在生产环境运行时读文件,而我会等到某个客户的安全审计问"为什么一个 JSON 解析器有文件系统访问权限"时才发现。
2. 把遥测接进领域层的 AI agent
我让一个 AI agent 给支付服务加请求日志。agent 做了 agent 该做的事:它找到了每个请求都会经过的最中央的位置,把日志加在那里。那个位置是 payments.model.Ledger — pure 的领域核心。
package payments.model;
import sj.net.UnixGateway; // <- the agent added this
public class Ledger {
public long transfer(Account from, Account to, long cents)
requires cents > 0
ensures result == cents
{
UnixGateway.send("logs", ...); // <- and this
...
}
}
两个 claim 在编译时先后抓住了它:
error[E_ARCH_DENIED]: package `payments.model` imports `sj.net`
but its architecture entry allows only: (none).
error[E_CAP_DENIED]: package `payments.model` performs `net`
but holds no `net` grant.
architecture 声明说 payments.model 不 import 任何东西 — 连网络包都不行。而 capabilities 声明说这个 model 包不持有任何授权。agent 的产出当场报废。我不用去 review diff 再注意到一个可疑的 import — 编译器直接拒绝构建它。
修复方式就是 agent 一开始就该做的那件事:从 payments.app(已经持有 net 的边缘包)里记日志。和 agent 对话一次,把它指到 E_ARCH_DENIED 消息上,它就改对了。总共耗时 30 秒。claim layer 给了 agent 一个有名字的信号去修,而不是一段需要解读的代码 review。
如果没有 claim:领域层现在有了一个网络依赖,领域逻辑的每个测试都需要一个网络 mock,而那个"pure 核心"只剩下 pure 的名字。
3. 通过了所有测试的差一错误
一个账本提款。契约是:
public long withdraw(long cents)
requires cents > 0
requires cents <= this.balanceCents()
ensures result == cents
invariant balance >= 0
{
balance = balance - cents;
return cents;
}
八个单元测试,全绿。然后一次重构 — 有人(其实是 agent)把函数体改成了扣一笔手续费:
balance = balance - cents - 1; // $0.01 fee
return cents;
测试还是过的 — 没有一个恰好提走全部余额。但 superj run 抛出了:
ContractViolation: invariant balance >= 0
at payments.model.Ledger.withdraw (Ledger.sj:42)
触发它的测试是:提走全部余额。余额变成 -1。invariant 抓住了它。这不是我写的测试 — 是契约写的,通过断言一条必须对每一个退出都成立的性质,而不只是我的测试恰好覆盖的那些。
ensures result == cents 还抓住了更微妙的东西:后来一次改动试图返回 cents - 1(把手续费从返回金额而不是余额里扣)。ensures 触发了。业务规则 — "你提到的是你要求的金额" — 现在是一条编译期检查的运行时 invariant,而不是一行注释。
如果没有 claim:这个差一错误会一直留在生产里,直到某个客户提走全部余额并收到一个负余额错误,或者更糟,根本收不到错误。
4. 偷偷爬进热路径的分配
一个 SEDA 事件处理器。稳态路径:收到一个 ping,发布一个 pong。我给它标了 noalloc 来钉住整棵调用树:
public noalloc void onPing(EventHeader header, Ping evt) {
Pong pong = this.publisher.getHandler(Msgs.PONG).prepare();
pong.msg = evt.ts;
this.publisher.publish(Pong.SIZE);
}
然后 agent 加了一行调试日志 — 只有在某个 flag 被置上时才打,只在开发时用:
public noalloc void onPing(EventHeader header, Ping evt) {
if (this.debug) {
String msg = "ping " + evt.ts; // <- string concatenation
System.out.println(msg);
}
...
}
在字符串拼接处 error[E_NOALLOC]。字符串的 + 会分配。noalloc 修饰符是传递性的 — 它钉住整棵调用树 — 所以方法体内三行深处的分配在编译时就被抓住了。不用运行时 profiling,不用火焰图,不用查"为什么上次合并之后延迟退步了 400ns"。编译器说了不。
修复方式:把调试日志挪到一个单独的、非 noalloc 的方法里,热路径只在 debug 为 true 时才调用它。稳态入口上的 noalloc 依然成立;调试路径可以自由分配。这正是 noalloc 设计成要产生的形状 — 分配边界是显式的,不是偶然的。
如果没有 claim:调试日志藏在一个 flag 后面被人忘了关就进了生产,稳态路径每个事件分配一个 String,SEDA 的延迟预算悄悄退化。你三周后在一个 benchmark 里才发现,然后花一天时间二分定位。
5. 打破分层的 import
一个交易系统。三层:
static final String[] architecture = {
"trading.model", "",
"trading.risk", "trading.model",
"trading.exec", "trading.model, trading.risk, httpkit",
};
model 是 pure 的。risk 依赖 model。exec 依赖两者并和交易所通信。某天,trading.risk 里一次改动需要从交易所的 HTTP API 取一个价格。agent 加上了:
package trading.risk;
import sj.http.HttpClient; // <- exec-layer concern
error[E_ARCH_DENIED]: package `trading.risk` imports `sj.http`
but its architecture entry allows only: trading.model.
architecture 声明是一堵编译期的墙。trading.risk 不能 import HTTP 客户端 — 不是因为这个 import 错了,而是因为它所在的层不被允许和网络说话。价格查询属于 trading.exec(持有 net),结果作为一个参数传回 trading.risk。
这是没有 claim 的话代价最高的一类问题。代码能编译。测试能过。HTTP 调用能工作。但 trading.risk 现在有了一个对网络的隐藏依赖,这意味着每个风险计算都需要一个活的交易所才能测,risk 模块无法在一个离线回测器里复用,而让系统可被理解的干净分层就这样悄悄地 — 在一个 import 里 — 消失了。
修复方式:agent 把 HTTP 调用挪到 trading.exec,把价格作为参数传进来。两分钟,由 E_ARCH_DENIED 消息引导。分层保持干净,是因为编译器在强制,而不是因为团队有纪律。
这个模式
五个 bug。五种不同的 claim。一个共同点:每一个都在编译时或一次受检查的构建中被抓住,带一个稳定的诊断码命名,并由一条消息引导的小改动修复。没有一个需要代码 review 才能发现。没有一个需要有人想到去写的测试。没有一个流入生产。
claim 不是一个验证系统 — 它们不证明你的代码是正确的。它们是一个绊线系统:它们在一个很薄的层里声明你的意图,然后编译器把每一行代码都拿到这个意图下检查 — 包括依赖趁你不注意加进来的那几行、AI agent 接错包的那几行,以及你在周五下午五点写下、周一就会后悔的那几行。
按这个顺序采用:先 capabilities(一个键,即时默认拒绝,运行 superj capabilities 再收紧),其次 architecture(预期第一次 superj topology 会教会你点什么),最后是契约(只用在钱路径和核心类型上 — 别给全世界加注解)。每一个抓住一类不同的 bug。合在一起,它们把"本该有人抓住那个"变成"编译器抓住了"。
整个装置离你只有一个 Build.sj 键的距离。加上它,这张列表上的下一个 bug 就是你永远不会听说的那一个。