# SUB — back-end-reviewer-style — B3 SPEC-LIST STYLE BE (YC-029, run SOLTUON-12-08-2026)

> Engine: /fable-real single deep-pass · **propose-only** — lead verify rồi mới dùng.
> Đề: SPEC-LIST STYLE để worker Opus MAX (B6) viết W5 (DeleteContract allow-list + `[Authorize(Policy)]` + test-before) **KHÔNG lệch khuôn bản ĐÃ DEPLOY** (luật owner S176: đã deploy production = CHUẨN).

## Khuôn nguồn BE

Tất cả đều là bản **ĐÃ DEPLOY trên `origin/main`** (luật S176). W5 đụng 3 file production + 1-2 file test; mỗi luật SB-n dưới neo vào ≥1 khuôn ở đây.

| # | Nguồn (file:line) | Cho luật gì |
|---|---|---|
| K1 | `src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs:1740-1807` — `DeleteContractSigningPlanCommand{,Validator,Handler}` | **Khuôn HÀNH-VI chính của W5** (lời anh "tương tự như KHKK"): guard-order, allow-list phase, ForbiddenException owner-check, changelog Remove-trước-Add, comment T26 nén |
| K2 | `src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSoftDeleteFeatures.cs:14-166` | Comment đầu-file "Vì sao có file này" + XML-doc `Reason` (:34-36) + T26 bản ĐẦY-ĐỦ (:127-144) + tách-đường policy (:25-29) |
| K3 | `src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs:885-902` — `DeleteContractCommandHandler` HIỆN TẠI | Nền W5 sửa: 0 owner-check, chỉ `Phase >= DangInKy` chặn, 0 changelog, 0 `ICurrentUser` |
| K4 | `ContractFeatures.cs:330-356` (khối IDOR + `[W7 S161]`) | Khuôn tag-đợt trong comment + lý-do-đặt-chỗ |
| K5 | `src/Backend/SolutionErp.Api/Controllers/ContractsController.cs:11-14` (class `[Authorize]` TRẦN) + `:29-32` (`ListDeleted` per-action `Contracts.Read`) + `:78-83` (DELETE hiện TRẦN) | Shape authz của CHÍNH controller này: class-trần + per-action policy trên endpoint nhạy cảm |
| K6 | `src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs:153-156` | Khuôn DELETE endpoint: `[HttpDelete("{id:guid}")]` + `[Authorize(Policy = "KeHoachKyKet.Delete")]` + `[FromQuery] string? reason = null` |
| K7 | `src/Backend/SolutionErp.Domain/Contracts/ContractChangelog.cs:13-44` | Field changelog + `ChangelogEntityType.Contract=1` + `ChangelogAction.Delete=3` (sẵn, 0-mig) |
| K8 | `src/Backend/SolutionErp.Api/Middleware/GlobalExceptionMiddleware.cs:32-40` | Map exception→HTTP: Validation→400 · NotFound→404 · Unauthorized→401 · Forbidden→403 · Conflict→409; `detail = ex.Message` (:52) ⇒ message = chữ user đọc |
| K9 | `src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs:14` (`Contracts` const) + `:251` (∈ `All`) + `:278` (`Actions = ["Read","Create","Update","Delete"]`) + `src/Backend/SolutionErp.Api/Program.cs:86-87` (factory `{menu}.{action}`) | `"Contracts.Delete"` được SINH SẴN by-construction — 0 seed mới, 0 key mới |
| K10 | `src/Backend/SolutionErp.Domain/Identity/AppRoles.cs:5-6` (`Admin`/`Drafter`) + `src/Backend/SolutionErp.Application/Common/Interfaces/ICurrentUser.cs:3-10` (`Guid? UserId · Roles · FullName · Email · IsAuthenticated`) | Vật liệu owner-check |
| K11 | `tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs:26-34` (2 helper reflection) + `:121-144` (`PolicyName_IsActuallyRegisterable`) + `:150-153` ("2 shape KHÁC nhau — KHÔNG ép về cùng 1 kiểu") | Khuôn test authz reflection (Pattern 10) |
| K12 | `tests/.../Application/PeDeleteByApproverTests.cs:32-41` (vì-sao fixture riêng) + `:55-104` (`SoftDeleteFixture` = IdentityFixture + `AuditingInterceptor`) + `:774` (`T26_Delete_WritesChangelogWithDeleteAction_AndCarriesReasonWhenProvided`) | Fixture wire interceptor — chống "assert rỗng nghĩa" |
| K13 | `tests/.../Application/ContractSigningPlanCrudTests.cs:469-523` (`DeleteContractSigningPlan_SoftDeletes_KeepsChangelog_ThenCreateAgainSucceeds` — đo 3-trong-1) + `:525-539` (`DeleteContractSigningPlan_GuardedByOwnerAndPhase`, stranger→Forbidden) | Khuôn test delete GẦN W5 NHẤT (chính họ KHKK) |
| K14 | Migration `20260423030733_AddContractDetailsAndChangelog.cs:28` — `ContextNote nvarchar(2000)` | Neo `MaximumLength(2000)` cho validator (validator MATCH EF — khuôn K1 `:1748-1750`) |
| K15 | 4/4 csproj `src/Backend/*/*.csproj`: `net10.0` + `<Nullable>enable</Nullable>` + `<ImplicitUsings>enable</ImplicitUsings>` | Nullable strict là cấu-hình THẬT |
| K16 | `docs/STATUS.md:6` — khuôn pin test-count khai DELTA: `test **668→680**` | Chỗ ghi 680→680+N |
| K17 | Phép đo CẤM (đo 2026-08-12): `grep "catch ("` trong `Api/Controllers` = **0 hit** · `Console.WriteLine` trong `src/Backend` = **0 hit** · `FromSqlRaw\|ExecuteSqlRaw\|SqlQueryRaw` trong `Application` = **0 hit** | Luật CẤM §(g) là hiện-trạng đo được, không phải khẩu hiệu |

## SPEC-LIST STYLE BE (SB-1..)

> Worker viết W5 phải thoả TỪNG SB — mỗi SB có neo khuôn deployed + cách đo. "Khuôn" = copy HÌNH, không copy LỜI KHAI (câu khai sự-thật phải tự đúng trong ngữ-cảnh Contract).

### (a) Khuôn handler + guard-order

- **SB-1 — Bộ 3 mảnh cùng file, cùng khối:** `record DeleteContractCommand(Guid Id, string? Reason = null) : IRequest` → `class DeleteContractCommandValidator : AbstractValidator<>` → `class DeleteContractCommandHandler(...) : IRequestHandler<>` nằm LIỀN NHAU trong `ContractFeatures.cs` dưới banner sẵn có `// ========== DELETE (soft) ==========` (:885). KHÔNG tách file mới, KHÔNG tạo folder Commands/. Khuôn: K1 `:1741-1756` (KHKK delete đủ 3 mảnh liền nhau). Hiện Delete THIẾU validator — anh em cùng nghĩa (K1) CÓ ⇒ phải thêm.
- **SB-2 — Handler = primary-constructor `(IApplicationDbContext db, ICurrentUser currentUser)`:** khuôn K1 `:1754-1756` + K2 `:50-52`. KHÔNG inject DbContext cụ thể, KHÔNG service mới. `db.ContractChangelogs` sẵn trên interface (`IApplicationDbContext.cs:40`).
- **SB-3 — Guard-order chuẩn = NotFound → Forbidden → Conflict** (đúng thứ tự dòng trong K1): (1) load `?? throw new NotFoundException("Contract", request.Id)` (:1760-1761, giữ nguyên dạng đang có ở K3 `:893-894`) → (2) owner-check `var isOwner = entity.DrafterUserId == currentUser.UserId; var isAdmin = currentUser.Roles.Contains(AppRoles.Admin); if (!isOwner && !isAdmin) throw new ForbiddenException(...)` (:1763-1766; `Contract.DrafterUserId` có thật — `Contract.cs:16`) → (3) phase allow-list `throw new ConflictException(...)` (:1778-1783). Ngoại lệ CÓ TÊN: chỉ được đảo Conflict lên trước Forbidden khi guard-người PHỤ THUỘC dữ-kiện guard-trạng-thái (khuôn K2 `:63-107` — phải biết phase/step mới tính được "đúng lượt"), và phải comment lý-do. W5 KHÔNG rơi ca này ⇒ theo K1.
- **SB-4 — Allow-list phase viết TƯỜNG MINH một guard:** ngữ nghĩa W5 = cho `{Phase < DangInKy}` ∪ `{TraLai(98), TuChoi(99)}`, chặn hết phần còn lại (bao DaPhatHanh(9) + ChoDuyet(10)). Khuôn cú pháp = pattern-matching `is not (... or ...)` (K1 `:1778-1780`); range+set trộn được nhưng phải 1 guard DUY NHẤT + comment liệt kê đủ trạng-thái cho-phép. 🔴 KHÔNG giữ nguyên `>= DangInKy` rồi vá thêm if thứ hai — 2 if rời là mầm drift.

