# sub-reviewer-precommit — S182 YC-013 đợt-2 (PRE-COMMIT, diff thật)

**VERDICT: `PASS_WITH_FIXES — 0C / 3M`** (+ 6 minor). Không có blocker. 3 MAJOR đều **propose-only**, không chặn commit nếu lead chấp nhận rủi ro có khai.

---

## 0. Diff scope (đo tươi, KHÔNG chép từ lane)

`git status --porcelain` + `git diff --stat`:

| Nhóm | File | Ghi chú |
|---|---|---|
| BE | `ApprovalWorkflowV2AdminFeatures.cs` (+184) · `ContractSigningPlanFeatures.cs` (+55) · `ContractSigningPlanWorkflowService.cs` (+10) · **`ContractSigningPlanWorkflowGuards.cs` (untracked `??`, 72 dòng)** | 4/4 khớp spec |
| FE | `ApprovalWorkflowsV2Page.tsx` (+213) · `menuKeys.ts` ×2 · `KhkkCreatePage.tsx` ×2 | 5/5 khớp spec |
| Test | `UpdateAwDefinitionTests.cs` (+567) · `ContractSigningPlanApprovalTests.cs` (+326) · **`ContractSigningPlanGroupAndCatalogTests.cs` (+14)** | **3 file, spec/lệnh khai 2** → mục 9 |
| Ngoài scope | `.claude/governance/.session-counter.json` (+54/-53) · `docs/governance/so-yeu-cau-chu-du-an.md` (+2) | → mục 9 |

Tổng tracked: **+1400 / −106**, 13 file M + 1 file `??`.

### Máy — đo LẠI độc lập (không nhận số của lane)

| Phép đo | Lệnh chạy | Kết quả |
|---|---|---|
| Domain tests | `dotnet test SolutionErp.slnx` | **45 P / 0 F** |
| Infrastructure tests | (cùng lệnh) | **614 P / 0 F** |
| **Tổng** | | **659 P / 0 F** — khớp claim; baseline 645 (45+600) ⇒ **+14 đúng ở Infrastructure** |
| `npm run build` fe-admin | exit 0 | ✓ built 15.36s |
| `npm run build` fe-user | exit 0 | ✓ built 1.04s |
| tsc fe-admin | `npx tsc --noEmit -p tsconfig.app.json` | 0 err (225 file) |
| tsc fe-user | `npx tsc --noEmit -p tsconfig.app.json` | 0 err (214 file) |

---

## 1. W1a — công thức R-6 · **HELD**

`ApprovalWorkflowV2AdminFeatures.cs:355-361`:

```csharp
var actives = typeEnum == ApprovalWorkflowApplicableType.ContractSigningPlan
    ? await db.ApprovalWorkflows
        .Where(w => w.Code == request.Code && w.ApplicableType == typeEnum && w.IsActive)
        .ToListAsync(ct)
    : await db.ApprovalWorkflows
        .Where(w => w.ApplicableType == typeEnum && w.IsActive)
        .ToListAsync(ct);
```

- ✅ Đúng **3 vế** R-6, đúng thứ tự, **KHÔNG** có biến-thể regex `^KHKK-N[1-8]$` ở site này (grep `KHKK-N` trong `AdminFeatures.cs` chỉ ra ở **comment** + gọi `ParseGroup` cho D5, không ở vị-ngữ W1a).
- ✅ Comment `:339` cũ ("only ONE active per type") **đã bị xoá**, thay bằng khối 20 dòng nêu cả 2 nhánh + cấm regex. Không còn neo thối.
- ✅ Nhánh `else` **giữ nguyên byte** semantics per-type cho mọi loại khác.
- ✅ Ăn khớp với `nextVersion :336-338` vốn đã per-Code (`Where(w => w.Code == request.Code)`), nên 2 vế cùng một trục.

---

## 2. W1b — Guards 3-site · **HELD**

