# Sprint 5.1 — 資料安全護欄與全鏈路整合
## 完整解決方案與細化實施步驟

> **建立**: 2026-04-08 (台北時間)
> **建立者**: Claude Sonnet 4.6
> **狀態**: 📋 規範驗證通過，待統帥下令執行
> **前置討論**: 11 項首席架構師決策 (Q1-Q11)
> **依賴**: Phase 10/11 完成 (auto_repair_executions + alert_operation_log)
> **驗證**: 2026-04-08 規範合規驗證完成，P1-P5 問題已修正（見第零章）

---

## 第零章：規範驗證修正記錄（2026-04-08）

> 合規驗證後發現 5 個問題，已在本章修正，不影響原方案邏輯。

### P1 🔴 Playbook 無 PostgreSQL ORM — Migration M-001 方向修正

**問題**：原方案假設 `playbooks` 有 SQL table，但實際上 Playbook 存在 **Redis**（Working Memory，7天 TTL），沒有 PostgreSQL table。

**修正**：Migration M-001 不執行 `ALTER TABLE playbooks`，改為：
- `requires_approval_level`、`stateful_targets`、`requires_pre_backup` 三個欄位加入 **Pydantic model**（`apps/api/src/models/playbook.py`）
- `playbook_repository.py` 的 `to_redis_dict()` / `from_redis_dict()` 同步更新
- **無需 SQL migration**

### P2 🟡 velero_client.py 命名修正

**問題**：目錄慣例混用 `*_client.py` 和 `*_service.py`（signoz_client.py 也用 client）。

**修正**：維持 `velero_client.py` 命名（與 signoz_client.py、langfuse_client.py 一致，client 表示「外部系統連線器」），不改名。

### P3 🟡 docker-health-monitor.sh 狀態釐清

**問題**：LOGBOOK 說「待部署」，但方案要改為純感知層（移除 docker restart）。

**修正**：Sprint 5.1 執行時覆蓋腳本，LOGBOOK 同步更新。腳本改造列為 Layer 4 工作項。

### P4 🟡 三個新 service 補充 DI setter

**問題**：現有規範要求 `set_xxx_service()` 支援測試注入（見 `auto_repair_service.py` 模式）。

**修正**：三個新 service 均需補充 `set_xxx()` setter，已在 Layer 3 程式碼中標注。

### P5 🟡 補充 Deployment Verification 步驟

**問題**：方案缺少 CD smoke test 清單。

**修正**：已在 Layer 7 驗收標準中補充 pod 版本驗證步驟。

---

## 第一章：決策摘要 (Q1-Q11)

| 決策 | 問題 | 裁示 |
|------|------|------|
| Q1 | Stateful 服務分級 | BLOCK(PG/ClickHouse) / CRITICAL_HITL(Gitea/Harbor/Redis/MinIO) / AUTO(Nginx等) |
| Q2 | Pre-flight 備份過期行為 | 選項C：Abort + 觸發緊急備份 + 通知人工 |
| Q3 | Critical_Approval 二次確認 | 選項C：MultiSig 雙簽（延續 Sprint 3 基礎） |
| Q4 | AI 主動備份權 | 限制：非 CPU/IO 告警才可觸發；CPU/IO 告警時禁止 |
| Q5 | Guardrail 與 Plan A/B/C 順序 | Sprint 5.1 (Guardrail) 先，Plan A/B/C 列為 Sprint 5.2 |
| Q6 | Service Registry 儲存 | 靜態 YAML (ops/config/service-registry.yaml)，IaC 版控 |
| Q7 | Velero 整合方式 | kubectl get backup -n velero -o json，API Pod 需 RBAC |
| Q8 | MultiSig whitelist | Sprint 5.1 先做架構，同一 user 點兩次模擬雙簽 |
| Q9 | auto_repair flag 優先級 | Rule=false → 強制人工；Rule=true → AI+Guardrail 最終否決 |
| Q10 | Langfuse trace_id 寫入 | 必須寫入 alert_operation_log context 欄位 |
| Q11 | AlertManager 抑制 | 不抑制 AlertManager；靠 AWOOOI Redis TTL 去重 |

---

## 第二章：整體架構與資料流

### 2.1 防護層次 (Defense in Depth)

```
告警來源
  Prometheus (auto_repair flag) ──┐
  Sentry (error webhook)          ├──→ AWOOOI API Webhook 入口
  SignOz (anomaly)                │       ↓
  docker-health-monitor (感知層) ──┘   ALERT_RECEIVED 寫入 alert_operation_log
                                          ↓
                              ┌─── [Layer 1: Service Registry]
                              │    ops/config/service-registry.yaml
                              │    BLOCK / CRITICAL_HITL / AUTO
                              │         ↓
                              ├─── [Layer 2: Prometheus Flag]
                              │    auto_repair: "false" → 強制 HITL
                              │    auto_repair: "true"  → 進入 Layer 3
                              │         ↓
                              ├─── [Layer 3: AI + Guardrail]
                              │    OpenClaw RCA 分析
                              │    Langfuse trace_id 注入
                              │    Guardrail 最終否決（Stateful 檢查）
                              │         ↓
                              ├─── [Layer 4: Pre-flight Check]
                              │    Velero 備份時間檢查
                              │    CPU/IO 負載檢查（Q4 限制）
                              │    Redis 冪等鎖（防並行備份）
                              │         ↓
                              ├─── [Layer 5: Approval Level]
                              │    AUTO → 直接執行
                              │    STANDARD → 1票（現有 HITL）
                              │    CRITICAL → 2票（MultiSig）
                              │         ↓
                              └─── [Layer 6: 執行 + 記錄]
                                   execute_auto_repair()
                                   alert_operation_log 全程追蹤
                                   Telegram 所有節點通知
                                   SSE 前端即時推送
                                   Grafana deeplink
                                   Langfuse LLM trace
```

### 2.2 新增 event_type 清單

在現有 10 個 event_type 基礎上新增：

