---
url: https://docs.nemo12.com/architecture/sdd-038/audits.md
description: >-
  Các lượt rà cả sân luyện, mã chết, cổng nói dối, audit lượt 1 và 2, bốn luật
  từ audit 03.10.2026, việc tồn
---

# SDD-038 · Rà soát và audit sân luyện

> Một phần của [SDD-038 Cổng IELTS](index.md). Phần đầu là đặc tả đang áp dụng, mỗi mục giữ số § cũ; diễn biến và lý do các vòng quyết định nằm ở mục **Lịch sử quyết định** cuối trang.

## 51. Một lượt rà cả sân luyện: mã chết, một bài bị đè, và hai cổng nói dối (SRC-931, 21.09.2026)

> Mạch tiến độ bài tập (bốn trạng thái, hai cuốn sổ, quy ước `ref`, luật mở khoá và các ca hỏng)
> được mô tả gọn ở một chỗ: [Tiến độ bài tập IELTS](../../reference/ielts-exercise-progress.md).
> Mục này và các mục §42 · §46 · §53 giữ **lý do** của từng quyết định; trang kia nói **cái gì
> đang chạy**.

### 51a. 104 nhánh tiếng Việt không có cửa nào mở ra được

SRC-834/835 đã bỏ công tắc VI/EN khỏi sân luyện, và từ hôm ấy `useLang()` trả về đúng một hằng số
`"en"` (`apps/learn/src/ielts/lang.tsx`). Nhưng **109 chỗ trong mười hai file vẫn viết
`lang === "vi" ? <bản Việt> : <bản Anh>`** — mỗi màn hình vẫn cõng theo trọn một bản tiếng Việt mà
không thao tác nào của learner mở ra được.

Đã gỡ nhánh chết ở 104 chỗ. Năm chỗ còn lại đọc `item.lang` của NỘI DUNG (thang bốn chặng §5f),
không phải công tắc giao diện, nên chúng ở lại. Phép biến đổi chạy bằng parser TypeScript chứ
không bằng regex: ternary ở đây có cái trải bốn dòng và có cái lồng trong cái khác, và một regex sẽ
cắt nhầm đúng những chỗ ấy.

**Không một pixel nào đổi.** `useLang()` vốn đã trả `"en"`, nên thứ bị xoá đúng bằng thứ chưa bao
giờ hiện ra; 345 test của learn và 1.937 test của api đều xanh trước và sau. Kèm theo là 67 cặp
ngoặc JSX thừa mà phép gỡ để lại (`{"Passages"}` → `Passages`), mười khai báo `lang` không còn ai
đọc, và năm dòng `import { useLang }` theo sau chúng.

`pick()` và `t()` **ở lại**, và đó không phải sót tay: chúng chọn giữa `*_vi` và `*_en` của DỮ LIỆU
nội dung, nơi bản tiếng Anh còn đang lấp dần, và chúng rơi về bản còn lại khi bản kia trống. Gỡ
chúng là biến mỗi ô chưa dịch xong thành một chỗ trống không ai hiểu vì sao trống.

### 51b. Sáu ký hiệu không còn ai gọi

`useNextOf` (`Hub.tsx`), `levelInfo` (`levels.ts`), `findTest` (`data/examTests.ts`), `useLexicon`
(`data/store.ts`), `fetchMicroSkills` (`ielts/api.ts`) ở app learn, và `parseRef`
(`ieltsSkills/catalog.ts`) ở api. Cả sáu là dấu vết của một trang hoặc một lối đi đã bỏ:
`fetchMicroSkills` chẳng hạn là lời gọi của trang danh sách năng lực mà SRC-921 đã gỡ, còn
`parseRef` là nghịch đảo của `refOf` cho một đường đọc ngược chưa bao giờ dựng.

Phép đo dùng ở đây, ghi lại để lần sau đo cùng cách: một ký hiệu là CHẾT khi không file nào khác
nhắc tới nó **và** trong chính file của nó nó chỉ xuất hiện đúng một lần, tức là chỉ có dòng định
nghĩa. Thiếu vế thứ hai thì mọi hàm nội bộ đều bị kết tội oan.

### 51c. Một bài tập bị đè mất, một bài chưa bao giờ được nạp

Đây là chỗ nặng nhất của lượt rà, và nó không nhìn thấy được từ màn hình.

