# sub-reviewer-4 — LANE D: NHẤT QUÁN spec-cũ-ratified + QUYỀN OWNER (S160)

**VERDICT: SUA-TRUOC-THI-CONG** (4 HIGH · 3 MED · 4 LOW · 5 điểm củng cố). Chặn: **W8 chạy bằng default O-B** + **Δ1 policy-prefix**. W5 (trừ F-O7) và bảng default 5-câu-owner ĐỨNG.

> Đích soi: `plan-cha-dry-run-toan-trinh-29-07-2026.md` + `spec-wave-w5-cau-khkk-hd-29-07-2026.md` + `spec-wave-w8-authz-29-07-2026.md`.
> Đối chiếu: `runs/2026-07-28-S157-ke-hoach-ky-ket-hd/spec-ke-hoach-ky-ket-hd-28-07-2026.md` (RATIFIED, viết tắt **SC** = spec cũ) + `run.md` S160 + đĩa + curl prod.
> NOTE naming: task LƯỢT-1 gọi `sub-reviewer-owner-consistency-s160.md`, khối RUN-TRACE + ISOLATION chốt `sub-reviewer-4.md` — chọn theo ISOLATION (1 file duy nhất).

## 0. Trạng thái ghi-đĩa
- [x] khung rỗng (lượt 1) · [x] 3 file đích · [x] SC + run.md · [x] đo đĩa 9 lệnh · [x] curl prod · [x] chốt F-O1..F-O11

---

## 1. HIGH

### F-O1 [HIGH] — O-B vá ĐÚNG NỬA SAU của thứ O-3 tuyên "NGOÀI SCOPE", nhưng plan xếp làm DEFAULT tự chạy
- **SC:49 (O-3), verbatim hệ quả:** *"Lỗ hổng an ninh `ContractWorkflowService.cs:48-66` **+ controller HĐ class-trần**: **NGOÀI SCOPE**, treo có chủ đích. Wave nào chạm module Contract phải hỏi lại anh."* → nêu ĐÍCH DANH **2 vật**.
- **SC:410-411** ("Ngoài mọi wave — KHÔNG làm trong run này") liệt lại đúng 2 vật, "anh chốt *từ từ*".
- **`docs/STATUS.md:13` (canonical, S159 — 1 ngày trước):** *"❹-risk CÓ Ý THỨC (verbatim anh): «đang phát triển → cho hiển thị hết để mọi người góp ý, còn duyệt NCC thì cứ để như cũ» — menu HĐ public 13/13 role trong khi authz HĐ còn `[Authorize]` trần (lỗ hổng S156 treo chủ-đích) — **đã cảnh báo, anh tái khẳng định**."* ⇒ owner ĐÃ được cảnh báo đúng lỗ này và TÁI KHẲNG ĐỊNH giữ nguyên.
- **plan:73** (§5 row 11): DEFAULT = **O-B** = gắn `[Authorize(Policy)]` 22 endpoint ghi + seeder grant ⇒ chính là vá vật thứ 2. Chỉ **O-C** (vật thứ 1) bị đánh dấu "cần anh gật riêng".
- **Mâu thuẫn nội bộ:** `run.md:17` ④ lead tự viết *"riêng LỖ HỔNG authz S156 ... **KHÔNG tự coi là được sửa hay được bỏ qua**"* ⟂ plan:73 biến nó thành default tự chạy.
- **Hệ quả nghiệp vụ (không chỉ hình thức):** siết authz = có thể 403 đúng những người owner muốn "bấm thử để góp ý" (xem F-O3). Tức O-B đi ngược MỤC ĐÍCH owner phát biểu, không chỉ ngược thủ tục.
- **Sửa:** chuyển O-B về cùng nhóm "chờ anh gật" với O-C, **hoặc** giữ default nhưng plan phải có 1 dòng Δ tường minh + 1 câu hỏi owner: *"siết quyền ghi HĐ trước dry-run — đồng ý không, biết rằng siết sai grant = 403 người bấm thử?"*.

