# SUB-REVIEWER — W1 KHKK adversarial diff review (S161, 2026-07-29)

> Ghi-đĩa-TRONG-lúc-làm (chống #53 — 3 vai trước chết trong wave này). File = thứ sống sót.
> Scope: toàn bộ M/?? trong `git status` (trừ `.claude/`). 12 file M (+990/−19) + 20 file ?? (~8.2K dòng, trong đó Designer.cs 6.960 = sinh máy).
> KHÔNG phủ lại: FK/index/maxLength/parity 2 DB (database-agent 8/8) · fault-inject test (test-specialist 4 test) · suite 566/0 + build 0W/0E (lead).

## TIẾN TRÌNH
- [x] Trục 1 — seeder `SeedKeHoachKyKetAccessAsync` (5 sub-check a→e)
- [x] Trục 5 — MenuKeys.All (vị trí + tác dụng phụ)
- [ ] Trục 2 — CodeGenerator twin-diff + DI
- [ ] Trục 3 — 3 site AWV2AdminFeatures + grep-cùng-lớp
- [ ] Trục 4 — FE containment 2 lớp
- [ ] Trục 6 — boundary

---

## TRỤC 1 — Seeder (DbInitializer.cs)

**(a) Call-site thứ tự — PASS.** `RevokeTemporarilyHiddenModulesAsync` `:2096` → `SeedProcurementMasterAccessAsync` `:2102` → **`SeedKeHoachKyKetAccessAsync` `:2109`**. Đứng SAU revoke ⇒ thắng revoke. Cũng SAU `SeedAllRolesReviewReadPermissionsAsync` `:2089` (nhánh read-only skip-existing) ⇒ không bị nhánh nào hạ cờ về sau. Không có seeder nào chạy sau `:2109` (2 call S65/S69 đã comment-out `:2119`/`:2128`).

**(e) Revoker KHÔNG phủ Khkk_/KeHoachKyKet — PASS (rủi ro 2-chiều gotcha #84 KHÔNG hiện thực).** Đọc thân `RevokeTemporarilyHiddenModulesAsync:2296-2310`: predicate CHỈ 3 nhánh `MenuKey.StartsWith("Hrm") || StartsWith("Off") || == MenuKeys.Personal`. Nhánh S92 (Contracts/Master/Catalog) đã GỠ @S159. Không prefix nào ăn `Khkk_*` hay `KeHoachKyKet` ⇒ grant không bị lật ở restart kế. (Ghi chú: thứ tự `:2109` sau `:2096` vẫn giữ đúng phòng khi revoker sau này mở rộng prefix.)

**(b) UPGRADE-if-exists — PASS.** `:2414-2470`: `existing.TryGetValue((role.Id,key), out row)` → nâng từng cờ `if(!row.CanX)` → `continue`; nhánh else `db.Permissions.Add(...)` chỉ chạy khi row CHƯA có (fresh DB). Đúng khuôn `SeedProcurementMasterAccessAsync:2375-2394`, KHÔNG phải insert-only NO-OP (F-S1 đóng). Khác khuôn gốc 2 điểm CÓ CHỦ ĐÍCH: (i) key dictionary là tuple `(RoleId, MenuKey)` vì quét 13 role thay vì 1 → đúng, vì `.Where(p => keys.Contains(p.MenuKey))` không lọc role nên `ToDictionary(p => p.MenuKey)` kiểu cũ sẽ **ném `ArgumentException` trùng khoá** — implementer tránh được bẫy này; (ii) thêm vế CanDelete.

**(c) CanDelete chỉ Drafter+Admin, KHÔNG hạ cờ — PASS.** `allowDelete = role.Name == AppRoles.Admin || role.Name == AppRoles.Drafter`. Mọi phép gán đều dạng `if (!row.CanX) row.CanX = true` ⇒ monotonic tăng, 0 câu nào set `= false` trên nhánh update. Nhánh insert đặt `CanDelete = allowDelete` (role khác = false) — đúng.

**(d) 7 key khớp bộ seed menu-row — PASS (so từng chuỗi).** Seeder `:2419-2424` = `MenuKeys.KeHoachKyKet` + `Khkk_WfView, Khkk_List, Khkk_Create, Khkk_Pending, Khkk_Approved, Khkk_Deleted`. Menu-row `:1779-1786` = `Khkk_G1` + đúng 6 chuỗi trên. `Khkk_G1` CỐ Ý loại (group điều hướng, CanRead-all đã có) — khai trong comment `:2417-2418`. 0 typo. Phép đếm máy: tập literal `Khkk_*` trong TOÀN file = đúng 7 chuỗi `{G1, WfView, List, Create, Pending, Approved, Deleted}` — không có key thứ 8 lạc đâu đó.

**2 rủi ro phụ đã truy và ĐÓNG (không thành flag):**
- `existing.ToDictionary((RoleId,MenuKey))` có thể ném `ArgumentException` nếu DB có row trùng ⇒ đã kiểm `PermissionConfiguration.cs:26` có `HasIndex(RoleId, MenuKey).IsUnique()` ⇒ không thể trùng.
- Nhánh INSERT (fresh DB) cần MenuItems tồn tại trước vì FK `Permission.MenuKey → MenuItems.Key` (`PermissionConfiguration.cs:21-24`) ⇒ `SeedMenuTreeAsync` `:109` chạy TRƯỚC `SeedAdminPermissionsAsync` `:110` (ungated) ⇒ FK an toàn.

**F-1 (MAJOR, evidence-gap KHÔNG phải lỗi mã) — acceptance §③-B "đo CỜ" hiện KHÔNG đo được ở đâu.** Đo đĩa+DB @review: `SolutionErp_Dev` có `sys.tables=96`, mig cuối `20260729122015_AddContractSigningPlans` (Mig 69 đã apply), **nhưng `SELECT COUNT(*) FROM Permissions WHERE MenuKey LIKE 'Khkk%' OR MenuKey='KeHoachKyKet'` = 0** và `MenuItems` cũng **0 row Khkk** (menus=100 vs prod 142) ⇒ DB Dev CHƯA từng chạy app sau S159 ⇒ **cả 2 nhánh (upgrade trên prod · insert trên fresh) chưa nhánh nào chạy thật lần nào**. Không chặn commit (code đúng khuôn, test-specialist có test seeder-reflection), nhưng **acceptance F-04 chỉ đóng được sau restart prod** — đừng tick trước.

---

## TRỤC 5 — MenuKeys.All

**Vị trí + đúng array — PASS (đo máy, không đọc mắt).** Parse `public static readonly string[] All = [...]` → **55 entry**, có `KeHoachKyKet`, **KHÔNG có** `HopDongCung` (GĐ4 vẫn ngoài All — đúng chủ đích W1). Dòng thêm = `MenuKeys.cs:179`, nằm TRONG array (giữa `Personal` :178 và `System` :180), không phải trong comment. **Đúng 1 key vào All trong cả diff** — 0 key ngoài chủ đích.

**Tác dụng phụ — quét 7 call-site `MenuKeys.All` toàn repo:** `Program.cs:82` (sinh 4 policy — chủ đích) · `DbInitializer.cs:2057` (Admin full-CRUD; prod row đã có ⇒ skip-existing, nhưng seeder mới `:2109` nâng đủ cờ cho CẢ Admin ⇒ không hở) · `DbInitializer.cs:2209` (`.Where(InReviewScope)` = `StartsWith("Pe_")` ⇒ KeHoachKyKet không lọt) · `AuthorizePolicyRegressionTests.cs:138` (lưới policy-∈-All — nay hợp lệ) · 3 chỗ còn lại là comment. ⇒ 0 tác dụng phụ ngoài ý muốn.

**Gotcha #35 (root có con phải extend 3 chỗ `GetMyMenuTreeQuery`) — KHÔNG áp.** Đọc `GetMyMenuTreeQuery.cs:56-84`: 4 inherit-root hardcode (Contracts/Workflows/PurchaseEvaluations/PeWorkflows). `KeHoachKyKet` KHÔNG cần vào đó vì 7 row `Khkk_*` có Permission TƯỜNG MINH (không sống nhờ kế thừa). Đúng thiết kế — nhưng nghĩa là **quên grant 1 leaf = leaf đó biến mất im lặng**, không có lưới kế thừa đỡ.

**F-2 (MAJOR) — canonical drift `docs/STATUS.md` chưa cập nhật, mà chính row đó tự ràng buộc.** `docs/STATUS.md:464` `| Menu keys | **54** |` và `:463` `| Policies | **216** |`, trong đó `:463` viết nguyên văn *"row này BUỘC đổi CÙNG row `Menu keys`: thêm 1 menu key ⇒ 55 × 4 = 220"*. Đo mã: `|All| = 55` ⇒ **Policies thật = 220**. Diff KHÔNG đụng `docs/` ⇒ commit này làm lệch cả 2 số canonical + `governance-detectors.ps1` `Get-StatusValue` resolve số cũ. Fix 1 dòng: sửa `Menu keys → 55` + `Policies → 220` cùng commit (đếm consts hiện = 56 `public const string`, ≠ |All| vì `HopDongCung` cố ý ngoài All — nếu row `Menu keys` neo theo const thì ghi rõ thước).

**F-3 (MINOR) — comment nguồn của chính const nay SAI.** `MenuKeys.cs:37-40` vẫn viết *"2 root PLACEHOLDER … **CỐ Ý NGOÀI `All`** — chưa có module ⇒ không sinh policy per-action"*, áp cho CẢ `KeHoachKyKet` lẫn `HopDongCung`. Sau diff mệnh đề này chỉ còn đúng cho `HopDongCung`. Cùng lớp: `DbInitializer.cs:1772` *"Ngoài MenuKeys.All"*. Fix: tách câu — `KeHoachKyKet` ĐÃ vào All @S161, `HopDongCung` còn ngoài.

---

## TRỤC 2 — `ContractSigningPlanCodeGenerator` + DI

**Twin-diff vs `PurchaseEvaluationCodeGenerator` — PASS, lệch đúng 3 điểm CHỦ ĐÍCH.** So từng câu (`ContractSigningPlanCodeGenerator.cs:16-51` ⟂ `PurchaseEvaluationCodeGenerator.cs:14-54`): cùng `IsolationLevel.Serializable` + `FirstOrDefaultAsync(s => s.Prefix == prefix)` + nhánh null `LastSeq = 1` / nhánh có `LastSeq += 1` + `UpdatedAt` + `SaveChanges` → `Commit` → `return $"{prefix}/{seq.LastSeq:D3}"` + `catch { Rollback; throw; }`. Lệch: (1) prefix `KHKK/{year}` thay `PE/{year}/{typeLetter}` — bỏ tham số entity nên `GenerateAsync(CancellationToken)` không nhận entity (hợp lý: mã không phụ thuộc phiếu); (2) bảng dùng chung `WorkflowAppCodeSequences` thay bảng riêng; (3) không có `typeLetter` switch. **D3 giữ nguyên** ⇒ khớp `^KHKK/\d{4}/\d{3}$`.

**DI — PASS.** `DependencyInjection.cs:42` `services.AddScoped<IContractSigningPlanCodeGenerator, ContractSigningPlanCodeGenerator>();` đặt ngay cạnh `IPurchaseEvaluationCodeGenerator` `:40`, cùng scope `Scoped` (đúng — service chạm DbContext scoped). Namespace `using SolutionErp.Application.ContractSigningPlans.Services;` `:7` đã thêm.

**Bảng dùng chung — kiểm tương thích:** `WorkflowAppCodeSequenceConfiguration.cs:17` `Prefix` PK `HasMaxLength(20)`; `"KHKK/2026"` = 9 ký tự ⇒ OK, và không đụng prefix 4 module Office (`DT/LR|DT/OT|DT/CT|DX/XE`). Semantics khớp helper cũ `TravelVehicleCodeGen.GenerateMaDonTuAsync` (`TravelVehicleApprovalFeatures.cs:818-834`): helper tạo row `LastSeq=0` rồi `++`, bản mới tạo thẳng `=1` — **cùng kết quả 001**, không lệch. Không tái dùng helper được vì nó `internal static` trong assembly Application (Infrastructure không thấy) ⇒ trùng lặp là BẮT BUỘC, không phải cẩu thả.

**F-4 (LOW, kế thừa từ twin — KHÔNG phải hồi quy mới):** `dateTime.UtcNow.Year` ⇒ đêm 31/12 giờ VN (UTC+7) sinh mã mang năm CŨ; và `:D3` tràn thành 4 chữ số từ phiếu thứ 1000 (test regex `\d{3}` sẽ đỏ). Cả 2 y hệt PE codegen đang chạy prod ⇒ ghi nhận, không chặn W1.

**Ghi nhận (không tính flag):** generator hiện CHƯA có call-site sản xuất nào (`grep` chỉ ra DI + test) — đúng phạm vi W1 (schema-first), W2 mới gọi. Cũng lưu ý cho W2: `BeginTransactionAsync` sẽ ném nếu handler đã mở transaction ngoài.

---

## TRỤC 3 — 3 site `ApprovalWorkflowV2AdminFeatures.cs`

**Mirror khuôn VehicleBooking từng mệnh đề — PASS 3/3** (so sát dòng-kề-dòng):
- Site 1 `CountLiveOpinionsAsync:945-949` ⟂ twin `:935-939`: đủ `.AsNoTracking()` · `!o.IsDeleted` · `db.ContractSigningPlans.Any(p => p.Id == o.ContractSigningPlanId && !p.IsDeleted)` viết TƯỜNG MINH (đúng ý comment `:889` — không dựa query-filter) · `GroupBy` + `LevelOpinionCount`. KHÔNG dùng `IgnoreQueryFilters` — ĐÚNG (site này chỉ đếm phiếu SỐNG).
- Site 2 `ListRetainedOpinionsAsync:1017-1022` ⟂ twin `:1009-1015`: đủ `.AsNoTracking().IgnoreQueryFilters()` (bắt buộc — header KHKK CÓ `HasQueryFilter(!IsDeleted)`, nếu thiếu thì phiếu xoá mềm rơi khỏi JOIN ⇒ bị kết luận "mồ côi" ⇒ xoá cứng nhầm) · `.Join(db.ContractSigningPlans, …)` · `.Where(x => x.o.IsDeleted || x.p.IsDeleted)` · nhãn module `"Kế hoạch ký kết HĐ"` + mã `x.p.MaKeHoach` + `x.p.IsDeleted`.
- Site 3 `PurgeOrphanOpinionsAsync:1083-1086` ⟂ twin `:1078-1081`: đủ `!db.ContractSigningPlans.IgnoreQueryFilters().Any(...)` (KHÔNG có vế `o.IsDeleted` — đúng bản thu hẹp F2, giữ chữ ký thu-hồi trên phiếu còn sống) · `ExecuteDeleteAsync` · cộng vào `n`.

**Site thứ 4 cùng lớp — quét 2 hướng, KẾT LUẬN: không có site enumeration nào bị bỏ sót.**
1. `grep -n "LevelOpinions"` trong file = 24 dòng mã = 8 module × 3 site (PE·Contract·Proposal·Leave·Ot·Travel·Vehicle·**ContractSigningPlan**) — không lệch.
2. Quét **token-song-sinh** `VehicleBookingLevelOpinions` toàn repo (trừ `Migrations/`) ra 6 file: AdminFeatures (3 site ✓) · `IApplicationDbContext.cs` (DbSet — đã thêm `:152` ✓) · `ApplicationDbContext.cs` (impl ✓) · `VehicleBookingLevelOpinionConfiguration.cs` (module-riêng) · `TravelVehicleApprovalFeatures.cs` (đường Approve của MODULE đó — bản KHKK là việc W2) · `WorkflowAppApproveV2Tests.cs` (test module đó). ⇒ 0 site liệt-kê-thủ-công còn thiếu.

**F-5 (MINOR) — 2 con SỐ trong chú thích cùng file nay SAI, do chính diff này.** `:886-887` *"quét CẢ **7 bảng** LevelOpinion đang FK vào ApprovalWorkflowLevels (PE + HĐ + 5 module Văn phòng số)"* và `:961` *"giữ **7 khối** cùng một hình dạng"* — sau diff là **8**. Đây đúng lớp "chú thích mang số = khẳng định đo được" (bài S159): người sửa sau đếm 7 rồi dừng ⇒ bỏ sót đúng module mới. Fix: sửa 7→8 + thêm "+ KHKK" vào ngoặc liệt kê.

**F-6b (LOW, kế thừa) — site GHI VẾT changelog `:847-877` KHÔNG phủ type=10.** Điều kiện `def.ApplicableType is DuyetNcc or DuyetNccPhuongAn` ⇒ admin sửa quy trình KHKK tại chỗ sẽ KHÔNG ghi changelog vào phiếu KHKK đang treo, dù module MỚI CÓ bảng `ContractSigningPlanChangelogs` (entity #6). Hiện Contract cũng đang bị bỏ y vậy nên đây là **hố có sẵn**, không phải hồi quy — nhưng chú thích `:843-846` chỉ khai giới hạn cho "5 module Văn phòng số", nay hụt thêm KHKK ⇒ nên khai đủ hoặc wire ở W2/W3.

---

## TRỤC 4 — FE `ApprovalWorkflowsV2Page.tsx` (fe-admin)

**(a) `copyFromDefinition` — containment ĐỦ, và đúng chỗ.** `copyFromDefinition:204-205` vẫn bê `allowApproverSkipToFinal/Finalize` từ nguồn (clone/edit) vào state — nhưng cả 2 đường RA đều đã bịt: `levelFlagsPayload(e, lockEarlyFinalize)` ép `false` (`:255-256`) và cả **2** call-site truyền cờ (`:783` POST-create, `:815` PUT-sửa-tại-chỗ). `grep levelFlagsPayload` = 1 định nghĩa + đúng 2 call-site ⇒ **không có đường thứ 3** bỏ sót. Kịch bản độc: clone 1 quy trình type-3 có `finalize=true` sang type-10 ⇒ state true nhưng payload false ⇒ DB false, và render cũng hiện untick (`checked={earlyFinalizeLocked ? false : …}`) ⇒ màn hình khớp dữ liệu. `applicableType` là **prop của definition đang mở** (`:441` truyền `type.applicableType`), không phải state trôi ⇒ khoá đúng đối tượng.

**(b) `DEFAULT_CODE_BY_TYPE[10] = 'QT-KHKK-V2-001'` — suy diễn HỢP LỆ.** Khớp khuôn 3 dòng có sẵn (`QT-DN-V2-001` / `QT-DN-PA-V2-001` / `QT-HD-V2-001` = `QT-{viết tắt}-V2-001`), chỉ là giá trị điền sẵn ô Mã (`:645 defaultCode = … ?? 'QT-V2-001'`), admin sửa được. Không khai thì rơi fallback chung — nghĩa là **không khai cũng không vỡ**, khai chỉ để đẹp. Không có rủi ro.

**(c) Regression type≠10 — 0 pixel, xác minh từng nhánh.** Khi `earlyFinalizeLocked=false`: className 2 label trả về **đúng chuỗi cũ** (`…text-slate-700` và `…text-emerald-700`), `title={undefined}` (React bỏ attribute), `checked={false ? false : entry.x}` ≡ `entry.x`, `disabled={false}` (bỏ attribute), khối ghi chú bọc `{earlyFinalizeLocked && …}` không render. ⇒ Designer type 1/2/3 không đổi gì.

**F-7 (MAJOR) — nhãn tiếng Việt của type 10 KHÔNG TỒN TẠI ở BE ⇒ UI hiện tên enum tiếng Anh, và tên đó bị LƯU vào DB.** FE cố ý bỏ bảng nhãn và khai rõ (`ApprovalWorkflowsV2Page.tsx:159-161`: *"nhãn hiển thị lấy từ BE qua `applicableTypeLabel`"*), nhưng BE `AwLabels.Type` (`ApprovalWorkflowV2AdminFeatures.cs:90-95`) chỉ có **3** entry (DuyetNcc/DuyetNccPhuongAn/Contract); `:206` fallback `type.ToString()`. Hệ quả tại chính màn W1 phải nghiệm thu: tiêu đề trang `:302` → *"Quy trình duyệt (Mới): **ContractSigningPlan**"* · card `:320` → **"ContractSigningPlan"** · và nặng nhất `:647` `useState(\`Quy trình ${applicableTypeLabel}\`)` ⇒ admin bấm Lưu là **ghi vào cột Name của DB chuỗi "Quy trình ContractSigningPlan"**. Vi phạm quy ước "UI 100% tiếng Việt". Đây là lớp *hợp-đồng-đứt-giữa-2-bờ*: FE khai bên kia lo, bên kia không có, mà `tsc` + build + 566 test đều xanh. Fix 1 dòng: thêm `[ApprovalWorkflowApplicableType.ContractSigningPlan] = "Kế hoạch ký kết HĐ"` vào `:94`. (Chuỗi tiếng Việt đã có sẵn trong CÙNG file ở `:1021` cho `RetainedOpinionRef` ⇒ càng lộ chỗ thiếu.)

**F-8 (MINOR) — "containment 2 lớp" thực chất là 2 lớp CỦA CÙNG 1 client; BE không có rào.** `CreateAwDefinitionCommand` gán thẳng `AllowApproverSkipToFinal/Finalize` từ request (`:373-374`) và validator không ràng theo `ApplicableType` (grep 2 cờ trong file: chỉ có rào ở đường PUT `:584-616` và ép false cho dòng MỚI `:802-803`). ⇒ 1 lệnh `curl POST /api/approval-workflows-v2` với `applicableType=10, allowApproverFinalize=true` vẫn tạo được **đúng cái "cờ chết"** mà F-B3 muốn diệt. Spec §②-10 chỉ yêu cầu FE nên KHÔNG tính lệch-spec, nhưng nếu muốn containment thật thì thêm rule validator: type 10 ⇒ 2 cờ phải false.

**F-9 (LOW) — neo dòng trong chú thích mới trỏ dòng TRƯỚC commit.** `:157-158` viết *"rơi về fallback chung 'QT-V2-001' (:614)"*; sau chính diff này fallback nằm ở **`:645`** (`:614` giờ là dòng destructuring prop `applicableType`). Người đọc sau nhảy tới :614 sẽ thấy thứ khác. Fix: đổi `(:614)` → `(:645)` hoặc bỏ số dòng, trỏ tên biến `defaultCode`.

**Ghi nhận i-1 (không phải lỗi diff) — đường tới Designer type 10 CHỈ có URL gõ tay.** Xác minh độc lập: `Layout.tsx:163-167` allowlist `AwV2_*` chỉ nhận `DuyetNcc|DuyetNccPhuongAn|Contract`; landing card `:317-333` **không phải link** (mô tả `:309` bảo "chọn loại quy trình từ menu bên trái"). Route `App.tsx:76 /system/approval-workflows-v2/:typeCode` có ⇒ deep-link `/system/approval-workflows-v2/ContractSigningPlan` chạy được nhờ `TYPE_CODE_TO_INT`. Đúng spec "menu KHÔNG đổi" — nhưng acceptance §③-B *"Admin tạo được quy trình type=10 3 Bước"* **phải nghiệm thu bằng deep-link đó**, đừng chờ thấy nó trên sidebar.

**Mirror 2-app (§3.9) — N-A.** `fe-user/src/pages/system/` không tồn tại; `grep ApprovalWorkflow fe-user/src` chỉ ra màn xem read-only của module khác. Designer là admin-only ⇒ không cần mirror.

---

## TRỤC 6 — Boundary

**3 file phải 0-diff — PASS.** `git status --porcelain` không liệt `ContractCodeGenerator.cs` (quyết "bỏ MB" KHÔNG đụng codegen ✓), `ContractWorkflowService.cs`, `ContractFeatures.cs` (O-A để mở ✓). Toàn bộ 12 file `M` + 20 file `??` đều nằm trong phạm vi W1 (Domain/Config/Mig/DbSet/MenuKeys/DI/Seeder/AWV2-3-site/FE-Designer/test). 0 file lạc.

**Mig 3-file (#17) — ĐỦ nhưng xem F-0.** `20260729122015_AddContractSigningPlans.cs` (372 dòng) + `.Designer.cs` (6.960) + `ApplicationDbContextModelSnapshot.cs` (+550, tracked-M) = 3/3.

**F-0 (HIGH — GATE THỦ TỤC, phải xử lý TRƯỚC commit).** 2/3 file migration đang **UNTRACKED** (`??`) trong khi snapshot đã **tracked-M**. Đây đúng vết S155: `git commit -a` (hoặc `-am`) chỉ nạp file đã theo dõi ⇒ commit sẽ mang **snapshot + 7 DbSet + 7 EF Config + Domain** nhưng **THIẾU migration** ⇒ CI/prod dựng model có 7 bảng mà DB không có bảng ⇒ `Invalid object name 'ContractSigningPlans'`; build + 566 test local VẪN XANH nên không lộ. Tổng cộng **20 đường dẫn `??`** (2 thư mục Domain/Application + 7 Configuration + 2 Migration + 1 Service + 1 test) phải `git add` tường minh. Nghiệm thu 1 lệnh: `git status --porcelain -- src tests | grep '^??'` phải **rỗng** ngay trước `git commit`.

---

## TIẾN TRÌNH (cập nhật cuối)
- [x] Trục 1 · [x] Trục 5 · [x] Trục 2 · [x] Trục 3 · [x] Trục 4 · [x] Trục 6

VERDICT: PASS_WITH_FLAGS — 10 issue (1 HIGH-gate · 3 MAJOR · 4 MINOR · 2 LOW-kế-thừa). Không có lỗi logic nào chặn commit; F-0 là điều kiện thủ tục BẮT BUỘC, F-7 nên fix cùng commit (1 dòng).
