Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

直接申込に入れたfail-closedガードが、団体配分と招待発行の別経路には無かった

公開 読了時間 約5分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

kimiteras-portalは直接申込にstart_month未設定のfail-closedガードを入れた直後の再レビューで、顧客が自分で実行できる団体配分と、外部へ出て回収できない招待発行という別の書き込み経路にガードが複製されていなかったことが発覚した。

結論から

kimiteras-portalの広告枠(placement_loops)には、契約期間の起点になるstart_month(掲載開始月)が未設定のまま申込を成立させてはいけない、という不変条件がある。直接申込の経路(bookLoopSlotCore)にfail-closedのガードを入れて解決したはずだったが、その直後の再レビューで団体配分と招待発行という、まったく別の2つの書き込み経路には同じガードが入っていなかったことが発覚した。同じ条件を守るべき書き込み口が増えるたびに、複製し忘れるリスクがあるという話。

直接申込だけでは足りなかった理由

placement_loopsからcontracts行を作る経路は1つではない。公開カタログを経由する直接申込(bookLoopSlotCore)にfail-closedガードを入れても、それはそのガードを通る経路にしか効かない。同じcontracts行を、別の関数が別の入口から作れる状態が残っていれば、そちらは無検査のままになる。今回、再レビューで見つかった穴は2つあった。

穴1:団体配分は顧客自身が実行できる、最も起こりやすい生成口だった

group-booking.tsassignLoopUnitsToMemberは、団体に配分された占有枠から契約を作る関数で、修正前は掲載期間(period)をNULLのまま契約を作るコードのままだった。

// 修正前(sha 4f71bfae, lines 370-385)
const range = periodRange(startYm, effectiveTerm);
// ...
period_start: range ? range.start : null,
period_end: range ? range.end : null,

rangenullでも三項演算子で素通しし、period_start/period_endをNULLのまま契約を作ってしまう。これは直接申込で問題になったのと同じ形の欠陥だが、団体配分の経路はさらに見つかりにくい条件が重なっていた

  • 団体の占有枠は通常非公開のため、公開カタログのフィルタも保存時ガードも効かない
  • 実行するのは運営ではなく団体担当者(顧客)自身で、/groupdistributeUnitsActionから誰でも呼べる

つまりperiod NULLの契約を、顧客が自分の操作だけで作れてしまう、社内のどのチェックよりも起こりやすい生成口だった。修正では直接申込と同じfail-closedの検証を追加し、期間を確定できない場合は配分そのものを拒否したうえで、すでに確保済みの占有(placements行)も削除して巻き戻すようにしている。

// 修正後(sha 10aab915, lines 373-387)
if (!range) {
  await admin.from("placements").delete().eq("id", placementId as string);
  return {
    ok: false,
    error: "この枠は掲載開始月が設定されていないため、配分できません。お手数ですが運営までご連絡ください。",
  };
}

占有だけ残して契約を作らずに終わると、確保された枠が誰にも使われないまま塞がり続ける孤児状態になる。拒否と同時に占有を巻き戻すことで、その孤児化も防いでいる。

穴2:招待発行は「発行後」に気づいても手遅れだった

loop-invite.tsissueLoopInviteは、紹介店・団体向けの招待リンクを発行する関数だ。修正前は占有の有無だけを確認し、start_monthをまったく検査していなかった。

// 修正前(sha 10aab915, lines 66-78)
const { data: loop } = await admin
  .from("placement_loops")
  .select("id, label, bought_out_by_company_id")
  .eq("id", params.loopId)
  .maybeSingle();
// ...
if (!loop.bought_out_by_company_id)
  return { ok: false, error: "..." };
// start_month のチェックはここに無い

招待リンクは印刷したQRやメールで外部に配布される。会社名・住所・代表者・規約同意まで入力し終えた最後の送信で申込を拒否する既存の仕組みはあったが、それでは招待先にとって「入力し終えた最後に拒否される」行き止まりになる。招待は発行された時点で社外に出てしまうため、あとから気づいても回収できない。修正では発行時点でstart_monthの形式を検証し、無効なら発行そのものを拒否するようにした。

