# 第 1 批：權限地基（13 件 → 9 張卡）

## 這批一句話

這批修的是「誰有資格動這筆東西」這個判斷本身——不是補一行呼叫，而是把判斷規則定下來、把判斷的入口做出來。第 2 批那 29 件裡有一大票是「補一道守門呼叫」，而它們要呼叫的那道守門就在這批裡；所以這批**必須先做完、合回主線、套件發版**，第 2 批才有東西可以接。**現在就能開**，沒有等任何前置。13 件分成兩種形狀：①「任務狀態操作」這一組（#37）是一條守門被十支端點共吃，跨 BE 與 jedi-detection 兩個 repo，是整批的關鍵路徑；②其餘十二件是各套件自己的「擁有者／角色」判斷（問卷、公告、設備清冊、意見回饋、專案成員），彼此互不相干，可以同時派。

## 卡片清單

| 卡 | 卡名（做什麼） | repo／套件 | 涵蓋 SUMMARY # | 建議 model／effort | 序列組 |
|---|---|---|---|---|---|
| 1-1 | 任務的「完成／退回」改成只有被指派人與管理者能做（新開一道嚴守門） | BE 主專案 | #37（BE 半） | opus／medium | **A（第一棒）** |
| 1-2 | 弱點掃描八支改狀態端點改吃那道新的嚴守門 | 套件 jedi-detection | #37（套件半） | sonnet／medium | **A（接 1-1）** |
| 1-3 | 專案成員與任務指派寫入時，先反查填進來的東西是不是這個專案的 | 套件 jedi-task-platform | #41、#109、#110 | opus／medium | 獨立 |
| 1-4 | 問卷還原歷史版本要驗版本歸屬；問卷討論改／刪要驗作者本人 | 套件 jedi-survey | #38、#39 | opus／medium | 獨立 |
| 1-5 | 公告改／刪要驗是不是自己發的；發送對象改成必選 | 套件 jedi-bulletin | #42、#111 | opus／high | 獨立（含一項待裁） |
| 1-6 | 設備與資訊系統七支讀取補權限檢查；修改請求不准夾帶停用／啟用 | 套件 jedi-asset | #43（守門半）、#112 | opus／medium | **B（接 1-9）** |
| 1-7 | 意見回饋六支裸奔端點補權限，改／刪補「這筆是不是你的」，清單分兩套 | 套件 jedi-issue | #45 | opus／high | 獨立（含一項待裁） |
| 1-8 | 「檢查流程圖」那支端點補權限檢查與輸入上限 | BE 主專案 | #40 | sonnet／medium | 獨立 |
| 1-9 | 兩支資料庫修正：補設備讀取權限給稽核人員角色、放寬意見回饋的新增規則 | BE 主專案（`scripts/sql/`） | #44、#43（權限資料半） | sonnet／medium | **B（1-6 之前）** |

**序列組說明**：
- **A**：1-2 呼叫的方法是 1-1 做出來的。1-1 先合、套件側再改，否則 jedi-detection 會呼叫一個不存在的方法（症狀是 AttributeError 500，不是 403）。
- **B**：1-9 的第一支 migration 必須先套上 DEV，1-6 才驗得起來。順序反過來的症狀是**五個畫面的下拉選單安靜變空、不跳錯誤**（見 1-6 的「功能不能壞」段）。
- 1-3／1-4／1-5／1-7／1-8 互不碰同一個檔，可以同時派。

---

## 卡 1-1：任務的「完成／退回」改成只有被指派人與管理者能做

- **範圍**：BE 主專案（`~/Projects/Billows/Audit-Manager/compliance-manager-be`），worktree 建議 `wt-fix-b1-flow-engine-guard`，涵蓋 **#37 的 BE 半**
- **這張卡是整批的第一棒**，1-2 等它。

### 每件的修法

- **#37 產品定義上「只能看、沒有待辦」的一般成員（viewer），可以在任務畫面按「完成任務」把別人的任務結案、也可以把流程退回上一關；稽核紀錄上的經手人就變成這個只能看的人**
  - **修法**：現在守門只問「你是不是這個專案的參與者」，任何角色都算通過。決策者已裁定的角色規則是**完成／退回任務＝被指派的人 ＋ 管理者**（原則十七的表）。所以要在既有那道「是不是這個專案的人」之後，**再加一道「是不是這個任務的負責人、或是這個專案的管理者」**。
  - **照抄對象**：`app/flow_control/service/job_batch_complete_service.py:47-60` 的「批次完成」已經是正確寫法——先 `assert_project_participant`，再查角色是不是 manager，不是 manager 就逐筆比對 assignee。單筆照這個形狀抄。
  - 🔴 **入口清單**：
    - `common/authz/workflow.py:29-49` — `assert_workflow_project_participant()`，判定政策所在（admin 放行／無 context 不守／解析不到 project_id fail-closed／**任一參與者放行**）。「任一參與者放行」就是這條的根。
    - `app/flow_engine/service/workflow_execution_service.py:126-140` — `assert_project_participant()`，BE 的公開守門入口，十支端點都經過它
    - `app/flow_engine/service/workflow_execution_service.py:660` — `complete_job()` 內呼叫守門處
    - `app/flow_engine/service/workflow_execution_service.py:841` — `revert_job()` 內呼叫守門處
    - `api/flow_engine/routes/flow_engine_route.py:140` / `:165` — 兩支 route 的呼叫端（route 不改，只確認參數夠不夠判 assignee）
    - `common/authz/__init__.py:34` / `:134` / `:156` — canonical 出口，新守門要在這裡登記匯出（原則：授權守門一律走 `common/authz/`，不另立新 helper）
  - 🔴 **這支檔案 1,374 行，已破 800 行上限**（`docs/claude/oversized-files.md` 有登記）。**新邏輯一律新開檔**（例如 `app/flow_engine/service/job_operator_guard.py`）再從原檔一行呼叫，不准往裡加；能順手把 `assert_project_participant` 那一段抽出去就抽，commit message 註明。
  - **功能不能壞**：
    - 這支守門**同時被佐證文件的新增／更新／刪除吃**（`app/flow_engine/service/job_evidence_service.py:87`、`:153`、`:178`）。角色規則表也說佐證應該收嚴到「被指派人＋管理者」，**但佐證那三支不在第 1 批的範圍**。所以本卡**不可以直接把既有那道 `assert_project_participant` 收嚴**——那會連帶改掉佐證的行為，而佐證的手測與驗收沒有排在這一批。見下方 D-b1-1。
    - 同一支守門還被 `revert_job` 的 `known_project_id` 旁路用著（`:841`，FR-050 修 StageRollbackService 誤殺留下的）。**那個旁路只跳過反查鏈、不跳過判定**，新守門要沿用同一個 `known_project_id` 參數，否則階段回退會 100% 誤擋 manager。
    - `app/flow_engine/service/stage_rollback_service.py:152` 會呼叫 `revert_job`——它走的是系統發起的階段回退，**手測要包含這一條**，不能只測畫面上的退回鍵。
  - **手測**：
    1. 用 viewer 角色登入，開一個自己**不是負責人**的任務 → 按「完成任務」→ **要被拒**（403，不是 500）
    2. 同一個 viewer，按「退回上一關」→ **要被拒**
    3. 用該任務的**被指派人**登入 → 完成 → **要成功**
    4. 用該專案的 **manager** 登入 → 完成別人的任務 → **要成功**
    5. **批次完成**（勾多筆一起完成）行為不變：viewer 打得到入口但逐筆被過濾、manager 全部成功
    6. **階段回退**（launch_audit 之後由系統發起的那條）manager 仍可執行 → 這條專測 `known_project_id` 旁路沒被弄壞
    7. 佐證文件的上傳／刪除**行為不變**（viewer 仍可上傳）——這是刻意保留，證明沒誤動佐證那三支

- **這張卡的手測總清單**：上面七項全部親手點過；第 6、7 項最容易漏，它們是「沒有弄壞既有東西」的證據。
- **需決策者先裁的**：**D-b1-1**（見本頁末）
- **交接給 1-2 的東西**：新守門的**方法名與簽名**要寫進 Notion 卡回寫裡（1-2 的 runner 看不到這邊的 context，只靠 commit message 與卡上的回寫）。

