Spec 4「審核階段 + 流程方向性驗證」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: 在既有 stage_object / BPMN flow template 機制上新增 review 階段(reviewer 主導 approve/reject),同時為範本 create/update 加方向性驗證(DAG 規則 + reverse edge 例外),並讓所有 stage advance 共用留言。

Architecture: 兩個子題共動 compliance.stage_objects schema 與 FlowTemplateAppService / StageAdvanceService 兩個入口。Review reverse 走既有 BPMN ExclusiveGateway + condition_param 機制(不改 jedi-flow-engine 套件);validator 是 pure function 易於 unit test;留言 reuse JobCommentService

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 / bpmn-js
  • DB: PostgreSQL 14 (compliance / public / oscal schemas with RLS)
  • 內部套件: jedi-flow-engine(不改,spec 4 範圍不動套件)

Spec 來源: docs/features/FR-026-2605-project-flow-engine-integrate/04-review-stage-and-flow-validation/design.md v1.1


§1

開工前必讀

  1. docs/features/FR-026-2605-project-flow-engine-integrate/04-review-stage-and-flow-validation/design.md — Spec 4 設計 v1.1(必讀
  2. docs/features/FR-026-2605-project-flow-engine-integrate/02-stage-integration/design.md — Spec 2 已 ship;§十 reconciliation 10 條落地偏差是 spec 4 銜接點
  3. docs/features/FR-026-2605-project-flow-engine-integrate/01-flow-template-management/design.md — Spec 1 已 ship;stage_object schema + validator 進入點
  4. docs/features/FR-026-2605-project-flow-engine-integrate/03-ap-binding/design.md — Spec 3 已 ship;binding pattern
  5. CLAUDE.md — DDD / @transaction / Error code / API patterns / changelog 規範
  6. docs/claude/jedi-packages.md — jedi-flow-engine API 參考(BpmnUtils / condition_param)
  7. docs/claude/frontend-overview.md — FE 設計 cheatsheet(design tokens / PrimeVue quirks)
§2

環境前置

  • BE: feature/project-flow-engine-review branch (HEAD = 4a782dc 之後)
  • BE: pyproject.toml jedi-flow-engine 已 pin 0.0.27(v1 不需 path mode,因不改套件)
  • 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
  • 測試: compliance-manager-test 跟同 branch
  • 測試帳號: blsadmin / Billows@123!(manual);blsit / Billows@123!(pytest)

§3

M0 — Pre-flight Verification

純檢查,不修改任何檔案。確認既有實作的假設是 plan 寫對。

Task M0.1: 確認 stage_objects 表結構

psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -c "\d compliance.stage_objects"

Expected: 含 id / uid / code / name_i18n / kind / route_pattern / complete_button_label_i18n / complete_handler_key / precondition_key / default_main_roles / is_builtin / is_active / sort / created_user / updated_user / created_at / updated_at 17 個欄位。can_start / can_end / allowed_predecessors / allowed_successors — 確認 M1 要加的 4 欄不存在。

psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -c \
  "SELECT id, code, kind, complete_handler_key FROM compliance.stage_objects ORDER BY sort;"

Expected: 4 列(planning / task_execution / audit / poam),無 review

Task M0.2: 確認 BPMN 既有結構

grep -A 2 "Flow_poam_audit" scripts/sql/seeds/bpmn/builtin-full-audit.bpmn

Expected: 找到 <bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit"/> <camunda:property name="reverse" value="true"/>。確認 M4 patch 是必要的。

python -c "from jedi_flow_engine.common.utils.bpmn_uilts import BpmnUtils; import inspect; print(inspect.getsourcefile(BpmnUtils))"

Expected: 印出 jedi-flow-engine 套件路徑。再 grep 該檔的 _parse_condition_param_to_string

grep -A 5 "_parse_condition_param_to_string" $(python -c "from jedi_flow_engine.common.utils.bpmn_uilts import BpmnUtils; import inspect; print(inspect.getsourcefile(BpmnUtils))")

Expected: 輸出 lowercase + Camunda ${} 格式(Spec 2 §十.9 修正後)。若不是,整個 reject 機制需先補套件 — 但這應該已是 0.0.27 既有行為。

Task M0.3: 確認 JobCommentService wiring(design §8.2 critical fix)

grep -A 2 "JobExecution\.uid ==" infra/grc/repository/grc_job_comment_repo_impl.py

Expected: 印出 .filter(JobExecution.uid == job_uid) — 確認 repo 期望 UUID,跟 design §8.2 一致。

python -c "
from jedi_flow_engine.common.utils.bpmn_uilts import BpmnUtils
import inspect
print(inspect.getsource(BpmnUtils.get_job_by_id))
"

Expected: 回傳的物件含 id 欄位(BPMN element string id,如 UserTask_review)。確認傳這個 id 到 JobCommentService 會 silent fail。

Task M0.4: 確認 di_containers wiring 點

sed -n '365,380p' di_containers/grc/grc_containers.py

Expected: 列出 8 個現有 dependency;job_comment_service 不在內 — 確認 M6 需加新 dep。

sed -n '378,395p' di_containers/grc/grc_containers.py

Expected: 看到 5 個 handler register + 4 個 precondition register。確認 M6 加 review_decision_handler() 註冊的位置。

Task M0.5: 確認 GRC error code 序號可用

grep "GRC_4006" common/code/grc_error_code.py

Expected: 0 hits。確認 GRC_400060 / 400061 / 400062 可用。


§4

M1 — Schema migration:stage_objects 加 4 欄 + seed review

Task M1.1: 寫 migration SQL(schema + seed + 規則回填)

Files:

  • Create: scripts/sql/2026-05-14-spec4-stage-objects-rules-and-review.sql
-- Date: 2026-05-14
-- Spec 4 — stage_objects 加 4 欄方向性驗證 + seed review stage
-- 對應 design.md §5

-- 1. compliance.stage_objects 加 4 欄 (2026-05-14)
ALTER TABLE compliance.stage_objects
    ADD COLUMN IF NOT EXISTS can_start BOOLEAN NOT NULL DEFAULT FALSE,
    ADD COLUMN IF NOT EXISTS can_end BOOLEAN NOT NULL DEFAULT TRUE,
    ADD COLUMN IF NOT EXISTS allowed_predecessors JSONB NOT NULL DEFAULT '[]'::jsonb,
    ADD COLUMN IF NOT EXISTS allowed_successors JSONB NOT NULL DEFAULT '[]'::jsonb;

-- 2. 4 個既有 stage 規則回填 (2026-05-14)
UPDATE compliance.stage_objects
   SET can_start = TRUE,
       can_end = TRUE,
       allowed_predecessors = '[]'::jsonb,
       allowed_successors = '["task_execution", "review", "audit", "poam"]'::jsonb
 WHERE code = 'planning';

UPDATE compliance.stage_objects
   SET can_start = FALSE,
       can_end = TRUE,
       allowed_predecessors = '["planning", "review"]'::jsonb,
       allowed_successors = '["audit", "review"]'::jsonb
 WHERE code = 'task_execution';

UPDATE compliance.stage_objects
   SET can_start = FALSE,
       can_end = TRUE,
       allowed_predecessors = '["task_execution", "review", "poam"]'::jsonb,
       allowed_successors = '["poam"]'::jsonb
 WHERE code = 'audit';

UPDATE compliance.stage_objects
   SET can_start = FALSE,
       can_end = TRUE,
       allowed_predecessors = '["audit"]'::jsonb,
       allowed_successors = '[]'::jsonb
 WHERE code = 'poam';

-- 3. Seed review stage (2026-05-14)
INSERT INTO compliance.stage_objects (
    code, name_i18n, kind, route_pattern,
    complete_button_label_i18n, complete_handler_key, precondition_key,
    default_main_roles, is_builtin, sort,
    can_start, can_end, allowed_predecessors, allowed_successors
) VALUES (
    'review',
    '{"zh_Hant_TW": "審核", "en": "Review"}'::jsonb,
    'stateful', NULL,
    '{"zh_Hant_TW": "送出審核", "en": "Submit Review"}'::jsonb,
    'review_decision', NULL,
    '["reviewer"]'::jsonb, TRUE, 25,
    FALSE, TRUE,
    '["planning", "task_execution"]'::jsonb,
    '["audit"]'::jsonb
) ON CONFLICT (code) DO NOTHING;

-- 4. 驗證 SQL(manual run,不影響 migration)
-- SELECT code, can_start, can_end, allowed_predecessors, allowed_successors
--   FROM compliance.stage_objects ORDER BY sort;
set -a && source .env && set +a
PGPASSWORD="$(echo $DB_SECRET | python -c 'import sys,json;print(json.load(sys.stdin)["rds_master_password"])')" \
  psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev \
  -f scripts/sql/2026-05-14-spec4-stage-objects-rules-and-review.sql

Expected: 4 個 ALTER TABLE + 4 個 UPDATE 1 + 1 個 INSERT 0 1若 INSERT 0 0 表示 review 已存在(M0.1 應該確認過不該發生)。

psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -c \
  "SELECT code, can_start, can_end, allowed_predecessors::text, allowed_successors::text FROM compliance.stage_objects ORDER BY sort;"

Expected: 5 列(planning sort=10 / task_execution sort=20 / review sort=25 / audit sort=30 / poam sort=40);4 欄值對應 design §5.2。

git add scripts/sql/2026-05-14-spec4-stage-objects-rules-and-review.sql
git commit -m "$(cat <<'EOF'
feat(spec4-M1): stage_objects 加方向性驗證 4 欄 + seed review stage

對應 spec 4 design.md §5:
- 加 can_start / can_end / allowed_predecessors / allowed_successors 4 欄
- 回填 4 個既有 stage 規則(planning 唯一 can_start=true)
- seed 第 5 個 stage 'review' (kind=stateful, default_main_roles=[reviewer])

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

§5

M2 — Domain entity / mapper / model 對應新欄位

Task M2.1: 更新 ORM model

Files:

  • Modify: infra/flow_engine/models/stage_object.py
    can_start: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否可作為流程起點")
    can_end: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="是否可直接接 EndEvent")
    allowed_predecessors: Mapped[list] = mapped_column(JSONB, nullable=False, default=list, comment="允許的 forward predecessor stage code 清單")
    allowed_successors: Mapped[list] = mapped_column(JSONB, nullable=False, default=list, comment="允許的 forward successor stage code 清單")

Task M2.2: 更新 Entity dataclass

Files:

  • Modify: domain/flow_engine/entity/stage_object_entity.py
    can_start: bool = False
    can_end: bool = True
    allowed_predecessors: list = field(default_factory=list)
    allowed_successors: list = field(default_factory=list)

