# c1-l1 QUYẾT-vs-code

Lane 1/3 review cụm-1 — lens: QUYẾT (spec chốt) đối chiếu CODE trên đĩa.
Đối tượng: `.claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/sub-invest-fable-b2-cum1.md` (33.048B, 258 dòng).
Mọi dòng dưới đây đo từ đĩa @2026-07-31, HEAD `e5f53d0`.

## Điểm số

- **Citation hygiene: 23/25 chuẩn** — file:line trỏ đúng chỗ gần như tuyệt đối.
- **Claim chịu lực: 2/9 ĐÚNG · 4 LỆCH · 3 SAI** — lỗi KHÔNG nằm ở "trỏ sai dòng", mà ở **suy ra cái gì từ dòng đã trỏ đúng** và ở **cái không được trỏ**.
- **Blocker: 2** (F-1, F-2) · **Phải sửa trước khi implement: 4** (F-3, F-4, F-6, F-13).

## Findings

### F-1 [HIGH · BLOCKER] K3 8 menu-con khai "NGOÀI `MenuKeys.All`" ⇒ 0 permission row ⇒ sidebar không bao giờ hiện

- Spec dòng 214 (K3.c): "MenuKeys.cs +8 const `AwV2_KhkkN1..AwV2_KhkkN8` — **NGOÀI `All`** (0 policy mới, tiền lệ `HopDongCung`/leaf `Pe_*`)". K3.c liệt: const + MenuItem row + `Layout.tsx` resolvePath + mirror `menuKeys.ts`. **Không có bước cấp quyền.**
- Đĩa: `src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs:2058` — `foreach (var menuKey in MenuKeys.All)` là nguồn DUY NHẤT của grant admin generic. Key ngoài `All` KHÔNG sinh row `Permissions`.
- `src/Backend/SolutionErp.Application/Permissions/Queries/GetMyMenuTree/GetMyMenuTreeQuery.cs` — grep `Admin|IsAdmin` = **0 hit** ⇒ cây menu KHÔNG có nhánh admin-bypass, không có đường vớt.
- Tiền lệ mà spec viện dẫn nói NGƯỢC lại: mọi key ngoài `All` trong repo đều đi kèm danh sách grant TƯỜNG MINH — `Pe_*` → `peKeys` `DbInitializer.cs:2196-2208`; `HopDongCung` → `.Concat(new[] { MenuKeys.KeHoachKyKet, MenuKeys.HopDongCung })` `:2214`; `Khkk_*` → `KhkkKeys()` `:2185-2189` + `SeedKeHoachKyKetAccessAsync` `:2415`.
- Và 3 leaf `AwV2_*` đang sống thì **NẰM TRONG `All`**: `src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs:182` — `ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2`. Tức 8 leaf mới phá chính khuôn anh em ruột của nó.
- Hệ quả đo được: acceptance K3.e mục 3 ("sidebar đếm được **8 menu-con**") sẽ FAIL tại UAT.
- **Đề nghị:** K3.c phải khai thêm 1 việc — hoặc đưa 8 key vào danh sách grant explicit (mẫu `KhkkKeys()` `:2185-2189`), hoặc đưa vào `All` và chấp nhận +32 policy kèm sửa 2 số canonical `docs/STATUS.md` (Policies 220 / Menu keys 55).

### F-2 [HIGH · BLOCKER] K1.d mắc đúng lỗi F-1, nhưng K1.f không có phép đo nào bắt được

- Spec dòng 57 (K1.d): "+const `MenuKeys.ContractCatalog` (**NGOÀI `All`** …) + 1 MenuItem row con group `Catalogs` … + **grant admin (SeedAdminPermissionsAsync loop)**". Hai vế này loại trừ nhau: nguồn của loop chính là `MenuKeys.All` (`DbInitializer.cs:2058`).
- Acceptance K1.f (dòng 69-75) có 7 mục: 3-file mig · seed 86 · restart ×2 · curl 200/403 · test +5 · DoD 86 dòng · số STATUS. **Không mục nào chạm menu.**
- Tức: bản cài đặt trong đó trang danh mục **không vào được từ sidebar** vẫn PASS 7/7. Đây là câu trả lời cho phép thử "impl SAI nào vẫn PASS?".
- Ghi chú thêm: 4 leaf catalog hiện có (`Catalogs, CatalogUnits, CatalogMaterials, CatalogServices, CatalogWorkItems`) đều nằm trong `All` (`MenuKeys.cs:169`, dòng thứ 29 của khối `All`) và nằm trong Master-set review-scope `DbInitializer.cs:2181-2183` — leaf mới ngoài `All` cũng sẽ vắng mặt ở review-scope, tức non-admin không thấy.
- **Đề nghị:** +1 acceptance đo được ("đăng nhập admin → sidebar `Danh mục` có leaf mới → click mở đúng trang `:kind=contract-catalog`") và khai đúng đường grant.

