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 <script> [args...]", file=sys.stderr)
raise SystemExit(2)
target = Path(sys.argv[1]).resolve()
root = Path.cwd().resolve()
opened: list[str] = []
sys.addaudithook(
lambda event, args: opened.append(str(args[0]))
if event == "open" and isinstance(args[0], (str, bytes, Path))
else None
)
sys.argv = sys.argv[1:]
code = 0
try:
runpy.run_path(str(target), run_name="__main__")
except SystemExit as exc:
code = exc.code or 0
inside = sorted(
{
str(p.relative_to(root))
for p in (Path(o).resolve() for o in opened)
if p.is_file() and root in p.parents
}
)
print(f"\n[probe] exit={code} / リポジトリ内で読んだファイル {len(inside)} 件")
for path in inside:
print(" ", path)
raise SystemExit(code)
計測対象の終了コードをそのまま返すので、監査を挟んでも合否は変わりません。実行結果です。
$ python3 probe_inputs.py scripts/check_failure_ledger.py --require-rows
(判定器の通常出力は省略)
[probe] exit=0 / リポジトリ内で読んだファイル 3 件
scripts/check_failure_ledger.py
spec/article_failure_ledger.md
spec/article_retrospective_loop.md
3 件のうち 1 件はスクリプト自身なので、実質の入力は契約と台帳の 2 ファイルです(社内データ)。PR の差分も、レビュー結果も、修正コミットも見ていません。
つまりこうなります。**台帳に書かれなかった失敗は、台帳を数えても現れません。**閾値は永遠に満たされず、CI は緑のまま通ります。
契約側には「起票せずに直すことを禁止する」と書いてあります。しかし文章で禁止しただけで、機械はそれを検査していません。実際、この禁止は破られました(台帳 L-0013)。
さらに台帳の履歴を見ると、もっと強い証拠が出ます。
$ git log --format='%h %ad %s' --date=short -- spec/article_failure_ledger.md
0f60c93 2026-09-09 fix(guard): 昇格漏れ検出が過去の改善済み行で恒久的に沈黙する欠陥(H2)を修正 (#473)
2fead50 2026-09-08 fix(spec,article): 台帳への起票漏れを回収し、監査スクリプトの空振り合格を塞ぐ (#471)
ead2a08 2026-09-08 feat(harness): 記事制作の失敗を Harness 改善へ昇格させる閉ループを定義(Issue #425 PR-6) (#464)
観測 14 件に対して、書き込みは 3 回だけです(社内データ)。最初のコミットで 10 行がまとめて入っています。その 10 件の観測日は 2026-09-05 から 09-08 に散っており、10 行のうち 9 行は観測日より後にまとめて書かれました(残る 1 行はコミットと同日なので、その場で起票された可能性を排除できません)。
ここで 4 節の結果と繋がります。まとめ書きの時点で、改善 PR(#437 / #447 / #449 / #458 / #461)はすべて merge 済みでした。台帳が生まれたコミットの中身を、当時のガードと組み合わせて第 4 節の差分テストにかけると確かめられます。
$ WORK="$(mktemp -d)" && trap 'rm -rf "$WORK"' EXIT
$ mkdir "$WORK/spec"
$ git show ead2a08:spec/article_failure_ledger.md > "$WORK/spec/article_failure_ledger.md"
$ git show ead2a08:spec/article_retrospective_loop.md > "$WORK/spec/article_retrospective_loop.md"
$ git show 2fead50:scripts/check_failure_ledger.py > "$WORK/guard_before.py"
$ python3 audit_promotion_silence.py "$WORK/spec" "$WORK/guard_before.py"
ベースライン: exit 0 / 台帳に出現した escape_mode 8 種(契約の値域とは別)
...(型ごとの行は省略)
NG: 5/8 種の型が、未対応をいくら積んでも昇格漏れとして鳴りません: E1, E2, E3, E5, E6
つまり台帳は、生まれた瞬間に 5 種の型が沈黙した状態で始まっています。その日のうちに 6 種になりました。判定器は稼働初日から、8 種中 5 種について一度も鳴る可能性がありませんでした。
**この入口の穴は、第 5 節の修正では埋まりません。**修正されたのは「台帳に積まれた未対応を数え損ねる」経路であって、そもそも台帳に積まれない経路ではないからです。
「ガードを作った」と「ガードが効いている」の距離は、ここまで開きます。アラートの設計で発火条件を先に定義するのと同じで(Alerting on SLOs)、どの入力でこの判定が鳴るのかを、作った直後に 1 回試すべきでした。テストが本当に不具合を検出できるかを測る mutation testing(Stryker)と同じ発想を、判定器そのものに向ける必要があります。
7. rollback:改善を単独 PR に切り出す理由
昇格したあとの話です。
このリポジトリの契約では、台帳への 1 行追記は記事の PR に同梱してよいが、対策の実装は必ず単独 PR と決めています。運用上の綺麗さではありません。理由は 1 つです。
git revert <merge sha> 1 手で戻せることが、rollback 手段そのものだから。
記事や他の変更と混ざった PR は revert できません。すると「効かなかったら戻す」という規約は、書いてあっても実行できない文章になります。満たせない契約を書くことは、それ自体が失敗の型として台帳に記録されています(L-0003)。
そしてこの判定を merge の前段に置くには、ブランチ保護の required status checks に載せる必要があります(About protected branches)。CI で走るだけで必須チェックに入っていなければ、鳴っても止まりません。第 5 節の話は「鳴らない」でしたが、その手前に「鳴っても止めない」という別の穴があります。
戻す条件も、書いた時点で決めておきます。観測窓(次の記事 3 本、または 14 日の早いほう)で、同じ型が再発した / 無関係な PR を 2 回以上ブロックした / 検出 0 件のまま手順コストだけ増えた、のいずれかに当たれば revert します。そして根拠になった観測行は未対応に戻します。対策が失敗しただけで、失敗そのものが消えたわけではないからです。
第 5 節の修正も、この規約どおり単独の PR(#473)で入りました。記事や台帳の変更とは混ざっていないので、観測窓で効かないと分かれば git revert 1 手で戻せます。逆に言えば、この記事の PR には修正を含めていません。記事を revert したら直ったはずのガードまで戻る、という状態を作らないためです。
ここで 1 つ、閾値方式そのものの前提が見えます。「同じ型が 2 件たまったら着手する」という約束は、判定器が生きていることを前提にしています。第 5 節の欠陥が残っていた間、その約束は 8 種のうち 6 種で自動的には果たされませんでした。閾値を決めることと、閾値に達したことに気づけることは別の作業です。
閾値を下回るものを先回りで直さないのは、慎重さではなく過学習の防止です。1 回の偶発事象ごとにガードを足すと、ガード自体が保守できない量になります。この判断は Harness にも技術的負債がある で扱った「削る判断」と対になっています。
8. この設計で解けないこと
正直に書いておきます。
- 入口の穴は、台帳の内側を見るガードでは原理的に塞げません。 台帳に無い失敗は、台帳を数えても現れません。塞ぐなら PR の差分側(レビューの確定指摘、ガードの修正コミット)から台帳行の存在を要求する検査が要ります。第 5 節の欠陥を直しても、この穴は残ります。
- 差分テストは「鳴った理由」までは見ていません。 ガードが非 0 で落ちたことしか確かめていないので、別の理由で落ちたケースを「鳴った」と読む余地があります。ベースラインが
exit 0であることを先に確かめて、変更を 1 種類に絞ることでしか詰めていません。 - 起票の判断そのものは自動化していません。 「これは観測可能な事象か、主観評価か」の線引きは人間と AI の判断です。ここを機械化しようとすると、今度は分類器の誤りが新しい失敗の型になります。
- これは 1 リポジトリ・14 観測・4 日間の記録です。 閾値 2 件が適切かどうかは、この規模でしか検証していません。観測が数百件に増えたときに同じ閾値でよいかは分かりません。
- 政策判断の側は扱っていません。 権限や実行範囲をどう絞るかは エージェントの権限は影響範囲で設計する、宣言と実効の乖離は AIガバナンスが効かない4類型 の側の話です。
まとめ:次に取る 3 手
昇格ループを持っている、あるいはこれから作るなら、順番はこうです。
- 判定器を監査する。 第 4 節の差分テストを自分のガードに流します。必ず鳴るはずの入力で鳴らない型が 1 つでもあれば、そのループは「作ってある」だけです。実装を読んで納得するのではなく、入力を与えて終了コードを見てください。
- 入力集合を数える。 判定器が実際に開くファイルを記録します。台帳しか読んでいないなら、起票漏れは構造的に検出できません。それを分かったうえで運用するのと、気づかず CI 緑を信じるのとでは別物です。
- 起票と改善を分ける。 観測は台帳へ 1 行、改善は単独 PR。混ぜた瞬間に rollback 規約が空文になります。
昇格ループの価値は、自動化を増やすことではありません。「まだ人間と AI が毎回判断している場所」を、数えられる形で残すことです。数えられなければ、増やす判断も、削る判断もできません。
FAQ
AI の推論を自動化へ昇格させる、とは具体的に何をすることですか
毎回 AI に解かせている判断のうち、繰り返し同じ形で現れるものを、決定的なスクリプトと CI チェックへ置き換えることです。置き換えの判断材料として、失敗の観測を台帳に 1 行ずつ残し、同じ型が閾値件数たまったときだけ改善に着手します。
昇格の閾値はどう決めればよいですか
このリポジトリでは「同じ型の未対応が 2 件以上」にしています。1 件の偶発事象で仕組みを書き換えないための下限です。重要なのは値そのものより、列を数えるだけで判定できる形に分類軸を設計することです。主観的な分類だと機械が数えられず、判定を自動化できません。
昇格判定が沈黙していることは、どうすれば分かりますか
判定器に「必ず鳴るはずの入力」を与えて、終了コードを確かめます。このリポジトリでは、同じ型の未対応を閾値以上に増やした台帳のコピーを作って流しました。標準出力ではなく終了コードで判定してください。診断だけが正しく、終了コードが 0 のまま返る形が実際に起きています。
改善をなぜ同じ PR に入れてはいけないのですか
効かなかったときに git revert 1 手で戻せなくなるからです。記事や他の変更と混ざった PR は revert できないため、「観測窓で再発したら戻す」という規約が実行不能になります。満たせない規約を書くこと自体が、記録すべき失敗の型です。
台帳に何を書き、何を書かないのですか
書くのは観測可能な事象だけです。CI の失敗、レビューの確定指摘、公開後の誤り、手順の未実行。書かないのは「記事の出来がいまひとつだった」のような主観評価です。数えられないものを混ぜると、閾値判定が意味を失います。
