# 2026-08-02 覆核輪受評範圍收斂（narrowed scope）— SUMMARY

| 項目 | 內容 |
|------|------|
| Arc | 覆核輪（close-out）受評範圍未收斂 — 控制項導覽顯示母輪全部控制項 |
| 定位 | FR-053.1（覆核輪承襲與脈絡）續作 bug fix；FR-053 母案「第 3 刀讀取端按輪過濾」的漏網 |
| Branch | `fix/1.13.0-bug`（BE 單 repo，FE 零改動） |
| Commits | 6 支（`b283f1ab` / `167f578f` / `73a6abc0` / `1182034d` / `9b2eae04` / `ea25fcd1`）|
| 驗證 | STG user 手動端對端測試通過；POC migration 已套用並驗證 |
| Notion | CM-1032（`b283f1ab` 結案狀態同步）＋ 本 arc 主案（見 §7） |
| 核心教訓 | **同一 bug 修三次才修對** — 寫入端 / 讀取端 / 第二寫入者三層各自獨立 |

---

## 1. 問題與三層 root cause

**現象**：覆核輪的控制項導覽顯示母輪全部控制項，而不是只顯示本輪需複驗的那幾條
（母輪 not_met）。POC 實證（專案 `659deb51` / round 8）：AP=15 條、AR=4 條、job=4 條
—— 三端只有 AP 錯。

同一個「範圍沒收斂」的表象，實際上由三個彼此獨立的成因造成，逐層剝開才修對：

| 層 | 成因 | 修正 commit |
|----|------|------------|
| **① 寫入端** | `launch_reverify` → `clone_ap_for_round` 原樣複製母輪 `control_selections`（全集）。narrowed（母輪 not_met）稍後才在 `_generate_reverify_prep_jobs` 算出，只餵給 job 生成、**沒回寫 AP** | `167f578f` |
| **② 讀取端** | `build_control_tree_by_ssp_id` 只在凍結輪（`round.ssp_id == ssp_id`）才讀本輪 AP。覆核輪 planning 階段 `ssp_id IS NULL`（SSP 快照延到 `launch_audit`），FE 退用 living SSP → 前置條件不成立 → 退回 `ProfileResolutionService.resolve` 的 living profile 全集 | `1182034d` |
| **③ 第二寫入者** | `clone_ap_for_round` 也複製了 `ap_assessment_activities.related_controls`（同為全集）；`set_tasks` 結尾的 `_backfill_scope_from_tasks`（FR-040 Phase9 行程驅動）用**行程控制項聯集**回寫 AP → **蓋掉第 ① 層的修正** | `9b2eae04` |

**③ 的 STG 實證**（專案 `b4491646`）：04:08:04 建輪時 AP 455 為 1 條（正確），04:25:26 按
「啟動稽核」，04:25:37 被改寫成 2 條（母輪全集）—— 建輪後 11 秒被打回。job 端
`wecm(round_id=2447)` 始終正確。

### OSCAL 語意依據（非僅顯示問題）

OSCAL v1.2.2 `reviewed-controls.control-selections`：**AP 的是「本次計畫查哪些」、
AR result 的是「本次實際查哪些」**。覆核輪＝partial reassessment，本就該有自己收斂的
reviewed-controls。

修正前 AP=15 / AR=4 的 diff 會被讀成「計畫查 15 條卻只查了 4 條」（像評估未完成），
**匯出 OSCAL AP 會把這個矛盾一併輸出**。佐證：`ar_results.reviewed_controls` 在 schema
是 per-result `required[1]`，正因每輪範圍本可不同。

---

## 2. Commits

| Hash | 標題 | 主要檔案 |
|------|------|---------|
| `b283f1ab` | `finalize_audit` 漏同步 `project.status`——順利結案專案永遠停在 `in_progress` | `app/grc/service/audit_round_app_service.py` |
| `167f578f` | 覆核輪 AP reviewed-controls 未收斂——控制項導覽顯示母輪全集 | `assessment_plan_app_service.py`（新增 `narrow_reviewed_controls_for_reverify`）+ `audit_round_app_service.py` |
| `73a6abc0` | （舊）覆核輪 AP reviewed-controls 既有資料收斂 migration | `scripts/sql/`（**已被 `ea25fcd1` 重寫取代**，只改一張表不完整） |
| `1182034d` | planning 階段覆核輪控制樹仍讀 living profile 全集——補漏 `167f578f` | `app/oscal/service/ssp_control_implementation_service.py`（新增 `_is_planning_stage_reverify`） |
| `9b2eae04` | 覆核輪稽核行程仍帶母輪全集——`set_tasks` 回填把收斂好的 AP 蓋回去 | `assessment_result_app_service.py`（抽出 `narrowed_control_ids_from_parent`）+ `assessment_plan_app_service.py`（`clone_ap_for_round` 加 `narrowed_control_ids` 參數）+ `audit_round_app_service.py` |
| `ea25fcd1` | migration 重寫成兩張表——只改 reviewed-controls 會被行程聯集蓋回 | `scripts/sql/2026-08-02-reverify-round-narrow-ap-reviewed-controls.sql` |

> `b283f1ab` 是同 session 順手修的另一支獨立 bug（結案狀態同步），不屬於範圍收斂三層，
> 已由 Notion CM-1032 涵蓋，此處併列以完整交代 branch 上的六支 commit。

---

## 3. 改動範圍（檔案 × 改動）

### BE

| 檔案 | 改動 |
|------|------|
| `app/grc/service/audit_round_app_service.py` | ① `finalize_audit` 補 `_sync_project_status`（+4 行）② `_generate_reverify_prep_jobs` 於 narrowed 非空後呼叫 `narrow_reviewed_controls_for_reverify` ③ `launch_reverify` clone 前先算 narrowed 並傳入 `clone_ap_for_round` |
| `app/grc/service/assessment_plan_app_service.py` | ① 新增 `narrow_reviewed_controls_for_reverify(ap_id, control_ids, curr_user)`——不走對外入口 `set_reviewed_controls`（吃 ap_uid + 內含 auditor 權限檢查，建輪內部流程無 curr_user_id 語境），直接委派 v2 domain service；`description` / `control_objective_selections` 傳 None → v2 upsert 只覆寫 `control_selections`。② `clone_ap_for_round` 加 `narrowed_control_ids` 參數，同步收斂 reviewed-controls + `activity.related-controls`；過濾後控制為空的 activity 整條不 clone，其 task 一併不 clone（不留孤兒行程） |
| `app/grc/service/assessment_result_app_service.py` | 抽出 `narrowed_control_ids_from_parent(parent)`（直接吃母輪 entity），原 `narrowed_control_ids_for(round_entity)` 委派至此——同一份邏輯不分岔 |
| `app/oscal/service/ssp_control_implementation_service.py` | 新增 `_is_planning_stage_reverify`（close-out + `ssp_id IS NULL` + 有 `assessment_plan_id`）；凍結條件之外新增此分支，一樣讀本輪 AP 當控制母體 |
| `scripts/sql/2026-08-02-reverify-round-narrow-ap-reviewed-controls.sql` | 既有資料修正：備份兩張表 → `ap_reviewed_controls.control_selections` 15→4 → `ap_activities.related_controls` 15→4 → `schema_migrations` 登記 |

### 測試碼（已 commit，**未實跑**，見 §6）

| 檔案 | 內容 |
|------|------|
| `test/test_audit_round_app_service.py` | `TestFinalizeAuditSyncsProjectStatus` ×3、`TestLaunchReverifyNarrowsApScope` ×3（收斂到 narrowed / 母輪 AP 零觸碰 / narrowed 空時不動 AP） |
| `test/test_ssp_control_tree_round_scope.py`（新檔） | 判定函式 ×4（planning 覆核輪認得 / 無 AP 退回 / frozen 不走本分支 / 初始輪與 surveillance 不縮範圍）+ scope 分支 ×3 |
| `test/test_fr040_ap_task_linkage.py` | clone 收斂行程相關 ×N |

### FE

**零改動**——導覽、規劃頁、AP 編輯頁、AR 矩陣、匯出全部自動一致（因為都讀同一顆 AP）。

---

## 4. 行為差異（對 user 可見）

| 情境 | 修正前 | 修正後 |
|------|--------|--------|
| 覆核輪控制項導覽（planning 階段） | 顯示母輪全部控制項 | 只顯示母輪未通過（not_met）的控制項 |
| 覆核輪控制項導覽（launch_audit 後 / 凍結） | 同上（被 `set_tasks` 回填打回全集） | 維持收斂 |
| 覆核輪稽核行程（AP activities） | 帶母輪全部控制的行程 | 只留與 narrowed 有交集的行程；無交集者整條不建（含其 task） |
| 按「啟動稽核」後 | AP 被 `_backfill_scope_from_tasks` 打回全集 | 行程本身已收斂，聯集回填結果仍是 narrowed |
| 匯出 OSCAL AP | AP=全集 / AR=narrowed，語意矛盾 | 兩者一致 |
| 專案列表「已完成」篩選 | 稽核順利通過（無 not_met）的專案永遠 `in_progress`，篩選撈不到 | 結案即同步 `completed` |

**非覆核情境零影響**：`clone_ap_for_round` 未傳 `narrowed_control_ids` 時行為與修正前完全一致；
`_is_planning_stage_reverify` 綁 `round_type == "close-out"`，初始輪 / surveillance 的
母體真相仍是 living profile。

---

## 5. 規範文件

| 文件 | 狀態 |
|------|------|
| `docs/issues/resolved/2026-08-02-reverify-round-ap-scope-not-narrowed.md` | **已完成**（2026-08-02）：⓪ Resolution 段、三層 root cause（§3.5/§3.6/§3.7）、§4.2 JSONB 形狀更正，已 `git mv` 到 `resolved/` |
| `docs/specs/current/`（專案管理群 + 稽核執行群） | **已完成**（2026-08-02）：`project-management/_overview.md`、`project-planning.md`、`project-overview.md`、`audit-execution/_overview.md`、`ap-authoring.md` |
| `scripts/sql/2026-08-02-reverify-round-narrow-ap-reviewed-controls.sql` | 已 commit，檔頭含完整背景 + 檔末還原語句 |
| Notion CM-1032 | `b283f1ab` 已建卡（狀態：修正待驗證，待更新為 Done） |
| 本 SUMMARY | 放 FR-053.1 handoff/ —— 本 arc 修的是 FR-053 第 3 刀「讀取端按輪過濾 + 凍結輪讀本輪 AP reviewed_controls」與 FR-053.1「覆核輪 clone」兩者交會處的漏網，屬 FR-053.1 續作 bug fix，不另立 FR |

---

## 6. 已知 follow-up

### 6.1 測試未實跑 ⚠️

三個 code fix（`167f578f` / `1182034d` / `9b2eae04`）**測試碼皆已補齊並 commit，但依 user
指示本次跳過實跑 pytest，未經執行驗證**。實際驗證來自 user 在 STG 的手動端對端測試。
下次進版或 CI 接入前應補跑：

```
pytest test/test_audit_round_app_service.py test/test_ssp_control_tree_round_scope.py test/test_fr040_ap_task_linkage.py -v
```

### 6.2 文案統一未做（user 已拍板，延後執行）

「待**複**驗」→「待**覆**核」全站統一，以及覆核輪預設名稱調整。**FE + BE 全改，兩個
commit 分開**：

| Repo | 檔案 | 現況 |
|------|------|------|
| FE | `src/config/locales/i18n/zh-tw/grc-shared.json:58` | `"pending_reverify": "待複驗"` |
| FE | `src/config/locales/i18n/zh-tw/error-code.json:455` | `GRC_412037`「母輪不在**待複驗**狀態，無法發起覆核」 |
| FE | `src/views/project/DashboardView.vue:151` | 註解 `// amber — 待複驗` |
| BE | `common/code/grc_error_code.py:248` | `GRC_REVERIFY_PARENT_NOT_PENDING = ("母輪不在待複驗狀態，無法發起覆核", "GRC_412037")` |
| BE | `app/grc/service/audit_round_app_service.py:610` | `name=name or f"複驗-第{round_no}輪"` → 應為「覆核-第N輪」 |

> BE error code 文案改動須同步 FE `error-code.json`（含 en / zh-cn，見 memory
> `feedback_be_error_code_fe_i18n_sync`）。

### 6.3 STG 既有髒資料未清

本次 migration 只清了 **POC**。STG 上既有的 close-out 輪（activity 96 等）仍帶母輪全集，
需另寫一支 migration（兩張表，同 `ea25fcd1` 模式，值取自同輪 `ar_results.reviewed_controls`）。
**依環境異動鐵律，套 STG 需決策者當次明確指示。**

### 6.4 `ea25fcd1` 未 push

branch `fix/1.13.0-bug` 上其餘 commit 已在 origin，**`ea25fcd1` 尚未 push**（等 user 明示）。

### 6.5 POC 備份表待清

確認無需回退後可 DROP：

```sql
DROP TABLE IF EXISTS oscal.bak_20260802_ap_reviewed_controls;
DROP TABLE IF EXISTS oscal.bak_20260802_ap_activities;
```

---

## 7. 部署 handover

| 項目 | 狀態 |
|------|------|
| **DEV** | code 已在 branch，重啟 BE 生效；migration 未套（DEV 無對應髒資料需確認） |
| **STG** | code 已部署並經 user 手測（`b4491646` / `12767c34` 通過）；**既有髒資料未清**（見 6.3） |
| **POC** | migration `ea25fcd1` **已套用並驗證**——專案 `659deb51` / round 8 / ap 6，兩張表皆 15 → 4，母輪（ap 5 / activity 17）維持 15 條未動。備份表已建 |
| BE 重啟 | 改動全在 app service 層，**pull 後必須重啟 BE** |
| FE | 零改動，不需重新 build |
| Breaking change | 無 |

### migration 套用指令（供其他環境參考）

```bash
psql -h <host> -p 25432 -U cmmgr -d <db> --single-transaction -v ON_ERROR_STOP=1 \
  -f scripts/sql/2026-08-02-reverify-round-narrow-ap-reviewed-controls.sql
```

> ⚠️ 該檔 WHERE 明確鎖 POC 的 `r.id = 8`，**不是通用 UPDATE**。要套其他環境必須先盤點
> 該環境的 round / ap id 並改寫條件（見 6.3）。

### migration 已知陷阱（重寫時必看）

兩張目標表的 JSONB **形狀不同，不可共用同一個賦值**（2026-08-02 POC 唯讀實測）：

```
ar_results.reviewed_controls                = [{"include-controls":[...]}]      陣列
ap_reviewed_controls.control_selections     = [{"include-controls":[...]}]      陣列（同形）
ap_assessment_activities.related_controls   = {"control-selections":[{...}]}    物件，多一層
```

故 `ap_reviewed_controls` 整欄賦值、`ap_assessment_activities` 需 `jsonb_build_object` 包一層。
