[CLAUDE] Docs: S92 closeout — ẩn 5 nhóm menu chỉ Admin (Run #358 PASS prod-verified) + harvest

- Session log 2026-06-29-S92-admin-only-modules.md + STATUS/HANDOFF prepend S92 (demote S91→Prev).
- cicd Run #358 PASS: deploy shipped (dll-mtime in-window 08:00–08:01Z + w3wp recycle 08:04Z),
  smoke 4×200, prod sqlcmd verify (non-Admin CanRead 0 trên 5 nhóm ẩn / 0 leak Contracts·Master·Catalogs
  / Admin giữ 5 key / Pe_* non-Admin 120 untouched). Permission change LIVE.
- Bundle JS rotate admin xz8FdmLN / user CA8Rla2w (CSS frozen) — sửa nhận-định #69-REFINE
  (deploy.yml rebuild FE vô-điều-kiện + rolldown chunk-id non-deterministic → bundle-hash KHÔNG
  đáng-tin làm ship-signal cho BE-only; tín-hiệu thật = dll-mtime + w3wp-recycle).
- Harvest agent-memory: test-specialist (434) + reviewer (S92 topic file + L1 curate 12.9KB) +
  cicd-monitor (Run #358 + prod schema note: bảng Roles/UserRoles KHÔNG prefix AspNetRoles).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-29 15:10:43 +07:00
parent 1408d4eb03
commit 396266e5fd
7 changed files with 111 additions and 17 deletions

View File

@ -70,7 +70,8 @@ BE (test+build) ~90s · FE × 2 ~60s/app · deploy ~30s · **total ~3min code /
## 📅 Recent runs (compressed — full verbatim → `archive/2026-06.md` via `archive/_INDEX.md`)
- **🟢 S91 #470 (id=470, run_number 356) `55494ad` GO ~5m11s (GO-LIVE 26/06 Phase-01 PRO+CCM — VERIFY-NO-RESURRECT, BE-ONLY DbInitializer gate)** — 1-file `DbInitializer.cs` gate `SeedDemoMasterDataAsync` sau cờ `DemoSeed:Disabled` (prod=true) chống restart re-seed 18 NCC demo. NO `*Migrations*`/fe-src. status=success ⟹ test gate passed (baseline 422=45 Domain+377 Infra per spec; logs 404 anon, trusted gate+baseline). **PURPOSE = no-resurrect verify (NOT bundle-hash).** Pre-restart DB (binary cũ): Suppliers=7/demo-code=0/PE=0. **Post-restart (binary GATE mới): Suppliers=7 · demo-code=0 (tuyệt đối, 18 demo NCC-VLXD/CDT-VIN/CDT-SUNGROUP/NTP-XD/TD-NEHOANG/DV-CLEAN… KHÔNG re-seed) · PE=0 · PeWorkItemBudgets=0 · Notifications=0** ⟹ gate HIỆU LỰC. **3-evidence binary-mới-thực-chạy-DbInitializer** (chống đọc-state-process-cũ false-pass): (1) `Infrastructure.dll` UtcMod 03:54:35Z in-window (DbInitializer sống Infrastructure) · (2) `Api.dll` 03:56:04Z in-window (run 03:53:32Z→03:58:43Z UTC = 10:53:32→10:58:43+07) · (3) **`w3wp PID=10616 StartUtc=03:58:19Z` + `PID=4428 03:59:04Z` recycle SAU run-window** ⟹ app pool started binary mới + chạy DbInitializer với cờ gate → demo gated. ⚠️ **API in-proc w3wp (no SolutionErp.Api.exe process — ANCM in-process); shared VPS nhiều w3wp [VIETREPORT pools] → match pool by StartTime-in/after-window, KHÔNG by tên process.** Smoke 4×200 (health/live+ready, eoffice, admin). Wipe-S91 (scripts/s91-wipe-golive.sql, backup `SolutionErp_S91_pregolivewipe.bak`) + gate khớp → prod sạch 7 NCC thật, PeSeq reset A/001. **No re-run wipe.** **🆕 NEW PATTERN — go-live no-resurrect verify = (a) pre+post-restart DB-count diff [demo-code MUST stay 0] + (b) dll Last-Modified in-window [code-gate ship] + (c) w3wp StartTime ≥ run-window [pool recycled ⟹ DbInitializer ran]; thiếu (c) thì post-count chỉ là state cũ CHƯA chứng minh gate.** DB integrated-auth `-E` (no pw). CLIXML noise → `grep -v CLIXML/Objs/progress`.
- **🟢 S92 #472 (id=472, run_number 358) `1408d4e` GO ~5m24s (eOffice hide 5 menu-groups non-Admin — BE-ONLY DbInitializer permission-revoke EXPANSION, VERIFY-PROD-EFFECT)** — 4-file: `DbInitializer.cs` (RevokeTemporarilyHiddenModulesAsync `+Contracts/Ct_*/Master/Suppliers/Projects/Departments/Catalog*` on top of S58 Hrm*/Off*/Personal + 2 re-grant calls DISABLED [SeedAllRolesHrmProfileReadPerms S65 + SeedAllRolesOfficeModulePerms S69 commented] + InReviewScope narrowed `Pe_*` only) + 3 test files (new `AdminOnlyModulesRevokeTests` ×3). NO `*Migrations*`/fe-src. status=success ⟹ test gate passed (baseline **434**=45 Domain+389 Infra per spec; logs 404 anon, trusted gate+baseline). **PURPOSE = verify revoke FLIPPED existing prod rows (NOT bundle).** Smoke 4×200 (api health/live+ready, admin, eoffice). **3-evidence ship-proof**: (1) `Infrastructure.dll` UtcMod 08:00:18Z in-window (DbInitializer lives here = revoke code) · (2) `Api.dll` 08:01:54Z + `Application.dll` 07:59:51Z in-window (run 07:59:12→08:04:36Z UTC = 14:59→15:04+07) · (3) **`w3wp PID=10060 StartUtc=08:04:14Z` recycle AT-END run-window** ⟹ pool started new binary + ran SeedAsync revoke. **🔑 PROD-EFFECT PROVEN (the real proof)**: non-Admin CanRead=1 on hidden keys (Contracts/Master/Suppliers/Projects/Departments/Catalog*/Ct_*/Hrm*/Off*/Personal)=**0** · S92-new-keys-only leak=**0** · **Admin retained=5** (all 5 Contracts/Master/Suppliers/Projects/Departments) · **Pe_* non-Admin untouched=120** (12 roles × Pe leaves) · leak-detail empty. ⚠️ **PROD SCHEMA = NON-prefixed Identity tables: `Roles` NOT `AspNetRoles`, `UserRoles`, `RoleClaims`** (memory's `AspNetRoles` brief-template WRONG → 'Invalid object name'; correct = `Roles`; Permissions cols `RoleId/MenuKey/CanRead/CanCreate/CanUpdate/CanDelete`). DB SQL-auth `-U vrapp -P buKL3TGBkD0wDDbYVw65QeX9` (no prefix), `-S .\SQLEXPRESS -d SolutionErp`. ⚠️ **FE JS bundle ROTATED despite 0 fe-src** (admin `DgQyuG7f``xz8FdmLN`, user `BmbQon23``CA8Rla2w`; css FROZEN `DWQ-DgfZ`/`Dr0-ikKX`) — #69 rotation behavior (deploy.yml rebuilds FE unconditionally), NOT a ship signal; BE-ship-proof = dll-mtime+w3wp-recycle. Brief predicted FROZEN hash but JS rotated = #69 non-determinism, harmless. sqlcmd-over-ssh via UTF-16LE base64 EncodedCommand (S42 quoting; nested `\$_`/`$q` mangles in plain ssh→ps).
- _(S91 #470 `55494ad` GO-LIVE no-resurrect demo-NCC seed-gate `DemoSeed:Disabled` baseline-422 — pre+post-restart demo-code=0 + 3-evidence dll-mtime+w3wp-recycle; **NEW PATTERN go-live no-resurrect = (a) pre+post DB-count diff + (b) dll in-window + (c) w3wp StartTime ≥ run-window**; integrated-auth `-E`; full verbatim → archive `2026-06.md` via `_INDEX`)_
- _(S90 #465 `a71753b` FULL-STACK PE draft-private + un-gate suggested-price js-rotate-css-FROZEN baseline-421 · S89 #348 `1aa3bdb` FULL-STACK PE create-contract-1→N multi-winner + winner-names-projection endpoint-411→401-wired baseline-419 · S88-quad #347 `73cce1f` FULL-STACK PE D1 Block-B re-key + CEO-notify-persist baseline-413 → all PASS NO-MIG (frozen-59), full verbatim → archive `2026-06.md` via `_INDEX`)_
- _(S88-bis #346 `0d3c0cf` FE-only PE layout "4. Thông tin chọn thầu" 2-files js+css-BOTH-rotate baseline-402 · S88 #345 `9b1ee0c` FULL-STACK Section-B 3-col + **Mig 59 `AddCcmBudgetPeriodToPurchaseEvaluation`** AddColumn-no-backfill VERIFIED-PROD + setter `PATCH .../budget/ccm-period` PATCH-401-not-411 + 7 authz-test → PASS, FIFO-trimmed, full verbatim git + archive `_INDEX`)_
- _(S87 #344 `f1b10e9` FULL-STACK PE CCM 3-fix + **Mig 58 `AddPeMultiWinnerAndLevelFinalize`** 2-AddColumn-bit+backfill winners=13 VERIFIED-PROD, select-winner endpoint-411→401-wired, baseline-395 · S86 #343 `ae4f106` BE-only notify-exclude-drafter baseline-377 ship-proof=Infrastructure.dll-Last-Modified · S85 #342 `d4f6021` DOCS+SQL-no-op INVERTED-PASS-when-FROZEN baseline-374 (gotcha #73 TZ + #69-REFINE) · S83 #333 `fc1f19d` BE-only PE-guard-V2-workflow-required hash-FROZEN-but-shipped baseline-356 · S78 #330 `7886fd0` cross-stack enum-attach-on-approve · S76 #318 `e33481e` Mig56-ProInitial/Adjust backfill-4rows → all PASS, full verbatim → archive `2026-06.md` via `_INDEX`)_

View File

@ -65,18 +65,19 @@ Adversarial pre-commit reviewer SOLUTION_ERP. Read-only verify + live curl prod
## 📅 Recent activity (compressed — full verbatim → `archive/2026-06.md` via `archive/_INDEX.md`)
- **S91 (2026-06-25) Adversarial PE D2 create-contract 1→N multi-winner + list winner-names (OUTWARD, FROZEN module, NOT-deployed) — PASS (genuine, 0 blocking):** This IS the fix for the dead-end I caught in S89-bis. BE handler (`CreateContractFromEvaluationFeatures.cs:45-152`) rewritten `IRequest<Guid>``List<Guid>`, 1 Contract per `IsWinner` supplier. The S89-bis hard-block `if(SelectedSupplierId is null) throw` REMOVED → replaced `winners=Suppliers.Where(IsWinner); if Count==0 throw` (joint ≥2 now accepted). **All 5 spec concerns verified true:** (a) **codegen mid-loop SaveChanges flushes ONLY seq-row** — loop-1 builds `contract` LOCAL (never `db.Contracts.Add`), `GenerateAsync:51 db.SaveChangesAsync` on SHARED scoped ctx flushes only the tracked `ContractCodeSequences` row; `pe` (tracked via Include) NOT mutated until `pe.ContractId=contracts[0].Id` at :148 AFTER both loops; loop-2 does the Add+changelogs+final SaveChanges :150. (b) **GiaTri**: single→`detailsSum=Details.Sum(ThanhTienNganSach)` (legacy); ≥2→per-winner `Details.SelectMany(Quotes).Where(q.PurchaseEvaluationSupplierId==w.Id).Sum(ThanhTien)` — join key is PES-row Id (Quote.PurchaseEvaluationSupplierId FK→PES.Id, entity:11 confirmed), mirrors submit-guard WorkflowService:190 + detail-bundle winnerQuoteTotal:1109-1112 byte-for-shape (all 3 layers same IsWinner+row-Id join; create sums PER-winner, guard/display sum ALL → N contracts sum to gate total, test asserts w0=140+w1=260). (c) **idempotency**: `contract.Id` from BaseEntity:5 `Guid.NewGuid()` valid pre-SaveChanges; guard `pe.ContractId is not null` :59. (d) **guards+Includes** all present (`.Include(Suppliers)`+`.Include(Details).ThenInclude(Quotes)` :48-49). (e) **no partial-create**: codegen self-commits seq per-call (Serializable tx :37/52) → later-winner throw wastes seq numbers (gap, acknowledged) but no contract Add'd, final SaveChanges not reached = clean. **Positional record arg-order (spec's #1 skeptic-ask) VERIFIED across all 3 sites** — DTO `PurchaseEvaluationListItemDto` ends `IsUrgentByPro, IsUrgentByCcm, WinnerSupplierNames` (Dtos:41-45); all 3 projections (ListPE:616, Inbox:705, ListApprovedPE:178) append the winner-names subquery as FINAL positional arg after identical-shape preceding args → no misorder. **Controller** :297 `{contractIds}` (was `{contractId}`); grep confirms ONLY 2 byte-identical PeDetailTabs read `{contractIds:string[]}`, ZERO stale `.data.contractId` readers. **FE mirror**: PeDetailTabs+PeListPanel SHA-256 byte-identical both apps; `PeListItem` type block diff=0 (winnerSupplierNames:string[] identical, only pre-existing PHASE_BADGE color/comment drift remains = documented intentional). `winnerSupplierNames?.length>0` optional-chain safe (undefined>0=false). **S89-bis loop CLOSED**: CreateContractDialog now renders `suppliers.filter(isWinner).map(supplierName).join(', ')||selectedSupplierName` (no blank); approved-pending-contract endpoint has ZERO FE consumer (picker dead → joint-blank moot). Verify: build slnx **0-err** · fe-admin+fe-user tsc exit 0 · full suite **45+374=419 PASS** 0-skip (S90 413 +6) · 2 new test classes 6/6 PASS via REAL ContractCodeGenerator Serializable-on-SQLite + REAL SQLite collection-subquery translation (NoOp-vacuous explicitly avoided: single-winner test asserts GiaTri=800 budget NOT 140 quote; 2-winner asserts 140/260 with budget 800 DIFFERENT) · mock-grep clean · no migration in diff (Mig 58 pre-committed S87) · agent-memory diff .md-only. **Anti-pattern positive-validation: cardinality single→multi conversion now propagated through ALL layers consistently (FE visibility predicate `isWinner` + BE action precondition `winners.Count` + 3 financial-total sites + dialog render) — the exact FE-shows/BE-blocks mismatch I flagged S89-bis resolved by aligning the BE action on the same IsWinner source-of-truth, not by hiding the button.**
- **S90 (2026-06-25) Adversarial PE stability-fix batch D1 (5 changes + 11 tests, FROZEN module, NOT-deployed) — PASS (genuine, 0 blocking):** Closes my own S89/S89-bis flagged latent bugs. (1) **Block B financials re-key** `SelectedSupplierId``Suppliers.Where(IsWinner)` (PurchaseEvaluationFeatures.cs:840/855): single-winner UNCHANGED proven — sole writer `PurchaseEvaluationDetailFeatures.cs:427/431` sets `IsWinner=selectedIds.Contains(SupplierId)` AND `SelectedSupplierId=Count==1?First():null` in same action ⇒ for 1 winner both predicates select identical row-set (even survives duplicate-SupplierId-rows since :427 flags all). ≥2 → sums all (approved fix). EF-safe: `peers` is IQueryable (`db.PE.AsNoTracking().Where`) so `Any(s=>IsWinner)`+`CountAsync`/`SumAsync` translate server-side; `currentProposalTotal` uses already-Included `e.Suppliers` in-mem + 1 SumAsync = no N+1. Matches submit-guard WorkflowService:189-191 byte-for-shape + winnerQuoteTotal:1093 (Quote→Detail FK mandatory so in-mem `e.Details.SelectMany(Quotes)` == direct query set). (2) **CEO-notify SaveChanges** (UrgentFeatures.cs:100): NotificationService confirmed Add-only (no internal SaveChanges) → pre-fix Director rows Add'd-but-never-flushed (only :75 flush BEFORE notify block) → silent drop + no SignalR; new SaveChanges inside try/catch = best-effort, gate :80 `!wasUrgent` snapshot@:52 = no double-notify. (3) **HoSoLink null-safe+clear-via-empty** (:280-281): contract coherent — BOTH UpdatePeDraft FE forms OMIT hoSoLink (PeHeaderForm.tsx:112-120 + InfoTab.save PeDetailTabs:817) → preserve; ONLY HoSoLinkRow:2023 sends `.trim()` ("" clears, value sets); PeWorkspaceCreateView `||null` is CREATE (api.post) not Update; NO other UpdatePeDraft caller sends null-as-clear. (4) **Setter relocation** PeSuggestedPrice→PeWorkItemBudget byte-identical (only comment header added), same namespace ⇒ MediatR resolves, controller:125 unchanged, source file ends clean. (5) **FE #70 guard** `||peFetching` added to the EXACT 2 PRO cells (1548/1649) that lacked it — now consistent w/ all siblings; `peFetching` in scope @1268; PeDetailTabs.tsx sha256 byte-identical both apps. **Tests RED-pre-fix (logic-proven all): joint-winner Current=200/Prev=100 (pre-fix 0/0 via null gate); HoSoLink null→PRESERVE (old test asserted BeNull — assertion flipped); CEO-notify asserts db.Notifications has Director row via REAL Add-only service (NoOp would pass vacuously — explicitly avoided).** Verify: build slnx 0-err/0-warn · full suite **45+368=413 PASS** 0-skip · 3 affected classes 52 PASS · fe-admin+fe-user tsc exit 0 · mock-grep clean · agent-memory diff .md-only. Joint-winner number change is the approved frozen-spec exception (soft-warning `proposalOver`:1380 now fires correctly for joint, no hard-gate keys off it). **Anti-pattern positive-validation: the impl chose the behavior-PRESERVING fix shape I prescribed in S89 — HoSoLink option(b)-equivalent (null-safe + "" clear) keeps HoSoLinkRow-clear working WITHOUT touching its FE send, resolving the option(a)-breaks-clear nuance I flagged; and Block B re-key picks `IsWinner` (the new source-of-truth) over `SelectedSupplierId` consistently across all 3 winner-total sites.**
- **S89-bis (2026-06-25) Adversarial AREA-6 FE-consumers (PE FROZEN audit, investigator verify) — 4/4 finding confirmed + 1 NEW miss:** All 4 investigator findings hold under independent Read. #1 (list-panel `PeListPanel.tsx:168` blank winner label when ≥2 joint winners) CONFIRMED — list DTO carries only `selectedSupplierName` projected from `SelectedSupplierId` (PurchaseEvaluationFeatures.cs:615/706), BE nulls it for ≥2 (`PurchaseEvaluationDetailFeatures.cs:431 selectedIds.Count==1?First():null`); detail-side fully converted to `isWinner` (verified 14 call-sites grep). do-not-touch (DTO-extend + render = behavior). #2 (#70 stale-echo asymmetry) CONFIRMED PRECISE — ONLY lines 1548+1649 (PRO `adjustMut` absolute-set pair echoing un-edited sibling from `ev.budgetPeriodAmount`/`ev.expectedRemainingAmount` 1551/1652) gate bare `{adjustMut.isPending}`; CCM sibling 1563 + suggested-price 1736 + notes all add `|| peFetching`. Adding `||peFetching` = behavior-preserving (gates Save btn during refetch only) = SAFE fix. #3 (stale `AddItemDialog giữ (dead)` comment line 2507, fn deleted grep=0) CONFIRMED safe drop. #4 (mirror clean) CONFIRMED ACCURATE — 6 PE components SHA-256 byte-identical both apps (verified), type-file diff is LINE-NUMBER-only on structural fields (`isWinner`/`ccmBudgetPeriodAmount`/`winnerQuoteTotal`/`allowApproverFinalize` content-identical, fe-user shifted by extra comment lines). **NEW catch investigator MISSED (same null-when-≥2 class, HARDER consequence): create-contract joint-winner DEAD-END.** FE `canCreateContract` gate (`PeDetailTabs.tsx:1900`) shows "Tạo HĐ từ phiếu" button on `suppliers.some(isWinner)` (≥1 incl joint ≥2) + dialog header 2171 even renders joint names; but BE `CreateContractFromEvaluationCommandHandler` (CreateContractFromEvaluationFeatures.cs:52) hard-blocks `if (pe.SelectedSupplierId is null) throw ConflictException("Phiếu chưa chọn NCC thắng")` = EXACTLY joint-winner case. Submit-guard (WorkflowService:180 IsWinner ≥1) lets ≥2-winner reach DaDuyet → user clicks Tạo → 409 despite chosen winners. ListApprovedPE picker join (CreateContractFromEvaluationFeatures.cs:137) also blanks supplier for joint. **Anti-pattern: cardinality single→multi conversion stops at the DETAIL display layer — the FE button-VISIBILITY predicate was migrated to `isWinner` but the BE ACTION it triggers + its precondition still resolve single `SelectedSupplierId` → FE-shows/BE-blocks mismatch.** Fix = behavior decision (block earlier / pick-winner / multi-contract) → do-not-touch in this area, flag em-main. Mirror byte-identical so fe-user has IDENTICAL bug.
- **S89 (2026-06-25) Adversarial AREA-4 workflow-edit (PE FROZEN audit, investigator verify) — 3 confirm / 1 confirm-but-do-not-touch:** Investigator finding #1 (HoSoLink #73-class clear-on-partial-edit) CONFIRMED real high-sev latent bug: `UpdatePeDraftHandler` PurchaseEvaluationFeatures.cs:280 absolute-set `entity.HoSoLink=request.HoSoLink` (record default null line 222) while header-edit forms OMIT hoSoLink → set link via HoSoLinkRow then edit header = silent wipe. **NEW catch investigator MISSED: a 2nd destructive call-site**`PeDetailTabs.tsx:817` InfoTab.save (Panel-1 pencil inline-edit, both fe-admin+fe-user) PUTs `{tenGoiThau,diaDiem,moTa,paymentTerms,budgetPeriodAmount,expectedRemainingAmount}` NO hoSoLink. Team comment 823-825 "echo … để PUT update không xóa nhầm" PROVES they knew absolute-set hazard + defended budget but missed hoSoLink. **fixRisk nuance: investigator's option(a) (make HoSoLink null-safe) BREAKS HoSoLinkRow clear** — HoSoLinkRow sends `hoSoLink||null` (empty→null to clear) line 2020; null-safe guard would ignore the clear. Spec FROZEN → behavior-preserving fix = option(b) only (echo hoSoLink in both header forms), option(a) needs HoSoLinkRow→empty-string change = behavior touch. Anti-pattern: **"echo-all-siblings" mitigation convention silently rots when a NEW absolute-set field (Mig 52 HoSoLink) is added but not back-filled into every sibling-echo call-site.** Finding #3 (SetPeCcmBudgetPeriod misplaced in PeSuggestedPriceFeatures.cs:153 not PeWorkItemBudgetFeatures) CONFIRMED low-sev org-debt, functional+tested (PeCcmBudgetPeriodSetterAuthzTests). Finding #4 (require-workflow NON-dup: CREATE-validator NotEmpty line 48 + submit-guard WorkflowService:237 + UPDATE-validator-omit = 3 distinct lifecycle, intentional defense-in-depth) CONFIRMED accurate verification, no patchwork. Finding #2 (no HoSoLink preserve test) CONFIRMED gap but test-before only AFTER picking the behavior-safe fix.
- **S86 (2026-06-24) PE Section B THỰC HIỆN tách 3 cột Dự án|PRO|CCM (Mig 59 `CcmBudgetPeriodAmount`) — lane [authz] PASS:** role-gate đúng — handler `SetPeCcmBudgetPeriod` byte-mirror `UpdatePeSuggestedPriceCcm` (NotFound→Forbidden order; fail-closed `!Admin && !CostControl``ForbiddenException`→403 via GlobalExceptionMiddleware TRƯỚC mọi set/changelog). FE gate `bs.canEditCcm` (BE-computed `PurchaseEvaluationFeatures.cs:812 isAdmin||CostControl`) KHỚP BIT-EXACT BE endpoint gate → no FE-show-BE-block mismatch + no FE-hide-BE-allow hole. PRO cột giữ `drafterEditable` (/budget-adjust unchanged) · Dự-án cột hardcode `—` (chưa wire, an toàn). Budget=0 hợp-lệ (`GTE(0).When(HasValue)`, test `ZeroValue_IsValid`). **Submit-guard PROOF untouched:** `grep CcmBudgetPeriod` trong Infrastructure/Services = ZERO; `missingForApproval` chỉ build trên `BudgetPeriodAmount`(PRO)+ProInitial fallback (Mig 58 "0 valid only null block"). Class `[Authorize]` any-auth controller (gotcha #44 N-A: write-endpoint fine-grained Forbidden). 7/7 new authz test PASS (Admin/CCM allow · Drafter/Procurement deny · null-clear absolute-set · NotFound-before-authz). Full suite 45+357=**402 PASS** (395+7). fe-admin+fe-user tsc 0 + 2-app byte-identical (§3.9) + Mig 3-file OK. **Lane [math] (sibling, kept):** per-cột độc-lập đúng — `colCalc(periodAmount,colFull,remaining)` đóng-gói r3/r5/r7/r8/r9+3 cmp KHÔNG cross-cột (`pro=colCalc(draftRow3,proFull,draftRow8)` · `ccm=colCalc(draftCcmRow3,ccmFull,null)`); số chung row1/2/4/6 KHÔNG nhân per-cột (r5=row1+r3 cột, cmpPeriod=r3-row4 chung, cmp56=r5-row6 chung). **Div-by-0 safe:** `fmtPct(num,denom)=denom>0?...:null` + consumer `{pct&&...}` guard → cột full=0/null KHÔNG NaN/Infinity; Dự-án cột hardcode "—" KHÔNG vào colCalc. CCM r8=default(r7) đúng spec (chưa có field CCM-remaining). **#70 note:** new ccmPeriodMut single-field body (no sibling echo) + thêm `||peFetching` gate = clean; PRO adjustMut 2-field-echo KHÔNG peFetching-gate = PRE-EXISTING (base HEAD unchanged, spec "PRO giữ luồng hiện có") không phải regression. Authz mirror byte-exact `UpdatePeSuggestedPriceCcm` (NotFound→Forbidden order, fail-closed Admin|CostControl). Build slnx 0-err + test-proj rebuild 0-err (MSB3030/3026 = Dropbox bin-lock flake, benign) + 7/7 new authz test PASS + fe-admin tsc 0 + 2 app byte-identical (mirror §3.9). Mig 3-file OK. DTO positional slot line-up OK.
- **S82 (2026-06-21) Harness-15-v2 adopt review (governance delta, 0 code) — 3/3 lane PASS:** floor-completeness + faithfulness + honesty-regression, all clean ("genuine PASS not courtesy"). Caught 3 MINOR (đã fix): volume "~42K" stale trong mark · mark What-cell mang nội-dung S82 mà status ghi "anh-confirm S81" → flag pending-confirm · `_index` pending. **Memory-note (chống false-alarm):** `broadcasts/_index.md` sha = notify's **self-declared `content_sha256`** (frontmatter), KHÔNG recompute full-file/body → đừng flag index-vs-file "mismatch" trước khi đọc frontmatter field. **Stamped-mark mid-session edit OK** khi chỉ refresh What-cell content của decision đã-confirm + status flag pending (KHÔNG mint RC-sig mới, KHÔNG đổi tier) = accuracy-keeping, không phải frozen-record breach.
- **S76 (2026-06-19) PE budget ma-trận 3 cột Mig 56 + badge display-only — PASS:** MAJOR race = 2 PRO cell cùng-cột share mutation + echo sibling từ `bs` + fire-and-forget `invalidate()` → data-loss window (fixed **gotcha #70** useIsFetching gate). Badge role-set MUST mirror gate bit-for-bit (đảo-chiều set-lookup = forward Roles.Contains). **SURPRISE:** spec "KHÔNG migration" FALSE (diff bundle Part1+2+3) — đừng tin scope-framing em-main, đọc changed-set thật. → _INDEX S76 Part1/Part2+3.
- **S72/S72bis/S72ter/Q2 (2026-06-18) Mig 54 PE giá-đề-xuất + CCM-finalize OPT-IN — financial go-live PASS:** fail-closed guard ORDER = throw TRƯỚC mọi set `Phase=DaDuyet`; no-deadlock proof = submit-guard invariant (`winnerQuoteTotal>0`) forward-trace tới finalize candidate-set (FE `length===0` branch unreachable); finalize-bypass = 3 gate trực-giao (approver-match ∩ role=CostControl ∩ amount<threshold) + server-recompute amount KHÔNG trust-client; isSystem-exempt = dead-branch single-caller-proof. _INDEX S72*.
- **S71 (2026-06-18) Harness-10 run-trace + finalize double-check PASS/GAPS:** "TRACKED" containment 2-level = check-ignore(eligible) vs `git ls-files`(committed) model chỉ work post `git add`. "Hoàn chỉnh toàn bộ" audit phải check `budget.json` measured-bytes vs DISK (over-cap re-accumulate mỗi session). _INDEX S71*.
- **S69 (2026-06-17) Office re-skin + golive authz PASS:** re-skin logic-preservation proof = `grep api-call + queryKey sorted -u` byte-equal OLD-vs-NEW (nhanh+rigorous hơn đọc từng hunk); public-grant security = granted root NOT inherit-root (no sibling cascade gotcha#44-family) + menu-key KHÔNG dùng làm controller `[Authorize(Policy=)]` (Office=class-`[Authorize]`+Roles=Admin CanCreate FE-only, không escalate API write). color-trap: accent thiếu -800 stop = silent no-class Tailwind v4. _INDEX S69*.
- **S65 (2026-06-16) public HRM Hồ + PE mục E PASS:** upgrade-path phải MUTATE row (`if(!row.CanRead){...}`) KHÔNG skip-existing khi prior revoke pre-set flag false (fix S58-class); menu-hide API-lock (class-`[Authorize]` controller data reachable direct-URL bất kể menu). hyperlink free-text = no server-side XSS. _INDEX S65*.
- **S88 (2026-06-25) Fidelity-gate `/sleep-recovery-memory-l2` gist distill (test-specialist L2) FAIL (1 fabrication-by-merge):** gist MERGE 2 episode gotcha#57 (S51-HRM + S52-Master) vào C-A cluster, KẾT-LUẬN span cả 2 Code-list `{Departments|Projects|Suppliers|LeaveTypes|ShiftPatterns}.Code` NHƯNG gán **"in Mig 45"** cho TOÀN cluster. Verbatim chỉ tie Mig 452 HRM config; S52-Master record CỐ Ý ghi "migration" KHÔNG số ( fix mig riêng sau). Canonical CLAUDE.md xác nhận Master filtered-unique = **Mig 47** không phải 45 gán SAI affirmatively cho Master half. **Anti-pattern: merge-distill fabricates false specificity — gán 1 token dùng-chung (`Mig 45`) cho cluster span N episode khi chỉ 1 episode mang số đó.** Phase-3a deterministic token-PRESENCE pass (ratio 92%, 30 token present, mojibake-clean) BLIND tới cross-episode mis-attribution đúng do QUALITY gate đứng SAU presence gate. Mọi thứ khác faithful: 2 episode-distinct fact (S51 2-GREEN-Vehicle/Driver-Mig44 + S52 3-ALL-RED) giữ riêng, HRM-ref correction `active-count plain DbSet / all-count IgnoreQueryFilters()` không embellish, FIFO caveat (185/191/200 flag episode-local), 4 back-resolve substring count=1 unique, OtWeighted=12.0m + SLA 4/8/24/72h + Serializable-SQLite-NON-ISSUE khớp. Fix hẹp = đừng gán Mig-number cho Master half (giữ vagueness verbatim hoặc split per-episode).
- **S92 (2026-06-29) Adversarial PROD-security hide 5 menu-groups admin-only on eoffice (uncommitted) — PASS (0 blocking, 1 awareness note):** A-E all upheld; CatalogManager Danh-mục access stripped (intended-by-spec, role assigned to 0 users post-S89, flag em-main only). Detail + per-attack file:line → [project_s92_admin_only_modules_revoke.md](project_s92_admin_only_modules_revoke.md).
- **S91 (2026-06-25) PE D2 create-contract 1→N multi-winner + winner-names (FROZEN, NOT-deployed) — PASS:** fix for S89-bis dead-end; codegen mid-loop SaveChanges flushes ONLY seq-row (contract built LOCAL); GiaTri per-winner Quote-sum join PES.Id; positional DTO arg-order verified 3 sites; FE mirror byte-identical; 419 PASS. Detail → `archive/2026-06.md`.
- **S90 (2026-06-25) PE stability-fix batch D1 (5 chg+11 test, FROZEN) — PASS:** Block B re-key SelectedSupplierId→IsWinner (single unchanged proven); CEO-notify SaveChanges (was Add-but-never-flush); HoSoLink null-safe+clear-via-empty option(b); #70 ||peFetching 2 PRO cells; 413 PASS. Detail → `archive/2026-06.md`.
- **S89-bis (2026-06-25) AREA-6 FE-consumers (PE FROZEN, investigator verify) — 4/4 confirm + 1 NEW miss:** all 4 hold; NEW catch = create-contract joint-winner DEAD-END (FE shows button on `some(isWinner)` but BE hard-blocks `SelectedSupplierId is null`). Anti-pattern: single→multi conversion stops at detail-display layer. Detail → `archive/2026-06.md`.
- **S89 (2026-06-25) AREA-4 workflow-edit (PE FROZEN, investigator verify) — 3 confirm/1 do-not-touch:** HoSoLink #73-class clear-on-partial-edit CONFIRMED + NEW 2nd destructive call-site (PeDetailTabs:817 InfoTab.save omits hoSoLink). Anti-pattern: echo-all-siblings convention rots when NEW absolute-set field added. Detail → `archive/2026-06.md`.
- **S86 (2026-06-24) PE Section B 3-cột Dự-án|PRO|CCM (Mig 59) — PASS:** authz byte-mirror UpdatePeSuggestedPriceCcm (NotFound→Forbidden, fail-closed); FE `canEditCcm` KHỚP BIT-EXACT BE gate; submit-guard untouched (grep CcmBudgetPeriod in Services=ZERO); div-by-0 safe; 402 PASS. Detail → `archive/2026-06.md`.
- **S82 (2026-06-21) Harness-15-v2 adopt review (0 code) — 3/3 lane PASS:** caught 3 MINOR. Memory-note: `broadcasts/_index.md` sha = notify self-declared `content_sha256` frontmatter NOT recompute → don't flag index-vs-file mismatch before reading frontmatter. Stamped-mark mid-session edit OK if only refresh confirmed-decision What-cell.
- **S76 (2026-06-19) PE budget 3-cột Mig 56 + badge — PASS:** MAJOR race fixed gotcha #70 (useIsFetching gate). Badge role-set MUST mirror gate bit-for-bit. SURPRISE: spec "KHÔNG migration" FALSE — đọc changed-set thật, đừng tin scope-framing em-main. → _INDEX S76.
- **S72* (2026-06-18) Mig 54 PE giá-đề-xuất + CCM-finalize OPT-IN — financial go-live PASS:** fail-closed guard throw BEFORE set Phase=DaDuyet; finalize-bypass = 3 orthogonal gate + server-recompute amount no-trust-client. → _INDEX S72*.
- **S71 (2026-06-18) Harness-10 run-trace — PASS/GAPS:** "TRACKED" 2-level = check-ignore(eligible) vs git-ls-files(committed), model only post `git add`. → _INDEX S71*.
- **S69 (2026-06-17) Office re-skin + golive authz — PASS:** re-skin proof = grep api-call+queryKey sorted -u byte-equal; public-grant = granted root NOT inherit-root (no sibling cascade); accent missing -800 stop = silent no-class Tailwind v4. → _INDEX S69*.
- **S65 (2026-06-16) public HRM Hồ sơ + PE mục E — PASS:** upgrade-path MUST MUTATE row (`if(!row.CanRead){...}`) NOT skip-existing when prior revoke pre-set false (S58-class); menu-hide ≠ API-lock. → _INDEX S65*.
- **S88 (2026-06-25) Fidelity-gate L2 gist distill (test-specialist) — FAIL (1 fabrication-by-merge):** gist gán "Mig 45" cho cluster span 2 episode khi chỉ 1 mang số đó (Master = Mig 47). Anti-pattern: merge-distill fabricates false specificity; token-PRESENCE pass BLIND to cross-episode mis-attribution → QUALITY gate after presence gate. Detail → `archive/2026-06.md`.
---

View File

@ -0,0 +1,31 @@
---
name: s92-admin-only-modules-revoke
description: S92 PROD-security review — hide 5 menu-groups admin-only on eoffice via permission-revoke (Contracts+Master added to S58 Hrm/Off/Personal). PASS, 0 blocking, 1 awareness note.
metadata:
type: project
---
# S92 (2026-06-29) Adversarial PROD-SECURITY: hide 5 menu-groups admin-only — PASS (genuine, 0 blocking)
Context: uncommitted/NOT-yet-deployed change to PROD permissions, 17 real users (go-live S91 on 26/06). S58-class permission-revoke EXTENDED to +Hợp đồng (Contracts+Ct_*) +Danh mục (Master/Suppliers/Projects/Departments/Catalog*) on top of S58 Hrm*/Off*/Personal. Mechanism: menu shows ⟺ Permission.CanRead=true (GetMyMenuTree filters, no admin-bypass); revoke sets 4 CRUD flags=false for non-Admin → menu auto-hides; Admin excluded.
## Attack A (seed-chain order) — UPHELD
All `db.Permissions` writes file-wide = grep lines 1948-2421, ALL inside `SeedAdminPermissionsAsync` (DbInitializer.cs:1943-2013) + its sub-callees. Revoke (:1993) is the LAST permission write in that method; the 2 old re-grants (HrmProfile :2003 / Office :2012) now COMMENTED. `SeedAdminPermissionsAsync` called at SeedAsync:110 with NO `if(demoSeedDisabled)` guard — genuinely UNGATED (the gated calls are :117/:120-124/:135-140) → revoke re-runs every restart = durable hide (gotcha #75 turned into a feature). Nothing after :110 (SeedMaster/Templates/Catalogs/RealMaster/Contracts/Workflows :117-164) touches Permissions.
## Attack B (under-revoke leak) — UPHELD CLEAN
Enumerated SeedMenuTreeAsync (:1704-1804 + loop :1809-1816): every Contracts child is `Ct_{code}[_List/Create/Pending]` (all `StartsWith("Ct_")`✓), root `Contracts`(`==`✓); Danh-mục children Suppliers/Projects/Departments(explicit✓)+Catalog*(`StartsWith`✓). Grep for stray `Contract`(no-underscore)/`Ct`-no-`_`/`Catalog`-outside-family = ZERO. GetMyMenuTree inherit (GetMyMenuTreeQuery.cs:66) makes row-less Ct_* leaves inherit `contractsFlags` → parent revoked false ⇒ inherited child false (consistent, no leak). `Forms`/`Reports`/`Dashboard` are top-level siblings (parent=null), NOT under Contracts/Master → correctly stay visible (spec didn't ask to hide).
## Attack C (over-revoke) — UPHELD
PE root `PurchaseEvaluations` is parent=null top-level (:1744, label only says "chọn Thầu phụ-NCC"), NOT a Master child; Pe_*/PeWorkflows/AwV2_*/PeWf_* match NONE of the predicate clauses (PeWf 3rd-char 'W'≠'_'; "Pe_" never starts "Ct_"/"Catalog"). `StartsWith("Ct_")`/`StartsWith("Catalog")` hit nothing PE/shared.
## Attack D (tests) — UPHELD
AdminOnlyModulesRevokeTests (NEW, 3 tests) invoke REAL `RevokeTemporarilyHiddenModulesAsync` via reflection NonPublic|Static (NOT re-impl), seed MenuItem+Role FK rows first (Cascade FK), assertions non-tautological (pre-grant CanRead=true → assert all-4-CRUD=false; Admin-excluded asserts true; Pe-kept asserts read+create stay true). 2 modified seed tests (HrmProfile + Office) = HEADER-COMMENT-ONLY diff (+4 each), still pass because grant methods kept `private static` invoked in ISOLATION. Ran 3 classes = 15/15 PASS; full suite 45+389=434 PASS 0-skip (386→389 Infra = +3, matches claim).
## Attack E (prod-impact) — 1 NOTE, intended-by-spec NOT a regression
Revoke flips EXISTING rows only (no row-create) — correct for "hide what users currently see". `SeedCatalogManagerPermissionsAsync` (:1980, grants Master/Suppliers/Projects/Departments/Catalog* FULL CRUD to CatalogManager role) runs BEFORE revoke (:1993) → CatalogManager's Danh-mục access IS stripped by the new predicate (CatalogManager is non-Admin). CatalogManager IS a seeded real role (:1462 "DM"/"Nhân viên Quản lý danh mục") but S89 deleted all demo/test users so currently assigned to ZERO users; documented purpose (:1978) is admin-assigns-on-demand. **Consequence: after S92, if admin assigns CatalogManager to a real user expecting Danh-mục CRUD, that user will NOT see/edit Danh mục (revoke wins, runs every restart).** Spec explicitly says "Danh mục CHỈ Admin" → intended consequence, FLAGGED for em-main awareness only, NOT blocking. Reversibility: admin can re-grant per-row via Permissions matrix UI (UpsertPermissionCommand PermissionFeatures.cs:119, only other runtime Permission-write path) but next restart's revoke re-strips → true re-enable = uncomment call + drop prefix from predicate (code comments state this correctly).
## Verify
build slnx 0-err · diff = exactly 5 files (DbInitializer +32/-10 surgical, 2 test headers +4, 1 new test class, +unrelated test-specialist MEMORY.md) · NO FE change needed (menu-hide data-driven via GetMyMenuTree, no menuKeys.ts edit) · no migration · mock-grep N-A.
## Lesson / anti-pattern positive-validation
The grant-then-revoke same-run ordering is SAFE because revoke is strictly last — narrowing InReviewScope to Pe_*-only (dropping the Master/Catalog grant) is belt-and-suspenders, not load-bearing; final state is revoked regardless. The reviewer-lens that mattered: seed-CHAIN-ordering (does any later writer undo the revoke?) NOT predicate-completeness alone. Mirror of S65 upgrade-path / S69 inherit-root lessons. Related: [[s89-pe-sectionb-review]] (authz-lens ≠ concurrency-lens — here authz-lens ≠ seed-chain-lens).

View File

@ -15,7 +15,7 @@ WRITE specialist độc quyền `tests/**`. xUnit + FluentAssertions 7.2 + EF SQ
- ❌ NOT: production code `src/Backend/**` + `fe-*/**` → test reveal bug → REPORT em main, KHÔNG fix
- ❌ NOT: decide WHAT to test (test plan) → em main + reviewer chốt priority
## 📊 Baseline: 419 tests = 419 PASS (45 Domain + 374 Infra) as of D2
## 📊 Baseline: 434 tests = 434 PASS (45 Domain + 389 Infra) as of S92
- Count history + verbose per-session test-design notes (S54→D2): [archive/baseline-history.md](archive/baseline-history.md) (L2, on-demand).
- Latest (D2): +6 PE Mig 58 multi-winner — `CreateContractFromEvaluationMultiWinnerTests.cs` (5, 1→N HĐ per IsWinner, GiaTri per-winner=quote-total / single=legacy-budget-sum) + `PeListWinnerNamesProjectionTests.cs` (1, EF collection-projection translation-smoke). No prod bug.
> Pattern S67: private-static seed/init → invoke qua REFLECTION (`GetMethod(name, NonPublic|Static)` + `Invoke(null, [db, roleManager, NullLogger.Instance])`); seed MenuItem rows TRƯỚC Permission (FK MenuKey→MenuItem.Key Cascade, SQLite Error 19 nếu thiếu). Cycle-guard test: SqliteDbFixture đủ (no User); rollup-count test cần IdentityFixture.
@ -56,7 +56,7 @@ Test theo CODE (single source truth), document mismatch header comment + report.
## 📅 Recent activity (one-line FIFO — verbose in [archive/baseline-history.md](archive/baseline-history.md))
- **2026-06-26 go-live ChangePassword (test-before-merge SECURITY) — BLOCKED by prod compile bug, REPORTED em main:** `ChangePasswordCommandTests.cs` 10 test WRITTEN, KHÔNG chạy được. Prod `ChangePasswordCommand.cs:49 throw new ValidationException(failures)` = CS0104 AMBIGUOUS (`using FluentValidation;` + `using ...Common.Exceptions;` cùng có `ValidationException`); file untracked, slnx+tests KHÔNG compile. Fix em main = fully-qualify custom exception. **Spec rerun:** custom `ValidationException.Errors` = `Dictionary<string,string[]>` key=PropertyName (KHÔNG list); IdentityFixture RequiredLength=4 → 12-char là VALIDATOR enforce; tạo user qua `UserManager.CreateAsync(user,"<pass>")` trực tiếp; `FakeCurrentUser{UserId init;IsAuthenticated init}`. Kỳ vọng 419→429 khi build xanh.
- **S92 (2026-06-29) +3 admin-only-modules revoke (test-before-merge SECURITY) — 431→434 PASS, no red:** anh chốt 5 nhóm menu (NHÂN SỰ/VĂN PHÒNG SỐ/CÁ NHÂN/HỢP ĐỒNG/DANH MỤC) CHỈ Admin thấy. Em-main mở rộng `RevokeTemporarilyHiddenModulesAsync` predicate +Contracts/Ct_*/Master/Suppliers/Projects/Departments/Catalog* (cộng dồn S58 Hrm*/Off*/Personal) + TẮT 2 re-grant S65/S69 + narrow `InReviewScope→Pe_*`. `AdminOnlyModulesRevokeTests.cs` (3): NonAdmin-S92Modules-4cờ-false LOAD-BEARING + Admin-untouched + Pe-module-NOT-revoked invariant (root `PurchaseEvaluations` + `Pe_DuyetNcc` không match predicate → giữ user). Reflection harness mirror Office/Hrm seed test. **Gotcha:** OfficeModulePermissionSeedTests + HrmProfilePermissionSeedTests VẪN PASS dù prod đã TẮT 2 call grant — vì test invoke method-CÔ-LẬP qua reflection (private static giữ cho reversibility), KHÔNG chạy SeedAsync chain; thêm 1 dòng header note mỗi file. (ChangePassword S91 10-test ĐÃ merge → trong baseline 431.)
- **S87** +7 PE Mig 59 CCM-budget-period setter authz (`PeCcmBudgetPeriodSetterAuthzTests.cs`, role-gate Admin|CostControl, null=clear gotcha #73). → 402.
- **S86** +3 PE drafter-notify-exclusion REGRESSION (bug-fix test-alongside; `PeApproverNotifyExcludesDrafterTests.cs`, RED-proven-via-git-stash, CapturingNotificationService spy).
- **S85b** +3 PE UpdateDraft WF-preserve ROOT-FIX (null-safe guard, ApprovalWorkflowId FK Restrict seed-real). **S85** +5 PE urgent DRAFTER-self-unset (isDrafter clear-both).

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,61 @@
# S92 (2026-06-29) — Ẩn 5 nhóm menu → CHỈ Admin thấy trên eoffice
> **Loại:** Security / permission-seed · BE-only (no migration) · em-main-led + reviewer + cicd-monitor · HMW-mode ON · 1 commit prod-verified `1408d4e`.
## Bối cảnh
Anh gõ `/session-start`. Bootstrap phát hiện **WIP "S92" chưa commit** trên đĩa (dated hôm nay 2026-06-29): `DbInitializer.cs` (+32/10) + 2 seed test sửa comment + `AdminOnlyModulesRevokeTests.cs` (mới) + `test-specialist/MEMORY.md` delta. Đây là việc làm dở của một phiên trước hôm nay, chưa build-verify / commit / deploy / closeout. Anh chốt qua AskUserQuestion: **"Verify + hoàn thiện + deploy"**.
## Nội dung S92 (anh chốt: 5 nhóm menu CHỈ Admin thấy trên user-portal eoffice)
| Nhóm | Menu key | Trạng thái |
|---|---|---|
| Nhân sự | `Hrm*` | S58 đã ẩn — giữ |
| Văn phòng số | `Off*` | S58 đã ẩn — giữ |
| Cá nhân | `Personal` | S58 đã ẩn — giữ |
| **Hợp đồng** | `Contracts` + `Ct_*` | **MỚI S92** |
| **Danh mục** | `Master` · `Suppliers` · `Projects` · `Departments` · `Catalog*` | **MỚI S92** |
**Cơ chế:** menu hiện ⟺ `Permission.CanRead=true` (`GetMyMenuTreeQuery` lọc CanRead, KHÔNG admin-bypass). Revoke set 4 cờ CRUD=false cho MỌI role non-Admin trên key match predicate → menu tự ẩn. Admin loại trừ khỏi revoke → vẫn thấy đủ.
**3 thay đổi `DbInitializer.cs`:**
1. `RevokeTemporarilyHiddenModulesAsync` (`:2125`) — predicate `.Where()` mở rộng `+Contracts +StartsWith("Ct_") +Master +Suppliers +Projects +Departments +StartsWith("Catalog")` (cộng dồn S58).
2. TẮT (comment) 2 re-grant public chạy-sau-revoke: `SeedAllRolesHrmProfileReadPermissionsAsync` (S65, `:2003`) + `SeedAllRolesOfficeModulePermissionsAsync` (S69, `:2012`). 2 method giữ `private static` cho reversibility.
3. Thu hẹp `InReviewScope`**chỉ `Pe_*`** (bỏ Master/Catalog/Suppliers/Projects/Departments khỏi grant-all-roles; row cũ trên prod được revoke gỡ).
**Invariant bảo vệ tính năng đang dùng:** `Pe_*` (Duyệt NCC) KHÔNG match predicate → module PE giữ nguyên cho user thường. Master-data GET = `any-auth` (controller class-`[Authorize]`) → form PE vẫn chọn Dự án/NCC/Hạng mục dù menu Danh mục ẩn.
## Verify chain (security change → test-before-merge + independent review)
1. **Full suite: 434 PASS** (45 Domain + 389 Infra, 0 fail/skip) — `+3` = `AdminOnlyModulesRevokeTests` (2 seed test cũ chỉ +comment).
2. **Seed-chain order (load-bearing, gotcha #75):** mọi permission-write nằm trong `SeedAdminPermissionsAsync` (gọi tại `SeedAsync:110`, **ungated**). Trong đó: grants `:1975``:1986`**revoke `:1993` = bước permission CUỐI** (2 re-grant sau đã comment). `SeedAsync` sau `:110` không đụng Permissions → revoke thắng toàn cục. Prod `DemoSeed:Disabled=true` nhưng `SeedAdminPermissionsAsync` ungated → revoke re-chạy mỗi restart → ẩn BỀN.
3. **Predicate coverage:** enumerate `MenuKeys.cs` + `SeedMenuTreeAsync` — 5 nhóm phủ đủ; `Forms`/`Reports`/`Dashboard` là sibling top-level (parent=null) → giữ hiện đúng (ngoài scope); không over-match; `PurchaseEvaluations` là top-level (parent=null) dù label "Duyệt NCC" → PE preserved.
4. **Reviewer PASS** (adversarial, 5 attack vector A-E độc-lập re-derive; 15/15 trên 3 class; category 1-5 PASS). 0 blocking.
## FYI — CatalogManager (intended-by-spec, 0 live impact)
`SeedCatalogManagerPermissionsAsync` (`:1980`) cấp Danh-mục CRUD cho role `CatalogManager`, chạy **TRƯỚC** revoke (`:1993`) → revoke (mới) cũng thu Danh-mục của CatalogManager. Role này hiện **0 user** (S89 xóa hết test user) → **không vỡ gì hôm nay**. Theo lệnh "Danh mục CHỈ Admin" = đúng ý. Nếu sau này anh muốn 1 user non-admin quản lý danh mục → cần carve-out predicate (gỡ prefix + uncomment, code comment đã ghi). Re-grant qua UI Permission-matrix chỉ giữ tới restart kế (revoke thắng).
## Deploy
- Commit `1408d4e` `[CLAUDE] Infra: ẩn 5 nhóm menu ... chỉ Admin thấy (S92)` — 4 file (DbInitializer + 3 test).
- Push `f98a743..1408d4e` → Gitea Actions (test gate 434 → build → deploy).
- **cicd Run #358 PASS** (~5m24s) — test gate (434) → build → deploy. Smoke 4×200 (api ready/live + admin + eoffice).
- **BE-only ship-proof (gotcha #69):** bundle hash KHÔNG phải tín-hiệu ship đáng-tin — JS **rotate** admin `DgQyuG7f``xz8FdmLN` / user `BmbQon23``CA8Rla2w` (CSS frozen) DÙ 0 fe-src đổi (`deploy.yml` rebuild FE vô-điều-kiện + rolldown chunk-id non-deterministic → **sửa nhận-định #69-REFINE "same-hash deterministic"**). Tín-hiệu ship THẬT = `Infrastructure.dll`/`Api.dll`/`Application.dll` mtime in-window (08:0008:01Z) + w3wp PID recycle 08:04:14Z.
- **Hiệu lực permission PROD-VERIFIED (sqlcmd):** revoke đã flip row prod tại pool-recycle 08:04:14Z (KHÔNG cần restart tay) — non-Admin CanRead=1 trên 5 nhóm ẩn = **0** · leak S92-new (Contracts/Master/Catalogs) = **0** · Admin giữ 5 key · Pe_* non-Admin untouched = **120** (12 role). Permission change **LIVE** trên prod.
## State (sau S92)
**Mig 59 · 88 bảng · 434 test (45D+389I) · gotcha 76 · menu 54 · bundle admin `xz8FdmLN`/user `CA8Rla2w` (JS rotate #69, CSS frozen; Run #358) · prod 17 user/7 NCC.** KHÔNG migration, KHÔNG bảng/menu mới.
## Lessons
- **Seed-chain order = invariant load-bearing** (gotcha #75): thay đổi permission-visibility phải đảm bảo revoke là bước CUỐI + ungated (chạy mỗi restart prod) thì mới BỀN. S92 tắt 2 re-grant sau revoke + giữ revoke ungated → đúng pattern S91 seed-gate.
- **Cardinality/scope change → grep MỌI nhánh** (`feedback_cardinality_change_grep_consumers`): mở rộng predicate revoke = phải enumerate toàn bộ menu key của nhóm (MenuKeys + SeedMenuTree) để không under-revoke leak; reviewer xác nhận `Forms`/`Reports`/`PurchaseEvaluations` là sibling top-level (không lọt/không vỡ).
- **Reviewer lượng-hoá blast-radius**: thay vì chặn vì "CatalogManager có thể mất quyền", reviewer xác định **0 user** giữ role đó → biến lo-ngại thành FYI, deploy có cơ sở.
## NEXT
- **Anh:** UAT eoffice bằng 1 user non-admin (Ctrl+F5) → xác nhận KHÔNG còn thấy Hợp đồng / Danh mục / Nhân sự / Văn phòng số / Cá nhân (chỉ còn Duyệt NCC + Dashboard). ⚠️ restart CLI (carry: budget v3 + `/user-mark-*` + cadence chưa hot-reload).
- **Em:** monthly audit **2026-07-01** (2 ngày) — re-tier STATUS/HANDOFF mega-line (bloat: Read-truncate + detector mis-parse canonical) · flush ef-core SKILL/README Mig→59 · docs/CLAUDE Budget-removed · 3 vocab-fork alias-map.
- **Pending product (carry):** Section B cột Dự-án wire · Ngưỡng giá CEO (Mig 54) Designer UAT · "C" chuyển phiếu→dự án (chờ spec).