# ADR-108: Incident ID 改為 fingerprint 派生（P1 徹底長期修）

| | |
|---|---|
| Status | Proposed (等首席架構師審) |
| Date | 2026-05-03 |
| Author | Claude Opus 4.7 + 統帥 ogt |
| Tier | 🔴 **Tier 3 紅區**（incident model + 1435 個引用點）|
| Related | ADR-073 (alert_analyzer fingerprint), ADR-068 (飛輪五根因), `feedback_telegram_dedup.md` |

## 1. 問題

### 鐵證
2026-05-02 統帥 Telegram 收到同症狀重複告警 24h 內 15+ 次（INC-20260501-6FE3BD HostBackupFailed）。
4 個 agent 真實數據查證，**根因之一**：

```python
# apps/api/src/models/incident.py:362-364
incident_id: str = Field(
    default_factory=lambda: f"INC-{datetime.now(timezone.utc).strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}",
    description="事件唯一識別碼",
)
```

`incident_id` 用 `uuid4()[:6]` **隨機**派生，導致：

1. 同 alertname + 同 target 過 180s debounce 就生**全新 INC ID**
2. 所有以 `incident_id` 為 key 的 dedup 邏輯（`telegram_sent:{incident_id}` / `auto_repair:emergency_escalated:{incident_id}` / `decision:DEC-{HEX}` 等）**永遠 miss**
3. UI 顯示同問題不同 INC ID，難以追蹤
4. KM 學習無法把同問題的多次發生視為同一個案例

### 已做的繞道修法（治標）
- `b3a0f0d7`：decision card dedup key 改 `telegram_sent:fp:{alertname}:{target}` 繞過 incident_id
- `47342dfb`：escalation card dedup 同樣改

但這是**繞道** — 系統內仍有 1435 個 incident_id 引用點，未來任何新增 dedup / 對應 / 追蹤邏輯只要使用 incident_id，就會再次踩同樣的雷。

## 2. 替代方案

### A. 維持 uuid4，所有 dedup 都改用 fingerprint（已做的繞道方向）
- ✅ 改動小（單檔）
- ❌ 治標不治本，dedup 散在各處，未來必再漏
- ❌ UI 同問題仍多 INC ID

### B. 改 incident_id 用 fingerprint hash 派生（**推薦**）
- ✅ 治本：incident_id 本身就帶語意（同症狀同 ID）
- ✅ 所有 incident_id-based 邏輯自動正確
- ✅ UI 同問題同 INC ID，可追蹤
- ❌ Tier 3 紅區，影響面大（1435 引用點，但 95% 是 string pass-through 不需改）
- ❌ 跨日同 fingerprint 怎麼算（用 date prefix 還是省略？）

### C. 引入 `incident_uuid` 與 `incident_fingerprint` 雙欄位
- ✅ 向後相容
- ❌ 雙欄位語意混淆，使用者不知該用哪個
- ❌ 仍是治標

**選 B**。

## 3. 推薦方案：B 詳細設計

### 3.1 新 incident_id 格式

```python
# apps/api/src/models/incident.py:362
def _derive_incident_id(fingerprint: str | None = None) -> str:
    """
    fingerprint 給的話 → INC-{YYYYMMDD}-{fingerprint[:6].upper()}
    fingerprint None → INC-{YYYYMMDD}-{uuid4()[:6].upper()}（保留向後相容）
    """
    date = datetime.now(timezone.utc).strftime('%Y%m%d')
    if fingerprint:
        suffix = fingerprint[:6].upper()
    else:
        suffix = str(uuid4())[:6].upper()
    return f"INC-{date}-{suffix}"
```

### 3.2 Open 期間強制 reuse

```python
# apps/api/src/services/incident_service.py
async def get_or_create_incident_by_fingerprint(
    self, fingerprint: str, ...
) -> Incident:
    """同 fingerprint 在 OPEN 狀態（INVESTIGATING/PENDING）→ 復用，否則新建"""
    existing = await self._repo.find_open_by_fingerprint(fingerprint)
    if existing:
        return existing  # reuse 同一 INC ID，新 signal 加進去
    return self._build_new_incident(fingerprint=fingerprint, ...)
```

### 3.3 跨日決策

| 情境 | 行為 |
|------|------|
| 同 fingerprint，前次已 RESOLVED | 新建 INC（新 ID 含新日期）|
| 同 fingerprint，前次仍 INVESTIGATING/PENDING | 復用同 INC ID（不換日期）|
| 同 fingerprint，前次跨日 INVESTIGATING > 24h | 復用（不該換 ID 造成 dedup 混亂）|

**rationale**：fingerprint 代表「同症狀」，open 期間應該是同一事故。日期 prefix 只在新建時用，復用時保留原日期。

### 3.4 與既有 1435 引用點的相容性

