## 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>
6.2 KiB
6.2 KiB
ADR-111: AwoooP Bootstrap Order & Identity Paradox
狀態:Accepted 日期:2026-05-03(台北) 決策者:統帥 範圍:AwoooP 平台啟動順序、project_id 標記體系、31 個 background loop 的 identity 策略 關聯:ADR-106(架構)、ADR-107(儲存)、ADR-123(background loop migration)
背景
AWOOOI 作為 AwoooP 的 first tenant 和 first runtime host,在系統啟動時存在一個根本矛盾:
- AwoooP 的核心原則是「所有操作必須帶 project_id」
- 但
apps/api/src/main.py有 31 個 background loop(onboarder 實測),全部都沒有 project_id - AWOOOI 既是「需要被 project_id 保護的 tenant」,又是「在任何 project 被建立前就要運行的平台基礎設施」
這就是 Bootstrap Paradox:你需要平台先啟動,才能建立 project;但啟動時又沒有 project。
問題清單
- 31 個 background loop 無 project_id(main.py 實測)
- AWOOOI cron/job/healthcheck 在 AwoooP 對象化之前就必須運行
run_ai_slo_watchdog_loop()是平台自我監控,不屬於任何 tenant_run_model_version_tracker_loop()追蹤所有 tenant 共用的 AI provider 版本,屬於 platform resource- GCP Ollama 三層容災(ADR-110)的 failover 狀態(
ollama:current_primary)是 platform_resource,不屬於任何 tenant
決策
D1 — 三種 project_id 標記
所有入口點(loop、webhook、job、CLI)必須帶其中一個標記:
| 標記 | project_id 值 | 適用情境 |
|---|---|---|
platform_internal |
__platform__ |
平台自我維護、不屬於任何 tenant;允許讀取 platform_resource,但必須記 audit |
legacy_awoooi_default |
awoooi |
過渡期:AWOOOI 業務 loop 尚未改造前的臨時 fallback |
requires_project_id |
從呼叫鏈或 event envelope 取得 | 已完成多租戶改造的入口點 |
D2 — Platform Resource 明確清單
以下 Redis key 和狀態是 platform_resource,使用前綴 platform: 而非 {project_id}::
| Resource | Key | 理由 |
|---|---|---|
| Ollama topology | platform:ollama:topology |
GCP-A/GCP-B/Local 三層路由,所有 tenant 共用(ADR-110) |
| Telegram polling leader | platform:telegram:polling:leader |
全域 pod 鎖,不屬於任何 tenant |
| Gemini daily count | platform:ollama:gemini_daily_count:{date} |
全平台 Gemini 緊急路由計數 |
| SLO watchdog state | platform:slo_watchdog:* |
平台自我監控 |
| Model version cache | platform:model_version:* |
所有 tenant 共用的 provider 版本資訊 |
D3 — 啟動順序(Hard Order)
1. DB migrations(awooop_projects + awooop_contract_revisions 等)
2. Seed AWOOOI project_id(INSERT IF NOT EXISTS)
3. platform_internal services 啟動(SLO watchdog、model version tracker)
4. legacy_awoooi_default services 啟動(AWOOOI 業務 loop,帶 project_id=awoooi)
5. API server 開始接收請求
6. requires_project_id services 啟動(Phase 2+ 改造後的新入口點)
禁止:API server 在 step 2(seed AWOOOI project)完成前接受任何需要 project_id 的請求。 實作:FastAPI lifespan context manager 控制啟動順序。
D4 — 過渡期豁免規則(legacy_awoooi_default)
以下條件下,project_id=awoooi 作為過渡期 fallback 是允許的:
- 明確標記為
legacy_awoooi_default(不能靜默 fallback) - 寫入 audit log(標記
legacy=true) - 有退場時程(Phase 4 完成後 90 天內改造完畢)
D5 — Hard Reject 規則
在 Phase 2 完成後,以下情況必須拒絕(不能靜默 fallback):
- 新建立的 API endpoint 收到無 project_id 的請求(非 legacy)
requires_project_id標記的 loop 缺少 project_id 的 context variable
D6 — GCP Ollama 拓撲的 bootstrap 處理
ADR-110 的三層 Ollama 拓撲是 platform_resource:
- bootstrap 時必須在 step 3 之前確認 GCP-A/GCP-B 健康狀態
platform:ollama:topologyRedis key 由ollama_failover_manager.py在啟動時初始化- 所有 tenant 的 LLM call 使用這個 platform-level topology,不能 per-project 覆蓋(safety: no per-tenant Ollama endpoint override)
實作指引
Python contextvars 實作
# apps/api/src/core/context.py(新建)
import contextvars
project_id_ctx_var: contextvars.ContextVar[str] = contextvars.ContextVar(
'project_id', default='__platform__'
)
def get_current_project_id() -> str:
return project_id_ctx_var.get()
async def run_with_project(coro, project_id: str):
"""在指定 project context 中執行 coroutine"""
token = project_id_ctx_var.set(project_id)
try:
return await coro
finally:
project_id_ctx_var.reset(token)
main.py 改造示意(Phase 2 完成後)
# platform_internal loop
asyncio.create_task(run_with_project(
run_ai_slo_watchdog_loop(), "__platform__"
))
# legacy_awoooi_default loop(過渡期)
asyncio.create_task(run_with_project(
run_incident_analysis_sweeper(), "awoooi"
))
後果
Benefits
- Bootstrap Paradox 有明確解法(三種標記 + 啟動順序)
- platform_resource 明確隔離,不會和 tenant 資料混算
- GCP Ollama 三層拓撲作為 platform_resource,不受 per-tenant policy 影響
Costs
- 31 個 background loop 全部需要改造(INV-8 列出優先序)
- main.py 啟動邏輯需要重構(step 1~6 排序)
- 需要新建
apps/api/src/core/context.py
Risks
- legacy_awoooi_default 標記若不退場,會永遠留在 codebase
- 緩解:ADR-123 明確定義退場時程,Phase 4 後 90 天
驗收標準
apps/api/src/core/context.py建立(project_id contextvars)- main.py 31 個 loop 全部帶 project_id context(PR-10 完成)
platform_internalloop 帶project_id=__platform__legacy_awoooi_defaultloop 帶project_id=awoooi- 啟動順序 D3 被 FastAPI lifespan 強制執行
platform:ollama:topologykey 在啟動時正確初始化
關聯
- ADR-106(架構)、ADR-107(儲存)、ADR-110(GCP Ollama 拓撲)
- ADR-123(Background Loop Migration Strategy,配套 ADR)
- INV-3(Entrypoints)、INV-8(Background Loop Catalog)