# AwoooP Phase 1 — Isolation Foundation Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 在不改變任何業務行為的前提下，為整個 AWOOOI codebase 補上 `project_id` 隔離地基——Redis key namespace、DB table project_id 欄位、session_id 格式標準化、agent_loader 路徑可配置、統一 ACL 與 Budget 資料模型。

**Architecture:** 純粹的防火隔離重構。所有改動都保留現有行為（`project_id=awoooi` 為預設值），不改任何 AI 推理路徑、不改任何 Telegram 發送邏輯、不新增 API endpoint。改完後 AWOOOI 的 Redis 不再會與未來的 EwoooC 碰撞，DB audit table 的 project_id 為後續 Control Plane 打好欄位基礎。

**Tech Stack:** Python/FastAPI, PostgreSQL (SQLAlchemy + Alembic), Redis (aioredis), pytest, Pydantic Settings

**Reference:** `docs/adr/ADR-106-agent-platform-architecture.md` (D3, Phase 1)

---

## File Map（所有將建立或修改的檔案）

**修改：**
- `apps/api/src/services/telegram_gateway.py` — 3 個 Redis key 常數加 `awoooi:` 前綴
- `apps/api/src/hermes/nl_gateway.py` — 2 個 Redis key 模式加 `awoooi:` 前綴
- `apps/api/src/services/ai_rate_limiter.py` — 5 個 Redis key 模式加 `awoooi:` 前綴
- `apps/api/src/services/ollama_failover_manager.py` — `ollama:*` key 改為 `global:ollama:*`
- `apps/api/src/hermes/agent_loader.py` — 移除硬碼路徑，改讀 env var
- `apps/api/src/core/config.py` — 新增 `HERMES_AGENTS_DIR`、`PLATFORM_PROJECT_ID` 設定

**新建 Migration：**
- `apps/api/migrations/adr106_p1_project_isolation.sql` — 4 張 table 加 project_id 欄位 + backfill + 建立 2 張新 table

**新建 Model：**
- `apps/api/src/db/models.py` — 新增 `PlatformUserAccess`、`PlatformBudget` 兩個 ORM model

**新建 Tests：**
- `apps/api/tests/test_p1_redis_namespace.py` — Redis key namespace 單元測試
- `apps/api/tests/test_p1_agent_loader.py` — agent_loader env var 測試
- `apps/api/tests/test_p1_project_isolation.py` — DB migration + project_id 隔離整合測試

---

### Task 1：為 AWOOOI 定義 `PLATFORM_PROJECT_ID` 常數

**Files:**
- Modify: `apps/api/src/core/config.py`

- [ ] **Step 1: 找到 config.py 裡 SYSTEM_NAME 所在行**

```bash
grep -n "SYSTEM_NAME" apps/api/src/core/config.py
```
Expected: 顯示行號（約 L44）

- [ ] **Step 2: 在 SYSTEM_NAME 下方新增兩個欄位**

在 `SYSTEM_NAME: str = "awoooi"` 後加入：

```python
PLATFORM_PROJECT_ID: str = Field(
    default="awoooi",
    description="ADR-106 P1: 本服務的 project_id，用於 Redis key namespace 和 DB 隔離",
)
HERMES_AGENTS_DIR: str = Field(
    default="/app/agents",
    description="ADR-106 P1: Hermes agent_loader 載入 .md 的目錄，取代硬碼的 /Users/ogt/awoooi/.claude/agents",
)
```

- [ ] **Step 3: 確認語法正確**

```bash
cd apps/api && python3 -m py_compile src/core/config.py && echo "OK"
```
Expected: `OK`

- [ ] **Step 4: Commit**

```bash
git add apps/api/src/core/config.py
git commit -m "feat(platform): add PLATFORM_PROJECT_ID and HERMES_AGENTS_DIR to config"
```

---

### Task 2：修 agent_loader.py 硬碼路徑

**Files:**
- Modify: `apps/api/src/hermes/agent_loader.py:9`

- [ ] **Step 1: 寫失敗測試**