Task M2.3: 更新 Mapper

Files:

  • Modify: infra/flow_engine/mapper/stage_object_mapper.py
            sort=m.sort,
            can_start=m.can_start,
            can_end=m.can_end,
            allowed_predecessors=m.allowed_predecessors or [],
            allowed_successors=m.allowed_successors or [],
            created_user=m.created_user,
        m.sort = e.sort
        m.can_start = e.can_start
        m.can_end = e.can_end
        m.allowed_predecessors = e.allowed_predecessors
        m.allowed_successors = e.allowed_successors
        m.created_user = e.created_user

Task M2.4: 重啟 BE 驗證

lsof -ti :8000 | xargs kill -9 2>/dev/null; sleep 1
nohup python main_socketio.py > /tmp/be.log 2>&1 &
sleep 3 && tail -20 log/app.log

Expected: 無 SQLAlchemy column 錯誤;INFO 級別 startup log。

TOKEN=$(curl -s -X POST http://localhost:8000/login \
  -H "Content-Type: application/json" \
  -d '{"login_name":"blsadmin","password":"Billows@123!"}' | python -c "import sys,json;print(json.load(sys.stdin)['data']['token'])")

curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8000/flow-engine/stage-objects | python -m json.tool | head -30

Expected: 回 5 個 stage(含 review);但 4 個新欄位還沒出現在 response — 由 M2.5 決定是否曝。

Task M2.5: 評估 stage_object DTO 是否曝新欄位

Files:

  • Optional modify: app/flow_engine/dto/stage_object_dto.py

新欄位用於 BE 內部 validator,FE 不需要展示給 user。v1 不曝到 DTO;如未來 FE editor 想顯示「此 stage 允許接 X / Y」hint,再加。skip 這個 task。

Task M2.6: Commit M2

git add infra/flow_engine/models/stage_object.py \
        domain/flow_engine/entity/stage_object_entity.py \
        infra/flow_engine/mapper/stage_object_mapper.py
git commit -m "$(cat <<'EOF'
feat(spec4-M2): stage_object entity / mapper / model 對應 4 個新欄位

把 M1 加的 4 個 DB 欄位映射到 ORM model / entity dataclass / mapper。
DTO 暫不曝(validator 內部用),FE 不需展示。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

§6

M3 — BpmnTopologyValidator 實作 + unit test

Task M3.1: 寫 validator unit test(TDD — 先寫失敗測試)

Files:

  • Create: tests/test_bpmn_topology_validator.py
"""BpmnTopologyValidator unit tests (Spec 4 design.md §7).

純 function 測試,不需 DB;mock stage_object 規則。
"""
import re
import pytest

from app.flow_engine.util.bpmn_topology_validator import (
    BpmnTopologyValidator,
    StageRules,
    ValidationViolation,
)


@pytest.fixture
def default_rules():
    """5 stage 規則(mirror M1 migration seed)"""
    return {
        "planning": StageRules(
            can_start=True, can_end=True,
            allowed_predecessors=[],
            allowed_successors=["task_execution", "review", "audit", "poam"],
        ),
        "task_execution": StageRules(
            can_start=False, can_end=True,
            allowed_predecessors=["planning", "review"],
            allowed_successors=["audit", "review"],
        ),
        "review": StageRules(
            can_start=False, can_end=True,
            allowed_predecessors=["planning", "task_execution"],
            allowed_successors=["audit"],
        ),
        "audit": StageRules(
            can_start=False, can_end=True,
            allowed_predecessors=["task_execution", "review", "poam"],
            allowed_successors=["poam"],
        ),
        "poam": StageRules(
            can_start=False, can_end=True,
            allowed_predecessors=["audit"],
            allowed_successors=[],
        ),
    }


def _read_builtin_xml(name: str) -> str:
    from pathlib import Path
    p = Path("scripts/sql/seeds/bpmn") / f"{name}.bpmn"
    return p.read_text(encoding="utf-8")


# ── Pass cases ───────────────────────────────────────────────────────────

def test_builtin_internal_check_passes(default_rules):
    xml = _read_builtin_xml("builtin-internal-check")
    violations = BpmnTopologyValidator(default_rules).validate(xml)
    assert violations == []


def test_builtin_self_assessment_passes(default_rules):
    xml = _read_builtin_xml("builtin-self-assessment")
    violations = BpmnTopologyValidator(default_rules).validate(xml)
    assert violations == []


def test_builtin_full_audit_passes_after_reverse_patch(default_rules):
    """M4 patch 後此 test 才會過;現在會 fail with successor_not_allowed (poam→audit)"""
    xml = _read_builtin_xml("builtin-full-audit")
    violations = BpmnTopologyValidator(default_rules).validate(xml)
    assert violations == [], f"unexpected violations: {violations}"


# ── Fail cases ───────────────────────────────────────────────────────────

def _xml_with_start_to_poam():
    return """<?xml version="1.0"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
                  xmlns:camunda="http://camunda.org/schema/1.0/bpmn">
  <bpmn:process id="P" isExecutable="true">
    <bpmn:startEvent id="S"><bpmn:outgoing>F1</bpmn:outgoing></bpmn:startEvent>
    <bpmn:userTask id="UT_p">
      <bpmn:extensionElements><camunda:properties>
        <camunda:property name="stage_object_code" value="poam"/>
      </camunda:properties></bpmn:extensionElements>
      <bpmn:incoming>F1</bpmn:incoming><bpmn:outgoing>F2</bpmn:outgoing>
    </bpmn:userTask>
    <bpmn:endEvent id="E"><bpmn:incoming>F2</bpmn:incoming></bpmn:endEvent>
    <bpmn:sequenceFlow id="F1" sourceRef="S" targetRef="UT_p"/>
    <bpmn:sequenceFlow id="F2" sourceRef="UT_p" targetRef="E"/>
  </bpmn:process>
</bpmn:definitions>"""


def test_start_stage_not_allowed(default_rules):
    xml = _xml_with_start_to_poam()
    violations = BpmnTopologyValidator(default_rules).validate(xml)
    assert any(v.rule == "start_stage_not_allowed" for v in violations)


def test_unknown_stage_code(default_rules):
    """UserTask 帶不存在的 stage_object_code"""
    xml = _xml_with_start_to_poam().replace('value="poam"', 'value="nonexistent"')
    violations = BpmnTopologyValidator(default_rules).validate(xml)
    assert any(v.rule == "unknown_stage_code" for v in violations)


# ── §12.1 reverse regex 邊界 case(design §7.3)─────────────────────────

@pytest.mark.parametrize("condition,expected_reverse", [
    ("${decision == 'reject'}", True),
    ("${review_status == 'reject'}", True),
    ("${ decision  ==  'reject' }", True),
    ("${review_status == 'rejected_in_form'}", False),  # false positive 防範
    ("${decision == 'Reject'}", False),                   # 大小寫敏感
    ('${decision == "reject"}', True),                    # 雙引號
    ("${decision != 'approve'}", False),                  # 非語意推導
    ("${a == 'reject' && b == 'foo'}", False),           # 複合條件
    ("", False),                                          # 空字串
])
def test_reverse_condition_regex(condition, expected_reverse):
    from app.flow_engine.util.bpmn_topology_validator import is_reverse_condition
    assert is_reverse_condition(condition) is expected_reverse


# ── reachability — reverse edge 不算路徑 ─────────────────────────────────

def test_no_end_path_pure_reverse_cycle(default_rules):
    """A → B 一條 forward,B → A 一條 reverse;沒有 forward 到 EndEvent 的路徑"""
    xml = """<?xml version="1.0"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
                  xmlns:camunda="http://camunda.org/schema/1.0/bpmn">
  <bpmn:process id="P" isExecutable="true">
    <bpmn:startEvent id="S"><bpmn:outgoing>F1</bpmn:outgoing></bpmn:startEvent>
    <bpmn:userTask id="UT_a">
      <bpmn:extensionElements><camunda:properties>
        <camunda:property name="stage_object_code" value="planning"/>
      </camunda:properties></bpmn:extensionElements>
      <bpmn:incoming>F1</bpmn:incoming><bpmn:incoming>F3</bpmn:incoming>
      <bpmn:outgoing>F2</bpmn:outgoing>
    </bpmn:userTask>
    <bpmn:userTask id="UT_b">
      <bpmn:extensionElements><camunda:properties>
        <camunda:property name="stage_object_code" value="task_execution"/>
      </camunda:properties></bpmn:extensionElements>
      <bpmn:incoming>F2</bpmn:incoming><bpmn:outgoing>F3</bpmn:outgoing>
    </bpmn:userTask>
    <bpmn:endEvent id="E"/>
    <bpmn:sequenceFlow id="F1" sourceRef="S" targetRef="UT_a"/>
    <bpmn:sequenceFlow id="F2" sourceRef="UT_a" targetRef="UT_b"/>
    <bpmn:sequenceFlow id="F3" sourceRef="UT_b" targetRef="UT_a">
      <bpmn:extensionElements><camunda:properties>
        <camunda:property name="reverse" value="true"/>
      </camunda:properties></bpmn:extensionElements>
    </bpmn:sequenceFlow>
  </bpmn:process>
</bpmn:definitions>"""
    violations = BpmnTopologyValidator(default_rules).validate(xml)
    assert any(v.rule == "no_end_path" for v in violations)
pytest tests/test_bpmn_topology_validator.py -v 2>&1 | head -30

Expected: ModuleNotFoundError: No module named 'app.flow_engine.util.bpmn_topology_validator'

Task M3.2: 實作 validator

Files:

  • Create: app/flow_engine/util/bpmn_topology_validator.py
"""BPMN topology validator (Spec 4 design §7).

Pure function — input BPMN XML + stage rules dict, output list of violations.
不依賴 DB / Flask context;caller (FlowTemplateAppService) 負責從 DB 拉規則。
"""
from __future__ import annotations

import re
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set

from jedi_flow_engine.common.utils.bpmn_uilts import BpmnUtils


# ── Data structures ─────────────────────────────────────────────────────────


@dataclass
class StageRules:
    can_start: bool = False
    can_end: bool = True
    allowed_predecessors: List[str] = field(default_factory=list)
    allowed_successors: List[str] = field(default_factory=list)


@dataclass
class ValidationViolation:
    rule: str                                       # e.g. "successor_not_allowed"
    reason_i18n_key: str                            # e.g. "flow_engine.validator.successor_not_allowed"
    context: Dict[str, str] = field(default_factory=dict)


# ── Reverse detection(design §7.3)──────────────────────────────────────────


