api-contracts.html 12.9 KB
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Unit Contract 规范 · LemonBoss PRD</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,500;9..144,700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="assets/shell.css" />
</head>
<body data-page="api-contracts.html">
<div class="shell">
  <aside id="sidebar"></aside>
  <main class="page">
    <header class="page-head">
      <div class="crumb"><span class="pill">06 · API</span><span class="sep">/</span><span>Unit Contract 规范</span></div>
      <h1>Unit Contract 规范</h1>
      <p class="deck">每个 Unit 暴露给「外部」的契约协议。Architect 在 SDD 中规定,Coder 实现后产出 <code>contract.json</code>。两者必须一致;DocStore 校验。Contract 是跨 Unit 协同的唯一接口。</p>
    </header>
    <article class="body">

      <section>
        <h2>为什么需要 Contract</h2>
        <p>没有 Contract 时,downstream Unit(如 auth-fe)只能<b>读 upstream Unit 的源码</b>(如 auth-be 的 server.js)。这会引发:</p>
        <ul>
          <li>实现细节耦合,任何重构都连环失败</li>
          <li>Coder Agent 沙箱必须开放跨目录读权限(不安全)</li>
          <li>contract_hash 无法计算 → L2 gate 失效</li>
        </ul>
        <p>有 Contract 后,downstream 只读 upstream 的 <code>contract.json</code>。这是<b>跨 Unit 唯一允许的读路径</b>。</p>
      </section>

      <section>
        <h2>Contract Schema(TypeBox)</h2>
<pre class="code"><span class="k">const</span> Contract = Type.<span class="n">Object</span>(<span class="p">{</span>
  unit_id:  Type.<span class="n">String</span>(),
  version:  Type.<span class="n">String</span>(<span class="p">{</span> pattern: <span class="s">'^sha256:'</span> <span class="p">}</span>),
  exports:  Type.<span class="n">Object</span>(<span class="p">{</span>
    http:     Type.<span class="n">Optional</span>(Type.<span class="n">Array</span>(HttpEndpoint)),
    ts_types: Type.<span class="n">Optional</span>(Type.<span class="n">String</span>()),     <span class="c">// 相对路径 ./types.d.ts</span>
    events:   Type.<span class="n">Optional</span>(Type.<span class="n">Array</span>(EventSpec)),
    cli:      Type.<span class="n">Optional</span>(Type.<span class="n">Array</span>(CliSpec)),
  <span class="p">}</span>),
  consumes: Type.<span class="n">Optional</span>(Type.<span class="n">Array</span>(Type.<span class="n">String</span>())), <span class="c">// 上游 Unit id</span>
<span class="p">}</span>);

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

<span class="k">const</span> EventSpec = Type.<span class="n">Object</span>(<span class="p">{</span>
  name:    Type.<span class="n">String</span>(),         <span class="c">// "unit.state"</span>
  payload: Type.<span class="n">Object</span>(<span class="p">{}</span>, <span class="p">{</span> additionalProperties: <span class="v">true</span> <span class="p">}</span>),
  channel: Type.<span class="n">Union</span>(<span class="p">[</span>Type.<span class="n">Literal</span>(<span class="s">'sse'</span>), Type.<span class="n">Literal</span>(<span class="s">'ws'</span>)<span class="p">]</span>),
<span class="p">}</span>);

<span class="k">const</span> CliSpec = Type.<span class="n">Object</span>(<span class="p">{</span>
  cmd:    Type.<span class="n">String</span>(),         <span class="c">// "auto-dev"</span>
  args:   Type.<span class="n">Array</span>(Type.<span class="n">Object</span>(<span class="p">{</span> name: Type.<span class="n">String</span>(), required: Type.<span class="n">Boolean</span>() <span class="p">}</span>)),
<span class="p">}</span>);</pre>
      </section>

      <section>
        <h2>完整示例 · auth-be</h2>