建立 `apps/api/tests/test_p1_agent_loader.py`：

```python
import os
import pathlib
import pytest
from unittest.mock import patch

def test_agent_loader_respects_env_var(tmp_path):
    """agent_loader 應從 HERMES_AGENTS_DIR 而非硬碼路徑讀取 agent md"""
    # 建立假 agent md
    (tmp_path / "debugger.md").write_text("---\nname: debugger\n---\nYou are a debugger.")

    with patch.dict(os.environ, {"HERMES_AGENTS_DIR": str(tmp_path)}):
        # 清除 lru_cache 讓 import 重新讀 env
        import importlib
        import src.hermes.agent_loader as loader
        importlib.reload(loader)
        result = loader.get_agent_system_prompt("debugger")

    assert result is not None
    assert "debugger" in result.lower()

def test_agent_loader_returns_none_for_missing(tmp_path):
    """找不到的 agent 應回傳 None"""
    with patch.dict(os.environ, {"HERMES_AGENTS_DIR": str(tmp_path)}):
        import importlib
        import src.hermes.agent_loader as loader
        importlib.reload(loader)
        result = loader.get_agent_system_prompt("nonexistent-agent")

    assert result is None
```

- [ ] **Step 2: 執行確認測試失敗**

```bash
cd apps/api && pytest tests/test_p1_agent_loader.py -v
```
Expected: FAIL（因為目前 agent_loader 還是用硬碼路徑）

- [ ] **Step 3: 修改 agent_loader.py**

將 `apps/api/src/hermes/agent_loader.py` 改為：

```python
"""載入 agents/*.md 並解析 system prompt（ADR-095, ADR-106 P1）

ADR-106 P1: 移除硬碼路徑 /Users/ogt/awoooi/.claude/agents，改從
HERMES_AGENTS_DIR env var 讀取，預設 /app/agents。
"""
from __future__ import annotations
import os
import pathlib
from functools import lru_cache

def _get_agents_dir() -> pathlib.Path:
    return pathlib.Path(os.environ.get("HERMES_AGENTS_DIR", "/app/agents"))

def _parse_agent_md(path: pathlib.Path) -> str:
    """去除 YAML frontmatter，回傳 body 作為 system prompt"""
    text = path.read_text(encoding="utf-8")
    if text.startswith("---"):
        end = text.find("---", 3)
        if end != -1:
            return text[end + 3:].strip()
    return text.strip()

@lru_cache(maxsize=None)
def get_agent_system_prompt(agent_name: str) -> str | None:
    path = _get_agents_dir() / f"{agent_name}.md"
    if not path.exists():
        return None
    return _parse_agent_md(path)

def list_available_agents() -> list[str]:
    return [p.stem for p in sorted(_get_agents_dir().glob("*.md"))]
```

- [ ] **Step 4: 執行確認測試通過**

```bash
cd apps/api && pytest tests/test_p1_agent_loader.py -v
```
Expected: 2 passed

- [ ] **Step 5: Commit**

```bash
git add apps/api/src/hermes/agent_loader.py apps/api/tests/test_p1_agent_loader.py
git commit -m "fix(hermes): replace hardcoded agent_loader path with HERMES_AGENTS_DIR env var (ADR-106 P1)"
```

---

### Task 3：Redis key namespace — telegram_gateway.py

**Files:**
- Modify: `apps/api/src/services/telegram_gateway.py`

- [ ] **Step 1: 找到現有 key 常數位置**

```bash
grep -n "telegram_snooze\|telegram_silence\|polling:leader" apps/api/src/services/telegram_gateway.py
```
Expected: 顯示 3 個常數行號

- [ ] **Step 2: 寫失敗測試**

建立 `apps/api/tests/test_p1_redis_namespace.py`：