---

## 卡 1-2：弱點掃描八支改狀態端點改吃那道新的嚴守門

- **範圍**：套件 monorepo `~/Projects/Jedicogy/module/jedi-python-package`，子目錄 `jedi-detection`，worktree 建議 `wt-fix-b1-detection`，涵蓋 **#37 的套件半**
- **前置**：卡 1-1 必須先合回主線。開工前確認 1-1 的 commit 在，並從它的 Notion 回寫取新守門的方法名。

### 每件的修法

- **#37（套件半）弱點掃描模組有八支會改狀態的功能（開始掃描、整組取消、重跑單台、取消單台、立即開始、刪單筆紀錄、整組刪、派工）吃的是 1-1 那道同一個守門；只改流程模組那兩支的話，這八支仍然對「只能看」的角色敞開——他可以對客戶的正式機器發動帶帳密的掃描、反覆取消再重派、刪掉失敗的執行紀錄**
  - **修法**：這八處目前都是 `self._wf_svc.assert_project_participant(job.workflow_execution_id)`。**改成呼叫 1-1 做出來的那道嚴守門**（方法名見 1-1 回寫），參數形狀不變。套件只負責呼叫，判定政策在宿主——這是既有設計（見 `jedi_detection/domain/ports.py:200-231` 的 `TaskInfo` 契約說明）。
  - 🔴 **入口清單**（八處全部人工開檔核對過，行號確實）：
    - `jedi-detection/jedi_detection/app/service/detection_orchestration_service.py:196` — 派工／立即開始
    - 同檔 `:871` — 整組操作入口
    - 同檔 `:925`
    - 同檔 `:1007`
    - 同檔 `:1032` — 整組取消
    - 同檔 `:1097` — 刪單筆執行紀錄
    - 同檔 `:1127` — 整組刪
    - 同檔 `:1470`
    - 注入點在宿主：`di_containers/detection_tools/detection_orchestration_containers.py:77`（`workflow_execution_service=...`）——**這一行不用改**，注進來的就是 1-1 改過的那個物件；列在這裡是給 runner 確認「為什麼改宿主就會影響套件」。
  - ⚠️ **同檔還有一處呼叫 `complete_job`**（`:1639`，auto 完成模式，掃描跑完自動把任務結案）。**那一支是系統代勞、不是使用者操作**，收嚴守門會讓自動完成整組壞掉（症狀：掃描明明成功、任務卻永遠停在執行中）。✅ **已補查（2026-09-21）：auto 路徑走的是「無 request context → 不守」那條，只要 1-1 的新守門沿用 `assert_workflow_project_participant` 就不用動這支；但這件事要寫死在卡片上，不能讓 runner 自己「順手收嚴」。**
    查到的鏈路：`complete_job`（BE `app/flow_engine/service/workflow_execution_service.py:650`）內唯一的守門是 `:660` 的 `self.assert_project_participant(workflow_execution.id)`（`:126-139`），它把判定委派給 `common/authz/workflow.py:29` 的 `assert_workflow_project_participant`，而那支**第一件事就是 `user = get_user_context()`；`user is None` 時直接 `return` 不守**（`:34-37`，註解明寫「背景任務無 request context → 不在守門範圍」）。
    auto 這條路徑確認**沒有 user context**：呼叫鏈是 agent 回報 → `receive_result`（**純 mTLS、不掛 JWT**，套件 `detection_orchestration_service.py:2009` 的 docstring 明文警告過同一件事，該處為此刻意改用派工單自記的 `tenant_id` 讀通知設定，因為 `get_channel_config()` 在 context 為 None 時會 raise）→ `_close_group_if_terminal`（`:1579` 與 `:1599` 兩個呼叫點）→ `_auto_complete_job_if_needed`（`:1622`）→ `complete_job`（`:1639`）。
    **要寫進卡片的三句**：① 1-1 的新守門**一律走 `common.authz` 既有的那支**，不要另寫判定、不要把 `user is None` 改成 fail-closed（改了就是這條 auto 路徑全掛）；② `:1639` 這支**不動、不傳系統身分旁路**；③ 手測必須含「掃描跑完 auto 完成照舊生效」那一項（已在下方手測第 5 項）——**這一項是「沒把系統代勞的路擋掉」的唯一證據，不可省**。
  - 🔴 **這支檔案 2,498 行，遠超 800 行上限**。本卡是「改既有呼叫」不是「往裡加」，照上限規則屬允許；**但不准在這支檔案裡新增任何新函式**。
  - **功能不能壞**：
    - 八支都是弱點掃描頁面上的按鈕，**manager 與被指派人的操作全部要照舊可用**
    - `TASK_STATUS_PROCESSING` 這個字面值是套件與宿主的凍結契約（`domain/ports.py:238-245`），**不要碰**
  - **手測**（DEV，弱點掃描頁面）：
    1. manager 開始一次掃描 → 成功
    2. 被指派人取消整組 → 成功
    3. viewer 打同一組的取消 → **403**
    4. viewer 打「刪除執行紀錄」→ **403**
    5. 掃描跑完的 **auto 完成**照舊生效（任務自動變完成）→ 這條專測上面那個 ⚠️
- **這張卡的手測總清單**：八支各用 manager 與 viewer 各打一次（十六次），加上 auto 完成那一項。**批次驗、不要逐支驗**：一輪把十七項全跑完、收集所有失敗，再一次修完、再驗一輪。
- **紀律補充**：套件異動走 poetry path dependency（主專案 `pyproject.toml` 把 `jedi-detection` 的 pin 改 path 形式，改動**不 commit**）；**不發版、不推 Nexus**。發版是第 1 批全部合完之後的獨立動作。

---

## 卡 1-3：專案成員與任務指派寫入時，先反查填進來的東西是不是這個專案的

- **範圍**：套件 monorepo，子目錄 `jedi-task-platform`，worktree 建議 `wt-fix-b1-task-platform`，涵蓋 **#41、#109、#110**
- 三件同一個形狀（「驗了人、沒驗物」）、同一組檔案，併一張。

### 每件的修法

- **#41 任何人自己開一個新專案（開的人自動是管理者），送出指派時「專案填自己的、任務填別人專案的」，系統只查他是不是自己那個專案的管理者就放行——於是他能把別人專案的任務登記成自己的指派；主系統靠這張表反查任務屬於哪個專案，塞一筆假紀錄可能騙過那道判斷，進而改動別人專案的任務狀態**
  - **修法**：寫入前**先由任務反查它真正屬於哪個專案**，跟請求填進來的專案比對，對不上就拒絕（回「找不到」而不是「無權限」，避免當成探測工具）。
  - **照抄對象**：**同一支檔案的刪除功能已經是這個寫法**——`jedi-task-platform/jedi_task_platform/participant/app/service/task_assignee_service.py:239-251`：先 `get_one()` 撈出既有紀錄、`if existing is None: raise NotFound`、再比對 `existing.project_id != project_id` 就 `raise NotFound`、最後才守 manager。照這個順序抄。
  - 🔴 **入口清單**：
    - `jedi-task-platform/jedi_task_platform/participant/app/service/task_assignee_service.py:140-144` — `add_task_assignee()`，守門在 `:144`（`assert_project_manager(..., _dto.project_id, control_id=_dto.control_id)`），**`_dto.project_id` 是呼叫方填的，沒有反查**
    - 同檔 `:175` — `batch_add_task_assignees()`。**已補查（2026-09-21）：兩個假設都不成立，這支不必改。** 它**不經過** `add_task_assignee`，但它也**什麼都不做**——自 FR-038 Wave 2A 起是 dark stub，整支只有 docstring（`:186-189`）＋ `return []`（`:190`），沒有任何 DB 寫入，所以「反查歸屬」無從補、也無風險可補。**本卡不動這支。**
      ⚠️ **但它仍是活端點，且下游有人在打**：套件 route `jedi-task-platform/jedi_task_platform/participant/api/routes/task_assignee_route.py:27`（`POST /task-assignees/batch`）→ FE `compliance-manager-fe/src/views/.../TaskSetupView.vue:292` 在「快速配置」流程呼叫 `API.TASK_ASSIGNEES_BATCH`（`src/config/api/api.js:128`）。**這與 FR-048 文件所記的「FE 無呼叫者」相反**——若第 3 批的死碼清除要刪這支，刪掉會讓 FE 快速配置收 404。**這件事屬第 3 批的判斷，本卡只回報、不處理。**

