migration 安全規約¶
対象読者: migration ファイルを作成するすべての開発者・エージェント。
PR を出す前にこのチェックリストを確認すること。
1. 加法のみ・冪等¶
migration は既存データを一切破壊しない加法的な操作のみを行う。再実行しても安全な冪等 SQL を使う。
| 操作 | 正しい書き方 |
|---|---|
| テーブル作成 | CREATE TABLE IF NOT EXISTS |
| 列追加 | ALTER TABLE … ADD COLUMN IF NOT EXISTS(nullable、デフォルト値なし) |
| インデックス作成 | CREATE INDEX IF NOT EXISTS |
| ポリシー変更 | DROP POLICY IF EXISTS … → CREATE POLICY |
| トリガ変更 | DROP TRIGGER IF EXISTS … → CREATE TRIGGER |
| 関数作成・変更 | CREATE OR REPLACE FUNCTION |
新規列は必ず nullable(既存行が NOT NULL に違反しないため)。
既存行のデータは削除・上書き禁止。DELETE・UPDATE・DROP TABLE・DROP COLUMN は原則不可(§3 参照)。
実例: 00000000000063_legal_retention_audit_log.sql(CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS + DROP POLICY IF EXISTS → CREATE POLICY)、00000000000064_legal_retention_soft_delete_columns.sql(ADD COLUMN IF NOT EXISTS nullable、7 表全テーブルで同一パターン)。
2. RLS 同一テナント参照ガード¶
クロステナント FK(他テナントの行を自テナント行に紐付ける攻撃)を防ぐため、外部キーが示す参照先が同一テナントに属することを WITH CHECK の EXISTS で検証する。
with check (
tenant_id = public.current_tenant_id()
and exists (
select 1 from public.<親テーブル> p
where p.id = <外部キー列> -- 外側テーブル列を完全修飾
and p.tenant_id = public.current_tenant_id()
)
)
未修飾 tenant_id シャドーイング(必ず回避)¶
相関サブクエリ内で tenant_id を未修飾で書くと、内側テーブル(親テーブル)の tenant_id 列に解決され p.tenant_id = p.tenant_id(恒真)となり検証が無効化される。
→ 外側テーブル列は <外側テーブル>.<列名> で完全修飾するか、テナント一致は public.current_tenant_id() と直接比較する。
実例:
- 00000000000055_dispatch_stops_write_same_tenant_refs.sql — driver_id / collection_site_id が自テナント所属か EXISTS で検証。コメントにシャドーイング罠の解説あり。
- 00000000000019_weighing_integrity.sql — weighings / partner_item_contracts の partner_id が自テナント所属か検証。
- 00000000000056_manifests_job_id_same_tenant.sql — マニフェストの job_id 同一テナント検証。
3. 法定保存記録は物理削除禁止(soft-delete のみ)¶
廃掃法・会計法令に基づき、基本 7 表 + 以後追加された 7 表(external_partners / external_partner_settlements / driver_roll_calls / driver_daily_notes / vehicle_daily_logs / billing_invoices / billing_invoice_lines)=計 14 表の法定レコードは物理削除禁止。
| テーブル | 法的位置づけ |
|---|---|
manifests |
産廃管理票(廃掃法12条の3・5年保存) |
weighings |
計量=処分・取引実績 |
weighing_items |
計量内訳(按分結果) |
jobs |
委託=取引の正本 |
permits |
許可証(コンプラ照合根拠) |
partner_item_contracts |
委託契約・単価契約(5年保存) |
partners |
上記法定子の CASCADE 親 |
実装構成(フェーズ1)¶
| 役割 | migration | 内容 |
|---|---|---|
| soft-delete 列 | 00000000000064_legal_retention_soft_delete_columns.sql |
deleted_at timestamptz・deleted_by uuid を 7 表に追加(nullable) |
| 物理削除ブロック | 00000000000065_legal_retention_block_physical_delete.sql |
各表に BEFORE DELETE トリガ。app.bypass_legal_delete = 'on' セッション変数がない限り RAISE EXCEPTION で拒否(service_role・RPC・CASCADE 経由も阻止) |
| SELECT 除外 | 00000000000066_legal_retention_select_filter.sql |
基底 SELECT を deleted_at IS NULL でフィルタ。for all を SELECT/INSERT/UPDATE に分割し DELETE ポリシーは剥奪(weighing_items のみ DELETE 残置・下記) |
| 削除済みの一律非表示 | 00000000000068_legal_retention_drop_admin_reads_deleted.sql |
mig66 の permissive な admin reads deleted * ポリシーを撤去。admin を含む全員から削除済みを非表示にする(fail-safe)。参照/復元は admin 限定 DEFINER RPC で(下記) |
| soft-delete / restore RPC | 00000000000067_legal_retention_rpcs.sql |
soft_delete_legal_record / restore_legal_record(SECURITY DEFINER)。削除は必ず RPC 経由 |
| 監査 | 00000000000063_legal_retention_audit_log.sql |
audit_log テーブル(append-only)。soft_delete / restore イベントを記録 |
削除済みの参照・復元(permissive RLS は使わない)¶
soft-delete 済みレコードは admin を含む全員の通常 SELECT から非表示(mig68 で permissive な admin reads deleted * ポリシーを撤去)。「admin だけ削除済みを多く見る」を permissive RLS で表現しない — RLS はクエリの意図を区別できず、admin の全運用一覧へ削除済みが再露出して整合性事故(例: 取消済み計量の再完了)の温床になるため。削除済みの参照/復元は admin 限定の SECURITY DEFINER RPC で行う:
- 復元:
restore_legal_record(p_table, p_id)を record_id 指定で実行する(DEFINER で RLS を迂回するため SELECT 不可視でも復元可)。admin は復元対象のrecord_idをaudit_log(admin-only SELECT)から取得する。 - 削除済み一覧(ゴミ箱 UI): Later。実装時も permissive RLS ではなく admin 限定 DEFINER RPC で提供する。
weighing_items の例外¶
weighing_items は complete_weighing RPC(SECURITY INVOKER)が完了前の按分を再計算するため DELETE ポリシーを残している。ただし物理削除トリガは完了済み親を持つ items の削除を阻止する(complete_weighing は app.bypass_legal_delete GUC でトリガをバイパスして書込む)。
法定テーブルを新設するとき(§ 4 チェックリストも参照)¶
deleted_at timestamptz/deleted_by uuid列を nullable で追加(§1 加法ルール)BEFORE DELETEトリガを付与して物理削除を封じるdeleted_at IS NULLフィルタを SELECT RLS に加える(admin 含む全員に適用。削除済み閲覧用の permissive ポリシーは作らない — 参照/復元は admin 限定 DEFINER RPC で)- 表専用の
soft_delete_<table>DEFINER RPC を新設する(mig67 の allowlist は凍結・拡張しない) audit_logへの記録が動作することをテストで確認
4. 版番プレフィックス一意・空ファイル禁止・dry-run 確認¶
- ファイル名は 14桁ゼロ埋め連番(例:
00000000000068_…)。同じ番号を持つファイルが 2 つ以上あってはならない。 - 空ファイル禁止(中身のない
.sqlはゲートで弾かれる)。 - PR を作成すると
.github/workflows/supabase-migrations-check.ymlが自動起動し、以下を検証する: - 版番プレフィックスの重複チェック
- 空ファイル検出
- dev プロジェクトへの
supabase db push --dry-run(DB は変更しない)
ゲートを通過するまで PR はマージ不可。ローカルでも事前確認を推奨する。
5. 本番へ migration 番号の飛びを出さない¶
本番の migration 適用は厳格な番号順です。develop の検証環境は並行 PR の出順ずれを吸収するため include-all を許容しますが、develop が緑でも本番の番号順を保証しません。
- リリース前に develop と未マージの子ワークツリーを確認し、含まれる migration 番号を昇順で列挙する。
- 大きい番号だけを先に main へ出さない。小さい番号を同じリリースへ含めるか、未マージ側の番号を採番し直す。
- main へ出した後に小さい番号を追加すると prod の db push が before the last migration で停止するため、リリース順を逆転させない。
- 新規番号は自ワークツリーの最大値ではなく、他の未マージ作業の番号も含む最大値 + 1 から採る。
- 連番変更時は proof ファイル、sentinel 関数、期待値、AGENTS の参照を同じ変更で更新する。
6. デプロイ経路(develop → dev / main → 本番)¶
.github/workflows/supabase-deploy.yml が migration の適用を担う。
| イベント | 適用先 |
|---|---|
develop ブランチへの push |
dev プロジェクト(SUPABASE_DEV_DB_URL) |
main ブランチへの push |
本番プロジェクト(SUPABASE_PROD_DB_URL) |
supabase db push は schema_migrations を参照し未適用分のみを順に適用する(重複適用なし)。
main へのマージは必ずユーザー(PdM)の承認を経ること(CLAUDE.md 規定)。
手動適用(supabase db push 直接実行など)は禁止。Supabase MCP からの apply_migration も本プロジェクトでは禁止(接続先が Greco 本番でないため)。
まとめ: 新しい migration を書く前のチェックリスト¶
- [ ] 加法のみか(
IF NOT EXISTS・nullable 列・CREATE OR REPLACE・冪等な DROP→CREATE) - [ ]
WITH CHECKにEXISTS同一テナント検証を書いたか、tenant_idを完全修飾したか - [ ] 対象テーブルが法定 7 表に含まれる場合、soft-delete パターンに従っているか(物理 DELETE ではないか)
- [ ] 版番プレフィックスは一意で 14桁ゼロ埋めか、ファイルは空でないか
- [ ] PR ゲート(
supabase-migrations-check.yml)が緑か