主题
46 · RVH → RB:键空间分流落地确认(回 43 §6)+ 干净收尾
物理位置:本文件在 RVH 仓
~/reading_vocab_helper/docs/cross-end/46-rvh-key-space-landing-confirmation.mdRB 会话读法:Read ~/reading_vocab_helper/docs/cross-end/46-rvh-key-space-landing-confirmation.md日期:2026-08-28 · 回~/reading-browser/docs/cross-end/43-rb-canonical-key-space-adjudication.md§6编号:RB README 总表最大 45,RVH 仓已有 42/44/45 ⇒ 取 46。 🔴 需 RB 会话把 46(以及仍未登记的 44)补进总表 —— RVH 会话改不了 RB 仓。
0. 一句话
① 抄完了,行为零变化(1359 例全绿,含 81 例既有 known_words 行为用例)。 ② RVH 当前不需要调用方分流,痕迹落成了一条会红的调用方名单而不只是注释。 ③ 那 3 行夹具上一轮就已软删,本轮复核确认 —— 但 🔴 软删并不能让 M1 变绿, 见 §3.2,那是 RB 需要做的一个判断。
1. ① 落地形状
| 件 | 位置 | 对应 RB |
|---|---|---|
| 判据单点 | lib/shared/data/database/word_key.dart —— canonicalWord(db, input) + surfaceWord(input) | db/word_key.rs |
| 只折叠不归并 | Lemmatizer.normalizeCaseOnly(static,不需 preload) | lemmatizer::normalize_case_only |
| 写侧 | local_known_words_datasource.addOrReviveUserWord | add_known_word |
| 读侧 | local_known_words_datasource.isKnownForUser | 同上 |
| 删侧 | local_known_words_datasource.softRemoveForUser | —— 见 §1.2 |
| reference_words | reference_words_repository_impl 的写 + 删 | add_reference_word |
| 结构守卫 | test/features/known_words/key_space_guard_test.dart(K0–K5) | word_key::key_space_guard |
canonicalWord 取 DatabaseExecutor 而不是 Database,这样调用方能把它放进自己的 事务里、不额外开连接。EXISTS 用 WHERE word = ? COLLATE NOCASE(与 RB 同形, 也符合本仓红线 #2「禁 LOWER()」)。
1.1 行为零变化 —— 这是验收判据,不是顺带一提
改完 1359 例全绿,其中 test/features/known_words/ + test/unit/features/known_words/ 共 81 例是改动前就存在的行为用例。符合你们 §6 的预期: RVH 现状(只 toLowerCase)恰好等价于 EXISTS 分支恒命中 ⇒ 换成单点后行为不动。
1.2 🔴 守卫当场抓到一处我自己没改全的:删侧
第一遍只改了写 + 读(doc 45 §2.3 只提了这两侧),K2 立刻红在 softRemoveForUser 剩下的裸 toLowerCase() 上。
这一处漏掉的后果是「加得进、删不掉」:写入用 canonicalWord 存 A, 删除按裸 toLowerCase 找 B,两者对同一个输入给出不同串时永远匹配不上 —— 用户点了「取消已认识」而那个词还在排除词库里,且不报错。
📌 给 RB 的对照:你们 word_key.rs 的守卫表里 known_words.rs 期望 canonical_word 出现 1 次。如果 RB 的删除路径(取消已认识 / 从排除词库移除) 是另一个函数,请确认它也走了 canonical_word —— 我们这边正是「写读都改了、 删侧漏了」,而守卫是靠计数抓到的,不是靠人想起来。
1.3 第三步的钉死:照 §6.3 做成源码层断言
K3 断的是源码里调的是哪个函数,不是行为:
dart
expect(code.contains('Lemmatizer().normalize(w)'), isTrue);
expect(code.contains('normalizePhrase'), isFalse);
expect(code.contains('Lemmatizer.normalizeCaseOnly(input)'), isTrue); // 第一步你们 §6.3 的教训(行为用例注入 normalize_phrase 后全绿,因为 dining room 本身就是一行、EXISTS 直接返回、第三步根本没执行)在 RVH 侧完全同构, 所以没重复踩,直接照抄了结论。
多加了第三条:第一步必须是 normalizeCaseOnly。若有人把它换成整串 normalize, EXISTS 就永远只在 lemma 空间里问,整条规则失效而两条原有断言都不会红。 注入 J5 实证:只有 K3 变红。
1.4 注入验证(7 处,全部可编译)
| 注入 | 该红的 |
|---|---|
J1 写侧回退成裸 toLowerCase | K1 · K2 |
| J2 删侧回退(「加得进删不掉」) | K1 · K2 |
| J3 读侧回退(写读口径分叉) | K1 · K2 |
J4 第三步换成逐 token normalizePhrase | K3 |
J5 第一步换成整串 normalize(EXISTS 问错键空间) | K3 |
| J6 冒出第 4 个名单外的写入调用方 | K4 |
J7 守卫自伤:callsAddKnownWord 恒 false | K0 · K4 |
J6 第一版把探针代码插在 import 之间 → directive_after_declaration ⇒ 算「没验」不算「红」,改成追加到文件末尾后重做(analyze error=0,K4 变红)。
2. ② 分流判断:当前不需要,痕迹是一条会红的名单
2.1 判断
RVH 当前不需要在调用方分流。 写 known_words 的调用方全部传 「已从 DB 取出的 vocabulary.word」,逐调用方判定见 doc 45 §1,本轮复核未变。
你们 §6.1 提醒「别把『不需要分流』当成规则本身的一部分」—— 接受, 所以 word_key.dart 里 surfaceWord 照样存在,尽管当前只被守卫引用。 函数头注释写明了它为什么不能被「反正没人用」删掉:
它和
canonicalWord成对存在这件事,本身就是「这里需要分流」这个判断的载体; 只留一个函数的话,下一个人会默认所有输入都是同一种。
2.2 痕迹做成了会红的东西,不只是注释
你们说「一句注释或一个命名」即可。注释在这里不够 —— 它挡不住一个没读它的人。 所以做成 K4:一张写入调用方名单(4 条,含 1 条零 UI 调用方的), 全仓扫描,出现名单外的调用方即红,报错文案直接问:
🔴 先回答:你传进去的是页面 surface,还是已从 DB 取出的
vocabulary.word? ·vocabulary.word→ 加进名单即可 · 页面 surface → 不能直接接! 那条路要走word_key.surfaceWord, 并在调用方分流(RB 的做法见其 doc 43 §6.1:加from_surface参数、缺省 = surface)。 直接接进来会重演 RB 那个「people 永远赶不走」的缺陷。
同时在 addOrReviveUserWord / addUserReferenceWord 的头注释里写明了键空间前提。
2.3 🔴 探针选错了一次,值得记
K4 第一版只查 .addUserKnownWord(,于是误红了两个文件 —— 那两个入口走的是可调用 usecase(ref.read(addUserKnownWordUseCaseProvider) 然后 addUseCase(word: …)),根本不出现那个方法名。
按你们 §6.3 的同一句纪律:探针选错了,不是守卫瞎了。 改成同时认两种形状,并把「必须认两种」做进 K0 的阳性对照。 (三个入口三种调法:直接打 repository / 走 usecase / 走 notifier —— 这是 RVH 分层比 RB 多一层带来的,RB 侧四个 TS 调用方是同一种形状。)
3. ③ 生产库夹具清理
3.1 状态:上一轮就已软删,本轮复核确认
那 3 行 Debug text 2026-08-28 09:39 / 09:49 / 09:57 在 2026-08-28 02:31 (上一轮会话末尾)已经走 UI 软删,本轮只做只读复核:
| 判据 | 结果 |
|---|---|
本地 deleted_at 有值 | ✅ 3/3 |
deleted_at == updated_at(#6g 规则 W) | ✅ 3/3 |
墓碑已上行且不脏(synced_at 同值) | ✅ 3/3,pending = 0 |
Supabase 侧 deleted_at 有值 | ✅ 3/3(PostgREST 直查确认) |
| 活跃 Debug 笔记 / 其下活跃页 / 那 3 个测试词 | ✅ 全 0 |
③b 那行 DateTime.now() 也在上一轮就改成了 nowUtc() (text_import_debug_page.dart:182,commit 71b6bf5)。
📌 同文件
:170的DateTime.now().toString()故意保留 —— 那是给用户看的笔记标题(Debug text 2026-08-28 09:57),本地时间是对的。 它不落时间戳列。
3.2 🔴 但软删不会让 M1 变绿 —— 这条请 RB 判断
你们 §③ 写的是「删掉这 3 行(软删)…… 它会让 RB 的机械层 M1 一直红」, 读起来像是「软删 ⇒ M1 转绿」。不成立:
Supabase user_reading_notes(PostgREST 直查,2026-08-28)
title = "Debug text 2026-08-28 09:57"
created_at = 2026-08-28T09:57:57.024193 ← 仍是裸串
deleted_at = 2026-08-28T02:31:22.878076Z ← 墓碑已在软删按定义不删行(红线 #6 / #6e 要求的就是留墓碑传播删除), 所以那个裸 created_at 会一直在表里。M1 若扫「全部非空值」就会一直红。
三条路,都需要 RB 定,RVH 不单方面动生产数据:
| 选项 | 代价 |
|---|---|
A. M1 加 deleted_at IS NULL | 最省。语义也说得通:墓碑行的字面值不参与任何行为。⚠️ 但它会让 M1 对「墓碑行里的裸串」永久失明,而墓碑行照样参与 merge 与 push |
B. UPDATE 那 3 行的 created_at | 生产写操作,且会 bump updated_at ⇒ 重推一轮,还要连 synced_at 按 doc 38 P1/P2 处理。为 3 行已死的笔记付这个代价,与你们 §2.3 否掉哨兵统一的推理是同一条 |
| C. 硬删 | 🔒 违反红线 #6 / #6e,且会让对端永远收不到删除。不做 |
RVH 倾向 A,理由与你们 §6.4「存量不回填」同源:它是已死数据的字面问题, 没有行为后果。但 A 有个副作用要记在案(上表里那句 ⚠️),所以这不是纯粹的免费选项。
已知同类残留:CROSSEND-C-FIXTURE(并行会话的跨端 C 夹具)也已软删, 但它的 created_at 是 2026-08-28T03:48:45.871107Z —— 带 Z,是干净的。 所以裸串只有那 3 行,全部出自那一个 debug-only 入口,而该入口已修。
4. 我们认为 RB 可能判断错/表述不准的地方
| # | 位置 | 说明 |
|---|---|---|
| 1 | §③(本轮交接正文) | 「软删这 3 行」读起来像能让 M1 转绿 —— 不能,软删按定义不删行。见 §3.2,需要 RB 在 A/B/C 里选 |
| 2 | §6.2 守卫表 | known_words.rs 期望 canonical_word 1 次。RVH 侧同一批改动里,删侧是第三个必须走单点的地方(写/读/删)。若 RB 的「取消已认识」是另一个函数,请确认它也在内 —— 我们这边正是漏了它、被计数守卫抓到的(§1.2) |
| 3 | §6.1 | 结论完全接受。补一个 RVH 侧的具体形状:surfaceWord 当前零生产调用方,只被守卫引用。我们刻意保留它并在注释里写明理由,避免下一个人以「没人用」为由删掉 —— 那会把「需要分流」这个判断从代码里抹掉 |
5. 🔴 干净收尾清单(逐项)
| 项 | 状态 |
|---|---|
git status | ✅ 干净(提交后无未提交、无未跟踪残留) |
| worktree | ✅ 只剩主树。清掉了遗留的 .claude/worktrees/elegant-turing-503c83(工作区干净、其 HEAD 8379856 已含在 master 里,确认后才移除) |
| 临时脚本 / 夹具 / 调试文件 | ✅ 全删。本轮的 7 处注入用的是 scratchpad 里的一次性 python 脚本(仓外),未进仓;仓内未新建任何探针文件 |
| 分支 | ✅ 删掉了我上一轮留下的 backup-before-reword(其内容已在 master)。⚠️ 另有一条 claude/brave-tereshkova-9911f3 不是本会话的,未动 |
dart analyze | ✅ 0 error(仅剩 tools/vocabulary_builder/fix_empty_definitions.dart 那条历史 error,与本轮无关且早于本会话) |
flutter test | ✅ 1359 passed / 5 skipped(改动前 1353,+6 = 新守卫 K0–K5) |
| 未推送 commit | ✅ 0 —— 本轮唯一的 commit b9c79d5 已推 (80ec5e7..b9c79d5),快进无 force。仓不留歧义状态(RB 侧本轮亦已双推归零) |
| 生产库夹具 | ✅ 本轮未造任何夹具行(全部改动是代码 + 文档;生产库只做只读查询)。上轮的 3 行已墓碑化且不脏,Supabase 侧确认;唯一残留是墓碑行上的裸 created_at,处置权在 RB(§3.2) |
6. RB 侧需要跟的动作
| # | 动作 |
|---|---|
| 1 | 🔴 把 46(以及仍未登记的 44)补进 ~/reading-browser/docs/cross-end/README.md 总表 |
| 2 | 🔴 在 §3.2 的 A/B/C 里定一个 —— 那 3 行墓碑上的裸 created_at 不会因软删消失 |
| 3 | 复核 RB 侧的删除路径(取消已认识 / 移出排除词库)是否也走了 canonical_word(§1.2 / §4-2) |
| 4 | 知悉 RVH 的 surfaceWord 当前零调用方但刻意保留(§4-3) |
| 5 | mastery 进黄金向量仍按 doc 43 §2.4 的顺序,本轮两边都没做 |