- **#109 某個專案的管理者在寫入「群組層」／「控制項層」成員時，填進來的群組或控制項可能根本不屬於那個專案，系統不比對就寫進去**
  - **修法**（第五棒裁定第 10 條：**逐支補、底層不動**）：在每一支寫入方法的守門之前，補一道「這個群組／控制項所屬的專案，是不是等於填進來的專案」的比對。**不要改 `assert_project_manager` 本身**——那支的職責是判角色，不是判歸屬；混進去會讓兩個問題共用一套查法，日後分歧。
  - 🔴 **入口清單**：
    - `jedi-task-platform/jedi_task_platform/participant/app/service/project_control_participant_service.py:59`（新增）／`:103`（更新）／`:136`（刪除）— 三處都是 `assert_project_manager(..., project_id, group_id=group_id, control_id=control_id)`，角色查了、歸屬沒查
    - `jedi-task-platform/jedi_task_platform/participant/app/service/control_group_participant_service.py:82`（新增）／`:121`（更新）／`:151`（刪除）— 同上，帶 `group_id`
    - ⚠️ **已補查（2026-09-21）：`project_group_participant_service.py` 三支寫入不在本卡範圍，與第 3 批 #68 完全重疊（裁定刪除），本卡不動。**
      查證內容：`jedi-task-platform/jedi_task_platform/participant/app/service/project_group_participant_service.py` 的 `add_project_group_participant()`（`:62`）／`update_`（`:90`）／`delete_`（`:117`）確認**整支檔案沒有 import `assert_project_manager`，一道角色守門都沒有**（import 區 `:1-10` 只有 auth_context／transaction／DTO／entity／domain service）。**但這三支是全系統零呼叫的死碼，不是活的入口**：
      ① 套件自己沒有對應 route——`jedi_task_platform/participant/api/routes/` 只有 `control_group_participant_route.py`／`project_control_participant_route.py`／`project_participant_route.py`／`task_assignee_route.py` 四支，**沒有 `project_group_participant_route.py`**；
      ② BE 主專案 `grep -rn "add_project_group_participant|update_project_group_participant|delete_project_group_participant" --include=*.py .` **零命中**；
      ③ FE `grep -rn "ProjectGroupParticipant|project-group-participant" src/` **零命中**；
      ④ 唯一的 consumer 是 AI 儀表板走**查詢**那支（`di_containers/dashboard_apis/participant.py:96-101`，`participant.get_project_group_participants`），不碰這三支寫入。
      **結論**：這正是第 3 批卡 N-2 的 #68（「群組層成員管理三支因系統啟動漏接一條線，整支完全不檢查權限」→ 裁定刪除三支、查詢留著、資料表保留）。**本卡的 #109 範圍維持 `project_control_participant_service.py` 與 `control_group_participant_service.py` 兩支檔案共六處，不擴大。** 順便把上面查到的 file:line 補給第 3 批當入口清單（N-2 的 #68 入口欄原本也是待補）。
  - **零引用查證**：本件不含刪除動作，不適用。

- **#110 更新專案成員資料時，撈不到既有紀錄就整段跳過管理者檢查——目前靠後面另一道檢查頂住，實際結果是「查無資料」而不是「未經授權的寫入」；但哪天有人改成「查不到就當新增」，這道把關會靜默消失**
  - **修法**：把「撈不到就跳過檢查」改成「**撈不到就明確拒絕**」（`raise NotFound`），不要依賴後面那道檢查。
  - **照抄對象**：同檔刪除功能（`task_assignee_service.py:246-248`）已經是 `if existing is None: raise NotFound(...)`。
  - 🔴 **入口清單**：
    - `jedi-task-platform/jedi_task_platform/participant/app/service/task_assignee_service.py:208-214` — `update_task_assignee()`，`if existing is not None and existing.project_id is not None:` 才守門，**`existing is None` 時整段 if 不進去、守門被跳過**
    - `jedi-task-platform/jedi_task_platform/participant/app/service/project_participant_service.py:108-112` — `update_project_participant()`，⚠️ **這一支的形狀不同**（它是 `if enforce_role and self._participant_role_service:` 直接守 `project_id`，沒有 existing 反查）。**開卡前首腦確認 #110 指的是哪一支**；材料檔寫「更新專案成員資料」，字面像後者，但「撈不到既有紀錄就跳過」的程式形狀只在前者。

- **功能不能壞**：
  - 三件都是**寫入路徑加一道比對**，正常操作（在自己專案裡指派自己專案的任務）完全不受影響
  - `enforce_role=False` 這個參數在多處出現，是**系統內部呼叫的旁路**（例如建專案時批次灌初始成員）。**新加的歸屬比對要不要吃這個旁路，要跟既有守門一致**——不一致的症狀是建專案時灌成員失敗、而錯誤訊息看起來像「找不到專案」
  - 守門未接線時會 `RuntimeError` fail loudly（`jedi_task_platform/participant/common/guard.py:45-52`），**這個設計不要動**
- **這張卡的手測總清單**（DEV，專案規劃頁）：
  1. 自己開一個新專案 A（自動是 manager），另外找一個別人的專案 B
  2. 在 A 裡送指派，任務填 B 的任務編號 → **要被拒**（#41）
  3. 在 A 裡送群組層成員，群組填 B 的群組編號 → **要被拒**（#109）
  4. 在 A 裡送控制項層成員，控制項填 B 的 → **要被拒**（#109）
  5. 更新一筆**不存在**的任務指派 → **要回「找不到」**（#110）
  6. 正常路徑全部照舊：在 A 裡指派 A 的任務、設 A 的群組成員、改 A 的成員角色、刪成員 → **全部成功**
  7. **建一個新專案並帶初始成員** → 成功（這條專測 `enforce_role=False` 旁路沒壞）
  - **批次驗**：一輪跑完 1～7，收齊失敗再一次修。
- **紀律補充**：poetry path dependency 開發、path 改動不 commit、**不發版**。

---

## 卡 1-4：問卷還原歷史版本要驗版本歸屬；問卷討論改／刪要驗作者本人

- **範圍**：套件 monorepo，子目錄 `jedi-survey`，worktree 建議 `wt-fix-b1-survey`，涵蓋 **#38、#39**

### 每件的修法

- **#38 在自己有權限的那份問卷上按「還原歷史版本」時，把版本編號改成別部門問卷的版本編號，就能把別人的答案複製進自己這一份裡——這不只是看到，是把別人的內容搬進來**
  - **修法**：取出歷史版本之後，**比對它屬不屬於當前這份問卷**（`answer_history` 反查它的 `task_survey_id`，跟參數帶進來的 `task_survey` 比對），不同就拒絕。權限照舊走嚴的那條（被指派人本人或專案管理者），**不跟著讀取放寬**。
  - 🔴 **入口清單**（人工開檔核對）：
    - `jedi-survey/jedi_survey/app/service/question_answer_history_service.py:49-58` — `revert_question_answer_from_history()`。`:53-54` 的 `assert_task_survey_writer()` 守的是**參數帶進來的那份問卷**，`:56` 的 `get_question_answer_history(history_uid)` 則是**照 uid 直接撈**，兩者之間**一行歸屬比對都沒有**
    - `jedi-survey/jedi_survey/api/routes/question_answer_history_route.py:41-47` — route 入口（`RevertQuestionAnswerHistoryRoute.post`），`task_survey_uid` 與 `history_uid` 都從 payload 拿
  - **功能不能壞**：還原歷史版本後會透過 WebSocket 推 `reload` 給同一份問卷的其他人（route `:59-61`）——比對失敗要在推播**之前**就拒絕，不要推一個空事件出去。

