GATE-K4B: PASS-WITH-FLAGS (4 MAJOR, 5 MINOR — 0 CHẶN; vá MAJOR-B + chạy cổng MAJOR-D trước khi gõ commit)

# Gate K4b — adversarial review wave K4b (nối `?group=` end-to-end), S166

**Mốc đo (neo lúc mở gate):** HEAD = `14ea2ea` (wal: flush 20260801T0945); commit code cuối = `7a903cf`.
Spec nói HEAD `7a903cf` — thực tế trên đĩa có thêm 6 commit `wal:` phía trên (chưa xác minh chỉ-WAL, sẽ đo).
`git status --porcelain` lúc mở gate: 18 M + 1 ?? (`tests/.../KhkkGroupMenuSeedTests.cs`).
`git diff --stat`: 541 insertions / 82 deletions / 18 files.

Scope chấm = K4b only: KhkkListPage ×2 · KhkkCreatePage ×2 · fe-user/Layout.tsx (staticMap-G1) · BE ContractSigningPlansController.cs (/deleted +group) · ContractSigningPlanFeatures.cs (ListDeleted).
K4a (MenuKeys/DbInitializer/Layout-regex/types/STATUS/test) ĐÃ qua gate K4a — không chấm lại.

---

## Ghi TỪNG MỤC (append khi đo xong, không soạn-gộp)

### M1 — Đo nền (tự chạy, không tin claim)
- `dotnet build SolutionErp.slnx` — **Build succeeded, 0 Warning, 0 Error** (56.4s, tôi tự chạy).
- `npx tsc -b` fe-admin — **EXIT=0**; fe-user — **EXIT=0** (tôi tự chạy, không đọc log nền của lane).
- WAL 6 commit `7a903cf..HEAD` chỉ chạm `.claude/**` (đo `git diff --stat`) ⇒ toàn bộ code K4a+K4b nằm ở working-tree. Mốc so sánh dùng `7a903cf` là ĐÚNG về nội dung code dù HEAD ghi khác.

### M2 — Mirror 2 app (sha256, 16 ký tự đầu)
| file | fe-admin | fe-user | |
|---|---|---|---|
| `pages/khkk/KhkkListPage.tsx` | `fa4c7aa268f35eeb` | `fa4c7aa268f35eeb` | MATCH (khớp con số `fa4c7aa2` spec nêu) |
| `pages/khkk/KhkkCreatePage.tsx` | `545b4c2fe0ebae88` | `545b4c2fe0ebae88` | MATCH |
| `types/khkk.ts` | `7a99b86518948093` | `7a99b86518948093` | MATCH |

`Layout.tsx` lệch by-design (đã khai trong cả 2 file: admin `Khkk_WfView` → Designer, không mang `group`; admin không nới regex `Khkk_G*`).

### M3 — FLAG-1 (code LEAD tự viết) — BE `/deleted` + group
- Ký-pháp: `public record ListDeletedContractSigningPlansQuery(int? ApprovalGroup = null) : PagedRequest, ...`. `PagedRequest` là `abstract record` (`Common/Models/PagedResult.cs:15`) với 4 property `init` ⇒ trộn positional-ctor + object-initializer HỢP LỆ (record kế thừa record; nếu `PagedRequest` là `class` thì đã không biên dịch được). Build 0E xác nhận.
- Controller mirror List **ĐÚNG TỪNG NÉT**: `[FromQuery(Name = "group")] int? group = null` đặt ở vị trí tham số đầu, `new Query(group) { Page = …, PageSize = …, Search = …, SortDesc = … }` — cùng khuôn `List` (`:42`, anchor này ĐÚNG, tôi đếm lại).
- Handler: vế lọc `if (request.ApprovalGroup is not null) q = q.Where(p => p.ApprovalGroup == request.ApprovalGroup)` nằm **SAU** khối IDOR non-admin và **TRƯỚC** `Search` — đúng thứ tự list sống (`:755-758`). `IgnoreQueryFilters().Where(x => x.IsDeleted)` giữ nguyên phía trên ⇒ lọc nhóm không đụng vế soft-delete.
- `ContractSigningPlan.ApprovalGroup` là `int` **không nullable**, default `= 1` (`ContractSigningPlan.cs:41`) ⇒ so sánh `== request.ApprovalGroup` (int? vs int) dịch SQL thành so sánh giá trị, không sinh ca NULL-semantics.
- **Không có validator biên 1..8** cho `/deleted` — nhưng list SỐNG cũng không có ⇒ **consistent**, ghi nhận chứ không tính lỗi. `?group=999` = lọc rỗng (200 + `items: []`, `total: 0`), không 500. `?group=abc` = model-binding fail ⇒ 400 do `[ApiController]`. Cả hai hành vi giống hệt list sống.
- FE gửi `group` cho **cả 2 nhánh** (`KhkkListPage.tsx:124`, nằm ngoài tam-nguyên `deletedView` nên áp cho cả `/contract-signing-plans` lẫn `/deleted`) ⇒ khớp BE mới. Title/subtitle màn "Đã xóa" in tên nhóm — hết là lời nói dối vì server đã lọc thật.

