FR-067 · Detection Multi-Agent Dispatch — 需求討論稿 · 2026-08-23(已拍板,設計見 design.md)

一次檢測,讓每台 Agent 掃自己的網段

檢測工具平台(FR-056/FR-058)目前一次執行只派一台 agent(駐點掃描機),而且是系統隨手挑的——清單順序不固定、也不看那台是不是還在線。客戶落地環境會在不同網段部署多台 agent,需求是:單一檢測任務內設定多組「agent+掃描目標」,按一次執行就展開成 N 張工單,各 agent 掃自己的網段,全部完成才算這次執行完成。內部模型已由 user 拍板走「展開 N 筆執行+群組彙總」路線(先前討論的模型 B),本稿把它落成可審的設計。

模型 B 已拍板 D1–D9 已全數拍板(2026-08-23) 前作:FR-056/FR-058(檢測工具整合) Agent 端(evidence-agent)零改動 現況:挑機無序+不驗在線=離線永久卡單
§1

分工概述(30 秒版)

整案一句話:檢測任務可以設定多組「哪台 agent 掃哪些目標」,一次執行同時派給多台,全部回報完才算完成,報告與通知彙總呈現。

給非工程讀者的階段對照表(「完成怎麼判定」都是決策者可以親手操作驗證的方法):

階段 做什麼 產出 完成怎麼判定(決策者檢查法)
① 地基:Agent 清單看得懂 Agent 下拉只列「能做掃描」的機器、標示在線/離線 Agent 清單 API 補齊能力與在線資訊 打開任務設置的 agent 下拉,只看到能掃描的 agent,離線的有標示
② 任務設置多組 assignment 檢測工具綁定介面可加多列「agent+掃描目標」 新資料表+設置 UI 在任務設置加兩組 agent+各自網段,儲存後重開頁面設定還在
③ 執行展開與收口 按「開始執行」展開 N 張工單;各 agent 回報後系統判定整組狀態 執行群組(group)層+展開/收口邏輯 按開始執行後,到執行紀錄看到兩台各自的狀態;等兩台都完成,整次執行才顯示完成
④ 結果呈現與通知 執行紀錄看得到「哪份報告是哪台掃的」;通知改成收口一封彙總 執行紀錄 enrich agent 名稱+彙總通知信 執行紀錄每列有 agent 名稱;收到的通知只有一封、內含兩台各自結果
⑤ 相容與收尾 舊資料/不設定 assignment 的任務照舊能跑;測試與 SPEC 更新 backfill+向下相容邏輯+文件 拿一個沒設 assignment 的舊任務按執行,行為與現在一樣(自動挑一台健康的)
§2

需求背景與現況問題

2.1 現在系統怎麼挑機器:隨機,而且不看死活

目前一次檢測執行=一筆執行紀錄(detection_executions)+一張 agent 工單(agent_tasks),工單建立當下就把 agent 釘死。挑哪台的邏輯是:撈出「這個租戶下、啟用中、有掃描能力」的 agent 清單,取第一台——而底層查詢沒有排序(_pick_agent()app/detection_tools/service/detection_orchestration_service.py:392-398),回傳順序是資料庫實體儲存順序,會隨著 agent 心跳更新而漂移。白話說:今天挑到 A 台、明天可能挑到 B 台,使用者無從預期,也無處指定。

更嚴重的是完全不檢查那台是否在線last_seen_at 沒有納入判斷)。挑中一台已離線的 agent,工單會永遠掛在待領取(pending)狀態——agent 的領單機制是「心跳時只領自己名下的單」(domain/agent_task/service/agent_task_domain_service.py:110-124),機器不回來,單就永遠沒人領,沒有回收、沒有告警、使用者只看到「執行中」轉不完。這在 FR-058 設計文件(design.md:189)就已列為技術債。

2.2 客戶環境的真實需求:多網段、多 agent

落地部署的客戶環境常見多個隔離網段(辦公網/機房網/DMZ),每個網段部署一台 agent。掃描目標(hosts)分散在各網段,單一 agent 打不到別的網段。目前的模型下:

  • UI 無處指定「這批目標給哪台掃」——檢測工具綁定的參數(tool_params)只有 hosts/profile/timeout 等,沒有任何 agent 欄位;
  • 執行紀錄也無處查看「這份報告是哪台掃出來的」——執行紀錄 response 不含 agent 資訊;
  • 要掃全部網段,只能開多個檢測任務各跑一次,狀態與報告散落各處,人工湊。

