Spec 4:審核階段 + 流程方向性驗證

狀態:v1 — brainstorm 收斂版,待 review 整體脈絡../README.md 依賴:Spec 1 / Spec 2 / Spec 3(皆已 ship) 建立日期:2026-05-14


§1

一、目標

兩個獨立但相關的子題:

  1. 審核(review)階段:在「執行任務」與「稽核」之間插入一個由 reviewer 角色主導的人工過審節點;不過時可由範本指定退回到任一上游 UserTask;過/退回都可附留言。
  2. 流程方向性驗證:範本 create/update 時 BE 主動驗證 BPMN 圖結構是否「合理」(如禁止 start → poam 開頭),給 stage_object 補上下游規則欄位作為「真實來源」。

兩題共用同一份 spec 因 schema 動的是同一張 compliance.stage_objects 表、driver code 都在 FlowTemplateAppService._validate_bpmnStageAdvanceService.advance_stage


§2

二、需求溯源

來源 重點
docs/draft_requirement_spec/project-flow-engine-integrate/requirement.md requirement.md 沒明列 review 階段,這是後續加的訴求
本次 brainstorm Q1 「插入一個審核工作節點,不合格可退回上一階段,可留言更好」
本次 brainstorm Q2 「流程相依性問題 — 例如第一步就是缺失改善是不合理的;範本存檔時要檢查方向性 + 防呆」

§3

三、Brainstorm 結論

# 主題 決議
Q1 審核形態 新增第 5 個 stage_object review(kind=stateful,主要角色 reviewer)
Q2 退回 granularity 由 BPMN 範本決定(在 review UserTask 後接 ExclusiveGateway,reject sequenceFlow 可指向任一上游 UserTask)
Q3 留言 Reuse 既有 JobCommentService;所有 stage advance 共用 — 不只 review reject
Q4 驗證規則來源 stage_object 加上下游規則欄位(declarative);validator 讀表,新增 stage 不改 code
Q5 Reverse edge 處理 Gateway 的 reverse edge 不受 allowed_successors 拘束 — 用 condition 含 reject 或 extension property reverse=true 識別
Q6 驗證時機 BE create/update 反 400 + FE editor inline warning(FE 拖 sequenceFlow 時呼叫 /validate preview)
Q7 預設規則寬鬆度 全部 stage 都 can_end=true(容許「練習設定」),但 can_startplanning(避開 start → poam 等不合理開頭)
公開 1 builtin 範本配置 並列:保留既有 builtin-full-audit,新增 builtin-full-audit-with-review 為另一可選範本
公開 2 review approve precondition 不卡:reviewer 自由判斷,沿用 Spec 2 §五 v1 不強制 gate
公開 3 comment 約束 BE 強制decision=reject AND comment IS NULL/empty → 400

§4

四、範圍

In scope

A — 審核階段

  • compliance.stage_objects seed 新增 review 一列
  • StageAdvanceService.advance_stage 接受 decision: 'approve' | 'reject' + comment payload
  • _build_condition_param 加 review 分支:{"decision": "approve" | "reject"}
  • ReviewDecisionHandler(不動 AP.status;純粹 dispatch BPMN 推進方向)
  • BPMN engine 已支援 ${decision == 'reject'} 條件式(既有 condition_param 機制,不改套件)
  • BE 強制驗證 decision=reject 必填 comment(webargs schema layer)

B — 全 stage 共用 comment

  • StageAdvanceRequestSchema 加 optional comment 欄位(reject 場景 required,由 marshmallow validates schema 控制)
  • StageAdvanceService.advance_stage 推進 BPMN 後同步 JobCommentService.add_comment(job_uid=當前 PROCESSING job_execution.uid, ...)
  • FE Banner ConfirmDialog 加 comment textarea(approve / 一般 stage advance optional;reject required)

C — 流程方向性驗證

  • compliance.stage_objects 加 4 個欄位:can_start BOOLEANcan_end BOOLEANallowed_predecessors JSONBallowed_successors JSONB
  • 5 個 stage 規則回填(含新 review)
  • FlowTemplateAppService._validate_bpmn_topology() 演算法(7 步檢查,見 §七)
  • 新 endpoint POST /flow-engine/flow-templates/validate(純驗證不存檔)
  • 6 個新 error code(含具體違規 reason context)

D — Builtin 範本

  • 新增 builtin-full-audit-with-review(並列於既有 builtin-full-audit)
  • 既有 3 個 builtin 範本驗證能通過新 validator(poam → audit reverse edge 需補 extension property)

Out of scope(明確不做)

  • 修改 jedi-flow-engine 套件(既有 ExclusiveGateway / condition_param 已足用)
  • review approve 的 all_controls_reviewed precondition(公開議題 2 決議不卡)
  • 既有 review_mark API 行為改動(reviewer 仍可逐控制項標記,純資訊性)
  • 用 Comment 表做「審計軌跡 timeline view」UI(→ 後續 spec)
  • 跨範本「stage 物件 CRUD UI」(→ 後續 spec,本 spec 僅 seed)
  • Multi-reviewer 串聯審核(每個 review UserTask 仍是單一推進角色集合)

§5

五、Schema 改動

5.1 compliance.stage_objects 加欄位

ALTER TABLE compliance.stage_objects
    ADD COLUMN can_start BOOLEAN NOT NULL DEFAULT FALSE,
    ADD COLUMN can_end BOOLEAN NOT NULL DEFAULT TRUE,
    ADD COLUMN allowed_predecessors JSONB NOT NULL DEFAULT '[]'::jsonb,
    ADD COLUMN allowed_successors JSONB NOT NULL DEFAULT '[]'::jsonb;

預設值意涵:新加 stage 預設「不能當 start、可以 end、上下游皆空(→ 必須明確 seed 才合法)」— 避免 builtin seed 漏設導致 validator silent pass。

5.2 5 個 stage 規則(覆蓋既有 4 個 + 新 review)

