**HEADLINE:** PASS_WITH_CONCERNS — 3 lane mô tả code KHỚP source thật (mọi file:line verify đúng); thiết kế "gỡ/gate bypass, giữ 1 nút save=advance" KHÔNG vỡ 5-state; NHƯNG xác nhận độc lập EDGE-5 (mở lại phiếu DaDuyet qua forge Reject+TraLai) là lỗ THẬT reachable ở handler + self-finalize thiếu guard.


## Phạm vi verify độc lập
Đã đọc source thật (không tin mù lane): `PurchaseEvaluationWorkflowService.cs` (1207 dòng full), `PurchaseEvaluationFeatures.cs` (create + transition handler), `ApprovalWorkflow.cs` (entity V2), `PurchaseEvaluationLevelOpinion.cs`, `PurchaseEvaluationPhase.cs`, `PurchaseEvaluationsController.cs`, 2× `PeWorkflowPanel.tsx`, grep toàn backend `.Remove/RemoveRange`, glob test files. **Không có deploy claim → không chạy curl (đúng: đây là run điều tra + thiết kế, chưa có code).**

---

## (1) Luồng hiện tại 3 lane mô tả có KHỚP code thật?

**KẾT LUẬN: KHỚP 100% ở mọi neo file:line quan trọng.** Bảng đối chiếu:

| Claim (lane) | Verify | Source thật |
|---|---|---|
| ApproveV2Async = UPSERT opinion + advance CÙNG 1 call | ✅ | UPSERT `:763-790`, advance `:927-957`, cùng method, `SaveChangesAsync` ở caller `:285` |
| Gate `:728-739` là gate THUẬN (không chặn creator) | ✅ | `:728-739` chỉ check `actor.Id ∈ pendingLevelGroup` — creator LÀ approver Cấp-1 → pass |
| `ApplyDrafterBypassOnSubmitAsync` tự ký ô creator + comment CANNED + advance qua | ✅ | `:571-681`; canned `"(duyệt tự động…)"` `:619`; advance `:647-680`; gọi tại `:260-261` |
| DrafterUserId = creator, set lúc create | ✅ | `FEAT:147` `DrafterUserId = currentUser.UserId`; Service dùng DrafterUserId (KHÔNG CreatedBy) mọi identity-logic |
| Gỡ bypass → luồng mong-muốn chạy với ZERO đổi ApproveV2Async | ✅ | Pointer giữ `(0,1)` → creator ∈ pendingLevelGroup → UPSERT comment THẬT + advance `:927`. Cơ chế đã có sẵn |
| Opinion KHÔNG BAO GIỜ bị xoá (chỉ UPSERT overwrite) | ✅ | grep `.Remove/RemoveRange` toàn backend = **0 match trên LevelOpinions** (có trên 20+ bảng khác, KHÔNG có LevelOpinions) |
| 4 mode Trả lại, default Drafter, Allow* per-slot | ✅ | `effectiveMode = returnMode ?? Drafter` `:117`; validate flag `:397-410`; entity `AllowReturnToDrafter` default `true` `:113`, 3 mode kia default `false` |
| FE 2 app byte-identical + Trả lại/Từ chối ẩn cho creator, Duyệt KHÔNG ẩn | ✅ | `git diff --no-index` exit 0, cả 2 = 903 dòng; `PeWorkflowPanel.tsx:446` `if((isSendBack\|\|isCancel) && drafterUserId===currentUser.id) return null` |
| currentApproval phản ánh pointer HIỆN TẠI → creator thấy nút Duyệt enabled | ✅ | `FEAT:1161-1182` build approvers từ `steps[idxCur].Levels.Where(Order==lvlCur)`; FE `actorInV2Level` `:99-100`, `isDisabled=blockedByV2Level` `:449` |

**2 điểm lane hơi lệch (đều MINOR, không sai bản chất):**
- **Entity comment stale:** `ApprovalWorkflowLevel` doc `:81-82` ghi *"Approver = ApproverUserId chính xác (KHÔNG OR-of-many)"* — nhưng Service `:718-732` THỰC THI OR-of-N (group Levels by Order). Các lane mô tả OR-of-N (đúng behavior thật); comment entity là tài-liệu cũ chưa cập-nhật. Không phải bug, chỉ doc-drift.
- **Lane B gộp `skipToFinal` chung rủi ro với `AllowApproverFinalize` (B2):** verify `:828-845` — `skipToFinal` GIỮ `Phase=ChoDuyet`, chỉ đẩy pointer tới Cấp cuối; **CEO/NV cuối vẫn phải ký thật** để lên DaDuyet. Chỉ `AllowApproverFinalize` `:867-886` mới set `Phase=DaDuyet` TRỰC TIẾP. → guard B2 cho `AllowApproverFinalize` là hợp lệ; áp cho `skipToFinal` gần như thừa (không phải lỗ self-finalize-terminal). Implementer đừng over-engineer nhánh skipToFinal.

