# ADR-113: AwoooP Active Revision Invalidation & Outbox

**狀態**：Accepted
**日期**：2026-05-03（台北）
**決策者**：統帥
**範圍**：active revision 切換時的 worker cache 失效通知機制
**關聯**：ADR-107（D4 Redis cache）、ADR-112（activation workflow）

---

## 背景

ADR-107 D4 規定：
- Redis 可作為 contract revision 的 cache
- 但 Redis 不能是 source of truth
- 任何 cached value 必須帶 source `revision_id` 和 `body_hash`

問題：當 active revision 切換（activate 操作）時，正在運行的 worker 仍持有舊 revision 的 cache。
如果不通知，worker 可能繼續用舊 policy 執行，直到 TTL 過期（可能是數分鐘）。

這在 `policy_routing` contract 切換時特別危險：worker 可能用舊的 model routing policy 執行。

---

## 問題

1. activate 操作是 DB 事務，成功後如何通知所有 worker 失效 cache？
2. 如果 Redis pub/sub 通知丟失（Redis 重啟），worker 如何知道自己的 cache 過期了？
3. 跨 Pod 的多個 worker 如何收到通知？
4. 如何防止 split-brain（Worker A 用新 revision，Worker B 仍用舊 revision）？

---

## 決策

### D1 — Transactional Outbox 模式

activate 操作必須在**同一個 DB 事務**中：
1. 更新 `awooop_active_revisions`（切換 active pointer）
2. 在 `awooop_contract_outbox` 寫入一筆事件

```sql
-- 在同一個事務中
BEGIN;
  UPDATE awooop_active_revisions
  SET active_revision_id = 'new-revision-uuid',
      updated_at = NOW()
  WHERE project_id = 'awoooi'
    AND contract_family = 'policy_routing'
    AND contract_id = 'awoooi-sre';

  INSERT INTO awooop_contract_outbox
    (event_id, event_type, project_id, contract_family, contract_id,
     old_revision_id, new_revision_id, created_at, delivered_at)
  VALUES
    (gen_random_uuid(), 'revision_activated', 'awoooi', 'policy_routing', 'awoooi-sre',
     'old-revision-uuid', 'new-revision-uuid', NOW(), NULL);
COMMIT;
```

### D2 — Outbox Relay Worker

一個 outbox relay loop（每 1 秒掃描）：

```python
async def run_contract_outbox_relay():
    while True:
        # 取未送出的 outbox 事件（SELECT FOR UPDATE SKIP LOCKED）
        pending = await get_pending_outbox_events(limit=10)
        for event in pending:
            # 1. 發布 Redis pub/sub 通知
            await redis.publish(
                f"platform:contract:invalidate:{event.project_id}",
                json.dumps({
                    "contract_family": event.contract_family,
                    "contract_id": event.contract_id,
                    "new_revision_id": str(event.new_revision_id)
                })
            )
            # 2. 更新 outbox delivered_at
            await mark_outbox_delivered(event.event_id)
        await asyncio.sleep(1)
```

### D3 — Worker 端 Cache 設計

每個 worker 的 revision cache 必須：

1. **帶 revision_id**：`cache_value = (body_json, revision_id, body_hash, cached_at)`
2. **訂閱 Redis pub/sub**：每個 worker 訂閱 `platform:contract:invalidate:*`
3. **收到通知時**：比對 `revision_id`，若有差異立即清除 local cache
4. **Fallback（Redis 通知丟失時）**：每次使用 cache 前，**對比 DB active pointer 的 revision_id**
   - 若 cache 的 revision_id ≠ DB 的 active revision_id → 清除 cache，重新讀 DB
   - 比對頻率：低優先度 contract（channel_event）每 5min；高優先度（policy_routing）每 30s

### D4 — Split-Brain 防禦

Runtime 在每次 LLM call 前必須做一次 revision_id 比對：

```python
async def get_effective_policy(project_id: str, contract_id: str) -> EffectivePolicy:
    cached = await get_from_cache(project_id, contract_id)
    if cached:
        # 驗證 revision_id 仍是當前 active
        db_active_id = await get_active_revision_id_from_db(project_id, contract_id)
        if cached.revision_id != db_active_id:
            await invalidate_cache(project_id, contract_id)
            cached = None
    if not cached:
        revision = await get_active_revision_from_db(project_id, contract_id)
        cached = build_effective_policy(revision)
        await set_cache(project_id, contract_id, cached)
    return cached
```

### D5 — GCP Ollama 拓撲的 outbox

ADR-110 的 GCP Ollama topology 切換也走 outbox：
- `contract_family = 'platform_resource'`
- `contract_id = 'ollama_topology'`
- `project_id = '__platform__'`
- 所有 worker 收到通知後重置 Ollama client 連線

---

## awooop_contract_outbox 表設計

```sql
CREATE TABLE awooop_contract_outbox (
    event_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_type      VARCHAR(64) NOT NULL,   -- 'revision_activated', 'revision_revoked', etc.
    project_id      VARCHAR(64) NOT NULL,
    contract_family VARCHAR(32) NOT NULL,
    contract_id     VARCHAR(128) NOT NULL,
    old_revision_id UUID,
    new_revision_id UUID NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    delivered_at    TIMESTAMPTZ,            -- NULL = 未送出
    relay_attempts  INT NOT NULL DEFAULT 0,
    CONSTRAINT fk_new_revision
        FOREIGN KEY (new_revision_id)
        REFERENCES awooop_contract_revisions(revision_id)
);

-- Index for relay worker
CREATE INDEX idx_outbox_undelivered ON awooop_contract_outbox(created_at)
    WHERE delivered_at IS NULL;
```

---

## 後果

### Benefits
- 事務保證：activate 操作失敗時，outbox 也不會寫入（不會有假通知）
- Redis pub/sub 丟失時，fallback 比對機制保底
- Split-brain 防禦：每次 LLM call 前比對 revision_id

### Costs
- 需要額外的 outbox relay loop（31 個 loop 再加一個）
- 每次 LLM call 前有一次 DB read（可用短 TTL cache 降低頻率）

### Risks
- outbox relay worker 掛掉時，Redis 通知延遲（但 fallback 比對仍有效）
- 緩解：outbox relay loop 加入 `run_ai_slo_watchdog_loop()` 的監控
- DB active pointer 讀取在高 QPS 下可能成為瓶頸
- 緩解：Redis 短 TTL cache（30s）+ 只在 TTL 過期時才查 DB

---

## 驗收標準

- [ ] `awooop_contract_outbox` 表建立（Phase 1 migration）
- [ ] outbox relay loop 加入 main.py（標記 `platform_internal`）
- [ ] worker cache 的 split-brain 防禦測試：activate 後 ≤60s 所有 worker 使用新 revision
- [ ] GCP Ollama topology 切換走 outbox（ADR-110 整合）
- [ ] Redis pub/sub 失效模擬測試通過（worker 靠 fallback 比對恢復）

## 關聯

- ADR-107（Redis cache 規範）、ADR-112（activation workflow）
- ADR-110（GCP Ollama topology，特殊 platform_resource outbox 事件）