- **#39 有問卷修改權限的人可以改掉別人寫的討論內容、而且改完還掛著原作者的名字；刪除是直接從資料庫抹掉、不留痕跡**
  - **修法**（兩半，都要做）：
    - **改**：一律禁止改別人的。動手前先載出那則留言比對 `created_user`，不是本人就拒絕。
    - **刪**：管理者可以刪別人的，但要**另開一條寫明白的管理路徑**，刪掉之後記成「由某某管理員移除」，**不能讓留言憑空消失**。
  - 🔴 **入口清單**（人工開檔核對）：
    - `jedi-survey/jedi_survey/app/service/survey_discussion.py:46-55` — `update_discussion()`，`verify_survey_discussion_exist_by_uid(uid)` 只確認存在，**沒有比對 `created_user`**；`:53` 把 `updated_user` 設成操作人，**`created_user` 原封不動**，所以畫面上仍顯示原作者
    - `jedi-survey/jedi_survey/app/service/survey_discussion.py:57-61` — `delete_discussion()`，整支只有一行 `delete_by_uid(uid)`，**硬刪、不比對作者、不留痕**
    - `jedi-survey/jedi_survey/app/service/survey_discussion_service.py:69-80` — 外層 wrapper，兩支都只是轉呼叫（改法落在內層，但這一層的簽名可能要多收一個「操作人 id」）
    - `jedi-survey/jedi_survey/api/routes/survey_discussion_route.py:62-65`（PUT，守 `survey_update_capability`）／`:82-85`（DELETE，守 `survey_delete_capability`）— **門檻不是零**：要有問卷修改／刪除能力點才按得動，但那道門只問「你有沒有改問卷的權力」，不問「這則留言是不是你寫的」
  - **功能不能壞**：
    - 「軟刪除留痕」需要資料表有可用的欄位。✅ **已補查（2026-09-21）：`survey.survey_discussions` 沒有任何軟刪除欄位 → 這一半必須配一支 BE migration，開卡時拆成兩張卡。**
      實查 DEV（`\d survey.survey_discussions`）與出貨基線（`scripts/init/02-schema.sql:16432-16443`）**完全一致**，全部欄位只有：`uid` / `user_id` / `survey_id` / `message` / `id` / `created_at` / `updated_at` / `created_user` / `updated_user` / `ref_id`。**沒有 `is_deleted`、沒有 `deleted_at`、沒有 `deleted_user`，連可借用的 `status` 都沒有。**
      連帶要寫進卡片的三件事：
      ① **migration 屬主線**（`phase=active`／`envs=*`）→ 紀律段要寫「**出貨基線待重產**」（新客戶裝的是 `scripts/init/02-schema.sql` 這份長好的結構，不同步則新裝客戶缺這支且無錯誤訊息）。**只套 DEV，STG／POC 一律不碰。**
      ② 這張表有四條 RLS policy（select／insert／update／delete，全部以 `survey.surveys` 的可見性為條件）。**改成軟刪除後，「已刪除」的列仍會通過 select policy** ——所以「怎麼呈現」必須在讀取端（`survey_discussion_service.py:31` 的 `get_survey_discussions`）處理，不能指望 RLS 幫忙過濾。
      ③ 既有 delete policy 仍允許實刪；migration 不要動 policy（軟刪除走 UPDATE 路徑即可），**否則會連帶影響 `surveys` CASCADE 刪除的既有行為**（`survey_discussions_survey_id_fkey` 是 `ON DELETE CASCADE`）。
    - 留言讀取端（`get_survey_discussions`，`survey_discussion_service.py:31`）要確認軟刪除的留言**怎麼呈現**——是顯示「由管理員移除」的佔位，還是整筆不回。這是產品呈現決定，卡上要寫清楚要哪一種（建議顯示佔位，才符合「不能憑空消失」的裁定）。
- **這張卡的手測總清單**（DEV，問卷填答頁）：
  1. A 在問卷 X 上留一則討論
  2. B（有問卷修改能力點）試改 A 那則 → **要被拒**
  3. A 改自己那則 → 成功，畫面上作者仍是 A
  4. 管理者刪 A 那則 → 成功，**畫面上看得出「由某某管理員移除」，不是整筆消失**
  5. B（非管理者）刪 A 那則 → **要被拒**
  6. 在問卷 X 按還原歷史版本，選 X 自己的舊版本 → 成功，答案正確還原，其他人的畫面收到 reload
  7. 同上但把版本編號換成問卷 Y 的 → **要被拒**，且**不推 reload**
  - **批次驗**：一輪跑完 1～7。
- **需決策者先裁的**：無裁決項，但**補查結果改變了開卡形狀**——`survey_discussions` 確認無軟刪除欄位，故本卡依原定規則**拆成兩張**：卡 1-4a（套件側：#38 歸屬比對 ＋ #39 的「改」那一半 ＋ 讀取端呈現）、卡 1-4b（BE `scripts/sql/` migration：加軟刪除欄位，只套 DEV，出貨基線待重產）。**1-4b 要先套上 DEV，1-4a 的軟刪除那一半才驗得起來。**
- **紀律補充**：poetry path dependency、**不發版**。

---

## 卡 1-5：公告改／刪要驗是不是自己發的；發送對象改成必選

- **範圍**：套件 monorepo，子目錄 `jedi-bulletin`，worktree 建議 `wt-fix-b1-bulletin`，涵蓋 **#42、#111**
- 🔴 **這張卡含一項待裁（D-b1-2），裁定之前只能做 #42 那一半。**

### 每件的修法

- **#42 改公告、刪公告只問「你能不能改公告」，不問「這則是不是你發的」——一個部門的編輯者能改掉或永久刪掉別部門的公告。公告是大家會相信的內容，一則被動手腳的公告能直接誤導全公司；而且刪公告是整筆從資料庫移除、內容救不回來**
  - **修法**：改與刪之前先確認「這則公告是不是自己發的」（比對 `created_user`）。
  - ⚠️ **決策者已判斷「公告刪了就刪了是合理的、不需要留存」**（見 `docs/security-report/M17-bulletin.md:112`）——所以**不要順手把硬刪改成軟刪**，即使那張表有 `is_deleted` 欄位。這張卡只補歸屬判斷。
  - 🔴 **入口清單**（人工開檔核對）：
    - `jedi-bulletin/jedi_bulletin/app/service/bulletin_service.py:284-300` — `update_bulletin()`，直接 `BulletinEntity(**kwargs)` + `update_bulletin()`，**沒有先載出既有公告比對 `created_user`**
    - `jedi-bulletin/jedi_bulletin/app/service/bulletin_service.py:303-315` — `delete_bulletin()`，`get_bulletin_by_uid` 只為了清橋表，**沒有比對作者**
    - `jedi-bulletin/jedi_bulletin/api/routes/bulletin_route.py:66-73` — PUT，守 `bulletin_update_capability`
    - `jedi-bulletin/jedi_bulletin/api/routes/bulletin_route.py:77-82` — DELETE，守 `bulletin_delete_capability`
  - **功能不能壞**：
    - `delete_bulletin()` 的**先清橋表再刪主表的順序不可反**（`bulletin_org_units.bulletin_id` 的外鍵沒有 CASCADE，順序反了會撞 ForeignKeyViolation 回 409）。新加的比對要放在這兩步**之前**。
    - 管理者要不要能改／刪別人的公告？**卡上要寫明**：建議「自己發的 ＋ 具備管理能力點者」，與 #39 的刪除裁定同一形狀。

