Files
solution-erp/docs/gotchas.md
pqhuy1987 a62e797332 [CLAUDE] Docs: S56 closeout — pre-golive verify + golive-harden + doc-drift + gotcha #58
STATUS/HANDOFF S56 + session log: WF1 pre-golive-verify (7-stream → GO) + WF2 golive-harden (4 fix, code a20cde8 Run #379 PASS). Test 216→228. Code golive-ready; 2 ops VPS pending (IT user + tzutil); FE Phase 2 deferred.

§L closeout (H1/H2): database-agent executed-file→verified-runtime (agents/README:4, D1 closed); ef-core skill 47→48; sys.tables 92→93 reconciled (cicd ground-truth); root CLAUDE test 203→228 + 92→93 bảng; gotcha #58 NEW (EF read-modify-write lost-update→ExecuteUpdate atomic). agent-memory harvest: cicd Run#379 + Fidelity Serializable-correction (impl/test MEMORY, H2 GATE 4.5/5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:20:51 +07:00

65 KiB
Raw Blame History

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:

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:

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 addUnable 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<>:

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:

$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 :8082taskkill /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:

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:

.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:

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:

& "$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/*:
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:

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:

"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):

[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:

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

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:

<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 localhost127.0.0.1 để đồng bộ

FE routing + state (Phase 6)

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:

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: ContractsWorkflows. 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:

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 PeWorkflowsPeWf_*) — 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:

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:

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.

- 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:

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")]:

[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):

// 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):

// 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=5404 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:

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:

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:

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.

// 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:

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:

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 DVBLmZltDgn1iU9E)
  • 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(...):

# 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):

# 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.


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
  27. 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)
  28. 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)
  29. 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)
  30. 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)
  31. 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)