```python
# 新增 8 個
"GUARDRAIL_BLOCKED"    # Stateful 服務被 BLOCK，含原因
"PRE_FLIGHT_PASSED"    # Pre-flight 通過（備份時間 OK）
"PRE_FLIGHT_FAILED"    # Pre-flight 失敗（備份過期）
"BACKUP_TRIGGERED"     # 緊急備份已觸發（非同步進行中）
"BACKUP_COMPLETED"     # 緊急備份完成
"BACKUP_FAILED"        # 緊急備份失敗
"APPROVAL_ESCALATED"   # 升級為 CRITICAL_APPROVAL（需 2 票）
"CHANGE_APPLIED"       # 手動變更已記錄（record-change 概念）
```

---

## 第三章：需要產出的技術文件

### 3.1 ADR（架構決策記錄）

| # | 文件 | 內容 |
|---|------|------|
| ADR-062 | `ADR-062-data-safety-guardrails.md` | 服務分級防線、Pre-flight、MultiSig 完整決策記錄 |
| ADR-063 | `ADR-063-service-registry-iac.md` | Service Registry YAML 設計與維護規範 |

### 3.2 設定文件（IaC）

| # | 文件 | 內容 |
|---|------|------|
| SR-001 | `ops/config/service-registry.yaml` | 全服務 Stateful 分級清單（主要產出物） |

### 3.3 Migration 腳本

| # | 文件 | 內容 |
|---|------|------|
| M-001 | `apps/api/migrations/sprint51_playbook_safety.sql` | playbooks 表新增 3 欄位 |
| M-002 | `apps/api/migrations/sprint51_approval_multisig.sql` | approval_records 新增 approval_level/votes |
| M-003 | `apps/api/migrations/sprint51_alert_log_events.sql` | alert_operation_log ENUM 新增 8 事件 |

### 3.4 K8s 設定

| # | 文件 | 內容 |
|---|------|------|
| K-001 | `k8s/rbac/api-velero-reader.yaml` | API Pod 讀取 Velero backup 的 ClusterRole + Binding |

### 3.5 Runbook

| # | 文件 | 內容 |
|---|------|------|
| RB-001 | `docs/runbooks/SPRINT51-ROLLBACK.md` | Sprint 5.1 Rollback 步驟（ENUM 不可回滾的處理方式） |

---

## 第四章：細化實施步驟

### Layer 0 — 前置條件確認

#### L0-1: 確認 K8s RBAC 能力

**目的**：確認 API Pod 是否已有 kubectl exec 能力，或需要新增 Velero backup reader

```bash
# 在 API Pod 內執行測試
kubectl exec -n awoooi-prod deployment/awoooi-api -- \
  kubectl get backup -n velero --no-headers 2>&1 | head -5
```

**預期結果**：
- 成功列出 backup → 直接進 L0-2
- `Error from server (Forbidden)` → 需執行 K-001 RBAC

**若需要新增 RBAC**（K-001）：
```yaml
# k8s/rbac/api-velero-reader.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: awoooi-api-velero-reader
rules:
  - apiGroups: ["velero.io"]
    resources: ["backups"]
    verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: awoooi-api-velero-reader
subjects:
  - kind: ServiceAccount
    name: awoooi-api
    namespace: awoooi-prod
roleRef:
  kind: ClusterRole
  name: awoooi-api-velero-reader
  apiGroup: rbac.authorization.k8s.io
```

```bash
kubectl apply -f k8s/rbac/api-velero-reader.yaml
```

#### L0-2: 確認 Redis 連線

```python
# 驗證現有 auto_repair_service 使用的 Redis client
# 查看 apps/api/src/core/redis.py 或 auto_repair_service.py 的 import
grep -n "redis\|Redis\|get_redis" apps/api/src/services/auto_repair_service.py | head -10
```

**預期**：確認 `get_redis_client()` 或類似函數存在且可用

#### L0-3: 建立 ops/config 目錄

```bash
mkdir -p ops/config
```

---

### Layer 1 — 資料庫變更 (Migration)

> ⚠️ **執行前必須先備份**：
> ```bash
> PGPASSWORD=awoooi_prod_2026 pg_dump -U awoooi -d awoooi_prod \
>   -h localhost -t playbooks -t approval_records \
>   > /tmp/sprint51_pre_migration_backup_$(date +%Y%m%d_%H%M%S).sql
> ```

#### L1-1: Playbook model 新增安全欄位 (M-001) — ⚠️ 無 SQL，改 Pydantic + Redis

> **P1 修正**：Playbook 存在 Redis（無 PostgreSQL table），不需要 SQL migration。
> 修改點：`apps/api/src/models/playbook.py` + `apps/api/src/repositories/playbook_repository.py`

**修改 `apps/api/src/models/playbook.py`**（在 Playbook class 新增欄位）：

```python
# 在 Playbook class 內新增（Sprint 5.1 / 2026-04-08 Asia/Taipei）
requires_approval_level: str = "auto"
# auto=直接執行, standard=1票審核, critical=2票MultiSig
# 由 Service Registry 決定，Playbook 記錄最後一次執行時的 level

stateful_targets: list[str] = []
# 此 Playbook 操作的 Stateful 服務清單，對應 service-registry.yaml
# 例: ["postgres", "redis"]

requires_pre_backup: bool = False
# 執行前是否需要 Pre-flight 備份檢查
```

**修改 `apps/api/src/repositories/playbook_repository.py`**：

在 `to_redis_dict()` 加入三個新欄位的序列化，在 `from_redis_dict()` 加入反序列化（帶預設值，向後相容）。

#### L1-2: approval_records 新增 MultiSig 欄位 (M-002)

```sql
-- apps/api/migrations/sprint51_approval_multisig.sql
-- Sprint 5.1: MultiSig 雙簽核支援
-- 執行者: Claude Sonnet 4.6 / 2026-04-08 Asia/Taipei

BEGIN;

ALTER TABLE approval_records
  ADD COLUMN IF NOT EXISTS approval_level VARCHAR(20)
    DEFAULT 'standard'
    CHECK (approval_level IN ('standard', 'critical')),
  ADD COLUMN IF NOT EXISTS approval_votes JSONB
    DEFAULT '[]'::jsonb
    COMMENT '已投票記錄: [{user_id, voted_at, action}]',
  ADD COLUMN IF NOT EXISTS required_votes INTEGER
    DEFAULT 1
    COMMENT 'standard=1, critical=2';

COMMENT ON COLUMN approval_records.approval_votes IS
  'JSON array: [{"user_id": "123", "voted_at": "2026-04-08T...", "action": "approve"}]';

-- 現有記錄預設為 standard 1票
UPDATE approval_records
SET approval_level = 'standard', required_votes = 1
WHERE approval_level IS NULL;

COMMIT;
```

