# B4 — BE-STYLE review lane 2/2 (lens: Ô-ĐO + TEST-MATRIX + BS-3)

> agent: `back-end-reviewer-style` (ensemble B4, lane 2/2) · run `2026-08-12-S190-soltuon-dieu-chinh-hd`
> input: `sub-style-be-b3.md` (20 SB + 18 ô C + 7 ca test) · `spec-soltuon-dieu-chinh-hd-12-08-2026.md` §②-W5 + §③-W5 + §④
> nguyên tắc: mọi số dưới là **ĐO**, không suy; 0-hit luôn kèm **control dương** (control sai ⇒ hỏng THƯỚC, không phải hỏng vật).

## Verdict

**B4-BE-ODO: PASS_WITH_FIXES — 15 findings + T-matrix 13 ca (+1 phản-ca)**

Phần LUẬT (SB-1..SB-12, SB-20, BS-1/2/4/5/6/7) **đứng vững**: 9/9 khuôn tao mở ra đối chiếu đều SỐNG và
đúng nội dung được khai (K1 `:1741-1806` · K3 `:885-901` · K5 `:13/:30-32/:78` · K6 `:153-156` · K7 field-set
đủ · K9 · K11 helper+registerable · K12 `SoftDeleteFixture:55-80` · K13 `:469-539`). Nhưng lớp **ĐO** thì thủng:
**5/18 ô C sai hoặc dương-giả**, **1 luật (e) đã bị chính spec v2.1 đảo mà list chưa lật**, và **bộ 7 ca test có
1 ca trùng + thiếu 5 ca**. List CHƯA dùng làm STYLE-GATE được cho tới khi vá R-1..R-6 (đều là sửa chữ, ≤30 phút).

| Nhóm | Kết |
|---|---|
| Anchor/khuôn (lane-1 chồng lấn, tao chỉ spot-check) | 9/9 SỐNG |
| Luật SB nội dung | ĐẠT — trừ (e) SB-13/14/18 (R-1) |
| 18 ô C chạy được | **13 OK · 5 PHẢI SỬA** (C3, C5, C14, C17, C18) + C1/C10/C13/C16 phải chỉnh kỳ vọng |
| 7 ca test | 6 giữ · 1 trùng-lặp phải thay · **+5 ca thiếu** ⇒ 13 |
| BS-3 (policy khoá oan) | **XÁC NHẬN BẰNG SỐ THẬT — mạnh hơn mức list khai** |

## Findings (R-1..R-15)

### R-1 🔴 BLOCKER — list ĐÁNH NHAU với spec v2.1: (e) bắt gắn policy, spec CẤM gắn

- `sub-style-be-b3.md:60` (SB-13 "…đặt NGAY TRÊN action Delete") · `:61` (SB-14) · `:68` (SB-18 reflection-test
  `attr!.Policy.Should().Be("Contracts.Delete")`) · ô `:100` (C10 "**=1**") · ô `:103` (C13 "≥1").
- Ngược trực tiếp `spec…12-08-2026.md:76-82` — *"🔄 **v2.1 theo BS-3 (be-style B3):** KHÔNG gắn
  `[Authorize(Policy="Contracts.Delete")]` đợt này"* — và acceptance `:125-127` ✅(iii) *"KHÔNG thêm
  `[Authorize(Policy` mới vào DELETE"*.
- Vì sao đây là BLOCKER chứ không phải lệch chữ: `spec:85-87` phong chính 2 file style-list làm **STYLE-GATE
  bắt buộc** cho worker B6. Worker sẽ cầm 2 mệnh lệnh ngược nhau, và mệnh lệnh của style-list là mệnh lệnh
  **ship regression** (R-2). Trớ trêu: chính BS-3 của file này đẻ ra v2.1, nhưng phần (e) + checklist **không
  được lật theo**.
- **Vá:** SB-13 → "GIỮ nguyên shape hiện tại: class `[Authorize]` trần, action DELETE **KHÔNG** policy —
  `[carry:w8-delete-policy]`"; SB-14 → comment khai *vì sao chưa gắn* (dẫn (82) + BS-3) thay vì khai lý-do gắn;
  SB-18 → **bỏ**; C10 lật kỳ vọng **=0**; C13 **bỏ**.

### R-2 🔴 BLOCKER (dữ liệu) — BS-3 ĐÚNG, và số thật còn xấu hơn con số list mượn của PE

Đo LocalDB `SolutionErp_Dev` (`sqlcmd -S "(localdb)\MSSQLLocalDB" -d SolutionErp_Dev -E`), bảng `Permissions`
JOIN `Roles`, 13 role / 13 dòng cho key `Contracts`:

```
Contracts|Admin            |R1 C1 U1 D1     ← DUY NHẤT có CanDelete=1
Contracts|Drafter          |R1 C1 U0 D0     ← 🔴 CanDelete = 0
Contracts|Procurement      |R1 C1 U0 D0
Contracts|{10 role còn lại}|R1 C0 U0 D0
--- CONTROL DƯƠNG (cùng câu query, key KHKK — nơi policy ĐANG chạy tốt):
KeHoachKyKet|Drafter       |R1 C1 U1 D1     ← Drafter CÓ CanDelete
KeHoachKyKet|Admin         |R1 C1 U1 D1
KeHoachKyKet|{11 role}     |R1 C1 U1 D0
```

⇒ **12/13 role `CanDelete=0` trên key `Contracts`** (BS-3 mượn số PE "11/13" làm tiền lệ — số của CHÍNH key này
là **12/13**, Drafter nằm trong nhóm 0). Gắn policy = mọi role trừ Admin ăn **403 trước khi owner-check chạy**
⇒ Drafter **mất** đường xoá nháp đang có (hành-vi deployed = CHUẨN, S176). KHKK sống được với policy **chỉ vì
seed đã grant `CanDelete` cho Drafter** — đó là biến số data, không phải style.
- **Hệ quả cho list:** BS-3 phải nâng từ *"FLAG lead ĐO trước khi ship"* thành **luật CẤM của đợt này** (đã có
  số, khỏi FLAG nữa), + ghi kèm điều kiện mở W8: *policy chỉ gắn được KÈM grant data cho Drafter, 2 việc CÙNG
  commit, nếu không là khoá oan*.
- **Giới hạn phép đo (khai thẳng):** đây là **Dev**, không phải prod. Prod có thể đã bị admin sửa tay qua
  `/system/permissions`. Trước W8 phải đo lại prod (`sqlcmd -E` qua ssh — bài S177), KHÔNG suy từ Dev.

### R-3 🔴 MAJOR — ô C17 (vế raw-SQL) là phép đo RỖNG: luôn 0 hit dù có vi phạm

`sub-style-be-b3.md:107` viết `grep -rnE "FromSqlRaw\|ExecuteSqlRaw" …`. Với `-E`, `\|` = **pipe LITERAL** ⇒
tìm chuỗi `FromSqlRaw|ExecuteSqlRaw` (không tồn tại) ⇒ **0 vĩnh viễn**.

```
grep -rnE "FromSqlRaw\|ExecuteSqlRaw"        …/Application/ | wc -l   → 0
grep -rnE "IApplicationDbContext\|ZZZNOTEXIST" …/Application/ | wc -l → 0   ← CONTROL DƯƠNG: chuỗi CÓ THẬT vẫn 0 ⇒ THƯỚC HỎNG
grep -rE  --include=*.cs "IApplicationDbContext|ZZZNOTEXIST" …       | wc -l → 292 ← bản đúng có răng
grep -rE  --include=*.cs "FromSqlRaw|ExecuteSqlRaw|SqlQueryRaw" …    | wc -l → 0   ← claim K17 ĐÚNG, nhưng đúng nhờ may
```

⇒ claim của K17 (`:28`) là **THẬT**, nhưng ô canh nó **không có răng**: mai worker thêm `FromSqlRaw`, ô vẫn xanh.

### R-4 🔴 MAJOR — 3 ô DƯƠNG-GIẢ ngay hôm nay (pass trước khi worker viết 1 dòng)

Đo trên `ContractFeatures.cs` (902 dòng) **trước** W5:

| Ô | Lệnh trong list | Baseline HÔM NAY | Vì sao hỏng |
|---|---|---|---|
| C3 `:93` | `grep -n "ICurrentUser currentUser" ContractFeatures.cs` | **7 hit** (`:56, :247, :284, :320, :456`, …) | file có 7 handler khác đã inject; ô không phân biệt "đã sửa delete" ⟂ "chưa làm gì" |
| C5 `:95` | `grep -n "AppRoles.Admin"` + `grep -n "DrafterUserId == currentUser.UserId"` | **4 hit** / **1 hit** (`:647` `var isDrafter = c.DrafterUserId == currentUser.UserId;`) | idiom owner-check ĐÃ có sẵn trong file (khối IDOR) ⇒ ô xanh sẵn |
| C1 `:91` | `grep -n "record DeleteContractCommand"` | **1 hit** (`:887`, bản KHÔNG có `Reason`) | kỳ vọng "1 hit, **có** `string? Reason`" không nằm trong lệnh — mắt người phải đọc, máy không bắt |

Chữa: **scope theo thân handler** bằng `awk '/class DeleteContractCommandHandler/,/^}/'` rồi mới grep (xem §Ô đo).
🔸 Ghi nhận ngược lại: `db.Contracts.Remove(` chỉ **1 hit** (`:899`) và `T26`/`[W5` baseline = **0/0** ⇒ C6/C8/C9
là ô LÀNH, không cần scope (nhưng scope vẫn tốt hơn).

### R-5 🔴 MAJOR — C14 hỏng 2 tầng, và tầng 2 phá đúng cái bẫy nó định canh

`:104` — `grep -n "AuditingInterceptor" tests/.../Application/Contract*Tests.cs`
1. Path chứa `...` **literal** ⇒ lệnh gãy (không có thư mục nào tên `...`).
2. Kể cả sửa thành path thật: glob `Contract*Tests.cs` khớp **6 file có sẵn**, và `grep -l AuditingInterceptor`
   trên glob đó trả **6/6 file** (`ContractCatalogTests.cs:83` · `ContractHardCopyTests.cs` ·
   `ContractSigningPlanApprovalTests.cs:82` · `ContractSigningPlanCrudTests.cs:77` ·
   `ContractSigningPlanGroupAndCatalogTests.cs:81` · `ContractV2PipelineTests.cs`) ⇒ ô **XANH kể cả khi file
   test MỚI không wire interceptor** = đúng cái bẫy BS-4 (`:80` assert-rỗng-nghĩa) mà nó được dựng để canh.

Chữa: neo **tên file cụ thể** (`ContractDeleteGuardTests.cs`), cấm glob.

### R-6 🔴 MAJOR — C18 dùng `git diff --stat` ⇒ MÙ với file .cs mới (mà W5 đẻ ra đúng loại đó)

Fault-inject (tạo → đo 2 thước → dọn):

```
echo "class Z{}" > src/Backend/SolutionErp.Application/Contracts/__probe_untracked.cs
git diff --stat -- src/Backend/SolutionErp.Application/Contracts/   → (0 dòng)      ← C18 as-written KHÔNG THẤY
git status --porcelain -- src/Backend/SolutionErp.Application/Contracts/
                                                    → ?? …/__probe_untracked.cs    ← thước đúng THẤY
```