```python
"""ADR-106 P1: 所有 Redis key 必須帶 project_id 前綴"""
from src.services.telegram_gateway import (
    SNOOZE_KEY_PREFIX,
    SILENCE_KEY_PREFIX,
    POLLING_LEADER_KEY,
)

def test_snooze_key_has_project_prefix():
    assert SNOOZE_KEY_PREFIX.startswith("awoooi:"), (
        f"SNOOZE_KEY_PREFIX '{SNOOZE_KEY_PREFIX}' 缺少 awoooi: 前綴"
    )

def test_silence_key_has_project_prefix():
    assert SILENCE_KEY_PREFIX.startswith("awoooi:"), (
        f"SILENCE_KEY_PREFIX '{SILENCE_KEY_PREFIX}' 缺少 awoooi: 前綴"
    )

def test_polling_leader_key_has_project_prefix():
    assert POLLING_LEADER_KEY.startswith("awoooi:"), (
        f"POLLING_LEADER_KEY '{POLLING_LEADER_KEY}' 缺少 awoooi: 前綴"
    )
```

- [ ] **Step 3: 確認測試失敗**

```bash
cd apps/api && pytest tests/test_p1_redis_namespace.py -v
```
Expected: 3 FAILED

- [ ] **Step 4: 修改 telegram_gateway.py 的 key 常數**

找到並更新三個常數：

```python
# ADR-106 P1: 所有 key 加 awoooi: 前綴防止多專案碰撞
SNOOZE_KEY_PREFIX = "awoooi:telegram_snooze:"    # 原: "telegram_snooze:"
SILENCE_KEY_PREFIX = "awoooi:telegram_silence:"  # 原: "telegram_silence:"
SNOOZE_TTL_SECONDS = 30 * 60
SILENCE_TTL_SECONDS = 60 * 60

POLLING_LEADER_KEY = "awoooi:telegram:polling:leader"  # 原: "telegram:polling:leader"
POLLING_LEADER_TTL = 45
POLLING_LEADER_RENEW = 20
POLLING_LEADER_WATCH = 30
```

- [ ] **Step 5: 確認測試通過**

```bash
cd apps/api && pytest tests/test_p1_redis_namespace.py -v
```
Expected: 3 passed

- [ ] **Step 6: 確認語法**

```bash
cd apps/api && python3 -m py_compile src/services/telegram_gateway.py && echo "OK"
```

- [ ] **Step 7: Commit**

```bash
git add apps/api/src/services/telegram_gateway.py apps/api/tests/test_p1_redis_namespace.py
git commit -m "fix(telegram): add awoooi: project prefix to all Redis keys (ADR-106 P1)"
```

---

### Task 4：Redis key namespace — hermes/nl_gateway.py

**Files:**
- Modify: `apps/api/src/hermes/nl_gateway.py`

- [ ] **Step 1: 找到現有 key 格式字串**

```bash
grep -n "hermes:rl\|hermes:session" apps/api/src/hermes/nl_gateway.py
```
Expected: 顯示 2-4 行，是 f-string 格式

- [ ] **Step 2: 在 test_p1_redis_namespace.py 中補充 hermes 測試**

在 `apps/api/tests/test_p1_redis_namespace.py` 末尾追加：

```python
def test_hermes_rate_limit_key_format():
    """驗證 hermes rate limit key 格式包含 awoooi: 前綴"""
    import inspect
    import src.hermes.nl_gateway as nl
    source = inspect.getsource(nl)
    assert "awoooi:hermes:rl:" in source, "hermes rate limit key 缺少 awoooi: 前綴"

def test_hermes_session_key_format():
    import inspect
    import src.hermes.nl_gateway as nl
    source = inspect.getsource(nl)
    assert "awoooi:hermes:session:" in source, "hermes session key 缺少 awoooi: 前綴"
```

- [ ] **Step 3: 確認測試失敗**

```bash
cd apps/api && pytest tests/test_p1_redis_namespace.py::test_hermes_rate_limit_key_format tests/test_p1_redis_namespace.py::test_hermes_session_key_format -v
```
Expected: 2 FAILED

- [ ] **Step 4: 更新 nl_gateway.py 的 key 格式字串**

找到並更新所有 `hermes:rl:` 和 `hermes:session:` 出現處：