### (b) Exception nào cho ca nào

- **SB-5 — Phân loại theo K8:** sai NGƯỜI (không phải Drafter-của-phiếu/Admin) = `ForbiddenException` → 403; sai TRẠNG-THÁI (phase ngoài allow-list) = `ConflictException` → 409; không thấy = `NotFoundException("Contract", request.Id)` → 404 (ctor: tên-entity-English + key — K3 `:894`). CẤM `InvalidOperationException`/`UnauthorizedAccessException`/return-null. Cả 3 loại từ `SolutionErp.Application.Common.Exceptions` (using sẵn — K2 `:4`).
- **SB-6 — Message = tiếng Việt trọn câu, có đường-thoát:** `detail = ex.Message` đi thẳng ra user (K8 `:52`). Khuôn giọng: Forbidden nói AI được phép ("Chỉ người soạn kế hoạch hoặc Admin được xoá phiếu này." — K1 `:1766`); Conflict nói TRẠNG-THÁI nào được + lối ra ("Chỉ xoá được ... ở trạng thái Nháp, Trả lại hoặc Từ chối. ... đang duyệt phải Trả lại/Từ chối trước." — K1 `:1782-1783`). Viết lại cho danh-từ "hợp đồng"/các phase HĐ (DaPhatHanh cần câu riêng ý "HĐ đã phát hành không bao giờ xoá được" — hình theo K1, lời theo ngữ-cảnh Contract).

### (c) Comment tiếng Việt tự-khai (khuôn T26 + khuôn khai-lý-do)

