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:
Your Name
2026-05-04 13:37:11 +08:00
parent b4055c5915
commit 13e51802fe
28 changed files with 5988 additions and 1 deletions

View File

@@ -0,0 +1,311 @@
"""
AwoooP Control Plane Models
============================
Phase 1 新表:六合約 control plane、tenant 隔離、principal mapping。
ADR-111~1182026-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 bootstrapADR-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-onlyADR-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 pointerADR-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 invalidationADR-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 keyADR-114partitioned 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 PKpartition 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 mappingADR-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 × capabilityADR-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()")
)

View File

@@ -1,12 +1,16 @@
"""載入 .claude/agents/*.md 並解析 system promptADR-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/agentsDockerfile COPY .claude/agents/ ./.claude/agents/
_AGENTS_DIR = pathlib.Path(os.getenv("AGENTS_DIR", "/app/.claude/agents"))
def _parse_agent_md(path: pathlib.Path) -> str: