# FR-053 輪次隔離任務執行（Round-Scoped Jobs）— Design

> 建立：2026-07-22 ｜ 狀態：第 1 刀實作中
> Notion case：3a5346da-4cd0-810c-a777-ec5610048d5c
> 相關：FR-038（engagement 模型 / per-AO prep job）、FR-050（輪次階段）

## 1. 問題陳述

**病根：收證據 job 全專案僅一批。** `app/project/service/project_start_app_service.py:_generate_prep_jobs`
只在專案啟動跑一次：per-AO clone 專屬流程範本 → 起 `workflow_execution` → 生
`job_execution` → 寫 `public.workflow_execution_control_mapping`（wecm，
PK=(workflow_execution_id, control_id)，另有 version_id / ao_part_id 欄，
FK→compliance.workflow_executions）。

任務狀態、證據（`compliance.job_evidences`）、聊天、問卷
（`survey.task_surveys.task_id`→job）全部掛在 job 上被**跨輪共用**。覆核輪
`launch_reverify`（`app/grc/service/audit_round_app_service.py:547` 起）只 clone AP、
不生新 job（STG 實證：execution 全 1:1，第二輪操作直接覆寫第一輪歷史）。

AR / 凍結 SSP / 程序書池已按輪隔離健康，不動。

## 2. 三刀修法（user 已拍板）

1. **wecm 加 `round_id`**——接線 truth 從 catalog AO props 轉移至 wecm
   （props 保留不動，向後相容）。
2. **覆核輪生 job**：對 narrowed scope（母輪 not_met）復用 `_generate_prep_jobs`
   核心，生成新輪 execution + job + wecm(round_id)。
3. **讀取端按輪過濾**：control-tree（`get_jobs_by_wf_uids` caller 鏈）、dashboard、
   job 匯出改經 wecm by round_id 篩 execution；凍結輪範圍改讀本輪 AP
   reviewed_controls（拆 profile 共用潛伏雷）。

**不做**：歷史資料遷移（user 拍板後續全刪）、合規資源庫調整（範本工廠層不受影響）、
catalog/profile 每輪快照。

## 3. 六項前提查證（2026-07-22 逐一讀碼確認）

### 3.1 `_generate_prep_jobs` 核心迴圈可抽 helper 參數化復用 — ✅ 可行，但 BPMN uid 注入必須「per-round 再 clone」

讀碼結論（`project_start_app_service.py:338-437`）：

- 核心迴圈輸入面天然可參數化：`control_ids` 子集、`catalog_id`（專案 cloned catalog，
  living、不 per-round，覆核輪可直接沿用）、template 來源。輸出面（execution / job /
  wecm add）與輪次無關，加 `round_id` 參數即可。
- **「沿用專案已 clone 範本」模式成立**：專案啟動時 `_generate_prep_jobs` 已把 cloned
  template uid 回填進 cloned catalog AO `part.props`（`:399-402`），所以覆核輪走
  `_part_wf_uid(part.props)` 取到的**現值就是專案專屬 cloned uid**——以它為 source
  再 clone，不需回到資源庫 source template（也繞開 `clone_cache` 對資源庫 uid 的語意）。
- **⚠️ BPMN job uid 注入段（`:417-427`）不可對同一份 cloned xml 二次注入。**
  查證 `jedi_flow_engine.common.utils.bpmn_uilts.BpmnUtils.inject_job_execution_uid`
  （bpmn_uilts.py:572-608）：注入前會**剔除既有同名 `job_execution_uid` property 再
  append**（`:603-604` `props = [p for p in props if p.get("@name") != "job_execution_uid"]`）
  ——即「覆蓋」語意。若覆核輪復用第一輪的 cloned template 直接注入第二輪 job uid，
  第一輪 job uid 會從 xml 消失 → BPMN 編輯器對第一輪任務失去辨識（重存時重複建 job，
  正是當初注入要防的 bug）。
  **處置**：覆核輪對每個 in-scope AO **再 clone 一份 per-round template**
  （source = 專案 cloned template，`source_template_uid` 指向它），對**新副本**注入
  新輪 job uid——不跳過注入（跳過會復發重複建 job bug）、不動第一輪 xml。
  另外**不回填 AO props**（props 維持指向專案 cloned uid 供舊讀路徑向後相容；
  新輪接線 truth 全靠 wecm.round_id，第 3 刀讀取端據此改查）。