- **#111 用網址直接開一則公告時系統什麼都不檢查（不看部門、不看是不是草稿、不看有沒有到發布時間），所以調部門之後舊部門的公告用舊網址照樣打得開；公告列表碰到「沒有被分配部門」的帳號就整個不過濾、全部給看（含草稿、含過期），而「要不要過濾」這個開關還是呼叫端自己在請求裡傳的**
  - **修法**（已裁定，原則十四「讓意圖變明確」）：**發送對象改成必選——「全公司」或「指定部門」，兩者擇一，都沒選就不能發布**；讀取時一律照那個值過濾，清單與單筆用同一套規則。一個設計解掉三件事（#111 的兩條 ＋ #42 旁邊「改公告沒帶發送對象會靜默清空」那個附帶問題）。
  - 🔴 **入口清單**（人工開檔核對）：
    - `jedi-bulletin/jedi_bulletin/app/service/bulletin_service.py:212-236` — `get_bulletins_and_pager()`，`:222` 是 `if _filter.auth and user_org_unit_id:` ——**`user_org_unit_id` 為空時整個部門過濾被跳過**（`elif` 那一支只加 `owner_created_user`，不限部門）
    - 同檔 `:239-255` — `get_bulletins()`，同一個形狀（`:246`）
    - 同檔 `:258-265` — `get_bulletin()`（單筆），**只有 `get_bulletin_by_uid` 一行，什麼都不檢查**（這是 #111 的第 3 條）
    - 同檔 `:284-300` — `update_bulletin()` 的 `:297-299`：**先 `delete_by_bulletin_id` 清掉整組部門、再用 payload 重建**。payload 沒帶 `org_units` 就等於清空 → 那則公告變成所有人可見（#42 的附帶問題）
    - `jedi-bulletin/jedi_bulletin/api/routes/bulletin_route.py:57-62` — 單筆讀取 route，只有 `@auth_required`
    - **`auth` 這個開關是呼叫端傳的**：FE 只有 `compliance-manager-fe/src/views/bulletins/BulletinManage.vue:82` 一處在傳 `auth: true`（首腦已 grep 全 FE 確認**只有這一處**）。改成伺服器判定之後，這一行要跟著拿掉。
  - **FE 現況（首腦已查，runner 不用重查）**：`compliance-manager-fe/src/views/bulletins/BulletinForm.vue:42-43` 的 `org_units` **前端已經是 `required`**。所以「必選」在畫面上已成立，缺的是**後端也認**與**「全公司」這個明確選項**。
  - **功能不能壞**：
    - 既有公告資料裡**已經有一批 `org_units` 是空的**（那正是 #111 的成因）。改成必選之後，**那些舊資料要落到哪一邊**？留空會讓它們讀取時全被擋掉（資料等於廢掉，原則十三的反面），塞「全公司」會讓原本只想發給某部門的變成全公司可見。**這是 D-b1-2 的核心。**
    - 首頁與公告列表都走 `BulletinManage.vue`（首腦已確認 `BulletinList.vue` 只是把它包一層、`Home.vue` 只是 router.push）——**手測只要顧這一個畫面**。
- **這張卡的手測總清單**（#42 部分可先做）：
  1. A（部門甲的公告編輯者）發一則公告
  2. B（部門乙的公告編輯者）試改 A 那則 → **要被拒**
  3. B 試刪 A 那則 → **要被拒**
  4. A 改自己那則 → 成功，且**發送對象沒有被清空**
  5. 管理者改／刪 A 那則 → 依卡上寫定的規則（建議放行）
  6. 刪一則有綁部門的公告 → 成功、不回 409（這條專測清橋表順序沒被弄壞）
  - **#111 部分的手測等 D-b1-2 裁定後補**
- **需決策者先裁的**：**D-b1-2**（見本頁末）
- **紀律補充**：poetry path dependency、**不發版**。**若 D-b1-2 裁定要加欄位，migration 與 FE 各另開一張小卡**，不要讓這張卡跨三個 repo。

---

## 卡 1-6：設備與資訊系統七支讀取補權限檢查；修改請求不准夾帶停用／啟用

- **範圍**：套件 monorepo，子目錄 `jedi-asset`，worktree 建議 `wt-fix-b1-asset`，涵蓋 **#43 的守門半、#112**
- 🔴 **前置**：卡 1-9 的第一支 migration（補 `device.read` / `information-system.read` 給稽核人員角色）**必須先套上 DEV**，否則這張卡一驗就會看到「五個畫面的下拉選單安靜變空」。

### 每件的修法

- **#43 管理員把某人的「查看設備」權限關掉，那人只是側邊選單看不到入口，把網址貼上去照樣打開整張設備與資訊系統清冊（主機名稱、網路位址、作業系統版本、各系統的機密性等級、部署模式、授權邊界、負責人）——等於把客戶內部網路的組成攤開。這不是漏掉，是程式碼裡寫明的刻意取捨，現已裁定推翻**
  - **修法**（已裁定採甲案）：**七支讀取功能各加一道權限檢查**，用的是同一支檔案裡寫入功能已經在用的現成機制（`capability_required`），**不要造新東西**。權限點名保留、字串凍結。
  - 🔴 **入口清單**（人工開檔核對，七支齊全）：
    - **設備四支**：`jedi-asset/jedi_asset/api/routes/device_route.py:37-38`（`DeviceListRoute.post`，清單）／`:56-57`（`DeviceMenuRoute.get`，選單）／`:68-69`（`DeviceDetailRoute.get`，明細）／`:106-107`（`DeviceReferenceRoute.get`，**被引用查詢**——刪除前顯示「將解除 N 筆關聯」那個）
    - **資訊系統三支**：`jedi-asset/jedi_asset/api/routes/information_system_route.py:42-43`（選單）／`:59-60`（清單）／`:102-103`（明細）
    - 七支目前**都只有 `@auth_required`，一個 `@capability_required` 都沒有**；同檔的寫入六支全都有（device `:78`／`:90`／`:124`，IS `:82`／`:114`／`:135`）
    - **那句刻意取捨的註解在**：`jedi-asset/jedi_asset/plugin/contract.py:27`（「`read` 兩項 BE route 不守（守門只在寫入類），但前端選單與…」）。**修完要把這句改掉**，否則下一個讀它的人（含 AI）會被過期脈絡帶偏。
    - 能力點名（凍結，不可改）：`contract.py:31` `device.read`、`contract.py:35` `information-system.read`。`AssetPluginConfig`（`contract.py:66-78`）目前**只有六個寫入能力點欄位、沒有兩個讀取的**——要照同一形狀補兩個 config 欄位與兩個 `DEFAULT_*` 常數。
  - 🔴 **功能不能壞（這一段是本卡最關鍵的部分，比守門本身更容易出事）**：
    - **會擋到誰（首腦已查證的數字，runner 不用重查）**：兩顆讀取權限**14 個角色裡 13 個持有、1 個沒有**。唯一沒有的是某客戶的「稽核人員」角色，掛 **1 個使用者**（全庫 41 個使用者）。**新裝客戶踩不到**（出貨初始資料只建一個管理員角色、八顆權限全給）；**踩得到的是已經自訂過角色的既有客戶**。
    - **影響面比表面大**：這七支同時是**別的功能的下拉選單資料來源**，實查到五處：①專案規劃頁 ②任務設定頁 ③合規文件的設備分頁 ④合規文件的資訊系統分頁 ⑤合規文件匯入時的資產挑選器。
    - 🔴 **症狀是「安靜變空」不是「跳錯誤」**：這五處呼叫後端的地方**全部都是把錯誤接住、只寫進開發者主控台、然後把清單設成空的**。使用者看到的是一個空的下拉選單，並且以為公司根本沒建過設備資料。**驗收如果只測「資產管理頁打不打得開」，會完全測不到這個。**
    - **所以 1-9 的 migration 要先套**：補權限進「稽核人員」角色，五處才會照舊有資料。
    - ⚠️ **別漏掉設備的「被引用查詢」**（`device_route.py:107`）。按得到刪除鍵的人必然已有刪除權限，替它補讀取檢查沒有實際衝擊，**但漏掉就會變成「四支讀取只補了三支」的不對稱，日後沒人知道為什麼少一支**。

