---
url: https://docs.nemo12.com/quality/audits/2026-09-20.md
description: >-
  Audit #015 (20.09.2026, SRC-904): ổn định sau khi gộp năm mạch việc song song,
  về lỗi giao diện, logic, authentication và điện thoại.
---

# Audit #015 — Ổn định sau khi gộp năm mạch việc song song (2026-09-20, commit `cb51b14a`)

Chỉ đạo SRC-904 của chủ dự án: **push code, test và audit một lượt, ra báo cáo về tính ổn định** —
ít lỗi giao diện, ít lỗi logic, ít lỗi authentication, và chạy tốt trên điện thoại.

Bản này khác các audit trước ở một điểm, và điểm ấy là kết luận quan trọng nhất: **phần lớn lỗi
nặng tìm được không nằm trong code ai viết ra, mà sinh ra từ LƯỢT GỘP.** Năm mạch việc chạy song
song trên cùng một cây làm việc, và lúc gộp lại, ba khối mã bị đặt sai chỗ theo đúng một kiểu:
vẫn biên dịch được, vẫn qua mọi cổng, và vẫn sai.

## 1. Phương pháp và phạm vi

| Bước | Cách làm | Kết quả |
| --- | --- | --- |
| Chốt phạm vi | `origin/main` khi bắt đầu là `f69a0e60`; cây làm việc chung có 11 commit local chưa đẩy + một mạch leaderboard chưa commit | 43 module API · 263 migration · 204 file test · 489 file docs |
| Dọn nhánh trước khi đẩy | So từng commit local với origin theo NỘI DUNG (không theo hash) | 6/11 commit là bản làm TRÙNG việc đã có trên origin; chỉ 5 commit mang việc thật |
| Máy chạy trước | `check:docs` · `check:code` · `typecheck` · `test` · `contrast` · cổng số migration | 30 cổng docs/code PASS · typecheck ✓ · **2.484 test PASS** (209 file) · contrast 231/231 |
| e2e | `apps/learn` build thật + Playwright, gồm 320px và 375px | **232 PASS** (thêm 4 phép gác mới trong phiên) |
| Rà bằng agent | 5 lens song song (auth · tiền · lớp live · giao diện/mobile · migration), mỗi phát hiện qua **3 người phản biện độc lập** với nhiệm vụ BÁC BỎ | 56 agent · 17 phát hiện · **13 sống, 4 bị bác** |
| Đối chiếu chéo | Phát hiện nào được cả người lẫn agent tìm thấy độc lập thì ghi một dòng | 2 dòng trùng (D-4 lối vào lớp live, D-1 nguyên tử của `grant`) |

Ngưỡng của vòng phản biện đặt nghiêm: **không chắc thì coi như bị bác**. 4 phát hiện bị loại đều
vì chỉ 1/3 người giữ lại.

## 2. Ba lỗi do chính lượt GỘP sinh ra, và vì sao không cổng nào đỏ

Đây là lớp lỗi đáng ghi nhớ nhất của phiên này.

### G-1 🔴 Hai nhánh route `/live` bị đặt ra ngoài thân `switch`

Lượt gộp tự động đưa ba dòng `/live` của `parse()` vào **giữa khối `/reading`**, và hai nhánh
`case` của `path()` ra **ngoài** thân `switch`. Cái thứ hai là lỗi cú pháp nên `tsc` bắt được.
Cái thứ nhất thì biên dịch sạch mà vẫn sai: `/ielts/live` chỉ đọc ra đúng khi đường dẫn đồng thời
khớp regex `/reading`, tức là không bao giờ.

Sửa ở `3eab3752`.

### G-2 🔴 Lối vào "Join a live class" nằm ngoài component, không bao giờ hiện

`IeltsTaskPage` đóng lại ở dòng 1209 của `apps/learn/src/IeltsPathway.tsx`, còn khối JSX của lối
vào lớp live nằm ở 1210–1223, tức là ở **cấp module**.