- 附帶驗證：DEV 實證 template↔execution 現況 1:1（0 個 template 有多 execution），
  per-round clone 維持這個不變量，`get_jobs_by_wf_uids` 的 wt→we→je JOIN 不會混輪。

**Helper 形狀**（第 2 刀落地）：抽 `_generate_prep_jobs_core(...)`，參數含
`control_ids`、`round_id`、`template_source_mode`（`resource_library` = 現行 clone 資源庫
範本 + 回填 props；`project_clone` = 以 AO props 現值為 source 再 clone、不回填 props）。
`start_project` 與 `launch_reverify` 各以自己的模式呼叫。

### 3.2 覆核輪生成點 — ✅ 放 `launch_reverify`（能當場算 narrowed scope）

讀碼結論：

- `narrowed_control_ids_for`（`assessment_result_app_service.py:331-352`）依賴三件事：
  母輪 `parent.ar_result_id`（findings 來源）、母輪 `parent.assessment_plan_id`
  （in-scope 母體 + AO→control 映射）、`finding_repo.get_by_ar_result` 的
  `target_status_state == not_met`。
- `launch_reverify` 前置斷言 `parent.status == pending_reverify`
  （audit_round_app_service.py:565）。到達 pending_reverify 的唯一路徑是
  `finalize_audit`（auditing→remediation，AR findings 已 finalize 落庫）→
  `close_round`（remediation→pending_reverify）。**即 launch_reverify 時母輪
  AR/AP/findings 全部齊備，narrowed scope 能當場算。**
- `start_auditing` 的 close-out 分支（`:438-442`）也呼叫同一
  `narrowed_control_ids_for` 建 AR 矩陣——兩處同源（母輪 AP+AR），不會漂移。
- 語意上也應放 launch_reverify：覆核輪建立後 status=planning，planning 階段語意
  就是收證據——job 必須在 planning 一開始就存在，PM 才能指派、受檢方才能傳證據。
  放 start_auditing（稽核已開始）太晚。
- 前置條件補充：job 生成需要 cloned catalog 的 AO parts——catalog 是專案層 clone
  （living，不隨輪凍結），launch_reverify 時可直接用（經
  `project_extensions.living_ssp_id → import_profile → source_catalog_id` 導航，
  與 start_project 同鏈）。

**結論：生成點 = `launch_reverify`，在建輪 + 綁主工作流之後呼叫
`_generate_prep_jobs_core(control_ids=narrowed, round_id=新輪.id, mode=project_clone)`。**
narrowed 為空（母輪無 not_met——理論上不會走到 launch_reverify，防禦處理）時 fallback
全 in-scope 或 skip，第 2 刀實作時定案（傾向 skip + log warning）。

### 3.3 wecm 全部 caller 逐一判定

| Caller | 用法 | 判定 |
|---|---|---|
| `app/project/service/project_start_app_service.py:428` | `_wecm_domain.add(...)` 寫入 per-AO prep job 綁定 | **第 1 刀改**：帶 `round_id=initial round` |
| `app/grc/service/ar_import_app_service.py:_build_evidence_pool` →`_get_allowed_wf_ids_by_control`（經 `WfControlMappingLookupQuery`） | 依 control_id 撈授權 wf id 集合，剔除證據池跨控制污染 | **第 3 刀要加 round 過濾**。AR 匯入本身 per-round（`resolve_round_for_import`），現行查詢跨輪撈——第 2 刀後同 control 會有多輪 wf，覆核輪匯入會把第一輪 job 的證據污染進池。改 `get_wf_ids_by_control_ids(control_ids, round_id=None)`，`round_id` 有值時 `AND m.round_id = :rid`；沿用既有 best-effort fallback（查無 → 不過濾） |
| `infra/grc/repository/wf_control_mapping_lookup_query.py` | 上列的 infra 查詢實作 | **第 3 刀改**（同上，加 optional 參數） |
| `app/flow_engine/service/workflow_execution_service.py:320` | legacy BPMN process property `framework_control` producer（version_id 路線，非 per-AO prep job） | **不動**。legacy 路線無輪次概念，round_id 留 NULL（nullable 欄位天然相容） |
| `app/flow_engine/service/workflow_execution_control_mapping_service.py` | `get_ref_control_workflow_executions` — FR-038 2A stub，一律回 `[]` | **不動**（死碼 stub，等 canonical 修復另案） |
| `infra/grc/repository/grc_job_repo_impl.py:507` | 僅 docstring 提及「控制歸屬可由 wecm 回推」，實際 query 未 join wecm | **不動** |
| `infra/grc/repository/job_import_lookup_query.py:get_job_control_uid_map` | job_uid 白名單 → join wecm 取 control_id | **不動**。輸入已是特定 job_uid 集合（隱含輪次——第 2 刀後 job 本身 per-round），join 只為取 control token；PK (we_id, control_id) 且 execution↔job 1:1，不會因多輪炸出多列 |
| `infra/flow_engine/repository/workflow_execution_control_mapping_repo_impl.py` | CRUD 實作 | **第 1 刀改**：`add()` 帶 round_id（entity/mapper/model 同步） |
| `domain/flow_engine/.../workflow_execution_control_mapping_domain_service.py` + query entity | pass-through | **第 1 刀改**：entity / query entity 加欄位（domain service 本體免改） |
| `api/flow_engine/routes/workflow_execution_control_mapping_route.py` | 呼叫上面的 stub service | **不動** |

