Rebounder Tech Blog

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

snapshotSignedPolicies の冪等ガードで署名済み契約は二度と上書きできない

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

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

結論

署名時版をスナップショットする関数が両カラムnullのときだけ書き込む冪等ガードだと、一度署名された契約はコード上どこからも値を戻されないため、UIが再同意を呼びかけても実装がなければ永久に通らない。

結論

「両方のカラムが null のときだけ書き込む」という冪等ガードを一方向の関数に仕込むと、その関数は二度目以降絶対に動かなくなる。 署名時点の規約をスナップショットする関数が、まさにこの形をしていた。一度署名が成立すればスナップショット用のカラムに値が入り、以後はガードの条件(両方 null)が永久に成立しない。これ自体は正しい設計だが、「カラムを null に戻す処理」が他のどこにも実装されていなければ、スナップショットは文字どおり一生更新されない。

表面化したのは、規約を改定して契約専用の版を差し込んでも、既に署名済みの契約では合意内容が改定前のまま固定され続ける、という形だった。管理画面には「改めて Web 同意(再同意)を取得してください」という案内文があったが、その案内文が指す操作を実行するコードが存在しなかった。

症状

  • 広告主ごとの契約に、全体版とは別の専用版の規約・掲載基準を差し込める機能がある
  • 既に署名済みの契約に専用版を差し込んだが、申込書の表示・同意済みとして扱われる内容は署名当時の版のまま変わらない
  • 管理画面の署名済みカードには「署名時の版は証跡として凍結され続けます。正式な合意にするには、改めて Web 同意(再同意)を取得してください」という案内文が表示されている
  • しかし、その案内文の近くにボタンも遷移先もなく、再同意を実際に開始する手段がどこにも無い

原因

契約への署名時に規約をスナップショットする関数は、次のように書かれていた。

// src/lib/policies.ts(03a8cd300b, 107-123行)
export async function snapshotSignedPolicies(contractId: string): Promise<void> {
  // 全体版ではなく effective 版(契約専用版があれば専用版)を凍結する(0060)。
  const current = await getEffectivePolicies(contractId);
  const terms = current.terms?.id ?? null;
  const ps = current.posting_standards?.id ?? null;
  if (!terms && !ps) return;
  const admin = createSupabaseAdminClient();
  await admin
    .from("contracts")
    .update({
      signed_terms_doc_id: terms,
      signed_posting_standards_doc_id: ps,
    })
    .eq("id", contractId)
    .is("signed_terms_doc_id", null)
    .is("signed_posting_standards_doc_id", null);
}

.is("signed_terms_doc_id", null).is("signed_posting_standards_doc_id", null) という2つの条件が示す通り、この UPDATE は 両方のカラムがまだ null の契約にしか当たらない。 二重署名や処理の再実行で誤って上書きしないための、意図的な冪等ガードだ。一度署名が成立して両カラムに値が入れば、同じ契約に対してこの関数をもう一度呼んでも、WHERE 句に一致する行が無いので何も起きない。エラーにもならない。静かに no-op する。

これ自体は正しい。問題は、このガードを「もう一度通す」ための経路、つまりカラムを null に戻す処理が、修正前のコードのどこにも存在しなかったことだ。 管理画面の署名済みカードには次の案内文があった。

// src/app/admin/contracts/[id]/ContractPolicyCard.tsx
// (修正直前 = c8e05e29d620209109cb742fcaee5a299cc16a58, 198-203行)
{isSigned && (
  <div className="mb-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-relaxed text-amber-800">
    この契約は<strong>署名済み</strong>です。署名時の版は証跡として凍結・表示され続けます。
    差し込んだ専用版を<strong>正式な合意</strong>にするには、改めて Web 同意(再同意)を取得してください。
  </div>
)}

この文言は「再同意すれば直る」という前提で書かれている。しかし再同意が成立するには、まず signed_terms_doc_id と signed_posting_standards_doc_id が null に戻り、snapshotSignedPolicies のガードが再び成立する必要がある。その null に戻す処理自体が実装されていなかった。 案内文は実在しない機能を指していた。

直す

修正は snapshotSignedPolicies 側のガードを緩めることではない。ガードはそのままに、「カラムを null へ戻し、署名を未署名の状態に戻す」専用の処理を新設した。

// src/app/admin/contracts/[id]/policy-actions.ts(03a8cd300b, 163-227行)
/**
 * 規約改定のため署名をリセットし「再同意待ち」へ戻す(0060)。
 *
 * 署名済みの申込書は署名時版(signed_*_doc_id)で凍結されるため、後から専用版を差し込んでも
 * 旧版のまま表示され続ける。これを正式な合意へ更新するには再同意が要る。本アクションは
 * 署名時版スナップショットを解除し application_status を「署名依頼中」へ戻すことで、広告主が
 * 申込書リンクから再度 Web 同意できるようにする。再同意時に snapshotSignedPolicies が
 * effective 版(専用版優先)を改めて署名時版として凍結する=改定版が正式合意になる。
 */