### F-O2 [HIGH] — quyền quyết O-B bị chuyển từ OWNER sang REVIEW-ENSEMBLE (uỷ quyền sai cửa)
- `w8:3`: *"Option = **REVIEW CHỐT quyết**"*; plan:73 *"(O-A fallback **nếu review bác**)"*. Vật này do **owner** đóng ở O-3/`STATUS.md:13`, reviewer không có thẩm quyền mở lại.
- Phụ: **plan:27** ghi *"option **O-A/O-B mặc định**"* ⟂ plan:73 chọn hẳn O-B ⇒ 2 option cùng làm "mặc định" = **0 bit** (không biết đang ở đâu thì không có gì để đảo).
- **Sửa:** plan §5 row 11 ghi 1 giá trị default duy nhất + ghi rõ "quyết định thuộc owner, review chỉ khuyến nghị".

### F-O3 [HIGH] — W8 bước 3 (seeder grant) KHÔNG THỂ ĂN trên prod ⇒ O-B deploy = 403 toàn bộ, giết đúng dry-run
- `w8:19` bước 3: *"Seeder grant (gotcha #84 — CÙNG COMMIT): cấp `Contracts` Create/Update cho role tham gia dry-run"*.
- **Đĩa bác:** `DbInitializer.cs:2233-2243` — với key **non-PE**, nếu row đã tồn tại thì `continue` (**skip-existing**, comment nguyên văn `:2242` *"Key non-Pe: skip-existing (giữ nguyên như cũ)"*). Nhánh insert `:2247-2253` đặt `CanRead=true, CanCreate=isPe` (⇒ **false** cho `Contracts`), `CanUpdate=false`, `CanDelete=false`.
- Row `Contracts`/`Ct_*` **đã tồn tại prod** (grant "hiển thị hết" S159 — `DbInitializer.cs:2209-2213` comment: *"nhánh grant key-thường là **skip-existing** ⇒ trên prod row false **KHÔNG được nâng ở đây** — re-grant prod đã chạy SQL trực tiếp cùng ngày"*).
- ⇒ O-B thêm `[Authorize(Policy="Contracts.Create/Update/Delete")]` **có răng ngay**, còn grant **no-op** ⇒ **403 cho 13/13 role non-Admin trên 22 endpoint ghi** (`MenuPermissionHandler.cs:41-50` khớp `p.MenuKey==req.MenuKey` + cờ tương ứng; chỉ Admin bypass `:27-31`).
- Bài học ĐÃ CÓ trong chính SC mà w8 không mang theo: **SC:271 (NOTE @S160)** *"ĐỔI nhãn/parent/CanRead của key ĐÃ TỒN TẠI phải đi đường **update-backfill kiểu `labelBackfill` đợt-5**, KHÔNG phải insert; và CẤM SQL tay"*. Thêm vết đắt: `DbInitializer.cs:2287-2296` ghi Run #423 — grant SQL tay bị revoker lật, *"447/494 row rơi, 11/13 role về 0"*.
- Acceptance `w8:32` (đếm Permission rows per-role + restart-proof) CÓ răng — nhưng chỉ bắt **SAU deploy** (`w8:21` "chạy NGAY phép 403/201 sau deploy").
- **Sửa:** bước 3 phải ghi rõ **nhánh upgrade riêng cho key non-PE** (đọc row tồn tại → set CanCreate/CanUpdate → SaveChanges), idempotent, và nghiệm thu **bằng restart THẬT**; nếu không có nhánh này thì O-B **cấm deploy**.

