# reviewer-spec-review — chấm đối-kháng SPEC `spec-pe-delete-approver.md`

> run `2026-07-27-S155-pe-delete-approver` · S3 · vai reviewer (READ-only, verdict-only)
> Quy ước: mọi FLAG đều có `file:line` mở được. Mọi trục PASS đều kèm **đã thử phá bằng cách nào**.
> Ghi TRONG LÚC LÀM (chống #53).

**VERDICT: PASS-WITH-FLAGS — 17 FLAG (4 HIGH / 8 MED / 5 LOW).**
Khung spec đứng vững: trích dẫn chịu lực đúng, mệnh đề trung tâm F.0 **không bác được**. Nhưng 4 lỗ HIGH sẽ cho ra bản cài đặt **hỏng hoặc hở** nếu giao thẳng cho hmw. H1/H2/H3 phải chốt **trước** khi bắt đầu code (H1 + H2 cần owner trả lời 1 câu; H3 là 2 dòng ràng buộc phải viết vào checklist).

---

## 🔴 HIGH

### H1 — Nới authz mà **không một ai** có quyền: checklist 7 vừa **phá chức năng đang chạy** vừa **giết luôn tính năng mới**

**Điều sai.** Checklist 7 gắn `[Authorize(Policy = "PurchaseEvaluations.Delete")]` lên action Delete, và spec kết luận *"0 key mới, 0 migration quyền"* (§B3 dòng 66). Nhưng spec **không kiểm ai đang có cờ `CanDelete`**.

**Bằng chứng đo được (Dev DB, `SolutionErp_Dev`, lệnh chạy thật):**
```sql
SELECT r.Name, p.CanDelete FROM Permissions p JOIN Roles r ON r.Id=p.RoleId
WHERE p.MenuKey='PurchaseEvaluations'
```
→ `Admin=1 · DeptManager=1` · **Accounting/AuthorizedSigner/CatalogManager/CostControl/Director/Drafter/Equipment/Finance/HrAdmin/Procurement/ProjectManager = 0** (11/13 role = 0).

Nguồn của con số đó nằm trong mã: `DbInitializer.cs:2515` — `bool canDelete = roleName == AppRoles.DeptManager;   // chỉ TPB xóa nháp`. Và seeder thứ hai chỉ nâng `CanRead/CanCreate`, **không bao giờ chạm `CanDelete`** (`DbInitializer.cs:2120-2126`), lại **skip-if-exists** (`:2118`) ⇒ sửa seeder cũng **không** vá được prod.

Tầng policy **không kế thừa**: `MenuPermissionHandler.cs:41-51` lọc `p.MenuKey == req.MenuKey` **khớp chính xác** rồi `AnyAsync(p => p.CanDelete)`. Cơ chế kế thừa mà spec dẫn (`GetMyMenuTreeQuery:66-72`) chỉ là **display-layer** — đúng như spec nói, nhưng nó **không** cấp quyền API.

**Hai hệ quả cụ thể:**
1. **Regression trên chức năng đang chạy:** nút "Xóa phiếu" bản Nháp (`fe-user/src/components/pe/PeDetailTabs.tsx:445` + `:460`) hôm nay chạy được vì endpoint **không có policy**. Gắn policy vào ⇒ **role `Drafter` (người soạn phiếu) mất quyền xoá nháp của chính mình**.
2. **Tính năng mới chết ngay:** approver trong ảnh UAT thuộc Phòng Cung ứng ⇒ role `Procurement`, `CanDelete=0` ⇒ **403** dù đúng lượt và đúng cờ F6.

Test 25 chỉ kiểm chiều **403** nên nó sẽ **xanh trong cả hai kịch bản hỏng trên**. Không có test chiều thuận (đủ quyền ⇒ 204).

**Đề xuất sửa.** (a) Thêm mục checklist: cấp `CanDelete` cho tập role đích + ghi rõ đường cấp (tick tay `/system/permissions` trên prod **hay** seeder có upgrade-path như khối `:2120-2126`); (b) chốt với owner: quyền xoá-nháp (Drafter) và quyền xoá-khi-đang-duyệt (approver) **dùng chung 1 cờ hay tách**; (c) thêm test happy-path 204 cạnh test 25.

---

### H2 — `AllowApproverDelete` **per-ROW hay per-CẤP**: spec chốt "per-Cấp" nhưng giấu hệ quả, và mã có sẵn **cả hai** cách đọc

**Điều sai (2 lớp).**

*Lớp 1 — mơ hồ thi hành.* Nhà của cờ là `ApprovalWorkflowLevel`, mà **1 row = 1 NGƯỜI** (`ApprovalWorkflow.cs:83-88`, `ApproverUserId` là field của row). "Cấp" chỉ là **tập row cùng `Order`**. Vậy `AllowApproverDelete` của "Cấp" phải đọc bằng cách nào? Repo đang dùng **cả hai** cho cờ anh em F5:
- per-ROW: `PurchaseEvaluationWorkflowService.cs:859` — `if (matchingLevel.AllowApproverFinalize && applyLevelFinalize)`
- per-CẤP: `PurchaseEvaluationFeatures.cs:1182` — `g.Any(l => l.AllowApproverFinalize)`

⇒ hai người cài đặt sẽ ra **hai kết quả khác nhau** từ đúng câu chữ §B2 dòng 60 (*"Cấp đó có `AllowApproverDelete == true`"*).

*Lớp 2 — hệ quả chưa khai.* Đọc theo per-Cấp (`Any`) ⇒ **mọi người trong Cấp đều xoá được**. Ảnh prod (run.md dòng 74) ghi rõ Cấp 2 Phòng Cung ứng có **3 người** (Bùi Lê Thủy Trà, Trần Xuân Lưu, Lê Trần Đăng Trường). Owner chốt (1) là *"Người đứng đầu phòng đc xóa khi đến lượt họ"* = **1 người**. Spec §2 có khai "đổi nghĩa" nhưng chỉ khai vế *"thành cấp nào admin tick"* — **không** khai vế *"và như vậy N người cùng Cấp đều xoá được"*. Đó là hệ quả trực tiếp lên quyền huỷ chứng-từ tài chính, phải nằm trên bàn.

**Đề xuất sửa.** Chốt bằng chữ trong spec + checklist: dùng `matchingLevel.AllowApproverDelete` (per-row, 1 người — sát ý owner) hay `pendingLevelGroup.Any(...)` (per-Cấp, N người). Nếu chọn per-Cấp thì thêm 1 dòng khai hệ quả "N người" cho owner duyệt lại.

---

### H3 — Endpoint "Đã xóa" thiếu **2 ràng buộc bắt buộc**; D3 kín về rò-accumulator nhưng **hở về rò-dữ-liệu**

**Điều sai.** Checklist 8 chỉ ghi *"handler RIÊNG list 'Đã xóa' với `IgnoreQueryFilters()` — tuân D3"*. Thiếu hai thứ:

1. **Thiếu `.Where(x => x.IsDeleted)`.** `IgnoreQueryFilters()` gỡ **toàn bộ** filter chứ không đảo nó ⇒ query trả **cả phiếu sống lẫn phiếu xoá**. Màn "Đã xóa" sẽ liệt kê mọi phiếu trong hệ thống. Không mục nào trong spec nói câu này; test 22 chỉ soi accumulator nên **không bắt được**.
2. **Thiếu tái lập IDOR-scope.** Handler list hiện hành có rào chặt: `PurchaseEvaluationFeatures.cs:596-617` — non-admin chỉ thấy phiếu khi (i) mình là Drafter, (ii) phase ∈ `eligiblePhases` theo role, hoặc (iii) mình là approver trong workflow đã pin. Handler MỚI dựng từ đầu **mặc định không có gì**. Kết quả: mọi user đăng nhập đọc được **toàn bộ phiếu đã xoá của mọi dự án**, kèm `BudgetPeriodAmount` + tên gói thầu + NCC (đúng bộ field của `PurchaseEvaluationListItemDto`).

Đây là 2 "đường rò thứ 4/thứ 5" mà 3 điều cấm của D3 **không phủ** — D3 chỉ chống rò **sang accumulator**, không chống rò **ra người dùng**.

**Đề xuất sửa.** Viết thẳng vào checklist 8: `IgnoreQueryFilters().Where(x => x.IsDeleted)` **∧** sao chép nguyên khối IDOR `:596-617` (hoặc siết hơn: chỉ Drafter của phiếu + approver của workflow pin + Admin). Thêm 2 test: (a) list "Đã xóa" **không chứa** phiếu sống; (b) user ngoài phạm vi **không thấy** phiếu đã xoá của người khác.

---

### H4 — Lệnh Update quy trình (F-1…F-11) **không khai policy authz**, trong khi chính nó là **cổng cấp quyền**

**Điều sai.** F-1 chỉ nói *"`UpdateAwDefinitionCommand` mới"*. Không dòng nào nói endpoint mới phải mang policy. Mọi verb GHI hiện có của controller này đều có: `ApprovalWorkflowsV2Controller.cs:29` (POST), `:40` (PATCH user-selectable), `:48` (DELETE) — cả ba đều `[Authorize(Policy = "Workflows.Create")]`; class chỉ `[Authorize]` trần (`:18`).

Vì sao nghiêm trọng hơn một endpoint thường: hai thao tác mà F-1 **cho phép** đều là hành vi **cấp quyền**:
- op #1 "thêm người vào Cấp có sẵn" ⇒ tự thêm mình vào `pendingLevelGroup` ⇒ lọt guard `PurchaseEvaluationWorkflowService.cs:702-708` ⇒ **duyệt được phiếu người khác**;
- op #2 "bật/tắt cờ `Allow*`" ⇒ **gồm chính F6** ⇒ tự cấp quyền **xoá** phiếu đang duyệt.

Thiếu 1 dòng attribute = leo thang đặc quyền cho mọi tài khoản đăng nhập.

**Đề xuất sửa.** Ghi rõ trong F-1: `[Authorize(Policy = "Workflows.Create")]` (hoặc key riêng nếu owner muốn tách), + 1 test 403 cho user thường, + cân nhắc ghi vết thay-đổi-quy-trình (spec đòi changelog cho xoá phiếu ở B4 nhưng **không** đòi vết cho việc sửa quyền — bất đối xứng).

---

## 🟠 MED

### M1 — Quy tắc F.2 **không đủ chặt**: 3 phản ví dụ giữ nguyên cả 2 tập Order mà **vẫn hỏng phiếu đang chạy**

Phát biểu đang xét: *"AN TOÀN ⟺ KHÔNG đổi tập `{Step.Order}` và tập `{Level.Order}`"* (§F.2 dòng 208).

**(a) Cờ `AllowApproverFinalize` (F5) bật/tắt giữa chừng — đổi ĐIỂM KẾT THÚC của phiếu đang chờ.**
`PurchaseEvaluationWorkflowService.cs:859-878`: khi `matchingLevel.AllowApproverFinalize` bật, người duyệt tại cấp đó **kết thúc luôn** (`Phase = DaDuyet`, `:866`, kèm `ApplyBudgetSnapshotOnFinalizeAsync` `:865` = **đóng băng số tiền**). Bật cờ này cho một Cấp mà phiếu **chưa đi tới** ⇒ phiếu đang chờ nay **không lên Ban Giám đốc nữa**. Tắt ⇒ ngược lại.
Spec xếp **mọi** cờ `Allow*` vào ✅ AN TOÀN (#2) *"thuộc tính row, 0 đụng Order"*, trong khi xếp `CeoApprovalThreshold` vào ❓HỎI OWNER (#12) với lý do *"đổi nghĩa quyết định đang chờ"*. **Cùng một lý do, hai kết luận.** F5 còn nặng hơn threshold: threshold vẫn cần CCM chủ động tích (`:881-901`), F5 thì đọc thẳng ở `:859`.

**(b) `matchingLevel` fallback `pendingLevelGroup.First()` — thao tác #1 "thêm người" chạm đúng chỗ này.**
`:733-734`: `var matchingLevel = pendingLevelGroup.FirstOrDefault(l => … l.ApproverUserId == actorUserId) ?? pendingLevelGroup.First();`. Nhánh `.First()` chạy khi **Admin duyệt thay**. Thứ tự bên trong nhóm do `OrderBy(l => l.Order)` (`:689`) quyết — mà các row **cùng `Order`** thì **không có tie-breaker** ⇒ thứ tự do DB trả về. Thêm/bớt row cùng Cấp có thể đổi "row đầu tiên" ⇒ (i) chữ ký admin gắn sang **Level row của người khác**, (ii) UPSERT `LevelOpinion` khoá theo `(PE, ApprovalWorkflowLevelId)` (`:736-738`) ghi vào row khác. Spec có nhắc `First()` ở nguồn (sub-invest-wfver-4 F9) nhưng **không** đưa vào bảng 13 thao tác — đúng chỗ trục 3 nghi ngờ.

**(c) Thêm người vào Cấp **ĐÃ QUA** ⇒ lịch sử duyệt hiển thị SAI.**
`ComputeLevelStatus` (`PurchaseEvaluationFeatures.cs:1135-1148`) thuần con-trỏ: `stepIdx0 < currentIdx → "Done"`. Row mới thêm vào một Cấp đã qua sẽ render **"Done"** dù người đó **chưa từng ký** và **không có** `LevelOpinion` nào. Ở phiếu `DaDuyet` còn tuyệt đối hơn: `:1137` `if (isTerminalDone) return "Done"`. Bảng 13 thao tác chỉ soi đường duyệt (`:689-702`), không soi 4 read-site hiển thị cho ca **thêm**.

**Đề xuất sửa.** Tách #2 thành 2 dòng: cờ **không đổi luồng** (F1 4 mode, F3, F4) = ✅; cờ **đổi luồng/quyền** (F5 `AllowApproverFinalize`, F6 `AllowApproverDelete`) = ⚠️/❓ cùng hạng với `CeoApprovalThreshold`. Thêm 2 dòng vào bảng: "thêm người vào Cấp ĐANG CHỜ khi có khả năng Admin-duyệt-thay" và "thêm người vào Cấp ĐÃ QUA". Bổ sung câu: quy tắc F.2 chỉ bảo đảm **con trỏ không dịch**, KHÔNG bảo đảm **ngữ nghĩa/hiển thị không đổi**.

### M2 — Bảng 13 thao tác **bỏ sót 2 trường** admin sửa được mà vẫn giữ nguyên 2 tập Order

`ApprovalWorkflow.Code` (`ApprovalWorkflow.cs:23`) và `ApplicableType` (`:25`).
- Đổi `Code` ⇒ vỡ chuỗi version vì `nextVersion = MAX(Version) WHERE Code = request.Code` (`ApprovalWorkflowV2AdminFeatures.cs:325-328`) — bản kế tiếp có thể tụt về v1 hoặc đụng version cũ.
- Đổi `ApplicableType` ⇒ (i) phiếu đang pin bỗng chạy workflow của **module khác**; kiểm tra khớp loại **chỉ xảy ra lúc pin** (`PurchaseEvaluationFeatures.cs:258-265`), không có re-validate; (ii) vỡ bất-biến "chỉ 1 active mỗi type" vì khối hạ-active lọc theo `ApplicableType` (`:331-334`).

Cả hai **giữ nguyên** `{Step.Order}` và `{Level.Order}` ⇒ theo chữ của F.2 chúng là "AN TOÀN". Nếu F-1 là allow-list đóng (chỉ #1#2#3) thì thực thi vẫn an toàn, nhưng **bảng phân loại là tài liệu người sau dùng để mở rộng** ⇒ phải khai tường minh 2 trường này thuộc NGOÀI-PHẠM-VI/PHÁ-VỠ.

### M3 — Nợ N1 **không trung tính** lượt này: nó ăn thẳng vào F-5 của chính spec

Xoá mềm ⇒ cascade không chạy (`AuditingInterceptor.cs:56-62` đổi `Deleted → Modified`) ⇒ `PurchaseEvaluationLevelOpinions` của phiếu đã xoá **ở lại DB** và **vẫn Restrict** FK về `ApprovalWorkflowLevel` (`PurchaseEvaluationLevelOpinionConfiguration.cs:25-28`).
F-5 lại chốt: *"chặn khi row đã có `LevelOpinion`"*. ⇒ Sau khi tính năng chạy, admin sẽ bị **chặn bỏ người khỏi Cấp vì một phiếu đã bị xoá** — chặn thật, không phải cảnh báo.
Quan trọng: **trước** lượt này rác chỉ sinh từ phiếu Nháp (hiếm có `LevelOpinion`); **sau** lượt này mọi phiếu bị xoá đều ở `ChoDuyet` ⇒ **chắc chắn** đã có `LevelOpinion` + `Approvals` + `Changelogs`. Tức lượt này **làm N1 tệ hơn theo đúng cái trục mà spec vừa dựng luật**.
**Đề xuất sửa.** F-5 phải nói rõ: đếm `LevelOpinion` **loại trừ** opinion thuộc PE đã `IsDeleted` (hoặc khai nhận hành vi chặn-do-phiếu-xoá là chấp nhận được, kèm thông điệp lỗi nói đúng lý do).

### M4 — Test 21 phát biểu **sai số học** ⇒ không thi hành được như viết

Phiếu `ChoDuyet` chỉ nằm trong **2/4** phép cộng:
- ✅ `PrevSubmitted` — `PeBudgetAccumulator.cs:53-59` (`ChoDuyet || DaDuyet`);
- ✅ `PendingSelected` — `:114-117` (loại `{DangSoanThao, DaDuyet, TuChoi}` + có winner);
- ❌ `PrevSelected` — `:61-62` đòi `Phase == DaDuyet`;
- ❌ `PendingSubmitted` — `:107` **loại tường minh** `ChoDuyet`.

Test 21 viết *"cả 4 phép cộng lũy kế **giảm đúng**"*. Với 2 phép sau, delta đúng = **0**. Ai viết assert "giảm" cho cả 4 sẽ có test không thể xanh; ai viết assert "giảm" cho 2 và quên 2 kia sẽ mất chốt chặn hồi quy.
Ngoài ra `ComputePendingAsync` còn output thứ **5**: `PriorPes` (`:128-133`, gồm cả phiếu Nháp) cũng đổi sau khi xoá — spec không nhắc.
**Đề xuất sửa.** Viết lại test 21 thành 4 assert có hướng: `PrevSubmitted ↓`, `PendingSelected ↓`, `PrevSelected` **không đổi**, `PendingSubmitted` **không đổi**, + `PriorPes` mất đúng 1 phần tử.

### M5 — §E1 **nói mạnh hơn bằng chứng**, và checklist 14 ⟂ 15 mô tả **hai nút khác nhau**

- Sự thật đo được: `PeWorkflowPanel` nhận **đúng 3 prop** `{evaluation, readOnly, onApproved}` — `fe-user/src/components/pe/PeWorkflowPanel.tsx:31-43`. **Không có** `onDelete`, không có mutation xoá trong file.
- `onDelete` được truyền cho **`PeDetailTabs`**, không phải cho panel: `PurchaseEvaluationsListPage.tsx:570-575` và `:665-670` (đúng số dòng spec dẫn, **sai component**).
- §E2 lại đặt nút mới vào `PeWorkflowPanel.tsx:458-506`.

⇒ Câu *"Dây đã nối sẵn"* (§E1) **không đúng cho chỗ đặt nút**. `sub-invest-fe-2` đã khai đúng ở PH-14 mục 4 (*"phải nâng mutation `del` lên rồi truyền prop mới cho `PeWorkflowPanel`, hoặc tạo mutation riêng"*) — spec **bỏ mất caveat** này.
Kèm theo: checklist **14** đặt nút trong panel; checklist **15** bảo *"bỏ chặn `readOnly` cho riêng nút Xóa"* — nhưng nút bị chặn đó nằm trong `PeDetailTabs` với gate **3 điều kiện** `mode === 'workspace' && canEditPhase && !readOnly` (`PeDetailTabs.tsx:445`) **cộng** `phase === DangSoanThao` (`:460`); màn Duyệt truyền `mode` mặc định `'detail'` và `canEditPhase=false` ở `ChoDuyet`. ⇒ "bỏ chặn readOnly" **không đủ**, và hai mục sẽ đẻ ra **2 nút Xoá** hoặc **1 nút không nối dây**.
**Đề xuất sửa.** Chốt 1 chỗ đặt nút duy nhất; ghi rõ prop/mutation phải thêm; xoá hoặc viết lại mục 15 cho khớp mục 14.

### M6 — Checklist 27 bảo sửa row **`Policies` + `Menu keys`** trong STATUS.md — **hai số đó KHÔNG được đổi**

`docs/STATUS.md:450` ghi rõ `Policies = |MenuKeys.All| × |MenuKeys.Actions| = 54 × 4 = 216` (**DERIVED**, `governance-detectors.ps1` resolve canonical từ đúng row này) và `:451` `Menu keys = 54` (đếm `MenuKeys.All`).
Key mới "Đã duyệt"/"Đã xóa" là `Pe_*` sinh qua **factory** (`MenuKeys.cs:134-141`), **không** nằm trong `All` (`:147-165` — đã đọc toàn bộ, `Pe_*` vắng mặt; đây cũng chính là điều spec dùng để loại `[Authorize(Policy="Pe_….Delete")]`).
⇒ Cả 2 số **giữ nguyên 54 / 216**. Làm theo mục 27 = ghi số sai vào chỗ canonical đang bị detector soi.
**Đề xuất sửa.** Mục 27 chỉ giữ "Mig mới"; nếu muốn ghi số mục menu thì ghi ở chỗ khác và nói rõ đó là số row `MenuItems`, không phải `MenuKeys.All`.

### M7 — Thứ tự thi công: văn xuôi và checklist **đá nhau**

§F.0 chốt *"A-E KHÔNG CHẠY nếu thiếu F"*, nhưng §4 CHECKLIST xếp A-E (mục 1→28) **trước**, còn F (F-1→F-16) nằm ở PHẦN II **cuối file**, không có mốc thứ tự nào. Người làm top-down sẽ dựng đúng thứ tự mà spec vừa cảnh báo là hỏng. Migration F6 (mục 2) đứng trước lệnh Update (F-1) cũng đúng, nhưng **phải nói ra**.
**Đề xuất sửa.** 1 dòng thứ-tự cứng đầu §4: `mục 1-2 (schema F6) → F-1…F-4 (lệnh Update + validator) → 3-8 → 9-13 → 14-20 → test`.

### M8 — Mục 3 và F-11 là **cùng một việc, hai độ sâu** ⇒ mục 3 sẽ rơi đúng bẫy F-11 cảnh báo

Mục 3: *"DTO + Designer admin: tick F6 per-Cấp (mirror chỗ tick F5)"*. F-11: liệt **5 dây FE** `:54 / :110 / :159 / :177 / :605` + cảnh báo 🔴 *"thiếu `:605` = tick xong KHÔNG gửi lên BE"* + `fe-user:282` + **4 neo BE** `:41 / :230 / :188 / :368`.
Ai làm theo mục 3 mà không lật xuống PHẦN II sẽ sót đúng cái `:605`. (Neo BE của F-11 kiểm chứng được: `ApprovalWorkflowV2AdminFeatures.cs:230` `AllowApproverFinalize` trong `CreateAwLevelInput`, `:368` map vào entity — khuôn đúng.)
**Đề xuất sửa.** Gộp mục 3 vào F-11 hoặc trỏ chéo tường minh.

---

## 🟡 LOW

### L1 — Tiêu đề F.0 mạnh hơn thân bài
*"A-E KHÔNG CHẠY nếu thiếu F"* → chính xác phải là *"A-E không **tới được phiếu đang treo**"*. Phiếu **tạo mới sau** khi kích hoạt v(n+1) vẫn có cờ và vẫn xoá được. Thân bài (dòng 195-196) nói đúng điều này; chỉ tiêu đề quá rộng. Giữ nguyên kết luận thứ-tự-thi-công, chỉ sửa chữ để người sau không suy rộng sai.

### L2 — §1 bỏ mất dữ kiện đối trọng cho vế "quay lại không được"
Vế lũy kế của spec **đúng** (đã kiểm, xem trục 2). Nhưng ở `TraLai`, người soạn **được đổi hạng mục**: `UpdatePurchaseEvaluationDraftCommand.WorkItemId` (`PurchaseEvaluationFeatures.cs:221`) + write `:301-302`, guard cho phép `DangSoanThao ∥ TraLai` (`:248-250`). Tức "bấm sai gói thầu" **có** đường tự sửa (dời lũy kế sang gói đúng), chỉ là vẫn ăn số trong lúc chờ sửa. Không đổi quyết định owner, nhưng nên khai để §1 không bị đọc thành "không còn cách nào khác".

### L3 — 2 trích dẫn lệch biên 1 dòng (nội dung đúng)
- `DbInitializer.cs:1893-1903`: nhánh `if (existingItems.TryGetValue…)` bắt đầu đúng `:1893`, nhưng `continue;` nằm ở **`:1904`** (đóng nhánh `:1905`). Nội dung "chỉ update Order rồi continue, KHÔNG đụng Label" **đúng 100%** (comment `:1897-1898` tự khai).
- `:1917-1923`: `var labelBackfill = …` khai ở **`:1918`**, 4 entry `:1920-1923`, đóng `:1924`. Dòng `:1917` là comment. Vô hại nhưng nên chỉnh cho khớp.

### L4 — F-12 bị dán nhãn "bài kiểm chứng F.0" nhưng chỉ chứng **chiều thuận**
F-12 chứng "update tại chỗ ăn ngay vào phiếu đang chạy". Mệnh đề F.0 còn nửa kia: "POST version mới **không** tới phiếu đang treo". Không có test cho nửa đó, mà nó rẻ: tick F6 bằng POST ⇒ phiếu treo **vẫn** bị từ chối xoá. Thêm 1 test này thì F.0 mới có răng.

### L5 — N4 ("`TuChoi` là nhánh chết") đúng về transition nhưng đừng dọn vội
Guard `PurchaseEvaluationWorkflowService.cs:101-106` chặn **đường đi tới** `TuChoi`, nhưng chính comment `:99` ghi *"Phase TuChoi + phiếu TuChoi cũ GIỮ display/filter"* ⇒ **phiếu `TuChoi` cũ vẫn tồn tại**, và nhánh allow-list xoá vẫn phục vụ chúng. Dọn nhánh = mất quyền xoá phiếu lịch sử. Nên ghi rõ trong N4.

---

## ✅ ĐIỂM SPEC LÀM ĐÚNG — đừng sửa hỏng

1. **Trích dẫn chịu lực: 16/16 mẫu ĐÚNG DÒNG + ĐÚNG NỘI DUNG.** Gồm `PeBudgetAccumulator.cs:53-59` · `:104-126` · `:114-126` · `PurchaseEvaluationFeatures.cs:1404-1406` · `:149` · `:249-251` · `:291` · `ApprovalWorkflowV2AdminFeatures.cs:325-328` · `:331-334` · `:336` · `PurchaseEvaluationWorkflowService.cs:674` · `:683` · `:686` · `:694-695` · `:705` · `:101-106` · `PurchaseEvaluationsController.cs:146-151` · `MenuKeys.cs:147-165` · `Program.cs:82-89` (khớp **chính xác** khối `foreach menu → foreach action → AddPolicy`) · `DbInitializer.cs:2092-2096` + `:2492-2496` (**cả hai đúng từng dòng** — 5 dòng `Add(...)` mỗi bên) · `PurchaseEvaluationConfiguration.cs:84` · `ApprovalWorkflow.cs:81-82`.
2. **E3 md5 kiểm lại bằng lệnh thật:** `md5sum` cho **cùng** `02671be6334110028c55fe82f5e70dee` ở cả `fe-user` và `fe-admin`, **942 dòng** — đúng y spec. Luật "mirror 2 app thay cho 4-place" là luật đúng cho việc này.
3. **B3 chọn policy root thay `Pe_*` là ĐÚNG** và lý do đúng: `Pe_*` thật sự vắng khỏi `MenuKeys.All` (đọc trọn `:147-165`), mà `Program.cs:82-89` chỉ đăng ký policy cho phần tử của `All`.
4. **Bẫy `labelBackfill` là THẬT và quan trọng:** vòng upsert gặp key cũ chỉ sửa `Order` rồi `continue`, comment `:1897-1898` tự khai *"KHÔNG đụng Label / ParentKey / Icon"*. Sửa `tree.Add` mà không thêm `labelBackfill` sẽ **không đổi được nhãn** trên DB đã seed. Giữ nguyên cảnh báo 🔴 ở mục 11.
5. **D3 (3 điều cấm + "handler riêng, KHÔNG thêm cờ `includeDeleted`") là đúng và đáng giữ nguyên chữ** — đã kiểm: `IgnoreQueryFilters()` là toán tử trên một `IQueryable`, 2 hàm accumulator đều dựng `peers` mới (`:49-51`, `:100-102`), nên không rò chéo. (Lỗ của D3 nằm ở chỗ khác — xem H3.)
6. **F-3 (CẤM delete-then-insert, diff theo `Level.Id`) và F-9 (usage-check trước khi xoá version)** là hai điều đúng và bắt buộc: FK `Restrict` ở `PurchaseEvaluationLevelOpinionConfiguration.cs:25-28` + `PurchaseEvaluationConfiguration.cs:67-73`, và handler xoá hiện tại (`ApprovalWorkflowV2AdminFeatures.cs:406-418`) **thật sự** `Remove()` thẳng, 0 usage-check, đúng như nợ tự khai `:400-402`.
7. **Khảo cổ S60 (`6db195d`) và mắt xích "guard tự trỏ sang Xóa phiếu"** — kiểm lại trên đĩa: `PurchaseEvaluationWorkflowService.cs:101-106` đúng nguyên văn. Đây là phần chẩn-đoán mạnh nhất của spec.

---

## 🎯 THÁCH-CLEAN: đã thử phá bằng cách nào

### Trục 2 — mệnh đề F.0: **KHÔNG bác được** (6 đường tấn công, đều tắc)

| # | Đường thử bác | Kết quả |
|---|---|---|
| 1 | Có write-site nào khác ghi `PE.ApprovalWorkflowId` (job/seeder/admin action)? | `grep -rn "ApprovalWorkflowId\s*=" --include=*.cs src/Backend \| grep -v Migrations` → PE **đúng 2** write-site: `:149` (create) + `:291` (update). Các module Office (`LeaveOtApprovalFeatures.cs:238/:509/:537/:702`, `ProposalFeatures.cs:277/:339`, `TravelVehicleApprovalFeatures.cs:189/:549/:774/:802`) **có** re-pin — nhưng **không phải PE**. Mắt xích 4 đứng. |
| 2 | Có migration/`Sql()` nào backfill lại con trỏ workflow? | `grep -rniE "ApprovalWorkflowId" Migrations/*.cs \| grep -iE "UPDATE\|Sql\("` → **0 hit**. `grep "Sql(" Migrations/*.cs \| grep -i purchaseeval` → **0 hit**. Đứng. |
| 3 | Có chỗ nào phiếu resolve workflow theo `Code`+`IsActive` thay vì Id? | `ApproveV2Async` `:671-675` chỉ `w.Id == awId`. Inbox `:832-836` lấy `wfIds` từ chính phiếu. Badge list `:672-685` join `lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId`. Flow-tree dùng workflow đã pin. **Không có** đường `Code`/`IsActive`. Đứng. |
| 4 | Admin xoá bản cũ để ép phiếu rơi sang bản mới? | FK `Restrict` (`PurchaseEvaluationConfiguration.cs:67-73`) ⇒ nổ FK, không "rơi sang". Đứng. |
| 5 | Drafter sửa phiếu lúc `ChoDuyet` để re-pin? | Chặn `:248-250`. Đứng. |
| 6 | Trả lại → sửa → gửi lại có re-pin không? | **CÓ** (`:248-250` cho `TraLai`, write `:291`) — nhưng spec đã tự khai ở dòng 196 và đúng là **không giải được ca gốc** trong 1 bước. Chỉ làm yếu **tiêu đề**, không làm yếu kết luận (⇒ L1). |

⇒ **Kết luận F.0 ĐỨNG.** Thứ tự thi công "F trước" là đúng.

### Trục 1 — vì sao không cho FAIL: đã kiểm **từng** trích dẫn chịu lực bằng cách mở file, không đọc lại spec. Cách phá đã dùng: (a) so từng số dòng với `sed -n`/`Read` có đánh số; (b) đếm ngược thủ công 2 khối `DbInitializer` (2075→2096 và 2470→2496) để chắc 2 danh sách permission song song đúng dòng; (c) tính lại md5 thay vì tin con số spec chép. Lỗi duy nhất tìm được là **diễn giải** (M5), không phải số dòng.

### Trục 5 — an toàn/số liệu: đã thử phá bằng 4 câu hỏi
(i) "3 điều cấm D3 có đủ không" → **KHÔNG** (H3, 2 đường rò khác trục);
(ii) "nới allow-list rồi ai vẫn xoá được" → đo bảng `Permissions` thật (H1);
(iii) "test có răng không" → test 21 sai số học (M4), test 25 xanh-giả (H1), test 22 **có** răng cho đúng thứ nó tuyên bố;
(iv) "hoãn N1 có trung tính không" → **KHÔNG** (M3, đụng thẳng F-5).

### Trục 6 — trung thực: 3 chỗ nói mạnh hơn bằng chứng đã tìm được là M5 (dây FE), L1 (tiêu đề F.0), H2-lớp-2 (hệ quả "N người cùng Cấp"). Ngoài ra spec **giữ đúng** nhãn nguồn: những chỗ sub gắn `[SUY LUẬN]` (vd F10 "chỉ cần nới guard là số tự đúng") được spec trình bày kèm cơ chế query filter chứ không đội lốt `[CODE]`.

---

## 📌 Việc phải làm trước khi giao hmw (rút gọn)

1. **Owner trả lời 1 câu:** F6 đọc per-ROW (1 người) hay per-CẤP (N người)? → H2.
2. **Chốt đường cấp quyền `PurchaseEvaluations.Delete`** cho tập role đích + xác nhận không cắt quyền xoá-nháp của Drafter → H1.
3. **Thêm 2 ràng buộc vào checklist 8** (`.Where(IsDeleted)` + IDOR-scope) → H3.
4. **Thêm policy vào F-1** → H4.
5. Sửa bảng F.2 (M1/M2), sửa test 21 (M4), gỡ mâu thuẫn 14/15 (M5), bỏ "Policies/Menu keys" khỏi mục 27 (M6), thêm dòng thứ tự (M7).

<!-- END reviewer-spec-review · TOTAL=17 FLAG -->
