v4 Decision ADR v4.1 (Final) — C採用・PII最小化設計

2026-04-17 | Claude Code | v4.1 patch: Codex 10回目指摘3件を in-place 織り込み(契約書/SQL claim/Meta revoke)

📌 本書の位置づけ

9ラウンドの Codex レビューで「SaaS プラットフォーム仕様の物理的制約」に到達。経営判断として 選択肢 C(INV-18 best-effort + PII最小化 + 契約書明記)を採用。 本書は子安氏に送る最終版 ADR

1. v3(21)→v4(23) 主要変更点

Codex 9回目指摘CC 対応
[critical] INV-18 が non-Japan services で偽の pass §4 にデータクラス × vendor マトリクス追加。PII を Class 1 とし、Tokyo-only の Supabase DB / R2 APAC placement に閉じる。Sentry / Better Stack には PII を redaction して送らない設計を §7 で明示
[critical] Vault 削除が crypto-shredding でない、PITR で復活 §8 erasure design を再設計。「削除ではなく revoke」方式: Meta 側で IG token を revoke → DB上の secret は tombstone でマーク → PITR 7日経過後に物理削除
[high] pg-boss は long-lived Node worker が必要で BoM と不整合 §5 で pg-boss を削除、pg_cron + CF Workers scheduled trigger + Supabase DB ポーリングに置換。serverless runtime との整合を取る

2. 実現したいこと(要件)

v3 (21) と同一。19 §1 参照。

2.1 ビジネスサマリ(再掲)