-- planning:起點,自由銜接任意 stage
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';

-- task_execution:通常承 planning 或 review reverse
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';

-- review:v1 新加
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;

-- audit:承 task_execution / review / poam
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';

-- poam:純承 audit,forward 只能接 End;poam → audit 是 reverse(走 §七 B.3 例外)
UPDATE compliance.stage_objects
   SET can_start = FALSE,
       can_end = TRUE,
       allowed_predecessors = '["audit"]'::jsonb,
       allowed_successors = '[]'::jsonb
 WHERE code = 'poam';

規則表只列 forward:reverse edge(如 review reject → task_execution、poam → audit)不在 allowed_successors 內,靠 §七 B.3 reverse 例外規則放行。這讓規則表保持「合法 forward 拓樸」單一語意,validator 對 reverse 走獨立判斷分支。

整理過後的查詢矩陣:

code can_start can_end allowed_predecessors allowed_successors(forward 唯一來源)
planning task_execution, review, audit, poam
task_execution planning, review audit, review
review planning, task_execution audit
audit task_execution, review, poam poam
poam audit

task_execution.allowed_successors=["audit", "review"] 兩條 forward 路徑:

  • → audit:對應「無 reviewer 流程」(既有 builtin-full-audit
  • → review:對應「有 reviewer 流程」(新 builtin-full-audit-with-review

poam.allowed_successors=[]poam.can_end=true 表達「poam 唯一 forward 是接 End;poam → audit 走 reverse」。

5.3 為什麼用 JSONB 而不是關聯表

stage_object_predecessors / stage_object_successors 關聯表表達力更完整,但這份「規則表」量級在 N²(N=5~10)、語意上是 stage_object 的內在屬性、且很少獨立查詢,用 JSONB 集中放在同一列:

  • 讀寫一筆即拿到完整規則(validator 一次 query 5 列)
  • migration 簡單(不用維護 FK 一致性)
  • 未來需要關聯式查詢時可派生 view,不需重構

§6

六、API 改動

6.1 StageAdvanceRequestSchema 擴充

class StageAdvanceRequestSchema(Schema):
    project_uid = fields.UUID(required=True)
    ap_uid = fields.UUID(required=True)
    force = fields.Boolean(load_default=False)

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

    @validates_schema
    def _validate_reject_requires_comment(self, data, **kwargs):
        # 公開議題 3:BE 強制
        if data.get("decision") == "reject" and not (data.get("comment") or "").strip():
            raise ValidationError(
                {"comment": ["reject 必須附留言"]},
                field_name="comment",
            )

非 review stage 送 decision 的處理(reviewer 反饋):schema 不額外驗證;StageAdvanceService.advance_stage 在 stage_code != "review" 時把 ctx["decision"] 忽略並 logger.warning("decision payload ignored for non-review stage %s", stage_code)不 raise 400,因為這純粹是 caller 多送資料,不影響業務正確性;忽略 + log 比阻擋更友善。

6.2 StageAdvanceResponseSchema 加欄位(optional)

current_stage_info 內含 current_stage.complete_button_label_i18n 已存在。review stage 的 reject 按鈕 label 由 FE 直接從 i18n bundle 取(key flowEngine.banner.review.reject,見 §10.4)— 不在 stage_objects 表加新欄位、不在 response 額外回,因為:

  • v1 只有 review 有 reject 按鈕(特化行為),不值得 schema 動
  • i18n 文案修改流程在 FE 完整(FE i18n PR 直接調,BE 不參與)

未來若多個 stage 需要 reject 按鈕,再考慮把 reject_button_label_i18n 加進 stage_objects 列。

6.3 新 endpoint POST /flow-engine/flow-templates/validate

POST /flow-engine/flow-templates/validate
{ "bpmn_xml": "<?xml ..." }

Response 200:
{
  "valid": true,
  "warnings": []                      // 未來 lenient warning list 預留
}

Response 400(既有 envelope):
{
  "code": 0,
  "msg": "BPMN topology validation failed",
  "data": {
    "error_code": "GRC_400060",
    "violations": [
      {
        "rule": "successor_not_allowed",
        "reason_i18n_key": "flow_engine.validator.successor_not_allowed",
        "context": {
          "source_stage": "planning",
          "target_stage": "poam",
          "sequence_flow_id": "Flow_xx"
        }
      }
    ]
  }
}

FlowTemplateAppService.create / .update 內部復用同一 validator function — 違規會 raise BadRequestError(GRC_FLOW_TEMPLATE_TOPOLOGY_INVALID) with error.context = {"violations": [...]} 攜帶細節。


§7

七、Validator 演算法

7.1 整體流程

_validate_bpmn(xml)                     ← 既有,保留
    ↓
_validate_bpmn_topology(util)            ← v1 新增
    ↓ pass
return / persist
    ↓ fail
raise BadRequestError(GRC_FLOW_TEMPLATE_TOPOLOGY_INVALID,
                      data={"violations": [...]})

7.2 檢查步驟(7 條)

# 規則 違規 error reason_key
1 StartEvent 後第一個 UserTask 的 stage_object_code 對應 stage_object 的 can_start = TRUE start_stage_not_allowed
2 每個 UserTask 的 stage_object_code 存在於 compliance.stage_objects unknown_stage_code
3 每條 forward UserTask→UserTask 的 sequenceFlow,target.stage_code ∈ source.allowed_successors(reverse edge 跳過此檢查,見 B.3) successor_not_allowed
4 每條連到 EndEvent 的 sequenceFlow,來源 UserTask 對應 stage_object 的 can_end = TRUE end_stage_not_allowed
5 每個 ExclusiveGateway 至少 1 條 outgoing 帶 conditionExpression(不允許全空 default-only) gateway_missing_condition
6 每個 UserTask 從 StartEvent 走 forward edge 可達(BFS 時略過所有 reverse edge);且至少存在一條由 forward edge 組成的 StartEvent → EndEvent 路徑 unreachable_node / no_end_path
7 同一 stage_object_code 可重複出現多次(例:兩道 review)— 不擋;但若違反 §3 successor 規則仍會被擋 (不擋)

7.3 Forward vs Reverse edge 判定(B.3 例外)

對每條 sequenceFlow(source=Gateway 或 UserTask、target=UserTask):

# Primary:extension property(穩定來源,FE editor 拖出 reject 流程時自動加上)
is_reverse_by_property =
    sequenceFlow has <camunda:property name="reverse" value="true"/>

# Fallback:condition 字串嚴格 anchored regex(給手寫 / 舊範本兼容)
REVERSE_CONDITION_REGEX = re.compile(
    r"^\s*\$\{\s*[A-Za-z_][A-Za-z_0-9]*\s*==\s*['\"]reject['\"]\s*\}\s*$"
)
is_reverse_by_condition =
    REVERSE_CONDITION_REGEX.fullmatch(sequenceFlow.conditionExpression or "")

is_reverse = is_reverse_by_property OR bool(is_reverse_by_condition)

if is_reverse:
    skip §7.2 step 3 successor check
    skip §7.2 step 6 reachability 時把這條 edge 視為不存在

設計選擇

  • extension property 是 primary:FE editor 拖 reject sequenceFlow 時自動填 <camunda:property name="reverse" value="true"/>;穩定、不靠字串 match
  • 嚴格 anchored regex 是 fallback:只 match 形如 ${var == 'reject'}(exact)— 變數名 decision / review_status / verdict 都可,但 quote 後必須緊接 }(含可選空白)
  • 明確不 match 的 case
    • ${review_status == 'rejected_in_form'}(false positive 防範)→ quote 內必須完全是 reject
    • ${decision != 'approve'}(false negative 接受)→ 不靠 condition 識別,需 FE 補 extension property
    • ${decision == 'Reject'}(大小寫敏感)→ 必須小寫 reject,rationale:BPMN best practice 是 lowercase enum
    • ${ a == 'reject' && b == 'foo' }(複合條件)→ 不 match,建議改用 extension property

未來反悔條件:若客戶提複合 reject condition 需求,加 stage_object.reverse_condition_keywords JSONB 欄位讓 user 自訂;或乾脆把 condition match 邏輯改成「在 BPMN engine 端 evaluate 條件值」(成本較高)。

7.4 為什麼 Gateway 全空 default-only 不允許(step 5)

ExclusiveGateway 的核心是「分支」。若所有 outgoing 都無 condition,runtime 永遠走 default flow → gateway 形同 sequence pass-through,等同無意義節點且暗示範本設計錯誤。直接 400 反而給 user 明確 signal。

7.5 既有範本兼容性 + 自訂範本 backfill

範本 過 validator? 需要動的事
builtin-full-audit 需 patch Flow_poam_audit (poam → audit) sequenceFlow 需加 <camunda:property name="reverse" value="true"/> 才被識別為 reverse;不加會違反 poam.allowed_successors=[]
builtin-internal-check ✓ pass task_execution.can_end=true,無 reverse edge
builtin-self-assessment ✓ pass audit.can_end=true,無 reverse edge
builtin-full-audit-with-review(v1 新增) ✓ pass 建範本時直接加好 Flow_review_reject + Flow_poam_audit 兩條 reverse property

自訂範本 backfill(reviewer 提醒):Spec 1 已 ship 三週,使用者可能已 duplicate 過 builtin-full-audit 成自訂範本(XML 不含 reverse property)。Migration 必須同時:

  1. patch builtin 本體 XML
  2. scan 所有 compliance.flow_templates where is_builtin=false,若 bpmn_xmlFlow_poam_audit (sourceRef=UserTask_poam && targetRef=UserTask_audit),補相同 extension property

掃描 + 修改用 lxml / xmltodict 處理,dry-run 先列要動的 row讓 user 確認後再執行(避免炸壞客製範本)。實作放 M4 milestone。


§8

八、Handler / Service 改動

8.1 ReviewDecisionHandler

app/grc/service/oscal_stage_handlers.py 加:

class ReviewDecisionHandler(IStageCompletionHandler):
    """review stage 推進 — 純 dispatch decision,不動 AP.status。

    - decision=approve:BPMN 走 default forward;AP.status 由下個 stage 的 handler 接管
    - decision=reject:BPMN 走 reject sequenceFlow,回到上游 UserTask;
      AP.status 保持 active(既有,task_execution stage 對應的 status)

    不做業務 precondition(公開議題 2 決議 reviewer 自由判斷)。
    """
    @property
    def key(self) -> str:
        return "review_decision"

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

註冊在 di_containers/grc/grc_containers.py 啟動時加入 stage_registry。

8.2 StageAdvanceService.advance_stage 接 decision/comment payload

@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 / role / precondition 驗證 ...

    handler_result = handler.execute(..., ctx=ctx)

    ... 既有 warning short-circuit ...

    # condition_param 組裝:review stage 多接 decision
    condition_param = self._build_condition_param(
        stage_code=stage_object.code,
        handler_result=handler_result,
    )

    self._workflow_execution_service.complete_main_workflow_job(
        workflow_execution_uid=wf_ctx["workflow_execution_uid"],
        job_id=curr_job_template.id,
        user=curr_user,
        comment=comment or f"stage advance: {stage_object.code}",
        condition_param=condition_param,
        user_nickname=ctx.get("user_nickname"),
    )

    # v1 新增:寫 GRC JobComment(reuse 既有 service)
    # CRITICAL(已驗證):JobCommentService.add_comment 接受的 job_uid 是 JobExecution.uid (UUID),
    # 不是 BPMN template element id(如 "UserTask_review")。
    # curr_job_template.id 是 BPMN element id,不能直接傳;必須先查 PROCESSING JobExecution 拿 uid。
    if comment:
        try:
            job_execution = self._job_execution_domain_service.get_job_execution(
                JobExecutionQueryEntity(
                    workflow_execution_id=wf_ctx["workflow_execution_id"],
                    template_job_id=curr_job_template.id,
                    # 注意:此時 BPMN 推進已完成,curr_job_execution.status 已是 COMPLETED;
                    # 不能用 status=PROCESSING filter,改用 template_job_id 取最新一筆
                )
            )
            if job_execution 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_execution.uid),     # ← UUID,不是 BPMN element id
                    content=comment,
                    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)

    return {...}

