# sub-review-verdict-1 — reviewer ADVERSARIAL tấn công verdict `LAI`

> Khung ghi trước khi đọc (lượt 1), nội dung append theo từng phép đo.
> Đối tượng công: `sub-invest-bch-1.md` §1 — *"b.13→21 TÁI DÙNG Contract V2 (không dựng mới, không hồi sinh LEGACY enum) · b.7→12 DỰNG MỚI"*.
> Ranh: read-only. Không sửa code-tree, không commit. Mọi số liệu đo lại từ đĩa.

## 0. Meta + phạm vi đo

- Run: `2026-07-27-S156-bch-post-ceo-flow`
- **3 file Read đầy đủ** (đúng trần): `ContractPhase.cs` (29 dòng) · `ContractWorkflowService.cs` (443 dòng, đường thật `src/Backend/SolutionErp.Infrastructure/Services/` — KHÔNG phải `Application/Contracts/` như spec ghi) · `CreateContractFromEvaluationFeatures.cs` (203 dòng).
- **4 lệnh grep xác minh** (không phải Read — dùng để chấm claim của invest trỏ ra ngoài 3 file, đúng mandate "đo, đừng tin"): fallback-policy · `WorkflowPolicyRegistry` consumers · `ChoDuyet` trong `ContractFeatures.cs` · `Authorize` trong `ContractsController.cs`.
- **LENS-VERDICT: LUNG-LAY — 11 điểm** (chi tiết §5-§6). Lõi verdict SỐNG, nhãn "TÁI DÙNG" SAI CHI PHÍ, 1 sub-claim SAI SỰ THẬT trên đĩa.

---

## 1. Q1 — "phase bị gỡ = trạm của b.13→21": ĐỨNG, và đứng vững hơn cả cách invest chứng

**Kết luận: KHÔNG phải trùng hợp tên gọi. Đây là tương ứng CẤU TRÚC — chứng bằng ACTOR, không bằng tên.**

Invest chỉ trích `ContractPhase.cs:3-13` (comment). Chứng mạnh hơn nằm ở bảng vai còn sống trong `ContractFeatures.cs`, invest KHÔNG dùng:

| Legacy phase (đã gỡ) | Vai được map trên đĩa | Trạm sơ đồ | Khớp? |
|---|---|---|---|
| `DangGopY=3` | `ContractFeatures.cs:366` ProjectManager+Procurement+CostControl+Finance+Accounting+Equipment | b.14 BCH bổ sung/góp ý | ✅ |
| `DangDamPhan=4` | `:367` Drafter, DeptManager, ProjectManager | b.15 đàm phán TP | ✅ |
| `DangInKy=5` | `:368` Drafter, DeptManager, ProjectManager | b.16 ký nháy / in ký | ✅ |
| `DangKiemTraCCM=6` | `:369` **CostControl** | b.17 CCM kiểm tra | ✅ |
| `DangTrinhKy=7` | `:370` **Director, AuthorizedSigner** | b.18 CEO ký | ✅ |
| `DangDongDau=8` | `:371` **HrAdmin** | b.19 HR đóng dấu | ✅ |
| `DangChon=1` | (không map trong `PhaseActorRoles`) | TIỀN-13 (chọn NCC = PE) | ❌ ngoài dải |

Chuỗi vai `CostControl → Director/AuthorizedSigner → HrAdmin` (`:369-371`) **trùng khít** chuỗi CCM→CEO→HR của b.17→18→19. Xác suất trùng ngẫu nhiên 3 vai liên tiếp đúng thứ tự ≈ 0 ⇒ hệ thống ĐÃ TỪNG mô hình hoá đúng dải này rồi gỡ. Lập luận invest **ĐỨNG**.

**3 khiếm khuyết về độ chính xác (không lật kết luận):**

