# Spec 3「AP 套用流程」Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 讓 user 建專案 / 建後續輪 AP 時挑選流程範本，AP 建立同時 snapshot clone 範本 BPMN 到該 AP 專屬 `workflow_execution`，達成範本生命週期與 AP 完全解耦。

**Architecture:** 拔除 Spec 2 過渡期的 hardcode default 邏輯，把 `flow_template_uid` 改為必填參數沿 `start_oscal_project` + `launch_new_round` 兩條 entry 傳入；reuse Spec 2 已落地的 `WorkflowTemplateSnapshotService.clone_master_as_snapshot` 做 per-AP immutable snapshot；補 `launch_new_round` 缺漏的 main workflow binding（Spec 2 漏蓋）；AP list + detail API 新增 `flow_template` enrichment 讓 FE default 帶值與顯示。

**Tech Stack:**
- BE: Python 3 / Flask / Flask-RESTful / SQLAlchemy / marshmallow / dependency-injector / pytest
- FE: Vue 3 (Composition API) / Vite / Pinia / PrimeVue 3.53 / vee-validate / vue-i18n
- DB: PostgreSQL 14 (compliance / public / oscal schemas with RLS)
- 內部套件: jedi-flow-engine (path mode during dev, see CLAUDE.md "jedi-* 套件" 段)

**Spec 來源**: `docs/features/FR-026-2605-project-flow-engine-integrate/03-ap-binding/design.md` v2.1

---

## 開工前必讀

1. `docs/features/FR-026-2605-project-flow-engine-integrate/03-ap-binding/design.md` — Spec 3 設計 v2.1（**必讀**，本 plan 對應其 M0–M8）
2. `docs/features/FR-026-2605-project-flow-engine-integrate/02-stage-integration/design.md` — Spec 2 已 ship；§十 reconciliation 是 Spec 3 銜接點
3. `docs/features/FR-026-2605-project-flow-engine-integrate/01-flow-template-management/design.md` — Spec 1 已 ship；範本列表 API + Preview Dialog 是依賴
4. `CLAUDE.md` — 開發規範（DDD / @transaction / Error code / API patterns）
5. `docs/claude/jedi-packages.md` — jedi-flow-engine API 參考
6. `docs/claude/frontend-overview.md` — FE 設計 cheatsheet（design tokens / PrimeVue quirks / Audit Lifecycle UI）

## 環境前置

- BE: `feature/project-flow-engine-integrate` branch（HEAD = `5acceb0` 之後）
- BE: `pyproject.toml` 已切 jedi-flow-engine path mode（working tree modified，不 commit）
- BE: `python main_socketio.py` 跑得起來（port 8000）；日誌 `log/app.log`
- BE: `psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev`（密碼 `jedi@123!`；migration 一律用 `cmmgr` 不用 `cm_app`）
- FE: `compliance-manager-fe` 同 branch；`npm run dev` 起 5173
- 測試帳號: `blsadmin / Billows@123!`（manual）；`blsit / Billows@123!`（pytest）

---

## M0 — Pre-flight Verification

### Task M0.1: Grep verification — 確認 default lookup 無其他 caller

**Files:** 純檢查、無修改

- [ ] **Step 1: Grep `get_default_main_workflow_template` 所有 caller**

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
grep -rn "get_default_main_workflow_template" --include="*.py"
```

Expected: 僅 2 個 hit（定義 `domain/flow_engine/service/flow_template_domain_service.py:35` + caller `app/project/service/oscal_project_service.py:510`）

- [ ] **Step 2: Grep `get_builtin_by_name` 所有 caller**

```bash
grep -rn "get_builtin_by_name" --include="*.py"
```

Expected: 僅 3 個 hit（interface / impl / domain service 內部呼叫）

- [ ] **Step 3: Grep `DEFAULT_MAIN_WORKFLOW_TEMPLATE_NAME`**

```bash
grep -rn "DEFAULT_MAIN_WORKFLOW_TEMPLATE_NAME" --include="*.py"
```

Expected: 僅 1 個 hit（定義處）

- [ ] **Step 4: Grep `GRC_FLOW_MAIN_TEMPLATE_NOT_FOUND` 所有 caller**

```bash
grep -rn "GRC_FLOW_MAIN_TEMPLATE_NOT_FOUND" --include="*.py"
```

Expected: 含 `common/code/grc_error_code.py:198` 定義 + `app/project/service/oscal_project_service.py:508+512` 兩處 raise。**若有其他 hit**，記下來 M4 一併處理。

- [ ] **Step 5: Grep `_bind_main_workflow_to_ap` 既有 caller**

```bash
grep -rn "_bind_main_workflow_to_ap" --include="*.py"
```

Expected: 僅 2 個 hit — 定義 `app/project/service/oscal_project_service.py:492` + 呼叫 `:487`。M4 會新增第二個 caller（`launch_new_round`）。

- [ ] **Step 6: 確認 jedi-flow-engine path mode 在跑**

```bash
poetry show jedi-flow-engine | grep -i location
```

Expected: 輸出 `~/Projects/Jedicogy/module/jedi-python-package/jedi-flow-engine` 而非 `.venv/lib/...`

- [ ] **Step 7: 確認 FE repo changelog 慣例**

```bash
ls ~/Projects/Billows/Audit-Manager/compliance-manager-fe/docs/changelog/ 2>/dev/null | head -3
```

Expected: 若資料夾存在 → FE 有獨立 changelog 慣例（M5/M6 各自寫一筆）；若不存在 → FE 改動的 changelog 寫在 BE repo `docs/changelog/` 標記 `modules: [frontend]`。

- [ ] **Step 8: 預先決定 FE dropdown 加在哪個 component**

```bash
grep -n "profile_uid\|profileUid\|compliance.*資源" ~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/project/ProjectCreateView.vue ~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/components/grc/ProjectBasicInfoForm.vue 2>/dev/null
```

判斷邏輯：若 ProjectBasicInfoForm 已含 profile 相關 dropdown，flow_template dropdown 加在這裡保持「合規資源 + 流程範本」一組；否則加在 ProjectCreateView 該 step 的 inline form。M5 直接套用此結論。

**輸出**: 寫一段 verification 結果到 conversation（含 FE 慣例 + dropdown 落點決定），沒問題就進 M1。

---

## M1 — BE Error Code 整理

### Task M1.1: 刪除 `GRC_FLOW_MAIN_TEMPLATE_NOT_FOUND` + fix `GRC_AR_DATA_MISSING` 編號

**Files:**
- Modify: `common/code/grc_error_code.py:198, 201`

- [ ] **Step 1: 讀檔確認當前狀態**

```bash
sed -n '195,205p' common/code/grc_error_code.py
```

Expected: 看到 line 198 `GRC_FLOW_MAIN_TEMPLATE_NOT_FOUND = ("找不到預設主流程範本（seed 缺失）", "GRC_412022")` 跟 line 201 `GRC_AR_DATA_MISSING = ("找不到稽核回合資料", "GRC_500001")`

- [ ] **Step 2: 刪除 `GRC_FLOW_MAIN_TEMPLATE_NOT_FOUND`**

用 Edit tool 刪掉 `common/code/grc_error_code.py:198` 整行（含換行）：

```python
# 刪除這行
GRC_FLOW_MAIN_TEMPLATE_NOT_FOUND   = ("找不到預設主流程範本（seed 缺失）", "GRC_412022")
```

- [ ] **Step 3: 修正 `GRC_AR_DATA_MISSING` 編號**

`common/code/grc_error_code.py:201`：

```python
# Before
GRC_AR_DATA_MISSING                = ("找不到稽核回合資料",           "GRC_500001")
# After
GRC_AR_DATA_MISSING                = ("找不到稽核回合資料",           "GRC_412024")
```

理由：HTTP 412 PreconditionFailed 不該配 500xxx 編號（違反 CLAUDE.md error code 命名規則）。

- [ ] **Step 4: Grep 確認沒有其他 caller 引用刪除的 code**

```bash
grep -rn "GRC_FLOW_MAIN_TEMPLATE_NOT_FOUND" --include="*.py"
```

Expected: 應該有 `app/project/service/oscal_project_service.py:508, 512` 兩處還在 raise — **這兩處留到 M4 一併 fix**（M4 改 `_bind_main_workflow_to_ap` 時整段 helper 重寫，順手換掉 raise 對象）。

- [ ] **Step 5: Run BE smoke import test**

```bash
python -c "from common.code.grc_error_code import GrcErrorCode; print(GrcErrorCode.GRC_AR_DATA_MISSING)"
```

Expected: 印出 `("找不到稽核回合資料", "GRC_412024")` 不會 ImportError

- [ ] **Step 6: Changelog**

Create `docs/changelog/2026-05-13-tweak-grc-error-code-cleanup.md`:

```markdown
---
type: tweak
modules: [common.code.grc_error_code]
issue: docs/features/FR-026-2605-project-flow-engine-integrate/03-ap-binding/design.md (§9.7)
---

