diff --git a/database/autoheal_models.py b/database/autoheal_models.py index 65c55bb..adab951 100644 --- a/database/autoheal_models.py +++ b/database/autoheal_models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, ForeignKey, Index, Float +from sqlalchemy import CheckConstraint, Column, Integer, String, DateTime, Text, Boolean, ForeignKey, Index, Float from sqlalchemy.orm import relationship from database.models import Base from datetime import datetime @@ -55,6 +55,29 @@ class ActionPlan(Base): executed_at = Column(DateTime, nullable=True) __table_args__ = ( + CheckConstraint( + "action_type IS NOT NULL OR created_by IS NOT NULL", + name="chk_action_plans_source_marker", + ), + CheckConstraint( + "action_type IS NULL OR action_type IN ('auto', 'code_review_fix', 'openclaw_recommendation')", + name="chk_action_plans_action_type", + ), + CheckConstraint( + "created_by IS NULL OR created_by IN (" + "'nemotron', 'openclaw', 'code_review_pipeline', " + "'ai_orchestrator', 'watcher_agent', 'agent_actions', " + "'elephant_alpha', 'manual', 'system'" + ")", + name="chk_action_plans_created_by", + ), + CheckConstraint( + "status IS NULL OR status IN (" + "'pending', 'approved', 'rejected', 'executed', " + "'auto_pending', 'auto_disabled', 'pending_review'" + ")", + name="chk_action_plans_status", + ), Index('idx_action_plans_type', 'action_type'), Index('idx_action_plan_sku_status', 'sku', 'status'), Index('idx_action_plan_created', 'created_at'), diff --git a/migrations/037_add_action_plans_guardrails.sql b/migrations/037_add_action_plans_guardrails.sql new file mode 100644 index 0000000..61d2ead --- /dev/null +++ b/migrations/037_add_action_plans_guardrails.sql @@ -0,0 +1,78 @@ +-- ============================================================================= +-- Migration 037: action_plans source/status guardrails +-- 日期: 2026-05-12 台北 +-- ============================================================================= +-- 背景: +-- action_plans 是 Group A(CodeReview/OpenClaw)與 Group B(NemoTron) +-- 共用的 superset schema。migration 019 解了缺欄,但沒有 caller/source 護欄, +-- 新寫入容易變成 action_type、created_by 都空的不可歸因資料。 +-- +-- 設計: +-- 1. 使用 NOT VALID:不掃描歷史資料,但 PostgreSQL 仍會檢查新 INSERT/UPDATE。 +-- 2. 不限制 plan_type/payload 組合,先只防止不可歸因與明顯非法狀態。 +-- 3. 若未來新增 caller,需新增 migration 擴充白名單。 +-- ============================================================================= + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'chk_action_plans_source_marker' + ) THEN + ALTER TABLE action_plans + ADD CONSTRAINT chk_action_plans_source_marker + CHECK (action_type IS NOT NULL OR created_by IS NOT NULL) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'chk_action_plans_action_type' + ) THEN + ALTER TABLE action_plans + ADD CONSTRAINT chk_action_plans_action_type + CHECK ( + action_type IS NULL + OR action_type IN ('auto', 'code_review_fix', 'openclaw_recommendation') + ) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'chk_action_plans_created_by' + ) THEN + ALTER TABLE action_plans + ADD CONSTRAINT chk_action_plans_created_by + CHECK ( + created_by IS NULL + OR created_by IN ( + 'nemotron', 'openclaw', 'code_review_pipeline', + 'ai_orchestrator', 'watcher_agent', 'agent_actions', + 'elephant_alpha', 'manual', 'system' + ) + ) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'chk_action_plans_status' + ) THEN + ALTER TABLE action_plans + ADD CONSTRAINT chk_action_plans_status + CHECK ( + status IS NULL + OR status IN ( + 'pending', 'approved', 'rejected', 'executed', + 'auto_pending', 'auto_disabled', 'pending_review' + ) + ) + NOT VALID; + END IF; +END $$; + +DO $$ +BEGIN + RAISE NOTICE 'Migration 037 done: action_plans guardrail CHECK constraints added as NOT VALID'; +END $$; diff --git a/tests/test_auto_heal_safety.py b/tests/test_auto_heal_safety.py index b3fa2ae..2dd3faa 100644 --- a/tests/test_auto_heal_safety.py +++ b/tests/test_auto_heal_safety.py @@ -56,6 +56,19 @@ def test_incident_model_keeps_legacy_and_current_columns(): } <= columns +def test_action_plan_model_has_source_guardrail_constraints(): + from database.manager import Base + + constraints = {item.name for item in Base.metadata.tables["action_plans"].constraints} + + assert { + "chk_action_plans_source_marker", + "chk_action_plans_action_type", + "chk_action_plans_created_by", + "chk_action_plans_status", + } <= constraints + + def test_auto_heal_status_update_backfills_dual_playbook_columns(monkeypatch): from services.auto_heal_service import AutoHealResult, AutoHealService