lenient 寫 comment:BPMN engine 端 complete_main_workflow_job 已寫 element_variable 一份(既有行為)作為 BPMN-side 元資料;GRC JobComment 是面向 user 的留言層(接得到 /job-executions/<uid>/comments API)。雙寫各有用途;GRC 端失敗 log warning 不擋 advance。

Order of operations:JobExecution 查詢必須在 complete_main_workflow_job 之後做,因為當前 job 已從 PROCESSING → COMPLETED;用 template_job_id filter 取得對應 row。advance 本身是 @transaction scope,這幾個 query/insert 共用同一 transaction。

8.3 _build_condition_param 加 review 分支

def _build_condition_param(self, stage_code, handler_result) -> Optional[dict]:
    if stage_code == "audit":                        # 既有
        ...
        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

§9

九、Builtin 範本 — builtin-full-audit-with-review

StartEvent
    ↓
UserTask_planning              (main_role=manager)
    ↓
UserTask_task_execution        (main_role=manager)
    ↓
UserTask_review                (main_role=reviewer)  ← v1 新加
    ↓
Gateway_review_decision (default=Flow_review_forward)
    ├─ Flow_review_forward  (default, approve)   →  UserTask_audit
    └─ Flow_review_reject  (${decision=='reject'}, reverse=true)  →  UserTask_task_execution
                                                                           ↑(回到執行)