### F-3 [HIGH] K3.⚠️ đảo ngược một quyết định owner @S155 mà không khai — trích dẫn `:44` bị cắt đúng chỗ nói lý do

- Spec dòng 180 viết: "…(KHÔNG có nhánh AllowApproverFinalize; MEMORY-verified **`:44` comment không port CeoApprovalThreshold**)".
- Nguyên văn `src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs:44-46`:
  > `(b) 🔴 KHÔNG PORT 2 nhánh kết thúc sớm (AllowApproverFinalize, CeoApprovalThreshold) và cũng KHÔNG port AllowApproverSkipToFinal (:318-352 nguồn copy) — chủ dự án phán "PHÁ VỠ" @S155. W3 chỉ đường THƯỜNG: PMH → CCM → CEO ký thật.`
- Spec trích 1 trong 3 tên nhánh, và tên bị bỏ đúng là `AllowApproverFinalize` — thứ K3.⚠️(a) định port. Mệnh đề owner-veto cũng bị bỏ.
- **Công bằng với lane:** `spec-4gd-khkk-tong-quat-31-07-2026.md:46` (hàng OG-3) CÓ ghi lời owner @S164 ("*a Chương sẽ quyết định là cái đó cần CEO ko hay là anh ấy đc duyệt*") ⇒ có cơ sở hợp lệ để SUPERSEDE quyết định @S155. Vấn đề là B2-cụm-1 không hề nói đây là đảo quyết cũ, nên người đọc sau sẽ gặp comment `:44-46` và tưởng code đang bị làm trái ý owner.
- **Đề nghị:** K3.⚠️ trích `:44-46` đầy đủ + thêm đúng 1 câu "OG-3 @S164 SUPERSEDE veto @S155", và đưa việc **sửa lại comment `:44-46`** vào danh sách việc của K3.

### F-4 [MED-HIGH] K3 thiếu việc code thứ 4: khối comment `:36-43` sẽ thành lời khai sai ngay khi (a) land

- K3.⚠️ liệt 3 việc (a)(b)(c); (c) chỉ gỡ khoá + note FE `ApprovalWorkflowsV2Page.tsx:151-152`.
- Nhưng `ContractSigningPlanWorkflowService.cs:37-43` đang khai: "🔴 KHAI RÕ: service này có **ĐÚNG MỘT** nhánh set DaDuyet, KHÔNG có site thứ hai kiểu admin-override của nguồn copy". Và `:292-293` cài sẵn 2 lệnh tự-kiểm:
  ```
  grep -rn "Phase = ContractSigningPlanPhase.DaDuyet" src/Backend --include=*.cs
  grep -rn "ApplyApprovedValuesOnFinalize" src/Backend --include=*.cs
  ```
- Sau khi port nhánh duyệt-sớm, mệnh đề "ĐÚNG MỘT" hoặc vẫn đúng (nếu nhánh mới nhảy vào cùng choke-point) hoặc thành sai (nếu sinh site thứ 2) — **phải chứng, không phải đoán**.
- Đây đúng class lỗi mà chính spec dùng để bắt FE ở K3.⚠️(c) ("không gỡ = config-lie NGƯỢC"), chỉ khác chiều: lần này là comment BE nói "không có" trong khi code đã có.
- **Đề nghị:** thêm acceptance "chạy 2 lệnh `:292-293`, dán output vào PR, hai lệnh phải khớp nhau" + sửa `:36-43` cùng commit K3.

### F-5 [MED] K2.b đọc sai comment `:39`; site `:342` không phải bẫy — và phương án (i) sẽ TẠO RA bẫy đó