### 3.4 control-tree 任務查詢鏈改法 + round 解析規則 — ✅ resolver 能力「夠組合、缺一個現成 method」

現行鏈（`app/oscal/service/ssp_control_implementation_service.py:265-321`）：
`build_control_tree_by_ssp_id` 讀 cloned catalog AO `part.props` 的
workflow-template-uid（專案 cloned uid）→
`infra/oscal/repository/ssp_control_implementation_query.py:get_jobs_by_wf_uids`
（wt.uid → we → je JOIN，**無輪次維度**）。第 2 刀後同一 AO 會有多輪 template/execution
——props 只指得到第一輪，新輪 job 完全查不到；即使改 props 又會蓋掉舊輪。

**改法（第 3 刀）**：jobs 查詢改「經 wecm by round_id 篩 execution」——
`get_jobs_by_wf_uids` 加 `round_id` 參數，JOIN
`public.workflow_execution_control_mapping m ON m.workflow_execution_id = we.id
AND m.round_id = :rid`（或改由 wecm 起頭反查 wt/we/je；實作時取改動小者）。
AO 對位從「props wf uid」換成 wecm 的 `(control_id, ao_part_id)`。

**Round 解析規則**（`GET /ssp/<ssp_uid>/control-tree` 只有 ssp_uid）：

- **凍結輪 ssp_uid** → 經 `project_audit_rounds.ssp_id` 反查該輪（=本輪 round_id）。
- **living SSP** → 取當前輪 = 該專案 `round_no` 最大的輪次。

查證 resolver 現有能力（`domain/oscal/service/ssp_project_resolver.py`）：

- frozen 鏈已有：`_resolve_project_id` 走 `audit_round_domain_service.get_by_ssp_id(ssp_id)`
  （repo `project_audit_round_repo_impl.py:49` 實作存在）✅——但 resolver 只回
  `project_id + source`，**round 本身沒帶出來**。
- living「當前輪」pattern 已有：`is_writable` 內 `list_by_project → max(round_no)`
  （`:129-132`）✅——但同樣沒封裝成可複用的 round 解析。

**結論：能力足夠組合，缺一個現成 method。** 第 3 刀在 resolver（或 domain service）補
`resolve_round_for_ssp(ssp_id) -> Optional[round]`：frozen 命中 `get_by_ssp_id` 直接回
該輪；living 命中 `get_by_living_ssp_id` 則 `list_by_project` 取 round_no 最大者；
皆無（資源庫範本 SSP）回 None → control-tree 走「無輪次」舊行為（jobs 空/props 舊路）。

### 3.5 凍結輪範圍改讀本輪 AP reviewed_controls — ✅ 形狀已驗、解析器可複用

DEV 實查一筆 `oscal.ap_reviewed_controls.control_selections`（LIMIT 1）：

```json
[
  {"include-controls": [
    {"control-id": "AC.L1-3.1.1"},
    {"control-id": "AC.L1-3.1.2"},
    "..."
  ]}
]
```

既有解析能力（grep 實證）：

- `app/grc/service/assessment_result_app_service.py:147 _parse_control_ids`——
  逐 selection 取 `include-controls[].control-id`，與上列形狀完全對齊 ✅
- `app/grc/service/assessment_plan_app_service.py:105` 有一份同款 static method
  （既存的雙份現況，本案不合併，複用 AR 側即可——凍結輪範圍讀取落在 AR/round 語境）。