#### L1-3: alert_operation_log ENUM 新增 (M-003)

> ⚠️ PostgreSQL ENUM ALTER 不可 rollback，請先確認備份完成

```sql
-- apps/api/migrations/sprint51_alert_log_events.sql
-- Sprint 5.1: alert_operation_log 新增 8 個 event_type
-- 執行者: Claude Sonnet 4.6 / 2026-04-08 Asia/Taipei

BEGIN;

-- 新增 ENUM 值（PostgreSQL 支援逐一 ADD VALUE）
ALTER TYPE alert_event_type ADD VALUE IF NOT EXISTS 'GUARDRAIL_BLOCKED';
ALTER TYPE alert_event_type ADD VALUE IF NOT EXISTS 'PRE_FLIGHT_PASSED';
ALTER TYPE alert_event_type ADD VALUE IF NOT EXISTS 'PRE_FLIGHT_FAILED';
ALTER TYPE alert_event_type ADD VALUE IF NOT EXISTS 'BACKUP_TRIGGERED';
ALTER TYPE alert_event_type ADD VALUE IF NOT EXISTS 'BACKUP_COMPLETED';
ALTER TYPE alert_event_type ADD VALUE IF NOT EXISTS 'BACKUP_FAILED';
ALTER TYPE alert_event_type ADD VALUE IF NOT EXISTS 'APPROVAL_ESCALATED';
ALTER TYPE alert_event_type ADD VALUE IF NOT EXISTS 'CHANGE_APPLIED';

COMMIT;
```

**執行方式（在 192.168.0.188 執行）**：

> ⚠️ M-001 已改為 Pydantic model 修改，不再是 SQL。只有 M-002 和 M-003 需要執行 SQL。

```bash
# 先備份（M-002/M-003 涉及 approval_records 和 ENUM）
PGPASSWORD=awoooi_prod_2026 pg_dump -U awoooi -d awoooi_prod -h localhost \
  > /tmp/sprint51_pre_$(date +%Y%m%d_%H%M%S).sql

# M-002: approval_records 多簽欄位
PGPASSWORD=awoooi_prod_2026 psql -U awoooi -d awoooi_prod -h localhost \
  -f sprint51_approval_multisig.sql

# M-003: alert_event_type ENUM 擴充（最後執行）
PGPASSWORD=awoooi_prod_2026 psql -U awoooi -d awoooi_prod -h localhost \
  -f sprint51_alert_log_events.sql

# 驗證
PGPASSWORD=awoooi_prod_2026 psql -U awoooi -d awoooi_prod -h localhost \
  -c "\dT alert_event_type"
```

---

### Layer 2 — 基礎設施層

#### L2-1: Service Registry YAML (SR-001)

**檔案**：`ops/config/service-registry.yaml`

