GATE-K4C: PASS — 0 blocker · 1 CLARIFY (flake 1/11 trên test CŨ, không tái lập) · 3 MINOR

# GATE-K4C — DONE

> Reviewer adversarial gate, wave K4c (chốt cụm-2), S166. Ghi TỪNG MỤC ngay khi đo xong (chống #53: file luôn hợp-lệ giữa chừng).
> Verdict CHỈ hợp-lệ khi có dòng `END` cuối file. Chưa có END = lượt chạy chưa xong, ĐỪNG đọc verdict.

## 0. Bối cảnh nhận từ lead

- Diff CHƯA commit, lead khai HEAD `ce35f90`, 2 file:
  - `fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx` (+68/−5) — WfView lọc nhóm client-side
  - `tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanCrudTests.cs` (+1 Fact) — MAJOR-A `/deleted` lọc server-side
- Lane artifacts: `sub-implfe-k4c.md` (DONE, 21/21 + 2 control-âm, 2 FLAG minor) · `sub-test-k4a.md` §MAJOR-A (8/8, RED→GREEN 2 chiều)

## 1. Đo — điền dần

### 1.0 Phạm vi diff (đo, không tin khai)

- HEAD thật lúc review = `2023f41` (`wal: flush 20260801T1045`), **KHÔNG** phải `ce35f90` như lead khai — `ce35f90` là commit code cuối (K5), sau đó có 1 commit `wal:` chèn vào. Không ảnh hưởng nội dung diff (2 wal-commit không đụng `src|tests|fe-*`), nhưng nhãn base trong spec lead SAI 1 nấc.
- `git diff --numstat` (working tree vs HEAD), lọc cây code:
  - `fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx` **+68 / −5** ✅ khớp khai.
  - `tests/.../ContractSigningPlanCrudTests.cs` **+106 / −2** (lead khai "+1 Fact" — đúng về SỐ FACT, nhưng LOC lớn hơn vì còn sửa helper `CreateAsync` + thêm helper `ListDeletedAsync`; không phải chỉ-thêm-1-Fact thuần).
- `git status --porcelain -- src tests fe-user fe-admin` = đúng 2 dòng ` M` trên, **0 dòng `??`** ⇒ cây code KHÔNG phình.
- `??` toàn repo = **1** file duy nhất: chính artifact này (`sub-reviewer-gate-k4c.md`). 8 file `M` còn lại đều là agent-memory / run-artifact / `.session-counter.json` — ngoài cây code, không tính scope drift.
- fe-admin: **0 diff** ✅ (không có dòng nào của `fe-admin` trong git status).

### 1.1 SOI-1 — lọc client-side WfView

- Vị ngữ: `allWorkflows.filter(wf => wf.code === khkkGroupWorkflowCode(khkkGroup))`, `khkkGroupWorkflowCode = n => \`KHKK-N${n}\`` (`fe-user/src/types/khkk.ts:163`). Khớp **BE**: seeder `DbInitializer.cs:609` `var code = $"KHKK-N{n}"` ✅ và rào (v) `ContractSigningPlanFeatures.cs:420` regex `^KHKK-N([1-8])$` ✅. **Cả 2 số dòng trích trong comment đều ĐÚNG** (đã grep, không trượt).
- `AwDefinitionDto.code` tồn tại (`fe-user/src/types/approvalWorkflowV2.ts:41`) ✅. Lọc theo `code` (không theo `id`/`name`) ⇒ giữ đủ mọi **version** cùng nhóm — đúng như comment khai, và đúng với `history: AwDefinitionDto[]`.
- Guard `/^[1-8]$/` — ca `"03"`: regex TRƯỢT (đòi đúng 1 ký tự) ⇒ `khkkGroup = null` ⇒ **bỏ lọc, hiện tất cả**, KHÔNG bị `Number("03")=3` nuốt thành nhóm 3. **Lane khai đã xử = ĐÚNG, verify độc lập xong.**
  - Đồng nhất 3 chỗ: `KhkkListPage.tsx:80` và `KhkkCreatePage.tsx:67` dùng **y hệt** `/^[1-8]$/` ⇒ không đẻ luật thứ 2 cho cùng một tham số.
  - Đường sinh link không bao giờ đẻ `"03"`: `Layout.tsx:216` lấy `group` từ capture của regex `^Khkk_G([1-8])_(...)$` ⇒ luôn 1 chữ số.
- Regression type ≠ 10: `typeInt === 10 &&` là **liên từ ĐẦU TIÊN** ⇒ type 1/2/3 (PE + Contract) luôn cho `khkkGroup = null` ⇒ `workflows === allWorkflows`, `actions = undefined` (PageHeader có `{actions && ...}` nên render **y hệt** trước). Mọi consumer downstream (`workflows.length === 0`, `workflows.map`) không đổi ⇒ **0 đổi hành vi cho matrix PE/HĐ**. ✅
- Rỗng-vì-lọc ⟂ rỗng-thật: tách 2 nhánh THẬT (`:157-165`), nhánh lọc còn nói thêm "loại phiếu này có N quy trình ở nhóm khác" — bảo vệ đúng ca người nhóm N5 tưởng cả module chưa cấu hình. Điều kiện `allWorkflows.length > 0 &&` là **phép so sánh** (không phải `{0 && ...}`) ⇒ không rò số `0` ra UI. ✅

### 1.2 SOI-2 — queryKey KHÔNG đổi: đúng hay rò cache?

**Phán: ĐÚNG, không rò.** 3 vế:
1. Payload không phụ thuộc `group`: query gọi `/approval-workflows-v2?applicableType=10&isUserSelectable=true` (`:63-71`) — BE `ApprovalWorkflowsV2Controller.cs:24-26` chỉ nhận `applicableType` + `isUserSelectable`, **không có** trục `group` ⇒ 8 nhóm dùng chung ĐÚNG một payload. Thêm `group` vào `queryKey` = 8 lần gọi mạng cho cùng một body + 8 ô cache trùng nội dung. Bỏ ra là đúng.
2. Không stale khi đổi leaf: `khkkGroup`/`workflows` là **giá trị dẫn xuất trong render** từ `useSearchParams()`, KHÔNG phải `useState` khởi tạo-một-lần ⇒ điều hướng leaf→leaf (cùng route, React Router không remount) vẫn tính lại ngay. Đây đúng là cái bẫy "vá-5" đã cắn `KhkkListPage` ở K4b, lần này **tránh được by-construction**, không cần `useEffect`.
3. Cache dùng chung là ĐIỀU MONG MUỐN: nhảy N1→N2 không refetch, lọc lại tại chỗ.

Cái giá (đã cân, chấp nhận): tải trọn bộ type-10 rồi mới cắt — với 8 workflow seed thì không đáng kể.

### 1.3 SOI-4 + SOI-5 — build + cây

| Phép đo | Lệnh | Kết quả |
|---|---|---|
| tsc fe-user | `npx tsc -b` (reviewer tự chạy, KHÔNG đọc panel IDE) | **exit 0**, 0 dòng lỗi ✅ |
| tsc fe-admin | `npx tsc -b --force` | **exit 0** ✅ |
| fe-admin diff | `git status --porcelain -- fe-admin` | **rỗng** ✅ |
| Cây phình | `git status --porcelain -- src tests fe-user fe-admin \| grep '^??'` | **0 hit** ✅ (control dương: cùng lệnh bỏ path-filter ra 1 hit = artifact này ⇒ phép đo KHÔNG rỗng) |
| Bundle prod fe-user | `npm run build` (reviewer tự chạy) | exit 0 — `index-DBtbmn5s.js` **1,643.96 kB** / css **92.71 kB**, **trùng từng chữ số** với con số lane khai ⇒ claim đo-lường của lane KHÔNG phải số bịa |
| Suite BE | `dotnet test SolutionErp.slnx` (reviewer tự chạy) | **622 PASS / 0 FAIL** (45 Domain + 577 Infra) — khớp con số lead báo |

### 1.4 SOI-3 — test MAJOR-A có RĂNG THẬT không?

**Phán: CÓ RĂNG. Probe "đổi test-input sang null" là tương-đương HỢP LỆ ở ca này — nhưng vì lý do đo được, không phải vì nghe hợp lý.**

- Kiểm tương-đương trên MÃ THẬT (không tin lập luận suông): trong `ListDeletedContractSigningPlansQueryHandler`, `request.ApprovalGroup` **chỉ xuất hiện đúng 1 chỗ** — `:896-897` `if (request.ApprovalGroup is not null) q = q.Where(p => p.ApprovalGroup == request.ApprovalGroup)`. Xoá đúng khối đó thì `Handle(g)` **đồng nhất** `Handle(null)` với MỌI `g`. Vậy truyền `null` là mô phỏng CHÍNH XÁC thế-giới-bỏ-param, không phải xấp xỉ. (Nếu tham số được dùng ở ≥2 chỗ thì lập luận này sập — nên phải grep trước khi chấp nhận, và tôi đã grep.)
- Cặp (null ⇒ 2) ∧ (group=1 ⇒ 1) nằm **cùng một Fact, cùng một fixture** ⇒ 2 thế giới đặt cạnh nhau trong 1 lượt chạy; không cần sửa prod-code để chứng.
- `Total` **không phải assert trang trí**: `ProjectPagedAsync:790` `var total = await q.CountAsync(ct)` chạy trên **chính `q` đã `Where`**, trước `Skip/Take` (`:791-792`). Nên "lọc trong bộ nhớ sau khi lấy trang" sẽ cho `Items` giống hệt mà `Total` sai — assert `Total` phân biệt được đúng thứ nó khai là phân biệt.
- Đã giết các mutant hiển nhiên: "luôn trả phiếu đầu" (vế group=3 ⇒ đúng `planG3`) · "không khớp thì bỏ lọc" (vế group=7 ⇒ rỗng + `Total`=0) · "đẩy vế lọc vào trong `if (!admin)`" (vế 5 Admin ⇒ vẫn lọc). Vế IDOR ở `:884-893` đúng là NGOÀI vế lọc như comment khai ✅.
- 2 sanity trước phép đo (2 row thật `IsDeleted` ∧ thật khác nhóm) là thứ khiến "lọc ra 1 phiếu" không thể đúng-vì-lý-do-khác. Đúng bài chống chân-lý-rỗng.
- **Giới hạn còn lại (khai thật, không phải blocker):** probe này KHÔNG bao mutant "lọc đúng hình dạng nhưng sai CỘT" — 2 phiếu trong fixture khác nhau ở nhiều trường (Id, `MaKeHoach`, thời điểm), nên một vế `Where` bám trường tương quan khác cũng qua được. Mutant kiểu đó phi thực tế ở đây; ghi lại để lượt sau đừng đọc "RED→GREEN" thành "mutation-tested toàn phần".
- Trích dẫn số dòng trong test (`:896-897`, `:884-893`, `:401-404`, `:420-424`, `:756-757`) — **grep lại từng cái, ĐÚNG 5/5**, không trượt.

### 1.5 🔴 FLAKE quan sát được — 1 FAIL / 11 lượt chạy lớp test này

- Lượt chạy **đầu tiên** của tôi (`dotnet test --filter ContractSigningPlanCrudTests -v q`, chạy **đồng thời** với `npx tsc -b`, 13 s): `Failed: 1, Passed: 7` — tên test đỏ = **`DeleteContractSigningPlan_SoftDeletes_KeepsChangelog_ThenCreateAgainSucceeds`** (test CŨ, không phải Fact mới). **Thông điệp assert KHÔNG bắt được** vì `-v q` nuốt mất — khai thẳng chỗ hổng của phép đo, không đoán bù.
- Không tái lập: cùng lớp chạy lại **10 lượt** (3 lượt rảnh + 6 lượt dưới tải `tsc -b --force` fe-admin + 1 lượt chạy riêng chính test đó) ⇒ **10/10 XANH**; `dotnet test SolutionErp.slnx` **622/0**.
- Truy nguyên cơ chế lây nhiễm (để biết CÓ THỂ do diff không): fixture `KhkkFixture` mở `SqliteConnection("DataSource=:memory:")` **riêng cho từng test** ⇒ không có DB dùng chung; `ContractSigningPlanCodeGenerator` **không có static** nào (đọc trọn file), sequence nằm trong bảng của chính DB đó; xUnit **2.9.3** ⇒ test trong CÙNG một lớp chạy TUẦN TỰ. Thay đổi của diff lên test cũ = helper `CreateAsync` thêm tham số **mặc định `null`**, mà handler quy `null → DefaultApprovalGroup = 1` (`:207`, `:399`) — **đúng bằng hành vi trước diff**. ⇒ **không tìm được đường nào diff gây ra**, nhưng cũng KHÔNG có bằng chứng lớp này vốn đã flake từ trước.
- Xử trí đề nghị: **không chặn commit** (mọi phép đo đầy đủ đều xanh, 622/0 hai nguồn độc lập), nhưng **ghi vào WAL/handoff làm mốc**: nếu CI đỏ đúng test này lần nào nữa thì đã có mốc "S166 gate-K4c: 1/11" để không phải điều tra lại từ đầu. Đừng viết vào STATUS là "8/8 tuyệt đối" — con số trung thực là **8/8 ở 11/12 lượt quan sát**.

## 2. Kết luận

**VERDICT: PASS — 0 blocker.** 1 CLARIFY (flake trên) + 3 MINOR dưới.

| Hạng mục | Trạng thái | Ghi chú |
|---|---|---|
| 1. Wire/feature claim | PASS | Không có `// Mock` / `alert(` / TODO-wire trong diff; lọc bám `code` khớp seeder BE + rào BE cùng khuôn |
| 2. Schema | N-A | 0 migration, 0 entity, 0 file `Migrations/` trong diff |
| 3. Security | PASS | 0 đổi authz. Trang chỉ **thu hẹp** danh sách hiển thị; endpoint `/deleted` lọc nhóm **SAU** khối IDOR (`:884-893`) ⇒ lọc chỉ hẹp đi, không nới quyền. Không có key policy mới ⇒ gotcha #85 không đụng |
| 4. Code quality | PASS | tsc fe-user 0 · tsc fe-admin 0 · `npm run build` fe-user 0 · `dotnet test` 622/0 · 0 file ngoài phạm vi · 0 `--no-verify` |
| 5. Test coverage | PASS | +1 Fact có răng đúng chuẩn "bug → test-before-fix" (RED đo được trước, GREEN sau); suite 569→577 Infra |
| 6. Writing quality | N-A | Diff không có nội dung hướng ra ngoài (comment nội bộ + chuỗi UI tiếng Việt; đã đọc từng chuỗi mới: đủ dấu, câu hoàn chỉnh) |

**MINOR-1** — `ListDeletedAsync` (`ContractSigningPlanCrudTests.cs:216-224`) **không dispose** `TestApplicationDbContext` (`var db = f.NewDb(actor);` rồi trả `Task` luôn), trong khi 3 helper anh em đều `await using var db`. Không sai kết quả (connection do fixture giữ, `IDisposable` của fixture đóng), nhưng lệch khuôn của chính file. Chuẩn nhận: đổi thành `async` + `await using`, hoặc ghi 1 dòng lý do cố ý.

**MINOR-2** — link "Xem tất cả nhóm" dựng bằng `` `${pathname}?type=${typeInt}` `` ⇒ **rơi mọi query khác** ngoài `type`. Hôm nay vô hại (trang chỉ đọc `type` + `group`), nhưng thêm tham số thứ 3 sau này sẽ mất im lặng. Chuẩn nhận: dựng từ `new URLSearchParams(searchParams)` rồi `delete('group')`.

**MINOR-3** — bấm "Xem tất cả nhóm" xong URL còn `?type=10` ⇒ **không leaf nào trong sidebar khớp** (vì `group` là danh-tính điều hướng, `Layout.tsx:201-221`) ⇒ mất highlight menu. Hành vi chấp nhận được cho một nút "thoát lọc", nêu để không ai đọc thành lỗi.

**Không có Critical. Không có Major.**

Xác nhận 2 FLAG của lane là ĐÚNG, không phải cách nói giảm: (a) fe-admin không có `WorkflowMatrixViewPage` — `git status -- fe-admin` rỗng và `App.tsx` bên admin không có route matrix ⇒ lệch cố-ý, đã khai từ K4a; (b) Admin đổi `Code` trong Designer sẽ làm leaf nhóm ra trang rỗng — **nhưng có báo rõ** nhờ câu rỗng-vì-lọc mới, và BE cũng khoá cùng khuôn `^KHKK-N([1-8])$` (`:420`) nên phiếu không pin nhầm được. Hai tầng nhất quán, không phải FE tự chế luật riêng.

GATE-K4C-END