1. **Trích dẫn nống (điểm 2).** Invest §0:18-21 viết *"`ContractPhase.cs:3-13` ghi thẳng"* rồi gán cho dải đó cả `CurrentApprovalLevelOrder` + *"workflow V2 admin-config (`ApprovalWorkflows` Steps=Phòng > Levels=Cấp OR-of-N)"*. Đọc thật `ContractPhase.cs:3-13`: chỉ có state-machine 5 trạng thái + câu *"LEGACY values (...) deprecated post-Mig 21 — giữ enum cho data cũ"*. **Không một chữ** nào về V2 / ApprovalWorkflows / Cấp / OR-of-N; `CurrentWorkflowStepIndex` nằm ở `:25` (ngoài dải 3-13), `CurrentApprovalLevelOrder` nằm ở file KHÁC (`ContractWorkflowService.cs:84`), mô tả Steps/Levels ở `:204-216`. File chỉ nói **ĐÃ GỠ**, không nói **GỠ ĐỂ THAY BẰNG GÌ** — phần "vì sao" là suy luận của invest, không phải trích dẫn.
2. **Lệch biên dải (điểm 3).** Mệnh đề *"các trạm của bước 13→21 chính là những phase đã bị gỡ"* chỉ đúng cho **b.14→19**. b.13 chạy trên `DangSoanThao=2` (CÒN SỐNG, `ContractPhase.cs:17`) và b.20-21 chạy trên `DaPhatHanh=9` (CÒN SỐNG, `:24`). Chính §3 của invest map đúng (khúc 2 và khúc 4) — tức §0 phát biểu rộng hơn §3 tự đo. Mâu thuẫn nội bộ nhẹ.
3. **"Đi ngược kiến trúc" = quy ước, KHÔNG phải rào chắn (điểm bổ sung tại §4-5).** Không có gì trong mã CẤM gán lại legacy: `ContractWorkflowService.cs:191-199` nhánh admin override set `contract.Phase = targetPhase` **bất kỳ**, không lọc legacy. Hồi sinh enum là *rẻ mà dở*, không phải *bất khả*. Nếu owner hỏi "sao không dùng lại?", câu trả lời phải là chi phí vận hành (2 mô hình song song), không phải "hệ thống chặn".

---

## 2. Q2 — `ApproveV2Async` gánh được CCM→CEO→HR không?

**Trả lời: gánh được KHUNG (thứ tự trạm), KHÔNG gánh được ĐƯỜNG SỐNG (nhìn thấy / được gọi / rẽ nhánh theo tiền).**

### 2.1 Cái CÓ (invest đúng)

- 3 trạm = 3 Step: `:234` `steps = aw.Steps.OrderBy(Order)`, `:246` group Levels theo Order = Cấp, `:259-260` OR-of-N match `ApproverUserId`.
- Tiến trạm: `:355-361` (cấp kế trong Bước) → `:365-392` (Bước kế, Cấp 1) → `:366-385` terminal `DaPhatHanh` + gen mã.
- Gen mã không đúp cho HĐ sinh từ phiếu: `:371` `if (string.IsNullOrEmpty(contract.MaHopDong))` — HĐ từ phiếu đã có mã tại `CreateContractFromEvaluationFeatures.cs:113` ⇒ giữ nguyên. ✅
- UPSERT ý kiến cấp duyệt `:292-316` + `SignedByUserId` `:306`/`:314`. ✅

### 2.2 Cái THIẾU — 5 cơ chế, trong đó 3 cái invest KHÔNG nêu

| # | Thiếu gì | Bằng chứng | Hệ quả cho b.17→19 | Invest có nêu? |
|---|---|---|---|---|
| a | Rẽ nhánh CEO theo GiaTri | `grep CeoApprovalThreshold\|AllowApproverFinalize` trên `ContractWorkflowService.cs` = **0 hit** | b.18 ">5 tỷ mới CEO ký" không tự động được | ✅ có (§2 + Q5) |
| b | Tín hiệu bàn giao trạm kế | `:407` `if (contract.DrafterUserId is Guid drafterId && drafterId != actorUserId)` — notify **chỉ Drafter** | CCM duyệt xong, CEO **không được báo** | ✅ nêu §2-bis, ❌ không hạ cấp verdict |
| c | Người duyệt XEM được phiếu | `grep ChoDuyet src/.../ContractFeatures.cs` = **0 hit**; view-guard `:343-349` map 100% legacy | CCM/CEO/HR mở HĐ đang chờ chính mình → chặn | ✅ nêu §2-bis, ❌ không hạ cấp verdict |
| d | **PMH trình được HĐ** | `:70-79` — trình `DangSoanThao/TraLai → ChoDuyet` đòi role `Drafter` HOẶC `DeptManager`; role `Procurement` **không nằm trong đó** → `ForbiddenException :77` | b.13/b.17 "PMH trình ký" **403** nếu PMH chỉ mang role Procurement | ❌ **BỎ SÓT** |
| e | **Guard cho Trả lại / Từ chối** | `:49-66` nhánh Reject nằm **TRƯỚC MỌI** kiểm tra: không check role, không check approver-hiện-tại, không check `fromPhase`. Handler chỉ chặn chưa-đăng-nhập (`ContractFeatures.cs:224` `UnauthorizedException`), controller chỉ `[Authorize]` trần (`ContractsController.cs:13`, 0 per-action policy) | **Mọi user đã đăng nhập** POST reject → set HĐ **bất kỳ**, **phase bất kỳ kể cả `DaPhatHanh` terminal** → `TuChoi`/`TraLai` + `SlaDeadline=null` | ⚠️ invest chỉ ghi "không guard theo cấp" — **nhẹ hơn sự thật nhiều** |

