-- AwoooP Phase 1: Control Plane Schema Foundation -- 2026-05-04 ogt + Claude Sonnet 4.6(ADR-111~118,Phase 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 role(PR-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_migration(BYPASSRLS) -- 預估執行時間:< 30 秒(全為新表,無既有資料修改) -- -- 回滾路徑: -- 見 awooop_phase1_control_plane_ROLLBACK.sql -- --------------------------------------------------------------------------- CREATE EXTENSION IF NOT EXISTS pgcrypto; -- =========================== -- Step 1: DB Roles(ADR-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(六合約共用 revision,append-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 hex(64 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 hex,draft 時 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_revisions(active 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 RESTRICT(C-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_outbox(ADR-113,C-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 到 projects(C-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_dedupe(ADR-114,M-1 Partition 版) -- =========================== -- pg_partman 維護 1 天 partition,retention 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_subjects(ADR-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_state(Strangler 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 VIEW(ADR-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 自動更新 trigger(Mi-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 Trigger(C-5 完整版,ADR-112 D2) -- =========================== -- 允許的 lifecycle 流轉: -- draft → published(publish 操作) -- 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 Guard(M-5,確保 active_revision_id 指向正確的 active revision) -- =========================== -- SECURITY DEFINER:trigger 以 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_* 表 RLS(ADR-118,C-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