```yaml
# ops/config/service-registry.yaml
# Service Registry — 服務 Stateful 分級清單
# 版本: 1.0.0 / 2026-04-08 Asia/Taipei
# 維護: 修改需 PR + 統帥審核，禁止直接 push
# 說明:
#   BLOCK        = 系統禁止自動修復，僅告警（資料風險最高）
#   CRITICAL_HITL = 允許 Playbook，但需 MultiSig 2票
#   STANDARD_HITL = 允許 Playbook，需 1票審核
#   AUTO         = 允許自動執行（無狀態服務）

services:
  # ─── BLOCK：系統禁止（連 Playbook 都不提供）────────────────────────────
  - name: postgres
    display_name: "PostgreSQL 主庫 (awoooi_prod)"
    host: "192.168.0.188"
    stateful_level: BLOCK
    reason: "主要業務資料庫，重啟可能導致 WAL 截斷、事務回滾"
    alert_only: true
    containers: ["postgres"]

  - name: momo-db
    display_name: "PostgreSQL (momo_db)"
    host: "192.168.0.188"
    stateful_level: BLOCK
    reason: "momo 產品資料庫，禁止自動操作"
    alert_only: true
    containers: ["momo-db"]

  - name: langfuse-db
    display_name: "PostgreSQL (Langfuse)"
    host: "192.168.0.110"
    stateful_level: BLOCK
    reason: "LLM trace 資料庫，重啟導致追蹤資料遺失"
    alert_only: true
    containers: ["langfuse-db"]

  - name: harbor-db
    display_name: "PostgreSQL (Harbor Registry)"
    host: "192.168.0.110"
    stateful_level: BLOCK
    reason: "Harbor Registry 資料庫，重啟可能損壞 image layer 索引"
    alert_only: true
    containers: ["harbor-db"]

  - name: sentry-postgres
    display_name: "PostgreSQL (Sentry)"
    host: "192.168.0.110"
    stateful_level: BLOCK
    reason: "Sentry 錯誤追蹤資料庫"
    alert_only: true
    containers: ["sentry-postgres"]

  - name: signoz-clickhouse
    display_name: "ClickHouse (SignOz)"
    host: "192.168.0.188"
    stateful_level: BLOCK
    reason: "列欄式 OLAP 資料庫，寫入中重啟可能損壞列欄檔案"
    alert_only: true
    containers: ["signoz-clickhouse"]

  # ─── CRITICAL_HITL：高風險，需 MultiSig 2票 ──────────────────────────
  - name: redis
    display_name: "Redis (AWOOOI)"
    host: "192.168.0.188"
    stateful_level: CRITICAL_HITL
    reason: "AWOOOI 依賴 Redis 做冪等鎖與快取，重啟丟失鎖狀態"
    requires_pre_backup: false  # Redis 無法備份 in-memory，需人工評估
    containers: ["redis"]

  - name: harbor-redis
    display_name: "Redis (Harbor)"
    host: "192.168.0.110"
    stateful_level: CRITICAL_HITL
    reason: "Harbor session 快取"
    containers: ["harbor-redis"]

  - name: sentry-redis
    display_name: "Redis (Sentry)"
    host: "192.168.0.110"
    stateful_level: CRITICAL_HITL
    reason: "Sentry 任務佇列"
    containers: ["sentry-redis"]

  - name: gitea
    display_name: "Gitea (程式碼倉庫)"
    host: "192.168.0.110"
    stateful_level: CRITICAL_HITL
    reason: "restart 會殺掉活躍 SSH session，Git push 中斷可能損壞 working copy"
    requires_pre_backup: false
    containers: ["gitea"]

  - name: harbor
    display_name: "Harbor (Container Registry)"
    host: "192.168.0.110"
    stateful_level: CRITICAL_HITL
    reason: "重啟中斷 pull/push；GC 進行中重啟可能損壞 layer"
    requires_pre_backup: false
    containers: ["harbor-core", "harbor-jobservice", "harbor-portal"]

  - name: minio
    display_name: "MinIO (物件存儲)"
    host: "192.168.0.188"
    stateful_level: CRITICAL_HITL
    reason: "寫入中重啟可能導致 multipart upload 中斷"
    requires_pre_backup: false
    containers: ["minio"]

  # ─── STANDARD_HITL：中風險，需 1票審核 ──────────────────────────────
  - name: prometheus
    display_name: "Prometheus"
    host: "192.168.0.110"
    stateful_level: STANDARD_HITL
    reason: "有 TSDB WAL，exited 狀態用 docker start（非 restart）"
    restart_command: "docker start"  # 特殊：使用 start 而非 restart
    containers: ["prometheus"]

  - name: grafana
    display_name: "Grafana"
    host: "192.168.0.110"
    stateful_level: STANDARD_HITL
    reason: "有 SQLite 設定儲存，exited 用 docker start"
    restart_command: "docker start"
    containers: ["grafana"]

  - name: alertmanager
    display_name: "Alertmanager"
    host: "192.168.0.110"
    stateful_level: STANDARD_HITL
    reason: "有 silence 狀態，exited 用 docker start"
    restart_command: "docker start"
    containers: ["alertmanager"]

  # ─── AUTO：無狀態，允許自動修復 ──────────────────────────────────────
  - name: nginx
    display_name: "Nginx (反向代理)"
    host: "192.168.0.110"
    stateful_level: AUTO
    containers: ["nginx", "nginx-188"]

  - name: awoooi-api
    display_name: "AWOOOI API (K3s)"
    host: "k3s"
    stateful_level: AUTO
    containers: []  # K3s Pod，由 kubectl rollout 管理

  - name: awoooi-web
    display_name: "AWOOOI Web (K3s)"
    host: "k3s"
    stateful_level: AUTO
    containers: []

  - name: blackbox-exporter
    display_name: "Blackbox Exporter"
    host: "192.168.0.110"
    stateful_level: AUTO
    containers: ["blackbox-exporter"]

  - name: langfuse
    display_name: "Langfuse (LLMOps)"
    host: "192.168.0.110"
    stateful_level: AUTO
    containers: ["langfuse-web", "langfuse-worker"]

  - name: ollama
    display_name: "Ollama (Local LLM)"
    host: "192.168.0.188"
    stateful_level: AUTO
    containers: ["ollama"]

  - name: momo-app
    display_name: "momo Web App"
    host: "192.168.0.188"
    stateful_level: AUTO
    containers: ["momo-app"]

  - name: tsenyang-website
    display_name: "Tsenyang Website"
    host: "192.168.0.188"
    stateful_level: AUTO
    containers: ["tsenyang-website"]

  - name: stock-platform
    display_name: "Stock Platform"
    host: "192.168.0.110"
    stateful_level: AUTO
    containers: ["stock-platform"]

# ─── 備份策略參考 ────────────────────────────────────────────────────────
backup_policies:
  velero_max_age_hours: 4      # Velero 備份過期閾值（Q2 決策）
  emergency_backup_timeout: 600 # 緊急備份超時秒數
  block_backup_on_high_io: true  # CPU/IO > 80% 時禁止觸發備份（Q4 決策）
  io_threshold_percent: 80

# ─── MultiSig 設定 ───────────────────────────────────────────────────────
multisig:
  critical_required_votes: 2   # CRITICAL_HITL 需要幾票
  standard_required_votes: 1   # STANDARD_HITL 需要幾票
  vote_expiry_minutes: 30       # 投票有效期
```

#### L2-2: Prometheus auto_repair flag 補齊

在 `ops/monitoring/alerts-unified.yml` 確認所有 Docker 容器規則有正確的 `auto_repair` 標籤。

新增（若缺少）：
```yaml
# Docker 容器健康規則（Plan A 接入後使用）
- alert: DockerContainerUnhealthy
  expr: container_health_status{job="docker-health-monitor"} == 0
  for: 2m
  labels:
    severity: warning
    layer: docker-188
    auto_repair: "true"   # 由 Service Registry 決定實際動作
  annotations:
    summary: "容器 {{ $labels.container }} 健康檢查失敗"
    description: "主機 {{ $labels.host }} 容器 {{ $labels.container }} 狀態異常"

- alert: DockerContainerExited
  expr: container_running_status{job="docker-health-monitor"} == 0
  for: 1m
  labels:
    severity: critical
    layer: docker-188
    auto_repair: "true"
  annotations:
    summary: "容器 {{ $labels.container }} 已停止"
```

#### L2-3: K8s RBAC (K-001)

見 L0-1 的 YAML 內容，執行：
```bash
kubectl apply -f k8s/rbac/api-velero-reader.yaml
# 驗證
kubectl auth can-i list backups.velero.io \
  --as=system:serviceaccount:awoooi-prod:awoooi-api -n velero
```

---

### Layer 3 — 基礎設施層（IaC 相關 Python）

#### L3-1: ServiceRegistryClient (新檔案)

**檔案**：`apps/api/src/services/service_registry.py`