---

## (2) Thiết kế thay đổi có ĐÚNG state-machine (không vỡ 5-state, không kẹt phiếu)?

**KẾT LUẬN: SOUND — không vỡ 5 trạng thái, không sinh class kẹt phiếu mới.**

5-state: `DangSoanThao(1) · ChoDuyet(10) · DaDuyet(7) · TraLai(98) · TuChoi(99)` (verify `PurchaseEvaluationPhase.cs:15-27`).

- Thay đổi CHỈ chạm nhánh SUBMIT (`:260-261` gọi bypass). Gỡ/gate bypass → sau submit pointer ĐỨNG tại Cấp creator thay vì advance qua. Transition `DangSoanThao/TraLai → ChoDuyet` giữ nguyên; **KHÔNG thêm state, KHÔNG bỏ state**. Nhánh approve/reject downstream không đụng.
- **Không kẹt phiếu:** nếu creator không tự bấm Duyệt → phiếu nằm ChoDuyet tại Cấp họ (giống mọi approver chưa thao tác — SLA `:35` xử lý; creator luôn thấy ở "Phiếu của tôi"). KHÔNG phải class-kẹt mới.
- **A1 vs A2 — cảnh báo về tính nhất-quán A1 (nâng lên openQuestions):** A1 ("chỉ bypass `order < drafterSlots.Min`") CHỈ nhất-quán khi creator chiếm các Cấp LIÊN-TỤC từ dưới. Nếu creator chiếm Cấp **không liên-tục** (vd Cấp 1 + Cấp 3, Cấp 2 là NV khác): A1 bypass `order<min(=1)` = KHÔNG bypass gì → NV Cấp-2 (đáng ra là "cấp dưới" theo tinh-thần S60) phải ký lại → **mất một phần tính-năng skip-NV S60 mà A1 tự nhận giữ**. A2 (xóa hẳn bypass) đơn-giản, không mâu-thuẫn nội-tại, nhưng bỏ hẳn skip-NV. Đây là quyết-định owner, KHÔNG phải bug — nhưng Implementer phải biết A1 murky ở case đa-Cấp-rời-rạc.

---

## (3) RISK — verify từng rủi-ro độc lập

### 3a. Self-approval (creator duyệt Cấp-1) — CÓ control-issue THẬT không?
**Bình thường: KHÔNG.** Cấp-1 của creator = chữ-ký ĐỀ-XUẤT; thẩm-quyền terminal ở cấp trên (CEO/BGĐ Bước cuối). Creator không tự đẩy DaDuyet trong quy-trình nhiều-bước → phân-tách nhiệm-vụ giữ nguyên. Chuyển từ auto-bypass-im-lặng sang ký-tay-tường-minh là **nâng-cấp** minh-bạch (SignedByUserId=creator, comment thật thay canned).

**THÀNH lỗ CHỈ KHI** slot Cấp-1 creator bật `AllowApproverFinalize=true` (`:867`) → creator một-mình set `Phase=DaDuyet`, bỏ CEO. **Hiện KHÔNG có guard** `actor==DrafterUserId`. → Lane B **B2 là enhancement hợp lệ** (severity MAJOR có điều-kiện — phụ thuộc config admin, xác-suất thấp). **Lưu ý quan trọng:** khả-năng creator-self-finalize ĐÃ TỒN TẠI hôm nay qua bypass single-step-terminal (`:667-680`: quy-trình 1 bước + creator là cấp cuối → auto DaDuyet lúc submit) → B2 KHÔNG phải fix regression, mà là bịt lỗ cũ + lỗ mới cùng lúc.

### 3b. Reject-return có tạo LOOP vô-hạn?
**CÓ — nhưng severity LOW.** Không có cap số lần Drafter resubmit ↔ reject (verify: không counter/cap trong `:92-146` hay handler). Mỗi vòng thêm Approval + Changelog row (audit phình) nhưng opinion UPSERT (không nổ row). Không data-corruption. Lane C EDGE-4 chính-xác — thiếu guard escalation, không phải bug chặn merge.

