# c3-l2

LANE 2/3 review cụm-3 — LENS ĐỦ-vs-SPEC + OG (owner-gate).
Target: `sub-invest-fable-b2-cum3.md` (K7 bridge 8v/8test + K8 dry-run 17 bước B0-B16).
Spec đối chiếu: `spec-4gd-khkk-tong-quat-31-07-2026.md` §②③④ + `spec-cum1-chi-tiet-31-07-2026.md`.
Mọi neo dưới đây ĐO LẠI TRÊN ĐĨA hôm nay (không mượn số của lane khác).

## Findings

### HIGH

**H1 — Bridge đặt trên controller class-Policy ⇒ tập khóa hiệu lực là 2, spec khai 1; FE gate lỏng hơn server; 0 test authz.**
- Claim của cum3 `:54`: "🔴 đúng-key-policy-endpoint-đích (gotcha #85) … key `Contracts.Create`, KHÔNG phải `KeHoachKyKet.*`".
- Đĩa: `src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs:26` = `[Authorize(Policy = "KeHoachKyKet.Read")]` **class-level**; comment `:12-19` khai luật nhà: "tầng 1 class `KeHoachKyKet.Read` (mọi endpoint tối thiểu Read) + tầng 2 per-action `KeHoachKyKet.{Create|Update|Delete}` — GHI ĐỦ TRÊN MỌI action".
- ASP.NET Core: action-`[Authorize(Policy=X)]` KHÔNG thay class-level, hai cái **AND**. ⇒ tập khóa THẬT của bridge = `{KeHoachKyKet.Read ∧ Contracts.Create}` — chưa ai khai. Đây là action ĐẦU TIÊN trên controller mang tiền tố khóa khác (khuôn nhà cùng hình dạng: `EmployeesController` class `Hrm_HoSo.Read` + per-action cùng tiền tố — `tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs:208-256`).
- Hệ quả đo được: (a) FE gate `can('Contracts','Create')` (cum3 `:67`) LỎNG HƠN server 1 khóa — cùng lớp bệnh gotcha **#85/#82** tôi đã ghi @S162 (gate query phải = ĐÚNG tập khóa endpoint); (b) nếu câu III trả lời "người phòng Mua hàng bấm" mà vai đó KHÔNG có `KeHoachKyKet.Read` thì nút chết 403 im lặng (#44), và A1 chạy bằng admin (bypass) sẽ KHÔNG bắt được; (c) T1-T8 (`:73-82`) **0 case authz**.
- Sev: HIGH. Đề nghị: spec khai TƯỜNG MINH tập khóa hiệu lực 2 vế + 2 test âm (thiếu `KeHoachKyKet.Read` → 403; thiếu `Contracts.Create` → 403) + FE gate khớp ĐÚNG tập đó; nếu muốn 1-khóa thì phải nói rõ endpoint chuyển sang `ContractsController` (quyết của lead, không phải của tôi).

**H2 — QĐ9 "khóa budget" có bước ĐO nhưng phép đo KHÔNG THỂ TRƯỢT (0-bit), và nhánh trigger thứ 2 (CEO) không được đo.**
- Nghĩa vụ: `spec-4gd:19` QĐ9 — freeze khi **finalize tại CCM HOẶC CEO duyệt final**; "phiếu DaDuyet đọc SNAPSHOT, không đọc live".
- cum3 có B6 (`:119`, kỳ vọng "ApprovedAmount ghi tại choke-point + FREEZE", sqlcmd 2 cột) và B8 (`:121`, "mở lại detail: số khóa 🔒; sửa line bị phase-guard chặn; sqlcmd số không đổi").
- Lỗ: **không bước nào làm NHIỄU nguồn live** rồi đo số đã-freeze có đứng yên không. B8 chặn sửa bằng phase-guard ⇒ số không đổi là chuyện đương nhiên **kể cả khi display vẫn đọc LIVE**. Hỏi theo sàn-sự-thật: "phép kiểm nào khiến kết luận *đã freeze* TRƯỢT nếu freeze hỏng?" — hiện KHÔNG có.
- Lỗ 2: nhánh CEO (`:120` B7) nằm trong nhánh B "*nếu còn giờ*" (`:105`) và kỳ vọng chỉ là "Phase=DaDuyet đường chuẩn" — **0 assert freeze**. ⇒ 1 trong 2 trigger của QĐ9 không có mặt trong kịch bản chính.
- Sev: HIGH. Đề nghị: thêm bước "sau DaDuyet, đổi nguồn live (giá PE / NS tham chiếu / PeReferenceAmount) → mở lại phiếu KHKK: số HIỂN THỊ phải KHÔNG đổi, sqlcmd snapshot ≠ live" + ép nhánh CEO vào phần bắt buộc (hoặc khai thẳng "QĐ9 chỉ được chứng ở nhánh CCM, nhánh CEO nợ").