- **SB-7 — Comment allow-list phải tự khai 3 vế** (khuôn K1 `:1768-1777`): (i) VÌ SAO có allow-list này (trích/neo lời anh: *"tương tự như KHKK"* — YC-029 Đ4, spec v2 §②-W5); (ii) vì sao ChoDuyet VẪN CẤM (approver-delete = Q4 chờ anh, KHÔNG mở đợt này); (iii) nhắc 2-tầng hiển-thị⟂API (gotcha #82) vì FE cũng hiện nút theo phase+vai. Tag đợt theo khuôn K4: mở đầu comment bằng `[W5 S190]` (mirror `[W7 S161]` `:353`, `[S159-đợt5]` K5 `:29`).
- **SB-8 — Owner-check phải KHAI là "sửa lỗ sẵn có":** spec v2 W5(a) bắt khai tường minh — comment nói rõ bản cũ (K3) KHÔNG kiểm chủ-sở-hữu, đây là siết mới đồng-khuôn KHKK, không phải hành-vi từ trước. Khuôn "khai vá lệch/lỗ cũ" = K1 `:1772-1775`.
- **SB-9 — Comment T26 chép HÌNH bản NÉN của K1 `:1787-1790`** (4 dòng: thứ-tự Remove-trước-Add + cơ chế `CascadeDeleteTiming.Immediate` DETACH child `Added` ⇒ SaveChanges ghi 0 row im lặng + vì sao thêm-sau thì sống), kèm chữ "bug T26 S155" để grep được. KHÔNG chép nguyên văn 18 dòng bản ĐẦY-ĐỦ của K2 `:127-144` (bản đó dành cho file có thêm rào interceptor — Contract không cần lặp).

### (d) Changelog — field nào, thứ tự nào

- **SB-10 — Remove TRƯỚC, Add changelog SAU = LUẬT:** `db.Contracts.Remove(entity);` đứng TRƯỚC `db.ContractChangelogs.Add(new ContractChangelog {...});`, rồi `await db.SaveChangesAsync(ct);` MỘT lần (K1 `:1791-1805`, K2 `:145-164`). Đo: số-dòng của `.Remove(` < số-dòng của `.Add(`.
- **SB-11 — Bộ field ghi delete** (map K1 `:1793-1803` sang K7): `ContractId = entity.Id` · `EntityType = ChangelogEntityType.Contract` (header ⇒ `EntityId` BỎ TRỐNG — K7 `:19` "null nếu là Contract header") · `Action = ChangelogAction.Delete` · `PhaseAtChange = entity.Phase` (phase TẠI LÚC xoá — ghi TRƯỚC khi có gì đổi) · `UserId = currentUser.UserId` · `UserName = currentUser.FullName ?? currentUser.Email` · `Summary = $"Xoá hợp đồng {entity.MaHopDong ?? "(chưa có mã)"}"` (placeholder khuôn K1 `:1801` — đa số HĐ xoá được chưa gen mã nên vế `??` là BẮT BUỘC) · `ContextNote = reason`. KHÔNG dùng `FieldChangesJson` cho delete.
- **SB-12 — `Reason` đi trọn đường 3 tầng theo khuôn:** controller `[FromQuery] string? reason = null` (K6 `:155-156`) → command `string? Reason = null` + XML-doc khai "DeletedBy/DeletedAt do AuditingInterceptor set chỉ trả lời ai+lúc nào, LÝ DO nằm ở changelog" (K2 `:34-36`, K1 `:1740`) → validator `RuleFor(x => x.Reason).MaximumLength(2000)` + comment "MaxLength MATCH EF" neo config (K1 `:1748-1750`; số 2000 = K14) → handler `var reason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();` (K1 `:1785`).

### (e) Policy string + chỗ đặt attribute

- **SB-13 — `[Authorize(Policy = "Contracts.Delete")]` đặt NGAY TRÊN action Delete, class-level GIỮ `[Authorize]` trần:** shape của CHÍNH controller này (K5: class trần `:13` + per-action `ListDeleted` `:31-32` kèm câu "chặt hơn [Authorize] trần của class — không phá gì"). 🔴 CẤM "sửa cho nhất quán" sang class-level policy kiểu KHKK — 2 shape tồn tại CÓ CHỦ ĐÍCH (K11 `:150-153`); class-level policy trên ContractsController sẽ khoá GET list của mọi role thiếu Contracts.Read (gotcha #44 silent-403). Tên policy hợp lệ by-construction: K9 (KHÔNG sinh key mới, KHÔNG sửa `DbInitializer`, KHÔNG sửa `MenuKeys`).
- **SB-14 — Comment trên endpoint khai lý-do policy** theo khuôn K5 `:29-30` (2 dòng: đợt + vì-sao): nêu `#82` ([Authorize] trần = lỗ), và policy này là tầng-2 ĐỘC LẬP với owner-check trong handler (defence-in-depth như KHKK: K6 có policy VÀ K1 có owner-check).

### (f) Test style (test-before)

- **SB-15 — Chỗ đặt + fixture:** file mới `tests/SolutionErp.Infrastructure.Tests/Application/ContractDeleteGuardTests.cs` (hoặc tên `Contract...Tests` cùng dạng; namespace `SolutionErp.Infrastructure.Tests.Application`). 🔴 Fixture PHẢI wire `AuditingInterceptor` — chép hình `SoftDeleteFixture` K12 `:55-104` (private sealed trong file test, KHÔNG sửa `IdentityFixture` dùng chung — lý-do K12 `:41`: 500+ test đang dựa hành-vi cũ). Thiếu interceptor thì `Remove()` = xoá CỨNG ⇒ assert soft-delete/changelog "đúng ở cả hai thế giới" = đo số không (K12 `:32-41`).
- **SB-16 — Đặt tên + cấu trúc:** xUnit `[Fact]` + FluentAssertions; tên method English PascalCase `Hành-động_Kịch-bản_Kỳ-vọng` (khuôn K13 `:482` `DeleteContractSigningPlan_SoftDeletes_KeepsChangelog_ThenCreateAgainSucceeds`, `:531` `..._GuardedByOwnerAndPhase`); banner section `// ==== (n) ... ====` tiếng Việt khai VẬT ĐO + VÌ SAO (K13 `:469-480`); because-message tiếng Việt trong mọi `.Should()`; assert exception khuôn `(await act.Should().ThrowAsync<ConflictException>("...")).WithMessage("*mảnh-tiếng-Việt*")` (K13 `:459-462`). AAA ngầm bằng dòng trống + comment `// ---------- TRƯỚC / ACT / SAU ----------` khi test dài (K12-file `:330-357`).
- **SB-17 — Bộ ca BẮT BUỘC** (spec v2 ✅W5-i, đủ 6): TraLai Drafter xoá được · TuChoi Drafter xoá được · DangSoanThao Drafter xoá được (giữ cũ) · ChoDuyet CẤM kể cả Drafter (409) · DaPhatHanh CẤM mọi đường kể cả Admin (409) · người-lạ xoá HĐ người khác → 403 (khuôn stranger `new TestCurrentUser(Guid.NewGuid(), ...)` K13 `:538`) · +V1-nháp phase 2 vẫn xoá được (chống vá quá tay). Mỗi ca xoá-thành-công phải assert 3-trong-1 theo K13 `:490-509`: (a) khuất query thường + `IgnoreQueryFilters()` row còn `IsDeleted=true` + `DeletedBy` đúng; (c) changelog đọc-ngược DB `Single(l => l.Action == ChangelogAction.Delete)` + `ContextNote`/`PhaseAtChange`/`UserId` đúng — 0 dòng = dẫm T26.
- **SB-18 — Reflection-authz:** THÊM `[Fact]` vào `AuthorizePolicyRegressionTests.cs` (file sẵn — không tạo file mới): `GetActionAuthorize(typeof(ContractsController), nameof(ContractsController.Delete))` → `attr.Should().NotBeNull(...)` + `attr!.Policy.Should().Be("Contracts.Delete", "...")` (khuôn K11 `:73-80`) + giữ nếp kiểm registerable: policy split 2 phần ∈ `MenuKeys.All`/`MenuKeys.Actions` (K11 `:121-144`). Because-message nêu #82 + ngày/đợt.
- **SB-19 — Test-before + pin số:** viết test TRƯỚC (RED trên code hiện tại — ca ChoDuyet-CẤM và 403-người-lạ phải ĐỎ với K3) rồi code W5 (GREEN) — khuôn W3 KHKK "test-before RED→GREEN" (`docs/STATUS.md:11`). Chạy `dotnet test SolutionErp.slnx`; số pin 680→680+N khai DELTA vào row session `docs/STATUS.md` (khuôn `:6` "test **668→680**") — ĐÚNG 1 row, không neo số tuyệt đối chỗ khác (B1).

### (g) CẤM (đã đo hiện-trạng = 0, giữ 0 — K17)

- **SB-20:** KHÔNG `try-catch` trong controller (exception bay lên K8) · KHÔNG `Console.WriteLine` (Serilog qua `ILogger<T>`; và theo khuôn K1/K2: handler delete KHÔNG log gì — middleware đã log Warning cho 4xx `K8:42-45`, đừng thêm log thừa) · KHÔNG raw SQL trong Application (`FromSqlRaw`/`ExecuteSqlRaw`) · Nullable strict đang bật 4/4 csproj (K15) — không `!` nén warning, `Guid?` null-check tường minh trước dùng (khuôn K2 `:56-58`) · KHÔNG migration, KHÔNG sửa schema (`ChangelogAction.Delete` sẵn — K7 `:42`), KHÔNG đổi `SolutionErp.slnx` (0 file .cs mới ngoài test — file test nằm trong project test sẵn có).

## Bẫy style BE repo này

- **BS-1 — Doc nói "comment English", bản deploy nói ngược:** `docs/CLAUDE.md` §7 ghi *"Code + comment: English"*, nhưng TOÀN BỘ khuôn deploy (K1/K2/K4/K5 + cả 2 file test K11-K13) là comment tiếng Việt tự-khai. Luật S176: deployed = CHUẨN ⇒ **theo tiếng Việt**. FLAG cho lead: dòng doc đó stale — KHÔNG tự sửa doc trong wave này (ngoài scope W5).
- **BS-2 — Enum value 98/99 nằm NGOÀI dải tuyến-tính:** `TraLai=98`, `TuChoi=99` LỚN HƠN `DaPhatHanh=9` về số ⇒ guard cũ `Phase >= DangInKy` (K3 `:896`) đang chặn cả 2 phase mà W5 phải MỞ. Mọi so-sánh range mới phải xử lý 98/99 bằng vế TẬP tường minh, không bằng range (đây chính là lý-do SB-4 đòi 1 guard duy nhất).
- **BS-3 — Policy gắn vào đường xoá Drafter đang dùng = nguy-cơ khoá oan (tiền-lệ PE S155):** K2 `:25-29` khai đo DB Dev 11/13 role `CanDelete=0` ⇒ PE phải TÁCH ĐƯỜNG để Drafter không mất quyền xoá nháp chính mình. W5 chọn khuôn KHKK 1-đường (policy + owner-check trên CÙNG endpoint) — đúng khuôn, NHƯNG hệ-quả data: role Drafter thiếu `CanDelete` trên key `Contracts` sẽ 403 TRƯỚC KHI owner-check chạy ⇒ Drafter có thể MẤT nút xoá nháp đang có (hành-vi hiện tại: DELETE trần, ai đăng nhập cũng gọi được). Đây là quyết-định data/quyền, KHÔNG phải style — **FLAG lead ĐO ma-trận quyền prod trước khi ship** + khai trong PR; FE W5 gate nút theo phase+vai (spec) nhưng tầng API gate theo policy ⇒ đúng lớp 2-tầng #82, có thể "bấm được mà 403".
- **BS-4 — Assert-rỗng-nghĩa trong test:** fixture thiếu `AuditingInterceptor` thì soft-delete thành hard-delete, mọi assert "khuất khỏi list" xanh oan (K12 `:32-41`). Neo chống-vacuous khuôn K12 `:352-355`: sau ACT phải assert `IgnoreQueryFilters().Single(...).IsDeleted == true` TRƯỚC khi assert các hệ-quả.
- **BS-5 — Bê khuôn nhưng đừng bê lời khai:** message/comment của K1 nói "kế hoạch", "phiếu PE bị khoá 409" — chép nguyên sang Contract là khai SAI sự-thật trong ngữ-cảnh mới (bài S176). Mọi câu tiếng Việt phải viết lại quanh danh-từ "hợp đồng" + phase HĐ.
- **BS-6 — `DrafterUserId` là `Guid?`:** (`Contract.cs:16`) — HĐ cũ/di-sản có thể null ⇒ owner-check `entity.DrafterUserId == currentUser.UserId` với cả 2 vế nullable: null == null-user là ca không được lọt. Khuôn K2 `:56-58` chặn từ cửa: `if (!currentUser.IsAuthenticated || currentUser.UserId is null) throw new UnauthorizedException();` — thêm nấc-0 này là ĐÚNG khuôn khi handler cần định-danh (K1 bỏ qua vì controller đã `[Authorize]`, nhưng so-sánh nullable của K1 `:1763` chỉ an toàn nhờ `[Authorize]`; W5 nên theo K2 cho kín — 1 dòng, đo được).
- **BS-7 — `SaveChangesAsync` đúng MỘT lần:** khuôn K1/K2 kết bằng 1 lời gọi duy nhất — 2 lần Save (một cho Remove một cho changelog) đẻ nhu-cầu transaction + vỡ atomicity (K2 `:143-144` khai đã cân nhắc và BÁC).

## Checklist style ĐO ĐƯỢC cho worker

Chạy từ repo root; PASS = đúng vế kỳ-vọng. (Số dòng dưới là của bản HIỆN TẠI — sau khi worker sửa, đo bằng pattern, không bằng số dòng.)

| # | Lệnh đo | Kỳ vọng |
|---|---|---|
| C1 | `grep -n "record DeleteContractCommand" src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs` | 1 hit, có `string? Reason = null` |
| C2 | `grep -n "DeleteContractCommandValidator" src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs` | ≥1 hit + gần đó `MaximumLength(2000)` |
| C3 | `grep -n "ICurrentUser currentUser" src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs` | có hit trong `DeleteContractCommandHandler` |
| C4 | Trong thân handler delete: dòng `NotFoundException` < dòng `ForbiddenException` < dòng `ConflictException` | thứ tự SB-3 |
| C5 | `grep -n "AppRoles.Admin" ...ContractFeatures.cs` (vùng delete) + `grep -n "DrafterUserId == currentUser.UserId" ...` | owner-check 2 vế có mặt |
| C6 | Dòng `db.Contracts.Remove(` < dòng `db.ContractChangelogs.Add(` ; `grep -c "SaveChangesAsync"` trong handler delete = 1 | SB-10 · BS-7 |
| C7 | `grep -n "ChangelogAction.Delete" ...ContractFeatures.cs` + `grep -n "PhaseAtChange" ...` + `grep -n '(chưa có mã)' ...` | bộ field SB-11 |
| C8 | `grep -n "T26" ...ContractFeatures.cs` | ≥1 (comment SB-9) |
| C9 | `grep -n '\[W5' ...ContractFeatures.cs` | ≥1 (tag đợt SB-7) |
| C10 | `grep -n 'Authorize(Policy = "Contracts.Delete")' src/Backend/SolutionErp.Api/Controllers/ContractsController.cs` | =1, đứng giữa `[HttpDelete("{id:guid}")]` và `public async Task<IActionResult> Delete` |
| C11 | Class-level controller: `[Authorize]` TRẦN vẫn nguyên tại `:13` (không Policy) | SB-13 |
| C12 | `grep -n "FromQuery] string? reason" ...ContractsController.cs` | 1 hit trên Delete (SB-12) |
| C13 | `grep -rn "nameof(ContractsController.Delete)" tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs` | ≥1 (SB-18) |
| C14 | File test mới: `grep -n "AuditingInterceptor" tests/.../Application/Contract*Tests.cs` + `grep -n "IgnoreQueryFilters" ...` | ≥1 mỗi cái (SB-15, BS-4) |
| C15 | Đếm ca trong file test mới: TraLai · TuChoi · DangSoanThao · ChoDuyet-409 · DaPhatHanh-409 · stranger-403 · V1-phase2 | 7 ca SB-17 đủ mặt |
| C16 | `dotnet test SolutionErp.slnx` | PASS 680+N; row session `docs/STATUS.md` khai `680→680+N` |
| C17 | `grep -rn "catch (" src/Backend/SolutionErp.Api/Controllers/` · `grep -rn "Console.WriteLine" src/Backend/` · `grep -rnE "FromSqlRaw\|ExecuteSqlRaw" src/Backend/SolutionErp.Application/` | 0 · 0 · 0 (giữ nguyên K17) |
| C18 | `git diff --stat` của wave | KHÔNG đụng `Persistence/**`, KHÔNG file `.cs` mới ngoài `tests/`, KHÔNG sửa `MenuKeys.cs`/`DbInitializer.cs`/`SolutionErp.slnx` |

== HẾT ==