- 包裝層 `_inscope_control_ids(ap_id)`（`assessment_result_app_service.py:157`：
  `reviewed_repo.get_by_ap` → `_parse_control_ids`）可直接複用。

**結論：可複用，不新寫解析器。** 第 3 刀凍結輪 control-tree / 匯出的控制母體從
「profile resolve（living，跨輪共用、會被後續改動污染）」改為
「本輪 `round.assessment_plan_id` → `ap_reviewed_controls` → `_parse_control_ids`」。

### 3.6 資源庫 end-to-end 驗證項（手測步驟，只列不改資源庫）

「新資源庫 → 啟動專案 → 第一輪 wecm 有 round_id」：

1. 合規資源庫建立並 publish 一個新 module_frame 三件組（catalog/profile/範本 SSP +
   AO workflows 初始化，走既有資源庫 UI 流程，**不改資源庫程式**）。
2. 以該資源庫啟動新專案（`POST /project/start`），確認 response
   `job_count > 0`、`first_round_created = true`。
3. DEV DB 驗證：

   ```sql
   SELECT m.round_id, r.round_no, r.round_type, count(*)
   FROM public.workflow_execution_control_mapping m
   JOIN compliance.workflow_executions we ON we.id = m.workflow_execution_id
   LEFT JOIN compliance.project_audit_rounds r ON r.id = m.round_id
   WHERE we.created_user = '<測試帳號>'    -- 或依新專案 cloned template 時間窗過濾
   GROUP BY 1,2,3;
   ```

   期望：本次新專案的全部 wecm 列 `round_id` 非 NULL 且指向 round_no=1 /
   round_type='initial' 的輪次。
4. 規劃頁 control-tree 正常顯示任務（第 1 刀不改讀取端，此步為回歸確認）。
5. （第 2 刀後補測）走完整輪：launch_audit → start_auditing → finalize（含 not_met）
   → close_round → launch_reverify，驗證新輪 wecm 只含 narrowed 控制且 round_id=新輪。

## 4. 第 1 刀落地細節（本次實作）

- Migration `scripts/sql/2026-07-22-fr053-wecm-round-id.sql`：
  `ADD COLUMN round_id integer`（nullable、**不加 FK**——比照
  `project_audit_rounds.parent/ssp/poam` 等 soft-ref 慣例）+ partial index
  `WHERE round_id IS NOT NULL` + `INSERT public.schema_migrations`。
- Model / Entity / Mapper / RepoImpl.add / QueryEntity 加 `round_id`。
- **round 建立時機查證**：`start_project` 現行順序是
  7b `_generate_prep_jobs` → 7c `_create_first_round`（round 後建）。
  **選「調換順序」**（7c 移到 7b 前）而非事後 UPDATE：兩步互不依賴
  （job 生成不讀 round、create_round 不讀 job；`_create_first_round` best-effort
  失敗回 None → job 照生、round_id=NULL，nullable 相容），改動最小且 wecm 單點寫入。
  `create_round` 對外 dto 只有 uid 無 int id（uid-only API 慣例，不動 response shape），
  故 start_project 內以 round_uid 反查一次 int id（本檔既有 `get_session()+sa.text`
  慣例，如 `_resolve_source_triple`）再傳入 `_generate_prep_jobs(round_id=...)`。

## 5. 第 2 / 3 刀切工建議

- **第 2 刀**（覆核輪生 job）：抽 `_generate_prep_jobs_core` + `project_clone` 模式
  （per-round clone、注入新 job uid、不回填 props）+ `launch_reverify` 接線
  （narrowed scope 當場算）。牽動檔案：project_start_app_service（抽 helper）、
  audit_round_app_service（呼叫點 + DI 注入 flow-engine domain services 或改注入
  project_start_app_service 的 helper——實作時二選一，傾向把 core 抽到獨立
  service 由兩邊注入）。
- **第 3 刀**（讀取端）：resolver 補 `resolve_round_for_ssp` → control-tree
  `get_jobs_by_wf_uids` 改 wecm-by-round → `WfControlMappingLookupQuery` 加 round 參數
  （AR 匯入證據池）→ dashboard（`grc_project_repo_impl.get_ap_dashboard` /
  `count_incomplete_prep_jobs` 的 catalog→props→wt JOIN 改 wecm-by-round）→
  job 匯出（`job_import_service.export_excel` 鏈）→ 凍結輪控制母體改讀本輪 AP
  reviewed_controls（§3.5）。