Hai trục soạn bài (§35e) cùng đổ vào MỘT không gian id: `scripts/micro-seed.mjs` đặt tên bài là
`<kỹ năng>.<năng lực>.l<bậc>.<thứ tự>`, lấy `micro` của bài nếu có, không thì lấy `code` của file.
Nên một bài ở trục dạng khai `micro: "main-idea"`, bậc 1, thứ tự 3 sinh ra **đúng cái id** mà bài
thứ ba bậc 1 của `reading/main-idea.json` sinh ra.

Chuyện ấy đã xảy ra:

| Bài | Id | Kết cục |
| --- | --- | --- |
| `reading-tasks/matching-headings.json` "Give the paragraph a name" | `reading.main-idea.l1.3` | Nạp ở migration 0265, **đang chạy** |
| `reading/main-idea.json` "Five more, same job" | `reading.main-idea.l1.3` | Bị đè, **chưa từng tới production** |
| `reading/main-idea.json` "Same five, two languages" | `reading.main-idea.l2.3` | Không migration nào nạp, **chưa từng tới production** |

`INSERT OR REPLACE` không báo lỗi khi trùng khoá, nó chỉ đè. Nên Main Idea chạy trên production với
mười bài trong khi kho có mười hai, và dấu hiệu duy nhất là một danh sách ngắn hơn kho hai dòng —
không ai đọc ra điều đó từ giao diện.

**Đã sửa ở ba chỗ:**

1. `reading/main-idea.json` đánh lại bài va chạm thành `seq: 5`. Bài ở trục dạng GIỮ số 3, vì nó đã
   nằm trên production và `/ielts/skills/reading/main-idea/levels/1/3` đã là một URL learner có thể
   đã lưu — luật CLAUDE.md là link đã gửi đi thì không được gãy.
2. Migration `0269_main_idea_two_missing_exercises.sql` nạp đúng hai bài còn thiếu, sinh bằng
   `micro-seed` rồi cắt lấy hai bài (chứ không nạp lại cả file: một diff nhỏ thì đọc được).
3. `scripts/check-micro-content.mjs` nay dựng id **y như `micro-seed` dựng** và bắt trùng, nêu tên
   cả hai file. Đã thử ngược: đặt lại va chạm thì cổng đỏ đúng một dòng.

**Vì sao `seq: 5` chứ không phải `seq: 4`, và bài học thứ hai của cùng một cái bẫy.** Bản sửa đầu
tiên chọn số 4, vì trong `content/` số 4 còn trống. Nhưng không gian id này còn một người dùng nữa
mà `content/` không nhìn thấy: **migration viết tay**. Migration 0268 (SRC-925, hôm trước) đã tự
tay dựng `reading.main-idea.l1.4` để gom ba câu khởi động lẻ vào một bài, và bài ấy chỉ sống trong
SQL. Nạp đè lên nó là `INSERT OR REPLACE` xoá dòng cũ, rồi khoá ngoại `ON DELETE CASCADE` cuốn theo
cả ba câu — mất nội dung, vẫn không một dòng lỗi nào.

Thứ duy nhất phản đối là phép đếm tổng số câu trong `microSkills/routes.test.ts`: mong 2.429, nhận
2.426. Đó đúng là công dụng mà chú thích của phép đếm ấy tự nhận, và nó đã làm được việc — nhưng nó
chỉ nói "lệch ba", không nói lệch ở đâu. Nên cổng nay đối chiếu thêm với **id do migration KHÔNG
sinh từ `content/` dựng** (nhận ra bằng tên bộ sinh nằm trong file), và nói thẳng tên file đã lấy
số ấy. Thử ngược: đặt lại `seq: 4` thì cổng đỏ đúng một dòng, nêu tên `0268`.

### 51d. Hai cổng nói dối theo hai kiểu khác nhau

**`check-micro-content` không chạy ở đâu cả.** `content/ielts-micro/README.md` ghi rõ cổng này gác
thành phần từng bậc, nhưng nó không có trong `npm run check:code`, không có trong CI, không có
trong hook. Một cổng như thế tệ hơn không có cổng: người soạn đọc README rồi tin rằng máy đang gác
hộ. Chạy tay nó ra **164 dòng lỗi**, tất cả rơi vào 11 file của trục DẠNG CÂU HỎI — trục sinh sau
nó (SRC-895) và nó chưa biết mặt: nó đòi `code` trong khi file khai `task_type`, đòi mười câu ở bậc
3 trong khi một đề Matching Headings là năm tiêu đề, đòi ngữ liệu 110 từ ở bậc 5 trong khi độ dài ở
trục này do chính dạng câu hỏi quyết định.

