Skip to content

CLAUDE.md — RVH 跨端 Sync 协议红线(正文)

本文件由 rvh/CLAUDE.md 的同名一节于 2026-08-29 原样下沉而来(docs/plans/archive/post-merge-repo-optimization-plan.md T2-1),内容一字未改。 索引与编号对照表留在 rvh/CLAUDE.md(对照表是跨仓消歧工具,任何 RVH 会话都可能需要)。 注入是祖先链全量:碰 rvh/lib/features/sync/ 下任何文件时,本文件与 rvh/CLAUDE.md 一起来。


🔄 跨端 Sync 协议红线(本节只留 Dart 落地形状 + 守卫)

🔒 2026-08-28 合仓后,本节不再复述契约docs/plans/archive/rvh-merge-plan.md T3-8)。 合仓前这里是 728 行 / 67KB 的 RB 红线镜像 —— 同一条规则两处叙述,就是第二本账。 它已经漂过:#6g 的触发门槛,RB 侧写「几秒漂移就够」(按 autoSync 60s 估的理论最坏值), 而 RVH 2026-08-27 真机反算是 27 秒;两个数字在两个仓里各自躺着,谁也不知道对方写了什么。

现在的分工:

问什么去哪
规则是什么 · 为什么 · 反例 · 事故记录CLAUDE.md §4 · src-tauri/CLAUDE.md §2 · docs/cross-end/
RVH 这边长什么样 · 拿什么 grep 守住 · 本端独有的坑本节

改契约 → 改根;改 Dart 落地 → 改这里。两边都动的,先改根。 ⚠️ 本节保留的 grep 块绑死 Dart 文件名与函数名,只能长在这里 —— 删掉它们等于把判据一起删了。

🔴 谁在执行:目前没有人。 这些块合仓前标着「自动化校验(CI grep)」,但 2026-08-28 实测 没有任何 CI job / 脚本 / skill 引用它们ci-rvh.yml 只跑 analyze + 架构 grep + test)。 它们是判据规格,不是闸门。真正在守的是每条末尾点名的那些 Dart 回归测试 (tombstone_synced_at_test.dart 等,多数带「注入缺陷实证」)—— 那些确实每次 flutter test 都跑。 把这些 grep 接进 ci-rvh.yml 已登记为 docs/plans/archive/rvh-merge-plan.md §7 的待决项; 在接上之前,别把「grep 写着」当成「已经守住」


#5b user_id 必填 — save 路径 + pull 路径双侧守门

契约src-tauri/CLAUDE.md §2 #5b。

RVH 落地(v46):

  • Schema:4 张表 user_id TEXT NOT NULL(开发期删库重建,清掉既存 NULL 行)
  • save 路径:4 个 Model 加 required String userId;4 个 Repository 构造期绑定 (NotebookRepositoryImpl / BookRepositoryImpl / ReadingPageRepositoryImpl, 各自 provider 注入 currentUserIdProviderKnownWordsRepositoryImpl 是模板)
  • pull 路径sync_repository_impl.dart 4 个 _pull* 的 INSERT 列表加 user_id 列 + _userId

本端独有的坑:RVH 的 clearLearningDataForUser 不按 user_id 过滤(hard DELETE), 而 user_id NULL 的行无法被任何按 user_id 的 sync 路径识别 —— 这是 RVH 侧 NULL 行能长期滞留的根, 后续 #5h / #5d 两条都被它牵连。实证:Bug D,free 一词在本地共存 2 行(自加 + 从 RB pull)。

守卫判据(⚠️ 见节首「谁在执行」):

bash
# 4 张表的 INSERT 调用点必须包含 user_id 字段(任一失败即 CI fail)
for table in learning_entries reading_notes reading_pages word_page_links; do
  if ! grep -A 10 "INSERT.*INTO ${table}" lib/features/sync/data/repositories/sync_repository_impl.dart | grep -q 'user_id'; then
    echo "❌ pull path missing user_id for ${table}"; exit 1
  fi
done
# Model toDatabase 必须包含 user_id
grep -L "'user_id'" \
  lib/features/vocabulary_notebook/data/models/notebook_entry_model.dart \
  lib/features/reading_notes/data/models/reading_note_model.dart \
  lib/features/reading_tracking/data/models/reading_source_model.dart \
  lib/features/reading_tracking/data/models/word_source_relation_model.dart \
  && (echo "❌ model toDatabase missing user_id"; exit 1) || true

关联:cross-end log docs/cross-end/archive/01-vocabulary-word-pk-log.md「Bug D」段。

#5d Watermark = server_updated_at(trigger 维护的权威服务器时钟)

契约src-tauri/CLAUDE.md §2 #5d / #5a / #5d′(含 replica lag 与 client 时钟漂移那两个被证伪的假设)。

RVH 落地

  • supabase_sync_datasource.dart::pullRowscursorCol 默认 'server_updated_at'
  • sync_repository_impl.dart::syncNow — 每个 _pullXxx 返回 ({int count, String? maxObserved}), 跨表取 MAX → _setLastSyncAt(watermark);各 _pullXxx 内部 cursor = rows.last['server_updated_at']
  • 首次升级兜底app_metadata.key='server_updated_at_migration_v1',未标记时清空 last_sync_at 触发全量 pull
  • pullXxx 仍用 INSERT OR IGNORE / ON CONFLICT(id) DO UPDATE,重叠拉取 idempotent

🔒 本端独有:watermark 必须 per-user(v58,RB 无等价条款) key = last_sync_at:<userId>SyncMetadataKeys.watermarkFor,见 lib/core/constants/sync_metadata_keys.dart),禁止退回全局单值;用户切换时 clearLearningDataForUser 在清库的同一事务内删掉离开用户的 key。