---

## MỤC 1 — Vá-5: derive `phase`/`group` từ URL + `navKey` reset (`KhkkListPage.tsx:70-102`)

**Cơ chế lane dùng:** KHÔNG phải `useEffect`. Là hai lớp:
1. **Derive thuần** — `urlFilter`/`urlPhase`/`deletedView`/`group` đọc thẳng `searchParams` mỗi render (`:74-80`). `useSearchParams` bám `location` nên đổi query (cùng route) VẪN re-render ⇒ 3 trục danh tính luôn tươi. Trước K4b `phase` là `useState(initialFilter…)` — khởi tạo 1 lần, đây đúng là nguồn của lỗi "phải F5".
2. **Lớp override 3-trạng-thái** — `chipPhase: KhkkPhaseValue | null | undefined`, `undefined` = chưa bấm chip (nghe URL), `null` = user chọn "Tất cả", số = user chọn trạng thái. Tách `undefined` khỏi `null` là ĐÚNG và cần: gộp lại thì bấm "Tất cả" trên leaf `?filter=ChoDuyet` sẽ bị URL kéo ngược.
3. **Reset trong render** (`:95-101`) — `navKey = ${group}|${urlFilter}|${deletedView}`, so với `lastNavKey`, khác thì `setLastNavKey` + `setChipPhase(undefined)` + `setPage(1)`. Đây là khuôn React chính thống "điều chỉnh state khi prop/input đổi", KHÔNG phải `useEffect` ⇒ không có lượt vẽ bằng dữ liệu cũ.

**Bảng ca chuyển tôi soi từng ca:**

| Ca | navKey trước → sau | reset `chipPhase` | reset `page` | `phase` cuối |
|---|---|---|---|---|
| leaf→leaf cùng nhóm khác filter (`?group=2` → `?group=2&filter=DaDuyet`) | `2\|\|` → `2\|DaDuyet\|` | CÓ | CÓ | `DaDuyet` |
| leaf→leaf khác nhóm cùng filter (`?group=2&filter=ChoDuyet` → `?group=5&filter=ChoDuyet`) | `2\|ChoDuyet\|` → `5\|ChoDuyet\|` | CÓ | CÓ | `ChoDuyet` (đúng, chỉ đổi `group`) |
| vào deleted (`?group=3&filter=DaDuyet` → `?group=3&view=deleted`) | `3\|DaDuyet\|` → `3\|\|del` | CÓ | CÓ | `null` |
| ra khỏi deleted | `3\|\|del` → `3\|\|` | CÓ | CÓ | `null` |
| root trần `/khkk/list` ↔ leaf nhóm 1 `?group=1` | `\|\|` ↔ `1\|\|` | CÓ | CÓ | `null` |
| mount lần đầu | `lastNavKey` khởi tạo = `navKey` | KHÔNG (đúng) | KHÔNG | theo URL |

⇒ **Vá-5 ĐẠT.** Cả 6 ca đều re-render đúng, không cần F5.

**2 ghi chú kỹ thuật (không phải lỗi):**
- Lượt render bị React huỷ (lượt phát hiện `navKey` đổi) vẫn chạy tới `useQuery` với `page`/`chipPhase` CŨ. TanStack v5 `getOptimisticResult` sẽ `build()` một ô cache rỗng cho khoá cũ đó; effect không chạy trên render bị huỷ nên **không có fetch thừa**, ô rỗng bị gc. Vô hại.
- `search` CỐ Ý không reset (lane FLAG-4, lead giữ). Nó nằm trong `queryKey` nên không đẻ sai dữ liệu; chỉ là lựa chọn UX.

