Files
solution-erp/docs/gotchas.md
pqhuy1987 0c6efdaf4f [CLAUDE] Docs: Session 25 chốt cuối — Plan AB→AF cumulative 7 commits + 4 agent MEMORY flush
S25 wrap final:
- STATUS.md + HANDOFF.md prepend Plan AB→AF cumulative narrative (7 commits cdfd542..506cada + 7 CICD Runs #215-#221)
- gotchas.md +2 NEW entries:
  - #48 Multi-Changelog.Add() SQLite frozen-clock tie-break (Run #215 catch, fix Plan AB Chunk A2)
  - #49 UI dual-phase badge confusion khi state machine self-loop (Plan AD drop + extractNextTargetHint helper)
- Checklist debug bug mới +2 entries (24-25)
- Session log NEW docs/changelog/sessions/2026-05-19-s25-pe-history-visibility.md (~360 LOC)
- 4 agent MEMORY drift sync:
  - investigator/MEMORY.md (30→32KB) FIFO entry S25 wrap + count metadata
  - implementer/MEMORY.md (34→36KB) FIFO entry + patterns 16-18 saved
  - reviewer/MEMORY.md (31→32KB) FIFO entry + lesson SQLite tie-break + UAT skip risk reinforced
  - cicd-monitor/MEMORY.md (~72KB CRITICAL OVER) — 7 Run entries #215-#221 + curate flag MAX

Memory user-level +2 NEW entries (separate commit memory dir, KHÔNG trong this commit):
- feedback_fe_merge_synthetic_audit.md (Plan AC2 pattern)
- feedback_fe_usermap_fallback.md (Plan AF pattern)

Stats final S25:
- 31 mig (no schema) · 59 tables · ~146 endpoints · 35 FE pages
- 111 test unchanged (UAT defer test-after per §7)
- 49 gotcha (+2: #48 + #49)
- 23 memory user-level (+2 NEW S25 patterns)
- 6 skills · 4 sub-agents active
- 7 commits cumulative S25 · 7 CICD Runs (1 FAIL caught + 6 PASS)
- 6× bundle rotate × 2 app (Run #220 BE-only unchanged)

Critical pending S26+:
- Memory curate cicd-monitor PRIORITY MAX (~72KB strongly over hard threshold)
- Plan B Contract V2 wire HIGH priority (5-6 chunk pre-allocated S23 HANDOFF)

Per §6.5 KEEP narrative — KHÔNG cut rationale/gotcha context, chỉ phân tầng prepend latest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:33:41 +07:00

44 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

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