---
url: https://docs.nemo12.com/quality/audits/2026-08-26.md
description: >-
  Audit #005 (26.08.2026): rà soát Dolphin sau Family Workspace, Dory và
  Journey, gồm trạng thái cổng, phát hiện và nợ mở.
---

# Audit #005 — 2026-08-26

Rà soát toàn hệ sau một ngày xây liên tục: [Family Workspace](../../architecture/sdd-023-family-workspace/index.md) (SRC-584), danh sách theo gia đình (SRC-585), giao diện dolphin sang tiếng Anh (SRC-586), [Dory note+tag](../../architecture/sdd-024-dory.md) (SRC-587/588) và [Family Journey](../../architecture/sdd-026-family-journey.md) (SRC-589).

Đây **không phải** một lần chấm đầy đủ theo [Audit Standard](../audit-standard/index.md) 255 chỉ báo. Đây là một lần **rà có mục tiêu** vào phần vừa xây, cộng một lượt kiểm sức khoẻ toàn hệ. Nói rõ để không ai đọc nhầm con số.

## 1. Trạng thái cổng — tất cả xanh

| Cổng | Kết quả |
| --- | --- |
| Test | **625 / 625 pass**, 50 file |
| Typecheck | 0 lỗi trên toàn workspace |
| Lint | 0 lỗi |
| QG-001 docs integrity | PASS, 114 docs |
| Contract snapshot | khớp, 240 route `/v1` |
| Migration dry-run | 0001..0081 áp sạch |

## 2. Bề mặt sống — kiểm bằng request thật

Chín bề mặt, đúng trạng thái mong đợi:

```
api  200 · www  200 · learn  200 · marlins  200 · docs  200 · pearl  200
dolphin  302 · admin  302 · coral  302      ← Cloudflare Access chặn ở biên, đúng thiết kế
```

Năm route nội bộ đều trả **401** khi gọi không phiên: `/v1/mentor/families`, `/v1/mentor/learners`, `/v1/showcase/mentors`, `/v1/admin/overview`, `/v1/coral/overview`.

**Không có trường nội bộ nào rò ra API công khai.** Kiểm `/v1/public/mentors` trên production: 5 hồ sơ trả về, không mẩu nào chứa `alumni_note`, `verification_note`, `consent_note`, `internal_note`, `sources_json` hay `alumni_verified_by`.

## 3. Phát hiện

### 🔴 F1 — `GET /v1/mentor/families` sẽ hỏng khi vượt ~100 gia đình

Truy vấn danh sách **không có `LIMIT`**, và bốn truy vấn phụ sau đó dựng `IN (?1,?2,…)` với **một placeholder cho mỗi gia đình**. D1 có trần số tham số buộc trên một truy vấn; vượt trần thì endpoint trả 500, không phải trả chậm.

Đây không phải suy đoán về giới hạn: **chính repo này đã gặp và đã xử lý** — `modules/content/backfill.ts` chia lô đúng 100 cho cùng một hình dạng truy vấn. Phần tôi vừa viết không theo tiền lệ đó.

Hiện có 4 gia đình nên chưa lộ. Nó sẽ lộ **đột ngột** ở một ngưỡng, không phải chậm dần — đó là loại lỗi tệ nhất để phát hiện trong sản xuất.

**Trạng thái: ĐÃ SỬA** — xem §4.

### 🟠 F2 — `listPublicAlbums` cùng một lỗi, trên trang CÔNG KHAI

`modules/showcase/service.ts` dựng `IN (${marks})` trên toàn bộ id album đã publish, không chia lô. Nghiêm trọng hơn F1 ở một điểm: nó nằm trên `nemo12.com`, tức là hỏng thì **người lạ nhìn thấy**, không phải nhân viên nội bộ.

**Trạng thái: ĐÃ SỬA** — xem §4.

### 🟠 F3 — Journey hứa "từng người" nhưng với phụ huynh thì chưa làm được