_REVERSE_CONDITION_REGEX = re.compile(
    r"^\s*\$\{\s*[A-Za-z_][A-Za-z_0-9]*\s*==\s*['\"]reject['\"]\s*\}\s*$"
)


def is_reverse_condition(condition: Optional[str]) -> bool:
    """strict anchored match for ${var == 'reject'} forms only."""
    if not condition:
        return False
    return _REVERSE_CONDITION_REGEX.fullmatch(condition) is not None


# ── Validator ───────────────────────────────────────────────────────────────


class BpmnTopologyValidator:
    """Validates BPMN XML against stage rules. design.md §7.2 7 步檢查."""

    def __init__(self, stage_rules: Dict[str, StageRules]):
        self._rules = stage_rules

    def validate(self, xml: str) -> List[ValidationViolation]:
        violations: List[ValidationViolation] = []

        try:
            util = BpmnUtils(xml)
        except Exception:
            return [ValidationViolation(
                rule="bpmn_parse_failed",
                reason_i18n_key="flow_engine.validator.bpmn_parse_failed",
            )]

        # 收集 BPMN element 索引
        process_dict = util.workflow or {}
        sequence_flows = self._collect_sequence_flows(process_dict)
        user_tasks = self._collect_user_tasks(process_dict)        # id → UserTask dict
        gateways = self._collect_gateways(process_dict)            # id → gateway dict
        start_events = self._collect_start_events(process_dict)
        end_events = self._collect_end_events(process_dict)

        # Step 1: StartEvent 後第一個 UserTask 必 can_start
        violations.extend(self._check_start_stage(start_events, sequence_flows, user_tasks))

        # Step 2: 所有 stage_object_code 必合法(同時把 unknown 標出來)
        violations.extend(self._check_known_stage_codes(user_tasks))

        # Step 3: 每條 forward UserTask→UserTask 的 successor 檢查(reverse skip)
        violations.extend(self._check_forward_successors(sequence_flows, user_tasks, gateways))

        # Step 4: 連到 EndEvent 的 UserTask 必 can_end
        violations.extend(self._check_end_stage(end_events, sequence_flows, user_tasks))

        # Step 5: ExclusiveGateway 至少一條 outgoing 有 condition
        violations.extend(self._check_gateway_conditions(gateways, sequence_flows))

        # Step 6: Reachability — 走 forward edge only
        violations.extend(self._check_reachability(
            start_events, end_events, sequence_flows, user_tasks, gateways
        ))

        return violations

    # ── helpers ──────────────────────────────────────────────────────────

    def _to_list(self, x) -> list:
        if x is None:
            return []
        return x if isinstance(x, list) else [x]

    def _collect_sequence_flows(self, process_dict: dict) -> List[dict]:
        flows = self._to_list(process_dict.get("bpmn:sequenceFlow"))
        return flows

    def _collect_user_tasks(self, process_dict: dict) -> Dict[str, dict]:
        tasks = self._to_list(process_dict.get("bpmn:userTask"))
        return {t["@id"]: t for t in tasks}

    def _collect_gateways(self, process_dict: dict) -> Dict[str, dict]:
        gws = self._to_list(process_dict.get("bpmn:exclusiveGateway"))
        return {g["@id"]: g for g in gws}

    def _collect_start_events(self, process_dict: dict) -> List[dict]:
        return self._to_list(process_dict.get("bpmn:startEvent"))

    def _collect_end_events(self, process_dict: dict) -> List[dict]:
        return self._to_list(process_dict.get("bpmn:endEvent"))

    def _get_stage_code(self, user_task: dict) -> Optional[str]:
        ext = user_task.get("bpmn:extensionElements") or {}
        props = ext.get("camunda:properties") or {}
        plist = self._to_list(props.get("camunda:property"))
        for p in plist:
            if p.get("@name") == "stage_object_code":
                return p.get("@value")
        return None

    def _is_reverse_flow(self, flow: dict) -> bool:
        # Primary:extension property
        ext = flow.get("bpmn:extensionElements") or {}
        props = ext.get("camunda:properties") or {}
        plist = self._to_list(props.get("camunda:property"))
        for p in plist:
            if p.get("@name") == "reverse" and str(p.get("@value")).lower() == "true":
                return True
        # Fallback:condition regex
        cond = flow.get("bpmn:conditionExpression")
        if isinstance(cond, dict):
            cond_text = cond.get("#text", "")
        elif isinstance(cond, str):
            cond_text = cond
        else:
            cond_text = ""
        return is_reverse_condition(cond_text)

    def _resolve_user_task_or_gateway(self, target_ref: str,
                                       user_tasks: Dict[str, dict],
                                       gateways: Dict[str, dict]) -> Optional[dict]:
        return user_tasks.get(target_ref) or gateways.get(target_ref)

    # ── checks ──────────────────────────────────────────────────────────

    def _check_start_stage(self, start_events, sequence_flows, user_tasks):
        out: List[ValidationViolation] = []
        for se in start_events:
            outgoing_ids = self._to_list(se.get("bpmn:outgoing"))
            for fid in outgoing_ids:
                flow = next((f for f in sequence_flows if f.get("@id") == fid), None)
                if flow is None:
                    continue
                target_id = flow.get("@targetRef")
                ut = user_tasks.get(target_id)
                if ut is None:
                    out.append(ValidationViolation(
                        rule="start_not_to_user_task",
                        reason_i18n_key="flow_engine.validator.start_not_to_user_task",
                        context={"target_ref": target_id},
                    ))
                    continue
                code = self._get_stage_code(ut)
                rule = self._rules.get(code or "")
                if rule is None or not rule.can_start:
                    out.append(ValidationViolation(
                        rule="start_stage_not_allowed",
                        reason_i18n_key="flow_engine.validator.start_stage_not_allowed",
                        context={"stage": code or "<unknown>"},
                    ))
        return out

    def _check_known_stage_codes(self, user_tasks):
        out: List[ValidationViolation] = []
        for tid, ut in user_tasks.items():
            code = self._get_stage_code(ut)
            if code is None:
                out.append(ValidationViolation(
                    rule="user_task_missing_stage_code",
                    reason_i18n_key="flow_engine.validator.user_task_missing_stage_code",
                    context={"user_task_id": tid},
                ))
            elif code not in self._rules:
                out.append(ValidationViolation(
                    rule="unknown_stage_code",
                    reason_i18n_key="flow_engine.validator.unknown_stage_code",
                    context={"code": code, "user_task_id": tid},
                ))
        return out

    def _check_forward_successors(self, sequence_flows, user_tasks, gateways):
        out: List[ValidationViolation] = []
        for flow in sequence_flows:
            if self._is_reverse_flow(flow):
                continue
            source_id = flow.get("@sourceRef")
            target_id = flow.get("@targetRef")
            source_ut = user_tasks.get(source_id)
            if source_ut is None:
                continue  # source 是 Gateway / StartEvent,不在此檢查
            target_ut = user_tasks.get(target_id)
            if target_ut is None:
                continue  # target 是 Gateway / EndEvent,跳過(end 由 check_end_stage 處理)
            src_code = self._get_stage_code(source_ut)
            tgt_code = self._get_stage_code(target_ut)
            src_rule = self._rules.get(src_code or "")
            if src_rule is None:
                continue
            if tgt_code not in src_rule.allowed_successors:
                out.append(ValidationViolation(
                    rule="successor_not_allowed",
                    reason_i18n_key="flow_engine.validator.successor_not_allowed",
                    context={
                        "source_stage": src_code or "",
                        "target_stage": tgt_code or "",
                        "sequence_flow_id": flow.get("@id"),
                    },
                ))
        return out

    def _check_end_stage(self, end_events, sequence_flows, user_tasks):
        out: List[ValidationViolation] = []
        end_ids = {e["@id"] for e in end_events}
        for flow in sequence_flows:
            target_id = flow.get("@targetRef")
            if target_id not in end_ids:
                continue
            source_id = flow.get("@sourceRef")
            source_ut = user_tasks.get(source_id)
            if source_ut is None:
                continue
            code = self._get_stage_code(source_ut)
            rule = self._rules.get(code or "")
            if rule is None or not rule.can_end:
                out.append(ValidationViolation(
                    rule="end_stage_not_allowed",
                    reason_i18n_key="flow_engine.validator.end_stage_not_allowed",
                    context={"stage": code or "<unknown>"},
                ))
        return out

    def _check_gateway_conditions(self, gateways, sequence_flows):
        out: List[ValidationViolation] = []
        for gid, gw in gateways.items():
            outgoing_ids = self._to_list(gw.get("bpmn:outgoing"))
            has_condition = False
            for fid in outgoing_ids:
                flow = next((f for f in sequence_flows if f.get("@id") == fid), None)
                if flow is None:
                    continue
                cond = flow.get("bpmn:conditionExpression")
                if cond:
                    has_condition = True
                    break
            if not has_condition:
                out.append(ValidationViolation(
                    rule="gateway_missing_condition",
                    reason_i18n_key="flow_engine.validator.gateway_missing_condition",
                    context={"gateway_id": gid},
                ))
        return out

    def _check_reachability(self, start_events, end_events,
                            sequence_flows, user_tasks, gateways):
        """Forward-only BFS:reverse edge 視為不存在。"""
        out: List[ValidationViolation] = []
        if not start_events:
            return out

        # Build forward adjacency(skip reverse)
        adj: Dict[str, List[str]] = {}
        for flow in sequence_flows:
            if self._is_reverse_flow(flow):
                continue
            src = flow.get("@sourceRef")
            tgt = flow.get("@targetRef")
            adj.setdefault(src, []).append(tgt)

        start_id = start_events[0]["@id"]
        reachable: Set[str] = set()
        stack = [start_id]
        while stack:
            node = stack.pop()
            if node in reachable:
                continue
            reachable.add(node)
            for nxt in adj.get(node, []):
                stack.append(nxt)

        # Unreachable UserTask
        for tid in user_tasks.keys():
            if tid not in reachable:
                out.append(ValidationViolation(
                    rule="unreachable_node",
                    reason_i18n_key="flow_engine.validator.unreachable_node",
                    context={"node_id": tid},
                ))

        # 至少有一條 forward path 到 EndEvent
        end_ids = {e["@id"] for e in end_events}
        if not (end_ids & reachable):
            out.append(ValidationViolation(
                rule="no_end_path",
                reason_i18n_key="flow_engine.validator.no_end_path",
            ))
        return out
pytest tests/test_bpmn_topology_validator.py -v