- **#112 只給了修改權限、刻意不給刪除權限的操作人員，直接送一個「停用」欄位，就能讓任何一個資訊系統從所有選單與清單裡消失，稽核專案就選不到它了**
  - **修法**（兩個方向要一起處理，只堵一邊等於只修一半）：
    - **方向一（停用）**：從修改用的資料格式把 `is_active` 拿掉；或收到 `is_active=false` 時改要求刪除權限。
    - **方向二（悄悄啟用）**：資料存放結構把 `is_active` 預設成 `True`，而修改流程是「拿送進來的內容重建一份完整資料、再整筆覆蓋回去」——所以**只有修改權限的人送出一個不帶 `is_active` 的修改請求，就會把一個已被停用的資訊系統悄悄重新啟用**。
  - 🔴 **入口清單**（人工開檔核對，三處都還在原狀）：
    - `jedi-asset/jedi_asset/api/serializers/information_system.py:59` — `InformationSystemUpdateSchema.is_active`（`load_default=None, allow_none=True`），**修改用的格式收這個欄位**
    - `jedi-asset/jedi_asset/infra/repository/information_system_repo_impl.py:127` — `model.is_active = entity.is_active`，**照單寫入**
    - `jedi-asset/jedi_asset/infra/repository/information_system_repo_impl.py:134-140` — `deactivate()`，刪除功能做的確實是同一件事（`model.is_active = False`）
    - `jedi-asset/jedi_asset/domain/entity/information_system_entity.py:27` — `is_active: bool = True`，**這就是方向二的來源**（不帶欄位 → entity 拿預設 True → 覆蓋回去變成啟用）
    - **設備那半沒有這個欄位**，是另一回事，不要順手動。
  - **功能不能壞**：正常的「刪除資訊系統」（走 delete route `:136`，守 `information_system_delete_capability`）行為不變；正常修改（不碰啟用狀態）行為不變。
- **這張卡的手測總清單**：
  1. **先確認 1-9 的 migration 已套上 DEV**（`SELECT` 一下稽核人員角色有沒有那兩顆能力點）
  2. 用**有**那兩顆權限的角色：資產管理頁的設備清單／選單／明細／被引用查詢、資訊系統的選單／清單／明細 → **七支全部照舊可用**
  3. 用**沒有**那兩顆權限的角色（自建一個測試角色，把兩顆拿掉）：七支 → **全部 403**
  4. 🔴 **同一個沒有權限的角色，逐一打開這五個畫面，確認下拉選單「是否仍然有資料」**：①專案規劃頁 ②任務設定頁 ③合規文件的設備分頁 ④合規文件的資訊系統分頁 ⑤合規文件匯入的資產挑選器。**期待結果依卡上寫定的決定**（若採「全補」則這五處對無權限者會空，那是預期行為、要在回報裡明講；若決定把「當選單用」的排除在守門外，則要仍有資料）
  5. 只有修改權限的帳號送 `is_active=false` → **要被拒**
  6. 只有修改權限的帳號送一個**不帶** `is_active` 的修改請求給一個**已停用**的系統 → **那個系統不可以被重新啟用**
  7. 有刪除權限的帳號正常刪除一個資訊系統 → 成功
  - **批次驗**：一輪把 2～7 全跑完（約 20 個請求／畫面），收齊失敗再一次修。**不要逐支驗。**
- **驗收打折要當場講明**：如果 DEV 上湊不出「自訂過角色的既有客戶」這個情境（要自己手動建角色、拿掉能力點來模擬），**回報裡要寫明這是模擬的**，不可報成「已驗證既有客戶不受影響」。
- **紀律補充**：poetry path dependency、**不發版**。

---

## 卡 1-7：意見回饋六支裸奔端點補權限，改／刪補「這筆是不是你的」，清單分兩套

- **範圍**：套件 monorepo，子目錄 `jedi-issue`，worktree 建議 `wt-fix-b1-issue`，涵蓋 **#45**
- 🔴 **這張卡含一項待裁（D-b1-3）**，裁定之前可以先做「補權限」與「補歸屬」兩半，清單分流那一半要等。

### 每件的修法

- **#45 任何一個一般員工在意見回饋頁面上，就能改掉或刪掉別人的回饋，連帶把開發團隊正在處理的那張外部問題單也一起關掉，而稽核紀錄還會把操作人記成合法本人。這是目前所有問題裡唯一一條「一般員工現在就能實際操作利用」的——不需要任何特殊權限、不需要技術手段，在正常操作介面上就做得到**
  - **修法**（兩層都要補，缺一不可）：
    - **一、權限檢查**：用現有的統一守門機制（`capability_required`），**不要另外寫一套**。**這一層不卡任何決策**——十顆能力點在資料庫裡全部已定義好、也已綁在前端選單上，缺的純粹是後端那一段接線。
    - **二、歸屬檢查**：改或刪之前先確認那筆是不是自己的（或有沒有管理權限）。要放在業務邏輯那一層，因為要先把資料撈出來才知道要判誰。
  - 🔴 **入口清單**（人工開檔核對）：
    - **route 層，六支只有 `@auth_required`**：`jedi-issue/jedi_issue/api/routes/feedback_route.py:35-36`（`FeedbacksRoute.post`，清單）／`:52-53`（單筆明細）／`:59-60`（新增）／`:74-75`（**改**）／`:91-92`（**刪**）／`:102-103`（刪附件）
    - **唯一有守門的**：同檔 `:115-117`（`FeedbackExportRoute`，守 `feedback_export_capability`）
    - **另外兩支對外入口**：`jedi-issue/jedi_issue/api/routes/issue_member_route.py:12-13`（**全站使用者名冊**，登入就能撈、回所有客戶的人含電子郵件——**這一支已裁定整塊移除，歸第 3 批的 M23 成員名冊，本卡不動**）／`jedi_issue/api/routes/label_route.py:13-14`（標籤下拉，內容是系統預設分類選項、不是客戶資料，不必守）
    - **service 層，改與刪從頭到尾沒有任何「這筆是不是你的」比對**：`jedi-issue/jedi_issue/app/feedback/service/feedback_service.py:241-243`（`update_feedback()`，`get_feedback_issue_by_uid(uid)` 拿到就直接改）／`:279-281`（`delete_feedback()`，同樣拿到就直接刪）
    - **跨出系統那一段**：`feedback_service.py:282-296` — 刪除時會先把 GitLab／GitHub 上那張 issue 設成 closed（`set_gitlab_issue_closed` / `set_github_issue_closed`），再 `set_issue_closed`、最後才刪本地。**所以「任何員工都能關掉開發團隊正在處理的問題單」是這幾行**。歸屬檢查要放在這一串之前。
    - **能力點清單（已定好，字串凍結）**：`jedi-issue/jedi_issue/plugin/contract.py:39-48` — `feedback.create` / `.read` / `.update` / `.delete` / `.export` / `feedback-view.read`。同檔 `:32-34` 的註解自己寫明「BE route 目前只守 `feedback.export` 一項；其餘五項同樣是前端選單與 `route_capabilities` 在認」——**修完要把這句改掉**。
    - **`AssetPluginConfig` 的對應物**：`contract.py:94` 目前只有 `feedback_export_capability` 一個欄位，要照同一形狀補其餘五個。
  - **功能不能壞（首腦已查證的三項綠燈，runner 不用重查）**：
    - **這些入口有沒有被別的頁面借去當資料來源？** **沒有**——只有意見回饋自己那兩支頁面在呼叫。（**與 1-6 的設備清冊形狀完全不同**：那邊是「一個讀取入口被四個不相干的頁面當資料來源」，所以那邊要先補權限資料、這邊不用。）
    - **補下去會不會擋掉現在的人？** **不會**——DEV 14 個角色、41 個使用者，**14 個角色五顆權限全部都有**，零缺漏。
    - **新客戶裝起來會不會踩到？** **不會**——出貨初始資料只建一個管理員角色、**五顆全給**，兩條選單路由也都掛好了。
    - **實際效果**：把前端本來就在認的那套規則，在後端也認一次。**今天不會有任何人被擋**；等到哪天管理員真的去取消某個角色的勾選，那一格才會生效。
  - **清單分兩套（D-b1-3 的所在）**：
    - **裁定結果**：「我的意見回饋」那一頁只回自己建立的；**管理頁**回整個客戶的（但要真的檢查呼叫的人有沒有管理權限）。
    - 🔴 **但要修的是兩件事、不是一件**：**後端目前分不出這兩頁是誰在呼叫**。前端確實有兩個路由、選單也各自綁了一顆能力點，**但兩頁用的是同一個畫面元件、打的是同一支入口**——首腦已核對：`compliance-manager-fe/src/views/feedback/FeedbackManageTabView.vue:65` 用 `:view="permissions.length === 1"` 這個**前端算出來的旗標**切換唯讀模式，而 `SystemFeedbackManage.vue:73` 兩種模式打的都是同一個 `API.FEEDBACKS`。後端只看到「有人要清單」，就把整個客戶的回饋全部回傳。
    - 所以順序是：**先讓後端能分辨這兩種呼叫**（沒有這一步，第二步做不到），**再兩邊各自套不同規則**。
    - **連帶一件**：自己那頁只看得到自己的，改與刪自然也只改得到自己的；**但後端仍然要獨立檢查一次**——「看不到所以改不到」是前端在擋，把網址直接貼上去就繞過去了。