理由:watermark 语义是「本地这份数据集是谁的、消费到哪了」,而用户切换会清空本地数据集。 全局单值下新用户带着上一个用户的 cursor 做增量 pull,server_updated_at 落在两者之间的行被永久跳过。 该缺陷长期被 syncNow 的 force-full-pull 兜底掩盖,但兜底在有残留已同步行时失效,实测两条路径: ① known_words.user_id 可为空而清库按 WHERE user_id = ? 过滤 ⇒ 空值行永远删不掉 ⇒ 兜底被永久禁用 (v59.2 已修,见 #5h;但 per-user 仍是主防线);② auth_repository_impl.dart:39_controller.add(user) 排在 :42await _detectUserSwitch 之前 ⇒ autoSync 首次 sync 与清库事务并发,旧用户的行仍在 ⇒ 兜底不触发。

per-user 之后:到来的用户读自己的 key(通常不存在 → 全量 pull),清理删的是离开用户的 key —— 读写碰不同 key,先后顺序不影响正确性。注意 app_metadata 不是同步表, 本款是本端实现细节、不构成跨端契约(RB 自行保存其 watermark)。 回归 test/features/sync/watermark_per_user_test.dart

守卫判据(⚠️ 见节首「谁在执行」):

bash
# pullRows 默认 cursorCol 必须是 server_updated_at
grep -q "cursorCol = 'server_updated_at'" \
  lib/features/sync/data/datasources/supabase_sync_datasource.dart \
  || (echo "❌ pullRows default cursor not server_updated_at"; exit 1)

# 各 _pullXxx 不得遗留 rows.last['updated_at'] / rows.last['created_at'] cursor 推进
if grep -E "cursor = rows\.last\['(updated_at|created_at)'\]" \
     lib/features/sync/data/repositories/sync_repository_impl.dart; then
  echo "❌ pull cursor regression: must use server_updated_at"; exit 1
fi

# syncNow 不得遗留 sync_start_at - 5s 逻辑
if grep -q "subtract(const Duration(seconds: 5))" \
     lib/features/sync/data/repositories/sync_repository_impl.dart; then
  echo "❌ legacy -5s watermark buffer still present"; exit 1
fi

# v58: watermark 必须 per-user,不得退回全局单值裸 key
grep -q "String get _lastSyncAtKey => SyncMetadataKeys.watermarkFor(_userId)" \
  lib/features/sync/data/repositories/sync_repository_impl.dart \
  || (echo "❌ #5d: watermark key not per-user"; exit 1)

# 用户切换必须在清库事务内删掉离开用户的 watermark 行
grep -q "SyncMetadataKeys.watermarkFor(userId)" \
  lib/features/auth/data/repositories/auth_repository_impl.dart \
  || (echo "❌ #5d: clearLearningDataForUser missing watermark delete"; exit 1)

# 那行 no-op 不得复活(prefs 里从来没有 last_sync_at)——排除注释行,
# 否则会被解释「为什么删掉它」的注释本身误伤
if grep -v -E "^\s*//" \
     lib/features/auth/data/repositories/auth_repository_impl.dart \
     | grep -q "_prefs.remove('last_sync_at')"; then
  echo "❌ #5d: no-op prefs watermark reset came back"; exit 1
fi

#5e 预装库单点 + Lemmatizer JSON 双端 SHA256 必须 byte-equal

契约 → 根 CLAUDE.md §4 #10 与 #9(normalize 归一)。

🔴 两类资产从 2026-08-29(rvh-merge-plan T4-5)起判据不同,别混着说

  • 预装库 assets/databases/lampio_dict.db = 一个 symlink,指向 ../../../src-tauri/assets/lampio_dict.db。双端不再是「两份相等」而是只有一份 ⇒ 本端没有可以漂移的对象,也没有任何要接收 / 校验 / 覆盖的动作。 实测 flutter build bundle 跟随 symlink(产物是真实 65,777,664 B 的 db,AssetManifest.bin 有它), 运行时照旧 rootBundle.load('assets/databases/lampio_dict.db'),与 symlink 无关。 ⚠️ Windows clone 上它不是文件(git core.symlinks=false 会落成 40 字节路径文本)—— 在 Windows 上碰 rvh/ 前先 git config core.symlinks true 再重新 clone。
  • assets/nlp/*.json 仍是货真价实的两份(RB build_dict 产物的副本), byte-equal 照旧靠断言守(scripts/cross-end-check.sh §C)。下面那段双端验证流程说的是这一类

2026-06-15 资产折叠后 lemma_* 三表随预装库走,JSON 降级为 pipeline 构建输入。 资产变更史(v26 的 178 个 surface 修正 / v27 的 4 条补漏 / 各版 SHA)→ docs/cross-end/24-rb-lemmatizer-asset-v26-reseed-handoff.md · docs/cross-end/28-rb-lemmatizer-residual-v27-confirmation.md · CHANGELOG.md合仓后 RB 是唯一产源tools/vocabulary_builder_v3/),RVH 纯消费。

RVH 落地lib/core/nlp/lemmatizer.dart 词典优先 4 层 (Layer 1 surface_to_base → Layer 2 base_forms 自映射 → Layer 3 _proposeStem + base_forms 裁决 → Layer 4 fallback)。 Flutter 启动期 loadLemmatizerFromAssets() 从 rootBundle 读预装库;CLI / test 用 dart:io File 加载。 assets/nlp/*.json 仅作 CLI/测试 fixture(RB build_dict 产物的 byte-equal 副本,不是 shipped runtime 资产), 见 assets/nlp/README.mdLayer 3/4 算法代码两端各一份的对齐义务照旧(折叠只合并数据)。

🔒 本端独有:跑 lemmatizer JSON 双端 byte-equal 验证时的两个陷阱

bash
# RB 端
cargo run --manifest-path src-tauri/Cargo.toml --example phase0_normalize --release --quiet \
  < rvh/test/data/lemma-regression.txt > /tmp/rb-output.csv

# RVH 端:必须编成可执行文件跑,不要用 `dart run`(build hooks 会往 stdout 写东西,污染 CSV)
# ⚠️ `dart compile exe` 自 Dart 3.10 起对本项目失效:
#      'dart compile' does not support build hooks, use 'dart build' instead.
#    (objective_c 等依赖引入了 build hooks。)改用 `dart build cli`:
(cd rvh && dart build cli -o /tmp/rvh-p0) && \
  /tmp/rvh-p0/bundle/bin/phase0_normalize < rvh/test/data/lemma-regression.txt > /tmp/rvh-output.csv

diff /tmp/rb-output.csv /tmp/rvh-output.csv   # 必须为空

dart build cli 的产物是目录<out>/bundle/bin/<入口名> + <out>/bundle/lib/*.dylib), 不是单文件;入口取 pubspec.yaml 配的默认值(本项目 = bin/phase0_normalize.dart)。 该命令当前标注 preview,提示打在 stderr,不污染 stdout。

🔴 另一组证据(docs/cross-end/ 下的 phase0_inputs.txt + rb.csv + rvh.csv)用之前先看 layer 词表: 当前应只出现 dictionary / base / fallback(+ empty)。出现词表以外的标签、或这几个标签消失 = 实现换代而文件没跟上,立刻用上面的命令重新生成,别拿它当验收依据。 这不是多虑:那组文件曾在旧架构(suffix/irregular/exception)上停了整整四个月无人察觉 —— 失效方式极隐蔽,两端一起陈旧,故两个 csv 彼此仍逐字节相同 → 任何「diff 两个 csv」的检查照旧假绿。 经过见 docs/cross-end/26-rvh-phase0-evidence-stale-report.md

关联docs/cross-end/13-lemmatizer-fold-reseed-handoff.md · docs/cross-end/27-rvh-lemmatizer-residual-scan-handoff.md · docs/cross-end/09-phrase-asset-fix-reseed-handoff.md · 计划 rvh/docs/plans/lemmatizer-dictionary-refactor-rvh.md

#5f Sync push 必经 onConflict(非 PK UNIQUE 表)

🔒 RB 侧没有对应的编号红线,本条是这条约束在本仓的唯一叙述 —— 别删成指针。

规则:所有 sync push 路径必须from(table).upsert(rows, onConflict: '...') (supabase_flutter SDK,等价 PostgREST ?on_conflict={cols} + Prefer: resolution=merge-duplicates), onConflict 列必须匹配目标表的非 PK UNIQUE 约束列清单。仅 PK(id) 冲突的表可省略。

理由:PostgREST 默认按 PK(id) 解决冲突,而 v43 schema 引入了多张同步表的非 PK UNIQUE:

Supabase 表UNIQUE 约束必传 onConflict
user_learning_entries(user_id, word)'user_id,word'
user_word_page_links(learning_entry_id, reading_page_id)'learning_entry_id,reading_page_id'
user_known_words(user_id, word)'user_id,word'
user_word_cloze_contexts(user_id, word, sentence)'user_id,word,sentence'
user_reading_notes仅 PK(id)不传
user_reading_pages仅 PK(id)不传

跨端并发推同一逻辑行(不同 id)会撞 PostgreSQL 23505 / HTTP 409,UPSERT 不触发 → push 失败。 Bug G(2026-04-30 RB 端 due 测暴露)实证:双端真并发 add 同词,间隔 28ms 即触发。 早期 Bug D / Bug F 未修时,RVH push 永久失败(user_id NULL 被 NOT NULL/RLS 拒)掩盖了此问题, 双端 user_id 真值首次对齐后立刻浮现。

RVH 落地supabase_sync_datasource.dart::pushRows 接受 String? onConflictsync_repository_impl.dart 的 5 个 push 函数按表的 UNIQUE 约束传参。 删掉了原来 catch 23505 + UPDATE WHERE id 的兜底(在 (user_id, word) UNIQUE 上 silent fail, 且对端 UPSERT 改写 id 后 UPDATE 找不到行)。

守卫判据(⚠️ 见节首「谁在执行」):

bash
# pushRows 的所有 call site 必须显式传 onConflict 或注释「仅 PK 冲突」
grep -A 3 "pushRows(" lib/features/sync/data/repositories/sync_repository_impl.dart \
  | grep -E "user_(learning_entries|word_page_links|known_words|word_cloze_contexts)" \
  | xargs -I{} grep -q "onConflict:" {} || echo "❌ push call site missing onConflict"

关联:RB 修复 commit a1b2f09push.rs::post_rows?on_conflict={cols}); cross-end log docs/cross-end/archive/01-vocabulary-word-pk-log.md「Bug G」段。

#5g word_cloze_contexts 语境池:双端共写 + pull 后必经 reconcile 重新封顶

契约 → 根 CLAUDE.md §3「Auth & Sync」的 cloze 段(一词多语境池 · 应用层封顶 5 · 双端共写 · 合并后重新封顶 · 两条只对采集侧成立的约束 ①可挖空性 ②created_at 必须 UTC)。 双端共写的裁决 → docs/cross-end/23-rb-ocr-cloze-context-decision.md

RVH 落地

  • sync:sync_repository_impl.dart::{_pushWordClozeContexts,_pullWordClozeContexts,_reconcileClozePool,_asciiLower}
  • 采集(v60):ocr_cloze_gate.dart(闸门,纯函数)→ filter_confirmation_notifier.dart::_saveOcrClozeContexts(收割时收集候选、跨页择优)→ cloze_pool_writer.dart::insertAll(去重 / 复活 / 封顶 / UTC 时钟,逐条镜像 RB crud.rs::insert_cloze_context

⚠️ 封顶 5 写在三处ClozePoolWriter.poolCap · sync_repository_impl._clozePoolCap · RB CLOZE_POOL_CAP。改一处必须改三处。

🔒 本端独有(RB 侧没有 OCR 腿,这些判据只长在 RVH)

  • ASCII 折叠:去重键必须用 _asciiLower(仅 A–Z→a–z),匹配 SQLite COLLATE NOCASE + RB to_ascii_lowercase禁用 Dart String.toLowerCase()(Unicode 感知 ⇒ 去重键错位 ⇒ 收敛发散)。
  • push mapped 字典禁含 source_platform(远端 user_word_cloze_contexts 无此列,误加 PGRST204 全炸)。
  • 删词 / 切用户必须软删该词语境(镜像 RB clear_cloze_context_for),否则下一轮 pull 当「remote 未删活行」复活。
  • source_url 对 RVH 采集行必须留 NULL,🚫 禁止造合成 URL(如 rvh-ocr://<page_id>): RVH 的 OCR reading_pages 本身 source_ref 就是 NULL,合成值无主;RB 的页级级联清理按 source_url = reading_pages.source_ref 精确等值匹配,合成值要么白造、要么恰好命中某页 ⇒ RB 把该语境错误归组到那一页并尝试打开它。NULL 不影响 RB 渲染(归 orphan 桶排在尾部,卡面照常挖空)。

    附带知情(既有行为,不是破口):级联键是 source_url,NULL 匹配不上任何行 ⇒ 在 RVH 删掉某张扫描页不会带走它贡献的 cloze 行;可清理路径只剩删词、句级删除、封顶轮换。

  • C2 每个 word 每次收割最多写 1 条:同页多次出现的词若逐词位写行,一次收割就能吃掉多个坑位 (最坏 5 个全占,把该词历史语境整体清空)。按闸门得分取最优一行。
  • C1 在实现里是两条断言:① normalize(surface) == normalize(word)(挡住 OCR 拼写纠错改过的词: 句里是 heer、要存的是 beer);② 直接调 ClozeBuilder.buildContextCloze 验可挖空性 —— 用 RB 将要跑的同一套算法,不是自写近似正则。

    🔑 为什么必须在采集侧执行、不能指望本端 UI 暴露问题:RVH 自己不挖空_clozeBlankMode = false,只高亮目标词),buildContextCloze 返回 null 时抽屉退化成纯文本、 句子照样完整显示 —— 本端看不出任何异常。同一条行到了 RB 那边是「整条语境不可见却占着坑位」。 这道闸是替对端把的关,本端正常显示不构成它合格的证据。

  • 闸门方案length ≥ 25 ∧ 行左边缘落正文主列 ∧ 行末非连字符)RB 已认可无修改意见; 行末连字符那条是刚性需求不是可选优化 —— C1 的断言挡不住断词行(断出的 ball 确实在句里)。

    ⚠️ 2026-08-27 真机验证后的两处修正:① 连字符必须紧贴词尾\w[-‐‑–]$)—— 只看行末字符会把 -(破折号标点)当断词,实测一行 "...his release -" 误拒了 months, 连带按规则 3b 拒掉 5 个行首词,27 词里 6 个白丢语境;② 主列规则默认关闭OcrClozeGate.enableMainColumnRule = false,实现与单测保留)—— 词位 x 由 ML Kit 行框按 字符比例插值,实测一页 18 行里 2 行报偏 140–240px;而它要挡的邻页碎片本就过不了长度闸 ⇒ 代价可测、收益存疑。

    🔴 改闸门前先读 ocr_cloze_gate.dart 的代码注释 —— 三处判据(断词的两个方向只挡得住前半截、 主列聚类的阈值基准必须是版面内容宽度而非行左边缘跨度、ocr_word_positions.line_x 实际存的是 word_x 故行左边缘要按 line_text 分组取 min(x))比条文细,且都实测踩过。 那些细节的真相源是代码注释,不在本文件

待办:OCR 采集的跨端真机实测尚未做(RB 回执 §7 三步)。

守卫判据(⚠️ 见节首「谁在执行」):

bash
# C3:采集侧时钟必须 UTC —— 裸 DateTime.now() 会让本端的行系统性挤掉对端的行
grep -q 'now ?? nowUtcIso()' \
  lib/features/vocabulary_notebook/data/datasources/cloze_pool_writer.dart \
  || (echo "❌ #5g C3: cloze created_at 不是 UTC 时钟"; exit 1)
# 排除注释行 —— 否则会被「解释为什么不能用 DateTime.now()」的那段注释本身误伤(同 #5d)
if grep -v -E '^\s*(//|\*|/\*)' \
     lib/features/vocabulary_notebook/data/datasources/cloze_pool_writer.dart \
     | grep -q 'DateTime\.now()'; then
  echo "❌ #5g C3: cloze 写入路径出现裸 DateTime.now()"; exit 1
fi

# C1:可挖空性必须用 RB 的同一套算法验,不能换成自写正则
grep -q 'ClozeBuilder.buildContextCloze' \
  lib/features/vocabulary_notebook/domain/services/ocr_cloze_gate.dart \
  || (echo "❌ #5g C1: 闸门没走 ClozeBuilder 验证可挖空性"; exit 1)

# 采集必经闸门:收割路径不得绕过 OcrClozeGate 直接写池
grep -q 'OcrClozeGate.pickBest' \
  lib/features/vocabulary_filtering/presentation/providers/filter_confirmation_notifier.dart \
  || (echo "❌ #5g: 收割路径绕过了闸门"; exit 1)

关联:RB 等价实现 src-tauri/src/commands/sync/push.rs + src-tauri/src/commands/sync/pull/ · 交接 docs/cross-end/16-rb-cloze-context-sync-handoff.md(⚠️ 其「池子归属」一节已被 doc 23 取代)· 请求 docs/cross-end/22-rvh-ocr-cloze-context-review-request.md · 落地计划 rvh/docs/plans/archive/word-cloze-contexts-sync-rvh-plan.md(2026-08-30 归档)。

#5h push dirty-check 必须按 user_id 过滤(v59)

契约 → 根 CLAUDE.md §4 #5i(含「括号是最危险处」「用 = ?1 不用 IS ?1」 「不要把 NULL 行回填成当前 user_id 再推」三款,两端同文)。

RVH 落地:6 个 push 函数(_pushBooks / _pushReadingPages / _pushLearningEntries / _pushWordPageLinkRelations / _pushKnownWords / _pushWordClozeContexts)的 dirty-check SELECT 带 WHERE user_id = ?(绑 _userId),原有 dirty 条件整体加括号。 getSyncStatus 的 pendingCount 口径与之逐表一致。

🔒 本端独有:触发路径不依赖任何竞态(这点很重要,别只盯着 auth 时序)

  • known_words.user_id 是 RVH schema 里唯一可空的列,而 clearLearningDataForUser 从前按 user_id = ? 删它 ⇒ NULL 行任何用户都删不掉、永久残留,被之后每一个登录用户轮流认领
  • clearLearningDataForUser 若抛错(DB busy 等)会 rethrow,前一个用户的行原样留在库里
  • auth 广播先于清库的窗口(auth_repository_impl.dart:39 vs :42)只是又一条路径, 且它是否赢得竞争从未被实测证明 —— 修复不建立在这个假设上

v59 实测复现(test/features/sync/push_user_scoping_test.dart,驱动真实 syncNow()): 本地 6 张表各放 1 行 userA + 1 行 userB 的脏行,以 userB 身份同步 → 日志 pushed=1212 行全部带 user_id=userB 上行,6 张表无一幸免。

NULL 孤儿行清扫(v59.2)clearLearningDataForUserknown_words 删除条件由 user_id = ? 改为 user_id = ? OR user_id IS NULL,连带清掉归属不明的孤儿行 (读侧看不见、push 也推不走的纯死重,且会让 syncNow 的 force-full-pull 兜底被永久禁用,见 #5d)。

⚠️ 不要照抄 RB 的 user_id IS NULL OR user_id != ?1:两端函数语义相反。 RB 的 clear_learning_data_if_user_changed到来用户(删「不属于他的」); RVH 的 clearLearningDataForUser离开用户(_detectUserSwitchAuthNotifier.signOut 两个调用点都传 lastUserId)。字面照搬会变成「删掉到来用户的数据、反而留下离开用户的」,正好删反。

word_cloze_contexts 不需要这个 OR:其 user_id 是 NOT NULL(#5b), 加了是永不命中的死谓词;schema 若放宽须同步补上。

与 auth 时序的关系_controller.add(user) 保持在 _detectUserSwitch 之前,不要调换。 push 按 user_id 过滤(本条)+ watermark per-user(#5d)之后,清库与新用户首次 sync 读写的是 不相交行集,先后顺序不影响正确性;而调换会让清库异常导致广播丢失 ⇒ 登录成功却卡在登录页。 理由详见 auth_repository_impl.dart 构造函数注释。

🔴 2026-08-31(#6i)起判据换型:六份内联副本已收敛成常量 _kUserScopedDirty 抽常量的直接理由是 #6i 要让 pull 侧问的问题与 push 会做的事共用同一句 SQL;顺带发现 那六份已经漂成三种形状(4 份缺 updated_at IS NOT NULL、cloze 那份完整、 word_page_links 那份整支 updated_at 条件都不在 —— 只有前者是行为等价的, 后者真的漏推过东西,见 docs/cross-end/49-rvh-tombstone-vs-remote-alive-confirmation.md §2)。

⚠️ 旧判据(数 WHERE user_id = ? + 下一行以 AND ( 开头)在今天会恒绿 —— 那个字面形状在文件里一次都不出现了(它在常量里)。换型后的判据不再是 grep, 而是真的每次 flutter test 都跑的 test/features/sync/tombstone_remote_alive_test.dart结构守卫 组:

断言注入什么会让它红(均已实证)
G1a常量本体形状:user_id = ? 在最外层 · 脏条件整体括起来 · updated_at IS NOT NULL AND ... 在场去掉外层括号 / 去掉某一支
G1b常量之外(剥注释后)不许再出现 synced_at IS NULL 片段pull 侧手抄一份谓词
G1c6 个 _pushXxx + _tombstonePendingPush 都引用它,且引用处恰好 8 个(6 push + pendingCount + 待推判据)某个 push 手搓回内联 / 加第 7 张表漏引用

⚠️ 不能改成数全文件的 WHERE user_id = ?_reconcileRowCounts / _reconcileClozePool 等处有正当的、与脏检查无关的 user_id 过滤(实测 5 处), 那么写会恒红。

关联:RVH 回归 test/features/sync/push_user_scoping_test.dart · v59.2 清扫回归 test/features/sync/watermark_per_user_test.dart · RB 侧核实与修复交接 docs/cross-end/20-rb-push-user-scoping-handoff.md (RB 已于 2026-08-05 修完,见根 CLAUDE.md §4 #5i 的回归测试清单)。

#6b Sync pull 父表禁 INSERT OR REPLACE / ConflictAlgorithm.replace

🔒 RB 侧没有对应的编号红线(RB pull.rs 一开始就用 ON CONFLICT 风格,从未踩坑), 本条是这条约束在本仓的唯一叙述 —— 别删成指针。

规则:被其他表 FK 指向(且 FK 带 ON DELETE CASCADE)的父表,sync pull 路径禁用 SQLite INSERT OR REPLACE / sqflite ConflictAlgorithm.replace,必须用 INSERT ... ON CONFLICT(id) DO UPDATE SET ...。叶子表也统一改风格防红线漂移。

理由INSERT OR REPLACE 的 SQLite 语义是 DELETE 旧行 + INSERT 新行; DELETE 触发 ON DELETE CASCADE 链 ⇒ 子表被级联删除;新行插入后子表已没了, 即使同循环重新拉子表也存在 race 窗口(子表行 updated_at 未变的永远 miss)。 Bug E(2026-04-29 立项 / 04-30 修复)实证 FK 链:

reading_notes (id PK)
  ←─ reading_pages.note_id REFERENCES reading_notes(id) ON DELETE CASCADE
       ←─ word_page_links.reading_page_id REFERENCES reading_pages(id) ON DELETE CASCADE

learning_entries (id PK)
  ←─ word_page_links.learning_entry_id REFERENCES learning_entries(id) ON DELETE CASCADE

数据丢失矩阵

场景是否丢失
子表 updated_at > watermark 同循环被拉⚠️ 临时丢失 → 后续 pull 重建(race 窗口)
reading_pages 远端未变,word_page_links 远端变了❌ word_page_links 永久丢失(FK 指错)
仅 reading_notes 标题变了,子表远端均未变❌ word_page_links 永久丢失(不在批次)

RVH 落地sync_repository_impl.dart):

  • _pullReadingNotes(父表)→ ON CONFLICT(id) DO UPDATE SET title = excluded.title, ...不写 note_type / created_at(immutable,保留首次 INSERT 值)
  • _pullReadingPages(父表)→ 同上,不写 note_id / linked_at / created_at
  • _pullKnownWords(叶子表)→ 统一风格,仅更新 reason / updated_at / synced_at
  • _pullNotebookEntriesINSERT OR IGNORE + 单独 UPDATE 路径(已合规)
  • _pullWordPageLinksINSERT OR IGNORE(叶子表,已合规)
  • app_metadataINSERT OR REPLACE 不在 sync pull 路径,保留

SET 子句 review 纪律:每个 ON CONFLICT DO UPDATE 的 SET 列必须严格 review, immutable 列不写 SETcreated_at / note_type / source_platform / linked_at / source / added_at),否则对端误改这些列时会覆盖本地首次值。

守卫判据(⚠️ 见节首「谁在执行」):

bash
# sync pull 路径不得出现 INSERT OR REPLACE INTO <父表>
for table in reading_notes reading_pages learning_entries; do
  if grep -q "INSERT OR REPLACE INTO ${table}" \
       lib/features/sync/data/repositories/sync_repository_impl.dart; then
    echo "❌ Bug E regression: pull path uses INSERT OR REPLACE on parent table ${table}"
    exit 1
  fi
done

关联:cross-end log docs/cross-end/archive/01-vocabulary-word-pk-log.md「Bug E」段。

#6c 封面 BLOB 跨端契约(v47 落地)

🔒 RB 侧没有对应的编号红线,本条是这份 7 款契约在本仓的唯一叙述 —— 别删成指针。

reading_notes.cover_image_data 是字节直传字段(PostgreSQL bytea / SQLite BLOB)。 任何 push/pull 路径必须遵守:

  1. 三字段名cover_image_data / cover_image_mime / cover_source_url —— 命名敏感,差一个字符 push/pull 全炸
  2. PostgREST bytea hex transit
    • Push:Uint8List'\x' + 小写 hex(Dart 字面量 r'\x' 实际两字符 \x, 与 RB Rust format!("\\x{}", hex::encode(b)) 完全等价)
    • Pull:JSON 字符串 → startsWith(r'\x') → 去前缀 → hex decode → Uint8List
    • 不是 base64 / 0x 前缀 / 大写 hex
  3. MIME 始终 image/jpeg:双端都压 192×192 JPEG q85;用户上传 PNG/HEIC/WebP 也得在 RVH CoverImageProcessor.compress decode → resize → encodeJpg → 写库。100KB 上限 → 拒绝,三字段全 NULL
  4. 三字段同进同出cover_image_data 为 NULL 时 mime / source_url 也是 NULL; 绝不允许 mime/source_url 有值但 data NULL 的组合
  5. ON CONFLICT SET 必须包含三 cover 列_pullReadingNotes 缺一不可,否则远端封面更新永远拉不下来
  6. 父表禁 INSERT OR REPLACE(继承 #6b)
  7. cover_source_url 可保留 NULL:RVH 用户上传 / RB webview 多源抓取时无单一原始 URL,写 NULL 合规

落地:RVH lib/core/services/cover_image_processor.dartimage 包)+ sync_repository_impl.dart::_bytesToPostgresHex / _postgresHexToBytes; RB 等价代码 src-tauri/src/commands/sync/push.rs::push_reading_notes + src-tauri/src/commands/sync/pull/; RB 侧封面获取走 content-script webview canvas。

守卫判据(⚠️ 见节首「谁在执行」):

bash
# push 路径必须 hex 编码 cover_image_data,不能直接传 Uint8List
grep -A 20 "_pushBooks\|push_reading_notes" \
  lib/features/sync/data/repositories/sync_repository_impl.dart \
  | grep "cover_image_data" \
  | grep -q "_bytesToPostgresHex\|hex::encode" \
  || (echo "❌ push path missing hex encoding for cover_image_data"; exit 1)

# pull 路径 ON CONFLICT SET 必须含三 cover 列
for col in cover_image_data cover_image_mime cover_source_url; do
  grep -A 30 "_pullReadingNotes" \
    lib/features/sync/data/repositories/sync_repository_impl.dart \
    | grep -q "${col} = excluded.${col}" \
    || (echo "❌ ON CONFLICT SET missing ${col}"; exit 1)
done

#6d last_opened_at RVH pull-only(v52 落地)

契约 → 根 CLAUDE.md §3「Merge 策略」:reading_pages.last_opened_atMAXRVH 无阅读功能,只 pull 不 push 此列

RVH 落地sync_repository_impl.dart::_pushReadingPages 的字段白名单内不含 last_opened_at (显式 mapped 字典,非 SELECT * 过滤),字典上方有 🛡️ 注释守门。pull 侧 MAX merge:

sql
last_opened_at = CASE
  WHEN excluded.last_opened_at IS NULL THEN reading_pages.last_opened_at
  WHEN reading_pages.last_opened_at IS NULL THEN excluded.last_opened_at
  WHEN excluded.last_opened_at > reading_pages.last_opened_at
    THEN excluded.last_opened_at
  ELSE reading_pages.last_opened_at
END

RFC3339 字典序 = 时间序(与 #5d watermark 同模式)。Graceful 降级:Supabase 端若未执行 ALTER TABLE user_reading_pages ADD COLUMN last_opened_at,pull 字段缺失得 null, CASE WHEN 自然保留本地 NULL,不报错。

🔒 本端独有的消费方与排序陷阱:2026-08-14 起阅读笔记列表页的「最近阅读」排序(默认)

  • 列表项副标题相对时间消费该列,走 getLastOpenedAtByNoteSELECT note_id, MAX(last_opened_at) ... GROUP BY note_id,一次取全表避免列表 N+1)。 RVH 自采的笔记该字段恒为 NULL(本端无 touch 路径),排序上统一落到有值的之后、 组内保持 updated_at DESC —— 实现是分组拼接而非整体 sort (Dart List.sort 不稳定,整体排会打乱「从未阅读」那组的次序)。 回归 test/features/books/reading_note_list_state_test.dart

历史:v52 落地时无 UI 消费方 —— 最初设计在 review 卡 SourceInfoBar 加「🖥 X 时间前」徽标, 2026-05-22 评审后认定 review 场景下该信息价值薄(用户不在阅读,时间戳与记忆无关), 删除 UI 渲染只保留数据层。

#6e reading_notes 删除必走软删,且必须手工级联软删子树(v57.1,RVH 先行)

契约src-tauri/CLAUDE.md §2 #6a(父行删除必须同事务显式软删整棵子树,禁靠 FK CASCADE)

  • #6b(push payload 的 deleted_at 传本地真值,禁恒写 null)+ #6c(pull 父行守卫三态)。 本条 RVH 先行,交接 docs/cross-end/19-reading-notes-tombstone-handoff.md §4。

RVH 落地

  • reading_note_datasource_impl.dart::deleteBook —— 单事务,笔记本体先行noteRows == 0 在事务内抛(异常回滚,不留「子表已软删、笔记没墓碑」的半截状态)
  • 8 处读查询加 deleted_at IS NULL
  • sync_repository_impl.dart::_pushBooks —— dirty-check 加墓碑分支;payload 的 deleted_at 传本地真值
  • sync_repository_impl.dart::_pullReadingNotes —— 三分支模板,分支①连带软删本地子树防对端漏传
  • _pullReadingPagesbookExists 守卫加 AND deleted_at IS NULL

🔒 本端独有:部署顺序是硬约束,不可颠倒 —— ① Supabase ALTER → ② 双端 pull → ③ 双端 push。 ①最先是因为 PostgREST 对未知列返回 PGRST204,整批 push 失败;②可早于③(pull 读缺失字段得 null,无害); 反向部署会让「对端写墓碑而本端无视」变成真实数据问题。

已知遗留:本红线立项前双端删除产生的 Supabase 行 deleted_at 仍是 NULL = 活跃, 存量僵尸行需手工补墓碑。

守卫判据(⚠️ 见节首「谁在执行」):

bash
F=lib/features/reading_notes/data/datasources/reading_note_datasource_impl.dart
# 删除路径不得出现硬删
grep -qE "db\.delete\(\s*'reading_notes'" $F \
  && (echo "❌ #6e regression: reading_notes hard delete"; exit 1) || true
# push payload 必须带 deleted_at 真值(窗口 60:mapped 字典离函数头 ~41 行,留余量)
grep -A 60 "_pushBooks(Database" lib/features/sync/data/repositories/sync_repository_impl.dart \
  | grep -q "'deleted_at': r\['deleted_at'\]" \
  || (echo "❌ #6e: push payload missing deleted_at real value"; exit 1)
# _pullReadingPages 的父行守卫必须存在且**不放行墓碑父行**。
# ⚠️ 2026-08-27 守卫改三态(#6f)后,原来那句
#      grep -q "FROM reading_notes WHERE id = ? AND deleted_at IS NULL"
#    已失效 —— 现在的形状是 `_parentState(db, _kNoteParentSql, ...)`,判据挪进了常量。
grep -q "_parentState(db, _kNoteParentSql, r\['note_id'\])" \
  lib/features/sync/data/repositories/sync_repository_impl.dart \
  || (echo "❌ #6e/#6f: _pullReadingPages 父笔记守卫丢了"; exit 1)

#6f 墓碑父行不算存在:删除子树按父表算 + pull 父行必须三态(v61)

契约src-tauri/CLAUDE.md §2 #6a(写侧子树)+ #6c(读侧三态,含「压成两态的两种方向都是缺陷」 与「watermark 不回头 ⇒ 永久丢行」)。裁决单 docs/cross-end/34-rb-tombstone-parent-adjudication-handoff.md

⚠️ 「软删子行必须 bump updated_at」那一款归 #6g 的规则 W 统一管, 判据与 CI grep 以 #6g 为准,别在两处各写一份。

RVH 的落地形状reading_pages.note_idword_page_links.{learning_entry_id,reading_page_id} 三个父列都是 NOT NULL,所以没有 RB pull_reading_pages 那种「照落 + 置 NULL」的选项 —— 缺失 → skip(FK 落地限制)、墓碑 → skip 远端活行。两态动作相同但理由不同, 代码里仍显式分开写,好让「父列改可空时该分头处理」一眼可见。 远端墓碑行不经过守卫(各 _pullXxx 的墓碑三分支排在守卫之前),照常落地。

  • 写侧:notebook_datasource.dart::deleteNotebookEntry(词条 + links + cloze,单事务)· app_database.dart::prunePreinstalledVocabulary(v28 起就合规,可作模板)· reading_note_datasource_impl.dart::deleteBook(笔记子树,v61 补 updated_at bump)
  • 读侧:sync_repository_impl.dart::_parentState + _pullReadingPages / _pullWordPageLinks 两处调用点

🔒 本端独有:撤销必须与删除对称(2026-08-27 真机验证补) restoreNotebookEntry(UI 上删除后 4 秒 SnackBar 的 Undo)必须在同一事务里把同一次删除带走的 子行还回来 —— 按 entry 的墓碑时间戳精确匹配(deleted_at = <stamp>),不是无条件 deleted_at IS NOT NULL(否则会错误复活更早因 deleteBook 等原因软删的行)。

🔴 这条是 #6f 落地当天漏掉的:删除侧改成带走子树后,撤销侧仍只清 entry 的 deleted_at ⇒ Undo 之后「词回来了、但 6 条来源和 1 条语境永久没了」。用户看得见词回来、看不见来源没了, 属静默损失。#6f 之前删除不碰 links,撤销才恰好对称 —— 是本次改动新引入的不对称,不是既有缺陷。 RB 侧没有这条路径(其复活走 save_wordON CONFLICT DO UPDATE SET deleted_at = NULL), 所以 RB doc 34 §5 承诺的「删了又加回来,来源还在」在 RVH 得靠本方法自己兑现。

不适用的地方(判过了,别"顺手"补)

  • 标「已认识」local_known_words_datasource.dart::addOrReviveUserWord)在 RVH 只写 known_words,完全不碰 learning_entries ⇒ 不是墓碑路径,无子树义务。这与 RB 不同 (RB 的 add_known_word 会软删 learning_entries),是产品语义差异不是遗漏。 日后若改成「标已认识即从生词本移除」,本条立刻适用。
  • word_cloze_contexts 的 pull 无父行守卫 —— 该表无 FK、按 word 关联,RB 侧同样无条件 INSERT。 单方面加守卫会造成两端收敛规则分歧(#5g 的 reconcile 前提是两端同规则),故不加

存量孤儿:写侧修复只管今后的删除。此前的存量孤儿仍在 Supabase 里,巡检 (supabase/sql/monitoring.sql §3b tombstone_parent两端共用一份,RVH 不重复实现) 一上线就报 warn —— 那是真的,不是误报。清理由人执行、先备份,形状是打成墓碑 (deleted_at = now() + bump updated_at),不要 hard DELETE。 跨端并发的残余(一端删词、另一端同时存同一个词)是设计残余不是 bug, 写侧根治不了,兜底就是那条巡检 —— 别为它加更多写侧逻辑。

守卫判据(⚠️ 见节首「谁在执行」):

bash
F=lib/features/sync/data/repositories/sync_repository_impl.dart
ND=lib/features/vocabulary_notebook/data/datasources/notebook_datasource.dart
BD=lib/features/reading_notes/data/datasources/reading_note_datasource_impl.dart

# 规则 1:删词必须带走两样子行
grep -q "UPDATE word_page_links SET deleted_at = ?, updated_at = ?" $ND \
  || (echo "❌ #6f: deleteNotebookEntry 没带走 word_page_links"; exit 1)
grep -q "UPDATE word_cloze_contexts SET deleted_at = ?, updated_at = ?" $ND \
  || (echo "❌ #6f: deleteNotebookEntry 没带走 word_cloze_contexts"; exit 1)

# 规则 2:软删 word_page_links 的地方一律不许只写 deleted_at。
#   ⚠️ 别用 `grep -c` 数总量 —— 数量会随重构漂移。直接查反例形状:
#   `SET deleted_at = ?` 后面**不接** `, updated_at`(含跨行的 heredoc SQL)。
if grep -rn "UPDATE word_page_links SET deleted_at = ?$" $F $ND $BD \
   || grep -rn "UPDATE word_page_links SET deleted_at = ?\s*$" $F $ND $BD; then
  echo "❌ #6f 规则 2: 软删 word_page_links 漏了 updated_at bump(回声环)"; exit 1
fi

# 规则 3:守卫必须走单点 _parentState,不许手搓回裸 COUNT(*)
grep -q "enum _ParentState { alive, tombstoned, missing }" $F \
  || (echo "❌ #6f: _ParentState 没了"; exit 1)
grep -q "_parentState(db, _kEntryParentSql, r\['learning_entry_id'\])" $F \
  || (echo "❌ #6f: _pullWordPageLinks 父词条守卫不是三态"; exit 1)
grep -q "_parentState(db, _kPageParentSql, r\['reading_page_id'\])" $F \
  || (echo "❌ #6f: _pullWordPageLinks 父页守卫不是三态"; exit 1)
# 裸 COUNT(*) 不许在 _pullWordPageLinks 里复活。
# ⚠️ 判据必须**限定在这个函数体内**:全文件搜 `SELECT COUNT(*) FROM learning_entries`
#    会误伤 _pullLearningEntries 里那处**合法**的「本地行存不存在」检查
#    (它决定要不要写墓碑,不是父行守卫)——实测这条 grep 的第一版就是这么假红的。
if sed -n '/_pullWordPageLinks($/,/^  }$/p' $F \
   | grep -v -E '^\s*//' | grep -q 'COUNT(\*)'; then
  echo "❌ #6f: _pullWordPageLinks 里裸 COUNT(*) 父行守卫复活了"; exit 1
fi

关联:RVH 回执 docs/cross-end/36-rvh-tombstone-parent-confirmation.md · 回归 test/features/sync/tombstone_parent_test.dart(10 用例,三处注入缺陷实证过)。

#6g 墓碑行的 synced_at 怎么写 —— W / P1 / P2 / P3 四条(v62)

契约 → 根 CLAUDE.md §4 #6d(两端同文;含四条子规则、NULL 陷阱、 「禁止 parse 成 DateTime 再比」、回声环症状)。契约单 docs/cross-end/38-rb-tombstone-synced-at-contract-handoff.md

「脏」的定义两端同文(RVH 6 个 _pushXxx 的 WHERE / RB push.rs::USER_SCOPED_DIRTY):

sql
synced_at IS NULL
OR (updated_at IS NOT NULL AND updated_at > synced_at)
OR (deleted_at IS NOT NULL AND (synced_at IS NULL OR deleted_at > synced_at))

RVH 落地(四条各自的单点):

RVH 落点
W 本端软删 deleted_atupdated_at 写同一个参数全仓 11 处软删站点:notebook_datasource.dart::deleteNotebookEntry · reading_note_datasource_impl.dart::deleteBook · app_database.dart::prunePreinstalledVocabulary · local_known_words_datasource.dart::softRemoveForUser · cloze_pool_writer.dart · sync_repository_impl.dart::_reconcileClozePool
P1 pull 墓碑分支的 synced_at 只由远端行自己的列拼出sync_repository_impl.dart::_remoteSyncTs 单点,回落链 updated_at → created_at → ''终点刻意是空串不是 now
P2 synced_at = MAX(...),操作数不许为 NULL6 处墓碑分支的 synced_at = MAX(?, ?);Dart 侧算用 _maxIsocompareTo
P3 push 成功后的 mark 也要「写完立刻 clean」sync_repository_impl.dart::_markSynced

🔒 本端独有的两项证据(RB 侧没有,别删)

  • 触发门槛实测 27 秒(2026-08-27 真机反算,见 docs/cross-end/39-rvh-tombstone-synced-at-confirmation.md §5.3)。 ⚠️ 2026-08-28(T3-8)起根 §4 #6d 已把这个数并进去,那边现在两句都在(「几秒」标注为按 autoSync 60s 估的理论最坏值、27 秒为真机实测)。本条留在这里的是反算过程,不是那个数字本身。
  • 丢 P3 在 RVH 是真实可达的,RB 侧只是防御性 —— 拉回来的墓碑一旦再次变脏,重推后永久留脏。

真机验证(2026-08-27,Android 24094RAD4C + 真实 Supabase):把一条已是墓碑user_word_page_links 行的 deleted_at 改成 2099-01-01(等价于「对端时钟远快于本端」, 比改设备时钟安全 —— 该行本就死的,且 adb 无 root 改不了时钟)。该行远端 updated_at 恰好是 NULL ⇒ 一次同时覆盖 P1 / P2 / NULL 陷阱

  • 判据分岔:本地 synced_at 落成 2099-01-01取自远端行)。旧代码会写本端 now2026-08-27T06:4xdeleted_at > synced_at ⇒ DIRTY ⇒ 进环
  • 症状级:server_updated_at8+ 轮 autoSync / 9 分钟里逐字节不变 ⇒ 一次都没重推
  • 🔑 防假绿的对照:同期 last_sync_attempt_at 每 60s 推进 —— 否则「没重推」可能只是 「压根没同步」。这个对照必须做

