Files
awoooi/apps/api/migrations/awooop_phase1_control_plane_2026-05-04.sql
Your Name 13e51802fe feat(awooop): Phase 0 全 ADR + Phase 1 control plane schema(含 critic 四項修正)
## Phase 0(文件層,全部 Accepted)
- ADR-106/107:AwoooP 平台架構 + 儲存策略
- ADR-111~118:Bootstrap → RLS 七項核心 ADR
- ADR-119~124:SAGA → Singleton Decomposition 六項 ADR
- ADR-UI-01~04:Operator Console 四個 UI ADR

## Phase 1(DB schema + migration)
- awooop_phase1_control_plane_2026-05-04.sql:7 張新表 + trigger + RLS
  - Step 1:三角色(platform_admin/migration BYPASSRLS,awooop_app 受 RLS)
  - Step 13:GRANT awooop_app 最小權限(7 條)
  - Step 14:RLS fail-closed,移除 __platform__ 後門
- awooop_phase1_batch1_rls_2026-05-04.sql:高流量四表三步式 ADD COLUMN
- awooop_phase1_batch1_backfill.py:SKIP LOCKED 分批回填腳本
- awooop_models.py:7 個 SQLAlchemy 2.x models

## Critic 修正(4 Critical + 3 Major)
- C-1:ADD CONSTRAINT IF NOT EXISTS → DO 塊 + pg_constraint 查詢
- C-2:__mapper_args__ 字串 list → primary_key=True on mapped_column
- C-3:__platform__ RLS 後門 → 全移除,改用 BYPASSRLS role
- C-4:awooop_app role 從未建立 → Step 1 + 7 條 GRANT
- M-1:active_pointer_guard SECURITY DEFINER(FORCE RLS 跨租戶保護)
- M-2:pg_partman create_parent 加冪等防護
- M-3:immutability trigger 新增身份欄位保護(project_id/family/contract_id)

## Task 1.2 修補
- agent_loader.py:硬編碼 Mac 路徑 → AGENTS_DIR 環境變數
- Dockerfile:補 COPY .claude/agents/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 13:37:11 +08:00