# GRC error code 清理：刪 412022 + fix 500001 編號

## 變更
- 刪除 `GRC_FLOW_MAIN_TEMPLATE_NOT_FOUND` (412022)
- `GRC_AR_DATA_MISSING` 編號從 500001 改 412024

## 原因
- 412022: Spec 3 拔除 hardcode default lookup 後不再需要
- 500001: HTTP 412 配 500 編號違反 CLAUDE.md error code 命名規則
```

- [ ] **Step 7: Commit**

```bash
git add common/code/grc_error_code.py docs/changelog/2026-05-13-tweak-grc-error-code-cleanup.md
git commit -m "tweak(spec3 M1): GRC error code 清理 — 刪 412022 + fix 500001→412024"
```

---

## M2 — BE AP List + Detail Enrichment

### Task M2.1: 設計 `flow_template` enrichment 共用 helper

**Files:**
- Create: `app/grc/service/_flow_template_enricher.py`

- [ ] **Step 1: 找到 AP list/detail service 入口**

```bash
grep -n "get_assessment_plans_menu\|get_assessment_plan_detail" app/grc/service/*.py
```

Expected: 在 `app/grc/service/project_service.py` 找到 method 定義

- [ ] **Step 2: 寫 `FlowTemplateEnricher` 失敗測試**

Create `test/test_flow_template_enricher.py`:

```python
"""Spec 3 M2 — FlowTemplateEnricher unit tests."""
import pytest
from unittest.mock import MagicMock

from app.grc.service._flow_template_enricher import FlowTemplateEnricher


class TestFlowTemplateEnricher:
    def test_enrich_returns_full_payload_when_all_layers_exist(self):
        ext_service = MagicMock()
        engine_service = MagicMock()
        master_service = MagicMock()

        ext_service.get_by_assessment_plan_ids.return_value = {
            1: MagicMock(flow_template_snapshot_uid="snap-uid-1")
        }
        engine_service.get_many_by_uids.return_value = {
            "snap-uid-1": MagicMock(source_template_uid="master-uid-1")
        }
        master_service.get_many_by_uids.return_value = {
            "master-uid-1": MagicMock(name="完整稽核流程", is_active=True)
        }

        enricher = FlowTemplateEnricher(ext_service, engine_service, master_service)
        result = enricher.enrich_many(ap_ids=[1])

        assert result[1] == {
            "snapshot_uid": "snap-uid-1",
            "master_uid": "master-uid-1",
            "master_name": "完整稽核流程",
            "is_master_active": True,
        }

    def test_enrich_returns_none_when_ext_missing(self):
        ext_service = MagicMock()
        engine_service = MagicMock()
        master_service = MagicMock()
        ext_service.get_by_assessment_plan_ids.return_value = {}

        enricher = FlowTemplateEnricher(ext_service, engine_service, master_service)
        result = enricher.enrich_many(ap_ids=[1])

        assert result[1] is None

    def test_enrich_marks_master_inactive_when_soft_deleted(self):
        ext_service = MagicMock()
        engine_service = MagicMock()
        master_service = MagicMock()

        ext_service.get_by_assessment_plan_ids.return_value = {
            1: MagicMock(flow_template_snapshot_uid="snap-uid-1")
        }
        engine_service.get_many_by_uids.return_value = {
            "snap-uid-1": MagicMock(source_template_uid="master-uid-1")
        }
        master_service.get_many_by_uids.return_value = {
            "master-uid-1": MagicMock(name="Deleted", is_active=False)
        }

        enricher = FlowTemplateEnricher(ext_service, engine_service, master_service)
        result = enricher.enrich_many(ap_ids=[1])

        assert result[1]["is_master_active"] is False

    def test_enrich_uses_batch_queries(self):
        """避免 N+1：N 個 AP 應該只觸發 3 次 query（ext / engine / master）"""
        ext_service = MagicMock()
        engine_service = MagicMock()
        master_service = MagicMock()
        ext_service.get_by_assessment_plan_ids.return_value = {}

        enricher = FlowTemplateEnricher(ext_service, engine_service, master_service)
        enricher.enrich_many(ap_ids=[1, 2, 3, 4, 5])

        # 即使 5 個 AP 也只該 call 一次 ext_service.get_by_assessment_plan_ids
        ext_service.get_by_assessment_plan_ids.assert_called_once_with([1, 2, 3, 4, 5])
```

- [ ] **Step 3: Run test to verify it fails**

```bash
pytest test/test_flow_template_enricher.py -v
```

Expected: FAIL with `ImportError: cannot import name 'FlowTemplateEnricher'`

- [ ] **Step 4: 實作 `FlowTemplateEnricher`**

Create `app/grc/service/_flow_template_enricher.py`:

```python
"""Spec 3 M2 — AP `flow_template` enrichment helper.

DDD note: 純 app service 層 helper，不直接碰 ORM。透過注入的 domain service 取資料。
"""
from typing import Dict, List, Optional


class FlowTemplateEnricher:
    """把 N 個 AP id batch enrich 成 `flow_template` payload dict。

    Caller 已在 `@transaction` scope 內。
    """

    def __init__(
        self,
        ap_extension_domain_service,
        workflow_template_service,
        flow_template_domain_service,
    ):
        self._ext = ap_extension_domain_service
        self._engine = workflow_template_service
        self._master = flow_template_domain_service

    def enrich_many(self, ap_ids: List[int]) -> Dict[int, Optional[dict]]:
        """Return {ap_id: flow_template_payload | None}.

        Lenient：任一層缺失就回 None / `is_master_active=False`，不 raise。
        """
        if not ap_ids:
            return {}

        # 一段 query：取所有 ext
        exts_by_ap_id = self._ext.get_by_assessment_plan_ids(ap_ids)
        if not exts_by_ap_id:
            return {ap_id: None for ap_id in ap_ids}

        # 二段 query：取所有 snapshot（engine 表）
        snapshot_uids = [
            ext.flow_template_snapshot_uid for ext in exts_by_ap_id.values()
            if ext and ext.flow_template_snapshot_uid
        ]
        snapshots_by_uid = self._engine.get_many_by_uids(snapshot_uids)

        # 三段 query：取所有 master
        master_uids = [
            snap.source_template_uid for snap in snapshots_by_uid.values()
            if snap and snap.source_template_uid
        ]
        masters_by_uid = self._master.get_many_by_uids(master_uids)

        # 組裝
        result = {}
        for ap_id in ap_ids:
            ext = exts_by_ap_id.get(ap_id)
            if not ext or not ext.flow_template_snapshot_uid:
                result[ap_id] = None
                continue
            snap = snapshots_by_uid.get(ext.flow_template_snapshot_uid)
            if not snap or not snap.source_template_uid:
                result[ap_id] = None
                continue
            master = masters_by_uid.get(snap.source_template_uid)
            if not master:
                # snapshot 還在但 master 連 row 都沒了 — 回部分資料
                result[ap_id] = {
                    "snapshot_uid": ext.flow_template_snapshot_uid,
                    "master_uid": snap.source_template_uid,
                    "master_name": None,
                    "is_master_active": False,
                }
                continue
            result[ap_id] = {
                "snapshot_uid": ext.flow_template_snapshot_uid,
                "master_uid": snap.source_template_uid,
                "master_name": master.name,
                "is_master_active": bool(master.is_active),
            }
        return result
```

- [ ] **Step 5: Run test to verify it passes**

```bash
pytest test/test_flow_template_enricher.py -v
```

Expected: 4 tests PASS

- [ ] **Step 6: Commit**

```bash
git add app/grc/service/_flow_template_enricher.py test/test_flow_template_enricher.py
git commit -m "feat(spec3 M2): FlowTemplateEnricher helper for AP flow_template enrichment"
```

### Task M2.2: 補 domain service / repo batch query method

**Files:**
- Modify: `domain/flow_engine/service/flow_template_domain_service.py`
- Modify: `domain/flow_engine/repository/flow_template_repo.py`
- Modify: `infra/flow_engine/repository/flow_template_repo_impl.py`
- Modify: `domain/ap_extension_*` (path TBD by grep)

- [ ] **Step 1: 定位 ap_extension domain service / repo**

```bash
grep -rn "AssessmentPlanExtensionDomainService\|assessment_plan_extensions" --include="*.py" -l | head -5
```

- [ ] **Step 2: 為各 service 加 batch query failing tests**

注意每個 service 都應有對應 `test/test_<service>.py`；找不到就先 create file。完整 test bodies：

`test/test_flow_template_domain_service.py`：

```python
import pytest
from unittest.mock import MagicMock
from domain.flow_engine.service.flow_template_domain_service import FlowTemplateDomainService


def test_get_many_by_uids_returns_dict_keyed_by_uid():
    repo = MagicMock()
    repo.get_many_by_uids.return_value = {
        "uid-1": MagicMock(uid="uid-1", name="A"),
        "uid-2": MagicMock(uid="uid-2", name="B"),
    }
    service = FlowTemplateDomainService(repo)
    result = service.get_many_by_uids(["uid-1", "uid-2"])
    assert list(result.keys()) == ["uid-1", "uid-2"]
    assert result["uid-1"].name == "A"

def test_get_many_by_uids_empty_list_returns_empty_dict():
    repo = MagicMock()
    service = FlowTemplateDomainService(repo)
    assert service.get_many_by_uids([]) == {}
    repo.get_many_by_uids.assert_not_called()  # 短路：空 list 不打 repo

def test_get_many_by_uids_missing_uids_omitted():
    repo = MagicMock()
    repo.get_many_by_uids.return_value = {"uid-1": MagicMock()}
    service = FlowTemplateDomainService(repo)
    result = service.get_many_by_uids(["uid-1", "uid-2"])  # uid-2 不存在
    assert "uid-2" not in result
```

`test/test_ap_extension_domain_service.py`：

```python
def test_get_by_assessment_plan_ids_returns_dict_keyed_by_ap_id():
    repo = MagicMock()
    repo.get_by_assessment_plan_ids.return_value = {
        1: MagicMock(assessment_plan_id=1, flow_template_snapshot_uid="snap-1"),
        2: MagicMock(assessment_plan_id=2, flow_template_snapshot_uid="snap-2"),
    }
    service = AssessmentPlanExtensionDomainService(repo)
    result = service.get_by_assessment_plan_ids([1, 2, 3])
    assert set(result.keys()) == {1, 2}
    assert result[1].flow_template_snapshot_uid == "snap-1"

def test_get_by_assessment_plan_ids_empty_list_short_circuits():
    repo = MagicMock()
    service = AssessmentPlanExtensionDomainService(repo)
    assert service.get_by_assessment_plan_ids([]) == {}
    repo.get_by_assessment_plan_ids.assert_not_called()
```

`test/test_workflow_template_service.py`（注意這是 jedi-flow-engine 套件的 service — 修改範圍跨套件）：

```python
def test_get_many_by_uids_returns_dict():
    # 套件層級加 method，測試與上述同 pattern
    ...
```

> 注意：若 WorkflowTemplateService 是 jedi-flow-engine 套件的 service，加 `get_many_by_uids` method **是套件 API 異動**。依 CLAUDE.md「外部套件異動規範」：開工前確認改動範圍 + dev 走 path mode（pyproject 已切）、feature 完成才 bump。本 task 改動是必要的（FlowTemplateEnricher batch query 需要），算合理範圍。

- [ ] **Step 3: Run tests to verify they fail**

```bash
pytest test/test_flow_template_domain_service.py -v
pytest test/test_ap_extension_domain_service.py -v
pytest test/test_workflow_template_service.py -v
```

Expected: 3 FAIL with `AttributeError: ... has no attribute 'get_many_by_uids' / 'get_by_assessment_plan_ids'`

- [ ] **Step 4: 實作 batch query methods**

在每個 repo / domain service 加：

```python
# repo interface
def get_many_by_uids(self, uids: List[str]) -> Dict[str, EntityT]: ...

# repo impl
def get_many_by_uids(self, uids):
    if not uids:
        return {}
    rows = self.session.query(self._model).filter(self._model.uid.in_(uids)).all()
    return {str(row.uid): self._mapper.to_entity(row) for row in rows}

# domain service
def get_many_by_uids(self, uids):
    return self._repo.get_many_by_uids(uids)
```

`AssessmentPlanExtensionDomainService.get_by_assessment_plan_ids`：

```python
def get_by_assessment_plan_ids(self, ap_ids):
    if not ap_ids:
        return {}
    rows = self.session.query(...).filter(model.assessment_plan_id.in_(ap_ids)).all()
    return {row.assessment_plan_id: mapper.to_entity(row) for row in rows}
```

- [ ] **Step 5: Run tests to verify they pass**

Expected: 3 PASS

- [ ] **Step 6: Commit**

```bash
git add domain/ infra/ test/
git commit -m "feat(spec3 M2): batch query methods for FlowTemplate / AP extension / WorkflowTemplate"
```

### Task M2.3: AssessmentPlanMenuResponseSchema 加 `flow_template` 欄位

**Files:**
- Modify: `api/grc/serializers/assessment_plan.py:12-30`

- [ ] **Step 1: 加 schema 欄位**

```python
# api/grc/serializers/assessment_plan.py

class FlowTemplateEnrichmentSchema(Schema):
    snapshot_uid = fields.String(allow_none=True)
    master_uid = fields.String(allow_none=True)
    master_name = fields.String(allow_none=True)
    is_master_active = fields.Boolean(allow_none=True)


class AssessmentPlanMenuResponseSchema(Schema):
    uid = fields.String()
    # ...既有欄位...
    ssp_uid = fields.String(allow_none=True)
    flow_template = fields.Nested(FlowTemplateEnrichmentSchema, allow_none=True)  # 新增
```

- [ ] **Step 2: Commit (schema only)**

```bash
git add api/grc/serializers/assessment_plan.py
git commit -m "feat(spec3 M2): add flow_template enrichment field to AssessmentPlanMenuResponseSchema"
```

### Task M2.4: AP list / detail service 整合 enricher

**Files:**
- Modify: `app/grc/service/project_service.py` (`get_assessment_plans_menu` / detail)
- Modify: `di_containers/grc/grc_container.py` (wire FlowTemplateEnricher)

- [ ] **Step 1: 加 integration failing test**

`test/test_grc_assessment_plan_api.py`:

```python
def test_get_assessment_plans_menu_returns_flow_template_enrichment(
    app_client, blsit_headers, project_with_two_aps
):
    """AP list endpoint 必須回 flow_template enrichment（FE default 帶值依賴）。"""
    resp = app_client.get(
        f"/grc/project/{project_with_two_aps.uid}/assessment-plans/menu",
        headers=blsit_headers,
    )
    assert resp.status_code == 200
    aps = resp.json["data"]
    assert len(aps) == 2
    for ap in aps:
        assert "flow_template" in ap
        assert ap["flow_template"]["master_uid"] is not None
        assert ap["flow_template"]["snapshot_uid"] is not None
        assert ap["flow_template"]["master_name"] is not None
        assert ap["flow_template"]["is_master_active"] is True

def test_get_assessment_plan_detail_returns_flow_template_enrichment(
    app_client, blsit_headers, ap_fixture
):
    resp = app_client.get(
        f"/grc/project/{ap_fixture.project_uid}/ap/{ap_fixture.uid}",
        headers=blsit_headers,
    )
    assert resp.status_code == 200
    assert resp.json["data"]["flow_template"]["master_name"] == "完整稽核流程"
```

- [ ] **Step 2: Run test — should fail**

Expected: FAIL `KeyError: 'flow_template'`（schema 加了但 service 還沒 enrich）

- [ ] **Step 3: 改 `project_service.py` integrate enricher**

```python
class ProjectService:
    def __init__(
        self,
        # ...既有 deps...
        flow_template_enricher: FlowTemplateEnricher = None,  # 新增
    ):
        self._flow_template_enricher = flow_template_enricher

    @transaction
    def get_assessment_plans_menu(self, project_uid):
        aps = self._existing_logic(project_uid)
        ap_ids = [ap.id for ap in aps]
        enrichments = self._flow_template_enricher.enrich_many(ap_ids)
        for ap in aps:
            ap.flow_template = enrichments.get(ap.id)
        return aps
```

Detail endpoint 類似處理。

- [ ] **Step 4: DI wiring**

`di_containers/grc/grc_container.py`：

```python
flow_template_enricher = providers.Factory(
    FlowTemplateEnricher,
    ap_extension_domain_service=...,
    workflow_template_service=...,
    flow_template_domain_service=...,
)
project_service = providers.Factory(
    ProjectService,
    # ...
    flow_template_enricher=flow_template_enricher,
)
```

- [ ] **Step 5: Run tests — should pass**

```bash
pytest test/test_grc_assessment_plan_api.py -v
```

Expected: 2 PASS

- [ ] **Step 6: 重啟 BE + manual smoke**

```bash
# Kill stale (memory: backend_restart_orphan_pids)
lsof -i :8000  # 確認 listener
pkill -9 -f main_socketio.py
python main_socketio.py > /dev/null 2>&1 &

curl -s -H "Authorization: Bearer <token>" http://localhost:8000/grc/project/<uid>/assessment-plans/menu | jq '.data[0].flow_template'
```

Expected: 印出 `{snapshot_uid, master_uid, master_name, is_master_active}` 完整 payload

- [ ] **Step 7: Commit**

```bash
git add app/grc/service/project_service.py di_containers/grc/grc_container.py test/test_grc_assessment_plan_api.py
git commit -m "feat(spec3 M2): AP list/detail API enrich with flow_template payload"
```

### Task M2.5: Changelog M2

- [ ] **Step 1: Create changelog**

`docs/changelog/2026-05-13-feat-ap-flow-template-enrichment.md`:

```markdown
---
type: feat
modules: [grc, flow_engine]
issue: docs/features/FR-026-2605-project-flow-engine-integrate/03-ap-binding/design.md (§9.6)
---

# AP list + detail API 新增 flow_template enrichment

## 變更
- `GET /grc/project/<uid>/assessment-plans/menu` 回 `flow_template: {snapshot_uid, master_uid, master_name, is_master_active}`（每筆 AP）
- `GET /grc/project/<uid>/ap/<ap_uid>` 同上
- 行為 lenient：master 軟刪後 `is_master_active=false` 不擋 API（進行中 AP 仍可跑完）
- 加 `FlowTemplateEnricher` 共用 helper + 三段 batch query 防 N+1

## API 差異
（before / after JSON 範例）

## 測試
- 2 integration test + 4 unit test 全 pass
```

- [ ] **Step 2: Commit changelog**

```bash
git add docs/changelog/2026-05-13-feat-ap-flow-template-enrichment.md
git commit -m "docs(spec3 M2): changelog for AP flow_template enrichment"
```

---

## M3 — Spec 2 Carry-forward Test Gap 補測試

> **理由**：M4 改 `_bind_main_workflow_to_ap` 會影響 `WorkflowTemplateSnapshotService` / `StageAdvanceService` 等。先 M3 補測試，M4 出 regression 立刻 fail。

### Task M3.1: `WorkflowTemplateSnapshotService` unit test

**Files:**
- Create: `test/test_workflow_template_snapshot_service.py`

- [ ] **Step 1: 寫 failing tests**

```python
"""Spec 3 M3 carry-forward — WorkflowTemplateSnapshotService unit tests."""
import pytest
from unittest.mock import MagicMock
from app.flow_engine.service.workflow_template_snapshot_service import (
    WorkflowTemplateSnapshotService,
)


class TestWorkflowTemplateSnapshotService:
    def test_clone_master_as_snapshot_returns_new_uid(self):
        engine_service = MagicMock()
        engine_service.add_workflow_template.return_value = MagicMock(uid="new-uid-1", id=99)
        service = WorkflowTemplateSnapshotService(engine_service)

        result = service.clone_master_as_snapshot(
            master_uid="master-uid-1",
            master_name="完整稽核流程",
            xml="<bpmn>...</bpmn>",
            description="desc",
            curr_user="test-user",
        )

        assert result.uid == "new-uid-1"
        engine_service.add_workflow_template.assert_called_once()
        kwargs = engine_service.add_workflow_template.call_args.kwargs
        assert kwargs["type"] == "MAIN_PROCESS"
        assert kwargs["version"] == "1.0.0"
        assert kwargs["provider"] == "snapshot"
        assert kwargs["source_template_uid"] == "master-uid-1"
        assert kwargs["xml"] == "<bpmn>...</bpmn>"

    def test_clone_two_times_returns_different_uids(self):
        engine_service = MagicMock()
        engine_service.add_workflow_template.side_effect = [
            MagicMock(uid="snap-1"),
            MagicMock(uid="snap-2"),
        ]
        service = WorkflowTemplateSnapshotService(engine_service)

        r1 = service.clone_master_as_snapshot(
            master_uid="m", master_name="n", xml="<x/>", curr_user="u"
        )
        r2 = service.clone_master_as_snapshot(
            master_uid="m", master_name="n", xml="<x/>", curr_user="u"
        )

        assert r1.uid != r2.uid

    def test_clone_uses_master_name_as_prefix(self):
        engine_service = MagicMock()
        engine_service.add_workflow_template.return_value = MagicMock()
        service = WorkflowTemplateSnapshotService(engine_service)

        service.clone_master_as_snapshot(
            master_uid="m-uid", master_name="完整稽核流程",
            xml="<x/>", curr_user="u"
        )

        kwargs = engine_service.add_workflow_template.call_args.kwargs
        assert kwargs["name"].startswith("完整稽核流程 (snapshot ")
```

- [ ] **Step 2: Run — should fail OR pass depending on existing state**

```bash
pytest test/test_workflow_template_snapshot_service.py -v
```

Expected: PASS（Spec 2 service 已實作）— 若 FAIL 表示有 regression 要先 fix

- [ ] **Step 3: Commit**

```bash
git add test/test_workflow_template_snapshot_service.py
git commit -m "test(spec3 M3): WorkflowTemplateSnapshotService unit tests (carry-forward from Spec 2)"
```

### Task M3.2: `StageAdvanceService` unit test

**Files:**
- Create: `test/test_stage_advance_service.py`

- [ ] **Step 1: 識別 StageAdvanceService 公開 method**

```bash
grep -n "def " app/flow_engine/service/stage_advance_service.py | grep -v "^.*def _"
```

- [ ] **Step 2: 為每個 public method 寫至少一個 happy-path + 一個 error-path test**

```python
"""Spec 3 M3 carry-forward — StageAdvanceService unit tests."""
import pytest
from unittest.mock import MagicMock
from app.flow_engine.service.stage_advance_service import StageAdvanceService


class TestStageAdvanceService:
    def test_advance_calls_on_complete_handler(self):
        # arrange: mock workflow_execution / handlers / preconditions
        # act: service.advance(ap_uid, stage_code, ...)
        # assert: handler 被叫到、status 推進
        ...

    def test_advance_raises_when_precondition_fails(self):
        # arrange: precondition returns False
        # act + assert: raise PreconditionFailedError(GRC_STAGE_PRECONDITION_FAILED)
        ...

    def test_advance_raises_when_no_next_node(self):
        # arrange: BPMN parser returns no outgoing flow
        # act + assert: raise BadRequest(GRC_STAGE_NO_NEXT_NODE)
        ...
```

- [ ] **Step 3: Run + Commit**

```bash
pytest test/test_stage_advance_service.py -v
git add test/test_stage_advance_service.py
git commit -m "test(spec3 M3): StageAdvanceService unit tests (carry-forward from Spec 2)"
```

### Task M3.3: `oscal_stage_preconditions` 個別 function unit test

**Files:**
- Create: `test/test_oscal_stage_preconditions.py`

- [ ] **Step 1: 識別所有 precondition function**

```bash
grep -n "^def " app/flow_engine/service/oscal_stage_preconditions.py
```

- [ ] **Step 2: 為每個 function 寫至少一個 True / 一個 False case**

範例（`all_tasks_completed`）：

```python
def test_all_tasks_completed_true_when_all_closed():
    ap = MagicMock(tasks=[MagicMock(status="closed"), MagicMock(status="closed")])
    assert all_tasks_completed(ap) is True

def test_all_tasks_completed_false_when_any_pending():
    ap = MagicMock(tasks=[MagicMock(status="closed"), MagicMock(status="pending")])
    assert all_tasks_completed(ap) is False
```

- [ ] **Step 3: Run + Commit**

### Task M3.4: `oscal_stage_handlers` 個別 handler unit test

**Files:**
- Create: `test/test_oscal_stage_handlers.py`

- [ ] **Step 1: 為每個 handler 寫 happy / error path test**

範例（`activate_handler`）：

```python
def test_activate_handler_calls_oscal_api(...):
    ...

def test_activate_handler_raises_when_oscal_returns_error(...):
    ...
```

- [ ] **Step 2: Run + Commit**

### Task M3.5: `AssessmentPlanExtensionRepoImpl` unit test

**Files:**
- Create: `test/test_assessment_plan_extension_repo_impl.py`

- [ ] **Step 1: 寫 upsert + 各種查詢 path 的 test**

```python
def test_upsert_creates_when_not_exists(): ...
def test_upsert_updates_when_exists(): ...
def test_get_by_assessment_plan_id_returns_none_when_missing(): ...
def test_get_by_assessment_plan_ids_batch_returns_dict(): ...
```

- [ ] **Step 2: Run + Commit**

### Task M3.6: Changelog M3

- [ ] **Step 1: Create changelog**

`docs/changelog/2026-05-13-tweak-spec2-carry-forward-tests.md`:

```markdown
---
type: tweak
modules: [flow_engine, infra.flow_engine]
issue: docs/features/FR-026-2605-project-flow-engine-integrate/03-ap-binding/design.md (§13 M3)
---

# 補 Spec 2 carry-forward 5 service test gap

## 變更
- 新增 5 個測試檔，覆蓋 Spec 2 review 標的 5 service
- 為 M4 改動鋪測試安全網

## 測試
- WorkflowTemplateSnapshotService: 3 tests
- StageAdvanceService: N tests
- oscal_stage_preconditions: N tests
- oscal_stage_handlers: N tests
- AssessmentPlanExtensionRepoImpl: N tests
```

- [ ] **Step 2: Commit**

---

## M4 — BE Core: `_bind_main_workflow_to_ap` 改造 + 兩條 entry 加 `flow_template_uid`

### Task M4.1: `StartProjectDto` 加 `flow_template_uid` 欄位

**Files:**
- Modify: `app/project/dto/project_dto.py` (line 找 `class StartProjectDto`)

- [ ] **Step 1: 加欄位**

```python
@dataclass
class StartProjectDto:
    # ...既有欄位...
    flow_template_uid: str = ""   # required by route schema；service 內檢查非空
```

- [ ] **Step 2: 加 entry test — service 必填驗證**

`test/test_oscal_project_service_flow_template.py`:

```python
def test_start_oscal_project_raises_when_flow_template_uid_empty(...):
    dto = StartProjectDto(name="x", profile_uid="p", flow_template_uid="")
    with pytest.raises(BadRequestError) as exc:
        service.start_oscal_project(dto, ...)
    assert "flow_template_uid" in str(exc.value)
```

- [ ] **Step 3: Run — FAIL**

- [ ] **Step 4: Service 簽章 + 加 validation**

`app/project/service/oscal_project_service.py:151`：

```python
@transaction
def start_oscal_project(
    self,
    start_project_dto: StartProjectDto,
    curr_user: str,
    curr_user_uid: str,
    curr_user_id: int = None,
    locale: str = None,
) -> ProjectDTO:
    if not start_project_dto.flow_template_uid:
        raise BadRequestError(GrcErrorCode.GRC_FLOW_TEMPLATE_UID_REQUIRED)
    # ...既有 steps...
```

注意：先別動 step 22；那部分到 M4.4 一起改。

- [ ] **Step 5: 加 error code**

`common/code/grc_error_code.py`（兩個新 code）：

```python
GRC_FLOW_TEMPLATE_UID_REQUIRED = ("缺少流程範本參數",      "GRC_400054")
GRC_FLOW_BINDING_DI_MISSING    = ("流程綁定服務未注入（部署錯誤）", "GRC_412025")
```

`GRC_FLOW_BINDING_DI_MISSING` 對應 M4.4 helper 內 DI null check 的 raise — 412 而非 500 表「server config 待修，重啟修好即可」非「不可恢復錯誤」。

- [ ] **Step 6: Run — PASS**

- [ ] **Step 7: Commit**

```bash
git add app/project/dto/project_dto.py app/project/service/oscal_project_service.py common/code/grc_error_code.py test/test_oscal_project_service_flow_template.py
git commit -m "feat(spec3 M4): StartProjectDto + start_oscal_project 加 flow_template_uid required validation"
```

### Task M4.2: `start_oscal_project` Route schema 加 `flow_template_uid`

**Files:**
- Modify: `api/project/routes/project_route.py`（找 start project schema）

- [ ] **Step 1: 找 route schema**

```bash
grep -rn "StartProjectRequestSchema\|class.*StartProject.*Schema" api/project/
```

- [ ] **Step 2: 加欄位**

```python
class StartProjectRequestSchema(Schema):
    name = fields.String(required=True)
    profile_uid = fields.String(required=True)
    # ...
    flow_template_uid = fields.UUID(required=True)  # 新增
```

- [ ] **Step 3: Integration test**

```python
def test_post_oscal_projects_start_400_when_no_flow_template_uid(app_client, blsit_headers):
    resp = app_client.post(
        "/oscal/projects/start",
        json={"name": "x", "profile_uid": "...", ...},  # 無 flow_template_uid
        headers=blsit_headers,
    )
    assert resp.status_code == 400
```

- [ ] **Step 4: Run + Commit**

```bash
pytest test/test_post_oscal_projects_start.py -v
git add api/project/routes/project_route.py test/
git commit -m "feat(spec3 M4): /oscal/projects/start 必填 flow_template_uid"
```

### Task M4.3: `launch_new_round` Route schema + service 簽章加 `flow_template_uid`

**Files:**
- Modify: `api/grc/serializers/audit.py:182` (LaunchNewRoundRequestSchema)
- Modify: `api/grc/routes/audit_route.py:349` (LaunchNewRoundResource)
- Modify: `app/project/service/oscal_project_service.py:604` (launch_new_round)

- [ ] **Step 1: 加 schema field**

`api/grc/serializers/audit.py`:

```python
class LaunchNewRoundRequestSchema(Schema):
    title = fields.String(required=True)
    flow_template_uid = fields.UUID(required=True)  # 新增
    start_date = fields.Date(load_default=None, allow_none=True)
    end_date = fields.Date(load_default=None, allow_none=True)
```

- [ ] **Step 2: 改 route 傳參**

`api/grc/routes/audit_route.py:349` 內 use_kwargs 解 body 後傳給 service 時加 `flow_template_uid=data["flow_template_uid"]`

- [ ] **Step 3: 改 service 簽章**

```python
@transaction
def launch_new_round(
    self,
    project_uid: str,
    user_id: int,
    title: str,
    flow_template_uid: str,   # 新增
    start_date=None,
    end_date=None,
    curr_user: str = None,
    locale: str = None,
) -> dict:
    if not flow_template_uid:
        raise BadRequestError(GrcErrorCode.GRC_FLOW_TEMPLATE_UID_REQUIRED)
    # ...既有 steps 1–9 不動...
    # step 10 - to be added in M4.4
```

- [ ] **Step 4: Integration test**

```python
def test_post_launch_new_round_400_when_no_flow_template_uid(...):
    resp = app_client.post(
        f"/grc/project/{uid}/launch-new-round",
        json={"title": "Round 2"},
        headers=blsit_headers,
    )
    assert resp.status_code == 400
```

- [ ] **Step 5: Run + Commit**

```bash
git add api/grc/ app/project/service/oscal_project_service.py test/
git commit -m "feat(spec3 M4): /grc/project/<uid>/launch-new-round 必填 flow_template_uid"
```

### Task M4.4: `_bind_main_workflow_to_ap` 改造 + 接 `launch_new_round`

**Files:**
- Modify: `app/project/service/oscal_project_service.py:492-543` (`_bind_main_workflow_to_ap`)
- Modify: `app/project/service/oscal_project_service.py:151` (`start_oscal_project` step 22 call)
- Modify: `app/project/service/oscal_project_service.py:604` (`launch_new_round` 加 step 10)

- [ ] **Step 1: 寫 integration test — 兩條 entry 都該綁 main workflow**

```python
def test_start_oscal_project_binds_main_workflow_with_user_specified_template(
    app_client, blsit_headers, profile_fixture, flow_template_a
):
    resp = app_client.post(
        "/oscal/projects/start",
        json={
            "name": "Test Project",
            "profile_uid": profile_fixture.uid,
            "flow_template_uid": flow_template_a.uid,
        },
        headers=blsit_headers,
    )
    assert resp.status_code == 200
    project_uid = resp.json["data"]["uid"]

    # 驗 ap_extensions.flow_template_snapshot_uid 存在且不等於 master uid
    ext = session.query(AssessmentPlanExtension).filter_by(...).first()
    assert ext.flow_template_snapshot_uid is not None
    assert ext.flow_template_snapshot_uid != flow_template_a.uid

    # 驗 snapshot.source_template_uid == master uid
    snap = session.query(WorkflowTemplate).filter_by(uid=ext.flow_template_snapshot_uid).first()
    assert snap.source_template_uid == flow_template_a.uid

def test_launch_new_round_binds_main_workflow_independently(
    app_client, blsit_headers, project_with_closed_ap1, flow_template_b
):
    """Spec 2 gap fix — launch_new_round 也要綁 main workflow，跟第一輪 snapshot 不同。"""
    resp = app_client.post(
        f"/grc/project/{project_with_closed_ap1.uid}/launch-new-round",
        json={"title": "Round 2", "flow_template_uid": flow_template_b.uid},
        headers=blsit_headers,
    )
    assert resp.status_code == 200
    ap2_uid = resp.json["data"]["ap_uid"]

    ap1_ext = session.query(AssessmentPlanExtension).filter_by(assessment_plan_id=ap1_id).first()
    ap2_ext = session.query(AssessmentPlanExtension).filter_by(assessment_plan_id=ap2_id).first()

    assert ap1_ext.flow_template_snapshot_uid != ap2_ext.flow_template_snapshot_uid
    assert ap1_ext.workflow_execution_uid != ap2_ext.workflow_execution_uid

def test_invalid_flow_template_uid_returns_404(...):
    resp = app_client.post(
        "/oscal/projects/start",
        json={"name": "x", "profile_uid": "p", "flow_template_uid": "00000000-0000-0000-0000-000000000000"},
        headers=blsit_headers,
    )
    assert resp.status_code == 404
    assert resp.json["msg"].startswith("流程範本不存在")

def test_soft_deleted_flow_template_returns_404(...):
    # arrange: flow_template_a is_active=False
    # ...
    assert resp.status_code == 404
```

- [ ] **Step 2: Run — should FAIL**

Expected: FAIL because service 還沒接 `flow_template_uid` 到 `_bind_main_workflow_to_ap`

- [ ] **Step 3: 改 `_bind_main_workflow_to_ap`**

```python
def _bind_main_workflow_to_ap(
    self,
    ap,
    project_uid: str,
    flow_template_uid: str,   # 新增 required
    curr_user: str,
) -> None:
    """Spec 3 — 用 caller 指定的 flow_template_uid 取 master，clone 一份 per-AP
    凍結 snapshot 到 engine 表，啟動 workflow_execution，寫 assessment_plan_extensions。

    Caller 已開 @transaction scope；本 helper 不再加。
    """
    # DI 缺失視為 server misconfig（部署錯）— 不該 silent skip 讓 AP 建得起來但
    # Banner 後續永遠 404。沿用 Spec 2 step 22 review fix H2 的做法 raise 412
    # 讓 user / dev 立刻看到 root cause。
    # 用既有 GRC_AP_NO_WORKFLOW_BINDING (404029) 不準確（語意是「AP 沒綁 workflow」
    # 而非「部署層 DI 壞」）。Spec 3 新增專用 code：
    if (self._flow_template_domain_service is None
            or self._ap_extension_domain_service is None
            or self._workflow_template_snapshot_service is None):
        raise PreconditionFailedError(GrcErrorCode.GRC_FLOW_BINDING_DI_MISSING)

    master = self._flow_template_domain_service.get_by_uid(flow_template_uid)
    if master is None or not master.is_active:
        raise NotFound(GrcErrorCode.GRC_FLOW_TEMPLATE_NOT_FOUND)

    snapshot = self._workflow_template_snapshot_service.clone_master_as_snapshot(
        master_uid=str(master.uid),
        master_name=master.name,
        xml=master.bpmn_xml,
        description=master.description,
        curr_user=curr_user,
    )

    main_wf = self.workflow_execution_service.start_workflow_execution(
        template_id=snapshot.id,
        name=f"AP-{ap.uid}-main",
        params={"ap_uid": str(ap.uid), "project_uid": project_uid},
        created_user=curr_user,
        description=f"AP main workflow for {ap.title}",
    )

    self._ap_extension_domain_service.upsert(
        assessment_plan_id=ap.id,
        workflow_execution_uid=str(main_wf.uid),
        flow_template_snapshot_uid=str(snapshot.uid),
        curr_user=curr_user,
    )
    logger.info(
        f"[bind_main_workflow] ap_uid={ap.uid} bound to "
        f"workflow_execution_uid={main_wf.uid} snapshot_uid={snapshot.uid} "
        f"master_uid={master.uid}"
    )
```

- [ ] **Step 4: 改 `start_oscal_project` 呼叫處**

`app/project/service/oscal_project_service.py:487`：

```python
self._bind_main_workflow_to_ap(
    ap=ap,
    project_uid=str(project.uid),
    flow_template_uid=start_project_dto.flow_template_uid,  # 新增
    curr_user=curr_user,
)
```

- [ ] **Step 5: 改 `launch_new_round` — 加 step 10 呼叫 helper**

`app/project/service/oscal_project_service.py:754`（既有 return 之前）：

```python
# Step 10 (Spec 3) - 補 Spec 2 漏蓋：launch_new_round 也要綁 main workflow snapshot
self._bind_main_workflow_to_ap(
    ap=ap,
    project_uid=str(project_entity.uid),
    flow_template_uid=flow_template_uid,
    curr_user=curr_user,
)
```

- [ ] **Step 6: Run integration tests — PASS**

```bash
pytest test/test_oscal_project_service_flow_template.py -v
```

Expected: 4 tests PASS

- [ ] **Step 7: 重啟 BE smoke**

```bash
pkill -9 -f main_socketio.py && python main_socketio.py > /dev/null 2>&1 &
tail -f log/app.log
# 另開 terminal 試 curl
```

- [ ] **Step 8: Commit**

```bash
git add app/project/service/oscal_project_service.py test/
git commit -m "feat(spec3 M4): _bind_main_workflow_to_ap 接 flow_template_uid + launch_new_round 補綁 main workflow"
```

### Task M4.5: 拔除 Spec 2 hardcode default lookup

**Files:**
- Modify: `domain/flow_engine/service/flow_template_domain_service.py:30-36`
- Modify: `domain/flow_engine/repository/flow_template_repo.py:28`
- Modify: `infra/flow_engine/repository/flow_template_repo_impl.py:88`

- [ ] **Step 1: 確認 M4.4 後沒人 call default lookup**

```bash
grep -rn "get_default_main_workflow_template\|get_builtin_by_name\|DEFAULT_MAIN_WORKFLOW_TEMPLATE_NAME" --include="*.py"
```

Expected: 只剩定義處跟內部呼叫，沒有外部 caller

- [ ] **Step 2: 刪 domain service**

`domain/flow_engine/service/flow_template_domain_service.py` — 刪 line 30-36（DEFAULT_MAIN_WORKFLOW_TEMPLATE_NAME 常數 + get_default_main_workflow_template method）

- [ ] **Step 3: 刪 repo interface**

`domain/flow_engine/repository/flow_template_repo.py:28` — 刪 `def get_builtin_by_name(...) ...`

- [ ] **Step 4: 刪 repo impl**

`infra/flow_engine/repository/flow_template_repo_impl.py:88` — 刪整個 `def get_builtin_by_name(...)` 實作 body

- [ ] **Step 5: Run 全 BE pytest**

```bash
pytest test/ -v 2>&1 | tail -30
```

Expected: 全綠

- [ ] **Step 6: Smoke 確認 import 沒壞**

```bash
python -c "from app.project.service.oscal_project_service import OscalProjectService; print('ok')"
```

- [ ] **Step 7: Commit**

```bash
git add domain/flow_engine/ infra/flow_engine/
git commit -m "tweak(spec3 M4): 拔除 Spec 2 過渡 hardcode default workflow lookup"
```

### Task M4.6: Changelog M4

- [ ] **Step 1: Create changelog**

`docs/changelog/2026-05-13-feat-spec3-flow-template-required.md`:

```markdown
---
type: feat
breaking: true
modules: [project, grc, flow_engine]
issue: docs/features/FR-026-2605-project-flow-engine-integrate/03-ap-binding/design.md (§9.1-9.5)
---

# Spec 3：兩條 entry 必填 flow_template_uid + launch_new_round 補綁 main workflow

## 變更
- POST /oscal/projects/start 必填 `flow_template_uid` (UUID)
- POST /grc/project/<uid>/launch-new-round 必填 `flow_template_uid` (UUID)
- launch_new_round 現在會綁 main workflow snapshot（補 Spec 2 漏蓋）
- 拔除 hardcode default lookup（FlowTemplateDomainService.get_default_main_workflow_template / DEFAULT_MAIN_WORKFLOW_TEMPLATE_NAME / IFlowTemplateRepo.get_builtin_by_name 全刪）

## Breaking
- 既有 FE 呼叫不帶 `flow_template_uid` 會 400；本 commit 與 FE M5/M6 必須一起部署
- 若 dev DB 既有 round-1 AP 有效運作（snapshot 已建好），不需 backfill；舊 AP 仍用舊 snapshot

## 測試
- 4 integration test 全 pass（含跨 round snapshot 隔離驗證）
```

- [ ] **Step 2: Commit**

---

## M5 — FE ProjectCreateView Dropdown + Preview

> 切到 FE repo 工作。

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
```

### Task M5.1: FlowTemplateService 確認 list API 可呼叫

**Files:** 純驗證

- [ ] **Step 1: 找 Spec 1 既有 service**

```bash
grep -rn "flow-engine/flow-templates" src/ | head -5
```

Expected: 看到 `src/service/FlowTemplateService.js` 或類似 file 已包好 list / detail

- [ ] **Step 2: 確認 API 可工作**

```bash
# 用瀏覽器 DevTool 在 /flow/template-manage 頁面手動觀察 list API call
```

### Task M5.2: ProjectCreateView 加流程範本 dropdown

**Files:**
- Modify: `src/views/project/ProjectCreateView.vue`
- Modify: `src/components/grc/ProjectBasicInfoForm.vue`（如果 dropdown 加在這裡）

- [ ] **Step 1: 套用 M0 Step 8 verification 結論**

M0 已 grep 完 ProjectCreateView.vue / ProjectBasicInfoForm.vue，dropdown 落點已定。
若 M0 結論：dropdown 在 ProjectBasicInfoForm，則 modify `src/components/grc/ProjectBasicInfoForm.vue`；
若結論：在 ProjectCreateView inline，則 modify `src/views/project/ProjectCreateView.vue`。
本 plan 範例以 ProjectBasicInfoForm 為例（若實際是 inline，把 form/v$ 改成 props 取代）。

- [ ] **Step 2: 加 form field + state**

`<script setup>` 部分：

```js
import { ref, computed, onMounted } from 'vue'
import FlowTemplateService from '@/service/FlowTemplateService'
import FlowTemplatePreviewDialog from '@/views/flow-template/components/FlowTemplatePreviewDialog.vue'

const flowTemplates = ref([])
const loadingTemplates = ref(false)
const previewVisible = ref(false)

const loadFlowTemplates = async () => {
  loadingTemplates.value = true
  try {
    const res = await FlowTemplateService.getList({ is_active: true })
    flowTemplates.value = res.data || []
  } finally {
    loadingTemplates.value = false
  }
}

onMounted(loadFlowTemplates)
```

template 部分（加在 profile_uid dropdown 下方）：

```vue
<div class="field">
  <label for="flow-template" class="required">第一輪稽核計畫流程範本</label>
  <div class="flex gap-2">
    <Dropdown
      id="flow-template"
      v-model="form.flowTemplateUid"
      :options="flowTemplates"
      option-label="name"
      option-value="uid"
      :loading="loadingTemplates"
      :placeholder="$t('project.flowTemplate.placeholder')"
      :class="{ 'p-invalid': v$.flowTemplateUid.$error }"
      class="flex-1"
    />
    <Button
      icon="pi pi-search"
      :label="$t('project.flowTemplate.detailBtn')"
      text
      size="small"
      :disabled="!form.flowTemplateUid"
      @click="previewVisible = true"
    />
  </div>
  <small v-if="v$.flowTemplateUid.$error" class="p-error">
    {{ $t('project.flowTemplate.requiredError') }}
  </small>
</div>

<FlowTemplatePreviewDialog
  v-model:visible="previewVisible"
  :template-uid="form.flowTemplateUid"
/>
```

- [ ] **Step 3: 加 vee-validate / vuelidate rule**

```js
const rules = computed(() => ({
  // ...
  flowTemplateUid: { required: helpers.withMessage(t('project.flowTemplate.requiredError'), required) },
}))
```

- [ ] **Step 4: 改送 API payload**

確認 submit handler 把 `form.flowTemplateUid` 放到 POST body：

```js
await ProjectService.createProject({
  name: form.name,
  profile_uid: form.profileUid,
  // ...
  flow_template_uid: form.flowTemplateUid,  // 新增
})
```

- [ ] **Step 5: i18n key**

`src/i18n/locales/zh-TW.json`:

```json
"project": {
  "flowTemplate": {
    "label": "流程範本",
    "firstRoundLabel": "第一輪稽核計畫流程範本",
    "placeholder": "請選擇流程範本",
    "detailBtn": "詳細選擇",
    "requiredError": "請選擇流程範本",
    "inheritedHint": "預設沿用上一輪稽核計畫的流程範本"
  }
}
```

`en.json` 對應補。

- [ ] **Step 6: Manual smoke**

```bash
npm run dev
# 開 http://localhost:5173/project/projects/new
# 走完整 wizard，看 dropdown / 預覽 Dialog
# 提交 → 確認後端 200 + ap_extensions 有 row
```

- [ ] **Step 7: Commit**

```bash
git add src/views/project/ src/components/grc/ src/i18n/
git commit -m "feat(spec3 M5): ProjectCreateView 加流程範本 dropdown + 預覽 Dialog + i18n"
```

### Task M5.3: Changelog M5

- [ ] **Step 1: Create changelog**

依 M0 Step 7 verification 結論：
- 若 FE repo 有 `docs/changelog/` → 在該 repo create `2026-05-13-feat-project-create-flow-template.md`
- 若無 → 在 BE repo `docs/changelog/2026-05-13-feat-spec3-flow-template-required.md`（M4.6 那筆）追加 M5 段，標 `modules: [project, frontend]`

---

## M6 — FE ProjectApListView Dialog Dropdown + Default 帶值

### Task M6.1: ProjectApListView「新增稽核計畫」Dialog 加 dropdown + default

**Files:**
- Modify: `src/views/project/ProjectApListView.vue`

- [ ] **Step 1: 看現有 Dialog 結構**

```bash
grep -n "新增稽核計畫\|newRound\|launchNewRound\|launch-new-round\|launch_new_round" src/views/project/ProjectApListView.vue | head
```

- [ ] **Step 2: 加 dropdown + default 帶值 computed**

`<script setup>`：

```js
import FlowTemplateService from '@/service/FlowTemplateService'
import FlowTemplatePreviewDialog from '@/views/flow-template/components/FlowTemplatePreviewDialog.vue'

const flowTemplates = ref([])
const previewVisible = ref(false)
const newRoundForm = ref({
  title: '',
  flowTemplateUid: null,
  startDate: null,
  endDate: null,
})

// Default 帶值（依賴 M2 — apList[0].flow_template enrichment）
const defaultFlowTemplateUid = computed(() => {
  const latest = props.apList?.[0]
  return latest?.flow_template?.master_uid ?? null
})

watch(() => dialogVisible.value, (v) => {
  if (v) {
    newRoundForm.value.flowTemplateUid = defaultFlowTemplateUid.value
  }
})

onMounted(async () => {
  flowTemplates.value = (await FlowTemplateService.getList({ is_active: true })).data
})
```

template Dialog body：

```vue
<div class="field">
  <label for="round-flow-template" class="required">
    {{ $t('project.flowTemplate.label') }}
  </label>
  <div class="flex gap-2">
    <Dropdown
      id="round-flow-template"
      v-model="newRoundForm.flowTemplateUid"
      :options="flowTemplates"
      option-label="name"
      option-value="uid"
      :placeholder="$t('project.flowTemplate.placeholder')"
      class="flex-1"
    />
    <Button
      icon="pi pi-search"
      :label="$t('project.flowTemplate.detailBtn')"
      text size="small"
      :disabled="!newRoundForm.flowTemplateUid"
      @click="previewVisible = true"
    />
  </div>
  <small v-if="defaultFlowTemplateUid && newRoundForm.flowTemplateUid === defaultFlowTemplateUid"
         class="text-muted">
    {{ $t('project.flowTemplate.inheritedHint') }}
  </small>
</div>

<FlowTemplatePreviewDialog
  v-model:visible="previewVisible"
  :template-uid="newRoundForm.flowTemplateUid"
/>
```

- [ ] **Step 3: 改 submit body**

```js
await ProjectApService.launchNewRound(projectUid, {
  title: newRoundForm.value.title,
  flow_template_uid: newRoundForm.value.flowTemplateUid,
  start_date: newRoundForm.value.startDate,
  end_date: newRoundForm.value.endDate,
})
```

- [ ] **Step 4: Manual smoke**

```bash
# /project/projects/<uid> 開啟 → 新增稽核計畫 Dialog
# 確認 dropdown default 帶到「最新一輪 AP 範本」
# 提交 → 後端 200 + ap_extensions 第二筆 row（不同 snapshot uid）
```

- [ ] **Step 5: Commit**

```bash
git add src/views/project/ProjectApListView.vue
git commit -m "feat(spec3 M6): ProjectApListView 新增稽核計畫 Dialog 加流程範本 dropdown + default 帶值"
```

---

## M7 — Manual Smoke（必過項）

### Task M7.1: 跑兩個不同範本的 end-to-end smoke

- [ ] **Step 1: 起 BE + FE 環境**

```bash
# BE
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
pkill -9 -f main_socketio.py && python main_socketio.py > /dev/null 2>&1 &
tail -f log/app.log &  # 監看
# FE
cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
npm run dev
```

- [ ] **Step 2: 建專案 A — 選範本「完整稽核流程」**

走 wizard 全程，AP1 建出來。

- [ ] **Step 3: 跑完 AP1 lifecycle**

依 Banner 推進 4 個 stage：
- preparing → active（推進按鈕：啟動專案）
- active → auditing（啟動稽核）
- auditing → remediation（提交稽核）
- remediation → closed（完成改善）

每個 stage 都驗：
- AP.status 對應
- Banner stage code 對應 BPMN 當前 UserTask

- [ ] **Step 4: launch_new_round — 選範本 B「自我評估稽核」**

確認 default 帶到「完整稽核流程」（最新一輪 master），改選 B。提交。

- [ ] **Step 5: 跑 AP2 lifecycle**

範本 B 是「規劃 → 執行 → 稽核 → End」少 poam stage。
驗 Banner stage 流轉：
- preparing → active → auditing → **closed**（跳過 remediation）

- [ ] **Step 6: DB 驗**

```sql
-- 兩列 ap_extensions
SELECT assessment_plan_id, flow_template_snapshot_uid, workflow_execution_uid
FROM compliance.assessment_plan_extensions
WHERE assessment_plan_id IN (
  SELECT id FROM oscal.assessment_plans WHERE project_id = <project_id>
);
-- → 兩 row，flow_template_snapshot_uid 互不相同

-- 兩列 snapshot
SELECT id, uid, name, source_template_uid, provider, version
FROM public.workflow_templates
WHERE uid IN (
  SELECT flow_template_snapshot_uid FROM compliance.assessment_plan_extensions
  WHERE assessment_plan_id IN (<ap1_id>, <ap2_id>)
);
-- → 兩 row，provider='snapshot'，source_template_uid 分別指向 master A / B

-- 兩個 workflow_executions
SELECT uid, name, template_id, status
FROM public.workflow_executions
WHERE uid IN (
  SELECT workflow_execution_uid FROM compliance.assessment_plan_extensions
  WHERE assessment_plan_id IN (<ap1_id>, <ap2_id>)
);
-- → 兩 row，互不相關
```

- [ ] **Step 7: Snapshot 隔離驗證**

```sql
-- 改 master A 的 BPMN XML（模擬 admin 編輯）
UPDATE compliance.flow_templates SET bpmn_xml = '<bpmn>...modified...</bpmn>'
WHERE name = '完整稽核流程';

-- 驗 snapshot row 不變
SELECT xml FROM public.workflow_templates WHERE uid = '<ap1_snapshot_uid>';
-- → 仍是 build 時的原 XML，不被 master 變更影響
```

- [ ] **Step 8: 若任一驗證 fail → 記到 changelog + issue**

按 CLAUDE.md「重大決策完成後自動歸檔思考過程」開 `docs/analysis/2026-05-13-spec3-smoke-failure-root-cause.md`

### Task M7.2: 收尾 changelog 補 M7 結果

`docs/changelog/2026-05-13-feat-spec3-flow-template-required.md` 加段：

```markdown
## 驗證結果（M7 smoke）
- Round 1（範本 A，4 stages 全跑）：✅
- Round 2（範本 B，跳 poam）：✅
- Snapshot 隔離（改 master 不影響 AP）：✅
- DB 驗證（兩 row ext / 兩 row snapshot / 兩 row workflow_execution）：✅
```

```bash
git add docs/changelog/2026-05-13-feat-spec3-flow-template-required.md
git commit -m "docs(spec3 M7): smoke 驗證結果補進 changelog"
```

---

## M8 — Phase E 4 Fixture Bug 修補（事後）

> **跨 repo 工作**，需切到 compliance-manager-test repo。本 plan 不展開，僅標範圍。
> 詳見：`docs/issues/pending/2026-05-13-test-fixture-broken-by-spec2-phase-d-and-fe-refactor.md`

- [ ] Bug A：修 `ProjectCreatePage.addFourParticipantsWithRoles`（適配 wizard 加範本 dropdown 後的 step 結構）
- [ ] Bug B：修 `launchProjectViaUi`（M4 補齊 main workflow binding 後應該綠）
- [ ] Bug C：修 `launchAuditViaUi`
- [ ] Bug D：修 `launchRemediationViaUi`
- [ ] 重寫 Spec 2 Banner 3 個 BDD scenario，含範本選擇步驟
- [ ] Move issue file `pending/` → `resolved/`，加 Resolution 段

---

## 全 Spec 3 task arc 收尾

### Task: 套件發版（user 明確同意才動）

- [ ] **若 M4 涉及 jedi-flow-engine 異動**：bump version + push Nexus + 主專案 `pyproject.toml` 改回 pin → `poetry update`；當前 path mode pyproject 變更撤回
- [ ] **若無 jedi 異動**：直接 commit pyproject 還原 path mode 那行

### Task: SUMMARY + conversation history

- [ ] `docs/conversation-history/2026-05-13-spec3-ap-binding/SUMMARY.md` 寫收尾報告（commit list / 行為差異 / follow-up）
- [ ] 拆 part-NN-of-NN（依 milestone 邊界）

### Task: 跨 repo review

- [ ] 跑 `pr-review-toolkit:review-pr` 對 BE / FE 兩個 PR
- [ ] 結果歸檔到 `docs/review/2026-05-13-spec3-review.md`

---

## 完成標準（Definition of Done）

- [ ] BE: M1–M4 全 commit + pytest 全綠
- [ ] FE: M5–M6 全 commit + manual smoke 通過
- [ ] M7 端到端 smoke 兩個範本各跑一輪 lifecycle 都通
- [ ] DB 驗證 3 條（兩 row ext / 兩 row snapshot / 兩 row workflow_execution）通過
- [ ] Spec 2 hardcode default 完全清除（grep 無 hit）
- [ ] 至少 3 個 changelog（tweak / feat / feat 三檔）
- [ ] 若 jedi-flow-engine 動到 → 套件 bump + Nexus（user 確認後執行）