3. Invariants(20項目 MUST、INV-18 は best-effort に降格

v3 からの変更: Codex 9回目指摘に基づき、INV-18 Data Residency を MUST(Hard Gate)から「SHOULD + 契約書で範囲明記」に降格。残り 19項目は MUST 維持。

3.1 MUST 19項目

INV-1〜17, 19, 20 は v3 (21) と同一で MUST。

3.2 INV-18 Data Residency(best-effort + 契約範囲明記)

要件 以下の Class 1 データ(PII / 機微情報)は日本国内(Tokyo region)に保管する。それ以外のデータクラスは best-effort(ベンダ standard location)で許容し、顧客契約書に明記する。

3.3 データクラス定義

Class 1 PII / 機微情報 — JP居住必須

保管先: Supabase Tokyo region(Postgres + Vault)のみ。他ベンダには渡さない。

Class 2 運用情報 — ベンダ standard location 許容

保管先: Sentry (US/EU) / Better Stack (EU) / Cloudflare logs (global) で OK。redaction 必須

Class 3 画像 — APAC placement(best-effort Tokyo)

保管先: Cloudflare R2 APAC placement(best-effort Tokyo、公式の residency 保証なしを契約書明記)。

3.4 契約書明記テンプレート(案)(v4.1: Codex 10回目指摘 #1 で Anthropic 追加 + CF log 詳細分類)

第X条(データ処理とサブプロセッサ)

1. 当社(AI経営共創パートナーズ)は以下のサブプロセッサを利用します:
   - Supabase Inc.(米国法人、データは日本国内保管):
       用途: データベース・認証・シークレット管理
       データ処理場所: 東京リージョン(AWS ap-northeast-1)
   - Cloudflare Inc.(米国):
       用途: フロントエンド配信・API Gateway・WAF・画像ストレージ(R2)・ログ
       データ処理場所: 全世界エッジ(リクエストは最寄りエッジで処理)
   - Anthropic PBC(米国):
       用途: Claude AI によるキャプション生成・画像分析
       データ処理場所: 米国
       送信データ: 店舗がアップロードした料理画像、生成指示プロンプト
       保持: Anthropic の standard retention(最大30日、学習には使用しない契約)
   - Sentry Inc.(米国):
       用途: エラー監視・パフォーマンス監視
       データ処理場所: 米国(US tier)
   - Better Stack s.r.o.(チェコ):
       用途: 死活監視・アラート
       データ処理場所: EU

2. 【Class 1 情報】貴店舗に関する以下の情報は日本国内のみに保管します:
   - 店舗情報(店舗名・住所・電話番号)
   - Instagram 連携情報(user_id・access_token・refresh_token、すべて暗号化)
   - 承認者メールアドレス・店舗管理者アカウント情報
   - 投稿履歴のテキスト部分(キャプション・ハッシュタグ・画像管理ID)
   - 操作履歴・監査ログ(IP アドレス・user-agent・actor_id を含む)
   → 保管先: Supabase 東京リージョン(AWS ap-northeast-1)

3. 【Class 2 情報】以下の匿名化・redaction 済みデータは国外サブプロセッサを
   経由する可能性があります:
   - 匿名化されたエラー trace(store_id をハッシュ化、caption_text をマスク)
     → Sentry(米国)
   - サービス死活監視の heartbeat
     → Better Stack(EU)
   - Cloudflare edge logs(IP 末尾オクテット削除、path は集約済)
     → Cloudflare グローバル

4. 【Class 3 情報】画像データは以下のとおり扱います:
   - 料理画像(Class 3、PII ではない):
     * Cloudflare R2 の APAC placement(東京近傍、best-effort、契約保証なし)
     * Anthropic Claude API(米国、キャプション生成のため送信)
   - 画像には店舗を特定できる情報(看板・メニュー表等)が含まれる場合、
     貴店舗は投稿前に確認する責任を負います。

5. Class 2 データの redaction 実装は以下を最低限守ります:
   - 個人を特定可能な識別子(名前・メール・電話)の除去
   - IP アドレスの末尾オクテット削除または都道府県レベル集約
   - store_id のハッシュ化
   - Claude API 送信時も同様の redaction を適用

4. データクラス × Vendor マトリクス(Codex 9回目指摘 #1 対応)

VendorRegionClass 1 (PII)Class 2 (運用)Class 3 (画像)
Supabase Postgres Tokyo (ap-northeast-1) 5 5 N/A
Supabase Vault (pgsodium) Tokyo 5 IG token N/A N/A
Supabase Auth Tokyo 5 N/A N/A
Cloudflare Workers Global edge 4 処理のみ(保管せず) BE 実行ログ N/A
Cloudflare R2 APAC (best-effort Tokyo) 2 画像のみ、PII不可 N/A BE
Cloudflare Pages Global CDN N/A N/A N/A (静的配信)
Sentry US / EU 選択 禁止(redaction 必須) BE N/A
Better Stack EU 禁止(heartbeat のみ) BE N/A
Anthropic Claude API US 2 画像+テキスト送信時は redaction N/A N/A(画像は送る)

凡例: 5=Native / 4=Sound / 3=Achievable / 2=要注意 / BE=Best Effort(契約書明記)

4.1 Class 1 データのフロー(JP region 固定)

ユーザー (店舗ブラウザ)
   ↓ HTTPS
Cloudflare Workers (edge: 最寄り、処理のみ・保管なし)
   ↓ HTTPS (JP region 固定)
Supabase Postgres / Auth / Vault (Tokyo ap-northeast-1)
   ← Class 1 データはすべてここに保管

4.2 Class 2 データ(redaction 後に外部送信)

Cloudflare Workers
   ↓ エラー発生時
   ↓ redaction処理(PII除去)
   ↓
Sentry (US or EU)
   - store_id → ハッシュ化
   - caption_text → マスク
   - IP → 末尾オクテット削除

4.3 Class 3 データ(画像、APAC best-effort)

店舗ブラウザ
   ↓ presigned URL (10min 有効)
Cloudflare R2 APAC placement
   - Tokyo 近傍に配置される(best-effort)
   - EU/FedRAMP のような契約保証はなし
   - 画像自体は PII ではない(料理写真のみ)

5. Bill of Materials(v3 から pg-boss を削除)

v3 からの変更: Codex 9回目指摘 #3 対応で pg-boss(Node worker 前提)を削除。serverless runtime(CF Workers + Supabase Edge Functions)と整合する async 処理に置換。
レイヤー製品 / 技術備考
FrontendCloudflare Pages + React 19 + Vite既存資産流用
API GatewayCloudflare Workers + Hono既存コード構造流用
業務ロジック(DB近接)Supabase Edge Functions (Deno)複雑な Postgres 処理は DB 近接で
AuthSupabase Auth (Tokyo)MFA / SSO 対応
一貫性境界Postgres 14 + advisory lock + txoutbox / audit chain / rate limit counter
非同期ジョブ(新設計)pg_cron (Postgres 拡張) + CF Workers scheduled trigger + Postgres 上の job tablepg-boss ではなく pg_cron で 1分ごとポーリング + CF Workers cron で複合
Cronpg_cron(DB内)+ CF Workers Cron Triggers(外部)2系統で冗長性確保
MediaCloudflare R2 + presigned URLAPAC placement
DBSupabase Postgres Small compute (Tokyo)PITR $100/mo add-on
AbuseCF WAF + Turnstile
観測Supabase Logs + Sentry + Better StackClass 2 のみ送信(redaction)
SecretsSupabase Vault (pgsodium) + CF Workers Secrets§8 erasure 設計参照

5.1 新 async 処理設計(pg_cron + SQL claim_jobs 関数)(v4.1: Codex 10回目指摘 #2 対応、advisory_lock の誤用を修正)

Job queue テーブル

CREATE TABLE job_queue (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  kind TEXT NOT NULL,              -- 'publish_post', 'reconcile_ig', 'refresh_token'
  payload JSONB NOT NULL,
  idempotency_key TEXT UNIQUE,     -- IG API 重複実行防止キー
  state TEXT NOT NULL DEFAULT 'pending',  -- 'pending', 'processing', 'done', 'failed'
  scheduled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  locked_by TEXT,                  -- 実行中 worker の ID
  locked_until TIMESTAMPTZ,        -- lease 期限(タイムアウト後は他worker が claim 可)
  attempts INT NOT NULL DEFAULT 0,
  max_attempts INT NOT NULL DEFAULT 3,
  last_error TEXT,
  next_attempt_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  finalized_at TIMESTAMPTZ
);

CREATE INDEX idx_job_queue_ready ON job_queue (scheduled_at, state)
  WHERE state IN ('pending', 'processing');

原子的 claim 関数(SQL 1文で race-free)

CREATE OR REPLACE FUNCTION claim_jobs(
  p_worker_id TEXT,
  p_lease_seconds INT DEFAULT 300,
  p_max_jobs INT DEFAULT 5
) RETURNS SETOF job_queue AS $$
  -- SKIP LOCKED で並行 worker が同じ行を取らない
  -- state='processing' AND locked_until < now() の expired lease も対象
  UPDATE job_queue
  SET
    state = 'processing',
    locked_by = p_worker_id,
    locked_until = now() + (p_lease_seconds || ' seconds')::interval,
    attempts = attempts + 1
  WHERE id IN (
    SELECT id FROM job_queue
    WHERE scheduled_at <= now()
      AND (next_attempt_at IS NULL OR next_attempt_at <= now())
      AND attempts < max_attempts
      AND (
        state = 'pending'
        OR (state = 'processing' AND locked_until < now())
      )
    ORDER BY scheduled_at ASC
    LIMIT p_max_jobs
    FOR UPDATE SKIP LOCKED
  )
  RETURNING *;
$$ LANGUAGE SQL;

finalize関数(CAS で lease 所有者のみ commit 可能)

CREATE OR REPLACE FUNCTION finalize_job(
  p_job_id UUID,
  p_worker_id TEXT,
  p_success BOOLEAN,
  p_error TEXT DEFAULT NULL,
  p_retry_after_seconds INT DEFAULT NULL
) RETURNS BOOLEAN AS $$
DECLARE
  v_changed INT;
BEGIN
  IF p_success THEN
    UPDATE job_queue
    SET state = 'done', finalized_at = now(), last_error = NULL,
        locked_by = NULL, locked_until = NULL
    WHERE id = p_job_id AND locked_by = p_worker_id AND state = 'processing';
  ELSIF p_retry_after_seconds IS NOT NULL THEN
    UPDATE job_queue
    SET state = 'pending', last_error = p_error,
        next_attempt_at = now() + (p_retry_after_seconds || ' seconds')::interval,
        locked_by = NULL, locked_until = NULL
    WHERE id = p_job_id AND locked_by = p_worker_id AND state = 'processing';
  ELSE
    UPDATE job_queue
    SET state = 'failed', last_error = p_error, finalized_at = now(),
        locked_by = NULL, locked_until = NULL
    WHERE id = p_job_id AND locked_by = p_worker_id AND state = 'processing';
  END IF;

  GET DIAGNOSTICS v_changed = ROW_COUNT;
  RETURN v_changed = 1;  -- false なら lease 喪失(他 worker に奪われた)
END;
$$ LANGUAGE plpgsql;

実行モデル

Primary: pg_cron (Supabase内、Tokyo)
  * * * * * →
    SELECT * FROM claim_jobs('pg_cron_worker_' || gen_random_uuid(), 300, 5);
    → 各 job を Supabase Edge Functions に委譲(非同期呼び出し)
    → Edge Functions 内で finalize_job() を呼ぶ
    ※ pg_cron 自体は 10分以内に完了(claim のみ、実処理は Edge Functions)

Secondary (watchdog/reaper): Cloudflare Workers Scheduled Trigger
  */5 * * * * →
    REST API 経由で expired lease の job を SELECT
    → pg_cron 停止時の fallback(stuck job を claim し直す)
    → 通常時は対象ゼロ

設計の key points

5.2 Failure matrix の再設計

E2 crash injection:

6. Decision(v4 最終)

✅ 最終採用アーキテクチャ

案β' — Supabase + CF Workers hybrid + pg_cron(serverless整合版)

Supabase (Tokyo) Postgres + Auth + Vault + pg_cron
+ CF Pages + Workers + Workers Cron + R2 (APAC) + WAF + Turnstile
+ Sentry + Better Stack (Class 2 のみ・redaction必須)

Hard Gate 通過(INV-1/3/8/12/13)/INV-18 は best-effort(契約書明記)/PII 完全 JP居住

7. TCO (C 採用時の実価格、v3 と同等)

Enterprise plan 購入なし、既存 vendor pricing で運用。v3 (21) §5 と同じ試算。

項目100店舗月額
Supabase Pro + Small + PITR + disk + egress$142
CF Workers + R2 + WAF$30
Sentry Team$26
Better Stack 2 responders$68
Claude API (usage)$40
Staging/Dev$30
月額合計$336

3年 TCO ¥6.7M / 100店舗売上 ¥3.5M/月 / 粗利率 94.7%(v3 と同じ)

8. Erasure 設計(Vault + PITR 整合)

v3 からの変更: Codex 9回目指摘 #2 対応。「row delete = crypto-shredding」主張を撤回し、「revoke + tombstone + PITR tail 管理」に再設計。

8.1 問題の正確な認識

8.2 採用する erasure flow (v4.1: Codex 10回目指摘 #3 で Meta revoke 具体化 + blocking branch)

Step 1: Meta 側で token revoke(具体化)

Target: Instagram Business Account に紐付く IG access_token

Endpoint(Meta Graph API 公式):
  DELETE https://graph.facebook.com/v21.0/{ig-user-id}/permissions
    ?access_token={access_token}
  # App に与えた全権限を一括 revoke(instagram_basic, pages_show_list 等)

  または個別 permission:
  DELETE https://graph.facebook.com/v21.0/{ig-user-id}/permissions/{permission-name}

Required credentials:
  - access_token(revoke 対象の token そのもの、または app access_token)

Expected response:
  { "success": true }
  # success != true or HTTP != 200 の場合は revoke 失敗

Post-revoke 検証(必須):
  GET https://graph.facebook.com/debug_token?input_token={access_token}
      &access_token={app_id}|{app_secret}
  # is_valid=false なら revoke 成功
  # is_valid=true なら revoke 失敗(blocking branch へ)

参照: https://developers.facebook.com/docs/graph-api/reference/user/permissions/

Step 2: DB 側で tombstone

- stores.deleted_at = now() 設定(soft delete)
- vault secret を vault.update_secret(id, '__REVOKED__') で上書き
- 業務クエリは WHERE deleted_at IS NULL で除外
- audit_logs に {action: 'token_revoke', revoke_verified: true/false, meta_response} を記録

Step 3: PITR tail を待つ(7日間)

- Supabase PITR 7日 add-on 契約に依存
- 7日経過で PITR 範囲外(復元不可)
- この間に誤削除 rollback 可能

Step 4: 物理削除(8日目以降の定期バッチ)

DELETE FROM stores WHERE deleted_at < now() - '7 days'::interval;
-- Vault secret も vault.delete_secret(id) で完全削除
-- 削除証明書を audit_logs + email で顧客提示

Blocking branch(Codex 10回目指摘 対応)

以下のいずれかの場合、deletion certificate を発行しない:

Blocking 時の対応:

  1. deletion certificate の代わりに pending notification を顧客に送付
  2. 顧客に Meta Business Manager の Business Settings → Apps から手動で app remove する手順を案内
  3. 手動削除確認を受けて初めて deletion certificate を発行
  4. audit_logs に blocking 発生と解除を全記録

Token type 別の revoke 方法

Token typeRevoke 方法
User access token(通常ケース)DELETE /{user-id}/permissions
Long-lived user token同上(user token のまま revoke 可能)
Page access token(business IG account)DELETE /{page-id}/subscribed_apps で app のアクセスを停止
System user tokenMeta Business Manager で system user の token を manually revoke する必要あり(Blocking branch 相当)

8.3 GDPR 「削除要求から 30日以内」との整合

8.4 pgsodium deprecation への対応

8.5 Master Key Rotation

Supabase が Vault master key を管理するため、アプリ側は rotation job を持たない。Supabase 支援が必要な場合は enterprise 契約 or サポートチケット経由(alpha 時点では limited)。

9. 他セクション(v3 から変更なし)

10. 子安氏への依頼事項(Q1-Q7)

Q1-Q7 + blocking concerns 自由記述
#質問回答形式
Q1Decision(§6)の案β'(Supabase + CF hybrid + pg_cron)で進めて良いかYes / No
Q2Invariants 20項目で追加・削除・優先度変更はあるか(INV-18 best-effort 降格含む)追加リスト or「なし」
Q3Evidence(v3 §8 + pg_cron 調整)E1〜E9 test mechanics に修正はあるか修正リスト or「なし」
Q4Delivery Plan 12週間で実装可能か(Supabase 経験前提)Yes / No(No なら現実工期)
Q5Blocking concerns(Supabase Vault alpha SLA 除外リスク含む)自由記述
Q624/7 on-call 受諾可否 + compensation / escalation 合意
  • 平日 19時-翌9時 primary → Yes / No
  • 週末 rotation → Yes / No
  • on-call 手当提案
Q7契約書テンプレート(§3.4)の文言に修正・追加・削除はあるか修正文言 or「承認」

10.1 進め方

11. 付録:9ラウンドの経緯

Round対象Verdict主な指摘
1v3 コードneeds-attentionscheduler/approval/cron 5件
2v3 スケール+セキュリティneeds-attention11件
3v3 修正後needs-attention5件(一部再発)
4v3 修正後needs-attention4件(audit fork 再発)
5v3 アーキテクチャNo-ship 判定δ option 推奨
6v4 ADR 叩き台reject-and-rewrite10件
7v4 Decision v1needs-major-revision6件
8v4 Decision v2needs-major-revision6件(γ不可判明)
9v4 Decision v3needs-major-revision3件(プラットフォーム限界)
10(予定)本書 v4narrow review

11.1 関連ドキュメント