署名済み申込書が、signed_terms_source_versionを見ず現行規約を同意済みと表示
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
署名済み契約の確認ページが常に現行の規約本文を『ご同意の対象』として表示していたため、規約改定後は顧客が実際には同意していない条文を同意済みとして見せてしまう経路があった。直し方は署名時点の規約版番号を記録し、現行版と一致する時だけ本文を表示することだった。
結論
電子署名で契約済みの確認ページに、同意した時点の規約ではなく常に「現行の」規約本文を表示し、それを「ご同意の対象」と label していた。 規約を改定すると、過去に旧版へ同意した顧客が自分の契約ページを開いた瞬間、読んだことのない新しい条文を「あなたが同意した内容」として見せられる経路があった。
直し方は、署名が成立した時点の規約版番号をデータベースへ凍結保存し、表示時にその版番号と現行版番号が一致する場合だけ本文をインライン表示する、というもの。一致しない場合は本文を出さず、「ご同意時点の規約(第N版)は現在の最新版とは内容が異なります」という案内に切り替える。
症状
広告主との契約は、申込書ページ(/p/[token])で規約本文を提示し、Web上の同意操作で電子署名が成立する。署名済みの契約を開いても、このページは次のように常に“現行”の規約本文を描画していた。
// 修正前(抜粋)
{advTerms && (
<details className="mb-3 rounded-md border border-gray-200 bg-gray-50">
<summary className="cursor-pointer px-3 py-2 text-xs font-medium text-gray-700 hover:text-brand">
「{advTerms.title}」の全文を表示(ご同意の対象)
</summary>
...
</details>
)}
advTermsはgetContractTemplate()が返す現行の規約本文で、条件はadvTerms &&だけ——署名済みかどうかも、署名時にどの版だったかも一切見ていない。見出しは署名済み・未署名のどちらでも同じ「ご同意の対象」のままだった。
規約は版が上がるたびに条文が変わりうる(src/lib/contract-doc.tsのコメントは「本文を差し替えたら version を+1する」「過去版のエントリは絶対に消さない」と明記している)。つまり規約を改定した瞬間、旧版で署名済みの契約も含めて、申込書ページを開いた全顧客が新しい条文を「ご同意の対象」として見ることになっていた。
原因
このコードが書かれた時点では、「署名が成立した瞬間の規約版」をどこにも記録していなかった。contractsテーブルには署名日時(agreed_at)や署名者名(agreed_name)はあったが、その時何条で同意したかという情報が無かったので、表示側も「今の規約を見せる」以外の選択肢が無かった。
レビューで指摘されたのは次の理屈だった。規約本文は版ごとに構造化されたメタ情報を持つ。
// src/lib/contract-doc.ts
const META_HISTORY: Record<string, Record<number, ContractMeta>> = {
"ad-terms": {
1: { version: 1, effective_date: "2026-07-24", has_auto_renew_clause: true },
2: { version: 2, effective_date: "2026-08-30", has_auto_renew_clause: true },
},
...
};
このhas_auto_renew_clauseのようなフラグは、版によって値が変わりうる構造化シグナルとして設計されている。もし将来のある版でこのフラグが変わった場合、「現行版を常に表示する」実装は、顧客が実際には読んでいない条文の有無を「同意済み」として見せてしまう。レビューはこの一点を「法的誤提示」として最優先(P0)で指摘した。
直し方
署名が成立した瞬間の規約版番号をcontracts.signed_terms_source_versionへ凍結保存するようにし、表示側はこの凍結版と現行版を突き合わせる条件分岐を追加した。
// src/app/p/[token]/page.tsx(修正後・抜粋)
const signedTermsVersion = contract.signed_terms_source_version;
// 本文をそのまま提示してよいのは「顧客が実際に見た本文と同一」と言い切れる時だけ:
// - 未署名: これから同意する現行版そのもの
// - 署名済: 凍結版(signed_terms_source_version)が現行版と一致する時のみ
const showTermsBody =
!!advTerms &&
(!isSigned ||
(signedTermsVersion != null && signedTermsVersion === advTermsMeta?.version));
// 署名済だが凍結版が現行版と異なる=本文は出さず、控えの案内に留める。
const signedOnOlderTerms =
isSigned &&
signedTermsVersion != null &&
signedTermsVersion !== advTermsMeta?.version;
表示側も、版が一致しない場合に向けて専用の案内を追加した。
{signedOnOlderTerms && (
<p className="mb-3 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs leading-relaxed text-gray-600">
ご同意時点の規約(第{signedTermsVersion}版)は、現在の最新版とは内容が異なります。
ご同意時点の控えが必要な場合は、お手数ですが担当者までお問い合わせください。
</p>
)}
見出しの文言も、署名済みかどうかで分けた。未署名には「ご同意の対象」、署名済みには「ご同意時点の内容」と表示を変え、現在進行形で結んでいた本文も「同意いただいています」という過去の事実として描写するよう修正した。本文を即時に差し替えるのではなく、同意の記録としての版の整合性を保つ方向で直している。
気づけなかった理由
このコードは型として正しく、実行時エラーにもならない。advTermsは常に値が取れるオブジェクトで、それをそのままJSXに描画するだけなので、typecheck・lint・ビルドのどの機械検査も引っかからない。
欠陥の中身は「署名済みの顧客には署名時点の版を見せる」という業務要件そのものが実装から欠けていたことで、これを検証するテストもこの修正以前には存在しなかった。規約の版が実際に複数に増えるまでは「現行版を出す」実装と「署名時点の版を出す」実装の振る舞いの差が一度も表面化しなかったため、機能としては動いているように見えていた。この修正でsigned_terms_source_versionによる版の突き合わせを検証するユニットテストが新たに追加され、今後規約の版を上げたときに同じ欠陥が再現しないようにしている。
よくある質問
Q1本番で実際に顧客への誤表示が起きたのですか?
いいえ。別agentによるコードレビューで実装の論理的な欠陥として検出されたもので、改定後の規約で実際にこの経路を通った契約が存在したかどうかは確認していません。本記事はレビューで見つかった設計上の欠陥として書いています。
Q2署名時点の版はどうやって記録しているのですか?
規約本文はコード側に版番号つきで管理していて(版を編集するたびに+1)、署名が成立した瞬間にその版番号をcontracts.signed_terms_source_versionという列へ凍結保存します。表示側はこの凍結済みの版番号と、現行の版番号を突き合わせて一致するかどうかを見ます。
Q3なぜtypecheckやテストでは検出できなかったのですか?
advTermsの型も、それを描画するJSXも構文としては正しく、実行時エラーにもなりません。『署名済みの顧客には署名時点の版を出す』という業務要件を検証するテストがこの修正以前には無かったため、静的検査では見つからない種類の欠陥でした。修正と同時に新規ユニットテストが追加されています。
確認した環境
- kimiteras-portal(Next.js, Supabase)。広告主との電子契約フロー(/p/[token] 申込書確認ページ)
- 規約本文はsrc/lib/contract-doc.tsのMETA_HISTORYで版番号つきでコード管理。署名確定時にcontracts.signed_terms_source_versionへ版番号を凍結(2026-07-24時点)
この記事の根拠
- TypeScriptファイル 491〜501行目コミット 82fbbf7
- TypeScriptファイル 174〜188行目コミット 00d6d04
- TypeScriptファイル 514〜533行目コミット 00d6d04
- TypeScriptファイル 51〜74行目コミット 00d6d04
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。