Expected:

  • test_builtin_internal_check_passes
  • test_builtin_self_assessment_passes
  • test_builtin_full_audit_passes_after_reverse_patch — fail with successor_not_allowed (poam→audit)這是預期,M4 patch 後才會過
  • test_start_stage_not_allowed
  • test_unknown_stage_code
  • ✅ 9 個 regex parametrize tests
  • test_no_end_path_pure_reverse_cycle

如果 test_builtin_full_audit_passes_after_reverse_patch 以外的任何 test fail → 看實作

git add app/flow_engine/util/bpmn_topology_validator.py tests/test_bpmn_topology_validator.py
git commit -m "$(cat <<'EOF'
feat(spec4-M3): BpmnTopologyValidator 純函式 + 13 條 unit test

對應 spec 4 design.md §7:
- StageRules / ValidationViolation dataclass
- is_reverse_condition strict anchored regex(design §7.3 false positive 防範)
- 7 條檢查:start_stage / known_codes / forward_successors / end_stage /
  gateway_conditions / reachability (forward only) / no_end_path
- Builtin 範本 3 個 pass case(full-audit 待 M4 patch reverse)

builtin-full-audit test 預期 fail until M4。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

§7

M4 — Builtin 範本 patch + 新建 builtin-full-audit-with-review

Task M4.1: Patch 既有 builtin-full-audit BPMN seed file

Files:

  • Modify: scripts/sql/seeds/bpmn/builtin-full-audit.bpmn

找到:

<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit"/>

改為:

<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit">
  <bpmn:extensionElements>
    <camunda:properties>
      <camunda:property name="reverse" value="true"/>
    </camunda:properties>
  </bpmn:extensionElements>
</bpmn:sequenceFlow>
pytest tests/test_bpmn_topology_validator.py::test_builtin_full_audit_passes_after_reverse_patch -v

Expected: ✅ PASS

Task M4.2: 新建 builtin-full-audit-with-review BPMN seed file

Files:

  • Create: scripts/sql/seeds/bpmn/builtin-full-audit-with-review.bpmn
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
                  xmlns:camunda="http://camunda.org/schema/1.0/bpmn"
                  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                  id="Definitions_builtin_full_audit_with_review"
                  targetNamespace="http://bpmn.io/schema/bpmn">
  <bpmn:process id="Process_builtin_full_audit_with_review" name="完整稽核流程(含審核)" isExecutable="true">
    <bpmn:startEvent id="StartEvent_1" name="開始">
      <bpmn:outgoing>Flow_start_planning</bpmn:outgoing>
    </bpmn:startEvent>
    <bpmn:userTask id="UserTask_planning" name="規劃">
      <bpmn:extensionElements>
        <camunda:properties>
          <camunda:property name="stage_object_code" value="planning"/>
          <camunda:property name="main_role" value="manager"/>
        </camunda:properties>
      </bpmn:extensionElements>
      <bpmn:incoming>Flow_start_planning</bpmn:incoming>
      <bpmn:outgoing>Flow_planning_task_execution</bpmn:outgoing>
    </bpmn:userTask>
    <bpmn:userTask id="UserTask_task_execution" name="執行任務">
      <bpmn:extensionElements>
        <camunda:properties>
          <camunda:property name="stage_object_code" value="task_execution"/>
          <camunda:property name="main_role" value="manager"/>
        </camunda:properties>
      </bpmn:extensionElements>
      <bpmn:incoming>Flow_planning_task_execution</bpmn:incoming>
      <bpmn:incoming>Flow_review_reject</bpmn:incoming>
      <bpmn:outgoing>Flow_task_execution_review</bpmn:outgoing>
    </bpmn:userTask>
    <bpmn:userTask id="UserTask_review" name="審核">
      <bpmn:extensionElements>
        <camunda:properties>
          <camunda:property name="stage_object_code" value="review"/>
          <camunda:property name="main_role" value="reviewer"/>
        </camunda:properties>
      </bpmn:extensionElements>
      <bpmn:incoming>Flow_task_execution_review</bpmn:incoming>
      <bpmn:outgoing>Flow_review_gateway</bpmn:outgoing>
    </bpmn:userTask>
    <bpmn:exclusiveGateway id="Gateway_review_decision" name="審核決議" default="Flow_review_forward">
      <bpmn:incoming>Flow_review_gateway</bpmn:incoming>
      <bpmn:outgoing>Flow_review_forward</bpmn:outgoing>
      <bpmn:outgoing>Flow_review_reject</bpmn:outgoing>
    </bpmn:exclusiveGateway>
    <bpmn:userTask id="UserTask_audit" name="稽核">
      <bpmn:extensionElements>
        <camunda:properties>
          <camunda:property name="stage_object_code" value="audit"/>
          <camunda:property name="main_role" value="auditor"/>
        </camunda:properties>
      </bpmn:extensionElements>
      <bpmn:incoming>Flow_review_forward</bpmn:incoming>
      <bpmn:incoming>Flow_poam_audit</bpmn:incoming>
      <bpmn:outgoing>Flow_audit_gateway</bpmn:outgoing>
    </bpmn:userTask>
    <bpmn:exclusiveGateway id="Gateway_has_findings" name="是否有缺失" default="Flow_gateway_end">
      <bpmn:incoming>Flow_audit_gateway</bpmn:incoming>
      <bpmn:outgoing>Flow_gateway_poam</bpmn:outgoing>
      <bpmn:outgoing>Flow_gateway_end</bpmn:outgoing>
    </bpmn:exclusiveGateway>
    <bpmn:userTask id="UserTask_poam" name="缺失改善">
      <bpmn:extensionElements>
        <camunda:properties>
          <camunda:property name="stage_object_code" value="poam"/>
          <camunda:property name="main_role" value="manager"/>
        </camunda:properties>
      </bpmn:extensionElements>
      <bpmn:incoming>Flow_gateway_poam</bpmn:incoming>
      <bpmn:outgoing>Flow_poam_audit</bpmn:outgoing>
    </bpmn:userTask>
    <bpmn:endEvent id="EndEvent_1" name="結案">
      <bpmn:incoming>Flow_gateway_end</bpmn:incoming>
    </bpmn:endEvent>
    <bpmn:sequenceFlow id="Flow_start_planning" sourceRef="StartEvent_1" targetRef="UserTask_planning"/>
    <bpmn:sequenceFlow id="Flow_planning_task_execution" sourceRef="UserTask_planning" targetRef="UserTask_task_execution"/>
    <bpmn:sequenceFlow id="Flow_task_execution_review" sourceRef="UserTask_task_execution" targetRef="UserTask_review"/>
    <bpmn:sequenceFlow id="Flow_review_gateway" sourceRef="UserTask_review" targetRef="Gateway_review_decision"/>
    <bpmn:sequenceFlow id="Flow_review_forward" name="通過" sourceRef="Gateway_review_decision" targetRef="UserTask_audit"/>
    <bpmn:sequenceFlow id="Flow_review_reject" name="退回" sourceRef="Gateway_review_decision" targetRef="UserTask_task_execution">
      <bpmn:extensionElements>
        <camunda:properties>
          <camunda:property name="reverse" value="true"/>
        </camunda:properties>
      </bpmn:extensionElements>
      <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${decision == 'reject'}</bpmn:conditionExpression>
    </bpmn:sequenceFlow>
    <bpmn:sequenceFlow id="Flow_audit_gateway" sourceRef="UserTask_audit" targetRef="Gateway_has_findings"/>
    <bpmn:sequenceFlow id="Flow_gateway_poam" name="有缺失" sourceRef="Gateway_has_findings" targetRef="UserTask_poam">
      <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${has_findings == true}</bpmn:conditionExpression>
    </bpmn:sequenceFlow>
    <bpmn:sequenceFlow id="Flow_gateway_end" name="無缺失" sourceRef="Gateway_has_findings" targetRef="EndEvent_1"/>
    <bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit">
      <bpmn:extensionElements>
        <camunda:properties>
          <camunda:property name="reverse" value="true"/>
        </camunda:properties>
      </bpmn:extensionElements>
    </bpmn:sequenceFlow>
  </bpmn:process>
</bpmn:definitions>
def test_builtin_full_audit_with_review_passes(default_rules):
    xml = _read_builtin_xml("builtin-full-audit-with-review")
    violations = BpmnTopologyValidator(default_rules).validate(xml)
    assert violations == [], f"unexpected violations: {violations}"
pytest tests/test_bpmn_topology_validator.py::test_builtin_full_audit_with_review_passes -v

Expected: ✅ PASS

Task M4.3: 寫 migration — DB 範本 patch + 新 builtin seed

Files:

  • Create: scripts/sql/2026-05-14-spec4-builtin-flow-templates.sql
-- Date: 2026-05-14
-- Spec 4 — builtin BPMN reverse property patch + 新增 builtin-full-audit-with-review

-- 1. Patch builtin-full-audit 既有 Flow_poam_audit 加 reverse extension property (2026-05-14)
UPDATE compliance.flow_templates
   SET bpmn_xml = REPLACE(
       bpmn_xml,
       '<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit"/>',
       '<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit"><bpmn:extensionElements><camunda:properties><camunda:property name="reverse" value="true"/></camunda:properties></bpmn:extensionElements></bpmn:sequenceFlow>'
       ),
       updated_user = 'system',
       updated_at = NOW()
 WHERE is_builtin = TRUE
   AND name = '完整稽核流程'
   AND bpmn_xml LIKE '%<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit"/>%';

-- 2. Patch 自訂範本 duplicate 自 builtin-full-audit 的 — design §7.5 backfill (2026-05-14)
-- 條件:is_builtin=FALSE 且 XML 含未 patch 的 Flow_poam_audit
UPDATE compliance.flow_templates
   SET bpmn_xml = REPLACE(
       bpmn_xml,
       '<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit"/>',
       '<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit"><bpmn:extensionElements><camunda:properties><camunda:property name="reverse" value="true"/></camunda:properties></bpmn:extensionElements></bpmn:sequenceFlow>'
       ),
       updated_user = 'system',
       updated_at = NOW()
 WHERE is_builtin = FALSE
   AND is_active = TRUE
   AND bpmn_xml LIKE '%<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit"/>%';

-- 3. Seed 新 builtin 範本 builtin-full-audit-with-review (2026-05-14)
-- BPMN XML 用 PG dollar-quoting
INSERT INTO compliance.flow_templates (tenant_id, name, description, is_builtin, bpmn_xml, created_user, updated_user)
SELECT NULL, '完整稽核流程(含審核)',
       '規劃 → 執行 → 審核 → 稽核 → 缺失改善 → 結案;reviewer 過審或退回流程', TRUE,
       $xml$<?xml version="1.0" encoding="UTF-8"?>
