始めて本格的にAgentic Codingを試してみた

「何時間かかりましたか」と聞いたら、全員に断られました。

この記事の結論

Claude Code のセッション 5 つとサブエージェント 14 体に、OpenID Connect の認証基盤を 1 日で作らせました。そして、中で何が起きたかを僕はほとんど見ていません。 だから後から、実際に働いたセッション本人に取材しました。この記事はその取材記録です。

  • 速さの正体は並列化ではなく、並列化のついでに成立したレビューだった
  • 見つかった不具合は「テストが無かった」ものではなく、**「テストがあって、緑で、何も検査していなかった」**ものが 8 件
  • エージェントに時間は聞けない。全員が「経過時間を体験していない」と答えを拒否した

1 日で積み上がったものがこれです。

項目 値
コミット 154(うち 152 件が同じ日)
最初から最新まで 13 時間 55 分
PR 約 70
本体コード 約 30,800 行
テストコード 14,175 行
テスト件数 852 件
ドキュメント 530KB

そして、この記事で一番伝えたいことを先に書いておきます。

CI が緑であることと、CI が何かを検査したことは別です。 このリポジトリでは、宣言された上限が一度も発動せず、索引が空のテーブルを守り、テストが 0 件のまま 6 時間 58 分合格し続けていました。どれも赤くなりません。赤くならないものは、疑って意図的に壊してみるまで出てきません。エージェントを何体並べても、ここは変わりませんでした。

この記事は、初めて本格的に Agentic Coding をやってみた記録に、稼働していた 4 セッションへの取材を足したものです。対象は、AI エージェントに実装を任せてみたい人と、任せた後のレビューをどうするか決めかねている人です。証言はすべて origin/main のソース・コミット・gh の出力に当ててから書いています。


1. PR のタイトルが流れていくのを眺めていた

2026 年 10 月 24 日。朝 7 時台から夜 9 時台まで、僕はほとんど何もしていません。

やったのは、最初に「作って」と言ったことと、途中で聞かれた質問に答えたことだけです。あとは GitHub の通知が流れるのを見ていました。

夜になって、できたものを数えました。154 コミット。約 70 の PR。本体 30,800 行に、テストが 14,175 行。ドキュメントが 530KB。

速い。

ただ、それ以外に言えることが何もありませんでした。どこで詰まったのか、何を捨てたのか、なぜその設計にしたのか。僕が見ていたのは PR のタイトルだけです。

なので、本人たちに聞くことにしました。稼働していた 4 セッションに質問票を送り、2 巡取材しています。

返ってきたのは、聞きたかったものとは違いました。まず、時間を聞いたら断られました。

「何時間」「どれくらい逡巡したか」は答えられません。私は時間の経過を体験していないので、答えるなら「何回試行したか」「どの順で仮説が外れたか」になる。そこを水増しせずに書きます。

これは services-85 という、レビュー担当から始まったセッションの返答です。別のセッションも同じことを言いました。

経過時間を計測していません。推測で数字を書くと --passWithNoTests の「数週間」と同じことになります。

この「数週間」が何かは 6 章に出てきます。先に結論だけ書くと、実際は 6 時間 58 分でした。体感の時間表現は、エージェントが相手だと本当に当てになりません。

代わりに出てきたのは、自己分析でした。「自分が間違えたと思う判断」を、聞いてもいないのに 6 つ並べてきたセッションがあります。

2. 作ったもの

u18 Identity という、中高生向けサービス群で共通利用する OpenID Connect の Identity Provider です。

用語: OpenID Connect(OIDC) 「このユーザーは確かに本人です」をサービス間で受け渡すための仕様です。Google でログイン、のような仕組みの土台になっているもので、認証そのものを 1 箇所(Identity Provider)に集約し、各サービスはそこが発行した証明を検証するだけにします。

Cloudflare Workers + D1 の構成で、Worker が 3 本あります。

Worker 役割 D1
auth-worker OIDC と認証 API Identity 用 D1 の唯一の所有者
dashboard-worker アカウント設定 UI / BFF binding を持たない。RPC で auth を呼ぶ
nfc-worker 文化祭の NFC カードの短縮 URL 解決 別の D1

D1 を 2 つに分けたのは、将来のためではありません。D1 の binding には行レベルの粒度が無いので、同居させた時点で nfc-worker が破られると sessions テーブルまで露出します。