Vì sao không cổng nào đỏ: trong một file `.tsx` thì đó **vẫn là cú pháp hợp lệ**. `{/* ... */}` là
một block statement, còn `<a>...</a>` đứng một mình là một expression statement. `tsc` xanh, `lint`
xanh, build xanh. React dựng phần tử ấy đúng một lần lúc nạp module rồi vứt đi.

Hậu quả: `/ielts/live` vẫn chạy nếu gõ thẳng địa chỉ, nhưng **không còn đường nào đi tới nó từ
giao diện**. Một tính năng biến mất mà không một cổng nào đỏ. Cả agent lens giao diện lẫn phép rà
tay đều tìm ra độc lập.

Sửa ở `27683b98`, kèm phép gác hỏi TRANG ĐÃ DỰNG chứ không hỏi mã nguồn.

### G-3 🔴 Hai cặp migration trùng số

| Số | Hai file cùng số | Xử lý |
| --- | --- | --- |
| 0259 | `0259_listening_subskills_five.sql` (đã trên `main`) và `0259_nemo_leaderboard.sql` (chưa commit) | Đổi bản chưa đẩy thành **0261** |
| 0257 | `0257_ielts_exam_papers.sql` (đã áp production) và `0257_ielts_diagnostic_speaking_takes.sql` (chưa đẩy) | Đổi bản chưa đẩy thành **0263** |

`wrangler d1 migrations apply` sắp theo TÊN FILE, nên số trùng là thứ tự áp không xác định
(`migrations/README.md` §1). Luật "migration đã áp lên production thì không đổi tên" được giữ:
chỉ bản chưa từng lên `main` bị đánh số lại.

Cổng AS-04.1.1 bắt được lớp lỗi này, **nhưng nó chỉ chạy trong CI**. Nếu đẩy nguyên trạng thì CI
đỏ và chặn deploy của mọi phiên. Sau khi sửa: dãy liền mạch `0001..0263`, không trùng.

## 3. Phát hiện 🔴 — chặn phát hành hoặc động tới tiền

| # | Phát hiện | Bằng chứng | Trạng thái |
| --- | --- | --- | --- |
| D-1 | **`grant()` ghi sổ và cộng ví bằng hai câu lệnh rời.** `INSERT OR IGNORE` vào `nemo_ledger` rồi mới `UPDATE` số dư, không bọc `DB.batch`. Chết giữa hai câu là ví thiếu **vĩnh viễn**: chạy lại thì `meta.changes === 0` nên thoát sớm ở dòng 152, câu cộng ví không bao giờ chạy lại. `readWallet` đọc số dư đã lưu còn `unpaid` cộng từ sổ, nên trang ví hiện `balance = 0` cạnh `unpaid = 100`. | `workers/api/src/modules/leaderboard/service.ts:148` | ✅ sửa `7d4cf64e` |
| D-2 | **Điểm tốc độ của ván đấu tính từ `ms` do CLIENT gửi.** Learner tự đặt `ms` nhỏ để ăn điểm tối đa mọi câu. | `workers/api/src/modules/liveSession/routes.ts:467` | ✅ sửa `643fc870` |
| D-3 | **Tải lại trang sau khi mentor kết thúc buổi thì learner mất hẳn đường viết phản tư 4F.** | `apps/learn/src/ielts/Live.tsx:401` | ✅ sửa `d461442f` |
| D-4 | Lối vào lớp live không bao giờ hiện (G-2 ở trên) | `apps/learn/src/IeltsPathway.tsx` | ✅ sửa `27683b98` |
| D-5 | **Phòng học treo vĩnh viễn ở "Opening the room..." khi `/v1/live/form` hỏng**, dù phòng và câu hỏi đã về. | `apps/learn/src/ielts/Live.tsx:436` | ✅ sửa `d461442f` |
| D-6 | Tương phản WCAG AA dưới chuẩn ở hai nút đặc của lớp live: `text-white` trên `bg-accent` chỉ 3,29:1 (cần 4,5:1) | `apps/learn/src/ielts/Live.tsx:113` và `:429` | ✅ sửa `37c2a13f` |