**H3 — DoD "mọi bản ghi mới mang tiền tố ZZTEST" KHÔNG đạt được cho phiếu KHKK; và đúng bản ghi sống-vĩnh-viễn lại là bản ghi không mang dấu.**
- Nghĩa vụ `spec-4gd:35`. cum3 thực hiện bằng: B1 `:114` "GhiChu tiền tố **ZZTEST**" (KHKK) + B10 `:123` "TenHopDong tiền tố ZZTEST" (HĐ).
- Đĩa: `ContractSigningPlanFeatures.cs:34-57` `ContractSigningPlanListItemDto` — 22 field, **KHÔNG có `GhiChu`** (entity có `ContractSigningPlan.cs:45 GhiChu`, nhưng list không trả). Phiếu KHKK cũng **không có field tên tự do** nào khác; detail header đọc `k.peTenGoiThau` = tên gói thầu THẬT (`fe-user/src/pages/khkk/KhkkDetailPage.tsx:288`).
- ⇒ Trên màn Danh sách/Đang duyệt/Đã duyệt KHKK, phiếu test hiện: `KHKK/2026/xxx` + tên gói thầu thật + dự án thật + NCC thật — **không phân biệt được với phiếu thật**. Mà theo chính cum3 `:131` (HỐ-1-KHKK) đây là bản ghi **không xoá được kể cả Admin** ⇒ nó nằm lại lâu nhất và mang dấu ít nhất.
- Sev: HIGH. Đề nghị: hoặc (a) khai thẳng "ZZTEST chỉ hiện ở GhiChu, KHÔNG hiện trên list — chấp nhận", hoặc (b) đây là lập luận MẠNH NHẤT cho phương án "bộ ZZTEST riêng" ở K8.2 (Project ZZTEST ⇒ `ProjectName` hiện ZZTEST trên list) — hiện K8.2 **không nêu lập luận này**.

**H4 — Câu giờ-G (K8.2) thiếu 2 hệ quả nặng nhất trên bản ghi THẬT, trong khi default là "im lặng = chạy trên data thật".**
- K8.2 `:140` liệt cái giá: phiếu/HĐ ZZTEST nằm vĩnh viễn + cháy 1 seq. **Thiếu:**
  1. **Chiếm slot**: phiếu KHKK DaDuyet của A/049 khoá luôn việc lập phiếu nhóm-4 THẬT cho PE đó tới khi sqlcmd (cum3 tự khai ở `:131` nhưng KHÔNG đưa vào câu hỏi cho anh). Đĩa xác nhận rào: `ContractSigningPlanFeatures.cs:341-347` `AnyAsync(p.PurchaseEvaluationId == pe.Id && p.Phase != TuChoi)` → 409.
  2. **Chiếm `pe.ContractId`**: cum3 `:52` quyết `pe.ContractId ??= contract.Id`. Đĩa: `CreateContractFromEvaluationFeatures.cs:59-60` `if (pe.ContractId is not null) throw new ConflictException("Phiếu này đã tạo HĐ rồi.")` ⇒ bridge PE→HĐ CŨ **409 vĩnh viễn** cho PE thật A/049 — và vì bridge cũ tạo **N HĐ cho N winner trong 1 call** (loop `foreach (var w in winners)` `:81+`), 1 lần bridge KHKK khoá luôn đường tạo HĐ cho **mọi winner còn lại** của phiếu đó. cum3 gọi đây là "đúng ý, khóa đường cũ tự nhiên" — nhưng đó là **rút một đường đang dùng được trên bản ghi thật**, thuộc quyền anh, không phải hệ quả kỹ thuật trung tính.
- Sev: HIGH. Đề nghị: viết lại K8.2 thành 2 phương án A/B, mỗi phương án 1 dòng "cái mất" gồm đủ 4 mục (2 mục trên + phiếu nằm lại + seq cháy); bỏ "im lặng = A" hoặc đổi default sang B.

