GATE-K6: PASS-WITH-FLAGS 5 — luật ẩn ĐÚNG và N=15 tái-lập được trên DỮ LIỆU PROD SỐNG (không chỉ trên seed), nhưng comment mới nhồi 3 con-số và 1 trong 3 ("28 leaf") đã SAI ngay lúc land.

# Gate K6 — reviewer adversarial (S167, 2026-08-01)

Đối tượng: diff UNCOMMITTED `fe-admin/src/components/Layout.tsx` (+52/−19).
Spec pin: `sub-invest-fable-b2-cum2.md:67-100` (K6 mục 1-4 + acceptance C1-C4).
Lane artifact: `sub-implfe-k6.md`.
Phép đo mạnh nhất của lượt này: **cây menu LIVE PROD** (`GET https://api.solutions.com.vn/api/menus/me`, token admin, HTTP 200, 44.656 byte) đem áp CHÍNH tập key cắt ra từ file đã ship. Lane chỉ đo được từ SEED; tôi đo được thực tế đang chạy.

---

## R0 — Rào residual-write (đo TRƯỚC khi đọc diff)

`git status --porcelain` cho 8 file `M` + 3 file `??`. Trừ nhóm `.claude/*` (governance — ngoài phạm vi gate; trong đó `.claude/WAL.md` và `.claude/sessions/session-9/_tiep-4.md` là nghi-thức `/tiep` của lead, KHÔNG phải dư-lượng của lane), phần cây mã chỉ có **đúng 1 file**: `fe-admin/src/components/Layout.tsx`.

- `git diff --stat -- fe-admin/src` → `1 file changed, 52 insertions(+), 19 deletions(-)` — khớp con số lane khai.
- **0 residual-write.** Không có file BE, không có file `fe-user`, không có file test nào bị chạm.
- Đo lại `--stat` một lần nữa ngay trước khi chốt verdict (chống "cây phình giữa review"): vẫn đúng 1 file mã, 52/19.

---

## R1 — 12-key: so TỪNG CHỮ vs spec + chống gõ-nhầm

Tôi **không gõ tay lại** tập key. Tôi cắt nó ra khỏi file đã ship bằng script riêng (`k6-verify.cjs`, regex bắt khối `const ADMIN_HIDDEN_KEYS = new Set<string>([...])`, xoá comment trước rồi mới lấy chuỗi trong nháy đơn — vì comment trong khối có chứa chữ `Ct_*` dễ lọt vào phép trích).

Kết quả trích (n = 12, đúng thứ tự trong file):

```
["PurchaseEvaluations","KeHoachKyKet","Contracts","HopDongCung","Forms","Reports",
 "Master","Hrm","Off","Personal","Workflows","PeWorkflows"]
```

So với 12 key spec liệt ở `sub-invest-fable-b2-cum2.md:73-86`: **khớp 12/12, đúng từng ký tự, đúng thứ tự, 0 thừa, 0 thiếu, 0 sai chính tả.**

Luật ghép cũng khớp nguyên văn spec — `Layout.tsx:246`:

```ts
return ADMIN_HIDDEN_KEYS.has(key) || key.startsWith('Ct_')
```

### R1b — Chống "key gõ đúng chính tả nhưng không tồn tại" (typo-noop)

Một key viết sai sẽ **không báo lỗi gì cả**: `Set.has()` trả `false`, module vẫn hiện, `tsc` vẫn xanh. Nên tôi kiểm sự TỒN TẠI của từng key trong cây prod sống:

| Key | Có trong cây prod? | Parent thật |
|---|---|---|
| PurchaseEvaluations · KeHoachKyKet · Contracts · HopDongCung · Forms · Reports · Master · Hrm · Off · Personal | OK (10/10) | `null` (root) |
| Workflows · PeWorkflows | OK (2/2) | `System` |

**Control-âm** (3 key bịa gần giống, để chứng phép kiểm này phân biệt được chứ không phải luôn trả OK): `PurchaseEvaluation` → MISS · `Offi` → MISS · `Personnel` → MISS.