UserTask_audit
    ↓
Gateway_has_findings (default=Flow_gateway_end)
    ├─ Flow_gateway_poam (${has_findings == true})  →  UserTask_poam
    └─ Flow_gateway_end  (default, 無缺失)           →  EndEvent

UserTask_poam
    ↓
Flow_poam_audit  (reverse=true)  →  UserTask_audit

為什麼並列而非取代既有 builtin-full-audit(公開議題 1)

  • 既有 4 個 builtin 已被 Spec 1 ship 三週、可能被自訂範本 duplicate 引用過;移除既有範本會擾動既有 user 範本 default 值與 launch_new_round 的「沿用上一輪」邏輯
  • 新增成本低,並列讓 user 可在「沒有 reviewer 角色」與「有 reviewer 流程」之間自由選擇

9.1 Reverse edge 兩種 runtime 行為差異(重要)

Spec 2 與 Spec 4 都會在 BPMN 內看到 reverse edge,但 runtime 行為不同 — 設計上必須對齊使用者心智模型:

場景 BPMN 結構 Runtime 行為 是否新建 round
Spec 2:poam → audit Flow_poam_audit (reverse=true) close_round handler 接管:建新 ar_data run_no+1、複製 ar_controls、AP.status → auditing、BPMN 推 audit UserTask ✓ 新 round(覆核模式)
Spec 4:review reject → task_execution Flow_review_reject (reverse=true, condition=${decision == 'reject'}) ReviewDecisionHandler 純 dispatch decision;BPMN 推回 task_execution UserTask;不建新 round、AP.status 不動 ✗ 不建 round

為什麼設計不同

  • poam → audit 是「稽核覆核」業務流程:缺失改完後需要 auditor 重新驗證,每次 iteration 需新一份 ar_data 紀錄
  • review reject → task_execution 是「審核退件」純流程退回:PM 重補資料後送 reviewer 再審,沒有「round」概念

Long-term reconsideration(紀錄供未來 reference):Spec 2 §9.4「Loop back v1 簡化版」原本希望 poam → audit 加 PM Dialog 二擇一(close vs reaudit);Spec 4 引入真 reverse 機制後,若客戶要求 poam 也支援「純 BPMN reverse 不建 round」選項,可考慮在 poam stage advance 也加 decision payload(reject_to_audit / close_round)— 但這要等 Spec 5+ 再評估,本 spec 不動 poam 行為。


§10

十、FE 改動概要

10.1 Banner

場景 UI 行為
current_stage = review 主按鈕「送出審核(approve)」+ 次按鈕「退回(reject)」
其他 stage 既有主按鈕(無變動)

雙按鈕都先彈 ConfirmDialog,內含 <Textarea v-model="comment" />

  • approve(包含其他 stage):comment optional,placeholder「可選填說明」
  • reject:comment required,提示「請說明退回原因(必填)」,未填時 disable 「確認」按鈕

10.2 BPMN editor inline warning

editor 內 sequenceFlow drag-end 事件 → debounce 500ms → POST /flow-engine/flow-templates/validate

  • valid:清除側邊欄 warning panel
  • 400:把 violations array 渲染為側邊欄警告列表,違規 sequenceFlow 在畫布上加紅色 outline + tooltip 顯示 reason

存檔仍由 BE 決定(不送出 / 送出 → BE 再驗一次當作守門員)。

10.3 Palette

  • Palette 加「審核」UserTask 預設 stencil:拖出時自動填 stage_object_code=review + main_role=reviewer
  • 既有 stage(planning / task_execution / audit / poam)palette 不動

10.4 i18n

新增 key:

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: "可選填說明"

§11

十一、Error code

Code 訊息 用途
GRC_FLOW_TEMPLATE_TOPOLOGY_INVALID (GRC_400060) 範本流程結構不合法 validator 違規總碼,response.data 帶 violations array
GRC_STAGE_REVIEW_REJECT_REQUIRES_COMMENT (GRC_400061) 退回審核必須附留言 由 schema validator 翻譯成 400;envelope.msg 顯示
GRC_STAGE_REVIEW_DECISION_INVALID (GRC_400062) decision 值必須為 approve 或 reject schema validator