⇒ Trả lời độc ác cho Q2: chuỗi CCM→CEO→HR **chạy được trên bàn thí nghiệm** (admin bấm, hoặc user được pin đúng Level và tự mò vào URL), **không chạy được trong vận hành** — người duyệt không thấy phiếu (c), không được báo (b), người trình có thể bị chặn (d), và bất kỳ ai cũng có thể giết phiếu (e). Nhãn "TÁI DÙNG — không dựng mới" do đó **sai về chi phí**: tối thiểu 4 hạng mục phải wire mới (visibility, inbox, notify-approver-kế, guard reject) trước khi 17→19 dùng được, chưa kể (a).

---

## 3. Q3 — b.15-16 map vào attachment: mất 3 vết, invest chỉ bắt 1

Invest bắt: `AttachmentPurpose` không phân biệt ký-nháy vs ký-chính (Q12). **Đúng nhưng là cái nhẹ nhất.** Hai cái nặng hơn bị bỏ:

1. **Mất TRẠNG THÁI máy-đọc-được.** Upload attachment KHÔNG đổi `Phase`; suốt b.13→16 HĐ đứng nguyên `DangSoanThao`. Hệ thống không phân biệt được "chưa soạn" / "đang đàm phán" / "chờ TP ký nháy" / "đã ký nháy, chờ trình". Hệ quả dây chuyền: **Q11 (gate lane ⑥ "duyệt mẫu xong mới ký HĐ") mất luôn đối tượng để chấm** — muốn chặn cứng thì phải có cờ trạng thái, mà mô hình attachment không sinh ra cờ nào. Enum cũ (`DangDamPhan`/`DangInKy`) từng cho đúng thứ đó.
2. **Mất ĐỒNG HỒ SLA đúng chỗ sơ đồ đặt.** `ContractWorkflowService.cs:31-32` `GetPhaseSla` trả 7 ngày **CHỈ** cho `ChoDuyet`, `null` cho mọi phase khác. Khúc b.13→16 nằm ngoài `ChoDuyet` ⇒ chỉ có 1 deadline tĩnh cấp lúc tạo (`CreateContractFromEvaluationFeatures.cs:109-110`, fallback 7 ngày), và **`:62` Trả lại set `SlaDeadline = null`** ⇒ sau 1 lần trả lại thì HĐ hết đồng hồ vĩnh viễn. Mà chính khúc này sơ đồ ghi "BCH 3 ngày" + "13→18 hết 7-10 ngày". Invest Q10 chỉ hỏi SLA per-step **trong V2** — bỏ đúng khúc mà sơ đồ ghi số.
3. **Mất định danh chữ ký** (invest đã bắt — Q12): ai ký nháy, ngày nào, bản nào — chỉ còn tên file + comment tự do.

⇒ Mapping "trạm offline → attachment" **hợp triết lý Mig 21** (đúng như invest nói) nhưng **không miễn phí**: đổi 3 thứ đo được lấy 1 thứ linh hoạt. Verdict nên khai giá này, không nên trình bày như tương đương.

---

## 4. Q4 — nhánh invest BỎ QUA