**D-6 là thứ làm CI đỏ thật** trong phiên này. Chỗ ở dòng 429 bị cổng bắt; chỗ ở dòng **113 thì
cổng KHÔNG bắt được** vì lớp nằm trong một ternary bên trong template literal. Cả hai đã sửa.

## 4. Phát hiện 🟡 — sai mà chưa chặn phát hành

| # | Phát hiện | Bằng chứng | Trạng thái |
| --- | --- | --- | --- |
| D-7 | **Bảng xếp hạng chỉ kiểm "đã đăng nhập"**, mà tài khoản thì ai cũng tự tạo được. Tên thật và ảnh của learner lộ ra cho bất kỳ ai lập được một tài khoản. Đây là RANH GIỚI CHÍNH SÁCH, không phải lỗi code: phạm vi mở đã được cân nhắc và ghi rõ trong đầu file. Cần chủ dự án chốt. | `workers/api/src/modules/leaderboard/routes.ts:31` | ✅ sửa `be939583` |
| D-8 | Route admin `/leaderboard/close` không chặn chốt tháng **đang chạy**, và `INSERT OR IGNORE` khoá vĩnh viễn giải sai người | `workers/api/src/modules/leaderboard/routes.ts:145` | ✅ sửa `c08cc011` |
| D-9 | **Học vắt qua nửa đêm bị dồn hết sang ngày hôm sau**, làm hỏng vế "không bỏ ngày nào" của luật thưởng | `workers/api/src/modules/ieltsSkills/routes.ts:120` | ✅ sửa `c52d38c0` |
| D-10 | Câu quiz check-in learner **bỏ trống** bị gửi lên thành lựa chọn đầu tiên | `apps/learn/src/ielts/Live.tsx:147` | ✅ sửa `b8fb5b0d` |
| D-11 | `/ielts/recordings` viết **toàn tiếng Việt** trong sân luyện tiếng Anh (SRC-834/835) | `apps/learn/src/ielts/Recordings.tsx:208` | ✅ sửa `676b91ec` |
| D-12 | `aria-label` của menu tài khoản và nút đóng modal **cứng tiếng Việt** ở mọi trang `/ielts`, dù đã truyền cờ `english` | `apps/learn/src/AvatarMenu.tsx:44` | ✅ sửa `676b91ec` |
| D-13 | **Tên learner bị bóp mất trên bảng xếp hạng ở điện thoại** | đo thật, bảng dưới | ✅ sửa `8c37458c` |

### D-13 đo bằng số, vì mắt không thấy

Hàng của bảng có bốn phần tử, ba trong số đó (`hạng`, `ảnh`, `cột phải`) mang `shrink-0`, nên ô
TÊN là chỗ duy nhất còn co được và nó gánh trọn phần thiếu. Trang **không** tràn ngang và **không**
báo lỗi — đúng kiểu hỏng im lặng mà DS-001 §4b đã ghi từ SRC-900.

| Khung nhìn | Trước | Sau | Tên cần |
| --- | --- | --- | --- |
| 320px | **14px** | 113px | 205px |
| 375px | 69px | 168px | 205px |
| 414px | 108px | **207px (đủ chữ)** | 207px |

Chữa theo luật 2 của DS-001 §4b: dưới `sm`, dòng "còn thiếu bao nhiêu" xuống nằm ở dòng phụ dưới
tên thay vì đứng ở cột phải. Không mất con số nào, chỉ đổi chỗ.

## 5. Phát hiện ⚪ — nhỏ, và nợ cũ không thuộc phiên này