既有 GRC_FLOW_TEMPLATE_BPMN_INVALID (400040) 保留,覆蓋「XML 格式錯誤、必要元素缺漏」這類「結構性」錯誤;topology 違規用新 code 分離,方便 FE 對應不同 UI(inline 紅框 vs 全域 toast)。


§12

十二、測試策略

12.1 BE pytest

Validator unit test(純函式,無 DB):

Case 預期
既有 builtin-full-audit + reverse property pass
既有 builtin-internal-check pass
既有 builtin-self-assessment pass
新 builtin-full-audit-with-review pass
「start → poam」(poam.can_start=false) fail start_stage_not_allowed
「planning → poam」(poam ∉ planning.allowed_successors) fail successor_not_allowed
「task_execution → end」 pass(task_execution.can_end=true)
「review → end」 pass(review.can_end=true)
「planning → end」(純練習設定) pass(planning.can_end=true)
Gateway 全 default fail gateway_missing_condition
孤立 UserTask(不可達) fail unreachable_node
純循環 A → B → A 兩條都標 reverse(無 end path) fail no_end_path
未知 stage_code fail unknown_stage_code

Reverse edge 識別 regex 邊界 case(§7.3 對應):

Condition 字串 預期 is_reverse 理由
${decision == 'reject'} 標準格式
${review_status == 'reject'} 變數名不限 decision
${ decision == 'reject' } 內部空白容許
${review_status == 'rejected_in_form'} quote 內必須完全是 reject
${decision == 'Reject'} 大小寫敏感
${decision == "reject"} 雙引號等價
${decision != 'approve'} 不靠語意推導;FE 補 extension property
${a == 'reject' && b == 'foo'} 複合條件不 match;FE 補 extension property
同時帶 extension reverse=true + condition 任一條成立即可(不重複算)

Integration test(DB):

  • create_flow_template 帶不合法 BPMN → 400 with violations
  • update_flow_template 同上
  • POST /flow-engine/flow-templates/validate happy path + 400 path
  • advance_stage decision=approve + comment → BPMN 走 default flow + JobComment 寫入
  • advance_stage decision=reject + comment → BPMN 走 reject flow + JobComment 寫入
  • advance_stage decision=reject comment → 400 from schema validator
  • advance_stage 非 review stage 帶 decision → ignore decision(手動忽略,不擋)

12.2 Manual smoke(必過項)

□ 建專案選範本「完整稽核含審核」(builtin-full-audit-with-review)
   → AP1 第一輪走 planning → task_execution → review
□ reviewer 在 review banner 按「退回」+ 寫退回原因
   → BPMN 回到 task_execution UserTask
   → JobComment 列表可見退回原因
□ PM 重新走 task_execution → review,reviewer 「送出審核」approve(comment optional)
   → BPMN 走到 audit UserTask
□ 走完 audit → 有缺失 → poam → audit → 無缺失 → End
□ 同專案 launch_new_round 切回 builtin-full-audit(無 review)
   → AP2 走原本 4-stage 流程,行為與 Spec 2 落地一致
□ 範本管理頁編輯:拖 sequenceFlow 從 Gateway 指向 poam(違反 audit.allowed_successors)
   → 側邊欄即時警告 + 畫布紅框
□ 強制存檔 → BE 回 400 with violations

12.3 跨 repo(compliance-manager-test)

  • 新 BDD scenario:「reviewer 退回後 PM 重新提交可走完整輪」
  • adapt 既有 Spec 2 Banner BDD(無 review stage 的流程仍 pass)

§13

十三、落地里程碑