- Spec dòng 105 xếp `WorkflowService.cs:342` là "`= targetPhase` (gán qua BIẾN — đúng class bẫy `:39` comment)" rồi gắn "🔴 PHẢI XỬ", đề 2 phương án: (i) gọi `ApplyApprovedAmounts` ngay trước `:342` khi `targetPhase == DaDuyet`, (ii) assert + test khoá hành vi.
- Đọc `:39` trong ngữ cảnh `:36-43`: câu đó đang mô tả **NGUỒN COPY** (`ContractWorkflowService.cs:190-193`), và cả khối khẳng định KHKK **cố ý không có** site đó, lý do nêu ở `:40-42`: "hợp-đồng transition KHKK chỉ nhận 4 action cố định, KHÔNG có tham số `targetPhase`".
- Đo lại từ đĩa, đúng như code khai:
  - `:342` nằm trong `private async Task ReturnOrRejectAsync(...)` khai báo tại `:310`.
  - Toàn repo có **đúng 2 call-site**: `:128-129` truyền literal `ContractSigningPlanPhase.TraLai`, `:133-134` truyền literal `ContractSigningPlanPhase.TuChoi`.
  - Cửa vào là `TransitionAsync(Guid planId, string action, …)` `:86-90` — chữ ký **không có** `targetPhase`; `AllowedActions` `:73` chặn trước mọi truy vấn `:95-96`; switch đóng kín với `default: throw` `:137-139`.
  - Thân hàm còn tự khoá thêm: `:318` `isReturn = targetPhase == TraLai`, `:320-323` chỉ chấp nhận phiếu đang `ChoDuyet`, `:346-347` xoá con-trỏ, `:349-355` ghi log "Trả lại/Từ chối".
- ⇒ `targetPhase == DaDuyet` **không reachable hôm nay**. Phương án (i) sẽ mở đường thứ 2 tới `DaDuyet` chạy xuyên thân hàm return/reject (xoá con-trỏ + log sai nhãn) — tức tự tay dựng đúng cái bẫy `:37-43` nói là đã tránh được.
- **Đề nghị:** bỏ (i), giữ (ii); đổi nhãn ô bảng từ "🔴 PHẢI XỬ" thành "an toàn do thiết kế — khoá bằng test hành vi". Test K2.g #6 giữ nguyên, nhưng phải phát biểu là "không action string nào đưa phiếu tới DaDuyet mà bỏ qua freeze".

### F-6 [MED-HIGH] K2 tự mâu thuẫn về `CatalogEntryId` bắt buộc; hệ quả là rào "phiếu phải có hạng mục" không tồn tại ở đâu

Ba câu trong cùng một cụm, đọc liền nhau thì triệt tiêu nhau:
- dòng 87 (K2.a-4): "Chặn phát sinh mới bằng validator: **line MỚI/SỬA bắt buộc có `CatalogEntryId`** (chỉ row legacy được giữ null)".
- dòng 113 (K2.c): auto-gen `Features.cs:389` "mồi `CatalogEntryId = null, TenHangMuc = null`" — tức **mọi** line của **mọi** phiếu mới đều null.
- dòng 126 (K2.d): submit-guard "Line `CatalogEntryId == null` … → **CHO QUA**".
- Đối chiếu code: `ContractSigningPlanFeatures.cs:383-400` auto-sinh line trong chính handler create, `:389` `plan.Lines.Add(new ContractSigningPlanLine { … })`.
- ⇒ Phiếu vừa tạo (100% line null) submit và duyệt trọn được mà không mang hạng mục nào. Mục tiêu K2 ("1 phiếu = 1 nhóm, N hạng mục") không được rào tại bất kỳ tầng nào, còn DoD "line khác nhóm → 409" thì PASS trivially vì tập line-có-catalog rỗng.
- **Đề nghị:** khai rõ luật NOT-NULL chỉ áp cho lines-editor K4b; và hoặc thêm rào submit "≥1 line có `CatalogEntryId`" với acceptance đo được, hoặc defer TƯỜNG MINH sang K4b kèm ghi nợ.

### F-7 [MED] Chứng "2 line NULL cùng (Plan,Supplier) không lọt" hở mắt xích — mắt xích thật nằm ở module khác

