# Gotchas — SOLUTION_ERP

> Bẫy/pitfall đã gặp + cách xử lý. Đọc trước khi debug tương tự để không mất thời gian. Cập nhật liên tục khi gặp bug mới.

## Tech stack constraints (.NET 10 + TS 6 + Vite 8)

### 1. MediatR 14.x không tương thích → pin 12.4.1

**Triệu chứng:** `Unable to resolve service for type 'MediatR.IMediator'` — `AddMediatR` vẫn chạy nhưng không register IMediator.

**Fix:** Pin `MediatR 12.4.1`. Khi đó `RequestHandlerDelegate<TResponse>` là delegate không tham số (v14 có thêm CancellationToken).

### 2. Swashbuckle 10.x + Microsoft.OpenApi 2.x breaking change

**Triệu chứng:** Build fail `The type or namespace 'Models' does not exist in 'Microsoft.OpenApi'`. Swagger 404.

**Fix:**
- Remove `Microsoft.AspNetCore.OpenApi` khỏi Api
- Downgrade Swashbuckle về `6.9.0`

### 3. TypeScript 6 `erasableSyntaxOnly` cấm `enum`

**Fix:** Dùng `const + as const + typeof[keyof]` pattern:

```ts
export const SupplierType = { NhaCungCap: 1 } as const
export type SupplierType = typeof SupplierType[keyof typeof SupplierType]
```

### 4. TypeScript 6 deprecate `baseUrl`

**Fix:** Bỏ `baseUrl` trong tsconfig, chỉ giữ `paths`. Paths resolve relative tsconfig location.

### 5. Node 22 local vs CI pin 20

**Bài học NamGroup:** CI build fail trên Node latest.

**Fix:**
- `package.json` engines: `">=20"` (min, không upper)
- `.nvmrc` = `20` cho CI
- GitHub/Gitea Actions: `actions/setup-node@v4` với `node-version: '20.x'`

## EF Core 10

### 6. Expression tree không support switch expression

**Triệu chứng:** `CS8514: An expression tree may not contain a switch expression`.

**Fix:** Tách switch ra ngoài LINQ:

```csharp
var hasPermission = action switch
{
    "Read" => await query.AnyAsync(p => p.CanRead),
    "Create" => await query.AnyAsync(p => p.CanCreate),
    _ => false,
};
```

### 7. Design-time DbContext resolve fail

**Triệu chứng:** `dotnet ef migrations add` → `Unable to resolve service for type 'DbContextOptions<T>'`.

**Fix:** Tạo `IDesignTimeDbContextFactory<ApplicationDbContext>` trong Infrastructure.

### 8. `AddDefaultTokenProviders()` không có trong `AddIdentityCore`

**Fix:** Bỏ call nếu chưa cần password reset. Khi cần, chuyển `AddIdentity` hoặc add package `Microsoft.AspNetCore.Identity.UI`.

## OpenXml / ClosedXML

### 9. `SpaceProcessingModeValues` namespace

**Fix:** Full path + wrap `EnumValue<>`:

```csharp
textElement.Space = new DocumentFormat.OpenXml.EnumValue<
    DocumentFormat.OpenXml.SpaceProcessingModeValues>(
    DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve);
```

### 10. Placeholder `{{field}}` bị split runs

**Vấn đề:** Word hay split text thành nhiều `<w:t>` — placeholder miss khi regex replace.

**Fix:** Iterate Paragraph, gom text tất cả `<w:t>` → replace → gán lại text đầu + clear rest. Đã implement trong `DocxRenderer`.

### 11. Word COM `SaveAs` PowerShell type conversion

**Fix:** Dùng `SaveAs2`:

```powershell
$doc.SaveAs2($outPath, 16)   # 16 = wdFormatDocumentDefault
```

### 12. Word COM stuck

**Fix:**
- `$word.DisplayAlerts = 0`
- Nếu stuck → `Get-Process WINWORD | Stop-Process -Force`
- Fallback: LibreOffice headless `soffice --headless --convert-to docx`

## System.Text.Json

### 13. Record deserialization fail với Unicode qua CLI

**Triệu chứng:** POST JSON tiếng Việt từ Windows bash/curl → 400 "JSON value could not be converted".

**Fix:** Dùng `curl --data-binary @file.json` (file UTF-8). API handle đúng qua axios/Swagger.

## File operations

### 14. Dropbox sync có thể revert file đang edit

**Triệu chứng:** Write thành công, build pass, runtime chạy code cũ.

**Fix:** Sau Write quan trọng → Read lại verify. Nếu revert → Write lại.

### 15. `.gitignore` wwwroot rules

- `wwwroot/uploads/` → **ignore** (user files)
- `wwwroot/templates/` → **commit** (source of truth)
- `wwwroot/exports/` → ignore (temp)

## Dev workflow

### 16. Port conflict khi restart dev server

**Fix:** `TaskStop` task cũ, hoặc `netstat -ano | findstr :8082` → `taskkill /F /PID <pid>`.

### 17. EF migration 3-file rule

Mỗi migration tạo: `{name}.cs` + `{name}.Designer.cs` + `ApplicationDbContextModelSnapshot.cs`. Commit đủ 3.

## Claude Code harness quirks

### 18. Edit tool "File not read" sau system-reminder

**Triệu chứng:** Edit file vừa Read, lỗi "File has not been read yet".

**Nguyên nhân:** System reminder interrupt reset read-cache.

**Fix:** Read lại file rồi Write/Edit. Hoặc dùng Write (ghi đè full) thay Edit.

### 19. Build pass nhưng DI thiếu registration

**Triệu chứng:** `dotnet build` → 0 errors nhưng runtime throw `Unable to resolve service`.

**Nguyên nhân:** C# compiler chỉ check type, không check DI graph.

**Fix:** Sau thêm interface mới + impl → luôn add `services.AddScoped<IX, X>()` trong `DependencyInjection.cs`. Test API start up là OK check.

## Contract workflow

### 20. Mã HĐ gen 2 lần sau reject → approve lại

**Fix:** Check `if (contract.MaHopDong is null)` trước khi gen. Đã implement trong `ContractWorkflowService.TransitionAsync`.

### 21. ~~BE adjacency vs FE NEXT_PHASES sync~~ (RESOLVED)

**Đã xử lý:** FE không còn hardcode `NEXT_PHASES` nữa. BE expose `contract.workflow.nextPhases` trong `ContractDetailDto` từ `WorkflowPolicyRegistry.ForContract(contract)`. FE render dynamic từ đó — single source of truth.

Nếu đổi policy BE: chỉ cần update `WorkflowPolicies.Standard` hoặc `WorkflowPolicies.SkipCcm` trong `Domain/Contracts/WorkflowPolicy.cs`. FE tự reflect.

### 22. Race condition gen mã HĐ khi 2 user cùng transition tới DangDongDau

**Fix:** `IsolationLevel.Serializable` transaction trong `ContractCodeGenerator`. Không skip.

### 42. Dual schema workflow V1 vs V2 — Service phải branch theo pin field (Session 17)

**Symptom:** Phiếu PE pin V2 (`ApprovalWorkflowId` set qua workspace Select) nhưng Service vẫn match approver theo schema cũ (`Dept+PositionLevel`). Approver V2 không duyệt được, button Duyệt báo Forbidden.

**Root cause:** Sau Mig 23-24 entity PE có 2 field workflow pin:
- `WorkflowDefinitionId` (Mig 21 V1 legacy) — pin schema flat cũ
- `ApprovalWorkflowId` (Mig 23 V2 mới) — pin schema 3-table mới

Service trước đó chỉ đọc `WorkflowDefinitionId` → bỏ qua V2.

**Fix (`b41484b`):** `PurchaseEvaluationWorkflowService.TransitionAsync` branch:
```csharp
if (evaluation.ApprovalWorkflowId is Guid awId)
    await ApproveV2Async(evaluation, awId, ...);   // iterate ApprovalWorkflowSteps + Levels match ApproverUserId
else
    await ApproveV1LegacyAsync(evaluation, ...);   // iterate WorkflowSteps match Dept+PositionLevel
```

**Pattern reusable** khi wire schema mới song song schema cũ (Contract V2 sắp tới): pin field flag để rẽ logic, KHÔNG drop legacy ngay (giữ backward compat phiếu cũ).

### 43. Step.Order ≠ index 0-based — không thể EF query trực tiếp (Session 17)

**Symptom:** Implement `ResolveV2InboxIdsAsync` (V2-aware Inbox) bằng EF query thẳng:
```csharp
.Where(s => s.Order == e.CurrentWorkflowStepIndex.Value + 1)  // FAIL — Step.Order là logical, không phải position
```
Logic sai: `WorkflowStep.Order` không phải position 0-based mà là số sort thứ tự (vd 5, 10, 20). `Steps.OrderBy(s => s.Order).ToList()[idx]` mới đúng.

**Fix:** Precompute candidates EF query → in-memory sort by Order → array index access:
```csharp
var candidates = await db.PE.Where(...).ToListAsync(ct);     // Step 1: EF lấy phiếu V2 pending
var workflows = await db.AW.Include(...).ToDictionaryAsync(w => w.Id, ct);
foreach (var c in candidates) {
    var steps = wf.Steps.OrderBy(s => s.Order).ToList();    // Step 2: in-memory sort
    var step = steps[c.CurrentWorkflowStepIndex.Value];     // Step 3: array index
    ...
}
```

Trade-off: scalable đến vài trăm phiếu pending, không ngon cho >10k. Optimize sau nếu cần.

## Permission matrix

### 23. Permission update không real-time

**Triệu chứng:** Admin tick permission cho role X → user X vẫn thấy menu cũ.

**Nguyên nhân:** FE cache menu trong `localStorage`, không auto refetch.

**Fix:** User phải logout/login. Phase 3 iteration 2 có thể thêm SignalR push "permission-changed" → FE tự refetch `/menus/me`.

### 24. MenuKey typo — không check type

**Fix:** Luôn dùng `MenuKeys.Contracts` const (BE) + `MenuKeys.Contracts` (FE `menuKeys.ts`). Không hardcode string.

## IIS / Windows Server

### 25. `Install-WindowsFeature Web-WebSockets` khóa section `<webSocket>` ở applicationHost

**Triệu chứng:** Sau khi install WebSocket feature → TẤT CẢ IIS site có `<webSocket enabled="true" />` trong web.config trả về HTTP 500.19 với error code `0x80070021` "configuration section cannot be used at this path" — kể cả site khác project không liên quan.

**Nguyên nhân:** Feature install thêm `<webSocket>` section vào `applicationHost.config` với `overrideModeDefault="Deny"`. Site web.config override section đó → fail.

**Fix:** Unlock section ở server level:
```powershell
& "$env:SystemRoot\system32\inetsrv\appcmd.exe" unlock config -section:system.webServer/webSocket
```

Tương tự khi dùng URL Rewrite `<serverVariables>` cần unlock `system.webServer/rewrite/allowedServerVariables`.

**Cảnh báo co-existence:** Trên VPS shared với project khác, enable feature mới qua `Install-WindowsFeature` có thể làm sập site project khác. Luôn test all site sau mỗi enable.

## SignalR / Realtime

### 26. SignalR WebSocket không cho custom Authorization header

**Triệu chứng:** `new HubConnectionBuilder().withUrl('/hubs/x', { headers: { Authorization: ... } })` — WebSocket transport vẫn 401.

**Nguyên nhân:** Browser WebSocket API không cho set custom headers cho handshake. Chỉ 2 transport khác (SSE / LongPolling) mới dùng headers.

**Fix:**
- FE: dùng `accessTokenFactory: () => token` — SignalR client tự append `?access_token=` query cho WebSocket
- BE: Wire JWT bearer `OnMessageReceived` để đọc token từ query khi path matches `/hubs/*`:
```csharp
options.Events = new JwtBearerEvents {
    OnMessageReceived = ctx => {
        var accessToken = ctx.Request.Query["access_token"];
        var path = ctx.HttpContext.Request.Path;
        if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
            ctx.Token = accessToken;
        return Task.CompletedTask;
    }
};
```

### 27. SignalR SaveChangesInterceptor — capture Added ở SavingChanges, push ở SavedChanges

**Lý do:** SavedChanges chỉ có entries sau commit thành công. Nhưng ở SavedChanges thì `EntityEntry.State` đã về `Unchanged` → không thể filter `Added`.

**Fix:** 2-phase pattern:
```csharp
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(...) {
    _pending = eventData.Context.ChangeTracker.Entries<Notification>()
        .Where(e => e.State == EntityState.Added)
        .Select(e => e.Entity).ToList();
    return base.SavingChangesAsync(...);
}

public override async ValueTask<int> SavedChangesAsync(..., int result, ...) {
    foreach (var n in _pending) await _realtimeNotifier.PushAsync(n);
    _pending.Clear();
    return result;
}
```

## DevOps / CI/CD

### 28. LibreOffice download URL 404 khi pin wrong version

**Triệu chứng:** `Invoke-WebRequest https://download.documentfoundation.org/libreoffice/stable/25.2.7/...` → 404.

**Nguyên nhân:** LibreOffice mirror chỉ giữ vài version mới nhất. 25.2.7, 24.8.7 không có. Chỉ 25.8.6 tồn tại tại thời điểm cài.

**Fix:** Check mirror URL trước khi pin. Dùng `Invoke-WebRequest -Method Head` verify trước download thật.

### 29. PowerShell 5.1 `>> $GITHUB_PATH` ghi UTF-16 → NUL byte crash Gitea Actions

**Triệu chứng:** Gitea Actions job fail với "NUL byte in PATH". `echo "C:\\dotnet" >> $env:GITHUB_PATH`.

**Nguyên nhân:** PS 5.1 default encoding UTF-16 LE BOM khi redirect `>>`. Gitea reads PATH as UTF-8 → NUL byte xuất hiện sau mỗi ASCII char.

**Fix:** Dùng `Out-File -Encoding utf8 -Append`:
```powershell
"C:\dotnet" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
```

Hoặc drop step GITHUB_PATH hoàn toàn nếu NSSM PATH đã có sẵn dotnet+node.

### 30. PS 5.1 scripts với Vietnamese diacritics → parser error

**Triệu chứng:** `Cannot parse script: Unexpected character` khi chạy PS script có text tiếng Việt inline.

**Nguyên nhân:** PS 5.1 đọc file script với ANSI codepage (Windows-1258 hoặc default 1252), không phải UTF-8.