- **這張卡的手測總清單**：
  1. A 送一則意見回饋
  2. B（一般員工、有全部五顆能力點）試改 A 那則 → **要被拒**（歸屬）
  3. B 試刪 A 那則 → **要被拒**，且**外部問題單沒有被關掉**（這條要去 GitLab／GitHub 看，或確認整合沒啟用時看 log 沒有送出 close）
  4. A 改自己那則、刪自己那則 → 成功
  5. A 刪自己那則的附件 → 成功；B 刪 A 那則的附件 → **要被拒**
  6. 建一個測試角色、拿掉 `feedback.update` → 該角色連自己的都改不動（**403，不是 500**）
  7. 匯出行為不變（原本就有守門）
  8. **清單分流（等 D-b1-3）**：A 打「我的意見回饋」只看到自己的；管理者打管理頁看到全客戶的；一般員工把管理頁網址貼上 → **只拿到自己的或 403，不可以拿到全客戶的**
  - **批次驗**：一輪跑完 1～7（8 待裁），收齊失敗再一次修。
- **需決策者先裁的**：**D-b1-3**（見本頁末）
- **紀律補充**：poetry path dependency、**不發版**。`feedback-view.read` 的命名多了 `-view`（對應唯讀檢視那張頁 `system-feedback-view`），**字串凍結不要順手統一**。

---

## 卡 1-8：「檢查流程圖」那支端點補權限檢查與輸入上限

- **範圍**：BE 主專案，worktree 建議 `wt-fix-b1-flow-template-validate`，涵蓋 **#40**
- 獨立小卡，不依賴任何其他卡。

### 每件的修法

- **#40 「檢查流程圖」這支功能任何登入帳號都能打——同一個檔案裡其他功能都掛了權限檢查，只有它沒有；送進來的內容也沒有長度上限、節點數與連線數都不設限**
  - ⚠️ **原本報告寫「打幾次就讓全站停擺」，實測後確認不成立**（它走的是另一套檢查器，處理一千個節點只要 0.027 秒，從頭到尾沒碰到那兩支會算不完的程式）。那段敘述已整個移到 M06 第 3 條（歸第 5 批）。**本卡只修越權與輸入上限兩件，不要順手做 DoS 那條的治本。**
  - **修法**：補權限檢查（照同檔既有的三個 `require_capability` 常數挑一個，這支是編輯器用的 inline 檢查，語意上對 `flow_template.update`）；加上內容長度與節點數上限。
  - 🔴 **入口清單**（人工開檔核對）：
    - `api/flow_engine/routes/flow_template_route.py:130-144` — `FlowTemplateValidateRoute.post`，`method_decorators = [jwt_required()]` 之外**一個 `require_capability` 都沒有**
    - 同檔 `:21-26` — 三個現成的能力點 decorator（`_flow_template_create` / `_update` / `_delete`），**照抄挑一個，不要新造**
    - 同檔 `:47`／`:68`／`:78`／`:90`／`:108`／`:121` — 其餘六支全都掛了，只有 validate 沒掛（**漏寫不是設計**）
    - `api/flow_engine/serializers/flow_template.py:31-32` — `FlowTemplateValidateRequestSchema.bpmn_xml = fields.Str(required=True)`，**沒有 `validate=validate.Length(max=...)`**。同檔其他欄位有用 `validate.Length`，照抄
    - `app/flow_engine/service/flow_template_app_service.py:140-152` — `validate()`，直接 `BpmnTopologyValidator(rules).validate(bpmn_xml)`，**沒有節點數／連線數上限**
  - **上限數字（已定案，不用重推）**：**分岔深度 8 層、節點總數 100 個**（依據見 `docs/security-report/M06-flow-engine.md` 的 `{#limits}` 段）。XML 字串長度上限報告沒給數字 → **卡上寫「runner 依 100 個節點的實際 XML 大小推一個寬鬆值並在 commit message 寫理由」**，不要憑空定。
  - **功能不能壞**：
    - 這支是 **FE 編輯器拖 sequenceFlow 時 debounce 呼叫**的（route docstring 寫明），**回應形狀不可變**：topology 違規不 400、回 200 + `{valid: false, violations:[...]}` 讓 FE 渲染 inline 警告。**超過上限時要回什麼？** 建議也走 violations 形狀（多一個 rule），不要改成 400——改成 400 會讓編輯器的 inline 警告變成整頁錯誤（原則十一：安全措施不可以擋住正常客戶）。**這一點卡上要寫死。**
    - `publish()`（同檔 service `:108-113`）也呼叫 `_validate_bpmn` 與 `_validate_bpmn_topology`。**新加的上限如果放在共用的 validator 裡，會連帶擋住既有超過 100 個節點的範本無法發布**。✅ **已補查（2026-09-21）：DEV 與出貨初始資料都沒有任何範本接近 100 個節點，100 這個上限放在共用 validator 裡也不會擋到任何既有資料。**
      查到的實際數字：
      - **DEV（`compliance.workflow_templates`，59 筆）**：節點數最大 **3 個**（`<bpmn:` 標籤總數最大 14、含 diagram 標籤）。`WHERE 節點數 > 100` 查出 **0 筆**。這 59 筆全是 `[a] ...` / `[b] ...` 形狀的控制項稽核項自動產生範本，不是人畫的。
      - **出貨初始資料（`scripts/sql/seeds/bpmn/` 四支 `.bpmn`）**：`builtin-full-audit-with-review.bpmn` **9 個節點**（10 條 flow，最複雜的一支）／`builtin-full-audit.bpmn` 7 個／`builtin-self-assessment.bpmn` 5 個／`builtin-internal-check.bpmn` 4 個。與 `docs/security-report/M06-flow-engine.md` 記的「最複雜出貨範本 9 節點 2 層分岔」完全吻合。
      - 注意：`scripts/init/04-seed-core.sql:566,599` 明文驗證 `workflow_templates` **必須為 0 筆**（D11.2：建資源庫時才自動產生），所以新裝客戶一開始連一筆範本都沒有，只有上面那四支 `.bpmn` 會被灌進去。
      **結論**：**上限放共用 validator 是安全的**，不必為了 publish 路徑另開一條。**但打折處要照實寫進 commit message**：開發環境裡沒有任何一份是客戶自己畫的範本，「客戶實際會畫多複雜」我們沒有真實樣本，100 這個數字是拿現有最大值（9）的十倍推出來的。
  - **手測**：
    1. 一般登入帳號（沒有流程範本能力點）打 validate → **403**
    2. 有能力點的帳號打 validate，送一張正常流程圖 → **200 且 valid: true**
    3. 同上，送一張有 topology 違規的圖 → **200 + violations**（形狀不變，FE 的 inline 警告照舊出現）
    4. 送一張 150 個節點的圖 → **回 violations（不是 400）**，訊息看得出是超過上限
    5. 送一個超長字串 → 被 schema 擋下
    6. **在 FE 流程編輯器裡實際拖一條線** → inline 警告照舊跳出來（這條最重要，是「沒弄壞正常客戶」的證據）
    7. 既有內建範本仍可發布（專測上面那個 ⚠️）
- **這張卡的手測總清單**：上面七項。第 6、7 項不可省。

---

## 卡 1-9：兩支資料庫修正——補設備讀取權限給稽核人員角色、放寬意見回饋的新增規則

- **範圍**：BE 主專案 `scripts/sql/`，worktree 建議 `wt-fix-b1-migrations`，涵蓋 **#44、#43 的權限資料半**
- 🔴 **這張卡要先做完並套上 DEV，卡 1-6 才驗得起來。**
- 🔴 **只套 DEV。STG／POC 一律不碰**（環境異動鐵律）。

### 每件的修法