- Spec dòng 87 lập luận: "Data cũ AN TOÀN (unique cũ bảo đảm ≤1 row/cặp ⇒ tối đa 1 NULL-row/cặp)". Vế **data cũ** này ĐÚNG: `ContractSigningPlanLineConfiguration.cs:22-24` là `HasIndex(new { ContractSigningPlanId, SupplierId }).IsUnique().HasFilter("[IsDeleted] = 0")`, và index mới cũng filter `[IsDeleted]=0` nên row xoá mềm không tham gia. Migrate sẽ không vỡ.
- Nhưng unique cũ **bị DROP trong chính Mig 71** (K2.a-4) ⇒ sau migrate nó không còn che auto-gen nữa. Thứ thật sự chặn auto-gen sinh 2 line cùng supplier là index ở **module khác**, spec không hề nhắc: `PurchaseEvaluationConfiguration.cs:105` — `b.HasIndex(x => new { x.PurchaseEvaluationId, x.SupplierId }).IsUnique();` (không filtered). Nhờ nó mà vòng lặp `Features.cs:383` (`foreach w in winners`) với `:392 SupplierId = w.SupplierId` không thể ra 2 line trùng.
- Kết luận của spec đúng, nhưng chuỗi chứng đứt: bảo đảm đang được chuyển từ một index **sắp bị xoá** sang một index **chưa từng được nêu tên**.
- **Đề nghị:** cite `PurchaseEvaluationConfiguration.cs:105` như bất-biến chịu lực và khoá bằng 1 test; nếu K4b sau này cho thêm line tay thì bất-biến này hết hiệu lực, phải khai luôn.

### F-8 [MED] Tiền: list DTO có HAI tổng, spec chỉ xử một — sau freeze, list và detail sẽ nói 2 con số khác nhau

- `ContractSigningPlanFeatures.cs:709-713` (projection list): `Count(lines)` `:709` · `Sum(PeReferenceAmount)` `:710-711` · **`Sum(ProposedAmount)` `:712-713`**.
- Bảng 17-consumer K2.f dòng 148 chỉ có `:711`. `:712-713` **không xuất hiện** trong bảng.
- K2.e (dòng 132) định nghĩa lại semantics cho row tách: `PeReferenceAmount = 0`, nhưng không nói `ProposedAmount` của row tách bằng bao nhiêu ⇒ tổng đề-xuất trên list hoặc tụt hoặc nhân đôi, không dấu vết.
- List cũng không có tổng `ApprovedAmount`. Sau `DaDuyet`, detail hiện `ApprovedAmount` (display-gate `budgetFrozen`, K2.b dòng 107) còn list vẫn hiện `ProposedAmount` ⇒ hai màn hai con số cho cùng một phiếu đã chốt.
- **Đề nghị:** thêm `:712-713` vào bảng consumer; quy định `ProposedAmount` của row tách; và cho list đọc `ApprovedAmount ?? ProposedAmount` khi phiếu đã đóng băng (hoặc khai tường minh là chấp nhận lệch).

### F-9 [MED] Claim "hit `IgnoreQueryFilters` đầu tiên trong src/Backend — S155 đo 0-hit" là SAI

- Spec dòng 38 (K1.b): "(Đây là hit `IgnoreQueryFilters` **đầu tiên** trong src/Backend — S155 đo 0-hit — cố ý, khai trong PR.)"
- Đo lại: **29 occurrence / 5 file** trong `src/Backend`:
  `SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs` 1 · `SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs` 18 · `SolutionErp.Application/Contracts/ContractFeatures.cs` 3 · **`SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs` 3** (đúng file cụm-1 sẽ sửa) · `SolutionErp.Application/PurchaseEvaluations/PeSoftDeleteFeatures.cs` 4.
- Kèm theo: `PurchaseEvaluationsController.cs:186` cũng đang khai "DUY NHẤT dùng `IgnoreQueryFilters()` trong toàn `src/Backend` (D3)" — hai claim độc quyền cùng sai, claim của spec nhiều khả năng thừa kế từ claim này.
- Nội dung kỹ thuật vẫn ĐÚNG và đáng giữ: seeder phải `IgnoreQueryFilters()` mới né được #75/#76, còn khuôn `SeedRealMasterDataAsync` thì đúng là không có (`DbInitializer.cs:2970` `var existingProjectCodes = await db.Projects.Select(p => p.Code).ToListAsync();` — không IgnoreQueryFilters, đúng như spec mô tả). Chỉ cái nhãn "đầu tiên" là sai.
- **Đề nghị:** bỏ nhãn "đầu tiên / S155 đo 0-hit"; nếu tiện thì ghi errata cho `PurchaseEvaluationsController.cs:186`.

### F-10 [LOW-MED] Ba citation trỏ vào USE-SITE trong khi việc phải sửa nằm ở DECLARATION