### F-O4 [HIGH] — Δ1 (plan §5 row 12) `All += KeHoachKyKet`: sinh policy nhưng 0 grant ⇒ #44 silent-403; đồng thời mất hạt per-leaf và nới Read thành default-OPEN
Đo đĩa (5 mệnh đề, tất cả verify được bằng lệnh trong file này):
1. `MenuKeys.cs:41` `public const string KeHoachKyKet = "KeHoachKyKet";` — **NGOÀI `All`** (mảng `All` `:163-183`, không chứa) ⇒ plan:48 nói đúng, 0 policy hiện tại.
2. Menu row root có thật: `DbInitializer.cs:1774` `(MenuKeys.KeHoachKyKet, "Kế hoạch ký kết HĐ", null, 26, ...)`; 6 leaf + G1 dùng key **`Khkk_*`** (`:1779-1786`), G1 có parent = `MenuKeys.KeHoachKyKet`.
3. Grant: `:2206` `.Concat(new[] { MenuKeys.KeHoachKyKet, MenuKeys.HopDongCung })` với comment `:2204-2205` *"2 root placeholder GĐ2/GĐ4 — **CanRead-only** mọi role (IsPeKey=false ⇒ nhánh read-only)"* ⇒ `CanCreate/Update/Delete = false`, và skip-existing chặn nâng (F-O3).
4. `MenuPermissionHandler.cs:41` `p.MenuKey == req.MenuKey` — **khớp ĐÚNG-KHOÁ, KHÔNG kế thừa root** ⇒ CanRead trên `Khkk_List`/`Khkk_Create` (13/13 role, `:2177-2180`) **KHÔNG cứu** policy `KeHoachKyKet.Create`.
5. ⇒ W2/W3 lên prod: `POST /api/contract-signing-plans` **403 im lặng cho 13/13 role**, chỉ Admin qua (`:27-31`) — đúng gotcha #44, đúng thứ dry-run cần nhất ("mọi người bấm thử").
Hai hệ quả nữa **chưa được khai** trong row 12:
- **Mất hạt:** SC §2.4 `:210-212` ratified *"+4 const `ContractSigningPlans`/`_List`/`_Create`/`_Inbox` **VÀ đưa CẢ 4 vào `All`**"*. Plan chỉ đưa **1 root** ⇒ mọi endpoint chung 1 tiền tố; 6 leaf `Khkk_*` (đã seed + granted) nằm ngoài `All` ⇒ 0 policy, quyền leaf thành **trang trí**.
- **Nới quyền đọc:** root `KeHoachKyKet` đang CanRead **mọi role** ⇒ `KeHoachKyKet.Read` = **default-OPEN** cho toàn module (đọc `ProposedAmount`/`ApprovedAmount` per-NCC), trong khi SC `:224` seed **admin-permission** = default-CLOSED. Owner-verbatim "hiển thị hết" (`STATUS.md:13`) nói về **MENU**, không nói về **quyền đọc số tiền qua API**.
- **Trả lời câu hỏi của đề bài ("Δ1 cần anh gật lại không?"):** **đổi TÊN KEY = KHÔNG cần** (hệ quả kỹ thuật của skeleton `Khkk_*` mà chính owner chốt @S159; SC:269 còn CẤM đẻ bộ key mới). **Đổi mức mặc định đọc (closed→OPEN) + bỏ hạt per-leaf = CẦN 1 dòng cho anh** — nó đổi *ai đọc được số tiền*, không phải chi tiết đặt tên.

---

## 2. MED

### F-O5 [MED] — nhãn "12 default ĐẢO ĐƯỢC, anh veto lúc nào cũng kịp" mâu thuẫn với chính §4 của plan
- plan:59 (tiêu đề §5): *"dry-run chạy bằng default **ĐẢO ĐƯỢC** — anh veto lúc nào cũng kịp"*.
- plan:55 (§4.4) tự khai: HĐ ≥ phase 5 **KHÔNG xoá được** qua API (`ContractFeatures.cs:632-633`); phiếu KHKK/PE `DaDuyet` **không xoá**; **sequence không rollback**. plan:54 (§4.3): **KHÔNG cờ IsDryRun**.
- ⇒ "đảo được" đúng cho **MÃ**, sai cho **DỮ LIỆU đã sinh trên prod**. Nặng nhất: default #2 (Q6 — giá nào vào HĐ, sai thì HĐ đã phát hành mang số sai) và #6 (format mã KHKK **đóng băng vào Mig 69** — chính SC:306-308 đã cảnh báo *"'không chặn' chỉ đúng nghĩa không-chờ-trả-lời, KHÔNG đúng nghĩa không có quyết định nào bị khoá"*).
- **Sửa:** hạ câu tiêu đề §5 xuống *"đảo được ở mã; dữ liệu/HĐ đã sinh thì không"* + đánh dấu #2/#6 là **default một-chiều**.