```python
# 原: f"hermes:rl:{chat_id}"
# 改: f"awoooi:hermes:rl:{chat_id}"

# 原: f"hermes:session:{chat_id}:{user_id}"
# 改: f"awoooi:hermes:session:{chat_id}:{user_id}"
```

- [ ] **Step 5: 語法確認**

```bash
cd apps/api && python3 -m py_compile src/hermes/nl_gateway.py && echo "OK"
```

- [ ] **Step 6: 確認測試通過**

```bash
cd apps/api && pytest tests/test_p1_redis_namespace.py -v
```
Expected: 全部 passed（5 個）

- [ ] **Step 7: Commit**

```bash
git add apps/api/src/hermes/nl_gateway.py apps/api/tests/test_p1_redis_namespace.py
git commit -m "fix(hermes): add awoooi: project prefix to nl_gateway Redis keys (ADR-106 P1)"
```

---

### Task 5：Redis key namespace — ai_rate_limiter.py 和 ollama_failover_manager.py

**Files:**
- Modify: `apps/api/src/services/ai_rate_limiter.py`
- Modify: `apps/api/src/services/ollama_failover_manager.py`

- [ ] **Step 1: 找出 ai_rate_limiter 所有 Redis key 格式**

```bash
grep -n "ai_rate:" apps/api/src/services/ai_rate_limiter.py
```
Expected: 顯示 5 個 key 模式（rpm, daily_req, daily_token, total_cost, gemini_count）

- [ ] **Step 2: 在測試檔案補充 ai_rate_limiter 驗證**

在 `apps/api/tests/test_p1_redis_namespace.py` 末尾追加：

```python
def test_ai_rate_keys_have_project_prefix():
    """ai_rate_limiter 的 Redis key 要有 awoooi: 前綴"""
    import inspect
    import src.services.ai_rate_limiter as arl
    source = inspect.getsource(arl)
    assert "awoooi:ai_rate:" in source, "ai_rate key 缺少 awoooi: 前綴"

def test_ollama_failover_key_is_global():
    """Ollama failover 是 platform-global 資源，用 global: 前綴而非 awoooi:"""
    import inspect
    import src.services.ollama_failover_manager as ofm
    source = inspect.getsource(ofm)
    assert "global:ollama:" in source, "ollama failover key 應使用 global: 前綴"
    assert '"ollama:current_primary"' not in source, "不應有裸的 ollama:current_primary key"
```

- [ ] **Step 3: 確認測試失敗**

```bash
cd apps/api && pytest tests/test_p1_redis_namespace.py::test_ai_rate_keys_have_project_prefix tests/test_p1_redis_namespace.py::test_ollama_failover_key_is_global -v
```
Expected: 2 FAILED

- [ ] **Step 4: 更新 ai_rate_limiter.py 的 key 格式**

找到並替換所有 `ai_rate:` 開頭的 key 加上 `awoooi:` 前綴：

```python
# 原: f"ai_rate:rpm:{provider}"
# 改: f"awoooi:ai_rate:rpm:{provider}"

# 原: f"ai_rate:daily_req:{provider}:{date}"
# 改: f"awoooi:ai_rate:daily_req:{provider}:{date}"

# 原: f"ai_rate:daily_token:{provider}:{date}"
# 改: f"awoooi:ai_rate:daily_token:{provider}:{date}"

# 原: f"ai_rate:total_cost:{provider}"
# 改: f"awoooi:ai_rate:total_cost:{provider}"
```

- [ ] **Step 5: 找到並更新 ollama_failover_manager.py 的 key**

```bash
grep -n "ollama:current_primary\|ollama:" apps/api/src/services/ollama_failover_manager.py
```

將 `ollama:current_primary` 改為 `global:ollama:current_primary`（platform global resource）

- [ ] **Step 6: 語法確認**

```bash
cd apps/api && python3 -m py_compile src/services/ai_rate_limiter.py src/services/ollama_failover_manager.py && echo "OK"
```