| # | Phát hiện | Trạng thái |
| --- | --- | --- |
| D-14 | `hours()` in ra **"11h 60m"** vì làm tròn hai vế độc lập. Không vô hại: 12h là đúng mốc ăn thưởng, nên learner còn thiếu một giây lại đọc thấy mình đã đủ. Nay cắt xuống: 43.199 giây ra "11h 59m", đủ 43.200 mới ra "12h". | ✅ sửa `cb51b14a` |
| D-15 | Bảng THÁNG in cứng `of 30 days`, nên tháng 31 ngày hiện **"31 of 30 days"**, tháng 2 hiện "28 of 30 days". Nay đếm từ `from`/`to` server trả về. | ✅ sửa `cb51b14a` |
| H-1 | `sdd-038` có **hai mục "## 26." và hai mục "## 27."** | ⚪ nợ cũ, đã có trên `origin/main` trước phiên |
| H-2 | `docs/traceability.md` có **dòng SRC-587 hai lần** | ⚪ nợ cũ, đã có trên `origin/main` trước phiên |
| H-3 | **SRC-895 và SRC-898 đã làm xong và đã lên `main`, nhưng không có dòng sổ và không tài liệu canonical nào nhắc tới.** | ✅ đã đóng 21.09: SRC-895 do một phiên khác ghi, SRC-898 ghi ở [SDD-038 §54](../../architecture/sdd-038/exercises.md) bằng những gì đọc được trong mã nguồn |
| H-4 | **6/11 commit local là bản làm trùng** việc đã có trên origin (SRC-884, 889, 894, 896, 898, 885) | ⚪ dấu hiệu, xem §7 |

## 6. Cổng: cái gì bắt được, cái gì không

| Cổng | Ở máy | Trong CI | Ghi chú |
| --- | --- | --- | --- |
| `check:docs` (10 script) | ✓ | ✓ | bắt được SRC-904 chưa có canonical |
| `check:code` (20 script) | ✓ | ✓ | |
| `typecheck` | ✓ | ✓ | bắt được **một nửa** lỗi G-1 |
| `test` (2.484 test) | ✓ | ✓ | |
| **Tương phản WCAG AA (AS-09.3.1)** | ✗ **không có trong `npm run check`** | ✓ | **làm CI đỏ trong phiên này** |
| **Số migration liên tục, không trùng (AS-04.1.1)** | ✗ | ✓ | sẽ đỏ nếu không sửa G-3 trước |
| e2e Playwright | ✗ (chạy tay) | ✓ | |

Ba điểm mù đáng ghi:

1. **`npm run check` ở máy KHÔNG gọi cổng tương phản.** Chạy tay bằng
   `npm run contrast --workspace @nemo12/design-system`.
2. **Cổng tương phản đọc lớp bằng biểu thức trên mã nguồn**, nên nó không thấy lớp nằm trong một
   ternary bên trong template literal — đúng chỗ D-6 ở dòng 113 nấp.
3. **Không cổng nào bắt được JSX mồ côi ngoài thân component** (G-2), vì đó là cú pháp hợp lệ.
   Phép gác duy nhất có tác dụng là hỏi trang đã dựng, tức là e2e.

## 7. Điều rút ra về việc chạy nhiều phiên song song

Ba số liệu của phiên này nói cùng một chuyện:

* **6/11 commit local làm trùng việc đã có trên origin.** Hai phiên khác nhau làm cùng một SRC,
  bằng hai bản vá khác nhau, rồi cùng tồn tại.
* **Ba khối mã bị lượt gộp đặt sai chỗ** (G-1, G-2), và chỉ một nửa bị `tsc` bắt.
* Giữa lúc audit chạy, một phiên khác đang sửa cùng file và đã đẩy một bản vá **chỉ sửa một trong
  hai chỗ** của D-6; phần còn lại nằm trong cây làm việc chung chưa commit.

`CLAUDE.md` đã có luật worktree riêng cho việc dài. Phiên này là bằng chứng nữa rằng **chi phí của
lượt gộp không nằm ở xung đột git** — git báo xung đột thì người ta sửa. Chi phí nằm ở chỗ git
KHÔNG báo gì: hai khối mã hợp lệ, đặt cạnh nhau sai chỗ, và mọi cổng vẫn xanh.

## 8. Việc còn lại, xếp theo thứ tự nên làm