### F-O6 [MED] — O-3 bị THU HẸP mà không khai Δ tường minh (W6/W7 chạm Contract nhưng không có cửa hỏi)
- SC:49: *"**Wave nào chạm module Contract** phải hỏi lại anh"* (phạm vi = MỌI wave).
- plan:90 rút còn *"O-3 còn hiệu lực cho **phần SỬA service**"*; `w8:3` lặp lại y vậy ⇒ **cắt mất vế controller** và vế "mọi wave".
- Hệ quả trên bảng wave: **W6** (plan:25 "sẵn chạy") nằm TRỌN trong module Contract (view-guard + inbox + vai-trình); **W7** (plan:26 "sẵn chạy"); **W8-O-B** (plan:73 default). 3 wave chạm Contract, 0 cửa hỏi. (Điểm sáng: `w6:32` có tự rào *"đụng nhánh Reject-trước-guard = KHÔNG PHẢI wave này (O-C, cần anh gật riêng — đừng «tiện tay sửa»)"*.)
- **Suy luận công bằng:** lệnh anh @S160 (`run.md:8-15`) *"dry-run luôn cho đến hợp đồng cứng"* **có** phủ W5/W6/W7 (đó là ĐƯỜNG ĐI của dry-run). Nhưng **W8 không nằm trên đường đi** — nó là đổi hành vi bảo mật ⇒ không suy ra được từ câu đó.
- **Sửa:** plan §1 (hoặc §5 row 11) thêm 1 dòng Δ: *"O-3 :49 được owner mở @S160 cho phần **chạm-để-chạy** (W5/W6/W7); phần **chạm-để-đổi-hành-vi-bảo-mật** (W8 O-B/O-C) VẪN đóng, chờ gật."*

### F-O7 [MED] — O-B không phủ CỬA TẠO HĐ THẬT mà dry-run dùng (bridge endpoint)
- HĐ dry-run sinh qua `POST /api/purchase-evaluations/{peId}/create-contract` (`w5:17` ghi rõ FE gọi CÙNG endpoint này).
- Đĩa: endpoint ở `PurchaseEvaluationsController.cs:339`; class chỉ `[Authorize]` trần (`:15`). `grep -c "Authorize(Policy"` = 3 nhưng **2/3 là COMMENT** (`:147`, `:161` — `:161` nguyên văn *"🔴 CỐ Ý KHÔNG có `[Authorize(Policy=...)]` — owner chốt 2026-07-27..."*), attribute thật chỉ `:188` (`PurchaseEvaluations.Read`). ⇒ **bridge endpoint = 0 policy**.
- ⇒ Sau O-B: `POST /api/contracts` bị siết, nhưng cửa **tạo HĐ per-winner + ăn sequence thật** vẫn mở cho mọi user đăng nhập. `w8:7` chỉ khoanh *"(2) Create ăn sequence thật (`ContractFeatures.cs:113-118`)"* = nửa cửa. Acceptance `w8:28` `MoiEndpointGhi_CoAuthorizePolicy` (reflection trên `ContractsController`) **cũng không bắt** cửa này.
- ⚠️ Cộng hưởng: **W5 (`w5:14`) THÊM param vào chính body endpoint chưa có khoá** ⇒ mở rộng cửa trước khi khoá.
- **Sửa:** w8 §① liệt cửa thứ 3 = bridge endpoint; hoặc khai tường minh "cố ý để mở, lý do X" (kiểu comment `:161`) để không tưởng đã phủ hết.

---

## 3. LOW