- **#43（權限資料半）補上設備／資訊系統讀取權限給唯一沒有它的那個角色**
  - **為什麼這件事在 migration 這張卡**：卡 1-6 要給七支讀取端點補守門，**補的同時必須把那顆權限補進唯一沒有它的那個角色**，否則五個地方的下拉選單會**安靜變空**（不跳錯誤）。**這一半先落地，1-6 才有安全的施工順序。**
  - **首腦已查證的現況**：兩顆讀取權限 **14 個角色裡 13 個持有、1 個沒有**；唯一沒有的是某客戶的「稽核人員」角色，掛 1 個使用者。新裝客戶踩不到（出貨初始資料只建一個管理員角色、八顆全給）。
  - 🔴 **入口清單**：
    - `scripts/sql/packages/_capabilities.sql:35` — `('device.read', 'device', 'read', 'device read access', FALSE)`，能力點本身**已存在**，不用新建
    - `scripts/sql/packages/_capabilities.sql:49` — `('information-system.read', ...)`，同上
    - `scripts/init/04-seed-core.sql:277` / `:350` — 出貨初始資料把兩顆給 `Administrator` 角色的那兩行（**不用改**，列出來是證明新客戶不受影響）
    - `scripts/init/04-seed-core.sql:450` / `:469` — `route_capabilities` 把兩顆綁到 `device-manage` / `information-system-manage` 兩條選單路由（**不用改**）
    - **要新增的**：一支 migration，把兩顆 `role_capabilities` 補給那個缺的角色。**先 `SELECT` 確認缺的是哪一個 `role_id`**（DEV 上現查，不要照抄任何文件裡的 id）。
  - **功能不能壞**：這是**加權限**不是收權限，對任何人都只會變寬不會變窄。

- **#44 一個沒有被分配部門的一般使用者送意見回饋一定會失敗，而且畫面不給任何理由——管理員去查這個人的權限，每一項都對，就是送不出去。新客戶剛裝好、部門還沒建起來時特別容易踩到**
  - **這不是資安漏洞，是一個完全靜默的功能故障。**
  - **修法**（已裁定）：**放寬那條資料庫規則**——送意見回饋跟你在哪個部門本來就無關，那條規則從一開始就不該要求部門。（另兩個選項已明確不採用：「規定帳號一定要有部門」會影響所有既有帳號與建帳流程，代價不成比例；「只把錯誤訊息講清楚」沒有解決卡住這件事。）
  - 🔴 **入口清單**（人工開檔核對）：
    - `scripts/sql/packages/jedi_issue/004-feedback-issues-rls-grants.sql:55-58` — `feedback_issues_insert` policy：
      `WITH CHECK (app_tenant_allowed_for_session(tenant_id) AND (can_read_all_orgs = 't' OR app_org_allowed_for_session(org_unit_id)))`
    - **對照同表另三條**（`:49`／`:62`／`:68`，select／update／delete）：那三條都是 `is_super_admin = 't' OR app_tenant_allowed_for_session(tenant_id)`，**既給超級管理員留旁路、也不要求部門**。**只有 insert 這一條沒有旁路、而且是唯一要求使用者必須有部門的。** 這正是「權限查起來每一項都對、就是送不出去、畫面也不給理由」的來源。
  - 🔴 **施工時要一併確認**：放寬之後，沒有部門的人送出來的回饋，**客戶歸屬仍然要正確記錄下來**——`org_unit_id` 留空可以接受，**但 `tenant_id` 不能空**。要在手測裡實際 `SELECT` 出那一列確認。
  - **功能不能壞**：意見回饋清單的讀取靠 select policy（`:49`）過濾，不吃 insert policy，所以放寬 insert 不會讓別人看到不該看的。

### 這張卡的共同紀律（migration 專用，不可省）

- **檔頭要有 `-- Date:`**，每個語句加日期註解
- **沒有新建 table** → 不必加 `GRANT ... TO cm_app`；但**要確認現有 GRANT 不受影響**
- **收尾必 `INSERT public.schema_migrations`**
- **一律 `psql --single-transaction -v ON_ERROR_STOP=1 -f <檔>` 用 `cmmgr` 套**（`cm_app` 受 RLS 擋系統層寫入會 silent fail）
- **只套 DEV**（本機 `localhost:5432`，連線資訊查 `.env`）。**STG／POC 一律不碰**，那是上版動作不是開發動作
- 🔴 **這兩支都是主線 `phase=active`／`envs=*` 的 migration** → **回寫時要明講「出貨基線待重產」**。重產要寫 188 基線庫，屬決策者裁示，**runner 只回報不自己動**。
- **絕不把密碼寫進 migration 檔**（連線資訊只寫帳號／host／port／db 名，密碼寫「請查 `.env`」）

### 這張卡的手測總清單

1. 套完之後 `SELECT` 確認那個「稽核人員」角色已經有 `device.read` 與 `information-system.read`
2. 用那個角色登入 → 側邊選單看得到設備與資訊系統的入口
3. 建一個**沒有被分配部門**的一般使用者 → 用他送一則意見回饋 → **要成功**
4. `SELECT` 出那一列 → `tenant_id` **有值**、`org_unit_id` 可以是空
5. 有部門的使用者送回饋 → 照舊成功
6. 超級管理員送回饋 → 照舊成功
7. 意見回饋清單的可見範圍沒有變寬（用兩個不同客戶的帳號各看一次）
   - **批次驗**：一輪跑完 1～7。

---

## 決策者要裁的事（本批共 3 項）

### D-b1-1：#37 的新守門，要新開一支還是直接把既有那支收嚴？

**問題**：現在有一道守門叫「是不是這個專案的參與者」，任何角色都算通過。這道守門同時被三組功能吃：①完成／退回任務（**本批要收嚴**）②弱點掃描八支改狀態端點（**本批要收嚴**）③佐證文件的上傳／更新／刪除（**角色規則表也說要收嚴，但不在本批**）。直接把那支收嚴，第三組會跟著改；新開一支則要在十處換呼叫。

**我的建議**：**新開一支嚴守門**（例如「是不是這個任務的負責人或這個專案的管理者」），既有那支不動。理由是佐證那三支的手測與驗收沒有排在這一批，順手改掉等於做了一件沒人驗的變更；而角色規則表本來就要求佐證也收嚴，留給後續一棒照同一支新守門接上去，是加一行呼叫的事。

### D-b1-2：#111 公告「發送對象必選」，既有那批發送對象是空的公告要落到哪一邊？

**問題**：裁定是「發送對象改成必選——全公司或指定部門，兩者擇一，都沒選就不能發布」。但資料庫裡**已經有一批公告的發送對象是空的**（那正是這條的成因）。這批舊資料要怎麼處理？留空會讓它們讀取時全被擋掉（等於廢掉那些公告），一律當成「全公司」會讓原本只想發給某部門的變成全公司可見。而且要做到「分得出刻意選全公司」與「忘了選」，資料上可能需要多一個欄位（不能靠「橋表空集合」，那正是現在分不出來的原因）。

**我的建議**：**多一個「發送範圍」欄位（全公司／指定部門），舊資料一律標成「全公司」並在上線公告裡說明**。理由是那些公告現在**實際上就是全公司都看得到**（這條的症狀正是如此），標成全公司是把現狀寫實、不是擴大可見範圍；而留空會讓客戶的舊公告憑空消失，那比較像壞掉。若採此案，卡 1-5 要拆成三張（套件 ＋ BE migration ＋ FE 加選項）。

### D-b1-3：#45 清單分兩套，後端要怎麼分辨「我的意見回饋」與「管理頁」這兩種呼叫？

**問題**：裁定是「自己那頁只回自己建立的、管理頁回整個客戶的（但要真的檢查管理權限）」。但前端兩頁**用同一個畫面元件、打同一支入口**，後端只看到「有人要清單」。要分辨就得改契約：①同一支入口多一個明確參數（例如「範圍＝我的／全部」），送「全部」時後端驗管理能力點；②開第二支入口給管理頁。

**我的建議**：**①同一支入口多一個參數**。理由是既有原則二講「權限檢查走一條路由＋登記，不為每個情境各開路由」；而且第二支入口要重做分頁、排序、匯出的整套契約，前端也要改兩處。多一個參數的代價是 FE 要在管理頁那邊明確送值（一行）。