<!-- [貼上 Task M4.2 的完整 XML 內容] -->
$xml$,
       'system', 'system'
 WHERE NOT EXISTS (
     SELECT 1 FROM compliance.flow_templates
      WHERE is_builtin = TRUE AND name = '完整稽核流程(含審核)'
 );

-- 4. 驗證 SQL(manual run)
-- SELECT id, name, is_builtin, LENGTH(bpmn_xml) AS xml_size FROM compliance.flow_templates ORDER BY id;
-- SELECT id, name FROM compliance.flow_templates
--  WHERE bpmn_xml LIKE '%<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit"/>%';
--  (第二條應該回 0 列)
python3 - <<'PYEOF'
xml = open("scripts/sql/seeds/bpmn/builtin-full-audit-with-review.bpmn").read()
sql = open("scripts/sql/2026-05-14-spec4-builtin-flow-templates.sql").read()
# 用 plain string replace,避開 re.sub 對 \1 \g<...> 等 regex metachar 的解讀
sql_new = sql.replace(
    "<!-- [貼上 Task M4.2 的完整 XML 內容] -->",
    xml,
)
open("scripts/sql/2026-05-14-spec4-builtin-flow-templates.sql", "w").write(sql_new)
print("DONE")
PYEOF
psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -c \
  "SELECT id, tenant_id, name FROM compliance.flow_templates
    WHERE is_builtin = FALSE AND is_active = TRUE
      AND bpmn_xml LIKE '%<bpmn:sequenceFlow id=\"Flow_poam_audit\" sourceRef=\"UserTask_poam\" targetRef=\"UserTask_audit\"/>%';"

Expected: 列出將被 patch 的自訂範本 row(可能是 0 列 — dev 環境通常無)。若有 row → 確認 user 認可前不要 commit migration

psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev \
  -f scripts/sql/2026-05-14-spec4-builtin-flow-templates.sql

Expected: 1 UPDATE 1(builtin patch)+ N UPDATE N(自訂 backfill 數)+ 1 INSERT 0 1(新 builtin)

psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -c \
  "SELECT id, name, is_builtin, LENGTH(bpmn_xml) AS xml_size FROM compliance.flow_templates ORDER BY id;"

Expected: 至少 4 列(既有 3 builtin + 新 builtin),LENGTH 都 > 1000。

git add scripts/sql/seeds/bpmn/builtin-full-audit.bpmn \
        scripts/sql/seeds/bpmn/builtin-full-audit-with-review.bpmn \
        scripts/sql/2026-05-14-spec4-builtin-flow-templates.sql \
        tests/test_bpmn_topology_validator.py
git commit -m "$(cat <<'EOF'
feat(spec4-M4): builtin-full-audit patch reverse + 新 builtin-full-audit-with-review

對應 spec 4 design.md §7.5 + §9:
- Patch builtin-full-audit BPMN Flow_poam_audit 加 reverse extension property
- 自訂範本 backfill:scan 並 patch duplicate 自 builtin-full-audit 的範本
- 新增 builtin-full-audit-with-review(規劃→執行→審核→稽核→改善→結案)
  含 Gateway_review_decision 兩條 outgoing:forward(approve) / reverse(reject)
- M3 test_builtin_full_audit_passes_after_reverse_patch / new with_review 都過

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

§8

M5 — Error code + FlowTemplate validator 整合

Task M5.1: 加 3 個 error code

Files:

  • Modify: common/code/grc_error_code.py
    GRC_FLOW_TEMPLATE_TOPOLOGY_INVALID = ("範本流程結構不合法",         "GRC_400060")
    GRC_STAGE_REVIEW_REJECT_REQUIRES_COMMENT = ("退回審核必須附留言",   "GRC_400061")
    GRC_STAGE_REVIEW_DECISION_INVALID  = ("decision 值必須為 approve 或 reject", "GRC_400062")

Task M5.2: FlowTemplateAppService 整合 validator

Files:

  • Modify: app/flow_engine/service/flow_template_app_service.py
from app.flow_engine.util.bpmn_topology_validator import (
    BpmnTopologyValidator,
    StageRules,
    ValidationViolation,
)
from domain.flow_engine.entity.stage_object_query_entity import StageObjectQueryEntity
def __init__(
    self,
    flow_template_domain_service: FlowTemplateDomainService,
    stage_object_domain_service,  # 新增
    user_service,
):
    self._domain = flow_template_domain_service
    self._stage_object_domain_service = stage_object_domain_service  # 新增
    self._user_service = user_service
def _load_stage_rules(self) -> dict:
    entities = self._stage_object_domain_service.find_all(
        StageObjectQueryEntity(is_active=True)
    )
    return {
        e.code: StageRules(
            can_start=e.can_start,
            can_end=e.can_end,
            allowed_predecessors=e.allowed_predecessors or [],
            allowed_successors=e.allowed_successors or [],
        )
        for e in entities
    }

def _validate_bpmn_topology(self, xml: str) -> None:
    rules = self._load_stage_rules()
    violations = BpmnTopologyValidator(rules).validate(xml)
    if violations:
        err = BadRequestError(GrcErrorCode.GRC_FLOW_TEMPLATE_TOPOLOGY_INVALID)
        err.context = {
            "violations": [
                {
                    "rule": v.rule,
                    "reason_i18n_key": v.reason_i18n_key,
                    "context": v.context,
                }
                for v in violations
            ]
        }
        raise err
@transaction
def create(self, name: str, description: Optional[str], bpmn_xml: str) -> FlowTemplateDto:
    self._validate_bpmn(bpmn_xml)
    self._validate_bpmn_topology(bpmn_xml)   # 新增
    ...

同樣在 update method 對應位置加。

@transaction
def validate(self, bpmn_xml: str) -> dict:
    """Endpoint /flow-engine/flow-templates/validate 用:
    純驗證不存檔,回 violations 列表(無錯就空)。
    """
    self._validate_bpmn(bpmn_xml)            # 基本格式檢查仍 raise 400
    rules = self._load_stage_rules()
    violations = BpmnTopologyValidator(rules).validate(bpmn_xml)
    return {
        "valid": len(violations) == 0,
        "violations": [
            {"rule": v.rule, "reason_i18n_key": v.reason_i18n_key, "context": v.context}
            for v in violations
        ],
    }

Task M5.3: DI container wiring

Files:

  • Modify: di_containers/flow_engine/flow_template_containers.py
grep -n "FlowTemplateAppService\|stage_object_domain_service" di_containers/flow_engine/flow_template_containers.py

Expected: FlowTemplateAppService Factory declared inside this file; stage_object_domain_service 也已 declared(spec 1 ship 時就有)— 因此 wiring 只需把它注入 Factory,不需新建 domain service。

找到既有:

flow_template_app_service = providers.Factory(
    FlowTemplateAppService,
    flow_template_domain_service=flow_template_domain_service,
    user_service=...,
)

改為:

flow_template_app_service = providers.Factory(
    FlowTemplateAppService,
    flow_template_domain_service=flow_template_domain_service,
    stage_object_domain_service=stage_object_domain_service,  # 新增
    user_service=...,
)

Task M5.4: 重啟 BE + integration smoke

lsof -ti :8000 | xargs kill -9 2>/dev/null; sleep 1
nohup python main_socketio.py > /tmp/be.log 2>&1 &
sleep 3 && tail -20 log/app.log

Expected: 無 DI / import 錯誤。

TOKEN=$(curl -s -X POST http://localhost:8000/login \
  -H "Content-Type: application/json" \
  -d '{"login_name":"blsadmin","password":"Billows@123!"}' | python -c "import sys,json;print(json.load(sys.stdin)['data']['token'])")

curl -s -X POST http://localhost:8000/flow-engine/flow-templates \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "test-invalid",
    "description": "should fail",
    "bpmn_xml": "<?xml version=\"1.0\"?><bpmn:definitions xmlns:bpmn=\"http://www.omg.org/spec/BPMN/20100524/MODEL\" xmlns:camunda=\"http://camunda.org/schema/1.0/bpmn\"><bpmn:process id=\"P\" isExecutable=\"true\"><bpmn:startEvent id=\"S\"><bpmn:outgoing>F1</bpmn:outgoing></bpmn:startEvent><bpmn:userTask id=\"UT_p\"><bpmn:extensionElements><camunda:properties><camunda:property name=\"stage_object_code\" value=\"poam\"/></camunda:properties></bpmn:extensionElements><bpmn:incoming>F1</bpmn:incoming><bpmn:outgoing>F2</bpmn:outgoing></bpmn:userTask><bpmn:endEvent id=\"E\"><bpmn:incoming>F2</bpmn:incoming></bpmn:endEvent><bpmn:sequenceFlow id=\"F1\" sourceRef=\"S\" targetRef=\"UT_p\"/><bpmn:sequenceFlow id=\"F2\" sourceRef=\"UT_p\" targetRef=\"E\"/></bpmn:process></bpmn:definitions>"
  }'

Expected: HTTP 400,envelope {"code": 0, "msg": "...", "data": {"violations": [{"rule": "start_stage_not_allowed", ...}]}}

git add common/code/grc_error_code.py \
        app/flow_engine/service/flow_template_app_service.py \
        di_containers/flow_engine/flow_template_containers.py
git commit -m "$(cat <<'EOF'
feat(spec4-M5): FlowTemplateAppService 整合 BpmnTopologyValidator

對應 spec 4 design.md §6.3 + §7 + §11:
- 加 3 個 error code(GRC_400060/061/062)
- _validate_bpmn_topology + _load_stage_rules 接 stage_objects 規則表
- create / update 加 topology validator pass
- 新 public method validate() 給 /flow-templates/validate endpoint 用(M6)
- DI wiring:FlowTemplateAppService 加 stage_object_domain_service 依賴

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

§9

M6 — POST /flow-engine/flow-templates/validate endpoint

Task M6.1: 加 request / response schema

Files:

  • Modify: api/flow_engine/serializers/flow_template.py
class FlowTemplateValidateRequestSchema(Schema):
    bpmn_xml = fields.Str(required=True)


class FlowTemplateValidationViolationSchema(Schema):
    rule = fields.Str(required=True)
    reason_i18n_key = fields.Str(allow_none=True)
    context = fields.Dict(load_default=dict)


class FlowTemplateValidateResponseSchema(Schema):
    valid = fields.Bool(required=True)
    violations = fields.List(fields.Nested(FlowTemplateValidationViolationSchema), load_default=list)

Task M6.2: 加 route resource

Files:

  • Modify: api/flow_engine/routes/flow_template_route.py
  • Modify: api/flow_engine/__init__.py