**MINOR-1 (không phải hồi quy):** bấm LẠI đúng leaf đang đứng ⇒ `navKey` không đổi ⇒ chip override KHÔNG nhả. Ví dụ: đứng ở "Đang duyệt" nhóm 1, bấm chip "Tất cả", rồi bấm lại mục "Đang duyệt" trong sidebar → vẫn hiện tất cả. Hình dạng này ĐÃ có trước K4b (khi đó `phase` là `useState` khởi tạo 1 lần) nên K4b không làm xấu đi. Muốn chữa thì so bằng `location.key` thay vì `navKey`.

---

## MỤC 2 — Vá-3 cache-key + phán FLAG-2 (phase-decoded thay filter-thô)

Khoá hiện tại: `['khkk-list', { group, phase, search, page, deletedView }]` (`KhkkListPage.tsx:112`).

**Đủ trục chưa — đối chiếu với ĐÚNG tập tham số gửi lên server** (`:117-128`): `phase` (bỏ khi deleted), `group`, `search`, `page`, `pageSize` (hằng), + chính `url` (`/contract-signing-plans` vs `/deleted`).

| Thứ quyết định response | Có trong khoá? |
|---|---|
| endpoint (`deletedView`) | CÓ |
| `group` | CÓ (vá-3, trục mới) |
| `phase` | CÓ |
| `search` | CÓ |
| `page` | CÓ |
| `pageSize` | hằng `PAGE_SIZE` — không cần |

⇒ **Không thiếu trục nào.**

**PHÁN FLAG-2 (lane nêu, lead nhận, tôi phán lại — ĐỘC LẬP): dùng `phase` đã giải mã là ĐÚNG, không phải nhân nhượng.**
- Lý do quyết định: khoá cache phải định danh **REQUEST**, không định danh **URL**. `filter` thô không bao giờ được gửi đi; thứ gửi đi là `phase`. Hai chuỗi `filter` khác nhau mà cùng giải mã ra một `phase` sẽ sinh **hai request byte-identical** ⇒ gộp một ô cache là đúng, tách ra mới là lỗi (đôi cache, đôi lượt mạng cho cùng một câu hỏi).
- Ca lệch mà spec hỏi (`?filter=rác` → `phase=null` → trùng khoá với "tất cả"): **hành vi ĐÚNG**. `?filter=rác` khiến trang hiển thị mọi trạng thái, y hệt "tất cả" — cùng màn hình, cùng dữ liệu, nên cùng ô cache. Không có gì để phân biệt.
- Chỗ raw `filter` VẪN cần thì nó CÓ mặt: `navKey` (`:95`) dùng `urlFilter` THÔ, nên hai leaf `?filter=rác1` / `?filter=rác2` vẫn được coi là hai lượt điều hướng khác nhau và vẫn reset chip/page. Hai vai trò tách bạch đúng chỗ.
- Điều kiện để phán này SAI (nêu ra để sau này ai đổi thì biết): nếu trang bắt đầu render khác nhau theo chuỗi `filter` thô (ví dụ in tên bộ lọc lấy từ URL). Hiện `urlFilter` chỉ chảy vào `urlPhase` + `navKey`, không chảy vào JSX — tôi đã grep hết đường đi của nó trong file.

**MINOR-5:** trong `deletedView`, `phase` vẫn nằm trong khoá nhưng KHÔNG được gửi (`:119`). Chip bị ẩn ở màn xoá (`:201 {!deletedView && …}`) và không leaf nào sinh URL vừa `filter` vừa `view=deleted`, nên thực tế `phase` luôn `null` ở đó. Chỉ URL gõ tay mới đẻ được 2 ô cache cho cùng một response. Vô hại, có sẵn từ trước K4b.

---

## MỤC 3 — FLAG-1: bản vá LEAD TỰ VIẾT (BE 3 chỗ + FE 2 nhánh) — soi kỹ nhất

*(số đo chi tiết đã ghi ở M3 phía trên; đây là phần phán)*

**3 chỗ BE — đối chiếu từng nét với list SỐNG:**