```python
# apps/api/src/services/service_registry.py
# Service Registry Client — 讀取 ops/config/service-registry.yaml
# 撰寫: Claude Sonnet 4.6 / 2026-04-08 Asia/Taipei
# 架構: leWOOOgo 積木化，純 Service 層，無 Router/DB 依賴

from __future__ import annotations

import logging
from enum import Enum
from functools import lru_cache
from pathlib import Path
from typing import Any

import yaml

logger = logging.getLogger(__name__)

# YAML 路徑（相對於 repo root，API 啟動時設定 working dir）
_DEFAULT_REGISTRY_PATH = Path(__file__).parents[5] / "ops" / "config" / "service-registry.yaml"


class StatefulLevel(str, Enum):
    BLOCK = "BLOCK"               # 禁止，僅告警
    CRITICAL_HITL = "CRITICAL_HITL"  # 2 票 MultiSig
    STANDARD_HITL = "STANDARD_HITL"  # 1 票
    AUTO = "AUTO"                  # 自動執行


class ServiceInfo:
    def __init__(self, data: dict[str, Any]) -> None:
        self.name: str = data["name"]
        self.display_name: str = data.get("display_name", self.name)
        self.host: str = data.get("host", "unknown")
        self.stateful_level: StatefulLevel = StatefulLevel(data.get("stateful_level", "AUTO"))
        self.reason: str = data.get("reason", "")
        self.alert_only: bool = data.get("alert_only", False)
        self.requires_pre_backup: bool = data.get("requires_pre_backup", False)
        self.restart_command: str = data.get("restart_command", "docker restart")
        self.containers: list[str] = data.get("containers", [])


class ServiceRegistryClient:
    """
    Service Registry 客戶端
    讀取 ops/config/service-registry.yaml，提供服務 Stateful 分級查詢
    設計原則: 純讀取，不寫入；失敗時 fallback AUTO（防護不應阻擋告警流程）
    """

    def __init__(self, registry_path: Path | None = None) -> None:
        self._path = registry_path or _DEFAULT_REGISTRY_PATH
        self._services: dict[str, ServiceInfo] = {}
        self._backup_policies: dict[str, Any] = {}
        self._multisig_config: dict[str, Any] = {}
        self._loaded = False

    def _load(self) -> None:
        if self._loaded:
            return
        try:
            with open(self._path) as f:
                data = yaml.safe_load(f)
            for svc in data.get("services", []):
                info = ServiceInfo(svc)
                self._services[info.name] = info
                # 也按 container 名稱建立索引
                for container in info.containers:
                    self._services[container] = info
            self._backup_policies = data.get("backup_policies", {})
            self._multisig_config = data.get("multisig", {})
            self._loaded = True
            logger.info(f"Service Registry 載入完成: {len(self._services)} 個服務")
        except Exception as e:
            logger.error(f"Service Registry 載入失敗: {e}，所有服務 fallback AUTO")
            self._loaded = True  # 防止重複嘗試

    def get_service(self, name: str) -> ServiceInfo | None:
        self._load()
        return self._services.get(name)

    def get_stateful_level(self, service_name: str) -> StatefulLevel:
        """查詢服務分級，未知服務 fallback AUTO"""
        info = self.get_service(service_name)
        if info is None:
            logger.warning(f"未知服務 '{service_name}'，fallback AUTO")
            return StatefulLevel.AUTO
        return info.stateful_level

    def is_blocked(self, service_name: str) -> bool:
        return self.get_stateful_level(service_name) == StatefulLevel.BLOCK

    def requires_multisig(self, service_name: str) -> bool:
        return self.get_stateful_level(service_name) == StatefulLevel.CRITICAL_HITL

    def get_required_votes(self, service_name: str) -> int:
        self._load()
        level = self.get_stateful_level(service_name)
        if level == StatefulLevel.CRITICAL_HITL:
            return self._multisig_config.get("critical_required_votes", 2)
        return self._multisig_config.get("standard_required_votes", 1)

    def get_backup_policies(self) -> dict[str, Any]:
        self._load()
        return self._backup_policies

    def get_restart_command(self, service_name: str) -> str:
        info = self.get_service(service_name)
        return info.restart_command if info else "docker restart"


# Singleton
_registry_client: ServiceRegistryClient | None = None


def get_service_registry() -> ServiceRegistryClient:
    global _registry_client
    if _registry_client is None:
        _registry_client = ServiceRegistryClient()
    return _registry_client
```

#### L3-2: VeleroClient (新檔案)

**檔案**：`apps/api/src/services/velero_client.py`

```python
# apps/api/src/services/velero_client.py
# Velero Backup 查詢客戶端 (kubectl 方式，Q7 決策)
# 撰寫: Claude Sonnet 4.6 / 2026-04-08 Asia/Taipei

from __future__ import annotations

import asyncio
import json
import logging
from datetime import UTC, datetime, timedelta

logger = logging.getLogger(__name__)

_VELERO_NAMESPACE = "velero"
_KUBECTL_TIMEOUT = 30  # 秒


class VeleroClient:
    """
    透過 kubectl 查詢 Velero 備份狀態
    設計原則: 失敗時 fallback「假設備份過期」（保守原則）
    """

    async def get_latest_backup_age_hours(self) -> float:
        """
        查詢最近一次 Completed 備份距今幾小時
        失敗時返回 999.0（視為嚴重過期，觸發 Abort）
        """
        try:
            result = await asyncio.wait_for(
                self._run_kubectl(
                    ["get", "backup", "-n", _VELERO_NAMESPACE,
                     "-o", "json", "--field-selector", "status.phase=Completed"]
                ),
                timeout=_KUBECTL_TIMEOUT,
            )
            data = json.loads(result)
            items = data.get("items", [])
            if not items:
                logger.warning("Velero: 找不到任何 Completed 備份")
                return 999.0

            # 找最新的完成時間
            latest = max(
                items,
                key=lambda x: x.get("status", {}).get("completionTimestamp", ""),
            )
            completion_ts = latest["status"].get("completionTimestamp", "")
            if not completion_ts:
                return 999.0

            completed_at = datetime.fromisoformat(completion_ts.replace("Z", "+00:00"))
            age = (datetime.now(UTC) - completed_at).total_seconds() / 3600
            logger.info(f"Velero 最近備份: {completion_ts}，距今 {age:.1f} 小時")
            return age

        except asyncio.TimeoutError:
            logger.error("Velero kubectl 查詢超時")
            return 999.0
        except Exception as e:
            logger.error(f"Velero 查詢失敗: {e}")
            return 999.0

    async def trigger_emergency_backup(self, backup_name: str | None = None) -> bool:
        """
        觸發緊急備份（非同步，不等待完成）
        返回 True 表示指令已成功發送
        """
        import time
        name = backup_name or f"emergency-{int(time.time())}"
        try:
            await asyncio.wait_for(
                self._run_kubectl([
                    "create", "backup", name,
                    "-n", _VELERO_NAMESPACE,
                    "--include-namespaces", "awoooi-prod",
                    "--wait=false",
                ]),
                timeout=_KUBECTL_TIMEOUT,
            )
            logger.info(f"Velero 緊急備份已啟動: {name}")
            return True
        except Exception as e:
            logger.error(f"Velero 緊急備份失敗: {e}")
            return False

    async def _run_kubectl(self, args: list[str]) -> str:
        proc = await asyncio.create_subprocess_exec(
            "kubectl", *args,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, stderr = await proc.communicate()
        if proc.returncode != 0:
            raise RuntimeError(f"kubectl 失敗: {stderr.decode()}")
        return stdout.decode()


_velero_client: VeleroClient | None = None


def get_velero_client() -> VeleroClient:
    global _velero_client
    if _velero_client is None:
        _velero_client = VeleroClient()
    return _velero_client
```

