1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIに実装させるなら、ガード自身をテストしないと意味がない

1
Last updated at Posted at 2026-09-22

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/

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?