M 範圍 提交策略 大小估
M0 Pre-flight verify:grep 既有 builtin XML 是否含其他 reverse 模式;確認 complete_main_workflow_job 不需動 純檢查 XS
M1 BE schema migration:compliance.stage_objects 加 4 欄 + seed review row + 4 stage 規則回填 1 commit S
M2 BE _validate_bpmn_topology 演算法 + 6 error code + violations payload 1 commit M
M3 BE POST /flow-engine/flow-templates/validate endpoint 1 commit S
M4 BE seed builtin-full-audit-with-review BPMN + 既有 builtin-full-audit patch reverse extension property 1 commit S
M5 BE StageAdvanceRequestSchema 加 decision/comment 欄位 + reject required comment validator 1 commit S
M6 BE ReviewDecisionHandler + StageAdvanceService 接 decision/comment + _build_condition_param review 分支 + JobComment 整合 1 commit M
M7 BE error code / unit + integration test 1 commit M
M8 FE BPMN editor palette + inline warning(呼叫 /validate 1 commit M
M9 FE Banner approve/reject 雙按鈕 + ConfirmDialog comment textarea 1 commit M
M10 FE 全 stage advance ConfirmDialog 加 optional comment textarea 1 commit S
M11 Manual smoke + 跨 repo e2e + changelog 收尾

M1→M2 順序:schema 必須先動(M1)才能讓 validator(M2)能讀 stage_object 規則,這個 dep 是硬的;測試與 endpoint(M3)建在 validator 之上。M4 builtin patch 放 M3 後因 validator 完成才有底氣 patch(patch 後跑 M2 validator 自我驗證)。


§14

十四、對外依賴

依賴 提供方 狀態
compliance.stage_objects 表 + seed Spec 1 ✅ 已 ship
compliance.flow_templates + _validate_bpmn Spec 1 ✅ 已 ship
StageAdvanceService / IStageCompletionHandler registry Spec 2 ✅ 已 ship
complete_main_workflow_jobcondition_param Spec 2 ✅ 已 ship(既有機制可直接用,不改套件)
JobCommentService + /job-executions/<uid>/comments Spec 既有 GRC 模組 ✅ 已 ship
BPMN editor + FlowTemplatePreviewDialog Spec 1 FE ✅ 已 ship
assessment_plan_extensions + snapshot pattern Spec 2/3 ✅ 已 ship

不依賴:jedi-flow-engine 套件變更(既有 condition_param + ExclusiveGateway 足以表達 reject 分支;BPMN engine 端的 string-match 解析 condition expression ${decision == 'reject'} 已在 Spec 2 §十.9 修正後支援 Camunda 標準格式)。


§15

十五、開放議題

無。Brainstorm Q1~Q7 已 resolved;3 個公開議題已由 user 拍板(並列 / 不卡 / BE 強制)。

進入 Phase 3 — implementation-plan.md。


§16

十六、Implementation Reality / Reconciliation(M5 加)

16.1 err.context 在 create/update 400 路徑被 jedi_common handler 吞掉

項目 內容
Plan / design §6.3 範例 400 response 把 violations array 放在 data.violations
實際落地 jedi_common.handler.handle_client_error 只序列化 error_code + msgerr.context 是 dead data。create / update 的 400 不含 violations 細節
為什麼可以接受 FE 走 POST /flow-engine/flow-templates/validate endpoint(M6)取完整 violations array;create/update 400 只當「存檔守門員」(design §10.2 已說明此分工)
未來修法選項 (1) patch jedi_common.handlergetattr(error, "context", None) 序列化;(2) custom Exception class 帶 violations field handler 識別。本 spec 不做
影響檔案 app/flow_engine/service/flow_template_app_service.py:_validate_bpmn_topology(保留 err.context 賦值,未來 jedi_common 改後即可生效,無需改主專案)

16.2 Marshmallow ValidationError 訊息被 jedi-common 吞掉 → 業務驗證移到 service 層

問題假設(design v1.0 / v1.1):M7 plan 把「reject 必填 comment」用 @validates_schema + raise ValidationError({"comment": [...]}, field_name="comment") 處理,預期 FE 能拿到 GRC_400061 error code 與 field 路徑。

實際限制jedi-commonhandler.register_error_handlersValidationError 固定回 COMMON_BAD_REQUEST generic 訊息,不讀 exception.messagesjedi-common/jedi_common/handler/handler.py:39-43)。schema-level message + field path 全部 collapse 成同一個 400。

M8 解法

  • StageAdvanceService.advance_stagestage_code == 'review' 時做業務驗證:
    • decision 為 None / 空 → BadRequestError(GRC_STAGE_REVIEW_DECISION_INVALID, GRC_400062)
    • decision == 'reject' + comment 空 → BadRequestError(GRC_STAGE_REVIEW_REJECT_REQUIRES_COMMENT, GRC_400061)
  • schema 內 @validates_schema 留著當第二道防線(type / OneOf 仍能擋格式錯)。

未來反悔條件:若 jedi-common 升級後 ValidationError handler 開始讀 exception.messages,可以把 service 層業務驗證刪掉只留 schema validator(單一 source of truth)。

影響檔案

  • app/flow_engine/service/stage_advance_service.py:advance_stage(service 層新增 review 業務驗證 + BadRequestError raise)
  • api/flow_engine/serializers/stage_advance.py:StageAdvanceRequestSchema(schema-level @validates_schema 保留作第二道防線)

16.3 task_execution → review 推進不能提前把 AP.status 改成 'auditing'

問題假設(design v1.0 / §8.1):design §8.1 只規範 review stage 推進時 ReviewDecisionHandler 不動 AP.status,沒處理上游 task_execution → review 的 status 行為。M0–M9 ship 後沿用 spec 2 的 TaskExecutionOnCompleteHandlerlaunch_audit該 method 一定把 AP.status 改成 'auditing'

user M11.1 smoke 撞到的症狀(2026-05-14):

  1. 「檢視稽核結果」按鈕在 review 階段就出現(ProjectAuditorOverview.vue:1160 條件 status === 'auditing' 提前成立)
  2. progress / 狀態欄位顯示「稽核」但 BPMN 還在 review UserTask
  3. user 心智模型不通:「執行任務 → 審核 → 稽核 — 通過審核狀態才該進稽核」

M11.1 解法(user 拍板 A 案,2026-05-14):拆 launch_audit 的「業務驗證」與「正式啟動稽核」階段:

  • OscalAuditService 新增 submit_for_review:只做權限檢查 + AP 存在 + incomplete_tasks 驗證(spec 2 launch_audit 的 step 0-2 subset),建 AR data、改 AP.status
  • TaskExecutionOnCompleteHandler review-aware 分流:
    • 下一站是 review(builtin-full-audit-with-review)→ 走 submit_for_review
    • 下一站是 audit(builtin-full-audit / 其他既有範本)→ 走 launch_audit(spec 2 原行為)
  • ReviewDecisionHandler.execute(approve) 注入 oscal_audit_service 並 call launch_audit(force=True) —— 此時才正式 materialize AR data + AP.status='auditing'。force=True 是因為 task_execution 推進時 submit_for_review 已驗過 incomplete_tasks,不需重驗
  • ReviewDecisionHandler.execute(reject):不動 AP.status,BPMN 走 reverse 回 task_execution
  • StageAdvanceService.advance_stage dispatch handler 前 peek BPMN 下一個 UserTask 的 stage_object_codectx['_next_stage_code'](不解析 gateway condition;review → Gateway 場景跳過 Gateway 不影響本機制,因為 ReviewDecisionHandler 自己處理 decision 分流)

未來反悔條件

  • 若 launch_audit 加入「不可重入」防呆(同一 AP 不能跑兩次),ReviewDecisionHandler approve 的 force=True 走法可能要改 change_status_only 之類專屬 flag
  • 若範本設計演化成「review 後可重新指派 task」(reject 後改 task 完成度),需要在 review reject 路徑加 AP.status 退回 'active' 邏輯(目前 reject 不動 status,user 還在 task_execution 階段可繼續用,沒問題)