| | List sống (K2, `:756-758` + Controller `:42,49`) | `/deleted` (K4b, lead viết) | Khớp? |
|---|---|---|---|
| record | `ListContractSigningPlansQuery(…, int? ApprovalGroup = null) : PagedRequest` | `ListDeletedContractSigningPlansQuery(int? ApprovalGroup = null) : PagedRequest` | KHỚP |
| controller param | `[FromQuery(Name = "group")] int? group = null` | y hệt | KHỚP |
| truyền | `new Query(phase, …, group) { Page = … }` | `new Query(group) { Page = … }` | KHỚP khuôn |
| vị trí lọc | SAU khối IDOR, TRƯỚC `Search` | SAU khối IDOR, TRƯỚC `Search` | KHỚP |
| vị ngữ | `p.ApprovalGroup == request.ApprovalGroup` | y hệt | KHỚP |

**Positional-record trộn object-initializer có compile đúng thứ tự không** (spec hỏi thẳng): CÓ, và không có bẫy thứ tự. C# chạy constructor positional TRƯỚC, rồi mới gán object-initializer; hai tập không giao nhau (`ApprovalGroup` chỉ ở ctor; `Page/PageSize/Search/SortDesc` chỉ ở initializer, đều là `init` accessor của `PagedRequest`). Điều kiện ngầm khiến nó hợp lệ: `PagedRequest` phải là **record** (record không kế thừa được class) — đã kiểm: `public abstract record PagedRequest` (`Common/Models/PagedResult.cs:15`). `dotnet build` 0E do tôi tự chạy xác nhận.

**Validator biên 1..8:** KHÔNG có, ở CẢ HAI endpoint ⇒ **consistent, ghi nhận chứ không trừ điểm** (đúng như spec dự liệu). Hành vi đo được bằng suy luận kiểu:
- `?group=999` → `int?` bind được → `Where(ApprovalGroup == 999)` → **200 + `items: []`, `total: 0`** (không 500, không lộ dữ liệu).
- `?group=abc` → bind fail → **400** tự động do `[ApiController]`.
- `?group=` (rỗng) → `null` → xem tất cả.
- Không có ca nào NỚI phạm vi thấy được, vì vế lọc nằm SAU IDOR và chỉ thu hẹp.

**FE 2 nhánh:** `group: group ?? undefined` (`:124`) đặt NGOÀI biểu thức tam nguyên `deletedView`, nên áp cho cả `/contract-signing-plans` lẫn `/deleted` — đúng "cả 2 nhánh". FE chỉ gửi khi `group` đã qua `/^[1-8]$/` (`:80`) nên rác không tới server. Title/subtitle màn xoá in tên nhóm (`:166,175`) — hợp lệ VÌ server đã lọc thật; nếu thiếu vá BE thì đúng là "tên nhóm nói dối" như chú thích lead viết.

🔴 **MAJOR-A — `sợi-dây-1-argument`, 0 test.** Toàn bộ tính năng này treo trên 2 mẩu: `(group)` ở Controller và 3 dòng `if` ở handler. Tôi grep `tests/` cho `ListDeletedContractSigningPlansQuery` → **0 hit**; không test nào chạm handler này. Phép thử phản chứng: xoá `(group)` khỏi `new ListDeletedContractSigningPlansQuery(group)` ⇒ tính năng chết câm, mà `dotnet build` vẫn 0E, bộ test lead báo 41/41 vẫn xanh, `tsc` ×2 vẫn EXIT=0 — **không phép kiểm nào TRƯỢT**. §7 cho phép test-after với feature mới nên đây không phải điều kiện chặn commit, nhưng phải vào sổ nợ K4c: 1 test "hai phiếu xoá khác nhóm, `?group=n` chỉ trả 1" là đủ. (Khuôn có sẵn: `ContractSigningPlanApprovalTests`.)

---

## MỤC 4 — staticMap G1 vs regex nhóm 2..8: có key nào trùng-match 2 đường không?

**Đo:** `staticMap` (fe-user `:63-87`) dùng 6 key **KHÔNG-infix**: `Khkk_List`, `Khkk_Create`, `Khkk_Pending`, `Khkk_Approved`, `Khkk_WfView`, `Khkk_Deleted`. Regex (`:212`) là `^Khkk_G([1-8])_(WfView|List|Create|Pending|Approved|Deleted)$` — **bắt buộc có khúc `G{n}_`**. Hai tập **rời nhau theo cấu trúc**: không chuỗi nào vừa có vừa không có infix. Thêm nữa `if (staticMap[key]) return staticMap[key]` (`:139`) chặn TRƯỚC regex, nên kể cả có trùng thì staticMap thắng — thứ tự an toàn.