[SDD-026](../../architecture/sdd-026-family-journey.md) §1 nói đơn vị của journey là **một người**, và lấy chính nhà Trí Minh làm ví dụ: bố đã hào hứng còn con thì chưa.

Nhưng trong hiện thực, dấu vết dùng để suy ra bước của **mọi liên hệ trong cùng một nhà là như nhau**: note gắn được với một learner cụ thể (`learner_id`), nhưng **không gắn được với một liên hệ cụ thể**. Hệ quả: bố và mẹ **luôn được suy ra cùng một bước**, cho tới khi có người bấm xác nhận khác đi.

Đây là **tài liệu hứa nhiều hơn code làm được** — đúng loại trôi mà [SRC-416](../../intake.md) sinh ra để chặn, chỉ khác là cổng đó không bắt được vì nó kiểm tên bảng chứ không kiểm ngữ nghĩa.

Hai đường sửa, cả hai đều đáng cân nhắc chứ không hiển nhiên:

1. Thêm `contact_id` vào `family_notes` — thêm một cột, đi ngược nguyên tắc "ít fields".
2. Chấp nhận giới hạn và **nói thẳng trên giao diện**: bước của phụ huynh là suy ra ở mức gia đình cho tới khi có người xác nhận.

**Trạng thái: ĐÃ SỬA TÀI LIỆU** theo hướng 2 — code giữ nguyên, tài liệu thôi hứa quá. Cột `contact_id` để lại cho đợt sau nếu thấy cần thật.

### 🟡 F4 — Đề xuất tag treo mãi ở `pending`

`family_note_tag_suggestions` chỉ chuyển sang `accepted`/`rejected` khi người dùng **lưu tag** cho note đó. Bấm "Suggest tags" rồi bỏ đi thì các dòng nằm `pending` vĩnh viễn, và gọi lại lần hai sinh thêm một bộ nữa.

Chỗ hỏng thật không phải rác: **gọi hai lần rồi lưu thì cùng một tag bị chốt hai lần**, và tỉ lệ chấp nhận — đúng con số bảng này sinh ra để đo — bị thổi lên.

Và một lỗ hổng thứ hai không có trong ghi nhận đầu: **bảng đo mà không có đường đọc**. Luật ở [SDD-024](../../architecture/sdd-024-dory.md) §6 nói *"một tag bị từ chối quá nửa số lần thì định nghĩa tag hoặc prompt sai"* — không có endpoint nào đọc được thì luật ấy không bao giờ áp được, và bảng thành bảng chỉ-ghi.

**Trạng thái: ĐÃ SỬA** — xem §4.

### 🟡 F5 — Journey đọc toàn bộ note của một nhà, không giới hạn

`memberSignals()` lấy mọi note đang active rồi đếm trong JS. Với 37 note thì không sao; với một nhà theo bốn năm thì đây là một truy vấn lớn chạy mỗi lần mở màn hình.

**Trạng thái: ĐÃ SỬA** — xem §4.

### ✅ Đã kiểm và KHÔNG có vấn đề

* **Nhật ký không chứa nội dung.** Mọi lời gọi `logAccess` trong module families chỉ truyền id và loại hành động (`note_id`, `tag_count`, `stage`, `member_id`); không khoá nào chạm danh sách cấm của `sanitiseDetail` (AS-07.4.3).
* **Cổng phủ xác thực làm việc.** `authCoverage.test.ts` bắt được **hai lần** trong ngày: `/v1/mentor/notes/*` và `/v1/mentor/journey/*` chưa qua `requireSession`. Cả hai đã vá trước khi lên production.
* **Prompt AI đi đúng một đường.** `dory.suggest-tags` đăng ký trong `shared/prompts.ts`; không có lời gọi `AI.run` nào nằm ngoài file đó.
* **Phạm vi gửi cho AI hẹp đúng như thiết kế.** Chỉ thân note đang xét, cắt 8000 ký tự; không gửi hồ sơ gia đình, không gửi note nhà khác, không gửi dữ liệu học.
* **Tag lạ bị chặn ở server.** `normaliseTags` lọc cả đầu vào người dùng lẫn đầu ra của mô hình.