存量数据不需要 backfill:已写歪的行正在回声环里,而环本身保证它们每轮都会被服务端重新发回来 ⇒ 修复上线后第一轮 sync 就走进修好的墓碑分支而自愈。没在环里的行 = 没写歪 = 不用管。

守卫判据(⚠️ 见节首「谁在执行」):

bash
F=lib/features/sync/data/repositories/sync_repository_impl.dart

# P1:回落链单点必须在,且不许出现本端时钟
grep -q "String _remoteSyncTs(Map<String, dynamic> r) =>" $F \
  || (echo "❌ #6g P1: _remoteSyncTs 单点没了"; exit 1)
# ⚠️ 排除注释行 —— 否则会被「解释为什么不能用 now」的注释本身误伤(同 #5d / #5g)
if sed -n '/String _remoteSyncTs/,/^$/p' $F | grep -v -E '^\s*(//|///)' | grep -q 'now'; then
  echo "❌ #6g P1: _remoteSyncTs 的回落链里出现了本端时钟"; exit 1
fi

# P2:6 处墓碑分支必须都走 MAX(数量是硬断言 —— 加第 7 张同步表而漏改会红)
n=$(grep -c 'synced_at = MAX(?, ?)' $F)
[ "$n" = "6" ] || (echo "❌ #6g P2: 墓碑分支 MAX 数量 = $n,应为 6"; exit 1)