絕大多數引用是 `incident_id` 當 string 傳遞（log / DB FK / Redis key 構造），**不需改**。

需要審視的引用模式：
1. **直接 hardcode 比對**：`if incident_id == "INC-20260501-6FE3BD":` — grep 確認無
2. **uuid 格式假設**：解析 `incident_id` 拆 date / suffix — 需 audit
3. **長度假設**：固定切片 / hash — 需 audit

預期實際需改動：< 5 處。

## 4. 影響面（真實數據盤點）

| 檔案 | 引用次數 | 預計需改 |
|------|---------|---------|
| `services/telegram_gateway.py` | 139 | 0（純 string pass）|
| `services/decision_manager.py` | 133 | 0（已用 fingerprint dedup）|
| `api/v1/webhooks.py` | 64 | **2-3** (建立 incident 邏輯)|
| `services/incident_service.py` | 60 | **5-10** (新增 get_or_create_by_fingerprint) |
| `services/learning_service.py` | 58 | 0 |
| `services/openclaw.py` | 52 | 0 |
| `api/v1/incidents.py` | 49 | 0 |
| `services/approval_execution.py` | 46 | 0 |
| `models/incident.py` | (model 本身) | **1** (default_factory) |

**總改動點：~10 個檔案、~20 處實作**（非全 1435 引用都要改）

## 5. 遷移步驟（漸進式，不停機）

### Phase 1（單 commit, 低風險）
1. `models/incident.py:362` 改 `_derive_incident_id` 接受 fingerprint kwarg, default uuid 保持向後相容
2. 加 unit test 驗證 fingerprint 給的話派生規則正確
3. 不改任何 caller — 此 phase 純基礎建設

### Phase 2（單 commit, 中風險）
1. `incident_service.py` 新增 `get_or_create_by_fingerprint`
2. `webhooks.py:alertmanager_webhook` 改用新方法（取代當前隨機建 INC）
3. 加 integration test：同 fingerprint 24h 內 webhook 進來只生 1 個 INC

### Phase 3（單 commit, 中風險）
1. `incident_service.py:create_incident_from_signal` 也走 fingerprint reuse path
2. 加 regression test 含「跨日仍 INVESTIGATING」場景

### Phase 4（清理 commit, 低風險）
1. 既有 b3a0f0d7 / 47342dfb 的繞道 dedup 可保留（防呆雙重保險）
2. 文件更新 + LOGBOOK 收尾

## 6. 風險與緩解

| 風險 | 影響 | 緩解 |
|------|------|------|
| Phase 2 開始後新 incident 與舊 incident 共存 PG，UI 顯示混亂 | 中 | 分頁 by created_at, 不依賴 ID 排序 |
| fingerprint hash 撞號 | 極低 | SHA256[:6] = 16M 可能值, 同日撞號機率 < 1ppm |
| Open 期間 reuse 把不同 severity 的事件混淆 | 中 | reuse 時 `severity = max(old, new)`，warning 升級為 critical 是合理 |
| 既有 fingerprint 為空的歷史 incident 無法套用 | 低 | _derive_incident_id 退回 uuid path |
| Codex 同時改 incident_service.py 衝突 | 中 | Phase 2 動手前先 git stash list 檢查 |

## 7. 驗收條件

1. **同 fingerprint 24h 內進來 N 個 alertmanager webhook → 只 1 個 incident_id**（PG 查詢驗證）
2. **incident_id 結尾後 6 字元 = fingerprint 前 6 字元**（可直接從 INC ID 反推 fingerprint）
3. **既有 4 個 agent 7d 漏斗統計復跑**：DISTINCT incident_id 應顯著少於 DISTINCT fingerprint × 7（過去 24h 76 INC / 2 distinct fp → 改後應接近 2）
4. **無 UI / API regression**（incident detail page 仍可顯示）
5. **dedup 繞道（b3a0f0d7 / 47342dfb）+ 新 ID 派生雙重保險，效果不降低**

## 8. 工程量估算

| Phase | 預計時間 | 工程性質 |
|-------|---------|---------|
| Phase 1 | 1 小時 | 純機械改動 + 1 test |
| Phase 2 | 3 小時 | 含 webhook 邏輯重構 + integration test |
| Phase 3 | 2 小時 | incident_service 內部 |
| Phase 4 | 1 小時 | 文件 + 清理 |
| **總計** | **~7 小時** | 分 4 commits 漸進式 |

## 9. 等首席架構師批准

統帥 ogt — 此 ADR 為 **Tier 3 紅區改動**前置文件，不批准不動 code。

需確認：
- [ ] 路線正確（B 而非 A/C）
- [ ] 跨日 reuse 行為（3.3）合理
- [ ] 接受 ~7 小時 / 4 commits 漸進式遷移
- [ ] Codex 並行改 `incident_service.py` 的協調機制（先 stash list 檢查）

批准後我從 Phase 1 開始。