**Đo phía dữ liệu (không suy từ code FE):** `MenuKeys.KhkkGroupNumbers = [2,3,4,5,6,7,8]` (`MenuKeys.cs:72`) ⇒ BE **không hề sinh** key `Khkk_G1_*`; `grep -rn "Khkk_G1_" src/Backend fe-admin/src fe-user/src` = **0 hit**. Nhánh `1` trong dải `[1-8]` của regex là nhánh CHẾT — lane đã khai rõ là cố ý, tôi xác nhận vô hại hôm nay.

⚠️ **Bẫy ngủ (ghi để người sau biết, KHÔNG tính lỗi hôm nay):** nếu sau này ai seed `Khkk_G1_List`, sẽ có **hai leaf khác key nhưng cùng URL** `/khkk/list?group=1` (một từ staticMap, một từ regex). `queryMatches` (`:353-374`) so khớp active bằng **URL chứ không bằng key** ⇒ tái hiện đúng lớp bug UAT S155 "mục render sau thắng". Ai chuyển nhóm 1 sang khuôn infix thì phải **bỏ 6 dòng staticMap cùng lượt**.

**6 leaf nhóm 1 — kiểm từng URL (fe-user):**

| key | URL | đúng? |
|---|---|---|
| `Khkk_List` | `/khkk/list?group=1` | ĐÚNG |
| `Khkk_Create` | `/khkk/create?group=1` | ĐÚNG |
| `Khkk_Pending` | `/khkk/list?group=1&filter=ChoDuyet` | ĐÚNG (`FILTER_TO_PHASE.ChoDuyet` tồn tại) |
| `Khkk_Approved` | `/khkk/list?group=1&filter=DaDuyet` | ĐÚNG |
| `Khkk_Deleted` | `/khkk/list?group=1&view=deleted` | ĐÚNG (`get('view') === 'deleted'`) |
| `Khkk_WfView` | `/khkk/workflow-matrix?type=10&group=1` | route sống, **`group` KHÔNG có người đọc** — xem MAJOR-C |

`group` không nằm trong `TRANSIENT_QUERY_KEYS` (`:349` = `id, q, editHeader, page, phase, awId`) ⇒ so khớp active NGHIÊM theo `group`; 8 leaf cùng hành động khác nhóm phân biệt được, và root trần `/khkk/list` (0 key query) không khớp leaf `?group=1` (1 key) vì `queryMatches` so cả SỐ LƯỢNG khoá. Đúng ý đồ.

🔴 **MAJOR-C — tham-số-trang-trí lan sang staticMap.** `grep "searchParams.get('group')"` trên cả 2 app = **4 hit, TẤT CẢ nằm ở `KhkkListPage` + `KhkkCreatePage`**; `WorkflowMatrixViewPage` **0 hit**. Tức `Khkk_WfView` (mới thêm `&group=1` trong ĐÚNG diff K4b này) và 7 leaf `Khkk_G{2..8}_WfView` (K4a) đều mở ra **cùng một ma trận workflow type-10**, không lọc theo nhóm. Đây là đúng finding ① tôi đã nêu ở gate K4a; K4b **không sửa mà thêm cái thứ 8**. Lead đã disposition FLAG-3 = K4c/K5 theo build-order ⇒ tôi KHÔNG chặn commit vì nó, nhưng ghi rõ: hôm nay `&group=1` ở dòng `Khkk_WfView` chỉ có tác dụng **phân biệt active-state**, KHÔNG có tác dụng lọc nội dung. Ai đọc URL mà tưởng nó lọc thì hiểu sai.

---

## MỤC 5 — Mirror 2 app

Bảng sha256 đã ghi ở **M2**: `KhkkListPage.tsx` = `fa4c7aa268f35eeb` ở CẢ HAI app (khớp đúng `fa4c7aa2` spec yêu cầu xác minh sau khi lead cp lại), `KhkkCreatePage.tsx` = `545b4c2fe0ebae88` ×2, `types/khkk.ts` = `7a99b86518948093` ×2. Không file nào lệch.

`Layout.tsx` lệch by-design và **lệch có khai ở cả hai phía**:
- admin `Khkk_WfView` → `/system/approval-workflows-v2/ContractSigningPlan` (Designer), **cố ý KHÔNG mang `group`** — chú thích ngay tại chỗ (`fe-admin/.../Layout.tsx:37-39`) nói rõ Designer không đọc tham số này. Tôi đồng ý: mirror mù ở đây mới là lỗi.
- 5 dòng `/khkk/*` còn lại của admin ĐÃ mang `group=1` (`:41-45`) ⇒ không có ca "một app nói dối, một app nói thật".
- admin không nới regex `Khkk_G*` — hệ quả 42 leaf nhóm 2..8 drop im lặng bên admin; **đã khai bằng khối ràng-buộc-ngược tại `isAdminHidden`** (`:199-210`) và là trạng thái K4a chấp nhận, không phải phát sinh mới ở K4b.

