""" 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 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()") ) # ───────────────────────────────────────────────────────────────────────────── # Phase 4: Run State Machine(ADR-114/ADR-119) # 2026-05-04 ogt + Claude Sonnet 4.6 # ───────────────────────────────────────────────────────────────────────────── class AwoooPRunState(Base): """Run FSM 主表(SKIP LOCKED worker lease,ADR-114)""" __tablename__ = "awooop_run_state" __table_args__ = ( CheckConstraint( "state IN (" "'pending','running','waiting_tool'," "'waiting_approval','completed','failed','cancelled','timeout')", name="chk_run_state", ), Index("idx_run_state_pending", "project_id", "created_at", postgresql_where=text("state = 'pending' AND lease_until IS NULL")), Index("idx_run_state_stale", "lease_until", postgresql_where=text("state = 'running' AND lease_until IS NOT NULL")), Index("idx_run_state_project_timeline", "project_id", "created_at"), Index("idx_run_state_trace_id", "trace_id", postgresql_where=text("trace_id IS NOT NULL")), ) run_id: Mapped[UUID] = mapped_column(primary_key=True) project_id: Mapped[str] = mapped_column( String(64), ForeignKey("awooop_projects.project_id"), nullable=False ) agent_id: Mapped[str] = mapped_column(String(128), nullable=False) state: Mapped[str] = mapped_column(String(32), nullable=False, default="pending") lease_until: Mapped[datetime | None] = mapped_column(nullable=True) heartbeat_at: Mapped[datetime | None] = mapped_column(nullable=True) worker_id: Mapped[str | None] = mapped_column(String(128), nullable=True) attempt_count: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0) max_attempts: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=3) trace_id: Mapped[str | None] = mapped_column(String(128), nullable=True) trigger_type: Mapped[str | None] = mapped_column(String(32), nullable=True) trigger_ref: Mapped[str | None] = mapped_column(String(256), nullable=True) is_shadow: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) input_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) output_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) cost_usd: Mapped[Decimal] = mapped_column( Numeric(10, 4), nullable=False, default=Decimal("0.0000") ) step_count: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0) error_code: Mapped[str | None] = mapped_column(String(64), nullable=True) error_detail: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column( nullable=False, server_default=text("NOW()") ) started_at: Mapped[datetime | None] = mapped_column(nullable=True) completed_at: Mapped[datetime | None] = mapped_column(nullable=True) timeout_at: Mapped[datetime | None] = mapped_column(nullable=True) class AwoooPRunStepJournal(Base): """SAGA step journal(ADR-119)— 每個 tool call 獨立記錄""" __tablename__ = "awooop_run_step_journal" __table_args__ = ( UniqueConstraint("run_id", "step_seq", name="uix_run_step_seq"), CheckConstraint( "result_status IN ('pending','success','failed','compensated')", name="chk_step_result_status", ), Index("idx_run_step_run_id", "run_id", "step_seq"), ) step_id: Mapped[UUID] = mapped_column( primary_key=True, server_default=text("gen_random_uuid()") ) run_id: Mapped[UUID] = mapped_column( ForeignKey("awooop_run_state.run_id", ondelete="CASCADE"), nullable=False ) project_id: Mapped[str] = mapped_column(String(64), nullable=False) step_seq: Mapped[int] = mapped_column(SmallInteger, nullable=False) tool_name: Mapped[str] = mapped_column(String(128), nullable=False) mcp_gateway_id: Mapped[str | None] = mapped_column(String(128), nullable=True) input_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) output_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) compensation_json: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) result_status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending") error_code: Mapped[str | None] = mapped_column(String(64), nullable=True) was_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) block_reason: Mapped[str | None] = mapped_column(String(128), nullable=True) created_at: Mapped[datetime] = mapped_column( nullable=False, server_default=text("NOW()") ) completed_at: Mapped[datetime | None] = mapped_column(nullable=True) latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) class AwoooPRunIdempotency(Base): """Run 去重冪等表(ADR-114)— (project_id, channel_type, provider_event_id) → run_id""" __tablename__ = "awooop_run_idempotency" __table_args__ = ( UniqueConstraint( "project_id", "channel_type", "provider_event_id", name="uix_run_idempotency_key", ), Index("idx_run_idempotency_run_id", "run_id"), ) idempotency_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( ForeignKey("awooop_run_state.run_id"), nullable=False ) created_at: Mapped[datetime] = mapped_column( nullable=False, server_default=text("NOW()") ) # ============================================================================= # Phase 5: MCP Gateway 四表(ADR-116/ADR-118,2026-05-04) # ============================================================================= class AwoooPMcpToolRegistry(Base): """MCP Tool 白名單(Gate 3: Tool)""" __tablename__ = "awooop_mcp_tool_registry" __table_args__ = ( CheckConstraint( "tool_type IN ('builtin','mcp_server','custom')", name="chk_tool_type", ), CheckConstraint( "jsonb_typeof(allowed_scopes) = 'array'", name="chk_allowed_scopes_array", ), UniqueConstraint("project_id", "tool_name", name="uix_tool_registry_project_name"), Index("idx_mcp_tool_registry_project", "project_id", "is_active"), ) tool_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", ondelete="CASCADE"), nullable=False ) tool_name: Mapped[str] = mapped_column(String(128), nullable=False) tool_type: Mapped[str] = mapped_column(String(32), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) allowed_scopes: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list) environment_tags: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) 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 AwoooPMcpGrant(Base): """Agent × Tool 授權記錄(Gate 2 + Gate 3)""" __tablename__ = "awooop_mcp_grants" __table_args__ = ( CheckConstraint( "jsonb_typeof(granted_scopes) = 'array'", name="chk_grant_scopes_array", ), CheckConstraint( "(is_revoked = FALSE AND revoked_at IS NULL AND revoked_by IS NULL)" " OR (is_revoked = TRUE AND revoked_at IS NOT NULL)", name="chk_revoke_consistency", ), UniqueConstraint("project_id", "agent_id", "tool_id", name="uix_mcp_grant_agent_tool"), Index( "idx_mcp_grants_lookup", "project_id", "agent_id", "tool_id", postgresql_where=text("is_revoked = FALSE"), ), ) grant_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", ondelete="CASCADE"), nullable=False ) agent_id: Mapped[str] = mapped_column(String(128), nullable=False) tool_id: Mapped[UUID] = mapped_column( ForeignKey("awooop_mcp_tool_registry.tool_id", ondelete="CASCADE"), nullable=False ) granted_by: Mapped[str] = mapped_column(String(128), nullable=False) granted_scopes: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list) expires_at: Mapped[datetime | None] = mapped_column(nullable=True) is_revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) revoked_at: Mapped[datetime | None] = mapped_column(nullable=True) revoked_by: Mapped[str | None] = mapped_column(String(128), nullable=True) created_at: Mapped[datetime] = mapped_column( nullable=False, server_default=text("NOW()") ) class AwoooPMcpCredentialRef(Base): """k8s Secret 參照(ADR-118 credential isolation)— 只存路徑,不存明文""" __tablename__ = "awooop_mcp_credential_refs" __table_args__ = ( CheckConstraint( r"k8s_secret_ref ~ '^[a-z0-9-]+/[a-z0-9-]+#[a-zA-Z0-9_-]+$'", name="chk_k8s_ref_format", ), CheckConstraint( r"value_sha256 IS NULL OR value_sha256 ~ '^[0-9a-f]{64}$'", name="chk_value_sha256_hex", ), UniqueConstraint("tool_id", "k8s_secret_ref", name="uix_credential_ref_tool"), Index("idx_mcp_cred_refs_tool", "tool_id", postgresql_where=text("is_active = TRUE")), ) ref_id: Mapped[UUID] = mapped_column( primary_key=True, server_default=text("gen_random_uuid()") ) tool_id: Mapped[UUID] = mapped_column( ForeignKey("awooop_mcp_tool_registry.tool_id", ondelete="CASCADE"), nullable=False ) project_id: Mapped[str] = mapped_column( String(64), ForeignKey("awooop_projects.project_id", ondelete="CASCADE"), nullable=False ) k8s_secret_ref: Mapped[str] = mapped_column(String(256), nullable=False) value_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) created_at: Mapped[datetime] = mapped_column( nullable=False, server_default=text("NOW()") ) rotated_at: Mapped[datetime | None] = mapped_column(nullable=True) class AwoooPMcpGatewayAudit(Base): """MCP Gateway call 稽核日誌(ADR-116 P1-09)""" __tablename__ = "awooop_mcp_gateway_audit" __table_args__ = ( CheckConstraint( "result_status IN ('success','blocked','failed','timeout')", name="chk_gateway_result_status", ), CheckConstraint( "block_gate IS NULL OR (block_gate >= 1 AND block_gate <= 5)", name="chk_block_gate_range", ), Index("idx_mcp_audit_run", "project_id", "run_id", "created_at"), Index( "idx_mcp_audit_blocked", "project_id", "block_gate", "created_at", postgresql_where=text("result_status = 'blocked'"), ), ) call_id: Mapped[UUID] = mapped_column( primary_key=True, server_default=text("gen_random_uuid()") ) project_id: Mapped[str] = mapped_column(String(64), nullable=False) run_id: Mapped[UUID | None] = mapped_column(nullable=True) trace_id: Mapped[str | None] = mapped_column(String(128), nullable=True) agent_id: Mapped[str | None] = mapped_column(String(128), nullable=True) tool_id: Mapped[UUID | None] = mapped_column( ForeignKey("awooop_mcp_tool_registry.tool_id"), nullable=True ) tool_name: Mapped[str] = mapped_column(String(128), nullable=False) credential_ref: Mapped[str | None] = mapped_column(String(256), nullable=True) input_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) output_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) gate_result: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) result_status: Mapped[str] = mapped_column(String(16), nullable=False) block_gate: Mapped[int | None] = mapped_column(SmallInteger, nullable=True) block_reason: Mapped[str | None] = mapped_column(String(256), nullable=True) latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) created_at: Mapped[datetime] = mapped_column( nullable=False, server_default=text("NOW()") ) # ============================================================================= # Phase 7: Channel Hub 雙表(ADR-106 channel_event family,2026-05-04) # ============================================================================= class AwoooPConversationEvent(Base): """入站 Channel Event 鏡像(Telegram/LINE inbound,不儲存明文)""" __tablename__ = "awooop_conversation_event" __table_args__ = ( CheckConstraint( "channel_type IN ('telegram','line','slack','api','internal')", name="chk_conv_event_channel_type", ), CheckConstraint( "content_type IN ('text','photo','document','command','callback_query')", name="chk_conv_event_content_type", ), UniqueConstraint( "project_id", "channel_type", "provider_event_id", name="uix_conv_event_dedup", ), Index("idx_conv_event_run", "project_id", "run_id", "received_at"), Index("idx_conv_event_subject", "project_id", "platform_subject_id", "received_at"), Index("idx_conv_event_recent", "project_id", "channel_type", "received_at"), ) event_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", ondelete="CASCADE"), nullable=False ) channel_type: Mapped[str] = mapped_column(String(32), nullable=False) provider_event_id: Mapped[str] = mapped_column(String(256), nullable=False) platform_subject_id: Mapped[str | None] = mapped_column(String(128), nullable=True) channel_user_id: Mapped[str | None] = mapped_column(String(256), nullable=True) channel_chat_id: Mapped[str | None] = mapped_column(String(256), nullable=True) run_id: Mapped[UUID | None] = mapped_column(nullable=True) content_type: Mapped[str] = mapped_column(String(32), nullable=False, default="text") content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) content_preview: Mapped[str | None] = mapped_column(String(256), nullable=True) content_redacted: Mapped[str | None] = mapped_column(Text, nullable=True) redaction_version: Mapped[str] = mapped_column( String(32), nullable=False, server_default=text("'audit_sink_v1'") ) source_envelope: Mapped[dict[str, Any]] = mapped_column( JSONB, nullable=False, server_default=text("'{}'::jsonb") ) attachment_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) is_duplicate: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) provider_ts: Mapped[datetime | None] = mapped_column(nullable=True) received_at: Mapped[datetime] = mapped_column( nullable=False, server_default=text("NOW()") ) class AwoooPOutboundMessage(Base): """出站訊息記錄(interim/final/approval_request + shadow status)""" __tablename__ = "awooop_outbound_message" __table_args__ = ( CheckConstraint( "channel_type IN ('telegram','line','slack','api','internal')", name="chk_outbound_channel_type", ), CheckConstraint( "message_type IN ('interim','final','error','approval_request')", name="chk_outbound_message_type", ), CheckConstraint( "send_status IN ('pending','sent','failed','shadow')", name="chk_outbound_send_status", ), Index("idx_outbound_msg_run", "project_id", "run_id", "queued_at"), Index( "idx_outbound_msg_pending", "project_id", "channel_type", "queued_at", postgresql_where=text("send_status = 'pending'"), ), ) message_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", ondelete="CASCADE"), nullable=False ) run_id: Mapped[UUID] = mapped_column(nullable=False) conversation_event_id: Mapped[UUID | None] = mapped_column(nullable=True) channel_type: Mapped[str] = mapped_column(String(32), nullable=False) channel_chat_id: Mapped[str] = mapped_column(String(256), nullable=False) message_type: Mapped[str] = mapped_column(String(32), nullable=False) content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) content_preview: Mapped[str | None] = mapped_column(String(256), nullable=True) content_redacted: Mapped[str | None] = mapped_column(Text, nullable=True) redaction_version: Mapped[str] = mapped_column( String(32), nullable=False, server_default=text("'audit_sink_v1'") ) source_envelope: Mapped[dict[str, Any]] = mapped_column( JSONB, nullable=False, server_default=text("'{}'::jsonb") ) provider_message_id: Mapped[str | None] = mapped_column(String(64), nullable=True) send_status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending") send_error: Mapped[str | None] = mapped_column(Text, nullable=True) queued_at: Mapped[datetime] = mapped_column( nullable=False, server_default=text("NOW()") ) sent_at: Mapped[datetime | None] = mapped_column(nullable=True) triggered_by_state: Mapped[str | None] = mapped_column(String(32), nullable=True) waiting_since: Mapped[datetime | None] = mapped_column(nullable=True)