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。
| 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 との整合を取る |
v3 (21) と同一。19 §1 参照。
INV-1〜17, 19, 20 は v3 (21) と同一で MUST。
| 要件 | 以下の Class 1 データ(PII / 機微情報)は日本国内(Tokyo region)に保管する。それ以外のデータクラスは best-effort(ベンダ standard location)で許容し、顧客契約書に明記する。 |
保管先: Supabase Tokyo region(Postgres + Vault)のみ。他ベンダには渡さない。
保管先: Sentry (US/EU) / Better Stack (EU) / Cloudflare logs (global) で OK。redaction 必須。
保管先: Cloudflare R2 APAC placement(best-effort Tokyo、公式の residency 保証なしを契約書明記)。
第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 を適用
| Vendor | Region | Class 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(契約書明記)
ユーザー (店舗ブラウザ) ↓ HTTPS Cloudflare Workers (edge: 最寄り、処理のみ・保管なし) ↓ HTTPS (JP region 固定) Supabase Postgres / Auth / Vault (Tokyo ap-northeast-1) ← Class 1 データはすべてここに保管
Cloudflare Workers ↓ エラー発生時 ↓ redaction処理(PII除去) ↓ Sentry (US or EU) - store_id → ハッシュ化 - caption_text → マスク - IP → 末尾オクテット削除
店舗ブラウザ ↓ presigned URL (10min 有効) Cloudflare R2 APAC placement - Tokyo 近傍に配置される(best-effort) - EU/FedRAMP のような契約保証はなし - 画像自体は PII ではない(料理写真のみ)
| レイヤー | 製品 / 技術 | 備考 |
|---|---|---|
| Frontend | Cloudflare Pages + React 19 + Vite | 既存資産流用 |
| API Gateway | Cloudflare Workers + Hono | 既存コード構造流用 |
| 業務ロジック(DB近接) | Supabase Edge Functions (Deno) | 複雑な Postgres 処理は DB 近接で |
| Auth | Supabase Auth (Tokyo) | MFA / SSO 対応 |
| 一貫性境界 | Postgres 14 + advisory lock + tx | outbox / audit chain / rate limit counter |
| 非同期ジョブ(新設計) | pg_cron (Postgres 拡張) + CF Workers scheduled trigger + Postgres 上の job table | pg-boss ではなく pg_cron で 1分ごとポーリング + CF Workers cron で複合 |
| Cron | pg_cron(DB内)+ CF Workers Cron Triggers(外部) | 2系統で冗長性確保 |
| Media | Cloudflare R2 + presigned URL | APAC placement |
| DB | Supabase Postgres Small compute (Tokyo) | PITR $100/mo add-on |
| Abuse | CF WAF + Turnstile | |
| 観測 | Supabase Logs + Sentry + Better Stack | Class 2 のみ送信(redaction) |
| Secrets | Supabase Vault (pgsodium) + CF Workers Secrets | §8 erasure 設計参照 |
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');
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;
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 し直す)
→ 通常時は対象ゼロ
locked_until カラム + FOR UPDATE SKIP LOCKED で atomic claimWHERE locked_by = ? で lease 所有者のみ commit 可、changes=0 で奪取検知E2 crash injection:
locked_until 経過後に別 worker が claim → idempotency_key により IG 二重投稿なし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居住
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 と同じ)
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/
- 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} を記録
- Supabase PITR 7日 add-on 契約に依存 - 7日経過で PITR 範囲外(復元不可) - この間に誤削除 rollback 可能
DELETE FROM stores WHERE deleted_at < now() - '7 days'::interval; -- Vault secret も vault.delete_secret(id) で完全削除 -- 削除証明書を audit_logs + email で顧客提示
以下のいずれかの場合、deletion certificate を発行しない:
Blocking 時の対応:
Business Settings → Apps から手動で app remove する手順を案内| Token type | Revoke 方法 |
|---|---|
| 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 token | Meta Business Manager で system user の token を manually revoke する必要あり(Blocking branch 相当) |
Supabase が Vault master key を管理するため、アプリ側は rotation job を持たない。Supabase 支援が必要な場合は enterprise 契約 or サポートチケット経由(alpha 時点では limited)。
| # | 質問 | 回答形式 |
|---|---|---|
| Q1 | Decision(§6)の案β'(Supabase + CF hybrid + pg_cron)で進めて良いか | Yes / No |
| Q2 | Invariants 20項目で追加・削除・優先度変更はあるか(INV-18 best-effort 降格含む) | 追加リスト or「なし」 |
| Q3 | Evidence(v3 §8 + pg_cron 調整)E1〜E9 test mechanics に修正はあるか | 修正リスト or「なし」 |
| Q4 | Delivery Plan 12週間で実装可能か(Supabase 経験前提) | Yes / No(No なら現実工期) |
| Q5 | Blocking concerns(Supabase Vault alpha SLA 除外リスク含む) | 自由記述 |
| Q6 | 24/7 on-call 受諾可否 + compensation / escalation 合意 |
|
| Q7 | 契約書テンプレート(§3.4)の文言に修正・追加・削除はあるか | 修正文言 or「承認」 |
| Round | 対象 | Verdict | 主な指摘 |
|---|---|---|---|
| 1 | v3 コード | needs-attention | scheduler/approval/cron 5件 |
| 2 | v3 スケール+セキュリティ | needs-attention | 11件 |
| 3 | v3 修正後 | needs-attention | 5件(一部再発) |
| 4 | v3 修正後 | needs-attention | 4件(audit fork 再発) |
| 5 | v3 アーキテクチャ | No-ship 判定 | δ option 推奨 |
| 6 | v4 ADR 叩き台 | reject-and-rewrite | 10件 |
| 7 | v4 Decision v1 | needs-major-revision | 6件 |
| 8 | v4 Decision v2 | needs-major-revision | 6件(γ不可判明) |
| 9 | v4 Decision v3 | needs-major-revision | 3件(プラットフォーム限界) |
| 10(予定) | 本書 v4 | narrow review |