export async function requestPolicyReconsent(
  _prev: ContractPolicyState,
  formData: FormData
): Promise<ContractPolicyState> {
  await requireStaff();
  const contractId = String(formData.get("contract_id") ?? "");
  if (!UUID.test(contractId)) return { error: "契約が不正です。" };
  const supabase = await createSupabaseServerClient();

  const { data: contract } = await supabase
    .from("contracts")
    .select("id, application_status")
    .eq("id", contractId)
    .maybeSingle();
  if (!contract) return { error: "契約が見つかりません。" };
  if (contract.application_status !== "署名済") {
    return { error: "署名済みの契約のみ再同意を依頼できます。" };
  }

  const { data: reset } = await supabase
    .from("contracts")
    .update({
      application_status: "署名依頼中",
      signed_terms_doc_id: null,
      signed_posting_standards_doc_id: null,
      signed_date: null,
      agreed_at: null,
      agreed_name: null,
    })
    .eq("id", contractId)
    .eq("application_status", "署名済")
    .select("id");
  if (!reset || reset.length === 0) {
    return { error: "署名のリセットに失敗しました。" };
  }

  await logAudit({
    action: "update",
    entity: "contracts",
    entityId: contractId,
    summary: "規約改定のため署名をリセット(再同意待ちへ)",
    metadata: { reason: "policy_reconsent" },
  });

  revalidatePath(`/admin/contracts/${contractId}/application`);
  return {
    success:
      "署名をリセットし、再同意待ちにしました。申込書リンクを広告主へ再送してください(再同意で改定版が署名時版として確定します)。",
  };
}

signed_terms_doc_id と signed_posting_standards_doc_id を明示的に null へ戻すことで、次に広告主が申込書リンクから再同意したときに snapshotSignedPolicies のガードが再び成立し、その時点の effective 版(専用版があれば専用版)が新しい署名時版としてスナップショットされる。ガード自体は一切変えていない。 変えたのは「ガードをもう一度通すための初期化処理を、誰かが作る」という欠けていた一手だけだ。

リセットで当初署名の内容(どの版に同意していたか)は失われるため、logAudit で metadata.reason: "policy_reconsent" として記録し、対象契約の application_status が「署名済」であることを UPDATE の .eq() 条件にも含めることで、未署名の契約を誤ってリセットしないガードも同時に備えている。

一般化できる形

「両方の値が未設定のときだけ書き込む」という冪等ガードは、一方向の処理としては正しい。 だがその正しさは暗黙に「いつか誰かが値を未設定へ戻す経路を作る」ことを前提にしている。ガードを書いた本人は、二重実行を防ぐことだけを考えていて、「このガードをもう一度通したくなる日が来る」とは想定していない。

結果として、ガードを再び通す処理は、ガードの実装とは別のタイミングで、別の人が、別の理由(この場合は規約改定への対応)で初めて必要になる。そのときにコードを探しても見つからず、代わりに見つかるのは「再同意してください」というUIの案内文だけ、ということが起こる。案内文は「機能がある」という期待を作ってしまうため、実装が追いつくまでの間、むしろ気づきを遅らせる。

.eq(col, value) や .is(col, null) を WHERE 句に使った UPDATE を書くときは、**「この条件が二度目以降も成立するルートが、コードのどこかに実在するか」**を合わせて確認する必要がある。存在しなければ、その UPDATE は実質「一生に一度だけ」の処理であり、UIにそれを前提とした案内文を置く前に、リセットする側の処理を先に実装しておくべきだ。

よくある質問

Q1なぜ署名済みの契約に専用版の規約を差し込んでも、正式な合意になりませんか?

署名した版は signed_terms_doc_id などのカラムにスナップショットされ、以後の表示や請求はそのスナップショットを参照します。この関数(snapshotSignedPolicies)は両カラムが null のときだけ書き込む冪等ガードで、一度署名されて値が入った契約は二度と上書きしません。専用版を差し込んでも、スナップショット自体が更新されないので、合意内容は署名した当時の版のまま固定されます。

Q2「再同意してください」という案内文があるのに、なぜ誰も気づかなかったのですか?

そのUI文言は署名済みカードに常時表示されていましたが、裏側には再同意(署名のリセット)を実行する処理が存在しませんでした。文言だけが「できるはず」という前提で書かれ、実装を伴わないまま運用されていたため、実際に専用版を差し込んで再同意を試みるまで、誰も気づきませんでした。別エージェントによるレビューで指摘されて発覚しています。

Q3直し方は、スナップショットを書く側の冪等ガードを変えたのですか?

いいえ、snapshotSignedPolicies 自体は変えていません。署名をリセットする新しい処理(requestPolicyReconsent)を追加し、署名済みの契約だけを対象に application_status を「署名依頼中」へ戻し、スナップショット用のカラムを明示的に null へ戻しました。null に戻れば、次に広告主が再同意したタイミングで冪等ガードが再び成立し、effective版(専用版優先)がスナップショットされます。リセット前の値は audit_logs に残します。

確認した環境

  • Next.js 16.2.7 / @supabase/supabase-js 2.106.2 / React 19.2.4
  • 2026-06-25 に別エージェントの再レビューで発覚・同日修正

この記事の根拠

  • TypeScriptファイル 107〜123行目コミット 03a8cd3
  • TypeScriptファイル 163〜227行目コミット 03a8cd3
  • TypeScriptファイル 198〜203行目コミット c8e05e2

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