# Stage 4：多輪稽核設計規格書

> 日期：2026-03-24
> 狀態：Draft

---

## 一、目標

實作多輪稽核歷史保留：每次 close-round 建立新一輪 ar_data，保留前一輪完整快照，支援 round 切換瀏覽與歷史 findings 參考。

---

## 二、設計決策摘要

| 決策 | 結論 | 理由 |
|------|------|------|
| 多輪資料結構 | 每輪建新 ar_data（run_no 遞增） | 符合 OSCAL 規範，不可變審計軌跡 |
| 已通過控制項 | 繼承上一輪 verdict | 覆核只看矯正部分，非完整重新稽核 |
| Findings 處理 | 不複製到新一輪 | 避免資料膨脹、POA&M ar_finding_id 斷裂 |
| 歷史 findings 查閱 | AR control detail 回傳 previous_findings | 供不同稽核員瞭解前次缺失 |
| 歷史輪次保護 | 後端驗證，非最新輪禁止修改 | 審計合規要求，不能只靠前端控制 |
| 跨輪比較 | 不做（未來需要時再加） | YAGNI，資料基礎已具備 |

---

## 三、完整流程

```
Round 1（首次稽核 — launch_audit）
  建立 ar_data (run_no=1) + 15 個 ar_controls (verdict=null)
  稽核員填寫 verdict + findings
  confirm_audit → 5 fail → AP=remediation，產生 5 筆 POA&M

  PM 處理 POA&M → 全部 closed

Round 2（覆核 — close_round）
  建立 ar_data (run_no=2) + 15 個 ar_controls
    - 10 個 pass/na 控制項：verdict 繼承 Round 1
    - 5 個有 POA&M 的控制項：verdict=null（待覆核）
  稽核員覆核（可查看 Round 1 的 previous_findings）
  confirm_audit → 2 fail → AP=remediation，產生 2 筆新 POA&M

  PM 處理 POA&M → 全部 closed

Round 3（第二次覆核 — close_round）
  建立 ar_data (run_no=3) + 15 個 ar_controls
    - 13 個繼承
    - 2 個 verdict=null
  稽核員覆核
  confirm_audit → 全 pass → AP=closed（結案）
```

---

## 四、close_round 邏輯變更

### Stage 3（被取代）

```
重置同一份 ar_data (run_no=1) 上的 verdict = null
```

### Stage 4（新邏輯）

```python
def close_round(ap_uid, curr_user):
    # 1. 驗證 AP status = remediation
    # 2. 驗證所有 POA&M = closed
    # 3. 查當前 ar_data（最大 run_no = N）
    # 4. 收集有 POA&M 的 control_identifiers
    # 5. 建立新 ar_data (run_no = N+1)
    # 6. 複製 ar_controls 到新一輪：
    #    - control_id 在 POA&M 集合中 → verdict=null, 清除 confidence/rationale/remarks
    #    - control_id 不在 POA&M 集合中 → 完整繼承 verdict/confidence/rationale/remarks
    # 7. AP status → auditing
    # 回傳 {ap_uid, status, run_no: N+1, reset_count, inherited_count}
```

### 差異對照

| 項目 | Stage 3 | Stage 4 |
|------|---------|---------|
| ar_data | 複用 run_no=1 | 新建 run_no=N+1 |
| 未通過的 ar_controls | 重置 verdict | 複製到新輪，verdict=null |
| 已通過的 ar_controls | 不動 | 複製到新輪，verdict 繼承 |
| 歷史保留 | 無（覆蓋） | 完整保留每一輪 |

---

## 五、confirm_audit 修改

僅一處改動：查 ar_data 時從硬編碼 `run_no == 1` 改為查最新 run_no。

```python
# Before (Stage 2/3)
ar_data = session.query(OscalAssessmentResultData).filter(
    OscalAssessmentResultData.assessment_result_id == ar.id,
    OscalAssessmentResultData.run_no == 1,
).first()

# After (Stage 4)
ar_data = session.query(OscalAssessmentResultData).filter(
    OscalAssessmentResultData.assessment_result_id == ar.id,
).order_by(OscalAssessmentResultData.run_no.desc()).first()
```

launch_audit 不改（首次啟動建 run_no=1，邏輯不受影響）。

---

## 六、API 設計

所有 API 在 AP-scoped 路徑下：`/api/1.0/grc/project/<project_uid>/ap/<ap_uid>/...`

### 6.1 新增：Round 列表

```
GET /api/1.0/grc/project/<project_uid>/ap/<ap_uid>/ar/rounds
```

**Response：**
```json
{
  "code": 1,
  "data": [
    {
      "run_no": 1,
      "title": "稽核執行",
      "started_at": "2026-03-10T09:00:00",
      "completed_at": "2026-03-15T17:00:00",
      "total_controls": 15,
      "pass_count": 10,
      "fail_count": 4,
      "partial_count": 1,
      "na_count": 0,
      "pending_count": 0,
      "is_current": false
    },
    {
      "run_no": 2,
      "title": "覆核",
      "started_at": "2026-03-20T09:00:00",
      "completed_at": null,
      "total_controls": 15,
      "pass_count": 10,
      "fail_count": 0,
      "partial_count": 0,
      "na_count": 0,
      "pending_count": 5,
      "is_current": true
    }
  ]
}
```

`is_current`：標記最新一輪（唯一可編輯的輪次）。

verdict 統計從 ar_controls 即時聚合：
- `pass_count`：verdict = "pass"
- `fail_count`：verdict = "fail"
- `partial_count`：verdict = "partial"
- `na_count`：verdict = "na"
- `pending_count`：verdict IS NULL