W5 tạo `ContractDeleteGuardTests.cs` (file .cs MỚI) ⇒ ô "KHÔNG file .cs mới ngoài tests/" chạy trên `git diff`
là ô mù đúng chỗ cần sáng. Đây là **bài S179 của chính vai này** (gate PRE-commit + `git diff` = chết câm).
Chữa: `{ git diff --name-only HEAD; git ls-files -o --exclude-standard; } | sort -u`.
🔸 Bên lề, KHAI để không ai tưởng tao đo ẩu: 2 file `sub-style-review-b4-*.md` mới trong run-folder **không**
hiện ở `git status --porcelain` lẫn `git ls-files -o` (mà `check-ignore -v --no-index` nói `!.claude/**` =
NOT-ignored). Tao không truy tiếp (ngoài địa phận + hết ngân sách lượt) — **nêu để lead tự kiểm**, vì nếu
đúng thì phép audit "tracked-change ngoài run-folder" của Harness-10 có điểm mù cần biết.

### R-7 🔴 MAJOR (test-matrix) — ca "V1-nháp phase 2" TRÙNG ca "DangSoanThao", và không canh được rủi ro nó tự nhận canh

`SB-17 :67` liệt cả *"DangSoanThao Drafter xoá được (giữ cũ)"* **và** *"+V1-nháp phase 2 vẫn xoá được (chống vá
quá tay)"* — mà `ContractPhase.cs:17` là `DangSoanThao = 2`. **Hai ca = một ca.** (spec `:124` chép y hệt cặp này.)
Rủi ro THẬT mà "chống vá quá tay" phải canh: worker chép **HÌNH** pattern-match của K1
(`is not (DangSoanThao or TuChoi or TraLai)` — `ContractSigningPlanFeatures.cs:1778-1780`, đúng thứ SB-4 `:39`
gọi là "khuôn cú pháp") ⇒ allow-list **THU HẸP**, đánh rơi phase legacy `DangChon(1)/DangGopY(3)/DangDamPhan(4)`
vốn đang xoá được (`ContractFeatures.cs:896` `Phase >= DangInKy`). Vật thật: Dev đang có **2 HĐ ở phase 3**
(đo: `SELECT Phase, COUNT(*) … GROUP BY Phase` → `2|2, 3|2, 5|1, 7|1, 9|2, 10|1`).
⇒ Ca đúng = **phase 3 (hoặc 4)**, không phải phase 2.

### R-8 🔴 MAJOR (test-matrix) — nhánh `isAdmin` KHÔNG có ca DƯƠNG nào

7 ca SB-17 không có "Admin xoá HĐ **người khác** ở Nháp → OK". Nếu worker viết `if (!isOwner) throw` (rơi vế
`|| isAdmin`), chỉ ca DaPhatHanh-Admin bắt được — và **chỉ khi** test đó dựng Admin ≠ Drafter (nhận 403 thay vì
409 kỳ vọng). Bắt-được-nhờ-tình-cờ-dựng-dữ-liệu ≠ bằng chứng. Cần ca dương riêng.

### R-9 🔴 MAJOR (test-matrix) — BS-6 nêu lỗ null nhưng KHÔNG ca nào khoá

`Contract.cs:16` `public Guid? DrafterUserId` · `TestCurrentUser.cs:14` `public Guid? UserId` + `:20` ctor rỗng
⇒ dựng actor null **1 dòng**. Không có nấc-0 thì `entity.DrafterUserId == currentUser.UserId` hoá `null == null`
⇒ `isOwner = true` ⇒ xoá lọt. Và test **gọi handler TRỰC TIẾP** (khuôn K12 `NewDeleteHandler(f)` /
`PeDeleteByApproverTests.cs:148-152`) ⇒ rào `[Authorize]` ở controller **không cứu** ở tầng này. BS-6 (`:82`) đòi
thêm nấc-0 nhưng SB-17 không có ca ⇒ luật không răng. (Dev hiện **0/9** HĐ có `DrafterUserId` null — nên ca này
sống ở test, càng phải viết.)

### R-10 🟡 MEDIUM — thứ tự guard SB-3 không có ca nào phân biệt được

