## Phase 1-3: Control Plane + Contract System - awooop_phase1_control_plane_2026-05-04.sql: 12 張核心表 + RLS - awooop_phase1_batch1_rls_2026-05-04.sql: 全部 FORCE RLS + GRANT - packages/awooop-contracts/: 六合約 JSON Schema + golden fixtures - src/models/awooop_contracts.py: Pydantic v2 contract models(extra=forbid) - src/repositories/contract_repository.py: contract lifecycle(draft→published→active) - src/services/contract_service.py: HMAC publish sig + Redis multi-sig activate - src/services/schema_validator.py: LLM output validator(retry×3, E-SCHEMA-001) ## Phase 2: Tenant Isolation - awooop_phase2_budget_ledger_2026-05-04.sql: budget_ledger + RLS - src/services/budget_service.py: Token Budget Hard Kill 三層防線 - src/core/context.py: PROJECT_ID ContextVar(31 background loop 自動繼承) - src/db/base.py + models.py: project_id 欄位 + RLS set_config 注入 - src/hermes/nl_gateway.py: project_id Redis key 前綴(Phase A 雙寫) - src/services/anomaly_counter.py: per-project 改造(Phase A fallback) ## Phase 4: Platform Shell in Shadow Mode - awooop_phase4_run_state_2026-05-04.sql: run_state + step_journal + idempotency - src/services/run_state_machine.py: 8-state FSM + SKIP LOCKED + stale reaper - src/services/platform_runtime.py: UUID v7 + W3C trace_id + shadow_execute - src/services/audit_sink.py: PII/secret redaction 9 patterns - src/api/v1/platform/runs.py: POST/GET /v1/platform/runs(Router→Service 架構) - src/workers/platform_worker.py: SKIP LOCKED worker + heartbeat + reaper loop - src/main.py: platform router + lifespan worker start/stop ## Phase 5: MCP Gateway 五閘門 - awooop_phase5_mcp_gateway_2026-05-04.sql: 4 表 + RLS - src/plugins/mcp/gateway.py: McpGateway(Gate 1~5, E-MCP-GATE-001~009) - src/plugins/mcp/redaction_middleware.py: 雙層 redaction + 16K 截斷 - src/plugins/mcp/registry.py: __provider name mangling(ADR-116) - src/plugins/mcp/credential_resolver.py: k8s secret ref 解析 - tests/test_mcp_credential_isolation.py: 10 個迴歸測試(secret leak 防再現) ## Phase 6-8: EwoooC + Channel Hub + Approval Token - awooop_phase6_ewoooc_onboarding_2026-05-04.sql: ewoooc tenant + 4 read-only MCP tools - awooop_phase7_channel_hub_2026-05-04.sql: conversation_event + outbound_message - src/services/provider_proxy.py: ProviderProxy + PlatformEnvelope(ADR-115) - src/services/channel_hub.py: Telegram inbound mirror + Progressive Feedback(30s) - src/services/awooop_approval_token.py: HS256 + jti NX replay 防護 + suggest mode Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
262 lines
9.0 KiB
Python
262 lines
9.0 KiB
Python
"""
|
||
Contract Repository
|
||
===================
|
||
AwoooP Phase 3: contract revision CRUD(append-only)
|
||
2026-05-04 ogt + Claude Sonnet 4.6(ADR-107/ADR-112)
|
||
|
||
設計原則:
|
||
- append-only:已 published 的 revision 不可修改
|
||
- active pointer 以 UPSERT 維護(awooop_active_revisions)
|
||
- outbox 事件在同一 transaction 寫入(ADR-113)
|
||
- RLS 透過 get_db_context() 自動套用
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
from uuid import UUID
|
||
|
||
import structlog
|
||
from sqlalchemy import select, update
|
||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||
|
||
from src.db.awooop_models import (
|
||
AwoooPActiveRevision,
|
||
AwoooPContractOutbox,
|
||
AwoooPContractRevision,
|
||
)
|
||
from src.db.base import get_db_context
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Read
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
async def get_revision(
|
||
revision_id: UUID,
|
||
project_id: str = "awoooi",
|
||
) -> AwoooPContractRevision | None:
|
||
"""依 revision_id 讀取單筆(含 RLS 驗證)"""
|
||
async with get_db_context(project_id) as db:
|
||
result = await db.execute(
|
||
select(AwoooPContractRevision).where(
|
||
AwoooPContractRevision.revision_id == revision_id,
|
||
AwoooPContractRevision.project_id == project_id,
|
||
)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
|
||
async def get_active_revision(
|
||
project_id: str,
|
||
contract_family: str,
|
||
contract_id: str,
|
||
) -> AwoooPContractRevision | None:
|
||
"""
|
||
讀取 active revision(runtime 路徑)。
|
||
只返回 lifecycle_status='active' 的 revision。
|
||
"""
|
||
async with get_db_context(project_id) as db:
|
||
result = await db.execute(
|
||
select(AwoooPContractRevision)
|
||
.join(
|
||
AwoooPActiveRevision,
|
||
AwoooPActiveRevision.active_revision_id == AwoooPContractRevision.revision_id,
|
||
)
|
||
.where(
|
||
AwoooPActiveRevision.project_id == project_id,
|
||
AwoooPActiveRevision.contract_family == contract_family,
|
||
AwoooPActiveRevision.contract_id == contract_id,
|
||
AwoooPContractRevision.lifecycle_status == "active",
|
||
)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
|
||
async def list_revisions(
|
||
project_id: str,
|
||
contract_family: str,
|
||
contract_id: str,
|
||
lifecycle_status: str | None = None,
|
||
) -> list[AwoooPContractRevision]:
|
||
"""列出所有 revision(按 version 降序)"""
|
||
async with get_db_context(project_id) as db:
|
||
q = select(AwoooPContractRevision).where(
|
||
AwoooPContractRevision.project_id == project_id,
|
||
AwoooPContractRevision.contract_family == contract_family,
|
||
AwoooPContractRevision.contract_id == contract_id,
|
||
)
|
||
if lifecycle_status:
|
||
q = q.where(AwoooPContractRevision.lifecycle_status == lifecycle_status)
|
||
q = q.order_by(
|
||
AwoooPContractRevision.version_major.desc(),
|
||
AwoooPContractRevision.version_minor.desc(),
|
||
)
|
||
result = await db.execute(q)
|
||
return list(result.scalars().all())
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Write(append-only)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
async def create_draft(
|
||
*,
|
||
project_id: str,
|
||
contract_family: str,
|
||
contract_id: str,
|
||
version_major: int,
|
||
version_minor: int,
|
||
body_json: dict[str, Any],
|
||
body_hash: str,
|
||
body_schema_version: str = "v1.0",
|
||
) -> AwoooPContractRevision:
|
||
"""建立 draft revision(不可被 runtime 讀取)"""
|
||
async with get_db_context(project_id) as db:
|
||
revision = AwoooPContractRevision(
|
||
project_id=project_id,
|
||
contract_family=contract_family,
|
||
contract_id=contract_id,
|
||
version_major=version_major,
|
||
version_minor=version_minor,
|
||
lifecycle_status="draft",
|
||
body_json=body_json,
|
||
body_hash=body_hash,
|
||
body_schema_version=body_schema_version,
|
||
)
|
||
db.add(revision)
|
||
await db.flush()
|
||
await db.refresh(revision)
|
||
|
||
logger.info(
|
||
"contract_draft_created",
|
||
revision_id=str(revision.revision_id),
|
||
project_id=project_id,
|
||
contract_family=contract_family,
|
||
contract_id=contract_id,
|
||
)
|
||
return revision
|
||
|
||
|
||
async def mark_published(
|
||
*,
|
||
revision_id: UUID,
|
||
project_id: str,
|
||
publisher_id: str,
|
||
publish_signature: str,
|
||
published_at: Any, # datetime
|
||
) -> AwoooPContractRevision:
|
||
"""
|
||
draft → published 轉換(HMAC 簽章驗證後由 service 呼叫)。
|
||
published revision 可被 activate,但不可被 runtime 直接讀取。
|
||
"""
|
||
async with get_db_context(project_id) as db:
|
||
await db.execute(
|
||
update(AwoooPContractRevision)
|
||
.where(
|
||
AwoooPContractRevision.revision_id == revision_id,
|
||
AwoooPContractRevision.project_id == project_id,
|
||
AwoooPContractRevision.lifecycle_status == "draft",
|
||
)
|
||
.values(
|
||
lifecycle_status="published",
|
||
publisher_id=publisher_id,
|
||
publish_signature=publish_signature,
|
||
published_at=published_at,
|
||
)
|
||
)
|
||
result = await db.execute(
|
||
select(AwoooPContractRevision).where(
|
||
AwoooPContractRevision.revision_id == revision_id
|
||
)
|
||
)
|
||
revision = result.scalar_one()
|
||
logger.info(
|
||
"contract_published",
|
||
revision_id=str(revision_id),
|
||
project_id=project_id,
|
||
publisher_id=publisher_id,
|
||
)
|
||
return revision
|
||
|
||
|
||
async def mark_active(
|
||
*,
|
||
revision_id: UUID,
|
||
project_id: str,
|
||
contract_family: str,
|
||
contract_id: str,
|
||
old_revision_id: UUID | None,
|
||
) -> AwoooPContractRevision:
|
||
"""
|
||
published → active 轉換 + 更新 active pointer + 寫入 outbox。
|
||
三個操作在同一 transaction(ADR-113 transactional outbox)。
|
||
"""
|
||
async with get_db_context(project_id) as db:
|
||
# 1. 更新 revision lifecycle_status
|
||
await db.execute(
|
||
update(AwoooPContractRevision)
|
||
.where(
|
||
AwoooPContractRevision.revision_id == revision_id,
|
||
AwoooPContractRevision.project_id == project_id,
|
||
AwoooPContractRevision.lifecycle_status == "published",
|
||
)
|
||
.values(lifecycle_status="active")
|
||
)
|
||
|
||
# 2. UPSERT active pointer
|
||
stmt = pg_insert(AwoooPActiveRevision).values(
|
||
project_id=project_id,
|
||
contract_family=contract_family,
|
||
contract_id=contract_id,
|
||
active_revision_id=revision_id,
|
||
)
|
||
stmt = stmt.on_conflict_do_update(
|
||
constraint="uq_active_pointer",
|
||
set_={
|
||
"active_revision_id": revision_id,
|
||
},
|
||
)
|
||
await db.execute(stmt)
|
||
|
||
# 3. 寫入 outbox event(ADR-113)
|
||
outbox_event = AwoooPContractOutbox(
|
||
event_type="contract.activated",
|
||
project_id=project_id,
|
||
contract_family=contract_family,
|
||
contract_id=contract_id,
|
||
old_revision_id=old_revision_id,
|
||
new_revision_id=revision_id,
|
||
)
|
||
db.add(outbox_event)
|
||
|
||
# 4. 如有舊 active revision,標記為 revoked
|
||
if old_revision_id:
|
||
await db.execute(
|
||
update(AwoooPContractRevision)
|
||
.where(
|
||
AwoooPContractRevision.revision_id == old_revision_id,
|
||
AwoooPContractRevision.lifecycle_status == "active",
|
||
)
|
||
.values(lifecycle_status="revoked")
|
||
)
|
||
|
||
result = await db.execute(
|
||
select(AwoooPContractRevision).where(
|
||
AwoooPContractRevision.revision_id == revision_id
|
||
)
|
||
)
|
||
revision = result.scalar_one()
|
||
|
||
logger.info(
|
||
"contract_activated",
|
||
revision_id=str(revision_id),
|
||
old_revision_id=str(old_revision_id) if old_revision_id else None,
|
||
project_id=project_id,
|
||
contract_family=contract_family,
|
||
contract_id=contract_id,
|
||
)
|
||
return revision
|