class FlowTemplateValidateResource(Resource):
    method_decorators = [jwt_required()]

    @inject
    def post(
        self,
        flow_template_app_service: FlowTemplateAppService = Provide[
            Containers.flow_engine_container.flow_template_app_service
        ],
    ):
        body = FlowTemplateValidateRequestSchema().load(request.get_json(silent=True) or {})
        result = flow_template_app_service.validate(bpmn_xml=body["bpmn_xml"])
        return return_response(True, FlowTemplateValidateResponseSchema().dump(result))
api.add_resource(FlowTemplateValidateResource, "/flow-engine/flow-templates/validate")

Task M6.3: smoke + commit

lsof -ti :8000 | xargs kill -9 2>/dev/null; sleep 1
nohup python main_socketio.py > /tmp/be.log 2>&1 &
sleep 3

# 應該 200 valid=true
TOKEN=...(同上)
curl -s -X POST http://localhost:8000/flow-engine/flow-templates/validate \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"bpmn_xml\": $(python -c "import json;print(json.dumps(open('scripts/sql/seeds/bpmn/builtin-internal-check.bpmn').read()))")}"

Expected: {"code": 1, "data": {"valid": true, "violations": []}}

curl -s -X POST http://localhost:8000/flow-engine/flow-templates/validate \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"bpmn_xml": "<不合法的 XML>"}'

Expected: HTTP 400 GRC_400040(既有 _validate_bpmn 擋下)。

git add api/flow_engine/serializers/flow_template.py \
        api/flow_engine/routes/flow_template_route.py \
        api/flow_engine/__init__.py
git commit -m "$(cat <<'EOF'
feat(spec4-M6): POST /flow-engine/flow-templates/validate endpoint

對應 spec 4 design.md §6.3:
- 純驗證 endpoint,FE editor inline warning 拖 sequenceFlow 時 debounce call
- 不存檔;回 {valid, violations:[]}
- 沿用 FlowTemplateAppService.validate() (M5)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

§10

M7 — StageAdvanceRequestSchema 加 decision / comment + ReviewDecisionHandler

Task M7.1: 改 schema 加 decision/comment + reject required comment validator

Files:

  • Modify: api/flow_engine/serializers/stage_advance.py
from marshmallow import Schema, fields, validate, validates_schema, ValidationError


class StageAdvanceRequestSchema(Schema):
    force = fields.Bool(load_default=False)
    ctx = fields.Dict(load_default=dict)

    # v1 新增
    decision = fields.Str(
        load_default=None,
        validate=validate.OneOf(["approve", "reject"]),
    )
    comment = fields.Str(load_default=None)

    @validates_schema
    def _validate_reject_requires_comment(self, data, **kwargs):
        if data.get("decision") == "reject" and not (data.get("comment") or "").strip():
            raise ValidationError(
                {"comment": ["reject 必須附留言(GRC_400061)"]},
                field_name="comment",
            )

Task M7.2: 加 ReviewDecisionHandler

Files:

  • Modify: app/grc/service/oscal_stage_handlers.py
class ReviewDecisionHandler(IStageCompletionHandler):
    """review stage 推進 — dispatch decision;不動 AP.status。

    對應 spec 4 design.md §8.1:
    - decision=approve:BPMN 走 default forward;AP.status 由下個 stage 接管
    - decision=reject:BPMN 走 reject sequenceFlow,回上游 UserTask;AP.status 不動
    - 無業務 precondition(公開議題 2 — reviewer 自由判斷)
    """

    @property
    def key(self) -> str:
        return "review_decision"

    def execute(
        self,
        ap_uid: str,
        project_uid: str,
        user_id: int,
        curr_user: str,
        ctx: dict,
    ) -> dict:
        decision = (ctx or {}).get("decision") or "approve"
        return {"decision": decision, "warning": None}

Task M7.3: 註冊 ReviewDecisionHandler 到 stage_registry

Files:

  • Modify: di_containers/grc/grc_containers.py
from app.grc.service.oscal_stage_handlers import (
    PlanningOnCompleteHandler,
    TaskExecutionOnCompleteHandler,
    AuditOnCompleteHandler,
    PoamOnCompleteHandler,
    TerminalCloseHandler,
    ReviewDecisionHandler,    # 新增
)
review_decision_handler = providers.Factory(ReviewDecisionHandler)
def register_stage_hooks_to_registry(grc_container, flow_template_container) -> None:
    registry = flow_template_container.stage_registry()
    registry.register_handler(grc_container.planning_on_complete_handler())
    registry.register_handler(grc_container.task_execution_on_complete_handler())
    registry.register_handler(grc_container.audit_on_complete_handler())
    registry.register_handler(grc_container.poam_on_complete_handler())
    registry.register_handler(grc_container.terminal_close_handler())
    registry.register_handler(grc_container.review_decision_handler())    # 新增
    ...

Task M7.4: Commit M7

git add api/flow_engine/serializers/stage_advance.py \
        app/grc/service/oscal_stage_handlers.py \
        di_containers/grc/grc_containers.py
git commit -m "$(cat <<'EOF'
feat(spec4-M7): StageAdvance schema + ReviewDecisionHandler

對應 spec 4 design.md §6.1 + §8.1:
- StageAdvanceRequestSchema 加 decision (approve|reject) + comment 欄位
- @validates_schema:decision=reject 必填 comment(GRC_400061)
- ReviewDecisionHandler:純 dispatch decision,不動 AP.status
- 註冊到 stage_registry

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

§11

M8 — StageAdvanceService 接 decision/comment + JobComment 整合 + condition_param review 分支

Task M8.1: 改 _build_condition_param 加 review 分支

Files:

  • Modify: app/flow_engine/service/stage_advance_service.py
def _build_condition_param(self, stage_code: str, handler_result) -> Optional[dict]:
    if stage_code == "audit":                       # 既有
        if not isinstance(handler_result, dict):
            return None
        new_status = handler_result.get("status")
        return {"has_findings": new_status == "remediation"}
    if stage_code == "review":                      # v1 新增
        if isinstance(handler_result, dict):
            decision = handler_result.get("decision", "approve")
            return {"decision": decision}
        return {"decision": "approve"}
    return None

Task M8.2: advance_stage 接 decision/comment + 非 review stage ignore decision warning

Files:

  • Modify: app/flow_engine/service/stage_advance_service.py
@transaction
def advance_stage(
    self,
    project_uid: str,
    ap_uid: str,
    user_id: int,
    curr_user: str,
    force: bool = False,
    decision: Optional[str] = None,             # v1 新增
    comment: Optional[str] = None,              # v1 新增
    ctx: Optional[dict] = None,
) -> dict:
    ctx = dict(ctx or {})
    ctx.setdefault("force", force)
    if decision:
        ctx["decision"] = decision
    ...
    stage_object = self._stage_object_domain_service.get_one(
        StageObjectQueryEntity(code=stage_code)
    )
    if stage_object is None:
        raise NotFound(GrcErrorCode.GRC_STAGE_OBJECT_NOT_FOUND)

    # v1 — design §6.1:非 review stage 送 decision 忽略 + log warning
    if decision is not None and stage_object.code != "review":
        logger.warning(
            "decision payload ignored for non-review stage code=%s",
            stage_object.code,
        )
        ctx.pop("decision", None)

Task M8.3: 加 JobCommentService dependency + 寫 comment

Files:

  • Modify: app/flow_engine/service/stage_advance_service.py
def __init__(
    self,
    ...既有 8 個 deps...,
    job_comment_service,      # v1 新增
):
    ...
    self._job_comment_service = job_comment_service

接在 self._workflow_execution_service.complete_main_workflow_job(...) 之後、return 之前:

    # v1 — design §8.2:寫 GRC JobComment(reuse 既有 service)
    # CRITICAL:job_uid 是 JobExecution.uid (UUID),不是 BPMN element id;
    # complete_main_workflow_job 後 curr_job_execution 已 COMPLETED,
    # 改用 template_job_id filter(不限 status)拿 uid
    if comment and comment.strip():
        try:
            job_exec = self._job_execution_domain_service.get_job_execution(
                JobExecutionQueryEntity(
                    workflow_execution_id=wf_ctx["workflow_execution_id"],
                    template_job_id=curr_job_template.id,
                )
            )
            if job_exec is None:
                logger.warning(
                    "JobExecution not found for template_job_id=%s — skip JobComment write",
                    curr_job_template.id,
                )
            else:
                self._job_comment_service.add_comment(
                    job_uid=str(job_exec.uid),
                    content=comment.strip(),
                    user_id=user_id,
                    author_nickname=ctx.get("user_nickname") or curr_user,
                )
        except Exception:
            logger.warning("write GRC JobComment failed (non-blocking)", exc_info=True)

Task M8.4: Route 層把 schema 載入的 decision/comment 傳進 service

Files:

  • Modify: api/flow_engine/routes/stage_advance_route.py

既有 route body(line 50–69 附近)長這樣:

def post(self, project_uid, ap_uid, service=...):
    body = StageAdvanceRequestSchema().load(request.get_json(silent=True) or {})
    user_ctx = get_user_context()
    ctx = dict(body.get("ctx") or {})
    ctx.setdefault("user_nickname", user_ctx.nickname)

    result = service.advance_stage(
        project_uid=project_uid,
        ap_uid=ap_uid,
        user_id=user_ctx.id,
        curr_user=user_ctx.login_name,
        force=bool(body.get("force", False)),
        ctx=ctx,
    )
    return return_response(True, StageAdvanceResponseSchema().dump(result))

只需在 service.advance_stage(...) 加兩個 kwargs:

    result = service.advance_stage(
        project_uid=project_uid,
        ap_uid=ap_uid,
        user_id=user_ctx.id,
        curr_user=user_ctx.login_name,
        force=bool(body.get("force", False)),
        decision=body.get("decision"),                 # 新增
        comment=body.get("comment"),                   # 新增
        ctx=ctx,
    )

不動 ctx build 邏輯 — 既有 user_nickname 來自 user_ctx.nickname,service M8.3 寫 JobComment 已會從 ctx.get("user_nickname") 取,wiring 一致。

Task M8.5: DI container — 把 JobCommentService 注進 StageAdvanceService

Files:

  • Modify: di_containers/grc/grc_containers.py
stage_advance_service = providers.Factory(
    StageAdvanceService,
    stage_registry=flow_template_container.stage_registry,
    stage_object_domain_service=flow_template_container.stage_object_domain_service,
    assessment_plan_extension_domain_service=assessment_plan_extension_domain_service,
    workflow_execution_service=workflow_execution_container.workflow_execution_service,
    workflow_execution_domain_service=workflow_execution_container.workflow_execution_domain_service,
    job_execution_domain_service=workflow_execution_container.job_execution_domain_service,
    project_domain_service=project_container.project_domain_service,
    project_participant_domain_service=project_participant_container.project_participant_domain_service,
    job_comment_service=job_comment_service,                              # 新增
)

