diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index dc44364ee..53ebfaa91 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -60,6 +60,9 @@ COPY k8s/ ./k8s/ # 2026-04-10 Claude Sonnet 4.6: RAG 知識庫索引來源 (ADR-067 Phase 33) COPY docs/ ./docs/ COPY .agents/skills/ ./.agents/skills/ +# 2026-05-04 Claude Sonnet 4.6 (Task 1.2): hermes agent_loader 的 system prompt 來源 +# agent_loader.py 預設讀 /app/.claude/agents/,對應 K8s AGENTS_DIR 環境變數 +COPY .claude/agents/ ./.claude/agents/ # 2026-04-12 ogt (ADR-073 P2-1): CronJob 腳本 — 獨立腳本取代 inline Python COPY scripts/ ./scripts/ diff --git a/apps/api/migrations/awooop_phase1_batch1_rls_2026-05-04.sql b/apps/api/migrations/awooop_phase1_batch1_rls_2026-05-04.sql new file mode 100644 index 000000000..3f726c926 --- /dev/null +++ b/apps/api/migrations/awooop_phase1_batch1_rls_2026-05-04.sql @@ -0,0 +1,271 @@ +-- AwoooP Phase 1 Batch 1: 現有四表加 project_id + RLS +-- 2026-05-04 ogt + Claude Sonnet 4.6(ADR-118 Batch 1,C-3/C-4 db-expert 修正版) +-- 2026-05-04 critic 修正版:ADD CONSTRAINT IF NOT EXISTS 不存在於 PG → 改用 DO 塊檢查 pg_constraint +-- +-- 對象:incidents / knowledge_entries / playbooks / audit_logs +-- 這四張表是高頻寫入表,採「三步式 migration」避免長時間鎖表: +-- +-- Step A: ADD COLUMN nullable(metadata-only,瞬間) +-- Step B: 分批回填(每批 5000 筆,外部腳本呼叫) +-- Step C: NOT VALID CHECK → VALIDATE(SHARE UPDATE EXCLUSIVE,不擋讀寫) +-- → SET NOT NULL(PG 12+ 利用已驗證 check,不掃表) +-- → SET DEFAULT 'awoooi' +-- +-- ⚠️ 執行前必確認: +-- 1. awooop_phase1_control_plane_2026-05-04.sql 已執行(awooop_projects 表存在) +-- 2. apps/api 已 deploy 「SET LOCAL app.project_id」版本,rollout 100% +-- 3. 31 個 background loop 改用 awooop_platform_admin role(PR-10) +-- 4. 量測各表體量(見下方 pre-migration check query) +-- +-- Pre-migration check: +-- 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'); +-- +-- 分批回填腳本: +-- apps/api/scripts/awooop_phase1_batch1_backfill.py(另行提供) +-- +-- ⚠️ RLS 是 fail-closed: +-- SET LOCAL app.project_id 未設 → 讀不到任何資料(C-4 修正) +-- WITH CHECK 防止 INSERT 寫入錯誤 tenant +-- +-- 回滾路徑: +-- ALTER TABLE incidents DISABLE ROW LEVEL SECURITY; +-- DROP POLICY IF EXISTS incidents_tenant_isolation ON incidents; +-- DROP POLICY IF EXISTS knowledge_entries_tenant_isolation ON knowledge_entries; +-- DROP POLICY IF EXISTS playbooks_tenant_isolation ON playbooks; +-- DROP POLICY IF EXISTS audit_logs_tenant_isolation ON audit_logs; +-- ALTER TABLE incidents DISABLE ROW LEVEL SECURITY; +-- ALTER TABLE knowledge_entries DISABLE ROW LEVEL SECURITY; +-- ALTER TABLE playbooks DISABLE ROW LEVEL SECURITY; +-- ALTER TABLE audit_logs DISABLE ROW LEVEL SECURITY; +-- ALTER TABLE incidents DROP COLUMN IF EXISTS project_id; +-- ALTER TABLE knowledge_entries DROP COLUMN IF EXISTS project_id; +-- ALTER TABLE playbooks DROP COLUMN IF EXISTS project_id; +-- ALTER TABLE audit_logs DROP COLUMN IF EXISTS project_id; +-- --------------------------------------------------------------------------- + + +-- =========================== +-- STEP A: ADD COLUMN(nullable,瞬間取鎖,不重寫表) +-- =========================== +-- 一次只做 ADD COLUMN,讓 AccessExclusiveLock 最短 + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'incidents' AND column_name = 'project_id' + ) THEN + ALTER TABLE incidents ADD COLUMN project_id VARCHAR(64); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'knowledge_entries' AND column_name = 'project_id' + ) THEN + ALTER TABLE knowledge_entries ADD COLUMN project_id VARCHAR(64); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'playbooks' AND column_name = 'project_id' + ) THEN + ALTER TABLE playbooks ADD COLUMN project_id VARCHAR(64); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'audit_logs' AND column_name = 'project_id' + ) THEN + ALTER TABLE audit_logs ADD COLUMN project_id VARCHAR(64); + END IF; +END $$; + + +-- =========================== +-- STEP B: 分批回填(外部腳本) +-- =========================== +-- 此步驟由 apps/api/scripts/awooop_phase1_batch1_backfill.py 執行 +-- 每批 UPDATE ... WHERE project_id IS NULL LIMIT 5000 +-- 完成條件:SELECT count(*) FROM incidents WHERE project_id IS NULL; → 0 +-- +-- 快速驗證(執行此 SQL 前必須確認回填完成): +-- SELECT +-- 'incidents' as tbl, count(*) as null_count FROM incidents WHERE project_id IS NULL +-- UNION ALL SELECT 'knowledge_entries', count(*) FROM knowledge_entries WHERE project_id IS NULL +-- UNION ALL SELECT 'playbooks', count(*) FROM playbooks WHERE project_id IS NULL +-- UNION ALL SELECT 'audit_logs', count(*) FROM audit_logs WHERE project_id IS NULL; +-- 所有 null_count 必須為 0,否則停止。 +-- +-- ⚠️ 回填完成確認後才可繼續執行 Step C + + +-- =========================== +-- STEP C: NOT NULL 強制 + DEFAULT + Index + RLS +-- =========================== +-- PostgreSQL 12+:NOT VALID CHECK → VALIDATE → SET NOT NULL +-- VALIDATE 只取 SHARE UPDATE EXCLUSIVE,不擋讀寫 +-- SET NOT NULL 在 VALIDATE 後不再掃表(利用 check constraint 証明) + +-- --- incidents --- + +-- PostgreSQL 無 ADD CONSTRAINT IF NOT EXISTS,改用 DO 塊檢查 pg_constraint +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'chk_incidents_project_id_not_null' + AND conrelid = 'incidents'::regclass + ) THEN + ALTER TABLE incidents + ADD CONSTRAINT chk_incidents_project_id_not_null + CHECK (project_id IS NOT NULL) NOT VALID; + END IF; +END $$; + +ALTER TABLE incidents + VALIDATE CONSTRAINT chk_incidents_project_id_not_null; + +ALTER TABLE incidents ALTER COLUMN project_id SET NOT NULL; +ALTER TABLE incidents ALTER COLUMN project_id SET DEFAULT 'awoooi'; +ALTER TABLE incidents DROP CONSTRAINT IF EXISTS chk_incidents_project_id_not_null; + +CREATE INDEX IF NOT EXISTS idx_incidents_project_id ON incidents (project_id); + +ALTER TABLE incidents ENABLE ROW LEVEL SECURITY; +ALTER TABLE incidents FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS incidents_tenant_isolation ON incidents; +CREATE POLICY incidents_tenant_isolation ON incidents + FOR ALL TO awooop_app + USING (project_id = current_setting('app.project_id', TRUE)) + WITH CHECK (project_id = current_setting('app.project_id', TRUE)); + + +-- --- knowledge_entries --- + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'chk_km_project_id_not_null' + AND conrelid = 'knowledge_entries'::regclass + ) THEN + ALTER TABLE knowledge_entries + ADD CONSTRAINT chk_km_project_id_not_null + CHECK (project_id IS NOT NULL) NOT VALID; + END IF; +END $$; + +ALTER TABLE knowledge_entries + VALIDATE CONSTRAINT chk_km_project_id_not_null; + +ALTER TABLE knowledge_entries ALTER COLUMN project_id SET NOT NULL; +ALTER TABLE knowledge_entries ALTER COLUMN project_id SET DEFAULT 'awoooi'; +ALTER TABLE knowledge_entries DROP CONSTRAINT IF EXISTS chk_km_project_id_not_null; + +CREATE INDEX IF NOT EXISTS idx_knowledge_entries_project_id ON knowledge_entries (project_id); + +ALTER TABLE knowledge_entries ENABLE ROW LEVEL SECURITY; +ALTER TABLE knowledge_entries FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS knowledge_entries_tenant_isolation ON knowledge_entries; +CREATE POLICY knowledge_entries_tenant_isolation ON knowledge_entries + FOR ALL TO awooop_app + USING (project_id = current_setting('app.project_id', TRUE)) + WITH CHECK (project_id = current_setting('app.project_id', TRUE)); + + +-- --- playbooks --- + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'chk_playbooks_project_id_not_null' + AND conrelid = 'playbooks'::regclass + ) THEN + ALTER TABLE playbooks + ADD CONSTRAINT chk_playbooks_project_id_not_null + CHECK (project_id IS NOT NULL) NOT VALID; + END IF; +END $$; + +ALTER TABLE playbooks + VALIDATE CONSTRAINT chk_playbooks_project_id_not_null; + +ALTER TABLE playbooks ALTER COLUMN project_id SET NOT NULL; +ALTER TABLE playbooks ALTER COLUMN project_id SET DEFAULT 'awoooi'; +ALTER TABLE playbooks DROP CONSTRAINT IF EXISTS chk_playbooks_project_id_not_null; + +CREATE INDEX IF NOT EXISTS idx_playbooks_project_id ON playbooks (project_id); + +ALTER TABLE playbooks ENABLE ROW LEVEL SECURITY; +ALTER TABLE playbooks FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS playbooks_tenant_isolation ON playbooks; +CREATE POLICY playbooks_tenant_isolation ON playbooks + FOR ALL TO awooop_app + USING (project_id = current_setting('app.project_id', TRUE)) + WITH CHECK (project_id = current_setting('app.project_id', TRUE)); + + +-- --- audit_logs --- + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'chk_audit_project_id_not_null' + AND conrelid = 'audit_logs'::regclass + ) THEN + ALTER TABLE audit_logs + ADD CONSTRAINT chk_audit_project_id_not_null + CHECK (project_id IS NOT NULL) NOT VALID; + END IF; +END $$; + +ALTER TABLE audit_logs + VALIDATE CONSTRAINT chk_audit_project_id_not_null; + +ALTER TABLE audit_logs ALTER COLUMN project_id SET NOT NULL; +ALTER TABLE audit_logs ALTER COLUMN project_id SET DEFAULT 'awoooi'; +ALTER TABLE audit_logs DROP CONSTRAINT IF EXISTS chk_audit_project_id_not_null; + +CREATE INDEX IF NOT EXISTS idx_audit_logs_project_id ON audit_logs (project_id); + +ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY; +ALTER TABLE audit_logs FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS audit_logs_tenant_isolation ON audit_logs; +CREATE POLICY audit_logs_tenant_isolation ON audit_logs + FOR ALL TO awooop_app + USING (project_id = current_setting('app.project_id', TRUE)) + WITH CHECK (project_id = current_setting('app.project_id', TRUE)); + + +-- =========================== +-- 驗收查詢 +-- =========================== +-- SELECT tablename, rowsecurity, forcerowsecurity FROM pg_tables +-- WHERE tablename IN ('incidents','knowledge_entries','playbooks','audit_logs'); +-- +-- -- RLS fail-closed 測試(需 awooop_app role 執行): +-- SET ROLE awooop_app; +-- SET LOCAL app.project_id = 'ewoooc'; +-- SELECT count(*) FROM incidents; -- 應 = 0(無 ewoooc 資料) +-- SET LOCAL app.project_id = 'awoooi'; +-- SELECT count(*) FROM incidents; -- 應 = 全部既有資料筆數 +-- RESET ROLE; +-- +-- -- 確認無 NULL project_id: +-- SELECT count(*) FROM incidents WHERE project_id IS NULL; -- = 0 +-- SELECT count(*) FROM knowledge_entries WHERE project_id IS NULL; -- = 0 +-- SELECT count(*) FROM playbooks WHERE project_id IS NULL; -- = 0 +-- SELECT count(*) FROM audit_logs WHERE project_id IS NULL; -- = 0 diff --git a/apps/api/migrations/awooop_phase1_control_plane_2026-05-04.sql b/apps/api/migrations/awooop_phase1_control_plane_2026-05-04.sql new file mode 100644 index 000000000..b412708a0 --- /dev/null +++ b/apps/api/migrations/awooop_phase1_control_plane_2026-05-04.sql @@ -0,0 +1,546 @@ +-- 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 diff --git a/apps/api/scripts/awooop_phase1_batch1_backfill.py b/apps/api/scripts/awooop_phase1_batch1_backfill.py new file mode 100644 index 000000000..9cc13f294 --- /dev/null +++ b/apps/api/scripts/awooop_phase1_batch1_backfill.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +AwoooP Phase 1 Batch 1 回填腳本 +================================ +對 incidents / knowledge_entries / playbooks / audit_logs 四張表 +分批將 project_id IS NULL 的列回填為 'awoooi'。 + +前置條件: + awooop_phase1_batch1_rls_2026-05-04.sql Step A(ADD COLUMN nullable)已執行 + +執行方式: + export DATABASE_URL="postgresql+asyncpg://awoooi:@192.168.0.188:5432/awoooi_prod" + cd apps/api && python scripts/awooop_phase1_batch1_backfill.py + +2026-05-04 ogt + Claude Sonnet 4.6(ADR-118 Batch 1 C-3 修正) +""" + +import asyncio +import os +import time + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +DATABASE_URL = os.environ["DATABASE_URL"] + +TABLES = [ + ("incidents", "incident_id"), + ("knowledge_entries", "id"), + ("playbooks", "id"), + ("audit_logs", "id"), +] + +BATCH_SIZE = 5000 +SLEEP_MS = 100 # 批次間休眠 ms,降低對正常流量的影響 + + +async def count_nulls(conn, table: str) -> int: + result = await conn.execute( + text(f"SELECT count(*) FROM {table} WHERE project_id IS NULL") # noqa: S608 + ) + return result.scalar() + + +async def backfill_table(engine, table: str, pk_col: str) -> int: + total_updated = 0 + print(f"\n[{table}] 開始回填...") + + while True: + async with engine.begin() as conn: + result = await conn.execute(text(f""" + UPDATE {table} + SET project_id = 'awoooi' + WHERE {pk_col} IN ( + SELECT {pk_col} FROM {table} + WHERE project_id IS NULL + LIMIT :batch_size + FOR UPDATE SKIP LOCKED + ) + """), {"batch_size": BATCH_SIZE}) + rows = result.rowcount + + total_updated += rows + if rows == 0: + break + + print(f" [{table}] 已回填 {total_updated} 筆...") + await asyncio.sleep(SLEEP_MS / 1000) + + print(f" [{table}] 回填完成,共 {total_updated} 筆") + return total_updated + + +async def verify(engine) -> bool: + print("\n=== 驗收確認 ===") + ok = True + async with engine.connect() as conn: + for table, _ in TABLES: + null_count = await count_nulls(conn, table) + status = "✅" if null_count == 0 else "❌" + print(f" {status} {table}: {null_count} 筆 NULL project_id") + if null_count != 0: + ok = False + return ok + + +async def main(): + print("=" * 60) + print("AwoooP Phase 1 Batch 1 Backfill") + print("=" * 60) + + engine = create_async_engine(DATABASE_URL, echo=False) + t0 = time.monotonic() + + for table, pk_col in TABLES: + await backfill_table(engine, table, pk_col) + + passed = await verify(engine) + elapsed = time.monotonic() - t0 + + print(f"\n{'✅ 所有表回填完成' if passed else '❌ 仍有 NULL,請重跑'}") + print(f"耗時:{elapsed:.1f}s") + print() + if passed: + print("下一步:執行 awooop_phase1_batch1_rls_2026-05-04.sql 的 Step C") + else: + print("⚠️ 請確認無長 transaction 持有 SKIP LOCKED 的列後重跑") + + await engine.dispose() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/apps/api/src/db/awooop_models.py b/apps/api/src/db/awooop_models.py new file mode 100644 index 000000000..119081326 --- /dev/null +++ b/apps/api/src/db/awooop_models.py @@ -0,0 +1,311 @@ +""" +AwoooP Control Plane Models +============================ +Phase 1 新表:六合約 control plane、tenant 隔離、principal mapping。 +ADR-111~118,2026-05-04 ogt + Claude Sonnet 4.6 +""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy import ( + Boolean, + CheckConstraint, + ForeignKey, + Index, + Integer, + Numeric, + SmallInteger, + String, + Text, + UniqueConstraint, + text, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from src.db.base import Base + + +class AwoooPProject(Base): + """租戶主表(ADR-111 bootstrap,ADR-115 tenant onboarding)""" + + __tablename__ = "awooop_projects" + __table_args__ = ( + CheckConstraint( + "migration_mode IN ('legacy_awoooi_default','shadow','canary','active')", + name="chk_migration_mode", + ), + CheckConstraint( + "budget_limit_usd IS NULL OR budget_limit_usd >= 0", + name="chk_budget_non_negative", + ), + CheckConstraint( + "jsonb_typeof(allowed_channels) = 'array'", + name="chk_allowed_channels_array", + ), + ) + + project_id: Mapped[str] = mapped_column(String(64), primary_key=True) + display_name: Mapped[str] = mapped_column(String(256), nullable=False) + migration_mode: Mapped[str] = mapped_column( + String(32), nullable=False, default="legacy_awoooi_default" + ) + budget_limit_usd: Mapped[Decimal | None] = mapped_column( + Numeric(14, 4), nullable=True + ) + allowed_channels: Mapped[list[Any]] = mapped_column( + JSONB, nullable=False, server_default=text("'[]'::jsonb") + ) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[datetime] = mapped_column( + nullable=False, server_default=text("NOW()") + ) + updated_at: Mapped[datetime] = mapped_column( + nullable=False, server_default=text("NOW()") + ) + + +class AwoooPContractRevision(Base): + """六合約共用 revision 表(append-only,ADR-107/ADR-112)""" + + __tablename__ = "awooop_contract_revisions" + __table_args__ = ( + UniqueConstraint( + "project_id", "contract_family", "contract_id", + "version_major", "version_minor", + name="uq_revision_version", + ), + CheckConstraint( + "contract_family IN (" + "'project_tenant','agent','mcp_gateway','policy_routing'," + "'runtime_run_state','channel_event','platform_resource')", + name="chk_contract_family", + ), + CheckConstraint( + "lifecycle_status IN ('draft','published','active','revoked')", + name="chk_lifecycle", + ), + CheckConstraint("version_major >= 0", name="chk_version_major_non_neg"), + CheckConstraint("version_minor >= 0", name="chk_version_minor_non_neg"), + CheckConstraint( + r"body_hash ~ '^[0-9a-f]{64}$'", name="chk_body_hash_format" + ), + Index( + "idx_revisions_lookup", + "project_id", "contract_family", "contract_id", + "lifecycle_status", "version_major", "version_minor", + ), + Index("idx_revisions_hash", "body_hash"), + ) + + revision_id: Mapped[UUID] = mapped_column( + primary_key=True, server_default=text("gen_random_uuid()") + ) + project_id: Mapped[str] = mapped_column( + String(64), ForeignKey("awooop_projects.project_id"), nullable=False + ) + contract_family: Mapped[str] = mapped_column(String(32), nullable=False) + contract_id: Mapped[str] = mapped_column(String(128), nullable=False) + version_major: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1) + version_minor: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0) + lifecycle_status: Mapped[str] = mapped_column( + String(16), nullable=False, default="draft" + ) + body_json: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + body_hash: Mapped[str] = mapped_column(String(64), nullable=False) + body_schema_version: Mapped[str] = mapped_column( + String(16), nullable=False, default="v1.0" + ) + publish_signature: Mapped[str | None] = mapped_column(String(128), nullable=True) + publisher_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + published_at: Mapped[datetime | None] = mapped_column(nullable=True) + created_at: Mapped[datetime] = mapped_column( + nullable=False, server_default=text("NOW()") + ) + + +class AwoooPActiveRevision(Base): + """Active revision pointer(ADR-107/ADR-113)""" + + __tablename__ = "awooop_active_revisions" + __table_args__ = ( + UniqueConstraint( + "project_id", "contract_family", "contract_id", + name="uq_active_pointer", + ), + ) + + pointer_id: Mapped[UUID] = mapped_column( + primary_key=True, server_default=text("gen_random_uuid()") + ) + project_id: Mapped[str] = mapped_column( + String(64), ForeignKey("awooop_projects.project_id"), nullable=False + ) + contract_family: Mapped[str] = mapped_column(String(32), nullable=False) + contract_id: Mapped[str] = mapped_column(String(128), nullable=False) + active_revision_id: Mapped[UUID] = mapped_column( + ForeignKey("awooop_contract_revisions.revision_id", ondelete="RESTRICT"), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + nullable=False, server_default=text("NOW()") + ) + + +class AwoooPContractOutbox(Base): + """Transactional outbox for contract revision invalidation(ADR-113)""" + + __tablename__ = "awooop_contract_outbox" + __table_args__ = ( + UniqueConstraint("new_revision_id", "event_type", name="uq_outbox_event"), + Index( + "idx_outbox_pending", + "next_retry_at", "created_at", + postgresql_where=text("delivered_at IS NULL"), + ), + Index( + "idx_outbox_backlog_per_project", + "project_id", "created_at", + postgresql_where=text("delivered_at IS NULL"), + ), + ) + + event_id: Mapped[UUID] = mapped_column( + primary_key=True, server_default=text("gen_random_uuid()") + ) + event_type: Mapped[str] = mapped_column(String(64), nullable=False) + project_id: Mapped[str] = mapped_column( + String(64), ForeignKey("awooop_projects.project_id"), nullable=False + ) + contract_family: Mapped[str] = mapped_column(String(32), nullable=False) + contract_id: Mapped[str] = mapped_column(String(128), nullable=False) + old_revision_id: Mapped[UUID | None] = mapped_column( + ForeignKey("awooop_contract_revisions.revision_id"), nullable=True + ) + new_revision_id: Mapped[UUID] = mapped_column( + ForeignKey("awooop_contract_revisions.revision_id"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + nullable=False, server_default=text("NOW()") + ) + delivered_at: Mapped[datetime | None] = mapped_column(nullable=True) + relay_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + next_retry_at: Mapped[datetime | None] = mapped_column(nullable=True) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + + +class AwoooPChannelEventDedupe(Base): + """Channel event idempotency key(ADR-114,partitioned by created_at)""" + + __tablename__ = "awooop_channel_event_dedupe" + __table_args__ = ( + UniqueConstraint( + "project_id", "channel_type", "provider_event_id", "created_at", + name="uq_channel_event_dedupe", + ), + Index("idx_dedupe_run", "run_id"), + ) + + # Composite PK(partition key 必須是 PK 一部分) + # SQLAlchemy 2.x 要求 primary_key=True 標在 mapped_column,不能用 __mapper_args__ 字串 list + dedupe_id: Mapped[UUID] = mapped_column( + primary_key=True, server_default=text("gen_random_uuid()") + ) + project_id: Mapped[str] = mapped_column(String(64), nullable=False) + channel_type: Mapped[str] = mapped_column(String(32), nullable=False) + provider_event_id: Mapped[str] = mapped_column(String(256), nullable=False) + run_id: Mapped[UUID] = mapped_column(nullable=False) + created_at: Mapped[datetime] = mapped_column( + primary_key=True, server_default=text("NOW()") + ) + + +class AwoooPPlatformSubject(Base): + """Canonical principal mapping(ADR-115)""" + + __tablename__ = "awooop_platform_subjects" + __table_args__ = ( + UniqueConstraint( + "project_id", "channel_type", "channel_user_id", + name="uq_platform_subject", + ), + CheckConstraint( + "jsonb_typeof(roles) = 'array'", name="chk_roles_array" + ), + Index( + "idx_platform_subjects_lookup", + "project_id", "channel_type", "channel_user_id", + ), + Index( + "idx_platform_subjects_resolve", + "project_id", "platform_subject_id", + ), + Index( + "idx_platform_subjects_last_seen", + "project_id", "last_seen_at", + ), + ) + + subject_id: Mapped[UUID] = mapped_column( + primary_key=True, server_default=text("gen_random_uuid()") + ) + project_id: Mapped[str] = mapped_column( + String(64), ForeignKey("awooop_projects.project_id"), nullable=False + ) + channel_type: Mapped[str] = mapped_column(String(32), nullable=False) + channel_user_id: Mapped[str] = mapped_column(String(256), nullable=False) + channel_chat_id: Mapped[str | None] = mapped_column(String(256), nullable=True) + platform_subject_id: Mapped[str] = mapped_column(String(128), nullable=False) + display_name: Mapped[str | None] = mapped_column(String(256), nullable=True) + roles: Mapped[list[str]] = mapped_column( + JSONB, nullable=False, server_default=text("'[]'::jsonb") + ) + first_seen_at: Mapped[datetime] = mapped_column( + nullable=False, server_default=text("NOW()") + ) + last_seen_at: Mapped[datetime] = mapped_column( + nullable=False, server_default=text("NOW()") + ) + + +class AwoooPProjectMigrationState(Base): + """Strangler Fig migration state per project × capability(ADR-106 遷移追蹤)""" + + __tablename__ = "awooop_project_migration_state" + __table_args__ = ( + UniqueConstraint("project_id", "capability", name="uq_project_capability"), + CheckConstraint( + "capability IN (" + "'run_execution','contract_governance'," + "'budget_tracking','principal_mapping')", + name="chk_capability", + ), + CheckConstraint( + "current_phase IN (" + "'legacy_awoooi_default','shadow','canary'," + "'read_only','suggest','auto_remediate')", + name="chk_phase", + ), + ) + + state_id: Mapped[UUID] = mapped_column( + primary_key=True, server_default=text("gen_random_uuid()") + ) + project_id: Mapped[str] = mapped_column( + String(64), ForeignKey("awooop_projects.project_id"), nullable=False + ) + capability: Mapped[str] = mapped_column(String(64), nullable=False) + current_phase: Mapped[str] = mapped_column( + String(32), nullable=False, default="legacy_awoooi_default" + ) + phase_entered_at: Mapped[datetime] = mapped_column( + nullable=False, server_default=text("NOW()") + ) + updated_at: Mapped[datetime] = mapped_column( + nullable=False, server_default=text("NOW()") + ) diff --git a/apps/api/src/hermes/agent_loader.py b/apps/api/src/hermes/agent_loader.py index 3f550eeaa..789f77cc8 100644 --- a/apps/api/src/hermes/agent_loader.py +++ b/apps/api/src/hermes/agent_loader.py @@ -1,12 +1,16 @@ """載入 .claude/agents/*.md 並解析 system prompt(ADR-095) 2026-04-24 Claude Sonnet 4.6 (WS4 Hermes NL) +2026-05-04 Claude Sonnet 4.6 (Task 1.2): 移除本機絕對路徑,改用 AGENTS_DIR 環境變數 """ from __future__ import annotations +import os import pathlib from functools import lru_cache -_AGENTS_DIR = pathlib.Path("/Users/ogt/awoooi/.claude/agents") +# 本機預設: /Users/ogt/awoooi/.claude/agents(由 AGENTS_DIR 覆蓋) +# K8s 容器預設: /app/.claude/agents(Dockerfile COPY .claude/agents/ ./.claude/agents/) +_AGENTS_DIR = pathlib.Path(os.getenv("AGENTS_DIR", "/app/.claude/agents")) def _parse_agent_md(path: pathlib.Path) -> str: diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 24ecbb2f8..e121ba027 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -6,6 +6,147 @@ --- +## 2026-05-04 | AwoooP Phase 1 Critic 修正(4 Critical) + +critic review 發現的 4 個 Critical + 3 個 Major 問題全部修正: + +### 已修正 + +- **C-1 `ADD CONSTRAINT IF NOT EXISTS` 語法錯誤**:PostgreSQL 無此語法,batch1_rls 四張表改用 `DO $$ IF NOT EXISTS (SELECT 1 FROM pg_constraint ...) THEN ALTER TABLE ADD CONSTRAINT ...` 包裝 +- **C-2 `__mapper_args__` 字串 list 崩潰**:`AwoooPChannelEventDedupe` 改為 `primary_key=True` 標在 `dedupe_id` / `created_at` 的 `mapped_column`,移除 `__mapper_args__`;`python3 -c import` 驗證通過 +- **C-3 `__platform__` RLS 後門**:`contract_revisions_tenant` / `active_revisions_tenant` 兩個 policy 移除 `OR current_setting(...) = '__platform__'`;平台層改用 BYPASSRLS role(無後門) +- **C-4 `awooop_app` role 從未建立**:Step 1 新增 `CREATE ROLE awooop_app NOLOGIN`(冪等);Step 13 新增 7 條 GRANT 語句(依最小權限原則) +- **M-1 active_pointer_guard SECURITY DEFINER**:trigger fn 改為 `SECURITY DEFINER SET search_path = public, pg_catalog`,確保跨租戶指向檢測在 FORCE RLS 環境下正確運作 +- **M-2 pg_partman create_parent 冪等**:加 `IF NOT EXISTS (SELECT 1 FROM partman.part_config ...)` 防止重跑 migration 出錯 +- **M-3 immutability trigger 強化**:所有 lifecycle_status 下禁止修改 `project_id`/`contract_family`/`contract_id` 身份欄位 +- **M-4 budget_limit_usd 精度**:`float | None` → `Decimal | None = mapped_column(Numeric(14, 4))` + +### 下一步 + +Phase 1 Task 1.5 seed data 驗證 + Task 1.7 integration test → Phase 2 Redis key migration + +--- + +## 2026-05-04 | AwoooP Phase 1 Control Plane Schema 建立 + +Task 1.1(表名核對)+ Task 1.2(agent_loader 路徑修補)+ Task 1.3~1.7(migration + models + runbook) + +### 完成 + +- **Task 1.1 表名核對**:`incidents`/`mcp_audit_log` 表名正確,SQLAlchemy 全部已是 `mapped_column` 語法 +- **Task 1.2 agent_loader 修補**:`agent_loader.py:9` 本機絕對路徑改為 `AGENTS_DIR` 環境變數,Dockerfile 補 COPY `.claude/agents/`;K8s 中不再全部返回 None +- **Task 1.3 Migration**: + - `migrations/awooop_phase1_control_plane_2026-05-04.sql`(新表 + trigger + RLS) + - `migrations/awooop_phase1_batch1_rls_2026-05-04.sql`(高流量表三步式 ADD COLUMN) + - `scripts/awooop_phase1_batch1_backfill.py`(分批回填腳本) + - db-expert review C-1~C-5 全部納入:fail-closed RLS / immutability trigger / active pointer guard / outbox FK + backoff / partition dedupe +- **Task 1.3 Models**:`src/db/awooop_models.py`(7 個 SQLAlchemy 2.x models,import 驗證通過) +- **Task 1.4 Runbook**:`docs/runbooks/awooop-partition-retention.md`(partition 策略 + retention + pg_partman) + +### 部署順序鎖死(RLS 前置條件) + +1. `apps/api` deploy「SET LOCAL app.project_id」版本 → K8s rollout 100% +2. PR-10(31 background loop 改用 awooop_platform_admin role)完成 +3. 執行 `awooop_phase1_control_plane_2026-05-04.sql` +4. 執行 `awooop_phase1_batch1_backfill.py`(回填四張表) +5. 確認 NULL count = 0,執行 `awooop_phase1_batch1_rls_2026-05-04.sql` + +### 下一步 + +critic review 完成後 → Phase 1 收尾(seed data 驗證 + integration test 框架)→ 進 Phase 2(Redis key migration) + +--- + +## 2026-05-04 | AwoooP Phase 0 全部 ADR 完成(ADR-111 ~ ADR-124 + ADR-UI-01~04) + +承接昨日 DETAILED-IMPLEMENTATION-PLAN 建立,今日完成所有 Phase 0 文件層工作。 + +### 完成 + +- **ADR-111**:Bootstrap Order(三身份標記 + platform_resource 清單) +- **ADR-112**:Contract Governance(三層授權 + HMAC 簽章 + approval workflow) +- **ADR-113**:Active Revision Invalidation & Outbox(transactional outbox + relay worker + split-brain 防禦) +- **ADR-114**:Idempotency, Worker Lease & Run Recovery(channel event 去重 + SKIP LOCKED + stale reaper) +- **ADR-115**:Canonical Principal Mapping & Tenant Onboarding(platform_subjects 表 + EwoooC Proxy Adapter) +- **ADR-116**:Security Hardening(三個 PoC 漏洞修補 + approval_token HS256 規格) +- **ADR-117**:MCP OAuth 2.1 & Confused Deputy Prevention(RFC 9728 aud + run.allowed_tools 防 Confused Deputy) +- **ADR-118**:Row-Level Security & Tenant DB Isolation(PostgreSQL RLS + awooop_app role + 分批上線策略) +- **ADR-119**:Durable Execution & SAGA Compensation(step journal + 補償命令 + 觸發條件) +- **ADR-120**:Token Budget Hard Kill(三層 budget + pre-call check + emergency kill switch) +- **ADR-121**:OTel GenAI Semantic Conventions(gen_ai.* span + telemetry.py P0-08 修補) +- **ADR-122**:OWASP Agentic AI Top 10 & ISO 42001 Alignment(10 項映射表 + 差距清單) +- **ADR-123**:Background Loop Migration Strategy(31 個 loop 三分類 + 退出時程表) +- **ADR-124**:Global Singleton Decomposition(13 個 singleton 分解策略 + Tier 3 保護措施) +- **ADR-UI-01**:Operator Console Architecture(apps/web/ 子路由整合 + auth gate + 8 模組) +- **ADR-UI-02**:Contract Governance UI(M3 Dashboard + M4 Editor + activation approval flow) +- **ADR-UI-03**:Run Monitor UI(M5 即時 + M6 Detail + SAGA Steps Tab) +- **ADR-UI-04**:Approval Queue UI(M7 Queue + M8 Decision + 倒數計時 + Telegram 連動) + +### Phase 0 驗收狀態 + +- Phase 0 ADR 文件:18 份全部 Accepted ✅ +- INV-1~INV-9(9 份 Inventory):✅ 已完成(上一 Session) +- docs-only 原則:✅ 全程未動 runtime code + +### 下一步 + +Phase 0 完成 → 可開新 Codex Session 進入 **Phase 1**(DB schema migration): +1. 建立 `awooop_*` 系列表(awooop_projects, awooop_contract_revisions, awooop_active_revisions, awooop_contract_outbox, awooop_channel_event_dedupe, awooop_run_state, awooop_platform_subjects) +2. Batch 1 RLS(incidents / knowledge_entries / playbooks / audit_logs 加 project_id) +3. db-expert 審查 migration safety + +--- + +## 2026-05-03 | AwoooP 完整詳細實施計畫建立(12-Agent 全景審查整合版) + +承接統帥「全景、全流程、全節點」指示,12 位 Agent 並行深度審查後,整合所有發現建立完整執行計畫。 + +### 完成 + +- **ADR 編號修正**:MASTER-WORKPLAN.md 中 ADR-108/109/110 被 incident fingerprint / telegram dedup / GCP Ollama 占用,AwoooP 專用 ADR 全部改從 ADR-111 開始(ADR-111~115 五份核心 ADR) +- **新建 [DETAILED-IMPLEMENTATION-PLAN.md](/Users/ogt/awoooi/docs/awooop/DETAILED-IMPLEMENTATION-PLAN.md)**,整合: + - 原 24 項 + 新增 ~70 個問題(P0/P1/P2 完整清單) + - Pre-flight Audit Phase 0:ADR-111~115(核心)+ ADR-116~124(強化)+ ADR-UI-01~04(Operator Console)= 18 份 ADR + - INV-1~INV-9(9 份 Inventory,含 GCP IP、31 個 background loop、13 個全域單例) + - 8 Phase 詳細六要素工作項(含 RACI / DoD / 禁止碰的邊界) + - 完整 DB Schema(4 個核心表詳細 DDL + RLS) + - 安全修補計畫(vuln-verifier PoC 三個漏洞 + approval token 規格) + - API Endpoint 完整清單(現有 4 + Phase 4-7 新增 11) + - 錯誤碼字典(12 個 error code) + - 前端 Operator Console 8 個模組清單 + - 重構切割計畫(11 PR,含並行群組) + - Feature Flag / Kill-Switch Registry(9 個 flag,`AWOOOP_BUDGET_HARD_KILL` 預設 ON) + - Runbook 清單(RB-01~RB-08) + - 工具補強計畫(PgBouncer / Sealed Secrets / OPA / chaostoolkit / awooop-ctl / pg_partman) + - 業界對齊($47k Hard Kill / SAGA / MCP OAuth 2.1 / OTel GenAI / OWASP Agentic AI Top 10 / ISO 42001) + - **GCP Ollama 拓撲(ADR-110)對 AwoooP 的全景影響**(Redis key 三層拓撲遷移、INV-4 含 GCP IP、EwoooC 共用 platform 路由) + - 工作排序總表(含並行群組 G-A~G-G + Critical Path) + - 量化驗收門檻(完整版,每 Phase 必要量化指標) +- **新建 docs/awooop/inventory/ 目錄**(INV-1~INV-9 待填內容) + +### 重要發現(vs 舊版 MASTER-WORKPLAN) + +| 項目 | 舊估計 | 實際 | +|------|--------|------| +| P0/P1 問題數 | 24 | ~70(3x) | +| 必補 ADR 數 | 5 | 18(3.6x) | +| Inventory 份數 | 4 | 9(2.25x) | +| background loop 數 | ~10 | 31(實測 main.py)| +| 全域單例數 | 未統計 | 13(INV-9)| +| Runbook 需求 | 0 | 8 份 | +| ADR 編號 | ADR-108~112 | ADR-111~115(已修正)| + +### 驗證 + +- 仍是 docs-only。沒有動 runtime、沒有建立空 AwoooP code 目錄。 +- GCP Ollama 拓撲(ADR-110)已完整納入計畫。 + +### 下一步 + +Phase 0 docs-only 開工:INV-1~INV-4(Inventory)優先,並行建立 ADR-111 Bootstrap Order,完成後才開新 Codex 對話進 Phase 1 schema code。 + +--- + ## 2026-05-03 | ADR-110 GCP Ollama 三層容災架構正式上線 **統帥決策**:Ollama 主機從單一 111(Local HDD)升級為 GCP 三層容災。 diff --git a/docs/adr/ADR-106-agent-platform-architecture.md b/docs/adr/ADR-106-agent-platform-architecture.md new file mode 100644 index 000000000..e814ed8bd --- /dev/null +++ b/docs/adr/ADR-106-agent-platform-architecture.md @@ -0,0 +1,571 @@ +# ADR-106: AwoooP Agent Platform Architecture and Migration Strategy + +**Status**: Accepted +**Date**: 2026-05-01 +**Scope**: Multi-tenant Agent Platform, Agent contracts, MCP Gateway, runtime state, channel events, migration strategy + +## Context + +AWOOOI currently contains the strongest AI automation implementation in the +ecosystem: OpenClaw/NemoTron/Hermes/ElephantAlpha roles, Agent Loop +foundations, MCP providers, Telegram workflows, model routing, cost guards, +Playbook learning, and operational audit trails. + +Other products already have adjacent AI or messaging surfaces: + +- EwoooC / MOMO PRO has business analysis bots, ElephantAlpha orchestration, + market-intelligence tools, LINE/Telegram/Email notification paths, and local + AI provider selection. +- Tsenyang already has Telegram webhook capability. +- Bitan and future products need repeatable AI onboarding without copying + AWOOOI internals. + +The old choice set was too narrow: + +- A pure centralized HTTP hub solves governance but creates a single point of + failure and makes AWOOOI look like every product's private brain. +- A shared SDK reduces duplicated code but cannot solve tenant isolation, + identity, budget, channel, MCP credential, or cross-project audit problems. +- A light configuration plane helps routing drift but still leaves tool use, + session state, and channel handling scattered across projects. + +The approved direction is a fourth path: + +> Build **AwoooP**, a multi-tenant Agent Platform. AWOOOI is the first and +> largest tenant and first runtime host, not the platform boundary itself. + +This ADR records the architecture and migration strategy only. It does not +authorize runtime code changes, provider cost changes, destructive operations, +or channel cutovers. + +## Numbering Note + +`ADR-105-revert-a2-ollama-primary.md` previously reserved `ADR-106` as a +placeholder for model-catalog cleanup. No checked-in `ADR-106*.md` existed. +This ADR consumes the `ADR-106` number for the broader Agent Platform decision. +The model-catalog and dynamic-routing debt is folded into the Policy / Routing +contract below and should be implemented under this platform roadmap or a later +non-conflicting ADR number. + +## Decision + +### D0 - Name the Platform AwoooP + +The platform product name is **AwoooP**. + +Naming rules: + +- Product name: `AwoooP` +- Repository/package slug: `awooop` +- Project/tenant id for the existing AWOOOI product remains `awoooi` +- `AwoooP` is the platform boundary; `AWOOOI` is a tenant and initial runtime + host + +Do not create empty project directories just for the name. Create `awooop` +runtime/package directories only when a concrete implementation phase owns +code, schemas, clients, or workers. + +Recommended future layout when implementation begins: + +| Purpose | Path | +|---|---| +| Shared contract schemas | `packages/awooop-contracts/` | +| Python/TS client SDK | `packages/awooop-client/` | +| Platform API/runtime shell | `apps/awooop-runtime/` | +| Async run workers | `apps/awooop-worker/` | +| Detailed schema docs | `docs/awooop/` | + +### D1 - Adopt AwoooP as a Six-Plane Platform + +AwoooP is defined as six cooperating planes: + +| Plane | Responsibility | Must not do | +|---|---|---| +| Project / Tenant | Tenant identity, isolation, budget, channel allowance, ACL, migration mode | Store agent prompt details | +| Agent | Agent identity, version, role, I/O contract, safety ceiling, context domain | Decide concrete model/provider credentials | +| MCP Gateway | Tool authorization, credential resolution, rate limits, approval, result sanitization, audit | Expose raw credentials to agents | +| Policy / Routing | Effective model/provider route, fallback, privacy ceiling, budget gate, generation defaults | Bypass tenant hard stops | +| Runtime / Run State | Run lifecycle, async state machine, shadow/canary/active mode, checkpoint/resume | Treat long tasks as simple HTTP request-response | +| Communication / Channel Event | Telegram/LINE/Slack/Email/API receive, verify, normalize, send | Run LLM inference or call MCP directly | + +### D2 - Require Platform Envelope Fields + +Every platform invocation must carry the following envelope fields: + +- `project_id` +- `environment_id` when environment-specific resources are involved +- `agent_id` +- `agent_version` after resolution +- `session_id` +- `trace_id` +- `run_id` for runtime-managed work +- `policy_version` after effective policy resolution + +Missing `project_id`, `trace_id`, or `run_id` in runtime or MCP paths is a hard +reject, not a warning. + +### D3 - Treat Project as the Smallest Isolation Unit + +`project_id` is the platform tenant boundary. All Redis keys, session stores, +RAG namespaces, KM queries, MCP tool scopes, budget ledgers, ACL checks, and +channel routing must be project-scoped unless a resource is explicitly declared +as `platform_resource`. + +`global:*` resources are platform resources, not AWOOOI resources. + +### D4 - Define Agents as Platform Capability Modules + +Agents are not product-local prompt strings. Agents are versioned platform +capabilities with: + +- `agent_id` +- `base_agent_ref` +- immutable published version +- role and capability tags +- privacy ceiling +- payload input schema +- LLM output schema +- prompt artifact reference and hash +- context domain constraints +- MCP requirement declarations +- execution profile +- eval and lifecycle governance + +Specialized agents must inherit from base agents without loosening safety: + +- `openclaw-core` +- `openclaw-sre` +- `openclaw-biz` +- future `openclaw-pharmacy` +- future `openclaw-marketing` + +Published agent artifacts are immutable. Prompt, schema, or contract changes +must publish a new version. + +### D5 - Use MCP Gateway Instead of Direct Tool Calls + +Agents never call MCP servers directly and never see raw credentials. Every +tool call goes through MCP Gateway. + +Gateway authorization is the intersection of: + +`Project grant AND Agent requirement AND Tool contract AND Environment boundary AND Approval state` + +Tool results entering model context must be sanitized first. Tool calls must be +traceable through `trace_id` and `run_id`. + +### D6 - Resolve EffectivePolicy Before Every Model Call + +Provider and model selection is not embedded inside Agent Contract. Runtime must +derive an `EffectivePolicy` before each model call from: + +- platform policy +- agent safety ceiling +- project policy and budget +- environment constraints +- channel constraints +- intent and complexity +- current provider health + +Budget hard stop overrides all fallback behavior. Cloud fallback must include a +reason and be written to trace/audit. Any policy change that increases cost, +enables a new paid provider, or raises token ceilings still requires explicit +human approval under `docs/HARD_RULES.md`. + +### D7 - Model Runtime as Durable Runs + +An agent invocation is a `Run`, not just an HTTP request. + +Required runtime states: + +```text +CREATED -> POLICY_RESOLVED -> QUEUED -> RUNNING +RUNNING -> WAITING_TOOL -> RESUMED -> RUNNING +RUNNING -> WAITING_APPROVAL -> RESUMED -> RUNNING +RUNNING -> COMPLETED +any non-terminal -> FAILED +any non-terminal -> CANCELLED +``` + +Terminal states: + +- `COMPLETED` +- `FAILED` +- `CANCELLED` + +`WAITING_APPROVAL` must be resumable without replaying the whole task. `FAILED` +must include a structured `failure_code`. + +### D8 - Keep Channel Adapters Thin + +Telegram, LINE, Slack, Email, and API adapters only perform: + +1. receive +2. verify +3. normalize to `ConversationEvent` +4. send `OutboundMessage` + +Channel adapters must not call LLMs, call MCP tools, decide approval, or embed +business logic. Channel escaping, token resolution, delivery retry, and +provider message IDs remain adapter responsibilities. + +### D9 - Migrate by Strangler Fig + +No production path should be replaced in one cutover. Migration must progress by +tenant and capability: + +1. `shadow`: platform receives mirrored events, writes audit/trace only, no user + response and no external side effects. +2. `canary`: platform can respond to selected low-risk traffic, side effects + disabled by default. +3. `read_only`: read-only queries and business chat move first. +4. `suggest`: analysis and recommendations move next, approval still external. +5. `auto_remediate`: write/execute tools move only after Gateway, approval, + replay, and audit evidence are green. + +AWOOOI must also become a tenant (`project_id=awoooi`) instead of keeping a +privileged private path forever. + +## Contract Baselines + +### C1 - Project / Tenant Contract + +Project is the smallest isolation unit. + +Minimum contract fields: + +- `project_id` +- `display_name` +- `status` +- `environments` +- `data_boundary` +- `platform_resources` +- `budget` +- `rate_limits` +- `allowed_agents` +- `allowed_channels` +- `approval_gates` +- `status_policy` + +Invariants: + +- `project_id` is immutable after creation. +- Data boundary enforcement happens at data/Gateway APIs, not in prompt text. +- Budget hard stop rejects cloud model calls with `BUDGET_EXCEEDED`. +- Agent calls outside `allowed_agents` return `AGENT_NOT_PERMITTED`. +- Project overrides may tighten permissions, never loosen them. +- Migration state lives in `project_migration_state`, not the stable project + contract record. +- `suspended` behavior must explicitly define channel, model, and MCP access. + +### C2 - Agent Contract + +Agent Contract answers who the agent is, what it can do, what its interfaces are, +and its highest safety boundary. + +Minimum contract fields: + +- `agent_id` +- `base_agent_ref` +- `version` +- `lifecycle_status` +- `role` +- `capability_tags` +- `risk_class` +- `privacy_ceiling` +- `artifact_refs` +- `interface` +- `context_policy` +- `mcp_requirements` +- `execution_profile` +- `governance` + +Invariants: + +- Published versions are immutable. +- `base_agent_ref` must include a version range and resolve to an audited exact + version at runtime. +- Prompt and schema artifacts require hashes. +- Agent requirements are not permissions; Gateway decides actual tool access. +- Agent output is LLM payload only. Runtime attaches trace, cost, policy, and + validation metadata. +- `requires_approval` is calculated by runtime from project, agent, policy, and + Gateway rules, not trusted from LLM output alone. + +### C3 - MCP Gateway Contract + +MCP Gateway is the security boundary between agents and external systems. + +Minimum contract fields: + +- `tool_id` +- `domain` +- `resource` +- `verbs` +- `owner_project_id` +- `tenancy_scope` +- `side_effect_level` +- `data_sensitivity` +- `server` +- `schemas` +- `credential_policy` +- `authorization_policy` +- `approval_policy` +- `rate_limits` +- `result_policy` +- `audit_policy` + +Tool call envelope requires: + +- `trace_id` +- `run_id` +- `project_id` +- `environment_id` +- `agent_id` +- `agent_version` +- `session_id` +- `tool_id` +- `verb` +- `idempotency_key` +- `payload` + +Invariants: + +- Agents never see raw credentials. +- Missing `project_id`, `trace_id`, or `run_id` is rejected. +- Project grant, agent declaration, and context boundary must all pass. +- Results entering context are sanitized. +- Write, execute, and destructive operations require approval unless explicitly + allowed by all relevant contracts. + +### C4 - Policy / Routing Contract + +Policy / Routing resolves the model and execution policy for a single call. + +Minimum contract fields: + +- `policy_id` +- `version` +- `lifecycle_status` +- `scope` +- `match` +- `constraints` +- `route_plan` +- `generation_defaults` +- `budget_guard` +- `approval_guard` +- `observability` + +`EffectivePolicy` must include: + +- `trace_id` +- `project_id` +- `agent_id` +- `agent_version` +- `policy_version` +- allow/deny decision +- provider/model refs +- fallback chain +- max tokens +- privacy mode +- budget state +- approval requirement +- matched policy layers +- decision notes + +Invariants: + +- Agent privacy ceiling beats project override. +- Project/global budget hard stop beats fallback. +- Providers/models are referenced through catalog refs, not hardcoded into + agent contracts. +- Policy merge uses strictest-wins semantics. +- Effective policy must be replayable from versioned inputs. +- Schema retry has a hard cap. + +### C5 - Runtime / Run State Contract + +Runtime owns durable execution state. + +Minimum run fields: + +- `run_id` +- `trace_id` +- `project_id` +- `environment_id` +- `agent_id` +- `agent_version` +- `session_id` +- `channel_type` +- `mode` +- `execution_type` +- `state` +- input refs +- effective policy ref +- checkpoint +- approval +- result +- audit timestamps and failure details + +Invariants: + +- Shadow mode has no external side effects and no user-visible response. +- Canary mode has no side effects by default. +- Every state transition writes audit. +- `WAITING_APPROVAL` is resumable. +- `CANCELLED` cannot resume. +- Tool loop iterations have a hard cap. +- Client response requires schema validation. + +### C6 - Communication / Channel Event Contract + +Communication Hub normalizes channels without embedding AI logic. + +Inbound `ConversationEvent` minimum fields: + +- `event_id` +- `trace_id` +- `project_id` +- `environment_id` +- channel metadata +- sender metadata +- routing metadata +- payload +- security metadata +- delivery metadata + +Outbound message minimum fields: + +- `outbound_id` +- `trace_id` +- `run_id` +- `project_id` +- channel target +- message payload +- policy +- delivery state + +Invariants: + +- Adapter does not call LLM or MCP. +- Raw payload is saved for short audit retention. +- Outbound messages pass ACL, redaction, and rate limit. +- Bot token resolution stays inside adapter runtime. +- Retry is idempotent. +- Escaping/formatting is adapter responsibility. + +## Implementation Order + +### Phase 0 - Documentation and Contract Freeze + +- This ADR. +- Follow-up schema documents or ADRs only when implementation needs field-level + detail. +- No runtime behavior change. + +### Phase 1 - Isolation Foundation + +- Add `project_id` to Redis keys, sessions, budget ledgers, dispatch logs, MCP + audit snapshots, and approval records where applicable. +- Define `platform_resource` exceptions separately from tenant resources. +- Preserve existing AWOOOI behavior under `project_id=awoooi`. +- Follow ADR-107: PostgreSQL is the source of truth for AwoooP control-plane + contracts; Redis is cache/watch only; CRDs are future runtime projection. + +### Phase 2 - Platform Shell + +- Add platform APIs and data models around existing logic. +- Implement envelope validation, trace propagation, audit, and effective policy + calculation. +- Keep existing AWOOOI and EwoooC implementations behind adapters. + +### Phase 3 - Shadow Mode + +- Mirror selected events into AwoooP. +- Compare platform decisions with legacy decisions. +- Do not send user-visible responses or execute side effects. + +### Phase 4 - Read-Only and Suggest Cutover + +- Move low-risk chat and read-only analysis first. +- Add EwoooC business-agent traffic as the first downstream tenant validation. +- Keep remediation and write tools in legacy path until Gateway evidence is + sufficient. + +### Phase 5 - Controlled Active Runtime + +- Move write/execute operations only after: + - Gateway authorization is enforced. + - Approval resume is proven. + - Trace and audit can replay a complete run. + - Budget hard stop has live evidence. + - Production rollback path is documented. + +## Consequences + +### Benefits + +- A single agent improvement can apply to all projects or to one specialization. +- Product-specific customization happens through policy, permissions, and + context scope rather than copy-pasted prompts. +- Tool credentials remain outside agent context. +- Cross-project data leakage is blocked by data/Gateway filters. +- Telegram, LINE, Slack, Email, and API can share the same agent runtime. +- Long-running agent work becomes observable, resumable, and replayable. + +### Costs + +- More platform tables and contracts must exist before large runtime changes. +- Early delivery focuses on audit and shadow evidence rather than visible + feature changes. +- Each downstream project needs explicit tenant onboarding. +- Provider/model policy changes become governed artifacts, not quick env edits. + +### Risks + +- Over-building before live traffic proves value. +- Contract sprawl if every detail becomes a new ADR. +- Legacy paths remaining forever if strangler milestones lack deadlines. +- Confusing `OpenClaw` brand identity if `openclaw-core`, `openclaw-sre`, and + `openclaw-biz` are not documented clearly. + +### Mitigations + +- Use this ADR as the index and create detailed schema docs only as needed. +- Use ADR-107 for control-plane storage decisions before creating migrations or + CRDs. +- Require `project_migration_state` milestones per tenant. +- Keep AWOOOI on the same tenant path as all other products. +- Treat shadow/canary evidence as the gate for every cutover. +- Keep cost-changing provider behavior behind explicit approval. + +## Non-Goals + +- This ADR does not deploy new providers or increase paid model usage. +- This ADR does not move Telegram/LINE/Slack webhooks. +- This ADR does not authorize destructive MCP tools. +- This ADR does not replace ADR-105 Agent Loop governance; it generalizes the + platform boundary above it. +- This ADR does not require Temporal or a specific workflow engine in v1. + +## Acceptance Criteria + +- Six contract baselines are captured in this ADR. +- AwoooP is named as the platform product and AWOOOI is explicitly defined as + first tenant, not the whole platform. +- MCP is Gateway-governed, not direct agent access. +- Runtime migration is shadow/canary/active, not big-bang. +- Cost and privacy hard stops are preserved. +- LOGBOOK records this architecture decision. + +## References + +- `docs/12-agent-game-rules.md` +- `docs/HARD_RULES.md` +- `docs/LOGBOOK.md` +- `docs/adr/ADR-080-ai-autonomy-flywheel-overview.md` +- `docs/adr/ADR-095-12agent-sdk-integration.md` +- `docs/adr/ADR-100-ai-autonomous-slo.md` +- `docs/adr/ADR-105-mcp-agent-loop-governance.md` +- `docs/adr/ADR-107-awooop-control-plane-storage.md` +- `docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md` diff --git a/docs/adr/ADR-107-awooop-control-plane-storage.md b/docs/adr/ADR-107-awooop-control-plane-storage.md new file mode 100644 index 000000000..217a7ca20 --- /dev/null +++ b/docs/adr/ADR-107-awooop-control-plane-storage.md @@ -0,0 +1,219 @@ +# ADR-107: AwoooP Control Plane Storage Strategy + +**Status**: Accepted +**Date**: 2026-05-01 +**Scope**: AwoooP control plane storage, contract materialization, cache/watch, K8s projection + +## Context + +ADR-106 defines AwoooP as the multi-tenant Agent Platform with six contracts: + +- Project / Tenant +- Agent +- MCP Gateway +- Policy / Routing +- Runtime / Run State +- Communication / Channel Event + +Those contracts need a physical source of truth. The key question is whether +the AwoooP control plane should be implemented as Kubernetes CRDs first, or as +PostgreSQL tables first. + +AwoooP must serve more than Kubernetes: + +- AWOOOI on K3s is the first runtime host and tenant. +- EwoooC / MOMO PRO currently runs Docker workloads. +- Tsenyang, Bitan, LINE, Slack, Email, and API entrypoints may not all be + Kubernetes-native. +- Runtime state, audit, budget, approval, and conversation events are + high-volume product control-plane data, not only deployment desired state. + +ADR-105 already established the local pattern for MCP RAG: keep PostgreSQL + +pgvector + Redis hot cache as the source of truth unless scale, latency, or +tenant isolation requires a later split. + +## Decision + +### D1 - Use PostgreSQL as the Control Plane Source of Truth + +AwoooP v1 uses PostgreSQL as the authoritative store for the six contract +families and runtime governance state. + +PostgreSQL owns: + +- contract drafts and immutable published revisions +- active revision pointers +- tenant/project records +- agent registry and version metadata +- policy and routing revisions +- MCP tool registry, grants, and audit summaries +- budget ledgers and hard-stop state +- ACL subjects and project access +- run state and approval state +- channel event and outbound delivery state +- audit and trace correlation records + +Kubernetes etcd is not the source of truth for these records in v1. + +### D2 - Materialize Contracts as Versioned Revisions + +Each published contract must produce an immutable revision. + +Minimum revision fields: + +- `contract_family` +- `contract_id` +- `revision_id` +- `version` +- `lifecycle_status` +- `body_json` +- `body_schema_version` +- `body_hash` +- `created_by` +- `created_at` +- `published_at` +- `supersedes_revision_id` + +Runtime reads only published or active revisions. Mutable drafts are never used +for runtime decisions. + +### D3 - Keep Artifact Bodies Out of Control Tables + +Large or independently governed artifacts are stored by reference plus hash: + +- system prompts +- JSON Schemas +- eval suites +- policy fixtures +- replay fixtures + +The database stores: + +- artifact ref +- SHA-256 hash +- artifact type +- expected schema version + +This makes historical runs replayable without stuffing large prompt bodies into +every contract row. + +### D4 - Use Redis for Cache and Watch, Not Authority + +Redis is allowed for: + +- effective policy cache +- agent contract cache +- project boundary cache +- rate limit counters +- short-lived session hot state +- contract revision invalidation +- worker coordination + +Redis is not allowed to be the only source of truth for: + +- contract revisions +- active revision pointers +- budget hard-stop decisions +- run terminal state +- approval decisions +- audit history + +Any cached value used by runtime must carry the source `revision_id` and +`body_hash`. + +### D5 - Use Kubernetes CRDs Only as Runtime Projection + +CRDs are not the primary control-plane database in v1. + +Allowed future CRD projections: + +- `AwoooPRuntime` +- `AwoooPWorker` +- `MCPServerBinding` +- `ChannelIngress` +- `TenantRuntimeBinding` + +These CRDs describe Kubernetes runtime wiring, deployment, service exposure, and +MCP server binding. They do not own product contracts, budgets, run state, +conversation events, or audit ledgers. + +If an operator is later built, it should project from PostgreSQL into Kubernetes +objects, not require Kubernetes CRDs to become the source of truth for all +AwoooP state. + +### D6 - Define Read Paths by Stability + +| Data | Source of truth | Runtime read path | +|---|---|---| +| Contract draft | PostgreSQL | Admin/API only | +| Published contract revision | PostgreSQL | DB read + Redis cache | +| Active revision pointer | PostgreSQL | DB read + Redis invalidation | +| Prompt/schema/eval artifact | Git or object storage | Ref + hash from DB | +| Effective policy | PostgreSQL-derived | Redis cache with revision IDs | +| Run state | PostgreSQL | Worker writes DB, optional Redis notification | +| Budget hard stop | PostgreSQL | DB transaction + Redis hot counter | +| Rate limit | PostgreSQL policy + Redis counter | Redis counter, DB policy | +| Audit history | PostgreSQL | DB append/query | +| K8s runtime wiring | CRD projection | Kubernetes watch | + +## Consequences + +### Benefits + +- AwoooP remains useful for Docker, Next.js, API-only, and future non-K8s + tenants. +- Product governance data can be joined with incidents, Playbooks, KM, budget, + and audit records. +- Contract changes are transactional and replayable. +- Runtime can use Redis for speed without risking split-brain authority. +- K8s CRDs remain available for the infrastructure layer without forcing all + business control-plane data into etcd. + +### Costs + +- AwoooP needs explicit schema and migration discipline before runtime code. +- Runtime workers must implement revision-aware caches. +- K8s operator work, if needed later, must include a projection layer. + +### Risks + +- PostgreSQL schema can become a dumping ground if contract families are not + modeled explicitly. +- Redis cache can become stale if invalidation is not versioned by revision and + hash. +- Future CRDs can drift from DB state if projection reconciliation is not + audited. + +### Mitigations + +- Every runtime decision records `revision_id` and `body_hash`. +- Active revision changes must emit an invalidation event. +- Contract publish is append-only; do not mutate published `body_json`. +- CRD projection writes must be reconciled and auditable. +- High-volume audit/event tables should be partitioned before production scale. + +## Non-Goals + +- This ADR does not create database migrations. +- This ADR does not create AwoooP directories or runtime services. +- This ADR does not introduce Kubernetes CRDs. +- This ADR does not choose Temporal, Celery, Redis Streams, or another worker + engine. +- This ADR does not change paid provider behavior. + +## Acceptance Criteria + +- PostgreSQL is declared as the v1 AwoooP control-plane source of truth. +- Redis is limited to cache/watch/counter responsibilities. +- Artifacts are referenced by ref + hash rather than copied into every runtime + record. +- CRD-first is explicitly rejected for v1. +- Future CRDs are limited to Kubernetes runtime projection. +- ADR-106 and the task-routing docs point to this ADR. + +## References + +- `docs/adr/ADR-105-mcp-agent-loop-governance.md` +- `docs/adr/ADR-106-agent-platform-architecture.md` +- `docs/LOGBOOK.md` +- `docs/12-agent-game-rules.md` diff --git a/docs/adr/ADR-111-awooop-bootstrap-order.md b/docs/adr/ADR-111-awooop-bootstrap-order.md new file mode 100644 index 000000000..f68eccd1e --- /dev/null +++ b/docs/adr/ADR-111-awooop-bootstrap-order.md @@ -0,0 +1,166 @@ +# ADR-111: AwoooP Bootstrap Order & Identity Paradox + +**狀態**:Accepted +**日期**:2026-05-03(台北) +**決策者**:統帥 +**範圍**:AwoooP 平台啟動順序、project_id 標記體系、31 個 background loop 的 identity 策略 +**關聯**:ADR-106(架構)、ADR-107(儲存)、ADR-123(background loop migration) + +--- + +## 背景 + +AWOOOI 作為 AwoooP 的 first tenant 和 first runtime host,在系統啟動時存在一個根本矛盾: + +- AwoooP 的核心原則是「所有操作必須帶 project_id」 +- 但 `apps/api/src/main.py` 有 **31 個 background loop**(onboarder 實測),全部都沒有 project_id +- AWOOOI 既是「需要被 project_id 保護的 tenant」,又是「在任何 project 被建立前就要運行的平台基礎設施」 + +這就是 **Bootstrap Paradox**:你需要平台先啟動,才能建立 project;但啟動時又沒有 project。 + +--- + +## 問題清單 + +1. 31 個 background loop 無 project_id(main.py 實測) +2. AWOOOI cron/job/healthcheck 在 AwoooP 對象化之前就必須運行 +3. `run_ai_slo_watchdog_loop()` 是平台自我監控,不屬於任何 tenant +4. `_run_model_version_tracker_loop()` 追蹤所有 tenant 共用的 AI provider 版本,屬於 platform resource +5. GCP Ollama 三層容災(ADR-110)的 failover 狀態(`ollama:current_primary`)是 platform_resource,不屬於任何 tenant + +--- + +## 決策 + +### D1 — 三種 project_id 標記 + +所有入口點(loop、webhook、job、CLI)必須帶其中一個標記: + +| 標記 | project_id 值 | 適用情境 | +|------|-------------|---------| +| `platform_internal` | `__platform__` | 平台自我維護、不屬於任何 tenant;允許讀取 platform_resource,但必須記 audit | +| `legacy_awoooi_default` | `awoooi` | 過渡期:AWOOOI 業務 loop 尚未改造前的臨時 fallback | +| `requires_project_id` | 從呼叫鏈或 event envelope 取得 | 已完成多租戶改造的入口點 | + +### D2 — Platform Resource 明確清單 + +以下 Redis key 和狀態是 **platform_resource**,使用前綴 `platform:` 而非 `{project_id}:`: + +| Resource | Key | 理由 | +|----------|-----|------| +| Ollama topology | `platform:ollama:topology` | GCP-A/GCP-B/Local 三層路由,所有 tenant 共用(ADR-110)| +| Telegram polling leader | `platform:telegram:polling:leader` | 全域 pod 鎖,不屬於任何 tenant | +| Gemini daily count | `platform:ollama:gemini_daily_count:{date}` | 全平台 Gemini 緊急路由計數 | +| SLO watchdog state | `platform:slo_watchdog:*` | 平台自我監控 | +| Model version cache | `platform:model_version:*` | 所有 tenant 共用的 provider 版本資訊 | + +### D3 — 啟動順序(Hard Order) + +``` +1. DB migrations(awooop_projects + awooop_contract_revisions 等) +2. Seed AWOOOI project_id(INSERT IF NOT EXISTS) +3. platform_internal services 啟動(SLO watchdog、model version tracker) +4. legacy_awoooi_default services 啟動(AWOOOI 業務 loop,帶 project_id=awoooi) +5. API server 開始接收請求 +6. requires_project_id services 啟動(Phase 2+ 改造後的新入口點) +``` + +**禁止**:API server 在 step 2(seed AWOOOI project)完成前接受任何需要 project_id 的請求。 +**實作**:FastAPI lifespan context manager 控制啟動順序。 + +### D4 — 過渡期豁免規則(legacy_awoooi_default) + +以下條件下,`project_id=awoooi` 作為過渡期 fallback 是允許的: + +- 明確標記為 `legacy_awoooi_default`(不能靜默 fallback) +- 寫入 audit log(標記 `legacy=true`) +- 有退場時程(Phase 4 完成後 90 天內改造完畢) + +### D5 — Hard Reject 規則 + +在 Phase 2 完成後,以下情況**必須拒絕**(不能靜默 fallback): + +- 新建立的 API endpoint 收到無 project_id 的請求(非 legacy) +- `requires_project_id` 標記的 loop 缺少 project_id 的 context variable + +### D6 — GCP Ollama 拓撲的 bootstrap 處理 + +ADR-110 的三層 Ollama 拓撲是 platform_resource: +- bootstrap 時必須在 step 3 之前確認 GCP-A/GCP-B 健康狀態 +- `platform:ollama:topology` Redis key 由 `ollama_failover_manager.py` 在啟動時初始化 +- 所有 tenant 的 LLM call 使用這個 platform-level topology,不能 per-project 覆蓋(safety: no per-tenant Ollama endpoint override) + +--- + +## 實作指引 + +### Python contextvars 實作 + +```python +# apps/api/src/core/context.py(新建) +import contextvars + +project_id_ctx_var: contextvars.ContextVar[str] = contextvars.ContextVar( + 'project_id', default='__platform__' +) + +def get_current_project_id() -> str: + return project_id_ctx_var.get() + +async def run_with_project(coro, project_id: str): + """在指定 project context 中執行 coroutine""" + token = project_id_ctx_var.set(project_id) + try: + return await coro + finally: + project_id_ctx_var.reset(token) +``` + +### main.py 改造示意(Phase 2 完成後) + +```python +# platform_internal loop +asyncio.create_task(run_with_project( + run_ai_slo_watchdog_loop(), "__platform__" +)) + +# legacy_awoooi_default loop(過渡期) +asyncio.create_task(run_with_project( + run_incident_analysis_sweeper(), "awoooi" +)) +``` + +--- + +## 後果 + +### Benefits +- Bootstrap Paradox 有明確解法(三種標記 + 啟動順序) +- platform_resource 明確隔離,不會和 tenant 資料混算 +- GCP Ollama 三層拓撲作為 platform_resource,不受 per-tenant policy 影響 + +### Costs +- 31 個 background loop 全部需要改造(INV-8 列出優先序) +- main.py 啟動邏輯需要重構(step 1~6 排序) +- 需要新建 `apps/api/src/core/context.py` + +### Risks +- legacy_awoooi_default 標記若不退場,會永遠留在 codebase +- 緩解:ADR-123 明確定義退場時程,Phase 4 後 90 天 + +--- + +## 驗收標準 + +- [ ] `apps/api/src/core/context.py` 建立(project_id contextvars) +- [ ] main.py 31 個 loop 全部帶 project_id context(PR-10 完成) +- [ ] `platform_internal` loop 帶 `project_id=__platform__` +- [ ] `legacy_awoooi_default` loop 帶 `project_id=awoooi` +- [ ] 啟動順序 D3 被 FastAPI lifespan 強制執行 +- [ ] `platform:ollama:topology` key 在啟動時正確初始化 + +## 關聯 + +- ADR-106(架構)、ADR-107(儲存)、ADR-110(GCP Ollama 拓撲) +- ADR-123(Background Loop Migration Strategy,配套 ADR) +- INV-3(Entrypoints)、INV-8(Background Loop Catalog) diff --git a/docs/adr/ADR-112-awooop-contract-governance.md b/docs/adr/ADR-112-awooop-contract-governance.md new file mode 100644 index 000000000..654328476 --- /dev/null +++ b/docs/adr/ADR-112-awooop-contract-governance.md @@ -0,0 +1,166 @@ +# ADR-112: AwoooP Contract Governance & Publishing Workflow + +**狀態**:Accepted +**日期**:2026-05-03(台北) +**決策者**:統帥 +**範圍**:誰可以 publish / activate contract revision、如何簽章、approval workflow +**關聯**:ADR-106(六合約)、ADR-107(儲存)、ADR-113(outbox) + +--- + +## 背景 + +ADR-107 D2 確定了「每個 published contract 必須產生一個 immutable revision」,但沒有定義: + +1. 誰有權限 publish 一個 contract revision? +2. 誰有權限 activate(切換 active pointer)? +3. 如何防止未授權的 contract 修改影響正在運行的 runtime? +4. 簽章機制是什麼? +5. publish 和 activate 的 approval workflow 如何設計? + +--- + +## 決策 + +### D1 — 三層授權模型 + +| 操作 | 允許者 | 條件 | +|------|--------|------| +| `draft` | project member(任何有 project 寫權限的人)| 無限制 | +| `publish` | contract_publisher(特定角色)| 簽章驗證通過 | +| `activate` | contract_activator(特定角色)OR 自動(若已預審)| approval workflow 通過 | + +### D2 — 簽章規格 + +每次 publish 操作必須帶有: + +``` +HMAC-SHA256(body_json_canonical + revision_id + publisher_id + timestamp, signing_key) +``` + +- `signing_key`:從 K8s Secret `awooop-contract-signing-key` 讀取 +- `body_json_canonical`:JSON canonical form(sorted keys) +- 簽章存在 `awooop_contract_revisions.publish_signature` 欄位 +- runtime 讀取 active revision 時**不**驗簽(已在 publish 時驗過) +- audit 查詢時可重新驗簽(forensic) + +### D3 — Activation Approval Workflow + +activate 操作分為兩種路徑: + +**路徑 A(需 approval)**: +- 六合約中的 `policy_routing`、`mcp_gateway`、`runtime_run_state` contract +- 因為這些 contract 直接影響運行中的 run 和安全邊界 +- 走 multi_sig_redis approval flow(已有),加入 `approval_token` HS256 簽章(ADR-116) + +**路徑 B(pre-approved,可直接 activate)**: +- `agent` contract(只改 I/O schema,不改安全邊界) +- `project_tenant` contract(只改 display_name 等 metadata) +- `channel_event` contract(channel 設定變更) +- 但仍需 CODEOWNERS review + +### D4 — CODEOWNERS + +``` +# .github/CODEOWNERS(或 Gitea 等效) +packages/awooop-contracts/ @awoooi-platform-team +apps/api/src/core/config.py @awoooi-platform-team +docs/adr/ADR-11*.md @awoooi-platform-team +``` + +### D5 — 版本命名規則 + +``` +{contract_family}/{contract_id}/v{major}.{minor} + +例: + agent/openclaw-sre/v1.0 + policy_routing/awoooi-sre/v2.1 + mcp_gateway/awoooi-k8s/v1.3 +``` + +- major bump:breaking change(需 activation approval + 30 天 migration) +- minor bump:backward compatible(pre-approved 路徑) + +### D6 — Draft 隔離保證 + +- draft revision 的 `lifecycle_status = 'draft'` +- runtime reads 只走 `awooop_published_revisions` view(不含 draft) +- DB 層:`awooop_published_revisions` view 的 WHERE 子句保證隔離 +- 應用層:contract service 的 `get_active()` 方法只查 published + active + +### D7 — Audit 要求 + +所有 draft / publish / activate / rollback 操作必須寫入 `audit_logs`: +``` +{action, contract_family, contract_id, revision_id, actor_id, project_id, trace_id, timestamp, result} +``` + +--- + +## 實作細節 + +### Publish API + +``` +POST /v1/platform/projects/{project_id}/contracts/{contract_id}/publish + +Request: +{ + "revision_id": "uuid", + "body_json": {...}, + "body_schema_version": "v1.0", + "publish_signature": "HMAC-SHA256(...)" +} + +Response: +{ + "revision_id": "uuid", + "body_hash": "sha256hex", + "published_at": "2026-05-03T..." +} +``` + +### Activation API(需 approval) + +``` +POST /v1/platform/projects/{project_id}/contracts/{contract_id}/activate + +Request: +{ + "revision_id": "uuid", + "approval_token": "HS256..." # 對於需要 approval 的路徑 +} +``` + +--- + +## 後果 + +### Benefits +- contract 修改有完整 audit trail +- runtime 不可能意外讀到未發布的 draft +- breaking change 有 30 天 migration window 保護 + +### Costs +- publish 操作需要 signing_key(K8s Secret 管理) +- 開發者 activate policy_routing contract 需要走 approval(增加摩擦) + +### Risks +- signing_key rotation 可能導致歷史簽章驗證失敗(forensic 用途) +- 緩解:簽章存入 DB,rotation 後歷史記錄仍可用舊 key 驗(key 版本號記錄) + +--- + +## 驗收標準 + +- [ ] `awooop_contract_revisions` 表有 `publish_signature` 欄位 +- [ ] publish API 驗簽,失敗時拒絕 +- [ ] runtime `get_active()` 不返回 draft(整合測試) +- [ ] activation approval workflow(路徑 A)通過 vuln-verifier PoC +- [ ] CODEOWNERS 設定完成 + +## 關聯 + +- ADR-106(六合約定義)、ADR-107(contract revision schema) +- ADR-113(active revision 切換 outbox)、ADR-116(approval token security) diff --git a/docs/adr/ADR-113-awooop-active-revision-outbox.md b/docs/adr/ADR-113-awooop-active-revision-outbox.md new file mode 100644 index 000000000..08d0e6484 --- /dev/null +++ b/docs/adr/ADR-113-awooop-active-revision-outbox.md @@ -0,0 +1,182 @@ +# ADR-113: AwoooP Active Revision Invalidation & Outbox + +**狀態**:Accepted +**日期**:2026-05-03(台北) +**決策者**:統帥 +**範圍**:active revision 切換時的 worker cache 失效通知機制 +**關聯**:ADR-107(D4 Redis cache)、ADR-112(activation workflow) + +--- + +## 背景 + +ADR-107 D4 規定: +- Redis 可作為 contract revision 的 cache +- 但 Redis 不能是 source of truth +- 任何 cached value 必須帶 source `revision_id` 和 `body_hash` + +問題:當 active revision 切換(activate 操作)時,正在運行的 worker 仍持有舊 revision 的 cache。 +如果不通知,worker 可能繼續用舊 policy 執行,直到 TTL 過期(可能是數分鐘)。 + +這在 `policy_routing` contract 切換時特別危險:worker 可能用舊的 model routing policy 執行。 + +--- + +## 問題 + +1. activate 操作是 DB 事務,成功後如何通知所有 worker 失效 cache? +2. 如果 Redis pub/sub 通知丟失(Redis 重啟),worker 如何知道自己的 cache 過期了? +3. 跨 Pod 的多個 worker 如何收到通知? +4. 如何防止 split-brain(Worker A 用新 revision,Worker B 仍用舊 revision)? + +--- + +## 決策 + +### D1 — Transactional Outbox 模式 + +activate 操作必須在**同一個 DB 事務**中: +1. 更新 `awooop_active_revisions`(切換 active pointer) +2. 在 `awooop_contract_outbox` 寫入一筆事件 + +```sql +-- 在同一個事務中 +BEGIN; + UPDATE awooop_active_revisions + SET active_revision_id = 'new-revision-uuid', + updated_at = NOW() + WHERE project_id = 'awoooi' + AND contract_family = 'policy_routing' + AND contract_id = 'awoooi-sre'; + + INSERT INTO awooop_contract_outbox + (event_id, event_type, project_id, contract_family, contract_id, + old_revision_id, new_revision_id, created_at, delivered_at) + VALUES + (gen_random_uuid(), 'revision_activated', 'awoooi', 'policy_routing', 'awoooi-sre', + 'old-revision-uuid', 'new-revision-uuid', NOW(), NULL); +COMMIT; +``` + +### D2 — Outbox Relay Worker + +一個 outbox relay loop(每 1 秒掃描): + +```python +async def run_contract_outbox_relay(): + while True: + # 取未送出的 outbox 事件(SELECT FOR UPDATE SKIP LOCKED) + pending = await get_pending_outbox_events(limit=10) + for event in pending: + # 1. 發布 Redis pub/sub 通知 + await redis.publish( + f"platform:contract:invalidate:{event.project_id}", + json.dumps({ + "contract_family": event.contract_family, + "contract_id": event.contract_id, + "new_revision_id": str(event.new_revision_id) + }) + ) + # 2. 更新 outbox delivered_at + await mark_outbox_delivered(event.event_id) + await asyncio.sleep(1) +``` + +### D3 — Worker 端 Cache 設計 + +每個 worker 的 revision cache 必須: + +1. **帶 revision_id**:`cache_value = (body_json, revision_id, body_hash, cached_at)` +2. **訂閱 Redis pub/sub**:每個 worker 訂閱 `platform:contract:invalidate:*` +3. **收到通知時**:比對 `revision_id`,若有差異立即清除 local cache +4. **Fallback(Redis 通知丟失時)**:每次使用 cache 前,**對比 DB active pointer 的 revision_id** + - 若 cache 的 revision_id ≠ DB 的 active revision_id → 清除 cache,重新讀 DB + - 比對頻率:低優先度 contract(channel_event)每 5min;高優先度(policy_routing)每 30s + +### D4 — Split-Brain 防禦 + +Runtime 在每次 LLM call 前必須做一次 revision_id 比對: + +```python +async def get_effective_policy(project_id: str, contract_id: str) -> EffectivePolicy: + cached = await get_from_cache(project_id, contract_id) + if cached: + # 驗證 revision_id 仍是當前 active + db_active_id = await get_active_revision_id_from_db(project_id, contract_id) + if cached.revision_id != db_active_id: + await invalidate_cache(project_id, contract_id) + cached = None + if not cached: + revision = await get_active_revision_from_db(project_id, contract_id) + cached = build_effective_policy(revision) + await set_cache(project_id, contract_id, cached) + return cached +``` + +### D5 — GCP Ollama 拓撲的 outbox + +ADR-110 的 GCP Ollama topology 切換也走 outbox: +- `contract_family = 'platform_resource'` +- `contract_id = 'ollama_topology'` +- `project_id = '__platform__'` +- 所有 worker 收到通知後重置 Ollama client 連線 + +--- + +## awooop_contract_outbox 表設計 + +```sql +CREATE TABLE awooop_contract_outbox ( + event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_type VARCHAR(64) NOT NULL, -- 'revision_activated', 'revision_revoked', etc. + project_id VARCHAR(64) NOT NULL, + contract_family VARCHAR(32) NOT NULL, + contract_id VARCHAR(128) NOT NULL, + old_revision_id UUID, + new_revision_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + delivered_at TIMESTAMPTZ, -- NULL = 未送出 + relay_attempts INT NOT NULL DEFAULT 0, + CONSTRAINT fk_new_revision + FOREIGN KEY (new_revision_id) + REFERENCES awooop_contract_revisions(revision_id) +); + +-- Index for relay worker +CREATE INDEX idx_outbox_undelivered ON awooop_contract_outbox(created_at) + WHERE delivered_at IS NULL; +``` + +--- + +## 後果 + +### Benefits +- 事務保證:activate 操作失敗時,outbox 也不會寫入(不會有假通知) +- Redis pub/sub 丟失時,fallback 比對機制保底 +- Split-brain 防禦:每次 LLM call 前比對 revision_id + +### Costs +- 需要額外的 outbox relay loop(31 個 loop 再加一個) +- 每次 LLM call 前有一次 DB read(可用短 TTL cache 降低頻率) + +### Risks +- outbox relay worker 掛掉時,Redis 通知延遲(但 fallback 比對仍有效) +- 緩解:outbox relay loop 加入 `run_ai_slo_watchdog_loop()` 的監控 +- DB active pointer 讀取在高 QPS 下可能成為瓶頸 +- 緩解:Redis 短 TTL cache(30s)+ 只在 TTL 過期時才查 DB + +--- + +## 驗收標準 + +- [ ] `awooop_contract_outbox` 表建立(Phase 1 migration) +- [ ] outbox relay loop 加入 main.py(標記 `platform_internal`) +- [ ] worker cache 的 split-brain 防禦測試:activate 後 ≤60s 所有 worker 使用新 revision +- [ ] GCP Ollama topology 切換走 outbox(ADR-110 整合) +- [ ] Redis pub/sub 失效模擬測試通過(worker 靠 fallback 比對恢復) + +## 關聯 + +- ADR-107(Redis cache 規範)、ADR-112(activation workflow) +- ADR-110(GCP Ollama topology,特殊 platform_resource outbox 事件) diff --git a/docs/adr/ADR-114-awooop-idempotency-worker-lease.md b/docs/adr/ADR-114-awooop-idempotency-worker-lease.md new file mode 100644 index 000000000..6af0f3439 --- /dev/null +++ b/docs/adr/ADR-114-awooop-idempotency-worker-lease.md @@ -0,0 +1,187 @@ +# ADR-114: AwoooP Idempotency, Worker Lease & Run Recovery + +**狀態**:Accepted +**日期**:2026-05-03(台北) +**決策者**:統帥 +**範圍**:channel event 去重、run state 的 worker lease、stale run 回收 +**關聯**:ADR-106(Run State contract)、ADR-107(儲存)、ADR-119(SAGA) + +--- + +## 背景 + +AwoooP 的 async runtime 面臨兩個核心挑戰: + +**挑戰 1:Idempotency(冪等性)** +- Telegram bot retry / Alertmanager retry 可能同一個事件送來多次 +- 若不去重,會建立多個重複的 run(造成重複告警、重複 approval 請求) + +**挑戰 2:Worker 可靠性** +- Worker pod crash 後,`RUNNING` 狀態的 run 會永遠卡住 +- 需要 lease 機制:worker 定期續租,超時的 run 被回收 + +--- + +## 決策 + +### D1 — Channel Event Idempotency Key + +每個 channel event 的 idempotency key 由三個欄位組成複合唯一鍵: + +``` +(project_id, channel_type, provider_event_id) +``` + +- `project_id`:tenant 隔離 +- `channel_type`:`telegram` / `alertmanager` / `gitea` / `line` / `api` +- `provider_event_id`: + - Telegram:`update_id` + - Alertmanager:`groupKey` + fingerprint hash + - Gitea webhook:`X-Gitea-Delivery` header + - LINE:`webhookEventId` + - API:caller-provided `idempotency_key` header + +```sql +CREATE TABLE awooop_channel_event_dedupe ( + dedupe_id UUID PRIMARY KEY 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(), + UNIQUE(project_id, channel_type, provider_event_id) +); +``` + +**重複事件的處理**: +- 第一次到達 → INSERT dedupe 記錄 + 建立 run → 返回 run_id +- 重複到達 → ON CONFLICT DO NOTHING → 查詢已有 run_id → 返回 E-IDEMPOTENT-001 (HTTP 200) + +### D2 — Worker Lease 機制 + +`awooop_run_state` 表的 lease 相關欄位: + +```sql +-- 在 awooop_run_state 表中 +lease_until TIMESTAMPTZ, -- worker 必須在此時間前 renew,否則 run 被視為 stale +heartbeat_at TIMESTAMPTZ, -- worker 最後一次 heartbeat 時間 +attempt_count INT NOT NULL DEFAULT 0, -- 總嘗試次數 +worker_id VARCHAR(128), -- 目前持有 lease 的 worker pod ID +``` + +**取單規則(SKIP LOCKED)**: + +```sql +-- Worker 取 run 時 +SELECT * FROM awooop_run_state +WHERE status = 'PENDING' + AND (lease_until IS NULL OR lease_until < NOW()) +ORDER BY created_at ASC +LIMIT 1 +FOR UPDATE SKIP LOCKED; + +-- 取到後,更新 lease +UPDATE awooop_run_state +SET status = 'RUNNING', + lease_until = NOW() + INTERVAL '5 minutes', + heartbeat_at = NOW(), + attempt_count = attempt_count + 1, + worker_id = '{this_pod_id}' +WHERE run_id = '{run_id}'; +``` + +**Heartbeat renew(每 30 秒)**: + +```sql +UPDATE awooop_run_state +SET lease_until = NOW() + INTERVAL '5 minutes', + heartbeat_at = NOW() +WHERE run_id = '{run_id}' + AND worker_id = '{this_pod_id}' + AND status = 'RUNNING'; +-- 若 rows affected = 0,表示 lease 已被 stale reaper 搶走,worker 應停止執行 +``` + +### D3 — Stale Run Reaper + +一個 platform_internal loop(每 1 分鐘),掃描並回收 stale run: + +```sql +-- Stale 條件:RUNNING 狀態 + lease 已過期 +UPDATE awooop_run_state +SET status = 'PENDING', -- 回到 PENDING,等待 worker 重新取 + lease_until = NULL, + worker_id = NULL, + reaped_at = NOW() +WHERE status = 'RUNNING' + AND lease_until < NOW() -- lease 過期 + AND attempt_count < 3; -- 最多 3 次嘗試 + +-- 超過 3 次仍失敗 → FAILED +UPDATE awooop_run_state +SET status = 'FAILED', + failure_reason = 'Max retry attempts exceeded' +WHERE status = 'RUNNING' + AND lease_until < NOW() + AND attempt_count >= 3; +``` + +### D4 — Run 狀態機(有效狀態列表) + +``` +PENDING → 等待 worker 取單 +RUNNING → worker 持有 lease 中 +WAITING_TOOL → 等待 MCP tool 回應(非 blocking,worker 可以做其他事) +WAITING_APPROVAL → 等待人工 approval(blocking,lease renew 停止) +RESUMED → approval 後從 WAITING_APPROVAL 繼續 +COMPLETED → 正常結束 +FAILED → 失敗(含超過最大重試) +CANCELLED → 主動取消 +``` + +**特別注意 WAITING_APPROVAL**: +- worker 停止 lease renew(approval 可能等很久) +- run 不會被 stale reaper 回收(stale reaper 不處理 WAITING_APPROVAL 狀態) +- approval 過期(超過 `approval_token` TTL 15min)→ 自動 → FAILED(E-APPROVAL-002) + +### D5 — 最大重試次數與 SAGA + +- 預設最大重試:3 次(可由 agent contract 配置) +- 每次 worker 取單:`attempt_count + 1` +- `attempt_count >= max_attempts` → 不再回收,直接 FAILED +- SAGA journal(ADR-119):每次 attempt 的 step 結果記錄在 `saga_steps` JSONB + +--- + +## 後果 + +### Benefits +- Telegram/Alertmanager retry 不會建立重複 run(idempotency key) +- Worker crash 後 stale run 1 分鐘內被回收 +- SKIP LOCKED 確保多 worker 不會競爭同一個 run + +### Costs +- 每個 channel event 需要查詢 dedupe 表(一次 DB read) +- Lease renew 每 30 秒一次 DB write(高 QPS 下需要監控) + +### Risks +- dedupe 表若不 partition,會隨時間無限增長 +- 緩解:dedupe 記錄 TTL 7 天(超過 7 天的 event 重複送達視為新 event) +- lease TTL 設太短 → 正常 run 被回收;設太長 → crash 後恢復慢 +- 緩解:5 分鐘 lease + 30 秒 heartbeat = 可接受的平衡 + +--- + +## 驗收標準 + +- [ ] `awooop_channel_event_dedupe` 表建立(Phase 1 migration) +- [ ] 重複 Telegram update_id 不建立重複 run(整合測試) +- [ ] Worker crash 後 stale run 1 分鐘內被回收(模擬測試) +- [ ] SKIP LOCKED:多 worker 不競爭同一 run(並發測試 10 workers) +- [ ] WAITING_APPROVAL run 不被 stale reaper 誤回收 + +## 關聯 + +- ADR-106(Run State contract)、ADR-116(approval token security) +- ADR-119(SAGA 補償,`saga_steps` JSONB 在 run_state 表) +- INV-1(Redis dedupe key 設計) diff --git a/docs/adr/ADR-115-awooop-principal-mapping-tenant-onboarding.md b/docs/adr/ADR-115-awooop-principal-mapping-tenant-onboarding.md new file mode 100644 index 000000000..b22a1c800 --- /dev/null +++ b/docs/adr/ADR-115-awooop-principal-mapping-tenant-onboarding.md @@ -0,0 +1,174 @@ +# ADR-115: Canonical Principal Mapping & Tenant Onboarding Patterns + +**狀態**:Accepted +**日期**:2026-05-03(台北) +**決策者**:統帥 +**範圍**:所有 channel 的使用者身份統一映射、EwoooC Provider Proxy Adapter 設計 +**關聯**:ADR-106(Communication contract)、ADR-107(儲存)、ADR-110(GCP Ollama) + +--- + +## 背景 + +問題 1 — **Principal 分裂**: +- Telegram 使用者有 `user_id` + `chat_id` +- LINE 使用者有 `userId` + `chatId`(格式不同) +- API 呼叫者有 `api_key` 或 `jwt_sub` +- 內部 service 呼叫有 service account name +- 沒有統一的身份映射,多 tenant 場景下無法做 ACL 和 audit + +問題 2 — **EwoooC 接入零技術路徑**: +- MASTER-WORKPLAN P1-02:不能只改 `OLLAMA_API_BASE` +- EwoooC 需要完整的 `project_id` / `agent_id` / `trace_id` / `run_id` envelope +- 否則 budget、audit、policy 全部無效 + +問題 3 — **telemetry.py:71 硬碼 IP**(P0-08): +- `if "192.168.0.188" not in endpoint: raise` 阻止 EwoooC 使用不同的 OTEL endpoint +- 已列入 INV-4,PR-01 修補 + +--- + +## 決策 + +### D1 — Canonical Platform Subject(統一身份) + +所有 channel 的使用者映射到 `awooop_platform_subjects` 表: + +```sql +CREATE TABLE awooop_platform_subjects ( + subject_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id VARCHAR(64) NOT NULL REFERENCES awooop_projects(project_id), + -- 原始身份(channel-specific) + channel_type VARCHAR(32) NOT NULL, -- telegram/line/slack/api/internal + channel_user_id VARCHAR(256) NOT NULL, -- Telegram: user_id, LINE: userId + channel_chat_id VARCHAR(256), -- Telegram: chat_id, LINE: chatId + -- 統一身份 + platform_subject_id VARCHAR(128) NOT NULL, -- {project_id}:{channel_type}:{channel_user_id} + display_name VARCHAR(256), + -- Roles / ACL + roles JSONB NOT NULL DEFAULT '[]', -- ["admin", "approver", "viewer"] + -- Metadata + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(project_id, channel_type, channel_user_id) +); +``` + +**platform_subject_id 格式**: +``` +{project_id}:{channel_type}:{channel_user_id} + +例: + awoooi:telegram:123456789 + ewoooc:line:Uf987654321 + awoooi:api:svc-account-k8s-monitor + awoooi:internal:slo-watchdog-job +``` + +### D2 — Principal Mapping 規則 + +| Channel | channel_user_id 取自 | channel_chat_id 取自 | +|---------|---------------------|---------------------| +| Telegram | `message.from.id` | `message.chat.id` | +| LINE | `event.source.userId` | `event.source.groupId` / `roomId` | +| Slack | `event.user` | `event.channel` | +| API(JWT)| JWT `sub` claim | N/A | +| API(API key)| api_key SHA256 prefix | N/A | +| Internal service | service account name | N/A | + +**Auto-provisioning 規則**: +- 新 channel user 第一次出現 → 自動 INSERT platform_subjects(roles=['viewer']) +- 管理員手動升級 roles(admin 操作) +- 敏感操作(approval、activate contract):必須有 `approver` role + +### D3 — EwoooC Provider Proxy Adapter + +EwoooC 接入 AwoooP 的完整路徑(不只是改 URL): + +``` +EwoooC 請求 + ↓ +Provider Proxy Adapter(新建 apps/api/src/services/provider_proxy.py) + ↓ +注入 envelope: + { + "project_id": "ewoooc", + "agent_id": "openclaw-biz", + "trace_id": "", + "run_id": "", + "policy_revision_id": "", + "channel_type": "telegram", + "platform_subject_id": "ewoooc:telegram:{user_id}" + } + ↓ +EffectivePolicy 解析(ewoooc 的 policy contract) + ↓ +Budget Guard(ewoooc 的 budget_ledger) + ↓ +GCP Ollama 路由(platform:ollama:topology,三層共用) + ↓ +Audit(project_id=ewoooc) +``` + +**關鍵限制(EwoooC 接入時)**: +- EwoooC 使用 GCP Ollama 三層拓撲(ADR-110),不能指定自己的 Ollama host +- EwoooC budget 計數在 `budget_ledger` 的 `project_id=ewoooc` partition,與 AWOOOI 隔離 +- EwoooC 的 run 不可讀取 AWOOOI 的 context、incident、KM(RLS 保護) + +### D4 — 其他 Tenant Onboarding 模板(Tsenyang / Bitan) + +每個新 tenant 的 onboarding 步驟: + +``` +1. INSERT INTO awooop_projects(project_id, display_name, migration_mode='shadow') +2. 建立 Project/Tenant contract(版本 v1.0,包含 allowed_k8s_namespaces、budget_limit、channel_config) +3. 建立 Agent contract(至少一個 agent) +4. 建立 Policy/Routing contract(model routing policy) +5. Provider Proxy Adapter 設定(指向 platform GCP Ollama topology) +6. 第一個 shadow run 驗證(確認 project_id 正確、audit 有資料) +7. 進入 shadow → canary gate 觀察期 +``` + +### D5 — 跨 Tenant 隔離驗證 + +以下操作必須在 EwoooC onboarding 時通過 vuln-verifier PoC: +- `SELECT * FROM incidents WHERE incident_id = ''` 在 ewoooc project context → 空結果(RLS 拒絕) +- `GET /v1/platform/runs/{awoooi_run_id}` 帶 ewoooc token → 403 E-TENANT-001 +- EwoooC LLM call 的 token 計數進 ewoooc budget,不進 awoooi budget + +--- + +## 後果 + +### Benefits +- 所有 channel 的使用者身份統一,ACL 和 audit 可以跨 channel 查詢 +- EwoooC 接入有完整 envelope,budget / audit / policy 全部有效 +- Tsenyang / Bitan 未來接入有現成模板,不需要重新設計 + +### Costs +- 需要建立 `awooop_platform_subjects` 表並在每次請求時 upsert(IO 成本) +- Provider Proxy Adapter 是新建模組(約 300-500 行) + +### Risks +- platform_subject_id 格式若未來 channel_user_id 變化 → 需要 migration +- 緩解:UUID `subject_id` 是 PK,`platform_subject_id` 只是顯示欄位,改 format 只需更新 display,不影響 FK +- EwoooC Provider Proxy 若 latency 過高 → EwoooC 回應慢 +- 緩解:Proxy 只做 envelope 注入(<1ms),不做額外 IO + +--- + +## 驗收標準 + +- [ ] `awooop_platform_subjects` 表建立(Phase 1 migration) +- [ ] Telegram 新 user 第一次請求 → 自動建立 platform_subject(整合測試) +- [ ] Provider Proxy Adapter 建立(Phase 6 before EwoooC onboarding) +- [ ] EwoooC run 無法讀取 AWOOOI data(cross-tenant RLS vuln-verifier PoC) +- [ ] EwoooC budget 與 AWOOOI budget 獨立計算(整合測試) +- [ ] `telemetry.py:71` hardcoded IP assert 已移除(PR-01 完成,Phase 2 前) + +## 關聯 + +- ADR-106(Communication contract)、ADR-107(儲存) +- ADR-110(GCP Ollama 三層拓撲,EwoooC 共用) +- ADR-116(security hardening)、ADR-118(RLS) +- INV-4(hardcoded IP,telemetry.py:71 修補) diff --git a/docs/adr/ADR-116-awooop-security-hardening.md b/docs/adr/ADR-116-awooop-security-hardening.md new file mode 100644 index 000000000..472b65c76 --- /dev/null +++ b/docs/adr/ADR-116-awooop-security-hardening.md @@ -0,0 +1,218 @@ +# ADR-116: AwoooP Security Hardening + +**狀態**:Accepted +**日期**:2026-05-03(台北) +**決策者**:統帥 +**範圍**:callback nonce 修補、webhook replay 防護、approval_token 規格、requires_approval 修補 +**關聯**:ADR-112(contract governance)、ADR-114(approval flow) + +--- + +## 背景 + +vuln-verifier 對 codebase 進行 PoC 驗證,確認三個安全漏洞真實存在: + +### 漏洞 1:Callback Nonce 偽造(P0-05) +- 位置:`apps/api/src/services/security_interceptor.py:451-490` +- 問題:server nonce 的驗證邏輯不依賴 `server_secret`,攻擊者可在不知道 secret 的情況下構造通過驗證的 nonce +- PoC:已確認可通過驗證 + +### 漏洞 2:Webhook HMAC Replay 無防護(P0-06) +- 位置:`apps/api/src/api/v1/webhooks.py:679-728` +- 問題:webhook HMAC 驗證只驗 body hash,沒有 timestamp 或 nonce +- PoC:截取合法 webhook request,重放到任意時間,仍通過驗證 + +### 漏洞 3:requires_approval 由 LLM Output 決定(P0-04) +- 位置:`apps/api/src/services/decision_manager.py`(approval 鏈) +- 問題:`requires_approval` 欄位的值來自 LLM 的 response,攻擊者可透過 prompt injection 讓 LLM 輸出 `requires_approval=false`,繞過 approval 要求 +- PoC:已確認可繞過 + +--- + +## 決策 + +### D1 — Callback Nonce 重設計 + +**修補前(不安全)**: +```python +# server 端生成 nonce:僅用時間戳或隨機值,不含 secret +server_nonce = hash(timestamp + random) +# 驗證時:只比對 client 提供的 nonce == server_nonce(不驗 secret 參與) +``` + +**修補後(安全)**: +```python +import hmac, hashlib, secrets + +def generate_callback_nonce(run_id: str, server_secret: str) -> str: + """生成含 secret 的 nonce,防止偽造""" + salt = secrets.token_hex(16) + message = f"{run_id}:{salt}:{int(time.time())}" + signature = hmac.new( + server_secret.encode(), + message.encode(), + hashlib.sha256 + ).hexdigest() + return f"{salt}:{signature}" + +def verify_callback_nonce(nonce: str, run_id: str, server_secret: str, + max_age_seconds: int = 300) -> bool: + """驗證 nonce,含 server_secret 參與,拒絕超過 5 分鐘的 nonce""" + try: + salt, provided_sig = nonce.split(":", 1) + # 重建 message(注意:時間驗證需要 timestamp 在 nonce 中) + # 完整實作需在 nonce 中包含 timestamp + ... + except ValueError: + return False + +# server_secret 從 K8s Secret 注入(settings.CALLBACK_NONCE_SECRET) +# 禁止 hardcode +``` + +### D2 — Webhook Timestamp + Nonce + +**修補方案**: + +每個 webhook request 必須帶: +1. `X-Timestamp` header:Unix timestamp(秒) +2. `X-Nonce` header:16-byte random hex(防 replay) + +驗證邏輯: +```python +async def verify_webhook_security(request: Request, body: bytes) -> None: + timestamp = request.headers.get("X-Timestamp") + nonce = request.headers.get("X-Nonce") + + if not timestamp or not nonce: + raise WebhookSecurityError("Missing security headers") + + # 1. Timestamp 驗證(±5 分鐘 window) + ts = int(timestamp) + if abs(time.time() - ts) > 300: + raise WebhookSecurityError("Timestamp out of window") + + # 2. Nonce 去重(Redis NX,TTL 10 分鐘) + nonce_key = f"platform:webhook:nonce:{nonce}" + if not await redis.set(nonce_key, "1", nx=True, ex=600): + raise WebhookSecurityError("Nonce already used (replay attack)") + + # 3. HMAC 驗證(原有邏輯,保留) + # hmac_header = request.headers.get("X-Hub-Signature-256") + # verify_hmac(body, hmac_header, settings.WEBHOOK_SECRET) +``` + +**對 Gitea / Alertmanager 的影響**: +- Gitea webhook:支援 secret 但未必加 timestamp,需要在 Gitea 設定端更新 secret policy +- Alertmanager:不支援自訂 header,例外處理:Alertmanager 請求允許 timestamp 寬鬆 window(改為 ±10 分鐘,但仍要 nonce) +- 內部呼叫(K8s Job / internal services):使用 mutual TLS(mTLS)代替,不需要 timestamp/nonce + +### D3 — requires_approval 改為 Policy-Derived + +**修補前(不安全)**: +```python +# LLM response 中有 requires_approval 欄位,被直接採用 +decision = llm_response.get("requires_approval", False) +``` + +**修補後(安全)**: +```python +async def should_require_approval( + project_id: str, + agent_id: str, + action_type: str, + risk_level: str, + effective_policy: EffectivePolicy +) -> bool: + """ + approval 要求從 policy contract 決定,禁止由 LLM output 決定。 + LLM 的 'requires_approval' 欄位被忽略。 + """ + # 從 effective_policy 的 approval_gates 讀取 + for gate in effective_policy.approval_gates: + if gate.matches(action_type=action_type, risk_level=risk_level): + return True + return False +``` + +**規則**: +- `action_type=auto_repair` + `risk_level >= HIGH` → 必須 approval +- `action_type=knowledge_write` → 不需 approval(低風險) +- `action_type=destructive_mcp_tool` → 必須 approval(不論 risk_level) +- 以上規則寫在 policy contract,不在 LLM prompt 中 + +### D4 — approval_token 規格(Phase 8) + +``` +算法:HS256 +簽章 Key:K8s Secret `awooop-approval-signing-key` + +Payload: + iss: "awooop-platform" + sub: "{project_id}:{run_id}" + aud: "awooop-approval" + jti: UUID(唯一 ID,存 Redis NX,TTL 15min) + exp: now + 900(15 分鐘) + iat: now + approval_type: "human" | "system" + decision_scope: [{tool_id}, ...] # 這個 token 授權哪些 tool 的執行 + +驗證(Phase 8 resume API): + 1. HS256 簽章驗證 + 2. exp 未過期 + 3. Redis NX:SET awooop:approval:jti:{jti} "used" NX EX 900 + → 若 SET 失敗 → E-APPROVAL-003(已使用) + 4. sub 的 run_id 與 API path 的 run_id 一致 + 5. decision_scope 包含 run 要求執行的 tool +``` + +### D5 — Secret 管理 + +所有 security-critical secret 必須從 K8s Secret 注入,禁止硬碼: + +| Secret 名稱 | K8s Secret Key | 用途 | +|------------|---------------|------| +| `CALLBACK_NONCE_SECRET` | `awooop-security/callback-nonce-secret` | callback nonce HMAC | +| `WEBHOOK_SECRET` | 現有 `awoooi-secrets/webhook-secret` | webhook HMAC(已有)| +| `APPROVAL_SIGNING_KEY` | `awooop-security/approval-signing-key` | approval_token HS256 | +| `CONTRACT_SIGNING_KEY` | `awooop-security/contract-signing-key` | contract publish HMAC(ADR-112)| + +--- + +## 實作優先序(Phase 2) + +1. **PR-07a(立即)**:security_interceptor.py nonce 重設計 +2. **PR-07b(立即)**:webhooks.py timestamp + nonce 加入 +3. **PR-07c(立即)**:decision_manager.py requires_approval 改為 policy-derived +4. **Phase 4(approval_token)**:HS256 token 生成 + Phase 8 verify + +--- + +## 後果 + +### Benefits +- 三個 PoC 確認的漏洞全部修補 +- approval 決策權回到 policy(不再由 LLM 決定),防止 prompt injection 繞過 + +### Costs +- webhook caller 需要加 `X-Timestamp` + `X-Nonce` header(需要 Gitea/Alertmanager 設定調整) +- callback nonce 需要額外 Redis 查詢(nonce 去重) + +### Risks +- K8s Secret rotation 時,正在驗證中的 nonce/token 可能失效 +- 緩解:rotation 時短暫雙 key 驗證(新 key 生成後,舊 key 再維持 TTL 時間) + +--- + +## 驗收標準 + +- [ ] vuln-verifier PoC 重跑:P0-05 nonce 偽造失敗 +- [ ] vuln-verifier PoC 重跑:P0-06 webhook replay 失敗 +- [ ] vuln-verifier PoC 重跑:P0-04 LLM 無法決定 requires_approval +- [ ] Phase 8:approval_token 無 token resume 被拒絕(E-APPROVAL-001) + +## 關聯 + +- ADR-114(approval flow,WAITING_APPROVAL state) +- ADR-112(contract activation,approval workflow) +- ADR-122(OWASP Agentic AI Top 10 — OAI-03 Excessive Agency、OAI-09 Access Control Bypass) diff --git a/docs/adr/ADR-117-mcp-oauth2-confused-deputy.md b/docs/adr/ADR-117-mcp-oauth2-confused-deputy.md new file mode 100644 index 000000000..e1a416cd2 --- /dev/null +++ b/docs/adr/ADR-117-mcp-oauth2-confused-deputy.md @@ -0,0 +1,211 @@ +# ADR-117: MCP OAuth 2.1 & Confused Deputy Prevention + +**狀態**:Accepted +**日期**:2026-05-03(台北) +**決策者**:統帥 +**範圍**:MCP Gateway 對外授權規格、Confused Deputy 防護、OAuth 2.1 實作邊界 +**關聯**:ADR-106(MCP Gateway contract)、ADR-116(approval_token)、ADR-118(RLS) + +--- + +## 背景 + +AwoooP 的 MCP Gateway 代替 Agent 呼叫外部工具(K8s、資料庫、Prometheus)。這個設計有兩個核心風險: + +### 風險 1:Confused Deputy(混淆代理人攻擊) + +``` +攻擊者 → 發給 Agent A 一個 run:「幫我呼叫 mcp_tool=drop_table」 +Agent A → 用自己的 service token 向 MCP Gateway 請求 +MCP Gateway → 看到合法 service token → 執行 drop_table +``` + +問題:MCP Gateway 作為 Deputy,用自己的權限替攻擊者執行了操作,沒有驗證「這個 run 有沒有授權使用這個 tool」。 + +### 風險 2:Cross-Tenant Tool Access + +``` +EwoooC 的 run → 向 MCP Gateway 請求 tool=k8s_exec_pod +MCP Gateway → 若沒有 tenant 隔離 → 可能執行在 awoooi-prod namespace +``` + +### 現況 +- 現行 MCP Gateway 沒有 OAuth 2.1 授權流程 +- `mcp_bridge.py` 直接用 service token 向外部工具請求(Confused Deputy) +- 沒有 `aud`(audience)驗證(RFC 9728) +- 沒有 Dynamic Client Registration(RFC 7591) + +--- + +## 決策 + +### D1 — OAuth 2.1 Token 規格(MCP 呼叫鏈) + +**MCP Gateway Token(代表 run 向外部工具請求)**: + +``` +算法:HS256 +簽章 Key:K8s Secret `awooop-mcp-signing-key` + +Payload: + iss: "awooop-platform" + sub: "{project_id}:{run_id}" + aud: "{tool_endpoint}" # RFC 9728 Resource Indicators + jti: UUID(Redis NX,TTL tool_call_ttl) + exp: now + 60(60 秒,僅用於一次 tool call) + iat: now + scope: "{tool_id}" # 精確 tool,不是模糊 namespace + project_id: "{project_id}" + agent_id: "{agent_id}" + allowed_namespaces: [...] # 從 MCP Gateway contract 讀取,不從 LLM output + +驗證(MCP Gateway 端): + 1. HS256 簽章驗證 + 2. aud 必須 == 當前 tool endpoint(RFC 9728) + 3. exp 未過期(60s 短 TTL) + 4. jti Redis NX(防 replay) + 5. scope 包含請求的 tool_id + 6. allowed_namespaces 限制 K8s 操作範圍 +``` + +### D2 — Confused Deputy 防護 + +**三道防線**: + +**防線 1:run-level tool allowlist** + +每個 run 在建立時,從 Agent contract 的 `allowed_tools` 欄位讀取允許的 tool 清單。 +MCP Gateway 在執行前驗證:`tool_id in run.allowed_tools`。 + +```python +async def execute_mcp_tool( + run_id: str, + tool_id: str, + tool_params: dict, + mcp_token: str # HS256 token +) -> MCPResult: + # 1. 驗證 token(aud == tool endpoint, scope == tool_id) + claims = verify_mcp_token(mcp_token, audience=get_tool_endpoint(tool_id)) + + # 2. 驗證 run 有沒有授權使用這個 tool(防 Confused Deputy) + run = await get_run(run_id) + if tool_id not in run.allowed_tools: + raise MCPError("E-MCP-001", f"Tool {tool_id} not in run allowed_tools") + + # 3. 驗證 project_id 一致(防 cross-tenant) + if claims["project_id"] != run.project_id: + raise MCPError("E-MCP-002", "Cross-tenant tool access denied") + + # 4. 執行(allowed_namespaces 在 token 中,工具自行驗證) + return await _execute_tool(tool_id, tool_params, claims["allowed_namespaces"]) +``` + +**防線 2:allowed_namespaces 來自 contract** + +K8s 相關工具的命名空間限制必須從 MCP Gateway contract 讀取,不從 LLM output 讀取: + +```python +# MCP Gateway contract 中 +{ + "tool_id": "k8s_exec_pod", + "allowed_namespaces": ["awoooi-prod"], # 寫死在 contract + "risk_level": "HIGH", + "requires_approval": true # 高風險工具強制 approval +} +``` + +**防線 3:approval gate for HIGH risk tools** + +`risk_level=HIGH` 的 tool call 必須帶 `approval_token`(ADR-116 D4),且 `approval_token.decision_scope` 必須包含這個 `tool_id`。 + +### D3 — RFC 7591 Dynamic Client Registration(階段性) + +Phase 6(EwoooC onboarding)時才需要完整實作。 +當前 Phase 2 先用靜態 client_id(每個 project 一個預設 client)。 + +**Phase 6 目標**: +- `POST /v1/platform/oauth/register`:tenant 自助申請 MCP client +- Response 帶 `client_id` + `client_secret`(存 K8s Secret) +- Scopes 必須從 MCP Gateway contract 的 `allowed_tools` 交集 + +### D4 — PKCE(Authorization Code Flow) + +對 Human-in-the-loop 的 approval callback 使用 PKCE: +- `code_verifier`:256-bit random(agent 生成) +- `code_challenge`:S256 hash +- Redirect URI:`{platform_base_url}/v1/platform/oauth/callback` +- 防止 authorization code 截獲攻擊 + +### D5 — Scope 模型 + +``` +MCP scope 格式:{tool_category}:{tool_id}:{action} + +例: + k8s:k8s_exec_pod:exec # K8s pod exec + k8s:k8s_list_pods:read # K8s pod list(只讀) + prometheus:prometheus_query:read # Prometheus 查詢 + pg:pg_query:read # PostgreSQL 只讀查詢 + pg:pg_mutate:write # PostgreSQL 寫入(需 approval) +``` + +**Scope hierarchy**(MCP Gateway contract 中定義): +- `k8s:*:exec` → requires approval (HIGH) +- `k8s:*:read` → auto-approve if risk=LOW +- `pg:*:write` → requires approval (HIGH) + +--- + +## 實作要點 + +### mcp_bridge.py 修補優先序 + +| 漏洞 | 位置 | 修補方式 | PR | +|------|------|---------|-----| +| namespace hardcode | mcp_bridge.py:592,602,631,647,681 | 從 MCP Gateway contract 讀取 | PR-05 | +| 無 Confused Deputy 防護 | mcp_bridge.py(tool 執行路徑)| run.allowed_tools 驗證 | Phase 3 | +| 無 token aud 驗證 | mcp_bridge.py(token 生成)| 加入 RFC 9728 aud claim | Phase 3 | + +### MCP 錯誤碼 + +| 錯誤碼 | 含義 | +|--------|------| +| E-MCP-001 | Tool not in run.allowed_tools(Confused Deputy 攔截)| +| E-MCP-002 | Cross-tenant tool access denied | +| E-MCP-003 | aud mismatch(RFC 9728 violation)| +| E-MCP-004 | Scope insufficient for requested action | +| E-MCP-005 | HIGH risk tool requires approval_token | + +--- + +## 後果 + +### Benefits +- Confused Deputy 攻擊從根源切斷:每個 run 的 tool 授權在建立時固定 +- RFC 9728 aud 驗證:MCP token 無法被用於不同 tool endpoint +- allowed_namespaces 從 contract 讀取:LLM 無法透過 prompt injection 擴大 K8s 操作範圍 + +### Costs +- Phase 3 需要重構 mcp_bridge.py 的 token 生成邏輯 +- 每次 tool call 需要 Redis jti NX(約 1ms 額外延遲) + +### Risks +- PKCE 在 Phase 6 前未實作,approval callback 仍使用 approval_token(ADR-116 D4) +- 緩解:approval_token 已有 jti replay 防護,短期可接受 + +--- + +## 驗收標準 + +- [ ] `mcp_bridge.py` 命名空間 hardcode 移除(PR-05) +- [ ] MCP token 包含 `aud` + `scope`(Phase 3) +- [ ] Confused Deputy 防護:tool_id 不在 run.allowed_tools → E-MCP-001(整合測試) +- [ ] Cross-tenant:ewoooc token 無法執行 awoooi namespace 操作(vuln-verifier PoC) +- [ ] HIGH risk tool(k8s_exec_pod)無 approval_token → E-MCP-005 + +## 關聯 + +- ADR-106(MCP Gateway contract allowed_tools 定義) +- ADR-116(approval_token HS256,HIGH risk tool approval) +- ADR-118(RLS,tenant DB 隔離) +- INV-4(namespace hardcode 位置:mcp_bridge.py:592,602,631,647,681) diff --git a/docs/adr/ADR-118-row-level-security-tenant-isolation.md b/docs/adr/ADR-118-row-level-security-tenant-isolation.md new file mode 100644 index 000000000..80601eae9 --- /dev/null +++ b/docs/adr/ADR-118-row-level-security-tenant-isolation.md @@ -0,0 +1,233 @@ +# ADR-118: Row-Level Security & Tenant DB Isolation + +**狀態**:Accepted +**日期**:2026-05-03(台北) +**決策者**:統帥 +**範圍**:PostgreSQL RLS 設計、bypass role、application-layer RLS 補充、migration 策略 +**關聯**:ADR-107(儲存)、ADR-115(tenant onboarding)、ADR-116(security) + +--- + +## 背景 + +INV-2 確認:現有 20 張業務資料表全部缺少 `project_id` 欄位,沒有任何 Row-Level Security (RLS) 防護。 + +**風險**: +- EwoooC 的 LLM run 可透過 SQL injection 讀取 AWOOOI 的 incidents / knowledge_entries +- 跨 tenant audit_logs 洩漏 +- RLS 缺失讓 ADR-115 D5 的跨 tenant 隔離驗收無法通過 + +**設計目標**: +1. PostgreSQL 原生 RLS(不依賴 application-layer 單點防護) +2. 不影響現有的 legacy_awoooi_default 背景 loop(非破壞式上線) +3. Phase 1 migration 可分批上線(不需要一次 lock 所有表) + +--- + +## 決策 + +### D1 — PostgreSQL RLS 架構 + +**DB Role 分層**: + +```sql +-- 應用程式角色(最小權限) +CREATE ROLE awooop_app LOGIN; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO awooop_app; + +-- Platform 管理角色(可 bypass RLS) +CREATE ROLE awooop_platform_admin LOGIN; +GRANT awooop_app TO awooop_platform_admin; +ALTER ROLE awooop_platform_admin BYPASSRLS; + +-- Migration 角色(可 bypass RLS,用於 Alembic) +CREATE ROLE awooop_migration LOGIN; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO awooop_migration; +ALTER ROLE awooop_migration BYPASSRLS; +``` + +**注意**: +- 應用程式連線一律使用 `awooop_app`(受 RLS 約束) +- 背景 loop 在過渡期使用 `awooop_platform_admin`(bypass RLS,避免 loop 中斷) +- Alembic migration 使用 `awooop_migration` + +### D2 — project_id Context Variable 注入 + +應用程式在每次 DB 操作前,透過 `SET LOCAL` 設置當前 `project_id`: + +```python +# apps/api/src/db/session.py +from contextvars import ContextVar + +_project_id_ctx: ContextVar[str] = ContextVar("project_id", default="") + +async def get_db_session() -> AsyncSession: + async with AsyncSession(engine) as session: + project_id = _project_id_ctx.get("") + if project_id: + # SET LOCAL 只在當前事務中有效 + await session.execute( + text("SET LOCAL app.project_id = :pid"), + {"pid": project_id} + ) + yield session +``` + +**API 層注入**: + +```python +# FastAPI middleware / dependency +async def inject_project_id( + request: Request, + project_id: str = Path(...), + db: AsyncSession = Depends(get_db_session) +): + _project_id_ctx.set(project_id) + # SET LOCAL 已在 get_db_session 中執行 + yield +``` + +### D3 — RLS Policy 標準模板 + +每張有 `project_id` 的表套用統一 RLS 模板: + +```sql +-- 以 incidents 表為例 +ALTER TABLE incidents ENABLE ROW LEVEL SECURITY; +ALTER TABLE incidents FORCE ROW LEVEL SECURITY; -- 連 table owner 也受約束 + +-- 隔離 policy:只能看到自己 project 的資料 +CREATE POLICY incidents_tenant_isolation ON incidents + USING ( + project_id = current_setting('app.project_id', TRUE) + OR current_setting('app.project_id', TRUE) = '' -- 空字串 = 允許(過渡期) + ); + +-- platform_internal 操作:project_id = '__platform__' 可讀全部 +CREATE POLICY incidents_platform_admin ON incidents + USING ( + current_setting('app.project_id', TRUE) = '__platform__' + ); +``` + +**`current_setting('app.project_id', TRUE)` 說明**: +- 第二個參數 `TRUE` = missing_ok,若 GUC 未設置則返回空字串 +- 空字串在過渡期視為「允許」(legacy_awoooi_default 背景 loop 可繼續運行) +- Phase 3 後,空字串 policy 移除(強制要求 project_id) + +### D4 — 分批 RLS 上線策略 + +**Batch 1(Phase 1 migration,高風險表)**: +- `incidents` +- `knowledge_entries` +- `playbooks` +- `audit_logs` + +**Batch 2(Phase 2)**: +- `approval_records` +- `mcp_audit_log` +- `auto_repair_executions` +- `alert_operation_log` + +**Batch 3(Phase 3,其餘表)**: +- 其餘 12 張表(見 INV-2) + +**每個 batch 的驗收**: +```sql +-- 驗收測試:ewoooc project 無法看到 awoooi 資料 +SET LOCAL app.project_id = 'ewoooc'; +SELECT count(*) FROM incidents; -- 必須為 0(awoooi 的 incident 不可見) + +SET LOCAL app.project_id = 'awoooi'; +SELECT count(*) FROM incidents; -- 必須為 awoooi 的 incident 數量 +``` + +### D5 — Application-Layer RLS 補充 + +PostgreSQL RLS 是第一道防線。應用層在 Repository 層加第二道: + +```python +# apps/api/src/repositories/incident_repository.py +class IncidentRepository: + def __init__(self, db: AsyncSession, project_id: str): + self._db = db + self._project_id = project_id + + async def get_incident(self, incident_id: str) -> Incident | None: + # Application-layer RLS:明確 WHERE project_id + result = await self._db.execute( + select(Incident).where( + Incident.incident_id == incident_id, + Incident.project_id == self._project_id # 明確過濾 + ) + ) + return result.scalar_one_or_none() +``` + +**雙層防禦原則**: +- DB RLS = 最後防線(即使 application bug 也不洩漏) +- Application WHERE = 第一道(明確意圖,可測試) + +### D6 — `awooop_published_revisions` View 的 RLS + +Contract revision 的 view 必須加 RLS(確保 runtime 不跨 tenant 讀 contract): + +```sql +-- 在 awooop_contract_revisions 表啟用 RLS(由 ADR-107/ADR-112 建立的表) +ALTER TABLE awooop_contract_revisions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY contract_revisions_tenant ON awooop_contract_revisions + USING ( + project_id = current_setting('app.project_id', TRUE) + OR current_setting('app.project_id', TRUE) IN ('', '__platform__') + ); +``` + +--- + +## 過渡期安全性評估 + +| 期間 | 空字串 policy | 等效行為 | 可接受性 | +|------|--------------|---------|---------| +| Phase 1~2 | 允許(空字串 = bypass)| 等同現在無 RLS | ✅ 比現在多一層(應用層)| +| Phase 3 | 移除空字串允許 | 真正強制隔離 | ✅ 目標狀態 | +| EwoooC onboarding 前 | 必須完成 Phase 3 | RLS 強制隔離 | ✅ 前置條件 | + +--- + +## 後果 + +### Benefits +- PostgreSQL 原生 RLS:即使應用程式有 SQL injection 漏洞,跨 tenant 資料仍不可見 +- 分批上線:不影響現有 31 個背景 loop 的運行 +- `__platform__` project_id 作為 superuser pattern,保留 platform_internal 完整讀寫能力 + +### Costs +- 每個 DB session 需要 `SET LOCAL app.project_id`(約 0.1ms 額外開銷) +- Alembic migration 需要使用 `awooop_migration` 角色(需要更新 K8s Secrets) +- Application Repository 層需要全部加 `project_id` 參數(PR-08/PR-09) + +### Risks +- `current_setting('app.project_id', TRUE)` 返回空字串的情境: + - 未注入的連線(如直接 psql 連線) + - Phase 1~2 過渡期的 legacy 背景 loop + - 緩解:空字串 policy 在 Phase 3 移除,Phase 1~2 可接受 +- `FORCE ROW LEVEL SECURITY` 連 table owner 也受約束,需確認 migration 角色有 BYPASSRLS + +--- + +## 驗收標準 + +- [ ] `awooop_app` role 建立,應用程式連線改用此 role(Phase 1) +- [ ] Batch 1 四張高風險表 RLS 啟用(Phase 1 migration) +- [ ] `SET LOCAL app.project_id = 'ewoooc'` → incidents 查詢返回 0 筆(整合測試) +- [ ] EwoooC onboarding 前:全部 20 張表 RLS 啟用(Phase 3 完成) +- [ ] vuln-verifier PoC:`GET /v1/platform/runs/{awoooi_run_id}` 帶 ewoooc token → 403(ADR-115 D5) + +## 關聯 + +- ADR-107(儲存策略,DB schema) +- ADR-115(tenant onboarding,cross-tenant 隔離驗收) +- ADR-116(security hardening,approval token) +- INV-2(20 張缺 project_id 的表) +- PR-08/PR-09(Repository project_id batch 新增) diff --git a/docs/adr/ADR-119-durable-execution-saga-compensation.md b/docs/adr/ADR-119-durable-execution-saga-compensation.md new file mode 100644 index 000000000..e9e7b4441 --- /dev/null +++ b/docs/adr/ADR-119-durable-execution-saga-compensation.md @@ -0,0 +1,242 @@ +# ADR-119: Durable Execution & SAGA Compensation + +**狀態**:Accepted +**日期**:2026-05-03(台北) +**決策者**:統帥 +**範圍**:Agent run 的步驟級 journal、補償命令設計、SAGA 觸發條件 +**關聯**:ADR-114(worker lease)、ADR-106(Run State contract) + +--- + +## 背景 + +ADR-114 解決了 worker crash 後的 run 回收問題(stale reaper)。但 stale reaper 只是把 run 回到 PENDING 狀態,讓 worker 重試。這帶來新問題: + +**問題 1:無法知道 run 到哪一步才 crash** + +worker 重新取到 run 後,從頭執行 → 重複呼叫已完成的步驟(例如:已經寫了 K8s repair,重試時再寫一次)。 + +**問題 2:部分執行的副作用不可逆** + +Agent 已執行了 `k8s_exec_pod`(重啟了某個 Pod),但後續步驟失敗了。重試時再重啟一次,造成服務閃斷兩次。 + +**問題 3:無法對 approval 決策做補償** + +`WAITING_APPROVAL` 的 run 被 reject 後,Agent 已呼叫的 tool 副作用無法回滾。 + +**設計目標**: +- Step-level idempotency(步驟可重試而不產生重複副作用) +- Compensation(補償命令)設計 +- 不依賴外部 durable execution 服務(Temporal 等),使用 DB + Redis 實作 + +--- + +## 決策 + +### D1 — SAGA 步驟 Journal(saga_steps JSONB) + +`awooop_run_state` 表新增 `saga_steps` JSONB 欄位: + +```sql +ALTER TABLE awooop_run_state +ADD COLUMN saga_steps JSONB NOT NULL DEFAULT '[]'; + +-- saga_steps 結構(JSONB array) +-- [ +-- { +-- "step_id": "uuid", +-- "step_type": "llm_call" | "tool_call" | "approval_request" | "callback", +-- "tool_id": "k8s_exec_pod", -- tool_call 時填入 +-- "tool_params": {...}, -- tool_call 時填入(供補償使用) +-- "status": "pending" | "completed" | "failed" | "compensated", +-- "result": {...}, -- 步驟結果(completed 時填入) +-- "compensation_cmd": {...}, -- 補償命令(tool_call 才有) +-- "started_at": "ISO8601", +-- "completed_at": "ISO8601" +-- } +-- ] +``` + +### D2 — 步驟冪等性(Step Idempotency) + +Worker 重新取到 run 後,**跳過已完成的步驟**: + +```python +async def resume_run_from_checkpoint(run: RunState) -> None: + completed_steps = { + step["step_id"] + for step in run.saga_steps + if step["status"] == "completed" + } + + for step in plan_steps: + if step.step_id in completed_steps: + # 跳過已完成的步驟,使用已記錄的 result + result = get_step_result(run.saga_steps, step.step_id) + else: + # 執行步驟,寫入 journal + result = await execute_step(step) + await append_saga_step(run.run_id, step, result) +``` + +**每個步驟執行前 journal 寫入「pending」,執行後更新為「completed」或「failed」**: + +```python +async def execute_step_with_journal(run_id: str, step: Step) -> StepResult: + # 寫入 pending(防止重複執行) + await upsert_saga_step(run_id, step.step_id, status="pending") + + try: + result = await step.execute() + await upsert_saga_step(run_id, step.step_id, status="completed", result=result) + return result + except Exception as e: + await upsert_saga_step(run_id, step.step_id, status="failed", error=str(e)) + raise +``` + +### D3 — Compensation(補償命令)設計 + +**只有 tool_call 步驟有補償命令**(LLM call 不可逆): + +| Tool | 補償命令 | 可補償條件 | +|------|---------|----------| +| `k8s_exec_pod`(exec 類)| N/A(副作用不可逆)| 不補償 | +| `k8s_scale_deployment`(scale 到 N)| scale 回原值 | 有原始 replica count | +| `k8s_create_resource` | `k8s_delete_resource` | 有 resource 名稱 | +| `pg_mutate`(INSERT)| `pg_mutate`(DELETE 對應 ID)| 有 inserted_id | +| `knowledge_write` | `knowledge_delete` | 有 entry_id | + +**補償命令格式**(在 saga_steps 中): + +```json +{ + "step_id": "uuid", + "tool_id": "k8s_scale_deployment", + "tool_params": {"deployment": "api", "replicas": 3}, + "status": "completed", + "compensation_cmd": { + "tool_id": "k8s_scale_deployment", + "params": {"deployment": "api", "replicas": 1}, // 原始值 + "reason": "SAGA compensation: scale deployment back" + } +} +``` + +### D4 — SAGA 觸發條件 + +**自動觸發補償**的情境: + +```python +SAGA_COMPENSATION_TRIGGERS = [ + # 1. approval 被 reject + RunStatus.WAITING_APPROVAL → reject → trigger_compensation, + + # 2. approval_token 過期(15min TTL) + approval_token_expired → trigger_compensation, + + # 3. 後續步驟失敗,已有 compensation_cmd 的步驟需要回滾 + step_failed AND has_compensatable_steps → trigger_compensation, + + # 4. 主動 CANCEL + user_cancel → trigger_compensation if has_compensatable_steps, +] +``` + +**補償執行順序**:反向(最後執行的步驟先補償): + +```python +async def run_saga_compensation(run: RunState) -> None: + completed_steps_with_compensation = [ + step for step in reversed(run.saga_steps) + if step["status"] == "completed" and step.get("compensation_cmd") + ] + + for step in completed_steps_with_compensation: + cmd = step["compensation_cmd"] + try: + await execute_mcp_tool( + run_id=run.run_id, + tool_id=cmd["tool_id"], + tool_params=cmd["params"], + project_id=run.project_id + ) + await update_saga_step(run.run_id, step["step_id"], status="compensated") + except Exception as e: + # 補償失敗不 raise,繼續補償其他步驟 + await update_saga_step(run.run_id, step["step_id"], status="compensation_failed", + error=str(e)) + await write_audit_log("SAGA_COMPENSATION_FAILED", run.run_id, step, str(e)) +``` + +### D5 — SAGA 狀態欄位 + +`awooop_run_state` 補充欄位: + +```sql +ALTER TABLE awooop_run_state +ADD COLUMN saga_status VARCHAR(32) DEFAULT 'in_progress'; +-- 'in_progress' | 'compensating' | 'compensated' | 'compensation_failed' + +ALTER TABLE awooop_run_state +ADD COLUMN compensation_started_at TIMESTAMPTZ; + +ALTER TABLE awooop_run_state +ADD COLUMN compensation_completed_at TIMESTAMPTZ; +``` + +### D6 — 不補償的情境 + +以下情境**不執行 SAGA 補償**,只記錄 audit log: + +- `step_type = "llm_call"`(LLM 輸出不可逆,補償無意義) +- `step_type = "k8s_exec_pod"`(exec 副作用不可逆) +- `attempt_count >= max_attempts`(已超過最大重試,直接 FAILED) +- `status = "CANCELLED"` 且無 compensatable steps(直接結束) + +--- + +## 與 ADR-114 的關係 + +| 機制 | ADR-114 | ADR-119 | +|------|---------|---------| +| Worker crash 後恢復 | Stale reaper 回收 → PENDING | ✓(由 ADR-114 觸發)| +| 重試時跳過已完成步驟 | ✗ | saga_steps journal checkpoint | +| 部分執行副作用回滾 | ✗ | compensation_cmd 反向執行 | +| approval reject 處理 | ✗(FAILED)| SAGA 補償 + FAILED | + +--- + +## 後果 + +### Benefits +- Worker crash 重試後不重複執行已完成步驟(冪等性) +- Approval reject 後可選擇性回滾 K8s 操作(k8s_scale, k8s_create) +- saga_steps journal 是完整的執行審計紀錄 + +### Costs +- 每個步驟需要額外 DB write(journal upsert) +- `saga_steps` JSONB 欄位隨執行步驟增長(長 run 可能超過 1MB) +- 緩解:步驟執行結果只存必要欄位,大型 result 存 S3/GCS 只存 reference + +### Risks +- 補償執行失敗(compensation_failed):操作已部分執行,系統進入不一致狀態 +- 緩解:audit_log + Telegram 告警,需要人工介入 +- K8s exec 等不可逆操作無法補償:設計上明確標記,並在 HIGH risk tool 加 approval gate + +--- + +## 驗收標準 + +- [ ] `awooop_run_state` 新增 `saga_steps JSONB` 欄位(Phase 1 migration) +- [ ] Worker crash 重試後跳過已完成步驟(模擬測試) +- [ ] Approval reject → k8s_scale 補償命令執行(整合測試) +- [ ] `compensation_failed` → audit_log 寫入 + Telegram 告警(整合測試) +- [ ] `saga_steps` JSONB 格式驗證(Phase 3 schema validation) + +## 關聯 + +- ADR-114(worker lease,stale reaper 觸發入口) +- ADR-106(Run State contract,run 狀態機) +- ADR-116(approval_token,reject 觸發 SAGA 補償) +- ADR-117(MCP tool 執行,補償命令也走 MCP Gateway) diff --git a/docs/adr/ADR-120-token-budget-hard-kill.md b/docs/adr/ADR-120-token-budget-hard-kill.md new file mode 100644 index 000000000..6862d54a5 --- /dev/null +++ b/docs/adr/ADR-120-token-budget-hard-kill.md @@ -0,0 +1,250 @@ +# ADR-120: Token Budget Hard Kill + +**狀態**:Accepted +**日期**:2026-05-03(台北) +**決策者**:統帥 +**範圍**:三層 token budget 架構、hard kill 機制、$47k 教訓、告警閾值 +**關聯**:ADR-106(Agent contract budget_limit)、ADR-112(contract governance) + +--- + +## 背景 + +### $47k Agent Loop 事故教訓 + +某 agentic AI 平台的 Agent 進入無限 LLM 呼叫迴圈(prompt injection 導致),48 小時內耗費 $47,000 USD。 + +事故發生的原因: +- **只有告警,沒有 hard kill**:超出預算閾值時發告警,但 Agent 繼續執行 +- **沒有 token 計數**:每次 LLM call 的 token 消耗沒有即時統計 +- **沒有分層 budget**:整個 platform 共享一個 budget 上限,單一 Agent 可以消耗所有額度 + +### 現況 + +- `budget_ledger` 表存在,但沒有 hard kill 機制 +- LLM call 前沒有 pre-check(只在事後統計) +- 沒有 per-run budget limit(只有 per-provider total budget) + +--- + +## 決策 + +### D1 — 三層 Budget 架構 + +``` +Platform Budget(全域上限) + └── Tenant Budget(per project_id) + └── Agent Budget(per agent_id) + └── Run Budget(per run_id,最細粒度) +``` + +**層次說明**: + +| 層次 | 限制對象 | 存放位置 | 更新頻率 | +|------|---------|---------|---------| +| Platform | 全部 LLM 呼叫 | `config.py` + K8s Secret | 靜態 | +| Tenant | per project_id | `awooop_projects.budget_limit_usd` | 合約期間靜態 | +| Agent | per agent_id | Agent contract `budget_limit` | 版本更新 | +| Run | per run_id | `awooop_run_state.token_budget_remaining` | 每次 LLM call 更新 | + +### D2 — Hard Kill 機制(三道防線) + +**防線 1:Pre-call Budget Check(呼叫前)** + +```python +async def check_budget_before_llm_call( + project_id: str, + agent_id: str, + run_id: str, + estimated_tokens: int +) -> None: + """預估 token 消耗,超預算直接拒絕呼叫""" + + # Layer 1: Run budget + run = await get_run(run_id) + estimated_cost_usd = estimate_cost(estimated_tokens, model=run.model) + if run.token_budget_remaining < estimated_cost_usd: + raise BudgetExhaustedError( + "E-BUDGET-001", + f"Run {run_id} budget exhausted: remaining ${run.token_budget_remaining:.4f}" + ) + + # Layer 2: Tenant budget(Redis 快取,每分鐘同步 DB) + tenant_budget = await get_tenant_budget_remaining(project_id) + if tenant_budget < estimated_cost_usd: + raise BudgetExhaustedError( + "E-BUDGET-002", + f"Tenant {project_id} budget exhausted" + ) + + # Layer 3: Platform budget(Redis 快取) + platform_budget = await get_platform_budget_remaining() + if platform_budget < estimated_cost_usd: + raise BudgetExhaustedError( + "E-BUDGET-003", + "Platform budget exhausted — all LLM calls suspended" + ) +``` + +**防線 2:Post-call Accounting(呼叫後)** + +```python +async def record_token_usage( + project_id: str, agent_id: str, run_id: str, + prompt_tokens: int, completion_tokens: int, + model: str, provider: str +) -> None: + actual_cost = calculate_cost(prompt_tokens, completion_tokens, model, provider) + + async with db_transaction(): + # 扣除 run budget + await db.execute( + text(""" + UPDATE awooop_run_state + SET token_budget_remaining = token_budget_remaining - :cost, + total_tokens_used = total_tokens_used + :total_tokens + WHERE run_id = :run_id + """), + {"cost": actual_cost, "total_tokens": prompt_tokens + completion_tokens, + "run_id": run_id} + ) + + # 寫入 budget_ledger + await db.execute( + text(""" + INSERT INTO budget_ledger + (project_id, agent_id, run_id, model, provider, + prompt_tokens, completion_tokens, cost_usd, recorded_at) + VALUES (:project_id, :agent_id, :run_id, :model, :provider, + :prompt_tokens, :completion_tokens, :cost_usd, NOW()) + """), + {...} + ) + + # 更新 Redis 快取(async,不在事務中) + await update_budget_cache(project_id, actual_cost) +``` + +**防線 3:Emergency Platform Kill Switch(緊急停機)** + +```python +# Redis key: platform:budget:emergency_kill +# SET platform:budget:emergency_kill "1" → 停止所有 LLM 呼叫 + +async def check_emergency_kill() -> None: + if await redis.exists("platform:budget:emergency_kill"): + raise BudgetExhaustedError( + "E-BUDGET-004", + "Emergency kill switch activated — contact platform admin" + ) +``` + +### D3 — Budget 欄位(awooop_run_state) + +```sql +ALTER TABLE awooop_run_state +ADD COLUMN token_budget_usd NUMERIC(10, 4) NOT NULL DEFAULT 1.00, +ADD COLUMN token_budget_remaining NUMERIC(10, 4) NOT NULL DEFAULT 1.00, +ADD COLUMN total_tokens_used INT NOT NULL DEFAULT 0, +ADD COLUMN total_cost_usd NUMERIC(10, 4) NOT NULL DEFAULT 0.0000; +``` + +**預設 Run Budget**: +- 從 Agent contract 的 `run_budget_usd` 讀取 +- 若未設定:預設 $1.00 +- 上限(Platform 強制):$10.00 per run + +### D4 — 告警閾值(三段) + +```python +BUDGET_ALERT_THRESHOLDS = { + "warn": 0.80, # 80% 用量 → Telegram 警告(不 kill) + "critical": 0.95, # 95% 用量 → Telegram 緊急告警 + 人工確認建議 + "hard_kill": 1.00, # 100% → hard kill(E-BUDGET-001/002/003) +} +``` + +**告警通知(Telegram)**: +``` +[BUDGET-WARN] Run {run_id} (project={project_id}) +已使用 ${used:.3f} / ${limit:.3f} ({pct:.1f}%) +模型:{model},Provider:{provider} +``` + +### D5 — Budget Ledger 表強化 + +```sql +-- 現有 budget_ledger 補充欄位 +ALTER TABLE budget_ledger +ADD COLUMN IF NOT EXISTS project_id VARCHAR(64), +ADD COLUMN IF NOT EXISTS agent_id VARCHAR(128), +ADD COLUMN IF NOT EXISTS run_id UUID, +ADD COLUMN IF NOT EXISTS model VARCHAR(64), +ADD COLUMN IF NOT EXISTS provider VARCHAR(32), +ADD COLUMN IF NOT EXISTS prompt_tokens INT, +ADD COLUMN IF NOT EXISTS completion_tokens INT, +ADD COLUMN IF NOT EXISTS cost_usd NUMERIC(10, 4); + +-- 分析用 index +CREATE INDEX idx_budget_ledger_project_date + ON budget_ledger(project_id, recorded_at); + +CREATE INDEX idx_budget_ledger_run + ON budget_ledger(run_id); +``` + +### D6 — Agent Contract Budget 欄位 + +Agent contract(六合約之一)的 budget 欄位: + +```json +{ + "agent_id": "openclaw-sre", + "run_budget_usd": 0.50, // per run 上限(預設 $0.50) + "daily_budget_usd": 10.00, // per day 上限 + "max_tokens_per_run": 100000, // 輔助限制(token 數量) + "llm_call_limit_per_run": 20 // 防止無限迴圈的最大 LLM 呼叫次數 +} +``` + +**`llm_call_limit_per_run`**(防無限迴圈): +- Agent 每次 LLM call 前,計算 `saga_steps` 中 `step_type=llm_call` 的數量 +- 超過 `llm_call_limit_per_run` → E-BUDGET-005(LLM call limit exceeded) + +--- + +## 後果 + +### Benefits +- 三層 budget 防護:Platform / Tenant / Run 層層限制 +- Pre-call check:在發出 API request 前就攔截,不產生費用 +- Emergency kill switch:緊急情況下一個 Redis key 停止所有 LLM 呼叫 +- `llm_call_limit_per_run`:從根本防止 $47k 事故(無限迴圈) + +### Costs +- 每次 LLM call 前增加 3 次 Redis get(約 1-2ms 總延遲) +- Post-call accounting 有 DB write(已有 budget_ledger,只是補欄位) +- Budget cache 與 DB 可能有最多 1 分鐘的不一致(可接受) + +### Risks +- Token 預估(estimated_tokens)不準確 → Pre-call check 誤殺或漏殺 +- 緩解:estimated_tokens 保守估計(prompt token count × 1.5),寧可誤殺不漏殺 +- Budget cache Redis 失效 → 退回 DB 查詢(比較慢但不 bypass) + +--- + +## 驗收標準 + +- [ ] `awooop_run_state` 新增 budget 相關欄位(Phase 1 migration) +- [ ] Pre-call budget check 攔截超預算的 LLM 呼叫(整合測試) +- [ ] `llm_call_limit_per_run` 超過 → E-BUDGET-005(整合測試) +- [ ] Emergency kill switch:Redis SET → 所有 LLM 呼叫停止(整合測試) +- [ ] 80% budget 告警送達 Telegram(整合測試) +- [ ] EwoooC budget 與 AWOOOI budget 獨立計算(ADR-115 D5 驗收) + +## 關聯 + +- ADR-106(Agent contract budget_limit 欄位定義) +- ADR-112(contract governance,Agent contract publish/activate) +- ADR-119(SAGA,run 失敗後 budget 狀態保留) +- ADR-121(OTel,token 使用量 telemetry 上報) diff --git a/docs/adr/ADR-121-otel-genai-semantic-conventions.md b/docs/adr/ADR-121-otel-genai-semantic-conventions.md new file mode 100644 index 000000000..78fcbe866 --- /dev/null +++ b/docs/adr/ADR-121-otel-genai-semantic-conventions.md @@ -0,0 +1,236 @@ +# ADR-121: OTel GenAI Semantic Conventions + +**狀態**:Accepted +**日期**:2026-05-03(台北) +**決策者**:統帥 +**範圍**:GenAI span 命名、token attribute、SignOz 上報、現有 telemetry.py 修補 +**關聯**:ADR-110(GCP Ollama)、ADR-120(token budget)、INV-4(hardcoded IP) + +--- + +## 背景 + +OpenTelemetry 已發布 **GenAI Semantic Conventions 1.0**(2025 Q1),定義了 AI/LLM 應用的標準 span 屬性。 + +現有 `apps/api/src/services/telemetry.py` 的問題: +1. **P0-08**:`telemetry.py:71` hardcoded `if "192.168.0.188" not in endpoint: raise`(阻擋 EwoooC) +2. **不標準**:span 名稱和 attribute 不符合 GenAI Semantic Conventions +3. **token 追蹤缺失**:LLM call 的 prompt/completion token 沒有進 OTel span +4. **OTel 版本**:需要 >=1.25.0 支援 GenAI semconv(INV-5 確認目前 >=1.20.0) + +--- + +## 決策 + +### D1 — GenAI Span 命名規範 + +**標準格式**(GenAI semconv): + +``` +{gen_ai.operation.name} {gen_ai.system} + +例: + chat ollama ← Ollama chat 呼叫 + chat openai ← OpenAI 呼叫(備援) + chat gemini ← Gemini 呼叫 + embeddings ollama ← embedding 呼叫 +``` + +**禁止的命名方式**: +``` +# 禁止(舊格式,不符合 semconv) +"llm_call" +"openclaw_decision" +"ai_provider_call" +``` + +### D2 — GenAI Span Attributes(標準屬性) + +每個 LLM call span 必須包含以下 attributes: + +```python +# 標準 GenAI attributes(OpenTelemetry GenAI semconv 1.0) +{ + # 系統識別 + "gen_ai.system": "ollama" | "openai" | "gemini" | "nvidia_nim", + "gen_ai.operation.name": "chat" | "embeddings" | "completion", + + # 請求 + "gen_ai.request.model": "llama3.2", # 請求的模型名稱 + "gen_ai.request.max_tokens": 4096, + "gen_ai.request.temperature": 0.7, + "gen_ai.request.top_p": 0.9, + + # 回應 + "gen_ai.response.model": "llama3.2", # 實際使用的模型(可能因 fallback 不同) + "gen_ai.response.finish_reasons": ["stop"], + + # Token 計數(重要:budget 對帳用) + "gen_ai.usage.prompt_tokens": 512, + "gen_ai.usage.completion_tokens": 128, + + # AwoooP 自訂 namespace(非標準,但必需) + "awooop.project_id": "awoooi", + "awooop.agent_id": "openclaw-sre", + "awooop.run_id": "uuid", + "awooop.provider_tier": "primary" | "secondary" | "fallback", # ADR-110 三層 + + # W3C TraceContext(由 OTel SDK 自動注入) + # trace_id, span_id, parent_id +} +``` + +### D3 — telemetry.py 修補(P0-08 + semconv 升級) + +**P0-08 修補(PR-01,立即)**: + +```python +# 修補前(telemetry.py:71,不安全) +if "192.168.0.188" not in endpoint: + raise ValueError(f"Unsupported OTEL endpoint: {endpoint}") + +# 修補後(允許任何 HTTPS 或設定允許清單的 endpoint) +ALLOWED_OTEL_ENDPOINTS = { + settings.OTEL_EXPORTER_OTLP_ENDPOINT, # 從環境變數讀取 +} +# 移除 hardcode assert,直接使用設定值 +``` + +**semconv 升級(Phase 2)**: + +```python +# apps/api/src/services/telemetry.py +from opentelemetry import trace +from opentelemetry.semconv._incubating.attributes import gen_ai_attributes + +class GenAITracer: + """符合 GenAI Semantic Conventions 1.0 的 LLM call tracer""" + + def __init__(self, tracer_name: str = "awooop.platform"): + self._tracer = trace.get_tracer(tracer_name) + + def start_llm_span( + self, + system: str, # "ollama", "openai", etc. + operation: str, # "chat", "embeddings" + model: str, + project_id: str, + agent_id: str, + run_id: str, + provider_tier: str # "primary", "secondary", "fallback" + ) -> trace.Span: + span_name = f"{operation} {system}" + span = self._tracer.start_span(span_name) + span.set_attributes({ + gen_ai_attributes.GEN_AI_SYSTEM: system, + gen_ai_attributes.GEN_AI_OPERATION_NAME: operation, + gen_ai_attributes.GEN_AI_REQUEST_MODEL: model, + "awooop.project_id": project_id, + "awooop.agent_id": agent_id, + "awooop.run_id": run_id, + "awooop.provider_tier": provider_tier, + }) + return span + + def end_llm_span( + self, + span: trace.Span, + response_model: str, + prompt_tokens: int, + completion_tokens: int, + finish_reason: str + ) -> None: + span.set_attributes({ + gen_ai_attributes.GEN_AI_RESPONSE_MODEL: response_model, + gen_ai_attributes.GEN_AI_USAGE_PROMPT_TOKENS: prompt_tokens, + gen_ai_attributes.GEN_AI_USAGE_COMPLETION_TOKENS: completion_tokens, + gen_ai_attributes.GEN_AI_RESPONSE_FINISH_REASONS: [finish_reason], + }) + span.end() +``` + +### D4 — OTel 依賴版本更新(INV-5) + +```toml +# pyproject.toml 更新 +opentelemetry-sdk = ">=1.25.0" # GenAI semconv 支援 +opentelemetry-exporter-otlp-proto-grpc = ">=1.25.0" +opentelemetry-semantic-conventions = ">=0.46b0" # incubating GenAI attrs +``` + +### D5 — SignOz 上報設定(188:24318) + +```python +# apps/api/src/services/telemetry.py +# OTEL_EXPORTER_OTLP_ENDPOINT 從環境變數讀取(不再 hardcode) +# K8s deployment 中設置: +# OTEL_EXPORTER_OTLP_ENDPOINT=http://192.168.0.188:24318 + +# EwoooC 可設置不同的 endpoint(PR-01 修補後解除封鎖) +``` + +**K8s ConfigMap(per-tenant 可配置)**: +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: awooop-telemetry-config +data: + OTEL_EXPORTER_OTLP_ENDPOINT: "http://192.168.0.188:24318" + OTEL_SERVICE_NAME: "awooop-platform" + OTEL_RESOURCE_ATTRIBUTES: "deployment.environment=production" +``` + +### D6 — GCP Ollama 三層拓撲的 span 標記(ADR-110 整合) + +```python +# ADR-110 三層拓撲的 span 標記 +PROVIDER_TIER_MAP = { + "34.143.170.20": "primary", # GCP-A + "34.21.145.224": "secondary", # GCP-B + "192.168.0.111": "fallback", # Local +} + +def get_provider_tier(ollama_host: str) -> str: + for ip, tier in PROVIDER_TIER_MAP.items(): + if ip in ollama_host: + return tier + return "unknown" +``` + +Fallback 事件(primary → secondary 或 secondary → fallback)必須在 span 上標記 `awooop.provider_tier`,讓 SignOz 可以分析 fallback 頻率。 + +--- + +## 後果 + +### Benefits +- GenAI semconv 標準化:SignOz / Grafana 可用通用 GenAI dashboard +- `gen_ai.usage.prompt_tokens` / `completion_tokens` 進 OTel:budget 對帳有 trace 依據 +- EwoooC 解封(PR-01 移除 hardcode assert) +- GCP Ollama 三層 fallback 可視化 + +### Costs +- OTel 版本升級(>=1.25.0)需要更新 pyproject.toml + 驗證現有 instrumentation +- 現有 LLM call 程式碼需要加 `start_llm_span` / `end_llm_span` 包裝 + +### Risks +- OTel GenAI semconv incubating 狀態(not stable),未來可能有 attribute 名稱變更 +- 緩解:`awooop.*` 自訂 namespace 隔離,即使 semconv 變更只需更新 GenAI attributes 部分 + +--- + +## 驗收標準 + +- [ ] `telemetry.py:71` hardcoded IP assert 移除(PR-01 完成) +- [ ] OTel 版本升級至 >=1.25.0(Phase 2) +- [ ] LLM call span 包含 `gen_ai.usage.prompt_tokens` / `completion_tokens`(整合測試) +- [ ] SignOz 可看到 `chat ollama` span 帶 token 計數(Phase 2 驗收) +- [ ] GCP Ollama fallback 事件在 SignOz 有 `awooop.provider_tier` 標記可查詢 + +## 關聯 + +- ADR-110(GCP Ollama 三層拓撲,fallback 事件標記) +- ADR-120(token budget,telemetry token 計數對帳) +- INV-4(telemetry.py:71 hardcoded IP,P0-08) +- INV-5(OTel 版本相容性矩陣) diff --git a/docs/adr/ADR-122-owasp-agentic-ai-iso42001-alignment.md b/docs/adr/ADR-122-owasp-agentic-ai-iso42001-alignment.md new file mode 100644 index 000000000..f70108bab --- /dev/null +++ b/docs/adr/ADR-122-owasp-agentic-ai-iso42001-alignment.md @@ -0,0 +1,205 @@ +# ADR-122: OWASP Agentic AI Top 10 & ISO 42001 Alignment + +**狀態**:Accepted +**日期**:2026-05-03(台北) +**決策者**:統帥 +**範圍**:行業標準映射到 AwoooP 控制項、合規差距識別 +**關聯**:ADR-116(security hardening)、ADR-117(Confused Deputy)、ADR-120(budget) + +--- + +## 背景 + +2025 年起,OWASP 發布了 **Agentic AI Top 10**(OAI-01 到 OAI-10),定義了 AI Agent 系統的十大安全風險。同期 ISO/IEC 42001(AI Management System Standard)也開始被企業採納。 + +AwoooP 作為 AI Agent 平台,需要對應這兩個標準,確保架構設計符合行業期望。本 ADR 記錄每個風險點的控制狀態。 + +--- + +## OWASP Agentic AI Top 10 映射 + +### OAI-01:Prompt Injection(提示注入) + +**風險**:攻擊者透過惡意 prompt 操控 Agent 行為。 + +| 控制項 | 狀態 | 實作位置 | +|--------|------|---------| +| Input sanitization | ⚠️ Partial | `decision_manager.py`(需加強)| +| System prompt isolation | ⚠️ Partial | Agent contract `system_prompt` 欄位(Phase 3)| +| Output validation | ✅ | `requires_approval` 改 policy-derived(ADR-116 D3)| +| Injection detection pattern | ❌ TODO | Phase 5:injection pattern 規則庫 | + +**差距**:需要在 LLM input 加 injection pattern 偵測層。 + +--- + +### OAI-02:Insecure Output Handling(不安全輸出處理) + +**風險**:Agent 輸出被用於執行命令而不驗證。 + +| 控制項 | 狀態 | 實作位置 | +|--------|------|---------| +| Tool call parameter validation | ⚠️ Partial | `mcp_bridge.py`(需加 schema 驗證)| +| Output schema enforcement | ❌ TODO | Phase 3:Agent contract output_schema | +| Structured output only(no free-form exec)| ✅ | MCP Gateway 強制 tool_id + params 結構 | + +--- + +### OAI-03:Excessive Agency(過度授權) + +**風險**:Agent 被賦予超出必要的工具和資源存取權限。 + +| 控制項 | 狀態 | 實作位置 | +|--------|------|---------| +| Per-run allowed_tools allowlist | ✅ | ADR-117 D2 | +| Agent contract tool restriction | ✅ | ADR-106(allowed_tools 欄位)| +| HIGH risk tool requires approval | ✅ | ADR-116 D4 + ADR-117 | +| Namespace restriction in K8s ops | ✅ | ADR-117 D2(contract-derived)| + +**狀態:COMPLIANT**(Phase 3 實作後) + +--- + +### OAI-04:Resource Exhaustion(資源耗盡) + +**風險**:Agent 呼叫無限 LLM 或工具,耗盡計算/費用資源。 + +| 控制項 | 狀態 | 實作位置 | +|--------|------|---------| +| Token budget hard kill | ✅ | ADR-120 D2 | +| LLM call count limit per run | ✅ | ADR-120 D6(llm_call_limit_per_run)| +| Tenant budget isolation | ✅ | ADR-120 D1 | +| Emergency kill switch | ✅ | ADR-120 D2 防線 3 | + +**狀態:COMPLIANT**(Phase 2 實作後) + +--- + +### OAI-05:Supply Chain Vulnerabilities(供應鏈漏洞) + +**風險**:使用的 AI 模型、工具或 SDK 有未修補的漏洞。 + +| 控制項 | 狀態 | 實作位置 | +|--------|------|---------| +| Model version tracking | ✅ | `ai_provider_version_history` 表 | +| Dependency scanning(pip-audit)| ⚠️ Partial | CI/CD 需要加入 pip-audit | +| Container image scanning | ⚠️ Partial | Harbor 有 Trivy,但未開啟 | +| SBOM generation | ❌ TODO | Phase 5 | + +**差距**:CI/CD 需要加入 pip-audit + Harbor Trivy 掃描啟用。 + +--- + +### OAI-06:Data Privacy Violation(資料隱私違規) + +**風險**:Agent 處理或洩漏敏感使用者資料。 + +| 控制項 | 狀態 | 實作位置 | +|--------|------|---------| +| Tenant data isolation(RLS)| ✅ | ADR-118 | +| PII detection before LLM | ❌ TODO | Phase 5:PII filter middleware | +| Audit log for sensitive operations | ✅ | ADR-112 D7 | +| K8s Secret 不進 DB | ✅ | INV-4 已確認 | + +**差距**:PII filter 在 Phase 5 實作,Phase 1~4 期間靠 RLS 隔離。 + +--- + +### OAI-07:Memory Poisoning(記憶體污染) + +**風險**:攻擊者透過操控 Agent 記憶(KM)影響未來決策。 + +| 控制項 | 狀態 | 實作位置 | +|--------|------|---------| +| KM write requires approval (HIGH risk) | ⚠️ Partial | `knowledge_write` 已在 approval gate(ADR-116 D3)| +| KM entry source tracking | ✅ | `knowledge_entries.source` 欄位 | +| KM poisoning detection | ❌ TODO | Phase 5:KM 異常寫入頻率監控 | +| Human review for critical KM | ⚠️ Partial | 目前只有 approval,沒有 review UI | + +--- + +### OAI-08:Unauthorized Actions(未授權操作) + +**風險**:Agent 在沒有授權的情況下執行影響系統的操作。 + +| 控制項 | 狀態 | 實作位置 | +|--------|------|---------| +| Principal mapping(who sent this run?)| ✅ | ADR-115(platform_subjects 表)| +| Approval workflow | ✅ | ADR-114 + ADR-116 | +| requires_approval not from LLM | ✅ | ADR-116 D3(P0-04 修補)| +| SAGA compensation on rejection | ✅ | ADR-119 | + +**狀態:COMPLIANT**(Phase 2 實作後) + +--- + +### OAI-09:Access Control Bypass(存取控制繞過) + +**風險**:攻擊者繞過 Agent 的存取控制機制。 + +| 控制項 | 狀態 | 實作位置 | +|--------|------|---------| +| Callback nonce HMAC(防偽造)| ✅ | ADR-116 D1(P0-05 修補)| +| Webhook replay 防護 | ✅ | ADR-116 D2(P0-06 修補)| +| JWT / approval_token HS256 | ✅ | ADR-116 D4 | +| RLS 防 cross-tenant | ✅ | ADR-118 | +| Confused Deputy prevention | ✅ | ADR-117 D2 | + +**狀態:COMPLIANT**(Phase 2 實作後) + +--- + +### OAI-10:Observability Deficiency(可觀測性不足) + +**風險**:AI Agent 的行為無法被審計和追蹤。 + +| 控制項 | 狀態 | 實作位置 | +|--------|------|---------| +| OTel GenAI spans | ✅ | ADR-121 | +| run_id / trace_id 全鏈路 | ✅ | ADR-111(contextvars)| +| Audit log for all contract ops | ✅ | ADR-112 D7 | +| Token usage telemetry | ✅ | ADR-120 D2 + ADR-121 D2 | +| Background loop 31 個 tagging | ⚠️ Partial | PR-10(Phase 2)| + +--- + +## ISO 42001 映射 + +| 條款 | 要求 | AwoooP 控制項 | +|------|------|-------------| +| 6.1 風險評估 | AI 系統風險識別 | ADR 系列(111~124)= 正式風險評估文件 | +| 6.2 目標與計畫 | AI 目標量化 | DETAILED-IMPLEMENTATION-PLAN Phase DoD | +| 8.4 AI 系統生命週期 | 版本管理、審批流程 | ADR-112(contract governance)| +| 9.1 監控與量測 | 效能指標追蹤 | ADR-121(OTel)+ SignOz dashboard | +| 10.1 不符合項與矯正 | 問題追蹤與改善 | ADR + INV 系統 | + +--- + +## 差距摘要(需後續 Phase 解決) + +| OWASP Item | 差距項目 | 目標 Phase | +|-----------|---------|-----------| +| OAI-01 | Injection detection pattern 規則庫 | Phase 5 | +| OAI-02 | Agent contract output_schema 驗證 | Phase 3 | +| OAI-05 | pip-audit CI + Harbor Trivy | Phase 4 | +| OAI-06 | PII filter middleware | Phase 5 | +| OAI-07 | KM 異常寫入頻率監控 | Phase 5 | +| OAI-10 | 31 個 background loop tagging(PR-10)| Phase 2 | + +--- + +## 驗收標準 + +- [ ] OWASP OAI-03/04/08/09:Phase 2 實作後 vuln-verifier PoC 全過 +- [ ] OWASP OAI-10:31 個 background loop 全部 tagged(PR-10 完成) +- [ ] ISO 42001 映射表展示給統帥確認(Phase 2 收尾) +- [ ] 差距清單進入 Phase 4/5 backlog + +## 關聯 + +- ADR-116(security hardening,P0-04/05/06) +- ADR-117(Confused Deputy,OAI-03/09) +- ADR-118(RLS,OAI-06/09) +- ADR-119(SAGA,OAI-08) +- ADR-120(budget hard kill,OAI-04) +- ADR-121(OTel,OAI-10) diff --git a/docs/adr/ADR-123-background-loop-migration-strategy.md b/docs/adr/ADR-123-background-loop-migration-strategy.md new file mode 100644 index 000000000..f7a2f9647 --- /dev/null +++ b/docs/adr/ADR-123-background-loop-migration-strategy.md @@ -0,0 +1,199 @@ +# ADR-123: Background Loop Migration Strategy + +**狀態**:Accepted +**日期**:2026-05-03(台北) +**決策者**:統帥 +**範圍**:31 個背景 loop 的三分類、project_id 注入策略、退出時程表 +**關聯**:ADR-111(bootstrap order)、ADR-114(worker lease)、INV-3/INV-8 + +--- + +## 背景 + +INV-3 / INV-8 確認 `main.py` 中有 **31 個** background loop,全部在沒有 `project_id` 的情況下運行。這些 loop 是 AwoooP 多租戶化的最大阻礙之一。 + +**三個核心問題**: +1. 沒有 `project_id` → 無法使用 RLS(ADR-118) +2. 沒有 `trace_id` → OTel 追蹤不完整(ADR-121) +3. 31 個 loop 不可能同時遷移,需要策略性分批 + +--- + +## 決策 + +### D1 — 三分類(Loop 歸屬) + +**分類 A:Platform Internal(維持 `__platform__`)** + +這些 loop 本就是平台層,不屬於任何 tenant,永遠使用 `project_id = __platform__`: + +| Loop 名稱 | main.py 行 | 說明 | +|----------|-----------|------| +| `run_ai_slo_watchdog_loop` | 623 | Platform 層 SLO 監控 | +| `_run_model_version_tracker_loop` | 701 | AI Provider 版本追蹤 | +| `run_contract_outbox_relay` | (Phase 2 新增)| ADR-113 outbox relay | +| `run_stale_run_reaper` | (Phase 2 新增)| ADR-114 stale reaper | + +**分類 B:AWOOOI Tenant(遷移到 `project_id = awoooi`)** + +這些 loop 實質上只服務 AWOOOI 這個 tenant,遷移後仍只服務 AWOOOI,但有正確的 project_id: + +| Loop 類型 | 範例 | 遷移難度 | +|----------|------|---------| +| 告警處理 loop | `run_alert_processor` | LOW(加 ctx var)| +| Incident 管理 loop | `run_incident_timeline_loop` | LOW | +| Approval resolver | `run_approval_timeout_resolver` | MEDIUM(P0 priority)| +| KM / learning loop | `run_aol_writeback_loop` | MEDIUM | +| Telegram 相關 | `resend_stale_ready_tokens` | MEDIUM | + +**分類 C:Per-Tenant(Phase 3+,需重構)** + +這些 loop 未來需要為每個 tenant 運行一個實例: + +| Loop 類型 | 說明 | 目標架構 | +|----------|------|---------| +| Poller / receiver loop | 收取 channel 事件 | 每 tenant 一個 worker | +| Heartbeat / health check | 監控 tenant 的 K8s 狀態 | 每 tenant 一個 worker | + +**Phase 3 前,分類 C 的 loop 暫時保留在 legacy_awoooi_default 狀態。** + +### D2 — project_id 注入方式(三種) + +**方式 1:contextvars 注入(分類 B 優先)** + +```python +# 每個 loop 的頂層加入 project_id context +async def run_approval_timeout_resolver(): + project_id_ctx_var.set("awoooi") # ADR-111 contextvars + trace_id = generate_trace_id() + trace_id_ctx_var.set(trace_id) + + while True: + await _resolve_approval_timeouts() + await asyncio.sleep(30) +``` + +**方式 2:platform_admin DB role(分類 A)** + +平台層 loop 使用 `awooop_platform_admin` role(BYPASSRLS),project_id 設為 `__platform__`: + +```python +async def run_contract_outbox_relay(): + project_id_ctx_var.set("__platform__") + async with get_platform_db_session() as db: # 使用 awooop_platform_admin role + await _relay_outbox_events(db) +``` + +**方式 3:legacy_awoooi_default 過渡標籤(未遷移的 loop)** + +未遷移的 loop 在 `audit_log` 中用 `project_id = legacy_awoooi_default` 標記,讓 Phase 3+ 的審計可追溯: + +```python +# 過渡期標記(PR-10) +LOOP_PROJECT_ID = os.getenv("LOOP_PROJECT_ID", "legacy_awoooi_default") + +async def run_some_legacy_loop(): + project_id_ctx_var.set(LOOP_PROJECT_ID) + # ... loop body +``` + +### D3 — 遷移優先序(與 PR 計畫對齊) + +**P0 優先(立即)**: +- `run_approval_timeout_resolver`(main.py:490)— 缺 trace_id,approval 超時無法追蹤 +- 加 `project_id_ctx_var.set("awoooi")` + `trace_id` 生成 + +**P1 優先(Phase 2)**: +- `resend_stale_ready_tokens`(main.py:362)— Telegram 相關,缺 project_id 影響 RLS +- `run_aol_writeback_loop`(main.py:540)— KM 寫入路徑缺 project_id + +**P2 優先(Phase 2,PR-10 批量)**: +- 其餘 20+ 個分類 B 的 loop,加 `project_id_ctx_var.set("awoooi")` 標記 + +**Phase 3(重構)**: +- 分類 C 的 loop 重構為 per-tenant worker 架構 + +### D4 — 退出時程表(legacy_awoooi_default 清零) + +| 里程碑 | 條件 | 目標 Phase | +|--------|------|-----------| +| 分類 A 全部標記 `__platform__` | 4 個 loop | Phase 2 完成 | +| 分類 B 全部標記 `awoooi` | 27 個 loop | Phase 2 完成(PR-10)| +| `legacy_awoooi_default` loop 清零 | RLS 可真正強制 | Phase 3 完成前 | +| 分類 C 重構為 per-tenant | EwoooC 支援前置 | Phase 3 | +| K8s `LOOP_PROJECT_ID` env var 移除 | 不再需要過渡標籤 | Phase 3 | + +### D5 — Loop Health 監控 + +每個 loop 必須有 Prometheus gauge 記錄最後一次執行時間: + +```python +# 標準 loop 健康監控模板 +from prometheus_client import Gauge + +LOOP_LAST_RUN = Gauge( + "awooop_background_loop_last_run_timestamp", + "Unix timestamp of last successful loop execution", + labelnames=["loop_name", "project_id"] +) + +async def run_approval_timeout_resolver(): + project_id = "awoooi" + project_id_ctx_var.set(project_id) + + while True: + try: + await _resolve_approval_timeouts() + LOOP_LAST_RUN.labels( + loop_name="approval_timeout_resolver", + project_id=project_id + ).set_to_current_time() + except Exception as e: + logger.error(f"Loop error: {e}") + await asyncio.sleep(30) +``` + +**告警規則**: +```yaml +# Prometheus alert: loop 超過 5 分鐘沒有更新 +- alert: BackgroundLoopStale + expr: time() - awooop_background_loop_last_run_timestamp > 300 + labels: + severity: warning +``` + +--- + +## 後果 + +### Benefits +- 31 個 loop 的 project_id 遷移有明確分類和優先序 +- Phase 2 完成後,`legacy_awoooi_default` loop 清零,RLS 可真正強制 +- Loop 健康監控:每個 loop 都有 Prometheus gauge,SLO watchdog 可監控 + +### Costs +- PR-10 需要修改 ~27 個 loop 的頂部(逐個加 ctx var) +- 分類 C 的 loop 重構(Phase 3)是大型工程,需要 planner 拆解 + +### Risks +- 過渡期有 `legacy_awoooi_default` loop 繞過 RLS(設計上允許) +- 緩解:RLS 的空字串 policy 在 Phase 3 移除,Phase 1~2 可接受 +- P0 priority loop(approval_timeout_resolver)若遷移失敗 → approval 超時告警無法送達 + +--- + +## 驗收標準 + +- [ ] `run_approval_timeout_resolver` 加 project_id + trace_id(P0,立即) +- [ ] 全部 31 個 loop 標記 project_id(PR-10,Phase 2) +- [ ] `legacy_awoooi_default` loop 清零(Phase 3 前提條件) +- [ ] 每個 loop 有 Prometheus gauge 健康監控(Phase 2) +- [ ] Loop stale alert 觸發測試(Phase 2 驗收) + +## 關聯 + +- ADR-111(三 identity markers,platform_internal / legacy_awoooi_default / requires_project_id) +- ADR-114(stale reaper,新增 platform_internal loop) +- ADR-118(RLS,loop project_id 正確性是 RLS 前置條件) +- INV-3/INV-8(31 個 loop 詳細清單) +- PR-10(批量加 loop tagging) diff --git a/docs/adr/ADR-124-global-singleton-decomposition.md b/docs/adr/ADR-124-global-singleton-decomposition.md new file mode 100644 index 000000000..2f762e867 --- /dev/null +++ b/docs/adr/ADR-124-global-singleton-decomposition.md @@ -0,0 +1,222 @@ +# ADR-124: Global Singleton Decomposition for Multi-tenancy + +**狀態**:Accepted +**日期**:2026-05-03(台北) +**決策者**:統帥 +**範圍**:13 個 global singleton 的分類、分解策略、Tier 3 安全邊界 +**關聯**:ADR-111(bootstrap order)、ADR-118(RLS)、INV-9 + +--- + +## 背景 + +INV-9 確認 codebase 中有 **13 個** global singleton,全部是模組層級變數(`_engine: Optional[X] = None`)。這些 singleton 在多租戶環境下有兩個核心問題: + +1. **Tenant 共用狀態**:`AnomalyCounter` 等組件將所有 tenant 的數據混合在同一個實例中 +2. **Bootstrap 時機不明確**:singleton 在首次呼叫時才初始化,可能在 project_id 未知的情況下被觸發 + +**Tier 3 限制(RED_ZONES.md)**: +- `DecisionManager`(decision_manager.py:1402)— 禁止未經架構審查的修改 +- `TrustEngine`(trust_engine.py:189)— 核心信任計算,修改需要 P10 授權 + +--- + +## 決策 + +### D1 — 四種分解策略 + +**策略 1:Platform Singleton(保留,不分解)** + +這些 singleton 本就應該是平台層,無需 per-tenant 實例: + +| Singleton | 檔案 | 原因 | +|-----------|------|------| +| `TelegramGateway`(polling lock)| `telegram_gateway.py:1324` | Polling leader 是平台層,非 per-tenant | +| `HostRepairAgent` | `host_repair_agent.py` | 修復 host 是平台操作,非 per-tenant | +| `AIProviderRegistry` | `registry.py` | Provider 清單是平台層(但需要 `__provider` 修補,PR-04)| +| `FailoverAlerter` | `failover_alerter.py` | 告警路由是平台層 | + +**策略 2:Tenant-Scoped Instance(Phase 3,按需實例化)** + +這些 singleton 需要改為 per-project_id 實例: + +```python +# 修改前(全域 singleton) +_anomaly_counter: Optional[AnomalyCounter] = None + +def get_anomaly_counter() -> AnomalyCounter: + global _anomaly_counter + if _anomaly_counter is None: + _anomaly_counter = AnomalyCounter() + return _anomaly_counter + +# 修改後(per-tenant,Phase 3) +_anomaly_counters: dict[str, AnomalyCounter] = {} + +def get_anomaly_counter(project_id: str) -> AnomalyCounter: + if project_id not in _anomaly_counters: + _anomaly_counters[project_id] = AnomalyCounter(project_id=project_id) + return _anomaly_counters[project_id] +``` + +需要此策略的 singleton: + +| Singleton | 檔案 | 分解難度 | +|-----------|------|---------| +| `AnomalyCounter` | `anomaly_counter.py:85` | LOW(PR-11,Phase 2)| +| `ConsensusEngine` | `consensus_engine.py:344` | MEDIUM(Phase 3)| +| `IntentClassifier` | `intent_classifier.py` | MEDIUM(Phase 3)| + +**策略 3:Context-Injected(Phase 3,依賴注入)** + +透過 `ContextVar` 或 FastAPI `Depends` 注入,不使用模組層級 singleton: + +```python +# DecisionManager 不再是全域 singleton,而是 per-request 注入 +async def get_decision_manager( + project_id: str = Depends(get_project_id), + effective_policy: EffectivePolicy = Depends(get_effective_policy) +) -> DecisionManager: + return DecisionManager(project_id=project_id, policy=effective_policy) +``` + +需要此策略的 singleton: + +| Singleton | 檔案 | 備註 | +|-----------|------|------| +| `DecisionManager` | `decision_manager.py:1402` | Tier 3!需要 P10 架構審查 | +| `TrustEngine` | `trust_engine.py:189` | Tier 3!需要 P10 授權 | + +**策略 4:Module-Level Config(保留 singleton,但 config 注入)** + +Singleton 保留,但內部狀態改為從 `project_id` 動態讀取(而不是靜態初始化): + +```python +# DecisionFusionAdapter:不改 singleton 結構,但方法接受 project_id +class DecisionFusionAdapter: + async def fuse( + self, + project_id: str, # 新增 project_id 參數 + decisions: list[Decision] + ) -> FusedDecision: + policy = await get_effective_policy(project_id) + # 用 policy 而非 self 的靜態 config + ... +``` + +### D2 — 分解優先序 + +**立即(Phase 2)**: +- PR-04:`registry.py` `_provider` → `__provider` double underscore(INV-9 找到的封裝漏洞) +- PR-11:`AnomalyCounter` per-project(依賴 PR-10 loop tagging) + +**Phase 3**: +- `ConsensusEngine` per-tenant instance +- `IntentClassifier` per-tenant instance +- `DecisionFusionAdapter` 方法簽名加 `project_id` + +**Phase 4+(Tier 3,需 P10 審查)**: +- `DecisionManager` → 依賴注入重構(大型工程,需要獨立 ADR) +- `TrustEngine` → 依賴注入重構(Tier 3,必須有首席架構師授權) + +### D3 — AnomalyCounter 分解設計(Phase 2,PR-11) + +AnomalyCounter 是最安全的分解起點(影響範圍小,沒有 Tier 3 限制): + +```python +# anomaly_counter.py +_anomaly_counters: dict[str, AnomalyCounter] = {} +_counters_lock = asyncio.Lock() + +async def get_anomaly_counter(project_id: str) -> AnomalyCounter: + async with _counters_lock: + if project_id not in _anomaly_counters: + _anomaly_counters[project_id] = AnomalyCounter( + project_id=project_id, + redis_prefix=f"anomaly:{project_id}:" # per-tenant Redis key + ) + return _anomaly_counters[project_id] +``` + +**Redis key 遷移**(INV-1 P2 keys): +``` +舊:anomaly:counter:{metric} +新:anomaly:{project_id}:counter:{metric} +``` + +### D4 — DecisionManager + TrustEngine 的保護措施(Phase 2 前) + +在真正分解前,Phase 2 的保護措施: + +1. **Context 注入**:確保所有 DecisionManager 方法的 `project_id` 都從 `contextvars` 讀取 +2. **Redis key 隔離**:DecisionManager 內部的 Redis key 改為帶 `project_id` prefix(PR-06 已覆蓋部分) +3. **禁止直接呼叫 global**:在 Tier 3 檔案頂部加警告 comment(不是程式碼,是文件) + +### D5 — ConsensusEngine Redis Key 遷移(PR-06) + +INV-9 確認 `ConsensusEngine` 的 `CONSENSUS_PREFIX` 沒有 project 隔離: + +```python +# 修改前(consensus_engine.py) +CONSENSUS_PREFIX = "consensus:" + +# 修改後(PR-06,Phase 2) +def get_consensus_prefix(project_id: str) -> str: + return f"consensus:{project_id}:" +``` + +--- + +## 13 Singleton 完整分解計畫 + +| Singleton | 策略 | 優先級 | Phase | Tier | +|-----------|------|-------|-------|------| +| `TelegramGateway` | Platform Singleton(保留)| - | - | - | +| `HostRepairAgent` | Platform Singleton(保留)| - | - | - | +| `AIProviderRegistry` | Platform Singleton + PR-04 | P1 | Phase 2 | - | +| `FailoverAlerter` | Platform Singleton(保留)| - | - | - | +| `AnomalyCounter` | Tenant-Scoped | P1 | Phase 2 | - | +| `ConsensusEngine` | Tenant-Scoped + PR-06 | P1 | Phase 3 | - | +| `IntentClassifier` | Tenant-Scoped | P2 | Phase 3 | - | +| `DecisionFusionAdapter` | Config-Injected | P2 | Phase 3 | - | +| `AIRouter` | Config-Injected | P2 | Phase 3 | - | +| `AIRouterExecutor` | Config-Injected | P2 | Phase 3 | - | +| `DecisionManager` | Context-Injected | P3 | Phase 4+ | Tier 3 | +| `TrustEngine` | Context-Injected | P3 | Phase 4+ | Tier 3 | +| `TelegramGateway`(messages)| Context-Injected | P2 | Phase 3 | - | + +--- + +## 後果 + +### Benefits +- AnomalyCounter per-tenant:EwoooC 和 AWOOOI 的異常計數互不干擾 +- ConsensusEngine Redis key 隔離(PR-06):共識決策不跨 tenant 污染 +- Tier 3 singleton(DecisionManager / TrustEngine)有清晰的分解路徑,但保護好不急躁 + +### Costs +- AnomalyCounter per-tenant 需要 Redis key migration(舊格式的 counter 會被遺棄) +- Phase 3+ 的大規模 singleton 分解是重大工程(每個需要獨立 PR + critic 審查) + +### Risks +- `_anomaly_counters: dict` 本身沒有 GC 機制,長期運行可能累積 tenant 實例 +- 緩解:WeakValueDictionary 或定期清理(tenant 長時間無活動則清除實例) +- Tier 3 singleton 分解失敗 → 架構回退需要 hotfix +- 緩解:Tier 3 必須先有完整測試覆蓋才能動手 + +--- + +## 驗收標準 + +- [ ] PR-04:`registry.py` `_provider` → `__provider`(Phase 2) +- [ ] PR-06:ConsensusEngine Redis prefix 加 project_id(Phase 2) +- [ ] PR-11:AnomalyCounter per-tenant(Phase 2) +- [ ] Phase 3 前:ConsensusEngine / IntentClassifier tenant-scoped 完成 +- [ ] Phase 4+ 前:DecisionManager / TrustEngine 分解有獨立 ADR + P10 授權 + +## 關聯 + +- ADR-111(bootstrap order,singleton 初始化時機) +- ADR-118(RLS,tenant 隔離需要正確的 project_id context) +- INV-9(13 singleton 完整清單 + 檔案位置) +- PR-04/PR-06/PR-11(AnoooP Phase 2 具體 PR) diff --git a/docs/adr/ADR-UI-01-operator-console-architecture.md b/docs/adr/ADR-UI-01-operator-console-architecture.md new file mode 100644 index 000000000..dbb31a4f3 --- /dev/null +++ b/docs/adr/ADR-UI-01-operator-console-architecture.md @@ -0,0 +1,173 @@ +# ADR-UI-01: Operator Console Architecture + +**狀態**:Accepted +**日期**:2026-05-03(台北) +**決策者**:統帥 +**範圍**:AwoooP Operator Console 前端整體架構、與現有 apps/web/ 的整合方式 +**關聯**:ADR-106(六合約)、ADR-112(contract governance)、ADR-115(principal mapping) + +--- + +## 背景 + +AwoooP 需要一個 Operator Console(管理員後台),讓平台管理員可以: +1. 管理 tenant(project)和 principal 角色 +2. 查看和管理 contract revisions(六合約的 draft/publish/activate) +3. 監控 runs(run state, saga steps, budget usage) +4. 執行 approval decisions(human approval for WAITING_APPROVAL runs) + +現有 `apps/web/` 是 Next.js 14 前端,已有 AI 監控 dashboard。Operator Console 需要在不破壞現有功能的情況下整合。 + +--- + +## 決策 + +### D1 — 整合方式:子路由擴展(不建新應用) + +Operator Console 作為 `apps/web/` 的新路由段: + +``` +apps/web/src/app/ +├── (existing)/ ← 現有頁面(不動) +│ ├── dashboard/ +│ ├── incidents/ +│ └── ... +└── awooop/ ← 新增 Operator Console + ├── layout.tsx ← Console layout(sidebar + auth gate) + ├── tenants/ ← Tenant 管理 + │ ├── page.tsx + │ └── [project_id]/ + │ ├── page.tsx + │ └── principals/ + ├── contracts/ ← Contract governance + │ ├── page.tsx + │ └── [contract_id]/ + │ ├── page.tsx + │ └── revisions/ + ├── runs/ ← Run 監控 + │ ├── page.tsx + │ └── [run_id]/ + │ └── page.tsx + └── approvals/ ← Approval decisions + ├── page.tsx + └── [run_id]/ + └── page.tsx +``` + +### D2 — Auth Gate(Operator Console 專屬) + +Operator Console 需要 `platform_subject.roles` 中包含 `admin` 或 `approver`: + +```typescript +// apps/web/src/app/awooop/layout.tsx +import { redirect } from "next/navigation" +import { getServerSession } from "@/lib/auth" +import { hasOperatorRole } from "@/lib/awooop-auth" + +export default async function AwoooPLayout({ children }) { + const session = await getServerSession() + if (!session || !hasOperatorRole(session.user)) { + redirect("/unauthorized") + } + return ( +
+ +
{children}
+
+ ) +} +``` + +### D3 — API Client 分層 + +``` +apps/web/src/lib/ +├── awooop/ +│ ├── client.ts ← AwoooP Platform API client(/v1/platform/...) +│ ├── types.ts ← TypeScript types(從 API spec 生成) +│ ├── runs.ts ← Run 相關 API calls +│ ├── contracts.ts ← Contract 相關 API calls +│ ├── tenants.ts ← Tenant 相關 API calls +│ └── approvals.ts ← Approval 相關 API calls +``` + +**API base URL**: +```typescript +// 從環境變數讀取(禁止 hardcode 內網 IP,feedback_frontend_internal_ip_ban) +const AWOOOP_API_BASE = process.env.NEXT_PUBLIC_AWOOOP_API_URL +// K8s ConfigMap 設置:NEXT_PUBLIC_AWOOOP_API_URL=https://api.awoooi.com +``` + +### D4 — 8 個 UI 模組(Phase 5 實作) + +| 模組 | 路由 | 功能 | ADR 關聯 | +|------|------|------|---------| +| M1 Tenant List | `/awooop/tenants` | 列出所有 project,建立/停用 | ADR-115 | +| M2 Principal Manager | `/awooop/tenants/[id]/principals` | 角色管理,auto-provision 確認 | ADR-115 | +| M3 Contract Dashboard | `/awooop/contracts` | 六合約總覽,active revision 狀態 | ADR-106 | +| M4 Contract Editor | `/awooop/contracts/[id]/revisions` | draft/publish/activate 操作 | ADR-112 | +| M5 Run Monitor | `/awooop/runs` | 所有 run 的即時狀態 | ADR-114 | +| M6 Run Detail | `/awooop/runs/[run_id]` | saga_steps, budget, trace_id | ADR-119 | +| M7 Approval Queue | `/awooop/approvals` | WAITING_APPROVAL run 列表 | ADR-114 | +| M8 Approval Decision | `/awooop/approvals/[run_id]` | approve/reject + token 生成 | ADR-116 | + +**各模組的詳細設計在 ADR-UI-02 / ADR-UI-03 / ADR-UI-04。** + +### D5 — 設計原則 + +1. **禁止 emoji,使用 Lucide icon**(feedback_no_emoji_use_icons) +2. **全站統一字體/顏色**(feedback_design_system_consistency) +3. **100% next-intl i18n**(feedback_i18n_zero_hardcode)— 所有文字走翻譯 key +4. **禁止內網 IP**(feedback_frontend_internal_ip_ban)— NEXT_PUBLIC_* 只用公網域名 +5. **Logo SVG 與正式環境一致**(feedback_brand_logo_consistency) + +### D6 — 即時更新(WebSocket / SSE) + +Run Monitor(M5)和 Approval Queue(M7)需要即時更新: + +```typescript +// Server-Sent Events(比 WebSocket 更簡單,適合單向推送) +// GET /v1/platform/runs/stream?project_id=awoooi&status=RUNNING,WAITING_APPROVAL + +const eventSource = new EventSource( + `/v1/platform/runs/stream?project_id=${projectId}` +) +eventSource.onmessage = (event) => { + const update = JSON.parse(event.data) as RunStateUpdate + updateRunInCache(update) +} +``` + +--- + +## 後果 + +### Benefits +- 複用現有 apps/web/ 的 auth、i18n、設計系統(不重造輪子) +- 子路由設計:現有功能不受影響,可獨立部署 Operator Console 路由 +- Phase 5 前,`/awooop/*` 路由可以用 feature flag 隱藏(9 個 feature flag 中的 `ENABLE_OPERATOR_CONSOLE`) + +### Costs +- 需要在 apps/web/ 建立 `awooop/` 目錄結構(~15 個新檔案) +- API client 需要 TypeScript type generation(從 OpenAPI spec) + +### Risks +- Operator Console 的 auth gate 若有漏洞 → 非管理員可存取敏感操作 +- 緩解:auth gate 在 layout.tsx 實作(server-side),不依賴 client-side 隱藏 + +--- + +## 驗收標準 + +- [ ] `/awooop` 路由在 `apps/web/src/app/` 建立(Phase 5) +- [ ] auth gate:非 admin/approver → redirect `/unauthorized`(整合測試) +- [ ] `NEXT_PUBLIC_AWOOOP_API_URL` 不含內網 IP(CI lint check) +- [ ] 8 個模組全部有對應路由(Phase 5 完成) + +## 關聯 + +- ADR-106(六合約,Contract Dashboard M3/M4 的資料來源) +- ADR-112(contract governance API,M4 的操作路徑) +- ADR-114(run state,M5 M6 的資料來源) +- ADR-115(principal mapping,M1 M2) +- ADR-116(approval token,M8 核心功能) diff --git a/docs/adr/ADR-UI-02-contract-governance-ui.md b/docs/adr/ADR-UI-02-contract-governance-ui.md new file mode 100644 index 000000000..2ab353706 --- /dev/null +++ b/docs/adr/ADR-UI-02-contract-governance-ui.md @@ -0,0 +1,166 @@ +# ADR-UI-02: Contract Governance UI + +**狀態**:Accepted +**日期**:2026-05-03(台北) +**決策者**:統帥 +**範圍**:M3 Contract Dashboard + M4 Contract Editor 的詳細設計 +**關聯**:ADR-UI-01(Operator Console 架構)、ADR-112(contract governance)、ADR-113(outbox) + +--- + +## 背景 + +Contract Dashboard(M3)和 Contract Editor(M4)是 Operator Console 最複雜的模組,需要展示六合約的全貌,並支援 draft → publish → activate 的工作流程。 + +--- + +## M3 — Contract Dashboard + +### 頁面設計 + +**路由**:`/awooop/contracts` + +**主要資訊**: +``` +┌─────────────────────────────────────────────────────────────┐ +│ Contract Dashboard [+ New Draft] │ +├──────────┬──────────────────────┬──────────┬───────────────┤ +│ Project │ Contract Family │ Active │ Last Updated │ +├──────────┼──────────────────────┼──────────┼───────────────┤ +│ awoooi │ policy_routing │ v2.1 ✅ │ 2026-05-03 │ +│ awoooi │ mcp_gateway │ v1.3 ✅ │ 2026-04-20 │ +│ awoooi │ agent │ v1.0 ✅ │ 2026-03-15 │ +│ awoooi │ project_tenant │ v1.0 ✅ │ 2026-01-10 │ +│ awoooi │ runtime_run_state │ v1.1 ✅ │ 2026-04-28 │ +│ awoooi │ channel_event │ v1.2 ✅ │ 2026-04-15 │ +│ ewoooc │ policy_routing │ ⚠️ None │ — │ +└──────────┴──────────────────────┴──────────┴───────────────┘ +``` + +**狀態 badge**: +- `✅ v2.1`:有 active revision,lifecycle_status = active +- `⚠️ None`:無 active revision(需要初始設定) +- `🔄 Pending Activation`:有 published revision 等待 activate(approval 路徑) +- `🔴 Outbox Lag`:outbox 有未送出事件超過 60 秒(ADR-113 告警) + +### API 呼叫 + +```typescript +// GET /v1/platform/projects/{project_id}/contracts +// Response: ContractSummary[] + +interface ContractSummary { + project_id: string + contract_family: ContractFamily + contract_id: string + active_revision_id: string | null + active_version: string | null + lifecycle_status: "active" | "no_active" | "pending_activation" + last_updated_at: string + has_pending_outbox: boolean // ADR-113 outbox lag indicator +} +``` + +--- + +## M4 — Contract Editor + +### 頁面設計 + +**路由**:`/awooop/contracts/[contract_id]/revisions` + +**頁面佈局**: + +``` +左側:Revision List 右側:Revision Detail +┌─────────────────────┐ ┌──────────────────────────────────┐ +│ policy_routing/ │ │ Revision: v2.1 (Active) │ +│ awoooi-sre │ │ │ +│ ─────────────────── │ │ [Body JSON] [Signature] [History]│ +│ v2.1 ● Active │ →選擇→ │ │ +│ v2.0 Published │ │ { │ +│ v1.9 Published │ │ "routing_rules": [...], │ +│ v1.8 Published │ │ "model_priority": [...] │ +│ v0.1 Draft │ │ } │ +│ ─────────────────── │ │ │ +│ [+ New Draft] │ │ Signature: ✅ Valid │ +└─────────────────────┘ │ Published by: admin@awoooi │ + │ Published at: 2026-05-03 14:23 │ + │ │ + │ [Activate] [Revoke] [Diff vs v2.0]│ + └──────────────────────────────────┘ +``` + +### 操作按鈕邏輯 + +| 按鈕 | 可見條件 | 行為 | +|------|---------|------| +| `[+ New Draft]` | 任何時候 | 建立新 draft revision | +| `[Publish]` | status=draft | 呼叫 publish API + 簽章驗證 | +| `[Activate]` | status=published | 路徑 A:送 approval;路徑 B:直接 activate | +| `[Revoke]` | status=active | 確認對話框 → revoke(回到 no_active 狀態)| +| `[Diff vs v2.0]` | status=published or active | 顯示與前一版本的 JSON diff | + +### Activation Approval Flow(路徑 A) + +對於 `policy_routing`、`mcp_gateway`、`runtime_run_state` 三類 contract(ADR-112 D3 路徑 A): + +``` +點擊 [Activate] + ↓ +顯示對話框: +「此 contract 需要 approval。 + 請說明變更原因:[ textarea ] + [提交 Approval 請求] [取消]」 + ↓ +POST /v1/platform/projects/{project_id}/contracts/{contract_id}/activate +Body: { "revision_id": "...", "change_reason": "..." } + ↓ +API 返回:{ "approval_run_id": "...", "status": "WAITING_APPROVAL" } + ↓ +頁面顯示:「Approval 請求已送出 → 前往 Approval Queue」 + ↓ +M7 Approval Queue 顯示待審核項目 +``` + +### 版本 Diff 視圖 + +```typescript +// 使用 react-diff-viewer 或自製 JSON diff +import { DiffViewer } from "@/components/awooop/diff-viewer" + +// 對比兩個 revision 的 body_json + +``` + +--- + +## 後果 + +### Benefits +- Contract Dashboard 一眼看清六合約的 active 狀態 +- Outbox lag indicator(ADR-113):若 relay worker 卡住,在 UI 立即可見 +- Diff 視圖:activate 前可以比對版本差異,減少誤操作 + +### Costs +- JSON diff 顯示需要第三方套件或自製(約 100 行) +- Activation approval flow 需要與 M7/M8 的 Approval 模組配合 + +--- + +## 驗收標準 + +- [ ] M3:六合約全部顯示,狀態 badge 正確(Phase 5 整合測試) +- [ ] M4:draft → publish → activate 完整流程可操作(Phase 5) +- [ ] M4:路徑 A contract activate → Approval Queue 有對應請求(整合測試) +- [ ] M4:JSON diff 視圖可比對版本差異(Phase 5) + +## 關聯 + +- ADR-UI-01(Operator Console 整體路由) +- ADR-112(contract governance API) +- ADR-113(outbox,M3 的 outbox lag indicator) diff --git a/docs/adr/ADR-UI-03-run-monitor-ui.md b/docs/adr/ADR-UI-03-run-monitor-ui.md new file mode 100644 index 000000000..efa43e2e9 --- /dev/null +++ b/docs/adr/ADR-UI-03-run-monitor-ui.md @@ -0,0 +1,185 @@ +# ADR-UI-03: Run Monitor UI + +**狀態**:Accepted +**日期**:2026-05-03(台北) +**決策者**:統帥 +**範圍**:M5 Run Monitor + M6 Run Detail 的詳細設計 +**關聯**:ADR-UI-01(架構)、ADR-114(worker lease)、ADR-119(SAGA)、ADR-120(budget) + +--- + +## M5 — Run Monitor + +### 頁面設計 + +**路由**:`/awooop/runs` + +**主要資訊(即時更新,SSE)**: + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ Run Monitor Filter: [All Projects ▼] [Status ▼] [Agent ▼] 🔴 Live │ +├────────────────────┬───────────────────┬───────────────┬──────────┬──────────┤ +│ Run ID │ Project / Agent │ Status │ Budget │ Started │ +├────────────────────┼───────────────────┼───────────────┼──────────┼──────────┤ +│ abc123... │ awoooi / openclaw │ 🟡 RUNNING │ $0.12/$1 │ 14:23:01 │ +│ def456... │ awoooi / openclaw │ 🔵 WAITING │ $0.08/$1 │ 14:20:45 │ +│ │ │ _APPROVAL │ │ │ +│ ghi789... │ awoooi / openclaw │ ✅ COMPLETED │ $0.45/$1 │ 14:15:00 │ +│ jkl012... │ awoooi / openclaw │ ❌ FAILED │ $0.30/$1 │ 14:10:22 │ +└────────────────────┴───────────────────┴───────────────┴──────────┴──────────┘ +``` + +**狀態顏色對應**(ADR-114 D4 狀態機): +- 🟡 RUNNING:黃色(進行中) +- 🔵 WAITING_APPROVAL:藍色(等待人工) +- ⚪ WAITING_TOOL:灰色(等待工具) +- ✅ COMPLETED:綠色 +- ❌ FAILED:紅色 +- 🚫 CANCELLED:灰色刪除線 + +**Budget 進度條**: + +```tsx + +``` + +### SSE 連線 + +```typescript +// apps/web/src/lib/awooop/runs.ts +export function subscribeToRunUpdates( + projectId: string, + onUpdate: (update: RunStateUpdate) => void +) { + const url = `/v1/platform/runs/stream?project_id=${projectId}&status=RUNNING,WAITING_APPROVAL` + const es = new EventSource(url) + + es.onmessage = (event) => { + const update: RunStateUpdate = JSON.parse(event.data) + onUpdate(update) + } + + return () => es.close() // cleanup +} +``` + +--- + +## M6 — Run Detail + +### 頁面設計 + +**路由**:`/awooop/runs/[run_id]` + +**頁面分 Tab**: + +``` +[Overview] [SAGA Steps] [Budget] [Trace] +``` + +#### Tab 1: Overview + +``` +Run: abc12345-... +Status: RUNNING Worker: worker-pod-abc Attempt: 1/3 +Agent: openclaw-sre Project: awoooi +Started: 2026-05-04 14:23:01 (台北) +Trace ID: 4bf92f3577b34da6a3ce929d0e0e4736 + +Channel: Telegram | Subject: awoooi:telegram:123456789 +Trigger: "請幫我重啟 api deployment" +``` + +#### Tab 2: SAGA Steps(ADR-119) + +``` +Step 1 [LLM Call] ✅ completed 14:23:01 → 14:23:04 (3.2s) + ↳ Model: llama3.2 | Tokens: 512+128 | Cost: $0.0042 + ↳ Decision: tool_call → k8s_scale_deployment + +Step 2 [Tool Call] ✅ completed 14:23:04 → 14:23:07 (3.1s) + ↳ Tool: k8s_scale_deployment + ↳ Params: {deployment: "api", replicas: 3 → 1} + ↳ Compensation: scale back to 3 ← [如果此 step 要補償] + +Step 3 [Approval Req] 🔵 pending 14:23:07 → ... + ↳ Waiting for human approval + ↳ Token expires: 14:38:07 (14分鐘後) + +[Run SAGA Compensation] ← 緊急按鈕(只有 admin 可見) +``` + +SAGA Step 視圖特別功能: +- 補償命令(compensation_cmd)展開顯示 +- 緊急 SAGA 補償按鈕(確認對話框後觸發) + +#### Tab 3: Budget + +``` +Token Budget: [$0.12 / $1.00] ████░░░░░░ 12% + +Per-call Breakdown: + Step 1 LLM: 512 prompt + 128 completion = $0.0042 + Step 2 Tool: —(不計 token) + ────────────────────────────────────── + Total: 640 tokens | $0.0042 + +Daily Tenant Budget: [$1.23 / $10.00] 12.3% +``` + +#### Tab 4: Trace(OTel) + +``` +Trace ID: 4bf92f3577b34da6a3ce929d0e0e4736 +[在 SignOz 查看 →] ← 外部連結到 SignOz 188:3301 + +Span List: + chat ollama (openclaw-sre/awoooi) 3.2s ✅ + ├── gen_ai.request.model: llama3.2 + ├── gen_ai.usage.prompt_tokens: 512 + └── gen_ai.usage.completion_tokens: 128 +``` + +SignOz 連結(從環境變數): +```typescript +const SIGNOZ_BASE_URL = process.env.NEXT_PUBLIC_SIGNOZ_URL +// 設置:NEXT_PUBLIC_SIGNOZ_URL=http://192.168.0.188:3301 +// 注意:這是監控工具的內網 URL,不進 JS Bundle 的 public API call,只作為連結目標 +``` + +--- + +## 後果 + +### Benefits +- Run Monitor 即時顯示所有 RUNNING + WAITING_APPROVAL run(SSE) +- Run Detail 的 SAGA Steps tab 讓 operator 清楚看到每步的執行狀況 +- Budget tab:run 級和 tenant 級 budget 可視化 +- 緊急 SAGA 補償按鈕:operator 可手動觸發補償(高風險操作,需確認) + +### Costs +- SSE 連線維持 → server 端需要 AsyncGenerator endpoint(約 30 行) +- SignOz 連結是外部連結,不嵌入(避免 CSP 問題) + +--- + +## 驗收標準 + +- [ ] M5:RUNNING + WAITING_APPROVAL run 即時更新(SSE 整合測試) +- [ ] M5:Budget bar 80%/95% 顏色正確(視覺測試) +- [ ] M6:SAGA Steps 展開顯示補償命令(Phase 5) +- [ ] M6:Budget tab 顯示 per-call breakdown(Phase 5) +- [ ] M6:緊急 SAGA 補償按鈕 → 確認對話框 → API 呼叫(整合測試) + +## 關聯 + +- ADR-UI-01(路由結構) +- ADR-114(run 狀態機,狀態顏色對應) +- ADR-119(SAGA steps,Tab 2 資料來源) +- ADR-120(budget,Tab 3 資料來源) +- ADR-121(OTel spans,Tab 4) diff --git a/docs/adr/ADR-UI-04-approval-queue-ui.md b/docs/adr/ADR-UI-04-approval-queue-ui.md new file mode 100644 index 000000000..731a60c07 --- /dev/null +++ b/docs/adr/ADR-UI-04-approval-queue-ui.md @@ -0,0 +1,219 @@ +# ADR-UI-04: Approval Queue UI + +**狀態**:Accepted +**日期**:2026-05-03(台北) +**決策者**:統帥 +**範圍**:M7 Approval Queue + M8 Approval Decision 的詳細設計 +**關聯**:ADR-UI-01(架構)、ADR-114(WAITING_APPROVAL state)、ADR-116(approval_token) + +--- + +## M7 — Approval Queue + +### 頁面設計 + +**路由**:`/awooop/approvals` + +**即時更新(SSE)— 重要:approval 過期倒數** + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ Approval Queue 🔵 3 pending │ +├──────────────┬────────────────────────────┬────────────────┬──────────────┤ +│ Run ID │ Request Summary │ Risk Level │ Expires in │ +├──────────────┼────────────────────────────┼────────────────┼──────────────┤ +│ abc123... │ k8s_exec_pod(api, restart) │ 🔴 HIGH │ 12:34 ⏱️ │ +│ def456... │ k8s_scale_deployment(3→1) │ 🟡 MEDIUM │ 06:12 ⏱️ │ +│ ghi789... │ pg_mutate(DELETE incidents) │ 🔴 HIGH │ 02:01 ⚠️ │ +└──────────────┴────────────────────────────┴────────────────┴──────────────┘ +``` + +**倒數計時顏色**: +- > 10 分鐘:灰色 +- 5-10 分鐘:⏱️ 橙色 +- < 5 分鐘:⚠️ 紅色閃爍 +- 過期:❌ 已過期(不可再 approve) + +**倒數計時邏輯**(前端計算,不依賴 SSE 更新): + +```typescript +function TokenCountdown({ expiresAt }: { expiresAt: string }) { + const [remaining, setRemaining] = useState(() => + Math.max(0, differenceInSeconds(new Date(expiresAt), new Date())) + ) + + useEffect(() => { + const interval = setInterval(() => { + setRemaining(prev => Math.max(0, prev - 1)) + }, 1000) + return () => clearInterval(interval) + }, []) + + const minutes = Math.floor(remaining / 60) + const seconds = remaining % 60 + const isUrgent = remaining < 300 // < 5分鐘 + + return ( + + {minutes}:{seconds.toString().padStart(2, "0")} + + ) +} +``` + +**Telegram 通知連動**: +- 新的 WAITING_APPROVAL run → 同時發 Telegram 通知給有 `approver` role 的 principal +- 通知格式(ADR-116 + approval_token): + ``` + [APPROVAL REQUEST] Run abc123 + Operation: k8s_exec_pod(api, restart) + Risk: HIGH | Expires: 15min + [Approve] [Reject] ← Telegram inline buttons + ``` +- Telegram approve/reject → 走 callback → Phase 8 resume API + +--- + +## M8 — Approval Decision + +### 頁面設計 + +**路由**:`/awooop/approvals/[run_id]` + +**頁面佈局**: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Approval Request Expires: 12:34 ⏱️ │ +├─────────────────────────────────────────────────────────────────────────┤ +│ Run: abc12345-... │ +│ Agent: openclaw-sre | Project: awoooi │ +│ Subject: awoooi:telegram:123456789 (陳統帥) │ +│ Trigger: "請幫我把 api deployment 縮到 1 個 replica" │ +├─────────────────────────────────────────────────────────────────────────┤ +│ REQUESTED OPERATION │ +│ ────────────────────────────────────────────────────────────────────── │ +│ Tool: k8s_scale_deployment │ +│ Risk Level: 🔴 HIGH │ +│ Parameters: │ +│ namespace: awoooi-prod │ +│ deployment: api │ +│ replicas: 1 (current: 3) │ +│ │ +│ Compensation available: ✅ (scale back to 3 if rejected/rolled back) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ SAGA Context(前面做了什麼) │ +│ ────────────────────────────────────────────────────────────────────── │ +│ Step 1 ✅ LLM decision: scale api to 1 (reason: SLO budget exceeded) │ +│ Step 2 ⏳ WAITING: scale_deployment approval │ +├─────────────────────────────────────────────────────────────────────────┤ +│ APPROVAL DECISION │ +│ │ +│ Comment (optional): ____________________________________ │ +│ │ +│ [✅ Approve] [❌ Reject] │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Approve / Reject 流程 + +```typescript +async function handleApprovalDecision( + runId: string, + decision: "approve" | "reject", + comment: string +) { + // 1. 取得 approval_token(Platform API 生成 HS256 token) + const { approval_token } = await fetchApprovalToken(runId) + + // 2. 送出決策 + const response = await fetch(`/v1/platform/runs/${runId}/resume`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + approval_token, // ADR-116 D4 + decision, + comment, + approver_subject_id: currentUser.platform_subject_id + }) + }) + + if (!response.ok) { + const error = await response.json() + if (error.code === "E-APPROVAL-002") { + // approval_token 過期 → 提示用戶刷新頁面 + toast.error("Approval token has expired. Please refresh.") + } else if (error.code === "E-APPROVAL-003") { + // jti 已使用(重複提交) + toast.error("This approval has already been submitted.") + } + return + } + + // 3. 成功 → 跳回 Approval Queue + router.push("/awooop/approvals") + toast.success(`Run ${decision === "approve" ? "approved" : "rejected"} successfully.`) +} +``` + +### 錯誤處理 + +| 錯誤碼 | UI 行為 | +|--------|---------| +| E-APPROVAL-001 | 「此 run 不再需要 approval」→ 跳回 Queue | +| E-APPROVAL-002 | 「Token 已過期,請刷新」→ 重新取 token | +| E-APPROVAL-003 | 「已送出過 approval,請勿重複提交」→ 禁用按鈕 | +| E-APPROVAL-004 | 「您沒有 approver 權限」→ 顯示錯誤,跳回首頁 | + +### Token 取得機制 + +```typescript +// GET /v1/platform/runs/{run_id}/approval-token +// → 返回 HS256 approval_token(只對目前 session 的用戶有效) + +async function fetchApprovalToken(runId: string): Promise<{ approval_token: string }> { + const response = await fetch(`/v1/platform/runs/${runId}/approval-token`) + if (!response.ok) throw new Error("Failed to fetch approval token") + return response.json() +} +``` + +**注意**:approval_token 在 API 端生成後,每次頁面載入都重新取得(15min TTL,不快取)。 + +--- + +## 後果 + +### Benefits +- 倒數計時讓 approver 清楚知道緊迫程度(防止 approval 超時) +- SAGA Context 顯示:approver 可以看到 run 到目前為止做了什麼,做出知情決策 +- Compensation 顯示:approver 知道 reject 後會自動回滾什麼操作 +- Telegram 按鈕連動:approver 可以在 Telegram 直接 approve/reject,不需要開網頁 + +### Costs +- 倒數計時 setInterval(每頁面一個,不影響效能) +- approval_token 每次頁面載入都重新取得(一次 API call,可接受) + +### Risks +- 倒數計時與 server 時鐘有偏差(前端計算 vs server exp claim) +- 緩解:頁面載入時以 server 返回的 `expires_at` 為準,不用 `Date.now()` + +--- + +## 驗收標準 + +- [ ] M7:WAITING_APPROVAL run 即時顯示 + 倒數計時(Phase 5) +- [ ] M7:< 5 分鐘 → 紅色閃爍(視覺測試) +- [ ] M8:SAGA Context 正確顯示前序步驟(Phase 5) +- [ ] M8:Approve → run 恢復 RESUMED 狀態(整合測試) +- [ ] M8:Reject → SAGA 補償觸發(整合測試) +- [ ] M8:E-APPROVAL-002(token 過期)→ 錯誤提示正確(整合測試) +- [ ] Telegram inline button approve → Phase 8 resume API(整合測試) + +## 關聯 + +- ADR-UI-01(Operator Console 路由架構) +- ADR-UI-03(M6 Run Detail,SAGA 步驟資料共用) +- ADR-114(WAITING_APPROVAL state,lease 不 renew) +- ADR-116(approval_token HS256,M8 核心) +- ADR-119(SAGA compensation,reject 觸發) diff --git a/docs/runbooks/awooop-partition-retention.md b/docs/runbooks/awooop-partition-retention.md new file mode 100644 index 000000000..8d3fb6179 --- /dev/null +++ b/docs/runbooks/awooop-partition-retention.md @@ -0,0 +1,174 @@ +# AwoooP Partition & Retention Runbook + +**建立**:2026-05-04(台北) +**ADR 依據**:ADR-114(channel_event_dedupe)、ADR-119(run_state) +**關聯**:Phase 1 Task 1.4、Phase 4/7(run_state / mcp_gateway_audit 建立時套用) + +--- + +## 概覽 + +| 表 | Partition 策略 | Retention | 建立 Phase | +|---|---|---|---| +| `awooop_channel_event_dedupe` | RANGE by `created_at`(日)| 7 天 | Phase 1 ✅ | +| `awooop_run_state` | RANGE by `created_at`(月)| 90 天 hot + 1 年 warm | Phase 4 | +| `awooop_mcp_gateway_audit` | RANGE by `created_at`(月)| 90 天 hot + 1 年 warm | Phase 5 | +| `awooop_agent_audit_log` | RANGE by `created_at`(月)| 90 天 hot + 1 年 warm | Phase 7 | + +--- + +## 1. awooop_channel_event_dedupe(Phase 1,已完成) + +### pg_partman 維護(建議) + +```sql +-- 確認 pg_partman 已安裝 +SELECT extname, extversion FROM pg_extension WHERE extname = 'pg_partman'; + +-- 初始化(若 Phase 1 migration 未自動完成) +SELECT 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 +); + +UPDATE partman.part_config + SET retention = '7 days', + retention_keep_table = false + WHERE parent_table = 'public.awooop_channel_event_dedupe'; +``` + +### pg_partman 定期維護(CronJob,每天 00:00) + +```bash +# K8s CronJob 或 pg_cron 執行 +psql $DATABASE_URL -c "SELECT partman.run_maintenance('public.awooop_channel_event_dedupe');" +``` + +### 手動維護(無 pg_partman) + +```sql +-- 查看現有 partition +SELECT inhrelid::regclass AS partition_name, + pg_get_expr(c.relpartbound, c.oid) AS bounds + FROM pg_inherits i + JOIN pg_class c ON c.oid = i.inhrelid + WHERE inhparent = 'awooop_channel_event_dedupe'::regclass + ORDER BY bounds; + +-- 建立下一週 partition(每天執行一次) +DO $$ +DECLARE + d DATE := CURRENT_DATE + 1; +BEGIN + 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 $$; + +-- 刪除 7 天前的 partition(毫秒級,遠優於 DELETE) +DO $$ +DECLARE + old_date DATE := CURRENT_DATE - 8; + partition_name TEXT := 'awooop_channel_event_dedupe_' || to_char(old_date, 'YYYYMMDD'); +BEGIN + EXECUTE 'DROP TABLE IF EXISTS ' || partition_name; + RAISE NOTICE 'Dropped partition: %', partition_name; +END $$; +``` + +--- + +## 2. awooop_run_state(Phase 4 建立時套用) + +> **尚未建立**。Phase 4 建立 `awooop_run_state` 表時使用以下模板。 + +### 月份 Partition 建立模板 + +```sql +CREATE TABLE awooop_run_state ( + run_id UUID NOT NULL DEFAULT gen_random_uuid(), + project_id VARCHAR(64) NOT NULL, + -- ... 其他欄位 ... + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (run_id, created_at) +) PARTITION BY RANGE (created_at); + +-- pg_partman 初始化 +SELECT partman.create_parent( + p_parent_table := 'public.awooop_run_state', + p_control := 'created_at', + p_type := 'native', + p_interval := '1 month', + p_premake := 3 +); + +UPDATE partman.part_config + SET retention = '90 days', -- hot tier: 90 天自動 DROP + retention_keep_table = true, -- warm tier: 保留為 detached table + retention_schema = 'warm_archive'-- warm partition 移到此 schema + WHERE parent_table = 'public.awooop_run_state'; +``` + +### Retention 策略 + +| Tier | 資料年齡 | 存放位置 | 清理方式 | +|------|---------|---------|---------| +| Hot | 0~90 天 | `public.awooop_run_state_*` | pg_partman 自動管理 | +| Warm | 91 天~1 年 | `warm_archive.awooop_run_state_*` | 保留(detach,不 DROP)| +| Cold | > 1 年 | S3 / GCS export(可選)| 手動 COPY TO 後 DROP | + +--- + +## 3. awooop_mcp_gateway_audit + awooop_agent_audit_log(Phase 5/7) + +與 `awooop_run_state` 相同策略(月份 partition,90 天 hot + 1 年 warm)。 + +建立時直接套用同一 pg_partman 模板,替換表名即可。 + +--- + +## 健康檢查 + +```sql +-- 確認各 partition 的資料分佈 +SELECT + child.relname AS partition, + pg_size_pretty(pg_total_relation_size(child.oid)) AS size, + (SELECT count(*) FROM ONLY pg_class WHERE oid = child.oid) AS approx_rows +FROM pg_inherits +JOIN pg_class parent ON parent.oid = pg_inherits.inhparent +JOIN pg_class child ON child.oid = pg_inherits.inhrelid +WHERE parent.relname = 'awooop_channel_event_dedupe' +ORDER BY child.relname; + +-- 確認最舊 partition(應 <= 7 天前) +SELECT min(created_at), max(created_at) FROM awooop_channel_event_dedupe; +``` + +--- + +## 告警規則(建議加入 Prometheus) + +```yaml +# partition 數量異常(應維持 7 ± 2 個) +- alert: AwoooPDedupPartitionCountAbnormal + expr: | + (SELECT count(*) FROM pg_inherits + WHERE inhparent = 'awooop_channel_event_dedupe'::regclass) NOT BETWEEN 5 AND 10 + labels: + severity: warning + +# pg_partman maintenance 未執行(超過 25 小時) +- alert: AwoooPPartmanMaintenanceStale + expr: time() - awooop_partman_last_run_timestamp > 90000 + labels: + severity: warning +```