⇒ Cả 12 key đều trỏ vào node có thật; 2 key `Workflows`/`PeWorkflows` đúng là CON của `System` nên bắt buộc phải liệt riêng như spec nói.

### R1c — Tập ẩn có bỏ sót root nghiệp vụ nào không?

Cây prod có **đúng 12 root**: `Dashboard · PurchaseEvaluations · KeHoachKyKet · Hrm · Off · Personal · Contracts · HopDongCung · Forms · Reports · Master · System`. Tập ẩn phủ 10/12; 2 root còn lại là `Dashboard` và `System` — đúng bằng phần spec CHỪA (`:91`). Không có root nghiệp vụ nào lọt lưới.

Đối chiếu nguồn mã: `DbInitializer.cs:1926-2049` (danh sách `tree`) cũng cho đúng 12 tuple có `Parent = null`, và `DbInitializer.cs:2177` là **điểm INSERT menu-row DUY NHẤT** trong toàn bộ backend (grep `new MenuItem|MenuItems.Add` chỉ 1 hit ghi). Nghĩa là mọi menu-row hợp lệ đều sinh từ đúng danh sách này.

---

## R2 — C1: N = 15, đo trên PROD chứ không chỉ trên seed

Cây prod: **200 node · 166 lá trước lọc** (trùng khít con số lane dựng từ seed — hai đường đo độc lập ra cùng một cây).

Áp bộ lọc đệ quy (tôi dựng lại `filterForAdmin` độc lập, dùng tập key cắt từ file):

**15 lá sau lọc**, đúng tên từng mục:

```
Dashboard · Users · Roles · Permissions · MenuVisibility
AwV2_DuyetNcc · AwV2_DuyetNccPhuongAn
AwV2_KhkkN1 · AwV2_KhkkN2 · AwV2_KhkkN3 · AwV2_KhkkN4
AwV2_KhkkN5 · AwV2_KhkkN6 · AwV2_KhkkN7 · AwV2_KhkkN8
```

Trùng 15/15 với danh sách lane khai ở `sub-implfe-k6.md:41-47`.

### R2b — Hai phép kiểm lane KHÔNG làm, mà C1 thật sự cần

C1 hỏi "**còn THẤY** bao nhiêu lá", mà `filterForAdmin` mới là bước một. Còn hai bước nữa mới ra được cái mắt nhìn thấy:

1. **`resolvePath` trả `null` ⇒ `MenuLeaf` return null ⇒ lá biến mất im lặng** (`Layout.tsx:331-333`, gotcha #50). Tôi kiểm từng lá trong 15 lá: 5 lá đầu nằm trong `staticMap` (`Dashboard:56`, `Users:63`, `Roles:64`, `Permissions:65`, `MenuVisibility:66`); `AwV2_DuyetNcc`/`AwV2_DuyetNccPhuongAn` đi nhánh `:178`; 8 lá `AwV2_KhkkN{1..8}` đi nhánh `:188`. **0 lá bị drop.** Vậy 15 lá lọc được cũng đúng là 15 lá hiện ra.
2. **Nhóm bị rỗng-hoá**: `MenuNodeRenderer:256` phân loại theo `children.length > 0`, nên một GROUP sống sót mà mất sạch con sẽ tụt xuống render như LEAF. Tôi so cây trước/sau lọc theo từng node: **0 group bị rỗng-hoá**. (`System` giữ 5 con: 4 leaf quản trị + `ApprovalWorkflowsV2`; `ApprovalWorkflowsV2` giữ đủ 10 con.)

### R2c — Control-âm (chứng phép đếm phân biệt được, không phải luôn ra 15)

| Bỏ key khỏi tập | Lá sau lọc |
|---|---|
| `KeHoachKyKet` | 63 (+48) |
| `Master` | 29 (+14) |
| `Off` | 29 (+14) |
| `Personal` | 16 (+1) |
| tập RỖNG (giữ mỗi luật `Ct_`) | 125 |

Con số `+48` trùng khít control-âm lane khai (15 → 63). Phép đo có răng.

---

## R3 — C2 (fe-user 0-diff) và C4 (không chạm route/quyền)

**C2 —** `git diff --stat -- fe-user/src` → **rỗng, 0 file**. Control-dương chạy cùng lệnh: `git diff --stat -- fe-admin/src` → 1 file. Vậy "rỗng" là rỗng thật, không phải lệnh hỏng.

**C4 —** ba tầng chứng, tầng sau mạnh hơn tầng trước:

1. *Trong diff:* grep các dòng `+` tìm `usePermission|PermissionGuard|Route|navigate|path:|element=` → đúng **1 hit, và là dòng COMMENT** ("Route fe-admin còn nguyên…"), 0 dòng mã. Control-dương: `ADMIN_HIDDEN_KEYS` cho 2 hit.
2. *Trong kiến trúc:* toàn fe-admin chỉ có **2 nơi** đọc `menu` từ `AuthContext` — `Layout.tsx:362` (lọc, chỉ để render) và `usePermission.ts:15` (**đọc cây THÔ, không lọc**). Nghĩa là `can()` và `PermissionGuard` hoàn toàn không biết K6 tồn tại. Đây mới là chứng cấu trúc cho "display-only", mạnh hơn việc diff không chạm route.
3. *Trên prod, bằng curl thật* (token admin):

| Verb | Endpoint | Kỳ vọng | Thực tế |
|---|---|---|---|
| GET | `/api/menus/me` | 200 | **200** |
| GET | `/api/purchase-evaluations?page=1&pageSize=1` | 200 | **200** |
| GET | `/api/contract-signing-plans?page=1&pageSize=1` | 200 | **200** |

Hai module vừa bị ẩn khỏi menu vẫn trả 200 cho admin ⇒ đúng là bớt lối đi, không thu hồi quyền. Route đích cũng có thật trong `fe-admin/src/App.tsx`: `/purchase-evaluations` (`:88`) và `/khkk/list` (`:108`) — hai ví dụ mà comment mới nêu đích danh đều là route sống, không rơi vào catch-all `:124`.

---

## R4 — Chất lượng comment (mục 4 của gate)

Ba thứ gate yêu cầu thì comment ĐỦ CẢ BA:

- **Lineage 2 đảo, đủ 3 mốc** — `:203` S29 ẩn → `:204-205` S57 bỏ ẩn (đảo lần 1) → `:206-207` S164 QĐ7 ẩn lại (đảo lần 2).
- **Câu chốt** — `:209-210`: *"cả hai đều từng đúng, mốc sau thắng mốc trước. Đừng 'khôi phục' S57 vì tưởng gặp bug."*
- **Display-only #82 khai rõ** — `:212-216`, có đủ vế "muốn CHẶN thật thì phải làm ở BE authz `[Authorize(Policy=…)]`". Ràng buộc ngược với `resolvePath` cũng được giữ lại (`:224-226`), nên khớp nối K4a không mất.

Nhưng vế thứ tư thì hỏng:

**MAJOR-1 — comment nhồi 3 con-số, 1 số đã sai ngay lúc land.** Gotcha #84 mục (4) viết thẳng: *"Comment quanh seeder nêu LUẬT + lệnh grep tự-kiểm, CẤM ghi số-đếm (số thành nợ tự-lan — 2 lần lệch trong 1 ngày @S159)"*. Diff đưa vào 3 số:

| Vị trí | Câu | Đo trên prod | Phán |
|---|---|---|---|
| `:229` | "Khkk_G1..G8 + **48 leaf** chết theo" | 48 lá dưới `KeHoachKyKet` | ĐÚNG |
| `:221` | "**42 leaf** `Khkk_G{2..8}_*`" | 42 | ĐÚNG |
| **`:245`** | "mở lại root thì **28 leaf** không ùa về theo" | **42 lá** (49 key `Ct_*` = 7 group + 42 leaf) | **SAI** |

Số 28 là con số của thời `Ct_<Type>_<Group|List|Create|Pending>`; S155 đợt-2 đã thêm `WfView`/`Approved`/`Deleted` nên mỗi loại HĐ nay 6 lá, 7 loại = 42. Số này còn nằm nguyên trong skill `permission-matrix` (dòng `× 7 type = 28 leaf`) nên nhiều khả năng được chép từ doc stale chứ không phải tự đếm — đúng bệnh "số thành nợ tự-lan" mà #84 cảnh báo. Tự nó không đổi hành vi chạy, nhưng nó là một câu SAI mới toanh được thêm vào file, ngay dưới đoạn đang dặn người sau đừng đọc nhầm.

**Acceptance để đóng MAJOR-1:** hoặc bỏ cả ba số và thay bằng luật + lệnh tự-kiểm (ví dụ nêu "mọi key `Ct_*`" kèm lệnh grep đếm tại chỗ), hoặc giữ hai số đúng và sửa 28 → 42 kèm ghi rõ mốc S155. Không nhận phương án "để nguyên vì chỉ là comment".

**MINOR-2 — câu mở đầu xếp sai tầng.** `:197-198` liệt "Hệ thống (Người dùng / Vai trò / Phân quyền / Menu eOffice) · Quy trình duyệt (Mới)" bằng cùng một dấu chấm giữa dòng, đọc thành hai mục ngang hàng; thực tế `ApprovalWorkflowsV2` là CON của `System` (`DbInitializer.cs`, Order 96). Lồng nó vào trong ngoặc của "Hệ thống" là hết mơ hồ.

**MINOR-3 — `|| key.startsWith('Ct_')` nay là nhánh chết.** `Contracts` đã bị ẩn ở tầng root nên không node `Ct_*` nào còn đi tới được luật prefix. Comment `:243-245` đã tự khai điều này và nêu lý do giữ (phòng khi mở lại root). Tôi ĐỒNG Ý giữ — chỉ ghi lại để lượt sau không tưởng là vừa phát hiện ra thứ mới.

---

## R5 — Hệ quả không ai khai: 3 module mất lối vào ở CẢ HAI app

**CLARIFY-1 (cần owner biết, không phải lỗi mã).** Cây prod cho thấy 4 node đang mang `isVisible = false`: `Hrm`, `Off`, `Personal`, `Pe_DuyetNccPhuongAn` (và `Pe_DuyetNcc` có `displayLabel` admin đặt lại).

- `fe-user/src/components/Layout.tsx:243` lọc `n.isVisible !== false` ⇒ **`Hrm`/`Off`/`Personal` hiện đã tắt bên eOffice**.
- `fe-admin` KHÔNG đọc `isVisible` (grep 0 hit) ⇒ trước K6, fe-admin là **lối vào menu cuối cùng** của ba module đó.
- Sau K6, ba key này nằm trong tập ẩn ⇒ **không còn mục menu nào cho `Hrm`/`Off`/`Personal` ở bất kỳ app nào.**

Vẫn còn đường thoát: quyền không đổi (deep-link chạy), và trang `Menu eOffice` (`MenuVisibility`) vẫn nằm trong 15 lá sống nên admin bật lại được. Nhưng spec K6 chỉ nói "module nghiệp vụ làm việc ở fe-user" — với ba module này thì fe-user đang tắt sẵn, nên hệ quả thật là "biến mất khỏi menu", khác với điều câu spec gợi ra. Cần owner xác nhận đây là ý muốn, hoặc bật `isVisible` lại bên eOffice trước khi K6 lên prod.

---

## R6 — Build tươi + kiểm chứng con số lane khai

- `npx tsc -b` trong `fe-admin`: **exit 0**, không phát ra chẩn đoán nào. Chạy SAU diff nên không dính bẫy snapshot cũ (gotcha #68).
- `npm run build`: PASS, và tái lập **trùng từng chữ số** với artifact lane — `dist/assets/index-B0OrpamE.js` 1,726.07 kB (gzip 429.28) · `index-AgG72YRZ.css` 86.93 kB (gzip 14.51). Tên file băm trùng nghĩa là nội dung bundle giống hệt ⇒ claim đo-lường của lane là thật, không phải chép lại.
- Hai cảnh báo (`chunk > 500 kB`, `INEFFECTIVE_DYNAMIC_IMPORT`) đúng là có sẵn từ trước, không do diff.

**MINOR-4 — câu khai của lane hơi rộng.** `sub-implfe-k6.md:56` viết *"`git diff --stat` = đúng 1 file"*; chạy trần thì `git diff --stat` cho 8 file (7 file `.claude/*` đang dirty). Câu đúng phải là "đúng 1 file MÃ". Không sai bản chất, nhưng người đọc sau có thể tưởng cây sạch tuyệt đối.

---

## R7 — Disposition 3 FLAG của lane

**FLAG-1 (N = 15) — CHẤP NHẬN, và tôi xác nhận bằng đường đo độc lập.** Lane nói spec quên cộng 8 leaf Designer K3 vào công thức của chính nó: đúng. Spec `:97` viết `N = Dashboard + Users + Roles + Permissions + MenuVisibility + |leaf AwV2 trong DB|` rồi chốt "7 (nếu 2 AwV2) / 8 (nếu 3)", trong khi cùng dòng lại nói "SAU K3 = N+8". K3 đã land và đã lên prod (10 row `AwV2*` đang sống), nên đáp số hiện tại là 15, không phải 7/8. Số 15 của tôi đo từ payload prod, không đo từ seed.

**FLAG-2 (dư-lượng prod: có row `AwV2_Contract` chèn tay không?) — ĐÓNG.**
- Đường sqlcmd: **không chạm được, tôi đã thử chứ không đoán.** Binary có trên máy (`/c/Program Files/Microsoft SQL Server/Client SDK/ODBC/170/Tools/Binn/sqlcmd`), nhưng chuỗi kết nối prod là `Server=.\SQLEXPRESS` + `Password=__SET_VIA_SECRETS__` (`appsettings.Production.json.example:3`) — tức DB nằm local trên VPS, không có endpoint lẫn mật khẩu nào từ máy này ra tới. Khai đúng: không đo được bằng SQL.
- Đường thay thế **mạnh hơn**: `/api/menus/me` trên prod với token admin. Kết quả: **đúng 10 key `AwV2*`** — `AwV2_DuyetNcc`, `AwV2_DuyetNccPhuongAn`, `AwV2_KhkkN1..N8`. **Không có `AwV2_Contract`.** Đồng thời 8 group `Khkk_G1..G8` đều đã có mặt trên prod.
- Phụ chứng phía mã: grep `AwV2_Contract` toàn repo = **0 hit**, với control-dương `AwV2_KhkkN1` = 2 hit (nên biết chắc lệnh grep còn sống). Nhánh `resolvePath:178` có nhận code `Contract` nhưng đó là bộ giải route chờ sẵn, không phải menu-row — lane phân biệt đúng chỗ này.
- **Giới hạn của phép đo, khai thẳng:** `/api/menus/me` đã lọc theo quyền (`GetMyMenuTreeQuery` chỉ trả node `CanRead` hoặc có con `CanRead`). Một row chèn tay mà không kèm row `Permissions` sẽ vô hình trong phép đo này — nhưng nó cũng vô hình trên sidebar admin, tức không ảnh hưởng con số C1. Vậy: C1 đóng trọn vẹn; còn mệnh đề "bảng `MenuItems` không tồn tại row nào khác" thì đây chỉ là cận dưới. Bối cảnh liên quan: gotcha #84 nói mọi sửa tay trên prod đều tạm thời nếu có code-path ngược chạy lúc khởi động, nên kể cả có row chèn tay thì nó cũng không bền.

**FLAG-3 (nhánh chết trong `resolvePath`) — GIỮ, đừng dọn lượt này.** Ba lý do: (1) file này đã đảo hai lần, dọn đi là xoá đúng thứ phải dựng lại nếu có lần đảo thứ ba; (2) nhánh chết ở đây là dữ liệu tĩnh trong `staticMap`, không tốn gì lúc chạy và `tsc` không coi là lỗi; (3) dọn sẽ phình diff ra ngoài phạm vi spec K6, tức tự chuốc lấy lỗi trôi-phạm-vi. Điều kiện kèm theo: giữ nguyên cảnh báo ngược ở `:224-226` (gỡ `KeHoachKyKet` khỏi tập thì phải nới regex `Khkk_G*` trước) — tôi đã kiểm cảnh báo này còn ĐÚNG: `staticMap` hiện chỉ có 6 key `Khkk_*` không-infix của nhóm 1, chưa hề có nhánh `Khkk_G{n}_*`. Nếu sau này muốn dọn thì tách một lượt riêng.

---

## R8 — Bảng 6 hạng mục

| Hạng mục | Kết luận | Ghi chú |
|---|---|---|
| 1. Wire BE / feature claim | PASS | Diff thuần FE hiển thị; 3 endpoint prod trả 200 chứng quyền không đổi |
| 2. Schema integrity | N-A | 0 migration, 0 entity, 0 menu-key mới |
| 3. Security | PASS | `usePermission` đọc cây thô ⇒ guard không bị nới/siết; không đụng `[Authorize]` |
| 4. Code quality | PASS-WITH-FLAGS | `tsc -b` exit 0, build tái lập trùng băm; trừ MAJOR-1 comment sai số |
| 5. Test coverage | N-A có điều kiện | fe-admin không có hạ tầng test (`package.json` chỉ `dev/build/lint/preview`); 15 lá này chỉ được canh bởi phép đo thủ công của gate, không có lưới tự động |
| 6. Writing quality | PASS | Comment tiếng Việt câu trọn, đủ dấu; nội bộ nên không soi theo chuẩn outward |

## R9 — Tổng hợp finding

| # | Mức | Vị trí | Nội dung |
|---|---|---|---|
| 1 | MAJOR | `fe-admin/src/components/Layout.tsx:245` | "28 leaf" sai (thật 42 lá / 49 key `Ct_*`); 3 số-đếm trong comment vi phạm gotcha #84 mục (4) |
| 2 | CLARIFY | `fe-user/src/components/Layout.tsx:243` + dữ liệu prod | `Hrm`/`Off`/`Personal` đang `isVisible=false` bên eOffice ⇒ sau K6 mất lối vào menu ở cả hai app; cần owner xác nhận |
| 3 | MINOR | `Layout.tsx:197-198` | Câu mở đầu xếp "Quy trình duyệt (Mới)" ngang hàng "Hệ thống", thực tế là con của `System` |
| 4 | MINOR | `Layout.tsx:243-246` | Luật prefix `Ct_` nay là nhánh chết — cố ý, đã khai, đồng ý giữ |
| 5 | MINOR | `sub-implfe-k6.md:56` | "`git diff --stat` = đúng 1 file" nên viết là "1 file MÃ" (thực tế 8 file dirty) |

**Không có blocker.** Cả 5 mục đều không chặn commit về mặt hành vi; MAJOR-1 nên sửa trước khi commit vì nó là một câu sai mới được thêm vào, mà sửa chỉ tốn một dòng.

## R10 — Những chỗ diff này chịu được soi (ghi để không chỉ kể lỗi)

- Chọn liệt ROOT thay vì liệt từng leaf là đúng cấu trúc: `filterForAdmin:249-253` cắt đệ quy cả cây con, nên tập 12 key tự động theo kịp mọi leaf BE thêm sau này mà không phải bảo trì.
- Ràng buộc ngược `resolvePath ↔ tập ẩn` (di sản K4a) không bị mất khi viết lại trọn khối comment — đây là chỗ dễ rơi nhất khi thay comment cũ bằng comment mới.
- Con số lane khai đều tái lập được: 166 lá trước lọc, 15 lá sau lọc, control-âm +48, tên bundle sau build. Không có con số nào thuộc loại "tự khai không kiểm được".

END gate-k6 — VERDICT=PASS-WITH-FLAGS 5