// 修正後(sha f0c52ff, lines 67-87)
if (!isValidStartMonth(loop.start_month as string | null))
  return {
    ok: false,
    error: "この枠は掲載開始月が未設定のため、招待リンクを発行できません。先に枠へ開始月を設定してください(未設定のままだと、招待先は入力し終えた最後に申込を拒否されます)。",
  };

同じ不変条件を守る箇所が、書き込み口が増えるたびに漏れる

直接申込・団体配分・招待発行という3つの経路は、見た目も呼び出し元もまったく違う。だが守るべき不変条件(start_monthが無ければ契約を作らない)は同じだ。1箇所を直したという安心感が、他の入口の確認を後回しにさせるという構造がここにある。今回は同じ2エージェント+新しい1エージェントによる再レビューで見つかったが、レビューの目が増えるたびに見つかった事実は、見る人がいなければ見つからなかった裏返しでもある。書き込み口を増やす変更をするたびに、同じ不変条件を守る箇所が他にもないかを確認する必要がある。

まとめ

「直接申込のガードを直したから終わり」という判断は早すぎた。同じデータに書き込める経路が複数あるシステムでは、1つの経路を直しただけでは他の経路が無検査のまま残る。特に団体配分のように顧客自身が実行でき、公開カタログのガードが及ばない経路や、招待発行のように発行後は回収できない経路は、直接申込より優先して確認すべき候補だった。同種の構造を持つ機能を直すときは、修正した経路だけでなく「同じデータに書き込める他の経路」を洗い出す工程を、修正そのものと同じ重みで扱う必要がある。

よくある質問

Q1直接申込にガードを入れたのに、なぜ足りなかったのですか?

掲載枠(placement_loops)へのcontracts行の書き込み口は1つではないためです。公開カタログからの直接申込(bookLoopSlotCore)にfail-closedガードを入れても、団体配分(assignLoopUnitsToMember)と招待発行(issueLoopInvite)という別の関数が同じ検証を経ずにcontractsを作れる状態が残っていました。保存時のガードは、そのガードを通る経路にしか効きません。

Q2団体配分の経路がなぜ特に見つかりにくかったのですか?

団体の占有枠は通常非公開のため、公開カタログのフィルタも保存時ガードも効きません。しかも実行するのは運営ではなく団体担当者(顧客)自身で、/groupのdistributeUnitsActionから誰でも呼べます。period NULLの契約を顧客が自分で作れる、最も起こりやすい生成口でした。

Q3招待発行のガードは後回しでもよかったのでは?

だめでした。招待リンクは印刷したQRやメールで外部に配布されるため、発行後に気づいても回収できません。申込の最終送信で拒否する既存の仕組みでは、招待先が会社名・住所・代表者・規約同意まで入力し終えた最後で初めて拒否される行き止まりになるため、発行そのものを止める必要がありました。

Q4直し方はどうなっていますか?

直接申込と同じfail-closedの検証を両方の経路に追加しました。団体配分は掲載期間を確定できない場合に配分を拒否し、あわせて確保済みの占有(placements行)を削除して巻き戻します。招待発行は開始月が有効な形式でなければ発行自体を拒否します。どちらも失敗時にSlackへ通知するようにし、無音の取りこぼしを無くしています。

確認した環境

  • Next.js 16.2.7(App Router)/ kimiteras-portal
  • 2026-09-15 の再レビュー2回で発覚・同日中に修正

この記事の根拠

  • TypeScriptファイル 370〜385行目コミット 4f71bfa
  • TypeScriptファイル 373〜387行目コミット 10aab91
  • TypeScriptファイル 66〜78行目コミット 10aab91
  • TypeScriptファイル 67〜87行目コミット f0c52ff

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。