| Spec nói | Thứ ở đó | Declaration thật |
|---|---|---|
| `LineDto :570-574` +2 field (dòng 131) | projection `.Select(l => new ContractSigningPlanLineDto(…))` | `public record ContractSigningPlanLineDto(` — `Features.cs:59` |
| `DetailDto :608-616` +2 field (dòng 131) | lời gọi ctor `return new ContractSigningPlanDetailDto(` `:608` | `public record ContractSigningPlanDetailDto(` — `Features.cs:119` |
| `types/khkk.ts:203` `KhkkLineDto` +2 field (dòng 149) | field `lines: KhkkLineDto[]` của interface Detail | interface `KhkkLineDto` (chỗ khác trong file) |

Ngược lại 2 citation cùng loại thì chuẩn: `ContractSigningPlanListItemDto` `:34` (đúng decl) và `ListContractSigningPlansQuery` `:622-626` (đúng decl). **Đề nghị:** với mỗi DTO ghi CẶP (decl + mọi construction site), vì sửa 1 nơi thì không biên dịch được.

### F-11 [LOW] Đường entity K1.a không nằm "cạnh WorkItem"

- Spec dòng 11: `src/Backend/SolutionErp.Domain/Master/ContractCatalogEntry.cs` "(cạnh WorkItem — folder Domain/Master hiện hữu)".
- Đĩa: `Domain/Master/` = `Catalogs/`, `Department.cs`, `Project.cs`, `Supplier.cs`, `SupplierStatus.cs`, `SupplierType.cs`. `WorkItem.cs` nằm ở `Domain/Master/Catalogs/` (cùng `MaterialItem.cs`, `ServiceItem.cs`, `UnitOfMeasure.cs`).
- Ảnh hưởng thật: namespace `SolutionErp.Domain.Master` vs `SolutionErp.Domain.Master.Catalogs` ⇒ `using` ở Configuration, Features, `ApplicationDbContext`, `IApplicationDbContext`. **Đề nghị:** chốt `Domain/Master/Catalogs/ContractCatalogEntry.cs` (Application path `Application/Master/Catalogs/` mà spec chọn thì đã đúng khuôn).

### F-12 [LOW] Index-swap cite lệch 2 dòng

- Spec dòng 86: "DropIndex unique cũ … (tên EF-sinh — lấy đúng từ `ContractSigningPlanLineConfiguration.cs` vùng **:24-26**)".
- Đĩa: unique cũ ở **`:22-24`**; `:25` là `HasIndex(x => x.SupplierId)`, `:26` là `HasIndex(x => x.ContractId)` — hai index thường, không liên quan. Đi theo `:24-26` là đụng nhầm.
- Cùng mục: "`:29 WithMany(p => p.Lines)` giữ nguyên" ✅ chính xác.

### F-13 [MED] "Item 6" của Mig 71 là tham chiếu treo — và trả lời câu hỏi (c) "hợp lý hay trộn concern"

- K3.⚠️(b) dòng 181: "gắn vào **Mig 71 item 6 (K2.a)**". §Thứ tự commit dòng 242: "Mig 71 gồm cả **item 6** `EndedByLevelFinalize`".
- K2.a (dòng 83-88) liệt **1..5**. Không có item 6. Người làm K2 mà chỉ đọc K2.a sẽ không thêm cột ⇒ K3 mất tiền đề "0-mig" và test K3.d #5 (`EndedByLevelFinalize=1`) fail.
- **Trả lời (c): là TRỘN CONCERN**, ba lý do đo được: (1) tên mig `AddContractSigningPlanGroupAndLineCatalog` không phủ cột này, mà tên mig là nhãn duy nhất người sau đọc trong `__EFMigrationsHistory`; (2) giữa commit K2 và commit K3 cột là ghost — defined-but-unread, đúng class ghost-wire; (3) chính spec phải viết thêm 2 chỗ cross-ref để bù, và đã hụt 1 chỗ (đây).
- Vẫn chấp nhận được NẾU làm đủ 3 việc: liệt item 6 tường minh vào K2.a · đổi tên mig cho phủ · khai ghost-window trong PR. Tách Mig 72 thì sạch concern hơn nhưng tốn 1 mig — trade-off để lead chốt, không phải lỗi.

### F-14 [MED] Roster K3: acceptance chỉ đo BAO NHIÊU, không đo AI ⇒ chọn sai người vẫn PASS trọn bộ