2.3 目標行為

user 已拍板的目標模型(先前討論的「模型 B」):

  1. 任務設置時,檢測工具綁定下可設多組 assignment(每組=一台 agent+一批掃描目標);
  2. 按一次「開始執行」,系統展開成 N 筆執行紀錄(每組一筆,維持既有「一筆執行=一張工單」的 1:1 關係),外面包一層**執行群組(group)**做彙總;
  3. 各 agent 心跳領各自的單、掃自己的網段、各自回報;
  4. 全部終態才算這次執行完成——群組彙總狀態、自動完成任務(completion_mode=auto)、通知信都改在群組收口時做。
§3

現況盤點:可複用 vs 缺口

以下座標均經兩輪唯讀探脈查證。

3.1 可複用(不動或小動)

心跳領單機制原樣可用

Agent 每 300 秒 POST heartbeat(mTLS),回應夾帶自己名下的 pending 工單(app/remote_agent/service/agent_enrollment_service.py:305-374)。工單 agent_id 建單釘死+領單只查自己名下,天然不會重複執行、也沒有搶單問題——N 筆工單各釘各的 agent,機制零改動。

Agent 端(evidence-agent)零改動

Connector 只讀自己那筆工單參數裡的 hosts/targets;_task_uid 組唯一資源名,N 筆工單各有各的 uid 天然不撞。agent 端甚至已有多台 summary 加總邏輯可參考(evidence-agent/core/task_executor.py:329-351 _merge_summaries)。

單筆回報鏈完整保留

agent POST result → app/agent_task/service/agent_task_service.py:66-93on_scan_succeeded/faileddetection_orchestration_service.py:466-531)→ detection_result_handler.py:73-155(拉報告檔→寫 job_evidences→標記成功)。選模型 B 的最大理由就是這條鏈原地不動(見 D1)。

UI 有前例可抄

storage-config 已有 agent 下拉元件(compliance-manager-fe/src/views/storage-config/StorageConfigForm.vue:502-516,存 agent_uid),任務設置的 assignment 列表可沿用同款選取模式。

3.2 缺口(本案要動的)

# 缺口 現況座標 影響
1 assignment 無處存放 config.job_execution_detection_toolstool_params(JSONB)無任何 agent 欄位;constraint uq_jedt_job_active 鎖「一任務一綁定」 存放載體要選(→ D2)
2 _pick_agent 無序+不驗在線 detection_orchestration_service.py:392-398 改成照 assignment 指定;未指定時 fallback 要過濾離線(→ D3、D8)
3 一任務最多一筆 running 的守門 get_running_by_job_execution_uid(:60-68,取 results[0]);caller=start_execution:123 與 cancel_execution:359 語意要升級到 group 層
4 completion_mode=auto 單筆就觸發 on_scan_succeeded :495-502——單筆 succeeded 就 complete_job;若 N 筆各觸發,第 2 筆起撞任務狀態 409,且 receive_result 不吞例外→agent 收 5xx 改「group 內全部終態」判定(→ D5)
5 通知每筆一封 _notify_scan_result :654-704(Email/Telegram/Discord) N 台=N 封轟炸,改收口彙總一封(→ D6)
6 取消只有單筆語意 _cancel_running_execution :366-390:打該 agent 的 cancel API,失敗 raise 409 不標記 群組取消粒度要定(→ D7)
7 自動派工冪等條件 發佈任務時 _auto_dispatch_detection_scansapp/grc/service/task_execution_service.py:98-134),冪等=該任務無任何 detection_executions 展開 N 筆後條件語意要重新確認(改查 group 存在性)
8 執行紀錄查不到「誰掃的」 執行紀錄 response 無 agent 欄位 enrich agent 名稱(→ D9)
9 Agent 清單 API 太瘦 GET /remote-agents 只回 uid+name,不含 capabilities 也不過濾(spec 坑 10:docs/specs/current/evidence/remote-agent-manage.md:444 FE 下拉要能只列「能掃描的」(→ D9)
10 test_connection 也走 agents[0] app/detection_tools/service/detection_tool_service.py:189-193 要對齊可指定(→ D9)
11 效能與索引債 兩處 N+1(_resolve_scan_params_resolve_report_file_uid);detection_executionsagent_task_uid 是 soft-ref 無 FK 無 index;兩表無 job_execution_uidagent_task_uid 索引 展開 N 筆後查詢量放大,順手補
12 測試與文件 BE 測試 5 檔 mock 1:1 形狀(test_detection_orchestration.py 等);E2E detection-job-factory.js 只送單 tool;SPEC 三頁(my-tasks.md、project-planning.md、project-task-edit.md) 收尾更新

3.3 關鍵既有事實(設計依據)

  • detection_executionsinfra/detection_execution/model/detection_execution.py):agent_task_uid(soft-ref)、job_execution_uidstatus(running/succeeded/failed/cancelled,無狀態機守門)、summary(JSONB)、report_file_idevidence_id(單值代表,已知債)。與 agent_tasks 嚴格 1:1——建立點 detection_orchestration_service.py:150-165;反查點 get_by_agent_task_uiddomain/detection_execution/service/detection_execution_domain_service.py:54-58,agent 回報不帶 execution_uid,全靠工單 uid 反查)。
  • 報告與工單的關聯是字串約定job_evidences.description = "[檢測工具] {agent_task.uid}",是報告↔︎工單唯一的關聯依據——這正是「1:1 是大量下游隱含依賴」的例證之一。
  • Agent 註冊表 compliance.remote_agentsinfra/remote_agent/model/remote_agent.py):uid/name/base_url/capabilities(JSONB,agent 心跳自報,0.2.8 起恆為 ["file_storage","detection_scan"])/last_seen_at(在線判定=距今 < 心跳間隔 300s × 3)/org_unit_id(存在但全 NULL 未用)。
  • 心跳 poll 模型:BE 連不進客戶內網,唯一反向推送是 cancel/probe 走 agent.base_url
  • 綁定寫入鏈:FE 送出(compliance-manager-fe/src/views/project/ProjectPlanningView.vue:1125-1127)→ api/grc/serializers/job.py:159-173(DetectionToolBindingSchema:uid/params/completion_mode)→ app/grc/service/job_service.py:375-430