- ✅ File MỚI `ContractSigningPlanWorkflowGuards.cs` — `public static class` ở **Application** (đúng F1 của review-1: không nhét vào `Scope` internal, không `InternalsVisibleTo`, không inline-duplicate). Đây đúng là finding "spec-unbuildable vì assembly-ranh" đợt-1 và nó **đã được đóng đúng cách**.
- ✅ 3 call-site, **đối số đúng thứ tự ở cả 3** (tôi đối chiếu tay từng site):
  - Create — `ContractSigningPlanFeatures.cs:516-517` → `(db, request.ApprovalWorkflowId, approvalGroup, ct)`
  - UpdateDraft — `:643-644` → `(db, awId, plan.ApprovalGroup, ct)`
  - Submit — `ContractSigningPlanWorkflowService.cs:196-197` → `(db, awId, plan.ApprovalGroup, ct)`
- ✅ Semantics **cho-qua code ngoài họ** GIỮ NGUYÊN: `ParseGroup` trả `null` ⇒ `if (wfGroup is int k && ...)` không vào ⇒ pass. Ghi chú `:49-57` nêu đúng lý do gốc `:477-478`.
- ✅ Điểm chèn Submit: **sau** 2 rào hạng-mục (`:177-187`), **ngay trên** `LoadStepsAsync(awId, ct)` `:199`. Cũng nằm sau guard phase `:160` và sau `plan.ApprovalWorkflowId is not Guid awId` `:163` ⇒ `awId` luôn hợp lệ, không có nhánh nào bỏ qua.
- ✅ Thân rào cũ ở Create **đã xoá hẳn** (không để 2 bản luật song song).

---

## 3. W1b2 / D6 · **HELD**

- ✅ `EnsureWorkflowPinnableAsync` (`ContractSigningPlanFeatures.cs:331-353`) chỉ có **2 caller**: Create `:507`, UpdateDraft `:642` (grep toàn repo — 3 hit gồm 1 định-nghĩa).
- ✅ UpdateDraft: cả 3 rào (`Type` / `Pinnable` / `GroupMatch`) nằm **TRONG** `if (request.ApprovalWorkflowId is Guid awId && awId != plan.ApprovalWorkflowId)` `:638`. Đúng D6 — giữ-nguyên-pin không bị hỏi. T7 chân (4) đo đúng vế này.
- ✅ **2 message TÁCH** (`:346-352`): "đã ngừng hoạt động" ⟂ "chưa được ghim cho người dùng chọn". Không gộp `!IsActive || !IsUserSelectable`.
- ✅ Không gọi ở đường Submit — đúng "D6 chốt 2 site".

---

## 4. W1c — changelog shape · **HELD**

`AdminFeatures.cs:916-955`:

- ✅ `EntityType = ContractSigningPlanEntityType.Workflow` — enum **=4** (verified `ContractSigningPlanChangelog.cs:12`), slot có sẵn, không đẻ slot mới.
- ✅ `Action = ChangelogAction.Update` · `EntityId = def.Id` · `PhaseAtChange = plan.Phase` · `UserId` · `UserName` · `Summary` · `ContextNote` — đủ 8 field mirror khối PE.
- ✅ Filter: `ApprovalWorkflowId == def.Id && Phase != DaDuyet && Phase != TuChoi` — `TraLai` **KHÔNG** bị loại (đúng spec: phiếu trả lại sẽ trình lại).
- ✅ **CỐ Ý không `IgnoreQueryFilters()`** ở đây (ngược với DELETE) — có khai lý do tại chỗ `:930-934`. Đây là điểm mà đợt-1 tôi FAIL, nay đã được **phân biệt đúng 2 chiều** (changelog = ngữ nghĩa "sống"; DELETE = ngữ nghĩa "FK còn thật").
- ✅ `note`/`summary`/`actorName` hoist ra ngoài 2 nhánh — không đổi hành vi (chỉ string op), khối PE dùng lại y hệt giá trị cũ.
- ✅ Ghi nằm **trước** `await db.SaveChangesAsync(ct)` `:958` + `tx.CommitAsync(ct)` `:962` ⇒ atomic cùng lượt PUT.
- ✅ Câu cũ xếp KHKK vào rọ "chỉ có UpdatedAt/UpdatedBy" đã gỡ (`:875-882`) — không để lại neo thối.

---

## 5. W1d — usage-count + D5 · **HELD** (2 minor)

### (i) pin-count 8 module — **đếm ĐÚNG 8, không thiếu không thừa**

Tôi grep Domain thay vì tin comment:

```
grep -E "public (Guid|Guid\?) ApprovalWorkflowId" src/Backend/SolutionErp.Domain
→ Contract · ContractSigningPlan · LeaveRequest · OtRequest · Proposal
  · TravelRequest · VehicleBooking · PurchaseEvaluation   = 8
  (+ ApprovalWorkflow.cs:76 = FK nội bộ của Step, không phải module)
```

- ✅ `IgnoreQueryFilters()` áp đúng **PE / Contract / KHKK** (`:1287-1292`). Tôi grep `HasQueryFilter` toàn `src/Backend`: trong 8 module pin, **chỉ 3 entity đó** có global filter (`ContractConfiguration.cs:44` · `ContractSigningPlanConfiguration.cs:55` · `PurchaseEvaluationConfiguration.cs:84`). 5 module Office **không** có ⇒ đếm thẳng là đúng. Claim trong comment `:1281-1285` HELD bằng phép đo độc lập.
- ✅ Đây chính là finding 🔴 đợt-1 của tôi ("usage-count trên bảng có HasQueryFilter thiếu IgnoreQueryFilters") — **đã đóng**, và T11 đo đúng cái bẫy đó.

### (ii) opinions 8 bảng

- ✅ Đủ 8 (`:1300-1319`): PE · Contract · ContractSigningPlan · Proposal · LeaveRequest · OtRequest · TravelRequest · VehicleBooking.
- ✅ `levelIds` lấy qua `l.Step!.ApprovalWorkflowId == def.Id` + `IgnoreQueryFilters()`.
- ✅ `IgnoreQueryFilters()` trên 8 bảng opinion = **no-op** (không bảng nào có `HasQueryFilter`; `ContractSigningPlanLevelOpinion.cs:18` khai rõ "CỐ Ý KHÔNG"). Vô hại + đồng hình, đúng như comment nói. Và **KHÔNG** lọc `!IsDeleted` là ĐÚNG cho câu hỏi FK (khác `CountLiveOpinionsAsync :969` hỏi câu khác — "chữ ký còn hiệu lực"). Hai hàm hỏi 2 câu khác nhau, không phải mâu thuẫn.

### (iii) 404

- ✅ `throw new NotFoundException("ApprovalWorkflow", request.Id)` thay `KeyNotFoundException` (`:1275`). T13 có control dương.

### (iv) D5

- ✅ Chỉ áp cho họ, qua `ContractSigningPlanWorkflowGuards.ParseGroup(def.Code) is int group` `:1337` — **dùng lại đúng 1 nguồn regex**, không chép chuỗi lần 3.
- ✅ Thứ tự: usage (i+ii) TRƯỚC, D5 SAU — khai lý do `:1259-1261`.

---

## 6. W2d — danh-sách-chốt 6 ô · **HELD trên trục dữ-liệu**, hở trên trục hiển-thị (→ MAJOR-1)

### 🔴 #8 `allowApproverFinalize` — **0-CHẠM, chứng minh bằng grep trên DIFF**

```
git diff -- fe-admin/.../ApprovalWorkflowsV2Page.tsx | grep "allowApproverFinalize"
→ exit 1 (0 hit)
```
Control dương: grep trên **file** ra 7 hit (`:57 :123 :217 :238 :267 :1456 :1457`) ⇒ lưới grep có răng, và ô #8 ở `:1456` **không** có `disabled`, **không** có `title`, `checked={entry.allowApproverFinalize}` nguyên vẹn. **Prod 8/88 cờ chịu lực AN TOÀN.**

### #7 `allowApproverSkipToFinal` — giữ nguyên hành vi

Diff chỉ đổi **3 biểu thức trùng lặp** `applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN` → `isKhkk`. `isKhkk` định nghĩa `:715` = **cùng biểu thức**, cùng scope, cùng prop. Giá trị y hệt. `checked={isKhkk ? false : ...}` giữ ép-false (đúng spec "GIỮ NGUYÊN").

### `levelFlagsPayload` — 0-chạm

`git diff | grep levelFlagsPayload` → **1 hit duy nhất, nằm trong COMMENT** (`:486` của diff). Payload không đổi ⇒ ô mờ vẫn echo state thật lên BE.

### 6 ô disabled — GIỮ state thật, **CẤM ép false: tuân thủ**

| # | Cờ | Dòng | `checked` | `disabled` | tooltip |
|---|---|---|---|---|---|
| 1 | `allowReturnOneLevel` | 1358-1362 | `entry.…` ✅ | `isKhkk` | chung |
| 2 | `allowReturnOneStep` | 1369-1373 | `entry.…` ✅ | `isKhkk` | chung |
| 3 | `allowReturnToAssignee` | 1380-1384 | `entry.…` ✅ | `isKhkk` | chung |
| 5 | `allowApproverEditDetails` | 1407-1411 | `entry.…` ✅ | `isKhkk` | chung |
| 6 | `allowApproverEditBudget` | 1418-1422 | `entry.…` ✅ | `isKhkk` | chung |
| 9 | `allowApproverDelete` | 1468-1472 | `entry.…` ✅ | `isKhkk` | chung |

Không ô nào bị `checked={false}`. ✅ ERRATA D3 (state-thật, không forced-true) được thi hành **đúng** ở ô #4 `:1396-1400`: `checked={entry.allowReturnToDrafter}` + `disabled` + **tooltip RIÊNG** + label **KHÔNG** bị `text-slate-400` (đúng ý "không mờ kiểu chưa-có").

### Ô Ngưỡng CEO (W2f)

- ✅ `disabled={isEdit || isKhkk}` `:1052` — vô điều kiện cho type-10.
- ✅ POST gửi `ceoApprovalThreshold: isKhkk ? null : …` `:889-891`.
- ✅ helper-text riêng `:1057-1058`.

---

## 7. W2g — `find(w => w.isActive && …)` · **HELD** (hợp-đồng FE↔BE đã đo)

- ✅ 2 app **mirror LOGIC** — 2 diff byte-identical ở phần code (`fe-admin/.../KhkkCreatePage.tsx:125-127` ⟂ `fe-user/.../KhkkCreatePage.tsx:125-127`).
- ✅ 🔴 **`isActive` CÓ THẬT trong DTO BE** — đây là chỗ dễ vỡ nhất (nếu BE không trả, `w.isActive` = `undefined` ⇒ falsy ⇒ `autoWorkflow` LUÔN null ⇒ **chết đường tạo phiếu KHKK**, mà tsc + build + 659 test đều xanh). Đo: `AwDefinitionDto` nhận `d.IsActive` ở **`ApprovalWorkflowV2AdminFeatures.cs:178`** (đối số thứ 8), serialize camelCase ⇒ `isActive`. **Hợp đồng khớp.**
- ✅ `history` = FULL list kể cả active — verified `:205-213`: `versions` = mọi definition của type, `active` = `versions.FirstOrDefault(v => v.IsActive)`. Claim của cả W2b lẫn W2g HELD.
- ✅ Filter `isUserSelectable` client-side ở `:109` **vẫn giữ**.

---

## 8. 14 test — **đúng 14 ca spec, KHÔNG có ca nào nới để xanh**

| Ca | File | Nhận xét đối-kháng |
|---|---|---|
| T1 | `UpdateAwDefinitionTests.cs` | Có **tiền-đề đo** (`Be(8)` trước khi act) — chống "xanh ở thế giới sai" |
| T2 | ″ | Regression type-1 + chứng-nhân xuyên-type |
| T3 | ″ | **2 chân**: (a) code lạ không nuke 8 nhóm; (b) code lạ vẫn ratchet chính nó ⇒ giết được cả bản vá regex lẫn bản vá "type-10 đừng tắt gì" |
| T4 | `ContractSigningPlanApprovalTests.cs` | 3 vế: chặn + **đĩa bất động** (`ReadPinAsync` từ context MỚI + changelog=0) + control dương |
| T5 | ″ | **Seed phiếu pin lệch thẳng DbContext** ✅ + chứng-nhân phiếu đúng nhóm submit LỌT cùng lượt; fixture kéo hạng-mục theo `approvalGroup` ⇒ loại 2 cửa 409 khác |
| T6 | ″ | Vế âm + **chân (2) chứng minh rào đang sống** (không phải xanh-mù) |
| T7 | ″ | 4 chân, đủ cả (4) giữ-nguyên-pin-vẫn-sửa-được |
| T8 | `UpdateAwDefinitionTests.cs` | Đo **SHAPE** 9 field + sanity `changes ≠ rỗng` + assert PE changelog = 0 (không rải chéo module) |
| T9 | ″ | **Control dương TRONG CÙNG LƯỢT** (4 phiếu: 1 sống → có vết; DaDuyet/TuChoi/xoá-mềm → không) |
| T10 | ″ | Control dương + **vi-sai message** (1 vs 2 hồ sơ ⇒ câu phải khác — giết "câu hằng") + chứng-nhân `DbUpdateException` |
| T11 | ″ | Có **tiền-đề 2 chiều**: filter-on = 0, filter-off = 1 ⇒ đo đúng cái bẫy F2 |
| T12 | ″ | **Seed `LeaveRequest` thật** ✅ + chứng-nhân xoá thẳng module 2 ⇒ dangling THẬT trong DB |
| T13 | ″ | Control dương id thật |
| T14 | ″ | **3 chân** (không phải 2): (1) không-phải-cuối ⇒ OK · (2) cuối ⇒ 409 · (3) ngoài họ 1-version ⇒ OK. Tiền-đề `0 usage` tách bạch D5 với rào (i) |