---

## MỤC 6 — Hồi quy đường cũ không-param

| Đường | Kỳ vọng | Đo được | |
|---|---|---|---|
| `/khkk/list` trần (root `KeHoachKyKet`, fe-user staticMap `:83`) | xem TẤT CẢ nhóm | `groupParam === null` ⇒ `group = null` ⇒ `group ?? undefined` ⇒ axios bỏ tham số ⇒ BE `ApprovalGroup is null` ⇒ không thêm vế `Where` | ĐÚNG |
| tiêu đề trang khi trần | không in tên nhóm | `group !== null ? … : 'Kế hoạch ký kết HĐ'` | ĐÚNG |
| `/khkk/create` không param | giữ default cũ | `presetGroup = null` ⇒ `useState(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT)`, hằng `= 1` (`types/khkk.ts:122`) | ĐÚNG |
| `/khkk/list?group=rác` | không tự đoán nhóm 1 | regex `/^[1-8]$/` trượt ⇒ `null` ⇒ xem tất cả | ĐÚNG |

**MINOR-2:** trên `KhkkCreatePage`, hai nút "Huỷ" (`:165`, `:373`) đều `navigate('/khkk/list')` **không mang `group`** — người dùng đi từ leaf nhóm 5 vào lập phiếu, bấm Huỷ thì rơi về danh sách MỌI nhóm. Bất đối xứng với chiều đi (nút "Lập kế hoạch" ở `KhkkListPage:186` có mang `group`). Không sai dữ liệu, chỉ mất ngữ cảnh.

**MINOR-3:** đi từ `/khkk/create?group=3` sang `/khkk/create` (trần) thì ô chọn GIỮ 3, trong khi chú thích `:64` viết "không có tham số thì giữ nguyên default cũ" — chữ và việc lệch nhau một nhịp (nhánh `if (presetGroup !== null)` `:82` cố ý không đụng khi về null). Hành vi thực tế hợp lý hơn chữ; sửa CHỮ là đủ.

**Đã kiểm giao thoa preset × rào nhóm-đã-dùng:** vào `?group=3` rồi chọn PE đã có kế hoạch nhóm 3 ⇒ `selectPe` (`:147-154`) nhảy sang nhóm trống đầu tiên; nếu người dùng đổi leaf sang nhóm đã dùng thì `groupTaken` bật, nút Lưu **disabled** (`:380`) + dòng đỏ (`:313`). Preset KHÔNG phá được rào 409. ĐẠT.

---

## MỤC 7 — Mỏ neo số dòng (tái phát bài K4a ②)

🔴 **MAJOR-B — mỏ neo bị CHÍNH diff này làm sai, và sai ngay lúc land.**
`fe-user/src/components/Layout.tsx:204-205` viết:
> ``Tên tham số `filter` / `view` lấy ĐÚNG từ `KhkkListPage` (`:61` `get('filter')` → `FILTER_TO_PHASE`, `:62` `get('view') === 'deleted'`)``

Đo hai đầu:
- `git show 7a903cf:fe-admin/src/pages/khkk/KhkkListPage.tsx | grep -n` → `61: get('filter')`, `62: get('view')` — **đúng ở thời điểm K4a viết**.
- Trên đĩa hôm nay (sau K4b) → **`74:` `get('filter')`, `76:` `get('view')`** — lệch +13 / +14, đúng bằng khối chú thích + derive mà K4b chèn vào đầu component.

Vì K4a và K4b **vào CHUNG một commit** (lead đã khai), mỏ neo này sẽ sai **từ byte đầu tiên** của commit — không phải trôi dần theo thời gian. Đây đúng lớp lỗi tôi đã nêu ở gate K4a mục ②, tái phát sau đúng một wave, lần này **xuyên file** (K4b sửa file A làm hỏng chú thích trong file B). Mỉa mai đo được: chính khối chú thích đó, cách 2-3 dòng, dặn hai lần *"grep tên hằng — đừng neo số dòng"* và *"số dòng trượt theo edit"*.