§4

設計方案

4.1 一句話架構

維持「一筆執行=一張工單」1:1 不動,往上加一層「執行群組(execution group)」:任務設置存多組 assignment;按執行時建一個 group、每組 assignment 展開一筆 execution+一張工單;單筆回報鏈原樣跑;每筆終態時檢查 group 是否收口,收口才做「彙總狀態/自動完成任務/彙總通知」三件事。

4.2 端到端時序

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
    participant U as 使用者
    participant FE as 前端
    participant BE as 後端
    participant A1 as Agent 甲(網段 A)
    participant A2 as Agent 乙(網段 B)

    U->>FE: 任務設置:加兩組 assignment(甲+網段A目標、乙+網段B目標)
    FE->>BE: 儲存綁定(含 assignments)
    U->>FE: 按「開始執行」
    FE->>BE: start_execution
    BE->>BE: 逐 assignment 驗證(存在/啟用/有能力/在線)
    BE->>BE: 建 group + 展開 2 筆 execution + 2 張工單(各釘各的 agent)
    A1->>BE: 心跳(300s)
    BE-->>A1: 回應夾帶甲名下的工單
    A2->>BE: 心跳
    BE-->>A2: 回應夾帶乙名下的工單
    A1->>A1: 掃網段 A
    A2->>A2: 掃網段 B
    A1->>BE: 回報結果(報告檔+summary)
    BE->>BE: 單筆回報鏈原樣(寫證據/標記成功)
    BE->>BE: 檢查 group:乙還在跑 → 不收口
    A2->>BE: 回報結果
    BE->>BE: 單筆回報鏈原樣
    BE->>BE: 檢查 group:全部終態 → 收口
    BE->>BE: 彙總狀態+(auto 模式)完成任務
    BE-->>U: 一封彙總通知信(兩台各自結果)
圖 1 — 設定 assignment → 執行展開 → 各 agent 心跳領單 → 回報 → group 收口 → 自動完成/通知

4.3 資料模型

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart TD
    JEDT["job_execution_detection_tools<br/>(既有)檢測工具綁定<br/>tool_params/completion_mode"]
    ASG["🆕 job_execution_detection_tool_agents<br/>assignment 子表<br/>agent_uid+targets(每組一列)"]
    GRP["🆕 detection_execution_groups<br/>執行群組<br/>彙總狀態/收口時間"]
    EXE["detection_executions(既有)<br/>單筆執行 status/summary/report<br/>+🆕 group_uid"]
    TASK["agent_tasks(既有)<br/>agent_id 建單釘死"]
    AGT["remote_agents(既有)<br/>capabilities/last_seen_at"]

    JEDT -->|"1 → N"| ASG
    ASG -.->|"參照"| AGT
    JEDT -->|"執行時建"| GRP
    GRP -->|"1 → N 展開"| EXE
    EXE ===|"嚴格 1:1(不動)"| TASK
    TASK -.->|"agent_id"| AGT