**H5 — 12/14 bước không có người thật, 0/14 có tài khoản; và KHÔNG có cửa nào bắt điền trước giờ-G.**
- Bảng `:112-127`: cột là {Ai · Hành động · Kỳ vọng · Bằng chứng} — **không có cột email/tài khoản**. Chỉ B6 (`TP.CCM Phan Văn Chương`) và B7 (`CEO Trường`) là người thật; B1/B2/B3 "BCH drafter [K3-bảng]", B4 "Trạm-1 [K3-bảng]", B5 "Trạm-2 [K3-bảng]", B9 "Người có Contracts.Create [⏳ câu III]", B11 "Approver HĐ [đội hình type-3]", B12 "HRA/CCM", B13 "Bất kỳ + Admin", B14 "Lead".
- cum3 `:104` giải thích hợp lý vì sao chưa điền (Dev không có `QT-DN-V2-001`) — **nhưng** B0 (`:110`) có 5 mục và KHÔNG mục nào là "điền đủ 14 dòng cột Ai từ bảng K3 + verify từng tài khoản login 200 + xác nhận người có mặt khung giờ X". Checklist `:166` cũng chỉ đòi "B0 đủ 5 mục + câu giờ-G".
- Hệ quả: tới giờ-G mới phát hiện thiếu người ⇒ hoặc hoãn, hoặc **chạy bằng Admin bypass** (V2 Admin skip mọi check) — mà chạy bằng admin thì dry-run mất đúng thứ nó định chứng (gating trạm-by-trạm; `SignedByUserId` sẽ ra "Admin duyệt thay"). Spec không nói mode nào được phép.
- Sev: HIGH. Đề nghị: B0 += mục (vi) "bảng 14 dòng đã điền tên+tài khoản, mỗi tài khoản đã thử login 200 trong 24h trước" + 1 dòng chính sách "được/không được dùng Admin thay trạm".

### MED

**M6 — B0(ii) tự-tham-chiếu: không bắt được lỗi mà OG-2 sinh ra để bắt.**
`:110` B0(ii) verify "…`KHKK-N4` active + roster khớp **bảng K3**". Bảng K3 và seed `KHKK-N4` cùng một nguồn (K3 sinh ra cả hai) ⇒ nếu K3 seed nhầm từ bản `QT-DN-V2-001` **archived**, B0 vẫn PASS. OG-2 (`spec-4gd:44`) yêu cầu đúng cái này: "verify bản **LIVE (không archived)**". Tên `QT-DN-V2-001` không xuất hiện trong B0. Đề nghị: B0 đo trực tiếp nguồn (`ApprovalWorkflows` Code=`QT-DN-V2-001` + cờ active/version) rồi mới so xuống `KHKK-N4`.

**M7 — QUYẾT-1 (PVC → nhóm B1 → `KHKK-N4`) dựng trên bảng CHƯA được anh soát, và 3/3 trích dẫn vào bảng đó đều lệch.**
- OG-6 còn ⏳ (`spec-4gd:47`: "anh soát bảng seed trước land"). QUYẾT-1 (`cum3:102`) chốt nhóm dựa trên bảng đó mà không khai phụ thuộc này ⇒ anh sửa bảng thì nhóm/mã workflow của cả buổi dry-run đổi theo.
- Trích dẫn (đo bằng `sed -n 'Np'` trên `danh-muc-sp002-transcribe.md`):
  | cum3 nêu | nội dung THẬT tại dòng đó | neo ĐÚNG |
  |---|---|---|
  | `:52` = "B1 · Vật tư / Materials (23) → N4" | `\| A3-07 \| Điện tạm \| Temporary power supply \|` | **`:65`** |
  | `:75` = "B1-23 Vật tư chính khác" | `\| B1-10 \| Nylon \| Nylon sheets \|` | **`:88`** |
  | `:20` = "A1 = thiết bị/vật tư PHỤ → N1" | `\| # \| Nhãn menu đề xuất \|` (header bảng) | **`:33`** (hoặc `:22` dòng nhãn N1) |
- Sev: MED (kết luận nghiệp vụ có thể vẫn đúng, nhưng neo không dẫn tới bằng chứng — người soát bấm vào sẽ thấy thứ khác và mất niềm tin đúng chỗ cần nhất).

**M8 — Neo `ContractFeatures.cs:632-633` (HỐ-1 guard numeric) SAI, và sai theo kiểu "chép từ sổ S160" — trái chính luật K7.0 tự đặt.**
`cum3:130` viết: "`DeleteContract` guard numeric `Phase >= DangInKy` (`ContractFeatures.cs:632-633`, S160)". Đĩa: guard thật ở **`:780`** `if (entity.Phase >= ContractPhase.DangInKy)` trong `DeleteContractCommandHandler` (`:773`, record `:771`). `:632` nằm trong khối authz của GetDetail (`isHardCopyActor`/`ForbiddenException("Bạn không có quyền xem HĐ này.")`). Nội dung claim ĐÚNG, neo lệch ~148 dòng. K7.0 mở đầu bằng "đo lại hôm nay, không tin sổ" (`:8`) — nguyên tắc áp cho K7 nhưng không áp cho K8.