Cổng nay biết cả hai trục, mỗi trục một khuôn, và khuôn của trục dạng đọc từ chính 55 bài đã soạn
(năm bài một dạng, năm câu một bài, chỉ bậc 1 · 3 · 5) đúng cách mà dải độ dài cũ đọc từ
`reading/main-idea`. Trần độ dài KHÔNG áp cho trục dạng — dải bậc 3 (25-92 từ) và bậc 5 (48-139 từ)
chồng lên nhau thật — nhưng sàn thì giữ, vì một "ngữ liệu" mười từ thì ở dạng nào cũng không còn là
bài đọc. Xanh rồi mới nối vào `npm run check:code`: nối một cổng đang đỏ là chặn deploy của mọi
phiên vì một việc không phải của họ.

**`audit:ielts-content` trả 401 kể từ SRC-885.** Phép rà nội dung gọi `/v1/public/ielts/content/*`
trần, mà §27 đã khoá cả nhóm ấy sau `requireSession`. Nó không đọc mã lỗi nên thứ người chạy nhìn
thấy là một vệt stack trace của `fetch` — một câu trả lời sai cho câu hỏi "nội dung có hỏng không".
Nay nó nhận token qua `NEMO12_SESSION`, gửi bằng `Authorization: Bearer`, và khi thiếu token thì in
ra đúng câu lệnh phải gõ thay vì stack trace.

### 51e. Hai trang chưa từng được nhắc trong docs

`/ielts/warmup` (bộ khởi động ba câu) và `/ielts/domain` (chọn lĩnh vực của learner) đều là trang
sống, đều có lối vào thật — `Shell.tsx` trỏ tới `domain`, trang `/ielts/courses` trỏ tới `warmup` —
nhưng `/ielts/domain` không xuất hiện ở bất kỳ dòng nào của `docs/`, còn `/ielts/warmup` chỉ được
nhắc gián tiếp qua nội dung của nó (§17, §48). Ghi lại ở đây để lần sau đọc docs là thấy:

| Trang | Việc của nó | Nội dung đến từ |
| --- | --- | --- |
| `/ielts/warmup` | Ba câu mở đầu, không chấm điểm, dành cho người vừa mở trang | `ielts_content_items` kiểu `warmup` (§17) |
| `/ielts/domain` | Learner chọn lĩnh vực; kho từ và cụm bài theo lựa chọn ấy | `ielts_content_clusters` + hồ sơ learner |

### 51f. Còn mở, và đây là chỗ cần chủ dự án quyết

* **Ranh giới thật của §8a hẹp hơn câu chữ của nó.** Rà xong thì thấy sân luyện đang theo một luật
  tinh hơn "mọi trang chỉ tiếng Anh": **TÊN và NHÃN tiếng Anh, THÂN BÀI DẠY vẫn tiếng Việt.**
  `ExamPart.tsx` ghi thẳng điều đó trong một comment (`name_en` ở tiêu đề, `note_vi`/`measures_vi`/
  `tell_vi`/`shape_vi` ở thân); `levels.ts` làm đúng thế (`short: "Level 1"` tiếng Anh, `title` và
  `why` tiếng Việt); `microCheckpoints.ts` và `checkpoints.ts` chỉ có bản tiếng Việt và không có
  trường `*_en` nào. Ba chỗ ấy nhất quán với nhau và nhất quán với thang bốn chặng §5f — thứ lệch
  là §8a, chứ không phải mã. Đã thêm một đoạn vào §8a nói rõ ranh giới; nếu chủ dự án muốn thân bài
  cũng sang tiếng Anh thì đó là một đợt SOẠN LẠI nội dung dạy học, không phải một lượt dịch.
* **Trục dạng câu hỏi mới chỉ có ở Reading.** `content/ielts-micro/` có `reading/` (10 năng lực),
  `writing/` (16 năng lực) và `reading-tasks/` (11 dạng). Nghe và Nói chưa có trục dạng nào, nên
  hai kỹ năng ấy chỉ lọc được theo năng lực. Đó là việc soạn nội dung, không phải việc của mã.
