契約ファーストでLLMに実装させる|I/Oとexit codeを先に固定する

LLM に実装させる前に固定すべきは、手順ではなく契約である。ここでいう契約とは、入出力の形・スキーマ・失敗時の挙動を指す。結論から言えば、手順の指示はモデルの解釈でぶれるが、契約は機械で検証できる。だから契約を先に1枚書き、その順守を検証で強制する——本稿はその設計を、本サイトの実物で整理する。

なぜ契約が先か

「こういう手順で実装して」という指示は、モデルの解釈に開かれている。同じ指示でも、セッションが変われば別の関数名・別のデータ形・別のエラー処理が返ってくる。自然言語で書く以上、ぶれはゼロにできない。

対して契約はぶれを機械で検出できる。入出力の形は型やテストで、スキーマは検証コードで、工程が通ったかは終了コードで判定できる。合否が人間の主観ではなく diff や例外や exit code で決まる——これが「手順」と「契約」の決定的な違いである。

そしてもう一つ、契約と実装のどちらが正かを先に決めておく必要がある。CMS を自作している未公開プロジェクトの API 契約規約は、その主従をこう明文化している。

実装が契約と食い違ったら、先に yaml を直してから実装を直す …
(中略)
v1 の間、レスポンスから何かを消さない。「使われていないはず」は理由にならない

契約が正で、実装が従う。LLM が実装の都合で契約を曲げようとしても、曲げてよいのは契約ファイルを先に直したときだけである。この順序が無いと、契約が生成のたびに実装へ引きずられ、固定した意味が消える。

契約に書くもの

契約に何を書くかは、次の4点で棚卸しできる。

  • I/O: 入出力の形。エントリポイントの関数名・引数・戻り値の形式まで含める
  • スキーマ: フィールドの必須/任意・形式・列挙値(enum)
  • 失敗時挙動: 異常時にどう振る舞うか。exit code・例外・縮退方針
  • 禁止事項: 「〜しない」を明文で。暗黙の期待は破られる

見落とされやすいのが3番目の失敗時挙動である。正常系だけを契約にすると、異常時の振る舞いがモデル任せになる。セッションログビューワのプロジェクトは、失敗時挙動を契約の中心に据えている。

このプロジェクトの生存戦略は「壊れずに劣化する(graceful degradation)」こと。
未知を落とさない … 未知の type は … 生 JSON ごと保持し … raw JSON 表示にフォールバックする
壊れ行で止まらない … 例外でセッション全体の読み込みを落とさない

「壊れたら止まる」のか「壊れずに劣化する」のかは設計判断であって、モデルが即興で決めてよいものではない。4番目の禁止事項も同じで、「〜しない」という負の制約は明文化しない限り生成のたびに破られる。

実例 — 1枚の契約を2つの検証が挟む

本サイトの記事メタデータは、この設計の実物である。まず契約が1枚ある——そのスキーマを定める DATA-8(要点抜粋)である。

必須: slug(^[a-z0-9-]+$、/articles/<slug>/ と一致), id(記事番号 NN),
      title, description, publishedAt(YYYY-MM-DD),
      status(draft | published。published のみ描画・sitemap 掲載)
検証は DATA-6 と同様に明示失敗:
      必須キー欠落・slug 形式不正・記事 slug 重複・status 不正は例外
# … .claude/rules/10-data-schema.md の DATA-8 から要点抜粋(任意フィールド等を省略)

この1枚の契約を、2つの検証が両側から挟む。一つはランタイムの検証で、lib/SiteRepository.php が JSON を読むたびに契約を強制し、外れたら例外で止める。

$slug = $article['slug'];
if (preg_match('/^[a-z0-9-]+$/', $slug) !== 1) {
    throw new RuntimeException("article slug が不正です(^[a-z0-9-]+$ に不一致): {$slug}");
}
# …
$status = $article['status'];
if (!in_array($status, self::VALID_ARTICLE_STATUS, true)) {
    throw new RuntimeException("article status が不正です(draft|published 以外): {$status} [slug: {$slug}]");
}

契約の ^[a-z0-9-]+$draft | published も、そのまま実行時の検査になり、違反があれば半壊状態で描画せず例外で止まる。

もう一つは制作工程の検証で、組版ツール tools/build-articles.php が原稿の frontmatter と sites.json のメタを機械照合し、食い違えば exit 1 で止める。

if (($meta['id'] ?? null) !== $target['id']) {
    $errors[] = "id が一致しません: ファイル名={$target['id']} / sites.json=" . (string) ($meta['id'] ?? '(なし)');
}

同じ DATA-8 という1枚の契約を、ランタイムの例外と制作工程の exit 1 が両側から強制している。契約が1箇所に固定されているからこそ、検証を2箇所に置いても食い違わない。この記事自体も、その2つの検証を通って公開されている。組版ゲートの詳細は別稿で扱う。

契約とゲートの接続

制作工程の検証が価値を持つのは、その exit code をゲート条件に接続したときである。組版が exit 1 を返す限り、その記事は承認フェーズへ進めない。契約 → 機械検証 → ゲートが一直線につながり、契約に外れた成果物は先へ進めなくなる。

ここで効くのは、合否が主観ではなく終了コードで出ることである。レビュアーの気分でもその日の丁寧さでもなく、01 かで決まる。だから LLM の生成物であっても、ゲートの前で機械的にふるいにかけられる。失敗を exit code でゲートに接続する運用そのものはフェーズを刻んで承認を挟む記事で扱った。

よくある質問

契約が重すぎて、書くほうが実装より大変になる。 すべてを契約にする必要はない。「取りこぼすと壊れる境界」だけを薄く書けばよい。国会議事録を AI 要約する公開ツールの開発では、各フェーズ関数の名前・引数・戻り値の形式という暗黙契約だけを INTERFACES.md という薄いファイルへ切り出した。別セッションのエージェントがこの境界を取りこぼすと、別名で実装されて呼び出せなくなるからである。メモリに保存する案(ロード保証が弱い)や、委譲のたびにプロンプトへ手書きする案(属人的で抜け漏れる)は退けた。全部ではなく、壊れる境界だけを薄く固定する。

既存コードに後付けするには。 契約が無いコードには、現物から契約を書き起こす。動いている実装が事実上の契約なので、その入出力・スキーマ・失敗時挙動を読み取って明文化し、以後はそれを正として扱えばよい。

まとめ

LLM に実装させる前に固定するのは、手順ではなく契約——入出力・スキーマ・失敗時挙動である。契約は機械で検証でき、契約が正・実装が従という主従を先に決める。契約は1枚に固定し、ランタイムと制作工程の両側から検証で強制する。検証の exit code をゲートに接続すれば、契約に外れた成果物は先へ進めない。固定すべきは分量ではなく、取りこぼすと壊れる一線だと考えている。

--- 参照: .claude/rules/10-data-schema.md(DATA-8)/lib/SiteRepository.phptools/build-articles.php(いずれも本サイトの実装、2026-07-16 確認)。関連事例(e-Stat 公開 API での検証ファースト設計): https://zenn.dev/saneyuki_rokaru/articles/estat-population-api-design (2026-07-16 確認)