# P3:_markSynced 必须 MAX + 双 COALESCE(缺任一个都会踩 NULL 陷阱)
sed -n '/Future<void> _markSynced/,/^  }$/p' $F \
  | grep -q "MAX(COALESCE(updated_at, ?), COALESCE(deleted_at, ''))" \
  || (echo "❌ #6g P3: _markSynced 不是 MAX + 双 COALESCE"; exit 1)

# §4.2:禁止在 synced_at 的计算里 parse 成 DateTime
if grep -n 'DateTime.parse' $F | grep -iE 'synced|deleted|maxIso'; then
  echo "❌ #6g: synced_at 的计算里出现 DateTime.parse(时间序 ≠ 字符串序)"; exit 1
fi

# W:本端软删站点不许只写 deleted_at(全仓,不限于 sync 文件)
if grep -rn "SET deleted_at = ?[,)]*$" lib --include="*.dart" \
   | grep -v 'updated_at'; then
  echo "❌ #6g W: 有本端软删站点没同时写 updated_at"; exit 1
fi

关联:RVH 请求 docs/cross-end/37-rvh-tombstone-synced-at-handoff.md · 回执 docs/cross-end/39-rvh-tombstone-synced-at-confirmation.md · 回归 test/features/sync/tombstone_synced_at_test.dart(11 用例,五处注入缺陷实证)。

