Files
solution-erp/.claude/workflows/runs/2026-07-29-S161-khkk-w1-schema/sub-database-agent-1.md
2026-07-29 19:44:56 +07:00

240 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# sub-database-agent-1 — Design-review Mig 69 `AddContractSigningPlans` (S161 W1, READ-ONLY advisory)
> Ghi TỪNG MỤC trong lúc làm (chống #53). Không sửa file production. Không commit.
> Nguồn đo: file migration + Designer + ModelSnapshot + 7 EF Configuration + sqlcmd LocalDB Dev/Design.
## VERDICT TỔNG: **PASS 8/8 mục (a)(h) + sqlcmd + DB11 — ĐỦ ĐIỀU KIỆN DEPLOY, 0 blocker.**
4 advisory cho wave sau (A-1 chặn 2 kế hoạch/1 PE · A-2 dọn Attachments khi xoá DossierItem · A-3 IX_SlaDeadline chỉ khi wire job · A-4 backup trước khi chạy Down trên DB có data). Chi tiết §END.
---
## 0. Vật liệu đã đọc (evidence anchors)
| Vật | Đường dẫn | Ghi chú |
|---|---|---|
| Migration | `src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260729122015_AddContractSigningPlans.cs` | 373 dòng |
| Designer | `...20260729122015_AddContractSigningPlans.Designer.cs` | 264.515 B |
| Snapshot | `...Migrations/ApplicationDbContextModelSnapshot.cs` | 264.399 B, git diff +550/-0 |
| Config ×7 | `src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/ContractSigningPlan*.cs` | 7 file |
| Spec W1 | `.claude/workflows/runs/2026-07-29-S160-khkk-dryrun-plan/spec-wave-w1-schema-designer-29-07-2026.md:16-20` | §②-2/4/6 |
| Spec gốc maxLength | `.claude/workflows/runs/2026-07-28-S157-ke-hoach-ky-ket-hd/spec-ke-hoach-ky-ket-hd-28-07-2026.md:134-137` | danh sách 11 cột chuỗi |
Đếm operation trong migration (grep + uniq):
- `CreateTable` ×7 · `CreateIndex` ×20 · `DropTable` ×7 (Down)
- `AddColumn` / `AlterColumn` / `DropColumn` / `RenameColumn` / `Sql(` = **0** (không match nào)
---
## 3. Đối chứng sqlcmd LocalDB (DB1/DB10 — schema THẬT, không trí nhớ)
`sqlcmd -S "(localdb)\MSSQLLocalDB" -d SolutionErp_Dev -E` :
```
TABLECOUNT|96
CSPTABLE|ContractSigningPlanApprovals
CSPTABLE|ContractSigningPlanAttachments
CSPTABLE|ContractSigningPlanChangelogs
CSPTABLE|ContractSigningPlanDossierItems
CSPTABLE|ContractSigningPlanLevelOpinions
CSPTABLE|ContractSigningPlanLines
CSPTABLE|ContractSigningPlans
MIGTOP|20260729122015_AddContractSigningPlans
MIGCOUNT|69
```
`-d SolutionErp_Design` :
```
DESIGN_TABLECOUNT|96 · DESIGN_CSP|7 · DESIGN_MIGTOP|20260729122015_AddContractSigningPlans · DESIGN_IXCOUNT|27
```
**2 DB parity TUYỆT ĐỐI** (96/96 bảng · 7/7 CSP · cùng mig-top · 27 index = 20 IX + 7 PK).
**KHÔNG có committed-but-unapplied-local drift** (khác bẫy S53).
→ Acceptance §③-B "sys.tables 89 → 96" = **ĐẠT** (đo được 96; delta +7 khớp 7 CreateTable).
### Index thật trong DB (27 dòng, khớp 1:1 file mig)
Nhóm filtered (soi gotcha #57):
```
ContractSigningPlanLines | IX_..._ContractSigningPlanId_SupplierId | unique=1 | ([IsDeleted]=(0)) | ContractSigningPlanId,SupplierId
ContractSigningPlans | IX_ContractSigningPlans_MaKeHoach | unique=1 | ([MaKeHoach] IS NOT NULL) | MaKeHoach
```
Các IX còn lại filter = `-` (không filter), unique=0 trừ `IX_ContractSigningPlanLevelOpinions_ContractSigningPlanId_ApprovalWorkflowLevelId` (unique=1, **không** filter — bàn ở §2e/§4).
### FK thật (8 FK, cột `delete_referential_action_desc`)
```
Approvals → ContractSigningPlans CASCADE
Attachments → ContractSigningPlans CASCADE
Changelogs → ContractSigningPlans CASCADE
DossierItems → ContractSigningPlans CASCADE
LevelOpinions→ ContractSigningPlans CASCADE
Lines → ContractSigningPlans CASCADE
LevelOpinions→ ApprovalWorkflowLevels NO_ACTION (= Restrict)
ContractSigningPlans → ApprovalWorkflows NO_ACTION (= Restrict)
```
→ 6 con Cascade 1-hop + 2 Restrict, **đúng y spec §②-2/4**. Zero FK vật lý sang PE/Project/Supplier/User/Contract (loose-Guid, convention Mig 49) — xác nhận bằng chính danh sách 8 FK trên (không có dòng nào trỏ `PurchaseEvaluations`/`Suppliers`/`Projects`).
---
## 2. Chấm điểm từng mục (a)(h)
### (a) Down reversible sạch — **PASS**
`:348-370` — 7 `DropTable`, thứ tự: Approvals · Attachments · Changelogs · DossierItems · LevelOpinions · Lines · **ContractSigningPlans (cuối)**.
- 6 bảng con (mọi bảng có FK Cascade trỏ Plans) drop TRƯỚC cha ⇒ 0 vi phạm FK-order.
- `Attachments → DossierItems` **không có FK vật lý** (loose, chỉ HasIndex `:252-254`) ⇒ không tồn tại ràng buộc thứ tự giữa 2 bảng này; thứ tự alphabet hiện tại vẫn hợp lệ.
- `LevelOpinions → ApprovalWorkflowLevels` (Restrict) và `Plans → ApprovalWorkflows` (Restrict): bảng cha nằm NGOÀI mig ⇒ drop bảng con không đụng cha.
- Up chỉ CreateTable/CreateIndex ⇒ Down = đảo hoàn toàn, **không có state nào sót lại**. Không cần `DropIndex` riêng (DropTable cuốn index theo).
- ⚠️ DB2: Down là destructive-by-nature (mất data 7 bảng). Với dry-run GĐ2 chưa có data prod thì rủi ro = 0; **nếu chạy Down sau khi prod đã có phiếu ⇒ bắt buộc backup trước.**
### (b) 0 ALTER bảng cũ — **PASS**
Đếm operation trên toàn file: `AddColumn`/`AlterColumn`/`DropColumn`/`RenameColumn`/`RenameTable`/`Sql(` = **0 match**. Chỉ 7 CreateTable + 20 CreateIndex (Up) + 7 DropTable (Down).
→ Không đụng 89 bảng cũ ⇒ deploy prod = **additive thuần**, không khoá bảng đang chạy, rollback rẻ.
`ApprovalWorkflowApplicableType += ContractSigningPlan = 10` là đổi ENUM C# trên cột `int` sẵn có ⇒ **đúng là không sinh migration** (xác nhận: mig không có AlterColumn nào trên `ApprovalWorkflows`).
### (c) FK strategy — **PASS**
Khớp 100% ý đồ spec (đo từ `sys.foreign_keys`, không đọc code):
- 6 con → Plan: **Cascade 1-hop**
- Plan → `ApprovalWorkflows`: **Restrict** (`:42-47`, config `ContractSigningPlanConfiguration.cs:37-40`) ✓ — không cho xoá quy trình khi còn phiếu pin.
- LevelOpinions → `ApprovalWorkflowLevels`: **Restrict** (`:196-201`) ✓ — bảo vệ chữ ký.
- **0 multiple-cascade-path**: điểm nguy hiểm duy nhất là `Attachments` (vừa con của Plan vừa trỏ DossierItem). Đã né đúng cách bằng loose-Guid + HasIndex (`ContractSigningPlanAttachmentConfiguration.cs:11-15,31`). Nếu đặt FK Cascade ở đó thì `Plan→DossierItem→Attachment` + `Plan→Attachment` = 2 đường cascade ⇒ **SQL Server từ chối CREATE TABLE ngay** (lỗi 1785). Chứng cứ ngược: bảng tạo được trên cả 2 DB ⇒ không có multiple path.
- ⚠️ **Hệ quả cần W2/W3 biết (advisory, không phải lỗi schema):** xoá 1 `DossierItem` **KHÔNG** tự dọn `Attachments` trỏ nó → `ContractSigningPlanDossierItemId` thành con trỏ mồ côi. Đây đúng khuôn PE, nhưng PE có handler dọn tay. **W2 phải tự set null / xoá mềm attachment con khi xoá DossierItem**, nếu không màn xem hồ sơ sẽ hiện file gắn vào mục đã biến mất.
### (f) maxLength khớp spec `:134-137` — **PASS (11/11, +5 cột ngoài danh sách cũng đã khai)**
Đo từ `sys.columns` (⚠️ `max_length` của nvarchar là **BYTE** = 2× số ký tự):
| Cột (bảng) | spec yêu cầu | DB thật (byte) | = ký tự | verdict |
|---|---|---|---|---|
| MaKeHoach (Plans) | 50 | 100 | 50 | ✓ |
| GhiChu (Plans) | 2000 | 4000 | 2000 | ✓ |
| Name (DossierItems) | 500 | 1000 | 500 | ✓ NOT NULL |
| Note (DossierItems/Lines/Attachments) | 1000 | 2000 | 1000 | ✓ ×3 |
| TvgsName (DossierItems) | 200 | 400 | 200 | ✓ |
| Summary (Changelogs) | 1000 | 2000 | 1000 | ✓ |
| ContextNote (Changelogs) | 2000 | 4000 | 2000 | ✓ |
| SignedByFullName (LevelOpinions) | 200 **IsRequired** | 400, is_nullable=**0** | 200 | ✓ |
| FileName (Attachments) | 500 | 1000 | 500 | ✓ NOT NULL |
| StoragePath (Attachments) | 1000 | 2000 | 1000 | ✓ NOT NULL |
| ContentType (Attachments) | 200 | 400 | 200 | ✓ NOT NULL |
+5 cột chuỗi ngoài danh sách spec, **đều đã khai độ dài** (không rơi vào bẫy nvarchar(max)):
`HoSoLink` 1000 (mirror PE Mig 52) · `Comment` (Approvals) 1000 · `Comment` (LevelOpinions) 2000 · `UserName` (Changelogs) 200 · `FieldChangesJson` = **nvarchar(-1)=MAX, CỐ Ý** (JSON tự do, không index — `ContractSigningPlanChangelogConfiguration.cs:30`).
**Chỉ đúng 1 cột nvarchar(max) trong cả 7 bảng, và nó là cột được chỉ định.** Không có cột chuỗi nào "quên khai" (rủi ro lens-schema C3 = đã chặn).
**Tiền tệ:** `PeReferenceAmount`/`ProposedAmount`/`ApprovedAmount` = `decimal(18,2)` đo từ `sys.columns.precision/scale` = **18/2** — khớp khuôn `PurchaseEvaluationQuotes.BgVat/ChuaVat/ThanhTien` (18/2). Không có cột tiền nào rơi về `decimal(18,0)` (bẫy mất phần lẻ).
### (d) Index đủ cho query list/inbox tương lai — **PASS**
**Trục 1 — mọi bảng con seek được theo PlanId (chống scan lúc mở chi tiết phiếu):** 6/6 con có index **dẫn đầu bằng `ContractSigningPlanId`**:
`Lines(PlanId,SupplierId)` unique-filtered · `DossierItems(PlanId)` · `Attachments(PlanId)` · `Changelogs(PlanId,CreatedAt)` + `(PlanId,EntityType)` · `Approvals(PlanId,ApprovedAt)` · `LevelOpinions(PlanId,LevelId)` unique.
→ Load 1 phiếu = 6 seek, **0 table scan**. Đây là cái quan trọng nhất và nó đủ.
**Trục 2 — list (W2):** `IX_ContractSigningPlans_Phase_IsDeleted` (2 equality) + `IX_ProjectId` + `IX_DrafterUserId` + `IX_PurchaseEvaluationId`. Query "phiếu của tôi" / "theo dự án" / "PE này đã có kế hoạch chưa" (spec cũ `:157` — tra ngược bằng `Plans.Where(PurchaseEvaluationId==x)`, KHÔNG thêm cột lên PE) đều có index đích. ✓
**Trục 3 — inbox (W3):** `WHERE Phase=ChoDuyet AND IsDeleted=0` → seek `IX_Phase_IsDeleted`; rồi lọc theo `ApprovalWorkflowId` (có IX) + 2 con-trỏ StepIndex/LevelOrder nằm sẵn trong row. ✓
**Trục 4 — 3 site admin xoá Level:** cả 3 đều lọc `levelIds.Contains(o.ApprovalWorkflowLevelId)``IX_ContractSigningPlanLevelOpinions_ApprovalWorkflowLevelId` phục vụ đúng (`ApprovalWorkflowV2AdminFeatures.cs:945` count · `:1017` retained · `:1083` purge). ✓ **Đã wire đủ 3 site** (grep xác nhận 3 vị trí, khớp yêu cầu spec §②-10).
**Sai khác duy nhất so twin — `IX_SlaDeadline` KHÔNG có** (cả `Contracts` lẫn `PurchaseEvaluations` đều có). **Không phải thiếu sót:** `SlaExpiryJob` chỉ quét `db.Contracts` (`SlaExpiryJob.cs:76` + `:138`, 0 tham chiếu tới bảng khác), và entity khai tường minh `ContractSigningPlan.cs:34-36` "SLA = FE hiển thị tham khảo, KHÔNG đăng ký SlaExpiryJob". Index không job nào quét = index chết. **Bỏ là ĐÚNG.**
🔸 *Điều kiện lật:* wave sau nếu wire KHKK vào `SlaExpiryJob` ⇒ phải thêm `IX_ContractSigningPlans_SlaDeadline` (1 mig nhỏ) trước khi bật job, không thì job quét full-table mỗi chu kỳ.
### (e) UNIQUE filtered gotcha #57 — **PASS**
Đo từ `sys.indexes.filter_definition` (không đọc code):
- `IX_ContractSigningPlanLines_ContractSigningPlanId_SupplierId``is_unique=1`, filter **`([IsDeleted]=(0))`** ✓ **ĐÚNG gotcha #57**. Xoá mềm 1 NCC rồi thêm lại NCC đó = được (không đụng unique).
- `IX_ContractSigningPlans_MaKeHoach``is_unique=1`, filter `([MaKeHoach] IS NOT NULL)` ✓ khuôn `Contracts.MaHopDong` / `PurchaseEvaluations.MaPhieu` (đo được cả 2 đều cùng dạng filter). Nháp chưa gen mã (`MaKeHoach=null`) không đụng nhau.
- `IX_ContractSigningPlanLevelOpinions_(PlanId,LevelId)``is_unique=1`, **KHÔNG filter**. 👉 **ĐÚNG, không phải sót:** đối chứng 2 twin đo trực tiếp trong DB — `IX_PurchaseEvaluationLevelOpinions_(PeId,LevelId)` unique KHÔNG filter · `IX_ContractLevelOpinions_(ContractId,LevelId)` unique KHÔNG filter. Họ `*LevelOpinion`**UPSERT 1-row-per-level**, không có luồng xoá-mềm-rồi-ký-lại ⇒ thêm filter sẽ lệch khuôn 7 twin và mở cửa cho 2 row cùng (Plan,Level).
### (g) loose-Guid nào cần IX mà thiếu — **PASS (superset của khuôn PE)**
| loose-Guid | IX? | đối chứng khuôn PE |
|---|---|---|
| `Plans.PurchaseEvaluationId` | ✓ | (PE không có twin — mới) |
| `Plans.ProjectId` | ✓ | PE có `IX_ProjectId` |
| `Plans.DepartmentId` | ✓ | **PE KHÔNG có** → CSP chặt hơn |
| `Plans.DrafterUserId` | ✓ | PE không có twin |
| `Lines.SupplierId` / `Lines.ContractId` | ✓ / ✓ | PE có `IX_ContractId` |
| `DossierItems.SupplierId` | ✓ | PE `Attachments.PeSupplierId` |
| `Attachments.DossierItemId` | ✓ | (loose cố ý, né multiple-cascade) |
| `Approvals.ApprovalWorkflowLevelId` | ✓ | **PE Approvals KHÔNG có** → CSP chặt hơn |
| `Approvals.ApprovedByUserId` | ✗ | PE Approvals cũng ✗ — **parity**, cột hiển thị không phải predicate |
| `Changelogs.UserId` / `EntityId` | ✗ / ✗ | PE Changelogs cũng ✗ — **parity** |
| `LevelOpinions.SignedByUserId` | ✗ | PE/Contract LevelOpinions cũng ✗ — **parity** |
**0 loose-Guid thiếu index mà có người truy vấn.** 4 cột không index đều trùng khớp twin PE (chỉ đọc ra để hiển thị, không nằm trong WHERE của bất kỳ site nào đã wire).
### (h) Snapshot drift — **PASS (0 drift)**
1. **Designer vs ModelSnapshot** (diff sau khi bỏ dòng trống/comment): **15 dòng khác, TẤT CẢ là header**`using ...Migrations` · `[Migration("20260729122015_...")]` · `partial class AddContractSigningPlans` vs `ApplicationDbContextModelSnapshot : ModelSnapshot` · `BuildTargetModel` vs `BuildModel`. **0 dòng khác biệt về model.** (Designer 4.973 dòng / Snapshot 4.971 dòng — lệch đúng 2 dòng header.)
2. **git diff ModelSnapshot = `1 file changed, 550 insertions(+)`, 0 deletions** — 3 hunk đều dạng `-0` (thuần chèn): `@@ -314,0 +315,453 @@` (7 entity) · `@@ -5685,0 +6139,82 @@` (quan hệ) · `@@ -6277,0 +6813,15 @@` (navigation). 👉 **Không dòng nào của 89 bảng cũ bị sửa** ⇒ chứng minh ở tầng MODEL rằng mig này additive thuần (khớp mục (b) ở tầng SQL).
3. **3-file rule đủ:** `20260729122015_AddContractSigningPlans.cs` (22.059 B) + `.Designer.cs` (264.515 B) + `ApplicationDbContextModelSnapshot.cs` (264.399 B) — cả 3 đều xuất hiện trong `git status` (2 file đầu untracked-mới, snapshot modified). ✓ skill `ef-core-migration`.
4. Tên file dùng timestamp EF chuẩn `20260729122015_`, **không có tiền tố `69_`** ⇒ khớp acceptance §③-B dòng 1. `__EFMigrationsHistory` COUNT = **69** ⇒ đây đúng là mig thứ 69.
---
## 4. Kiểm tra DB11 (concurrency) — floor bắt buộc của vai này
**`ContractSigningPlanCodeGenerator.cs` — PASS.**
- `BeginTransactionAsync(IsolationLevel.Serializable, ct)` (`:23-24`) bọc read-modify-write của sequence ✓ (đúng pattern tham chiếu `WorkflowAppCodeGen`).
- `(DbContext)db` cast (`:22`) để với tới `Database` — đúng cách vì `IApplicationDbContext` chỉ expose DbSet + SaveChangesAsync.
- Commit/Rollback đủ nhánh (`:43`, `:48`).
- **Hàng rào thứ 2 đo được trong DB:** `PK_WorkflowAppCodeSequences` đặt **trên chính cột `Prefix`** (`is_unique=1`) ⇒ nếu 2 giao dịch cùng lần-đầu-tiên-của-năm chèn `KHKK/2026`, một bên vỡ PK và rollback — **không thể sinh 2 row cùng prefix**.
- **Hàng rào thứ 3:** `IX_ContractSigningPlans_MaKeHoach` unique-filtered ⇒ dù codegen có lọt mã trùng thì INSERT phiếu vẫn bị chặn ở DB.
- 🔸 *Rủi ro còn lại (đã có tiền lệ, KHÔNG phải lỗi mới):* SERIALIZABLE + đọc-rồi-ghi cùng row ⇒ 2 giao dịch đồng thời có thể **deadlock (1205)** thay vì xếp hàng. Fail-safe (một bên abort, mã KHÔNG trùng, không mất data), và đây là hành vi chung của 4 module Office đang chạy prod. Nếu sau này thấy 1205 trong log Submit ⇒ đổi câu đọc sang `UPDLOCK, HOLDLOCK` để biến deadlock thành chờ. **Không đề xuất sửa trong wave này.**
- 🔸 Format `{LastSeq:D3}`: seq ≥ 1000 sẽ tự nới thành 4 chữ số (`KHKK/2026/1000`) — không lỗi, nhưng regex acceptance `^KHKK/\d{4}/\d{3}$` sẽ không còn khớp. Với nhịp phiếu KHKK thì còn rất xa; chỉ ghi để test-specialist biết ràng buộc của phép đo.
**Không có write-path nào khác trong W1** (W1 chỉ đặt schema; handler CQRS thuộc W2/W3) ⇒ chưa có chỗ nào cần `RowVersion`. 🔸 **Cảnh báo trước cho W3:** `Plans.Phase` + `CurrentWorkflowStepIndex/LevelOrder` là bộ đếm bị 2 người duyệt đua — đây đúng lớp lỗi S43/S56 (LeaveBalance lost-update). W3 phải đi qua guard "đọc lại Phase trong transaction" hoặc `ExecuteUpdate` có điều kiện `WHERE Phase = @expected`, KHÔNG dùng bare-SaveChanges trên entity đã tracked.
---
## 5. Đề xuất (ADVISORY — không sửa gì, lead quyết)
**A-1 (nên làm ở W2, KHÔNG cần migration) — chặn 2 kế hoạch DaDuyet cho cùng 1 PE.**
`Plans.PurchaseEvaluationId``NOT NULL` + IX **không unique** ⇒ DB cho phép N kế hoạch trên 1 PE. W5 lại tra bằng `Plans.Where(PurchaseEvaluationId==peId && Phase==DaDuyet)` (`spec-wave-w5:13`) — nếu có 2 phiếu DaDuyet cùng PE thì câu này **mơ hồ**, lấy nhầm giá `ApprovedAmount` là sai tiền.
→ Đề xuất: guard trong handler Create/Approve của W2/W3 ("PE này đã có kế hoạch đang mở/đã duyệt"). **KHÔNG đề xuất thêm filtered-unique index** vì (i) plan cha chốt "Mig 69 = migration DUY NHẤT", (ii) chưa rõ nghiệp vụ có cho làm lại kế hoạch sau khi 1 cái bị huỷ hay không — khoá cứng ở DB lúc này là quyết định thay owner.
**A-2 (W2) — xoá `DossierItem` phải dọn `Attachments` con.** Không có FK vật lý giữa 2 bảng (cố ý, để né multiple-cascade-path) ⇒ DB **không** tự dọn. Thiếu bước này thì màn hồ sơ hiện file gắn vào mục đã biến mất.
**A-3 (chỉ khi wire SLA job) — thêm `IX_ContractSigningPlans_SlaDeadline`.** Hiện KHÔNG cần (job chỉ quét `Contracts`).
**A-4 (vận hành, DB2) — Down xoá 7 bảng + toàn bộ data trong đó.** Script Down đã sinh và đọc sạch, nhưng nếu chạy sau khi prod đã có phiếu thì phải backup trước. Với dry-run GĐ2 (0 row) thì rollback an toàn.
**KHÔNG có đề xuất nào bắt buộc trước deploy.**
---
## 6. Bằng chứng Down chạy được (DB2-safe: sinh SCRIPT, KHÔNG apply)
`dotnet ef migrations script 20260729122015_AddContractSigningPlans 20260727033522_AddPeAllowApproverDelete --no-build`
```sql
BEGIN TRANSACTION;
DROP TABLE [ContractSigningPlanApprovals];
DROP TABLE [ContractSigningPlanAttachments];
DROP TABLE [ContractSigningPlanChangelogs];
DROP TABLE [ContractSigningPlanDossierItems];
DROP TABLE [ContractSigningPlanLevelOpinions];
DROP TABLE [ContractSigningPlanLines];
DROP TABLE [ContractSigningPlans];
DELETE FROM [__EFMigrationsHistory] WHERE [MigrationId] = N'20260729122015_AddContractSigningPlans';
COMMIT;
```
→ Sinh **không lỗi**, 7 DROP + xoá history nằm trong **MỘT transaction** (atomic, không kẹt nửa chừng). Chiều Up cũng sinh sạch (đuôi script: 20 CREATE INDEX + INSERT history `ProductVersion = 10.0.6` = EF Core 10, đúng pin).
⚠️ **Tôi CỐ Ý KHÔNG chạy `database update <prev>`** trên LocalDB Dev — đó là lệnh destructive (DB2), và acceptance "Down chạy sạch trên DB copy" thuộc quyền lead/implementer trên **bản copy**, không phải trên DB Dev đang dùng.
---
## §END — VERDICT
| # | Mục | Verdict |
|---|---|---|
| a | Down reversible sạch (7 DropTable đúng thứ tự FK) | **PASS** |
| b | 0 ALTER bảng cũ | **PASS** (0 AddColumn/AlterColumn/DropColumn/Sql) |
| c | FK strategy 6 Cascade + 2 Restrict, 0 multiple-cascade-path | **PASS** |
| d | Index đủ cho list/inbox | **PASS** (6/6 con seek theo PlanId; thiếu IX_SlaDeadline là CỐ Ý ĐÚNG) |
| e | UNIQUE filtered gotcha #57 | **PASS** (Lines `[IsDeleted]=(0)` ✓; LevelOpinions không filter = đúng khuôn 2 twin) |
| f | maxLength khớp spec `:134-137` | **PASS** 11/11 + 5 cột ngoài spec; đúng 1 nvarchar(max) cố ý |
| g | loose-Guid thiếu IX | **PASS** — superset khuôn PE, 4 cột không IX đều parity |
| h | Snapshot drift | **PASS** — Designer ≡ Snapshot (chỉ khác header), diff 550 insert / 0 delete |
| — | sqlcmd đối chứng Dev + Design | **PASS** — 96/96 bảng, 7/7 CSP, 27/27 index, mig-top khớp, 2 DB parity |
| — | DB11 concurrency (codegen) | **PASS** — SERIALIZABLE + PK(Prefix) + unique-filtered MaKeHoach = 3 lớp |
**KẾT LUẬN: PASS TOÀN BỘ — Mig 69 ĐỦ ĐIỀU KIỆN DEPLOY.** 0 blocker, 0 finding phải sửa trước deploy. 4 advisory (A-1..A-4) đều thuộc wave sau / vận hành.
TOTAL: 10 dòng verdict · 0 FAIL · 4 advisory.