AIに実装させるなら、ガード自身をテストしないと意味がない
AIエージェントに実装を任せて、その成果物を本番に出す運用を続けている(記録が残っているのは2026年7月からで、12週間ぶん)。
最初に置いたのはガード(出荷前の自動検査)だった。それは正しかったと思う。ただ、ガードを置いただけでは足りなかった。ガードは腐る。そして腐ったことに誰も気づかない。
この記事は、腐り方を3つに分類して、3つ目——「ガードが壊れても気づかない」——に対して自己検査を義務づけた話。実際にそれが効いた日のログも最後に載せる。
この記事の数字の出どころは、当社サイトに置いた元データだ(配信の通し201回を全部数えた記録・期間と数え方つき)。引用するときは、こちらを出典にしてほしい。
https://braincommunity.jp/jissoku/mihari.html
出発点:ファイルの形は完璧だったのに、全滅していた
ある日、85ページを一斉に更新した。ビルドは通り、生成されたHTMLの構造も正しく、レビューでも異常はなかった。
翌朝、人が触って気づいた。全ページの計算ボタンが、押しても何も起きない。
原因は単純で、テンプレート内のプレースホルダ __CALC_ENDPOINT__ が置換されないまま出荷されていた。置換スクリプトは存在していたが、その日使った経路からは呼ばれていなかった。
重要なのはここだ。ファイルを読む検査は、全部通っていた。 HTMLとしては完全に正しい。ボタンも存在する。イベントハンドラも付いている。送信先の文字列が __CALC_ENDPOINT__ だっただけ。
読むだけの検査では、これは捕まらない。
腐り方1:ガードの無い入口が残る
最初にやったのは、ビルド〜デプロイのスクリプトにガードを差し込むことだった。
ところが、デプロイの入口が3つあった。手動用のスクリプト、自動化パイプライン、そして誰も使わなくなった古いスクリプト。ガードを入れたのは前の2つだけ。3つ目は「もう使っていないから」と放置された。
放置されたスクリプトは、パスが古くて動かない状態だった。だから安全に見えた。だが、動かないスクリプトは「直せば動く」ということでもある。 親切な誰かがパスを直した瞬間、全ガードを迂回する経路が復活する。
対処は削除ではなく、明示的な閉鎖にした。
# deploy.ps1 — 閉鎖済み。この入口は使わない。
#
# 閉じた理由:
# ビルドとデプロイを、ガードを1つも通さずに実行していた。
# 静的検収なし、エンドポイント注入なし、実ブラウザでの動作確認なし。
# パスを「直す」と、全ガードを迂回する経路が復活する。
# 壊れた第3の入口は無害ではない。親切な人が直すのを待っている迂回路である。
#
# 正しい入口:
# 手動 : .\publish.ps1
# 自動 : <pipeline> _deploy.yaml
# 検査のみ: <pipeline> _predeploy_check.yaml
Write-Host "[STOP] この入口は閉鎖されています。" -ForegroundColor Red
exit 1
ファイルを消さずに残したのは、消すと「なぜ無いのか」が伝わらないから。閉じた理由をコードの隣に置いておくと、次に触る人(や次のAI)が同じ穴を掘り直さない。
腐り方2:ガードが読むだけで、押さない
冒頭の障害がまさにこれだった。出荷物のファイルを読む検査は全部通っていた。
そこで、本当にボタンを押す検査を足した。ヘッドレスブラウザでビルド済みの成果物を開き、UIを操作し、結果テーブルが描画されることを要求する。デプロイ前に1回、デプロイ後の本番に対してもう1回。
# 押してみる。読むだけの検査とは別物。
page.goto(target)
page.select_option("#engine", "gpt") # 選ぶ
page.click("#calc") # 押す
page.wait_for_selector("#result tbody tr", timeout=10_000) # 描かれたか
rows = page.eval_on_selector_all("#result tbody tr", "els => els.length")
assert rows > 0, "押したが結果が描かれない"
副作用のある送信(顧客台帳への書き込みなど)は、検査中だけ無効化する。「押したら本当に書き込まれた」まで確認したくなるが、そこは別の話にした。検査が副作用を持つと、検査を回すのが怖くなり、回さなくなる。
腐り方3:ガードが壊れても、誰も気づかない ← 本題
ここからが本題。
ガードはコードだ。コードである以上、バグる。条件式の向きを間違えれば、何も検出しないガードができあがる。そしてそのガードは、毎回「合格」と表示し続ける。
これが一番たちが悪い。ガードが無いよりも悪い。無ければ「検査していない」と自覚できるが、壊れたガードは「検査した」という嘘の安心を配り続ける。
linterは誰がテストするのか、という問題である。
3点セット:素 / 故意の違反 / 戻して
そこで、すべてのガードに自己検査を義務づけた。自己検査は3つの状態を順に作り、それぞれの終了コードを要求する。
| 状態 | 作り方 | 期待 |
|---|---|---|
| 素 | 違反のない入力を合成する | rc=0(合格する) |
| 故意の違反 | ルールを1つだけ壊す。ルールの数だけ繰り返す | rc=1(必ず落ちる) |
| 戻して | 壊した箇所を元に戻す | rc=0(また合格する) |
def check(label, rc, out, want_rc, want_text=None):
ok = (rc == want_rc)
if ok and want_text:
ok = want_text in out # 落ちた理由まで一致すること
print(" %-28s rc=%d 期待=%d %s" % (label, rc, want_rc, "OK" if ok else "NG"))
if not ok and want_text and want_text not in out:
print(" 期待した理由が出ていません: %s" % want_text)
for line in out.strip().splitlines():
print(" " + line)
return ok
results = []
seed = make_clean_fixture(tmp) # 素
results.append(check("素", *run(seed), 0, "[OK] 合格"))
for name, breaker, reason in VIOLATIONS: # 故意の違反(ルールの数だけ)
broken = breaker(copy_of(seed))
results.append(check(name, *run(broken), 1, reason))
results.append(check("戻して", *run(seed), 0, "[OK] 合格")) # 戻して
if all(results):
print("[OK] 自己検査 合格(%d項目)" % len(results))
sys.exit(0)
print("[FAIL] 自己検査 不合格(%d/%d)" % (sum(results), len(results)))
sys.exit(1)
設計上のポイントが3つある。
(1) 「戻して」が要る理由
「素」と「故意の違反」だけだと、何にでも落ちるガードが通ってしまう。常に rc=1 を返すガードは、全部の違反ケースを「検出」する。最後にもう一度クリーンな入力を流して rc=0 を要求すると、これが死ぬ。
(2) 終了コードだけでなく、落ちた理由まで照合する
want_text を必須にしているのがこれ。rc=1 は「何かで落ちた」としか言っていない。違反Aを仕込んだのに、無関係な違反Bの検出で落ちているケースが実在する。落ちた理由の文言まで一致を要求して初めて、「このルールが効いている」と言える。
(3) 期待値の正本を二重に書かない
自己検査が使う「合格すべき設定一覧」は、ガード本体の定数を読みに行く。自己検査側にコピーを持たせると、片方だけ更新されて破綻する。
# 正本は guard.py の ALLOWED。ここに並べ直さない。
src = (HERE / "guard.py").read_text(encoding="utf-8")
m = re.search(r"^ALLOWED\s*=\s*\((.*?)\)", src, re.S | re.M)
if not m:
raise SystemExit("[FAIL] guard.py の ALLOWED が読めません=種を作れないので止めます")
ALLOWED = re.findall(r'"([^"]+)"', m.group(1))
実際にこれが効いた日
つい先日、この自己検査がデプロイを止めた。設定ファイルの許可リストに3項目足しただけの、何の変哲もない変更だった。
止まった内容がこれ。
素(開ける所だけ開いている) rc=0 期待=0 OK
違反1 個社面が塞がれていない rc=1 期待=1 OK
違反2 全部開けている rc=1 期待=1 NG
期待した理由が出ていません: が広すぎます
違反3 何も開けていない rc=1 期待=1 OK
(以下すべて OK)
戻して(素に復帰) rc=0 期待=0 OK
[FAIL] 自己検査 不合格(16/17)
終了コードは全部合っている。rc=1 期待=1。ガードの判定ロジックは正しく動いている。落ちているのは文言照合だけ。
原因はこれだった。
for m in ng[:12]: # ← [FAIL] の印字は12行で打ち切り
print("[FAIL] %s" % m)
if len(ng) > 12:
print("[FAIL] ... 残り %d 件" % (len(ng) - 12))
許可リストを10項目から13項目に増やしたため、「違反2(全部開けている)」のケースで13件の「〜を開けていません」が12行の窓を埋め尽くし、本命の「Allow が広すぎます」が ... 残り 2 件 の向こうに押し出されていた。
直し方は、重い違反を先に積むだけだった。
# 重い違反(全部開く/塞ぐべき場所を開ける)を先に積む。
# 印字は12行で打ち切るので、許可リストが増えると
# 「開けていません」が窓を埋め、重い違反が「残り N 件」に隠れる。
for a in allows:
if a in ("", "/"):
ng.append("Allow: %r が広すぎます(全部開きます)" % a)
continue
...
for o in ALLOWED: # 情報量の低いものは後ろへ
if o not in allows:
ng.append("%s を開けていません" % o)
ここから得た教訓
ガードの出力は、人間向けの体裁ではなく、機械が読む契約である。
ng[:12] の打ち切りは、ログを読みやすくするための親切心で入っていたはずだ。人間が読む限り、それは正しい判断だった。だが自己検査が文言を照合し始めた瞬間、印字順と印字件数が仕様になった。
もしこの自己検査が無ければ、どうなっていたか。ガード本体は正常なので、デプロイは通っていた。そして「重い違反が表示から消える」という性質を抱えたまま運用が続き、本当に Allow: / を書いてしまった日に、ログにその行は出ない。... 残り 2 件 とだけ出る。
自己検査は、まだ起きていない障害の予兆を捕まえたことになる。
実際に何回止まったか
この仕組みで配信が何回止まったかを、記録から全部数えた。人の指示でAIが起動した配信 201回(2026-07-21〜09-22)のうち、48回が本番の前に止まった。止めたのは、出荷物の欠陥を見つけた見張りが23回、見張り自身の自己検査が14回、見張りの外(本番へ上げる所)が11回。上の2回(2026-09-22 03:24 と 03:30)も、この14回に入っている。
止めた段20種の内訳と、数え方・この数字で言えないことは、元データの方にだけ載せている。
→ https://braincommunity.jp/jissoku/mihari.html
4つの規約
ここまでの話を、AIに実装を任せるときの規約として4つにまとめている。
1. 実行前の静的検収
AIが書いたものは、動かす前に機械が読む。人のレビューは静的検収の代わりにならない(人は85ファイルを1行ずつ読まない)。
2. 失敗時の完全停止
ガードが落ちたら、AIに自己修復させない。止める。「直してから再実行」を人が判断する。自律修復を許すと、ガードを通すこと自体が目的化して、検査を回避する方向に最適化される。
3. 全実行の記録
どの入口から、何を、いつ出荷したか。全部残す。再現できない出荷は、事故のとき原因を特定できない。
4. 仕様と実装の同時改訂
片方だけ変えることを許さない。今回の変更でも、許可リストの定数と設定ファイルの両方を同時に直さないとガードが落ちるようにしてある。「設定だけ直して記録を忘れる」が構造的に起きない。
まとめ
- AIに実装させるなら、出荷前のガードは必須。ただしガードは腐る
- 腐り方は3つ。ガードの無い入口が残る/読むだけで押さない/ガードが壊れても気づかない
- 3つ目に対しては、ガード自身に自己検査を義務づける。素=0 / 故意の違反=1 / 戻して=0 の3点セット
- 終了コードだけでなく落ちた理由の文言まで照合する。そうしないと「何にでも落ちるガード」と「無関係な理由で落ちているガード」を見逃す
- 自己検査の期待値は、ガード本体の定数を読みに行く。正本を二重に書かない
- ガードの出力は機械が読む契約になる。印字順や件数の打ち切りが仕様になることを忘れない
ガードを書いた人は、そのガードが本当に落ちることを確かめただろうか。私は確かめていなかった。確かめる仕組みを入れたら、入れた分だけ止まってくれるようになった。
止まるのは、面倒だが、ありがたい。
元データと引用のしかた
見張りが配信を止めた回数・止めた段の全部・数え方・この数字で言えないことは、当社サイトに置いている。数字を引用するときは、記事ではなく元データを出典にしてほしい。そのまま貼れる形はこうだ。
出典:ブレインコミュニティ合同会社「出荷前の見張りは、201回の配信のうち48回を止めた」
https://braincommunity.jp/jissoku/mihari.html (2026-09-23 測定)
当社が自分で測ったほかの数字も、同じ置き場にまとめている。
→ https://braincommunity.jp/jissoku/
業務システムの内製化と、AI活用の受託開発をしています。
ブレインコミュニティ合同会社 — https://braincommunity.jp/