**M9 — "4 file test SigningPlan" là 3; và module KHKK hiện KHÔNG có regression authz nào — K7 cũng không thêm.**
`cum3:14` liệt "(Approval/Crud/Schema/AuthorizePolicy)". Đĩa: `tests/…/Application/ContractSigningPlanApprovalTests.cs`, `…/ContractSigningPlanCrudTests.cs`, `…/Common/ContractSigningPlanSchemaTests.cs` = **3**; `tests/…/Api/AuthorizePolicyRegressionTests.cs` là file CHUNG (`grep -n "SigningPlan"` → **0 hit**), phủ ApprovalWorkflowsV2/HrmConfigs/Employees. ⇒ ContractSigningPlansController chưa từng bị test authz khoá, và T1-T8 không thêm (nối H1).

**M10 — Câu III (ai bấm) đánh dấu ⏳ nhưng KHÔNG có cửa chặn, trong khi acceptance đã nướng sẵn đáp án.**
`cum3:56` "⏳ PENDING-OWNER"; B9 `:122` ghi "[⏳ câu III]". Nhưng: không có dòng OG mới trong `spec-4gd §④`; không nằm trong B0 5 mục (`:110`); checklist `:166` chỉ đòi câu giờ-G. Ngược lại checklist `:163` **chốt cứng** "nút chỉ `DaDuyet && can('Contracts','Create')`". ⇒ câu treo mà đáp án đã thành tiêu chí nghiệm thu; anh trả lời khác thì acceptance sai. Đề nghị: hoặc nâng thành OG-10 chặn K7-FE, hoặc bỏ ⏳ và khai "đã tự chốt theo l1-F15, anh phủ quyết sau được".

**M11 — QUYẾT-2 chọn `HopDongNhaCungCap` bằng lý do KỸ THUẬT (né hố mã "MB"), không phải nghiệp vụ.**
`cum3:103`: "abbr 'NCC' CÓ trong RG-001 v02 — né hố 'MB' không gốc quy định, S160". Hệ quả: (a) dry-run không đi qua loại HĐ mà nghiệp vụ mua vật tư có thể dùng thật (`HopDongMuaBan`); (b) hố "MB" tiếp tục không lộ. Đề nghị: khai 1 dòng cho anh — "chọn NCC vì mã MB chưa có trong quy định; nếu nghiệp vụ là MuaBan thì cần chốt token mã trước" — và để anh quyết, đừng để lựa chọn tài liệu pháp lý bị lái bởi codegen.

**M12 — DoD "HĐ đúng số lượng OG-1" mất phép đo; 2 định nghĩa "gộp" đang chỏi nhau.**
`spec-4gd:11` QĐ1: gộp theo **cặp (NCC × dòng-danh-mục)**. `cum3:41` guard-3: gộp chỉ theo **SupplierId** (không ràng buộc dòng-danh-mục). Hai định nghĩa cho ra số HĐ khác nhau khi 1 NCC trúng nhiều hạng mục. A1 (`:86`) đo "đúng N dòng **chọn**" = đo lại chính input của caller ⇒ không phép nào chứng được quy tắc cardinality. (UI 1-1 làm rủi ro thực tế thấp, nhưng DoD đang tự khai một thứ nó không đo.) Đề nghị: khai rõ "khoá gộp = SupplierId trong phạm vi 1 phiếu" và sửa DoD thành phát biểu đo được.

### LOW