#6h next_review_date 必须带 UTC 标识符 —— 写侧与读侧一起(v63)

编号是 RVH 本地的。 RB 侧尚未立成编号条款,只记在 docs/verification/learning-loop.md §2.4 与 docs/cross-end/40-rb-learning-loop-handoff.md §3.1。 跨仓引用请写「RB doc 40 §3.1」而不是「#6h」。

规则(三条,缺一即为缺陷):

  1. 写侧 —— 凡产出 learning_entries.next_review_date 的时钟,一律 nowUtc() / DateTime.utc(...)禁止裸 DateTime.now()DateTime(...) 字面构造。哨兵走单点常量 kNeverReviewedSentinel (声明在 notebook_datasource.dart)。
  2. 读侧 —— 任何作为 SQL 参数去和该列比较的时间串,必须同样带 UTC 后缀(nowUtcIso(), 或对已有本地锚点调 .toUtc().toIso8601String() —— 换表示、不动时刻)。
  3. 判据是「有没有后缀」,不是「是不是 UTC」 —— 一个无后缀的串无法判断是哪个时刻,那就是问题本身。

理由:Dart 的 toIso8601String()local DateTime 不输出时区后缀(offset 恰为 0 的 local 也不输出), 裸串经 push 原样直达 Supabase。两端的到期判定(RB get_due_cards / RVH getDueReviews) 与 merge 判据(RB common.rs::should_take_remote_srs / RVH _pullNotebookEntries都是字符串比较 ⇒ UTC+8 下卡片晚 8 小时到期、且本端在 next_review 这一维系统性占优;负时区下方向翻转

🔴 单端自测永远看不出来:同一台机器写、同一台机器读,DateTime.parse 把裸串按本机时区 解回同一个时刻,本地自洽。RB 的六道既有守卫(SM-2 三端黄金向量 / sync-verify / sync 单测) 在同一批真实数据面前全绿 —— 没有一道在问「算出来的那个时刻是哪个时刻」。

⚠️ 两个反向 footgun(都实测踩过)

  1. 只修写侧会新造一个反向缺陷。 写侧裸串与读侧裸串此前互相抵消(对本端自写的行碰巧正确); 写侧改 UTC 而读侧不改 ⇒ due 统计从「碰巧对」变成「早 8 小时」。三处必须同批改。
  2. 「给裸串补个 Z」能把形状检查骗绿而排期依然错一个时区。 所以形状与语义 必须是两条独立断言(RB M1/M3 的分法)。

实现sm2_algorithm.dart::getNextReviewDatenowUtc())· notebook_datasource.dart::kNeverReviewedSentinelDateTime.utc(1970,1,1),加词与复活共用)· statistics_repository_impl.dart 三处绑定串。