**Đếm:** 10 `[Fact]` mới ở `UpdateAwDefinitionTests` + 4 ở `ContractSigningPlanApprovalTests` = **14** ⟂ suite +14 (600→614). Khớp 2 nguồn.

**Có test nào bị NỚI không?** Có **đúng 1** assertion đổi, ở file thứ 3 (`ContractSigningPlanGroupAndCatalogTests.cs:858-868`):
`Contain("KHKK-N5")` → `Contain("N5")`. Đây là **spec-change bắt buộc**, không phải nới-để-xanh: message mới do W1b chốt bỏ mã, giữ số nhóm; câu cũ sẽ ĐỎ. Có ghi lý do 8 dòng tại chỗ. **Chấp nhận** — nhưng xem MINOR-1 (assertion mù thứ-tự).

---

## 9. Residual — 3 khoản NGOÀI danh sách

1. **`tests/…/ContractSigningPlanGroupAndCatalogTests.cs`** (+14/−6) — file test **thứ 3**, lệnh chỉ khai 2. Chính-đáng (hệ quả tất yếu của đổi message W1b), nhưng **phải khai trong commit-body**, đừng để người đọc log sau này tưởng có ai lén sửa test cũ.
2. **`.claude/governance/.session-counter.json`** (+54/−53) — tick S182 của lead. **Không liên quan YC-013.** → xem MINOR-2 (BOM).
3. **`docs/governance/so-yeu-cau-chu-du-an.md`** (+2) — thêm dòng `YC-013` + `YC-014`. Đúng luật sổ (GHI NGAY), không liên quan code.

**`#8` / `levelFlagsPayload` / `SkipToFinal` có bị đụng ngoài ý không?** — KHÔNG. Đã grep trên **diff** (không phải trên file) cho cả 3, kết quả ở mục 6.

---

## 10. FLAG-A của FE lane — **CLAIM ĐÚNG, và tôi đo được**

| Lệnh | fe-admin | fe-user |
|---|---|---|
| `npx tsc --noEmit` (nguyên văn acceptance cũ) | exit 0 — **`--listFiles` = 0 dòng** | exit 0 — **`--listFiles` = 0 dòng** |
| `npx tsc --noEmit -p tsconfig.app.json` | exit 0 — `--listFiles` = **225 dòng** | exit 0 — `--listFiles` = **214 dòng** |

`fe-admin/tsconfig.json` = `{"files": [], "references": [...]}` ⇒ dạng trần **biên dịch 0 file**, luôn xanh **theo cấu trúc**, không phải may mắn. FLAG-A **HELD**.

🔸 Bổ sung có ích cho lane: `npm run build` = `tsc -b && vite build` — `tsc -b` (build mode) **CÓ** đi theo project references ⇒ acceptance `npm run build` là thước THẬT. Chỉ acceptance `npx tsc --noEmit` trần là rỗng. Đề nghị: mọi acceptance FE về sau ghi `npm run build` hoặc `-p tsconfig.app.json`, và **kèm `--listFiles | wc -l` một lần** để chứng thước không rỗng.

