SDD-004 — Curriculum Storage & Learning Experiences
Nguyên tắc: Hierarchy để tổ chức · Graph để biểu diễn quan hệ · Registry để quản lý identity · R2 chứa nội dung lớn · Learning Experiences là reusable objects, không khóa cứng trong course. Load structure first, content on demand.
1.1 docs là nguồn, Pearl và D1 là bản dẫn xuất (SRC-497)
Chủ dự án chốt 2026-08-22: "docs.nemo12.com là chỗ lưu thông tin cốt lõi, source of truth, để tôi đọc và AI Agents đọc, và nó lưu CẢ năng lực LẪN kiến thức. Sau đó D1 lấy thông tin từ docs, Pearl cũng lấy thông tin từ docs."
Trước quyết định này, thực tế có ba nơi giữ sự thật mà không nơi nào giữ đủ:
| Giữ ở đâu | Giữ cái gì | Vấn đề |
|---|---|---|
apps/pearl/content/ (curriculum) | cây NĂNG LỰC (55 package, 205 module, 800 unit) | docs chỉ nói VỀ nó, muốn biết chương trình có gì thì phải mở site |
D1 skill_nodes | cây KIẾN THỨC theo chủ đề (42 strand, 1.790 node) | không có bản đọc được cho người, sửa phải qua SQL |
docs/ | chuẩn, luật, thiết kế | không giữ chính nội dung mà nó đặt luật |
Hình dạng mới:
docs/curriculum/<môn>/… cây NĂNG LỰC (SRC-497) ──gen-pearl-from-docs──► site Pearl (SINH RA)
docs/knowledge/<môn>.md cây KIẾN THỨC (SRC-498) ◄──export-knowledge──── D1 skill_nodes
(chiều ngược docs ──► D1: chưa dựng)Hai luật đi kèm, nếu không có thì "nguồn duy nhất" chỉ là một câu khẩu hiệu:
- Bản dẫn xuất không được sửa tay.
npm run check:docschạygen-pearl-from-docs.mjs --check, đỏ ngay khi bản sinh trongapps/pearl/content/lệch khỏi nguồn, kể cả lệch bằng một file thừa. Bản sinh cũng KHÔNG nằm trong git: nó được dựng lại ngay trước khi build site. - Cái gì có ở bản dẫn xuất mà không có ở nguồn là lỗi của NGUỒN, không phải chuyện nhỏ của bản dẫn xuất. Chủ dự án nói thẳng thứ tự: D1 nhiều hơn docs thì làm cho docs đủ trước đã, rồi mới trích xuất ngược ra.
docs/ từ nay chứa hai loại file, chịu hai bộ luật khác nhau, và đây là chỗ dễ hiểu nhầm nên nói rõ: tài liệu quản trị (PRD, SDD, reference) chịu luật frontmatter và đóng vết REQ; dữ liệu chương trình dưới docs/curriculum/ chịu luật của chuẩn curriculum (số lượng từng tầng, sáu phần bắt buộc mỗi Unit, cấm em dash). Bắt loại thứ hai đeo frontmatter kiểu PRD chỉ tạo ra 288 khối siêu dữ liệu vô nghĩa.
Cây kiến thức nay đã có mặt trong docs (SRC-498)
docs/knowledge/<môn>.md giữ trọn 1.790 node của cả 11 môn, gồm 42 strand theo chủ đề vốn chỉ tồn tại trong D1 và không ai đọc được nếu không viết SQL. Mỗi dòng là một knowledge node kèm Module, Unit, mã node, lớp, mức B21 và số câu hỏi đang có.
Phải nói thẳng để không ai hiểu nhầm mức độ hoàn thành: đây mới là một nửa. Chiều D1 → docs đã chạy (npm run gen:knowledge, có cờ --check để biết bản trong docs đã cũ tới đâu), còn chiều docs → D1 thì chưa. Nghĩa là hôm nay docs đã ĐỦ, đúng thứ tự chủ dự án chốt, nhưng sửa tay trong docs/knowledge/ vẫn chưa đi ngược về runtime được. Cho tới khi có bộ seed đọc từ docs, D1 vẫn là nơi ghi của cây kiến thức và docs là ảnh chụp có kỳ hạn.
Hai mảnh từng thiếu nay đã vào docs (SRC-501), nằm ngay trong file của từng môn để người đọc không phải tự ghép ba thư mục lại:
| Mảnh | Trong docs | Ghi chú |
|---|---|---|
| Cạnh tiên quyết | 1.109 cạnh, kèm câu vì sao của từng cạnh | thứ engine dùng để mời learner quay lại vá nền |
| Sổ lab | 1.587 lab, cột Gắn node trống là lab chưa ghi được bằng chứng | dò lab mồ côi bằng mắt, không cần SQL |
| Kho câu hỏi | chỉ mục theo node: bao nhiêu câu, đang dùng bao nhiêu, khoảng độ khó | KHÔNG đưa nội dung câu |
Chỗ kho câu là một quyết định có đánh đổi, nên nói rõ: đưa cả 15.575 câu vào docs vừa làm docs hết đọc được, vừa để lộ đáp án ra một trang công khai. Docs giữ chỉ mục và hợp đồng; bản đầy đủ đọc thẳng từ D1.
2. Curriculum Hierarchy
School → Subject → Area → Grade Band → Unit → Topic → {Knowledge Nodes, Skills, Learning Packages, Learning Experiences}. Grade là metadata/context, không phải ownership boundary (một node dùng G7–G9).
Luật độ dày (REQ-KNW-18, SRC-205): mỗi Module ≥ 3 Unit, mỗi Unit ≥ 1 node, mỗi node ≥ 10 câu (REQ-KNW-17). Module một-hai unit làm learner tưởng đã xong cả mảng khi mới xong một bài, và lưới module trên Toàn cảnh trông như dữ liệu hỏng. Unit bổ sung phải nằm đúng phạm vi module và nối tiếp sư phạm (dễ→khó hoặc bù khía cạnh còn thiếu), không phải chẻ nhỏ một bài sẵn có ra cho đủ số.
3. Registry, không phải cây document khổng lồ
Mỗi entity một record: subjects, areas, units, topics, knowledge_nodes, skills, learning_packages, learning_experiences, labs. Quan hệ ở bảng riêng entity_relations {parent_of, contains, prerequisite_of, teaches, assesses, related_to} — một Skill tái sử dụng ở nhiều grade/school/package/assessment/lab. (Fix RISK-014: cấm content-as-migrations kiểu mori 15.9k SQL trộn DDL + data.)
4. Storage 3 lớp (REQ-KNW-06)
| Lớp | Chứa |
|---|---|
| D1 | IDs, hierarchy, relations, metadata (type, title, grade_min/max, difficulty, duration, status, version, quality score), content_ref |
| R2 | Markdown, JSON payload lớn, images/diagrams/audio/video, simulations, lab assets, datasets, PDFs — key dạng r2://learning-experiences/math/algebra/lx-283/v7/content.json |
| KV/Cache | published manifests, config, resolved learning paths, static catalogs |
5–8. Learning Experience types (REQ-KNW-04)
LearningExperience {identity, targets, prerequisites, metadata, experience_type, content_ref, evidence_contract} — experience_type quyết định renderer/behavior:
- Micro (30s–5'): chọn đáp án, điền, matching, reorder, prediction; session nối 5–20 micro.
- Practice (10–30'): 10–20 items từ Item Bank, không duplicate content.
- Assessment (subtype riêng): blueprint + item selection + scoring + timing + evidence + interpretation; các dạng Diagnostic/Placement/Readiness/Mastery Check/Adaptive/Exam Simulation/Quick Check/Oral/Writing/Project/Performance.
Blueprint → Item Bank → Selection Engine → Assessment Instance(mỗi learner một đề khác nhau). - Lab (asset lớn nhất): objective, concepts, skills, instructions, materials, stages, observations, data, questions, reflection, evidence. Curriculum chỉ reference lab — chi tiết Lab Platform: SDD-010.
- Project / Simulation tương tự — entity độc lập, metadata hóa (
target_skill, difficulty, estimated_time, pedagogy, prerequisites, expected_evidence, age_range, interaction_type) để Recommendation Engine chọn experience, không chỉ course.
9. Item Bank (REQ-KNW-05)
Registry riêng: MCQ, multi-select, short answer, numeric, ordering, matching, essay, coding, drawing, interactive. Một question xuất hiện trong practice/assessment/micro/diagnostic — không duplicate. (Hợp nhất 4 item stores legacy — RISK-006; kho đề thi thật exam_problems giữ registry riêng thuộc Shark, liên kết qua relations — Q-038, Q-041; thiết kế chi tiết: SDD-012.)
10. Manifest (REQ-KNW-06)
Unit/Package publish manifest nhẹ (danh sách experience IDs); frontend tải manifest trước, heavy content chỉ tải khi mở experience.
11. Versioning (REQ-KNW-12)
Asset immutable theo version (lab-0042/v3); learner session ghi {experience_id, experience_version} — sửa content sau vẫn biết learner học bản nào.
12. Cây Building 21 — luật dựng nội dung môn (SRC-247, SRC-270, SRC-273)
Cây năng lực Building 21 (source='b21' trong skill_nodes) là hình dạng chuẩn cho Toán và Tiếng Việt. Luật dựng, không phải số hiện trạng:
- Pearl là nguồn, D1 là bản nạp. Cây B21 nạp vào D1 bằng
scripts/curriculum-parse.mjs+scripts/seed-b21-tree.mjs— parse markdown của trang Pearl chứ không gõ lại bằng tay. Gõ lại là tạo nguồn thứ hai, và nguồn thứ hai luôn lệch (xem §1.1). - Định mức nội dung mỗi môn: 30 Learning Experience + 30 Assessment Experience kiểu IB. Đây là sàn khởi động của một môn B21, không phải trần; con số 30 chọn để mỗi lớp trong dải ưu tiên có đủ bài mà vẫn soạn xong trong một đợt.
- Assessment là bài đo trên chính node, rút từ kho câu chung, không phải bộ câu riêng. Nhờ vậy mỗi câu sai truy ngược được về đúng Unit và đúng hiểu lầm.
- Mỗi đáp án nhiễu phải gắn một hiểu lầm có thật (
items.misconception); câu hỏi dẫn dắt kiểu IB nằm ởskill_nodes.objective_vi. Nhiễu ngẫu nhiên không đo được gì. - Toán sinh bằng hàm có kiểm chứng; Tiếng Việt viết tay từng ngữ liệu. Không công thức nào sinh nổi một câu văn đáng đọc — đây là luật, không phải tình trạng tạm.
- Thứ tự mở rộng lớp: 4-7 trước (SRC-247), rồi xuống 1-3 (SRC-270), rồi lên 8-9 (SRC-273). Lớp giữa là chỗ learner đông nhất và là chỗ nền vỡ lộ ra rõ nhất.
- Gắn lớp cho unit bằng greedy cân đều trên dải lớp đang mở. Lấy giữa khoảng thì unit dồn cục vào một lớp.
- Rollback bằng một câu DELETE nhờ cột
source. Mọi đợt seed cây mới phải giữ tính chất này.
Bẫy khoá đã mắc một lần (SRC-273), ghi lại để không mắc lại: items.id là khoá chính toàn cục, còn skill_nodes khoá theo (subject_id, id). Hai môn có node trùng tên sẽ ghi đè câu của nhau trong khi cây vẫn trông đúng. Vì vậy mọi id node của một nhánh mới phải mang tiền tố riêng của nhánh (tiền lệ: b21g13 cho nhánh lớp 1-3), và mọi script seed phải đặt id câu theo node đã có tiền tố.
13. Ba loại experience đều là nút, mỗi loại một địa chỉ (SRC-248)
Trong thẻ Unit, Learning Experience, Assessment Experience và Lab đều là nút bấm được, mỗi loại đi tới một trang riêng có URL chia sẻ được. Trước SRC-248 chỉ chấm Lab bấm được, hai loại kia là hình trang trí — learner thấy có nội dung mà không mở được.
Nhãn aria-label của mỗi nút phải kèm loại experience, vì bài học thường trùng tên với lab của chính Unit đó và người dùng trình đọc màn hình sẽ nghe hai nút giống hệt nhau.
14. Sàn phủ đầu năm học: 3-4 Unit mỗi lớp (SRC-320)
Chủ dự án chốt 2026-08-18: đầu năm học, mỗi lớp chỉ cần 3-4 unit là đủ. Đây là luật sàn phủ, áp vào ba chỗ:
- Kiểm tra sẵn sàng của một môn theo lớp: một lớp coi là đã mở được khi có ≥ 4 unit, mỗi unit đủ ≥ 6 items (ngưỡng
assessReady). Dưới sàn thì lớp đó chưa nên hiện lối vào. - Thứ tự làm việc khi dựng môn mới: dựng khung unit trước cho đủ chiều rộng, nạp items sau. Khung rỗng dò được bằng máy; thiếu khung thì không ai biết còn thiếu gì.
- Ưu tiên khi phân bổ công soạn: lớp chưa đạt sàn được ưu tiên trước lớp đã đạt sàn muốn dày thêm.
Sàn này không thay thế luật độ dày ở §2 (Module ≥ 3 Unit, node ≥ 10 câu) — nó chỉ nói đủ để khai giảng, còn §2 nói đủ để học hết.
15. Unit không có nguồn i_will thì ẩn strand, không xoá (SRC-449, SRC-465)
i_will của mỗi Unit phải sync được từ cây năng lực Pearl. Unit không tra được nguồn i_will nghĩa là nó thuộc cây kiến thức theo chủ đề cũ chứ không thuộc cây năng lực — hai trục cùng chạy.
Luật xử lý, theo REQ-KNW-16 và tiền lệ Toán/Văn/Anh:
- Ẩn cả strand chủ đề (
HIDDEN_STRANDS), không xoá dữ liệu. Câu hỏi và lab của các unit đó nằm im theo node, evidence của learner giữ nguyên, chờ đợt trỏ về cây năng lực. Đây là quyết định có mất mát tạm thời và phải nói ra như vậy, không được gọi là dọn dẹp. - Trang Pearl của môn bị ẩn strand phải sửa lời cho khớp — bỏ cách nói "hai trục cùng chạy" khi chỉ còn một trục hiện ra (luật S11 của chuẩn curriculum: số liệu và mô tả trong văn xuôi phải khớp thực tế).
- Môn nào Pearl đã có cây năng lực riêng khác hẳn strand đang chạy thì không vá i_will được — phải làm một đợt chuẩn hoá kiểu Tin học: tách module Pearl ra trang riêng →
sync-tree→re-home→ ẩn strand cũ. Vá i_will lên cây sai trục chỉ tạo ra vết khớp giả.
Hai môn đi theo đường (3) và đã xong là IELTS (package I1-I5) và SAT (package SA1-SA5) (SRC-465): cây mới nhận i_will/evidence/misconception sync từ Pearl, strand kỹ-năng-đề-thi cũ vào HIDDEN_STRANDS, nội dung soạn mới theo định mức 6 câu/node, mỗi unit một lab picker nhắm đúng hiểu lầm/bẫy chấm của unit đó, và graph tiên quyết có câu vì sao cho từng cạnh (kể cả cạnh xuyên Package). Đo cuối 2026-08-21: cả hai môn đủ lab mọi unit, 0 unit dưới sàn câu, AUDIT sạch.
16. Gắn hình cho câu hỏi: bản đồ soạn tay, không suy từ id (SRC-492, SRC-496)
scripts/attach-figures.mjs ban đầu có bảng BY_NODE suy dạng hình từ id node. Cách đó chỉ đúng với nhánh Hình học cũ, nơi id đã tự nói ra hình (pythagoras, inscribed-angle). Cây B21 đặt tên node theo năng lực và mỗi Unit có nhiều node, nên suy từ id là bịa.
Luật:
- Hình gắn theo bản đồ soạn tay
node → hình, nạp bằng cờ--map; quét cả cây bằng--strand. - Hình đến từ bản đồ KHÔNG bị
refinetheo từ khoá đề, vì tham số của nó đã được chọn cho đúng Unit; refine chỉ dành cho hình suy đoán. - Mỗi dòng bản đồ phải kèm câu vì sao gắn hình này — bản đồ không có lý do là bản đồ không kiểm được.
- Thà thiếu còn hơn gắn sai: node mà hình không giúp hiểu (an toàn phòng thí nghiệm, đạo đức sinh học, kỹ năng viết) thì bỏ trống có chủ đích, không gắn cho đủ.
Tầng 1 của cơ chế này áp cho ba môn khoa học Lý, Hoá, Sinh (SRC-496) và chính việc soạn bản đồ lộ ra một lỗ hổng engine phải ghi lại: renderer coordinate chỉ vẽ điểm rời, nên đồ thị khoa học mất hết ý nghĩa — đoạn nằm ngang của đường đun nóng chính là chỗ chất đổi trạng thái. Nay engine có thêm dạng line. Luật rút ra: dạng hình phải diễn đạt được ý nghĩa sư phạm của dữ liệu, không chỉ hiển thị đúng các con số.
17. Nâng câu candidate lên published là cron, không phải nút (SRC-538)
Cổng chất lượng của kho câu giữ nguyên ngữ nghĩa: câu qua sàng lọc máy không tì vết thì lên kệ; câu bị gắn cờ vẫn ghi hồ sơ rồi giữ lại chờ người rà. Cái đổi là ai bấm.
- Bước nâng chạy tự động mỗi giờ trong worker (cron
10 * * * *), dùng cùng một hàmpromoteScreenedCandidatesvới nút ở Admin → Ops, nên không có hai đường vào với hai luật khác nhau. - Phút 10 chứ không phải phút 0, để không chạm hai cron khác của worker (19h và 21h UTC).
withLockbắt buộc, chống hai lần cron chồng nhau.
Lý do bỏ bước bấm tay: candidate chất đống chặn mọi đợt nội dung mới. Ngày 2026-08-24 có 106 câu của SRC-531 và SRC-537 nằm chờ đúng kiểu đó trong lúc chủ dự án bận. Luật chung: bước cổng chất lượng nào chỉ chạy khi có người nhớ ra thì sớm muộn cũng thành nút cổ chai — hoặc tự động hoá, hoặc phải có báo động khi hàng chờ vượt ngưỡng.
18. Trang danh sách Unit — ba cột chấm tròn (SRC-539)
Coral có tab Unit (/{school}/{subject}/unit, dữ liệu từ GET /v1/coral/units) liệt kê tất cả Unit của môn kèm Package và Module chứa nó, cùng ba cột chấm tròn: Learning Experience, Assessment Experience, Lab.
Luật dựng, phần quan trọng nhất của trang này:
- Ba cột phải dựng theo đúng
unitPathmà app learn dùng, không được đếm riêng. Bảng đếm riêng sẽ nói unit đã đủ trong khi learner mở ra thấy trống — và không ai phát hiện được vì hai bên không bao giờ so nhau. - Quy ước màu chấm (dùng lại ngưỡng đã có, không đặt ngưỡng mới): LX = mỗi skill_node một chấm, xanh khi ≥ 10 câu (REQ-KNW-17), vàng 1-9, rỗng 0, đỏ khi node đang ẩn. AX =
skip·mid(chỉ hiện khi unit có ≥ 2 node) ·final, sẵn sàng khi unit ≥ 6 câu (ngưỡngassessReady). Lab = mỗi dòng bảnglabsmột chấm. - Bộ lọc bắt buộc: chưa có lab · bài học mỏng · bài đo chưa sẵn sàng. Trang này tồn tại để tìm chỗ thủng, không phải để ngắm.
- Lab mồ côi phải hiện cảnh báo, không được lặng lẽ bỏ qua. Lab có strand/module/unit không khớp Unit nào là dữ liệu hỏng; bỏ qua im lặng làm số liệu phủ trông đẹp hơn thực tế. Đo 2026-08-24 môn Toán: 98/161 lab mồ côi — chính trang này lộ ra.
19. Tầng Course kiểu CEFR (SRC-609)
Chủ dự án chốt qua chuỗi thiết kế 2026-08-26/27: thêm tầng Course trên cây kiến thức, mô hình kiểu CEFR — ladder (mạch) → level 1-5 (thay khái niệm lớp; xếp lớp bằng bài đo mở đầu, lớp chỉ còn là thuộc tính learner) → course → unit → lesson. Bốn bảng mới tinh trong migration 0084_curriculum_courses.sql, cố ý không đụng bảng courses (lớp học có lịch, SRC-550):
curriculum_courses— mỗi course một vạch đích; UNIQUE (subject, ladder, level, seq).curriculum_course_sources— nguồn theokind:gdpt(mã bảng chuẩn phủ) ·b21·aops·material(ngữ liệu Văn) ·competency(≥3/course) ·note(ô trống có chủ ✍️, luật S10).curriculum_units— mỗi unit 1 Big Idea + 1 Essential Question;curriculum_unit_elementsgiữ 3-5key_concept, 2material, 2-3 khung tư duy (framework/model/formula/principle).curriculum_lessons— mỗi lesson 1 Guiding Question (đường hiển thị dùng lạiobjective_vicủa node) + 1-2 concepts lấy từ key concepts của unit.
Dữ liệu seed đợt đầu, tất cả status='draft': Toán 30 course (arithmetic 6 · algebra 10 · geometry 10 · data 4 — ladder data là nhà của 19 chủ đề TK/XS/DL trước đây không mạch nào nhận), Ngữ văn 15 course (ladder core), và course V8 "Biện pháp tu từ" soạn trọn 4 unit × 5 lesson làm mẫu chuẩn. Bất biến đã kiểm bằng máy lúc sinh migration: 82/82 chủ đề GDPT Toán phủ trọn, mỗi chủ đề đúng một course (4 chủ đề tiền-đại-số thuộc ladder algebra — chuyển mạch có chủ đích, không đếm hai lần).
Migration 0162 (SRC-631) thêm hai bảng cho bốn chặng của một Lesson (CD-12): curriculum_lesson_stories — Story mở bài theo khuôn SCQA, bốn cột chứ không một ô văn bản vì bốn phần hiển thị khác nhau và người soạn hay bỏ quên phần "chỗ vướng"; và curriculum_lesson_reflections — bài ngẫm của learner, một learner một bài đúng một bản ghi, viết lại thì đè lên. Nội dung Story của S1 · A8 · V11 · V12 nạp ở migration 0163.
Migration 0180 (SRC-654) thêm curriculum_stage_completions: learner xác nhận đã xong TỪNG CHẶNG của một bài (CD-12), không phải xong cả bài. Bấm lại là xoá dòng, nên bảng này giảm được — một chỉ báo chỉ biết tăng thì không còn là chỉ báo.
Migration 0085 (SRC-611) thêm tầng đánh giá: curriculum_unit_outcomes / curriculum_unit_tasks + curriculum_task_criteria (Performance Task, tiêu chí nối outcome) / curriculum_lesson_outcomes / curriculum_lesson_materials (học liệu youtube·pdf·lab·link kèm reflection, CHECK ép lab dùng ref_id còn lại dùng url) / curriculum_lesson_checks (Formative Task, kind theo SDD-022 + reflection). Migration 0086 (SRC-616) thêm bảng spec cho phần còn lại của CD-8/9/10: curriculum_course_specs/_course_outcomes/_course_prereqs, curriculum_unit_specs/ _unit_prereq_notes/_unit_misconceptions, curriculum_lesson_misconceptions/_lesson_errors/ _lesson_interventions; đồng thời seed 10 course Tin lập trình thi đấu + 15 course Tiếng Anh (ladder core). Nội dung course soạn ở scripts/course-content/<id>.json, đổ bằng scripts/course-content-to-sql.mjs, chấm bằng scripts/audit-course.mjs theo rubric /100 — ngưỡng ≥90 mới commit.
Ba luật giữ tầng này khỏi hỏng: (1) competency không chia cho các course — mọi course chạm cả bộ năng lực của môn ở mức của nó, chỉ ngữ liệu mới là phân hoạch; (2) xong = bằng chứng theo competency, không phải đi hết lesson (tránh học vì thưởng — lý do REQ-GAM-01 hoãn); (3) course nâng live chỉ khi mọi lesson đủ 16 câu published. Chuẩn số lượng và câu chữ: course-design-standard (CD-1..CD-5); quy trình soạn: skill /course-design; chỉ tiêu audit: CS-10.
20. Chứng chỉ khoá học: suy ra, không lưu (SRC-1331)
Chủ dự án 10.10.2026: learner xem chứng chỉ mình đã có và sắp có ở learn; bố mẹ xem của con ở marlins. Luật đi kèm: không bịa chứng chỉ hay con số, khoá không có tiêu chí hoàn thành trong dữ liệu thì không hiện.
Không có bảng chứng chỉ. Chứng chỉ là một phép đọc trên dữ liệu hoàn thành đã có (workers/api/src/modules/certificates/service.ts). Lưu riêng thì có hai sự thật: learner bỏ tick một bước là sổ hoàn thành đổi mà chứng chỉ thì không. (Bảng certificate_codes ở §20f chỉ là chỉ mục tra mã, không giữ chứng chỉ.)
| Họ khoá | Bài xong khi | Khoá xong (earned) khi | Sắp có (in_progress) khi |
|---|---|---|---|
Curriculum (curriculum_courses status live) | có dòng curriculum_stage_completions stage close (chặng Tổng hợp, chặng cuối, luôn hiện vì có bài ngẫm) | mọi lesson của khoá xong | ít nhất một bài xong, hoặc có curriculum_entitlements |
| AI Teen 1..3 (SDD-043 §15) | mọi bước self của lesson trong stepCatalog.ts có dòng ai_teen_step_ticks | mọi lesson trong bản kê xong | ít nhất một tick, hoặc ai_teen_enrollments active |
earned_at= mốc hoàn thành muộn nhất trong các dòng trên. Hiển thị DD.MM.YYYY giờ Việt Nam.verification_id=NEMO-+ 10 ký tự hex đầu của SHA-256(learner|course|earned_at): ổn định qua mọi lần mở, đổi nếu learner bỏ tick rồi tick lại (vì ngày cấp đổi theo). Tra mã công khai: §20f (SRC-1333).- Khoá không có lesson nào bị bỏ qua (không có tiêu chí). IELTS, SPEAK, lớp theo lịch (
courses) và khoá cho bố mẹ chỉ ghi ghi danh, điểm danh hoặc tiến độ luyện, không có luật "xong khoá", nên không sinh chứng chỉ.
API (Hono + zod-openapi, requireSession + requireLearnerAccess, nên chính learner, người trong gia đình, và vai nội bộ có dòng audit):
| Route | Trả về |
|---|---|
GET /v1/learners/{learnerId}/certificates | { learner_name, certificates[] }, đã có trước (mới nhất trước), rồi sắp có (gần xong trước) |
GET /v1/learners/{learnerId}/certificates/{courseId} | { learner_name, certificate }; 404 khi khoá không có chứng chỉ nào cho learner |
Giao diện (một file CertificatesPanel.tsx chép giống hệt ở hai app):
- learn:
/certificates(danh sách) và/certificates/{courseId}(chứng chỉ in được, nút "In chứng chỉ" gọiwindow.print). Lối vào: mục "Chứng chỉ" dựng sẵn trongAvatarMenucủa mọi cổng learn có menu đầy đủ. Sân IELTS (onlyItems) không có mục này vì IELTS chưa có tiêu chí xong khoá. - marlins: cụm "Chứng chỉ của con" ở trang của con (ngoài trần bốn ô điều hướng, như cụm Báo cáo học tập), trang
/child/{id}/certificatesvà/child/{id}/certificates/{courseId}.
Kiểm chứng: certificates.test.ts (D1 thật từ migrations: luật suy ra cho cả hai họ, khoá draft / không bài / chưa bắt đầu bị bỏ, quyền: chưa đăng nhập, phụ huynh nhà khác, con đọc con nhà khác đều 401); e2e apps/learn/e2e/certificates.spec.ts và apps/marlins/e2e/certificates.e2e.ts (bộ Playwright đầu tiên của marlins, chạy trong CI ở bước "Smoke test marlins").
20f. Tra cứu chứng chỉ công khai theo mã (SRC-1333)
Chủ dự án 10.10.2026 (uỷ quyền): ai cầm chứng chỉ in ra (nhà trường, người nhận) kiểm được nó có thật không, mà không cần tài khoản; tối thiểu dữ liệu cá nhân.
API GET /v1/certificates/verify/{code}, không đòi phiên (khai trong PUBLIC của authCoverage.test.ts), rate limit certificateVerify 60 lượt/giờ cho một IP, Cache-Control: no-store. Trả đúng một trong hai dạng, luôn 200:
| Kết quả | Thân trả về |
|---|---|
| Mã khớp một chứng chỉ đang còn đạt | { valid: true, learner_name, course_title, earned_date } |
| Mã lạ, sai định dạng, hay hết hiệu lực | { valid: false }, cùng một dạng cho mọi trường hợp |
learner_name= tên gọi (từ cuối của họ tên) + chữ đầu của họ (từ đầu) và dấu chấm: "Nguyễn Văn Đắc" thành "Đắc N.". Không bao giờ trả learner id, email, họ tên đầy đủ, trường, gia đình, hay mã khoá.course_title= tên tiếng Việt của khoá;earned_date= ngày cấp theo giờ Việt Nam, dạng YYYY-MM-DD (giao diện hiện DD.MM.YYYY).- Không trả 404 cho mã lạ: một mã "không có" và một mã "có mà hết hạn" giống hệt nhau, nên người dò không học được gì về không gian mã. Không gian mã 40 bit cộng trần 60 lượt/giờ làm việc dò vô vọng.
Chỉ mục, không phải nguồn sự thật (migration 0344, bảng certificate_codes(code PK, learner_id, course_id, earned_at)). Mã là băm một chiều nên không đi ngược từ mã về learner được; không có chỉ mục thì mỗi lượt tra phải suy lại chứng chỉ của mọi learner, tốn theo số learner. Cách chọn: ghi LƯỜI. Mỗi lần listCertificates chạy (learner, phụ huynh hay vai nội bộ mở danh sách hoặc một chứng chỉ), mọi chứng chỉ đã đạt được INSERT OR IGNORE vào bảng. Như vậy là đủ, không phải gần đủ: mã chỉ tồn tại ngoài server sau khi có người mở chứng chỉ ra xem hay in, và đúng lần mở ấy ghi nó vào bảng. Lượt tra là một lần đọc theo khoá chính rồi suy lại chứng chỉ của MỘT learner.
Lúc tra, server không tin bảng: nó suy lại chứng chỉ của learner trong dòng và chỉ trả valid khi khoá ấy vẫn earned và băm của earned_at hiện tại vẫn ra đúng mã. Vì thế các trường hợp "thu hồi" tự rơi về valid:false mà không cần việc dọn: khoá rút khỏi live, learner bỏ tick (ngày cấp đổi nên mã đổi), learner archived, hay learner bị xoá (dòng đi theo ON DELETE CASCADE). Bảng mất (code chạy trước migration) thì ghi bị bỏ qua và tra trả valid:false, không lỗi 500.
Giao diện (learn, tiếng Việt, đứng trước mọi cổng đăng nhập): /verify có ô nhập mã và nút "Tra cứu" (đổi URL sang /verify/{code}, chữ thường được nâng thành chữ hoa), /verify/{code} tra ngay và hiện "Chứng chỉ hợp lệ" kèm người nhận, khoá, ngày cấp DD.MM.YYYY, hoặc "Không tìm thấy chứng chỉ hợp lệ". Trang chứng chỉ in được ở cả learn và marlins in thêm dòng "Tra cứu tại learn.nemo12.com/verify/{mã}".
Kiểm chứng: certificates.test.ts (mã hợp lệ trả đúng bốn trường và tên đã che; không chuỗi nào trong id, email, họ, tên đệm, gia đình, mã khoá lọt vào thân; mã lạ / sai định dạng cùng một dạng valid:false; đổi tiến độ hay rút khoá thì mã cũ thành invalid; lượt thứ 61 trong giờ bị 429); e2e apps/learn/e2e/certificates.spec.ts (trang tra hợp lệ không cần đăng nhập, ô nhập với mã sai, dòng URL tra cứu trên chứng chỉ).
Trace
| REQ | Mục |
|---|---|
| REQ-KNW-04 | §5–§8 |
| REQ-KNW-05 | §9 |
| REQ-KNW-06 | §4, §10 |
| REQ-KNW-12 | §11 |
| REQ-LRN-49 | §20 |
| REQ-LRN-50 | §20f |
| REQ-PAR-19 | §20 |