GATE-K5: PASS — 0 blocker · 6/6 SOI đạt (mất-phiếu + regression GĐ3/4 chứng được là KHÔNG xảy ra) · 1 CLARIFY + 5 MINOR treo · rào commit: `menus.json` untracked ở gốc, đừng `git add -A`

> **Re-đo TRƯỚC verdict** (bài S166 F-1 ②): HEAD trôi `ff4d4dc → 13f7f82 → 80b8cf5` trong lượt review, **toàn `wal: flush`** — `git diff --name-only 13f7f82..HEAD -- src tests fe-admin fe-user` = **0**. Cây code vẫn **đúng 4 file, +466/−108**, 2 cặp sha mirror y nguyên, 0 untracked trong vùng code ⇒ verdict đo trên đúng thứ sắp commit.

# Gate K5 — adversarial review cây pipeline GĐ2 mở 8 sub-folder nhóm (S166, 2026-08-01)

Vai: reviewer (READ-only, verdict-only). Ghi TỪNG MỤC, Edit sau mỗi mục (chống #53).

## §0 Mốc đo (neo trước khi soi)
- Lệnh: `git status --porcelain` + `git rev-parse HEAD` lúc 1st-measure.
- 🔴 **LỆCH TIỀN-ĐỀ #1:** spec lead nói "diff chưa commit trên HEAD `88e7ecf`". Đĩa đo được **HEAD = `ff4d4dc9d0b745867f83b5349f3c239d6b2ee798`**. Phải truy `git log` xem `88e7ecf` nằm đâu trước khi tin base.
- 🔴 **RÀO GIT #1:** `git status` có `?? menus.json` ở ROOT repo (untracked, ngoài spec 4 file). Cần đo: file gì, có bị commit ké không.
- Cây làm việc lúc mốc-0 (8 file M + 3 ??): 4 file code đúng spec + 4 file `.claude/**` + 3 untracked.

## §1 Phân lập diff + rào git (đo, không tin lời khai)
- `git log`: `88e7ecf` LÀ tổ tiên của HEAD ⇒ tiền-đề lead đúng về **nội dung**, sai về **tên mốc**: giữa `88e7ecf` và HEAD có **3 commit `wal: flush`**. `git diff --name-only 88e7ecf..HEAD` = **6 file, TOÀN BỘ `.claude/**`**, 0 file code ⇒ base code vẫn là `88e7ecf`. Verdict K5 đo trên `git diff` (working-tree vs HEAD).
- 🔴 Cây PHÌNH GIỮA LƯỢT (lần thứ 3 trong 3 gate liên tiếp): HEAD `ff4d4dc` lúc đo-0 → `13f7f82` sau ~2 tool-call. Phân lập: `git diff --stat -- fe-admin fe-user src tests` = **đúng 4 file, +466/−108** khớp spec TỪNG SỐ.
- `git status --porcelain -- src tests fe-admin fe-user` = **0 dòng `??`** ⇒ không có file code chưa track (bài "HIGH nằm ở git" KHÔNG tái phát trong vùng code).
- 🔴 **RÀO GIT — `menus.json` 44.656 B nằm ở ROOT repo, untracked** (mtime 10:25, nội dung = payload `/api/menus/me`, sản phẩm phụ của lane cicd verify prod). Không phải file của K5, nhưng nếu lead commit bằng `git add -A`/`git add .` thì nó lên repo. Đây là finding **về THAO TÁC COMMIT sắp tới**, không phải về diff K5.
- Cùng loại: `.claude/agent-memory/implementer-frontend/archive/2026-H2.md` untracked (hợp lệ, nên add có chủ đích).

## §2 Build gate — TÔI TỰ CHẠY, không nhận lời khai
| lệnh | nơi chạy | exit |
|---|---|---|
| `npx tsc -b` | `fe-user` | **0** |
| `npx tsc -b` | `fe-admin` | **0** |
Lời khai lane (`tsc -b` ×2 exit 0 + `npm run build` ×2) khớp phần tôi đo lại. Bundle hash lane khai (`index-CZUcTUUg.js` / `index-BZWvx0Y8.js`) tôi **KHÔNG** kiểm lại — chưa deploy nên không có ô canonical để đối chứng; ghi rõ là **lời khai chưa đo**, đừng đọc thành đã-verify (bài K4a ③).

## §3 SOI-1 — Node nhóm đúng nguồn + route khớp K4b ✅
- **1 nguồn, 0 map thứ hai.** `grep KHKK_APPROVAL_GROUPS|khkkGroupMenuLabel` toàn `fe-user/src`+`fe-admin/src`: consumer = hook K5 (`:217/:228/:230`) + `KhkkCreatePage` + `KhkkListPage`, **tất cả import từ `@/types/khkk`**. Không có mảng 8 phần tử nào khác, không có bảng nhãn thứ hai.
- 8 node dựng bằng `KHKK_APPROVAL_GROUPS.map(...)` (hằng `[1..8] as const`), nhãn `khkkGroupMenuLabel(n)` = `"{n}. " + KHKK_APPROVAL_GROUP_LABELS[n]` — **derive, không chép**.
- 🟢 **ĐỐI CHIẾU LIVE (tôi tự đo, không nhận lời khai):** so nhãn cây suy ra ⟂ nhãn thật trong payload `/api/menus/me` (`menus.json` prod) → **8/8 KHỚP TỪNG KÝ TỰ**, cả 8 node `Khkk_G1..G8` đều `displayLabel = null`. Tức hôm nay cây và sidebar gọi cùng một nhóm bằng cùng một tên.
- **Route:** `onOpenAll → /khkk/list?group=${n}`. Đối chiếu K4b: `Layout.tsx:217` `khkkLeafPath(group,'List')` = `/khkk/list?group=${group}`, và leaf tĩnh `Khkk_List: '/khkk/list?group=1'` (`:63`) ⇒ **chuỗi TRÙNG KHÍT**.
- **Nối end-to-end (3 chặng, đo cả 3):** `KhkkListPage.tsx:79-80` đọc `searchParams.get('group')` + rào `^[1-8]$` → `:124` gửi `group` lên server → BE `ContractSigningPlanFeatures.cs:757-758` `q.Where(p => p.ApprovalGroup == request.ApprovalGroup)`. Không phải tham-số-trang-trí (khác hẳn bài K4a ①).
- **Sáng đúng mục sidebar:** `Layout.tsx:388-390` `isActive = pathname khớp && queryMatches(...)` (so khớp TẬP query) ⇒ `?group=n` từ cây ăn khớp leaf nhóm n. Lời khai lane "trang tự sáng đúng mục" **đúng, đã kiểm**.

## §4 SOI-2 — Phân phiếu, fallback, bất-biến Σ ✅ (1 MINOR)
- **KHÔNG có ca phiếu rơi khỏi cả 9 ngăn.** Vòng `:215-224` là if/else **toàn phần**: khớp `typeof g === 'number' && GROUPS.includes(g)` → ngăn n; **mọi thứ còn lại** (null · undefined · thiếu field · 0 · 9 · NaN · chuỗi) → `khkkUngrouped`. Không nhánh nào `continue`/bỏ qua.
- **Σ enforce ở đâu — trả lời thẳng: ở CẤU TRÚC, không phải ở kỷ luật.** `khkkLeaves` và các ngăn cùng sinh từ **một mảng `khkkItems`** bằng một phép phân hoạch; `stage.leaves` giữ vai danh sách phẳng canonical và `stageCount()` (`PipelineStageFolders.tsx:96`) vẫn đếm trên nó ⇒ chip số của folder cha **không thể** lệch tổng các ngăn. `console.warn` `:252` chỉ là chuông, và là **DEV-only**.
- Thứ tự đúng: ngăn "(chưa phân nhóm)" `push` ở `:238-245` **TRƯỚC** khi `reduce` tính Σ ở `:251` ⇒ chuông không tự báo động giả.
- **Ngăn lạc gần như không thể xảy ra** (đo BE): `ApprovalGroup` là `int` non-null, validator `InclusiveBetween(1,8)` (`:353-356`), default 1 (`:399`), backfill Mig 71 = 1, và **0 đường update** nào ghi lại field này. ⇒ nhánh fallback là phòng thủ, không phải đường sống.
- 🟡 **MINOR-1 — nhãn ngăn lạc hứa một đằng, đi một nẻo:** `onOpenAll: () => navigate('/khkk/list')` (`:243`). Không kèm `?group=` ⇒ BE **không lọc gì cả**, trang mở ra là **toàn bộ phiếu của cả 8 nhóm**, trong khi nhãn và tooltip nói "Mở danh sách: (chưa phân nhóm)". Sidebar cũng không sáng mục nào (query rỗng không khớp leaf nào). BE hiện **không có** bộ lọc "không thuộc nhóm nào" để trỏ tới, nên đây là hạn chế thật chứ không phải quên gõ. Đề nghị: bỏ `onOpenAll` cho riêng ngăn này (component đã có nhánh `<span>` khi thiếu `onOpenAll`, `:151-155`) — mất một cú bấm gây hiểu nhầm, không mất gì khác.

## §5 SOI-3 — Ngăn rỗng render THẬT ✅
Đo đường đi, không đọc lời khai — **3 chốt, không chốt nào lọc theo `length`**:
1. Dựng: `KHKK_APPROVAL_GROUPS.map(...)` — `.map` trên hằng 8 phần tử, **0 `.filter`** trong cả khối.
2. Vẽ: `PipelineStageFolders.tsx:287-295` `stage.groups !== undefined ? stage.groups.map(g => <GroupFolder…>)` — vẽ **thẳng mảng nhận được**, không điều kiện phụ.
3. Trong ngăn: `GroupFolder` `count === 0` → vẫn render `<details>` đủ (chevron + tên bấm được + `SoonChip`), thân in *"Chưa có phiếu ở nhóm này."*
- Nhánh *"Chưa có dữ liệu ở giai đoạn này."* (`:296`) nay **bị nhánh `groups` chặn trước** ⇒ GĐ2 rỗng hiện 8 ngăn thay vì 1 dòng. Đúng spec "HIỆN ĐỦ 8 kể cả rỗng".
- Không đụng `hideEmpty`: vẫn lọc ở tầng **stage** bằng `stageCount()` = `leaves.length` ⇒ đang tìm-kiếm mà gói không có KHKK thì cả folder GĐ2 ẩn, **không** đổ ra 8 ngăn rỗng.

## §6 SOI-4 — GĐ3/GĐ4 regression ✅ 0 ĐỔI HÀNH VI (chứng bằng tương đương từng vế)
Nghi-can số-1 là đúng chỗ phải nghi: builder GĐ3/4 **bị viết lại vòng lặp**. Tôi chứng tương đương 4 vế thay vì tin "cùng tập, cùng thứ tự":
| vế | CŨ (`seen` + lặp `peItems`) | MỚI (`Set` + lặp id) | kết |
|---|---|---|---|
| tập id | mọi `p.contractId` truthy | `if (p.contractId) add` | **bằng** |
| thứ tự | lần-xuất-hiện-ĐẦU trong `peItems` | `Set` giữ **thứ tự chèn**, chèn khi duyệt `peItems` | **bằng** |
| khử trùng | `seen.has → continue` | `Set` tự khử | **bằng** |
| dòng phụ | `p.tenGoiThau` (p = phiếu ĐẦU mang id đó) | `peItems.find(p => p.contractId === contractId)` = **cũng là phiếu ĐẦU** | **bằng** |
- **`c.type` KHÔNG bị đụng**: vẫn chỉ xuất hiện ở `onOpen` GĐ4 → `/hard-copies?type=${c.type}` (`:322`), khớp `HardCopiesPage.tsx:64-67`. Hành vi 7-loại-HĐ nguyên vẹn.
- **stage4 an toàn theo cấu trúc**: thân `if (linkage.canHardCopy && c?.hasSealedCopy)` chỉ đọc `c`, **0 tham chiếu `p`** — nên việc biến `p` biến mất khỏi scope vòng lặp không thể làm rơi HĐ. (Nếu có tham chiếu thì `tsc` đã đỏ; tôi kiểm cả hai đường.)
- **Union hôm nay đúng 1 nguồn** `pe.contractId` ⇒ tập HĐ **y hệt trước K5** ⇒ **0 ca HĐ đang hiện bị biến mất**. Vế "HĐ 2..N liên-danh vô hình" là **giới hạn dữ liệu có từ trước**, K5 không làm xấu đi.
- ⚪ Ghi chú (không phải lỗi): `peItems.find` nằm trong vòng ⇒ O(n·m); n = số phiếu 1 gói thầu nên vô hại.
- 🟡 **CLARIFY-2 — một dòng checklist đọc được 2 nghĩa:** *"GĐ2 by approvalGroup · GĐ3/4 by c.type"*. Lane đọc là **"kiểm trục, đừng đụng"**; câu hỏi SOI-4 của lead cũng đọc y vậy ("hành vi 7-loại-HĐ … có biến mất ca nào không"). Nghĩa còn lại — *GĐ3/4 cũng mở sub-folder theo 7 loại HĐ* — thì K5 mới làm nửa checklist. **Hai người cùng chọn một nghĩa không chứng minh nghĩa đó đúng ý owner.** Đề nghị ghim cách đọc vào commit message để nó không quay lại thành "sót".

## §7 SOI-5 — Chỗ cắm FLAG-1: 0 field bịa ✅
- `grep` mọi dòng `+` của diff chứa `lines` hoặc `contractIds`: **9 hit, TOÀN BỘ nằm trong `//` comment**. Dòng chạy được duy nhất đọc dữ liệu là `if (p.contractId)` — field **có thật** (`PeListItem:126`).
- Control âm: `grep "lines\|contractIds" fe-user/src/types/purchaseEvaluation.ts` = **0 hit** ⇒ đúng, `lines[]`/`contractIds` **không tồn tại** trên DTO list; nếu ai đó lỡ gõ thành mã chạy thì `tsc` đỏ ngay. Lời khai FLAG-1 của lane **đo lại thấy đúng**.
- Hình dạng "gom TẬP id" (`Set<string>`) là vế wire được thật, không phải hứa suông: thêm nguồn 2 = thêm 1 dòng tại `:290-291`, không phải sửa vòng lặp.

## §8 SOI-6 — SHA-pair ×2, kế thừa, và phép đo prod
- **Mirror:** `sha256` 2 file đổi ×2 app:
  `usePipelineStages.ts` = `edff99314e8b75de39b0123bcdad5d98d8081a5bd294e2e05b1ef784094a19ac` (khớp hash lane khai)
  `PipelineStageFolders.tsx` = `bed52f39d595c86a24afd27180146871d52e29943731ffb52f7caf23e66de936`
  Thêm phép **đối chứng độc lập**: `diff` từng cặp file = **rỗng cả hai** ⇒ identical theo byte, không chỉ theo hash.
- **`INDEX_PAGE_SIZE` = 200 KHÔNG đổi**, `indexTruncated` KHÔNG đổi, dòng cảnh báo *"Chỉ mục tải 200 bản ghi mới nhất…"* vẫn ở chân folder cha (`:301-305`). FLAG-3 (cảnh báo không lặp ở từng ngăn) **đúng như lane khai** — kế thừa, không phải mới.
- 🔵 **Đo prod (tôi tự curl, admin token, `http=200`):** `GET /api/contract-signing-plans?pageSize=200` → **`total = 0`**. Bảng KHKK trên prod **rỗng hoàn toàn**.
  - ⇒ Phân bố `approvalGroup` là **chân-lý-rỗng**: `{}`. Tôi **không** dùng nó để khẳng định hay bác bỏ điều gì về phân bố nhóm — khai thẳng là chưa đo được, không chờ.
  - ⇒ Nhưng nó cho **một sự thật dùng được ngay cho UAT**: hôm nay mở cây trên prod, GĐ2 của **mọi** gói thầu sẽ hiện **đúng 8 dòng rỗng chip "SẮP"**, 0 phiếu. Đó là ĐÚNG theo spec — cần nói trước để người UAT không đọc thành "hỏng".
- 🟡 **MINOR-3 — chữ "SẮP" trên ngăn rỗng:** `SoonChip` vốn nghĩa *"tính năng sắp có"*; đặt lên nhóm rỗng nó thành *"nhóm này sắp có"*, trong khi sự thật là *"nhóm này chưa có phiếu"*. Lane bám tiền lệ folder-GĐ nên **nhất quán**, nhưng K5 nhân nó lên **8 lần mỗi gói** và (theo phép đo prod ở trên) **100% ngăn đều rỗng ngày đầu**. Chữ trong thân ngăn đã nói đúng ("Chưa có phiếu ở nhóm này.") nhưng chỉ thấy sau khi bung. Quyết định thuộc owner, không phải lỗi mã.
- 🟡 **MINOR-4 — a11y: `<button>` lồng trong `<summary>`** (`PipelineStageFolders.tsx:141-160`). `<summary>` mang role=button ngầm ⇒ đây là **nội dung tương tác lồng nhau**, trình đọc màn hình có thể không đọc nút bên trong. Đã kiểm tiền lệ tại base `88e7ecf`: cả 3 `<summary>` của `PipelineTreePanel` và cả 3 của `PurchaseEvaluationsListPage` **chỉ chứa `span`/`svg`** ⇒ K5 là **khuôn MỚI**, không có tiền lệ để bám. Bàn phím/chuột vẫn chạy đúng (`preventDefault` + `stopPropagation` chặn nhịp đóng/mở).
- 🟡 **MINOR-5 — bằng chứng mạnh nhất chết theo phiên:** "15/15 OK" của lane chạy bằng script `new Function` dựng tại chỗ, **không commit**. FE **không có hạ tầng test nào** (`fe-user/package.json` chỉ có `dev|build|lint|preview`, không `vitest`/`jest`). Vật canh bất-biến Σ còn lại trong repo là `console.warn` **DEV-only** — bản prod bị loại bỏ khi build. Tức nếu mai ai sửa vòng phân hoạch thành lọc, **không gì đỏ**. Không đòi lane dựng hạ tầng test trong wave này; ghi để đừng ai đọc "15/15" thành "đã có lưới an toàn".
- ⚪ MINOR-6: với GĐ2, `toKhkkLeaf` chạy **2 lần/phiếu** (1 cho `khkkLeaves` đếm, 1 cho ngăn) ⇒ `stage.leaves` thành mảng **chỉ để đếm**, leaf trong đó không bao giờ được vẽ. Vô hại, nhưng là mỡ thừa nếu sau này gói có nhiều phiếu.

## §9 Các cổng còn lại
| Cổng | Kết | Ghi chú |
|---|---|---|
| Cat 1 Wire/claim | **PASS** | 0 `// Mock`, 0 `alert(`, 0 TODO/placeholder trong dòng `+`. Không phải wave BE nên không có verb để curl; đã curl prod để đo dữ liệu (§8). |
| Cat 2 Schema | **N-A** | `git diff --name-only -- src tests` = **0 file**. 0 migration, 0 entity, 0 DTO. |
| Cat 3 Security | **PASS** | Gate GĐ2 vẫn `KHKK_MENU_KEYS = ['KeHoachKyKet']` — đúng key policy của endpoint, KHÔNG OR `Khkk_*` (gotcha **#85**/#82 giữ nguyên). Ngăn nhóm nằm **bên trong** thân GĐ2, mà `locked` chặn trước cả `groups` ⇒ không mở thêm mặt tấn công, không đẻ lệnh gọi API mới. |
| Cat 4 Code quality | **PASS** | `tsc -b` ×2 = 0 (tôi chạy). Mirror byte-identical. Anti-fiddle: đúng 4 file trong phạm vi spec, 0 file lạc. `LeafList` tách ra là **bê nguyên văn** khối JSX cũ (đối chiếu từng dòng khối `-` với thân hàm mới: `ul`/`li key`/`className`/badge/secondary trùng khít) ⇒ tách hàm KHÔNG kèm sửa lén giao diện. |
| Cat 5 Test | **N-A (khai rõ)** | FE không có hạ tầng test — xem MINOR-5. Không có test BE để tăng vì 0 file BE. |
| Cat 6 Writing | **N-A** | Diff là mã + chú thích nội bộ, không phải văn bản hướng ra ngoài. Chuỗi hiển thị mới đều tiếng Việt đủ dấu: *"Chưa có phiếu ở nhóm này."*, *"(chưa phân nhóm)"*, *"Mở danh sách: …"*. |

## §10 VERDICT
**PASS — 0 blocker.** 6/6 mục SOI của lead đều đo được và đều đạt; 2 chỗ tôi nghi nhất (mất phiếu im lặng · regression GĐ3/4) đều **chứng được là không xảy ra**, bằng tương đương từng vế chứ không bằng lời khai. Phần lõi K5 là loại hiếm: bất-biến Σ được bảo đảm bởi **cấu trúc** (một mảng nguồn, một phép phân hoạch, một chỗ đếm), nên nó không phụ thuộc vào ai đó nhớ cộng cho khớp.
**Còn treo (không chặn commit):** MINOR-1 nhãn ngăn lạc đi sai chỗ · CLARIFY-2 cách đọc dòng checklist GĐ3/4 · MINOR-3 chữ "SẮP" · MINOR-4 nút lồng trong summary · MINOR-5 không có lưới test · MINOR-6 mỡ thừa.
**Trước khi commit — 1 việc THAO TÁC:** `menus.json` (44.656 B) đang nằm untracked ở gốc repo; **đừng `git add -A`**, hãy add đúng 4 file code (+ artifact lane/gate nếu muốn).

**Câu nặng nhất:** cây nay hiện đủ 8 nhóm, nhưng prod đang có **0 phiếu KHKK** (`total=0`, tôi tự curl) — nên thứ người UAT nhìn thấy ngày đầu là **8 dòng "SẮP" rỗng ở mọi gói thầu**, đúng spec mà rất dễ bị đọc thành hỏng; cần nói trước.

GATE-K5-END
