# cicd-verify — S171 KHKK 3-panel mirror Duyệt NCC (`998ea55`)

> **Lane:** cicd-monitor (READ-ONLY / propose-only) · **Spawn:** 2026-08-04
> **Nguyên tắc ghi:** khung rỗng lượt 1, **append sau MỖI phép đo** (bài học ngay trong phiên này: lane `implementer-frontend` ghi khung mà không append ⇒ chết ở lượt trả về ⇒ nhật-ký 642 B rỗng).

## 0. Đề bài PIN

| Mục | Giá trị |
|---|---|
| Commit đích | `998ea55` `[CLAUDE] Contract: KHKK 3-panel mirror Duyet NCC…` (CODE — không docs-only) |
| HEAD sau push | `bf0200b` (`[CLAUDE] Docs: …` — docs-only) |
| Range đã đẩy | `4b1905d..bf0200b` |
| Baseline Tests (STATUS.md B1) | **644 PASS** = 45 Domain + 599 Infra |
| Baseline Migrations (STATUS.md B1) | **71** — đợt này **KHÔNG có mig mới** (enum `+ApprovalAttachment=3` không đổi schema) |

**Flake đã biết (lead đo local TRƯỚC push):** Domain 45/45 · Infra **598/599 — 1 FAIL** =
`SolutionErp.Infrastructure.Tests.Application.CreatePeWorkflowDefinitionCommandHandlerTests.Create_DifferentEvaluationType_DoesNotAffect_Other`.
Bất-định đã chứng: full-suite 2→1→1, chạy riêng 0/0/0. Nghi hạ-tầng: `PeWorkflowAdminTests.cs` tạo `new SqliteDbFixture()` riêng ×6 test ⇒ flake dưới song-song xUnit.
🔴 **Việc phải chốt:** lead CHƯA đo được baseline trên HEAD sạch (`git worktree` fail vì đường-dẫn Dropbox) ⇒ "flake có TRƯỚC S171" hiện là **SUY-LUẬN**. CI runner sạch = đúng baseline đó ⇒ soi log ≥2 run TRƯỚC `998ea55`.

---

## 1. Stage 1 — Push + path-filter ✅ ĐO 19:50

```
git log -1            → bf0200bc730bc40596500b6c01058b1ec6a63f5a  [CLAUDE] Docs: va 4 diem stale…
git log origin/main..HEAD → (rỗng)  ⇒ đã push đủ, không còn commit treo
git diff --name-only 998ea55^..998ea55 → 21 file
```

**21 file của `998ea55`** — 18 FE (`fe-admin` ×9 + `fe-user` ×9: `components/khkk/*`, `components/pipeline/PipelineTreePanel.tsx`, `hooks/usePipelineStages.ts`, `pages/khkk/*`, `types/khkk.ts`) + 3 BE (`ContractSigningPlansController.cs`, `ContractSigningPlanFeatures.cs`, `ContractSigningPlanAttachment.cs`).

