TL;DR
- 同じ問題を毎回 AI に解かせるのをやめたいなら、必要なのは「AI の判断を自動化に置き換える」仕組みだけではありません。「そろそろ自動化に落とすべきだ」と気づく仕組みのほうが先に壊れます。
- このリポジトリには昇格ループが実装済みです。観測台帳(
spec/article_failure_ledger.md)、閾値を定義した契約(spec/article_retrospective_loop.md)、昇格漏れを検出するガード(scripts/check_failure_ledger.py)、rollback 規約の 4 段が揃っています。CI は緑でした。 - その CI 緑のまま、8 種のすり抜け型のうち 6 種で昇格判定が沈黙していました(
社内データ)。一度でも改善 PR を出した型は、以後どれだけ再発を積んでも検出されませんでした。 - 対照実験で確かめました。過去に改善 PR を持たない型の未対応を 2 件に増やすと
exit 1で落ちます。過去に改善 PR を持つ型を4 件に増やしてもexit 0で通っていました(社内データ)。 - さらに悪い形でした。その
exit 0のとき、同じスクリプトの標準出力には「昇格が必要」と表示されています。診断は正しく計算されているのに、合否へ伝播していませんでした。人間が読む行と CI が読む終了コードが食い違う状態です。 - 直し方は「抑制条件を精密にする」ではなく抑制そのものをやめるでした。改善 PR が付いているのは「その行が直った」記録であって「その型が今後すべて解決した」記録ではないからです。修正後に同じ差分テストを流すと、沈黙は 6/8 → 0/8 になります(
社内データ)。 - 入口の穴のほうは残ります。判定器がリポジトリ内で開くファイルは 3 件だけ(うち 1 件はスクリプト自身)で、実質の入力は契約と台帳の 2 ファイルです(
社内データ)。台帳に書かれなかった失敗は、台帳を数えても現れません。 - 第 4 節に、自分のガードへ「必ず鳴るはずの入力」を与えて鳴らない型を洗い出す差分テストを置きました。実装を読まないので、ガードを直せば結果も追随します。終了コードは 0(鳴らない型なし)/ 1(検出)/ 2(検査できなかった)に分かれ、検査できなかった状態を合格と同じ出力で返しません。
はじめに:昇格ループは「昇格させる側」より「気づく側」で壊れる
AI エージェントに仕事を任せていると、同じ判断を毎回させていることに気づきます。同じ形式の見落とし、同じ規約違反、同じレビュー指摘。そのたびにモデルを呼び、そのたびに少しずつ違う答えが返ってきます。コストは積み上がり、結果は揺らぎます。
やることは決まっています。**繰り返し現れる判断は、決定的な自動化(スクリプトと CI)へ落とす。**Google SRE の言葉を借りれば、機械が人間と同等に実行できる作業は toil であり、設計で消す対象です(Eliminating Toil)。
ここまでは合意しやすい話です。問題は、その先の運用にあります。
「どれを昇格させるか」を決めるには、証拠が要ります。証拠を集めるには、失敗を記録する必要があります。記録するには、失敗が起きたときに誰かが書き留める必要があります。そして昇格すべき水準に達したことを、誰かが気づく必要があります。
この連鎖のうち、実際に壊れるのは最後の 2 つです。
AI エージェント向けのスキル設計そのものは、スキルを資産化する定義の書き方 や Agent Skill ハブ、レビュースキルをレジストリ化する で扱いました。この記事はその手前でも先でもなく、「スキルやガードを増やすべきだと判断する経路」そのものを対象にします。蓄積される知識の側(schema / 出所 / 失効 / 巻き戻し)は AIエージェントのOperational Memory設計 が扱っていて、本記事はその知識をいつ自動化へ引き上げるかの判断側にあたります。
数値はすべて、このリポジトリの 0f60c93(2026-09-09)時点の台帳 14 行に対する実測です。第 4 節と第 5 節では、比較のために修正前(2fead50)のガードも同じ入力で動かしています。どちらの時点かは各ブロックに書きました。一般論としての推奨ではなく、実装したうえで測ったら判定器が死んでいて、直して測り直した記録として書きます。
1. 昇格ループを 4 段に分ける
昇格ループを「AI の出力を自動化に変える」という 1 個の作業として考えると設計できません。段に分けると、それぞれ別の壊れ方をすることが見えます。
| 段 | 答えるべき問い | このリポジトリでの実体 | 典型的な壊れ方 |
|---|---|---|---|
| evidence | 何が実際に起きたか | spec/article_failure_ledger.md(1 行 = 1 観測) | 書かれない |
| policy gate | どの条件で昇格するか | spec/article_retrospective_loop.md の昇格条件 | 条件が主観的で数えられない |
| 昇格判定 | 条件を満たしたことに誰が気づくか | scripts/check_failure_ledger.py | 鳴らない |
| rollback | 効かなかったとき何で戻すか | 改善は必ず単独 PR(git revert 1 手) | 混ぜてしまい戻せない |
多くのチームは 1 段目と 2 段目を作って満足します。ポストモーテムを書き、改善方針を決める。Postmortem Culture が扱っているのはここです。
しかし 3 段目が無いと、1 段目は「読まれない記録」に、2 段目は「守られない方針」になります。3 段目を機械化して初めて、閾値が意味を持ちます。
2. evidence:観測を「なぜすり抜けたか」で分類する
台帳を作るとき、最初に迷ったのが分類軸でした。
素直に思いつくのは工程での分類です。リサーチのミス、執筆のミス、事実誤認。しかしこれは記録には使えても、対策を決められません。同じ「事実誤認」でも、検査が無かったのか、検査はあるが発火しなかったのか、発火したが呼び出し側が結果を捨てたのかで、打つ手はまったく違います。
そこで軸を「誰が間違えたか」ではなく「なぜ Harness をすり抜けたか」に置きました。この分類の定義そのものは spec/article_retrospective_loop.md にしかありません(転記すると定義が二重管理になり、片方だけ古びます)。ここでは性質だけ書きます。
- 型は 9 種(未分類を含む)で、1 観測につき 1 つだけ選ぶ
- どれにも当てはまらないものは未分類に置き、2 件たまるまで新しい型を作らない(1 件の偶発事象で分類体系を膨らませないため)
- 主観評価は台帳に入れない。入るのは CI の失敗・レビューの確定指摘・公開後の誤り・手順の未実行という観測可能な事象だけ
この「1 観測 1 型」がそのまま昇格判定になります。閾値は「同じ型の未対応が 2 件以上」。列を数えるだけで判定できる形にしたのは、3 段目を機械化するためです。
3. policy gate:閾値をガード側に書き写さない
判定ガードを書くとき、最初にやりたくなるのは閾値の定数化です。
PROMOTION_THRESHOLD = 2 # これをやると契約が二重管理になる
これをやると、契約を直したときにガードだけ古い値のまま残ります。実際にこのリポジトリでは、契約表を別ファイルへ転記した結果、転記した時点ですでに SSoT と食い違っていたという観測があります(台帳 L-0004)。
なので scripts/check_failure_ledger.py は、値域も必須列も閾値も契約の Markdown から実行時に読み出します。
$ python3 scripts/check_failure_ledger.py --require-rows
検査対象: spec/article_failure_ledger.md の観測行 14 件(列 9 個 / escape_mode 値域 9 種 / status 値域 5 種)
昇格漏れ検査: status:open 4 件 / escape_mode 4 種を集計しました(閾値: 同一 escape_mode 2 件以上)
E3: open 1 件 → 閾値未満(昇格しない)(他 status: fixed 3/判定には使わない)
E4: open 1 件 → 閾値未満(昇格しない)
E7: open 1 件 → 閾値未満(昇格しない)
E8: open 1 件 → 閾値未満(昇格しない)(他 status: promoted 1/判定には使わない)
failure ledger check passed.
判断のルールを実行コードから切り離し、宣言された定義のほうを正本にする、という考え方自体は policy as code の系譜です(Open Policy Agent)。ここでやっているのはその最小形で、ポリシーエンジンを入れる代わりに 契約の Markdown をそのまま読むだけです。
出力に「列 9 個 / 値域 9 種 / 閾値 2」と出ているのは飾りではありません。何を読んで判定したかを毎回表示することで、契約を読めていないのに合格したケースを見分けられます。
ここまでは、設計としてはうまくいっています。CI も緑です。問題は次です。
4. 昇格判定を監査する:鳴らない型を差分テストで見つける
ガードがあることと、ガードが効いていることは別です。ここを取り違えないために、判定器そのものに監査をかけました。
監査の問いは 1 つです。「この型は、未対応が積み上がったときに鳴るのか?」
実装を読んで推測するのではなく、必ず鳴るはずの入力を合成して、実際のガードへ通し、終了コードを見ます。実装が変わっても結果が追随するのがこの形の利点です。audit_promotion_silence.py として保存します。
#!/usr/bin/env python3
"""昇格判定が「もう鳴らない型」を、実装を読まずに突き止める差分テスト。
台帳に出てくる型ごとに「必ず鳴るはずの入力」(未対応をしきい値以上に積んだ台帳)を
合成し、実際のガードへ通して終了コードを見る。ガードの実装が変わっても結果は追随する。
usage: audit_promotion_silence.py <spec ディレクトリ> <ガードのパス> [--max N]
終了コード:
0 : 検査して、鳴らない型が 0 件だった
1 : 鳴らない型を検出した
2 : 検査できなかった(ファイル不在 / ベースラインが最初から非 0)。合格ではない
"""
from __future__ import annotations
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
LEDGER = "article_failure_ledger.md"
ROW_RE = re.compile(r"^\| (L-\d+) \|([^|]*)\|([^|]*)\| (E\d+) \|.*\| (\w+) \|", re.M)
def sync_summary(text: str) -> str:
"""「現在の集計」表を本体から数え直す(集計不一致で落ちると原因が混ざるため)。"""
counts: dict[str, int] = {}
for _, _, _, mode, status in ROW_RE.findall(text):
if status == "open":
counts[mode] = counts.get(mode, 0) + 1
rows = "\n".join(f"| {m} | {c} | 実測 |" for m, c in sorted(counts.items()))
return re.sub(
r"(\| escape_mode \| open 件数 \| 昇格判定 \|\n\| --- \| --- \| --- \|\n)(?:\|.*\n)+",
lambda m: m.group(1) + rows + "\n",
text,
)
def inject(text: str, mode: str, count: int, start_id: int) -> str:
"""指定した型の未対応行を count 件だけ足す。"""
lines = text.splitlines()
last = max(i for i, line in enumerate(lines) if line.startswith("| L-"))
extra = [
f"| L-{start_id + i} | 2026-01-01 | harness | {mode} | 差分テスト用の合成観測 | "
f"audit_promotion_silence.py | open | | |"
for i in range(count)
]
return sync_summary("\n".join(lines[: last + 1] + extra + lines[last + 1 :]) + "\n")
def run_guard(spec_dir: Path, guard: Path, text: str) -> int:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "repo"
shutil.copytree(spec_dir, root / "spec")
(root / "spec" / LEDGER).write_text(text, encoding="utf-8")
result = subprocess.run(
[sys.executable, str(guard), "--repo-root", str(root)],
capture_output=True,
text=True,
)
return result.returncode
def main(argv: list[str]) -> int:
if len(argv) < 3:
print("usage: audit_promotion_silence.py <spec dir> <guard> [--max N]", file=sys.stderr)
return 2
spec_dir, guard = Path(argv[1]), Path(argv[2])
max_rows = int(argv[argv.index("--max") + 1]) if "--max" in argv else 5
ledger = spec_dir / LEDGER
if not ledger.is_file() or not guard.is_file():
print("ERROR: 台帳またはガードが読めません。検査していません(合格ではありません)", file=sys.stderr)
return 2
base_text = sync_summary(ledger.read_text(encoding="utf-8"))
base_code = run_guard(spec_dir, guard, base_text)
if base_code != 0:
print(f"ERROR: ベースラインが exit {base_code} です。差分の原因を切り分けられないため検査しません",
file=sys.stderr)
return 2
modes = sorted({m for _, _, _, m, _ in ROW_RE.findall(base_text)})
print(f"ベースライン: exit 0 / 台帳に出現した escape_mode {len(modes)} 種(契約の値域とは別)")
if not modes:
print("ERROR: 台帳に観測行が 0 件です。これは『鳴らない型 0 件』ではなく『未検査』です",
file=sys.stderr)
return 2
silent = []
for mode in modes:
fired_at = None
for n in range(1, max_rows + 1):
if run_guard(spec_dir, guard, inject(base_text, mode, n, 9000)) != 0:
fired_at = n
break
if fired_at is None:
silent.append(mode)
print(f" {mode}: 未対応を +{max_rows} 件積んでもガードは exit 0 → 鳴らない")
else:
print(f" {mode}: 未対応を +{fired_at} 件積んだ時点で exit 非 0 → 鳴る")
if silent:
print(f"\nNG: {len(silent)}/{len(modes)} 種の型が、未対応をいくら積んでも昇格漏れとして鳴りません: "
f"{', '.join(silent)}")
return 1
print(f"\nOK: {len(modes)} 種すべてで昇格漏れが検出されました")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
前提は Python 3.9 以上(from __future__ import annotations により型注釈の評価は遅延されます)で、外部パッケージは不要です。台帳のコピーを一時ディレクトリへ作って実行するので、対象リポジトリには何も書き込みません。しきい値は契約から読まず、未対応を 1 件ずつ増やして鳴るまで試す(既定は 5 件まで)ので、契約の閾値が変わっても追随します。
修正前のガード(2fead50 時点の check_failure_ledger.py)に対して流した実出力です。
$ WORK="$(mktemp -d)" && trap 'rm -rf "$WORK"' EXIT
$ git show 2fead50:scripts/check_failure_ledger.py > "$WORK/guard_before.py"
$ python3 audit_promotion_silence.py spec "$WORK/guard_before.py"
ベースライン: exit 0 / 台帳に出現した escape_mode 8 種(契約の値域とは別)
E1: 未対応を +5 件積んでもガードは exit 0 → 鳴らない
E2: 未対応を +5 件積んでもガードは exit 0 → 鳴らない
E3: 未対応を +5 件積んでもガードは exit 0 → 鳴らない
E4: 未対応を +1 件積んだ時点で exit 非 0 → 鳴る
E5: 未対応を +5 件積んでもガードは exit 0 → 鳴らない
E6: 未対応を +5 件積んでもガードは exit 0 → 鳴らない
E7: 未対応を +1 件積んだ時点で exit 非 0 → 鳴る
E8: 未対応を +5 件積んでもガードは exit 0 → 鳴らない
NG: 6/8 種の型が、未対応をいくら積んでも昇格漏れとして鳴りません: E1, E2, E3, E5, E6, E8
$ echo $?
1
8 種のうち 6 種が沈黙していました(社内データ)。過去に一度でも改善 PR を出した型は、以後どれだけ再発しても昇格漏れとして検出されません。
分母の 8 は「契約が定義している型の数」ではなく、台帳に 1 行でも出現した型の数です。契約側の値域は未分類を含めて 9 種あります。一度も観測されていない型は、この監査でも黙って対象外になります。分母がどちらなのかを出力に書いておかないと、それ自体が「未検査を合格に見せる」形になります。
終了コードは 3 分岐です。全 5 経路の実測を載せます。「鳴らない型 0 件」と「検査していない」を同じ出力にしないためです。
| 入力 | 出力の要点 | 終了コード |
|---|---|---|
| 修正前のガード | 6/8 種が鳴らない | 1 |
| 修正後のガード(第 5 節) | OK: 8 種すべてで昇格漏れが検出されました | 0 |
| 存在しないパスをガードに指定 | ERROR: 台帳またはガードが読めません。検査していません(合格ではありません) | 2 |
別のガードを渡してベースラインが非 0(例: scripts/check_risk_gate_drift.py) | ERROR: ベースラインが exit 1 です。差分の原因を切り分けられないため検査しません | 2 |
| 観測行 0 件の台帳 | ERROR: 台帳に観測行が 0 件です。これは『鳴らない型 0 件』ではなく『未検査』です | 2 |
この監査が見ていないことも書いておきます。**ガードが非 0 で落ちた理由が、本当に昇格漏れかどうかまでは見ていません。**だからベースラインが exit 0 であることを先に確かめ、そこから 1 種類の変更だけを加えて差分を取っています。それでも「別の理由で落ちた」を「鳴った」と読む余地は残ります。
最悪の状態でも黙らないことも確かめました。上の修正前ガードの出力は 6/8 ですが、常に exit 0 を返すダミーのガードを渡すと NG: 8/8 種 を列挙して exit 1 を返します。「全部素通しの状態」で黙る監査は監査ではないので、そこは先に確かめてあります。
なお、この監査スクリプトの初版は台帳を直接読んで実装を推測する形で、台帳表が読めないときに「検査していません」と表示しながら exit 1 を返していました。出力と終了コードが食い違う点では次節の欠陥と同型ですが、向きは逆です。次節のほうは未検査・検出を合格側(exit 0)へ倒す形で、初版は失敗側へ倒す形でした。安全側とはいえ「未検査」と「検出」を区別できないので、独立レビューで指摘され、差分テスト方式へ作り直すときに解消しました。
5. なぜ沈黙していたのか:診断と判定が食い違っていた
以降、この節で「ガード」と書いたら 2fead50 時点の修正前の実装を指します。修正後の挙動は節の後半で対照します。
原因は判定器の中にありました。昇格漏れの検出はこう書かれていました。
if status == "promoted" or _norm(row.values.get("improvement_pr", "")):
promoted_modes.add(mode)
...
for mode, count in sorted(open_counts.items()):
if count < contract.promotion_threshold:
continue
if mode in promoted_modes: # ← ここ
continue
improvement_pr が埋まった行が 1 つでもあれば、その型は「対応済み」として以後の判定から外れます。過去の 1 回の改善が、その型の再発検出を恒久的に止めていました。
対照実験で確かめました。台帳のコピーに合成行を足し、片方は過去に改善 PR を持たない型(E4)、もう片方は持つ型(E3)を増やします。集計表の不一致で落ちると原因が混ざるので、集計表も同時に合わせてあります(第 4 節のスクリプトの inject() がやっているのがこれです)。
| ケース | 増やした型 | その型の未対応件数 | 期待 | 修正前の終了コード | 修正後 |
|---|---|---|---|---|---|
| 対照 | E4(改善 PR 無し) | 2 | 検出される | 1(ERROR 出力あり) | 1 |
| 実験 1 | E3(改善 PR あり) | 2 | 検出される? | 0 | 1 |
| 実験 2 | E3(改善 PR あり) | 4 | さすがに検出される? | 0 | 1 |
件数を 4 件まで増やしても鳴りませんでした(社内データ)。閾値は 2 件です。
そして実験 2 の標準出力がこれです。
昇格漏れ検査: status:open 7 件 / escape_mode 4 種を集計しました(閾値: 同一 escape_mode 2 件以上)
E3: open 4 件 → 昇格が必要
E4: open 1 件 → 閾値未満(昇格しない)
E7: open 1 件 → 閾値未満(昇格しない)
E8: open 1 件 → 閾値未満(昇格しない)
E3: open 4 件 → 昇格が必要 と表示したうえで、最後に failure ledger check passed. と出して exit 0 を返していました。
人間向けの診断は正しく、CI が見る合否だけが間違っていました。 これは「検査が走らない」より厄介です。ログを読んだ人は「昇格が必要と出ているから、誰かが対応するのだろう」と思い、CI は緑なので誰も対応しません。同じ構造の壊れ方は 効いていないガードを見つける型 でも扱いましたが、診断だけが正しいケースが一番見つけにくい形です。
原因は実装の意図にあります。「同じ型で何度も改善候補を起票しない」ためのガードだったのが、「その型ではもう起票しない」に化けていました。抑制のスコープを型全体ではなく未対応行の集合に閉じるべきでした。
修正して、同じ実験をやり直す
この欠陥は 2fead50 の翌日に修正されました(PR #473)。直し方は「抑制条件を精密にする」ではなく、抑制そのものをやめるというものです。
考え方はこうです。改善 PR が付いていることは「その行が改善された」を意味するのであって、「その型が今後すべて解決した」を意味しません。契約側の昇格条件はもともと「未対応の行数」だけで判定できる形になっていて、昇格した行は未対応から抜けます。**未対応のまま残っている行は、定義上どの改善にもカバーされていません。**だから他の状態の行を抑制に使う理由がありません。
「未対応のまま残すが対策はしない」と決めた場合の表明手段は、台帳の状態値(wont_fix。理由を同じ行に書く)として最初から用意されていました。ガード側で黙らせる必要はなかった、というのが結論です。
修正後の同じ差分テストです。台帳もスクリプトも変えず、ガードだけを差し替えています。
$ python3 audit_promotion_silence.py spec scripts/check_failure_ledger.py
ベースライン: exit 0 / 台帳に出現した escape_mode 8 種(契約の値域とは別)
E1: 未対応を +2 件積んだ時点で exit 非 0 → 鳴る
E2: 未対応を +2 件積んだ時点で exit 非 0 → 鳴る
E3: 未対応を +1 件積んだ時点で exit 非 0 → 鳴る
E4: 未対応を +1 件積んだ時点で exit 非 0 → 鳴る
E5: 未対応を +2 件積んだ時点で exit 非 0 → 鳴る
E6: 未対応を +2 件積んだ時点で exit 非 0 → 鳴る
E7: 未対応を +1 件積んだ時点で exit 非 0 → 鳴る
E8: 未対応を +1 件積んだ時点で exit 非 0 → 鳴る
OK: 8 種すべてで昇格漏れが検出されました
$ echo $?
0
沈黙 6/8 → 0/8 です(社内データ)。第 5 節冒頭の対照実験 3 ケースも、修正後は 3 件とも exit 1 になります。
この「壊した入力を修正前のガードに与えて素通りを再現し、修正後に落ちることを確かめる」は、本リポジトリの契約が deterministic guard の改善に要求している検証そのものです。**再現できない素通りは「直したつもり」**なので、直す前に必ず再現側から測ります。
6. 入口の穴:判定器は台帳の外を見ていない
もうひとつの穴は、もっと構造的です。
判定器が実行中に開くファイルを、Python の audit hook で全部記録しました。任意のスクリプトに使える 40 行強で、probe_inputs.py として保存します。
#!/usr/bin/env python3
"""判定器が実際に読むファイルを列挙する(入力集合の実測)。
usage: probe_inputs.py <判定器のパス> [判定器へ渡す引数...]
"""
import runpy
import sys
from pathlib import Path
if len(sys.argv) < 2:
print("usage: probe_inputs.py
