Sổ rủi ro của máy sinh nội dung
Mỗi mục dưới đây là chuyện đã xảy ra thật, không phải rủi ro giả định. Ghi lại vì cùng một kiểu hỏng đã quay lại nhiều lần dưới hình dạng khác nhau, và vì phần lớn chúng không báo lỗi
- hệ vẫn chạy, màn hình vẫn xanh, chỉ có kết quả là sai.
Đọc trang này trước khi sửa bất cứ thứ gì trong course-intelligence.
Loại nguy hiểm nhất: hỏng mà vẫn trông như đang chạy
Phép đo tự khen mình
Đã xảy ra: cột score ghi 1.0 cho mọi lượt sinh bất kể chất lượng. Ba lỗi độc lập cùng đẩy điểm lên 1.0, lọc finding sai khoá, mẫu số đếm nhầm, và model không được truyền xuống. Bảng so sánh model chạy suốt một thời gian trên dữ liệu bịa.
Bài học: một phép đo chỉ đáng tin khi nó có thể cho điểm thấp. Thêm phép đo mới thì việc đầu tiên là dựng một ca chắc chắn phải trượt và xem nó có trượt không.
Câu truy vấn nhầm họ bảng
api.conan.school có hai họ bảng cùng tồn tại: client D1 chung tự thêm tiền tố legacy_, còn course-intelligence dùng SQL thô không tiền tố. Nhầm họ thì câu truy vấn chạy êm và trả về rỗng, không báo lỗi gì.
Deploy trước khi áp migration
Đã xảy ra: nút "Dồn lực" lỗi 500. Nguyên nhân: mã đã lên nhưng bảng chưa có. Các endpoint đọc vẫn sống vì phần lớn câu truy vấn có .catch() trả rỗng, màn hình trông vẫn bình thường; chỉ endpoint ghi chết, và không có gì chỉ ra nguyên nhân thật.
Luật (bản 01.09.2026): file migration nằm cùng commit với mã dùng tới nó; CI áp migration trước bước deploy trong cùng job. Sau khi CI xanh, xác nhận bảng đã có bằng SELECT COUNT(*). (Bản cũ của luật này, "áp tay trước rồi push", đã hết hiệu lực.)
Cron dẫm lên nhau
Đã xảy ra: lượt cron ba tiếng vẫn nổ trong lúc chuỗi "dồn lực" đang chạy, hai orchestrator giành cùng một hàng việc, mỗi artefact bị sinh hai lần và hai lượt ghi đè nhau. Nay có nhịp tim (heartbeat_at): lượt cron nhường khi chuỗi còn đập, và nhận lại việc nếu nhịp tắt quá 90 phút.
Loại thứ hai: mất dữ liệu không lấy lại được
Ghi đè xoá mất bản thua
Đã xảy ra: mọi lượt sinh ghi đè thẳng vào bảng nội dung, nên khi cần so "model nào viết hay hơn" thì không còn hai bản để đặt cạnh nhau. Mọi bản sinh trước 29.08.2026 mất vĩnh viễn.
Nay course_content_candidates giữ lại mọi bản. Nhưng món nợ vẫn còn: máy sinh vẫn ghi đè thẳng vào bảng đang phục vụ người học, và bản thắng phiếu bầu chưa được đưa trở lại thành bản chính thức.
Sửa prompt mà quên tăng số hiệu
Sửa lời dặn máy mà không tăng PROMPT_VERSIONS[kind] trong arena.ts thì phiếu bầu A/B của prompt cũ và mới trộn vào nhau, và không ai biết nội dung tốt lên là nhờ prompt mới hay chỉ do may. Dữ liệu đó không tách lại được về sau.
Lộ tên model trước khi bầu
Nếu màn hình so bản để lộ bên nào do model nào sinh, phiếu bầu đo kỳ vọng chứ không đo chất lượng, và cái sai đó không gỡ ra khỏi dữ liệu được nữa. Vì thế nextArenaPair không trả vềmodel và prompt_hash, và bên trái/phải được tráo theo từng cặp.
Loại thứ ba: chậm và treo
Lượt gọi model treo vô hạn
Đã xảy ra: một lượt generate đứng ở trạng thái "đang chạy" hơn năm phút không có gì cắt nó. Cloudflare Workflows không tự cắt lượt gọi treo, nên ở chế độ dồn lực cả hàng việc đứng lại phía sau.
Nay có ba hàng rào, đặt ở callAI, cửa duy nhất mọi lượt gọi đi qua:
| Hàng rào | Giá trị | Vì sao |
|---|---|---|
| Trần thời gian | timeoutFor(): sàn AI_TIMEOUT_MS = 120s, nâng theo token tới 300 s; 600 s cho model lý luận | Thà hỏng nhanh, vào sổ, rồi thử lại, còn hơn treo cả hàng việc |
| Trần token một lượt | tokenBudget(): thường min(yêu cầu, 12 000), lý luận min(64 000, max(4×yêu cầu, 24 000)), thay cho hằng số MAX_TOKENS_CEILING = 6000 cũ | Những lượt gọi lớn là những lượt lâu nhất và hay treo nhất; trần theo loại model vì model lý luận tiêu token cho phần "nghĩ" |
| Trần số từ mỗi đoạn | maxWords: 300 (engine sinh văn) | Dặn ở cuối lời dặn hệ thống, câu dặn cuối là câu mô hình bám sát nhất |
Thời gian mỗi lượt được đo và ghi vào content_prompt_log (duration_ms, outcome), kể cả khi hỏng, lượt treo mới là lượt đáng đo nhất.
Trần token đi ĐÔI với trần schema
Nới schema (bài dài hơn, nhiều mục hơn) mà không nới max_tokens thì JSON bị cắt giữa chừng và cả lượt sinh hỏng. Hai con số đó luôn phải sửa cùng nhau. Đây cũng là lý do trần số từ được đặt ở mức đoạn văn chứ không phải cắt max_tokens xuống thấp: cắt token thô bạo sẽ làm hỏng những artefact nhiều phần (ba bài viết + chín ví dụ trong một lượt).
Vỏ bao của nhà cung cấp: lỗi tưởng là "model kém"
Đã xảy ra (29.08.2026), và đây là lỗi tốn nhiều thời gian nhất: mã đọc result.response, đúng với các model llama trên Workers AI. Nhưng DeepSeek V4 trả về vỏ bao kiểu OpenAI, choices[0].message.content. Nên result.response là undefined, cả cái vỏ bao được đưa thẳng cho zod, và zod báo "thiếu trường articles, thiếu trường inquiry".
Thông báo đó đúng theo nghĩa đen mà lạc hướng hoàn toàn: nội dung chưa từng được lấy ra khỏi vỏ. Nó dẫn tới kết luận sai "model không tôn trọng khuôn JSON", rồi tới cả một đợt viết mã gọi OpenAI/Google/Anthropic, trong khi lỗi nằm ở một dòng đọc sai trường.
Bài học: khi zod báo thiếu trường, nghi đường đọc trước khi nghi model. Và cùng một cổng (Workers AI) vẫn có nhiều dạng vỏ bao khác nhau tuỳ họ model.
Model lý luận: phần "nghĩ" cũng ăn token
Đã xảy ra cùng lúc: DeepSeek V4 viết phần suy nghĩ vào reasoning_content, và phần đó cũng tính vào max_tokens. Với trần 6000, model tiêu sạch ngân sách cho phần nghĩ rồi trả về content rỗng kèm finish_reason: "length", hết chỗ trước khi kịp viết câu trả lời. Đo được: 111-120 giây chỉ để nghĩ.
Trớ trêu là chính việc hạ trần từ 10.000 xuống 6.000 "cho nhanh" đã làm lỗi này nặng thêm.
Nay trần chia theo loại model (tokenBudget() / timeoutFor() trong authoring.ts): model thường tới 12.000 token / 120–300 giây, model lý luận tới 64.000 token / 600 giây. Hai con số này phải sửa cùng nhau, y như trần token đi cùng trần schema. (Các con số 6.000/24.000 và 120/300 giây trong các đoạn cũ của trang này là bản trước, giữ làm lịch sử.)
Model phớt lờ khuôn JSON
Đã xảy ra (29.08.2026): deepseek-v4-flash chạy 24-47 giây rồi trả về object thiếu hẳn các trường mà response_format.json_schema đòi (inquiry, articles…). Engine thử lại 3 lượt rồi bỏ cuộc, mỗi lượt sinh tốn hơn hai phút và không ra được gì.
Cùng lúc, llama-70b hỏng theo kiểu khác hẳn: Unterminated string in JSON at position 1504
- JSON đứt giữa chừng, tức model dừng sớm chứ không phải sai cấu trúc.
Bài học 1: hai lỗi này nhìn từ màn hình đều là "failed", nhưng cách chữa ngược nhau, một bên là model không làm được structured output, một bên là bị cắt độ dài. Đọc lỗi thật trong course_content_runs.error, đừng đoán từ nhãn failed.
Bài học 2, quan trọng hơn: đừng vội kết luận "model kém". Hai model rất khác nhau cùng hỏng một lúc thì thủ phạm nhiều khả năng nằm ở phía mình, không ở model. Mà lúc đó hệ không có cách nào kiểm chứng, vì câu trả lời thô của model bị vứt ngay tại chỗ JSON.parse, chỉ còn lại thông báo của zod kiểu "thiếu trường inquiry". Biết câu trả lời sai hình dạng, không biết nó là hình dạng gì.
Nay content_prompt_log.raw_reply giữ lại nguyên văn câu trả lời khi hỏng (4000 ký tự đầu, chỉ khi hỏng), hiện ngay cạnh prompt trên cả hai màn hình. Không có nó thì mọi kết luận về chất lượng model đều là suy đoán.
Luật rút ra: một đường ống gọi model phải giữ được cả hai đầu, thứ gửi đi và thứ nhận về. Thiếu một đầu là mù một nửa.
Bài học 3: JSON.parse thẳng là một giả định, không phải một phép đọc. Model hay bọc kết quả trong <think>…</think>, hàng rào ```json, một câu dẫn, hoặc một lớp vỏ một khoá, và mỗi lớp vỏ đó làm hỏng cả lượt sinh dù nội dung bên trong dùng được. coerceReply() bóc vỏ trước khi kiểm khuôn. Bóc vỏ không phải là nới lỏng kiểm tra: zod vẫn kiểm y nguyên, chỉ là nó không còn trượt vì cái vỏ.
Model bị khoá giấy phép
Đã xảy ra: @cf/meta/llama-3.2-11b-vision-instruct có trong danh mục nhưng gọi vào thì lỗi 5016 vì bị khoá giấy phép. Có trong danh mục không đồng nghĩa với gọi được, lượt chạy thật mới là bằng chứng.
Bộ model tự cũ đi
Đã xảy ra: hệ chạy deepseek-r1-distill-qwen-32b (bản chưng cất 32B) suốt một thời gian trong khi DeepSeek V4 Pro/Flash đã có sẵn trên cùng cổng. Vòng xoay còn chứa qwen2.5-coder, model viết mã, đang được giao viết văn giảng dạy tiếng Việt.
Bài học: rà lại danh mục model theo lịch. Không rà thì hệ tự tụt hậu mà không báo gì.
Sổ của mình kể chuyện cũ, còn workflow đã chết từ lâu
Đã xảy ra (29.08.2026): một lượt đứng ở running, bước generate, mãi không sang bước tiếp. Nguyên nhân: cột status trong course_content_runs là sổ ghi chép của chính mình, do workflow tự ghi. Bản chạy bị giết giữa chừng, vượt giới hạn nền tảng, isolate bị thu hồi, hay bất cứ lý do nào không chạy tới khối catch, thì không có ai ghi lại dòng đó, và nó đứng running vĩnh viễn.
Nhìn màn hình thì tưởng Cloudflare Workflows treo. Thật ra workflow đã chết, chỉ có sổ của mình là còn kể chuyện cũ.
Luật: với mọi lượt còn running, hỏi thẳng binding WORKFLOW.get(id).status(). Cloudflare là nguồn sự thật, sổ của mình chỉ là bản sao, lệch nhau thì sửa bản sao. Hàm reconcileRunningRuns() làm việc này mỗi lần mở màn hình.
Trần chỉ kéo xuống, không bao giờ nâng lên
Đã xảy ra ngay sau đó: nới MAX_TOKENS_CEILING từ 6.000 lên 24.000 rồi 64.000 mà không đổi được gì cả. Vì công thức là Math.min(requested, ceiling), mà chỗ gọi learning-experience xin đúng 6.000, nên min(6000, 64000) vẫn là 6.000. Số liệu thật trong sổ token: output_tokens đúng bằng 6.000 ở cả ba lượt liên tiếp.
Luật: model lý luận cần sàn, không phải trần. Con số chỗ gọi xin là ngân sách cho phần viết; phần nghĩ phải được cộng thêm (tokenBudget() nhân bốn, tối thiểu 24.000).
Thử lại với đúng ngân sách vừa thất bại
Vòng lặp ngoài thử ba lượt, mỗi lượt hai phút, với đúng ngân sách token đã chứng minh là không đủ. Hai mươi phút để tới cùng một kết luận. Nay hết token thì thử lại một lần với ngân sách gấp đôi, rồi thôi.
Báo "lỗi" rồi dừng là không đủ
Một lượt hỏng phải nói được hỏng vì gì và làm gì thì hết. Nhãn failed một mình thì người vận hành không sửa được gì, và người viết mã thì đi đoán.
Ba thứ được ghi cho mỗi lượt hỏng, và cả ba đều hiện trên màn hình ngay cạnh prompt:
| Cột | Là gì |
|---|---|
raw_reply | Bằng chứng, nguyên văn nhà cung cấp trả về, gồm cả vỏ bao |
error_text | Kết luận rút ra từ bằng chứng, viết cho người đọc, kèm câu "CÁCH SỬA:…" |
duration_ms | Bao lâu, phân biệt "vừa gọi" với "treo hai phút" |
Thông báo lỗi phải nêu cách sửa, không chỉ nêu triệu chứng. "HẾT TOKEN TRƯỚC KHI KỊP VIẾT. Model tiêu hết 6000 token cho phần suy luận rồi trả về nội dung rỗng. CÁCH SỬA: nới trần token cho model lý luận, hoặc chia nhỏ lượt sinh.", câu đó sửa được. "failed" thì không.
Riêng lỗi zod "thiếu trường" được gắn thêm một câu dẫn hướng, vì tự nó luôn chỉ sai chỗ: nó nói nội dung sai hình dạng, trong khi nguyên nhân thường là nội dung chưa được lấy ra khỏi vỏ.
Việc dài: gom hết rồi mới ghi = mất trắng khi bị cắt
Đã xảy ra (31.08.2026), sinh ngân hàng câu hỏi. Hàm gom đủ 30 câu đã qua kiểm rồi mới ghi một lần, và ném lỗi nếu chưa đủ. Bảy lượt hỏng theo hai kiểu, cả hai đều mất sạch:
- ba lượt bị tường 900 giây của Workflows cắt ngang giữa chừng;
- bốn lượt ném đi 9, 10, 15, 16 câu ĐÃ KIỂM ĐẠT chỉ vì chưa chạm đích 30.
Tiền model đã trả, lượt kiểm chéo đã chạy, chất lượng đã có, rồi vứt.
Luật: việc dài phải ghi dần. Mỗi vòng ghi ngay phần của nó; lượt bị cắt để lại phần việc đã làm, lượt sau đọc lại chỗ đã có mà bù tiếp. Vẫn báo hỏng khi chưa đủ để máy điều phối xếp lượt bù, nhưng "hỏng" nghĩa là chưa xong, không phải về số không.
Cùng một ý với việc chia nhỏ lượt gọi model: hỏng thì phải mất ít.
Kèm theo, và đây mới là chỗ dễ hỏng thầm lặng
Ghi dần sinh ra một trạng thái mới mà hệ chưa biết: dở dang. Máy điều phối lúc đó hỏi
NOT EXISTS (SELECT 1 FROM course_lesson_quiz_bank WHERE concept_id=... AND bank='formative')nên một bài ba câu cũng được tính là xong và không bao giờ được bù. Phép đếm mức khoá cũng đếm COUNT(DISTINCT concept_id), nghĩa là một bài ba câu đẩy khoá lên mức 4 vĩnh viễn.
Đổi phép kiểm sang đếm câu với sàn 12 (minAccept của chuẩn v2.1) thì lộ ra sự thật: khoá Customer Understanding trông như 23/27 bài "đã có ngân hàng", thực chất chỉ 13/27 đủ câu. Mười bài đứng ở 8-11 câu suốt nhiều tuần, không ai bù, vì phép kiểm bảo chúng đã xong.
Luật: thêm trạng thái dở dang thì phải sửa MỌI chỗ hỏi "đã có chưa" thành "đã đủ chưa". Hỏi EXISTS là hỏi sai câu. Đếm.
Đọc kỹ thông điệp lỗi trước khi nới trần
Ngân hàng câu hỏi quá giờ ba lần liên tiếp. Hai lần đầu tôi nới trần thời gian (120 → 180 → 300 giây) và cả hai lần đều hỏng lại. Chính thông điệp lỗi do mình viết ra đã chỉ đúng cách sửa ngay từ lần đầu: "CÁCH SỬA: chia nhỏ lượt sinh này". Xin 12 câu mỗi lượt thay vì 26 thì hết hẳn loại lỗi đó.
Nới trần là hoãn một lượt gọi quá lớn, không phải sửa nó. Trước khi nới bất kỳ trần nào, đọc lại câu "CÁCH SỬA:" trong course_content_runs.error, nó được viết ra chính vì lúc đó mình hiểu vấn đề rõ hơn lúc đang vội.
Máy theo dõi thoát sớm vì lỗi lọc của chính mình
Vòng theo dõi lọc started_at > '06:17' trong khi các lượt bắt đầu lúc 06:16, nên thấy "đang chạy = 0" ngay nhịp đầu và kết luận "hết lượt, dừng ở 20/27", trong khi cả bảy lượt đang chạy bình thường. Tôi đã báo cáo kết luận sai đó.
Luật cho mọi vòng theo dõi: điều kiện dừng "không còn việc chạy" chỉ đáng tin sau khi đã thấy việc chạy ít nhất một nhịp. Chưa thấy gì thì đó là chưa kịp ghi sổ, không phải đã xong.
Loại thứ tư: kết luận vội
Cỡ mẫu nhỏ
Vài chục phiếu chia cho 5 loại artefact và 2 model là mỗi ô vài phiếu. Ngưỡng: ≥12 phiếu một ô, và khoảng tin Wilson không còn trùm qua 50%. Dưới đó màn hình từ chối kết luận, kể cả khi con số nhìn có vẻ nghiêng hẳn.
Chữ trong prompt không giữ được
Ràng buộc nào bắt buộc phải đúng thì chốt bằng mã ở đường ghi, không chỉ dặn trong prompt. Em dash, tên giả lập, độ dài, số lượng, tất cả đều đã từng lọt dù prompt đã dặn.
Prompt để hướng, mã để chặn. Thêm một câu dặn rồi coi như đã xử lý xong là cách hỏng hay gặp nhất.
Đọc khuôn prompt thay vì prompt thật
Khuôn viết trong mã không phải thứ model đọc: prompt thật còn có hàng rào chủ đề của khoá, dàn nhân vật, bối cảnh, danh sách khái niệm đã dùng, bơm vào lúc chạy. Đọc khuôn rồi đi tối ưu là đang tối ưu một thứ không tồn tại. Đọc prompt thật ở GET /ops/prompts/live hoặc tab Lời dặn máy.
Thước đo do người viết
Không có gì tự động đảm bảo bộ luật chấm nội dung khớp với yêu cầu ban đầu. Xem Điều gì đảm bảo thước đo đúng?.
Ràng buộc CHECK của D1 thành lỗi 500 câm
Đã xảy ra (29.08.2026): evidence_type: 'ai_answer_rating' không nằm trong bộ CHECK của bảng member_evidence. D1 ném lỗi ràng buộc thô, Worker trả 500, và người học nhận đúng một dòng Internal Server Error, không nói được gì. Một giá trị sai thứ hai (self_declared_curiosity) cũng đã âm thầm hỏng ở workflow tò mò.
Nay appendMemberEvidenceIfNew() tự kiểm trước khi ghi và ném lỗi nói rõ giá trị nào hợp lệ. Bộ giá trị chép lại trong mã cố ý: cột CHECK không đọc ngược ra được từ mã ứng dụng, nên hoặc chép lại và giữ đồng bộ, hoặc chấp nhận 500 câm.
Luật: cột nào có CHECK thì đường ghi phải kiểm trước. Ràng buộc ở cơ sở dữ liệu là lưới an toàn cuối cùng, không phải nơi báo lỗi cho người dùng.
INSERT OR IGNORE nuốt luôn lỗi ràng buộc
Đã xảy ra (29.08.2026): cột item_kind của course_material_progress có CHECK không chứa 'phase', nên mọi lượt bấm "Tôi đã hoàn thành" đều bị cơ sở dữ liệu từ chối. Nhưng câu ghi dùng INSERT OR IGNORE, và OR IGNORE nuốt cả lỗi CHECK, không riêng lỗi trùng khoá.
Kết quả: API trả 200, màn hình đổi sang "Đã hoàn thành", người học tải lại trang thì mất sạch. Không một dòng lỗi nào ở bất cứ đâu, trong log, trong màn hình, trong dữ liệu.
Hai luật:
- Dùng
ON CONFLICT(<khoá>) DO NOTHINGthay choINSERT OR IGNORE. Nó chỉ bỏ qua đúng lỗi trùng khoá; lỗiCHECKvẫn ném ra như phải thế. OR IGNOREchỉ dùng khi đã tự hỏi "còn lỗi nào khác mà tôi đang lặng lẽ vứt đi?" và trả lời được.
Đây là mục thứ ba trong sổ này cùng một gốc: một cột CHECK bị vi phạm, và ba lần nó biểu hiện ba kiểu khác nhau, 500 câm, ghi hụt im lặng, và lượt chạy bị đánh dấu hỏng dù kết quả vẫn tốt. Cột CHECK là ràng buộc cuối; đường ghi phải kiểm trước.
Giao diện đọc theo NHÃN thay vì theo DỮ LIỆU
Đã xảy ra: workflow tò mò viết xong câu trả lời, ghi vào answer_json (886 ký tự), rồi mới chết ở bước ghi bằng chứng, nên status='failed'. Giao diện kiểm status === 'answered' trước khi hiện, nên người học mở lại bài thấy một ô rỗng: chữ đã tốn tiền sinh ra, trả lời đúng câu họ hỏi, và mất trắng.
Luật: hỏi "có dữ liệu để hiện không", đừng hỏi "nhãn có nói là thành công không". Nhãn trạng thái ghi lại lượt chạy; dữ liệu ghi lại kết quả. Một lượt chạy hỏng nửa chừng vẫn có thể để lại kết quả dùng được, và vứt nó đi là tự phạt mình hai lần.
Và tệ hơn: một lỗi của hệ trở thành hàng rào chặn người học
Lỗi 500 trên nằm ở bước chấm lại AI, mà bước đó lại là điều kiện để mở nút "Tôi đã hoàn thành". Kết quả: người học kẹt hẳn giữa bài, không đi tiếp được, vì một lỗi họ không gây ra và không sửa được.
Luật: điều kiện mở đường đi tiếp chỉ được dựa vào việc người học đã làm, không dựa vào việc hệ ghi thành công. Phần chấm lại AI nay đứng ngoài điều kiện, nó là để hệ tự học, không phải bài tập bắt buộc.
Giao diện: build xanh mà trang trắng
Hook đặt sau return sớm
Đã xảy ra: thêm bốn useState vào giữa thân component, bên dưới ba câu return sớm. Lần render đầu (chưa có dữ liệu, thoát sớm) chạy 1 hook, lần sau chạy 5 → React ném lỗi → trang trắng trên production.
Luật: mọi hook nằm trên mọi return sớm. Cần dữ liệu để tính giá trị ban đầu thì khởi tạo rỗng rồi tính bằng hàm thuần ở dưới.
Điều đáng nhớ hơn cả bản thân lỗi: tsc và vite build không bắt được nó, vì đây là luật chạy chứ không phải luật kiểu. Build xanh không đồng nghĩa trang mở được, và cả sổ này đã cảnh báo đúng kiểu đó rồi: hỏng mà công cụ kiểm tra vẫn báo ổn.
Công cụ sửa file và phép kiểm giả
Cắt chuỗi theo chỉ số làm hỏng file trong im lặng
Đã xảy ra: một trang admin trắng xoá trên production. Đoạn sửa file cắt theo chỉ số (t.index(...) rồi t[start:end]); chỉ số tính sai cho ra lát cắt rỗng, và str.replace('', new, 1) trong Python chèn vào đầu file, khối JSX nằm trên cả dòng import.
Luật: sửa file bằng neo văn bản tường minh, không cắt theo chỉ số.
Phép kiểm không thể trượt
Cùng lần đó, lệnh build chạy kèm | grep -iE "^error" nên nuốt mất thông báo lỗi thật, và câu lệnh vẫn in ra "build ok". Lỗi lọt thẳng lên production.
Luật: không lọc output của lệnh build, đọc mã thoát hoặc mấy dòng cuối. Và xác nhận chunk của trang vừa sửa có mặt trong danh sách file xuất ra. Đây đúng là kiểu lỗi mà cả sổ này cảnh báo: hỏng mà vẫn trông như đang chạy, lần này chính công cụ kiểm tra là thứ nói dối.
Luật dò trùng chặn đúng thuật ngữ mà bài bắt buộc phải nói
Đã xảy ra (31.08.2026). Unit "job dimensions" trượt luật story-no-retell tám lượt liền, qua hai model khác nhau, vì lời giải nhắc "công việc cảm xúc và công việc xã hội", chính là khái niệm unit đang dạy. Phép dò trùng chỉ gỡ tên nhân vật và bối cảnh khỏi phép so, không gỡ thuật ngữ của bài, nên nó chặn đúng cụm mà cả bài bắt buộc phải nói tới.
Hai dấu hiệu đáng lẽ phải nhận ra sớm hơn: hai model rất khác nhau cùng trượt một chỗ (thủ phạm ở phía mình), và cùng một luật trượt nhiều lượt liên tiếp (luật sai, không phải văn sai).
scqaViolations(scqa, cast, terms) nay nhận thêm danh sách thuật ngữ được phép lặp.
Trợ lý AI: kho kiến thức rỗng mà câu trả lời vẫn trôi chảy
Chỉ mục kiến thức trống, nhưng trợ lý vẫn trả lời trơn tru
Đã xảy ra (phát hiện 01.09.2026). Chỉ mục Vectorize conan-knowledge của Ran chỉ được nạp khi có người bấm tay POST /api/ai/embed; không cron nào gọi nó. Chỉ mục đứng yên, nhưng ragChunks rỗng không gây lỗi gì cả: prompt vẫn hợp lệ, Ran vẫn trả lời mạch lạc, bằng kiến thức chung của model, không phải bằng nội dung Conan School. Không log, không cảnh báo.
Nặng hơn: embed.ts nạp ebook_sections / frameworks, nguồn của kiến trúc trước. Toàn bộ nội dung dạy học hiện nay (unit, khái niệm, bài viết khái niệm, SCQA) chưa từng có mặt trong chỉ mục, kể cả những lần có người bấm tay.
Bài học: đừng đánh giá vùng biết của một trợ lý bằng cách hỏi thử nó. Câu trả lời trôi chảy không phân biệt được "biết" với "đoán hay". Phải hỏi dữ liệu, nay là GET /api/ai/embed/status, và vòng nạp RanKnowledgeIndexWorkflow để lại sổ ở ran_knowledge_runs.
Model embedding tiếng Anh cho nội dung tiếng Việt
Còn nguyên. Cả embed.ts lẫn chat.ts dùng @cf/baai/bge-base-en-v1.5 cho kho nội dung gần như thuần tiếng Việt. Đây không phải lỗi làm hệ dừng, nó chỉ làm điểm tương đồng nhiễu, nên tài liệu đúng bị lọc ra ngoài ngưỡng còn tài liệu lạc lọt vào, một cách âm thầm.
Không sửa được bằng một dòng: bge-m3 (đa ngữ) có 1024 chiều thay vì 768, mà số chiều gắn cứng vào Vectorize index. Muốn đổi phải tạo index mới, nạp lại toàn bộ, rồi mới chuyển binding, và phía truy vấn với phía nạp phải đổi cùng lúc, lệch nhau thì mọi kết quả là rác với điểm số trông vẫn bình thường.
Cách chặn: cặp (chỉ mục, model) nay chọn ở một hàm duy nhất, selectRetrieval trong src/domains/ai/retrieval.ts, và cả hai phía gọi nó. Không file nào được tự khai tên model nữa.
Số liệu người học nhìn thấy sống trong frontend
Đã xảy ra (01.09.2026). Giá hội viên và bảng quyền lợi, kể cả công thức giá tăng dần theo tháng, nằm hard-code trong com.conan.school/app/routes/membership.tsx. Không có gì hỏng theo nghĩa thông thường; trang vẫn hiển thị đúng. Nhưng dữ liệu đó không tồn tại với phần còn lại của hệ: Ran phải trả lời "chưa biết" cho mọi câu hỏi về học phí, và mỗi lần đổi giá là một lượt sửa mã nguồn.
Cám dỗ ở đây là chép con số vào prompt của Ran cho nhanh. Làm vậy tạo ra nguồn thứ hai, và hai nguồn thì chắc chắn sẽ lệch, chỉ là chưa biết khi nào. Lời giải đúng là chuyển số liệu vào D1 (membership_plans/membership_features) và cho cả hai bên cùng đọc.
Câu hỏi phải hỏi mỗi lần Ran cần nói ra một con số: người học nhìn thấy con số này ở đâu, và nó lấy từ đâu?
Regex tiếng Việt: bẫy lặp lại nhiều lần
- Dùng lookaround Unicode
(?<![\p{L}\p{M}])…(?![\p{L}\p{M}]), không dùng\b(ASCII, cắt sai ở chữ có dấu). - Chuẩn hoá NFC trước khi so; đếm từ theo locale
vi-VN. - Đã xảy ra: regex tên riêng chặn đúng câu mà luật dàn nhân vật bắt buộc phải có;
[Oo]ngkhớp nhầm "xong A", "trong B". - Luôn thử luật mới với chính những câu mà luật khác bắt buộc phải có.
Xem thêm: Hệ thống tự sinh và tự nâng chất lượng · Sới so bản (A/B) · Điều gì đảm bảo thước đo đúng?
Route trong wrangler.toml làm CI đỏ SAU khi đã upload
Xảy ra 01.09.2026 với conan-tool-plane, và hoá ra admin-router đã hỏng y hệt từ 08.2026 mà không ai để ý.
wrangler deploy upload mã trước, gắn route sau. Token Cloudflare của CI chỉ có quyền Workers/Pages, không có quyền zone. Kết quả: Worker mới đã sống ở *.workers.dev, nhưng bước gắn route xxx.conan.school/* hỏng vì thiếu quyền, CI báo đỏ, và log nói rõ "Successful trigger changes were not rolled back". Người nhìn CI đỏ tưởng mã hỏng; người thử workers.dev thấy nó chạy, hai người cãi nhau vì cùng một sự thật.
Cách nhận ra: log deploy có dòng does not have permission to access the zone.
Cách xử lý: hoặc thêm quyền "Zone → Workers Routes → Edit" và "Zone → DNS → Edit" cho zone conan.school vào token CI (việc này chỉ chủ tài khoản làm được, trong dashboard), hoặc bỏ [[routes]] và dùng địa chỉ workers.dev cho tới lúc đó. Tool Plane chọn đường hai. Đừng để [[routes]] nằm im trong một wrangler.toml deploy qua CI: nó không cảnh báo gì cho tới lúc merge.
Cùng đợt còn một bẫy nhỏ: wrangler d1 info <tên> đọc wrangler.toml và ánh xạ tên sang database_id trong đó, còn là placeholder thì trả 7404 dù database đã tạo xong. Tra bằng wrangler d1 list.
Khoá GitHub App: GitHub phát PKCS#1, Worker chỉ nhận PKCS#8
Xảy ra 02.09.2026. Tạo GitHub App, tải .pem, đặt làm secret, xoá GITHUB_PAT, và mọi tool GitHub trả TOOL_EXECUTION_FAILED không nói gì thêm. File GitHub tải về bắt đầu bằng BEGIN RSA PRIVATE KEY (PKCS#1); importPKCS8 của WebCrypto cần BEGIN PRIVATE KEY (PKCS#8). Chuyển bằng openssl pkcs8 -topk8 -nocrypt -in app.pem -out app.pkcs8.pem rồi nạp lại secret. Executor giờ tự chuyển PKCS#1 sang PKCS#8 (security/pkcs.ts, có bài kiểm), nên nạp file GitHub phát ra nguyên bản cũng chạy; chuyển tay bằng openssl vẫn đúng.
Bài học rộng hơn: đừng xoá đường cũ trước khi đường mới đã gọi thử thành công. Lần này PAT bị xoá trước khi thử App, nên có vài phút mọi tool GitHub chết.
Workflow trỏ tên Pages project không tồn tại, trong khi domain đã gắn vào project khác
Xảy ra 02.09.2026. deploy-play trỏ project_name: play-conan-school, project đó chưa từng tồn tại; domain play.conan.school thực ra đã gắn vào Pages project kahoot-frontend từ trước. CI báo The Pages project "play-conan-school" does not exist ở mọi lượt push từ 08.2026, không ai để ý vì cả job backend cùng workflow cũng đỏ vì thiếu quyền zone (mục trên), nên cái đỏ này lẫn vào cái đỏ kia.
Phản xạ sai là tạo project mới cho khớp tên: được một project trống ở *.pages.dev, còn domain vẫn ở project cũ, deploy xanh mà site không đổi. Cách đúng: wrangler pages project list xem domain đang ở project nào, rồi sửa workflow trỏ về đó.
Cùng ngày, deploy-api đỏ với code: 7403 khi chạy migration D1, token CI thiếu Account → D1 → Edit. Sửa quyền tại chỗ thì secret không đổi; tạo token mới thì phải dán lại CLOUDFLARE_API_TOKEN trong GitHub, nếu không CI vẫn gửi token cũ và đỏ y nguyên.
Token thiếu D1 Edit không chỉ làm CI đỏ: nó đóng băng cả deploy, và không ai đo
Đo ngày 03.09.2026, một ngày sau khi mục trên được ghi. Lỗi 7403 vẫn còn, và hậu quả đã dồn:
schema_migrationsKHÔNG TỒN TẠI trongconan-platform-production. Không phải "thiếu vài dòng", bảng sổ chưa từng được tạo, vìCREATE TABLEcủa nó là câu lệnh đầu tiên bị 7403 chặn.- Vì thế không migration nào từng được áp bởi CI.
20260901a_ran_knowledge_index.sqlvà20260901b_membership_plans.sqlđã nằm trênmainnhưng chưa bao giờ chạy:ran_knowledge_sourcesvàmembership_plansđều không có trong D1. - Mã đọc
membership_plansthì đã deploy. Nên trang Membership Plans ở admin và câu trả lời của Ran về học phí đang hỏng trên production, và hỏng theo kiểu khó thấy nhất, phần lớn truy vấn.catch()về mảng rỗng, nên màn hình chỉ trông như "chưa có dữ liệu". - Và vì migration chạy trước deploy, một lượt migration đỏ chặn luôn deploy. API production đứng ở bản 01.09.2026 15:42. Mọi commit sau đó chưa từng lên.
Bài học: một job CI đỏ vì thiếu quyền không tự giới hạn ở cái nó định làm. Ở đây nó lặng lẽ biến thành "production ngừng nhận bản mới", và không màn hình nào nói câu đó. Chỗ duy nhất nói là danh sách gh run list, thứ không ai mở khi mọi trang vẫn tải bình thường.
Kiểm nhanh, không cần quyền ghi:
npx wrangler d1 execute conan-platform-production --remote \
--command "SELECT COUNT(*) FROM schema_migrations"
gh run list --workflow=deploy-api.yml --limit 5Bảng không tồn tại, hoặc lượt chạy gần nhất đỏ, thì mọi migration trong repo đang là lời hứa suông, kể cả những file đã merge từ lâu.
Đã xảy ra đúng như vậy (04.09.2026 00:11): thêm quyền vào token, rerun deploy-api, script tạo sổ, ghi mốc nền 164 file, áp 11 migration trong 84 giây, rồi deploy, production nhận bản mới sau hai ngày đứng. Khi token được sửa, script tự phục hồi đúng thứ tự: tạo sổ, ghi mốc nền cho các file <=20260829o_backfill_queue.sql, rồi áp 20260901a, 20260901b và các file mới hơn theo tên. Không phải áp tay gì cả, và đừng áp tay, vì áp tay mà không ghi sổ sẽ khiến lượt CI sau chạy lại chính file đó.
Bí danh tương thích đặt nhầm chiều: đổi tên đường dẫn làm hỏng trang đang chạy
Xảy ra 03.09.2026. Đổi tên domain community thành events ở API: /api/events là đường dẫn mới, /api/community/* giữ lại làm bí danh tương thích, và năm lời gọi ở tokyo với www được chuyển sang đường dẫn mới trong cùng một PR.
Lý lẽ lúc đó nghe hợp lý: giữ bí danh để ba workflow deploy lệch nhịp không làm gãy gì. Nhưng bí danh chỉ che được MỘT chiều, "API lên trước, frontend lên sau". Chiều thực sự xảy ra là chiều ngược lại, và đáng ra phải đoán được:
deploy-apichạy migration trước deploy, nên nó là workflow dễ kẹt nhất. Hôm đó nó kẹt vì token thiếuD1 → Edit.deploy-comvàdeploy-wwwđẩy Pages, không có gì chặn, luôn lên.
Kết quả sau khi merge: frontend gọi /api/events, API mang đường dẫn đó không lên được, /api/events trả 404 trên production, và trang Sự kiện của cả tokyo lẫn www hỏng, một tính năng vốn đang chạy tốt, hỏng vì một PR không đụng gì tới nó.
Sửa: đưa frontend về /api/community/events, đường dẫn tồn tại ở cả hai bản API. Chỉ chuyển sang đường dẫn mới sau khi API mới đã deploy và đã kiểm bằng curl.
Luật rút ra
Khi đổi tên một đường dẫn, phía gọi chuyển SAU phía phục vụ, không bao giờ cùng lúc. Và trong repo này "cùng lúc" nghĩa là ba workflow deploy độc lập với ba xác suất hỏng khác nhau, cái chứa migration luôn là cái dễ kẹt nhất.
Thứ tự đúng: (1) API deploy với cả hai đường dẫn; (2) curl xác nhận đường mới trả 200; (3) PR riêng chuyển frontend; (4) gỡ bí danh khi log không còn lượt gọi.
Kiểm nhanh trước khi merge một PR đổi tên đường dẫn:
curl -s -o /dev/null -w "%{http_code}\n" https://api.conan.school/api/<đường-dẫn-mới>Ra 404 thì đường dẫn mới chưa có trên production, và mọi frontend gọi nó sẽ hỏng ngay khi Pages deploy.
Bật Email Sending thêm DMARC p=reject cho cả miền, không riêng thư của Worker
Xảy ra 04.09.2026. Chạy wrangler email sending enable conan.school để Worker gửi được email. Cloudflare tự thêm vào zone bốn nhóm bản ghi: MX + SPF cho cf-bounce.conan.school, DKIM cf-bounce._domainkey, và _dmarc.conan.school = v=DMARC1; p=reject;.
Ba nhóm đầu chỉ chạm subdomain cf-bounce. Nhóm cuối chạm cả miền: DMARC p=reject bảo mọi máy nhận từ chối thư @conan.school nào không qua SPF/DKIM khớp miền. Trước đó zone không có bản ghi SPF gốc và không thấy DKIM của Google (google._domainkey), trong khi MX là Google Workspace, nghĩa là thư người của trường gửi từ Gmail @conan.school có thể bị từ chối kể từ lúc bật, và hệ email của Worker vẫn chạy hoàn toàn bình thường.
Đo lại ngày 08.09.2026: rủi ro là THẬT và vẫn đang mở
| Bản ghi | Kết quả |
|---|---|
_dmarc.conan.school | v=DMARC1; p=reject;, mức nghiêm nhất, không có rua= |
SPF gốc conan.school | không tồn tại |
| DKIM của Google | không có selector nào (thử google, default, s1, s2, selector1, selector2, 20230601) |
cf-bounce.conan.school | có SPF v=spf1 include:_spf.mx.cloudflare.net ~all |
cf-bounce._domainkey | có DKIM |
| MX | aspmx.l.google.com, Google Workspace |
Nghĩa là hai đường gửi có số phận trái ngược:
- Thư của hệ đi qua Cloudflare, envelope ở
cf-bounce.conan.school, có đủ SPF và DKIM của subdomain đó nên qua DMARC. Đúng như quan sát: thư hệ thống vẫn tới hộp thư bình thường, Gmail hiện "signed by: conan.school". - Thư người thật gửi từ Google Workspace dưới địa chỉ
@conan.schoolkhông có SPF gốc để khớp, cũng không có DKIM khớp miền. Vớip=reject, máy nhận được chỉ dẫn từ chối thẳng.
Chính vì thư hệ thống vẫn chạy ngon mà lỗi này khó bị phát hiện: bảng điều khiển nào cũng xanh, email_deliveries toàn sent, chỉ có thư của con người là biến mất.
Không có rua= là chỗ tệ thứ hai: bật mức nghiêm nhất mà không nhận báo cáo nào, nên không có cách nào biết bao nhiêu thư đang bị từ chối.
Sửa, theo đúng thứ tự này
- Hạ DMARC xuống
p=nonengay, kèmrua=để bắt đầu thấy số liệu:v=DMARC1; p=none; rua=mailto:[email protected];Làm trước vìp=rejectđang có hiệu lực; hai bước sau cần thời gian lan truyền. - Thêm SPF gốc:
v=spf1 include:_spf.google.com include:_spf.mx.cloudflare.net ~all - Bật DKIM trong Google Admin: Apps → Google Workspace → Gmail → Authenticate email → tạo khoá, rồi thêm bản ghi
google._domainkeymà nó sinh ra. - Đợi vài ngày, đọc báo cáo
rua, khi thấy mọi nguồn hợp lệ đều pass thì mới nâng lạip=quarantinerồip=reject.
Đừng nâng thẳng lại p=reject sau khi thêm SPF/DKIM mà chưa đọc báo cáo. Miền này còn gửi thư từ những nguồn chưa liệt kê ở đây, và mỗi nguồn bỏ sót là một luồng thư bị từ chối lặng lẽ.
Token OAuth của wrangler chỉ có zone (read) nên agent không tự sửa DNS được, và Google Admin đòi passkey, cả hai bước đều phải do người vận hành làm.
Email thất bại mà hệ vẫn trông như chạy: fireEmail nuốt lỗi theo thiết kế
fireEmail() (biên nhận, xác nhận) không bao giờ ném, đúng, vì một email hỏng không được làm hỏng lượt kích hoạt membership. Hệ quả: binding EMAIL sai tên, miền chưa bật, hay Email Service đổi API thì không có gì đỏ ngoài các dòng status='failed' trong email_deliveries. Sau mỗi thay đổi ở wrangler.toml hay ở miền gửi, chạy POST /api/system/email/smoke và đọc sổ.
Đáp án đúng dồn vào một vị trí: quiz vẫn đủ 20 câu và vẫn chấm điểm
Xảy ra 04.09.2026, lúc soạn khoá đầu tiên bằng CoursePack. Người viết nội dung, kể cả model, có thiên hướng đặt đáp án đúng vào vị trí thứ hai, vì đó là chỗ nghe tự nhiên nhất khi đọc bốn phương án thành câu. Đo thật trên hai lesson đầu: 16/20 và 15/20 câu có đáp án ở cùng một vị trí.
Đây là loại hỏng tệ nhất vì không có gì sai ở đâu cả: đủ 20 câu, đủ 4 phương án, correct_index hợp lệ, màn hình chấm điểm bình thường, auditCourse không có luật nào về chuyện này. Chỉ có một thứ mất đi, bài quiz thôi dò được lỗ hổng, vì người học tinh ý đếm ra quy luật trước khi đọc hết đề. Và vì mô hình lesson dùng quiz để chẩn đoán chứ không phải để chặn, hỏng ở đây không chặn ai lại, nó chỉ lặng lẽ làm cả tầng chẩn đoán thành vô nghĩa.
Kiểm: python3 scripts/course_pack_to_sql.py --check <pack>, nó đòi dùng đủ cả 4 vị trí và không vị trí nào quá 8/20. Phép kiểm này chạy trong ci-checks mỗi lượt PR. Sửa: python3 scripts/balance_quiz_answers.py <pack>.u*.json xoay vòng vị trí theo chỉ số câu hỏi. Câu nào mà vị trí mang nghĩa thì đánh dấu "fixed_order": true.
Bài học rộng hơn: với nội dung viết tay, lỗi đếm được là loại lỗi phải để máy bắt. Người soát tay đọc từng câu thấy câu nào cũng ổn, vì lỗi không nằm trong câu nào cả, nó nằm ở phân bố.
Dấu chấm phẩy trong nội dung tiếng Việt và công cụ tách câu lệnh SQL
Phát hiện 04.09.2026 khi viết phép kiểm cho migration sinh từ CoursePack. Nội dung tiếng Việt có dấu chấm phẩy thật ('Ba chặng: U1 ...; U2 ...; U3 ...'). Bất kỳ công cụ nào tách file SQL bằng split(';') ngây thơ sẽ cắt đôi câu INSERT ngay giữa một chuỗi, và lỗi báo ra trông y hệt lỗi nội dung (unrecognized token), khiến người đọc đi sửa nhầm chỗ.
CI không dính lỗi này: scripts/apply-d1-migrations.mjs gọi wrangler d1 execute --file=…, và wrangler có bộ tách hiểu chuỗi. Nhưng script kiểm, script thống kê hay công cụ tạm nào đọc migration đều phải tách có nhận biết dấu nháy. Đừng kết luận nội dung sai trước khi kiểm bộ tách của chính mình.
Trang mới xanh mướt trong khi bảng của nó chưa tồn tại
Ghi 04.09.2026, lúc dựng chuyên mục Learner Stories.
Loader của mọi trang danh sách trên www.conan.school đều bắt lỗi và trả mảng rỗng, đúng cách, vì một API chết không nên làm sập cả trang. Hệ quả là một trang hoàn toàn chưa có bảng trong D1 vẫn trả HTTP 200, vẫn dựng đủ tiêu đề, vẫn hiện dòng "Chưa có nội dung nào", không phân biệt được với trạng thái bình thường lúc chưa ai viết bài.
Chỗ này nối thẳng vào "Token thiếu D1 Edit" ở trên: nếu CI không áp được migration, người xem trang sẽ không bao giờ biết. Cả hai lớp đều im lặng.
Kiểm trước khi kết luận "chưa ai viết bài":
gh run list --workflow=deploy-api.yml --limit 3
wrangler d1 execute conan-platform-production --remote \
--command "SELECT COUNT(*) FROM legacy_learner_stories"Và với nội dung có cờ công khai, kiểm luôn cờ: is_public mặc định 0, nên "đã viết" và "đã lên web" là hai chuyện khác nhau.
CI chạy cả bảy job cho một PR chỉ thêm file JSON: 90% quota Actions trong 4 ngày
Xảy ra 04.09.2026. GitHub báo đã dùng 1.803/2.000 phút Actions của chu kỳ, còn 27 ngày.
ci-checks có bảy job, mỗi job npm ci riêng ở một thư mục khác nhau, và không job nào lọc theo file đổi. Một lượt PR vì thế tốn ~35–50 phút tính tiền bất kể thay đổi là gì. Nhánh soạn nội dung feat/coursepack-sales đẩy commit khoảng 13 phút một lần suốt cả ngày, 25 lượt chạy trong một ngày, mỗi lượt build lại www, tokyo, admin, tool-plane và admin-router cho một thay đổi chỉ chạm ba file JSON trong api.conan.school/content/.
Loại hỏng này không đỏ ở đâu cả: mọi check đều xanh, mọi bản dựng đều đúng. Cái cạn dần là hạn mức, và tín hiệu duy nhất là một email cảnh báo ở mốc 90%, tức là lúc đã gần hết.
Đã sửa: job changes đọc git diff giữa base và head của PR rồi bật/tắt sáu job phía sau bằng needs/if; bước kiểm CoursePack tách khỏi job api thành job riêng không cài phụ thuộc; actions/setup-node bật cache: npm cho cả sáu job. PR nội dung giờ tốn hai job nhẹ thay vì bảy.
Ba điều phải giữ khi sửa bộ lọc này:
- Không so được diff thì chạy TẤT CẢ. Base bị force-push hay checkout nông đều làm
git diffrỗng, mặc định an toàn là chạy thừa, không bao giờ là bỏ qua. - Đổi chính
ci-checks.ymlthì mọi job chạy lại, nhưng so theo LƯỢT PUSH, không so theo base của PR. Diff của PR luôn chứaci-checks.ymlcho tới khi merge, nên so base thì bộ lọc tự vô hiệu hoá đúng trên nhánh cần nó nhất. Đã dính đúng bẫy này ở lượt push đầu tiên: bộ lọc chạy, báo xanh, và vẫn chạy cả bảy job. set -egiết bước lọc khigrepkhông khớp. Mỗi phép gán kiểugrep -q … && X=truephải có|| trueở cuối, một bước "chọn job" đỏ vì KHÔNG có gì đổi là bẫy khó đoán nhất ở đây.
guards vẫn luôn chạy, không lọc: nó không cài phụ thuộc và nó soi cả repo.
348 lỗi type ở admin trông y như lỗi mã, thật ra là CI thiếu một lần npm ci
Xảy ra 04.09.2026. Commit 600dc3a dựng bước tsc cho job admin trong ci-checks. Bước này đỏ ngay lượt đầu và đỏ liên tục cả ngày, với 348 lỗi trải khắp legacy/admin-course-content/src/pages/*.tsx:
error TS2322: Type '{ children: string; className: string; }' is not assignable
to type 'IntrinsicAttributes & BadgeProps'.Đọc thì tưởng ai đó truyền sai props cho Badge. Mã hoàn toàn đúng, BadgeProps extends React.HTMLAttributes<HTMLSpanElement> nhận cả className lẫn children.
Chuyện thật là: admin.conan.school/tsconfig.json include cả ../packages/admin-shared/src, mà packages/admin-shared là một gói riêng, có package.json và package-lock.json riêng. Phân giải module từ file của nó đi lên packages/admin-shared/node_modules, rồi packages/, rồi gốc repo, không bao giờ nhìn vào admin.conan.school/node_modules. CI chỉ npm ci ở admin.conan.school, nên react và radix-ui không phân giải được, React.HTMLAttributes biến mất, và BadgeProps co lại còn mỗi asChild + variant. Mọi className thành lỗi.
Hai chỗ khiến nó khó thấy:
- Ở máy thì xanh. Người soạn có sẵn
packages/admin-shared/node_modulesvànode_modulesgốc từ lần cài trước, nêntscsạch.tsc -bcòn cachetsconfig.tsbuildinfo, chạy phải kèm--forcemới tái hiện được. vite buildkhông lộ ra. Vite phân giải bare import kháctsc, nêndeploy-adminxanh suốt và production vẫn đúng. Bướctscmới dựng là thứ duy nhất nhìn thấy.
Đã sửa: job admin cài thêm phụ thuộc của packages/admin-shared trước bước tsc, và cache-dependency-path gộp cả hai lockfile.
Bài học rộng hơn: khi tsconfig của một site include mã nằm ngoài thư mục site, CI phải cài phụ thuộc cho mọi gói được include, chứ không riêng thư mục có working-directory. Một gác vừa dựng mà đỏ ngay lượt đầu thì nghi môi trường CI trước khi nghi mã.
name_en ghi thiếu một họ bảng: nút EN bấm mà tên khoá không đổi
05.09.2026. Bộ sinh CoursePack ghi name_en vào courses nhưng không ghi vào legacy_courses. Thẻ khoá ở trang cộng đồng đọc legacy_courses, nên với mọi khoá đã soạn, cột đó NULL và pickName() lùi về tên tiếng Việt.
Hệ quả: đang ở chế độ EN, tên unit và tên bài đổi sang tiếng Anh, riêng tên khoá vẫn tiếng Việt. Không có lỗi nào, không màn hình nào đỏ, nút EN vẫn bấm được, chỉ là nó không đổi được thứ to nhất trên thẻ.
Sửa: bộ sinh ghi cả hai họ bảng, cộng migration 20260907o bù cho khoá đã vào D1.
Bài học: hai họ bảng legacy_* và không tiền tố cùng tồn tại, và giao diện đọc từ họ nào thì cột phải có ở họ đó. Thêm một cột hiển thị mà chỉ ghi vào một họ là tạo ra đúng loại hỏng mà màn hình vẫn trông như đang chạy.
Kệ sách trông rỗng vì sách mức 1 không publish (07.09.2026)
Soạn xong 12 quyển mức 1 cho Food và 12 cho Drink, /c/food/books vẫn chỉ hiện một quyển. Không có lỗi nào ở đâu cả: /api/communities/:slug/books lọc WHERE is_published = 1, và sách mức 1 (khung, chương là Essential Question, section là Guiding Question, không có nội dung) sinh ra với is_published = 0 có chủ ý.
Vì sao đáng ghi: đây là loại hỏng ngược, hệ chạy đúng mà người soạn tưởng mất dữ liệu, rồi đi chèn lại hoặc bật is_published cho một quyển chưa có chữ nào. Đếm tiến độ bằng SQL, không bằng mắt trên trang: SELECT outline_level, COUNT(*) FROM legacy_ebooks WHERE community_id=? GROUP BY 1.
Đáp án quiz dồn một vị trí: đo lại lần thứ hai (07.09.2026)
Luật này đã ghi từ 04.09 và nó lặp lại y nguyên khi soạn edu-classroom-management: 19/20 câu ở cùng một vị trí, ở cả 9 lesson, 180 câu. Người viết, kể cả model, dồn đáp án đúng vào vị trí nghe tự nhiên nhất, và không có gì trong nội dung trông sai cả.
Lần này cửa kiểm bắt được (--check từ chối sinh SQL), và balance_quiz_answers.py sửa trong một lượt. Bài học không phải "nhớ chạy script" mà là: một luật chỉ tồn tại khi có gác chạy được. Nếu --check không kiểm phân bố đáp án thì 180 câu đó đã vào D1 mà không ai biết.
name_en ghi thiếu một họ bảng: nút EN bấm mà tên khoá không đổi
05.09.2026. Bộ sinh CoursePack ghi name_en vào courses nhưng không ghi vào legacy_courses. Thẻ khoá ở trang cộng đồng đọc legacy_courses, nên với mọi khoá đã soạn, cột đó NULL và pickName() lùi về tên tiếng Việt.
Hệ quả: đang ở chế độ EN, tên unit và tên bài đổi sang tiếng Anh, riêng tên khoá vẫn tiếng Việt. Không có lỗi nào, không màn hình nào đỏ, nút EN vẫn bấm được, chỉ là nó không đổi được thứ to nhất trên thẻ.
Sửa: bộ sinh ghi cả hai họ bảng, cộng migration 20260907o bù cho khoá đã vào D1.
Bài học: hai họ bảng legacy_* và không tiền tố cùng tồn tại, và giao diện đọc từ họ nào thì cột phải có ở họ đó. Thêm một cột hiển thị mà chỉ ghi vào một họ là tạo ra đúng loại hỏng mà màn hình vẫn trông như đang chạy.
Đổi tên một bảng: RENAME, không bao giờ dựng bảng mới song song (08.09.2026)
Khi đổi Templates thành Thư viện, đường ngắn nhất trông có vẻ an toàn là tạo bảng mới community_library_docs, chép dữ liệu sang, rồi để community_templates nằm đó "cho chắc".
Đó chính là kiểu hỏng nguy hiểm nhất của repo này: hai bảng cùng sống, hai chỗ ghi, một chỗ đọc, và màn hình vẫn trông như đang chạy. Bản chèn tiếp theo (một migration nội dung của cộng đồng khác, hay một trang admin chưa cập nhật) sẽ ghi vào bảng cũ, tài liệu không hiện, và không có lỗi nào ở đâu cả. Không ai đi tìm cho tới khi có người hỏi vì sao tài liệu vừa thêm không thấy đâu.
ALTER TABLE community_templates RENAME TO community_library_docs; giữ nguyên dữ liệu và khiến mọi đường ghi vào tên cũ hỏng ngay và ồn ào, đúng thứ cần. Endpoint cũ thì giữ làm bí danh trỏ về cùng handler (/:slug/templates → /:slug/library), vì frontend cũ còn chạy cho tới khi CI deploy xong; bí danh ở tầng HTTP không tạo ra bản dữ liệu thứ hai.
Kệ khung để active mà chưa có nội dung là một ngoại lệ có điều kiện của mục Template không bao giờ bịa asset_url. 1080 tài liệu khung hiện trên kệ với asset_url/body_portable NULL nhưng is_preview=0: người xem thấy tên và một ổ khoá, không có nút mở, nên không có đường nào dẫn tới 404. Bẫy cũ trở lại nguyên vẹn ngay khi ai đó bật is_preview=1 cho một tài liệu chưa có nội dung thật.
Migration 372 KB làm D1 bỏ dở giữa chừng và đóng băng api (08.09.2026)
Kệ thư viện sinh ra một file 20260908c_library_shelf.sql chứa cả 1080 tài liệu, 372 KB, gấp năm lần file nội dung lớn nhất từng chạy được (20260903d_content_food.sql, 75 KB). CI áp tới file đó và wrangler trả về đúng một dòng:
✘ [ERROR] {"D1_RESET_DO":true}
[migrate] THẤT BẠI ở 20260908c_library_shelf.sql, dừng lại, KHÔNG deploy.Hai hậu quả, hậu quả thứ hai nặng hơn:
- D1 bỏ dở giữa chừng, không quay lui. 76 collection đã vào bảng, phần còn lại không, và không có gì đánh dấu chỗ dừng. May là mọi INSERT đều
OR IGNOREnên áp lại an toàn, nếu viếtINSERT INTOtrần thì lần chạy sau sẽ hỏng vì trùng khoá và file kẹt vĩnh viễn. - api production đứng lại. Migration chạy TRƯỚC deploy trong cùng job, nên một file SQL hỏng làm mọi thay đổi mã của api không lên được, đúng cơ chế đã đóng băng api hai ngày hồi 02–03.09. Frontend vẫn ship bình thường, nên nhìn từ ngoài không có gì báo động.
Luật rút ra: một migration không quá cỡ một file nội dung đã chạy được (~75 KB là mốc đã có bằng chứng). Sinh nhiều file nhỏ thay vì một file to, kệ thư viện tách thành 18 file, mỗi cộng đồng một file, ~24 KB. File hỏng chưa vào sổ schema_migrations thì xoá nó đi được: script áp migration chỉ đọc thư mục, không có lỗ hổng nào để lại.
Ghi chung với mục Dấu chấm phẩy trong nội dung tiếng Việt và [D1 legacy_ prefix]: D1 từ chối câu lệnh lớn theo nhiều kiểu khác nhau, và kiểu nào cũng báo lỗi nghèo nàn.
Lần hỏng thứ hai, ngay sau khi tách file: OR IGNORE nuốt xung đột rồi FK vỡ ở bảng khác
Tách xong 18 file, lượt deploy kế tiếp vẫn đỏ, lần này ở file đầu tiên:
[migrate] áp 20260908d01_library_shelf_food.sql
✘ [ERROR] FOREIGN KEY constraint failedNguyên nhân nằm ở dữ liệu ĐÃ có trên production: 76 collection kiểu cũ mang id theo slug (cm-ind-food-col-quy-trinh), trong khi bộ file mới dùng id đánh số (cm-ind-food-col-01). Ba slug trùng nhau, UNIQUE(community_id, slug) chặn, và INSERT OR IGNORE bỏ qua im lặng ba ngăn 02/03/04. Tài liệu ngay sau đó trỏ vào ba id không tồn tại → D1 chặn bằng khoá ngoại.
Hai điều đáng nhớ:
OR IGNOREkhông áp cho khoá ngoại. SQLite chỉ áp ON CONFLICT cho UNIQUE / NOT NULL / CHECK. Một migrationOR IGNOREtoàn tập vẫn chết vì FK, và chết ở câu lệnh cách nguyên nhân vài chục dòng, thông báo lỗi không hề nhắc tới ngăn nào bị bỏ qua.- Chỗ vỡ hiện ra ở bảng KHÁC với bảng có xung đột. Nhìn thông báo FK ở bảng tài liệu sẽ không bao giờ đoán ra gốc là một hàng cha bị bỏ qua ở bảng ngăn.
Cách gỡ: một migration dọn trước (20260908c_library_collections_reconcile.sql) gỡ tham chiếu về NULL rồi xoá bốn ngăn cũ, để bộ file mới chèn trên nền sạch; mỗi file d* kết bằng một câu UPDATE ... SET collection_id = community_id || '-col-01' WHERE collection_id IS NULL xếp lại các tài liệu có từ trước.
Trước khi viết một migration INSERT OR IGNORE sinh hàng loạt, hãy đọc production xem bảng đó đang có gì. Repo và D1 lệch nhau là chuyện thường sau một lượt áp hỏng, và OR IGNORE biến sự lệch đó thành im lặng thay vì thành lỗi.
Nội dung viết xong mà không ai đọc được: SQL thô không parse cột json (08.09.2026)
Sáu tài liệu thư viện của Education được viết đầy đủ, migration áp xanh, D1 đếm đúng length(body_portable) từ 3.200 tới 6.400 ký tự. Trên trang, cả sáu vẫn trông y hệt lúc còn là kệ rỗng, không nút mở, không thân bài, không lỗi ở đâu cả.
Nguyên nhân: d1Client (decodedRow trong src/shared/d1Client.ts) tự JSON.parse những cột khai kiểu json, nhưng route thư viện viết bằng DB.prepare thô nên đi vòng qua nó. Payload trả về body_portable là một chuỗi; portableTextHasContent() đòi Array.isArray nên trả false; nút "Mở ra đọc" không bao giờ hiện.
Ba điều đáng nhớ:
- Hai đường đọc D1 trong repo hành xử khác nhau,
d1Clientdecode, SQL thô thì không. Cùng một cột, hai kiểu dữ liệu, tuỳ route nào đọc. Route thô đọc cột json thì phải tự parse. - Số đếm trong D1 không chứng minh được người dùng đọc được.
SELECT COUNT(*)vàlength(body_portable)đều đẹp trong khi màn hình trống. Bài kiểm đúng là payload thật của endpoint, gọi bằng phiên đăng nhập thật. - Đây là lần thứ hai trong cùng một ngày cùng một kiểu hỏng: thứ trông như đang chạy. Lần trước là kệ hiện tên mà FK chặn nửa chừng; lần này là nội dung đủ mà giao diện không có đường mở. Cả hai chỉ lộ ra khi kiểm từ phía người dùng, không phải từ phía dữ liệu.
Kệ 1080 tài liệu khai kind='file' mà không có tệp nào (08.09.2026)
Bộ sinh kệ thư viện gán kind theo dự định tương lai, bảng tính thì 'file', Google Sheet thì 'link', trong khi asset_url để NULL và status='active'. 277 tài liệu như vậy đã vào production và không ai thấy gì: tất cả đang is_preview=0 nên giao diện không vẽ nút mở, nên chưa có ai bấm vào để nhận 404.
Đó chính là điều làm nó nguy hiểm. Trạng thái tự mâu thuẫn nằm im trong bảng cho tới ngày có người bật is_preview=1 cho một ngăn, hoặc cấp Pro cho một cộng đồng, rồi 404 hiện ra hàng loạt mà không ai nhớ vì sao.
Luật 10 vốn nói đúng chuyện này (mặc định kind='doc', đổi sang file/link vào ĐÚNG lúc asset_url có giá trị thật). Cái thiếu là một chỗ thi hành nó. scripts/check-library-docs.mjs bắt cả 277 dòng trong lượt chạy đầu tiên; 20260908h đưa chúng về doc.
Bài học rộng hơn: một luật chỉ tồn tại khi có gác chạy được. Luật 10 đã nằm trong skill và trong docs từ 03.09, đã được ba agent đọc, và vẫn bị vi phạm 277 lần bởi chính người viết ra bản ghi luật đó.
Audit chỉ phủ thứ máy vừa sinh: khoá viết tay rơi ngoài, và không ai được báo
Đo ngày 05.09.2026. CourseOpsOrchestratorWorkflow xếp lịch audit từ danh sách khoá nó vừa sinh nội dung trong lượt đó. Khoá viết tay không bao giờ đi vào hàng việc ấy, nên cron ba tiếng một lần chạy đều đặn, không lỗi, không cảnh báo, và không bao giờ chạm tới chúng.
Con số lúc phát hiện: 97 khoá đang hoạt động có audit cũ hơn nội dung, trong đó 72 khoá chưa từng được audit lần nào. Từ 03.09.2026 nội dung viết tay được phép, nên phần rơi ngoài chỉ ngày càng lớn, và nó là phần mới nhất, tức phần cần kiểm nhất.
Đây là loại hỏng khó thấy nhất trong sổ này: không có gì đỏ. Bảng điều khiển vẫn báo audit chạy đều, vì nó chạy đều thật, chỉ là chạy quanh chỗ cần kiểm.
Điều kiện phải là "đã đủ chưa", không phải "đã có chưa"
Phản xạ đầu tiên là xếp lịch cho khoá chưa có dòng nào trong course_quality_reports. Sai: một khoá audit một lần từ lâu, rồi nội dung đổi, sẽ mãi mãi được tính là xong.
Đo thật cho thấy khoảng cách: 72 khoá chưa từng audit, nhưng 97 khoá có audit cũ hơn nội dung, phép kiểm EXISTS bỏ sót đúng 25 khoá.
Điều kiện đúng là so mốc thời gian: audit mới nhất phải mới hơn nội dung mới nhất (course_concepts.created_at và course_lesson_quiz_bank.created_at).
Bước queue-stale-audits có trần riêng mỗi lượt. Thả cả 97 workflow cùng lúc là tự dựng một cơn dồn ứ; cron ba tiếng một lần rút cạn hàng dần.
Sửa lại 14.09.2026, trần đó từng là 12, và 12 là con số mượn nhầm chỗ. Nó được chọn cho các workflow SINH nội dung: mỗi lượt vài phút và vài nghìn token model. Nhưng auditCourse không gọi model lần nào, nó đọc D1 rồi chấm bằng mã. Giữ chung một trần với việc đắt gấp trăm lần chỉ kéo dài hàng đợi mà không đổi lấy điều gì. Trần cho audit nâng lên 40, tách hẳn khỏi budget của phần sinh; tồn đọng trăm-mấy khoá rút cạn trong khoảng nửa ngày thay vì hai ngày.
Bài học chung: một con số trần chỉ đúng cho loại việc mà nó được đo. Chép nó sang loại việc khác thì nó thành một cái phanh không ai nhớ vì sao có.
Hai bảng người dùng: users và legacy_users không phải một, và không trùng nhau
Phát hiện 07.09.2026. conan-platform-production có ĐỒNG THỜI hai bảng người dùng. Đường xác thực thành viên (verify-code, Google OAuth trong domains/user/index.ts) ghi vào users; còn createD1AdminClient gắn tiền tố legacy_, nên mọi thứ đi qua nó, membership claims, admin, learning, đọc legacy_users. Đếm cùng ngày:
| số dòng | |
|---|---|
users | 121 |
legacy_users | 1256 |
có ở users mà không có ở legacy_users | 32 |
lookupRecipient() trong shared/email/send.ts trước đó chỉ hỏi legacy_users, nên với 32 người đó nó trả null, chỗ gọi lặng lẽ bỏ qua email, không lỗi, không log, không dòng nào trong email_deliveries. Đã sửa: hàm hỏi cả hai bảng và ghi cảnh báo khi không tìm thấy ở đâu cả.
Chưa sửa, và lớn hơn nhiều: processMembershipClaim đọc db.from('users') → legacy_users với một claim.user_id lấy từ phiên thành viên (tức một users.id). Với 32 người đó, workflow kích hoạt membership ném MEMBERSHIP_USER_NOT_FOUND. Bất cứ ai chạm vào đường tiền phải kiểm tra điều này trước. Luật chung: đừng bao giờ đoán một user_id thuộc bảng nào, hỏi cả hai, hoặc truy ngược tới chỗ sinh ra nó.
Đăng ký bằng mã email là mã chết vì kiểm sai thứ tự
Phát hiện 07.09.2026, đã sửa. Trong POST /api/user/auth/verify-code:
let user = await …SELECT … FROM users WHERE email=?1…
if (user?.status !== 'active') {
return c.json({ error: 'Tài khoản này hiện không hoạt động.' }, 403)
}
if (!user) { /* tạo tài khoản mới */ } // ← không bao giờ chạy tớiVới người chưa có tài khoản, user là undefined, nên user?.status là undefined, khác 'active' → trả 403 "Tài khoản này hiện không hoạt động." Nhánh tạo tài khoản ngay dưới là mã chết. Nghĩa là không ai đăng ký mới được bằng mã email, họ nhận được mã, nhập đúng, và bị báo tài khoản không hoạt động.
Hai chi tiết làm lỗi này sống lâu: thông báo lỗi nghe hợp lý (người dùng tin là tài khoản mình có vấn đề chứ không phải hệ hỏng), và đường Google OAuth ngay bên cạnh kiểm ĐÚNG thứ tự (if (user && user.status !== 'active')) nên ai thử bằng Google đều thấy chạy tốt.
Sửa: đảo thành if (!user) { tạo } else if (user.status !== 'active') { 403 }. Bài học: ?. trong một điều kiện phủ định gộp "không tồn tại" vào "tồn tại nhưng sai", hai trường hợp cần hai lối đi khác nhau.
Ảnh theo dõi email đếm sai theo cả hai chiều
Ảnh 1×1 là cách đo lượt mở duy nhất khả thi qua email, nhưng đừng báo cáo nó như sự thật: client chặn ảnh thì người đọc thật không để lại dòng nào; Gmail cache ảnh qua proxy nên thường chỉ ghi được lượt mở ĐẦU TIÊN dù đã đặt no-store; còn Apple Mail Privacy Protection tải trước mọi ảnh dù người dùng chưa mở, sinh dòng "mở" giả ngay sau khi gửi. Cột via_proxy trong email_opens đánh dấu nhóm thứ ba. Đọc con số như "ít nhất bao nhiêu người đã mở".
KV từ chối expirationTtl dưới 60 giây, và cú ném làm hỏng cả endpoint
Xảy ra 07.09.2026. Nút "Gửi thử cho tôi" trong admin dùng KV làm khoá chống bấm liên tục:
await c.env.CACHE.put(lockKey, ts, { expirationTtl: 15 }) // ← 500Cloudflare KV có TTL tối thiểu 60 giây. Với 15, KV trả 400 Invalid expiration_ttl of 15. Expiration TTL must be at least 60., và điều đáng nói là binding ném, không trả về lặng lẽ. Nên một dòng tưởng chỉ ảnh hưởng cơ chế chống spam lại làm cả endpoint trả 500, sau khi thư đã gửi hay chưa thì tuỳ thứ tự lệnh. Ở đây khoá đặt trước lúc gửi nên chưa ai nhận thư thừa, còn nếu đặt sau thì người dùng sẽ nhận thư mà giao diện báo lỗi.
Không có gì bắt được lỗi này trước khi chạy thật: tsc không biết, wrangler deploy --dry-run không biết, và giao diện chỉ hiện "API 500 INTERNAL_ERROR". Chỉ wrangler tail nói ra.
Cách làm đúng khi cần ngưỡng ngắn hơn 60 giây: đặt TTL 60 để KV tự dọn rác, còn ngưỡng thật thì so mốc thời gian lưu trong giá trị.
const last = await c.env.CACHE.get(key)
if (last && Date.now() - Number(last) < 15_000) return c.json({ error: '...' }, 429)
await c.env.CACHE.put(key, String(Date.now()), { expirationTtl: 60 })Bài học rộng hơn: một lượt ghi KV hỏng không phải chuyện phụ, nó ném như mọi lượt gọi mạng khác. Nếu lượt ghi đó chỉ là tiện ích (đo đạc, chống spam) thì bọc try/catch; nếu nó là hàng rào thật thì để nó ném, nhưng phải đặt TRƯỚC tác dụng phụ mà nó canh giữ.
SVG trong D1: hai bẫy nằm ở chỗ đọc, không ở chỗ ghi (13.09.2026)
Ảnh cover của khoá học được vẽ bằng thuật toán và cất thẳng trong D1 (legacy_courses.cover_svg), không đẩy lên R2, mỗi ảnh hai tới bốn kilobyte, ba mươi mốt khoá gộp lại tám mươi mốt kilobyte. Quyết định đó đúng, và nó dời rủi ro sang chỗ khác: giao diện bây giờ phải vẽ một chuỗi markup lấy từ cơ sở dữ liệu.
Bẫy thứ nhất, để chuỗi thô rời khỏi API. Đường ngắn nhất từ một chuỗi SVG tới màn hình là dangerouslySetInnerHTML, và nó cho mọi thẻ script trong chuỗi ấy chạy với quyền của trang. Cách đúng là đổi sang data URI ngay ở API và xoá cột thô khỏi payload, để frontend không bao giờ cầm markup mà chọn cách nhúng. Chặn ở chỗ dữ liệu rời khỏi server là chặn một lần cho mọi frontend; chặn ở từng thẻ là chặn lại mỗi lần có thẻ mới.
Dấu # cắt đứt data URI, và không có lỗi nào
Mã màu trong SVG là #C4542B. Không encodeURIComponent thì trình duyệt đọc # đầu tiên là mốc neo và cắt data URI ngay tại đó. Ảnh vẫn hiện, chỉ là mất sạch màu từ chỗ đó trở đi, và console trống.
Đây là kiểu hỏng tệ nhất của sổ này: không có gì đỏ, không có gì trong log, và thứ sai lại là thứ chỉ mắt người mới bắt được.
Hai bẫy này áp cho mọi cột chứa markup, không riêng ảnh cover.
Gác check-no-new-supabase chết vì diff quá lớn, không vì vi phạm (13.09.2026)
PR gộp 190 khoá nội dung có diff 28 MB. check-no-new-supabase.mjs kéo cả diff về một biến, vượt maxBuffer của execFileSync, và script chết, job đỏ với một cú ném của Node chứ không phải với một vi phạm.
Đỏ thì còn thấy được. Cái nguy hơn là cách chữa sai: nới maxBuffer cho tới khi vừa. Diff của repo này chỉ có lớn dần, nên đó là hẹn lại đúng lỗi cũ ở một PR sau, lần này kèm CI hết bộ nhớ.
Cách chữa đúng là để git lọc, đừng lọc trong Node. Hai bước:
git diff --name-only -i -G supabase <range> -- <đuôi file được quét>, hỏi xem file nào có dòng chạm tới chữ đó. Mọi mẫu cấm (@supabase/,supabaseClient,SUPABASE_) đều chứa chữsupabase, nên đây là tập cha, không bỏ sót. Gần như luôn rỗng.- Chỉ khi bước một ra kết quả mới lấy diff thật, và chỉ của mấy file đó.
Sửa xong phải thử lại bằng vi phạm thật
Một gác lọc hẹp hơn rất dễ thành một job xanh không soi gì. Bản sửa này được thử bằng ba mẫu: import từ @supabase/supabase-js trong .ts, biến SUPABASE_URL trong .vars, và một dòng phụ thuộc trong package.json. Cả ba đều bị bắt.
Cùng loại bẫy đã ghi ở mục "gác tài liệu Thư viện tự kiểm bằng sáu mẫu lỗi", gác nào cũng cần một cách tự chứng minh là nó còn soi.
Bẫy đi kèm, đã cắn ngay trong lượt sửa này: chạy tay node scripts/check-no-new-supabase.mjs mà không đặt GITHUB_BASE_REF thì script rơi về git diff --cached, rỗng khi cây làm việc sạch, và in ra dòng xanh. Muốn kiểm thật thì phải GITHUB_BASE_REF=main node scripts/....
81 khoá soạn xong nằm trong git mà chưa bao giờ lên production (13.09.2026)
Đo lại toàn kho sau đợt ảnh cover: 107 trên 182 pack có dòng đang chạy trong D1. Tám mươi mốt pack còn lại đã soạn đủ mức 3, ba unit, chín bài, 180 câu quiz, và chưa từng được biên dịch ra migration. Chín cộng đồng thiếu gần hết khoá của mình: Customer Success 10, AI & Technology 9, CEO 9, Founder 9, Product 8, Health 8, Home 8, Commerce 7, Hospitality 7.
Không có gì báo. --check xanh trên cả 190 pack, CI xanh, sổ schema_migrations xanh, vì không migration nào tồn tại thì cũng không migration nào hỏng. Nội dung nằm trong repo là điều kiện cần, không phải điều kiện đủ; giữa "đã soạn" và "người học thấy được" còn một bước mà không ai đo.
Đếm hai con số, đừng đếm một
Báo cáo tiến độ nội dung phải nêu cả pack trong git lẫn khoá đang chạy trong D1. Suốt đợt soạn, con số duy nhất được nhắc là con số thứ nhất, nên bức tranh nghe như đã xong trong khi hơn bốn mươi phần trăm chưa ai đọc được.
Phép đo rẻ: lấy course_id của mọi manifest, so với SELECT id FROM legacy_courses WHERE is_active = 1.
--check xanh mà build() chết: hai tầng misconception dùng hai tên khoá (13.09.2026)
Biên dịch 69 pack thì tệp đầu tiên chết bằng KeyError: 'code'. Nguyên nhân: 82 pack ghi unit_code ở misconception cấp UNIT, trong khi bộ biên dịch đòi code. Ở cấp LESSON thì unit_code lại đúng, nó trỏ ngược lên mã của unit.
Hai tầng, hai tên khoá, và không có gì trong check() soi tầng unit. Nên --check xanh trên cả 190 pack, CI xanh, và lỗi chỉ hiện ra ở build(), tức lúc sinh SQL, giữa một đợt biên dịch hàng loạt.
Bộ kiểm phải soi đúng thứ bộ sinh đòi
Đây là loại lệch nguy nhất giữa hai nửa của một đường ống: nửa kiểm nói được, nửa sinh nói không. Mỗi lần build() đọc một trường mà check() không kiểm, trường đó là một quả mìn hẹn giờ, nó nổ ở lượt biên dịch chứ không nổ ở lượt kiểm, và lượt biên dịch thường là lúc đang vội.
Đã thêm phép kiểm vào check() và thử ngược lại bằng cách trả một pack về hình dạng cũ: gác bắt đúng.
Gửi thư hằng loạt vào danh sách sai làm hỏng cả thư giao dịch
Khi dựng vòng gửi hằng ngày (07.09.2026), lựa chọn nguồn người nhận là quyết định nguy hiểm nhất của cả tính năng. Trong D1 có hai bảng người dùng: users 121 dòng (người đã thực sự tạo tài khoản và đăng nhập được) và legacy_users 1256 dòng (dữ liệu nhập từ thời trước, phần lớn chưa từng đăng nhập). Lấy bảng lớn hơn thì "phủ" được nhiều người hơn, và đó chính là cái bẫy.
Địa chỉ nhập cũ chứa hộp thư đã đóng và bẫy spam. Gửi hằng ngày vào đó đẩy tỉ lệ bật lại và tỉ lệ báo spam lên, và hậu quả không dừng ở thư tiếp thị: uy tín gửi gắn với cả MIỀN, nên khi nó tụt thì login_code cũng vào thư rác. Lúc đó người dùng không đăng nhập được, và triệu chứng trông như lỗi xác thực chứ không như lỗi email.
Ba hàng rào đang dựng: chỉ gửi cho users với status='active'; mọi thư gắn kết mang link huỷ và header List-Unsubscribe (Gmail đòi huỷ-một-chạm với người gửi số lượng lớn, thiếu nó thì người ta bấm "báo spam" thay vì "huỷ nhận"); và công tắc daily_email_enabled trong platform_settings tắt được trong một phút.
Bài học rộng hơn: với email, "gửi cho nhiều người hơn" không phải một nút gạt vô hại. Mỗi địa chỉ xấu trong danh sách là một khoản vay lấy từ khả năng giao thư của những thư quan trọng nhất.
Tám course_id bị hai pack dùng chung: và vì sao không ai thấy (13.09.2026)
course_id trong manifest là khoá chính của dòng trong legacy_courses. Hai pack cùng một id thì pack biên dịch sau ghi đè dòng của pack trước: một khoá biến mất khỏi production, và không có lỗi nào ở đâu cả, mỗi migration là một tệp riêng, chạy riêng, không tệp nào thấy tệp kia.
Đo lúc phát hiện: 8 id bị hai pack tranh, Customer Success 6 cặp, Health 1, Hospitality 1. Customer Success có 18 pack cho một cộng đồng 10 id, sáu khoá viết ngày 07.09 mượn lại id của sáu khoá viết ngày 06.09.
--check không bắt được, vì nó soi từng pack một
Phép kiểm hình dạng chạy trên một pack tại một thời điểm, nên nó không có cách nào biết pack bên cạnh dùng id gì. Va chạm chỉ hiện ra khi soi NHIỀU pack một lượt, và CI vốn đã gọi đúng như vậy (--check với toàn bộ pack), chỉ là phép kiểm chưa tồn tại.
Gác đã thêm vào main() của course_pack_to_sql.py, không thêm vào check().
Chọn pack nào giữ id: hai quy tắc khách quan, không đoán
sort_order không phân định được, cả 8 cặp đều trùng luôn cả sort_order, vì hai pack cùng được viết cho một vị trí. Hai quy tắc dùng thay:
- Có dòng đang live thì D1 quyết.
health-course-1đang chạy với slugdo-tien-bo-cua-khach, tức nó thuộc vềhealth-progress-evidence; pack kia đổi id. Đổi ngược lại là làm mồ côi dữ liệu học tập đã gắn với dòng đó. - Không có dòng live thì ai giữ trước giữ tiếp, đọc từ ngày
gitthêm tệp. Pack thêm sau là pack đã mượn một id có chủ.
Id mới cấp trên số cao nhất đang dùng (cs-course-11…16, health-course-11, hosp-course-11) chứ không lấp vào chỗ trống thấp: số từ 11 trở lên tự nó nói "thêm sau bộ mười khoá gốc", còn lấp vào 01/02 thì ngụ ý một vị trí trong bộ gốc mà khoá đó không có.
Ô nhập URL ảnh không kiểm gì: bài lên web với ảnh vỡ (07.09.2026)
Learner story đầu tiên lên www.conan.school với cả ảnh bìa lẫn ảnh đại diện vỡ. Trang render đúng, API trả đúng, is_public = 1 đúng, không log lỗi ở đâu. Giá trị trong D1 là:
cover_url = https://photos.app.goo.gl/GbtPs4JUae3M7YZM7
avatar_url = https://photos.app.goo.gl/s3fjbuHpBpcupeF86Link chia sẻ Google Photos trỏ tới một trang HTML, không phải file ảnh. <img src> nạp về HTML, không hiển thị được, và trang chỉ hiện chữ alt. Google Photos còn chặn hotlink nên kể cả moi được URL ảnh bên trong thì vài giờ sau cũng hết hạn.
Ô nhập ở admin lúc đó là {field('cover_url', 'Ảnh bìa (URL)')}, một ô text trơn. Người soạn dán thứ mà trình duyệt của họ đang mở, và với ảnh thì thứ đang mở gần như luôn là một trang chia sẻ chứ không phải file.
Ô nhập URL là ô nhập không kiểu
Toàn bộ lớp lỗi này sinh ra từ việc bắt người dùng tự cung cấp một URL đúng loại. Không có phản hồi nào tại chỗ nhập, và hỏng chỉ hiện ra ở đầu kia, trên trang công khai, sau khi đã đăng.
Hai lớp đã thêm: nút tải ảnh lên (upload vào R2, trả về URL chắc chắn đúng loại) để đường dễ nhất cũng là đường đúng, và gác ở API từ chối các host trang chia sẻ đã biết kèm câu giải thích. Chỗ nào trong admin còn ô "URL ảnh" gõ tay thì chỗ đó còn nguyên rủi ro này.
Gác UI của admin quét sai đường dẫn nên crash thay vì kiểm (phát hiện 07.09.2026, sửa 08.09.2026)
admin.conan.school/scripts/check-ui-standards.mjs khai roots = ['admin-members/src', 'admin-trial/src', …] và giải theo gốc repo, tức tìm conanplatform/admin-members/src, thư mục không tồn tại từ đợt dọn 08.2026, vì các app đã dời vào admin.conan.school/legacy/. Chạy npm run check:ui là ENOENT ngay tệp đầu, readdirSync ném lỗi.
Nó không nằm trong CI, ci-checks.yml chạy bản ở gốc repo (scripts/check-ui-standards.mjs), là một script khác hẳn, kiểm typography. Nên chính sách "chỉ Heroicons, cấm emoji và svg nội tuyến" của admin hiện không có gác nào thi hành, ở cả sáu cụm legacy.
Một gác chưa từng chạy không hỏng, nó chưa bao giờ hoạt động
Khác với gác hỏng do lệch dần, cái này chết ngay dòng đầu tiên với mọi lượt chạy. Điều đó nghĩa là nó chưa từng bảo vệ điều gì kể từ khi thư mục dời chỗ, và không ai nhận ra vì không job CI nào gọi tới.
Đã sửa 08.09.2026. Script hỏng bị xoá; chính sách chuyển sang scripts/check-admin-icons.mjs ở gốc repo, có step riêng trong job guards. Đường dẫn tự khám phá legacy/admin-* thay vì liệt kê tay, và gác tự kiểm bằng 8 mẫu lỗi nên một lần sửa hỏng luật sẽ làm nó đỏ thay vì âm thầm xanh. Lượt chạy đầu bắt 10 vi phạm thật (⚠ ✓ ✕ ★ 🥇 📣 👥 trong 6 file), đã dọn hết, nên gác đặt cổng tuyệt đối chứ không cần mốc chặn.
Luật không có gác thì không phải luật: 618 → 3.445 chỗ màu trong năm ngày (08.09.2026)
CLAUDE.md dành cho LUẬT MÀU nhiều chữ nhất trong cả file, có bảng ánh xạ token, có mục "Vì sao luật này nghiêm đến vậy" với sự cố thật (114 chỗ / 27 file, 31.08.2026, khiến đổi --color-primary không lan tới trang học). Nhưng phần "Kiểm trước khi xong" chỉ là một dòng grep để người sửa tự nhớ chạy. Không job CI nào gọi nó.
Rà soát 03.09.2026 đếm 618 chỗ và xếp nó vào backlog. Đếm lại 08.09.2026: 3.445.
Đây là thứ đáng ghi, không phải con số: mọi luật khác trong CLAUDE.md đều có một gác chạy được (check-ui-standards, check-no-new-supabase, check-no-auth-bypass, check-library-docs, mốc tsc, sổ schema_migrations) và không luật nào trong số đó bị vi phạm. Luật bị vi phạm nhiều nhất đúng là luật duy nhất chỉ được viết ra chứ không được đo.
Đã thêm scripts/check-color-tokens.mjs, mốc chặn theo từng thư mục app, chỉ được hạ, chạy trong job guards (không cài phụ thuộc nên gần như miễn phí về quota). Phép thử cho một luật mới từ nay: cái gì đỏ khi ai đó phá nó, và ta đã thấy nó đỏ chưa.
Gác kiểm "gói có mặt đúng phiên bản" không bắt được "gói bị cấm" (08.09.2026)
check-ui-standards.mjs kiểm sàn major của các gói UI. Nó duyệt requiredMajors rồi if (!dependencies[name]) continue, tức là chỉ kiểm những gói có mặt. Một gói bị cấm mà nó không biết tên thì nó không nhìn tới.
mam.conan.school khai framer-motion: ^12.38.0 và import from "framer-motion" ở bốn component, trong khi CLAUDE.md nói rõ phải dùng gói tên motion và motion/react. Gác xanh suốt.
Vì sao không có triệu chứng nào: gói motion re-export framer-motion, nên framer-motion nằm sẵn trong lockfile của cả www và com dưới dạng phụ thuộc bắc cầu, bản dựng chạy, giao diện chạy, npm ls trông bình thường. Chỉ package.json nói sự thật.
Bài học: một danh sách cho phép không thay được một danh sách cấm. Gác giờ bắt cả hai chiều, và đã tự kiểm bằng cách thả một file vi phạm vào repo để xem nó đỏ.
Lý do mục: chú thích trong mã không có hạn dùng (08.09.2026)
Bốn domain của api dựng bảng lười lúc chạy, với lý do viết ngay cạnh: "phiên deploy và phiên áp migration là hai việc tách rời trong repo này." Lý do đó đúng lúc viết. Nó hết đúng từ 04.09.2026, khi CI bắt đầu áp migration trước bước deploy trong cùng một job.
Chú thích thì ở lại, và người đọc sau đọc nó như một ràng buộc đang sống, rồi viết domain thứ năm theo đúng lối đó. Cái giá đã tích: một DB.batch DDL thừa trên mọi request của diễn đàn và Thư viện Prompt, và bảy bảng không nằm ở đâu trong git.
Loại lệch này khó thấy hơn "tài liệu sai" vì không có câu nào sai, chỉ có một lý do đã mục. Khi đổi một ràng buộc hạ tầng, hãy grep chính lý do đó trong mã. Đã gỡ, lược đồ vào 20260915a_runtime_ddl_to_migrations.sql.
Bậc số Tailwind không tồn tại: 55 lớp không sinh ra CSS nào (08.09.2026)
Tailwind chỉ có 50, 100, 200 … 900, 950. Các trang Books của www dùng text-purple-650, text-slate-655, text-slate-450, hover:text-purple-755, border-slate-150, bg-purple-650…
- 55 lớp với bậc số không tồn tại.
Tailwind không cảnh báo lớp lạ; nó chỉ không sinh gì. Nên hàng chục phần tử trong trải nghiệm đọc sách không có màu, chỉ thừa hưởng màu của cha. Không lỗi, không cảnh báo, trang vẫn đọc được.
Cách bắt: so chuỗi class trong bundle JS với CSS đã dựng.
grep -o "purple-650" build/client/assets/*.js # có
grep -o "purple-650" build/client/assets/*.css # KHÔNG có → lớp chếtCả 55 chỗ nằm trong bốn file, đều là mã do máy sinh, mô hình bịa ra bậc số nghe hợp lý (650 nằm giữa 600 và 700). Bậc số Tailwind là danh sách đóng, không phải thang liên tục.
Nửa chế độ tối: 52 lớp dark: trên site không có dark mode (08.09.2026)
www không cấu hình dark mode ở đâu: không darkMode, không class .dark, không @custom-variant dark, theme.css chỉ có một bộ token sáng. Nhưng trong Tailwind v4, dark: mặc định gắn với @media (prefers-color-scheme: dark), nó vẫn sinh CSS và vẫn áp dụng.
Hệ quả: người dùng để máy ở chế độ tối thấy trang nền sáng với vài chục chỗ chữ và viền nhảy sang màu dành cho nền tối, text-blue-400 trên nền trắng. Không ai thử ở chế độ tối vì "site này đâu có dark mode".
Luật: một site chọn theme sáng thì không được có lớp dark: nào dùng bảng màu. Lớp dark: dùng token (từ shadcn) thì vô hại vì token không đổi.
Hex dự phòng lệch khỏi token nó dự phòng (08.09.2026)
var(--color-score-profile, #22d3ee) trông rất cẩn thận. Token thật là #818cf8. Bốn trên bốn giá trị dự phòng đều đã lệch, token đổi, dự phòng thì không ai nhớ sửa.
Giá trị dự phòng chỉ hiện ra khi theme.css không nạp; và đúng khoảnh khắc đó, thứ hiện ra là màu sai chứ không phải màu an toàn. Một dự phòng không bao giờ chạy thì không ai phát hiện nó đã mục. Bỏ hẳn nhánh dự phòng thì đúng hơn giữ một nhánh không ai kiểm.
Bảng phong cách tài liệu hoá một bảng màu không tồn tại (08.09.2026)
packages/admin-shared/.../StyleGuidePage.tsx, bảng hướng dẫn dùng chung của cả cụm admin, in cứng Primary #f26625, Foreground #111827, Secondary #f9fafb, Border #e5e7eb. Trong theme.css bốn giá trị thật là #d94f2b, #111111, #f2f2f2, #f2f2f2. Không giá trị nào khớp.
Mọi trang chép theo bảng hướng dẫn đều lệch, và không có gì báo, vì bản thân bảng hướng dẫn trông rất chính quy. Đây gần như chắc chắn là nguồn của một phần trong 1.602 chỗ vi phạm còn lại ở admin. (Cùng file này trước đó còn kê đơn text-red-600 cho nút Destructive, xem rà soát 08.09.)
Luật: tài liệu về màu không được chứa mã màu. Ô màu phải render bằng chính lớp token (bg-primary), nhãn ghi tên token chứ không ghi hex, như vậy trang tự đúng theo theme.css và không thể lệch lại được.
Gác bắt "một dạng" vi phạm thì bỏ lọt các dạng còn lại (08.09.2026)
Gác màu bản đầu bắt bậc số Tailwind và hex trong khai báo CSS. Nó bỏ sót bg-[#faf7f0], lớp Tailwind giá trị tuỳ ý, và đó lại là dạng phổ biến nhất: 792 chỗ toàn repo, riêng www 513.
Khi viết gác cho một luật, liệt kê mọi cách viết ra thứ bị cấm trước khi viết dòng regex đầu tiên. Ở đây mã màu có bốn cửa vào: bậc số bảng màu, giá trị tuỳ ý trong className, khai báo CSS, và thuộc tính style của JSX.
Hệ quả kéo theo: gác mở rộng làm mốc của com và play tăng, trông y hệt một lần lùi. Nên --update-baseline giờ từ chối mọi lần nâng trừ khi có --allow-raise kèm lý do ghi vào báo cáo, "mốc chỉ được hạ" phải do công cụ giữ, không do người sửa tự nhớ.
Model viết cụt giữa câu, và ba lớp cùng để lọt (14.09.2026)
Người học bấm "Xin một góc nhìn" ở bài Bốn loại phương án thay thế (khoá Định vị, cộng đồng Marketing) và nhận về đúng một câu rưỡi, dừng giữa chừng ở "…tập trung vào nhóm khách hàng đang". Bấm lại vẫn ra đúng đoạn đó. Trong bảng course_concept_inquiry_responses lúc đó có 2 dòng đã sinh góc nhìn, 1 trong 2 bị cắt, tức tỷ lệ hỏng một phần hai, không phải một ca hiếm.
Không có lớp nào trong ba lớp dưới đây bắt được, và mỗi lớp trượt vì một lý do khác nhau:
1. Phép kiểm hết-token chỉ chạy khi nội dung RỖNG. unwrapEnvelope ném TokenExhausted với điều kiện finish_reason === 'length' && !content.trim(). Một câu trả lời viết dở dang có nội dung, nên nó đi thẳng qua như thể đã xong. Vế && !content.trim() ấy được viết cho ca DeepSeek tiêu hết token vào phần suy luận rồi trả về rỗng, đúng cho ca đó, và vô tình biến ca "viết được một nửa" thành ca hợp lệ.
2. Vỏ bao của Workers AI không có finish_reason nào để mà kiểm. Nhánh if ('response' in result) return result.response trả thẳng, không qua phép kiểm nào. Nghĩa là với mọi model chạy trên Workers AI, trong đó có DEFAULT_MODEL, hệ chưa bao giờ có một tín hiệu hết-token. Phép đo gián tiếp duy nhất có sẵn là usage.output_tokens chạm trần: một câu trả lời viết xong dừng TRƯỚC trần, không dừng ĐÚNG ở trần.
3. Khuôn JSON chỉ đòi "một chuỗi ≥ 80 ký tự". InquiryViewSchema là { view: string }. Một đoạn văn cụt giữa câu thoả mãn hoàn toàn, nên zod gật đầu và nội dung được ghi vào D1.
Cache vĩnh viễn biến một lần hỏng thành hỏng mãi mãi
if (saved.ai_view) return, góc nhìn sinh một lần rồi dùng lại mọi lượt bấm sau. Với một câu trả lời tử tế thì đó là tiết kiệm; với một câu trả lời cụt thì đó là ghi vĩnh viễn một đoạn văn dở dang vào bài của người học, và không có đường nào thoát: bấm lại bao nhiêu lần cũng ra đúng nó.
Cách sửa không phải đi dọn tay bảng dữ liệu, mà là cho nó tự lành, điều kiện dùng lại bản cache phải là "bản cũ CÒN DÙNG ĐƯỢC", không phải "bản cũ CÓ TỒN TẠI".
log: false tắt luôn cả sổ chi phí, nên không có gì để đọc lúc hỏng
Ba engine đối diện người học (inquiry-view, lesson-reflection, unit-task-grade) khai log: false vì prompt của chúng chứa nguyên văn bài người học viết. Đúng.
Nhưng câu INSERT INTO content_token_usage lại nằm trong if (logId), mà logId là null với đúng ba engine đó. Hệ quả ngoài ý muốn: chúng biến mất khỏi cả sổ CHI PHÍ lẫn sổ KẾT CỤC. Khi một trong ba hỏng thật, thứ duy nhất còn lại là một dòng console.warn. Bảng token chỉ chứa số và tên engine, không có chữ nào của người học, tắt nhật ký prompt không có lý do gì kéo theo tắt nó.
Đổi model là phần dễ nhất và cũng là phần ít quan trọng nhất của bản sửa: DEFAULT_MODEL (llama-70b) là model yếu nhất danh mục ở đúng việc khó nhất của lượt gọi này, nhưng model nào rồi cũng có lúc viết hỏng. Thứ đáng sửa là ba lớp đã để lọt, và cái cache không biết tự lành.
Repo đủ mà production thiếu: 18 bài Sales không ai biết là chưa lên (14.09.2026)
Đo lại toàn bộ trên D1 sau khi mọi pack đã "lên production": 18 cộng đồng, 190 khoá, 570 unit, khớp. Nhưng 1.692 bài thay vì 1.710. Chênh đúng 18, và cả 18 nằm ở Sales: sáu khoá còn 2 bài mỗi unit trong khi pack trong git đã có đủ 3 từ lâu.
Nguyên nhân không phải nội dung thiếu, nội dung đã viết xong và đã nằm trong git. Nó là bước biên dịch: sáu pack được bồi dày sau khi migration của chúng đã sinh, và không ai sinh lại. Pack đủ, migration cũ, D1 cũ.
Đếm khoá thì thấy đủ; phải đếm tới BÀI mới thấy thiếu
Mọi phép đếm ở tầng khoá đều xanh: 190/190 pack có dòng đang chạy, mỗi cộng đồng đủ mười khoá. Chỗ thiếu nằm sâu hơn một bậc, và không có gác nào đếm tới đó.
Phép đo đúng là so số bài trong git với số bài trong D1, theo từng khoá, không phải so số khoá.
Bồi pack rồi thì phải sinh lại migration. Bộ biên dịch dùng ON CONFLICT DO UPDATE toàn tập nên áp lại lên khoá đã có là an toàn và không mất gì, nhưng chỉ an toàn khi bài mới được thêm vào cuối. Ở sales-territory-planning, bài mới được chèn vào giữa nên -u3-c2 đổi hẳn nội dung và bài cũ đẩy xuống -c3. Lần này vô hại vì cả sáu khoá chưa có một dòng tiến độ, ghi danh, phản hồi hay reflection nào; nhưng phép kiểm phải làm TRƯỚC khi áp, không phải sau.
Trang trả 200, khung đầy đủ, ruột rỗng: năm chỗ hỏng ở www (08.09.2026)
Rà soát SEO của www.conan.school tìm ra năm chỗ hỏng. Không chỗ nào làm đỏ bất cứ thứ gì: mọi trang vẫn trả 200, vẫn có <title>, vẫn dựng đủ điều hướng và chân trang. Chỉ có ruột là rỗng.
1. /api/public/courses trả 500 vì D1 chạm trần 100 biến SQL. Route lấy danh sách khoá rồi hỏi mentor và lớp bằng .in('course_id', courseIds). D1 gắn mỗi phần tử của IN (...) thành một biến SQL và chặn ở 100. Hồi trường có 11 khoá thì chạy tốt; tới 199 khoá thì câu lệnh trả too many SQL variables và cả route 500.
2. /books, /news, /playbooks gọi ba đường dẫn API không tồn tại. Lần lượt /api/public/home, /api/public/news, /api/public/playbooks, đường thật là /api/public/misc/home, /api/public/content/news, /api/public/content/playbooks.
3. /playbooks lấy nhầm hình dạng dữ liệu. Endpoint trả một mảng brief, còn loader lại const { briefs, versions, categories } = json.data, trên một mảng thì cả ba là undefined, và if (!briefs) return { articles: [] }.
catch quanh một lời gọi mạng biến mọi lỗi thành "chưa có gì"
Cả ba chỗ trên đều hỏng theo đúng một khuôn:
try {
const json = await (await fetch(url)).json();
return { items: json.data?.items || [] };
} catch (e) { console.error(e); return { items: [] }; }500, 404, sai hình dạng, tất cả cùng đi ra một cửa: danh sách rỗng. Và giao diện của một danh sách rỗng là "Danh sách đang được cập nhật", tức là thông điệp của một trang khoẻ mạnh chưa có nội dung, không phải của một trang hỏng. /courses in đúng câu đó trong khi D1 có 199 khoá; /books không có một đường dẫn nào tới 176 quyển sách của chính nó.
Hệ quả SEO nặng gấp đôi: mất trang trong chỉ mục, và mất toàn bộ đường dẫn nội bộ tới chúng. Sitemap cũng bọc lời gọi trong Promise.allSettled rồi bỏ qua lỗi, nên mọi URL khoá học lặng lẽ biến mất khỏi đó luôn.
4. Một headers() ở route con gỡ sạch header bảo mật của trang đó. React Router cho headers() của route sâu nhất thay thế header của cha chứ không gộp. Hai mươi hai route ở www khai headers() chỉ để đặt một dòng Cache-Control trùng y hệt dòng root đã đặt, và đổi lại, mỗi trang đó phục vụ không HSTS, không X-Frame-Options, không Referrer-Policy. Trang chủ nằm trong số đó, còn /books thì có đủ, nên so hai trang bất kỳ sẽ thấy kết quả mâu thuẫn nhau.
5. Trang chủ gửi 1,99 MB HTML để render ba thẻ khoá học. Loader return { ...json.data } với một phản hồi 1,96 MB chứa mọi khoá và mọi ebook của trường. Giá trị loader được tuần tự hoá nguyên vẹn vào window.__reactRouterContext, nên toàn bộ đi thẳng vào HTML, kèm classroom_url, zalo_group_url và authoring_meta_json. Lọc ở component không cứu được gì: phần bị bỏ đã đi qua dây rồi. Chiếu xuống đúng năm trường được dùng: 37,7 KB.
Gác: scripts/check-seo.mjs (job guards). Chi tiết ở SEO của www.
Hai bảng courses chỉ trùng 11 slug (08.09.2026)
Khi dựng tầng xem trước cho 691 learning unit, câu truy vấn đầu tiên viết là WHERE c.is_active = 1, và D1 trả no such column: c.is_active.
Lý do: có hai bảng courses trong cùng một D1, và chúng không phải một danh mục.
legacy_courses | courses | |
|---|---|---|
| Ai đọc | d1Client (tiền tố legacy_), /api/public/courses, trang khoá học của www | SQL thô, course_learning_units |
| Đang chạy | 202 (is_active = 1) | 217 (status = 'active') |
| Cột trạng thái | is_active | status |
| Slug trùng nhau | 11 | 11 |
Sai bảng thì hoặc lỗi cột, hoặc, tệ hơn, một câu lệnh hợp lệ không tìm thấy gì
Lỗi cột còn may, vì nó đỏ ngay. Trường hợp nguy hiểm là khi hai bảng có chung tên cột: câu lệnh chạy, trả về rỗng, và mã ở trên biến rỗng thành "chưa có nội dung".
Hệ quả cụ thể ở đây: 201 trong 212 khoá có learning unit không có dòng nào trong legacy_courses. Nếu chỉ đọc bảng legacy, /courses/:slug của chúng trả 404, và ~680 trang unit sẽ treo dưới một trang cha không tồn tại, tức là một họ URL vừa dựng xong đã hỏng. Trang khoá học vì thế đọc legacy trước, không thấy thì rơi về bảng không tiền tố.
Route khai đúng mà không bao giờ tới được: /sitemap/units (08.09.2026)
Router xem trước unit khai bốn đường dẫn theo thứ tự /{slug}/units, /{slug}/units/{n}, /sitemap/courses, /sitemap/units. Hono khớp route theo thứ tự khai, nên /sitemap/units bị /{slug}/units nuốt và trả 404 "Course not found", một route tồn tại, viết đúng, và không bao giờ chạy.
/sitemap/courses thì lại chạy, vì không có route /{slug}/courses nào để nuốt nó. Hai route sinh đôi, một cái sống một cái chết, cùng một file, kiểu lỗi mà đọc mã rất khó thấy.
Cùng họ với bẫy đã ghi ở api-service: khai route cụ thể trước route có tham số. Xem thêm SEO của www.
Dòng ghi danh sống lâu hơn khoá (14.09.2026)
Cho khoá "AI Teen" nghỉ (62 bài mà 0 unit) và phát hiện 13 dòng course_enrollments vẫn trỏ vào nó. Không ai trong 13 người có một dòng tiến độ nào, nên không ai mất phần đã học, nhưng hai chỗ khác vẫn coi những dòng ấy là thật:
1. GET /my-courses lọc theo trạng thái GHI DANH, không theo trạng thái KHOÁ. Khoá đã nghỉ vẫn nằm nguyên trong "Khoá học của tôi", bấm vào thì tới một khoá không còn đường học. Cho nghỉ một khoá mà người học vẫn thấy nó là cho nghỉ nửa vời.
2. activeCourseCount đếm cả ghi danh vào khoá đã nghỉ. Trần "tối đa hai khoá cùng lúc" tính cả khoá chết, nên 13 người đó lập tức chỉ mở được một khoá thay vì hai, không màn hình nào báo vì sao, và họ không có cách nào tự gỡ.
Tắt một khoá là ba việc, không phải một
legacy_courses.is_active gỡ khỏi danh mục. courses.status gỡ khỏi đường course-intelligence. Và các đường đọc qua course_enrollments phải tự lọc theo courses.status, dòng ghi danh không biết khoá của nó đã chết.
Sửa ở chỗ ĐỌC chứ không đi dọn bản ghi ghi danh: dòng ghi danh là dữ liệu của người dùng, và một migration dọn khoá không phải chỗ để sửa nó.
Ngoại lệ cố ý: khoá đã học xong vẫn hiện trong danh sách dù sau đó có nghỉ. Đó là lịch sử của người ta, không phải một lời mời học tiếp.
"Migration đã áp" không có nghĩa mọi câu lệnh trong nó đã ăn (14.09.2026)
20260914d_retire_ai_teen.sql có đúng hai câu UPDATE: một cho legacy_courses, một cho courses. CI báo áp xong, schema_migrations ghi nhận, wrangler thoát mã 0. Chỉ câu đầu ăn.
| trước | sau | |
|---|---|---|
legacy_courses | is_active=1, published | is_active=0, archived ✓ |
courses | active | vẫn active, updated_at vẫn 23.07 ✗ |
Đã loại từng khả năng, và không cái nào giải thích được:
- Dòng đích tồn tại;
WHEREkhớp đúng một dòng khi kiểm lại sau đó. CHECK(status IN ('draft','active','archived'))cho phép giá trị đang ghi.- Trigger duy nhất trên bảng là
AFTER INSERT, không chặnUPDATE. - Không có dấu nháy đơn nào trong phần chú thích để làm rối bộ tách câu lệnh của wrangler.
- Một migration hai câu
ALTERkhác cùng đợt (20260913b) thì áp đủ cả hai, nên không phải "wrangler chỉ chạy câu đầu".
Còn lại đúng một khả năng: D1 bỏ dở giữa chừng mà không báo lỗi. Cùng họ với sự cố 08.09.2026 ở mục migration 372 KB, chỉ khác là lần đó tệp to nên còn đoán được lý do; lần này tệp có hai câu lệnh.
Sau migration đổi dữ liệu, phải ĐỌC LẠI thứ nó vừa đổi
Sổ schema_migrations chỉ ghi rằng tệp đã được đưa cho D1, không ghi rằng D1 đã làm hết. Với migration đổi lược đồ thì thiếu sót lộ ra ngay ở lần dùng tiếp theo; với migration đổi dữ liệu thì không có gì lộ ra cả, con số vẫn ở giá trị cũ, và mọi màn hình vẫn xanh.
Ở đây hệ quả là 13 người ghi danh vẫn thấy một khoá không còn đường học, và trần hai-khoá-cùng-lúc vẫn tính khoá chết. Không ai được báo.
Sáu khối @theme riêng: hệ thiết kế chưa từng được nạp vào chỗ người ta làm việc (08.09.2026)
admin.conan.school tích được 1.601 chỗ dùng màu ngoài token, nhiều nhất repo. Đi tìm nguyên nhân thì thấy nó không phải chuyện cẩu thả.
Sáu cụm legacy mỗi cụm khai một khối @theme riêng (trái LUẬT UI), với bảng màu khác hệ thiết kế và khác nhau: --color-primary là #f26625 thay vì #d94f2b, --color-border là #e5e7eb thay vì #f2f2f2, --color-accent là #f3f4f6 ở ba cụm và #fff7ed ở ba cụm còn lại.
Quan trọng hơn: không khối nào khai emerald, amber, cyan. Người soạn trang mở index.css của cụm mình, thấy primary và muted nhưng không có màu ngữ nghĩa nào cho "đúng / cảnh báo / thông tin", nên họ gõ text-emerald-600. 204 lần emerald, 222 lần amber.
Bài học: trước khi kết luận người ta không tuân luật, hãy kiểm xem thứ luật bắt dùng có nằm trong tầm tay họ không. Con số 1.601 là triệu chứng; nguyên nhân là hệ thiết kế chưa bao giờ được nạp vào sáu ứng dụng đó.
Dev thấy một màu, production ra màu khác (08.09.2026)
Ứng dụng admin được deploy là vỏ SPA, nạp năm cụm legacy qua alias (xem gác lệch tập file về cụm thứ sáu); src/index.css của vỏ @import theme.css, nên CSS trong dist/ chỉ có --color-primary:#d94f2b. Sáu khối @theme với #f26625 chỉ có hiệu lực khi chạy một cụm riêng lẻ ở chế độ dev, mỗi cụm có index.html và main.tsx riêng.
Nên người soạn trang admin ở máy nhìn thấy giao diện cam, rồi ship lên một cổng đất nung. Cùng lỗi này còn ở @source "../../packages/admin-shared/src", sai độ sâu bốn cấp, nên chạy cụm riêng lẻ thì Tailwind không quét component dùng chung và các lớp của chúng không có CSS.
Khi thứ người sửa nhìn thấy không phải thứ người dùng nhìn thấy, token mất hết ý nghĩa và gõ thẳng slate-500 trở thành lựa chọn hợp lý hơn. Sửa dev cho khớp production là điều kiện để luật màu có thể được tuân thủ, không phải việc làm đẹp.
Kiểm "lớp token có sinh CSS không" phải giữ nguyên variant (08.09.2026)
Sau khi quy đổi 1.601 lớp về token, phép kiểm quan trọng nhất không phải gác màu xanh mà là: mọi lớp token có sinh ra CSS không? Đổi text-emerald-600 → text-emerald trong khi --color-emerald không tồn tại là tạo ra đúng loại lớp chết đang đi bắt.
Lần chạy đầu báo 29 lớp thiếu, toàn báo động giả, vì phép kiểm bóc hover: ra rồi tìm .bg-primary\/90. Lớp đó chỉ tồn tại dưới dạng hover:bg-primary/90, và Tailwind sinh selector .hover\:bg-primary\/90:hover. Giữ nguyên variant thì còn 0.
Cách kiểm đúng: gom mọi lớp token kèm variant từ mã, escape theo lối Tailwind (: → \:, / → \/), rồi tìm trong CSS đã dựng. Chuỗi variant lồng của shadcn (dark:data-[state=active]:bg-input/30) vẫn sinh báo động giả, kiểm tay vài cái cuối.
Một khoá, hai slug, hai trang: và sitemap nộp cả hai (08.09.2026)
Hai bảng courses dùng chung id (218 dòng trùng id) nhưng slug lệch nhau ở 190 khoá: legacy_courses giữ slug tiếng Việt (doc-tin-hieu-mua), courses giữ slug tiếng Anh (sales-buying-signals). Cùng một khoá, cùng một cái tên hiển thị, hai địa chỉ.
Cả hai URL trả 200. Cả hai tự canonical về chính mình, vì canonical ở root.tsx dựng từ pathname. Và khi sitemap khai hợp của hai danh mục thì cả hai cùng được nộp.
Bản sao chỉ lộ ra khi đếm, không lộ ra khi đọc
Không có triệu chứng nào: mở URL nào cũng thấy một trang khoá học bình thường. Chỗ lệch duy nhất là trang ở slug tiếng Anh có danh sách unit, trang ở slug tiếng Việt thì không, vì API tra unit theo bảng không tiền tố. Đọc một trang thì thấy đủ; phải đặt hai trang cạnh nhau mới thấy chúng là một.
Nửa số bản sao là do chính bản vá trước đó sinh ra: khai hợp hai danh mục vào sitemap-courses để không bỏ sót 201 khoá chỉ có ở bảng mới, nhưng không khử trùng theo id.
Cách sửa là chọn một slug chuẩn cho mỗi id (COALESCE(l.slug, c.slug), tiếng Việt thắng), cho mọi lượt tra nhận cả hai slug, và 301 từ slug không chuẩn về slug chuẩn. Sau đó sitemap-courses.xml từ 402 xuống 212: con số giảm là con số đúng.
Bài học chung: khi hai bảng mô tả cùng một thực thể, việc đầu tiên phải làm không phải là hợp hai danh sách lại, mà là quyết định khoá định danh nào là thật, ở đây là id, không phải slug.
Gác URL ảnh khoá chết bản ghi mà nó sinh ra để cứu (08.09.2026)
Gác thêm hôm 07.09 từ chối avatar_url/cover_url trỏ tới trang chia sẻ. Nó đúng ý định nhưng sai phạm vi: biểu mẫu admin gửi cả hai trường ảnh trong mỗi lượt PUT, kể cả trường người soạn không đụng tới. Learner story duy nhất đang có thì cả hai trường đều là link Google Photos, đúng lý do gác ra đời. Hệ quả: người soạn thay ảnh bìa, bấm Lưu, PUT mang theo avatar_url cũ, gác trả 400, không gì được lưu. Muốn sửa được thì phải thay cả hai trường trong cùng một lượt, một điều kiện không ai nói ra và không ai đoán được. Nó im lặng gấp đôi vì băng báo lỗi nằm ở đầu trang, còn nút Lưu ở cuối một biểu mẫu dài. Bấm Lưu, không thấy gì đổi, không thấy lỗi. Chẩn đoán ra bằng updated_at IS NULL, không phải "lưu rồi bị từ chối" mà là "chưa lượt lưu nào tới nơi".
Gác chỉ được cấm cái MỚI, không được cấm cái ĐANG CÓ
Một gác kiểm dữ liệu ghi vào phải hỏi "giá trị này có đổi không?", chứ không chỉ "giá trị này có hợp lệ không?". Cấm cả giá trị cũ nghĩa là mọi bản ghi lỡ vi phạm từ trước bị đóng băng vĩnh viễn, và bản ghi vi phạm chính là bản ghi cần sửa nhất. Đã sửa: PUT đọc bản ghi hiện tại, bỏ qua trường nào có giá trị giống hệt cái đang lưu. POST vẫn nghiêm ngặt vì chưa có gì để so.
Đặt báo lỗi cạnh cái nút gây ra nó
Băng lỗi ở đầu trang chỉ đọc được khi biểu mẫu ngắn. Lỗi lưu nay hiện ngay cạnh nút "Lưu thay đổi", và nút tải ảnh nói rõ "Đã tải lên, nhớ bấm Lưu" vì upload xong trông y như đã hoàn tất.
Khối tuỳ biến của tài liệu lesson biến mất im lặng
Loại: hỏng mà hệ vẫn trông như đang chạy. Ghi ngày 08.09.2026, khi dựng tính năng. Tài liệu đọc trong lesson (course_lesson_docs) đi qua ba lớp phải khớp nhau từng chữ: whitelist ở máy chủ (api/src/shared/rich-doc.ts), khai node trong trình soạn thảo (admin/.../lesson-doc/nodes.ts), và renderer dùng chung (packages/rich-doc/RichDoc.tsx). Thiếu chỗ nào thì khối mới biến mất ở đúng chỗ đó, và không có lỗi nào ở đâu cả:
- thiếu whitelist → chèn được, xem trước được, mất khi lưu;
- thiếu khai node → không chèn được (thấy ngay, ít nguy hiểm nhất);
- thiếu renderer → admin xem trước vẫn thấy đủ, người học không thấy gì. Cái thứ ba nguy hiểm nhất: người soạn nghiệm thu bằng nút "Xem như người học" và thấy đúng, còn ngoài kia thì không. Nút xem trước dùng đúng
RichDoc.tsxcủa tokyo chính là để chặn nửa sau của điều đó, nhưng chỉ chặn được khi renderer đã biết vẽ khối, nên vẫn phải sửa đủ ba chỗ trong cùng một commit. Liên quan: hai bản@tiptap/corecùng lúc trong cụm admin cho hậu quả giống hệt, extension đăng ký vào bản này, editor dựng bằng bản kia, khối tuỳ biến im lặng không xuất hiện. Xem skilllesson-docs.
Mẹo nối alpha vào cuối mã hex: sai lặng lẽ với mọi dạng màu khác (08.09.2026)
events.$slug.tsx của tokyo tô badge chuỗi sự kiện bằng màu do người vận hành đặt:
borderColor: `${seriesColor || '#6366f1'}33`, // nối "33" để lấy ~20% độ mờ
backgroundColor: `${seriesColor || '#6366f1'}11`,Mẹo này chỉ đúng khi giá trị là hex 6 ký tự. Người vận hành nhập rgb(...), một tên màu, hay hex 3 ký tự thì kết quả là chuỗi CSS vô nghĩa, trình duyệt bỏ qua thuộc tính đó, viền và nền biến mất, badge vẫn hiện chữ, và không có lỗi nào ở đâu. Không ai báo vì trang trông vẫn chạy.
Dùng color-mix(in srgb, ${màu} 20%, transparent): nhận mọi dạng màu CSS hợp lệ, kể cả var(--color-…). Nhân tiện, giá trị dự phòng cũng nên là token chứ không phải một mã hex lạ, ở đây là #6366f1, một màu chàm không có trong hệ thiết kế.
Neo phép thay thế vào một dòng xuất hiện hai lần (08.09.2026)
Khi sửa hàng loạt bằng script, chèn một dòng khai báo bằng cách tìm dòng neo const photos = Array.isArray(event.photos) ? event.photos : [];, dòng đó có hai lần trong cùng file, ở hai component khác nhau. Phép replace(..., 1) đặt biến vào component sai, trong khi chỗ dùng nằm ở component kia.
tsc bắt được ngay lần này. Nhưng cùng lỗi với một dòng neo không gây lỗi type, chẳng hạn chèn một lớp CSS hay một thuộc tính, sẽ đi thẳng vào production. Trước khi neo, đếm:
grep -c "<dòng neo>" <file> # phải là 1Ngoại lệ thật của luật màu: thương hiệu KHÁC chạy chung ứng dụng (08.09.2026)
tokyo.conan.school phục vụ hai thương hiệu: Conan và Inside6 (isInside6Portal, inside6.com) với bộ nhận diện tím mận riêng. Quy màu Inside6 về primary là biến trang Inside6 thành trang Conan, sai về sản phẩm, không phải "đúng luật".
Nhưng rải mã hex khắp root.tsx, login.tsx, online-events.$slug.tsx cũng sai. Cách đúng là khai một lần vào theme.css thành một họ token có tiền tố (--color-inside6-*), như đã làm với màu dịch vụ bên thứ ba (--color-vendor-facebook, -zoom, -zalo).
Phép thử để phân biệt ngoại lệ thật với vi phạm: màu này có phải bộ nhận diện của một thực thể khác không? Nếu có, khai thành token có tên. Nếu không, nó là màu Conan tô sai chỗ.
Thang xám đảo ngược trên nền tối: quy đổi máy móc làm nút trắng trên nền đen (08.09.2026)
Bộ quy đổi màu → token dùng cho www, admin, com giả định trang nền sáng: slate-900 là chữ, slate-200 là viền, slate-50 là nền. Đúng với ba site đó.
play.conan.school/frontend/src/pages/HostHistory.tsx là trang nền tối, dựng bằng slate-900/800/750/700/600. Ở đó thang chạy ngược, số càng nhỏ càng sáng, tức càng nổi lên trên nền. Quy đổi máy móc cho ra:
| lớp gốc | vai trò thật | quy đổi máy móc | hậu quả |
|---|---|---|---|
bg-slate-800 (thẻ, sáng hơn nền) | bề mặt nổi | bg-foreground #111 | thẻ trùng nền, biến mất |
bg-slate-700 (nút) + text-white | nút | bg-border #f2f2f2 | nút trắng, chữ trắng, không đọc được |
border-slate-700 | viền mờ | border-border #f2f2f2 | viền trắng chói trên nền đen |
Nguy hiểm vì không có gì đỏ: mọi lớp đều là token hợp lệ, build xanh, gác màu xanh. Chỉ mắt người trên đúng trang đó mới thấy.
Cách đúng, dùng cặp token bề mặt nghịch đảo, bậc trung gian diễn đạt bằng độ mờ của màu chữ nghịch đảo:
bg-slate-900 → bg-surface-inverse border-* → border-surface-inverse-foreground/15
bg-slate-800 → bg-surface-inverse-foreground/5 text-slate-400 → text-surface-inverse-foreground/60
bg-slate-700 → bg-surface-inverse-foreground/10 text-white → text-surface-inverse-foregroundTrước khi quy đổi xám, xác định trang nền sáng hay nền tối. Dấu hiệu rẻ nhất: text-white trong cùng khối, hoặc bg-*-900 ở phần tử bao ngoài cùng.
Dev server nói dối, bản dựng nói thật (08.09.2026)
Sau khi sửa một trang nền tối, dev server hiện nó nền sáng: .bg-surface-inverse có trong DOM nhưng getComputedStyle trả rgba(0,0,0,0), và stylesheet chỉ có 95 rule, không có utility đó. Suýt kết luận token hỏng và đi sửa theme.css.
Bản dựng production thì đúng: .bg-surface-inverse{background-color:var(--color-surface-inverse)} với --color-surface-inverse:#111. Nguyên nhân nằm ở cách khởi động dev server (chạy vite từ ngoài thư mục dự án qua --prefix), không nằm ở mã.
Khi dev và bản dựng bất đồng, tin bản dựng, đó mới là thứ được deploy. Xem lại bằng vite preview trên chính dist/ trước khi kết luận có lỗi.
File cấu hình chết đọc như đang có hiệu lực (08.09.2026)
play.conan.school/frontend/tailwind.config.js là cấu hình kiểu Tailwind v3 với khối content và theme.extend. Dự án chạy v4, và v4 chỉ đọc file đó khi CSS có chỉ thị @config, không có. File hoàn toàn vô tác dụng, nhưng ai mở ra cũng tưởng đó là nơi khai báo cấu hình, rồi sửa vào đó và không hiểu vì sao không ăn.
Cùng lượt còn hai thứ chết tương tự: src/App.css (184 dòng, không file nào import) và ba alias --color-conan-red/sand/espresso không nơi nào dùng. Khi nâng Tailwind v3 → v4, xoá tailwind.config.js thay vì để lại; nếu còn cần thì phải khai @config cho rõ.
Tài khoản mới không có hồ sơ nghiệp vụ, và đường tiền chết lặng vì nó
Phát hiện 08.09.2026, đã sửa. D1 có hai bảng người dùng và chúng KHÔNG thay thế nhau:
| Bảng | Là gì | Ai ghi vào |
|---|---|---|
users | danh tính đăng nhập, id, email, tên, ảnh, trạng thái | verify-code, Google OAuth |
legacy_users | hồ sơ nghiệp vụ, membership_tier, payment_code, mobile, location | createD1AdminClient (gắn tiền tố legacy_) |
Cột về tiền chỉ tồn tại ở legacy_users. users không có membership_tier. | ||
Cho tới hôm nay, đường đăng ký chỉ tạo dòng ở users và không tạo dòng tương ứng ở | ||
legacy_users. Người dùng đăng nhập bình thường, xem được mọi thứ, nhưng họ không có hồ sơ | ||
| nghiệp vụ, và 34 tài khoản đang hoạt động ở tình trạng đó. | ||
| Hai chỗ vỡ, cả hai đều lặng: |
processMembershipClaimtra người dùng bằngdb.from('users')…eq('id', claim.user_id), tức tìm mộtusers.idtronglegacy_users, nên nó némMEMBERSHIP_USER_NOT_FOUND. Workflow kích hoạt chết, người đã chuyển tiền không được mở quyền, lỗi chỉ nằm trong log workflow.activateToMasterchạyUPDATE legacy_users SET membership_tier=…theo id. Không có dòng thì câu lệnh cập nhật 0 dòng và không báo lỗi gì. Kích hoạt trông như thành công. Cái thứ hai nguy hiểm hơn: mộtUPDATEkhông khớp dòng nào là thành công về mặt SQL. Không có ngoại lệ, không có log, không có gì đỏ. Đây đúng là loại hỏng mà hệ vẫn trông như đang chạy. Sửa:ensureLegacyUserProfile()trongshared/userProfile.ts, gọi ngay sau mỗi lần tạo dòng ởusers(cả hai đường đăng ký); migration20260908cbù 34 dòng còn thiếu. Bài học rộng hơn: khi một thực thể sống ở hai bảng, chỗ nguy hiểm không phải câuSELECTmà là lúc TẠO. MộtSELECThụt còn trảnullđể mã kiểm được; mộtUPDATEhụt thì im lặng hoàn toàn. Sửa bằng cách cho câu truy vấn đọc cả hai bảng là chữa triệu chứng, thứ thiếu là DÒNG.
Deploy xanh, deployment đúng, nhưng domain trả bản cũ (08.09.2026)
Sau khi merge lượt dọn màu play, deploy-play xanh cả hai job. Nhưng https://play.conan.school vẫn trả bản dựng cũ.
Không phải cache (cf-cache-status: BYPASS, thêm query cũng ra CSS y hệt). Không phải deploy hỏng. Ba URL cùng lúc:
| URL | --color-primary | có .bg-surface-inverse |
|---|---|---|
824c2343.kahoot-frontend.pages.dev (deployment vừa tạo) | #d94f2b ✅ | có ✅ |
main.kahoot-frontend.pages.dev (alias production) | #d94f2b ✅ | có ✅ |
play.conan.school | #ea580c ❌ | không ❌ |
Deployment đúng; domain mới là chỗ sai, nó không trỏ vào deployment production của project kahoot-frontend. Không worker nào trong repo khai route play.conan.school (grep -rn "play.conan.school" --include=wrangler.toml rỗng), nên chỗ gắn nằm ở tầng Cloudflare Pages, ngoài tầm của mã nguồn.
Dấu vết sớm nhất, đọc được ngay trong log deploy:
✨ Success! Uploaded 3 files (3 already uploaded)"3 already uploaded" cho một thay đổi vừa viết lại 8 file nguồn là mâu thuẫn. Nó có nghĩa nội dung dựng ra trùng hệt một deployment cũ, đúng ra phải có file mới vì hash asset đã đổi. (Ở đây con số đó là của lượt so trùng phía Cloudflare; điều đáng chú ý là không ai đọc nó.)
Cùng họ với rủi ro đã ghi tháng trước về www: một domain gắn vào project khác, và deploy-www vẫn xanh hằng tuần. Chú thích trong deploy-play.yml còn ghi # project đang giữ domain play.conan.school, một khẳng định đã sai mà không ai kiểm lại.
Luật rút ra: một lượt deploy xanh KHÔNG phải bằng chứng người dùng thấy bản mới. Bằng chứng duy nhất là mở chính domain đó và so nội dung với deployment vừa tạo:
curl -s https://<domain>/ | grep -oE '/assets/[^"]+\.css' | head -1 # domain thật
curl -s https://main.<project>.pages.dev/ | grep -oE '/assets/[^"]+\.css' | head -1
# hai hash khác nhau = domain KHÔNG trỏ vào deployment productionSửa chỗ gắn domain là việc trên dashboard Cloudflare, không sửa được bằng mã.
Chẩn đoán lại: domain đúng, nhánh deploy sai (03.10.2026)
Kết luận "sửa ở dashboard, không sửa được bằng mã" ở trên là sai. So ba URL lần nữa:
| URL | asset |
|---|---|
kahoot-frontend.pages.dev (alias của bản production) | index-Bklg7Tyc.css |
play.conan.school | index-Bklg7Tyc.css |
main.kahoot-frontend.pages.dev (bản deploy mới nhất) | index-C_CXcpHK.js |
Domain trỏ đúng vào production. Cái sai là _deploy-cloudflare.yml ghi cứng --branch=main, mà production branch của kahoot-frontend không phải main: mọi lượt deploy từ 09.2026 chỉ thành bản preview, và main.<project>.pages.dev là alias của nhánh main, không phải của production. Bảng tháng 9 gọi nó là "alias production", và đó là chỗ suy luận trượt.
Sửa: bước deploy Pages đọc production_branch của project qua API Cloudflare rồi deploy vào đúng nhánh đó (không đọc được thì cảnh báo và dùng main). Sáu project Pages còn lại đều có production branch là main, nên không đổi gì với chúng.
Luật rút ra: so domain với <project>.pages.dev (alias production), không với main.<project>.pages.dev.
Lần thứ hai, ở mentors (09.09.2026)
Cùng hình dạng, phát hiện đúng một ngày sau:
| URL | --accent | hex tự chế trong CSS |
|---|---|---|
site-creator-vinext-starter.dac2205.workers.dev (Worker vừa deploy) | #d94f2b ✅ | 0 ✅ |
mentors.conan.school | #cc4e2d ❌ | 14 ❌ |
Nặng hơn play ở hai điểm:
deploy-mentorschưa từng thành công lần nào trước 09.09.2026 (xem #gitignore-nuot-nguon), nên nội dung sau domain đó không thể đến từ CI. Nó là tàn dư của một lượt deploy tay từ lúc nào đó.- CSS ở domain có
--accent: #cc4e2d, trong khiglobals.csstrong git, kể cả trước lượt dọn màu, là#d94f2b. Bản đang phục vụ cũ hơn cả mã hiện tại trong repo, không rõ bao lâu.
Repo không khai route hay domain nào cho mentors.conan.school (grep trong mọi wrangler.toml rỗng; vinext tự sinh cấu hình), nên chỗ gắn nằm hoàn toàn ở dashboard Cloudflare.
Hai site nhỏ, cùng một lỗi, không ai biết. Điểm chung: cả hai đều không có job nào trên ci-checks.yml, nên không ai từng nhìn kỹ chúng. Sau mỗi lượt deploy của một site ít được để ý, chạy phép đối chiếu hai URL ở trên.
Ghi danh 'active' vào một khoá 'archived' là trạng thái BÌNH THƯỜNG
Bắt được 09.09.2026, sáu tiếng trước khi nó gửi thư thật. Truy vấn chọn thư "nhắc bài đang học dở" lọc course_enrollments.status = 'active' và dừng ở đó. Nhưng hai cột status này nói về hai thứ khác nhau:
| Cột | Nói về |
|---|---|
course_enrollments.status | ghi danh của MỘT NGƯỜI còn hiệu lực không |
courses.status | KHOÁ còn mở không |
| Khi một khoá đóng lại, ghi danh của người học không tự đổi trạng thái. Nên "ghi danh active | |
| vào khoá archived" là trạng thái hợp lệ và phổ biến, không phải dữ liệu bẩn. | |
Hệ quả: 16 người sắp nhận thư mời "học tiếp" khoá ai-teen, đã archived, im lặng 47 ngày. | |
Bấm vào là không còn gì ở đó. Sau khi thêm c.status = 'active', số thư nhắc học đúng đắn còn | |
| 4. | |
| Đây là loại sai không lộ ra ở đâu cả: truy vấn chạy đúng cú pháp, trả về dữ liệu thật, thư gửi | |
thành công, email_deliveries ghi sent. Chỉ có người nhận là bấm vào một thứ không tồn tại. | |
Bài học: khi hai bảng cùng có cột tên status, đừng cho rằng lọc một cái là đủ. Hỏi từng | |
| cột đang nói về vòng đời của thực thể nào. Diễn tập trước một vòng gửi tự động, chạy đúng truy | |
| vấn của nó lên dữ liệu thật và ĐỌC kết quả, là cách duy nhất bắt được loại này trước người dùng. |
Cùng một lớp CSS, hai vai trò, hai token (09.09.2026)
Lượt dọn màu mam tìm bốn chỗ bg-slate-900, và không cái nào quy đổi giống cái nào:
| Chỗ | Vai trò | Token đúng |
|---|---|---|
DebugFooter | panel nền tối trọn vẹn | bg-surface-inverse |
BulkActionBar | thanh công cụ nổi nền tối | bg-surface-inverse |
AssetPicker, UploadModal (/60) | màn che hộp thoại | bg-surface-inverse/60 |
UploadModal:153 | nút tối trên hộp thoại SÁNG | bg-foreground + text-background |
Chỗ cuối là bẫy: nó cũng là bg-slate-900, nhưng nó không phải vùng nền tối, nó là một nút tương phản. Dùng surface-inverse ở đó là sai ngữ nghĩa (bề mặt nghịch đảo ≠ chữ đậm), và sẽ kéo theo text-surface-inverse-foreground cho một nút nằm trên nền trắng.
Cùng chuyện với bg-white/10: trên trang sáng gần như vô hình, trên nền tối là một mảng nổi rõ.
Không có cách nào tự động phân biệt, phải đọc chỗ dùng. Trước khi cho một bộ quy đổi hàng loạt chạy, tách các vùng nền tối ra xử lý tay:
grep -rnoE "(bg|from|to)-(slate|zinc|gray|neutral|stone)-(800|900|950)" app --include=*.tsx@apply không sinh utility rời: phép kiểm "lớp có sinh CSS không" phải biết (09.09.2026)
Phép kiểm đối chiếu lớp token trong mã với CSS đã dựng báo bg-card/70, border-card/20, selection:bg-primary/20 là không sinh CSS. Cả ba đều đúng và đang chạy, chúng nằm trong @apply của app.css, nên Tailwind gộp giá trị thẳng vào rule .glass / html,body / ::selection thay vì sinh class rời.
Khi phép kiểm báo thiếu, đọc chính rule đã biên dịch trước khi kết luận token hỏng:
grep -oE "\.glass\{[^}]*\}|html,body\{[^}]*\}" build/**/*.cssCùng họ với bài học "khi dev và bản dựng bất đồng, tin bản dựng", phép kiểm cũng sai được, và một phép kiểm sai làm mất thời gian đúng bằng một lỗi thật.
Hai phiên cùng thư mục: git add -A của người này quét file tạm của người kia (09.09.2026)
Đã xảy ra hai chiều trong cùng một ngày:
git add -Acủa tôi ở checkout chính quét 16 file đang dở của phiên khác (việc SEO www) vào commit dọn màu admin. Phảireset, tách 70 file của mình sang nhánh riêng, trả HEAD về nhánh họ.- Chiều ngược lại: một mục
mam-wttạm tôi thêm vào.claude/launch.json(để mở dev server trỏ vào worktree) bị commit8e5105acủa phiên khác quét vào, và commit đó đã đẩy lên remote.
Repo này thường có nhiều phiên chạy song song trong cùng một thư mục làm việc. Hai luật:
- Làm trong worktree riêng (
git worktree add) cho mọi thay đổi mã. Đã áp dụng từ lượtcom. .claude/launch.jsonlà ngoại lệ không tránh được: công cụ preview đọc file ở checkout chính, nên mục tạm phải thêm vào đó. Hoàn nguyên ngay sau khi xem xong, và biết rằng cửa sổ giữa lúc thêm và lúc hoàn nguyên là lúc phiên khác có thể commit nó.
Kiểm link bằng mã trạng thái HTTP là kiểm rỗng khi đích là một SPA
Bắt được 09.09.2026. Rà tám đường dẫn trong các nút của email bằng curl -o /dev/null -w '%{http_code}'. Cả tám trả 200. Kết luận "mọi link đều sống" là SAI. tokyo.conan.school là ứng dụng một trang. Máy chủ trả index.html cho mọi đường dẫn, kể cả đường dẫn không có route nào. Mã 200 chỉ chứng minh máy chủ còn sống, không chứng minh trang tồn tại. Router mới là nơi quyết định, và nó chạy ở trình duyệt sau khi đã trả 200. Đối chiếu với com.conan.school/app/main.tsx (69 route) thì lộ ra: /learn đứng một mình không có route. Chỉ có learn/lesson/:lessonId và learn/unit/:unitId. Nút "Học tiếp" trong thư nhắc học trỏ vào /learn, và app không có route bắt-tất-cả, nên người bấm vào thấy trang trống. Đã sửa: nút dẫn thẳng tới /courses/<slug> của đúng khoá họ đang dở, vừa có route thật, vừa đúng chỗ hơn một trang danh sách chung. Cách kiểm đúng cho đích là SPA: đối chiếu đường dẫn với danh sách route trong router (grep -oE 'path: "[^"]*"' app/main.tsx), đừng tin mã trạng thái. Cách này cũng áp dụng cho www.conan.school và bất kỳ site nào khác trong repo. Hai lỗi bắt được cùng ngày, nút trỏ vào route không tồn tại, và thư nhắc học mời quay lại khoá đã đóng, đều thuộc một họ: thứ ở đầu kia của một đường dẫn không được kiểm. Trong email thì họ lỗi này đặc biệt đắt, vì thư đã gửi đi rồi thì không rút lại được.
Bộ lọc đọc-rồi-ghi KHÔNG phải cơ chế chống trùng
Sự cố thật 09.09.2026, 113 người nhận cùng một lá thư hai lần. Vòng gửi hằng ngày chống trùng bằng một điều kiện trong truy vấn chọn người:
AND NOT EXISTS (SELECT 1 FROM email_deliveries d
WHERE d.user_id = u.id AND date(d.created_at,'+7 hours') = date('now','+7 hours'))Điều kiện này đúng, và nó chống trùng giữa các NGÀY. Nhưng nó được đánh giá một lần, ở đầu vòng. Khi cron 0 1 * * * chạy hai lượt chồng nhau, cả hai lượt đọc danh sách trước khi lượt nào kịp ghi dòng đầu tiên, nên cả hai đều thấy cùng một danh sách "chưa ai nhận gì". Con số khớp chính xác và đáng đọc: lượt một lấy 113 người; lượt hai bắt đầu chậm hơn vài giây nên 22 dòng đầu đã kịp ghi, nó lấy 91. Tổng 204 thư cho 113 người, gửi liền mạch từ 01:00 tới 01:04 UTC. Nhìn vào sổ thì thấy một dải thời gian trơn tru, không có gì trông như hai lượt chạy. Sửa: một bảng daily_email_runs với run_date làm KHÓA CHÍNH. Mỗi lượt phải INSERT để giành quyền chạy; đúng một lượt thành công, lượt còn lại nhận lỗi ràng buộc rồi thoát. Để cơ sở dữ liệu cưỡng chế tính duy nhất, đừng để mã tự kiểm. Vì sao không dùng KV làm cờ: KV nhất quán cuối, đọc-sau-ghi trong vài giây không đảm bảo, đúng khoảng thời gian mà hai lượt cron chồng nhau. D1 nhất quán mạnh nên phép này đáng tin. Luật rút ra cho mọi vòng chạy tự động có tác dụng phụ ra bên ngoài: nếu hai lượt chạy cùng lúc là chuyện có thể xảy ra, và với cron thì luôn có thể, thì tính duy nhất phải nằm ở một ràng buộc của kho dữ liệu, không nằm ở một câu SELECT đọc trước khi ghi. Kiểm bằng cách tự hỏi: nếu hàm này chạy hai lần đồng thời thì sao?
110 "blocker" của lượt audit, và không cái nào là lỗi nội dung (09.09.2026)
Sau khi hàng đợi audit rút cạn, lượt chấm gần nhất của 190 khoá báo 110 blocker ở 61 khoá, hai luật no-leaked-answer và no-circular. Đọc tay từng câu trong số 83 câu bị gắn cờ: không câu nào là lỗi thật. Ba dạng câu hỏi hợp lệ của tiếng Việt bị bắt nhầm:
| Dạng | Số câu | Vì sao bắt nhầm |
|---|---|---|
| Câu có/không | 43 | Câu hỏi tiếng Việt kết thúc bằng chính chữ "không?", và đáp án đúng là "Không". Chữ ấy là ngữ pháp, không phải đáp án rò ra |
| Chọn một trong hai | 24 | "Giữa A và B thì cái nào?" buộc phải nêu cả hai phương án, đó là cả ý nghĩa của câu hỏi |
| Câu hỏi đếm | 10 | "Người tới mười lần xuất hiện trong trí nhớ mấy lần?" → "Mười lần". Con số phải có trong đề thì mới hỏi được |
| Sáu câu còn lại là loại mà chính bài học yêu cầu lặp chữ: "Khách nói vỡ trận. Trong tóm tắt bạn nên nói gì?" → "Vỡ trận", vì bài dạy dùng đúng chữ của khách. |
Một tín hiệu nghiêm trọng mà sai toàn tập thì tệ hơn không có
Blocker là mức cao nhất trong thang: nó nói "cái này chắc chắn sai, đừng cho ra". 110 blocker mà 0 cái đúng dạy người đọc bỏ qua mục blocker, và lần sau có một blocker thật thì nó nằm lẫn trong đống nhiễu. Sửa bộ đo, không sửa 83 câu nội dung đang đúng.
Ba ngoại lệ đã thêm vào leaksAnswer và isCircular (phải thêm cho cả hai, vì chúng gắn cờ gần như cùng một tập câu, miễn ở một chỗ thì con số chỉ chuyển cột): đáp án là hạt nghi vấn; đề nêu từ hai phương án trở lên; đáp án là số đếm thuần. Kết quả trên 34.200 câu: 110 → 5, và cả 5 câu còn lại vẫn là bắt nhầm. Gác vẫn phải bắt được rò rỉ thật, nên bản sửa được thử ngược bằng sáu ca: hai ca rò rỉ dựng riêng đều bị bắt, bốn ca hợp lệ đều được bỏ qua.
Siết prompt làm model lý luận chạy LÂU hơn, không nhanh hơn (09.09.2026)
Lượt chạy trọn quyển food-book-03 (20 section, deepseek-v4-pro) hỏng 3/20 vì 3046: Request timeout và trả lại 3 bản vì vượt trần 900 chữ. Cách sửa trông hiển nhiên: siết prompt chặt hơn, nói rõ trần cứng, nói rõ cấu trúc 4-5 tiểu mục, nói rõ số chữ mỗi đoạn. Kết quả lượt sau: không còn bản nào vượt trần, đúng mục tiêu, nhưng số section hỏng vì timeout tăng từ 3 lên 8/20, và tổng số nhận được giảm từ 13 xuống 11. Ràng buộc chặt hơn làm model lý luận nghĩ lâu hơn để thoả mãn tất cả, và thời gian nghĩ là đúng thứ đang chạm trần nền tảng. Bài học: với model lý luận, prompt không phải cái van duy nhất, và siết nó có thể đẩy ngược. Khi timeout là lý do hỏng nhiều hơn mọi lý do nội dung cộng lại, việc phải làm là thu nhỏ đơn vị gọi model, không phải mô tả kỹ hơn thứ cần sinh. Cách sửa thật: một section được sinh bằng hai lượt gọi, mỗi lượt hai tiểu mục (320–380 chữ), mỗi lượt một bước workflow riêng có trần và lượt thử lại riêng; nửa sau nhận nửa trước làm ngữ cảnh để không lặp ý. Và: đừng đọc "zero lỗi loại A" là thành công khi tổng số nhận được đi xuống. Đếm cả hai đầu.
Luật ignore theo TÊN thư mục nuốt một file nguồn: app chưa bao giờ deploy được (09.09.2026)
deploy-mentors chỉ từng chạy hai lượt (11.08 và 03.09.2026) và cả hai đều failure, luôn cùng một lỗi:
[UNRESOLVED_IMPORT] Could not resolve './build/sites-vite-plugin' in vite.config.tsmentors.conan.school/build/sites-vite-plugin.ts là plugin Vite viết tay, tức là mã nguồn. Nhưng .gitignore của monorepo có luật chung build/ cho đầu ra biên dịch, và luật đó khớp theo tên thư mục ở mọi độ sâu, nên nó nuốt luôn thư mục nguồn trùng tên. File chưa bao giờ vào git; máy người viết có nó nên mọi thứ chạy, CI checkout sạch thì chết ngay bước nạp config.
Nguy hiểm vì im lặng hai lớp: git status không báo gì (file bị ignore), và deploy hỏng thì không ai đo, mentors không có job nào trên ci-checks.yml, nên tín hiệu duy nhất là gh run list --workflow=deploy-mentors.yml.
Luật: khi thêm một app vào monorepo, kiểm trên một bản clone sạch, đừng tin thư mục làm việc của mình:
git clone --depth 1 <repo> /tmp/probe && cd /tmp/probe/<app> && npm ci && npm run buildVà khi đặt tên thư mục nguồn, tránh build/, dist/, out/, dù nội dung là mã viết tay.
JSX: chuỗi template viết trong thuộc tính nháy kép (09.09.2026)
<article className="recommendation-card ${open ? "expanded" : ""}"> // SAI
<article className={`recommendation-card ${open ? "expanded" : ""}`}> // đúngNăm chỗ trong mentors.conan.school/app/MentorIntelligenceApp.tsx, chia hai mức nguy hiểm:
- Có nháy kép bên trong → nháy đóng sớm → lỗi cú pháp, build chết. Ồn ào, dễ sửa.
- Không có nháy kép bên trong (
className="disclosure ${className}") → parse được, và in nguyên văn${className}vào thuộc tính class. Không lỗi, không cảnh báo. Hệ quả:.nav-group.active,.sidebar.mobile-open,.recommendation-grid.singlechưa bao giờ được áp dụng, trạng thái "đang chọn" không bao giờ sáng lên, và không ai báo vì trang vẫn hiện.
Cách bắt, rẻ và chắc:
grep -rn 'className="[^"]*\${' app --include=*.tsx # phải rỗngVà kiểm trên trang đang chạy: document.querySelectorAll('[class*="${"]').length phải bằng 0.
Lời hứa trong tài liệu mà mã không thi hành
09.09.2026. Mục profile_incomplete trong EMAIL_CATALOG mô tả trigger là "nhắc tối đa ba lần, cách nhau vài ngày". Mã chỉ kiểm nửa đầu:
if ((await countSent(env, member.id, 'profile_incomplete')) < 3) { … }Không có kiểm khoảng cách. Ba lần hoàn toàn có thể rơi vào ba ngày liên tiếp, đúng thứ câu mô tả nói là không. Nó suýt xảy ra thật. Sau sự cố cron chạy hai lượt, 90 người nhận hai lá thư trong cùng một ngày, và hôm sau 104 người đủ điều kiện nhận lá thứ ba. Ba lá thư giống nhau trong hai ngày. Điều đáng nói không phải con số mà là chỗ lỗi trốn: mô tả trigger trong catalog được đọc như tài liệu, nó hiện nguyên văn trên trang Email Catalog của admin, và trang docs viết từ nó. Người đọc tin rằng hệ có khoảng cách giữa các lần nhắc, vì tài liệu nói vậy. Không có gì đối chiếu câu đó với mã. Sửa: hằng MIN_DAYS_BETWEEN_NUDGES = 5, kiểm cả số lần lẫn số ngày kể từ lần gần nhất. Sau khi sửa, ngày mai còn 0 người đủ điều kiện thay vì 104; nhóm 98 người quay lại ngày 13.09, nhóm 105 người ngày 14.09. Luật rút ra: trường trigger và purpose trong catalog là TÀI LIỆU, và tài liệu sai nguy hiểm hơn tài liệu thiếu. Khi viết một mô tả có chứa điều kiện, "tối đa ba lần", "cách nhau vài ngày", "chỉ lượt vào mới", hãy mở mã ra kiểm từng vế một. Nếu mã chưa thi hành vế nào thì hoặc viết mã, hoặc bỏ vế đó khỏi mô tả.
Hai bài trùng tên làm migration chèn nội dung vào nhầm chỗ, im lặng
Loại: hỏng mà hệ vẫn trông như đang chạy. Ghi 09.09.2026, khi soạn tài liệu đọc cho khoá Problem Framing. Unit "Đánh giá Kết quả" của khoá problem-framing có hai bài cùng tên Đánh giá, cùng một định nghĩa. Hai migration đầu của tài liệu đọc tra bài bằng WHERE cc.name = '…'. Với tên trùng, một câu INSERT ... SELECT khớp cả hai hàng và cố chèn hai lần cùng một id; INSERT OR IGNORE nuốt lỗi khoá chính, chèn được một hàng và bỏ im lặng hàng kia. Đã dựng lại trên SQLite để xem tận mắt: hai tài liệu khác nhau đều rơi vào cùng một bài, bài thứ hai không có gì. Không lỗi, không cảnh báo, và migration vẫn xanh.
Tra theo toạ độ, đừng tra theo tên hiển thị
(unit.sort_order, concept.unit_sort_order) là duy nhất trong một khoá và ổn định giữa các môi trường. Tên bài thì do người soạn đặt, có thể trùng, và có thể đổi bất cứ lúc nào. Luật rộng hơn: INSERT OR IGNORE chỉ an toàn khi mệnh đề WHERE chắc chắn khớp đúng một hàng. Không chắc điều đó thì "OR IGNORE" biến một lỗi thành một khoảng trống.
Cùng lượt rà soát còn thấy ba bài rỗng hoàn toàn trong khoá này (0 bài viết, 0 ví dụ, 0 quiz, 0 SCQA): 50.2 Kế hoạch Triển khai, 60.1 Kế hoạch Dự án, 70.0 Đánh giá, đúng ba bài thiếu guiding_question_vi. Người học mở được cả ba và thấy một trang không có gì. Không màn hình nào đếm số bài rỗng, nên chúng đã nằm đó từ lúc dựng khoá.
Hai ngân hàng câu hỏi, và chỉ một cái do CoursePack lấp (09.09.2026)
Rà 2.170 cảnh báo của lượt audit thì lộ ra một chuyện về cấu trúc, không phải về chữ nghĩa. course_lesson_quiz_bank có hai bank:
| bank | Ai lấp | Dùng ở đâu |
|---|---|---|
summative | CoursePack, 20 câu mỗi bài | Quiz chốt cuối bài |
formative | Máy sinh | Ba câu tự kiểm rút ngẫu nhiên cuối phần SCQA |
Trong 190 khoá pack: 34.200 câu summative (đủ 20/bài) nhưng chỉ 1.068 câu formative ở 96 bài, tức 1.614 trên 1.710 bài không có câu tự kiểm nào, và khối ba câu ấy đơn giản là không hiện ra. Không có gì báo, vì không có gì hỏng: pack không hứa lấp formative, và máy sinh chỉ chạy ở những khoá nó từng chạm tới. |
Đếm nhầm bank là kết luận ngược
Lúc mới thấy "1.068 câu máy sinh lẫn trong khoá pack", phản xạ đầu là dọn chúng đi cho sạch. Sai: chúng nằm ở bank KHÁC, không tranh chỗ với câu của pack, và bỏ đi là lấy mất phần tự kiểm của 96 bài duy nhất còn có nó. Trước khi kết luận "dữ liệu thừa", kiểm xem nó có nằm cùng một chỗ với dữ liệu mình đang so hay không.
Bốn câu trong số đó có ký tự Trung Quốc lẫn vào (是否, 客, 方, 一), vết của model lúc sinh. Đã cho nghỉ bằng status='retired'; bốn bài liên quan còn 9–11 câu, trên ngưỡng 3 câu mỗi lượt rút.
Backfill một cột khoá là khoá người ta bằng dữ liệu quá khứ (09.09.2026)
20260918a thêm community_memberships.home_set_at và backfill bằng updated_at, để phục vụ luật "mỗi ngày đổi cộng đồng một lần". Backfill chạy đúng, nhưng sáu người vừa đặt nhà sáng hôm đó (00:37–01:31 giờ VN) nhận một mốc rơi vào chính ngày đang xét, nên họ hết lượt ngay khi luật lên, trong khi họ chọn lúc luật chưa tồn tại và chưa ai báo họ điều gì. Không có gì đỏ ở đâu cả: migration xanh, deploy xanh, mã đúng. Chỉ là sáu người mở màn chọn ra và thấy mọi thẻ bị làm mờ, với một lời giải thích về một luật họ chưa từng nghe. Bài học: khi một luật giới hạn theo thời gian đọc một cột backfill từ dữ liệu cũ, hãy đếm xem bao nhiêu dòng rơi vào cửa sổ đang bị chặn trước khi merge. Nếu có, hoặc backfill lùi một chu kỳ ngay trong migration đầu, hoặc chấp nhận và nói trước với người vận hành. Luật mới hứa "được báo trước" thì nhóm đầu tiên chính là nhóm không được báo. 20260918c sửa bằng cách lùi mốc của đúng nhóm đó một ngày. Điều kiện dùng hai mốc thời gian cố định, không dùng now: CI áp migration lúc nào là chuyện của CI, và một điều kiện kiểu "rơi vào hôm nay" sẽ trúng tập khác, hoặc không trúng ai, nếu file chạy sang ngày hôm sau.
Bản chép tay của token: đúng hôm nay, lệch ngày mai (09.09.2026)
video.conan.school là site duy nhất không có Tailwind. Điều đó không chỉ là nợ kỹ thuật, nó khiến @import "packages/design-system/theme.css" vô tác dụng: token ở đó nằm trong khối @theme, và CSS thuần bỏ qua at-rule lạ. video buộc phải tự khai lớp token, chép giá trị bằng tay.
Mã cũ trung thực về chuyện đó (/* tokens are synced by value */). Nhưng một bản chép tay không có người kiểm là một bản chép sẽ lệch, và lệch im lặng: trang vẫn hiển thị, màu vẫn trông hợp lý, chỉ là không còn là màu của hệ thiết kế. Gác màu không bắt được, khai một token là việc hợp lệ, không phải vi phạm.
scripts/check-token-sync.mjs so từng cặp (--primary ↔ --color-primary…). Thêm token vào bản chép thì thêm dòng vào manifest của script, nếu không nó nằm ngoài tầm kiểm.
Bài học rộng hơn: mỗi khi chấp nhận một bản sao vì lý do kỹ thuật, hãy viết cùng lúc phép kiểm giữ hai bản khớp nhau. Không có phép kiểm thì "đồng bộ" chỉ là ý định.
Gác đếm nhầm chính file token của app (09.09.2026)
Regex bắt hex-trong-khai-báo-CSS của check-color-tokens.mjs khớp nhầm tên custom property:
--bg-color: #ffffff; /* chứa chuỗi con `color: #ffffff` */
--border-color: #f2f2f2; /* chứa chuỗi con `border-color: #f2f2f2` */Khai một token không phải là dùng màu tự chế, đó chính là việc của file token. Lỗi này ẩn suốt vì theme.css được miễn trừ; video là app đầu tiên khác phải tự giữ lớp token của mình.
Sửa: (?<![-\w]) trước tên thuộc tính, và xếp các nhánh dài trước (background-color trước color) để khớp trọn vẹn thay vì khớp chuỗi con.
Cửa thứ năm của một mã màu: rgba() (09.09.2026)
Lộ ra khi đọc CSS ĐÃ DỰNG của video: minifier đổi rgba() thành hex và bốn màu hiện ra, có cả #ff5a00, một sắc cam không phải màu thương hiệu, dùng cho hiệu ứng nền.
Một mã màu có năm cửa vào, và gác phải bắt đủ: bậc số bảng màu · giá trị tuỳ ý trong className · hex trong khai báo CSS · hex trong style của JSX · và rgb/hsl.
Chỉ bắt màu có sắc (R, G, B không bằng nhau): rgba(0,0,0,.08) của box-shadow là quy ước CSS cho bóng đổ, không phải một lựa chọn màu, bắt nó chỉ tạo nhiễu và làm gác mất uy tín.
Toàn repo có 21 chỗ, phần lớn là một mã scrollbar rgba(59,48,48,.2) chép qua bảy file. Đọc CSS đã dựng là cách rẻ để tìm những cửa mà gác nguồn còn bỏ sót.
Miễn trừ không có gác thay thế là một lỗ (09.09.2026)
Gác màu có danh sách exempt cho "file mà việc của nó LÀ định nghĩa màu": theme.css, bộ theme người dùng tự chọn, và (từ 09.09.2026) api.conan.school/src/shared/email/templates.ts.
Mục thứ ba đáng chú ý vì nó suýt trở thành một lỗ. EMAIL_PALETTE phải là hex, email client lược bỏ biến CSS, nên var(--color-primary) trong một lá thư là màu không tồn tại. Miễn trừ nó khỏi gác màu là đúng. Nhưng miễn trừ và dừng ở đó thì bảng màu email tự do trôi khỏi theme.css mà không ai biết.
Luật: mỗi dòng trong exempt phải kèm một gác thay thế, hoặc một lý do vì sao không cần.EMAIL_PALETTE được scripts/check-token-sync.mjs so từng dòng với theme.css (20 cặp, 2 bản chép tay). Miễn trừ có gác thay thế là một ranh giới; miễn trừ không có là một lỗ.
Cùng lượt còn một báo động giả thứ hai của gác màu, border: '#f2f2f2', là một khoá trong chính bảng màu, giống --bg-color: #ffffff ở video nhưng ở dạng đối tượng TS. Lần này không nới regex: file đó là nguồn màu, nên nó thuộc exempt, không thuộc "trường hợp regex cần thông minh hơn". Nới regex mãi sẽ đến lúc gác không còn bắt được gì.
Ba nơi chép một bảng màu vì hằng số không được export (09.09.2026)
api.conan.school có shared/email/templates.ts với hằng C gồm 12 màu, chú thích ánh xạ từng token, cấu trúc hoàn toàn đúng. Nhưng nó là const chứ không export const.
Hệ quả: shared/email/send.ts và domains/system/index.ts, hai file đã import từ chính templates.ts cho việc khác, tự viết lại #6b6b6b, #fff7ed, #111111… cho dòng huỷ nhận thư và trang xác nhận. Chín mã màu chép lại chỉ vì thiếu một chữ export.
Khi thấy cùng một mã màu ở nhiều file trong một app, hỏi trước: có phải nó đã tồn tại ở đâu đó mà chỉ thiếu export không? Thường rẻ hơn nhiều so với việc sửa từng chỗ.
Luật story-has-pivot đo từ vựng ngẫu nhiên, không đo cấu trúc câu chuyện (09.09.2026)
Luật đòi lời giải SCQA chứa một trong mười hai cụm cố định (nhận ra, hoá ra, thay vì, lần này, bắt đầu…). Đo trên 2.280 SCQA của 190 khoá:
- 1.533 bị gắn cờ, tức hai phần ba corpus.
- 747 câu "qua" chủ yếu nhờ "bắt đầu" (242) và "thay vì" (219), hai chữ có trong mọi văn giải thích. Chỉ 146 câu thật sự có "nhận ra".
Đọc mẫu các câu bị gắn cờ thì chúng là lời giải nêu thẳng insight, viết tốt, chỉ không dùng giọng kể. Nên câu hỏi không phải "sửa luật hay sửa nội dung", mà là một quyết định về đặc tả: lời giải SCQA nên kể khoảnh khắc, hay nêu insight?
Người soạn chọn nêu insight (09.09.2026), nên luật bị bỏ. Chuẩn lên v2.4.
Một luật có hai nhánh thì đừng bỏ cả hai
story-has-pivot gắn hai phép kiểm vào cùng một rule_id: nhánh danh sách cụm từ (sai), và nhánh bắt lời giải viết ngược thành đoạn dựng cảnh (đúng, và không dùng danh sách nào). Bỏ cả hai là mất một gác đang chạy tốt.
Nhánh thứ hai giữ lại dưới tên đúng nghĩa story-answer-not-scene, và được thử lại bằng một ca dựng riêng để chắc nó còn bắt.
Kèm theo: một luật chạy mà audit không bao giờ báo
Chạy scqaViolations() trên toàn corpus còn cho thấy story-brevity gắn cờ 1.847 SCQA, nhưng con số đó chưa bao giờ xuất hiện trong course_quality_reports, vì quality-audit.ts chỉ đọc bốn rule_id trong một danh sách viết cứng và story-brevity không nằm trong đó.
Luật vẫn chạy ở đường SINH (chặn lúc tạo), chỉ là vô hình ở đường CHẤM. Chưa xử lý, cần biết trần số câu hiện tại có còn đúng không trước khi bật nó lên trong báo cáo.
Màu MANG NGHĨA: hai thang khác nhau trộn chung một mớ hex (09.09.2026)
DebugFooter.tsx của admin-shared tô bảng request bằng màu để người đọc quét bằng mắt tìm request hỏng hoặc chậm. Nhưng nó trộn hai thang khác hẳn nhau vào cùng một mớ hex:
| Thang | Bản chất | Sai lầm khi gộp |
|---|---|---|
| HTTP method (GET/POST/PUT/PATCH/DELETE) | phân loại, năm mục ngang hàng | quy hai method về cùng một token là mất khả năng phân biệt |
| status & thời gian phản hồi | thứ bậc, nhanh→chậm, 2xx→5xx | quy về thang phân loại là mất thứ tự nặng-nhẹ |
Cùng một mã #ffcc00 vừa là "PUT" vừa là "chậm vừa", nên khi quy đổi máy móc, rất dễ chọn một token cho cả hai và âm thầm phá một trong hai ý nghĩa. Không có gì đỏ: mọi lớp vẫn là token hợp lệ, bảng vẫn hiện đủ chữ.
Cách làm: tách thành hai hằng có tên (METHOD_COLOR phân loại, LEVEL thứ bậc), rồi ánh xạ riêng. Hệ thiết kế đã có đủ năm token phân biệt (emerald, cyan, amber, rose, destructive) cho thang phân loại và --color-score-poor cho bậc thứ ba của thang thứ bậc.
Trước khi quy đổi một màu, hỏi nó đang mã hoá cái gì. Trang trí thì quy về primary/muted thoải mái; phân loại thì phải giữ đủ số token phân biệt; thứ bậc thì phải giữ đúng thứ tự.
Xem được cả thứ nằm sau cổng đăng nhập (09.09.2026)
admin.conan.school nằm sau Cloudflare Access nên chạy ở máy chỉ tới màn hình "không có quyền", DebugFooter không bao giờ mount, và ba lượt trước điều đó là lý do để bỏ qua phần kiểm bằng mắt.
Có cách rẻ hơn là bỏ qua: dựng lại đúng markup và đúng các biểu thức màu của component bằng javascript_tool ngay trên trang đang chạy, nơi theme.css đã nạp, rồi chụp. Nó kiểm đúng thứ có thể hỏng (color-mix phân giải ra màu gì, năm màu method có phân biệt nổi trên nền tối không) mà không cần qua cổng đăng nhập.
Không thay thế được việc xem thật, nhưng tốt hơn nhiều so với "không kiểm được nên thôi".
Một trần cho hai lối viết: story-brevity (09.09.2026)
Trần số câu của SCQA là answer: 4. Đo phân bố thật thì lộ ra nó được hiệu chỉnh cho MỘT trong hai nhóm nội dung, và đang được áp cho cả hai:
answer | trung vị | p90 | tối đa | vượt trần 4 |
|---|---|---|---|---|
| Máy sinh (156 SCQA) | 3 | 4 | 6 | 1% |
| Người soạn (2.280 SCQA) | 7 | 16 | 22 | 67% |
Trần 4 vừa khít với máy, nó đang làm đúng việc ghì model lại. Nhưng dùng chính con số ấy để chấm nội dung người viết thì hai phần ba corpus thành vi phạm, và điều đó nói về thước đo chứ không nói về nội dung.
Tách làm hai trần, cố ý: MAX_SENTENCES giữ nguyên cho đường SINH; AUDIT_MAX_SENTENCES (answer 18, question 2) cho đường CHẤM, đặt quanh phân vị 95 của chính corpus nên nó bắt phần đuôi 2,6% chứ không bắt cái bình thường. Kết quả: 1.847 → 60 ở lượt chấm, còn lượt sinh giữ nguyên 1.847.
Một con số vượt trần mà không ai thấy thì chưa phải một tín hiệu
Trước thay đổi này, story-brevity chạy trong scqaViolations() nhưng không bao giờ vào báo cáo: quality-audit.ts chỉ đọc bốn rule_id trong một danh sách viết cứng, và nó không nằm trong đó. Nên con số 1.847 không ảnh hưởng gì tới ai, nới trần mà không đồng thời đưa luật vào danh sách ấy thì cũng không sửa được gì.
Đây cũng là lý do luật này nằm ở RULES_IN_CODE_ONLY: chạy trong mã mà chưa khai trong chuẩn. Nay đã khai (chuẩn v2.5) và chuyển sang RULE_ENFORCEMENT.
Output của workflow không phải chỗ giữ nội dung (09.09.2026)
Một lượt soạn trọn quyển food-book-03 chạy 5 giờ, kết thúc ✅ Completed, soạn xong 15/20 section, và không lấy ra được chữ nào. Hai chỗ chặn cùng lúc:
- API Workflows cắt output mỗi bước ở khoảng 1.400 ký tự, phía máy chủ. Cờ
--truncate-output-limitcủa wrangler chỉ điều khiển phép cắt của chính wrangler (dấu[...output truncated]); phần bị cắt ở đây mang dấu khác ([truncated output]) và không cờ nào chạm tới. Bản wrangler mới hơn cũng vậy. - Đường đọc qua API chỉ nhận instance id mang tiền tố school. Lượt này khởi động bằng
wrangler workflows triggernên id là một UUID ngẫu nhiên, vàGET /section-draft-runs/:idtrả 404, đúng luật chống đọc chéo trường, nhưng nó đóng nốt cửa còn lại.
Một mình chỗ thứ hai chỉ là bất tiện; một mình chỗ thứ nhất cũng vậy. Cùng lúc thì năm giờ chạy máy thành số không, mà mọi bảng điều khiển đều báo xanh.
Luật rút ra: kết quả của một lượt chạy nền phải được GHI, từng phần một, ngay khi có. Bảng book_section_drafts làm đúng việc đó, mỗi section một dòng, ghi ngay sau khi soạn xong, đọc lại bằng GET /section-drafts/:ebookId/saved. Nó không phải nội dung đã xuất bản: luật "nội dung vào repo trước, D1 sau" không đổi, bảng này chỉ là chỗ đậu giữa engine và repo.
Và: một trạng thái Completed không nói gì về việc kết quả có lấy ra được không.
Gác ở máy và gác ở CI nhìn hai tập file khác nhau (09.09.2026)
npm run check:colors đỏ ở máy người soạn, check:colors trong CI xanh, cùng một commit, cùng một script.
Nguyên nhân: các gác đi bộ trên cây thư mục, còn CI đi bộ trên một bản clone. Máy có thêm admin.conan.school/legacy/admin-workshops/, 28 file chỉ nằm trên đĩa, bị .gitignore nuốt (dòng admin.conan.school/legacy/* ignore tất cả, rồi năm dòng ! mở lại đúng năm cụm được dùng). Một file index.css trong cụm đó có rgba(59, 48, 48, 0.2), và gác mới mở rộng để bắt rgba() (xem cửa thứ năm) đã bắt đúng nó.
Vì sao nguy hiểm hơn là "phiền": một gác đỏ vì thứ CI không bao giờ thấy là một gác người ta học cách bỏ qua. Vài lần như vậy là lần thứ tư người sửa cũng bỏ qua một vi phạm thật.
Đã sửa bằng scripts/lib/git-ignored.mjs: các gác đi bộ (check-color-tokens, check-admin-icons) bỏ qua file mà git ls-files --others --ignored liệt kê. Cố ý chỉ trừ file bị ignore, không trừ file chưa git add, một file mới viết mà chưa add vẫn phải bị soi, nếu không thì gác chỉ bắt được thứ đã kịp vào git.
Và tài liệu cũng sai theo: admin-portal skill lẫn CLAUDE.md đều ghi "sáu cụm legacy", trong khi vite.config.ts chỉ khai năm alias và git chỉ theo dõi năm thư mục. Các trang của cụm thứ sáu (WorkshopsPage, ZaloGroupsPage…) đã nằm ở @content và @marcom, nó là một bản sao chết. Đã sửa cả hai chỗ.
Phép thử cho lần sau: khi một gác đỏ ở máy mà CI xanh, hỏi trước hết "file này có trong git không?", git check-ignore -v <đường dẫn> trả lời trong một giây.
node_modules/ có dấu / không chặn được một symlink tên node_modules (09.09.2026)
www.conan.school/node_modules được commit lên main dưới dạng liên kết tượng trưng trỏ tới /Users/dac/ConanCode/conanplatform/www.conan.school/node_modules, một đường dẫn tuyệt đối chỉ tồn tại trên đúng một cái máy, và trỏ vào chính nó (vòng lặp).
Vì sao lọt: dòng .gitignore là node_modules/, có dấu / ở cuối, nên nó chỉ khớp THƯ MỤC. Một symlink cùng tên là một file, nên git add -A nhận nó như mọi file khác.
Vì sao không ai thấy: deploy-www vẫn xanh. npm ci xoá sạch node_modules trước khi cài, nên nó gỡ luôn cái liên kết hỏng rồi tạo thư mục thật, CI không bao giờ vấp. Ở máy thì ngược lại: liên kết vòng làm mọi lệnh đọc node_modules báo "Too many levels of symbolic links", và thư mục phụ thuộc thật đã bị thay mất.
Đã sửa: bỏ dấu / (node_modules khớp cả thư mục lẫn symlink) và gỡ blob khỏi git.
Bài học rộng hơn dấu gạch chéo: một đường dẫn tuyệt đối của máy lập trình viên lọt vào repo là thứ CI không bao giờ báo, vì CI xoá nó trước khi dùng. Chỉ có git ls-files mới thấy, git ls-files | grep node_modules là một phép kiểm một giây.
144 bìa sách đã vẽ xong nằm trong D1 mà không có đường ra (09.09.2026)
Kệ Deep Books hiện 144/181 ô xám ghi "No Cover", trong khi D1 có cover_svg cho 156/181 quyển, bìa đã được vẽ và ghi vào cơ sở dữ liệu từ trước đó.
Nguyên nhân: /api/public/misc/deepbooks và /api/content/ebooks chỉ chiếu cột cover_url, và cột đó rỗng ở đúng 144 quyển. Tokyo (/c/:slug) thì hiện bìa bình thường vì communities/:slug/books đã đổi cover_svg thành data URI từ lâu, nên không ai nghi ngờ gì: người soạn thấy bìa ở tokyo và tưởng www cũng vậy.
Đây là loại hỏng khó thấy nhất trong sổ này: không có lỗi, không có log, dữ liệu đầy đủ, chỉ là một cột không được chiếu. Phép thử duy nhất bắt được nó là đếm ở hai đầu: SELECT COUNT(*) WHERE cover_svg IS NOT NULL bên D1, và đếm số thẻ có ảnh trên trang.
Sửa: gom svgDataUri + withBookCover về shared/svgDataUri.ts và cho cả bốn đường đọc sách đi qua nó. Gom về shared/ chứ không chép: một hàm chép ba lần là ba bản sẽ lệch, đúng vết xe của EMAIL_PALETTE.
Kèm hai chuyện lộ ra khi sửa:
public/ebooks/:slugdùngselect('*'), nên nó đã trảcover_svgthô ra www suốt một thời gian, đúng thứ mà luật "SVG không rời khỏi API" sinh ra để chặn.select('*')là cách chắc chắn nhất để một cột mới lặng lẽ đi ra ngoài./booksnối?v=20260705vàocover_urlđể phá cache. Với mộtdata:URI thì chuỗi đó là rác nối vào chính nội dung ảnh. Chỉ gắn phiên bản cho ảnh có URL thật.
Thước đo sai cho ra một bản cáo trạng đúng giọng (12.09.2026)
Bộ chấm Big Idea (scripts/audit-big-ideas.mjs) kết luận 327 trên 570 unit "thiếu tương phản", và xếp đó là chỗ yếu nhất của toàn hệ nội dung. Con số ấy đứng trong một báo cáo có bảng, có phân bố điểm, có danh sách khoá cần sửa trước, tức là đủ mọi thứ để người đọc tin và bắt tay vào viết lại 327 câu văn.
Cả 327 câu đều không có lỗi gì. Luật nhận diện chỉ bắt hai khuôn chứ không và thay vì, trong khi tiếng Việt còn: so sánh ("rẻ nhất trên hoá đơn, đắt nhất trên bảng lương"), nhượng bộ ("kể cả khi nội dung khó hơn"), cặp điều kiện ("quá dày thì người ta ngừng mở, quá thưa thì thông tin tới lúc đã muộn"), và hai vế song song ("Lãi là một ý kiến kế toán; tiền mặt là một sự thật ngân hàng"), khuôn phổ biến nhất, và là khuôn duy nhất không có từ nối nào để bắt.
Sửa luật xong: tương phản 42% → 76%, nhân quả 43% → 58%, điểm chung 85,9% → 91,2%. Không một chữ nào của nội dung thay đổi. Số Big Idea thật sự phẳng là bốn.
Kèm một bẫy nhỏ làm chậm việc sửa: \b trong JavaScript không làm việc với chữ có dấu, ranh giới từ chỉ tính theo [A-Za-z0-9_], nên /\bthì\b/ không khớp "chúng thì mọi". Thêm luật thì vào rồi mà con số không nhúc nhích, dễ kết luận nhầm là "nội dung đúng là không có nhân quả thật".
Bài học: một thước đo hỏng nguy hiểm hơn một thước đo không có, vì nó sinh ra việc làm. Trước khi tin một tiêu chí chấm rớt hàng loạt, đọc tay mười mẫu bị rớt. Nếu quá nửa trong số đó nhìn vẫn ổn thì lỗi nằm ở thước, không nằm ở nội dung. Xem skill big-idea-audit và rà soát Big Idea 12.09.
Bìa vector trên www: đường dẫn cùng gốc của tokyo (26.09.2026)
Bìa vector sống ở com.conan.school/public/images/book-covers/ và cover_url lưu dạng /images/book-covers/<slug>.svg
- cùng gốc với tokyo. www đọc sách qua các route công khai và nhúng thẳng
cover_url, nên đường dẫn tương đối trỏ vào chính www. Nó vẫn "chạy" với 12 bìa kệ Drink chỉ vì www có một bản chép tay của đúng 12 tệp đó trongwww.conan.school/public/images/book-covers/, thêm bìa mới là ảnh hỏng, không lỗi nào. Sửa:withBookCover()đổi đường dẫn bắt đầu bằng/thành URL tuyệt đối tới tokyo. Đừng chép thêm tệp sang www.
Trang section của www không bao giờ render thân bài (27.09.2026)
books.$slug.sections.$sectionId.tsx của www đọc section.content_portable từ API nhưng chỉ render câu chuyện SCQA và quiz. Sách có câu chuyện thì trông đầy đủ; sách viết theo khuôn mức 3 (không có SCQA) thì trang section chỉ có tiêu đề và nút "tiếp", với 148 quyển vừa lên mức 3, đó là gần như toàn kệ. Không lỗi nào, vì dữ liệu có mặt trong loader. Đã thêm PortableTextRenderer cho thân bài. Bài học: sau khi đổi khuôn nội dung, mở một trang của khuôn mới trên từng site đọc nó, đừng chỉ mở trang của khuôn cũ.
Cửa so chữ số ép người dịch viết sai giọng (28.09.2026)
book_section_body_en_to_sql.py --check so từng chữ số giữa bản Việt và bản Anh. Nó đúng khi bắt số bị rơi, nhưng bản đầu coi "tháng 8" → "August" và "17 giờ" → "5 p.m." là mất số, nên người dịch viết "August (month 8)" và "17h" để qua cửa, cửa xanh, văn thì cứng. Đã dạy cửa hiểu tên tháng và giờ p.m. (đếm cả giờ 12 lẫn giờ 24, vì bản Việt có thể viết "5 giờ chiều" hay "17 giờ"), rồi dọn lại các quyển đã dịch. Một cửa làm người viết bẻ câu để qua là cửa đang đo sai thứ; khi nhiều người cùng phàn nàn một kiểu lách, sửa cửa trước khi sửa người.
Deploy api đỏ vì "fetch failed" giữa 874 migration (25.09.2026)
PR #280 mang 874 file migration. Bộ áp chạy từng file, mỗi file ~5 giây, nên bước migrate dài hơn một giờ; một lần fetch failed của wrangler giữa chừng làm job đỏ. Sổ schema_migrations ghi từng file đã áp, nên gh run rerun <id> --failed chạy tiếp đúng từ chỗ dừng, không áp lại gì. Trong lúc chờ, D1 ở trạng thái nửa chừng (ví dụ chỉ 15 quyển đã publish) và worker vẫn là bản cũ: đó là bình thường, không phải hỏng.
Actions startup_failure nuốt mất hai lượt deploy (26–27.09.2026)
Từ 26.09 11:23 mọi workflow của repo kết thúc startup_failure sau 0 giây, không job, không log, lỗi ở cấp tài khoản chứ không ở mã. Hai PR ảnh khoá học (#283, #284) merge vào main trong khoảng đó nên không deploy, migration không áp, trong khi trang PR vẫn "merged". Lượt chạy startup_failure không rerun được (cannot be retried), và các workflow deploy chỉ có trigger push theo đường dẫn, nên khi Actions chạy lại, không có gì tự deploy cả. Đã thêm workflow_dispatch cho deploy-api/com/www/docs: lần sau chạy tay bằng gh workflow run deploy-api.yml --ref main. Kiểm sau sự cố: gh run list --branch main --limit 5 phải thấy deploy xanh SAU commit cuối, không chỉ PR "merged".
Section bị khoá hiện "Nội dung đang được cập nhật" (27.09.2026)
Với người chưa có Pro của cộng đồng sở hữu sách, API (content/ebooks/:slug/chapters/:n) trả mọi section, trừ section đầu của chương 1, qua lockEbookSection: locked: true, thân bài rỗng. Trang đọc ở tokyo (SectionView) chỉ hỏi "có thân bài không", nên section bị khoá rơi vào nhánh "Nội dung đang được cập nhật, tác giả đang biên soạn": người đọc (và cả người soạn) tưởng sách chưa viết xong, trong khi D1 có đủ 3.115/3.115 section. Đã tách nhánh section.locked với lời mời Pro trỏ về /c/:slug/pro (API trả thêm community_slug). Bài học: trạng thái rỗng phải phân biệt chưa có với không được xem, hai thứ đó cần hai thông điệp khác nhau, và nhầm thì trang nói dối.
Cùng lượt: bỏ công tắc ngôn ngữ riêng ở cuối trang sách (EN/VN · EN · VN); trang sách theo công tắc EN/VI ở thanh trên.
Dọn dấu gạch dài: regex tự xoá chính mình (28.09.2026)
Luật mới cấm dấu gạch dài (U+2014) ở mọi nội dung. Lượt thay hàng loạt đầu tiên chạy phép dọn trên cả tệp thay vì chỉ quanh vị trí dấu, nên xoá nhiều hơn chèn khoảng 1.400 dòng và làm hỏng dấu phẩy cuối dòng trong mã không liên quan. Lượt thứ hai chỉ sửa đúng chỗ có dấu (chèn = xoá).
Bẫy còn lại, và nó im lặng: bảy regex dùng chính ký tự đó ([:\-–<gạch dài>], noEmDash, doc-sanitize) bị thay thành -, nên bộ lọc vẫn chạy mà không còn bắt gạch dài nữa. Mọi chỗ nhắc tới ký tự trong mã giờ viết \u2014 / char(8212). Gác: npm run check:emdash.
Viết song song: từng unit đúng, cả khoá vỡ mạch (28.09.2026)
Mười khoá Retail viết theo unit, ba agent một khoá, chạy song song. Mọi gác đều xanh (hình dạng, số câu, phân bố đáp án, không gạch dài), nhưng nhân vật và con số giữa các unit không khớp: unit sau dẫn "sau unit 1" tới một nhân vật unit 1 không có, và một mốc tiền ở unit 3 mâu thuẫn với chính dự tính ở unit 1. Hỏng loại này chỉ lộ ra khi đọc liền cả khoá. Sửa bằng một lượt soát khớp mỗi khoá (skill course-pack).
Bìa đã vẽ mà không vào D1 (28.09.2026)
12 sách Retail có bìa SVG trong repo và cover_url trong pack, nhưng book_pack_to_sql.py chỉ ghi cover_url cho quyển đã có (nhánh generate: false). Quyển sinh mới lên kệ is_published = 1 với bìa rỗng, mọi gác xanh; chỉ audit_books.py (tiêu chí B5) bắt được. Bộ biên dịch giờ ghi cover_url cho cả hai nhánh.
Hình vẽ đọc ra chữ ở cỡ thẻ (02.10.2026)
Luật bìa sách là "không chữ", và cửa kiểm book_art.py check chặn được mọi <text>. Nhưng chữ vẫn lọt vào bằng hình: hàng phong bì nắp tam giác đọc thành "WWWW", hai nét gạch chéo thành "X", vạch đếm thành "IIII", thước vuông thành "L". Không công cụ nào bắt được vì về mặt mã đó là những đường thẳng hợp lệ. 18 agent tự xem ảnh vẫn để lọt 5 bìa; chỉ lộ ra khi xem cả 216 bìa cạnh nhau ở cỡ thẻ thật (200px), cỡ mà người đọc thấy nhiều nhất.
Luật thực hành: gạch bỏ bằng một nét ngang hoặc phủ sẫm; đếm bằng chấm hay số lượng vật, không bằng nét lặp. Và người điều phối luôn tự xem bảng tổng ở cỡ thẻ trước khi merge. Xem skill book-covers.
Sự kiện đọc sách chưa từng được ghi (03.10.2026)
legacy_ebook_learning_events có 0 dòng: trình đọc gửi sự kiện (book_open, section_view, reading_heartbeat...) từ ngày đầu, và chưa một sự kiện nào vào được bảng. Hai chỗ chặn chồng lên nhau:
- Lệnh ghi là
upsert(..., { onConflict: 'event_id' }), sinh raON CONFLICT ("event_id"), mà bảng không có ràng buộc duy nhất nào trênevent_id. SQLite ném "ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint" ở mọi lượt ghi, kể cả quyển có id UUID. - Schema API bắt
ebook_id/section_idlà UUID, trong khi 216/245 quyển có id dạngfood-book-03: trả 422.
Client gọi record(...).catch(console.error), nên trang đọc chạy trơn tru. Thứ mất đi thì không ai thấy: tiến độ đọc, "đọc tiếp", và mọi bằng chứng đọc sách cho Learner Model (captureReadingMilestone). Một bảng rỗng không báo lỗi; phải đếm mới thấy.
Luật rút ra: onConflict của d1Client phải trỏ vào một ràng buộc có thật (khoá chính hoặc chỉ mục duy nhất); thêm bảng có upsert thì thêm chỉ mục cùng migration. Và id thực thể trong schema API dùng chuỗi không rỗng, không .uuid(), vì repo có hai kiểu id. Sửa ở migration 20261007a và content/index.ts (EntityId).
Nâng gói: lockfile cài được mà cây phụ thuộc đã gãy (03.10.2026)
Nâng minor/patch toàn repo lộ ra hai chỗ hỏng đã nằm sẵn mà CI không thấy:
- Ba site React Router (www, tokyo, mam) có cây phụ thuộc mâu thuẫn từ trước. wrangler từ 4.12x khai
peerOptional @cloudflare/workers-types ^5, còn@react-router/cloudflaređòi^4.npm cicài theo lockfile nên vẫn xanh, nhưng mọi lệnhnpm install/npm updateđều ERESOLVE. Sửa: lên workers-types^5vàoverridescho@react-router/cloudflaredùng chung bản đó. www còn ghim cứngreact-router 7.14.0trong khi@react-router/cloudflaređã lên 7.18: cả họ phải nâng cùng một số. - Hai bản
@types/reactcùng số hiệu vẫn là hai kiểu khác nhau. adminincludemã củapackages/admin-shared, gói này cónode_modulesriêng. Nâng cả hai lên 19.3.0 vẫn ra 416 lỗi (refkhông gán được,childrenkhông có trongButtonProps) vì TypeScript so kiểu theo file chứ không theo số hiệu. Sửa:pathstrongadmin.conan.school/tsconfig.jsonépreactvề một bản, cùng ý vớidedupeởvite.config.ts. Build không bắt được lỗi này vìvite buildkhông kiểm type.
Luật rút ra: nâng gói thì nâng cả packages/* cùng lượt, và kiểm bằng typecheck, không chỉ build. wrangler vẫn phải nằm trong package.json các site: CI deploy bằng npx --no-install wrangler.
TypeScript 7: build xanh ở máy, npm ci đỏ ở CI (03.10.2026)
TypeScript 7 là bản native, gói npm không còn JS API (require('typescript').createProgram là undefined). Hai hệ quả, cả hai đều không lộ ra nếu chỉ chạy tsc và build:
@react-router/dev7.18 khai peertypescript ^5 || ^6. Ở www và mam, TS 7 typegen + tsc + build đều xanh ở máy, nhưngnpm citừ lockfile ERESOLVE, tức CI đỏ. Kiểm bằngnpm ci --dry-run, không phải build.- typescript-eslint cần JS API, nên mentors, video, play/frontend sập ở bước lint.
Năm site này giữ TS 5/6 với khoá //typescript ghi lý do trong package.json. Nâng lại khi react-router và typescript-eslint hỗ trợ TS 7. Tương tự: mermaid giữ 11 (vitepress-plugin-mermaid chỉ nhận 10/11), eslint ở mentors giữ 9 (eslint-plugin-react chưa hỗ trợ 10). vinext 1.0 chuyển lệnh deploy sang @vinext/cloudflare (vinext-cloudflare deploy), deploy-mentors.yml đã đổi theo.
Lỗi type che lỗi thật: route luôn trả 401 mà không ai biết (03.10.2026)
api.conan.school đứng ở mốc 194 lỗi type suốt nhiều tuần. Khi hạ xuống 7, hai lỗi runtime thật lộ ra giữa đống đó:
- Ba route yêu thích sách (
content/index.ts) đọcc.get('user'), khoá mà không middleware nào đặt. Chúng luôn trả 401. tsc báo đúng chỗ này, nhưng nằm lẫn trong 194 dòng nên không ai đọc. Sửa: dùngc.get('userId'). LEARNING_EXPERIENCE_COMPARISON_WORKFLOW.get(id).status()thiếuawaitchoget(), nên.statuslàundefined, ném lỗi, vàcatchbiến nó thành 404 "không tìm thấy workflow".
81 lỗi khác cùng một gốc: handler của createRoute trả mã trạng thái không khai trong responses, nên handler sai kiểu và c mất kiểu, kéo theo hàng loạt implicit any. Khai đủ mã trong responses là hết.
Luật rút ra: mốc lỗi type chỉ được hạ. Một mốc cao không phải "nợ vô hại", nó là chỗ giấu lỗi thật. Cùng lượt này: www lâu nay render nội dung tin tức như HTML thô (nay qua xss với danh sách cho phép), và các trang chi tiết ở www biến lỗi 5xx thành 404, nay 404 chỉ khi API nói 404, còn lại 503 (app/lib/loader-errors.ts).
Đáp án đều tuyệt đối mà đoán được: chu kỳ A-B-C-D (03.10.2026)
balance_quiz_answers.py gán đáp án đúng theo i % 4. Phân bố ra 5/5/5/5 nên mọi gác đều xanh, nhưng thứ tự là A, B, C, D, A, B... trong mọi quiz của mọi pack: người học đoán đúng 100% sau bốn câu mà không đọc đề. Lỗi này vô hình với mọi phép đếm phân bố; chỉ lộ khi đọc liền một quiz. Sửa thành phép xáo có hạt giống, không ba câu liền cùng vị trí. Đã cân lại 10 khoá retail (20261014a), rồi toàn bộ 200 pack còn lại (20261016a001..200, 04.10.2026).
Seed khẳng định nguồn không có thật (03.10.2026)
Pack do agent viết cho đợt 180 khoá ghi nguồn là "buổi quan sát ở cửa hàng học viên lớp Conan", "tư liệu nội bộ đã ẩn danh". Không có buổi nào như thế. Luật ngoại lệ của course_seed đòi nêu nguồn cụ thể, và agent đáp ứng bằng cách bịa nguồn: thoả gác, sai sự thật. Retail đã đổi thành "tình huống mẫu, nhân vật hư cấu"; 04.10.2026 rà nốt 198 khoá của 17 cộng đồng và khoá tư duy chung (khoảng 3.500 chỗ sửa). Lượt quét sau cùng bắt thêm một khoá danh sách ban đầu bỏ sót (sales-pipeline-hygiene): quét bằng một regex rồi tin danh sách đó là đủ thì sót.
Mentor: admin sửa gì www cũng không đổi (03.10.2026)
Đã xảy ra: GET /api/public/mentors lọc và sắp mentor theo một danh sách 5 slug viết cứng trong src/domains/public/mentors.ts. Cột sort_order có trong bảng nhưng không ai đọc. Thêm một mentor ở đâu đi nữa thì www vẫn hiện đúng năm người cũ.
Cùng lúc đó, hai truy vấn lọc theo is_active, một cột không tồn tại trong legacy_mentors: danh sách mentor ở admin (/api/admin/mentors) và mảng mentors của /api/public/misc/home. Lỗi đầu làm trang Mentors của admin trả 500; lỗi sau bị nuốt vì Promise.all chỉ đọc .data, nên mentors rỗng mà phản hồi vẫn 200.
Vì sao không ai thấy: bảng legacy_mentors đi từ thời Supabase sang, chưa từng có CREATE TABLE trong migrations/, nên không có chỗ nào trong repo để đọc ra danh sách cột thật. Metadata sinh ra (legacyTableMetadata.ts) cũng không có is_active, nhưng không ai đối chiếu.
Đã sửa: migration 20261015a_mentor_status.sql thêm status và ghi lược đồ thật vào chú thích; API công khai đọc status='published' theo sort_order. Bài học: trước khi lọc theo một cột của bảng legacy_* không có migration, kiểm bằng wrangler d1 execute conan-platform-production --remote --command "SELECT sql FROM sqlite_master WHERE name='legacy_<bảng>'". Xem mentor & album.
Bộ lọc HTML gỡ mất phần trình bày (03.10.2026)
Khi đưa nội dung playbook qua bộ lọc xss (www.conan.school/app/lib/sanitize-html.server.ts), danh sách cho phép ban đầu bỏ thuộc tính class và thẻ iframe. Trình chuyển EditorJS (editorjs-converters.ts) lại sinh đúng hai thứ đó: <div class="embed"><iframe ...> cho video và <div class="warning"> cho khung cảnh báo. Lọc như vậy an toàn, build xanh, và video biến mất khỏi trang mà không báo gì.
Sửa: cho class ở thẻ khối, cho iframe nhưng src chỉ giữ khi là https từ EMBED_HOSTS. Luật rút ra: trước khi thêm bộ lọc trên một đường render, đọc xem trình sinh HTML phía trước dùng những thẻ và thuộc tính nào.
Cùng lượt: lỗi type của api về 0 và gác CI đổi từ mốc sang cổng tuyệt đối (npm run typecheck, gồm cả tsconfig.node.json cho tests/ và scripts/). admin chuyển lint sang oxlint vì typescript-eslint chưa chạy được với TypeScript 7 (gói TS 7 không còn JS API).
Bộ chọn job bỏ qua mọi job khi PR lớn: SIGPIPE dưới pipefail (04.10.2026)
ci-checks.yml chọn job bằng echo "$FILES" | grep -qE .... grep -q thoát ngay ở dòng khớp đầu tiên, echo ghi tiếp vào ống đã đóng và chết vì SIGPIPE, set -o pipefail biến cả ống thành mã 141, và hàm trả false. Với vài chục file thì echo xong trước khi grep thoát nên không ai thấy; với PR #332 (~800 file, 200 migration) cả job api lẫn CoursePack bị bỏ qua và CI xanh. Sửa: grep đọc từ here-string (<<<"$FILES"). Bài học chung ở skill ci-guards: gác đổi trạng thái theo KÍCH THƯỚC đầu vào thì phải thử với đầu vào lớn.
Thay chữ hàng loạt bằng script để lại câu gãy (06.10.2026)
Đợt rà nguồn 04.10 đổi "tư liệu lớp Conan" thành "tình huống mẫu" bằng script thay chuỗi con. Mọi gác đều xanh, nhưng đọc lại 3.825 câu bị thay thì khoảng 270 câu gãy: lặp chữ ("bộ hai mươi tám bộ hồ sơ", "người kể kể", "hai hai phỏng vấn"), "thường" chèn hai lần trong một câu, chủ ngữ lạc, viết hoa giữa câu ("Trong Các cuộc"). Bài học: sau một lượt thay hàng loạt, trích ĐÚNG các chuỗi đã đổi (so cây JSON trước và sau commit) rồi đọc lại từng câu; regex dò lỗi trên toàn kho cho quá nhiều kết quả vô hại để dùng được. Sửa ở 20261020a001..093.
Gác đúng mà làm hỏng văn: bộ kiểm số ép bản Anh viết cứng (06.10.2026)
Bộ kiểm bản Anh của sách đòi mọi con số trong bản Việt có mặt trong bản Anh. Nghe đúng, nhưng "thứ 2" dịch là Monday, "1 lần" là once, nên 23 agent dịch đều tự chèn chú thích để qua gác: "Monday (day 2 in Vietnam)", "January (month 1)", "the road to profit of 0", "1 new phone number". 38 chỗ như vậy trước khi sửa gác. Một gác mà người viết phải làm xấu sản phẩm để vượt qua là gác sai; sửa gác, đừng bắt người viết chịu.
Cùng lượt: quiz có thể chia đáp án đều 3/3/3/3 và không lặp thứ tự giữa các quiz, mà câu cuối của MỌI quiz vẫn là D. Năm quyển dính (kể cả hai quyển đã xuất bản). book_quiz_to_sql.py --check nay bắt luôn kiểu này.
Pages project mới: wrangler không tự tạo, và API trả JSON có dấu cách (06.10.2026)
Site mới content.conan.school: lượt deploy đầu đỏ vì wrangler pages deploy --project-name không tạo project còn thiếu trong CI. Thêm job tạo project qua API thì job đó lại đỏ dù project đã được tạo: API trả JSON xuống dòng ("success": true, có dấu cách), còn bước kiểm grep "success":true. Kiểm kết quả API bằng grep -Eq '"success": *true'. Token CI không có Zone DNS Edit, nên CNAME của site mới phải thêm tay một lần ở Cloudflare. Xem content-site.