REVIEWER-GATE-K1: PASS-WITH-FLAGS — 9 finding

# Reviewer gate K1 — ContractCatalogEntries (SP-002 danh mục loại HĐ)

Ngày: 2026-07-31 · vai: reviewer (adversarial, read-only prod-code)
Artifact ghi LIÊN TỤC chống #53 — mỗi finding đổ ngay khi có.

## Trạng thái các bước
- [x] B1 Đọc spec cụm-1 + transcribe + 3 lane return
- [x] B2 dotnet build tươi — **Build succeeded · 0 Warning(s) · 0 Error(s) · Time Elapsed 00:00:44.23** (6 project)
- [x] B3 dotnet test — **45 Domain + 552 Infra = 597 PASS / 0 FAIL / 0 SKIP** (đúng kỳ vọng, 0 flaky, không phải chạy isolation)
- [x] B4 Đối chiếu code-vs-spec (a→h)
- [x] B5 Ranh CẤM
- [x] B6 Xác nhận 2 khoảng-hở UX

## Log

### B2 build (nguyên văn)
```
Build succeeded.
    0 Warning(s)
    0 Error(s)
Time Elapsed 00:00:44.23
```
6 project: Domain · Domain.Tests · Application · Infrastructure · Api · Infrastructure.Tests.

### B4-a Entity 10 cột — ĐẠT
`ContractCatalogEntry.cs:15-24` = Code · TenVi · TenEn? · GroupCode · ApprovalGroup · SignerRole · InitialsChain? · NduqNote? · IsActive · SortOrder = **10 cột** + 7 audit từ `AuditableEntity`. Khớp bảng spec `sub-invest-fable-b2-cum1.md:13-26`.

