# review-synthesis — S156 wave 2 · `/fable-clone reviewer` 4 lăng kính

> Lead-written (P3/P4 refute + synthesize). Run `wf_1618ae3c-a17` · **4/4 lane sạch, 0 chết, 0 rỗng**
> (đóng gói C2 có tác dụng: ≤3 file/lane + ép khung rỗng lượt 1-2 + trần 25; lane tốn nhiều nhất 7/25 lượt).
> Nguồn: `sub-review-verdict-1.md` · `sub-review-fidelity-2.md` · `sub-review-q2-3.md` · `sub-review-gaps-4.md`

## Bảng verdict

| Lăng kính | Verdict | Điểm |
|---|---|---|
| lens-verdict | `LUNG-LAY` | 11 (3H · 7M · 1L) |
| lens-fidelity | `CO-VAN-DE` | 10 (2H · 3M · 5L) |
| lens-q2 | `THIEU-PHUONG-AN` + thiên-vị-có-hướng | 14 (4H · 6M · 4L) |
| lens-gaps | 4/4 claim THẬT, **0 dương-giả** | 2 đáng làm · 1 đổi khung · 1 sai tầng |

**Tổng 35 điểm.** Lõi verdict `LAI` **SỐNG** (cả 4 lane không lane nào bác thế chia đôi 7→12 dựng mới ⟂
13→21 tái dùng). Cái đổ là **NHÃN CHI PHÍ** và **CÁCH ĐẶT VẤN ĐỀ**, không phải kết luận.

---

## A. 🔴 2 phát hiện MỚI — reviewer đào ra, invest KHÔNG có

### A1. LỖ HỔNG AN NINH THẬT trên production (lead đã tự verify độc lập)

`ContractWorkflowService.cs:48-66` — nhánh `Reject` chạy **TRƯỚC MỌI GUARD**:

```
if (decision == ApprovalDecision.Reject) {
    ... contract.Phase = TuChoi  |  TraLai
    contract.SlaDeadline = null
    SaveChanges(); return;          ← thoát luôn, không qua guard nào phía dưới
}
```
**0 kiểm `actorRoles` · 0 kiểm actor có phải người duyệt lượt này · 0 kiểm `fromPhase`.**

Tầng 2 (authz API) **cũng hở**: `ContractsController.cs:13` = **đúng 1 `[Authorize]` trần**, 0 per-action
policy, phủ **22 endpoint ghi**. Đối chứng loại trừ "dự án không làm kiểu đó":
`PurchaseEvaluationsController` có **3** `[Authorize(Policy...)]`.

⇒ **MỌI user đã đăng nhập có thể Từ-chối / Trả-lại BẤT KỲ hợp đồng nào — kể cả HĐ đã `DaPhatHanh`
(terminal, đã phát hành).** Và `SlaDeadline` bị xoá kèm.

🔴 **Đây là gotcha #82 TÁI PHÁT** (`feedback_permission_grant_two_layers`: display-layer ⟂ API-authz-layer
là 2 tầng độc lập; `[Authorize]` trần = lỗ hổng). Lần trước bắt ở `ReportsController` @S118 — cùng hình dạng.
**KHÔNG liên quan tính năng mới**; nó đang sống trên prod.

### A2. PMH không trình được HĐ — 403 ngay bước 13/17

`ContractWorkflowService.cs:70-79`: gate trình `DangSoanThao|TraLai → ChoDuyet` đòi role
`Drafter` **hoặc** `DeptManager`. PMH (Phòng cung ứng) mang role `Procurement` ⇒ `ForbiddenException`.
Mà sơ đồ giao **PMH** trình ở cả b.13 lẫn b.17. Đúng lớp *"cơ-chế đúng, thứ đi qua nó không có"* — lớp đã
cắn 4 lần đợt PE, nay lần 5.

---

## B. 1 claim của invest SAI SỰ THẬT (phải sửa trước khi trình owner)

