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:
Spec 來源: docs/features/FR-026-2605-project-flow-engine-integrate/03-ap-binding/design.md v2.1
docs/features/FR-026-2605-project-flow-engine-integrate/03-ap-binding/design.md — Spec 3 設計 v2.1(必讀,本 plan 對應其 M0–M8)docs/features/FR-026-2605-project-flow-engine-integrate/02-stage-integration/design.md — Spec 2 已 ship;§十 reconciliation 是 Spec 3 銜接點docs/features/FR-026-2605-project-flow-engine-integrate/01-flow-template-management/design.md — Spec 1 已 ship;範本列表 API + Preview Dialog 是依賴CLAUDE.md — 開發規範(DDD / @transaction / Error code / API patterns)docs/claude/jedi-packages.md — jedi-flow-engine API 參考docs/claude/frontend-overview.md — FE 設計 cheatsheet(design tokens / PrimeVue quirks / Audit Lifecycle UI)feature/project-flow-engine-integrate branch(HEAD = 5acceb0 之後)pyproject.toml 已切 jedi-flow-engine path mode(working tree modified,不 commit)python main_socketio.py 跑得起來(port 8000);日誌 log/app.logpsql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev(密碼 jedi@123!;migration 一律用 cmmgr 不用 cm_app)compliance-manager-fe 同 branch;npm run dev 起 5173blsadmin / Billows@123!(manual);blsit / Billows@123!(pytest)Files: 純檢查、無修改
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)
grep -rn "get_builtin_by_name" --include="*.py"Expected: 僅 3 個 hit(interface / impl / domain service 內部呼叫)
grep -rn "DEFAULT_MAIN_WORKFLOW_TEMPLATE_NAME" --include="*.py"Expected: 僅 1 個 hit(定義處)
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 一併處理。
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)。
poetry show jedi-flow-engine | grep -i locationExpected: 輸出 ~/Projects/Jedicogy/module/jedi-python-package/jedi-flow-engine 而非 .venv/lib/...
ls ~/Projects/Billows/Audit-Manager/compliance-manager-fe/docs/changelog/ 2>/dev/null | head -3Expected: 若資料夾存在 → FE 有獨立 changelog 慣例(M5/M6 各自寫一筆);若不存在 → FE 改動的 changelog 寫在 BE repo docs/changelog/ 標記 modules: [frontend]。
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。
GRC_FLOW_MAIN_TEMPLATE_NOT_FOUND + fix GRC_AR_DATA_MISSING 編號Files:
common/code/grc_error_code.py:198, 201sed -n '195,205p' common/code/grc_error_code.pyExpected: 看到 line 198 GRC_FLOW_MAIN_TEMPLATE_NOT_FOUND = ("找不到預設主流程範本(seed 缺失)", "GRC_412022") 跟 line 201 GRC_AR_DATA_MISSING = ("找不到稽核回合資料", "GRC_500001")
用 Edit tool 刪掉 common/code/grc_error_code.py:198 整行(含換行):
# 刪除這行
GRC_FLOW_MAIN_TEMPLATE_NOT_FOUND = ("找不到預設主流程範本(seed 缺失)", "GRC_412022")common/code/grc_error_code.py:201:
# Before
GRC_AR_DATA_MISSING = ("找不到稽核回合資料", "GRC_500001")
# After
GRC_AR_DATA_MISSING = ("找不到稽核回合資料", "GRC_412024")理由:HTTP 412 PreconditionFailed 不該配 500xxx 編號(違反 CLAUDE.md error code 命名規則)。
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 對象)。
python -c "from common.code.grc_error_code import GrcErrorCode; print(GrcErrorCode.GRC_AR_DATA_MISSING)"Expected: 印出 ("找不到稽核回合資料", "GRC_412024") 不會 ImportError
Create docs/changelog/2026-05-13-tweak-grc-error-code-cleanup.md:
---
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 命名規則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"flow_template enrichment 共用 helperFiles:
app/grc/service/_flow_template_enricher.pygrep -n "get_assessment_plans_menu\|get_assessment_plan_detail" app/grc/service/*.pyExpected: 在 app/grc/service/project_service.py 找到 method 定義
Create test/test_flow_template_enricher.py:
"""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])pytest test/test_flow_template_enricher.py -vExpected: FAIL with ImportError: cannot import name 'FlowTemplateEnricher'
Create app/grc/service/_flow_template_enricher.py:
"""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 resultpytest test/test_flow_template_enricher.py -vExpected: 4 tests PASS
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"Files:
domain/flow_engine/service/flow_template_domain_service.pydomain/flow_engine/repository/flow_template_repo.pyinfra/flow_engine/repository/flow_template_repo_impl.pydomain/ap_extension_* (path TBD by grep)grep -rn "AssessmentPlanExtensionDomainService\|assessment_plan_extensions" --include="*.py" -l | head -5注意每個 service 都應有對應 test/test_<service>.py;找不到就先 create file。完整 test bodies:
test/test_flow_template_domain_service.py:
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 resulttest/test_ap_extension_domain_service.py:
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 — 修改範圍跨套件):
def test_get_many_by_uids_returns_dict():
# 套件層級加 method,測試與上述同 pattern
...注意:若 WorkflowTemplateService 是 jedi-flow-engine 套件的 service,加
get_many_by_uidsmethod 是套件 API 異動。依 CLAUDE.md「外部套件異動規範」:開工前確認改動範圍 + dev 走 path mode(pyproject 已切)、feature 完成才 bump。本 task 改動是必要的(FlowTemplateEnricher batch query 需要),算合理範圍。
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 -vExpected: 3 FAIL with AttributeError: ... has no attribute 'get_many_by_uids' / 'get_by_assessment_plan_ids'
在每個 repo / domain service 加:
# 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:
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}Expected: 3 PASS
git add domain/ infra/ test/
git commit -m "feat(spec3 M2): batch query methods for FlowTemplate / AP extension / WorkflowTemplate"flow_template 欄位Files:
api/grc/serializers/assessment_plan.py:12-30# 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) # 新增git add api/grc/serializers/assessment_plan.py
git commit -m "feat(spec3 M2): add flow_template enrichment field to AssessmentPlanMenuResponseSchema"Files:
app/grc/service/project_service.py (get_assessment_plans_menu / detail)di_containers/grc/grc_container.py (wire FlowTemplateEnricher)test/test_grc_assessment_plan_api.py:
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"] == "完整稽核流程"Expected: FAIL KeyError: 'flow_template'(schema 加了但 service 還沒 enrich)
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 apsDetail endpoint 類似處理。
di_containers/grc/grc_container.py:
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,
)pytest test/test_grc_assessment_plan_api.py -vExpected: 2 PASS
# 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
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"docs/changelog/2026-05-13-feat-ap-flow-template-enrichment.md:
---
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 全 passgit add docs/changelog/2026-05-13-feat-ap-flow-template-enrichment.md
git commit -m "docs(spec3 M2): changelog for AP flow_template enrichment"理由:M4 改
_bind_main_workflow_to_ap會影響WorkflowTemplateSnapshotService/StageAdvanceService等。先 M3 補測試,M4 出 regression 立刻 fail。
WorkflowTemplateSnapshotService unit testFiles:
test/test_workflow_template_snapshot_service.py"""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 ")pytest test/test_workflow_template_snapshot_service.py -vExpected: PASS(Spec 2 service 已實作)— 若 FAIL 表示有 regression 要先 fix
git add test/test_workflow_template_snapshot_service.py
git commit -m "test(spec3 M3): WorkflowTemplateSnapshotService unit tests (carry-forward from Spec 2)"StageAdvanceService unit testFiles:
test/test_stage_advance_service.pygrep -n "def " app/flow_engine/service/stage_advance_service.py | grep -v "^.*def _""""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)
...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)"oscal_stage_preconditions 個別 function unit testFiles:
test/test_oscal_stage_preconditions.pygrep -n "^def " app/flow_engine/service/oscal_stage_preconditions.py範例(all_tasks_completed):
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 Falseoscal_stage_handlers 個別 handler unit testFiles:
test/test_oscal_stage_handlers.py範例(activate_handler):
def test_activate_handler_calls_oscal_api(...):
...
def test_activate_handler_raises_when_oscal_returns_error(...):
...AssessmentPlanExtensionRepoImpl unit testFiles:
test/test_assessment_plan_extension_repo_impl.pydef 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(): ...docs/changelog/2026-05-13-tweak-spec2-carry-forward-tests.md:
---
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_bind_main_workflow_to_ap 改造 + 兩條 entry 加 flow_template_uidStartProjectDto 加 flow_template_uid 欄位Files:
app/project/dto/project_dto.py (line 找 class StartProjectDto)@dataclass
class StartProjectDto:
# ...既有欄位...
flow_template_uid: str = "" # required by route schema;service 內檢查非空test/test_oscal_project_service_flow_template.py:
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)app/project/service/oscal_project_service.py:151:
@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 一起改。
common/code/grc_error_code.py(兩個新 code):
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 待修,重啟修好即可」非「不可恢復錯誤」。
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"start_oscal_project Route schema 加 flow_template_uidFiles:
api/project/routes/project_route.py(找 start project schema)grep -rn "StartProjectRequestSchema\|class.*StartProject.*Schema" api/project/class StartProjectRequestSchema(Schema):
name = fields.String(required=True)
profile_uid = fields.String(required=True)
# ...
flow_template_uid = fields.UUID(required=True) # 新增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 == 400pytest 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"launch_new_round Route schema + service 簽章加 flow_template_uidFiles:
api/grc/serializers/audit.py:182 (LaunchNewRoundRequestSchema)api/grc/routes/audit_route.py:349 (LaunchNewRoundResource)app/project/service/oscal_project_service.py:604 (launch_new_round)api/grc/serializers/audit.py:
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)api/grc/routes/audit_route.py:349 內 use_kwargs 解 body 後傳給 service 時加 flow_template_uid=data["flow_template_uid"]
@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.4def 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 == 400git 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"_bind_main_workflow_to_ap 改造 + 接 launch_new_roundFiles:
app/project/service/oscal_project_service.py:492-543 (_bind_main_workflow_to_ap)app/project/service/oscal_project_service.py:151 (start_oscal_project step 22 call)app/project/service/oscal_project_service.py:604 (launch_new_round 加 step 10)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 == 404Expected: FAIL because service 還沒接 flow_template_uid 到 _bind_main_workflow_to_ap
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}"
)app/project/service/oscal_project_service.py:487:
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,
)app/project/service/oscal_project_service.py:754(既有 return 之前):
# 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,
)pytest test/test_oscal_project_service_flow_template.py -vExpected: 4 tests PASS
pkill -9 -f main_socketio.py && python main_socketio.py > /dev/null 2>&1 &
tail -f log/app.log
# 另開 terminal 試 curlgit 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"Files:
domain/flow_engine/service/flow_template_domain_service.py:30-36domain/flow_engine/repository/flow_template_repo.py:28infra/flow_engine/repository/flow_template_repo_impl.py:88grep -rn "get_default_main_workflow_template\|get_builtin_by_name\|DEFAULT_MAIN_WORKFLOW_TEMPLATE_NAME" --include="*.py"Expected: 只剩定義處跟內部呼叫,沒有外部 caller
domain/flow_engine/service/flow_template_domain_service.py — 刪 line 30-36(DEFAULT_MAIN_WORKFLOW_TEMPLATE_NAME 常數 + get_default_main_workflow_template method)
domain/flow_engine/repository/flow_template_repo.py:28 — 刪 def get_builtin_by_name(...) ...
infra/flow_engine/repository/flow_template_repo_impl.py:88 — 刪整個 def get_builtin_by_name(...) 實作 body
pytest test/ -v 2>&1 | tail -30Expected: 全綠
python -c "from app.project.service.oscal_project_service import OscalProjectService; print('ok')"git add domain/flow_engine/ infra/flow_engine/
git commit -m "tweak(spec3 M4): 拔除 Spec 2 過渡 hardcode default workflow lookup"docs/changelog/2026-05-13-feat-spec3-flow-template-required.md:
---
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 隔離驗證)切到 FE repo 工作。
cd ~/Projects/Billows/Audit-Manager/compliance-manager-feFiles: 純驗證
grep -rn "flow-engine/flow-templates" src/ | head -5Expected: 看到 src/service/FlowTemplateService.js 或類似 file 已包好 list / detail
# 用瀏覽器 DevTool 在 /flow/template-manage 頁面手動觀察 list API callFiles:
src/views/project/ProjectCreateView.vuesrc/components/grc/ProjectBasicInfoForm.vue(如果 dropdown 加在這裡)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 取代)。
<script setup> 部分:
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 下方):
<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"
/>
const rules = computed(() => ({
// ...
flowTemplateUid: { required: helpers.withMessage(t('project.flowTemplate.requiredError'), required) },
}))確認 submit handler 把 form.flowTemplateUid 放到 POST body:
await ProjectService.createProject({
name: form.name,
profile_uid: form.profileUid,
// ...
flow_template_uid: form.flowTemplateUid, // 新增
})src/i18n/locales/zh-TW.json:
"project": {
"flowTemplate": {
"label": "流程範本",
"firstRoundLabel": "第一輪稽核計畫流程範本",
"placeholder": "請選擇流程範本",
"detailBtn": "詳細選擇",
"requiredError": "請選擇流程範本",
"inheritedHint": "預設沿用上一輪稽核計畫的流程範本"
}
}en.json 對應補。
npm run dev
# 開 http://localhost:5173/project/projects/new
# 走完整 wizard,看 dropdown / 預覽 Dialog
# 提交 → 確認後端 200 + ap_extensions 有 rowgit add src/views/project/ src/components/grc/ src/i18n/
git commit -m "feat(spec3 M5): ProjectCreateView 加流程範本 dropdown + 預覽 Dialog + i18n"依 M0 Step 7 verification 結論:
docs/changelog/ → 在該 repo create 2026-05-13-feat-project-create-flow-template.mddocs/changelog/2026-05-13-feat-spec3-flow-template-required.md(M4.6 那筆)追加 M5 段,標 modules: [project, frontend]Files:
src/views/project/ProjectApListView.vuegrep -n "新增稽核計畫\|newRound\|launchNewRound\|launch-new-round\|launch_new_round" src/views/project/ProjectApListView.vue | head<script setup>:
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:
<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"
/>
await ProjectApService.launchNewRound(projectUid, {
title: newRoundForm.value.title,
flow_template_uid: newRoundForm.value.flowTemplateUid,
start_date: newRoundForm.value.startDate,
end_date: newRoundForm.value.endDate,
})# /project/projects/<uid> 開啟 → 新增稽核計畫 Dialog
# 確認 dropdown default 帶到「最新一輪 AP 範本」
# 提交 → 後端 200 + ap_extensions 第二筆 row(不同 snapshot uid)git add src/views/project/ProjectApListView.vue
git commit -m "feat(spec3 M6): ProjectApListView 新增稽核計畫 Dialog 加流程範本 dropdown + default 帶值"# 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走 wizard 全程,AP1 建出來。
依 Banner 推進 4 個 stage:
每個 stage 都驗:
確認 default 帶到「完整稽核流程」(最新一輪 master),改選 B。提交。
範本 B 是「規劃 → 執行 → 稽核 → End」少 poam stage。 驗 Banner stage 流轉:
-- 兩列 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,互不相關-- 改 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 變更影響按 CLAUDE.md「重大決策完成後自動歸檔思考過程」開 docs/analysis/2026-05-13-spec3-smoke-failure-root-cause.md
docs/changelog/2026-05-13-feat-spec3-flow-template-required.md 加段:
## 驗證結果(M7 smoke)
- Round 1(範本 A,4 stages 全跑):✅
- Round 2(範本 B,跳 poam):✅
- Snapshot 隔離(改 master 不影響 AP):✅
- DB 驗證(兩 row ext / 兩 row snapshot / 兩 row workflow_execution):✅git add docs/changelog/2026-05-13-feat-spec3-flow-template-required.md
git commit -m "docs(spec3 M7): smoke 驗證結果補進 changelog"跨 repo 工作,需切到 compliance-manager-test repo。本 plan 不展開,僅標範圍。 詳見:
docs/issues/pending/2026-05-13-test-fixture-broken-by-spec2-phase-d-and-fe-refactor.md