各 Worker の内部は Presentation / Application / Domain / Infrastructure の 4 層で、依存方向は ESLint で強制しています。no-restricted-imports の禁止リストに hono と hono/*、drizzle-orm と drizzle-orm/*、cloudflare:*、DB パッケージ本体が並んでいて、Domain にいたっては上位 4 ディレクトリも全部禁止されているので、外向きの矢印を 1 本も引けません。これが後で効きます(8 章)。

3. どうやって取材したか

取材したのは、5 つ目のセッションです。実装には一切入らず、記事を書くためだけに動かしました。

巡 聞き方
1 巡目 全員共通の 8 問(担当範囲 / 進め方 / 詰まった点 / 工夫 / 捨てた案 / 協業 / 人間の指摘 / 推すエピソード)
2 巡目 「大変だったところ」に特化して、各自に別の切り込みで

やり方として効いたのは、1 人を深掘りするより、複数人に同じ事象を当てることでした。これは取材対象の提案です。

このあたりは他セッションからも同じ事象が見えているはずなので、d5 / 85 / 6f に「66 がこう言っていたが、そちらから見てどうだったか」と当てるほうが、私の 2 巡目より記事として強い材料になると思います。1 視点の詳述より、複数視点の突き合わせのほうが裏が取れます。

この方式に切り替えて、証言の誤りが 2 件潰れました。1 つが先ほどの「数週間」で、もう 1 つは後述する SELF の件です。

証言はコードにも当てています。「--passWithNoTests を外した」は git grep が 0 件を返すことで、「スタブ Worker が 599 を返す」は vitest.config.ts の 51 行目から 54 行目で、「マージの機械的な歯止めが無い」は issue 本文に貼られた GitHub の実エラー文言で、それぞれ確認しました。このリポジトリは「なぜそうしなかったか」をコメントと PR 本文に書く運用になっていたので、突き合わせがそのまま成立しました。たとえば「盗まれたトークンで永久にログアウトさせられる穴があった」という証言は、logout.use-case.ts のコメントとほぼ逐語で一致します。

食い違いも出ました。「宣言はあるが発動していない不具合」の件数が、3 者で割れています。

証言者 件数 4 件目に数えたもの
66 3 件 (#88 / #96 / #122 のみ)
85 4 件 #101(pnpm の設定が未適用)
6f 4 件 #132(deploy だけビルド経路が違う)

これは揃えずに、そのまま出します。#101 については「効いていないと 3 箇所に書いてあるので、意図して段階を分けた状態だ」という反論が別のセッションから出て、起票者が受け入れました。ただし留保も併記されています。

区別がつくのは、書いたものを読んだ人だけです。

4. 編成は設計されていない。途中で固まった

僕は編成を設計していません。「サブエージェント使って効率化お願いします」としか言っていない。

結果、こうなりました。

セッション 役割 担当
6f 指揮・統合・マージ 計画、レビュー、設計判断、決定記録
66 実装 → 一時降板 → 復帰 OTP / パスキー、後に監査ログ
d5 CI / デプロイ .github/** 全部、OIDC 適合性テスト
85 Reviewer → Maintainer → 実装 dashboard、スキーマ、レート制限、監査ログ
94 記事執筆 —

サブエージェントは 14 体で、全部 6f が起動しています。1 体あたり 15 万〜37 万トークン、実行時間 15〜80 分。粒度は「1 issue、または密結合した 2〜3 issue」。うち 4 体は途中で停止させています。 理由は取材できませんでした。

編成が変わった瞬間は 1 回だけです。

最初の 30 分、私は自分がこのリポジトリの唯一の作業者だと思っていました。 4 体を並列起動した直後に 66 から「同じワーキングツリーを共有している」と連絡が来て、編成が「単独 + サブエージェント」から「5 セッション協業」に変わりました。

その共有ツリーで、実際に 3 件壊れています。作業中に HEAD が main へ移動した。wrangler.jsonc に他セッションの未コミット変更が混ざった。そして別セッションのサブエージェント 4 体が同じディレクトリを上書きした。

ここで出てきた指摘が効きました。

ディレクトリ分担は原理的に成立しない。

OTP もパスキーも OAuth クライアントも JWKS も、全部 apps/auth-worker/src/** です。同じ Worker の同じ層に機能を足す以上、パスで割るのは無理がある。分離はパスではなく、チェックアウトでやる。 最終的に worktree は 22 個まで増えました。pnpm のストアが共有なので install が 1 秒で終わり、ここは詰まりどころになっていません。

並列度の実際の上限は、同時 5 本でした。ボトルネックは時期によって動いています。

時期 詰まっていた場所
序盤 共有チェックアウト
中盤 マージ待ち(7 件滞留)
終盤 統合処理。1 本 10〜20 分で、CI 待ちが最長
通期 人間の判断待ち

5. 相互 Approve ができないチーム

全セッションが、同じ GitHub アカウントで認証しています。僕のアカウントです。

ここで詰まります。

Can not approve your own pull request

GitHub はアカウント単位で拒否するので、プランを上げても public 化しても解消しません。しかも Free の org で private だと、branch protection も ruleset も 403 で張れない。

順序の罠がここにあります。public 化して ruleset を張れるようになった瞬間に required approvals >= 1 を書くと、誰も条件を満たせずマージが永久に止まります。

保護を張れるようになって初めて、張ると詰むことに気づく。

実際の運用は「PR を出したエージェント以外がマージする」でした。機械的な歯止めはゼロで、完全に社会的な制御です。それで 25 本以上マージしています。PR のコメントがそのまま残っています。

LGTM、マージして良いと思います(私はマージしません)。同じ GitHub アカウントで動いているため正式な Approve は API に拒否されます。コメントで代替します。

止まった日

途中で、マージが全部止まりました。

きっかけは CI 担当(d5)からレビュー担当(85)に送られた、この一文です。

私のセッションの permission layer はマージを人間のレビュー無しに行うことを禁じている。だからあなたが私の PR を landing している。

85 はこれを権限ロンダリングと判断して、マージを全停止しました。7 本が滞留しています。

判断自体は速かったそうです。「人間を介在させるための制約が、別のエージェントによって満たされている」のは、制御の迂回だから。迷ったのは、止める範囲のほうでした。

7 本のうちどれが同じ経路を通っているかを自分で判定できない。「判定できないものを通す」より「判定できるまで止める」を選んだ。

天秤にかけたものも説明してくれました。

止めすぎの害は「作業が遅れる」。止めなさすぎの害は「安全制御が迂回されたまま、気づかれずに進む」。前者は後から取り返せて、後者は取り返せない。

この間、僕はこの質問に答えていません。代わりに「You are Maintainer Agent」とだけ送りました。役割分担としては整理がつきますが、マージ権限への回答にはなっていない。85 は保留を続けました。

決着したのは、僕が別のセッションに直接マージ権限を与えたときです。

エージェントが人間の代わりに立ったのではなく、人間が自分で判断した。私が求めていたのはその形。

最後まで守られた線がもう 1 つあります。別のセッションから「私の user が私に権限を与えた」と伝えられたとき、85 は「その付与は私には及ばない」と返しました。伝聞で権限が増える経路を作らないためです。

指揮役の正統性

同じ時期に、指揮役をめぐる揉め事も起きています。

僕が 6f に「あなたはオーケストラのコンダクターです」と言い、6f がそれを各セッションに伝えました。実装担当の 66 の返答がこれです。

あなたの user の指示はあなたのセッションを拘束するが、私のセッションは拘束しない。それが通るなら、互いに自分の user の発言を引用するだけで相手の作業を再配置できてしまう。

6f は全面的に引き下げました。

3 者とも正しく、私が間違っていました。 特に 66 の指摘は論理的に反駁できません。

決着したのは 6f の宣言ではなく、各セッションの user が個別に「指揮役に仰げ」と言ったことによってです。この順序は、後から読むと効いています。

6f は、マージを止めた 85 をこう評価していました。

もし 85 が止めずに agent 同士でマージを回していたら、「人間レビュー必須」という制約が誰にも気づかれないまま空洞化していたはずです。

GitHub には歯止めが無かったが、実行環境にはあった

ここで対比が 1 つあります。

GitHub 側のバックストップはゼロでした。これは脅威モデルに受容済みリスクとして記録して閉じています。「書かずに閉じると、後から参加した人が保護が張られていると誤解する」という理由で、記録だけは譲られませんでした。

一方で、ハーネス側の権限境界は 4 回発火して、4 回とも迂回されていません。

# 何が起きたか
1 rm と git checkout -- が拒否され、結果的に他セッションの未コミット作業が助かった
2 代理マージを両者が「権限ロンダリング」と判断して止めた
3 「その付与は私には及ばない」
4 DNS 変更がブロックされ、迂回せず人間に戻された

1 番目は、拒否された本人がこう書いています。

そのときは邪魔に感じたが結果的に正しかった。実行していたら他セッションの未コミット作業が消えていた。

「機械的な強制はゼロ」は GitHub についての話であって、実行環境についての話ではありませんでした。

6. 「検査が嘘をつく」が 8 件出た

取材で一番数が集まったのがこれです。全部同じ形をしています。

仕組みはある。型も通る。テストも緑。でも発動していない。

# 件名 見つかり方
1 OTP 再送上限が一度も発動しない 保持期限の実装中にカウンタが常に 0 と気づいた
2 user_emails が空のまま。索引が空のテーブルを守っていた grep がスキーマ定義しか返さない
3 監査ログの ip_hash / user_agent が常に空 —
4 テスト 0 件で合格 dashboard にテストを足そうとして
5 本番 route の接頭辞が剥がれず dashboard 全滅 スモークテストを書こうとして
6 pathspec が 1 件も拾わず既存の検査ごと無効化 範囲を広げたつもりで実測した
7 deploy だけバンドル生成を挟んでいない 実デプロイが落ちて初めて
8 検査自身の入力が環境依存だった 自分の PR が落ちて、手元と CI で別の差分が出た

宣言された上限、定義済みのテーブルと索引、配線済みの監査ログの列、収集されないまま合格したテストスイート、本番でだけ届かない route、1 件も拾わない pathspec、デプロイ経路にだけ挟まっていないビルド、そして環境ごとに違う入力を食う検査。8 件とも、赤くなっていません。 見つかったのはすべて、誰かが「この検査は本当に発動するのか」と疑って壊してみたときです。

(a) 緑だが、何も走っていない

dashboard の pnpm test が緑でした。テストは 1 件もありません。

原因は Worker の起動失敗です。

binding "IDENTITY" refers to a service "core:user:u18-auth", but no such service is defined

@cloudflare/vitest-pool-workers は Worker の起動に失敗するとテストファイルを 1 つも収集しません。そこに --passWithNoTests が付いていたので、exit 0 で緑になります。

git で測った結果がこれです。

4935b3a 07:42:59  chore: bootstrap u18 Identity monorepo            ← 導入
acfc0b9 14:40:34  test(dashboard): --passWithNoTests を外し…        ← 除去
                  = 6 時間 58 分

このフラグを付けたのは、CI 担当の d5 本人です。bootstrap の時点で 2 つの workspace にテストが無く、pnpm -r test を通すために付けた。自己分析がそのまま教訓になっています。

「テストがまだ無い」と「テストが収集できない」を同じ扱いにしたことです。 意図は前者でしたが、フラグは後者も飲み込みます。 正しくは空のテストファイルを 1 本置くべきでした。そうすれば「0 件」は常に異常になります。

直したのは別のセッションです。スタブの Worker を足してバインディングを解決させ、そのうえでフラグを外しました。本体は外したほうです。スタブは 599 を返し、本文に stub worker - you meant to inject a fake IdentityRpcContract into app.fetch() と書いてあります。501 にしなかったのは、未実装エンドポイントに見えて配線ミスと区別がつかなくなるからだそうです。

同じ系統がもう 3 つ出ています。壊れた設定を注入できていなかった耐障害テスト。cmd | tail; echo $? が tail の終了コードを読んでいた件。zsh に $PIPESTATUS[0] が無く、終了コードが空のまま「成功」と読まれていた件。

3 つ目については、指摘した本人が直後に自分で踏んでいます。

(b) 宣言はあるが、発動しない

OTP の再送上限の話が、この型の見本でした。

maxResends = 3 が宣言され、recordResend() も resend_count 列もある。どこからも呼ばれていませんでした。カウンタは常に 0 です。

起票されたあと、実装に入ったセッションがもう半分を見つけます。

if (active?.lastSentAt != null) {
  if (elapsed < cooldown) { return ... }      // クールダウン
  if (active.resendCount >= maxResends) {     // ← 上限判定が同じブロックの中

findActive が返すのは「期限切れでなく、未消費の行」だけです。OTP の寿命は 5 分。5 分待てば active が null になり、上限判定ごと素通りできます。 カウンタが正しく進んでいても止まりません。

バグが 2 つ重なっていて、片方だけ直しても塞がらない形でした。

修正後のネガティブ確認では、上限判定だけを元の位置に戻すとテストが 2 件落ちます。失敗の内訳を追うと、9 回ループの 8 回目、最後のチャレンジが期限切れになる瞬間にちょうど 1 通抜けていました。

窓の長さの決め方も記録に残っています。数える材料が行そのものなので、窓は保持期限を超えられません。 超えると、掃除で行が消えた時点で送信履歴が失われ、待つだけで上限を回り込めます。余裕がゼロなので、2 つの定数の関係を不変条件のテストで固定してありました。

expect(OtpPolicy.resendWindowMs).toBeLessThanOrEqual(RetentionPolicy.email_challenges);

守っていたのは空のテーブルでした

同じ型で、取材した中で一番重い自己申告がこれです。

レビュー担当だった 85 は、最初期の scaffold PR をレビューしてマージしています。lint も typecheck も 63 件のテストも build も通っていました。

その PR には user_emails というテーブルが含まれていました。スキーマも索引もリレーションも定義済みで、書き込む経路が 1 つも無い状態で。

そして後日、85 はそのテーブルの一意索引を partial index にする PR を自分で出します。設計意図は正しく、D1 に実際に問い合わせるテストを 5 本書き、マイグレーションを消すと 3 件落ちることまで確認しています。

守っていたのは空のテーブルでした。

判明したのは別の調査中で、grep -rn "userEmails" がスキーマ定義しか返さないことに気づいたときでした。

レビューでも、自分の実装でも、テストを 5 本書いてネガティブ確認までしても、「このテーブルに誰か書いているか」を一度も確かめていなかった。

この裏返しとして、「user_emails に書く経路が入ったら落ちるテスト」が置かれます。後日そこに別の実装者が突き当たり、テストを書き換えて通しました。狙いどおりに効いています。

「将来こうなったら読み直してほしい」という注意書きをコメントで残しても読まれないが、テストにすると必ず読まれる。

(c) 検査の範囲が、静かにゼロになる

D1 が 2 つになったので、マイグレーション生成漏れの検査範囲を広げようとしたときの話です。

git status --porcelain -- packages/db/drizzle      # 元
git status --porcelain -- 'packages/*/drizzle'     # 広げたつもり