---

# FINDINGS

## 🔴 MAJOR-1 — tooltip gắn trên `<input disabled>`: Chrome/Edge không hiện ⇒ nửa D3=C có thể chết câm

**Vị trí:** `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx:1361, 1372, 1383, **1399**, 1410, 1421, 1471` (và tiền lệ cũ `:1443`).

`title` được đặt trên chính `<input type="checkbox" disabled={isKhkk}>`. Trình duyệt Chromium (Chrome/Edge — đúng trình duyệt đích, xem `feedback_hosolink_open_from_web_browser_block`) **chặn sự kiện chuột trên form-control bị disabled**, nên native tooltip thường **không** bật lên; Firefox thì có. `<label>` bao ngoài KHÔNG mang `title`, nên rê chuột vào phần chữ cũng không ra gì.

**Vì sao là MAJOR chứ không phải nit:** D3=C của anh là *"mờ ô chưa nghe **+ tooltip**"*. Nếu tooltip không hiện thì admin chỉ thấy 7 ô xám không lý do — đúng thứ mà rule-box `:1088-1096` cố tránh. Nặng nhất là **ô #4** `:1399`: câu *"KHKK hiện luôn trả về người soạn khi Trả lại — cờ chưa phân nhánh"* là **thông tin DUY NHẤT** phân biệt ô #4 với 6 ô kia (rule-box chỉ nói #4 "có tác dụng thật", không nói *"cờ chưa phân nhánh"*). Tooltip không hiện ⇒ ô #4 trông y hệt 6 ô "chưa áp dụng", tức **ngược nghĩa** — đúng class lỗi mà ERRATA D3 vừa sửa xong ở tầng `checked`.

**Đây là máy dựng mà không có cửa gọi** (`feedback_guard_built_but_never_called`).