#### L3-3: PreflightService (新檔案)

**檔案**：`apps/api/src/services/preflight_service.py`

```python
# apps/api/src/services/preflight_service.py
# Pre-flight 安全檢查服務 (Q2/Q4 決策)
# 撰寫: Claude Sonnet 4.6 / 2026-04-08 Asia/Taipei

from __future__ import annotations

import logging
from dataclasses import dataclass
from enum import Enum

from .service_registry import ServiceRegistryClient, StatefulLevel, get_service_registry
from .velero_client import VeleroClient, get_velero_client

logger = logging.getLogger(__name__)


class PreflightResult(str, Enum):
    PASS = "PASS"
    ABORT_BACKUP_EXPIRED = "ABORT_BACKUP_EXPIRED"
    ABORT_HIGH_IO = "ABORT_HIGH_IO"
    SKIP = "SKIP"  # 服務不需要 Pre-flight


@dataclass
class PreflightReport:
    result: PreflightResult
    backup_age_hours: float | None = None
    backup_name_triggered: str | None = None
    reason: str = ""


class PreflightService:
    """
    Pre-flight 安全檢查
    - 只有 CRITICAL_HITL 且 requires_pre_backup=True 的服務才觸發
    - 備份過期 → Abort + 觸發緊急備份（非同步）
    - CPU/IO 高負載告警 → 禁止觸發備份（Q4）
    """

    def __init__(
        self,
        registry: ServiceRegistryClient | None = None,
        velero: VeleroClient | None = None,
    ) -> None:
        self._registry = registry or get_service_registry()
        self._velero = velero or get_velero_client()

    async def check(
        self,
        service_name: str,
        alert_labels: dict | None = None,
    ) -> PreflightReport:
        """
        執行 Pre-flight 檢查
        alert_labels: Prometheus 告警標籤，用於判斷 CPU/IO 負載
        """
        info = self._registry.get_service(service_name)
        if info is None or not info.requires_pre_backup:
            return PreflightReport(result=PreflightResult.SKIP, reason="服務不需要 Pre-flight")

        # Q4: CPU/IO 高負載告警時禁止觸發備份
        if self._is_high_io_alert(alert_labels):
            logger.warning(f"Pre-flight: {service_name} 屬於 CPU/IO 高負載告警，跳過備份觸發")
            return PreflightReport(
                result=PreflightResult.ABORT_HIGH_IO,
                reason="告警類型為 CPU/IO 高負載，禁止觸發備份（Q4 決策）",
            )

        policies = self._registry.get_backup_policies()
        max_age = policies.get("velero_max_age_hours", 4)

        age = await self._velero.get_latest_backup_age_hours()
        if age <= max_age:
            return PreflightReport(
                result=PreflightResult.PASS,
                backup_age_hours=age,
                reason=f"備份時間正常 ({age:.1f}h < {max_age}h)",
            )

        # 備份過期 → 觸發緊急備份 + Abort
        import time
        backup_name = f"emergency-preflight-{int(time.time())}"
        triggered = await self._velero.trigger_emergency_backup(backup_name)
        return PreflightReport(
            result=PreflightResult.ABORT_BACKUP_EXPIRED,
            backup_age_hours=age,
            backup_name_triggered=backup_name if triggered else None,
            reason=(
                f"備份過期 ({age:.1f}h > {max_age}h)。"
                f"{'緊急備份已啟動: ' + backup_name if triggered else '緊急備份啟動失敗，請人工處理'}"
            ),
        )

    def _is_high_io_alert(self, labels: dict | None) -> bool:
        if not labels:
            return False
        alert_name = labels.get("alertname", "").lower()
        return any(kw in alert_name for kw in ["cpu", "io", "disk", "load", "memory"])


_preflight_service: PreflightService | None = None


def get_preflight_service() -> PreflightService:
    global _preflight_service
    if _preflight_service is None:
        _preflight_service = PreflightService()
    return _preflight_service
```

---

### Layer 4 — auto_repair_service 注入 Guardrail

**修改檔案**：`apps/api/src/services/auto_repair_service.py`

在 `evaluate_auto_repair()` 的**最前面**（全域熔斷之後、severity 檢查之前）注入：

```python
# 在 evaluate_auto_repair() 內，行 178 之後插入：

# ─── Guardrail: Service Registry 分級檢查 ──────────────────────────────
from .service_registry import get_service_registry, StatefulLevel

registry = get_service_registry()
# 從 incident 的 target_resource 或 service_name 提取服務名稱
service_name = incident.target_resource or incident.service_name or ""
stateful_level = registry.get_stateful_level(service_name)

if stateful_level == StatefulLevel.BLOCK:
    # 寫入 alert_operation_log
    await op_log.append(
        "GUARDRAIL_BLOCKED",
        incident_id=str(incident.id),
        actor="guardrail",
        action_detail=f"服務 {service_name} 屬於 BLOCK 等級，禁止自動修復",
        success=False,
        context={"service_name": service_name, "stateful_level": "BLOCK"},
    )
    return AutoRepairDecision(
        should_repair=False,
        reason=f"GUARDRAIL_BLOCK: 服務 {service_name} 屬於禁止自動修復清單（資料風險）",
        playbook=None,
    )
```