**Fix (1):** Save script với BOM UTF-8 (Write-Host có dấu vẫn work):
```powershell
[System.IO.File]::WriteAllText($path, $content, [System.Text.Encoding]::UTF8)
```

**Fix (2, safer):** Rewrite script ASCII-only. Text tiếng Việt nằm trong log messages thay dùng code:
```powershell
Write-Host "Setup IIS sites done"  # thay vi "Hoan tat"
```

## TypeScript / FE

### 31. Dialog `size="xl"` TS2322 nếu variant không khai báo

**Triệu chứng:** `<Dialog size="xl">` → `Type '"xl"' is not assignable to type '"sm" | "md" | "lg"'`.

**Fix:** Sửa usage về `"lg"`, hoặc add `"xl"` vào `DialogSize` type union trong `components/ui/Dialog.tsx`. Đừng lazy `as any`.

## FE architecture

### 32. NavLink `end` prop cho query-param URL variants

**Triệu chứng:** `/contracts?type=1` highlight cả `/contracts` lẫn `/contracts?type=2` cùng lúc.

**Nguyên nhân:** Default NavLink `startsWith` match. Query string không parse distinct paths.

**Fix:** `end={path.includes('?')}` trong resolvePath để query-variants match exact:
```tsx
<NavLink to={path} end={path.includes('?')}>
```

## IIS / Windows Server (continued)

### 33. IPv4/IPv6 port hijack trên VPS shared (G-084)

**Triệu chứng:** `git.baocaogiaoduc.vn` trả về homepage Next.js của VietReport
thay vì Gitea UI. Headers lộ `x-nextjs-cache: HIT` + `X-Powered-By: ARR/3.0`
(request đã qua IIS ARR proxy rồi mới hit Next.js).

**Root cause:** Next.js app (NSSM service) được deploy lên VPS shared với
Gitea, ignore env `PORT=3001 HOSTNAME=127.0.0.1` và bind `0.0.0.0:3000`.
Gitea bind `0.0.0.0:3000` trước đó bị Windows fallback xuống IPv6-only
`[::]:3000` (default `IPV6_V6ONLY=1`). IIS ARR rewrite `http://localhost:3000`
→ Windows DNS resolve IPv4 first → hit Next.js → leak homepage cho TẤT CẢ
subdomain có ARR proxy về `:3000`.

**Fix (VietReport applied):**
1. Next.js NSSM env `PORT=3001 HOSTNAME=127.0.0.1` — bind loopback IPv4
2. Gitea `HTTP_ADDR=127.0.0.1` — bind loopback IPv4 explicit
3. IIS `web.config` rewrite URL dùng `127.0.0.1` thay `localhost`
4. NSSM `DependOnService=gitea` — boot order tránh race

**3 rules rút ra — áp dụng mọi service trên VPS shared:**
- Reverse-proxy luôn **IP literal `127.0.0.1`**, KHÔNG dùng `localhost`
- Backend services bind **loopback IPv4 explicit**, KHÔNG `0.0.0.0`
- Service dependency cho boot order khi nhiều service cùng port family

**SOLUTION_ERP relevance:**
- API host trong IIS app pool out-of-process (ANCM tự quản lý port Kestrel ephemeral) → risk THẤP
- FE gọi trực tiếp `https://api.solutions.com.vn` (không ARR proxy) → risk THẤP
- **NHƯNG** nếu tương lai thêm ARR reverse proxy (fe-admin/user `/api` proxy) hoặc
  deploy Kestrel standalone qua NSSM → PHẢI apply 3 rules trên
- Scripts + skill doc đã update `localhost` → `127.0.0.1` để đồng bộ

## FE routing + state (Phase 6)

### 34. React Router NavLink `isActive` chỉ match pathname, không query string

**Triệu chứng:** 2 NavLink cùng pathname (`/purchase-evaluations?type=2` vs
`/purchase-evaluations?type=2&pendingMe=1`) cùng highlight khi URL là một
trong 2. User thấy menu "Danh sách" + "Duyệt" active đồng thời.

**Nguyên nhân:** React Router v6 `NavLink`'s built-in `isActive` chỉ so
pathname. `end` prop chỉ thêm exact-match cho pathname segment, không check
query string.

**Fix:** Custom `isActive` với `queryMatches` helper (URLSearchParams set
equality). Xem `Layout.tsx` cả 2 FE:

```tsx
function queryMatches(current: string, target: string): boolean {
  const a = new URLSearchParams(current)
  const b = new URLSearchParams(target)
  const aKeys = [...a.keys()].sort()
  const bKeys = [...b.keys()].sort()
  if (aKeys.length !== bKeys.length) return false
  return aKeys.every((k, i) => bKeys[i] === k && a.get(k) === b.get(k))
}

function MenuLeaf({ node }: { node: MenuNode }) {
  const location = useLocation()
  const path = resolvePath(node.key)
  const [targetPath, targetQuery = ''] = path.split('?')
  const isActive = location.pathname === targetPath
    && queryMatches(location.search.replace(/^\?/, ''), targetQuery)
  return <NavLink to={path} className={isActive ? 'active' : ''}>...</NavLink>
}
```

### 35. Menu tree inheritance phải extend khi thêm root mới

**Triệu chứng:** Admin/role đã grant `PurchaseEvaluations.Read` (inherit parent)
nhưng menu children `Pe_DuyetNcc_List` / `Pe_DuyetNcc_Create` không hiển thị.
Chỉ thấy root `PurchaseEvaluations` ở Layout sidebar.

**Nguyên nhân:** `GetMyMenuTreeQuery` hardcode 2 inherit root: `Contracts` và
`Workflows`. Descendant Ct_*/Wf_* auto-inherit CRUD flags từ parent qua switch
statement. Khi thêm root mới (`PurchaseEvaluations`, `PeWorkflows`) — không có
trong switch → children mặc định (false,false,false,false) → filter
`HasAccess` hide children.

**Fix:** Extend switch + `nextInherit` propagation:

```csharp
var contractsFlags    = GetFlags(MenuKeys.Contracts);
var workflowsFlags    = GetFlags(MenuKeys.Workflows);
var peFlags           = GetFlags(MenuKeys.PurchaseEvaluations);    // NEW
var peWorkflowsFlags  = GetFlags(MenuKeys.PeWorkflows);            // NEW

// Trong BuildChildren:
if (inheritFromKey is not null && !resolved.ContainsKey(m.Key))
{
    flags = inheritFromKey switch
    {
        var k when k == MenuKeys.Contracts           => contractsFlags,
        var k when k == MenuKeys.Workflows           => workflowsFlags,
        var k when k == MenuKeys.PurchaseEvaluations => peFlags,       // NEW
        var k when k == MenuKeys.PeWorkflows         => peWorkflowsFlags, // NEW
        _ => flags,
    };
}

var nextInherit = inheritFromKey
    ?? (m.Key == MenuKeys.Contracts             ? MenuKeys.Contracts
        : m.Key == MenuKeys.Workflows           ? MenuKeys.Workflows
        : m.Key == MenuKeys.PurchaseEvaluations ? MenuKeys.PurchaseEvaluations
        : m.Key == MenuKeys.PeWorkflows         ? MenuKeys.PeWorkflows
        : null);
```

**Rule:** Khi thêm 1 root mới có child leaves (vd `PeWorkflows` → `PeWf_*`) —
PHẢI update cả 3 chỗ: (1) MenuKeys.All, (2) GetMyMenuTreeQuery GetFlags + switch,
(3) nextInherit propagation.

### 36. Vite env var embed compile-time — đổi `.env.production` phải rebuild FE

**Triệu chứng:** Đổi `VITE_API_BASE_URL=...` trong `.env.production` nhưng FE
vẫn gọi URL cũ. Hot reload không giúp.

**Nguyên nhân:** Vite inline `import.meta.env.VITE_*` tại build time vào JS
bundle (minified). File `.env*` chỉ đọc khi `vite build` — không runtime.

**Fix:** Sau đổi env:
1. Rebuild: `cd fe-admin ; npm run build`
2. Deploy dist mới lên IIS
3. Clear CDN/browser cache (Ctrl+Shift+R)

Verify bundle có URL mới: `curl dist/assets/index-*.js | grep -oE 'https://[^"]+api'`.

## Deploy / Production (continued)

### 37. PowerShell 5.1 diacritics trong script — gotcha #30 tái phát

**Triệu chứng (bis):** `migrate-domains.ps1` viết với "Phương Án", "→",
em-dash → PS 5.1 parser fail `Missing closing ''`, `Unexpected character`.

**Fix:** Luôn ASCII-only cho .ps1 — rule lặp lại gotcha #30. Cách phát hiện:
grep file cho UTF-8 multi-byte chars trước khi deploy:
```bash
grep -P '[\x80-\xff]' scripts/*.ps1
# Nếu có match → rewrite ASCII-only
```

## Email / Users

### 38. Email rename Identity user — 4 field cần update đồng thời

**Triệu chứng:** Đổi `user.Email` xong login với email mới vẫn 401. Hoặc
UserManager.FindByEmail trả null.

**Nguyên nhân:** Identity lookup qua `NormalizedEmail` (uppercase), không
`Email`. Username cũng dùng email. 4 field phải sync:

```csharp
u.Email = newEmail;
u.NormalizedEmail = newEmail.ToUpperInvariant();
u.UserName = newEmail;
u.NormalizedUserName = newEmail.ToUpperInvariant();
await userManager.UpdateAsync(u);
```

**Bonus:** Check conflict trước khi rename (user khác đã có email mới) →
skip để tránh duplicate.

### 39. act_runner v0.2.13 fetch `actions/checkout` từ github.com timeout 21s

**Triệu chứng:** Run #108/#109 fail trong **22s** với:
```
Get "https://github.com/actions/checkout/info/refs?service=git-upload-pack":
  dial tcp 20.205.243.166:443: connectex: A connection attempt failed
  because the connected party did not properly respond...
```

Test gate (Domain + Infra) chưa kịp chạy. Build/deploy không tới.

**Nguyên nhân:** act_runner mỗi run đều `git fetch` action source code từ
github.com (kiểm tra update `actions/checkout@v4`). Khi VPS → github.com
TCP có vấn đề (intermittent firewall/network), 21s timeout → toàn job fail
TRƯỚC step nào của workflow chạy.

**Fix:** Thay `uses: actions/checkout@v4` bằng manual git checkout từ Gitea
internal — bypass github.com hoàn toàn.

```yaml
- name: Checkout (manual git, bypass github.com)
  shell: powershell
  run: |
    git config --global --add safe.directory '*'
    git init -q
    git remote add origin "https://gitea-actions:${{ github.token }}@git.baocaogiaoduc.vn/${{ github.repository }}.git"
    $ref = "${{ github.ref }}"
    if ($ref -like "refs/heads/*") { $ref = $ref.Substring(11) }
    git fetch --depth=30 origin $ref
    git checkout --quiet "${{ github.sha }}"
```

Tương tự với `actions/upload-artifact@v4` — bỏ vì cũng phụ thuộc github.com.
TRX file vẫn save local trong `test-results/` cho debug.

**Long-term option:** config `github_mirror` trong gitea-runner config.yaml
mirror github.com → Gitea internal repo. Hoặc pre-cache `.cache/act/<hash>/`
manually 1 lần.

**Reference:** Run #108 commit `52999f3` fail, run #110 commit `14b7d18` fix pass.

### 40. npm junction cache `tsc not found` sau Move-Item — chưa xác định root cause

**Triệu chứng:** Implement npm cache strategy bằng junction (mklink /J) +
Move-Item node_modules → cache dir → fail `'tsc' is not recognized` ở step
`npm run build`. Log NO Write-Host "cache MISS" output, NO npm install
output. Timing 1.6s từ end-of-BE-build → start-of-fe-admin npm run build
(impossible cho npm install 49s).

**Hypothesis:**
- (A) Move-Item của `node_modules` chứa nested junctions/symlinks → .bin/
  relative paths broken sau move
- (B) act_runner PowerShell stream capture có quirk với cache MISS branch →
  output bị silenced
- (C) `Test-Path` trả về stale TRUE từ một state khác

**Workaround tạm:** Rollback về fresh `npm install` mỗi run (49s + 33s = 82s).
Path filter docs-only skip CI là alternative win lớn hơn.

**TODO khi debug session sau:**
- Thử `robocopy /MIR` thay `Move-Item` (handle symlinks tốt hơn)
- Hoặc Copy-Item với `-Force -Recurse` (slower nhưng safer)
- Hoặc dùng act_runner built-in `cache.host` server (có sẵn trong config.yaml)

**Reference:** Run #111 commit `29eb5d9` fail, rollback ở `a21790d`.

### 41. Gitea Actions `paths-ignore` — workflow file change vẫn trigger

**Triệu chứng:** Setup `paths-ignore: ['docs/**', '**/*.md']` để skip CI
khi commit MD-only. Tự nhiên commit `.gitea/workflows/deploy.yml` (chính
workflow file) cũng bị skip → không thể test workflow change.

**Nguyên nhân:** `paths-ignore` evaluate set của file thay đổi. Nếu TẤT CẢ
file thay đổi match patterns → skip. Workflow file `.gitea/workflows/**`
không trong list ignore → trigger normal. **OK behavior.**

**Edge case ngược:** commit thay đổi cả `docs/STATUS.md` + `src/Backend/...cs`
→ NOT skip vì có file ngoài ignore patterns. **Cũng OK.**

**Verify:** Commit chỉ touch `docs/STATUS.md` → check Gitea Actions UI →
phải KHÔNG có run mới trigger. Test với `curl /api/v1/.../runs/<id>`
trả `Not found` cho run-id tiếp theo.

**Pattern hiện áp dụng:**
```yaml
on:
  push:
    branches: [main]
    paths-ignore:
      - 'docs/**'
      - '**/*.md'
      - '.claude/skills/**'
      - '.gitignore'
      - 'scripts/**.md'
```

KHÔNG ignore: `.gitea/workflows/**`, `*.cs`, `*.tsx`, `*.ts`, `*.csproj`,
`*.json`, `*.slnx`, `tests/**`.

**Saving:** ~196s/commit cho ~30% commit thuộc loại docs-only (chốt MD,
session log, etc).

**Reference:** Commit `29eb5d9` add filter, verify ở commit `512880c`
(docs-only) → Gitea NO trigger run #113.

### 44. Silent 403 từ class-level `[Authorize(Policy = ...)]` quá strict (Session 18)

**Triệu chứng:** UAT 2026-05-08 — Drafter `nv.test` Workspace tạo phiếu B, dropdown "Quy trình duyệt" empty silent. Admin Designer cùng URL endpoint thấy data đầy đủ. Không có toast error / network panel hint.