**4.1 🔴 Sub-claim SAI SỰ THẬT (nặng nhất).** Invest §1:51-55 (Điểm LAI thứ 3): *"HĐ sinh từ phiếu sẽ rơi nhánh V1 legacy (**hardcoded policy fallback nếu V1 không có active def**)"*.
Đo trên đĩa: `grep "WorkflowPolicyRegistry|WorkflowTypeAssignment"` trên `ContractWorkflowService.cs` = **0 hit**. Đường duyệt thật: `:98` không có `ApprovalWorkflowId` → V1 → `:108-113` nạp `WorkflowDefinitions` → **`:115-116` `if (def == null || def.Steps.Count == 0) throw new ConflictException("HĐ chưa pin workflow definition hoặc workflow không có step.")`**.
⇒ **KHÔNG có fallback nào.** Nếu `activeWfId` null tại `CreateContractFromEvaluationFeatures.cs:68-71` (không có V1 def active cho `ContractType` đó) thì HĐ sinh ra **trình được nhưng KHÔNG AI DUYỆT ĐƯỢC** — 409 ngay lần approve đầu, HĐ kẹt cứng ở `ChoDuyet`. Đây là hỏng **cứng**, không phải "degrade êm" như invest mô tả. (`WorkflowPolicyRegistry` chỉ còn sống ở `ContractFeatures.cs:448-455` và `DbInitializer.cs:720` — xem 4.4.)

**4.2 Bỏ sót đường bỏ-qua-CEO ĐÃ WIRE.** Q5 của invest liệt 3 phương án cho ngưỡng b.18 và khẳng định *"cơ chế ngưỡng phía HĐ CHƯA TỒN TẠI"*. Đúng cho **tự động theo tiền**, nhưng thiếu phương án (d) **0 dòng code BE**: `ContractWorkflowService.cs:318-352` `skipToFinal` + `matchingLevel.AllowApproverSkipToFinal` (`:322`) cho phép CCM ở Bước 1 **nhảy thẳng tới Bước cuối/Cấp cuối** (`:339-340`) — tức bỏ qua Step CEO, HR vẫn duyệt thật để về `DaPhatHanh`. Cơ chế này Contract V2 ĐÃ CÓ (khác `AllowApproverFinalize` vốn PE-only). Nhược: thủ công (người bấm), không tự chấm theo 5 tỷ. Phải đưa vào bảng phương án để owner cân.

**4.3 Bỏ sót gate vai người TRÌNH** — xem §2.2 mục (d), `:70-79`.

**4.4 Bỏ sót lệch DISPLAY ⟂ GUARD.** `ContractFeatures.cs:448-449` vẫn dựng policy hiển thị bằng `WorkflowPolicyRegistry.FromDefinition(def)` / `.ForContract(c)` và `:455` `.ForContractWithOverrides(...)` — tức phần trả về FE (`workflow.nextPhases`) sinh từ **mô hình legacy 9-phase**, trong khi `TransitionAsync` chỉ chấp nhận mô hình phẳng V1-step/V2-level. Đây là lớp lỗi "FE bày nút mà BE không nhận" (hoặc ngược lại) — cần đo trước khi hứa "tái dùng UI sẵn có" cho b.17→19.

**4.5 Bỏ sót hệ quả multi-winner ở khúc duyệt.** `CreateContractFromEvaluationFeatures.cs:145` `pe.ContractId = contracts[0].Id` + guard `:59-60` `if (pe.ContractId is not null) throw` ⇒ (i) N HĐ liên-danh phải chạy **N chuỗi duyệt độc lập** (CEO ký N lần cho 1 gói thầu) — nhân đôi tải cho b.17→19 mà §3 bảng không ghi; (ii) cầu **không tái chạy được**: thêm winner sau khi đã tạo HĐ là bất khả qua API này. Q4 của invest bắt đúng phần ngưỡng per-HĐ vs per-gói, nhưng bỏ phần tải vận hành + khoá idempotent.

**4.6 (ủng hộ invest — đo lại thấy ĐÚNG).** 2 claim nặng nhất của invest đứng vững: `CeoApprovalThreshold` 0-hit phía HĐ ✅; `ChoDuyet` 0-hit trong `ContractFeatures.cs` ✅ (⇒ view-guard `:343-349` + inbox `:365-371` legacy-only là THẬT). Cầu pin V1 `:108` `WorkflowDefinitionId = activeWfId`, 0 tham số V2 ✅.