**在 `evaluate_auto_repair()` 中，Playbook 匹配後注入 approval_level**：

```python
# 在找到 top_playbook 後，決定 approval level
required_votes = registry.get_required_votes(service_name)
# 寫入 ApprovalRecord（或透過現有 approval_service 設定）
# 確保 approval_record.approval_level 和 required_votes 正確設定
```

---

### Layer 4 — Critical Bug 修復 (Prometheus auto_repair flag)

**修改檔案**：`apps/api/src/api/v1/webhooks.py`

在 `alertmanager_webhook()` 或處理告警的函數中，讀取 labels 並傳入：

```python
# 在建立 Incident 之前，提取 auto_repair flag
labels = alert.labels or {}
can_auto_repair = labels.get("auto_repair", "false").lower() == "true"

# 建立 Incident 時帶入此欄位（若 Incident model 有此欄位）
# 或在呼叫 _try_auto_repair_background 前判斷
if not can_auto_repair:
    # 強制 HITL，不觸發自動修復背景任務
    await op_log.append(
        "GUARDRAIL_BLOCKED",
        incident_id=incident_id,
        actor="prometheus-rule",
        action_detail=f"Prometheus rule 設定 auto_repair=false，強制人工審核",
        context={"alert_name": alert.name, "auto_repair_flag": False},
    )
    return  # 不呼叫 _try_auto_repair_background
```

---

### Layer 4 — Langfuse trace_id 注入 (Q10)

**修改檔案**：`apps/api/src/api/v1/webhooks.py` 或 `openclaw.py`

在 OpenClaw 分析完成後，取得 Langfuse trace_id 並寫入：

```python
# 在 analyze_alert() 返回後
langfuse_trace_id = getattr(analysis_result, "langfuse_trace_id", None)

await op_log.append(
    "AUTO_REPAIR_TRIGGERED",
    incident_id=incident_id,
    actor="openclaw",
    context={
        "langfuse_trace_id": langfuse_trace_id,
        "langfuse_url": f"http://192.168.0.110:3100/trace/{langfuse_trace_id}" if langfuse_trace_id else None,
        "model_used": analysis_result.model_used,
        "confidence": analysis_result.confidence,
    },
)
```

---

### Layer 4 — ALERT_RECEIVED 補點

**修改檔案**：`apps/api/src/api/v1/webhooks.py`

在每個告警入口函數的**最開頭**（驗證完 HMAC 之後）：

```python
# signoz_webhook / alertmanager_webhook / sentry_webhook 入口
await op_log.append(
    "ALERT_RECEIVED",
    actor="alertmanager",  # 或 "sentry" / "signoz"
    action_detail=f"收到告警: {alert.name or alert.title}",
    context={"source": "alertmanager", "labels": labels},
)
```

---

### Layer 4 — docker-health-monitor 改造（純感知層）

**覆蓋** `scripts/ops/docker-health-monitor.sh`：

移除所有 `docker restart` / `docker start` 邏輯，改為：
1. 偵測容器狀態
2. 組裝符合現有 Alertmanager webhook 格式的 JSON
3. POST 到 `/api/v1/webhooks/alertmanager`（現有端點，無需新端點）
4. Fallback：API down → 直接 Telegram Bot API

```bash
# 核心改造：send_awoooi_alert 改為送 Alertmanager 格式
send_to_awoooi() {
    local container="$1"
    local status="$2"
    local hostname
    hostname=$(hostname)

    # 組裝 Alertmanager 格式 JSON
    local payload
    payload=$(cat <<JSON
{
  "version": "4",
  "groupKey": "docker-health-${hostname}-${container}",
  "status": "firing",
  "alerts": [{
    "status": "firing",
    "labels": {
      "alertname": "DockerContainerUnhealthy",
      "container": "${container}",
      "host": "${hostname}",
      "layer": "docker-${hostname##*.}",
      "severity": "warning",
      "auto_repair": "true"
    },
    "annotations": {
      "summary": "容器 ${container} 狀態異常: ${status}",
      "description": "主機 ${hostname} 容器 ${container} 偵測狀態=${status}"
    },
    "startsAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
  }]
}
JSON
)
    # POST 到現有 alertmanager webhook 端點
    curl -s -X POST "${AWOOOI_API_URL}/api/v1/webhooks/alertmanager" \
        -H "Content-Type: application/json" \
        -d "$payload" \
        --connect-timeout 10 --max-time 30
}
```

---

### Layer 5 — Telegram 通知補全（6 種新場景）

**修改檔案**：`apps/api/src/services/telegram_gateway.py` 或通知相關 service

每種場景需要的通知格式：

```
T1: GUARDRAIL_BLOCKED
🚫 [服務保護] 自動修復已阻擋
━━━━━━━━━━━━━━━━━
服務: PostgreSQL (.188)
原因: 屬於 BLOCK 等級（資料庫，禁止自動操作）
告警: KubePodCrashLooping
━━━━━━━━━━━━━━━━━
⚠️ 請人工評估並手動處理

T2: PRE_FLIGHT_FAILED + BACKUP_TRIGGERED
⏸ [Pre-flight 阻擋] 備份已過期，修復暫停
━━━━━━━━━━━━━━━━━
服務: Redis (.188)
備份距今: 6.3 小時（上限 4 小時）
緊急備份: 已啟動 emergency-1744000000
━━━━━━━━━━━━━━━━━
請等待備份完成後，人工重新評估修復方案

T3: BACKUP_COMPLETED / BACKUP_FAILED
✅ 緊急備份完成  /  ❌ 緊急備份失敗
backup: emergency-1744000000
━━━━━━━━━━━━━━━━━
（完成）可繼續手動執行修復
（失敗）請人工介入，備份異常

T4: APPROVAL_ESCALATED（第 1 票完成，等待第 2 票）
🔐 [MultiSig] 等待第 2 票授權
━━━━━━━━━━━━━━━━━
操作: Restart Redis
風險: CRITICAL（HITL 雙簽）
已獲授權: 1/2 票
━━━━━━━━━━━━━━━━━
[✅ 第二簽核] [❌ 否決]

T5: MultiSig 完成（2/2）
✅ [MultiSig 完成] 雙簽授權通過
操作: Restart Redis
授權: 2/2 票  開始執行...

T6: CHANGE_APPLIED（手動變更記錄）
📝 [變更記錄] 手動操作已記錄
━━━━━━━━━━━━━━━━━
操作者: SRE / ogt
動作: [操作描述]
時間: 2026-04-08 22:30 +08
```