圖 2 — 資料模型:新增 assignment 子表與 execution group 層,既有 1:1 關係不動

4.4 群組狀態機

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
stateDiagram-v2
    [*] --> running : 執行展開(任一筆 running)
    running --> running : 部分回報,仍有未終態
    running --> succeeded : 全部 succeeded
    running --> partial_failed : 任一 failed/cancelled 且無 running
    running --> failed : 全部 failed/cancelled
    succeeded --> [*] : 收口(auto 可自動完成任務+彙總通知)
    partial_failed --> [*] : 收口(不自動完成,留人工+彙總通知)
    failed --> [*] : 收口(不自動完成+彙總通知)
圖 3 — group 彙總狀態機(依 D4 建議語意)

4.5 收口判定的實作要點

  • 收口檢查放在單筆終態寫入之後、同一交易內on_scan_succeeded/failed 尾端加「查 group 內是否還有非終態」→ 沒有才做收口三件事(彙總狀態/auto 完成任務/彙總通知)。N 筆併發回報時靠 group 列鎖(SELECT ... FOR UPDATE group row)保證收口只跑一次——這同時解掉現況「N 筆各自觸發 complete_job、第 2 筆起撞 409 讓 agent 收 5xx」的地雷。
  • 並行守門語意上移:「一任務最多一筆 running」升級為「一任務最多一個 running group」;get_running_by_job_execution_uid 的 caller(start_execution 的 force 判斷、cancel_execution)改查 group。
  • 自動派工冪等_auto_dispatch_detection_scans 的冪等條件從「無任何 execution」改為「無任何 group」(backfill 後舊資料也有 group,語意一致)。
§5

待決策清單 D1–D9

🟢 D1 內部模型:A(一筆 execution 掛多工單)vs B(展開 N 筆 execution+group 彙總)→ 已拍板 B

兩個做法:(A)一筆 execution 底下掛多張工單,execution 內部變 1:N;(B)一次執行展開 N 筆 execution(維持與工單 1:1),上面新增 group 層做彙總。

深度分析結論建議 B,user 已拍板。核心理由:「一筆執行=一張工單」的 1:1 是大量下游程式隱含依賴的不變式——工單反查執行(get_by_agent_task_uid)、整支回報處理鏈(handle_result)、report_file_id 的單值語意、報告靠 description 字串反解分組、FE 的部分成功(CM-952)/零實質警示(CM-954)渲染,全都假設 1:1。走 B 這些全部原地保留,只需新增 group 聚合(DDD 四層)+把三件收口邏輯(auto 完成/通知/彙總狀態)上移——而這三件在 A 之下同樣要做;走 A 則要反轉不變式、重寫已成熟且有測試守著的邏輯,成本高風險大。存量 backfill 也簡單:DEV 現有 73 筆 execution 每筆自成一個 group 即可。

D2 assignment 存放載體:tool_params JSONB 內陣列 vs 新子表

選項一:在既有 tool_params(JSONB)裡加一個 assignments 陣列,不動 schema。選項二:新子表 job_execution_detection_tool_agents(綁定 1 → N assignment,每列 agent_uid+targets)。

建議:新子表。理由:①列表與詳情要 join agent 名稱、執行前要驗證 agent 存在性,JSONB 內陣列做不到 FK 級的可查可驗;②tool_params 現況會整份快照進工單參數agent_tasks.params),塞在裡面 assignment 全文會漏到執行紀錄與通知信;③既有 uq_jedt_job_active 鎖「一任務一綁定」不必動,子表掛在綁定下即可。

D3 指定的 agent 離線/失效時的語意

執行當下發現某組 assignment 指定的 agent 不健康(不存在/停用/已註銷/無掃描能力/離線),要部分派出還是整次擋下?

建議:執行前逐 assignment 驗證,任一不過就整次報錯不派,錯誤訊息列出哪幾台不合格。理由:部分派出=部分網段被跳過,掃描結果殘缺卻顯示「完成」,比不派更糟;整次擋下讓使用者當場修正(換機或等上線)。在線判定沿用既有標準(last_seen_at 距今 < 3 個心跳週期=900 秒)。