<pre class="code"><span class="p">{</span>
  <span class="n">"unit_id"</span>: <span class="s">"auth-be"</span>,
  <span class="n">"version"</span>: <span class="s">"sha256:7f3a..."</span>,
  <span class="n">"exports"</span>: <span class="p">{</span>
    <span class="n">"http"</span>: <span class="p">[</span>
      <span class="p">{</span>
        <span class="n">"method"</span>: <span class="s">"POST"</span>,
        <span class="n">"path"</span>: <span class="s">"/api/auth/login"</span>,
        <span class="n">"request"</span>: <span class="p">{</span> <span class="n">"email"</span>: <span class="s">"string"</span>, <span class="n">"password"</span>: <span class="s">"string"</span> <span class="p">}</span>,
        <span class="n">"responses"</span>: <span class="p">{</span>
          <span class="n">"200"</span>: <span class="p">{</span> <span class="n">"token"</span>: <span class="s">"string"</span>, <span class="n">"user_id"</span>: <span class="s">"string"</span> <span class="p">}</span>,
          <span class="n">"401"</span>: <span class="p">{</span> <span class="n">"error"</span>: <span class="s">"\"invalid_credentials\""</span> <span class="p">}</span>
        <span class="p">}</span>,
        <span class="n">"auth"</span>: <span class="s">"none"</span>
      <span class="p">}</span>,
      <span class="p">{</span>
        <span class="n">"method"</span>: <span class="s">"POST"</span>,
        <span class="n">"path"</span>: <span class="s">"/api/auth/register"</span>,
        <span class="n">"request"</span>: <span class="p">{</span> <span class="n">"email"</span>: <span class="s">"string"</span>, <span class="n">"password"</span>: <span class="s">"string"</span> <span class="p">}</span>,
        <span class="n">"responses"</span>: <span class="p">{</span>
          <span class="n">"201"</span>: <span class="p">{</span> <span class="n">"user_id"</span>: <span class="s">"string"</span> <span class="p">}</span>,
          <span class="n">"409"</span>: <span class="p">{</span> <span class="n">"error"</span>: <span class="s">"\"email_exists\""</span> <span class="p">}</span>
        <span class="p">}</span>,
        <span class="n">"auth"</span>: <span class="s">"none"</span>
      <span class="p">}</span>
    <span class="p">]</span>,
    <span class="n">"ts_types"</span>: <span class="s">"./types.d.ts"</span>
  <span class="p">}</span>,
  <span class="n">"consumes"</span>: <span class="p">[]</span>
<span class="p">}</span></pre>
      </section>

      <section>
        <h2>contract_hash 计算</h2>
<pre class="code"><span class="k">function</span> <span class="n">hashContract</span>(contract) <span class="p">{</span>
  <span class="c">// 1. 序列化 exports 字段(不包含 version 自身)</span>
  <span class="k">const</span> normalized = canonicalJSON(contract.<span class="n">exports</span>);
  <span class="c">// 2. sha256 完整 hex</span>
  <span class="k">const</span> full = crypto.<span class="n">createHash</span>(<span class="s">'sha256'</span>).<span class="n">update</span>(normalized).<span class="n">digest</span>(<span class="s">'hex'</span>);
  <span class="c">// 3. 截前 16 位(加 'sha256:' 前缀)</span>
  <span class="k">return</span> <span class="s">`sha256:</span><span class="v">${full.slice(0, 16)}</span><span class="s">`</span>;
<span class="p">}</span>