Task M8.6: smoke + commit

lsof -ti :8000 | xargs kill -9 2>/dev/null; sleep 1
nohup python main_socketio.py > /tmp/be.log 2>&1 &
sleep 3 && tail -20 log/app.log

Expected: 無 DI 錯誤。

依 design §12.2 流程 smoke。詳細放 M11 全範圍 smoke。

git add app/flow_engine/service/stage_advance_service.py \
        api/flow_engine/routes/stage_advance_route.py \
        di_containers/grc/grc_containers.py
git commit -m "$(cat <<'EOF'
feat(spec4-M8): StageAdvanceService 接 decision/comment + JobComment 整合

對應 spec 4 design.md §6.1 + §8.2 + §8.3:
- advance_stage 接受 decision/comment optional 參數
- 非 review stage 送 decision 忽略 + log warning(design §6.1)
- _build_condition_param 加 review 分支:{"decision": "approve"|"reject"}
- complete_main_workflow_job 後寫 GRC JobComment(reuse JobCommentService)
  CRITICAL fix:先查 JobExecution.uid (UUID) 再傳,不能直接用 BPMN element id
- DI wiring:StageAdvanceService 加 job_comment_service 依賴

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

§12

M9 — BE integration test:advance_stage decision/comment + validator route

Task M9.1: 寫 integration test

Files:

  • Create: tests/test_stage_advance_v4.py
"""Spec 4 — StageAdvanceService decision/comment integration test."""
import pytest


def test_advance_with_approve_decision_no_comment(app_client, headers, seed_review_ap):
    """approve + 無 comment → 200,BPMN 走 forward"""
    resp = app_client.post(
        f"/project/{seed_review_ap['project_uid']}/ap/{seed_review_ap['ap_uid']}/stage/advance",
        json={"decision": "approve"},
        headers=headers,
    )
    assert resp.status_code == 200
    body = resp.get_json()
    assert body["status"] is True
    assert body["data"]["advanced"] is True


def test_advance_with_reject_requires_comment(app_client, headers, seed_review_ap):
    """reject + 無 comment → 400"""
    resp = app_client.post(
        f"/project/{seed_review_ap['project_uid']}/ap/{seed_review_ap['ap_uid']}/stage/advance",
        json={"decision": "reject"},
        headers=headers,
    )
    assert resp.status_code == 400


def test_advance_with_reject_and_comment(app_client, headers, seed_review_ap):
    """reject + comment → 200,BPMN 回 task_execution"""
    resp = app_client.post(
        f"/project/{seed_review_ap['project_uid']}/ap/{seed_review_ap['ap_uid']}/stage/advance",
        json={"decision": "reject", "comment": "需要重新整理證據"},
        headers=headers,
    )
    assert resp.status_code == 200
    # GET stage info 確認回到 task_execution
    info = app_client.get(
        f"/project/{seed_review_ap['project_uid']}/ap/{seed_review_ap['ap_uid']}/stage/info",
        headers=headers,
    ).get_json()
    assert info["data"]["current_stage"]["code"] == "task_execution"


def test_advance_non_review_stage_ignores_decision(app_client, headers, seed_planning_ap):
    """非 review stage 送 decision → ignore + log warning,不擋"""
    resp = app_client.post(
        f"/project/{seed_planning_ap['project_uid']}/ap/{seed_planning_ap['ap_uid']}/stage/advance",
        json={"decision": "reject", "comment": "x"},   # planning 不該理 decision
        headers=headers,
    )
    # Planning 推進條件 OK 就 200;非 review stage decision 被吞掉
    # (此 test 主要驗 schema 不擋;log warning 用 caplog 驗)
    assert resp.status_code in (200, 412)   # 412 是因為 planning all_tasks_assigned precondition


def test_validate_endpoint_pass(app_client, headers):
    xml = open("scripts/sql/seeds/bpmn/builtin-internal-check.bpmn").read()
    resp = app_client.post(
        "/flow-engine/flow-templates/validate",
        json={"bpmn_xml": xml},
        headers=headers,
    )
    assert resp.status_code == 200
    assert resp.get_json()["data"]["valid"] is True


def test_validate_endpoint_fail(app_client, headers):
    xml = open("tests/fixtures/bpmn/invalid_start_to_poam.bpmn").read()  # 需建 fixture
    resp = app_client.post(
        "/flow-engine/flow-templates/validate",
        json={"bpmn_xml": xml},
        headers=headers,
    )
    assert resp.status_code == 200
    body = resp.get_json()["data"]
    assert body["valid"] is False
    assert any(v["rule"] == "start_stage_not_allowed" for v in body["violations"])
mkdir -p tests/fixtures/bpmn
# 把 M3 test 內 _xml_with_start_to_poam() 回傳的 XML 寫成檔

tests/fixtures/bpmn/invalid_start_to_poam.bpmn

<?xml version="1.0"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
                  xmlns:camunda="http://camunda.org/schema/1.0/bpmn">
  <bpmn:process id="P" isExecutable="true">
    <bpmn:startEvent id="S"><bpmn:outgoing>F1</bpmn:outgoing></bpmn:startEvent>
    <bpmn:userTask id="UT_p">
      <bpmn:extensionElements><camunda:properties>
        <camunda:property name="stage_object_code" value="poam"/>
      </camunda:properties></bpmn:extensionElements>
      <bpmn:incoming>F1</bpmn:incoming><bpmn:outgoing>F2</bpmn:outgoing>
    </bpmn:userTask>
    <bpmn:endEvent id="E"><bpmn:incoming>F2</bpmn:incoming></bpmn:endEvent>
    <bpmn:sequenceFlow id="F1" sourceRef="S" targetRef="UT_p"/>
    <bpmn:sequenceFlow id="F2" sourceRef="UT_p" targetRef="E"/>
  </bpmn:process>
</bpmn:definitions>

既有 tests/conftest.py 有 session-scoped app / app_client / token / headers fixture(line 99~228)— mirror 該 pattern,加 function-scoped fixture:

import pytest
from app.project.service.oscal_project_service import OscalProjectService
from di_containers.containers import Containers


@pytest.fixture
def seed_review_ap(app, headers):
    """建一個用「完整稽核流程(含審核)」範本的 AP,推進到 review stage。

    依賴:dev DB 已 seed builtin-full-audit-with-review (M4)
          測試帳號 blsit 是某 tenant 內 manager
    """
    container = Containers.grc_container
    oscal_project_service = container.oscal_project_service()
    flow_template_domain_service = Containers.flow_engine_container.flow_template_domain_service()

    with app.app_context():
        master = flow_template_domain_service.get_builtin_by_name("完整稽核流程(含審核)")
        assert master is not None, "M4 builtin seed missing"

        project = oscal_project_service.start_oscal_project(
            project_name=f"test-spec4-review-{pytest.helpers.uuid4_short()}",
            ...其他 required params 依既有 fixture pattern 補...,
            flow_template_uid=master.uid,
            curr_user="blsit",
        )
        # 此時 AP 在 planning stage;接著 advance planning → task_execution → review
        ap_uid = project.assessment_plans[0].uid
        # ... advance helper(mirror Spec 3 test fixture pattern)...
        return {"project_uid": str(project.uid), "ap_uid": str(ap_uid)}


@pytest.fixture
def seed_planning_ap(app, headers):
    """同上但停在 planning stage(給 non-review-stage test 用)"""
    ...

注意:fixture 寫法需 mirror 既有 spec 2/3 test pattern(如 tests/test_stage_advance_service_metadata.py 若存在);若 advance helper 不好寫,可改用 manual seed DB(INSERT row 直接到 PROCESSING 階段)— 但這較脆弱,不推薦。

如果 dev DB 上跑 integration test 較複雜,可改採 unit test with mocks(mock domain services + stage_registry),把這幾 case 改成 service-level test — 對齊 Spec 3 §16.1 條 3 的 deferral pattern。

pytest tests/test_stage_advance_v4.py -v

Expected: all pass。fixture seeding 可能需要 conftest 補助 — 若 dev DB 無「review stage AP」現成資料,可改用 app_service.start_oscal_project(flow_template_uid=...) 在 fixture 內建。

git add tests/test_stage_advance_v4.py tests/fixtures/bpmn/
git commit -m "$(cat <<'EOF'
test(spec4-M9): StageAdvance decision/comment integration tests

對應 spec 4 design.md §12.1:
- approve no-comment / reject required-comment / reject with-comment
- 非 review stage 送 decision 不擋(ignore)
- validate endpoint pass / fail

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

§13

M10 — FE:Banner approve/reject 雙按鈕 + ConfirmDialog comment + BPMN editor inline warning

此段在 compliance-manager-fe repo 進行。BE plan 已完成,可獨立交付。 跨 repo commit 不可 atomic:BE M1~M9 commit + push 完才切到 FE。 切到 FE repo:

cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
git fetch
git checkout feature/project-flow-engine-review 2>/dev/null || git checkout -b feature/project-flow-engine-review

Task M10.1: i18n 加新 key

Files:

  • Modify: src/locales/zh-TW.json
  • Modify: src/locales/en.json
{
  "flowEngine": {
    "validator": {
      "successor_not_allowed": "「{source_stage}」階段後不可接「{target_stage}」階段",
      "start_stage_not_allowed": "流程不可從「{stage}」階段開始",
      "end_stage_not_allowed": "「{stage}」階段不可直接結案",
      "unknown_stage_code": "未知的階段代碼:{code}",
      "gateway_missing_condition": "決策節點「{gateway_id}」缺少分支條件",
      "unreachable_node": "節點「{node_id}」無法從開始點抵達",
      "no_end_path": "找不到從開始到結束的完整路徑"
    },
    "banner": {
      "review": {
        "submit": "送出審核",
        "reject": "退回",
        "rejectReasonRequired": "請說明退回原因(必填)",
        "commentOptional": "可選填說明"
      }
    }
  }
}

Task M10.2: FlowPhaseBanner.vue 雙按鈕 + ConfirmDialog comment

Files:

  • Modify: src/components/grc/FlowPhaseBanner.vue

設計重點:同一個 Dialog instance 被所有 stage 共用,靠 rejectMode flag 切換 placeholder 與 confirm-button disable 條件。對應 design §10.3「全 stage advance 加可選 comment textarea (reuse 同元件)」要求 — 非 review stage 點主按鈕也走 openAdvanceDialog(rejectMode=false) 開同一個 Dialog;review reject 按次按鈕走 openRejectDialog(rejectMode=true)

