---
url: https://docs.nemo12.com/architecture/sdd-001-platform.md
description: >-
  Kiến trúc nền tảng Nemo12 trên Cloudflare: frontend, API Hono, D1, xác thực,
  phân quyền và cách tổ chức monorepo.
---

# SDD-001 — Platform Architecture

**Nguyên tắc:** One World, One Learner, One Platform. Nemo12 là *distributed education platform về business capability, unified learner system về technology*.

## 1. High-Level

```text
nemo12.com ── Cloudflare Edge (WAF, DDoS, rate limit, bot)
   │
   ├─ apps (Vite/React SPA + assets-on-Worker): web · learn · marlins · mentors(dolphin) · admin · coral · b21 · ielts
   ├─ apps (VitePress): docs · pearl · compass · playbooks
   ├─ workers/foundry (nemo12-foundry) ── Cloudflare Workflows sinh nội dung, chỉ service binding từ api
   ├─ workers/tool-plane (mcp.nemo12.com) ── MCP server cho AI agent, dùng chung D1
   │
   └─ api.nemo12.com ── Hono on Workers (modular monolith)
         ├─ D1  `nemo12-platform` (id f4275a70-66fa-4746-8292-40f96a7ed288) — system of record
         ├─ R2  `nemo12-content` — heavy assets/media
         ├─ KV  `nemo12-config` (id f08c239409404bfe82b5aad303de362d) — config/flags/manifests cache
         ├─ Queues `nemo12-events` (+ `nemo12-events-dlq`) — async fan-out
         ├─ Workflows — durable multi-step processes
         └─ AI Gateway `nemo12` — mọi AI request (bắt buộc, không bypass)
```

Tài nguyên trên account Cloudflare `ff302e77218f9616c3baaf01db59d64e`, zone `nemo12.com` (id `09caf325ee80eca4743957e03b655867`). Tạo ngày 2026-08-13.

## 2. Modular Monolith (REQ-PLT-02)

Bounded modules trong một Worker api (tách service chỉ khi có lý do scale/security rõ). Danh sách
dưới đây là **thư mục thật** trong `workers/api/src/modules/` (29, 2026-09-04) — bản đầu của SDD này
liệt kê 16 tên module dự kiến (identity · family · learner · school …) mà không tên nào thành thư
mục; Audit #013 (T-8) bắt được người mới đọc "platform SDD" để tìm module thì lạc:

```text
auth · invitations · families · onboarding · privacy · referral          (danh tính, gia đình, đồng thuận)
knowledge · learning · models · retention · goals · progress · exams     (trí tuệ học tập, đo, luyện)
curriculum · courses · content · coral · dictation · b21                 (học liệu, xưởng nội dung, khung năng lực)
parent · digest · portraits · forum · events · mentor · showcase         (phụ huynh, cộng đồng, mentor)
orca · whale · admin                                                    (cuộc thi, học bổng, vận hành)
```

Mỗi module: thư mục riêng, export router Hono (đăng ký ở `src/index.ts`) + service; dùng chung
`src/shared/*` (authz, audit, prompts, ratelimit, reliability, runlog, time…); không import chéo
internals của module khác. Kiểm bằng `ls workers/api/src/modules` — đừng sửa danh sách này bằng tay
mà không đối chiếu.

## 3. Cloudflare Services (REQ-PLT-01)

| Nhu cầu | Dịch vụ | Quy tắc |
| --- | --- | --- |
| Compute | Workers | Hono, TypeScript |
| Relational | D1 | Một database `nemo12-platform`; school phân biệt bằng `school_id`/`program_id`, không tách DB theo school (ADR-002) |
| Heavy content | R2 | metadata ở D1, D1 chỉ giữ `content_ref` (SDD-004, SDD-009) |
| Cache/config | KV | không chứa learner state quan trọng |
| Async | Queues | batching, retry, DLQ (SDD-006 §5) |
| Durable process | Workflows | diagnosis, evaluation, recalculation… |
| AI | AI Gateway `nemo12` | logging, caching, rate-limit; **cấm** direct provider call và cấm "probe-then-fallback" kiểu chuyenchon (RISK-022) |
| API edge | API Shield/Gateway (khi bật) + code-level hardening | method/path allowlist, body cap (kế thừa pattern sutucon `shared/gateway.ts`) |

## 4. Frontend (REQ-PLT-05, REQ-BRD-01, REQ-SCH-01)

```text
apps/
├── web/       nemo12.com (+ www)   — public, 6 school pages /turtle…/whale, SEO+OG
├── learn/     learn.nemo12.com     — Nemos; school = route context (/turtle, /shark…)
├── marlins/   marlins.nemo12.com   — parents
├── mentors/   mentors.nemo12.com   — Dolphins (build sau, thứ 4)
├── admin/     admin.nemo12.com     — internal, sau Cloudflare Access (build cuối)
└── docs/      docs.nemo12.com      — VitePress, render docs/ canonical (sau Access)
```

