Skip to content

47 ·「本地墓碑 + 云端活行」跨端契约裁决

方向RB→RVH(裁决单;两端各自实施,各回一份回执)
日期2026-08-31
触发docs/plans/backlog.md「🟠 P2 —「本地墓碑 + 云端活行」两端永久分歧」(2026-08-31 真机实测,实例 word_cloze_contexts 3a50ad2b-f876-4250-905c-2dc25798292e / cough up
性质裁决 + RB 侧已落地(2026-08-31,见 §10)。RVH 侧待实施(Dart,flutter analyze + flutter test)——分会话的理由是工具链,见根 CLAUDE.md §9
相关红线CLAUDE.md §4 RB #6d(W/P1/P2/P3)· #6b · #6c · #7 · #5i;rvh/lib/features/sync/CLAUDE.md RVH #6f · RVH #6g · RVH #5h
前序34(父行三态)· 38 / 39(#6d 契约)· ../verification/cross-end-cloze-pool-2026-08.md(cloze 池 relay)

0. 一句话裁决

判据不是「谁的时间戳更晚」,而是「push 会不会把这条墓碑送出去」。 会 → 照旧 skip(等 push 传播);不会 → 这条墓碑永远送不出去,云端是唯一还活着的真相,接受远端(复活本地行)。

分支②的注释写的是「本地墓碑靠 push 传播,不复活」。本裁决不推翻那句话,而是把它缺的后半句补上并让代码去验它: 当 push 已经把墓碑送出去过(或墓碑本身就是从云端拉下来的),"靠 push 传播"这条退路已经用完了, 继续 skip 不是在等待,是在永久停住


1. 现场(不复述 backlog,只留裁决要用到的三条)

  1. word_cloze_contexts 3a50ad2b:RVH 本地墓碑 deleted_at = synced_at = 2026-08-26T09:23:02已 clean), 云端 deleted_at = null / updated_at = 2026-08-27T03:10:02
  2. 两端代码一字不差:写侧都刻意复活同句软删行(RB crud.rs::insert_cloze_context / RVH cloze_pool_writer.dart,注释都写着「红线 #7 精神」);pull 侧都写着分支②「不复活」。 单方面改哪一端都会立刻制造真正的不对称。
  3. 缺口在那句注释的前提:按 RB #6d 规则 W(软删时 deleted_atupdated_at 绑同一个参数)
    • push 成功后的 mark_synced / _markSynced墓碑推完就恒不脏。 于是 pull 指望 push、push 说没得推。无异常、无告警。

2. 难点复核:为什么不能用时间戳,也不能只看「clean」

2.1 时间戳出局

「远端 updated_at 08-27 晚于本地 08-26 ⇒ 远端赢」是跨时钟比较,正是 RB #6d 明令禁止的那类 (该条记着 RVH 真机反算的 27 秒漂移门槛;日级差异看着安全,秒级不是)。本裁决全程不比较任何两个时间戳。

2.2 「远端变活」有两种成因,本端看起来完全一样

成因直觉上谁该赢
对端刻意复活(用户又遇到同一句 / 又存了同一个词)—— 两端写侧都刻意实现的路径远端
对端还没拉到墓碑就把自己那份陈旧的活行推了上去(红线 #6b 那条路径:push payload 传本地真值 ⇒ deleted_at=null 覆盖掉云端墓碑)本端

现有列区分不了 ① 和 ②。 本裁决不试图区分(§4 说明为什么这个选择是划算的)。


3. 裁决

3.1 规则(两端同文)

pull 的分支②「本地已删 + remote 活」拆成两支

条件动作
②a本地墓碑仍待推(命中 push 的脏检查)skip,与今天完全一致 —— 我们的删除还没到过云端,让 push 去传播
②b本地墓碑已不待推(不命中脏检查)接受远端:复活本地行

②b 的复活写法(四条硬约束,缺一不可):

SET deleted_at = NULL, updated_at = <sync_ts>, synced_at = <sync_ts>
  • sync_ts = 该远端行自己的列updated_at ?? created_at,即两端既有的 sync_ts / _remoteSyncTs 单点) —— 禁止任何形式的本端 now。这是 RB #6d P1 / RVH #6g P1 在新分支上的直接适用。
  • 必须同时写 synced_at,让该行写完那一刻立刻不脏RB #6d P3 / RVH #6g P3 的收敛判据, 它按条文覆盖「每一条写 synced_at 的路径」)。漏写会让复活行反向再推一次(不成环,但白推)。
  • 复活后继续走既有的「双活」合并逻辑(cloze 的 sense_gloss 取非空一方、learning_entries 的 SM-2 merge、……)——不要复制第二份 merge 代码。双活分支写的是同一个 sync_ts,幂等。
  • 计入该轮 pull 的 count,并按各表既有惯例把 word 记进 touched_words(cloze 的 reconcile_cloze_pool / _reconcileClozePool 必须照常在收尾跑,见 §6.4)。

3.2 「仍待推」怎么算 —— 🔒 必须复用 push 的脏检查常量,禁止手搓

判据的文本本身就是 push 侧的脏条件,逐字:

sql
user_id = ?1
AND ((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)))

pull 侧的检查 = 上式 + AND id = ?2

🔒 两端都必须从「同一个字符串常量」拼出这条 SQL,不许在 pull 侧重写一遍:

  • RB:直接用 push.rs::USER_SCOPED_DIRTY(已是常量,为红线 #5i 抽出来的)。
  • RVH:目前 6 个 _pushXxx 各内联一份 —— 本轮先抽成一个 _kDirtyWhere 常量, push 与 pull 共用。⚠️ 抽的时候会发现 _pushReadingPages 那份写的是 updated_at > synced_at(少了 updated_at IS NOT NULL AND),与另外 5 份不同字 —— SQLite 里 NULL > x 为 NULL/假,行为等价,但这正是「六份副本已经开始漂」的证据, 收敛成一份即消除。⚠️ RVH #5h 的守卫是按出现次数计数的,抽常量后必须同步改判据 (改成「6 个 _pushXxx 都引用该常量」+「常量本体形状正确」两条)。

为什么这条是硬约束:本缺陷的成因就是「注释里写着的假设,代码里没人校验」。 如果 pull 侧手抄一份脏条件,那两份会漂 —— 而漂了之后的症状与今天一模一样(静默不收敛)。 共用常量让「pull 问的问题」与「push 会做的事」在结构上不可能不一致。

3.3 为什么这不是跨时钟比较

判据里没有任何两个时间戳被拿来比大小跨越设备边界

  • synced_atdeleted_at / updated_at 是同一行的三列,且按 RB #6d 的 W/P1/P2 保证 它们同源(本端软删 → 三列全是本端时钟;pull 落地的墓碑 → 三列全由那个远端行的列拼出)。 比的是「这一行自己相对自己脏不脏」,与另一台设备的时钟无关。
  • 判据的语义是因果的、不是时序的:问的是「push 还会不会送它」,答案由本地状态唯一决定。

3.4 「clean ⇒ 云端接受过这条墓碑」——逐条核过的蕴含

裁决成立的前提是:一条墓碑变 clean,只可能因为服务端已经知道它。 两端逐条核:

变 clean 的路径蕴含成立?
push POST 成功后 mark_synced / _markSynced✅ 服务端接受过
pull 墓碑分支①(远端墓碑落地,synced_at = MAX(sync_ts, deleted_at)✅ 墓碑本来就来自服务端
pull 分支③ 把远端墓碑当占位行 INSERT(RB pull_excluded_words / RVH _pullKnownWords✅ 同上
RVH _pullKnownWords 的「活行」路径不成立 —— 见 §5.1,这是本轮查出的既有缺陷,必须先修

RB 侧四条路径全部成立(10 个 pull_* 的双活分支都排在分支②之后,够不到墓碑行)。

3.5 谁赢:远端活行赢 —— 三条理由

  1. 与两端写侧的既定语义一致。 两端都刻意实现了「再遇到同一句/同一个词 → 复活旧行而不是造新行」 (红线 #7 精神)。若让墓碑赢(即把 clean 墓碑重新置脏推上去),等于宣布被删过的句子/词再也回不来, 直接与写侧对着干,而且会与对端写侧形成每次用户操作都触发一轮的来回拉扯。
  2. 与引擎的全局规则一致。 push 是无条件 upsert = 服务端 last-write-wins; 分支② 是整个引擎里唯一拒绝接受云端的地方,而它的正当性只在「墓碑还没推上去」时成立。
  3. 可恢复性不对称。 若判错(成因②,删除被撤销):用户再删一次即可——删除让行重新变脏 → push → 云端墓碑 → 对端落地,收敛。 而今天的分歧任何用户操作都修不了墓碑那一端:对已墓碑行再删是 no-op (RB remove_cloze_contextAND deleted_at IS NULL;RVH 各删除站点同样带 AND deleted_at IS NULL)。 「可能少删一次、可重做」严格优于「永久不收敛」。

4. backlog 三条出路的处置

出路裁决理由
加显式意图列(如 revived_at),据它区分成因 ①/②否决,但可重启加列只让「谁赢」更精确,不解决「不收敛」——不收敛才是缺陷本体。收敛修好之后,② 的残余代价是「一次删除被撤销、可重做」,不值一次跨端 DDL(Supabase DDL + 两端 schema + 迁移链)。
🔁 重启条件:真机上观测到 ② 类误撤销(观测手段见 §7.3,今天完全不可见)。
复活走「新 id 新行」,不复用旧行否决正是两端注释里明写要避免的「新行 + 旧墓碑」两条记录;且会撞 cap-5 池语义(红线 #5g)——旧墓碑仍占着去重键,新行进池会立刻把另一条真语境挤成墓碑。
接受分歧、只保证可观测否决为终局,采纳其一半用户仍会看到两端内容不一致,不可接受。但它提的「让这类差额可见」是对的 —— 收进 §7.3 的 warn 计数(成本 ~2 行)。

⚠️ 明确不做:不动 word_cloze_contexts 的 cap-5 池语义(红线 #5g,两端 reconcile 规则必须同步)。 ⚠️ 明确不做:不改 rvh/scripts/sync_reconcile.sh_reconcileRowCounts 的口径 —— 它们如实报出了这个分歧,那不是对账的 bug。修完之后差额会自然归零。


5. 影响面:不是 cloze 独有

分支②「本地已删 + remote 活 → skip」在 RB 全部 10 个 pull_* 里逐字都在pull/vocab.rs × 3、pull/library.rs × 4、pull/prefs.rs × 3)。 而写侧的复活路径在 RB 覆盖 10 张表里的 8 张:

RB 复活写路径
learning_entriesvocabulary/crud.rs:296 · :471SET deleted_at = NULL, mastery_level='level0'…,红线 #7)
word_page_linksvocabulary/crud.rs:376 · :497 · :626ON CONFLICT … DO UPDATE SET deleted_at = NULL
word_cloze_contextsvocabulary/crud.rs:66(本案)
known_wordsknown_words.rs:95
reading_pagesnotes/source.rs:310ensure_source_for_url
domain_prefsnotes/source.rs:260 · :398 · reading/view.rs:231
rss_feedsrss.rs:62
favorite_sitesfavorite_sites.rs:50
reading_notes / page_annotations无复活写路径 —— 但分支②仍在,对端复活同样够得着,照改

同一形状在整个同步矩阵上都可达。 其中最贵的是 learning_entries: 「手机上删了词 W → 之后在桌面端又遇到 W 并存了它 → 桌面端有 W、手机上永远没有」, 比丢一条例句显眼得多。故规则一次性覆盖 10 张表,不做 cloze 特例。

5.1 🔴 本轮查出的既有缺陷(RVH,必须先修,否则本裁决会把静默丢失变成静默复活)

sync_repository_impl.dart::_pullKnownWords 没有分支②:远端活行走 INSERT … ON CONFLICT(id) DO UPDATE SET reason = ?, updated_at = ?, synced_at = ?synced_at 传的是本端 now。当本地行是墓碑时:

  • 它不清 deleted_at(符合红线 #6b),但synced_at 刷成本端 nowdeleted_at < synced_at ⇒ 墓碑变 clean 却从未被 push 送出去 ⇒ 该删除已经在静默丢失(与本裁决无关,今天就在丢)。 ⚠️ 触发条件要写准,否则测不出来:正常轮次里 push 排在 pull 之前,墓碑当轮就上行了,够不到这条。 实际会咬的是两种: 本条分歧场景本身(远端已被对端复活/覆盖成活行); 该轮 push 失败或被批大小推迟(_batchLimit),而同一轮的 pull 又恰好把这行带了下来 —— 一次网络抖动就足以永久吃掉一次删除。T7 的夹具必须造这两种之一,别用「push 正常成功」的顺序场景。
  • 且它破坏 §3.4 的蕴含 —— 本裁决的判据在这张表上会读到「clean」而事实上服务端从没见过这条墓碑, 于是把「该被推上去的删除」判成「该被远端撤销」。

🔒 实施顺序硬约束:RVH 必须_pullKnownWords 改成与另外 5 个 pull 同形的显式三分支 (且 synced_at 按 #6g P1 只由远端行的列拼出),然后才落地本裁决的分支②b。 RB 侧 pull_excluded_words 已有分支②,无此问题。


6. 两端各改哪里

6.1 RB(Rust)—— ✅ 已落地(2026-08-31),as-built 见 §10

#位置改什么
RB-1src-tauri/src/commands/sync/pull/mod.rs(新 helper)新增 pub(super) fn tombstone_pending_push(conn, table, id, user_id) -> bool,SQL 由 push::USER_SCOPED_DIRTY 格式化拼出SELECT 1 FROM {table} WHERE id = ?2 AND ({USER_SCOPED_DIRTY})),禁止手写谓词
RB-2pull/vocab.rs · pull/library.rs · pull/prefs.rs 共 10 处分支②每处改成 ②a/②b 两支;②b 按 §3.1 写 deleted_at = NULL, updated_at = ?, synced_at = ?sync_ts 来自远端行)后落到既有双活逻辑
RB-3同上 10 处②b 命中时 log::warn! 一行(表名 + id),供 §7.3 观测
RB-4pull/vocab.rs::pull_cloze_contexts确认 ②b 复活的行的 word 进了 touched_words(当前循环开头就 insert,核实即可,多半零改动
RB-5sync/mod.rs::sync_matrix_tests::pull_upsert_never_overwrites_tombstone守卫保持绿(新分支是普通 UPDATE,不是 ON CONFLICT,扫不到)——但文案要改:「墓碑态只由三分支决定」→「由四分支决定」,并新增计数守卫(见 §7.1 G2)
RB-6src-tauri/CLAUDE.md §2 + 根 CLAUDE.md §4 索引新立 RB 红线 #6e(#6e 在 RB 侧是空号):「分支② 的 skip 必须以『墓碑仍待推』为条件,且该条件必须由 push 的脏检查常量拼出」。索引行与正文同时加(根 §4 的写作纪律)

6.2 RVH(Dart)

#位置改什么
RVH-0sync_repository_impl.dart::_pullKnownWords先修 §5.1:补显式三分支,synced_at_remoteSyncTs(#6g P1),不再写本端 now
RVH-1sync_repository_impl.dart把 6 个 _pushXxx 内联的脏 WHERE 抽成单一常量 _kDirtyWhere;顺手消掉 _pushReadingPages 那份的 updated_at IS NOT NULL 缺字
RVH-2sync_repository_impl.dart(新方法)Future<bool> _tombstonePendingPush(db, table, id),SQL 由 _kDirtyWhere 拼出
RVH-3_pullReadingNotes:761 · _pullReadingPages:882 · _pullLearningEntries:~1024 · _pullWordPageLinks:1215 · _pullKnownWords(RVH-0 新建的那支)· _pullWordClozeContexts:15156 处分支② 同 §3.1 改造 + AppLogger.w 一行
RVH-4rvh/lib/features/sync/CLAUDE.md新立 RVH #6i(本仓下一个空号,实施会话按当时的实际空号定),正文写「RVH 落地形状 + 守卫判据」,并在编号对照表里写明 ↔ RB #6e。⚠️ RVH #5h 的守卫判据要跟着改(脏 WHERE 抽常量后,按次数计数的那条会红)

🔒 跨仓引用一律带仓名(写「RB #6e」/「RVH #6i」,不写裸 #6e)。

6.3 Supabase

零改动。 不加列、不改 DDL、不改 RLS。

6.4 与 cap-5 池(红线 #5g)的相互作用 —— 已判,实施时别再纠结

②b 在 cloze 上复活一行后,活跃行数可能变成 6。收尾的 reconcile_cloze_pool / _reconcileClozePool 会按既有规则(created_at ASC, id ASC 留新删旧)把最旧的一条重新软删 —— 被复活的那行 created_at 是旧的, 有可能当场又被淘汰

这不是缺陷,是 cap-5 的既定语义,且与「该行经分支③ 全新插入」的结局完全相同。 关键是它收敛:那次淘汰是一条新的、脏的墓碑,会被 push 上行,对端经分支① 落地。 两端一致,无自发环(再复活需要一次新的用户操作)。实施时不要为它加特例。


7. 验收(本仓规矩:新判据必须带反向注入实证)

7.1 结构守卫

守卫注入验证
G1pull 侧的待推判据 SQL 必须由 push 的脏检查常量拼出(RB:源码断言 pull/ 里出现 USER_SCOPED_DIRTY;RVH:断言 _tombstonePendingPush 引用 _kDirtyWhere在某一张表的分支② 手搓一份谓词 → 必须红
G2pull 实现里 deleted_at = NULL 的出现次数 = 10(RB)/ 6(RVH),且全部在 ②b helper 调用点之后少改一张表 / 多出一处裸复活 → 必须红
G3RB pull_upsert_never_overwrites_tombstone 保持绿(②b 不是 ON CONFLICT把 ②b 写成 ON CONFLICT … SET deleted_at = NULL → 必须红(红线 #6b 仍然管着 upsert 那条路)
G4②b 的 UPDATE 里不出现本端时钟(RB:now / Utc::now;RVH:沿用 #6g P1 的 _remoteSyncTs 排除注释行 grep)sync_ts 换成本端 now → 必须红

7.2 单测(两端各一套,每条都要反向注入一次实证

#用例注入什么会让它红
T1墓碑synced_at IS NULL)+ 远端活 → 仍 skip,行保持墓碑把待推判据改成恒 false(无条件复活)⇒ 本端刚删的东西会被上一轮的远端活行撤销
T2墓碑已推过synced_at = deleted_at,即本案形状)+ 远端活 → 复活deleted_at IS NULL把判据改成恒 true(退回今天的 skip)
T3墓碑已推过、之后本端又改过updated_at > synced_at)+ 远端活 → 仍 skip判据只留 synced_at IS NULL 一支(#5i 同型的「漏一支」缺陷)
T4②b 复活后该行立刻不脏(跑一遍 push 的脏检查,命中 0 行)②b 漏写 synced_at(#6d P3)
T5②b 写进 synced_at 的值逐字等于远端行的 updated_at ?? created_at②b 用本端 now(#6d P1)
T6cloze 专项:满池(5 活)+ ②b 复活第 6 条 → 收尾 reconcile 后活跃行 = 5,且被淘汰行是脏的(会上行)复活后不跑 reconcile / 淘汰不 bump updated_at(#6d W)
T7(RVH 独有)_pullKnownWords:本地墓碑 + 远端活行 → 不得synced_at 刷成本端 now(§5.1)退回今天的裸 ON CONFLICT … SET synced_at = now ⇒ 该墓碑变 clean 却从未上行

⚠️ T1 与 T3 是防「过度修复」的那一半,不能省 —— 只测 T2 的话,一个「无条件复活」的实现会全绿, 而那正是本裁决明确拒绝的行为(它会把本端未传播的删除撤销掉)。

7.3 可观测(保留「加意图列」这条路的重启证据)

②b 每次命中写一条 warn(表名 + id)。用途有二: ① ② 类误撤销今天完全不可见,有了它才能判断 §4 那条「重启加列」的条件是否真的发生; ② 修复上线后这条 warn 应当只在存量收敛期出现几次然后归零 —— 若长期持续出现,说明有一条未知的 复活/覆盖路径在持续制造分歧,那是新缺陷的信号。

7.4 真机验收

两端各自实施完之后,跑 bash rvh/scripts/sync_reconcile.sh(逐 id、只读,需 admin/.env.local): word_cloze_contexts 那条永久差额应当归零。⚠️ 见 §8 —— 存量那 1 行不会自愈,先按 §8 处理再验。


8. 存量数据

1 行word_cloze_contexts 3a50ad2b,RVH 侧墓碑 / 云端活行)。

🔴 不会自愈:RVH 的 pull watermark 早已越过该行的 server_updated_at(2026-08-27), 修复上线后它不会再被拉下来,新分支②b 够不着它。两个选项:

做法评价
A等用户在 RVH 上再遇到这句 → 写侧复活本地行,自愈零成本、零风险,时机不定(可能永远不发生)
BRVH 落地后,在 Supabase 上对该 id 做一次 no-op touchUPDATE user_word_cloze_contexts SET updated_at = updated_at WHERE id = '3a50ad2b-…'),set_server_updated_at()BEFORE INSERT OR UPDATE FOR EACH ROWserver_updated_at 被刷新 ⇒ 该行重回 RVH 的 pull 窗口 → 走 ②b 复活推荐,且顺带就是 §7.4 的端到端验收

⚠️ B 属根 CLAUDE.md §6「数据库操作」🔴 高危档:限定单 id、先备份、由用户确认后执行, 不要扩成批量。执行时机 = RVH 侧改动已装机之后(否则那一轮 pull 仍走老分支②,白 touch 一次)。


9. 明确留给实施会话的三件事

  1. RVH 新红线的编号:本单写 RVH #6i 是按当前空号推的,实施会话以 rvh/lib/features/sync/CLAUDE.md 当时的实际空号为准,并在编号对照表里双向写明 ↔ RB #6e(跨仓引用带仓名,不重编号 —— 根 §4 已裁)。
  2. RB 分支② 的确切处数:本单核到 10 处(pull/vocab.rs 3:notebook_entries · cloze_contexts · excluded_words; pull/library.rs 4:reading_notes · reading_pages · word_page_links · page_annotations; pull/prefs.rs 3:favorite_sites · rss_feeds · domain_prefs —— 与 PENDING_PUSH_TABLES 的 10 张一一对应)。实施时以 G2 的计数守卫钉死,不靠本单的数字。
  3. 回执:两端各回一份 docs/cross-end/NN-*.md(编号接 README 总表往下发), 写明落地位置 + 注入实证结果 + 与本单判断不一致之处。⚠️ 本仓惯例:回执里订正裁决单的判断是常态 (doc 42/43/45 都各订正过若干条),发现本单哪条不成立请直接写进回执,别默默绕过。

10. RB 侧落地记录(2026-08-31,as-built)

cargo test --lib 251/251 绿(原 241 + 本轮新增 10)。逐条对 §6.1:

#落点与计划的差异
RB-1sync/pull/mod.rs::resolve_tombstone_vs_remote_alive + enum TombstoneVsRemoteAlive合成一个函数(检查 + 复活 + warn),不是计划里的「helper 返回 bool、调用方各写一遍 UPDATE」——复活语句因此也只有一处,G2 才数得动
RB-210 处分支②,形状统一为 if 本地已删 && resolve(...) == KeepSkipping { continue; }domain_prefs 是复合键无 id 列,key_where"domain = ?2"user_id 那半截由 USER_SCOPED_DIRTY 自带
RB-3warn 收进 helper 一处,不是 10 处集中比分散更难漏
RB-4核实:touched_words 在循环开头无条件 insert ⇒ 零改动与计划一致
RB-5pull_upsert_never_overwrites_tombstone 保持绿(复活是普通 UPDATE,不是 ON CONFLICT),文案改成「四分支」并写明为什么本守卫不因此放宽与计划一致
RB-6红线 #6e 正文进CLAUDE.md §4(不是 src-tauri/CLAUDE.md §2)计划写错了归属:#6e 的规则文本「两端同文」,按 §4 的归属规则属双端契约,正文必须留根,否则改 Dart 的会话看不见

新增守卫与用例(10 个)pull/mod.rs::redline_6e_tests 6 例 · pull/vocab.rs::redline_6e_cloze_cap_tests 1 例 · sync/mod.rs::sync_matrix_tests 3 条结构守卫。

九处反向注入,全部按预期变红

注入红的是
判据恒 false(无条件复活)T1 + T3
判据恒 true(退回裸 skip)T2 + T4 + T5
判据窄化成只剩 synced_at IS NULL T3(T1 照绿 —— 印证「只造新增行抓不到它」)
复活漏写 synced_atT4 + T5
复活掺本端时钟T5
reconcile 封顶差一位T6
reconcile Step 2 漏 bump updated_atT6
判据手搓一份G1
某张表漏改 / 多出一处裸复活G2(两个方向各一次)
复活写成 ON CONFLICT … SET deleted_at = NULLG3
复活掺本端时钟 / 漏写 synced_atG4(两个方向各一次)

🔴 两处「守卫自己错了」——本轮最值钱的产出

  1. T4(revived_row_is_immediately_clean)第一版是假绿。 夹具里那个远端 ts 刻意取得 早于本地墓碑(为的是让「远端更晚才赢」的实现露馅),可这恰好让「复活漏写 synced_at」 注进去之后 updated_at > synced_at 为假 ⇒ T4 照样全绿,是隔壁 T5 把它抓出来的。 已改成两个方向都跑REMOTE_TS / REMOTE_TS_LATER,后者 = 真机那行的真实方向)。 可复用判据:一条只在时间戳方向凑巧时才成立的断言等于没有断言。
  2. G1 第一次跑就假红library.rs 里一句解释性注释提到了 synced_at IS NULL,被判成 「手抄了脏条件」。已加 code_only() 只剥整行注释(同 RVH #5d/#5g/#6g 那三条 grep 守卫的做法)。 ⚠️ 刻意剥行尾注释:行尾若真写了 SQL 片段,宁可假红——假红看一眼就排除,假绿没人会发现。

一条边界,别高估它

T6 的 deleted_at > synced_at 那条断言鉴别力有限:synced_at 停在远端 ts(过去)、淘汰的 deleted_at 取本端 now ⇒ 只要复活没把 synced_at 写成未来值它恒真。它守的是 「复活没有让后续淘汰生来就干净」不是 #6d 规则 W —— W 由紧挨着的 del == upd 那条守。 测试里已写明这段边界。

还没做的

  • RVH 侧全部(§6.2,RVH-0 先做)
  • 存量那 1 行(§8 选 B,时机 = RVH 装机之后)
  • 实机验收(§7.4 的 sync_reconcile.sh 归零)—— 需要两端都装机