### F-O8 [LOW-MED] — nhãn 2 đời: dùng ĐÚNG, nhưng file RATIFIED chưa được stamp errata
- ĐÚNG: `plan:13` + `w1:11` khai Δ tường minh; đĩa chứng `DbInitializer.cs:1774` "Kế hoạch ký kết HĐ" + `:1779` "1. Kế hoạch ký kết HĐ (NCC-TP)"; owner-verbatim `docs/STATUS.md:13` *"❷ **Kế hoạch ký kết HĐ** (đời trước «Đề xuất ký kết hợp đồng» — superseded)"* + `docs/HANDOFF.md:9` cùng nội dung. W5/W8 không dùng nhãn ⇒ 0 rủi ro.
- Tồn dư: SC **:27** vẫn ghi *"OWNER CHỐT NHÃN @S159: tên phiếu/menu = «Đề xuất ký kết hợp đồng» — **mọi label VN trong spec đọc theo tên này**"* và SC **:223** vẫn dạy seed nhãn cũ, dù **CÙNG FILE** đã được chèn NOTE @S160 tại `:268-271` ⇒ pass bảo trì **bất đối xứng**. Ai đọc §2.4 trước w1 sẽ seed sai nhãn.
- **Sửa:** stamp errata 1 dòng tại SC:27 + SC:223 (không viết lại spec — đúng luật "chỉ BỔ SUNG").

### F-O9 [LOW · CỦNG CỐ] — 5 câu owner cũ: default KHỚP trạng thái treo, KHÔNG lấn
SC:488 khai *"5 câu owner (O-Q1 · O-Q2 · Q3 · Q6 · Q11) vẫn nguyên"*; plan không đâu tuyên "đã trả lời". Từng dòng:
| plan §5 | Câu | Đối chiếu SC | Phán |
|---|---|---|---|
| #1 | Q3 | SC:364 *"W3 mặc định chỉ làm đường THƯỜNG; 2 đường kia chờ anh"* + phán "PHÁ VỠ" S155 | **KHỚP** — plan chọn bảo thủ; `w3:7` nhất quán (0 bật `AllowApproverFinalize`/`CeoApprovalThreshold`) |
| #2 | Q6 | SC:400 *"mặc định thiết kế: **KH thắng** + audit lệch"* | **KHỚP** (nhưng xem F-O5: hệ quả DỮ LIỆU không đảo được) |
| #3 | Q11 | SC:401 hỏi "chặn hay cảnh báo", không nêu default | plan chọn **mềm** = hướng đảo-được (nâng 409 sau) — hợp lý, không lấn |
| #4 | O-Q1 | SC:62 *"cảnh báo mềm (tiền lệ S62) — O-Q1 owner quyết mềm/cứng"* | **KHỚP** nghiêng sẵn của SC |
| #5 | O-Q2 | SC:391-393 | plan **DEFER W4, 0 dev** = giữ hiện trạng ⇒ không tiêu quyền nào |
⇒ 5/5 nằm TRONG biên SC đã ghi. Đây là điểm **củng cố** (cân bằng với 4 HIGH ở trên).

### F-O10 [LOW] — baseline "0 HĐ" ĐÚNG (tôi đo lại độc lập) nhưng thiếu 1 mệnh đề nhân-quả + 1 điều kiện env
- **Curl độc lập (2026-07-29, token admin 468B):** `GET /api/contracts?page=1&pageSize=1` → `{"items":[],"total":0,...}` · `GET /api/contracts/deleted?...` → `{"items":[],"total":0,...}` ⇒ **plan:43 ĐỨNG** (measured-label gate PASS: claim có phép phản chứng và phép đó đã chạy).
- Nguồn "7 HĐ V1" của sổ S156 = **demo seeder**, không phải HĐ nghiệp vụ: `docs/changelog/recently-done-archive-2026-04.md:22` *"SeedDemoContractsAsync — 7 demo HĐ varied phases ... 7 HĐ covering 7 ContractType"*. Plan gọi "STALE" đúng KẾT LUẬN nhưng thiếu vế nguyên nhân (0 ≠ "ai đó xoá 7 HĐ thật").
- Điều kiện env: `DbInitializer.cs:134` bọc `SeedDemoContractsAsync` trong `if (!demoSeedDisabled)`; path tạo-mới `:892` chạy khi **không còn** HĐ `[DEMO]` ⇒ **env không bật cờ (Dev/staging) sẽ tái sinh 7 HĐ demo** ⇒ quy ước lọc `ZZTEST` của plan §4.1 phải cộng bộ lọc `[DEMO]`.