SB-3 (`:38`) chốt NotFound → Forbidden → Conflict. Muốn đo phải có ca **CHÉO**: (a) người-lạ + id không tồn tại
⇒ **404** (không phải 403); (b) người-lạ + HĐ `ChoDuyet` ⇒ **403** (không phải 409). Thiếu 2 ca thì hai bản cài
đặt khác thứ tự **đều xanh** ⇒ luật SB-3 chỉ là lời khuyên.

### R-11 🟡 MEDIUM — validator MỚI (SB-1/SB-12) là mảnh code duy nhất 0 ca chạm

`DeleteContractCommandValidator` + `MaximumLength(2000)` là code **mới toanh** (baseline: file không có
validator cho delete). Không ca nào gọi. 1 ca `Reason` 2001 ký tự → `ValidationException` là rẻ nhất trong bộ.

### R-12 🟡 MEDIUM — SB-19/C16 "ĐÚNG 1 row" sẽ để canonical stale

STATUS có **2 ô số**: `docs/STATUS.md:6` (dòng session, dạng DELTA `test **668→680**` — khuôn SB-19 trỏ) và
`docs/STATUS.md:476` (row canonical `| Tests | **680 PASS** | 45 Domain + 635 Infra …`). Chính row `:476` tự khai
*"ô này lệch 2 đời… cùng ô đã 4 lần tái phát ⇒ nguồn cập nhật = cicd-verify lượt kế, KHÔNG chép tay"*. Câu
SB-19 *"ĐÚNG 1 row, không neo số tuyệt đối chỗ khác"* đọc thẳng = **không lật `:476`** ⇒ canonical stale đúng
class STATUS đang cảnh báo. Phải gọi tên: dòng session mới (DELTA) **và** row canonical (số tuyệt đối, sau
cicd-verify) — 2 chỗ, khác vai, không phải "neo số 2 nơi".

### R-13 🟡 MEDIUM — K7 có mồi bẫy nằm ngay trên đầu vùng được trỏ (ghost-wire)

`ContractChangelog.cs:10-13`: *"Populate qua MediatR `AuditBehavior` interceptor — auto từ Insert/Update/Delete
commands trên Contract + Details (xem Application/Common/Behaviors/AuditBehavior.cs)"*. Đo:
`ls Application/Common/Behaviors/` → **chỉ `ValidationBehavior.cs`**; `grep -rn --include=*.cs "AuditBehavior"
src/ tests/` → **2 hit, cả 2 là chính 2 dòng comment này**. File KHÔNG tồn tại; `CLAUDE.md` Tier-4 vẫn liệt
"MediatR AuditBehavior" là TODO. Ai HIỆN GHI `ContractChangelogs`: đúng **2 chỗ tay**
(`CreateContractFromSigningPlanFeatures.cs:247` · `CreateContractFromEvaluationFeatures.cs:121`).
⇒ Worker mở K7 rất dễ kết luận "changelog tự ghi, khỏi Add" ⇒ bỏ SB-10/SB-11 ⇒ 0 dòng ⇒ acceptance ✅(ii) đỏ mà
không hiểu vì sao. List phải cắm 1 dòng cảnh báo cạnh K7.

### R-14 🔵 NIT — SB-15 trỏ fixture xa hơn mức cần (khuôn CÙNG HỌ đã có)