- [ ] **Step 7: 執行所有 namespace 測試**

```bash
cd apps/api && pytest tests/test_p1_redis_namespace.py -v
```
Expected: 全部 passed（9 個）

- [ ] **Step 8: Commit**

```bash
git add apps/api/src/services/ai_rate_limiter.py apps/api/src/services/ollama_failover_manager.py apps/api/tests/test_p1_redis_namespace.py
git commit -m "fix(ai): add project/global prefix to ai_rate_limiter and ollama_failover Redis keys (ADR-106 P1)"
```

---

### Task 6：DB migration — 四張 table 加 project_id，兩張新 table

**Files:**
- Create: `apps/api/migrations/adr106_p1_project_isolation.sql`
- Modify: `apps/api/src/db/models.py`

- [ ] **Step 1: 建立 migration SQL**

建立 `apps/api/migrations/adr106_p1_project_isolation.sql`：

```sql
-- ADR-106 Phase 1: Project Isolation Foundation
-- 2026-05-01 ogt + Claude Sonnet 4.6
-- 操作：4 張既有 table 加 project_id 欄位 + 2 張新 table

BEGIN;

-- ── 1. hermes_dispatch_log 加 project_id ──────────────────────────────────
ALTER TABLE hermes_dispatch_log
  ADD COLUMN IF NOT EXISTS project_id VARCHAR(50) NOT NULL DEFAULT 'awoooi';

CREATE INDEX IF NOT EXISTS idx_hermes_dispatch_project_id
  ON hermes_dispatch_log (project_id);

-- ── 2. mcp_audit_snapshots 加 project_id ─────────────────────────────────
ALTER TABLE mcp_audit_snapshots
  ADD COLUMN IF NOT EXISTS project_id VARCHAR(50) NOT NULL DEFAULT 'awoooi';

CREATE INDEX IF NOT EXISTS idx_mcp_audit_project_id
  ON mcp_audit_snapshots (project_id);

-- ── 3. approval_records 加 project_id ────────────────────────────────────
ALTER TABLE approval_records
  ADD COLUMN IF NOT EXISTS project_id VARCHAR(50) NOT NULL DEFAULT 'awoooi';

CREATE INDEX IF NOT EXISTS idx_approval_records_project_id
  ON approval_records (project_id);

-- ── 4. incident_records 加 project_id（如果存在）─────────────────────────
ALTER TABLE incident_records
  ADD COLUMN IF NOT EXISTS project_id VARCHAR(50) NOT NULL DEFAULT 'awoooi';

CREATE INDEX IF NOT EXISTS idx_incident_records_project_id
  ON incident_records (project_id);

-- ── 5. 新建 platform_user_access ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS platform_user_access (
  id            BIGSERIAL PRIMARY KEY,
  user_id       VARCHAR(100) NOT NULL,       -- e.g. "telegram:123456789"
  platform      VARCHAR(20)  NOT NULL,       -- telegram | line | slack | api
  project_id    VARCHAR(50)  NOT NULL,
  role          VARCHAR(20)  NOT NULL DEFAULT 'operator', -- admin | operator | viewer
  granted_by    VARCHAR(100),
  granted_at    TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
  revoked_at    TIMESTAMPTZ,
  UNIQUE (user_id, platform, project_id)
);

CREATE INDEX IF NOT EXISTS idx_pua_project_user
  ON platform_user_access (project_id, user_id, platform)
  WHERE revoked_at IS NULL;

-- ── 6. 新建 platform_budget ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS platform_budget (
  id                     BIGSERIAL PRIMARY KEY,
  project_id             VARCHAR(50)    NOT NULL,
  provider               VARCHAR(50)    NOT NULL,  -- gemini | claude | openrouter | ollama
  billing_type           VARCHAR(20)    NOT NULL DEFAULT 'metered', -- metered | local_fixed_cost
  monthly_limit_usd      NUMERIC(10,4)  NOT NULL DEFAULT 0,
  alert_threshold_usd    NUMERIC(10,4)  NOT NULL DEFAULT 0,
  current_month_usd      NUMERIC(10,4)  NOT NULL DEFAULT 0,
  period_start           DATE           NOT NULL DEFAULT DATE_TRUNC('month', NOW()),
  hard_stopped           BOOLEAN        NOT NULL DEFAULT FALSE,
  updated_at             TIMESTAMPTZ    NOT NULL DEFAULT NOW(),
  UNIQUE (project_id, provider, period_start)
);

-- 初始化 AWOOOI 現有費用上限（對應 ai_rate_limiter.py 裡的 $5/$10 硬停）
INSERT INTO platform_budget (project_id, provider, billing_type, monthly_limit_usd, alert_threshold_usd)
VALUES
  ('awoooi', 'gemini',     'metered',           5.0,  4.0),
  ('awoooi', 'claude',     'metered',           10.0, 8.0),
  ('awoooi', 'openrouter', 'metered',           10.0, 8.0),
  ('awoooi', 'ollama',     'local_fixed_cost',  0.0,  0.0)
ON CONFLICT DO NOTHING;

COMMIT;
```