Invest §1:51-55 viết cầu PE→HĐ *"rơi nhánh V1 legacy (hardcoded policy fallback nếu V1 không có active def)"*.
**Sai.** Đo: `grep WorkflowPolicyRegistry|WorkflowTypeAssignment` trên `ContractWorkflowService.cs` = **0 hit**.
Đường thật: `:98` → `:108-113` → **`:115-116` throw `ConflictException`**.

⇒ Nếu `activeWfId` null thì HĐ sinh từ phiếu **trình được nhưng KHÔNG AI DUYỆT ĐƯỢC — kẹt cứng ở `ChoDuyet`**.
**Hỏng CỨNG, không "degrade êm"** như invest mô tả. Nặng hơn chứ không nhẹ hơn.

🔑 **Vì sao invest sai:** nó tin **skill-doc `contract-workflow`** (mô tả `LoadPolicyAsync` có fallback
`WorkflowPolicyRegistry`) **hơn đĩa**. Trên đĩa fallback đã chết ở đường *transition*, chỉ còn sống ở đường
*hiển thị* (`ContractFeatures.cs:448-455`) ⇒ **lệch DISPLAY ⟂ GUARD**, và doc stale che luôn cái lệch đó.

---

## C. Q2 — cách đặt vấn đề THIÊN VỊ CÓ HƯỚNG (owner đã nói "cần bàn thêm", nên phần này quan trọng nhất)

| # | Vấn đề | Chứng |
|---|---|---|
| H1 | **3 phương án KHÔNG cùng phạm vi** ⇒ so sánh chi phí vô nghĩa (apples-to-oranges) | option-space thật là **2 trục**, không phải 3 điểm |
| H2 | **Thiếu phương án tái dùng module `Proposal`** (`ApplicableType=4`) | có sẵn trong repo |
| M1 | Phương án "PE + workflow thứ 2" bị bác ở §1 nhưng **không hiện trong Q2** ⇒ owner đọc §6 không biết nó tồn tại. Và lý do bác dựng "1 cột" thành **bất-khả kiến trúc**, trong khi chính tài liệu này coi `AddColumn` là rẻ ở chỗ khác (Mig 53 "3 AddColumn", Mig 67 "11 cột") ⇒ **tiêu chuẩn kép** | |
| M2 | Thiếu phương án "Contract-sớm nhưng tách entity con" | |
| M3 | **Hiệu ứng hào quang**: dữ kiện "rẻ" DUY NHẤT (`ApplicableType=10` append-only) lại gắn vào phương án **ĐẮT NHẤT** (a) — mà slot enum là lát mỏng nhất của chi phí (a) (thật: 4 bảng + ~600 LOC BE + ~1.956 LOC FE) | |
| M4 | **Bất đối xứng ngôn từ**: (a) và (c) mở bằng lợi ích, **chỉ (b)** bị gắn tính từ rủi ro; 0 phương án nào có rủi ro **định lượng**. Câu chốt "Em nghiêng (a) hoặc (c) — (b) khuyên tránh" đặt khuyến nghị **TRƯỚC** lời mời chốt ⇒ thu hẹp còn 2 lựa chọn ngay trong câu hỏi | |
| HIGH | **Chi phí là CẢM TÍNH** dù **bản sao đo được nằm sẵn trong repo** (Proposal / Mig 38) | |

**Cách sửa (reviewer đề, lead đồng ý):** mỗi phương án kèm **3 số cùng đơn vị — số bảng mới · LOC BE ·
LOC FE — lấy từ twin thật trong repo, không ước.**

---

## D. 4 lỗ invest tuyên bố: **0 DƯƠNG-GIẢ**, nhưng 2 lỗ đóng khung sai

