Compare commits

...

34 Commits

Author SHA1 Message Date
b1bae77c9c [CLAUDE] Workflow: sua quy trinh duyet TAI CHO + co F6 AllowApproverDelete (dot 1)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m31s
Nen cho tinh nang "xoa phieu PE o man duyet" (UAT: phieu bam sai goi thau dang
an luy ke). Dot 1 lam NEN, dot 2 moi lam nut xoa.

Vi sao phai lam nen truoc: phieu dang chay pin ApprovalWorkflowId va doc workflow
SONG theo Id do. Moi POST tao workflow moi = Id MOI, nen tick co tren ban moi
KHONG BAO GIO toi duoc phieu dang treo. Phai co lenh sua-tai-cho giu nguyen Id.

- Domain: ApprovalWorkflowLevel + AllowApproverDelete (F6, per-NGUOI) + Mig
  20260727033522_AddPeAllowApproverDelete (AddColumn bit default false, no new table)
- Application: UpdateAwDefinitionCommand + Validator + Handler
  - 2 phep thu: (T1) khung khong doi - Order tung row bat bien
                (T2) luat choi khong doi - cam CeoApprovalThreshold, AllowApproverFinalize,
                     Code, ApplicableType => 409 bao tao ban moi
  - diff theo Level.Id (CAM delete-then-insert: no FK Restrict + mo coi LevelOpinion)
  - guard 2 tang chu ky: tang 1 chu ky tren ho so CON SONG; tang 2 chu ky CON LUU
    (cha xoa mem hoac opinion xoa mem) - ca hai deu CHAN, khong xoa cung
  - purge chi khi ho so cha KHONG CON ROW; boc transaction (ExecuteDelete tu commit)
  - KHONG tang Version, KHONG dung IsActive, KHONG doi Id
- Api: PUT /api/approval-workflows-v2/{id} + [Authorize(Policy="Workflows.Create")]
- Infra: message 403 in TEN NGUOI thay GUID tran
- FE: Designer tach "Sua tai cho" (PUT) vs "Tao phien ban moi" (POST) + o tick F6
- Tests: +21 (551 tong, 0 fail) - gom F12 chung minh phieu dang chay nhan thay doi ngay

Review: 14 FLAG, 3 HIGH da va (ExecuteDelete ngoai transaction / purge xoa cung
chu ky phieu xoa mem / endpoint khong co day FE goi toi).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 12:36:59 +07:00
d745fec445 wal: flush 20260727T1224 2026-07-27 12:24:07 +07:00
e48a6c5068 wal: flush 20260727T1211 2026-07-27 12:11:59 +07:00
bc17dba547 wal: flush 20260727T1155 2026-07-27 11:55:41 +07:00
59e2cc0e43 wal: flush 20260727T1135 2026-07-27 11:35:45 +07:00
acc6c6ccc1 wal: flush 20260727T1126 2026-07-27 11:26:00 +07:00
1aeb94d9b1 wal: flush 20260727T1108 2026-07-27 11:08:33 +07:00
9c0ac6884d wal: flush 20260727T1050 2026-07-27 10:50:36 +07:00
8c5b3717df wal: flush 20260727T1030 2026-07-27 10:30:24 +07:00
b0cdd02468 wal: flush 20260727T0959 2026-07-27 09:59:49 +07:00
e981d34fdc wal: flush 20260727T0945 2026-07-27 09:45:33 +07:00
dd674a6d47 wal: flush 20260727T0939 2026-07-27 09:39:31 +07:00
d25e21ba7b wal: flush 20260727T0914 2026-07-27 09:14:17 +07:00
06ab87f67e wal: flush 20260727T0909 2026-07-27 09:09:59 +07:00
0db0c6a3ee wal: flush 20260727T0903 2026-07-27 09:03:41 +07:00
a3a6d66cff wal: flush 20260727T0853 2026-07-27 08:53:42 +07:00
c41af1cf3f wal: flush 20260727T0846 2026-07-27 08:46:29 +07:00
8d4075aa73 wal: pause 2026-07-27 01:01:29 +07:00
5197ce2f1b wal: flush 20260726T1942 2026-07-26 19:42:22 +07:00
b5945a66d4 wal: flush 20260726T1938 2026-07-26 19:38:22 +07:00
82a0457e6f wal: flush 20260726T1928 2026-07-26 19:28:36 +07:00
8776ca8f8a wal: flush 20260726T1912 2026-07-26 19:12:14 +07:00
ed99b04bf7 wal: flush 20260726T1905 2026-07-26 19:05:09 +07:00
976df03460 wal: flush 20260726T1848 2026-07-26 18:48:02 +07:00
76580d7b1f wal: flush 20260726T1842 2026-07-26 18:42:20 +07:00
b44afe460a wal: flush 20260726T1837 2026-07-26 18:37:44 +07:00
a32d75c715 wal: flush 20260726T1831 2026-07-26 18:31:56 +07:00
ab06b09335 wal: flush 20260726T1829 2026-07-26 18:29:03 +07:00
7447d2c4be wal: flush 20260726T1822 2026-07-26 18:22:44 +07:00
632ffc44cd wal: flush 20260726T1817 2026-07-26 18:17:48 +07:00
2af380816e wal: flush 20260726T1809 2026-07-26 18:09:42 +07:00
256f724cdc wal: flush 20260726T1807 2026-07-26 18:07:03 +07:00
035daeca95 wal: flush 20260726T1802 2026-07-26 18:02:51 +07:00
48f3fef502 wal: flush 20260726T1801 2026-07-26 18:01:00 +07:00
52 changed files with 14229 additions and 75 deletions

View File

@ -1,11 +1,66 @@
# WAL — auto-generated, không sửa tay
updated: — | session: — | branch:
updated: 2026-07-27T01:01+07:00 | session: S155 (phiên-LOGIC L7, window 2) | branch: main
goal: (trống — không có mạch dở)
goal: [A-product, ĐANG CHẠY] PE nút XÓA phiếu ở màn DUYỆT — pipeline anh lệnh: 2 Invest → file-chi-tiết+checklist → review spec → hmw Opus 5 MAX. || [B-governance, TREO] 22 FLAG/finding chưa disposition — CHẶN bởi (42)(43)(44). Hai mạch SONG SONG, A không đóng thay B.
chain:
(rỗng)
[x] Sàn-3 sạch 5/5 + WAL trống — `git log --format='%s' origin/main..HEAD | grep -v '^wal:'` = rỗng
[x] scaffold session-7 — `ls .claude/sessions/session-7/` (5 file: _context/_mind/_pause-1/_snapshot-1)
[x] registry-probe 2/2 ALIVE (ctx-curator, ctx-verifier) — mẫu-số hẹn 3, ctx-audit dùng spawn THẬT thay probe (KHAI)
[x] tick counter 27→28 squash-benign — `.claude/governance/.session-counter.json`
[x] test 532 PASS (45D+487I) — `dotnet test SolutionErp.slnx --nologo -v minimal`
[x] V5 detector 43 + INFORM 4 · selfimprove GAPS none · V4 shard-probe IM (15/15)
[x] bookend @open 6 vai — run-folder `runs/2026-07-26-S154-bookend-open/`, 6 sub + synthesis
[x] trio AUTO 3 nấc — eval MIXED-12 / refine 3A-8B-3E / audit 68Đ-8T-78 — `trio-synthesis.md`
[x] Phase 3.5 — `_mind-s-7.md` MIND-0+MIND-1, `mind-check --session 7` dat=10/0 exit 0
[x] ctx-audit @open DAT-12điểm (4 FLAG, cả 4 nhắm lead, nhận 2/4) + ctx-curator @pause SUA-5điểm (nhận 5/5)
[!] disposition 22 FLAG/finding còn sống (23 raw 1 bác F-3) — CHƯA vá gì, chờ (42)(43)(44)
[ ] bump `docs/STATUS.md:6` counter → **29** @close (⚠️ reconcile @tiep S155: bản cũ ghi "27→28" — tick S155 đã đẩy 28→29, đọc LIVE `.session-counter.json` lúc close, ĐỪNG chép số này)
[ ] ghi 2 datum vào auto-memory @close (#53 ổn-định-theo-vai · row-ngoài-cross-check drift)
[ ] 3 ESCALATE trio: E1 → cửa lead · E2 → cửa owner · E3 N1+N2@S152 chưa land
[ ] `measured{}` thiếu 6 row (3 ctx + 3 ring) — phủ 17/23 = 73,9%
[ ] @MIND-2 (block kế) mục C — đính chính 2 số bất-biến trong `_context-s-7.md` (FLOW CẤM sửa tại chỗ): `:62` "counter 27→28" nay là **29** · `:67` "12 file" nay là **13** (máy-derive + đĩa đều 13)
[ ] @MIND-2 mục D — xử 3 nhãn ctx-verifier nêu: "Phép thử tự-soi H24" đã định-đoạt ⇒ RỜI D sang con-trỏ · "Guard post-wave assertion" {gần-chốt} nhưng VÔ-GIA-CƯ (0 dòng bản-đồ) ⇒ +1 dòng chain · "Bắt con-đo khai mẫu-số" có ca độc-lập thứ 4 (10→9 của chính lead @S155), 0 ca ngược ⇒ đề {gần-chốt}
next: (trống)
[x] A-owner-chốt 4 ý: (1) trưởng phòng xóa khi đến lượt (2) xóa MỀM (3) phiếu đang-duyệt VẪN xóa được, xóa→mục "Đã xóa"+hết ăn lũy kế (4) 🆕 MENU: Duyệt→"Đang duyệt" +"Đã duyệt" +"Đã xóa" — verbatim ở `run.md` §OWNER CHỐT
[!] wf: A-product PE-delete-approver — run `.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/` · 3 Invest chạy nền: be-1 (Q4-Q7 resume) · fe-2 (Q3/Q5/Q6 resume) · menu-3 (MỚI: menu+Đã-xóa+trưởng-phòng)
[!] 🔴 A-#53 ×2: be-1 + fe-2 CẢ HAI garble return (trả lát-cắt transcript). Đĩa cứu TRỌN: be-1 14.528B Q1-Q3 · fe-2 8.029B Q1/Q2/Q4 ⇒ ghi-đĩa-trong-lúc-làm CHỨNG MINH lần nữa. Đã SendMessage-resume cả 2
[x] A-S2 spec v1 XONG — `spec-pe-delete-approver.md` (4 hạng mục A-E + checklist 28 mục + 4 nợ khai N1-N4)
[!] A-yêu-cầu-(7) MỚI anh giao giữa chừng: quy trình duyệt cho **sửa TẠI CHỖ** (thêm người / chỉnh quyền) thay vì ép tạo version mới; chỉ đổi CẤU TRÚC mới bắt buộc version mới → Invest #4 `sub-invest-wfver-4.md` đang chạy
[ ] ⚠️ mâu-thuẫn CẦN GIẢI ở (7): nếu "nhiều người 1 Cấp" = nhiều bản ghi Level cùng Order thì "thêm người" CHÍNH LÀ "thêm Level" ⇒ ranh AN-TOÀN/PHÁ-VỠ của anh có thể tự mâu thuẫn ở tầng dữ liệu. W4 của Invest #4 phải trả lời
[x] A-S2-bis spec PHẦN II (hạng mục F) + S3 reviewer `PASS-WITH-FLAGS 17 FLAG (4H/8M/5L)`; F.0 KHÔNG bác được sau 6 đường tấn công; lead vá 4H+8M vào spec
[x] A-owner lượt 3: (8) TÁCH 2 quyền xóa · (9) F6 PER-NGƯỜI · (10) F5 = PHÁ VỠ · (11) ngưỡng CEO = PHÁ VỠ · (12) Step.DepartmentId = AN TOÀN · (13) CHIA 2 ĐỢT
[x] A-S4-đợt1 wave-1 XONG + lead-vá — build 0W/0E · **test 532 PASS** (45D+487I). Landed: F6 entity+Mig `20260727033522_AddPeAllowApproverDelete` 3-file · F-10 comment · F-1..F-4 UpdateAwDefinitionCommand (T1 SIẾT HƠN spec: khoá Order từng row, bắt cả ca hoán vị #11) · F-5a loại-trừ opinion PE-đã-xoá **phủ PE+Contract+Proposal** · F-11 FE 5/5 dây (kể cả bẫy `:605`) build ×2 PASS
[!] 🔴 A-SỰ-CỐ wave-1: sub implementer-backend **MẤT CẢ 2 KÊNH** — return không gọi StructuredOutput + diary `sub-implementer-backend-1.md` **0-byte rồi BIẾN MẤT**. Đây là `skeleton-ruột-rỗng` ở mức nặng nhất từng gặp: ghi-đĩa-trong-lúc-làm KHÔNG cứu được. Recover bằng lane ĐĨA-TRUTH (git diff + grep + build + test). Datum cho memory @close
[x] A-lead-vá 2 lỗ sub BE bỏ sót (chỉ lộ ra vì em-main tự đối chiếu diff, KHÔNG có lời khai nào): **(1) THIẾU HẲN controller endpoint** ⇒ UpdateAwDefinitionCommand là code CHẾT, không ai gọi được + kéo theo F-1a authz cũng vắng → lead thêm `[HttpPut("{id:guid}")]` + `[Authorize(Policy="Workflows.Create")]` · **(2) F-6 chưa làm** — `:705` vẫn `string.Join` trên GUID dù biến đã tên `names` → lead resolve sang FullName qua userManager, fallback GUID
[!] wf: A-S4-đợt1 wave-2 hmw — run-id engine `wf_a5ee3ecb-a19` · 2 task: test-specialist (F-1b + F-12..F-16, F-15 CHỈ nửa-trước vì nút xóa thuộc đợt 2) · reviewer (soi diff TRƯỚC deploy, ưu tiên 2 mục LEAD tự viết chưa ai soi: endpoint PUT + F-6 userManager)
[!] 🔴 A-DEPLOY **HOÃN — GATE ĐÓNG**. Anh lệnh deploy, em KHÔNG đẩy vì test ĐỎ. Đo thật: `dotnet test` = **6 FAIL / 505** (Domain 45 PASS · Infra 499P/6F; baseline cũ 532 ⇒ +18 test mới, 6 đỏ). Fail: **F12** (bài kiểm chứng TRUNG TÂM §F.0!) · F14 · F14_F5a · F1c · F16 · ZZ_DIAG_AddLevel (scaffold chẩn-đoán sub bỏ quên, KHÔNG phải test thật — phải gỡ)
[!] 🔴 A-wave2 reviewer **KHÔNG SINH GÌ**`reviewer-diff-dot1.md` không tồn tại, return rỗng ⇒ **diff đợt 1 CHƯA HỀ ĐƯỢC SOI**, kể cả 2 mục lead tự viết. Phải chạy lại
[!] wf: A-wave3 hmw VÁ 6 ĐỎ — run-id engine `wf_4ea6451f-648` · 1 task implementer-backend. 2 LỚP lỗi lead đã phân loại: **A** `association severed` (F14, F14_F5a) — `ExecuteDelete` xoá DB nhưng KHÔNG đụng change-tracker ⇒ opinion còn trong RAM ⇒ `Remove(level)` ném · **B** `affected 0 rows` (F12, F1c, F16) — nghi `AsNoTracking` + entity MỚI bị đánh dấu `Modified` thay `Added` (chứng: ZZ_DIAG in `ApprovalWorkflowLevel/Modified`). +gỡ ZZ_DIAG scaffold
[x] A-wave3 XONG — **test XANH 0 FAIL / 549** (45 Domain + 504 Infra; 5051 do gỡ ZZ_DIAG scaffold). Lead tự đọc lại F14: assertion **KHÔNG bị nới** (ConflictException + message "chữ ký" + DB nguyên vẹn + khối CHỨNG-NHÂN tự Remove để chứng FK Restrict là thật) ⇒ KHÔNG xanh-giả. Root cause đúng 2 lớp lead đoán: **B** = thêm `db.ApprovalWorkflowLevels.Add(newLevel)` TƯỜNG MINH (EF đoán nhầm Added→Modified) · **A** = helper detach chữ ký khỏi ChangeTracker trước `Remove(level)`, quét theo TÊN CỘT ⇒ phủ 7 bảng chữ ký/6 module
[x] A-wave4 reviewer XONG-PHẦN-1 (F1→F7, 20.906B; file kết `WIP` ⇒ thiếu 4 trục + CHƯA có dòng deploy) — **3 HIGH**: F1 `ExecuteDelete` ngoài transaction, 4 nguồn ném nằm giữa ⇒ mất chữ ký không hoàn tác (đúng nghi ngờ lead) · F2 purge xoá CỨNG chữ ký của phiếu chỉ XOÁ MỀM + của opinion-soft trên phiếu SỐNG (7 bảng) · **F3 endpoint PUT 0 dây FE gọi tới** (lead verify `api.put`=0 hit) ⇒ ~600 dòng BE là MÃ CHẾT với owner
[!] 🔴 A-LỖI-KHOANH-PHẠM-VI CỦA LEAD (nguồn F3): spec CÓ sẵn **F-7** *"Designer thêm nút Sửa (khác Nhân bản)"* nhưng lead liệt wave-1 gồm F-1..F-6/F-10/F-11 và **BỎ SÓT F-7** — đúng cái dây nối 2 nửa. Build+549 test đều KHÔNG bắt được (test gọi thẳng handler; 2 đầu build sạch). Chỉ soi đường nút→endpoint mới thấy
[x] A-owner chốt lượt 4: **"vá trọn rồi đẩy"** (bác 2 phương án đẩy-sớm)
[!] wf: A-wave5 hmw — run-id `wf_07fd9304-74e` · BE vá F1 (bọc transaction) + F2 (thu hẹp purge = chỉ khi phiếu cha KHÔNG CÒN ROW; phiếu xoá-mềm ⇒ CHẶN; bỏ vế `o.IsDeleted`) +2 test nghiệm thu · FE làm F-7 (tách "Sửa tại chỗ"→PUT ⟂ "Tạo phiên bản mới"→POST)
[x] A-wave4-bis reviewer XONG TRỌN — 47.113B · **14 FLAG** · verdict **`DEPLOY-CÓ-RỦI-RO`**. CLEAN 4 trục sau thách-phá thật: F9 migration (4 phép) · F10 validator T1/T2 (11 đường lách, tắc hết) · F12 test còn lại (3 phép) · F13 hồi quy (3 phép)
[ ] 🔴🔴 **A-F8 CỔNG DEPLOY — LUẬT COMMIT, ĐỌC TRƯỚC KHI PUSH**: 2/3 file migration đang **UNTRACKED** (`20260727033522_AddPeAllowApproverDelete.cs` + `.Designer.cs`) trong khi `ApplicationDbContextModelSnapshot.cs` **đã tracked**. `git commit -a` / `git add -u` ⇒ nạp snapshot+entity NHƯNG BỎ file migration ⇒ prod `MigrateAsync()` không thấy gì để áp ⇒ cột không được tạo ⇒ mọi truy vấn `ApprovalWorkflowLevels` ném `Invalid column name`**HẠ TOÀN BỘ 7 MODULE** (PE + HĐ + 5 module Văn phòng số). ⇒ **BẮT BUỘC `git add` ĐÍCH DANH** 3 path (2 migration + `tests/.../UpdateAwDefinitionTests.cs`); trước commit `git status --porcelain | grep '^??'` phải RỖNG ở vùng `Migrations/` + `tests/`; sau commit `git show --stat HEAD | grep AddPeAllowApproverDelete` phải ra **2 dòng**
[ ] A-F11 (MED) test XANH GIẢ `F16_…SafeUpdate_Succeeds_AndWritesNoPeChangelog` `:753-754`: assert `PeChangelogs.Count==0` nhưng seed **0 phiếu PE** ⇒ đúng ở CẢ 2 thế giới (còn cổng / gỡ cổng) ⇒ không đo gì. Vá: seed 1 PE ghim đúng workflow Proposal đó rồi mới assert. Nghiệm thu: tạm gỡ cổng `def.ApplicableType is …` `:790-792` ⇒ ca này PHẢI ĐỎ
[~] A-wave4-bis reviewer RESUME (SendMessage) làm nốt 4 trục F8+ (validator lách · migration an-toàn-prod · test xanh-giả · regression) + dòng khuyến nghị deploy
[~] A-wave4 reviewer — spawn TRỰC TIẾP qua Agent-tool (KHÔNG qua hmw: wave-2 hmw-reviewer sinh 0 file). Ưu tiên: 2 mục lead tự viết · vùng wave-3 vừa vá · 🔴 nghi ngờ riêng của lead: `ExecuteDelete` commit NGAY, nếu SaveChanges sau đó fail thì chữ ký đã xoá CỨNG mà phần còn lại rollback ⇒ mất dữ liệu không hoàn tác
[~] A-vá-lỗi wave-3 gốc: F14 lộ bug THẬT — guard F-5 không chặn trước `db.ApprovalWorkflowLevels.Remove(gone)` ⇒ EF ném "association ... severed" thay vì ConflictException lịch sự. Tức đúng cái F-14 sinh ra để bắt
[ ] A-DEPLOY (sau khi wave-3 xanh + reviewer PASS)
[ ] ⚠️ Deploy này mang **migration mới** `20260727033522_AddPeAllowApproverDelete` lên PROD — AddColumn bit default false, additive an-toàn, nhưng LÀ đổi schema prod thật
[~] wf: A-S4-đợt1 hmw — run-id engine `wf_f1bfea65-ed9` · run-folder `.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/` · 2 task: implementer-backend (F6 schema+mig+UpdateCommand+validator T1/T2+authz+F-5a+F-6+F-10) · implementer-frontend (F-11 5 dây, bẫy :605). Chết giữa chừng ⇒ /tiep §4 relaunch ĐÃ-CẮT-GỌT, đọc sub-*.md trên đĩa trước
[ ] A-S4-đợt1 wave 2 (sau khi wave 1 xanh): test-specialist F-12..F-16 + reviewer soi diff
[ ] A-S5-đợt2: mục 3-28 (nút xóa FE · menu restructure · màn "Đã xóa" 8a/8b/8c) — CHỈ sau khi đợt 1 UAT xanh
[ ] A-ràng-buộc CỨNG cho spec: `IgnoreQueryFilters()` chỉ mở ĐÚNG 1 endpoint list "Đã xóa" — rộng tay = phiếu xóa lọt lại `PeBudgetAccumulator` = phá đúng ý (3) của anh
verify: (trống)
next: [A] chờ 2 Invest về → viết file chi tiết + checklist → hỏi anh điểm quyết → reviewer → hmw. || [B] CHỜ anh 3 số (42) canonRows · (43) carry con-trỏ-vs-slug · (44) END-line-thành-luật + (E2) harvest-curator 111,1% trần.
verify:
python scripts/session_ctx.py mind-check --session 7
tail -1 .claude/workflows/runs/2026-07-26-S154-bookend-open/sub-ring1-open-S154.md
git log --name-only --format='%h %s' origin/main..HEAD -- .claude/sessions/session-7/
ls -1 .claude/workflows/runs/2026-07-26-S154-bookend-open/ | wc -l
carry: thu-moi se=0 all=0 (đo LẠI @tiep S155 set-difference 6 repo: se 23/23 đã có trong inbox; all post-watermark 2026-07-15 = 0) | mồi-ngầm ctx-audit chờ chấm @close — 🔴 RE-ANCHOR THEO TÊN Ý (@tiep S155): đích = ý **"Guard post-wave assertion mù 4 path hook"** trong MIND-0 mục D. Số dòng gốc `:99` (run.md:38) đã TRÔI +41 khi chèn MIND-1 ⇒ `:99` nay là ý "Trần mind_ctx_kb" = SAI đích. CẤM neo line-number vào `_mind` (luật chèn-trên làm mọi anchor tự hỏng) | `[carry:ctx-t9-dogfood]` còn SỐNG: Phase 3.5 ✓ · pause C-bis ✓ · tiep §3-ter ✓ (S155) · **session-end (l) còn nợ @close**

View File

@ -33,6 +33,11 @@
"strikes_total": 25,
"rca_entries": 14,
"at": "2026-07-26 11:52"
},
{
"strikes_total": 25,
"rca_entries": 14,
"at": "2026-07-26 18:39"
}
]
}

View File

@ -10,3 +10,21 @@
> **Persistent diary cross-session.** Auto-injected first ~200 lines at spawn (L1 HOT).
> Update BEFORE every stop. Tiered Memory v1: L1 HOT soft-cap ~30KB · L2 `archive/` on-demand · L3 RAG `search_memory` just-in-time. Keep entry ≤ 1.5K chars (gotcha #53).
> **NEW agent S153 (2026-07-26)** — vai-3 vòng Ctx (soi chuỗi lớp mềm 2 đầu phiên); 2 vai anh em: `ctx-curator` (@pause) · `ctx-verifier` (@tiep).
## S154 (L7) — 2026-07-26 · LƯỢT CHẠY RUNTIME ĐẦU TIÊN của vai
**Verdict @open:** `DAT — 12 điểm` (8 ĐẠT/khai · 4 FLAG). Sub-file `runs/2026-07-26-S154-bookend-open/sub-ctx-audit-open-S154.md` — 22.294 B / 244 dòng, END dòng cuối.
**Bối cảnh chạy:** phiên 0-pause · 0-tiep tính tới @open ⇒ vai-1 `ctx-curator` và vai-2 `ctx-verifier` CHƯA chạy ⇒ rơi vào **fallback hồi-tố đầy đủ** (đúng điều khoản "lưới không thủng ca 1-cửa"). Máy `mind-check --session 7` chạy TRƯỚC: `dat=10 TRUOT=0 exit 0`.
**4 FLAG — cả 4 nhắm vào LEAD, 2 cái lead đã nhận:**
- `carry-ket-luan-nguoc` **HIGH** — block-0 ý D#4 lead viết *"cặp H24 KHÔNG tự đóng được điểm mù"*, nhưng chính phiên đó `lead-stale-auditor` TỰ đưa `.claude/agents/lead-*-auditor.md` vào tập-đo và ra ca THẬT (FLAG-2 của nó) ⇒ **phép thử S153 hẹn cho S154 đã CHẠY và ĐẬU**, kết luận lead ghi là NGƯỢC. 🔴 Bài: ý carry qua nhiều phiên dễ **cứng lại thành kết luận** trước khi phép thử của nó kịp trả lời; người khó thấy nhất là người mang nó đi.
- `mau-so-co-lai` **MED** — carry hẹn probe **3 vai** ctx, lead báo `2/2 ALIVE`. Lý do bỏ `ctx-audit` (sẽ có spawn THẬT @Phase 3.5) là hợp lý **nhưng không khai**`2/2` đọc thành "đủ". Lớp `vắng-mặt trông giống ổn`.
- `mau-so-bat-doi-xung` **LOW-MED** — mục E khai KHÔNG-CHẤM cho ring1/ring2 nhưng nuốt của harness-audit.
- `carry-mat-neo-dinh-luong` **LOW** — câu hỏi chi-phí spawn @pause mất đúng con số làm nó trả lời được (bản S6 có `~100K tok/spawn`, bản L7 rụng).
**Mồi-ngầm gieo cho @close:** `_mind-s-7.md:99` (ý D *"guard post-wave assertion mù 4 path hook"*) — @close kiểm ý này còn sống / đổi nhãn có căn cứ / hay rơi im lặng.
**Vết vận-hành:** dính #53 ×1 (return kể ý-định `"Git check clean… Now writing mục E…"`, `tool_uses=22`, đĩa đã 19.437B) → resume 1× → CLEAN. 🔴 **Ghi-đĩa-trong-lúc-làm cứu trọn**; nếu gom cuối thì mất 19KB.
**Containment:** 0 residual-write. `_mind-s-7.md` giữ nguyên 14.707 B (lead single-writer). 🔴 Verify bằng `git log --name-only origin/main..HEAD -- <path>`**KHÔNG dùng `git status`**, nó mù đúng 4 path Stop-hook tự commit (`.claude/sessions/` là 1 trong 4).

View File

@ -8,16 +8,14 @@ Read-only INTERNAL audit SOLUTION_ERP. Tools: Read/Grep/Glob/Bash + 5 RAG MCP. O
- NOT: external docs/CVE/lib investigator-api · write implementer · test test-specialist · architecture decision em main
## 📌 Recent invest (1-line digest; full detail → archive/runs/git)
- **S153 (07-26) ctx soft-memory spec [fable-real]:** thư `0df10df4` sàn-7 spec 9-task/24-check (`runs/2026-07-26-S153-ctx-adopt/sub-fable-invest-S153.md`). Findings đắt: SE **0 content-ts-reader sống** (session_ctx.py = git-ts+mtime; C10 tự khai thiếu nguồn LATENESS) rào-2 canonical `_TS_KEY_RE` đặt session_ctx.py, máy-7-phép = EXTEND python (tái dụng `_SECRET_RES` 1-nguồn) KHÔNG .ps1 mới (2-sự-thật); `secrets_sweep` rglob TRỌN session-dir `_mind` tự vào lưới @snapshot §3; `_mind` mới-nhất-ở-TRÊN `_context` FLOW ở-CUỐI (bẫy chép-khuôn); (36)(b) máy-$0/pause + vai @2-bookend O-1 carve-out KHỎI CẦN (pause §1 NGỪNG-spawn giữ nguyên); C11(b) filter `sub-*.md`+`^sub-<role>[-.]` đặt tên `sub-ctx-audit-*` lọt lưới ngay (kẽ trio `harness-*-return` đã queue #8 fix); roster 21 > hmw_width 20 = owner-note; (35)-(38) vết OWNER-DELEGATED khuôn `hmw_width._ratified_by`. Tag `[s153, ctx-mind-spec, ts-reader-absent, one-source-regex]`
- **S155 (07-27) PE xóa-phiếu màn DUYỆT [24 finding, `runs/2026-07-27-S155-pe-delete-approver/sub-invest-be-1.md`]:** DELETE `/api/purchase-evaluations/{id}` ĐÃ (`PurchaseEvaluationsController.cs:146`, authz = class `[Authorize]` TRẦN) nhưng allow-list `{DangSoanThao,TuChoi}` (`PurchaseEvaluationFeatures.cs:1404`) chặn ChoDuyet. `Remove()`=**SOFT** gián tiếp qua `AuditingInterceptor.cs:56-62` lớp #81-EXT indirect. Lũy kế: `PeBudgetAccumulator.cs` 2 hàm/4 call-site DUY NHẤT; ChoDuyet ăn số **2 chỗ** (`ComputeAsync:54` PrevSubmitted + `ComputePendingAsync:114` PendingSelected); `TraLai` KHÔNG triệt tiêu; `TuChoi` rơi khỏi 100% phép cộng. Query 0 dòng `IsDeleted` dựa global `HasQueryFilter` (`PurchaseEvaluationConfiguration.cs:84`) soft-del TỰ rớt, không sửa accumulator. Snapshot Mig67 KHÔNG re-compute. 🔴 Cascade 6 FK (`:75-80`) **không bao giờ chạy** soft con `BaseEntity` lại = rác ẩn. **Git khảo cổ TRÚNG:** "chỗ cho hủy" = nút Từ chối, gỡ `6db195d` 12-06 S60; guard `PurchaseEvaluationWorkflowService.cs:101-106` còn sống tự trỏ "Xóa phiếu khi còn Bản nháp" = chính cái lỗ. 0 tiền lệ hủy chứng-từ in-flight; khuôn gần nhất `CancelMeetingBookingHandler` (`MeetingFeatures.cs:457`, Status=Cancelled NOT IsDeleted). Tag `[s155, pe-delete-approver, s60-tuchoi-removal-hole, soft-delete-no-cascade]`
- **S155 (07-27) PE xóa-phiếu FE/authz-slice [18 finding, `runs/2026-07-27-S155-pe-delete-approver/sub-invest-fe-2.md`]:** Khối HÀNH ĐỘNG = `components/pe/PeWorkflowPanel.tsx:458-506` **byte-identical 2 app** (md5 `02671be6…`, cùng PeDetailTabs/PeListPanel/ListPage) sửa-1-bên-là-sót. Gate 3 tầng: `?pendingMe=1``readOnly={!pendingMe}` (`ListPage:590`/`:676`) · `next=workflow.nextPhases` BE-source (`:257`) · `blockedByV2Level` (`:99-103` isAdmin actorcurrentApproval.approvers). 🔴 Plumbing xóa ĐÃ NỐI SẴN vào màn duyệt: `onDelete={del.mutate}` truyền `ListPage:573`+`:668` (`api.delete('/purchase-evaluations/:id')` `:90`) nhưng nút chết `PeDetailTabs:445` đòi `mode==='workspace' && canEditPhase && !readOnly`; nút hiện chỉ `:460` `phase===DangSoanThao`. **Authz 2 tầng:** PE vùng FE = **0-hit** `usePermission|PermissionGuard` (guard chỉ sống Master+Users, khuôn `DepartmentsPage:101 action="Delete"`); `menuKeys.ts` KHÔNG const `Pe_*` (chỉ root `:23`) `Pe_*` sinh BE `MenuKeys.cs:134-141`, seed `DbInitializer:1870-1877`, FE khớp bằng regex `Layout.tsx:120` (fe-admin `:107` **LỆCH**, thiếu WfView). 🔴 `Pe_*` **KHÔNG ∈ `MenuKeys.All`** `Program.cs:82-89` không đăng-ký policy `Pe_*.{action}` (0 dynamic provider) dùng `[Authorize(Policy="Pe_…")]` = policy-chưa-tồn-tại; **dùng được ngay = `PurchaseEvaluations.Delete`** (root All, + `GetMyMenuTreeQuery:66-72` cho `Pe_*` KẾ THỪA cờ CRUD từ root 0 key mới, 0 migration). Ma trận enumerate `db.MenuItems` (`PermissionFeatures.cs:20`) nên ô Delete đã hiện sẵn. Q6: thêm-nút KHÔNG kích 4-place (0 route/page/staticMap `Layout.tsx:56-106`); `Pe_*` resolve nhánh regex `:119-134` KHÔNG qua staticMap. Tag `[s155, pe-delete-fe-authz, two-app-byte-identical, pe-keys-not-in-All]`
- **S155 (07-27) PE menu/trash/dept-head [21 finding, `runs/2026-07-27-S155-pe-delete-approver/sub-invest-menu-3.md`]:** 🔴 Menu = **SEED DB** không hard-code FE `Pe_<Code>` khai `DbInitializer.cs:1874-1877`; thêm mục = restart API, 0 migration (upsert `:1888-1914` chỉ sửa `Order`, **KHÔNG đụng Label** rename qua `labelBackfill:1918-1924`; `DisplayLabel` **CHỈ ăn fe-user** `MenuDtos.cs:14`). 6-chỗ-mirror: +2 seed permission SONG SONG `:2092-2096`/`:2492-2496` + regex `Layout.tsx:120` (fe-admin `:107` thiếu `WfView`). Đổi KEY = mồ côi 13 row/key × 10 key giữ key chỉ đổi label. **`Đã duyệt` chạy được HÔM NAY**: `phase=` đủ 3 tầng (`Controller:23``Features:565``:620`), DaDuyet=7 `?type=1&phase=7`, 0 route mới. `pendingMe` KHÔNG phải param BE (FE rẽ `/inbox` `ListPage:61-68` + lọc client `:182`). `IgnoreQueryFilters` 0 src/Backend / 15 hit tests; `HasQueryFilter(`=12. An-toàn accumulator = **cấu trúc** (`PeBudgetAccumulator.cs:42`/`:100` dựng `peers` FRESH). 🎁 Changelog `: BaseEntity` + lọc CHỈ `PurchaseEvaluationId` (`:1424`) lịch sử phiếu-xóa đọc MIỄN PHÍ; cái chôn = DETAIL bundle. 0 tiền lệ thùng-rác (hit "khôi phục" duy nhất = STRING `EmployeeFeatures.cs:162` ghost-wire). 🔴 **"Trưởng phòng" TÊN CHƯA ĐỜI**: `Department.ManagerUserId` **0/10 Dev** + 2 FE hardcode `managerUserId: null` payload SỬA (`DepartmentsPage.tsx:43`/`:57`) tự-xoá mỗi lần sửa phòng. `PositionLevel.TruongPhong` 33/33 NULL (V1-legacy). Tag `[s155, menu-seed-db, phase-param-exists, changelog-survives-softdelete, dept-head-name-only]`
- **S155 (07-27) WF-V2 sửa-tại-chỗ vs version-mới [25 finding, `runs/2026-07-27-S155-pe-delete-approver/sub-invest-wfver-4.md`]:** 🔴 **0 lệnh Update** `ApprovalWorkflowsV2Controller.cs` (54d TOÀN BỘ) chỉ GET/POST/PATCH-user-selectable/DELETE sửa 1 cờ = buộc POST = `MAX(Version per Code)+1` (`ApprovalWorkflowV2AdminFeatures.cs:325-328`) + hạ MỌI active cùng type (`:330-334`). Chặn THẬT bằng . 🔴 **F17:** POST tạo entity mới **Id mới** (`:336`+`BaseEntity.cs:5`) phiếu đọc workflow theo **Id đã pin** (`PurchaseEvaluationWorkflowService.cs:674`) **cờ mới KHÔNG tới phiếu đang chạy**; re-pin chỉ `PurchaseEvaluationFeatures.cs:149`/`:291` `:249-251` chặn chỉ Nháp/TraLai ChoDuyet kẹt bản vĩnh viễn; 0 backfill re-pin. cờ `AllowApproverDelete` **HỎNG nếu không có lệnh Update**. Pin = **tham chiếu SỐNG** (chỉ NS snapshot Mig67). Con trỏ **LAI**: Bước=INDEX `steps[currentIdx]` (`:686` vỡ ÂM THẦM/`:683` kẹt) · Cấp=ORDER-VALUE (`:694``:695` kẹt) ranh AN-TOÀN = **không đổi tập `{Step.Order}``{Level.Order}`**. 13 thao tác: 3 an-toàn (thêm-người-cùng-Order · cờ `Allow*` · đổi tên) · 2 nửa (bớt/đổi `ApproverUserId` 403 GUID-trần `:705`, FK Restrict `PeLevelOpinionConfiguration:25-28`) · 6 phá-vỡ · 2 hỏi-owner (`CeoApprovalThreshold`·`Step.DepartmentId`). OR-of-N XÁC NHẬN (`:689` GroupBy Order) owner không tự mâu thuẫn; comment `ApprovalWorkflow.cs:81-82` SAI *"KHÔNG OR-of-many"*. Blast 6+ module (198 hit/23 file). Designer **fe-admin ONLY** (`ApprovalWorkflowsV2Page.tsx`), "Nhân bản"→*"Tạo quy trình mới"*; **0 cảnh báo đang-dùng/0 disable IsActive**. F6 tick `:999-1007` +dây `:54/:110/:159/:177/:605` + fe-user `WorkflowMatrixViewPage.tsx:282` + BE `:41/:230/:188/:368`. CẤM delete-then-insert Levels diff theo `Level.Id`. Tag `[s155, wf-v2-no-update-cmd, new-version-new-id-unreachable, pointer-index-vs-order]`
- **S153 (07-26) ctx soft-memory spec [fable-real, detail `runs/2026-07-26-S153-ctx-adopt/sub-fable-invest-S153.md`]:** SE **0 content-ts-reader sống** (session_ctx.py = git-ts+mtime) rào-2 canonical `_TS_KEY_RE` đặt session_ctx.py, máy-7-phép = EXTEND python (tái dụng `_SECRET_RES` 1-nguồn) KHÔNG .ps1 mới; `secrets_sweep` rglob TRỌN session-dir `_mind` tự vào lưới; `_mind` mới-nhất-ở-TRÊN `_context` FLOW ở-CUỐI (bẫy chép-khuôn); C11(b) filter `^sub-<role>[-.]` tên `sub-ctx-audit-*` lọt lưới ngay. Tag `[s153, ctx-mind-spec, ts-reader-absent, one-source-regex]`
- **S148 (07-23) PE tree DUP project by-space:** `FLOCK 01`/`FLOCK01` = **path-A TWO real Project rows** diff-by-space. Tree groups on `p.projectId` GUID (`PurchaseEvaluationsListPage.tsx:192-197`, display projectCode); PEProject FK `ProjectId` (`PurchaseEvaluation.cs:15`) JOIN-resolved (`PeFeatures.cs:579/:647`) NO denorm string. CRUD zero-trim (`ProjectFeatures.cs:80` validator, `:95/:138` exact `Code==` dupe-check). Both seeds exact-Code key emit ONLY no-space: `SeedRealMasterDataAsync:2776` (UNGATED :128) + `SeedDemoMasterDataAsync:2681` (gated :117 OFF-prod). Space=legacy/manual. Blast=3 tables ProjectId (Contracts/PeWorkItemBudgets/PurchaseEvaluations); only `PeWorkItemBudgets` UNIQUE(ProjectId,WorkItemId) filtered [IsDeleted]=0 (`PeWorkItemBudgetConfiguration.cs:27`). Direction: KEEP no-space (in seedidempotent), RETIRE space (not-in-seed); soft-del survivorseed RE-CREATES (#75/#76). Dev only FLOCK 01 pair (FLOCK03/CAL01 space-twins prod-only); prod UNVERIFIED (SSH dies on SQL-client, S134). Tag `[s148, pe-tree-dup-project-by-space, path-A-two-rows, no-normalize-crud]`
- **S147 (07-23) PE attach TraLai edit-block:** FE-ONLY block, BE zero phase-guard (`PurchaseEvaluationsController.cs:15` class `[Authorize]` only; handlers check exists only `PurchaseEvaluationAttachmentFeatures.cs:66/:160`). Gate = raw `readOnly` PROP not phase (`GeneralAttachmentsSection` :3555/:3577 hide-when-readOnly); List detail HARDCODE `readOnly=true` (:574/:669) TraLai loại khỏi SCREEN không khỏi editable-SET (workspace `PeListPanel.tsx:88` isEditablePhase GỒM TraLai). fe-admin MIRROR. BE no-guard CỐ Ý (S78). Fix FE: `attachEditable=isDrafter&&isEditablePhase`. Tag `[s147, pe-attach-tralai, fe-only-screen-gate]`
- **S141 (07-20) adap đợt-9/10 5-lane [fable-clone-ensemble, 5/5 clean]:** (ii)-nới-có-vết đủ đóng dangling-head (3/3 squash-benign, `.session-counter.json`); tick-head=wal-commit. byte-safetolerant-EXTRACTION 2-lỗ tách → pin regex `\"?([0-9a-fA-F]{8,64})\"?`. Roster-count 3-tầng (prose/glob/pointer). refine⟂lead-omission = discriminator WHOSE-memory. Detail → `runs/…S141…`.
- **S138 (07-17) adap 3-bản 17-07 [fable-real]:** 3/3 FIT; delta hẹp hub PHẢI grep landing-site trong luật SỐNG (SE chép 4-nhãn lỗi vào `reviewer.md:46` = adopt-verbatim kế-thừa lỗi nguồn). Detail → `runs/…S138…`.
- **S135/S134/S133 (07-17) PE budget invest [fable-real, #53→sub-file cứu trọn]:** S134 lũy-kế lệch-tiền-đề row1 (`PeBudgetAccumulator.cs:42-48` PrevSubmitted ĐÃ gồm ChoDuyet; DaDuyet-only chỉ row2 :50-60) predicate 1-bản helper Features:962+WorkflowService:1018. S133 freeze-at-DaDuyet 4-assignment (`PurchaseEvaluationWorkflowService.cs:861/:899/:931/:1053`, Mig67). S135 tick H24 single-entry `session-start:181-206`. Detail → archive + runs.
- **S128 (07-16) adap-16-07 [fable-real, garble→on-behalf]:** RAG config in-repo (`AI_INFRA\claude-rag\lib\projects.py:39` owner-final, `rag.json:26` override); nhãn-đo "calibrated" chỉ C1; ghi-đĩa-trong-lúc-làm cứu draft khi garble. Detail → runs.
- **S126 (07-16) worker-pin + all-inherit sweep [opus×2]:** 14/14 opus+max (no-tier lane resolve opus DÙ lead=Fable); B1 284→15 floor-sống; mega-line blow Read-cap → perl-window. `[s126, h23-precedence]`
- **S125 (07-16) adap-review [fable-real]:** auto-memory 50 tracked/0 porcelain; 14/14 opus+effort:max; residue all-inherit floor-sống 2 (`fable-real.md:37`+`fable-clone.md:43`) vá; verifier-suspect-first.
- **S122 (07-15) retro-harvest [6/6 clean]:** 🔴 0/7 mất 7/7 XONG synthesis-sai-chỗ (orphan đo TÊN không NỘI-DUNG); lane-RỖNG≠mất; lane tự truy git đừng tin brief.
- **S133-S141 (07-1620) gộp [detail archive/2026-07.md + `runs/…S1{28,33,34,35,38,41}…`]:** S141 adap 5-lane (byte-safetolerant-EXTRACTION pin regex `\"?([0-9a-fA-F]{8,64})\"?`; roster-count 3-tầng) · S138 delta-hẹp PHẢI grep landing-site trong luật SỐNG · S134 lũy-kế lệch-tiền-đề row1 (`PeBudgetAccumulator.cs:42-48` PrevSubmitted ĐÃ gồm ChoDuyet) · S133 freeze-at-DaDuyet 4-assignment (`PurchaseEvaluationWorkflowService.cs:861/:899/:931/:1053` Mig67) · S128 RAG config in-repo (`projects.py:39` owner-final) · S126 worker-pin 14/14 opus+max, no-tieropus lead=Fable · S125 verifier-suspect-first · S122 orphan đo TÊN không NỘI-DUNG, lane-RỖNGmất.
- **S116/S117 (07-13) PE negative-quote + giá-chốt RANGE [fable]:** neg-quote FE-ONLY (BE/DB nhận âm; quote sống `details[].quotes` KHÔNG `suppliers[].quotes`). S117 ApprovedPrice WRITE 1-site `ApplyApprovedPriceOnFinalize`; HĐ-gen KHÔNG đọc ApprovedPrice (GiaTri=SUM IsSelected). endsBeforeCeo=config-lie #78 (detail :1152 / list :647).
- **[archived archive/2026-07.md + git]** S87-S114 PE recon (multi-winner IsSelected per-hạng-mục Mig65 · WinnerQuoteTotal DERIVED · budget gate PURE-ROLE · V2 routing=ApproverUserId LINEAR) · Supplier-import-v2 (ClosedXML, NCC=Code, IsPublic fail-closed, import authz-mismatch) · S105 BVAAU auth RCA (relative-`/api` prod-404) · S104 30-field · S98-S101 harness · Office/HRM/Governance recon. Ctrl-F `_INDEX.md`.
@ -55,8 +53,9 @@ Prod: `ssh vietreport-vps` powershell-wrapper `sqlcmd -S '.\SQLEXPRESS' -d Solut
- **Seeds gating:** `SeedDemoMasterDataAsync` gated `DemoSeed:Disabled` (OFF prod) · `SeedRealMasterDataAsync` UNGATED (62 dự án + 71 hạng mục Excel, per-code idempotent, exact-Code key #75/#76 resurrect risk).
## 🔄 Curate log
- **S148 (07-23):** hard-cap rewrite 28.2→~14KB — collapsed multi-line S133-S148 → 1-liner, dropped stale pointer-list (S87-S114 → grouped 1-line, verbatim in archive/git). Single-writer inv-cb (hook @28KB).
- **Prev:** S125 (23.9→~15KB, 14 lines→archive/2026-07.md) · S71/S70/S40. Verbatim byte-exact in `archive/`.
- **S155 (07-27):** hook @19.5KB → compact 20.7→~17.1KB: gộp S122-S141 thành 1 dòng, trim 3 entry S155 (detail nguyên vẹn ở `runs/2026-07-27-S155-pe-delete-approver/sub-*.md`) + S153.
- **S148 (07-23):** hard-cap rewrite 28.2→~14KB — collapsed multi-line S133-S148 → 1-liner, dropped stale pointer-list (S87-S114 → grouped 1-line, verbatim in archive/git).
- **Prev:** S125 (23.9→~15KB) · S71/S70/S40. Verbatim byte-exact in `archive/`.
## S149 (2026-07-24→25) — 10 invocation gộp 1 entry [LEAD GHI ON-BEHALF @S150 — M9/B3 theo H2-F3 Coverage-miss (10 lượt → 0 delta sổ, ngoài lưới C11(b) by-design); nguồn: sub-file trong `runs/2026-07-24-S149-{dong-du-5-vong,hoi-tu-bookend,owner-gated-3-khoan}/` + `runs/2026-07-25-S149-adap-backlog/sub-invest-backlog.md`]

View File

@ -1,11 +1,8 @@
# Reviewer Agent — Persistent Memory
- **[verbatim → git 8b6df0a closeout] S146 (07-22) PRE-COMMIT spec-v2-applied (governance 20 file) — 2 MAJOR THẬT (cả hai của LEAD); nấc `verified` tự-chạy-lệnh không-tin-lead:** F1 `session-end.md` còn `hook-3-path` GẠCH-NỐI dù worker CE đã chỉ đích-danh (class "residual có người chỉ tận nơi vẫn rớt") + bất-đối-xứng nội-bộ (`pause.md` đã 4-path). F2 phép quét cardinality của lead siết LỆCH-TRỤC (co-occurrence CÙNG-DÒNG + 1 mẫu-chữ) → re-run HEAD tiền-vá lọt 4 ca thật gồm chính F1. F3 HELD (git show da7ee8a: hook 4-path + `-File` re-đọc mỗi Stop). 🔴 Bài rút: lead sửa 1 phép quét 4 lần/phiên vì mã-hoá ý-định NGỮ-NGHĨA vào regex; nấc đúng = lưới-soát-cho-người + allowlist CÓ TÊN + use⟂mention; lần-4 = citation-trap (dò bắt chính nốt-vá vì nó trích giá-trị cũ).
- **[→ archive/2026-07.md @S145-curate] S145 Axis-E lane — FAIL 2C/1M/4m:** cross-cut seam catch (hook 3→4 path sessions/ orphan phá Sàn-3 tiep.md; session_ctx_kb=64 nghịch owner + ghost-wire #H18). LESSON: bất-biến chia-đôi giữa 2 axis nằm ĐÚNG SEAM → chỉ cross-cut sweep bắt.
- **[→ archive/2026-07.md @S145-curate] S145 lane-4 (Axis-D) — PWF 2M/1m:** phép-4 LANDMINE (`agent-memory/ring1-audit/` EMPTY, auto-fires close ⇒ phép-4 FAIL). LESSON: 'vai-có-sổ' acceptance ⇒ MUST `ls agent-memory/<role>/` (folder≠sổ).
- **[→ archive/2026-07.md @S145-curate] S145 C7-lane (Axis A) — PWF 1C:** spec "4 surface→2" stale-fix FALSE (4 caller THẬT); grep-"4 surface"=0-hit = ANTI-TEETH Goodhart. LESSON: stale-fix verify caller-trên-đĩa; 2-script-same-suffix = conflation-vector.
- **[→ archive/2026-07.md @S145-curate] S145b (07-22) GATE roster #2/#3 ring2-audit+ring4-audit (18→20) — PASS 1m:** implementer vá ĐÚNG mọi S145a-finding + tự sweep 2 NUMERIC site ngoài checklist. 1 MINOR measured-label: `README:249` "17 folder" falsified by ls=18 (ring1-audit RỖNG). LESSON: đếm VALID_ROLES = per-file grep bidirectional KHÔNG regex (hmw.js `[2]`/`[S110` comment lừa parser).
- **[→ archive/2026-07.md @S155-curate] S155 ×2 (07-27) PE delete-approver — DIFF-review đợt 1 tiền-deploy (14 FLAG 4H/6M/3L/1I, DEPLOY-CÓ-RỦI-RO) + SPEC-review (PWF 4H/8M/5L):** 🔴 class MỚI **`HIGH nằm ở git chứ không ở mã`** — 2/3 file migration UNTRACKED mà snapshot ĐÃ tracked ⇒ `commit -a` nạp model-có-cột nhưng bỏ migration ⇒ `Invalid column name` cả 7 module, build+test vẫn xanh ⇒ **gate deploy phải chấm `git status ^??` cho `Migrations/`** · `ExecuteDelete` ngoài transaction TRƯỚC `SaveChanges` = mất dữ liệu không hoàn tác · purge xoá CỨNG chữ ký phiếu XOÁ MỀM ⇒ **test đang KHOÁ chiều ngược, vá xong test đỏ ≠ hồi quy, CẤM nới assert** · ~600 dòng BE = mã chết (`grep api.put`=0) · **xanh-giả bắt bằng phép 2-thế-giới** (còn-gate ⟂ bỏ-gate cùng cho 0 ⇒ 0 thông tin) · CLEAN chứng bằng **liệt kê vét cạn** write-site + sweep gián-tiếp (#81-EXT) · 2 lỗi TỰ BẮT: trích line-number **từ diff** (diff không mang số dòng file) · `grep -v Snapshot` ăn nhầm tên migration · **file bị sửa TRONG LÚC soi ⇒ neo bằng NỘI DUNG DÒNG + ghi bản-đồ-trôi**.
- **[→ archive/2026-07.md @S155-curate] S146·S147·S152-D2 (digest):** S146 lead sửa 1 phép quét 4 lần/phiên vì mã-hoá ý-định NGỮ-NGHĨA vào regex ⇒ nấc đúng = lưới-soát-cho-người + allowlist CÓ TÊN + use⟂mention · S147 **decouple 1 gate ⇒ truy xem prop dùng chung đã mang carve-out chưa** (`itemsReadOnly` ≠ raw `readOnly`); "same fix" cho surface anh-em có thể trúng gate KHÁC · S152-D2 census thay spot 307/307; **3 THƯỚC-HỎNG** (md5 disk vs `git show` = FAIL giả do CRLF⟂LF · `grep -c` dính citation-trap · `grep -iF` MSYS trả 0-hit IM LẶNG trên UTF-8) + máy-trích mù đuôi `{.yml,.json,.sql}`.
- **[→ archive/2026-07.md @S145-curate] S145 4-lane (Axis-A/D/E + S145b GATE) — digest:** bất-biến chia-đôi giữa 2 axis nằm ĐÚNG SEAM ⇒ chỉ cross-cut sweep bắt (hook 3→4 path orphan; `session_ctx_kb` ghost-wire #H18) · 'vai-có-sổ' acceptance ⇒ MUST `ls agent-memory/<role>/` (folder≠sổ, ring1-audit RỖNG) · stale-fix phải verify caller-TRÊN-ĐĨA — grep-"4 surface"=0-hit là ANTI-TEETH Goodhart; 2-script-same-suffix = conflation-vector · đếm VALID_ROLES = per-file grep bidirectional KHÔNG regex (comment lừa parser).
- **[→ archive/2026-07.md @S145b-curate] S145a·S143 (digest, archived):** S145a NUMERIC "roster 17" hardcode trốn name-enum sweep ⇒ sweep repo-wide CẢ numeric LẪN name-enum (superseded S145b PASS) · S143 CLASS `bằng-chứng-tự-huỷ-sau-squash` (cite outward = commit SỐNG-sau-squash) + số neo-mốc + errata CẤM sửa file đã-stamp + #53 ghi-đĩa-trong-lúc-làm.
- **[→ archive/2026-07.md @S140/@S134] S139·S134b·S133 (digest, archived):** S139 re-measure MỌI acceptance từ git HEAD + no-self-exempt (chấm cả file CHÍNH VAI MÌNH); "measured" sống CHỈ KHI falsify-path CHẠY ∧ đứng · S134b insertion-only + `grep '^-'`=0 chắc hơn diff-mắt · S133 "mọi nhánh set X" phải grep CẢ assignment-qua-BIẾN (#81).
@ -80,8 +77,6 @@ Adversarial pre-commit reviewer SOLUTION_ERP. Read-only verify + live curl prod
## 📅 Recent activity (compressed — full verbatim → `archive/2026-06.md` + `archive/2026-07.md` via `archive/_INDEX.md`)
- **S147 (07-23) DIAGNOSIS-review PE attach TraLai (no diff — verify root-cause + stress fix) — Diagnosis PASS 5/5, fix CORE-safe/EXT-unsafe:** 5 claims verified from disk; BE-no-guard CONFIRMED **intentional** by S78 changelog L9 ("handler KHÔNG guard drafter-only → approver upload no-403") + sole pipeline behavior=ValidationBehavior. 🔴 **MAJOR blind-spot:** `attachEditable=isDrafter&&isEditablePhase` SAFE for GeneralAttachmentsSection (:2013 readOnly=raw, no carve-out) but UNSAFE for SupplierAttachmentsCell (:2758) whose readOnly=**itemsReadOnly** (`readOnly&&!approverEditMode`, Mig28-F3 ChoDuyet approver-edit) → naive REPLACE breaks approver QuoteDocument edit in ChoDuyet; SAME gap in optional BE guard (carve-out {ChoDuyet+ApprovalAttachment} misses ChoDuyet+QuoteDocument). **Scope trap:** isDrafter@:140 in MAIN scope, OUT-of-scope at :2013/:2758 (child comps take only {ev,readOnly}); changing :421 over-broadly hits HoSoLinkRow:2017. **Completeness:** WORKSPACE (readOnly=false, picker editableOnly∋TraLai) already edits comparison-doc in TraLai → bug is LIST-detail-only (:574/:669 hardcode). LESSON: **decouple a gate → trace if shared prop already carries a carve-out (itemsReadOnly ≠ raw readOnly); "same fix" for sibling surface may hit a DIFFERENT gate source.**
- **[→ archive/2026-07.md @S126/@S134] S123·S124·S125·S129·S131·S131b digest (all archived):** S131b follow-up phải GIỮ con-số source · S131 số-atomic-đúng vẫn fencepost → hero-ratio re-derive từ raw ([topic](project_s131_h24_h22_3gap_outward_gate.md)) · S129 writer-only = NỬA-VÁ + quick-test tự-chạy = chứng rẻ nhất · S124/125 do-token trap → re-Read full + sibling-test 2-CHIỀU (MATCH⇒file-bệnh·FAIL⇒verifier-bệnh) · S123 🔴 #53 ghi diary TRƯỚC return + vá-1-lớp grep MỌI matcher cùng-lớp ([topic](project_s123_governance_4change_review.md)).
- **Digest S93→S117 (verbatim → `archive/2026-07.md`; 1 mệnh-đề/entry):** S93 code-gate re-derived denom · S97 default-flip byte-mirror SHA ×2 · S98 Windows byte-verify = .NET (MSYS strip
báo sai) · S100 HELD stale run-id NOT-stamped · S101 persist-claim cần reader-side · S108 outward-claim "đã sửa X" phải cat X trước gate · S109 gist meta-count ≠ disk · S110 claim-về-code grep lại kể cả reword · S111 nâng-nấc-quên-sync cùng-diff · S115 verify EACH regex-alt by-hand · S116 persist-claim = tracked + 0-drift · S117 2-tier guard compare SUM-EXPRESSION.
@ -102,12 +97,5 @@ Adversarial pre-commit reviewer SOLUTION_ERP. Read-only verify + live curl prod
- `/fable-real reviewer` gate spec hội-tụ-bookend: **GO-WITH-FIXES 1C/6M/4m** (CLEAN 218K, 0 garble) — spec v1→v2 trước wave implement 5-lane Opus (5/5 done 946K 0-garble). Lưu ý sổ: lượt gate adap-backlog `wf_f4e4c006` KHÔNG tính invocation vai này (reviewer-lane **skeleton-ruột-rỗng** — khung 274B, 0 append, return+resume đều fail → em-main-solo re-do; xem sổ garble sub-class @S150).
## S152 (2026-07-26) — D2 Fidelity-gate 6 gist "CỤM BỔ SUNG" (coverage-diff) — **PASS_WITH_FIXES** (A/B/C/D PASS · E FAIL)
- **Census thay spot:** 307/307 trích có trong verbatim CẶP (275 exact + 32 sau khi hoàn `'``"`), 307/307 chứa ĐÚNG token gắn nhãn, 307/307 token thật-sự VẮNG khỏi gist tiền-append (0 gap thổi-phồng). `ref`=46 khớp đúng claim lead. Trích = cửa-sổ ~150 ký-tự cố-định, token ở ~27% → cắt giữa từ ở 2 biên nhưng payload (file:line/finding) còn nguyên ⇒ điều-hướng được, KHÔNG rác.
- 🔴 **E FAIL — class-gap hệ-thống, chữ-ký sạch:** máy-trích emit file-ref CHỈ cho `{.cs 101/.tsx 45/.ts 16/.ps1 8}` = 170 token, **ZERO cho `{.yml,.json,.slnx,.csproj,.sql,.config}`** ⇒ 13 token infra vắng ở 5/6 cặp. Nặng nhất: `deploy.yml` (chính file pipeline, trong gist của vai CI/CD!) · `appsettings.Production.json` (16 lần trong verbatim) · 2× `s59-*.sql` (chạy TAY prod ngoài pipeline) · `memory-budget.json` (single-source canonical CLAUDE.md). Header cụm TỰ đặt chuẩn "file-ref phải sống trong gist" mà KHÔNG khai N/A ⇒ trượt bằng chính thước mình.
- 🔴 **3 THƯỚC-HỎNG (bug ở phép đo, không ở vật):** (1) md5 disk vs `git show` — spec gate ghi vậy — báo FAIL GIẢ 3/6 verbatim do worktree CRLF ⟂ blob LF (autocrlf=true + 188 file legacy `i/lf w/crlf`); đúng phải `git diff HEAD --quiet` hoặc strip-CR 2 phía. (2) `grep -c distill-gen` vô-dụng vì CHÍNH header cụm chứa chữ "distill-gen giữ" (citation-trap nhẹ) → phải so VALUE `distill-gen:\s*\d+`. (3) `grep -iF` trên MSYS trả **0-hit im-lặng** cho pattern ASCII trong file UTF-8 (`Mig42`: `-F`=1 nhưng `-iF`=0) = bẫy vắng-mặt-trông-giống-sạch S146 — thoát nhờ đối-chiếu Python.
- **Tự bắt 2 lỗi của CHÍNH mình trước khi báo:** "Mig 47/50/42/46 MISS" = artifact chuẩn-hoá của tao (`Mig42` liền không dấu cách, có ĐỦ 2 phía) · context-regex `.{70}` trả RỖNG đọc nhầm thành sạch → phải `.{0,70}`. ⇒ **LUẬT: mọi MISS phải literal-grep XÁC-NHẬN 2 phía bằng công-cụ THỨ HAI trước khi vào report.**
- D re-ground từ đĩa: `docs/gotchas.md` count=83/max=83 ⇒ 0 nhãn `gotcha #N>83`; soi NGƯỢC 24 dòng gotcha ≤83 tìm mirror-error (run-number đội lốt gotcha) → 0 ca, mọi #N đều là gotcha thật trong ngữ-cảnh nguồn. Tag `[s152, d2-fidelity-gate, coverage-diff, extractor-ext-blindspot, thuoc-hong-x3]`
## S152 (2026-07-25) — R1 retention-note ranh NEVER [M9 on-behalf lead]
- **NEVER Edit/Write vào code-tree, NEVER commit, NEVER push** — vai = verdict-only (PASS/FAIL + issue file:line), sửa là việc em-main/implementer. KHAI THẬT 2 nấc: (a) ranh commit/push/code-tree **chưa từng vỡ**; (b) Write/Edit runtime CÓ cấp và ĐÃ dùng hợp lệ cho sub-file run-folder + diary-self-write (AS-10 VERIFY-KEEP, nhân-chứng S143) — tức "NEVER" áp cho ĐÍCH (code/commit/push), không phải cho tool-tồn-tại; containment thật = git-diff lead. Tag `[s152, r1-retention, never-edit-write-commit-push-target-scoped]`

View File

@ -115,3 +115,44 @@
- **S145b (07-22) gate GOVERNANCE-roster vai-KIỂM #2/#3 `h24-audit`+`sleep-audit` (C4/C4b TÁCH, roster 1820) PASS 1m.** ⭐⭐ **POSITIVE-VALIDATION: implementer VÁ ĐÚNG mọi finding vai#1 S145a + tự mở rộng sweep:** de-number `fable-clone/real` argument-hint+heading ("roster 17"→"roster"), fix `harness-11-engine:345` completeness-claim "1417"→"1720"+trọn-roster, +3 skill-matrix row, +3 decision-tree branch; PLUS tự tìm-diệt 2 NUMERIC site NGOÀI checklist (`vocab-alias-map` "17/17"→"TOÀN-ROSTER" · `README` auto-toan-vong "roster 17"→trỏ-canonical) = **bài "grep NUMERIC lẫn name-enum repo-wide" S145a ĐÃ NGẤM**. Axis4 CLEAN: STATUS 20 + hmw VALID_ROLES 20/20 (bidirectional cross-check mọi agent.mdVALID_ROLES = 0 MISS) + README×6 + subset-enum-detector (đếm 1-2/3 per-line) xác nhận mọi ROSTER-enum FULL(3/3), 7 "subset" đều per-role hợp-lệ (decision-tree/skill-matrix/VALID_ROLES split 2-dòng). Role-file mirror đủ (RANH SẮC h24 "KIỂM con-đo H24 KHÔNG soi-lead trực-tiếp" · gist-ADDITIVE invariant · fail-safe NO-OP · JSON-pin THẬT: `flag_classes`=11/`h24_cadence`/`class_repeat` tồn-tại). Wiring: (ii-bis) AUTO sau cặp H24 KHÁC (ii) consent-gate H24 (khai thẳng, KHÔNG mâu-thuẫn) · Phase4.5 sau Phase3-Fidelity+Phase4-WRITE trước Phase5. Axis5 no-over-claim (executed/verified-pending-restart; counter "KHONG H24 audit phien nay"=khớp NO-OP). 🔸 **1 MINOR — measured-label gate:** `README:249` "cả 3 hiện chưa folder 17 folder vật-lý đúng" FALSIFIED bằng `ls agent-memory/` = **18 dir** (`tooling-harvest-audit/` RỖNG tồn-tại từ phiên này); nhưng 17 dir MEMORY.md + git KHÔNG track empty-dir committed-state=17 khớp enum low-impact, fix=reword "17 folder-có-MEMORY". **LESSON tự-thân: regex/bracket-count trên `hmw.js` VALID_ROLES DỐI** (non-greedy `[\s\S]*?]` + bracket-depth-parser CẢ HAI trượt `[2]`/`[S110` trong comment báo "18/0" giả) **đếm VALID_ROLES = per-file grep bidirectional (agent.md↔literal), KHÔNG regex**. Tag `[s145b, governance-roster-followup, PASS, vai1-lesson-absorbed, numeric+name-sweep-repowide, folder-count-vat-ly-falsified, regex-on-hmw-unreliable, bidirectional-crosscheck-wins]`
- **S145 Axis-E lane (/fable-clone tiep/pause/snapshot + CROSS-CUT) review SPEC apply-hub FAIL 2C/1M/4m.** Corroborated Axis-A lane CRITICAL independently: A.4/A2 "4 surface2" FALSE (grep `scripts/nhip-no-probe.ps1` = 4 caller pause:44·tiep:112·**session-start:216**·**session-end:104**; A2 grep-0-hit = anti-teeth Goodhart). **UNIQUE to Axis-E = 2 seam-catch 4 lane kia RƠI:** (CRITICAL-2 CE) Axis C.2 hook 34 path (+sessions/) phá Sàn-3 tiep.md §0 "3 path"/"3 điểm " hardcode **4 site** (:21/:23/:75/:78); sau +sessions/ (a) 4 stale, (b) BẬC-MẠNH signal chỉ phủ runs/ sessions/ orphan KHÔNG signal = reopens "sổ trốngsạch" blind-spot ON THE SAFETY-CRITICAL RECOVERY FLOOR, (c) BẬC-TRUNG :75/:78 "bẩn ngoài 3 path" misclassify sessions/-dirt (logic bug THẬT ko chỉ prose). Đã biết: adap-report 07-17:23 "meld-forward-gap (hook 3 path vs manifest 4)". Cả C.death-path(spec:33) lẫn E.death-path(spec:47) KHÔNG nhắc ripple = gap ĐÚNG khe 2 lane. (MAJOR-3) C.1 session_ctx_kb=64 nghịch owner-authority: 4 artifact phiên trước phân-loại OWNER-GATED + K6.2 CẤM "mượn số hub chưa đo" (session-model-se-draft:55); spec cite "canonical default" KHÔNG cite owner-directive + acceptance C1 ko đòi `_owner_set` lineage (bulk=30 ); + **ghost-wire #H18: 0 script đọc session_ctx_kb** (pull_warn_days spec analogize thì reader nhip-no-probe:190) analogy hỏng 2 mặt. **LESSON cross-cut lane:** bất-biến chia-đôi giữa 2 axis (hook-path-count Sàn-3-blind-spot-count) nằm ĐÚNG SEAM, mỗi lane-đơn thấy nửa chỉ cross-cut sweep bắt; "áp hub-answer" (bulk/C7 hub-trả) "số owner-giữ" (session_ctx_kb SE CỐ Ý từ-chối) đừng gộp. Tag `[s145-axisE, crosscut-seam-catch, hook-4path-breaks-san3-recovery-floor, session_ctx_kb-owner-gated+ghost-wire, corroborate-4surface-critical, FAIL]`
---
## @S155-curate (2026-07-27) — moved verbatim từ L1 MEMORY.md (hook 23.9KB → <17.1KB)
- **[verbatim git 8b6df0a closeout] S146 (07-22) PRE-COMMIT spec-v2-applied (governance 20 file) 2 MAJOR THẬT (cả hai của LEAD); nấc `verified` tự-chạy-lệnh không-tin-lead:** F1 `session-end.md` còn `hook-3-path` GẠCH-NỐI worker CE đã chỉ đích-danh (class "residual người chỉ tận nơi vẫn rớt") + bất-đối-xứng nội-bộ (`pause.md` đã 4-path). F2 phép quét cardinality của lead siết LỆCH-TRỤC (co-occurrence CÙNG-DÒNG + 1 mẫu-chữ) re-run HEAD tiền-vá lọt 4 ca thật gồm chính F1. F3 HELD (git show da7ee8a: hook 4-path + `-File` re-đọc mỗi Stop). 🔴 Bài rút: lead sửa 1 phép quét 4 lần/phiên mã-hoá ý-định NGỮ-NGHĨA vào regex; nấc đúng = lưới-soát-cho-người + allowlist TÊN + usemention; lần-4 = citation-trap ( bắt chính nốt-vá trích giá-trị ).
- **S147 (07-23) DIAGNOSIS-review PE attach TraLai (no diff verify root-cause + stress fix) Diagnosis PASS 5/5, fix CORE-safe/EXT-unsafe:** 5 claims verified from disk; BE-no-guard CONFIRMED **intentional** by S78 changelog L9 ("handler KHÔNG guard drafter-only approver upload no-403") + sole pipeline behavior=ValidationBehavior. 🔴 **MAJOR blind-spot:** `attachEditable=isDrafter&&isEditablePhase` SAFE for GeneralAttachmentsSection (:2013 readOnly=raw, no carve-out) but UNSAFE for SupplierAttachmentsCell (:2758) whose readOnly=**itemsReadOnly** (`readOnly&&!approverEditMode`, Mig28-F3 ChoDuyet approver-edit) naive REPLACE breaks approver QuoteDocument edit in ChoDuyet; SAME gap in optional BE guard (carve-out {ChoDuyet+ApprovalAttachment} misses ChoDuyet+QuoteDocument). **Scope trap:** isDrafter@:140 in MAIN scope, OUT-of-scope at :2013/:2758 (child comps take only {ev,readOnly}); changing :421 over-broadly hits HoSoLinkRow:2017. **Completeness:** WORKSPACE (readOnly=false, picker editableOnlyTraLai) already edits comparison-doc in TraLai bug is LIST-detail-only (:574/:669 hardcode). LESSON: **decouple a gate → trace if shared prop already carries a carve-out (itemsReadOnly ≠ raw readOnly); "same fix" for sibling surface may hit a DIFFERENT gate source.**
### S152 (2026-07-26) — D2 Fidelity-gate 6 gist "CỤM BỔ SUNG" (coverage-diff) — **PASS_WITH_FIXES** (A/B/C/D PASS · E FAIL)
- **Census thay spot:** 307/307 trích trong verbatim CẶP (275 exact + 32 sau khi hoàn `'``"`), 307/307 chứa ĐÚNG token gắn nhãn, 307/307 token thật-sự VẮNG khỏi gist tiền-append (0 gap thổi-phồng). `ref`=46 khớp đúng claim lead. Trích = cửa-sổ ~150 ký-tự cố-định, token ~27% cắt giữa từ 2 biên nhưng payload (file:line/finding) còn nguyên điều-hướng được, KHÔNG rác.
- 🔴 **E FAIL — class-gap hệ-thống, chữ-ký sạch:** máy-trích emit file-ref CHỈ cho `{.cs 101/.tsx 45/.ts 16/.ps1 8}` = 170 token, **ZERO cho `{.yml,.json,.slnx,.csproj,.sql,.config}`** 13 token infra vắng 5/6 cặp. Nặng nhất: `deploy.yml` (chính file pipeline, trong gist của vai CI/CD!) · `appsettings.Production.json` (16 lần trong verbatim) · 2× `s59-*.sql` (chạy TAY prod ngoài pipeline) · `memory-budget.json` (single-source canonical CLAUDE.md). Header cụm TỰ đặt chuẩn "file-ref phải sống trong gist" KHÔNG khai N/A trượt bằng chính thước mình.
- 🔴 **3 THƯỚC-HỎNG (bug ở phép đo, không ở vật):** (1) md5 disk vs `git show` spec gate ghi vậy báo FAIL GIẢ 3/6 verbatim do worktree CRLF blob LF (autocrlf=true + 188 file legacy `i/lf w/crlf`); đúng phải `git diff HEAD --quiet` hoặc strip-CR 2 phía. (2) `grep -c distill-gen` vô-dụng CHÍNH header cụm chứa chữ "distill-gen giữ" (citation-trap nhẹ) phải so VALUE `distill-gen:\s*\d+`. (3) `grep -iF` trên MSYS trả **0-hit im-lặng** cho pattern ASCII trong file UTF-8 (`Mig42`: `-F`=1 nhưng `-iF`=0) = bẫy vắng-mặt-trông-giống-sạch S146 thoát nhờ đối-chiếu Python.
- **Tự bắt 2 lỗi của CHÍNH mình trước khi báo:** "Mig 47/50/42/46 MISS" = artifact chuẩn-hoá của tao (`Mig42` liền không dấu cách, ĐỦ 2 phía) · context-regex `.{70}` trả RỖNG đọc nhầm thành sạch phải `.{0,70}`. **LUẬT: mọi MISS phải literal-grep XÁC-NHẬN 2 phía bằng công-cụ THỨ HAI trước khi vào report.**
- D re-ground từ đĩa: `docs/gotchas.md` count=83/max=83 0 nhãn `gotcha #N>83`; soi NGƯỢC 24 dòng gotcha 83 tìm mirror-error (run-number đội lốt gotcha) 0 ca, mọi #N đều gotcha thật trong ngữ-cảnh nguồn. Tag `[s152, d2-fidelity-gate, coverage-diff, extractor-ext-blindspot, thuoc-hong-x3]`
<!-- ===== @S155-curate (2026-07-27) moved from MEMORY.md L1 ===== -->
- **S155 (07-27) DIFF-review đợt 1 tiền-DEPLOY (PE delete-approver, 14 FLAG 4H/6M/3L/1I) — DEPLOY-CÓ-RỦI-RO:** 🔴 **class MỚI `HIGH nằm ở git chứ không ở mã`** — 2/3 file migration UNTRACKED (`?? …AddPeAllowApproverDelete.{cs,Designer.cs}`) trong khi **snapshot đã tracked**`git commit -a`/`add -u` nạp model-có-cột mà bỏ migration ⇒ `DbInitializer.cs:64 MigrateAsync()` không thấy gì để áp ⇒ `Invalid column name` cho **cả 7 module** dùng schema V2. Build+test đều xanh, local đã áp mig ⇒ **0 dấu vết**. **LUẬT: gate deploy phải chấm `git status ^??` cho `Migrations/`, không chỉ chấm diff.** · **H `ExecuteDelete` ngoài transaction TRƯỚC `SaveChanges`** = mất chữ ký không hoàn tác (đếm được **4 nguồn ném** nằm giữa 2 mốc ⇒ đường nổ có thật, không giả định) · **H purge xoá CỨNG chữ ký của phiếu mới XOÁ MỀM** ⇒ phá thẳng "khôi phục phiếu" của chính đợt 2; và **test `:607-609` đang KHOÁ chiều ngược** (`Should().Be(0)`) ⇒ cảnh báo trước cho wave vá: đỏ ≠ hồi quy, **cấm nới assert** · **H ~600 dòng BE = mã chết**: `grep api.put` = **0 hit**, Designer chỉ POST ⇒ đúng mục tiêu §F.0 (cứu phiếu đang treo) KHÔNG dùng được trong UAT.
- **XANH-GIẢ bắt được bằng phép 2-thế-giới:** `F16_…NoPeChangelog` assert `PurchaseEvaluationChangelogs.Count==0` nhưng test seed **0 phiếu PE** ⇒ còn-gate và bỏ-gate **cùng cho 0** ⇒ phép đo không phân biệt được 2 thế giới = 0 thông tin. **Cách bắt: truy MỌI write-site của bảng được assert, rồi hỏi "bỏ guard đi thì quan sát có đổi không?"**
- **CLEAN có răng (không "đọc thấy ổn"):** bất-biến Version/IsActive/Id chứng bằng **liệt kê vét cạn** `grep "def\.[A-Za-z]* *="` = đúng **3** write (Name/Description/UpdatedAt) **+ sweep gián-tiếp** (`= def;`/`SetValues`/`Entry(`/`CurrentValues`) = 0 hit ghi (hit duy nhất là READ trong string nội suy) — đúng lớp gotcha #81-EXT · **11 đường lách thử, tắc cả 11** · double-insert `DbSet.Add`+`nav.Add` = 1 entity, **và đã có test bắt nếu sai** (F12 assert đúng 2 row) · bán-kính đo NGƯỢC từ config: `grep OnDelete` ra **đúng 7** FK Restrict = khớp 7 bảng handler quét.
- **2 lỗi CỦA CHÍNH TAO tự bắt:** (i) lần đầu trích line-number **từ diff** thay vì từ đĩa ⇒ sai toàn bộ, phải re-grep sửa lại (**LUẬT: `git diff` không mang số dòng file — mọi anchor phải grep từ đĩa**); (ii) `ls | grep -v Snapshot` **ăn nhầm** `AddPeApprovedBudgetSnapshot` ⇒ tưởng thiếu file migration — thoát nhờ đối chiếu `__EFMigrationsHistory` (S152 luật "MISS phải xác nhận bằng công-cụ THỨ HAI" ăn thật lần nữa).
- **File bị sửa TRONG LÚC soi** (mtime lệch 71s — wave vá song song): anchor ≤`:659` giữ nguyên, sau `:660` trôi **+21**. **LUẬT: review file đang-được-sửa ⇒ ghi bản-đồ-trôi + neo bằng NỘI DUNG DÒNG, đừng chỉ số.** Tag `[s155, diff-review-pre-deploy, untracked-migration-gate, executedelete-no-tx, vacuous-test-two-worlds, anchor-drift]`
- **S155 (07-27) SPEC-review tiền-hmw (PE xoá phiếu ở màn duyệt + lệnh Update workflow) — PASS-WITH-FLAGS 4H/8M/5L; spec trích-dẫn SẠCH nhưng 4 lỗ chí-mạng:** 🔴 **H1 class MỚI `nới-authz-mà-không-ai-có-quyền`** — spec gắn `[Authorize(Policy="PurchaseEvaluations.Delete")]` lên endpoint đang **KHÔNG có policy**; sqlcmd Dev đo: CanDelete=1 chỉ `Admin`+`DeptManager`, **11/13 role = 0** (`DbInitializer.cs:2515` `canDelete = roleName==DeptManager`, seeder-2 chỉ nâng Read/Create + skip-if-exists ⇒ prod không tự vá) ⇒ vừa **regression** (Drafter mất xoá-nháp đang chạy) vừa **feature chết** (Procurement 403). `MenuPermissionHandler.cs:41-51` khớp MenuKey **CHÍNH XÁC, 0 kế-thừa** — inheritance của `GetMyMenuTreeQuery` chỉ là display. **LUẬT: đóng API = phải đo bảng `Permissions` xem AI còn qua được; test-403 một-chiều luôn xanh-giả.** · **H3** `IgnoreQueryFilters()` **gỡ** filter chứ không đảo ⇒ list "Đã xoá" thiếu `.Where(IsDeleted)` + thiếu tái-lập IDOR `:596-617` = lộ toàn hệ. · **H2** cờ trên `ApprovalWorkflowLevel` = **1 row = 1 NGƯỜI**, repo có SẴN 2 idiom cho cờ anh-em F5 (`Service:859` per-row ⟂ `Features:1182` `g.Any()` per-Cấp) ⇒ "per-Cấp" trong spec = N người xoá được (owner muốn 1). · **H4** lệnh Update workflow không khai policy mà chính nó là **cổng cấp quyền** (thêm-người + bật-cờ). · **M1 phản-ví-dụ cho luật "AN TOÀN ⟺ giữ 2 tập Order"**: cờ `AllowApproverFinalize` đổi ĐIỂM KẾT THÚC phiếu đang chờ (`:859-878`) · `pendingLevelGroup.First()` (`:733-734`, tie **không có tie-breaker**) → thêm/bớt row cùng Order đổi slot chữ-ký Admin · thêm người vào Cấp ĐÃ QUA → `ComputeLevelStatus` thuần-con-trỏ (`:1135-1148`) hiện "Done" dù chưa ký. **⇒ con-trỏ-không-dịch ≠ phiếu-không-hỏng.** · **M3** soft-delete ⇒ cascade không chạy ⇒ `LevelOpinion` mồ-côi vẫn Restrict Level ⇒ nợ hoãn (N1) **ăn thẳng** vào luật mới F-5 cùng spec. · **M4** phiếu ChoDuyet chỉ vào **2/4** phép cộng ⇒ test "cả 4 giảm" bất-khả-xanh. · **M6** thêm key `Pe_*` **KHÔNG** đổi `Policies`/`Menu keys` (derived từ `MenuKeys.All`, `Pe_*` sinh factory) ⇒ spec bảo sửa số canonical = hỏng detector. **Cách phá F.0 (6 đường, tắc cả 6)** ⇒ mệnh-đề trung-tâm ĐỨNG, chỉ tiêu-đề rộng quá. Tag `[s155, spec-review-pre-impl, authz-widen-nobody-has-it, ignorequeryfilters-not-inverted, order-set-rule-underfit]`
- **[verbatim → git 8b6df0a closeout] S146 (07-22) PRE-COMMIT spec-v2-applied (governance 20 file) — 2 MAJOR THẬT (cả hai của LEAD); nấc `verified` tự-chạy-lệnh không-tin-lead:** F1 `session-end.md` còn `hook-3-path` GẠCH-NỐI dù worker CE đã chỉ đích-danh (class "residual có người chỉ tận nơi vẫn rớt") + bất-đối-xứng nội-bộ (`pause.md` đã 4-path). F2 phép quét cardinality của lead siết LỆCH-TRỤC (co-occurrence CÙNG-DÒNG + 1 mẫu-chữ) → re-run HEAD tiền-vá lọt 4 ca thật gồm chính F1. F3 HELD (git show da7ee8a: hook 4-path + `-File` re-đọc mỗi Stop). 🔴 Bài rút: lead sửa 1 phép quét 4 lần/phiên vì mã-hoá ý-định NGỮ-NGHĨA vào regex; nấc đúng = lưới-soát-cho-người + allowlist CÓ TÊN + use⟂mention; lần-4 = citation-trap (dò bắt chính nốt-vá vì nó trích giá-trị cũ).
- **S147 (07-23) DIAGNOSIS-review PE attach TraLai (no diff — verify root-cause + stress fix) — Diagnosis PASS 5/5, fix CORE-safe/EXT-unsafe:** 5 claims verified from disk; BE-no-guard CONFIRMED **intentional** by S78 changelog L9 ("handler KHÔNG guard drafter-only → approver upload no-403") + sole pipeline behavior=ValidationBehavior. 🔴 **MAJOR blind-spot:** `attachEditable=isDrafter&&isEditablePhase` SAFE for GeneralAttachmentsSection (:2013 readOnly=raw, no carve-out) but UNSAFE for SupplierAttachmentsCell (:2758) whose readOnly=**itemsReadOnly** (`readOnly&&!approverEditMode`, Mig28-F3 ChoDuyet approver-edit) → naive REPLACE breaks approver QuoteDocument edit in ChoDuyet; SAME gap in optional BE guard (carve-out {ChoDuyet+ApprovalAttachment} misses ChoDuyet+QuoteDocument). **Scope trap:** isDrafter@:140 in MAIN scope, OUT-of-scope at :2013/:2758 (child comps take only {ev,readOnly}); changing :421 over-broadly hits HoSoLinkRow:2017. **Completeness:** WORKSPACE (readOnly=false, picker editableOnly∋TraLai) already edits comparison-doc in TraLai → bug is LIST-detail-only (:574/:669 hardcode). LESSON: **decouple a gate → trace if shared prop already carries a carve-out (itemsReadOnly ≠ raw readOnly); "same fix" for sibling surface may hit a DIFFERENT gate source.**
## S152 (2026-07-26) — D2 Fidelity-gate 6 gist "CỤM BỔ SUNG" (coverage-diff) — **PASS_WITH_FIXES** (A/B/C/D PASS · E FAIL)
- **Census thay spot:** 307/307 trích có trong verbatim CẶP (275 exact + 32 sau khi hoàn `'``"`), 307/307 chứa ĐÚNG token gắn nhãn, 307/307 token thật-sự VẮNG khỏi gist tiền-append (0 gap thổi-phồng). `ref`=46 khớp đúng claim lead. Trích = cửa-sổ ~150 ký-tự cố-định, token ở ~27% → cắt giữa từ ở 2 biên nhưng payload (file:line/finding) còn nguyên ⇒ điều-hướng được, KHÔNG rác.
- 🔴 **E FAIL — class-gap hệ-thống, chữ-ký sạch:** máy-trích emit file-ref CHỈ cho `{.cs 101/.tsx 45/.ts 16/.ps1 8}` = 170 token, **ZERO cho `{.yml,.json,.slnx,.csproj,.sql,.config}`** ⇒ 13 token infra vắng ở 5/6 cặp. Nặng nhất: `deploy.yml` (chính file pipeline, trong gist của vai CI/CD!) · `appsettings.Production.json` (16 lần trong verbatim) · 2× `s59-*.sql` (chạy TAY prod ngoài pipeline) · `memory-budget.json` (single-source canonical CLAUDE.md). Header cụm TỰ đặt chuẩn "file-ref phải sống trong gist" mà KHÔNG khai N/A ⇒ trượt bằng chính thước mình.
- 🔴 **3 THƯỚC-HỎNG (bug ở phép đo, không ở vật):** (1) md5 disk vs `git show` — spec gate ghi vậy — báo FAIL GIẢ 3/6 verbatim do worktree CRLF ⟂ blob LF (autocrlf=true + 188 file legacy `i/lf w/crlf`); đúng phải `git diff HEAD --quiet` hoặc strip-CR 2 phía. (2) `grep -c distill-gen` vô-dụng vì CHÍNH header cụm chứa chữ "distill-gen giữ" (citation-trap nhẹ) → phải so VALUE `distill-gen:\s*\d+`. (3) `grep -iF` trên MSYS trả **0-hit im-lặng** cho pattern ASCII trong file UTF-8 (`Mig42`: `-F`=1 nhưng `-iF`=0) = bẫy vắng-mặt-trông-giống-sạch S146 — thoát nhờ đối-chiếu Python.
- **Tự bắt 2 lỗi của CHÍNH mình trước khi báo:** "Mig 47/50/42/46 MISS" = artifact chuẩn-hoá của tao (`Mig42` liền không dấu cách, có ĐỦ 2 phía) · context-regex `.{70}` trả RỖNG đọc nhầm thành sạch → phải `.{0,70}`. ⇒ **LUẬT: mọi MISS phải literal-grep XÁC-NHẬN 2 phía bằng công-cụ THỨ HAI trước khi vào report.**
- D re-ground từ đĩa: `docs/gotchas.md` count=83/max=83 ⇒ 0 nhãn `gotcha #N>83`; soi NGƯỢC 24 dòng gotcha ≤83 tìm mirror-error (run-number đội lốt gotcha) → 0 ca, mọi #N đều là gotcha thật trong ngữ-cảnh nguồn. Tag `[s152, d2-fidelity-gate, coverage-diff, extractor-ext-blindspot, thuoc-hong-x3]`

View File

@ -1,4 +1,4 @@
{
{
"_note": "Harness-24 (S121, 2026-07-15, wave adap-6-broadcast W2) session counter for lead-self-audit cadence. THIS FILE HOLDS STATE ONLY - every cadence NUMBER lives in .claude/agent-memory/memory-budget.json -\u003e h24_cadence (single-source, H24 section 2(3)). Never copy the digits here.",
"_why_content_based_not_blind_increment": "Owner-decisions-15-07-2026 Q2, trap found by lane E: the Stop-hook .claude/hooks/wal-flush.ps1 auto-commits on EVERY turn-boundary. A blind counter++ would therefore tick many times per real session and the cadence would be meaningless. So a tick is CONTENT-ADDRESSED: it is keyed on the repo HEAD sha at tick time. Ticking twice on the same HEAD is a NO-OP, which is what makes running /session-start twice in a row safe (acceptance: 2x /session-start on the same HEAD =\u003e +1, not +2).",
"_why_this_path": "Deliberately .claude/governance/ and NOT .claude/agent-memory/. The hook covers exactly four paths (.claude/WAL.md, agent-memory/, workflows/runs/, sessions/ - the 4th added S146 together with the session-model logic-session folder); .claude/governance/ is OUTSIDE all four, so this file is never swept into a \u0027wal:\u0027 commit as a side effect. Compare .claude/agent-memory/.mfe-state.json, which IS inside the hook\u0027s reach - a counter must not live there. RE-VERIFIED at S146 when the 4th path landed: the claim is about the DIRECTORY, and .claude/sessions/ != .claude/governance/, so widening the hook did NOT invalidate it - only the count changed. Any future widening of wal-flush.ps1 MUST re-check this sentence, not just bump the number.",
@ -11,9 +11,9 @@
},
"_tick_invariant_note": "Tick invariant (hub dede7ec5 Delta-1, verbatim): moi LAN-CHOT +1; mot cap dung-noi tang DUNG +1, khong +2, khong +0. SE form = tick-at-entry-gate idempotent-per-label (hub Delta-2 recovery-gate +1 = permitted form) - NET +1/session-label EQUIV hub +1/cap on CLOSED pairs; an OPEN pair is transiently +0 until its entry gate fires (hub blessed, 9a35405b block-1 phep dung-noi). Guard song-con = label-convention (session-start 2.1.8: new conversation = new S\u003cnn\u003e label, NEVER reuse). history[] is append-unbounded BY DESIGN =\u003e absence of a marker = never-happened (safe semantics); IF a FIFO cap is ever added, eviction MUST be handled explicitly (absence-vi-bi-day != absence-vi-chua-xay-ra - log-BOUNDED design-note dede7ec5).",
"_seed_honesty": "Seeded UNTICKED on purpose. counter=0 and last_ticked_* = null mean \u0027no tick has ever happened\u0027, which is the truth at S121 - the ritual that performs the tick lands in W3. Seeding a fake first tick here would make the very first cadence reading a lie, and H24 exists to catch exactly that kind of invented number.",
"counter": 27,
"last_ticked_session": "S153",
"last_ticked_head": "1b7386d96978733df703bbd617cc31ae17467dd8",
"counter": 28,
"last_ticked_session": "S154",
"last_ticked_head": "871ac0a619b54a58ddd7130d2948dccd5e6c58ea",
"last_ticked_at": "2026-07-26",
"last_audit": {
"light_at_counter": 27,
@ -141,6 +141,11 @@
"at": "2026-07-26",
"session": "S153",
"event": "squash-benign (session-counter-tick.ps1 M2, contract fail_loud_on_regress trigger-2 BENIGN branch): counter 26-\u003e27, session S152-\u003eS153, head 3058470-\u003e1b7386d. last_ticked_head 3058470 object EXISTS (cat-file=commit) but NOT reachable (merge-base --is-ancestor exit!=0), counter not regressed =\u003e a closeout squash lifted the ticked wal:/session commit out of history (expected drift, not tamper). Trace appended, continue, no owner alarm. Written atomically (temp + Move-Item -Force)."
},
{
"at": "2026-07-26",
"session": "S154",
"event": "squash-benign (session-counter-tick.ps1 M2, contract fail_loud_on_regress trigger-2 BENIGN branch): counter 27->28, session S153->S154, head 1b7386d->871ac0a. last_ticked_head 1b7386d object EXISTS (cat-file=commit) but NOT reachable (merge-base --is-ancestor exit!=0), counter not regressed => a closeout squash lifted the ticked wal:/session commit out of history (expected drift, not tamper). Trace appended, continue, no owner alarm. Written atomically (temp + Move-Item -Force)."
}
]
}

View File

@ -0,0 +1,99 @@
# _context-s-7 — SESSION-MODEL narrative durable
<!-- SCAFFOLD-META: sID=7 | ts-moc=2026-07-26T16:16:45+07:00 | nguon-ts=git log -1 --format=%cI @ HEAD 871ac0a | generator=scripts/session-scaffold.ps1 -->
> **sID (LOGIC-session) = 7.** 1 phiên-logic (bootstrap → work → sweep) trải nhiều window vật-lý nối bằng `/pause`+`/tiep`. Window = 0-ID governance; **sID = đơn-vị LOGIC**. Folder pin: `.claude/sessions/session-7/`.
> ts-mốc khởi-tạo = **2026-07-26T16:16:45+07:00** (nguồn: `git log -1 --format=%cI @ HEAD 871ac0a`) — mốc git-committer, KHÔNG phải wall-clock tuỳ-tiện.
>
> 🔴 **CẤM rewrite / tự-tóm block FLOW** (chống self-summary lossy). FLOW = append-only @`/pause`-only; vượt cap `session_ctx_kb` → distill SECTION CŨ thành pointer/gist, TUYỆT ĐỐI KHÔNG sửa entry verbatim đã ghi.
---
## (a) STOCK-map — reference-not-copy
> Bảng pointer tới nguồn durable. **KHÔNG copy nội-dung** (chống drift). Điền @bootstrap; row session-specific append dưới các row stable.
| Tên | Path | 1-dòng |
|---|---|---|
| WAL | `.claude/WAL.md` | mạch-việc-dở máy-state (H-22) — nối bằng `/tiep` |
| AI-context | `CLAUDE.md` | AI agent context SOLUTION_ERP (scope + quick-rules) |
| Trạng-thái | `docs/STATUS.md` | canonical mọi con-số (mig · bảng · test · roster) |
| Bàn-giao | `docs/HANDOFF.md` | brief 5 phút + bảng số chờ-anh + carry |
| Sổ đếm H24 | `.claude/governance/.session-counter.json` | counter + nhịp lead-self-audit |
| Thư cross-project | `broadcasts/_index.md` | INBOUND directed (`outbox/se`) — fan-out `outbox/all` KHÔNG vào đây |
| _(session-specific)_ | _(path)_ | _(1-dòng — append @bootstrap phiên này)_ |
---
## (b) FLOW — append-only @pause-only
> 🔴 **CẤM rewrite FLOW.** Mỗi `/pause` APPEND đúng 1 entry theo SCHEMA dưới (đặt DƯỚI dòng `FLOW-START`). Entry cũ = immutable. FLOW = SOURCE durable của "đang-đến-đâu"; bảng-đầu-việc = VIEW derive từ đây (single-source).
<!-- ENTRY-SCHEMA (guidance, KHONG phai entry that — /pause command dien; parser real-entry match `^### PAUSE-\d+`, placeholder `<k>` khong false-match):
### PAUSE-<k> <ts ISO-8601>
> anh: <tin-nhan anh VERBATIM — copy nguyen-van, KHONG paraphrase>
**(1) quyet-dinh da CHOT** / **(2) delta con SONG** / **(3) con-tro**
```json
{ "machine_block": "python scripts/session_ctx.py machine-block --session <N> --json" }
```
-->
<!-- FLOW-START — entries append bên dưới dòng này, mới nhất ở CUỐI -->
### PAUSE-1 2026-07-26T19:42:22+07:00
> anh: /pause
**(1) quyết-định đã CHỐT**
- Chạy trọn bootstrap L7 theo hình B: bookend @open 6 vai + trio AUTO 3 nấc + Phase 3.5 — **0 cổng consent** (owner @S151/@S152 "vòng 1→5 auto").
- **KHÔNG vá FLAG nào trong cửa này** — 2/4 FLAG HIGH nằm đúng vùng (42)(43) đang chờ anh; vá trước = tự khoá đường anh chọn.
- **KHÔNG ghi memory trong cửa-sổ đo** (Mirror C1) — `User memory` count đang LÀ vật bị đo; xếp hàng sang closeout.
- Trình anh **3 số mới (42)(43)(44)**; **(39) RAG DOWN tự đóng** (đo tươi: alive, chunk 2449, rerank sống).
- Nhận **2/4 FLAG** của `ctx-audit` + **5/5 điểm SỬA** của `ctx-curator` (verdict `SUA — 5 điểm`, lượt chạy đầu đời của vai).
**(2) delta còn SỐNG**
- **22 FLAG/finding còn sống** (23 raw 1 bác) chưa disposition — chặn bởi (42)(43)(44).
- 3 ESCALATE trio: E1 `cicd/archive/_INDEX.md` → cửa **lead** · E2 `harvest-curator/MEMORY.md` tái-phình 19.456B=111,1% → cửa **owner** · E3 N1+N2@S152 chưa land.
- `measured{}` thiếu **6 row** (3 ctx + 3 ring) ⇒ phủ 17/23 = 73,9%.
- Nợ @close: bump `STATUS:6` counter 27→**28**; ghi memory 2 datum (#53 ổn-định-theo-vai · row-ngoài-cross-check-drift); mồi-ngầm `_mind-s-7.md:99` chờ `ctx-audit` chấm.
- 🔴 Lead sai **4 lần**, cả 4 ở chỗ **không có máy canh** (1 do máy bắt · 2 do vai bắt · 1 lộ ở cửa dừng). Chi tiết + neo: `_mind-s-7.md` block MIND-1 mục C.
- `thu-moi: se=0 all=0` (2 thư mới nhất `9c909007`/`0df10df4` đã adopt @S152/@S153; `outbox/all`=0).
**(3) con-trỏ**
- run-folder: `.claude/workflows/runs/2026-07-26-S154-bookend-open/` — 12 file (6 sub bookend + 3 trio-return + `sub-ctx-audit` + 2 synthesis + `run.md`)
- synthesis: `bookend-open-synthesis.md` · `trio-synthesis.md`
- lớp mềm: `.claude/sessions/session-7/_mind-s-7.md` (MIND-0 + MIND-1)
- **verdict `ctx-curator` @PAUSE-1:** `CTX-CURATOR: SUA — 5 điểm` — 5 điểm SỬA (D lật quá đà · mất neo · A co mẫu-số 4→2 · "20 FLAG" không khai cơ-sở · paraphrase quyết-định anh) + 3 mục ĐẠT (khoản-1, khoản-2 con-trỏ hợp lệ, khoản-5 ý guard). Lead sửa **cả 5** rồi mới chèn MIND-1; 0 tệp vai ghi, 0 commit vai.
---
## (c) STOCK-touched — máy-derive
> Bảng file-đã-đụng trong phiên, **máy-derive** từ `git diff --name-only <anchor>..HEAD` @pause/snapshot. KHÔNG điền tay.
> Máy-derive @PAUSE-1: `python scripts/session_ctx.py machine-block --session 7 --json` · anchor = `[CLAUDE] Docs: S153 closeout-bis …` @2026-07-26T16:16:45+07:00 · `changed_count = 17` · `run_id = null`.
> 🔸 **Khai giới-hạn (nguyên văn nghi-thức):** `changed_files` = `git diff anchor..HEAD` ⇒ **chỉ phần ĐÃ COMMIT**; việc còn dirty không hiện. Đừng đọc thành "toàn bộ việc đã làm".
| File | Δ | Ghi-chú |
|---|---|---|
| `.claude/agent-memory/.mfe-state.json` | M | `harness-eval` ghi by-design (`mfe-eval.ps1`), đúng ngoại-lệ Mirror C1 |
| `.claude/agent-memory/ctx-audit/MEMORY.md` | M | lead seed diary S154 · 3.288→5.768 B |
| `.claude/sessions/session-7/_context-s-7.md` | M | scaffold + entry `PAUSE-1` (file này) |
| `.claude/sessions/session-7/_mind-s-7.md` | M | lớp mềm: MIND-0 @open + MIND-1 @pause · 20.474 B |
| `runs/2026-07-26-S154-bookend-open/run.md` | A | sổ chạy bookend |
| `…/sub-tooling-auditor-open-S154.md` | A | H1 · END `TOTAL=3 FINDING` |
| `…/sub-harvest-curator-open-S154.md` | A | H2 · END `TOTAL=6 FINDING` |
| `…/sub-lead-stale-open-S154.md` | A | H24 vai-STALE · END `TOTAL=5 FLAG` |
| `…/sub-lead-gap-open-S154.md` | A | H24 vai-GAP · END `TOTAL=5 FLAG` |
| `…/sub-ring1-open-S154.md` | A | KIỂM V1 · END `TOTAL=53 CLAIM` (46Đ/2T/5KC) |
| `…/sub-ring2-open-S154.md` | A | KIỂM V2 · END `TOTAL=19 CLAIM` (17Đ/1T/1KC) |
| `…/harness-eval-return.md` | A | trio 1/3 · END `TOTAL=12 SIGNAL · VERDICT=MIXED` |
| `…/harness-refine-return.md` | A | trio 2/3 · END `TOTAL=3 ACTION · BÁC=8` |
| `…/harness-audit-return.md` | A | trio 3/3 · END `TOTAL=78 CLAIM` (68Đ/8T) |
| `…/sub-ctx-audit-open-S154.md` | A | vòng Ctx vai-3 · END `TOTAL=12 DIEM` |
| `…/bookend-open-synthesis.md` | A | lead-written |
| `…/trio-synthesis.md` | A | lead-written |

View File

@ -0,0 +1,154 @@
# _mind-s-7 — SESSION-MODEL lớp MỀM (trí-nhớ suy-nghĩ 4 tầng)
<!-- SCAFFOLD-META: sID=7 | ts-moc=2026-07-26T19:12:14+07:00 | nguon-ts=git log -1 --format=%cI @ HEAD 8776ca8 | render=lead Write @session-start Phase 3.5 (session-scaffold.ps1 KHONG render tep nay) -->
<!-- MIND-RULES-START -->
> **sID (LOGIC-session) = 7.** Tệp này giữ 4 thứ KHÔNG nằm ở WAL / HANDOFF / `_context`: **(A)** gói-turn · **(B)** hướng-tiếp · **(C)** kế-hoạch delta-suy-nghĩ · **(D)** đang-thảo-luận (+ **(E)** dòng-sống spawn). Nhà: `.claude/sessions/session-7/_mind-s-7.md`.
> ts-mốc khởi-tạo = **2026-07-26T19:12:14+07:00** (nguồn: `git log -1 --format=%cI @ HEAD 8776ca8`) — mốc git-committer, KHÔNG phải wall-clock tuỳ-tiện.
>
> **GHI = lead (single-writer)** @4 nhịp: block-0 cuối bootstrap · refresh @`/pause` · đọc + đối-chiếu @`/tiep` · mục E gom MỌI spawn/engine-run. **KIỂM = máy + vai** (tách khỏi ghi): `python scripts/session_ctx.py mind-check --session 7` mỗi cửa (~$0) + vai `ctx-audit` @2 bookend (INFORM-only, quyền phán TRƯỢT).
> **Trần tệp** = khoá `mind_ctx_kb` trong `.claude/agent-memory/memory-budget.json` — **B1: trỏ khoá, CẤM chép số vào tệp này.**
### 🔴 CHIỀU CHÈN — NGƯỢC với `_context` (đọc trước khi chèn block đầu tiên)
- **`_mind` = MỚI-NHẤT-Ở-TRÊN.** Block mới chèn NGAY DƯỚI **dòng marker điểm-chèn** (dòng comment nằm ngay trên block top, dưới khối luật này).
- **3 dòng comment marker của tệp** (mở khối luật · đóng khối luật · điểm-chèn) xuất hiện **ĐÚNG 1 LẦN** mỗi cái. Vì vậy phần chữ ở dưới CHỈ gọi chúng bằng **mô-tả**, KHÔNG viết lại tên marker — máy/người tìm-chuỗi sẽ không bắt nhầm anchor rồi chèn vào giữa khối luật. Máy khớp anchor bằng **token** trên dòng bắt đầu `<!--` (KHÔNG kèm `-->`, vì marker có chú-thích viết cùng dòng — mirror `FLOW-START` của `session-context-template.md`).
- **`_context` FLOW = MỚI-NHẤT-Ở-CUỐI** (`.claude/templates/session-context-template.md`, dòng `FLOW-START`). **HAI TỆP NGƯỢC CHIỀU NHAU** — CẤM "adopt verbatim" khuôn bên kia sang đây. Lý-do: `_mind` đọc để **nạp lại trạng-thái** ⇒ thứ mới nhất phải ở màn hình đầu; `_context`**biên-niên-sử durable** ⇒ đọc xuôi thời-gian.
- **Số hiệu = ĐẾM số block hiện có TRƯỚC KHI chèn** (block-0 chiếm số 0 ⇒ đang có n block thì block mới là `MIND-n`). Máy chỉ đòi *số duy-nhất* + *block top ≥ mọi số dưới* — KHÔNG neo công-thức đếm, nhà đổi công-thức vẫn kiểm được.
- **Block cũ BẤT BIẾN** (append-only, không rewrite / không tự-tóm). **Ngoại lệ DUY NHẤT**: nén khi vượt trần — nén block CŨ NHẤT **trừ block-0 và block top**, verbatim vẫn còn trong git; **<3 block TỪ-CHỐI nén**, giương cờ vượt trần (nợ hiển thị, không xoá chứng-nhân).
- **Việc ĐÃ-CHỐT chỉ con-trỏ** (`.claude/WAL.md` · `docs/HANDOFF.md` · `.claude/workflows/runs/<id>/…`) **CẤM chép nội-dung** (2-sự-thật + phình trần).
- **Mục trống ghi đúng chuỗi `(trống — khai)`** trên 1 dòng riêng, KHÔNG thêm chữ, KHÔNG bọc dấu nhấn (máy so chuỗi). CẤM bỏ heading vắng-mặt trông giống ổn.
- **5 heading trong mỗi block = `### A.` `### E.`** (thứ tự AE cố định) đây hợp-đồng hình-dạng cho máy `mind-check` vai `ctx-audit`.
- **Mục D: mỗi ý 1 dòng + BẮT BUỘC nhãn**, viết **đúng chuỗi CÓ DẤU**: `{mới-nêu | đang-cãi | gần-chốt | treo-chờ-anh}`. Máy so chuỗi viết không dấu = **trượt-cấu-trúc**. (Phần chữ hướng-dẫn trong khối luật này theo khuôn nhà thể không dấu, nhưng **token máy đọc thì giữ dấu** đừng chép ngược.)
### 3 RÀO NỘI-DUNG (máy `mind-check` phép 1-3 — mỗi rào kèm LÝ-DO ĐO ĐƯỢC)
1. **Rào-1 — CẤM verbatim lời anh.** Dạng bị bắt = dòng blockquote mở đầu bằng `anh` + hai chấm, ví-dụ:
> anh: <lời anh nguyên-văn — dòng NÀY hợp-lệ vì nằm TRONG khối luật, xem mục enclosure>
*Lý-do đo được:* verbatim lời anh **nguồn-duy-nhất** = `_context` FLOW (`### PAUSE-<k>` dòng quote). `_mind` lớp mềm bị nén/diễn-giải mỗi cửa chép verbatim vào đây tạo bản-thứ-2, khi 2 bản lệch thì bản mềm (đọc trước, giàu ngữ-cảnh hơn) sẽ thắng bản durable. Trong `_mind` chỉ được TRỎ: `xem PAUSE-<k>`.
2. **Rào-2 — CẤM khoá `ts:` ở ĐẦU DÒNG** (mọi biến thể: thụt space/tab, hoa/thường, khoảng trắng trước dấu hai chấm). Mốc thời-gian sống **trong dòng heading, sau em-dash**: `## MIND-<k> — <ISO-8601> @ <HEAD>`.
*Lý-do đo được:* marker nhà (`_pause-*` · `_tiep-*` · `_snapshot-*`) mang khoá đó đầu dòng **by-design**; reader-mốc TƯƠNG-LAI (gap C10-LATENESS của `scripts/governance-detectors.ps1` C10 tự khai "no reliable source" cho ts-compare) sẽ quét cả `session-<N>/_*` **không lọc theo tên tệp** một dòng khoá trong `_mind` đủ để lấy sai "mốc gần nhất". Regex canonical = `_TS_KEY_RE` trong `scripts/session_ctx.py`; **CẤM cài bản thứ 2** bất kỳ máy nào (2-sự-thật chính thứ rào này chặn).
3. **Rào-3 — CẤM secret, KỂ CẢ dưới dạng ví-dụ.** Tập tiền-tố cấm = `SECRET_PATTERNS` trong `scripts/session_ctx.py` **trỏ nguồn, KHÔNG chép mẫu vào tệp này**. Ngoại lệ DUY NHẤT: nhãn đã che `[đã che <8-hex>]`. Bắt buộc quét NHÁP **trước** khi chèn: `mind-check --session 7 --draft <tệp-nháp-scratchpad>`.
*Lý-do đo được, 2 tầng:* (i) block BẤT BIẾN secret lọt **vĩnh viễn** (còn trong git xoá sau); (ii) **enclosure KHÔNG đối-xứng giữa 2 lưới**: `mind-check` chỉ soi DƯỚI marker đóng khối luật, còn `secrets-sweep` (cùng `session_ctx.py`, chạy auto-snapshot mỗi `/pause`) **rglob TRỌN `session-<N>/` và không biết khối luật này** một mẫu secret viết làm ví-dụ ngay tại đây sẽ **chặn commit ở MỌI cửa pause**. thế rào-2 rào-3 trên **cố ý KHÔNG có ví-dụ literal**; chỉ rào-1 , rào-1 chỉ `mind-check` đọc enclosure-aware.
### Enclosure use ⟂ mention
Máy chỉ quét phần **DƯỚI** marker đóng khối luật (dòng comment cuối cùng của phần luật này). Khối luật được phép chứa chuỗi bị cấm để **giải thích** (citation-trap: doc định-nghĩa anti-pattern tất-yếu chứa chính mẫu đó). Hệ quả 2 chiều: **đừng** dịch ví-dụ xuống dưới marker đóng, **đừng** viết nội-dung thật lên trên .
<!-- BLOCK-SCHEMA (guidance, KHONG phai block that /pause dien; parser real-block match `^## MIND-\d+`, placeholder `<k>` khong false-match).
Prose huong-dan duoi day khong dau theo khuon nha; TOKEN may so chuoi thi GIU DAU: nhan muc D + chuoi muc trong `(trống — khai)`.
## MIND-<k> — <ISO-8601> @ <HEAD ngan> (window <w>)
### A. Goi-turn
<1-3 gach dau dong: turn vua roi lam gi, ket qua gi GOI lai, khong ke lai>
### B. Huong-tiep + nhanh-da-loai
<di dau tiep VA nhanh nao da LOAI + vi sao loai (chong dao lai vong cu)>
### C. Ke-hoach (delta suy-nghi)
<phan SUY-NGHI doi so lan truoc CAM chep checklist/todo (cho o WAL/HANDOFF)>
### D. Dang-thao-luan
<moi y 1 dong + nhan CO DAU: {mới-nêu | đang-cãi | gần-chốt | treo-chờ-anh}>
### E. Dong-song spawn/engine-run
<1 dong / spawn hoac engine-run: <vai><verdict><con-tro path (run-folder / sub-file / commit)>>
<muc trong dung chuoi: (trống khai)>
-->
<!-- MIND-RULES-END -->
<!-- diem-chen: block MOI chen NGAY DUOI dong marker duoi day (moi-nhat-o-TREN -- NGUOC chieu _context FLOW).
Marker duoi day phai giu NGUYEN-VAN, KHONG viet chu-thich cung dong: may khop chuoi DAY-DU
(`_MIND_TOP_MARKER` trong scripts/session_ctx.py) -- khac khuon `FLOW-START` cua _context (khong may nao doc). -->
<!-- MIND-TOP -->
## MIND-1 — 2026-07-26T19:42:22+07:00 @ 5197ce2 (window 1)
### A. Gói-turn
- Khép trọn 3 khối bootstrap: bookend @open 6 vai → trio AUTO 3 nấc → Phase 3.5 (block-0 + `mind-check` 10/10 + `ctx-audit` lần chạy đầu đời). REPORT đã trình — xem `PAUSE-1`.
- Tổng thu: **23 FLAG/finding raw** (19 @open theo END-line 3+6+5+5, + 4 FLAG `ctx-audit`), **1 đã BÁC** (F-3 tooling) ⇒ **22 còn sống**; + 1 mệnh đề hệ-thống + 3 số xin anh quyết. **0 byte mất** qua **#53 ×11**.
- Vai `ctx-audit` ra **4 FLAG, CẢ 4 nhắm lead**; lead nhận **2/4**.
### B. Hướng-tiếp + nhánh-đã-loại
- Tiếp: chờ anh 3 số **(42) canonRows · (43) carry con-trỏ-vs-slug · (44) END-line thành luật**; nối lại bằng `/tiep`. Sau khi có số thì mới chạy disposition.
- Nhánh đã LOẠI: ① **vá FLAG trước khi anh chốt** — loại vì 2/4 FLAG HIGH nằm ĐÚNG vùng (42)(43) sắp quyết, vá trước là tự khoá đường anh chọn · ② **ghi memory ngay khi có datum** — loại suốt phiên vì `User memory` count đang LÀ vật bị đo (Mirror C1); nay cửa-sổ đo đã đóng nên hết lý do, chuyển sang hàng-đợi closeout · ③ **báo `thu-moi` bằng số đếm file inbox** — loại vì inbox là **tích luỹ** (64 file), không phải "thư mới".
### C. Kế-hoạch (delta suy-nghĩ)
- Đính chính ý D#4 của block-0 (block-0 bất-biến nên sửa ở đây): phép thử tự-soi mà S153 hẹn cho S154 **ĐÃ chạy và ĐẠT** — neo: `.claude/workflows/runs/2026-07-26-S154-bookend-open/sub-lead-stale-open-S154.md:20,27` (FLAG-2 HIGH, ca THẬT trên đúng 2 tệp tự-quy-chiếu) + `.claude/workflows/runs/2026-07-26-S154-bookend-open/sub-ring2-open-S154.md:63` (ring2 duyệt ĐẠT). 🔴 **NHƯNG KHÔNG suy ra "hết cần vòng KIỂM"** — M-1 cùng cửa-sổ (`sub-ring2-open-S154.md:133`, `STATUS:449 FE pages` 68↔66, **cả 2 con-đo đều im**) chứng vét-cạn vẫn hở.
- Nhận thức mới: **lead sai 4 lần trong phiên, cả 4 ở chỗ KHÔNG có máy canh**`grep '<!-- END'` bắt trích-dẫn · con-trỏ `_mind` thiếu tiền tố · kết luận D#4 ngược chiều · BƯỚC 0.9 đo `inbox/se|all` (2 path KHÔNG tồn tại, inbox tổ chức theo NGƯỜI GỬI) ⇒ đọc thành "0 thư". Chỉ cái thứ 2 bị MÁY bắt; 2 cái do VAI bắt; cái thứ 4 lộ ở cửa dừng. **Không cái nào lead tự thấy.**
- Ca #4 đáng ghi riêng: kết luận cuối **vẫn đúng** (2 thư mới nhất đã adopt @S152/S153, `outbox/all`=0) nhưng **đúng nhờ may, không nhờ phép đo** — đúng lớp lead soi người khác cả phiên.
- Đính chính chính bản vá của lead: discriminator END mà lead đề *"nằm ở dòng CUỐI"* sai ở rìa — `sub-harvest-curator` END `:289/290 dòng``sub-lead-gap` END `:229/230 dòng` đều có **1 dòng trống cuối**`tail -1` ra rỗng. Phát biểu đúng = **"dòng cuối KHÔNG-RỖNG"**.
- Nghi vấn để dành: guard `§2.1.9(f)` dùng `git status` — mù đúng 4 path Stop-hook. Đã dùng phép thay thế (`git log --name-only origin/main..HEAD -- <path>`) khi kiểm residual-write vào `_mind`, chạy đúng.
### D. Đang-thảo-luận
- (42) Row ngoài `$canonRows`: mở rộng máy canh hay bỏ số + trỏ nguồn sống {treo-chờ-anh}
- (43) Carry: con-trỏ / re-stamp đủ slug / đường-3 con-trỏ-cho-người + dòng slug-only-cho-máy {treo-chờ-anh}
- (44) Dòng `END … TOTAL=n` nâng thành luật — slot (33) treo từ S152, phiên này cứu 3 lần biên độ tới ×30 {treo-chờ-anh}
- Phép thử tự-soi của cặp H24 (S153 hẹn → S154 chạy): **ĐẠT** — cặp H24 tự bắt ca thật về chính tệp của mình; neo `sub-lead-stale-open-S154.md:20,27` + `sub-ring2-open-S154.md:63` {gần-chốt}
- Vét-cạn vẫn hở ⇒ **vòng KIỂM giữ nguyên, KHÔNG bỏ** — M-1 `sub-ring2-open-S154.md:133` (`FE pages` 68↔66, cả 2 con-đo im) là ca sống {đang-cãi}
- Bắt mọi con-đo khai mẫu-số kèm tập-bù: ring2 chấm TRƯỢT khai-phạm-vi của lead-gap vì *lời khai không vét cạn*; `ctx-audit` bắt lead co mẫu-số 3→2 không khai; `ctx-curator` bắt lead co mẫu-số **4→2** ngay mục A của nháp này ⇒ **3 ca độc lập cùng hướng, 2 ca là chính lead** {đang-cãi}
- Guard post-wave assertion mù 4 path hook — mù là **CẤU-TRÚC** (không cần N ca), có tiền-lệ Sàn-3 ④/⑤ + 1 ca chạy thật {gần-chốt}
- Trần `mind_ctx_kb`=32KB: số đo thật đầu tiên = 14.707B block-0, sau MIND-1 sẽ tăng — còn xa trần, chưa đủ mẫu để kết {treo-chờ-anh}
- Nhịp spawn `ctx-curator` @MỖI pause có đắt quá không — lượt này là lần đo chi-phí THẬT đầu tiên (~80K tok, 12 tool-call, ~356s) {treo-chờ-anh}
- `ring5-audit` build SE-form ngay hay chờ nhịp adopt chung VIPIX {treo-chờ-anh}
### E. Dòng-sống spawn/engine-run
- ctx-audit (@open, lần chạy đầu đời) — `DAT — 12 điểm` (4 FLAG, cả 4 nhắm lead; lead nhận 2/4) — `.claude/workflows/runs/2026-07-26-S154-bookend-open/sub-ctx-audit-open-S154.md`
- ctx-curator (@pause-1, carve-out `pause.md §1`, lần chạy đầu đời) — `SUA — 5 điểm`, lead nhận CẢ 5 và sửa nháp trước khi chèn; verdict ghi tại entry `PAUSE-1` mục (3) của `.claude/sessions/session-7/_context-s-7.md`
- 🔴 11 spawn trước đó của cửa-sổ này đã liệt ĐỦ ở mục E block-0 (`ctx-audit` chấm **11/11, 0 lượt thiếu**) — CẤM chép lại, xem `.claude/sessions/session-7/_mind-s-7.md` block MIND-0
## MIND-0 — 2026-07-26T19:12:14+07:00 @ 8776ca8 (window 1)
### A. Gói-turn
- Bootstrap L7: 6 cửa máy xanh (Sàn-3 sạch 5/5 · scaffold session-7 · registry-probe 2/2 ALIVE · tick 27→28 squash-benign · V4 IM · test 532) → bookend @open 6 vai → trio AUTO 3 nấc.
- Đắt nhất: **#53 ×10**, 8/9 vai dính ≥1 lần, `harness-refine` là vai DUY NHẤT sạch lượt đầu. 0 byte mất.
- Kết quả nặng ký nhất KHÔNG phải FLAG lẻ mà là mệnh đề hệ-thống: *row có máy canh thì đúng, row không có máy canh thì trôi* — 3/3 row ngoài `$canonRows` đều lệch, 6/6 row trong tập đều đúng.
### B. Hướng-tiếp + nhánh-đã-loại
- Tiếp: chờ anh chốt **(42) canonRows** · **(43) carry con-trỏ-vs-re-stamp** · **(44) END-line thành luật**; ba cái này chặn phần disposition còn lại. Trong lúc chờ: dogfood nốt `[carry:ctx-t9-dogfood]` (pause C-bis + tiep §3-ter chưa chạm lượt này).
- Nhánh đã LOẠI: ① **vá 10 FLAG ngay trong @open** — loại vì H24 §2(4) đòi fire→chứng-kiến→RỒI MỚI vá, và vá giữa cửa-sổ đo là tự làm nhiễu Mirror C1 · ② **ghi memory ngay khi có datum** — loại vì `User memory` count đang LÀ một FLAG được chấm, ghi bây giờ là bẻ chính thước mình vừa đặt · ③ **pin sub-file theo "có tồn tại"** — loại bằng bằng chứng đo được (ring1 7.404→29.270B, audit 1.293→39.419B).
### C. Kế-hoạch (delta suy-nghĩ)
- Delta lớn nhất so bản đồ cũ: trước nay coi drift là **lỗi kỷ luật của lead**; phiên này đo được nó là **hàm của việc có máy canh hay không**. Đổi cách chữa: không phải "nhắc lead cẩn thận hơn" mà là **mở rộng tập máy canh, hoặc bỏ hẳn con số khỏi doc**.
- Nhận ra lớp lỗi chung nối 4 ca rời rạc trong phiên (lead `grep '<!-- END'` bắt trích-dẫn · ring2 `sed|cut` trên mega-line · eval `grep` key JSON bắt chuỗi mô tả · refine đối chiếu output-hàm-A với luật-hàm-B): tất cả là **khớp-chuỗi-phẳng trên dữ liệu CÓ CẤU TRÚC**, và tất cả sai về phía *trông có vẻ ổn*. Thuốc chữa là đổi công cụ theo cấu trúc, không phải cẩn thận hơn.
- Nghi vấn để dành: `§2.1.9(f)` post-wave assertion dùng `git status`**mù đúng 4 path Stop-hook tự commit**. Lần này phát hiện residual-write chỉ vì vai TỰ KHAI ⇒ guard đang dựa vào tính trung thực của đối tượng bị giám sát. Sàn-3 đã giải đúng bài này bằng tín-hiệu ④/⑤ (soi commit của hook), chưa ai port sang guard này.
- Đổi ý so với bản đồ cũ về `#53`: không còn coi là *lát cắt ngẫu nhiên của transcript*. Có 2 datum ngược: `lead-gap` trả **y nguyên một chuỗi hai lượt độc lập** (ổn định theo vai), và `harness-audit` lộ **failure-mode thứ hai** (làm-mà-chưa-ghi, khác làm-và-ghi-hỏng-return).
### D. Đang-thảo-luận
- Row ngoài `$canonRows`: mở rộng tập máy canh hay bỏ số + trỏ nguồn sống theo khuôn B1 — em nghiêng phương án 2 cho row không đo được bằng 1 lệnh {mới-nêu}
- Carry: giữ con-trỏ (chống drift, thước mù) vs re-stamp đủ slug (thước sống, nguy cơ drift) vs đường thứ 3 con-trỏ-cho-người + dòng slug-only-cho-máy {mới-nêu}
- Dòng `END … TOTAL=n` nâng thành luật — slot (33) treo từ S152, phiên này cứu 3 lần với biên độ tới ×30 {gần-chốt}
- Trần `mind_ctx_kb`=32KB đủ hay không — nay có số đo thật đầu tiên là chính tệp này, chưa đủ mẫu để kết {treo-chờ-anh}
- Nhịp spawn `ctx-curator` @MỖI pause có đắt quá không — núm hạ đã để sẵn {treo-chờ-anh}
- `ring5-audit` build SE-form ngay hay chờ nhịp adopt chung VIPIX {treo-chờ-anh}
- Điểm mù tự-quy-chiếu của cặp H24: phiên này ring2 lại bắt M-1 mà cả 2 con-đo cùng sót ⇒ nghiêng hẳn về kết luận cặp H24 KHÔNG tự đóng được điểm mù của mình, phải có vòng KIỂM {đang-cãi}
- Bắt mọi con-đo khai mẫu-số kèm tập-bù: phiên này ring2 chấm TRƯỢT phần khai-phạm-vi của lead-gap vì *lời khai không vét cạn* ⇒ ý này mạnh thêm một nấc {đang-cãi}
- Guard post-wave assertion mù 4 path hook — sửa bằng cách soi commit của hook thay vì `git status` {mới-nêu}
### E. Dòng-sống spawn/engine-run
- tooling-auditor (H1) — `PASS_WITH_FLAGS 3 finding`, 1 bị lead+ring1 bác — `.claude/workflows/runs/2026-07-26-S154-bookend-open/sub-tooling-auditor-open-S154.md`
- harvest-curator (H2) — `GATE-PASS-WITH-FLAGS 6 finding``.claude/workflows/runs/2026-07-26-S154-bookend-open/sub-harvest-curator-open-S154.md`
- lead-stale-auditor — `5 FLAG` (3H·1M·1L) — `.claude/workflows/runs/2026-07-26-S154-bookend-open/sub-lead-stale-open-S154.md`
- lead-gap-auditor — `5 FLAG` (1H·2MH·2M) — `.claude/workflows/runs/2026-07-26-S154-bookend-open/sub-lead-gap-open-S154.md`
- ring1-audit — `46Đ/2T/5KC trên 53 claim``.claude/workflows/runs/2026-07-26-S154-bookend-open/sub-ring1-open-S154.md`
- ring2-audit — `17Đ/1T/1KC trên 19 claim` + M-1 MISS thật — `.claude/workflows/runs/2026-07-26-S154-bookend-open/sub-ring2-open-S154.md`
- harness-eval — `MIXED 12 signal``.claude/workflows/runs/2026-07-26-S154-bookend-open/harness-eval-return.md`
- harness-refine — `3 action/8 bác/3 escalate``.claude/workflows/runs/2026-07-26-S154-bookend-open/harness-refine-return.md`
- harness-audit — `68Đ/8T trên 78 claim``.claude/workflows/runs/2026-07-26-S154-bookend-open/harness-audit-return.md`
- registry-probe ctx-curator + ctx-verifier — `2/2 ALIVE` (`tool_uses=0` = đúng lệnh) — không sinh file, ghi tại `bookend-open-synthesis.md §6`
- synthesis lead-written ×2 — `.claude/workflows/runs/2026-07-26-S154-bookend-open/bookend-open-synthesis.md` + `.claude/workflows/runs/2026-07-26-S154-bookend-open/trio-synthesis.md`

View File

@ -0,0 +1,5 @@
ts: 2026-07-26T19:42:22+07:00
head-sha: 5197ce2f1b69e1b31467216782e69965a694da7d
window-ordinal: 1
jsonl-hint: D--Dropbox-CONG-VIEC-SOLUTION-SOLUTION-ERP/a0d38c2a-2097-4703-842d-fdffb19a8360
account-label: none

View File

@ -0,0 +1,8 @@
ts: 2026-07-26T19:42:22+07:00
head-sha: 5197ce2f1b69e1b31467216782e69965a694da7d
window-ordinal: 1
jsonl-hint: D--Dropbox-CONG-VIEC-SOLUTION-SOLUTION-ERP/a0d38c2a-2097-4703-842d-fdffb19a8360
account-label: none
kind: auto-snapshot @PAUSE-1 (pause.md §2.6(D))
secrets-sweep: 0 hit / 4 pattern (pattern-bounded — KHONG phai chung-minh sach)
machine-block: changed_count=17 · run_id=null · anchor 2026-07-26T16:16:45+07:00

View File

@ -0,0 +1,5 @@
ts: 2026-07-27T01:01:29+07:00
head-sha: 8d4075a
window-ordinal: 2
jsonl-hint: D--Dropbox-CONG-VIEC-SOLUTION-SOLUTION-ERP/2408c62a-7a5a-4a7f-8ec5-9a6c40fff802
account-label: none

View File

@ -0,0 +1,97 @@
# bookend-open-synthesis — S154 (phiên-LOGIC L7) · 2026-07-26
> Lead scribe. 6 vai (4 đo + 2 KIỂM) + 5 script máy. Hình B: AUTO vô-điều-kiện, 0 cổng consent.
## 1. Kết quả từng vai (pin = file ĐÃ ĐÓNG, verify END ở DÒNG CUỐI)
| vai | verdict | file | bytes | END |
|---|---|---|---|---|
| 🟫 tooling-auditor (H1) | `PASS_WITH_FLAGS — 3 finding` | `sub-tooling-auditor-open-S154.md` | 13.605 | `TOTAL=3 FINDING` |
| ⬜ harvest-curator (H2) | `GATE-PASS-WITH-FLAGS — 6 finding` | `sub-harvest-curator-open-S154.md` | 24.858 | `TOTAL=6 FINDING` |
| 🔷 lead-stale-auditor | `5 FLAG` (3H · 1M · 1L) | `sub-lead-stale-open-S154.md` | 14.296 | `TOTAL=5 FLAG` |
| 🔶 lead-gap-auditor | `5 FLAG` (1H · 2MH · 2M) | `sub-lead-gap-open-S154.md` | 25.809 | `TOTAL=5 FLAG` |
| 🧿 ring1-audit | `46Đ/2T/5KC trên 53 claim` | `sub-ring1-open-S154.md` | 29.270 | `TOTAL=53 CLAIM` |
| 🧿 ring2-audit | `17Đ/1T/1KC trên 19 claim` | `sub-ring2-open-S154.md` | 25.645 | `TOTAL=19 CLAIM` |
## 2. Phát hiện cấp HỆ-THỐNG (mạnh hơn mọi FLAG lẻ)
`governance-detectors.ps1:204-208` chỉ resolve canonical **6 token** `{mig, table, gotcha, test, menu, policy}`. Bảng CURRENT STATE có **13 row**.
| row | máy canh? | STATUS | đĩa/live | lệch |
|---|---|---|---|---|
| Migrations · SQL tables · Tests · Gotchas · Menu keys · Policies | ✅ | — | — | **0** |
| User memory | ❌ | 31 | **53** | 22 (~41% corpus) |
| RAG chunks | ❌ | 2447 | **2449** | 2 |
| FE pages | ❌ | 68 | **66** | +2 |
🔴 **Ba row kiểm hôm nay đều ngoài tập, cả ba đều lệch. Sáu row trong tập, 0 lệch.**
⇒ Mệnh đề: *con số có máy canh thì đúng; không có máy canh thì trôi* — độc lập với kỷ luật lead.
`[OK] canonical matches disk` của detector chỉ đúng cho **6/13 row**; đọc thành "bảng sạch" là SAI.
🔴 Nguy nhất: `User memory` nằm trong `tiep_reload.sources[0]` ⇒ phiên `/tiep` nạp lại đọc corpus nhỏ hơn thật gần một nửa.
**Đề xuất (chưa làm, chờ owner):** mở rộng `$canonRows`, HOẶC bỏ số + trỏ nguồn sống theo khuôn B1 mà `agents/README.md:3/:6/:27` đã dùng cho roster.
## 3. FLAG đã CONFIRMED bằng đo độc lập của lead
| # | class | nội dung | lệnh lead |
|---|---|---|---|
| S-1 | `view-stale-count` HIGH | `STATUS:454` User memory **31** vs đĩa **53** | `ls .claude/auto-memory/*.md \| grep -v MEMORY.md \| wc -l` → 53 (47 fb + 6 proj); `MEMORY.md` 53 dòng `^- \[` |
| S-2 | `view-residual-asym` HIGH | `lead-{stale,gap}-auditor.md:4` description *"KHÔNG mỗi phiên"* ⟂ thân `:51` *"MỖI phiên, CẢ HAI bookend"* | `grep -c 'KHÔNG mỗi phiên'` = 1/file; `git show 11db5ef` 2 hunk đều trong `## Nhịp`, **0 hunk chạm :4** |
| S-3 | `view-stale-status` HIGH | `HANDOFF (39)` + `STATUS:471` khai **RAG DOWN**, thực tế **UP** | `list_projects` → alive, chunk 2449, rerank 0.516/0.512/0.477 |
| S-4 | `view-stale-count` LOW | RAG chunks 2447 vs 2449 | cùng lệnh trên |
| S-5 | `view-residual-asym` MED | trio *"CHƯA probe lần nào / pending consent"* còn ở `session-start.md:65` + `memory-budget.json:185`+3 `rollout`, dù đã chạy S144/S151/S152 | run-folder 3 vòng + diary 4.5KB (không còn 1.5KB) |
| G-1 | `gap-carry-dropped` HIGH | carry con-trỏ-hoá: `HANDOFF:6`→5 slug · `:13`→6 · `:18`→**26** | `grep -o '\[carry:[a-z0-9-]*\]' \| sort -u \| wc -l` theo dòng |
| G-2 | `gap-carry-aged` MED-HIGH | H24-2 **FIRE=0**, streak max 4 — **hệ quả của G-1** (thước mù vì slug rời dạng máy-đọc) | ring2 re-implement detector bằng Python: 22 mark · `carryLines[0]` span :10-:13 = 7 khoá — **khớp 100%** |
| G-3 | `gap-decision-sunk` MED-HIGH | spec S153 `:32` "#24 = 3 khoản KHÔNG-KIỂM-ĐƯỢC" — 0 hit trên bề mặt owner | ring2: `grep -rl 'KHÔNG-KIỂM-ĐƯỢC' --include=*.md .` = đúng 3 file, không có bản diễn-đạt-khác |
| G-4 | `gap-carry-dropped` MED | `STATUS:636-756` 14 dòng ⚪ = 6 việc distinct; 4/6 không slug lẫn slot | — |
| G-5 | `gap-underfill` MED | `crystallized_backfill.target` 210K owner-set, headroom 176.696, **fill thủ-công chưa làm** | ring2: `grep -rn 'source_order' .claude/commands/` = **0** ⇒ FLAG mạnh hơn mức con-đo tự khai |
## 4. Con KIỂM bắt được gì (giá trị của vòng KIỂM)
**ring1 — 2 TRƯỢT:**
- `H1-21` **F-3 dương-giả** (*"khối S153 thiếu `→ log`"*): phá bằng 3 phép — `STATUS:6` có, `STATUS:471` có dạng markdown-link, file đích tồn tại 9.367B. **Lead bác ĐÚNG** (lead bác độc lập TRƯỚC, ring1 xác nhận SAU, 2 nguồn không trao đổi kết luận).
- `H2-21` **TRƯỢT-rìa LOW**: H2 dán SHA sai — 3 đường thêm ở `48f3fef` 18:01:00, không phải `035daec` 18:02:51. Kết luận H2 không đổi; nêu vì H2 dạy "vắng ở porcelain có 2 nghĩa" mà lại dán nhầm chứng.
- **5 KHÔNG-CHẤM khai thẳng** (iconv 628/628 · orphan 0/60 · 0-byte 10 file · 2 mục DIFF) = "hết ngân-sách lượt", **không gật cho đủ**.
**ring2 — 1 TRƯỢT + 1 MISS-thật:**
- **M-1 (cả 2 con-đo cùng sót):** vai-STALE **tự liệt**`:5` các row ngoài tập cross-check — *`User memory` · `Sub-agents` · `RAG chunks` · `API endpoints` · `FE pages` · `Master data`* — rồi **chỉ đo 3**, bỏ trắng 3. 🔴 *"Biết chỗ máy mù, tự chỉ ra chỗ mù, rồi không soi hết chỗ mù"* = **dừng giữa chừng trong đúng tập-đo mình vừa tuyên**, không phải "ngoài tầm". → đây là nguồn của mục §2.
- **TRƯỢT khai-phạm-vi (vai-GAP):** 4 mục nó khai chưa-phủ **đều THẬT và đáng ghi công**, nhưng lời khai **không vét cạn**. 🔴 Nguyên tắc ring2 phát biểu: *"lời khai thiếu-sót cũng phải bị soi tính đầy-đủ, nếu không nó thành lá chắn."*
- **6 falsify: 4 HELD / 2 BROKE** — cả 2 BROKE là **giả-thuyết của chính ring2 bị đĩa bác**:
- **F-1** nghi vai-STALE trích sai `session-start.md:65` → bị bác: đó là **bẫy mega-line** (`sed | cut -c1-300` cắt mất đoạn chứa câu). Cùng lớp `STATUS.md:6` = 63.104 ký-tự/dòng.
- **F-2** nghi drift roster (parse ra `VALID_ROLES=21` + 3 tên lạ `derived-staleness`/`view`/`omission`) → bị bác: **bẫy `]` trong comment** làm range mở lại — đúng bẫy S153 đã ghi, tái phát ở dạng mới.
## 5. Sổ #53 — ×8 trong 1 phiên, 0 mất dữ liệu
| vai | tool_uses | lần garble | recover |
|---|---|---|---|
| tooling-auditor | 31 → 36 | 1 | resume 1× |
| harvest-curator | 27 → 34 | 1 | resume 1× |
| lead-stale-auditor | 20 → 23 | 1 | resume 1× |
| lead-gap-auditor | 22 → 43 → 48 | **2** (lần 2 trả **y nguyên chuỗi cũ**) | resume 2× |
| ring1-audit | 28 → 49 | 1 | resume 1× |
| ring2-audit | 26 → 43 → 49 | **2** | resume 2× |
🔴 **6/6 vai spawn qua Agent-tool đều dính ≥1 lần** ⇒ trong phiên này first-return garble là **mặc định**, không phải ngoại lệ.
🔴 **Datum MỚI:** `lead-gap` trả **đúng cùng một chuỗi hai lần** qua 2 lượt độc lập ⇒ lát cắt sai **ổn định theo vai**, không ngẫu nhiên. (Trước nay #53 được mô tả như lát cắt ngẫu nhiên của transcript.)
🔴 **Datum phản-bác kỷ-luật-vai:** H2 chỉ ra bất-đối-xứng — 4 lượt land TRƯỚC mốc M9-flush 15:49 **đều vào sổ dù return cũng hỏng y hệt** ⇒ biến quyết định = **thời-điểm quét**, không phải chất lượng return. Đề sửa 1 chữ: *M9 quét đĩa lần cuối SAU dòng END của lượt sau-chót.*
**Ba lớp giữ phiên sống, thứ tự rẻ→đắt:**
1. **ghi-đĩa-trong-lúc-làm** → cứu nội dung (0 byte mất, cả 8 ca)
2. **dòng END+TOTAL ở DÒNG CUỐI** → phân biệt *đã đóng**chỉ tồn tại*. Bằng chứng giá trị: `lead-stale` 6.542B→14.296B · `lead-gap` 8.960B→25.809B (**~3×**) · `ring1` 7.404B→29.270B (**~4×**). Pin sớm = mất 2/3 nội dung mà **trông vẫn sạch**.
3. **resume-in-session** → lấy lại verdict (8/8 thành công)
🔴 **Lead tự dính bẫy đếm END:** `grep -c '<!-- END'` trên file ring1 ra **1**, nhưng hit là `:12` — ô bảng **TRÍCH DẪN** END của H1. Đây là `citation-trap self-reference`: matcher bắt **mention** thay **use**. Discriminator đúng = **vị trí dòng cuối**, không phải sự hiện diện chuỗi. Lead suýt pin bản cắt đúng lúc đang cảnh báo người khác về bản cắt.
## 6. Máy (V5 + V4) — chạy TRƯỚC vai
- `governance-detectors.ps1` TOTAL **43** + INFORM-only **4** (C11 FLAG-khuôn ×4 @ S151/S152 sub-file) · 0 self-match
- `memory-selfimprove-audit.ps1` CRITERIA `A1 A2 A3 B1 B2 HCV` · **GAPS none**
- `nhip-no-probe.ps1` `light 1/6 · deep 3/15 ok · tran-bo-nho 0 · run-chua-gom 0 · pull-cach 4 ngay`
- `crystallized-backfill.ps1` cap 380K · hotload 609.913B · range [152.478..203.304] tok · headroom **176.696** · target 210K
- `distill-shard-probe.ps1` `pending=15 | nguong=15 => IM` ⇒ V4 KHÔNG fire, `ring4-audit` NO-OP đúng luật
- `session-counter-tick.ps1 -Session S154`**27→28**, classify `squash-benign`
- `dotnet test`**532 PASS** (45 D + 487 I), 0 fail / 0 skip
- registry-probe `ctx-curator` + `ctx-verifier`**2/2 ALIVE** (`tool_uses=0` = đúng lệnh, không phải #53)
## 7. Disposition — CHỜ OWNER, chưa vá gì
Không mục nào được vá trong lượt @open. Danh sách trình anh ở REPORT.

View File

@ -0,0 +1,274 @@
# harness-audit — S154 (con KIỂM, nấc 3/3) · bookend-open · 2026-07-26
> 🔴 Ghi-đĩa-trong-lúc-làm. File LUÔN hợp-lệ giữa chừng; dòng END chỉ xuất-hiện khi TRỌN.
> Trích END của file khác thì **bọc backtick** (lead đã dính bẫy grep-bắt-mention ×3 phiên này).
## §0 PIN-GATE (nghĩa-vụ i) — verify TỒN-TẠI trước khi chấm
PIN đích-danh (lead truyền, **KHÔNG tự tìm run mới nhất**): `.claude/workflows/runs/2026-07-26-S154-bookend-open/`
| check | lệnh | kết-quả |
|---|---|---|
| `test -d <pin>` | dir tồn-tại | **OK** |
| `test -s harness-eval-return.md` | non-empty | **OK** |
| `test -s harness-refine-return.md` | non-empty | **OK** |
| byte eval | `wc -c` | **24889** — lead khai 24.889 ⇒ **KHỚP byte-exact** |
| byte refine | `wc -c` | **20585** — lead khai 20.585 ⇒ **KHỚP byte-exact** |
| dòng eval / refine | `wc -l` | **264 / 109** — khớp lead khai |
| END eval (dòng cuối) | `tail -1` | `` <!-- END harness-eval-S154 · TOTAL=12 SIGNAL · VERDICT=MIXED --> `` ⇒ ĐÓNG |
| END refine (dòng cuối) | `tail -1` | `` <!-- END harness-refine-S154 · TOTAL=3 ACTION · BÁC=8 --> `` ⇒ ĐÓNG |
**mtime-chain (chứng TUẦN-TỰ, không song-song):** synthesis 18:36:32 → eval 18:45:58 → refine 19:03:59 → audit (sau 19:03).
⇒ refine ghi SAU eval 18 phút; audit sau cả hai. **Tuần-tự HELD.** PIN-GATE **PASS** ⇒ được phép chấm.
---
## §1 RE-CHẠY ĐỘC-LẬP (nghĩa-vụ iv) — 5 instrument, đối-chứng BYTE-EXACT
🔴 Tao KHÔNG tin số con-ĐO cite. Dưới đây là số **máy tao tự chạy lượt này**.
### [R1] `scripts/measure-agent-memory.ps1` — re-chạy TƯƠI
**23/23 row l1_hot BYTE-IDENTICAL** với bảng [I1] của con-ĐO:
`tooling-auditor 23464 · implementer-backend 21987 · frontend-designer 21492 · reviewer 21281 · lead-gap-auditor 19792 · harvest-curator 19456 · cicd-monitor 19281 · implementer-frontend 18435 · lead-stale-auditor 14078 · ring2-audit 13964 · test-specialist 13189 · investigator-codebase 12154 · investigator-api 9021 · ring1-audit 7884 · database-agent 7388 · harness-refine 4813 · harness-audit 4530 · harness-eval 4492 · office-document 3323 · ctx-audit 3288 · ctx-curator 2940 · ctx-verifier 2663 · ring4-audit 2315`
**10/10 l2_index BYTE-IDENTICAL:** cicd 19322 · reviewer 13780 · inv-cb 12384 · impl-be 10105 · harvest 7986 · impl-fe 5844 · test-spec 5404 · tooling 3672 · fd 1622 · lead-gap 963. Header `soft_cap 30720` / `autoinject_cap 25600` khớp.
**Số dẫn-xuất tao cho SCRIPT cộng (không cộng tay — đúng lane tao ngã S151/S153):**
```
live rows = 23 base rows = 17 coverage = 73.9%
MISSING baseline (6): ctx-audit, ctx-curator, ctx-verifier, ring1-audit, ring2-audit, ring4-audit
ORPHAN baseline (0):
SUM l1 all 23 = 271230
SUM l1 rows-with-baseline (17) = 238176
SUM baseline l1_hot (17 rows) = 191948
over autoinject 25600 = 0 | over soft 30720 = 0 | l1_over_30kb TRUE = 0 | l2_index over 20480 = 0
```
⇒ **0 lệch** trên: 271230 · 238176 · 191948 · 73,9% · danh-sách 6 vai thiếu · 0/0/0/0.
### [R2] `scripts/memory-archive-gate.ps1` — re-chạy DRY
Tham-số máy in khớp: `cap 25600 · low-water 21760 (ratio 0.85) · keep-floor 5 · strike-need 2`.
`[A6] DRY-RUN: strike counters NOT persisted` ⇒ xác-nhận DRY (md5 `.archive-strikes.json` = `4160ee71…` không đổi).
**PASS1:** 0/23 over-cap, strike 0 toàn bảng — **cột `entries` khớp [I3] từng vai** (impl-be 24 · cicd 20 · impl-fe 20 · reviewer 18 · test-spec 17 · inv-cb 12 · inv-api 10 · tooling 10 · harvest 9 · lead-gap 8 · fd 7 · db-agent 6 · harness-audit 6 · harness-eval 6 · harness-refine 6 · ring2 6 · lead-stale 5 · ring1 5 · office-doc 3 · ring4 2 · ctx-audit 0 · ctx-curator 0 · ctx-verifier 0).
**PASS2 A7:** `total pointers 401, resolved 401, failed 0` trên 10 sub — **từng dòng khớp** (cicd 101 · fd 5 · harvest 46 · impl-be 41 · impl-fe 44 · inv-cb 55 · lead-gap 5 · reviewer 56 · test-spec 28 · tooling 20). ⇒ **0 lệch.**
### [R3] `scripts/crystallized-backfill.ps1` — re-chạy DRY
```
hotload measured : 609913 bytes | Tran tong (live cap) : 380000 tok
tok RANGE : [152478 .. 203304] tok
measured_headroom: 176696 tok | target (owner): 210000 tok
expected_backfill: 176696 tok (headroom binds (target exceeds room))
```
⇒ **6/6 số BYTE-EXACT** với [I4]. 0 lệch.
### [R4] `scripts/nhip-no-probe.ps1` — re-chạy
```
NHIP-NO: kiem light 1/6 deep 3/15 ok | tran-bo-nho 0 over-cap l2idx 0 over | run-chua-gom 0 | pull-cach 4 ngay
```
⇒ **BYTE-EXACT toàn dòng** với [I5]. 0 lệch.
### [R5] `scripts/mfe-eval.ps1` — re-chạy TRÊN CÂY CÁCH-LY (0 byte ghi vào repo thật)
🔴 Con-ĐO chạy thẳng trên repo và **ghi `.mfe-state.json`** (+5 dòng, git-TRACKED). Tao KHÔNG lặp lại vết ghi đó: tao dựng cây tạm rồi chạy `-RepoRoot <scratch>` (lane fault-injection `-RepoRoot` đã dùng từ S95).
Chứng cách-ly: md5 `memory-budget.json` + `harvest-curator/MEMORY.md` giữa repo thật ⟂ cây tạm **giống hệt** (`f53c8754…` / `9664baa7…`); sau khi chạy, md5 `.mfe-state.json` **repo thật KHÔNG đổi** (`54e111d975eff38bdd618ecb88d1e70b` trước = sau); `git status` repo thật chỉ còn `.session-counter.json` (có sẵn từ trước) + chính file này.
Số re-dựng vs [I2] — **toàn bộ khớp**: `cap 380000 / mem-sub 60000` · `marks 9 · AS 15 · guards 5 · gotchas 7` · `DENOMINATOR 36 (raw 9+15+16+8)` · `span 28492 → 7123-9497 tok → 2.5% · FIT PASS` · `age: 9 parseable, 4 >30d KEPT` · `strikes_total 25 · RCA 14 · AS 15 · warn-baseline 25 · last_run 25` · **23/23 vai = 100%**, từng mẫu-số khớp (cicd 10/10 · ctx-audit 10/10 · ctx-curator 6/6 · ctx-verifier 7/7 · db 7/7 · fd 2/2 · harness-audit 5/5 · harness-eval 5/5 · harness-refine 5/5 · harvest 7/7 · impl-be 18/18 · impl-fe 13/13 · inv-api 7/7 · inv-cb 8/8 · lead-gap 6/6 · lead-stale 6/6 · office-doc 6/6 · reviewer 8/8 · ring1 5/5 · ring2 5/5 · ring4 5/5 · test-spec 6/6 · tooling 8/8). ⇒ **0 lệch.**
### [R6] Tam-giác `harvest-curator` mà lead bắt soi — 3 số phải TỰ KHỚP
| số | nguồn tao tự dựng | khớp? |
|---|---|---|
| **19456** | `wc -c harvest-curator/MEMORY.md` = **19456** = `measure` [R1] = `archive-gate` [R2] (**3 nguồn độc-lập**) | ✅ |
| **17510** | `budget.json:192` rollout *"L1 over self-target 17.1K"* + `harvest-curator/MEMORY.md:9` *"L1 self-target ≤17.1KB"*; 17,1 × 1024 = **17510,4** | ✅ |
| **17655** | `harvest-curator/MEMORY.md:5` tự khai *"L1 **17.655B** > self-target 17.1KB"* | ✅ |
| 19456 17510 = **1946** · 19456 / 17510 = **111,1%** · 19456 17655 = **+1801** | tao tính lại từng phép | ✅ |
⇒ **Ba số TỰ KHỚP.** Không có số nào bịa.
**TỔNG RE-CHẠY: 5 instrument · ~60 số load-bearing đối-chứng · 0 LỆCH.** (Contract đòi ≥2 — tao chạy 5.)
---
## §2 VERDICT PER-CLAIM — con-ĐO (`harness-eval-return.md`)
| # | claim của con-ĐO | verdict | neo bằng-chứng |
|---|---|---|---|
| **E1** | [I1] bảng 23 row l1 + 10 l2_index + 2 cap header | **ĐẠT** | [R1] byte-identical 33/33 số |
| **E2** | Σ l1 (23) = **271230**, mean 11792.6 | **ĐẠT** | [R1] script-sum 271230; 271230/23 = 11792,6 |
| **E3** | Σ l1 có-baseline **238176** / baseline **191948** / delta **+46228** | **ĐẠT** | [R1] 238176 ✓ 191948 ✓; 238176191948 = 46228 |
| **E4** | NO-BASELINE = **6 vai** (3 ctx + 3 ring), đích danh | **ĐẠT** | [R1] set-difference cho ĐÚNG 6 tên đó, **orphan 0** |
| **E5** | 0/23 > 25600 · 0/23 > 30720 · cờ `l1_over_30kb` TRUE = 0 | **ĐẠT** | [R1] 0/0/0 + [R2] 0/23 over-cap (2 máy độc-lập) |
| **E6** | TRIO l1 **13835** (base 4676, +9159, ×2,96) | **ĐẠT** | 4492+4813+4530=13835; `budget.json:200-202` 1547+1616+1513=4676; 13835/4676=2,959 |
| **E7** | ZERO-DELTA ×3 (db-agent 7388 · fd 21492 · office-doc 3323) khớp **từng byte** | **ĐẠT** | [R1] live = `budget.json:190/196/197` baseline y hệt |
| **E8** | `tooling-auditor` **23464 = 91,7%** cap, vai l1 ĐỈNH fleet; impl-be tụt **21987 = 85,9%** | **ĐẠT** | 23464/25600 = 91,66%; max(23 row) = 23464; 21987/25600 = 85,89% |
| **E9** | *"Baseline `measured._note` WATCH đang trỏ **sai vai**"* (S-A2) + signal #7 *"WATCH-vai-đỉnh trong cùng `_note` (impl-be 23955)"* | 🔴 **TRƯỢT** | Parse JSON: `measured._note` **KHÔNG chứa 23955** (`-match '23955'` = **False**). Chuỗi *"WATCH: gần autoinject-cap nhất 23955/25600"* nằm ở **`measured."implementer-backend".rollout`, dòng `:189`**. WATCH thật trong `_note` là *"cicd l2_index 18774/20480 (~92% cap)"* (`-match '18774'` = **True**) — và chuỗi đó **vẫn đúng hướng** (nay 94,3%). ⇒ con-ĐO gán sai toạ-độ VÀ vu cho `_note` một lỗi nó không có |
| **E10** | baseline phủ **17/23 = 73,9%**, thiếu 6 row | **ĐẠT** | [R1] 17 row non-underscore (parse JSON, không grep); 17/23 = 73,9% |
| **E11** | cicd l2_index **19322/20480 = 94,3%**, dư **1158**, **+548** vs 18774 | **ĐẠT** | [R1] 19322; 2048019322=1158; `budget.json:186` l2_index 18774; 1932218774=548 |
| **E12** | `budget.json:185 _note` vẫn khai trio *"SEED-STATE / vai chua chay lan nao / ~1.5KB"* | **ĐẠT** | đọc `_note` verbatim — có đúng mệnh-đề đó |
| **E13** | 3 field `rollout:200-202` vẫn *"S141 seed - first-run pending restart+consent"* | **ĐẠT** | `:200/:201/:202` đúng dòng, đúng chuỗi (con-ĐO cite chuẩn) |
| **E14** | harvest **19456** (+1374 vs baseline 18082) | **ĐẠT** | [R1] 19456; `budget.json:192` l1_hot 18082; 1945618082=1374 |
| **E15** | tín-hiệu (a): **111,1%** self-target 17510, delta **+1801** vs 17655 H2 tự khai | **ĐẠT** | [R6] tam-giác 3 số tự khớp; cite `budget.json:192` **ĐÚNG** và `harvest-curator/MEMORY.md:9` **ĐÚNG** |
| **E16** | lead-gap **19792** (+16101, ×5,36) · lead-stale **14078** (+10746, ×4,23) | **ĐẠT** | 197923691=16101, ÷=5,362; 140783332=10746, ÷=4,225 (`budget.json:198/199`) |
| **E17** | [I2] toàn khối mfe verbatim (36 · 28492 · 2,5% · FIT PASS · 25/14/15 · 23 dòng per-role) | **ĐẠT** | [R5] re-dựng cây cách-ly, **mọi số khớp**, kể cả 23 mẫu-số per-role |
| **E18** | S-B4: 23/23 = 100%, **phương-sai 0**, mẫu-số lệch **9×** (fd 2 vs impl-be 18) | **ĐẠT** | [R5] fd **2/2**, impl-be **18/18**, 21 vai còn lại đều 100% |
| **E19** | S-B5: mfe biết **23** vai / `measured{}` biết **17** ⇒ 2 đồng-hồ lệch roster | **ĐẠT** | [R5] 23 dòng per-role ⟂ [R1] 17 row baseline |
| **E20** | [I3] params + 0 over-cap + strike 0 + bảng `entries` + A7 **401/401/0** trên 10 sub | **ĐẠT** | [R2] khớp từng dòng, kể cả 10 con số pointer |
| **E21** | S-C3: 3 vai ctx `entries=0` trong khi bytes>0; *"Mọi vai khác ≥ 2 entries"* | **ĐẠT** | [R2] ctx 0/0/0; min của 20 vai còn lại = `ring4-audit` **2** ⇒ mệnh-đề "≥2" đúng khít |
| **E22** | [I4] 6 số crystallized | **ĐẠT** | [R3] 6/6 byte-exact |
| **E23** | S-D2: tok_high **53,5%** cap; STATUS **32,0%** + HANDOFF **21,9%** = **53,9%** hotload | **ĐẠT** | 203304/380000=53,5%; 195194/609913=32,0%; 133759/609913=21,9%; tổng 53,93% |
| **E24** | [I5] dòng NHIP-NO | **ĐẠT** | [R4] byte-exact |
| **E25** | S-E1: `light_every=6` `deep_every=15`, single-source ăn đúng | **ĐẠT** | probe in `1/6` `3/15`, khớp key trong `memory-budget.json` |
| **E26** | 3 tín-hiệu lead giao: (a) XÁC-NHẬN · (b) XÁC-NHẬN **+MỞ RỘNG 3→6** · (c) XÁC-NHẬN | **ĐẠT** | (a) [R6] · (b) [R1] 6 vai thiếu, `ring2-audit` **13964** đúng là vai không-baseline nặng nhất · (c) E12+E13 |
| **E27** | Provenance: *"[I4]+[I5] byte-exact 100% vs số lead"* | **KHÔNG-CHẠM-ĐƯỢC** | Tao dựng lại được **số MÁY** ([R3]/[R4] 0 lệch) nhưng **không có log lượt lead** trong tầm ⇒ vế *"vs lead"* không kiểm được. **KHÔNG chấm ĐẠT** cho phần tao không đo được |
| **E28** | Caveat 1: có ghi `.mfe-state.json` (tracked), `.archive-strikes.json` không đổi | **ĐẠT** | md5 hiện tại `54e111d9…` = đúng POST con-ĐO khai; `.archive-strikes.json` `4160ee71…` = PRE=POST. `git ls-files`: `.mfe-state.json` **tracked**, `.archive-strikes.json` **untracked** — đúng khai |
| **E29** | Caveat 4: trục tuổi/độ-tươi gist = **N/A**, `budget.json:42` tự khai thiếu máy | **ĐẠT** | `:42` = `_residual_khai_that`, đúng nội-dung 6 cặp gist stale + khai chưa có máy |
| **E30** | VERDICT tổng **MIXED**, không in HEALTHY/REGRESSION tổng | **ĐẠT** | Có trục thật nói ngược (headroom 33304, l2idx dư 1158) ⟂ 8 trục sạch bằng máy độc-lập ⇒ MIXED đúng, **không gộp che** trục lẻ |
**Con-ĐO: 28 ĐẠT · 1 TRƯỢT (E9) · 1 KHÔNG-CHẠM-ĐƯỢC (E27) trên 30 claim.**
---
## §3 VERDICT PER-CLAIM — con-ĐỀ-XUẤT (`harness-refine-return.md`)
| # | claim của con-ĐỀ-XUẤT | verdict | neo bằng-chứng |
|---|---|---|---|
| **R1** | Fail-closed gate: eval 24889 B / 264 dòng / END verbatim | **ĐẠT** | tao đo lại y hệt (§0) |
| **R2** | [V1] `memory-archive-gate.ps1:153` = `^#{2,3}\s`; `:143` khai đó là **chủ-đích** | **ĐẠT** | `:153` verbatim `if ($lines[$hi] -match '^#{2,3}\s')`; `:143` verbatim *"LOGICAL entries = HEADING markers ONLY (^#{2,3}\s) -- NOT the '---' separators"* |
| **R3** | [V1] *"đối chiếu cột `entries` máy in… quy-tắc parser **xác nhận**, không phải suy đoán"* | 🔴 **TRƯỢT** | Cột `entries` **hiển-thị** đến từ **hàm KHÁC**: `Get-EntryMarkerLineNumbers` (`:77-83`) khớp `^(#{2,3}\s\|---\s*$)` = **heading HOẶC `---`**, in tại `:131`. `:153` (`$logCount`) chỉ chạy **bên trong nhánh over-cap**, in tại `:208`. ⇒ refine đối chiếu **output hàm A** với **luật hàm B** rồi tuyên "xác nhận" |
| **R4** | [V1] ring1 *"4 (`^## `) \| 5 \| ✅ (máy tính cả `^### `)"* | 🔴 **TRƯỢT (BROKE)** | `grep -n '^#'` trên `ring1-audit/MEMORY.md` = dòng **1, 10, 18, 33, 38**; 10/18/33/38 đều là `## `, dòng 1 là `# ` ⇒ **0 dòng `^### `**. PowerShell đúng regex `:153` cho **4**. Nguyên-nhân thật = **1 dòng `---`**: ring1 4+1=**5** ✓máy · harvest 8+1=**9** ✓máy · tooling 9+1=**10** ✓máy. 🔴 **Hệ-thống, không phải ngoại-lệ** — nhưng harvest/tooling **không nằm trong bảng mẫu 11 dòng** của refine nên quy-luật bị che, và refine bịa 1 lời-giải-thích cho cái nó tưởng là ca lẻ |
| **R5** | [V1] 3 diary ctx có **0** heading h2/h3 | **ĐẠT** | PowerShell đúng regex `:153` → ctx-audit **0** · ctx-curator **0** · ctx-verifier **0** (và 0 dòng `---` ⇒ **cả hai** hàm đếm đều = 0 ⇒ kết-luận A1 **không bị R3/R4 làm hỏng**) |
| **R6** | [V1] hệ-quả code `:172` (`$logCount -le $keepFloor`) → `:201` *"cannot auto-drain"* | **ĐẠT** | đọc code: `0 -le 5` → `$warnFloor=$true`; `:201` verbatim đúng |
| **R7** | [V1] `:164` value-protect chạy trên 0 entry ⇒ **D3 hard-skip vô-hiệu** | **ĐẠT** | vòng `:157-170` bị chặn bởi `$logCount=0` ⇒ `$entProt` rỗng |
| **R8** | [V2] `_INDEX.md` = **19322 B** = `l2_index`; vẫn khai `Total 78 records` vs A7 đếm **101** (lệch **23**) | **ĐẠT** | `wc -c` = 19456→ **19322** ✓; dòng `:11` verbatim *"Total **78 records**"*; [R2] A7 cicd **101**; 10178=23 |
| **R9** | [V2] *"**0 BYTE ĐỔI** kể từ S153"* ⇒ A1@S153 chưa chạy | **ĐẠT** | `git log -- _INDEX.md`: chạm gần nhất = **c2d01f0** (S152 closeout). Sau đó **0 commit** nào chạm ⇒ mệnh-đề đúng, thậm chí mạnh hơn |
| **R10** | [V2] *"attempt-1 = D1 ĐÃ chạy: 22634→19322"* | **ĐẠT** (số) | chuỗi `22634` truy được trong 5 file `runs/2026-07-25-S151-trio-memory/` ⇒ không bịa. *(nhãn "attempt-1" sai — xem R21)* |
| **R11** | [V3] **5/19** `*.gist.md` có `COVERAGE-TOKENS`, đích danh 5 file | **ĐẠT byte-exact** | `find` = **19** gist; `grep -l COVERAGE-TOKENS` = **5**, đúng 5 tên đó (cicd/harvest/inv-cb/reviewer/tooling `2026-07`) |
| **R12** | [V4] A3@S151 20933→**17655**; dư **145**; tái-phình **19456** (+1801) | **ĐẠT** | `harvest-curator/MEMORY.md:5` verbatim *"L1 **17.655B** > self-target 17.1KB"*; 1765517510=145; [R6] 19456 |
| **R13** | [V4] *"`budget.json:191` rollout ghi lời hứa từ S107"* | 🔴 **TRƯỢT (toạ-độ)** | `:191` = **`test-specialist`**. `harvest-curator` ở **`:192`**. Nội-dung trích đúng verbatim, chỉ sai dòng. *(con-ĐO cite `:192` — ĐÚNG)* |
| **R14** | [V4] *"47 phiên chưa đóng"* (S107 → nay) | **ĐẠT** | 154 107 = 47 |
| **R15** | [V5] `:185` cấm hand-edit + re-sync 2026-08-01 · `:2` *"never hand-edit measured_bytes"* · `measure-agent-memory.ps1:41` chỉ `ConvertTo-Json` **stdout, 0 write-back** | **ĐẠT (cả 3)** | `:2` verbatim chứa *"never hand-edit measured_bytes."*; `:41` = `} \| ConvertTo-Json -Depth 5`; `grep -c "Set-Content\|Out-File\|WriteAllText"` = **0** ⇒ 0 write-back xác-nhận |
| **R16** | [V6] chuỗi WATCH 23955 nằm ở **field `implementer-backend.rollout`**, KHÔNG ở `_note` | **ĐẠT** | parse JSON: `_note -match '23955'` = **False**; field rollout = *"done (WATCH: gần autoinject-cap nhất 23955/25600)"*. 🔴 Đây là chỗ refine **bắt đúng lỗi E9 của con-ĐO** |
| **R17** | [V6]/[A2(c)] toạ-độ *"`:187` implementer-backend.rollout"* | 🔴 **TRƯỢT (toạ-độ)** | `:187` = **`investigator-codebase`**. `implementer-backend` ở **`:189`**. 🔴 Nặng vì [V6] tự khai mục-đích là *"Ghi ra để lead Edit **đúng dòng**"* — sai đúng cái nó hứa; lead làm theo literal sẽ sửa nhầm row |
| **R18** | [V6] `_note` có WATCH **KHÁC** (cicd 18774/20480 ~92%) vẫn ĐÚNG HƯỚNG ⇒ **KHÔNG được sửa** | **ĐẠT** | `_note -match '18774'` = **True**; nay 19322/20480 = 94,3% ⇒ chuỗi vẫn đúng hướng. Cảnh-báo "đừng sửa" là **đúng và cần** |
| **R19** | [V7] S144 return dòng-1 tự khai **"FIRST-RUN THẬT"** | **ĐẠT verbatim** | `head -1` = `# harness-refine — RETURN S144 (2026-07-22) — con ĐỀ-XUẤT, FIRST-RUN THẬT` |
| **R20** | [V7]/[V8] S144 có **"9 action-row"** | 🔴 **TRƯỢT** | Đếm nhãn bảng: **A1·A2·A3·A4 = 4**. Chính file S144 tự khai **2 chỗ**: caveat 3 *"**A1A4** propose-only"* và caveat 7 *"**A1A4** đều attempt = 1"*. ⇒ 9 sai hơn gấp đôi |
| **R21** | [V8] *"cicd `_INDEX` đã **2 lần** (D1@S151, A1@S153)"* | 🔴 **TRƯỢT** | S144 **A1** nhắm CHÍNH đối-tượng đó: *"Nén tại chỗ `_INDEX.md` cicd xuống dưới cap"* ⇒ **3 lần** (A1@S144 · D1@S151 · A1@S153), và lượt này là ca thứ **4**. 🔴 Lệch này khiến ESCALATE **E1 CÀNG chính-đáng** (nặng hơn refine trình bày), không nhẹ đi |
| **R22** | [V8] *"S151 9 · S152 2 · S153 3 action"* | **KHÔNG-CHẠM-ĐƯỢC** | 3 return đó dùng khuôn bảng khác (0 hit trên mọi mẫu nhãn tao thử) ⇒ **hết ngân-sách lượt, KHÔNG gật cho đủ** |
| **R23** | [V7] persona `.claude/agents/harness-refine.md` khai *"chạy THẬT từ S151"* (bản sao thứ BA) | **ĐẠT verbatim** | `:58` = *"🧊 vế first-run … hết hiệu-lực: **bộ-ba chạy THẬT từ S151**, AUTO 1 vòng/phiên @S151"* |
| **R24** | [V7] diary `:22` *"CHƯA chạy lần nào; first-run chờ restart-CLI…"* | **ĐẠT verbatim** | `sed -n '22p'` khớp từng chữ |
| **R25** | [E3] `## Luật cứng` có đúng **3 bullet**, **0** dòng TRIAGE-3-ô / re-cộng-số | **ĐẠT** | `:16` heading; `:17/:18/:19` = 3 bullet; `grep -ci TRIAGE` = **0** |
| **R26** | [B1] `memory-archive-gate.ps1:205` *"propose only after 2 consecutive over-cap"* | **ĐẠT verbatim** | `:205` = `"WATCH (strike $cur<$strikeNeed): re-run; propose only after $strikeNeed consecutive over-cap"` |
| **R27** | [B2] `HANDOFF.md:10` ⑤ = drift-audit **2026-08-01** + full re-sync `measured{}` (**+3 row ctx** ngoài 3 row ring) | **ĐẠT** | `:10` verbatim ⑤ *"monthly drift-audit **2026-08-01 (5 ngày)** + full re-sync `measured{}` (**+3 row ctx** ngoài 3 row ring…"* (refine chỉ lược "(5 ngày)") |
| **R28** | [B2] `reinject-ledger.md:12` build-gap ≠ floor-rot · `:5` budget = quyền owner · `:34` ledger **TRỐNG** | **ĐẠT (cả 3)** | `:12` verbatim *"Item chưa-từng-dựng = **build-gap**"*; `:5` verbatim *"`memory-budget.json` = **quyền cấp-ngân-sách của anh**… MFE + audit chỉ **đọc**"*; `:34` verbatim *"(chưa có floor-rot nào được reinject…)"* |
| **R29** | [B3] *"`budget.json:189` rollout ghi đã curate S96 + H17 strike-1 CLEARED"* | 🔴 **TRƯỢT (toạ-độ)** | `:189` = **`implementer-backend`**. `frontend-designer` ở **`:190`** — nội-dung trích đúng verbatim (*"done (S96 first-overflow curate; H17 strike-1 CLEARED)"*), chỉ sai dòng. **Lỗi thứ 3 cùng lớp** (R13/R17/R29) |
| **R30** | [B5]/[B6] `budget.json:42` tự khai trục tuổi gist **chưa có máy** | **ĐẠT** | `:42` = `_residual_khai_that`; `grep -c "CHUA CO MAY"` = **1** |
| **R31** | caveat 5 — **8 phép tự cộng** (13835·4676·2136·1158·1946·145·1801·3197) | **ĐẠT 8/8** | tao tính lại độc-lập cả 8: đúng hết. *(Đây là chỗ refine làm TỐT: nó tự cộng lại thay vì chép — và 8/8 đứng vững)* |
| **R32** | caveat 1 — *"đúng **1 file** ghi = chính return này, 0 file khác bị chạm"* | **ĐẠT** | `git status` lúc tao vào: chỉ `.session-counter.json` (có sẵn) + file tao ⇒ refine **không để lại vết ghi lạ** |
| **R33** | caveat 2 — *"1/3 action S153 land, **2 cái kia 0 byte đổi**"* | **ĐẠT (vế đo được)** | 2 cái không-land đo trực-tiếp: [V2] `_INDEX` 0-byte-đổi (R9) + [V3] gist vẫn 5/19 (R11) |
**Con-ĐỀ-XUẤT: 25 ĐẠT · 7 TRƯỢT (R3·R4·R13·R17·R20·R21·R29) · 1 KHÔNG-CHẠM-ĐƯỢC (R22) trên 33 claim.**
🔴 **Cụm TRƯỢT có HÌNH-DẠNG, không rải đều — 2 lớp:**
1. **Toạ-độ dòng trong `memory-budget.json`** (R13 `:191`→192 · R17 `:187`→189 · R29 `:189`→190). **3/3 cite dòng của refine vào file này đều lệch 1-2 dòng**, trong khi **mọi** cite vào `.ps1` / `HANDOFF` / `reinject-ledger` / diary đều **ĐÚNG**. Mùi: refine đọc budget.json bằng một phép đánh-số **lệch hệ** (khả năng cao: đếm trên bản đã lọc/xén, hoặc suy dòng từ thứ-tự field thay vì đọc số dòng thật). 🔴 Đây là file mà A2 **đề-nghị lead Edit** ⇒ rủi ro sửa nhầm row là THẬT.
2. **Bookkeeping lịch-sử** (R20 9→4 · R21 2 lần→3 lần). Cả hai đều ở [V7]/[V8] = nền của toàn-bộ logic chốt-dừng. **Nhưng lệch theo hướng LÀM NHẸ quá-khứ** ⇒ kết-luận escalate vẫn đúng (thậm chí đúng hơn).
---
## §4 CHẤM 3 ESCALATE — chính-đáng hay né việc?
| # | escalate | phán | lý-lẽ (tao tự kiểm, không theo lời khai) |
|---|---|---|---|
| **ES1** | E1 `cicd-monitor/archive/_INDEX.md` → cửa **LEAD** | ✅ **CHÍNH ĐÁNG — và nhẹ hơn thực-tế** | Tiền-đề đo được: 19322 = 19322, `git log` xác-nhận **0 commit** chạm file sau S152-closeout. Đề-xuất đã bắn **3 lần** (không phải 2 — R21) ⇒ lần 4 đúng là lặp vô-ích. **KHÔNG né việc**: nó vẫn giao lead đủ để quyết NGAY — 2 lối ra rõ ({thực-thi nguyên-văn \| HỦY vì đã dưới cap 20480}), bối-cảnh cấp-thiết (dư **1158 B**, vai TĂNG-mỗi-run), và ràng *"nếu HỦY **phải ghi rõ**, không thì vòng sau lại đào lên"* = **đóng vòng**, đúng thuốc cho lặp-vô-hạn. Cửa LEAD đúng (0 số/policy đổi) |
| **ES2** | E2 `harvest-curator/MEMORY.md` → cửa **OWNER** | ✅ **CHÍNH ĐÁNG** | Đúng lớp "đã-thực-thi-mà-vẫn-không-đạt": cơ-chế drain **chứng minh chạy được** (20933→17655) nhưng **chưa bao giờ chạm đích** (dư 145 B) rồi tái-phình 19456 = 111,1% ([R6] 3 số tự khớp). 3 lối ra nó liệt (nâng target · đổi nhịp · đổi placement) **đều là SỐ/nhịp = quyền owner**, neo `reinject-ledger.md:5` (verbatim đúng) ⇒ **từ chối chọn hộ số là ĐÚNG RANH, không phải né**. Thêm điểm: nó tự khai counter-datum **bất-lợi cho mức khẩn của chính nó** — *"dưới cap hệ-thống (76,0%), strike 0 ⇒ 0 sức ép máy, chỉ vi-phạm ngưỡng vai TỰ đặt"* (tao xác-nhận: [R1] 19456 < 25600, [R2] strike 0). Khai đúng cái làm yếu case của mình = dấu hiệu không né |
| **ES3** | E3 N1+N2@S152 trong `harness-refine/MEMORY.md` → cửa **LEAD** | ✅ **CHÍNH ĐÁNG (có bảo-lưu)** | Đo đĩa đúng (R25: `## Luật cứng` **3 bullet**, `grep -ci TRIAGE` = **0**). ⚠️ Bảo-lưu: đối-tượng là **diary của CHÍNH NÓ** và cửa LEAD ⇒ về hình-thức có mùi tự-miễn. Nhưng nó **tự khai thẳng phần bất-lợi**: *"tao VẪN áp cả 2 luật trong lượt này… chúng sống trong **hành-vi**, chưa sống trong **file**; ai không đọc return S152 thì mất"* — tự tố rằng luật đang chạy bằng trí-nhớ chứ không bằng vật. 🔴 **Việc lead cần thấy**: ES3 (escalate) và **A3(ii)** (action mới) **cùng nhắm 1 file diary** đã có tiền-sự không-land ⇒ nếu A3(ii) cũng không land thì đó là **lỗi cơ-chế đặt-chỗ**, đúng như refine tự đoán |
⇒ **3/3 ESCALATE chính-đáng. 0 ca né việc.** Dấu hiệu chung: cả 3 đều **giao lại lối ra cụ-thể** + **neo được bằng đo**, không phải "đẩy lên trên cho xong".
---
## §5 CHẤM 8 BÁC + 4 NHƯỜNG
**8 BÁC — có lý-lẽ hay bác cho gọn?** ⇒ **8/8 có lý-lẽ neo được. 0 ca bác-cho-gọn.**
| # | phán | vì sao |
|---|---|---|
| **B1** tooling-auditor archive | ✅ **lý-lẽ MẠNH** | Neo **luật của MÁY**, không phải ý-thích: `:205` verbatim *"propose only after 2 consecutive over-cap"*, mà [R2] cho **strike 0 / 0 over-cap**. Phân-biệt sắc với E2 (H2 có ngưỡng TỰ-ĐẶT **ghi trong config** đang bị vượt; tooling **không có ngưỡng nào bị vượt**) — đúng, tao xác-nhận `budget.json:193` tooling **không** mang self-target. Và nó chuyển sang việc đúng (A2(c) sửa WATCH) thay vì bỏ trắng |
| **B2** bơm 6 row `measured{}` | ✅ **lý-lẽ** | 3 neo verbatim đúng hết: `HANDOFF:10 ⑤` (đã queue) · `budget.json:185`+`:2` (cấm hand-edit) · `reinject-ledger:12` (build-gap ≠ floor-rot ⇒ không được vào ledger). **Giữ vế nợ**: *"sau 2026-08-01 mà vẫn 17 row ⇒ lúc đó mới là nợ thật"* ⇒ **không xoá tín-hiệu**, chỉ hoãn đúng lịch |
| **B3** frontend-designer archive | ✅ **lý-lẽ** | 0-delta **tuyệt đối** — tao xác-nhận byte-exact 21492 = 21492 ⇒ 0 tăng-trưởng ⇒ archive một diary bất-động = 0 lợi đo được. *(cite `:189` sai dòng — đã trừ ở R29)* |
| **B4** trend Σ l1 +24,1% | ✅ **lý-lẽ SẮC NHẤT** | Nó **không cãi số**, nó cãi **tính so-sánh-được**: baseline là **HỖN-MỐC** (S107 + 2 row @S138 + 3 row @S141) ⇒ cộng delta xuyên 3 mốc rồi gọi "xu-hướng" = so lệch-mốc. Tao xác-nhận `_note` tự khai **đúng 3 mốc** đó. 🔴 Đây chính là lane *"số không có máy canh"* lead dặn soi — và refine đã tự soi ra. Nó vẫn công-nhận số con-ĐO **không sai**, chỉ **không neo action** lên nó ⇒ giữ được counter-datum |
| **B5** 14 gist COVERAGE-TOKENS | ✅ **lý-lẽ** | Đo được (5/19, R11) + chốt-dừng + khai thêm *"trục tuổi N/A ⇒ **không có số để neo cường-độ**"* — từ chối bịa mức khẩn |
| **B6** dựng máy đo tuổi gist | ✅ **đúng ranh** | Ngưỡng = quyền OWNER; dựng máy = lane **tooling**. Nêu-để-trỏ, **không nhân bản** |
| **B7** "sửa" mfe bão-hoà | ✅ **đúng ranh** | Chỗ sửa nằm **trong `mfe-eval.ps1`** ⇒ lane tooling. Vẫn **giữ 1 câu cảnh-báo** để không ai đọc lệch *"fd 2/2 và impl-be 18/18 không cùng nghĩa"* — tao xác-nhận [R5] đúng 2 vs 18 |
| **B8** sửa persona | ✅ **đúng ranh** | role-file ≠ agent-memory corpus. Và nó nêu để lead **vá CLASS** (bản sao thứ 3 của cùng mệnh-đề sai) — tao xác-nhận persona `:58` verbatim đúng như trích (R23) |
> Đáng ghi: **4/8** (B1·B2·B6·B7) bác bằng cách chỉ ra **luật/ngưỡng của người khác** (máy, HANDOFF, ledger, owner) chứ không bằng ý-thích ⇒ kiểu bác khó phản-bác nhất.
**4 NHƯỜNG — đúng turf?** ⇒ **4/4 ĐÚNG.**
1. **crystallized headroom → `lead-gap-auditor`/owner: ĐÚNG.** [R3] cho thấy headroom đo trên `hotload_sources` = STATUS · HANDOFF · ACTIVE-MARKS · migration-todos · gotchas · agents/README = **lead session hot-feed surface**, KHÔNG phải per-role agent-memory corpus ⇒ ngoài turf refine, đúng turf H24. Và nó **TRỎ** bookend 10 FLAG thay vì nhân bản.
2. **Instrument validity → `tooling-auditor`: ĐÚNG** (sửa nằm trong `.ps1`).
3. **Cửa-lật của A1 → `tooling-auditor`: ĐÚNG, và là NHƯỜNG TỐT NHẤT** — nó khai sẵn cửa để lead đảo hướng *"mà không cần chạy lại gì"*. 🔴 **Phát-hiện R3/R4 của tao khiến cửa này NÊN ĐƯỢC MỞ**: script có **HAI hàm đếm entry lệch nhau** (`:80` heading-hoặc-`---` ⟂ `:153` heading-only, dù `:143` khai `---` bị loại **có chủ-đích**) ⇒ có mùi **lỗi parser thật**, không chỉ lỗi diary. Chính refine đã để sẵn đường thoát mà không biết mình cần nó.
4. **harvest-op → `harvest-curator` H2: ĐÚNG** — E2 chỉ đưa ngưỡng lên owner, không tự curate. *(Tao cũng NHƯỜNG đúng chỗ này.)*
---
## §6 FALSIFY-LOG (nghĩa-vụ iii) — mỗi phép đều thử PHÁ
| # | phép thử phủ-định (tao cố phá) | kết |
|---|---|---|
| F1 | *"Lead pin sai folder / artifact chưa đóng"* → `test -d` + `test -s` + `wc -c` + `tail -1` cả 2 file | **HELD** — 24889/20585, 264/109, 2 END đúng dòng cuối |
| F2 | *"3 vai chạy SONG SONG ⇒ audit chấm bản cũ"* → đọc mtime-chain | **HELD** — 18:36 → 18:45 → 19:03 → tao. Tuần-tự thật |
| F3 | *"Con-ĐO chép số từ trí-nhớ / phiên trước"* → re-chạy **5 instrument** đối-chứng ~60 số | **HELD** — 0 lệch. Con-ĐO **không** bịa số nào |
| F4 | *"19456 là số H2 tự khai, không phải số đo"* → đo bằng **3 nguồn độc-lập** (`wc -c` + `measure` + `archive-gate`) | **HELD** — cả 3 ra 19456 |
| F5 | *"Tam-giác 19456/17510/17655 không tự khớp"* → tính lại 3 hiệu + 1 tỷ-lệ | **HELD** — 1946 · 111,1% · +1801 đều đúng; 17,1×1024 = 17510,4 |
| F6 | *"`measured{}` không phải 17 row — con-ĐO đếm bằng grep nên sai"* → **parse JSON** (không grep), loại key `_*`, làm set-difference | **HELD** — đúng 17, thiếu **đúng 6** tên đó, **orphan 0** |
| F7 | *"Cột `entries` máy in ĐÚNG là `$logCount` của `:153` như refine nói"* → truy code-path in bảng | 🔴 **BROKE** — cột hiển-thị đến từ `Get-EntryMarkerLineNumbers` (`:80`), khớp `^(#{2,3}\s\|---\s*$)`. Refine sai hàm |
| F8 | *"ring1 lệch 4-vs-5 vì máy đếm cả `^### ` (lời refine)"* → liệt **mọi** dòng bắt đầu `#` trong ring1 | 🔴 **BROKE** — 0 dòng `^###`. Thật là **1 dòng `---`**. Thử tiếp trên 2 vai refine KHÔNG tabulate: harvest 8+1=9 ✓ · tooling 9+1=10 ✓ ⇒ **quy-luật hệ-thống bị mẫu-nhỏ che** |
| F9 | *"R3/R4 vỡ ⇒ ACTION A1 cũng vỡ"* → kiểm xem ctx có khác 0 dưới **hàm kia** không | **HELD (A1 SỐNG)** — ctx có **0 heading VÀ 0 dòng `---`** ⇒ **cả hai** hàm đều = 0 ⇒ tiền-đề A1 đứng vững. **Phương-pháp sai, kết-luận đúng** |
| F10 | *"`_note` đúng là chỗ chứa WATCH impl-be như con-ĐO nói"* → `-match '23955'` trên `_note` | 🔴 **BROKE** (bắt lỗi con-ĐO E9) — False; nó ở `implementer-backend.rollout` `:189`. `_note` thật chứa 18774 và **vẫn đúng hướng** |
| F11 | *"Toạ-độ dòng của refine trong budget.json đúng"* → `sed -n '187p/189p/190p/191p/192p'` | 🔴 **BROKE ×3** — `:187`=inv-cb (không phải impl-be) · `:191`=test-spec (không phải harvest) · `:189`=impl-be (không phải fd). **3/3 cite budget.json lệch** |
| F12 | *"Refine cite dòng ẩu ở MỌI file"* (thử tổng-quát-hoá F11) | **HELD — KHÔNG tổng-quát-hoá được** | `.ps1` (`:143·:153·:164·:172·:201·:205`), `HANDOFF:10`, `reinject-ledger:5/12/34`, diary `:5/:9/:22`, `measure…ps1:41` — **đúng hết**. Lệch **chỉ** ở `memory-budget.json` ⇒ lỗi có **địa-chỉ**, không phải cẩu-thả toàn-cục |
| F13 | *"S144 thật có 9 action như refine khai"* → đếm nhãn bảng + đọc caveat tự-khai của S144 | 🔴 **BROKE** — **4** (A1A4), chính S144 tự khai 2 chỗ |
| F14 | *"cicd `_INDEX` mới là đối-tượng 2 lần"* → mở S144 xem A1 nhắm gì | 🔴 **BROKE** — S144-A1 = *"Nén tại chỗ `_INDEX.md` cicd"* ⇒ **3 lần**, lượt này là ca **4** |
| F15 | *"R20/R21 sai ⇒ ESCALATE E1 sập"* → thử lật chiều kết-luận | **HELD (E1 MẠNH HƠN)** — lệch theo hướng **làm NHẸ** quá-khứ; sửa đúng thì E1 càng chính-đáng |
| F16 | *"refine để lại vết ghi lạ (Bash = kênh-ghi-mở)"* → `git status` + `git ls-files` | **HELD** — chỉ `.session-counter.json` (có sẵn) + file tao. Refine sạch |
| F17 | *"chính TAO để lại vết khi re-chạy 5 instrument"* → md5 `.mfe-state.json` + `.archive-strikes.json` trước/sau + `git status` | **HELD** — `54e111d9…` = `54e111d9…`; `4160ee71…` không đổi; `git status` không thêm dòng nào ngoài file tao. **mfe chạy trên cây cách-ly `-RepoRoot`** |
| F18 | *"8 phép tự-cộng của refine có cái sai"* → tính lại độc-lập cả 8 | **HELD 8/8** |
| F19 | *"Verdict MIXED của con-ĐO là gộp-che trục lẻ"* → soi §2 (14 trục) vs §4 (12 signal) vs END | **HELD (có 1 caveat)** — 12 = số item §4, 14 = số dòng bảng §2; END khai `TOTAL=12 SIGNAL` khớp §4. ⚠️ Nhưng §6 viết *"MIXED — 12 signal"* rồi liệt *"4 REG · 2 MIXED · 8 HEALTHY"* = **14**; đọc nhanh dễ tưởng 4+2+8 = 12. **Không sai**, nhưng lead nên bắt con-ĐO **tách rõ 2 mẫu-số** ở lượt sau |
**16 phép: 11 HELD · 5 BROKE** (F7·F8·F10·F11·F13·F14 — F11 tính **1 phép** dù bắt 3 ca).
---
## §7 KHÔNG-CHẤM (khai thẳng, CẤM gật cho đủ)
1. **E27** — vế *"[I4]+[I5] byte-exact **vs số lead**"*: tao dựng lại **số máy** (0 lệch) nhưng **không có log lượt lead** trong tầm ⇒ vế đối-chiếu-với-lead **không kiểm được**.
2. **R22** — *"S151 9 · S152 2 · S153 3 action"*: 3 return đó dùng khuôn bảng khác, hết ngân-sách lượt để bóc từng file.
3. **Ngoài trục — escalate lead, tao KHÔNG tự chấm:** `memory-archive-gate.ps1` có **2 hàm đếm entry lệch nhau** — `Get-EntryMarkerLineNumbers` `:80` = `^(#{2,3}\s|---\s*$)` (nuôi **cột hiển-thị**, mọi 23 vai) ⟂ `:153` = `^#{2,3}\s` (nuôi **planner**, chỉ nhánh over-cap), trong khi `:143` khai loại `---` là **có chủ-đích**. ⇒ Bảng người đọc và bảng máy-quyết-định **không cùng một định-nghĩa "entry"**. Đây là **code-defect**, thuộc `tooling-auditor` (đúng cửa-lật NHƯỜNG-3 của refine) — tao **nêu verbatim, không tự chấm**.
---
## §8 CAVEAT BẮT BUỘC (chống overclaim)
1. 🔴 **read-only = chặn-THIẾU-tool, KHÔNG phải sandbox.** Lượt này tao ghi **đúng 1 file** trong repo = chính return này (+ 1 cây tạm ngoài repo ở scratchpad). Chứng: `git status` sau lượt tao chỉ có `.session-counter.json` (**có sẵn từ trước lượt tao**) + file này. **Containment THẬT = lead soát `git status`** — đừng tin chữ "read-only". Tao **PHÁN**, lead single-writer **GHI**.
2. 🔴 **pin-based:** tao chấm ĐÚNG folder lead PIN, **không tự tìm run mới nhất**. Nếu lead pin sai, verdict này vô-giá-trị — nhưng tao đã verify byte/END khớp con số lead tự khai nên rủi-ro đó đã đóng.
3. 🔴 **Ranh giữ:** 0 chấm diff-code (reviewer) · 0 soi lead-surface (2 vai H24) · 0 thao-tác harvest (NHƯỜNG H2). §7.3 là **INFORM cho lead**, không phải verdict của tao.
4. 🔴 **Meta-count = lane tao từng ngã (S151/S153).** Mọi tổng ở đây tao **cho script cộng** hoặc tính lại tay **2 lần**; 78 = 30+33+3+8+4 và 68+8+2 = 78 (kiểm chéo 2 chiều).
---
## §9 VERDICT TỔNG — KHÔNG gộp che claim TRƯỢT lẻ
**68 ĐẠT / 8 TRƯỢT / 2 KHÔNG-CHẤM trên 78 claim** · falsify **16 phép: 11 HELD / 5 BROKE** · re-chạy **5 instrument, ~60 số → 0 LỆCH**.
- **con-ĐO (`harness-eval`)**: **28Đ / 1T / 1KC / 30**. 🔴 Chất-lượng ĐO **rất cao** — 5 máy re-dựng độc-lập, **0 số nào lệch**. Lỗi duy-nhất (**E9**) là lỗi **TOẠ-ĐỘ + quy-kết**, không phải lỗi số: nó gán WATCH-impl-be cho `_note` và tuyên `_note` "trỏ sai vai", trong khi `_note` mang một WATCH **khác** và **vẫn đúng**.
- **con-ĐỀ-XUẤT (`harness-refine`)**: **25Đ / 7T / 1KC / 33**. Quyết-định (3 ESCALATE · 8 BÁC · 4 NHƯỜNG) **đứng vững 15/15**; TRƯỢT dồn vào **2 lớp có địa-chỉ**: (i) **3/3 cite dòng vào `memory-budget.json` đều lệch** — đúng file mà A2 xin lead Edit; (ii) **bookkeeping lịch-sử lệch theo hướng làm-nhẹ quá-khứ** (S144 9→4; `_INDEX` 2→3 lần).
- **Điều mỉa-mai đáng ghi nhất:** [V6] của refine **bắt đúng** lỗi toạ-độ của con-ĐO (E9) rồi **tự sai toạ-độ** ở chính câu sửa (R17). Cả hai vai đều đúng về **số**, cùng sai về **chỗ**.
- **Điều nguy nhất cho lead:** nếu lead thực-thi **A2(c) theo literal `:187`** thì sẽ sửa nhầm row `investigator-codebase`. 🔴 **Toạ-độ đúng: `:189`** (`implementer-backend.rollout`) — và **KHÔNG được đụng `:185` `_note`**, vì WATCH trong đó (cicd 18774/20480) **vẫn đúng hướng**.
🔴 **KHÔNG in verdict-tổng gộp.** 8 claim TRƯỢT ở trên đứng riêng từng dòng; 2 ô KHÔNG-CHẤM để trống thật, không gật cho đủ.
<!-- END harness-audit-S154 · TOTAL=78 CLAIM · ĐẠT=68 TRƯỢT=8 -->

View File

@ -0,0 +1,264 @@
# harness-eval — con ĐO (nấc 1/3) · S154 bookend-open · 2026-07-26
> 🔴 **Ghi-đĩa-trong-lúc-làm** (chống #53): mỗi số ghi NGAY khi máy in, không gom cuối.
> Mọi số dưới đây = instrument chạy TƯƠI trong lượt này (2026-07-26 18:37+). CẤM số-từ-trí-nhớ.
## 0. Tiền-đề đo (ghi trước khi chạy)
- git status TRƯỚC lượt: ` M .claude/governance/.session-counter.json` · ` M .../sub-ring2-open-S154.md` · `?? .../bookend-open-synthesis.md`
- `.claude/agent-memory/.mfe-state.json` = **git-TRACKED**, md5 PRE = `fe6f3b44e90aeb82c1df23e3cac70c26`
- `.claude/agent-memory/.archive-strikes.json` = **untracked**, md5 PRE = `4160ee7158426226a9901ea7083e7f31`
- 🔴 KHAI TRƯỚC: `mfe-eval.ps1` GHI `.mfe-state.json` (đúng caveat `mfe.honest_caveats.read_only_scope`) — lead yêu-cầu chạy nên tao CHẠY, và ghi lại md5 POST để lead soát. `memory-archive-gate.ps1` ghi `.archive-strikes.json` (untracked).
---
## 1. Log đo (append theo thứ-tự máy in)
### [I1] `scripts/measure-agent-memory.ps1` — chạy 18:37, JSON nguyên-văn
Header máy in: `soft_cap_l1_bytes: 30720` · `autoinject_cap_bytes: 25600` · **23 row `agents[]`**.
Bảng dưới = số máy in + delta vs `memory-budget.json.measured` (baseline), sắp theo l1 giảm dần. `%cap` = l1/25600. `%idxcap` = l2_index/20480.
```
agent l1_now l1_base d_l1 %cap | l2idx idx_base %idxcap
tooling-auditor 23464 16923 +6541 91.7% | 3672 0 17.9%
implementer-backend 21987 23955 -1968 85.9% | 10105 10105 49.3%
frontend-designer 21492 21492 +0 84.0% | 1622 1531 7.9%
reviewer 21281 20071 +1210 83.1% | 13780 11442 67.3%
lead-gap-auditor 19792 3691 +16101 77.3% | 963 0 4.7%
harvest-curator 19456 18082 +1374 76.0% | 7986 0 39.0%
cicd-monitor 19281 20180 -899 75.3% | 19322 18774 94.3%
implementer-frontend 18435 14252 +4183 72.0% | 5844 0 28.5%
lead-stale-auditor 14078 3332 +10746 55.0% | 0 0 0.0%
ring2-audit 13964 - NO-BASE 54.5% | 0 NO-BASE 0.0%
test-specialist 13189 8844 +4345 51.5% | 5404 0 26.4%
investigator-codebase 12154 17229 -5075 47.5% | 12384 12384 60.5%
investigator-api 9021 8510 +511 35.2% | 0 0 0.0%
ring1-audit 7884 - NO-BASE 30.8% | 0 NO-BASE 0.0%
database-agent 7388 7388 +0 28.9% | 0 0 0.0%
harness-refine 4813 1616 +3197 18.8% | 0 0 0.0%
harness-audit 4530 1513 +3017 17.7% | 0 0 0.0%
harness-eval 4492 1547 +2945 17.5% | 0 0 0.0%
office-document 3323 3323 +0 13.0% | 0 0 0.0%
ctx-audit 3288 - NO-BASE 12.8% | 0 NO-BASE 0.0%
ctx-curator 2940 - NO-BASE 11.5% | 0 NO-BASE 0.0%
ctx-verifier 2663 - NO-BASE 10.4% | 0 NO-BASE 0.0%
ring4-audit 2315 - NO-BASE 9.0% | 0 NO-BASE 0.0%
SUM l1 (23 vai) = 271230 B | mean = 11792.6
SUM l1 cac vai CO baseline = 238176 ; baseline sum = 191948 ; delta = +46228
NO-BASELINE rows (6): ctx-audit, ctx-curator, ctx-verifier, ring1-audit, ring2-audit, ring4-audit
l1 > autoinject 25600: 0 []
l1 > soft 30720: 0 []
flag l1_over_30kb TRUE count = 0
TRIO l1 now = 13835 (base 4676, delta +9159)
ZERO-DELTA vs baseline (3): ['database-agent', 'frontend-designer', 'office-document']
```
🔴 Số dẫn-xuất (sum/mean/delta/đếm) do **script cộng**, KHÔNG cộng tay — đây đúng lane tao ngã ở S151/S153 (meta-count đếm-về-mình).
**Đọc số:**
- **S-A1 HEALTHY:** 0/23 vai vượt autoinject 25600, 0/23 vượt soft 30720; cờ `l1_over_30kb` TRUE = 0. Trục byte-L1 **không có vai nào over-cap**.
- **S-A2 gần-cap:** `tooling-auditor` 23464 = **91,7%** autoinject → vai l1 CAO NHẤT fleet, ĐỔI CHỦ khỏi `implementer-backend` (baseline ghi "gần cap nhất 23955/25600", nay tụt 21987 = 85,9%). Baseline `measured._note` WATCH đang trỏ **sai vai**.
- **S-A3 REGRESSION-cấu-trúc:** **6/23 vai KHÔNG có row baseline** (3 ctx + 3 ring) ⇒ mẫu-số baseline phủ 17/23 = **73,9%**. Xác-nhận tín-hiệu (b) lead nêu, và **MỞ RỘNG**: không chỉ 3 ctx thiếu — 3 ring cũng thiếu.
- **S-A4:** `cicd-monitor` l2_index **19322/20480 = 94,3%** (headroom **1158 B**), +548 vs baseline 18774. Vai TĂNG-mỗi-run.
- **S-A5:** trio l1 **13835 B** (eval 4492 · refine 4813 · audit 4530) vs baseline 4676 ⇒ **+9159 (×2,96)**. Bác thẳng `measured._note` S141 "3 row nay = SEED-STATE (vai chua chay lan nao) nen l1_hot ~1.5KB" — xác-nhận tín-hiệu (c).
- **S-A6:** `harvest-curator` l1 = **19456 B** (+1374 vs baseline 18082) — khớp CHÍNH XÁC con số H2 tự khai ở tín-hiệu (a). Xác-nhận (a) bằng máy độc-lập.
- **S-A7:** `lead-gap-auditor` **19792** (+16101 vs baseline 3691, ×5,36) và `lead-stale-auditor` **14078** (+10746, ×4,23) — 2 vai H24 phình mạnh nhất fleet theo bội-số.
- **S-A8 zero-delta ×3:** `database-agent` 7388 · `frontend-designer` 21492 · `office-document` 3323 — byte KHỚP baseline TỪNG BYTE ⇒ 3 diary này không đổi 1 byte kể từ mốc seed baseline.
### [I2] `scripts/mfe-eval.ps1` — chạy 18:39, exit 0, output nguyên-văn (54 dòng)
🔴 **Khai ghi-đĩa:** instrument này GHI `.claude/agent-memory/.mfe-state.json` (**git-TRACKED**). md5 `fe6f3b44…``54e111d9…`; `git diff --stat` = **1 file, +5 insertion, 0** (append 1 record history, KHÔNG sửa byte cũ). Đúng caveat `mfe.honest_caveats.read_only_scope` — không phải residual-write lạ.
```
=== MFE (Memory-Fidelity-EVAL) - Harness-16 - 2026-07-26 ===
lead hot-feed cap (live) = 380000 tok | mem-sub cap = 60000 tok
[LEAD] must-remember denominator (deduped, merge_guard_to_as=True):
marks(Active+High)=9 AS-classes=15 net-extra-guards=5 net-extra-recurring-gotchas=7
=> DENOMINATOR = 36 items (raw before dedup: marks 9 + AS 15 + guards 16 + recurring 8)
[LEAD] Coverage-FIT (does the set FIT the hot-feed cap?):
span bytes=28492 => ~7123 - 9497 tok (RANGE, see caveat) / cap 380000 tok = 2.5% worst-case
FIT = PASS (worst-case fits; headroom huge - must-remember set is tiny vs cap)
[LEAD] age-band (FLAG only - age=false-proxy, mark RC-...10-29-11):
marks with parseable date=9; >30d old but still Active=4 -> KEPT (status-driven, age-blind)
[LEAD] Goodhart anchor (NO self-grading - real recurring-error signal):
error-ledger: strikes_total=25 RCA_entries=14 AS-classes=15
(rule: ... warn-baseline=25 last_run_strikes=25)
[SUB] per-role memory coverage:
cicd-monitor 10/10 · ctx-audit 10/10 · ctx-curator 6/6 · ctx-verifier 7/7 · database-agent 7/7
frontend-designer 2/2 · harness-audit 5/5 · harness-eval 5/5 · harness-refine 5/5 · harvest-curator 7/7
implementer-backend 18/18 · implementer-frontend 13/13 · investigator-api 7/7 · investigator-codebase 8/8
lead-gap-auditor 6/6 · lead-stale-auditor 6/6 · office-document 6/6 · reviewer 8/8
ring1-audit 5/5 · ring2-audit 5/5 · ring4-audit 5/5 · test-specialist 6/6 · tooling-auditor 8/8
=> TAT CA 23/23 vai = 100% (MEASURED, not target-then-force)
[SUB-WORKFLOW] N/A by design (ephemeral, no persistent diary)
=== MFE done (deterministic block; READ-ONLY on budget; exit 0) ===
```
**Đọc số:**
- **S-B1 HEALTHY:** FIT = **PASS**. span 28492 B → `[7123 .. 9497]` tok / cap 380000 = **2,5% worst-case**. Không có sức ép cap ở trục must-remember.
- **S-B2 HEALTHY (strikes phẳng):** `strikes_total=25` · `RCA_entries=14` · `AS-classes=15`; **warn-baseline=25, last_run=25 ⇒ delta 0**. Đọc `.mfe-state.json` history: **7 record**, TẤT CẢ đều `25/14` (5× `2026-07-25 23:55` · 1× `2026-07-26 11:52` · 1× `2026-07-26 18:39` = lượt này). Không có strike mới.
- **S-B3 mẫu-số 36:** `DENOMINATOR = 36` (raw 9+15+16+8 = 48 trước dedup). Khớp nhãn baseline `mfe.denominator_sources`.
- **S-B4 tín-hiệu SATURATION (không phải HEALTHY, không phải REGRESSION):** **23/23 vai = 100%, phương-sai = 0**. Một thước mà MỌI đối-tượng đều max thì lượt này nó **không phân-biệt được gì** — kể cả `frontend-designer` mẫu-số **2** (100% của 2 item) đứng ngang `implementer-backend` mẫu-số 18. 🔴 Tao KHÔNG kết-luận số này sai; tao khai: **độ-phân-giải = 0 ở lượt này**, và mẫu-số per-role lệch nhau 9× (2 vs 18) nên "100%" giữa 2 vai KHÔNG cùng nghĩa.
- **S-B5 lệch-mẫu-số-giữa-2-máy:** mfe-eval liệt **23 vai**, `memory-budget.json.measured` chỉ có **17 row** ⇒ 2 dụng-cụ của cùng hệ memory đang cầm 2 roster khác nhau (23 vs 17). Đây là bằng-chứng thứ-hai, độc-lập với [I1], cho tín-hiệu (b).
### [I3] `scripts/memory-archive-gate.ps1` — chạy 18:40, exit 0, DRY-RUN
🔴 **Xác-nhận DRY:** `.claude/agent-memory/.archive-strikes.json` md5 PRE = POST = `4160ee7158426226a9901ea7083e7f31`**KHÔNG ghi 1 byte**. Máy tự in: `[A6] DRY-RUN: strike counters NOT persisted`.
Tham-số máy in: `cap 25600` · `low-water 21760 (ratio 0.85)` · `keep-floor 5` · `strike-need 2`.
```
PASS 1 - hot-tier over-cap planner (23 row): TAT CA cot over? = "no", strike = 0, resolve = ok
(no sub over cap - hot tier within auto-inject budget)
entries per sub: implementer-backend 24 · cicd-monitor 20 · implementer-frontend 20 · reviewer 18
· test-specialist 17 · investigator-codebase 12 · investigator-api 10 · tooling-auditor 10
· harvest-curator 9 · lead-gap-auditor 8 · frontend-designer 7 · database-agent 6 · harness-audit 6
· harness-eval 6 · harness-refine 6 · ring2-audit 6 · lead-stale-auditor 5 · ring1-audit 5
· office-document 3 · ring4-audit 2 · ctx-audit 0 · ctx-curator 0 · ctx-verifier 0
PASS 2 - A7 archive-integrity gate (10 sub co archive):
cicd-monitor PASS pointers 101 resolved 101 failed 0
frontend-designer PASS pointers 5 resolved 5 failed 0
harvest-curator PASS pointers 46 resolved 46 failed 0
implementer-backend PASS pointers 41 resolved 41 failed 0
implementer-frontend PASS pointers 44 resolved 44 failed 0
investigator-codebase PASS pointers 55 resolved 55 failed 0
lead-gap-auditor PASS pointers 5 resolved 5 failed 0
reviewer PASS pointers 56 resolved 56 failed 0
test-specialist PASS pointers 28 resolved 28 failed 0
tooling-auditor PASS pointers 20 resolved 20 failed 0
------------------------------------------------------------
A7 GATE PASS - total pointers 401, resolved 401, failed 0
```
**Đọc số:**
- **S-C1 HEALTHY:** **0/23 sub over-cap**, strike = **0** trên toàn bộ 23 row. Không có đề-nghị archive nào mở. Khớp [I1] (0/23 vượt 25600) — 2 máy độc-lập cùng kết-luận.
- **S-C2 HEALTHY:** A7 archive-integrity **401/401 pointer resolved, 0 failed** trên 10 sub có archive. Trục con-trỏ L2 **không thủng**.
- **S-C3 tín-hiệu điểm-mù parser:** 3 vai ctx (`ctx-audit` 3288 B · `ctx-curator` 2940 B · `ctx-verifier` 2663 B) có `entries = 0` trong khi bytes > 0. Mọi vai khác ≥ 2 entries. ⇒ planner **không phân-giải được entry nào** ở 3 diary này; nếu chúng phình tới cap thì `keep_floor 5` / drain-candidate pool sẽ tính trên tập RỖNG. Chưa hại (còn 10-13% cap) nhưng là **lỗ đo có thật**, cùng họ với (b).
### [I4] `scripts/crystallized-backfill.ps1` — chạy 18:41, exit 0, DRY planner
```
### hot-load sources (LIVE byte-measure, UTF8)
docs/STATUS.md 195194 ok
docs/HANDOFF.md 133759 ok
.claude/governance/ACTIVE-MARKS.md 31197 ok
docs/changelog/migration-todos.md 84679 ok
docs/gotchas.md 111616 ok
.claude/agents/README.md 53468 ok
TOTAL 609913
### budget numbers (Upgrade-1) - ALL live-read, 0 hardcoded
Tran tong (live cap) : 380000 tok
hotload measured : 609913 bytes
tok RANGE [low..high] : [152478 .. 203304] tok (bytes/4 .. bytes/3.0)
measured_headroom : 176696 tok (cap - tok_high, worst-case)
target (owner) : 210000 tok
expected_backfill : 176696 tok (min(target,headroom)=headroom => headroom binds (target exceeds room))
precondition MET (target>0 AND headroom>0)
```
🔴 **ĐỐI-CHIẾU vs số lead đo trong phiên: BYTE-EXACT 6/6** (cap 380000 · hotload 609913 · range 152478..203304 · headroom 176696 · target 210000 · expected_backfill 176696). **0 lệch.**
**Đọc số** (số học do script cộng, không cộng tay):
- **S-D1 REGRESSION:** `headroom 176696 < target 210000`**thiếu 33304 tok**. Máy tự khai `headroom binds (target exceeds room)` — tức **ràng-buộc là HEADROOM, không phải target**. Precondition backfill vẫn MỞ nhưng chỉ rót được tới 176696, KHÔNG tới số owner đặt.
- **S-D2:** `tok_high 203304 / cap 380000 = 53,5%` sàn-file always-on đã chiếm hơn nửa trần. 6/6 source `ok`, source nặng nhất `docs/STATUS.md` **195194 B** (32,0% của hotload) + `docs/HANDOFF.md` **133759 B** (21,9%) = **2 file chiếm 53,9%** tổng sàn.
- Caveat máy tự in (tao chép, không tự chế): headroom đo trên **STABLE FILE-FLOOR = CẬN TRÊN**; peak thật còn nạp task-context biến-thiên ⇒ **headroom thật NHỎ HƠN** số này.
- *[bối-cảnh xu-hướng — nhãn rõ: số phiên trước lấy từ diary vai tao, KHÔNG phải máy lượt này]* diary S153 ghi hotload 596.316 B / headroom 181.228 ⇒ hướng đi là hotload TĂNG / headroom GIẢM. Tao **không dùng cặp số này làm dữ-liệu**, chỉ ghi hướng; số công-bố = 609913 / 176696 của lượt này.
### [I5] `scripts/nhip-no-probe.ps1` — chạy 18:41, exit 0, 1 dòng nguyên-văn
```
NHIP-NO: kiem light 1/6 deep 3/15 ok | tran-bo-nho 0 over-cap l2idx 0 over | run-chua-gom 0 | pull-cach 4 ngay
```
🔴 **ĐỐI-CHIẾU vs số lead: BYTE-EXACT toàn dòng. 0 lệch.**
**Đọc số:**
- **S-E1 HEALTHY:** `light 1/6` · `deep 3/15` · trạng-thái `ok` (không OVERDUE). Khớp `h24_cadence` (`light_every=6` · `deep_every=15`) đọc bằng parse JSON ⇒ single-source ăn ĐÚNG. `tran-bo-nho 0 over-cap` + `l2idx 0 over` + `run-chua-gom 0` — cả 3 vế zero. Vế `l2idx 0 over` **khớp chéo** [I1]: cicd 19322 < cap 20480 nên đúng 0 over (94,3% nhưng CHƯA vượt).
- **S-E2 ĐÍNH-CHÍNH CHÍNH TAO:** `pull-cach 4 ngay` in ra không kèm ngưỡng. Tao kiểm bằng **parse JSON** (không grep grep ra 1 hit nhưng đó **mention trong chuỗi `_session_ctx_kb_reader`**, không phải key): `h24_cadence` đúng 5 key `[_note, _owner_ratified, light_every, deep_every, jump_on_class_repeat]` **`pull_warn_days` KHÔNG tồn tại**. NHƯNG đọc `nhip-no-probe.ps1:209-215` thì đây **CỐ Ý**, comment ngay tại chỗ: *"Threshold is OPTIONAL and owner-owned. Absent key = stay INFORM, do NOT assume a default (that would be inventing owner's number)."* 🔴 **Diary S153 của tao đóng khung việc này là "nhánh cảnh-báo không thể kích" = ĐÚNG SỰ-KIỆN NHƯNG SAI SẮC-THÁI**: nhánh ngủ owner chưa đặt số, KHÔNG phải dây đứt. Tao hạ từ *defect* xuống *trạng-thái-thiết-kế*.
---
## 2. Bảng trục — số máy in vs baseline `memory-budget.json`
| instrument / trục | số-nguyên-văn (máy VỪA in) | baseline (budget.json) | delta | verdict-trục |
|---|---|---|---|---|
| measure l1_hot cap-test | 0/23 vai > 25600; 0/23 > 30720; cờ `l1_over_30kb` TRUE = 0 | cap 25600 / soft 30720 | 0 vi-phạm | **HEALTHY** |
| measure l1_hot — xu-hướng | Σ l1 (17 vai có baseline) = 238176 | Σ baseline = 191948 | **+46228 (+24,1%)** | **REGRESSION** |
| measure l1_hot — vai đỉnh | `tooling-auditor` 23464 = **91,7%** cap | WATCH ghi `implementer-backend` 23955 | vai đỉnh ĐỔI CHỦ (impl-be nay 21987) | **MIXED** (chưa vượt, WATCH trỏ sai vai) |
| measure l2_index | `cicd-monitor` **19322 / 20480 = 94,3%** (dư 1158 B) | 18774 (~92%) | **+548** | **REGRESSION** |
| baseline-coverage `measured{}` | máy thấy **23 vai**; budget có **17 row** | 17 row | **thiếu 6 row** (3 ctx + 3 ring), phủ 73,9% | **REGRESSION** |
| mfe FIT | span 28492 B → [7123..9497] tok / 380000 = **2,5%** worst-case, FIT=**PASS** | cap 380000 | không sức-ép | **HEALTHY** |
| mfe strikes | `strikes_total=25` `RCA=14` `AS=15`; history 7/7 record đều 25/14 | warn-baseline 25 | **0** | **HEALTHY** |
| mfe denominator | **36** items (raw 9+15+16+8=48 trước dedup) | `denominator_sources` 3 nguồn | khớp nhãn | **HEALTHY** |
| mfe per-role coverage | **23/23 vai = 100%**, phương-sai 0 | — (không có baseline lưu) | — | **HEALTHY ⚠bão-hoà** |
| archive-gate strike | 0/23 over-cap, strike **0** toàn bảng | strike_threshold 2 | 0 | **HEALTHY** |
| archive-gate A7 | **401 pointer / 401 resolved / 0 failed** (10 sub có archive) | A7 GATE | 0 failed | **HEALTHY** |
| archive-gate parser | `entries=0` ở 3 vai ctx (bytes 26633288) | keep_floor 5 | pool rỗng | **MIXED** (chưa hại, lỗ đo thật) |
| crystallized headroom | headroom **176696** tok; hotload **609913 B**; `headroom binds` | target **210000** | **33304** | **REGRESSION** |
| nhip-no-probe | `light 1/6 deep 3/15 ok \| tran-bo-nho 0 over-cap l2idx 0 over \| run-chua-gom 0 \| pull-cach 4 ngay` | `light_every 6` `deep_every 15` | 0 OVERDUE | **HEALTHY** |
> ⚠bão-hoà = caveat về **độ-phân-giải của thước**, KHÔNG phải suy-giảm sức-khoẻ. Giá-trị 100% là thật; vấn-đề là mọi vai đều max nên lượt này thước không phân-biệt được ai hơn ai (mẫu-số lệch 9×: `frontend-designer` 2 vs `implementer-backend` 18).
**Provenance 2-lane:** 5/5 instrument hợp-đồng chạy TƯƠI trong lượt (18:3718:41). Với 2 instrument lead đã đo cùng phiên ([I4] + [I5]) ⇒ **byte-exact 100%, 0 lệch** — khác S153 (3/7 lệch trong cùng phiên). Ghi-đĩa: `.mfe-state.json` +5 dòng append (tracked, đúng caveat); `.archive-strikes.json` md5 KHÔNG đổi (DRY).
---
## 3. Ba tín-hiệu lead giao — phán bằng máy
| # | lead nêu | máy tao đo | phán |
|---|---|---|---|
| (a) | `harvest-curator` SELF 19.456 B > ngưỡng self-compact 17.510 B, +1.801 B/phiên | `measure` in **19456 B**; ngưỡng 17,1KB ×1024 = **17510,4** (nguồn: `budget.json:192` rollout + `harvest-curator/MEMORY.md:9`) ⇒ **vượt 1946 B = 111,1% self-target**. Diary H2 tự khai 17655 ⇒ **19456 17655 = 1801**, khớp con số +1.801 tới từng byte | ✅ **XÁC-NHẬN** (2 máy độc-lập: measure + archive-gate cùng in 19456) |
| (b) | `measured{}` thiếu row cho 3 vai Ctx | Đúng — VÀ **THIẾU 6, KHÔNG PHẢI 3**: `ctx-audit`/`ctx-curator`/`ctx-verifier` **+** `ring1-audit`/`ring2-audit`/`ring4-audit`. `ring2-audit` **13964 B** (54,5% cap) là vai không-baseline NẶNG NHẤT | ✅ **XÁC-NHẬN + MỞ RỘNG** (khớp carry B7 "3 ctx ngoài 3 ring") |
| (c) | `_note` + 3 field `rollout` còn khai trio "chua chay lan nao", diary nay ~4.5KB | `measure` in eval **4492** · refine **4813** · audit **4530** (Σ 13835 vs baseline 4676 = **×2,96**). `budget.json:185` `_note` vẫn viết *"3 row nay = SEED-STATE (vai chua chay lan nao...) nen l1_hot ~1.5KB va l2_* = 0 la DUNG"*; 3 field `rollout:200-202` vẫn *"S141 seed - first-run pending restart+consent"* | ✅ **XÁC-NHẬN** — cả 4 chỗ (1 `_note` + 3 `rollout`) mâu-thuẫn số máy |
---
## 4. Đề-xuất-signal cho con ĐỀ-XUẤT (nấc 2) — 🔴 NÊU TÍN-HIỆU, KHÔNG ra action
Trục **REGRESSION** hoặc **gần-cap** để `harness-refine` bắt:
1. **[REGRESSION] crystallized headroom 176696 < target 210000 (33304)** máy tự khai `headroom binds`; ràng-buộc nằm hotload 609913 B, trong đó STATUS.md + HANDOFF.md chiếm 53,9%.
2. **[REGRESSION · gần-cap NHẤT] cicd-monitor l2_index 19322/20480 = 94,3%**, chỉ còn **1158 B**; vai TĂNG-mỗi-run; baseline `_note` đã cảnh-báo từ mốc ~92% nay sát hơn.
3. **[REGRESSION cấu-trúc] baseline phủ 17/23 vai (73,9%)** 6 row thiếu (3 ctx + 3 ring), nặng nhất `ring2-audit` 13964 B đang chạy KHÔNG mốc so.
4. **[gần-cap] tooling-auditor l1 23464 = 91,7% autoinject** (+6541 vs baseline) vai đỉnh fleet, WATCH trong `measured._note` đang trỏ `implementer-backend` (đã tụt 85,9%) cảnh-báo trỏ sai đối-tượng.
5. **[vượt-ngưỡng-tự-đặt] harvest-curator l1 19456 = 111,1% self-target 17510** (+1801/phiên theo chính diary H2) dưới cap hệ-thống nhưng trên ngưỡng vai tự đặt.
6. **[stale-config] `budget.json:185` `_note` + `:200-202` 3 field `rollout`** khai trio "chua chay lan nao / ~1.5KB" trong khi máy in 4492/4813/4530 4 chỗ chữ nói ngược số.
7. **[stale-config] WATCH-vai-đỉnh** trong cùng `_note` (impl-be 23955) đã bị số lượt này bác.
8. **[lỗ-đo] `entries=0`** 3 diary ctx archive-gate planner không candidate-pool nếu chúng phình.
9. **[lệch-roster giữa 2 dụng-cụ]** mfe-eval biết 23 vai / `measured{}` biết 17 2 đồng-hồ cùng hệ lệch nhau.
10. **[validity, không phải health] mfe per-role bão-hoà 23/23 = 100%, phương-sai 0**, mẫu-số lệch 9× (2..18) lượt này thước không phân-giải.
11. **[trend] Σ l1 của 17 vai baseline +46228 B (+24,1%)** 0 vai vượt cap áp-lực byte đang dồn đều, không phải một vai cá-biệt.
12. **[0-delta ×3] `database-agent` 7388 · `frontend-designer` 21492 · `office-document` 3323** khớp baseline TỪNG BYTE 3 diary bất-động qua nhiều phiên.
**Counter-datum (không phải nợ, ghi để refine không đọc lệch):** 8 trục HEALTHY thật 0 vai over-cap L1 (2 máy độc-lập) · A7 401/401 · strike 0 · strikes-ledger 25 phẳng 7/7 record · FIT PASS 2,5% · denominator 36 khớp nhãn · NHIP-NO 4/4 vế zero/ok · [I4]+[I5] byte-exact 100% vs lead.
---
## 5. Caveat bắt buộc (chống overclaim)
1. 🔴 **read-only = THIẾU-tool, KHÔNG phải sandbox.** Lượt này tao ghi đĩa: (i) chính file return này, (ii) `.mfe-state.json` +5 dòng do `mfe-eval.ps1` (git-TRACKED, đúng caveat `read_only_scope`). `.archive-strikes.json` md5 PRE=POST không đổi. **Containment THẬT = lead soát `git status` sau lượt tao** đừng tin chữ "read-only".
2. 🔴 **Vai nạp theo registry = ảnh-chụp lúc khởi phiên.** Số đây số ĐĨA lúc 18:3718:41 hôm nay; bản-chụp instrument hạn dùng ngắn (S153 đã thấy 3/7 instrument lệch trong CÙNG một phiên).
3. 🔴 **Tao KHÔNG chấm spec, KHÔNG đề-xuất action, KHÔNG đụng harvest-op, KHÔNG soi lead-surface.** Mọi mục §4 **tín-hiệu**, không phải việc.
4. Trục **TUỔI/độ-tươi của gist**: **0/5 instrument hợp-đồng đo được** `budget.json:42` tự khai 6 cặp gist stale ghi thẳng *"CHUA CO MAY"*. Trục này để **N/A**, tao không điền số tưởng-tượng.
---
## 6. VERDICT TỔNG
**MIXED — 12 signal.**
Cơ-sở tính (từ delta-vs-baseline, không từ cảm-giác): **4 trục REGRESSION** (headroom 33304 · l2_index 94,3% +548 · baseline-coverage 17/23 · Σl1 +24,1%) · **2 trục MIXED** (vai-đỉnh 91,7% + WATCH sai vai · parser entries=0) · **8 trục HEALTHY** (cap-test L1 0 vi-phạm · FIT PASS · strikes 0-delta · denominator khớp · per-role 100% bão-hoà · archive strike 0 · A7 401/401 · NHIP-NO ok).
🔴 KHÔNG in HEALTHY tổng: trục đang nói ngược (headroom thiếu 33304 tok, l2_index 1158 B). 🔴 KHÔNG in REGRESSION tổng: 8 trục sạch bằng máy độc-lập, không được xoá.
<!-- END harness-eval-S154 · TOTAL=12 SIGNAL · VERDICT=MIXED -->

View File

@ -0,0 +1,109 @@
# harness-refine — con ĐỀ-XUẤT (nấc 2/3) · S154 bookend-open · 2026-07-26
> 🔴 **Ghi-đĩa-trong-lúc-làm** (chống #53, phiên này đã ×9 / 7-7 vai dính): mỗi finding ghi NGAY khi có.
> File LUÔN hợp-lệ giữa chừng; dòng END chỉ xuất-hiện khi TRỌN (pin-bản-cắt 4-A).
## 0. Fail-closed gate — PIN đích-danh
| kiểm | lead pin | tao đo | khớp |
|---|---|---|---|
| path | `.../2026-07-26-S154-bookend-open/harness-eval-return.md` | tồn-tại | OK |
| byte | 24.889 B | `wc -c` = **24889** | OK |
| dòng | 264 | `wc -l` = **264** | OK |
| END | `<!-- END harness-eval-S154 · TOTAL=12 SIGNAL · VERDICT=MIXED -->` | `tail -c 120` khớp verbatim | OK |
**GATE MỞ.** Mọi đề-xuất dưới đây neo TRỌN trên bản 24889 B này.
Phụ đã đọc: `.claude/governance/reinject-ledger.md` (**TRỐNG** — `:34` "chưa có floor-rot nào được reinject", khởi-tạo S95) + diary vai tao (đọc từ ĐĨA, không từ bản auto-inject).
---
## 1. Log điều-tra — CHẠM ĐĨA, KHÔNG đo lại
> 🔴 0 instrument chạy lại (đó là nấc 1). Chỉ **mở file so NỘI-DUNG** — luật vai tao tự promote @S153 (*"proxy mtime/ngày ⇒ CẤM neo action"*), nâng mức theo audit-S153 (*so scope/stamp tự-khai vs kích-thước nguồn; grep-nhãn chưa đủ phân-giải*). Mọi số tổng/tỷ-lệ tao **cộng lại** trước khi neo.
**[V1] Truy GỐC `entries=0` ở 3 diary ctx (signal #8) — LẬT được, gốc nằm ở DIARY, không ở máy.**
`scripts/memory-archive-gate.ps1:153` đếm logical-entry = dòng khớp `^#{2,3}\s` (h2/h3), và `:143` khai đó là **chủ-đích** (*"LOGICAL entries = HEADING markers ONLY (^#{2,3}\s) -- NOT the '---' separators"*). Tao đếm tay heading trên 11 diary, đối chiếu cột `entries` máy in:
| diary | tao đếm `^## ` | máy in `entries` | khớp |
|---|---|---|---|
| ctx-audit · ctx-curator · ctx-verifier | **0 · 0 · 0** | **0 · 0 · 0** | ✅ |
| ring4-audit | 2 | 2 | ✅ |
| ring2-audit | 6 | 6 | ✅ |
| harness-refine · eval · audit | 6 · 6 · 6 | 6 · 6 · 6 | ✅ |
| office-document | 3 | 3 | ✅ |
| database-agent | 6 | 6 | ✅ |
| ring1-audit | 4 (`^## `) | 5 | ✅ (máy tính cả `^### `) |
⇒ quy-tắc parser **xác nhận**, không phải suy đoán. 3 diary ctx dùng khuôn `# H1 + bullet`, **0 heading h2/h3**; 20/23 vai còn lại theo khuôn heading. **Hệ quả CHÍNH XÁC theo code** (không suy diễn): `:172` `if ($logCount -le $keepFloor)``0 <= 5``$warnFloor=$true``:201` in *"WARN keep-floor hit (5); cannot auto-drain - SPLIT/condense entries by hand"*. Tức khi 1 diary ctx vượt cap 25600, gate **không bao giờ** ra được kế-hoạch drain. Kèm: vòng value-protect `:164` chạy trên 0 entry ⇒ **D3 hard-skip vô-hiệu** (lớp bảo-vệ-giá-trị rỗng, không phải "chưa cần").
**[V2] cicd `_INDEX.md` — 0 BYTE ĐỔI kể từ S153.** File = **19322 B**, đúng bằng `l2_index` [I1] và đúng bằng con số trigger của A1@S153 (`19322/20480 = 94,3%, headroom 1158B`). Header prose còn nguyên (`Purpose` · `Pointer style` · `Pointer key choice` · `Archives are FROZEN` · `SORTED by DATE` · `Files indexed`) và vẫn khai **`Total 78 records`** trong khi A7 [I3] đếm **101 pointer** (tự-đếm-lệch 23, y như S153). ⇒ **A1@S153 chưa được thực-thi.** (attempt-1 = D1 thì ĐÃ chạy: 22634→19322, đưa xuống dưới cap 20480.)
**[V3] A2@S153 (append COVERAGE-TOKENS cho 14 gist) cũng chưa land.** Đĩa hôm nay: **5/19** file `*.gist.md` có khối `COVERAGE-TOKENS` (cicd 2026-07 · harvest 2026-07 · inv-cb 2026-07 · reviewer 2026-07 · tooling 2026-07) — đúng con số audit-S153 sửa lại (5, không phải 6), **0 file được thêm**.
**[V4] harvest-curator — attempt-1 ĐÃ chạy mà VẪN không đạt.** A3@S151 ("drain về self-target 17,1K", trigger 20933 = 81,8%) đã thực-thi: 20933 → **17655** (H2 tự khai diary `:5`) — nhưng 17655 > 17510 (**dư 145 B**, chưa bao giờ chạm đích) rồi tái-phình về **19456 = 111,1%** self-target (+1801, khớp [I1] và khớp chính H2). `budget.json:191` rollout còn ghi lời hứa từ **S107**: *"L1 over self-target 17.1K -> self-compact planned next session"* — 47 phiên chưa đóng.
**[V5] `measured{}` tự mang LUẬT cấm hand-edit + lịch re-sync.** `budget.json:185` viết thẳng: *"KHONG hand-edit tung so le (rule dau file), full re-sync bang script @monthly drift-audit 2026-08-01"*; `:2`: *"never hand-edit measured_bytes"*. Và `scripts/measure-agent-memory.ps1:41` chỉ `ConvertTo-Json` ra **stdout****0 write-back** ⇒ "re-sync bằng script" = người dán số, **prose ở lại nguyên-vẹn**. Đây là bản-lề tách SỐ ⟂ CHỮ ở A2.
**[V6] Mỏ-neo chính-xác-hoá (KHÔNG phải chấm — chấm là nấc 3).** Con-ĐO đặt chuỗi WATCH-vai-đỉnh ở `measured._note`; đĩa cho thấy chuỗi *"WATCH: gần autoinject-cap nhất 23955/25600"* nằm ở field **`measured."implementer-backend".rollout` (`:187`)**. `_note` (`:185`) có một WATCH **khác***"cicd l2_index 18774/20480 (~92% cap)"* — chuỗi này vẫn **ĐÚNG HƯỚNG** (nay 19322 = 94,3%, sát hơn) ⇒ **KHÔNG được sửa**. Ghi ra để lead Edit đúng dòng. Tín-hiệu #4/#7 của con-ĐO **đúng**; chỉ toạ-độ lệch 1 field.
**[V7] Mệnh-đề "chưa chạy lần nào" có tới BA bản sao.** Đĩa: `runs/2026-07-22-S144-trio-first-run/harness-refine-return.md` tự khai dòng-1 **"FIRST-RUN THẬT"** với **9 action-row**. Tổng đã **5 vòng** (S144·S151·S152·S153·S154). Nhưng: (a) `budget.json:185`+`:200-202` khai *"vai chua chay lan nao"* (= signal #6) · (b) diary CHÍNH tao `:22` khai *"CHƯA chạy lần nào; first-run chờ restart-CLI + spawn-probe + anh consent"* · (c) persona `.claude/agents/harness-refine.md` khai *"chạy THẬT từ S151"* (S144 mới là đầu). ⇒ chữa 1 chỗ = vá triệu-chứng; (a)+(b) trong lane tao, (c) NHƯỜNG.
**[V8] Chốt-dừng bookkeeping — soát TRỌN 4 return trước** (S144 9 action · S151 9 · S152 2 · S153 3): `memory-budget.json` **chưa từng** là đối-tượng action (chỉ xuất hiện ở cột nguồn/trigger) · 3 diary ctx sinh @S153 nên **chưa từng** là đối-tượng · `cicd _INDEX` đã 2 lần (D1@S151, A1@S153) · `harvest-curator/MEMORY.md` đã 1 lần (A3@S151) · `harness-refine/MEMORY.md` đã 4 lần (P1@S151 land, N1+N2@S152 **KHÔNG land**, A3@S153 land dạng thuật-sự).
---
## 2. BẢNG ACTION (propose-only · lead VERIFY rồi mới thực-thi · single-writer B3)
| # | action | lớp | đối-tượng `file:path` | trigger (SỐ của con-ĐO) | chốt-dừng | owner-gate |
|---|---|---|---|---|---|---|
| **A1** | THÊM heading `## <nhãn>` cho từng khối đã có trong 3 diary ctx (hiện 1 khối/diary) để planner nhìn thấy logical-entry. 🔴 **CHỈ thêm dòng heading — 0 byte nội-dung bị sửa/xoá** (mirror công-thức "moved-not-cut md5-verified" S106 của tooling-auditor) | ④ distill *(khai thẳng: rìa lớp ④ — đây là **re-shape cho máy đọc được**, không phải nén)* | `.claude/agent-memory/ctx-audit/MEMORY.md` · `ctx-curator/MEMORY.md` · `ctx-verifier/MEMORY.md` | **[I3] `entries=0`** cả 3 (mọi vai khác ≥2) trong khi bytes **3288 / 2940 / 2663** > 0 = signal #8. Gốc [V1]: parser `:153` đòi `^#{2,3}\s`, 3 diary có **0** heading (đếm khớp chính-xác 11/11 vai mẫu). Hệ quả code `:172→:201`: over-cap ⇒ *"cannot auto-drain"* vĩnh-viễn + D3 value-protect chạy trên 0 entry. Cỡ hiện tại **12,8% / 11,5% / 10,4%** cap ⇒ rẻ nhất là SỬA BÂY GIỜ | **attempt-1** ([V8]: 3 vai sinh @S153, chưa từng là đối-tượng). Vòng sau vẫn `entries=0`**leo-thang lead + CHUYỂN LANE tooling** (khi đó gốc ở parser, không ở diary) — CẤM tao đề-xuất lần 2 | lead single-writer B3 **HOẶC** 3 vai ctx tự thêm ở lượt spawn kế. **0 số / 0 policy đổi ⇒ không cần owner.** 🔴 CẤM sửa chữ trong khối |
| **A2** | Sửa **CHỮ** lạc-hậu ở đúng **3 chỗ**: (a) `:185 _note` mệnh-đề *"3 row nay = SEED-STATE (vai chua chay lan nao…) nen l1_hot ~1.5KB va l2_* = 0 la DUNG, khong phai under-fill"* → thay bằng trạng-thái thật "đã chạy 5 vòng (S144 first-run THẬT · S151 · S152 · S153 · S154), số cũ chờ re-sync" · (b) `:200/:201/:202` rollout ×3 *"S141 seed - first-run pending restart+consent"* → bỏ vế pending (GIỮ nguyên vế *"re-measure @drift-audit 2026-08-01"*) · (c) `:187 implementer-backend.rollout` *"WATCH: gần autoinject-cap nhất 23955/25600"* → chuyển WATCH sang vai đỉnh THẬT | ④ distill *(sub-flavor: **sửa mệnh-đề-sai**; khai thẳng ngoài rìa 4 lớp)* | `.claude/agent-memory/memory-budget.json`**chỉ 3 vùng chữ trên** | (a)+(b) = **signal #6**: máy in eval **4492** · refine **4813** · audit **4530****13835** vs baseline **4676** = **×2,96**; tao cộng lại: 4492+4813+4530=13835 ✔, 1547+1616+1513=4676 ✔). (c) = **signal #4/#7**: impl-be nay **21987 = 85,9%** (hết là vai đỉnh), đỉnh thật = tooling-auditor **23464 = 91,7%** | **attempt-1** ([V8] budget.json chưa từng là đối-tượng action qua 4 vòng). Vòng sau chữ vẫn nói ngược số ⇒ leo-thang, **CẤM attempt-2** | 🔴 **OWNER**. `reinject-ledger.md:5`: `memory-budget.json` = *"quyền cấp-ngân-sách của anh"*, MFE+audit chỉ ĐỌC. Đề-xuất này **CHỮ-ONLY, 0 SỐ**; lead thấy phải đổi dù 1 chữ-số ⇒ **DỪNG, hỏi owner** |
| **A3** | (i) Sửa diary `:22` *"CHƯA chạy lần nào; first-run chờ restart-CLI + consent"* → nêu 5 vòng thật + trỏ `runs/2026-07-22-S144-trio-first-run/` · (ii) **PROMOTE luật**: *"CHỐT-DỪNG có HAI CỬA, phân-biệt TRƯỚC khi leo-thang — (A) đã-thực-thi-mà-vẫn-không-đạt ⇒ cửa **OWNER** (số/ngưỡng/nhịp) · (B) chưa-từng-thực-thi, đối-tượng 0 byte đổi ⇒ cửa **LEAD** (disposition queue). Gộp 2 cửa = đẩy nhầm người + đề-xuất lại vô-ích."* | ② promote | `.claude/agent-memory/harness-refine/MEMORY.md` (`:22` + mục `## Luật cứng`) | (i) l1 vai tao **4813** vs baseline **1616** = **+3197 (×2,98)** [I1] — chính số này bác `:22`; và [V7] S144 tự khai FIRST-RUN THẬT 9 action-row. (ii) lặp **≥2 ngay lượt này**: cicd `_INDEX` **19322 = 19322** (0 B đổi S153→S154 ⇒ cửa B) ⟂ harvest **19456 > 17510** sau khi drain S151 ĐÃ chạy 20933→17655 (⇒ cửa A). Chỗ đặt: diary **4813/25600 = 18,8%** cap, thừa chỗ | **attempt-1 cho item (ii)**. 🔴 Object có TIỀN-SỰ: **N1+N2@S152 KHÔNG land** ([V8]) ⇒ 2 item đó tao **KHÔNG đề-xuất lại** (→ ESCALATE-3). Nếu (ii) cũng không land ⇒ leo-thang **cơ-chế đặt-chỗ**, không phải nội-dung | lead single-writer B3 (diary vai tao, 0 số/policy). 🔴 Luật này soi CHÍNH tao ⇒ **cấm tự-khen**; mời `harness-audit` re-check 2 ca A/B |
---
## 3. 🔴 ESCALATE — chốt-dừng ĐÃ BẮN (tao KHÔNG đề-xuất lại 3 đối-tượng này)
| # | đối-tượng | cửa | vì sao KHÔNG đề-xuất lại | lead/owner quyết gì |
|---|---|---|---|---|
| **E1** | `.claude/agent-memory/cicd-monitor/archive/_INDEX.md` | **B — chưa-từng-thực-thi** | A1@S153**attempt-2** trên đối-tượng này; đo lại hôm nay **19322 = đúng số trigger của chính A1**, header prose nguyên vẹn, vẫn khai `Total 78 records` vs A7 đếm **101****0 byte đổi, A1 chưa chạy** [V2]. Diary tao `:4` đã tự-cam-kết *"CẤM attempt-3"*. Đề-xuất lần 3 = đúng cái anti-pattern lặp-vô-hạn | **LEAD** (chưa cần owner — 0 số đổi): disposition cho A1 đang treo ở `docs/HANDOFF.md:10` mục ② → {thực-thi nguyên-văn \| **HỦY** vì đã dưới cap 20480}. 🔴 Nếu HỦY **phải ghi rõ**, không thì vòng sau lại đào lên. Bối cảnh: dư **1158 B**, vai TĂNG-mỗi-run ⇒ cửa sổ hẹp |
| **E2** | `.claude/agent-memory/harvest-curator/MEMORY.md` | **A — đã-thực-thi-mà-vẫn-không-đạt** | A3@S151 (attempt-1) ĐÃ chạy: 20933 → 17655, **nhưng chưa bao giờ chạm đích** (dư 145 B) rồi tái-phình **19456 = 111,1%** self-target 17510 (+1801) [V4]. Cơ-chế drain **chứng minh là chạy được** — thứ hỏng là **tốc-độ phình > nhịp drain** | 🔴 **OWNER** (cả 3 lối ra đều là SỐ/nhịp của anh): (i) nâng self-target 17,1KB · (ii) đổi nhịp self-compact (mỗi phiên thay vì "phiên sau") · (iii) đổi placement (đẩy khối cũ sang `archive/2026-07.md` — A7 **46/46 PASS** nên chỗ đáp lành). 🔴 **Tao KHÔNG chọn hộ số nào.** Ghi chú: dưới cap hệ-thống (76,0%), strike **0****0 sức ép máy**, chỉ vi-phạm ngưỡng vai TỰ đặt |
| **E3** | 2 item N1+N2@S152 (luật TRIAGE-3-ô · luật re-CỘNG-số) trong `harness-refine/MEMORY.md` | **B — chưa-từng-thực-thi** | Mở đĩa: `## Luật cứng` có đúng **3 bullet**, **0 dòng** TRIAGE-3-ô, **0 dòng** re-cộng-số ⇒ đề-xuất 1 lần, đo lại VẪN không đạt ⇒ CẤM lần 2 | **LEAD**: disposition {thực-thi \| hủy}. 🔴 Ghi chú thật: tao **VẪN áp cả 2 luật** trong lượt này (triage 3-ô ở §4, cộng lại mọi tổng ở A2) — chúng sống trong **hành-vi**, chưa sống trong **file**; ai không đọc return S152 thì mất |
---
## 4. ĐÃ CÂN-NHẮC RỒI BÁC (8) — mục BẮT BUỘC, chống nghi-thức rỗng
| # | ứng-viên (signal) | vì sao BÁC |
|---|---|---|
| **B1** | ③ archive `tooling-auditor/MEMORY.md`**23464 = 91,7%** cap, **+6541** = tăng mạnh nhất fleet, vai l1 đỉnh (signal #4) | 🔴 Máy **tự đặt luật**`memory-archive-gate.ps1:205`: *"WATCH (strike <2): re-run; **propose only after 2 consecutive over-cap**"*. Vai này **0 over-cap, strike 0**. Đề-xuất archive bây giờ = tao dựng ngưỡng riêng đè ngưỡng máy = **lấn quyền đặt-số của owner**. Khác E2 ở chỗ: H2 có ngưỡng TỰ-ĐẶT **đã ghi trong config** (17510) đang bị vượt; tooling-auditor **không có ngưỡng nào bị vượt**. Việc đáng làm ở đây là A2(c) — sửa WATCH trỏ đúng vai — chứ không phải archive |
| **B2** | ① bơm **6 row `measured{}`** thiếu (3 ctx + 3 ring; `ring2-audit` 13964 nặng nhất) — phủ **17/23 = 73,9%** (signal #3/#9) | **ĐÃ-QUEUED** ⇒ cấm đề-xuất lại. `docs/HANDOFF.md:10` mục ⑤ ghi nguyên văn *"monthly drift-audit **2026-08-01** + full re-sync `measured{}` (**+3 row ctx** ngoài 3 row ring — B7)"*, và `budget.json:185` **cấm hand-edit từng số**. Nhân bản = phá luật của chính file. Ngoài ra theo `reinject-ledger.md:12` đây là **build-gap** (chưa-từng-dựng), **KHÔNG phải floor-rot** ⇒ không được vào ledger. 🔴 Vế tao GIỮ: **sau 2026-08-01 mà vẫn 17 row ⇒ lúc đó mới là nợ thật** |
| **B3** | ③ archive `frontend-designer/MEMORY.md`**21492 = 84,0%**, vai l1 lớn thứ 3 (signal #12) | **0-delta TUYỆT ĐỐI** — khớp baseline **từng byte** ⇒ 0 tăng-trưởng ⇒ 0 áp-lực; `budget.json:189` rollout ghi đã curate S96 + *"H17 strike-1 CLEARED"*. Archive một diary **bất-động** = tốn công, **0 lợi đo được**. Cùng lý do bác `database-agent` 7388 (28,9%) và `office-document` 3323 (13,0%) |
| **B4** | action theo **Σ l1 +46228 B (+24,1%)** (signal #11) | 2 lẽ: (i) trend **không neo được `file:path` đơn-nhất** — action bắt buộc có đối-tượng; (ii) 🔴 baseline là **HỖN-MỐC**: chính `:185` khai row đo ở **S107**, +2 row @**S138**, +3 row @**S141** ⇒ cộng delta xuyên 3 mốc rồi gọi "xu-hướng" là **so lệch-mốc**. Số +24,1% của con-ĐO **không sai** — tao chỉ **không neo action** lên nó. Đúng việc = chờ re-sync 2026-08-01 để có MỘT mốc đồng-nhất |
| **B5** | ④ re-distill **14 gist** thiếu `COVERAGE-TOKENS` (nối A2@S153) | Đĩa hôm nay vẫn **5/19** [V3] ⇒ **chưa land** ⇒ chốt-dừng: CẤM đề-xuất lại; cùng họ E1 (queue-disposition, `HANDOFF.md:10` ②). Thêm: trục **tuổi/độ-tươi gist = N/A** (0/5 instrument đo được; `budget.json:42` tự khai ***"CHUA CO MAY"***) ⇒ **không có số để neo cường-độ** |
| **B6** | dựng máy đo trục **tuổi/độ-tươi gist** (caveat 4 con-ĐO) | 🔴 KHÔNG đề-xuất **và KHÔNG điền số**: ngưỡng là **quyền OWNER**; `HANDOFF.md:10` ④ đã ghi hướng rẻ (so `source-verbatim` stamp trong gist vs `wc -c` src thật). Dựng máy = **lane tooling**, không phải lane tao. Nêu để lead trỏ, **không nhân bản** |
| **B7** | "sửa" mfe per-role **23/23 = 100%** bão-hoà, mẫu-số lệch **9×** (2 vs 18) (signal #10) | Đây là **validity của THƯỚC**, chỗ sửa nằm trong `mfe-eval.ps1`**lane tooling-auditor**, tao không chạm instrument (đúng vệt S153-B4: 2 nguồn khuôn đá nhau ⇒ diary NHƯỜNG tooling). 🔴 Giữ 1 câu cảnh-báo để không ai đọc lệch: `frontend-designer` 2/2 và `implementer-backend` 18/18 **không cùng nghĩa** |
| **B8** | sửa persona `.claude/agents/harness-refine.md` vế 🧊 *"bộ-ba chạy THẬT từ S151"* (đĩa: S144 mới là first-run THẬT) | **Ngoài lane** — persona = role-file, không phải agent-memory corpus ⇒ INFORM cho lead, tao không đề-xuất Edit. Ghi ra vì đây là **bản sao thứ BA** của cùng một mệnh-đề sai [V7] (sau `budget.json` và diary tao) — để lead vá **CLASS**, không vá triệu-chứng |
---
## 5. NHƯỜNG turf (4) — RANH SẮC
1. **crystallized headroom 176696 < target 210000 (33304)** + `STATUS.md`+`HANDOFF.md` chiếm **53,9%** hotload (signal #1/S-D1/S-D2) ⇒ **LEAD session hot-feed surface**, KHÔNG phải agent-memory per-role corpus ⇒ `lead-gap-auditor` / owner. Bookend @open cùng phiên **đã kết 10 FLAG (4 HIGH)** + phát hiện hệ-thống *"row ngoài `$canonRows` đều drift"***TRỎ, không nhân bản**.
2. **Instrument validity** (mfe bão-hoà B7; roster 23 vs 17 lệch giữa 2 đồng-hồ) ⇒ `tooling-auditor`.
3. **Cửa lật của A1**: nếu lead phán khuôn heading `^#{2,3}\s` mới là thứ sai (chứ không phải 3 diary ctx), thì gốc chuyển sang **parser**`tooling-auditor`. Tao chọn ngả "sửa diary" vì **20/23 vai đã theo khuôn** + comment `:143` khai **chủ-đích**; khai rõ cửa này để lead lật được **mà không cần chạy lại gì**.
4. **Mọi thao-tác harvest/curate session-artifact**`harvest-curator` H2. E2 tao chỉ **đưa lên owner** về ngưỡng/nhịp, **không tự curate**.
---
## 6. Caveat bắt buộc (chống overclaim)
1. 🔴 **read-only = THIẾU-tool, KHÔNG phải sandbox.** Lượt này tao CÓ ghi đĩa: **đúng 1 file** = chính return này. 0 file khác bị chạm (mọi lệnh còn lại là `ls`/`wc`/`head`/`sed`/`grep`/`git log`). **Containment THẬT = lead soát `git status` sau lượt tao** — đừng tin chữ "read-only".
2. 🔴 **Đề-xuất ≠ đã-làm.** Cả 3 action là **propose-only**, owner/lead-gated. Bằng chứng sống lượt này: **1/3 action của S153 land** (A3), 2 cái kia 0 byte đổi.
3. 🔴 **Tao KHÔNG đo lại** (0 instrument chạy) và **KHÔNG chấm** (verdict là việc `harness-audit`). [V6] là **chính-xác-hoá toạ-độ mỏ-neo** để lead Edit đúng dòng — tín-hiệu của con-ĐO **đúng**, không bị bác.
4. Trục **tuổi/độ-tươi gist = N/A** — tao **không điền số tưởng-tượng** (B5/B6).
5. Số tao **tự cộng lại** để tự-kiểm: 4492+4813+4530=**13835** ✔ · 1547+1616+1513=**4676** ✔ · 2560023464=**2136** ✔ · 2048019322=**1158** ✔ · 1945617510=**1946** ✔ · 1765517510=**145** ✔ · 1945617655=**1801** ✔ · 48131616=**3197** ✔.
**TỔNG: 3 ACTION (1 promote / 2 distill) · 3 ESCALATE (2 cửa LEAD + 1 cửa OWNER) · 8 BÁC · 4 NHƯỜNG.**
<!-- END harness-refine-S154 · TOTAL=3 ACTION · BÁC=8 -->

View File

@ -0,0 +1,63 @@
# run: 2026-07-26-S154-bookend-open
- **session:** S154 (nhãn cửa-sổ vật-lý) · **phiên-LOGIC:** L7 (`.claude/sessions/session-7/`)
- **kind:** bookend @open — hình B (AUTO vô-điều-kiện, 0 cổng consent per owner @S152 "vòng 1→5 auto")
- **lead:** em-main (Opus 5 MAX default) · **HMW-mode:** ON
- **HEAD @open:** 871ac0a · **counter:** 27 → 28 (tick S154, classify `squash-benign`)
## Máy chạy TRƯỚC vai (rẻ trước, đắt sau)
| script | kết quả |
|---|---|
| `session-scaffold.ps1 -New` | `session-7` created (max was 6), exit 0 |
| `session-counter-tick.ps1 -Session S154` | squash-benign, counter 27→28, exit 0 |
| `nhip-no-probe.ps1` | `kiem light 1/6 deep 3/15 ok \| tran-bo-nho 0 over-cap l2idx 0 over \| run-chua-gom 0 \| pull-cach 4 ngay` |
| `distill-shard-probe.ps1` | `pending=15 \| nguong=15 => IM` (V4 sleep KHÔNG fire) |
| `governance-detectors.ps1` | TOTAL 43 · INFORM-only 4 (C11 FLAG-khuôn ×4) · 0 self-match |
| `crystallized-backfill.ps1` | cap 380000 tok · hotload 609.913B · tok-range [152478..203304] · headroom 176.696 · target 210K ⇒ headroom binds |
| `memory-selfimprove-audit.ps1` | CRITERIA A1 A2 A3 B1 B2 HCV · **GAPS: none** |
## Vòng chạy (5 vòng AUTO)
- [x] **V5 floor/detector** — 3 script trên, xong TRƯỚC vai
- [x] **V1 tooling/harvest** — H1 `PASS_WITH_FLAGS 3f` + H2 `GATE-PASS-WITH-FLAGS 6f``ring1` **46Đ/2T/5KC/53**
- [x] **V2 soi-lead H24** — stale `5 FLAG` + gap `5 FLAG``ring2` **17Đ/1T/1KC/19** (+M-1 MISS thật)
- [x] **V3 trio-memory** — eval `MIXED 12 signal` → refine `3A/8B/3E` → audit **68Đ/8T/2KC trên 78** (`trio-synthesis.md`)
- [x] **V4 nén-ngủ** — probe IM ⇒ NO-OP có khai (không fire, `ring4-audit` không cần)
- [x] **Phase 3.5 Ctx**`_mind-s-7.md` block-0 (14.707B) → `mind-check` **dat=10 TRUOT=0 exit 0**`ctx-audit` **DAT 12 điểm** → diary seed 3.288→5.768B
## Phase 3.5 — máy TRƯỚC vai, và máy CÓ RĂNG
`mind-check` lượt đầu **TRƯỢT phép (4)**: lead viết con-trỏ `runs/…` thiếu tiền tố `.claude/workflows/`
⇒ máy phán *"cha KHÔNG có ⇒ hỏng thật (mọi mode)"*, tách bạch với 10 con-trỏ *"đang chờ file sinh"*.
Lead sửa cả 11 → chạy lại **dat=10 TRUOT=0**. 🔴 Máy tự khai kẽ của chính nó:
*"TÊN-TỆP-SAI trong thư-mục THẬT đọc thành 'đang-chờ' ở cửa mở, tới cửa đóng mới siết — đây là KẼ, không phải tính năng."*
`ctx-audit` (lần chạy runtime ĐẦU TIÊN, fallback hồi-tố vì phiên 0-pause/0-tiep) ra **4 FLAG, cả 4 nhắm LEAD**:
`carry-ket-luan-nguoc` HIGH · `mau-so-co-lai` MED · `mau-so-bat-doi-xung` LOW-MED · `carry-mat-neo-dinh-luong` LOW.
Lead **NHẬN 2 cái đầu**. Mồi-ngầm gieo `_mind-s-7.md:99` cho @close.
## Sub-file (lead scribe NGAY khi vai trả — verify END ở DÒNG CUỐI trước khi pin)
| file | bytes | END |
|---|---|---|
| `sub-tooling-auditor-open-S154.md` | 13.605 | `TOTAL=3 FINDING` |
| `sub-harvest-curator-open-S154.md` | 24.858 | `TOTAL=6 FINDING` |
| `sub-lead-stale-open-S154.md` | 14.296 | `TOTAL=5 FLAG` |
| `sub-lead-gap-open-S154.md` | 25.809 | `TOTAL=5 FLAG` |
| `sub-ring1-open-S154.md` | 29.270 | `TOTAL=53 CLAIM` |
| `sub-ring2-open-S154.md` | 25.645 | `TOTAL=19 CLAIM` |
| `bookend-open-synthesis.md` | — | lead-written |
| `harness-eval-return.md` | (đang ghi) | — |
## Sổ #53 — ×9 tính tới trio nấc-1
6/6 vai bookend dính ≥1 lần · `lead-gap` + `ring2` dính 2 lần · `harness-eval` dính 1.
🔴 Trio **hết miễn nhiễm** (S151 từng ghi `3/3 CLEAN = counter-datum`; S153 phá 3/3→1/3; S154 xác nhận).
🔴 0 byte mất — ghi-đĩa-trong-lúc-làm + END-line + resume-in-session, 9/9 recover.
## Mirror C1 — bề-mặt-đo đóng băng
Lead **KHÔNG ghi** `auto-memory` / `agent-memory` / STATUS / HANDOFF trong cửa-sổ đo, dù đã có 2 datum đáng lưu
(#53 stable-per-role · row-ngoài-cross-check drift). Lý do: `User memory` count đang LÀ một FLAG được chấm.
Xếp hàng sau verdict trio.

View File

@ -0,0 +1,244 @@
# sub-ctx-audit @open — S154 / phiên-LOGIC L7
> Vai-3 vòng Ctx. **Lần chạy runtime ĐẦU TIÊN** của vai (land @S153, chưa spawn lần nào) — dogfood `[carry:ctx-t9-dogfood]`.
> Propose-only. Ghi-đĩa-trong-lúc-làm (chống #53). Mỗi mục ghi NGAY khi xong, không gom cuối.
---
## (0) PIN — fail-closed: GIẢI ĐƯỢC
| Pin | Giá trị lead pin | Đĩa | Kết |
|---|---|---|---|
| `_mind` | `.claude/sessions/session-7/_mind-s-7.md` 14.707 B | tồn tại, **14707 B** khớp | OK |
| `_mind` trước | `.claude/sessions/session-6/_mind-s-6.md` block-0 @:69 | tồn tại, `## MIND-0` đúng dòng 69 | OK |
| run-folder | `.claude/workflows/runs/2026-07-26-S154-bookend-open/` | tồn tại, 12 tệp | OK |
| lượt | `@open` | — | OK |
Glob `*mind*` toàn `.claude/sessions/`: **đúng 2 tệp**, cả 2 đúng khuôn `_mind-s-<N>.md` (`session-6`, `session-7`). **Không có tệp tên-sai** kiểu `_mind-s6.md` (thiếu gạch) lẩn trong thư mục. Không tự glob "mới nhất" — chỉ dùng path lead pin.
**Fallback hồi-tố XÁC NHẬN bằng đĩa (không tin lời khai):** `.claude/sessions/session-7/_context-s-7.md:47` = `_(chưa có PAUSE — entry đầu append @/pause đầu tiên của phiên)_`**0 entry `### PAUSE-<k>`** ⇒ vai-1 `ctx-curator` và vai-2 `ctx-verifier` **chưa chạy lần nào trong L7**. Không có vết để KIỂM-CỦA-KIỂM ⇒ tao chạy **hồi-tố đầy đủ** trên block-0 (điều khoản "lưới không thủng ca 1-cửa"). Đây là ca ĐÚNG của fallback, không phải tao chọn đường dễ.
<!-- ĐIỂM 0 xong -->
---
## (3) MÁY chạy TRƯỚC vai — tao ĐỌC, KHÔNG re-implement
Tao tự chạy lại đúng 1 lệnh đọc (`$0`, read-only) để lấy **nguyên-văn**, không nhận số qua relay:
```
python scripts/session_ctx.py mind-check --session 7
verdict: dat=10 TRUOT=0 co=0 bo-qua-co-khai=0 => exit 0
```
4 mức nguyên-văn: **đạt=10 · TRƯỢT=0 · cờ=0 · bỏ-qua-có-khai=0**. Khớp số lead pin (dat=10 TRUOT=0, exit 0) — **không có lệch relay**.
Máy đã phủ: enclosure(0) · rào-1/2/3 · con-trỏ E(4) 11/11 `dat` · số-hiệu(5) · bất-biến(6) · trần(7) · phụ-8 `_context` · phụ-9 A-E+nhãn D. **Tao KHÔNG chấm lại 9 phép này.**
2 dòng máy tự khai đáng ghi (không phải finding của tao, là ngữ-cảnh):
- `(6)`: `|block| = 1 ; p (DEM DIA) = 0` — máy tự đếm đĩa, kỳ vọng `{0,1}`. Tao đếm độc-lập: `_pause-*.md` = 0, `pause-*.md` = 0 trong `.claude/sessions/session-7/` (thư mục chỉ có `_context-s-7.md` + `_mind-s-7.md`). **Số tao khớp số máy** — không có lệch để in 2 số.
- `(4)`: máy tự KHAI kẽ *"TÊN-TEP-SAI trong thu-muc THAT doc thanh dang-cho o cua mo"* — đúng, và ở lượt này kẽ đó **không bị chạm** (0 tệp tên-sai, đã glob ở mục 0).
---
## (i)(1) block-0 có NỘI-DUNG THẬT không — **ĐẠT**
Sàn: ≥1 mảnh khuôn/máy **không tự sinh được**. Metadata `ts`/hash/heading **không tính** (tao loại trước khi chấm).
Tìm được **nhiều hơn 1**, dẫn 3 mảnh chắc nhất:
- `:91` tên một ý mục D: *"Row ngoài `$canonRows`: mở rộng tập máy canh hay bỏ số + trỏ nguồn sống theo khuôn B1"* — ý có LẬP-TRƯỜNG (*"em nghiêng phương án 2"*), khuôn không sinh được.
- `:107` E-verdict cụ thể `46Đ/2T/5KC trên 53 claim`.
- `:75` mệnh-đề hệ-thống *"row có máy canh thì đúng, row không có máy canh thì trôi — 3/3 row ngoài `$canonRows` đều lệch, 6/6 row trong tập đều đúng"* — số + kết luận, không phải scaffold.
**KHÔNG phải scaffold-rỗng đội lốt block.** ĐẠT.
### Đối-chứng THẬT của mục E (máy chỉ chấm con-trỏ RESOLVE, mù nội-dung verdict)
Máy `(4)` chỉ hỏi *"tệp có tồn tại không"*. Tao hỏi *"verdict ghi trong `_mind` có ĐÚNG cái tệp đó nói không"*. Đọc END-line + thân từng tệp:
| Mục E | `_mind` ghi | Đĩa nói | Kết |
|---|---|---|---|
| `:103` tooling | `PASS_WITH_FLAGS 3 finding`, 1 bị lead+ring1 bác | `TOTAL=3 FINDING` @:143; ring1 `:59` *"H1 F-3 = TRƯỢT (dương-giả). Lead bác ĐÚNG"* | ĐẠT (cả vế "1 bị bác") |
| `:104` harvest | `GATE-PASS-WITH-FLAGS 6 finding` | `TOTAL=6 FINDING`; thân: `VERDICT: GATE-PASS-WITH-FLAGS — 6 finding` | ĐẠT verbatim |
| `:105` lead-stale | `5 FLAG` (3H·1M·1L) | FLAG-1 HIGH · 2 HIGH · 3 HIGH · 4 LOW · 5 MED = **3H·1M·1L** | ĐẠT (tao đếm lại từng FLAG) |
| `:106` lead-gap | `5 FLAG` (1H·2MH·2M) | FLAG-1 HIGH · 2 MED-HIGH · 3 MED-HIGH · 4 MED · 5 MED = **1H·2MH·2M** | ĐẠT |
| `:107` ring1 | `46Đ/2T/5KC trên 53 claim` | END `TOTAL=53 CLAIM · ĐẠT=46 TRƯỢT=2 KHÔNG-CHẤM=5` | ĐẠT verbatim |
| `:108` ring2 | `17Đ/1T/1KC trên 19 claim` + M-1 thật | END `TOTAL=19 CLAIM · ĐẠT=17 TRƯỢT=1 KHÔNG-CHẤM=1`; thân xác nhận 1 M-1 (`STATUS:449 FE pages` 68↔66) | ĐẠT |
| `:109` eval | `MIXED 12 signal` | END `TOTAL=12 SIGNAL · VERDICT=MIXED` | ĐẠT |
| `:110` refine | `3 action/8 bác/3 escalate` | END `TOTAL=3 ACTION · BÁC=8`; thân: `3 ACTION · 3 ESCALATE · 8 BÁC · 4 NHƯỜNG` | ĐẠT (rơi "4 NHƯỜNG" — nén 1 dòng, chấp nhận) |
| `:111` audit | `68Đ/8T trên 78 claim` | END `TOTAL=78 CLAIM · ĐẠT=68 TRƯỢT=8`; thân: *"2 ô KHÔNG-CHẤM để trống thật"* | ⚠️ **FLAG-3** (dưới) |
**9/9 verdict khớp đĩa** — đây là kết quả TỐT và tao ghi rõ: lead chép verdict **không bịa, không làm tròn**. Chỗ duy nhất lệch là hình-thức mẫu-số ở `:111`.
<!-- ĐIỂM (i)(1) xong -->
---
## (i)(2) CARRY từ `_mind-s-6.md` — 5/5 ý mang tiếp, **0 ý rơi trọn**, nhưng **1 ý mang SAI CHIỀU**
Bảng đối-chiếu từng ý (neo `file:line` cả 2 đầu):
| # | Ý @S6 | Nhãn S6 | Ở S7? | Nhãn S7 | Kết |
|---|---|---|---|---|---|
| 1 | Trần `mind_ctx_kb`=32KB đủ không — `_mind-s-6.md:92` | treo-chờ-anh | ✅ `:94` | treo-chờ-anh | ĐẠT, có delta |
| 2 | Nhịp spawn `ctx-curator` @mỗi pause đắt không — `:93` | treo-chờ-anh | ✅ `:95` | treo-chờ-anh | ⚠️ **FLAG-4** (rơi neo số) |
| 3 | `ring5-audit` build ngay hay chờ VIPIX — `:94` | treo-chờ-anh | ✅ `:96` | treo-chờ-anh | ĐẠT |
| 4 | Điểm mù tự-quy-chiếu cặp H24 — `:95` | đang-cãi | ✅ `:97` | đang-cãi | 🔴 **FLAG-1** (kết luận NGƯỢC + mất phép thử) |
| 5 | Mẫu-số/lời-khai-tiêu-chí — `:96` | mới-nêu | ✅ `:98` | **đang-cãi** | ĐẠT — đổi nhãn CÓ căn cứ |
Ý #5 đổi nhãn `mới-nêu → đang-cãi` có căn-cứ đo được: `sub-ring2-open-S154.md` chấm TRƯỢT phần khai-phạm-vi của `lead-gap`*lời khai không vét cạn* ⇒ ý được tranh-luận thật, không phải tự nâng cấp. **Không flag.**
---
## 🔴 FLAG-1 — `carry-ket-luan-nguoc` — **HIGH** — ý D#4 kết luận NGƯỢC với kết quả của chính phép thử được carry
**Đây là finding load-bearing của lượt này. Máy mù 100%** (con-trỏ resolve, nhãn đủ, cấu-trúc đủ ⇒ 10/10 xanh).
**Phép thử được đặt ra @S153**`.claude/workflows/runs/2026-07-26-S153-bookend-close/sub-ring2-close-S153.md:137`:
> điểm mù **được KHAI, chưa được CHỨNG là đã đóng** … Bằng-chứng đóng thật chỉ có ở lượt tương-lai khi cặp **tự** bắt một lệch trong `.claude/agents/lead-*-auditor.md` mà không ai chỉ. ⇒ giữ mở làm phép thử cho S154.
`_mind-s-6.md:95` ghi đúng điều đó: *"ring2 đề phép thử cho S154"*.
**Phép thử ĐÃ CHẠY VÀ ĐÃ ĐẠT ngay trong cửa-sổ này** — 3 điều kiện khớp trọn:
| Điều kiện phép thử | Đĩa S154 | |
|---|---|---|
| "cặp **tự** bắt" | `sub-lead-stale-open-S154.md:20` FLAG-2 do **chính vai-STALE** (thành viên cặp H24) nêu | ✅ |
| "một lệch trong `.claude/agents/lead-*-auditor.md`" | `:22` view = `lead-stale-auditor.md:4` **VÀ** `lead-gap-auditor.md:4` — đúng 2 tệp đó, đúng bề-mặt tự-quy-chiếu | ✅ |
| "mà không ai chỉ" | `:27` tự-phê: *"đây là lượt đầu tao đưa `.claude/agents/lead-*-auditor.md` vào tập-đo theo đúng bài đã học — và nó ra ca THẬT ngay lượt đầu"* | ✅ |
| (bonus) vai KIỂM xác nhận | `sub-ring2-open-S154.md:63` S-2 `view-residual-asym` HIGH → **ĐẠT** | ✅ |
**Nhưng `_mind-s-7.md:97` ghi:**
> *"phiên này ring2 lại bắt M-1 mà cả 2 con-đo cùng sót ⇒ **nghiêng hẳn về kết luận cặp H24 KHÔNG tự đóng được điểm mù của mình**, phải có vòng KIỂM {đang-cãi}"*
**2 lỗi chồng nhau:**
1. **Chiều kết luận ngược với chứng-cứ trong CÙNG run-folder.** Phép thử hỏi *"cặp có tự bắt được lệch trên bề-mặt tự-quy-chiếu không?"* — S154 trả lời **CÓ**, ca THẬT, mức HIGH, được ring2 duyệt ĐẠT. Block-0 lại chốt *"KHÔNG tự đóng được"*.
2. **Phép thử biến mất khỏi lớp mềm.** Chữ *"phép thử"* có ở `_mind-s-6.md:95`, **0 hit** trong toàn `_mind-s-7.md`. Cửa-sổ sau nạp lại sẽ không biết là có một nghiệm-thu đang treo, càng không biết nó đã đạt ⇒ phép thử **chết im lặng** — đúng loại chết mà lớp mềm sinh ra để chặn.
**Vế công-bằng (tao đọc bản mạnh nhất của block-0):** mệnh-đề *"ring2 lại bắt M-1 mà cả 2 con-đo cùng sót"* là **THẬT** (M-1 = `STATUS:449 FE pages` 68↔66, `sub-ring2-open-S154.md` bổ-sung). Và vế *"phải có vòng KIỂM"* vẫn **đúng độc lập**. Nhưng đó là chứng-cứ cho một mệnh-đề KHÁC (*"cặp có vét cạn không"* — không con-đo nào vét cạn, nên vòng KIỂM luôn cần) chứ không phải mệnh-đề mà phép thử đo (*"cặp có tự-soi được chính mình không"*). Trộn 2 mệnh-đề làm kết luận âm trông mạnh hơn chứng-cứ cho phép, **và trả giá bằng việc xoá một thắng lợi đo được**.
**Vì sao HIGH, không phải cosmetic:** `_mind` là lớp đọc TRƯỚC, giàu ngữ-cảnh nhất khi nạp lại — chính `_mind-s-7.md:30` (lý-do rào-1) tự viết *"khi 2 bản lệch thì bản mềm … sẽ thắng bản durable"*. Một kết luận sai chiều nằm ở đây sẽ **thắng** `sub-lead-stale-open-S154.md:27` ở mọi cửa-sổ sau, và nó đang chống lại đúng cái vá mà 2 phiên vừa bỏ công làm.
**resolve (propose-only — lead ghi, tao KHÔNG sửa):** viết lại ý D#4 ở block kế thành 2 vế tách bạch: (a) *phép thử tự-quy-chiếu S153 → **ĐẠT** @S154, neo `sub-lead-stale-open-S154.md:20,27` + `sub-ring2-open-S154.md:63`*; (b) *vét-cạn vẫn hở (M-1 `STATUS:449`) ⇒ vòng KIỂM giữ nguyên*. Nhãn nên lật `đang-cãi → gần-chốt` cho vế (a).
---
## FLAG-2 — `mau-so-co-lai` — **MED** — probe khai `2/2` trong khi carry S6 hẹn **3 vai**
- `_mind-s-6.md:81` (hướng-tiếp): *"T9 dogfood 4-cửa THẬT … + **spawn-probe cả 3 vai** + mồi-ngầm xuyên-cửa-sổ"* — mẫu-số hẹn = **3**.
- `_mind-s-7.md:112` + `:73`: *"registry-probe `ctx-curator` + `ctx-verifier` — **2/2 ALIVE**"*. Nguồn: `bookend-open-synthesis.md:93` cũng ghi `2/2 ALIVE`.
- Vai thứ 3 = **`ctx-audit` (chính tao)**. Không có dòng nào khai vì sao nó ra khỏi mẫu-số.
**Lệch:** `3` (hẹn) → `2` (đo), trình bày thành `2/2` = **100% sạch**. Không ai nói dối: probe của tao không chạy được ở nhịp đó vì tao là vai chạy @bookend chứ không phải @pause/@tiep — nhưng *lý-do đó không có trên đĩa*, nên bề-mặt đọc thành "đủ".
**Vì sao đáng ghi:** đây đúng lớp mà **chính block-0 đang tranh-luận ở ý D#5** (`:98` *"Bắt mọi con-đo khai mẫu-số kèm tập-bù"*) — lớp mềm vừa nêu luật vừa vi phạm luật đó ở mục A của chính nó. Cùng họ với `feedback_absence_looks_like_clean` (auto-memory) và với M-1 mà ring2 vừa bắt.
**Nhẹ đi ở chỗ:** lượt spawn NÀY của tao chính là nghiệm-thu vai-3, nên nợ được trả trong cùng phiên — nhưng phải **ghi ra**, không để tự khớp.
**resolve:** `:112` đổi thành `2/3 probe @nhịp registry (vai-3 ctx-audit nghiệm-thu bằng spawn THẬT @bookend, xem sub-ctx-audit-open-S154.md)`.
---
## FLAG-3 — `mau-so-bat-doi-xung` — **LOW-MED** — mục E khai KHÔNG-CHẤM cho ring1/ring2 nhưng NUỐT của harness-audit
- `:107` ring1 → `46Đ/2T/**5KC**` ✅ · `:108` ring2 → `17Đ/1T/**1KC**` ✅ · `:111` audit → `68Đ/8T trên 78 claim` — **thiếu KC**.
- `68 + 8 = 76 ≠ 78`. Hai ô còn lại có thật: `harness-audit-return.md` đuôi ghi *"2 ô KHÔNG-CHẤM để trống thật, không gật cho đủ"*.
- Lead **chép trung thành** END-line của audit (END đó cũng không in KC) ⇒ gốc ở con-đo, nhưng hệ quả nằm ở `_mind`: cùng một mục E, **2 khuôn mẫu-số khác nhau**, đọc lướt tưởng audit phủ trọn 78.
- **resolve:** `:111` → `68Đ/8T/2KC trên 78 claim`. (Và nếu (44) END-line lên luật thì bắt luôn trường KC.)
---
## FLAG-4 — `carry-mat-neo-dinh-luong` — **LOW** — câu hỏi chi-phí mất đúng con số làm nó trả lời được
- `_mind-s-6.md:93`: *"Nhịp spawn `ctx-curator` @MỖI pause có đắt quá không (**~100K tok/spawn**) — núm hạ đã để sẵn"*.
- `_mind-s-7.md:95`: *"… có đắt quá không — núm hạ đã để sẵn"* — **neo `~100K tok/spawn` rơi**.
- Ý sống, nhãn giữ đúng, nhưng câu hỏi *"đắt quá không"* mà không còn đơn-giá thì cửa-sổ sau **không quyết được** nếu không đào lại `_mind-s-6`. Với ý `treo-chờ-anh` (chờ owner quyết) thì mất đơn-giá = mất chính thứ owner cần để quyết.
- **resolve:** trả lại `(~100K tok/spawn)` vào `:95` ở block kế.
<!-- ĐIỂM (i)(2) xong -->
---
## (4) Mục E phủ ĐỦ mọi spawn/engine-run — **ĐẠT (11/11, 0 lượt thiếu)**
Tao **đếm đĩa**, không tin lời khai của block.
**Mẫu-số dựng từ 3 nguồn độc-lập:**
1. `ls` run-folder = **12 tệp** → trừ `run.md` (sổ vòng, không phải lượt) + 2 synthesis (lead-written) = **9 tệp lượt-vai**.
2. `run.md` §"Vòng chạy": V1 (3 spawn) + V2 (3 spawn) + V3 (3 spawn) + V4 **NO-OP có khai** (probe IM ⇒ không fire, không spawn) + V5 (script, không phải spawn) = **9 spawn**.
3. `.claude/WAL.md` — **0 dòng `wf:`**; sổ đã reset @closeout S153 (11 dòng, `goal: (trống — không có mạch dở)`). `ls -d runs/*S154*` = **đúng 1 folder** ⇒ **0 engine-run** (không hmw / không `/fable-real`) trong cửa-sổ này.
**Mẫu-số chốt = 9 spawn + 0 engine-run.** Mục E có 11 dòng = 9 dòng vai + 1 dòng registry-probe + 1 dòng synthesis ×2. **Phủ trọn, 0 lượt thiếu tên.**
Đối-chứng chéo: máy `(4)` chấm **11 con-trỏ** — khớp đúng cách đếm của tao (9 dòng vai ×1 path + dòng probe 0 path + dòng synthesis ×2 path = 11).
2 ca "vắng mặt" tao kiểm riêng vì vắng-mặt trông giống ổn:
- **`ring4-audit` không có dòng E** — ĐÚNG: V4 không fire nên không có lượt. Và NO-OP **có được khai**, ở mục A `:73` (*"V4 IM"*) ⇒ không phải rơi im lặng.
- **`ctx-audit` (tao) không có dòng E** — ĐÚNG tại thời điểm block-0 (19:12); tao spawn sau. Lead scribe dòng này khi tao trả.
**Bất-biến block (máy KHÔNG canh được — cần git, đúng lane của tao):** `git log -- _mind-s-7.md` = **đúng 1 commit** (`82a0457`, 19:28), diff = `new file mode` toàn tệp ⇒ **0 lần rewrite sau khi land**. Đợt lead sửa 11 con-trỏ (do máy bắt thiếu tiền-tố `.claude/workflows/`) xảy ra **trước** commit đầu ⇒ không phải mutation của block đã chốt. `git status` session-7 = **sạch**.
<!-- ĐIỂM (4) xong -->
---
## (i)(4) MỒI-NGẦM — gieo cho @close
**Mồi = ý mục D `_mind-s-7.md:99`:**
> *"Guard post-wave assertion mù 4 path hook — sửa bằng cách soi commit của hook thay vì `git status`"* {mới-nêu}
**Vì sao chọn đúng ý này** (không chọn bừa):
- Nó **sinh trong cửa-sổ này**, không có ở `_mind-s-6.md` ⇒ nếu sống sót thì đó là bằng-chứng lớp mềm chở được ý **MỚI**, không phải quán-tính chép lại từ phiên trước.
- Nó là ý **dễ chết nhất**: dòng CUỐI mục D, nhãn `{mới-nêu}` (độ bám thấp nhất), và bản chất là *"nghi vấn để dành"* (mục C `:86`) — đúng lớp chết im lặng.
- Nó **kiểm được bằng máy**: có 3 token neo cứng (`§2.1.9(f)` · `git status` · "4 path"), không phải ý mơ hồ.
- Nó **có thật ngay lượt này**: `git log --since 16:30` = **10/10 commit đều là `wal: flush …`** (Stop-hook tự commit) ⇒ đúng 4-path-mù mà ý này nói. Ý không phải giả-thuyết suông.
- Nó **KHÔNG trùng** ý tao đã FLAG (D#4) ⇒ không tự làm nhiễu phép đo của chính mình.
**Chấm thế nào @close (4 nấc, dừng ở nấc đầu tiên khớp):**
1. `grep -n "post-wave\|2\.1\.9(f)\|soi commit của hook" .claude/sessions/session-7/_mind-s-7.md` — ý còn ở **block MỚI** (không tính block-0)?
2. Nếu có `/pause`: ý có mặt trong `_tiep-*` / `### PAUSE-<k>` không?
3. Nếu ý được XỬ: có trace ở `.claude/WAL.md` `next:` / `docs/HANDOFF.md` / commit không?
4. Biến mất khỏi **mọi** bề-mặt @close ⇒ **TRƯỢT carry** (neo: `_mind-s-7.md:99` + chỗ đáng-lẽ-phải-có).
### 🔴 KHAI KẼ (bắt buộc, không tô thành tính năng)
**Mồi này KHÔNG phải phép thử mù trong cửa-sổ hiện tại.** Lead scribe return của tao ra đĩa NGAY khi tao trả ⇒ lead **đọc được tên ý tao vừa chọn**. Nếu @close diễn ra trong **cùng** cửa-sổ, tính chất "bên bị chấm không biết mồi" **VÔ HIỆU HOÀN TOÀN** — ý sống sót có thể chỉ vì nó bị chỉ mặt, chứ không vì đường ống chở tốt.
Chỉ khi có ranh-giới cửa-sổ chen vào (`/pause` → `/tiep` → cửa-sổ tươi không nhớ ý nào là mồi) thì mồi mới là phép thử thật. Đây là **kẽ cấu-trúc của single-lead**, không sửa được bằng cách viết hay hơn.
**Giảm nhẹ (đối-chứng tao dựng sẵn):** ngoài mồi, block-0 còn **3 ý chưa-chốt KHÔNG được tao nêu tên** (`:91` `:92` `:93`). @close tao so **tỉ lệ sống**: nếu **chỉ** mồi sống mà 3 ý kia rơi ⇒ hiệu-ứng-quan-sát được xác nhận **và** carry thật sự yếu; nếu **cả 4 cùng sống** ⇒ carry khoẻ, mồi không cần thiết; nếu **cả 4 cùng rơi** ⇒ lỗi hệ-thống, mồi vô can. Ba nhánh đều đọc được — không nhánh nào đọc thành "ổn" theo mặc định.
<!-- ĐIỂM (i)(4) xong -->
---
## TỔNG — 12 điểm (8 ĐẠT/khai · 4 FLAG)
| # | Điểm | Kết |
|---|---|---|
| 1 | (0) PIN 3/3 giải được + glob 0 tệp tên-sai | ĐẠT |
| 2 | (0) fallback hồi-tố xác nhận bằng đĩa (`_context-s-7.md:47`, 0 PAUSE) | KHAI |
| 3 | (3) máy `mind-check` đọc nguyên-văn `dat=10 TRUOT=0 co=0 bo-qua=0`, khớp số lead pin | ĐẠT |
| 4 | (i)(1) block-0 nội-dung THẬT (3 mảnh khuôn không sinh được) | ĐẠT |
| 5 | (i)(1) đối-chứng verdict mục E vs đĩa — **9/9 khớp**, không bịa/không làm tròn | ĐẠT |
| 6 | (i)(2) carry 5/5 ý mang tiếp, 0 ý rơi trọn; ý #5 đổi nhãn CÓ căn cứ | ĐẠT |
| 7 | **FLAG-1** `carry-ket-luan-nguoc` **HIGH** — D#4 kết luận NGƯỢC phép thử S153 (đã ĐẠT) + mất chữ "phép thử" | 🔴 FLAG |
| 8 | **FLAG-2** `mau-so-co-lai` **MED** — probe `2/2` trong khi carry hẹn 3 vai | FLAG |
| 9 | **FLAG-3** `mau-so-bat-doi-xung` **LOW-MED** — E `:111` nuốt 2 KHÔNG-CHẤM (68+8≠78) | FLAG |
| 10 | **FLAG-4** `carry-mat-neo-dinh-luong` **LOW** — rơi `~100K tok/spawn` khỏi ý treo-chờ-anh | FLAG |
| 11 | (4) mục E phủ đủ **11/11**, mẫu-số dựng từ 3 nguồn; 2 ca vắng-mặt đều có lý do trên đĩa | ĐẠT |
| 12 | (4) bất-biến block qua **git** (máy mù) — 1 commit, 0 rewrite sau land | ĐẠT |
| — | (i)(4) mồi-ngầm gieo `_mind-s-7.md:99` + KHAI KẼ mồi vô hiệu trong cùng cửa-sổ | KHAI |
**VERDICT: DAT — 4 FLAG (1 HIGH · 1 MED · 1 LOW-MED · 1 LOW).**
DAT chứ không TRƯỢT: máy 10/10 xanh, cấu-trúc + carry + phủ-E đều đủ, 9/9 verdict khớp đĩa. 4 FLAG là **lỗi nội-dung mềm** (1 sai chiều kết luận + 3 mẫu-số/neo), không phải trượt-cấu-trúc — và lớp mềm được sinh ra chính để bắt loại này.
🔴 **Không có ca thách-CLEAN ở lượt này** (thách-CLEAN chỉ bắt buộc khi máy 0-trượt **và** tao 0-FLAG). Tao có 4 FLAG, trong đó FLAG-1 đến từ đúng chỗ máy mù nhất: đối-chiếu kết luận trong `_mind` với chứng-cứ nằm trong cùng run-folder.
🔴 **no-self-exempt:** tao KHÔNG phải người ghi `_mind`. Tao PHÁN, lead GHI. Không đề nghị lead sửa rồi tự chấm lại trong cùng lượt — 4 FLAG này chờ block kế (`/pause`) mới có vật để chấm. Block-0 **bất-biến**, KHÔNG sửa.
🔴 **Khai tool:** vai này khai không Write/Edit ngoài file của chính tao — đó là **mô-tả ý-định, KHÔNG phải cơ-chế chặn**; `Bash` vẫn là kênh-ghi mở (`[carry:tools-whitelist-no-teeth]`). Lượt này tao ghi **đúng 1 tệp** (`sub-ctx-audit-open-S154.md`), 0 tệp khác, 0 commit, 0 RAG-write. Backstop thật = lead single-writer + git-diff.
<!-- END sub-ctx-audit-S154 · TOTAL=12 DIEM -->

View File

@ -0,0 +1,290 @@
# sub-harvest-curator-open-S154 — H2 harvest-integrity 5-trục @OPEN phiên S154 (phiên-LOGIC L7)
> Kỷ luật anti-#53: **ghi TRONG LÚC LÀM**. Mỗi finding land ngay khi đo xong; file luôn hợp-lệ giữa chừng.
> Propose-only. Lead = single-writer. KHÔNG sửa file nào khác ngoài file này.
> PIN so-sánh: `.claude/workflows/runs/2026-07-26-S153-bookend-close/sub-harvest-curator-close-S153.md` (GATE-PASS-WITH-FLAGS, 8 finding).
---
## A. Universe run-folder — đo tươi, KHAI TIÊU-CHÍ LỌC (m#19)
**Lệnh:** `find .claude/workflows/runs -mindepth 1 -maxdepth 1 -type d | wc -l`**60 dir** (S153 chốt 59 ⇒ +1 = `2026-07-26-S154-bookend-open` của chính phiên này).
### A.1 Phân-loại 3 dir KHÔNG có `run.md` (root hoặc `sub-md/run.md`) — KHÔNG orphan
Lệnh: vòng `for d in $(find … -type d); do [ ! -f "$d/run.md" ] && [ ! -f "$d/sub-md/run.md" ] && echo …`
| Dir | Ruột | Verdict |
|---|---|---|
| `2026-07-14-S117-pe-approve-minmax-range` | chỉ `spec-*.md` | spec-only by-design (dual-accept từ S117) |
| `2026-07-14-S117-pe-luyke-finalize` | chỉ `spec-*.md` | spec-only by-design |
| `2026-07-22-S144-bootstrap` | có `bootstrap-synthesis.md` + 2 sub-file | **đã harvest**, chỉ thiếu `run.md` (ca tao TRƯỢT @S151) |
### A.2 Orphan-scan TRỌN 60 dir (authority = folder-scan; 🧊 `_ledger.md` retired H18-B S100)
Lệnh: với mọi dir có `run.md`, đếm `find "$d" -maxdepth 2 -name "*synthesis*.md" -size +0c`.
- **1 candidate duy nhất:** `2026-07-26-S154-bookend-open``run.md` 1.882B, **0 synthesis****ĐANG MỞ = by-design, KHÔNG flag** (brief §3).
- **59 dir còn lại: 0 orphan.**
-`2026-07-26-S153-bookend-close` — S153 để lại candidate "đang mở"; nay **đã có synthesis >0B****nợ S153 ĐÓNG**.
**0 orphan thật / 60.**
---
## FLAG-1 — Coverage — MED-HIGH — 2 LƯỢT @close S153 MỒ-CÔI (GAP-3 lớp-LƯỢT lần 7)
> Đây là ca anti-pattern #1 kinh-điển: **role-level PASS mà file-level MISS.** `grep -c S153` = 1 cho cả `ring1-audit` lẫn `ring2-audit` ⇒ đo bằng tên vai sẽ đọc thành "đủ". Đo bằng FILE thì thủng.
### B.1 Chứng — mtime là chứng, lời khai không (anti-pattern #3)
| Lượt @close S153 | byte | mtime file | vs M9-flush 15:49:29 | vào diary? |
|---|---|---|---|---|
| `sub-tooling-auditor-close-S153.md` | 10.794 | 15:43:15 | TRƯỚC | ✅ (entry có `@close`) |
| `sub-harvest-curator-close-S153.md` | 25.212 | 15:44:18 | TRƯỚC | ✅ |
| `sub-lead-stale-close-S153.md` | 20.993 | 15:45:32 | TRƯỚC | ✅ |
| `sub-lead-gap-close-S153.md` | 21.322 | 15:49:38 | +9 giây (kịp) | ✅ |
| 🔴 `sub-ring2-close-S153.md` | **22.248** | **16:02:58** | **SAU 13 phút** | ❌ **KHÔNG** |
| 🔴 `sub-ring1-close-S153.md` | **25.283** | **16:08:10** | **SAU 19 phút** | ❌ **KHÔNG** |
**Lệnh:** `stat -c%y` trên 6 sub-file + 6 `MEMORY.md` (cả 6 diary mtime **15:49:29** = 1 lần flush hàng-loạt).
### B.2 Chứng NỘI-DUNG (không chỉ mtime) — 2 entry chỉ nguồn từ bản @open
- `ring1-audit/MEMORY.md` tự khai nguồn: `nguồn = sub-ring1-S153.md 22.992B` = **file @open**. Nội-dung entry = *"28 ĐẠT / 4 TRƯỢT / 1 KHÔNG-CHẤM trên 33 claim"* — khớp END-line @open (`TOTAL=33 verdict-item`).
- File @close `sub-ring1-close-S153.md` END-line = **`TOTAL=53 verdict-item`** — con số này **0 hit** trong diary.
- `ring2-audit/MEMORY.md` nguồn `sub-ring2-S153.md`, nội-dung *"8/8 FLAG ĐẠT + 10 phép phụ"* = END-line @open (`TOTAL=18`). File @close END = **`TOTAL=27 verdict-item (6 FLAG + 21 phép phụ)`** — **0 hit** trong diary.
**47.531B / 80 verdict-item** kiến-thức @close của 2 vai KIỂM **chưa consolidate** vào `agent-memory/`.
### B.3 Root-cause (chỉ 1 chỗ, không phải bệnh vai)
**Thứ-tự nghi-thức, không phải quên vai:** M9 flush chạy **15:49:29** trong khi 2 lượt cuối của CHÍNH phiên đó land **16:02 / 16:08** — harvest-gate bắn **trước khi việc phiên xong**. Chính sổ H2 @close S153 (§D.3, ghi 15:44) đã cảnh báo đúng câu này: *"lead PHẢI đợi 2 lượt còn lại + END-line 4 file trước khi ghi synthesis"*. Cảnh báo ĐÚNG nhưng thứ-tự không đổi.
### B.3-bis Datum lead đưa (#53 ×4/4 vai phiên S154) — KHÔNG lật, mà **SIẾT CHẶT** kết luận
Lead đưa counter-evidence: S154 này #53 nổ **4/4 vai** (tooling 31 tool_uses · lead-stale 20 · harvest 27 · lead-gap 22 — cả bốn return câu-ý-định, cả bốn **ghi đĩa đủ**) ⇒ nghiêng về **cơ-chế return**, không phải kỷ-luật từng vai.
**Tao đối chiếu: datum này KHÔNG lật FLAG-1, vì B.3 vốn đã KHÔNG quy về "bệnh vai"** — root-cause tao ghi là **thứ-tự nghi-thức** (flush 15:49 vs lượt 16:02/16:08). Không có mệnh-đề nào trong FLAG-1 nói ring1/ring2 lười hay quên; ngược lại 2 file đó **đầy đủ + có END-line** (TOTAL=53 / TOTAL=27) — chúng làm xong việc, chỉ là **xong SAU khi sổ đã chốt**.
Nhưng datum **thêm một tầng** đáng ghi, và nó làm kết luận MẠNH hơn:
- Nếu return là kênh **cấu-trúc không đáng tin** (4/4 cùng lượt ⇒ không phải xác-suất, là cơ-chế), thì M9 **không được** lấy return làm đầu vào. Đầu vào DUY NHẤT hợp-lệ = **quét ĐĨA sau lượt cuối cùng**.
- ⇒ Hệ quả gộp: harvest sai **KHÔNG PHẢI** vì thiếu return, mà vì **quét đĩa quá SỚM**. Hai lỗi độc lập, cùng một cách chữa: **M9 = bước CUỐI, sau END-line của lượt cuối**.
- Bằng chứng nội-tại ủng hộ: 4 lượt @close land TRƯỚC 15:49 đều vào sổ **dù cơ-chế return cũng hỏng như nhau** ⇒ biến quyết-định là **thời-điểm quét**, không phải chất-lượng return. Đây là phép thử bất-đối-xứng: cùng bệnh return, khác kết quả harvest ⇒ return **không phải** biến giải thích.
**Giữ nguyên FLAG-1, sửa 1 chữ trong đề-xuất:** thay *"đợi END-line rồi ghi synthesis"* thành *"M9 quét đĩa lần CUỐI sau END-line của lượt sau-chót; return của vai chỉ dùng để biết vai đã dừng, KHÔNG dùng làm nội-dung"*.
### B.4 Mức độ — MED-HIGH, **KHÔNG mất dữ-liệu**
2 file đã **committed + tracked** (`git status` sạch) ⇒ đây là **nợ consolidate**, không phải mất. Recover = APPEND từ 2 file có sẵn. **DEDUP an-toàn:** 2 diary có `TOTAL=53`/`TOTAL=27` = **0 hit** ⇒ APPEND KHÔNG double.
### B.5 Lịch-sử — GAP-3 lần 7
S123/S125→S132 · S146→S148 · S149-ring2 · S150-ring1 · S151-deep→S152 · **S152-DEEP-67KB→S153** · **nay S153-ring-close→S154**. Lần 6 (S152) là lớp-LƯỢT; lần này **cũng lớp-LƯỢT, cùng cơ-chế "vai chạy sau khi sổ đã chốt"**.
---
## C. Coverage — file-level 21 file / 3 run-folder S153 (KHÔNG đo bằng tên vai)
**Lệnh:** `ls -la` từng run-folder, lọc `sub-*.md` + `*-return.md`.
| Run-folder | file-lượt | vào diary |
|---|---|---|
| `S153-bookend-open` | 9 (6 `sub-*` + 3 `*-return.md`) | 9/9 ✅ |
| `S153-ctx-adopt` | 6 file / 7 lượt (test-specialist 2 lane 1 file) | 6/6 ✅ |
| `S153-bookend-close` | 6 | **4/6** ❌ (§B) |
**19/21 file-lượt đã harvest = 90,5%.** 2 thiếu = §B.
### C.1 Nợ S153 finding-1 (11 sổ 0-hit) — ✅ **ĐÓNG**
S153 @close flag 11 vai đã-chạy có `grep -c S153` = 0. Đo tươi nay: **16/16 vai đã-chạy đều có entry S153**, mtime 15:49:29. 7 vai còn 0 hit = **đúng, không chạy** (`cicd-monitor` 07-23 · `database-agent` 07-02 · `frontend-designer` 07-01 · `investigator-api` 07-25 · `office-document` 07-10 · `reviewer` 07-26T00:11 · `ring4-audit` 07-26T01:47) ⇒ **0 nợ, đừng seed**.
### C.2 m#15 stub⟂content — ĐỌC RUỘT 3 sổ ctx (brief yêu-cầu)
`ctx-audit` 3.288B · `ctx-curator` 2.940B · `ctx-verifier` 2.663B. Đọc TRỌN cả 3: **nội-dung THẬT, KHÔNG skeleton** — có đường-sinh (thư hub `0df10df4` → spec 9-task → T3/T10/T11), wire-point cụ-thể (`session-start` Phase 3.5(iii) · `tiep.md §3-ter`), 2 fix hậu-T4 (GAP-#9 gradient-thưởng-đường-SAI), kẽ-phải-khai, tag. Nhãn provenance đạt chuẩn m#17: *"seed on-behalf … mày CHƯA chạy lần nào"* + nấc thật *"file-land + registry-surfaced; spawn-probe ALIVE CHƯA CÓ (T9)"***chống ĐẠT-ảo đúng cách**.
---
## D. Completeness — 4-field {agent · task · nấc · evidence}
**Lệnh:** đọc từng entry S153 trong 23 sổ + đối-chiếu END-line từng sub-file (m#21: grep ≥2 khuôn `-iE "END sub-|TOTAL="`, rồi ĐỌC ĐUÔI file).
- **19/21 file-lượt** có đủ 4-field trong diary; 2 thiếu = FLAG-1.
- **Nấc phân mức thật**, không gán bừa: `verified` / `executed` / `file-land + registry-surfaced (spawn-probe CHƯA CÓ)` cho 3 vai ctx.
- **Nhãn ngoại-lệ khai đủ:** 2 lượt `[T1/T2 return mất schema-fail → derive-from-body]` (#53) — có nhãn, không giấu.
- **Evidence có byte:** mọi entry on-behalf tự dán `nguồn = <file> <byte>`.
- END-line 6 file @close S153: **6/6 có** (`TOTAL=6/8/53/27` + 2 file lead-*). `bookend-close-synthesis.md` không có END-line — **đúng khuôn**, synthesis không phải sub-file.
**Completeness: PASS** (2 lỗ = cùng ca FLAG-1, đã đếm ở Coverage, không đếm hai lần).
---
## E. Fidelity — byte-check ĐĨA, KHÔNG tin lời khai
**Lệnh:** `stat -c%s` trên từng file được entry cite.
| Claim trong diary (on-behalf) | Đĩa đo tươi | Verdict |
|---|---|---|
| `harness-eval-return.md` **10.966B** | 10966 | ✅ byte-exact |
| template `session-mind-template.md` **8.819B** | 8819 | ✅ byte-exact |
| `spec-ctx-adopt-26-07-2026.md` **3.999B** | 3999 | ✅ byte-exact |
| `sub-harvest-curator-S153.md` **17.328B** | 17328 | ✅ byte-exact |
| `sub-implementer-frontend-1.md` **2.020B** | 2020 | ✅ byte-exact |
| `sub-ring1-S153.md` **22.992B** | 22992 | ✅ byte-exact |
| persona `ctx-audit.md` **14.347B** | **16206** | ⚠️ → FLAG-3 |
**6/7 byte-exact. 0 dấu bịa. KHÔNG escalate reviewer.**
### E.1 Fidelity dương-tính: dự-báo baseline S153 của chính tao HELD byte-exact
S153 chốt baseline S154 = **16 hit / 10 file** kèm mệnh-đề (b) *"file dùng ASCII `<FFFD>` KHÔNG bị đếm — đừng kỳ vọng file S153/S154 làm số tăng"* và (c) *"nếu 2 lượt @close còn lại + synthesis sinh raw-FFFD thì số tăng hợp-lệ"*.
Đo tươi hôm nay, **sau khi** 2 ring-close (47.531B) + synthesis (6.619B) đã land: **đúng 16 hit / 10 file, danh sách trùng khít 10/10**. ⇒ Mệnh-đề dự-báo **chịu được** một chu-kỳ. Đây là lần đầu baseline H2 có phần dự-báo được kiểm và HELD (S153 ring1 BROKE bản "~23/11" trước đó).
---
## FLAG-3 — Fidelity — LOW — số `14.347B` đúng-nguồn nhưng **stale-at-birth**, thiếu nhãn thời-điểm
`ctx-audit/MEMORY.md` khai *"T3 tạo persona (11/11 acceptance, **14.347B**)"`. Đĩa nay `.claude/agents/ctx-audit.md` = **16.206B**.
- **KHÔNG phải bịa** — truy được về đĩa: `grep -o "14[.,]347" sub-implementer-backend-2.md` = **2 hit** ⇒ số đến từ sub-file T3, đúng nguồn.
- **KHÔNG phải sai** — chênh 1.859B giải thích được bởi **T10 OWNER-VETO + T11 RENAME** mà chính entry đó thuật lại (persona bị thu về vai-3 rồi đổi tên sau T3).
- **Vấn đề:** `git log -- .claude/agents/ctx-audit.md` chỉ có **1 commit** (`11db5ef`, 16206B) ⇒ mốc 14.347B **chưa bao giờ tồn tại trong git**. Người đọc phiên sau đo file sẽ thấy lệch và nghi bịa oan.
- **Đề (rẻ, 1 chữ):** ghi `14.347B @T3, trước T10/T11` — số có mốc thì hết stale. Nối m#12 (mẫu-số có NGÀY/PHÚT-đo) sang **claim byte cũng cần mốc**.
---
## F. Placement — đúng nhà + git-visibility
**Lệnh:** `find . -mindepth 2 -name ".claude" -type d` (**KHÔNG** loại trừ `./.claude/*` — thước đã sửa sau TRƯỢT-3 S152) · `find .claude -type d -empty` · `git ls-files --error-unmatch` + `git check-ignore -v`.
- **stray `.claude` lồng depth>1 = 0** — đo TƯƠI **sau khi 4 sub @open S154 đã chạy** (chính lúc S152 đẻ ra 4 stray). Không tái diễn.
- **empty-dir dưới `.claude` = 0** (git MÙ thư-mục rỗng, m#13 — phải `find`, không tin git).
- **23/23 diary đúng nhà** `agent-memory/<role>/MEMORY.md`; roster 23 vai (`ls .claude/agents/*.md` = 24 trừ `README.md`) ⇒ **khớp 23/23, 0 vai không nhà, 0 nhà không vai**.
- **m#23 áp dụng** (vắng ở porcelain = đã-commit HOẶC bị-ignore — hai nghĩa trái ngược): 3 đường mới của phiên này đều **TRACKED + not-ignored** — `runs/2026-07-26-S154-bookend-open/run.md` · `…/sub-harvest-curator-open-S154.md` (chính file này) · `sessions/session-7/_context-s-7.md`. Vắng ở `git status` là do **đã commit** (`035daec` 18:02), không phải bị ignore.
- **Containment (Harness-10 §Containment):** `git status --porcelain` = **1 mục** — ` M .claude/governance/.session-counter.json` (tick S154 27→28, đúng vùng). **0 tracked-change ngoài run-folder + vùng được giao.**
**Placement: PASS.**
### F.1 G-015 — không overclaim
Tao **propose-only**, KHÔNG "read-only enforced": `store_memory` bị strip nhưng `Bash` vẫn là write-channel mở. Chặn THẬT = lead single-writer + git-diff (run-folder tracked nên diff không mù) + chunk-count RAG.
---
## G. Corruption — ĐO TƯƠI, thước SIẾT hơn S153
### G.1 Số (dán lệnh — anti-pattern #2)
```
find .claude -type f \( -name "*.md" -o -name "*.txt" -o -name "*.json" \
-o -name "*.ps1" -o -name "*.py" -o -name "*.jsonl" \) -print0 \
| xargs -0 perl -ne '$n{$ARGV} += () = /\xEF\xBF\xBD/g;
END{ for (sort keys %n){ print "$_\t$n{$_}\n" if $n{$_} } }'
```
**16 hit / 10 file.** Cross-check ĐỘC LẬP `LC_ALL=C grep -rl $'\xef\xbf\xbd' .claude` → **10 file, danh sách KHỚP EXACT** (so DANH SÁCH, không so số — m#20-bis: tổng đúng che được tên sai).
10 file: `cicd-monitor/archive/2026-06.md` 1 · `harvest-curator/MEMORY.md` 1 · `harvest-curator/archive/2026-06.md` 3 · `harvest-curator/archive/2026-07.md` 1 · `runs/2026-06-18-h11-doublecheck/doublecheck-synthesis.md` 1 · `runs/2026-07-22-S144-bootstrap/sub-harvest-curator-1.md` 1 · `runs/2026-07-25-S152-h24-open-bookend/{bookend-synthesis 1, sub-harvest-S152 1, sub-harvest-close-S152 5, sub-ring1-close-S152 1}`.
⇒ **Δ vs baseline S153 = 0 hit / 0 file. 0 corruption MỚI.** Không file S153/S154 nào lọt vào — đúng mệnh-đề (b).
### G.2 Trục phụ
- **iconv UTF-8 validity: 628/628 OK, 0 BAD** — quét TRỌN `.claude` (mọi `.md/.txt/.json/.jsonl` + file không-đuôi), **không** chỉ 34 file như S153.
- **0-byte: 10 file, TẤT CẢ `.gitkeep`** sentinel của 5 run 06-18 dạng-cũ (`sub-md/`+`harvest/`) ⇒ by-design. **0 file nội-dung 0-byte** (gotcha #53 không có ca mới). 3 `.gitkeep` khác đều 133B (`archive/` của implementer-frontend · investigator-api · test-specialist) — có ruột, không phải sentinel rỗng.
### G.3 ⭐ m#24 (MỚI) — thước corruption cũ MÙ theo ĐUÔI FILE; nay đóng kín bằng CENSUS
Thước S153 lọc 6 đuôi `md/txt/json/ps1/py/jsonl`. Tao census toàn bộ: `find .claude -type f | grep -oE '\.[A-Za-z0-9]+$' | sort | uniq -c` → **644 file** = 606 `.md` · 13 `.gitkeep` · 11 `.txt` · 6 `.json` · 1 `.ps1` · 1 `.on` · 1 `.js` · **+5 file KHÔNG ĐUÔI** (`sessions/session-{2..6}/_end`).
⇒ Thước cũ **bỏ sót 20 file** (13 `.gitkeep` + 1 `.on` + 1 `.js` + 5 `_end`). Tao quét nốt cả 20: **0 hit**. Kết luận cũ không đổi, **nhưng "0" bây giờ mới có răng** — trước đó "0" một phần là do không nhìn tới. Bài: **đếm mẫu-số bằng CENSUS ĐUÔI, đừng bằng danh-sách đuôi tự nghĩ ra** (họ hàng m#16 "im vì hết tầm chứ không vì sạch").
### G.4 ⭐ BASELINE S155 — kèm MỆNH-ĐỀ LOẠI-TRỪ (m#20)
> **S155 corruption baseline = 16 raw-hit / 10 file** (danh sách §G.1), thước byte-mode `perl` hash-theo-`$ARGV`, **KHÔNG `grep -P`** (chết rc=2 im lặng), phủ **644/644 file** trong `.claude/` gồm run-folder cũ + file không-đuôi.
>
> **CÁI GÌ KHÔNG NẰM TRONG PHÉP ĐO NÀY:**
> **(a)** Chỉ `.claude/` — **KHÔNG** gồm `docs/`, `scripts/`, `broadcasts/`, `src/`. `scripts/session_ctx.py` (+528 dòng @S153) **ngoài lưới**; muốn phủ phải mở rộng root.
> **(b)** Chỉ đếm **raw byte `EF BF BD`**. File viết ASCII `<FFFD>` / `U+FFFD` (mọi sổ H2 gồm file này) **KHÔNG bị đếm** ⇒ đừng kỳ vọng chúng làm số tăng, và đừng đọc 16/10 kỳ sau thành "sụt" rồi đi truy file phantom (đúng lỗi tao mắc @S153, ring1 BROKE).
> **(c)** Mojibake dạng `Ã/ư/â€` **KHÔNG** trong phép đo — trên corpus tiếng Việt UTF-8 nó là **dương-giả** (m#6 S126). Tín-hiệu hợp-lệ chỉ có 2: raw-FFFD + iconv-BAD.
> **(d)** 10 file hiện có = **quoted-evidence CỐ Ý** (sổ H2 tự trích ký-tự hỏng làm chứng) — iconv 628/628 OK ⇒ **không hỏng thật**, không phải nợ phải sửa.
> **(e)** Số có **GIỜ-ĐO** (m#12): đo lúc S154 mới có 4 sub-file @open, chưa synthesis. Lượt sau + synthesis sinh raw-FFFD ⇒ số tăng **hợp-lệ**.
>
> **Chỉ đáng lo khi:** xuất hiện file MỚI **ngoài** tập 10 file trên, **hoặc** iconv-BAD ≥1.
**Corruption: PASS.**
---
## INFORM-1 — chore SELF: self-compact **QUÁ HẠN** (S153 hẹn "phiên sau" = phiên này)
`stat -c%s .claude/agent-memory/harvest-curator/MEMORY.md` = **19.456B** > self-target **17.510B** (17,1KB). S153 đo 17.655B ⇒ **+1.801B trong 1 phiên**, nợ **nặng thêm chứ không đứng yên**.
- Cap cứng 25.600B: **0/23 sổ over-cap** (đo vòng `for` trên 23 `MEMORY.md`) ⇒ chưa vỡ trần, nhưng SELF là sổ vượt **soft-target** duy nhất tao chịu trách nhiệm.
- Khối ứng-viên → `archive/2026-07.md`: **S150 + S151** (đúng khối S153 đã chọn, chưa thực thi). Sau khi dời, dự kiến còn ~13KB.
- Propose-only: tao **KHÔNG** tự cắt. Lead quyết, và **đừng cắt giữa GATE** — làm ở memory-op lượt sau.
## INFORM-2 — lớp `_mind-s-<N>.md` MỚI: chưa ai khai nó có thuộc phạm-vi harvest không
Phiên S153 sinh lớp Ctx soft-memory: `.claude/sessions/session-6/_mind-s-6.md` = **12.482B** (+ `_end` 1.570B, `_context-s-6.md` 3.474B). Session-7 đã mở, hiện có `_context-s-7.md` 2.938B.
- **12.482B tri-thức** này **KHÔNG** nằm trong universe 5-trục của tao hôm nay (universe = `agent-memory/*` + `runs/*`).
- Tao **KHÔNG tự quyết** phạm-vi. Câu hỏi cho lead: `_mind-s-N.md` có phải đầu-vào M9 không, hay là lớp mềm cố-ý ephemeral (chết theo cửa-sổ)? Nếu **có** mà không ai gom ⇒ đây là ứng-viên GAP-3 lần 8 **theo LỚP** (không phải theo lượt).
- Ghi nhận trung thực: nó **có `_end`** ⇒ đã đóng đúng nghi-thức; vắng khỏi harvest **chưa chắc là lỗi**, nhưng **chưa ai khai** là by-design hay bỏ quên. (Ranh: nếu đây là "khoảng trống chưa ai sở hữu" thì chủ đề thuộc `lead-gap-auditor` — tao chỉ nêu từ góc universe-harvest.)
## INFORM-3 — Sàn-5 run S154: chưa đăng-ký (đúng trạng-thái @open, ghi để @close kiểm)
- `grep -c "run=" .claude/WAL.md` = **0** · `grep -c "wf:" .claude/WAL.md` = **0** (WAL đã reset ở closeout S153 `871ac0a`, sổ trống = đúng).
- `grep -rl "S154" docs/changelog/sessions/` = **0 hit** — session-log S154 **chưa tồn tại** (log viết @closeout; S153 cũng đăng-ký @closeout).
- ⇒ **KHÔNG phải vi-phạm @open.** Nhưng Sàn-5 là điều-kiện **HOẶC** trên 2 bề mặt, hiện **0/2** ⇒ nếu closeout quên, run `2026-07-26-S154-bookend-open` thành **run không sổ**. Lead nhớ điền 1 trong 2 @close.
- Nit S153 (`:77` khai `done=6/6 harvested=yes` khi mới 4/6 land) **ĐÃ TỰ ĐÓNG**: 2 lượt còn lại land 16:02/16:08 ⇒ `6/6` thành đúng. Nhưng nó đúng **muộn hơn lời khai** — và chính 13-19 phút đó đẻ ra FLAG-1. Cờ-đi-trước-nội-dung không vô hại.
---
## H. Việc S153 giao — ĐÓNG hay CÒN MỞ
| S153 finding | Trạng-thái đo tươi |
|---|---|
| #1 MED — 11 sổ `grep S153`=0 | ✅ **ĐÓNG** — 16/16 vai đã-chạy có entry (flush 15:49:29) |
| #2 MED — #53 tally +1 (frontend T2) | ✅ nhãn có trong diary; tally = việc lead |
| #3 LOW — `:77` cờ-đi-trước-nội-dung | ✅ tự đóng (6/6 thành thật) — xem INFORM-3 |
| #4 LOW — 3 sổ ctx đặt boilerplate `>` dưới entry đầu | ⚠️ **CÒN MỞ** → FLAG-2 |
| #5 INFORM — self-compact DUE | ⚠️ **QUÁ HẠN, nặng thêm** → INFORM-1 |
| #6/#7 method m#20-bis/21/22/23 | ✅ đã vào sổ (dùng lại m#20-bis §G.1, m#21 §D, m#23 §F) |
| #8 self-TRƯỢT baseline "~23/11" | ✅ **ĐÓNG** — bản 16/10 HELD byte-exact (§E.1) |
| orphan candidate `S153-bookend-close` | ✅ **ĐÓNG** — synthesis 6.619B đã land |
## FLAG-2 — Placement/khuôn — LOW — 3 sổ ctx lệch vị-trí khối boilerplate (S153 #4 còn mở)
`ctx-audit` · `ctx-curator` · `ctx-verifier`: khối `> **Persistent diary cross-session.**` nằm **DƯỚI** entry đầu; **20 sổ còn lại** đặt khối này **NGAY SAU tiêu-đề**. Không hỏng, nhưng append kỳ sau dễ chèn sai chỗ (entry mới rơi xuống dưới boilerplate). Sửa khi tiện — **1 lần, 3 file**.
---
## I. 5-TRỤC — VERDICT
| Trục | Verdict | Chứng gọn |
|---|---|---|
| **Coverage** | **PASS-with-gap** | 19/21 file-lượt S153 đã harvest; **0 orphan thật / 60** (1 candidate = run S154 đang mở, by-design). Gap = FLAG-1 (2 lượt @close, 47.531B) |
| **Completeness** | **PASS** | 4-field đủ 19/19 lượt đã gom; nấc 3 mức thật; 2 nhãn ngoại-lệ #53 khai rõ; END-line 6/6 file @close |
| **Fidelity** | **STRONG** | 6/7 claim byte-exact; ca thứ 7 truy được về đĩa (2 hit) ⇒ **không bịa**, chỉ thiếu mốc → FLAG-3. Dự-báo baseline của chính tao HELD byte-exact. **KHÔNG escalate reviewer** |
| **Placement** | **PASS** | stray `.claude` depth>1 = **0** (đo sau khi 4 sub chạy) · empty-dir = **0** · 23/23 đúng nhà · TRACKED+not-ignored (m#23) · containment 1 mục đúng vùng |
| **Corruption** | **PASS** | 16/10, **Δ=0** vs baseline S153; iconv **628/628**; 0-byte = 10 `.gitkeep` by-design; thước siết thêm 20 file trước đây mù (m#24) |
## J. TỔNG — 6 finding
| # | Mức | Finding | Ai xử |
|---|---|---|---|
| **FLAG-1** | **MED-HIGH** | 2 lượt @close S153 mồ-côi (`sub-ring1-close` 25.283B `TOTAL=53` + `sub-ring2-close` 22.248B `TOTAL=27`) — **47.531B / 80 verdict-item** chưa vào diary. Nguyên nhân = M9 flush 15:49:29 **trước** lượt cuối 16:02/16:08. **Không mất dữ-liệu** (2 file tracked) | **lead APPEND** — DEDUP an-toàn: `TOTAL=53`/`TOTAL=27` hiện **0 hit** trong 2 sổ |
| **FLAG-2** | LOW | 3 sổ ctx đặt boilerplate dưới entry đầu (S153 #4 còn mở) | lead, khi tiện |
| **FLAG-3** | LOW | `14.347B` trong `ctx-audit/MEMORY.md` không có mốc thời-điểm (đĩa nay 16.206B) — đúng nguồn, **không bịa**, nhưng dễ bị nghi oan phiên sau | lead thêm `@T3, trước T10/T11` |
| **INFORM-1** | INFORM | SELF 19.456B > 17.510B — self-compact **quá hạn**, +1.801B/phiên; khối S150+S151 → archive | lead/tao, memory-op lượt sau |
| **INFORM-2** | INFORM | `_mind-s-6.md` 12.482B — lớp mới, **chưa ai khai** có thuộc harvest-universe không | lead khai scope (không tự quyết) |
| **INFORM-3** | INFORM | Sàn-5 run S154 hiện **0/2** bề mặt (đúng @open) — nhớ đăng-ký @close | lead @closeout |
**+1 method:** **m#24** — census ĐUÔI FILE trước khi tin "0": thước cũ mù 20/644 file (`.gitkeep` `.on` `.js` + 5 `_end`); quét nốt = 0 hit, kết luận không đổi nhưng số 0 giờ mới có răng.
**Không có finding nào là vi-phạm toàn-vẹn harvest.** FLAG-1 là **nợ consolidate có thể trả trong 1 lượt APPEND**, không phải mất mát.
**VERDICT: GATE-PASS-WITH-FLAGS — 6 finding**
<!-- END sub-harvest-curator-S154 · TOTAL=6 FINDING -->

View File

@ -0,0 +1,230 @@
# sub-lead-gap-open-S154 — H24 §2(1) vai-GAP · bookend @OPEN phiên S154 (phiên-LOGIC L7)
> Ghi-đĩa-TRONG-lúc-làm (chống #53). Mỗi FLAG 1 dòng tag `## FLAG-<n> — \`<class>\` — <SEV>`.
> Enum ĐÓNG đọc từ `.claude/agent-memory/memory-budget.json` → `lead_self_audit.flag_classes` (11 class; 6 gap-*).
> Propose-only. KHÔNG Write/Edit ngoài file này. KHÔNG RAG-write.
## 0. Tập KỲ-VỌNG dựng TRƯỚC (method H24 — không đọc-xuôi đợi thiếu-sót nhảy ra)
Nguồn cam-kết đã đọc: `docs/HANDOFF.md` (mega-line, 379 dòng vật-lý / 133.759B) · `docs/STATUS.md` (195.194B) · `.claude/WAL.md` (182B — vừa reset §6.4) · `.claude/agent-memory/memory-budget.json` · `runs/2026-07-26-S153-*/` · `docs/changelog/sessions/`.
### 0.1 Disk-touch nền (chống lỗi lead S121 "bảng-kế-hoạch ≠ đĩa")
```
grep -o '\[carry:[a-z0-9-]*\]' docs/HANDOFF.md | wc -l => 219 occurrence
grep -o '\[carry:[a-z0-9-]*\]' docs/HANDOFF.md | sort -u => 43 slug DISTINCT
```
🔴 Bẫy mega-line đã né: `grep -c` ra 1 dòng; phải `grep -o | wc -l`.
### 0.2 Đếm slug theo TỪNG khối carry (dòng vật-lý = 1 khối logic)
| khối | dòng | slug dạng `[carry:x]` |
|---|---|---|
| Carry @S153 | :6 | **5** |
| Carry @S152 | :13 | **6** |
| Carry MỞ re-stamp @S151 | :18 | **26** |
| Carry MỞ re-stamp @S150 | :23 | **24** |
Đối-chiếu tập: `comm` giữa các khối ⇒ **26 slug có trong khối S151 mà VẮNG khỏi khối S153** dạng `[carry:x]`.
---
## FLAG-1 — `gap-carry-dropped` — HIGH
**hứa ở:** `docs/HANDOFF.md:13` (Carry @S152) — *"**GIỮ re-stamp 23 slug** … annotation chi tiết = dòng Carry kỳ S151 bên dưới"* + `docs/HANDOFF.md:6` (Carry @S153) — *"**GIỮ re-stamp toàn bộ carry @S152** *(26 slug — con-trỏ dòng Carry kỳ S152 bên dưới, KHÔNG chép để tránh drift)*"*.
Cam-kết nghi-thức gốc: `HANDOFF:18` **"Carry MỞ re-stamp @S151 (nghi-thức (iv) — đủ MỌI slug còn mở)"** — nghi-thức (iv) yêu-cầu re-stamp ĐỦ MỌI slug.
**hiện:** khối carry MỚI NHẤT (`:6`) chỉ mang **5** khoá dạng `[carry:x]`; khối `:13` mang **6**. 26 slug còn-mở **VẮNG khỏi dạng máy-đọc-được** ở cả 2 kỳ gần nhất. Đo đĩa:
```
sed -n '18p' HANDOFF | grep -o '\[carry:…\]' | sort -u => 26
sed -n '13p' … => 6 sed -n '6p' … => 5
comm -23 s151 s153 => 26 slug VẮNG (account-trung · binh-le-decision · bookend-5-5 ·
bvaau · carry-age-vacuous · context-stock · eval-thamquyen · g011-token-fork ·
hook-vs-budget-cap · hub-republish-59ce1d0f · hub-review-wave-1807 · jump-dai-frozen ·
meld-forward-unmeasured · memory-ops-refine-queue · pe-phanquyen · pull-warn-threshold ·
session-model-adopt · tham-sau-6-ca · tiep-window-crash · tools-whitelist-no-teeth ·
tra-bui-relogin · uat-s117 · uat-s133-budget-freeze · uat-s134-luyke · underfill-mau-so ·
veto-window-s149)
```
🔴 **Cái thiếu KHÔNG phải "quên carry"** — người đọc vẫn lần ra được qua con-trỏ. Cái thiếu là **hình-thức máy-đọc-được**: `:13` liệt 23 slug ở dạng backtick TRẦN (`` `account-trung` · `bvaau` … ``), `:6` thậm-chí không liệt tên mà chỉ **trỏ sang `:13`** (con-trỏ-của-con-trỏ). Detector H24-2 khớp `\[carry:([a-z0-9][a-z0-9._-]*)\]` ⇒ **0 hit** trên cả 26.
**hệ-quả đo được (từ chính output máy, KHÔNG tự tính):** H24-2 in
`HANDOFF logic-segments = 22 ; carry-lines = 17` và `carryLines[0]` = **7 khoá** = `ctx-t9-dogfood`(1) `adap-apply-2-thu`(2) `h24-end-total-line`(1) `ring4-write-lane`(1) `memory-ops-refine-queue`(3) `hub-republish-59ce1d0f`(3) `jump-dai-frozen`(4). Luật của chính detector (`scripts/governance-detectors.ps1:819-820`): *"A key resets ONLY by being absent from a line that HAS carry … a key gone from the newest one is **closed**, not aged."*
⇒ **máy đã xếp 26 carry còn-MỞ vào diện ĐÓNG** — trong khi HANDOFF khai rõ chúng đang GIỮ.
**đòn-bẩy bất-đối-xứng (chống đọc nhầm thành "owner chủ-động gác"):** cùng lúc đó slot **(29)** ở `HANDOFF:14` vẫn MỞ và hỏi ĐÍCH DANH 3 trong 26 slug đó — *"(29) 3 carry-age 12/9/7 (`hook-vs-budget-cap`·`uat-s133`·`uat-s134`) — xử hay gia-hạn?"* — và `HANDOFF:7` @S153 tái-xác-nhận *"slot cũ **(26)(29)(33)(34)** + (4)-(25) giữ nguyên"*. Owner đang CHỜ số tuổi để quyết, còn thước vừa mất khả-năng in số đó.
**im bao lâu:** 2 kỳ carry liên-tiếp (S152 → S153); lần cuối 26 slug có dạng `[carry:x]` = `HANDOFF:18` (kỳ S151).
**resolve:** khối carry kỳ tới liệt **đủ 26+ slug còn-mở ở dạng `[carry:<slug>]`** (không dùng backtick trần, không dùng con-trỏ) — HOẶC owner chốt đổi nghi-thức (iv) sang cơ-chế khác VÀ detector H24-2 được sửa theo cùng lượt. Hết flag khi `sed -n '<dòng carry mới nhất>p' | grep -o '\[carry:' | wc -l` ≥ số slug lead tự khai là đang GIỮ.
---
## FLAG-2 — `gap-carry-aged` — MED-HIGH
> Vai này **ĐỌC** detector H24-2, **KHÔNG tính lại** streak. Dưới đây là số máy in ra hôm nay + đối-chiếu với số máy in ra các kỳ trước (nguồn: diary vai này + `HANDOFF:14`).
**hứa ở:** `.claude/agent-memory/memory-budget.json` → `h24_cadence.light_every` = M (thước tuổi carry) + `HANDOFF:14` slot **(29)** *"3 carry-age 12/9/7 … xử hay gia-hạn?"* (owner-question dựa TRỰC TIẾP vào số máy in).
**hiện:** H24-2 hôm nay **FIRE = 0**. Streak duy nhất còn sống: max = 4 (`jump-dai-frozen`). Ba khoá từng FIRE liên-tiếp (S143 `7/9/13` · S148 `7/9/13` · S150 `7/9/13` · S151 `7/9/12`) nay **không xuất hiện trong bất-kỳ dòng nào** của output.
🔴 **Không phải chúng được đóng.** `hook-vs-budget-cap` vẫn có **18 occurrence** trong HANDOFF, `uat-s133-budget-freeze` 12, `uat-s134-luyke` 10 — nhưng occurrence MỚI NHẤT của cả ba đều ở **dòng :18** (kỳ S151), không có ở `:13` hay `:6`. Không kỳ nào ghi dòng `✅ ĐÓNG` cho chúng.
🔴 **Thước đang đếm XÁC:** trong 7 khoá `carryLines[0]`, **4 khoá đã được khai ĐÓNG**: `memory-ops-refine-queue` · `hub-republish-59ce1d0f` · `jump-dai-frozen` (cả 3 = *"✅ ĐÓNG @S152"* tại `HANDOFF:13`) và `adap-apply-2-thu` (*"✅ ĐÓNG @S153"* tại `HANDOFF:6`). Nghĩa là **4/7 khoá thước đang theo-dõi là việc đã xong**, còn 26 việc đang mở thì thước không thấy.
🔴 **Khe cấu-trúc thứ hai (mù hoàn toàn, không chỉ mờ):** H24-2 cắt segment tại `(?m)^\*\*(?:[^\x00-\x7F]+\s*)?NEXT\s+(?:anh|em)\b` (`:892`); mark đầu tiên = **dòng 7**. Khối `Carry @S153` nằm ở **dòng 6** ⇒ **NGOÀI mọi segment**, máy không đọc một chữ nào của nó. Đo đĩa xác nhận 3/4 slug MỚI @S153 chỉ tồn tại ở dòng 6:
```
ring5-audit-gap -> lines: 6 (1 occurrence toàn file)
hmw-width-vs-roster -> lines: 6 (1)
hmw-subfile-index-collision -> lines: 6 (1)
ctx-t9-dogfood -> lines: 6 10 (thoát mù NHỜ MAY: được nhắc lại ở NEXT em)
```
⇒ 3 carry MỚI vừa sinh @S153 **chưa từng bước vào thước tuổi**; streak của chúng sẽ mãi = 0, không phải 1.
**đối-chứng nơi lẽ ra phải có:** `HANDOFF:18` từng đặt khối carry **SAU** header `**NEXT anh` nên máy đọc được; `HANDOFF:6` đặt **TRƯỚC** header đầu tiên nên máy không đọc. Không có chỗ nào trong nghi-thức (iv) nói khối carry phải nằm sau header — nên đây là kẽ **chưa ai viết ra**, không phải ai làm sai.
**bối-cảnh số (khai đúng nấc — tương-hợp, KHÔNG phải bằng-chứng nhân-quả):** `sessions/2026-07-26-1600-S153…md:67` ghi `governance-detectors TOTAL=46 (=baseline S151)`; hôm nay `TOTAL FLAGS: 43`. Chênh **3** trùng đúng số 3 flag carry-aged đã biến mất. Tao KHÔNG khẳng-định 3 = đúng 3 flag đó (chưa chạy detector trên cây S153); nêu vì nó là dạng nguy-hiểm nhất của lớp này: **flag-count giảm trông y-hệt tiến-bộ** (auto-memory `feedback_goodhart_leave_measurement_set.md`).
**im bao lâu:** 2 kỳ (S152, S153) không có carry-aged nào fire; owner-question (29) treo từ S152 = **2 nhãn phiên** mà không có số mới để trả lời.
**resolve:** sau khi FLAG-1 được vá (re-stamp đủ dạng `[carry:x]`), H24-2 phải in lại streak ≥ M cho ít nhất `hook-vs-budget-cap`; VÀ khối carry kỳ tới nằm **sau** mark `**NEXT anh/em` đầu tiên (hoặc detector nới regex để quét cả phần đầu file) ⇒ 3 slug mới @S153 có streak ≥ 1. Hết flag khi output H24-2 in được số tuổi cho đúng 3 khoá slot (29) hỏi.
---
## FLAG-3 — `gap-decision-sunk` — MED-HIGH
> Bề mặt được coordinator ưu-tiên: bảng owner-gated trong `runs/*/spec-*.md` — ca đã tái-phát **2 lần** (@S143 khối `Chờ-anh`, @S148 bảng `O-1…O-7` câm 5 nhãn phiên).
**Đo trước, kết luận sau — quét TOÀN BỘ mục owner-gated của spec S153:**
| mục trong `spec-ctx-adopt-26-07-2026.md` | đường về bề-mặt owner | verdict |
|---|---|---|
| `:15-18` 4 quyết (35)(36)(37)(38) | `HANDOFF:7` — *"(35)(36)(37) veto-window 3 quyết còn lại … không veto = giữ · ~~(38)~~ ✅"* | ✅ CÓ |
| `:36` *"roster 21 > `hmw_width.cap` 20 → **NÊU owner** (câu hỏi riêng, KHÔNG tự đổi)"* | `HANDOFF:7` slot **(40)** + `[carry:hmw-width-vs-roster]` | ✅ CÓ |
| gap `ring5-audit` (synthesis §4) | `HANDOFF:7` slot **(41)** + `[carry:ring5-audit-gap]` | ✅ CÓ |
| `:32` *"**#24 = 3 khoản KHÔNG-KIỂM-ĐƯỢC chép vào adap-report + thư hub**"* | — | 🔴 **0 HIT** |
**hứa ở:** `.claude/workflows/runs/2026-07-26-S153-ctx-adopt/spec-ctx-adopt-26-07-2026.md:32` (bản CHỐT lead ký) + nguồn gốc `sub-fable-invest-S153.md:135` — checklist phép **#24** liệt đích danh 3 khoản:
- (i) *bằng-chứng-nội-dung @tiep không máy-kiểm được* (transcript ngoài tầm máy — mirror hub §7(iii); lưới thay = #18 dogfood + #19 mồi-ngầm);
- (ii) *2-cửa-sổ-song-song = kẽ chấp nhận, **chưa bịt*** (mirror hub §7(iv); PIN `--session` giảm đua nhưng không đóng);
- (iii) *máy không canh bất-biến-block per-run* (#17 lưới git).
**hiện: VẮNG — 0 hit trên CẢ HAI đích được chỉ định, và 0 hit trên 3 bề-mặt owner khác.**
```
token adap-report(2026-07-25-…-vong-ctx.md) thư-hub(…ctx-adopt-executed…) HANDOFF STATUS session-log-S153
KHÔNG-KIỂM-ĐƯỢC 0 0 0 0 0
bằng-chứng-nội-dung 0 0 0 0 0
2-cửa-sổ-song-song 0 0 0 0 0
song-song 0 0 1 0 0
bất-biến 0 0 2 7 0
```
Đọc RUỘT để loại khả-năng "diễn đạt khác": `adap-report §5 honest-caveat` có đúng 4 khoản **(a)** hub dogfood-0-cửa · **(b)** nấc-executed vs thời-điểm-hiệu-lực · **(c)** dải tok `bytes/4..bytes/3.0` · **(d)** bảng 13 hàng = địa-chỉ nội-bộ hub — **không khoản nào** là (i)/(ii)/(iii). Thư hub có 6 datum + câu *"kiểm-chứng = CHƯA (dogfood T9)"* — cũng không phải 3 khoản này (T9 là *lịch kiểm*, còn (i)(ii)(iii) là *thứ máy KHÔNG BAO GIỜ kiểm được*, hai chuyện khác nhau).
🔴 **Đòn-bẩy bất-đối-xứng (loại bào-chữa "cả khối checklist bị bỏ"):** các phép ANH-EM của #24 trong CÙNG danh sách đều **có đường về**:
- #17 + #21 (DEFER-có-khai) → `adap-report:20` ✅
- #18 + #19 + #20 → `adap-report:25` + `HANDOFF:6` carry `ctx-t9-dogfood` ✅
- #22 + #23 → `HANDOFF:6` carry ✅
⇒ **23/24 phép có vết, riêng #24 = 0**. Không phải quên cả khối; đúng một mục rơi, và là mục **duy nhất** mang nội-dung tự-bất-lợi.
🔴 **Vì sao nặng hơn "một dòng thiếu":** thư `aedd7fb67405` đã `nac: sent` + đóng `content_sha256` ⇒ **đã rời nhà**. Hub đọc được "18/18 PASS · 6 datum · kiểm-chứng CHƯA" mà **không** đọc được 3 kẽ SE tự biết là không kiểm được. Đóng lại bây giờ không sửa được tại chỗ — phải **thư đính-chính** (tiền-lệ có thật: `broadcasts/outbox/ai_infra/2026-07-22-se-to-ai_infra-errata-bao-nac-wave-dot-9-10.md`), tức chi phí đã tăng vì im 1 nhãn phiên.
**im bao lâu:** 1 nhãn phiên (S153 → S154), nhưng **qua một ranh-giới không lùi được** (thư đã gửi + sha đã đóng). Đây là khác biệt so với 2 ca trước (S143/S148 câm 5 nhãn nhưng còn ở trong nhà).
**resolve:** (a) chép 3 khoản (i)(ii)(iii) vào `adap-report §5 honest-caveat` **và** phát thư đính-chính cho hub; HOẶC (b) owner chốt rằng 3 khoản đó không cần gửi — và ghi quyết định ấy vào `HANDOFF` NEXT-anh có số. Hết flag khi `grep -c 'KHÔNG-KIỂM-ĐƯỢC' adap-report` ≥ 1 **và** hub có bản mang 3 khoản.
---
## FLAG-4 — `gap-carry-dropped` — MED
**hứa ở:** `docs/STATUS.md:3` *"**Update rule:** trước khi bắt đầu 1 task → ghi row `🔥 In Progress`. Xong → `✅ Recently Done`"* — row còn `⚪ pending` = việc CHƯA xong, theo luật của chính file. Cộng nghi-thức (iv) `HANDOFF:18` *"đủ MỌI slug còn mở"*.
**hiện:** vùng `STATUS.md:636-756` có **14 dòng ``** = **6 việc distinct** (`PE "lỗi phân quyền vào phiếu"` lặp **9 lần** qua S101→S111). Đối chiếu với sổ carry:
| việc `⚪ pending` | slug `[carry:*]` | slot NEXT-anh có số | vết HANDOFF |
|---|---|---|---|
| UAT S117 incognito #77 | `uat-s117` ✅ | — | mới |
| PE lỗi phân quyền (×9 khối) | `pe-phanquyen` ✅ | — | mới |
| **UAT S113** Supplier import v2 + *"Database NCC" ĐẦY ĐỦ* | ❌ 0 | ❌ 0 | chỉ `:234` |
| **UAT S112** A1 + *"Database NCC" THẬT LayoutValid* | ❌ 0 | ❌ 0 | chỉ `:243` |
| **Follow-up non-blocking S112** (`FE-Import-guard Suppliers/Create` · 2 stale-comment · test `NormalizeJoin`+FormC · harvest run-trace stamp) | ❌ 0 | ❌ 0 | chỉ `:243` |
| **H22 pilot S111** (hook-fire re-check · kill-giữa-chain→`/tiep` · đổi-acc · pause-roundtrip workflow THẬT) | ❌ 0 | ❌ 0 | chỉ `:249` |
⇒ **4/6 việc còn-mở nằm NGOÀI sổ carry hoàn toàn.** Vết HANDOFF duy nhất của cả 4 nằm ở dòng **234/243/249** — dưới cửa-sổ tiering mà chính `HANDOFF:3` đặt (*"giữ **2-3 session gần nhất**"*), tức vùng theo luật là đã-nên-archive.
🔴 **Tự-khai sai bị bắt bằng đĩa (bài S121 — bảng mô-tả Ý-ĐỊNH ≠ TRẠNG-THÁI):** row H22-pilot tự ghi trạng-thái `⚪ pending **(NEXT-anh)**`. Chạm đĩa: `HANDOFF:19` (khối NEXT anh mang số) chứa các slot `(4)(5)(6)(7)(11)(12)(13)(14)(15)(20)(21)(22)(26)(27)(28)(29)(30)(31)(32)` và `grep -i 'pilot\|hook-fire\|H22'` trên chính dòng đó = **0**. Hai mục con `đổi-acc` và `kill-giữa-chain` = **0 hit toàn `HANDOFF.md`**. Nhãn "(NEXT-anh)" đúng với sơ-đồ CŨ (`:249`, trước khi chuyển sang hệ đánh-số) và **hết đúng** từ khi anh chỉ trả lời bằng SỐ ⇒ mục này về cấu-trúc **không thể được anh trả lời**.
🔸 **Khai cận-dưới trung thực (KHÔNG thổi):** tao **không** khẳng-định 4 việc đó chưa làm. `pause-roundtrip` gần như chắc đã xảy ra (S152 = phiên-logic 5 nối bằng `/pause`+`/tiep`). Cái tao đo được và khẳng-định: **không sổ nào ghi chúng đã xong, và không sổ nào giữ chúng đang mở** — đúng khe mà nghi-thức carry sinh ra để bịt. Phân-biệt "anh chủ-động gác" vs "bỏ rơi" thì tao **không đủ dữ-liệu ⇒ hỏi, không đoán**.
**im bao lâu:** S111/S112/S113 → S154 = **≈41-43 nhãn phiên** kể từ lần cuối có mặt ở khối NEXT còn hiệu-lực.
**resolve:** mỗi việc → (a) cấp `[carry:<slug>]` vào khối carry kỳ tới, HOẶC (b) đổi row STATUS sang `` kèm vết, HOẶC (c) anh nói "gác" ⇒ đánh dấu `owner-held` để lượt sau tao xếp INFORM chứ không FLAG.
---
## FLAG-5 — `gap-underfill` — MED
**hứa ở:** `memory-budget.json → crystallized_backfill.target = 210000` (**OWNER-SET @S115**, `role_boundary_note` cấm AI tự hạ) + `_target_note`: *"actual hot-feed fill is **em-main manual per-session from source_order** (gist → value-marked-archive → curated-RAG, dedup vs hot-load)"*. Và `token_governor.pct_print._note`: *"**Headroom > 0 WHILE high-value content still unloaded = under-fill (WRONG) → load more**"*.
**hiện:** `crystallized-backfill.ps1` chạy tươi phiên này in:
```
tok RANGE [152478 .. 203304] measured_headroom 176696 tok target 210000
expected_backfill 176696 tok precondition MET (target>0 AND headroom>0)
would pull up to 176696 tok in order: 1.gist 2.value-marked 3.curated RAG
```
Nhưng **bước RÓT không tồn tại trong bất-kỳ nghi-thức nào**:
```
grep -i 'source_order' .claude/commands/{session-start,session-end,tiep,pause}.md => 0 / 0 / 0 / 0
```
⇒ máy in ra một kế-hoạch 176.696 tok mỗi phiên mà **không lệnh nào tiêu-thụ nó**.
🔴 **Datum MỚI (đây là lý do tao báo lại chứ không im vì "repeat"):** @S153 vừa land khoá `tiep_reload` — bản đặc-tả **nạp-nền chính thức** đầu tiên của SE, `mode: "rut-gon"`, `sources` = **5 mục** (`STATUS` dòng CURRENT+bảng · `HANDOFF` segment mới nhất · `WAL` trọn · `_mind-s-<N>` trọn · `_context-s-<N>` trọn). **0/5 mục thuộc `source_order`** {gist · value-marked-archive · curated-RAG}. Nghĩa là: đúng lúc SE viết mới toàn bộ quy-tắc nạp-ngữ-cảnh, nghĩa-vụ backfill 176.696 tok **không được gấp vào** — bỏ sót **mới, có ngày tháng**, không phải tồn-đọng cũ. Vật-liệu thì sẵn: RAG **SỐNG** (`proj_solution_erp`, 2449 chunk — lead đo tươi ~17:55), gist/archive có trên đĩa.
🔸 **Khai cận-dưới:** rót không để lại artifact ⇒ `0-hit` là **cận-dưới**, không chứng minh được "chưa từng rót lần nào". Cái chứng minh được: **bước rót không có trong nghi-thức** ⇒ nếu có xảy ra thì là ad-hoc, không tái-lập.
🔸 Mẫu-số vẫn hỏng như đã nêu @S150: `target` 210.000 > headroom 176.696 ⇒ `expected_backfill` luôn bị headroom chặn ⇒ chỉ-báo mất khả-năng phân-biệt *nạp-thiếu-thật* vs *trần-đặt-quá-tay*. **Số là quyền anh**, tao chỉ nêu.
**im bao lâu:** 2 nhãn phiên ở dạng này (S153 → S154); trục gốc "không có bước rót" đã nêu @S153 và **chưa vá**.
**resolve:** (a) thêm 1 bước rót tường-minh (đọc `source_order`, dedup vs `hotload_sources`, dừng ở `expected_backfill`) vào `session-start` §2.1.6 và/hoặc `tiep_reload.sources`; HOẶC (b) anh hạ `target` về 0 = TẮT có chủ đích. Hết flag khi `grep source_order` trên bộ command ≥ 1, hoặc `target = 0`.
---
## Honest-zero + BÁC ứng-viên (khai vì im lặng đọc thành sạch)
- **`gap-owner-specifics` = 0 lượt này.** Đã soi 4 chỉ-thị owner của S153 và cả 4 **giữ trọn nguyên-văn**: *"còn thiếu 2 vai ctx?"* / *"Build đủ 3 vai như hub"* (session-log:13, spec:18, adap-report:29) · *"kiểm lại label 5 vòng → đặt tên đúng giống hub và sisters để dễ audit"* (session-log:14) · pipeline *"/fable-real invest → spec → hmw Opus 5 MAX xử lý"* (spec:4, sub-fable-invest:39, adap-report:12). Đáng ghi: vế **"và sisters"** KHÔNG được thoả (SE theo hub, VIPIX dùng `ctx-check/ctx-reload/ctx-chain`) — nhưng lead **tự khai nghịch-lý** ngay tại session-log:54 kèm căn-cứ (tiền-lệ anh ratify @S149) ⇒ đúng khuôn, **không phải** mất specifics.
- **Bảng owner-gated KHÔNG chìm cả khối lượt này** — 3/4 mục có đường về `HANDOFF` slot có số (40)/(41)/(35)-(38). Đây là **bác một phần** mẫu tái-phát S143→S148: nghi-thức đã ăn; chỗ thủng còn lại hẹp và cụ-thể (FLAG-3).
- **BÁC ứng-viên FLAG — slot (39) RAG.** `HANDOFF:7` ghi *"(39) RAG Qdrant DOWN (6333 refused…)"* nhưng lead đo tươi ~17:55: `proj_solution_erp` sống, 2449 chunk, rerank OK. Đây là **mô-tả trạng-thái đã đổi** = trục *"CÓ nhưng LỆCH"* ⇒ **turf `lead-stale-auditor`**, tao KHÔNG lấn. Chuyển giao theo luật turf-handoff: **"→ lead chuyển phiên sau"**, không ghi "→ vai X".
- **BÁC ứng-viên FLAG — `gap-incident-unrecorded` = 0.** Sự-cố #53 của S153 (14-cận-dưới) CÓ vết bền: session-log §5:59 + adap-report datum-5 + thư hub datum-5. Residual-write `test-specialist` cũng có vết (:60). Không có sự-cố nào tao thấy dấu mà 0 sổ ghi.
- **Ranh đã giữ:** các số cũ trong `adap-report` (`VALID_ROLES 20→21` ở `:21` vs `21→23` ở `:29`; §4 `:39-40` còn ghi *"chờ anh (38)"*/*"chờ anh (36)"* sau khi (36)(38) đã quyết) và header `ctx-soft-memory-se-draft.md:3` *"CẤM wire trước khi anh duyệt"* sau khi đã wire — **tất cả là trục CÓ-nhưng-LỆCH ⇒ `lead-stale-auditor`**, liệt ở đây chỉ để không rơi vào khe giữa 2 vai.
## 🔴 KHAI PHẠM VI — cái lượt này KHÔNG phủ
Coordinator thu hẹp phạm vi giữa chừng (sau 2 lần return-lane hỏng). Ba bề mặt dưới **đã được đo và ĐÃ kết thành FLAG** trong file này: *việc rớt khỏi work-state* (→ FLAG-4) · *memory under-fill* (→ FLAG-5) · *yêu-cầu owner mất specifics* (→ honest-zero, có liệt bằng-chứng).
Cái **THỰC SỰ chưa phủ** lượt này, khai thẳng:
1. **`gap-carry-aged` cho 26 slug bị FLAG-1 che** — không đo được tuổi thật của chúng vì thước đã mù (vòng lặp: phải vá FLAG-1 trước). Số 7/9/13 đang dùng là số **kỳ S150/S151**, KHÔNG phải số hôm nay.
2. **Các mục `⚪ pending` trong 9 khối `<details>` gập** của STATUS (S103-S108) — chỉ đọc phần bung, chưa soi từng khối gập.
3. **`docs/changelog/migration-todos.md`** — chỉ dùng làm bề-mặt đối-chứng grep, chưa dựng tập kỳ-vọng riêng từ nó.
4. **Bookend @close** — dữ-liệu closeout chưa tồn tại lúc đo; sổ bền TRẮNG lúc này là **vô-nghĩa để FLAG** (khuôn đã chốt @S153).
## TOTAL + phân-rã theo class (cho `jump_on_class_repeat`)
```
[LEAD-GAP] HIGH | gap-carry-dropped | hứa: HANDOFF:13 "GIỮ re-stamp 23 slug" + :6 "GIỮ re-stamp toàn bộ carry @S152 (26 slug)" | hiện: khối carry mới nhất :6 chỉ 5 khoá [carry:x] — 26 slug 0-hit dạng máy-đọc | im: 2 kỳ carry | resolve: khối carry kỳ tới liệt đủ 26+ slug dạng [carry:<slug>]
[LEAD-GAP] MED-HIGH | gap-carry-aged | hứa: h24_cadence.light_every + HANDOFF:14 slot (29) "3 carry-age 12/9/7 — xử hay gia-hạn?" | hiện: H24-2 FIRE=0; carryLines[0] 7 khoá, 4/7 đã ĐÓNG; khối Carry @S153 (:6) nằm TRƯỚC marks[0] ⇒ 3 slug mới vô hình | im: 2 nhãn phiên không có số mới | resolve: sau khi vá FLAG-1, H24-2 in lại streak ≥ M cho hook-vs-budget-cap
[LEAD-GAP] MED-HIGH | gap-decision-sunk | hứa: spec-ctx-adopt-26-07-2026.md:32 "#24 = 3 khoản KHÔNG-KIỂM-ĐƯỢC chép vào adap-report + thư hub" | hiện: VẮNG — 0 hit trên cả 2 đích + 3 bề-mặt owner; 23/24 phép anh-em CÓ vết | im: 1 nhãn phiên, đã qua ranh thư-đã-gửi (sha đóng) | resolve: chép 3 khoản + thư đính-chính, hoặc owner chốt miễn
[LEAD-GAP] MED | gap-carry-dropped | hứa: STATUS:3 update-rule + nghi-thức (iv) "đủ MỌI slug còn mở" | hiện: 4/6 việc ⚪ pending KHÔNG có slug lẫn slot có-số; H22-pilot tự khai "(NEXT-anh)" mà HANDOFF:19 grep = 0 | im: ≈41-43 nhãn phiên | resolve: cấp slug, hoặc đóng row, hoặc owner đánh dấu gác
[LEAD-GAP] MED | gap-underfill | hứa: crystallized_backfill.target=210000 (OWNER-SET) + _target_note "em-main manual pour from source_order" | hiện: expected_backfill 176.696 tok/phiên mà grep source_order trên 4 command = 0; tiep_reload.sources (MỚI @S153) 0/5 mục thuộc source_order | im: 2 nhãn phiên | resolve: thêm bước rót vào nghi-thức, hoặc owner hạ target về 0
TOTAL: 5
gap-carry-dropped : 2 (FLAG-1 HIGH, FLAG-4 MED)
gap-carry-aged : 1 (FLAG-2 MED-HIGH)
gap-decision-sunk : 1 (FLAG-3 MED-HIGH)
gap-underfill : 1 (FLAG-5 MED)
gap-owner-specifics: 0 (honest-zero, có bằng-chứng)
gap-incident-unrecorded: 0 (BÁC — sự-cố S153 có vết bền 3 nơi)
```
🔴 Propose-only. Không Write/Edit ngoài file này · không commit · không RAG-write. Em-main VERIFY → APPEND (B3).
<!-- END sub-lead-gap-S154 · TOTAL=5 FLAG -->

View File

@ -0,0 +1,97 @@
# sub-lead-stale — BOOKEND @OPEN S154 (phiên-logic L7) · 2026-07-26
**Vai:** H24 §2(1) vai-STALE (view LỆCH source). **Nhịp:** hình-B vô-điều-kiện @open (KHÔNG theo counter).
**Nền máy (KHÔNG chạy lại):** `governance-detectors.ps1` phiên này TOTAL **43** + INFORM-only **4** (C11 FLAG-khuôn ×4 tại runs S151/S152). Lane của tao = NGỮ-NGHĨA máy mù.
**Phủ máy (đọc `scripts/governance-detectors.ps1:204-282`):** canonical-resolve CHỈ gồm `{mig, table, gotcha, test, menu, policy}` ⇒ các row `User memory` · `Sub-agents` · `RAG chunks` · `API endpoints` · `FE pages` · `Master data` **NGOÀI tập-đo máy** — tin máy tới đâu thì tới đó, "0 flag" ở các row này KHÔNG đọc thành sạch.
**Kỷ luật:** ghi TỪNG finding ngay khi thấy (anti-#53 + anti-pin-bản-cắt 4-A@S152). File này LUÔN hợp-lệ giữa chừng; chỉ dòng `END+TOTAL` cuối mới là bản chốt.
---
## FLAG-1 — `view-stale-count` — HIGH
- **view:** `docs/STATUS.md:454` — row canonical `| User memory | **31** |`, note gần nhất neo ở `+S84` (`feedback_derived_flush_execution`), re-ground gần nhất khai `S71 (H1 disk-count: 25 feedback_* + 3 project_*)`.
- **source (đĩa, 2026-07-26):** `.claude/auto-memory/` = **53** file `.md` trừ `MEMORY.md` (**47** `feedback_*` + **6** `project_*`); `.claude/auto-memory/MEMORY.md` cũng có **53** dòng index `^- \[`.
- **Lệch:** view nói **31**, source nói **53** — thiếu **22**, tức ~41% corpus không có trong con số canonical. Đơn-vị KHỚP (cùng phép đếm mà chính note dùng ở lần re-ground S71: file `feedback_*` + `project_*` trên đĩa), nên đây KHÔNG phải lệch-định-nghĩa mà là stale thật.
- **Vì sao máy mù:** `Get-StatusValue` (detectors:204-208) chỉ map `{mig,table,gotcha,test,menu,policy}` → row `User memory` không có token, `[OK] canonical matches disk` không hề cross-check nó. Đây đúng lớp **canonical-poison** đã cắn ở S148 (row `Tests` 528 sai suốt 2 phiên) — nhưng lần này row NGOÀI tập cross-check nên không có ai bắt hộ.
- **resolve:** row `:454` = 53 (hoặc chốt lại định-nghĩa đếm), + note ghi rõ lần re-ground S154; hết flag khi `Value` khớp `ls .claude/auto-memory/*.md | grep -v MEMORY.md | wc -l`.
---
## FLAG-2 — `view-residual-asym` — HIGH
- **view:** `.claude/agents/lead-stale-auditor.md:4` **VÀ** `.claude/agents/lead-gap-auditor.md:4` — frontmatter `description:` cả HAI file còn câu *"Chạy theo NHỊP (`h24_cadence`), KHÔNG mỗi phiên."*
- **source (cùng file, phần thân):** `lead-stale-auditor.md:51` `## Nhịp — mày chạy **MỖI phiên, ở CẢ HAI bookend** (HÌNH B @S149)` + `:52` *"spawn **VÔ-ĐIỀU-KIỆN** … Không cửa-người nào phải mở, không bộ-đếm nào phải tới hạn"*; song song `lead-gap-auditor.md:44` + `:46`. Nghi-thức: `session-start §2.1.8(e)` + `session-end §L.b(j)(ii)`.
- **Lệch:** view (description) nói cadence-GATED "KHÔNG mỗi phiên", source (thân cùng file + nghi-thức) nói VÔ-ĐIỀU-KIỆN mỗi bookend. **Một file, hai chỗ, nói ngược nhau** — bác được mà không cần ra khỏi file.
- **Bằng-chứng sửa-một-phía (git, không suy đoán):** `git show 11db5ef -- .claude/agents/lead-stale-auditor.md` = **2 hunk, cả 2 nằm trong khối `## Nhịp`** (đổi tiêu-đề + thay dòng `light_every`/`deep_every`); `-- lead-gap-auditor.md` tương tự. **0 hunk chạm dòng 4.** Tức đợt vá M-1 @S153 (do `ring2-audit` thách-CLEAN bắt) sửa THÂN, bỏ MÔ-TẢ — dư-lượng đúng một phía, ×2 file.
- **Vì sao HIGH (không phải cosmetic):** `description` là bề-mặt **định-tuyến** — harness nạp nó để quyết có spawn vai hay không, và chính thân file `:53` cảnh-báo đúng hại này (*"persona auto-inject ⇒ đọc đúng chữ rồi tự tính 'light chưa tới hạn ⇒ chưa tới lượt tao' — sai NGAY tại bookend"*). Vá M-1 mà chừa đúng bề-mặt gây hại ⇒ lỗ hổng vẫn sống, chỉ đổi chỗ trú.
- **Tự-phê (điểm mù tự-quy-chiếu, bài M-1@S153):** đây là lượt đầu tao đưa `.claude/agents/lead-*-auditor.md` vào tập-đo theo đúng bài đã học — và nó ra ca THẬT ngay lượt đầu.
- **resolve:** dòng `:4` của CẢ HAI file bỏ mệnh-đề "KHÔNG mỗi phiên", thay bằng "chạy MỖI phiên ở CẢ HAI bookend (hình B); `h24_cadence` chỉ còn cổng `deep_every`". Hết flag khi `grep -n "KHÔNG mỗi phiên" .claude/agents/lead-*-auditor.md` = 0 hit.
---
## FLAG-3 — `view-stale-status` — HIGH
> 🔴 **ERRATA — bản đầu của FLAG này SAI CHIỀU, tao tự khai:** lượt đầu tao đọc `HANDOFF (39)` + `STATUS:471` rồi kết luận "STATUS:457 khai alive trong khi RAG DOWN". Sai vì tao **suy từ doc, không chạm nguồn sống** — đúng cái bẫy `resume_premise_reverify` ("khẳng-định trạng-thái = phải chạm 1 lệnh"). Lead đính chính; tao **tự đo lại bằng `list_projects` (không nhận số qua relay)** và viết lại chiều lệch bên dưới.
- **Đo sống của tao (2026-07-26, `mcp__rag-unified__list_projects`):** `proj_solution_erp` **TRẢ LỜI**`chunk_count = 2449`, `last_indexed_at = 2026-05-29T23:26`, `current_cwd_project = solution_erp`; server trả **7 collection**, 5 sister có `last_indexed_at` **hôm nay 17:0217:05** ⇒ Qdrant **UP**, không phải 6333-refused.
- **view (bề-mặt owner-facing, ĐANG stale):** `docs/HANDOFF.md:7` khoản **(39)** — *"RAG Qdrant DOWN (6333 refused, gotcha #14 off-after-reboot; charter AI_INFRA — anh tự chạy `rag-infra.ps1 -Action ensure` / để AI_INFRA / cho em chạy cross-repo?)"* — đây là **câu hỏi đang treo bắt owner quyết**.
- **view thứ 2 (cùng lớp):** `docs/STATUS.md:471` dòng State S153 — `… · **RAG DOWN (defer AI_INFRA)** · prod 37 NCC/17 user.`
- **source:** chính phép đo trên (`list_projects` trả 2449 + 6 collection sister tươi hôm nay) + `docs/STATUS.md:457` — ô canonical khai *"alive … rerank sống"*, **và ô này ĐÚNG**.
- **Lệch:** view nói **DOWN**, source nói **UP**. Tức bề-mặt sai là **HANDOFF (39) + STATUS:471**, KHÔNG phải ô canonical `:457`. Đúng luật B1: derived lệch canonical ⇒ flag derived.
- **Hại cụ-thể (nặng hơn một số sai):** (39) đang **bắt owner trả lời một câu hỏi dựng trên tiền-đề đã chết** — 3 lựa-chọn ("anh tự chạy ensure / để AI_INFRA / cho em chạy cross-repo") đều vô nghĩa nếu dịch-vụ đang chạy. Owner tốn một lượt quyết cho việc không tồn tại; và nếu anh chọn "chạy `ensure`" thì đó là thao-tác thừa lên hạ-tầng đang sống.
- **Vì sao máy mù:** `RAG chunks` không nằm trong `$canonRows` (`scripts/governance-detectors.ps1:204-208` chỉ `{mig,table,gotcha,test,menu,policy}`) ⇒ 0 cross-check; và không detector nào ping 6333. Trục này **chỉ có người đo mới biết**.
- **Caveat trung-thực:** (39) có thể ĐÚNG lúc viết (S153) — RAG lên lại sau đó là hoàn toàn có thể. Flag không nói lead "bịa"; flag nói **nhãn chưa lật khi trạng-thái đã đổi**, đúng định-nghĩa `view-stale-status`.
- **resolve:** đo lại 1 lệnh `list_projects` @open (rẻ), rồi (a) gạch/đóng (39) ở HANDOFF nếu UP, (b) sửa `STATUS:471` khỏi "RAG DOWN". Hết flag khi 3 bề-mặt (`:457`, `:471`, HANDOFF (39)) cùng nói một trạng-thái với phép đo sống.
---
## FLAG-4 — `view-stale-count` — LOW
- **view:** `docs/STATUS.md:457``| RAG chunks | **2447** | re-verify S151 (…, +6 vs S126's 2441; …)`.
- **source:** `list_projects` hôm nay = **2449** cho `proj_solution_erp`.
- **Lệch:** view **2447** vs source **2449** = **+2** (2 chunk `store_memory` ghi thêm sau S151 — khớp cơ-chế "store_memory stopgap" mà chính ô này mô-tả).
- **Vì sao vẫn đáng ghi dù nhỏ:** ô này nằm trong bảng CURRENT STATE = nguồn `/tiep` nạp lại (`memory-budget.json → tiep_reload.sources[0]`), và nó **ngoài tập cross-check của máy** ⇒ không ai bắt hộ; drift +2/phiên-vài-lượt sẽ tích lại đúng như row `User memory` (FLAG-1) đã tích thành +22.
- **KHÔNG lẫn với `last_indexed 05-29`:** ô đã khai đúng chỗ đó là stale-index CÓ CHỦ-ĐÍCH (chờ VOYAGE_API_KEY / AI_INFRA) ⇒ **không flag phần đó** (trap #3 đóng-băng-có-chủ-đích).
- **resolve:** `:457` = 2449 + đổi neo "re-verify S154". Hết flag khi số ô khớp `chunk_count` của `list_projects` tại lần re-ground gần nhất.
---
## FLAG-5 — `view-residual-asym` — MED
**Một gốc, hai lá còn sót** — đợt vá @S153 (FLAG-4 của chính tao: *trio-memory bị khai là chưa-chạy/consent-gated*) chỉ sửa **STATUS**, hai bề-mặt sinh-đôi đứng nguyên.
- **Bằng-chứng gốc đã vá một phía:** `git show c2d01f0:docs/STATUS.md` (bản S152) còn chuỗi *"CHƯA spawn-probe lần nào (cần restart CLI + anh consent; KHÔNG được đọc thành verified-runtime)"*; bản hôm nay `grep -c "CHƯA probe lần nào" docs/STATUS.md` = **0** ⇒ STATUS đã sạch.
- **Lá 1 — view:** `.claude/commands/session-start.md:65`*"+3 vai trio-memory (S141) **CHƯA probe lần nào** — lần probe đầu = phiên SAU restart (đừng đọc con số cũ thành 'đủ roster hôm nay')."*
- **Lá 2 — view:** `.claude/agent-memory/memory-budget.json:185` (`measured._note`) — *"3 row nay = SEED-STATE (vai chua chay lan nao: first-run cho restart Claude Code + consent anh @session-start §2.1.9 / session-end §L.b(k)) nen l1_hot ~1.5KB va l2_* = 0 la DUNG"* + `:200`/`:201`/`:202` field `rollout` cả ba = *"S141 seed - first-run pending restart+consent"*.
- **source (đĩa, 3 nguồn độc-lập):**
1. run-folder: `.claude/workflows/runs/2026-07-22-S144-trio-first-run/` · `…/2026-07-25-S151-trio-memory/` · `…/2026-07-25-S152-trio-memory/` — mỗi folder có đủ `harness-eval-return.md` + `harness-refine-return.md` + `harness-audit-return.md` + `trio-synthesis.md`**đã chạy ≥3 vòng**, không phải "chưa lần nào".
2. diary: `harness-eval 4.492B` · `harness-refine 4.813B` · `harness-audit 4.530B`**KHÔNG còn ~1.5KB** như `_note:185` khẳng-định.
3. cổng consent: `.claude/commands/session-start.md:169` + `:276` + `:280` đã ghi *"AUTO 1 vòng/phiên … owner @S151 — 🧊 consent-gated S141 **superseded**"*, và `:271` ghi *"Từ S152: cả 5 vòng … đều AUTO, 0 cổng consent"*.
- **Lệch:** view nói *chưa chạy lần nào + đang chờ consent + diary ~1.5KB*; source nói *chạy 3 vòng + consent đã gỡ từ S151 + diary ~4.5KB*. Riêng lá-2 còn **tự mâu-thuẫn với chính file lệnh** đã tuyên bố superseded.
- **Vì sao đây là `view-residual-asym` chứ không phải `view-stale-status` đơn thuần:** tiền-đề "chưa chạy/consent" ĐÃ được nhận diện và ĐÃ được vá — nhưng chỉ ở 1 trong 3 nơi nó sống. Đúng chân-dung "sửa nguồn quên gương" (S124), và cùng khuôn với FLAG-2 phiên này (vá thân persona quên frontmatter) ⇒ **2/5 FLAG hôm nay cùng một tật: acceptance của đợt vá dừng ở file đầu tiên tìm thấy**.
- **Không lấn turf:** phần `measured` **con-số byte** đang defer có chủ-đích tới drift-audit 2026-08-01 (`_note:185` khai rõ) ⇒ **KHÔNG flag các số đo**; chỉ flag **mệnh-đề trạng-thái** "chua chay lan nao / pending restart+consent".
- **resolve:** (a) `session-start.md:65` đổi sang "trio đã chạy S144/S151/S152 (run-folder chứng); mốc S122 giữ nguyên là lịch-sử"; (b) `memory-budget.json:185` + 3 field `rollout` bỏ mệnh-đề "chua chay lan nao / pending consent", giữ nguyên số + giữ nguyên hẹn re-measure 2026-08-01. Hết flag khi `grep -n "CHƯA probe lần nào" .claude/commands/session-start.md` = 0 **và** `grep -c "pending restart+consent" .claude/agent-memory/memory-budget.json` = 0.
---
## TỔNG-KẾT (phân-rã theo class — để tính `jump_on_class_repeat`)
| class | số FLAG | FLAG# |
|---|---|---|
| `view-stale-count` | 2 | FLAG-1 (HIGH), FLAG-4 (LOW) |
| `view-residual-asym` | 2 | FLAG-2 (HIGH), FLAG-5 (MED) |
| `view-stale-status` | 1 | FLAG-3 (HIGH) |
| `view-stale-header` | 0 | — |
| `view-stale-role-desc` | 0 | — |
**TOTAL = 5 FLAG** (3 HIGH · 1 MED · 1 LOW).
### VERIFIED-CLEAN (kèm LỆNH — bài ring2 @S151: VC không có lệnh = không tính)
- `agents/README.md` roster-count: `grep -n "roster" .claude/agents/README.md``:3`/`:6`/`:27`/`:242`/`:253` **cố ý BỎ số**, trỏ canonical; đếm đĩa `ls .claude/agents/*.md | wc -l` = **24** trừ README = **23** = khớp `STATUS:456` **23**`memory-budget.json:28` "roster nay = 23". **0 flag.**
- `STATUS:441` tiêu-đề CURRENT STATE: đọc *"mốc gần nhất **S153**"*, và re-ground giá-trị mới nhất trong section = S153 ⇒ **đã bump, 0 `view-stale-header`** (ca này ĐÃ là FLAG-2 @S153, nay sạch).
- `HANDOFF:7` khoản **(38)**: đã `~~(38)~~ ✅ anh đã VETO + chốt hub-form 3-vai` ⇒ nhãn đã lật đúng, **0 flag**.
- Cổng consent 5 vòng ở `session-start.md:169/:188/:271/:276/:280`: mọi vế retired đều có dấu 🧊 + lineage + ngày ⇒ **0 stale-status** (đây là mẫu ĐÚNG mà FLAG-5 lá-2 nên copy).
- `STATUS:452` row `Tests **532 PASS** | 45 Domain + 487 Infra`: khớp `:471` "532 test" ⇒ ca canonical-poison S148 **đã đóng, không tái phát**.
- 🔴 **KHÔNG kết luận sạch trục H24-3 session-label** (bài S150): `STATUS` vẫn dùng anchor `CURRENT` chứ không `Last updated`, `HANDOFF:5` viết `(**S153**` nên regex `\(S` trượt ⇒ máy im ở trục này **KHÔNG đọc thành sạch**; tao không đo được nó trong lượt @open này ⇒ khai là **chưa phủ**, không khai là clean.
<!-- END sub-lead-stale-S154 · TOTAL=5 FLAG -->

View File

@ -0,0 +1,263 @@
# sub-ring1-audit — KIỂM vòng-1 @OPEN S154 (phiên-logic L7)
> Vai KIỂM độc-lập trên số của H1 `tooling-auditor` + H2 `harvest-curator`. Read-only + propose-only.
> Ghi-đĩa-TRONG-lúc-làm (chống #53) — file LUÔN hợp-lệ giữa chừng, mỗi verdict 1 mục đếm được.
> Bắt đầu 2026-07-26. Repo `D:\Dropbox\CONG_VIEC\SOLUTION\SOLUTION_ERP`.
## §0 PIN + fail-safe (nghĩa-vụ (i)) — input TỒN-TẠI, ĐÓNG, tuần-tự C4b
| kiểm | kỳ-vọng (lead pin) | tao ĐO ĐỘC-LẬP | phán |
|---|---|---|---|
| H1 tồn-tại | `sub-tooling-auditor-open-S154.md` | có, `wc -c` = **13605** | ✅ KHỚP |
| H1 END | `TOTAL=3 FINDING` @:143 | `wc -l`=143, dòng 143 = `<!-- END sub-tooling-auditor-S154 · TOTAL=3 FINDING -->` | ✅ KHỚP |
| H2 tồn-tại | `sub-harvest-curator-open-S154.md` | có, `wc -c` = **24858** | ✅ KHỚP |
| H2 END | `TOTAL=6 FINDING` @:289 | `wc -l`=**290** (≠289) — nhưng `od -c` đuôi = `-->\n\n` ⇒ END nằm dòng **289**, dòng 290 = dòng TRỐNG cuối. **Không phải dấu-hiệu file đang bị ghi** | ✅ KHỚP (đã giải-thích chênh) |
| tuần-tự C4b | mtime(ring1) > mtime(H1),(H2) | H1 18:06:39 · H2 18:08:40 · ring1 (file này) sinh sau 18:08 | ✅ ĐÚNG THỨ-TỰ |
**pin ĐẦY-ĐỦ, KHÔNG NO-OP.** Cả 2 vai-đo có chạy phiên này, artifact tươi (mtime 2026-07-26 18:0x, cùng phiên).
🔴 **Cảnh-báo đã cân-nhắc và BÁC:** chênh `wc -l` 290 vs pin 289 KHÔNG phải file-còn-đang-ghi — byte-count khớp tuyệt-đối (24858) và đuôi file có END marker đóng. Chênh thuần do đếm dòng-trống cuối.
(findings ghi dần bên dưới)
---
## §1 DATUM (a) — F-3 của H1: **DƯƠNG-GIẢ. Lead BÁC ĐÚNG.** (kiểm bằng lệnh của chính tao)
🔴 Đây là phép thử về chính tao — tao KHÔNG nhận qua relay, tự chạy lại từ đầu.
**H1 claim (F-3, `sub-tooling-auditor-open-S154.md:114`):** *"`grep -c "→ log" docs/STATUS.md` = **29** … nhưng **khối S153 `:466-471` KHÔNG có dòng nào**; `grep -rn "2026-07-26-1600-S153"` = 7 hit, live-anchor **DUY NHẤT**`.claude/sessions/session-6/_end:2`"*
**Tao đo lại — 3 phép độc-lập, cả 3 phá claim:**
| # | lệnh của TAO | kết-quả | hệ-quả |
|---|---|---|---|
| a-1 | `grep -n $'\xe2\x86\x92 log' docs/STATUS.md \| cut -d: -f1` → in **toàn bộ 29 số dòng** | `6 8 10 43 54 67 93 101 120 145 177 201 222 249 282 316 331 337 349 371 379 408 426 436 **471** 480 484 488 508` | **471 CÓ trong danh sách 29 hit của chính H1.** Khối S153 = `:466-471`, nên `:471` NẰM TRONG khối ⇒ claim "KHÔNG có dòng nào" **SAI** |
| a-2 | `sed -n '471p' docs/STATUS.md` | `- State: … → log [`2026-07-26-1600-S153-ctx-adopt-3vai-rename.md`](changelog/sessions/2026-07-26-1600-S153-ctx-adopt-3vai-rename.md)` | con-trỏ **CÓ THẬT**, dạng **markdown-link** |
| a-3 | `sed -n '6p' docs/STATUS.md` (khối CURRENT S153) | đuôi dòng = ``→ log `changelog/sessions/2026-07-26-1600-S153-ctx-adopt-3vai-rename.md` `` | con-trỏ **THỨ HAI**, dạng backtick |
| a-4 | `ls -la` + `wc -c` file đích | **9367B**, mtime 2026-07-26 15:45 | file đích **TỒN-TẠI**, byte khớp đúng số lead đưa (9.367B) |
**🔬 Tao truy luôn CƠ-CHẾ sai của H1 (kiểm quy-NGUỒN, không chỉ con-SỐ — bài E1/S150 #4):**
```
grep -c $'\xe2\x86\x92 log `' docs/STATUS.md = 25 <- dạng backtick
grep -c $'\xe2\x86\x92 log \[' docs/STATUS.md = 4 <- dạng markdown-link (25+4 = 29 ✔)
4 dòng dạng markdown-link = :471 :480 :488 :508
```
⇒ `:471` là **1 trong 4 dòng DUY NHẤT dùng khuôn markdown-link** trong toàn STATUS. H1 đếm tổng đúng (29) nhưng khi soi khối S153 đã tìm theo **khuôn backtick** — trúng đúng 1 trong 4 ngoại-lệ khuôn. **Đây là mù-khuôn (form-blindness), không phải mù-số.**
**Phá thêm mệnh-đề phụ "live-anchor DUY NHẤT":**
```
grep -rn "2026-07-26-1600-S153" .claude docs 11 hit / 8 file (H1 khai 7)
docs/STATUS.md 2 <- :6 + :471 CẢ HAI đều live-prescriptive
docs/HANDOFF.md 1 <- :5 live
.claude/sessions/session-6/_end 1 <- :2 H1 gọi "duy nhất"
(7 hit còn lại = run-folder frozen S153/S154)
```
⇒ live-anchor **≥3** (STATUS:6, STATUS:471, HANDOFF:5), không phải 1. Và tổng **11 ≠ 7**.
⇒ Nội-tại H1 cũng đã tự mâu-thuẫn: nó khai *"7 hit … 4 hit còn lại"* ⇒ **1+4 = 5 ≠ 7**, hụt 2 ngay trong câu của nó.
### ⇒ VERDICT §1: **H1 F-3 = TRƯỢT (dương-giả).** Lead bác **ĐÚNG**.
Hệ-quả hành-động: **KHÔNG vá gì cả.** Nếu lead nghe theo H1 mà "thêm 1 mảnh `→ log` vào `:471`" thì sẽ tạo **con-trỏ TRÙNG** trên chính dòng đã có — vá một lỗi không tồn tại. Con-trỏ hiện đủ **2 tầng** (header `:6` + Recently-Done `:471`) + HANDOFF, **vượt** khuôn 28 khối anh em (đa số chỉ 1 tầng).
---
## §2 H1 — verdict per-claim (nghĩa-vụ (ii))
### §2.A ② VAI-TRÒ / roster — tao TỰ ĐẾM LẠI 4 bề mặt (số CÓ-THỂ-SAI)
🔴 Áp luật tự-đặt @S150 sau khi bẫy glob README cắn chính tao: **mọi lệnh đếm `.claude/agents/*.md` phải loại README**.
| H1-# | claim H1 | lệnh TAO chạy (khác nguồn/khác engine) | tao đo | phán |
|---|---|---|---|---|
| H1-1 | đĩa agents = **23** | `ls … \| grep -v README \| wc -l` **+** `git ls-files … \| grep -v README` **+** `find … ! -name README.md` | **23 / 23 / 23** (3 nguồn) | ✅ **ĐẠT** |
| H1-2 | `hmw.js` VALID_ROLES = **23** | **đổi engine awk→python**: neo `startswith('const VALID_ROLES = [')` → dòng đóng `]`; strip `//`; regex `'([a-z0-9-]+)'` | mảng = dòng **22→43**, count **23** | ✅ **ĐẠT** |
| H1-3 | `STATUS:456` = **23** | `sed -n '456p'` | `\| Sub-agents \| **23** \| +S153 bộ-ba vòng Ctx…` | ✅ **ĐẠT** |
| H1-4 | `agent-memory/*/` = **23** | `ls -d .claude/agent-memory/*/ \| wc -l` | **23** | ✅ **ĐẠT** |
| H1-5 | tên trùng khít 1:1 (2 phép `diff` rỗng) | **so TẬP thay so SỐ** (bài E1/S150 #3): `diff roles↔disk`, `diff mem↔disk` | **cả 2 diff RỖNG** sau khi khử CRLF (xem falsify F-2) | ✅ **ĐẠT** |
| H1-6 | `agents/README.md:3` = B1-pointer, **0 số** | đọc `:1-6` + `grep -nE '\b(1[0-9]\|2[0-9])\b'` | `:3` đúng là *"Số vai canonical → docs/STATUS.md §Sub-agents. KHÔNG chép số vào file này"*; các số bắt được (`12`,`14`,`8`) đều nằm trong **văn-tự-sự lịch-sử** ("bản trước S122 cứng-hoá 12-agent… roster đã 14"), **0 count-claim hiện-hành** | ✅ **ĐẠT** |
| H1-7 | `STATUS:6` header cũng khai `Sub-agents 23` | `sed -n '6p'` | có: `**Sub-agents 23** · counter **27**` | ✅ **ĐẠT** |
**⭐ INFORM-R1 (KHÔNG phải lỗi H1 — nhắc lead @close):** `STATUS:6` khai `counter **27**` trong khi `.session-counter.json` đã tick **28** ngay trong phiên này (lead tick S154, classify `squash-benign`). ⇒ `STATUS:6` là **ảnh chụp lúc đóng S153**, **đúng-tại-thời-điểm**, KHÔNG phải drift. Nó **chỉ thành stale nếu closeout S154 quên bump**. Xếp **INFORM**, không chấm TRƯỢT ai.
### §2.B ②-bis DƯ-LƯỢNG TÊN CŨ — tao đếm ra **17**, H1 khai **13**. Truy trước khi phạt (bài E1/S150 #1).
🔴 Vênh 4 hit. Câu hỏi bắt buộc TRƯỚC khi chấm: *"H1 sai, hay số đã DỜI sau khi H1 đo?"* — tao decompose từng file:
| file | hit | H1 có đếm? | lý-do |
|---|---|---|---|
| `sessions/2026-07-26-1600-S153-….md` | 3 | ✅ (khai `:14,:52,:54`) | frozen |
| `agents/ctx-curator.md` · `ctx-verifier.md` | 1+1 | ✅ (lineage-note) | |
| `agent-memory/ctx-{audit,curator,verifier}/MEMORY.md` | 1+1+1 | ✅ (diary seed) | |
| `runs/S153-close/` run.md + 3 sub | 1+1+1+1 | ✅ (frozen ×4) | |
| `docs/STATUS.md` | 1 | ✅ (`:456`) | frozen |
| **⇒ cộng đúng phần H1 khai** | **13** | | **khớp TUYỆT-ĐỐI phân-loại 2+3+8 của H1** |
| 🔺 `sub-tooling-auditor-open-S154.md` (**file của CHÍNH H1**) | **3** | ❌ | **self-citation**: H1 phải VIẾT RA 3 tên cũ trong bảng phân-loại của nó ⇒ tự sinh hit khi đang đo |
| 🔺 `sub-lead-gap-open-S154.md` | **1** | ❌ | mtime **18:06:52** > mtime H1 **18:06:39** ⇒ **chưa tồn-tại** lúc H1 đo |
| **TỔNG tao đo NAY** | **17** | | 13 + 3 + 1 = 17 ✔ **decompose khít, 0 dư** |
⇒ **H1 ĐÚNG TẠI PIN CỦA NÓ.** 4 hit chênh = **citation-trap** (H1 tự-trích tên cũ để phân-loại — lớp `feedback_citation_trap_selfreference`) + **time-drift** (file em nó land sau). Cả 4 đều là **mention/frozen**, 0 cái mô-tả hành-vi sống.
**H1-8 ②-bis: ✅ ĐẠT.** Mệnh-đề load-bearing *"0 site MÔ-TẢ-HÀNH-VI-SỐNG bằng tên cũ"* **HELD** dưới lens của tao (tao đọc cả 4 hit mới: 3 trong bảng phân-loại H1 + 1 trong bối-cảnh lead-gap = trích-dẫn, không phải chỉ-thị).
### §2.C ① SKILL + ③ PLUGIN — tái-dựng bằng nguồn/engine KHÁC
| H1-# | claim | lệnh TAO | đo | phán |
|---|---|---|---|---|
| H1-9 | skill project = **6** | `ls -d .claude/skills/*/ \| wc -l` | **6** | ✅ ĐẠT |
| H1-10 | `git status .claude/skills/` rỗng | chạy lại | **rỗng** | ✅ ĐẠT |
| H1-11 | `~/.claude/skills/` = **23** | `ls -1 \| wc -l` | **23** | ✅ ĐẠT |
| H1-12 | `.claude/commands/*.md` = **19** | `ls -1 \| wc -l` | **19** | ✅ ĐẠT |
| H1-13 | plugin **18 reg / 15 en / 3 dis** | 🔴 **đổi parse: `json.load` thay `sed+grep -c`** (bài E1/S150 #3) | **18 / 15 / 3**, disabled = `pr-review-toolkit@` · `code-modernization@` · `hookify@` — **trùng khít 3 tên** | ✅ ĐẠT |
| H1-14 | marketplace = **37** | `ls -1 …/plugins \| wc -l` | **37** | ✅ ĐẠT |
| H1-15 | lead model `claude-opus-5[1m]`, effort `xhigh` | `json.load` đọc key | `model=claude-opus-5[1m]` · `effortLevel=xhigh` | ✅ ĐẠT |
| H1-16 | `hmw.js:153 WIDTH_CAP=20` = `memory-budget.json cap:20` | grep + `json.load` đệ-quy tìm key `cap` | `hmw.js:153 const WIDTH_CAP = 20` · `/hmw_width/cap = 20` | ✅ ĐẠT (0 drift mirror) |
> 🔴 Ghi-chú F-6 lịch-sử (S153 tao từng bắt H1 **under-count** khi dump 18 key): lần này tao dump **toàn bộ** `enabledPlugins` bằng `json.load` và đếm theo giá-trị `is True` / `is False` — 15+3 = 18 = `len(ep)`, **không còn key nào lọt ngoài 2 nhóm**. Số 18/15/3 lần này **có răng**.
### §2.D 🔴 THÁCH-CLEAN #1 (BẮT BUỘC) — mở skill H1 gọi "FRESH": `ef-core-migration`
H1 khai: *"Nợ S151 (Mig 67) VẪN ĐÓNG — verified tươi … bảng skill = row cuối 67 ⟂ canonical STATUS Mig 67 ⇒ khớp"*. CLEAN không được tin trần — tao mở bằng **lens sạch (đĩa `src/`, thứ H1 KHÔNG chạm)**:
| lens | đo | kết |
|---|---|---|
| row 67 có ở `:89`? | `sed -n '89p'` → `\| **67** \| **AddPeApprovedBudgetSnapshot** \| 🎯 S133 …` | ✅ |
| 67 có THẬT là row CUỐI? | `awk 'NR>89 && /^\\\| *[0-9]/'` → **0 dòng** | ✅ không có row 68 ẩn |
| bảng liên-tục không lỗ? | grep row 60→67 ra đủ 60,61,62,63,64,65,66,67 | ✅ 0 lỗ |
| canonical STATUS | `grep -oE 'Mig [0-9]+'` → `Mig 67` | ✅ |
| 🔬 **Mig 67 có TỒN-TẠI trên đĩa `src/` không?** | `find src -iname '*ApprovedBudgetSnapshot*'` → `Persistence/Migrations/20260717032812_AddPeApprovedBudgetSnapshot.cs` (+`.Designer.cs`) | ✅ **CÓ THẬT** |
| 🔬 số-học khép kín | `ls Migrations/*.cs \| wc -l` = **135** = 67 migration + 67 Designer + 1 `ApplicationDbContextModelSnapshot` | ✅ **135 = 67+67+1 khít** |
| mtime skill | 2026-07-26 01:41, 34.147B (H1 khai 34.147B) | ✅ byte-exact |
⇒ **THÁCH-CLEAN #1: CLEAN CHỊU ĐƯỢC.** Skill `ef-core-migration` **fresh THẬT**, không phải stale-báo-fresh. **H1-17 ✅ ĐẠT.**
*(Đối-chiếu lịch-sử: S151 tao TRƯỢT-1 vì bảng dừng ở 66; S153 chịu; nay chịu tiếp — và lần này tao siết thêm bằng đĩa `src/`, thứ 2 lượt trước chưa dùng.)*
### §2.E F-1 · F-2 của H1
| H1-# | claim | tao đo | phán |
|---|---|---|---|
| H1-18 | F-1 `docs/governance/README.md` chưa chạm từ `72bbfa5` **2026-06-02** | `git log -1 --date=short` → `72bbfa5 2026-06-02`; `git status` rỗng | ✅ **ĐẠT** |
| H1-19 | F-1 `:5` khai `Active trial week 1-4 (…→ 2026-06-23)` | `sed -n '5p'` → đúng verbatim; hôm nay 2026-07-26 ⇒ quá hạn **33 ngày** (tao tự tính lại: 06-23→07-26 = 33) | ✅ **ĐẠT** |
| H1-20 | F-1 `:35` khai `phase-9-uat … (current)` | `sed -n '35p'` → `- \`phase-9-uat\` — UAT + production hardening (current)` | ✅ **ĐẠT** |
| H1-21 | **F-3** khối S153 thiếu `→ log` | **§1** — 3 phép phá | ❌ **TRƯỢT (dương-giả)** |
| H1-22 | F-2 **8/10** thư inbox mới nhất = 0-row | vòng `for` của tao (xem falsify F-3) | **8/10** — 2 file 07-22 có row `:58/:59`, 8 file kia 0 | ✅ **ĐẠT** |
| H1-23 | F-2 `_index.md` **112 dòng** | `wc -l` | **112** | ✅ **ĐẠT** |
| H1-24 | S153 **không đụng skill nào**, commit chạm skill gần nhất `c2d01f0` | `git log -1 --date=short -- .claude/skills/` + `git show --stat 11db5ef -- .claude/skills/` | `c2d01f0 2026-07-26`; S153-closeout chạm **0 dòng** skill | ✅ **ĐẠT** |
| H1-25 | DIFF F-5 ĐÓNG: `memory-budget.json` có `_roster_note_S153` **và** giữ `_why_20_not_16` | `grep -c` 2 khoá | **2/2 có mặt** ⇒ đúng "thêm khoá, KHÔNG sửa record đã ratify" | ✅ **ĐẠT** |
| H1-26 | §Ground: HEAD `871ac0a`, `git status` = **3 mục** | nay HEAD `2af3808`, status 2 mục | 🔴 **ĐẠT-TẠI-PIN** — Stop-hook `wal-flush` auto-commit mỗi turn (`48f3fef` 18:01 · `035daec` 18:02 · `256f724` 18:07 · `2af3808` 18:09). Lúc H1 đo (~17:57-18:00) HEAD **đúng** là `871ac0a` và run-folder **chưa** tracked. **KHÔNG phạt** (bài E1/S150 #1) | ✅ **ĐẠT** |
| H1-27 | DIFF F-1: `agents/README.md:225-226` vá đúng nấc + 3 citation tươi | **không tái-dựng lượt này** (hết ngân-sách lượt) | ⬜ **KHÔNG-CHẤM** |
| H1-28 | DIFF F-3: `HANDOFF.md:8` neo con-trỏ live ctx-draft | **không tái-dựng lượt này** | ⬜ **KHÔNG-CHẤM** |
---
## §3 H2 — verdict per-claim (5 trục + 6 finding)
| H2-# | claim H2 | lệnh TAO (khác engine/nguồn) | tao đo | phán |
|---|---|---|---|---|
| H2-1 | universe **60** run-dir | `find … -mindepth 1 -maxdepth 1 -type d \| wc -l` | **60** | ✅ ĐẠT |
| H2-2 | **3 dir** không có `run.md` (tên cụ-thể) | vòng `for` test `! -f run.md && ! -f sub-md/run.md` | **3, TRÙNG KHÍT TÊN**: `S117-pe-approve-minmax-range` · `S117-pe-luyke-finalize` · `S144-bootstrap` | ✅ ĐẠT (so TẬP) |
| H2-3 | FLAG-1: 2 file **25.283 + 22.248 = 47.531B** | `wc -c` | **25283 + 22248 = 47531** | ✅ ĐẠT byte-exact |
| H2-4 | FLAG-1: END `TOTAL=53` / `TOTAL=27` | `grep -n "END sub-ring1-close"` + `tail` | ring1-close `:166` `TOTAL=53 verdict-item` · ring2-close `TOTAL=27 verdict-item (6 FLAG + 21 phép phụ)` | ✅ ĐẠT |
| H2-5 | **DATUM (b)** dedup an-toàn: `TOTAL=53`/`TOTAL=27` = **0 hit** trong `agent-memory/` | 🔴 **3 phép KHÁC lệnh lead**: (1) `os.walk` python đọc mọi `.md` → 0/0 · (2) `grep -rn "53 verdict-item\|27 verdict-item"` → **0** · (3) đọc chéo sổ ring1/ring2: entry S153 tự khai nguồn = file **@open** | **0 hit cả 3 phép** | ✅ **ĐẠT** |
| H2-6 | coverage **21 file-lượt / 3 run-folder** (9+6+6) | vòng `for` đếm `sub-*`+`*-return` | **9 + 6 + 6 = 21** | ✅ ĐẠT |
| H2-7 | **19/21** đã harvest (90,5%) | 21 2 (§FLAG-1) = 19 | khớp | ✅ ĐẠT |
| H2-8 | C.1 **16 sổ** có entry S153; **7 vai** 0-hit là đúng-vì-không-chạy | vòng `for grep -c S153` trên 23 sổ | **16/23**; 7 vai 0-hit = `cicd-monitor` `database-agent` `frontend-designer` `investigator-api` `office-document` `reviewer` `ring4-audit` — **TRÙNG KHÍT 7/7 TÊN** | ✅ **ĐẠT (so TẬP, không so SỐ)** |
| H2-9 | Placement stray `.claude` depth>1 = **0** | `find . -mindepth 2 -name .claude -type d` | **0** | ✅ ĐẠT |
| H2-10 | empty-dir dưới `.claude` = **0** | `find .claude -type d -empty` | **0** | ✅ ĐẠT |
| H2-11 | **23/23** sổ đúng nhà | `ls */MEMORY.md \| wc -l` + vòng `for` tìm dir thiếu | **23**, **0 dir thiếu** `MEMORY.md` | ✅ ĐẠT |
| H2-12 | Corruption **16 hit / 10 file** | 🔴 **đổi engine perl→python byte-mode** (`b.count(b'\xef\xbf\xbd')`), duyệt `os.walk` | **16 / 10**, và **per-file khớp TỪNG SỐ**: 1,1,3,1,1,1,1,1,5,1 — cộng = 16 | ✅ **ĐẠT (mạnh: khớp cả VECTOR, không chỉ tổng)** |
| H2-13 | Fidelity byte-exact | `wc -c` 3 mẫu | `sub-harvest-curator-S153.md` **17328** ✔ · `sub-ring1-S153.md` **22992** ✔ · `ctx-audit.md` **16206** ✔ | ✅ ĐẠT |
| H2-14 | FLAG-3: sổ khai `14.347B` ≠ đĩa `16.206B` | `wc -c` | đĩa = **16206** ⇒ chênh **1.859B** đúng như H2 tính | ✅ ĐẠT |
| H2-15 | INFORM-1 SELF **19.456B** > target 17.510B; **0/23** vượt cap 25.600 | `wc -c` + vòng `for` so cap | **19456**; **0 sổ** > 25600 | ✅ ĐẠT |
| H2-16 | INFORM-2: `_mind-s-6` **12.482** · `_end` **1.570** · `_context-s-6` **3.474** · `_context-s-7` **2.938** | `wc -c` | **12482 · 1570 · 3474 · 2938** | ✅ ĐẠT 4/4 byte-exact |
| H2-17 | INFORM-3: WAL `run=` 0 · `wf:` 0 · session-log S154 0 | `grep -c` ×2 + `grep -rl` | **0 · 0 · 0** | ✅ ĐẠT |
| H2-18 | E.1 dự-báo baseline S153 (**16/10**) HELD | chính phép §H2-12 của tao | **16/10 khớp** ⇒ mệnh-đề dự-báo của H2 **chịu được 1 chu-kỳ** | ✅ ĐẠT |
| H2-19 | G.3 census **644 file** dưới `.claude` | `find .claude -type f \| wc -l` | **646** nay (+2 vs pin, trong đó **≥1 = chính file này của tao**) | ✅ **ĐẠT-TẠI-PIN** (time-drift, không phạt) |
| H2-20 | §F containment `git status` = **1 mục** | nay **2 mục** (thêm file của tao, chưa commit) | ✅ **ĐẠT-TẠI-PIN** — cùng lớp H1-26 | ✅ ĐẠT |
| **H2-21** | m#23: 3 đường mới **"đã commit (`035daec` 18:02)"** | `git log --diff-filter=A` từng đường | 🔴 cả 3 đường được THÊM ở **`48f3fef` 18:01:00**, KHÔNG phải `035daec` 18:02:51 | ❌ **TRƯỢT-rìa (LOW)** — *kết-luận không đổi* (vẫn TRACKED + not-ignored, m#23 vẫn đúng); **sai đúng 1 SHA + 1 phút**. Nêu vì H2 dạy "vắng ở porcelain có 2 nghĩa" mà lại dán nhầm bằng-chứng SHA |
| H2-22 | iconv **628/628** UTF-8 OK, 0 BAD | **không chạy iconv lượt này** | ⬜ **KHÔNG-CHẤM** |
| H2-23 | 0-byte = **10 file** đều `.gitkeep` | **không tái-dựng** | ⬜ **KHÔNG-CHẤM** |
| H2-24 | A.2 orphan-scan **0/60** (mọi dir có synthesis >0B) | **không tái-dựng trọn 60** (đắt) | ⬜ **KHÔNG-CHẤM** |
### §3-bis 🔴 THÁCH-CLEAN #2 (BẮT BUỘC) — mở sổ H2 gọi "ĐÃ HARVEST": có **delta APPEND THẬT** không?
H2 báo Completeness **PASS** / Coverage 19/21. Rủi ro kinh-điển: **coverage đếm-TÊN mà chưa APPEND** (đúng anti-pattern #3 persona tao). Tao mở bằng **git-delta**, thứ không giả được:
```
git show --stat 11db5ef -- <4 sổ>
.claude/agent-memory/harness-eval/MEMORY.md | 6 ++++++
.claude/agent-memory/implementer-backend/MEMORY.md | 4 ++++
.claude/agent-memory/lead-stale-auditor/MEMORY.md | 4 ++++
3 files changed, 14 insertions(+) <- APPEND THUẦN, 0 xoá
.claude/agent-memory/test-specialist/MEMORY.md | 11 +++++----- (11 thêm / 10 xoá)
```
- **3 sổ = APPEND THUẦN** (+14 dòng, **0 deletion**) ⇒ không phải sửa-tại-chỗ, đúng nghĩa harvest.
- Sổ thứ 4 (`test-specialist`) +11/10 = **append + compact cùng lượt**, không phải "0 delta".
- **Truy nội-dung về đĩa** (không dừng ở byte đổi): entry `test-specialist:3` khai *"T4 fault-inject `mind-check` **18/18 PASS 0 FAIL**, run `2026-07-26-S153-ctx-adopt`"* → run-folder đó **tồn-tại**, chứa **9 file**, và chuỗi `18/18` **có thật** trong `ctx-adopt-synthesis.md` ⇒ entry **truy được về nguồn**, không phải tự-khai.
⇒ **THÁCH-CLEAN #2: CLEAN CHỊU.** "Đã harvest" của H2 = **APPEND thật + nội-dung truy được**, không phải coverage-đếm-tên. **H2-25 ✅ ĐẠT.**
---
## §4 FALSIFY-LOG (nghĩa-vụ (iii)) — 6 phép · 3 HELD / 3 BROKE (**cả 3 BROKE đều vào CHÍNH TAO**)
| # | phép thử **phủ-định** | kết |
|---|---|---|
| **F-1** | *"F-3 của H1 đúng — khối S153 thật sự thiếu con-trỏ"* → tao thử **xác-nhận** bằng cách in trọn 29 số dòng ` log` | **BROKE (claim H1 vỡ)** — `471` nằm ngay trong danh-sách 29 hit của chính H1. Truy tiếp ra cơ-chế: `:471` là 1 trong **4 dòng duy nhất** dùng khuôn markdown-link (25 backtick + 4 link = 29) ⇒ H1 **mù-KHUÔN, không mù-số** |
| **F-2** | *"H1 nói tên vai trùng 1:1 — thử PHÁ bằng `diff` 2 tập"* | **BROKE — VÀO CHÍNH TAO.** `diff` in **23/23 dòng khác nhau** ⇒ suýt tố H1 sai roster. Truy ra: `io.open(...,'w')` của **script python TAO viết** đẻ **CRLF**, `file` xác nhận *"with CRLF line terminators"*; `tr -d '\r'` xong → **diff RỖNG cả 2 phép**. ⇒ H1 **HELD**; lỗi là **artifact của thước tao** |
| **F-3** | *"H1 nói 8/10 thư inbox 0-row — thử phá, tao đoán thật ra 10/10"* | **BROKE — VÀO CHÍNH TAO (lần 2).** Vòng `for` của tao grep **kèm đuôi `.md`** → ra **10/10 = 0 row**. Nhưng `_index.md` ghi tên **KHÔNG có `.md`** (`:58`/`:59` verbatim) ⇒ grep của tao **luôn ra 0**, một số-rác. Bỏ `.md` → đúng **8/10**. ⇒ H1 **HELD** |
| **F-4** | *"skill ef-core khai Mig 67 nhưng đĩa chỉ có 66"* — tao đếm file migration được **66** ⇒ tưởng bắt được dương-thật | **BROKE — VÀO CHÍNH TAO (lần 3).** Bộ lọc của tao `! -name '*Snapshot*'` (định loại `ApplicationDbContextModelSnapshot.cs`) **ăn luôn** migration hợp-lệ `20260717032812_AddPeApprovedBudgetSnapshot.cs` — tên nó **chứa chữ "Snapshot"**. Số-học khép kín chứng minh: `ls Migrations/*.cs` = **135** = 67+67+1. ⇒ Mig 67 **CÓ THẬT**, skill **fresh THẬT**, H1 **HELD** |
| **F-5** | *"vênh 17 vs 13 hit tên-cũ ⇒ H1 under-count"* | **HELD (H1 đúng tại pin)** — decompose từng file ra **13 + 3 (self-citation của chính H1) + 1 (`sub-lead-gap` land 18:06:52 > H1 18:06:39)** = 17, **khít 0 dư**. Không phải sai, là **citation-trap + time-drift** |
| **F-6** | *"H2 báo 'đã harvest' nhưng có thể chỉ đếm tên, chưa APPEND"* | **HELD (H2 đúng)** — git-delta cho **+14 dòng APPEND THUẦN 0 xoá** trên 3 sổ, và nội-dung `18/18` truy được về `ctx-adopt-synthesis.md` |
> 🔴 **Bài lớn nhất lượt này: 3/6 phép phá lại vỡ vào THƯỚC CỦA TAO, không vào vai bị chấm.** Cả 3 đều cùng một lớp — **thước tự-đẻ số-rác rồi số-rác trông y hệt phát-hiện**: CRLF (F-2) · grep sai khuôn tên (F-3) · lọc `-name` ăn nhầm (F-4). Nếu tao dừng ở lượt đo đầu, tao đã nộp **3 cáo-buộc OAN** (roster lệch 23/23 · F-2 sai tập-đo · skill stale Mig 66). Nối thẳng bài S153 (`grep -c || echo 0` in "0" **và** exit 1). **Luật tự áp:** *số 0 và số "khác kỳ vọng" đều phải bị nghi là LỖI THƯỚC trước khi bị coi là phát-hiện.*
---
## §5 TỰ TÁI-DỰNG SỐ LOAD-BEARING (nghĩa-vụ (iv)) — 17 số, **15 khớp / 2 lệch-đã-giải-thích**
**Khớp (15):** roster `23` ×4 bề mặt (3 nguồn khác nhau: `ls`/`git ls-files`/`find`) · VALID_ROLES `23` (python≠awk) · plugin `18/15/3` (`json.load`≠`sed+grep`) · marketplace `37` · skill `6` · command `19` · standalone `23` · run-dir `60` · orphan-byte `25283+22248=47531` · corruption **vector** `[1,1,3,1,1,1,1,1,5,1]=16/10` (python≠perl) · coverage `9+6+6=21` · diary-S153 `16/23` + **7 tên vai 0-hit khớp tập** · `12482/1570/3474/2938` · `19456` · Mig `135 = 67+67+1`.
**Lệch (2) — cả 2 là TIME-DRIFT, KHÔNG phải sai:** census `646` (pin H2 `644`, +2 gồm chính file này) · `git status` `2 mục` (pin H1 `3`, pin H2 `1` — Stop-hook `wal-flush` commit 4 lần trong 8 phút: `48f3fef`→`035daec`→`256f724`→`2af3808`).
🔴 **Phát-hiện phương-pháp cho lead:** H1 và H2 khai `git status` **3 mục** vs **1 mục** — nhìn qua tưởng 1 trong 2 sai. Thật ra **cả hai đều đúng**, vì hook auto-commit chạy **giữa** 2 lượt đo. ⇒ Ở repo này, mọi số `git status`/`HEAD`/census **bắt buộc kèm giờ-đo** mới có nghĩa; so 2 vai với nhau mà không so giờ = **đẻ mâu-thuẫn giả**.
---
## §6 TỔNG — 53 claim · ĐẠT 46 / TRƯỢT 2 / KHÔNG-CHẤM 5
| khối | claim | ĐẠT | TRƯỢT | KC |
|---|---|---|---|---|
| **H1** `tooling-auditor` | 28 | **25** | **1** (H1-21 F-3) | 2 |
| **H2** `harvest-curator` | 25 | **21** | **1** (H2-21 SHA m#23) | 3 |
| **cộng** | **53** | **46** | **2** | **5** |
🔴 **KHÔNG gộp verdict-tổng che TRƯỢT lẻ** (anti-pattern #5): dưới đây từng dòng TRƯỢT đứng riêng.
### 2 TRƯỢT — cả 2 KHÔNG chạm số load-bearing
1. **H1-21 · F-3 · MED-hệ-quả · DƯƠNG-GIẢ ⇒ ĐỀ-XUẤT VÁ PHẢI HUỶ.**
Claim *"khối S153 thiếu con-trỏ ` log`"* **sai**: `STATUS:471` có (markdown-link) + `STATUS:6` có (backtick) + file đích **9.367B tồn-tại**. Live-anchor **≥3**, không phải 1; tổng hit **11**, không phải 7 (và H1 tự cộng hụt: `1+4=5≠7`).
🔴 **Hành-động cho lead: ĐỪNG vá.** Vá theo H1 = thêm con-trỏ **thứ 3 trùng lặp** vào đúng dòng đã có. **Lead bác F-3 là ĐÚNG.**
*Giá-trị còn lại của F-3:* 0. Nhưng **cơ-chế** thì đáng giữ — khuôn ` log` tồn-tại **2 dạng** (25 backtick / 4 markdown-link); mọi detector tương-lai soi khuôn này phải bắt **cả hai**, nếu không sẽ tái-sinh đúng dương-giả này.
2. **H2-21 · m#23 SHA · LOW · sai bằng-chứng, ĐÚNG kết-luận.**
H2 dán *"đã commit (`035daec` 18:02)"*; `git log --diff-filter=A` cho thấy cả 3 đường vào ở **`48f3fef` 18:01:00**. Kết-luận (TRACKED + not-ignored) **không đổi**. Đáng nêu vì H2 dựng nguyên m#23 để dạy *"vắng ở porcelain có 2 nghĩa trái ngược"* — bài đúng mà **neo SHA lệch** thì phiên sau truy `035daec` sẽ thấy diff không chứa 3 đường đó và nghi oan.
### 5 KHÔNG-CHẤM (khai thẳng, không giả-vờ đã kiểm)
`H1-27` README:225-226 · `H1-28` HANDOFF:8 · `H2-22` iconv 628/628 · `H2-23` 0-byte 10 `.gitkeep` · `H2-24` orphan-scan trọn 60 dir. Lý-do: hết ngân-sách lượt, **không phải** đã kiểm và thấy ổn.
### 2 THÁCH-CLEAN — **cả 2 CHỊU** (không ca nào thủng)
- **#1 skill `ef-core-migration` H1 gọi "fresh"** → fresh **THẬT**: row 67 là row cuối, bảng 60→67 không lỗ, **Mig 67 có mặt trên đĩa `src/`**, số-học `135 = 67+67+1` khép kín.
- **#2 sổ H2 gọi "đã harvest"** → APPEND **THẬT**: +14 dòng thuần 0 xoá trên 3 sổ, nội-dung `18/18` truy được về `ctx-adopt-synthesis.md`.
> Khác S153 (ô 🟢 thủng ở **mệnh-đề dự-báo**): lượt này mệnh-đề dự-báo baseline `16/10` của H2 **được kiểm và HELD** (§H2-18). Ô 🟢 lượt này **kín cả phần dự-báo**.
### 3 INFORM cho lead (không phải lỗi ai)
- **INFORM-R1** — `STATUS:6` khai `counter 27`, đĩa đã **28** (lead tick S154 trong phiên, `squash-benign`). Là **ảnh chụp lúc đóng S153**, đúng-tại-thời-điểm. ⇒ **nhớ bump @closeout S154**, nếu quên thì mới thành stale thật.
- **INFORM-R2** — Mọi số `git status`/HEAD/census ở repo này **vô nghĩa nếu không kèm giờ-đo** (hook `wal-flush` commit 4 lần / 8 phút). Đề: bookend sau, H1/H2 dán `HH:MM` cạnh mỗi số dạng này.
- **INFORM-R3** — F-2 (inbound `_index`) **CÒN MỞ đúng như H1 báo**, và tao đo thêm: lane inbound có **44 row**, row mới nhất **2026-07-22**; 8 thư từ 07-18→07-25 chưa có row. Xác nhận H1, không phải finding mới.
## §7 G-015 self-honesty
Vai này **propose-only**, KHÔNG khai "read-only enforced": `Bash` vẫn là kênh-ghi mở (`[carry:tools-whitelist-no-teeth]`). Lượt này tao ghi **đúng 1 file của chính tao** (`sub-ring1-open-S154.md`), 0 file khác. Backstop thật = lead single-writer + `git status` sau lượt.
**no-self-exempt:** tao KHÔNG phải H1/H2. Riêng ca `ring1-close-S153` mồ-côi (H2 FLAG-1) **sổ của chính tao** tao **chỉ xác-nhận số**, **KHÔNG tự APPEND** (đó memory-op của lead).
<!-- END sub-ring1-S154 · TOTAL=53 CLAIM · ĐẠT=46 TRƯỢT=2 KHÔNG-CHẤM=5 -->

View File

@ -0,0 +1,203 @@
# sub-ring2-open-S154 — KIỂM cặp H24 @open S154 (phiên-LOGIC L7)
> Vai: `ring2-audit` (C4 TÁCH, AUTO sau cặp H24). Trục = **KIỂM 2 con-đo**, KHÔNG soi lead trực tiếp.
> Kỷ-luật anti-#53: **ghi từng mục ra đĩa NGAY khi có kết-luận**, mỗi mục kết `---`.
---
## §0 — PIN + fail-safe (nghĩa-vụ (i))
Pin đích-danh do lead giao, KHÔNG glob-latest. Đo lại bằng lệnh của tao:
```
$ wc -c sub-lead-stale-open-S154.md sub-lead-gap-open-S154.md
14296 sub-lead-stale-open-S154.md
25809 sub-lead-gap-open-S154.md
$ grep -n 'END sub-lead-stale' … → 97:<!-- END sub-lead-stale-S154 · TOTAL=5 FLAG -->
$ grep -n 'END sub-lead-gap' … → 229:<!-- END sub-lead-gap-S154 · TOTAL=5 FLAG -->
$ grep -c '^## FLAG-' stale → 5 gap → 5
```
| Kiểm | Lead khai | Tao đo | Khớp |
|---|---|---|---|
| byte stale | 14.296B | **14296** | ✅ |
| byte gap | 25.809B | **25809** | ✅ |
| END stale | `TOTAL=5 FLAG` @:97 | `:97` đúng chữ | ✅ |
| END gap | `TOTAL=5 FLAG` @:229 | `:229` đúng chữ | ✅ |
| khuôn slot stale | — | `grep -c '^## FLAG-'` = **5** == TOTAL | ✅ |
| khuôn slot gap | — | `grep -c '^## FLAG-'` = **5** == TOTAL | ✅ |
🔴 **Bài 4-A (@S152 pin-bản-cắt) — ĐÃ CHẶN.** Lead cảnh-báo cả 2 file từng ở trạng-thái cụt (6.542B / 8.960B). Tao đo byte HIỆN-TẠI khớp pin **và** slot-count == END TOTAL cho cả 2 ⇒ tao đang chấm **bản đóng**, không phải bản cắt. Nhịp mtime cũng nhất-quán: stale 18:05 → gap 18:16 → ring2 (tao) sau 18:16 ⇒ tuần-tự C4b thoả (`mtime(tao) > mtime(H24 return)`).
**H24 CÓ chạy phiên này** (2 sub-file tươi, cùng run-folder `2026-07-26-S154-bookend-open`) ⇒ **KHÔNG NO-OP**, chấm tiếp.
---
## §1 — Enum ĐÓNG (nghĩa-vụ (ii), phần class)
Đọc `.claude/agent-memory/memory-budget.json``lead_self_audit.flag_classes` = **list 11 phần-tử**:
`view-stale-count · view-stale-status · view-stale-header · view-stale-role-desc · view-residual-asym · gap-carry-dropped · gap-carry-aged · gap-owner-specifics · gap-decision-sunk · gap-underfill · gap-incident-unrecorded`
| class dùng | ∈ enum? | dùng ở |
|---|---|---|
| `view-stale-count` ×2 | ✅ | S-1, S-4 |
| `view-residual-asym` ×2 | ✅ | S-2, S-5 |
| `view-stale-status` ×1 | ✅ | S-3 |
| `gap-carry-dropped` ×2 | ✅ | G-1, G-4 |
| `gap-carry-aged` ×1 | ✅ | G-2 |
| `gap-decision-sunk` ×1 | ✅ | G-3 |
| `gap-underfill` ×1 | ✅ | G-5 |
**10/10 class ∈ enum ĐÓNG · 0 class tự chế.** Hai class `view-stale-header` / `view-stale-role-desc` khai 0 và `gap-owner-specifics` / `gap-incident-unrecorded` khai honest-zero — cả 4 vẫn nằm trong enum, không có class nào bị bỏ quên khỏi bảng phân-rã (5/5 view + 6/6 gap đều có mặt trong 2 bảng TỔNG-KẾT).
---
## §2 — VERDICT per-FLAG (10 FLAG)
> Luật tao tự áp: ĐẠT = (a) sự-kiện nền có thật trên đĩa **do tao đo lại**, (b) class đúng enum + đúng trục, (c) mệnh-đề "hại" không phóng-đại. Số của con-đo mà tao không dựng lại được ⇒ ghi khuyết-tật RIÊNG, KHÔNG gộp vào verdict FLAG.
### vai-STALE (5)
| # | class | verdict | tao đo lại bằng gì |
|---|---|---|---|
| **S-1** | `view-stale-count` HIGH | **ĐẠT** | `ls .claude/auto-memory/*.md \| grep -v MEMORY.md \| wc -l` = **53** (47 `feedback_*` + 6 `project_*`, và `grep -v` phần còn lại = **rỗng** ⇒ 47+6 phủ kín, không có file lạ). `grep -c '^- \[' MEMORY.md` = **53**. `STATUS:454` = **31**. Lệch **22** xác nhận. |
| **S-2** | `view-residual-asym` HIGH | **ĐẠT** | `grep -c 'KHÔNG mỗi phiên'` = **1** ở CẢ HAI `lead-stale-auditor.md` / `lead-gap-auditor.md`, đều tại **:4** (frontmatter `description`). Thân `:51-52` nói "MỖI phiên, CẢ HAI bookend, VÔ-ĐIỀU-KIỆN". Mâu-thuẫn nội-file xác nhận. |
| **S-3** | `view-stale-status` HIGH | **ĐẠT** | **Tao tự gọi `list_projects`, không nhận qua relay:** `proj_solution_erp` trả `chunk_count 2449`, server trả 7 collection, 5 sister `last_indexed_at` = **2026-07-26T17:0217:05** ⇒ Qdrant **UP**. `HANDOFF:7 (39)` = "RAG Qdrant DOWN (6333 refused…)" · `STATUS:471` = "RAG DOWN (defer AI_INFRA)". View DOWN vs source UP ⇒ lệch thật. |
| **S-4** | `view-stale-count` LOW | **ĐẠT** | `STATUS:457` = **2447**; đo sống của tao = **2449**. Δ=+2 xác nhận. Class đúng (số, không phải trạng-thái) và SEV LOW đúng tỉ-lệ. |
| **S-5** | `view-residual-asym` MED | **ĐẠT** | 3 run-folder trio (`S144-trio-first-run` · `S151-trio-memory` · `S152-trio-memory`) mỗi folder đủ `harness-eval/refine/audit-return.md`**đã chạy ≥3 vòng**. Diary `wc -c` = **4492 / 4813 / 4530** ⇒ KHÔNG phải ~1.5KB. `grep -c 'pending restart+consent' memory-budget.json` = **3** (:200/:201/:202) và `session-start.md:65` vẫn giữ "CHƯA probe lần nào" (verify ở §3-F1). Residual 2 lá xác nhận. |
### vai-GAP (5)
| # | class | verdict | tao đo lại bằng gì |
|---|---|---|---|
| **G-1** | `gap-carry-dropped` HIGH | **ĐẠT** | Tao tự viết Python đếm regex `\[carry:([a-z0-9][a-z0-9._-]*)\]`: toàn file **219 occurrence / 43 distinct**; `:6`=**5** · `:13`=**6** · `:18`=**26** · `:23`=**24**; `set(:18) set(:6)` = **26** và danh-sách 26 slug **trùng khít từng chữ** với bảng con-đo in ra. `set(:18) set(:13)` = **23** = đúng con số HANDOFF:13 tự hứa "GIỮ re-stamp 23 slug". |
| **G-2** | `gap-carry-aged` MED-HIGH | **ĐẠT** | 🔴 **Tao RE-IMPLEMENT detector, không đọc lời khai:** port regex `^\*\*(?:[^\x00-\x7F]+\s*)?NEXT\s+(?:anh\|em)\b` (`governance-detectors.ps1:892`) sang Python ⇒ **22 mark**, mark đầu = **dòng 7**; khối `Carry @S153`**dòng 6****NGOÀI mọi segment**. Dựng segment ⇒ **22 logic-segment · 17 có carry** (khớp máy "22 ; 17"); `carryLines[0]` = span :10-:13 = **đúng 7 khoá, đúng thứ-tự** con-đo liệt. "4/7 đã ĐÓNG" verify verbatim: `:13` "✅ ĐÓNG @S152: `[carry:memory-ops-refine-queue]` · `[carry:hub-republish-59ce1d0f]` · `[carry:jump-dai-frozen]`" + `:6` "✅ ĐÓNG @S153: `[carry:adap-apply-2-thu]`". |
| **G-3** | `gap-decision-sunk` MED-HIGH | **ĐẠT** | `spec-ctx-adopt-26-07-2026.md:32` có đúng chữ "#24 = 3 khoản KHÔNG-KIỂM-ĐƯỢC chép vào adap-report + thư hub". `grep -rl 'KHÔNG-KIỂM-ĐƯỢC' --include=*.md .` toàn repo = **đúng 3 file**: spec · `sub-fable-invest-S153.md` (nguồn) · chính file con-đo. ⇒ **0 hit** ở adap-report VÀ ở thư hub `2026-07-26-se-to-ai_infra-ctx-adopt-executed-va-datum.md`. Cả 2 đích được chỉ định đều trống. |
| **G-4** | `gap-carry-dropped` MED | **ĐẠT** | `awk 'NR>=636&&NR<=756 && /⚪/'` = **14 dòng**, và `grep -c '⚪'` toàn file cũng = **14** ⇒ vùng con-đo khai phủ trọn tập. Phân loại: PE "lỗi phân quyền" ×**9** + 5 việc khác = **6 distinct** ✅ khớp. H22-pilot: `sed -n '19p' HANDOFF \| grep -ci 'pilot\|hook-fire\|H22'` = **0**; `grep -c 'đổi-acc'` = **0**`grep -c 'kill-giữa-chain'` = **0** toàn HANDOFF ⇒ nhãn "(NEXT-anh)" của row STATUS là **tự-khai không có chỗ dựa**. |
| **G-5** | `gap-underfill` MED | **ĐẠT** | `crystallized_backfill.target` = **210000**, `source_order` = `['gist','value-marked-archive','curated-RAG']`. Tao grep RỘNG HƠN con-đo: `grep -rn 'source_order' .claude/commands/` = **0** (không chỉ 4 lệnh nó thử — **toàn thư-mục lệnh**). `tiep_reload.mode='rut-gon'`, `sources` = **5 mục** (STATUS·HANDOFF·WAL·`_mind-s-<N>`·`_context-s-<N>`) ⇒ **0/5 thuộc `source_order`** xác nhận. |
**⇒ 10 ĐẠT · 0 TRƯỢT · 0 KHÔNG-CHẤM-ĐƯỢC trên 10 FLAG.**
---
## §3 — FALSIFY-LOG (nghĩa-vụ (iii)) — 6 phép, 4 HELD / 2 BROKE
> BROKE ở đây = **giả-thuyết CỦA TAO bị bác**, không phải con-đo sai. Hai phép BROKE dưới đều là tao tự nghi rồi tự bị đĩa bác — đó là falsify thật, giá-trị hơn HELD.
**F-1 — BROKE (nghi-vấn của tao bị bác; bài học đắt nhất lượt này).**
Tao nghi vai-STALE **trích sai dòng** ở FLAG-5 lá-1: đọc `session-start.md:65` bằng `sed -n '65p' | cut -c1-300` thì thấy *"**Cách:** spawn từng vai với task PROBE tối-thiểu… Đo S122: toàn-roster-khi-đó ALIVE (N=14…"* — **không thấy** câu *"CHƯA probe lần nào"*. Suýt phán `offset-gõ-tay` (đúng class tao từng BROKE @S152 F-3).
**Bác bằng lệnh KHÁC (theo NỘI DUNG, không theo lát cắt):** `grep -o 'trio-memory (S141) \*\*CHƯA probe lần nào\*\* — lần probe đầu = phiên SAU restart'`**hit**; `grep -o 'CHƯA probe lần nào' | wc -l` = **1**; Python `line65.find(...)`**offset 318**, `len(line65)` = **422 ký-tự** (nhưng `awk length` = **507** vì đếm BYTE — tiếng Việt đa-byte).
⇒ Trích dẫn `:65` của con-đo **ĐÚNG**. Cái sai là **phép đo của tao**: `cut -c1-300` cắt mất đoạn chứa câu. 🔴 **Bài (cùng họ `grep -c` dòng vs `-o|wc -l` occurrence @S149, và bẫy byte-vs-char @S130):** trên **mega-line** (repo này có `STATUS.md:6` = 63.104 ký-tự/1 dòng) **CẤM phán vắng-mặt từ một lát cắt** — phải hỏi theo NỘI DUNG. Vắng-mặt-do-cắt trông y hệt vắng-mặt-thật.
**F-2 — BROKE (nghi-vấn của tao bị bác lần 2).**
Tao nghi drift roster ở bề-mặt thứ hai mà chính `agents/README.md:244` cảnh-báo (*"Đổi roster = đổi 2 BỀ-MẶT ĐỘC-LẬP: file `.md` … và hằng `VALID_ROLES` trong `hmw.js`"*). Parse tự-động cho **`VALID_ROLES` = 21** + 3 tên lạ `derived-staleness` / `view` / `omission` — đúng chân-dung **tên-CHẾT sống sót** mà tao từng bắt @S150 (`lead-view`/`omission`).
**Bác bằng đọc RUỘT thay vì regex:** 3 tên đó nằm trong **COMMENT bên trong mảng** (`hmw.js:34-35` giải-thích vì sao chọn từ-vựng "view"/"omission" để né va-chạm `derived-staleness`) — **không phải phần-tử**. Đo lại sau khi strip `//`: `VALID_ROLES` = **23**, disk roster = **23**, `set(VALID_ROLES) == set(disk)`**True**, sai-biệt hai chiều = **∅**.
**VERIFIED-CLEAN**, tao KHÔNG tạo báo-động-giả. 🔴 Bài: **use ⟂ mention** — regex quét mảng nuốt luôn chữ trong comment; cùng lớp `citation-trap` (auto-memory). Vá phép đo rồi phải chạy lại bằng đường khác.
**F-3 — HELD.** Thử bác datum (a) bằng **lệch-định-nghĩa**: biết đâu "31" đúng theo một phép đếm khác? Note của chính row `:454` khai phép đếm lần re-ground gần nhất: *"re-ground S71 (H1 disk-count: **25 `feedback_*` + 3 `project_*`**)"* = 28, rồi cộng đúng 3 lần bồi (+S75, +S79, +S84) = **31**. ⇒ đơn-vị = **file `feedback_*` + `project_*` trên đĩa**, y hệt phép tao chạy (47+6=53, phần dư = **rỗng** ⇒ 2 tiền-tố phủ kín). Không có định-nghĩa nào cứu được 31. **FLAG-1 đứng vững.**
**F-4 — HELD.** Thử bác G-2 bằng cách nghi con-đo **chép lời máy** thay vì có thật. Tao **re-implement** detector bằng Python: port nguyên regex `:892`, tự dựng segment ⇒ **22 mark / mark đầu = dòng 7 / 22 logic-segment / 17 segment có carry / `carryLines[0]` = span :10-:13 = đúng 7 khoá đúng thứ-tự**. Khối `Carry @S153` (dòng 6) nằm **trước** mark đầu ⇒ ngoài mọi segment. Số của máy tái-dựng **100%** bằng công-cụ khác ngôn-ngữ. **FLAG-2 gap = CONFIRMED-máy.**
**F-5 — HELD.** Thử bác G-3 bằng nghi "diễn đạt khác nên grep trượt": tao grep **toàn repo** `grep -rl 'KHÔNG-KIỂM-ĐƯỢC' --include=*.md .` ⇒ đúng **3 file** (spec · `sub-fable-invest-S153.md` nguồn · chính file con-đo). Không có bản diễn-đạt-khác nào ở adap-report/thư-hub vì token **không tồn tại ngoài 3 file đó**. **FLAG-3 đứng.**
**F-6 — HELD (mở rộng, không chỉ xác-nhận).** Thử bác G-5 bằng nghi con-đo chỉ grep 4 lệnh nên bỏ sót chỗ khác có `source_order`: tao grep **cả thư-mục** `grep -rn 'source_order' .claude/commands/` = **0**. ⇒ kết-luận con-đo **yếu hơn** sự thật; FLAG-5 đứng và mạnh hơn mức nó tự khai.
---
## §4 — TỰ TÁI-DỰNG SỐ CÓ-THỂ-SAI (nghĩa-vụ (iv))
| # | số con-đo | tao dựng lại bằng | kết |
|---|---|---|---|
| 1 | 53 file auto-memory / 53 dòng index / view 31 | `ls\|grep -v\|wc -l` + `grep -c '^- \['` + phần-dư-rỗng | **53 / 53 / 31** ✅ khớp |
| 2 | RAG 2449 + UP | **tao tự gọi `list_projects`** (không qua relay) | **2449**, 7 collection, 5 sister index hôm nay ✅ |
| 3 | carry 219 occ / 43 distinct / 5·6·26·24 / comm=26 | Python regex độc-lập trên `HANDOFF.md` | **219 / 43 / 5·6·26·24 / 26** ✅ khớp tuyệt-đối, 26 slug trùng từng chữ |
| 4 | máy "22 segment ; 17 carry-lines" + `carryLines[0]`=7 khoá | **re-implement detector** bằng Python | **22 / 17 / 7 khoá đúng thứ-tự** ✅ |
| 5 | 14 dòng `⚪` = 6 việc distinct, PE ×9 | `awk` vùng 636-756 + `grep -c` toàn file | **14 = 14** (vùng phủ trọn tập) · **6 distinct · PE ×9** ✅ |
| 6 | "22 hit"→ **2 hunk**`11db5ef` | `git show … \| grep -c '^@@'` | 🔴 **1 hunk**, KHÔNG phải 2 → xem D-1 |
| 7 | roster 23 ⟂ `VALID_ROLES` | strip-comment + so tập 2 chiều | **23 == 23, ∅ hai chiều** ✅ |
| 8 | `FE pages` **68** (con-đo KHÔNG đo) | 3 glob của tao + `find` của lead | 🔴 **66** — xem M-1 |
**⇒ 8 phép tái-dựng, 6 khớp, 1 lệch-số của con-đo (D-1), 1 lệch-của-LEAD mà con-đo bỏ sót (M-1).**
---
## §5 — THÁCH-CLEAN (BẮT BUỘC) — mở 5/5 mục VERIFIED-CLEAN của vai-STALE
> Bài @S150/@S153: **CLEAN nguy nhất nằm ở class con-đo tự tuyên 0**, không ở class nó fire nhiều. vai-STALE tuyên `view-stale-header` = 0 và `view-stale-role-desc` = 0 ⇒ tao soi thẳng 2 trục đó.
| VC# | mục | tao kiểm bằng | verdict |
|---|---|---|---|
| VC-1 | README roster-count | `ls .claude/agents/*.md \| wc -l` = **24**, trừ README = **23** = `STATUS:456` **23**. 🔴 **Tao đi XA hơn con-đo:** nó chỉ đếm SỐ; tao kiểm **từng TÊN** — 23/23 vai trên đĩa đều được nêu đích danh trong README (min 3 hit/vai). Và bề-mặt thứ 2 (`hmw.js VALID_ROLES`) = **23, set-equal, sai-biệt hai chiều = rỗng** (F-2). | **ĐẠT** (clean thật, còn chắc hơn mức khai) |
| VC-2 | `STATUS:441` header | Đọc `:441` = *"mốc gần nhất **S153**"*. @open S154 **chưa có** lần re-ground S154 nào ⇒ S153 vẫn đúng luật *"bump theo lần re-ground giá-trị mới nhất"*. Tao **không** bắt lỗi ở đây dù đang là phiên S154 — bump sớm mới là sai. | **ĐẠT** |
| VC-3 | `HANDOFF` (38) | `sed -n '7p' \| grep -o``~~(38)~~ ✅ **anh đã VETO + chốt hub-form 3-vai @S153**` — nhãn đã lật, có gạch ngang. | **ĐẠT** |
| VC-4 | cổng consent 5 vòng | Đọc `session-start.md` `:271` *"🧊 Vế … **hết hiệu lực @S152** — owner chốt 'các vòng 1→5 auto luôn'"* · `:276` *"AUTO 1 vòng/phiên … 🧊 adopt S141 dạng consent-gated → **superseded**"* · `:280` *"AUTO … supersede consent-gate S141; ĐÂY = nhà canonical"*. Mọi vế retired đều có 🧊 + lineage + mốc. | **ĐẠT** |
| VC-5 | `STATUS:452` Tests | `532`**45 + 487 = 532** (tự cộng), khớp `:471` *"532 test"*. Ca canonical-poison S148 (528) không tái-phát. | **ĐẠT** |
### 🔴 M-1 — FLAG BỊ SÓT (cả HAI con-đo cùng im) — `view-stale-count` MED
**`docs/STATUS.md:449``| FE pages | **68** | unchanged S54 (ItTicketsPage reassign = in-place 2 app); +1 S52 AttendanceReportPage |`**
- **source (đĩa hôm nay):** `find fe-admin/src/pages -name '*.tsx'` = **37** · `find fe-user/src/pages -name '*.tsx'` = **29****66**. Ba biến-thể glob của tao (kể-cả `.jsx`, kể-cả bỏ `index.tsx`, mọi độ sâu) đều ra **66**; lead chạy `find` độc-lập cũng **66****2 người · 2 lệnh · cùng số**.
- **NEO ĐỊNH-NGHĨA (bắt buộc chỉ ra, không kết ẩu):** đơn-vị lấy từ **chính note của row**: *"**+1** S52 AttendanceReportPage"* (thêm ĐÚNG MỘT file page ⇒ +1) và *"ItTicketsPage reassign = **in-place 2 app**"* (dời page giữa 2 app ⇒ **không đổi** tổng). ⇒ đơn-vị = **số file page, gộp cả 2 app** — đúng phép tao chạy.
- **Số học đóng kín (chứng-cứ mạnh nhất, không phải phép đếm trần):**
`68` (mốc S54) ` 4` (S61 commit **`79ef8da`** *"xoa module Budget cu (Mig 50)"* xoá `fe-admin/src/pages/budgets/{BudgetCreatePage,BudgetsListPage}.tsx` + 2 file `fe-user/…` tương ứng — đo bằng `git log --diff-filter=D`) `+ 2` (sau `79ef8da` chỉ thêm đúng `fe-admin|fe-user/src/pages/office/OfficeDashboardPage.tsx` — đo bằng `--diff-filter=A`) **= 66** = đúng con số trên đĩa.
⇒ Không còn cửa "lệch-định-nghĩa": lịch-sử git giải-thích **trọn vẹn** 68→66.
- **Giả-thuyết bị bỏ (khai để không giấu):** tao từng đoán tên file là `Bg_*`; `git log -S 'Bg_' -- fe-admin/src/pages` = **0 hit****bỏ**. Tên thật là `BudgetCreatePage`/`BudgetsListPage`.
- **Vì sao cả 2 con-đo sót — bất-đối-xứng CHỐNG-BÀO-CHỮA:** vai-STALE **tự tay liệt**`:5` các row NGOÀI tập cross-check của máy — *"`User memory` · `Sub-agents` · `RAG chunks` · **`API endpoints`** · **`FE pages`** · **`Master data`**"* — rồi **chỉ đo 2 trong số đó** (`User memory`→FLAG-1, `RAG chunks`→FLAG-4), đo thêm `Sub-agents` (clean), và **bỏ trắng 3 row còn lại**. Nó **biết chỗ máy mù, tự chỉ ra chỗ mù, rồi không soi hết chỗ mù**. Đây không phải "ngoài tầm" — đây là **dừng giữa chừng trong đúng tập-đo mình vừa tuyên**.
- **Hại cụ-thể:** row nằm trong bảng CURRENT STATE = nguồn `/tiep` nạp lại (`tiep_reload.sources[0]` = *"STATUS dòng CURRENT + bảng CURRENT STATE"*) ⇒ mỗi lần nối mạch, số sai được nạp lại như sự-thật. Và nó **im lặng nuốt một sự-kiện kiến-trúc** (module Budget bị xoá S61) — cùng lớp `canonical-poison` S148, khác ở chỗ không ai bắt hộ vì máy không resolve row này.
- **resolve:** `:449` = **66** + đổi neo *"re-ground S154 (`find fe-*/src/pages -name '*.tsx'`)"*; ghi kèm 68→66 = 4 Budget(S61) +2 Office. Hết flag khi số ô khớp lệnh đếm ghi ngay trong note.
### 🔴 FINDING CẤP HỆ-THỐNG (mạnh hơn 3 finding lẻ)
Ba row lệch trong **cùng một phiên**`User memory` 31↔53 · `RAG chunks` 2447↔2449 · `FE pages` 68↔66 — **đều nằm ngoài** `$canonRows` của máy (`governance-detectors.ps1:204-208` chỉ resolve `{mig, table, gotcha, test, menu, policy}`). Tao đo **trọn 7 row ngoài tập** để không kể chuyện bằng số tổng:
| row ngoài tập-đo máy | view | tao đo | |
|---|---|---|---|
| User memory | 31 | **53** | ✗ lệch (FLAG-1) |
| RAG chunks | 2447 | **2449** | ✗ lệch (FLAG-4) |
| FE pages | 68 | **66** | ✗ lệch (**M-1, không ai bắt**) |
| Sub-agents | 23 | **23** | ✓ |
| Skills | 6 | **6** | ✓ |
| API endpoints | ~253 | 256 (`[Http*]` controller) | ~ có dấu `~` nên **không kết**; đơn-vị chưa neo |
| Master data (prod) | 71 WorkItems | — | **chưa đo** (cần DB prod, ngoài tầm @open) |
**4 row đo được ⇒ 3 lệch**; 2 row clean đúng là 2 row có neo-đếm rẻ (`ls`). **Luận-điểm:** thứ máy không soi thì trôi — mặc định là DRIFT, không phải mặc định sạch. Kiến-nghị **cơ-chế** (mở rộng `$canonRows`, hoặc gắn lệnh-đếm vào từng row) chứ không phải 3 lần vá số lẻ. 🔴 Quyền đổi số/policy = **của anh**; tao chỉ nêu.
---
## §6 — KIỂM KHAI-PHẠM-VI của vai-GAP (`:199-207`)
**Bốn mục nó khai chưa phủ đều THẬT và đáng ghi công** (carry-aged bị FLAG-1 che · 9 khối `<details>` gập · `migration-todos.md` · bookend @close chưa tồn-tại). Khai thiếu-sót chủ-động = đúng kỷ-luật; mục 4 còn chặn đúng bẫy "sổ bền TRẮNG lúc @open ⇒ đừng FLAG".
**Nhưng câu mở đầu là một khẳng-định VÉT CẠN***"Cái **THỰC SỰ** chưa phủ lượt này, khai thẳng"* — nên tao chấm nó như claim vét-cạn, và nó **thủng 2 chỗ**:
1. 🔴 **Bề mặt `broadcasts/inbox/` — không phủ, không khai, không nhắc một chữ.** Đây **trong turf** của nó: `gap-decision-sunk` = *"quyết-định-đang-treo không được surface lại nên chìm luôn"*, mà thư governance đến chờ adopt chính là quyết-định treo. Nó **có** mở `broadcasts/outbox` (để chứng minh FLAG-3) ⇒ **biết kênh này tồn tại**, chỉ soi chiều ĐI mà bỏ chiều ĐẾN. Tao soi hộ: 65 thư inbox đối-chiếu `docs/governance/adap-reports/`**mọi thư `Governance-*`/`Agent-*` đều có adap-report, trừ đúng 1** (`2026-07-04-Governance-notify-harness-full-recheck.md`) — tên mang chữ *notify* nên quy-ước có thể miễn. ⇒ Tao **KHÔNG nâng thành FLAG** (thiếu neo quy-ước; đúng bài "không vu-oan"), nhưng **bề mặt bị bỏ im lặng là có thật**.
2. **`gap-owner-specifics` chỉ soi 1 phiên.** Nó khai *"Đã soi **4 chỉ-thị owner của S153**"* rồi kết 0. Nhưng `HANDOFF:19` đang giữ ~19 slot có số **(4)…(32)** từ các phiên TRƯỚC — chỉ-thị cũ mất specifics thì honest-zero này không chạm tới. Giới-hạn-1-phiên đó **không nằm trong 4 mục khai**.
**Verdict khai-phạm-vi: TRƯỢT** — trượt ở **tính vét-cạn của lời khai**, KHÔNG trượt ở 4 mục đã khai (cả 4 đúng). Đây đúng thứ tao được lập ra để bắt: *lời khai thiếu-sót cũng phải bị soi tính đầy-đủ*, nếu không nó thành lá chắn.
---
## §7 — KHUYẾT-TẬT NGOÀI FLAG (không hạ verdict FLAG, nhưng phải ghi)
- 🔴 **D-1 — số không tái-dựng được (vai-STALE, FLAG-2).** Nó viết *"`git show 11db5ef -- …lead-stale-auditor.md` = **2 hunk**, cả 2 nằm trong khối `## Nhịp`"*. Tao đo: `git show … | grep -c '^@@'` = **1** (đúng một hunk `@@ -48,10 +48,13 @@`). Bên trong hunk có **2 thay-đổi** (đổi tiêu-đề + thay dòng `light_every`/`deep_every`) — nhiều khả năng nó đếm *thay-đổi* rồi gọi là *hunk*. **Kết-luận load-bearing vẫn ĐÚNG** (mọi thay-đổi nằm trong khối `## Nhịp`; hunk phủ dòng cũ 48-57 ⇒ **0 chạm dòng 4**) nên FLAG-2 giữ **ĐẠT**. Nhưng đây đúng lớp *"số không dựng lại được"* tao nêu @S150 — lần này nó **có** kèm lệnh nên tao bắt được ngay: đó chính là giá-trị của việc dán lệnh.
- **D-2 — một trong ba địa-chỉ của honest-zero không có token.** vai-GAP dẫn *"sự-cố #53 CÓ vết bền: session-log §5:59 + **adap-report datum-5** + thư hub datum-5"*. Đo: session-log `grep -c '#53'` = **2** ✓ (kèm *"#53 = 14-cận-dưới phiên này"*), thư hub = **1** ✓, nhưng `adap-reports/2026-07-25-…-vong-ctx.md` = **0**. Bản-chất honest-zero (**sự-cố CÓ vết bền**) vẫn đứng nhờ 2 nguồn còn lại ⇒ chấm **ĐẠT**; riêng địa-chỉ thứ 3 nên sửa hoặc bỏ.
---
## §8 — TỔNG (khép sổ)
| nhóm claim | n | ĐẠT | TRƯỢT | KC |
|---|---|---|---|---|
| FLAG vai-STALE | 5 | 5 | 0 | 0 |
| FLAG vai-GAP | 5 | 5 | 0 | 0 |
| VERIFIED-CLEAN vai-STALE | 5 | 5 | 0 | 0 |
| BÁC `gap-incident-unrecorded`=0 | 1 | 1 | 0 | 0 |
| BÁC slot (39) + turf-handoff → vai-STALE | 1 | 1 | 0 | 0 |
| honest-zero `gap-owner-specifics`=0 | 1 | 0 | 0 | **1** |
| khai-phạm-vi "THỰC SỰ chưa phủ" | 1 | 0 | **1** | 0 |
| **TỔNG** | **19** | **17** | **1** | **1** |
- **KC (1):** `gap-owner-specifics`=0 — tao **không** tự đối-chứng 4 chỉ-thị S153 verbatim trong lượt này ⇒ **từ chối ĐẠT-ảo**, ghi KHÔNG-CHẤM-ĐƯỢC thay vì gật.
- **Điểm SÁNG đáng ghi (khe S149 đã đóng):** @S149 tao bắt *seam giữa 2 vai*`lead-omission` đẩy-turf sang `view-*` rồi thả, **không vai nào ôm**. Lượt này vai-GAP đẩy slot (39) RAG sang turf `lead-stale-auditor`, và vai-STALE **THỰC SỰ ôm** (FLAG-3). Cùng một vật, 2 vai giao đúng tay ⇒ **khe đã bịt**, có bằng-chứng.
- **Bổ-sung của tao (không thuộc 19 claim):** **1 M-1** (`STATUS:449 FE pages` 68↔66, `view-stale-count` MED) + **1 finding cấp hệ-thống** (mọi row ngoài `$canonRows` mặc định drift: 4 row đo được ⇒ 3 lệch) + **2 khuyết-tật** D-1/D-2.
- **Kỷ-luật vai:** propose-only, chỉ ghi file này; KHÔNG soi lead trực-tiếp (dùng bề-mặt lead chỉ để **kiểm việc 2 con-đo**); no-self-exempt — tao KHÔNG phải `lead-stale-auditor`/`lead-gap-auditor`; tuần-tự C4b thoả (mtime tao > 18:16).
<!-- END sub-ring2-S154 · TOTAL=19 CLAIM · ĐẠT=17 TRƯỢT=1 KHÔNG-CHẤM=1 -->

View File

@ -0,0 +1,143 @@
# sub-tooling-auditor — BOOKEND @OPEN S154 (phiên-logic L7)
> H1 tooling-freshness 4-mặt + DIFF vs S153-close. Read-only + propose-only. Ghi-trong-lúc-làm (chống #53).
> Bắt đầu 2026-07-26. Repo `D:\Dropbox\CONG_VIEC\SOLUTION\SOLUTION_ERP`. PIN đối-chiếu: `runs/2026-07-26-S153-bookend-close/sub-tooling-auditor-close-S153.md` (6 finding).
## Ground state (đo trước tiên — mọi số có lệnh kèm)
| mục | giá-trị | lệnh |
|---|---|---|
| HEAD | `871ac0a` 2026-07-26 16:16:45 +0700 "S153 closeout-bis — _end session-6 + _mind block-0 … + reset WAL" | `git log -1 --format='%h %ad %s' --date=iso` |
| working tree | **3 mục, SẠCH-về-governance**: 1 M `.claude/governance/.session-counter.json` (tick S154) + 2 untracked `.claude/sessions/session-7/` · `runs/2026-07-26-S154-bookend-open/` — cả 3 = artifact của chính bookend này | `git status --porcelain \| wc -l` = 3 |
| Δ vs S153-close | S153 lúc đó DIRTY 13 M + 3 untracked; nay **đã commit trọn** qua `11db5ef` + `871ac0a` | so PIN §Ground |
(findings ghi dần bên dưới — file LUÔN hợp-lệ giữa chừng)
---
## ② VAI-TRÒ SUB-AGENT — **PASS** (4 bề mặt khớp **23**, tên trùng 1:1)
| bề mặt | số | bằng-chứng (lệnh) |
|---|---|---|
| đĩa `.claude/agents/*.md` trừ README | **23** | `ls .claude/agents/*.md \| grep -v README \| wc -l` = 23 |
| `hmw.js` VALID_ROLES (đếm PHẦN-TỬ) | **23** | `awk '/^const VALID_ROLES = \[/{f=1} f{print} /^\]/{if(f)exit}' .claude/workflows/hmw.js \| sed 's#//.*##' \| grep -o "'[a-z0-9-]*'" \| wc -l` = 23 |
| `docs/STATUS.md:456` §Sub-agents (canonical) | **23** | `grep -n "Sub-agents" docs/STATUS.md``:456 \| Sub-agents \| **23** \| +S153 bộ-ba vòng Ctx …` |
| `.claude/agent-memory/*/` (nhà diary) | **23** | `ls -d .claude/agent-memory/*/ \| wc -l` = 23 |
| `.claude/agents/README.md` | **B1-pointer, 0 số** (ĐÚNG luật) | `:3` "Số vai canonical → docs/STATUS.md §Sub-agents. KHÔNG chép số vào file này" |
- **Tên trùng khít 1:1 (không chỉ trùng SỐ):** `diff <(VALID_ROLES sorted) <(disk sorted)` = **rỗng**; `diff <(agent-memory dirs) <(disk)` = **rỗng**. ⇒ 0 vai mồ-côi nhà, 0 nhà mồ-côi vai.
- **Header `STATUS:6` cũng khai `Sub-agents 23`** (`grep -o "Sub-agents [^|]\{0,20\}"` → hit-1 = `Sub-agents 23** · counter **27`) ⇒ 2 tầng STATUS nhất-quán. 15 hit `Sub-agents 12` còn lại đều nằm khối **Prev/frozen** (`:198 :222 :249 :282 :315 …`) = record lịch-sử, **KHÔNG sửa** (luật S143 prescriptive⟂frozen).
- 🔴 **Bẫy `]` (bài S153) TÁI-KIỂM và né được:** range naive `awk '/VALID_ROLES/,/^\]/'` cho **33** — sai vì range **MỞ LẠI**`!VALID_ROLES.includes(...)` phía validator rồi nuốt thêm chuỗi nháy-đơn trong header-comment. Neo đúng = `^const VALID_ROLES = \[` + `exit``^]` đầu tiên ⇒ 23. *(Khác biến-thể S153: lần đó lỗi do `]` trong comment; lần này do range tái-mở. Cùng một class "range không neo".)*
- **`hmw_width.cap` = 20 < roster 23** full-sweep LOUD-skip 3 lane cuối. **KHÔNG tính là finding mới** đây carry `[carry:hmw-width-vs-roster]` slot (40) đã mở @S153; mirror `hmw.js:153 const WIDTH_CAP = 20` **KHỚP** `memory-budget.json:24 "cap": 20` 0 drift mirror, chỉ ngưỡng owner chưa nâng.
## ②-bis DƯ-LƯỢNG TÊN CŨ (rename T11 hub-form) — **PASS, 0 prescriptive-drift**
Lệnh: Grep tool (ripgrep) `ctx-pause-check|ctx-reload|ctx-chain` repo-wide **13 hit**, phân loại 2 lớp theo luật S143:
| lớp | site | phán |
|---|---|---|
| **lineage-note CỐ Ý** (2) | `agents/ctx-verifier.md:14` · `agents/ctx-curator.md:14` | GIỮ khai cả nghịch-lý VIPIX gọi vai-2 đúng `ctx-reload` |
| **diary seed** (3) | `agent-memory/ctx-{audit,curator,verifier}/MEMORY.md:3` | GIỮ lineage |
| **frozen record** (8) | `runs/S153-close/` ×4 (`run.md:6` + 3 sub-file) · `sessions/2026-07-26-1600-S153-…md:14,:52,:54` · `STATUS.md:456` | GIỮ sửa = giả-mạo |
- **0 site MÔ-TẢ-HÀNH-VI-SỐNG bằng tên ** 0 . Kiểm phản-chứng: `sessions/…:52` cột VIPIX ghi `ctx-check→ctx-reload→ctx-chain` **ĐÚNG tên nhà VIPIX**, không phải drift SE.
- **Δ vs S153:** S153 đếm 7 hit; nay 13. Chênh = **+7 sinh mới toàn frozen** (4 sub-file/run.md của chính S153-close + 3 dòng session-log) **1 XÓA THẬT**: hit `WAL.md:11` @S153 đã biến mất do `871ac0a` reset WAL. tăng thuần artifact lịch-sử, không phải drift lan.
---
## ① SKILL — **PASS** (0 đổi, 0 stale, 0 thiếu map)
| mục | số | lệnh |
|---|---|---|
| skill project `.claude/skills/*/` | **6** | `ls .claude/skills/` contract-workflow · dependency-audit-erp · ef-core-migration · form-engine · iis-deploy-runbook · permission-matrix |
| thay-đổi working-tree | **0** | `git status --porcelain .claude/skills/` = **rỗng** |
| commit chạm skill gần nhất | `c2d01f0` 2026-07-26 (S152 closeout) | `git log -1 --date=short -- .claude/skills/` **S153 không đụng skill nào** |
| standalone `~/.claude/skills/` | **23** | `ls -1 ~/.claude/skills/ \| wc -l` = 23 IDENTICAL S153 |
| command-file `.claude/commands/*.md` | **19** | `ls -1 .claude/commands/*.md \| wc -l` = 19 IDENTICAL S153 |
- **Nợ S151 (Mig 67) VẪN ĐÓNG verified tươi:** `ef-core-migration/SKILL.md:89` còn row `| **67** | **AddPeApprovedBudgetSnapshot** | …` kèm chú *"(row bổ sung @S151 — ring1 TRƯỢT-1 bắt bảng dừng ở 66 trong khi canonical Mig 67)"*. File 34.147B, mtime 2026-07-26 01:41. Bảng skill = row cuối 67 canonical `STATUS` Mig 67 **khớp**.
- `skills/README.md:20` dùng **B1-pointer đúng luật** (*"mới nhất row cuối bảng skill; số `docs/STATUS.md`"*) 0 count-claim cứng để lão-hoá.
- **Không skill nào cần biết vòng Ctx** kết luận S153 giữ nguyên: lớp mềm session-lifecycle (tầng command + persona + máy `session_ctx.py`), skill tầng domain/ops. Kéo vào skill = sai tầng.
---
## ③ PLUGIN — **PASS** (0 delta mọi nấc)
| nấc | giá-trị | lệnh |
|---|---|---|
| registered | **18** | `~/.claude/settings.json:17-35` `enabledPlugins` |
| enabled | **15** | `sed -n '17,36p' ~/.claude/settings.json \| grep -c true` = 15 |
| disabled | **3** | `grep -c false` = 3 `pr-review-toolkit:20` · `code-modernization:22` · `hookify:27` |
| marketplace | **37** | `ls -1 ~/.claude/plugins/marketplaces/claude-plugins-official/plugins \| wc -l` = 37 |
| lead model | `claude-opus-5[1m]` `:11` · `effortLevel: xhigh` | cat settings |
- **IDENTICAL baseline S153 CẢ 4 nấc** (18/15/3 + marketplace 37). Marketplace = **lớp đo riêng**, đã đo tươi chứ không suy từ lớp registered (bài F-D @S143).
- **Authority VẪN user-global:** project `.claude/settings.json` (330B) chỉ `autoMemoryDirectory` + `hooks.Stop wal-flush.ps1` **0 khoá plugin/model** không conflate 2 file settings.
---
## F-1 — ④ docs — **MED** (carry S153 F-2, MỞ SANG PHIÊN THỨ 2, 0 tiến-triển)
`docs/governance/README.md` **chưa hề được chạm** kể từ `72bbfa5` **2026-06-02** (`git log -1 --date=short -- docs/governance/README.md`), working-tree 0 thay-đổi. 2 vị-từ sai:
- `:5` `**Status:** ✅ Active trial week 1-4 (2026-05-26 → 2026-06-23)` cửa-sổ đóng **33 ngày** trước hôm nay (2026-07-26) vẫn khai `Active`.
- `:35` `phase-9-uat — UAT + production hardening **(current)**` trong khi `CLAUDE.md` khai **Phase 10 COMPLETE + Phase 11 A→F done**; enum phase trong file này dừng phase-9, **thiếu hẳn phase-10/11**.
Đây **prescriptive** (mô-tả trạng-thái SỐNG của governance), không phải frozen phải . Lane lead/owner. *Ghi chú ranh: file này là index RAG-governance, không phải index doc chung.*
## F-2 — ④ docs — **LOW** (carry S153 F-4, class-gap CÓ TRƯỚC, chưa chốt luật)
`broadcasts/_index.md` (112 dòng) thiếu row **INBOUND**. Đo từng file thay đoán `for f in $(ls -1t broadcasts/inbox/ai_infra/ | head -10); do grep -c "$f" _index.md; done`:
```
0 2026-07-25-Governance-pause-tiep-upgrade-vong-ctx <- thư ② S153 (0df10df4)
0 2026-07-25-Governance-model-default-opus-5-max <- thư ① S153 (9c909007)
1 2026-07-22-...reply-session-model-dung-ban-da-duyet (:59)
1 2026-07-22-...reply-adap11-c7-bulk-shards (:58)
0 2026-07-21-Governance-eval-quality-audit-...
0 2026-07-20-Governance-3-khoan-title-tier-posture-...
0 2026-07-20-Governance-auto-toan-vong-distill-2-tang
0 2026-07-19-Governance-harness-dong-du-vong-audit-...
0 2026-07-19-Governance-goi-chot-owner-nam-khoan
0 2026-07-18-Governance-spawn-label-panel-audit-khung
```
**Phạm-vi tập-đo (khai rõ):** **8/10 thư inbox mới nhất = 0-row**, gap kéo từ **2026-07-18**. Lane inbound **CÓ tồn tại** đầy-đủ tới 2026-07-22 (`:15-59`, khuôn `ai_infra → se | processed | … | ✓`), lane outbound thì cập-nhật tới `:112` (2 row S153 `d432f5eab5cd` + `aedd7fb67405` ĐÃ ). **outbound sống, inbound chết từ 07-18.** Δ vs S153: **KHÔNG xấu thêm** (vẫn đúng tập 8 đó, S153 đo 6/8 trên head -8; tao đo head -10 nên ra 8/10 cùng một gap, khác bề-mặt-đếm). Lead cần chốt: lane inbound còn hiệu-lực hay đã bỏ nếu bỏ thì hàng `:15-59` phải khai, chứ đứng lưng-chừng bẫy cho phiên sau.
## F-3 — ④ docs — **LOW** (residual của S153 F-6 — đóng 2/3, hở đúng con-trỏ)
S153 F-6 yêu-cầu 3 thứ @closeout; đo tươi thì **2 đã đóng, 1 hở**:
| yêu-cầu | trạng-thái | bằng-chứng |
|---|---|---|
| HANDOFF stamp S153 | ĐÓNG | `docs/HANDOFF.md:5` `**🆕 Last updated:** 2026-07-26 chiều (**S153** …)` |
| STATUS row S153 | ĐÓNG | `docs/STATUS.md:466` `## ✅ Recently Done (S153 — 2026-07-26)` + `:471` State-line |
| **con-trỏ `→ log`** tới session-log | **HỞ** | `grep -c "→ log" docs/STATUS.md` = **29** (khuôn dùng đều khắp, vd `:480 :484 :488 :508`) nhưng **khối S153 `:466-471` KHÔNG có dòng nào**; `grep -rn "2026-07-26-1600-S153"` = 7 hit, live-anchor DUY NHẤT `.claude/sessions/session-6/_end:2 pointer:` 4 hit còn lại run-folder frozen + chính file S153 của tao |
session-log `docs/changelog/sessions/2026-07-26-1600-S153-ctx-adopt-3vai-rename.md` **vào được từ `_end` manifest nhưng KHÔNG vào được từ `STATUS`** lệch khuôn 29 khối anh em. Đề: thêm 1 mảnh `→ log …` vào `:471`. Lane lead (rẻ, additive).
---
## DIFF vs S153-close (PIN 6 finding) — 4 ĐÓNG / 2 MỞ
| S153 | mặt | phán @S154 | bằng-chứng đóng/mở |
|---|---|---|---|
| F-1 plugin README "KHÔNG enable" | ③④ | **ĐÓNG** | `agents/README.md:225-226` @S153 tách nấc ĐÚNG: *"Nấc THẬT của khối này = `enabled` nhưng CỐ Ý KHÔNG DÙNG"* + trỏ `~/.claude/settings.json:17-35` cho bit enable. **Neo số verify tươi:** `csharp-lsp :33` · `commit-commands :31` · block `:17-35` (grep -n trên chính settings.json) 3 citation ĐÚNG, không phải vá-cho-vừa-detector. Số chốt cuối = ring1 @close "3 hard + 1 nấc-khác", `:226` ghi minh-bạch cả 3 lượt số |
| F-2 governance/README stale | | 🔴 **MỞ** **F-1 @S154** | git log đứng 2026-06-02 |
| F-3 ctx-draft 0 con-trỏ | | **ĐÓNG** | `docs/HANDOFF.md:8` neo live, tự khai *"(F-3 H1 @close — neo con-trỏ live-doc)"* |
| F-4 `_index` inbound gap | | 🔴 **MỞ** **F-2 @S154** | 2 thư S153 vẫn 0 hit |
| F-5 `hmw_width` additive-note | | **ĐÓNG ĐÚNG KIỂU** | `memory-budget.json:28` `_roster_note_S153` = khoá MỚI, `:29 _why_20_not_16` **giữ nguyên từng chữ** thi-hành đúng "thêm câu, KHÔNG sửa record đã ratify" |
| F-6 session-log pointer | | 🟡 **ĐÓNG 2/3** **F-3 @S154** | HANDOFF+STATUS-row xong, `→ log` hở |
**THÊM/ĐỔI/XÓA khác:** THÊM = `.claude/sessions/session-7/` (scaffold L7) + run-folder S154 · ĐỔI = `.session-counter.json` 27→28 · XÓA = `WAL.md` reset sạch (`871ac0a`) mất 1 hit tên-cũ. **0 skill mới · 0 plugin mới · 0 vai mới · 0 command mới.**
## NEW-ALLOC — 0 action
- **Skill-matrix phủ 23/23:** `agents/README.md` `## 🔌 External skill/plugin mapping` `:212-230` + bảng vai 3 row ctx đã từ S153, cả 3 khai `ref-only … KHÔNG dùng domain skill` (đúng bản-chất monitor read-only).
- 0 skill/plugin mới trong cửa-sổ (skills diff rỗng · settings 18/15/3 identical · marketplace 37 = 0 delta) **0 alloc-action**.
- Carry chưa đo lại (khai , KHÔNG suy): `security-guidance` @S143 mang hook Stop + edit-time warning đụng `settings.json` hooks **owner quyết, tao không bật, không tự đánh giá lại**.
## G-015 self-honesty
Vai này propose-only. `store_memory` đã strip nhưng **`Bash` vẫn còn = write-channel MỞ** **KHÔNG khai "read-only enforced"**. Trong phiên này tao chỉ ghi **đúng 1 file của chính tao** (`sub-tooling-auditor-open-S154.md`); containment thật = em-main single-writer + git-diff post-session.
<!-- END sub-tooling-auditor-S154 · TOTAL=3 FINDING -->

View File

@ -0,0 +1,75 @@
# trio-synthesis — S154 (phiên-LOGIC L7) · vòng AUTO giữa 2 bookend
> Owner @S151: *"cứ đúng luật là tự làm, tao muốn có 1 vòng auto giữa 2 phiên bookend"* ⇒ 0 cổng consent.
> TUẦN TỰ eval → refine → audit. Lead scribe NGAY khi từng con trả.
| nấc | vai | verdict | file | bytes | END |
|---|---|---|---|---|---|
| 1/3 ĐO | `harness-eval` | `MIXED — 12 signal` | `harness-eval-return.md` | 24.889 | `TOTAL=12 SIGNAL` |
| 2/3 ĐỀ-XUẤT | `harness-refine` | `3 action / 8 bác / 3 escalate / 4 nhường` | `harness-refine-return.md` | 20.585 | `TOTAL=3 ACTION · BÁC=8` |
| 3/3 KIỂM | `harness-audit` | `68Đ / 8T / 2KC trên 78 claim` | `harness-audit-return.md` | 39.419 | `TOTAL=78 CLAIM` |
**Con KIỂM đo lại:** falsify **16 phép (11 HELD / 5 BROKE)** · re-chạy **5 instrument, ~60 số → 0 LỆCH**.
Phân rã: con-ĐO **28Đ/1T/1KC / 30** · con-ĐỀ-XUẤT **25Đ/7T/1KC / 33**.
## 1. Cụm TRƯỢT CÓ HÌNH DẠNG — không rải đều
🔴 **Lớp (i) — 3/3 cite dòng vào `memory-budget.json` đều lệch** (R13 · R17 · R29):
| claim | refine ghi | thật là | ghi chú |
|---|---|---|---|
| R13 | `:191` = harvest-curator | `:191` = **test-specialist**; harvest ở `:192` | nội-dung trích **đúng verbatim**, chỉ sai dòng |
| R17 | `:187` = implementer-backend | `:187` = **investigator-codebase**; impl-be ở `:189` | 🔴 nặng nhất — mục này **tự khai mục-đích***"ghi ra để lead Edit ĐÚNG DÒNG"* |
| R29 | `:189` = frontend-designer | `:189` = **implementer-backend**; fd ở `:190` | lỗi thứ 3 cùng lớp |
**Đây chính là file mà action A2 xin lead Edit.** Lead làm theo literal ⇒ **sửa nhầm row**. Nội dung trích đều đúng, nên đọc lướt sẽ thấy "khớp" — bẫy đúng lớp `acceptance literal tự-lão-hoá`.
🔴 **Lớp (ii) — bookkeeping lịch-sử lệch theo hướng LÀM NHẸ quá khứ:**
- **R20**: refine ghi S144 có *"9 action-row"*; đếm nhãn bảng = **A1·A2·A3·A4 = 4**. Chính file S144 tự khai 2 chỗ *"A1A4"*. Sai hơn **gấp đôi**.
- **R21**: refine ghi cicd `_INDEX` đã thử *"2 lần"* (D1@S151, A1@S153); thật là **3** — S144 **A1** nhắm CHÍNH đối-tượng đó (*"nén tại chỗ `_INDEX.md` cicd xuống dưới cap"*). Lượt này là ca thứ **4**.
🔴 **Lệch này khiến ESCALATE E1 CÀNG chính đáng, không nhẹ đi** — con KIỂM tìm ra lỗi làm MẠNH thêm kết luận nó đang chấm.
**Lớp (iii) — con-ĐO 1 TRƯỢT (E9):** gán sai toạ-độ **và** vu cho `measured._note` một lỗi nó không có. Chuỗi *"WATCH gần autoinject-cap nhất 23955/25600"* nằm ở `measured."implementer-backend".rollout` `:189`, không phải `_note`. WATCH thật trong `_note`*"cicd l2_index 18774/20480 ~92% cap"* — và **vẫn đúng hướng** (nay 94,3%).
**R4 BROKE — bài học phương pháp:** refine thấy cột `entries` lệch 1 ở `ring1-audit` (4 vs 5) rồi **bịa một lời giải thích** (*"máy tính cả `^### `"*). Con KIỂM đo: `grep -n '^#'` ra dòng 1/10/18/33/38 — **0 dòng `^### `**. Nguyên nhân thật = **1 dòng `---`**, và nó **hệ thống**: ring1 4+1=5 ✓ · harvest 8+1=9 ✓ · tooling 9+1=10 ✓. Quy luật bị che vì harvest/tooling **không nằm trong bảng mẫu 11 dòng** của refine. ⇒ *mẫu nhỏ + một ngoại lệ = mảnh đất màu mỡ cho lời giải thích bịa*.
**R3 TRƯỢT:** refine đối chiếu **output hàm A** (`Get-EntryMarkerLineNumbers` `:77-83`, khớp heading **HOẶC** `---`, in tại `:131`) với **luật hàm B** (`$logCount` `:153`, chỉ chạy trong nhánh over-cap, in tại `:208`) rồi tuyên *"xác nhận, không phải suy đoán"*.
## 2. Quyết định của con-ĐỀ-XUẤT: đứng vững 15/15
3 ESCALATE · 8 BÁC · 4 NHƯỜNG — con KIỂM chấm **tất cả đều chính đáng, 0 né việc**.
- **E1** `cicd/archive/_INDEX.md` — 19322=19322, **0 byte đổi** sau attempt ⇒ cửa LEAD, cấm attempt-3 *(thật ra là ca thứ 4 — xem R21)*
- **E2** `harvest-curator/MEMORY.md` — drain S151 **đã chạy** 20.933→17.655, vẫn hụt 145B, rồi **tái phình 19.456 = 111,1%** ⇒ cửa OWNER; vai **từ chối chọn hộ con số** (đúng ranh *"con số = quyền chủ dự án"*)
- **E3** N1+N2@S152 chưa land
🔴 Không có chốt-dừng thì hành vi mặc định là đề xuất drain lần 3, lần 4 — mỗi lần **trông như có làm việc**, trong khi vấn đề thật là ngưỡng sai hoặc corpus thật sự cần lớn hơn, và chỉ owner quyết được.
## 3. Con-ĐO: 3 tín-hiệu lead giao — xác nhận CẢ 3, mở rộng 1
| # | lead giao | máy xác nhận |
|---|---|---|
| (a) | harvest-curator diary vượt self-target | **19.456 B** = **111,1%** của 17.510 (17,1KB×1024); `19456 17655` = **1.801** khớp tới từng byte; **2 máy độc lập cùng in 19.456** |
| (b) | `measured{}` thiếu 3 row vai Ctx | 🔺 **MỞ RỘNG: thiếu 6 row** — 3 ctx **và** 3 ring. Phủ **17/23 = 73,9%**. `ring2-audit` **13.964 B** = vai không-baseline nặng nhất |
| (c) | `_note:185` + `rollout` khai trio "chưa chạy" | máy in **4.492 / 4.813 / 4.530****×2,96** baseline ~1.5KB) ⇒ **4 chỗ chữ nói ngược số** |
**Trục để N/A trung thực:** tuổi/độ-tươi gist — 0/5 instrument đo được, `budget.json:42` tự khai *"CHUA CO MAY"***không điền số tưởng tượng**.
**Con-ĐO tự đính chính diary S153 của chính nó:** từng đóng khung `pull_warn_days`*"nhánh cảnh-báo không thể kích"*; lượt này parse JSON (không grep — grep ra 1 hit nhưng là **mention** trong chuỗi `_session_ctx_kb_reader`, không phải key) ⇒ hạ từ **defect** xuống **trạng-thái-thiết-kế**, dẫn `nhip-no-probe.ps1:209-215`: *"Absent key = stay INFORM, do NOT assume a default"*.
## 4. Containment
- `harness-eval` **tự khai** ghi `.claude/agent-memory/.mfe-state.json` (+5 dòng, md5 `fe6f3b44…``54e111d9…`) — **hợp lệ**, đúng ngoại-lệ Mirror C1, `mfe-eval.ps1` ghi by-design. `.archive-strikes.json` md5 PRE=POST (DRY thật).
- 🔴 **Lỗ trong guard, phát hiện nhờ ca này:** `§2.1.9(f)` bảo *"`git status --short` → kỳ vọng 0 tracked-change ngoài run-folder"*. Phép đó **MÙ đúng 4 path Stop-hook tự commit** — write này đã bị hook nuốt vào `76580d7 wal: flush 20260726T1842` **trước khi lead kịp nhìn**. Lead tìm ra **chỉ vì vai tự khai** ⇒ guard đang dựa vào tính trung thực của đối tượng bị giám sát.
**Phép đúng:** `git log --name-only origin/main..HEAD | grep` — nhìn vào **commit của hook**, y hệt cách Sàn-3 ④/⑤ thu hồi điểm mù. Cùng bài học, chưa được áp sang guard này.
- Post-wave `git status` sau cả 3 nấc: chỉ `.session-counter.json` (tick của lead) + file return của chính vai. **0 residual-write.**
## 5. #53 trong trio
- `harness-eval` garble ×1 (return `"Instrument #4 and #5."`) → resume 1× → CLEAN
- `harness-refine` **SẠCH lượt đầu** — vai DUY NHẤT của cả phiên
- `harness-audit` garble ×1 → resume 1× → CLEAN
🔴 **Trio hết miễn nhiễm.** S151 từng ghi `trio return-only 3/3 CLEAN = counter-datum`; S153 phá 3/3→1/3; S154 = **1/3 sạch** ⇒ xác nhận, không phải nhiễu.
🔴 **`harness-audit` = failure-mode THỨ HAI, khác 9 ca kia:** làm 30 tool-call mà **chưa ghi** (1.293B) — 9 ca kia làm **và ghi**, chỉ hỏng lúc return. Ca này pin sớm = mất gần hết. Resume → **39.419B (×30)**.
⇒ Kỷ luật ghi-đĩa **cạnh tranh với bản năng nghề của vai KIỂM** (không muốn ghi verdict trước khi đủ chứng). Đúng bản năng, sai chiến lược sống sót.

View File

@ -0,0 +1,527 @@
# Reviewer — soi đối-kháng diff ĐỢT 1 (S155, PE delete-by-approver) — cửa cuối trước DEPLOY PROD
- Ngày: 2026-07-27
- Vật soi: `git status` + `git diff` toàn repo tại HEAD `8d4075a` (working tree, chưa commit)
- Phạm vi: 11 file MODIFIED + 3 file UNTRACKED (2 migration + Designer, 1 test file mới) + 1 sub-report
- Lead đã tự đo: `dotnet build` 0W/0E · `dotnet test` 549 PASS / 0 FAIL. Reviewer KHÔNG chạy lại build/test (tin số đo của lead), nhưng đọc lại assert của test mới để tìm xanh-giả.
> Ghi TRONG LÚC LÀM. Mỗi mục ghi ngay khi tìm ra.
---
## BẢNG DIFF (đo bằng `git diff --stat` + `git status --porcelain`)
| File | Δ |
|---|---|
| `src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs` | +605 |
| `tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs` | +54 |
| `src/Backend/SolutionErp.Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs` | +31/-2 |
| `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx` | +28 |
| `src/Backend/SolutionErp.Api/Controllers/ApprovalWorkflowsV2Controller.cs` | +17 |
| `src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs` | +13/-1 |
| `.../Configurations/ApprovalWorkflowConfiguration.cs` | +6 |
| `fe-user/src/types/approvalWorkflowV2.ts` | +7/-1 |
| `.../ApplicationDbContextModelSnapshot.cs` | +5 |
| `fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx` | +2 |
| UNTRACKED: `Migrations/20260727033522_AddPeAllowApproverDelete.{cs,Designer.cs}` | mới |
| UNTRACKED: `tests/.../Application/UpdateAwDefinitionTests.cs` | mới |
---
# FLAG
## 🔴 F1 — HIGH — `PurgeOrphanOpinionsAsync` (ExecuteDelete) chạy NGOÀI transaction và TRƯỚC `SaveChangesAsync` ⇒ mất dữ liệu không hoàn tác được
**File:** `src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs:886-918` (thân `PurgeOrphanOpinionsAsync`), gọi tại **`:681-683`**, còn `SaveChangesAsync` mãi tận **`:822`**.
**Điều sai.** Handler này KHÔNG mở transaction (`grep -n "BeginTransaction" ApprovalWorkflowV2AdminFeatures.cs`**0 hit** trong toàn file). `ExecuteDeleteAsync` phát `DELETE` thẳng xuống SQL và **auto-commit ngay** khi không có transaction ambient. Trình tự thực tế:
1. `PurgeOrphanOpinionsAsync` chạy **7 câu DELETE**, mỗi câu commit riêng → chữ ký đã BIẾN MẤT VĨNH VIỄN khỏi DB.
2. Sau đó mới tới `db.ApprovalWorkflowLevels.Remove(...)` (`:767-772`), truy vấn phiếu bị ảnh hưởng (`:794-800`), `db.PurchaseEvaluationChangelogs.Add(...)` (`:806`), rồi `SaveChangesAsync` (`:822`).
3. Nếu bước 3 ném (FK 547 từ một bảng chữ ký **thứ 8** chưa được liệt kê, unique index, `DbUpdateConcurrencyException`, timeout, mất kết nối, `OperationCanceledException` do client huỷ request), thì toàn bộ thay đổi ở bước 2 **rollback**, nhưng chữ ký ở bước 1 **không quay lại được**.
⇒ Kết quả: admin bấm Sửa, nhận **409/500**, tưởng "không có gì xảy ra", trong khi N dòng chữ ký đã bị xoá cứng. Đây đúng là class lỗi *sai IM LẶNG* mà chính spec S155 đang đi chữa.
Rủi ro này **không lý thuyết**: chính `PurgeOrphanOpinionsAsync` liệt kê tay 7 bảng (`:889`, `:893`, `:897`, `:901`, `:905`, `:909`, `:913`). Nếu tương lai có bảng `*LevelOpinion` thứ 8 (module Văn phòng số đang mở rộng), thì `CountLiveOpinionsAsync:830` bỏ sót → guard cho qua → purge 7 bảng → `Remove``SaveChanges` **nổ FK 547 ở bảng thứ 8** → rollback → mất chữ ký ở 7 bảng kia. Nghĩa là đường "purge thành công rồi SaveChanges fail" tồn tại thật, không phải giả định.
**Cách phá tao đã dựng (không chỉ đọc).** Đếm số nguồn ném NẰM GIỮA purge (`:683`) và SaveChanges (`:822`): `Remove()``:771` có thể ném `InvalidOperationException` (severed association) nếu `DetachTrackedOpinionsOn` bắt hụt (xem F5); `db.PurchaseEvaluations…ToListAsync(ct)``:794-800` là một **round-trip DB nữa** sau khi đã purge, huỷ token là ném; `ResolveUserNamesAsync``:686` cũng là round-trip DB sau purge; và `SaveChanges` còn ghi changelog cho N phiếu. Bốn nguồn ném độc lập, đều nằm SAU điểm không-hoàn-tác.
**Đề xuất sửa.** Bọc toàn handler trong một transaction rõ ràng (`db.Database.BeginTransactionAsync(ct)` → purge → SaveChanges → `CommitAsync`), **hoặc** dời `PurgeOrphanOpinionsAsync` xuống SAU `SaveChangesAsync` (khi đó phải chấp nhận `Remove` nổ FK trước, nên phương án transaction sạch hơn). Nếu `IApplicationDbContext` không expose `Database`, cách rẻ nhất là chuyển thẳng handler sang nhận `ApplicationDbContext` hoặc thêm `IDbTransactionScope` — repo đã có tiền lệ transaction ở `ContractCodeGenerator.GenerateAsync` (`IsolationLevel.Serializable`).
**Tiêu chí nghiệm thu.** Có test: giả lập `SaveChanges` ném sau khi purge (ví dụ chèn một opinion mồ côi + ép lỗi ở changelog) rồi assert **số row opinion mồ côi KHÔNG đổi**. Test hiện tại không có ca này.
---
## 🔴 F2 — HIGH — Purge xoá CỨNG cả chữ ký của phiếu chỉ mới XOÁ MỀM ⇒ phá thẳng tính năng "khôi phục phiếu đã xoá" của chính đợt 2
**File:** `ApprovalWorkflowV2AdminFeatures.cs:889-891` (và 6 khối lặp y hệt tới `:915`), điều kiện:
```csharp
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
&& (o.IsDeleted || !db.PurchaseEvaluations.Any(p => p.Id == o.PurchaseEvaluationId && !p.IsDeleted)))
.ExecuteDeleteAsync(ct);
```
**Điều sai — hai vế đều rộng hơn nhãn "mồ côi":**
- **Vế `!db.PurchaseEvaluations.Any(... && !p.IsDeleted)`**: phiếu **xoá mềm** (`IsDeleted=1`, dữ liệu còn nguyên, khôi phục được) bị coi ngang với phiếu không tồn tại. Chữ ký của nó bị **xoá CỨNG**. Nhưng chính spec S155 hạng mục A là "màn hình phiếu Đã xoá + khôi phục". Sau khi admin sửa quy trình một lần, phiếu xoá mềm khôi phục lên sẽ **mất trắng lịch sử ý kiến cấp duyệt** ở những Cấp bị gỡ người — im lặng, không cảnh báo.
- **Vế `o.IsDeleted`**: opinion **tự nó** xoá mềm trên một phiếu **CÒN SỐNG** cũng bị xoá cứng. Đây là bản ghi kiểm toán của tài liệu đang hiệu lực.
Cả 7 bảng đều dính (`ContractLevelOpinions:893`, `ProposalLevelOpinions:897`, `LeaveRequestLevelOpinions:901`, `OtRequestLevelOpinions:905`, `TravelRequestLevelOpinions:909`, `VehicleBookingLevelOpinions:913`).
**Cách phá.** Tao đọc ngược: có gì chặn không? `CountLiveOpinionsAsync:830-884` chỉ đếm opinion **còn sống**, nên đúng những opinion sắp bị xoá cứng là những opinion mà guard **cố tình bỏ qua** — tức guard và purge được thiết kế bù nhau, không có lớp nào canh phần "xoá mềm còn khôi phục được". Không có `changes.Add` nào ghi ID phiếu bị ảnh hưởng, chỉ có `:775` ghi số đếm `"Dọn {purgedOrphans} chữ ký mồ côi"` — không truy ngược được đã xoá của phiếu nào.
**Đề xuất sửa.** Hai lựa chọn, chọn 1:
- (a) **Thu hẹp**: chỉ purge khi phiếu cha **thật sự không còn row** (`!db.PurchaseEvaluations.IgnoreQueryFilters().Any(p => p.Id == o.PurchaseEvaluationId)`), còn phiếu xoá mềm thì **chặn admin** như phiếu sống (thông báo "phiếu X đã xoá đang giữ chữ ký ở Cấp này, khôi phục hoặc xoá hẳn trước").
- (b) **Chuyển FK sang nullable + SET NULL** để gỡ người không cần đụng chữ ký. (Đổi schema, không hợp đợt 1.)
Với đợt 1 sắp deploy, (a) là đường ít rủi ro nhất. Nếu owner chấp nhận giữ nguyên, tối thiểu phải ghi ID phiếu bị purge vào `changes` để còn dấu vết.
**Tiêu chí nghiệm thu.** Test: phiếu xoá mềm có 1 opinion ở Cấp bị gỡ → gọi Update → assert opinion **vẫn còn** (hoặc lệnh bị 409). Test hiện tại không có ca này.
---
## 🔴 F3 — HIGH — Endpoint PUT KHÔNG có dây FE nào gọi tới ⇒ mục tiêu số 1 của đợt 1 (cứu phiếu đang treo) KHÔNG dùng được trong UAT
**File BE:** `src/Backend/SolutionErp.Api/Controllers/ApprovalWorkflowsV2Controller.cs:45-51`
**File FE (nơi lẽ ra phải có):** `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx:589` — submit của Designer.
**Bằng chứng đo được (không phải đọc thấy):**
```
$ grep -rn "approval-workflows-v2" fe-admin/src fe-user/src
… 29 hit, TOÀN BỘ là api.get / route / queryKey, cộng đúng 3 hit ghi:
fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx:286 api.delete(`/approval-workflows-v2/${id}`)
fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx:297 api.patch(`/approval-workflows-v2/${id}/user-selectable`, …)
fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx:589 api.post('/approval-workflows-v2', …)
$ grep -rn "api.put" fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx
(0 hit)
```
**Điều sai.** ~600 dòng BE mới (`UpdateAwDefinitionCommand` + Validator + Handler + 3 helper) là **mã chết** đối với người dùng UAT. Designer trên UI chỉ có đường **POST tạo version mới**. Mà chính comment `:386-396` của file Application đã tự khai: POST sinh `Id` MỚI ⇒ phiếu đang treo pin `Id` CŨ ⇒ **cờ F6 vừa tick không bao giờ tới được phiếu cần cứu**.
⇒ Kịch bản UAT thật của owner: mở Designer → tick "Cho phép NV này XÓA phiếu" → Lưu → FE gọi POST → sinh version mới → phiếu bấm sai gói thầu **vẫn không xoá được**. Owner sẽ báo "làm rồi mà không ăn", đúng cái vòng lặp đợt 1 định chấm dứt.
Đây không phải lỗi mã — mã BE đúng. Đây là **lỗ hổng phạm vi**: nếu đợt 1 deploy để owner UAT, thì phần owner chạm được (FE) không nối vào phần vừa xây (BE).
**Đề xuất sửa.** Chọn 1 trong 3, khai rõ với owner trước khi deploy:
- (a) Nối dây FE trong đợt 1: Designer khi mở từ một version **đang tồn tại** thì `api.put('/approval-workflows-v2/'+id, payload)` thay vì POST, kèm nút phân biệt "Sửa tại chỗ" ⟂ "Tạo phiên bản mới".
- (b) Deploy đợt 1 nhưng **nói rõ** với owner rằng F6 chỉ áp cho phiếu tạo MỚI sau khi tick, còn phiếu đang treo phải chờ đợt 2 (kèm cách chữa tạm bằng `sqlcmd`/Swagger).
- (c) Hoãn phần PUT khỏi đợt 1.
**Tiêu chí nghiệm thu cho (a).** Sau deploy: tick cờ F6 cho một NV trên version đang có phiếu treo → `GET /api/approval-workflows-v2` trả `allowApproverDelete=true` **trên đúng `id` cũ** (Version không tăng), và phiếu treo đọc thấy cờ.
---
## 🟠 F4 — MED — 7 cờ Level bị GHI TUYỆT ĐỐI từ payload ⇒ client gửi thiếu field = **tắt cờ IM LẶNG**
**File:** `ApprovalWorkflowV2AdminFeatures.cs:424-440` (khai `UpdateAwLevelInput` với default) + **`:720-726`** (gán tuyệt đối).
```csharp
dbLevel.AllowReturnOneLevel = reqLevel.AllowReturnOneLevel; // default false
dbLevel.AllowReturnOneStep = reqLevel.AllowReturnOneStep; // default false
dbLevel.AllowReturnToAssignee = reqLevel.AllowReturnToAssignee; // default false
dbLevel.AllowReturnToDrafter = reqLevel.AllowReturnToDrafter; // default TRUE ← lệch chiều
dbLevel.AllowApproverEditDetails = reqLevel.AllowApproverEditDetails;
dbLevel.AllowApproverEditBudget = reqLevel.AllowApproverEditBudget;
dbLevel.AllowApproverDelete = reqLevel.AllowApproverDelete;
```
**Điều sai.** 7 cờ này là `bool` (không phải `bool?`) với default trong primary constructor. .NET 10 + System.Text.Json **có** tôn trọng default parameter của record, nên field vắng trong JSON → nhận default, **không** giữ giá trị đang lưu. Hai hệ quả:
- Client gửi payload thiếu `allowApproverEditBudget` → cờ đang BẬT bị **tắt**, trả 204, không ai biết.
- `AllowReturnToDrafter` default **`true`** trong khi 6 cờ kia default `false` — client gửi thiếu thì cờ này bị **BẬT** lên. Chiều lỗi ngược với 6 cờ còn lại, dễ bỏ sót nhất.
Đối chiếu: 2 cờ đổi-đường-đi (`AllowApproverSkipToFinal`, `AllowApproverFinalize`) được khai `bool?` với `null = không khai báo` (`:438-439`) và xử lý đúng kiểu echo — tức tác giả **đã biết** kỹ thuật này, chỉ không áp cho 7 cờ kia. Bất đối xứng nội bộ trong cùng một record.
Hiện chưa nổ vì **chưa có client nào gọi PUT** (xem F3). Nhưng nó là mìn đặt sẵn cho lúc nối dây FE: nếu người nối dây liệt kê tay payload và quên 1 dòng (đúng cái bẫy mà comment `fe-admin/.../ApprovalWorkflowsV2Page.tsx:617-620` vừa tự cảnh báo cho POST), thì kết quả không phải "cờ mới không lưu" mà là "**cờ CŨ bị xoá**".
**Đề xuất sửa.** Đổi 7 cờ sang `bool?` với ngữ nghĩa `null = giữ nguyên`, hoặc thêm test hợp đồng chốt rằng payload thiếu field ⇒ 400 chứ không phải ghi default.
**Tiêu chí nghiệm thu.** Test: level đang có `AllowApproverEditBudget=true`, gửi payload JSON **không có** key đó → assert giá trị sau lệnh **vẫn `true`** (hoặc lệnh 400).
---
## 🟠 F5 — MED — `DetachTrackedOpinionsOn` quét theo TÊN CỘT: bắt thừa sang entity không phải chữ ký, và có thể nuốt một `Added` thật
**File:** `ApprovalWorkflowV2AdminFeatures.cs:936-949`, gọi tại `:764-765`
```csharp
const string fkName = nameof(PurchaseEvaluationLevelOpinion.ApprovalWorkflowLevelId);
var stale = ((DbContext)db).ChangeTracker.Entries()
.Where(e => e.State != EntityState.Detached
&& e.Metadata.FindProperty(fkName) is not null
&& e.Property(fkName).CurrentValue is Guid levelId
&& levelIds.Contains(levelId))
.ToList();
foreach (var entry in stale) entry.State = EntityState.Detached;
```
**Bắt thừa — đo được:** `grep -rln "ApprovalWorkflowLevelId" src/Backend/**/*.cs` trả **9 file nguồn**, trong đó ngoài 7 entity `*LevelOpinion` còn có `PurchaseEvaluations/PurchaseEvaluationFeatures.cs`, `Contracts/ContractFeatures.cs`, `Office/*Features.cs` (chỉ là truy vấn, không phải entity) — **nhưng** phép quét không dùng danh sách entity, nó dùng `e.Metadata.FindProperty(name)`, tức bắt **mọi** entity type (kể cả shadow property) có cột trùng tên. Hôm nay tập đó = đúng 7 bảng chữ ký, nên hành vi đúng. Ngày mai thêm một entity bất kỳ có FK cùng tên (ví dụ bảng "phân công thay thế người duyệt", "nhật ký cấp duyệt") thì nó **bị detach im lặng** — mất luôn thay đổi đang chờ lưu của entity đó.
**Nuốt `Added`:** hàm không phân biệt state. Nếu tracker đang giữ một opinion state `Added` trỏ vào Level sắp gỡ, `entry.State = Detached` **huỷ luôn lệnh INSERT** mà không báo gì. Trong luồng hiện tại handler không add opinion nên chưa nổ, nhưng `IApplicationDbContext`**scoped theo request**, và MediatR pipeline behavior chạy chung context — điều kiện đủ để nó nổ không nằm trong file này.
**Ẩn lỗi thật:** comment `:930-934` biện hộ "guard F-5 đã xác nhận 0 chữ ký còn hiệu lực ⇒ mọi bản sao tracked đều là rác". Lập luận này đúng **chỉ khi** guard và tracker nhìn cùng một sự thật. Guard đọc `AsNoTracking` từ DB; một opinion vừa được tạo trong cùng request mà **chưa** SaveChanges thì DB chưa thấy ⇒ guard đếm 0 ⇒ detach xoá sổ nó. Đây đúng là "chỗ ta KHÔNG muốn EF quên".
**Đề xuất sửa.** (i) Liệt kê **tường minh** 7 CLR type thay vì so tên cột (`e.Entity is PurchaseEvaluationLevelOpinion or ContractLevelOpinion or …`) — mất tính "tự phủ" nhưng được tính đóng, và nếu thêm bảng thứ 8 thì compiler/test bắt được thay vì im lặng; (ii) chỉ detach entry có `State == Unchanged` hoặc `Deleted`, còn gặp `Added`/`Modified` thì **ném** để lộ ra bất thường thay vì nuốt.
**Tiêu chí nghiệm thu.** Test: tracker giữ 1 opinion `Added` trỏ Level sắp gỡ → gọi Update → assert **ném** (hoặc opinion vẫn được insert), không phải biến mất.
---
## 🟢 F6 — LOW — `u.FullName ?? u.Email` có thể ra `null` ⇒ thông điệp 403 lòi dấu phẩy trống
**File:** `src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs:709-714`
```csharp
var resolved = await userManager.Users.AsNoTracking()
.Where(u => allowedIdList.Contains(u.Id))
.Select(u => u.FullName ?? u.Email) // ← List<string?>
.ToListAsync(ct);
var names = resolved.Count > 0 ? string.Join(", ", resolved) : string.Join(", ", allowedIdList);
```
`User : IdentityUser<Guid>``Email``string?`. Nếu một NV có `FullName = null` **và** `Email = null` thì phần tử là `null`, `string.Join` render thành chuỗi rỗng ⇒ thông điệp thành `"… (Nguyễn A, , Trần B)"`. Fallback `resolved.Count > 0` **không cứu** được ca này vì Count vẫn > 0.
Tệ hơn một chút: nếu Cấp có 3 người mà **1 người đã bị xoá khỏi bảng Users** thì `resolved.Count = 2` > 0 ⇒ nhánh fallback không chạy ⇒ thông điệp liệt kê **thiếu người**, người đọc tưởng chỉ có 2 người duyệt được. Không phải lỗi bảo mật, nhưng là sai-im-lặng nhẹ đúng họ lỗi mà F-6 đang đi chữa (GUID khó hiểu → tên).
**Đề xuất.** `.Select(u => u.FullName ?? u.Email ?? u.Id.ToString())` (khuôn này đã dùng ĐÚNG, có đủ 3 nấc, ở `ResolveUserNamesAsync``ApprovalWorkflowV2AdminFeatures.cs:957` — tức chính diff này chứa cả bản đúng lẫn bản thiếu, cách nhau 2 file), và đối chiếu số lượng: `resolved.Count == allowedIdList.Count` thì mới bỏ GUID, thiếu thì nối thêm GUID của người không tra được.
---
## 🟢 F7 — LOW/INFO — "lộ TÊN người duyệt cho người vừa bị từ chối": KHÔNG phải rò rỉ mới, vì tên+email vốn đã công khai cho mọi tài khoản đăng nhập
**Đây là câu lead hỏi là quan trọng nhất. Tao trả lời bằng đo, không bằng cảm giác.**
Kênh rò rỉ **đã tồn tại từ trước**, rộng hơn hẳn:
- `src/Backend/SolutionErp.Api/Controllers/ApprovalWorkflowsV2Controller.cs:21-26``GET /api/approval-workflows-v2` chỉ có `[Authorize]` **trần** (class-level, cố ý theo gotcha #44), **không** policy.
- Query trả về `AwLevelDto` gồm `ApproverFullName` **và `ApproverEmail`**`ApprovalWorkflowV2AdminFeatures.cs:188-193`.
**Bất kỳ tài khoản đăng nhập nào** cũng GET được toàn bộ cây quy trình kèm **họ tên + email** của mọi người duyệt, ở mọi module. So với nền đó, việc thông điệp 403 in ra tên của đúng 1 Cấp là **tập con thực sự** của thứ đã công khai — delta rò rỉ ≈ 0.
Thêm nữa, FE **đang** hiển thị tên người duyệt cho chính người không đến lượt: banner amber "Không phải lượt bạn — chỉ {NV X / Y} duyệt được" (khuôn V2, xem skill `contract-workflow` §FE UX V2-aware). Nghĩa là tên đã ở trên màn hình người dùng đó rồi.
**Kết luận trục này: KHÔNG chặn deploy.** Nhưng ghi lại một mệnh đề đúng: *nếu* sau này siết `GET /api/approval-workflows-v2` bằng policy (việc nên làm), thì **phải nhớ siết cả chỗ này**, nếu không thông điệp 403 trở thành cửa hậu đọc tên. Ghi vào chỗ nào đọc được — hiện comment `:704-708` chỉ giải thích "GUID khó hiểu", không nhắc ràng buộc này.
**Chi phí query trong nhánh ném:** chấp nhận được. Nhánh này chỉ chạy khi người dùng bấm Duyệt sai lượt (hiếm, do FE đã disable nút), 1 round-trip `WHERE Id IN (≤3)`, và đằng nào cũng sắp ném 403 — không nằm trên đường nóng.
---
## 🔴 F8 — HIGH (cổng deploy, không phải cổng mã) — 2/3 file migration đang UNTRACKED; commit kiểu `-a`/`add -u` sẽ bỏ chúng lại và **hạ toàn bộ 7 module**
**Bằng chứng `git status --porcelain`:**
```
?? src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260727033522_AddPeAllowApproverDelete.Designer.cs
?? src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260727033522_AddPeAllowApproverDelete.cs
?? tests/SolutionErp.Infrastructure.Tests/Application/UpdateAwDefinitionTests.cs
M src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs
```
**Điều sai.** Trong bộ 3 file của gotcha #17, **file thứ ba (snapshot) đã tracked** còn **hai file kia thì chưa**. `git commit -a``git add -u` chỉ nạp file đã tracked. Kết quả nếu lỡ tay:
- Snapshot + entity (`ApprovalWorkflow.cs`) + config (`ApprovalWorkflowConfiguration.cs`) **vào commit** ⇒ model của EF **có** cột `AllowApproverDelete`;
- File migration **không vào commit**`DbInitializer.cs:64` `await db.Database.MigrateAsync()` chạy trên prod và **không thấy gì để áp** ⇒ cột không bao giờ được tạo;
- Mọi truy vấn chạm `ApprovalWorkflowLevels` ném `Invalid column name 'AllowApproverDelete'`.
Blast radius = **toàn bộ** người dùng schema V2: PE, Hợp đồng, và 5 module Văn phòng số (Đề xuất / Nghỉ phép / Tăng ca / Công tác / Xe). Không phải một tính năng hỏng — là **duyệt phiếu ngừng hoạt động toàn hệ**.
Đây là sai lầm **rẻ nhất để phạm và đắt nhất để lãnh** trong toàn bộ đợt này, vì nó không để lại dấu vết nào ở local (local đã áp migration rồi, xem F9) và build/test đều xanh.
**Đề xuất.** Trước khi commit: `git add` **đích danh** 3 đường dẫn trên, rồi `git status --porcelain | grep '^??'` phải **rỗng** cho vùng `Migrations/``tests/`. Sau khi commit: `git show --stat HEAD | grep AddPeAllowApproverDelete` phải ra **2 dòng**.
---
## 🟠 F9 — CLEAN (migration) + 1 LOW về tên — *thách-CLEAN: tao thử phá bằng 4 phép, trượt cả 4*
**Kết luận: bản migration này AN TOÀN để chạy trên prod có dữ liệu thật.** Không phải "đọc thấy ổn" — dưới đây là 4 cách tao đã thử phá:
**Phép 1 — 3-file rule (gotcha #17).** Đủ cả ba: `20260727033522_AddPeAllowApproverDelete.cs`, `…Designer.cs`, `ApplicationDbContextModelSnapshot.cs`. (Vấn đề *tracked hay chưa* tách riêng ở F8 — đó là lỗi git, không phải lỗi migration.)
**Phép 2 — Designer ⟂ Snapshot có lệch không?** So từng dòng, hai khối **trùng khít**:
```csharp
b.Property<bool>("AllowApproverDelete")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
```
(`…Designer.cs:198-201``ApplicationDbContextModelSnapshot.cs`, cùng nội dung.) Lệch snapshot là nguồn "migration kế tiếp sinh ra lệnh lạ" — không có ở đây.
**Phép 3 — đã chạy thật trên SQL Server chưa, hay mới chỉ là ý định?** Đo bằng `sqlcmd` trên LocalDB Dev, không tin file:
```
__EFMigrationsHistory (3 bản mới nhất):
20260727033522_AddPeAllowApproverDelete ← ĐÃ ÁP
20260717032812_AddPeApprovedBudgetSnapshot
20260714035636_AddPeApprovedPriceRange
INFORMATION_SCHEMA.COLUMNS / ApprovalWorkflowLevels:
AllowApproverDelete bit NO (CONVERT([bit],(0)))
```
Cột ra đúng hình dạng **y hệt 8 cờ anh em** đang sống (`AllowReturnOneLevel``AllowApproverFinalize` đều `bit NOT NULL DEFAULT CONVERT([bit],(0))`, riêng `AllowReturnToDrafter` default `(1)`). Tức nó không phải cấu trúc mới lạ, mà là dòng thứ 9 của một khuôn đã chạy 4 lần trước đó (Mig 29/30/31/58).
**Phép 4 — có làm khoá bảng / rewrite trên prod không?** `AddColumn<bool>(nullable: false, defaultValue: false)`**hằng số**, nên SQL Server 2012+ xử lý như thao tác **chỉ-metadata**, không viết lại bảng. Cộng thêm bảng cực nhỏ (`SELECT COUNT(*) FROM ApprovalWorkflowLevels` = **6** trên Dev; prod cùng bậc độ lớn — quy trình duyệt chỉ vài chục dòng Cấp). Không có nguy cơ timeout/khoá kéo dài.
**`Down()` reversible:** có — `DropColumn`. Lưu ý đúng mức: rollback làm **mất giá trị cờ đã tick**, nhưng vì default là `false` nên rollback đưa hệ về đúng trạng thái trước tính năng. Chấp nhận được.
**Migration chạy tự động lúc khởi động** (`DbInitializer.cs:64`), nên deploy không cần bước tay. Mặt trái: migration hỏng sẽ chặn app khởi động — bản này không thể hỏng vì lý do ở Phép 4.
> 🟢 **F9-LOW (tên gọi).** `AddPeAllowApproverDelete` — tiền tố `Pe` gây hiểu nhầm: cột nằm trên `ApprovalWorkflowLevels`, bảng **dùng chung ≥7 module**, không riêng PE. Người sau grep "ai sở hữu cột này" sẽ bị dẫn sai hướng. Không đáng sửa trước deploy (đổi tên migration đã áp còn tệ hơn), chỉ nên ghi 1 dòng vào changelog.
---
## 🟢 F10 — CLEAN (Validator T1/T2 + bất biến Version/IsActive/Id) — *thách-CLEAN: 11 đường lách đã thử, tắc cả 11; cộng 1 phép quét gián tiếp*
### (a) Handler có đúng KHÔNG tăng `Version` / KHÔNG đụng `IsActive` / KHÔNG đổi `Id` không?
Tao **không** trả lời bằng cách đọc lướt. Tao liệt kê **toàn bộ** phép ghi vào aggregate root trong thân handler:
```
$ sed -n '501,830p' ApprovalWorkflowV2AdminFeatures.cs | grep -n "def\.[A-Za-z]* *="
197: def.Name = request.Name;
198: def.Description = request.Description;
280: def.UpdatedAt = dateTime.UtcNow;
```
**Đúng 3 phép ghi, hết.** Không có `def.Version`, `def.IsActive`, `def.Id`, `def.Code`, `def.ApplicableType`, `def.CeoApprovalThreshold`, `def.IsUserSelectable`. Đây là phép **liệt kê vét cạn**, không phải kiểm điểm.
Rồi tao quét thêm **đường ghi gián tiếp** (đúng lớp gotcha #81-EXT — sửa qua biến trung gian nên grep trực tiếp không thấy):
```
$ sed -n '501,830p' … | grep -n "= def;|SetValues|Entry(|CurrentValues|\.Version|\.IsActive|\.IsUserSelectable|\.Code *=|ApplicableType *="
316: Summary = $"Sửa quy trình duyệt tại chỗ: {def.Code} v{def.Version} (giữ nguyên phiên bản)"
```
Hit duy nhất là một phép **ĐỌC** trong chuỗi nội suy. Không có alias, không có `SetValues`, không có `Entry().CurrentValues`. ⇒ Ba bất biến **được chứng minh**, không phải được tin.
### (b) 11 đường lách đã thử — tắc cả 11
| # | Cách phá | Chốt chặn | Vị trí (đã mở kiểm) |
|---|---|---|---|
| 1 | Hoán vị Order giữa 2 dòng (giữ nguyên TẬP) | Order từng dòng bất biến | `:575` |
| 2 | Thêm Cấp mới (Order chưa có) | `SetEquals` cuối vòng | `:621` |
| 3 | Rút hết người khỏi một Cấp | nhánh `lost` trong cùng khối | `:621-628` |
| 4 | Chuyển người sang Bước khác | `unknown` (dict theo từng Bước) | `:565` |
| 5 | Gửi trùng Id dòng Cấp | `Distinct().Count()` | `:560` |
| 6 | Bỏ sót / thêm Bước | `SetEquals` Id Bước | `:539` |
| 7 | Đảo thứ tự Bước (giữ TẬP) | so `reqStep.Order != dbStep.Order` | `:554` |
| 8 | Đổi ngưỡng CEO | echo nghiêm (null ≠ bỏ qua) | `:529` |
| 9 | Bật/tắt F5 (và F2 ngay dưới) | echo, lệch ⇒ 409 | `:580` (+`:584`) |
| 10 | Người MỚI mang sẵn cờ F2/F5 | chặn riêng nhánh `else` | `:594` |
| 11 | Bỏ người **đang giữ** cờ F2/F5 | quét `removedHere` | `:609` |
Cộng thêm: NV được trỏ tới phải **tồn tại thật** (`:643`, chặn FK 547 hoá 500), và người có chữ ký sống thì **không bỏ được / không đổi người được** (`:659`). Tao không tìm được đường thứ 12.
### 🟠 F10a — MED — đổi **Mô tả** quy trình: ghi thẳng, KHÔNG sinh vết
`:698` `def.Description = request.Description;` — gán vô điều kiện, và **không**`changes.Add` tương ứng (đối chiếu `:696` cho `Name` thì CÓ). Hệ quả kép:
- Đổi mô tả ⇒ `changes` rỗng ⇒ khối changelog `:790-821` **không chạy** ⇒ phiếu đang chạy **không có vết nào**;
- Mô tả là thứ người duyệt **đọc trên phiếu**, nên đây không phải trường trang trí.
**Đề xuất:** thêm `if (def.Description != request.Description) changes.Add(...)` — một dòng, đối xứng với `Name`.
### 🟠 F10b — MED — thêm người vào **Cấp ĐÃ QUA** không bị chặn, và màn hình sẽ hiển thị người đó là "đã duyệt"
Handler cho phép thêm dòng vào bất kỳ Cấp nào miễn Order đã tồn tại — kể cả Cấp mà con trỏ phiếu **đã đi qua**. Phía hiển thị, `ComputeLevelStatus`**số học con trỏ thuần**, không hề tra chữ ký:
```csharp
// PurchaseEvaluationFeatures.cs:1135-1147
if (stepIdx0 < currentIdx.Value) return "Done";
if (stepIdx0 == currentIdx.Value) {
if (levelOrder < currentLevel.Value) return "Done";
}
```
⇒ Admin thêm ông A vào Bước 1 Cấp 1 trong khi phiếu đã ở Bước 2 ⇒ luồng duyệt hiển thị Cấp đó **"Done"** ⇒ người đọc hiểu là **ông A đã ký**, trong khi ông A chưa từng chạm phiếu. Trên chứng từ tài chính, đó là **chữ ký ma trên màn hình**.
Đã nêu ở lượt soi spec (mục M1) và diff này **chưa xử lý**. Không chặn deploy đợt 1 (cần thao tác admin bất thường mới lộ), nhưng phải vào sổ trước khi mở tính năng cho nhiều admin.
**Đề xuất:** hoặc chặn thêm người vào Cấp đã qua trong `toAdd`, hoặc để `ComputeLevelStatus` đối chiếu `LevelOpinions` thay vì thuần con trỏ.
### 🟢 F10c — LOW — cho sửa `Step.DepartmentId` là AN TOÀN, đây là cách tao kiểm
Comment ở `:412` khẳng định "BE chỉ dùng làm hint, không khớp người duyệt". Tao không tin lời khẳng định, tao grep toàn `src/Backend`:
- `PurchaseEvaluationWorkflowService.cs:1117-1134``ContractWorkflowService.cs:131-148`**đều nằm trong nhánh V1 legacy** (`matchByDeptLevel`, đối tượng là `WorkflowStep` của Mig 21), **không phải** đường V2. Đường V2 khớp người **chỉ** bằng `ApproverUserId` (`PurchaseEvaluationWorkflowService.cs:702`).
- `PurchaseEvaluationFeatures.cs:1187-1188` / `:1240-1241` — thuần **DTO hiển thị** (map sang tên phòng).
⇒ Khẳng định đúng. Ghi chú nhỏ: `changes.Add` cho trường này chỉ ghi *"đổi phòng ban phụ trách"* (`:704`) mà **không ghi từ phòng nào sang phòng nào** — vết yếu hơn các trường khác.
---
## 🟠 F11 — MED — một ca test **XANH GIẢ**: `F16_…SafeUpdate_Succeeds_AndWritesNoPeChangelog`
**File:** `tests/SolutionErp.Infrastructure.Tests/Application/UpdateAwDefinitionTests.cs:753-754`
```csharp
(await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().CountAsync())
.Should().Be(0, "quy trình module Đề xuất KHÔNG được đẻ vết vào changelog của PE");
```
**Điều sai — ca này không mang thông tin nào.** Cách tao chứng minh (không phải đọc thấy nghi ngờ): truy **mọi** phép ghi vào `PurchaseEvaluationChangelogs` trong handler → chỉ có **một**, ở `:807`, nằm trong `foreach (var pe in affected)`, mà `affected` đến từ `db.PurchaseEvaluations.Where(p => p.ApprovalWorkflowId == def.Id …)` (`:794-800`). Test này seed **0 phiếu PE** (chỉ có một `Proposal`, `:730-740`). Vậy:
- **Thế giới A** (còn cổng `def.ApplicableType is DuyetNcc or DuyetNccPhuongAn`): `affected` rỗng ⇒ count = 0.
- **Thế giới B** (xoá cổng đi): `affected` **vẫn** rỗng ⇒ count = 0.
Hai thế giới, cùng một quan sát ⇒ phép đo **không phân biệt được** chúng ⇒ xoá cổng module đi test vẫn xanh. Nó đang đo *"không có phiếu PE nào tồn tại"*, không đo *"cổng module hoạt động"*.
**Đề xuất.** Cho ca này răng bằng cách seed **một phiếu PE ghim đúng workflow ProposalGeneral đó** (`SeedPeAsync(h.Db, wf.Id, …)` — hàm đã có sẵn ở `:108`), rồi mới assert `Count == 0`. Khi đó gỡ cổng ⇒ test đỏ.
**Tiêu chí nghiệm thu:** tạm gỡ điều kiện `def.ApplicableType is …` khỏi `:790-792`, chạy lại ca này — **phải ĐỎ**. Hiện tại nó xanh.
---
## 🟢 F12 — CLEAN (chất lượng các ca test còn lại) — *thách-CLEAN: 3 phép thử, gồm cả phép trả lời thẳng câu "double-insert"*
**Phép 1 — assert có phải chỉ là tiếng vọng của bộ nhớ không?** Cả file dùng **chung một** `h.Db`. Nếu `ResolveLikeRunningPeAsync` mà tracking thì nó sẽ trả về **chính những đối tượng handler vừa sửa trong RAM** ⇒ mọi assert "phiếu thấy thay đổi" thành vô nghĩa. Nó dùng `AsNoTracking()` (`:143`) ⇒ EF bỏ qua identity map, đọc lại từ DB thật. **Đúng.** Đây là chỗ dễ hỏng nhất và nó không hỏng.
**Phép 2 — trả lời trục "`db.ApprovalWorkflowLevels.Add` + `step.Levels.Add` có sinh double-insert?"** Không. Cùng **một tham chiếu đối tượng** được đưa vào DbSet và vào collection điều hướng của cha đang tracked ⇒ EF thấy **một** entity ở trạng thái `Added`, không phải hai. Và điều quan trọng hơn: **đã có test bắt được nếu sai**`F12` (`:262-263`) assert đúng **2** dòng ở Cấp 1:
```csharp
live.Steps[0].Levels.Where(l => l.Order == 1).Select(l => l.ApproverUserId)
.Should().BeEquivalentTo(new[] { alice.Id, bob.Id });
```
Double-insert sẽ ra **3** dòng ⇒ đỏ. Đây là bằng chứng, không phải suy luận.
**Phép 3 — các ca 409 có mù trước lỗi "ném SAU khi đã ghi" không?** Không. Mọi ca `F13*` assert **hai vế**: có ném **và** DB y nguyên. Mạnh nhất là `F13b_OmitCeoApprovalThreshold` (`:443`) — nó gửi kèm `Name = "Tên mới"` rồi assert tên **vẫn là tên cũ**, tức chứng minh **không phần nào** của lệnh lọt xuống trước khi ném.
Ngoài ra `F12` đo bằng **hành vi thật** (trước: `ForbiddenException`; sau: cùng người, cùng lời gọi, con trỏ tiến sang Cấp 2) chứ không bằng "DB có row mới" — đúng thứ spec cần. `F14` có khối chứng-nhân FK thật (đã tự dọn tracker để đo đúng tầng DB). `F16_Proposal…Blocked409` chạm module Đề xuất thật.
> 🔴 **F12-CẢNH BÁO CHO WAVE 5 (đang vá F2) — đọc trước khi sửa:**
> `UpdateAwDefinitionTests.cs:607-609` đang **khoá hành vi hiện tại**:
> ```csharp
> (await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
> .CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id)).Should().Be(0);
> ```
> Ca này **chốt rằng purge PHẢI xoá sạch chữ ký của phiếu xoá mềm** — tức đúng **chiều ngược lại** với đề xuất vá F2. Khi vá F2, ca này **sẽ ĐỎ**. Đó **không phải hồi quy**: phải viết lại chủ đích của ca (đổi thành "chữ ký của phiếu xoá mềm được GIỮ, và lệnh bị 409"), **không được** nới assert cho nó xanh. Nếu ai đó làm nó xanh bằng cách bỏ dòng assert thì F2 coi như chưa vá.
---
## 🟢 F13 — CLEAN (hồi quy) — *thách-CLEAN: 3 phép, và phép thứ 3 lòi ra 1 chỗ vá NỬA*
**Phép 1 — PUT mới có đụng client cũ không? KHÔNG.** Bảng route của controller sau khi thêm: `GET` (không template) · `POST` (không template) · `PUT {id:guid}` ← mới · `PATCH {id:guid}/user-selectable` · `DELETE {id:guid}`. Cặp (verb, template) của PUT **chưa từng tồn tại**, và không có `[HttpPut]` nào khác trong file. Phía client: `grep -rn "approval-workflows-v2" fe-admin/src fe-user/src` ra **29 hit**, phần ghi đúng **3** (`api.delete:286`, `api.patch:297`, `api.post:589`) — **không client nào** gọi PUT. Không thể hồi quy thứ chưa ai gọi.
**Phép 2 — đổi message 403 có làm đỏ test nào không? KHÔNG.** Tao grep chuỗi `"danh sách NV duyệt"` toàn repo trên `*.cs|*.ts|*.tsx` → đúng **3 hit**: 2 service + đúng **1** test là `ContractWorkflowServiceApproveV2Tests.cs:457`:
```csharp
.WithMessage("*Bước 1*Cấp 1: bạn không có trong danh sách NV duyệt*");
```
Nó dùng **ký tự đại diện** và dừng **trước** phần `({names})` — tức không hề chạm khúc bị sửa. Quan trọng hơn: nó đo `ContractWorkflowService`, file **không nằm trong diff**. Hai lý do độc lập ⇒ không thể đỏ.
**Phép 3 — module nào có nguy cơ vỡ?** Tao kiểm bán kính bằng cách đếm ngược từ **cấu hình FK** chứ không từ danh sách trong mã:
```
$ grep -A1 "HasForeignKey(x => x.ApprovalWorkflowLevelId)" …/Configurations/
→ 7 cấu hình, TẤT CẢ OnDelete(DeleteBehavior.Restrict):
PurchaseEvaluationLevelOpinion · ContractLevelOpinion · ProposalLevelOpinion
LeaveRequestLevelOpinion · OtRequestLevelOpinion · TravelRequestLevelOpinion
VehicleBookingLevelOpinion
```
**Đúng 7 — khớp tuyệt đối** với 7 bảng mà `CountLiveOpinionsAsync:830-884``PurgeOrphanOpinionsAsync:886-918` liệt kê. Nếu có bảng thứ 8 thì phép grep này phải ra 8; nó ra 7. Đồng thời `Level→Step`**Cascade**`Level→User`**Restrict** (`ApprovalWorkflowConfiguration.cs:65-75`), nên `Remove(level)` chỉ vướng đúng 7 bảng đó. ⇒ Không module nào vỡ vì thiếu quét.
### 🟠 F13a — MED — vá NỬA: câu 403 sinh đôi ở `ContractWorkflowService` **chưa được vá**
**File:** `src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs:264`
```csharp
var names = string.Join(", ", allowedUserIds); // ← vẫn in GUID trần
throw new ForbiddenException(
$"Bước {currentIdx + 1} ({currentStep.Name}) — Cấp {currentLevelOrder}: bạn không có trong danh sách NV duyệt ({names}).");
```
Đây là **cùng một câu, cùng một lỗi, cùng một schema V2**, chỉ khác module. F-6 vá `PurchaseEvaluationWorkflowService.cs:709-716` mà bỏ lại đây ⇒ người duyệt Hợp đồng vẫn nhận một dãy GUID không đọc được. Đúng lớp lỗi "được chỉ đích danh 1 chỗ thì phải grep cả lớp".
Không chặn deploy (đợt 1 là PE), nhưng nên gộp vào cùng commit vì chi phí bằng 0 và để lâu sẽ thành nợ vô hình.
**Đề xuất.** Bê nguyên khối `:709-716` sang, **kèm** sửa F6 (`?? u.Id.ToString()` ba nấc) cho cả hai chỗ một lượt.
---
---
## ⚠️ GHI CHÚ TRÔI DÒNG — file bị sửa TRONG LÚC soi (wave 5 vá song song)
Lúc kiểm lại anchor cuối cùng, `ApprovalWorkflowV2AdminFeatures.cs` có mtime **12:18:13** trong khi tao kiểm lúc **12:19:24** — tức file vừa đổi 71 giây trước. Đây là wave 5 đang vá F1/F2/F3 đúng như lead báo. Tao **không** soi lại bản vá đó (ngoài phạm vi lượt này, và lead đã nói sẽ tự verify bằng build + test + grep), nhưng ghi lại hai sự thật đo được để lượt sau không bị lạc:
**(1) F1 có dấu hiệu ĐÃ được vá đúng hướng.** Đo bằng grep, không phải suy đoán:
```
695: await using var tx = await context.Database.BeginTransactionAsync(ct);
704: purgedOrphans = await PurgeOrphanOpinionsAsync(…);
843: await db.SaveChangesAsync(ct);
847: await tx.CommitAsync(ct);
```
Hình dạng đúng: transaction mở **trước** purge, commit **sau** `SaveChanges`, và `await using` bảo đảm rollback khi ném. Đây là phương án (a) tao đề xuất ở F1. **Chưa** kiểm sâu (chưa soi `context` được lấy từ đâu, chưa soi tương tác với `IApplicationDbContext`), nên đừng đọc dòng này là "F1 đã PASS review".
**(2) Bản đồ trôi dòng.** Mọi anchor **`:659`** vẫn ĐÚNG NGUYÊN (đã kiểm lại: `:529`, `:539`, `:554`, `:575` không đổi) — nghĩa là **toàn bộ bảng 11 đường lách ở F10 vẫn mở được**. Các anchor **sau `:660`** trôi **+21 dòng**:
| Mốc | Số cũ trong báo cáo này | Số hiện tại |
|---|---|---|
| gọi `PurgeOrphanOpinionsAsync` | `:683` | `:704` |
| `def.Description = …` | `:698` | `:719` |
| `changes.Add` đổi phòng ban | `:704` | `:725` |
| `SaveChangesAsync` của handler | `:822` | `:843` |
| thân `PurgeOrphanOpinionsAsync` | `:886` | `:911` |
| `DetachTrackedOpinionsOn` | `:936` | `:961` |
| khối changelog (gate module) | `:790` | `:811` |
Các FLAG **F4, F5, F10a, F10c, F11** trỏ vào vùng sau `:660` ⇒ cộng **+21** khi mở file ở bản hiện tại. Bản chất phát hiện **không đổi** (tao đã đối chiếu bằng nội dung dòng, không chỉ bằng số).
---
# TỔNG KẾT
## Điểm làm ĐÚNG, đáng giữ nguyên
1. **`CountLiveOpinionsAsync` viết `!p.IsDeleted` TƯỜNG MINH thay vì dựa global query filter.** Tao kiểm `grep HasQueryFilter` toàn thư mục Configurations: chỉ PE và Contract có filter; **5 bảng Office KHÔNG có**. Nếu tác giả dựa vào filter thì PE đúng còn 5 module kia sai âm thầm. Đây là chi tiết dễ sai nhất trong cả file và nó **đúng**.
2. **Bảy bảng opinion được quét đủ**, khớp tuyệt đối với 7 cấu hình FK Restrict (F13 Phép 3).
3. **Hai cờ đổi-đường-đi xử lý kiểu ECHO** (`bool?`, lệch ⇒ 409, **không bao giờ ghi**) — cộng ca test `F13b_OmittedFinalizeEcho` chốt rằng bỏ trống thì **giữ nguyên**, không bị `?? false` xoá ngầm.
4. **`CeoApprovalThreshold` cố tình KHÔNG dùng "null = bỏ qua"**, kèm lý do viết ngay tại chỗ (`:416-422`): null **là** một giá trị có nghĩa. Đây là chỗ 9/10 lần người ta làm sai.
5. **Phép thử T1 siết mạnh hơn set-equality** (Order từng dòng bất biến), và có test `F13_SwapStepOrders` chứng minh set-equality một mình sẽ lọt.
6. **`F12` đo bằng hành vi thật** với nửa contrast (trước Forbidden / sau duyệt được) — chống xanh-rỗng đúng cách.
7. **Migration là dòng thứ 9 của một khuôn đã chạy 4 lần**, không phải cấu trúc mới (F9 Phép 3).
8. **Comment tự khai giới hạn** (`:786-789`: 5 module Office chưa có changelog) thay vì giấu. Khai giới hạn đúng chỗ đáng giá hơn một đoạn mã đẹp.
## Bảng FLAG
| # | Mức | Nội dung | Vị trí |
|---|---|---|---|
| F1 | HIGH | `ExecuteDelete` ngoài transaction, trước `SaveChanges` ⇒ mất chữ ký không hoàn tác | `AdminFeatures.cs:683``:822` |
| F2 | HIGH | Purge xoá CỨNG chữ ký của phiếu mới **xoá mềm** ⇒ phá "khôi phục phiếu" (đợt 2) | `AdminFeatures.cs:889-915` |
| F3 | HIGH | Endpoint PUT **không có dây FE** ⇒ mục tiêu §F.0 không dùng được trong UAT | `Controller.cs:45``…V2Page.tsx:589` |
| F4 | MED | 7 cờ ghi tuyệt đối ⇒ payload thiếu field = **tắt cờ im lặng** | `AdminFeatures.cs:424-440`, `:720-726` |
| F5 | MED | `DetachTrackedOpinionsOn` quét theo tên cột: bắt thừa + có thể nuốt `Added` | `AdminFeatures.cs:936-949` |
| F6 | LOW | `FullName ?? Email` ra null ⇒ 403 lòi dấu phẩy trống / liệt kê thiếu người | `PeWorkflowService.cs:709-714` |
| F7 | INFO | Lộ tên người duyệt: **không phải rò rỉ mới** (GET đã công khai tên+email) | `Controller.cs:21-26` |
| **F8** | **HIGH** | **2/3 file migration UNTRACKED ⇒ commit `-a` hạ toàn bộ 7 module** | `git status` |
| **F9-LOW** | LOW | Tên migration `…Pe…` gây hiểu nhầm (cột dùng chung ≥7 module) | migration filename |
| **F10a** | MED | Đổi **Mô tả** quy trình: ghi thẳng, không sinh vết changelog | `AdminFeatures.cs:698` |
| **F10b** | MED | Thêm người vào Cấp **đã qua** ⇒ màn hình hiện "Done" cho người chưa ký | `PeFeatures.cs:1135-1147` |
| **F10c** | LOW | Vết đổi phòng ban không ghi từ-đâu-sang-đâu | `AdminFeatures.cs:704` |
| **F11** | MED | Ca test `F16_…NoPeChangelog` **xanh giả** (0 phiếu PE ⇒ không phân biệt được) | `UpdateAwDefinitionTests.cs:753` |
| **F13a** | MED | Vá NỬA — `ContractWorkflowService.cs:264` vẫn in GUID trần | `ContractWorkflowService.cs:264` |
**TOTAL = 14 FLAG (4 HIGH · 6 MED · 3 LOW · 1 INFO).**
## Khuyến nghị deploy
Đánh giá **trên giả định F1/F2/F3 đã được vá xong** (wave 5), và lead tự xác minh lại bằng build + test + grep trước khi đẩy.
Ngoài F1/F2/F3, **còn đúng một thứ tao coi là chặn deploy: F8** — nó không phải lỗi mã nên vá mã bao nhiêu cũng không chạm tới nó, mà hậu quả thì nặng nhất bảng (mọi module dùng schema V2 ngừng duyệt). Nó chỉ tốn một lệnh `git add` đích danh cộng một lệnh kiểm chứng sau commit.
Các flag còn lại (F4, F5, F6, F10a, F10b, F11, F13a) **không chặn** đợt 1: F4 và F5 chỉ nổ qua đường PUT mà hiện chưa client nào gọi; F6/F10c/F13a là chất lượng thông điệp; F10b cần thao tác admin bất thường; F11 là nợ test chứ không phải lỗi chạy.
**DEPLOY-CÓ-RỦI-RO** — mã sẽ an toàn sau khi vá F1/F2/F3, nhưng rủi ro lớn nhất còn lại nằm ở **thao tác commit**: phải `git add` đích danh 2 file migration đang untracked (F8), nếu không prod sẽ có model chờ một cột mà DB không bao giờ được tạo.
<!-- END reviewer-diff-dot1 · TOTAL=14 FLAG -->

View File

@ -0,0 +1,224 @@
# reviewer-spec-review — chấm đối-kháng SPEC `spec-pe-delete-approver.md`
> run `2026-07-27-S155-pe-delete-approver` · S3 · vai reviewer (READ-only, verdict-only)
> Quy ước: mọi FLAG đều có `file:line` mở được. Mọi trục PASS đều kèm **đã thử phá bằng cách nào**.
> Ghi TRONG LÚC LÀM (chống #53).
**VERDICT: PASS-WITH-FLAGS — 17 FLAG (4 HIGH / 8 MED / 5 LOW).**
Khung spec đứng vững: trích dẫn chịu lực đúng, mệnh đề trung tâm F.0 **không bác được**. Nhưng 4 lỗ HIGH sẽ cho ra bản cài đặt **hỏng hoặc hở** nếu giao thẳng cho hmw. H1/H2/H3 phải chốt **trước** khi bắt đầu code (H1 + H2 cần owner trả lời 1 câu; H3 là 2 dòng ràng buộc phải viết vào checklist).
---
## 🔴 HIGH
### H1 — Nới authz mà **không một ai** có quyền: checklist 7 vừa **phá chức năng đang chạy** vừa **giết luôn tính năng mới**
**Điều sai.** Checklist 7 gắn `[Authorize(Policy = "PurchaseEvaluations.Delete")]` lên action Delete, và spec kết luận *"0 key mới, 0 migration quyền"* (§B3 dòng 66). Nhưng spec **không kiểm ai đang có cờ `CanDelete`**.
**Bằng chứng đo được (Dev DB, `SolutionErp_Dev`, lệnh chạy thật):**
```sql
SELECT r.Name, p.CanDelete FROM Permissions p JOIN Roles r ON r.Id=p.RoleId
WHERE p.MenuKey='PurchaseEvaluations'
```
`Admin=1 · DeptManager=1` · **Accounting/AuthorizedSigner/CatalogManager/CostControl/Director/Drafter/Equipment/Finance/HrAdmin/Procurement/ProjectManager = 0** (11/13 role = 0).
Nguồn của con số đó nằm trong mã: `DbInitializer.cs:2515``bool canDelete = roleName == AppRoles.DeptManager; // chỉ TPB xóa nháp`. Và seeder thứ hai chỉ nâng `CanRead/CanCreate`, **không bao giờ chạm `CanDelete`** (`DbInitializer.cs:2120-2126`), lại **skip-if-exists** (`:2118`) ⇒ sửa seeder cũng **không** vá được prod.
Tầng policy **không kế thừa**: `MenuPermissionHandler.cs:41-51` lọc `p.MenuKey == req.MenuKey` **khớp chính xác** rồi `AnyAsync(p => p.CanDelete)`. Cơ chế kế thừa mà spec dẫn (`GetMyMenuTreeQuery:66-72`) chỉ là **display-layer** — đúng như spec nói, nhưng nó **không** cấp quyền API.
**Hai hệ quả cụ thể:**
1. **Regression trên chức năng đang chạy:** nút "Xóa phiếu" bản Nháp (`fe-user/src/components/pe/PeDetailTabs.tsx:445` + `:460`) hôm nay chạy được vì endpoint **không có policy**. Gắn policy vào ⇒ **role `Drafter` (người soạn phiếu) mất quyền xoá nháp của chính mình**.
2. **Tính năng mới chết ngay:** approver trong ảnh UAT thuộc Phòng Cung ứng ⇒ role `Procurement`, `CanDelete=0`**403** dù đúng lượt và đúng cờ F6.
Test 25 chỉ kiểm chiều **403** nên nó sẽ **xanh trong cả hai kịch bản hỏng trên**. Không có test chiều thuận (đủ quyền ⇒ 204).
**Đề xuất sửa.** (a) Thêm mục checklist: cấp `CanDelete` cho tập role đích + ghi rõ đường cấp (tick tay `/system/permissions` trên prod **hay** seeder có upgrade-path như khối `:2120-2126`); (b) chốt với owner: quyền xoá-nháp (Drafter) và quyền xoá-khi-đang-duyệt (approver) **dùng chung 1 cờ hay tách**; (c) thêm test happy-path 204 cạnh test 25.
---
### H2 — `AllowApproverDelete` **per-ROW hay per-CẤP**: spec chốt "per-Cấp" nhưng giấu hệ quả, và mã có sẵn **cả hai** cách đọc
**Điều sai (2 lớp).**
*Lớp 1 — mơ hồ thi hành.* Nhà của cờ là `ApprovalWorkflowLevel`, mà **1 row = 1 NGƯỜI** (`ApprovalWorkflow.cs:83-88`, `ApproverUserId` là field của row). "Cấp" chỉ là **tập row cùng `Order`**. Vậy `AllowApproverDelete` của "Cấp" phải đọc bằng cách nào? Repo đang dùng **cả hai** cho cờ anh em F5:
- per-ROW: `PurchaseEvaluationWorkflowService.cs:859``if (matchingLevel.AllowApproverFinalize && applyLevelFinalize)`
- per-CẤP: `PurchaseEvaluationFeatures.cs:1182``g.Any(l => l.AllowApproverFinalize)`
⇒ hai người cài đặt sẽ ra **hai kết quả khác nhau** từ đúng câu chữ §B2 dòng 60 (*"Cấp đó có `AllowApproverDelete == true`"*).
*Lớp 2 — hệ quả chưa khai.* Đọc theo per-Cấp (`Any`) ⇒ **mọi người trong Cấp đều xoá được**. Ảnh prod (run.md dòng 74) ghi rõ Cấp 2 Phòng Cung ứng có **3 người** (Bùi Lê Thủy Trà, Trần Xuân Lưu, Lê Trần Đăng Trường). Owner chốt (1) là *"Người đứng đầu phòng đc xóa khi đến lượt họ"* = **1 người**. Spec §2 có khai "đổi nghĩa" nhưng chỉ khai vế *"thành cấp nào admin tick"***không** khai vế *"và như vậy N người cùng Cấp đều xoá được"*. Đó là hệ quả trực tiếp lên quyền huỷ chứng-từ tài chính, phải nằm trên bàn.
**Đề xuất sửa.** Chốt bằng chữ trong spec + checklist: dùng `matchingLevel.AllowApproverDelete` (per-row, 1 người — sát ý owner) hay `pendingLevelGroup.Any(...)` (per-Cấp, N người). Nếu chọn per-Cấp thì thêm 1 dòng khai hệ quả "N người" cho owner duyệt lại.
---
### H3 — Endpoint "Đã xóa" thiếu **2 ràng buộc bắt buộc**; D3 kín về rò-accumulator nhưng **hở về rò-dữ-liệu**
**Điều sai.** Checklist 8 chỉ ghi *"handler RIÊNG list 'Đã xóa' với `IgnoreQueryFilters()` — tuân D3"*. Thiếu hai thứ:
1. **Thiếu `.Where(x => x.IsDeleted)`.** `IgnoreQueryFilters()` gỡ **toàn bộ** filter chứ không đảo nó ⇒ query trả **cả phiếu sống lẫn phiếu xoá**. Màn "Đã xóa" sẽ liệt kê mọi phiếu trong hệ thống. Không mục nào trong spec nói câu này; test 22 chỉ soi accumulator nên **không bắt được**.
2. **Thiếu tái lập IDOR-scope.** Handler list hiện hành có rào chặt: `PurchaseEvaluationFeatures.cs:596-617` — non-admin chỉ thấy phiếu khi (i) mình là Drafter, (ii) phase ∈ `eligiblePhases` theo role, hoặc (iii) mình là approver trong workflow đã pin. Handler MỚI dựng từ đầu **mặc định không có gì**. Kết quả: mọi user đăng nhập đọc được **toàn bộ phiếu đã xoá của mọi dự án**, kèm `BudgetPeriodAmount` + tên gói thầu + NCC (đúng bộ field của `PurchaseEvaluationListItemDto`).
Đây là 2 "đường rò thứ 4/thứ 5" mà 3 điều cấm của D3 **không phủ** — D3 chỉ chống rò **sang accumulator**, không chống rò **ra người dùng**.
**Đề xuất sửa.** Viết thẳng vào checklist 8: `IgnoreQueryFilters().Where(x => x.IsDeleted)` **∧** sao chép nguyên khối IDOR `:596-617` (hoặc siết hơn: chỉ Drafter của phiếu + approver của workflow pin + Admin). Thêm 2 test: (a) list "Đã xóa" **không chứa** phiếu sống; (b) user ngoài phạm vi **không thấy** phiếu đã xoá của người khác.
---
### H4 — Lệnh Update quy trình (F-1…F-11) **không khai policy authz**, trong khi chính nó là **cổng cấp quyền**
**Điều sai.** F-1 chỉ nói *"`UpdateAwDefinitionCommand` mới"*. Không dòng nào nói endpoint mới phải mang policy. Mọi verb GHI hiện có của controller này đều có: `ApprovalWorkflowsV2Controller.cs:29` (POST), `:40` (PATCH user-selectable), `:48` (DELETE) — cả ba đều `[Authorize(Policy = "Workflows.Create")]`; class chỉ `[Authorize]` trần (`:18`).
Vì sao nghiêm trọng hơn một endpoint thường: hai thao tác mà F-1 **cho phép** đều là hành vi **cấp quyền**:
- op #1 "thêm người vào Cấp có sẵn" ⇒ tự thêm mình vào `pendingLevelGroup` ⇒ lọt guard `PurchaseEvaluationWorkflowService.cs:702-708`**duyệt được phiếu người khác**;
- op #2 "bật/tắt cờ `Allow*`" ⇒ **gồm chính F6** ⇒ tự cấp quyền **xoá** phiếu đang duyệt.
Thiếu 1 dòng attribute = leo thang đặc quyền cho mọi tài khoản đăng nhập.
**Đề xuất sửa.** Ghi rõ trong F-1: `[Authorize(Policy = "Workflows.Create")]` (hoặc key riêng nếu owner muốn tách), + 1 test 403 cho user thường, + cân nhắc ghi vết thay-đổi-quy-trình (spec đòi changelog cho xoá phiếu ở B4 nhưng **không** đòi vết cho việc sửa quyền — bất đối xứng).
---
## 🟠 MED
### M1 — Quy tắc F.2 **không đủ chặt**: 3 phản ví dụ giữ nguyên cả 2 tập Order mà **vẫn hỏng phiếu đang chạy**
Phát biểu đang xét: *"AN TOÀN ⟺ KHÔNG đổi tập `{Step.Order}` và tập `{Level.Order}`"* (§F.2 dòng 208).
**(a) Cờ `AllowApproverFinalize` (F5) bật/tắt giữa chừng — đổi ĐIỂM KẾT THÚC của phiếu đang chờ.**
`PurchaseEvaluationWorkflowService.cs:859-878`: khi `matchingLevel.AllowApproverFinalize` bật, người duyệt tại cấp đó **kết thúc luôn** (`Phase = DaDuyet`, `:866`, kèm `ApplyBudgetSnapshotOnFinalizeAsync` `:865` = **đóng băng số tiền**). Bật cờ này cho một Cấp mà phiếu **chưa đi tới** ⇒ phiếu đang chờ nay **không lên Ban Giám đốc nữa**. Tắt ⇒ ngược lại.
Spec xếp **mọi** cờ `Allow*` vào ✅ AN TOÀN (#2) *"thuộc tính row, 0 đụng Order"*, trong khi xếp `CeoApprovalThreshold` vào ❓HỎI OWNER (#12) với lý do *"đổi nghĩa quyết định đang chờ"*. **Cùng một lý do, hai kết luận.** F5 còn nặng hơn threshold: threshold vẫn cần CCM chủ động tích (`:881-901`), F5 thì đọc thẳng ở `:859`.
**(b) `matchingLevel` fallback `pendingLevelGroup.First()` — thao tác #1 "thêm người" chạm đúng chỗ này.**
`:733-734`: `var matchingLevel = pendingLevelGroup.FirstOrDefault(l => … l.ApproverUserId == actorUserId) ?? pendingLevelGroup.First();`. Nhánh `.First()` chạy khi **Admin duyệt thay**. Thứ tự bên trong nhóm do `OrderBy(l => l.Order)` (`:689`) quyết — mà các row **cùng `Order`** thì **không có tie-breaker** ⇒ thứ tự do DB trả về. Thêm/bớt row cùng Cấp có thể đổi "row đầu tiên" ⇒ (i) chữ ký admin gắn sang **Level row của người khác**, (ii) UPSERT `LevelOpinion` khoá theo `(PE, ApprovalWorkflowLevelId)` (`:736-738`) ghi vào row khác. Spec có nhắc `First()` ở nguồn (sub-invest-wfver-4 F9) nhưng **không** đưa vào bảng 13 thao tác — đúng chỗ trục 3 nghi ngờ.
**(c) Thêm người vào Cấp **ĐÃ QUA** ⇒ lịch sử duyệt hiển thị SAI.**
`ComputeLevelStatus` (`PurchaseEvaluationFeatures.cs:1135-1148`) thuần con-trỏ: `stepIdx0 < currentIdx → "Done"`. Row mới thêm vào một Cấp đã qua sẽ render **"Done"** dù người đó **chưa từng ký****không có** `LevelOpinion` nào. Ở phiếu `DaDuyet` còn tuyệt đối hơn: `:1137` `if (isTerminalDone) return "Done"`. Bảng 13 thao tác chỉ soi đường duyệt (`:689-702`), không soi 4 read-site hiển thị cho ca **thêm**.
**Đề xuất sửa.** Tách #2 thành 2 dòng: cờ **không đổi luồng** (F1 4 mode, F3, F4) = ✅; cờ **đổi luồng/quyền** (F5 `AllowApproverFinalize`, F6 `AllowApproverDelete`) = ⚠️/❓ cùng hạng với `CeoApprovalThreshold`. Thêm 2 dòng vào bảng: "thêm người vào Cấp ĐANG CHỜ khi có khả năng Admin-duyệt-thay" và "thêm người vào Cấp ĐÃ QUA". Bổ sung câu: quy tắc F.2 chỉ bảo đảm **con trỏ không dịch**, KHÔNG bảo đảm **ngữ nghĩa/hiển thị không đổi**.
### M2 — Bảng 13 thao tác **bỏ sót 2 trường** admin sửa được mà vẫn giữ nguyên 2 tập Order
`ApprovalWorkflow.Code` (`ApprovalWorkflow.cs:23`) và `ApplicableType` (`:25`).
- Đổi `Code` ⇒ vỡ chuỗi version vì `nextVersion = MAX(Version) WHERE Code = request.Code` (`ApprovalWorkflowV2AdminFeatures.cs:325-328`) — bản kế tiếp có thể tụt về v1 hoặc đụng version cũ.
- Đổi `ApplicableType` ⇒ (i) phiếu đang pin bỗng chạy workflow của **module khác**; kiểm tra khớp loại **chỉ xảy ra lúc pin** (`PurchaseEvaluationFeatures.cs:258-265`), không có re-validate; (ii) vỡ bất-biến "chỉ 1 active mỗi type" vì khối hạ-active lọc theo `ApplicableType` (`:331-334`).
Cả hai **giữ nguyên** `{Step.Order}``{Level.Order}` ⇒ theo chữ của F.2 chúng là "AN TOÀN". Nếu F-1 là allow-list đóng (chỉ #1#2#3) thì thực thi vẫn an toàn, nhưng **bảng phân loại là tài liệu người sau dùng để mở rộng** ⇒ phải khai tường minh 2 trường này thuộc NGOÀI-PHẠM-VI/PHÁ-VỠ.
### M3 — Nợ N1 **không trung tính** lượt này: nó ăn thẳng vào F-5 của chính spec
Xoá mềm ⇒ cascade không chạy (`AuditingInterceptor.cs:56-62` đổi `Deleted → Modified`) ⇒ `PurchaseEvaluationLevelOpinions` của phiếu đã xoá **ở lại DB****vẫn Restrict** FK về `ApprovalWorkflowLevel` (`PurchaseEvaluationLevelOpinionConfiguration.cs:25-28`).
F-5 lại chốt: *"chặn khi row đã có `LevelOpinion`"*. ⇒ Sau khi tính năng chạy, admin sẽ bị **chặn bỏ người khỏi Cấp vì một phiếu đã bị xoá** — chặn thật, không phải cảnh báo.
Quan trọng: **trước** lượt này rác chỉ sinh từ phiếu Nháp (hiếm có `LevelOpinion`); **sau** lượt này mọi phiếu bị xoá đều ở `ChoDuyet`**chắc chắn** đã có `LevelOpinion` + `Approvals` + `Changelogs`. Tức lượt này **làm N1 tệ hơn theo đúng cái trục mà spec vừa dựng luật**.
**Đề xuất sửa.** F-5 phải nói rõ: đếm `LevelOpinion` **loại trừ** opinion thuộc PE đã `IsDeleted` (hoặc khai nhận hành vi chặn-do-phiếu-xoá là chấp nhận được, kèm thông điệp lỗi nói đúng lý do).
### M4 — Test 21 phát biểu **sai số học** ⇒ không thi hành được như viết
Phiếu `ChoDuyet` chỉ nằm trong **2/4** phép cộng:
-`PrevSubmitted``PeBudgetAccumulator.cs:53-59` (`ChoDuyet || DaDuyet`);
-`PendingSelected``:114-117` (loại `{DangSoanThao, DaDuyet, TuChoi}` + có winner);
-`PrevSelected``:61-62` đòi `Phase == DaDuyet`;
-`PendingSubmitted``:107` **loại tường minh** `ChoDuyet`.
Test 21 viết *"cả 4 phép cộng lũy kế **giảm đúng**"*. Với 2 phép sau, delta đúng = **0**. Ai viết assert "giảm" cho cả 4 sẽ có test không thể xanh; ai viết assert "giảm" cho 2 và quên 2 kia sẽ mất chốt chặn hồi quy.
Ngoài ra `ComputePendingAsync` còn output thứ **5**: `PriorPes` (`:128-133`, gồm cả phiếu Nháp) cũng đổi sau khi xoá — spec không nhắc.
**Đề xuất sửa.** Viết lại test 21 thành 4 assert có hướng: `PrevSubmitted ↓`, `PendingSelected ↓`, `PrevSelected` **không đổi**, `PendingSubmitted` **không đổi**, + `PriorPes` mất đúng 1 phần tử.
### M5 — §E1 **nói mạnh hơn bằng chứng**, và checklist 14 ⟂ 15 mô tả **hai nút khác nhau**
- Sự thật đo được: `PeWorkflowPanel` nhận **đúng 3 prop** `{evaluation, readOnly, onApproved}``fe-user/src/components/pe/PeWorkflowPanel.tsx:31-43`. **Không có** `onDelete`, không có mutation xoá trong file.
- `onDelete` được truyền cho **`PeDetailTabs`**, không phải cho panel: `PurchaseEvaluationsListPage.tsx:570-575``:665-670` (đúng số dòng spec dẫn, **sai component**).
- §E2 lại đặt nút mới vào `PeWorkflowPanel.tsx:458-506`.
⇒ Câu *"Dây đã nối sẵn"* (§E1) **không đúng cho chỗ đặt nút**. `sub-invest-fe-2` đã khai đúng ở PH-14 mục 4 (*"phải nâng mutation `del` lên rồi truyền prop mới cho `PeWorkflowPanel`, hoặc tạo mutation riêng"*) — spec **bỏ mất caveat** này.
Kèm theo: checklist **14** đặt nút trong panel; checklist **15** bảo *"bỏ chặn `readOnly` cho riêng nút Xóa"* — nhưng nút bị chặn đó nằm trong `PeDetailTabs` với gate **3 điều kiện** `mode === 'workspace' && canEditPhase && !readOnly` (`PeDetailTabs.tsx:445`) **cộng** `phase === DangSoanThao` (`:460`); màn Duyệt truyền `mode` mặc định `'detail'``canEditPhase=false``ChoDuyet`. ⇒ "bỏ chặn readOnly" **không đủ**, và hai mục sẽ đẻ ra **2 nút Xoá** hoặc **1 nút không nối dây**.
**Đề xuất sửa.** Chốt 1 chỗ đặt nút duy nhất; ghi rõ prop/mutation phải thêm; xoá hoặc viết lại mục 15 cho khớp mục 14.
### M6 — Checklist 27 bảo sửa row **`Policies` + `Menu keys`** trong STATUS.md — **hai số đó KHÔNG được đổi**
`docs/STATUS.md:450` ghi rõ `Policies = |MenuKeys.All| × |MenuKeys.Actions| = 54 × 4 = 216` (**DERIVED**, `governance-detectors.ps1` resolve canonical từ đúng row này) và `:451` `Menu keys = 54` (đếm `MenuKeys.All`).
Key mới "Đã duyệt"/"Đã xóa" là `Pe_*` sinh qua **factory** (`MenuKeys.cs:134-141`), **không** nằm trong `All` (`:147-165` — đã đọc toàn bộ, `Pe_*` vắng mặt; đây cũng chính là điều spec dùng để loại `[Authorize(Policy="Pe_….Delete")]`).
⇒ Cả 2 số **giữ nguyên 54 / 216**. Làm theo mục 27 = ghi số sai vào chỗ canonical đang bị detector soi.
**Đề xuất sửa.** Mục 27 chỉ giữ "Mig mới"; nếu muốn ghi số mục menu thì ghi ở chỗ khác và nói rõ đó là số row `MenuItems`, không phải `MenuKeys.All`.
### M7 — Thứ tự thi công: văn xuôi và checklist **đá nhau**
§F.0 chốt *"A-E KHÔNG CHẠY nếu thiếu F"*, nhưng §4 CHECKLIST xếp A-E (mục 1→28) **trước**, còn F (F-1→F-16) nằm ở PHẦN II **cuối file**, không có mốc thứ tự nào. Người làm top-down sẽ dựng đúng thứ tự mà spec vừa cảnh báo là hỏng. Migration F6 (mục 2) đứng trước lệnh Update (F-1) cũng đúng, nhưng **phải nói ra**.
**Đề xuất sửa.** 1 dòng thứ-tự cứng đầu §4: `mục 1-2 (schema F6) → F-1…F-4 (lệnh Update + validator) → 3-8 → 9-13 → 14-20 → test`.
### M8 — Mục 3 và F-11 là **cùng một việc, hai độ sâu** ⇒ mục 3 sẽ rơi đúng bẫy F-11 cảnh báo
Mục 3: *"DTO + Designer admin: tick F6 per-Cấp (mirror chỗ tick F5)"*. F-11: liệt **5 dây FE** `:54 / :110 / :159 / :177 / :605` + cảnh báo 🔴 *"thiếu `:605` = tick xong KHÔNG gửi lên BE"* + `fe-user:282` + **4 neo BE** `:41 / :230 / :188 / :368`.
Ai làm theo mục 3 mà không lật xuống PHẦN II sẽ sót đúng cái `:605`. (Neo BE của F-11 kiểm chứng được: `ApprovalWorkflowV2AdminFeatures.cs:230` `AllowApproverFinalize` trong `CreateAwLevelInput`, `:368` map vào entity — khuôn đúng.)
**Đề xuất sửa.** Gộp mục 3 vào F-11 hoặc trỏ chéo tường minh.
---
## 🟡 LOW
### L1 — Tiêu đề F.0 mạnh hơn thân bài
*"A-E KHÔNG CHẠY nếu thiếu F"* → chính xác phải là *"A-E không **tới được phiếu đang treo**"*. Phiếu **tạo mới sau** khi kích hoạt v(n+1) vẫn có cờ và vẫn xoá được. Thân bài (dòng 195-196) nói đúng điều này; chỉ tiêu đề quá rộng. Giữ nguyên kết luận thứ-tự-thi-công, chỉ sửa chữ để người sau không suy rộng sai.
### L2 — §1 bỏ mất dữ kiện đối trọng cho vế "quay lại không được"
Vế lũy kế của spec **đúng** (đã kiểm, xem trục 2). Nhưng ở `TraLai`, người soạn **được đổi hạng mục**: `UpdatePurchaseEvaluationDraftCommand.WorkItemId` (`PurchaseEvaluationFeatures.cs:221`) + write `:301-302`, guard cho phép `DangSoanThao ∥ TraLai` (`:248-250`). Tức "bấm sai gói thầu" **có** đường tự sửa (dời lũy kế sang gói đúng), chỉ là vẫn ăn số trong lúc chờ sửa. Không đổi quyết định owner, nhưng nên khai để §1 không bị đọc thành "không còn cách nào khác".
### L3 — 2 trích dẫn lệch biên 1 dòng (nội dung đúng)
- `DbInitializer.cs:1893-1903`: nhánh `if (existingItems.TryGetValue…)` bắt đầu đúng `:1893`, nhưng `continue;` nằm ở **`:1904`** (đóng nhánh `:1905`). Nội dung "chỉ update Order rồi continue, KHÔNG đụng Label" **đúng 100%** (comment `:1897-1898` tự khai).
- `:1917-1923`: `var labelBackfill = …` khai ở **`:1918`**, 4 entry `:1920-1923`, đóng `:1924`. Dòng `:1917` là comment. Vô hại nhưng nên chỉnh cho khớp.
### L4 — F-12 bị dán nhãn "bài kiểm chứng F.0" nhưng chỉ chứng **chiều thuận**
F-12 chứng "update tại chỗ ăn ngay vào phiếu đang chạy". Mệnh đề F.0 còn nửa kia: "POST version mới **không** tới phiếu đang treo". Không có test cho nửa đó, mà nó rẻ: tick F6 bằng POST ⇒ phiếu treo **vẫn** bị từ chối xoá. Thêm 1 test này thì F.0 mới có răng.
### L5 — N4 ("`TuChoi` là nhánh chết") đúng về transition nhưng đừng dọn vội
Guard `PurchaseEvaluationWorkflowService.cs:101-106` chặn **đường đi tới** `TuChoi`, nhưng chính comment `:99` ghi *"Phase TuChoi + phiếu TuChoi cũ GIỮ display/filter"***phiếu `TuChoi` cũ vẫn tồn tại**, và nhánh allow-list xoá vẫn phục vụ chúng. Dọn nhánh = mất quyền xoá phiếu lịch sử. Nên ghi rõ trong N4.
---
## ✅ ĐIỂM SPEC LÀM ĐÚNG — đừng sửa hỏng
1. **Trích dẫn chịu lực: 16/16 mẫu ĐÚNG DÒNG + ĐÚNG NỘI DUNG.** Gồm `PeBudgetAccumulator.cs:53-59` · `:104-126` · `:114-126` · `PurchaseEvaluationFeatures.cs:1404-1406` · `:149` · `:249-251` · `:291` · `ApprovalWorkflowV2AdminFeatures.cs:325-328` · `:331-334` · `:336` · `PurchaseEvaluationWorkflowService.cs:674` · `:683` · `:686` · `:694-695` · `:705` · `:101-106` · `PurchaseEvaluationsController.cs:146-151` · `MenuKeys.cs:147-165` · `Program.cs:82-89` (khớp **chính xác** khối `foreach menu → foreach action → AddPolicy`) · `DbInitializer.cs:2092-2096` + `:2492-2496` (**cả hai đúng từng dòng** — 5 dòng `Add(...)` mỗi bên) · `PurchaseEvaluationConfiguration.cs:84` · `ApprovalWorkflow.cs:81-82`.
2. **E3 md5 kiểm lại bằng lệnh thật:** `md5sum` cho **cùng** `02671be6334110028c55fe82f5e70dee` ở cả `fe-user``fe-admin`, **942 dòng** — đúng y spec. Luật "mirror 2 app thay cho 4-place" là luật đúng cho việc này.
3. **B3 chọn policy root thay `Pe_*` là ĐÚNG** và lý do đúng: `Pe_*` thật sự vắng khỏi `MenuKeys.All` (đọc trọn `:147-165`), mà `Program.cs:82-89` chỉ đăng ký policy cho phần tử của `All`.
4. **Bẫy `labelBackfill` là THẬT và quan trọng:** vòng upsert gặp key cũ chỉ sửa `Order` rồi `continue`, comment `:1897-1898` tự khai *"KHÔNG đụng Label / ParentKey / Icon"*. Sửa `tree.Add` mà không thêm `labelBackfill` sẽ **không đổi được nhãn** trên DB đã seed. Giữ nguyên cảnh báo 🔴 ở mục 11.
5. **D3 (3 điều cấm + "handler riêng, KHÔNG thêm cờ `includeDeleted`") là đúng và đáng giữ nguyên chữ** — đã kiểm: `IgnoreQueryFilters()` là toán tử trên một `IQueryable`, 2 hàm accumulator đều dựng `peers` mới (`:49-51`, `:100-102`), nên không rò chéo. (Lỗ của D3 nằm ở chỗ khác — xem H3.)
6. **F-3 (CẤM delete-then-insert, diff theo `Level.Id`) và F-9 (usage-check trước khi xoá version)** là hai điều đúng và bắt buộc: FK `Restrict``PurchaseEvaluationLevelOpinionConfiguration.cs:25-28` + `PurchaseEvaluationConfiguration.cs:67-73`, và handler xoá hiện tại (`ApprovalWorkflowV2AdminFeatures.cs:406-418`) **thật sự** `Remove()` thẳng, 0 usage-check, đúng như nợ tự khai `:400-402`.
7. **Khảo cổ S60 (`6db195d`) và mắt xích "guard tự trỏ sang Xóa phiếu"** — kiểm lại trên đĩa: `PurchaseEvaluationWorkflowService.cs:101-106` đúng nguyên văn. Đây là phần chẩn-đoán mạnh nhất của spec.
---
## 🎯 THÁCH-CLEAN: đã thử phá bằng cách nào
### Trục 2 — mệnh đề F.0: **KHÔNG bác được** (6 đường tấn công, đều tắc)
| # | Đường thử bác | Kết quả |
|---|---|---|
| 1 | Có write-site nào khác ghi `PE.ApprovalWorkflowId` (job/seeder/admin action)? | `grep -rn "ApprovalWorkflowId\s*=" --include=*.cs src/Backend \| grep -v Migrations` → PE **đúng 2** write-site: `:149` (create) + `:291` (update). Các module Office (`LeaveOtApprovalFeatures.cs:238/:509/:537/:702`, `ProposalFeatures.cs:277/:339`, `TravelVehicleApprovalFeatures.cs:189/:549/:774/:802`) **có** re-pin — nhưng **không phải PE**. Mắt xích 4 đứng. |
| 2 | Có migration/`Sql()` nào backfill lại con trỏ workflow? | `grep -rniE "ApprovalWorkflowId" Migrations/*.cs \| grep -iE "UPDATE\|Sql\("`**0 hit**. `grep "Sql(" Migrations/*.cs \| grep -i purchaseeval`**0 hit**. Đứng. |
| 3 | Có chỗ nào phiếu resolve workflow theo `Code`+`IsActive` thay vì Id? | `ApproveV2Async` `:671-675` chỉ `w.Id == awId`. Inbox `:832-836` lấy `wfIds` từ chính phiếu. Badge list `:672-685` join `lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId`. Flow-tree dùng workflow đã pin. **Không có** đường `Code`/`IsActive`. Đứng. |
| 4 | Admin xoá bản cũ để ép phiếu rơi sang bản mới? | FK `Restrict` (`PurchaseEvaluationConfiguration.cs:67-73`) ⇒ nổ FK, không "rơi sang". Đứng. |
| 5 | Drafter sửa phiếu lúc `ChoDuyet` để re-pin? | Chặn `:248-250`. Đứng. |
| 6 | Trả lại → sửa → gửi lại có re-pin không? | **CÓ** (`:248-250` cho `TraLai`, write `:291`) — nhưng spec đã tự khai ở dòng 196 và đúng là **không giải được ca gốc** trong 1 bước. Chỉ làm yếu **tiêu đề**, không làm yếu kết luận (⇒ L1). |
**Kết luận F.0 ĐỨNG.** Thứ tự thi công "F trước" là đúng.
### Trục 1 — vì sao không cho FAIL: đã kiểm **từng** trích dẫn chịu lực bằng cách mở file, không đọc lại spec. Cách phá đã dùng: (a) so từng số dòng với `sed -n`/`Read` có đánh số; (b) đếm ngược thủ công 2 khối `DbInitializer` (2075→2096 và 2470→2496) để chắc 2 danh sách permission song song đúng dòng; (c) tính lại md5 thay vì tin con số spec chép. Lỗi duy nhất tìm được là **diễn giải** (M5), không phải số dòng.
### Trục 5 — an toàn/số liệu: đã thử phá bằng 4 câu hỏi
(i) "3 điều cấm D3 có đủ không" → **KHÔNG** (H3, 2 đường rò khác trục);
(ii) "nới allow-list rồi ai vẫn xoá được" → đo bảng `Permissions` thật (H1);
(iii) "test có răng không" → test 21 sai số học (M4), test 25 xanh-giả (H1), test 22 **có** răng cho đúng thứ nó tuyên bố;
(iv) "hoãn N1 có trung tính không" → **KHÔNG** (M3, đụng thẳng F-5).
### Trục 6 — trung thực: 3 chỗ nói mạnh hơn bằng chứng đã tìm được là M5 (dây FE), L1 (tiêu đề F.0), H2-lớp-2 (hệ quả "N người cùng Cấp"). Ngoài ra spec **giữ đúng** nhãn nguồn: những chỗ sub gắn `[SUY LUẬN]` (vd F10 "chỉ cần nới guard là số tự đúng") được spec trình bày kèm cơ chế query filter chứ không đội lốt `[CODE]`.
---
## 📌 Việc phải làm trước khi giao hmw (rút gọn)
1. **Owner trả lời 1 câu:** F6 đọc per-ROW (1 người) hay per-CẤP (N người)? → H2.
2. **Chốt đường cấp quyền `PurchaseEvaluations.Delete`** cho tập role đích + xác nhận không cắt quyền xoá-nháp của Drafter → H1.
3. **Thêm 2 ràng buộc vào checklist 8** (`.Where(IsDeleted)` + IDOR-scope) → H3.
4. **Thêm policy vào F-1** → H4.
5. Sửa bảng F.2 (M1/M2), sửa test 21 (M4), gỡ mâu thuẫn 14/15 (M5), bỏ "Policies/Menu keys" khỏi mục 27 (M6), thêm dòng thứ tự (M7).
<!-- END reviewer-spec-review · TOTAL=17 FLAG -->

View File

@ -0,0 +1,127 @@
# run — PE: nút XÓA phiếu ở màn DUYỆT (approver-side)
- **run-id:** `2026-07-27-S155-pe-delete-approver`
- **phiên:** S155 (phiên-LOGIC L7, window 2)
- **pipeline anh lệnh:** 2 Invest → file-chi-tiết + checklist → review spec → hmw Opus 5 MAX
- **mode:** RUN-TRACE (≥3 task) — sub ghi full-detail vào CHỈ `sub-<role>-<i>.md` của mình
## Nguồn yêu cầu (ảnh chat anh gửi — UAT thật, không phải giả định)
Phiếu mẫu: `PE/2026/A/046` · Duyệt NCC · dự án **FLOCK03** · hạng mục **MAT-16 "16 Mat Khác"** · gói thầu **"16 Mat - Băng cản nước"** · trạng thái **Đã gửi duyệt** · có cờ **GẤP (PRO)**.
Workflow đang chạy: Bước 1 (Phòng Cung ứng) — Cấp 2, NV duyệt **Bùi Lê Thủy Trà**, "Đến lượt bạn duyệt". Bước 2 có Cấp 2 "Duyệt thay CEO". Bước 3 Ban Giám đốc, kết thúc tại Cấp 2. `Lịch sử duyệt (1)`.
Khối **HÀNH ĐỘNG** hiện chỉ có: `✓ Duyệt` · `← Trả lại`**thiếu Xóa**.
Banner đang hiện: *"Bạn được phép chỉnh sửa Hạng mục / NCC / Báo giá (workflow bật mode Approver edit)"*.
**Lời user verbatim (Tra Sol):**
- "@Kenny Chỗ duyệt - tạo giúp em nút xóa với"
- "Vì mấy bạn bắt sai cái gói thầu" → "nên phải cho quyền xóa cái phiếu đó luôn"
- "chứ lỡ bấm sai cái gói thầu / quay lại không được / **phải xóa thì nó mới ko có lũy kế lên**"
**Lời anh (owner) verbatim:** "Hic hôm trước có cái chỗ cho huy / mọi người nói là ko cần cái đó / giờ lại cần"
⇒ 🔴 giả-thuyết cần KIỂM: đường xóa/thu-hồi **đã từng tồn tại rồi bị gỡ**. Nếu đúng → khôi phục rẻ hơn dựng mới, và phải tìm ra **lý do gỡ** để không tái phạm.
## Vì sao đây là lỗi SỐ LIỆU, không phải UX
CLAUDE.md S134: `ComputePendingAsync` complementary — **phiếu ChoDuyet có winner được tính vào lũy kế TẠM TÍNH**. Phiếu bấm sai gói thầu đang treo ở bước duyệt ⇒ **đang ăn ngân sách hạng mục sai**. "Trả lại" đẩy về `TraLai=98` chứ không triệt tiêu ⇒ số tạm tính vẫn lệch. Đó chính là điều Tra Sol mô tả bằng chữ "lũy kế lên".
## Stages
- [ ] **S1 — 2 Invest song song** (BE-slice + FE/authz-slice) → `sub-invest-be-1.md` · `sub-invest-fe-2.md`
- [ ] **S2 — file chi tiết + checklist** (lead tổng hợp) → `spec-pe-delete-approver.md`
- [ ] **S3 — review spec** (reviewer, adversarial)
- [ ] **S4 — hmw Opus 5 MAX** thực thi
## Ràng buộc mang theo mọi stage
- Governance L7 **vẫn treo** 22 FLAG chờ (42)(43)(44) — việc này **song song**, KHÔNG đóng thay.
- Soft delete là quy ước sẵn có (`AuditableEntity`: `IsDeleted`/`DeletedAt`/`DeletedBy`) — spec phải nói rõ dùng cái này hay thu-hồi-về-Nháp.
- Quyền = **2 tầng độc lập** (bài học S118 / gotcha #82): display-layer menu `CanRead` ⟂ API-authz `[Authorize(Policy)]`. Ẩn nút ≠ đóng API.
- Xóa phải có **vết** (changelog/audit) — phiếu đã có `Lịch sử duyệt (1)`.
## taskList snapshot
1. invest BE: entity/state-machine/endpoint-xóa/authz/lũy-kế/changelog + git-khảo-cổ "chỗ cho huy"
2. invest FE: màn duyệt (component khối HÀNH ĐỘNG) + permission matrix `Pe_*` + nơi PE đang xóa được (nếu có) + dấu vết UI đã gỡ
---
## 🔴 OWNER CHỐT (2026-07-27, anh trả lời 3 câu + ảnh menu)
**(1) AI được xóa:** *"Người đứng đầu phòng đc xóa khi đến lượt họ."* → 2 điều kiện **AND**: là người đứng đầu phòng ∧ đang tới lượt duyệt của họ. CHẶT HƠN "ai tới lượt cũng xóa được".
**(2) Kiểu xóa:** *"Xóa mềm."* → dùng đúng cơ chế `AuditableEntity.IsDeleted` sẵn có (qua `AuditingInterceptor`), KHÔNG dựng mới.
**(3) Ngữ nghĩa + phiếu đã có lượt duyệt:** *"Tính năng xóa mềm này sinh ra để phục vụ chuyện đó mà, vì nó đang trong quá trình duyệt và đang ăn lũy kế nên phải có nút xóa -> nhưng xóa là chuyển trạng thái đưa xuống mục đã xóa và ko ăn lũy kế nữa."*
**KHÔNG thêm rào cho phiếu đã có `Lịch sử duyệt (n)`** — đang-duyệt CHÍNH LÀ ca cần cứu. Câu hỏi "đã duyệt rồi có xóa được không" = **ĐÃ TRẢ LỜI: CÓ**.
⇒ Xóa = chuyển trạng thái + xuất hiện ở **mục "Đã xóa"** + **rớt khỏi lũy kế**.
**(4) 🆕 RESTRUCTURE MENU (ảnh, ghi chú đỏ chỉ vào mục menu "Duyệt"):**
```
Chỗ này:
Duyệt -> Đang duyệt.
Thêm :
Đã duyệt.
Đã xóa.
```
Menu hiện tại dưới `1. Duyệt Nhà Cung Cấp - Thầu phụ (NCC-TP)`: `Luồng duyệt` · `Danh sách` · `Thao tác` · `Duyệt`.
Menu sau khi sửa: `Luồng duyệt` · `Danh sách` · `Thao tác` · **`Đang duyệt`** (đổi tên từ `Duyệt`) · **`Đã duyệt`** (MỚI) · **`Đã xóa`** (MỚI).
URL màn hiện tại: `/purchase-evaluations?type=1&pendingMe=1` · tiêu đề "Duyệt NCC — Chờ duyệt (8)" · lọc cố định `Đã gửi duyệt`.
### 🔴 Nghịch lý phải xử ở (4) — nối thẳng vào F9/F10 của sub BE
Global filter `HasQueryFilter(x => !x.IsDeleted)` (`PurchaseEvaluationConfiguration.cs:84`) + `IgnoreQueryFilters` **0 hit toàn backend** là thứ khiến phiếu xóa **tự rớt khỏi 4 phép cộng** ⇒ cho không ý (3).
NHƯNG cùng filter đó làm phiếu xóa **vô hình với MỌI query** ⇒ mục "Đã xóa" **không thể** dựng nếu không mở `IgnoreQueryFilters()`.
⇒ Ràng buộc spec: mở `IgnoreQueryFilters()` cho **ĐÚNG 1 endpoint list "Đã xóa"**, KHÔNG đụng accumulator, KHÔNG mở diện rộng. Đây sẽ là **chỗ đầu tiên trong toàn backend** dùng `IgnoreQueryFilters` ⇒ đáng 1 dòng checklist riêng + test.
### ❓ Chưa giải: "người đứng đầu phòng" map vào đâu trong schema?
Ảnh cho thấy 1 Cấp có NHIỀU người (Phòng Cung ứng — Cấp 2: Bùi Lê Thủy Trà, Trần Xuân Lưu, Lê Trần Đăng Trường). 3 cách hiểu khả dĩ, ra 3 đoạn code KHÁC nhau:
(a) Cấp cao nhất trong Bước (Level order) · (b) field trưởng phòng trên `Department` (Mig 51 có `ParentId` org-tree) · (c) role/permission riêng.
⇒ giao sub invest #3 tìm, CẤM đoán.
## Stages (cập nhật)
- [~] S1 — 2 Invest BE/FE: đã cứu từ đĩa Q1-Q3 (BE) + Q1,Q2,Q4 (FE); **cả 2 dính #53**, đã SendMessage-resume làm nốt
- [ ] S1-bis — Invest #3 lát cắt MỚI: menu restructure + "Đã xóa" list + "người đứng đầu phòng"
- [ ] S2 — spec + checklist · [ ] S3 — reviewer · [ ] S4 — hmw Opus 5 MAX
---
## 🔴 OWNER CHỐT — BỔ SUNG (2026-07-27, lượt 2)
**(5) Quyền xóa** = cờ **`AllowApproverDelete` per-Cấp-duyệt** (AskUser). Owner bác phương án `Department.ManagerUserId`.
🔴 KHAI: đổi nghĩa so với chữ đầu *"người đứng đầu phòng"* → thành *"cấp nào admin tick"*. Owner chọn với trade-off ghi rõ trên bàn. KHÔNG phải hiểu nhầm.
**(6) Màn "Đã xóa" = CHỈ XEM**, chưa làm khôi phục (AskUser).
**(7) 🆕 QUY TRÌNH DUYỆT — sửa TẠI CHỖ vs bắt buộc TẠO MỚI** *(anh, verbatim)*:
> *"Chỗ quy trình duyệt -> Cho thêm tính năng thêm người/điều chỉnh quyền -> Chứ ko cần phải tạo mới. Khi nào cần thay đổi quy trình duyệt thì mới bắt buộc tạo mới."*
⇒ Bài toán = **PHÂN LOẠI THAY ĐỔI** thành 2 lớp:
- **Lớp AN TOÀN (sửa tại chỗ, giữ nguyên Version):** thêm/bớt NGƯỜI trong một Cấp · điều chỉnh QUYỀN (các cờ F1-F6)
- **Lớp PHÁ VỠ (bắt buộc Version mới):** đổi CẤU TRÚC quy trình — thêm/bớt/đổi thứ tự Bước hoặc Cấp
🔗 **Đụng thẳng spec đang viết:** tick cờ **F6 `AllowApproverDelete`** = "điều chỉnh quyền" ⇒ thuộc **lớp AN TOÀN** ⇒ phải sửa được tại chỗ trên workflow đang có phiếu chạy. Nếu không thì mọi lần bật quyền xóa lại đẻ 1 version mới.
**Nền đã có (lead peek):** `ApprovalWorkflow.Version` (int, monotonic per `Code`) · `IsActive` · `ActivatedAt` · `IsUserSelectable`; note `ApprovalWorkflowV2AdminFeatures.cs:402` *"Sau UAT khi link với PE/Contract thật cần check usage trước khi delete"* ⇒ usage-check là việc còn treo.
- [x] S1-ter — Invest #4 XONG (25 phát hiện) — `sub-invest-wfver-4.md`
- [x] S2 — spec XONG 37.490 B — `spec-pe-delete-approver.md` (2 phần, ~50 mục checklist)
- [x] S3 — reviewer XONG `PASS-WITH-FLAGS 17 FLAG (4H/8M/5L)``reviewer-spec-review.md`; **F.0 KHÔNG bác được** sau 6 đường tấn công; lead đã vá **4H + 8M** vào spec
---
## 🔴 OWNER CHỐT — LƯỢT 3 (sau review)
| # | Câu | Anh chốt | Vá FLAG |
|---|---|---|---|
| 8 | Quyền xóa nháp vs xóa-khi-duyệt | **TÁCH 2 quyền riêng** | H1 |
| 9 | Cờ F6 per-người hay per-cấp | **PER-NGƯỜI** (`matchingLevel`, 1 người) | H2 |
| 10 | Cờ F5 `AllowApproverFinalize` | **PHÁ VỠ** (nhất quán ngưỡng CEO) | M1 |
| 11 | `CeoApprovalThreshold` | **PHÁ VỠ** | W3#12 |
| 12 | `Step.DepartmentId` | **AN TOÀN** | W3#13 |
| 13 | Cách chạy hmw | **CHIA 2 ĐỢT — F trước, A-E sau** | — |
🎁 **Lời giải gọn cho (8):** đường xóa **nháp GIỮ NGUYÊN không policy** (Drafter không mất gì) · đường xóa-khi-duyệt = **endpoint RIÊNG** mang policy **root có sẵn** `PurchaseEvaluations.Delete`**0 key mới**`Menu keys=54` / `Policies=216` **không đổi** (khớp M6).
## ĐỢT 1 (hmw lượt này) — hạng mục F + schema F6
**Phạm vi:** checklist mục **1-2** (schema F6 + migration) → **F-1…F-4 + F-1a/b/c** (lệnh Update + validator 2 phép thử + authz) → **F-5/F-5a** (hạng NỬA) → **F-6** (message 403) → **F-10** (vá comment sai) → **F-11** (5 dây tick F6) → test **F-12…F-16**.
**KHÔNG làm đợt này:** nút xóa FE · menu restructure · màn "Đã xóa" (mục 3-28) — đợt 2.
**Lý do phân đợt:** F.0 chứng minh A-E không chạy nếu thiếu F ⇒ đợt 1 xong là UAT được ngay, biết chắc nền đúng trước khi xây tiếp; blast radius ≥6 module nên tách để truy lỗi dễ.
- [ ] S4-đợt1 — hmw fan-out Opus 5 MAX

View File

@ -0,0 +1,317 @@
# SPEC — PE: xóa phiếu ở màn DUYỆT + restructure menu
> run `2026-07-27-S155-pe-delete-approver` · S2 (lead tổng hợp từ 3 Invest) · trạng thái: **CHỜ REVIEW**
> Nguồn: `sub-invest-be-1.md` (24 phát hiện) · `sub-invest-fe-2.md` (18) · `sub-invest-menu-3.md` (21)
> Mọi `file:line` dưới đây đã được lead verify lại độc lập ở các mốc chịu lực.
---
## 1. Vì sao làm — đây là lỗi SỐ LIỆU, không phải thiếu tiện nghi
Lời user (Tra Sol, UAT prod, verbatim):
> *"Vì mấy bạn bắt sai cái gói thầu"* → *"nên phải cho quyền xóa cái phiếu đó luôn"*
> *"chứ lỡ bấm sai cái gói thầu, quay lại không được, **phải xóa thì nó mới ko có lũy kế lên**"*
**Bằng chứng mã cho từng vế:**
| Vế user nói | Bằng chứng | File |
|---|---|---|
| "đang ăn lũy kế" | `PrevSubmitted` gom `Phase == ChoDuyet \|\| DaDuyet`; `PendingSelected` gom ChoDuyet-có-winner ⇒ ăn ở **CẢ HAI** dòng | `PeBudgetAccumulator.cs:53-59` · `:114-126` |
| "quay lại không được" | `TraLai` rơi khỏi `ComputeAsync` nhưng **rơi VÀO cả 2 dòng** `ComputePendingAsync` ⇒ Trả lại chỉ **dời** số, không triệt tiêu | `PeBudgetAccumulator.cs:104-126` |
| không xóa được | allow-list xóa = `{DangSoanThao, TuChoi}`; phiếu `ChoDuyet`**409 Conflict** | `PurchaseEvaluationFeatures.cs:1404-1406` |
**Nguồn gốc lỗ hổng (khảo cổ, cả 2 sub độc lập xác nhận):** commit `6db195d` (Fri 12/06/2026 14:30, S60) gỡ hành động **Từ chối** theo UAT — *"anh Kiệt 14:14: Bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi"*. Guard còn sống tự khuyên đường thoát:
> *"Phiếu cần dừng: dùng Trả lại để người soạn sửa, **hoặc Xóa phiếu khi còn Bản nháp**."* (`PurchaseEvaluationWorkflowService.cs:101-106`)
Cả hai cửa đều **không dùng được** cho phiếu ChoDuyet ⇒ S60 bịt cửa cuối cùng. Đây khớp lời owner *"hôm trước có cái chỗ cho hủy / mọi người nói là ko cần cái đó / giờ lại cần"*.
🔸 Hệ quả phụ: `TuChoi` trong allow-list xóa nay là **nhánh chết** (guard S60 chặn cả Admin) ⇒ allow-list thực tế chỉ còn `{DangSoanThao}`.
---
## 2. Quyết định OWNER (đã chốt — không bàn lại)
| # | Quyết định | Nguồn |
|---|---|---|
| 1 | Xóa được khi **đến lượt duyệt** của người đó | anh, 2026-07-27 |
| 2 | **Xóa MỀM** | anh |
| 3 | Phiếu **đang duyệt VẪN xóa được** (không thêm rào cho phiếu đã có lượt duyệt) — xóa ⇒ xuống mục "Đã xóa" ⇒ **hết ăn lũy kế** | anh |
| 4 | **Menu:** `Duyệt`**"Đang duyệt"**; thêm **"Đã duyệt"**, **"Đã xóa"** | anh (ảnh, ghi chú đỏ) |
| 5 | Quyền xóa gắn vào **cờ `AllowApproverDelete` per-Cấp-duyệt** (admin tick trong Designer) | anh, AskUser |
| 6 | Màn "Đã xóa" **CHỈ XEM**, chưa làm khôi phục | anh, AskUser |
🔴 **Khai thẳng cho người đọc sau:** quyết định (5) **đổi nghĩa** so với chữ đầu tiên của anh (*"người đứng đầu phòng"*). Lý do: `Department.ManagerUserId` tuy có tên đúng nhưng **0/10 phòng có dữ liệu****cả 2 FE đang hardcode `managerUserId: null`** trong payload sửa ⇒ field tự xóa trắng mỗi lần sửa phòng ban. Anh chọn cờ per-Cấp với trade-off đó đã ghi rõ. **Không phải hiểu nhầm.**
---
## 3. Bốn hạng mục
### A. Cờ **F6 `AllowApproverDelete`** — 1 migration
Nhà: `ApprovalWorkflowLevel` (**không** phải workflow-level — Mig 28 từng để ở đó rồi **DROP ở Mig 29**, chuyển xuống slot Cấp).
Khuôn có sẵn, đã lặp: F1 4 mode `AllowReturn*` · F2 `AllowApproverSkipToFinal` (Mig 31) · F3 `AllowApproverEditDetails` · F4 `AllowApproverEditBudget` (Mig 30) · F5 `AllowApproverFinalize` (Mig 58).
**F6 = 1 `AddColumn` bit, default `false`, no new table.** Nhịp y hệt F4/F5.
### B. Nới guard xóa + đóng lỗ authz
**B1 — Guard phase.** `DeletePurchaseEvaluationCommandHandler` (`PurchaseEvaluationFeatures.cs:1392-1411`): allow-list `{DangSoanThao, TuChoi}`**thêm nhánh `ChoDuyet`**, nhưng nhánh này **kèm điều kiện người**, khác 2 nhánh cũ.
**B2 — 🔴 Điều kiện người (HIỆN KHÔNG CÓ).** Handler **không inject `ICurrentUser`** ⇒ hiện **không kiểm ai đang xóa**. Với `ChoDuyet` phải thoả **CẢ HAI**:
- đang là **lượt duyệt của actor** — dùng `CurrentWorkflowStepIndex` + `CurrentApprovalLevelOrder` khớp `ApproverUserId` (khuôn `ApproveV2Async` đã có)
- 🔴 **`matchingLevel.AllowApproverDelete == true` — PER-ROW, KHÔNG per-Cấp** *(owner chốt sau review H2)*
> 🔴 **Viết chính xác thế này, đừng diễn giải lại** *(vá H2 — repo đang dùng CẢ HAI cách đọc cho cờ anh em F5, nên 2 người cài đặt sẽ ra 2 kết quả từ cùng một câu chữ)*:
> - ✅ **DÙNG** khuôn per-ROW: `matchingLevel.AllowApproverFinalize` (`PurchaseEvaluationWorkflowService.cs:859`)
> - ❌ **KHÔNG dùng** khuôn per-CẤP: `g.Any(l => l.AllowApproverFinalize)` (`PurchaseEvaluationFeatures.cs:1182`)
>
> **Vì sao per-ROW:** 1 row = **1 người** (`ApproverUserId` là field của row); "Cấp" chỉ là **tập row cùng `Order`**. Đọc per-Cấp (`Any`) ⇒ **mọi người trong Cấp đều xóa được** — ảnh prod cho thấy Cấp 2 Phòng Cung ứng có **3 người**, tức quyền huỷ chứng-từ tài chính rộng gấp 3. Owner chọn per-ROW để giữ sát ý ban đầu *"người đứng đầu phòng"* = **1 người, admin chỉ đích danh**.
Nhánh `DangSoanThao` giữ nguyên hành vi cũ (không siết, tránh vỡ luồng đang chạy).
**B3 — 🔴 Authz API.** Action `Delete` (`PurchaseEvaluationsController.cs:146-151`) hiện **chỉ `[Authorize]` trần****mọi user đăng nhập gọi được**, rào duy nhất là phase. Nới phase mà không thêm rào = **mở toang**.
⚠️ **Bẫy:** `MenuKeys.All` **KHÔNG chứa `Pe_*`** (verify: `MenuKeys.cs:147-165`), mà `Program.cs:82-89` chỉ đăng ký policy cho key trong `All``[Authorize(Policy="Pe_….Delete")]` trỏ **policy không tồn tại** → hỏng lúc chạy.
**Dùng `[Authorize(Policy = "PurchaseEvaluations.Delete")]`** (root, có trong `All`); `GetMyMenuTreeQuery:66-72` cho `Pe_*` **kế thừa cờ CRUD từ root**. Ô tick `Delete` **đã hiện sẵn** trong ma trận. ⇒ **0 key mới, 0 migration quyền.**
**B4 — Ghi vết.** `ChangelogAction.Delete = 3` **đã tồn tại**, đã dùng ở 5 chỗ PE ⇒ không cần migration. Handler xóa hiện **không ghi changelog** → thêm, theo khuôn `LogTransitionAsync` (`PurchaseEvaluationWorkflowService.cs:1158-1185`, 12 call-site).
🔸 Vết `DeletedBy` do `AuditingInterceptor` set là **ai + lúc nào**, **không có LÝ DO** — changelog là chỗ chứa lý do.
### C. Menu restructure
🔴 **Menu = SEED DB** (bảng `MenuItems`), **không** hard-code FE. FE fetch `/api/menus/me`, chỉ map key→route.
| Việc | Cách | Bẫy |
|---|---|---|
| `Duyệt`**"Đang duyệt"** | thêm entry vào `labelBackfill` (`DbInitializer.cs:1917-1923`) | 🔴 **Sửa chữ trong `tree.Add(...)` KHÔNG có tác dụng** — upsert `:1893-1903` gặp key đã tồn tại thì **chỉ update `Order`** rồi `continue`, **không đụng Label** |
| **"Đã duyệt"** (mới) | key mới → nhánh INSERT chạy bình thường | trỏ URL `?type=1&phase=7` |
| **"Đã xóa"** (mới) | key mới | cần endpoint mới, xem D |
**6 chỗ phải chạm** (không phải 4-place mirror — xác nhận dứt khoát: thêm nút/mục **không** kích luật 4-place):
1. `MenuKeys.cs:134-141` — 2 factory const mới
2. `DbInitializer.cs:1874-1877` — 2 dòng `tree.Add`
3. `DbInitializer.cs:1917-1923` — entry `labelBackfill` cho rename
4. `DbInitializer.cs:2092-2096` **VÀ** `:2492-2496` — 🔴 **2 danh sách permission SONG SONG** cùng liệt kê đích danh 5 key PE; quên 1 bên = quyền lệch
5. `Layout.tsx` regex — `fe-user:120` **và** `fe-admin:107`
6. `docs/STATUS.md` — row Menu keys + Policies
🔸 **Nhân bản:** menu sinh trong `foreach (var code in MenuKeys.PurchaseEvaluationTypeCodes)` ⇒ mỗi mục mới **× số loại phiếu** (`DuyetNcc`, `DuyetNccPhuongAn`), không phải 2 dòng mà 2 × N.
🔸 **Không cần EF migration** (không đổi schema) nhưng **cần restart API** để `DbInitializer` chạy.
🔸 **Nhặt dọc đường:** regex `fe-admin/Layout.tsx:107` **thiếu `WfView`** so với `fe-user:120` — lệch có sẵn, nằm đúng vùng đang sửa ⇒ vá luôn.
### D. Màn "Đã xóa" — việc thật duy nhất
**D1 — Vì sao khó.** `PurchaseEvaluationConfiguration.cs:84` `HasQueryFilter(x => !x.IsDeleted)`. Chính filter này cho ta **miễn phí** quyết định (3) của anh (xóa ⇒ tự rớt khỏi **cả 4** phép cộng lũy kế). Nhưng nó cũng làm phiếu xóa **vô hình với mọi query** ⇒ không liệt kê được.
**D2 — Số đo** (2 sub verify độc lập, khớp): `IgnoreQueryFilters` = **0 hit** `src/Backend` · `HasQueryFilter(` = **12** · **15 hit trong `tests/`** ⇒ cú pháp đã quen trong repo, chỉ là lần đầu ở tầng production.
**D3 — 🔴 Ranh giới an toàn (CẤM vi phạm).** `IgnoreQueryFilters()` là toán tử trên **một `IQueryable` cụ thể**; `PeBudgetAccumulator` dựng `peers` **mới tinh** ở cả 2 hàm (`:49`, `:100`) ⇒ không rò **nếu** giữ 3 điều cấm:
-**KHÔNG** đặt vào helper dùng chung trả `IQueryable`
-**KHÔNG** sửa `PurchaseEvaluationConfiguration.cs:84`
-**KHÔNG** tái dùng đường snapshot/finalize
**Hẹp nhất = handler + endpoint RIÊNG.** **KHÔNG** thêm cờ `includeDeleted` vào query list hiện có (cờ sẽ lan).
**D4 — Lịch sử đọc được miễn phí.** `PurchaseEvaluationChangelog : BaseEntity` (**không** có `IsDeleted`), query lọc **chỉ theo `PurchaseEvaluationId`**, **không join** sang PE ⇒ global filter không chạm (verify `PurchaseEvaluationFeatures.cs:1424-1425`). Thứ bị chôn là **bundle chi tiết phiếu** (`GetPurchaseEvaluationQuery`) ⇒ nếu muốn **mở** phiếu đã xóa thì đó là chỗ `IgnoreQueryFilters` **thứ 2**, cân nhắc riêng.
**D5 — Không khôi phục** (owner chốt (6)). Ghi rõ trong spec để người sau không tự thêm: nếu sau này làm restore thì **phải chốt phiếu về phase nào** — về `ChoDuyet`**ăn lũy kế lại ngay**, đúng thứ vừa đi chữa.
**D6 — 0 tiền lệ thùng rác** toàn repo. Hit "khôi phục" duy nhất là **chuỗi thông báo** `EmployeeFeatures.cs:162` — chữ có, chức năng không (ghost-wire). ⇒ đây là màn đầu tiên loại này, đáng test riêng.
### E. FE — nút Xóa ở màn duyệt
**E1 — Dây đã nối sẵn.** Prop `onDelete` **đã truyền** vào **cả 2 call-site** màn Duyệt kèm mutation thật + invalidate (`PurchaseEvaluationsListPage.tsx:570-575` inline · `:665-670` focus overlay = đúng màn trong ảnh). Nút không hiện chỉ vì điều kiện render đòi `mode === 'workspace' && canEditPhase && !readOnly`, mà màn duyệt có `mode='detail'`, `readOnly=true`, `canEditPhase=false`.
**E2 — Chỗ đặt.** `PeWorkflowPanel.tsx:458-506`. 🔴 Đặt **CẠNH** vòng `next.map`, **KHÔNG** nhét vào map — map sinh nút theo **phase kế tiếp**, mà Xóa **không phải một phase**.
**E3 — 🔴 Mirror 2 app.** `PeWorkflowPanel.tsx` **byte-identical** giữa `fe-user``fe-admin` (md5 `02671be6334110028c55fe82f5e70dee`, 942 dòng). **Sửa 1 bên là sót.** Đây là luật thay thế cho 4-place.
**E4 — Gate hiển thị.** `!readOnly``actorInV2Level``phase === ChoDuyet`**cờ F6 của Cấp hiện tại**. Vùng PE hiện **0-hit** `usePermission|PermissionGuard`; khuôn để bắt chước: `fe-admin/.../DepartmentsPage.tsx:101` `action="Delete"`.
⚠️ Gate này là **display-layer**. Tầng API (B3) **độc lập** — ẩn nút **không** đóng API (gotcha #82 / S118).
---
## 4. ✅ CHECKLIST THỰC THI
> 🔴 **THỨ TỰ THI CÔNG CỨNG** *(vá M7 — văn xuôi §F.0 và checklist đá nhau: F.0 nói "A-E không chạy nếu thiếu F" nhưng checklist lại xếp A-E trước)*:
> **`1-2` (schema F6) → `F-1…F-4` + `F-1a` (lệnh Update + validator + authz) → `3-8` → `9-13` (menu/seed) → `14-20` (FE) → `21-26` + `F-12…F-16` (test) → `27-28` (docs)**
> Làm top-down theo số thứ tự sẽ dựng đúng thứ tự mà chính spec cảnh báo là hỏng.
### Backend
- [ ] **1.** `ApprovalWorkflowLevel` + `AllowApproverDelete` (bit, default `false`) — F6, khuôn F4/F5
- [ ] **2.** Migration `AddPeAllowApproverDelete`**3-file rule** (Migration + Designer + ModelSnapshot), no new table
- [ ] **3.** DTO + Designer admin: tick F6 **per-NGƯỜI** (mirror chỗ tick F5). 🔴 **KHÔNG làm mục này một mình — nó là F-11 ở độ sâu nông** *(vá M8)*: F-11 liệt **5 dây FE** + cảnh báo **thiếu `:605` = tick xong KHÔNG gửi lên BE** (tick vô hình, rất khó bắt). **Làm thẳng theo F-11**, bỏ qua mục 3 này
- [ ] **4.** `DeletePurchaseEvaluationCommandHandler` — inject `ICurrentUser`
- [ ] **5.** Nới allow-list: thêm nhánh `ChoDuyet` **kèm** điều kiện (đúng lượt ∧ F6). `DangSoanThao` giữ nguyên
- [ ] **6.** Ghi changelog `ChangelogAction.Delete` theo khuôn `LogTransitionAsync` (+ chỗ chứa **lý do**)
- [ ] **7.** 🔴 **TÁCH 2 ĐƯỜNG XÓA** *(owner chốt sau review H1 — đo thật trên DB Dev: **11/13 role có `CanDelete=0`**, chỉ `Admin` + `DeptManager` = 1; nguồn `DbInitializer.cs:2515` `canDelete = roleName == AppRoles.DeptManager`)*:
- [ ] **7a. Đường CŨ — xóa NHÁP: GIỮ NGUYÊN, KHÔNG gắn policy.** Rào vẫn là **phase** (`{DangSoanThao}`) + là người soạn. 🔴 Gắn policy vào đây = **role `Drafter` mất quyền xóa nháp của chính mình** (nút `PeDetailTabs.tsx:445` + `:460` đang chạy được **nhờ** endpoint không có policy) = **regression trên chức năng đang sống**
- [ ] **7b. Đường MỚI — xóa KHI ĐANG DUYỆT: command + endpoint RIÊNG**, mang `[Authorize(Policy = "PurchaseEvaluations.Delete")]`. Rào = policy **∧** đúng lượt **∧** cờ F6
- [ ] **7c.** Cấp `CanDelete` cho tập role đích (approver thật — ví dụ `Procurement` trong ảnh UAT đang = 0 ⇒ **403 dù đúng lượt và đúng cờ**). Ghi rõ đường cấp: tick tay `/system/permissions` trên prod **hay** seeder có upgrade-path (⚠️ khối `DbInitializer.cs:2120-2126` **skip-if-exists** `:2118`**không bao giờ chạm `CanDelete`** ⇒ sửa seeder **KHÔNG** vá được prod)
- [ ] **7d.** Test **chiều thuận**: đủ quyền + đúng lượt + đúng cờ ⇒ **204**. (Test 25 chỉ kiểm chiều 403 nên nó **xanh trong cả 2 kịch bản hỏng** ở trên — mù đúng chỗ cần thấy)
> ✅ **Vì sao cách này không đụng `MenuKeys.All`:** đường mới tái dùng policy **root** `PurchaseEvaluations.Delete` (đã có trong `All`), đường cũ không cần policy ⇒ **0 key mới** ⇒ 2 số `Menu keys=54` / `Policies=216` ở `STATUS.md:450-451` **giữ nguyên**, khớp mục 27.
> 🔴 Tầng policy **KHÔNG kế thừa**: `MenuPermissionHandler.cs:41-51` lọc `p.MenuKey == req.MenuKey` **khớp chính xác**. Cơ chế kế thừa `GetMyMenuTreeQuery:66-72` chỉ là **display-layer** — không cấp quyền API.
- [ ] **8.** Endpoint + handler **RIÊNG** list "Đã xóa" — tuân D3 **VÀ 2 ràng buộc bắt buộc dưới đây** *(vá H3 sau review)*:
- [ ] **8a.** 🔴 `IgnoreQueryFilters().Where(x => x.IsDeleted)``IgnoreQueryFilters()` **GỠ** filter chứ **không ĐẢO** nó; thiếu `.Where` ⇒ màn "Đã xóa" liệt kê **toàn bộ phiếu sống lẫn chết**. Test 22 soi accumulator nên **không bắt được** lỗi này
- [ ] **8b.** 🔴 Tái lập **IDOR-scope**: sao khối `PurchaseEvaluationFeatures.cs:596-617` (non-admin chỉ thấy phiếu khi là Drafter ∥ phase ∈ `eligiblePhases` theo role ∥ là approver của workflow đã pin). Handler MỚI **mặc định không có rào nào** ⇒ thiếu bước này = mọi user đọc được **toàn bộ phiếu đã xoá của mọi dự án** kèm `BudgetPeriodAmount` + tên gói thầu + NCC
- [ ] **8c.** Test: (a) list "Đã xóa" **không chứa** phiếu sống · (b) user ngoài phạm vi **không thấy** phiếu đã xoá của người khác
> 🔴 D3 chống rò **sang accumulator**; 8a/8b chống rò **ra người dùng**. Hai trục khác nhau, đừng gộp.
### Menu / seed
- [ ] **9.** `MenuKeys.cs` — 2 factory const mới (Approved / Deleted)
- [ ] **10.** `DbInitializer` tree `:1874-1877` — 2 dòng `tree.Add` (× số type code)
- [ ] **11.** `DbInitializer` `labelBackfill` — entry rename `Duyệt``Đang duyệt` 🔴 **bước này KHÔNG được bỏ**, sửa `tree.Add` là vô tác dụng
- [ ] **12.** `DbInitializer:2092-2096` **VÀ** `:2492-2496` — thêm key vào **CẢ HAI** danh sách permission
- [ ] **13.** Restart API để seed chạy (không cần EF migration cho menu)
### Frontend (🔴 mọi mục = **× 2 app**, giữ byte-identical)
- [ ] **14.** 🔴 **MỘT chỗ đặt nút DUY NHẤT** = `PeWorkflowPanel.tsx` cạnh `next.map`, gate E4. **Phải THÊM prop + mutation mới** — xem 14a *(vá M5)*
- [ ] **14a.** 🔴 **§E1 bản đầu SAI, đã đính chính:** `PeWorkflowPanel` nhận **đúng 3 prop** `{evaluation, readOnly, onApproved}` (`fe-user/src/components/pe/PeWorkflowPanel.tsx:31-43`) — **KHÔNG có `onDelete`**, không có mutation xoá. `onDelete` được truyền cho **`PeDetailTabs`**, **không phải** cho panel (`PurchaseEvaluationsListPage.tsx:570-575` / `:665-670` — số dòng đúng, **component sai**). ⇒ **"Dây đã nối sẵn" KHÔNG đúng cho chỗ đặt nút.** Phải nâng mutation `del` lên rồi truyền prop mới cho panel, hoặc tạo mutation riêng trong panel
- [ ] ~~**15.**~~ 🚫 **GỠ** *(vá M5)* — mục cũ bảo *"bỏ chặn `readOnly` cho riêng nút Xóa"*, nhưng nút bị chặn đó nằm trong **`PeDetailTabs`** với gate **4 điều kiện** (`mode === 'workspace' && canEditPhase && !readOnly` `:445` **+** `phase === DangSoanThao` `:460`), mà màn Duyệt có `mode='detail'`, `canEditPhase=false`. "Bỏ chặn readOnly" **không đủ**, và giữ cả 14 lẫn 15 sẽ đẻ ra **2 nút Xóa** hoặc **1 nút không nối dây**. Nút nháp trong `PeDetailTabs` **giữ nguyên không đụng** (đường 7a)
- [ ] **16.** `Layout.tsx` regex — nới cho 2 key mới, **và** vá lệch `fe-admin:107` thiếu `WfView`
- [ ] **17.** Mục "Đã duyệt" trỏ `?type=1&phase=7`**0 route mới, 0 page mới**
- [ ] **18.** Trang "Đã xóa" — gọi endpoint mục 8, chỉ-xem, **không** nút khôi phục
- [ ] **19.** Xác minh md5 `PeWorkflowPanel.tsx` **vẫn khớp** giữa 2 app sau khi sửa
- [ ] **20.** `npm build` × 2 app
### Test (test-before cho phần financial — luật dự án)
- [ ] **21.** 🔴 Xóa phiếu `ChoDuyet`**4 assert CÓ HƯỚNG, không phải "cả 4 cùng giảm"** *(vá M4 — phát biểu cũ SAI số học)*:
- `PrevSubmitted` **↓** (`PeBudgetAccumulator.cs:53-59` — gom `ChoDuyet || DaDuyet`)
- `PendingSelected` **↓** (`:114-117` — loại `{Nháp, DaDuyet, TuChoi}` + có winner)
- `PrevSelected` **KHÔNG ĐỔI** (`:61-62` đòi `Phase == DaDuyet`)
- `PendingSubmitted` **KHÔNG ĐỔI** (`:107` **loại tường minh** `ChoDuyet`)
- `PriorPes` **mất đúng 1 phần tử** (`:128-133` — output thứ 5 spec bản đầu quên)
> Viết assert "giảm" cho cả 4 ⇒ test **không thể xanh**. Viết cho 2 rồi quên 2 kia ⇒ **mất chốt hồi quy**.
- [ ] **22.** 🔴 Endpoint "Đã xóa" **KHÔNG** làm phiếu xóa lọt lại `PeBudgetAccumulator` (anti-regression cho D3)
- [ ] **23.** Cấp **không** có F6 ⇒ xóa **bị từ chối** (403/409), kể cả khi đúng lượt
- [ ] **24.** Đúng F6 nhưng **không đúng lượt** ⇒ từ chối
- [ ] **25.** API-authz: user thiếu `PurchaseEvaluations.Delete` gọi thẳng endpoint ⇒ **403** (không chỉ ẩn nút — gotcha #82)
- [ ] **26.** Changelog có bản ghi `Delete` sau khi xóa
### Docs
- [ ] **27.** `docs/STATUS.md`**CHỈ row Mig mới** *(vá M6)*. 🔴 **KHÔNG đụng row `Policies` (216) và `Menu keys` (54)** — key mới là `Pe_*` sinh qua **factory**, **không** vào `MenuKeys.All` ⇒ cả 2 số **giữ nguyên**. Sửa chúng = ghi số sai vào đúng chỗ canonical mà `governance-detectors.ps1` đang soi (`STATUS.md:450-451`, `Policies`**DERIVED** = `|All| × |Actions|`)
- [ ] **28.** `docs/gotchas.md` — bẫy `labelBackfill` (sửa `tree.Add` vô tác dụng) + bẫy `Pe_*` không có policy
---
## 5. ⚠️ NỢ ĐÃ BIẾT — khai, không vá lượt này
| # | Nợ | Vì sao hoãn |
|---|---|---|
| N1 | **Rác bảng con:** soft-delete đổi state `Deleted→Modified`**6 FK Cascade không bao giờ chạy**; 6/8 bảng con là `BaseEntity` **không có `IsDeleted`** ⇒ ở lại DB trỏ về phiếu vô hình | Sub grep **38 read-site**: tất cả join từ `db.PurchaseEvaluations`**chưa lộ số**. Rủi ro là report tương lai |
| N2 | **Snapshot `budgetFrozen`** (Mig 67): xóa phiếu X sau khi X đã vào snapshot của phiếu Y đã duyệt ⇒ số của Y **không đổi** | Đúng ý đồ "đóng băng = record-of-decision". Cần owner chốt nếu muốn re-compute |
| N3 | `Department.ManagerUserId` **0/10 dữ liệu** + **2 FE hardcode `null`** xóa trắng field mỗi lần sửa phòng ban | Bug có thật, nhưng quyết định (5) đã đi đường khác ⇒ tách việc |
| N4 | `TuChoi` trong allow-list xóa là **nhánh chết** (guard S60 chặn cả Admin) | Dọn khi nào owner quyết số phận nút Từ chối |
---
## 6. NGOÀI PHẠM VI
Khôi phục phiếu (owner chốt (6)) · mở lại **chi tiết** phiếu đã xóa (D4 — cần `IgnoreQueryFilters` thứ 2) · khôi phục nút Từ chối · vá N1N4.
---
# PHẦN II — Hạng mục F: lệnh **UPDATE quy trình duyệt** (yêu cầu owner (7))
> Nguồn: `sub-invest-wfver-4.md` (25 phát hiện). Lead verify độc lập F1/F3/F7/F14/F17.
## F.0 🔴 QUAN HỆ PHỤ THUỘC — đọc trước tiên
**Hạng mục A-E (quyền xóa) KHÔNG CHẠY nếu thiếu F.** Không phải "làm cùng thì tiện" mà là **phụ thuộc cứng**. 4 mắt xích:
1. POST tạo `new ApprovalWorkflow{...}` (`ApprovalWorkflowV2AdminFeatures.cs:336`) → `BaseEntity` sinh `Guid.NewGuid()`**Id MỚI**; Steps/Levels cũng row mới.
2. Phiếu đọc workflow **theo Id đã pin**: `PurchaseEvaluationWorkflowService.cs:674` `w.Id == awId`**tham chiếu SỐNG, không snapshot**.
3. Bản cũ chỉ bị `IsActive=false` (`:334`) ⇒ **vẫn resolve được** ⇒ phiếu chạy tiếp trên cấu hình **CŨ**, im lặng, không lỗi.
4. **Không có đường re-pin:** `PE.ApprovalWorkflowId` chỉ ghi ở `PurchaseEvaluationFeatures.cs:149` (create) / `:291` (update), mà update chặn `:249-251` *"Chỉ sửa được phiếu khi ở phase Nháp hoặc Trả lại"*. `UPDATE…ApprovalWorkflowId` trong Migrations = **0 hit**.
⇒ Tick `AllowApproverDelete` hôm nay ⇒ sinh v(n+1) ⇒ **phiếu đang treo vẫn đọc v(n)****không có cờ** ⇒ quyền xóa **không tới đúng phiếu cần xóa**.
⇒ Đường vòng duy nhất (Trả lại → gửi lại) chạy quy trình **lại từ đầu****vẫn ăn lũy kế****không giải được ca UAT gốc**.
## F.1 Hiện trạng: KHÔNG có lệnh Update nào
Mặt admin V2 chỉ 4 việc; **không** Update (trừ bật/tắt `IsUserSelectable`). Mọi POST **tự tăng** `Version = MAX(Version per Code)+1` (`:325-328`), client không truyền được, **kèm tác dụng phụ hạ `IsActive`** mọi bản cùng `ApplicableType` (`:331-334`).
⇒ (7) = **XÂY MỚI một lệnh chưa từng tồn tại**, không phải nới rào.
FE: Designer chỉ ở **`fe-admin/…/ApprovalWorkflowsV2Page.tsx`** (fe-user `WorkflowMatrixViewPage.tsx` chỉ-xem). **Không có nút "Sửa"** — chỉ **"Nhân bản"** (`:479`) mở dialog *"Tạo quy trình mới"* (`:638`), toast *"Version cũ đã archive"* (`:611`). Admin **có** thấy Version + trạng thái.
🔴 **0 cảnh báo "đang được dùng", 0 disable khi `IsActive`.** Designer HĐ **V1 CÓ** badge *"N HĐ còn chạy"***V2 không port**.
## F.2 🔴 QUY TẮC PHÂN LOẠI (rút gọn, kiểm được bằng máy)
**AN TOÀN ⟺ thoả CẢ HAI phép thử:**
> **(T1) Khung không đổi** — tập `{Step.Order}` và tập `{Level.Order}` giữ nguyên.
> **(T2) Luật chơi không đổi** — không đụng trường quyết định **đường đi** của phiếu. Hiện tập này = `{CeoApprovalThreshold}`.
🔴 **KHAI GIỚI HẠN (vá M1 — đừng đọc quy tắc mạnh hơn nó thật):** (T1) chỉ bảo đảm **con trỏ không dịch** ⇒ phiếu không kẹt. Nó **KHÔNG** bảo đảm **ngữ nghĩa****hiển thị** không đổi. Ba phản ví dụ đều giữ nguyên cả 2 tập Order mà vẫn hỏng: cờ F5 đổi điểm kết thúc · fallback `.First()` gắn chữ ký sang người khác · thêm người vào Cấp đã qua thì hiện "Done" dù chưa ký. Vì vậy bảng dưới có hạng **⚠️ NỬA**, và (T2) tồn tại.
Lý do **(T1)**: con trỏ phiếu là **LAI**`CurrentWorkflowStepIndex` dùng **INDEX** vào list đã sort (`:686`); `CurrentApprovalLevelOrder` so **GIÁ TRỊ Order** (`:694`). Giữ nguyên 2 tập Order ⇒ **không con trỏ nào dịch**.
🔴 Lý do **(T2)** — **owner chốt 2026-07-27, và nó SỬA quy tắc chứ không chỉ điền một ô.** Bản đầu của quy tắc chỉ có (T1), nên `CeoApprovalThreshold` (0 đụng Order) **tự động rơi vào AN TOÀN**. Owner xếp nó **PHÁ VỠ** ⇒ lộ ra rằng "không dịch con trỏ" **chưa đủ** để gọi là an toàn: một thay đổi có thể **không làm kẹt phiếu nào** mà vẫn **đổi luật chơi giữa chừng** (phiếu đang ở nhánh "không cần CEO" bỗng thành "cần CEO"). Đó là an toàn **kỹ thuật** nhưng không an toàn **về niềm tin** — người đã duyệt thì duyệt theo ngưỡng họ nhìn thấy lúc đó.
**Bài học mang theo:** thêm trường mới vào workflow phải hỏi **cả hai** câu, không chỉ câu con trỏ. Trường nào lái đường đi của phiếu thì thuộc (T2) dù không đụng Order.
### Bảng 13 thao tác
| # | Thao tác | Hạng | Lý do (mã) |
|---|---|---|---|
| 1 | **Thêm người vào Cấp có sẵn** (row trùng `Order`) | ✅ AN TOÀN | `levelGroups` giữ nguyên khoá; người mới duyệt được ngay, người cũ không mất gì (`:689-695`, `:702`) |
| 2a | **Cờ QUYỀN-TẠI-CHỖ**: F1 4 mode `AllowReturn*` · F3 `AllowApproverEditDetails` · F4 `AllowApproverEditBudget` · **F6 `AllowApproverDelete`** | ✅ AN TOÀN | chỉ đổi **người đó được làm gì tại chỗ họ đứng**; 0 đụng Order, 0 đổi đường đi phiếu. 🔴 F6 **buộc** phải ở hạng này — nếu không thì §F.0 vô nghiệm |
| 2b | **Cờ ĐỔI-ĐƯỜNG-ĐI**: **F5 `AllowApproverFinalize`** | ❌ **PHÁ VỠ** *(owner chốt sau review M1)* | Qua (T1) nhưng **trượt (T2)** — đổi **ĐIỂM KẾT THÚC**: bật ⇒ người duyệt tại cấp đó **kết thúc luôn** `Phase = DaDuyet` (`:866`) kèm **đóng băng số tiền** `ApplyBudgetSnapshotOnFinalizeAsync` (`:865`) ⇒ phiếu đang chờ **không lên Ban Giám đốc nữa**. Nặng hơn `CeoApprovalThreshold` (cái kia còn cần CCM chủ động tích `:881-901`, F5 đọc thẳng `:859`) |
| 3 | **Đổi tên hiển thị** | ✅ AN TOÀN | display-only |
| 3b | Đổi `ApprovalWorkflow.Code` | ❌ PHÁ VỠ *(vá M2)* | vỡ chuỗi version — `nextVersion = MAX(Version) WHERE Code = …` (`:325-328`) ⇒ bản kế tiếp tụt về v1 hoặc đụng version cũ |
| 3c | Đổi `ApplicableType` | ❌ PHÁ VỠ *(vá M2)* | phiếu đang pin bỗng chạy workflow **module khác** (khớp loại **chỉ kiểm lúc pin** `PurchaseEvaluationFeatures.cs:258-265`, không re-validate) + vỡ bất-biến "1 active mỗi type" (`:331-334`) |
| 3d | **Thêm người vào Cấp ĐÃ QUA** | ⚠️ NỬA *(vá M1c)* | `ComputeLevelStatus` thuần con-trỏ (`:1135-1148` `stepIdx0 < currentIdx → "Done"`) ⇒ người mới render **"Done"** dù **chưa từng ký**, không có `LevelOpinion` nào. Phiếu `DaDuyet` còn tuyệt đối hơn (`:1137`) |
| 3e | **Thêm/bớt row cùng Cấp khi có khả năng Admin-duyệt-thay** | ⚠️ NỬA *(vá M1b)* | `matchingLevel` fallback `pendingLevelGroup.First()` (`:733-734`); row cùng `Order` **không có tie-breaker** ⇒ thứ tự do DB trả ⇒ "row đầu tiên" đổi ⇒ chữ ký admin gắn **sang Level row người khác** + UPSERT `LevelOpinion` khoá `(PE, LevelId)` ghi nhầm row |
| 4 | **Bớt người khỏi Cấp** | ⚠️ NỬA | con trỏ không dịch, NHƯNG người bị gỡ đang chờ → **403** in **GUID trần** (`:705`); row đã ký → **FK `Restrict` chặn**; Cấp rỗng người ⇒ **phiếu KẸT** |
| 5 | **Đổi `ApproverUserId`** | ⚠️ NỬA | như #4 **+** chữ ký cũ (`LevelOpinion` khoá theo `LevelId`) **đổi nghĩa IM LẶNG** — ý kiến người A hoá thành của người B |
| 6 | Thêm Cấp (`Order` mới) | ❌ PHÁ VỠ | đổi `maxLevelOrder`, chèn chặng |
| 7 | Xoá Cấp | ❌ PHÁ VỠ | `pendingLevelGroup` null → `ConflictException` `:695`**phiếu KẸT** |
| 8 | Đổi `Order` Cấp | ❌ PHÁ VỠ | tương đương xoá+thêm với con trỏ |
| 9 | Thêm Bước | ❌ PHÁ VỠ | `steps[currentIdx]` trỏ **sang bước khác, ÂM THẦM** (`:686`) |
| 10 | Xoá Bước | ❌ PHÁ VỠ | số bước < `currentIdx``ConflictException` `:683`**phiếu KẸT** |
| 11 | Đổi `Order` Bước | ❌ PHÁ VỠ | như #9 |
| 12 | Đổi `CeoApprovalThreshold` | ❌ **PHÁ VỠ** *(owner chốt)* | Qua **(T1)** nhưng **trượt (T2)** — đổi luật chơi giữa chừng: phiếu đang ở nhánh "không cần CEO" bỗng thành "cần CEO". Người đã duyệt thì duyệt theo ngưỡng họ **nhìn thấy lúc đó** |
| 13 | Đổi `Step.DepartmentId` | ✅ **AN TOÀN** *(owner chốt)* | BE **không** dùng field này để khớp người duyệt (chỉ hint) ⇒ 0 phiếu kẹt. 🔸 Kèm việc: Designer FE **ép nhập** (`:573-575`) trong khi BE coi là optional — lệch sẵn có, xem F-17 |
🔴 **4 read-site con trỏ, mỗi cái vỡ MỘT KIỂU:**
| Read-site | Kiểu vỡ |
|---|---|
| `ResolveV2InboxIdsAsync` — hộp thư "Chờ duyệt" (`PurchaseEvaluationFeatures.cs:842-849`) | 🔴 **IM LẶNG**: `idx >= steps.Count → continue` ⇒ phiếu **biến mất khỏi màn Duyệt**, không lỗi |
| Flow-tree detail (`:1135-1191`) | tô sai Done/Current/Pending |
| Banner "Đến lượt bạn" (`:1223-1243`) | banner rỗng ⇒ FE mất gate `blockedByV2Level` |
| Badge "Kết thúc trước CEO" (`:672-677`) | giả định ẩn **`Step.Order - 1 == StepIndex`** — chỉ đúng khi Order liền mạch 1..N |
## F.3 ⚠️ BLAST RADIUS — không phải việc riêng của PE
`ApprovalWorkflow` V2 là schema **DÙNG CHUNG ≥6 module**: Proposal · Leave/OT · Travel/Vehicle · WorkflowApps · Contract (+PE). Hai con trỏ có **198 hit / 23 file**.
⇒ Test **bắt buộc phủ PE + ≥1 module Văn phòng số**.
## F.4 ✅ CHECKLIST hạng mục F
- [ ] **F-1.** `UpdateAwDefinitionCommand` mới — **CHỈ** nhận trường hạng ✅ (#1 #2 #3 #13). Trường hạng ❌ gửi lên ⇒ **từ chối kèm thông điệp bảo tạo version mới**
- [ ] **F-1a.** 🔴 **AUTHZ — KHÔNG ĐƯỢC QUÊN** *(vá H4 sau review)*: endpoint mới phải mang `[Authorize(Policy = "Workflows.Create")]`, khớp **cả 3** verb ghi hiện có của controller này (`ApprovalWorkflowsV2Controller.cs:29` POST · `:40` PATCH · `:48` DELETE); class chỉ `[Authorize]` trần (`:18`) nên **không tự bảo vệ**.
> 🔴 Vì sao nghiêm trọng hơn endpoint thường: **cả 2 thao tác F-1 cho phép đều là hành vi CẤP QUYỀN.** (i) "thêm người vào Cấp" ⇒ tự thêm mình vào `pendingLevelGroup` ⇒ lọt guard `PurchaseEvaluationWorkflowService.cs:702-708` ⇒ **duyệt được phiếu người khác**; (ii) "bật cờ `Allow*`" ⇒ gồm **chính F6** ⇒ **tự cấp quyền xoá** phiếu đang duyệt. Thiếu 1 dòng attribute = **leo thang đặc quyền cho mọi tài khoản đăng nhập**.
- [ ] **F-1b.** Test **403** cho user thường gọi endpoint Update
- [ ] **F-1c.** Ghi **vết** cho thay-đổi-quy-trình (spec đòi changelog cho xoá phiếu ở B4 nhưng quên đòi vết cho sửa quyền — bất đối xứng, review bắt)
- [ ] **F-2.** 🔴 Validator server-side thi hành **F.2 — CẢ HAI phép thử**: **(T1)** so tập `{Step.Order}` + `{Level.Order}` trước/sau · **(T2)** chặn thay đổi `CeoApprovalThreshold`. Vi phạm bất kỳ ⇒ **409**. Răng thật, KHÔNG dựa FE ẩn nút
- [ ] **F-17.** Designer FE **ép nhập** `Step.DepartmentId` (`:573-575`) trong khi BE coi là **optional** (chỉ hint) — lệch sẵn có, nay owner cho sửa field này tại chỗ nên phải chốt: nới FE cho optional, hay giữ ép. Hỏi lại nếu không rõ, ĐỪNG tự đổi
- [ ] **F-3.** 🔴 **CẤM delete-then-insert Levels****diff theo `Level.Id`**. Replace-all sẽ **nổ FK `Restrict`** trên workflow đang chạy + mồ côi `LevelOpinion`
- [ ] **F-4.** Update **KHÔNG** tăng `Version`, **KHÔNG** đụng `IsActive`, **KHÔNG** đổi `Id` — giữ Id là toàn bộ mục đích
- [ ] **F-5.** Hạng ⚠️ NỬA (#4 #5 #3d #3e): cho phép nhưng **cảnh báo trước** + chặn khi row đã có `LevelOpinion` (đón trước FK) + chặn khi Cấp sẽ **rỗng người**
- [ ] **F-5a.** 🔴 Đếm `LevelOpinion` phải **LOẠI TRỪ** opinion thuộc PE đã `IsDeleted` *(vá M3)*. Lý do: xoá mềm ⇒ cascade **không chạy** (`AuditingInterceptor.cs:56-62` đổi `Deleted→Modified`) ⇒ opinion của phiếu đã xoá **ở lại DB****vẫn Restrict** FK. Không loại trừ ⇒ admin bị **chặn thật** khỏi việc bỏ người ra khỏi Cấp, **chỉ vì một phiếu đã bị xoá**.
> 🔴 **Lượt này làm nợ N1 TỆ HƠN, phải khai:** *trước* đây rác chỉ sinh từ phiếu **Nháp** (hiếm có `LevelOpinion`); *sau* tính năng này mọi phiếu bị xoá đều ở **`ChoDuyet`** ⇒ **chắc chắn** đã có `LevelOpinion` + `Approvals` + `Changelogs`. Tức ta vừa dựng luật trên đúng cái trục mà ta đang làm xấu đi.
- [ ] **F-6.** Sửa message 403 `:705` — đang in **GUID trần**, đổi sang **tên người**
- [ ] **F-7.** Designer fe-admin: thêm nút **"Sửa"** (khác "Nhân bản"), chỉ mở trường hạng ✅
- [ ] **F-8.** Port badge **"N phiếu còn chạy"** từ Designer HĐ V1 sang V2 + cảnh báo trước khi sửa
- [ ] **F-9.** `DeleteAwDefinitionCommandHandler` — thêm **usage-check** (nợ tự khai `:400-402`); hiện xoá nhầm ⇒ **FK 547 → 500**
- [ ] **F-10.** Vá comment **SAI** `ApprovalWorkflow.cs:81-82` (*"KHÔNG OR-of-many"*) — mã + CLAUDE.md đều **OR-of-N**
- [ ] **F-11.** 🔴 Tick F6 ở Designer: `fe-admin:999-1007` (dưới F5) + **5 dây** `:54 / :110 / :159 / :177 / :605` — ⚠️ **thiếu `:605` = tick xong KHÔNG gửi lên BE** (tick vô hình) + `fe-user:282` + BE `:41 / :230 / :188 / :368`
### Test hạng mục F
- [ ] **F-12.** Update hạng ✅ trên workflow **đang có phiếu chạy** ⇒ phiếu **nhận thay đổi ngay** (bài kiểm chứng F.0)
- [ ] **F-13.** Update đụng tập Order ⇒ **409**, workflow **không đổi**
- [ ] **F-14.** Bớt người mà row đã có `LevelOpinion` ⇒ chặn **lịch sự** (409), KHÔNG nổ FK 547
- [ ] **F-15.** 🔴 Tick F6 bằng Update ⇒ phiếu `ChoDuyet` **đang treo từ trước** xoá được (nối F với A-E)
- [ ] **F-16.** Regression ≥1 module Văn phòng số (Leave/OT hoặc Proposal)
## F.5 Nợ khai thêm
| # | Nợ | Ghi chú |
|---|---|---|
| N5 | Designer V2 **0 cảnh báo đang-dùng, 0 disable** khi `IsActive` — V1 có, V2 không port | F-8 vá |
| N6 | `DeleteAwDefinition` 0 usage-check ⇒ FK 547 → 500 | F-9 vá |
| N7 | Comment `ApprovalWorkflow.cs:81-82` sai (OR-of-N) | F-10 vá |

View File

@ -0,0 +1,42 @@
# sub-implementer-backend-1 — ĐỢT 1 (hạng mục F + schema F6)
> run `2026-07-27-S155-pe-delete-approver` · vai `implementer-backend` · spec canonical = `spec-pe-delete-approver.md` PHẦN II + checklist mục 1-2
> Ghi TRONG LÚC LÀM (bài học #53). Trạng thái cập nhật dần từ trên xuống.
## Phạm vi nhận (theo TASK)
| # | Mục | Trạng thái |
|---|---|---|
| 1 | `ApprovalWorkflowLevel += AllowApproverDelete` (F6) | ⏳ |
| 2 | Migration `AddPeAllowApproverDelete` (3-file rule) | ⏳ |
| 3 | F-10 vá comment sai `ApprovalWorkflow.cs:81-82` | ⏳ |
| 4 | F-1..F-4 + F-1a/b/c `UpdateAwDefinitionCommand` | ⏳ |
| 5 | F-5/F-5a hạng NỬA (bớt người / đổi ApproverUserId) | ⏳ |
| 6 | F-6 message 403 in GUID → tên người | ⏳ |
KHÔNG làm: nút xóa FE · menu · màn "Đã xóa" · F-9 usage-check DeleteAwDefinition · F-11 (FE) · F-7/F-8 (FE).
---
## PHẦN 0 — Khảo sát nền (đọc trước khi viết dòng nào)
Số đo lấy từ đĩa, KHÔNG lấy từ memoryPack:
| Sự kiện | Bằng chứng |
|---|---|
| 8 cờ Allow* per-Level hiện có (F1×4, F3, F4, F2, F5) | `src/Backend/SolutionErp.Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs:104-142` |
| EF `HasDefaultValue` cho từng cờ | `.../Configurations/ApprovalWorkflowConfiguration.cs:80-103` |
| KHÔNG tồn tại lệnh Update — admin V2 chỉ 4 việc | `ApprovalWorkflowV2AdminFeatures.cs` (Get :101 · Create :238 · SetUserSelectable :384 · Delete :404) |
| POST tự tăng Version + hạ IsActive mọi bản cùng type | `:325-328` · `:331-334` · new `ApprovalWorkflow{}` `:336` |
| 3 verb ghi controller đều `[Authorize(Policy="Workflows.Create")]`; class chỉ `[Authorize]` trần `:18` | `ApprovalWorkflowsV2Controller.cs:29 / :40 / :48` |
| Con trỏ LAI: StepIndex = INDEX list sort · LevelOrder = so GIÁ TRỊ | `PurchaseEvaluationWorkflowService.cs:686` · `:689-695` |
| 403 in GUID trần | `PurchaseEvaluationWorkflowService.cs:701-707` (`string.Join(", ", allowedUserIds)`) |
**🔴 Phát hiện MỚI (chưa có trong spec/memoryPack) — quyết định cài đặt F-5/F-5a phụ thuộc nó:**
1. **7 bảng LevelOpinion** FK `ApprovalWorkflowLevelId` chứ không phải 1 (PE + Contract + Proposal + Leave + Ot + Travel + VehicleBooking) — grep `ApprovalWorkflowLevelId` trong `Domain/` ra 7 entity. Chặn-lịch-sự chỉ soi PE ⇒ **vẫn nổ FK 547** khi workflow thuộc module Văn phòng số (khớp §F.3 blast-radius ≥6 module).
2. **CẢ 7 LevelOpinion đều là `AuditableEntity`** (không phải BaseEntity) — vd `PurchaseEvaluationLevelOpinion.cs`, `ProposalLevelOpinion.cs:16`. Hệ quả: `Remove()` một opinion **KHÔNG xoá cứng**`AuditingInterceptor.cs` đổi `Deleted→Modified` ⇒ row Ở LẠI ⇒ **FK vẫn Restrict**. Muốn dọn rác phải `ExecuteDeleteAsync()` (bypass change-tracker).
3. **Chỉ PE + Contract có `HasQueryFilter(!IsDeleted)`**; Proposal/Leave/Ot/Travel/VehicleBooking **KHÔNG** có (grep `HasQueryFilter` toàn `Configurations/` = 16 hit, 0 hit cho 5 bảng Office). ⇒ F-5a "loại trừ opinion của phiếu đã IsDeleted" **phải viết `!p.IsDeleted` TƯỜNG MINH**, không được dựa global filter (dựa filter ⇒ đúng cho PE, SAI cho 5 module Office).
4. `Microsoft.EntityFrameworkCore.Relational 10.0.6` đã có trong `SolutionErp.Application.csproj:13``ExecuteDeleteAsync` dùng được ở tầng Application.
---

View File

@ -0,0 +1,130 @@
# sub-implementer-backend-4 — WAVE 3: vá 6 test đỏ (hạng mục F, S155)
Run: `2026-07-27-S155-pe-delete-approver` · vai: implementer-backend · ngày 2026-07-27
---
## BƯỚC 1 — ĐO TRƯỚC KHI SỬA (không sửa mù)
Lệnh: `dotnet test SolutionErp.slnx --nologo -v minimal`
```
Failed! - Failed: 6, Passed: 499, Skipped: 0, Total: 505 - SolutionErp.Infrastructure.Tests.dll (net10.0)
```
(Domain: 45 PASS — không đỏ.)
6 đỏ ĐÚNG như bảng lead giao. Thông điệp lỗi THẬT (trích từ output):
| # | Test | Exception THẬT | Ném tại |
|---|---|---|---|
| 1 | `F1c_SafeUpdate_LogsChangelogWithReason_OnlyToUnfinishedPes` | `DbUpdateConcurrencyException: expected to affect 1 row(s), but actually affected 0 row(s)` | `ApprovalWorkflowV2AdminFeatures.cs:806` (`SaveChangesAsync`) |
| 2 | `F12_SafeUpdate_AddsPersonToExistingLevel_RunningPeAcceptsNewApproverImmediately` | y hệt #1 | `:806` |
| 3 | `F16_ProposalWorkflow_SafeUpdate_Succeeds_AndWritesNoPeChangelog` | y hệt #1 | `:806` |
| 4 | `F14_RemoveApproverWithLiveOpinion_BlockedPolitely_NotFkViolation` | `InvalidOperationException: association ... 'ApprovalWorkflowLevel' and 'PurchaseEvaluationLevelOpinion' ... has been severed, but the relationship is ... required` | **UpdateAwDefinitionTests.cs:580** (dòng CHỨNG-NHÂN của CHÍNH TEST, không phải handler) |
| 5 | `F14_F5a_OpinionBelongsToDeletedPe_RemovalAllowed_AndOrphanPurged` | y hệt #4 | handler `:755` `db.ApprovalWorkflowLevels.Remove(gone)` |
| 6 | `ZZ_DIAG_AddLevel` | giàn giáo chẩn-đoán, tự ném `Exception` để in tracker | — |
Dump quyết định (từ ZZ_DIAG):
```
ENTRIES=[ApprovalWorkflowLevel/Modified/Id=60537ebc-...]
TRACKER=[ApprovalWorkflow/Modified || ApprovalWorkflowLevel/Modified || User/Unchanged ×3
|| Role/Unchanged || IdentityUserRole/Unchanged || ApprovalWorkflowStep/Unchanged
|| ApprovalWorkflowLevel/Unchanged]
```
---
## BƯỚC 2 — ROOT CAUSE ĐO ĐƯỢC (không chép giả thuyết)
### LỚP B (3 test) — nguyên nhân KHÔNG phải `AsNoTracking`
Giả thuyết lead: "vùng ghi changelog dùng `db.PurchaseEvaluations.AsNoTracking()` rồi đem cập nhật ⇒ affected 0".
**BÁC BỎ bằng đọc mã:** `:778-783` chỉ `Select(p => new { p.Id, p.Phase })` — projection sang anonymous type, KHÔNG hề gán/ghi lại PE nào. Không có UPDATE nào phát cho `PurchaseEvaluations`. Và F16 (module Đề xuất) thậm chí KHÔNG vào nhánh changelog PE (`:774-776` lọc `DuyetNcc/DuyetNccPhuongAn`) mà vẫn đỏ y hệt ⇒ thủ phạm nằm NGOÀI vùng changelog.
**Root cause THẬT (khớp đúng dump `Added` → `Modified`):**
`src/Backend/SolutionErp.Domain/Common/BaseEntity.cs:5`
```csharp
public Guid Id { get; set; } = Guid.NewGuid(); // ⇐ khoá LUÔN có giá trị ngay khi `new`
```
Handler thêm dòng Cấp mới bằng **navigation-add** trên entity cha ĐANG ĐƯỢC THEO DÕI
(`:730-747`): `step.Levels.Add(new ApprovalWorkflowLevel { ... })`.
Khi `DetectChanges` phát hiện một entity LẠ trong collection của cha đã-tracked, EF quyết
`Added` hay `Modified` bằng heuristic **"khoá đã set ⇒ coi là đã tồn tại"**. Vì `BaseEntity`
gán sẵn `Id = Guid.NewGuid()`, khoá KHÔNG BAO GIỜ là default ⇒ EF chọn **Modified** ⇒ phát
`UPDATE ApprovalWorkflowLevels SET ... WHERE Id = @p` cho một dòng CHƯA TỒN TẠI ⇒ 0 row
`DbUpdateConcurrencyException`.
Bằng chứng đối chứng: `POST` tạo mới (`CreateAwDefinitionCommandHandler`) KHÔNG dính vì nó
`db.ApprovalWorkflows.Add(wf)` — add từ GỐC ⇒ cả đồ thị bị ép `Added`, heuristic không chạy.
**Fix:** đăng ký dòng mới TƯỜNG MINH qua `DbSet.Add` (ép state `Added`, bỏ qua heuristic).
### LỚP A (2 test) — `ExecuteDelete` không đụng change-tracker (đúng hướng lead) + 1 ca KHÁC
`PurgeOrphanOpinionsAsync` (`:870-902`) dùng `ExecuteDeleteAsync` — đi thẳng SQL, **không**
cập nhật change-tracker. Bản sao `PurchaseEvaluationLevelOpinion` mà test đã `Add`+`Save`
vẫn nằm trong tracker ở state `Unchanged`. Tới `db.ApprovalWorkflowLevels.Remove(gone)`
(`:755`), EF chạy cascade-check TỨC THÌ (`CascadeDeleteTiming.Immediate` = mặc định), thấy
dependent còn tracked + FK `Restrict` + non-nullable ⇒ ném `InvalidOperationException`
NGAY TẠI `Remove()` (stack: `InternalDbSet.Remove``StateManager.CascadeDelete`
`HandleConceptualNulls`) — chưa kịp tới SaveChanges.
🔴 **PHÂN BIỆT 2 CA (không được gộp):**
- **F14** (phiếu CÒN SỐNG): handler PHẢI chặn 409. Đo được: guard `:658-665` CHẠY ĐÚNG,
mọi assert phía handler đều xanh. Chỗ nổ là **dòng 580 của chính test** (khối CHỨNG-NHÂN
tự tay `Remove` để chứng minh FK là thật) — xem BƯỚC 5.
- **F14_F5a** (phiếu ĐÃ xoá mềm): handler PHẢI cho qua + purge. Đây mới là lỗi production thật.
---
## BƯỚC 3 — CÁC THAY ĐỔI
### (1) `ApprovalWorkflowV2AdminFeatures.cs` — vá LỚP B (dòng Cấp mới bị đoán nhầm là "đã tồn tại")
Vùng `toAdd` (`:730-759` sau sửa): tách `new ApprovalWorkflowLevel {...}` ra biến rồi
**đăng ký tường minh**:
```csharp
db.ApprovalWorkflowLevels.Add(newLevel); // ép state Added, không qua heuristic khoá-đã-set
step.Levels.Add(newLevel); // giữ nav in-memory nhất quán cho phần đọc phía sau
```
Đúng khuôn đã có sẵn trong repo: `PeWorkItemBudgetFeatures.cs:44` cũng `db.X.Add(rec)` tường minh.
### (2) `ApprovalWorkflowV2AdminFeatures.cs` — vá LỚP A (`ExecuteDelete` bỏ quên change-tracker)
Thêm helper `DetachTrackedOpinionsOn(IApplicationDbContext, HashSet<Guid>)` (đặt cạnh
`PurgeOrphanOpinionsAsync`) + gọi NGAY TRƯỚC vòng `db.ApprovalWorkflowLevels.Remove(gone)`.
Quét `((DbContext)db).ChangeTracker.Entries()` theo **tên cột** `ApprovalWorkflowLevelId`
⇒ phủ đủ **cả 7 bảng chữ ký** (PE + HĐ + Proposal/Leave/Ot/Travel/Vehicle — đã kiểm:
`grep -rl ApprovalWorkflowLevelId src/.../Domain/` ra ĐÚNG 7 file, không dư entity nào khác)
mà không phải liệt kê tay từng DbSet.
Cast `((DbContext)db)` là khuôn đã có trong repo (`PeWorkItemBudgetFeatures.cs:52`,
`PurchaseEvaluationFeatures.cs:125`) ⇒ **KHÔNG phải đổi `IApplicationDbContext`**.
An toàn: tới điểm gọi, guard F-5 (`:658-673`) đã đọc DB bằng `AsNoTracking` và xác nhận các
dòng đó có **0 chữ ký còn hiệu lực** ⇒ bản sao nào còn tracked cũng là rác ⇒ detach = làm
tracker khớp lại DB (không nuốt dữ liệu thật).
### (3) `UpdateAwDefinitionTests.cs` — GỠ HẲN `ZZ_DIAG_AddLevel`
Xoá trọn `[Fact] ZZ_DIAG_AddLevel` (26 dòng giàn giáo, ném `Exception` để in tracker).
KHÔNG đụng bất kỳ `[Fact]` nào khác.
---
## BƯỚC 4 — ĐO SAU KHI VÁ (chạy riêng lớp test)
`dotnet build SolutionErp.slnx`**0 Warning · 0 Error**.
`dotnet test --filter FullyQualifiedName~UpdateAwDefinitionTests`:
```
Failed! - Failed: 1, Passed: 14, Skipped: 0, Total: 15
```
- LỚP B: **F12 · F1c · F16 → XANH** (F12 xanh với assert NGUYÊN VẸN — kể cả nửa contrast
`ForbiddenException` trước sửa, `CurrentApprovalLevelOrder == 2` sau sửa, và chữ ký gắn
đúng dòng Cấp mới của bob).
- LỚP A: **F14_F5a → XANH**.
- `ZZ_DIAG` → biến mất (15 test còn lại, trước là 16).
- **F14 → VẪN ĐỎ**, xem BƯỚC 5.

View File

@ -0,0 +1,94 @@
# sub-implementer-backend-5 — WAVE 5 BE: vá F1 + F2 (2 FLAG HIGH mất dữ liệu)
> Ghi TRONG LÚC LÀM, flush từng phần, tự `wc -c` kiểm byte thật.
- Ngày: 2026-07-27 · run `2026-07-27-S155-pe-delete-approver`
- Vật sửa: `src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs`
- Nền đo trước wave: `dotnet build` 0W/0E · `dotnet test` 549 PASS / 0 FAIL
---
## §0 — ĐỌC NGUYÊN VĂN F1 + F2 (reviewer-diff-dot1.md:33-80)
F1: `PurgeOrphanOpinionsAsync` (`:886-918`) dùng `ExecuteDeleteAsync` = auto-commit NGAY khi
không có transaction ambient. Gọi tại `:681-683`, `SaveChangesAsync` mãi `:822`. 4 nguồn ném
nằm GIỮA (`ResolveUserNamesAsync:686` round-trip · `Remove():771` severed-association ·
`db.PurchaseEvaluations…ToListAsync:794-800` round-trip · ghi changelog N phiếu trong SaveChanges).
Tiêu chí nghiệm thu: test giả lập SaveChanges ném SAU purge ⇒ số row opinion KHÔNG đổi.
F2: điều kiện purge `:889-891` (+6 khối lặp tới `:915`) rộng hơn nhãn "mồ côi" ở CẢ HAI vế —
(a) `!db.PurchaseEvaluations.Any(p => p.Id == … && !p.IsDeleted)` coi phiếu XOÁ MỀM ngang phiếu
không tồn tại; (b) `o.IsDeleted` — opinion xoá mềm trên phiếu CÒN SỐNG = bản ghi kiểm toán.
Phương án (a) của reviewer: chỉ purge khi phiếu cha thật sự KHÔNG CÒN ROW
(`IgnoreQueryFilters().Any(...)`), phiếu xoá mềm thì CHẶN admin như phiếu sống.
---
## §1 — ĐO NỀN TRƯỚC KHI SỬA (không tin doc, chạm đĩa)
| Sự thật | Bằng chứng |
|---|---|
| `IApplicationDbContext` KHÔNG expose `Database` | `grep -n "Database\|BeginTransaction" src/.../Common/Interfaces/IApplicationDbContext.cs`**0 hit** |
| Handler đã có tiền lệ ép kiểu `((DbContext)db)` | `ApprovalWorkflowV2AdminFeatures.cs:941` (trong `DetachTrackedOpinionsOn`) |
| Tiền lệ transaction repo | `ContractCodeGenerator.cs:35-59``var context = (DbContext)db; await using var tx = await context.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);` + try/Commit/catch/Rollback |
| `Microsoft.EntityFrameworkCore.Relational` CÓ trong Application | `SolutionErp.Application.csproj``<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.6" />` |
| 7 bảng `*LevelOpinion` KHÔNG có global query filter | `grep -rn "HasQueryFilter" Configurations/` → chỉ 1 hit `PurchaseEvaluationConfiguration.cs:84` (bảng CHA). ⇒ vế `o.IsDeleted` trong purge là THẬT (không phải dead code) |
| FK opinion→cha = **Cascade**, opinion→Level = **Restrict** | `PurchaseEvaluationLevelOpinionConfiguration.cs:20-28` |
| SQLite fixture BẬT FK enforcement thật | test cũ `UpdateAwDefinitionTests.cs:562-565` assert `DbUpdateException` khi `Remove(level)` có opinion ⇒ FK Restrict fire thật trong SQLite |
| Fixture SQLite hỗ trợ transaction | `SqliteDbFixture.cs:36-40` comment + `UseSqlite(shared connection)` |
### Hệ quả SUY RA TỪ ĐO (quan trọng cho thiết kế)
FK cha→opinion là **Cascade** ⇒ xoá cứng phiếu cha thì opinion cũng đi theo ⇒ **"mồ côi thật"
(cha KHÔNG CÒN ROW) gần như không sinh ra được qua đường ứng dụng**. Nghĩa là sau khi thu hẹp
theo F2, `PurgeOrphanOpinionsAsync` gần như là **lớp phòng-vệ-chiều-sâu** cho dữ liệu lệch sinh
ngoài luồng (sửa DB tay / import / FK tắt), KHÔNG còn là đường chạy thường ngày. Đúng ý F2:
guard chặn HẾT những gì còn cha, purge chỉ dọn thứ đã không thể khôi phục.
---
## §2 — VÁ F1 (rào giao dịch) — ĐÃ LAND
Kỹ thuật chọn: `await using var tx` + `CommitAsync` cuối, **KHÔNG try/catch** — đúng khuôn nhà
(`LeaveOtApprovalFeatures.cs:34-44`, `ProposalFeatures.cs:386`, `TravelVehicleApprovalFeatures.cs:822`).
Dispose của `IDbContextTransaction` rollback nếu chưa Commit ⇒ **diff là CHÈN-THÊM, 0 dòng thân
method bị thụt lề lại** (nếu bọc try/catch thì ~150 dòng phải re-indent = nhiễu review, khó soi).
- Mở rào: ngay TRƯỚC `PurgeOrphanOpinionsAsync` (thao tác không-hoàn-tác đầu tiên). Guard phía
trên chỉ ĐỌC `AsNoTracking` rồi ném, không cần nằm trong rào; mở sớm hơn chỉ kéo dài giữ khoá.
- KHÔNG dùng `IsolationLevel.Serializable` như 4 code-generator: ở đó Serializable chống ĐUA
cấp-số; ở đây yêu cầu là NGUYÊN TỬ, siết isolation chỉ làm màn quản trị dễ deadlock.
- `ExecuteDeleteAsync` tự enlist vào transaction đang mở của context ⇒ 7 câu DELETE nay cùng
sống/chết với `SaveChangesAsync`.
## §3 — VÁ F2 (thu hẹp purge + chặn hồ sơ xoá mềm) — ĐÃ LAND
Phân hoạch KÍN, không còn kẽ (đây là điểm thiết kế chính):
| Trạng thái chữ ký | Xử lý sau vá | Ai lo |
|---|---|---|
| cha SỐNG + ý kiến SỐNG | chặn 409 (thông điệp CŨ, giữ nguyên) | `CountLiveOpinionsAsync` (tầng 1) |
| cha XOÁ MỀM (row còn) | **chặn 409 + nêu TÊN hồ sơ** ← MỚI | `ListRetainedOpinionsAsync` (tầng 2) |
| ý kiến XOÁ MỀM, cha sống | **chặn 409 + nêu TÊN hồ sơ** ← MỚI | `ListRetainedOpinionsAsync` (tầng 2) |
| cha KHÔNG CÒN ROW | purge (xoá cứng) — đường duy nhất còn lại | `PurgeOrphanOpinionsAsync` |
- Áp CẢ 7 bảng chữ ký (PE · HĐ · Đề xuất · Nghỉ phép · Tăng ca · Công tác · Đơn xe), tầng 2
JOIN sang cha để lấy mã hồ sơ: `MaPhieu` · `MaHopDong` · `MaDeXuat` · `MaDonTu`×4.
- Guard tầng 2 áp cho **cả `toRemove` lẫn `approverChanged`** — reviewer viết "chặn admin **như
phiếu sống**", mà phiếu sống thì cả bỏ-người lẫn đổi-người đều bị chặn. Đổi người trên dòng
còn giữ chữ ký của hồ sơ xoá mềm = khi khôi phục, ý kiến người A hoá thành của người B.
- `IgnoreQueryFilters()` BẮT BUỘC ở tầng 2 và ở purge: PE/Contract có global filter `!IsDeleted`
(`PurchaseEvaluationConfiguration.cs:84`) — không tắt thì hồ sơ xoá mềm biến mất khỏi JOIN và
ta lại kết luận "mồ côi" = đúng cái sai đang chữa.
- Sửa luôn dòng changelog `"Dọn N chữ ký mồ côi (thuộc phiếu đã xoá)"` → `"(hồ sơ mang chữ ký đã
KHÔNG CÒN trong hệ thống)"` — nhãn cũ nay MÔ TẢ SAI việc đang làm.
### Đo ngay sau khi vá (trước khi đụng test)
`dotnet build SolutionErp.slnx` → **0 Warning / 0 Error**.
`dotnet test --filter UpdateAwDefinitionTests` → **14 PASS / 1 FAIL**. Đúng 1 fail, đúng cái phải
fail: `F14_F5a_OpinionBelongsToDeletedPe_RemovalAllowed_AndOrphanPurged` — test này khẳng định
CHÍNH hành vi mà F2 gọi là sai ("phiếu xoá mềm ⇒ cho gỡ + purge chữ ký"). Thông điệp thật nhận
được: `… còn 1 chữ ký/ý kiến đang LƯU, thuộc: Phiếu duyệt NCC PE-F14b-001 — đã xoá …`
⇒ chứng minh 3 việc cùng lúc: guard tầng 2 CHẠY · `IgnoreQueryFilters()` DỊCH ĐƯỢC trên SQLite ·
thông điệp NÊU ĐÚNG TÊN hồ sơ. 0 test nào khác đỏ ⇒ vá không lan.

View File

@ -0,0 +1,124 @@
# sub-implementer-frontend-2 — [F-11] nối cờ F6 `allowApproverDelete` vào Designer V2
> run `2026-07-27-S155-pe-delete-approver` · ĐỢT 1 · vai `implementer-frontend`
> Spec canonical: `spec-pe-delete-approver.md` §F.4 mục **F-11** (dòng 303) + §F.2 bảng 13 thao tác hạng **2a ✅ AN TOÀN**.
> Ghi TRONG LÚC LÀM (5/5 sub run này mất return vì #53) — file này luôn hợp lệ giữa chừng.
## 0. Phạm vi tự chốt trước khi viết dòng nào
| Làm | Không làm (đợt 2) |
|---|---|
| 5 dây Designer fe-admin + ô tick F6 | nút Xóa ở màn duyệt (`PeWorkflowPanel.tsx`) |
| 1 dây hiển thị chỉ-xem fe-user (`WorkflowMatrixViewPage:282`) | menu restructure (`Layout.tsx` regex, `menuKeys`) |
| type/DTO FE tương ứng (2 chỗ) | màn "Đã xóa" |
**Ý nghĩa cờ (viết đúng, không suy diễn):** **PER-NGƯỜI**, KHÔNG per-Cấp. 1 `Level` row = 1 người
(`ApproverUserId` là field của row); "Cấp" chỉ là tập row cùng `Order`. Owner chốt per-ROW để giữ
sát ý *"người đứng đầu phòng"* = **1 người, admin chỉ đích danh** (spec §B2 dòng 60-66).
Khối UI tick nằm trong ô `Quyền duyệt {tên NV}` (`:928-930`) ⇒ ngữ cảnh per-người đã hiện sẵn trên màn.
## 1. Đối chứng: F5 `allowApproverFinalize` đi qua ĐÚNG 5 chỗ nào (đọc trước, không tin spec suông)
Grep `allowApproverFinalize` trên `fe-admin/` → 5 hit trong `ApprovalWorkflowsV2Page.tsx` ngoài ô tick:
| Dây | Line (trước sửa) | Vai trò | Hỏng gì nếu thiếu |
|---|---|---|---|
| 1 | `:54` | `LevelDto` — mirror BE `AwAdminOverviewDto` (đọc VỀ) | TS2339 khi đọc `l.allowApproverDelete` ở dây 3 |
| 2 | `:110` | `EditLevelEntry` — state form đang soạn | TS2339 ở `updateField` + dây 4/5 |
| 3 | `:159` | `copyFromDefinition()`**Nhân bản** version cũ | tick cũ mất trắng khi admin bấm "Nhân bản" |
| 4 | `:177` | `makeDefaultLevelEntry()` — "+ Thêm NV" | entry mới thiếu key ⇒ TS2739 (object literal thiếu field) |
| 5 | `:605` | **payload POST** `/approval-workflows-v2` | 🔴 **BẪY**: tick xong KHÔNG gửi lên BE |
🔴 **Vì sao dây `:605` là bẫy thật (không phải cảnh báo lễ nghi):** payload POST là object literal
**liệt kê từng field bằng tay** (`:594-606`), KHÔNG spread `...e`. TypeScript **không** báo lỗi khi
thiếu field (thừa field mới lỗi, thiếu thì chỉ là object hẹp hơn) ⇒ build vẫn PASS, UI tick vẫn
hiện dấu tick, `onChange` vẫn chạy, state vẫn đổi — **chỉ có BE không bao giờ nhận**. Không có
compiler/test nào bắt; chỉ lộ khi UAT tick → lưu → mở lại thấy trống, hoặc tệ hơn: quyền xóa im
lặng không tới người được chỉ định.
## 2. Thay đổi đã áp — 3 file, 10 neo
### 2.1 `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx` (+28 dòng) — 5 dây + ô tick
| # | Line MỚI (cũ) | Nội dung |
|---|---|---|
| Dây 1 | `:58` (54) | `LevelDto` + `allowApproverDelete: boolean` ngay dưới `allowApproverFinalize` |
| Dây 2 | `:116` (110) | `EditLevelEntry` + `allowApproverDelete: boolean` |
| Dây 3 | `:168` (159) | `copyFromDefinition()``allowApproverDelete: l.allowApproverDelete ?? false` |
| Dây 4 | `:188` (177) | `makeDefaultLevelEntry()``allowApproverDelete: false` |
| **Dây 5** | **`:620`** (605) | **payload POST** `/approval-workflows-v2``allowApproverDelete: e.allowApproverDelete` |
| Ô tick | `:1025-1034` | `<label class="col-span-2 … text-rose-700">` ngay DƯỚI ô F5 (kết ở `:1024`) |
Nhãn ô tick (tiếng Việt, per-người): **"Cho phép NV này XÓA phiếu khi đến lượt duyệt của mình"**.
Màu `text-rose-700` phân biệt với F5 `text-emerald-700` — hành vi phá huỷ, cố ý cho khác màu.
`updateField``(field: keyof EditLevelEntry, value: boolean)` (`:940`) ⇒ chuỗi `'allowApproverDelete'`
được TS kiểm, không phải magic string.
### 2.2 `fe-user/src/types/approvalWorkflowV2.ts` (+7/1)
`AwLevelDto` + `allowApproverDelete: boolean` (`:27`); header đổi **"8 Allow* flag"** → **"9 Allow* flag"** (`:5-6`).
### 2.3 `fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx` (+2) — dây chỉ-xem
`:284` (F5 ở `:282`) — `<FlagRow active={r.level.allowApproverDelete} … colSpan2 />` cùng nhãn với Designer.
### 2.4 🔴 Dây `:605`/`:620` — KHAI RIÊNG THEO YÊU CẦU: **ĐÃ NỐI**, và đây là bằng chứng nó thật sự là bẫy
Kiểm cuối cùng, sau khi đã build xong. Grep `allowApproverDelete` toàn repo `*.ts,*.tsx` = **10 hit / 3 file**,
trong đó hit `ApprovalWorkflowsV2Page.tsx:620` nằm **bên trong** `s.levelEntries.map(e => ({…}))` của
`await api.post('/approval-workflows-v2', …)`.
**Ba tầng cùng im lặng nếu thiếu dòng này** (đo, không đoán):
1. **TS không bắt** — payload là object literal liệt kê tay từng field (`:609-621`), **không** `...e`.
Thiếu key = object hẹp hơn, hợp lệ với `any` param của `api.post` ⇒ build vẫn `✓ built`.
2. **BE không bắt**`AwLevelInput` là positional record với **default `= false`**
(`ApprovalWorkflowV2AdminFeatures.cs:216-230`) ⇒ JSON thiếu key bind thẳng `false`, **không 400**,
không validator nào kêu.
3. **UI không bắt** — ô tick là controlled input trên state FE, tick vẫn hiện dấu, vẫn đổi màu.
⇒ Chuỗi "tick → lưu → toast xanh → DB ghi `false`" **hoàn toàn không có tín hiệu lỗi ở bất kỳ tầng nào**.
Chỉ lộ khi UAT mở lại thấy ô trống, hoặc tệ hơn: người được chỉ định bấm xóa thì **403** mà không ai hiểu vì sao.
## 3. Verify
| Phép đo | Kết quả |
|---|---|
| `npm run build` **fe-admin** | ✅ **PASS**`✓ 1950 modules transformed` · `✓ built in 21.05s` · bundle `index-_gosUXpu.js` 1,630.28 kB · **0 TS error** |
| `npm run build` **fe-user** | ✅ **PASS**`✓ 1939 modules transformed` · `✓ built in 8.32s` · bundle `index-B-nir85X.js` 1,536.32 kB · **0 TS error** |
| Warning | 3 cái, **pre-existing 100%**: `@import` phải đứng trước rule · chunk >500 kB · `realtime.ts` INEFFECTIVE_DYNAMIC_IMPORT |
| Dây đã nối | **5/5** + ô tick + 2 neo fe-user (grep 10 hit / 3 file) |
| `git status` | đúng **3 file code** + sub-MD này. (`.session-counter.json` = harness, không phải tôi; `sub-implementer-backend-1.md` = lane BE song song) |
**Mirror / SHA:** slice này **KHÔNG có cặp mirror nào** — Designer chỉ tồn tại ở `fe-admin`
(fe-user không có `ApprovalWorkflowsV2Page.tsx`), matrix chỉ-xem chỉ tồn tại ở `fe-user`
(fe-admin không có `WorkflowMatrixViewPage.tsx`, cũng không có `types/approvalWorkflowV2.ts`).
⇒ 3 file, 0 cặp SHA. Không phải bỏ sót mirror.
**Chứng minh đợt-2 chưa bị đụng:** `PeWorkflowPanel.tsx` md5 **`02671be6334110028c55fe82f5e70dee`** ×2 app
— khớp đúng con số spec §E3 ⇒ file đặt nút Xóa còn nguyên vẹn và vẫn byte-identical.
## 4. 🔴 Phụ thuộc chéo lane — cần lane BE xác nhận (chưa xong lúc tôi return)
Đo **2 lần**, lane BE land giữa chừng:
- **Lúc bắt đầu:** `AllowApproverDelete` trong `src/Backend` = **0 hit**.
- **Lúc return (sau build):** đã có **entity** `ApprovalWorkflow.cs:169 public bool AllowApproverDelete`
+ migration `20260727033522_AddPeAllowApproverDelete` (3-file). ✅ **Tên khớp chính xác** cái FE chờ.
NHƯNG `SolutionErp.Application/` vẫn **0 hit****4 neo DTO chưa nối**.
⇒ Đúng lúc này, tick trên Designer **vẫn chưa tới được DB**: cột đã có, ô tick đã có, nhưng
không có đường DTO nối 2 đầu. Cần lane BE đóng nốt 4 neo dưới đây (đây là số dòng của khuôn F5,
đo trực tiếp trên file):
- **Đọc về:** `AwLevelDto` (`…AdminFeatures.cs:41`) phải có `bool AllowApproverDelete` + projection `:188`
⇒ camelCase mặc định của System.Text.Json cho ra đúng `allowApproverDelete``LevelDto:58` chờ.
- **Ghi lên:** `AwLevelInput` (`:230`) phải có `bool AllowApproverDelete = false` + map entity `:368`.
🔴 Nếu BE đặt **tên khác** (vd `AllowDeleteWhilePending`) thì FE gửi key lạ → **bị bỏ qua im lặng**,
đúng **cùng một class lỗi** với bẫy `:620`. Đây là chỗ duy nhất 2 lane có thể lệch mà cả 2 build đều xanh.
## 5. Đã CỐ Ý không làm (ranh giới đợt 1/đợt 2)
| Bỏ qua | Lý do |
|---|---|
| Nút Xóa ở màn duyệt (`PeWorkflowPanel.tsx` ×2) | đợt 2, spec mục 14/14a — cần prop + mutation mới |
| `types/purchaseEvaluation.ts` (`fe-admin:274/427` · `fe-user:276/429`) | đó là bundle **PE detail** (`levelOptions` + `approvalFlow`), BE đợt 1 **không** trả cờ này ⇒ khai `boolean` non-optional bây giờ = **type nói dối**. Thuộc gate E4 đợt 2 |
| `Layout.tsx` regex, `menuKeys.ts`, màn "Đã xóa" | checklist mục 3-28, đợt 2 |
| Luật 4-place mirror | không kích: 0 route mới, 0 menu key mới, 0 page mới (spec §C xác nhận thêm nút/mục không kích 4-place) |
<!-- END sub-implementer-frontend-2 -->

View File

@ -0,0 +1,33 @@
# sub-implementer-frontend-6 — WAVE 5 · [F-7] nối dây FE cho `PUT /approval-workflows-v2/{id}` (vá F3 HIGH)
> run `2026-07-27-S155-pe-delete-approver` · WAVE 5 (vá 3 FLAG HIGH trước deploy) · vai `implementer-frontend`
> Ghi TRONG LÚC LÀM, flush từng phần (#53 đã ăn 5/5 sub run này). File luôn hợp lệ giữa chừng.
## 0. F3 nói gì (đọc nguyên văn `reviewer-diff-dot1.md:84-113`, không tóm tắt lại theo trí nhớ)
- **Đo được, không phải cảm giác:** `grep -rn "api.put" fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx`**0 hit**.
3 đường ghi duy nhất của Designer: `api.delete :286` · `api.patch …/user-selectable :297` · `api.post('/approval-workflows-v2') :589`.
- **Hệ quả:** ~600 dòng BE mới (`UpdateAwDefinitionCommand` + Validator + Handler + 3 helper) là **mã chết** với người dùng UAT.
Owner tick cờ F6 → Lưu → POST → sinh **Id MỚI** → phiếu đang treo pin **Id CŨ** (`PurchaseEvaluationWorkflowService.cs:674` `w.Id == awId`) ⇒ cờ **không bao giờ tới** phiếu cần cứu.
- **Reviewer cho 3 đường (a)/(b)/(c); owner đã chốt (a) = nối dây FE trong đợt 1.**
- **Tiêu chí nghiệm thu reviewer đặt (`:113`):** tick cờ F6 cho 1 NV trên version đang có phiếu treo → lưu bằng đường "Sửa tại chỗ" → `GET /api/approval-workflows-v2` trả `allowApproverDelete=true` trên **đúng `id` cũ**, `Version` **KHÔNG tăng**.
## 1. Hợp đồng BE — đọc THẲNG từ mã, không tin MEMORY-PACK
| Neo | File:line | Sự thật |
|---|---|---|
| Endpoint | `ApprovalWorkflowsV2Controller.cs:45-51` | `[HttpPut("{id:guid}")] [Authorize(Policy="Workflows.Create")]`, `mediator.Send(cmd with { Id = id })`**204 NoContent** |
| Command | `ApprovalWorkflowV2AdminFeatures.cs:449-464` | `{ Id, Name, Description, Steps, Reason?=null, Code?=null, ApplicableType?=null, CeoApprovalThreshold?=null }` |
| Step input | `:442-447` | `Id` **BẮT BUỘC** (Guid, không nullable) · `Order` · `Name` · `DepartmentId` · `Levels` |
| Level input | `:424-440` | `Id` **Guid?** (`null` = dòng MỚI) · `Order` · `Name` · `ApproverUserId` · **7 cờ an-toàn** (4×Return, EditDetails, EditBudget, **AllowApproverDelete**) · **2 cờ echo `bool?`** (SkipToFinal, Finalize) |
**3 bẫy payload (task nêu) — đối chiếu mã, cả 3 đều THẬT:**
1. **`CeoApprovalThreshold` ECHO NGHIÊM** — `:529` `if (request.CeoApprovalThreshold != def.CeoApprovalThreshold) throw ConflictException`.
KHÔNG phải "null = bỏ qua" (khác hẳn `Code`/`ApplicableType``:521`/`:525` dùng `is string`/`is int` nên null mới được bỏ qua).
⇒ gửi thiếu ⇒ `null != 5_000_000`**409**. Comment BE `:459-463` tự khai lý do: null LÀ giá trị có nghĩa (= bỏ ngưỡng).
2. **Chỉ trường hạng AN TOÀN.** Cấm (409): ±Bước (`:539`) · đổi `Step.Order` (`:554`) · đổi `Level.Order` từng dòng (`:575`) · ±Cấp / bỏ trống Cấp (`:618-630`) · `Code` · `ApplicableType` · `CeoApprovalThreshold` · F5 `AllowApproverFinalize` (`:580`) · F2 `AllowApproverSkipToFinal` (`:583`) · bỏ/đổi người đang có **chữ ký sống** (`:658-673`).
Cho sửa: thêm/bớt người trong Cấp CÓ SẴN · đổi `ApproverUserId` · 7 cờ quyền-tại-chỗ (gồm **F6**) · tên (workflow/step/level) · `Step.DepartmentId` (`:705-706`, BE **không** kiểm NV có thuộc Phòng hay không).
3. **Payload liệt kê tay từng field** (`:605-621` khối POST) — thêm field mà quên dây = **silent drop**, TS xanh, BE default `false`, UI vẫn tick. Đúng bẫy đã xảy ra thật ở `sub-implementer-frontend-2.md §2.4`.
<!-- phần 2+ ghi tiếp sau khi áp thay đổi -->

View File

@ -0,0 +1,410 @@
# sub-invest-be-1 — ĐIỀU TRA BACKEND: nút XÓA phiếu PE ở màn DUYỆT
- **run-id:** `2026-07-27-S155-pe-delete-approver`
- **vai:** investigator-codebase (INTERNAL, read-only)
- **quy ước:** mọi dòng `[CODE]` = đọc thẳng từ file trên đĩa · `[SUY LUẬN]` = tôi suy ra, chưa có dòng mã nào nói · `[CHƯA XÁC MINH]` = không chứng được
- **trạng thái file:** ĐANG GHI DẦN (ghi ngay khi tìm ra từng mục — chống #53)
---
## Q1 — Đường XÓA hiện có tới đâu?
### F1. CÓ endpoint DELETE PE — nhưng **không có policy authz** `[CODE]`
`src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs:146-151`
```csharp
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await mediator.Send(new DeletePurchaseEvaluationCommand(id), ct);
return NoContent();
}
```
- Route đầy đủ: `DELETE /api/purchase-evaluations/{id}` (`:14` `[Route("api/purchase-evaluations")]`).
- Authz = **CHỈ class-level `[Authorize]` TRẦN** (`:15`) — **KHÔNG có `[Authorize(Policy = "Pe_*.Delete")]`** ở action.
⇒ Bất kỳ user **đã đăng nhập nào** cũng gọi được endpoint này (chỉ bị chặn bởi guard phase ở handler, xem F3).
⇒ Trùng khớp gotcha #82 / bài học S118: menu-ẩn ≠ API-đóng. Đây là **2 tầng độc lập** và tầng API hiện **mở**.
- Tôi đã grep toàn controller: **không một action nào** trong `PurchaseEvaluationsController.cs``[Authorize(Policy=...)]` (0 hit) — cả file dựa hoàn toàn vào guard trong handler. Đây là **thiết kế cố ý đã có từ trước** (comment `:69-71`, `:88-90`, `:120` nói rõ "Class [Authorize] any-auth; handler fine-grained Forbidden").
### F2. CÓ Command/Handler `DeletePurchaseEvaluationCommand` `[CODE]`
`src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs:1392-1411`
```csharp
// ========== DELETE ==========
public record DeletePurchaseEvaluationCommand(Guid Id) : IRequest;
public class DeletePurchaseEvaluationCommandHandler(
IApplicationDbContext db) : IRequestHandler<DeletePurchaseEvaluationCommand>
{
public async Task Handle(DeletePurchaseEvaluationCommand request, CancellationToken ct)
{
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
if (entity.Phase != PurchaseEvaluationPhase.DangSoanThao
&& entity.Phase != PurchaseEvaluationPhase.TuChoi)
throw new ConflictException("Chỉ xóa được phiếu ở phase Soạn thảo hoặc Từ chối.");
db.PurchaseEvaluations.Remove(entity);
await db.SaveChangesAsync(ct);
}
}
```
Ghi chú quan trọng về handler này:
- Handler **KHÔNG inject `ICurrentUser`****không check ai là người xóa**: không check Drafter-owner, không check role, không check "đến lượt bạn". Guard duy nhất là **phase**.
- Handler **KHÔNG ghi changelog** (đối chiếu Q4 — các thao tác khác đều ghi).
- Handler **KHÔNG đụng bảng con** (xem Q5).
### F3. XÓA hiện tại là **SOFT delete** — nhưng gián tiếp qua interceptor `[CODE]`
`db.PurchaseEvaluations.Remove(entity)` **trông như** hard-delete, nhưng bị `AuditingInterceptor` chặn lại và đổi thành soft:
`src/Backend/SolutionErp.Infrastructure/Persistence/Interceptors/AuditingInterceptor.cs:54-63`
```csharp
foreach (var entry in context.ChangeTracker.Entries<AuditableEntity>())
{
if (entry.State == EntityState.Deleted)
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = now;
entry.Entity.DeletedBy = userId;
}
}
```
⇒ Vì `PurchaseEvaluation : AuditableEntity`, mọi `.Remove()` trên PE = **UPDATE IsDeleted=1 + DeletedAt + DeletedBy** (DeletedBy lấy từ `ICurrentUser.UserId` — nên **vết "ai xóa" CÓ**, dù handler không tự ghi).
🔴 Đây là **indirect-assignment** giống hệt lớp bẫy gotcha #81-EXT: đọc handler thì tưởng hard-delete, sự thật là soft.
---
## Q2 — State machine PE cho phép xoá ở trạng thái nào?
### F4. Enum ĐỦ 10 giá trị (5 sống + 5 legacy deprecated) `[CODE]`
`src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPhase.cs:15-27`
| Giá trị | Số | Ghi chú trong mã |
|---|---|---|
| `DangSoanThao` | 1 | Nháp |
| `ChoPurchasing` | 2 | `[LEGACY] deprecated` |
| `ChoDuAn` | 3 | `[LEGACY] deprecated` |
| `ChoCCM` | 4 | `[LEGACY] deprecated` |
| `ChoCEODuyetPA` | 5 | `[LEGACY] deprecated` |
| `ChoCEODuyetNCC` | 6 | `[LEGACY] deprecated` |
| `DaDuyet` | 7 | Đã duyệt — terminal thành công |
| `ChoDuyet` | **10** | Đã gửi duyệt — generic intermediate |
| `TraLai` | 98 | Trả lại — Phase riêng |
| `TuChoi` | 99 | Từ chối — terminal khoá phiếu |
⚠️ Lưu ý cho spec: **5 phase legacy 2-6 vẫn nằm trong enum****vẫn lọt lưới các query lũy kế** (xem F9) — bất kỳ luật xóa nào viết theo dạng "≠ DaDuyet" hay "∈ {…}" phải nói rõ 2-6 rơi vào đâu.
### F5. Guard XÓA hiện hành: chặn ChoDuyet — **phiếu `PE/2026/A/046` KHÔNG xóa được** `[CODE]`
`PurchaseEvaluationFeatures.cs:1404-1406` — allow-list = `{DangSoanThao, TuChoi}`. Phiếu ở `ChoDuyet`**409 ConflictException "Chỉ xóa được phiếu ở phase Soạn thảo hoặc Từ chối."**
⇒ Đây chính xác là chỗ chặn cái Tra Sol muốn. **Đường ống đã có sẵn 100%, chỉ thiếu 1 nhánh cho ChoDuyet + gating quyền.** `[SUY LUẬN]` — mã chỉ nói guard, chuyện "chỉ thiếu 1 nhánh" là đánh giá của tôi.
### F6. Guard KHÔNG quan tâm "đã có ≥1 lượt duyệt" `[CODE]`
Không có điều kiện nào đọc `Approvals.Count` / `CurrentApprovalLevelOrder` / `CurrentWorkflowStepIndex` trong handler xóa (`:1399-1410` toàn văn ở F2). Luật hiện hành **chỉ nhìn Phase**, không nhìn tiến-độ-duyệt.
`Lịch sử duyệt (1)` của phiếu A/046 **không phải rào cản kỹ thuật**; nếu spec muốn cấm xóa sau khi đã có người duyệt thì đó là **luật MỚI**, không phải luật đang có.
### F7. Đối chiếu Contract (module anh em) — luật CHẶT HƠN `[CODE]`
`src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs:567-580` (trích nguyên văn):
```csharp
public class DeleteContractCommandHandler(IApplicationDbContext db) : IRequestHandler<DeleteContractCommand>
{
public async Task Handle(DeleteContractCommand request, CancellationToken ct)
{
var entity = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.Id, ct)
?? throw new NotFoundException("Contract", request.Id);
if (entity.Phase >= ContractPhase.DangInKy)
throw new ConflictException("Không được xóa HĐ đã qua phase 'Đang in ký'.");
db.Contracts.Remove(entity);
await db.SaveChangesAsync(ct);
}
}
```
⇒ HĐ dùng **ngưỡng `>=` phase**, PE dùng **allow-list**. Cả 2 đều **Remove() → soft qua interceptor**, đều **không ghi changelog**, đều **không check người**.
---
## Q3 — 🔴 LŨY KẾ (câu quan trọng nhất)
File trung tâm: `src/Backend/SolutionErp.Application/PurchaseEvaluations/PeBudgetAccumulator.cs` (141 dòng, 2 hàm static).
### F8. (a) Điều kiện gom phiếu — có **HAI** phép cộng, không phải một `[CODE]`
**Window chung cả 2 hàm** (`:49-51``:100-102`) — y hệt nhau:
```csharp
var peers = db.PurchaseEvaluations.AsNoTracking()
.Where(p => p.ProjectId == projectId && p.WorkItemId == workItemId
&& p.Id != peId && p.CreatedAt < peCreatedAt);
```
**(A) `ComputeAsync` — lũy kế "CHÍNH XÁC"** (`:40-82`):
- `PrevSubmitted` (`:53-59`): `Phase == ChoDuyet || Phase == DaDuyet``Count` + `SUM(BudgetPeriodAmount ?? 0)`.
🔴 **ChoDuyet ĐÃ được cộng ở đây** — tức phiếu bấm-sai-gói-thầu **đang ăn số ngay ở dòng "chính xác"**, không phải chỉ ở dòng tạm tính.
- `PrevSelected` (`:61-71`): `Phase == DaDuyet AND Suppliers.Any(IsWinner)` → SUM `ThanhTien` các quote `IsSelected`. (ChoDuyet **không** vào dòng này.)
**(B) `ComputePendingAsync` — lũy kế "TẠM TÍNH"** (`:92-139`):
- `PendingSubmitted` (`:104-112`): `Phase ∉ {DangSoanThao, DaDuyet, ChoDuyet, TuChoi}` → thực tế = `{TraLai}` + legacy 2-6. **Cố ý loại ChoDuyet** để không double-count với (A).
- `PendingSelected` (`:114-126`): `Phase ∉ {DangSoanThao, DaDuyet, TuChoi}` **AND** `Suppliers.Any(s => s.IsWinner)`**GỒM ChoDuyet-có-winner** (+ TraLai-có-winner + legacy 2-6) → SUM `ThanhTien` quote `IsSelected`.
- `PriorPes` (`:128-133`): `Phase ∉ {DaDuyet, TuChoi}` → danh sách phiếu "cần lưu ý" (gồm cả Nháp).
🔴 **Hệ quả trực tiếp cho đề bài:** phiếu ChoDuyet bấm sai gói thầu ăn số ở **2 chỗ**: `PrevSubmittedTotal` (dòng "chính xác") qua `BudgetPeriodAmount`, và `PendingSelectedTotal` (dòng "tạm tính") qua tổng báo giá được chọn.
🔴 **"Trả lại" KHÔNG triệt tiêu** — `TraLai` rơi khỏi (A) nhưng **rơi VÀO (B) cả 2 dòng** (`PendingSubmitted` + `PendingSelected` nếu còn winner). Đúng như run.md phán đoán, và đây là bằng chứng mã.
### F9. (b) **KHÔNG có dòng `IsDeleted == false` nào trong 2 query** — lọc đến từ global filter `[CODE]`
Grep toàn `PeBudgetAccumulator.cs`: **0 hit `IsDeleted`** trong thân query. Lọc đến từ EF global query filter:
`src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs:84`
```csharp
b.HasQueryFilter(x => !x.IsDeleted);
```
Và chính comment của accumulator đã khai điều này — `PeBudgetAccumulator.cs:15-16`:
```
// peers = PurchaseEvaluations cùng (ProjectId, WorkItemId), Id != this, CreatedAt < this
// (HasQueryFilter !IsDeleted tự loại phiếu xoá mềm).
```
Củng cố: **`IgnoreQueryFilters` = 0 hit trên toàn `src/Backend`** (grep đã chạy) ⇒ không có đường nào lách filter.
### F10. (c) Set `IsDeleted=true` cho phiếu ChoDuyet → **TỰ RỚT khỏi cả 2 phép cộng, không cần sửa thêm** `[CODE] + [SUY LUẬN]`
`[CODE]`: cả 4 phép cộng đều bắt đầu từ `db.PurchaseEvaluations` (`:49`, `:100`) — kể cả 2 join tiền (`:64-71`, `:119-126`) vì chúng `from p in selectedPeers` / `pendingSelectedPeers`, tức đã bị filter chặn ở gốc. Join sang `PurchaseEvaluationSuppliers`/`Quotes` không cần filter riêng (2 bảng này là `BaseEntity`, **không có** `IsDeleted`, xác nhận `PurchaseEvaluationSupplier.cs:9` + `PurchaseEvaluationQuote.cs:8`).
`[SUY LUẬN]`: ⇒ chỉ cần **nới guard phase**`DeletePurchaseEvaluationCommandHandler` là số lũy kế tự đúng. **Không phải sửa accumulator.** (Chưa chạy runtime để chứng — xem "Chưa xác minh" cuối file.)
### F11. (e) Snapshot `budgetFrozen` (Mig 67) — **KHÔNG tự sửa, đây là nợ số liệu tiềm ẩn** `[CODE]`
- Phiếu `DaDuyet` đọc **11 cột snapshot**, không đọc live: `PurchaseEvaluationFeatures.cs:929` `var frozen = e.Phase == DaDuyet && e.ApprovedBudgetSnapshotAt != null;` → nhánh `:930-960` phục vụ từ `e.ApprovedBudget*`.
- Snapshot được chốt 1 lần tại finalize: `PurchaseEvaluationWorkflowService.cs:1018-1023` gọi `ComputeAsync` rồi gán `ApprovedBudgetPrevSubmittedTotal/Count/PrevSelectedTotal/Count`.
⇒ Nếu phiếu X (ChoDuyet, sai gói) **đã bị tính vào snapshot** của phiếu Y duyệt sau đó, rồi **sau này ta xóa X**, thì **số của Y KHÔNG đổi** (đúng ý đồ "đóng băng", nhưng số đó nay dựa trên 1 phiếu không còn tồn tại).
`[SUY LUẬN]` spec cần chốt: **chấp nhận** (freeze = record-of-decision) hay **phải re-compute snapshot phiếu sau**? Tôi nghiêng "chấp nhận + ghi vết", nhưng đây là quyết định của em main/anh, không phải của tôi.
⇒ Ngoài ra `:956-959`: nhánh frozen **cố tình để Pending* = 0/null** ⇒ phiếu DaDuyet không hiển thị tạm tính, nên xóa 1 phiếu ChoDuyet **không** làm đổi màn hình phiếu đã duyệt.
### F12. (d) Grep hết consumer — **chỉ có 2 hàm accumulator + 4 call-site, KHÔNG có read-site nào khác** `[CODE]`
Lệnh đã chạy (để có thể tái kiểm):
- `grep -rn "PrevSubmitted\|lũy kế" --include=*.cs src/Backend | grep -v Migrations/` → 23 hit, tất cả thuộc 4 file: `PeBudgetAccumulator.cs`, `PurchaseEvaluationFeatures.cs`, `PurchaseEvaluationWorkflowService.cs`, `PurchaseEvaluation.cs` (+ `PurchaseEvaluationConfiguration.cs` khai precision).
- `grep -rn "db\.PurchaseEvaluations" --include=*.cs src/Backend` → 31 hit; ngoài 2 dòng accumulator (`:49`, `:100`), **không hit nào là phép cộng tiền theo peers** (còn lại: FirstOrDefault theo Id, list/inbox join, seeder, usage-count workflow).
- `grep -rn "PurchaseEvaluation" src/Backend/SolutionErp.Application/Reports/ .../ReportsController.cs`**0 hit** ⇒ module Báo cáo KHÔNG gom PE.
- `PeWorkItemBudgetFeatures.cs` (trang ngân sách gói thầu): đọc/ghi bảng `PeWorkItemBudgets` thuần, **không** `Sum` nào trên PE (chỉ `FirstOrDefaultAsync` theo `PeId``:86`, `:157`, `:230`).
**4 call-site của accumulator:**
| # | File:line | Hàm | Dùng để |
|---|---|---|---|
| 1 | `PurchaseEvaluationFeatures.cs:966` | `ComputeAsync` | display live rows 1-2 |
| 2 | `PurchaseEvaluationFeatures.cs:972` | `ComputePendingAsync` | display live rows tạm tính |
| 3 | `PurchaseEvaluationWorkflowService.cs:1018` | `ComputeAsync` | **ghi snapshot** khi finalize |
| 4 | `PurchaseEvaluationWorkflowService.cs:1029` | `ComputePendingAsync` | ghi changelog cảnh báo D4 |
`[SUY LUẬN]` ⇒ Rủi ro "sót read-site" (bài học `cardinality_change_grep_consumers`) ở đây **THẤP**: toàn bộ đường tiền đi qua đúng 1 file 141 dòng. Rủi ro thật nằm ở **snapshot đã đóng băng** (F11), không nằm ở query.
---
## Q4 — Vết / audit
### F13. PE có **2 bảng lịch sử TÁCH VAI** (không phải 1) `[CODE]`
| Bảng | Entity | Vai |
|---|---|---|
| `PurchaseEvaluationApprovals` | `PurchaseEvaluationApproval : BaseEntity` (`PurchaseEvaluationApproval.cs:7`) | **per-approver record** — ai duyệt, FromPhase/ToPhase/Decision/Comment. Config `PurchaseEvaluationConfiguration.cs:156-170` |
| `PurchaseEvaluationChangelogs` | `PurchaseEvaluationChangelog : BaseEntity` (`PurchaseEvaluationChangelog.cs:9`) | **nhật ký thao tác** — EntityType × Action × PhaseAtChange × Summary × ContextNote × FieldChangesJson. Config `:172-190` |
(Phân vai này chính là bài học gotcha #79 — Mig 60 backfill soi nhầm `Approvals` thay vì `Changelogs`.)
**Enum sẵn có:**
- `ChangelogAction` (`Domain/Contracts/ContractChangelog.cs:38-44`): `Insert=1, Update=2, **Delete=3**, Transition=4`**đã có sẵn `Delete`, không cần migration.**
- `PurchaseEvaluationEntityType` (`PurchaseEvaluationChangelog.cs:25-33`): `Header=1, Supplier=2, Detail=3, Quote=4, Workflow=5, Attachment=6`.
### F14. Duyệt / Trả lại / Từ chối ghi vết ở đâu `[CODE]`
Tất cả đi qua **1 helper duy nhất**: `PurchaseEvaluationWorkflowService.LogTransitionAsync` (`:1158-1185`), được gọi **13 lần** (`:160, :269, :315, :644, :828, :870, :909, :925, :942, :950, :1146, :1153` + `:1027` chú thích). Nội dung ghi (`:1175-1185`):
```csharp
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = evaluation.Id,
EntityType = PurchaseEvaluationEntityType.Workflow,
Action = ChangelogAction.Transition,
PhaseAtChange = toPhase,
UserId = actorUserId,
UserName = actorName ?? "Hệ thống",
Summary = $"Chuyển phase {fromPhase} → {toPhase}",
ContextNote = comment, // ← lý do do người duyệt gõ
});
```
Kèm notify Drafter ở `:1188-1197` (DaDuyet / TuChoi / TraLai).
### F15. Handler xóa **KHÔNG ghi changelog** — vết duy nhất là 3 cột của interceptor, và nó THIẾU 2 thứ `[CODE] + [SUY LUẬN]`
`[CODE]` `PurchaseEvaluationFeatures.cs:1399-1410`: 0 dòng `Changelogs.Add`, 0 dòng `Approvals.Add`. Vết duy nhất = `IsDeleted / DeletedAt / DeletedBy` do `AuditingInterceptor.cs:56-62` tự set.
`[SUY LUẬN]` Vết đó **có ai** + **lúc nào**, nhưng **thiếu**:
1. **Lý do xóa** — không có chỗ chứa (không cột nào ngoài 3 cột trên). Đây là điều bắt buộc phải có cho ca "bắt sai gói thầu" (người khác cần biết vì sao phiếu biến mất).
2. **Không ai đọc được** — 3 cột đó chỉ soi bằng SQL. UI đọc lịch sử qua `ListPurchaseEvaluationChangelogsQuery` (`PurchaseEvaluationFeatures.cs:1415-1434`) mà query đó lọc `WHERE PurchaseEvaluationId == id`**phiếu đã xóa không còn mở được** ⇒ vết chôn theo phiếu. Người cùng gói thầu (đang nhìn số lũy kế đột nhiên tụt) **không có cách nào biết** phiếu nào vừa bị rút ra.
### F16. Chèn vết ở đâu cho khớp pattern đang có `[CODE]` (tiền lệ trong repo)
`ChangelogAction.Delete` **đã được dùng ở 5 site PE/HĐ** — đây là khuôn có sẵn, chỉ việc mirror:
- `PurchaseEvaluationSupplierFeatures.cs:164` (xóa NCC khỏi phiếu)
- `PurchaseEvaluationDetailFeatures.cs:255` (xóa hạng mục) và `:392` (xóa báo giá)
- `PurchaseEvaluationAttachmentFeatures.cs:173` (xóa đính kèm)
- `PeDepartmentOpinionFeatures.cs:143` (xóa ý kiến phòng ban)
- (HĐ: `ContractAttachmentFeatures.cs:144`, `ContractDetailsFeatures.cs:467` qua `ChangelogService`)
`[SUY LUẬN]` ⇒ khuôn tự nhiên: trong `DeletePurchaseEvaluationCommandHandler`, **TRƯỚC** `Remove()`, `Add` 1 row `{EntityType=Header (hoặc Workflow), Action=Delete, PhaseAtChange=<phase lúc xóa>, UserId=currentUser, Summary="Xóa phiếu ...", ContextNote=<lý do bắt buộc nhập>}`. Handler hiện **chưa inject `ICurrentUser`** nên phải thêm (mọi handler PE khác đều đã inject — vd `PeWorkItemBudgetFeatures.cs:83`).
⚠️ Nhưng row changelog đó **cũng chôn theo phiếu** (F15 mục 2). Nếu muốn người khác thấy, phải có nơi hiển thị NGOÀI phiếu — `[CHƯA XÁC MINH]` repo hiện **không có** bảng audit toàn cục (`AuditLogs` vẫn nằm ở mục "future" trong skill `contract-workflow`, tôi đã grep `class AuditLog` → 0 hit trong `src/Backend/SolutionErp.Domain`).
---
## Q5 — Quan hệ dữ liệu (câu quan trọng thứ 2)
### F17. Bản đồ FK — **6 collection Cascade + 1 Cascade riêng, 0 Restrict phía con** `[CODE]`
`PurchaseEvaluationConfiguration.cs:75-80`:
```csharp
b.HasMany(x => x.Suppliers)...OnDelete(DeleteBehavior.Cascade); // :75
b.HasMany(x => x.Details)...OnDelete(DeleteBehavior.Cascade); // :76
b.HasMany(x => x.Approvals)...OnDelete(DeleteBehavior.Cascade); // :77
b.HasMany(x => x.Changelogs)...OnDelete(DeleteBehavior.Cascade); // :78
b.HasMany(x => x.Attachments)...OnDelete(DeleteBehavior.Cascade); // :79
b.HasMany(x => x.DepartmentOpinions)...OnDelete(DeleteBehavior.Cascade); // :80
```
+ `PurchaseEvaluationLevelOpinionConfiguration.cs:20-28`**Cascade** về PE, **Restrict** về `ApprovalWorkflowLevel`.
+ `Quotes` không FK thẳng lên PE mà qua `Detail` (`PurchaseEvaluationDetailConfiguration.cs:130` Cascade) — và `Quote → Supplier`**Restrict** (`:149-152`).
+ FK ra ngoài: `PE → ApprovalWorkflow` **Restrict** (`:70-73`).
### F18. 🔴 Soft-delete cha ⇒ **cascade KHÔNG BAO GIỜ chạy** ⇒ con ở lại nguyên vẹn `[CODE] + [SUY LUẬN]`
`[CODE]` `AuditingInterceptor.cs:58` đổi `entry.State = EntityState.Deleted → Modified`. EF chỉ phát `DELETE` (và cascade) cho state `Deleted`. Với `Modified` nó phát `UPDATE`.
`[SUY LUẬN]` ⇒ Sau khi xóa 1 PE: **toàn bộ** `PurchaseEvaluationSuppliers`, `Details`, `Quotes`, `Approvals`, `Changelogs`, `Attachments`, `DepartmentOpinions`, `LevelOpinions` **vẫn nằm trong DB, `IsDeleted` không tồn tại/không đổi**, trỏ về 1 phiếu cha vô hình.
**Xác nhận không có cột `IsDeleted` ở con `[CODE]`:** 7/8 bảng con là `BaseEntity` (không có IsDeleted): `PurchaseEvaluationSupplier.cs:9`, `Detail.cs:7`, `Quote.cs:8`, `Approval.cs:7`, `Changelog.cs:9`, `Attachment.cs:15`. Chỉ 2 bảng là `AuditableEntity`: `PurchaseEvaluationDepartmentOpinion.cs:24` + `PurchaseEvaluationLevelOpinion.cs:24` — nhưng cả 2 **không có `HasQueryFilter`** (grep `HasQueryFilter` chỉ ra 1 hit duy nhất trong file config PE là dòng `:84` của bảng cha).
**Có lộ số ở query nào khác không? — KHÔNG, nhưng nhờ MAY hơn nhờ thiết kế `[CODE]`:**
Tôi grep toàn bộ read-site đứng thẳng trên bảng con (`db.PurchaseEvaluationSuppliers|Quotes|LevelOpinions`, 38 hit): **mọi hit đều bị chặn bởi 1 trong 2 điều kiện** — (i) join/`from p in peers` bắt nguồn từ `db.PurchaseEvaluations` (nên dính filter cha) — `PeBudgetAccumulator.cs:66/68/121/123`, `PurchaseEvaluationFeatures.cs:656/783`, `CreateContractFromEvaluationFeatures.cs:185`; hoặc (ii) lọc theo `PurchaseEvaluationId == <id phiếu đang mở>` / `supplierRowIds` lấy từ phiếu đó. **Không có 1 read-site nào quét ngang bảng con toàn hệ thống.**
`[SUY LUẬN]` ⇒ rác **tồn tại thật** nhưng **hiện không lộ số**. Rủi ro là tương lai: ai viết 1 report kiểu `SUM(Quotes) GROUP BY project` mà không join PE sẽ ăn phải rác này. Đáng ghi thành ràng buộc trong spec, không phải blocker.
### F19. `PeWorkItemBudgets` **KHÔNG phải bảng con của PE** — xóa phiếu không đụng tới `[CODE]`
`PeWorkItemBudget` khoá theo cặp `(ProjectId, WorkItemId)` (`PeWorkItemBudgetConfiguration.cs:27` UNIQUE filtered `[IsDeleted]=0`), **dùng chung cho mọi phiếu cùng gói**. Không có FK PE→PeWorkItemBudget (0 hit `HasMany`/`HasOne` giữa 2 entity). Handler chỉ resolve nó **qua** PE (`PeWorkItemBudgetFeatures.cs:86/157`).
⇒ Xóa phiếu **không** được xóa/giảm record ngân sách gói thầu. Đúng ý đồ (bảng ngân sách là "tài liệu sống" — comment `PeWorkItemBudgetFeatures.cs:19-21`).
### F20. Đối chiếu: xóa NCC-trong-phiếu là **HARD delete** `[CODE]`
`PurchaseEvaluationSupplierFeatures.cs:170` `db.PurchaseEvaluationSuppliers.Remove(row)` — vì `PurchaseEvaluationSupplier : BaseEntity` (không `AuditableEntity`) nên interceptor **không** bắt → xóa cứng thật. Có guard `:154` chặn khi còn quote.
⇒ Trong cùng module, `Remove()` cho ra 2 hành vi khác nhau tuỳ base class. Ai đọc spec phải nói rõ đang nói bảng nào (bẫy #81-EXT lớp indirect-assignment).
---
## Q6 — 🔴 KHẢO CỔ GIT: "chỗ cho huy"
### F21. **TÌM THẤY.** "chỗ cho huy" = nút **"Từ chối"** ở màn duyệt, bị gỡ 2026-06-12 `[CODE]`
**Commit:** `6db195dd4270a8c463305d5051c901e05748c403`**Fri Jun 12 2026 14:30:38 +0700** — phiên **S60**.
**Subject:** `[CLAUDE] PurchaseEvaluation: go han hanh dong "Tu choi" - chi con Duyet hoac Tra lai (UAT anh Kiet S60 14:14)`
**Lý do gỡ (verbatim từ mã, `PurchaseEvaluationWorkflowService.cs:94-100`):**
```
// ===== UAT S60 (anh Kiệt 14:14) — GỠ hành động "Từ chối" =====
// "Bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi." Mọi policy đã bỏ
// TuChoi khỏi NextPhases (FE hết nút); guard này chặn caller direct
// (API forge / client cũ cache) — đứng TRƯỚC mọi branch nên chặn CẢ
// Admin manual override (spec = bỏ hẳn hành động, không escape hatch).
// Phase TuChoi + phiếu TuChoi cũ GIỮ display/filter. Flip lại nếu cần:
// xóa guard này + restore transitions trong PurchaseEvaluationPolicy.
```
⇒ Khớp **từng chữ** lời anh: *"hôm trước có cái chỗ cho **hủy** / mọi người nói là ko cần cái đó / giờ lại cần"*. Người yêu cầu gỡ = **anh Kiệt (FDC)**, ngày 12/06/2026 lúc 14:14, lý do = "Duyệt hoặc Trả về thôi" (giản lược UX), **KHÔNG phải vì lỗi kỹ thuật**.
Session log: `docs/changelog/sessions/2026-06-12-S60-S62-pe-budget-workitem-softwarning.md:23-28`.
Diff (6 file): 2 FE `PeWorkflowPanel.tsx` (filter `next.filter(p != TuChoi)`), Domain `PurchaseEvaluationPolicy.cs` (42 dòng transition), Service (+guard 14 dòng), +2 test spec-change. **Test 254 → 256 PASS.**
### F22. 🔴 Guard S60 **VẪN SỐNG** và chính nó trỏ người dùng sang "Xóa phiếu" `[CODE]`
`PurchaseEvaluationWorkflowService.cs:101-106`:
```csharp
if (targetPhase == PurchaseEvaluationPhase.TuChoi)
{
throw new ConflictException(
"Hành động \"Từ chối\" đã được gỡ khỏi quy trình duyệt — chỉ còn Duyệt hoặc Trả lại. " +
"Phiếu cần dừng: dùng Trả lại để người soạn sửa, hoặc Xóa phiếu khi còn Bản nháp.");
}
```
🔴 **Đây là mắt xích logic của cả đề bài:** S60 gỡ "Từ chối" và **chuyển hướng sang "Xóa phiếu"** — nhưng "Xóa phiếu" lại **chỉ chạy được ở Bản nháp** (F5). Vậy phiếu đã gửi duyệt rơi vào **lỗ**: không Từ chối được (gỡ rồi), không Xóa được (guard phase), chỉ Trả lại được — mà Trả lại **không triệt tiêu lũy kế** (F8). Đúng y điều Tra Sol mô tả: *"quay lại không được, phải xóa thì nó mới ko có lũy kế lên"*.
`[SUY LUẬN]` ⇒ Yêu cầu S155 **không phải feature mới**, nó là **đóng cái lỗ do S60 mở ra**. Ghi chú S60 còn để sẵn đường lui: *"Flip lại nếu cần: xóa guard này + restore transitions"*.
### F23. Nguồn gốc nút "Xóa phiếu" hiện tại `[CODE]`
- `4678d19` `[CLAUDE] App+Api: PurchaseEvaluation CQRS + Controller + WorkflowService` — commit **sinh ra** `DeletePurchaseEvaluationCommand` (git log -S).
- `378c993` `[CLAUDE] FE-Admin+FE-User: PE detail polish B12 — Lưu (no close), **Xóa phiếu**, header bar simplify, NCC name col, **no-delete có quotes**` — commit đưa nút Xóa lên UI (phía người soạn).
- **Không có** commit nào GỠ endpoint/handler xóa PE (git log -S `DeletePurchaseEvaluationCommand` chỉ ra 2 commit: 1 tạo + 1 `wal: flush` hôm nay).
**Lệnh đã chạy đầy đủ (để tái kiểm):**
```
git log --oneline -S "DeletePurchaseEvaluationCommand" -- .
git log --oneline -S "Xóa phiếu"
git log --oneline -S "handleDelete" -- fe-admin fe-user → RỖNG
git log --oneline -i --grep="thu h"
git log --oneline -i --grep="xóa phiếu"
git show --stat 6db195d
grep -rni "thu hồi|thuhoi|recall|withdraw" --include=*.cs --include=*.tsx --include=*.ts src fe-admin/src fe-user/src
```
`[CHƯA XÁC MINH]` Tôi **không** chạy `git log --diff-filter=D` (xóa nguyên file) vì hướng "Từ chối bị gỡ" đã cho kết quả khớp verbatim lời anh; nếu reviewer muốn loại trừ khả năng có file bị xóa hẳn thì cần chạy thêm.
---
## Q7 — Pattern tham chiếu
### F24. **KHÔNG có tiền lệ** thu-hồi/hủy một chứng-từ ĐANG CHẠY workflow `[CODE]`
Grep `public record Delete.*Command` toàn `src/Backend/SolutionErp.Application` (lọc các module chứng từ) → chỉ **2** lệnh xóa cấp-chứng-từ tồn tại trong repo:
- `DeletePurchaseEvaluationCommand` (`PurchaseEvaluationFeatures.cs:1394`) — allow-list `{Nháp, Từ chối}`
- `DeleteContractCommand` (`ContractFeatures.cs:565`) — ngưỡng `< DangInKy`
**0 lệnh** xóa/hủy cho `Proposal`, `LeaveRequest`, `OtRequest`, `TravelRequest`, `VehicleBooking`, `ItTicket` — các module này **không có đường rút đơn nào cả**.
**Thứ gần nhất (nhưng KHÔNG phải workflow):** `CancelMeetingBookingHandler` (`src/Backend/SolutionErp.Application/Office/MeetingFeatures.cs:457-479`) — đáng đọc vì nó là **khuôn duy nhất trong repo cho "hủy có vết"**:
```csharp
var isOwner = entity.BookedByUserId == userId;
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isOwner && !isAdmin)
throw new ForbiddenException("Chỉ người đặt phòng hoặc Admin được phép huỷ booking.");
// Status=Cancelled (NOT IsDeleted=true) — preserve history + audit trail.
entity.Status = MeetingBookingStatus.Cancelled;
```
3 điểm đáng mượn: (1) **check người** trong handler (owner-or-admin) — thứ handler xóa PE đang thiếu hoàn toàn (F2); (2) **chuyển TRẠNG THÁI thay vì IsDeleted**, chú thích rõ lý do "preserve history"; (3) DELETE verb ở API nhưng semantics là cancel (`MeetingFeatures.cs:25`).
`[SUY LUẬN]`**Spec S155 phải dựng luật mới**, không có khuôn PE/HĐ để copy. Hai hướng khả dĩ, cả hai đều có tiền lệ trong repo:
- **(A) Nới guard xóa** (rẻ nhất — đường ống đã có, lũy kế tự đúng theo F10) + thêm authz + changelog.
- **(B) Khôi phục "Từ chối"** (S60 để sẵn đường lui, F22) — nhưng `TuChoi` **KHÔNG** triệt tiêu `PrevSubmitted` ở dòng chính-xác? — **có triệt tiêu**: `ComputeAsync` chỉ nhận `{ChoDuyet, DaDuyet}` (`PeBudgetAccumulator.cs:54-55`) và `ComputePendingAsync` loại `TuChoi` ở cả 3 phép (`:108`, `:116`, `:130`) ⇒ **`TuChoi` rơi khỏi 100% các phép lũy kế**. Tức (B) cũng giải quyết được bài toán số liệu, mà **giữ được phiếu để tra cứu** — hợp ý F15 (vết không bị chôn).
Quyết định A/B **không thuộc thẩm quyền tôi** — nêu để em main/anh chọn.
---
## Tổng kết + những gì CHƯA xác minh
**Chưa xác minh (khai rõ, không lấp bằng suy đoán):**
1. **Chưa chạm DB nào** (Dev lẫn prod) — mọi kết luận đến từ đọc mã. Cụ thể **chưa** chứng bằng runtime rằng `IsDeleted=true` làm số lũy kế tụt (F10 là suy luận từ query filter, độ tin cao nhưng chưa có phép đo).
2. **Chưa đếm** hiện prod có bao nhiêu phiếu ChoDuyet-có-winner đang ăn lũy kế sai. Lệnh gợi ý cho implementer (Dev):
`sqlcmd -S "(localdb)\MSSQLLocalDB" -d SolutionErp_Dev -Q "SELECT p.MaPhieu,p.Phase,p.ProjectId,p.WorkItemId FROM PurchaseEvaluations p WHERE p.IsDeleted=0 AND p.Phase=10 AND EXISTS(SELECT 1 FROM PurchaseEvaluationSuppliers s WHERE s.PurchaseEvaluationId=p.Id AND s.IsWinner=1)"`
(prod qua SSH thường chết khi nạp SQL-client — S134/S148, cần implementer làm trực tiếp.)
3. **Chưa chạy** `git log --diff-filter=D` (F23).
4. **Chưa điều tra FE** — thuộc phần `sub-invest-fe-2.md`.
<!-- END sub-invest-be-1 · TOTAL=24 PHAT-HIEN -->

View File

@ -0,0 +1,253 @@
# sub-invest-fe-2 — FE + PHÂN QUYỀN: nút XÓA phiếu PE ở màn DUYỆT
> run: `2026-07-27-S155-pe-delete-approver` · vai: investigator-codebase (READ-only)
> Quy ước: **[CODE]** = đọc thẳng từ file · **[SUY LUẬN]** = diễn giải của tôi · **[CHƯA XÁC MINH]** = không chứng được.
> Ghi TRONG LÚC LÀM (bài học #53).
---
## Q1 — Định vị component khối HÀNH ĐỘNG (`✓ Duyệt` / `← Trả lại`)
### PH-01 [CODE] Component = `PeWorkflowPanel.tsx`, tồn tại ở **CẢ 2 APP**, **byte-identical**
| App | Đường dẫn | md5 |
|---|---|---|
| fe-user | `D:\...\SOLUTION_ERP\fe-user\src\components\pe\PeWorkflowPanel.tsx` (942 dòng) | `02671be6334110028c55fe82f5e70dee` |
| fe-admin | `D:\...\SOLUTION_ERP\fe-admin\src\components\pe\PeWorkflowPanel.tsx` (942 dòng) | `02671be6334110028c55fe82f5e70dee` |
**SỬA 1 BÊN LÀ SÓT.** Quy ước dự án (CLAUDE.md): duplicate giữa 2 app là CÓ CHỦ ĐÍCH.
Các file PE khác cũng byte-identical (cùng md5 2 app):
- `components/pe/PeListPanel.tsx` (264 dòng) — `b372d5e7f6c97b8cbe79ae7c2f1ccef7`
- `components/pe/PeDetailTabs.tsx` (3610 dòng) — `27ec74d8cd75024a4472304018e5e05f`
- `pages/pe/PurchaseEvaluationsListPage.tsx` (712 dòng) — `99e38e167d2f745ea7628174f1d2d6bf`
### PH-02 [CODE] Vị trí render 2 nút — `PeWorkflowPanel.tsx:458-506` (cùng số dòng ở CẢ 2 app)
```
:458 {next.length > 0 && !readOnly && (
:460 <Label className="text-xs">Hành động:</Label> ← nhãn khối "HÀNH ĐỘNG"
:462 {next.map(p => { ← render 1 button / phase kế tiếp
:467 const isSendBack = (p === Phase.DangSoanThao || p === Phase.TraLai) && ...
:470 const isCancel = p === Phase.TuChoi
:480 const label = isSendBack ? '← Trả lại' : isCancel ? '✗ Từ chối' : '✓ Duyệt'
:487-501 <button onClick={() => !isDisabled && setTarget(p)} disabled={isDisabled} .../>
:506 )}
```
Nút KHÔNG hardcode — sinh động từ mảng `next` (danh sách phase kế tiếp BE trả về). Nút Xóa mới sẽ **KHÔNG lọt** vào vòng `next.map` này (Xóa không phải 1 phase) ⇒ phải render **cạnh** khối, không nhét vào map. [SUY LUẬN]
### PH-03 [CODE] "Từ chối" đã bị gỡ khỏi UI (nhưng logic còn) — `PeWorkflowPanel.tsx:254`
```
:254 // UAT S60 (anh Kiệt 14:14): "bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi".
```
⇒ giải thích vì sao ảnh prod chỉ thấy 2 nút. Tiền lệ **gỡ-nút-theo-UAT** đã xảy ra ở đúng khối này.
### PH-04 🔴 [CODE] Bằng chứng "phiếu Nháp có nút Xóa riêng" — `PeWorkflowPanel.tsx:472-476`
```
:472 // S59 anh chốt (UAT: "nhân viên tạo phiếu thì trả lại và từ chối cho ai?"):
:473 // người duyệt CHÍNH LÀ người soạn phiếu → ẩn cả Trả lại + Từ chối
:474 // (trả cho chính mình vô nghĩa — đang sửa inline được; hủy phiếu sai
:475 // = nhờ cấp khác Từ chối, phiếu Nháp có nút Xóa riêng).
:476 if ((isSendBack || isCancel) && evaluation.drafterUserId === currentUser?.id) return null
```
⇒ comment tự khai: đường "hủy phiếu sai" hiện tại = **nhờ cấp khác Từ chối** (mà nút Từ chối đã bị ẩn @S60!) + **Xóa chỉ có ở phiếu Nháp**. Đây chính là lỗ hổng UAT anh Tra đang gặp. [SUY LUẬN]
---
## Q2 — Điều kiện hiện 2 nút hiện tại + nút Xóa mới phải gate bằng gì
### PH-05 [CODE] Chuỗi gate 3 tầng (fe-user & fe-admin giống hệt)
**Tầng 1 — màn hình:** `PurchaseEvaluationsListPage.tsx:37` `const pendingMe = sp.get('pendingMe') === '1'`
→ truyền `readOnly={!pendingMe}` vào `PeWorkflowPanel` tại **2 call-site**: `:588-592` (inline panel) + `:674-678` (focus overlay).
⇒ khối HÀNH ĐỘNG **chỉ sống ở URL `?pendingMe=1`** (= menu "Duyệt"). Ở "Danh sách" → `readOnly=true` → thay bằng dòng chữ `:507-511`.
**Tầng 2 — khối:** `PeWorkflowPanel.tsx:458` `{next.length > 0 && !readOnly && (`
- `next` = `PeWorkflowPanel.tsx:257` `evaluation.workflow.nextPhases.filter(p => p !== Phase.TuChoi)`**BE là single source of truth** (`:255` ghi rõ "BE policy đã gỡ TuChoi khỏi nextPhases; filter này = defense-in-depth FE").
**Tầng 3 — từng nút:**
- ẩn hẳn Trả lại/Từ chối khi actor == drafter: `:476` `if ((isSendBack || isCancel) && evaluation.drafterUserId === currentUser?.id) return null`
- disable khi không đến lượt: `:479` `const isDisabled = blockedByV2Level`
- `:103` `const blockedByV2Level = isV2Pending && !actorInV2Level`
- `:102` `const isV2Pending = !!evaluation.currentApproval`
- `:99-100` `const actorInV2Level = isAdmin || (currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id))`
- `:61` `const isAdmin = currentUser?.roles?.includes('Admin') ?? false`
### PH-06 🔴 [CODE] KHÔNG có `usePermission` / `PermissionGuard` nào trong PeWorkflowPanel
Grep `usePermission|PermissionGuard` trong `fe-{user,admin}/src/components/pe/*` + `pages/pe/*`**0 hit** (xem Q3 PH-11).
⇒ gate hiện tại **thuần role + workflow-position**, KHÔNG đụng ma trận quyền menu. [CODE]
### PH-07 [SUY LUẬN] Gate đề xuất cho nút Xóa mới (nhất quán với 2 nút kia)
Cùng vị trí `PeWorkflowPanel.tsx:458-506` (đặt CẠNH map, không nhét vào map vì Xóa ≠ phase):
`!readOnly` (chỉ màn Duyệt) **∧** `actorInV2Level` (đúng lượt duyệt hoặc Admin) **∧** `evaluation.phase === ChoDuyet`.
⚠️ Đây là gate **display-layer**; tầng API độc lập — xem Q3.
---
## Q4 — PE đang xóa được ở đâu (khuôn tái dùng)
### PH-08 [CODE] Nút "Xóa phiếu" hiện có — `PeDetailTabs.tsx:457-472` (cả 2 app, byte-identical)
```
:445 {mode === 'workspace' && canEditPhase && !readOnly && ( ← gate KHỐI action-bar
:457 /* Xóa phiếu — CHỉ DangSoanThao (bản nháp). TraLai không cho xóa
:458 (đã có lịch sử workflow). Soft-delete qua DELETE /pe/:id endpoint
:459 (AuditableEntity IsDeleted=true, không xóa hoàn toàn DB). */
:460 {evaluation.phase === PurchaseEvaluationPhase.DangSoanThao && ( ← gate NÚT
:463 onClick={() => { if (confirm(`Xóa phiếu "…"? … soft-delete …`)) onDelete() }}
:470 <Trash2 …/> Xóa phiếu
```
- `canEditPhase` = `:124` `isEditablePhase(evaluation.phase)``fe-user/src/types/purchaseEvaluation.ts:65` (DangSoanThao ∥ TraLai).
- Nút thu hẹp hơn nữa: **CHỈ `DangSoanThao`** (Nháp). `TraLai` bị loại có chủ đích (`:457-458`).
### PH-09 [CODE] API + gate quyền của đường xóa hiện tại
`PurchaseEvaluationsListPage.tsx:89-90`
```
const del = useMutation({ mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${id}`) …
```
+ bản mobile fullpage `:697-698` `api.delete(`/purchase-evaluations/${id}`)`.
FE **không** gate bằng permission nào cả — chỉ gate bằng phase + mode. [CODE]
### PH-10 🔴🔴 [CODE] Plumbing xóa ĐÃ ĐƯỢC NỐI SẴN vào màn DUYỆT — chỉ bị điều-kiện-render chặn
**cả 2 call-site của màn Duyệt**, prop `onDelete` đã truyền mutation xóa thật:
- `PurchaseEvaluationsListPage.tsx:570-575` (inline): `onDelete={() => del.mutate(detail.data!.id)}` + `readOnly={true}`
- `PurchaseEvaluationsListPage.tsx:665-670` (focus overlay = ảnh prod): `onDelete={() => del.mutate(detail.data!.id)}` + `readOnly={true}`
Nút không hiện vì `:445` yêu cầu `mode === 'workspace' && canEditPhase && !readOnly` mà ở đây `mode` = default `'detail'` (`:107`), `readOnly=true`, `canEditPhase=false` (phase ChoDuyet).
⇒ [SUY LUẬN] chi phí wire FE rất thấp — **hàm xóa + invalidate + đóng-detail đã sẵn**, phần thiếu là (a) nút ở đúng chỗ approver + (b) **cửa BE/authz** (Q3).
---
## Q3 — PHÂN QUYỀN 2 TẦNG (gotcha #82 / S118)
### (a) PH-11 [CODE] Menu key PE **thật** — `menuKeys.ts` chỉ có ROOT, `Pe_*` sinh ở BE
`fe-user/src/lib/menuKeys.ts` (md5 `4da4405…`) ⟂ `fe-admin/src/lib/menuKeys.ts` (md5 `81fe5ad…`) — **2 file KHÁC nhau** (không như file PE vốn byte-identical). Cả 2 đều có:
| key | fe-user line | fe-admin line |
|---|---|---|
| `PurchaseEvaluations: 'PurchaseEvaluations'` | `:23` | `:23` |
| `PeWorkflows: 'PeWorkflows'` | `:24` | `:24` |
| `ApprovalWorkflowsV2` / `AwV2_DuyetNcc` / `AwV2_DuyetNccPhuongAn` | `:26-28` | `:26-28` |
🔴 **KHÔNG có const `Pe_*` / `PeWf_*` nào trong menuKeys.ts của cả 2 app.** Chúng được **sinh động ở BE**:
`src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs:131-145`
```
:131 PurchaseEvaluationTypeCodes = ["DuyetNcc", "DuyetNccPhuongAn"];
:134 PurchaseEvaluationGroup(code) => $"Pe_{code}"
:135 PurchaseEvaluationList(code) => $"Pe_{code}_List"
:136 PurchaseEvaluationCreate(code) => $"Pe_{code}_Create"
:137 PurchaseEvaluationPending(code) => $"Pe_{code}_Pending" ← MÀN DUYỆT
:141 PurchaseEvaluationWorkflowView(code) => $"Pe_{code}_WfView"
:145 PeWorkflowTypeLeaf(code) => $"PeWf_{code}"
```
Seed vào bảng `MenuItems`: `DbInitializer.cs:1870-1877` (+`:2092-2096`, `:2492-2496`).
**10 key `Pe_*` thật** (2 typeCode × {group, _WfView, _List, _Create, _Pending}) + **2 key `PeWf_*`**. FE chỉ khớp chúng bằng **regex**, không bằng const: `fe-user/src/components/Layout.tsx:120` `/^Pe_([^_]+)_(List|Create|Pending|WfView)$/` · `fe-admin/src/components/Layout.tsx:107` `/^Pe_([^_]+)_(List|Create|Pending)$/` ⚠️ **2 app LỆCH nhau** (admin thiếu `WfView`).
### (b) PH-12 🔴 [CODE] Ma trận CRUD **ĐÃ CÓ `Delete`** — và đang **không ai dùng** cho PE
- `MenuKeys.cs:167` `Actions = ["Read", "Create", "Update", "Delete"]`
- `Permission.cs:8-11` — 4 cột `CanRead/CanCreate/CanUpdate/CanDelete`
- `menuKeys.ts:72` (fe-user) `export type CrudAction = 'Read' | 'Create' | 'Update' | 'Delete'`
- Ma trận admin enumerate **bảng `MenuItems`** (`PermissionFeatures.cs:20 db.MenuItems`) ⇒ ô tick `Delete` cho `Pe_DuyetNcc_Pending` **đã hiện sẵn trong UI** và ghi được row `Permissions`.
🔴 **BẪY 2-TẦNG chính xác nằm ở đây:** `Program.cs:82-89` chỉ đăng-ký policy `"{menu}.{action}"` cho `menu ∈ MenuKeys.All`. `MenuKeys.All` (`:147-165`) **CHỈ có root `PurchaseEvaluations` (:153) + `PeWorkflows` (:163)** — **KHÔNG có `Pe_*`/`PeWf_*`**. Không có `IAuthorizationPolicyProvider` động (grep = 0 hit).
⇒ Viết `[Authorize(Policy = "Pe_DuyetNcc_Pending.Delete")]` sẽ trỏ vào policy **CHƯA ĐĂNG KÝ** (ASP.NET ném `InvalidOperationException` → 500, **không phải 403**). [CODE + SUY LUẬN về hệ quả runtime — chưa chạy thử]
⇒ **Policy DÙNG ĐƯỢC NGAY hôm nay = `"PurchaseEvaluations.Delete"`** (root ∈ `All` ⇒ đã đăng ký, 0 code mới).
**Quà cho spec:** tầng display cũng đã sẵn — `GetMyMenuTreeQuery.cs:51-84` cho `Pe_*` **kế thừa** cờ CRUD từ root `PurchaseEvaluations` khi leaf không có row riêng (`:66 if (inheritFromKey is not null && !resolved.ContainsKey(m.Key))`, `:72`). Nghĩa là tick `Delete` **1 lần ở root** là mọi `Pe_*` có `canDelete=true`. Không cần key mới, không cần migration.
### (c) PH-13 [CODE] Khuôn `PermissionGuard` đúng chuẩn ở module KHÁC (để bắt chước)
- `fe-admin/src/pages/master/DepartmentsPage.tsx:101` `<PermissionGuard menuKey={MenuKeys.Departments} action="Delete">`
- `fe-admin/src/pages/master/ProjectsPage.tsx:130` · `SuppliersPage.tsx:257` — cùng khuôn `action="Delete"`
- `fe-user/src/pages/master/DepartmentsPage.tsx:76` — mirror phía user
- Cơ chế: `usePermission.ts:15-26` `can(menuKey, action)` → `findNode(menu, key)` trên cây menu từ `AuthContext` → đọc `node.canDelete`. `PermissionGuard.tsx:12-15` = wrapper `if (!can(...)) return fallback`.
🔴 Vùng PE (`components/pe/*`, `pages/pe/*`) — **0 hit** `usePermission|PermissionGuard` ở CẢ 2 app (xác nhận lại PH-06). Toàn bộ 5 file dùng guard này là Master + Users, KHÔNG có PE.
### (d) PH-14 🔴 CHECKLIST "phải chạm chỗ nào" để đủ CẢ HAI tầng
**Tầng DISPLAY (FE) — 4 mục:**
1. `fe-user/src/components/pe/PeWorkflowPanel.tsx` ~`:458-506` — thêm nút Xóa cạnh khối `next.map` (KHÔNG nhét vào map).
2. `fe-admin/src/components/pe/PeWorkflowPanel.tsx` — **y hệt** (byte-identical, sửa 1 bên là sót — PH-01).
3. Bọc `<PermissionGuard menuKey={MenuKeys.PurchaseEvaluations} action="Delete">` theo khuôn PH-13 (import `usePermission`/`PermissionGuard` — hiện PE chưa import).
4. Prop `onDelete` **không cần thêm** — đã truyền sẵn ở `PurchaseEvaluationsListPage.tsx:573` + `:668` (PH-10); nhưng nó đang gắn vào `PeDetailTabs`, **KHÔNG** vào `PeWorkflowPanel` ⇒ phải (a) nâng mutation `del` lên rồi truyền prop mới cho `PeWorkflowPanel`, hoặc (b) tạo mutation riêng trong panel. [SUY LUẬN]
**Tầng API-AUTHZ (BE) — 3 mục:**
5. `PurchaseEvaluationsController.cs:146` `[HttpDelete("{id:guid}")]` — hiện **chỉ có `[Authorize]` trần ở class `:15`**, 0 policy (grep `Policy` trong file = **0 hit**). Thêm `[Authorize(Policy = "PurchaseEvaluations.Delete")]` **trên chính action Delete** (policy này đã đăng ký sẵn — PH-12).
6. Nới guard phase trong handler: `PurchaseEvaluationFeatures.cs:1404-1406` hiện allow-list `{DangSoanThao, TuChoi}` ⇒ **ChoDuyet đang bị chặn 409** `"Chỉ xóa được phiếu ở phase Soạn thảo hoặc Từ chối."`. Không nới thì nút FE bấm ra lỗi. (thuộc slice BE)
7. Guard "đúng lượt duyệt" ở BE (mirror `actorInV2Level`) — hiện **KHÔNG có**; nếu chỉ dựa `PurchaseEvaluations.Delete` thì **bất kỳ ai có quyền Delete PE đều xóa được phiếu người khác đang duyệt**. [SUY LUẬN — rủi ro cần spec chốt]
**Seed / migration / role — 2 mục:**
8. **KHÔNG cần key mới, KHÔNG cần migration** nếu dùng root `PurchaseEvaluations` (PH-12). Chỉ cần admin tick ô `Delete` ở `/system/permissions` cho role đích → kế thừa xuống `Pe_*`.
9. Nếu spec muốn **key riêng** (vd `Pe_{code}_Pending` có Delete độc lập) ⇒ phải thêm key vào `MenuKeys.All` (`:147-165`) để Program.cs đăng ký policy, + seed `MenuItems`, + tính lại row `Policies` trong `docs/STATUS.md` (derived `|All| × |Actions|`).
**Phụ (từ run.md "xóa phải có vết"):**
10. `DeletePurchaseEvaluationCommandHandler` (`:1396-1411`) **KHÔNG ghi changelog** — chỉ `db.PurchaseEvaluations.Remove(entity)`. Soft-delete là thật: `AuditingInterceptor.cs:56-59` bắt `EntityState.Deleted` → `entry.Entity.IsDeleted = true`. [CODE]
---
## Q5 — DẤU VẾT UI ĐÃ GỠ ("chỗ cho huy")
### PH-15 🔴🔴 [CODE] **TÌM THẤY** — đường "khai tử phiếu" phía approver ĐÃ TỪNG TỒN TẠI và bị gỡ
**Commit `6db195d`** · **2026-06-12 14:30:38 +0700** · S60
> `[CLAUDE] PurchaseEvaluation: go han hanh dong "Tu choi" - chi con Duyet hoac Tra lai (UAT anh Kiet S60 14:14)`
**Lý do gỡ — nguyên văn UAT** (`PurchaseEvaluationWorkflowService.cs:94-96`):
```
// ===== UAT S60 (anh Kiệt 14:14) — GỠ hành động "Từ chối" =====
// "Bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi."
```
Khớp 1-1 lời anh: *"mọi người nói là ko cần cái đó"*.
**Gỡ tới đâu (6 file, `git show --stat 6db195d`):**
- Domain: xoá MỌI transition → `TuChoi` ở **cả 4 policy** (NccOnly + NccWithPlan + ForV2Schema + FromDefinition) ⇒ `nextPhases` hết trả TuChoi ⇒ **nút FE tự biến mất** (đúng cơ chế PH-05 tầng-2).
- Service: guard chặn `targetPhase=TuChoi` **đứng TRƯỚC mọi branch, chặn CẢ Admin** — commit message ghi *"spec bỏ hẳn, không escape hatch"*.
- FE ×2 app: `next.filter(p !== TuChoi)` (= `PeWorkflowPanel.tsx:257` hiện tại), `dialog/isCancel` **giữ dead-safe để flip lại dễ**.
### PH-16 🔴🔴 [CODE] Chính message của guard S60 trỏ vào **ngõ cụt** — đây là gốc UAT hôm nay
`PurchaseEvaluationWorkflowService.cs:101-106`:
```
"Hành động \"Từ chối\" đã được gỡ khỏi quy trình duyệt — chỉ còn Duyệt hoặc Trả lại. " +
"Phiếu cần dừng: dùng Trả lại để người soạn sửa, hoặc Xóa phiếu khi còn Bản nháp."
```
⇒ Hệ thống **tự khai** lối thoát duy nhất = *"Xóa phiếu khi còn Bản nháp"*. Nhưng phiếu `PE/2026/A/046` đang ở **ChoDuyet**, không còn Bản nháp ⇒ chỉ dẫn này **không thực hiện được**. [SUY LUẬN — nhưng dựa trên 2 mảnh CODE khớp nhau: guard-message + `PeDetailTabs.tsx:460` gate `phase === DangSoanThao`]
⚠️ **Hệ quả kèm theo (surprise):** allow-list xoá ở BE là `{DangSoanThao, TuChoi}` (`PurchaseEvaluationFeatures.cs:1404-1405`) — mà `TuChoi` **không còn tới được** từ S60 ⇒ **nửa allow-list đã chết**. [CODE]
🎁 S60 để sẵn đường lùi, ghi trong chính comment (`Service:99-100`): *"Flip lại nếu cần: xóa guard này + restore transitions trong PurchaseEvaluationPolicy."*
### PH-17 [CODE] **KHÔNG tìm thấy** nút Xóa/Thu-hồi từng nằm trong `PeWorkflowPanel` (màn duyệt)
`git log -S` trên **cả 2 app** cho `Trash2` · `onDelete` · `api.delete` giới hạn file `PeWorkflowPanel.tsx` → **0 commit cả 3**. Nút "Xóa phiếu" chỉ từng sống ở `PeDetailTabs` (workspace), sinh ra ở `378c993` (2026-05-07 16:37) với ràng buộc ghi rõ trong commit body: *"CHỈ Bản nháp (DangSoanThao), KHÔNG xóa Trả lại (đã có lịch sử workflow)"*.
**Lệnh đã chạy (liệt kê đủ để tái lập):**
```
git log --oneline -S "Xóa phiếu" -- fe-user/src/components/pe fe-user/src/pages/pe fe-admin/src/components/pe fe-admin/src/pages/pe → 1 hit (378c993)
git log --oneline -S "Thu hồi" -- fe-user/src fe-admin/src → 0 hit
git log --oneline -S "Hủy phiếu" -- fe-user/src fe-admin/src → 0 hit
git log --oneline -S "Huỷ phiếu" -- fe-user/src fe-admin/src → 0 hit
git log --oneline -S "Từ chối" -- fe-{user,admin}/src/components/pe/PeWorkflowPanel.tsx → 7 hit (6db195d = gỡ)
git log --oneline -S "Trash2"|"onDelete"|"api.delete" -- fe-{user,admin}/src/components/pe/PeWorkflowPanel.tsx → 0 hit ×3
git log --oneline --format="%h %ad %s" -- fe-user/src/components/pe/PeWorkflowPanel.tsx → 30 commit (đọc hết)
grep -i "từ chối|xóa" docs/changelog/sessions/2026-06-12-S60-S62-*.md → xác nhận §S60 dòng 23-27
```
**Kết luận Q5:** *"chỗ cho huy"* = **nút Từ chối** (`✗ Từ chối`, màu đỏ, cùng khối HÀNH ĐỘNG), gỡ `6db195d` 2026-06-12, lý do = UAT anh Kiệt 14:14. **Không phải** nút Xóa — nút Xóa chưa bao giờ có ở màn duyệt. Ngoài ra `9c330d2` (2026-06-11, S59) trước đó đã ẩn Trả lại+Từ chối khi approver == drafter (`PeWorkflowPanel.tsx:476`).
🔴 **Spec phải trả lời:** khôi phục `TuChoi` (rẻ, có đường lùi sẵn) **hay** dựng hành-động Xóa mới? Điểm khác biệt nghiệp vụ: `TuChoi` = phiếu còn tồn tại (vẫn hiện ở tab "Từ chối") — **chưa chắc triệt tiêu lũy kế**; Xóa = `IsDeleted=1` khuất khỏi mọi query. Anh Tra cần **"ko có lũy kế lên"** ⇒ nghiêng về Xóa. [SUY LUẬN]
---
## Q6 — Pattern 16-bis 4-place mirror
### PH-18 [CODE] Trả lời DỨT KHOÁT
**Nhánh 1 — chỉ thêm nút vào màn có sẵn (kịch bản mặc định): KHÔNG kích hoạt luật 4-place.**
Luật 16-bis chỉ áp khi có **page/route mới**, đúng như comment tại `fe-user/src/components/Layout.tsx:86` (*"4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap"*) và `:73-74` (thiếu staticMap → `MenuLeaf … if (!path) return null` → sidebar **drop im lặng**). Việc này: 0 page mới, 0 route `App.tsx`, 0 key `menuKeys.ts`, 0 entry `staticMap` (`Layout.tsx:56-106`). Màn đích đã có route `/purchase-evaluations?pendingMe=1` (`Layout.tsx:134`).
⇒ **Luật thay thế phải tuân = "2-app mirror"** (PH-01): `PeWorkflowPanel.tsx` byte-identical ở fe-user + fe-admin. Sửa 1 bên = sót — nguy hiểm ngang mức quên staticMap.
**Nhánh 2 — nếu Q3(b) chốt phải thêm MENU KEY MỚI: vẫn KHÔNG phải 4-place, mà là bộ mirror KHÁC.**
`Pe_*` **không đi qua `staticMap`** — chúng resolve bằng nhánh regex động `Layout.tsx:119-134` (`peMatch`), nên "chỗ thứ 4" của 16-bis không áp dụng. Bộ phải chạm khi thêm key PE mới là:
1. `MenuKeys.cs` — factory + **`All` (`:147-165`)** ⇐ nếu quên, policy không được đăng ký (PH-12).
2. `DbInitializer.SeedMenuTreeAsync` (`:1870-1877`) + 2 chỗ liệt kê key (`:2092-2096`, `:2492-2496`).
3. `Layout.tsx` regex `peMatch` **cả 2 app** — hiện đã LỆCH (fe-user có `WfView`, fe-admin không) ⇒ thêm suffix mới phải sửa cả 2.
4. `docs/STATUS.md` row `Menu keys` + row `Policies` (derived `|All| × |Actions|`, B1 canonical).
⇒ [SUY LUẬN] Đường **rẻ nhất & ít rủi ro nhất vẫn là dùng key `PurchaseEvaluations` sẵn có + action `Delete`** (PH-12) — 0 key mới ⇒ nhánh 2 không phải mở.
---
<!-- END sub-invest-fe-2 · TOTAL=18 PHAT-HIEN -->

View File

@ -0,0 +1,256 @@
# sub-invest-menu-3 — lát cắt 3: MENU restructure + màn "Đã xóa" + "người đứng đầu phòng"
> run: `2026-07-27-S155-pe-delete-approver` · vai: investigator-codebase · ghi-đĩa-trong-lúc-làm (chống #53)
> Nhãn: `[CODE]` = đọc thẳng file có file:line · `[SUY LUẬN]` = diễn giải · `[CHƯA XÁC MINH]` = chưa chứng được
---
## Q-A — Menu định nghĩa ở ĐÂU, thêm mục mới chạm những gì
### A-1 `[CODE]` 🔴 MENU LÀ **SEED DB**, KHÔNG phải hard-code FE — dứt khoát
Bằng chứng 3 tầng:
1. **Entity + bảng thật:** `src/Backend/SolutionErp.Domain/Identity/MenuItem.cs` — bảng `MenuItems` (Key PK, Label, ParentKey, Order, Icon, +`IsVisible`/`DisplayLabel` từ Mig 27).
2. **Nơi khai 4 mục con của `Pe_<Code>`:** `src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs:1867-1878`
```csharp
foreach (var code in MenuKeys.PurchaseEvaluationTypeCodes)
{
tree.Add((MenuKeys.PurchaseEvaluationGroup(code), label, MenuKeys.PurchaseEvaluations, peOrder++, "FileCheck")); // :1870
tree.Add((MenuKeys.PurchaseEvaluationWorkflowView(code), "Luồng duyệt", ...group..., peOrder++, "Network")); // :1874
tree.Add((MenuKeys.PurchaseEvaluationList(code), "Danh sách", ...group..., peOrder++, "List")); // :1875
tree.Add((MenuKeys.PurchaseEvaluationCreate(code), "Thao tác", ...group..., peOrder++, "Plus")); // :1876
tree.Add((MenuKeys.PurchaseEvaluationPending(code), "Duyệt", ...group..., peOrder++, "CheckCircle2")); // :1877
}
```
**Đúng 4 mục owner thấy trên prod = 4 dòng `:1874-1877`.** Nhãn `"Duyệt"` nằm ở **`:1877`**, key = `Pe_<Code>_Pending`.
3. **FE render từ API, KHÔNG hard-code danh sách:** `fe-user/src/components/Layout.tsx` render `AuthContext.menu` (fetch `/api/menus/me``GetMyMenuTreeQuery`). FE chỉ có **map key → route** (`resolvePath`, `Layout.tsx:55-137`), không có danh sách mục.
### A-2 `[CODE]` KHÔNG cần migration — nhưng CẦN chạy seed (restart API)
`DbInitializer.cs:1888-1914` = upsert idempotent: `existingItems` theo Key → **thiếu thì INSERT**, có rồi thì **chỉ sửa `Order`** (`:1899-1903`), **KHÔNG đụng Label/ParentKey/Icon** (`:1897-1898` comment nói rõ).
⇒ Thêm 2 mục mới `Đã duyệt` + `Đã xóa` = thêm 2 dòng `tree.Add(...)` + 2 const trong `MenuKeys.cs`**API restart là có** (DbInitializer chạy lúc startup). **KHÔNG cần EF migration** (không đổi schema). `[SUY LUẬN]` nhưng dựa thẳng vào cơ chế upsert ở `:1891-1908`.
### A-3 `[CODE]` Không phải 4-place mirror — là **6 chỗ** cho mục PE mới
| # | Chỗ | File:line | Ghi chú |
|---|---|---|---|
| 1 | Hằng key BE | `MenuKeys.cs:134-141` (thêm `PurchaseEvaluationApproved/Deleted(typeCode)`) | factory `$"Pe_{typeCode}_<X>"` |
| 2 | **Seed tree DB** | `DbInitializer.cs:1874-1877` (+2 dòng) | 🔴 chỗ FE-only-mindset hay quên |
| 3 | Seed permission admin | `DbInitializer.cs:2092-2096` + `:2492-2496` | **2 nơi**, xem A-3bis |
| 4 | FE `resolvePath` | `fe-user/Layout.tsx:120` regex + `:128-134` branch · `fe-admin/Layout.tsx:107` regex + `:110-118` | regex `(List\|Create\|Pending\|WfView)` phải nới |
| 5 | FE route `App.tsx` | (chỉ nếu dùng URL MỚI; nếu tái dùng `/purchase-evaluations?...` thì **0 chỗ**) | xem Q-B |
| 6 | FE `menuKeys.ts` | `fe-{admin,user}/src/lib/menuKeys.ts` | 🔴 xem A-3ter: **hiện KHÔNG có `Pe_*`** ⇒ có thể là 0 chỗ |
### A-3bis `[CODE]` Chỗ thứ 5 & 6 THẬT: 2 seed permission
- `DbInitializer.cs:2092-2096` — gom `peKeys` (Group/WfView/List/Create/Pending)
- `DbInitializer.cs:2492-2496` — gom `menuKeys` (cùng 5 key)
**2 danh sách song song liệt kê ĐÍCH DANH 5 key PE.** Thêm key mới mà quên 1 trong 2 = permission seed lệch. (Đã đọc ngữ cảnh 2 hàm ở A-6.)
### A-3ter `[CODE]` `menuKeys.ts` FE **không chứa `Pe_*`** — lệnh đã chạy
```
grep -rn "Pe_DuyetNcc|Pe_\$\{" fe-user/src/lib/menuKeys.ts fe-admin/src/lib/menuKeys.ts → 0 dòng output
```
⇒ FE mirror const chỉ có key top-level; `Pe_*` sinh động qua regex trong `Layout.tsx`. **Không phải chỗ phải sửa.**
### A-4 `[CODE]` Đổi tên `Duyệt` → `Đang duyệt`: **2 đường, 1 rủi ro thật**
**Đường rẻ nhất (0 code):** admin sửa `DisplayLabel` qua `/system/menu-visibility` (`fe-admin/src/pages/system/MenuVisibilityPage.tsx``PATCH` `MenusController.cs:26-32`). ⚠️ **CHỈ ăn ở fe-user**`MenuDtos.cs:14` ghi rõ *"override label cho fe-user; fe-admin luôn render Label gốc"*; fe-user đọc `effectiveLabel()` (`Layout.tsx:161-163`), fe-admin không có hàm này.
**Đường code:** thêm `[Pe_<code>_Pending] = "Đang duyệt"` vào `labelBackfill` (`DbInitializer.cs:1918-1924`) — vì vòng upsert `:1893-1904` **KHÔNG update Label** row đã tồn tại. Sửa mỗi `:1877`**KHÔNG ăn trên DB đã seed**.
🔴 **Rủi ro nếu ĐỔI KEY (không chỉ label):** đo trên Dev DB —
```
sqlcmd -d SolutionErp_Dev -Q "SELECT MenuKey,COUNT(*) FROM Permissions WHERE MenuKey LIKE 'Pe[_]%' GROUP BY MenuKey"
→ 10 key × 13 row (13 role) mỗi key, gồm Pe_DuyetNcc_Pending = 13 row
```
⇒ Permission row là **explicit, không phải chỉ dựa inherit**. Đổi key = 13 row/key thành mồ côi ⇒ role mất quyền (rơi về inherit từ root `PurchaseEvaluations` — có cứu, nhưng cờ CRUD thành cờ của ROOT chứ không phải cờ đã tinh chỉnh). **Khuyến nghị: GIỮ key `Pe_*_Pending`, chỉ đổi label.**
### A-5 `[CODE]` Cả 2 app đều có menu này — nhưng **lệch 1 nhánh**
fe-user `Layout.tsx:120` regex `(List|Create|Pending|WfView)` · fe-admin `Layout.tsx:107` regex `(List|Create|Pending)`**fe-admin THIẾU `WfView`** ⇒ leaf "Luồng duyệt" resolve `null``MenuLeaf` drop im lặng bên admin. Thêm leaf mới phải sửa **cả 2**, và biết trước là 2 file này **đã lệch sẵn** (không byte-identical như các file PE khác).
---
## Q-B — Dựng 2 màn `Đã duyệt` + `Đã xóa`
### B-1 `[CODE]` Màn `Duyệt` KHÔNG lọc phase ở BE — nó gọi **endpoint khác** rồi lọc ở FE
`pendingMe=1` **không phải** tham số BE. FE rẽ nhánh sang endpoint inbox:
```ts
// fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx:61-68
if (pendingMe) {
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
params: { type: typeFilter ?? undefined, approvalWorkflowId: approvalWorkflowId || undefined },
})
return { items: res.data, total: res.data.length, page: 1, pageSize: res.data.length }
}
```
BE = `GetMyPurchaseEvaluationInboxQuery` (`PurchaseEvaluationFeatures.cs:715-771`), controller `PurchaseEvaluationsController.cs:30-35`. **Không có `Phase == ChoDuyet` đơn lẻ** — mà là 2 nhánh OR (`:762`):
```csharp
where eligiblePhases.Contains(e.Phase) || v2InboxIds.Contains(e.Id)
```
`v2InboxIds` = `ResolveV2InboxIdsAsync(userId)` (`:748`) — phiếu V2 mà actor là approver **Cấp hiện tại**.
Lọc "Đã gửi duyệt" cứng nằm ở **FE**, sau khi nhận data:
```ts
// :182-183
const filtered = pendingMe
? allRows.filter(p => getPeDisplayStatus(p.phase) === PeDisplayStatus.DaGuiDuyet)
```
`[SUY LUẬN]` ⇒ màn `Đang duyệt` giữ nguyên 100%, chỉ đổi nhãn menu. Không đụng BE.
### B-2 `[CODE]` `Đã duyệt` = **ĐÃ CHẠY ĐƯỢC HÔM NAY**, 0 dòng BE, 0 route mới
Tham số `phase=` đã tồn tại đủ 3 tầng:
| Tầng | file:line |
|---|---|
| Controller | `PurchaseEvaluationsController.cs:23` `[FromQuery] PurchaseEvaluationPhase? phase = null` |
| Query record | `PurchaseEvaluationFeatures.cs:565` `PurchaseEvaluationPhase? Phase = null` |
| Áp filter | `PurchaseEvaluationFeatures.cs:620` `if (request.Phase is not null) q = q.Where(x => x.e.Phase == request.Phase)` |
| FE đọc URL | `PurchaseEvaluationsListPage.tsx:39` `sp.get('phase')``:75` truyền xuống API |
| FE đã có dropdown | `:389-403` — option "Đã duyệt" map `String(PurchaseEvaluationPhase.DaDuyet)` (`:395`) |
`DaDuyet = 7` (`PurchaseEvaluationPhase.cs:23`).
**URL `/purchase-evaluations?type=1&phase=7` chạy ngay.** Việc phải làm = 1 key menu + 1 nhánh `resolvePath` (×2 app) + 1 dòng seed. **Ô #5 bảng A-3 = 0 chỗ** (không route mới, không page mới).
⚠️ 1 khác biệt UX: nhánh `phase` đi đường `list` (có phân trang `pageSize:50`, `readOnly={!pendingMe}` `:590`/`:676`**detail read-only**) — khác nhánh inbox (không phân trang).
### B-3 `[CODE]` Xác minh 2 con số bằng lệnh của chính tôi
```bash
grep -rn "IgnoreQueryFilters" src/Backend --include=*.cs | wc -l → 0
grep -rn "b\.HasQueryFilter(" src/Backend --include=*.cs | wc -l → 12 (20 hit thô, 8 là comment)
grep -rn "IgnoreQueryFilters" tests --include=*.cs | wc -l → 15 ← BẤT NGỜ, xem B-4
```
✅ Cả 2 con số sub BE báo đều **ĐÚNG**: `IgnoreQueryFilters` = **0 hit toàn `src/Backend`**; PE global filter ở `PurchaseEvaluationConfiguration.cs:84`.
### B-4 `[CODE]` 🎁 Đã có **15 tiền lệ `IgnoreQueryFilters`** — nhưng ở **tests**, không phải production
`tests/.../Application/MasterCatalogFilteredUniqueTests.cs:71,102,134` · `PeWorkItemBudgetTests.cs:260` · `Services/SupplierExcelImportServiceTests.cs:78,174` · `Services/SupplierImportV2DedupTests.cs` (×9).
Khuôn dùng: `await db.Suppliers.IgnoreQueryFilters().CountAsync()`*"không có bản ghi phantom nào kể cả đã soft-delete"* (`SupplierExcelImportServiceTests.cs:78`).
`[SUY LUẬN]` Đội đã quen cú pháp này; cái mới **chỉ là lần đầu ở tầng production**, không phải kỹ thuật lạ. Test cho màn "Đã xóa" có khuôn assert sẵn để bắt chước.
### B-5 `[CODE]` 🔴 Chỗ HẸP NHẤT + ranh giới an toàn (câu quan trọng nhất của Q-B)
**Vì sao an toàn về mặt cấu trúc:** `IgnoreQueryFilters()` là toán tử **trên MỘT `IQueryable`**, không phải cờ trên `DbContext`. Cả 2 hàm accumulator đều dựng `peers` từ `db.PurchaseEvaluations.AsNoTracking()` **mới tinh** mỗi lần gọi:
- `PeBudgetAccumulator.cs:42-44` (`ComputeAsync`) — `var peers = db.PurchaseEvaluations.AsNoTracking().Where(...)`
- `PeBudgetAccumulator.cs:100-102` (`ComputePendingAsync`) — y hệt, `var peers = db.PurchaseEvaluations.AsNoTracking().Where(...)`
**Không có đường rò** từ 1 query list sang accumulator, **trừ khi** vi phạm 1 trong 3 điều sau. Đây là ranh giới cần ghi vào checklist:
| 🔴 CẤM | Vì sao |
|---|---|
| Đặt `IgnoreQueryFilters()` trong **helper dùng chung** trả `IQueryable<PurchaseEvaluation>` mà accumulator/list cũng gọi | rò gián tiếp — biến 1 dòng thành N call-site |
| Gỡ / sửa `HasQueryFilter``PurchaseEvaluationConfiguration.cs:84` | phá toàn hệ, đúng thứ đang cho owner "xóa là hết ăn lũy kế" |
| Bật `IgnoreQueryFilters` ở query **detail** rồi tái dùng cho snapshot/finalize | `PeBudgetAccumulator` được gọi từ cả display lẫn `ApplyBudgetSnapshotOnFinalizeAsync` (comment `PeBudgetAccumulator.cs:10-12`) — chung đường |
**Đề xuất hẹp nhất `[SUY LUẬN]`:** **query/handler RIÊNG** (vd `ListDeletedPurchaseEvaluationsQuery`) + endpoint riêng `GET /api/purchase-evaluations/deleted`, `IgnoreQueryFilters()` đặt **đúng 1 lần trong thân handler đó**. KHÔNG thêm cờ `includeDeleted` vào `ListPurchaseEvaluationsQuery` — vì handler đó đã có 4 filter + IDOR block (`:596-617`) + subquery projection (`:656-685`); nhét cờ vào = mọi call-site cũ đều thành đường có-thể-rò và phải review lại.
### B-6 `[CODE]` **KHÔNG có tiền lệ "thùng rác" nào trong repo** — nói thẳng
Lệnh đã chạy:
```bash
grep -rniE "Thùng rác|thung-rac|deleted=|showDeleted|includeDeleted" fe-user/src fe-admin/src → 0 màn thùng rác
grep -rniE "IsDeleted\s*=\s*false" src/Backend --include=*.cs | grep -v Migrations → 0 dòng
grep -rniE "Restore|Undelete|Khôi phục" src/Backend --include=*.cs → 1 hit DUY NHẤT, là chuỗi thông báo
```
Hit duy nhất: `EmployeeFeatures.cs:162``$"User {user.UserName} đã có hồ sơ NV (đã xoá mềm). Cần khôi phục thay vì tạo mới."`**câu chữ có, chức năng KHÔNG có** (ghost-wire loại "message nói về thứ chưa tồn tại"). Màn "Đã xóa" sẽ là **màn đầu tiên** loại này trong toàn hệ.
### B-7 `[CODE]` Restore: **0 cơ chế sẵn** — chỉ khảo sát, không chốt
Không endpoint nào set `IsDeleted=false` (lệnh B-6). Chiều ngược lại — `AuditingInterceptor.cs:56-62` chỉ chuyển `Deleted→Modified`, **không có nhánh un-delete**.
Dữ kiện để owner quyết `[SUY LUẬN]`:
- Restore = 1 `PATCH` set 3 field (`IsDeleted=false`, `DeletedAt=null`, `DeletedBy=null`) — **rẻ về code**;
- nhưng phiếu restore **quay lại ăn lũy kế ngay** (accumulator không lọc gì thêm ngoài query filter) ⇒ đúng thứ owner muốn tránh sẽ quay lại. Nếu owner muốn restore thì phải nói rõ phiếu restore về **phase nào** (giữ `ChoDuyet` = ăn lũy kế lại · ép về `DangSoanThao` = không ăn theo `ComputePendingAsync:105` loại `DangSoanThao`).
- ⚠️ `Suppliers`/`Projects`/`Departments` có UNIQUE **filtered `[IsDeleted]=0`** (Mig 47) — restore ở các bảng đó có thể đụng key trùng. **PE thì KHÔNG** (không có unique filtered trên `PurchaseEvaluations`) ⇒ riêng PE restore không vướng index.
### B-8 `[CODE]` 🎁 Vết xóa **KHÔNG bị chôn** — changelog sống sót, không cần `IgnoreQueryFilters`
`PurchaseEvaluationChangelog : BaseEntity` (`PurchaseEvaluationChangelog.cs:9`) — **BaseEntity, KHÔNG phải AuditableEntity****không có `IsDeleted`****không có query filter** (12 `HasQueryFilter` không có cái nào cho bảng này).
Query lịch sử lọc **chỉ theo Id**, không join PE:
```csharp
// PurchaseEvaluationFeatures.cs:1424-1425
return await db.PurchaseEvaluationChangelogs.AsNoTracking()
.Where(c => c.PurchaseEvaluationId == request.PurchaseEvaluationId)
```
`ListPurchaseEvaluationChangelogsQuery` **chạy được nguyên vẹn cho phiếu đã xóa**, 0 sửa đổi.
🔴 Cái BỊ chôn là **chỗ khác**: `GetPurchaseEvaluationQuery` (detail bundle) load PE root ⇒ dính filter ⇒ **404**. Nên: mở lại lịch sử = miễn phí; **mở lại DETAIL = phải `IgnoreQueryFilters` chỗ thứ 2**. Owner cần chốt: màn "Đã xóa" chỉ LIST (rẻ, 1 chỗ) hay LIST + mở được phiếu (2 chỗ).
---
## Q-C — "NGƯỜI ĐỨNG ĐẦU PHÒNG" map vào đâu?
### C-1 `[CODE]` 🎯 **CÓ TỒN TẠI** — `Department.ManagerUserId`, chú thích đúng chữ "Trưởng Phòng ban"
```csharp
// src/Backend/SolutionErp.Domain/Master/Department.cs:9
public Guid? ManagerUserId { get; set; } // TPB — Trưởng Phòng ban
```
⇒ Giả thuyết **(b) ĐÚNG về schema**. Không cần migration để có field.
### C-2 `[CODE]` 🔴 NHƯNG field đó **RỖNG + bị FE chủ động ghi `null`** — đây là cái bẫy
```
sqlcmd -d SolutionErp_Dev -Q "SELECT COUNT(*), SUM(CASE WHEN ManagerUserId IS NOT NULL THEN 1 ELSE 0 END) FROM Departments WHERE IsDeleted=0"
→ TotalDept=10 · HasManager=0 (0/10)
```
Tệ hơn: **cả 2 app hardcode `managerUserId: null` trong payload SỬA**, nên mỗi lần ai đó sửa phòng ban là **xóa sạch** giá trị vừa nhập:
- `fe-user/src/pages/master/DepartmentsPage.tsx:43``const payload = { id, code, name, managerUserId: null, note }`
- `fe-admin/src/pages/master/DepartmentsPage.tsx:57``managerUserId: null,`
BE thì ghi thẳng, không bảo vệ: `DepartmentFeatures.cs:217` `entity.ManagerUserId = request.ManagerUserId;`
⇒ Dùng đường (b) thì **bắt buộc** vá 2 file FE trước, nếu không tính năng sẽ "tự tắt" sau lần sửa phòng ban đầu tiên. `[CHƯA XÁC MINH]` prod (SSH chết khi nạp SQL-client, S134/S148) — nhưng vì FE null-hoá nên khả năng prod cũng rỗng là cao.
📌 0 read-site nào dùng `ManagerUserId` để phân quyền — grep toàn BE chỉ ra 5 hit, **tất cả là DTO passthrough** (`DepartmentFeatures.cs:15,126,140,144,164,173,217`).
### C-3 `[CODE]` Giả thuyết **(a) SAI** — `Level.Order` là *trình tự*, không phải *thứ bậc*
```csharp
// ApprovalWorkflow.cs:81-88
// Cấp = 1 NV cụ thể. 1 bước có nhiều cấp theo Order. ... Sequential trong cùng bước: cấp 1 → cấp 2 → ...
public int Order { get; set; } // 1-based trong cùng step
```
`Order` 1-based tăng dần = **ai duyệt trước/sau**, không mang nghĩa chức vụ. Nhiều row cùng `Order` = cùng 1 Cấp (đúng ảnh: Cấp 2 có 3 người). ⇒ "Cấp cao nhất trong Bước" **không phải khái niệm có sẵn**; muốn dùng phải TỰ định nghĩa `MAX(Order)` = trưởng phòng — `[SUY LUẬN]` đây là **suy diễn**, schema không hứa điều đó (một Bước có thể cấu hình ngược).
Còn "Kết thúc tại Cấp 2" trong ảnh = cờ **`AllowApproverFinalize`** (`ApprovalWorkflow.cs:142`, F5 Mig 58) — là **quyền per-slot do admin tick**, KHÔNG phải dấu hiệu chức vụ.
### C-4 `[CODE]` Giả thuyết **(c): 2 ứng viên, 1 có data 1 không**
| Ứng viên | file:line | Data Dev |
|---|---|---|
| Role `DeptManager` | `AppRoles.cs:8` | **8 user** ✅ |
| `PositionLevel.TruongPhong = 3` | `PositionLevel.cs:14` (chữ "TruongPhong" đúng nghĩa nhất) | **0 user — 33/33 NULL** ❌ |
```
sqlcmd -d SolutionErp_Dev -Q "SELECT PositionLevel, COUNT(*) FROM Users GROUP BY PositionLevel" → NULL|33
sqlcmd -d SolutionErp_Dev -Q "SELECT r.Name, COUNT(ur.UserId) FROM Roles r LEFT JOIN UserRoles ur ... GROUP BY r.Name"
→ Drafter|10 · CostControl|8 · DeptManager|8 · Procurement|6 · ProjectManager|2 · Director|2 · ...
```
⚠️ `PositionLevel` là di sản **V1** (Mig 18/21): mọi read-site còn lại đều thuộc workflow V1 (`PeWorkflowAdminFeatures.cs:187,223,264` · `WorkflowAdminFeatures.cs:194,223,271`) + CRUD user (`UsersController.cs:81`). **`ApproveV2Async` không đọc nó.**
⚠️ Role `DeptManager` **không nói PHÒNG NÀO** — phòng của user nằm ở `User.DepartmentId` (Dev: **31/33 có**). Nên (c) chỉ dùng được khi ghép `role DeptManager ∧ User.DepartmentId == Step.DepartmentId`. Đó là "một trưởng phòng nào đó của phòng này", không phải "ĐÚNG người đứng đầu".
### C-5 `[CODE]` Mảnh ghép "đến lượt họ" thì đã có sẵn và ĐÁNG TIN
- `Step.DepartmentId` (`ApprovalWorkflow.cs:75`) — Dev **6/6 step có DepartmentId** (`SELECT COUNT(*), SUM(CASE WHEN DepartmentId IS NOT NULL...) FROM ApprovalWorkflowSteps``6|6`).
- Con trỏ hiện tại = `PE.CurrentWorkflowStepIndex` + `PE.CurrentApprovalLevelOrder`; sub FE đã đo `blockedByV2Level` (`PeWorkflowPanel.tsx:99-103`) = `isAdmin ∥ actor ∈ currentApproval.approvers`.
⇒ Vế "đến lượt" **không phải làm mới**. Chỉ vế "người đứng đầu phòng" là thiếu.
### C-6 `[SUY LUẬN]` KẾT LUẬN Q-C + 3 chỗ thêm rẻ nhất (owner chọn, tôi không chốt)
**Kết luận:** khái niệm "người đứng đầu phòng" **có tên trong schema nhưng chưa có ĐỜI SỐNG**. `Department.ManagerUserId` là chỗ duy nhất mang đúng nghĩa — 0/10 dữ liệu, 0 read-site, và bị 2 file FE ghi đè `null`. Không có cờ nào trên `ApprovalWorkflowLevel` nói "người này là trưởng phòng". ⇒ **Viết spec được, nhưng phải chọn 1 trong 3 và trả giá tương ứng:**
| # | Cách | Migration? | Giá phải trả |
|---|---|---|---|
| **1** | Dùng `Department.ManagerUserId`, so với `Step.DepartmentId` của bước đang chờ | **KHÔNG** | phải vá 2 FE null-hoá (`DepartmentsPage.tsx:43` / `:57`) + thêm ô chọn TP + **nhập tay 10 phòng** trên prod; chưa nhập = nút không hiện với ai (fail-closed, an toàn) |
| **2** | Thêm cờ per-slot `AllowApproverDelete` trên `ApprovalWorkflowLevel` | **CÓ** (1 AddColumn, no new table) | đắt hơn 1 migration nhưng **khớp khuôn đã lặp 4× ở đúng bảng này** (F2 Mig 31 · F3 Mig 29 · F4 Mig 30 · F5 Mig 58 — `ApprovalWorkflow.cs:104-142`); admin tick trong Designer, 0 phụ thuộc data master, đúng chỗ "đến lượt" đã có sẵn |
| **3** | Role `DeptManager``User.DepartmentId == Step.DepartmentId` | **KHÔNG** | có data ngay (8 user) nhưng **ngữ nghĩa lỏng**: 8 người mang role trên 10 phòng ⇒ có phòng nhiều "trưởng", có phòng không ai. Không phải "người đứng đầu" theo nghĩa owner nói |
`[CHƯA XÁC MINH]` — số liệu C-2/C-4 là **Dev DB**. Prod chưa đo được (SSH `vietreport-vps` đứt phiên mỗi khi nạp SQL-client, tái xác nhận S134/S148). Lệnh để implementer chạy trên prod:
```sql
SELECT COUNT(*) Total, SUM(CASE WHEN ManagerUserId IS NOT NULL THEN 1 ELSE 0 END) HasManager FROM Departments WHERE IsDeleted=0;
SELECT PositionLevel, COUNT(*) FROM Users GROUP BY PositionLevel;
```
<!-- END sub-invest-menu-3 · TOTAL=21 PHAT-HIEN -->

View File

@ -0,0 +1,289 @@
# sub-invest-wfver-4 — Quy trình duyệt: sửa TẠI CHỖ vs bắt buộc TẠO VERSION MỚI
> run `2026-07-27-S155-pe-delete-approver` · lát cắt 4 (S1-ter) · READ-ONLY
> Nhãn: `[CODE]` = có file:line kiểm được · `[SUY LUẬN]` = suy từ mã, chưa chạy · `[CHƯA XÁC MINH]` = không chứng được lượt này
> 🔴 File này ghi TỪNG phát hiện ngay lúc tìm ra (chống #53). Return chỉ là tóm tắt.
---
## W1 — Hôm nay admin sửa workflow thì ĐIỀU GÌ thực sự xảy ra?
### F1 [CODE] 🔴 **KHÔNG TỒN TẠI lệnh Update.** Toàn bộ surface admin V2 chỉ có 4 việc.
`src/Backend/SolutionErp.Api/Controllers/ApprovalWorkflowsV2Controller.cs` (54 dòng, TOÀN BỘ file):
| Verb | Line | Policy | Việc |
|---|---|---|---|
| `GET /api/approval-workflows-v2` | `:21-26` | `[Authorize]` trần (class) | Overview (đọc) |
| `POST /api/approval-workflows-v2` | `:28-34` | `Workflows.Create` | **Tạo version MỚI** |
| `PATCH /{id}/user-selectable` | `:39-45` | `Workflows.Create` | Bật/tắt cờ ghim cho user pick |
| `DELETE /{id}` | `:47-53` | `Workflows.Create` | Xoá cả quy trình |
**KHÔNG có `PUT` / `PATCH` nào sửa Steps/Levels/Name/CeoApprovalThreshold.** Xác minh bằng lệnh:
```
grep -n "HttpPut|HttpPatch" ApprovalWorkflowsV2Controller.cs → chỉ 1 hit: user-selectable :39
grep -rn "UpdateAw|EditAw" src/Backend → (kết quả ghi ở F3)
```
⇒ Trả lời dứt khoát cho câu *"admin bị chặn thật hay chỉ tưởng"*: **BỊ CHẶN THẬT — không có đường sửa tại chỗ trong mã.** Không phải thói quen/UX.
Ngoại lệ DUY NHẤT sửa được tại chỗ hôm nay = cờ `IsUserSelectable` (`SetAwUserSelectableCommandHandler`, `ApprovalWorkflowV2AdminFeatures.cs:386-398` — load entity, gán 1 field, `SaveChangesAsync`, KHÔNG đụng Version).
### F2 [CODE] `Version` tăng ở ĐÚNG 1 chỗ, tự động, không ai chọn được.
`ApprovalWorkflowV2AdminFeatures.cs:324-328` (`CreateAwDefinitionCommandHandler.Handle`):
```csharp
var nextVersion = await db.ApprovalWorkflows
.Where(w => w.Code == request.Code)
.MaxAsync(w => (int?)w.Version, ct) ?? 0;
nextVersion++;
```
- Khoá theo `Code` (cùng Code = cùng "quy trình logic"), `MAX(Version)+1`.
- `CreateAwDefinitionCommand` (`:238-248`) **không có field `Version`** ⇒ client KHÔNG truyền được, không ép được.
-**"Ép tăng Version" = chính hành vi POST.** Không có trigger riêng nào khác. Mọi thay đổi (kể cả tick 1 cờ) hôm nay **bắt buộc** đi qua POST ⇒ **bắt buộc** đẻ version mới.
### F3 [CODE] POST còn có TÁC DỤNG PHỤ: hạ mọi version đang active của cùng `ApplicableType`.
`ApprovalWorkflowV2AdminFeatures.cs:330-334`:
```csharp
var actives = await db.ApprovalWorkflows
.Where(w => w.ApplicableType == typeEnum && w.IsActive)
.ToListAsync(ct);
foreach (var old in actives) old.IsActive = false;
```
+ bản mới `IsActive = true` (`:343`), `IsUserSelectable = true` (`:344`), `ActivatedAt = UtcNow` (`:347`).
⇒ Tick 1 cờ F6 hôm nay = **(a)** đẻ 1 row `ApprovalWorkflows` + N row Step + M row Level mới, **(b)** hạ active bản cũ, **(c)** bản cũ vẫn `IsUserSelectable=true` (không bị hạ) ⇒ dropdown user **dài thêm 1 dòng mỗi lần tick**.
### F4 [CODE] DELETE hiện **vô điều kiện** — note ở `:400-402` tự khai nợ:
```
// Hiện chưa có phiếu nào pin schema mới → unconditional delete OK cho UAT.
// Sau UAT khi link với PE/Contract thật cần check usage trước khi delete.
```
Handler `:406-418` = `Remove(def)` thẳng, **0 usage-check**. (Rào thật nằm ở FK — xem F5.)
### F5 [CODE] Rào thật của DELETE nằm ở **FK Restrict**, không ở mã.
- `PurchaseEvaluationConfiguration.cs:67-73``PE.ApprovalWorkflowId``ApprovalWorkflows` **`DeleteBehavior.Restrict`**, comment ghi rõ *"Restrict để KHÔNG xóa workflow"*.
- `PurchaseEvaluationLevelOpinionConfiguration.cs:25-28``LevelOpinion.ApprovalWorkflowLevelId` → Level **`Restrict`**, comment: *"admin xoá Level chặn nếu opinion tồn tại — bảo vệ data"*.
- `ApprovalWorkflowConfiguration.cs` (Step→Workflow) `Cascade`; (Level→Step) `Cascade`.
⇒ [SUY LUẬN] DELETE workflow đã có phiếu pin sẽ **nổ FK 547 → 500** (không phải 409 lịch sự) vì handler không check usage. Đây là nợ note `:400-402` tự khai.
🔴 **F5 là rào cứng SẴN CÓ, hợp ý owner:** dữ liệu đã ký (`LevelOpinion`) **khoá cứng** Level tương ứng — Level đã có người ký **không xoá được ở tầng DB**, kể cả sau này có lệnh Update.
### F6 [CODE] Kết luận W1 — dứt khoát
| Câu hỏi | Trả lời |
|---|---|
| Có Update tại chỗ? | **KHÔNG** (trừ `IsUserSelectable`) |
| Update sửa tới đâu? | n/a |
| Chỗ ép tăng Version? | `ApprovalWorkflowV2AdminFeatures.cs:325-328`, trigger = **mọi POST** |
| Version set ở đâu? | cùng chỗ, `MAX(Version per Code)+1`, client không truyền được |
| Admin bị chặn thật? | **CHẶN THẬT bằng mã** — không phải UX/thói quen. Muốn tick 1 cờ ⇒ buộc POST ⇒ buộc version mới + hạ active bản cũ |
⇒ Yêu cầu owner (7) **KHÔNG phải nới lỏng luật cũ** mà là **XÂY MỚI 1 lệnh Update chưa từng tồn tại**. Đây là điểm quan trọng nhất của lát cắt này.
---
## W2 — Phiếu ĐANG CHẠY bám vào workflow thế nào?
### F7 [CODE] 🔴 **THAM CHIẾU SỐNG, KHÔNG snapshot.**
`PurchaseEvaluationWorkflowService.cs:671-675` (`ApproveV2Async`) — mỗi lần duyệt **đọc lại workflow từ DB**:
```csharp
var aw = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Levels.OrderBy(l => l.Order))
.FirstOrDefaultAsync(w => w.Id == awId, ct)
```
PE **không copy** Steps/Levels lúc gửi duyệt — pin chỉ là `ApprovalWorkflowId` (1 Guid). ⇒ **Sửa workflow là ăn NGAY vào mọi phiếu đang chạy pin nó.** (Snapshot duy nhất trong PE là **ngân sách** Mig 67, KHÔNG phải workflow.)
### F8 [CODE] Con trỏ **LAI**: Bước = INDEX (mong manh) · Cấp = ORDER-VALUE (bền hơn nhưng vẫn vỡ)
| Con trỏ | Cách dùng | Dòng |
|---|---|---|
| `CurrentWorkflowStepIndex` | `steps[currentIdx]`**INDEX vào list đã sort theo Order** | `:677`, `:681-686` |
| `CurrentApprovalLevelOrder` | `levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder)`**so khớp giá trị Order** | `:689-695` |
**Hệ quả chứng minh được (không phải đoán):**
- **Chèn/xoá/đổi thứ tự BƯỚC** ⇒ `steps[currentIdx]` trỏ sang **bước khác** ⇒ phiếu đang chờ ở Bước 2 bỗng bị coi là đang ở Bước khác, **im lặng, không lỗi**. Nếu số bước giảm dưới `currentIdx` thì ném `ConflictException` `:683` *"CurrentWorkflowStepIndex=… không hợp lệ"***phiếu KẸT, không ai duyệt được**.
- **Xoá CẤP đang chờ** ⇒ `pendingLevelGroup` null ⇒ `ConflictException` `:695` *"Bước X không có cấp Y"***phiếu KẸT**.
- **Đổi Order của Cấp** ⇒ tương đương xoá+thêm với con trỏ ⇒ kẹt hoặc nhảy sai cấp.
🔴 ⇒ Đây **chính là bằng chứng kỹ thuật** cho ranh giới owner vạch: lớp "đổi CẤU TRÚC" **bắt buộc** version mới. **Chứng minh, không bác bỏ.**
### F9 [CODE] Đổi **người** ở Cấp đang chờ: hiệu lực TỨC THÌ, hai chiều.
`:697-709`:
```csharp
var allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet();
if (!allowedUserIds.Contains(actorUserId.Value)) throw new ForbiddenException(...)
```
- **Thêm người** vào Cấp đang chờ ⇒ người mới duyệt được ngay; người cũ **không mất gì**. ⇒ an toàn.
- **Bớt/đổi người** ở Cấp đang chờ ⇒ người bị gỡ gọi Duyệt sẽ **403 Forbidden** với message liệt kê **GUID trần** (`:705` `string.Join(", ", allowedUserIds)` — nối `Guid`, không phải tên) ⇒ [SUY LUẬN] thông báo lỗi khó hiểu cho end-user; nếu Cấp đó rỗng người sau khi bớt ⇒ **phiếu KẸT** (không ai thoả).
- `matchingLevel` `:733-734` = level khớp `ApproverUserId`, **fallback `pendingLevelGroup.First()`** khi Admin duyệt thay ⇒ với Admin, ý kiến ký vào slot **đầu tiên** của Cấp. [SUY LUẬN] nếu admin đổi thứ tự các row cùng Cấp thì "slot đầu tiên" đổi ⇒ chữ ký admin gắn sang người khác.
### F10 [CODE] `PurchaseEvaluationLevelOpinions` = **rào cứng ở tầng DB** cho việc xoá Level đã ký.
UNIQUE `(PurchaseEvaluationId, ApprovalWorkflowLevelId)` (`PurchaseEvaluationLevelOpinionConfiguration.cs:30`) · FK Level **Restrict** (`:25-28`) · FK PE **Cascade** (`:20-23`).
⇒ Level đã có người ký **không thể xoá** (DB chặn). ⇒ [SUY LUẬN] lệnh Update tương lai nếu "xoá row Level rồi thêm lại" (khuôn replace-all) sẽ **nổ FK** trên đúng những workflow đang chạy — **cấm dùng khuôn delete-then-insert**, phải diff theo Id.
### F11 [CODE] Ngoài `ApproveV2Async` còn **3 read-site khác** của con trỏ trong PE — mỗi cái vỡ một kiểu KHÁC nhau.
| Read-site | Dòng | Kiểu vỡ khi cấu trúc đổi |
|---|---|---|
| `ResolveV2InboxIdsAsync` (Hộp thư "Chờ duyệt") | `PurchaseEvaluationFeatures.cs:842-849``steps[idx]`, `idx >= steps.Count → continue` | **IM LẶNG**: phiếu **biến mất khỏi inbox**, không lỗi, không ai biết |
| Flow-tree detail (`ComputeLevelStatus`/`ComputeStepStatus`) | `:1135-1158`, `:1161-1191` | Tô sai Done/Current/Pending — hiển thị sai lịch sử |
| `currentApproval` banner "Đến lượt bạn" | `:1223-1243` — guard `idxCur < steps.Count` | Banner rỗng ⇒ FE mất gate `blockedByV2Level` |
| List badge "Kết thúc trước CEO" | `:672-677` + `:1210-1213` | xem F12 |
### F12 [CODE] 🔴 Giả định ẩn: **`Step.Order - 1 == StepIndex`**.
`PurchaseEvaluationFeatures.cs:674-676` so `lv.Step!.Order - 1` với `CurrentWorkflowStepIndex`; comment `:667` tự khai *"rank cấp = Step.Order-1, Order tuần-tự s+1 bởi seed/Designer"*.
⇒ Giả định này chỉ đúng khi **Order liền mạch 1..N**. **Xoá 1 Bước giữa** (để lại lỗ Order) làm index ≠ Order-1 ⇒ badge/heads-up sai **âm thầm**. ⇒ Nếu sau này cho sửa cấu trúc thì **phải re-index Order liên tục**, và việc đó lại dịch con trỏ phiếu đang chạy.
### F13 [CODE] **Blast radius vượt xa PE**: `ApprovalWorkflow` V2 là schema DÙNG CHUNG 6+ module.
`grep -rn "\.Levels" src/Backend` (bỏ Migrations/Configurations) → hit ở: `PurchaseEvaluationFeatures.cs` · `ApprovalWorkflowV2AdminFeatures.cs` · `Office/ProposalFeatures.cs` · `Office/LeaveOtApprovalFeatures.cs` (13 hit) · `Office/TravelVehicleApprovalFeatures.cs` · `Office/WorkflowAppsFeatures.cs` · `ContractFeatures.cs` · `ContractWorkflowService.cs`.
`grep -c CurrentWorkflowStepIndex|CurrentApprovalLevelOrder` = **198 hit / 23 file**.
⇒ Lệnh Update mới **KHÔNG phải việc riêng của PE** — nó chạm đơn nghỉ phép, OT, công tác, đặt xe, đề xuất, hợp đồng. [SUY LUẬN] test phải phủ ít nhất PE + 1 module Office.
---
## W4 — Cấu trúc có đỡ nổi "nhiều người trong một Cấp"? — **CÓ**, và đây là chỗ ranh giới owner **KHÔNG** tự mâu thuẫn
### F14 [CODE] "Nhiều người cùng Cấp" = **nhiều row `ApprovalWorkflowLevel` TRÙNG `Order`** (OR-of-N). Xác nhận 3 nguồn độc lập:
1. Service: `PurchaseEvaluationWorkflowService.cs:689``currentStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order)`; comment `:688` *"Group levels by Order = Cấp. Mỗi Cấp có N approvers (OR-of-N)"*.
2. Match: `:702` `allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet()`**bất kỳ ai trong nhóm** duyệt được.
3. Validator: `ApprovalWorkflowV2AdminFeatures.cs:309-314` `HaveNoDuplicateApproverInSameLevel` gom theo **`{Order, ApproverUserId}`** ⇒ **cho phép** nhiều row cùng `Order` miễn khác người. Comment `:252-256` nói thẳng *"Mỗi Cấp có N approver (multiple Level rows cùng Order = same Cấp)"*.
+ FE detail dựng cùng cách: `PurchaseEvaluationFeatures.cs:1164` `GroupBy(l => l.Order)``approvers` = list.
⇒ Ảnh prod (Cung ứng — Cấp 2 có 3 tên) **khớp mã**. Số Cấp tối đa = **3** (`MaxLevelsPerStep`, `:257` + `:282`).
### F15 [CODE] 🎁 **Giải nghịch lý owner có thể tự mâu thuẫn** — hoá ra KHÔNG mâu thuẫn, vì con trỏ Cấp khoá theo **giá-trị Order**, không theo row:
- Thêm 1 row Level với `Order` **ĐÃ TỒN TẠI** (= thêm NGƯỜI vào Cấp có sẵn) ⇒ `levelGroups` vẫn đủ khoá `g.Key`, `steps[currentIdx]` không đổi ⇒ **0 con trỏ nào dịch****AN TOÀN THẬT** (`:689-695`).
- Thêm 1 row Level với `Order` **MỚI** (= thêm CẤP) ⇒ đổi `maxLevelOrder`, chèn chặng ⇒ **PHÁ VỠ**.
⇒ Ranh giới đúng chữ owner nếu định nghĩa **"Cấp" = tập các row cùng `Order`**, không phải "1 row". Từ ngữ cần chốt trong spec: *thêm người = thêm row cùng Order sẵn có*.
🔸 Lưu ý ngược lại: **BỚT người** khỏi Cấp = xoá row ⇒ vẫn "an toàn về con trỏ" nhưng có 2 rủi ro thật (F9 người-đang-chờ mất quyền; F10 FK Restrict chặn nếu row đó đã ký) ⇒ **không cùng hạng an toàn với "thêm"**.
### F16 [CODE] ⚠️ Comment entity **SAI/lỗi thời** ngay tại nhà: `ApprovalWorkflow.cs:81-82` viết
> *"Cấp = 1 NV cụ thể. … Approver = ApproverUserId chính xác (**KHÔNG OR-of-many**)"*
trong khi mã (F14) và CLAUDE.md đều là OR-of-N. ⇒ Bẫy cho người đọc sau; nên vá comment khi động vào file này.
---
## 🔴 F17 — GIẢ THUYẾT LEAD: **XÁC NHẬN ĐÚNG.** Cờ mới KHÔNG tới được phiếu đang chạy.
Chuỗi bằng chứng, 4 mắt xích, mỗi mắt có `file:line`:
| # | Mắt xích | Bằng chứng |
|---|---|---|
| 1 | POST tạo **entity MỚI****Id MỚI** | `ApprovalWorkflowV2AdminFeatures.cs:336` `var def = new ApprovalWorkflow {...}` + `:373` `db.ApprovalWorkflows.Add(def)`; `BaseEntity.cs:5` `public Guid Id { get; set; } = Guid.NewGuid();`**không** UPDATE row cũ |
| 2 | Phiếu đọc workflow **theo Id đã pin** | `PurchaseEvaluationWorkflowService.cs:674` `FirstOrDefaultAsync(w => w.Id == awId)``awId` = `evaluation.ApprovalWorkflowId` (`:292`) |
| 3 | Row cũ **vẫn sống** sau khi tạo bản mới | POST chỉ set `old.IsActive = false` (`:334`) — **không xoá**, không cascade ⇒ phiếu cũ vẫn resolve được (không lỗi, chỉ là **đọc cấu hình CŨ**) |
| 4 | **KHÔNG có đường re-pin** cho phiếu đang chạy | `ApprovalWorkflowId` chỉ ghi ở **2 chỗ**: `PurchaseEvaluationFeatures.cs:149` (create) và `:291` (update) — mà `:249-251` chặn `throw ConflictException("Chỉ sửa được phiếu khi ở phase Nháp hoặc Trả lại.")`**phiếu `ChoDuyet` KHÔNG re-pin được** |
**Kiểm thêm — không có backfill nào cứu:**
```
grep -rn "UPDATE.*ApprovalWorkflowId\|SET ApprovalWorkflowId" src/.../Migrations/*.cs → 0 hit
grep -rn "ApprovalWorkflowId = " src/Backend (trừ Migrations/Configurations) → PE chỉ 2 write-site nêu trên
```
### ⇒ Hệ quả cho việc đang làm (spec F6 `AllowApproverDelete`)
Admin tick F6 hôm nay ⇒ buộc POST ⇒ workflow **v(n+1)** Id mới ⇒ **đúng những phiếu đang treo cần xoá vẫn pin v(n)**`ApproveV2Async`/handler xoá đọc Level của **v(n)** — nơi `AllowApproverDelete = false`.
🔴 **Yêu cầu (5) HỎNG nếu không có (7).** Hai yêu cầu không song song mà **phụ thuộc**: (7) là **điều kiện cần** để (5) chạm được ca UAT gốc (phiếu `PE/2026/A/046` đang treo).
Đường vòng duy nhất nếu KHÔNG làm (7): người soạn **Trả lại → sửa → gửi lại** để re-pin sang v(n+1) (`:249-251` cho `TraLai`) — nhưng gửi lại **chạy quy trình LẠI từ đầu****vẫn ăn lũy kế** trong lúc chờ ⇒ **không giải được bài toán gốc**.
[SUY LUẬN] Ngược lại, một lệnh Update **sửa tại chỗ row Level của v(n)** sẽ tới phiếu đang chạy **NGAY** (mắt xích 2 — tham chiếu sống, không cache) — đó chính là cái owner mô tả.
---
## W3 — Bảng phân loại AN TOÀN / NỬA AN TOÀN / PHÁ VỠ
**Quy ước đọc:** "Cấp" = **tập các row `ApprovalWorkflowLevel` cùng `Order`** (F14). Phân loại chỉ xét **sửa tại chỗ trên workflow đang có phiếu chạy**.
### F18 [CODE] Bảng 13 thao tác
| # | Thao tác | Hạng | Lý do kỹ thuật (mã) |
|---|---|---|---|
| 1 | **Thêm người vào Cấp có sẵn** (row mới, `Order` đã tồn tại) | ✅ **AN TOÀN** | Con trỏ Bước = index vào `steps` (không đổi); con trỏ Cấp khớp **giá trị** `g.Key` (`Service:689-695`) ⇒ 0 dịch. Người mới vào `allowedUserIds` ngay (`:702`). Không đụng FK/UNIQUE |
| 2 | **Bật/tắt cờ `Allow*`** (F1-F5, và F6 sắp thêm) | ✅ **AN TOÀN** | Cờ đọc từ `matchingLevel` **tại thời điểm duyệt** (`:799`, `:859`) ⇒ hiệu lực ngay, không đụng con trỏ/khoá. **Đây là ô owner cần cho F6** |
| 3 | **Đổi tên hiển thị** `Workflow.Name` / `Step.Name` / `Level.Name` | ✅ **AN TOÀN** | Chỉ display: `Step.Name` vào message + DTO (`:707`, `Features:1186`); `Level.Name` vào `levelName` (`Features:1176`). Không tham gia quyết định |
| 4 | **Đổi `CeoApprovalThreshold`** | ⚠️ **NỬA — hỏi owner** | Kỹ thuật an toàn (đọc live `aw.CeoApprovalThreshold` `Service:883`, không đụng con trỏ) **NHƯNG đổi nghĩa quyết định đang chờ**: cùng 1 phiếu, hôm nay CCM được duyệt-final, mai thì không. Không vỡ gì — **là câu hỏi chính sách, không phải kỹ thuật** |
| 5 | **Bớt người khỏi Cấp** (xoá 1 row) | ⚠️ **NỬA AN TOÀN** | Con trỏ **không dịch** (F15) NHƯNG: (a) người bị gỡ đang chờ → `ForbiddenException` `:703-708` với message **liệt kê GUID trần** (`:705`); (b) nếu row đó **đã ký** → FK `Restrict` chặn ở DB (`PeLevelOpinionConfiguration:25-28`) → lỗi hạ tầng, không phải 409; (c) bớt HẾT người của Cấp đang chờ ⇒ `allowedUserIds` rỗng ⇒ **phiếu KẸT** |
| 6 | **Đổi `ApproverUserId` của 1 row** | ⚠️ **NỬA AN TOÀN** | = bớt + thêm cùng lúc: người cũ mất quyền tức thì (`:702-708`); nếu row đã ký thì **chữ ký cũ vẫn trỏ row đó** (`LevelOpinion.ApprovalWorkflowLevelId`) ⇒ **lịch sử đổi nghĩa im lặng**: bản ghi "Cấp 2 do A ký" nay hiển thị dưới tên B. FK không chặn (chỉ chặn DELETE). 🔴 **Đây là ô nguy hiểm nhất trong nhóm "sửa người"** |
| 7 | **Thêm Cấp** (`Order` MỚI trong Bước) | ❌ **PHÁ VỠ** | Đổi `maxLevelOrder` (`Service:690`) ⇒ chèn chặng vào giữa luồng đang chạy: phiếu vừa qua Cấp 2 nay bị hỏi Cấp 3 (hoặc ngược lại) ⇒ **đổi nghĩa "đã duyệt xong Bước"**. Ngoài ra `ComputeLevelStatus` (`Features:1135-1148`) tô lại toàn bộ trạng thái đã hiển thị |
| 8 | **Xoá Cấp** | ❌ **PHÁ VỠ** | Nếu là Cấp đang chờ → `pendingLevelGroup` null → `ConflictException` `:695` *"Bước X không có cấp Y"***phiếu KẸT hoàn toàn**. Nếu Cấp đã ký → FK `Restrict` chặn (F10) |
| 9 | **Đổi `Order` của Cấp** | ❌ **PHÁ VỠ** | Con trỏ khoá theo giá-trị `Order` ⇒ đổi số = vừa "xoá" khoá cũ vừa "tạo" khoá mới ⇒ kẹt (`:694-695`) hoặc **nhảy sai cấp im lặng**. Còn vi phạm `HaveSequentialOrders` (`Admin:297-307`) nếu để hở |
| 10 | **Thêm Bước** | ❌ **PHÁ VỠ** | Thêm ở **cuối** thì kéo dài luồng phiếu đang chạy (đổi nghĩa "sắp xong"); thêm ở **giữa** thì `steps[currentIdx]` (`:686`) trỏ sang bước khác — **im lặng, không lỗi**. Phá thêm giả định `Step.Order-1 == index` (F12) nếu Order không re-index |
| 11 | **Xoá Bước** | ❌ **PHÁ VỠ** | `currentIdx >= steps.Count``ConflictException` `:683` (**phiếu KẸT**) hoặc trỏ nhầm bước. Inbox thì **im lặng bỏ phiếu** (`Features:844` `continue`) ⇒ phiếu **biến mất khỏi màn Duyệt mà không báo lỗi**. Level con có opinion → FK Restrict chặn cascade |
| 12 | **Đổi `Order` Bước** (reorder) | ❌ **PHÁ VỠ** | Cùng cơ chế #10/#11: index cố định + thứ tự đổi = trỏ sai người, **không có exception** ⇒ dạng hỏng tệ nhất (âm thầm) |
| 13 | **Đổi `DepartmentId` của Bước** | ⚠️ **NỬA — hỏi owner** | BE **không match theo phòng** (V2 match `ApproverUserId`, `:702`) ⇒ kỹ thuật **an toàn**; chỉ đổi nhãn hiển thị (`Features:1188`). **NHƯNG** Designer FE ép *"NV phải thuộc Phòng đã chọn"* (`ApprovalWorkflowsV2Page.tsx:573-575`) ⇒ đổi phòng làm cấu hình **tự mâu thuẫn với chính luật FE**, và [CHƯA XÁC MINH] tôi không tìm thấy nơi nào BE re-validate ⇒ dữ liệu "lệch phòng" sẽ tồn tại im lặng. **Phải hỏi owner** muốn coi đây là gì |
### F19 [SUY LUẬN] Quy tắc rút gọn để spec dùng
> **AN TOÀN ⟺ thao tác KHÔNG làm đổi tập `{Step.Order}` và KHÔNG làm đổi tập `{Level.Order}` của Bước.**
> Mọi thao tác chỉ đụng **nội dung row** (người, cờ, tên) mà giữ nguyên **khung Order** = sửa tại chỗ được. Đụng vào **khung** = version mới.
Đây là phát biểu kiểm được bằng mã (con trỏ chỉ đọc index-of-steps + value-of-level-Order), không phải quy ước cảm tính.
### F20 [SUY LUẬN] Cảnh báo cho người viết lệnh Update
-**CẤM khuôn "xoá hết Levels rồi insert lại"** (khuôn quen của replace-all): nổ FK `Restrict` trên đúng workflow đang chạy (F10), và **đổi Id Level** ⇒ mọi `LevelOpinion` cũ mồ côi ngữ nghĩa.
- ✅ Phải **diff theo `Level.Id`**: giữ Id cũ cho row còn lại, chỉ Add row mới / Update cờ.
- Nhóm ⚠️ NỬA (#5, #6) nên **chặn ở tầng lệnh khi Level đó đã có `LevelOpinion`** — biến lỗi FK 500 thành 409 có chữ.
---
## W5 — Designer FE
### F21 [CODE] Vị trí + phân bố 2 app (KHÔNG mirror byte-identical như `PeWorkflowPanel`)
| App | File | Vai |
|---|---|---|
| **fe-admin** | `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx` (**1033 dòng**) | Designer **DUY NHẤT** (đọc + tạo + ghim + xoá) |
| **fe-user** | `fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx` (**321 dòng**) | **CHỈ XEM** — ma trận read-only, header `:1-16` ghi rõ *"User read-only matrix view"*, `FlagRow` `:303-314``readOnly` |
⇒ fe-user **không có** Designer. Nhưng nó **liệt kê 8 cờ** ⇒ thêm F6 **vẫn phải chạm 2 app** (xem F24).
### F22 [CODE] Luồng "sửa" trên FE = **luôn là Tạo mới** — FE nói thật, không giấu
- Nút trên card: **`Nhân bản`** (`:479` `onClick={() => onClone(def)}`) · **`Ghim/Bỏ ghim`** (`:484-491`) · **`Xoá version`** (`:493-495`). **KHÔNG có nút "Sửa".**
- `onClone``setCloneFrom(d); setDesignerOpen(true)` (`:302`, `:339`) → mở **cùng 1 dialog** với tiêu đề **`Tạo quy trình mới — {label}`** (`:638`), nút **`Lưu + kích hoạt`** (`:644`).
- Clone chép **đủ 8 cờ** từ bản cũ (`:141-160`) ⇒ trải nghiệm "giống như sửa", nhưng POST là `api.post('/approval-workflows-v2', …)` (`:578`) ⇒ **bản mới**.
- Toast sau khi lưu: **`"Đã lưu quy trình mới. Version cũ đã archive."`** (`:611`).
⇒ Trả lời câu *"có nút nào gợi ý sửa mà thực chất tạo mới không?"*: **CÓ — `Nhân bản`**, nhưng FE **có khai báo** (tiêu đề + toast + hint). Đây là chỗ sinh ra thói quen owner muốn bỏ.
🔸 `steps.map((s, i) => ({ order: i + 1, …}))` (`:585-586`) ⇒ FE **luôn re-index Order Bước liên tục 1..N** khi lưu ⇒ giả định F12 được giữ **nhờ FE**, không phải nhờ BE.
### F23 [CODE] 🔴 **KHÔNG có cảnh báo "workflow đang được dùng", KHÔNG disable field khi `IsActive`.**
Lệnh chạy:
```
grep -n "đang được dùng|đang dùng|còn chạy|usage|inUse|disabled=" ApprovalWorkflowsV2Page.tsx
→ 4 hit, TẤT CẢ vô can: :643 disabled={save.isPending} · :744/:753 disabled nút ↑↓ đầu/cuối list · :817 disabled={addDisabled} (sequential gating Cấp)
```
- Xoá version chỉ có `confirm()` chữ trơn: `Xoá version đang áp dụng "{code} v{version}"?` (`:308`) / `Xoá version "{code} v{version}"?` (`:345`) — **không đếm phiếu đang dùng**.
- Đối chiếu: Designer **HĐ V1** (skill `contract-workflow`) có badge *"N HĐ còn chạy"*. **V2 không port tính năng đó** ⇒ [SUY LUẬN] admin xoá nhầm sẽ gặp FK 500 (F5) mà không được cảnh báo trước.
- Admin **CÓ** thấy Version: badge `{code} v{01}` (`:386`) + `Đang áp dụng`/`Archived` (`:388-398`) + hint `"Version auto-tăng mỗi lần lưu"` (`:654`) + khối `Lịch sử versions` (`:320`) và dòng *"Khi tạo version mới, version hiện tại tự động archive"* (`:329`).
**Admin BIẾT mình vừa tạo bản mới.** Vấn đề owner nêu không phải "không biết" mà là **"không muốn phải làm vậy"**.
### F24 [CODE] 🎯 Chỗ tick F6 `AllowApproverDelete` — **4 neo, 2 app** (spec bám thẳng)
| # | File:line | Việc |
|---|---|---|
| 1 | `fe-admin/.../ApprovalWorkflowsV2Page.tsx:999-1007` | Khối `<label className="col-span-2 …text-emerald-700">` đang tick **F5** `allowApproverFinalize`**chèn F6 ngay dưới `:1007`**, cùng khuôn `checked={entry.allowApproverDelete}` / `onChange={e => updateField('allowApproverDelete', e.target.checked)}` |
| 2 | cùng file `:54` (type `LevelDto`) · `:110` (type `EditLevelEntry`) · `:159` (clone) · `:177` (default `false`) · `:605` (payload POST) | **5 chỗ khai báo/nối dây** cho mỗi cờ mới — đếm theo dấu vết F5, **không được sót `:605`** (thiếu = tick xong không gửi lên BE, hỏng im lặng) |
| 3 | `fe-user/.../WorkflowMatrixViewPage.tsx:282` | Dòng `<FlagRow active={r.level.allowApproverFinalize} label="Duyệt là KẾT THÚC…" colSpan2 />` — thêm `FlagRow` F6 ngay dưới (+ khai field trong type của file này) |
| 4 | BE DTO/input: `ApprovalWorkflowV2AdminFeatures.cs:41` (`AwLevelDto`) · `:230` (`CreateAwLevelInput`) · `:188` (map ra DTO) · `:368` (map vào entity) | 4 chỗ — **cùng số lượng, cùng vị trí** như `AllowApproverFinalize`. Khuôn đã lặp 5 lần (F1-F5) |
🔸 Nhãn UI hiện tại đều là câu tiếng Việt đầy đủ (vd `"Cho phép duyệt thẳng Cấp cuối khi đang duyệt"` `:995`) ⇒ F6 nên theo cùng giọng, và **label phải khớp byte** giữa fe-admin `:1006` và fe-user `:282` (fe-user comment `:16` tự dặn *"mirror admin Designer line 885-948"* — [SUY LUẬN] số dòng trong comment đó **đã lệch** so với `:932-1007` hiện tại, dấu hiệu mirror thủ công dễ trôi).
### F25 [CODE] Bẫy cho lệnh Update tương lai, nhìn từ FE
`ApprovalWorkflowsV2Page.tsx:568-576` — validate client-side *"NV phải thuộc Phòng đã chọn"* + `:561-567` sequential gating. Nếu lệnh Update mới **không** đi qua Designer (vd 1 nút tick nhanh ngoài card) thì **mất hết** các luật này ⇒ nên **tái dùng validator BE** (`CreateAwDefinitionCommandValidator:250-315`) chứ không viết luật thứ hai.
---
## Tổng kết ngắn
| Câu | Trả lời được? | Chốt |
|---|---|---|
| W1 | ✅ | Không có Update. Admin **bị chặn thật bằng mã**; mọi thay đổi = POST = version mới + hạ active bản cũ |
| W2 | ✅ | **Tham chiếu sống**, không snapshot. Con trỏ **lai**: Bước=index (vỡ âm thầm) · Cấp=Order-value (vỡ thành kẹt). FK Restrict khoá Level đã ký |
| W3 | ✅ (11/13 dứt khoát) | 3 AN TOÀN · 2 NỬA · 6 PHÁ VỠ · **2 phải hỏi owner** (#4 `CeoApprovalThreshold`, #13 `DepartmentId`) |
| W4 | ✅ | OR-of-N là thật; **owner KHÔNG tự mâu thuẫn** nếu định nghĩa Cấp = tập row cùng `Order` |
| W5 | ✅ | Designer chỉ ở fe-admin; "Nhân bản" = tạo mới (có khai báo); **0 cảnh báo đang-dùng**; F6 có 4 neo/2 app |
| 🔴 Giả thuyết lead | ✅ **XÁC NHẬN** | (5) **hỏng nếu không có** (7) — xem F17 |
**Câu chưa trả lời được: 0/5** (2 ô trong W3 là *"phải hỏi owner"* theo đúng luật, không phải thiếu bằng chứng).
<!-- END sub-invest-wfver-4 · TOTAL=25 PHAT-HIEN -->

View File

@ -0,0 +1,31 @@
# sub-test-specialist-3 — WAVE 2 test cho lệnh UPDATE quy trình duyệt (hạng mục F)
> run `2026-07-27-S155-pe-delete-approver` · wave 2 · agent `test-specialist`
> Baseline trước khi làm: **532 PASS** (45 Domain + 487 Infra). Mục tiêu: > 532.
> Luật: KHÔNG sửa production code. Test lộ bug ⇒ ghi vào đây + return, lead quyết.
## 0. Trạng thái ghi chép (flush-early — wave 1 mất cả return lẫn diary)
- [x] đọc spec PHẦN II (F-12..F-16) + `ApprovalWorkflowV2AdminFeatures.cs:424-930`
- [x] đọc controller PUT (`ApprovalWorkflowsV2Controller.cs:45-51`) — mục (c) lead tự viết
- [x] đọc F-6 message resolve tên (`PurchaseEvaluationWorkflowService.cs:700-720`) — mục (d) lead tự viết
- [ ] viết file test
- [ ] chạy `dotnet test` + ghi số
- [ ] kết luận
## 1. Bề mặt đang đo (đọc từ đĩa, không đọc từ spec)
| Thứ | Vị trí | Ghi chú |
|---|---|---|
| `UpdateAwDefinitionCommand` | `ApprovalWorkflowV2AdminFeatures.cs:449-464` | `Id, Name, Description, Steps, Reason?, Code?, ApplicableType?, CeoApprovalThreshold?` |
| Validator | `:466-499` | Step.Id NotEmpty · Levels NotEmpty · Order 1..3 · no-dup (Order,UserId) |
| Handler | `:501-807` | (T2) Code/Type/Ceo → `:521-532` · (T1) Bước `:534-541` + `:554-556` · Level Order/từng-row `:575-577` · echo F5/F2 `:580-585` · Cấp rỗng `:618-630` |
| F-5/F-5a guard | `:647-674` | `CountLiveOpinionsAsync` 7 bảng `:814-865`; loại PE `IsDeleted` |
| Purge mồ côi | `:676-683` + `:870-902` | `ExecuteDeleteAsync` (SQLite hỗ trợ) |
| Changelog F-1c | `:774-804` | chỉ DuyetNcc/DuyetNccPhuongAn, phiếu != DaDuyet/TuChoi |
| Endpoint PUT | `ApprovalWorkflowsV2Controller.cs:45-51` | `[Authorize(Policy="Workflows.Create")]` |
| Read-live phiếu | `PurchaseEvaluationWorkflowService.cs:671-675` | `w.Id == awId` `AsNoTracking` ⇒ F-12 đo được |
## 2. Ghi chép TRONG LÚC LÀM
(đang cập nhật…)

View File

@ -19,7 +19,7 @@
import { useMemo, useState, type FormEvent } from 'react'
import { useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { GitBranch, Plus, Trash2, CheckCircle2, History, Workflow, ChevronUp, ChevronDown, Pin, PinOff } from 'lucide-react'
import { GitBranch, Plus, Trash2, CheckCircle2, History, Workflow, ChevronUp, ChevronDown, Pin, PinOff, Pencil, AlertTriangle } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
@ -52,6 +52,10 @@ type LevelDto = {
allowApproverEditBudget: boolean
allowApproverSkipToFinal: boolean
allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot Duyệt = KẾT THÚC (không trình CEO)
// [F6 S155] PER-NGƯỜI (per-row, KHÔNG per-Cấp): NV slot này được XÓA phiếu khi
// đang ChoDuyet và đến lượt duyệt của chính mình. 1 row = 1 người ⇒ admin chỉ
// đích danh, không mở cho cả Cấp.
allowApproverDelete: boolean
// [S76] Hiển thị-only: approver được nhập/điều chỉnh ngân sách cột nào (suy từ role).
canEditProBudget: boolean
canEditCcmBudget: boolean
@ -94,6 +98,12 @@ type TypeSummaryDto = {
type LevelOrder = 1 | 2 | 3
type EditLevelEntry = {
// [F-7 S155] Id của dòng Cấp ĐANG TỒN TẠI trong DB. `undefined` = dòng MỚI
// (admin vừa bấm "+ Thêm NV"). CHỈ dùng cho đường PUT sửa-tại-chỗ:
// `UpdateAwLevelInput.Id` là `Guid?` — có Id = sửa dòng cũ, null = thêm người
// vào Cấp có sẵn (ApprovalWorkflowV2AdminFeatures.cs:424-426).
// Đường POST tạo version mới KHÔNG gửi field này (BE sinh Id mới).
id?: string
order: LevelOrder
approverUserId: string
// Mig 29 (S21 t5) — 5 Allow* per slot (default backward compat S17: chỉ
@ -108,8 +118,20 @@ type EditLevelEntry = {
allowApproverEditBudget: boolean
allowApproverSkipToFinal: boolean
allowApproverFinalize: boolean
// [F6 S155] Cho phép NV này xóa phiếu khi đến lượt duyệt của mình (default false).
allowApproverDelete: boolean
}
type EditStep = {
// [F-7 S155] Id + Order GỐC của Bước trong DB — BẮT BUỘC cho PUT sửa-tại-chỗ
// (`UpdateAwStepInput.Id` là Guid **không** nullable: lệnh này không có đường
// tạo Bước mới) và `reqStep.Order` phải KHỚP `dbStep.Order` (:554) — nên KHÔNG
// được suy ra bằng index `i+1` như đường POST (Order trong DB có thể không liền mạch).
id?: string
order?: number
name: string
departmentId: string | null
levelEntries: EditLevelEntry[]
}
type EditStep = { name: string; departmentId: string | null; levelEntries: EditLevelEntry[] }
type ApproverUser = { id: string; fullName: string; email: string; departmentId: string | null }
@ -142,11 +164,17 @@ function makeEmptyStep(stepNo: number, deptId: string | null = null): EditStep {
// Mig 29 (S21 t5) — clone 5 Allow* per slot từ existing Level.
function copyFromDefinition(d: DefinitionDto): EditStep[] {
return d.steps.map(s => ({
// [F-7 S155] Giữ Id + Order gốc. Đường POST (tạo version mới) KHÔNG dùng 2
// field này; đường PUT (sửa tại chỗ) BẮT BUỘC có. Cùng 1 hàm nạp cho cả 2
// chế độ để không sinh nhánh nạp thứ hai — nơi 2 nhánh dễ lệch nhau.
id: s.id,
order: s.order,
name: s.name,
departmentId: s.departmentId,
levelEntries: s.levels
.filter(l => l.order >= 1 && l.order <= MAX_LEVELS_PER_STEP)
.map(l => ({
id: l.id,
order: l.order as LevelOrder,
approverUserId: l.approverUserId,
allowReturnOneLevel: l.allowReturnOneLevel ?? false,
@ -157,6 +185,9 @@ function copyFromDefinition(d: DefinitionDto): EditStep[] {
allowApproverEditBudget: l.allowApproverEditBudget ?? false,
allowApproverSkipToFinal: l.allowApproverSkipToFinal ?? false,
allowApproverFinalize: l.allowApproverFinalize ?? false,
// [F6 S155] `?? false` giữ nguyên khuôn F5: bản cũ (BE chưa có cột) clone
// ra vẫn hợp lệ thay vì undefined lọt vào checked → uncontrolled input.
allowApproverDelete: l.allowApproverDelete ?? false,
})),
}))
}
@ -175,6 +206,31 @@ function makeDefaultLevelEntry(order: LevelOrder, approverUserId: string): EditL
allowApproverEditBudget: false,
allowApproverSkipToFinal: false,
allowApproverFinalize: false,
// [F6 S155] default false — quyền xóa phiếu đang duyệt phải admin tick tay.
allowApproverDelete: false,
}
}
// [F-7 S155] 🔴 CHỐNG BẪY "silent drop": payload gửi BE là object literal LIỆT KÊ
// TAY từng field (không spread `...e`) — thiếu 1 dòng thì TS vẫn build sạch, UI vẫn
// tick, BE nhận default `false` ⇒ tick vô hình (đã xảy ra thật với F6, xem
// sub-implementer-frontend-2.md §2.4). Từ đợt này có HAI call-site (POST + PUT) ⇒
// nguy cơ nhân đôi. Gom 9 cờ vào ĐÚNG MỘT hàm, cả 2 call-site cùng spread nó:
// thêm cờ thứ 10 chỉ phải sửa 1 chỗ, không thể lệch giữa 2 đường.
// - 7 cờ QUYỀN-TẠI-CHỖ (4×Return + EditDetails + EditBudget + Delete): PUT ghi được.
// - 2 cờ ĐỔI-ĐƯỜNG-ĐI (SkipToFinal, Finalize): PUT chỉ ECHO — gửi lệch ⇒ 409
// (ApprovalWorkflowV2AdminFeatures.cs:580-585). Vẫn PHẢI gửi đúng giá trị đang lưu.
function levelFlagsPayload(e: EditLevelEntry) {
return {
allowReturnOneLevel: e.allowReturnOneLevel,
allowReturnOneStep: e.allowReturnOneStep,
allowReturnToAssignee: e.allowReturnToAssignee,
allowReturnToDrafter: e.allowReturnToDrafter,
allowApproverEditDetails: e.allowApproverEditDetails,
allowApproverEditBudget: e.allowApproverEditBudget,
allowApproverSkipToFinal: e.allowApproverSkipToFinal,
allowApproverFinalize: e.allowApproverFinalize,
allowApproverDelete: e.allowApproverDelete,
}
}
@ -269,6 +325,9 @@ export function ApprovalWorkflowsV2Page() {
function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => void }) {
const [designerOpen, setDesignerOpen] = useState(false)
const [cloneFrom, setCloneFrom] = useState<DefinitionDto | null>(null)
// [F-7 S155] Khác null ⇒ Designer mở ở chế độ SỬA TẠI CHỖ (PUT, giữ nguyên Id +
// Version). Null ⇒ chế độ tạo version MỚI (POST) như cũ. Hai đường loại trừ nhau.
const [editTarget, setEditTarget] = useState<DefinitionDto | null>(null)
const qc = useQueryClient()
const del = useMutation({
@ -299,7 +358,8 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
<DefinitionCard
def={type.active}
isActive
onClone={d => { setCloneFrom(d); setDesignerOpen(true) }}
onClone={d => { setEditTarget(null); setCloneFrom(d); setDesignerOpen(true) }}
onEditInPlace={d => { setCloneFrom(null); setEditTarget(d); setDesignerOpen(true) }}
onToggleSelectable={() => toggleSelectable.mutate({
id: type.active!.id,
isUserSelectable: !type.active!.isUserSelectable,
@ -318,7 +378,7 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-700">Lịch sử versions</h3>
<Button onClick={() => { setCloneFrom(type.active); setDesignerOpen(true) }}>
<Button onClick={() => { setEditTarget(null); setCloneFrom(type.active); setDesignerOpen(true) }}>
<Plus className="h-4 w-4" />
Tạo quy trình mới
</Button>
@ -336,7 +396,11 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
key={d.id}
def={d}
isActive={false}
onClone={dd => { setCloneFrom(dd); setDesignerOpen(true) }}
onClone={dd => { setEditTarget(null); setCloneFrom(dd); setDesignerOpen(true) }}
// 🔴 Version ARCHIVED cũng PHẢI sửa-tại-chỗ được: đúng kịch bản F3 —
// admin lỡ bấm "Tạo quy trình mới", bản cũ archive, nhưng phiếu đang
// treo vẫn pin Id bản cũ đó. Cứu phiếu = sửa CHÍNH bản archived này.
onEditInPlace={dd => { setCloneFrom(null); setEditTarget(dd); setDesignerOpen(true) }}
onToggleSelectable={() => toggleSelectable.mutate({
id: d.id,
isUserSelectable: !d.isUserSelectable,
@ -353,8 +417,9 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
applicableType={type.applicableType}
applicableTypeLabel={type.applicableTypeLabel}
cloneFrom={cloneFrom}
onClose={() => { setDesignerOpen(false); setCloneFrom(null) }}
onSaved={() => { setDesignerOpen(false); setCloneFrom(null); onSaved() }}
editTarget={editTarget}
onClose={() => { setDesignerOpen(false); setCloneFrom(null); setEditTarget(null) }}
onSaved={() => { setDesignerOpen(false); setCloneFrom(null); setEditTarget(null); onSaved() }}
/>
)}
</div>
@ -367,12 +432,14 @@ function DefinitionCard({
def,
isActive,
onClone,
onEditInPlace,
onToggleSelectable,
onDelete,
}: {
def: DefinitionDto
isActive: boolean
onClone: (d: DefinitionDto) => void
onEditInPlace: (d: DefinitionDto) => void
onToggleSelectable: () => void
onDelete: () => void
}) {
@ -475,10 +542,26 @@ function DefinitionCard({
))}
</ol>
</div>
<div className="flex flex-col gap-1.5">
<Button variant="outline" size="sm" onClick={() => onClone(def)}>
<div className="flex w-44 shrink-0 flex-col gap-1.5">
{/* [F-7 S155] HAI hành động TÁCH BẠCH — hệ quả khác hẳn nhau, nhãn phải nói rõ:
(1) SỬA TẠI CHỖ → PUT, giữ nguyên Id + phiên bản ⇒ phiếu ĐANG CHẠY nhận NGAY.
(2) TẠO PHIÊN BẢN MỚI → POST, sinh Id mới ⇒ CHỈ áp cho phiếu tạo SAU đó. */}
<Button
size="sm"
onClick={() => onEditInPlace(def)}
title="Sửa ngay trên phiên bản này: thêm/bớt người trong Cấp có sẵn, đổi quyền duyệt. Giữ nguyên số phiên bản — phiếu ĐANG CHẠY áp dụng NGAY."
>
<Pencil className="h-3.5 w-3.5" />
Sửa tại chỗ
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onClone(def)}
title="Chép bản này thành phiên bản MỚI (số phiên bản tăng). Phiếu đang chạy VẪN theo bản cũ — chỉ phiếu tạo sau mới theo bản mới."
>
<Plus className="h-3.5 w-3.5" />
Tạo từ bản này
Tạo phiên bản mới
</Button>
{/* Mig 25 — toggle stick: cho user chọn quy trình này khi tạo phiếu */}
<Button
@ -506,28 +589,45 @@ function Designer({
applicableType,
applicableTypeLabel,
cloneFrom,
editTarget,
onClose,
onSaved,
}: {
applicableType: number
applicableTypeLabel: string
cloneFrom: DefinitionDto | null
// [F-7 S155] Khác null ⇒ chế độ SỬA TẠI CHỖ (PUT). Loại trừ nhau với cloneFrom.
editTarget: DefinitionDto | null
onClose: () => void
onSaved: () => void
}) {
// [F-7 S155] `isEdit` quyết định TOÀN BỘ hành vi khác biệt của Designer:
// nguồn nạp, field nào khoá, thao tác nào chặn, và gọi PUT hay POST.
const isEdit = editTarget !== null
const source = editTarget ?? cloneFrom
const initialSteps: EditStep[] = useMemo(
() => (cloneFrom ? copyFromDefinition(cloneFrom) : [makeEmptyStep(1)]),
[cloneFrom],
() => (source ? copyFromDefinition(source) : [makeEmptyStep(1)]),
[source],
)
const defaultCode = DEFAULT_CODE_BY_TYPE[applicableType] ?? 'QT-V2-001'
const [code, setCode] = useState(cloneFrom?.code ?? defaultCode)
const [name, setName] = useState(cloneFrom ? cloneFrom.name : `Quy trình ${applicableTypeLabel}`)
const [description, setDescription] = useState(cloneFrom?.description ?? '')
const [code, setCode] = useState(source?.code ?? defaultCode)
const [name, setName] = useState(source ? source.name : `Quy trình ${applicableTypeLabel}`)
const [description, setDescription] = useState(source?.description ?? '')
// S69 — Ngưỡng gói CEO (nullable). String form, '' = null. Clone giữ ngưỡng cũ.
// [F-7 S155] Ở chế độ sửa-tại-chỗ ô này bị KHOÁ và giá trị gửi lên KHÔNG lấy từ
// state string này (xem payload PUT) — tránh mọi sai lệch do parse/format.
const [ceoThreshold, setCeoThreshold] = useState(
cloneFrom?.ceoApprovalThreshold != null ? String(cloneFrom.ceoApprovalThreshold) : '',
source?.ceoApprovalThreshold != null ? String(source.ceoApprovalThreshold) : '',
)
// [F-7 S155] Lý do sửa → BE ghi vào changelog của CHÍNH những phiếu đang chạy
// trên quy trình này (ApprovalWorkflowV2AdminFeatures.cs:802-818). Optional.
const [reason, setReason] = useState('')
// [F-7 S155] Thông điệp lỗi từ BE (nhất là 409 giải thích vì sao KHÔNG sửa tại
// chỗ được). Toast tự tắt sau vài giây và cắt dòng — 409 của lệnh này dài và là
// hướng dẫn hành động ("hãy tạo phiên bản MỚI") ⇒ phải giữ trên màn để đọc kỹ.
const [serverError, setServerError] = useState<string | null>(null)
const [steps, setSteps] = useState<EditStep[]>(initialSteps)
// Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* options
@ -569,12 +669,94 @@ function Designer({
if (!e.approverUserId) {
throw new Error(`Bước "${s.name}": có dòng cấp chưa chọn NV.`)
}
const u = usersList.data?.find(x => x.id === e.approverUserId)
if (u && u.departmentId !== s.departmentId) {
throw new Error(`Bước "${s.name}": NV "${u.fullName}" không thuộc Phòng đã chọn.`)
// [F-7 S155] Ràng "NV phải thuộc Phòng đã chọn" CHỈ áp cho đường tạo mới.
// Sửa tại chỗ được phép đổi Phòng của Bước (BE xếp hạng AN TOÀN và KHÔNG
// kiểm quan hệ NV↔Phòng — :705-706 chỉ gán DepartmentId), trong khi người
// đang duyệt thì KHÔNG được đụng (đổi người mà đã có chữ ký ⇒ 409). Giữ
// ràng này ở chế độ sửa sẽ chặn oan đúng thao tác BE cho phép.
if (!isEdit) {
const u = usersList.data?.find(x => x.id === e.approverUserId)
if (u && u.departmentId !== s.departmentId) {
throw new Error(`Bước "${s.name}": NV "${u.fullName}" không thuộc Phòng đã chọn.`)
}
}
}
}
// ============ [F-7 S155] ĐƯỜNG 1 — SỬA TẠI CHỖ (PUT, giữ Id + Version) ============
if (editTarget) {
// Chặn TRƯỚC ở FE những thay đổi BE sẽ trả 409, để người dùng đọc được câu
// tiếng Việt nói rõ phải làm gì thay vì nhận lỗi sau khi bấm Lưu. UI đã khoá
// các nút ±Bước/đổi thứ tự, đây là lớp chặn thứ hai (state vẫn có thể lệch
// nếu dữ liệu nạp vào bất thường).
if (steps.length !== editTarget.steps.length) {
throw new Error(
`Sửa tại chỗ không thêm/bớt được Bước (đang có ${editTarget.steps.length}, sau khi sửa còn ${steps.length}). Hãy dùng "Tạo phiên bản mới".`,
)
}
for (const s of steps) {
const src = editTarget.steps.find(x => x.id === s.id)
if (!s.id || !src) {
throw new Error('Sửa tại chỗ không tạo được Bước mới. Hãy dùng "Tạo phiên bản mới".')
}
if (src.levels.some(l => l.order < 1 || l.order > MAX_LEVELS_PER_STEP)) {
throw new Error(
`Bước "${src.name}" có cấp ngoài phạm vi 1${MAX_LEVELS_PER_STEP} nên màn này không hiển thị đủ. Không sửa tại chỗ được — hãy dùng "Tạo phiên bản mới".`,
)
}
// `src.levels[].order` từ DTO là `number`, còn `levelEntries[].order` là
// `LevelOrder` (1|2|3) ⇒ ép cả hai Set về `number` để so sánh được.
const srcOrders = new Set<number>(src.levels.map(l => l.order))
const curOrders = new Set<number>(s.levelEntries.map(e => e.order))
const lost = [...srcOrders].filter(o => !curOrders.has(o)).sort()
if (lost.length > 0) {
throw new Error(
`Bước "${s.name}": Cấp ${lost.join(', ')} sẽ không còn ai duyệt — phiếu đang chờ ở cấp đó sẽ KẸT. Hãy giữ ít nhất 1 người ở mỗi Cấp, hoặc dùng "Tạo phiên bản mới".`,
)
}
const added = [...curOrders].filter(o => !srcOrders.has(o)).sort()
if (added.length > 0) {
throw new Error(
`Bước "${s.name}": sửa tại chỗ không thêm được Cấp mới (Cấp ${added.join(', ')}). Hãy dùng "Tạo phiên bản mới".`,
)
}
}
await api.put(`/approval-workflows-v2/${editTarget.id}`, {
id: editTarget.id,
name,
description: description || null,
// Lý do sửa → changelog phiếu đang chạy. Rỗng = không gửi.
reason: reason.trim() === '' ? null : reason.trim(),
// ECHO (null = BE bỏ qua, nhưng gửi đúng vẫn tốt hơn: lệch ⇒ 409 sớm).
code: editTarget.code,
applicableType,
// 🔴 ECHO NGHIÊM (ApprovalWorkflowV2AdminFeatures.cs:529 so sánh BẰNG,
// KHÔNG có "null = bỏ qua"): thiếu/sai ⇒ 409. Lấy THẲNG số từ DTO vừa
// GET về — KHÔNG đi qua state string `ceoThreshold` để loại mọi rủi ro
// parse/format ('' → null, dấu phân cách nghìn, làm tròn).
ceoApprovalThreshold: editTarget.ceoApprovalThreshold,
steps: steps.map(s => ({
id: s.id,
// Order gốc, KHÔNG phải index: BE bắt `reqStep.Order == dbStep.Order` (:554).
order: s.order,
name: s.name,
departmentId: s.departmentId,
levels: s.levelEntries.map(e => ({
// null = dòng MỚI (thêm người vào Cấp có sẵn). Có Id = sửa dòng cũ.
id: e.id ?? null,
order: e.order,
name: `Cấp ${e.order}`,
approverUserId: e.approverUserId,
// 9 cờ qua ĐÚNG 1 hàm dùng chung với POST — xem levelFlagsPayload().
...levelFlagsPayload(e),
})),
})),
})
return
}
// ============ ĐƯỜNG 2 — TẠO PHIÊN BẢN MỚI (POST, sinh Id mới) ============
await api.post('/approval-workflows-v2', {
applicableType,
code,
@ -595,27 +777,37 @@ function Designer({
order: e.order,
name: `Cấp ${e.order}`,
approverUserId: e.approverUserId,
allowReturnOneLevel: e.allowReturnOneLevel,
allowReturnOneStep: e.allowReturnOneStep,
allowReturnToAssignee: e.allowReturnToAssignee,
allowReturnToDrafter: e.allowReturnToDrafter,
allowApproverEditDetails: e.allowApproverEditDetails,
allowApproverEditBudget: e.allowApproverEditBudget,
allowApproverSkipToFinal: e.allowApproverSkipToFinal,
allowApproverFinalize: e.allowApproverFinalize,
// [F6 S155 → F-7 S155] 🔴 DÂY DỄ QUÊN NHẤT: payload liệt kê tay từng
// field (KHÔNG spread `...e`) ⇒ thiếu 1 cờ thì TS vẫn build sạch, ô tick
// vẫn hiện, chỉ BE không bao giờ nhận. Từ khi có call-site thứ hai (PUT),
// 9 cờ gom vào levelFlagsPayload() dùng CHUNG — thêm cờ mới chỉ sửa 1 nơi.
...levelFlagsPayload(e),
})),
})),
})
},
onSuccess: () => {
toast.success('Đã lưu quy trình mới. Version cũ đã archive.')
setServerError(null)
toast.success(
isEdit
? `Đã lưu thay đổi trên ${editTarget!.code} v${editTarget!.version} — GIỮ NGUYÊN phiên bản. Các phiếu đang chạy trên quy trình này áp dụng NGAY.`
: 'Đã lưu quy trình mới. Version cũ đã archive. Lưu ý: phiếu ĐANG chạy vẫn theo bản cũ.',
)
onSaved()
},
onError: err => toast.error(getErrorMessage(err)),
onError: err => {
// 🔴 KHÔNG nuốt thông điệp BE. 409 của lệnh sửa-tại-chỗ giải thích chính xác
// thay đổi nào phạm luật + bảo phải làm gì ("tạo phiên bản MỚI") ⇒ giữ lại
// trên màn (banner) chứ không chỉ toast thoáng qua.
const msg = getErrorMessage(err)
setServerError(msg)
toast.error(msg)
},
})
function submit(e: FormEvent) {
e.preventDefault()
setServerError(null)
if (steps.length === 0) {
toast.error('Phải có ít nhất 1 bước')
return
@ -635,23 +827,74 @@ function Designer({
<Dialog
open
onClose={onClose}
title={`Tạo quy trình mới — ${applicableTypeLabel}`}
title={
isEdit
? `Sửa tại chỗ — ${editTarget!.code} v${String(editTarget!.version).padStart(2, '0')} (giữ nguyên phiên bản)`
: `Tạo quy trình mới — ${applicableTypeLabel}`
}
size="lg"
footer={
<>
<Button variant="outline" onClick={onClose}>Hủy</Button>
<Button onClick={submit} disabled={save.isPending} form="aw-v2-form">
{save.isPending ? 'Đang lưu…' : 'Lưu + kích hoạt'}
{save.isPending
? 'Đang lưu…'
: isEdit ? 'Lưu thay đổi (giữ nguyên phiên bản)' : 'Lưu + kích hoạt'}
</Button>
</>
}
>
<form id="aw-v2-form" onSubmit={submit} className="space-y-4">
{/* [F-7 S155] Banner phân biệt HAI đường — người dùng phải hiểu hệ quả
khác nhau TRƯỚC khi sửa, không phải sau khi bấm Lưu. */}
{isEdit ? (
<div className="rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-[11.5px] leading-relaxed text-emerald-900">
<div className="font-semibold">Đang SỬA TẠI CHỖ phiên bản {editTarget!.code} v{String(editTarget!.version).padStart(2, '0')} không tạo phiên bản mới.</div>
<div className="mt-1">
Lưu xong, thay đi áp dụng <span className="font-semibold">NGAY</span> cho cả những phiếu <span className="font-semibold">đang chạy</span> trên quy trình này
(kể cả phiếu đang chờ duyệt) đây cách duy nhất đ cờ quyền vừa tick tới đưc phiếu đã gửi đi.
</div>
<div className="mt-1">
Sửa được: thêm/bớt người trong Cấp <span className="font-semibold"> sẵn</span> · đi người duyệt · các ô quyền duyệt (gồm quyền XÓA phiếu) · tên hiển thị · Phòng của Bước.
<br />
Muốn thêm/bớt Bước hoặc Cấp, đi thứ tự, đi / Ngưỡng CEO / cờ &quot;Duyệt KẾT THÚC&quot; / &quot;Duyt thng Cp cui&quot; phi bm <span className="font-semibold">&quot;To phiên bn mi&quot;</span>.
</div>
</div>
) : (
<div className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-[11.5px] leading-relaxed text-amber-900">
<div className="font-semibold">Đang TẠO PHIÊN BẢN MỚI số phiên bản sẽ tăng, bản hiện tại chuyển sang lưu trữ.</div>
<div className="mt-1">
Phiếu <span className="font-semibold">đang chạy vẫn theo bản </span> (phiếu gắn chặt vào phiên bản lúc tạo). Chỉ phiếu tạo SAU khi lưu mới theo bản này.
Nếu chỉ muốn thêm người / đi quyền cho phiếu đang treo, hãy đóng lại bấm <span className="font-semibold">&quot;Sửa tại chỗ&quot;</span>.
</div>
</div>
)}
{/* [F-7 S155] Thông điệp từ máy chủ — nhất là 409 "không sửa tại chỗ được vì…".
Giữ trên màn (không chỉ toast) để đọc kỹ và làm theo hướng dẫn. */}
{serverError && (
<div className="flex items-start gap-2 rounded-lg border border-rose-300 bg-rose-50 px-3 py-2 text-[11.5px] leading-relaxed text-rose-900">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="whitespace-pre-wrap">{serverError}</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label> quy trình *</Label>
<Input value={code} onChange={e => setCode(e.target.value)} required className="font-mono" />
<div className="text-[11px] text-slate-400">Vd QT-DN-V2-001. Version auto-tăng mỗi lần lưu.</div>
<Input
value={code}
onChange={e => setCode(e.target.value)}
required
className="font-mono"
// Mã là khoá của chuỗi phiên bản ⇒ sửa tại chỗ KHÔNG đổi được (BE :521 → 409).
disabled={isEdit}
/>
<div className="text-[11px] text-slate-400">
{isEdit
? 'Không đổi được khi sửa tại chỗ — mã là khoá của chuỗi phiên bản.'
: 'Vd QT-DN-V2-001. Version auto-tăng mỗi lần lưu.'}
</div>
</div>
<div className="space-y-1.5">
<Label>Tên hiển thị *</Label>
@ -669,12 +912,33 @@ function Designer({
value={ceoThreshold === '' ? '' : Number(ceoThreshold.replace(/[^\d]/g, '')).toLocaleString('vi-VN')}
onChange={e => setCeoThreshold(e.target.value.replace(/[^\d]/g, ''))}
placeholder="Để trống = luôn theo đủ quy trình"
// 🔴 Sửa tại chỗ KHÔNG đổi được ngưỡng: phiếu đang chờ sẽ đổi nhánh
// cần/không-cần CEO giữa chừng (BE :529 → 409). Khoá ô để người dùng
// không gõ vào rồi mới nhận lỗi. Giá trị gửi lên lấy thẳng từ bản đang lưu.
disabled={isEdit}
/>
<div className="text-[11px] leading-relaxed text-slate-400">
Gói &lt; ngưỡng: CCM duyệt xong, không cần CEO. ngưỡng: phải lên CEO.
Đ trống = luôn theo đ quy trình.
{isEdit
? 'Không đổi được khi sửa tại chỗ — đổi ngưỡng làm phiếu đang chờ nhảy nhánh cần/không-cần CEO giữa chừng. Muốn đổi: tạo phiên bản mới.'
: 'Gói < ngưỡng: CCM duyệt là xong, không cần CEO. ≥ ngưỡng: phải lên CEO. Để trống = luôn theo đủ quy trình.'}
</div>
</div>
{/* [F-7 S155] Lý do sửa — BE ghi vào changelog của chính những phiếu đang
chạy trên quy trình này, để người đọc lịch sử phiếu hiểu vì sao quy
trình đổi giữa chừng (ApprovalWorkflowV2AdminFeatures.cs:802-818). */}
{isEdit && (
<div className="col-span-2 space-y-1.5">
<Label> do sửa (ghi vào lịch sử phiếu đang chạy)</Label>
<Textarea
rows={2}
maxLength={500}
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="Vd: Bổ sung quyền xóa phiếu cho Trưởng phòng Mua hàng theo yêu cầu ngày 27/07."
/>
</div>
)}
</div>
{/* Mig 29 (S21 t5) — 5 Allow* F1+F3 per slot Approver.
@ -1005,6 +1269,19 @@ function Designer({
/>
<span>Duyệt KẾT THÚC quy trình (không trình tiếp CEO)</span>
</label>
{/* [F6 S155 — quyền xóa phiếu đang duyệt] PER-NGƯỜI, KHÔNG
per-Cấp: 1 dòng NV = 1 ô tick riêng. Tick cho NV nào thì
CHỈ NV đó xóa được, và chỉ khi phiếu đang Chờ duyệt ĐÚNG
lượt của họ. Cấp có 3 NV mà tick 1 ⇒ vẫn chỉ 1 người xóa. */}
<label className="col-span-2 flex items-center gap-1 text-[11px] text-rose-700">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowApproverDelete}
onChange={e => updateField('allowApproverDelete', e.target.checked)}
/>
<span>Cho phép NV này XÓA phiếu khi đến lượt duyệt của mình</span>
</label>
</div>
</div>
)

View File

@ -280,6 +280,8 @@ function WorkflowCard({ wf }: { wf: AwDefinitionDto }) {
<FlagRow active={r.level.allowApproverSkipToFinal} label="Cho phép duyệt thẳng Cấp cuối khi đang duyệt" colSpan2 />
{/* [Mig 58 — anh Kiệt FDC, S89] Flag thứ 8: cấp này Duyệt = KẾT THÚC (không trình CEO) — trước đây matrix thiếu nên "chưa hiển thị chính xác". */}
<FlagRow active={r.level.allowApproverFinalize} label="Duyệt là KẾT THÚC quy trình (không trình tiếp CEO)" colSpan2 />
{/* [F6 S155] Flag thứ 9 — quyền xóa phiếu đang duyệt, PER-NGƯỜI của đúng dòng NV này (không phải cả Cấp). */}
<FlagRow active={r.level.allowApproverDelete} label="Cho phép NV này XÓA phiếu khi đến lượt duyệt của mình" colSpan2 />
</div>
</td>
</tr>

View File

@ -2,7 +2,8 @@
// ApprovalWorkflowV2AdminFeatures.cs). Chỉ subset cần cho fe-user read-only
// matrix view (Plan AA Chunk B S24).
//
// 8 Allow* flag per slot Level — Mig 29/30/31 + Mig 58 (allowApproverFinalize).
// 9 Allow* flag per slot Level — Mig 29/30/31 + Mig 58 (allowApproverFinalize)
// + F6 S155 (allowApproverDelete).
export type AwLevelDto = {
id: string
@ -20,6 +21,10 @@ export type AwLevelDto = {
allowApproverSkipToFinal: boolean
// [Mig 58 — anh Kiệt FDC, S89] Flag thứ 8: cấp này Duyệt = KẾT THÚC quy trình (không trình tiếp CEO).
allowApproverFinalize: boolean
// [F6 S155] Flag thứ 9: PER-NGƯỜI (per-row) — NV slot này được XÓA phiếu khi
// phiếu đang Chờ duyệt VÀ đúng lượt duyệt của chính họ. "Cấp" là tập row cùng
// Order ⇒ tick 1 row KHÔNG mở quyền cho cả Cấp.
allowApproverDelete: boolean
}
export type AwStepDto = {

View File

@ -33,6 +33,23 @@ public class ApprovalWorkflowsV2Controller(IMediator mediator) : ControllerBase
return Ok(new { id });
}
// [S155 F-1/F-1a] SỬA TẠI CHỖ quy trình — KHÔNG tăng Version, KHÔNG đụng
// IsActive, KHÔNG đổi Id. Giữ Id là TOÀN BỘ mục đích: phiếu đang chạy pin
// ApprovalWorkflowId và đọc workflow SỐNG (PurchaseEvaluationWorkflowService
// :674 `w.Id == awId`) ⇒ chỉ có sửa-tại-chỗ mới tới được phiếu đang treo.
// Chỉ nhận thay đổi hạng AN TOÀN; handler thi hành 2 phép thử (T1) khung
// không đổi + (T2) luật chơi không đổi, vi phạm ⇒ 409.
// 🔴 Policy BẮT BUỘC — khớp 3 verb ghi còn lại của controller này. Thiếu nó =
// leo thang đặc quyền: thêm mình vào một Cấp ⇒ duyệt phiếu người khác;
// bật cờ F6 ⇒ tự cấp quyền xoá phiếu đang duyệt.
[HttpPut("{id:guid}")]
[Authorize(Policy = "Workflows.Create")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateAwDefinitionCommand cmd, CancellationToken ct)
{
await mediator.Send(cmd with { Id = id }, ct);
return NoContent();
}
public record SetUserSelectableBody(bool IsUserSelectable);
// Mig 25 — admin toggle stick/unstick "cho user pick lúc create phiếu".

View File

@ -2,9 +2,11 @@ using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.ApprovalWorkflowsV2;
@ -39,6 +41,7 @@ public record AwLevelDto(
bool AllowApproverEditBudget,
bool AllowApproverSkipToFinal,
bool AllowApproverFinalize, // [Mig 58] slot này Approve = KẾT THÚC (không trình CEO)
bool AllowApproverDelete, // [Mig 68 S155] slot này được XÓA MỀM phiếu ChoDuyet khi tới lượt mình
// [S76] Hiển thị-only: approver này được nhập/điều chỉnh ngân sách cột nào (suy
// từ ROLE — KHÔNG đổi quyền). CanEditProBudget = Admin|Procurement (cột PRO);
// CanEditCcmBudget = Admin|CostControl (cột CCM). Badge "✎ NS PRO/CCM" trong Designer.
@ -185,7 +188,7 @@ public class GetAwAdminOverviewQueryHandler(
return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email,
l.AllowReturnOneLevel, l.AllowReturnOneStep, l.AllowReturnToAssignee,
l.AllowReturnToDrafter, l.AllowApproverEditDetails, l.AllowApproverEditBudget,
l.AllowApproverSkipToFinal, l.AllowApproverFinalize,
l.AllowApproverSkipToFinal, l.AllowApproverFinalize, l.AllowApproverDelete,
proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId));
}).ToList()
)).ToList());
@ -227,7 +230,10 @@ public record CreateAwLevelInput(
bool AllowApproverEditDetails = false,
bool AllowApproverEditBudget = false,
bool AllowApproverSkipToFinal = false,
bool AllowApproverFinalize = false);
bool AllowApproverFinalize = false,
// [Mig 68 S155] F6 — slot này được XÓA MỀM phiếu đang treo ChoDuyet khi tới
// lượt mình duyệt. Default false (admin opt-in per slot).
bool AllowApproverDelete = false);
public record CreateAwStepInput(
int Order,
@ -366,6 +372,7 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
AllowApproverEditBudget = l.AllowApproverEditBudget,
AllowApproverSkipToFinal = l.AllowApproverSkipToFinal,
AllowApproverFinalize = l.AllowApproverFinalize,
AllowApproverDelete = l.AllowApproverDelete, // [Mig 68 S155] F6
}).ToList(),
})
.ToList(),
@ -376,6 +383,747 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
}
}
// ============================================================================
// ========== PUT sửa TẠI CHỖ (S155 2026-07-27 — hạng mục F, owner chốt (7)) ==========
// ============================================================================
// Owner: "Chỗ quy trình duyệt -> Cho thêm tính năng thêm người/điều chỉnh quyền
// -> Chứ ko cần phải tạo mới. Khi nào cần thay đổi quy trình duyệt thì mới bắt
// buộc tạo mới."
//
// 🔴 VÌ SAO PHẢI CÓ (§F.0 spec S155 — phụ thuộc CỨNG, không phải tiện nghi):
// POST tạo version mới sinh Id MỚI (:336) → phiếu đang treo pin Id CŨ
// (`PurchaseEvaluationWorkflowService.cs:674` `w.Id == awId`, tham chiếu SỐNG
// không snapshot) → bản cũ chỉ bị IsActive=false (:334) nên vẫn resolve được →
// phiếu chạy tiếp trên cấu hình CŨ, IM LẶNG. Không có đường re-pin
// (`PE.ApprovalWorkflowId` chỉ ghi lúc create/update-nháp). ⇒ Tick cờ hôm nay
// bằng POST thì phiếu cần cứu KHÔNG BAO GIỜ thấy cờ.
// ⇒ Lệnh này GIỮ NGUYÊN Id + Version + IsActive. Giữ Id là TOÀN BỘ mục đích.
//
// 🔴 PHÂN LOẠI (§F.2) — AN TOÀN ⟺ thoả CẢ HAI phép thử:
// (T1) Khung không đổi — tập {Step.Order} và {Level.Order} giữ nguyên (con trỏ
// phiếu LAI: StepIndex = INDEX vào list sort `:686`, LevelOrder = so GIÁ
// TRỊ `:694` ⇒ giữ 2 tập Order thì không con trỏ nào dịch).
// Ở đây SIẾT MẠNH HƠN set-equality: Order của TỪNG row là BẤT BIẾN. Lý do:
// hoán vị Order giữa 2 Bước giữ nguyên TẬP nhưng đổi thứ tự thật (#11 PHÁ VỠ),
// set-equality một mình KHÔNG bắt được.
// (T2) Luật chơi không đổi — cấm trường quyết định ĐƯỜNG ĐI: CeoApprovalThreshold,
// F5 AllowApproverFinalize, Code, ApplicableType.
//
// ✅ Cho sửa: thêm/bớt người trong Cấp CÓ SẴN · đổi ApproverUserId · 7 cờ
// QUYỀN-TẠI-CHỖ (F1×4, F3, F4, F6) · tên hiển thị (workflow/step/level) ·
// Step.DepartmentId (owner chốt AN TOÀN — BE chỉ dùng làm hint, không khớp người duyệt).
// ❌ Từ chối 409 + bảo tạo version mới: thêm/bớt/đổi thứ tự Bước hoặc Cấp ·
// Code · ApplicableType · CeoApprovalThreshold · F5 · F2.
//
// 🔸 KHAI QUYẾT ĐỊNH (spec KHÔNG xếp hạng F2 `AllowApproverSkipToFinal`): xếp F2
// vào nhóm CẤM-ĐỔI cùng F5 vì nó cho phép nhảy thẳng Cấp cuối = đổi đường đi
// phiếu, không phải "quyền tại chỗ". Chọn phía CHẶT khi spec im lặng; nới sau
// rẻ hơn thu hồi. Cả F2 lẫn F5 nhận theo kiểu ECHO: gửi lên phải KHỚP giá trị
// đang lưu (null = không khai báo, giữ nguyên), lệch ⇒ 409 — KHÔNG bao giờ ghi.
public record UpdateAwLevelInput(
// null = dòng MỚI (thêm người vào Cấp CÓ SẴN). Có Id = dòng đang tồn tại.
Guid? Id,
int Order,
string? Name,
Guid ApproverUserId,
// 7 cờ QUYỀN-TẠI-CHỖ — sửa được tại chỗ.
bool AllowReturnOneLevel = false,
bool AllowReturnOneStep = false,
bool AllowReturnToAssignee = false,
bool AllowReturnToDrafter = true,
bool AllowApproverEditDetails = false,
bool AllowApproverEditBudget = false,
bool AllowApproverDelete = false,
// 2 cờ ĐỔI-ĐƯỜNG-ĐI — ECHO-only (null = không khai báo). Lệch ⇒ 409.
bool? AllowApproverSkipToFinal = null,
bool? AllowApproverFinalize = null);
public record UpdateAwStepInput(
Guid Id, // BẮT BUỘC — không có đường thêm/bớt Bước ở lệnh này
int Order,
string Name,
Guid? DepartmentId,
List<UpdateAwLevelInput> Levels);
public record UpdateAwDefinitionCommand(
Guid Id,
string Name,
string? Description,
List<UpdateAwStepInput> Steps,
// F-1c — lý do sửa, đi vào VẾT (changelog phiếu đang chạy). Optional.
string? Reason = null,
// (T2) ECHO-only. Code/ApplicableType: null = không khai báo → bỏ qua.
string? Code = null,
int? ApplicableType = null,
// 🔴 CeoApprovalThreshold ECHO NGHIÊM: phải KHỚP TUYỆT ĐỐI giá trị đang lưu
// (cả 2 cùng null, hoặc cùng giá trị). KHÔNG dùng "null = bỏ qua" như 2 trường
// trên vì null LÀ một giá trị có nghĩa (= bỏ ngưỡng) ⇒ nếu bỏ qua thì admin xoá
// trắng ô ngưỡng sẽ nhận 204 mà số KHÔNG đổi = sai IM LẶNG, đúng cái class lỗi
// spec này đang đi chữa. Client PHẢI echo lại ngưỡng hiện tại.
decimal? CeoApprovalThreshold = null) : IRequest;
public class UpdateAwDefinitionCommandValidator : AbstractValidator<UpdateAwDefinitionCommand>
{
public UpdateAwDefinitionCommandValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Description).MaximumLength(1000);
RuleFor(x => x.Reason).MaximumLength(500);
RuleFor(x => x.Steps).NotEmpty()
.WithMessage("Quy trình phải có ít nhất 1 bước.");
RuleForEach(x => x.Steps).ChildRules(step =>
{
step.RuleFor(s => s.Id).NotEmpty()
.WithMessage("Mỗi Bước phải kèm Id của Bước đang tồn tại (sửa tại chỗ không tạo Bước mới).");
step.RuleFor(s => s.Order).GreaterThanOrEqualTo(1);
step.RuleFor(s => s.Name).NotEmpty().MaximumLength(200);
step.RuleFor(s => s.Levels).NotEmpty()
.WithMessage("Mỗi bước phải có ít nhất 1 cấp duyệt.");
step.RuleForEach(s => s.Levels).ChildRules(level =>
{
level.RuleFor(l => l.Order)
.InclusiveBetween(1, CreateAwDefinitionCommandValidator.MaxLevelsPerStep);
level.RuleFor(l => l.Name).MaximumLength(200);
level.RuleFor(l => l.ApproverUserId).NotEmpty()
.WithMessage("Mỗi dòng cấp phải chỉ định 1 NV duyệt.");
});
step.RuleFor(s => s.Levels).Must(HaveNoDuplicateApproverInSameLevel)
.WithMessage("Một NV không được duyệt hai lần trong cùng một Cấp.");
});
}
private static bool HaveNoDuplicateApproverInSameLevel(List<UpdateAwLevelInput> levels) =>
levels.GroupBy(l => new { l.Order, l.ApproverUserId }).All(g => g.Count() == 1);
}
public class UpdateAwDefinitionCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
IDateTime dateTime,
UserManager<User> userManager) : IRequestHandler<UpdateAwDefinitionCommand>
{
private const string NewVersionHint =
"Thay đổi này đổi CẤU TRÚC/ĐƯỜNG ĐI của quy trình nên không sửa tại chỗ được — hãy tạo phiên bản quy trình MỚI.";
private sealed record LevelOpinionCount(Guid LevelId, int Count);
// F2 — một chữ ký CÒN LƯU trên dòng Cấp, kèm đủ thứ để NÓI TÊN hồ sơ đang giữ nó.
// `Code` nullable vì mọi module đều cho hồ sơ nháp chưa sinh mã.
private sealed record RetainedOpinionRef(Guid LevelId, string Module, string? Code, bool ParentDeleted);
public async Task Handle(UpdateAwDefinitionCommand request, CancellationToken ct)
{
var def = await db.ApprovalWorkflows
.Include(w => w.Steps)
.ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == request.Id, ct)
?? throw new NotFoundException("Quy trình duyệt", request.Id);
// ================= (T2) LUẬT CHƠI KHÔNG ĐỔI =================
if (request.Code is string code && !string.Equals(code, def.Code, StringComparison.Ordinal))
throw new ConflictException(
$"Không được đổi Mã quy trình khi sửa tại chỗ ({def.Code} → {code}): mã là khoá của chuỗi phiên bản. {NewVersionHint}");
if (request.ApplicableType is int applicableType && applicableType != (int)def.ApplicableType)
throw new ConflictException(
$"Không được đổi Loại áp dụng khi sửa tại chỗ: phiếu đang pin quy trình này sẽ chạy sang module khác. {NewVersionHint}");
if (request.CeoApprovalThreshold != def.CeoApprovalThreshold)
throw new ConflictException(
$"Không được đổi Ngưỡng giá trị gói CEO khi sửa tại chỗ ({FormatMoney(def.CeoApprovalThreshold)} → {FormatMoney(request.CeoApprovalThreshold)}): " +
$"phiếu đang chờ sẽ đổi nhánh cần/không-cần CEO giữa chừng, trong khi người đã duyệt thì duyệt theo ngưỡng họ nhìn thấy lúc đó. {NewVersionHint}");
// ================= (T1) KHUNG KHÔNG ĐỔI — Bước =================
var dbSteps = def.Steps.OrderBy(s => s.Order).ToList();
var reqStepIds = request.Steps.Select(s => s.Id).ToList();
if (reqStepIds.Distinct().Count() != reqStepIds.Count)
throw new ConflictException("Danh sách Bước gửi lên có Id trùng nhau.");
if (!dbSteps.Select(s => s.Id).ToHashSet().SetEquals(reqStepIds))
throw new ConflictException(
$"Không được thêm/bớt Bước khi sửa tại chỗ (quy trình đang có {dbSteps.Count} bước, gửi lên {reqStepIds.Count}). {NewVersionHint}");
// ================= Phân tích diff theo Level.Id (F-3: CẤM delete-then-insert) =================
var toRemove = new List<ApprovalWorkflowLevel>(); // bỏ người khỏi Cấp
var toUpdate = new List<(ApprovalWorkflowLevel Db, UpdateAwLevelInput Req, ApprovalWorkflowStep Step)>();
var toAdd = new List<(ApprovalWorkflowStep Step, UpdateAwLevelInput Req)>();
var approverChanged = new List<ApprovalWorkflowLevel>(); // đổi ApproverUserId (hạng NỬA #5)
foreach (var reqStep in request.Steps)
{
var dbStep = dbSteps.First(s => s.Id == reqStep.Id);
var where = $"Bước {dbStep.Order} ({dbStep.Name})";
if (reqStep.Order != dbStep.Order)
throw new ConflictException(
$"Không được đổi thứ tự Bước ({where}: {dbStep.Order} → {reqStep.Order}): con trỏ phiếu trỏ theo INDEX của danh sách đã sắp xếp nên sẽ trỏ sang bước khác, ÂM THẦM. {NewVersionHint}");
var dbLevels = dbStep.Levels.OrderBy(l => l.Order).ToList();
var reqLevelIds = reqStep.Levels.Where(l => l.Id is Guid).Select(l => l.Id!.Value).ToList();
if (reqLevelIds.Distinct().Count() != reqLevelIds.Count)
throw new ConflictException($"{where}: danh sách cấp duyệt gửi lên có Id trùng nhau.");
var dbLevelById = dbLevels.ToDictionary(l => l.Id);
var unknown = reqLevelIds.Where(id => !dbLevelById.ContainsKey(id)).ToList();
if (unknown.Count > 0)
throw new ConflictException($"{where}: có dòng cấp duyệt không thuộc bước này (Id {string.Join(", ", unknown)}).");
foreach (var reqLevel in reqStep.Levels)
{
if (reqLevel.Id is Guid lid)
{
var dbLevel = dbLevelById[lid];
// Order của TỪNG dòng là BẤT BIẾN (siết mạnh hơn set-equality).
if (reqLevel.Order != dbLevel.Order)
throw new ConflictException(
$"{where}: không được chuyển người duyệt từ Cấp {dbLevel.Order} sang Cấp {reqLevel.Order} khi sửa tại chỗ. {NewVersionHint}");
// (T2) 2 cờ ĐỔI-ĐƯỜNG-ĐI — echo phải khớp.
if (reqLevel.AllowApproverFinalize is bool f && f != dbLevel.AllowApproverFinalize)
throw new ConflictException(
$"{where} — Cấp {dbLevel.Order}: không được bật/tắt cờ \"Duyệt kết thúc (không trình CEO)\" khi sửa tại chỗ — cờ này đổi ĐIỂM KẾT THÚC của phiếu và đóng băng số tiền ngay lúc duyệt. {NewVersionHint}");
if (reqLevel.AllowApproverSkipToFinal is bool s && s != dbLevel.AllowApproverSkipToFinal)
throw new ConflictException(
$"{where} — Cấp {dbLevel.Order}: không được bật/tắt cờ \"Duyệt vượt cấp tới Cấp cuối\" khi sửa tại chỗ — cờ này cho bỏ qua các Bước/Cấp trung gian. {NewVersionHint}");
toUpdate.Add((dbLevel, reqLevel, dbStep));
if (reqLevel.ApproverUserId != dbLevel.ApproverUserId)
approverChanged.Add(dbLevel);
}
else
{
// Dòng MỚI — người mới KHÔNG được mang cờ đổi-đường-đi.
if (reqLevel.AllowApproverFinalize == true)
throw new ConflictException(
$"{where} — Cấp {reqLevel.Order}: người mới thêm không được mang cờ \"Duyệt kết thúc (không trình CEO)\". {NewVersionHint}");
if (reqLevel.AllowApproverSkipToFinal == true)
throw new ConflictException(
$"{where} — Cấp {reqLevel.Order}: người mới thêm không được mang cờ \"Duyệt vượt cấp tới Cấp cuối\". {NewVersionHint}");
toAdd.Add((dbStep, reqLevel));
}
}
var removedHere = dbLevels.Where(l => !reqLevelIds.Contains(l.Id)).ToList();
foreach (var gone in removedHere)
{
// Bỏ người đang giữ cờ đổi-đường-đi = đổi điểm kết thúc / bỏ đường tắt.
if (gone.AllowApproverFinalize)
throw new ConflictException(
$"{where} — Cấp {gone.Order}: không được bỏ người đang giữ cờ \"Duyệt kết thúc (không trình CEO)\" — bỏ đi là đổi ĐIỂM KẾT THÚC của phiếu. {NewVersionHint}");
if (gone.AllowApproverSkipToFinal)
throw new ConflictException(
$"{where} — Cấp {gone.Order}: không được bỏ người đang giữ cờ \"Duyệt vượt cấp tới Cấp cuối\". {NewVersionHint}");
}
toRemove.AddRange(removedHere);
// (T1) tập {Level.Order} SAU khi sửa phải trùng khít tập TRƯỚC.
var finalOrders = reqStep.Levels.Select(l => l.Order).ToHashSet();
var dbOrders = dbLevels.Select(l => l.Order).ToHashSet();
if (!dbOrders.SetEquals(finalOrders))
{
var lost = dbOrders.Except(finalOrders).OrderBy(o => o).ToList();
if (lost.Count > 0)
throw new ConflictException(
$"{where}: Cấp {string.Join(", ", lost)} sẽ KHÔNG CÒN người duyệt nào — phiếu đang chờ ở cấp đó sẽ KẸT. Hãy giữ lại ít nhất 1 người ở mỗi Cấp. {NewVersionHint}");
var added = finalOrders.Except(dbOrders).OrderBy(o => o).ToList();
throw new ConflictException(
$"{where}: không được thêm Cấp mới (Cấp {string.Join(", ", added)}) khi sửa tại chỗ. {NewVersionHint}");
}
}
// ================= NV được trỏ tới phải tồn tại (chặn FK 547 → 500) =================
var referencedUserIds = toUpdate.Select(t => t.Req.ApproverUserId)
.Concat(toAdd.Select(t => t.Req.ApproverUserId))
.Distinct().ToList();
if (referencedUserIds.Count > 0)
{
var existing = await db.Users.AsNoTracking()
.Where(u => referencedUserIds.Contains(u.Id))
.Select(u => u.Id).ToListAsync(ct);
var missing = referencedUserIds.Except(existing).ToList();
if (missing.Count > 0)
throw new ConflictException($"Có NV duyệt không tồn tại trong hệ thống (Id {string.Join(", ", missing)}).");
}
// ================= F-5 / F-5a — hạng NỬA: chặn LỊCH SỰ trước khi FK nổ =================
// Row đã có CHỮ KÝ (LevelOpinion) mà bị bỏ đi → FK Restrict nổ 547 → 500.
// Row đã có chữ ký mà bị đổi ApproverUserId → chữ ký của người A hoá thành
// của người B, IM LẶNG (opinion khoá theo LevelId, không theo UserId).
// Guard chạy HAI TẦNG (tầng 2 thêm ở wave 5 theo F2 của reviewer):
// tầng 1 `CountLiveOpinionsAsync` — chữ ký trên hồ sơ CÒN SỐNG (thông điệp cũ).
// tầng 2 `ListRetainedOpinionsAsync` — chữ ký còn LƯU nhưng không "sống"
// (hồ sơ cha xoá mềm, hoặc ý kiến tự nó xoá mềm).
// 🔴 ĐẢO QUYẾT ĐỊNH F-5a (bản trước): trước đây coi "phiếu đã xoá thì chặn admin là
// OAN" nên để purge xoá CỨNG chữ ký của phiếu xoá mềm. Sai — xoá mềm là KHÔI PHỤC
// ĐƯỢC và §A spec S155 có màn "Đã xoá" đọc lịch sử; xoá cứng chữ ký của nó là mất
// trắng, im lặng, không dấu vết. Nay chặn cả hai tầng; chỉ hồ sơ đã KHÔNG CÒN ROW
// mới bị coi là mồ côi (xem `PurgeOrphanOpinionsAsync`).
var guardedLevelIds = toRemove.Select(l => l.Id).Concat(approverChanged.Select(l => l.Id)).Distinct().ToList();
if (guardedLevelIds.Count > 0)
{
var liveOpinions = await CountLiveOpinionsAsync(guardedLevelIds, ct);
var blocked = toRemove.Where(l => liveOpinions.GetValueOrDefault(l.Id) > 0).ToList();
if (blocked.Count > 0)
{
var first = blocked[0];
var step = dbSteps.First(s => s.Id == first.ApprovalWorkflowStepId);
throw new ConflictException(
$"Bước {step.Order} ({step.Name}) — Cấp {first.Order}: không bỏ được người này vì đã có {liveOpinions[first.Id]} chữ ký/ý kiến trên phiếu còn hiệu lực. Bỏ đi là xoá mất lịch sử duyệt. {NewVersionHint}");
}
var blockedSwap = approverChanged.Where(l => liveOpinions.GetValueOrDefault(l.Id) > 0).ToList();
if (blockedSwap.Count > 0)
{
var first = blockedSwap[0];
var step = dbSteps.First(s => s.Id == first.ApprovalWorkflowStepId);
throw new ConflictException(
$"Bước {step.Order} ({step.Name}) — Cấp {first.Order}: không đổi được người duyệt vì dòng này đã có {liveOpinions[first.Id]} chữ ký/ý kiến trên phiếu còn hiệu lực — đổi người sẽ khiến ý kiến của người cũ hoá thành của người mới. {NewVersionHint}");
}
// ===== 🔴 F2 — TẦNG 2: chữ ký CÒN LƯU nhưng không "sống" =====
// Hai ca mà tầng 1 CỐ TÌNH bỏ qua, và trước bản vá bị `PurgeOrphanOpinionsAsync`
// xoá CỨNG (mất vĩnh viễn, chỉ để lại một con số đếm trong changelog):
// (a) hồ sơ cha XOÁ MỀM — row còn nguyên, khôi phục được, màn "Đã xoá" phải
// đọc được lịch sử ký;
// (b) chính opinion đã xoá mềm trên hồ sơ CÒN SỐNG — bản ghi KIỂM TOÁN của
// tài liệu đang hiệu lực.
// Nay chặn admin y như phiếu sống, và NÓI RÕ hồ sơ nào đang giữ chữ ký để họ
// xử lý được (khôi phục, hoặc xoá hẳn) thay vì đoán.
var retained = await ListRetainedOpinionsAsync(guardedLevelIds, ct);
if (retained.Count > 0)
{
var goneFirst = toRemove.FirstOrDefault(l => retained.ContainsKey(l.Id));
var target = goneFirst ?? approverChanged.FirstOrDefault(l => retained.ContainsKey(l.Id));
if (target is not null)
{
var step = dbSteps.First(s => s.Id == target.ApprovalWorkflowStepId);
var rows = retained[target.Id];
var verb = goneFirst is not null ? "không bỏ được người này" : "không đổi được người duyệt";
throw new ConflictException(
$"Bước {step.Order} ({step.Name}) — Cấp {target.Order}: {verb} vì còn {rows.Count} chữ ký/ý kiến đang LƯU, thuộc: {DescribeRetained(rows)}. " +
$"Chữ ký của hồ sơ đã xoá vẫn là LỊCH SỬ DUYỆT (màn \"Đã xoá\" còn đọc được) nên hệ thống KHÔNG tự xoá cứng. " +
$"Hãy khôi phục hồ sơ rồi xử lý, hoặc xoá HẲN hồ sơ đó khỏi hệ thống trước. {NewVersionHint}");
}
}
}
// ================= 🔴 F1 (reviewer đợt 1) — RÀO GIAO DỊCH =================
// TRƯỚC bản vá: `PurgeOrphanOpinionsAsync` dùng `ExecuteDeleteAsync`, mà
// ExecuteDelete phát DELETE thẳng xuống SQL và **auto-commit NGAY** khi không có
// transaction ambient. `SaveChangesAsync` thì mãi cuối method. Giữa hai điểm đó có
// ÍT NHẤT 4 nguồn ném ĐỘC LẬP: `ResolveUserNamesAsync` (round-trip DB) ·
// `db.ApprovalWorkflowLevels.Remove` (severed-association nếu detach bắt hụt) ·
// truy vấn phiếu bị ảnh hưởng (round-trip DB) · chính `SaveChanges` (FK/unique/
// concurrency/timeout/huỷ-token). Ném ở bất kỳ chỗ nào ⇒ mọi thay đổi sau đó
// rollback, NHƯNG chữ ký đã bị xoá CỨNG thì không quay lại — admin nhận 409/500 và
// tưởng "không có gì xảy ra". Đúng class lỗi SAI IM LẶNG mà spec S155 đang đi chữa.
//
// Mở transaction TẠI ĐÂY — ngay trước thao tác KHÔNG-HOÀN-TÁC đầu tiên. Mọi guard
// phía trên chỉ ĐỌC (AsNoTracking) và ném trước khi chạm dữ liệu, nên không cần
// nằm trong rào; mở sớm hơn chỉ kéo dài thời gian giữ khoá trên SQL Server.
// KHÔNG dùng IsolationLevel.Serializable như các code-generator (`ContractCodeGenerator.cs:37`):
// ở đó Serializable để chống ĐUA cấp-số; ở đây yêu cầu là NGUYÊN TỬ (all-or-nothing),
// không phải chống đua — siết isolation chỉ làm màn quản trị dễ deadlock.
// `await using` + dispose = rollback nếu chưa Commit (khuôn nhà: `LeaveOtApprovalFeatures.cs:34-44`).
var context = (DbContext)db;
await using var tx = await context.Database.BeginTransactionAsync(ct);
// ================= Dọn rác chữ ký MỒ CÔI trước khi xoá dòng =================
// 🔴 F2 (reviewer đợt 1) đã THU HẸP nhãn "mồ côi": chỉ còn opinion mà hồ sơ cha
// KHÔNG CÒN ROW trong DB. Hồ sơ xoá MỀM giữ nguyên chữ ký và bị guard chặn ở trên.
// 🔴 LevelOpinion là AuditableEntity ⇒ Remove() chỉ xoá MỀM (interceptor đổi
// Deleted→Modified) ⇒ row ở lại ⇒ FK vẫn chặn. Phải ExecuteDelete (SQL thẳng).
var purgedOrphans = 0;
if (toRemove.Count > 0)
purgedOrphans = await PurgeOrphanOpinionsAsync(toRemove.Select(l => l.Id).ToList(), ct);
// ================= ÁP THAY ĐỔI (chỉ trường hạng AN TOÀN) =================
var userNames = await ResolveUserNamesAsync(
toUpdate.Select(t => t.Db.ApproverUserId)
.Concat(toUpdate.Select(t => t.Req.ApproverUserId))
.Concat(toAdd.Select(t => t.Req.ApproverUserId))
.Concat(toRemove.Select(l => l.ApproverUserId))
.Distinct().ToList(), ct);
string NameOf(Guid id) => userNames.GetValueOrDefault(id) ?? id.ToString();
var changes = new List<string>();
if (def.Name != request.Name) changes.Add($"Đổi tên quy trình: \"{def.Name}\" → \"{request.Name}\"");
def.Name = request.Name;
def.Description = request.Description;
foreach (var reqStep in request.Steps)
{
var dbStep = dbSteps.First(s => s.Id == reqStep.Id);
if (dbStep.Name != reqStep.Name) changes.Add($"Bước {dbStep.Order}: đổi tên \"{dbStep.Name}\" → \"{reqStep.Name}\"");
if (dbStep.DepartmentId != reqStep.DepartmentId) changes.Add($"Bước {dbStep.Order} ({reqStep.Name}): đổi phòng ban phụ trách");
dbStep.Name = reqStep.Name;
dbStep.DepartmentId = reqStep.DepartmentId;
}
foreach (var (dbLevel, reqLevel, step) in toUpdate)
{
if (dbLevel.ApproverUserId != reqLevel.ApproverUserId)
changes.Add($"Bước {step.Order} Cấp {dbLevel.Order}: đổi NV duyệt {NameOf(dbLevel.ApproverUserId)} → {NameOf(reqLevel.ApproverUserId)}");
var flagDiff = DescribeFlagChanges(dbLevel, reqLevel);
if (flagDiff.Count > 0)
changes.Add($"Bước {step.Order} Cấp {dbLevel.Order} — {NameOf(reqLevel.ApproverUserId)}: {string.Join("; ", flagDiff)}");
dbLevel.Name = reqLevel.Name;
dbLevel.ApproverUserId = reqLevel.ApproverUserId;
dbLevel.AllowReturnOneLevel = reqLevel.AllowReturnOneLevel;
dbLevel.AllowReturnOneStep = reqLevel.AllowReturnOneStep;
dbLevel.AllowReturnToAssignee = reqLevel.AllowReturnToAssignee;
dbLevel.AllowReturnToDrafter = reqLevel.AllowReturnToDrafter;
dbLevel.AllowApproverEditDetails = reqLevel.AllowApproverEditDetails;
dbLevel.AllowApproverEditBudget = reqLevel.AllowApproverEditBudget;
dbLevel.AllowApproverDelete = reqLevel.AllowApproverDelete;
// ❌ KHÔNG gán AllowApproverFinalize / AllowApproverSkipToFinal — echo-only.
}
foreach (var (step, reqLevel) in toAdd)
{
var newLevel = new ApprovalWorkflowLevel
{
ApprovalWorkflowStepId = step.Id,
Order = reqLevel.Order,
Name = reqLevel.Name,
ApproverUserId = reqLevel.ApproverUserId,
AllowReturnOneLevel = reqLevel.AllowReturnOneLevel,
AllowReturnOneStep = reqLevel.AllowReturnOneStep,
AllowReturnToAssignee = reqLevel.AllowReturnToAssignee,
AllowReturnToDrafter = reqLevel.AllowReturnToDrafter,
AllowApproverEditDetails = reqLevel.AllowApproverEditDetails,
AllowApproverEditBudget = reqLevel.AllowApproverEditBudget,
AllowApproverDelete = reqLevel.AllowApproverDelete,
AllowApproverSkipToFinal = false, // đổi-đường-đi: dòng mới luôn tắt
AllowApproverFinalize = false,
};
// 🔴 PHẢI đăng ký TƯỜNG MINH qua DbSet.Add — KHÔNG được chỉ `step.Levels.Add(...)`.
// `BaseEntity.Id = Guid.NewGuid()` (BaseEntity.cs:5) ⇒ khoá LUÔN khác default.
// Khi DetectChanges gặp một entity lạ trong collection của cha ĐANG ĐƯỢC THEO DÕI,
// EF quyết Added/Modified bằng heuristic "khoá đã set ⇒ coi như đã tồn tại" ⇒ nó
// gán state **Modified** ⇒ phát UPDATE cho dòng CHƯA CÓ ⇒ 0 row ⇒
// DbUpdateConcurrencyException. DbSet.Add ép state Added, không qua heuristic.
// (POST tạo mới không dính vì Add từ GỐC — cả đồ thị bị ép Added.)
db.ApprovalWorkflowLevels.Add(newLevel);
step.Levels.Add(newLevel); // giữ nav in-memory nhất quán cho phần đọc phía sau
changes.Add($"Bước {step.Order} Cấp {reqLevel.Order}: THÊM NV duyệt {NameOf(reqLevel.ApproverUserId)}");
}
// 🔴 Gỡ THEO DÕI mọi bản sao chữ ký còn nằm trong change-tracker và trỏ vào các dòng
// Cấp sắp xoá — BẮT BUỘC làm TRƯỚC `Remove` bên dưới. Xem `DetachTrackedOpinionsOn`.
if (toRemove.Count > 0)
DetachTrackedOpinionsOn(db, toRemove.Select(l => l.Id).ToHashSet());
foreach (var gone in toRemove)
{
var step = dbSteps.First(s => s.Id == gone.ApprovalWorkflowStepId);
changes.Add($"Bước {step.Order} Cấp {gone.Order}: BỎ NV duyệt {NameOf(gone.ApproverUserId)}");
db.ApprovalWorkflowLevels.Remove(gone); // BaseEntity → xoá CỨNG (không dính soft-delete)
}
if (purgedOrphans > 0)
changes.Add($"Dọn {purgedOrphans} chữ ký mồ côi (hồ sơ mang chữ ký đã KHÔNG CÒN trong hệ thống) để gỡ được người khỏi Cấp");
// ================= F-1c — GHI VẾT =================
// (a) Chạm entity cha để AuditingInterceptor đóng dấu UpdatedAt/UpdatedBy kể
// cả khi chỉ có dòng Cấp đổi (ai + lúc nào).
def.UpdatedAt = dateTime.UtcNow;
// (b) LÝ DO + NỘI DUNG đổi → changelog của CHÍNH những phiếu đang chịu ảnh
// hưởng (phiếu chưa kết thúc, đang pin quy trình này). Đối xứng với yêu
// cầu ghi changelog khi xoá phiếu (§B4): người đọc lịch sử phiếu thấy
// được "quy trình vừa bị sửa lúc nào, ai sửa, vì sao".
// ⚠️ KHAI GIỚI HẠN: repo CHƯA có bảng changelog cho quy trình duyệt, mà
// đợt 1 bị chốt "no new table" ⇒ vết cho workflow thuộc 5 module Văn
// phòng số (Proposal/Leave/Ot/Travel/Vehicle) hiện CHỈ có UpdatedAt/
// UpdatedBy ở (a). Bảng `ApprovalWorkflowChangelogs` = việc tách riêng.
if (changes.Count > 0 &&
def.ApplicableType is ApprovalWorkflowApplicableType.DuyetNcc
or ApprovalWorkflowApplicableType.DuyetNccPhuongAn)
{
var affected = await db.PurchaseEvaluations.AsNoTracking()
.Where(p => p.ApprovalWorkflowId == def.Id
&& p.Phase != PurchaseEvaluationPhase.DaDuyet
&& p.Phase != PurchaseEvaluationPhase.TuChoi)
.Select(p => new { p.Id, p.Phase })
.ToListAsync(ct);
var note = string.Join(" | ", changes);
if (!string.IsNullOrWhiteSpace(request.Reason))
note = $"Lý do: {request.Reason.Trim()} | {note}";
foreach (var pe in affected)
{
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = pe.Id,
EntityType = PurchaseEvaluationEntityType.Workflow,
Action = SolutionErp.Domain.Contracts.ChangelogAction.Update,
EntityId = def.Id,
PhaseAtChange = pe.Phase,
UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email ?? "Hệ thống",
Summary = $"Sửa quy trình duyệt tại chỗ: {def.Code} v{def.Version} (giữ nguyên phiên bản)",
ContextNote = note,
});
}
}
await db.SaveChangesAsync(ct);
// 🔴 F1 — CHỐT rào: tới đây purge + gỡ dòng Cấp + changelog mới cùng nhau có hiệu lực.
// Ném ở BẤT KỲ đâu phía trên ⇒ dispose của `tx` rollback TRỌN, kể cả các câu DELETE
// của `PurgeOrphanOpinionsAsync` (ExecuteDelete tự enlist vào transaction đang mở).
await tx.CommitAsync(ct);
}
// Đếm chữ ký CÒN HIỆU LỰC trên các dòng Cấp — quét CẢ 7 bảng LevelOpinion đang
// FK vào ApprovalWorkflowLevels (PE + HĐ + 5 module Văn phòng số). Chỉ soi PE là
// vẫn nổ FK 547 khi quy trình thuộc module khác (§F.3 blast radius ≥6 module).
// 🔴 `!p.IsDeleted` viết TƯỜNG MINH: chỉ PE + Contract có global query filter,
// 5 bảng Office KHÔNG có ⇒ dựa filter là đúng PE mà sai 5 module kia.
private async Task<Dictionary<Guid, int>> CountLiveOpinionsAsync(List<Guid> levelIds, CancellationToken ct)
{
var acc = levelIds.ToDictionary(id => id, _ => 0);
void Merge(List<LevelOpinionCount> rows)
{
foreach (var r in rows) acc[r.LevelId] = acc.GetValueOrDefault(r.LevelId) + r.Count;
}
Merge(await db.PurchaseEvaluationLevelOpinions.AsNoTracking()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
&& db.PurchaseEvaluations.Any(p => p.Id == o.PurchaseEvaluationId && !p.IsDeleted))
.GroupBy(o => o.ApprovalWorkflowLevelId)
.Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));
Merge(await db.ContractLevelOpinions.AsNoTracking()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
&& db.Contracts.Any(p => p.Id == o.ContractId && !p.IsDeleted))
.GroupBy(o => o.ApprovalWorkflowLevelId)
.Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));
Merge(await db.ProposalLevelOpinions.AsNoTracking()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
&& db.Proposals.Any(p => p.Id == o.ProposalId && !p.IsDeleted))
.GroupBy(o => o.ApprovalWorkflowLevelId)
.Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));
Merge(await db.LeaveRequestLevelOpinions.AsNoTracking()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
&& db.LeaveRequests.Any(p => p.Id == o.LeaveRequestId && !p.IsDeleted))
.GroupBy(o => o.ApprovalWorkflowLevelId)
.Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));
Merge(await db.OtRequestLevelOpinions.AsNoTracking()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
&& db.OtRequests.Any(p => p.Id == o.OtRequestId && !p.IsDeleted))
.GroupBy(o => o.ApprovalWorkflowLevelId)
.Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));
Merge(await db.TravelRequestLevelOpinions.AsNoTracking()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
&& db.TravelRequests.Any(p => p.Id == o.TravelRequestId && !p.IsDeleted))
.GroupBy(o => o.ApprovalWorkflowLevelId)
.Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));
Merge(await db.VehicleBookingLevelOpinions.AsNoTracking()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
&& db.VehicleBookings.Any(p => p.Id == o.VehicleBookingId && !p.IsDeleted))
.GroupBy(o => o.ApprovalWorkflowLevelId)
.Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));
return acc;
}
// Chữ ký CÒN LƯU trên các dòng Cấp nhưng KHÔNG thuộc diện "sống" của
// `CountLiveOpinionsAsync` — tức hồ sơ cha VẪN CÒN ROW, mà (cha xoá mềm HOẶC ý kiến
// tự nó xoá mềm). Đây chính là tập mà bản trước đem đi xoá CỨNG (F2 của reviewer).
//
// 🔴 `IgnoreQueryFilters()` LÀ BẮT BUỘC, không phải trang trí: PE và Contract có global
// filter `!IsDeleted` (`PurchaseEvaluationConfiguration.cs:84`). Không tắt filter thì hồ
// sơ xoá mềm biến mất khỏi phép JOIN ⇒ ta lại kết luận "mồ côi" ⇒ đúng cái sai đang chữa.
// (5 bảng Văn phòng số KHÔNG có filter — gọi vẫn vô hại, giữ 7 khối cùng một hình dạng.)
private async Task<Dictionary<Guid, List<RetainedOpinionRef>>> ListRetainedOpinionsAsync(
List<Guid> levelIds, CancellationToken ct)
{
var rows = new List<RetainedOpinionRef>();
rows.AddRange(await db.PurchaseEvaluationLevelOpinions.AsNoTracking().IgnoreQueryFilters()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
.Join(db.PurchaseEvaluations, o => o.PurchaseEvaluationId, p => p.Id, (o, p) => new { o, p })
.Where(x => x.o.IsDeleted || x.p.IsDeleted)
.Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Phiếu duyệt NCC", x.p.MaPhieu, x.p.IsDeleted))
.ToListAsync(ct));
rows.AddRange(await db.ContractLevelOpinions.AsNoTracking().IgnoreQueryFilters()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
.Join(db.Contracts, o => o.ContractId, p => p.Id, (o, p) => new { o, p })
.Where(x => x.o.IsDeleted || x.p.IsDeleted)
.Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Hợp đồng", x.p.MaHopDong, x.p.IsDeleted))
.ToListAsync(ct));
rows.AddRange(await db.ProposalLevelOpinions.AsNoTracking().IgnoreQueryFilters()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
.Join(db.Proposals, o => o.ProposalId, p => p.Id, (o, p) => new { o, p })
.Where(x => x.o.IsDeleted || x.p.IsDeleted)
.Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Đề xuất", x.p.MaDeXuat, x.p.IsDeleted))
.ToListAsync(ct));
rows.AddRange(await db.LeaveRequestLevelOpinions.AsNoTracking().IgnoreQueryFilters()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
.Join(db.LeaveRequests, o => o.LeaveRequestId, p => p.Id, (o, p) => new { o, p })
.Where(x => x.o.IsDeleted || x.p.IsDeleted)
.Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Đơn nghỉ phép", x.p.MaDonTu, x.p.IsDeleted))
.ToListAsync(ct));
rows.AddRange(await db.OtRequestLevelOpinions.AsNoTracking().IgnoreQueryFilters()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
.Join(db.OtRequests, o => o.OtRequestId, p => p.Id, (o, p) => new { o, p })
.Where(x => x.o.IsDeleted || x.p.IsDeleted)
.Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Đơn tăng ca", x.p.MaDonTu, x.p.IsDeleted))
.ToListAsync(ct));
rows.AddRange(await db.TravelRequestLevelOpinions.AsNoTracking().IgnoreQueryFilters()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
.Join(db.TravelRequests, o => o.TravelRequestId, p => p.Id, (o, p) => new { o, p })
.Where(x => x.o.IsDeleted || x.p.IsDeleted)
.Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Đơn công tác", x.p.MaDonTu, x.p.IsDeleted))
.ToListAsync(ct));
rows.AddRange(await db.VehicleBookingLevelOpinions.AsNoTracking().IgnoreQueryFilters()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
.Join(db.VehicleBookings, o => o.VehicleBookingId, p => p.Id, (o, p) => new { o, p })
.Where(x => x.o.IsDeleted || x.p.IsDeleted)
.Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Đơn xe", x.p.MaDonTu, x.p.IsDeleted))
.ToListAsync(ct));
return rows.GroupBy(r => r.LevelId).ToDictionary(g => g.Key, g => g.ToList());
}
private static string DescribeRetained(List<RetainedOpinionRef> rows)
{
var head = string.Join("; ", rows.Take(3).Select(r =>
$"{r.Module} {(string.IsNullOrWhiteSpace(r.Code) ? "(chưa )" : r.Code)}" +
(r.ParentDeleted ? " — đã xoá" : " — ý kiến đã thu hồi")));
return rows.Count > 3 ? $"{head}; … và {rows.Count - 3} hồ sơ khác" : head;
}
// Xoá CỨNG những chữ ký THẬT SỰ MỒ CÔI — hồ sơ cha KHÔNG CÒN ROW trong DB (bị xoá cứng
// ngoài luồng ứng dụng: sửa tay, nhập liệu, hoặc lúc FK từng bị tắt). ExecuteDelete đi
// thẳng SQL, KHÔNG qua change-tracker nên không bị AuditingInterceptor đổi thành xoá mềm.
//
// 🔴 F2 (reviewer đợt 1) — ĐÃ THU HẸP hai vế, đây là chỗ dễ mất dữ liệu nhất của lệnh:
// • BỎ vế `o.IsDeleted` — opinion tự nó xoá mềm trên hồ sơ CÒN SỐNG là bản ghi KIỂM
// TOÁN của tài liệu đang hiệu lực, không phải rác.
// • `!Any(p => p.Id == … && !p.IsDeleted)` → `!IgnoreQueryFilters().Any(p => p.Id == …)`
// — hồ sơ XOÁ MỀM vẫn CÒN ROW, vẫn khôi phục và xem được ⇒ chữ ký của nó KHÔNG mồ côi.
// Hai ca đó nay bị guard tầng 2 (`ListRetainedOpinionsAsync`) CHẶN admin bằng 409, kèm
// tên hồ sơ đang giữ chữ ký — thay vì xoá lén rồi chỉ để lại một con số đếm.
//
// ⇒ BẤT BIẾN sau bản vá: tới được đây nghĩa là mọi dòng còn trỏ vào `levelIds` đều có cha
// đã biến mất khỏi DB. Xoá chúng là đường DUY NHẤT để FK Restrict cho gỡ dòng Cấp, và
// không màn hình nào còn đọc được chúng nữa. Điều kiện dưới đây vẫn viết TƯỜNG MINH
// (không dựa vào việc guard đã chạy) để purge tự phòng vệ nếu hai bên có ngày lệch nhau.
private async Task<int> PurgeOrphanOpinionsAsync(List<Guid> levelIds, CancellationToken ct)
{
var n = 0;
n += await db.PurchaseEvaluationLevelOpinions
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
&& !db.PurchaseEvaluations.IgnoreQueryFilters().Any(p => p.Id == o.PurchaseEvaluationId))
.ExecuteDeleteAsync(ct);
n += await db.ContractLevelOpinions
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
&& !db.Contracts.IgnoreQueryFilters().Any(p => p.Id == o.ContractId))
.ExecuteDeleteAsync(ct);
n += await db.ProposalLevelOpinions
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
&& !db.Proposals.IgnoreQueryFilters().Any(p => p.Id == o.ProposalId))
.ExecuteDeleteAsync(ct);
n += await db.LeaveRequestLevelOpinions
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
&& !db.LeaveRequests.IgnoreQueryFilters().Any(p => p.Id == o.LeaveRequestId))
.ExecuteDeleteAsync(ct);
n += await db.OtRequestLevelOpinions
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
&& !db.OtRequests.IgnoreQueryFilters().Any(p => p.Id == o.OtRequestId))
.ExecuteDeleteAsync(ct);
n += await db.TravelRequestLevelOpinions
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
&& !db.TravelRequests.IgnoreQueryFilters().Any(p => p.Id == o.TravelRequestId))
.ExecuteDeleteAsync(ct);
n += await db.VehicleBookingLevelOpinions
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
&& !db.VehicleBookings.IgnoreQueryFilters().Any(p => p.Id == o.VehicleBookingId))
.ExecuteDeleteAsync(ct);
return n;
}
// Gỡ theo dõi (detach) mọi entity chữ ký đang nằm trong change-tracker và trỏ vào các
// dòng Cấp sắp bị XOÁ CỨNG.
//
// VÌ SAO CẦN: `PurgeOrphanOpinionsAsync` dùng `ExecuteDelete` — đi thẳng SQL, KHÔNG cập
// nhật change-tracker (giới hạn có ghi trong tài liệu EF). Nếu context đang giữ một bản
// sao của chính những chữ ký vừa bị xoá (hoặc bất kỳ chữ ký nào trỏ vào dòng Cấp sắp gỡ),
// thì `db.ApprovalWorkflowLevels.Remove(level)` sẽ kích cascade-check CLIENT-SIDE
// (CascadeDeleteTiming.Immediate = mặc định): EF thấy dependent còn tracked + FK
// `Restrict` + cột không nullable ⇒ ném InvalidOperationException "association ... has
// been severed" NGAY TẠI `Remove()`, chưa kịp chạm DB ⇒ 500 thay vì thao tác hợp lệ.
//
// AN TOÀN: tới thời điểm gọi, guard F-5 đã đọc DB (AsNoTracking) và xác nhận các dòng
// này có 0 chữ ký CÒN HIỆU LỰC ⇒ mọi bản sao còn tracked đều là rác (thuộc phiếu đã xoá,
// hoặc chính nó đã bị purge khỏi DB) ⇒ gỡ theo dõi là làm tracker khớp lại với DB.
// Quét theo TÊN CỘT nên phủ đủ cả 7 bảng chữ ký (PE + HĐ + 5 module Văn phòng số) mà
// không phải liệt kê tay từng DbSet.
private static void DetachTrackedOpinionsOn(IApplicationDbContext db, HashSet<Guid> levelIds)
{
if (levelIds.Count == 0) return;
const string fkName = nameof(PurchaseEvaluationLevelOpinion.ApprovalWorkflowLevelId);
var stale = ((DbContext)db).ChangeTracker.Entries()
.Where(e => e.State != EntityState.Detached
&& e.Metadata.FindProperty(fkName) is not null
&& e.Property(fkName).CurrentValue is Guid levelId
&& levelIds.Contains(levelId))
.ToList();
foreach (var entry in stale)
entry.State = EntityState.Detached;
}
private async Task<Dictionary<Guid, string>> ResolveUserNamesAsync(List<Guid> userIds, CancellationToken ct)
{
if (userIds.Count == 0) return new Dictionary<Guid, string>();
return await userManager.Users.AsNoTracking()
.Where(u => userIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName ?? u.Email ?? u.Id.ToString(), ct);
}
private static List<string> DescribeFlagChanges(ApprovalWorkflowLevel db, UpdateAwLevelInput req)
{
var list = new List<string>();
void Cmp(string label, bool before, bool after)
{
if (before != after) list.Add($"{label} {(after ? "BẬT" : "TẮT")}");
}
Cmp("Trả lại 1 Cấp", db.AllowReturnOneLevel, req.AllowReturnOneLevel);
Cmp("Trả lại 1 Bước", db.AllowReturnOneStep, req.AllowReturnOneStep);
Cmp("Trả lại NV đã ký", db.AllowReturnToAssignee, req.AllowReturnToAssignee);
Cmp("Trả lại người soạn", db.AllowReturnToDrafter, req.AllowReturnToDrafter);
Cmp("Sửa Hạng mục/NCC/Báo giá", db.AllowApproverEditDetails, req.AllowApproverEditDetails);
Cmp("Sửa ngân sách", db.AllowApproverEditBudget, req.AllowApproverEditBudget);
Cmp("Xoá phiếu khi đang duyệt", db.AllowApproverDelete, req.AllowApproverDelete);
return list;
}
private static string FormatMoney(decimal? v) => v is decimal d ? d.ToString("#,##0.##") : "(không đặt)";
}
// ========== PATCH user-selectable toggle (Mig 25 — admin pin/unpin cho user pick) ==========
// Independent với IsActive: cho phép multiple version cùng selectable. Default
// version mới IsUserSelectable=true (mirror IsActive default), admin có thể

View File

@ -78,8 +78,20 @@ public class ApprovalWorkflowStep : BaseEntity
public List<ApprovalWorkflowLevel> Levels { get; set; } = new();
}
// Cấp = 1 NV cụ thể. 1 bước có nhiều cấp theo Order. Approver = ApproverUserId
// chính xác (KHÔNG OR-of-many). Sequential trong cùng bước: cấp 1 → cấp 2 → ...
// Cấp = tập các row CÙNG Order trong 1 Bước. MỖI ROW = 1 NV cụ thể
// (ApproverUserId), nhưng 1 Cấp có thể có N row ⇒ N người duyệt **OR-of-N**:
// bất kỳ ai trong Cấp duyệt là Cấp đó xong. Sequential giữa các Cấp trong cùng
// bước: cấp 1 → cấp 2 → ...
//
// [F-10 S155 2026-07-27] Comment cũ ghi "Cấp = 1 NV cụ thể ... (KHÔNG OR-of-many)"
// — SAI, ngược với mã lẫn tài liệu. Bằng chứng: service group Levels theo Order
// rồi cho phép BẤT KỲ ai trong nhóm duyệt
// (`PurchaseEvaluationWorkflowService.cs:689` `GroupBy(l => l.Order)` + `:702`
// `allowedUserIds.Contains(actorUserId)`); validator chỉ cấm TRÙNG cặp
// {Order, ApproverUserId} chứ không cấm nhiều row cùng Order
// (`ApprovalWorkflowV2AdminFeatures.cs:309-314`); CLAUDE.md + skill
// contract-workflow đều ghi "N NV cụ thể (OR-of-N cùng cấp)". Ảnh UAT prod:
// Cấp 2 Phòng Cung ứng có 3 người.
public class ApprovalWorkflowLevel : BaseEntity
{
public Guid ApprovalWorkflowStepId { get; set; }
@ -141,5 +153,20 @@ public class ApprovalWorkflowLevel : BaseEntity
/// nhất, KHÔNG threshold/role check). Mirror F2/F3/F4 admin opt-in per-slot pattern.
public bool AllowApproverFinalize { get; set; }
/// F6 (Mig 68 2026-07-27 — S155, UAT Tra Sol/anh Kiệt) — Cho phép NV slot này,
/// khi đang tới lượt duyệt của mình, XÓA MỀM phiếu đang treo ở ChoDuyet. Sinh ra
/// vì phiếu bấm sai gói thầu đang **ăn lũy kế** ngân sách hạng mục sai mà không
/// có cửa nào dừng: "Trả lại" chỉ DỜI số sang dòng khác chứ không triệt tiêu
/// (`PeBudgetAccumulator.cs:104-126`), còn xóa thì allow-list chỉ nhận
/// {DangSoanThao, TuChoi} (`PurchaseEvaluationFeatures.cs:1404-1406`).
/// Default false (admin opt-in per slot). Đọc **PER-ROW** `matchingLevel.
/// AllowApproverDelete` (khuôn F5 `PurchaseEvaluationWorkflowService.cs:859`),
/// KHÔNG đọc per-Cấp `g.Any(...)` — 1 Cấp có N người (OR-of-N), đọc `Any` sẽ
/// phát quyền huỷ chứng-từ cho CẢ Cấp thay vì đúng 1 người admin chỉ đích danh
/// (owner chốt 2026-07-27). Mirror F2/F3/F4/F5 admin opt-in per-slot pattern.
/// ⚠️ Cờ này chỉ là 1 trong 3 rào: policy `PurchaseEvaluations.Delete` ∧ đúng
/// lượt ∧ F6 — xem hạng mục B spec S155 (đợt 2).
public bool AllowApproverDelete { get; set; }
public ApprovalWorkflowStep? Step { get; set; }
}

View File

@ -98,5 +98,11 @@ public class ApprovalWorkflowLevelConfiguration : IEntityTypeConfiguration<Appro
// duyệt = KẾT THÚC quy trình (Phase=DaDuyet) miễn trình CEO. Default false
// (admin opt-in). Khác F2 (skip = advance tới Cấp cuối): F5 = terminal ngay.
e.Property(x => x.AllowApproverFinalize).HasDefaultValue(false);
// Mig 68 (S155 2026-07-27 — UAT Tra Sol) — F6 per-NV: cho phép NV slot này
// xóa MỀM phiếu đang treo ở ChoDuyet khi tới lượt mình duyệt (phiếu bấm sai
// gói thầu đang ăn lũy kế). Default false (admin opt-in per slot). Đọc
// per-ROW chứ KHÔNG per-Cấp — 1 Cấp có N người (OR-of-N).
e.Property(x => x.AllowApproverDelete).HasDefaultValue(false);
}
}

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPeAllowApproverDelete : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowApproverDelete",
table: "ApprovalWorkflowLevels",
type: "bit",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowApproverDelete",
table: "ApprovalWorkflowLevels");
}
}
}

View File

@ -192,6 +192,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<bool>("AllowApproverDelete")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowApproverEditBudget")
.ValueGeneratedOnAdd()
.HasColumnType("bit")

View File

@ -702,7 +702,18 @@ public class PurchaseEvaluationWorkflowService(
var allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet();
if (!allowedUserIds.Contains(actorUserId.Value))
{
var names = string.Join(", ", allowedUserIds);
// [S155 F-6] Trước đây in THẲNG GUID (`string.Join(", ", allowedUserIds)`)
// dù biến đã tên là `names` — người dùng cuối đọc được một dãy GUID và
// không hiểu phải nhờ AI duyệt. Nay resolve sang tên thật; fallback về
// GUID nếu không tra được để thông điệp không bao giờ rỗng.
var allowedIdList = allowedUserIds.ToList();
var resolved = await userManager.Users.AsNoTracking()
.Where(u => allowedIdList.Contains(u.Id))
.Select(u => u.FullName ?? u.Email)
.ToListAsync(ct);
var names = resolved.Count > 0
? string.Join(", ", resolved)
: string.Join(", ", allowedIdList);
throw new ForbiddenException(
$"Bước {currentIdx + 1} ({currentStep.Name}) — Cấp {currentLevelOrder}: bạn không có trong danh sách NV duyệt ({names}).");
}

View File

@ -1,6 +1,7 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using SolutionErp.Api.Controllers;
using SolutionErp.Domain.Identity; // MenuKeys — chốt policy name có thật (F-1b S155)
namespace SolutionErp.Infrastructure.Tests.Api;
@ -88,6 +89,59 @@ public class AuthorizePolicyRegressionTests
"PATCH user-selectable chỉ admin (Mig 25 Designer pin/unpin).");
}
// ===================================================================
// F-1b (S155 2026-07-27) — PUT sửa quy trình TẠI CHỖ.
//
// 🔴 Nghiêm trọng hơn một endpoint ghi thường, vì CẢ HAI thao tác mà lệnh này
// cho phép đều là hành vi CẤP QUYỀN:
// (i) "thêm người vào Cấp" ⇒ tự thêm mình vào Cấp đang chờ ⇒ lọt guard
// match-approver ⇒ DUYỆT ĐƯỢC PHIẾU NGƯỜI KHÁC;
// (ii) "bật cờ Allow*" ⇒ gồm chính F6 AllowApproverDelete ⇒ TỰ CẤP
// QUYỀN XOÁ phiếu đang duyệt.
// Class-level chỉ [Authorize] trần (cố ý, gotcha #44) nên nó KHÔNG tự bảo vệ:
// thiếu đúng 1 dòng attribute = leo thang đặc quyền cho MỌI tài khoản đăng nhập.
// ===================================================================
[Fact]
public void ApprovalWorkflowsV2Controller_Update_PUT_RequiresWorkflowsCreatePolicy()
{
var attr = GetActionAuthorize(typeof(ApprovalWorkflowsV2Controller), nameof(ApprovalWorkflowsV2Controller.Update));
attr.Should().NotBeNull(
"PUT sửa-tại-chỗ phải có [Authorize(Policy = ...)] — class-level [Authorize] trần cho lọt mọi user đăng nhập");
attr!.Policy.Should().Be("Workflows.Create",
"khớp cả 3 verb ghi còn lại (POST/PATCH/DELETE) của chính controller này — sửa quy trình = quyền admin Designer.");
}
// Bẫy đi kèm (spec §B3): `Program.cs` CHỈ đăng ký policy `{menu}.{action}` cho
// menu key nằm trong `MenuKeys.All`. Gắn một policy KHÔNG được đăng ký thì
// ASP.NET ném lúc chạy — test attribute ở trên vẫn xanh mà endpoint vẫn hỏng.
// ⇒ chốt luôn rằng tên policy đang dùng là tên CÓ THẬT trong bộ sinh policy.
[Fact]
public void ApprovalWorkflowsV2Controller_WriteVerbs_PolicyName_IsActuallyRegisterable()
{
var writeActions = new[]
{
nameof(ApprovalWorkflowsV2Controller.Create),
nameof(ApprovalWorkflowsV2Controller.Update),
nameof(ApprovalWorkflowsV2Controller.SetUserSelectable),
nameof(ApprovalWorkflowsV2Controller.Delete),
};
foreach (var action in writeActions)
{
var policy = GetActionAuthorize(typeof(ApprovalWorkflowsV2Controller), action)?.Policy;
policy.Should().NotBeNullOrEmpty($"action {action} phải có policy");
var parts = policy!.Split('.');
parts.Should().HaveCount(2, $"policy {policy} phải đúng dạng {{menuKey}}.{{action}}");
MenuKeys.All.Should().Contain(parts[0],
$"policy \"{policy}\" của {action} trỏ menu key KHÔNG có trong MenuKeys.All ⇒ Program.cs không sinh policy đó ⇒ 500 lúc chạy (bẫy Pe_* trong spec S155 §B3)");
MenuKeys.Actions.Should().Contain(parts[1],
$"policy \"{policy}\" của {action} dùng action không có trong MenuKeys.Actions");
}
}
// ===================================================================
// Coverage gap #3 (MAJOR — S35/S36 backlog, closed S45 2026-06-01).
// gotcha #44 regression cho 2 controller HRM bị MISS (chỉ ApprovalWorkflowsV2 có trước đó).

View File

@ -0,0 +1,972 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.ApprovalWorkflowsV2;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Contracts; // ApprovalDecision + ChangelogAction (shared HĐ/PE)
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Office; // Proposal + ProposalLevelOpinion (F-16 blast radius)
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
using SolutionErp.Infrastructure.Tests.Services; // NoOpNotificationService
namespace SolutionErp.Infrastructure.Tests.Application;
// ============================================================================
// S155 hạng mục F (2026-07-27) — lệnh UPDATE quy trình duyệt TẠI CHỖ.
// Test-before-merge: đụng CẢ security (tự thêm mình vào Cấp = duyệt phiếu người
// khác) LẪN financial (phiếu đang chạy đổi cấu hình giữa chừng) ⇒ hạng critical
// theo docs/rules.md §7.
//
// Đối tượng đo: `UpdateAwDefinitionCommandHandler`
// (src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs:501-930)
//
// 🔴 MỆNH ĐỀ TRUNG TÂM (spec §F.0) mà F-12/F-15 tồn tại để chứng minh:
// POST tạo version mới sinh **Id MỚI**, phiếu đang treo pin **Id CŨ** và đọc
// workflow **SỐNG** (`PurchaseEvaluationWorkflowService.cs:671-675` — chỉ
// `w.Id == awId`, không snapshot) ⇒ cấu hình tick hôm nay KHÔNG BAO GIỜ tới
// được phiếu cần cứu. Chỉ sửa-TẠI-CHỖ (giữ nguyên Id) mới tới.
// ⇒ mọi assert "phiếu thấy thay đổi" ở đây đều đi qua `ResolveLikeRunningPeAsync`
// — bản sao Y HỆT đường resolve của service, không phải một query tiện tay.
//
// Ranh giới ĐỢT: file này KHÔNG test hành vi XOÁ phiếu khi đang duyệt (nút +
// endpoint xoá-khi-duyệt = ĐỢT 2, chưa tồn tại trên đĩa). F-15 chỉ đo tới mức
// "cờ F6 đọc được từ phía phiếu đang treo".
//
// Harness: IdentityFixture (BẮT BUỘC — `ApprovalWorkflowLevel.ApproverUserId` có
// FK Restrict sang User, `ApprovalWorkflowConfiguration.cs:70-74`, nên approver
// phải là user THẬT chứ không phải Guid ngẫu nhiên).
// ============================================================================
public class UpdateAwDefinitionTests
{
private sealed record Harness(
IdentityFixture Fix,
TestApplicationDbContext Db,
UserManager<User> Um,
FixedDateTime Clock);
private static Harness NewHarness()
{
var fix = new IdentityFixture();
return new Harness(
fix,
fix.Services.GetRequiredService<TestApplicationDbContext>(),
fix.Services.GetRequiredService<UserManager<User>>(),
new FixedDateTime(new DateTime(2026, 7, 27, 3, 0, 0, DateTimeKind.Utc)));
}
private static UpdateAwDefinitionCommandHandler NewHandler(Harness h, User actor)
=> new(h.Db, new TestCurrentUser(actor.Id, actor.FullName, actor.Email), h.Clock, h.Um);
// stepSpecs[s] = các dòng Cấp của Bước (Order s+1). Mỗi phần tử = (LevelOrder, ApproverUserId).
// N dòng cùng LevelOrder = 1 Cấp có N người (OR-of-N) — đúng hình dạng prod.
private static async Task<ApprovalWorkflow> SeedWorkflowAsync(
TestApplicationDbContext db,
ApprovalWorkflowApplicableType type,
(int LevelOrder, Guid User)[][] stepSpecs,
decimal? ceoThreshold = null,
string code = "QT-UPD-V2")
{
var wf = new ApprovalWorkflow
{
Code = code,
Version = 1,
ApplicableType = type,
Name = "QT test sửa tại chỗ",
IsActive = true,
IsUserSelectable = true,
CeoApprovalThreshold = ceoThreshold,
ActivatedAt = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc),
};
for (int s = 0; s < stepSpecs.Length; s++)
{
var step = new ApprovalWorkflowStep
{
ApprovalWorkflowId = wf.Id,
Order = s + 1,
Name = $"Bước {s + 1}",
};
foreach (var (levelOrder, user) in stepSpecs[s])
{
step.Levels.Add(new ApprovalWorkflowLevel
{
ApprovalWorkflowStepId = step.Id,
Order = levelOrder,
Name = $"Cấp {levelOrder}",
ApproverUserId = user,
});
}
wf.Steps.Add(step);
}
db.ApprovalWorkflows.Add(wf);
await db.SaveChangesAsync(CancellationToken.None);
return wf;
}
private static async Task<PurchaseEvaluation> SeedPeAsync(
TestApplicationDbContext db,
Guid workflowId,
Guid drafterId,
string code,
int stepIdx = 0,
int levelOrder = 1,
PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.ChoDuyet,
bool isDeleted = false)
{
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = phase,
MaPhieu = code,
TenGoiThau = "Gói thầu test sửa quy trình tại chỗ",
ProjectId = Guid.NewGuid(),
DrafterUserId = drafterId,
ApprovalWorkflowId = workflowId,
CurrentWorkflowStepIndex = phase == PurchaseEvaluationPhase.ChoDuyet ? stepIdx : null,
CurrentApprovalLevelOrder = phase == PurchaseEvaluationPhase.ChoDuyet ? levelOrder : null,
IsDeleted = isDeleted,
DeletedAt = isDeleted ? new DateTime(2026, 7, 26, 0, 0, 0, DateTimeKind.Utc) : null,
};
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
return pe;
}
// 🔴 BẢN SAO Y HỆT đường resolve của phiếu đang chạy
// (`PurchaseEvaluationWorkflowService.cs:671-675`): chỉ lọc theo Id đã pin,
// Include Steps/Levels đã sắp xếp, AsNoTracking. Dùng nó (chứ không phải một
// query tiện tay) để assert "phiếu THẤY thay đổi" mới là bằng chứng thật.
private static Task<ApprovalWorkflow> ResolveLikeRunningPeAsync(TestApplicationDbContext db, Guid pinnedId)
=> db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Levels.OrderBy(l => l.Order))
.FirstAsync(w => w.Id == pinnedId, CancellationToken.None);
// Lệnh Update "trung thực": echo NGUYÊN TRẠNG mọi thứ đang lưu. Mọi test chỉ
// đổi ĐÚNG MỘT biến trên nền này (kỹ thuật cặp cùng-ruột-khác-1-biến) — nếu
// test đỏ thì biết chắc do biến đó chứ không do payload lệch chỗ khác.
private static UpdateAwDefinitionCommand BuildEchoCommand(ApprovalWorkflow wf, string? reason = null)
=> new(
Id: wf.Id,
Name: wf.Name,
Description: wf.Description,
Steps: wf.Steps.OrderBy(s => s.Order).Select(s => new UpdateAwStepInput(
Id: s.Id,
Order: s.Order,
Name: s.Name,
DepartmentId: s.DepartmentId,
Levels: s.Levels.OrderBy(l => l.Order).Select(EchoLevel).ToList())).ToList(),
Reason: reason,
Code: null,
ApplicableType: null,
CeoApprovalThreshold: wf.CeoApprovalThreshold);
private static UpdateAwLevelInput EchoLevel(ApprovalWorkflowLevel l) => new(
Id: l.Id,
Order: l.Order,
Name: l.Name,
ApproverUserId: l.ApproverUserId,
AllowReturnOneLevel: l.AllowReturnOneLevel,
AllowReturnOneStep: l.AllowReturnOneStep,
AllowReturnToAssignee: l.AllowReturnToAssignee,
AllowReturnToDrafter: l.AllowReturnToDrafter,
AllowApproverEditDetails: l.AllowApproverEditDetails,
AllowApproverEditBudget: l.AllowApproverEditBudget,
AllowApproverDelete: l.AllowApproverDelete,
AllowApproverSkipToFinal: null, // ECHO-only, null = không khai báo
AllowApproverFinalize: null);
private static UpdateAwLevelInput NewLevel(int order, Guid approverUserId, bool allowDelete = false)
=> new(Id: null, Order: order, Name: $"Cấp {order}", ApproverUserId: approverUserId,
AllowApproverDelete: allowDelete);
// Thay danh sách Cấp của Bước thứ `stepIdx` trong lệnh (giữ nguyên phần còn lại).
private static UpdateAwDefinitionCommand WithLevels(
UpdateAwDefinitionCommand cmd, int stepIdx, List<UpdateAwLevelInput> levels)
{
var steps = cmd.Steps.ToList();
steps[stepIdx] = steps[stepIdx] with { Levels = levels };
return cmd with { Steps = steps };
}
private static PurchaseEvaluationWorkflowService NewPeService(Harness h)
=> new(h.Db, h.Clock, new NoOpNotificationService(), h.Um);
private static Task ApproveAsync(PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorId)
=> svc.TransitionAsync(
evaluation: pe,
targetPhase: PurchaseEvaluationPhase.ChoDuyet, // approve-in-place
actorUserId: actorId,
actorRoles: Array.Empty<string>(), // KHÔNG Admin — phải qua guard thật
decision: ApprovalDecision.Approve,
comment: null,
ct: CancellationToken.None);
// =====================================================================
// F-12 ⭐ BÀI KIỂM CHỨNG TRUNG TÂM (spec §F.0) — sửa hạng AN TOÀN trên
// workflow ĐANG CÓ PHIẾU CHẠY ⇒ phiếu nhận thay đổi NGAY.
//
// Đo bằng HÀNH VI THẬT chứ không bằng một cột trong bảng: cùng một phiếu,
// cùng một lời gọi duyệt của cùng một người — chỉ khác nhau ở chỗ lệnh Update
// đã chạy hay chưa. Trước: Forbidden. Sau: duyệt được + con trỏ tiến.
// (Nếu chỉ assert "DB có row mới" thì test vẫn xanh kể cả khi phiếu đọc
// snapshot cũ — tức mù đúng chỗ spec sợ nhất.)
// Kèm luôn F-4 (Id/Version/IsActive bất biến) và F-6 (403 in TÊN, không GUID).
// =====================================================================
[Fact]
public async Task F12_SafeUpdate_AddsPersonToExistingLevel_RunningPeAcceptsNewApproverImmediately()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "Trần Thị An", null, []);
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Lê Văn Bình", null, []);
var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Phạm Thị Cúc", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
// Bước 1: Cấp 1 = alice · Cấp 2 = carol. Phiếu treo tại (Bước 1, Cấp 1).
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id), (2, carol.Id)]]);
var pe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F12-001");
var svc = NewPeService(h);
// --- TRƯỚC: bob KHÔNG duyệt được (nửa contrast — chống vacuous pass) ---
var beforeAct = async () => await ApproveAsync(svc, pe, bob.Id);
var forbidden = (await beforeAct.Should().ThrowAsync<ForbiddenException>(
"bob chưa nằm trong Cấp 1 nên phải bị chặn TRƯỚC khi sửa quy trình")).Which;
// F-6 (lead tự vá, chưa ai soi): message phải in TÊN người duyệt, không phải GUID trần.
forbidden.Message.Should().Contain("Trần Thị An",
"F-6: thông điệp 403 phải resolve GUID sang tên thật để người dùng biết nhờ ai duyệt");
forbidden.Message.Should().NotContain(alice.Id.ToString(),
"F-6: không được rơi lại về in GUID trần khi tra được tên");
// --- ACT: thêm bob vào CẤP CÓ SẴN (row trùng Order 1) ---
var step = wf.Steps.Single();
var lvlAlice = step.Levels.Single(l => l.ApproverUserId == alice.Id);
var lvlCarol = step.Levels.Single(l => l.ApproverUserId == carol.Id);
var cmd = WithLevels(BuildEchoCommand(wf), 0,
[EchoLevel(lvlAlice), NewLevel(1, bob.Id), EchoLevel(lvlCarol)]);
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
// --- F-4: giữ Id / Version / IsActive — giữ Id là TOÀN BỘ mục đích ---
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Id.Should().Be(wf.Id);
live.Version.Should().Be(1, "sửa tại chỗ KHÔNG được tăng Version");
live.IsActive.Should().BeTrue("sửa tại chỗ KHÔNG được đụng IsActive");
live.Steps.Should().HaveCount(1);
live.Steps[0].Levels.Where(l => l.Order == 1).Select(l => l.ApproverUserId)
.Should().BeEquivalentTo(new[] { alice.Id, bob.Id });
var peRow = await h.Db.PurchaseEvaluations.AsNoTracking().FirstAsync(p => p.Id == pe.Id);
peRow.ApprovalWorkflowId.Should().Be(wf.Id, "phiếu vẫn pin ĐÚNG Id cũ — không có đường re-pin");
// --- SAU: cùng phiếu, cùng người, cùng lời gọi ⇒ nay duyệt ĐƯỢC ---
await ApproveAsync(svc, pe, bob.Id);
var peAfter = await h.Db.PurchaseEvaluations.AsNoTracking().FirstAsync(p => p.Id == pe.Id);
peAfter.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
peAfter.CurrentApprovalLevelOrder.Should().Be(2,
"bob duyệt xong Cấp 1 ⇒ con trỏ tiến sang Cấp 2 — phiếu chạy trên cấu hình MỚI");
// Chữ ký gắn vào ĐÚNG dòng Cấp mới của bob (không dạt sang row alice).
var newLevelId = live.Steps[0].Levels.Single(l => l.ApproverUserId == bob.Id).Id;
var opinion = await h.Db.PurchaseEvaluationLevelOpinions.AsNoTracking()
.SingleAsync(o => o.PurchaseEvaluationId == pe.Id);
opinion.ApprovalWorkflowLevelId.Should().Be(newLevelId);
opinion.SignedByUserId.Should().Be(bob.Id);
}
}
// =====================================================================
// F-13 — đụng tập Order ⇒ 409 VÀ workflow trong DB KHÔNG đổi.
// Cả 2 vế: chỉ assert "có ném" là mù trước ca handler ném SAU khi đã ghi.
// =====================================================================
[Fact]
public async Task F13_AddNewLevelOrder_Rejected409_AndWorkflowUnchanged()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Bình", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id)]]);
var lvlAlice = wf.Steps.Single().Levels.Single();
// Thêm Cấp 2 (Order chưa từng có) = chèn chặng cho phiếu đang chạy.
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice), NewLevel(2, bob.Id)]);
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("*không được thêm Cấp mới*");
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single().Levels.Should().HaveCount(1, "409 rồi thì DB phải y nguyên");
live.Steps.Single().Levels.Single().ApproverUserId.Should().Be(alice.Id);
}
}
[Fact]
public async Task F13_RemoveWholeLevel_Rejected409_AndWorkflowUnchanged()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id), (2, carol.Id)]]);
var lvlAlice = wf.Steps.Single().Levels.Single(l => l.Order == 1);
// Bỏ TOÀN BỘ Cấp 2 ⇒ phiếu đang chờ ở Cấp 2 sẽ KẸT.
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("*KHÔNG CÒN người duyệt*");
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single().Levels.Select(l => l.Order).Should().BeEquivalentTo(new[] { 1, 2 });
}
}
[Fact]
public async Task F13_MovePersonBetweenLevelOrders_Rejected409_AndWorkflowUnchanged()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id), (2, carol.Id)]]);
var step = wf.Steps.Single();
var lvlAlice = step.Levels.Single(l => l.Order == 1);
var lvlCarol = step.Levels.Single(l => l.Order == 2);
// Hoán vị Cấp giữa 2 dòng: TẬP {1,2} GIỮ NGUYÊN nên set-equality một
// mình sẽ cho qua — chốt chặn thật là "Order của TỪNG dòng bất biến".
var cmd = WithLevels(BuildEchoCommand(wf), 0,
[EchoLevel(lvlAlice) with { Order = 2 }, EchoLevel(lvlCarol) with { Order = 1 }]);
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("*không được chuyển người duyệt từ Cấp*");
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id).Order.Should().Be(1);
live.Steps.Single().Levels.Single(l => l.ApproverUserId == carol.Id).Order.Should().Be(2);
}
}
[Fact]
public async Task F13_SwapStepOrders_KeepsOrderSet_ButStillRejected409()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id)], [(1, carol.Id)]]);
// Đảo Order 2 Bước: tập {Step.Order} = {1,2} KHÔNG đổi ⇒ phép thử
// set-equality "khung không đổi" theo nghĩa yếu sẽ cho lọt, nhưng con
// trỏ phiếu là INDEX vào list đã sort nên nó trỏ sang Bước KHÁC, ÂM THẦM.
var echo = BuildEchoCommand(wf);
var swapped = new List<UpdateAwStepInput>
{
echo.Steps[0] with { Order = 2 },
echo.Steps[1] with { Order = 1 },
};
var act = async () => await NewHandler(h, admin).Handle(echo with { Steps = swapped }, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("*không được đổi thứ tự Bước*");
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single(s => s.Levels.Any(l => l.ApproverUserId == alice.Id)).Order.Should().Be(1);
live.Steps.Single(s => s.Levels.Any(l => l.ApproverUserId == carol.Id)).Order.Should().Be(2);
}
}
// =====================================================================
// F-13b (T2) — 2 trường owner xếp PHÁ VỠ: CeoApprovalThreshold + F5.
// =====================================================================
[Fact]
public async Task F13b_ChangeCeoApprovalThreshold_Rejected409_AndValueUnchanged()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id)]], ceoThreshold: 500_000_000m);
var cmd = BuildEchoCommand(wf) with { CeoApprovalThreshold = 900_000_000m };
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("*Ngưỡng giá trị gói CEO*");
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.CeoApprovalThreshold.Should().Be(500_000_000m);
}
}
// Vế IM LẶNG của cùng luật: client KHÔNG khai báo ngưỡng (null) trong khi DB
// đang có ngưỡng. Nếu handler đọc null là "bỏ qua" thì admin xoá trắng ô ngưỡng
// sẽ nhận 204 mà số không đổi — sai im lặng, đúng lớp lỗi spec đang đi chữa.
[Fact]
public async Task F13b_OmitCeoApprovalThreshold_WhenStored_Rejected409_NoSilentNoop()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id)]], ceoThreshold: 500_000_000m);
var cmd = BuildEchoCommand(wf) with { CeoApprovalThreshold = null, Name = "Tên mới" };
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("*Ngưỡng giá trị gói CEO*");
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.CeoApprovalThreshold.Should().Be(500_000_000m);
live.Name.Should().Be("QT test sửa tại chỗ", "409 thì KHÔNG phần nào của lệnh được ghi");
}
}
[Fact]
public async Task F13b_ToggleAllowApproverFinalize_Rejected409_AndFlagUnchanged()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id)]]);
var lvl = wf.Steps.Single().Levels.Single();
// F5 = ĐỔI ĐIỂM KẾT THÚC của phiếu (Phase=DaDuyet + đóng băng số tiền
// ngay lúc duyệt) ⇒ không phải "quyền tại chỗ".
var cmd = WithLevels(BuildEchoCommand(wf), 0,
[EchoLevel(lvl) with { AllowApproverFinalize = true }]);
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("*Duyệt là kết thúc*");
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single().Levels.Single().AllowApproverFinalize.Should().BeFalse();
}
}
// Nửa còn lại của "ECHO-only, KHÔNG BAO GIỜ ghi": khi client bỏ trống 2 cờ
// đổi-đường-đi thì giá trị đang lưu phải GIỮ NGUYÊN. Đây mới là chốt chặn hồi
// quy thật — nếu ai đó sau này viết `db.X = req.X ?? false` thì F5 của người
// đang giữ quyền kết-thúc bị TẮT âm thầm qua một lần đổi tên vô hại.
[Fact]
public async Task F13b_OmittedFinalizeEcho_PreservesStoredFlags_NotSilentlyCleared()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id)]]);
var lvl = wf.Steps.Single().Levels.Single();
lvl.AllowApproverFinalize = true;
lvl.AllowApproverSkipToFinal = true;
await h.Db.SaveChangesAsync(CancellationToken.None);
// Lệnh hợp lệ, chỉ đổi tên — 2 cờ để null (không khai báo).
var cmd = BuildEchoCommand(wf) with { Name = "Tên quy trình đã đổi" };
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Name.Should().Be("Tên quy trình đã đổi");
var liveLevel = live.Steps.Single().Levels.Single();
liveLevel.AllowApproverFinalize.Should().BeTrue("cờ đổi-đường-đi là echo-only, không được ghi đè bằng null");
liveLevel.AllowApproverSkipToFinal.Should().BeTrue();
}
}
// =====================================================================
// F-14 — bớt người khỏi Cấp mà dòng đó ĐÃ CÓ chữ ký ⇒ chặn LỊCH SỰ (409),
// KHÔNG để nổ FK 547 → 500.
// Kèm CHỨNG-NHÂN: cuối test tự tay xoá dòng đó ⇒ DB nổ thật. Không có đoạn
// này thì assert "409" chỉ chứng minh handler biết ném, chứ không chứng minh
// nó đang đỡ một quả nổ có thật.
// =====================================================================
[Fact]
public async Task F14_RemoveApproverWithLiveOpinion_BlockedPolitely_NotFkViolation()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Lê Văn Bình", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
// Cấp 1 có 2 người ⇒ bỏ bob KHÔNG làm Cấp rỗng (tách bạch với F-13).
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id), (1, bob.Id)]]);
var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);
var pe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F14-001");
h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
{
PurchaseEvaluationId = pe.Id,
ApprovalWorkflowLevelId = lvlBob.Id,
Comment = "Đồng ý",
SignedAt = h.Clock.UtcNow,
SignedByUserId = bob.Id,
SignedByFullName = "Lê Văn Bình",
});
await h.Db.SaveChangesAsync(CancellationToken.None);
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
var ex = (await act.Should().ThrowAsync<ConflictException>(
"chữ ký trên phiếu CÒN SỐNG phải chặn việc bỏ người, và chặn LỊCH SỰ")).Which;
ex.Message.Should().Contain("chữ ký");
// DB nguyên vẹn: cả dòng Cấp lẫn chữ ký đều còn.
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single().Levels.Should().HaveCount(2);
(await h.Db.PurchaseEvaluationLevelOpinions.AsNoTracking()
.CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id)).Should().Be(1);
// CHỨNG-NHÂN: nếu không có guard thì đây là thứ người dùng nhận (FK Restrict).
// 🔴 Phải dọn change-tracker TRƯỚC: chữ ký ở trên do CHÍNH test `Add` vào cùng
// context nên nó vẫn đang được theo dõi. Khi dependent còn tracked, `Remove(level)`
// kích cascade-check CLIENT-SIDE của EF (CascadeDeleteTiming.Immediate = mặc định)
// và ném InvalidOperationException("...has been severed...") NGAY TẠI `Remove` —
// chưa từng chạm DB ⇒ dòng assert bên dưới KHÔNG BAO GIỜ được chấm. Dọn tracker
// để chứng-nhân đo ĐÚNG cái tầng nó khai: ràng buộc FK ở DB.
// (Prod không dính: context mỗi request không nạp opinion nào, guard đọc chữ ký
// bằng AsNoTracking ⇒ tới `Remove` là tracker vốn đã sạch.)
h.Db.ChangeTracker.Clear();
h.Db.ApprovalWorkflowLevels.Remove(lvlBob);
var raw = async () => await h.Db.SaveChangesAsync(CancellationToken.None);
await raw.Should().ThrowAsync<DbUpdateException>(
"FK Restrict LevelOpinion→Level là thật — guard 409 đang đỡ đúng quả nổ này");
}
}
// 🔴 F-2 (reviewer đợt 1) — TIÊU CHÍ NGHIỆM THU #1. Cùng RUỘT với test trên,
// khác ĐÚNG MỘT BIT: phiếu mang chữ ký đó đã bị xoá MỀM.
//
// ĐẢO SO VỚI BẢN TRƯỚC (bản trước tên `..._RemovalAllowed_AndOrphanPurged`, khẳng
// định "cho gỡ + xoá cứng chữ ký"). Vì sao đảo — không phải nới/siết cho hợp code:
// xoá mềm là KHÔI PHỤC ĐƯỢC và §A spec S155 có màn "Đã xoá" phải đọc được lịch
// sử ký. Xoá cứng chữ ký của phiếu xoá mềm = phiếu khôi phục lên mất trắng lịch
// sử duyệt, IM LẶNG, không dấu vết (changelog cũ chỉ ghi một con SỐ ĐẾM, không
// ghi phiếu nào). Đó là đúng class lỗi mà cả spec S155 đang đi chữa.
// "Bị chặn OAN" (lý lẽ cũ) không phải là mất mát: admin còn 2 đường thoát rõ ràng
// (khôi phục phiếu rồi xử lý, hoặc xoá HẲN phiếu) — cả hai đều hoàn tác được.
[Fact]
public async Task F2_OpinionBelongsToSoftDeletedPe_BlockedPolitely_AndSignatureKept()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Bình", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id), (1, bob.Id)]]);
var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);
var deletedPe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F14b-001", isDeleted: true);
h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
{
PurchaseEvaluationId = deletedPe.Id,
ApprovalWorkflowLevelId = lvlBob.Id,
Comment = "Đồng ý",
SignedAt = h.Clock.UtcNow,
SignedByUserId = bob.Id,
SignedByFullName = "Bình",
});
await h.Db.SaveChangesAsync(CancellationToken.None);
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
var ex = (await act.Should().ThrowAsync<ConflictException>(
"chữ ký của phiếu XOÁ MỀM vẫn là lịch sử duyệt khôi phục được")).Which;
// Thông điệp phải NÊU TÊN phiếu đang giữ chữ ký — không thì admin không biết gỡ gì.
ex.Message.Should().Contain("PE-F14b-001").And.Contain("đã xoá");
// 🔴 Điều F-2 đòi: chữ ký VẪN CÒN. Đọc bằng IgnoreQueryFilters để không bị
// chính global filter che mất row (đọc thiếu sẽ trông y hệt "đã bị xoá").
(await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id)).Should().Be(1);
// Và quy trình cũng không bị sứt: cả 2 dòng Cấp còn nguyên.
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single().Levels.Should().HaveCount(2);
}
}
// 🔴 F-2 — TIÊU CHÍ NGHIỆM THU #2, vế thứ HAI của điều kiện purge cũ (`o.IsDeleted`):
// ý kiến TỰ NÓ xoá mềm, trên phiếu CÒN SỐNG. Đó là bản ghi KIỂM TOÁN của tài liệu
// đang hiệu lực — bản trước đem xoá CỨNG luôn. Cặp cùng-ruột-khác-1-bit với test
// trên: ở đó cha xoá mềm / ý kiến sống, ở đây cha sống / ý kiến xoá mềm.
[Fact]
public async Task F2_SoftDeletedOpinionOnLivePe_NotHardPurged_BlockedAndKept()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Bình", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id), (1, bob.Id)]]);
var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);
// Phiếu CÒN SỐNG (không isDeleted) — chỉ ý kiến bị thu hồi (xoá mềm).
var livePe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F2b-001");
h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
{
PurchaseEvaluationId = livePe.Id,
ApprovalWorkflowLevelId = lvlBob.Id,
Comment = "Đồng ý (sau đó thu hồi)",
SignedAt = h.Clock.UtcNow,
SignedByUserId = bob.Id,
SignedByFullName = "Bình",
IsDeleted = true,
DeletedAt = new DateTime(2026, 7, 26, 0, 0, 0, DateTimeKind.Utc),
});
await h.Db.SaveChangesAsync(CancellationToken.None);
// Tầng 1 (`CountLiveOpinionsAsync`) CỐ TÌNH bỏ qua row này (`!o.IsDeleted`)
// ⇒ nếu tầng 2 không tồn tại thì đây rơi thẳng vào purge và mất vĩnh viễn.
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
var ex = (await act.Should().ThrowAsync<ConflictException>(
"ý kiến đã thu hồi trên phiếu đang hiệu lực là bản ghi kiểm toán")).Which;
ex.Message.Should().Contain("PE-F2b-001").And.Contain("thu hồi");
(await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id)).Should().Be(1);
}
}
// 🔴 F-1 (reviewer đợt 1) — TIÊU CHÍ NGHIỆM THU: purge (`ExecuteDelete`) auto-commit
// NGAY khi không có transaction ambient, mà `SaveChangesAsync` thì mãi cuối method.
// Ném ở khoảng GIỮA ⇒ mọi thứ sau rollback, chữ ký đã xoá cứng thì KHÔNG quay lại.
//
// Dựng lại đúng hình đó, KHÔNG mock:
// (1) chữ ký MỒ CÔI THẬT — phiếu cha đã không còn row. Không dựng được bằng đường
// ứng dụng (FK cha→opinion là Cascade: xoá cứng cha thì chữ ký đi theo), nên
// tắt `PRAGMA foreign_keys` đúng một câu DELETE để mô phỏng dữ liệu lệch sinh
// ngoài luồng — chính thứ mà purge tồn tại để dọn.
// (2) BẪY ném SAU purge: một changelog trỏ vào phiếu không tồn tại, gài sẵn trong
// change-tracker ⇒ `SaveChangesAsync` cuối method nổ FK. Hiện thực đúng luồng
// thật: context là scoped-theo-request, handler khác trong cùng request có thể
// để lại thay đổi đang chờ.
// Nếu rào giao dịch KHÔNG có: purge đã commit ⇒ assert dưới thấy 0 ⇒ đỏ.
[Fact]
public async Task F1_SaveChangesThrowsAfterPurge_RollsBackWholeCommand_OrphanSignaturesKept()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Bình", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id), (1, bob.Id)]]);
var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);
// Phiếu SỐNG pin quy trình này ⇒ nhánh ghi changelog (F-1c) có việc để làm,
// nên `SaveChanges` cuối method thật sự được gọi.
await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1-LIVE");
var ghostPe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1-GHOST");
h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
{
PurchaseEvaluationId = ghostPe.Id,
ApprovalWorkflowLevelId = lvlBob.Id,
Comment = "Đồng ý",
SignedAt = h.Clock.UtcNow,
SignedByUserId = bob.Id,
SignedByFullName = "Bình",
});
await h.Db.SaveChangesAsync(CancellationToken.None);
// (1) Xoá HẲN phiếu cha nhưng chặn cascade ⇒ còn lại chữ ký MỒ CÔI THẬT.
await h.Db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF;");
await h.Db.Database.ExecuteSqlRawAsync(
"DELETE FROM PurchaseEvaluations WHERE Id = {0}", ghostPe.Id);
await h.Db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON;");
h.Db.ChangeTracker.Clear(); // tracker vẫn giữ bản sao — dọn cho khớp DB
(await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id))
.Should().Be(1, "tiền đề: phải có đúng 1 chữ ký mồ côi cho purge nhắm tới");
// (2) BẪY — sẽ nổ FK tại `SaveChangesAsync`, tức SAU khi purge đã chạy.
h.Db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = Guid.NewGuid(), // phiếu KHÔNG tồn tại
EntityType = PurchaseEvaluationEntityType.Workflow,
Action = ChangelogAction.Update,
UserId = admin.Id,
UserName = "Quản trị",
Summary = "Bẫy: ép SaveChanges ném SAU khi purge đã chạy",
});
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<DbUpdateException>(
"bẫy phải nổ ở SaveChanges — nếu không thì test này không đo được cái nó khai");
// 🔴 Điều F-1 đòi: số row chữ ký KHÔNG ĐỔI sau lệnh thất bại.
h.Db.ChangeTracker.Clear();
(await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id))
.Should().Be(1, "purge phải cùng rollback với SaveChanges, không auto-commit riêng");
// Và phần còn lại của lệnh cũng rollback trọn: dòng Cấp chưa bị gỡ.
(await h.Db.ApprovalWorkflowLevels.AsNoTracking()
.CountAsync(l => l.Id == lvlBob.Id)).Should().Be(1);
(await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().CountAsync()).Should().Be(0);
}
}
// =====================================================================
// F-15 (NỬA TRƯỚC) — bật cờ F6 `AllowApproverDelete` bằng Update trên
// workflow đang có phiếu ChoDuyet treo ⇒ cờ ĐỌC ĐƯỢC từ phía phiếu đang chạy.
//
// 🔴 GIỚI HẠN CỐ Ý: KHÔNG test hành vi XOÁ phiếu. Nút xoá ở màn duyệt +
// endpoint xoá-khi-đang-duyệt thuộc ĐỢT 2 và CHƯA TỒN TẠI trên đĩa lúc viết
// test này. Ở đây chỉ chứng minh mắt xích §F.0: cờ tick hôm nay TỚI ĐƯỢC
// phiếu treo từ hôm qua (điều mà POST-tạo-version-mới không làm được).
// =====================================================================
[Fact]
public async Task F15_EnableAllowApproverDeleteViaUpdate_FlagReachesAlreadyPendingPe_DeleteBehaviorIsWave2()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id), (2, carol.Id)]]);
// Phiếu treo TỪ TRƯỚC khi admin tick cờ.
var pe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F15-001");
var before = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
before.Steps[0].Levels.Single(l => l.ApproverUserId == alice.Id)
.AllowApproverDelete.Should().BeFalse("nền: cờ F6 mặc định tắt");
var step = wf.Steps.Single();
var lvlAlice = step.Levels.Single(l => l.ApproverUserId == alice.Id);
var lvlCarol = step.Levels.Single(l => l.ApproverUserId == carol.Id);
var cmd = WithLevels(BuildEchoCommand(wf, reason: "Bấm sai gói thầu — cần cho phép xoá"), 0,
[EchoLevel(lvlAlice) with { AllowApproverDelete = true }, EchoLevel(lvlCarol)]);
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
// Đọc ĐÚNG như phiếu đang chạy đọc: theo Id đã pin + con trỏ (Bước, Cấp).
var peRow = await h.Db.PurchaseEvaluations.AsNoTracking().FirstAsync(p => p.Id == pe.Id);
var live = await ResolveLikeRunningPeAsync(h.Db, peRow.ApprovalWorkflowId!.Value);
var currentStep = live.Steps.OrderBy(s => s.Order).ToList()[peRow.CurrentWorkflowStepIndex!.Value];
var matchingRow = currentStep.Levels
.Single(l => l.Order == peRow.CurrentApprovalLevelOrder && l.ApproverUserId == alice.Id);
matchingRow.AllowApproverDelete.Should().BeTrue(
"§F.0: chỉ sửa-tại-chỗ mới đưa được cờ tới phiếu đang treo — POST version mới sinh Id khác");
live.Version.Should().Be(1, "phiếu vẫn đọc ĐÚNG bản đang pin, không phải bản mới");
// Per-ROW chứ không per-Cấp: người khác trong quy trình KHÔNG tự có cờ.
live.Steps[0].Levels.Single(l => l.ApproverUserId == carol.Id)
.AllowApproverDelete.Should().BeFalse();
}
}
// =====================================================================
// F-16 — blast radius: `ApprovalWorkflow` V2 dùng chung ≥6 module. Nếu guard
// chữ ký chỉ soi bảng PE thì quy trình của Văn phòng số vẫn nổ FK 547 → 500.
// =====================================================================
[Fact]
public async Task F16_ProposalWorkflow_RemoveApproverWithLiveProposalOpinion_Blocked409()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Bình", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.ProposalGeneral,
[[(1, alice.Id), (1, bob.Id)]], code: "QT-DX-V2");
var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);
var proposal = new Proposal
{
Id = Guid.NewGuid(),
Title = "Đề xuất mua máy tính",
Status = ProposalStatus.DaGuiDuyet,
DrafterUserId = Guid.NewGuid(),
ApprovalWorkflowId = wf.Id,
CurrentApprovalLevelOrder = 1,
};
h.Db.Proposals.Add(proposal);
h.Db.ProposalLevelOpinions.Add(new ProposalLevelOpinion
{
ProposalId = proposal.Id,
ApprovalWorkflowLevelId = lvlBob.Id,
Comment = "Đồng ý",
SignedAt = h.Clock.UtcNow,
SignedByUserId = bob.Id,
SignedByFullName = "Bình",
});
await h.Db.SaveChangesAsync(CancellationToken.None);
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>(
"chữ ký của module Đề xuất cũng Restrict FK y như PE — guard phải quét CẢ 7 bảng opinion");
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single().Levels.Should().HaveCount(2);
}
}
[Fact]
public async Task F16_ProposalWorkflow_SafeUpdate_Succeeds_AndWritesNoPeChangelog()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.ProposalGeneral,
[[(1, alice.Id)]], code: "QT-DX-V2");
var proposal = new Proposal
{
Id = Guid.NewGuid(),
Title = "Đề xuất tăng ngân sách",
Status = ProposalStatus.DaGuiDuyet,
DrafterUserId = Guid.NewGuid(),
ApprovalWorkflowId = wf.Id,
CurrentApprovalLevelOrder = 1,
};
h.Db.Proposals.Add(proposal);
await h.Db.SaveChangesAsync(CancellationToken.None);
// 🔴 CHỨNG-NHÂN cho cổng module (vá F11 — reviewer bắt ca này XANH GIẢ).
// Nếu KHÔNG ghim phiếu PE nào vào workflow này thì `affected` rỗng ở CẢ HAI
// thế giới — còn cổng `def.ApplicableType is DuyetNcc or DuyetNccPhuongAn`
// hay gỡ cổng đi — nên assert `Count == 0` bên dưới luôn đúng và KHÔNG đo gì.
// Ghim 1 phiếu PE (phase ChoDuyet = "chưa xong" ⇒ lọt truy vấn `affected`)
// vào đúng workflow module Đề xuất này thì phép đo mới phân biệt được:
// gỡ cổng ⇒ handler ghi changelog PE ⇒ ca này ĐỎ.
var peGateWitness = await SeedPeAsync(h.Db, wf.Id, admin.Id, "PE-F16-GATE");
var lvlAlice = wf.Steps.Single().Levels.Single();
var cmd = WithLevels(BuildEchoCommand(wf, reason: "Bổ sung người duyệt"), 0,
[EchoLevel(lvlAlice), NewLevel(1, carol.Id)]);
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single().Levels.Select(l => l.ApproverUserId)
.Should().BeEquivalentTo(new[] { alice.Id, carol.Id });
live.ApplicableType.Should().Be(ApprovalWorkflowApplicableType.ProposalGeneral);
// Phép đo nay CÓ RĂNG: có đúng 1 phiếu PE ChoDuyet ghim workflow này, nên
// nếu cổng module bị gỡ thì handler sẽ ghi vết cho nó ⇒ count = 1 ⇒ ĐỎ.
(await h.Db.PurchaseEvaluations.AsNoTracking()
.CountAsync(p => p.ApprovalWorkflowId == wf.Id && p.Phase == PurchaseEvaluationPhase.ChoDuyet))
.Should().Be(1, "chứng-nhân phải tồn tại, nếu không assert dưới là rỗng nghĩa");
(await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().CountAsync())
.Should().Be(0, "quy trình module Đề xuất KHÔNG được đẻ vết vào changelog của PE");
peGateWitness.Should().NotBeNull();
var proposalRow = await h.Db.Proposals.AsNoTracking().FirstAsync(p => p.Id == proposal.Id);
proposalRow.ApprovalWorkflowId.Should().Be(wf.Id);
}
}
// =====================================================================
// F-1c — GHI VẾT. Spec đòi changelog khi xoá phiếu (§B4) nhưng ban đầu quên
// đòi vết cho việc SỬA QUYỀN — bất đối xứng, review bắt. Vết phải rơi đúng
// phiếu ĐANG CHỊU ẢNH HƯỞNG (chưa kết thúc), không rải cho phiếu đã đóng.
// =====================================================================
[Fact]
public async Task F1c_SafeUpdate_LogsChangelogWithReason_OnlyToUnfinishedPes()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Lê Văn Bình", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị hệ thống", null, [AppRoles.Admin]);
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id)]]);
var pending = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1c-PENDING");
var approved = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1c-DADUYET",
phase: PurchaseEvaluationPhase.DaDuyet);
var rejected = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1c-TUCHOI",
phase: PurchaseEvaluationPhase.TuChoi);
var lvlAlice = wf.Steps.Single().Levels.Single();
var cmd = WithLevels(BuildEchoCommand(wf, reason: "Bấm sai gói thầu"), 0,
[EchoLevel(lvlAlice), NewLevel(1, bob.Id)]);
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
var logs = await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().ToListAsync();
logs.Select(l => l.PurchaseEvaluationId).Should().BeEquivalentTo(new[] { pending.Id },
$"chỉ phiếu chưa kết thúc mới chịu ảnh hưởng (DaDuyet {approved.MaPhieu} + TuChoi {rejected.MaPhieu} phải bị loại)");
var log = logs.Single();
log.Action.Should().Be(ChangelogAction.Update);
log.EntityType.Should().Be(PurchaseEvaluationEntityType.Workflow);
log.EntityId.Should().Be(wf.Id);
log.PhaseAtChange.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
log.UserId.Should().Be(admin.Id);
log.UserName.Should().Be("Quản trị hệ thống");
log.Summary.Should().Contain("giữ nguyên phiên bản");
log.ContextNote.Should().Contain("Lý do: Bấm sai gói thầu");
log.ContextNote.Should().Contain("THÊM NV duyệt Lê Văn Bình",
"vết phải đọc được bằng TÊN, không phải GUID");
}
}
}