### 6.2 修改：AR controls list

```
POST /api/1.0/grc/project/<project_uid>/ap/<ap_uid>/ar/controls/list
```

**Request 新增欄位：**
```json
{
  "run_no": 2,
  "pager": { "page": 1, "page_size": 25 },
  "sort": [{ "field": "control_id", "order": "asc" }],
  "filters": { "verdict": "fail" }
}
```

- `run_no`：optional，預設最新一輪
- 其餘欄位不變

**Response 新增欄位：**

每筆 ar_control 多回傳 `is_inherited`（Boolean），標記該筆 verdict 是否繼承自上一輪。

```json
{
  "ar_control_uid": "uuid",
  "control_id": "AC.L2-3.1.1",
  "control_title": "...",
  "verdict": "pass",
  "is_inherited": true
}
```

`is_inherited` 計算邏輯：run_no=1 時全部為 false。run_no>1 時，verdict 非 null 且該 control_id 未出現在當輪的 POA&M 集合中 → true。

### 6.3 修改：AR control detail

```
GET /api/1.0/grc/project/<project_uid>/ap/<ap_uid>/ar/control/<ar_control_uid>
```

**Response 新增欄位：**

```json
{
  "data": {
    "ar_control_uid": "uuid",
    "control_id": "AC.L2-3.1.1",
    "verdict": null,
    "findings": [],
    "previous_findings": [
      {
        "uid": "finding-uuid",
        "title": "存取控制政策未完整",
        "category": "deficiency",
        "severity": "high",
        "description": "未能提供完整的存取控制政策文件...",
        "recommendation": "建議補充政策文件...",
        "run_no": 1
      }
    ]
  }
}
```

- `previous_findings`：上一輪同 control_id 的 findings，唯讀
- run_no=1 時為空陣列
- 查詢邏輯：從當前 ar_control 的 ar_data 找 run_no，查 run_no-1 的 ar_data，找同 control_id 的 ar_control，撈其 findings

### 6.4 修改：close-round Response

```json
{
  "code": 1,
  "data": {
    "ap_uid": "ap-uuid",
    "status": "auditing",
    "run_no": 2,
    "reset_count": 5,
    "inherited_count": 10
  }
}
```

新增 `run_no`（新建的輪次）和 `inherited_count`（繼承的控制項數量）。

---

## 七、歷史輪次保護

### Helper function

```python
def _check_latest_round(ar_control_uid: str):
    """驗證 ar_control 屬於最新一輪 ar_data，否則 412"""
```

查詢邏輯：
1. 從 ar_control_uid 查到所屬 ar_data
2. 從 ar_data 查到所屬 assessment_result
3. 查該 assessment_result 下最大 run_no 的 ar_data
4. 比對是否為同一筆 ar_data，不是則 raise 412

### 套用位置

| API | 呼叫 _check_latest_round |
|-----|------------------------|
| PUT .../ar/control/<uid>/verdict | 是 |
| POST .../ar/control/<uid>/findings | 是 |
| PUT .../ar/finding/<uid> | 是（透過 finding → ar_control 反查） |
| DELETE .../ar/finding/<uid> | 是 |
| GET .../ar/controls/list | 否（唯讀） |
| GET .../ar/control/<uid> | 否（唯讀） |

---

## 八、Error Codes

新增至 `common/code/grc_error_code.py`：

| Code | 中文 | Error Code |
|------|------|------------|
| GRC_AR_NOT_LATEST_ROUND | 只能操作最新一輪的稽核結果 | GRC_412008 |

---

## 九、DDD 分層架構

### 新增檔案

| Layer | 檔案 | 說明 |
|-------|------|------|
| Route | `api/grc/routes/audit_route.py` 內新增 `ArRoundListResource` | Round 列表 |
| Serializer | `api/grc/serializers/audit.py` 內新增 schemas | Round response + request 修改 |

### 修改檔案

| 檔案 | 變更 |
|------|------|
| `app/project/service/oscal_audit_service.py` | close_round 重寫 + confirm_audit 改 run_no 查詢 |
| `app/grc/service/audit_service.py` | list_ar_controls 支援 run_no 參數 + get_ar_control_detail 加 previous_findings + _check_latest_round |
| `api/grc/serializers/audit.py` | AR control request/response 加欄位 + round schemas |
| `api/grc/routes/audit_route.py` | verdict/finding routes 加 _check_latest_round + 新增 ArRoundListResource |
| `api/grc/__init__.py` | 註冊 round 列表 URL |
| `api/grc/serializers/poam.py` | CloseRoundResponseSchema 加 run_no / inherited_count |
| `common/code/grc_error_code.py` | 新增 1 個 error code |
| `infra/grc/repository/grc_audit_repo_impl.py` | list_ar_controls 支援 run_no 參數 |

---

## 十、與 Stage 2/3 的關係

### 取代 Stage 3 close_round

Stage 3 的 close_round（重置 verdict）被 Stage 4 完全取代。因為 Stage 3 尚未 deploy，無向下相容問題。

### confirm_audit 改動影響

confirm_audit 的 `run_no==1` 改為查最新 run_no。這影響 Stage 2 的程式碼，但語意完全相容 — 首次稽核時最新 run_no 就是 1，行為不變。

### launch_audit 不動

首次啟動建 run_no=1 的邏輯不受影響。

### POA&M 不受影響

POA&M 的 `ar_finding_id` 始終指向原始 round 的 finding。Findings 不複製到新輪，所以 FK 關係不會斷裂。