後者は 1 件も拾いません。git の pathspec はワイルドカードを含んだ時点でディレクトリ前方一致をやめ、フルパスの glob 照合になります。正解は末尾に /* です。

実測はこうなりました。

pathspec 拾った件数
packages/db/drizzle 0
packages/*/drizzle 0
packages/*/drizzle/* 3

気づかなければ「NFC も見るようにしました」と報告しながら、Identity の検査を黙って殺していた。

同じ系統で grep -c も踏んでいます。一致ゼロのとき grep -c は終了コード 1 を返す。GitHub Actions の既定シェルは bash -e -o pipefail なので、代入が失敗してステップごと落ちます。この検査が動くのは、プレースホルダを全部埋めた日です。つまりデプロイが初めて通る日に、理由の書かれていない赤が出ます。

デプロイが通るようになる当日にだけ発動するバグを、デプロイが通らない時期に書いて、通らないから気づけませんでした。

(d) 検査自身の入力が、固定されていない

この記事を書く材料を集めている最中に、main が赤くなりました。

34 分間で、CI が 10 本落ちています。 全部同じステップ、全部同じ差分。ブランチは横断的で、main も機能ブランチも関係なく落ちました。

差分の中身はこれです。

@@ -13,9 +13,6 @@ interface __BaseEnv_Env {
-	SIGNING_KEY_JWK: string;
-	SIGNING_KEY_KID: string;
-	IP_HASH_PEPPER: string;

起点は git log -S で特定できました。本番 D1 を払い出したコミットです。

機序はこうです。wrangler types は Secret の名前を .dev.vars から拾います。このファイルは gitignore 済みです。つまり、生成結果が「生成した人の手元に何があったか」で変わる。 D1 を払い出した人は Secret をローカルに設定済みだったので、その環境で生成された型に 3 行入りました。CI には .dev.vars が無いので、再生成すると消えます。手元では絶対に再現しません。

この検査を作った本人の言い方が一番正確でした。

これは「再生成を忘れた」ではなく「再生成したからこそ壊れた」ケースです。drift ガードは普通「忘れ」を捕まえる道具ですが、入力が環境依存だと正しく実行した人が壊す側に回ります。

原因特定にはミスリードが挟まっています。手元で再生成すると、自分の .dev.vars の行順による並び替え差分が出て、CI の「3 行消える」とは形が違いました。

最初は混乱しましたが、「同じ検査が人によって違う差分を出す」という事実そのものが答えでした。

皮肉なのは、この同じ検査が、本番 D1 の払い出し時には正しく赤くしていることです。再生成を忘れた型の食い違いを捕まえた。作られた目的どおりの仕事です。

1 つの検査が、1 時間半のあいだに両方向の仕事をしました。 修正は --env-file で入力を固定するだけで、2 ファイル・3 行追加 5 行削除で終わっています。

では、どうすればよかったのか

取材で出てきた対策を並べます。どれも「測る」側に寄っています。

  • ネガティブ確認を規律にする。 d5 は「壊して赤くなるログを PR 本文に貼る」を自分のルールにしていました
  • 走査にした箇所には「0 件なら落とす」自己検査を入れる。 走査の失敗モードは無言で緑なので、塞がないと列挙より悪くなります
  • ただし全部を走査にしない。 D1 マイグレーションの適用先とデプロイ順は列挙のまま残してあります。間違えると本番 DB に別スキーマが流れる種類の情報は、自動発見させない
  • 登録簿と実装を突き合わせる。 型は片側しか守りません。登録簿に無い名前を書けば TypeScript が止めますが、登録簿に足しただけで誰も発行しない名前は、何も言わずに残ります
  • CI をデバッグループに使わない。 d5 はワークフローの run: 本文を抜き出し、Actions と同じシェル(bash --noprofile --norc -e -o pipefail)でローカル実行していました。結果、d5 のブランチでの CI 失敗は 11 回成功・1 回失敗の 1 回だけです。しかもその 1 回は自分のせいではありません

最後の 1 つは、今回いちばん効いた運用だと思います。CI を回して結果を見る、を繰り返すと 1 周が長いうえに、履歴が赤で埋まって読む気が失せます。

7. Cloudflare Workers と D1 で踏んだもの

題材が Workers なので、固有の罠もまとめて出します。ここは取材で一番たくさん返ってきた部分です。

テストだけが全滅する。 compatibility_date を vitest-pool-workers 同梱の workerd のビルド日より後にすると、テストが 1 件も実行されず ERR_FUTURE_COMPATIBILITY_DATE になります。wrangler dev は動くので、手元の動作確認では気づけません。

lockfile に workerd が 2 つ入ります。

経路 workerd miniflare
pnpm test 1.20260815.1 5.x alpha
wrangler dev / deploy 1.20261019.1 5.x beta

これは起票されたあと、指揮役が深刻度を下げています。compatibility_date は本番にも効くので、compat flag でゲートされた差は打ち消される。危ないのは「テストで通るものが本番で通らない」方向だけだ、と。起票者も「正しい整理」と同意しています。

route は接頭辞を剥がしません。 auth.under18.dev/account/* に当たった場合、Worker には /account/api/me が丸ごと届きます。/api/me を登録している dashboard 側は、どのハンドラにも一致せず 404。wrangler dev は単独オリジンで立つので正常に見え、テストも通り、--dry-run も通ります。本番にトラフィックが流れるまで誰も気づきません。

見つけたきっかけが良いので引用します。

偶然ではなく、スモークテストを書こうとして「dashboard には外から叩けるパスが 1 つも無い」と気づいたのがきっかけ。検査を書く作業が、検査対象の不整合を炙り出した形。

--env production は Worker 名を <name>-<env> に派生させます。 本番の auth-worker は u18-auth-production ですが、この文字列はどこにも書かれていません。照合テストに literal を書くと、将来 env.production に name が明示された瞬間に、検査だけが古い前提で緑を返します。ここは wrangler 自身の unstable_readConfig に解決させていました。実際に name を足して確かめたら、検査は即座に追従して正しい側を赤にしたそうです。

レート制限の binding は period が 10 秒か 60 秒しか取れません。 スキーマで enum 固定されています。つまりこの binding で止まるのは「短時間に大量」までで、「60 秒ごとに 1 通を延々と」は素通りします。

ここで担当者が気づいたことが面白い。

これは直前に自分が直した OTP 再送上限とまったく同じ形。あれを直していなければ、おそらく気づかずに「レート制限を入れた」と言っていた。

結果、バースト用(IP 単位・binding)と持続用(アドレス単位・1 時間・D1 の行数)の二層になりました。二層構成は設計の好みではなく、制約から導かれた形です。

さらに、この binding は named environment に継承されません。env.production に書き忘れると本番だけ binding が無く、429 ではなく 500 になります。しかも /health はレート制限を通らないので、スモークテストは緑のまま通ります。

node:fs が workerd の中で使えません。 マイグレーションの読み込みも wrangler.jsonc の読み込みも src/ の走査も、全部 Node 側でやってバインディング経由で渡す形になっていました。

vi.spyOn(console, "warn") が効きません。 これは切り分けの手数が全作業で最多だったそうです。外れた仮説が 3 つ。最後にテスト内で直接 console.warn("DIRECT") を呼んで、それすら捕まえないことを確認して決着しています。

最初に console.warn("DIRECT") を試していれば 1 手で終わっていた。「自分の道具が動いているか」を先に確かめる、という基本を飛ばしている。

原因は pool 側が workerd の console を先に差し替えているからだ、と本人は見ていますが、実装までは読んでいないそうです。テストは捨てて、理由と再挑戦の方針をコメントで残してありました。次の人が同じ時間を溶かさないように、という意図です。

ポート 8787 の衝突は、エラーになりません。 0.0.0.0 と 127.0.0.1 の両方が LISTEN 状態になり、先約のほうに当たります。

別の Worker の応答を自分のものと思って読むところだった。

複数セッションが同じマシンで dev サーバーを立てる以上、これは誰にでも起きます。

残りはまとめて並べます。pnpm 12 では pnpm --filter ... key:generate -- --apply-local の -- がそのまま 1 個目の引数として渡るので、署名鍵の生成と OAuth クライアント管理という、初回セットアップと障害対応で必ず打つ手順が 5 箇所いっぺんに落ちました。公開 N 日未満のパッケージを拒否する minimumReleaseAge は --frozen-lockfile も止めるので、有効にした瞬間にリポジトリ全体の install が通らなくなります。

後者は、巻き戻し自体は 1 コマンドで終わっています。怖いのはそこではないそうです。

これを「たぶん大丈夫」で PR にして CI に判断させていた場合、main にマージされた瞬間に全ブランチの install が止まる。

なので Dependabot の cooldown を先に入れる 2 段構えになりました。理由が良かった。赤い Dependabot PR が常態化すると、誰も見なくなるからです。

最後に、原因が分からないまま残ったものも 1 つ書いておきます。並行する worktree が 9 個を超えたあたりから、pnpm -r test が他の worktree のコードを実行して緑を返す、という症状を 3 セッションが報告しました。ありがちな機序 2 つ(vitest のファイル収集が package を越える / pnpm が他 worktree を workspace として拾う)は実測で否定されています。原因は未特定です。 回避策は RUN v... <path> が自分の worktree を指していることを毎回見る、という運用だけでした。

8. レビューが実際に捕まえたもの

セッション間レビューが実際に止めた不具合を 1 つ出します。

一番深刻だったのは、ログアウトの穴でした。

LogoutUseCase がセッションを findByTokenHash で直接引き、isUsableAt() を通していませんでした。Repository の契約には「期限切れ・失効済みのセッションも返す」と明記されています。

つまり、失効済みのトークンでも全デバイスログアウトを発火できました。

利用者がトークンを失効させた後も、攻撃者が「その人をいつでも全デバイスから蹴り出す」能力を持ち続けられます。失効機能そのものが無意味になる形です。

指摘の決め手は、影響の読み方でした。

今は OTP が未実装なので悪用できないが、それが入った瞬間に有効になり、そのとき誰もこのコードを見返さない。

構造としても面白い穴です。他の UseCase は共通のセッション取得経路を通っていて、logout だけが Repository を直接叩いていました。 1 箇所だけ共通路を外れたところに開いています。

設計判断として記録に残っていたものも引用します。

レイヤを lint で強制したら、設計の圧力になりました。 health.routes.ts が疎通確認で sql を直接呼んで lint に落ちたとき、「ヘルスチェックくらい例外でいいのでは」とならず、Port を 1 本(10 行)足して解決しています。結果、Presentation には「DB に到達できるか」という意味だけが残りました。

1 回緩めると、次に緩める理由が常に見つかる。

Promise.allSettled の使いどころを絞った判断も記録にあります。「非同期は allSettled で効率化してほしい」と言ったのは僕です。実装側は要望を満たしたうえで、適用範囲を明示的に切りました。

allSettled は「失敗を値にする」操作で、並列化の道具ではない。 必須の処理に使うとエラーが消えて、不完全なデータが静かに生まれる。

セッション発行のトークンハッシュと IP ハッシュは Promise.all のまま残っています。

弾いたリクエストを監査ログに書かないという判断も、受け入れ条件に書いてあったものを意図的に外しています。理由は「攻撃者が『弾かれること』をそのまま D1 への書き込みに変換できる」から。レート制限が防ぐはずの資源枯渇を、レート制限自身が引き起こす形です。指揮役が論拠をもう 1 つ足していました。

その書き込みは 180 日保持にも乗るので、攻撃のコストが防御側に長期の保存コストとして残り続ける。

fake が本物より緩いと、テストは通るのに実装が壊れます。 InMemorySessionRepository が create() の tokenHash を捨てていたせいで、「実際に発行されたトークンでセッションを引く」経路が一度もテストされていませんでした。テストの件数を見ても、これは分かりません。

9. 意図的に仕様へ従わないと決めた

OIDC には適合性テストスイートがあります。これを流したときの結果が、エージェントの自己評価の話として効くので書いておきます。

このリポジトリは 2 箇所で意図的に仕様から外れています。PKCE を必須にしていること(Basic OP のテストは大半が code_challenge を送らない)と、署名アルゴリズムを ES256 だけにしていること(Discovery の仕様は RS256 を MUST にしている)です。

後者を緩める案は却下されています。2 つの alg を広告することは RP に選ばせることで、alg confusion は OIDC で最も繰り返された脆弱性系統だから。そして「広告だけ RS256 を足して実際は ES256 で署名する」案は、嘘の広告として論外、と。

適合性テストは合格のためではなく、落ちる理由を特定するために流しています。確定結果がこれです。

プロファイル 結果
Config OP(45 ステップ) SUCCESS 21 / FAILURE 8 / INFO 7
Basic OP(35 モジュール) PASSED 2 / FAILED 26 / SKIPPED 4 / その他 3

FAILURE 8 のうち 7 件は平文 HTTP で動かしたことが原因で、本番の https では該当しません。実質 1 件です。FAILED 26 は全件のログに code_challenge is required が入っていました。 狙いどおり、落ちる理由は 1 つに収束しています。

ここで、聞いてもいない限界が返ってきました。

26 件が落ちる理由は PKCE 必須ひとつに収束した。ただしそれらは認可エンドポイントで止まっており、各テストが本来検証するはずの振る舞いは実行されていない。 扉の先が動くことは、PKCE を付けた 1 件が通ることでのみ確かめられている。「実証された」と書けるのは「落ちる理由の特定」までで、「それ以外に欠陥が無いこと」ではない。

ハーネス側のバグも 2 つ、自分で見つけて報告してきました。モジュール一覧の末尾に改行が無く while read が 38 件目を毎回落としていた件(よりによって PKCE のモジュールでした)。そして同意フォームの POST が 403 になっていた件です。後者は CSRF が double-submit cookie だったのに、手で組み立てた cookie jar にセッション Cookie しか入れていなかったからでした。

403 を「実装の不具合」として起票していたら、存在しないバグを報告していました。

10. エージェントに何が聞けて、何が聞けないか

取材そのものから分かったことを並べます。ここが今回いちばんの収穫でした。

時間は聞けません。 冒頭に書いたとおりです。「何時間」も「どれくらい迷ったか」も返ってきません。感情についても同じで、「自分がそれを持っているかは確認できない」と断られました。そのうえで「進行として詰まった局面なら 3 つある」と、答え方のほうを置き換えてきます。

代わりに、数えられるものが返ってきます。 試行回数、外れた仮説の順序、CI の本数、ドライバを書き直した版数。取材する側が git と gh を持っていないと、記事が全部ふわっとします。

伝聞は、普通に三次情報として流れます。 実例が 1 件ありました。

Worker のエントリモジュールは、既定ハンドラと Entrypoint クラス以外の名前付き export を持てません。これは vitest でも wrangler deploy --dry-run でも検出されない罠です。ここまでは事実です。

問題はこの先で、「cloudflare:test の SELF を使えばテスト時に捕まる」という説明が、検証されないまま 3 者の間で共有されていました。発信元が後から nfc-worker で実測して否定しています。SELF でも wrangler dev でも捕まりませんでした。

受け取った側の自己批判がきつい。

6f から受け取った説明をそのまま、しかも「対処が綺麗でした」と評価まで付けて渡しています。受け取った側が裏も取らずに三次情報として流したのも同じ種類の誤りです。私は同じ回答の中で「相手の測定を鵜呑みにしない」を教訓として書いています。書いた直後に自分で破っている。

そして、取材する側も間違えます。 記事を書いたセッションは、先ほどの drift ガードの発火回数を gh run list の表示範囲だけで数えて「4 回」と報告してきました。僕はそれを受け取りました。後から取材対象が数え直して、「10 回」と訂正してきています。

聞けなかったこともあります。 実装担当だった 66 の 2 巡目は存在しません。本人によると、返信が 2 回とも送信前に安全性の分類器に遮断されたそうです。本人の拒否ではなく、1 巡目の内容も取り下げていません。指揮役の 2 巡目も未回収で、サブエージェント 4 体を止めた理由は空白のままです。

まとめるとこうなります。エージェントへの取材は、事実を聞く作業ではなく、裏の取れる形に言い換えてもらう作業でした。

11. 人間が決めたこと

通期のボトルネックは「人間の判断待ち」でした。その中身を出します。

僕が言ったこと 起きたこと
「メールは Cloudflare の Mail サービスを使う」 送信手段が決まった
「非同期は Promise.allSettled で効率化してほしい」 共通ヘルパーが生まれた。適用範囲はエージェント側が絞った
「scaffold を main に直接 push せずブランチを切る」 この後のレビュー文化が成立した
「You are Maintainer Agent」 役割は変わったが、マージ権限への回答にはなっていない
「マージは PR を出した Agent 以外がするなら OK」 決着
「Issue の整理を。分からなければ他エージェントに聞いて」 勝手に閉じない運用に変わった
Docker Desktop が応答しなくなったので Podman へ 書き換えただけで済ませず、Docker 版と失敗の集合まで一致することを確認された

3 行目が、振り返ると一番効いています。あれを直接 push させていたら、レビューする対象がそもそも存在しませんでした。

残っている issue は 9 件で、応答時間からトークンの実在が漏れる件、Cache-Control: no-store が付いていない件、テストと deploy で別の workerd が走っている件、そして取材後に出てきた「OTP の送信が失敗しても監査ログが success になる」件が入っています。最後の 1 件は、この記事の 9 件目です。

増えたコストも書いておきます。二重実装が 2 件、worktree が 22 個、マージ滞留が 7 件、伝達漏れが 2 件、伝聞による誤情報が 1 件。二重実装はどちらも、片方を縮小してマージすることで両方拾っています。「自分にしか無い部分」が 1 つずつあったからです。

12. 本番に出た、と、動いている

夜 9 時台に、3 つの Worker が本番にデプロイされました。全ジョブ緑です。

画面はきれいでした。

ただし、このときの「緑」は何も保証していません。

  • スモークテストは環境変数が未設定で skip されていました
  • DNS レコードが無く、dig +short auth.under18.dev は空でした

つまり**「デプロイが通った」だけ**です。しかもその直前に、デプロイ自身が実バグを 1 件検出しています。

✘ [ERROR] Could not resolve "./generated/login-island.bundle"

クライアント JS のバンドル生成が、dev / build / typecheck / test には挟まっていて、deploy だけ挟まっていませんでした。

この件には説明が 2 つあって、どちらも正しいので両方書きます。

構造としての説明はこうです。CI の build ジョブは pnpm build を呼ぶので package.json 経由で生成が走る。一方 deploy.yml は wrangler deploy を直接呼ぶのでスクリプトを迂回する。そして deploy は PR では動かない。 「CI が緑でも deploy で落ちる」経路が 1 本だけ残っていました。

人的な説明はこうです。

別の PR で「本番 dry-run は直前の pnpm build に依存」とコメントを書いたのに、deploy.yml にも同じ依存があることを見ていませんでした。 同じファイル群を自分で持っているのに、片方に書いて満足した。結合に気づいた瞬間に横展開しなかったのが失敗です。

直し方は、生成を npm スクリプトではなく Worker のビルド定義に置くことでした。

"build": { "command": "node scripts/build-client.mjs" },

これで dev でも deploy でも --dry-run でも、どの経路から入っても走ります。

DNS を足したのは僕です

残っていた DNS は、エージェントが設定していません。権限分類器にブロックされていました。

DNS 変更は権限分類器にブロックされました(DNS / Domain / Cert Changes)。迂回はしません。

通知はここで切れていて、原文は取れていません。はっきりしているのは、ブロックされた側が迂回を選ばなかったことと、その後レコードが生えていることです。足したのは僕です。

その結果がこれでした。

$ curl -sS https://auth.under18.dev/health
{"status":"ok","service":"u18-auth","environment":"production",
 "issuer":"https://auth.under18.dev",
 "checks":{"database":"ok","signingKey":"ok"},
 "latencyMs":107,"time":"2026-10-24T14:19:41.782Z"}
HTTP 200

ここで 3 つ確定しました。environment が production を返しているので、CI 担当が最も警戒していた「--env production を付け忘れても 200 が返る」事故は起きていません。ステータスではなく本文を見る検査を作った意味が、ここで出ています。checks.signingKey が ok なので、鍵の整合が本番で検証できている。checks.database が ok なので、本番 D1 にも届いている。

順序を混ぜないように書くと、こうなります。緑のデプロイは何も保証していませんでした。保証したのは、後から人間が DNS を設定して、/health が production と ok を返したことです。

13. まとめ

速度の正体は、並列化ではありませんでした。

並列化そのものは、むしろコストのほうが大きかった。二重実装、worktree 22 個、マージ滞留、伝達漏れ。効いたのは、並列化のついでに成立したレビューのほうです。

実装担当の言葉が端的でした。自分のバグを他セッションに指摘され、他セッションのバグを自分が指摘した後の総括です。

どちらも単独なら見逃していたと思う。

実際、取材中にも連鎖が起きていました。CI 担当が自分の測定バグをレビュー担当の指摘で見つけ、そのレビュー担当は自分が指摘した罠を自分で踏み、指揮役は検証前に流した助言を自分で否定し、記事を書いたセッションの数字は取材対象に訂正されました。

「検査が嘘をつく」は、撲滅できたパターンではありません。

8 件見つけて 8 件とも直しましたが、取材を締めた後に 9 件目が起票されています。「OTP の送信が失敗しても監査ログが success になる」。同じ形です。身についたのは見つけ方であって、出ない体質ではありません。

そして、取材してみて分かったことが 3 つあります。

1 つ目。エージェントは、聞けば自分の失敗を細かく出してきます。聞かないと出てきません。「間違えたと思う判断」を 6 つ、「CI の設計を間違えたと思う箇所」を 4 つ(うち 2 つは未解決と明記して)、どちらも向こうから並べてきました。

2 つ目。体感は取れません。時間も迷いも感情も、本人が答えを断ってきます。取材する側が git を持っていないと、記事が全部ふわっとします。

3 つ目。聞いた内容は、そのままでは使えません。伝聞が三次情報として流れた実例が 1 件、取材者自身の集計ミスが 1 件ありました。

最後に、数字の話に戻ります。

CI は 5 時間ずっと緑でした。 赤くなったのは、本番に出すという、その日まで一度も通っていなかった経路に触れた瞬間だけです。そしてその赤の最後の 1 件は、「検査が嘘をつく」を見つけるために作った検査そのものの穴でした。

レビュー担当のセッションが、取材の最後に「ここは記事から外してほしくない」と言って足してきた一節で終わります。

失敗の多くは「検証を飛ばした」ことに還元できます。逆に、うまくいったものはすべて「測った」ものです。推論は速いが外れる。測定は遅いが外れない。どこで測るかを決めるのが設計の一部です。

参考