---

## 5. Bảng điểm công

| # | Điểm | Mức | Ảnh hưởng verdict |
|---|---|---|---|
| 1 | "hardcoded policy fallback" SAI — thật là `ConflictException` `:115-116`, HĐ kẹt cứng | 🔴 HIGH | nâng nặng "Điểm LAI thứ 3" từ *nên sửa* → *chặn* |
| 2 | Trích dẫn nống `ContractPhase.cs:3-13` (V2/Cấp không nằm trong dải) | 🟡 MED | không lật, hạ độ tin cite |
| 3 | Lệch biên: chỉ b.14→19 mới là phase-đã-gỡ (13, 20-21 dùng phase sống) | 🟢 LOW | không lật |
| 4 | Q1 dưới-chứng: bỏ bảng vai `:347-349`/`:369-371` = chứng mạnh nhất | 🟡 MED | củng cố verdict (invest có lợi) |
| 5 | Notify chỉ Drafter `:407` — đo đúng nhưng không hạ cấp nhãn "tái dùng" | 🟡 MED | sai chi phí |
| 6 | **Bỏ sót**: gate role trình `:70-79` chặn PMH thuần Procurement | 🔴 HIGH | thêm việc bắt buộc cho b.13/17 |
| 7 | **Bỏ sót**: Reject `:49-66` 0-guard + `[Authorize]` trần ⇒ mọi user giết HĐ mọi phase (kể cả `DaPhatHanh`) | 🔴 HIGH | lỗ bảo mật, phải vá trước khi mở luồng |
| 8 | **Bỏ sót**: mất đồng hồ SLA khúc 15-16 (`:31-32` + `:62`) | 🟡 MED | Q10 hỏi thiếu chỗ |
| 9 | **Bỏ sót**: mất trạng thái máy-đọc-được ⇒ Q11 gate cứng vô đối tượng | 🟡 MED | ràng buộc thiết kế |
| 10 | **Bỏ sót**: `AllowApproverSkipToFinal` `:318-352` = phương án (d) 0-code cho b.18 | 🟡 MED | thiếu lựa chọn cho owner |
| 11 | **Bỏ sót**: display dựng từ `WorkflowPolicyRegistry` (`ContractFeatures.cs:448-455`) ⟂ guard phẳng | 🟡 MED | rủi ro FE khi tái dùng |

3 HIGH · 7 MED · 1 LOW = **11 điểm**.

---

## 6. LENS-VERDICT

**LUNG-LAY (không BÁC).**

- **Giữ:** thế chia đôi `b.7→12 dựng mới ⟂ b.13→21 tái dùng Contract` **sống**. Q1 đứng, và đứng chắc hơn cách invest chứng (bảng vai `ContractFeatures.cs:347-349`, `:369-371` khớp CCM→CEO→HR).
- **Lung lay:** cụm chữ **"TÁI DÙNG (không dựng mới)"** sai về chi phí. Đúng hơn: *"tái dùng KHUNG duyệt V2, nhưng phải WIRE MỚI 4 đường sống (visibility `ChoDuyet` · inbox · notify approver kế · guard reject) + 1 rẽ nhánh tiền (b.18) trước khi 17→19 vận hành được"*. Nếu owner đọc "tái dùng" thành "gần như không tốn công", ước lượng sẽ hụt.
- **Phải sửa trong bản invest trước khi trình owner:** (i) bỏ chữ "hardcoded policy fallback" — thay bằng `ConflictException :115-116` + nâng "Điểm LAI thứ 3" lên mức CHẶN; (ii) thêm Q về vai người TRÌNH (`:70-79`); (iii) thêm cảnh báo bảo mật Reject `:49-66`; (iv) bổ sung phương án (d) `AllowApproverSkipToFinal` vào Q5; (v) siết lại câu §0 cho khớp §3 (b.14→19, không phải 13→21).
- **Không đề nghị sửa mã** (ranh vai): trên đây là mô tả lỗi + tiêu chí nghiệm thu, thực thi thuộc em main / implementer.

END · TOTAL=11 DIEM