**Root cause:** `ApprovalWorkflowsV2Controller` class-level `[Authorize(Policy = "Workflows.Read")]` → non-admin role (Drafter chỉ có `PurchaseEvaluations.Read`) bị 403 Forbidden khi GET `/api/approval-workflows-v2`. TanStack Query catch HTTP error trả `data=undefined`, FE component render dropdown empty không có "loading" / "error" state visible.

**Fix (`f77ea38`):** Tách policy theo action — class-level `[Authorize]` only (any authenticated), action-level chỉ POST/DELETE giữ `[Authorize(Policy = "Workflows.Create")]`:
```csharp
[ApiController]
[Route("api/approval-workflows-v2")]
[Authorize]                                   // ← any authenticated, không hardcode policy
public class ApprovalWorkflowsV2Controller(IMediator mediator) : ControllerBase
{
    [HttpGet]                                 // ← Drafter pick workflow lúc create — read-only OK
    public async Task<...> Overview(...) { ... }

    [HttpPost]
    [Authorize(Policy = "Workflows.Create")]  // ← admin Designer
    public async Task<...> Create(...) { ... }
}
```

**Pattern reusable:** Endpoint dùng cho nhiều use case (admin Designer + user list-pick) — split policy per action thay vì class-level uniform. Read-only list workflow KHÔNG nhạy cảm (chỉ là cấu hình quy trình, không expose business data).

**Phòng tránh tương lai:** Khi controller class-level `[Authorize(Policy)]`, audit role nào cần access từng action. Nếu GET cần broader role hơn POST → split policy. Default `[Authorize]` (any authenticated) cho list-pick endpoint.

**FE diagnostic improvement:** TanStack Query error nên hiển thị warning UI (toast hoặc banner) thay vì silent. Hiện tại `useQuery` catch silent → debug khó. Future: wire `onError` handler global show generic error toast.

### 45. PE "Trả về nhưng hệ thống vẫn duyệt" — FE button label vs decision payload mismatch (Session 21 turn 3)

**Triệu chứng:** UAT 2026-05-12 — User bro screenshot button labeled `← Trả lại` trong PE Workflow Panel (menu "Duyệt"), nhấn vào nhưng phiếu KHÔNG về phase TraLai — ngược lại tiến qua Cấp tiếp theo (hệ thống ghi nhận approve). User mô tả hành vi: "Trả về nhưng hệ thống vẫn duyệt".

**Root cause:** `PeWorkflowPanel.tsx` có 3 chỗ check transition type với logic KHÔNG sync giữa nhau:

- **L205-207** `isSendBack` (button label color): include cả `DangSoanThao` lẫn `TraLai` từ phase trung gian → label hiển thị `← Trả lại` đúng.
- **L64-66** `isReject` (payload `decision` gửi BE): CHỈ check `DangSoanThao`, **thiếu `TraLai`** → khi target=TraLai (98), `isReject=false` → payload `decision: 1` (Approve) thay vì `2` (Reject).
- **L247-248** dialog `isSendBack` (title + warning): CHỈ check `DangSoanThao`, **thiếu `TraLai`** → dialog title fallback `'✓ Duyệt → Trả lại'` (sai semantic) + KHÔNG hiển thị amber warning "Phiếu sẽ về Đang soạn thảo".

BE `PurchaseEvaluationWorkflowService.TransitionAsync`:
- L51 `if (decision == Reject)` branch → set Phase=TraLai correctly khi decision=Reject.
- L97 `APPROVE STEP` branch khi decision=Approve + fromPhase=ChoDuyet → `ApproveV2Async` UPSERT opinion = "đã duyệt" + advance Cấp.
- → Khi FE gửi `decision=1` (do bug `isReject`), BE đi vào nhánh APPROVE thay vì REJECT → phiếu được ghi nhận approve dù user định trả lại.

**Severity:** 🔴 CRITICAL — data integrity issue. NV nhấn "Trả lại" sẽ vô tình "duyệt" phiếu sang Cấp tiếp theo + UPSERT opinion vĩnh viễn vào `PurchaseEvaluationLevelOpinions` (Mig 26). Khó rollback vì BE đã `SaveChangesAsync`.

**Fix Chunk A (`de00887` BE defense-in-depth):**
```csharp
// PurchaseEvaluationWorkflowService.cs sau set isAdmin/isSystem (L48), trước REJECT branch (L51)
if ((targetPhase == PurchaseEvaluationPhase.TraLai
        || targetPhase == PurchaseEvaluationPhase.TuChoi)
    && decision != ApprovalDecision.Reject)
{
    throw new ConflictException(
        $"Transition tới {targetPhase} BẮT BUỘC decision=Reject (nhận {decision}). " +
        "Báo lỗi caller — payload mismatch giữa target phase và decision.");
}
```

Boundary protection cho mọi caller tương lai (API client / mobile / cron retry). 3 regression test:
- `TransitionAsync_TargetTraLai_WithApproveDecision_Throws_AndDoesNotMutateState` (bug reproduce)
- `TransitionAsync_TargetTuChoi_WithApproveDecision_Throws_AndDoesNotMutateState` (consistency cover)
- `TransitionAsync_TargetTraLai_WithRejectDecision_SetsPhaseTraLai` (happy path control)

**Fix Chunk B (`4b29d00` FE mirror 2 app):**
```typescript
// PeWorkflowPanel.tsx (fe-user + fe-admin) — 3 chỗ × 2 app

// Chỗ 1: isReject payload (line 64-66)
const isReject = target === PurchaseEvaluationPhase.TuChoi
  || (target === PurchaseEvaluationPhase.DangSoanThao
      && evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao)
  || (target === PurchaseEvaluationPhase.TraLai             // ← THÊM
      && evaluation.phase !== PurchaseEvaluationPhase.TraLai)

// Chỗ 2: dialog isSendBack (line 247-248)
const isSendBack = (target === PurchaseEvaluationPhase.DangSoanThao
                    || target === PurchaseEvaluationPhase.TraLai)   // ← THÊM
  && evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
  && evaluation.phase !== PurchaseEvaluationPhase.TraLai             // ← THÊM
```

Chỗ 3 (button label `isSendBack` L205-207) đã đúng từ S17, KHÔNG đụng.

**Pattern reusable — invariant check khi viết FE workflow transition:**
1. Button label condition (visual) phải SYNC với payload decision (semantic).
2. Dialog title/warning condition phải SYNC với button label + payload.
3. Tốt nhất: extract `isReject(target, currentPhase)` thành 1 helper FE + BE share semantic — KHÔNG duplicate logic giữa 3 chỗ.

**Phòng tránh tương lai:**
- Khi spec mới có thêm phase terminal/intermediate (vd Session 17 thêm TraLai làm Phase RIÊNG thay vì DangSoanThao revert), audit grep TOÀN BỘ logic check `=== DangSoanThao` để xem chỗ nào cần thêm `|| === NewPhase`.
- BE guard early invariant `(targetPhase ∈ terminalSet) ⇔ (decision == Reject)` thay vì trust FE payload.
- Test-before bug fix BẮT BUỘC §7 — 3 test cover bug reproduce + consistency + happy path.

**References:**
- Commit fix: `de00887` (BE Chunk A) + `4b29d00` (FE Chunk B)
- Spec Session 17: `feedback_n_stage_workflow_pattern` DEPRECATED + spec mới trong `PurchaseEvaluationWorkflowService.cs` comment L15-19
- State machine 5 trạng thái: Nháp / Đã gửi duyệt / **Trả lại (98) — Phase RIÊNG** / Từ chối / Đã duyệt

### 46. Gitea Actions API path `/tasks` not `/runs` + cache stale ~2 min (CICD Monitor S21 t4 discovery)

**Triệu chứng:** CICD Monitor sub-agent S21 t4 run đầu poll Gitea Actions runs sau push → `GET https://git.baocaogiaoduc.vn/api/v1/repos/vietreport-admin/solution-erp/actions/runs?limit=5` → **404 Not Found**. Tưởng repo không có Actions enabled hoặc API endpoint sai. Debug 10 phút retry path/auth/header trước khi tìm ra đúng path.

**Root cause:** Gitea API v1 spec dùng `/actions/tasks` (NOT `/actions/runs`). Naming khác GitHub Actions API (GitHub: `/actions/runs`). Public no-auth read OK cho repo public.

**Endpoint đúng:**
```bash
GET https://git.baocaogiaoduc.vn/api/v1/repos/vietreport-admin/solution-erp/actions/tasks?limit=5
```

