feat(awooop): Phase 0 全 ADR + Phase 1 control plane schema(含 critic 四項修正)
## Phase 0(文件層,全部 Accepted) - ADR-106/107:AwoooP 平台架構 + 儲存策略 - ADR-111~118:Bootstrap → RLS 七項核心 ADR - ADR-119~124:SAGA → Singleton Decomposition 六項 ADR - ADR-UI-01~04:Operator Console 四個 UI ADR ## Phase 1(DB schema + migration) - awooop_phase1_control_plane_2026-05-04.sql:7 張新表 + trigger + RLS - Step 1:三角色(platform_admin/migration BYPASSRLS,awooop_app 受 RLS) - Step 13:GRANT awooop_app 最小權限(7 條) - Step 14:RLS fail-closed,移除 __platform__ 後門 - awooop_phase1_batch1_rls_2026-05-04.sql:高流量四表三步式 ADD COLUMN - awooop_phase1_batch1_backfill.py:SKIP LOCKED 分批回填腳本 - awooop_models.py:7 個 SQLAlchemy 2.x models ## Critic 修正(4 Critical + 3 Major) - C-1:ADD CONSTRAINT IF NOT EXISTS → DO 塊 + pg_constraint 查詢 - C-2:__mapper_args__ 字串 list → primary_key=True on mapped_column - C-3:__platform__ RLS 後門 → 全移除,改用 BYPASSRLS role - C-4:awooop_app role 從未建立 → Step 1 + 7 條 GRANT - M-1:active_pointer_guard SECURITY DEFINER(FORCE RLS 跨租戶保護) - M-2:pg_partman create_parent 加冪等防護 - M-3:immutability trigger 新增身份欄位保護(project_id/family/contract_id) ## Task 1.2 修補 - agent_loader.py:硬編碼 Mac 路徑 → AGENTS_DIR 環境變數 - Dockerfile:補 COPY .claude/agents/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
311
apps/api/src/db/awooop_models.py
Normal file
311
apps/api/src/db/awooop_models.py
Normal file
@@ -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()")
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user