# sub-reviewer-3 — W7 bản cứng `Hdc_*` (1-MỐC) — ADVERSARIAL REVIEW TIỀN-COMMIT

> Vai: reviewer · Run: `2026-07-29-S161-khkk-w7-bancung` · Ngày 2026-07-29/30
> Luật: READ-only. KHÔNG sửa code-tree. Ghi TỪNG TRỤC ngay khi đo xong (chống #53).

---

## §0 — Trạng thái sống (chỉ tick khi ĐÃ ĐO)

- [x] §A Đo tiền-đề: git status + 3 sub-md
- [x] §1 Trục 1 — FE HardCopiesPage ruột
- [x] §2 Trục 2 — Hợp-đồng FE↔BE
- [x] §3 Trục 3 — Boundary O-A
- [x] §4 Trục 4 — 2 quan sát test-lane
- [x] §5 VERDICT (lead finalize on-behalf — return #53 stale hơn đĩa; tick §1-§4 do lead sau verify nội-dung từng §)

---

## §A — Tiền-đề ĐO TƯƠI (2026-07-30, trước khi soi 1 dòng nào)

### A.1 🔴 FLAG-0 (MAJOR — quy trình): sub-md của lane FE KHÔNG TỒN TẠI

Đề bài nói "đã có ... + sub FE + ...". Đo `ls`:

```
.claude/workflows/runs/2026-07-29-S161-khkk-w7-bancung/
  run.md                       1.688 B
  sub-implementer-backend-0.md 11.514 B
  sub-test-specialist-0.md     17.563 B
```

⇒ CHỈ 2 sub-md. `find .claude/workflows/runs -name "*frontend*"` cho ra W1/W2/W3 nhưng **KHÔNG có W7**.
Hệ quả review: lane FE **không có lời tự-khai nào** — mọi kết luận về FE ở dưới đều đo THẲNG TỪ ĐĨA,
không có đối-chứng-chéo của chính lane đó. Đây là ca `#53` (garble/skeleton-rỗng) hoặc lane chưa từng
ghi đĩa; lead cần biết vì **1 trong 5 nguồn bằng chứng của wave này bị khuyết**.

### A.2 git status (đo tươi)

```
 M .claude/governance/.session-counter.json      ← hook, không phải lane nào
 M fe-admin/src/App.tsx · Layout.tsx · types/contracts.ts
 M fe-user/src/App.tsx · Layout.tsx · types/contracts.ts
 M src/Backend/.../ContractsController.cs · ContractFeatures.cs · Dtos/ContractDtos.cs
?? fe-admin/src/pages/contracts/HardCopiesPage.tsx
?? fe-user/src/pages/contracts/HardCopiesPage.tsx
?? tests/SolutionErp.Infrastructure.Tests/Application/ContractHardCopyTests.cs
```

diffstat: `src/Backend` = **3 files, +31/−4** · `fe-*` = **6 files, +36/−14** · 3 file MỚI untracked.

### A.3 🔴 FLAG-1 (HIGH — nằm ở `git`, KHÔNG ở mã) — 3 file MỚI đang UNTRACKED

Lớp S155/S161 tái diễn: file **tracked-M** và file **untracked** trộn nhau trong cùng một wave.

| File untracked | Ai tham chiếu tới nó | Nếu commit thiếu |
|---|---|---|
| `fe-admin/src/pages/contracts/HardCopiesPage.tsx` | `fe-admin/src/App.tsx:23` `import { HardCopiesPage }` (đã tracked-M) | **CI FE build ĐỎ** — import trỏ file không tồn tại |
| `fe-user/src/pages/contracts/HardCopiesPage.tsx` | `fe-user/src/App.tsx:17` (đã tracked-M) | **CI FE build ĐỎ** |
| `tests/.../Application/ContractHardCopyTests.cs` | không ai — nhưng là TOÀN BỘ acceptance §③-B | suite tụt 590 → 587, mất 3 test PIN |

`git commit -a` **KHÔNG** nạp file untracked ⇒ commit sẽ chứa `import` mồ côi ở CẢ 2 app.
Gate BẮT BUỘC ngay trước commit: `git status --porcelain -- src tests fe-admin fe-user | grep '^??'` phải RỖNG
(hoặc `git add` đích danh 3 file trên). Đây là HIGH **quy trình**, không phải lỗi mã.

---

## §1 — TRỤC 1: FE `HardCopiesPage` ruột (ĐO XONG)

Nguồn: `fe-user/src/pages/contracts/HardCopiesPage.tsx` (445 dòng) — mirror `fe-admin` **byte-identical**
(`sha256 = 09c6e681…a71a9` cả 2, `diff` = rỗng). §3.9 mirror ĐẠT.

### 1.1 Upload multipart — ĐÚNG field-name endpoint thật ✅

| Bờ FE (`HardCopiesPage.tsx:264-272`) | Bờ BE (`ContractsController.cs:88-104`) | Khớp |
|---|---|---|
| `form.append('file', file)` | `IFormFile file` (`:92`) — bind theo TÊN | ✅ |
| `form.append('purpose', String(3))` | `[FromForm] AttachmentPurpose purpose = Other` (`:93`) | ✅ (`3` = `SealedCopy`, `ContractAttachment.cs:5-11`) |
| `form.append('note', …)` chỉ khi non-empty | `[FromForm] string? note = null` (`:94`) | ✅ |
| `POST /contracts/{id}/attachments` | `[HttpPost("{id:guid}/attachments")]` | ✅ |
| download `…/attachments/{aid}/**download**` | `[HttpGet("{id:guid}/attachments/{attId:guid}/download")]` (`:106`) | ✅ |

Validator bờ BE khớp UI: 20 MB (`ContractAttachmentFeatures.cs:28` `MaxBytes`) = chữ "tối đa 20 MB" (`:343`);
`RuleFor(x => x.Purpose).IsInEnum()` ⇒ `3` hợp lệ. MIME whitelist 8 loại ⊇ `accept=` của input (`:351`).
*(Ghi chú: `SEALED_COPY = 3` là hằng FE tự khai lại — KHÔNG có enum chung; đây là điểm đứt-hợp-đồng
tiềm năng nếu BE đổi số, nhưng số `3` đã ghim bằng test T3 nên có lưới.)*

### 1.2 Badge chỉ vẽ trên list `phase=9` ✅ (caveat "cờ không xét phase" ĐƯỢC CHẶN Ở PHÍA GỌI)

`:79` query **hardcode** `phase: ContractPhase.DaPhatHanh` (=9, `types/contracts.ts:10`) — người dùng
KHÔNG có cách nào đổi phase trên trang này (không ô lọc phase). ⇒ mọi hàng render đều là phase 9.
Sweep consumer: `grep -rn "hasSealedCopy" fe-*/src` = **đúng 4 hit** (2 khai type + 2 render), CẢ 2 đều
trong `HardCopiesPage`. ⇒ cờ "không xét phase" của BE **không rò ra màn nào khác**. Caveat T-ghim của
lane test là ĐÚNG ở tầng BE và ĐÃ ĐƯỢC TRUNG HOÀ ở tầng FE. Không phải lỗi.

### 1.3 UI ghi chú 1-mốc ✅
`:106-110` hộp amber: *"Ký giám đốc · đóng dấu · thủ tục lưu bộ gốc được thực hiện NGOÀI hệ thống
(trên giấy). … Có file scan là xem như xong."* — khớp literal chốt @S161 + đóng rủi ro §③-C.

### 1.4 Map 7 loại HĐ 1..7 ✅ (đối chiếu 3 nguồn, không tin 1 nguồn)

`ContractType.cs:5-11` (BE enum) ≡ `MenuKeys.cs:34-35 ContractTypeCodes` ≡ `TYPE_CODE_TO_INT`
(`Layout.tsx:19-25`) ≡ nhãn seed `DbInitializer.cs:1981-1987` "01."…"07." ≡ `ContractTypeLabel`
(`types/forms.ts:13-21`):

| int | enum BE | code | staticMap `Hdc_*` | nhãn seed :1981-1987 |
|---|---|---|---|---|
| 1 | HopDongThauPhu | ThauPhu | `/hard-copies?type=1` | 01. HĐ Thầu phụ - Bảng cứng |
| 2 | HopDongGiaoKhoan | GiaoKhoan | `?type=2` | 02. HĐ Giao khoán - Bảng cứng |
| 3 | HopDongNhaCungCap | NhaCungCap | `?type=3` | 03. HĐ Nhà cung cấp - Bảng cứng |
| 4 | HopDongDichVu | DichVu | `?type=4` | 04. HĐ Dịch vụ - Bảng cứng |
| 5 | HopDongMuaBan | MuaBan | `?type=5` | 05. HĐ Mua bán - Bảng cứng |
| 6 | HopDongNguyenTacNCC | NguyenTacNcc | `?type=6` | 06. HĐ Nguyên tắc NCC - Bảng cứng |
| 7 | HopDongNguyenTacDichVu | NguyenTacDv | `?type=7` | 07. HĐ Nguyên tắc Dịch vụ - Bảng cứng |

7/7 khớp, 0 lệch. `grep "Hdc_" Layout.tsx | grep -c "coming-soon"` = **0** ở CẢ 2 app (acceptance `:29` ĐẠT).

### 1.5 FLAG-2 (MINOR) — chú thích đầu file SAI về route của root `HopDongCung`

`HardCopiesPage.tsx:13` (cả 2 app, byte-identical) khai:
```
//   HopDongCung (root)            → /hard-copies (không query = tất cả loại)
```
ĐO trên đĩa: `fe-user/src/components/Layout.tsx:80` `HopDongCung: '/dashboard'` · `fe-admin` **KHÔNG có
entry `HopDongCung` nào** (`grep` = 0 hit ngoài chính dòng chú thích này). Vô hại về hành vi (root có 7
con ⇒ `MenuNodeRenderer` (`Layout.tsx:192-196`) render nó thành `MenuGroup`, **path không bao giờ được
dùng**), nhưng chú thích đang mô tả một lối vào KHÔNG TỒN TẠI. Nhánh `typeFilter === null` ("tất cả
loại", `:66`) hiện **không có lối vào từ UI** — chỉ tới được bằng gõ URL tay.

### 1.6 FLAG-3 (MINOR) — chú thích "ẩn hẳn khối này" trái với mã ngay dưới nó

`:406` khai *"levelOpinions null ⇒ **ẩn hẳn khối này**"*. Mã `:407-416` render khối **LUÔN LUÔN**, chỉ
đổi ruột thành câu "Hợp đồng này không có chữ ký duyệt điện tử." Hành vi thực tế (hiện placeholder) hợp
lý hơn với HĐ V1 legacy, nên **đề nghị sửa CHÚ THÍCH cho khớp mã**, không đổi mã.

### 1.7 FLAG-4 (MINOR) — `note` dùng chung khi thả NHIỀU file một lượt

`:283-286` `for (const f of Array.from(files)) upload.mutate(f)` — input có `multiple` (`:348`) và vùng
kéo-thả nhận N file. Cả N mutation cùng đọc **một** biến `note` qua closure ⇒ N file nhận CÙNG ghi chú;
`isPending` (`:353`) chỉ phản ánh mutation cuối nên chữ "Đang tải lên…" có thể tắt sớm khi file khác còn
bay. Không mất dữ liệu (mọi file vẫn POST), chỉ là UX. Ca 1-file — đường đi thực tế của "1 bộ scan" —
hoàn toàn đúng.

### 1.8 Ghi nhận cho prod-verify (không phải lỗi)
Bề mặt upload `purpose=3` **đã tồn tại từ trước** W7: `ContractAttachmentsSection.tsx:19` `PURPOSES =
[1,2,3,99]`, nhãn `3: 'Bản scan đã đóng dấu'`. ⇒ HĐ nào đã từng được đính kèm purpose=3 qua màn chi
tiết sẽ **bật badge ngay khi deploy**, chưa cần thao tác gì trên trang mới. Khi chạy acceptance
"upload → badge lật", phải chọn HĐ có **0** attachment purpose=3 để phép thử có chiều âm thật.

---

## §2 — TRỤC 2: hợp-đồng FE↔BE (ĐO XONG — 4/4 KHỚP)

Đi NGƯỢC lời khai: đọc từng bờ trên ĐĨA, không tin bảng đối-chứng của lane BE.

| # | FE gửi/đọc (file:line) | BE nhận/trả (file:line) | Verdict |
|---|---|---|---|
| 1 | `params.type = typeFilter ?? undefined` (`HardCopiesPage.tsx:80`) → query-string `type=` | `[FromQuery] ContractType? type = null` (`ContractsController.cs:25`) → `new ListContractsQuery(phase, supplierId, projectId, type)` (`:27`), vị-trí 4 khớp record `ContractFeatures.cs:285-289` | ✅ |
| 2 | `params.phase = 9` (`:79`) | `[FromQuery] ContractPhase? phase` (`:20`) — 0 dòng đụng | ✅ |
| 3 | `c.hasSealedCopy` (`:194`), type `hasSealedCopy: boolean` (`types/contracts.ts:96`/`:100`) | `bool HasSealedCopy` (`ContractDtos.cs:25`) — camelCase do `Program.cs` **không** override `PropertyNamingPolicy` (tự kiểm lại, không mượn kết luận lane BE) | ✅ |
| 4 | `params.search` (`:81`) | `[FromQuery] string? search` (`:19`) + handler CÓ áp `:329-336` | ✅ |
| 5 | `Paged<T>{items,total,totalPages}` (`types/master.ts:1-7`) | `PagedResult<T>` `Items/Total` + computed `TotalPages` (`PagedResult.cs:3-9`) — property tính toán VẪN serialize | ✅ |

**Phép đo mạnh nhất — `tsc --noEmit` từng app** (mạnh hơn grep vì bắt cả field thiếu lẫn thừa):
```
fe-admin$ npx tsc --noEmit -p tsconfig.app.json   → EXIT=0, 0 dòng lỗi
fe-user $ npx tsc --noEmit -p tsconfig.app.json   → EXIT=0, 0 dòng lỗi
```
⇒ KHÔNG tái diễn lớp `hợp-đồng-đứt-2-bờ` của S161-W1/W2. Lý do cấu trúc: wave này chỉ thêm **1** field
và **1** query-param, và cả hai đều được lane BE đo trước rồi lane FE dùng lại đúng tên.

---

## §3 — TRỤC 3: boundary O-A (ĐO XONG — 4/4 GIỮ NGUYÊN)

Phép đo = liệt kê **vét cạn** mọi file thay đổi (tracked + untracked), rồi hỏi từng ranh giới.

| Ranh giới spec §②-4 / §⑤ | Lệnh đo | Kết quả |
|---|---|---|
| attachment upload/download Contract **0-diff** | `git status --porcelain \| grep -i Attachment` | **NONE** — `ContractAttachmentFeatures.cs` không nằm trong tập thay đổi ✅ |
| **0 policy mới** | `git diff \| grep '^+.*\[Authorize'` | **NONE** ✅ |
| **0 migration** | `git status --porcelain \| grep -i migration` | **NONE** ✅ (và 0 cột/bảng mới — `HasSealedCopy` là field DTO, không phải cột) |
| **menu 142, chỉ staticMap** | `git status --porcelain \| grep -iE 'MenuKeys\|DbInitializer\|menuKeys.ts'` | **NONE** ✅ ⇒ `\|MenuKeys.All\|` không đổi ⇒ số policy (derived) không đổi ⇒ MenuItems prod **142** giữ (canonical `docs/STATUS.md:6`) |

Bổ sung — `ContractsController.cs` CÓ bị sửa nhưng chỉ **1 hunk** `@@ -20,8 +20,11 @@` nằm trọn trong
method `List`; 3 endpoint attachment (`:88`/`:106`/`:113`) **0 dòng đụng**. `[Authorize]` trần cấp class
(`:13`) giữ nguyên; policy `Contracts.Read` của `/deleted` (`:32`, có từ S159) không bị nới.

### 3.1 Ghi nhận an-ninh — vùng O-A anh ĐÃ CHỦ ĐỘNG để mở (KHÔNG phải finding mới)

Ghi lại để lead có mặt-bằng khi quyết W8, **không tính vào verdict** vì spec §②-4 chốt O-A và diff = 0:
- `DownloadContractAttachmentQueryHandler` (`ContractAttachmentFeatures.cs:115-123`) **không có rào
  quyền nào** — chỉ khớp `(AttachmentId, ContractId)`. Ai đăng nhập + biết cặp ID là tải được bản scan
  HĐ đã đóng dấu. *Giảm nhẹ:* muốn biết `attachmentId` phải qua `GET /contracts/{id}` mà endpoint đó
  **CÓ** rào (`ContractFeatures.cs:614-638`, đã gồm vế `isHardCopyActor` của W6) ⇒ W7 không mở rộng
  bề mặt lộ so với trước.
- `UploadContractAttachmentCommandHandler` (`:59-80`) cũng không có rào quyền/phase — spec §③-C khai
  thẳng "hành vi HIỆN TRẠNG". W7 **có** làm tăng khả-năng-xảy-ra trên thực tế vì nay có thêm một lối
  vào upload nổi bật (panel trang bản cứng) bên cạnh lối cũ ở màn chi tiết HĐ. Đề nghị đưa vào W8-authz
  cùng lỗ `[Authorize]` trần đã ghi ở `STATUS.md:17` mục ③.

---

## §4 — TRỤC 4: 2 quan sát của lane test (ĐO XONG)

### 4.1 (a) `+35/−4` (BE tự khai) vs `+31/−4` (test lane đo) → **`+31/−4` ĐÚNG**

Đo lại bằng **hai phương pháp độc lập**, cùng cho một kết quả:

```
git diff --numstat -- src/Backend
  4  1  ContractsController.cs
 18  2  ContractFeatures.cs
  9  1  Dtos/ContractDtos.cs
                        → cộng dọc = 31 thêm / 4 xoá

git diff -- src/Backend | grep -c '^+[^+]'  → 31
git diff -- src/Backend | grep -c '^-[^-]'  → 4
```

⇒ **lane test ĐÚNG, `sub-implementer-backend-0.md:152` SAI +4 dòng.** Không ảnh hưởng hành vi (mã trên
đĩa là mã đã build + đã test), nhưng đúng lớp *số-tự-khai thay cho số-đo*: con số `+35` không đi kèm
lệnh nào sinh ra nó, nên không có phép kiểm nào làm nó TRƯỢT. Khuyến nghị lead khi tổng hợp: lấy `+31/−4`,
và ghi errata 1 dòng trong sub-md của lane BE.

### 4.2 (b) Caveat "cờ chết 2 màn" (Đã xóa · Hộp thư luôn `false`) → **CHẤP NHẬN cho 1-mốc, KHÔNG cần +2 dòng derive**

Ba căn cứ ĐO ĐƯỢC (không phải cảm tính):

1. **Không có người tiêu thụ ⇒ không có lời nói dối hiển thị.** `grep -rn "hasSealedCopy" fe-admin/src
   fe-user/src` = **4 hit**, toàn bộ nằm ở `types/contracts.ts` (khai) + `HardCopiesPage.tsx:194` (vẽ).
   Màn "Đã xóa" và "Hộp thư" **không hề đọc** field này ⇒ hôm nay `false` không hiện ra đâu cả.
2. **Cái giá của việc derive không nhỏ như "2 dòng".** Muốn cờ sống ở 2 màn kia phải sửa 2 projection
   (`ContractFeatures.cs:466` + `:545`) và mở thêm 2 phép `EXISTS` cho `GetMyInbox` — vốn là truy vấn
   chạy mỗi lần đổ chuông. Đổi lấy 0 lợi ích hiện tại, trong một wave có chỉ-thị "1-MỐC, scope tối thiểu".
3. **Rủi ro duy nhất đã có lưới.** Nguy cơ thật là: sau này ai đó thêm cột "Bản cứng" vào lưới Hộp thư
   mà không đụng BE ⇒ hiện "Chưa có bản cứng" cho HĐ đang CÓ. Nhưng ca đó đã bị ghim bởi
   `ContractHardCopyTests.cs:387-395`, kèm thông điệp assert nói thẳng *"Đây là ranh giới scope W7 đang
   được ghim, KHÔNG phải phép đo nói rằng HĐ đó không có bản cứng"* — người sửa sau đọc đúng câu họ cần
   để biết mình đang đổi hợp đồng chứ không phải sửa test cho vừa. Ranh giới này còn được ghi lần thứ hai
   ngay tại chỗ khai field (`ContractDtos.cs:18-25`).

⇒ **Khuyến nghị: GIỮ NGUYÊN.** Nếu lead vẫn muốn derive, đó là quyết định mở-scope có chủ đích, và khi
đó phải sửa **cả 3** chỗ cùng lượt: 2 projection + 2 assert đang ghim `BeFalse` (đổi assert lúc đó là
hợp lệ vì hợp đồng đã đổi — khác hẳn ca "nới assert cho test xanh").

---

## §5 — VERDICT *(lead finalize ON-BEHALF @S162 — return sub #53 stale hơn đĩa: khai "đang viết Trục 3+4" trong khi đĩa đã có ĐỦ §1-§4 kèm evidence; đúng đường (3) `feedback_return_terminates_agent`)*

**PASS-có-điều-kiện — 0 lỗi hành-vi. Điều kiện commit = xử FLAG-1 (git add đích danh 3 file untracked).**

| Flag | Mức | Xử lý |
|---|---|---|
| FLAG-0 sub-md FE vắng | MAJOR quy-trình | GHI NHẬN — lane FE #53 từ đợt-1 (lead đã khai trong WAL); mọi kết luận FE của review này đo THẲNG TỪ ĐĨA nên không phụ thuộc lời khai lane |
| FLAG-1 3 file MỚI untracked | HIGH git | GATE commit: `git add` đích danh + porcelain-`??`-rỗng (đúng khuôn F-0 S155 đã áp W1-W6) |
| FLAG-2 comment root `HopDongCung` | MINOR | ĐÃ VÁ @S162 (comment ghi đúng: root = MenuGroup, path không dùng) — SHA-pair giữ `C7DCC20B…` |
| FLAG-3 comment "ẩn hẳn khối" | MINOR | ĐÃ VÁ @S162 (comment ghi đúng: khối vẫn hiện, ruột placeholder) — cùng SHA-pair |
| FLAG-4 note chung khi multi-file | MINOR UX | GIỮ — ca thật là 1 bộ scan/HĐ; ghi nhận cho backlog UX |
| §4.1 BE tự-khai +35 vs đo +31 | errata | ĐÃ GHI errata vào `sub-implementer-backend-0.md` @S162; số tổng hợp wave = **+31/−4** |
| §4.2 cờ-chết-2-màn | khuyến nghị | GIỮ NGUYÊN (3 căn cứ đo được; đã ghim 2 lớp test+DTO-comment) |
| §3.1 upload/download 0-rào | an-ninh W8 | KHÔNG tính verdict (O-A anh chốt để mở) — chuyển hồ sơ W8-authz |

Điểm neo chất lượng: hợp-đồng FE↔BE 4/4 + `tsc --noEmit` ×2 EXIT=0 · boundary 4/4 (attachment 0-diff, 0 policy, 0 mig, menu 142 giữ) · map 7 loại khớp 3 nguồn độc lập · badge phase-9 chặn ở phía gọi (hardcode `:79`, sweep 4-hit đúng chỗ).