SB-15 (`:65`) bảo chép `SoftDeleteFixture` từ `PeDeleteByApproverTests.cs:55-104` (module PE). Khuôn **cùng họ
Contract, đã deploy**, gần hơn: `ContractSigningPlanCrudTests.cs:77` (`KhkkFixture`) và `ContractCatalogTests.cs:83`
— đều `.AddInterceptors(new AuditingInterceptor(...))`. Đo: **5 fixture private-per-file** đã deploy dùng đúng
nếp này ⇒ dựng fixture thứ 6 là **ĐÚNG khuôn**, không phải trùng lặp (chặn trước phản-biện "sao không dùng
chung"). Giữ K12 làm nguồn **lý-do** (`:32-41`), đổi khuôn **hình** sang Contract-family.

### R-15 🔵 NIT — checklist nối bằng `&&` sẽ TỰ CẮT, và "không chạy" trông y hệt "pass"

`grep -c` trả **exit 1 khi đếm = 0** ⇒ chuỗi `A && B && C` đứt ngay ô đầu tiên có 0 hit. Tao dính thật khi chạy
thử: chuỗi dừng sau ô `T26 = 0`, 3 ô sau **không chạy mà không báo gì**. Ô CẤM (kỳ vọng 0) là loại dính nhiều
nhất. Chữa: nối bằng `;` (hoặc `| wc -l`, hoặc `|| true`).

## Ô đo phải sửa (18 ô → verdict + lệnh thay thế)

Đặt trước: `F=src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs` ·
`H='/class DeleteContractCommandHandler/,/^}/'` · `C=src/Backend/SolutionErp.Api/Controllers/ContractsController.cs` ·
`T=tests/SolutionErp.Infrastructure.Tests/Application/ContractDeleteGuardTests.cs`

| Ô | Verdict | Lệnh/kỳ vọng ĐÚNG |
|---|---|---|
| C1 | siết | `grep -c 'record DeleteContractCommand(Guid Id, string? Reason = null)' $F` → **1** |
| C2 | siết | `awk '/class DeleteContractCommandValidator/,/^}/' $F \| grep -c 'MaximumLength(2000)'` → **1** |
| C3 | **SỬA (R-4)** | `awk "$H" $F \| grep -c 'ICurrentUser currentUser'` → **1** (as-written = 7 dương-giả) |
| C4 | siết | `awk "$H" $F \| grep -n 'NotFoundException\|ForbiddenException\|ConflictException'` → 3 dòng, thứ tự NF < FB < CF |
| C5 | **SỬA (R-4)** | `awk "$H" $F \| grep -c 'AppRoles.Admin'` → 1 **và** `awk "$H" $F \| grep -c 'DrafterUserId == currentUser.UserId'` → 1 |
| C6 | OK (siết) | `awk "$H" $F \| grep -n 'db.Contracts.Remove(\|db.ContractChangelogs.Add(\|SaveChangesAsync'` → Remove < Add · `SaveChangesAsync` đúng **1** |
| C7 | OK | giữ; VN-literal chạy được trên shell này — control dương: `grep -c '(chưa có mã)' …/ContractSigningPlanFeatures.cs` → **2** |
| C8 | OK | baseline `T26` trong `$F` = **0** ⇒ ô có nghĩa |
| C9 | OK | baseline `[W5` trong `$F` = **0** ⇒ ô có nghĩa |
| C10 | **LẬT (R-1)** | `awk '/HttpDelete\("\{id:guid\}"\)/,/IActionResult> Delete\(/' $C \| grep -c 'Authorize(Policy'` → **0** |
| C11 | siết | `grep -c '^\[Authorize\]$' $C` → **1** (đừng neo số dòng `:13`) |
| C12 | OK | `grep -c 'FromQuery\] string? reason' $C` → **1** |
| C13 | **BỎ (R-1)** | thay bằng ghi chú `[carry:w8-delete-policy]`; **KHÔNG** viết test âm "Delete không có policy" — test đó **pin cái lỗ** và sẽ ĐỎ oan đúng lúc W8 gắn policy hợp lệ |
| C14 | **SỬA (R-5)** | `grep -c 'AuditingInterceptor' $T` → ≥1 · `grep -c 'IgnoreQueryFilters' $T` → ≥1 (**tên file cụ thể; CẤM glob `Contract*Tests.cs`** — 6/6 file cũ đã có, ô sẽ xanh oan) |
| C15 | siết | `grep -c '\[Fact\]' $T` → **≥13** + `grep -n 'public async Task ' $T` khớp đủ T-1..T-13 |
| C16 | siết (R-12) | `dotnet test SolutionErp.slnx` → **680+N**; lật **2 ô**: dòng session mới `docs/STATUS.md` (DELTA `680→680+N`) **và** row canonical `Tests` `:476` (số tuyệt đối, chỉ chép từ cicd-verify) |
| C17 | **SỬA (R-3)** | `grep -rn --include=*.cs "catch (" src/Backend/SolutionErp.Api/Controllers/ \| wc -l` → 0 (control: cùng lệnh trên `src/Backend/SolutionErp.Api/` → **30**) · `grep -rn --include=*.cs "Console.WriteLine" src/Backend/ \| wc -l` → 0 (control `Console\.` → **72**) · `grep -rE --include=*.cs "FromSqlRaw\|ExecuteSqlRaw\|SqlQueryRaw"` ⇒ **PHẢI viết `grep -rE --include=*.cs "FromSqlRaw|ExecuteSqlRaw|SqlQueryRaw"`** → 0, kèm control dương `"IApplicationDbContext|ZZZNOTEXIST"` → **292** |
| C18 | **SỬA (R-6)** | `{ git diff --name-only HEAD; git ls-files -o --exclude-standard; } \| sort -u` (as-written `git diff --stat` MÙ với .cs mới — đã fault-inject chứng minh) |
| **C19 mới** | thêm | mọi grep repo-wide phải có `--include=*.cs`: `grep -rn "Contracts\.Delete" src/ tests/` hôm nay trả **14 dòng "Binary file … matches"** (bin/obj DLL); thêm `--include=*.cs` → **0** (baseline đúng) |
| **C20 mới** | thêm | nối các ô bằng `;` không `&&` (R-15) |
| **C21 mới** | thêm (R-2) | trước khi kết luận "policy an toàn": `sqlcmd -S "(localdb)\MSSQLLocalDB" -d SolutionErp_Dev -E -Q "SELECT r.Name,p.CanDelete FROM Permissions p JOIN Roles r ON r.Id=p.RoleId WHERE p.MenuKey='Contracts'"` — control dương = cùng query với `MenuKey='KeHoachKyKet'` (Drafter phải =1) |

**Ngoài checklist — ô §④ nấc đo local thiếu tiền-đề dữ liệu:** spec `:138` đòi *"1 HĐ TraLai + 1 ChoDuyet + 1
DaPhatHanh"*. Đo Dev: `ChoDuyet(10)=1` ✅ · `DaPhatHanh(9)=2` ✅ · **`TraLai(98)=0` ❌ · `TuChoi(99)=0` ❌** — tức
**2 trạng thái TRUNG TÂM của W5 không có sẵn** để đo tay. Worker phải tự dựng (đưa 1 HĐ qua đường Trả lại/Từ
chối) hoặc đánh **DEFERRED-TO-DEPLOY** công khai; ô test tự-dựng trong xUnit thì seed thẳng Phase là đủ.

## T-matrix hợp nhất (T-1..T-13 + 1 phản-ca)

Nguồn: `SB-17` (`sub-style-be-b3.md:67`, 7 ca) ⊎ spec `✅W5(i)-(iii)` (`:120-127`) ⊎ ca tao thêm.
Cột "nguồn": **B** = cả 2 danh sách đã có · **S** = chỉ spec · **BE** = chỉ style-list · **MỚI** = cả 2 đều thiếu.

| # | Ca | Actor | Kỳ vọng | Nguồn |
|---|---|---|---|---|
| T-1 | `DangSoanThao(2)` — HĐ của chính mình | Drafter-chủ | **OK** + assert 3-trong-1: khuất query thường · `IgnoreQueryFilters().Single().IsDeleted==true` + `DeletedBy` · changelog **đúng 1 dòng** `Action=Delete` với `ContextNote`/`PhaseAtChange=2`/`UserId` | B |
| T-2 | `TraLai(98)` | Drafter-chủ | **OK** (vế MỚI của W5) — Dev 0 row phase 98 ⇒ seed thẳng trong fixture | B |
| T-3 | `TuChoi(99)` | Drafter-chủ | **OK** (vế MỚI của W5) | B |
| T-4 | **`DangGopY(3)` (legacy `< DangInKy`)** | Drafter-chủ | **OK** — chống vá-quá-tay THẬT (**thay** ca "V1-nháp phase 2" trùng T-1; Dev có 2 HĐ phase 3 sống) | MỚI (thay B) |
| T-5 | `ChoDuyet(10)` | Drafter-**chủ** | **409 Conflict** (khuôn KHKK; approver-delete = Q4 chờ anh) | B |
| T-6 | `DaPhatHanh(9)` | **Admin** | **409 Conflict** — HĐ đã gen mã không bao giờ xoá | B |
| T-7 | `DangSoanThao(2)` — HĐ người khác | người lạ (không chủ, không Admin) | **403 Forbidden** (sửa lỗ cũ; khuôn stranger `ContractSigningPlanCrudTests.cs:536-539`) | B |
| T-8 | `DangSoanThao(2)` — HĐ người khác | **Admin** | **OK** — ca DƯƠNG cho vế `\|\| isAdmin` (R-8) | MỚI |
| T-9 | HĐ `DrafterUserId = null` | actor `UserId = null` (`new TestCurrentUser()`) | **KHÔNG xoá được** (`UnauthorizedException` theo nấc-0 BS-6) — chặn `null == null ⇒ isOwner` (R-9) | MỚI |
| T-10 | `id` không tồn tại | người lạ | **404 NotFound** (không phải 403) — khoá thứ tự NotFound → Forbidden | MỚI |
| T-11 | `ChoDuyet(10)` — HĐ người khác | người lạ | **403 Forbidden** (không phải 409) — khoá thứ tự Forbidden → Conflict | MỚI |
| T-12 | `Reason` dài 2001 ký tự | Drafter-chủ | **ValidationException** (validator mới, SB-12) | MỚI |
| T-13 | `Reason` = `"   "` (whitespace) | Drafter-chủ | OK + `ContextNote == null` (trim-to-null, SB-12) — gộp assert vào T-1 cũng được | MỚI |
| **T-14** | **phản-ca — KHÔNG viết** | — | KHÔNG thêm `[Fact]` reflection-authz cho `ContractsController.Delete` (cả chiều khẳng-định lẫn chiều phủ-định): chiều khẳng-định ngược spec v2.1 (R-1), chiều phủ-định pin lỗ + ĐỎ oan khi W8 tới. Dùng ô grep C10 thay test. | MỚI |

**Ghép 2 danh sách:** trùng 6 ca (T-1,2,3,5,6,7) · style-list có mà spec không nói: yêu cầu
`IgnoreQueryFilters` chống-vacuous (BS-4, `:80`) — spec chỉ ghi "đọc-ngược DB" · spec có mà style-list nhạt:
✅(iii) grep-âm policy (đã thành C10) · **cả 2 cùng thiếu: T-8..T-13 (6 ca)** · **cả 2 cùng sai: ca "V1 nháp 2"
(→ T-4)**. Tổng đề nghị **13 ca thật** (SB-17 nói 7, spec nói 6) ⇒ C15 sửa kỳ vọng `≥13 [Fact]`.

## Điều tao KHÔNG kết luận (ranh giới)

- Không phán **có nên** mở approver-delete (Q4) hay gắn policy W8 — quyền anh; tao chỉ đưa **số** (R-2) để anh
  quyết không bị mù dữ liệu.
- Không soi logic nghiệp-vụ allow-list đúng/sai về mặt quy trình (→ `reviewer`); tao chỉ soi **đồng-khuôn với
  bản đã deploy** + đo được.
- Không đụng `Persistence/**` (EF config/migration = `database-reviewer-style`) — W5 là 0-mig, không có gì để soi.
- Không sửa file nào ngoài chính báo cáo này.

== HẾT ==