**Tiêu chí nghiệm thu để gỡ:** bỏ `:61`/`:62`, thay bằng mỏ neo grep-được (ví dụ trích chính chuỗi `searchParams.get('filter')` / `FILTER_TO_PHASE`), hoặc cập nhật thành `:74`/`:76` **và** đo lại sau khi mọi edit của cả 2 wave đã nằm yên. Sửa 1 dòng, không đụng logic, không cần build lại.

**Quan sát kèm (KHÔNG tính cho K4b — nợ có sẵn từ K2):** `ContractSigningPlanFeatures.cs:332` viết ``client hiện tại (`KhkkCreatePage.tsx:73-75` ×2 app) CHƯA gửi trường này``. Hai vế đều đã sai TRƯỚC K4b: `:73-75` pre-K4b là chú thích của query `workflows`, không phải chỗ dựng body; và client **CÓ** gửi `approvalGroup` (`KhkkCreatePage.tsx:119`, do chính K2 thêm). K4b chỉ đẩy nó lệch thêm 21 dòng. Ghi vào sổ nợ, đừng tính vào cổng này.

---

## MỤC 8 — Bảng 6 hạng mục + nghiệm thu

| # | Hạng mục | Kết quả | Ghi chú |
|---|---|---|---|
| 1 | Wire BE / feature claim | **PASS** | `group` đi trọn FE→BE ở CẢ 2 nhánh; BE lọc thật, `total`/số-trang nói thật |
| 2 | Toàn vẹn schema | **PASS** | 0 migration, 0 đổi cột; `ApprovalGroup` là `int` non-null default 1 nên vế lọc không sinh ca NULL |
| 3 | Bảo mật | **PASS** | vế lọc đặt SAU IDOR (chỉ thu hẹp) ×2 handler; `[Authorize(Policy="KeHoachKyKet.Read")]` giữ nguyên cả class lẫn action; không endpoint mới |
| 4 | Chất lượng mã | **PASS-WITH-FLAGS** | build 0E + tsc ×2 EXIT=0 (tôi tự chạy); mirror sha ×3 khớp; **MAJOR-B** mỏ neo sai |
| 5 | Độ phủ test | **PASS-WITH-FLAGS** | §7 test-after cho feature mới ⇒ không chặn; **MAJOR-A** vế lọc `/deleted` 0 test, xoá đi vẫn xanh toàn bộ |
| 6 | Chất lượng văn xuôi | **N-A** | không có nội dung hướng ra ngoài trong diff K4b |

**Nghiệm thu spec-cụm2 (đo, không suy):**
- *"bấm leaf→leaf ĐỔI kết quả không cần F5"* → **ĐẠT**, 6/6 ca chuyển ở bảng MỤC 1.
- *"URL G1 6 leaf mang `group=1`"* → **ĐẠT**: fe-user 6/6 (kể cả `WfView`); fe-admin 5/5 mục trỏ `/khkk/*`, riêng `WfView` trỏ Designer nên cố ý không mang `group` (lệch đã khai ở cả hai file).

**Xử lý 4 FLAG của lane:** FLAG-1 → lead vá đủ 3 chỗ BE + 2 nhánh FE, tôi xác nhận ĐÚNG (còn nợ test). FLAG-2 → tôi phán **KHÔNG phải lỗi**, phase-decoded là lựa chọn đúng (lập luận + điều kiện phản chứng ở MỤC 2). FLAG-3 → carry sang K4c, nhưng K4b vừa đẻ thêm instance thứ 8 (MAJOR-C). FLAG-4 → giữ, `search` nằm trong `queryKey` nên không sai dữ liệu.

---

## VERDICT

**GATE-K4B: PASS-WITH-FLAGS (3 MAJOR, 5 MINOR) — không có lỗi CHẶN.**

Đủ điều kiện vào commit gộp K4a+K4b. Đề nghị làm 1 việc rẻ TRƯỚC khi commit:
- **MAJOR-B** (sửa 1 dòng chú thích `fe-user/.../Layout.tsx:204-205`) — vì nó sai ngay tại commit này chứ không trôi dần, sửa sau sẽ phải đẻ commit riêng cho một dòng chữ.

Hai cái còn lại vào sổ nợ K4c, KHÔNG chặn hôm nay:
- **MAJOR-A** — 1 test cho vế lọc nhóm ở `/deleted` (2 phiếu xoá khác nhóm, `?group=n` trả đúng 1).
- **MAJOR-C** — `WorkflowMatrixViewPage` đọc `?group=` (đang là tham số trang trí ở 8 URL).