詳細 UI 跟 design §10.1 對應;ConfirmDialog confirmation 流程:

<template>
  ...既有 banner...
  <Button v-if="currentStage?.code === 'review'"
          severity="secondary"
          @click="openRejectDialog">
    {{ $t('flowEngine.banner.review.reject') }}
  </Button>
  <Button @click="openAdvanceDialog">
    {{ buttonLabel }}
  </Button>

  <Dialog v-model:visible="advanceDialogVisible" :header="$t(...)" modal>
    <Textarea v-model="comment" rows="4" autoResize
              :placeholder="commentPlaceholder" />
    <template #footer>
      <Button :label="$t('common.cancel')" text @click="advanceDialogVisible=false"/>
      <Button :label="$t('common.confirm')"
              :disabled="rejectMode && !comment.trim()"
              @click="submitAdvance" />
    </template>
  </Dialog>
</template>

<script setup>
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'

const { t } = useI18n()
const advanceDialogVisible = ref(false)
const rejectMode = ref(false)
const comment = ref('')

const commentPlaceholder = computed(() =>
  rejectMode.value
    ? t('flowEngine.banner.review.rejectReasonRequired')
    : t('flowEngine.banner.review.commentOptional')
)

function openAdvanceDialog() {
  rejectMode.value = false
  comment.value = ''
  advanceDialogVisible.value = true
}
function openRejectDialog() {
  rejectMode.value = true
  comment.value = ''
  advanceDialogVisible.value = true
}

async function submitAdvance() {
  await advanceStage({
    decision: rejectMode.value ? 'reject' : 'approve',
    comment: comment.value.trim() || undefined,
  })
  advanceDialogVisible.value = false
  emit('advanced')
}
</script>

Task M10.3: Service / API client

Files:

  • Modify: src/service/FlowPhaseService.js(或對應 stage advance service)
export async function advanceStage(projectUid, apUid, payload) {
  // payload: { force?, decision?, comment? }
  const { data } = await api.post(
    `/project/${projectUid}/ap/${apUid}/stage/advance`,
    payload,
  )
  return data
}

Task M10.4: BPMN editor inline warning(拖 sequenceFlow 呼叫 validate endpoint)

Files:

  • Modify: src/views/flow-template/FlowTemplateEditView.vue
import { debounce } from 'lodash'
const violations = ref([])

const validateBpmn = debounce(async (xml) => {
  try {
    const { data } = await flowTemplateService.validate({ bpmn_xml: xml })
    violations.value = data.violations || []
    highlightViolations(violations.value)
  } catch (err) {
    // 400 from BE — extract violations from envelope.data
    violations.value = err?.response?.data?.data?.violations || []
    highlightViolations(violations.value)
  }
}, 500)

// 監聽 bpmn-js modeler 的 commandStack changed 事件
modeler.on('commandStack.changed', () => {
  modeler.saveXML({ format: true }).then(({ xml }) => validateBpmn(xml))
})
<aside v-if="violations.length" class="validator-panel">
  <h4>流程結構警告</h4>
  <ul>
    <li v-for="(v, i) in violations" :key="i">
      {{ $t(v.reason_i18n_key, v.context) }}
    </li>
  </ul>
</aside>
function highlightViolations(viols) {
  const canvas = modeler.get('canvas')
  // clear previous markers
  modeler.get('elementRegistry').getAll().forEach(el =>
    canvas.removeMarker(el.id, 'violation-marker')
  )
  viols.forEach(v => {
    const flowId = v.context?.sequence_flow_id
    if (flowId) canvas.addMarker(flowId, 'violation-marker')
  })
}

CSS:

.djs-element.violation-marker .djs-visual > * {
  stroke: var(--danger-color, #dc3545) !important;
  stroke-width: 2px !important;
}

Task M10.5: Palette 加 review UserTask 預設 stencil

Files:

  • Modify: src/views/flow-template/components/BpmnPaletteProvider.js(或 palette config)

依既有 4 個 stage palette 模式加:

'create.review-task': createTask({
  type: 'bpmn:UserTask',
  name: '審核',
  properties: {
    'stage_object_code': 'review',
    'main_role': 'reviewer',
  },
}),

Task M10.6: Manual smoke FE side

cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe && npm run dev

依 design §12.2 必過項操作:

  1. 建專案選 「完整稽核流程(含審核)」→ Banner review 階段顯示送出 + 退回雙按鈕
  2. 拖 BPMN editor sequenceFlow 從 Gateway 指向 poam → 側邊欄出現 warning
  3. reject 不填 comment → 確認按鈕 disable

Task M10.7: Commit FE

cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
git add src/locales/ src/components/grc/FlowPhaseBanner.vue \
        src/views/flow-template/ src/service/FlowPhaseService.js
git commit -m "$(cat <<'EOF'
feat(spec4-fe): Banner approve/reject + BPMN editor inline warning

對應 BE spec 4 design.md §10:
- FlowPhaseBanner review 階段加退回按鈕 + ConfirmDialog comment textarea
- 全 stage advance ConfirmDialog 加可選 comment 欄位
- BPMN editor 拖 sequenceFlow 時 debounce 呼叫 /flow-templates/validate
- 違規 sequenceFlow 紅色 outline + 側邊欄 warning list
- Palette 加 review UserTask 預設 stencil
- i18n zh-TW / en 對應 keys

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

§14

M11 — Manual smoke + 跨 repo BDD + changelog

Task M11.1: Manual end-to-end smoke

依 design §12.2 完整跑一輪:

    • AP1 第一輪走 planning → task_execution → review
    • reviewer 在 review banner 按「退回」+ 寫退回原因(comment 必填)
    • 確認:BPMN 回 task_execution UserTask;JobComment 列表可見退回原因
    • 確認:BPMN 走到 audit UserTask
    • 確認 AP2 走原本 4-stage 流程,行為與 Spec 2 ship 後一致
    • 確認:側邊欄即時警告 + 畫布紅框

Task M11.2: BDD scenario(compliance-manager-test repo)

cd ~/Projects/Billows/Audit-Manager/compliance-manager-test
git checkout feature/project-flow-engine-review

features/flow-engine-review/review-stage.feature

Feature: Review stage approve/reject flow

  Scenario: Reviewer rejects with reason, PM re-submits, reviewer approves
    Given a project with "完整稽核流程(含審核)" template, PM "blsadmin" and reviewer "blsreviewer"
    And the AP is in "review" stage
    When reviewer logs in and rejects with reason "缺證據"
    Then the AP returns to "task_execution" stage
    And the job comment "缺證據" is visible in the audit trail
    When PM logs in and advances "task_execution" → "review"
    And reviewer approves with no comment
    Then the AP moves to "audit" stage

對應 step definition + page object(mirror 既有 banner BDD pattern)。

npm run test:bdd -- --tags @flow-engine-review

Task M11.3: Changelog 收尾(BE + FE 各一份;test repo 不需)

Files:

  • Create BE: docs/changelog/2026-05-14-feat-review-stage-flow-validator.md
  • Create FE: docs/changelog/2026-05-14-feat-review-stage-flow-validator.md
---
type: feat
breaking: false
modules: [flow-engine, grc]
issue: ~
commit: <bumped after commit>
---

# Review stage 階段 + 流程方向性驗證(Spec 4)

## 需求說明

在 BPMN 流程中加入 reviewer 過審環節(approve/reject),不通過時由範本指定退回路徑;
範本 create/update 時加方向性驗證避免不合理流程(如 start → poam)。

## 變更範圍

### Schema
- `compliance.stage_objects` 加 4 欄:can_start / can_end / allowed_predecessors / allowed_successors
- seed 第 5 個 stage_object: `review`

### API
- `POST /flow-engine/flow-templates/validate` — 純驗證 endpoint(不存檔)
- `POST /flow-engine/flow-templates`(create / update)— 加 topology 驗證
- `POST /project/<uid>/ap/<ap>/stage/advance` — 加 decision/comment optional 欄位
  - decision=reject 必填 comment(BE 強制,GRC_400061)
  - 非 review stage 送 decision 忽略 + log warning

### Service
- `FlowTemplateAppService._validate_bpmn_topology` 整合 7 條規則驗證
- `StageAdvanceService.advance_stage` 接 decision/comment + reuse JobCommentService
- `ReviewDecisionHandler` 註冊到 stage_registry

### Builtin
- 新範本 `builtin-full-audit-with-review`(並列既有 builtin-full-audit)
- 既有 builtin-full-audit + 自訂 duplicate 範本 backfill:Flow_poam_audit 加 reverse extension property

### Error code(3 個新)
- `GRC_400060` 範本流程結構不合法
- `GRC_400061` 退回審核必須附留言
- `GRC_400062` decision 值不合法

## 測試結果

- pytest `tests/test_bpmn_topology_validator.py` — 14 條 pass(含 9 條 regex 邊界 case)
- pytest `tests/test_stage_advance_v4.py` — 6 條 integration test pass
- Manual smoke 6 步完整跑通

## 參考資訊

- spec: `docs/features/FR-026-2605-project-flow-engine-integrate/04-review-stage-and-flow-validation/design.md` v1.1
- plan: `docs/features/FR-026-2605-project-flow-engine-integrate/04-review-stage-and-flow-validation/implementation-plan.md`

§15

收尾 checklist

不在本 plan 範圍(後續 spec):

  • 範本管理 UI 顯示 stage_object 的 allowed_predecessors / allowed_successors(給 user 看 hint)
  • 多 reviewer 串聯審核
  • Audit Trail timeline view(聚合 JobComment)
  • Spec 2 poam → audit 改成「真 reverse + Dialog 二擇一」(design §9.1 reconsideration)

§16

文件版本

版本 日期 變更
v1 2026-05-14 初版,11 milestones / 50+ tasks / TDD style
v1.1 2026-05-14 plan reviewer 反饋整合:(1) M5.3 / 全文 di_containers 檔名修正 flow_engine_containers.pyflow_template_containers.py;(2) M10.2 FE 路徑修正 src/components/grc/banner/src/components/grc/;(3) M8.4 route nickname pattern 對齊既有 user_ctx.nickname,不破壞 wiring;(4) M9 補 seed_review_ap / seed_planning_ap fixture 骨架 + 退路(unit test mock);(5) M4.3 Step 2 python script 改 str.replace 避免 regex metachar;(6) M10 跨 repo workflow 補 git checkout 指令;(7) M10.2 加 Dialog reuse pattern 說明;(8) M11.3 日期 placeholder 改 literal 2026-05-14