### 3c. Multi-cấp OR-of-N khi reject xử đúng?
**ĐÚNG.** Advance khi BẤT KỲ 1 người trong N ký (`:927` sau gate `:728-739`). Chỉ 1 opinion row/Cấp (key theo `ApprovalWorkflowLevelId` của người ký thật). **Giới-hạn:** Assignee-mode dropdown chỉ list người ĐÃ ký (`signedApprovers` = distinct levelOpinions) → N−1 đồng-cấp CHƯA ký không pick được làm return-target. Lane C EDGE-1 chính-xác, MINOR — không ảnh-hưởng luồng creator-sign-off đang thiết-kế.

### 3d. Comment cũ có bị MẤT khi trả lại?
**KHÔNG mất — ngược lại, TỒN-ĐỌNG (stale).** Verify grep = 0 lệnh xoá opinion. Khi trả về + forward lại, các Cấp trước-pointer VẪN giữ opinion vòng-cũ cho tới khi được re-approve đè. Không có round/version-stamp phân-biệt "ký vòng này" vs "vòng trước" → người xem Section 5 có thể tưởng Cấp đó đã ký hợp-lệ vòng hiện-tại. Lane C EDGE-2/3 chính-xác — đây là concern UX/audit THẬT (không phải mất data), owner cân-nhắc thêm cột round hoặc clear-forward-of-pointer.

### 3e. 🔴 BONUS — EDGE-5 xác nhận ĐỘC LẬP: mở-lại phiếu DaDuyet (latent hole THẬT, reachable)
Lane C flag EDGE-5 "latent". **Tôi truy tới handler + controller → xác nhận REACHABLE, không chỉ lý-thuyết:**
- Controller `PurchaseEvaluationsController.cs:130-143` endpoint `/transitions` chỉ có `[Authorize]` class-level (`:15`) — **KHÔNG per-action policy** → mọi user đã-đăng-nhập gọi được.
- Handler `TransitionPurchaseEvaluationCommandHandler` `FEAT:510-539` — chỉ check `IsAuthenticated` (`:517`), load entity (`:520`), gọi thẳng service. **KHÔNG guard fromPhase, KHÔNG lock terminal-state.**
- Trace exploit `Decision=Reject(2) + TargetPhase=TraLai(98)` trên phiếu DaDuyet: `:67-75` pass (TraLai+Reject hợp-lệ) → `:84-89` pass (không TuChoi) → `:92` vào reject-branch → `:98` `EnsureCanRejectV2Async` **early-return `:321` vì `Phase!=ChoDuyet`** (guard actor-scope BỊ BỎ QUA cho phiếu non-ChoDuyet) → `:117` mode=Drafter → `ApplyReturnModeAsync:388` pointer=null (DaDuyet đã clear) → `currentLevel=null` → `:397` skip cả validate Allow* → `:418-425` set **`Phase=TraLai`**. Phiếu DaDuyet MỞ LẠI.
- **Attacker hiện-thực nhất = chính Drafter của phiếu DaDuyet của họ** (biết Id phiếu mình, IDOR-filter `:591-596` cho họ thấy phiếu mình). Có thể mở-lại phiếu đã-chốt-giá (ApprovedPrice) → sửa → gửi lại. Phá invariant "DaDuyet = terminal/khoá".
- 3 mode pointer-based (OneLevel/OneStep/Assignee) tự throw Conflict (pointer null `:429-433`) — **chỉ Drafter-mode lọt**. FE không render nút (currentApproval==null) nhưng BE thiếu defense-in-depth.

**Đánh giá:** MAJOR (toàn-vẹn terminal-state), **PRE-EXISTING** (không do thay-đổi này sinh ra) — nhưng vùng reject-return CHÍNH LÀ scope đang sửa → nên bịt luôn (guard `fromPhase==DaDuyet/TuChoi → throw` trong reject-branch, hoặc bỏ early-return `Phase!=ChoDuyet` khỏi EnsureCanRejectV2Async cho case terminal).

---

## (4) "save=advance" vs "tách save-nháp/submit" — khuyến nghị nào AN-TOÀN hơn?

**CONCUR với Lane B: GIỮ 1 NÚT (save=advance) an-toàn hơn cho state-machine + đủ cho yêu-cầu.**

| Trục | 1 nút (save=advance) | 2 nút (nháp ý-kiến + submit) |
|---|---|---|
| State-machine | ✅ opinion-write + advance atomic (1 SaveChanges); mỗi opinion ⟺ 1 sign-off hoàn-tất; 5-state nguyên | ⚠️ sinh NỬA-state "opinion tồn-tại nhưng pointer chưa advance" → mơ-hồ "đã ký?/chưa?", rối reject-target + terminal-detect + `signedApprovers` |
| Chi-phí | 1 method BE (gate bypass), FE ~0 | Schema mới (opinion cần Draft/`SignedAt` nullable) + endpoint + DTO + FE handler |
| Re-comment | ✅ đã có: UPSERT overwrite latest-write-wins (`entity doc:17-19`) | Không lợi thêm |