D4 group 彙總狀態語意

N 筆執行各有 succeeded/failed/cancelled/running,群組整體狀態怎麼歸?

建議:全 succeeded=succeeded;任一 failed 且無 running=partial_failed(FE 顯示黃色,沿用 CM-952 部分成功視覺語彙);全 failed=failed;任一 running=running;cancelled 歸類比照 failed(含 cancelled 就不可能是全成功)。狀態機見圖 3。

D5 completion_mode=auto 的完成判定

現況單筆 succeeded 就自動完成任務。多筆之後,什麼條件才自動完成?

建議:group 內全部 execution succeeded 才 complete_job;partial_failed 不自動完成、留人工判斷(部分網段沒掃到,任務不該自動關)。同時修掉現況「多筆各自觸發撞 409」的地雷(見 §4.5 收口列鎖)。

D6 通知時點與形式

現況每筆回報發一封(Email/Telegram/Discord),N 台會變 N 封轟炸。

建議:改為 group 收口時發一封彙總信:每組 assignment 一列(agent 名稱/掃描目標/狀態/發現數),取代現行每筆一封。單一 assignment 的情境(向下相容路徑)收口即單筆,體感不變。

D7 取消粒度

執行中途取消,是整組取消還是可以只取消某一台?

建議:第一版只做整組取消——逐工單打各自 agent 的 cancel API,best-effort:可達的都取消、標記 cancelled;全部不可達才回 409。單一 assignment 取消列 future(要處理「取消一台後 group 怎麼歸態」的細節,第一版不背)。注意這比現況寬鬆:現行 _cancel_running_execution 是「打不到就 409 完全不標記」,多台情境下一台失聯就卡死整組取消不可接受。

D8 向下相容

舊資料與「不設定 assignment」的任務怎麼辦?

建議:綁定底下無 assignment 時=隱含單組 assignment——agent 自動挑(走修好的 _pick_agent:仍取一台,但過濾離線)、掃描目標用 tool_params 原值。既有 73 筆 execution backfill 每筆自成一個 group。這樣舊任務零遷移、行為與現在一致(且更健康:不會再挑到離線機)。

D9 連帶地基缺口是否併入本案

四項地基:①GET /remote-agents 補 capabilities 欄位+支援能力過濾(spec 坑 10);②執行紀錄 response enrich agent 名稱;③test_connection 對齊可指定 agent;④_pick_agent fallback 過濾離線。

建議併入本案。它們不是順手優化,而是本功能可用性的一部分:FE 下拉沒有 capabilities 就列不出「能掃描的機器」(①);多 agent 後查不到「這份報告誰掃的」功能等於半殘(②);測試連線挑的機器與實際執行不同台會誤導(③);fallback 不過濾離線則 D8 的相容路徑仍會踩離線卡單的老坑(④)。

§6

拆分草案

子需求粗切(細拆與 Notion 開卡等 design.md 階段):

# 子需求 範圍 依賴
.1 地基:Agent 清單 API 補齊 GET /remote-agents 補 capabilities+在線資訊+能力過濾;_pick_agent 離線過濾;test_connection 對齊 無(可先行)
.2 資料層:assignment 子表+group 表 migration(新表×2+detection_executions.group_uid+補索引)+backfill 73 筆+DDD 四層(entity/repo/domain service)
.3 綁定設置:assignment CRUD serializer(DetectionToolBindingSchema 擴充)→ job_service 寫入鏈→FE 任務設置多列 assignment UI(抄 storage-config 下拉) .1 .2
.4 執行展開與收口 start_execution 展開 N 筆+執行前驗證(D3)+收口判定(列鎖)+auto 完成(D5)+並行守門與自動派工冪等上移 group .2
.5 彙總呈現與通知 執行紀錄 group 檢視+agent 名稱 enrich+彙總通知信(D6)+整組取消(D7)+FE 執行紀錄改版 .4
.6 收尾 BE 測試 5 檔改 mock 形狀+E2E factory 擴充+SPEC 三頁更新(my-tasks/project-planning/project-task-edit) .3 .4 .5

⚠️ 本稿只到「可審設計」

design.md、Notion 開卡、實作皆為後續階段(big-feature-workflow Step 3 起),待本稿審過拍板 D2–D9 後進行。