### F-O11 [LOW] — literal SC:331 đã tự-lão-hoá (không lây sang bộ mới)
SC:331 (acceptance W2) dạy đối chứng *"`grep -c "Authorize(Policy" ContractsController.cs` = **0 hit toàn file**"*. Đĩa hôm nay = **1** (`/deleted` thêm @S159 đợt-5: `ContractsController.cs:28-29`). Bộ spec MỚI không chép literal này (grep "0 hit toàn file" trên `plan-cha`+`spec-wave-*` = 0 hit) và `w8:11` dẫn đúng tiền lệ `/deleted` ⇒ **đã tránh được**; chỉ cần errata ở SC.

---

## 4. ĐIỂM CỦNG CỐ (đo được, chống Smart-Friend một chiều)
1. **5/5 anchor §③-A của W5 HIT trên đĩa:** `CreateContractFromEvaluationFeatures.cs:53-60` (guard `Phase!=DaDuyet` + winners + `pe.ContractId`) · `:68-71` (pin V1: `WorkflowDefinitions ... IsActive`) · `:88-90` (`SUM(q.ThanhTien)` đúng 3 dòng) · `:145` (`pe.ContractId = contracts[0].Id`) · `ContractWorkflowService.cs:115-116` (`ConflictException("HĐ chưa pin workflow definition...")`).
2. **W5 (c) khớp SC:143-144** — `ContractId` nằm trên **`Lines`** (per-NCC), không phải header; `w5:16` gọi đúng "cột C6 sẵn từ W1". Không có drift cardinality.
3. **W8 §③-A anchor đúng:** `ContractsController.cs:13` `[Authorize]` class-trần · `:28-29` `[HttpGet("deleted")]`+`[Authorize(Policy="Contracts.Read")]` · `MenuKeys.cs:168` `Contracts, Forms, Reports,` (⇒ 4 policy `Contracts.*` sống thật).
4. **plan:48 đúng** — `KeHoachKyKet` có const, ngoài `All`.
5. **Q3 default ⟂ w3:7 không mâu thuẫn** (SC:362-365 tự khai xung đột và đã tự giải theo hướng plan chọn).

## 5. YÊU CẦU SỬA TRƯỚC THI CÔNG (acceptance cho lượt vá)
- **A1 (F-O1/F-O2/F-O6):** plan §5 row 11 + `w8:3` — O-B chuyển thành **"CHỜ ANH GẬT"** ngang O-C, hoặc thêm dòng Δ tường minh vs SC:49 + `STATUS.md:13` và ghi rõ quyết định thuộc owner. Bỏ cụm "O-A/O-B mặc định" (0 bit).
- **A2 (F-O3):** `w8:19` phải mô tả **nhánh upgrade grant cho key non-PE**; acceptance thêm 1 dòng: *"sau restart THẬT, `SELECT COUNT(*) FROM Permissions WHERE MenuKey='Contracts' AND CanCreate=1` ≥ số role dry-run"*.
- **A3 (F-O4):** plan §5 row 12 bổ sung: (i) bước cấp `Create/Update/Delete` cho key mới qua nhánh upgrade; (ii) khai hệ quả **Read default-OPEN** + hỏi owner 1 dòng; (iii) chốt còn giữ hạt per-leaf `Khkk_*` hay không.
- **A4 (F-O5):** sửa tiêu đề §5 + đánh dấu #2/#6 là default **một chiều**.
- **A5 (F-O7):** `w8:7` liệt cửa `POST /api/purchase-evaluations/{id}/create-contract` (hoặc khai cố-ý-để-mở).
- **A6 (F-O8/F-O11):** stamp errata SC:27 · SC:223 · SC:331 (1 dòng/chỗ, không viết lại).

<!-- END sub-reviewer-4 · 11 finding (4H/3M/4L) + 5 củng cố · verdict SUA-TRUOC-THI-CONG -->