---

### Layer 6 — alert_operation_log repository 更新

**修改檔案**：`apps/api/src/repositories/alert_operation_log_repository.py`

```python
# 更新 ALERT_EVENT_TYPES
ALERT_EVENT_TYPES = {
    # 原有 10 個
    "ALERT_RECEIVED",
    "TELEGRAM_SENT",
    "USER_ACTION",
    "AUTO_REPAIR_TRIGGERED",
    "EXECUTION_STARTED",
    "EXECUTION_COMPLETED",
    "TELEGRAM_RESULT_SENT",
    "RESOLVED",
    "SILENCED",
    "ESCALATED",
    # Sprint 5.1 新增 8 個
    "GUARDRAIL_BLOCKED",
    "PRE_FLIGHT_PASSED",
    "PRE_FLIGHT_FAILED",
    "BACKUP_TRIGGERED",
    "BACKUP_COMPLETED",
    "BACKUP_FAILED",
    "APPROVAL_ESCALATED",
    "CHANGE_APPLIED",
}
```

---

### Layer 7 — 驗收標準

#### E2E 測試情境 1：BLOCK 服務告警
```
輸入: Prometheus 告警 PostgreSQL down
預期:
  ✅ ALERT_RECEIVED 寫入 alert_operation_log
  ✅ GUARDRAIL_BLOCKED 寫入 alert_operation_log
  ✅ Telegram 收到 T1 通知
  ✅ 不觸發任何 docker restart / kubectl
  ✅ Prometheus alert 持續 firing（不被 Silence）
```

#### E2E 測試情境 2：Pre-flight 失敗
```
輸入: Redis 告警（連線數耗盡），Velero 備份距今 6 小時
預期:
  ✅ PRE_FLIGHT_FAILED 寫入 alert_operation_log
  ✅ BACKUP_TRIGGERED 寫入（緊急備份啟動）
  ✅ Telegram 收到 T2 通知
  ✅ 修復動作被 Abort
  ✅ Velero backup 物件被建立
```

#### E2E 測試情境 3：MultiSig 雙簽
```
輸入: Redis 告警，Velero 備份正常（< 4小時）
預期:
  ✅ APPROVAL_ESCALATED 寫入（critical level）
  ✅ Telegram 收到 T4 通知（等待第 2 票）
  ✅ 第 1 票點擊後：votes=[{user, action}]
  ✅ 第 2 票點擊後：執行修復 + T5 通知
  ✅ EXECUTION_COMPLETED 寫入
```

#### E2E 測試情境 4：Prometheus auto_repair flag
```
輸入: VeleroBackupFailed 告警（auto_repair: "false"）
預期:
  ✅ GUARDRAIL_BLOCKED 寫入（actor="prometheus-rule"）
  ✅ Telegram 收到阻擋通知
  ✅ 不觸發自動修復
```

#### E2E 測試情境 5：Langfuse 追查
```
輸入: 任意告警觸發 OpenClaw 分析
預期:
  ✅ AUTO_REPAIR_TRIGGERED 的 context 含 langfuse_trace_id
  ✅ context 含 langfuse_url（可點擊）
  ✅ 對應 Langfuse trace 有 LLM 呼叫記錄
```

---

## 第五章：Rollback 方案

### 5.1 ENUM 無法 rollback 的處理

`ALTER TYPE ADD VALUE` 在 PostgreSQL 中**不能 rollback**。

處理方式：
1. 執行前必做 pg_dump（見 L1 執行步驟）
2. 若需要回退 → 從 pg_dump 完整還原整個 DB
3. 新 event_type 的程式碼修改是**向後相容**的（只是多了選項，舊程式碼仍可運行）

### 5.2 程式碼 Rollback

```bash
# 所有程式碼變更在 Git，回退只需：
git revert <sprint51-commit-hash>
git push gitea main
# CD 自動重新部署舊版本
```

### 5.3 YAML Rollback

```bash
# Service Registry YAML 是 IaC，直接 git revert 即可
# ServiceRegistryClient 讀取失敗時 fallback AUTO，不影響現有功能
```

---

## 第六章：需要產出的技術文件清單

| # | 文件 | 類型 | 優先 |
|---|------|------|------|
| ADR-062 | Data Safety Guardrails 完整架構決策 | ADR | P0 |
| ADR-063 | Service Registry IaC 設計規範 | ADR | P0 |
| SR-001 | ops/config/service-registry.yaml | IaC | P0 |
| M-001~003 | Migration 腳本 ×3 | SQL | P0 |
| K-001 | k8s/rbac/api-velero-reader.yaml | K8s | P0 |
| RB-001 | SPRINT51-ROLLBACK.md | Runbook | P0 |
| PB-UPDATE | 12 個 Playbook 補齊 stateful_targets 欄位 | DB | P1 |
| DASHBOARD | Grafana alert_operation_log 儀表板 | Grafana | P1 |
| E2E-TEST | 5 個 E2E 測試情境的測試腳本 | QA | P1 |

---

## 第七章：執行前最後確認

**統帥在下令執行前，請確認：**

| # | 確認項 | 原因 |
|---|--------|------|
| C1 | API Pod 的 K8s ServiceAccount 名稱是否為 `awoooi-api` | L0-1 RBAC binding 需要正確名稱 |
| C2 | 192.168.0.188 上 kubectl 可用（API Pod 或直接 SSH） | Velero 查詢需要 |
| C3 | Telegram whitelist 目前只有一個 user_id | MultiSig Q8 確認：同一人兩次點擊 |
| C4 | Velero 是否已部署且有正常 Completed backup | Pre-flight 需要至少一筆 Completed backup |
| C5 | pg_dump 備份在執行 Migration 前完成 | ENUM 不可回滾 |