### B4-b Config + Mig — ĐẠT (1 nit tên index)
`ContractCatalogEntryConfiguration.cs:27` unique filtered `[IsDeleted] = 0` (#57) · `:28` index ApprovalGroup · `:29` HasQueryFilter.
Mig `20260731072441_AddContractCatalogEntries.cs`: 1 CreateTable + 2 CreateIndex, Down = DropTable. **0 RenameColumn**.
NIT: spec `:28` đặt tên `UX_ContractCatalogEntries_Code`, code để EF-default `IX_ContractCatalogEntries_Code` (`Mig:48`). Không ảnh hưởng chức năng.

### B3 test (nguyên văn)
```
Passed!  - Failed:     0, Passed:    45, Skipped:     0, Total:    45, Duration: 342 ms - SolutionErp.Domain.Tests.dll
Passed!  - Failed:     0, Passed:   552, Skipped:     0, Total:   552, Duration: 1 m 7 s - SolutionErp.Infrastructure.Tests.dll
```
Tổng **597 PASS / 0 FAIL** = baseline 590 + 7 test ContractCatalog. Không có fail lẻ ⇒ KHÔNG cần chạy isolation.

### B4-c Seed 86 — ĐO MÁY, KHÔNG tin lời khai (ĐẠT tuyệt đối)
Script parse trực tiếp `DbInitializer.cs` vùng `SeedContractCatalogAsync` (regex tuple 6-thành-phần):
- total rows = **86** · unique codes = **86** · dup codes = **[]**
- by GroupCode = A1:5 A2:1 A3:9 A4:5 B1:23 B2:14 B3:15 C1:6 C2:3 C3:4 CEO:1
- by ApprovalGroup = **1:5 · 2:10 · 3:5 · 4:23 · 5:14 · 6:15 · 7:13 · 8:1** → khớp CHÍNH XÁC spec `sub-invest-fable-b2-cum1.md:41` (5/10/5/23/14/15/13/1)
- empty TenEn = 0 (86/86 có tên EN) · dup SortOrder trong cùng GroupCode = none
- max len: Code 6≤20 · TenVi 153≤500 · TenEn 174≤500 · GroupCode 3≤10 → 0 nguy cơ truncation

Đối chiếu TỪNG DÒNG với `danh-muc-sp002-transcribe.md` (86 row parse từ markdown):
- code-set diff 2 chiều = **rỗng cả 2 chiều**
- text mismatch = **8 dòng, TẤT CẢ là B2-07..B2-14 bỏ chú-thích `(ảnh: …)`** — đúng lệch mà lane BE đã khai (`sub-implbe-k1.md:16`, mục 3). Không có mismatch nào ngoài tập đã khai.
- ApprovalGroup vs heading transcribe: **0 mismatch**
- signByGroup 8 nhóm khớp bảng transcribe `:9-16`, ô "N/A" bỏ đúng (N3 3 ô · N4 2 ô · N7 3 ô · N8 2 ô).

### B4-d Gate seeder — ĐẠT
`DbInitializer.cs:129-131` call-site NẰM NGOÀI mọi `if (!demoSeedDisabled)` ⇒ UNGATED (đúng spec K1.b, gotcha #51). `IgnoreQueryFilters()` có mặt khi dò existing (né #75/#76 resurrect).

### B4-e MenuKeys + drift DELTA — ĐẠT (1 finding kéo theo ở B4-f)
Đo máy trên `MenuKeys.cs` (parse vùng mảng `All`, strip comment, tách dấu phẩy):
- `All count = 56` · `has ContractCatalog = True` · `Actions = [Read, Create, Update, Delete]` = 4 → **policies derived = 224**.
- Diff `MenuKeys.cs:28-31` const + `:173` VÀO `All` ⇒ đúng vá-1 spec-cum1 `:8` (ngoài All = 0 permission row = sidebar chết).
- Grant Admin: loop `SeedAdminPermissionsAsync` `DbInitializer.cs:2064-2066` nguồn = `MenuKeys.All` ⇒ tự động, 0 code thêm. Menu row `DbInitializer.cs:1773-1776` leaf order 5 dưới `Catalogs`, đi nhánh INSERT (key mới) nên không dính bẫy gotcha #11.
- 🔴 **Drift DELTA CHƯA vào doc:** `docs/STATUS.md:6` hiện vẫn `Mig 69 · 96 bảng · 590 test PASS · Policies 220 · Menu keys 55`. Acceptance spec-cum1 ③ đòi "drift DELTA khai cùng commit" ⇒ **F-2**.

### B4-f CQRS + Controller authz — ĐẠT-theo-khuôn, nhưng khoảng-hở phải khai
Soi TỪNG action (không nhận `[Authorize]` trần làm bằng chứng):

| Action | Attribute thật | File:line |
|---|---|---|
| class | `[Authorize]` trần | `ContractCatalogController.cs:22` |
| GET list | (thừa hưởng class) | `:25` |
| GET id | (thừa hưởng class) | `:34` |
| POST | `[Authorize(Roles = "Admin")]` | `:39` |
| PUT | `[Authorize(Roles = "Admin")]` | `:47` |
| DELETE | `[Authorize(Roles = "Admin")]` | `:56` |

Khuôn đối chứng `CatalogsController.cs`: `:13` route · `:14` `[Authorize]` trần · 12 action ghi đều `[Authorize(Roles="Admin")]` (`:23,31,40,53,61,70,83,91,100,113,121,130`) ⇒ **mirror khuôn CHÍNH XÁC**, đúng QUYẾT spec K1.c `:47`. **KHÔNG có OR key con** (gotcha #85 N-A: controller này không dùng policy nào).
🔴 Đo tiếp: `grep -rn "ContractCatalog\." src/Backend --include=*.cs | grep -i policy` → **0 endpoint** dùng 4 policy vừa sinh. Hệ quả đo được (lớp gotcha #82): ô CRUD `ContractCatalog` trên ma trận phân quyền chỉ điều khiển **hiển thị menu**, KHÔNG điều khiển API — tick `CanCreate` cho CatalogManager vẫn 403 (`Roles="Admin"`), bỏ tick `CanRead` vẫn GET được (class `[Authorize]` trần). ⇒ **F-3**.

Validator (`ContractCatalogFeatures.cs:110-124` / `:168-183`): MaxLength lấy ĐÚNG từ EF config (20/500/500/10/100/300/500) — đối chiếu `ContractCatalogEntryConfiguration.cs:18-24` khớp 7/7. `ApprovalGroup` `InclusiveBetween(1,8)` cả Create lẫn Update. Dup-Code chạy trên active-set (`:131`, `:193`) ⇒ khớp index filtered.
🔸 `ListContractCatalogQuery` (`:42-46`) mặc định **KHÔNG lọc IsActive** ⇒ trả CẢ dòng đã tắt; spec K1.c `:50` ghi tham số `IncludeInactive?` (ngữ nghĩa mặc-định-loại). ⇒ **F-4**.

### B4-g FE 2 app — ĐẠT (đối xứng đo bằng hash)
- `CatalogsPage.tsx` SHA-256 **d71a9e3bb17b835b85b4f3467905af2ab414f52e3ec0c58e79d55ef57673985a** ×2 app IDENTICAL → **khớp claim lane FE `sub-implfe-k1.md:49`**.
- `menuKeys.ts` + `Layout.tsx` DIVERGE cả-file (đúng như lane khai) ⇒ đo hash **phần THÊM VÀO**: `menuKeys.ts` admin=`ab97ed56b743f262…` user=`ab97ed56b743f262…` **MATCH** · `Layout.tsx` admin=`4ed4ea6b6f059c0e…` user=`4ed4ea6b6f059c0e…` **MATCH**.
- Pattern 16-bis 4 chỗ: (1) page OK · (2) route `/master/catalogs/:kind` CÓ SẴN `fe-user/src/App.tsx:63` + `fe-admin/src/App.tsx:69` ⇒ đúng "không cần route mới" · (3) `menuKeys.ts` ×2 · (4) `Layout.tsx` staticMap ×2 (thiếu chỗ 4 = sidebar drop im lặng, gotcha #50).
- KIND_CONFIG kind-5: 10 field khớp ĐÚNG 10 field `CreateContractCatalogEntryCommand` · PUT gửi `{ id: form.id, ...body }` (`CatalogsPage.tsx:164`) ⇒ qua được chốt `id != body.Id → 400` (`Controller:50`) · `buildBody` ép `numeric` → 0 (BE `int` non-nullable) · `openCreate` mồi select N1 (né `InclusiveBetween`).
- 🔸 Page **0 hit** `PermissionGuard`/`usePermission` ⇒ nút Thêm/Sửa/Xóa hiện cho mọi user vào được trang (khuôn 4 kind cũ y vậy) ⇒ **F-5**.
- 🔸 `CatalogsPage.tsx:218` ×2 app: comment `{/* Sub-tabs cho 4 kind */}` còn "4" trong khi `KINDS` đã 5 ⇒ **F-7** (nit).

### B4-h Test — ĐẠT (7 [Fact], có T1b thật)
Đếm máy `[Fact]` = **7**: T1 `Seed_RunTwice_Keeps86Rows_NoDuplicateCode` · **T1b `Seed_AfterSoftDeleteOfOneCode_DoesNotResurrectIt`** · T2 phân-bố 8 nhóm + section + denorm-đồng-nhất · T3 unique filtered 2 CHIỀU · T4 validator biên 0/9/-1/99 ⟂ 1/4/8 · T5 vòng đời handler thật (soft-delete + tái dùng mã) · T6 menu-key ∈ All + MenuItem row + grant Admin.
T1b có RĂNG thật (không phải nhãn): fixture RIÊNG wire `AuditingInterceptor` (`:83`) ⇒ `Remove()` là xoá MỀM thật; có sanity chống-vacuous `raw.IsDeleted.Should().BeTrue()` (`:179`); assert 85 active **VÀ** 86 tổng `IgnoreQueryFilters` (`:185-190`) ⇒ gỡ `IgnoreQueryFilters()` khỏi seeder thì tổng thành 87 và test ĐỎ. Đúng phép đo cho #75/#76.
T6 còn cài invariant TOÀN BỘ `All`: `MenuKeys.All.Except(menuKeysInDb).Should().BeEmpty()` (`:414`) — bắt lỗi key ∈ All mà thiếu MenuItem row (FK nổ lúc boot).

### B5 Ranh CẤM — SẠCH
- `git status --porcelain | grep -cE "ContractSigningPlan"` → **0**.
- `git diff --stat` cho `Infrastructure/Services/ContractSigningPlanWorkflowService.cs` + `Application/Contracts/ContractSigningPlanFeatures.cs` → **rỗng** (0 thay đổi) ⇒ `WorkflowService.cs:342` KHÔNG bị đụng · vùng `Features.cs:342-347` (việc của K2+) KHÔNG bị đụng — đúng vá-2 spec-cum1 `:9`.
- Scope toàn diff: **17 file** (11 M + 6 untracked) = 6 FE + 10 BE + 1 test. 0 file ngoài phạm vi K1.
- 3-file rule Mig (gotcha #17): `20260731072441_AddContractCatalogEntries.cs` + `.Designer.cs` (cả 2 untracked, có trên đĩa) + `ApplicationDbContextModelSnapshot.cs` (modified, +83 dòng, có `ContractCatalogEntry`) = **đủ 3, cùng commit-set**; `grep -c RenameColumn` = **0**.

### B6 Hai khoảng-hở UX lane FE khai — XÁC NHẬN ĐÚNG THỰC TRẠNG (không quyết thay lead/owner)
1. **3 filter BE chưa có UI** — ĐÚNG. BE nhận 4 tham số (`groupCode`/`approvalGroup`/`isActive`/`q` — `Controller:27-30`), FE chỉ gửi `q` (`CatalogsPage.tsx:158`) ⇒ 3/4 filter chưa có đường bấm từ UI.
2. **Tab hiện cả fe-user** — ĐÚNG. `KINDS` render vô điều kiện 5 sub-tab, route `:kind` mở ở CẢ 2 app (`fe-user/src/App.tsx:63`), GET là `[Authorize]` trần ⇒ user thường vào `/master/catalogs/units` sẽ thấy tab "Danh mục Hợp đồng" và đọc được 86 dòng, dù leaf menu chỉ Admin được grant. Cùng lớp với F-3.

## FINDING (9 — 0 blocker kỹ-thuật; F-1 chờ lead/owner disposition trước khi commit seed)
1. **[HIGH · gate quy-trình]** `DbInitializer.cs:3147-3324` — 86 dòng seed đang nằm CÙNG change-set với code, trong khi spec đặt PRE-gate `[OG-6]` (owner soát transcribe) **TRƯỚC khi land seed** và kế hoạch tách 2 commit (`sub-invest-fable-b2-cum1.md:7` + `:240-241`: "Commit K1-seed (SAU [OG-6] anh gật)"). Nặng thêm vì seeder per-Code idempotent **không có nhánh update**: owner sửa ô sau khi seed đã chạy thì row cũ KHÔNG tự cập nhật (lane tự khai `sub-implbe-k1.md:51`). Tiêu chí đóng: hoặc owner gật OG-6 trước commit, hoặc tách seed sang commit riêng giữ chờ gật.
2. **[MEDIUM]** `docs/STATUS.md:6` — drift DELTA chưa vào doc: Mig 69→**70** · 96→**97** bảng · 590→**597** test · Policies 220→**224** · Menu keys 55→**56**. Acceptance spec-cum1 ③ đòi khai **cùng commit**.
3. **[MEDIUM · lớp #82]** `ContractCatalogController.cs:22,39,47,56` — 4 policy `ContractCatalog.{Read,Create,Update,Delete}` sinh ra do key vào `All` nhưng **0 endpoint tiêu thụ**; ô CRUD trên ma trận phân quyền chỉ điều khiển hiển-thị-menu, không điều khiển API (tick CanCreate vẫn 403 · bỏ tick CanRead vẫn GET được). KHÔNG phải lệch spec (K1.c QUYẾT mirror khuôn `CatalogsController.cs:14`) nhưng phải khai thành giới-hạn, đừng để lượt sau đọc ma trận thành "đã khoá API".
4. **[LOW]** `ContractCatalogFeatures.cs:42-46,59-60` — List mặc định trả CẢ dòng `IsActive=false`; entity khai mục đích cột này là "ẩn khỏi picker" (`ContractCatalogEntry.cs:23`) và spec K1.c `:50` đặt tham số `IncludeInactive?` (mặc-định-loại). Ngữ nghĩa hiện tại ngược ⇒ consumer K2 (picker) phải nhớ truyền `isActive=true`, quên là dòng đã tắt hiện lại.
5. **[LOW]** `fe-{admin,user}/src/pages/master/CatalogsPage.tsx` — 0 hit `PermissionGuard`/`usePermission`: nút Thêm/Sửa/Xóa render cho mọi user vào được trang, bấm mới nhận 403. Pre-existing của khuôn 4 kind cũ, không phải hồi quy do K1.
6. **[NIT]** `20260731072441_AddContractCatalogEntries.cs:48` — tên index `IX_ContractCatalogEntries_Code` (EF default) trong khi spec `sub-invest-fable-b2-cum1.md:28` đặt `UX_ContractCatalogEntries_Code`. Chức năng không đổi (unique + filter đúng).
7. **[NIT]** `fe-{admin,user}/src/pages/master/CatalogsPage.tsx:218` — comment `{/* Sub-tabs cho 4 kind */}` còn "4" trong khi `KINDS` đã 5 kind.
8. **[INFO · lệch đã khai]** `DbInitializer.cs` B2-07..B2-14 — 8 dòng bỏ hậu tố `(ảnh: …)` so với transcribe; đo máy xác nhận **đây là 8 mismatch DUY NHẤT** trên 86 dòng, đúng tập lane đã khai (`sub-implbe-k1.md:16`). Bản chất là ghi-chú người chép, nhưng vẫn thuộc phạm vi OG-6 owner soát.
9. **[LOW]** Acceptance K1.f `:70-71` (runtime) chưa đo: Mig 70 chưa áp Dev-DB (lane khai `sub-implbe-k1.md:48-49`), chưa có log "Seeded 86…" + `sqlcmd COUNT(*)=86` + `GROUP BY ApprovalGroup`. T1/T1b thay được phần logic-idempotent trên SQLite nhưng KHÔNG thay được "SQL Server nhận đúng filtered index + boot thật" ⇒ đừng tick 2 ô đó cho tới khi chạy API.

## Bằng chứng số (nguyên văn)
- build: `Build succeeded. · 0 Warning(s) · 0 Error(s) · Time Elapsed 00:00:44.23`
- test: `Failed: 0, Passed: 45` (Domain) + `Failed: 0, Passed: 552` (Infrastructure) = **597 PASS / 0 FAIL / 0 SKIP**
- seed: 86 row · 86 mã duy nhất · 5/10/5/23/14/15/13/1 · 0 mismatch ngoài 8 dòng đã khai
- MenuKeys.All = 56 · policies derived = 224 · [Fact] = 7 · RenameColumn = 0 · file KHKK bị chạm = 0

END sub-reviewer-gate-k1 — 9 finding · build+test XANH tươi · ranh CẤM sạch