1. \~~**D-1** `grant()` chưa nguyên tử~~ — **đã sửa trong phiên.** Hai câu nay nằm trong một
   `DB.batch`, và thứ tự bị ĐẢO: cộng ví trước, ghi sổ sau. Gộp thẳng theo thứ tự cũ là phá mất
   chống trùng, vì tới lượt câu thứ hai thì dòng sổ đã có rồi; ở thứ tự mới, `WHERE NOT EXISTS`
   đọc sổ khi nó còn chưa mang dòng ấy. Thêm phép gác cho bất biến mà lỗi này phá: **số dư luôn
   bằng tổng sổ**, đúng cả sau nhiều lượt chốt và nhiều loại thưởng.
2. \~~**D-2** điểm tốc độ do client gửi~~ — **đã sửa trong phiên.** `ms` bị gỡ khỏi thân request;
   server đo từ `question_started_at` của chính nó. Chữa luôn một lỗi thứ hai lộ ra khi sửa: đồng
   hồ đếm ngược trên màn learner đo từ lúc MÁY ẤY mở câu, nên ai vào muộn cũng thấy đủ 25 giây
   trong khi server đã tính gần hết giờ. Nay trạng thái phòng mang `elapsed_ms` — một KHOẢNG chứ
   không phải một MỐC, để máy sai giờ không làm ván đấu hiện ra đã hết giờ từ giây đầu.
3. \~~**D-5** và **D-3** hai đường cụt của phòng học live~~ — **đã sửa trong phiên.** Buổi đã
   đóng nay mở lại được cho người đã dự (và chỉ cho người đã dự), nên biểu mẫu 4F không còn biến
   mất vì một lượt tải lại trang. Cổng render chỉ còn đòi trạng thái phòng, nên một lượt
   `/v1/live/form` hỏng không khoá luôn ván đấu; chỗ nào thiếu bộ câu hỏi thì nói ra và cho bấm
   lại tại chỗ.
4. \~~**D-7** ranh giới ai xem được bảng xếp hạng~~ — **đã quyết và đã sửa** (21.09.2026, SRC-938).
   Chủ dự án giao toàn quyền quyết. Chọn hướng HẸP: chỉ learner đang học và người của nhà trường;
   `guardian` không nằm trong danh sách. Lý do đầy đủ ở [SDD-038 §34d](../../architecture/sdd-038/membership-unlock.md).
   Hẹp thì đảo lại được bằng một chuỗi thêm vào mảng vai, còn rộng thì không.

   \~~**D-8** chốt tháng đang chạy~~ — **đã sửa trong phiên.** Một kỳ chỉ chốt được sau khi nó kết
   thúc; phép gác nằm trong `closeWeek`/`closeMonth` nên cron và nút bấm tay đều đi qua, và route
   trả 400 kèm ngày kỳ ấy đóng lại. Tiện thể vá nốt vệt sót của D-9: bảng xếp hạng nay cũng đọc
   theo ngày học.
5. \~~**D-9** ranh giới nửa đêm của "không bỏ ngày nào"~~ — **đã sửa trong phiên.** Ngày học nay
   bắt đầu lúc 04:00 giờ VN (`learningDay`), dùng cho cả đường ghi lẫn đường đọc. Lượt chốt tuần
   tách sang cron riêng lúc 05:00 giờ VN, và hằng số cron nằm cạnh hằng số lằn ranh nên dời sai
   là test đỏ chứ không phải sổ chốt nhầm tuần trong im lặng.
6. \~~**D-11**, **D-12** chữ tiếng Việt còn sót trong sân luyện tiếng Anh~~ — **đã sửa trong
   phiên.** 14 chuỗi giao diện của `/ielts/recordings` và hai `aria-label` của menu tài khoản.
   Nội dung bài (`*_vi`) giữ nguyên tiếng Việt vì nó thuộc thang bốn chặng SRC-821, và có một
   phép gác riêng giữ đúng ranh giới ấy.
7. Đưa cổng tương phản vào `npm run check` để nó không còn là thứ chỉ CI mới biết.
8. **H-3** viết mục canonical cho SRC-895 và SRC-898 rồi mới ghi sổ.