- [ ] **Step 2: 確認 models.py 有需要的 import**

```bash
grep -n "^from\|^import" apps/api/src/db/models.py | grep -E "BigInteger|Numeric|Date|Boolean|UniqueConstraint|Index|text|func" | head -10
```

如果缺少任何 import，先補充到 models.py 的 import 區塊。

- [ ] **Step 3: 在 models.py 尾端新增 ORM models**

```python
# ADR-106 P1: Platform Isolation Models
# 2026-05-01 ogt + Claude Sonnet 4.6

class PlatformUserAccess(Base):
    """統一跨專案使用者存取控制 — 取代各專案分散的 whitelist"""
    __tablename__ = "platform_user_access"

    id         = Column(BigInteger, primary_key=True, autoincrement=True)
    user_id    = Column(String(100), nullable=False)
    platform   = Column(String(20), nullable=False)
    project_id = Column(String(50), nullable=False)
    role       = Column(String(20), nullable=False, default="operator")
    granted_by = Column(String(100))
    granted_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
    revoked_at = Column(DateTime(timezone=True))

    __table_args__ = (
        UniqueConstraint("user_id", "platform", "project_id", name="uq_pua_user_platform_project"),
        Index("idx_pua_project_user", "project_id", "user_id", "platform",
              postgresql_where=text("revoked_at IS NULL")),
    )

class PlatformBudget(Base):
    """統一跨專案 AI 費用預算與上限"""
    __tablename__ = "platform_budget"

    id                  = Column(BigInteger, primary_key=True, autoincrement=True)
    project_id          = Column(String(50), nullable=False)
    provider            = Column(String(50), nullable=False)
    billing_type        = Column(String(20), nullable=False, default="metered")
    monthly_limit_usd   = Column(Numeric(10, 4), nullable=False, default=0)
    alert_threshold_usd = Column(Numeric(10, 4), nullable=False, default=0)
    current_month_usd   = Column(Numeric(10, 4), nullable=False, default=0)
    period_start        = Column(Date, nullable=False)
    hard_stopped        = Column(Boolean, nullable=False, default=False)
    updated_at          = Column(DateTime(timezone=True), nullable=False, server_default=func.now())

    __table_args__ = (
        UniqueConstraint("project_id", "provider", "period_start", name="uq_budget_project_provider_period"),
    )
```

- [ ] **Step 4: 語法確認**

```bash
cd apps/api && python3 -m py_compile src/db/models.py && echo "OK"
```

- [ ] **Step 5: Commit**

```bash
git add apps/api/migrations/adr106_p1_project_isolation.sql apps/api/src/db/models.py
git commit -m "feat(db): add project_id to audit tables and create platform_user_access/platform_budget (ADR-106 P1)"
```

---

### Task 7：執行 migration（Dev 環境）

**Files:**
- 無新檔案，執行已建立的 migration

- [ ] **Step 1: 確認 dev DB 連線設定**