* **`NEMO_TO_VND` không ai gọi.** Hằng số ấy ghi luật "1 Nemo = 1.000đ" (§34b) nhưng việc chuyển
  khoản diễn ra ngoài đời, nên nó là chữ chứ không phải mã. Để lại có chủ ý, ghi ra đây để lần rà
  sau không phải điều tra lại.

## 71. Rà soát cuối phiên 22.09.2026 (SRC-965)

* **`IeltsLatestTest.tsx` đã xoá.** Ba lối vào "đã thi / tự đoán / không đoán" (SRC-956) không còn
  màn nào dẫn tới từ khi trang Goal bỏ đi ở SRC-961. Một file không ai gọi sẽ lệch khỏi phần còn
  lại mà không ai biết. Lấy lại được: `git show 85c9c930:apps/learn/src/IeltsLatestTest.tsx`.
* **Learning Plan có ca e2e cho sợi dây lưu.** Sửa một ô, bấm Lưu, mở lại trang. Nếu dây đứt ở bất
  cứ đâu — nút Save không nối, `cells` gửi lên rỗng, trang không đọc lại bản đã lưu — màn hình vẫn
  vẽ ra một bảng trông đúng, và learner chỉ phát hiện ở lần mở sau.
* **Một lỗi nữa lộ ra khi viết ca ấy:** nhánh "thiếu mục tiêu hoặc thiếu điểm" của Learning Plan
  KHÔNG có tiêu đề, vì tiêu đề nằm trong `header` của `EditGate` ở nhánh kia. Learner mở trang ra
  và không biết mình đang ở đâu.
* Hai bài học đã ghi vào trí nhớ dự án: `xoa-test-bang-script.md` và
  `useregistersave-vong-ve-vo-tan.md`.

## 129. Lượt audit đầu tiên và bộ skill audit (SRC-1086, 27.09.2026)

Chủ dự án: test và audit một lượt về UI, logic và nhiều khía cạnh khác; tạo skill chuyên để audit;
rồi "fix hết P0 và P1". Bộ skill: `/audit` (điều phối) và bốn góc `/audit-ui`, `/audit-logic`,
`/audit-language`, `/audit-security-perf` trong `.claude/skills/`. Bốn agent chạy song song, chỉ đọc
và chạy test; phát hiện có bằng chứng, xếp P0 (hỏng/sai dữ liệu), P1 (người dùng gặp), P2 (nâng cấp).

Đã sửa (P0, P1):

* **Speak together cộng giờ sau khi đã rời phòng** (P1, chứng minh bằng test): `/done` nay từ chối khi
  `ended_at` có giá trị, và UPDATE đóng buổi thêm `AND ended_at IS NULL` để một lượt Rời đua với Xong
  cũng không lọt. Test mới trong `speakPair/routes.test.ts`.
* **Dò mã phòng 4 số** (P1): `/speak-pairs/join` có rate limit riêng `speakPairJoin` (20 lần/10 phút),
  dò hết 9.000 mã mất hơn ba ngày; `/done` thêm rate limit `learningEvent`.
* **Bundle learn 2,08 MB** (P1): sân luyện IELTS (`ielts/Practice`) tải lười khi vào `/ielts/**`;
  bundle chính còn 1,60 MB, IELTS thành gói riêng 479 KB kèm nội dung Grammar và Speak together.
* **Câu giới hạn của phép đo bằng tiếng Anh** (P0, SRC-953): phiếu điểm đề luyện nay nói tiếng Việt
  và nói rõ "không phải band IELTS chính thức"; ba dòng dặn dò trước khi làm đề cũng tiếng Việt.
* **Lời tour và câu dẫn Learning Plan bằng tiếng Anh** (P1, SRC-953): năm lời tour ở trang `/ielts` và
  trang kỹ năng, và câu "Đặt mục tiêu, Nemo sẽ lên kế hoạch..." nay tiếng Việt.
* **Dấu "?" 24px trên điện thoại** (P1): vùng bấm nới lên 40px bằng một lớp giả trong suốt, hình tròn
  giữ nguyên (`CardHint`, `TaskTypes`, `TermTitle`).

Không làm, có lý do:

* Agent UI đề xuất đổi nút Speak together sang tiếng Việt: ngược chỉ đạo SRC-1084 (trang ấy tiếng Anh
  đơn giản toàn bộ), bỏ.