* Vite + React + TypeScript, Cloudflare Vite plugin; deploy dạng **Worker với static assets** (không dùng Pages — hợp nhất một mô hình deploy).
* School **không** có app riêng — chỉ là route/theme context (REQ-SCH-01).
* `@latest` khi cài, lock trong lockfile (Vite 8.x, Tailwind v4, shadcn CLI mới nhất).

## 5. API (REQ-PLT-04, REQ-PLT-10)

* Base: `https://api.nemo12.com/v1/` — resources: `/learners /families /invitations /enrollments /evidence /assessments /recommendations /learning /content /media /interactions /notifications`.
* Contract **OpenAPI sinh từ code** (`@hono/zod-openapi`): mọi route bắt buộc có zod schema (fix RISK-024 — sutucon hand-maintained openapi). `GET /v1/openapi.json` public.
* API-first cho mobile: không logic nào chỉ nằm trong web app; response envelope `{data}` / `{error:{code,message}}` theo error taxonomy SDD-006 §11.
* Versioning: `/v1` ổn định; breaking change → `/v2` song song (REQ-NFR-10).

## 6. Identity, Auth & Access (REQ-ACC-\*, REQ-SEC-05)

**Parent-first (Q-006):**

```text
Parent bấm "Đăng nhập với Google" (Google Identity Services, id_token)
→ api verify id_token (aud = GOOGLE_CLIENT_ID, email_verified)
→ chưa có user? tạo `users` + `families` + membership 'owner' (tự động, không form)
→ session: token opaque 32B, lưu sha256(token), cookie `nemo12_session`
  Domain=.nemo12.com HttpOnly Secure SameSite=Lax, Max-Age 30d, CÓ rotation khi refresh
```

* **GSI id_token flow → không cần Google client secret** (kế thừa sutucon, RISK-021 tránh được). `GOOGLE_CLIENT_ID` là var công khai.
* **Invite flow:** parent tạo invitation (email hoặc link + mã) → learner login Google qua link → account learner tạo + gắn `family_id` + `guardian` links. Learner không qua invite = không enroll được (REQ-ACC-03).
* Mobile (REQ-ACC-06): cùng endpoint đổi id_token → session token, client mobile giữ token trong secure storage, gửi qua `Authorization: Bearer` — cookie và bearer song song.
* RBAC: bảng `role_assignments` (user_id, role, scope) — roles `learner|guardian|mentor|staff|admin`; mọi authorization ở backend, theo quan hệ family/assignment (REQ-SEC-02).
* **Cloudflare Access** bảo vệ `admin.` + `docs.` (+ staging). Cấu hình Zero Trust: app per hostname, policy = email domain/list; **audience + team domain đặt qua env var, cấm hardcode** (REQ-SEC-06, fix RISK-013). *Trạng thái 2026-08-13: chưa bật được qua API token hiện có — cần làm trên dashboard; admin/docs không deploy public cho tới khi Access bật.*

## 7. Data Layer (REQ-ACC-04, REQ-PLT-11)

* **Canonical identity:** `users.id` (UUIDv7), `learners.id` riêng (một user có thể là learner; guardian liên kết qua `guardians`/`family_members`). **Email chỉ là thuộc tính đăng nhập — cấm làm khóa dữ liệu** (fix RISK-001 owner_key=email của chuyenchon).
* Core tables (migration 0001): `users, families, family_members, learners, guardians, invitations, sessions, auth_identities, role_assignments, audit_log`.
* Domain tables theo SDD-002/003/004/005/009.
* **Migrations:** một sequence duy nhất `migrations/` ở root monorepo, áp bằng `wrangler d1 migrations apply nemo12-platform`; idempotent khi có thể; cấm DML destructive trộn DDL; cấm nhiều thư mục migration trỏ cùng DB (fix RISK-004/005 của cả 2 legacy).
* Timestamps: TEXT ISO-8601 UTC (SQLite), tên cột `*_at`.

## 8. Events & AI plumbing (REQ-PLT-03, REQ-PLT-09)

* Domain events chuẩn: `{event_id, idempotency_key, type, occurred_at, producer, version, payload}` — publish sau commit, qua `nemo12-events`; consumer idempotent; quá retry → DLQ.
* Catalog khởi điểm: `UserCreated, FamilyCreated, LearnerInvited, LearnerJoined, EnrollmentCreated, EvidenceRecorded, AssessmentCompleted, SkillMasteryChanged, RecommendationGenerated, LearningExperienceCompleted, MentorInteractionRecorded, ParentObservationRecorded, CommentCreated, MediaUploaded, MediaModerated`.
* AI: một module `ai/` duy nhất gọi AI Gateway `nemo12`; ghi `{model, prompt_version, input_ref, output, confidence, evaluation_state}` cho mọi output quan trọng (REQ-INT-14). Prompt registry versioned trong repo (`packages/ai/prompts/`). Provider mặc định: Workers AI (llama) cho tác vụ rẻ, có thể route Claude qua Gateway cho đánh giá chất lượng cao (Q-020 tự quyết — xem open-questions).