| Lỗ | Verdict reviewer |
|---|---|
| **L1** cầu PE→HĐ bỏ pin V2 | ✅ THẬT, đáng làm (và hậu quả nặng hơn invest nói — xem §B) |
| **L2** `CeoApprovalThreshold` ghost-wire phía HĐ | ✅ THẬT, đáng làm — vòng lặp "đặt được → lưu → hiện lại → **không ai đọc**" khép kín, chứng từng mắt xích. `AllowApproverFinalize` cũng vắng |
| **L3** SLA hardcode `AddDays(7)` | ✅ THẬT nhưng **đổi khung**: là **thụt lùi so với V1** và là lỗ **toàn-V2 (cả PE)**, không riêng HĐ |
| **L4** `AttachmentPurpose` thiếu ký-nháy | ⚠️ Đúng chữ, **sai tầng**: `Purpose` hiện là **nhãn KHÔNG AI THI HÀNH** — validator chỉ `IsInEnum()`, 0 nhánh `Purpose ==` nào trong Backend. **Thêm enum là vô nghĩa nếu không dựng cổng đọc nó** |

> 🔑 Reviewer chốt: *"Invest không bịa lỗ nào; điểm yếu là **ĐÓNG KHUNG**, không phải bịa dữ kiện."*

---

## E. Điểm reviewer CỦNG CỐ cho invest (adversarial ≠ luôn hạ điểm)

Lập luận Q1 của invest (*"phase bị gỡ chính là trạm của b.13→21"*) **ĐỨNG**, và reviewer tìm được **chứng
MẠNH HƠN thứ invest dùng**: bảng vai còn sống `ContractFeatures.cs:369-371` map
`DangKiemTraCCM→CostControl` · `DangTrinhKy→Director/AuthorizedSigner` · `DangDongDau→HrAdmin`
= **trùng khít chuỗi CCM→CEO→HR** của b.17→18→19. Trùng 3 vai liên tiếp đúng thứ tự ≈ loại trừ "trùng
hợp tên gọi" — trong khi comment enum chỉ chứng được *"đã deprecated"*, không chứng được *"vì sao"*.

**Đính chính biên của invest:** chỉ **b.14→19** rơi vào vùng phase-đã-gỡ. b.13 dùng `DangSoanThao=2` và
b.20-21 dùng `DaPhatHanh=9` — **cả hai CÒN SỐNG**. Invest viết "13→21" là nống biên.

---

## F. Nhãn đúng cho verdict (thay "TÁI DÙNG")

> **"Tái dùng KHUNG V2 + WIRE MỚI 4 đường sống (visibility `ChoDuyet` · inbox · notify approver kế ·
> guard reject) + 1 rẽ nhánh tiền (ngưỡng CEO)"**

`ApproveV2Async` gánh được **KHUNG** (steps `:234` · level-group `:246` · OR-of-N `:259-260` · advance
`:365-392` · terminal `:371-385` không gen mã đúp) nhưng **KHÔNG gánh được ĐƯỜNG SỐNG**:
`grep ChoDuyet ContractFeatures.cs` = **0 hit** ⇒ view-guard `:343-349` + inbox `:365-371` legacy-only
⇒ **người duyệt không mở được phiếu đang chờ chính mình**. Notify `:407` chỉ báo Drafter.

---

## G. Việc phải làm trước khi trình owner 14 câu hỏi

1. Sửa claim SAI ở invest §1:51-55 (`hardcoded fallback` → `ConflictException`, nâng lên mức **CHẶN**)
2. Siết biên §0/§1: `b.14→19` chứ không `b.13→21`
3. Thêm Q mới: **vai người TRÌNH** (`:70-79` PMH 403)
4. Thêm cảnh báo **A1 lỗ hổng reject** — nhưng đây là **việc RIÊNG, không thuộc spec BCH**
5. Q5 thêm phương án **(d) `skipToFinal` + `AllowApproverSkipToFinal :322`** — đường bỏ-qua-CEO **ĐÃ WIRE**
   ở Contract V2, 0 code BE. Invest bỏ sót hẳn.
6. Viết lại Q2 theo 2 trục + 3 số đo cùng đơn vị lấy từ twin repo

<!-- END · TOTAL=35 DIEM · 4/4 LANE SACH · VERDICT=LOI-SONG-NHAN-SAI -->