- 11/11 mapping email trong bảng K3.a **đối chiếu đúng** `DbInitializer.cs:1613-1632`, kể cả cảnh báo bẫy `long.chau@` vs `long.nguyen@` (`:1616` vs `:1629`) — phần này lane làm tốt.
- Nhưng cơ chế resolve cho ô ⚠️ "Trưởng" **không đủ an toàn**:
  - Ứng viên thật trong DB: `truong.le@` "Lê Trần Đăng Trường" `:1626` · `tring.le@` "Lê Tự Đăng Trình" `:1625` · và còn `anh.nguyen@` "Nguyễn Thị Kim Ánh" `:1624` mà spec không nhắc (CCM có 6 NV `:1621-1626`, roster chọn 4).
  - Fail-soft theo khuôn `:178-183` chỉ bắt email **không tồn tại**. Chọn nhầm một email **có tồn tại** thì seed 8/8 thành công ⇒ K3.e mục 2 (đếm 8) PASS, mục 4/5 PASS ⇒ sai người duyệt lọt trọn vẹn. Không mục acceptance nào của K3.e hỏi "ai".
  - Test K3.d #2 `SeedKhkk_Shape_PerWorkflow` chỉ đếm hình dạng (Order1×4 + Order2×1), không assert danh tính 11 email.
- Cặp nhập nhằng thứ 2 không được gắn ⚠️: PRO "Thanh" → `thanh.lethanh@` "Lê **Thanh Bình**" `:1615`, trong khi CCM "Binh" → `binh.le@` "Lê Văn **Bình**" `:1621`. Nếu roster gốc ghi "Bình" thì cùng lớp nhập nhằng như "Trưởng".
- Thêm một lỗ của khuôn được sao chép: `:185` `var ccmDept = await db.Departments.FirstOrDefaultAsync(d => d.Code == "CCM");` **không có null-guard**. K3 cần cả `PRO`/`CCM`/`BOD`, mà fail-soft spec mô tả chỉ phủ user.
- **Đề nghị:** (1) test #2 assert đủ 11 email theo bộ ba (Code, StepOrder, LevelOrder), không chỉ đếm; (2) chốt ô ⚠️ TRƯỚC khi viết seed, không để thành hậu-kiểm; (3) fail-soft nên log ĐỦ email thiếu thay vì `return` ở email hỏng đầu tiên (một email hỏng hiện giết cả 8 workflow); (4) phủ dept vào fail-soft.

### F-15 [LOW] Picker đổi nghĩa nhưng giữ tên cũ; `existingGroups` là collection-projection trong ctor record

- `Features.cs:1199-1202` hiện loại PE đã có plan (`:1201-1202` — spec cite chuẩn). K2.c bỏ vế loại-trừ ⇒ DTO `ApprovedPeAwaitingPlanDto` (`:150`) không còn "AwaitingPlan" nữa; tên thành sai nghĩa cho cả BE lẫn FE.
- `existingGroups: int[]` là subquery collection tương quan đặt trong `select new ApprovedPeAwaitingPlanDto(…)` — cần kiểm dịch EF Core trước khi cam kết (rủi ro không translate / client-eval), hoặc lấy bằng 1 query phụ rồi ghép.

## Những claim CHỊU ĐƯỢC soi (khai để lead khỏi soi lại)

