GATE-K7: PASS-WITH-FLAGS 10

# Gate K7 — adversarial review cầu KHKK → HĐ (S167, 2026-08-01)

Vai: reviewer (READ-only). Base đo: HEAD = `f5ee146` (`wal: flush 20260801T1242`), diff UNCOMMITTED.
Nguyên tắc: mọi mục dưới đây là phép đo TỰ CHẠY của reviewer, không chép claim của lane.

---

## §0 — Mốc cây + residual scope

Đo bằng `git status --porcelain` + `git diff --stat` (2026-08-01, đầu lượt review).

13 file thuộc phạm vi K7 — CÓ ĐỦ, đúng như spec giao:

| Nhóm | File | Trạng thái |
|---|---|---|
| BE | `src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs` | M (+33) |
| BE | `src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs` | M (+23/-2) |
| BE | `src/Backend/SolutionErp.Application/ContractSigningPlans/CreateContractFromSigningPlanFeatures.cs` | **?? MỚI** (252 dòng) |
| BE | `src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs` | M (+86) |
| FE | `fe-{admin,user}/src/hooks/usePipelineStages.ts` | M (+61/-?) ×2 |
| FE | `fe-{admin,user}/src/pages/khkk/KhkkDetailPage.tsx` | M (+308) ×2 |
| FE | `fe-{admin,user}/src/types/khkk.ts` | M (+49) ×2 |
| Test | `tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs` | M (+48/-?) |
| Test | `tests/SolutionErp.Infrastructure.Tests/Api/KhkkBridgeAuthzTests.cs` | **?? MỚI** |
| Test | `tests/SolutionErp.Infrastructure.Tests/Application/CreateContractFromSigningPlanTests.cs` | **?? MỚI** |

**FLAG RESIDUAL (F-R1, MINOR)** — 3 file dirty NGOÀI cả 13-file-set LẪN lead-set đã khai
(`.claude/*` + `docs/STATUS.md`):

- `scripts/distill-shard-probe.ps1` (+1/-1)
- `scripts/measure-agent-memory.ps1` (+1/-1)
- `scripts/memory-archive-gate.ps1` (+11/-2)

Đọc diff: cả 3 là việc **governance memory-index rotation `_INDEX-<period>.md` @S167**, KHÔNG
liên quan K7. Không phải lỗi nội dung; rủi ro là **`git add -A` sẽ nuốt chúng vào commit K7**.
Đề nghị: hoặc khai chúng vào lead-set, hoặc commit tách. Untracked trong `src`/`tests` = đúng
3 file MỚI đã khai, **0 file lạ**.

---

## §1 — TRỌNG TÂM 1: hội-2-khoá 2 tầng (BE ∧ FE)

### 1a. Tầng server — ĐO ĐƯỢC, ĐẠT

- Class-level: `ContractSigningPlansController.cs:29` → `[Authorize(Policy = "KeHoachKyKet.Read")]`.
- Action mới: `ContractSigningPlansController.cs:227-228` →
  `[HttpPost("{id:guid}/create-contract")]` + `[Authorize(Policy = "Contracts.Create")]`.
- ASP.NET Core cộng dồn class + action theo AND ⇒ hội 2 khoá là THẬT, không phải claim.
- Policy `Contracts.Create` có tồn tại: `MenuKeys.cs:12` khai const `Contracts`, và
  `MenuKeys.cs:228` đưa `Contracts` VÀO mảng `All` ⇒ `Program.cs` sinh đủ 4 policy.
  (Nếu key ngoài `All` thì runtime 500 chứ không 403 — đã kiểm, không rơi vào ca đó.)

### 1b. Tầng FE — ĐỌC DIFF TỪNG DÒNG (lead ghi §3-§4 on-behalf; đây là lưới THẬT đầu tiên)

`fe-user/src/pages/khkk/KhkkDetailPage.tsx` (bản admin byte-identical, xem §3c):