```bash
grep -n "DATABASE_URL\|POSTGRES" apps/api/.env 2>/dev/null | head -5
```

- [ ] **Step 2: 執行 migration**

```bash
# 替換 $DATABASE_URL 為實際連線字串（從 .env 或 config 取得）
psql $DATABASE_URL -f apps/api/migrations/adr106_p1_project_isolation.sql
```
Expected: 輸出 `ALTER TABLE` × 4、`CREATE INDEX` × 4、`CREATE TABLE` × 2、`INSERT 0 4`，最後 `COMMIT`

- [ ] **Step 3: 驗證欄位存在**

```bash
psql $DATABASE_URL -c "\d hermes_dispatch_log" | grep project_id
psql $DATABASE_URL -c "\d platform_budget" | grep -E "project_id|billing_type"
```
Expected: 顯示 project_id 欄位

- [ ] **Step 4: Commit 確認**

```bash
git commit --allow-empty -m "chore(db): run adr106_p1 migration on dev (project isolation foundation)"
```

---

### Task 8：整合測試 — 確認 project_id 隔離完整性

**Files:**
- Create: `apps/api/tests/test_p1_project_isolation.py`

- [ ] **Step 1: 建立整合測試**

建立 `apps/api/tests/test_p1_project_isolation.py`：

```python
"""ADR-106 P1 整合測試：project_id 隔離完整性驗證"""
import inspect
import pytest


class TestRedisNamespaceIsolation:
    """驗證所有關鍵 Redis key 都有正確的 project_id 前綴"""

    def test_telegram_keys_have_awoooi_prefix(self):
        from src.services.telegram_gateway import (
            SNOOZE_KEY_PREFIX, SILENCE_KEY_PREFIX, POLLING_LEADER_KEY
        )
        assert SNOOZE_KEY_PREFIX == "awoooi:telegram_snooze:"
        assert SILENCE_KEY_PREFIX == "awoooi:telegram_silence:"
        assert POLLING_LEADER_KEY == "awoooi:telegram:polling:leader"

    def test_ollama_failover_key_is_global(self):
        """Ollama failover 是 platform-global 資源，不屬於單一 project"""
        import src.services.ollama_failover_manager as ofm
        source = inspect.getsource(ofm)
        assert "global:ollama:" in source, "ollama failover key 應使用 global: 前綴"
        assert '"ollama:current_primary"' not in source

    def test_hermes_keys_have_awoooi_prefix(self):
        import src.hermes.nl_gateway as nl
        source = inspect.getsource(nl)
        assert "awoooi:hermes:rl:" in source
        assert "awoooi:hermes:session:" in source

    def test_ai_rate_keys_have_awoooi_prefix(self):
        import src.services.ai_rate_limiter as arl
        source = inspect.getsource(arl)
        assert "awoooi:ai_rate:" in source


class TestAgentLoaderConfig:
    """驗證 agent_loader 使用可配置路徑"""

    def test_default_path_is_not_hardcoded_to_user_home(self):
        import src.hermes.agent_loader as loader
        source = inspect.getsource(loader)
        assert "/Users/ogt" not in source, "agent_loader 不應硬碼本機路徑"

    def test_uses_env_var(self):
        import src.hermes.agent_loader as loader
        source = inspect.getsource(loader)
        assert "HERMES_AGENTS_DIR" in source


class TestDBModels:
    """驗證 ORM 模型存在且有正確欄位"""

    def test_platform_user_access_model_exists(self):
        from src.db.models import PlatformUserAccess
        columns = {c.name for c in PlatformUserAccess.__table__.columns}
        assert "project_id" in columns
        assert "user_id" in columns
        assert "platform" in columns
        assert "role" in columns
        assert "revoked_at" in columns

    def test_platform_budget_model_exists(self):
        from src.db.models import PlatformBudget
        columns = {c.name for c in PlatformBudget.__table__.columns}
        assert "project_id" in columns
        assert "provider" in columns
        assert "billing_type" in columns
        assert "monthly_limit_usd" in columns
        assert "hard_stopped" in columns
```