影響檔案

  • app/project/service/oscal_audit_service.py(新加 submit_for_review method,launch_audit 不動)
  • app/grc/service/oscal_stage_handlers.pyTaskExecutionOnCompleteHandler.execute 加 review-aware 分流;ReviewDecisionHandler.__init__ 注入 oscal_audit_service + execute(approve) call launch_audit)
  • app/flow_engine/service/stage_advance_service.py(新加 _peek_next_stage_code + advance_stage dispatch handler 前塞 ctx['_next_stage_code']
  • di_containers/grc/grc_containers.py(review_decision_handler 注入 oscal_audit_service)
  • src/components/grc/FlowPhaseBanner.vue(reject 成功 toast 改 reject_success i18n)
  • locale flow_engine.banner.review.reject_success(zh-tw + en)

16.4 multi-review 串接 + reviewer 角色 trusted_caller

問題假設(§16.3 修法):原 §16.3 寫的 ReviewDecisionHandler.execute(approve) 無條件 call launch_audit,假設審核之後一定是 audit。

M11.1 smoke 實際撞到的延伸場景

  1. 範本可串接多個審核(task_execution → review1 → review2 → audit)—— review1 approve 不該觸發 launch_audit,要繼續推到 review2
  2. reviewer 角色不在 launch_audit 內部 _check_participant_role(["manager", "auditor"]) 白名單,approve 走 review_decision → launch_audit path 直接 403
  3. _peek_next_stage_codeget_next_elements 停在 Gateway,回 None;review handler 無法判斷下游

解法(commit 1c8d08c):

  • stage_advance_service._peek_next_stage_code_via_jobs:新加 helper 用 get_next_jobs 穿越 ExclusiveGateway 走 default 路徑,回下一個 UserTask 的 stage_object_code
    • _peek_next_stage_code 職責分離:前者給 advance_stage 的 handler 分流(停在 gateway,回 None 不解析);後者給 review handler 看 BPMN 真實下游
  • ReviewDecisionHandler.execute(approve)next_stage_code 判斷:
    • next_stage_code == "audit" → call launch_audit(trusted_caller=True) 真正啟動稽核
    • next_stage_code == "review" → 不 call launch_audit,BPMN 自然推進到下一個 review stage
  • OscalAuditService.launch_audit 新加 trusted_caller: bool = False 參數:
    • True 時跳過 _check_participant_role 二次驗(StageAdvanceService.advance_stage 已驗過 reviewer 角色,無需重複)
    • False 時保留原 spec 2 行為(直接 API 呼叫 path 仍完整驗角色)

未來反悔條件:若範本演化成「review approve 後可選分支(multi-outgoing gateway)」,_peek_next_stage_code_via_jobs 走 default 路徑的假設會 break,要改成解析 decision-driven condition

對應 changelog

  • docs/changelog/2026-05-15-tweak-multi-review-stage-support.md
  • docs/changelog/2026-05-15-fix-reviewer-role-blocked-from-launch-audit.md

16.5 entry_button_label_i18n — 按鈕文字由「下一個 stage」決定,非當前 stage hardcode

問題假設(design §10.1 + spec 2 遺留):FlowPhaseBanner 推進按鈕的 label 寫在當前 stage 的 complete_button_label_i18n,例如:

  • task_execution stage 的 button = "啟動稽核"(spec 2 設計直接接 audit)
  • review stage 的 button = "送出審核"(FE i18n hardcode)

M11.1 smoke 撞到的不一致

  • spec 4 改成 task_execution → review → audit 後,task_execution stage 推進按鈕仍顯示「啟動稽核」(user 看到不直覺,因為下一站是 review)
  • review approve 按鈕顯示「送出審核」(user 認為應該是「啟動稽核」,因為下一站才是 audit)

解法(commit 1c8d08c,BE-driven):

  • compliance.stage_objects 新增 entry_button_label_i18n JSONB 欄位
  • seed review.entry_button_label_i18n = "送審" + audit.entry_button_label_i18n = "啟動稽核"
  • StageAdvanceService.get_current_stage_info 動態算按鈕:用 _peek_next_stage_code_via_jobs 查下一個 stage 的 entry_button_label_i18n,空則 fallback 到當前 stage 的 complete_button_label_i18n
  • FE FlowPhaseBanner.vue 移除 isReviewStage 硬碼覆寫,primaryButtonLabel 統一讀 BE 提供的值

行為差異

階段 修前按鈕 修後按鈕
執行任務 啟動稽核 送審
審核(approve) 送出審核 啟動稽核
審核(reject 次按鈕) 退回 退回(不變)

對應 changelogdocs/changelog/2026-05-15-tweak-flow-engine-banner-dynamic-button-label.md

16.6 BPMN gateway condition 引號 — Camunda FEEL 單引號標準

問題假設(plan §九.9 引用 spec 2 §十.9 修正):Camunda BPMN gateway condition expression 用單引號 ${decision == 'reject'},spec 4 BPMN seed 也照此格式寫。

M11.1 smoke 撞到的 root cause

  • jedi-flow-engine 0.0.28BpmnUtils._parse_condition_param_to_string._fmtstr 值產生雙引號 ${decision == "reject"}
  • BPMN engine exact string match 失敗,無 flow 符合 → 走 default = Flow_review_forward = UserTask_audit
  • user 看到「按退回卻推進稽核」(問題 3 的真正 root cause — 不是 status 顯示誤判,BPMN 真的走錯)

解法(jedi-flow-engine 0.0.29,67e979f):

  • _fmt(str) 輸出單引號(Camunda FEEL 標準)
  • 主專案 pyproject.toml pin jedi-flow-engine==0.0.29

對應 changelogdocs/changelog/2026-05-15-fix-bpmn-gateway-condition-quote-mismatch.md

16.7 stage_object topology 規則放寬 — 支援多重審核 / 退回前置 stage

問題假設(plan §五.2 規則表 v1.1 reconciliation):原 stage_objects 規則只設計單一 review → audit,假設審核之後一定接稽核。

flow-template BPMN editor 開放給 user 後撞到的不一致: user 可建立含「多重審核」(review → review)或「審核退回到設定/執行任務」的 BPMN 範本,但拓撲驗證器報錯不讓發布。

解法(commit 3ff38d9 同 session):

stage 欄位 修前 修後
review allowed_predecessors ["planning", "task_execution"] ["planning", "task_execution", "review"]
review allowed_successors ["audit"] ["audit", "review", "task_execution", "planning"]
planning allowed_predecessors [] ["review"]

影響:現有 BPMN 範本不受影響(規則放寬,不是收緊)。

對應 changelogdocs/changelog/2026-05-15-fix-stage-object-topology-rules-too-restrictive.md 對應 migrationscripts/sql/2026-05-15-stage-object-topology-rules-fix.sql

16.8 flow_template draft / published 狀態機(衍生 spec)

範圍外延伸:本 spec 原本 plan 未含 flow_template 生命週期管理;M11.1 smoke 期間 user 反饋「使用者建範本可能還沒寫完就被誤用」,同 branch 衍生加入 draft/published 狀態機。屬於 flow-template 模組獨立 feature,不是 spec 4 review stage 修補,紀錄在此 reconciliation 因為共用同一 release branch。

狀態機

動作 前狀態 後狀態
POST /flow-templates(create) draft
PUT /flow-templates/<uid> 僅名稱描述異動 不變 不變
PUT /flow-templates/<uid> BPMN XML 異動 任何 draft
POST /flow-templates/<uid>/duplicate draft
PUT /flow-templates/<uid>/publish(完整 BPMN + topology 驗證) draft published
PUT /flow-templates/<uid>/unpublish published draft

稽核案建立防護POST /project/start 若選用 status='draft' 範本 → 400 GRC_400063(流程範本尚未發布)

新加 BPMN editor 智能 Gateway condition UI(FE):

  • ExclusiveGateway 屬性面板溯源上游 UserTask 的 stage_object_code
    • review → 語義下拉「通過(預設)/ 退回」,退回寫 ${decision == 'reject'}
    • audit → 「無缺失(預設)/ 有缺失」,有缺失寫 ${has_findings == true}
    • 其他 → fallback InputText 自由輸入
  • writeCamundaProp bpmn-js command stack 不追蹤導致首次寫入不生效(改用 modeling.updateModdleProperties()

對應 changelog

  • docs/changelog/2026-05-15-feat-flow-template-draft-publish-status.md
  • docs/changelog/2026-05-15-feat-flow-template-unpublish.md 對應 analysisdocs/analysis/2026-05-15-flow-template-bpmn-editor-design.md 對應 migrationscripts/sql/2026-05-15-flow-template-status.sql

§17

十七、文件版本

版本 日期 變更
v1 2026-05-14 初版,brainstorm Q1~Q7 + 3 公開議題收斂完整版
v1.1 2026-05-14 spec reviewer 反饋整合:(1) §5.2 規則表 reconciliation(移除 audit→review / review→task_execution forward 列項,明示 reverse 走 §7.3 例外);(2) §6.1 非 review stage 送 decision 行為寫死 ignore + log warning;(3) §6.2 reject_button_label 改從 FE i18n bundle 取,不動 stage_objects schema;(4) §7.2 step 6 reachability 明示「只走 forward edge」;(5) §7.3 reverse 識別改 extension property 為 primary、嚴格 anchored regex 為 fallback;(6) §7.5 加自訂範本 backfill migration(scan duplicated builtin-full-audit);(7) §8.2 critical JobComment wiring fix — 先查 JobExecution.uid (UUID) 再傳,不能用 BPMN element id;(8) §9.1 新增「Spec 2 vs Spec 4 reverse runtime 行為差異表」對齊使用者心智模型;(9) §12.1 補 regex 邊界 case 9 條 + 純循環無 end path test
v1.2 2026-05-14 M5 code reviewer 反饋:新增 §16 Implementation Reality / Reconciliation,紀錄 err.context 在 create/update 400 路徑被 jedi_common handler 吞掉的偏差;原 §16 文件版本改為 §17
v1.3 2026-05-14 M8 code reviewer 反饋:新增 §16.2 — Marshmallow ValidationError 訊息被 jedi-common handler 吞掉 → review 業務驗證(decision 缺值 / reject 必填 comment)改在 StageAdvanceService.advance_stage 用 BadRequestError(GRC_400061/062) 處理,schema-level @validates_schema 留作第二道防線
v1.4 2026-05-15 M11.1 manual smoke 反饋:新增 §16.3 — task_execution → review 推進不能提前把 AP.status 改成 'auditing'。拆 launch_audit 為 submit_for_review(驗證 only)+ launch_audit(驗證 + materialize + status change);TaskExecutionOnCompleteHandler review-aware 分流;ReviewDecisionHandler approve 時注入 oscal_audit_service 並 call launch_audit(force=True);FE FlowPhaseBanner reject 成功 toast 改 reject_success i18n
v1.5 2026-05-15 M11.1 後續整輪修補:(1) §16.4 multi-review 串接 + reviewer 角色 trusted_caller flag(commit 1c8d08c);(2) §16.5 entry_button_label_i18n 動態按鈕文字(stage_objects 加欄位 + BE peek 下一個 stage);(3) §16.6 BPMN gateway condition Camunda FEEL 單引號標準(jedi-flow-engine 0.0.29 — 問題 3「按退回卻推進稽核」的真正 root cause);(4) §16.7 stage_object topology 規則放寬(支援 review→review / review→planning / review→task_execution);(5) §16.8 衍生 flow_template draft/published 狀態機 + BPMN editor 智能 Gateway condition UI(同 branch 但屬獨立 feature)