## 4. Đã sửa trong chính lần audit này

| Mã | Sửa gì |
| --- | --- |
| F1 | `GET /v1/mentor/families` thêm `LIMIT 200`; bốn truy vấn phụ chia lô 100, theo đúng tiền lệ `backfill.ts` |
| F2 | `listPublicAlbums` chia lô 100 cho truy vấn người trong album |
| F3 | [SDD-026](../../architecture/sdd-026-family-journey.md) §3 nói rõ giới hạn: bước của phụ huynh suy ra ở mức gia đình, không phân biệt được bố với mẹ cho tới khi có người xác nhận |
| F4 | Sinh bộ đề xuất mới thì **xoá bộ treo của lần trước** — đề xuất chưa ai quyết là đề xuất **bị thay thế**, không phải bị từ chối; gán `rejected` cho chúng sẽ nói sai rằng người dùng đã nhìn và đã chối. Thêm `GET /v1/mentor/tag-stats` để luật "quá nửa bị từ chối" có đường áp; tag chưa ai quyết trả `null` chứ không trả 0 |
| F5 | `memberSignals()` đếm bằng `COUNT`/`MAX`/`SUM(CASE…)` trong SQL, mỗi nhóm một dòng, thay vì kéo hết note về |

## 5. Món nợ mở

**Cả năm phát hiện đã sửa.** Còn lại là việc chưa xây, không phải lỗi:

| # | Việc | Vì sao chưa làm |
| --- | --- | --- |
| — | **Anchor chưa xây** | [SDD-025](../../architecture/sdd-025-anchor.md) mới có tài liệu |
| — | **12 trên 13 AI agent chưa xây** | Mới có Tag suggester |
| — | **Chưa xem được dolphin bằng mắt** | Sau Cloudflare Access, cần đăng nhập Google; mọi kiểm chứng ở trên là qua API và mã nguồn |

### Ghi chú kỹ thuật cho F5

Việc đếm tag trong SQL dùng `tags_json LIKE '%"event"%'` và `tags_json <> '["event"]'`. Trông thô, nhưng **đúng** vì hai lý do cụ thể chứ không phải vì may:

* Bộ tag là **danh sách đóng** và không tag nào là chuỗi con của tag khác khi đã bọc dấu nháy.
* `normaliseTags` **luôn xuất theo thứ tự cố định của `TAGS`**, nên note chỉ gắn mỗi `event` luôn được ghi đúng chuỗi `["event"]` — đây là so khớp chính xác, không phải xấp xỉ.

Đã **kiểm bằng SQLite thật** chứ không suy luận: năm note với năm hình dạng tag khác nhau (`["event"]`, `[]`, `["parent","event"]`, `["goal"]`, `["child","fact"]`) cho ra đúng số mong đợi ở cả mức gia đình lẫn mức learner.

## 6. Điều đáng nói nhất

Ba cổng chất lượng **bắt lỗi thật của tôi trong ngày**, không phải lỗi giả:

1. `authCoverage.test.ts` — hai route mới thiếu `requireSession`. Nếu lọt, chúng sẽ mở dữ liệu gia đình cho bất kỳ ai gọi thẳng API.
2. `check-docs` SRC-416 — tôi nêu tên một bảng chưa tồn tại trong PRD như thể nó đã có.
3. Test của chính tôi — `\b` trong JavaScript không hiểu chữ tiếng Việt có dấu, nên `/\bbố\b/` không khớp "Bố lo…".

Và một lỗi **cổng không bắt được, người dùng bắt được**: tab hiện "Notes (0)" trong khi bên trong có mười note. Không có cổng nào kiểm được rằng một con số trên màn hình đọc đúng nguồn dữ liệu — chỗ đó vẫn cần mắt người.

## Trace

Nguồn [SRC-590](../../intake.md) · chuẩn [audit-standard](../audit-standard/index.md) · [audits/index](index.md).