MINOR 1-5 tuỳ lead, không cái nào đụng dữ liệu.

END — gate K4b đóng.

---

## MỤC 9 — Re-đo NGAY TRƯỚC khi phát verdict (bài S166 "cây phình giữa lượt review")

Giữa lượt review cây CÓ đổi: HEAD `14ea2ea` → `bc1e7c9` (2 commit `wal:` mới), `git diff --stat` tổng 541→664 dòng thêm. Tôi đo lại để verdict không neo vào ảnh chụp cũ:

- `git diff --stat 14ea2ea..HEAD` = **3 file, TẤT CẢ trong `.claude/`** (memory implementer-frontend, `sub-implfe-k4b.md`, chính artifact này). **0 file code.**
- `git diff --stat -- src fe-admin fe-user tests docs` = **13 file, 444+/59−**, từng dòng số **trùng khít** lần đo đầu (Layout admin 28, ListPage 90 ×2, CreatePage 24 ×2, types 25 ×2, Layout user 50, Controller 5, Features 9, MenuKeys 42, DbInitializer 79, STATUS 12).
- sha256 3 file mirror đo lại: `fa4c7aa2` / `545b4c2f` / `7a99b865` ×2 app — **không đổi**.

⇒ Verdict phát trên đúng thứ tôi đã soi. Không có code lọt vào sau lưng cổng.

🔴 **MAJOR-D — nguy cơ lúc commit, KHÔNG phải lỗi mã.** `git status --porcelain -- src tests` cho ra **`?? tests/SolutionErp.Infrastructure.Tests/Application/KhkkGroupMenuSeedTests.cs`** — file test của K4a vẫn **CHƯA ĐƯỢC TRACK**. Commit gộp mà lead dùng `git commit -am` hoặc liệt file bằng tay thiếu dòng này thì bộ test 49 dòng menu đi kèm K4a sẽ **rơi lại ngoài commit**: mã seed lên repo, test chứng minh nó thì không. Cổng rẻ, chạy trước khi gõ commit:

```
git status --porcelain -- src tests | grep '^??'     # phải RỖNG
```

Nếu chưa rỗng thì `git add` đúng đường dẫn đó rồi mới commit.

---

## TOTAL — chốt cuối (đính chính con số ở MỤC 8: là **4 MAJOR**, không phải 3; MAJOR-D sinh ra sau lượt re-đo)

**GATE-K4B: PASS-WITH-FLAGS — 4 MAJOR + 5 MINOR, 0 lỗi CHẶN.**

| mã | mức | nội dung | làm khi nào |
|---|---|---|---|
| MAJOR-A | major | vế lọc nhóm ở `/deleted` **0 test** — xoá đi vẫn xanh toàn bộ (`sợi-dây-1-argument`) | sổ nợ K4c |
| MAJOR-B | major | mỏ neo `fe-user/.../Layout.tsx:204-205` trỏ `KhkkListPage :61/:62`, đĩa là `:74/:76` — sai từ byte-0 của commit | **trước commit** (sửa 1 dòng chữ) |
| MAJOR-C | major | `?group=` trên `WfView` là tham số trang trí, 0 người đọc; K4b thêm instance thứ 8 | sổ nợ K4c/K5 (lead đã disposition) |
| MAJOR-D | major | `tests/…/KhkkGroupMenuSeedTests.cs` còn `??` chưa track — commit gộp dễ bỏ rơi test của K4a | **trước commit** (`git add` rồi commit) |
| MINOR-1..5 | minor | bấm-lại-leaf không nhả chip · "Huỷ" mất `group` · chữ-vs-việc ở `:64` CreatePage · `phase` thừa trong khoá khi deleted · search `/deleted` chỉ khớp `MaKeHoach` | tuỳ lead |

Nghiệm thu spec-cụm2: **2/2 ĐẠT** (leaf→leaf không cần F5; 6 leaf G1 mang `group=1`).
Cả 4 FLAG của lane đã xử lý: FLAG-1 xác nhận vá đúng · FLAG-2 phán **không phải lỗi** (có điều-kiện-phản-chứng) · FLAG-3 carry K4c · FLAG-4 giữ.

END — gate K4b đóng, artifact hoàn chỉnh tới dòng này.