守卫test/features/vocabulary_notebook/next_review_utc_test.dart (L-A 落库形状 / L-A0 判据自检 / L-B 语义 + 跨时区合成向量 / L-C 写侧结构 / L-D 读侧结构,八处注入实证)。 ⚠️ L-B 不能替代 RB 的 M3 —— 进程内自写数据必然自洽,M3 读的是两端真实数据、它才是最终判据。

关联:RVH 回执 docs/cross-end/42-rvh-learning-loop-confirmation.md

#6i 分支②「本地已删 + remote 活」的 skip 必须以「墓碑仍待推」为条件(2026-08-31)

契约 → 根 CLAUDE.md §4 RB #6e(两端同文;含「判据是因果的不是时序的」、 「远端赢的理由是可恢复性不对称」、以及为什么不加 revived_at 意图列)。 裁决单 docs/cross-end/47-rb-tombstone-vs-remote-alive-adjudication.md · 本端回执 docs/cross-end/49-rvh-tombstone-vs-remote-alive-confirmation.md。 🔒 跨仓引用带仓名:本条 = RVH #6iRB #6e(RVH 的 #6e 是 reading_notes 软删级联,别撞)。

RVH 落地(四个单点,缺一即漂):

RVH 落点
判据sync_repository_impl.dart::_tombstonePendingPush —— SQL 由 _kUserScopedDirty 拼出,禁止手抄
判据 + 复活 + warn_resolveTombstoneVsRemoteAlive(返回 keepSkipping / revived)—— 复活语句因此只有一处,G2 才数得动
调用形状_keepSkippingTombstone,6 处分支② 逐字同形:if (localDeletedAt != null) { if (await _keepSkippingTombstone(...)) continue; total++; }revived落到既有的双活逻辑,不复制第二份 merge
复活写法SET deleted_at = NULL, updated_at = ?, synced_at = ?,两个绑参都是 _remoteSyncTs(r)(#6g P1 禁本端时钟 + P3 写完立刻不脏)

🔒 本端独有:_pullKnownWords 必须先补三分支(RVH-0,裁决单 §5.1)

本表从前压根没有分支② —— 远端活行无条件走 ON CONFLICT(id) DO UPDATE SET … synced_at = <本端 now>。 本地行是墓碑时它不清 deleted_at(符合 #6b),却把 synced_at 刷成本端 now ⇒ deleted_at < synced_at墓碑变 clean 却从未上行 = 已认识词的删除静默丢失。 它同时破坏 #6i 的前提(clean ⇒ 服务端见过这条墓碑),所以必须先补、再落 ②b

⚠️ 触发条件要写准,否则测不出来:正常轮次里 push 排在 pull 之前,墓碑当轮就上行了,够不到这条。 真会咬的是两种 —— ① 本条红线要治的分歧场景本身;② 该轮 push 失败或被 _batchLimit 推迟、 而同一轮 pull 又恰好把这行带了下来:一次网络抖动就足以永久吃掉一次删除T7 的夹具用的是 ②(failPush),别用「push 正常成功」的顺序场景。

🔒 本端独有:word_page_links 的 push 口径本轮真的变了行为

_kUserScopedDirty 之前,本表那份脏检查整支 updated_at 条件都不在。于是 restoreNotebookEntry(删词 4 秒内的 Undo)把 link 复活成 deleted_at = NULL, updated_at = now 之后,该行不脏、永不上行 —— 本端词回来了,云端与对端那条 link 还是墓碑, 且任何后续操作都修不了(复活幂等,不会再让它变脏)。收敛成统一谓词顺带堵掉了这条。

一处刻意保留的次序(不是遗漏)_pullWordPageLinks / _pullReadingPages 的复活排在 父行三态守卫之前(与 RB 逐字同序)。父行是墓碑时,被复活的子行会立刻被守卫 skip 掉, 本地留下一条挂在墓碑父下的活行(#6f 服务端巡检 tombstone_parent 会报它)。 常见路径上父词条本轮更早就被同一条红线复活了(pull 顺序:learning_entriesword_page_links), 故不为它单方面改序 —— 两端次序一致比本端局部更优先,改序要两端一起改(回执 §5 已登记)。

守卫判据test/features/sync/tombstone_remote_alive_test.dart(21 用例,13 处注入实证)。 G1 见上面 #5h 的换型表;另有:

断言注入什么会让它红
G2await _keepSkippingTombstone( 恰好 6 次(每张同步表一处);deleted_at = NULL 全文件恰好 1漏改一张表(那张表继续永久分歧,静默)/ 多出一处裸复活(绕开判据,把本端未推的删除当场撤销)
G3ON CONFLICT … DO UPDATE SET 仍不许写 deleted_at(#6b 照旧管着 upsert 那条路)把 ②b 写成 ON CONFLICT … SET deleted_at = NULL
G4复活语句里不出现 now / DateTime.,且必须含 synced_at = ?掺本端时钟(#6g P1)/ 漏写 synced_at(#6g P3)

🔒 本端独有:那句 total++ 不是装饰(2026-08-31 真机补)

watermark 的推进闸门是 totalPushed > 0 || totalPulled > 0,它刻意保守 —— 「pull 返回了、但全被守卫 skip」的行若推进水位就永久丢失(见 _ParentState 文档)。 ②b 复活了行却不计数 ⇒ 闸门不开 ⇒ 水位不动 ⇒ 下一轮把同一批重新拉一遍。 真机实测该行连拉三轮空转,最后靠行数对账的全量自愈才顶过去。 ⚠️ 下游的双活逻辑可能对同一行再计一次(六张表里四张会)—— count 的三个消费者对多计一行 都不敏感,而少计会让水位卡住,两害相权宁可多计

🔴 三条只有做过才知道的坑(前两条 RB 侧栽过、本轮实测复现)

  1. 「复活后立刻不脏」那条断言只在一个时间戳方向上有鉴别力。 远端 ts 早于本地墓碑时, 「复活漏写 synced_at」注进去之后 updated_at > synced_at 恰好为假 ⇒ 照样全绿。 本轮注入实测:no-synced-at 只让 T4(later) 变红、T4(earlier) 绿着。 一条只在时间戳方向凑巧时才成立的断言等于没有断言 —— 两个方向都要跑。
  2. T4/T5 的探针必须选 word_page_links 它的分支② 之后是 INSERT OR IGNORE(行已存在 ⇒ 整条 no-op), 复活写下的值原样可验;换成 cloze / learning_entries,后续的双活逻辑会覆盖 synced_at (分别写 syncTs / 本端 now),断言就测不到复活语句本身了。
  3. T8(②b 计入 count)的探针恰好相反,必须选 word_cloze_contexts 只有它与 learning_entries 的「本地更胜 / 无 gloss 变化」分支不计数;其余四张下游会顺手把 count 补上 ⇒ 换成它们这条断言恒绿。实测对照:去掉 cloze 的 total++ ⇒ T8 红; 去掉 reading_notes 的 ⇒ 21 例全绿。这条对照组留在注入清单里,防止后人顺手换探针。

与 cap-5 池(#5g)的相互作用 —— 已判,别加特例:②b 在 cloze 上复活一行后活跃行可能变 6, 收尾的 _reconcileClozePool 会按既有规则(created_at ASC, id ASC 留新删旧)重新封顶, 被复活的行有可能当场又被淘汰。这是 cap-5 的既定语义,且与「该行经分支③ 全新插入」结局完全相同; 关键是它收敛(那次淘汰是一条新的、脏的墓碑,会 push 上行,对端经分支① 落地)。

跨端契约镜像 backlog(2026-04-29 镜像审计的余项)

🔒 本节是「RVH 对 RB 红线的对齐状态」的唯一真相源(2026-08-29 T4-4 定死)。 别处若出现「RVH 自曝 N 处违反」这类计数,一律以本节为准 —— 那种计数会腐烂: 2026-08-29 实测 scripts/cross-end-check.sh 的人工 checklist 与 docs/verification/learning-loop.md K7 都还写着 4 处,而 #7 / #9 早在 2026-08-28 就修完了。 两处已改为指向本节。

RB 已立、RVH 尚未完全对齐的红线(当前 2 条:#2 · #6;#7 / #9 已修,保留记录见下):

  • RB 红线 #2(SQLite 禁 LOWER(),用 COLLATE NOCASE)— RVH 多处违反 (notebook_datasource.dart:392 等),全局替换待做
  • RB 红线 #6(soft-delete 不 hard DELETE)— auth_repository_impl.dartclearLearningDataForUser 在用户切换时 hard DELETE 5 张表(known_words 按 user_id 过滤; 其他 4 张无条件全清)。当前产品定位「单设备单用户」可接受, 多账号本地共存功能上线前必须重审
  • RB 红线 #7save_word 须重新激活 soft-deleted 条目)— 2026-08-28 已修(v63)。 原来的表现不是「少复活一次」而是加词直接失败isWordInNotebookdeleted_at IS NULL 看不见软删行 → 走 INSERT → 撞 UNIQUE(user_id, word COLLATE NOCASE)删过的词从此再也加不回生词本。修法逐字段镜像 RB crud.rs::save_word: 新增 NotebookDatasource.reviveSoftDeletedEntry,清 deleted_at + SM-2 归零 + 落哨兵 + bump updated_at复用同一行 id、不复活子树。⚠️ 与 restoreNotebookEntry(4 秒 Undo,#6f 按墓碑时间戳还子树) 语义相反,把其中一条实现成另一条是这里最容易犯的错。 回归 test/features/vocabulary_notebook/redline7_revive_on_add_test.dart(R7-1…R7-5,五处注入实证)
  • RB 红线 #9(写 word 字段前必经 normalize)— 2026-08-28 键空间规则落地 (裁定见 RB doc 43,RVH 回执 docs/cross-end/46-rvh-key-space-landing-confirmation.md)。
    • 🔑 规则不是「一律归一」也不是「一律不归一」,而是「解析成一个真实存在的 vocabulary 行」—— 因为 vocabulary同时住着 lemma 行和非 lemma 行bear/bearingpeople/person 都是行):
      w = trim + lowercase + NFC(input)              # 无条件(红线 #9 这半截照旧)
      if EXISTS(vocabulary WHERE word = w): return w # 原形本身就是一行 → 用它
      return normalize(w)                            # 否则整串归一
      🔒 第三步必须是整串 normalize 而非逐 token 的 normalizePhrase(后者改写 46 个多词条目的键); 第一步必须是 Lemmatizer.normalizeCaseOnly(换成整串 normalize 会让 EXISTS 只在 lemma 空间里问、整条规则失效)。
    • 判据单点 lib/shared/data/database/word_key.dartcanonicalWord(db, input)(输入是 已从 DB 取出的 vocabulary.word)+ surfaceWord(input)(输入是页面 surface)。 🔴 两者不能合并 —— 同一个串从两条路来意思不同,而字符串本身不携带这个信息、只有调用方知道。 RVH 当前所有调用方传的都是 vocabulary.word(OCR 管线不写 known_words,只当读侧过滤器), 故本端暂不需要分流;RB 需要(多一条 content-script 查词弹窗的 surface 腿)。 ⚠️ 这个判断的载体是守卫 K4 的调用方名单,新增调用方即红。surfaceWord 当前零调用方但刻意保留 —— 删掉它等于把「这里需要分流」从代码里抹掉。
    • 🔴 写、读、删三侧必须共用单点。只改写+读的后果是**「加得进、删不掉」**且不报错 (写入存 A、删除按裸 toLowerCase 找 B)—— 这一处是守卫 K2 当场抓出来的,不是想起来的。
    • 存量不需要回填(此前裸 toLowerCase + 调用方全传 vocabulary.word ⇒ 恰好等价于 EXISTS 分支恒命中)。
    • 守卫test/features/known_words/key_space_guard_test.dart(K0–K5,7 处注入实证)+ redline9_writer_guard_test.dart。⚠️ 第三步那条做成源码层断言而非行为断言 —— 行为用例验不到它(测试库里 dining room 本身就是一行,EXISTS 直接返回、第三步根本没执行)。