**Path-filter (gotcha #41) — 7-glob live trong `.gitea/workflows/deploy.yml:19-26`:**
`docs/**` · `**/*.md` · `.claude/skills/**` · `.claude/agent-memory/**` · `.claude/workflows/runs/**` · `.gitignore` · `scripts/**.md`.
⇒ `998ea55` **KHÔNG** khớp glob nào (toàn `.tsx/.ts/.cs`) ⇒ **CI PHẢI chạy**. ✅ đúng như đề bài PIN.
⇒ `bf0200b` (4 file: 2 `.claude/agents/*.md` + `docs/HANDOFF.md` + `docs/STATUS.md`) khớp `**/*.md` + `docs/**` ⇒ tự nó docs-only.

🔴 **Nhưng CI keyed theo TIP của range, không theo từng commit** (AS-6): Gitea đánh giá cả push-range, ≥1 commit có file non-ignored ⇒ toàn range build, và run mang `head_sha` = **tip = `bf0200b`**. ⇒ **KHÔNG có run riêng mang sha `998ea55`** — tra theo `998ea55` sẽ ra 0 hit và dễ kết luận nhầm "CI không chạy". Run phủ `998ea55` = run của `bf0200b`.

## 2. Stage 2 — Gitea Actions poll

**Lượt 1 — 19:50:56.** `GET /api/v1/repos/vietreport-admin/solution-erp/actions/tasks?limit=6` (anon OK, repo public):

| id | run_number | head_sha | status | created_at |
|---|---|---|---|---|
| **560** | **#447** | **`bf0200bc`** | **running** | 2026-08-04T19:49:08+07:00 |
| 559 | #446 | `4b1905d5` | success | 2026-08-01T20:41:12 |
| 558 | #445 | `ff7f6559` | cancelled | 2026-08-01T20:37:54 |
| 557 | #444 | `0bfadc20` | success | 2026-08-01T17:08:56 |
| 556 | #443 | `a2b37dcc` | success | 2026-08-01T16:12:23 |
| 555 | #442 | `628132cf` | success | 2026-08-01T13:08:26 |

⇒ Run đích = **#447 / id 560**, head `bf0200b`, **phủ `998ea55`**. Bắt đầu 19:49:08, đang chạy.
Link: `https://git.baocaogiaoduc.vn/vietreport-admin/solution-erp/actions/runs/447`

### 2b. 🔑 Chụp PRE **kịp giờ** (spawn 19:50 / deploy land ~19:55)

Đo **TRƯỚC** khi deploy đáp ⇒ có differential THẬT thay vì chỉ ảnh chụp cuối.

| Đo PRE @19:50-19:51 | Giá trị |
|---|---|
| admin bundle | `index-DLRf9TeA.js` + `index-IuRRquXK.css` |
| eoffice bundle | `index-C7TGA4Kn.js` + `index-DesmJ6-_.css` |
| `GET …/{guid0}/changelogs` (no token) | **404** |
| `POST …/{guid0}/approval-attachments` (bodyless, no token) | 411 ⚠️ *(IIS Length-Required, pre-auth — KHÔNG phân biệt được)* |
| `POST …/{guid0}/approval-attachments` (**có body** `{}`, no token) | **404** ✅ *(thoát 411, đo được thật)* |
| control ÂM `POST …/{guid0}/khong-ton-tai-xyz` (có body) | 404 |
| **control DƯƠNG** `GET /api/contract-signing-plans` (route KHKK cũ) | **401** |

🔑 4 điểm mà bảng này khoá lại:
1. Bundle PRE **trùng khít baseline #444** (`DLRf9TeA`/`C7TGA4Kn`) ⇒ prod đứng im từ 08-01, chưa có gì của S171 lên.
2. 2 route mới **404 PRE** ⇒ chưa tồn tại ⇒ nếu POST-deploy thành 401 thì đó là **wire-proof** (route mới thật sự lên sóng), mạnh hơn chỉ nhìn "CI xanh".
3. **Control DƯƠNG 401** chứng phép-đo 401-vs-404 **có răng** — không phải mọi thứ ở host này đều trả 404.
4. Bẫy **411** đã né: POST bodyless bị IIS chặn TRƯỚC routing ⇒ 411 vô nghĩa; phải gửi body mới đọc được 404/401.

## 3. Stage 3 — Test gate + phán quyết flaky vs hồi-quy ✅ ĐO 2026-08-05 (phiên nối)

**Run #447 / id 560 — `status=success`**, 19:49:08 → 19:56:33 = **7 phút 25 giây** (`conclusion=None` là bình thường với endpoint `tasks` — tin `status`, xem MEMORY Stage 2).

Log web-UI `/actions/runs/447/jobs/0/logs` = **18 666 B**, 2 dòng tổng kết (đối chiếu **TÁCH-PHẦN**, không chỉ khớp tổng):

```
12:49:38  Passed!  - Failed:     0, Passed:    45, Skipped:     0, Total:    45  — SolutionErp.Domain.Tests.dll (net10.0)   [167 ms]
12:52:51  Passed!  - Failed:     0, Passed:   599, Skipped:     0, Total:   599  — SolutionErp.Infrastructure.Tests.dll     [1 m 50 s]
```

| Vế | Baseline STATUS.md (B1) | Run #447 | Δ |
|---|---|---|---|
| Domain | 45 | **45** | 0 |
| Infrastructure | 599 | **599** | 0 |
| **Tổng** | **644** | **644** | **0** |
| Failed | 0 | **0** | 0 |
| Skipped | 0 | **0** | 0 |

Δ = 0 **đúng kỳ vọng**: `998ea55` chạm 0 file test (§4.5) ⇒ không được phép có test mới. Số khớp tách-phần cả 2 project.

**Grep phủ định trên log #447** (đếm thật, không suy):
`Failed!` = 0 · `[FAIL]` = 0 · `error CS` = 0 · `Create_DifferentEvaluationType_DoesNotAffect_Other` = **0 hit**.

### 3.1 🔴 Phán quyết flaky vs hồi-quy — GIỮ 2 VẾ TÁCH BẠCH, cấm nén thành 1 câu

- ✅ **Vế A — không có hồi-quy do S171.** Đây là vế được chứng. Điều kiện "đủ" mà §4.3 treo lại chờ #447 nay đã đo: cây **có** S171, trên runner sạch, Release, 2 step tách ⇒ **599/599 Failed 0**. Cộng với #446 (cây **không** S171 ⇒ cũng 599/599) ⇒ cặp đối-chứng khép kín, delta đúng bằng 21 file của `998ea55`. **Không FAIL nào khác flaky đã biết** ⇒ không có hồi-quy THẬT để báo động.
- ❌ **Vế B — vẫn KHÔNG chứng được "flake pre-exist".** #447 là log CI thứ **4** liên tiếp có **0 hit** tên test đó ⇒ tổng cộng **0/4 log CI từng tái hiện**. Runner tiếp tục không nổ. Đây vẫn là *vắng mặt bằng chứng*, **không** phải bằng chứng vắng mặt. Nguyên trạng §4.3/§4.4: flake chỉ nổ ở local (2 project cùng lượt + máy nhiều tiến-trình), CI xanh **không rửa sạch** được nó và cũng **không xác nhận** được nó.

⇒ **Test gate: PASS.** Nợ kỹ-thuật `PeWorkflowAdminTests.cs` `new SqliteDbFixture()` ×6 vẫn treo, chưa ai chứng được nguồn gốc bằng CI.

## 4. Stage 3b — Baseline lịch-sử: flake có TRƯỚC S171 không? ✅ ĐO 19:52-19:54

### 4.1 Cô-lập delta: run #446 XANH ⟶ run #447, ở giữa **đúng bằng** `998ea55`

```
git diff --name-only 4b1905d..bf0200b | (loại 7-glob paths-ignore)  →  21 file
```
21 file đó **trùng khít** 21 file của `998ea55`. ⇒ Run #446 (`4b1905d`) và run #447 (`bf0200b`) khác nhau **đúng một commit code**. Đây là cặp đối-chứng sạch nhất có thể có, **miễn phí** — không cần `git worktree` (thứ đã fail vì đường-dẫn Dropbox).

### 4.2 Log CI các run TRƯỚC `998ea55` (web-UI `/actions/runs/{n}/jobs/0/logs`)

| Run | head_sha | Domain | Infra | Dòng `Failed!`/`[FAIL]` | Nhắc tên test flaky |
|---|---|---|---|---|---|
| **#446** | `4b1905d` ← **base ngay trước S171** | 45/45 | **599/599** | **0** | **0** |
| #444 | `0bfadc2` | 45/45 | 599/599 | 0 | 0 |
| #443 | `a2b37dc` | 45/45 | 594/594 | 0 | 0 |

`Passed!  - Failed:     0, Passed:   599, Skipped:     0` (log #446, 18 657 B).

### 4.3 🔴 Kết luận trung thực — CI **KHÔNG** chứng minh được "flake có sẵn"

Mệnh đề lead cần chốt: *"flake này có TRƯỚC S171"*. CI trả lời được **một nửa**, và tao nói rõ nửa nào:

- ✅ **Bác được "hồi-quy tất định"**: run #446 chạy **chính cái cây tiền-S171** trên runner sạch ⇒ Infra **599/599, Failed 0**. Nếu S171 gây lỗi thì #446 (không có S171) phải xanh — nó xanh — nhưng đó là điều kiện cần, chưa đủ. Phần "đủ" nằm ở #447 (§3).
- ❌ **KHÔNG chứng được "flake pre-exist"**: **chưa run CI nào từng tái hiện** test này fail (0 hit tên test trong cả 3 log). Một flake không nổ trên runner thì log xanh **không phải bằng chứng nó có sẵn** — nó chỉ là *vắng mặt bằng chứng*. Ghi đúng như vậy, không nâng cấp thành "đã chốt".

### 4.4 Vì sao runner khó tái hiện — 3 khác biệt **đo được** (không suy đoán)

| Trục | Local (lead) | CI (`deploy.yml:64-80`) |
|---|---|---|
| Phạm vi lệnh | `dotnet test SolutionErp.slnx` (2 project **cùng lượt**) | **2 step TÁCH**: `dotnet test tests/…Domain.Tests.csproj` rồi `…Infrastructure.Tests.csproj` |
| Cấu hình | mặc định (Debug) | `--configuration Release` |
| Máy | workstation, nhiều tiến-trình agent song song | runner riêng |

Mẫu-số chung của flake vẫn giữ nguyên 2 phía: `PeWorkflowAdminTests.cs` **`new SqliteDbFixture()` × 6** (đếm `grep -c` = **6**, khớp chẩn đoán của lead) ⇒ 6 SQLite riêng dựng/huỷ dưới song-song-collection của xUnit. Chạy 2 project cùng lượt (local) làm tăng tranh-chấp CPU/IO ⇒ hợp lý là **local dễ nổ hơn CI**, và đó cũng là lý do CI xanh **không** rửa sạch được flake.

### 4.5 Trục nhân-quả: S171 có chạm được vào test đó không?

- `git diff --name-only 4b1905d..bf0200b | grep -iE 'tests?/'` ⇒ **0 file test bị chạm**.
- 3 file BE của `998ea55` đều thuộc **`ContractSigningPlans`** (KHKK); test flaky thuộc **`CreatePeWorkflowDefinitionCommandHandler`** (PE workflow) — **hai module rời**.
- File chứa flake `PeWorkflowAdminTests.cs` sửa lần cuối **2026-05-08** (`dbb0089`), tức **~3 tháng trước** S171.

⇒ Không có đường nhân-quả nào từ `998ea55` tới test đó.

### 4.6 Quét **TOÀN BỘ 446 task** lịch sử: CI đã bao giờ đỏ vì test này chưa?

API trả về **446 task** (`limit=30` nhưng server trả hết). Lọc `status != success|running`:

- **Kỷ nguyên gần (từ 2026-06-16 tới nay): DUY NHẤT 1 run `failure` = #291** (`8c8179cd`, 06-16). Còn lại chỉ là `cancelled` (7 lần — push-đè, gotcha #86).
- Các `failure` khác đều nằm ở 04-2026/05-2026 (giai đoạn dựng pipeline: #1-#15, #29-#45, #85, #108-#111, #125-#127, #196/#197, #215).

**Loại trừ #291 bằng ĐO, không bằng trí nhớ:** log #291 (9 326 B) → `grep -c` tên test flaky = **0**; Domain `Passed! - Failed: 0, Passed: 45`; nguyên nhân thật = **`error CS7036`** ⇒ **lỗi biên-dịch**, chưa từng chạy tới test Infra. Không liên quan.

⇒ **Chốt §4: trong toàn bộ lịch sử CI, `Create_DifferentEvaluationType_DoesNotAffect_Other` chưa từng làm đỏ một run nào.**

## 5. Stage 4 — Ship thật? (FE bundle byte-level ×2 app) ✅ ĐO 2026-08-05 (phiên nối)

### 5.1 Hash PRE → POST: rotate cả 2 app, và **khớp log CI tới từng byte**

`deploy.yml:111-122` build **fe-admin trước, fe-user sau** ⇒ ánh xạ 2 khối build trong log là xác định, không phải đoán.

| App | PRE (§2b, = baseline #444) | POST (live `index.html`) | Log CI #447 | `Content-Length` | Log CI (kB) |
|---|---|---|---|---|---|
| admin `.js` | `index-DLRf9TeA.js` | **`index-Ajv-MaCz.js`** | `dist/assets/index-Ajv-MaCz.js` @12:54:41 | **1 783 465** | 1,783.46 kB ✅ |
| admin `.css` | `index-IuRRquXK.css` | **`index-Cd15r15R.css`** | `dist/assets/index-Cd15r15R.css` | **86 551** | 86.55 kB ✅ |
| eoffice `.js` | `index-C7TGA4Kn.js` | **`index-YsXRkBSR.js`** | `dist/assets/index-YsXRkBSR.js` @12:55:53 | **1 701 224** | 1,701.22 kB ✅ |
| eoffice `.css` | `index-DesmJ6-_.css` | **`index-BpzM6R0V.css`** | `dist/assets/index-BpzM6R0V.css` | **92 289** | 92.28 kB ✅ |

4/4 rotate. Kích-thước live khớp con số Vite in trong log CI **tới từng byte** ⇒ file trên đĩa prod **chính là** artifact run #447 sinh ra, không phải bản nào khác.

### 5.2 `Last-Modified` nằm TRONG cửa-sổ deploy (ship-proof theo #69, không dựa hash-delta)

Run #447: 19:49:08 → **19:56:33** (+07). `Last-Modified` (UTC) = **12:54:41** (admin ×2 file) và **12:55:53** (eoffice ×2 file) = **19:54:41 / 19:55:53 +07** ⇒ đúng trong cửa-sổ, và trùng khít giây với dòng build tương ứng trong log. Không có FROZEN, không có LM cũ ⇒ loại sạch nhánh "bundle cũ kẹt".

### 5.3 Control ÂM tầng-2 — bundle CŨ đã biến mất khỏi đĩa

| Hash cũ | HTTP | `Content-Type` | Size |
|---|---|---|---|
| `admin/assets/index-DLRf9TeA.js` | 200 | **`text/html`** | **900 B** |
| `eoffice/assets/index-C7TGA4Kn.js` | 200 | **`text/html`** | **876 B** |

200 ở đây là **bẫy SPA-fallback**, đã né đúng cách: discriminator là `Content-Type` chứ không phải mã HTTP. `text/html` ~876-900 B ⇒ file cũ **không còn trên đĩa**, IIS rewrite `/*`→`index.html` ⇒ chứng minh **ghi đè thật**, không phải cộng thêm.

### 5.4 Byte-marker (gotcha #77 — phân biệt deploy vs cache), có control 2 chiều

Marker chọn theo luật "0-hit PRE phải chứng được", đo bằng `git grep -F` trên **cả 2 cây** rồi python `str.find` trên bundle tải về (không `grep -P` — chết locale Git-Bash):

| Marker | `998ea55^` (admin/user) | `998ea55` (admin/user) | Bundle admin | Bundle eoffice |
|---|---|---|---|---|
| **M1** `Bước này chưa cấu hình cấp duyệt.` | **0 / 0** | 1 / 1 | **1** ✅ | **1** ✅ |
| **CTRL-DƯƠNG** `Chưa khai căn cứ nào.` (có TRƯỚC S171) | 1 / 1 | 1 / 1 | **1** ✅ | **1** ✅ |
| **CTRL-ÂM** chuỗi bịa | — | — | **0** ✅ | **0** ✅ |
| M3 `Bước không gắn phòng.` | 0 / 0 | 1 / 1 | 0 ⚠️ | 0 ⚠️ |

Ngữ cảnh trích thật quanh M1 (giống hệt 2 app, minify ra **backtick**):
```
text-[11px] italic text-slate-400`,children:`Bước này chưa cấu hình cấp duyệt.`}),e.levels.ma…
```

- **M1 = wire-proof mã FE mới:** vắng mặt ở cây tiền-commit ⇒ nó **chỉ có thể** tới từ `998ea55`.
- **CTRL-DƯƠNG** khoá tiền-đề "tiếng Việt đọc được trong bundle" ⇒ 0-hit của M3 không thể đổ cho unicode-escape.
- ⚠️ **M3 0-hit là ĐÚNG LUẬT, không phải thiếu ship:** M3 nằm trong **comment** `//` tại `fe-admin|fe-user/src/types/khkk.ts:288` ⇒ Vite strip. Ghi lại để không ai đọc nhầm thành cờ đỏ (đúng cơ-chế đã gặp @S168).

⇒ **Stage 4: PASS** — 4 chân độc lập (hash rotate · size khớp-byte với log CI · LM trong cửa-sổ · marker mới có mặt + control 2 chiều) + control-âm tầng-2.

## 6. Stage 4b — Smoke prod + 2 endpoint MỚI (gotcha #82) ✅ ĐO 2026-08-05 (phiên nối)

### 6.1 Negative-control 2 endpoint MỚI: PRE 404 → POST **401** (cả hai)

Attribute thật trong `ContractSigningPlansController.cs` (đọc từ mã, không suy từ cây menu — luật gotcha #85):
`[HttpGet("{id:guid}/changelogs")]` + `[Authorize(Policy = "KeHoachKyKet.Read")]` · `[HttpPost("{id:guid}/approval-attachments")]` + `[Authorize(Policy = "KeHoachKyKet.Update")]` — **policy per-method, đúng key của endpoint**, không OR key con.

| Probe (không token) | PRE §2b | POST | Ý nghĩa |
|---|---|---|---|
| `GET …/{guid0}/changelogs` | 404 | **401** ✅ | route MỚI đã lên sóng + authz gác |
| `POST …/{guid0}/approval-attachments` (json `{}`) | 404 | 415 ⚠️ | **pre-auth**, xem 6.2 |
| `POST …/{guid0}/approval-attachments` (**multipart**) | — | **401** ✅ | chạm được authz ⇒ 401 thật |
| **CTRL-ÂM** `POST …/{guid0}/khong-ton-tai-xyz` (multipart) | 404 | **404** ✅ | 401 KHÔNG phải phản-xạ chung của host |

🔴 **Cả 2 đều 401 — không 200 (không lỗ hổng authz), không 500 (không hỏng deploy).** Đúng yêu-cầu negative-control.

### 6.2 Bẫy 415 (họ hàng bẫy 411 ở §2b) — ghi lại để lần sau khỏi mất công

`POST` với `Content-Type: application/json` trả **415**, KHÔNG phải 401. Nguyên nhân: endpoint nhận **multipart** (upload file) ⇒ mismatch bị loại ở tầng **action-selection**, tức **TRƯỚC** authorization filter — y hệt cơ-chế 411 của IIS. 415 chỉ chứng "route tồn tại", **không** chứng authz. Muốn đọc được authz phải gửi **đúng multipart**. Sau khi sửa: **401**. ⇒ Luật: mã lỗi pre-auth (411/415) là *vô nghĩa cho câu hỏi authz*, phải re-probe đúng dạng.

### 6.3 🔑 Wire-proof MẠNH NHẤT — thông-điệp-lỗi **gọi tên field** (mạnh hơn 401)

`POST …/{guid0}/approval-attachments` + **bearer admin** + multipart ⇒ **400** với thân:

```json
{"status":400,"errors":{
  "ContractSigningPlanId":["'Contract Signing Plan Id' must not be empty."],
  "ContentType":["Định dạng file không được hỗ trợ. Cho phép: pdf, doc(x), xls(x), png, jpg, webp."]}}
```

Chuỗi này chứng end-to-end **Api → Application**: qua routing, **qua authz**, model-binding đọc được file multipart, và **validator MỚI của command MỚI đã chạy** (nó gọi đúng 2 tên field và bung whitelist đuôi file). 401 chỉ chứng route tồn tại; 400-liệt-kê-field chứng **binary mới đang phục vụ** — mạnh hơn cả đo `w3wp` StartTime.

Đối xứng cho endpoint kia: `GET …/{guid0}/changelogs` + bearer admin ⇒ **404** (phiếu GUID-toàn-0 không có thật) — cùng route mà **401 khi vắng token / 404 khi có token** ⇒ 401 kia đúng là **authz**, không phải routing.

### 6.4 Smoke prod (bearer admin) — **8/8 xanh**

| Endpoint | Mã |
|---|---|
| `GET /health/ready` | 200 |
| `GET /api/contract-signing-plans` (KHKK) | 200 |
| `GET /api/contracts` | 200 |
| `GET /api/purchase-evaluations` | 200 |
| `GET /api/menus` | 200 |
| `GET /api/suppliers` | 200 |
| `GET /api/catalogs/contract-catalog` | 200 |
| `POST /hubs/notifications/negotiate` (có body) | **200** + `connectionId` |

*(SignalR: bodyless trả **411** — lại đúng bẫy IIS Length-Required ở §2b; gửi kèm body thì 200 ⇒ WebSocket/negotiate lành, gotcha #25 không tái phát.)*

### 6.5 ✅ ĐÃ ĐÓNG — chân non-admin (gotcha #44) — lead đo 2026-08-05, phiên nối S172

🧊 **Nấc cũ (giữ làm vết):** `nv.test@solutions.com.vn` / `TestUser@123456` ⇒ **401** ×2 ⇒ kết luận *"credential UAT chết trên prod, chân này KHÔNG ĐO ĐƯỢC"*. **Kết luận đó SAI** — không phải credential chết, mà là **tra nhầm sổ**: `nv.test@` là account đời cũ. Bộ user test đang sống nằm ở [`docs/HANDOFF.md`](../../../docs/HANDOFF.md) slot **(64)**: `test.drafter@` · `test.approver@`, password chung `Test@12345678` (đổi theo policy prod ≥12 ký tự). ⇒ **Bài: "KHÔNG ĐO ĐƯỢC" phải kèm câu hỏi "đã tra hết sổ có sẵn chưa?"** — cùng lớp `feedback_mechanism_right_data_absent` (cơ-chế đúng, dữ-liệu tưởng không có nhưng thật ra có, chỉ nằm chỗ khác).

Login **200/200** cả hai. Probe bằng bearer NON-ADMIN (multipart đúng dạng theo bẫy §6.2):

| Probe (bearer non-admin) | `test.drafter@` | `test.approver@` | Ý nghĩa |
|---|---|---|---|
| `GET /api/contract-signing-plans` | **200** | **200** | qua `KeHoachKyKet.Read` |
| `GET …/{guid0}/changelogs` | **404** | **404** | **404 chứ KHÔNG 403** ⇒ qua authz, chỉ là phiếu GUID-0 không có thật |
| `POST …/{guid0}/approval-attachments` (multipart pdf) | **400** | **400** | qua `KeHoachKyKet.Update` **VÀ** validator MỚI chạy — thân lỗi gọi đúng tên field `ContractSigningPlanId` |
| **CTRL-ÂM** `POST …/{guid0}/khong-ton-tai-xyz` | **404** | **404** | 400/404 KHÔNG phải phản-xạ chung của host |

🔴 **Kết luận — phạm-vi ĐÚNG BẰNG MẪU ĐÃ ĐO (siết @S172 sau `ring2-audit` ESCALATE-1):** **2 tổ-hợp vai đã đo** — `Drafter+Procurement` (`test.drafter@`) và `CostControl+DeptManager` (`test.approver@`) — **có đủ** `KeHoachKyKet.Read`+`Update` (4/4 probe, CTRL-ÂM 404). 🔴 **CHƯA LOẠI** nhánh *"một vai thường KHÁC (vd `Employee` trần) vẫn 403"* ⇒ **#85 không tái phát TRÊN 2 TỔ-HỢP NÀY**, **chưa phải kết-luận toàn-cục**.

🧊 **Bản S172 đầu viết:** *"role thường **có đủ** … ⇒ #85 KHÔNG tái phát"* + *"chứng wire cho đúng role người dùng thật"*. **Cả hai vế đều rộng hơn mẫu** — mẫu là **2 tài-khoản**, và `HANDOFF` slot (64) khai **0/2 là user trần** (cả hai mang vai cấp riêng; `test.approver@` còn *"KHÔNG nằm trong trạm nào"* nên **không** đại-diện được "người dùng thật"). Giữ vết vì đây là ca mẫu của class **suy-luận vượt mẫu** — thứ enum ĐÓNG hiện **chưa có ô** để đếm (đề-nghị owner mở `view-claim-broader-than-sample`).

⚠️ **Câu hỏi MỚI đẻ ra từ phép đo này (KHÔNG phải bug — là quyết-định thiết-kế, để owner phán):** `test.drafter@` (Drafter+Procurement) **cũng** qua `KeHoachKyKet.Update` ⇒ người **soạn** phiếu upload được **file-khi-duyệt**. Spec 3-QĐ-owner đặt upload ở bước **duyệt**. Nếu chủ-ý là chỉ người duyệt mới upload thì policy đang **rộng hơn** ý-định. Đo được, chưa phán — xem WAL mục chờ-anh.

## 7. Stage 5 — Migration (kỳ vọng: KHÔNG có mig mới) ✅ ĐO 2026-08-05 (phiên nối)

**NO-Mig proof = 2 vế, cả 2 đều đo:**

1. `git diff --name-only 998ea55^..998ea55 -- '*Migrations*'` ⇒ **0 file**. Commit không đẻ mig — khớp đề bài PIN (enum `+ApprovalAttachment=3` là int, không đổi schema).
2. Lịch sử prod **không nhích**: vẫn 71, top vẫn `20260731085624_…` (của S164/S165), không có entry nào mang dấu 04-08.

| Phép đo | Repo | Prod (`ssh vietreport-vps` + `sqlcmd -E`) | Khớp |
|---|---|---|---|
| Số migration | **71** | **71** | ✅ |
| Mig mới nhất | `20260731085624_AddKhkkApprovalGroupCatalogAndFinalizeRuntime` | *(y hệt)* | ✅ |
| `sys.tables` (`is_ms_shipped=0`) | — | **97** | ✅ = STATUS.md B1 |

**Mạnh hơn khớp-tổng — set-diff 2 CHIỀU** (`comm -23` / `comm -13` trên danh sách đầy đủ; đếm repo neo-đuôi `grep -vE 'Designer\.cs$|ModelSnapshot\.cs$'` để không ăn nhầm `…Designer.cs`):

```
repo \ prod  →  0 dòng
prod \ repo  →  0 dòng
```

Cả hai rỗng ⇒ **hai tập trùng khít**, không phải "tình cờ bằng số lượng". Baseline Mig 71 + tables 97 giữ nguyên sau đợt này.

*(Chân "pool đã recycle & phục vụ binary mới" không cần đo riêng: §6.3 đã chứng bằng validator MỚI chạy thật trên prod — mạnh hơn `w3wp` StartTime.)*

## 8. Verdict ✅ 2026-08-05

# **PASS** — 🔄 **7/7 chân xanh** (cập-nhật S172: chân phụ non-admin ĐÃ ĐÓNG, xem §6.5)

| # | Chân | Kết quả | Bằng chứng gọn |
|---|---|---|---|
| 1 | Push + path-filter | ✅ | 21 file code, 0 khớp 7-glob; run keyed theo tip `bf0200b` (AS-6) |
| 2 | CI run | ✅ | #447 / id 560 `status=success`, 19:49:08→19:56:33 (**7m25s**) |
| 3 | Test gate | ✅ | **45/45 + 599/599 = 644**, Failed 0, Skipped 0 — khớp B1 **tách-phần**, Δ0 đúng kỳ vọng |
| 4 | Ship FE ×2 app | ✅ | 4/4 hash rotate · size khớp log CI **tới byte** · LM trong cửa-sổ · marker M1 mới có mặt ×2 + control 2 chiều · hash cũ rơi `text/html` |
| 5 | Endpoint MỚI (negative-control) | ✅ | cả 2 = **401** (không 200, không 500); +400-gọi-tên-field khi có bearer = wire-proof end-to-end |
| 6 | Migration | ✅ | repo 71 = prod 71, set-diff **0/0 hai chiều**, tables 97, commit chạm 0 file mig |
| — | Smoke prod | ✅ | **8/8** 200 (kèm SignalR negotiate 200 + connectionId) |
| 7 | Non-admin (gotcha #44/#85) — **phạm-vi = 2 tổ-hợp vai** | ✅ 🔄 **ĐÓNG @S172** (siết phạm-vi cùng ngày) | `Drafter+Procurement` + `CostControl+DeptManager`: login 200 · list **200** · changelogs **404 (không 403)** · attach **400 gọi tên field** · CTRL-ÂM 404 ⇒ **#85 không tái phát TRÊN 2 TỔ-HỢP NÀY**; 🔴 **chưa loại** nhánh vai thường khác (vd `Employee` trần) vẫn 403 |

### Việc cần em main xử (theo thứ tự)

1. ✅ ~~Cấp lại tài-khoản non-admin để đóng chân #44~~ — **XONG @S172**: không cần cấp mới, account đang sống nằm ở HANDOFF slot (64); nấc cũ tra nhầm `nv.test@` đời cũ. Xem §6.5.
2. **Flake `Create_DifferentEvaluationType_DoesNotAffect_Other` vẫn treo** — 0/4 log CI tái hiện. Đừng nén "CI xanh" thành "flake đã hết"; muốn chốt thì phải tấn công `PeWorkflowAdminTests.cs` (`new SqliteDbFixture()` ×6) chứ CI không trả lời hộ được. **Đây nay là việc treo DUY NHẤT của đợt.**
3. ⚠️ **MỚI (đẻ từ §6.5, chờ owner phán):** `test.drafter@` cũng qua `KeHoachKyKet.Update` ⇒ người soạn upload được file-khi-duyệt. Policy có thể rộng hơn ý-định spec. Đo được, chưa phán.
4. Không có gì để rollback. Prod khoẻ.

---

**END cicd-verify `998ea55` — VERDICT=PASS (🔄 S172: 7/7 chân xanh; chân non-admin đóng bằng đo, không còn "KHÔNG ĐO ĐƯỢC").**