- [ ] **Step 2: 執行整合測試**

```bash
cd apps/api && pytest tests/test_p1_project_isolation.py -v
```
Expected: 全部 passed（9 個）

- [ ] **Step 3: 執行全部 P1 測試確認無回歸**

```bash
cd apps/api && pytest tests/test_p1_redis_namespace.py tests/test_p1_agent_loader.py tests/test_p1_project_isolation.py -v
```
Expected: 全部 passed（共約 20 個測試）

- [ ] **Step 4: 確認現有測試無回歸**

```bash
cd apps/api && pytest tests/ -q --ignore=tests/test_p1_redis_namespace.py --ignore=tests/test_p1_agent_loader.py --ignore=tests/test_p1_project_isolation.py 2>&1 | tail -5
```
Expected: 通過率與改前相同，無新增 FAILED

- [ ] **Step 5: Final commit**

```bash
git add apps/api/tests/test_p1_project_isolation.py
git commit -m "test(platform): add P1 isolation integration tests (ADR-106 P1)"
```

---

### Task 9：設定 K8s ConfigMap/Secret（Prod 準備）

**Files:**
- Confirm / Modify: `apps/api/deploy/` 或 K8s manifest

- [ ] **Step 1: 確認 prod K8s deployment 有正確的 env var**

```bash
kubectl get deployment awoooi-api -n awoooi-prod -o jsonpath='{.spec.template.spec.containers[0].env}' 2>/dev/null | python3 -m json.tool | grep -A2 "HERMES\|PLATFORM_PROJECT" || echo "需要新增 env var"
```

- [ ] **Step 2: 找到 ConfigMap manifest**

```bash
find apps/api/deploy -name "*.yaml" -o -name "*.yml" 2>/dev/null | head -10
# 或
find . -path "*/k8s/*" -name "configmap*" 2>/dev/null | head -5
```

- [ ] **Step 3: 在 ConfigMap 新增兩個 env var**

```yaml
PLATFORM_PROJECT_ID: "awoooi"
HERMES_AGENTS_DIR: "/app/agents"
```

- [ ] **Step 4: 確認 /app/agents 目錄在 container 內存在**

```bash
grep -rn "agents\|COPY.*agent" apps/api/Dockerfile 2>/dev/null | head -5
```

如果 agents 目錄沒有被複製進 container，在 Dockerfile 加上：
```dockerfile
COPY .claude/agents /app/agents
```

- [ ] **Step 5: Commit Prod 設定**

```bash
git add apps/api/deploy/ apps/api/Dockerfile
git commit -m "chore(k8s): add PLATFORM_PROJECT_ID and HERMES_AGENTS_DIR to deployment config (ADR-106 P1)"
```

---

## Self-Review

**Spec coverage check（對照 ADR-106 Phase 1 要求）：**

| ADR-106 Phase 1 要求 | 計畫中的 Task |
|---------------------|-------------|
| Redis keys 加 project_id | Task 3（telegram）、Task 4（hermes）、Task 5（ai_rate + ollama） |
| platform_resource 例外（global:） | Task 5（ollama key 改 global:）|
| AWOOOI 行為保留在 project_id=awoooi | 全部 default='awoooi' |
| hermes_dispatch_log 加 project_id | Task 6（migration SQL）|
| mcp_audit_snapshots 加 project_id | Task 6（migration SQL）|
| approval_records 加 project_id | Task 6（migration SQL）|
| 建立統一 ACL 資料模型 | Task 6（platform_user_access table）|
| 建立統一 Budget 資料模型 | Task 6（platform_budget table）|
| agent_loader 硬碼路徑修復 | Task 2 |
| HERMES_AGENTS_DIR env var | Task 1 + Task 2 |
| K8s prod 設定 | Task 9 |

**注意：** `IncidentRecord` ORM model 已有 project_id 欄位由 Task 6 migration SQL 新增，但 SQLAlchemy model 需人工確認後補 PR（避免破壞既有查詢）。