**Cảnh-báo cross-lane:** khuyến-nghị 1-nút CHỈ đúng NẾU yêu-cầu owner thực-sự là "creator ký ô mình" (điều-tra cho thấy đạt được bằng gỡ/gate bypass). NẾU owner muốn THẬT-SỰ "comment để đó, lát mới đẩy" (draft-opinion rời advance) → mới cần Option 2-nút (schema+endpoint). → **openQuestion.**

---

## Verdict per-lane (đối kháng)
- **Lane A (current-flow):** ✅ PASS — mọi neo đúng, chốt "sửa 1 chỗ đủ" verify chính-xác.
- **Lane B (change-design):** ✅ PASS_WITH_NUANCE — thiết-kế sound; trừ (i) gộp skipToFinal vào B2 hơi quá (skipToFinal không set terminal), (ii) A1 murky ở case creator đa-Cấp-rời-rạc.
- **Lane C (reject-flow):** ✅ PASS — EDGE-1..5 đều verify đúng; EDGE-5 tôi nâng từ "latent" lên "reachable-confirmed ở handler" (bằng-chứng file:line trên).


## Open Questions

**Owner (anh Kiệt/PRO) cần chốt trước khi Implementer code:**

1. **A1 hay A2 (gỡ bypass thế nào):** A2 = xóa hẳn call `:260-261`, creator tự ký MỌI Cấp mình chiếm (đơn-giản, đảo hẳn S60, bỏ skip-NV-cấp-dưới). A1 = chỉ bypass NV dưới creator, creator ký ô mình — NHƯNG A1 mâu-thuẫn nội-tại khi creator chiếm Cấp KHÔNG liên-tục (vd Cấp 1+3): NV Cấp-2 vẫn phải ký, mất một phần skip-NV. Owner còn muốn giữ skip-NV-cấp-dưới S60 không?

2. **Guard B2 self-finalize (severity MAJOR có điều-kiện):** Có chặn creator tự-finalize phiếu mình khi slot Cấp-1 của họ bật `AllowApproverFinalize` không? (khả-năng này ĐÃ tồn-tại hôm nay qua bypass single-step-terminal). Nếu Designer cố-ý cho creator tự-chốt phiếu giá-trị-nhỏ → B2 phải là cảnh-báo-mềm, KHÔNG Forbidden cứng. Lưu ý: `skipToFinal` KHÔNG cần guard này (vẫn cần CEO ký).

3. **EDGE-5 mở-lại phiếu DaDuyet (MAJOR, pre-existing, reachable):** Có thêm guard `fromPhase==DaDuyet/TuChoi → throw` trong reject-branch không? Vùng reject-return đang trong scope sửa → nên bịt luôn defense-in-depth (hiện Drafter có thể forge mở-lại phiếu đã-chốt-giá của chính mình).

4. **1 nút hay 2 nút:** Xác-nhận yêu-cầu CHỈ là "creator ký ô mình" (1 nút save=advance đủ, khuyến-nghị) — hay có nhu-cầu THẬT "lưu comment nháp, để đó, lát mới đẩy" (2 nút, cần schema Draft-opinion + endpoint mới)?

5. **Comment stale khi trả lại (EDGE-2/3, MINOR-UX):** Giữ opinion cũ (hiện tại — có thể gây hiểu-nhầm "đã ký vòng này"), hay CLEAR các Cấp trước-pointer khi trả về, hay thêm cột round/version-stamp? (chạm schema nếu chọn version-stamp).

6. **Data cũ (migrate):** Phiếu đã submit trước đây có Approval `AutoApprove` + LevelOpinion canned do bypass ghi — cần cleanup/migrate hay chỉ áp luật mới cho phiếu mới?

**Ghi-chú kỹ-thuật cho Implementer (không phải quyết-định owner):** (a) test `PeSubmitGuardAndBypassTests.cs` tồn-tại — spec-change BẮT BUỘC update cùng commit (rule §7); (b) sửa FE phải mirror cả 2 app (hiện byte-identical 903 dòng — §3.9); (c) cập-nhật comment stale `ApprovalWorkflowLevel.cs:81-82` ("KHÔNG OR-of-many" sai với behavior OR-of-N thật).