**Response fields:**
- `id` (task internal ID)
- `run_number` (display number Run #N)
- `head_sha` (commit triggered task)
- `status` (queued / running / success / failure / cancelled)
- `conclusion` (final state when status=success/failure)
- `created_at`, `updated_at`
- `display_title` (commit message summary)

**Match task to commit:** Filter `head_sha == $commitSha` thay vì rely on order.

**Bonus gotcha — cache stale:** `updated_at` field caches ~2 phút sau khi deploy thật xong (act_runner ghi log final, Gitea API chưa update DB ngay). Cross-check VPS file timestamps khi cần time-sensitive verify:
```powershell
ssh vietreport-vps "Get-Item C:\inetpub\solution-erp\admin\index.html | Select LastWriteTime"
ssh vietreport-vps "Get-Item C:\inetpub\solution-erp\api\SolutionErp.Api.dll | Select LastWriteTime"
```

File LastWriteTime VPS = thực sự deploy completion time (NSSM copy + IIS recycle xong).

**Phòng tránh tương lai:**
- CICD Monitor system prompt + Bash command preset đã update sang `/actions/tasks` (saved trong MEMORY S21 t4).
- Khi setup automation script với Gitea API → đọc rõ Gitea API spec v1.20+ (`https://docs.gitea.com/api/`) thay vì assume GitHub naming.
- Time-sensitive verify (vd "deploy xong chưa, có an toàn trigger task tiếp không"): KHÔNG trust API status timestamp đơn lẻ → cross với VPS file mtime hoặc curl bundle hash live.

### 47. `.claude/agent-memory/**` paths-ignore — hypothesis disproven, preventive note cho non-.md state files (S22 discovery + S22 chốt revise)

**Triệu chứng ban đầu (em main HYPOTHESIS S22):** Cuối session flush 3-4 sub-agent MEMORY.md drift patch + commit dạng `[CLAUDE] Docs: chốt S22 ...` — em main đoán push trigger Gitea Actions full deploy ~3.5min waste vì `.claude/agent-memory/**` thiếu trong paths-ignore.

**Verify thực tế (CICD Monitor Run #193 S22 chốt — 2026-05-13 23:16):**

Hypothesis **DISPROVEN**. `paths-ignore` của workflow file đã có pattern `**/*.md` — glob này match **mọi `.md` file ở mọi độ sâu** (kể cả `.claude/agent-memory/{investigator,implementer,reviewer,cicd-monitor}/MEMORY.md`).

Push `cc8a7d3` (Docs S22 chốt + 4 agent MEMORY flush, 8 files tất cả là `.md`) → CI **correctly SKIPPED**. Run #193 cuối cùng cho code change là `b04a11a` (Mig 30 BE+FE), KHÔNG phải `cc8a7d3` Docs.

**Cross-check:** `git show --name-only cc8a7d3` → 8 files với extension `.md` only → match `**/*.md` glob → SKIPPED đúng.

**Preventive note cho future:** Nếu tương lai add **non-`.md` state files** vào `.claude/agent-memory/` (vd `archive.json`, `metrics.log`, `cache.bin`) — sẽ trigger CI vì KHÔNG match `**/*.md`. Khi đó add `.claude/agent-memory/**` vào paths-ignore explicit:

```yaml
paths-ignore:
  - 'docs/**'
  - '**/*.md'
  - '.claude/skills/**'
  - '.claude/agent-memory/**'    # ← preventive nếu add non-.md state files
```

**Severity:** Informational (hiện tại KHÔNG có vấn đề thật vì all MEMORY files là `.md`). KHÔNG cần fix `.gitea/workflows/deploy.yml`.

**Cross-ref:** Gotcha #41 paths-ignore docs-only skip pattern.

**Lesson:** Verify hypothesis qua actual Gitea API task list TRƯỚC KHI claim CI waste. Em main S22 đoán nhầm — CICD Monitor catch sai.

**References:**
- CICD Monitor Run #186 (S21 t4 2026-05-13 19:13) — first discovery
- CICD Monitor Run #187 (S21 t5 2026-05-13 20:12) — confirmed pattern + cache stale bonus
- Memory `feedback_multi_agent_setup` Plan G Trial Week 1 evidence

### 48. Multi-Changelog.Add() trong cùng SaveChangesAsync → SQLite frozen-clock tie-break → tests `OrderByDescending(CreatedAt).First()` non-deterministic (Session 25 Plan AB + Run #215 catch)

**Triệu chứng:** Plan AB Chunk A `cdfd542` add SECOND Changelog.Add() entry vào `ApplyReturnModeAsync` (cover Bug 2 — Return mode log) end-of-function. Caller `TransitionAsync:100` đã có sẵn `LogTransitionAsync` add FIRST Changelog entry (Action=Transition + ContextNote=comment chứa "không lùi được"). 2 entries cùng `SaveChangesAsync` transaction → SQLite test fixture frozen clock → CreatedAt **identical microseconds** cho cả 2 rows.

Plan M edge case tests (S23 t3) query `.OrderByDescending(c => c.CreatedAt).FirstAsync()` assert `ContextNote.Contains("không lùi được")` — sau Plan AB, SQLite tie-break non-deterministic, pick Plan AB row (EntityType=Workflow, Action=Update, ContextNote=null) → `Expected ContextNote not to be <null>` FAIL.

**CI Run #215 sha=cdfd542** test_infra FAIL 2/53 (51 PASS, 2 FAIL):
- `ApplyReturnMode_OneStep_AtStep1_ResetsToBuoc1Cap1_KeepsChoDuyet` (line 350)
- `ApplyReturnMode_OneLevel_AtStep1Level1_ResetsToBuoc1Cap1_KeepsChoDuyet` (line 308)

Test gate caught regression → deploy never reached → prod spared broken state.

**Fix Option A (chốt):** Test query filter by `Summary.Contains("Chuyển phase")` để pick đúng LogTransition entry. Plan AB BE code stays clean.

```csharp
// Trước Plan AB Chunk A2:
var changelog = await db.PurchaseEvaluationChangelogs
    .Where(c => c.PurchaseEvaluationId == pe.Id)
    .OrderByDescending(c => c.CreatedAt)
    .FirstAsync();

// Sau Plan AB Chunk A2 fix (commit 8c05947):
var changelog = await db.PurchaseEvaluationChangelogs
    .Where(c => c.PurchaseEvaluationId == pe.Id && c.Summary!.Contains("Chuyển phase"))
    .OrderByDescending(c => c.CreatedAt)
    .FirstAsync();
```

**Pattern reusable:** Khi handler/service add NEW Changelog row trong existing flow đã có LogTransition row, tests query audit table MUST filter EntityType / Action / Summary keyword **discriminator** thay vì raw OrderByDescending timestamp. Cross-ref Contract V2 test setup tương lai.

**Severity:** Major — caught by CI before prod ship, no user impact. Lesson reinforced UAT mode `feedback_uat_skip_verify` skip `dotnet test` per chunk RISK khi BE refactor > 100 LOC + signature change → em main resumed local test verify post Plan AB Chunk A2.

**References:**
- Plan AB Chunk A commit `cdfd542` (Run #215 FAIL)
- Plan AB Chunk A2 fix commit `8c05947` (Run #216 PASS)
- Memory `feedback_uat_skip_verify` lesson reinforced S25
- File: `tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceReturnModeTests.cs:304-310, 346-352`

### 49. UI dual-phase badge `fromPhase → toPhase` gây nhầm khi 3/4 Reject mode giữ Phase=ChoDuyet (Session 25 Plan AD)

**Triệu chứng:** Bro UAT 2026-05-19 Plan AC deploy: panel "Lịch sử duyệt" 6 entries TẤT CẢ hiện `Đã gửi duyệt → Đã gửi duyệt` (vì 3 mode Return OneLevel/OneStep/Assignee giữ Phase=ChoDuyet sau Mig 28 — chỉ Drafter mode set TraLai). Reject entry visually IDENTICAL Approve entry → user nhầm "Đã trả lại nhưng vẫn hiện đã duyệt".

**Fix Plan AD (commit `0aaf2df`):**

1. **Drop fromPhase → toPhase badges entirely** trong ApprovalsTab (cả `fe-user` + `fe-admin` mirror §3.9). Visual confusion gỡ bỏ.

2. **Thay bằng next-target hint parse từ comment** via helper `extractNextTargetHint(decision, toPhase, comment)`:
   - **Approve:** Summary "sang Cấp X" → "→ Cấp X", "sang Bước Y" → "→ Bước Y (Cấp 1)", "Duyệt vượt cấp" → "→ Vượt cấp tới Cấp cuối", toPhase=DaDuyet(20) → "→ Đã duyệt hoàn tất"
   - **Reject:** ContextNote "Người chỉ định" → "→ Trả về Người chỉ định (Bước X Cấp Y)" parse regex, "Người soạn thảo"/"Drafter" → "→ Trả về Người soạn thảo", "không lùi được" → "→ Không lùi được", "Trả về 1 Cấp"/"Trả về Cấp X" → "→ Lùi về Cấp X", toPhase=TuChoi(99) → "→ Từ chối hoàn toàn"

3. **Decision badge** (Plan AC `a734bf2` đã add): Duyệt emerald / Trả lại amber / Từ chối rose — phân biệt Action level KHÔNG dựa vào phase.

**Pattern reusable cross-project:** UI audit history KHÔNG nên render dual-phase badge khi state machine self-loop (e.g. ChoDuyet → ChoDuyet là advance pointer trong cùng phase, KHÔNG phải transition). Thay bằng Decision badge + semantic next-target hint parse từ structured comment.

**Severity:** UX confusion (KHÔNG functional bug). Bro UAT phản hồi sau Plan AC deploy.

**References:**
- Plan AD commit `0aaf2df` (Run #219 PASS)
- File: `fe-user/src/components/pe/PeDetailTabs.tsx:1995-2070` (ApprovalsTab + decisionBadge + extractNextTargetHint)
- Mirror: `fe-admin/src/components/pe/PeDetailTabs.tsx`

### 51. INFRASTRUCTURE seed vs DEMO seed phân biệt — `DemoSeed:Disabled` flag gate trap (Session 29 Plan B Chunk A2 Hotfix CICD)

**Triệu chứng:** Plan B Chunk A2 Implementer scaffold `SeedSampleContractWorkflowV2Async` mirror PE `SeedSampleApprovalWorkflowsV2Async` pattern — nested inside `if (!demoSeedDisabled)` branch trong `DbInitializer.cs:105-111`. Prod has `DemoSeed:Disabled=true` (Plan T S23 t10 — UAT permanent clean slate). Run #231 PASS deploy NHƯNG `QT-HD-V2-001` workflow KHÔNG seed prod. CICD Monitor (agentId a2ea2e3a) verify Stage 4 catch smoking gun log: `"DemoSeed:Disabled=true → skip workflow + contracts + PE + sample V2 seed (Plan T S23 t10 + Plan B Chunk A2 Contract V2)"`. → Drafter Workspace dropdown V2 EMPTY → V2 contract path **BLOCKED end-to-end UAT**.

**Root cause:** Implementer mirror PE pattern (which IS gated for valid reason — PE workflow A/B already seeded V1 historically) → áp dụng nguyên xi cho Contract V2 (no V1 sample existed) → V2 path không có default workflow để Drafter pick.

**Fix Hotfix CICD (`38f1c4d`):** PROMOTE `SeedSampleContractWorkflowV2Async` ra ngoài `DemoSeed` gate:
```csharp
if (!demoSeedDisabled)
{
    await SeedDemoContractsAsync(...);
    await SeedDemoPurchaseEvaluationsAsync(...);
    await SeedSampleApprovalWorkflowsV2Async(...);  // PE: gated (historical V1 sample existed)
}

// [Plan B S29 2026-05-22 Hotfix CICD] OUT of gate
await SeedSampleContractWorkflowV2Async(...);  // INFRASTRUCTURE — V2 path requires
```

**Pattern reusable — Seed classification table:**

| Category | Examples | Gate? |
|---|---|---|
| **INFRASTRUCTURE always run** | Roles, Departments, Catalogs, MenuTree, AdminPermissions, ContractTemplates, **SampleWorkflowV2 (V2 path requires)** | NO gate |
| **DEMO gated** | DemoUsers (30 sample, Plan T disabled prod), DemoContracts ([DEMO]), DemoPE ([DEMO]), SampleApprovalWorkflowsV2 (historical PE A/B legacy) | `if (!demoSeedDisabled)` |

**Decision tree khi add new Seed method:**
1. Production có cần seed này để work end-to-end không?
   - YES → INFRASTRUCTURE (no gate)
   - NO → DEMO (gated)
2. Có safe để admin edit/delete/disable không?
   - YES → INFRASTRUCTURE OK (admin sửa qua Designer khi cần)
   - NO → architect re-think (immutable seed = anti-pattern)

**Bonus — Smart Friend ROI:** CICD Monitor catch BEFORE bro UAT 401/empty experience. Cumulative pattern proven 4× S22 #44 + S25 #48 + S29 Plan B Reviewer #ApplicableType + S29 Plan B CICD #DemoSeed.

**Phòng tránh tương lai:** Khi Implementer mirror PE V2 pattern cho new module (Budget V2, Notification V2), explicit check question: "PE pattern gated/ungated, có applicable cho new module không?" — KHÔNG copy nguyên xi DemoSeed gate placement.

**References:**
- Hotfix CICD commit: `38f1c4d` (Run #232 PASS 3m33s)
- File: `src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs:105-122`
- Cross-ref: Plan T S23 t10 DemoSeed flag (gotcha implicit)
- Memory cross-ref: `feedback_demo_seed_flag_disable.md` (Plan T pattern)

### 50. Page move cross-app — `Layout.tsx` `resolvePath` staticMap missed mirror → silent sidebar drop (Session 29 Plan CA Hotfix 1)

**Triệu chứng:** Plan CA move 4 master pages từ fe-admin → fe-user (commit `06a441c` Implementer Case 2). BE `/api/menus/me` return tree đầy đủ (Master + 4 children + Catalogs sub-tree 4 children, ALL CanRead=true). Admin login eoffice → sidebar group "DANH MỤC" expand chỉ thấy "Danh mục chi tiết" (Catalogs), **3 leaf Suppliers/Projects/Departments + 4 sub-catalogs biến mất silent**.

**Root cause:** `fe-user/src/components/Layout.tsx:55-94` `resolvePath(key)` staticMap chỉ có 7 entries cho `Dashboard/Contracts/PE/Budgets/Bg_*`. THIẾU 7 entries cho master/catalog leaf mới move:
- `Suppliers`, `Projects`, `Departments`
- `CatalogUnits`, `CatalogMaterials`, `CatalogServices`, `CatalogWorkItems`

Component `MenuLeaf` line 238: `if (!path) return null` → khi `resolvePath` trả null, component returns null → React render nothing → **silent drop khỏi DOM**, không có error toast/console warn.

**Fix Hotfix 1 (`e55d96b`):** Thêm 7 entries vào fe-user `staticMap` mirror EXACT từ fe-admin `Layout.tsx:33-47`:
```typescript
const staticMap: Record<string, string> = {
  Dashboard: '/dashboard',
  // ... existing 6 entries
  Suppliers: '/master/suppliers',
  Projects: '/master/projects',
  Departments: '/master/departments',
  CatalogUnits: '/master/catalogs/units',
  CatalogMaterials: '/master/catalogs/materials',
  CatalogServices: '/master/catalogs/services',
  CatalogWorkItems: '/master/catalogs/work-items',
}
```

**Pattern reusable — 4-place mirror checklist khi Implementer Case 2 cookie-cutter copy page cross-app:**
1. ✅ Page file (`pages/<dir>/*.tsx`) — copy nguyên content
2. ✅ `App.tsx` Routes — add `<Route path="..." element={...} />`
3. ✅ `lib/menuKeys.ts` constants — mirror BE `MenuKeys.cs`
4. ⚠️ **`components/Layout.tsx` `resolvePath` staticMap** — KEY mapping → route path. **DỄ MISS** vì khác file scope với pages directory.

**Bonus:** fe-admin có `staticMap` đầy đủ từ trước Plan CA → mirror dễ. fe-user trước Plan CA KHÔNG có master keys → Implementer cookie-cutter copy page nhưng quên check staticMap diff.

**Phòng tránh tương lai:** Implementer Case 2 cross-app page move task prompt MUST list 4 places explicit. Reviewer Cat 1 wire claim verify SHOULD include: "Sidebar menu visible end-to-end test post-build".

**References:**
- Commit Hotfix 1: `e55d96b` (Run #230 PASS, fe-user bundle `DVBLmZlt` → `Dgn1iU9E`)
- File: `fe-user/src/components/Layout.tsx:55-94` (resolvePath staticMap + line 238 MenuLeaf null guard)
- Original Plan CA Chunk B commit: `06a441c` (Implementer missed point 4)
- Mirror: `fe-admin/src/components/Layout.tsx:33-53`

### 52. `qdrant-client` 1.18 xóa `search()` API — `except Exception: continue` nuốt lỗi silent → vector search luôn trả `[]` (Session 31 RAG eval diagnosis)

**Triệu chứng:** `search_memory` MCP tool chỉ trả kết quả cho queries có BM25 exact-match tốt (có tất cả token trong cùng 1 chunk). Queries dùng ngữ nghĩa / multi-hop concept → **0 results** dù Qdrant `points_count=2949` green.

**Root cause:** `qdrant-client 1.18.0` removed `QdrantClient.search()` method hoàn toàn. `retrieval.py` vẫn gọi `_qdrant.search(...)`:
```python
# OLD (broken in 1.18):
hits = _qdrant.search(
    collection_name=c,
    query_vector=query_vector,
    limit=top_k,
    with_payload=True,
)
# AttributeError: 'QdrantClient' object has no attribute 'search'
```
Exception bị `except Exception: continue` nuốt silent → `vec_results = []` mọi lúc → pipeline chỉ có BM25.

**Kết quả trước fix:** RAG eval v1.1 recall@5 = 0.455 (chỉ BM25 queries). v1.0 = 0.455 (same reason). BM25 strict AND-match: ALL tokens phải cùng chunk → multi-token query 8+ tokens fail.

**Fix (retrieval.py):**
```python
# NEW (qdrant-client 1.12+ query_points API):
resp = _qdrant.query_points(
    collection_name=c,
    query=query_vector,    # ← param renamed: query_vector → query
    limit=top_k,
    with_payload=True,
)
for h in resp.points:    # ← .points không phải iterable trực tiếp
```

**Kết quả sau fix:** recall@5 = 1.000 (11/11), avg rerank = 0.847. PASS gate.

**Phòng tránh:** Pin `qdrant-client` version trong deps (`==1.x.y`). Hoặc thêm health-check startup: `assert hasattr(_qdrant, 'query_points'), "upgrade qdrant-client"`. **KHÔNG dùng `except Exception: continue` che-mờ lỗi API** — ít nhất log warning.

### 53. Sub-agent truncation / stall pattern khi heavy MEMORY update phase end-of-task — Reviewer + CICD bị cut mid-sentence ở Update MEMORY.md step (Session 35 × 3 occurrence)

**Triệu chứng:** Sub-agent (Reviewer + CICD spawn ~100K token budget, ~30+ tool uses) chạy adversarial checks / smoke verify hoàn chỉnh, returning verdict PASS qua snippet visible, NHƯNG output bị truncate ở final "Update MEMORY.md BEFORE stop" step. Em main không nhận structured verdict đầy đủ.

**Pattern empirical S35:**
- Reviewer FE forms (1200 LOC + 60 mutation): Cat 1 "wire BE PERFECT" + 33 tool uses → truncated mid-MEMORY append
- Reviewer BE CRUD (576 LOC + 16 endpoint): "MEMORY size warning 24.6KB exceeds 24.4KB. Let me append concise entry but trim verbose..." → truncated mid-trim
- CICD Run #244 verify (FE Admin deploy): "VPS mtime cross-check confirms ship at 10:05" → stalled 600s watchdog timeout

**Root cause hypothesis:**
1. Sub-agent context window approaches limit khi cumulative tool output (Read MEMORY ~25KB initial + Read references + Bash output + grep results) + 100K spawn budget
2. MEMORY.md size ~25-31KB borderline triggers Edit/Write large operation late-stage → token overflow during streaming
3. Stream watchdog 600s timeout không recover (CICD case) — process hung internally

**Mitigation S35 verified:**
- **A. Tight brief scope** — Reviewer FE Admin spawn (~5K token brief, 4 cat tight, "concise findings only") → PASS clean 5K return không truncated. Pattern: brief budget < 8K + scope ≤ 4 cat + explicit "DO NOT curate MEMORY heavy — short append only this time"
- **B. Em main manual verify post-truncation** — Cat 2-6 grep-based verify (SHA256 diff exit code + grep count match) takes ~5 phút em main, faster than re-spawn
- **C. Curate MEMORY pre-spawn nếu > 25KB** — agent MEMORY > threshold trigger truncation risk. Em main curate proxy archive q3.md trước spawn heavy.
- **D. Avoid forcing MEMORY heavy update in agent spec** — phase "Update MEMORY.md BEFORE stop with detailed findings" → switch to "short append 1 entry FIFO most-recent-first, KHÔNG curate old"

**Cumulative occurrences S35:** 3/8 sub-agent spawn (Reviewer × 2 + CICD × 1 = 37.5% truncation rate at borderline ~25-31KB MEMORY). Heavy task + large MEMORY = correlation point.

**References:**
- S33 Implementer truncation pattern 2/3 (memory `feedback_implementer_truncation_mitigation` user-level — heavy scaffold ≥30 file)
- S35 Reviewer FE forms (Session 35 push #1 verify): output cut after Cat 1 PERFECT statement
- S35 Reviewer BE CRUD (Session 35 push #2 verify): cut mid-MEMORY trim
- S35 CICD Run #244 (Session 35 push #3 verify): stalled 600s watchdog after VPS mtime cross-check

**Phòng tránh:**
1. Tight brief scope ≤ 8K tokens cho Reviewer/CICD nếu task verifiable qua grep/diff em main
2. MEMORY pre-spawn audit: nếu > 25KB → curate proxy archive trước spawn
3. Agent spec ghi rõ "short append MEMORY only, NO curate", remove "BEFORE stop with detailed" directive khi MEMORY borderline
4. Em main backup verify Cat 2-6 manual grep nếu Reviewer truncated mid-verdict

**References:**
- AI_INFRA: `claude-rag/lib/retrieval.py` `vector_search()` function — fixed 2026-05-26 S31
- Eval run: `eval/runs/2026-05-26-baseline-v1.1-final.json`
- Diagnosis: Qdrant REST `/collections/proj_solution_erp` green (2949 points), `bm25.db` 2949 chunks → pipeline broken, not data. Confirmed via `python -c "from qdrant_client import QdrantClient; client.search(...)"` → AttributeError.

---

### 54. Anthropic API 529 Overloaded transient khi spawn sub-agent → 0 token fail (Session 37 Plan G-O3 + Session 29 cumulative)

**Triệu chứng:** Spawn Implementer/Reviewer/CICD qua Agent tool → completed status nhưng result = `API Error: 529 Overloaded` + `subagent_tokens=0` + `tool_uses=0`. Agent KHÔNG chạy gì, 0 token billed. Khác hẳn truncation (#53 — agent chạy nhưng cut output).

**Pattern empirical:** S37 FE Proposal spawn fail 529 (0 token, 218s duration = pure wait) + S29 Plan CA CICD verify fail 529 × 2 (transient Anthropic API overload window). Recurring khi Anthropic API load cao (peak hours / model release window).

**Phân biệt 529 vs #53 truncation:**
- 529 Overload: `tokens=0`, agent KHÔNG start → retry-able HOẶC em main solo fallback
- #53 truncation: agent chạy đầy đủ (~100-150K token) nhưng cut output mid-MEMORY/mid-exploration → KHÔNG retry (đã tốn token), em main grep verify manual

**Mitigation verified S37:**
- **A. Em main solo fallback** — KHÔNG retry loop (529 transient nhưng spawn lại có thể fail tiếp). Em main viết code trực tiếp reliable hơn (S37: BE 700 LOC + FE 4 file × 2 app solo sau 2 spawn fail). Proven faster than wait-retry.
- **B. Critical-path task KHÔNG để 1 agent block** — nếu task on critical path (cần ship trong session) → em main có sẵn fallback plan solo, KHÔNG block chờ agent.
- **C. Off-peak spawn** — heavy parallel spawn (3-4 agent) tránh giờ peak nếu không critical.

**Cumulative occurrence:** S29 × 2 (Plan CA CICD) + S37 × 1 (FE Proposal) = 3× across project. ~5-10% spawn fail rate observed at peak.

### 55. Sub-agent truncation mid-EXPLORATION phase (extend #53 — Session 37 Implementer BE)

**Triệu chứng:** Khác #53 (truncate mid-MEMORY end-of-task), S37 Implementer BE Proposal truncate NGAY ĐẦU ở exploration phase — return `"Now I need to look at Common Models... ICurrentUser, IDateTime..."` sau 30 tool uses, CHƯA write file nào. 150K token wasted (đọc reference + diagnose compile error mid-research).

**Root cause:** Heavy spec brief (~10K token) + agent đọc nhiều reference file (PE WorkflowService + Features + CodeSequence + Common Models) → context bloat trước khi bắt đầu write → truncate giữa research.

**Mitigation:** 
- Brief WRITE agent ≤ 8K (gotcha #53 rule A reinforced — heavy spec ~10K = quá rủi ro)
- Pre-supply reference snippets trong brief (em main đọc + paste shape thay vì để agent đọc full) → agent KHÔNG cần exploration phase tốn token
- HOẶC em main solo cho task spec phức tạp cần đọc > 4 reference file (S37 lesson: BE Proposal mirror PE = nhiều reference → em main solo reliable)

**References:** S37 Implementer BE spawn `a3afd177` (truncate mid-exploration) + memory `feedback_implementer_truncation_mitigation` (heavy scaffold ≥30 file pattern). Cumulative truncation S35 × 3 (mid-MEMORY) + S37 × 1 (mid-exploration) = 4× extend #53.

---

### 56. Sub-agent ghi MEMORY nhầm path do CWD drift (Session 42-43 × 3 occurrence)

**Triệu chứng:** Sau khi em main `cd fe-user` (PowerShell npm build) rồi spawn agent trong CÙNG message → agent (test-specialist S42, reviewer + cicd-monitor S43) ghi MEMORY vào `fe-user/.claude/agent-memory/<name>/` thay vì root `.claude/agent-memory/<name>/`. Agent KHÔNG thấy root MEMORY (CWD=fe-user) → viết lại minimal from scratch (mất history L1). Stray `?? fe-user/.claude/` untracked, dễ commit nhầm.

**Root cause:** Agent relative-path resolution dùng CWD shell hiện tại (đã drift sang fe-user do `cd` trước đó — PowerShell CWD persist cross-call). Path memory relative `.claude/...` → resolve sai gốc.

**Mitigation:**
- KHÔNG `cd` (đặc biệt PowerShell) TRƯỚC khi spawn agent. Dùng absolute path / `dotnet --project` / `git -C` / `npm --prefix` thay `cd`.
- Em main recovery: đọc stray → merge entry mới vào root MEMORY (append Recent activity) → `rm -rf fe-user/.claude` → KHÔNG stage stray.
- Stage chọn lọc (`git add <path cụ thể>`, KHÔNG `-A`) để stray + carry-over không lọt commit.

**References:** S42 test-specialist + S43 reviewer/cicd-monitor — cùng pattern, 3× recover thủ công.

---

### 57. Soft-delete entity + UNIQUE index PHẢI filter `[IsDeleted] = 0` (Session 45, ext S51)

**Triệu chứng:** Entity soft-delete (AuditableEntity) có UNIQUE index trên business key (Code / composite). Handler check trùng đã loại soft-deleted (`AnyAsync(x => x.Key == k && !x.IsDeleted)`) → định cho phép reuse slot. NHƯNG nếu DB UNIQUE index KHÔNG filter → xoá (soft) 1 row rồi tạo lại cùng key → handler PASS app-check nhưng `SaveChangesAsync` ném `DbUpdateException` (SQL Server 2627 / SQLite Error 19) → HTTP **500** (không phải Conflict sạch hay insert OK). Reachable thật: admin xoá nhầm 1 ngày lễ / mã catalog rồi nhập lại đúng.

**Root cause:** UNIQUE index mặc định tính CẢ row IsDeleted=1 → mâu thuẫn app-level `!IsDeleted` intent.

**Fix:** EF config filtered index — `e.HasIndex(x => x.Key).IsUnique().HasFilter("[IsDeleted] = 0")` (composite: `new { x.A, x.B }`). Migration DropIndex + CreateIndex(filter). SQL Server + SQLite test đều honor (bracket-quote + partial index OK).

**Đã áp sẵn:** Catalogs ×4, Contract/PE/Proposal/Budget/WorkflowApps code-unique. **Fixed S45:** Holiday `(Year,Date)` Mig 43. **Fixed S51 (Mig 45 `FilterHrmCatalogUniqueIndexesByIsDeleted`):** LeaveType + ShiftPattern + **OtPolicy** Code = **3 HRM catalog** (OtPolicy bị BỎ SÓT khỏi backlog "2 catalog" — bắt được khi grep TOÀN BỘ config). Vehicle/Driver (Mig 44) filtered day-1. ✅ test-before `HrmConfigFilteredUniqueTests.cs` (5 case, RED→GREEN).

⚠️ **EXT backlog (worktree session S51, Mig 46):** **FIX 3 (Master)** `Department`/`Supplier`/`Project` Code — CONFIRMED-reachable: AuditableEntity + GLOBAL `HasQueryFilter(!IsDeleted)` auto-ẩn soft-deleted khỏi Create check → check PASS → unfiltered index ném 500 (nghịch lý: global filter LÀM lộ bug, ngược HRM cần manual `!IsDeleted`). **SKIP 3** (audit-verified KHÔNG reachable): ContractClause (no CRUD handler — chỉ DbSet), MeetingRoom (Delete set `IsActive=false` NOT IsDeleted), EmployeeProfile (Create chặn reuse by-design — UserId ConflictException "Cần khôi phục" + EmployeeCode auto-gen atomic). Mọi bare-unique khác = composite junction / nullable-code đã `IS NOT NULL` filter / no-soft-delete.

**References:** Mig 43 `FilterHolidayUniqueIndexByIsDeleted` · `HolidayConfiguration.cs` · `HrmConfigHolidayTests.cs` Case 7 · surfaced bởi test-specialist Gap1 S45.

---

### 58. EF read-modify-write lost-update — dùng `ExecuteUpdateAsync` atomic + Serializable tx (Session 56)

**Triệu chứng:** Handler trừ/cộng counter kiểu đọc-sửa-ghi in-memory: `entity.X += n; await SaveChangesAsync()`. 2 request đồng thời (vd 2 lượt duyệt cuối 1 đơn nghỉ, hoặc admin + approver bấm cùng lúc) cùng đọc `X` cũ → cùng `+= n` → lần ghi sau đè lần trước → **mất 1 update** (quota lệch). Im lặng, không exception, không corruption — chỉ sai số. Reachable: `LeaveBalance.UsedDays` trừ phép (S43 gap, fixed S56).

**Root cause:** read-modify-write KHÔNG atomic dưới READ COMMITTED (default). EF tải value vào RAM, tính ở app, ghi lại — cửa sổ race giữa SELECT và UPDATE.

**Fix (proven S56, NO migration):** atomic server-side increment — `db.Set.Where(pred).ExecuteUpdateAsync(s => s.SetProperty(b => b.X, b => b.X + n), ct)`. EF Core 7+ phát `UPDATE SET X = X + @n` 1 lệnh atomic dưới row-lock → 2 increment đồng thời serialize, zero lost-update, BẤT KỂ isolation. ⚠️ `ExecuteUpdate` **bypass change tracker** → tracked instance giữ value CŨ; KHÔNG đọc lại entity đó (dùng `.AsNoTracking()` re-query / `ChangeTracker.Clear()`), KHÔNG thêm `entity.X += n` (double-count). Bọc trong explicit `BeginTransactionAsync(IsolationLevel.Serializable, ct)` để (a) atomic với các write khác cùng handler, (b) serialize nhánh auto-create row mới (2 insert cùng key). Convention codebase = Serializable (codegen `WorkflowAppCodeGen:34`, ProposalFeatures, TravelVehicle).

**References:** `LeaveOtApprovalFeatures.cs:354-405` (ApproveLeaveRequestHandler terminal DaDuyet) · `LeaveBalanceTests.cs` (TwoSeparateRequests accumulate test) · database-agent design S56 (DB11) · surfaced bởi `pre-golive-verify` workflow.

### 59. PowerShell 5.1 vỡ `git commit -m` khi message chứa `"` — dùng `git commit -F <file>` (Session 57bis)

**Triệu chứng:** `git commit -m @'...here-string có "quote kép"...'@` qua PowerShell 5.1 → git báo `error: pathspec 'án' did not match any file(s) known to git` — message bị CẮT tại dấu `"` đầu tiên, phần sau git hiểu thành pathspec args. Commit KHÔNG được tạo. Dễ tưởng nhầm lỗi Unicode tiếng Việt (không phải — tiếng Việt OK nếu không có `"`).

**Root cause:** here-string single-quote giữ literal ĐÚNG ở tầng PowerShell, nhưng khi gọi native exe (git.exe), PS 5.1 rebuild command-line và escape `"` không chuẩn (legacy native-arg passing) → `"` trong arg phá vỡ arg boundary.

**Fix (proven S57bis):** message dài / nhiều dòng / chứa quote → Write tool ghi file UTF-8 (vd `%TEMP%\commit_msg.txt`) → `git commit -F <file>`. Tránh hẳn native-arg escaping. Message 1 dòng không quote thì `-m` vẫn OK. Cùng họ bài thuốc "file payload" của #8 (Unicode CLI).

**References:** S57bis commit `dd117b7` (lần 1 `-m` fail pathspec — commit 1 cùng batch KHÔNG có `"` nên pass; lần 2 `-F` PASS) · họ hàng #30/#37 (PS 5.1 encoding class).

### 60. Identity seed `CreateAsync` silent-fail vs prod password policy — population Dev ≠ prod, lock/seed-by-email phải dump data thật (Session 58)

**Triệu chứng:** `LockDemoSampleUsersAsync` (S57bis) ship + chạy trên prod nhưng locked=0 — NO-OP hoàn toàn. 14 email hardcode (`bod.huynh@`...) không tồn tại trên prod; demo user thật trên prod là 20 account UAT-matrix scheme khác (`bod.1@`, `pm.nv@`... tạo TAY qua admin UI 05-13, chưa từng nằm trong code). Test xanh + deploy PASS + health 200 — không gì báo lỗi.

**Root cause (2 tầng):**
1. **Seed silent-fail:** `DemoUserPassword = "User@123456"` (11 ký tự) < prod `Identity:Password:RequiredLength=12` (appsettings.Production.json; Dev fallback 8) → `userManager.CreateAsync` trả `IdentityResult.Failed` — seed code chỉ `LogWarning` + `continue` (by-design 1-fail-không-abort) → **named-person user CHƯA BAO GIỜ được tạo trên prod**, luôn cả `nv.cao`/`nv.truong` (IT pool — chính là root cause "helpdesk inert phòng IT 0 user" S56) + 5 real staff.
2. **Lock-by-email viết theo population Dev:** author đọc seed code (Dev truth) thay vì dump prod Users → list lệch hoàn toàn.

**Fix (S58 `5998163`):** (a) `DemoUserPassword` → `"User@1234567"` (12 ký tự, thỏa policy mọi env); (b) lock list = union 14 Dev-population + 20 prod-population (exact-email, KHÔNG pattern — `binh.le@` là người thật sát scheme demo).

**Phòng tái diễn:** (1) Mọi thao tác theo-email trên user (lock/deactivate/migrate) → **dump bảng Users env đích TRƯỚC** khi viết list; assertion trả 0/`-1` ⟹ nghi data-mismatch trước khi nghi code. (2) Seed tạo user → password const phải thỏa policy NGHIÊM NHẤT mọi env (prod 12). (3) `IdentityResult` không throw — grep `LogWarning` sau deploy có user-seed mới.

**References:** `DbInitializer.cs` `DemoUserPassword` + `LockDemoSampleUsersAsync` · `DependencyInjection.cs:67` (RequiredLength fallback 8) · `appsettings.Production.json:18` (12) · cicd-monitor Run #381 entry (phát hiện PARTIAL) · investigator-codebase recon S58.

---

### 61. sqlcmd chạy file .sql chứa tiếng Việt PHẢI `-f 65001` — console mojibake là display-only nhưng thiếu flag thì DATA hỏng thật (Session 59)

**Triệu chứng:** Output sqlcmd trên VPS hiện `H? th?ng trung th?`, `Ph?n th�` — không phân biệt được "console hiển thị sai" (vô hại) với "data trong DB đã hỏng" (thảm họa, nhất là với UPDATE/INSERT chứa nvarchar tiếng Việt).

**Root cause 2 lớp:** (1) Console VPS codepage OEM → mọi output Unicode đều mojibake = **display-only**, data vẫn đúng. (2) NHƯNG sqlcmd mặc định cũng đọc **input file** theo OEM codepage — file `.sql` UTF-8 no-BOM (như Write tool tạo) chứa `N'Bê tông'` sẽ bị decode sai → **ghi vào DB chuỗi hỏng THẬT**. Hai hiện tượng nhìn giống hệt nhau trên console.

**Fix + phòng tái diễn (S59 proven):** (1) Mọi lệnh `sqlcmd -i <file>` với file chứa tiếng Việt → thêm **`-f 65001`** (đọc input UTF-8). (2) **KHÔNG kết luận data đúng/hỏng từ console VPS** — verify độc lập: chạy cùng file trên LocalDB local (console local hiện tiếng Việt đúng) + curl API prod đọc JSON (UTF-8 end-to-end). S59 rename 71 WorkItems: console VPS mojibake nhưng API JSON trả `"2 MEP Sub Hệ thống trung thế"` nguyên vẹn → data đúng.

**References:** `scripts/s59-rename-workitems-pmh.sql` (run với `-f 65001`) · verify pattern: login admin → GET `/api/catalogs/work-items` JSON.

---

### 62. Đổi mã (natural-key) của data có seed per-code idempotent — PHẢI UPDATE DB prod TRƯỚC khi deploy seed mới, sai thứ tự = nhân đôi rows (Session 59)

**Triệu chứng tiềm năng (đã né):** Seed kiểu `SeedRealMasterDataAsync` so khớp theo `Code` (`if (existingCodes.Contains(code)) continue`). Đổi 71 mã WorkItems `VT-01→MAT-1`… nếu **push code trước**: app restart chạy seed với tuples MỚI → thấy 71 mã mới "chưa tồn tại" (DB còn mã cũ) → INSERT 71 row mới = **142 rows trùng tên khác mã** trên prod.

**Quy trình đúng (S59 proven, Run #276 verify 71 — không phải 142):**
1. Viết SQL `UPDATE … SET Code=mới, Name=mới WHERE Code=cũ` (**GIỮ NGUYÊN Id** — record đang được trỏ tới không gãy; phiếu PE/2026/A/001 vừa tạo vẫn đúng hạng mục).
2. Chạy SQL trên **prod + LocalDB Dev** trước.
3. Sửa seed tuples trong code → commit + push SAU. App restart: seed thấy đủ mã mới → add 0.
4. cicd verify count ĐÚNG N (N×2 = FAIL nghiêm trọng — đưa vào prompt verify).

**Tổng quát:** idempotent-by-natural-key chỉ an toàn khi key bất biến — mọi rename key phải đổi DB và seed "cùng khoảnh khắc" theo thứ tự **DB-trước-code-sau** (với CI auto-deploy-on-push).

**References:** `scripts/s59-rename-workitems-pmh.sql` · `DbInitializer.cs` SeedRealMasterDataAsync · gotcha #57 họ hàng (soft-delete vs UNIQUE filtered) · Run #276 cicd verdict.

### 63. EF scaffold tự đoán `RenameColumn` SAI SEMANTICS khi drop + add cột cùng type — review migration trước khi tin, test xanh KHÔNG bắt được (Session 61)

**Triệu chứng (đã né):** Mig 50 drop `BudgetManualAmount` + add `BudgetPeriodAmount`/`ExpectedRemainingAmount` (đều `decimal(18,2)` nullable) → `dotnet ef migrations add` scaffold sinh `RenameColumn(BudgetManualAmount → ExpectedRemainingAmount)`. Nếu tin scaffold: số ngân sách nhập tay của 8 phiếu UAT prod rơi vào cột "Giá trị thực hiện dự kiến còn lại" (row 8) thay vì "Ngân sách kỳ này" (row 3) — **data đúng chỗ SAI semantics, không lỗi runtime nào báo**.

**Root cause:** EF model-diff heuristic map cột-bị-xóa ↔ cột-mới cùng type/nullability thành RENAME (tối ưu giữ data) — máy không biết semantics nghiệp vụ.

**Fix đúng (S61 proven, prod-verified Run #285):** bỏ RenameColumn → `AddColumn` cả 2 cột mới → `Sql("UPDATE ... SET BudgetPeriodAmount = BudgetManualAmount WHERE ... IS NOT NULL")` → `DropColumn` cột cũ. Precedent cùng shape: Mig `20260513130144` (add→backfill→drop).

**Vì sao test không bắt:** SQLite test dựng schema từ MODEL hiện tại (`EnsureCreated`), KHÔNG chạy migration — mọi sai trong migration operations vô hình với 263 test xanh. Guard duy nhất = đọc file migration sau scaffold + cicd spot-check data sau deploy.

**References:** `Migrations/20260612173224_ReplaceBudgetModuleWithPeWorkItemBudgets.cs` Up() comment đầu · reviewer S61 verify "KHÔNG còn RenameColumn" grep · gotcha #64 (cặp đôi — backfill chưa test local).

### 64. `dotnet ef database update` áp lên **Design DB** (DesignTimeDbContextFactory) — KHÔNG phải runtime Dev DB; Sql() data-migrate trong Up() có thể CHƯA TỪNG chạy trên data thật trước prod (Session 61)

**Triệu chứng:** claim "Mig 50 applied local OK" sau `dotnet ef database update` — đúng nhưng là DB `SolutionErp_Design` (pin tại `DesignTimeDbContextFactory.cs:14`, **0 rows**). Runtime `SolutionErp_Dev` (appsettings.Development.json) vẫn ở Mig 49. Nguy hiểm thật: mọi `Sql()` backfill/UPDATE trong Up() **chưa từng chạy trên DB có data** — prod deploy là lần ĐẦU TIÊN data-migrate chạy thật.

**Cơ chế 2 DB:** `dotnet ef` CLI → DesignTimeDbContextFactory → Design DB · runtime app start → `DbInitializer.cs:64 MigrateAsync()` tự heal Dev/prod DB. 2 đường KHÁC NHAU, đừng lẫn.

**Guard (S61 áp dụng):** migration có data-migrate ⟹ (1) ghi rõ trong commit "backfill lần đầu chạy trên prod"; (2) cicd-monitor brief BẮT BUỘC mục DATA-PRESERVE spot-check sau deploy (S61: `SELECT MaPhieu, BudgetPeriodAmount ...` → 8/8 phiếu giữ số, gồm phiếu 1.243.820.600 đ của anh Kiệt ✓); (3) optional: `dotnet run` local 1 lần trước push để Dev DB có data chạy thử backfill.

**Credit:** 🟥 reviewer S61 catch (đào connection-string mới lộ — "claim applied-local trên DB 0-rows = backfill untested"). Họ hàng S53 database-agent catch "committed-but-unapplied-local".

**References:** `DesignTimeDbContextFactory.cs:14` · `DbInitializer.cs:64` · reviewer S61 MINOR #1 · cicd S61 self-verify BACKFILL 8/8.

---

### 65. Build csproj con (vd `SolutionErp.Api.csproj`) ≠ `dotnet build SolutionErp.slnx` (gồm tests) — đổi chữ ký record command lọt test-compile → CI CS7036 FAIL-gated (Session 65)

**Triệu chứng:** thêm `ParentId` làm tham số positional thứ 5 BẮT BUỘC vào `CreateDepartmentCommand`; build local `dotnet build src/Backend/SolutionErp.Api/SolutionErp.Api.csproj` PASS → push → **CI test-gate FAIL CS7036** Run #291 ("no argument for required parameter 'ParentId'") vì `MasterCatalogFilteredUniqueTests.cs:63` còn gọi 4-arg. Deploy bị chặn (prod NGUYÊN — test-gate làm đúng việc), tốn 1 vòng FAIL→fix→re-push.

**Cơ chế:** `Api.csproj` (và mọi csproj con `src/Backend/*`) KHÔNG reference `tests/*` → build nó KHÔNG compile tests. CI chạy `dotnet build SolutionErp.slnx` / `dotnet test` = build CẢ tests → bắt call-site cũ. Đổi chữ ký record command/DTO/entity ctor = **spec change** (CLAUDE.md §7 "spec change = sửa test cũ + code chung commit").

**Guard:** đổi chữ ký BE ⟹ (1) `dotnet build SolutionErp.slnx` FULL (gồm tests) TRƯỚC push, KHÔNG build csproj con lẻ; (2) grep repo-wide `new <Command>(` (gồm `tests/`) sửa mọi call-site; (3) cân nhắc **trailing optional `= null`** (như `CreatePurchaseEvaluationCommand +HoSoLink` Mig 52 — backward-compat, 0 call-site break) đối lại positional-required (`CreateDepartmentCommand +ParentId` — vỡ test).

**Credit:** 🟩 cicd-monitor S65 Run #291 FAIL-detect (reproduced local CS7036) → em main fix `MasterCatalogFilteredUniqueTests.cs:63` +5th arg `null` → `6ce5803` → Run #292 PASS.

**References:** `MasterCatalogFilteredUniqueTests.cs:63` · `DepartmentFeatures.cs` (CreateDepartmentCommand) · Run #291 FAIL / #292 PASS · CLAUDE.md §7.

---

### 66. Tailwind v4 — rule element thô `h1-h4{color}` viết NGOÀI `@layer` thắng MỌI utility `text-white` → heading không đổi màu; fix ĐIỂM bằng important `text-white!`, KHÔNG move `@layer` (load-bearing) (Session 68)

**Triệu chứng:** Header chi tiết NV (`app-gradient-brand` nền gradient xanh) — `<h2 className="text-white">` render **ĐEN `#0b1220`**, trong khi `<div className="text-white">` (dòng meta cùng banner) render trắng đúng. Anh UAT báo "màu đen nhìn không nổi bật" — chính xác, dù code ghi `text-white`.

**Cơ chế:** `fe-user/src/index.css:79` có `h1,h2,h3,h4 { color:#0b1220 }` viết **thô, NGOÀI mọi `@layer`** (thêm S65 #290 "darker ink"). Tailwind v4: CSS unlayered có priority **CAO HƠN tất cả `@layer`** (kể cả `utilities`) → `text-white` (trong layer utilities) THUA. `<div>` không bị rule element này nhắm nên `text-white` thắng → nghịch lý meta-trắng / heading-đen cùng 1 banner.

**Guard:** (1) **User báo màu render ≠ class trong code ⟹ NGHI ngay rule CSS priority cao hơn** (unlayered element / `!important` / global) — grep `index.css` rule element TRƯỚC khi tin utility. (2) Ép utility thắng = **important modifier Tailwind v4 trailing-bang** `text-white!` → compile `.text-white\!{color:…!important}` (**verify bằng grep dist CSS** — đừng tin suông). (3) **KHÔNG move rule unlayered vào `@layer base` nếu load-bearing** — đo blast-radius `grep '<h[1-4][^>]*text-'`: rule này đang ép ~30+ heading toàn app về `#0b1220` (chúng gắn `text-slate-700/900/brand-800` nhưng bị override) → move = đổi màu loạt giữa UAT. (4) Authed page không screenshot dev-rig (gotcha #3) → visual gate = anh mắt thường qua deploy prod.

**Credit:** em main solo S68 — anh UAT realtime ("chữ đen nền xanh ko nổi bật") → diagnose unlayered heading rule → `text-white!` + thu nhỏ `text-lg` → grep dist confirm `!important` → Run #304 PASS, x2 app SHA256.

**References:** `fe-user/src/index.css:79-83` (h1-h4 unlayered) · `fe-{user,admin}/src/pages/hrm/EmployeesListPage.tsx:653` (h2 name `text-white!`) · Tailwind v4 layer precedence · gotcha #3 (authed screenshot).

---

### 67. Tailwind v4 — accent palette tự-chế thiếu stop (chỉ 50/100/500/600/700) → dùng `-300` "vỡ màu im lặng": tên-trùng-built-in rơi DEFAULT, tên-tự-chế drop hẳn; build PASS (Session 69)

**Triệu chứng:** Component `KpiCard` tái dùng có `activeBorder: 'border-{accent}-300'` cho 5 accent. Build PASS 0 error, nhưng active-border render SAI: teal/violet ra tone teal/violet MẶC ĐỊNH Tailwind (khác hệ custom #0ea5a4...), amberx/greenx KHÔNG có border (class drop). brand-300 OK (brand full 50-900).

**Cơ chế:** `@theme` SE định nghĩa accent palette (teal/violet/amberx/greenx) CHỈ ship `50/100/500/600/700` (brand ngoại lệ full). Tailwind v4: `border-teal-300` — `teal` TRÙNG tên built-in → emit DEFAULT teal-300 (#5eead4, khác --color-teal-*); `border-amberx-300` — `amberx` TỰ CHẾ không có --color-amberx-300 → class drop, không emit. Cả 2 **build KHÔNG báo lỗi** (Tailwind không validate color tồn tại) → phải SOI dist CSS.

**Guard:** (1) Component tái dùng accent-aware CHỈ dùng stop trong "hợp đồng chung" mọi accent (50/100/500/600/700) — đừng mượn stop chỉ brand có. (2) Nghi "vỡ màu im lặng" → grep dist CSS class tồn tại + đúng `var(--color-...)`. (3) Tên tự-chế (amberx/greenx) AN TOÀN hơn tên-trùng-built-in (teal/violet): miss = drop hẳn (dễ thấy) thay vì rơi-default-sai-tone (ẩn). (4) reviewer dimension "color-trap" = grep added-lines `(teal|violet|amberx|greenx)-(200|300|400|800|900)`.

**Credit:** reviewer S69 (soi dist Office foundation) → em main fix `activeBorder -300 → -500` ×2 app SHA256.

**References:** `fe-{user,admin}/src/components/ui/KpiCard.tsx` ACCENT map · `fe-*/src/index.css` @theme accent stops · gotcha #66.

---

### 68. IDE TypeScript diagnostic giữa background-agent/workflow = snapshot DỞ-DANG — chỉ tin build SẠCH chạy SAU agent xong (Session 69)

**Triệu chứng:** Sau workflow re-skin 7 designer song song (+ sau PE-FE agent), harness bắn loạt `× 'X' is declared but never read` / `× Type 'Element' not assignable`. Em suýt sửa theo. Build `npm run build` chạy SAU → **exit 0, 0 error** cả 2 app. Các × đều FALSE-ALARM.

**Cơ chế:** IDE TS language-server bắn diagnostic theo TỪNG lần save dở-dang của agent (thêm import/state TRƯỚC khi render JSX dùng nó → "unused"; xóa JSX TRƯỚC khi gỡ import → "unused"). Nhiều agent ghi SONG SONG → snapshot càng nhiễu, KHÔNG phản ánh trạng-thái-cuối nhất-quán nào.

**Guard:** (1) Agent/workflow NỀN: **bỏ qua diagnostic giữa-chừng**, tín hiệu thật = 1 lần build SẠCH chạy SAU agent hoàn-tất (lý do em main build-tập-trung). (2) Đừng vội sửa/cp-đè theo diagnostic — suýt cp đè file vốn đã hoàn chỉnh (2×). (3) Phân biệt diagnostic ai-edit: em main edit real-time → tin; agent nền edit → verify build trước. (4) Cùng họ gotcha #53 (return truncated): disk + build = source-of-truth, KHÔNG tin return/diagnostic suông.

**Credit:** em main S69 — 2× suýt sửa theo stale-diagnostic (re-skin + PE-FE), build sau-cùng vạch false-alarm.

**References:** workflow `office-puro-reskin-all` + PE-FE implementer-frontend · gotcha #53 · gotcha #3.

---

### 69. FE bundle hash KHÔNG deterministic + `deploy.yml` rebuild FE vô-điều-kiện mỗi run → bundle ROTATE kể cả commit BE-only/governance (Session 72)

**Triệu chứng:** Commit governance-only (0 FE source change, `git diff fe-*/src <range>` = 0 file) push → CI Run #312 → bundle admin+user ĐỀU ROTATE (`BgNCjwsG`/`CBvh0vtf` → `fc_xkNpJ`/`DP-tBcg0`). Suýt báo "FE thay đổi ngoài ý muốn"; STATUS/HANDOFF/commit đã claim nhầm "bundle frozen".

**Cơ chế:** (1) `.gitea/workflows/deploy.yml` path-filter gate CẢ workflow (chạy/skip toàn-bộ), KHÔNG gate từng step → 1 file non-ignored (vd `.claude/workflows/hmw.js`) đủ trigger → FE rebuild + deploy CHẠY HẾT. (2) Step deploy (`deploy.yml:161-167`) `Remove-Item fe-*\* + Copy-Item dist\*` VÔ-ĐIỀU-KIỆN mỗi run. (3) Vite/rolldown emit content-hash KHÔNG deterministic từ source y-hệt → hash mới mỗi build. ⟹ "BE-only ⟹ bundle frozen" là TRÙNG-HỢP quá-khứ, KHÔNG phải cơ-chế. Bundle rotate = EXPECTED mỗi deploy.

**Guard:** (1) Phát-hiện FE-ship THẬT = `git diff fe-admin/src fe-user/src <range>`, KHÔNG tin hash-delta. (2) **SPA-fallback 200 trap:** `GET /assets/index-<fake>.js` trả 200 (qua `/*`→index.html rewrite) → verify bundle = parse `index.html` refs + check size + `Last-Modified`, KHÔNG GET trực-tiếp asset hash-named. (3) Cosmetic-only, KHÔNG rollback; muốn hết false-alarm → thêm `.claude/**` vào `paths-ignore` (governance commit khỏi trigger deploy).

**Credit:** cicd-monitor S72 Run #312 — flagged-then-resolved bundle rotation trên governance commit `18fced6`; lật ngược invariant "BE-only frozen".

**References:** `.gitea/workflows/deploy.yml:17-30,161-167` · gotcha #41 (path-filter) · gotcha #46 (stale SHA).

---

### 70. FE absolute-set form echo field anh-em từ server-snapshot + `invalidate()` fire-and-forget → cửa-sổ STALE-ECHO mất dữ-liệu khi lưu 2 ô liên-tiếp (Session 76)

**Triệu chứng:** Form ngân sách PE — 2 ô cùng cột PRO (ban hành + hiệu chỉnh) lưu qua CÙNG `proMut`, mỗi save echo field anh-em từ `bs` (server snapshot). Lưu ô A (proInitial) → `invalidate()` refetch BẤT-ĐỒNG-BỘ; TRƯỚC khi refetch về, `bs.proInitial` còn CŨ → lưu ô B (proAdjust) echo `bs.proInitial` cũ → **đè mất giá-trị A**. Workflow review (reviewer) bắt — implementer self-review SÓT.

**Cơ chế:** BE handler absolute-set (set thẳng cả 3 field, thiếu=null=CLEAR) → FE PHẢI echo field không-đổi. Echo lấy từ `bs` (props từ query) — STALE trong cửa-sổ [mutate-resolve → refetch-land]. `mut.isPending` chỉ true trong HTTP mutate, KHÔNG cover refetch → nút Lưu re-enable khi `bs` vẫn cũ.

**Guard:** Gate nút Lưu = `mut.isPending || useIsFetching({queryKey:['pe-detail',ev.id]}) > 0` — khoá save tới khi refetch land (`bs` fresh). Đóng cửa-sổ stale-echo. Áp CẢ PRO+CCM cells + notes. (Pattern absolute-set-echo có từ CCM Mig 50/55 prod chưa-báo-lỗi nhưng PRO nhân-đôi bề-mặt → vá trước go-live tài-chính.)

**Credit:** reviewer S76 (2-lane workflow review Part 2/3) escalate MAJOR data-loss; em-main vá `useIsFetching` ×2 app trước deploy.

**References:** `fe-*/src/components/pe/PeDetailTabs.tsx` (PeBudgetSummaryTable `peFetching`) · gotcha #44 (silent issue self-review bias).

---

### 71. Thêm enum value vào entity DÙNG-CHUNG → pollute UI/guard phân-loại theo PROXY-predicate (không phải enum) (Session 78)

**Triệu chứng:** Thêm `PurchaseEvaluationAttachmentPurpose.ApprovalAttachment=5` (file người duyệt đính kèm khi DUYỆT — tái dùng entity `PurchaseEvaluationAttachment`, migration-free vì enum lưu int). File mới có `PurchaseEvaluationSupplierId=null`. NHƯNG 2 chỗ FE dùng `supplierId === null` làm PROXY ngầm cho "Bảng so sánh": (1) `banSoSanhAttachments` → file-khi-duyệt LẪN vào section Bảng so sánh; (2) submit-guard "Chưa đính kèm Bảng so sánh" → file-khi-duyệt FALSE-PASS guard (cho gửi-duyệt mà chưa có bảng so sánh thật, khi phiếu Trả-lại re-submit có sẵn file vòng trước).

**Cơ chế:** Reuse-enum migration-free RẺ (0 schema change, tái dùng storage+endpoint+component), NHƯNG predicate cũ phân-loại bằng FIELD-PROXY (`supplierId===null` = "không gắn NCC" ≈ "bảng so sánh") thay vì check enum tường minh → giá-trị enum MỚI rơi nhầm bucket. Build PASS (TS không bắt logic-bucket) + test xanh (FE-filter no xUnit) → CÂM.

**Guard:** Khi thêm enum value vào entity dùng-chung → **grep MỌI predicate lọc bằng field-proxy** (không phải enum) liên-quan, loại-trừ value mới: `supplierId===null && purpose !== ApprovalAttachment`. Áp cả 2 app SHA-mirror. Kiểm thêm: per-supplier list (lọc `supplierId===s.id`) tự loại (value mới supplierId=null); GET bundle projection KHÔNG `.Where(purpose)` → value mới tới được FE.

**Credit:** em-main self-review (grep toàn-bộ `.purpose` / `supplierId===null` 2 app) bắt TRƯỚC deploy — thay vai reviewer (over-cap). Build fe-admin riêng cũng bắt mirror-truncate `</Dialog>` sót (new_string copy-truncate) → **build-verify TỪNG app, KHÔNG tin "mirror identical"**.

**References:** `fe-*/src/components/pe/PeDetailTabs.tsx` (banSoSanhAttachments + missingForApproval) · `PeWorkflowPanel.tsx` (approvalAttachments) · `src/Backend/.../PurchaseEvaluations/PurchaseEvaluationAttachment.cs` enum · gotcha #70 (self-review bias).

---

### 72. DateTime serialize KHÔNG có 'Z' (EF đọc `Unspecified` kind) → FE `new Date()` hiểu là local → lệch +7h (Session 85)

**Triệu chứng:** anh Kiệt "mới tạo phiếu mà báo cách 7h trước" — relative-time thông-báo (`Date.now() - new Date(createdAt).getTime()`) lệch đúng +7h (VN UTC+7).

**Cơ chế:** timestamps lưu UTC (`DateTime.UtcNow`) nhưng EF Core đọc SQL Server `datetime2` về `DateTimeKind.Unspecified`. `AddControllers()` mặc-định (KHÔNG JSON config) → System.Text.Json serialize DateTime Unspecified-kind **KHÔNG có hậu-tố 'Z'** (`"2026-06-23T08:20:00"`). FE `new Date(naive)` hiểu chuỗi-không-TZ là **giờ LOCAL** → nhưng giá-trị là UTC → lệch +7h. Build PASS + test xanh (serialization không unit-test) → CÂM.

**Guard:** global `JsonConverter<DateTime>` ép UTC-with-Z: `Kind==Unspecified ? SpecifyKind(Utc) : ToUniversalTime()` → `.ToString("O")`. Đăng-ký `AddControllers().AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new UtcDateTimeJsonConverter()))`. System.Text.Json tự áp cho `DateTime?` (Nullable non-null). **Verify:** curl authed endpoint → field `createdAt` JSON CÓ 'Z'. Lưu-UTC + convert-on-display (FE) là CHUẨN — KHÔNG band-aid `tzutil` đổi giờ máy-chủ (ảnh-hưởng GETUTCDATE + lệch khác). SignalR realtime-push dùng serializer RIÊNG (`AddJsonProtocol`) → nếu giờ realtime còn lệch thì áp converter tương-tự.

**References:** `src/Backend/SolutionErp.Api/Serialization/UtcDateTimeJsonConverter.cs` · `Program.cs` AddControllers · `fe-user/src/components/NotificationBell.tsx:23`.

---

### 73. UPDATE-handler absolute-set field → CLEAR khi FE KHÔNG gửi field (bug-class S42 recurrence) (Session 85)

**Triệu chứng:** phiếu tạo CÓ Quy trình duyệt → bấm "Sửa" (đổi tên/dự án) → MẤT workflow → kẹt submit "chưa chọn quy trình duyệt" (**whack-a-mole**: SQL-fix vá xong, ai bấm Sửa lại rụng tiếp). long.chau + anh Kiệt + nhiều account.

**Cơ chế:** `UpdatePeDraftHandler` absolute-set `entity.ApprovalWorkflowId = request.ApprovalWorkflowId`. Form Sửa (PeHeaderForm) KHÔNG gửi `approvalWorkflowId` → command bind `null` (default `Guid? = null`) → handler set `= null` = **CLEAR**. Field SÓT khỏi pattern null-safe (`if (request.X is not null)`) mà `BudgetPeriodAmount`/`ExpectedRemainingAmount`/`WorkItemId` NGAY DƯỚI đã có (comment "client không gửi → GIỮ giá trị cũ" — bug-class S42). 1 field lọt khỏi quy-ước đã-thiết-lập.

**Guard:** MỌI field optional trong UPDATE-handler PHẢI null-safe `if (request.X is not null) entity.X = request.X` (preserve khi không gửi); CHỈ field text-clearable CỐ-Ý (TenGoiThau/DiaDiem/MoTa) mới absolute-set. Khi thêm field vào UPDATE command → **đối-chiếu pattern null-safe của field cạnh**. Triệu-chứng "vá-điểm xong TÁI-DIỄN" = nghi field-clear-on-omit, truy root KHÔNG vá-data lặp (root-cause-over-symptom).

**References:** `src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs:~282` (UpdatePeDraftHandler) · `fe-user/src/components/pe/PeHeaderForm.tsx:112` (PUT no-workflow) · bug-class S42 (BudgetPeriodAmount null-safe S61) · `tests/.../PeUpdateDraftWorkflowPreserveTests.cs`.

### 74. MCP `save_to_disk` (Chrome MCP + computer-use) KHÔNG tạo file truy-cập được trong env này → chụp ảnh prod cho docs bằng PowerShell `CopyFromScreen` (Session 90)

**Triệu chứng:** Cần chụp 12 màn prod (eoffice) khoanh-đỏ chèn vào Word guide. `mcp__Claude_in_Chrome__computer {action:screenshot, save_to_disk:true}` + `mcp__computer-use__screenshot {save_to_disk:true}` đều báo "Successfully captured" + ID nhưng **KHÔNG in path + KHÔNG tạo file nào tìm thấy được** (search TEMP/LOCALAPPDATA/APPDATA/Downloads/session-dir = rỗng) → PIL/python-docx không có file ảnh để chèn. `gif_creator` cũng vô-dụng (ảnh tĩnh = 0 frame).

**Cơ chế:** `save_to_disk` chỉ đính ảnh vào hội-thoại cho user xem, KHÔNG persist ra path mà lead truy-cập được (harness/env này).

**Fix — pipeline tự-chế (ra PNG thật):**
1. **Chụp:** PowerShell .NET `[System.Drawing.Graphics]::CopyFromScreen` (sau `SetProcessDPIAware`) → PNG full-res (2560×1600, DPR=1). Đưa đúng cửa-sổ Chrome lên trước bằng Win32: `EnumWindows`+`GetWindowText`+`GetWindowThreadProcessId` lọc **process `chrome.exe`** + `SetForegroundWindow` + ALT-tap unlock-foreground. ⚠️ Lọc theo title "Solutions ERP" KHÔNG đủ — **app Claude Code (Electron) cùng class `Chrome_WidgetWin_1` + title chứa tên project** → khớp nhầm; PHẢI lọc process-name = `chrome`.
2. **Khoanh đỏ:** inject DOM overlay `window.__ccA([{text|q, n, minLeft/maxLeft/minTop/maxTop, exact, up, nth}])` — **element-based** (bám `getBoundingClientRect`, miễn-nhiễm viewport đổi). Toạ-độ-cứng (rect px) KHÔNG ổn vì Chrome-screenshot render kích-thước thay-đổi (812↔833). Match **case-INSENSITIVE** (label CSS `text-transform:uppercase` → `textContent` mixed-case → exact-uppercase MISS).
3. **Crop:** bỏ chrome trình-duyệt top + taskbar bottom (top=92/bottom=72). ⚠️ Banner CDP **"Claude started debugging this browser"** hiện sau mỗi `navigate` (CDP re-attach), tự ẩn sau vài giây → ảnh chụp ngay-sau-navigate cần crop top≈146; verify nhanh bằng **montage dải-trên 12 ảnh** đọc 1 lần.
4. **Chèn:** `python-docx` `cell...add_picture(width=Inches(6.0))` — A4 content = **6.27"** nên 6.3" TRÀN; dùng ≤6.1".

---

### 75. Seed demo UNGATED + per-code idempotent → RE-ADD data đã wipe mỗi restart (wipe KHÔNG bền) (Session 91)

**Triệu chứng:** Sau wipe 18 NCC demo (`s91-wipe-golive.sql`) cho go-live sạch, định deploy → `SeedDemoMasterDataAsync` (DbInitializer.cs:111) chạy NGOÀI mọi `if (!demoSeedDisabled)` + idempotent per-code (`existingSupplierCodes.Contains(s.Code) ? continue : Add`). Code 18 NCC demo giờ THIẾU (đã xóa) → mỗi restart/IIS-recycle TỰ Add lại → Suppliers 7→25, undo go-live clean.

**Cơ chế:** wipe = triệu chứng; nguồn re-populate (seed) là gốc. Demo seed cố-ý-ungated (Production.json comment cũ "KEEP SeedMasterData") để giữ data UAT — nhưng go-live cần demo GONE. Khác user test (38): S89 đã gỡ KHỎI MẢNG `SeedDemoUsers` → không resurrect.

**Fix (root-cause, `55494ad`):** gate `SeedDemoMasterDataAsync` sau `DemoSeed:Disabled` (=true ở prod, mirror Contracts/PE/Workflow demo đã gated). Prod skip → wipe BỀN; Dev (false) vẫn seed local. **Verify:** deploy restart 2× → `demo_codes=0` (binary Last-Modified fresh + count THẬT từ prod). **Quy tắc:** TRƯỚC/SAU mọi prod data-wipe → audit DbInitializer seeder cho ungated per-code re-seed (resurrect). Liên-quan #73 / `feedback_root_cause_over_symptom`.

### 76. `deploy.yml` render `appsettings.Production.json` từ `.example` template mỗi deploy → prod config KHÔNG lấy từ file gitignored (Session 91)

**Triệu chứng:** Sửa `appsettings.Production.json` (gitignored) tưởng đổi config prod → vô-tác-dụng; `git add` báo file ignored.

**Cơ chế:** `deploy.yml:146-154` mỗi deploy đọc `appsettings.Production.json.example` (tracked) → `ConvertFrom-Json` → set `DB_CONNECTION`+`JWT_SECRET` từ secrets → **ghi đè** `C:\inetpub\...\appsettings.Production.json`. File local gitignored KHÔNG bao giờ deploy. Effective config = `appsettings.json` (tracked) ⊕ rendered-Production.json (từ `.example`).

**Fix/Quy tắc:** đổi prod config = sửa `.example` (tracked) HOẶC `appsettings.json`, KHÔNG file local gitignored. `.example` KHÔNG có `DemoSeed` → prod fallback `appsettings.json DemoSeed:Disabled=true` (đúng fallback đã giữ Contracts/PE gated nhiều tháng → gate #75 hiệu-lực ở prod).

### 77. SPA deploy bundle cache-lag — user thấy UI CŨ dù đã deploy (index.html cache / tab-SPA-mở) (Session 96)

**Triệu chứng:** Deploy FE xong (bundle rotate verified prod), nhưng user (anh Chương UAT) VẪN thấy UI CŨ dù Ctrl+F5 + logout/login — checkbox opt-out mới không hiện, vẫn "✅ Cấp này KẾT THÚC" static cũ. Team "bó tay rồi".

**Cơ chế:** Vite đặt hash trong TÊN file (`index-<hash>.js`, `Cache-Control: immutable max-age=1năm` = ĐÚNG). `index.html` = `Cache-Control: no-cache` (ĐÚNG chuẩn — revalidate ETag mỗi load). Nhưng: (a) tab SPA đang-mở chạy JS cũ trong RAM (cần reload thật, không chỉ F5-nửa-vời); (b) browser đôi khi giữ `index.html` cached bất-chấp no-cache → trỏ file JS CŨ (tên hash cũ). KHÔNG có service-worker (fe-user grep `serviceWorker|workbox|VitePWA` = 0) → không phải SW-cache.

**Chẩn-đoán (phân-biệt cache-vs-deploy — QUAN TRỌNG, đừng đoán):** **verify bundle prod BYTE-LEVEL**: `JS=$(curl -s $BASE/ | grep -oE '/assets/index-[A-Za-z0-9_-]+\.js')` → `curl -s "$BASE$JS" | grep -ao "<string-fix-mới>"` + `grep "<string-cũ>"`. Fix-string PRESENT + old-string GONE = deploy OK → **100% client-cache** (KHÔNG phải deploy-fail). S96: prod `0MU-kb4N` chứa "BỎ tick"/"Đã BỎ tick", "bạn duyệt xong" cũ MẤT → xác nhận cache.

**Fix (client):** **incognito window (Ctrl+Shift+N) = golden-test** (không dùng cache, chắc-chắn bundle mới → nếu incognito OK = 100% cache). Hoặc F12→Application→Clear site data→reload. **Đừng chỉ Ctrl+F5** (đôi khi không xóa index.html cached). **Server ĐÃ đúng chuẩn** (index.html no-cache + JS hash-immutable) — KHÔNG cần fix infra.

**Quy tắc:** UAT báo "fix chưa ăn" sau deploy → verify bundle prod byte-level TRƯỚC khi nghi deploy-fail; **incognito = phép-thử vàng** tách cache khỏi bug. Liên-quan #69 (bundle non-determ) · `feedback_verify_deployed_artifact_byte_level`.

**An toàn prod:** chỉ XEM/scroll/screenshot — KHÔNG bấm nút mutate (Gửi duyệt/Duyệt/Trả lại/Tạo HĐ). Mở `+ Thêm mới` (form `?mode=new`) KHÔNG tạo draft cho tới khi sửa field (auto-save on-edit).

**References:** `$env:TEMP\guideshots\capture.ps1` (session-scratch) · `docs/Huong-dan-Duyet-NCC.docx`. Pattern reusable → user-memory `pattern_prod_screenshot_to_docx`.

---

## Checklist debug bug mới

1. Build pass không? → fail → check using + package version compat
2. DI register đủ? → runtime error "Unable to resolve" → add `AddScoped/Singleton`
3. API log startup có error ẩn? → `tail` output file
4. File đã persist đúng chưa? → `head -5` verify sau Write
5. Nếu package exotic → thử downgrade về stable trước
6. Nếu TS error → check `erasableSyntaxOnly`, `verbatimModuleSyntax`
7. Nếu EF expression tree → tách logic ra ngoài query
8. Nếu Unicode CLI → dùng file payload
9. Nếu workflow 403 → check FE `workflow.nextPhases` sync từ BE pinned policy
10. Nếu SignalR 401 → dùng `accessTokenFactory` + BE OnMessageReceived hook (#26)
11. Nếu PS 5.1 script fail → check encoding UTF-8 / BOM / ASCII-only (#30)
12. Nếu subdomain trả sai content / bị hijack → check IPv4/IPv6 port collision trên VPS shared (#33)
13. Nếu 2 NavLink cùng active / không đúng highlight → custom isActive match query string (#34)
14. Nếu menu item có quyền nhưng không hiện → check GetMyMenuTreeQuery inheritance extend (#35)
15. Nếu FE gọi API sai URL sau đổi env → rebuild + clear bundle cache (#36)
16. Nếu .ps1 fail parser trên PS 5.1 → ASCII-only, grep multi-byte chars (#30, #37)
17. Nếu rename email Identity vẫn 401 → update 4 field NormalizedEmail/UserName (#38)
18. Nếu CI fail TCP timeout 21s ở "Set up job" → bypass github.com, manual checkout từ Gitea (#39)
19. Nếu npm install caching fail `tsc not found` → KHÔNG dùng junction Move-Item, thử robocopy/Copy-Item (#40)
20. Nếu CI vẫn trigger khi commit MD-only → paths-ignore trong on:push không match patterns đúng (#41)
21. Nếu user phàn nàn "feature work cho admin nhưng user empty/403 silent" → check class-level Authorize policy có over-restrict cho non-admin không, split per action (#44)
22. Nếu button workflow label nói "Trả lại" nhưng phiếu vẫn tiến approve → audit FE `isReject` payload condition vs button `isSendBack` label condition vs dialog `isSendBack` warning condition — phải sync 3 chỗ với CÙNG set target phase. BE thêm guard `(target ∈ terminalSet) ⇔ (decision=Reject)` chặn caller mismatch (#45)
23. Nếu Gitea Actions API trả 404 trên `/actions/runs` → đúng path là `/actions/tasks` (Gitea naming khác GitHub). Cache `updated_at` stale ~2 min → cross-check VPS file LastWriteTime cho time-sensitive verify (#46)
24. Nếu test `OrderByDescending(CreatedAt).First()` query audit table fail sau add Changelog mới → SQLite frozen-clock tie-break, MUST filter Summary/EntityType discriminator (#48)
25. Nếu page move cross-app (Implementer Case 2) nhưng menu leaf KHÔNG hiện sidebar dù BE trả permission OK → check `Layout.tsx` `resolvePath` staticMap miss key mapping → MenuLeaf null guard silent drop (#50). 4-place mirror checklist: page + Routes + menuKeys.ts + Layout.tsx staticMap
26. Nếu new Seed method KHÔNG chạy prod dù dotnet build PASS + deploy SUCCESS → check nested inside `if (!demoSeedDisabled)` gate (Plan T S23 t10 flag enabled prod) → INFRASTRUCTURE seed phải PROMOTE OUT of DemoSeed gate (#51). Decision tree: production cần để work end-to-end? YES → ungate
25. Nếu UI audit list show `Đã gửi duyệt → Đã gửi duyệt` lặp gây nhầm → drop dual-phase badge khi state machine self-loop, thay Decision badge + next-target hint parse từ comment (#49)
27. Nếu RAG `search_memory` trả 0 results dù Qdrant green + BM25 có data → `qdrant-client` upgrade xóa `search()` method, bị nuốt silent. Test: `python -c "from qdrant_client import QdrantClient; c=QdrantClient(url='http://127.0.0.1:6333'); c.search"`. Fix: dùng `query_points(query=...).points` (#52)
28. Nếu sub-agent (Reviewer/CICD) return PASS verdict bị cut mid-sentence ở "Update MEMORY.md" step → MEMORY > 25KB triggers truncation risk. Mitigation: tight brief ≤ 8K + em main grep verify manual + curate MEMORY pre-spawn nếu > 25KB (#53)
29. Nếu spawn sub-agent trả `API Error: 529 Overloaded` + `tokens=0` → Anthropic API transient overload, agent KHÔNG chạy. KHÔNG retry loop → em main solo fallback reliable (#54). Phân biệt với #53 truncation (agent chạy đủ token nhưng cut output)
30. Nếu sub-agent WRITE truncate NGAY ĐẦU exploration phase (chưa write file, đọc > 4 reference) → heavy spec ~10K + context bloat. Mitigation: brief ≤ 8K + pre-supply reference snippet trong brief HOẶC em main solo nếu cần đọc > 4 reference file (#55)
31. Nếu `git commit -m` qua PS 5.1 báo `error: pathspec 'xxx' did not match` với message tiếng Việt có `"` → native-arg escaping vỡ tại quote kép → Write message ra file UTF-8 + `git commit -F <file>` (#59)
32. Nếu thao tác theo-email/code trên data prod (lock/seed/migrate) trả 0 row affected → DUMP bảng env đích trước khi nghi code — population Dev ≠ prod (seed silent-fail `IdentityResult` không throw) (#60)
33. Nếu thêm enum value vào entity DÙNG-CHUNG mà UI/guard phân-loại sai (file lẫn section / false-pass guard) → grep MỌI predicate field-proxy (`supplierId===null`...) loại value mới + build-verify TỪNG app riêng (#71)