- **L13** Guard-list 6 mục (`cum3:38-44`) thiếu load + NotFound cho `Project`/`Supplier`, trong khi codegen cần `project.Code`/`supplier.Code` (`ContractWorkflowService.cs:390`) và khuôn gốc CÓ 2 throw (`CreateContractFromEvaluationFeatures.cs` `?? throw NotFoundException("Project"…)` + `TryGetValue → NotFoundException("Supplier"…)`). "Copy trọn khuôn" mà rơi 2 throw.
- **L14** Checklist `:167` đòi `ss-k8-01..13` nhưng `ss-k8-07` chỉ sinh ra ở nhánh B "nếu còn giờ" (`:105`, `:120`) ⇒ nhánh A (nhánh CHÍNH) luôn thiếu 1 chứng ⇒ checklist không bao giờ tick sạch.
- **L15** Cùng 1 HĐ mang 2 con số khác thời-điểm: `GiaTri = Σ ApprovedAmount` (chốt lúc finalize) vs `BudgetManualAmount = Σ PeReferenceAmount` (snapshot lúc lập KHKK) — `cum3:46`, không acceptance nào phân biệt. Cùng lớp bệnh mà cụm-1 vá-8 đang lo (Σ×3, `spec-cum1:15`).
- **L16** Hệ quy chiếu test lệch giữa 2 cụm: cum3 dùng "≥ baseline+8" (`:92`, `:164`), spec-4gd/cum1 dùng "≥590+k" (`spec-4gd:24-26`, `spec-cum1:25`). Không quy đổi được nếu wave khác land xen giữa.
- **L17** `:110` B0(iv) khẳng định "KHÔNG có cơ chế tắt notification per-record" — khẳng-định-VẮNG-MẶT không kèm lệnh đo/0-hit + control dương. Rẻ: dán 1 grep.
- **L18** QĐ9 dặn né **#81-EXT** (grep MỌI Phase-assignment-site kể cả admin-override/seeder — `spec-4gd:19`). K8 là cửa cuối nhưng không có bước thử đường bypass (ví dụ admin đổi Phase trực tiếp → hook freeze có chạy?). Thuộc K2/K3 về mặt code, nhưng K8 nên có 1 dòng "đã xác nhận sweep, evidence: …".

### Claim ĐÃ THỬ BÁC MÀ ĐỨNG (giữ nguyên, đừng sửa)

- **P1** `cum3:48` "terminal V2 có guard `if (MaHopDong is null)` (`ContractWorkflowService.cs:384`)" — **ĐÚNG NGUYÊN DÒNG**: `:384 if (string.IsNullOrEmpty(contract.MaHopDong))` (gen ở `:390`). Thiết kế "gen mã ngay lúc bridge" an toàn khỏi double-gen.
- **P2** `cum3:131` "global filter làm rào `:342-344` giải phóng slot khi soft-delete" — **ĐÚNG**: rào dùng `db.ContractSigningPlans.AnyAsync(...)` KHÔNG `IgnoreQueryFilters` (cả file chỉ 1 hit `:767`, thuộc query khác). Lối thoát sqlcmd đứng.
- **P3** HỐ-1-KHKK **ĐÚNG**: `:1142-1145` nhánh owner/admin CHẠY TRƯỚC, `:1148` allow-list `{DangSoanThao, TuChoi}` áp cho **tất cả** ⇒ phiếu DaDuyet không xoá được kể cả Admin. Phát hiện mới, có giá trị.
- **P4** BÁC tiền đề K7(b) (`:16-21`) — đúng phương pháp (re-đo thay vì thi hành đề bài cũ), và việc CẤM đụng `GetEligiblePhases` (`:162`) là bảo vệ đúng chỗ.
- **P5** Mirror 2 app: `fe-user` và `fe-admin` `pages/khkk/KhkkDetailPage.tsx` cùng 26927 B / 687 dòng; neo `:291-318` đúng là khối `actions` của PageHeader (`:288-300` xác nhận) ⇒ K7.3 chỉ đúng chỗ.

## Verdict

**FAIL (chưa cho build) — 18 finding: 5 HIGH · 7 MED · 6 LOW; 5 claim chịu được phản-biện.**

Chia 2 nửa rất khác nhau:
- **K7 (bridge)**: nền CHẮC — re-đo 3 nấc thật, bác được tiền đề lỗi thời, khuôn/atomicity/codegen đúng, 5/5 claim tôi thử bác đều đứng. Chặn duy nhất là **H1** (tập khóa authz 2 vế chưa khai + FE gate lỏng + 0 test authz) và **M10/M12** (câu III treo mà acceptance đã chốt; DoD cardinality không đo được). Vá 3 chỗ này là K7 chạy được.
- **K8 (dry-run)**: **chưa chạy được như viết**. 12/14 bước không có người thật và không cửa nào bắt điền (**H5**); phép đo QĐ9 không thể trượt (**H2**); dấu ZZTEST không hiện trên list KHKK nên DoD tự khai là không đạt (**H3**); và câu hỏi trình anh thiếu đúng 2 hệ quả nặng nhất trên bản ghi thật (**H4**).

Ưu tiên vá: H4 → H2 → H1 → H5 → H3 → M6/M10.

END sub-reviewer-c3-l2 — TOTAL: 18 finding (5 HIGH · 7 MED · 6 LOW) + 5 positive.