## 9. Monorepo (REQ-PLT-07)

```text
nemo12/
├── apps/{web,learn,marlins,mentors,admin,coral,b21,ielts}   # React + Vite, mỗi app một Worker assets
├── apps/{docs,pearl,compass,playbooks}                      # VitePress
├── workers/api            # Hono modular monolith (29 module, §2)
├── workers/foundry        # Cloudflare Workflows sinh nội dung (SDD-027)
├── workers/tool-plane     # MCP server mcp.nemo12.com (SDD-030)
├── packages/design-system # token + CSS + Motion preset; CHƯA export TSX (Audit #013 U-2)
├── migrations/            # một sequence duy nhất cho nemo12-platform
├── content/               # ngữ liệu (dictation…) nạp lúc build
├── scripts/               # cổng chất lượng check-*.mjs, seed, sinh trang
└── docs/                  # canonical documentation (REQ-PLT-08)
```

npm workspaces (Node 22). CI: **một job** `ci.yml` cho mọi push vào `main` (cổng → áp migration → deploy
app bị đụng, phạm vi tính bằng `.github/scripts/scope.sh`); 12 `deploy-*.yml` chỉ chạy tay. Các gói
`domain · database · events · ai · auth · shared` trong bản đầu chưa bao giờ được tạo — logic tương ứng
nằm ở `workers/api/src/shared/`.

### Xếp hàng chạm production: chờ, đừng xếp hàng (SRC-797)

