06 · API/Unit Contract 规范

Unit Contract 规范

每个 Unit 暴露给「外部」的契约协议。Architect 在 SDD 中规定,Coder 实现后产出 contract.json。两者必须一致;DocStore 校验。Contract 是跨 Unit 协同的唯一接口。

为什么需要 Contract

没有 Contract 时,downstream Unit(如 auth-fe)只能读 upstream Unit 的源码(如 auth-be 的 server.js)。这会引发:

  • 实现细节耦合,任何重构都连环失败
  • Coder Agent 沙箱必须开放跨目录读权限(不安全)
  • contract_hash 无法计算 → L2 gate 失效

有 Contract 后,downstream 只读 upstream 的 contract.json。这是跨 Unit 唯一允许的读路径。

Contract Schema(TypeBox)

const Contract = Type.Object({
  unit_id:  Type.String(),
  version:  Type.String({ pattern: '^sha256:' }),
  exports:  Type.Object({
    http:     Type.Optional(Type.Array(HttpEndpoint)),
    ts_types: Type.Optional(Type.String()),     // 相对路径 ./types.d.ts
    events:   Type.Optional(Type.Array(EventSpec)),
    cli:      Type.Optional(Type.Array(CliSpec)),
  }),
  consumes: Type.Optional(Type.Array(Type.String())), // 上游 Unit id
});

const HttpEndpoint = Type.Object({
  method:    Type.Union([Type.Literal('GET'), Type.Literal('POST'),
                          Type.Literal('PUT'), Type.Literal('DELETE'),
                          Type.Literal('PATCH')]),
  path:      Type.String(),
  request:   Type.Optional(Type.Object({}, { additionalProperties: true })),
  responses: Type.Record(Type.String(),    // "200" | "401" | ...
                          Type.Object({}, { additionalProperties: true })),
  auth:      Type.Optional(Type.Union([
    Type.Literal('none'), Type.Literal('jwt'), Type.Literal('apikey')
  ])),
});

const EventSpec = Type.Object({
  name:    Type.String(),         // "unit.state"
  payload: Type.Object({}, { additionalProperties: true }),
  channel: Type.Union([Type.Literal('sse'), Type.Literal('ws')]),
});

const CliSpec = Type.Object({
  cmd:    Type.String(),         // "auto-dev"
  args:   Type.Array(Type.Object({ name: Type.String(), required: Type.Boolean() })),
});

完整示例 · auth-be

{
  "unit_id": "auth-be",
  "version": "sha256:7f3a...",
  "exports": {
    "http": [
      {
        "method": "POST",
        "path": "/api/auth/login",
        "request": { "email": "string", "password": "string" },
        "responses": {
          "200": { "token": "string", "user_id": "string" },
          "401": { "error": "\"invalid_credentials\"" }
        },
        "auth": "none"
      },
      {
        "method": "POST",
        "path": "/api/auth/register",
        "request": { "email": "string", "password": "string" },
        "responses": {
          "201": { "user_id": "string" },
          "409": { "error": "\"email_exists\"" }
        },
        "auth": "none"
      }
    ],
    "ts_types": "./types.d.ts"
  },
  "consumes": []
}

contract_hash 计算

function hashContract(contract) {
  // 1. 序列化 exports 字段(不包含 version 自身)
  const normalized = canonicalJSON(contract.exports);
  // 2. sha256 完整 hex
  const full = crypto.createHash('sha256').update(normalized).digest('hex');
  // 3. 截前 16 位(加 'sha256:' 前缀)
  return `sha256:${full.slice(0, 16)}`;
}

// canonicalJSON:key 按字典序排列,无空白,统一引号
// 保证同一 exports 在任意机器上 hash 完全相同

截 16 位的概率碰撞计算 = 2^-64,对单项目内 ≤ 100 个 Unit 安全。

SDD ↔ contract.json 一致性

Architect 在 SDD 写的 <pre data-contract> 内容必须与 Coder 写的 contract.json 一致。校验流程:

  1. Architect 写 SDD 时把 contract 文本固化在 <pre>
  2. Coder 实现完成后写 contract.json,必须包含 SDD 描述的全部 endpoints
  3. DocStore 计算两者 hash 对比 — 不一致 → Coder 视为 fail,触发 retry
  4. 反过来 Coder 不许新增 SDD 未声明的 endpoint(沙箱拒绝)

contract 变化触发的连锁

Architect 改 SDD <pre data-contract> 内容变 新 contract_hash DocStore 计算 UnitRegistry diff 该 Unit changed reset 该 Unit → pending L1 重跑 Coder 看到新契约

跨 Unit 引用约定

auth-fe 想用 auth-be 的契约,不读 auth-be 的代码。流程:

// auth-fe 的 Coder Agent 在 system prompt 指引下:
const contract = JSON.parse(fs.readFileSync('../auth-be/contract.json'));
const endpoint = contract.exports.http.find(e => e.path === '/api/auth/login');
// → 写 fetch 调用基于 endpoint.responses 推导出 200 / 401 分支

沙箱白名单允许 ../{otherUnit}/contract.json 只读,不允许读 ../{otherUnit}/code/。