<span class="c">// canonicalJSON:key 按字典序排列,无空白,统一引号</span>
<span class="c">// 保证同一 exports 在任意机器上 hash 完全相同</span></pre>
        <p class="sig">截 16 位的概率碰撞计算 = 2^-64,对单项目内 ≤ 100 个 Unit 安全。</p>
      </section>

      <section>
        <h2>SDD ↔ contract.json 一致性</h2>
        <p>Architect 在 SDD 写的 <code>&lt;pre data-contract&gt;</code> 内容必须与 Coder 写的 contract.json 一致。校验流程:</p>
        <ol>
          <li>Architect 写 SDD 时把 contract 文本固化在 <code>&lt;pre&gt;</code></li>
          <li>Coder 实现完成后写 contract.json,<b>必须包含 SDD 描述的全部 endpoints</b></li>
          <li>DocStore 计算两者 hash 对比 — 不一致 → Coder 视为 fail,触发 retry</li>
          <li>反过来 Coder 不许新增 SDD 未声明的 endpoint(沙箱拒绝)</li>
        </ol>
      </section>

      <section>
        <h2>contract 变化触发的连锁</h2>
        <svg viewBox="0 0 1100 220" width="100%" role="img">
          <defs><marker id="arrCT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 Z" fill="#0E0E10"/></marker></defs>
          <g font-family="JetBrains Mono" font-size="11" fill="#0E0E10">
            <rect x="20"   y="80" width="180" height="50" rx="6" fill="#fff" stroke="#0E0E10"/>
            <text x="110" y="105" text-anchor="middle">Architect 改 SDD</text>
            <text x="110" y="120" text-anchor="middle" font-size="9">&lt;pre data-contract&gt; 内容变</text>

            <rect x="240" y="80" width="180" height="50" rx="6" fill="#fff" stroke="#0E0E10"/>
            <text x="330" y="105" text-anchor="middle">新 contract_hash</text>
            <text x="330" y="120" text-anchor="middle" font-size="9">DocStore 计算</text>

            <rect x="460" y="80" width="180" height="50" rx="6" fill="#FFF6CC" stroke="#8a6b00"/>
            <text x="550" y="105" text-anchor="middle">UnitRegistry diff</text>
            <text x="550" y="120" text-anchor="middle" font-size="9">该 Unit changed</text>

            <rect x="680" y="80" width="180" height="50" rx="6" fill="#E2F2EA" stroke="#0E8C5A"/>
            <text x="770" y="105" text-anchor="middle">reset 该 Unit</text>
            <text x="770" y="120" text-anchor="middle" font-size="9">→ pending</text>

            <rect x="900" y="80" width="180" height="50" rx="6" fill="#E2F2EA" stroke="#0E8C5A"/>
            <text x="990" y="105" text-anchor="middle">L1 重跑</text>
            <text x="990" y="120" text-anchor="middle" font-size="9">Coder 看到新契约</text>

            <g stroke="#0E0E10" fill="none" marker-end="url(#arrCT)">
              <line x1="200" y1="105" x2="235" y2="105"/>
              <line x1="420" y1="105" x2="455" y2="105"/>
              <line x1="640" y1="105" x2="675" y2="105"/>
              <line x1="860" y1="105" x2="895" y2="105"/>
            </g>
          </g>
        </svg>
      </section>

      <section>
        <h2>跨 Unit 引用约定</h2>
        <p>auth-fe 想用 auth-be 的契约,<b>不</b>读 auth-be 的代码。流程:</p>
        <pre class="code"><span class="c">// auth-fe 的 Coder Agent 在 system prompt 指引下:</span>
<span class="k">const</span> contract = JSON.<span class="n">parse</span>(fs.<span class="n">readFileSync</span>(<span class="s">'../auth-be/contract.json'</span>));
<span class="k">const</span> endpoint = contract.exports.<span class="n">http</span>.<span class="n">find</span>(e =&gt; e.<span class="n">path</span> === <span class="s">'/api/auth/login'</span>);
<span class="c">// → 写 fetch 调用基于 endpoint.responses 推导出 200 / 401 分支</span></pre>
        <p>沙箱白名单允许 <code>../{otherUnit}/contract.json</code> 只读,<b>不</b>允许读 <code>../{otherUnit}/code/</code>。</p>
      </section>

    </article>
    <nav class="prev-next"></nav>
    <footer class="site"><div><b>Section</b>06 · API</div><div><b>Page</b>api-contracts.html</div></footer>
  </main>
</div>
<script src="assets/shell.js"></script>
</body>
</html>