`ci.yml` (trên `main`), `rollback.yml` và `seed-data.yml` đều ghi thẳng production, nên chúng không
được chạy chồng lên nhau (Audit #013, I-8 và I-19). Cách làm cũ là cho cả ba cùng
`concurrency: group: production`. Cách đó **sai theo một kiểu im lặng**, và đã gây tai nạn thật
ngày 17.09.2026: GitHub chỉ cho **đúng một** run được xếp hàng chờ trong mỗi group, nên khi một
lượt `seed-data` xếp vào đúng lúc một run `ci` đang chờ, run `ci` ấy bị **huỷ** — mang theo cả
phần deploy của nó.

Vì sao `ci` huỷ `ci` thì không sao mà `seed-data` huỷ `ci` thì có sao: hai lượt `ci` trên `main`
nối tiếp nhau, lượt sau chứa commit của lượt trước nên deploy vẫn tới đích. `seed-data` thì không
deploy gì cả — nó hất run kia đi rồi ghi D1, và không có ai deploy bù. Kết quả hôm đó là một commit
nằm trên `main` nhưng không có trên production suốt ba tiếng, và trạng thái để lại là `cancelled`
nên rất dễ đọc nhầm thành "ai đó chủ động huỷ" thay vì "deploy đã mất".

Cách làm hiện tại giữ nguyên tính loại trừ nhưng giành nó bằng cách **chờ**:

* `seed-data.yml` có group riêng `production-seed` — hai lượt seed vẫn không chạy chồng nhau.

* Trước khi ghi, nó chạy `.github/scripts/wait-production.sh`:
  hỏi API xem còn run `ci` / `rollback` / `deploy-*` nào đang chạy không, và chờ cho tới khi sạch.

* Chạy **hai lần**: một lần ngay sau `checkout`, một lần nữa sát lệnh ghi. Giữa hai lần đó là
  `npm ci` — đủ dài để một lượt deploy kịp bắt đầu mà lần chờ đầu không biết.

* Chờ quá 25 phút thì **hỏng có tiếng**, và không ghi gì vào D1.

* `deploy-app.yml` (deploy tay một app) theo đúng cách ấy từ SRC-1205: group riêng
  `production-deploy-app`, chờ `wait-production.sh` ngay trước bước migration. Trước đó nó còn xếp
  chung `production`, nên bấm deploy tay lúc một run `ci` trên `main` đang chờ là huỷ run ấy, và
  mọi app khác đổi trong push đó không được deploy. Lỗi do lượt audit 03.10.2026 tìm ra.

`rollback.yml` **cố ý giữ nguyên** `group: production`. Rollback là công cụ khẩn cấp: lúc dùng nó
thì việc giành chỗ ngay là đúng, và luôn có người đang ngồi nhìn.

### Xanh không có nghĩa là đã kiểm (SRC-798)

`ci.yml` gác từng cổng theo phạm vi thay đổi, nên một lượt **có thể kết thúc xanh sau khi bỏ qua
mọi cổng**. Với commit chỉ đụng `docs/` thì đó đúng và cố ý. Vấn đề là hệ ở hai chỗ khác lại coi
"xanh" là bằng chứng, mà không hỏi lượt đó có chạy gì không:

1. `scope.sh` lấy **lượt `ci` xanh gần nhất** làm mốc so sánh cho lần sau;
2. `wait-for-ci.sh` cho phép deploy tay khi thấy một lượt `ci` xanh trên đúng commit.

Ngày 17.09.2026 chuyện này thành tai nạn thật. `scope.sh` tính sai phạm vi — `echo "$files" |
grep -q` gặp `set -o pipefail`: `grep -q` thoát ngay ở dòng khớp đầu tiên, `echo` chết vì SIGPIPE
(141), pipeline bị coi là thất bại, nên hàm trả `false` **đúng vào lúc regex có khớp**. Kết quả:
một push đụng `workers/api/` và `apps/mentors/` cho ra phạm vi rỗng, mọi cổng bị bỏ qua, không app
nào được deploy, và CI báo xanh trong **24 giây**. Lượt xanh rỗng đó rồi thành mốc so sánh cho lượt
kế, nên phạm vi tiếp tục thiếu.

Ba lớp vá, mỗi lớp chặn một tầng khác nhau:

| Lớp | Ở đâu | Chặn cái gì |
| --- | --- | --- |
| Nguyên nhân | `hit`/`miss` dùng herestring thay vì ống | không còn ống thì không còn SIGPIPE |
| Loại hỏng hóc | `scope.sh` tự soi: có file đổi mà `code` lẫn `docs` đều `false` → **nổ** | mọi nguyên nhân khác cho ra phạm vi rỗng, kể cả chưa biết |
| Không biết phạm vi | danh sách file rỗng → bật **mọi** cờ, không tắt | hướng an toàn là chạy rộng, không phải chạy hẹp |

Và ở phía tiêu thụ, `wait-for-ci.sh` nay hỏi thêm một câu trước khi tin: **lượt xanh đó có chạy cổng
code không?** Nó đọc kết luận của bước `Typecheck · Lint · Test` trong chính run ấy. Xanh mà bỏ qua
thì không tính là bằng chứng — rơi về nhánh tự chạy cổng tại chỗ (chậm hơn ~90 giây). Đọc không
được danh sách bước cũng tính là **không**: khi không biết thì kiểm lại, không cho qua.

> Lập luận đằng sau cả bốn: một phạm vi hẹp và một phạm vi **sai** trông giống hệt nhau trên màn
> hình, và cái giá của hai bên không cùng hạng. Chạy thừa vài phút là tiền; deploy hụt trong im
> lặng là một commit nằm trên `main` mà không có trên production, và không ai biết để đi tìm.

### Hỏi thẳng production đang chạy commit nào (SRC-799)

Cổng trên bắt được phạm vi **rỗng**. Phạm vi **thiếu một phần** thì nó không thấy: `code=true`,
mọi cổng chạy thật, mười bốn app lên, một app bị bỏ quên — mọi dấu hiệu đều bình thường. Loại đó
chỉ bắt được bằng cách hỏi chính production.

**Dấu.** `.github/scripts/deploy.sh` đóng `nemo12-commit:<sha>` vào Worker Version qua cờ
`--message` của wrangler; Cloudflare giữ nguyên câu đó và trả lại qua API. Chọn cách này thay vì
một endpoint `/__version` trong từng app vì endpoint phải viết mười lăm lần và **không dùng được
với hai app nằm sau Cloudflare Access** — mà dolphin, đúng app đã lệch hôm 17.09, là một trong hai.
Không có `GITHUB_SHA` (chạy tay dưới máy) thì **không đóng dấu**: một dấu sai tệ hơn không có dấu,
vì cổng đối chiếu sẽ tin nó.

Nhân việc này, 15 workflow `deploy-*.yml` chuyển từ gọi thẳng `npx wrangler deploy` sang gọi
`deploy.sh` — nên chúng được đóng dấu, và hưởng luôn phần thử lại khi API Cloudflare nấc (Audit
\#013, I-6) mà trước giờ chỉ `ci.yml` có.

**"Cũ hơn `main`" không phải lỗi.** Hầu hết app luôn cũ hơn vài chục commit, và đó là đúng — chúng
chỉ deploy lại khi có thứ thuộc về chúng đổi. Lệch **thật** là: bản đang chạy cũ hơn, **và** giữa
hai mốc có commit đụng vào chính app đó.

**Một nguồn cho câu "app nào gồm đường dẫn nào".** Câu đó trước chỉ nằm trong `scope.sh`. Cổng đối
chiếu cần đúng nó, nên nó được tách ra `.github/app-paths.json` và **cả hai bên cùng đọc**. Chép
sang lần thứ hai là mở đường cho ngày hai bên lệch — và lúc lệch thì cổng đối chiếu nói sai về
chính thứ nó sinh ra để canh, lại nói bằng giọng chắc chắn. `scope.sh` sau khi đổi được đối chiếu
trên bốn mốc so sánh khác nhau: đầu ra **giống hệt** bản cũ.

**Chạy hằng ngày, không chắn push.** Cổng này hỏi API Cloudflare, tức là phụ thuộc mạng; một cổng
như vậy đứng chắn mọi push thì đến ngày API nấc là mọi phiên đứng — cùng lập luận đã dùng để giữ
`check:ui-latest` ngoài CI (DS-001 §0). Và loại hỏng hóc nó canh là loại **dai**: lần trước dolphin
lệch gần bốn tiếng. Một lượt 06:00 giờ Việt Nam cộng chạy tay bất cứ lúc nào là đủ nhanh mà không
cầm chân ai.

> **Giới hạn, nói thẳng:** app deploy trước 17.09.2026 chưa mang dấu, nên lần đầu chạy sẽ hiện
> "chưa có dấu" — cảnh báo, không chặn, và tự hết sau lần deploy kế tiếp của từng app. Cổng cũng
> chỉ so **commit**, không so nội dung bản dựng: một lần deploy đúng commit nhưng dựng hỏng thì nó
> vẫn nói "đúng HEAD".

## 10. Documentation plane (REQ-PLT-08, REQ-BRD-12)

docs/ = canonical duy nhất; VitePress render tại `apps/docs`; frontmatter machine-readable ([conventions.md](../conventions.md)); CI check broken links + trace closure (QG-001).

Ba site VitePress dùng **chung một khuôn** (REQ-DOC-10): `apps/docs` (canonical docs), `apps/pearl`
(knowledge công khai) và `apps/playbooks` (playbooks.nemo12.com — action library, REQ-BRD-12). Cùng
`@nemo12/design-system` qua bridge token, cùng `cleanUrls` + `appearance: false` + search local, cùng
đường deploy Worker-với-static-assets trong `ci.yml`. Playbooks khác hai site kia ở chỗ **cây nội
dung là bản dẫn xuất**: `apps/playbooks/catalog/playbooks.json` là nguồn, `scripts/gen-playbooks.mjs`
sinh trang mục lục, khung 11 phần và sidebar; script chỉ đồng bộ frontmatter và **không ghi đè phần
thân đã soạn**, vì phần thân là sản phẩm của xưởng nội dung chứ không phải của repo (SDD-027).

**Cấu trúc điều hướng Pearl (SRC-750, 2026-09-16).** Pearl có 7 nhóm nội dung phẳng trên thanh nav
nhưng chỉ 5 trong số đó xuất hiện trên hub trang chủ, nên hai chỗ điều hướng nói hai chuyện khác
nhau. Đã gom nav thành 5 cụm theo câu hỏi người đọc thật sự hỏi (Học · Hiểu con · Hoàn thành ·
Curriculum · Vì sao), hub trang chủ lên 6 card (3 cột x 2 hàng, không để card mồ côi cuối hàng),
và **không đổi URL của bất kỳ trang nào đang sống** — chỉ đổi cách gom.

Mục mới `/completion/` là nơi canonical hoá *Completion & Graduation Specification* cho người đọc
công khai: 4 tiêu chí bắt buộc (≥6/12 sản phẩm đạt ≥80/100 với ≥3 phiên bản; ≥12 reflection; tham
gia Final Product Showcase; Final Assessment ≥80/100) và 4 trạng thái *In Progress · Completed ·
Graduated · Not Yet Graduated*. Nguyên tắc nền: tốt nghiệp xét từ **bằng chứng learner tạo ra**,
không từ tỉ lệ nội dung đã xem. Bản đặc tả tiếng Anh giữ nguyên tại `/completion/spec` và là bản có
hiệu lực khi lệch với các trang diễn giải tiếng Việt.

**Product Assessment Rubric (SRC-752, 2026-09-16)** tại `/completion/rubric`, thứ toàn bộ ngưỡng
80/100 treo vào. Năm tiêu chí **cân bằng** 20 điểm (vấn đề và mục đích · nội dung và độ chính xác ·
lập luận và lựa chọn · thực thi và hoàn thiện · đáp ứng phản hồi), bốn mức ăn 5/10/16/20 điểm. Số
học ra có chủ ý: 16 × 5 = 80, nên **ngưỡng tốt nghiệp đúng bằng "Đạt ở cả năm tiêu chí"** thay vì
một con số tuỳ tiện, và 20 × 5 = 100. Kèm một luật chặn bù trừ: bản nộp cuối phải ≥80 **và** không
có tiêu chí nào ở mức "Chưa đạt", vì phép cộng đơn thuần cho phép 5 + 20×4 = 85 lọt qua với một
tiêu chí hỏng hẳn. Tiêu chí 5 ở bản V1 luôn bằng 0 (chưa có phản hồi nào để đáp ứng), nên **trần
điểm của V1 là 80**: một bản nháp đầu tiên không thể một mình thoả điều kiện tốt nghiệp, khớp với
ràng buộc ≥3 phiên bản. Quy trình chấm ba bước: learner tự chấm trước, mentor chấm độc lập, rồi so
hai bản (khoảng lệch là đầu vào của Confidence Score). Chỉ tiêu chí 2 do từng khoá viết lại mô tả,
bốn tiêu chí còn lại dùng chung nguyên văn.

**Ghi nhận sau tốt nghiệp (SRC-763, 2026-09-16)** tại `/completion/certificate`. Ba lớp, và thứ tự
là quyết định chính: **hồ sơ tốt nghiệp** (trang có địa chỉ vĩnh viễn, dẫn thẳng tới sản phẩm thật
và chuỗi phiên bản) là lớp gốc, **chứng nhận** chỉ là bản in dẫn xuất, **huy hiệu** là con trỏ mang
mã hồ sơ. Đảo ngược thông lệ (chứng chỉ là thật, bằng chứng không tra được) vì một tờ giấy ghi "đã
hoàn thành" phá đúng nguyên tắc nền của quy định tốt nghiệp. Chứng nhận in **con số thật của bốn
tiêu chí** chứ không một câu tuyên bố, và **không xếp loại** giỏi/xuất sắc: rubric đã tính 96 và 82
như nhau, thêm tầng xếp loại là mở lại cuộc đua điểm. Xác minh qua URL mang **mã mờ** (không tên,
không ngày sinh, theo luật cấm dữ liệu cá nhân trong URL), **không cần tài khoản**; hồ sơ bị thu hồi
thì trang nói rõ đã thu hồi kèm ngày chứ không 404. Mặc định hồ sơ **riêng tư**, learner tự bật công
khai và tự chọn từng sản phẩm hiện hay ẩn. Luật không ngoại lệ: **nội dung reflection không bao giờ
vào hồ sơ công khai** (chỉ số lượng), vì reflection chỉ thật khi nó không phải màn trình diễn. Trạng
thái *Completed* và *Not Yet Graduated* cũng được cấp hồ sơ, ghi đúng trạng thái và phần còn thiếu,
cấm dùng chữ "tốt nghiệp". Trang nói thẳng đây không phải văn bằng được Bộ GD&ĐT công nhận. Khi
learner rời Nemo12: mang được toàn bộ dữ liệu ở định dạng mở, link hồ sơ vẫn sống, tắt công khai và
xoá được bất cứ lúc nào.

Danh sách các mảng Pearl còn thiếu nằm ở `/roadmap`; tính tới 16.09.2026 mục Hoàn thành không còn
trang stub nào.

### Cấp số SRC khi nhiều nhánh chạy song song (SRC-803, 2026-09-17)

`docs/intake.md` là sổ CHÈN-ONLY và mỗi dòng mang một số SRC duy nhất. `scripts/src-new.mjs` giữ
tính duy nhất ấy bằng ba lớp, và lớp thứ ba mới có từ SRC-803:

1. **Khoá `mkdir`** trong `--git-common-dir` — hai tiến trình trên cùng một máy không vào vùng ghi
   cùng lúc. Có từ SRC-457.
2. **Số mới = max(mọi số đang có, con trỏ) + 1** — con trỏ tụt lại cũng không cấp trùng. SRC-457.
3. **Ref giành trên remote**: `refs/src-claims/SRC-<n>`, một commit **mồ côi** rỗng cho mỗi số.

Lớp 3 tồn tại vì hai lớp đầu cùng mù một chỗ: chúng chỉ nhìn file `docs/intake.md` **của cây đang
chạy**. Ngày 2026-09-17, một nhánh worktree cấp SRC-798 rồi giữ nó nhiều giờ chưa gộp; phiên kế
tiếp đọc bản trên `main` — nơi dòng ấy chưa tới — và cấp đúng 798. Vài giờ sau lặp lại y hệt với
800\. Cả hai lần chỉ lộ ra lúc gộp và phải đánh số lại một nhánh đã xong việc.

**Vì sao là commit mồ côi.** Đẩy một ref MỚI luôn thành công; đẩy đè lên ref ĐÃ CÓ chỉ qua khi đó
là fast-forward. Commit không cha không bao giờ là fast-forward của bất cứ gì, nên lượt đẩy thứ hai
vào cùng một số chắc chắn bị từ chối — đúng ngữ nghĩa "tạo mới, cấm ghi đè" mà `git push` không có
cờ riêng. Trỏ ref vào một commit có sẵn (tip của `main` chẳng hạn) thì hai phiên cùng trỏ vào cùng
commit sẽ ra "Everything up-to-date", tức là thành công cả hai và cái khoá im lặng không khoá gì.

**Chỉ lời TỪ CHỐI của remote mới nghĩa là số đã bị lấy.** Mọi lỗi đẩy khác (mất mạng, thiếu quyền,
sai remote) được ném ra, không được coi là tranh chấp: nuốt chúng thì một trục trặc thoáng qua sẽ
lặng lẽ đốt một số, và một trục trặc dài sẽ đốt liền hai mươi lăm số rồi mới chịu dừng.

**Mất mạng thì vẫn cấp được**, nhưng script in cảnh báo rằng số CHƯA được giành — một phiên offline
phải làm việc được, nhưng im lặng cấp bừa là dựng lại đúng cái lỗ đang vá.

Sổ **có lỗ số là bình thường** và vô hại; số trùng thì tốn cả một lần đánh số lại. Xem số đã giành:
`git ls-remote origin 'refs/src-claims/*'`. Cổng: `scripts/check-src-claim.mjs` trong
`npm run check:code` — nó dựng hai bản sao thật tranh số và bắt buộc chúng không được trùng.

**Ref tự dọn khi hết việc (SRC-966, 22.09.2026).** Một ref giành chỉ có việc tới lúc số của nó vào
được bảng trong `docs/intake.md` trên `main`; từ đó trở đi nó thừa, vì bước tính "số đã dùng" đọc
chính bảng ấy. Nên sau mỗi lần cấp số thành công, `src-new.mjs` xoá những ref có số đã nằm trong
bảng trên `main`.

Vì sao đáng làm: lệnh `git ls-remote` ở trên là lệnh người ta gõ để trả lời "số nào đang được giữ".
Ngày 22.09.2026 nó in ra **147 dòng, trong đó 142 đã gộp từ lâu** — một câu trả lời đúng nhưng
không đọc được là một câu trả lời hỏng. Dọn tay một lần thì vài giờ sau lại đầy (21 ref sau nửa
buổi), nên chỗ dọn phải nằm ngay trong đường cấp số. Sau khi bật, cùng lệnh ấy in ra 4 dòng: ba số
cũ chưa gộp và số vừa giành.

Ba điều bước dọn KHÔNG làm, cố ý: không đụng số vừa giành (số ấy chưa vào `main`); không đụng số
của nhánh chưa gộp — đó chính là thứ ref này sinh ra để bảo vệ; và không bao giờ làm lệnh đỏ, vì
việc chính là cấp số, còn một lượt dọn trượt chỉ để lại vài dòng thừa cho lần sau.

### Cấp số MIGRATION: cùng cơ chế, một khác biệt (SRC-929, 20.09.2026)

Ngày 20.09.2026 tai nạn ấy lặp lại nguyên xi ở một cuốn sổ khác. Hai phiên cùng lấy `0267` — một
phiên cho `ielts_micro_progress`, một phiên cho `warmup_set_exactly_three` — vì cả hai đọc
`migrations/` của cây mình, và thư mục mỗi bên đều không chứa file của bên kia. CI đỏ sau mười ba
phút với `AS-04.1.1 — migration trùng số`, và một nhánh đã xong việc phải đánh số lại.

`scripts/migration-new.mjs` mang đúng ba lớp của SRC-803 sang, với ref `refs/migration-claims/<nnnn>`.
Ở đây thư mục `migrations/` đóng vai cuốn sổ: tên file CHÍNH LÀ số, nên không có bảng nào để chèn.

**Khác biệt phải nhớ: dãy migration không được có lỗ.** Cổng AS-04.1.1 đòi `0001..N` liên tục, nên
một số giành rồi bỏ làm đỏ CI của mọi phiên - ngược hẳn với sổ SRC, nơi lỗ số là bình thường. Hai
hệ quả trong thiết kế:

* Giành số xong thì script **dựng file ngay**, kèm khuôn chú thích, để số ấy có mặt thật trong nhánh
  chứ không sống như một lời hứa.
* Bỏ việc giữa chừng thì phải **trả số**: `npm run migration:new -- --release 0269`. Script từ chối
  trả một số đang có file thật trong cây, vì trả nó đi là mời phiên sau lấy trùng.

Cổng: `scripts/check-migration-claim.mjs` trong `npm run check:code`, dựng hai bản sao thật tranh
số y như `check-src-claim.mjs`, và kiểm thêm đường trả số.

## 11. Configuration rules (REQ-SEC-06)

Cấm hardcode: domain names, school ids, owner emails, Access audiences, upstream URLs — tất cả qua `vars`/secrets trong wrangler config (`packages/shared/config` là dự định, chưa dựng — tính tới 2026-08-20 mọi cấu hình nằm trong `wrangler.jsonc` của từng worker và được cổng `verify-bindings.mjs` đối chiếu với `reference/config.md`). (Fix RISK-013/016/023 — hai legacy hardcode domain ở 45+ và ~30 files.)

## 12. Hai lỗi im lặng, và cái gác cho lớp lỗi thứ hai (SRC-887, 2026-09-20)

Hai lỗi này được sửa một lần ở nhánh `session/code-hygiene` ngày 10.09 nhưng nhánh ấy kẹt lại vì
`SRC-698` bị cấp trùng, nên bản vá chưa bao giờ lên production. Mười ngày sau, cả hai vẫn còn.

**Lỗi 1 — chuỗi escape in nguyên văn.** `apps/mentors/src/FamilyWorkspace.tsx` viết
`hint='e.g. "Minh\u2019s family"'`. Trong chuỗi nháy đơn của một THUỘC TÍNH JSX, `\u2019` không
phải escape mà là bốn ký tự thường, nên mentor đọc đúng chữ `Minh\u2019s family` trên màn hình.
Sửa bằng cách bọc thành biểu thức JS: `hint={'e.g. "Minh\u2019s family"'}`. Không thay bằng ký tự
`’` viết thẳng, vì cổng SRC-379 chỉ cho chữ người dùng đọc dùng ký tự có trên bàn phím.

**Lỗi 2 — nút tab hiện rỗng.** `apps/coral/src/App.tsx` khai `TABS = [{ id: "lab", key: "labs" }]`
nhưng bảng nhãn `T` không có khoá `labs` ở cả hai thứ tiếng. Tra một khoá không tồn tại trả về
`undefined`, và React vẽ `undefined` thành rỗng — nút trống trơn, không lỗi, không log.

**Vì sao TypeScript không cứu được, và cái gác mới.** Kiểu của bảng là `Record<string, string>`
nên mọi chuỗi đều hợp lệ về mặt kiểu; không cổng nào có thể đỏ. Nay có
`scripts/check-ui-label-keys.mjs` trong `npm run check:code`: nó đối chiếu mọi khoá mà `TABS` tra
với các bản đồ ngôn ngữ trong `T`, và đỏ khi thiếu.

Cổng đặt ở `scripts/` chứ không phải unit test vì `apps/coral` không có bộ test nào, và dựng cả
một bộ chạy thử chỉ để so hai danh sách chuỗi thì đắt hơn thứ nó bảo vệ. Phạm vi cố ý hẹp — chỉ
những file khai đúng hình dạng ấy, hiện chỉ có Coral; app nào chép lại hình dạng thì thêm một dòng
vào `TARGETS`.

**Điều đáng rút ra.** Cả hai lỗi đều thuộc loại HỎNG IM LẶNG: màn hình vẫn vẽ, không có ngoại lệ
nào, và chỉ người nhìn vào đúng chỗ mới thấy. Chúng sống mười ngày sau khi đã có bản vá. Bài học
không phải "viết cẩn thận hơn" mà là: lỗi nào không tự báo thì phải có một phép đối chiếu bằng máy,
và phép ấy phải được kiểm là **có đỏ thật** — bản vá này được xác nhận bằng cách gỡ ra cho cổng đỏ
rồi lắp lại cho cổng xanh.

## 13. Hai lỗi im lặng nữa, từ audit theo khía cạnh (SRC-1222, 03.10.2026)

* **Deploy hỏng mà CI xanh.** `.github/scripts/deploy.sh` đọc `rc=$?` SAU `if wrangler deploy ...;
  then exit 0; fi`. Không nhánh nào chạy thì `$?` của cả câu `if` là 0, nên sau ba lần hỏng script
  thoát 0. Nay `rc` lấy ở nhánh `else`; kiểm bằng một `wrangler` giả luôn thoát 7, script thoát 7.
  Rà 25 lượt ci xanh gần nhất trên main: chưa lượt nào dính.
* **Khuôn schema của test bị chính lớp dọn rác xoá.** `runlog.testkit.ts` quét file
  `nemo12-schema-*` cũ hơn 6 giờ, mà khuôn đặt tên theo nội dung nên dùng mãi một file và mtime
  không đổi: lượt chạy đầu tiên sau 6 giờ xoá khuôn giữa lúc worker khác đang copy (ENOENT,
  "no such table"), vài chục ca đỏ chập chờn bị đọc nhầm là "máy chậm". Nay mỗi process chạm mtime
  khuôn trước khi quét, và copy hỏng vì ENOENT thì dựng lại khuôn một lần.

Cùng đợt: 30 lỗi khác ở chi phí AI (mọi lượt gọi của learner, kể cả Whisper/TTS và lượt hỏng, tính
vào trần USD ngày; trần foundry đóng khi không kiểm được), timeout gọi dịch vụ ngoài, dialog bàn
phím (Escape, giữ focus), ngày DD.MM.YYYY, escape HTML trang huỷ đăng ký, ghi theo lô. Còn mở:
trần USD foundry chưa tính chi phí lần thử lại của workflow (cần cột mới).

## Trace

| REQ | Mục |
| --- | --- |
| REQ-PLT-01..05,07,08,10,11 | §1–§5, §7, §9, §10 |
| REQ-ACC-01..06 | §6 |
| REQ-BRD-01/02 | §4 |
| REQ-BRD-12 | §10 |
| REQ-SCH-01 | §4 |
| REQ-SEC-05/06, REQ-MEN-01 | §6, §11 |