547 lines
22 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- AwoooP Phase 1: Control Plane Schema Foundation
-- 2026-05-04 ogt + Claude Sonnet 4.6ADR-111~118Phase 1 Task 1.3~1.7
-- 2026-05-04 db-expert review 修正版C-1/C-2/C-4/C-5/M-1/M-2/M-4/M-5/Mi-1/Mi-2/Mi-3
-- 2026-05-04 critic review 修正版awooop_app role 建立 + GRANT、移除 __platform__ 後門、
-- active_pointer_guard SECURITY DEFINER、pg_partman 冪等、immutability 強化
--
-- ⚠️ 部署順序鎖死ADR-118 RLS 前置條件):
-- 1. apps/api 必須先 deploy「會 SET LOCAL app.project_id」的版本
-- 2. K8s rollout 完成kubectl rollout status deploy/api = 100%
-- 3. 31 個 background loop 改用 awooop_platform_admin rolePR-10 完成)
-- 4. 以上完成後,才執行此 migration SQL
--
-- ⚠️ 不包含 Batch 1 高流量表incidents/knowledge_entries/playbooks/audit_logs
-- → 請執行 awooop_phase1_batch1_rls_2026-05-04.sql三步式 migration
--
-- 執行前確認:
-- SELECT relname, n_live_tup, pg_size_pretty(pg_total_relation_size(oid))
-- FROM pg_class WHERE relname IN ('incidents','knowledge_entries','playbooks','audit_logs');
--
-- 執行角色awooop_migrationBYPASSRLS
-- 預估執行時間:< 30 秒(全為新表,無既有資料修改)
--
-- 回滾路徑:
-- 見 awooop_phase1_control_plane_ROLLBACK.sql
-- ---------------------------------------------------------------------------
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ===========================
-- Step 1: DB RolesADR-118 D1
-- ===========================
DO $$
BEGIN
-- awooop_platform_admin: 平台管理BYPASSRLS背景 loop 使用)
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'awooop_platform_admin') THEN
CREATE ROLE awooop_platform_admin NOLOGIN;
END IF;
ALTER ROLE awooop_platform_admin BYPASSRLS;
-- awooop_migration: migration 執行BYPASSRLS只在 migration 期間使用)
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'awooop_migration') THEN
CREATE ROLE awooop_migration NOLOGIN;
END IF;
ALTER ROLE awooop_migration BYPASSRLS;
-- awooop_app: 應用程式角色(受 RLS 約束,需 SET LOCAL app.project_id
-- 必須在 GRANT 之前建立NOLOGIN 代表 app connection user 要 SET ROLE awooop_app
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'awooop_app') THEN
CREATE ROLE awooop_app NOLOGIN;
END IF;
END $$;
-- ===========================
-- Step 2: awooop_projects租戶主表
-- ===========================
CREATE TABLE IF NOT EXISTS awooop_projects (
project_id VARCHAR(64) PRIMARY KEY,
display_name VARCHAR(256) NOT NULL,
migration_mode VARCHAR(32) NOT NULL DEFAULT 'legacy_awoooi_default',
budget_limit_usd NUMERIC(14, 4) CHECK (budget_limit_usd IS NULL OR budget_limit_usd >= 0),
allowed_channels JSONB NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(allowed_channels) = 'array'),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_migration_mode CHECK (
migration_mode IN ('legacy_awoooi_default','shadow','canary','active')
)
);
CREATE INDEX IF NOT EXISTS idx_awooop_projects_active
ON awooop_projects(is_active) WHERE is_active = TRUE;
-- ===========================
-- Step 3: awooop_contract_revisions六合約共用 revisionappend-only
-- ===========================
CREATE TABLE IF NOT EXISTS awooop_contract_revisions (
revision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id VARCHAR(64) NOT NULL REFERENCES awooop_projects(project_id),
contract_family VARCHAR(32) NOT NULL,
contract_id VARCHAR(128) NOT NULL,
version_major SMALLINT NOT NULL DEFAULT 1 CHECK (version_major >= 0),
version_minor SMALLINT NOT NULL DEFAULT 0 CHECK (version_minor >= 0),
lifecycle_status VARCHAR(16) NOT NULL DEFAULT 'draft',
body_json JSONB NOT NULL,
-- body_hash: SHA-256 hex64 chars強制格式
body_hash VARCHAR(64) NOT NULL CHECK (body_hash ~ '^[0-9a-f]{64}$'),
body_schema_version VARCHAR(16) NOT NULL DEFAULT 'v1.0',
-- publish_signature: HMAC-SHA256 hexdraft 時 NULL
publish_signature VARCHAR(128) CHECK (
publish_signature IS NULL OR publish_signature ~ '^[0-9a-f]+$'
),
publisher_id VARCHAR(128),
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_revision_version
UNIQUE (project_id, contract_family, contract_id, version_major, version_minor),
CONSTRAINT chk_contract_family CHECK (
contract_family IN (
'project_tenant','agent','mcp_gateway','policy_routing',
'runtime_run_state','channel_event','platform_resource'
)
),
CONSTRAINT chk_lifecycle CHECK (
lifecycle_status IN ('draft','published','active','revoked')
)
);
-- runtime 讀取路徑:找某 contract 最新 published/active 版本
CREATE INDEX IF NOT EXISTS idx_revisions_lookup
ON awooop_contract_revisions
(project_id, contract_family, contract_id, lifecycle_status,
version_major DESC, version_minor DESC);
-- forensic 驗章反查
CREATE INDEX IF NOT EXISTS idx_revisions_hash
ON awooop_contract_revisions (body_hash);
-- ===========================
-- Step 4: awooop_active_revisionsactive pointer
-- ===========================
CREATE TABLE IF NOT EXISTS awooop_active_revisions (
pointer_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id VARCHAR(64) NOT NULL REFERENCES awooop_projects(project_id),
contract_family VARCHAR(32) NOT NULL,
contract_id VARCHAR(128) NOT NULL,
-- NOT NULL + ON DELETE RESTRICTC-1 修正)
active_revision_id UUID NOT NULL REFERENCES awooop_contract_revisions(revision_id)
ON DELETE RESTRICT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_active_pointer
UNIQUE (project_id, contract_family, contract_id)
);
-- ===========================
-- Step 5: awooop_contract_outboxADR-113C-2 修正版)
-- ===========================
CREATE TABLE IF NOT EXISTS awooop_contract_outbox (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type VARCHAR(64) NOT NULL,
-- FK 到 projectsC-2 修正outbox 不可是孤兒事件)
project_id VARCHAR(64) NOT NULL REFERENCES awooop_projects(project_id),
contract_family VARCHAR(32) NOT NULL,
contract_id VARCHAR(128) NOT NULL,
old_revision_id UUID REFERENCES awooop_contract_revisions(revision_id),
new_revision_id UUID NOT NULL REFERENCES awooop_contract_revisions(revision_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
delivered_at TIMESTAMPTZ,
relay_attempts INT NOT NULL DEFAULT 0,
-- C-2 新增exponential backoff 支援
next_retry_at TIMESTAMPTZ,
last_error TEXT,
-- C-2 新增:上游 publisher 重試去重(同一 revision 的同一事件類型只記一次)
CONSTRAINT uq_outbox_event UNIQUE (new_revision_id, event_type)
);
-- relay worker 主查詢:未投遞 + 可重試(含 next_retry_at NULL = 立即重試)
CREATE INDEX IF NOT EXISTS idx_outbox_pending
ON awooop_contract_outbox (next_retry_at NULLS FIRST, created_at)
WHERE delivered_at IS NULL;
-- 觀察用per project backlog 體量
CREATE INDEX IF NOT EXISTS idx_outbox_backlog_per_project
ON awooop_contract_outbox (project_id, created_at)
WHERE delivered_at IS NULL;
-- ===========================
-- Step 6: awooop_channel_event_dedupeADR-114M-1 Partition 版)
-- ===========================
-- pg_partman 維護 1 天 partitionretention 7 天DROP PARTITION 毫秒清完
CREATE TABLE IF NOT EXISTS awooop_channel_event_dedupe (
dedupe_id UUID NOT NULL DEFAULT gen_random_uuid(),
project_id VARCHAR(64) NOT NULL,
channel_type VARCHAR(32) NOT NULL,
provider_event_id VARCHAR(256) NOT NULL,
run_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Partition key 必須是 PK 的一部分declarative partition 要求)
PRIMARY KEY (dedupe_id, created_at),
CONSTRAINT uq_channel_event_dedupe
UNIQUE (project_id, channel_type, provider_event_id, created_at)
) PARTITION BY RANGE (created_at);
-- 初始化 pg_partman若 pg_partman 已安裝)
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_partman') THEN
-- 冪等:已在 part_config 則跳過 create_parent重跑 migration 安全)
IF NOT EXISTS (
SELECT 1 FROM partman.part_config
WHERE parent_table = 'public.awooop_channel_event_dedupe'
) THEN
PERFORM partman.create_parent(
p_parent_table := 'public.awooop_channel_event_dedupe',
p_control := 'created_at',
p_type := 'native',
p_interval := '1 day',
p_premake := 4
);
END IF;
UPDATE partman.part_config
SET retention = '7 days',
retention_keep_table = false
WHERE parent_table = 'public.awooop_channel_event_dedupe';
ELSE
-- pg_partman 未安裝:手動建前 14 天 partition含今日 ±7 天)
DECLARE
d DATE;
BEGIN
FOR d IN
SELECT generate_series(
CURRENT_DATE - INTERVAL '7 days',
CURRENT_DATE + INTERVAL '7 days',
INTERVAL '1 day'
)::DATE
LOOP
EXECUTE format(
'CREATE TABLE IF NOT EXISTS awooop_channel_event_dedupe_%s
PARTITION OF awooop_channel_event_dedupe
FOR VALUES FROM (%L) TO (%L)',
to_char(d, 'YYYYMMDD'),
d::TIMESTAMPTZ,
(d + INTERVAL '1 day')::TIMESTAMPTZ
);
END LOOP;
END;
END IF;
END $$;
-- run_id 反查Mi-5
CREATE INDEX IF NOT EXISTS idx_dedupe_run
ON awooop_channel_event_dedupe (run_id);
-- ===========================
-- Step 7: awooop_platform_subjectsADR-115
-- ===========================
CREATE TABLE IF NOT EXISTS awooop_platform_subjects (
subject_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id VARCHAR(64) NOT NULL REFERENCES awooop_projects(project_id),
channel_type VARCHAR(32) NOT NULL,
channel_user_id VARCHAR(256) NOT NULL,
channel_chat_id VARCHAR(256),
platform_subject_id VARCHAR(128) NOT NULL,
display_name VARCHAR(256),
roles JSONB NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(roles) = 'array'),
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_platform_subject
UNIQUE (project_id, channel_type, channel_user_id)
);
CREATE INDEX IF NOT EXISTS idx_platform_subjects_lookup
ON awooop_platform_subjects (project_id, channel_type, channel_user_id);
-- platform_subject_id 反查Operator Console M2 用)
CREATE INDEX IF NOT EXISTS idx_platform_subjects_resolve
ON awooop_platform_subjects (project_id, platform_subject_id);
-- 近期活躍 user 查詢
CREATE INDEX IF NOT EXISTS idx_platform_subjects_last_seen
ON awooop_platform_subjects (project_id, last_seen_at DESC);
-- ===========================
-- Step 8: awooop_project_migration_stateStrangler Fig 追蹤)
-- ===========================
CREATE TABLE IF NOT EXISTS awooop_project_migration_state (
state_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id VARCHAR(64) NOT NULL REFERENCES awooop_projects(project_id),
capability VARCHAR(64) NOT NULL,
current_phase VARCHAR(32) NOT NULL DEFAULT 'legacy_awoooi_default',
phase_entered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_project_capability UNIQUE (project_id, capability),
CONSTRAINT chk_capability CHECK (
capability IN (
'run_execution','contract_governance',
'budget_tracking','principal_mapping'
)
),
CONSTRAINT chk_phase CHECK (
current_phase IN (
'legacy_awoooi_default','shadow','canary',
'read_only','suggest','auto_remediate'
)
)
);
-- ===========================
-- Step 9: awooop_published_revisions VIEWADR-112 D6 draft 隔離)
-- ===========================
CREATE OR REPLACE VIEW awooop_published_revisions AS
SELECT *
FROM awooop_contract_revisions
WHERE lifecycle_status IN ('published', 'active');
-- ===========================
-- Step 10: updated_at 自動更新 triggerMi-1
-- ===========================
CREATE OR REPLACE FUNCTION awooop_set_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$;
DO $$
DECLARE
t TEXT;
BEGIN
FOREACH t IN ARRAY ARRAY[
'awooop_projects',
'awooop_active_revisions',
'awooop_platform_subjects',
'awooop_project_migration_state'
] LOOP
EXECUTE format(
'DROP TRIGGER IF EXISTS trg_%s_updated_at ON %I;
CREATE TRIGGER trg_%s_updated_at
BEFORE UPDATE ON %I
FOR EACH ROW EXECUTE FUNCTION awooop_set_updated_at();',
t, t, t, t
);
END LOOP;
END $$;
-- ===========================
-- Step 11: Immutability TriggerC-5 完整版ADR-112 D2
-- ===========================
-- 允許的 lifecycle 流轉:
-- draft → publishedpublish 操作)
-- published → active activate 操作)
-- active → revoked revoke 操作)
-- 禁止body/hash/signature/version 在 published/active/revoked 後修改
CREATE OR REPLACE FUNCTION awooop_revision_immutability_guard()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
-- 所有 lifecycle_status 下都禁止修改身份欄位project_id/family/contract_id
IF NEW.project_id IS DISTINCT FROM OLD.project_id
OR NEW.contract_family IS DISTINCT FROM OLD.contract_family
OR NEW.contract_id IS DISTINCT FROM OLD.contract_id
THEN
RAISE EXCEPTION
'revision % identity fields (project_id/contract_family/contract_id) are immutable',
OLD.revision_id;
END IF;
-- draft 可以自由修改,離開 draft 後鎖住核心欄位
IF OLD.lifecycle_status IN ('published', 'active', 'revoked') THEN
IF NEW.body_json IS DISTINCT FROM OLD.body_json
OR NEW.body_hash IS DISTINCT FROM OLD.body_hash
OR NEW.publish_signature IS DISTINCT FROM OLD.publish_signature
OR NEW.version_major IS DISTINCT FROM OLD.version_major
OR NEW.version_minor IS DISTINCT FROM OLD.version_minor
OR NEW.publisher_id IS DISTINCT FROM OLD.publisher_id
OR NEW.published_at IS DISTINCT FROM OLD.published_at
OR NEW.body_schema_version IS DISTINCT FROM OLD.body_schema_version
THEN
RAISE EXCEPTION
'revision % (%) is immutable: body/signature/version cannot be changed',
OLD.revision_id, OLD.lifecycle_status;
END IF;
END IF;
-- lifecycle_status 流轉白名單
IF NEW.lifecycle_status IS DISTINCT FROM OLD.lifecycle_status THEN
IF NOT (
(OLD.lifecycle_status = 'draft' AND NEW.lifecycle_status = 'published') OR
(OLD.lifecycle_status = 'published' AND NEW.lifecycle_status = 'active') OR
(OLD.lifecycle_status = 'active' AND NEW.lifecycle_status = 'revoked')
) THEN
RAISE EXCEPTION
'illegal lifecycle transition on revision %: % -> %',
OLD.revision_id, OLD.lifecycle_status, NEW.lifecycle_status;
END IF;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_revision_immutability ON awooop_contract_revisions;
CREATE TRIGGER trg_revision_immutability
BEFORE UPDATE ON awooop_contract_revisions
FOR EACH ROW EXECUTE FUNCTION awooop_revision_immutability_guard();
-- DELETE 完全禁止append-only 語意)
CREATE OR REPLACE FUNCTION awooop_revision_no_delete()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION
'awooop_contract_revisions is append-only: DELETE forbidden on revision %',
OLD.revision_id;
END;
$$;
DROP TRIGGER IF EXISTS trg_revision_no_delete ON awooop_contract_revisions;
CREATE TRIGGER trg_revision_no_delete
BEFORE DELETE ON awooop_contract_revisions
FOR EACH ROW EXECUTE FUNCTION awooop_revision_no_delete();
-- ===========================
-- Step 12: Active Pointer GuardM-5確保 active_revision_id 指向正確的 active revision
-- ===========================
-- SECURITY DEFINERtrigger 以 migration 擁有者執行,繞過 awooop_contract_revisions 的 RLS
-- 確保跨租戶指向檢測FORCE RLS 下 SECURITY INVOKER 只能看自己租戶的 revision
CREATE OR REPLACE FUNCTION awooop_active_pointer_guard()
RETURNS TRIGGER LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_catalog
AS $$
DECLARE
rev RECORD;
BEGIN
SELECT project_id, contract_family, contract_id, lifecycle_status
INTO rev
FROM awooop_contract_revisions
WHERE revision_id = NEW.active_revision_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'revision % not found', NEW.active_revision_id;
END IF;
IF rev.project_id <> NEW.project_id
OR rev.contract_family <> NEW.contract_family
OR rev.contract_id <> NEW.contract_id
THEN
RAISE EXCEPTION
'active pointer contract identity mismatch: pointer=(%,%,%) revision=(%,%,%)',
NEW.project_id, NEW.contract_family, NEW.contract_id,
rev.project_id, rev.contract_family, rev.contract_id;
END IF;
IF rev.lifecycle_status <> 'active' THEN
RAISE EXCEPTION
'active pointer must reference an active revision (got %)', rev.lifecycle_status;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_active_pointer_guard ON awooop_active_revisions;
CREATE TRIGGER trg_active_pointer_guard
BEFORE INSERT OR UPDATE ON awooop_active_revisions
FOR EACH ROW EXECUTE FUNCTION awooop_active_pointer_guard();
-- ===========================
-- Step 13: GRANT awooop_app 基本操作權限
-- ===========================
-- awooop_app 受 RLS 約束,需設定 app.project_id 才能存取資料
-- awooop_platform_admin / awooop_migration 有 BYPASSRLS不需 GRANT直接用 superuser 連線)
GRANT SELECT, INSERT, UPDATE, DELETE ON awooop_contract_revisions TO awooop_app;
GRANT SELECT, INSERT, UPDATE ON awooop_active_revisions TO awooop_app;
GRANT SELECT, INSERT ON awooop_contract_outbox TO awooop_app;
GRANT SELECT, INSERT ON awooop_channel_event_dedupe TO awooop_app;
GRANT SELECT, INSERT, UPDATE ON awooop_platform_subjects TO awooop_app;
GRANT SELECT ON awooop_projects TO awooop_app;
GRANT SELECT ON awooop_project_migration_state TO awooop_app;
GRANT SELECT ON awooop_published_revisions TO awooop_app;
-- ===========================
-- Step 14: awooop_* 表 RLSADR-118C-4 fail-closed 修正版)
-- ===========================
-- ⚠️ fail-closed沒有 SET LOCAL app.project_id 的 session 看不到任何資料
-- ⚠️ awooop_platform_admin / awooop_migration 已 BYPASSRLS不受 policy 約束
-- ⚠️ WITH CHECK 防止 INSERT 時塞入不同 tenant 的 project_id
-- ⚠️ 移除 __platform__ 後門critic C-3 修正):平台層改用 BYPASSRLS 角色,不靠 GUC 魔術字串
ALTER TABLE awooop_contract_revisions ENABLE ROW LEVEL SECURITY;
ALTER TABLE awooop_contract_revisions FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS contract_revisions_tenant ON awooop_contract_revisions;
CREATE POLICY contract_revisions_tenant ON awooop_contract_revisions
FOR ALL TO awooop_app
USING (project_id = current_setting('app.project_id', TRUE))
WITH CHECK (project_id = current_setting('app.project_id', TRUE));
ALTER TABLE awooop_active_revisions ENABLE ROW LEVEL SECURITY;
ALTER TABLE awooop_active_revisions FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS active_revisions_tenant ON awooop_active_revisions;
CREATE POLICY active_revisions_tenant ON awooop_active_revisions
FOR ALL TO awooop_app
USING (project_id = current_setting('app.project_id', TRUE))
WITH CHECK (project_id = current_setting('app.project_id', TRUE));
ALTER TABLE awooop_platform_subjects ENABLE ROW LEVEL SECURITY;
ALTER TABLE awooop_platform_subjects FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS platform_subjects_tenant ON awooop_platform_subjects;
CREATE POLICY platform_subjects_tenant ON awooop_platform_subjects
FOR ALL TO awooop_app
USING (project_id = current_setting('app.project_id', TRUE))
WITH CHECK (project_id = current_setting('app.project_id', TRUE));
-- ===========================
-- Step 15: AWOOOI 種子資料ADR-111 bootstrap
-- ===========================
INSERT INTO awooop_projects (project_id, display_name, migration_mode, is_active)
VALUES ('awoooi', 'AWOOOI', 'legacy_awoooi_default', TRUE)
ON CONFLICT (project_id) DO NOTHING;
INSERT INTO awooop_project_migration_state (project_id, capability, current_phase)
VALUES
('awoooi', 'run_execution', 'legacy_awoooi_default'),
('awoooi', 'contract_governance', 'legacy_awoooi_default'),
('awoooi', 'budget_tracking', 'legacy_awoooi_default'),
('awoooi', 'principal_mapping', 'legacy_awoooi_default')
ON CONFLICT (project_id, capability) DO NOTHING;
-- ===========================
-- 驗收查詢(執行後人工確認)
-- ===========================
-- \dt awooop_*
-- SELECT project_id, display_name, migration_mode FROM awooop_projects;
-- SELECT project_id, capability, current_phase FROM awooop_project_migration_state;
-- SELECT tablename, rowsecurity, forcerowsecurity FROM pg_tables
-- WHERE tablename LIKE 'awooop_%';
-- -- RLS fail-closed 測試:
-- SET LOCAL app.project_id = 'ewoooc';
-- SELECT count(*) FROM awooop_contract_revisions; -- 應回傳 0'ewoooc' 不存在 projects
-- SET LOCAL app.project_id = 'awoooi';
-- SELECT count(*) FROM awooop_projects; -- 應回傳 1