* Nhãn nút hành động trong sân luyện đang lẫn Anh và Việt (ExamSit "Back to papers", Grammar "Kiểm
  tra"): SRC-953 nói nhãn nút tiếng Việt, nhưng chủ dự án cần chốt một chiều trước khi sửa hàng loạt.
* P2 (bậc Grammar chỉ kiểm ở client, cờ `credited` sai khi đã cộng trong ngày, polling khi tab ẩn,
  NavStats không làm mới, h1 ẩn cho trang kỹ năng, sitemap thiếu bài What's new...) để lượt sau.
* Ca vitest `practiceEvidence` "xu hướng đổi thì vẫn ghi" đỏ cả khi bỏ mọi thay đổi của lượt này:
  phụ thuộc thời điểm chạy, không thuộc phạm vi.

## 132. Audit toàn sân luyện IELTS, lượt 2 (SRC-1097, 28.09.2026)

Năm góc (UI, logic, ngôn ngữ, bảo mật-hiệu năng, nội dung) chạy song song trên toàn `/ielts/**`.
Không có P0 ở mã; một P0 ở NỘI DUNG (đáp án đúng thường là lựa chọn dài nhất: 89% ở Reading, 78% ở
Listening) chờ chủ dự án duyệt một lượt viết lại bằng workflow. Chủ dự án: "Fix hết P1".

Đã sửa (P1):

* **Cộng giờ không giới hạn**: `POST /learning-events` nay có trần NGÀY 7200 giây cho mỗi learner, cộng
  mọi nguồn trong sổ (`DAILY_CAP_SECONDS`); dòng vẫn ghi, chỉ số giây bị cắt về phần còn lại.
* **"Try again" cộng lại trọn phút**: số phút cố định của bài chỉ cộng ở lần làm xong đầu tiên; lượt sau
  tính theo giây thật client đo, không vượt số phút của bài. Hai test mới trong `ieltsSkills/routes.test.ts`.
* **Bảng xếp hạng quét cả sổ mỗi lần mở**: phép tính đệm 60 giây theo isolate, gắn theo binding D1
  (`leaderboard/service.ts`); chậm tối đa một phút.
* **Polling khi tab ẩn**: Speak together (3 giây) và lớp Live (2,5 giây) thôi hỏi khi `document.hidden`,
  hỏi lại ngay khi quay về tab; đồng hồ 1 giây cũng dừng khi ẩn.
* **Ngôn ngữ** (SRC-953/834): đoạn giới thiệu bốn phần thi (`ExamPart.tsx`) sang tiếng Việt; "Các buổi
  học" → "Sessions", "Bài nói của con" → "Your recordings", nhãn "Bài viết của con" → "Your writing",
  lỗi micro sang tiếng Anh, tiêu đề gập của Grammar sang tiếng Anh.
* Kèm: trang buổi Speak together coi trả lời thiếu `start` là không có buổi (từng hiện NaN).

Còn chờ: P0 nội dung (viết lại lựa chọn sai), các P2 (rate limit hai route nộp bài, lộ mã learner ở bảng
xếp hạng, bundle chính 1,6 MB, đáp án Grammar lệch vị trí, audit-content bỏ qua migration lỗi, chủ đề
Grammar không kiểm ở máy chủ).

### 126g. Ai đã đăng ký buổi Speak together (SRC-1098, 28.09.2026)

Trang chi tiết buổi có thẻ "Who is coming (n)": ảnh đại diện hoặc chữ cái đầu và tên hiển thị của từng
người đã đăng ký, theo thứ tự đăng ký, đánh dấu "(you)" cho chính mình. API chi tiết trả `attendees` chỉ
gồm tên, ảnh, cờ `me` (không email, không mã nội bộ), tối đa 60 người. Đăng ký hoặc huỷ xong thì trang
tải lại chi tiết để danh sách cập nhật.

## 137. Làm nốt việc tồn (SRC-1125, 28.09.2026)

Chủ dự án: "Còn nhiều việc vẫn chưa được xử lý. Hãy làm nốt." Các P2 của lượt audit 2 (§132) và các chỗ hụt
ghi lại trong những lượt sau:

* **Rate limit hai route nộp bài có chấm điểm**: `ielts-exam-quiz/submit` và `ielts-diagnostic/submit` nay gọi
  `enforceRateLimit(RATE_LIMITS.submit)` như `ieltsPractice`.
* **Bảng xếp hạng không lộ mã learner khác**: ba route đọc (`/v1/ielts/leaderboard`, `/weeks`, `/weeks/{w}`) thay
  `learner_id` của người khác bằng `anon-<n>`; chỉ mã của người xem giữ nguyên để màn tô dòng của mình.
* **Chủ đề Grammar kiểm ở máy chủ**: `POST /ielts-grammar` chỉ cộng cho mã có trong `GRAMMAR_TOPIC_IDS` (sinh bởi
  `scripts/gen-grammar-topics.mjs` từ file nội dung; test gác khi quên sinh lại). Trước đây mã bịa vẫn ăn 120 giây.
* **Đáp án Grammar lệch về vị trí đầu**: `TaskCard` xáo lựa chọn khi hiển thị, cố định theo bài (`shuffled`);
  test gác tỉ lệ đáp án đứng đầu dưới 50%.
* **audit-content không bỏ qua im lặng** migration không chạy được trên sqlite3: in tên ra.
* **Vân tay video**: `src/catalog.ts` (danh sách import sinh tự động) ra khỏi vân tay mã dựng hình, để thêm một video
  không làm mọi video cũ bị coi là đã đổi. Lượt này đổi vân tay của cả kho MỘT lần.
* **Tài liệu API**: tham số `program` của `speak-schedule` (SRC-1120) nay khai trong OpenAPI.
* **Trang Grammar công khai**: câu "đang được soạn dần" thay bằng "cả 103 bài đã có nội dung".
* **Bài đo 6 câu cũ** trên trang hồ sơ IELTS của cổng learn thay bằng lối vào bài chẩn đoán mới (`/ielts/diagnostic`);
  kết quả cũ vẫn hiện ở cột "Bài đo".

Còn chờ chủ dự án: P0 nội dung (đáp án đúng thường là lựa chọn dài nhất trong kho đọc/nghe; nội dung chỉ nằm trong
D1 nên cần một lượt riêng), link Zoom, bốn link nhóm Zalo, video cho 62 bài Grammar, thương hiệu video Grammar,
và việc sau tháng 10/2026 của program Speaking.

## 164. Bốn luật từ audit 03.10.2026 (SRC-1204)

Audit toàn bộ code ngày 03.10.2026 tìm ra bốn chỗ hành vi sản phẩm lệch với ý định. Chủ dự án giao
"làm nốt", nên đây là phương án đã chọn - đổi được, nhưng đổi thì sửa ở đây trước.

* **Đồng hồ dùng thử chỉ chạy khi chính learner mở.** Đọc gói IELTS/Grammar hay thưởng học tập từ
  phiên bố mẹ, mentor, admin trước đó trả `not_started: true` và không ghi dòng nào; trước đó lượt
  đọc của người khác bắt đầu 14 ngày thử thay learner.

* **Ngày thi gợi ý không phải hạn chót.** Chỉ ngày learner đã xác nhận (`confirmed = 1`, hoặc lịch
  học kỳ đã khai) mới tạo mục tiêu, cam kết, chế độ luyện thi và đếm ngược. Kèm theo: Planning
  trước đó không đọc được ngày thi nào vì câu truy vấn lỗi bị nuốt im lặng; đã sửa.

* **Giới hạn học thử Speaking do server quyết.** Mọi learner chưa là thành viên IELTS trả phí còn
  hạn đều chịu giới hạn buổi thử, không còn phụ thuộc tham số `?program` do client gửi.

* **Trần 2 giờ học mỗi ngày áp cho mọi nguồn ghi giờ** (ngữ pháp, lượt nói, bài thang bậc, buổi
  nói đôi) qua một hàm ghi có kẹp trần, không chỉ đường ghi hoạt động chung.

* **Mã phòng Speak together giữ 3 chữ số** (quyết định 30.09.2026), nhưng mỗi tài khoản chỉ được
  nhập sai 10 lần một ngày; nhập đúng không tốn lượt.

* **Đáp án không còn xuống trình duyệt trước khi làm bài.** Đề full test ghép từ chính câu luyện
  tập (`useComposedTests`), nên trước đây đáp án nằm sẵn trong tab mạng. Chủ dự án chọn giữ MỘT
  ngân hàng câu và chấm luyện tập ở server:

  * Đường nội dung (`/v1/public/ielts/content/*`) bỏ `answer`/`accept`/`why`, thêm `uid` (mã câu
    đầy đủ) và `picks` (số phương án phải chọn).
  * `POST /v1/learners/{id}/ielts-content/check` chấm từng lô câu SAU khi learner trả lời, trả
    `correct` kèm đáp án và lời giải; gác bằng phiên + quyền với learner, trần 150 lượt/ngày.
    Logic đúng/sai dùng chung với `scoreObjective` (`ieltsContent/grade.ts`).
  * Reading, Listening, Warmup, ExamSit gọi đường chấm, có trạng thái chờ và nút thử lại.
  * Script soát nội dung và script sinh fixture e2e lấy khoá bằng `?keys=1` với phiên staff/admin;
    learner gửi `keys=1` nhận đúng bản không khoá. Bản có khoá trả `no-store`.

  Còn hở có chủ ý: learner gọi tay đường chấm vẫn lấy được khoá từng câu, trong giới hạn 150 lượt
  một ngày. Đóng hẳn đòi server tự ghép đề (biết câu nào thuộc lượt thi đang mở). Hai phép chuẩn
  hoá câu điền từ: từ SRC-1225 trang luyện chấm đúng nếu khớp theo MỘT trong hai phép (bản luyện cũ
  hoặc bản của đề đầy đủ), nên "half-past six" không còn sai khi luyện mà đúng khi thi, và không
  câu nào đang đúng lại thành sai. Đề đầy đủ giữ nguyên `normaliseStrict`, điểm đã chấm không đổi.

* **Server tự ghép đề và giữ tập câu của lượt (SRC-1229).** Đóng chỗ hở ở đoạn trên bằng ba lớp:
  * (a) `POST .../ielts-full-tests/{n}/attempts` tự ghép đề n (`ieltsFullTests/compose.ts`, hàm
    thuần chép phép xếp của `FullTests.tsx`/`examTests.ts`; test `fullTestCompose.test.ts` ở app so
    hai bên trên cùng dữ liệu) và lưu vào `papers_json` bốn mã đề kèm `question_uids` (40 câu
    Listening, 40 câu Reading đúng như trang làm bài rút). Mã đề app gửi lên bị bỏ qua. Khi chấm,
    `scoreObjective` chỉ tính câu trong tập: câu ngoài tập bị bỏ và đếm vào `ignored`, câu trong
    tập không gửi tính là bỏ trống. Lượt mở trước SRC-1229 được dựng tập câu lúc bắt đầu phần.
  * (b) Đường chấm luyện ghi mọi câu đã trả đáp án vào `ielts_content_keys_seen` (migration 0331).
    Lúc ghép, trong các đề của một kỹ năng server chọn đề có ít câu đã lộ nhất; hoà thì theo vị trí
    bắt đầu từ đề thứ n, và không lặp đề learner đã làm ở ô khác trừ khi hết đề. Không bao giờ
    chặn đề vì mọi đề đều đã lộ. Learner chưa xem đáp án câu nào thì nhận đúng đề như app ghép.
  * (c) Trong lúc phần L/R của một lượt `in_progress` chưa nộp, đường chấm luyện trả 403
    `TEST_IN_PROGRESS` cho câu thuộc tập của phần ấy. Nộp phần xong thì hỏi lại được, nên trang làm
    đề vẫn lấy đáp án ngay sau khi nộp như cũ. Chỉ khoá với lượt mở chưa quá 24 giờ: lượt bỏ dở
    không có cơ chế hết hạn, và khoá theo trạng thái thì câu của nó bị khoá ở trang luyện mãi mãi.
  * Cùng đợt: `uuid` trong `@cloudflare/realtimekit` ép lên 11.1.1 bằng `overrides` (lỗ hổng
    GHSA-w5hq-g745-h8pq ở bản 8.3.2; realtimekit chỉ dùng `v4` và `validate`).

## Lịch sử quyết định

### Từ mục 51. Một lượt rà cả sân luyện: mã chết, một bài bị đè, và hai cổng nói dối (SRC-931, 21.09.2026)

Chỉ đạo chủ dự án 2026-09-20: *"Rà soát và dọn dẹp code của learn.nemo12.com/ielts cho gọn gàng
nhé, và đối chiếu với docs.nemo12.com xem sao. Rồi đồng bộ cả hai bên. Docs thiếu thì bổ sung docs,
chỉ liên quan tới IELTS thôi, còn tính năng thiếu hoặc data thiếu thì chỉnh data."*