1. **QUYẾT-1 catalog authz — ĐÚNG 4/4 vế.** `CatalogsController.cs:13` `[Route("api/catalogs")]` · `:14` `[Authorize]` class-level · **12** action ghi đều `[Authorize(Roles = "Admin")]` tại `:23,31,40,53,61,70,83,91,100,113,121,130` (đúng dải `:23-:130` spec nói) · grep `Authorize(Policy = "(WorkItems|MaterialItems|UnitsOfMeasure|ServiceItems)` = **0 hit**. Comment `:9` của chính controller cũng khai cùng ý ("Authorize = đăng nhập đủ … ghi cần Admin"). Lý do (ii) của spec (picker cần READ rộng, khoá `KeHoachKyKet.Read` sẽ 403) khớp gotcha #85.
2. **Chứng cứ máy-freeze — ĐÚNG từng dòng.** `ContractSigningPlanLine.cs:17-19` comment snapshot + `:20 PeReferenceAmount` · `:23 ApprovedAmount` kèm chú "CHỈ ghi tại choke-point finalize (W3)" · `WorkflowService.cs:277 ApplyApprovedValuesOnFinalize(plan);` đứng **trước** `:278 plan.Phase = DaDuyet` · `:301-305` helper với `??=` · `:288-298` khối LUẬT, trong đó `:290` đúng nguyên văn spec trích.
3. **Liệt kê site gán Phase — ĐỦ, không sót.** Grep `Phase\s*=[^=]` toàn repo cho KHKK ra đúng 4 write-site vào `plan.Phase`: `Features.cs:373` (initializer, không có dấu chấm — spec cảnh báo đúng), `WorkflowService.cs:174`, `:278`, `:342`. Phần còn lại là đọc (`:173/:245/:341`), là `Changelog.FromPhase/ToPhase` (`:493-494`), là comment (`:39/:292`), hoặc nằm trong tests. DbInitializer 0 site — đúng như spec khai.
4. **Citation khác đều chuẩn:** `Features.cs:350` + `:460` (2 call-site `EnsureWorkflowTypeAsync`, spec ghi `:458-462` — nằm trong dải) · `:500` Include Lines · `:524` supplierIds · `:568-574` projection line · `:622-626` query record · `:711` Σ PeReference · `:1201-1202` picker · `DbInitializer.cs:174-176` idempotent per-type · `:178-183` fail-soft · `:1780-1787` Khkk_G1 + 6 leaf · `:1801` node ApprovalWorkflowsV2 · `:2970` khuôn thiếu IgnoreQueryFilters · `Layout.tsx:166-172` resolvePath 3-code + `:174 return null` (rớt im lặng #50 — đúng) · `ApprovalWorkflowsV2Page.tsx:147` slot 10, `:151-152` note khoá, `:164-171` DEFAULT_CODE_BY_TYPE · `KhkkCreatePage.tsx:8` "FE KHÔNG gửi lines" + `:56-66` lọc `isUserSelectable` · `KhkkDetailPage.tsx:425-441` bảng lines (lưu ý: thêm cột phải sửa cả `<td>` ở `:443+`, ngoài dải cite) · `types/khkk.ts:203` · `ApprovalWorkflow.cs:158 AllowApproverFinalize` có sẵn (đúng: 0 mig cho cột này).
5. **Số canonical khớp `docs/STATUS.md:6`** (Mig **69** · **96 bảng** · **590 test** · Policies 220 · Menu keys 55) ⇒ "96→97", "69→70→71", "baseline +17" đều nhất quán.

## Verdict

**PASS_WITH_BLOCKERS** — spec cụm-1 có nền chắc (citation 23/25 chuẩn, phần chứng-cứ freeze và authz mirror miễn nhiễm soi), nhưng **không được implement nguyên trạng**.

- **2 blocker phải sửa trước khi viết dòng code nào:** F-1 (8 menu-con K3 ngoài `All` ⇒ sidebar chết, chính acceptance K3.e sẽ FAIL) và F-2 (K1.d cùng lỗi, K1.f mù không bắt được).
- **4 phải sửa trong spec trước khi giao:** F-3 (khai việc đảo quyết owner @S155 + trích đủ `:44-46`), F-4 (việc code thứ 4: comment `:36-43` + 2 lệnh tự-kiểm `:292-293`), F-6 (mâu thuẫn `CatalogEntryId` bắt buộc ⇄ auto-gen null ⇒ rào hạng mục không tồn tại), F-13 (item 6 treo + tên Mig 71 không phủ).
- **1 phải gỡ khỏi spec:** F-5 phương án (i) — nó dựng đúng cái bẫy mà `:37-43` nói đã tránh; giữ (ii).
- Còn lại F-7…F-12, F-14, F-15 là siết chặt + sửa nhãn.
- Câu trả lời gọn cho 6 câu hỏi giao: (a) **ĐÚNG** · (b) chứng-cứ **ĐÚNG**, disposition site `:342` **SAI** · (c) **TRỘN CONCERN**, chấp nhận được kèm 3 điều kiện · (d) kết luận **ĐÚNG** nhưng chuỗi chứng **HỞ**, và "line mới bắt buộc có CatalogEntryId" **mâu thuẫn** K2.c · (e) mapping **ĐÚNG 11/11**, cơ chế resolve **KHÔNG đủ an toàn** · (f) 17/17 dòng cite **chuẩn**, nhưng **thiếu** `:712-713` Σ ProposedAmount.