- `:138-140` — `const { can } = usePermission()`; `canReadPlan = can('KeHoachKyKet','Read')`;
  `canCreateContract = can('Contracts','Create')`. Đúng 2 key của server, KHÔNG OR `Khkk_*`
  (gotcha #85 giữ được).
- `:299` — `showBridge = phase === KhkkPhase.DaDuyet && canReadPlan`.
- `:303-312` — `bridgeBlockReason`: nhánh ĐẦU TIÊN là `!canCreateContract` ⇒ nút render nhưng
  `disabled`.
- `:352-363` — `<Button disabled={bridgeBlockReason != null} onClick={() => setBridgeOpen(true)}>`.

**Truy đường lách:** grep toàn file cho `setBridgeOpen(true)` → xuất hiện ĐÚNG 1 lần, tại
`onClick` của nút đã disabled. Dialog `CreateContractFromKhkkDialog` render có điều kiện
`{bridgeOpen && ...}` (`:781`) ⇒ **không có đường nào tới `create.mutate()` khi thiếu
`Contracts.Create`**. React không bắn `onClick` trên `<button disabled>`.
⇒ FE **KHÔNG lỏng hơn server**. ĐẠT.

**Verify hook đọc gì:** `fe-user/src/hooks/usePermission.ts` — `findNode(menu, key)` đệ quy trên
cây THÔ từ `AuthContext`, trả `false` khi không thấy node; `Create` → `node.canCreate`. Nghĩa là
gate FE phụ thuộc **row Permission của key `Contracts`**, đúng thứ seeder mới cấp.

**CLARIFY-1 (không phải lỗi mã, là điều kiện nghiệm thu):** cây menu FE nạp lúc login và cache
`localStorage` (`AuthContext.loadMenu`). Comment seeder khai "grant ăn @restart, KHÔNG phải
deploy FE" — đúng cho BE, nhưng **nút vẫn xám cho tới khi user logout/login lại** vì `can()` đọc
menu cache cũ. UAT phải nói rõ bước đăng-xuất/đăng-nhập, nếu không sẽ bị đọc thành "seeder không
ăn".

---

## §2 — TRỌNG TÂM 2: seeder grant, gotcha #84 nghiêm

`DbInitializer.cs:2738-2814` = `SeedContractCreateAccessAsync`; call-site `:2361`.

### 2c. THỨ TỰ (vế nặng nhất) — ĐO BẰNG SỐ DÒNG THẬT, ĐẠT

`awk` liệt kê mọi `await Seed*/Revoke*` trong khối 2250-2420:

```
2331: await SeedAllRolesReviewReadPermissionsAsync(...)
2341: await RevokeTemporarilyHiddenModulesAsync(...)      ← revoker
2347: await SeedProcurementMasterAccessAsync(...)
2354: await SeedKeHoachKyKetAccessAsync(...)
2361: await SeedContractCreateAccessAsync(...)            ← MỚI, SAU revoker ✔
```

Và trong cửa sổ 2361→2520 **không còn call nào khác** ⇒ seeder mới là seeder-quyền CHẠY CUỐI,
không ai lật lại nó. ĐẠT.

Đọc thêm thân revoker `:2550-2589` để không chỉ tin thứ tự: filter là
`MenuKey.StartsWith("Hrm") || StartsWith("Off") || == Personal` — **nhánh `Contracts`/`Ct_*` đã
bị GỠ từ S159**. Vậy claim "revoker hiện không còn nhánh Contracts" là ĐÚNG, và việc giữ thứ tự
sau revoker là phòng-thủ dư (đúng chiều, không hại).

### 2b. UPGRADE-ONLY + IDEMPOTENT — ĐẠT

- Nhánh row-đã-có (`:2790-2797`): chỉ `if (!row.CanRead) row.CanRead = true` và
  `if (!row.CanCreate) row.CanCreate = true`. **Không chạm `CanUpdate`/`CanDelete`, không hạ cờ
  nào.** Không có `= false` ở bất kỳ đâu trong hàm.
- Nhánh row-chưa-có (`:2800-2808`): tạo `CanRead=true, CanCreate=true, CanUpdate=false,
  CanDelete=false`.
- Idempotent: lượt 2 trở đi `added=0, upgraded=0` ⇒ **không gọi `SaveChangesAsync`** (`:2811`),
  không log rác. ĐẠT.
- Guard role-chưa-seed (`:2779-2784`): `roles.Count == 0` → log warning + `return`, không ném.

### 2a/2d. TIỀN ĐỀ "chưa role nào có Contracts.Create" — ĐO ĐƯỢC TỪ MÃ

`DbInitializer.cs:2474` `reviewKeys = MenuKeys.All.Where(InReviewScope)…` ⇒ `Contracts` nằm
trong tập. `IsPeKey("Contracts") == false` ⇒ nhánh insert dùng `CanCreate = isPe` (`:2523`) =
**false**, và nhánh row-đã-có là **skip-existing** cho key non-Pe (`:2515`) ⇒ không ai nâng
`CanCreate` lên. Tiền đề của lane ĐÚNG.

Bộ vai: `roleNames = { Drafter, Procurement, Admin }` (`:2775`) — đúng 3 vai spec nêu, kèm comment
"câu III default, chờ anh ratify" và chỉ-dẫn sửa 1 chỗ. ĐẠT.

**MINOR F-2** — Admin thừa: `MenuPermissionHandler.cs:26-31` có Admin-bypass (`Succeed` ngay khi
role chứa `Admin`) ⇒ cấp row cho Admin **vô hại nhưng vô tác dụng**. Không đề nghị sửa; nêu để
người sau đừng đọc row Admin thành "bằng chứng Admin cần row".

**MINOR F-3** — comment `:2814` viết "Thu hồi khi cần: xoá call ở SeedAsync", nhưng call thật
nằm trong `SeedAdminPermissionsAsync` (thân 2288-2381), không phải `SeedAsync`. Chỉ-dẫn trỏ sai
tên hàm.

**MINOR F-4 (mở rộng ngoài Create)** — hàm nâng cả `CanRead`, trong khi tên hàm + comment chỉ hứa
`Contracts.Create`. Với 3 vai này `CanRead` gần như đã true từ S159 nên thực tế không đổi gì;
nhưng `reviewKeys` có ghi rõ "trên prod row false KHÔNG được nâng ở đây" ⇒ nếu prod có role nào
đang `Contracts.CanRead=false` thì hàm này **âm thầm mở lại quyền XEM**, rộng hơn nhãn.

---

## §3 — TRỌNG TÂM 3: chuỗi dữ liệu cây, khớp từng khớp nối

### 3a. Write-site → DTO (BE)

- Write-site: `CreateContractFromSigningPlanFeatures.cs:238` → `foreach (var l in lines) l.ContractId = contract.Id;`
  Đây là gán THẬT trên entity đã tracked (`plan.Lines` nạp qua `Include` `:93`), nằm TRƯỚC
  `SaveChangesAsync` `:248`. ĐẠT.
- Fallback: `:246` `if (pe is not null) pe.ContractId ??= contract.Id;` — `??=` đúng chiều
  (HĐ đầu thắng, không cướp ô của cầu PE cũ).
- DTO list: `ContractSigningPlanFeatures.cs:75` `List<Guid> ContractIds` (append CUỐI record ⇒
  positional-arg của các call-site cũ không lệch).
- Projection: `ContractSigningPlanFeatures.cs:836-842` — subquery
  `Where(l => l.ContractSigningPlanId == p.Id && l.ContractId != null).OrderBy(CreatedAt)
  .Select(l => l.ContractId!.Value).ToList()`. Filter `!= null` đứng TRƯỚC `.Value` ⇒ không NRE,
  và line chưa bridge thì mảng RỖNG (không phải null-element). ĐẠT.

### 3b. DTO → FE (tên + null-safety)

- `types/khkk.ts:200` → `contractIds?: string[] | null`. JSON camelCase khớp `ContractIds`.
  Khai optional dù BE non-null: đúng, vì cache React Query cũ / API chưa deploy không có field.
- Helper `types/khkk.ts:304-313` `khkkPlanContractIds(plan)`:
  `for (const id of plan.contractIds ?? []) if (id) out.push(id)` +
  `for (const line of plan.lines ?? []) if (line.contractId) out.push(line.contractId)`.
  **Ca "line không chọn ⇒ ContractId null ⇒ mảng rỗng, không crash" = ĐẠT** — cả 2 vòng đều
  `?? []` và đều lọc falsy trước khi push.
- Chữ ký `plan: KhkkListItemDto & Partial<Pick<KhkkDetailDto,'lines'>>` ⇒ truyền
  `KhkkListItemDto` thuần vẫn hợp lệ (lines optional). ĐẠT.
- Union + dedupe: `usePipelineStages.ts:295-300` — `contractIdsOfPackage` là `Set<string>`, đổ
  nguồn (1) `p.contractId` rồi nguồn (2) `khkkPlanContractIds(k)`. Chồng nhau ở HĐ đầu là CỐ Ý và
  `Set` khử. ĐẠT.
- Dòng phụ: `usePipelineStages.ts:321-326` — `fallbackName` nay tra CẢ 2 nguồn
  (`peItems.find(...)?.tenGoiThau ?? khkkItems.find(k => khkkPlanContractIds(k).includes(id))?.peTenGoiThau ?? null`).
  Đo field tồn tại: `types/khkk.ts:171 peTenGoiThau: string | null`, BE `…Features.cs:39
  string? PeTenGoiThau`. Khớp. ĐẠT.

### 3c. Mirror 2 app — TỰ ĐO SHA, KHÔNG TIN CLAIM

```
IDENTICAL  src/types/khkk.ts             60ec8fa3a5aea94456e2180b1b80f63c1994257c55a424f4e1f7adf376cbbc07
IDENTICAL  src/hooks/usePipelineStages.ts 76e1823345d4e78b87e802bc10d22ea0a42307bbb426b8787eb2d25b02e8c52f
IDENTICAL  src/pages/khkk/KhkkDetailPage.tsx cf0a2272061f4bd8cc19c54b1a4992fdac331e9093665ec4ce708e119ef38123
```

3/3 byte-identical — claim của lane TÁI LẬP ĐƯỢC.

**Rủi ro riêng của mirror byte-identical (đã kiểm, KHÔNG thủng):** file dùng đường dẫn cứng
`/contracts/${id}` ở 3 chỗ (`:527` neo "✓ HĐ", `:975` neo "Đã có HĐ", `:855` `navigate`). Vì 2
app có bảng route KHÁC nhau, đây là chỗ dễ 404 một bên. Đo:
`fe-user/src/App.tsx:65` và `fe-admin/src/App.tsx:84` đều có `<Route path="/contracts/:id">`.
⇒ ĐẠT ở cả 2 app.

**MINOR F-5** — 2 neo dùng `<a href>` thay `<Link>` ⇒ full reload (mất SPA state); và neo
"Đã có HĐ" (`:975`) nằm LỒNG trong `<label>` (nested-interactive, cùng lớp a11y với finding K5 về
`<button>` trong `<summary>`). Radio của dòng đó `disabled` nên không sinh double-activate, nhưng
khuôn thì vẫn là interactive-lồng-interactive.

**MINOR F-6 (hiệu năng, không sai)** — `usePipelineStages.ts:324` gọi `khkkPlanContractIds(k)`
BÊN TRONG `find` mà `find` lại nằm trong vòng lặp theo `contractIdsOfPackage` ⇒ O(n·m) và cấp phát
mảng mới mỗi lượt. Quy mô hiện tại (list kẹp 200) vô hại.

### 3d. Chuông DEV — đọc kỹ vì dễ thành chuông-câm hoặc chuông-loạn

`usePipelineStages.ts:303-315`: chỉ chạy khi `import.meta.env.DEV`, có cờ module-level
`warnedMissingLineContractIds` (`:62`) chặn lặp; điều kiện
`approved.length > 0 && approved.every(k => k.contractIds == null)`.
`== null` bắt cả `null` lẫn `undefined` — ĐÚNG chủ đích "vắng-field ⟂ mảng-rỗng", vì sau khi BE
deploy field luôn là `[]` (non-null) ⇒ chuông tự im. Logic ĐẠT.
Lưu ý (không phải lỗi): prod strip `DEV` ⇒ đây **không phải lưới an toàn**, chỉ là đèn báo lúc dev.

---

## §4 — TRỌNG TÂM 4: guard 4-vế + idempotency

Đọc `CreateContractFromSigningPlanFeatures.cs` toàn file (252 dòng).

Rào theo thứ tự thực thi:

| # | Dòng | Nội dung | Nhận xét |
|---|---|---|---|
| 0 | :87-89 | `IsAuthenticated` + `UserId` | ném `UnauthorizedException` |
| 1 | :92-99 | phiếu tồn tại + `Phase == DaDuyet` | ĐẠT |
| 2 | :101-109 | dòng ∈ phiếu (đếm sau `Distinct`) | id lạ/của phiếu khác đều rơi vào đây — ĐẠT |
| 3 | :111-115 | 1 HĐ = 1 NCC | ĐẠT |
| 4 | :125-127 | `ApprovedAmount is null` → 409 | KHÔNG fallback `ProposedAmount` — ĐÚNG |
| 5 | :128-129 | `ContractId is not null` → "đã có Hợp đồng" | chống bridge-lặp |
| 6 | :134-148 | workflow pin **4 vế** | xem dưới |

**vá-3 (4 vế workflow) — ĐẠT, và ĐÚNG "throw tách":**
`:136` NotFound nếu không tồn tại · `:137-140` sai `ApplicableType` · `:143-145` `!IsActive` ném
riêng · `:146-148` `!IsUserSelectable` ném riêng. Bốn thông điệp bốn nguyên nhân — đúng yêu cầu
spec, và **rộng hơn khuôn tiền lệ** `ContractFeatures.cs:79-87` (khuôn cũ CHỈ có 2 vế đầu — tôi đã
đọc lại `ContractFeatures.cs:74-88` để xác nhận, claim của lane đúng).

**Idempotency / double-click — ĐÂY LÀ FINDING NẶNG NHẤT.** Rào (5) là kiểm tra
**đọc-rồi-ghi trong bộ nhớ**, không có khoá nào ở tầng CSDL:

- không `unique index` trên `ContractSigningPlanLines.ContractId`;
- không `RowVersion`/optimistic concurrency trên `Line`;
- không transaction bao ngoài (`SaveChangesAsync` trần `:248`; transaction duy nhất trong luồng
  là transaction NỘI BỘ của `ContractCodeGenerator`, đã commit xong trước đó).

⇒ Hai request song song (double-click thật, hoặc 2 tab) đều đọc `l.ContractId == null` ở
`:128`, cùng đi tiếp, **tạo 2 Contract**, và lượt ghi sau đè `Line.ContractId`. Kết quả: 1 HĐ mồ
côi (đã tiêu 1 số sequence, đã có changelog) mà không dòng nào trỏ tới. Đây là **TOCTOU thật**,
không phải lo xa: nút "Tạo hợp đồng" chỉ chặn bằng `disabled={create.isPending}` (`:892`) — chặn
được double-click trong CÙNG tab, **không** chặn 2 tab / retry mạng.

Xếp hạng: **MAJOR F-1**, không phải blocker của gate (dữ liệu không mất, HĐ mồ côi xoá được, và
xác suất thấp vì thao tác có dialog), nhưng phải được KHAI, vì lane trình mục này như "đã đóng".
Acceptance đề nghị (không phải chỉ-dẫn sửa mã): một phép kiểm chứng minh 2 lệnh gọi đồng thời chỉ
sinh 1 HĐ — hoặc bằng ràng buộc CSDL, hoặc bằng cập-nhật-có-điều-kiện, hoặc khai công khai
"chấp nhận rủi ro, chặn ở UI".

---

## §5 — TRỌNG TÂM 5: vá-6

- **Project/Supplier NotFound guard:** `:153-154` (`Project`) và `:156-157` (`Supplier`), cả 2 ném
  `NotFoundException` trước khi dùng `.Code`. ĐẠT — loose-Guid không có FK đỡ lưng nên guard này
  là bắt buộc.
- **Codegen TRƯỚC `Add`:** `:212` `contract.MaHopDong = await codeGenerator.GenerateAsync(contract,
  project.Code, supplier.Code, ct);` rồi `:214` `db.Contracts.Add(contract);`. Đúng thứ tự bài S88
  (codegen tự-commit ⇒ không được để HĐ dở trong context). Chữ ký khớp
  `IContractWorkflowService.cs:29 Task<string> GenerateAsync(Contract, string, string, CancellationToken)`.
  ĐẠT.
- **SlaDeadline:** `:205-206` `DateTime.UtcNow.Add(workflow.GetPhaseSla(DangSoanThao) ?? TimeSpan.FromDays(7))`
  — trùng từng chữ với 2 tiền lệ (`ContractFeatures.cs:110`, bridge PE `:109-110`). ĐẠT.
- **`WorkflowDefinitionId` pin:** `:163-166` truy `WorkflowDefinitions` active theo
  `request.ContractType` → `:201` gán. Ngang bridge PE cũ (đã đọc lại
  `CreateContractFromEvaluationFeatures.cs:67-71` + field-map `:105-113` để đối chiếu). Set CẢ
  V1 + V2 (`:202` `ApprovalWorkflowId`) là ĐÚNG — service dispatch V2 trước. ĐẠT.

**KHAI-1 của test-lane (invest T1 ghi null nhưng vá-6 thắng): XÁC NHẬN ĐÚNG — ĐÓNG, không mở lại.**
Lý do đo được: vá-6 trong spec ②:16 yêu cầu pin `WorkflowDefinitionId` theo 2 tiền lệ; mã hiện
tại làm đúng thế; "để null" là hành vi mà chính spec loại bỏ. Không có mâu thuẫn còn lại.

**MINOR F-7 (field-map thiếu so với tiền lệ, có thể CỐ Ý):** bridge PE cũ carry
`DraftData = pe.PaymentTerms` (`CreateContractFromEvaluationFeatures.cs:104`); cầu K7 **không**
carry `DraftData`, dù đã nạp `pe` sẵn ở `:170`. Cũng không có `BypassProcurementAndCCM` (cái này
hợp lý vì command K7 không nhận tham số đó). Nếu bỏ `DraftData` là chủ đích thì nên có một dòng
comment, vì mọi khoản lệch khác so với tiền lệ đều đã được chú thích.

---

## §6 — TRỌNG TÂM 6+7 (test có răng · comment sạch số-đếm)

### 6b. Comment mang số-đếm / mỏ-neo — CÓ LỖI, ĐO ĐƯỢC BẰNG 1 LỆNH

**MAJOR F-8 — mỏ-neo TỰ VÔ HIỆU ngay lúc land (đúng lớp S166-K4b, tái phát):**
2 comment MỚI trỏ `DbInitializer.cs:2516` cho `CanCreate = isPe`:
- `ContractSigningPlansController.cs:224` ("seeder `DbInitializer.cs:2516`")
- `DbInitializer.cs:2743` ("`SeedAllRolesReviewReadPermissionsAsync:2516`")

Phép đo (2 lệnh, cho 2 thế giới):

```
git show HEAD:…/DbInitializer.cs | grep -n "CanCreate = isPe"   → 2516   (TRƯỚC diff)
grep -n "CanCreate = isPe" …/DbInitializer.cs                    → 2523   (SAU diff)
```

Chính diff này chèn 7 dòng ở `:2353-2359`, đẩy dòng đích xuống 2523. Nghĩa là con trỏ ĐÚNG lúc
viết và **SAI ngay khoảnh khắc commit** — không ai làm gì sai sau đó cả. Đây là lỗi loại "đo
trước khi chèn". Acceptance: hoặc cập nhật thành 2523, hoặc bỏ số dòng và trỏ bằng TÊN
(`SeedAllRolesReviewReadPermissionsAsync` + token `CanCreate = isPe` là duy nhất, grep ra ngay).

**MINOR F-9 — số-đếm trong comment tự mâu thuẫn:** `DbInitializer.cs:2743-2744` viết "nút bridge
sẽ 403 với **13/13 role** (kể cả Drafter), chỉ Admin qua được nhờ bypass". Đếm thật:
`AppRoles.cs:23-28` liệt **13** vai và Admin nằm trong đó; `MenuPermissionHandler.cs:26-31` cho
Admin bypass ⇒ con số đúng phải là **12/13**. Một câu chứa cả con số lẫn phản-ví-dụ của chính nó.

Ngoài 2 mục trên, đã quét các con số khác trong comment MỚI và chúng kiểm được:
`ContractConfiguration TenHopDong = 500` khớp `MaximumLength(500)` (`:73`) và clamp `:184`;
`ContractTypeLabel` 1..7 (`fe-user/src/types/forms.ts:13-21`) khớp ĐÚNG
`ContractType.cs:5-11` 1..7 ⇒ `IsInEnum()` không bao giờ đá dropdown.

### 6a. Ba file test — ĐỌC HẾT, CÓ RĂNG

**Đếm:** `KhkkBridgeAuthzTests` 5 `[Fact]` + `CreateContractFromSigningPlanTests` 12 `[Fact]`
= **17** — khớp đúng con số "17 test mới" spec nêu (đếm bằng `grep -c '^\s*\[Fact\]'`, không chép).

**`KhkkBridgeAuthzTests.cs` — răng thật, và đúng loại răng:**
- `:33-48` `BridgePolicies()` **đọc 2 tên policy TỪ CHÍNH CONTROLLER** bằng reflection, không
  hardcode ⇒ ai sửa attribute thì test đo theo cái mới mà vẫn hỏi đúng câu. Kèm
  `actionPolicy.Should().NotBe(classPolicy)` (`:45`) — chặn ca "2 vế trùng nhau thì hội-2-khoá
  còn 1 khoá mà nhìn vẫn như cũ".
- Chạy **máy quyết định THẬT** `MenuPermissionHandler` (`:57-68`), không mô phỏng.
- (2) `:170-171` và (3) `:195-196` mỗi test đều assert **vế còn lại VẪN QUA** trước khi assert vế
  thiếu bị chặn ⇒ không thể xanh nhờ "chặn tất cả". Đây đúng là thứ tôi định đòi.
- (5) `:235-243` phép **2 thế giới 1 biến**: cùng bộ quyền, chỉ lật `IsActive`, kết quả phải đảo.
- (4) `:214` assert `Permissions.Count == 0` trước khi kết luận Admin qua bằng bypass.

**`CreateContractFromSigningPlanTests.cs` — 12 ca, assert phân biệt được thế giới hỏng:**
- T1 `:243-245` — 3 con số CỐ Ý khác nhau (750 chốt / 999 đề xuất / 900 NS) ⇒ lấy nhầm nguồn nào
  cũng ra số khác. T1 `:266` soi **RUỘT mã HĐ** `{ProjectCode}/HĐTP/SOL&{SupplierCode}/01` —
  chính là bằng chứng vá-6 đã nạp 2 bảng Project/Supplier, mạnh hơn `NotBeNull`.
- T1 `:255-257` — assert pin **đúng bản V1 active của đúng loại**, và seed sẵn 1 bản NHIỄU loại
  khác (`:197-201`) để `!= null` không đủ để xanh. Đây là cách đúng để đóng KHAI-1.
- T3 `:360` + T7 `:491` + T11 `:661-662` — assert **`ContractCodeSequences` không cháy**, tức đo
  được thứ tự "rào đứng TRƯỚC codegen" bằng side-effect, không bằng đọc code.
- T6 `:436-440` và T7 `:496-498` — **đối chứng dương cùng lượt**, đổi đúng 1 biến thì QUA.
- T10 `:625-627` — đo **CHIỀU của `??=`**: viết `=` thì mọi assert khác vẫn xanh, chỉ vế này đỏ.
- T8 `:549-551` + `:555-562` — mối nối cầu → đường ống V2, đo bằng **hộp thư của đúng người**
  (Cấp 1 thấy, Cấp 2 chưa thấy), không phải đọc cột.

**T12 vs vá-7 — ĐÚNG, đủ 3 vế spec đòi:** (a) phiếu TỰ đi tới `DaDuyet` qua service THẬT
(`:724-730`), không seed thẳng; (b) `ApprovedAmount` đông cứng `{250, 111}` với tương phản chứng
minh `??=` (dòng chốt tay giữ 111, **không bị 999 đè**) `:735-736`, và `GiaTri = 361` với lập luận
"Σ Proposed = 1249 ⇒ mọi cách sai đều ra số khác" `:744-745`; (c) sau bắc cầu, bấm duyệt lại →
`Conflict`, rồi đọc lại **cả `ContractId` lẫn `ApprovedAmount` đều không đổi** `:753-758`. ĐẠT.

**T11 (double-click) — CÓ RĂNG NHƯNG CHỈ CHO NỬA TUẦN TỰ.** Assert thật, 4 vế: Conflict + đúng
thông điệp + `Contracts.Count == 1` + `LastSeq == 1`. Nhưng `:652-655` là `await` lần 1 **rồi mới**
lần 2 (`ChangeTracker.Clear()` mô phỏng request thứ 2). Test này **không đo được và không thể đo**
ca ĐỒNG THỜI — tức F-1 vẫn hở. Bản thân comment test khai đúng phạm vi ("bấm 2 lần liên tiếp"),
nên đây KHÔNG phải lane nói quá; rủi ro là **người đọc sau tưởng "idempotency đã đóng"**.

**Ngoại lệ `CreateContract` trong `AuthorizePolicyRegressionTests` — SIẾT, không mở lỗ:**
- `:370-373` là `Dictionary<string,string>` map **1 action → 1 chuỗi policy CHÍNH XÁC**, dùng
  `authz.Policy.Should().Be(expectedPolicy)` (`:419`) — so khớp tuyệt đối, **không** `StartsWith`
  / `Contains` / danh sách bỏ-qua.
- Action nào không có tên trong map vẫn rơi vào `else` đòi prefix `KeHoachKyKet` (`:428`) ⇒
  **action tương lai KHÔNG được miễn trừ tự động**. Đúng yêu cầu "chỉ miễn đúng 1 action".
- `MenuKeys.All.Should().Contain(parts[0])` (`:434`) nằm NGOÀI `if/else` ⇒ vẫn chạy cho cả nhánh
  ngoại lệ (chặn ca policy không tồn tại → 500).
- `:477-479` `KhkkCrossModuleGates.Keys.Should().BeSubsetOf(byName.Keys)` — chống **cấu-hình-chết**
  (đổi tên/xoá action mà quên dọn map thì mục đó im lặng không ai đo). Đây là vế "vắng-mặt trông
  giống ổn" mà tôi hay phải tự đòi; lane tự đặt sẵn.

---

## §7 — Trọng tài tươi (reviewer tự chạy, không nhận số của lane)

| Phép đo | Lệnh | Kết quả |
|---|---|---|
| Build BE | `dotnet build SolutionErp.slnx -v q` | **Build succeeded — 0 Warning, 0 Error** (62s) |
| 3 lớp test K7 | `dotnet test --filter …KhkkBridgeAuthzTests\|…CreateContractFromSigningPlanTests\|…AuthorizePolicyRegressionTests` | **39/39 passed, 0 failed** |
| Toàn suite | `dotnet test SolutionErp.slnx --no-build` | Domain **45/45** + Infrastructure **594/594** = **639 pass / 0 fail** |
| tsc fe-user | `npx tsc --noEmit` | exit 0, 0 dòng lỗi |
| tsc fe-admin | `npx tsc --noEmit` | exit 0, 0 dòng lỗi |

🔴 **Phép cộng khớp:** baseline trước đợt này là **622** (đo ở gate K4c cùng ngày) và diff thêm
**17** `[Fact]` ⇒ kỳ vọng 639. Đo được đúng **639**. Nghĩa là 17 test mới **có chạy thật** (không
bị filter/skip câm) **và** 0 test cũ bị vỡ. Đây là 1 con số đóng cả 2 câu hỏi.

### Prod probe — TIỀN ĐỀ SEEDER, ĐO ĐƯỢC, XÁC NHẬN

`GET /api/permissions/by-role/{id}` cho **cả 13 vai** trên `api.solutions.com.vn` (token admin;
lưu ý field token là `accessToken`, không phải `token` — sai tên là "login failed" giả):

```
roles on prod: 13
Accounting/AuthorizedSigner/CatalogManager/CostControl/DeptManager/Director/
Drafter/Equipment/Finance/HrAdmin/Procurement/ProjectManager  → Contracts R=True  C=False
Admin                                                          → Contracts R=True  C=True
--- roles with Contracts.CanCreate = true: 1 / 13
```

Kết luận đo được (không suy từ code):
- Tiền đề của lane **ĐÚNG**: hôm nay **Drafter = False, Procurement = False** ⇒ nút bridge sẽ 403
  cho họ nếu không có seeder. Lỗ là THẬT.
- **Sửa lại F-9 bằng số sống:** không phải "13/13 role", mà **12/13**; và Admin qua **không chỉ**
  nhờ bypass — Admin **đã có sẵn row `CanCreate=True`** trên prod.
- **Củng cố F-2 bằng số sống:** vì Admin đã True, nhánh Admin của seeder mới là **no-op**.
- **Dự đoán kiểm chứng được sau deploy+restart:** seeder phải log đúng **`0 added + 2 upgraded`**
  (Drafter, Procurement). Lệch con số này = có thứ khác đang lật quyền. Đây là acceptance rẻ nhất
  cho nghiệm thu #75/#76 "restart THẬT rồi đếm row".

Giới hạn khai thật: prod **chưa deploy** diff này ⇒ phép đo trên chứng minh TIỀN ĐỀ, **không**
chứng minh seeder chạy đúng. Không đo được bằng con mắt người dùng thường vì
`nv.test@solutions.com.vn` đăng nhập thất bại (tài khoản không còn/đổi mật khẩu) — đã dùng đường
admin thay thế.

---

## §8 — Tổng hợp finding (CHỐT)

| # | Sev | Vị trí | Một câu |
|---|---|---|---|
| F-1 | MAJOR | `CreateContractFromSigningPlanFeatures.cs:128-129` + `:248` | Chống-trùng chỉ là kiểm-tra-trong-bộ-nhớ, không ràng buộc CSDL / không transaction bao ngoài ⇒ 2 request ĐỒNG THỜI tạo 2 HĐ, 1 cái mồ côi; T11 chỉ phủ ca tuần tự. |
| F-8 | MAJOR | `ContractSigningPlansController.cs:224` · `DbInitializer.cs:2743` · `KhkkBridgeAuthzTests.cs:153` | Mỏ-neo `DbInitializer.cs:2516` đúng lúc viết, **sai ngay lúc land** (dòng thật 2523 — chính diff này chèn 7 dòng ở `:2353-2359` đẩy nó xuống); **3 nơi**, không phải 2. |
| F-R1 | MINOR | `scripts/{distill-shard-probe,measure-agent-memory,memory-archive-gate}.ps1` | Dirty ngoài cả 13-file-set lẫn lead-set (việc rotate `_INDEX-<period>.md`); `git add -A` sẽ nuốt vào commit K7. |
| F-9 | MINOR | `DbInitializer.cs:2743-2744` | "403 với **13/13** role… chỉ Admin qua nhờ bypass" tự mâu thuẫn; đo prod: **12/13** có `CanCreate=False`, và Admin **đã có sẵn row True** (không chỉ bypass). |
| F-2 | MINOR | `DbInitializer.cs:2775` | Cấp row cho Admin vô hại nhưng **vô tác dụng** — có Admin-bypass (`MenuPermissionHandler.cs:26-31`) và prod đo được Admin đã `CanCreate=True`. |
| F-3 | MINOR | `DbInitializer.cs:2814` | "Thu hồi khi cần: xoá call ở SeedAsync" — call thật nằm trong `SeedAdminPermissionsAsync` (thân 2288-2381). |
| F-4 | MINOR | `DbInitializer.cs:2792` | Hàm nâng cả `CanRead` trong khi tên + comment chỉ hứa `Contracts.Create` (rộng hơn nhãn). |
| F-5 | MINOR | `KhkkDetailPage.tsx:527` · `:975` | `<a href>` thay `<Link>` (full reload, mất state SPA) + neo "Đã có HĐ" lồng trong `<label>` (interactive-lồng-interactive, cùng lớp a11y với finding K5). |
| F-6 | MINOR | `usePipelineStages.ts:324` | `khkkPlanContractIds` gọi trong `find` trong vòng lặp — O(n·m) + cấp phát mảng mỗi lượt; vô hại ở list kẹp 200. |
| F-7 | MINOR | `CreateContractFromSigningPlanFeatures.cs:186-207` | Không carry `DraftData` như bridge PE tiền lệ (`CreateContractFromEvaluationFeatures.cs:104`) dù `pe` đã nạp sẵn, và không có dòng nào nói đó là chủ đích. |
| CLARIFY-1 | — | `usePermission.ts` + `AuthContext.loadMenu` | Sau khi seeder chạy, nút vẫn xám cho tới khi user **đăng-xuất/đăng-nhập lại** (menu cache `localStorage`) — UAT phải nêu, không thì bị đọc thành "seeder không ăn". |

**Không có blocker.** F-1 và F-8 phải xử lý nhưng không chặn commit: F-1 xác suất thấp + không mất
dữ liệu (HĐ mồ côi xoá được), F-8 là con trỏ trong chú thích.

**ĐẠT (đo xong, không có finding):** hội-2-khoá đủ 2 tầng và FE không lỏng hơn server (0 đường
gọi API lách gate) · thứ tự seeder SAU revoker (2341 → 2361) và là seeder-quyền chạy CUỐI ·
upgrade-only + idempotent (0 chỗ hạ cờ, lượt 2 không `SaveChanges`) · chuỗi
`Line.ContractId` → `ContractIds` → `khkkPlanContractIds` → `Set` union null-safe từng khớp ·
mirror 3/3 byte-identical (SHA tự đo) · route `/contracts/:id` tồn tại ở CẢ 2 app · guard 4 vế
workflow ném tách từng nguyên nhân · vá-6 đủ 4 khoản (2 NotFound guard · codegen trước `Add` ·
`SlaDeadline` · pin `WorkflowDefinitionId`) · 17 test mới có răng thật · ngoại lệ regression siết
đúng 1 action + chống cấu-hình-chết · build 0/0 · 639/0 test · tsc sạch 2 app.

**KHAI-1 (test-lane: invest T1 ghi `WorkflowDefinitionId` null nhưng vá-6 thắng): XÁC NHẬN ĐÚNG —
ĐÓNG, không mở lại.** Spec `②:4` chốt "16 vá THẮNG khi xung đột với nền"; mã theo vá-6 (`:163-166`
+ `:201`); và T1 `:255-257` assert đúng bản active + có bản nhiễu để chống xanh-giả.

---

## §9 — Đề nghị hành động (mô tả tiêu chí, KHÔNG chỉ-dẫn sửa mã)

1. **F-8** — bỏ số dòng, trỏ bằng TÊN (`SeedAllRolesReviewReadPermissionsAsync` + token
   `CanCreate = isPe` là duy nhất trong file, grep ra ngay), hoặc cập nhật cả **3** nơi thành 2523.
   Tiêu chí nghiệm thu: sau khi vá, `grep -n "CanCreate = isPe"` trên cây ĐÃ COMMIT phải khớp con
   số ghi trong chú thích.
2. **F-1** — hoặc dựng một phép kiểm chứng minh 2 lệnh gọi đồng thời chỉ sinh 1 HĐ, hoặc khai công
   khai "chấp nhận rủi ro, chỉ chặn ở UI" ngay tại `:128-129` để người sau không đọc T11 thành
   "đã đóng".
3. **F-9 / F-2** — sửa "13/13" thành "12/13" và bỏ vế "chỉ Admin qua nhờ bypass" (prod đo được
   Admin có row thật).
4. **F-R1** — tách 3 file `scripts/*.ps1` khỏi commit K7, hoặc khai chúng vào lead-set trước khi
   `git add`.
5. **Nghiệm thu sau deploy** — restart THẬT rồi soi log seeder: kỳ vọng **`0 added + 2 upgraded`**;
   và UAT phải có bước đăng-xuất/đăng-nhập lại (CLARIFY-1).

END gate-k7 — VERDICT=PASS-WITH-FLAGS 10