**Acceptance đề nghị (chọn 1):**
- (a) chuyển `title=` từ `<input>` lên `<label>` bao ngoài — label không disabled nên tooltip sống; hoặc
- (b) render câu của ô #4 thành **chữ nhìn thấy được** (một dòng `text-[10px]` dưới ô), giữ tooltip cho 6 ô kia.
- Kiểm chứng: mở dialog nhóm bất kỳ trên Chrome, rê chuột **vào chữ** của ô #4 → phải đọc được câu riêng.
- 🔸 Tiền lệ `:1443` (#7, có từ K3) **cùng bệnh** — vá thì vá cùng lớp (bài `citation-trap` gen-2: vá 1 CLASS phải grep MỌI matcher cùng-lớp TRONG diff).

---

## 🔴 MAJOR-2 — 3 thành phần bất đồng về HOA/thường của `Code`; test mù theo CẤU TRÚC (SQLite ≠ SQL Server)

**Vị trí:** `ContractSigningPlanWorkflowGuards.cs:37-38` ⟂ `ApprovalWorkflowV2AdminFeatures.cs:357` ⟂ `KhkkCreatePage.tsx:126` (×2 app).

Ba chỗ hỏi cùng một câu "code này có phải nhóm N{n} không", bằng **ba luật khác nhau**:

| Chỗ | Phép so | Với `khkk-n1` (thường) |
|---|---|---|
| `GroupCodePattern = new(@"^KHKK-N([1-8])$", CultureInvariant)` — **KHÔNG** `IgnoreCase` | case-**SENSITIVE** | → `null` = **ngoài họ** ⇒ rào nhóm 3-site TẮT, D5 TẮT |
| `w.Code == request.Code` (EF → SQL Server) | theo **collation DB**, mặc định `*_CI_AS` = case-**INSENSITIVE** | → **khớp** `KHKK-N1` ⇒ per-Code **vẫn hạ IsActive** bản thật |
| `(w.code ?? '').trim().toUpperCase() === 'KHKK-N1'` | case-**INSENSITIVE** | → **khớp** ⇒ FE auto-pin nó cho phiếu nhóm 1 |

Hệ quả cụ thể: admin gõ `khkk-n1` trên panel type-10 gộp (ô Mã ở panel đó **không** khoá — `lockedCode = null`) ⇒ bản mới **tắt** `KHKK-N1` thật, FE **vẫn** auto-pin sang nó, còn rào nhóm + D5 **im lặng tắt** cho toàn bộ phiếu nhóm 1. Không có lỗi nào báo.

**Vì sao test không thấy:** 14 ca chạy trên **SQLite** (`SqliteDbFixture`), nơi `=` trên TEXT mặc định là **BINARY = case-sensitive**. Tức phép so ở tầng DB trong test có ngữ nghĩa **NGƯỢC** với prod. Đây không phải test viết ẩu — nó **mù theo cấu trúc**, không assertion nào bịt được.

**Acceptance đề nghị:** chuẩn hoá tại **đường ghi** — `Code = request.Code.Trim().ToUpperInvariant()` trong `CreateAwDefinitionCommandHandler` (và dùng cùng giá trị đó cho `nextVersion` + `actives`), **hoặc** thêm `RegexOptions.IgnoreCase` cho `GroupCodePattern` + khai rõ "W1a phụ thuộc collation" ngay tại `:350`. Kèm 1 ca test `khkk-n1` để bất-biến này có chứng nhân (dù SQLite, nó vẫn bắt được vế regex).

---

## 🔴 MAJOR-3 — rào D6 ở đường TẠO có 0 test; T7 chỉ phủ đường SỬA NHÁP

**Vị trí:** `ContractSigningPlanFeatures.cs:507` (`EnsureWorkflowPinnableAsync` tại Create).

Spec W1b2 nói rõ **2 site** (Create + UpdateDraft). Danh sách T1–T14 **chỉ có T7**, và T7 gọi `UpdateDraftAsync` ở cả 4 chân — **không chân nào đi qua Create**. Vậy một guard **hard-409 mới cắm vào write-path chính của prod** (mọi phiếu KHKK đều sinh ra qua đây) đang có **0 răng đo**.

Rủi ro cụ thể: nếu ai đó về sau dời `EnsureWorkflowPinnableAsync` ra khỏi Create, hoặc đảo nó lên **trước** `EnsureWorkflowTypeAsync` (làm 404 đổi thành 409), hoặc gỡ nó khi refactor — **659 test vẫn xanh**. Và chiều ngược cũng không được đo: rào này có thể đang **chặn oan** một luồng tạo hợp lệ nào đó mà chưa ai chạm tới.

Ghi chú công bằng: đây là **lỗ của SPEC** (spec liệt 14 ca và thiếu ca này), implementer làm đúng spec. Nhưng luật §7 nói *"New endpoint / guard mới ở write-path → test"*, và tôi không hạ thước theo spec.

**Acceptance đề nghị:** thêm **1 ca** vào `ContractSigningPlanGroupAndCatalogTests` (nơi đã có sẵn `CreateAsync` helper + seed PE): Create pin workflow `IsActive=false` ⇒ 409 · pin `IsUserSelectable=false` ⇒ 409 **message khác** · control dương pin bản active+ghim ⇒ tạo được **và không đốt mã** (`ReadPlanScopesAsync` rỗng ở 2 ca đầu — khuôn đã có ở `:869`).

---

# MINOR

- **MINOR-1 — assertion mù THỨ TỰ toán hạng.** `Contain("N5").And.Contain("N3")` (`GroupAndCatalogTests:865`, `ApprovalTests:188` + `:246`) vẫn xanh nếu ai đó **đảo 2 đối số** ở call-site (`EnsureWorkflowGroupMatchAsync(db, awId, approvalGroup, ct)` — 2 tham số kề nhau, 3 call-site). Tôi đã đối chiếu tay: **cả 3 site hiện ĐÚNG**. Nhưng lưới không giữ được bất-biến đó. Đề nghị pin nguyên câu có thứ tự: `Contain("Phiếu nhóm N3 đang gắn quy trình N5")` ở ít nhất 1 ca.
- **MINOR-2 — `.session-counter.json` bị thêm BOM + đổi sang CRLF.** HEAD: `7b 0a` (`{`+LF). Worktree: `ef bb bf 7b 0d 0a` (**BOM** + `{` + CRLF). CRLF sẽ được git normalize, **BOM thì được commit**. Hiện chưa có script nào parse file này (`.claude/hooks/` chỉ có `wal-flush.ps1`, không đọc), nên rủi ro thấp — nhưng đây đúng là bẫy `BOM-json` đã ghi trong `feedback_resume_premise_reverify`, và nó vào commit **kèm theo code** mà không ai chủ ý. Đề nghị: `git add` **từng file cụ thể** (không `-A`), hoặc tách thành commit `wal:`/governance riêng.
- **MINOR-3 — D5 đếm anh-em KHÔNG lọc `ApplicableType`.** `AdminFeatures.cs:1339-1340`: `Count(w => w.Code == def.Code && w.Id != def.Id)`. Một dòng `KHKK-N1` ở type khác (UNIQUE là `(Code, Version)` nên chỉ cần Version khác) sẽ được tính là "anh em" và cho phép xoá bản type-10 cuối cùng. Ca hiếm, nhưng vá là **1 mệnh đề**: `&& w.ApplicableType == def.ApplicableType`.
- **MINOR-4 — panel nhóm tạo version MỚI ⇒ 6 cờ khoá bị đóng băng ở DEFAULT của FE, không phải "state thật".** Trên panel nhóm, "Tạo phiên bản mới" (không clone) khởi tạo entry từ `makeEmptyStep`; 6 ô `disabled` ⇒ admin **không thể** sửa ⇒ POST luôn ghi đúng bộ mặc định FE. Với KHKK thì vô hại (BE không đọc 6 cờ đó), nhưng chữ "state THẬT" trong D3=C **chỉ đúng cho đường CLONE/EDIT**. Đề nghị: khai 1 dòng trong commit-body hoặc nợ-khai, đừng để lượt sau đọc "state thật" rồi suy sang ca tạo-mới.
- **MINOR-5 — bỏ ghim bản active = đóng cửa tạo phiếu nhóm (không có rào).** Spec §54 đã khai; sau W2g còn chặt hơn (`isActive && isUserSelectable`). Không đòi vá đợt này, chỉ xác nhận **răng đã sắc hơn spec mô tả** ⇒ khi đóng sổ nên ghi vào accepted-risk cho đủ 2 vế (bỏ-ghim **và** bỏ-active).
- **MINOR-6 — `EnsureWorkflowGroupMatchAsync` cho qua khi `awId` không tồn tại.** Có khai chủ đích `:55-57` và ở Create/UpdateDraft thì `EnsureWorkflowTypeAsync` chạy trước nên vô hại. Ở **Submit** không có rào type đứng trước — nhưng `ContractSigningPlans.ApprovalWorkflowId` có **FK Restrict** (`ContractSigningPlanConfiguration.cs:50-53`) nên `awId` luôn resolve. Ghi lại để lượt sau đừng gỡ FK mà quên chỗ này.

---

# Điểm CHỊU ĐƯỢC soi (positive validation — ghi để lượt sau đừng phá)

1. **3/3 finding 🔴 của tôi ở review đợt-1 đều được đóng ĐÚNG BẢN CHẤT**, không phải đóng bằng chữ: (a) assembly-ranh → file `public static class` riêng; (b) `IgnoreQueryFilters` cho bảng có `HasQueryFilter` → đúng 3 bảng, và **phân biệt được 2 chiều** (DELETE cần, changelog cố ý không); (c) FK-thật → grep 8 module, 5 module loose-0-FK được khai là "rào DUY NHẤT" + T12 chứng minh bằng dangling THẬT trong DB.
2. **Mọi ca test đều có control dương CÙNG LƯỢT** — T9/T10/T11/T13/T14 và cả T6 chân (2). Không có ca "vế âm xanh-mù" nào.
3. **T5 dựng lại đúng trạng thái không-thể-tạo-qua-API** (seed thẳng DbContext) và **loại trước 2 cửa 409 nhiễu** bằng cách kéo hạng-mục theo `approvalGroup`. Đây là chống "xanh vì nhầm cửa" ở mức tôi không bẻ được.
4. **`key={typeCode ?? 'all'}` trên `TypePanel`** (`:400`) — remount khi đổi nhóm, chặn state-bleed giữa 8 panel dùng chung component. Chi tiết nhỏ nhưng đúng chỗ.
5. **POST gửi `code: lockedCode ?? code`** (`:882`) chứ không tin ô input đã `disabled` — bất-biến theo CẤU TRÚC, không theo kỷ luật UI.
