Skip to content

Changelog

本文件记录 Lampio 的版本变更历史。格式基于 Keep a Changelog


[Unreleased]

docs(kb):知识库落地 P1-P4 —— 同步协议全景 + 知识层守卫 + VitePress 站(2026-09-01)

docs/plans/knowledge-base-plan.md 走完 P1-P4。

P1 长知识:新增 docs/sync-protocol.md。核心是「失效模式族谱」一节—— 把 A 回声环 / B 永久停住 / C 永久丢行 / D 跨用户泄漏 四个家族摊开,其中 B 是 A 的解药引入的 (规则 W + mark_synced 让墓碑恒不脏 ⇒ 分支② 那句注释的前提失效),这条因果链此前散在 cross-end 34/37/38/47 里、没被写在一处。严守解释/规则边界:全篇只引用红线编号、不复述条文, CLAUDE.md §4 与 src-tauri/CLAUDE.md §2 一个字未动。原处留指针——docs/architecture.md §8 那份同步流程图长期停在已废弃的协议上(写着 updated_at > last_sync_at,映射表还列着不存在的 word_contexts),迁出后把这段记账写进指针里。

P2 守卫:新增 scripts/check-kb.mjs(A1-A9)+ 新鲜度报告。 🔑 新鲜度只打印、恒 exit 0 —— verifiedAt 落后于代码是常态不是违规,让它判红等于 「碰一次 sync 就 CI 红」。12 处反向注入 + 3 条假红反证(裸 grep 全仓命中 3 处、全在行内代码里, 本脚本剥离后为 0 —— 计划自己预告过这个陷阱)。

P3 骨架(srcDir = 仓根,实测证伪失败 ⇒ 采纳):计划只预见 dead link,实测主要代价是 VitePress 用 Vue SFC 编译器过每份 md,首轮 6 份编不过。其中 3 份是真实 markdown 缺陷 (在 GitHub 上本来就渲染错,已就地修好):docs/ui-standards.md 单反引号 span 里塞转义反引号 ⇒ span 提前闭合、<div> 泄漏成裸 HTML · rvh/docs/decisions.md 正文裸写泛型 · admin/docs/database.md 文末混进两行 tool-call 残渣。另 2 份靠 markdown-it 构建期转义大括号解决(源文件零改动)。 ⚠️ 走过一次弯路:先用 vue.template.compilerOptions.delimiters 整体挪定界符,md 是编过了, 但把默认主题自己的插值一起打坏(导航标题变字面量 {{ site.title }})——截图撞见的,不是推理出来的。 dead link 101 → 0,用精确模式表而非 ignoreDeadLinks: true,md→md 的真悬挂仍会红。 搜索按路径降权(boostDocument):记述层可搜但沉底,搜「墓碑」前 4 条全是知识文档。 手机实测页面 body 零横向滚动,ASCII 图各自在容器内滚——计划担心的那条没发生。

P4 上线:Vercel project lampio-kb生成域名、不绑自定义域;绑了 Standard Protection 会静默失效)。vercel.json + .vercelignore(CLI 上传 222 MB → 35 MB,判据是「排掉的目录里 不能有 .md」)。ci.ymlpnpm run docs:build。admin sidebar 加外链。 🔴 验收当场推翻了计划的模型:走代理复测,设置页写着 Require Log In + Standard Protection, 而无 cookie 请求返回 200 —— 站点是公开的。根因是计划把两种 *.vercel.app 当成了一种: 判据是**「是不是 production 域」,与自定义域无关**,项目别名 lampio-kb.vercel.app 本身 就是 production 域、不受保护;受保护的只有 preview 与每次部署的 generated URL。 ⇒「不绑自定义域就安全」这条缓解从来没有生效过。原以为正解是改 All Deployments当天即被截图证伪 —— 该档位 Hobby 不可用 (Pro,$150/月)。⇒ Hobby 下 Vercel 侧无可用档位覆盖 production 域,出路是换承载 (Cloudflare Access)或退回本机兜底。且「公开」这个结论本身只有裸状态码支撑,待复验响应体。 计划 §4.2 已就地订正并保留原文,动作在 backlog P0。 🔑 唯一救了这件事的是验收方式本身——计划写死了「不是看设置页写着什么,而是无痕窗口」。

顺带修好的既有缺口scripts/check-doc-links.mjs 模式 B 补上 supabase/ tools/ scripts/rb-debug-mcp/(这 9 份 md 此前一条链接闸门都没有,那两条悬挂正是站点构建撞出来的)· scripts/check-repo-layout.mjs 登记 .vercel/vercel link 在仓根建的,CI 看不见未跟踪目录, 是本机那一跑抓到的)。

chore(lint):pnpm lint 入闸 —— 51 条存量清到 0 error(2026-09-01)

eslint 一直没进 CI,原因是「存量清不完」。本轮清完并接进 ci.yml

先修尺寸:复测 eslint . 报 135 条,其中 84 条来自 .claude/worktrees/ 下本仓自己的 git worktree(Agent isolation 留下的,里面完整一份 src/+admin/+landing/+supabase/)。 上一轮加的 ignore 写的是 admin/** 而非 **/admin/**,一个都挡不住 ⇒ 补 .claude/**。 真实存量 = 51(backlog 记的 44 是 error 数,另有 7 条 warning)。

清掉的 29 条全是真修,不是压制

  • react-refresh/only-export-components ×7:3 个纯函数搬进 lib/normalizeSentencetextNormalize.ts · scoreToneTextdesign-tokens.ts 与既有 cefr* 同型 · buildWordRegex→ 新 wordEmphasis.ts)+ broadcastRemoveMark→新 annotationMarks.ts + 1 个取消导出 + 2 个死 hook 删除useAnnotationCounts/useVocabStats 注释都写着「给某某用」,实测零调用方)
  • react-hooks/immutability ×2 —— 真缺陷EmphasizedText 直接改调用方传进来的 /g 正则的 lastIndex,而调用方是 useMemo 缓存的同一实例 ⇒ 两处同时渲染会互踩游标、漏高亮。改成克隆
  • react-hooks/refs ×1 —— 真缺陷:渲染期写 ref,挪进 effect
  • set-state-in-effect ×3 真重构:1 处改渲染期派生(PronunciationHistoryStrip 的 scope 纠偏)
    • 2 处改 React 官方「渲染期调整」模式(TocPanel 换书重置 / ClozeRevealCard 切句重置)
  • 机械 ×5(三元当语句 ×2 · 空接口+空解构 ×2 · 删一个没人传的参数)+ 4 条失效 disable 指令
  • 顺带:三个组件各抄一份的 LOW_SCORE_THRESHOLD = 80 收成 design-tokens.ts 单点

两条关掉的规则,理由都是「前提不成立」而非「让闸门变绿」

  • preserve-manual-memoization 全仓关:它报的是「React Compiler 无法保留手写 memo」, 而本仓没启用 compilervite.config.ts 是裸 react());且报告行会随注释移动、追不上。写了重启条件
  • set-state-in-effect祖父条款(用户拍板):按文件白名单关(17 文件 22 条), 规则本身保持开启,新文件照样受约束

🔑 最小实验把 set-state-in-effect 的判据钉死了(backlog 原来的「分两类」不成立,实际三类): effect 体里同步 setState = 真阳性;effect 调一个 async useCallback = 假阳性(setState 在 await 之后,规则看不穿 callback 边界);同样语义写成内联 async IIFE = 不报。 而且逐条 disable 追不上——给某处加了 disable,规则立刻改报同一 effect 里的另一个 setState (实测 setMemory(null).catch 里那个)。这就是没走「逐条 disable 加理由」那条路的原因。

守卫scripts/check-lint-grandfather.mjs 逐文件复验「是否仍需要豁免」,不需要了就必须删 —— 名单只能变短。两条反向注入实证(塞一个已修好的文件进去 → 红;把注释条数改错 → 告警)。 ci.yml 加两步:pnpm run lint + pnpm run check:lint-grandfather

⚠️ 门槛是 0 error;3 条 exhaustive-deps 仍是 warning(加依赖可能让 effect 每次渲染重跑, 要逐个判行为),已在 backlog 立项 —— 修完它们才谈得上收紧到 --max-warnings 0

同日追加:祖父条款名单 17 → 12 文件(22 → 15 条)

把「规则假阳性」那一类消化干净了 —— 7 处 effect 直接调 async useCallback 的站点 (StatsPanel ×3 · 两个 ActionCard · useUserLevel · PronunciationHistoryStrip)改为 void (async () => { await load(); })()语义完全等价await 仍在同一 tick 同步调进去), 而规则不再误判。四种写法实测:load() · void load().catch(…) · void (async()=>{await load()})() 不报 · 整个 async 体内联 不报

🔑 包 IIFE 之前必须先判「setState 是不是真在 await 之后」 —— StatsPanel.loadStatic 就是例外:它在第一个 await 之前 setIsLoading(true),那是的同步级联。查下来那句 还是纯冗余(isLoading 初值就是 true,且该 loader 只有那一个 useCallback(…, []) 恒稳调用方) ⇒ 删掉而不是包起来。不做这一步就直接包,等于把真问题掩盖成假问题。

⚠️ 未做实机验证:当前跑着的是安装版 app,而 dev 版与它共用同一个 com.lampio.dev 数据目录(正式版 identifier 未拆分那条已知项),为 7 处纯形状改动同时开两个实例抢同一个库 不划算。由 tsc + pnpm build + 276 个单测兜住。

同日再追加:3 条 exhaustive-deps 修完,门槛收紧到 --max-warnings 0

三条的病根都不是「漏写依赖」,逐条判读后各有各的真问题:

  • BrowserPage:218:补 getContainerRect纯形式 —— 它 useCallback(…, []) 恒稳, 且 deps 里已有的 getWebviewRect 依赖它 ⇒ 重跑时机一字未变
  • AddressBar:257:那里有两个几乎逐字重复的 effect(一个还挂着 eslint-disable-line), 算同一个 siteUrl、写同一个 state,换 tab 时两条异步竞写 isSavedState。 合并为一个 + 补竞态守卫;祖父条款里 AddressBar 的条数随之 4 → 3
  • RssPanel:128setItems 的 updater 回调里发副作用,拿状态更新器当「读当前 items」用。 React 要求 updater 纯、StrictMode 下调两次 ⇒ 自动刷新可能触发两遍。改用 loadItems 的返回值(本次真正加载到的条数,比读提交态更准)+ latest-ref 取 handleRefreshisRefreshing 条件删掉不是漏掉 —— 守卫已在被调方第一行

pnpm linteslint . --max-warnings 0,反向注入验过(去掉一个依赖 → rc=1)。 祖父条款名单 15 → 14 条 / 12 文件

fix(release):artifact 配额闸门从「代理指标」换成真账 —— 旧版报 0 MB 时配额其实是满的(2026-09-01)

dev.11 为配额白烧了两轮(各 ~21 min × 3 runner),而 artifact-check 两次都报 0 MB 放行。 病根是口径错了,不是精度不够:旧版只数 expired==false 的 artifact,而同一时刻本仓有 16 个已过期、共 261 MB —— 过期 ≠ 已清除,GitHub 的账照算(清除异步,用量每 6–12 小时才重算)。 它自带的免责声明(「报数低 = 什么都没证明」)没说谎,但一个需要免责声明才能读懂的指标本身就是坏指标

  • 快照口径改成数所有 artifact,并拆出「其中已过期 N MB」——这半回答「能删掉多少」,可行动
  • 新增账单口径(权威,拦上传的就是这本账):users/{u}/settings/billing/usage
    • keychain 里一个 fine-grained PAT(lampio-gh-billing,只给 Account→Plan:read-only)。 判据单独成文件 scripts/gh-storage-usage.py
  • 🔴 实测:旧的 settings/billing/shared-storage.../actions 已 HTTP 410 下线 ("This endpoint has been moved"),只剩 usage 活着;三个端点对 classic OAuth token 一律要 user scope,而 fine-grained 的 Plan:read-only 就够

当场用它复算 8 月:372.0 GB-hours ÷ 743h = 平均 512 MB / included 512 MB(free 版)= 100% —— 配额确实顶死,两次重跑必败,这道闸门本可以省下那 40 分钟。

⚠️ 诚实边界写进代码注释:GB-hours ÷ 已过去小时数是本月至今的均值,不是此刻快照 (月初 <12h 判「太早没法算」;刚删完东西仍带历史、偏保守)。included 常量只写实测确认过的 free 档,换套餐会说「阈值假设失效」而不是拿错阈值硬算。缺 token / 取不到 → 说「本次没验」 并放行(取不到 ≠ 值不对),退出码实测:缺 token=0 / 配额满=1。

六条反向注入全部按预期:低用量不报 OVER · 顶格报 OVER · 换套餐只报数 · 非 JSON 说没验 · 本月无 storage 行不误报 · 缺 token 放行且不冒充。

顺手清掉本仓 16 个已过期 artifact(261 MB),快照归零。清完当场又暴露一格设计缺陷并补上: 两个口径对不上时要分开说 —— artifact 已清零而账单仍报 100%(GB-hours 是月度均值、带着 删除前的历史),此时照搬「先删已过期的」等于叫人去删已经不存在的东西。建议错了比没建议更糟, 故单独成支:「别现在打 tag,但不是让你删东西 —— 等 6–12 小时重算,判据看账单百分比不看快照」。 台账 ops_credentials 加行(2027-09-01 到期,过期表现写明是「静默退回没验」), release-verify.sh R15 对账名单 4→5。

fix(security):admin 分析 RPC 不再对 anon 开放 —— 注册数与趋势曾公网可查(2026-09-01)

get_user_count / get_registration_trend 两个 SECURITY DEFINER 函数读 auth.users, DDL 首行写着「仅供 admin service_role 调用」,而部署态里 anon 可执行 —— 声明与现实分家。 anon key 随桌面端二进制出厂、是公开值,所以等于注册总数与逐日趋势公网可查。 2026-08-27 隐私专题验收发现,2026-09-01 现场复现(HTTP 200 [{"count":7}])并修掉。

泄漏的是业务指标而非用户 PII(无邮箱 / user_id / 学习内容)——但它是 「DDL 注释里的意图从未被机械核对过」的实例。

  • 生产库执行两条 revoke execute … from public, anon, authenticated(事务内先验权限表再 commit)
  • supabase/sql/admin-analytics-rpcs.sql 末尾补同样两条CREATE OR REPLACE FUNCTION不保留后来的 revoke,只改库不改文件 = 下次重跑那个文件把洞还回去
  • privacy-verify.sh 的 P5 BASELINE 清空(那条断言刻意是「集合相等」而非「集合为空」, 就是要让「修好了」也必须回来改一次账)

验收:anon → HTTP 401 42501 permission denied for function;service_role → 200(admin 不受影响); privacy-verify.sh PASS=16 / FAIL=0 / SKIP=0。反向注入:把任一名字塞回基线 → P5 立刻 FAIL=1 (证明空基线不是空断言)。

fix(release):发版版本号的第四处进了断言 —— Cargo.lock 不再靠运气(2026-09-01)

scripts/set-version.mjs 只写三处(tauri.conf / Cargo.toml / package.json), check:version-sync 也只断言这三处;而 src-tauri/Cargo.lock[[package]] name = "app" 的 version 是第四处,只有本机跑过 cargo 才会跟着变。

实测证据v0.1.0-dev.10 的 tag 里四处一致 —— 靠的是「bump 之后碰巧跑过一次 cargo」; v0.1.0-dev.11 bump 完立刻打 tag,缺口就露出来了(Cargo.toml=dev.11 / Cargo.lock=dev.10, 当时靠一条手工 commit 2e78920a 补的)。失效面:CI 发版构建不带 --locked,三平台照常 编译通过;但从该 tag 做可复现构建(cargo build --locked)会失败 —— 又一个 「错着但什么都不会红」。

  • 写入器 + 读取器 + --check 三处都补上第四处,正则锚定 [[package]] name = "app" (Cargo.lock 有 720 行 version = ,裸正则会改到依赖版本上),\r?\n 兼容 Windows 检出。
  • 两条反向注入实证:① lock 版本漂移 → 退出 1 且报「从该 tag 跑 cargo build --locked 会失败」; ② 写新版本 → Cargo.lock 恰好 1 行变更,四处同步。另跑 cargo check --locked 验掉真实失效面。
  • 连带订正两处过期表述:docs/verification/release-chain.md R1 行(三处→四处)+ 根 CLAUDE.md §8「package.jsonversion 是死字段(长期 0.0.0)」—— 实测已是 0.1.0-dev.11死的是消费方不是值

🔑 backlog 那条写得对:只补写入器不补断言是不够的 —— 真正的病是「三处一致」这句话本身 在说谎,说谎的是断言的覆盖面

fix(vocabulary):从预装库删掉的 53 个词,其实一个都没送达存量用户(2026-08-31)

专有名词治理 §4.5 ② 的归因订正 + 修复。起点是「bangbus(成人网站品牌)出现在快速分级 C2 首屏」,plan 一直把它记作「L-C 的 pipeline 收词闸门没挡住」。实测归因错了:它 不在资产里(round30 早删了),是存量运行库的老种子残留

判据来自差集而非眼判:本机 dev 运行库 18,972 行 vs 资产 18,916 行,多出的 56 行 = 53 个 source='preinstalled' 残留(与 round30 delete_words 载荷集合精确相等, 两向差集皆空)+ 3 个 source='backfilled' 回填行(kyiv 是其一,正常数据)。 除此之外两库逐行零差异 ⇒ upsert 的列清单没问题,缺口只在「删」这一侧。

机制:seed 合并是 ON CONFLICT DO UPDATE只增改不删 ⇒「资产删干净了」与 「用户库删干净了」是两件事,而资产 SHA、体积闸、单测全都只看得见前者。补轮当时给 delete_words_gate1(9 词)补了 v27 prune 并确实生效,主载荷那 53 个漏了

  • helpers.rsv29 prune(清单是 round30 载荷的镜像,沿 v18/v23/v27 先例带 NOT EXISTS(learning_entries) 自守 —— FK 是 RESTRICT 但 rusqlite 通道从不开 PRAGMA foreign_keys,裸 DELETE 只会静默留下悬挂 FK 的孤儿生词本条目)。
  • 版本锚 → rb-18916-v28-prune53-2026-08-31。🔒 资产一个字节没动(红线 #10 不受影响), bump 的唯一目的是让存量用户跑一次 prune;RVH 锚不必跟着动。
  • 三条守卫,各自反向注入实证:G1 prune 清单 ⊇ 载荷的 delete_words + delete_words_gate1 (下一轮只改 pipeline 不加 prune 立刻红)· G2 prune 清单 ∩ 资产 = ∅(删了又被 upsert 灌回来 = 白做且用户拿不到)· SQL 必须带生词本自守。cargo test 254/254
  • 真机验收(pnpm tauri dev 在跑,改 Rust 即重启,seed 当场跑掉):18,972 → 18,920kyiv 未动、gate1 仍为 0、孤儿生词本条目 0、C2 池 310 → 290。

🔑 收益的诚实边界:修的是正确性(已裁定未送达)+ 删掉 8 个露骨词,不是「让 C2 变干净」。 C2 首屏换掉 bangbus/batterie,换进来的是 beck/benedict——还是人名;C1/B2 首屏一字不变。

⚠️ 本轮撞到并写进注释的机制风险:prune 是「一次性、按版本字符串」的 —— seed 开头版本相等 就 return,所以发布后才发现清单漏词 = 必须发一个新版本串,改清单本身没用。

docs(proper-noun):C2 档没有权威分级词,这不是「脏」是「没数据」(2026-08-31)

L-C 第 4 条「清完才谈解开 CALIBRATION_MAX_LEVEL」的形状被实测改写,同时否掉一个当天提出的方案。

  • 库分两半来路:12,172 个单词 = 7,002 权威分级(Oxford 5000 4,643 + CEFR-J 2,495)
    • 5,170 词频反推(Google Web Trillion top-20k)。pipeline 多轮打磨的是词条内容 (释义/例句/译文/词源/搭配),从没治理过的是「这个词该不该在库里」——两件事,故 「库很好」与「C2 全是垃圾」能同时成立。
  • 噪声随档位单调爆炸(各档均匀抽 30 词眼判):B2 ~10% · C1 ~30% · C2 ~73%。 而 C2 档 519 个词权威分级 = 0 个CALIBRATION_MAX_LEVEL='B2' 的真实含义是 「这一档没有可信来源」,解封需要引进权威词源,不是清理
  • 否掉「主动推送池只用 cefr_inferred=0(plan §0.7 第 5 条):收紧会砍掉 A2–B2 里 cellphone/calorie/clipboard/enzyme/reboot/proactive 这类权威表覆盖不到的现代高频词 (B2 池 4,303 → 877)。⇒ 症状 4 的对症位置只能是收词阶段,运行时判据不动。
  • 新增交接单 cross-end/48: RVH 的 seed prune 口径是「清未引用的 backfilled」、注释明写刻意不清 preinstalled, 与 RB 正好互补且都不完整 ⇒ 同一缺陷大概率在 RVH 也成立,附了自查 SQL。

fix(sync):立红线 #6e —— 分支② 的 skip 从此要先问「push 还会不会送它」(2026-08-31)

doc 47 裁决RB 侧落地。 分支②「本地已删 + remote 活 → skip」拆成 ②a 仍待推 → 照旧 skip / ②b 已不待推 → 接受远端、 复活本地行。判据 = push 的脏检查本身pull/mod.rs::resolve_tombstone_vs_remote_alivepush.rs::USER_SCOPED_DIRTY 格式化拼出,禁止手抄)——问的是「push 还会不会送它」, 全程不比较任何两个跨设备时间戳(那正是 #6d 禁止的)。复活语句同时守 #6d P1(updated_at/ synced_at 只由那个远端行自己的列拼出)+ P3(写完立刻不脏)。10 张表全改,②b 命中写一条 log::warn 作为「要不要加 revived_at 意图列」那条备选出路的重启依据。

新增 10 个用例/守卫,九处反向注入全部按预期变红cargo test 251/251)。其中 「墓碑仍脏 → 仍 skip」是防过度修复的那一半:少了它,一个「无条件复活」的实现会全绿, 而那会把本端还没推上去的删除当场撤销。

🔴 本轮最值钱的是两处「守卫自己错了」:① revived_row_is_immediately_clean 第一版是假绿 —— 夹具的远端 ts 刻意取得早于本地墓碑(为让「远端更晚才赢」的实现露馅),却恰好让「复活漏写 synced_at」注进去时 updated_at > synced_at 为假,是隔壁那条把它抓出来的;现改成两个方向都跑。 可复用判据:一条只在时间戳方向凑巧时才成立的断言等于没有断言。 ② 结构守卫 G1 第一次跑就假红 —— library.rs 一句解释性注释提到了 synced_at IS NULL 被判成手抄谓词;已加 code_only() 只剥整行注释(行尾注释刻意不剥:假红看一眼就排除,假绿没人发现)。

红线 #6e 正文进CLAUDE.md §4(不是 src-tauri/CLAUDE.md)—— 规则两端同文, 按 §4 归属规则属双端契约,藏进 Rust 侧等于让改 Dart 的会话看不见。 RVH 侧待实施(裁决单 §6.2,RVH-0 _pullKnownWords 先做);存量那 1 行按 §8 处理。

docs(cross-end):裁定「本地墓碑 + 云端活行」——判据是因果不是时序(2026-08-31)

真机对账逮到一条永不收敛的跨端分歧(word_cloze_contexts 3a50ad2b / cough up: RVH 本地墓碑、云端活行)。🔑 两端代码一字不差、都没做错 —— 写侧都刻意复活同句软删行 (红线 #7 精神,避免「新行 + 旧墓碑」),pull 侧分支② 也都写着「本地墓碑靠 push 传播,不复活」。 ⇒ 单方面改哪一端都是在制造真正的不对称。

缺口在那句注释的前提:按 RB #6d 规则 W(软删时 deleted_atupdated_at 绑同一个参数)

  • push 成功后的 mark_synced墓碑推完就恒不脏。于是 pull 指望 push、push 说没得推 —— 前提落空、规则悬空,无异常无告警。又一例「注释里写着的假设,代码里没人校验」。

难在「怎么判断谁更新」不在「谁赢」updated_at 跨端比大小正是 #6d 明令禁止的跨时钟比较 (该条记着 RVH 真机反算的 27 秒漂移门槛);而只看「本地墓碑已 clean」不够 —— 远端变活有刻意复活对端推了陈旧活行(#6b 那条路径)两种成因,本端看起来完全一样。

🔴 裁定:判据不是「谁的时间戳更晚」,而是「push 会不会把这条墓碑送出去」。 会 → 照旧 skip;不会 → 这条墓碑永远送不出去,云端是唯一还活着的真相 ⇒ 接受远端、复活本地行。 全程不比较任何两个时间戳 —— 只看同一行自己的三列,而 W/P1/P2 已保证它们同源。 🔒 判据 SQL 必须由 push 的脏检查常量拼出、禁止手搓(RB USER_SCOPED_DIRTY; RVH 先把 6 个 _pushXxx 里的内联 WHERE 收敛成一个常量 —— 抽的时候会发现 _pushReadingPages 那份已经少了 updated_at IS NOT NULL,行为等价但正是「六份副本开始漂」的证据)。 远端赢的决定性理由是可恢复性不对称:判错了再删一次就收敛,而今天的分歧 任何用户操作都修不了墓碑那一端(对已墓碑行再删是 no-op,两端删除站点都带 AND deleted_at IS NULL)。

读码查出三件超出原始记录的事:① 不是 cloze 独有 —— 分支② 在 RB 10 个 pull_* 里逐字都在, 写侧复活路径覆盖 10 张表里的 8 张,最贵的是 learning_entries(手机删词、桌面再存 ⇒ 手机永远没有); ② 🔴 RVH _pullKnownWords 压根没有分支②,且远端活行路径把 synced_at 刷成本端 now ⇒ 本地墓碑变 clean 却从未上行,今天就在静默丢已认识词的删除,还会破坏本裁决 「clean ⇒ 服务端见过这条墓碑」的前提 ⇒ 定为 RVH 实施的第 0 步; ③ 存量那 1 行不会自愈(watermark 已越过),需单 id no-op touch 把它推回 pull 窗口。

三条备选出路全部否决(加 revived_at 意图列可重启,条件 = 观测到误撤销 ⇒ 验收要求 ②b 命中写 warn)。 验收 4 条结构守卫 + 7 条单测各带反向注入,其中「墓碑仍脏 → 仍 skip」是防过度修复的一半 —— 少了它,一个「无条件复活」的实现会全绿。

本轮零代码改动,待两端各开实施会话(工具链不同,见 §9)。 裁决单 cross-end/47; 台账 docs/verification/sync-consistency.md K5。

feat:专名出口 —— 双击 John 不再给「嫖客」(2026-08-31)

专有名词治理 L-D 的产品出口部分。此前双击专名拿到的是有害或无用的词典义 (现查三例:John→「A prostitute's client.」· Texas→「蒸汽船顶层舱室」· TomTom→「tom-tom 的异体」),而 Kyiv/Zelensky 压根不在预装库里。

拆成成本截然不同的两半

  • 止损(确定性、每次查词成本 0):判据命中 → 折叠误导词典义 + 标「专有名词」+ 给展开开关。
  • 补值(LLM):「这是什么?」按需按钮,点了才调。⇒ 固定成本仍是 0, 且离线/未登录时这条腿只是不可用、止损那半截照样成立。

折叠而不是删除:那些义项确实在词典里、不是错数据,硬删等于替用户否决 —— 正是 fox 那条教训反对的做法。默认不展示解决「有害」,可展开解决「别替我做主」。

刻意复用已上线的句级 resolve_context,不新开 Edge Function —— 新函数要走 手工触发的 /edge-deploy 才生效,功能会卡在「代码合了但线上没有」。

两条互补的腿,缺一条漏一半:判据腿(vocab_scope::is_pure_proper_nounDictResult.is_proper_noun)只认库内行(实测 john/texas=1,march/space/fox/tuesday=0); Kyiv/Zelensky 只有页面腿够得到 —— content-script 从此保留 surface (getSelectedWord 返回 { word, surface }word 那一路语义一字节未变, 让 surface 流进 save_word 会破坏红线 #9 的键空间)。 判据腿确信 → 折叠;页面腿存疑 → 只提示不动展示(Title Case 会误报)。 渲染三条路径都接了:正常 entries · OOV not-found 分支 · backfill 回填后。

vocab_scope 重构:判据本体抽成 CRITERION 常量,「筛掉行」与「贴标签」共用。 头注释那条「可查路径绝不调用它」细化为**「不许用它筛掉行,可以用它给行贴标签」**—— 分界线是行还在不在结果里。新测 the_two_entry_points_never_disagree 逐行断言互补。

vitest 的 include 扩到 content-script 纯函数(新增 word-extract.test.js, 6 例里三组是反向断言)。cargo 241/241 · vitest 276/276 · pnpm build 绿。

实机验收 9/9(先补了 rb-debug 的 dblclick 驱动,见下条):John/Texas 折叠 · Kyiv/Tuesday 提示 · March/Space(句首大写)/ fox / march 一律无提示(反向断言)。 折叠开关三态正确;「这是什么?」在 Kyiv 上返回「乌克兰首都,位于该国中部偏北, 第聂伯河畔。」 —— 正是 plan §6 立这条时设想的那句。 ⚠️ 每条都先断言弹窗里的词换成了目标词 —— 不断言会读到上一次的残留(假绿,第一轮翻过车)。

feat(工程):rb-debug 加 dblclick 驱动 —— 查词弹窗从此可自动化验收(2026-08-31)

查词弹窗此前没有任何自动化入口,验收只能请人手动双击。这是真实的覆盖缺口: §4.3.1 那条「机械层与实机层不可互相替代」的教训,恰恰在最需要实机层的弹窗上没有工具。

三条此路不通都实测走过:① dblclick 不是 click 派生的,连发两个合成 click 不产生它; ② 换 lookup_trigger='select' 也不行 —— 那条腿读 window.getSelection(), 合成 mouseup 不产生选区;③ /debug/click 打元素中心,而正文里的词多数没有自己的 元素(John/Kyiv 就是裸文本节点,恰恰是要验的那批)。

做法:TreeWalker 按整词定位 → 建 Range → 真的把选区设成该词 → 在 rect 中心发 完整鼠标序列 + dblclick。🔴 派发目标取文本节点的父元素而非 elementFromPoint —— 后者会命中盖在词上面的上一个弹窗,isInsideRbUi 直接 return,双击静默落空。 debug 模块整体 dev-only,release 不编译。⚠️ MCP 侧需 build + 重启 MCP 才进工具集。

fix:实测推翻 B 类的「该处不高亮」(2026-08-31)

plan §6 原本要求「非句首 + 首字母大写 → 该处不高亮」。对 18 篇推荐文章正文实测: 中位 12.1%/篇的高亮实例会被抑制,其中只有 9.7% 落在判据已认得的专名上; 其余大量误伤 Title Case 里的普通学习词(Deep Space Network 里的 spaceInternational Conference 里的 conference)。是在 token 级重演 fox, 误杀面比刚退役的黑名单还大一个量级。⇒ B 类只保留非破坏性的提示。 落账 §0.7 第 4 条(含复现口径 + 「重跑看方向不看 12.1%」)。

fix:reference_words 黑名单退役 —— 专名过滤收敛到单一判据(2026-08-31)

专有名词治理 L-D 的过滤器部分。那张 193 词的静态整词黑名单停止参与任何查询过滤, 「可学 / 计难度」的判据从此只有 db/vocab_scope.rs 一个来源。 表和 193 行数据原样保留 —— chinese_meaning 是 L-D「A 类纯专名走 background 补全」 的中文兜底数据源。无迁移、无数据变更,回滚 = git revert

决定性数据(对本机真库实测,非推断):193 行里 145 行不在 vocabulary(纯空转)、 20 行已被新判据覆盖(冗余),独有贡献只有 28 行 —— 其中 17 行是误杀fox / dna 是 CEFR-J 权威分级词(cefr_inferred=0),另 15 个是带 adj 的国籍词。 剩下 7~8 个真专名(japan/amazon/google/microsoft/shanghai/stockholm/chile) 正是 vocab_scope 早已写明的「刻意假阴性」(带 verb 词性),对症是 L-C 补标,不是手工名单。

最有说服力的一条是同一词类被劈成两半american/chinese/french/spanish/german/ japanese/british 正常高亮,russian/ukrainian/israeli/iranian/saudi/afghan 永不高亮 —— 而判据文档明写「带 adj = 已词汇化,是合法学习词」。黑名单在否决一条已裁定的判据, 分界线只是 2026-04 那份 seed 恰好抄了哪些地缘新闻国家。

🔴 拆的是两条腿,不是一条:plan §0.2 记的「只接了 3 个查询」是错的,实为 6 处 —— 除 get_learning_words / _with_cefr / get_discoverable_words 外,还有 analyze_article_bodies_innerlearning 集,以及 content-script 整条腿highlight.js / discovery.js 各自拿 get_reference_word_setSet.delete 一遍)。 只改 Rust 侧的话 fox 照旧不高亮,而且不报错。

顺带下线:7 个 reference_words 命令(6 个零调用方;get_reference_word_set 的 唯一调用方就是上面那条 JS 腿)+ 随之全空的 db/word_list.rsredline9_guard 写入方 6→5、key_space_guard 去掉一档。

实机验收(①④ 两条已知未修一并结案):造受控探针文本,一次拿到正例 + 控制组 + 反向断言。 fox ×7 → rb-discover-b2,存进生词本 reload 后 → rb-highlight-b2(④ 那条腿此前从未实测过); Russianrb-discover-a2American 控制组不变;Texas 仍无 span —— 这条反向断言是关键,它把「黑名单退役」和「把闸门整个拆了」区分开。

新守卫 vocab_scope::the_reference_words_blacklist_stays_retired:覆盖 Rust + JS 七个文件(守 JS 是要点,只守 Rust 等于没守),带阳性对照 blacklist_detection_is_not_blind(四个探针取自被拆掉的真实原句)。 不变量归属 = src-tauri/CLAUDE.md §5 + vocab_scope.rs 模块头注释,不留在 plan 里

cargo test --lib 239/239 + pnpm build 绿。文档同步 5 份,其中 docs/vocabulary-domain-knowledge.md §7.2 顺手订正了一个从未存在过的行为 ("专有名词移去 reference_words,不进 vocabulary" —— config.yamlexclude_proper_nouns 是死开关,plan §6 早已点名)。

test:补跑改造点 #7 的实机验收 —— 路径确认活了,但档位对本改动饱和(2026-08-31)

昨天 §4.3「难度徽标」那条只验到改造点 #5,#7(analyze_article_bodiesdict) 因本地无缓存文章而未被覆盖,记在 §4.5 ③。今天前置条件自行满足,补跑完毕,该条结案。

先订正真因:不是「本机没缓存」这种状态问题,是赶上了两批推荐之间的空窗fetch_recommended_articlesexpires_at=gt.now() 过滤且DELETE 再插 —— 上游返回 0 条就把本地整张表清成 0 行。昨天 04:0x UTC 跑验收时上游无未过期行, 当天 22:00:46 UTC 才生成新一批,比验收晚 18 小时。 ⇒ 重开条件从一句散文改成可执行 SQL(expires_at > now()body_text 非空的行数 > 0)。

补跑结果:本地缓存 20 行 / 18 行带 body_text(684–57534 字符)。

  • 路径确实活了——这一点必须先证,否则又会把「饱和」误读成「路径没走到」, 正是上一轮踩的那个坑。用户 A1、缓存里 A2 文章 3 篇:若仍是 difficultyTier 回落, 三篇都该是「对你刚好」;实测两篇带 body 的 A2 都显示「略有挑战」, 只有没 body 的那篇仍是「对你刚好」⇒ coverageTier 在驱动。
  • 改前/改后档位分布 轻松 0 / 刚好 1 / 略有挑战 19,两侧一字不差。 「改前」用快速分级 C2 首卡 = abba(改后 acetate)做二值探针确认补丁真在跑 —— 没有这一步,「两侧相同」和「补丁没生效」无法区分。

🔑 结论 = #7 的改动在直方图上是真的,但在这批数据上够不到档位阈值, 并因此修正 §4.2「各档一致下修」的读法:那句说的是比值,不是档位。 A1 用户读 A2–C1 新闻,超纲率远高于 coverageTier 的 5% 上界,移除 770 个专名让比值下降 (方向与 Zipf 推算一致)但仍远在 5% 以上 ⇒ 档位不动。用户等级与文章难度差得越远, 档位越饱和、对本改动越不敏感。⇒ 想在档位上看见 #7,得挑等级与文章难度接近的用户, 不是加大文本的专名密度。

顺带记一条工具行为:pnpm tauri dev 的 watcher 对就地改写不触发,要 touch 一下才重编译。

chore:合仓后数据/资产目录整合 —— 两条静默失效的规则 + 两处「来回复制」结构化(2026-08-31)

合仓后第一次系统盘点根与各子项目的 data/·logs/·temp/·backups/ 与共享资产落点。 结论是两处真缺陷 + 两处该结构化的重复,其余(tools/vocabulary_builder_v3/{data,output} 的输入/产物分离、根 logs/)本来就对。

rvh/.gitignore 的预装库例外规则已失效(零现象)*.db 下面那条 ! 例外同时坏了两处 —— 文件 2026-07-26 改名 reading_vocab.dblampio_dict.db 后旧名匹配不到任何东西,且 .gitignore# 只有行首才是注释,那行尾部的「# 预装库例外,需提交」是 pattern 的一部分。 现在不炸只因该路径早已 tracked,而 git check-ignore 默认跳过已跟踪文件、git status 恒干净; 真出事要等到某次重新 git addrm --cached 后重加 / 本目录再进第二个 .db),届时静默丢资产。 → 修规则 + 在 cross-end-check.sh §B 补 check_committable 断言(红线 #10 的另一半前提: 资产不仅要「只有一份」,还得「进得了 git」)。⚠️ 判据只能看命中的 pattern 是不是 ! 开头, 不能看退出码check-ignore -v 命中否定规则时同样 rc=0。两个原缺陷各反向注入实测过。

② kaikki dump 绑死在 ~/Downloads 的绝对路径 symlink:换机 / 清 Downloads 即断, 而断掉只有跑 pipeline 时才发现;与此同时仓根 data/ 躺着同一份文件(md5 相同、436 MB、 零消费方、无任何文档提到)。→ 把仓根那份移进 tools/vocabulary_builder_v3/data/ 当真身, 仓根 data/ 目录随之消失;vb3 README 的「重建 symlink」小节改写为「直接放这里」。

③ lemmatizer 资产同型换型(byte-equal 两份 → 只有一份)rvh/assets/nlp/{base_forms,surface_to_base}.json 改为指向 RB src-tauri/assets/nlp/ 的 symlink,与预装库 2026-08-29 T4-5 那次同型同理由。 cross-end-check.sh §C 的 sha256 比对必须一起换掉(sha 跟随 symlink = 拿同一个文件跟自己比、恒绿), 换成与 §B 共用的结构三态 assert_single_file(四种形态各反向注入实测)。 RVH 侧消费方是 CLI/测试(dart:io File 读),flutter test test/core/nlp/ 102 passed。 🔴 过程中实测到一个真坑并写进纪律:在 symlink 化的资产上做反向注入,cp X 链接 / > 链接穿透写坏 RB 真身(当场把 base_forms.json 截成 45 B,已从 index 还原)—— 必须先 rm 链接本身。

④ 其它word-illustrations-mapping.tsv 的 vb3 镜像改 symlink 指向 docs/cross-end/ 那份(双仓时代交接留下的拷贝); 删空目录 tools/vocabulary_builder_v3/temp/(零使用者);新增 docs/brand/README.md 把 logo 母图 → favicon / Tauri icons / rvh branding 三条派生链docs/plans/rename-plan.md(会归档) 搬进活跃文档;根 CLAUDE.md §12 注明 temp/ 有个非 scratch 居民(temp/library 是 dev 快照缓存)。

⑤ 清理执行:删 rvh/data/(21 个 tracked 文件 / 14 MB —— 18 张 2026-01 开发期截图 + 3 个已被 RB pipeline 取代的 CEFR CSV,全仓零引用)· 删空壳 rvh/{temp,backups}/(只有 README + .gitkeep、零写入方;logs/ 留下,run_android.sh 真在写)· 根 backups/ 172 MB 移出仓外到 ~/Backups/lampio(与 backup-supabase.sh 的仓外约定一致)。rvh/CLAUDE.md 的文件组织约定同步改写。

⑥ 🔴 顺藤摸到的最大一条:根 .gitignore 的无锚定规则正在静默忽略 110 个已跟踪文件。 查询 git ls-files --cached -i --exclude-standard 返回 110,其中 104 个是 rvh/lib/**/data/** 的 Clean Architecture 数据层源码(datasource / model / repository), 被那条本意用来挡词库大文件的无锚定 data/ 规则吞掉;另有 rvh/test/data/ 2 个、 rvh/logs/{README.md,.gitkeep} 2 个(被无锚定的 logs 吞掉,使 rvh/.gitignore 里 那两条 !logs/… 反选整个失效 —— 父级目录一旦被排除,子级 .gitignore 再也收不回来)。 已跟踪的照常工作 ⇒ 零现象;代价是 rvh 里新增任何 data 层文件都 git add 不进去、 git status 还是干净的。原注释里那条 !rvh/data/ 反选说明作者当时想到了这个形状, 但只覆盖了 rvh/data/ 一处 —— 没想到 data/ 在 Dart 侧是遍地都有的层名。 → 根规则整条删除(大文件改由 tools/vocabulary_builder_v3/.gitignore 逐个点名, 那边的 !data/ no-op 一并清掉),logs / temp/ / backups/ 全部锚定成 /logs/ 式; 110 → 0。新增守卫 scripts/check-tracked-ignored.shpnpm run check:tracked-ignored,基线 0,反向注入实测),接进 ci.yml, 并把 '**/.gitignore' 捞回 CI 的 paths 过滤器(否则只改 rvh/.gitignore 不触发本门)。

⑦ 防再漂:把「仓库地图」变成可执行的白名单。上面两条缺陷的共同点是 控制文件里的断言没有机械守卫 —— CLAUDE.md §2 的仓库地图声明了顶层成员有哪些, 但没有任何东西在验它,于是仓根多出一个目录五个月无人知晓。 → 新增 scripts/check-repo-layout.mjspnpm run check:repo-layout):仓根出现白名单之外的目录就红,红时不只报错, 还追问「它凭什么是顶层成员而不是某个子项目里的一层」并给三条落点建议。 新增顶层目录 = 同时改白名单与 §2 地图,只改一处会红 —— 「建目录前先确认」由此结构化。 ⚠️ 覆盖面是互补的两半:CI 只看得见 tracked 目录(checkout 无未跟踪内容), 未跟踪的孤儿目录只有本机那一跑能发现 —— 被清掉的那个 436 MB data/ 恰好是 gitignored 的。 接线:ci.yml(与 check:tracked-ignored 并列)+ /arch-check S30(本 skill 首次 刻意越出「RB 源码」作用域,理由写在规则表那行)+ /doc-sync-check 新增第 7 类变更 「目录/布局变更」(触发条件 = 顶层目录增删或任一 .gitignore 改动)。反向注入实测: mkdir data → 立刻红。

⑧ 一次性复查发现的另外两件事(不设常设闸门,理由记在 docs/brand/README.md): 用「同 blob 多路径」扫全仓 28 组重复,绝大多数合法(Flutter 生成的多倍率图标 / 空文件 / 两个 MCP 子项目各自的 tsconfig),但撞出 landing/app/icon.svg 是品牌母图的第三份逐字节拷贝 —— 而本轮新写的 docs/brand/README.md 里正写着「landing 没有品牌图标资产」,已修正 (它走 Next.js App Router 的 app/icon.svg 约定,不经 public/,所以不是多余的一份, 只是没人记得它在)。另一组 docs/cross-end/{rb,rvh}.csv 逐字节相同是结论本身 (两端归一化无漂移的证据,该目录 README 明写「别拆别删」)—— 正是这种「相同即是结论」 的存在,让全仓重复检测不适合当 CI 门。

test:专有名词治理 L-A 的实机验收补完 —— 机械层与实机层第一次给出相反结论(2026-08-30)

proper-noun-governance-plan 的 §4.3 四条 + §4.2 改前/改后对照,在 dev app + rb-debug MCP 上 真跑了一遍(第五条 L-B 已作废、不该跑)。此前步骤 7 一度记成「实机验收 ✅」,而那些数 是对真库跑 SQL 查出来的——2026-08-27 更正为未做,本次才补上。 结果 = 3 条通过 + 1 条 4/5,无代码改动。

改前/改后怎么做的:把 db/vocab_scope.rs::exclude_proper_nouns 临时改成恒真、 重编译测完再 git checkout 还原,两边同一篇文本、同一个账号。判据取 content webview 里的 .rb-discover span,不靠肉眼——「看起来没高亮」和「DOM 里真的没这个 span」是两件事。

  • 发现模式 ✅:John Texas Virginia Manchester Todd Claire 消失, York Peter 仍在(两者 word_tags IS NULL,判据够不着)。同页待发现词 51 → 45
  • 快速分级 C2 首屏 ⚠️ 部分达成(与计划结论一致):人名 12 → 6。 首卡 acetate,第 3 张是 ajax —— 它的卡面直接印着 prep., 「判据第二半放行」的机制在界面上肉眼可见,不必回去读 SQL。
  • 难度徽标 ✅:人名密集文本 B2 → B1,下修恰好 1 档 ⇒ difficulty.ts::coverageTier 的 2%/5% 阈值本轮不动。
  • 误杀反向验收 ❌ 4/5:march/polish/american/chinese 都还在,fox 不在

🔴 最有价值的一条:机械层与实机层在 fox 上给出相反的用户结论,而两边都没算错。 计划里「误杀 0」说的是判据层——fox 确实 passes-predicate;但候选池是 判据 ∩ reference_words 黑名单两张网串联,黑名单不在判据作用域里,于是用户在页面上 看到的误杀不是 0。决定性佐证:改前那版(判据恒真)fox 同样不高亮 ⇒ 不是 L-A 的回归, 是那条老误杀(source='system'、用户删不掉),归 L-D。⇒ 「误杀 0」今后必须带作用域说。

另外两件机械层看不见的事: ① 改造点 #7 本轮实机未被覆盖 —— 本机 fetch_recommended_articles 拿到 0 篇带 body 的缓存文章,coverageNone ⇒ 推荐卡档位回落到 difficultyTier(纯 CEFR 减法,L-A 不碰), 改前/改后都是 5/5「对你刚好」。零漂移不是「改动无效」,是这条路径压根没被走到—— 而两者在界面上长得一模一样。真正验到的是改造点 #5。 ② bangbus(成人网站品牌)出现在 C2 首屏 20 词里 —— §0.5 症状 4 的第一个实机实例, 任何围绕 proper_noun 的查询都显不出它,只有真的翻卡片才撞得见。

🔑 一条方法学结论:计划里的数字全是快照相关量,别引用、要就当场重测。 plan 记 584/871/11707、「8 漏 6」、「12 → 10」;本机 dev 库实测 770/1058/11594、「8 漏 2」、「12 → 6」——本机 proper_noun 标签覆盖更宽。 方向性结论逐条成立,数字逐条不成立。 另一个必踩的坑:discovery_cefr_levels 默认只有 B1,B2,不先开满六档, A1/A2/C2 的样本压根不进候选池,「没高亮」会被误读成「被判据滤掉」。

计划仍不归档,但理由换了:不再是验收欠账,而是它是 L-C / L-D 的唯一真相源 + 四条已知未修(§4.5)。

ci:CI 接线四条尾巴全部做完 + 一个「伪装成安全事故」的假报警(2026-08-30)

四条:① release-verify 拆静态段接 CI · ② ci-verify.ymlpaths · ③ privacy-verify 的线上段改走定时巡检 · ④ 新增 check:readme-pathsci.yml。 这批尾巴本身是同日从归档件里捞回来的 —— post-merge-repo-optimization-plan 以 13/13 归档时它们只登记在台账的结论段里、没有搬家,「plan 会归档,尾项不会」的又一个实例。

③ 新建 .github/workflows/monitor-prod.yml(本仓第一个 schedule 型 workflow), 每天 01:00 UTC 跑 privacy-verify.sh --max-skip=8;同批给该脚本补上 --max-skip。 🔑 决定性理由比立项时写的更硬:不只是「让第三方 uptime 决定 CI 红绿」,更是 admin 走 Vercel 部署、而 Vercel 不等 GitHub Actions ⇒「commit 绿」与「线上安全」之间 本来就没有因果;真正会出事的时刻(部署完、而没有任何人提交任何东西)恰恰是 commit 触发永远覆盖不到的。⇒ 只能跟着时间走。 CI 实跑 PASS=7 FAIL=0 SKIP=8,与本机沙箱预测逐字相同。

check:readme-pathscheck-claude-md-paths.mjs--readmes 模式。 🔑 文件集用 git ls-files 现取,不写死清单 —— 索引类文档的职责就是指向别处, 是本仓最容易腐烂的一层;写死清单会让「新加的 README 没被纳入」变成静默缺口, 那正是这道门要防的病本身。反向注入里有一条专验这点:新建一份带失效路径的 README, 未 git add 时不报、git add -N 之后当场红。 首次全量跑 37 份里 5 份不合规(3 处「反向指路」+ 2 处子树相对),逐条订正后才接进来; 同批归一 8 处 ~/-锚定双仓写法 —— 那种写法匹配不上判据的正则,写了等于没守门。 接线时把 '**/README.md''**/CLAUDE.md' 的先例加进 ci.yml 两处 paths(37 份里 有 20 份住在被排掉的 admin/ landing/ rvh/ 下,不捞回来等于「配了门但那 20 份永不触发」)。

🔴 ③ 首跑炸出一个最坏形状的假阳性 —— 不是「闸门坏了没人知道」,是「闸门谎报安全事故」。 日志写着「/admin 返回 HTTP (应 307 到登录页)」「门没挡住这个变体」,看起来像 生产站点的鉴权门全没了。真因是 mktemp -t privverify-t NAME 是 BSD 语法, GNU coreutils 要求模板含 XXXXXX,否则报 "too few X's" 并输出空串BODY_FILE=""curl -o "" 失败 ⇒ 状态码解析成空 ⇒ 每条依赖响应体的判据都 FAIL。 识别线索是失败的形状:凡写临时文件的检查全红、只用 /dev/null 的 P10/P11/P13 全绿。 本机(macOS/BSD)恒绿 —— 只有真的推上去跑一次才看得见。两处已修 (privacy-verify 的 BODY_FILE/SCAN_FILE + release-verify 的 WORK,后者 CI 暂时碰不到但同雷)。

🔴 量 ③ 的基线时又踩了一次「剥凭据」的坑:只藏 admin/.env.local 量出 PASS=13 SKIP=2, 看着像「大部分都能验」;实则 P0-P6 在拿本机 macOS Keychain 读生产库。 把 security 也用失败桩顶掉后真实数字才是 PASS=7 SKIP=8。 ⇒ 「剥凭据」必须同时剥三样:admin/.env.local + Keychain + SUPABASE_DB_URL


①② 的详情如下。

backlog「CI 接线的四条尾巴」的前两条。这批尾巴本身是 2026-08-30 从归档件里捞回来的 —— post-merge-repo-optimization-plan 归档时它们只登记在台账的结论段里,没有搬家

release-verify 拆网络段与静态段。 它静态检查处数最多、本该优先接 CI, 此前不接的理由是 SKIP 数是网络可达性的函数而非环境常量:同机同码连跑两次 SKIP 分别是 4 和 11(第二次 latest.json 没取到 ⇒ R7-R11 连带全 SKIP), 钉任何 --max-skip 基线都会得到一道随网络天气变红的门。 🔑 解法是拆段而不是调基线 —— 新增 --static-only 只跑 R1-R4 (三处版本号一致 / semver 判据自检 / 正式版 identifier 硬闸 / updater 接线五环), 全是本地文件读 + node + python3 + grep。实测(把代理指向关闭端口 127.0.0.1:9, 任何 HTTP 都会失败):静态段 0.45s / SKIP=0(完全不受影响)· 全量 20.1s / SKIP=10(正是那个病)。 沙箱复测(藏 admin/.env.local + 空 HOME + RB_PROXY= 直连)与本机常态逐字相同; CI 首跑与沙箱预测也逐字相同(PASS=4 FAIL=0 SKIP=0)。 反向注入 4/4:改歪 Cargo.toml → R1 红 · 把 semver 比较器退化成字符串比较 → R2 红 · 版本去掉 prerelease 段 → R3 红 · 从 capabilities 摘掉 updater:default → R4 红。 顺手把汇总+退出收敛成单一 finish(),免得重蹈 sync-verify 那个 「早退路径各抄一份 ⇒ --max-skip 在唯一会走到的 CI 路径上完全无效」的覆辙。

ci-verify.ymlpaths 触发面。 形状照 ci.yml:以 '**' 起头 + 逐个 ! 排除, 不是正向白名单(起点是全仓,新目录默认在内、漏不了)—— 这四个脚本守的是跨切面不变量, 写窄了让真该跑的不跑比白跑严重得多。每条排除都逐个 grep 过四个脚本的路径引用: admin/**(一处不读,release-verify 里的 admin/… 只在注释里)· landing/**(只有网络段的 R13 读它)· 三个 archive/**(零引用)。 ⚠️ 不能排除 rvh/** —— learning-loop-verify 的 L2 要断言 Dart 消费方读的是根那一份黄金向量。 实测探针(短命分支,验完即删):只改 docs/plans/archive/ 的提交 → CI 触发、 CI (verify) 未触发 ✅。

scripts/README.md 的分类与矩阵同步:「暂缓、有明确理由」这一类现在是空的,🔴 8 → 7; docs/verification/release-chain.md §1 补一条分界说明 —— 读那张覆盖表时要分清 哪几行是「每次提交都在验」、哪几行是「只有人记得跑时才验」,后者假绿风险高得多。

chore(docs):文档治理尾项 7 条结案 —— 3 做 / 1 早已完成 / 3 改判(2026-08-30)

来自 docs-governance-plan.md(2026-08-14 立项)归档时转入 backlog 的最后 7 条。 逐条现查之后只有 3 条是真活 —— 这本身是「条目写下之后没人回头销账」的又一个样本。

ci.yml 不跑 eslint —— 真问题是作用域缺陷,不是「Deno globals」。 原记录写「全仓 507 errors,多在 supabase/functions/ 的 Deno 代码」,两点都不对: 实测 243,其中 156 个(64%)在 admin/ landing/ supabase/ rvh/ —— 根 eslint.config.js 只 ignore 了 dist,于是 eslint . 走进那四个各有自己工具链与 lint 的目录(admin/ 的 lint 由 ci-admin.yml 在跑,它和 landing/ 都自带 eslint.config.mjs)。 作用域已限定到桌面端本体 + 排除 src-tauri/target(Rust 构建产物)—— 与 ci.ymlpaths 取反、四个 dev-flow skill 排除 rvh//admin/ 是同一条原则: 根级工具的作用域必须显式限定,别写成根级 glob。 限定后真实存量 44 个全在 src/(25 个是 react-hooks/set-state-in-effect)—— 那是产品代码重构,按原条目给的第二条路显式接受并把裁定 + 订正后的数字写进 ci.yml 头注释;存量单独立条(含规则分档 / 热点文件 / 做法 / 两条别踩的 ⚠️)。

temp/ 11 份跨端交接稿收进版本控制。 全是 2026-04/05 Bug A–G(vocab-pk Phase 3) 那一轮的交接稿与会话 prompt,躺在 gitignored 的 temp/ 下 —— 一次 rm -rf temp/ 就永久消失,而 docs/cross-end/archive/01-vocabulary-word-pk-log.md(3 处) 与 rvh/CHANGELOG.md(1 处)都在按 temp/… 引用它们。全部移进 docs/cross-end/archive/一份没删:本仓多条红线(#5d watermark lag / #9 归一) 能追到这批文件 —— 结论进了红线与 CHANGELOG,但「当时怎么把两个会话协调起来的」只在这里。 该目录 README 补一节逐份说明 + 路径换算(老文档里的 temp/<名字>.md = 今天的 docs/cross-end/archive/<名字>.md,冻结件不去改)+ 为何不补 NN- 编号NN 是时序语义, 补号只能排到 47+,会把 4 月的文件标成第 47 号 —— 同 T4-1 的裁定)。

⑤ Edge Function 的出网 UA ReadBrowser/1.0Lampio/1.0(5 处 / 4 个函数: tts-synthesize · lookup-or-fetch-word · analyze-articles×2 · discover-sites,各自的括号后缀保留)。 ✅ 已部署(2026-08-30,/edge-deploy —— 四个函数逐个 Deployed,secret 预检全齐 (AZURE_SPEECH_* / LLM_*),未触及 _shared/ 故无需牵连其它消费方。 验收用的是「把线上代码下回来比对」functions download --workdir 到隔离目录,不碰本机源码): 5 处 UA 与本地提交逐行对上ReadBrowser 线上零残留;四个 endpoint 均返 401 而非 404/5xx (证明部署落地 + 平台闸完好 + 无启动崩溃)。 ⚠️ 刻意未做完整鉴权调用 —— 这四个真跑起来都有生产副作用或成本(缓冲池写行 / Azure TTS / LLM 计费), 而本次改的是出网 header,下载比对比花钱跑一次更对点。 ⚠️ 这是对外可见的出网标识:若有站点按 UA 做过白名单,行为从此变化。

三条改判 + 一条早已完成

  • admin/AGENTS.md 改判为「引用而非合并」 —— 实测确认它是 Next.js 工具链的注入块 (<!-- BEGIN:nextjs-agent-rules -->END),手工删掉多半下次 next 升级又长回来。 已在 admin/CLAUDE.md 顶部写明「不要并进来」+ 它是什么。这正是原条目自己 ⚠️ 预判到的分支。
  • build_dict.report.txt 改判为「留在原地」 —— tools/README.md 已有一整节裁定过: 输出路径写死在 src-tauri/examples/build_dict.rs:19,「移动它需要同步改代码,不要顺手挪」; 且它是刻意 tracked 的审计报告。原条目的「散在顶层」是观感问题,真问题(不知道那是什么)那一节已解决。
  • build_oxford_csv.py 改判为「不删」 —— vocabulary_builder_v3/README.md 第 58-60 行 明令禁止这个动作:「别因为『零引用』把它们当死脚本删掉,删了就丢失对应输入文件的来历」。 其产物 data/oxford_5000.csv(159 KB)正被 bin/build_all.dart / bin/build_wordlist.dart 消费。
  • ③ 四份文档的「最后更新」行早已删干净architecture / coding-standards / ui-standards / ui-component-inventory 头部都已改成「以 git log -1 为准」)—— 条目写于它们被修之前,没人回头销账。

🔴 ② 的第一版把 CI 弄红了,顺手补了一道判据。 那段说明里把 next 的 docs 目录写进了反引号, 本机装了依赖 ⇒ existsSync 为真 ⇒ 本地绿;CI 不装 admin 依赖 ⇒ 当场红。 check-claude-md-paths.mjs 因此新增一条判据:「本机存在」不够,还要问「干净 clone 里有没有」 (即是不是 git tracked)。 ⚠️ 第一版用 git check-ignore 实现,当场翻车:pnpm 的 node_modules 是 symlink 树, 它对这类路径直接 fatal: … is beyond a symbolic link 退出 128,而 128 ≠「没命中」的 1 —— 落进 catch 就把整批检查静默退化成只查存在性一个坏路径让整道闸门失效,正是假绿的形状。 改用 git ls-files 一次性取 tracked 集合(目录按前缀判),无 symlink 问题,全量 0.53s。 反向注入 5/5:node_modules 路径 → 红 · 构建产物 src-tauri/target/… → 红 · 普通失效路径 → 红(无回归)· tracked 目录 → 不误报 · GITIGNORED_BY_DESIGN 白名单仍豁免。

chore(docs):上下文预算 T0-T4 —— 记忆库启用 + 恒加载区减重 8.6%(2026-08-30)

承接同日的合仓后治理加固。治的是唯一还在按会话次数持续计费的那条欠账: 根 CLAUDE.md 每会话恒加载,而两轮「切 → 回涨」的曲线形状完全相同 —— 91,463 →(08-14 切) 57,266 →(14 天) 73,014 →(08-28 切) 50,557 →(1 天) 66,569。 根因不是「文档写太多」,是记忆库长期空置(那条按需召回的通道从未启用), 于是每条值得长期记住的事实只有一个落点:恒加载区。棘轮没有更便宜的去处。

T0 启用记忆库 —— 5 份种子(user / feedback×3 / project)+ MEMORY.md 索引。

T1 立「内容归属判据」(根 CLAUDE.md §4,净增 1,013 B)—— §4 此前只有「红线归属规则」, 管的是哪个文件;缺的是管哪一层:恒加载 / 按需子目录 / 记忆库 / 普通文档。 一句总原则(放在「需要它的会话集合最窄」的那个深度)+ 5 行落点表 + 一句自检问句: 「一个不知道这段历史的会话,会不会因此把事情做错?」 不会 → 是记忆不是指令。 🔑 写它的过程本身就验证了它:初稿 1,179 B、二稿 1,042 B 两次超阈值,超出来的全是论证性文字 —— 按判据本身那是记忆,已留在 plan 里。这条判据第一次被应用的对象就是它自己。

T2 §9 双端整合 13,574 → 8,996 B(−34%) —— 三条受保护规则逐条 diff: Schema 同步协议 逐字节相同,另两条各删 6 行 / 3 行全是历史记述ci-rvh.yml 首绿 run id + 6 个死因 + FLUTTER_VERSION + dart format 门;cross-end 归并 32 个文件)。 中途误删过两个属于规则具体化的括号,diff 一比就看见了 —— 这正是「一字不少」要 diff 核对的理由。 搬走的各有落点,其中「SM-2 路径写错带偏一个会话、订正没回流又躺一个月」没有归档件承载, 新写一份记忆 control-file-claims-need-mechanical-guards。反向自检 3/3(挑风险最高的三条机械核实, 不是抽样):ci-rvh.yml 头注释对版本与格式门的解释比原文详细,改 CI 的人必然打开它。

T3 §2 项目结构 7,086 → 4,958 B(−30%) —— 两条桌面端专属的坑真搬进子树 (src/pages/ 只有两个页面 → src/CLAUDE.mdcontent-script.js 是 esbuild 产物 → src-tauri/CLAUDE.md,两处目标文件里原本都没有这句)。🔑 最大的一件不在原方案里: 两张目录树在说同一批目录(「目录布局」给用途、「仓库地图」给耦合,四个卫星两边各写一遍), 合成一张占了本次减重的一半以上;另去掉与 T2 刚建的 §9「子项目边界」表的重复 —— 不去重就是当天自造的第二本账。

T4 防棘轮做成「仪表 + 判断层」而不是闸门 —— scripts/context-budget.sh exit 0: CLAUDE.md 家族体量 · 恒加载合计(根文件 + 12 个根 skill 的 frontmatter,后者恒注入, 只看根文件会漏掉一半)· 分节 Top6 · 记忆库条目数 · 与台账上一行的差值。 判断层 docs/verification/context-budget.md(5 条问句各带假绿风险 + 反向配方 + 已知未修 + 趋势台账, 回填了三行历史峰值作基准)。接进 /docs-audit Step 0;scripts/README.md 新增第四类仪表 并写明🔴 别把它当闸门接进 CI —— 任何固定体量阈值都会在「正常增长 → 下一次治理」之间恒红。 反向验证 3/3,其中台账列改成非数字时不崩且回退到上一条可解析行(fail-soft,优于设计预期)。

T5 §4 那 21.9 KB 双端契约红线:裁定 A(维持不动),用户拍板。 写明这是已决不是遗留议题,重启需要新证据 —— 重启条件在 docs/verification/context-budget.md §3,不在已归档的 plan 里。

🔴 两个阈值据实改判:T2 的「< 6 KB」与 T3 的「< 4 KB」都不成立。 §9 里不能动的部分本身就是 5,723 B、§2 合并后的仓库地图本身 4,217 B —— 达标只能删规则,那正是「为了让闸门变绿而破坏它守的东西」。 共同原因是立项时没有先量「不能动的那部分」有多大,本仓「数字要现场重测」的教训 这次应在了计划作者身上,已写进 plan 供后来的减重任务参照。

净结果:根 CLAUDE.md 66,569 → 60,814 B(−8.6%),恒加载合计约 18.8k tok; 减掉的全部是元记述,规则一条没少(三条受保护规则逐条 diff 在案)。

chore(docs):合仓后治理加固 —— 守门覆盖对称化 + 记忆库启用(2026-08-30)

起因是一轮合仓后评估:合仓兑现的四条收益(cross-end-check 进 CI · 契约唯一归属 · 共享资产一份 · 编号劈裂消失)全部属实,但兑现的是根侧的 —— 代码、契约、CI 进来了, 文档治理与守门覆盖没进来。下面七项都是这一个成因的不同面。

check-doc-links.mjs 改双模式 + 覆盖两棵文档树。 原闸门只认以 docs/plans/docs/cross-end/ 开头的全路径,而同目录文档互引按惯例写不带前缀的相对路径,正好落在 正则外 ⇒ 恰恰漏掉归档动作最高频制造的那一类悬挂。模式 A 行为逐字不变但收回只跑根侧 (放开到子树会多 26 条误报:rvh/docs/ 里裸写的 docs/plans/… 指的是它自己那棵树); 新增模式 B 按引用文件所在目录解析 [..](..),范围扩到 rvh/docs/ admin/docs/ 与 7 份 CLAUDE.md,任何 archive/ 一律不查。⚠️ 行内代码要换成 _ 而不是删掉 —— 本仓主流写法是 标签包反引号目标不包,删掉会让链接语法不成立、模式 B 匹配不上(丢覆盖面)。 反向注入 5/5,第 5 次是它当场抓了 as-built 自己写的两处示例路径。

② 首次覆盖当场扫出 16 处真悬挂,三类成因:backlog 分片把 epub 条目逐字搬进 backlog-archive.md 而相对基准没变(15 处引用)· T4-3 归档 rvh plan 后索引没跟着改 · 把根侧文档写成了子树内相对路径。另 4 处在 admin/CLAUDE.md / landing/CLAUDE.md, 是把 13 处 ~/reading-browser/…(双仓时期绝对路径)归一成仓根相对时暴露的 —— 那种写法没有任何闸门看得见check-claude-md-paths 的正则要求反引号后紧跟白名单顶层目录), 归一后它们才落进守门范围。

③ 修 rvh 侧两处假守门。 doc-consistency-check.sh 的 Step 4「断链检查」是一张写死 3 个文件名的黑名单,对新造断链恒绿 —— 归档当天造出的 2 处真悬挂它照样报「✅」。 按 T2-2 的同一修法就地改指真判据(委托仓根闸门),不删步不重编号;改的过程中撞到 set -e 让失败的命令替换当场中止整个脚本(既不打印诊断、Step 5/6 也不跑),已围起来。 install-hooks.sh 则是完全不工作:它检查 rvh/.git(合仓后不存在)⇒ 一进门 exit 1; 生成的 hook 又去调仓根 scripts/pre-commit-check.sh(该文件在 rvh/scripts/)。 两处路径假设都改对,并加一条合仓才需要的范围守卫:hook 装在仓根会对每次提交触发, 而它跑的是 flutter analyze ⇒ 本次没碰 rvh/ 直接放行。

/docs-audit 扩到两棵树。 它的 roots 缺 rvh/、扫描清单只有 3 份控制文件 (合仓 + T1-2/T1-3 + T2-1 之后是 7 份)—— 这是 rvh/docs/ 那套索引烂了大半年没人发现的 直接原因。同时修两处判据缺陷:PLACEHOLDER 漏 kebab-case同一个疏漏让 main 的 CI 连红过 6 天)· 状态行判据认不出 状态(… 更新):

⑤ 重写 rvh/docs/README.md(427→128 行)+ 新建 rvh/docs/plans/README.md 前者停在「最后更新 2026-01-28」,自称「38 个文档 / 6 个 Skill」(实为 59 / 8),逐条列着 20 多份 plan(两份已归档 ⇒ 死链),用着 2026-07-26 就改掉的旧库名,还宣传一条根本不存在的 git hook 保障。保留 7 层骨架(8 处按它引用)但剜掉腐烂:新增「合仓之后有五样东西不在这棵树里」 (产品定位 / cross-end / 契约红线 / CI / CHANGELOG 的真相源都在仓根)· 删掉那张必然腐烂的 plan 清单 · 份数一律给现算命令。后者把根侧 2026-08-14 起就有的三条约定(状态行纪律 / 归档节律 / 红线归属)补到 rvh 侧 —— 落差是可量的:根侧一次性 plan 13/13 全有状态行,rvh 侧 11/22 没有。

⑥ 仓库门面补上合仓事实。 README.md 写着「四个卫星子项目」不含 rvh/ —— 合仓两天后 第一入口仍看不出移动端在这个仓里。改为五个 + 一张表 + 写明 rvh/ 与其余四个性质不同。 「唯一的 as-built 日志」这句假话 T1-3 只修了 CLAUDE.md 两处,活跃文档里还剩 4 处,一并改成 「每端各一份」(rvh/CHANGELOG.md 248 KB 比根这份还大,只查一份必然漏)。 另修一处说反话的状态行(rename-plan.md 写「仓改名待办」而同一份文件的勾选框记着已完成) 与一处 verification/README.md 自己明令禁止的数值快照。

⑦ 启用持久记忆库 + 立项 context-budget-and-memory-plan.md~/.claude-cli/.../memory/ 此前是空目录、MEMORY.md 不存在 —— 这条按需召回通道从未启用, 于是每条值得长期记住的事实只有一个落点:恒加载的 CLAUDE.md,即棘轮的动力源。 实测曲线两轮「切→回涨」形状完全一样(91,463 →切 57,266 →14 天 73,014 →切 50,557 →1 天 66,569), 现值已回到第二次切之前的 91%。已写 4 份种子记忆 + 索引;减重本身按 plan 分 T1-T5 推进, 其中 §4 那 21.9 KB 的处置待拍板(「不下沉」那条裁定的前提是「除恒加载外没有别的按需通道」, 记忆库启用后这个前提变了)。

fix(release):发版链路三处「约束写在散文里、代码没断言」补上断言(2026-08-30)

来源 = docs/verification/{release-chain,data-baseline}.md 2026-08-27 那两轮验收记的 K2 / K3 / K6 与 data-baseline K3。三条同一个形状,且在下一次发版同时引爆,故一批做完。 这个域的失效不可观测 —— 出事时没有报错、没有告警,只有存量用户悄悄停在旧版本上。

promote-updater 缺「公开 Release 已 publish」前置断言 —— cmd_promote_updater 开头加 gh release view --json isDraft,草稿即拒绝并给出转正命令。 此前它用你的 token 从草稿里就取得到 latest.json 并推到公开的 updater-latest, 于是 endpoint 上线了、而客户端匿名去下资产是 404:两边都返回"成功",只有用户那头是坏的。 顺序原先只靠 /release 的 Step 7 → 7.5 保证,任何人手跑一次本子命令就绕过了。 反向注入实测(假 gh 顶掉真 gh,全程不触网):isDraft=true → 拒绝且不进入 download;false → 放行。

gen-latest-json.mjs 不核对平台数 —— 改为从 release.yml 的矩阵 target 现推 期望平台集合,集合不等即 exit 1;读不到 workflow / 推不出平台也 exit 1fail-closed, 否则这道闸变恒绿)。此前只在平台时才报错,配合 upload-artifactif-no-files-found: warn,「一条腿成功了但没产出制品」会静默生成只含两平台的 latest.json 并上线 —— 后果是最难发现的一种:只有那个平台的存量用户永远收不到更新,Release 页 / 下载页 / 看板 三路版本串全部一致,什么都不会红。判据与 release-verify.sh R8 同构(同一张映射表、同一条正则), 两处刻意不分叉:R8 事后复核、这里事前拦截。反向注入实测:抽掉 windows 那条腿的制品, 旧版静默产出 2 平台 latest.json 且 rc=0,新版 rc=1。

③ 指纹清单没有「发版后必须重生成」的闸门 —— 接进 /release skill 的 Step 8.1(硬步骤)

  • 护栏 #10:publish 之后跑 migration-verify.sh --update-manifest 并提交清单变更。 shipped_migrations.txt 是红线 #11 内联迁移那一半的唯一守门依据,由发布 tag 现推 —— 在此之前没有任何东西强制发版后去跑它,不跑 = 新出厂的迁移回到「改了不会有任何东西变红」。 🔒 刻意不做成 CI 硬闸:那会在「发版当天 → 跑清单」之间恒红,而恒红的闸门与恒绿的一样坏 (2026-08-18 实测过,CI 连红 6 天)。让它跟着发版动作走,不跟着时间走

顺带修 K6 的 SKILL.md 过期数字,两处改法不同.msi 那处是事实错误 (bundle.targets = nsis/app/dmg,WiX 不接受非数字预发布标识)→ 改对; 「当前 0.1.0-dev.8」那处删掉这个数而不是更新成 dev.10 —— 更新一次只会再漂一次, 正是 K6 自己总结的教训。三处作为历史事实的 0.1.0-dev.8(冻结基线 ×2、首个带 updater 的版本 ×1)刻意保留。

③ 的落点自查(同日追加):Step 8.1 初稿写「必须在 publish 之后跑,因为脚本读的是发布 tag」—— 理由是错的。读实现确认 shipped_tags() = git tag --list 'v*'只认本地 git tag, 不看任何 release 的存在与发布状态,从 Step 4 打完 tag 起就跑得动。位置放 Step 8 仍是对的 (让"入账"贴近"真的出厂"),但那层贴近靠流程约定、不靠脚本。 顺带查出一个真实缺口并登记为 data-baseline.md K7v0.1.0-dev.9 是本地 tag (也在 GitHub 上),但私有仓与公开仓都没有它的 release —— 从未出厂,却照样被当作 已出厂 tag 参与现推。今天没造成污染纯属运气(没有迁移在 dev.9 首次出现), 且误差方向是过严不是过松。Step 8.1 已补:发版中途换过 dev.N 就要看一眼 diff 里 有没有那个废弃版本号。同时补上脚本自己提醒的 cargo test --lib db::migrations

台账同步:release-chain.md K2/K3/K6 与 T3 · data-baseline.md K3/E2/K7 · release-verify.sh R12 注释(不退役,改为事前/事后分工)· backlog 两节移入 backlog-archive。 验收:release-verify PASS=17 FAIL=0 SKIP=0 · migration-verify PASS=10 FAIL=0 SKIP=0 · --update-manifest 幂等 · bash -n / node --check 均过。

fix(scripts):四个 verify 脚本的「变量后接中文标点」静默截断(2026-08-29)

post-merge-repo-optimization-plan T1-1set -u 下裸 $var 后紧跟非 ASCII 字节 (全角 、破折号)会被 bash 并进变量名 → unbound → 当场终止。逐处改 ${var}privacy-verify 10 处 · release-verify 10 · ops-verify 5 · migration-verify 4, 四个脚本头部照 cross-end-check.sh 先例写死陷阱说明 + 自检命令。修完全仓 14 个 .sh 扫描均为 0。

🔴 真正的收获不是改语法,是这四个脚本此前根本没跑完过 —— 修前逐个实测的断点: release-verify 死在 R1(R2–R15 从未执行)· privacy-verify 死在 P7(P7–P13 从未执行)· ops-verify 死在 M3(M3 后半 –M8 从未执行)· migration-verify 死在 D4 之后(D5–D9 从未执行)。 汇总行压根不打印,所以「以为一直是绿的」从来没有证据。修后四个脚本各整跑一次, PASS 16/17/24/10、FAIL 0、SKIP 0(本机装 libpqpsql 就位,privacy/ops 的 DB 段不再 SKIP)。

捞到一个新 FAIL 并当场修掉migration-verify D9(红线条文必须覆盖全部冻结文件) 只扫根 CLAUDE.md,而红线 #11 的正文早已下沉到 src-tauri/CLAUDE.md, 根那份只留一句话索引 ⇒ init_data.sql / seed_reference_words.sql 判为「文档没提」。 文档并不窄,是断言指错了地方(红线分表后没人回头改它,而它此前从来没执行过,所以一直没暴露)。 改为扫 CLAUDE.md 家族(根 + src-tauri/),D9 转绿。

docs/plans/backlog.md 里那条 🔴 记录随之删除(四个脚本已是修完态)。

refactor(cross-end):预装库从「两份 byte-equal」变成「一份 symlink」,连带三笔收尾(2026-08-29)

rvh-merge-plan T4-5 / T4-6 / T4-9 前置,外加两笔守卫补漏。

T4-5 预装库去重rvh/assets/databases/lampio_dict.db 改成指向 src-tauri/assets/lampio_dict.db 的 symlink。红线 #10 由此从「靠断言守」变成 「结构上不可能违反」。flutter build bundle --target-platform=android-arm64 实测跟随 symlink (产物是真实 65,777,664 B db、sha 一致、AssetManifest.bin 有它),flutter test 1359 passed 与基线逐字相同。

🔴 计划正文的收益模型被实测推翻:「每次 reseed 少长 65MB」不成立 —— git 按内容寻址, byte-equal 的两份天然只存一个 blob(HEAD 两路径同为 b7c3bdd4;全历史 distinct blob 并集 8,不是 13)。省的不是仓体积也不是 clone 下载量,是工作区 checkout 65MB + 判据结构化。 退路(build 期 copy)因此被排除:它唯一的卖点已被证伪,却要新增一个隐式构建前置。

cross-end-check.sh §B 必须同批换型,否则恒绿 —— [ -f ]sha256 都跟随 symlink, 照旧跑就是拿同一个文件跟自己比。改为结构三态:✅ -L-ef 解析到 RB 那份(-L 不能省, 否则硬链接会过)· ⚠️ 40 字节链接目标文本(Windows core.symlinks=false 的检出形态,判「本次没验」)· ❌ 其余。四种缺陷各反向注入实测过。Windows 风险经用户裁定「接受 + 做成可观测」:当前无破坏面 (ci-rust/release 只编译 src-tauri/ci-cross-end 跑 ubuntu),补齐办法进 rvh/QUICKSTART.md

T4-6 跨仓脚本:两格处置各错一半,方向相反。 ① sync-rvh-vocabulary.sh 不删、改型 —— cp 随 symlink 消失,但它装着没有替代品的体积闸 (≥80MB 告警 / ≥100MB 判死,盯 GitHub 单文件硬限)与卫生断言(4 表白名单 + lemma_* 行数 + 机器污染)。 git mvscripts/check-vocab-asset.sh。🔴 顺带补一条正文没意识到的:它原先「必跑」正是因为 你必须跑它才能送库;cp 没了「必跑」也没了 —— 换名字不换命运等于慢性关闸,故同批接进 ci-cross-end.yml。⚠️ 但 CI 挡不住体积闸(≥100MB 时 push 本身被拒,CI 在 push 之后)⇒ /vocab-reseed Step 3 的人工执行仍是主闸。 ② verify-rvh-alignment.sh 一行没改 —— 实测全文对 RVH 的引用只有打印给人看的手工清单, 零跨仓路径、零可达性断言,合仓改变不了它任何一条,「大幅简化」的前提不成立。它是一次性阶段验收, 3-6(user_reference_words 待 Dashboard DROP)未闭前是那件事唯一的自动检查 ⇒ 保留。

T4-9 老仓归档前置(gitee 那一步在用户侧):修掉 20 处归档后会误导的引用。 🔴 正文给的扫描范围与命令都不够:范围(scripts/ .claude/ *.md)三处全干净,真问题全在范围外; 裸 reading_vocab_helper 会把 Flutter 包名全捞进来(rvh/lib/** 每个文件都 import 它), 首扫 200+ 文件几乎全是噪声 ⇒ 判据须收成路径形态。真正必须改的:3 条可复制的 Edge Function 部署命令 · pipeline 6 处指向老仓的计划文档(三份都在本仓 rvh/docs/plans/)· admin/CLAUDE.md 4 处说假话 (「RVH 仍独立仓」,与 T4-7 在根 §9 查出的同型 —— 别只审自己那半边)· docs/vocabulary-domain-knowledge.md 「流水线物理上住在 RVH 仓库」过期两轮 · backlog 两份 RVH 开场模板还在教一条 T4-7 已删的规则 · 老仓 .mcp.json 配置指引(那文件 T3-4 就删了,根 .mcp.json 早就两个条目都有)。 复扫后残留全是历史记述;回滚源已核实完好(老仓 tag pre-rb-merge 在场,本仓两个 remote 都不指它)。

T4-9 收尾 + 全案归档(2026-08-29 稍晚):用户在 gitee 把老仓 reading_vocab_helper 置为关闭(继承「暂停」的代码只读,并冻结新建任务/评论/改名 —— 故要改名得在置关闭之前)。 仓未删:它仍是 tag pre-rb-merge 的宿主与最后回滚源。至此五阶段 + T4-1~T4-10 全 ✅rvh-merge-plan.mdrvh-merge-evaluation.md 一并移入 docs/plans/archive/, 14 处入向引用同批改指。归档时还查出两处控制文件仍在说「阶段 4 尚未收尾」rvh/CLAUDE.md 顶部提示块 + rvh/docs/plans/backlog.md 重启入口,都在教人「挑活前先扫台账别撞车」, 而要防的 T4-3/T4-5/T4-6 早已完成)——已改写。评估件顶部那句「⏸ 待决策,一行代码未动」 也停了整整一天没人动,同批订正,并挂上它那张收益表被实测推翻的两处(65MB 去重 / verify 脚本简化)。

两笔守卫补漏

  • rvh/.claude/skills/preinstalled-db-update 全文审计(v4→v5.1):reading_vocab.dblampio_dict.db · notebook_entrieslearning_entries · 设备活库名也错着(应是 lampio.db,与预装 asset 是两个文件)· 版本锚散文写死 =25 而代码是 29 ⇒ 改成「只看代码」。
  • 🔴 补上「打包塞进去的不是词库」这道闸。backlog LFS 条列的两条假绿,一条已随 §B 换型消失, 另一条曾经真的没人挡release.yml 至今无任何资产校验,Tauri 把那个文件原样塞进安装包 —— 是 LFS pointer / 截断 / 拷错也构建成功、包发出去、用户装上一个没有词典的 app,全程零报错check-vocab-asset.sh 加 SQLite magic-header 身份闸(判据故意不是「够不够大」:130B 的 pointer 与截断到 40MB 的库都要判死),失败时打印成因而不是让 sqlite3 吐 (26)/releaseStep 1.6 + 护栏 #9 硬 gate —— CI 那道只在改动命中 paths 时触发,而发版是从既有 commit 打 tag。

fix(scripts):三个脚本合仓后仍指向老仓,其中两处是静默错target(2026-08-29)

由一个用户提问引出:「RVH 已经迁到 RB 了,为什么还回 RVH 仓开会话?」——措辞回退暴露了实质问题。 顺着查,发现操作性文件里还大量留着双仓框架,而其中三处不只是措辞:

  • 🔴 scripts/learning-loop-verify.shRVH_ROOT 默认值仍是 $HOME/reading_vocab_helper, 合仓后没跟着改 ⇒ 它一直在验老仓那棵冻结在 pre-rb-merge 的树,不是仓内 rvh/。 当天两者恰好逐字节相同 ⇒ L1 全绿 ⇒ 看不出验错了对象。老仓一旦归档(T4-9)就整段转 SKIP。 改成 $RB_ROOT/rvh(同 T4-2 对 cross-end-check.sh 的处理)。
  • 🔴 scripts/sync-rvh-vocabulary.sh 的目标也仍是老仓。照旧跑会把新预装库拷进老仓, 而 rvh/assets/databases/lampio_dict.db 保持旧字节 —— 红线 #10 被静默破坏 (byte-equal 断言在老仓上通过,真正发版用的那份没换)。
  • scripts/learning-loop-verify.sh 崩在 M1$_bad 后紧跟破折号 → set -u 判 unbound → M/N 两段在 macOS bash 3.2 上从来没跑过,而 L 段全绿看不出被截断。与 T4-2 修 cross-end-check.sh 的是同一个陷阱。按模式全扫本脚本 3 处全修,并在头部写死陷阱说明 + 自检命令。 修完立刻跑出 2 个 FAIL —— 都是已知的 K1 存量卡(8h 偏),红得准。

全仓扫描发现同款陷阱还在另外 4 个脚本里privacy-verify 10 处 · release-verify 10 处 · ops-verify 5 处 · migration-verify 4 处),已连同扫描命令记进 docs/plans/backlog.md ——修法的重点不是改语法,是修完整跑一次看有没有此前从未执行的段落冒出新 FAIL

另外两处控制文件在说过期的话,会直接误导下一个会话:rvh/CLAUDE.mdrvh/docs/plans/backlog.md 顶部仍挂着「⏸ RVH 侧任务全线暂停,RB 正在推进合仓」,并说合仓计划「不在本仓」、 「阶段 2 是本仓会话该接的活」——合仓当天就收工了。已订正。

术语一并清理(只动操作性文件,不动历史记述):CLAUDE.md ×2 · cross-end-check.sh ×2 · verify-rvh-alignment.sh · learning-loop-verify.sh · 两个 skill · 一份常驻验证清单, 「RVH 会话 / RVH 仓」改为「另开一个会话(同一个仓,换的是上下文不是目录)」。

docs(claude): §9 从「跨仓协调」改写为「双端整合」,删掉跨仓库路径约定(2026-08-29)

rvh-merge-plan T4-7。「跨仓库路径约定 🔒」整节删除 —— 它要求跨端引用一律写 ~-锚定绝对路径, 而那条规则的前提就是两个仓;合仓后同一个 docs/... 只有一个含义。换成 3 行「读老文档时的换算」: docs/cross-end/*docs/plans/archive/* 里的 ~/reading_vocab_helper/... 等价于今天的 rvh/..., 但属历史记述不改,check-doc-links.mjs 的负向断言因此保留。

🔴 改写过程中查出 §9 在说三处假话(这才是本项的实际价值,删那节只占 ~0.7KB): ① 「RVH 位于 ~/reading_vocab_helper」——已是本仓 rvh/; ② 「RVH 待镜像表改名」——RVH 2026-07-09 就以 schema v56 镜像完成、07-10 三端 verified, 错了七周,而被指向的那份 handoff 自己也停在「⬜ 阻塞于 RVH 会话」, 两份文件互相印证了一个不存在的状态; ③ 「跨仓路径 check-claude-md-paths.mjs 检查不到、只能靠人核」——合仓 + T3-7 之后它能查, 而这句注解恰好写在那个「错了一个月的 RVH 路径」旁边,等于劝人别指望机械守卫。

配套:两份改名计划标 ✅ 并归档(它们自己写着「RVH 收尾后一并归档」),归档后 5 处指针一并改到 docs/plans/archive/...;归档件顶部写明状态行为何错七周——交接单的状态必须由收尾方回填docs-governance-plan.md 的 P0.6 标 ⏭(它当时要求的改法今天是反的)。

会话隔离规则收窄为按工具链判、不按目录判:原三条理由里「文件路径冲突」已随合仓消失(划线保留记录); 新增实践边界——改 rvh/CLAUDE.md / rvh/docs/** 这类不跑 Flutter 就能验收的可在 RB 会话做, 要跑 flutter analyze/test 的回新会话。Schema 同步协议里「5 张共享表」改成正确的 「10 张同步矩阵 / 与 RVH 共享 6 张」,sync.rs 改成早已拆成目录的 commands/sync/

⚠️ §9 净增 3.4KB(8874 → 12277 B)——一项以「删」为名的任务反而变大,因为删的是 3 条约定、 加的是三处事实订正与一条收窄后的规则。已压过一轮(复盘搬进归档件),不为数字再压。

docs(redline):四条双端契约红线收敛为「规则 + 两端落地」四段式(2026-08-29)

rvh-merge-plan T4-4。根 CLAUDE.md §4 的双端契约红线(#5i · #6d · #9 · #10)此前是一张 4 行表, 单元格分别 1705 / 3468 / 541 / 2427 字符——规则、事故记录、RB 实现、RVH 现状全搅在一格里, 于是「改规则」与「改某一端的实现」在文本上无从区分,这正是两端叙述会漂的机制。现固定为 规则(两端同文)→ 为什么 → RB 落地 → RVH 落地,并写死「改规则 → 改根;改某端落地 → 改那端的文件」。 迁移用 52 条关键短语逐条 grep -F 核对,0 条丢失。

🔴 「两仓统一编号」改判为不统一rvh/CLAUDE.md 此前写着这是 T4-4 的活):实测 rvh/ 内约 350 处编号引用(Dart 注释 130+ 处 + 冻结 handoff + CHANGELOG + SQL 注释),重编号 = 在禁改的 历史记述里造死链;且两套编号已经在混用且工作正常(RVH 没有本地编号的条目,注释直接写 RB 的号); 重编号也没有任何机械守卫。替代 = 双向指针,两侧对照表互指。判据与 T4-1 的「编号消歧而不重编号」同款。

另外三处:① 删掉 #10 的过期 cutover 句("RVH 侧改名待其 /preinstalled-db-update"——两端早已都是 lampio_dict.db 且 byte-equal),历史沿革改成 3 行表;② #9 补上「键空间细则」这一层—— cross-end/43 早把「必经 normalize」细化成「解析成一个真实存在的 vocabulary 行」, 而那条细则此前在恒加载文件里完全没有痕迹,只活在 word_key.rs 头注释与 RVH 侧; ③ 把「RVH 未对齐红线」的三本账收成一本——同一份名单存在三处且两处是错的cross-end-check.sh 的人工 checklist 与 learning-loop.md K7 都还写着「4 处」, 而 #7 / #9 已于 2026-08-28 修完)。唯一真相源定为 rvh/CLAUDE.md §跨端契约镜像 backlog, 另两处改指针;残留两条复核为 #2(33 处 LOWER()与 #6(用户切换仍 hard DELETE 5 张表)。

chore(ci):跨端一致性闸门进 CI —— §E 改由 Supabase DDL 仲裁(2026-08-29)

合仓(rvh-merge-plan T4-2)的最大单项收益兑现:scripts/cross-end-check.sh 此前需要 第二个仓才能跑,CI 里没有第二个仓,于是这套判据只存在于「有人想起来手工跑一次」的时候。 rvh/ 成为子目录后它第一次具备进 CI 的条件。新增 .github/workflows/ci-cross-end.yml(触发覆盖 src-tauri/**rvh/**,它守的正是两端漂移)。

接线之前先要能不恒红,而挡路的 3 个 ❌ 暴露了一个设计矛盾:§E 抬头自己写着 「列差异 ≠ 一定是漂移,本节差异需人工判断是否 local-only」,却用 bad() 把结论顶成退出码 1。 「需人工判断」和 CI 的二元判定不可调和。修法不是豁免这 3 条,是把那句人工判断机械化—— 「哪些列参与同步」本来就有真相源(supabase/sql 的远端 DDL):列在远端表里 ⇒ 同步列 ⇒ 两端都必须有;不在 ⇒ local-only ⇒ 两端不同合法。cached_file_path / content_html / linked_at / added_at 因此自动转绿,没有名单要维护docs/verification/learning-loop.md K11 原本开的方子是手工白名单,未采纳 —— 那一栏自己就写了「白名单本身会腐烂」)。 剩下 reading_notes:RVH:source_platform 是真缺失(RVH 本地无此列,push 硬编码 'rvh'), 进显式 COLUMN_WAIVERS,带理由与出处、每轮打印。

另外三处:① RB 列源改静态重建schema.sql + migrations.rsALTER…ADD COLUMN)—— live db 在 CI 里不存在,本机一套列源、CI 另一套就是「本机红 CI 绿」的经典陷阱; 注意 schema.sql 已冻结(红线 #11),不带上 migrations 的增列会假报「RB 缺 reading_notes.deleted_at」。② 新增 --max-skip=N —— skip() 不计入退出码, 路径写错 → 文件找不到 → skip → 退出 0 = 一道看起来绿其实什么都没查的闸门;CI 传 --max-skip=2(§A 那两条取自 gitignored 的 .env.local)。shasumsha256sum 兜底 (Linux 不保证有 perl 的 shasum)。

搭车把 scripts/check-doc-links.mjs 也清到 0 并接进 ci.yml(此前既不在 CI 也不在 package.json):7 处补 archive/ 前缀,3 处占位符(... 省略写法与尚未发号的 NN-*) 由新增的 PLACEHOLDER_RE 排除。先到 0 再接线 —— 接一道恒红的闸门等于没有闸门。

⚠️ Linux / bash 5 首跑未验(本机只有系统 bash 3.2,无 docker)。期望值:CI 首跑应为 ✅ 16 ❌ 0 ⚠️ 2、退出码 0;本机(有 .env.local + live db)为 ✅ 18 ❌ 0 ⚠️ 0--max-skip=2 同时兜住了这个风险 —— 若 awk/grep 在 Linux 上解析不出列,该表走 skip 分支 ⇒ 超基线 ⇒ 红而不是静默绿

顺带订正根 CLAUDE.md §9 一处过期描述(§D「散文写死的期望值与 JSON 是两本账」—— 散文 2026-08-27 已删、现从 JSON 现推)与脚本 checklist 第 1 条(RVH supabase/migrations/ 已在合仓 T2-4 整目录删除)。

fix(vocab):删侧补同一个键推导 + M1 判据改按列分(2026-08-28)

由 RVH 收尾反馈驱动的两条(回执 46,裁定见 cross-end/43 §7):

remove_known_word 走裸 word,与写侧不对称。 症状是「加得进、删不掉」—— UPDATE … WHERE word = ? 影响 0 行、不报错。RB 一度看起来没事:唯一按词删的 调用方(快速分级卡撤销)传的恰好是 vocabulary 行 ⇒ canonical_word 恒等 ⇒ 与写侧凑巧一致。巧合不是保证。已修 + 补守卫 write_and_delete_sides_are_paired(写删两侧的键推导必须成对)。 ⚠️ 时序:在 add 侧改 canonical_word 之前这条路是一直坏的 (存 person、删找 people),上一个 commit 让它变成「凑巧对」,本次才「有保证」。

② M1 的墓碑行判据改按列分。 RVH 问那 3 行已软删的调试夹具能不能靠 deleted_at IS NULL 放过(他们倾向这个)。:红线 #6d P2 规定墓碑行的 synced_at = MAX(远端 updated_at ?? 远端 created_at, deleted_at)created_atupdated_at 可空的那三张表上就是比较操作数,按行放过全部墓碑 = 对那类回声环永久失明。改为按列:参与比较的列裸值一律 FAIL(墓碑不豁免), 不参与的列墓碑裸值只提示;分类由 information_schema 现推不手抄。 结果与他们的方案相同(不动生产数据)但不失明。

本轮自己造了两个坑、当场修掉,都值得记:重写 M1 时弄丢 ::int::text 让 M2 探针恒空(假红,且它伪装成「判据自检失败」);say 函数从未定义, 于是 M1 那两行「仅墓碑行」提示一直是死的 —— 命令找不到、报错进 stderr、 脚本没有 set -e 照常往下跑,输出里什么都看不到。 两次的判据一样:没输出 ≠ 没问题

fix(vocab):键空间分流 —— 标 people 为已认识会存成 person(2026-08-28)

vocabulary同时住着 lemma 行和非 lemma 行bearbearing 都是行), 于是「该往 word 列写哪个串」有两种正确答案,取决于输入从哪来。 add_known_word 此前对全部调用方一律 normalize,而它 5 个调用方里有 4 个 传的是已经从 DB 取出来的 vocabulary.word。可达且静默:卡片显示 people → 用户点「我认识」→ 存成 person → 发现池 v.word NOT IN known_wordspeopleperson 不命中 ⇒ people 赶不走,同时 person 被静默排除 (用户从没标过它)。

落地:新增 db/word_key.rs 判据单点(canonical_word 先试原形、不命中再整串 归一 / surface_word)+ add_known_word 按调用方分流(缺省 = surface, content-script 不传参 ⇒ 不动产物)+ commands.ts 出口改名 addKnownWordFromVocab

  • 3 条结构守卫 + redline9_guard 新增 verdict DelegatesToWordKey

🔴 分流不能省:同一个串 found,正文里双击时弹窗给用户看的是 find、该存 find;分级卡上显示的就是 found、该存 found。字符串不携带「我从哪来」。

裁定与全部推理见 cross-end/43 (RVH 在 42 §6A 提出、45 确认并要求钉死第三步用整串归一)。 存量刻意不回填:回填判据要查 vocabulary,而预装词汇不在迁移链里data-baseline.md F2),那种迁移只会在真机上炸;且本机 population = 0。 检测查询在 43 §6.4。

反向验证 5 处注入全红。其中一处第一次没红 —— 探针选错(测试库里那个多词条目 本身就是一行,第三步根本没执行),改成源码层断言后才红。

test(cross-end):cloze 语境池的真·双端实测 —— 判据本身错了两次(2026-08-28)

cross-end/23 §7 与 cross-end/16 §6 欠了一个月的跨端实测。 两端会话共用一份接力棒台账 docs/verification/cross-end-cloze-pool-2026-08.md (跨端编号 41,已收尾并按生命周期拆散、停止更新)。

结论A 池收敛 + 淘汰序 ✅(双端收敛到同一份 5 条,13 行逐 id 一致;淘汰确实按 created_at ASC, id ASC 排,A4 五步预测无一例外,含跨端那一条)· B C1 静默占位 ✅ 复现 · C 删扫描页 🚫 未验(阻塞在 RVH 硬删,已进 backlog)· C3 时区偏置 ✅ 不成立

🔴 本轮最值钱的产出不是三个勾,是两次「判据自己错了」

doc 23 §7.1 那句「这一步同时验 C3」不成立。 RB 5 条 + RVH 新增 1 条时, 时钟正确与 +8h 偏置给出同一个观测(那条 RVH 行两种假设下都是最新的)⇒ 台账补 A4 鉴别步。 ② 而为订正 ① 而写的那条时间窗,又把 8 小时偏置重复施加了一次、界晚了 8 小时, 且错在不安全方向 —— 照它跑会把「A4 什么也没鉴别」记成「C3 不成立、鉴别通过」。 两次是同一个形状:把「存的是多少」当成随假设变化的量,而它是观测。 可复用的判据:A4 类鉴别只在「现在还没走到那条行自称的时刻」之前有效。

C3 最终由另一条证据结案:该行 created_at 对服务端 trigger 写的 server_updated_at 只差一个 sync 延迟(有偏置会差约一个时区)。这条已下沉成 sync-consistency.md S13 的验法, 取代原先那句「本仓无法验证 RVH 侧是否已改」。

B 的代价比 doc 23 §7.2 描述的大:坏行不只占一个不可见的坑,它进池时把最旧那条好语境 挤成墓碑、墓碑照常上行(红线 #6b)⇒ 那条真实语境两端都没了。新增判据 sync-consistency.md S13a:比 cloze_contextspositions 两个数组的长度即可, 不必揭晓复习卡读 n/N、也不写库。

顺带:红线 #6d 拿到三次野外实证(本端来源墓碑 1 次 + 远端来源 2 次,写完即不脏、无回声环); 确认 RVH 侧「入池 + 封顶淘汰」也共用一个时间戳,与 RB 同形;一条产品缺陷进 backlog (满池复活绕过封顶 + 谎报「✓ 语境 +1」)。本轮无代码改动。

test(learning-loop):第六个专题「学习循环」—— 抓到 34 个时间戳列里唯一那个裸值(2026-08-27)

docs/verification/learning-loop.md + scripts/learning-loop-verify.sh(L1-L5 源码层跨仓 + M1-M4 部署态)+ scripts/lib/mastery_ladder.py(两端阶梯提取器,带 --selftest 阳性对照)+ lemmatizer.rs::redline9_guard(3 条)+ 交接单 cross-end/40

抓到的真实缺陷user_learning_entries.next_review_date 是共享表 34 个 text 时间戳列里唯一带 timezone-naive 值的一列(45 行里 29 行)。根因在 RVH —— SM2Result.getNextReviewDate()DateTime.now()(本地时钟),而同一次写回的 兄弟列(lastReviewDate / updatedAt)全用 nowUtc()。后果两条且都静默: ① 两端的到期判定都是字符串比较(该列在两端都是 TEXT),UTC+8 下卡片 晚 8 小时才到期;② merge 的 remote_next > local_next 同样是字符串序 ⇒ RVH 状态恒赢;③ 负时区下方向翻转(提前到期 + 恒输)—— 单地测试只看到一半。 实测 5 张复习过的卡里 3 张(全部 RVH 复习的那些)偏 8h,RB 复习的两张正好 24h。

🔴 RB mark_synced 的注释写着「避免……RVH 推上来的 timezone-naive 字符串 字典序错位」—— 这个症状早就被看见过一次,当时只在 synced_at 那一处打了补丁, 没人回头问「那这些裸串本身对不对」。而 should_take_remote_srs 的注释至今写着 「RFC3339 UTC 归一后 = 时间序」,那个前提正是被裸串打破的那一个。 修法在 RVH 侧(一行),已交接;存量 3 行不自愈,请 RVH 裁定(交接单 §3.2)。

鉴别力对照:同一批数据下 cross-end-check.sh D 段 / sync-verify.sh 14 条 / cargo test --lib sync 40 条 / 三端黄金向量测试 —— 六道既有守卫全绿。 它们验的是「SM-2 算得对不对」和「行搬得对不对」,没有一道在问 「算出来的那个时刻是哪个时刻」。M1(字面形状)与 M3(next − last vs interval 的语义对账)刻意做成两条独立断言:只有 M1 的话,「给裸串补个 Z」就能把它变绿 而排期依然错一个时区。

补上的三个洞

  1. 红线 #9 此前零守卫 —— #5i 有 USER_SCOPED_DIRTY、#6d 有 tombstone_synced_at_guard、专名判据有计数守卫,唯独 #9 全靠人记, 而它是最难看出来的一条(漏归一不报错,只是那一行永远匹配不上)。 新增 redline9_guard 当场抓到 add_reference_word 写的是 word.to_lowercase() —— 手搓的半个 normalize(不 trim、不 lemmatize、 不做 NFC)。已改用 lemmatizer::normalize

    ⚠️ 2026-08-28 订正(RVH 回执 cross-end/42 §6A 挑战后复核):上面这段 原本写的是「表现:用户加了参考词,写得进、看得见、一次都不生效」—— 那个表现不存在add_reference_word 全仓零调用方lib.rs 注册了, 前端 / content-script / Rust 都不调),本机库 193 行全部 source='system', 用户根本走不到这条路径。这条修复是钉不变式,不是修活缺陷。 另:消费点是 4 处不是 1 处(reading/analysis.rs ×1 + vocabulary/query.rs ×3)。 复核中真正找到的活缺口在种子数据里,见下面 §「顺带查出来的」。

  2. 黄金向量的 RVH 副本无人看守 —— CLAUDE.md「改一条 expected 三端同步红」 只对 RB 两端成立(它们读同一个文件),RVH 读的是手工副本,落后时它继续全绿。 新增 L1/L2(RVH 自己在 test/fixtures/README.md 里请求过这条兜底)。

  3. cross-end-check.sh §D 是第二本账 —— 期望值散文写死在脚本里, 与黄金向量 JSON 毫无联系(CLAUDE.md §9 早点名过,点名不等于修好)。 改为从 JSON 现推 + L3 守住不许写回来。

顺带查出来的(2026-08-28,复核 RVH cross-end/42 §6A 时)seed_reference_words.sql(v3)的 193 条里有 4 条是死条目 —— olympics / pbs / gps / https vocabulary 里根本没有对应行, 而全部 4 个消费点都是拿 reference_words.wordvocabulary.word 做裸等值比 ⇒ 它们永远匹配不上任何东西;与此同时它们本想挡的 olympic / http确实在表里、却没被挡。种子在已冻结迁移里(红线 #11),只能走新编号迁移规整。 危害有限(专名过滤的主力早已是 vocab_scope 的 584 词判据),已记进 learning-loop.md K5 + cross-end/43

这一条同时暴露了一个更普遍的判据错误vocabulary 同时装着 lemma 行与 非 lemma 行bearbearing 都在),而 reference_words 的 4 个消费点里 三个看到的是已归一键空间(经 learning_entries join)、发现候选池那个看到的是 全部 vocabulary 行 —— 两个键空间真的不一样,「一律归一」与「一律不归一」 都会在某一侧失配。裁定写在 cross-end/43

顺带修 cross-end-check.sh 另外两处失效判据:§A 是死探针 —— 它 grep init_data.sql 里的 supabase key,而那两个 key 自 2026-07-23 起改构建期 注入,文件里只剩注释,于是这两条永久 SKIP、空转五周;改读 .env.local 后 当场变绿(两端确实同项目同 key,这件事五周没验过)。§B 的版本判据恒红 —— 断言 RB 的 v(\d+) 与 RVH 的 _preinstalledVocabVersion 相等,而两个计数器自 2026-07-19 pipeline 反转后各自独立,永远差 1 而 db byte-equal ✅;恒红的闸门与 恒绿的一样坏,判据改为「两端锚都在场」。

新增 L4 钉住 mastery 阶梯(黄金向量管不到的第二个跨端纯函数, 而 mastery_level 是同步列且 merge 取更高者 ⇒ 一端放松阈值就每轮 sync 都赢); 今天两端逐条一致。

反向验证:9 处注入全部实测变红、零残留。🔴 其中第一次注入暴露出守卫自己的假绿redline9_guard 第一版在整个文件里数 lemmatizer::normalize 的出现次数, 把归一改回 to_lowercase() 之后测试仍然全绿 —— 那个文件的红线 #9 文档注释里 引用了函数名,光注释就把计数喂饱了。已改为先剥注释行,并把「注释喂不饱计数」 写进阳性对照。

test(migrations):第五个专题「数据基线与迁移」—— v28 起的内联迁移此前零守门(2026-08-27)

docs/verification/data-baseline.md + src-tauri/src/db/shipped_migrations.txt(已出厂迁移的指纹清单,真相源 = 发布 tag)+ 三条 Rust 守卫 + scripts/migration-verify.sh(10 条)+ scripts/lib/migration_digest.py

盘完既有守卫后找到的第一个洞:红线 #11 只被守住了一半。 check-schema-frozen.mjs 从名字到实现都只盯 assets/sql/ 那三个文件, 而 v28 起的迁移 SQL 是 migrations.rs 里的内联字面量 —— 改动它们不碰任何守门: CI 全绿,装过那一版的库启动即 VersionMismatch之后每一条新迁移永不执行。 CLAUDE.md 红线 #11 与 §5 的措辞当时也只写 schema.sql,比实现窄一档 —— 照控制文件读,会以为另外两个文件与内联 SQL 可以改。条文与守门都已补齐。

新增 shipped_migrations_are_byte_frozen:拿 rustc 解析出的字符串按 sqlx 口径Sha384::digest(sql.as_bytes()),只哈希 SQL、不含 version/description)复算指纹, 兼抓「已出厂迁移被删 / 改号」(那会让 sqlx 报 VersionMissing, 且发生在任何 pending 应用之前 —— 整条链一步不走)。

第二个洞:既有测试全部是空库跑 v1→head,而红线 #11 说的是存量库。 新增 pending_migrations_apply_over_a_populated_legacy_db —— 起点取自出厂清单, 每张表灌两行(PK/唯一索引列取不同值、其余列刻意重复)再跑剩余迁移。 注入 CREATE UNIQUE INDEX ON learning_entries(mastery_level) 实测:新测试 FAILED, 而 fresh_chain_applies_cleanly_to_head / flattened_baseline_matches_terminal / versions_are_strictly_increasing_and_unique 三条既有守卫在同一个缺陷面前全绿

D5 让字节真的走一趟:读本机存量库的 _sqlx_migrations(拷贝出来只读,绝不碰原库), 拿 sqlx 亲手写下的 10 条 checksum 与提取器算的逐条比 —— 逐字节相同。 这既是「这个库现在还升不升得上来」的直接答案,也反过来坐实了摘要口径本身。 注入一条尚未出厂但本机已应用的迁移(v34)时,只有 D5 会红,源码层两条全绿 —— 源码层与本机层各自覆盖着对方够不到的一段。

12 处注入全部变红、还原后零残留(独立 worktree,不碰共享工作树)。 过程中修掉一次假红(D7 首版用 grep 计数区分 CREATE TABLE IF NOT EXISTS 与裸 CREATE TABLE,把 v28/v31/v32/v34 全误报)和一次假红隐患(指纹提取器不剥 Rust 注释, 把 migrations.rs 里那行注释掉的 // Migration { version: 28, … } 模板当成真迁移, 差点把「v28 指纹在 dev.9 与 dev.10 之间变了」当成真事故报上来)。

只报未修:「断链之后应用的真实表现」目前只有代码路径推断(记为 K2,下次重置开发库时顺手实测); 「指纹清单发版后要重生成」没有闸门(K3,已进 backlog)。

test(release):第四个专题「发版链路」—— 这个域的失效不可观测,装了旧版的人不会来报障(2026-08-27)

docs/verification/release-chain.md + 机械层 scripts/release-verify.sh(17 条,--deep 再 1 条)+ scripts/lib/minisign_verify.py(纯标准库 Ed25519 + minisign 验签,无第三方依赖)。

为什么单列:updater / 签名 / 下载链有十来跳,任何一跳断掉都是同一个症状 —— 没有报错、没有告警、没有工单,存量用户只是悄悄停在旧版本上,而你这边一切正常。

补的是既有守卫之间的空白(三处版本一致归 CI、公证归 verify-notarized、 三路版本字符串比对归 admin/lib/ops-external.ts 与 ops 页,均不重复): updater 客户端接线五环 · endpoint 匿名可取 · 平台键 vs 构建矩阵现推 · 资产匿名可下(含 404 阳性对照)· 签名 keyid vs 客户端 baked 公钥 · semver 可升级性 · 公开 Release 非草稿 + 产物集合 · landing 直链下探到文件 · workflow 默认权限 · 发版凭据在台账里有行。

12 条守卫全部注入验证过鉴别力(独立 worktree 里做,不碰共享工作树;还原后 git diff 零残留)。 其中一条注入复现的是本域最贵的真实陷阱:把版本从 0.1.0-dev.10 换成 0.1.0-beta.1 —— semver 的 prerelease 段字母序 beta < dev,存量用户会永远收不到这个「更新」, 而 Release 页、下载页、看板三路字符串全部一致。R2 把这条陷阱本身写进了比较器的自检向量。

--deep 让字节真的走一趟:把制品下下来,用客户端那把 baked 公钥做一遍 minisign 验签, 再翻转一个字节确认变 bad(阳性对照 —— 没有它,一个恒返回 True 的验签器会一直全绿)。 Windows 与 mac aarch64 制品都通过,顺带坐实了一条此前从未验证过的假设gen-latest-json.mjs签名之后给两个 mac tarball 插 arch 后缀改名, 安全性依赖「minisign 验的是内容不是文件名」。改名后的包确实验得过。

本轮验的是「已发布那一版的链路通不通」,不是「下次发版会不会出问题」。 新发现两条(promote-updater 缺前置断言 / gen-latest-json 不核平台数)已进 backlog, 只报未修;/release SKILL.md 的过期数字与另外三条记为有意接受,见该清单 §4。

docs+test(sync):红线 #6d 升格为双端契约 —— 旧条文只写了「取 MAX」,照它审对端会全部蒙混过关(2026-08-27)

RVH 回执(cross-end/37)报来一个方向相反、形状相同的回声环:它 6 个 _pullXxx 的墓碑分支里 5 个把 synced_at 写成本端时钟。它们压根没写 MAX,所以形式上不违反 #6d —— 问题出在条文只描述了 RB 的做法(取 MAX),没写死做法背后的两条性质。

条文拆成四条子规则(详见 CLAUDE.md §4 #6d 与 docs/cross-end/38-*.md): W 写侧软删时 deleted_atupdated_at 绑同一个参数 · P1 pull 墓碑分支的 synced_at 只能由该远端行自己的列拼出、任何形式的本端 now 都不许出现 · P2 MAX(写完后的 updated_at, 写完后的 deleted_at) 且操作数不许为 NULL · P3 「写完立刻 clean」覆盖 push 侧的 mark。 其中 W 此前是未写进契约的巧合 —— RVH 的 cloze 表没炸,靠的正是 RB clear_cloze_context_for 恰好写 deleted_at = updated_at;RB 改任一条 cloze 软删路径即无声破功。

两个边界裁定:① 远端 updated_at 为 NULL 时回落 created_at —— SQLite 多参 MAX() 只要一个操作数为 NULL 就整体返回 NULL,同一个回声环会从 P2 的实现里 换身份复发,而 updated_at 可空的三张表常态就是 NULL; ② 两端格式差异(RB +00:00 / RVH Z)守住 P1 后自动不成问题,关键是 MAX 取的不是「时间上更晚」而是「让脏谓词为假」 ⇒ 操作数必须就是这一行存下的那两个字符串、 用字符串序比,禁止 parse 成 DateTime。

RB 自查:pull 的 10 处墓碑分支 + 写侧 27 处全部合规,本轮没有修复任何真实缺陷。 新增的是钉子:结构守卫 pull/mod.rs::tombstone_synced_at_guard 扫全仓 Rust 源码, 每条墓碑 UPDATE 二选一落在 (P1+P2) 或 W 上,并断言 pull 站点恰好 10 处(加第 11 张同步表漏改即红)—— 三条规则各注入一次缺陷实证过。另把 common.rs::mark_synced 补成 MAX(COALESCE(updated_at, ?1), COALESCE(deleted_at, ''))(P3):RB 侧当前无已知可达路径, 是钉不变式不是修 bug;RVH 侧同形代码则是真实可达的。

fix(ops):生产库连接串换 pooler —— 直连主机是 IPv6-only,本机没 IPv6 出口时 5 个脚本一起哑火(2026-08-27)

db.<ref>.supabase.co 只有 AAAA 记录、没有 A 记录。本机无 IPv6 出口时 getaddrinfo 直接失败(could not translate host name),而那条 IPv6 路径时通时不通 —— 同一次会话里先能连、一小时后连不上。

🔴 受影响的不止验证脚本(都读同一个 Keychain lampio-supabase-db): ops-verify · sync-verify · privacy-verify · db-audit · backup-supabase。 备份是 dead man's switch,一段 IPv4-only 的网络期 = 备份静默失败。

修法:Keychain 换成 pooler(aws-1-ap-northeast-2.pooler.supabase.com:5432, session mode,IPv4 可达)。区域不用去 Dashboard 抄 —— supabase/.temp/pooler-url 里 CLI link 时就写下了。原直连串备份在 service lampio-supabase-db-direct,可回滚。 改一处,5 个脚本一起受益。

切换前逐项验过 pooler 支持全部用法(pg_policies / storage.* / pg_proc / cron.job / pg_dump -Fc --schema=public 备份同形)。切换后:privacy 16/16 · sync 14/14 · ops 24/24(M4 从 SKIP 恢复)· db-audit 全绿 · backup-supabase.sh 端到端真跑一次成功, 并首次以非零数据走通 storage 备份路径(6 个快照对象 74KB;此前每次都是「落盘 0 个对象 ✅」, 那条路径从没被真实数据验过)。

⚠️ 两个踩坑记在这里:① security add-generic-password -U 会更新备注却不更新密文, 可靠做法是 delete 再 add 并回读验证;② SOCKS 代理那条路是死的 —— CONNECT 返回 rep=0 但发数据过去直接 EOF,那是乐观应答,别在本地 TCP 转发上耗时间。

fix(ops):ops 页与首页横幅的快照会自己变新,「旧」也会自己长出来(2026-08-27)

loadOpsSnapshot() 服务端盖一个 fetchedAt,全页每一格的「多久没动了」都以它为基准。 而重拉时机只有 mount写操作成功后 —— 没有轮询、没有 focus 监听。

🔴 于是 ops 页开着不动几小时,fetchedAt 是冻住的,「探测结果超过 90 分钟算过期」 这类判定永远不会触发:你盯着一份几小时前的快照,而它显示「正常」。 这本身就是一层温和的假绿,且全页每张卡都有。 首页横幅更重 —— 它同样只取一次, 且fetchedAt 都没有,连「这份数据多旧」都显示不出来,而首页最容易长期开着。

做法 = B + C(两条各治一半):

  • B 切回前台就重拉useRefreshOnForegroundvisibilitychange + focus,带节流)—— 贴合真实使用:你切回去看的那一刻它是新的。不选定时轮询:页面开着就一直打 Supabase + 外部 API,而外部有 memo 挡、Supabase 侧没有,多数时候还没人在看。
  • C 让「旧」自己长出来useNowTick 每分钟推墙钟 + 判据 snapshotAgeStatus)—— 兜住「长期停在前台不动」那条尾巴:数据不会自己变新,但界面会如实说它老了。 超过 SNAPSHOT_STALE_MINUTES 则整页总体判 unknown + 出提示条 + 手动刷新按钮。

几处刻意的取舍:

  • 🔒 per-card 的基准仍然是 fetchedAt,一个字没改。 判「拿到这份数据时它成不成立」, 比拿几小时前的数据跟此刻墙钟比更诚实(后者会把「我们没再读」说成「那边没更新」)。 新增的是另一格,判快照自身,答案再并进总体。
  • 过期判 unknown 而非 warn:我们并不知道出了问题,只是不知道现在怎么样; 且 worstStatus 把 unknown 排在 warn 之下,不会盖住任何真实告警(有断言钉住)。
  • 阈值 = 半个探测周期(快照比探测周期还老,判定就整整落后一轮), 由 cron-manifest.spec.ts 钉在 supabase/sql 的真排期上。
  • useNowTick 首帧返回 null(渲染期读 Date.now() 会被 react-hooks/purity 拦), 调用方回落到「以取数时刻为基准」= 改动前的行为;effect 体内不做同步 setStatereact-hooks/set-state-in-effect 的级联渲染),故第一次推进等满一个间隔。

反向验证:新增 10 条断言(🔴 反向 3 条 + 阳性对照 2 条)。除判据本身外,还扫接线 —— 实测把 freshness 从总体里摘掉时,纯函数那一整组照样全绿而页面当场退回 「八格全绿 = 正常」;故补了对 ops-console.tsx / ops-health-banner.tsx 的源码断言 (总体里必须带 freshness、组件里不许自己算分钟数 —— 红线 9 的接线层)。 注入验证:摘掉 freshness → 接线断言变红,改回即绿。

pnpm test143 passed / 0 skipped(基线 114 + 本轮三项共 20 条 + 同期另一会话 8e0b182 的 9 条)。no-secret-leak 的两条阳性对照仍通过。

fix(ops):cron 任务集合与容差从「两本半账」收敛到 supabase/sql 一处(2026-08-27)

同一件事此前在三处各写一份:supabase/sql 里的真排期scripts/ops-verify.sh M6 里写死的 MAXAGE_H={任务名: 容差}(兼作 EXPECT 集合)、admin 的 EXPECTED_CRON_JOBS(容差已是现推)。 2026-08-27 实测三份确实对得上 —— 那是证据不是守门:任一处改了,另外两处毫无反应。

真相源统一为 supabase/sql/{monitoring,cron-setup}.sql 里真正执行的 cron.schedule(...)

  • ops-verify.sh M6 的任务集合改为从这两个文件解析(剥掉注释行 —— 别的 sql 文件里躺着 几个注释掉的 cron.schedule 示例,不剥就会把它们当成真任务而恒红),容差改为从库里那条 live schedule 现推,MAXAGE_H 整张表删除。现推值与原写死值逐个一致(26/26/1.5/192/192)。
  • M6 新增一条此前谁都没查的:live cron.job 的排期与仓库声明逐条比对。生产被 cron.alter_job 改过排期时,容差会乖乖跟着变 —— 数字变了、意图没变,正是这一页要防的 「接错线」形状,而只有对着仓库声明比才看得出来。
  • admin 侧 EXPECTED_CRON_JOBS 仍是手抄常量(部署包里没有 supabase/,运行时读不到 SQL), 但由新增的 admin/tests/unit/cron-manifest.spec.ts 对着那两个 SQL 文件逐个核。 🔑 那条硬约束只约束运行时:单测跑在仓库工作树里,../supabase/ 就在那儿 —— 这是不引入构建期生成物的唯一解法。
  • PROBE_STALE_MINUTES = 90 由同一份单测钉在 probe-endpoints 的真排期上cronToleranceHours(真排期) * 60 === 常量)。刻意不做成运行时现推:那要让探测卡去读容量卡的 stats.cronJobs,凭空多一条跨卡依赖,还得替「stats 取不到」另编回落 vs unknown 的答案。 不会自动跟随,但绝不会静默过期。

反向验证

  • 机械层:把 manifest 源换成「probe 排期被改成 */10」的 SQL 副本 → M6 报出排期不符; 换成「daily-db-audit 那行被注释掉」的副本 → M6 报出集合不符(同时证明剥注释规则生效)。
  • 单测:9 条里 4 条是 🔴 反向,另有一条阳性对照(manifest 解析为空时下面全部退化成 「空集 == 空集」的静默全绿,与 no-secret-leak 那两条同理)。注入验证:往 EXPECTED_CRON_JOBS 塞一个不存在的任务名 + 把 PROBE_STALE_MINUTES 改成 91 → 3 条断言变红,改回即绿。

⚠️ 已知残留(明写在两处注释里):容差分档(≤1h ×3 / ≤24h +2h / else +24h)在 ops-status-rules.ts::cronToleranceHours 与 M6 的 python 里各有一份实现。它不随排期漂 (两边都从 schedule 现推),只有改分档策略时才可能分叉。彻底消灭需要构建期生成物,代价不成比例。

./scripts/ops-verify.shPASS=24(原 23,+1 = 新增的排期一致性那条)FAIL=0 SKIP=0。

fix(ops):§12 seed 模板的探测目标从「会烂的注释」变成「被断言消费的声明」(2026-08-27)

supabase/sql/monitoring.sql §12 的 seed 模板里,admin 探测 URL 写的是换域名前的旧别名 readvocab-admin.vercel.app,而库里现役是 admin.lampio.app

🔴 危险之处不是「探到别处去了」。 2026-08-27 实测:旧别名 HTTP 200,返回的 commit 与现役生产完全相同 —— 它就是同一个 Vercel 部署的另一个别名。危险的是照模板重跑一次 seed 之后,探测测的不再是真实生产域名:admin.lampio.app 这一层的故障(DNS 配错、 证书过期、域名从项目上掉了)全都探不出来,而卡片一直绿 —— 旧别名照样活着, 正是它一直绿的原因。

修法不止是改那一行。 那行之所以能烂掉,是因为没有任何东西消费它。所以 scripts/ops-verify.sh M3 改成把模板当声明源:解析 §12 那两行 values,与库里现役的 ops_probe_state 逐条比对,不符即红。顺带删掉 M3 里写死的 "admin,landing" 期望 —— 那本身是又一份手抄的账,现在集合就是声明本身。🔒 库里现役两行一个字没动。

反向验证(只验通过路径 = 没验):

  • 把声明源指回修复前的模板 → M3 ,并逐条打印「声明 / 库里」的差异;
  • 把声明源换成一个没有该模板的文件 → M3 解析不出探测目标声明), 而不是安静跳过 —— 声明源丢了必须响,不能退化成空断言。
  • 旧 M3 之所以抓不到这次漂移:它压根不看模板,只验「库里的 URL 真能返回 200」, 而旧别名既 200 又错

./scripts/ops-verify.sh 仍是 PASS=23 FAIL=0 SKIP=0(M3 的三条 PASS 数量不变, 第一条从「恰好两行」换成「与 §12 声明逐条一致」)。判据变化见 docs/verification/ops-monitoring.md J28(人工那半只剩「这域名要是掉了会不会红」)。

refactor(sync):pull.rs 1804 行拆成 sync/pull/ 四块 —— 纯搬运,红线的集中点一个都没散(2026-08-27)

sync/pull.rs 是仓里唯一越过 /arch-check S1 警告线(1000 行)的 Rust 文件, 也是 CLAUDE.md §2 点名的下一个拆分候选。拆成:

文件行数装什么
pull/mod.rs182共用基础设施 + 十个 pull_*pub(super) use 重导出 + parent_state_tests
pull/vocab.rs704learning_entries · word_cloze_contexts(含 reconcile_cloze_pool + reconcile_tests)· known_words
pull/library.rs639reading_notes · reading_pages · word_page_links · page_annotations(含 tombstone_echo_tests
pull/prefs.rs353favorite_sites · rss_feeds · domain_prefs

共用基础设施留在 mod.rs 不是「工具函数放一起」,是红线要求。 红线 #5(增量水位只能用 服务端 server_updated_at)成立的方式是「10 个 pull 函数都得经 fetch_remote 这一处」, 红线 #6c(父行三态)成立的方式是「parent_state 全仓只有一份」—— 复制进子模块,红线就 退化成「每个子模块各自记得」,而漂了不会报错。PAGE_SIZE / CLOZE_POOL_CAP / batch_max_ts 同理。

唯一的实现改动是可见性形状:十个函数从 pub(super) async fnpub async fn (住进私有 mod vocab/library/prefs,实际可达范围不变),由 pull/mod.rspub(super) use 统一重导出 —— sync/mod.rs 的十个调用点一个字都没改pub(super) 无法被 pub(super) use 再导出(E0364),这是拆法被迫的形状,不是取舍。

姿势与 reading/ · notes/ 刻意不同:这里做 re-export。 那两个是命令域模块, re-export 会让「这个命令住在哪」不可见;而十个 pull_* 不是命令,是 sync_now 一处 顺序调用的内部步骤,顺序本身带依赖(page_annotations 必须等 reading_pages 先落地), 摊平成一张清单正是 sync/mod.rs 需要看到的形状。

结构守卫跟着改,并现场证明它没被拆空sync_matrix_tests::pull_impl_src()include_str!("pull.rs") 改成四个文件拼接。这引入一个新的假绿面 —— 漏列一个文件, 守卫就少扫一块源码而安静全绿。做了注入验证:把 include_str!("pull/library.rs") 删掉,四条守卫全部变红fns 6≠10、checked 6≠10、覆盖检查报出 4 张表缺落地 语句),确认那两处钉死的计数兜得住。

「纯搬运」是验过的,不是声称的:原文件第 11 行起(1-10 行是被重写的 use 块) 与四个新文件按行排序对比,diff 里没有任何一个 <(丢失)方向的实现行 —— 只有新加的 模块头与那 10 行可见性变更。cargo test --lib 216 passed,与拆前逐条相同。 移动过来的代码连既有的 rustfmt 偏差都原样保留(5 处),没有"顺手改进"。

踩坑防线(docs/coding-standards.md §10.3 那条)git add -A <新目录> 不会暂存 同名旧文件的删除,本地全绿而该 commit 单独 checkout 编译不过。这次用 git rm src-tauri/src/commands/sync/pull.rs 把删除提前进暂存区,并在独立 worktree 里 checkout 该 commit 跑 cargo check 复验。

运维看板判据层:六处「接错线」的灯 —— 判据收进一个纯模块(2026-08-26)

/admin/ops 的全部意义是让静默故障可见,所以它自己最危险的失效方式不是报错而是 假绿。开发期已自曝 4 处,08-25 冷启动复验又报出 6 处(只报未修),本轮做掉。 共同形状:信号与它要判的那件事之间接错了线 —— 那件事变了,信号不跟着变。

根因不是「某处判漏一档」,是同一个问题在两处各判一次。 卡片三档、总览两档, 于是 warn 的 finding 卡片黄、总览绿。修法不是把总览也补一档(那只是把两份判据同步 一次,下次照样漂),而是新增 admin/lib/ops-status-rules.ts —— 纯函数、零 IO、 不 import 任何服务端模块,卡片 / 总览条 / 首页横幅调用同一个函数,让它们没有 第二份判据可用。

  • ① 总览丢 warn 档:present 的 warn finding(tombstone_echo / push_stalled / dangling_fk / credential_expiry 这些「安静灼烧」的)被画成绿;容量那格更进一步 —— 卡片看 DB + Storage 两条水位,总览只看 DB,Storage 涨到 95% 卡片红、总览绿。 首页横幅同款;loadOpsSummary 改回状态而不是 critical 计数。
  • ② 站点可达不看新鲜度:探测停了(cron 死 / pg_net 坏 / 被 unschedule), ops_probe_state 冻在 200 / failures=0 → 这格永远绿而没有人在探。改为超过 3 个探测周期没有新结果即 unknown。同处 cron 卡三个洞一并修last_status=null (从未跑过)判 ok、succeeded 但早已停摆判 ok、以及最坏的一个 —— 任务被 unschedule 后直接从 cron.job 消失,所有 some(...) 恒 false → 也判 ok。 现按声明表核对集合,容差从 schedule 现推(排期改了容差自动跟着改)。
  • ③「告警通道」那盏灯接的不是告警通道:它读 NEXT_PUBLIC_SENTRY_DSN(admin 浏览器 SDK 的 DSN),而真正送 ops 告警的是 ops_notify() 用的 ops_monitor_config 三段 + alert_enabled —— 把 sentry_project_id 改成垃圾值, 告警全丢,那盏灯照样绿。改判后者,并核对指向(判据同机械层 M7)。 🔒 仍是只读显示、不给写入面monitoring.sql §11:从界面改错 = 静默失去全部 告警);三段的值不再下发浏览器,OpsSnapshot.config 收窄到白名单 key,只跨结论。
  • ④「立刻跑一次巡检」文案误导:它只调 run_db_audit(),而 backup_heartbeat / credential_expiry / credential_review 住在 ops_daily_monitor() 里,这个按钮 永远不会刷新它们;点完却显示「全部通过,无异常」。文案改为说清不含哪三项 (刻意不写「N 组」—— 同一天就新增了 tombstone_parent,写死的数字必然过期)。 顺带修掉 JSX 里被当字面量渲染的 **不发告警**
  • ⑤ 错误态被缓存整个 TTLtoProbeError()cached() 回调内部返回, 错误值与成功值一视同仁 → 一次瞬时抖动让卡片粘着「取不到」10 分钟。不是假绿是 陈旧红,而恒红的闸门与恒绿的一样坏。改为按值挑 TTL:失败、以及「成功但里面 写着某一路没取到」(unverified / 单 project 失败)都只缓存 30s。
  • getSentryHealthPromise.all:3 个 project 挂 1 个,另外 2 个本来 已取到的数字也看不到了 —— 而运维页恰恰在出故障时才被打开。改 allSettled 逐 project 降级;「部分取不到」时卡片判 warn,不因剩下两个是 0 就报绿。

回归tests/unit/ops-status-rules.spec.ts 44 条,每组成对写 —— 正向断言新判据 会变色,🔴 反向断言把旧判据的表达式原样写进用例,钉住它当时会说 ok。 只验通过路径 = 没验。另以生产真实行做过一次只读复核(库一行未写):今天全绿, 把 last_checked_at 推老 3h → unknown、probe-endpoints 移出列表 → critical、 sentry_project_id 改坏 → critical,三处旧判据均为 ok。库里三段与生产 bundle 的 DSN 逐字节一致,故新灯上线是绿的而非恒红。pnpm test 70 → 114 passed / 0 skipped (+44 = 新用例数),两条阳性对照仍通过。

裁定:「墓碑父行算不算存在」—— 删除子树补齐 + pull 父行守卫改三态(2026-08-26)

2026-08-26 的同步专题验证抓到一条:代码一致地把「父行是墓碑」当作「父行仍存在」, 而红线 #6a / #6c 都是无条件措辞,字面上禁止这么做。根因不是 bug,是一个从未被写下来的 设计立场。本轮把它裁定掉(详细理由与被否掉的两个方案留在 docs/plans/backlog.md 对应条目)。

写侧判为缺陷 —— 删词只软删 learning_entries + word_cloze_contexts不动 word_page_links,活的 link 挂在墓碑词下。且缺口比验证时记录的更宽:把 learning_entries 打成墓碑的路径有 3 条,当时三种行为 —— delete_vocabulary / batch_delete_vocabulary (清 cloze 不清 links)、known_words::add_known_word(标「已认识」,两样都不清)、 pull 墓碑传播(跨端删词,两样都不清)。

  • 新增 vocabulary/crud.rs::soft_delete_word_traces(cloze + links),三条写路径共用, 父行与子树同一事务。子查询限定「父行已是墓碑」——活词的 link 一条都不会被误杀。
  • 判它是缺陷的论据不是「两条删除路径不一致」(那个对比不成立:删笔记本带走 links 是因为 死的是侧父行),而是:同函数隔壁三行的 clear_cloze_context_for 早已按「删词 = 重置该词的学习记录」清了语境池,links 是同一形状的残留;复活时 SM-2 本就全部清零; 「留着好让 re-add 恢复来源」不需要活行(save_wordON CONFLICT 会复活软删的 link)。
  • 它也不是完全不可见:text_source::doc_has_traces 数 links 时不 join 词表 → 删掉唯一存过的词后,那篇粘贴文档永久锁成不可编辑且无从解释。

读侧判为红线措辞错,代码是对的 —— 决定性事实是 watermark 推进到本轮取回行MAX(server_updated_at),与某行有没有被守卫 skip 无关。给 FK 落地守卫加 AND deleted_at IS NULL 不是「下轮再试」,是永久丢行

  • 新增 sync/pull.rs::parent_state:父行三态(活 / 墓碑 / 缺失),三处调用点 (pull_word_sources ×2 · pull_page_annotations)改为显式三态 —— 缺失 skip(FK 落地限制)、 墓碑只拦远端活行、远端墓碑照常落地。
  • 顺手更正 pull_page_annotations 那句「不存在则 skip,下一个周期再试」的假注释
  • 红线 CLAUDE.md #6c 整条重写为三态规则(两种压缩方向相反、都是缺陷); #6a 补「join 行必须显式裁定随哪个父走」+「按父表算不按命令名算」。

补观测(第四件事,也是最要紧的一件) —— 此前这类孤儿在两端 UI 里都不可见 (读侧查询全带 deleted_at IS NULL),而 dangling_fk 明确豁免墓碑父行: 2026-08-26 实测库里有 2 条孤儿 link,巡检返回 []没有任何自动检查会告诉你。

  • supabase/sql/monitoring.sql §3b + scripts/db-audit.sh §3b 新增 tombstone_parent(warn), 覆盖 links→词 / links→页 / annotations→页 / pages→笔记本四种。 ⚠️ Supabase 那份要在 Dashboard 手动执行才生效。
  • 跨端并发(一端删词、另一端同时存同一个词)仍会漏下孤儿,写侧根治不了 —— 这正是不能只做「写侧补齐」的理由。
  • 回归测试:vocabulary/crud.rs::word_subtree_tests(含「活词的 link 一条不许动」反向断言, 注入验证过鉴别力)+ pull.rs::parent_state_tests
  • 交接单 docs/cross-end/34-rb-tombstone-parent-adjudication-handoff.md(RVH 需按 §4 复核)。

预装库补轮:例句层露骨清理 + 校准闸门 1 + 补 per-POS cefr(2026-08-26)

round30 的露骨内容清理只做了释义层,例句层没碰。而同轮的载荷 ④ 给全部 86,817 条例句 配了中文译文 —— 在此之前中文母语学习者可能一眼扫过英文脏话,之后 slur 是用母语呈现的。 这是载荷 ④ 引入的净损害,不是既有问题的延续;且例句会进复习卡 (cloze.ts::pickFallbackCloze 的回落路径就取这些例句)。最刺眼的一条挂在 A1 词 date 上: 「…那个小同性恋」(种族 + 性向双重蔑称)。

vocabulary  18,925 → 18,916      SHA ae33c8a4… → 06cbb4dc…
例句        86,817 → 86,693
C1/C2 可学池  857 → 848
per-POS cefr 缺口  212 → 0
  • 删 66 条露骨例句 / 64 词(A1 15 个 / A2 10 个)+ 连带去掉 8 条义项(另含 3 个 A1/A2 词)。 那 8 条的唯一例句按同一判据必删,删完 examples 会空 —— 空保护一响即说明 「这条例句该不该删」问错了,正解是整条义项都不该留(date「The anus.」· mud「A black person.」· rug「rughead」的 gloss 本身就是蔑称)。
  • 校准闸门 1:删 9 个露骨/网络垃圾词(blowjob bukkake creampie cunt deepthroat footjob handjob milf trackback)。⚠️ 闸门 2(C2 首屏 10 个人名,判据形状问题) 不在本轮,归 proper-noun-governance-plan.md §6 的 L-D。 两个闸门都清掉之前不要动 CALIBRATION_MAX_LEVEL
  • 正则只用来召回,不用来裁定:498 条命中里 119 条挂在普通词上,逐条人工过 (删 64 / 整义项删 8 / 留 47),裁定表 docs/cross-end/33-example-adjudication.tsv。 早期一版正则把 turkey-cock / cock crowed / Dick Hudson / cocktail 全部误命中。
  • 拼读脏话扫不到eff you see kay why oh you.(= fuck you,挂在 see/why 下) 在词界锚定的召回里一条都没进,是补一层掩码/拼读变体扫描才捞出来的。 doc 30 §5 当初是肉眼撞见它的,doc 31 §7 原把它记作已知残留 —— 现已清掉。
  • apply_round30.dartdropex / delete_gate1 两个载荷。dropex 三道预检全部 在进事务之前判死,因为三种失败模式都不报错、只静默改坏数据:① 目标句命中数 ≠ 1 (抄错一个字符就静默 no-op);② 删完把某 sense 的 examples 清空;③ examples / example_translations / example_source 三个平行数组长度不齐(按下标删必然错位, 错位后无任何可观测症状)。三道都做了注入式反向验证,确认会咬。 匹配按句子原文不按下标(同 doc 30 §4 那条 43.6% 贴错义项的教训)。
  • 补 per-POS cefr(回 RVH doc 32 §5.2,真机实测挖出、单测与 SHA 校验都发现不了): 212 个 POS 块缺 cefr 键,补上词级 primary_cefr_level。新增 80 词的全 POS 齐全率 46.2% → 100%(存量 98.7% → 100%)。症状是 RVH 拍照管线的难度筛选读 per-POS cefr 而非 primary_cefr_level,缺键的词被判 UNKNOWN 静默丢掉 —— ourselves(A2)「查词能 查到、拍照永不进候选」,而它正是 round30 为补 lemmatizer 缺口才加的词,只修好了一半。 另 8 个受损可学词:measles confetti impending insignia undersignedunqualified bioethics cheerleading
    • 根因 100% 是 round30 自己造的,且不按词的属性分而是按哪条载荷写进去的分 (RVH 排除了 cefr_source 与 POS 键两个假设,方向是对的):_applyFold 从零造 name 块时代码里压根没写这个键(157/157 全缺)+ add_entries 素材里 168 个 POS 块有 55 个不带(直灌不补)。157+55 = 212 = 全库缺口总数,一个不多。
    • 两处各自堵死(_applyFold 造块带词级值 · _applyAdd_withPosCefr 兜底), 并加事后机械守卫「缺口数只许降不许升」,一升整轮回滚。注入式反向验证: 摘掉加固后守卫立刻咬(209→212, exit 1),还原后放行。
    • 🔴 只补缺失,绝不覆盖已有值 —— per-POS cefrcefr_source='oxford' 的词是 权威且可与词级值不同的(above.adj=B1 / above.prep=A1)。 _archive/backfill_oxford_pos_cefr.dart 记着把 per-POS 拍平成 top-level 的那次事故 (~6,271 个多词性词丢了分词性区分)。「无值→填词级值」与「有值→改成词级值」是两回事。
  • VOCABULARY_SEED_VERSIONrb-18916-v28-poscefr-2026-08-26, 预装库已 byte-equal 同步进 RVH 仓(红线 #10)。
  • 加了 v27 prune:seed 是 UPSERT 只增改不删,删的 9 个词对存量用户不生效。 但与 v18/v23 那两轮不同,这次可能真有用户存过这些词(都是真词),而 learning_entries.wordON DELETE RESTRICT 在 rusqlite 通道指望不上 (从来没开过 PRAGMA foreign_keys,红线 #6a)—— 裸 DELETE 不会被拒, 只会静默留下悬挂 FK 的孤儿生词本条目。故 prune 自带 NOT EXISTS (SELECT 1 FROM learning_entries …) 自守:有人存过就留着那一行。
  • 交接单 docs/cross-end/33-rb-explicit-example-cleanup-handoff.md(新开一号,不是 改 doc 31)—— 动手时才发现 RVH 已经收完 round30 并回执了 doc 32,doc 31 是一份 已被应答的文档,事后改它会让 doc 32 里对 §4 的订正读起来不知所云。 doc 31 §7 只留一行指向 33 的前向指针。
  • RVH 侧成本比预想低得多:doc 32 §4.1 落的 prune 是通用集合差 (「本地 source='preinstalled' 但新库已不含的词条」),不是硬编码的 53 词清单 —— 本轮删的 9 个词会被自动 prune。RVH 只需提交资产 + bump _preinstalledVocabVersion 28→29,无新 FK 决策、无迁移映射表、lemma_* 一行未动故 assets/nlp/*.json 也不用同步。

「AI 语境助手」重做成「一个同意闸 + 功能子项」(2026-08-26)

上一条把短语高亮拆成独立偏好后,context_disambiguation 的身份问题就暴露了:它是隐私同意闸 ("允许把你读的句子发给第三方 AI"——supabase.rs 两处外发把关、landing/lib/legal.ts 隐私政策 描述的就是它),却被当成功能开关用,于是"关掉查词消歧"会连带关掉短语高亮,且完全不可见。

现在闸与功能分层,Settings 里画成结构(子项缩进 + 竖线),不靠"需要先开启上方的…"这类文案:

AI 语境助手
  允许发送阅读语境                    [闸,带隐私确认框]
    ├ 双击查词消歧   word_disambiguation_enabled   ← 新键
    └ 短语高亮       phrase_highlight_enabled

生效值 = 闸 AND 子项,content-script settings.js 与 React 两侧逐字同口径。闸关 → 子项置灰 (不隐藏,让用户看得见开了闸能得到什么)。现在可以"同意发送语境,但只要短语高亮、不要查词消歧" ——这个组合以前不存在。

  • 恢复了一个被静默推翻的决定:2026-06-28 的 D6 本就拍板"短语分析与双击消歧各自独立开关", 2026-07-18 的大杂烩提交 b9772d5("工作区快照")把 phrase_analysis 并进 context_disambiguation,提交信息里只字未提,唯一记录是一句代码注释。
  • 个性化例句 ④ 不给子项:其入口已被多语境 plan 决策隐藏(ClozeRevealCardpersonalizedEnabled 恒不传 → 不渲染),给不渲染的功能配开关 = 用户拨了没反应。Rust 侧的闸 保留可逆,注释写明入口重新露出时补第三个子项。
  • 同意书文案改为只描述真的会跑的两件事(原文提到"生成例句",那条链路当前不会触发), 与隐私政策里那句「AI 语境助手(查词消歧与短语高亮)」对齐。

修:content-script/core/state.js 里的 NUL 字节让整个文件对 grep 隐形(2026-08-26)

该文件一句注释里混进了一个 0x00(本该是空格,phrase + '\0' + sentence)。JS 侧无害(在注释里, esbuild 照常打包),但 file(1) 因此判定它是二进制,grep/rg 会静默跳过整个文件——不报错、 不提示,只是搜不到。于是 /arch-check 的全部 rg 类规则、以及任何仓库级搜索,长期看不见这个文件。 是在本轮改 state.settings 时因为"grep 明明有内容却返回空"才捞出来的。

词汇发现面板:三条高亮开关从面板内升为 Settings 偏好(2026-08-26)

面板里那三个绿色 Switch(已学词 / 待发现词 / 短语)撤销 —— 用户实际上几乎不逐页开关它们, 那是偏好不是操作。频道头回到 图标 + 名称 + 数量 + chevron 单行,某条偏好关掉时 整张卡不渲染(而不是留一张点不动的死卡),三条全关给整体空态 + 去设置的引导。

顺带修掉一个一直存在的错位discovery_highlight_default 此前只被 React 读作面板开关的 初值,content-script 完全不认这个键 —— 关掉它,新打开的页面照样高亮,然后 discovery-words-found 事件把面板开关顶回 on(useContentTools 里那两处 count > 0 → setActive(true) 就是在给这个错位打补丁)。现在 loadSettings 直接门控三条通道, 两侧读同一批键,补丁随之删除。

  • 偏好键:已学词复用既有的 highlight_mode'off' = 关,Settings 里「生词高亮」那一项, 不新开第二个控件)· 待发现词 discovery_highlight_default · 短语新增phrase_highlight_enabled。短语那条不能复用 context_disambiguation —— 后者同时门控 双击消歧 / 个性化例句 / 两处 LLM 调用的隐私闸,拿它当「短语高亮开关」暴露给用户, 关短语会连带关掉双击消歧。Settings 里短语开关在语境助手关闭时置灰并说明原因。
  • 即时生效loadSettings 首次之后的调用(= __RB_RELOAD_SETTINGS 广播)diff 前后值并收敛 (__RB_SET_VOCAB_HIGHLIGHT / __RB_SET_PHRASE_HIGHLIGHT / 重跑 highlightDiscoverable)。 必须 diff:三个 applier 都是「重跑整条通道」,而改任何一个设置都会广播到每个 tab —— 不 diff 就是每次改设置都白花一次整页短语 LLM 判定。React 侧同步走新的 rb-settings-changed window 事件(沿 rb-favorites-changed 先例)。
  • 撤销:Rust toggle_vocab_highlight / toggle_phrase_highlight 两条命令 + 对应的 __RB_TOGGLE_* 钩子(改为幂等 setter)+ commands.ts 两个封装 + hook 里三个 toggle handler。
  • 代价(已确认接受):per-page 临时开关的能力没有了,只能全局改。

实机验证:关掉待发现词 → 面板卡消失 + 页面 .rb-discover 91 → 0;关掉短语 → 卡消失 + .rb-phrase 归零;两条开回来后高亮与清单都重建(短语重跑 judged=7)。

预装词库 round30 —— 四载荷一次做完,且第一次不重跑 pipeline(2026-08-26)

lampio_dict.db 18,898 → 18,925vocab_scope 排除集 584 → 769, SHA 2880c504…→c875b3db…,lemma_* 表 byte-equal 不变。全程 LLM 花费 $0.012 (RVH 单子按整库重跑估的是 $5–15 —— 差三个量级,因为没有重译任何存量词)。 范围与全部实测见 docs/cross-end/30

做法改了:走就地改库tools/vocabulary_builder_v3/bin/apply_round30.dart, 红线 #10 的第二个豁免,先例 rebake_lemma_tables.dart)。原计划是整库 reseed, 第 0 步实测把它否掉了 —— 重跑 pipeline 才是本轮最大的风险源

  1. output/ 已丢失,唯一能喂给 --resume 的是 db,而 db 是 post-governance 的 (quality_governor 重排 + 每 POS 封顶 5 + 丢 archaic)。实测按下标 merge 会把 43.6% 的中文释义(23,808 条 / 6,343 词)贴到错误义项上,不报错、不校验。
  2. 更要命:cefr_source='llm'5,164 词分级1,099 条 word 级 word_tags (含全部 871 个 proper_noun只存在于 db,仓里没有缓存。重跑一次就没了 —— 而 vocab_scope.rs 判据第一句就是「打了 proper_noun 标签」,标签没了它查不到 任何行,刚落地的 L-A 整体静默变 no-op。

四个载荷本质都是行/列级改动,就地做全部绕开这两条。

载荷:① prune 53 行(RVH doc 29 裁定的死词形 46 + 露骨/垃圾词 7)· ② 补 80 行 (375 条义项 100% 有 zh)· ③ proper_noun 补标 155 词 · ④ 给 157 个存量专名折入 name POS 释义(468 条)。

③ 的判据换过两次,值得记:L-C 原文的「见过 name POS → 打标」实测误伤 8–10%china(瓷器) metabolism petroleum senate prairie matrix node cedar … 全都只有 noun 词性且从没被权威分级过,所以「权威分级过的词不带标签」那道安全网对 机械信号根本不成立);再试「name 义项占比」也切不开(真词最高 90% prairie、专名最低 25% charlie,分布重叠无阈值)。最终降级为候选过滤(517 词高召回)+ LLM 语义裁定 (→ proper 155 / common 362)—— 即现有 871 个好标签本来的产生方式。校准探针 15/15、误伤 0。

④ 是顺带查出来的kaikki_parser_irrelevantPos 一直在丢 name POS,于是库里 virginia=「Vagina」、johnson=「Penis」、manchester=「household linen」。把词排出 学习池却让查词依旧是垃圾,正是「保留专名词条的全部理由就是查得到」的反面。现已归位。 (virginia 那条还暴露出已发版数据里就存在的译文错位:gloss「Vagina」配的 zh 是 「弗吉尼亚(美国州名)」——正是第 1 点警告的那类错误的实例,非本轮引入。)

验收:真词误杀 0 · zh 覆盖 12,181/12,181 · 行数/表白名单/排除集单调性三道守卫全过 · cargo test --lib 207/207 · pnpm build 绿。

⚠️ doc 30 §8 的「C2 首屏人名显著下降」只部分达成:12 → 10abba/abby/abigail/alec/ alison/alma/amos 清掉了,但下一批(barney barr barry beck benedict bennyajax austin)顶上来 —— 它们在库里带 verb/adj/prep 词性(to beck=招手、 to barney=争吵),被 vocab_scope 判据的第二半放行。那一半正是 fox/march 不被误杀的安全网,模块注释早把这类列为「刻意接受的假阴性」。 ⇒ 瓶颈已从「标签覆盖率」转移到「判据形状」,继续补标没用; 因此本轮不解封 CALIBRATION_MAX_LEVEL(§8 把「清完才谈解开」当前提,条件不满足)。 真正对症的是 L-D 的 token 级信号(非句首 + 首字母大写降权)。

载荷 ④ 例句译文同轮做掉:86,432 条唯一例句 → 覆盖 86,817 个位置(同句在不同词条 复用),落在 12,163 个词条,覆盖率 100%、数组与 examples 严格等长(缺位补空串)。 词数不变,db 55→63MB,SHA c875b3db…→ae33c8a4…。成本 in 1.87M / out 2.55M tokens, 高峰口径上界 $4.19、44 分钟(并发 12)。缓存 data/example_translations.json以英文原句为键提交进仓 —— 不按下标,理由同第 1 点。 🔴 复习卡正面绝不能渲染译文(doc 30 §5.3):中文必然含目标词词义 = 直接递答案, 而 cloze 防泄露只遮英文形态。已落两条回归测试 + 注入式反向验证(把译文拼进卡面 后测试立刻变红,还原后转绿)。RVH 的 cloze_builder.dart::buildFallbackCloze 同构、 同风险,已写进 doc 31 交接。

同时修正 llm_translation_enricher 的价格表:旧值 (0.14, 0.28) 相对官方现价 output 少算 4.7 倍。DeepSeek 现按高峰/非高峰两档计价(非高峰半价;高峰 = UTC 01-04 与 06-10 周一至五 = 北京 09-12 与 14-18),本表改为返回高峰价作上界 并补 pricingCacheHit。 ⚠️ 待 RVH 同轮发版:资产已 byte-equal 同步过去但未在 RVH 仓提交(会话隔离); RVH 需落「导入加删除步骤」+ FK 迁移决策,否则存量安装照样留着 alway、其 P1 OCR 缺陷不会好。详见 doc 30 §9.5。

CI Rust 有记录以来第一次绿 —— 守红线 #11 的闸门被行尾问题恒红了 17 次(2026-08-25 → 08-26)

migration_chain_tests::flattened_baseline_matches_terminal(守红线 #11:schema.sql/v1 冻结后压平基线必须逐字节等于冻结前 golden)在 main 上从 2026-07-30 有记录起 0/17 次绿。 根因与 schema 毫无关系:ci-rust.ymlwindows-latestactions/checkout 按 runner 默认的 core.autocrlf=true 检出,于是 include_str! 进来的 terminal_schema_baseline.txtCRLF, 而拿它比对的 actual 是测试自己用 \n join 的内存串 —— 内容逐字节相同,只差行尾。 本机(macOS,LF)跑同一测试一直是 ok,所以从没人在本地看见过它。

两层修(缺任一都留隐患):

  1. 仓根 .gitattributes* text=auto eol=lf。动手前逐文件 git show :<path> 扫过 index, 100% 已是 LF(零命中),故这是纯检出行为约束、不产生任何重规范化 diff; 仓内也无 .bat/.ps1/.cmd(那些才需要 CRLF)。
  2. 断言侧 include_str!(...).replace("\r\n", "\n")。行尾在这里从来不是语义 (golden 每行是 type|name|normalized-ddl),而只靠 ① 会让这道守门的正确性 依赖三层目录之外的一个配置文件 —— 将来谁新增一个 include_str! 的 golden, 同一个坑会以「CI 莫名其妙红了」的形式再来一次。

🔴 反向验证(在隔离 worktree 里做):把 golden 人为转成 CRLF(64 行全带 CR)→ 通过; 再在 CRLF 基础上注入一处真实漂移(从 golden 的 known_words 抹掉 deleted_at 列)→ 仍然 FAILED。 证明这次修掉的是噪声,不是把闸门弄钝。

为什么值得单独记一条:恒红的闸门与恒绿的一样坏 —— 真出现 schema 漂移时没人分得出它和 这片噪声,而同 workflow 里另外 195 个测试(含守红线 #5i 的 push_user_scoping_tests、 守 #6a 的 all_synced_tables_have_tombstone_at_head)也一起被埋在同一片红色里。 这条是 docs/plans/archive/admin-ops-acceptance-recheck.md 那轮验收抓到的:前两轮验收的结论都写 「CICI (admin) 双绿」—— 而这个仓有三条 workflow,第三条从未进入过任何一轮的视野。 📌 归档提示:这两处改动被并行会话的 git add -A 卷进了 95b12d9(notes.rs 拆分), 按 commit 主题找不到它们,见 CLAUDE.md §8 路径 C。

同轮验收另报出 3 处新假绿(总览条丢 warn 档 / 站点可达不看 last_checked_at 新鲜度 / 「告警通道」灯接的是浏览器 SDK 的 DSN 而非真正送 ops 告警的那条通道)+ 4 处文档与部署态漂移, 均只报不修,清单在该报告 §3。

验证:本机 cargo test --lib 207 passed / 0 failed;CI Rust run 32855514599198 passed / 0 failed(14m33s,Windows 上少的 9 个是 cfg(target_os) 平台门测试); 同批 CI 亦绿。

专有名词治理 L-A:发现 / 推荐 / 难度 / 分级不再把人名地名当生词(2026-08-25)

排查 12 个接触面后确认,这不是「专名识别不准」,而是 vocabulary 一张表同时承担 可查 / 可学 / 计难度三个角色、判据从未分开表达。凡需区分处只能靠一张 193 词的静态 黑名单打补丁 —— 实测其中只有 45 个真正存在于 vocabulary(另 148 个是安慰剂), 而预装库里 871 个 proper_noun 标签有 835 个不在黑名单里,照常高亮/推荐/计难度。

新增 db/vocab_scope.rs::exclude_proper_nouns 作为唯一判据(打了 proper_noun 标签 noun/proper noun/name 以外的词性),接入 7 处:发现候选池 · 快速分级 ×2 · 校准探针 · 页面难度徽标 · RSS 条目难度 · 推荐文章覆盖率。 命中 584 词、误杀 0 —— 命中集全部 cefr_inferred=1,凡 Oxford/CEFR-J 权威分级过的词 (fox march polish turkey)天然不在其中,这是判据自带的安全网。有效覆盖 45 → 584。

判据刻意写成「无其它词性」而非「只有 noun」:两者在当前预装库上等价,但只有前者覆盖得到 缓冲池回填的 {proper noun} 形状与 L-C 补标后的 {noun, name} 形状。带 adj 的 194 个 国籍/文化词(american/chinese/english)予以保留 —— 它们是合法学习词。 ebay(verb)/walmart(verb) 这类带动词义的专名刻意漏过:同批混着 congress/karate/ sushi/pope 等真常用词,收紧必然误杀(fox 被当 Fox News 拉黑就是反面教材)。

难度方向已实测:按 Zipf 权重算 584 个移除词的档位分布,移除集中「对该用户超纲」的占比 在 A2/B1/B2/C1 各档为 74.9/57.1/25.1/13.9%,而 coverageTier 阈值是 2%/5% 量级 —— p 远大于当前超纲率 r,故各档一致下修。先前担心的 B2 档反向不成立(那个担忧错在拿 词种计数比 50%,正确的比较对象是 p vs r,且要用词频加权)。

v34 迁移CREATE INDEX idx_vocab_tagged ON vocabulary(word) WHERE word_tags IS NOT NULL。 判据首版把发现页热路径从 2ms 拖到 308ms —— 根因不是判据慢而是行读变贵(原本走索引覆盖 扫描,判据一碰 word_tags/pos_definitions 就每行读整行,而后者是整棵释义树、表 55MB)。 部分索引 + 判据改 NOT IN 子查询后:dict 载入 308→65ms、发现候选池 150→28ms。 选索引而非物化列,是因为物化 = 派生状态第二本账(reseed 忘记重算即静默过期),索引由 SQLite 自维护。

明确不改的 7 处(改了才是错):lookup_word / browse_vocabulary(可查 —— 双击专名 必须查得到)· get_learning_words*(CEFR 高亮标的是用户主动保存的词,过滤它等于替用户 否决他的决定,还会造出「生词本里在、复习卡里有、统计里算数,唯独页面上不高亮」)· get_cefr_distribution / srs.rs / save_word(已保存词的统计与复习)。

L-B(缓冲池闸门)实施中推翻:Wiktionary 页面标题区分大小写,而入参已被 normalize 小写化 —— 小写页是另一个词条(kyiv=「chicken Kiev 的异体」、texas=「蒸汽船顶层舱面」), POS 永远是 Noun,闸门一次都不会触发。改查大写页更糟:Baker/Hunter/Smith 的大写页 全有 Proper noun,而 baker/hunter 没有动词义兜底 → 普通词被当专名滤出学习循环。 真正的信号是「用户看到的 token 是否大写开头」,它在 word-extract.js::getSelectedWordtoLowerCase() 就丢了 —— L-B 依赖 L-D,不是平行关系,已并入。 线上已 deploy → 发现空转 → 摘掉 → 重新 deploy,行为回到原状(净留 4 处类型收口, deno check 从 6 错变干净)。

未解决(钥匙在 L-C,需整库 reseed):校准 CALIBRATION_MAX_LEVEL='B2' 封顶仍不能解开 —— virginia/manchester/peter/todd/claire/york 恰恰都没被标(8 个典型样本漏 6 个), 双击它们仍会拿到粗俗释义 + LLM 生成的粗俗例句。同期查出预装库里还有露骨词与网络爬取垃圾 (milf/hentai/blowjob/trackback,全部 cefr_inferred=1,来自 Google 词频源), 使解开 B2 上限从精度问题升级为「必须先清池子」的硬前置。 方案见 docs/plans/proper-noun-governance-plan.md

build_dict 报告可复现化:report.txt 不再是"每跑一次换一批"(2026-08-25)

tools/build_dict.report.txt 是 tracked 的审计报告,用途是人工复核 14 万条 lemmatizer 资产改了什么。但它的 "WordNet overrides" 段(355 条,只打印前 200)遍历顺序来自 HashMap——每个进程的迭代序都不同,于是任何一次重跑生成器(哪怕只改 1 条映射) 都会在这个文件里产生 ~370 行纯乱序 diff,把真实改动埋掉。v27 那轮实测:JSON 资产 diff 干净只有 4 条,report 却 188+/184−。报告的存在意义正好被它自己抵消。

三处修正,全部只动报告渲染,不碰数据路径

  1. 写盘前排序 wordnet_overrides.sort()。同节其余三张清单来源本身有序,不是同病: pos_drops = AGID 文件行序、self_base_overrides = BTreeMap 迭代序、 force_s2b_cleared = 硬编码数组序(已核实)。
  2. 策展层清单改为全量打印,截断上限 NOISE_LIST_CAP 只留给 AGID 机械噪音 (POS conflict drops,8212 条)。排序后若仍截断 200,"字母序前 200" 会让 WordNet overrides 的 44%、Self-base overrides 的 55% 永远看不见—— 人工复核面被悄悄砍掉一半。报告 783 → 1183 行。
  3. 加大小写碰撞断言:WordNet 的 key 先 to_lowercase() 再用,若将来两个原始 key 小写后撞到同一 surface 且给出不同 base,surface_to_base.json 本身也会变成顺序相关。 实测当前 5637 条输入 0 碰撞(所以今天产物是确定的),但那是输入的性质而非代码的 保证——现在破了就 fail-fast,且 panic 发生在任何资产写盘之前(反向注入实测)。

验收:连跑两次 cargo run --example build_dict --release,report 字节相同; surface_to_base.json / base_forms.json 相对 HEAD 零 diff(哈希逐字节比对)。

lemmatizer 资产 v27:v26 截断带里的 4 条同类漏网(2026-08-25,cross-end/27→28)

RVH 用 v26 当时的撒网条件在修后资产上复扫:高频段 165 条命中几乎全是正确映射 (people→person data→datum media→medium)——v26 那轮人工裁定没有被翻案。 漏的是 doc 24 §10 自己写明「按危害排序截断」的低频带(surface 排名 >30000)。 把上限放宽到 120,000 重扫,捞出 4 条与 v26 已修的 unbiased/outdated 完全同型的:

uninteresting(#53115) · uninterested(#71943) → uninterest(B1 名词) · unconcerned(#68910) → unconcern(B2 名词) · overjoyed(#59425) → overjoy(B1)

形容词被 AGID 当成一个几乎不存在的动词/名词的屈折形,而那个 base 在预装库里还顶着正经 CEFR → 学习者读到 surface,却拿到一个自己从没读过的词条进生词本。危害同型于 rath, 量级小一档(rather 排名 ~1000 vs 这几个 5–7 万)。

四条全走 §2.5 force_as_base。实测它们都不在 base_forms,所以「只删映射」这条路 走不通——Layer 3 后缀规则会按 ing/ed 把它们推回去(doc 24 §2.1 那个坑)。

overjoyed 是交接单交给 RB 裁定的边界情况,判为「修」overjoy 确是真动词、 overjoyed 作其过去分词语法上成立,但 PARK 的判据是「两读都成立、需 POS 上下文才能分」 (bit/ground 那种架构限制),而 overjoyed 只有一读;且 v26 已修的 unbias/outdate 同样是「技术上真实、实际不出现」的动词(overjoy 连 33 万词频表都不在)。

完整性清扫的结论是「大部分不动」:这 3 个 base 名下各有 3 个 surface,但 uninterests(名词复数)· unconcerns/unconcerning · overjoys/overjoying(规则 动词形)映射本来就对。三个 base 仍是合法词条、仍继续接收各自的屈折形——不是把 base 铲掉。这条被双向锁住:生成器 §4c 哨兵 +5 条 keep 断言 + 单测 v27_sibling_surfaces_left_untouched

⚠️ 与 v26 不同,本轮没有 affected-lemmas 存量清理白名单,这是有意的:v26 那份成立是 因为被作废的 lemma 修完后彻底不可达;本轮库里一行 word='uninterest' 既可能来自 uninteresting 的错误归一、也可能来自 uninterests 的正确归一,判据不存在。

资产:surface_to_base 140,281 → 140,277base_forms 101,709 → 101,713, 三个资产 SHA 全变(含预装库 lampio_dict.dbvocabulary 18,898 行未动)。逐条 diff 复核 改动恰好 4 条、零附带——同时再次证明生成器可字节复现仓库资产。sync-rvh-vocabulary.sh 行数硬断言同步更新(不改会让 RVH 同步直接失败),已整包同步 RVH 并 cmp 复验。 VOCABULARY_SEED_VERSION 故意不 bump(描述的是 vocabulary payload,本次未动); RVH 侧相反,必须 bump _preinstalledVocabVersion 26→27(读取路径不同,见 cross-end/28 §6)。

admin 运维面:把备份/监控体系接出界面 —— 接的过程中抓出四处「假绿」(2026-08-14 → 08-18)

C4/C5 那套东西(备份脚本、pg_cron 巡检、告警)此前唯一的出口是 Sentry push: 出事会响,但「现在健不健康」没有任何 pull 入口。新增 /admin/ops 一页七卡:备份心跳 / 巡检存活 / 巡检发现 / 容量水位 / 站点可达 / 发布链路 / 告警配置 + 凭据台账。

多平台整合的裁定:只接只读结论,不重做各平台的界面。Supabase(水位 / cron / 巡检留痕)与 GitHub、Sentry、Vercel(各取一个数字或版本号)进;Cloudflare 不进(DNS 不是会静默劣化的东西)。⚠️ 这些 token 一律不加 NEXT_PUBLIC_ 前缀、只在 Server Component 里读、只把结论交给渲染层——加它们等于把 admin 从「持数据库最高权限」扩成 「持基础设施凭据」的单点,故权限卡到最小并全部进 ops_credentials 台账。

新增 SQL:ops_credentials(🔒 只记「在哪 / 何时过期 / 过期了怎么办」,绝不存密钥值)、 ops_probe_state + ops_probe_endpoints()(pg_net 两步法——同一事务里发完就查一定拿到空, 故本轮先判上一轮的响应再发这一轮)、ops_platform_stats()

真正的收获:四处「假绿」,全是这套东西自己身上的

看板做出来之前,这四条都在安安静静地报告一切正常。

① uptime 从来没探过 admin9656078)。ops_probe_state 里只有 landing 一行, admin 从建表起就没种进去——后台挂了不会有任何人知道,而这恰恰是 dead man's switch 那一节要防的静默故障。暴露方式本身也说明问题:要验证线上鉴权修复时翻遍全仓找不到 admin 的域名,因为唯一该记录它的地方是个 <admin-域名> 占位符。

② 「巡检自身还活着吗」这张卡永远不会变绿4631926)。run_db_audit() 只在 where c > 0 时写 findings 行,即全绿的一次巡检不留任何痕迹;而卡片读的是 max(checked_at)。于是「跑了、很健康」与「根本没跑」在数据上完全同形。一个永远报不出 ok 的存活检查等于没有存活检查。改判据为心跳(每次运行无条件 upsert),与 local-backup 同形:发现可以为空,心跳不可以

③ 「立刻跑一次巡检」按钮在生产必 500,而 cron 一直 succeeded4631926 + dcbfa2b)。 两个坑同一形状:两条调用路径表现不一致,只有手动按钮会失败。裸 delete from _audit_out 被 Supabase 的 safeupdate 扩展拒(21000,即便目标是临时表);修掉后又撞 42501——函数要 join auth.users 却没写 security definer,经 PostgREST 以 service_role 跑读不了,而 pg_cron 那条以属主 postgres 跑照常成功。顺带扫了全部 6 个函数,ops_notify()ops_probe_endpoints() 潜伏同类问题(已 grant 给 service_role 但缺 definer,真调会失败), 今天只有 cron 在调故不改,但哪天给它们加界面入口必须先补 definer,已记进 §11。

④ 「发布链路一致性」卡从上线起就是假绿fe3ff66)。它显示「最新 Release —」的同时 写着绿色「三者一致」。两个缺陷叠加:/releases/latest 按设计排除 prerelease,而内测期 所有 release 都是 prerelease,于是它稳定 404、这张卡从没工作过;而取不到的那一路被 latestRelease && 短路跳过后,mismatches 为空就判 consistent = true——「跳过」被 等同于「通过」。改列表端点自挑(含 prerelease,排掉草稿与 updater-latest 渠道指针), 并把跳掉的比对记进 unverified,绿灯要求两者皆空,卡片改三态。 ⚠️ 修的过程中撞到第二个陷阱:GitHub 列表端点不按时间排序——实测返回 dev.8/7/6/5/**10**/channel,最新的排第 5,按常规写法取 releases[0] 会把假绿变成假红。 故显式按 published_at 排序。判定逻辑抽成纯函数 evaluateReleaseConsistency 才有了 回归守门——它此前内联在 async 函数里,这正是该缺陷一直没人守的原因。

admin 鉴权:存量缺口(01a96d4

admin 的鉴权此前只在客户端(AuthGuard 是 "use client",session 存 localStorage), 服务端拿不到任何 auth 上下文。两个后果:未登录 curl /admin/observability 能从 RSC flight payload 里取到运营数据(Server Component 渲染时就序列化进响应了,客户端组件挡界面不挡 字节);14 个 Server Action 无任何服务端鉴权。而 admin 是持 service_role 的公网部署。 改为两层:proxy.ts 在渲染前拦 /admin/**getUser() 向 Supabase 真验签,不是 getSession() 解本地 cookie)+ requireAdmin() 进每个 action 首行。回归断言里有一条 扫源码断言 app/actions/ 下每个导出动作首行都是 requireAdmin()——这次缺口的成因 正是「新加的记得了、三个存量文件没人记得」,人记不住,测试可以。

🔒 生产实测坐实了这道门是唯一屏障:Vercel 的 SSO 保护只罩带 hash 的部署 URL、 不罩生产别名admin.lampio.appreadvocab-admin.vercel.app 都是公网可达的。 5 条受保护路径未登录访问均 307 → /admin/login,响应体 15 字节。

收尾的账

  • 恢复演练库删除(存活 4 天,内含全量真实用户数据 + auth.users 邮箱与密码哈希)
    • Keychain 两条演练凭据清除。三方交叉确认:项目列表、REST 端点不可达、link 目标未被带歪
  • 异地加密口令另存 Bitwarden 并实测可解——runbook 里唯一那条「只存 Keychain 就等于 没存」收掉。刻意不放 iCloud 钥匙串:密文本身就在 iCloud Drive,钥匙再放同一个 Apple 账号等于两样锁一把锁。且验证必须用密码管理器那份去解,用 Keychain 那份试等于没验
  • 凭据台账 9 条落账,其中真正会过期的两条:GitHub 发版 PAT(2026-10-18)、 Apple Developer ID 证书(2027-02-01)。到期前 30 天自动告警
  • Sentry:新建 lampio-admin project(此前 admin 侧 SDK 全程 no-op),DSN 配进 Vercel
  • admin 自定义域名 admin.lampio.app(Cloudflare CNAME → vercel-dns-017, ⚠️ Proxy 必须 DNS only,开橙云会让 Vercel 签不出证书且报错不指向真因)

一条值得记住的验证手法NEXT_PUBLIC_SENTRY_DSN 在 Vercel 里是 Sensitive 变量, vercel env pull 对它一律返回空白——据此判断「值是空的」会误判(本轮踩过)。可靠判据是 CSP 的 connect-srclib/security-headers.ts 从 DSN 推导 origin,非空时该源才会出现, 且这个判据不需要登录、不需要读回变量值。

验证:typecheck / lint 0 error / build / 69 passed 2 skipped;CI 全绿;生产实测。

lemmatizer 资产 v26:高频词被归一成古语/残根的全量修正(2026-08-18)

RVH 真机实证:读到 rather → 归一成 rath(古语「早的」,预装库里是 A2 词、 带音标 /ɹɑːθ/)→ 以正经生词身份进了复习卡。根因是 AGID 把 rather 当作古形容词 rath 的比较级——历史上成立,对学习者应用无意义。与 v18 修过的 picked→pic 同源。 交接见 docs/cross-end/24-rb-lemmatizer-asset-v26-reseed-handoff.md

修法与交接单预设的不同:光删错映射不够rather/always 原本不在 base_forms, 删掉 Layer 1 映射后会落到 Layer 3 后缀规则,而 rath/alway 都在 base_forms 里、 能通过 stem 裁决 → 原样推回同一个错答案(实测复现)。所以修正必须「删映射 + 补 base_forms」两步走,让它在 Layer 2 就被截住。base_forms.json 因此也变了 SHA (交接单原以为不会变)。

修正落点是生成器不是补丁脚本。实测重跑 build_dict.rs 能字节复现仓库里的资产, 说明它才是真相源;而 bin/patch_lemmatizer_assets.py 早已坏掉两处(REPO 路径停在迁仓前、 「原串恰好出现 1 次」的断言在 v18 应用后必然失败),已删除。修正层单一真相源 = build_dict.rs §2.4 corrections + §2.5 force_as_base。

全量扫描(交接单要求,不是只修点名的 8 条):机械撒网 303 条 → 逐条人工裁定 → 按 base 反查同源 surface 做完整性清扫。第三步最关键:只修频率网命中的那几个时, 实测漏下 64 条同源映射(修了 changed→change 却留着 changing→changciting→cit)。 结果 REMAP 89 条 + SELF 89 条,共 178 个 surface 的归一结果改变; surface_to_base 140,370→140,281、base_forms 101,646→101,709。

守门:生成器新增 §4b 自检闸(§2.6 二次清理跑在修正之后,可能把刚写好的映射又删掉, 而 14 万条资产没法肉眼比对产物)+ §4c「勿动」哨兵断言(people→person 等 5 条与错映射 同处一次扫描结果,最易被顺手清理掉);Rust 侧新增 3 条 v26_* 单测,其中自映射那条 连 layer == "base" 一起断言——只断言结果的话,漏补 base_forms 的复发能蒙混过关。

预装库:只重烤 lemma_* 三张表(新入口 bin/rebake_lemma_tables.dart), vocabulary 表一行未动——output/ 中间产物早已丢失,整库重跑等于为 ~12k 词重新付费 LLM 翻译。已知代价:91 个旧 headword(alway/rath/lat…)成为不可达死行, 而 always/rather 等正确词形暂无本地词条、查询走缓冲池兜底,下次整库 reseed 收敛。 VOCABULARY_SEED_VERSION 故意不 bump(payload 一字未动,bump 只会让存量用户 白跑一次 18,898 行 upsert)。

存量数据:131 个作废 lemma 的清单已落 docs/cross-end/24-affected-lemmas.tsv (其中 91 个是预装库 headword,即带音标带 CEFR、看起来完全正经)。裁定不落本地迁移 (存量用户全是会重装的内部内测用户)。但重装清不掉它们——这些行同时在 Supabase 的 user_* 表里,重装重登后 sync pull 原样拉回,且拉得进来(本轮没动 vocabularyrath 仍是预装库 headword)。故只剩一件待做:按白名单在云端软删对应 user_* 行。 🔒 必须软删:墓碑是给对端设备看的——硬删后任何还没重装的设备本地那行没墓碑, 下次 push 会把它重新上传回云端。详见交接 §9.1b。

AI 阅读回顾:不点名文章 / 时间口径统一 / 过期不清空;生成节奏维持纯手动(2026-08-14)

一条线上的四件事,裁定留痕在 docs/plans/backlog.md §2.2〈2026-08-14 复评〉+〈二次复评〉。

「下一步读什么」不再点名具体文章a69f256)。它此前从本地未读 RSS里挑一篇点名, 而发现页推荐走的是 Supabase recommended_articles —— 两个池子不同源:周报点名的文章 在发现页可能根本不存在(跨池不可达),没订阅 RSS 的用户(新用户恰恰如此)候选还是空的。 改法不是"换池子做成可点击"(那只是把重复正式化),而是让 LLM 只说它真正知道的东西 (主题方向),"去哪读"交给 UI 把用户送到既有的发现页。EF prompt 硬规则禁止点名, Rust 侧那段未读 RSS 查询整段删除。两个入口从"竞争"变成"串联":反思 → 一句过渡 → 行动。

时间口径三重不一致统一53aa335)。名字叫「本周回顾」、数据窗口是滚动 7 天、缓存却按 日历周失效——周三生成的"本周回顾"实际覆盖上周四→本周三,周一还会因跨周被清空而数据窗口 只挪了一天。措辞一律改「最近一周 / 最近 30 天」,缓存改按报告年龄失效,并在页脚显示覆盖区间 让陈旧度自证。

过期不再清空缓存。上一条留下的尾巴:满 7 天返回 None = 上一份回顾凭空消失,而用户 想再看到任何内容的唯一途径是再烧一次 LLM。既然页脚已经写着覆盖区间,陈旧度已经自证, 删掉它没换来任何东西 —— 改为照常展示 + 页脚把「生成于 X」换成「已过去 N 天」(同一维度, 替代而非并列)。⚠️ 不是"过期就自动重新生成":分界线是不调 LLM 的自动合法,调 LLM 的 自动才是红线

生成节奏维持纯手动(复评确认,无代码变更,写进 report.rs / WeeklyReportCard 头注释)。 否决自动/后台生成的三条里两条是外部约束而非偏好:① 服务端 cron 物理上做不到——digest 全取自本地 SQLite,reading_log 是 local-only(v27),云端没有数据源;② 客户端自动会成为 第二条"未经逐次确认就发送"的通道,而 payload 是阅读历史摘要(标题 + 域名 + 生词)不是句子 → 要动对外法律文本 + 配设置开关。红线的精确形式 = 「未经请求的评判 + 未逐次确认的上传」。 连带否决「往发现页放『这周的你』卡片」:它要显示就得先有缓存,而有缓存的人早在 Stats 读过了 ——最需要它的新用户永远看不到

隐私政策补齐landing/lib/legal.ts,zh + en):原文只承诺"发送你选中的文本或其所在句子", 而周报发的是读过的标题 + 域名 + 生词 + 聚合统计。不是偷偷上传(全程用户点击触发、服务端 不留内容),缺的是枚举完整性——补一条「AI 阅读回顾」并把那句全局断言收窄到语境助手自身。

词汇详情页视觉细化收尾:个性化身份 / CEFR 三带徽章 / 义项折叠(2026-08-14)

计划 docs/plans/visual-hierarchy-refinement-plan.md 的 T5 / T7 / T6,至此 T1-T7 全部完成。

T5 SEMANTIC.personal —— AI 为你生成/挑选的内容此前与通用参考资料同质:Library 的 「我的记忆」是灰实心块(和词源/同族词一个样),而 Stats 的「下一步读什么」早已是 primary 左竖条。统一到后者并提炼成 token。刻意不新增暖色:amber 在本仓已承担 5 个语义,其中 两个会与「我的记忆」同屏,第 6 个用户学不会;primary 则在收藏星、在学词 chips 上已隐含 「你的东西」。

T7 CEFR 列表徽章:6px 色点 → 16px 圆 + 字母,配色改三带。 问题不是「点太小」:2026-08-12 改冷色系后六档是六个相邻色相,6px 上无法分辨(实测生词本 前 16 行分属 C1×3 + B2×13,看起来完全同色),而当时右侧的字母 badge 也一并撤了 → 难度信息 等于没有。首版改 16px 圆 + 白字母后,用户实机反馈「C1 和 B2 区分度不够」,量化确认并 挖出更深两层:① C1/C2 比 C1/B2 更挤(ΔE 10.7 vs 12.6);② 根因是 CEFR_PALETTE.hex 当初 按「白底对比度单调递增」而非「互相可分辨」选的,越往 C 端越挤进蓝紫这个人眼最难分辨的角; ③ 暗色方向反了——「越难越暗」在暗底上是越难越突出,首版没写 dark: 变体。 最终改为颜色只编码 A/B/C 三个带(同带内 1/2 同色,靠字母区分),跨带最小 ΔE 34.4(3 倍), 亮色深底白字 / 暗色浅底深字两个方向都保持「C 比 A 更跳」。 🔒 只改徽章专用 token,不动共享 CEFR_PALETTE(它喂正文下划线 / StatsPanel 图表, 且与 RVH 对齐)。

T6 词条详情义项折叠 —— per-POS 默认 3 条 + 「展开其余 N 个义项」。实测本地库 221 个 有释义的生词平均 6.8 个义项、最多 20 个、83% 有 4 条以上。排序只做交叉引用降权Alternative … form of / Plural of 这类编者交叉引用不是真实词义),不做整体重排: 中途曾想用「你当时读到的那个义项」当 hero,查库后撤回——sense_gloss 覆盖率只有 6.8%, 而支撑那次改写的「库里 sense 顺序不代表价值」也被推翻(1512 条义项里交叉引用只有 19 条、 排第 1 位的仅 2 条,是 outlier 而非规律)。除该前缀外没有可信的价值信号,多排一步就是瞎猜。

发现页站点/订阅四栏从 Tile 卡改列表行,ui/Tile 退役(2026-08-14)

同一个发现页里原先并存两套列表语法:「继续阅读 / 最近打开」是 ListItem variant="double",而站点/订阅四栏是 Tile tone="bordered" 的 2 列描边卡。 ListItemdouble 变体的实体映射注释原文就写着「history / sites / RSS / feeds」 ——迁移是回到既有约定,不是新发明。两栏 grid 布局保留,变的是栏里放什么行。

Tile 的 4 个调用点全在 HomePage,迁完即零引用 → 删 ui/Tile.tsx + barrel 导出 + ui-variants.tileVariants,并同步 docs/ui-component-inventory.md §1.2 / docs/ui-standards.md §13.2+§13.5 / CLAUDE.md §2 两处组件表。

🔒 「整行可点」是对 2026-07-18 P8 的有意反转,不是回归。 P8(commit b9772d5 那批反馈第 8 条「Home:…Tile 仅标题可点/hover…」)把整卡可点改成了 仅标题可点。它约束的对象是 Tile —— 2 列描边大卡,整卡点击是又大又含糊的靶;换成扁平 列表行后这个前提不再成立。且同一实体在 MySitesPanel.tsx:139 本来就是 「ListItem double + 整行 onClick + 星标 group-hover 显现」,而那个面板正是发现页 「浏览全部」点进去的地方 → 本次迁移 = 发现页收敛到同一实体已有的模型。 星标点击 stopPropagation,只做收藏/取消,不连带打开站点。

⚠️ 星标显隐刻意不对称(实施决定,非疏漏):

  • 我的收藏站点 / 我的订阅 → 星标 = 取消收藏,hover 才现(同 MySitesPanel,零损失), 实心且常驻品牌色(amber / RSS orange)
  • 推荐站点 / 推荐订阅 → 星标 = 收藏常显,空心中性、hover 才透出品牌色

后者是那两栏存在的主要动作,hover 隐藏等于把整栏的用途藏起来;MySitesPanel 没这个问题, 因为它每一行都已收藏、星标只承担「取消」。

顺带修掉两处既有的硬编码英文 aria-label'Remove from favorites' / 'Unsubscribe from feed'),改用早已存在的 S.unfavoriteAria / S.unsubscribeAria

外部提案第三轮的完整裁定与方案见 docs/plans/visual-hierarchy-refinement-plan.md(T4 / D2 / D3)。

词汇详情页视觉层级:我的记忆前置 / 词源去壳 / AI 徽标换图标(2026-08-13)

同一屏的三条视觉权重调整,连做才看得出层次是否真的分开(计划 T1-T3)。

  • 「我的记忆」上移到词源/同族词之前(此前它已在词典释义之前,但排在词源之后)。 「我的记忆」是为这个用户生成的内容,词源/同族词是通用参考资料,后者不该先被看到。
  • 词源/同族词收起态去壳:两个标签横排一行 + 一条 hairline,只有展开体才成卡。 横排 + 下边框读起来像一组「二选一」标签,正是 open 互斥单状态的真实语义。 🔒 三个调用点一起变(Library / ReviewCardFace / ClozeRevealCard)——后两者是 320px 起的卡侧栏,收起行实测约 108px,余量充足。
  • AI 例句徽标文字 → 图标:逐句标注保留(改成区域级提示会丢「哪一句是 AI 生成」, 词典产品里那是诚实性损失),但 purple 文字 chip 换成 12px Sparkles + tooltip—— admiral 5 个义项原先重复 5 个紫 chip = 满屏噪点。新增 SEMANTIC.aiMark; 死字面量 aiExample 从 facade + zh + en 三处同删。

实现上一个坑:tooltip 必须挂外层 span 而非 Icon——Icon 把 props 摊到 lucide 的 svg 元素上,而 SVG 的 title 属性不产生 tooltip(HTML 元素才行,SVG 需要 <title> 子元素)。

回顾 picker 去待办感 —— 起手行从「全部 · 221」变成「开始复习 · 20 张」(2026-08-13)

用户反馈:进回顾模块第一眼是「全部 · 221」,数字大、有压力感,大概率不会点; 整个复习清单也有点待办事项清单的味道。

221 不只是"权重过重",它是个没有信息价值的数。 会话按 REVIEW_BATCH_SIZE = 20 分批取牌(ReviewSession.loadCards),批读完 advanceCard 自动续下一批,中途从不回显 剩余量 —— 所以 221 在整条动线里只在点击前出现一次,点完再也见不到,且不对应任何 用户能完成的单位。它唯一的作用就是让人不敢点。

分组行的数字则相反,有选择价值Newsforkids.net Notes 210 vs The City of God 5 是用来挑体量的。所以判据不是"数字大小"而是"这个数有没有可比较的对象"—— 「全部」那个没有(它就是全部),分组的有。

改法四条

  1. 起手行从清单项行动✨ 开始复习 · 20 张。同样是数字,但框架从 「你欠多少」翻成「这次给多少」,且 20 是代码里的真实批量(不足一批时说实数, 走 min(REVIEW_BATCH_SIZE, allTotal))。会话标题仍传「全部」——范围确实是全部。
  2. 计数带单位词ReviewPickerRowcount: numbercountLabel: string, 单位由调用方给(复习「N 词」/ 重温「N 页」/ 起手「N 张」)。裸数右对齐 + 就是收件箱的行语法(「收件箱 42」),带上单位才从计数器变回体量描述。 该行被复习与重温共用且单位不同,故不能写死在组件里。
  3. 分组收进「或者自己挑」浏览区(发丝线 + 常态大小写 caption,与 uppercase 子级 caption 拉开层级),两组皆空时整块不渲染。图标 Target(靶心 = 达标/任务)换 Sparkles
  4. 会话内进度 {已复习}/{totalDue}已复习 N(实施中发现):原先每张卡上都挂着 3/221,只从 picker 拿掉而留着它 = 把债务数从看一次改成看每一次。会话是开放式的 (批空自动续、随时可停),那个分母既走不到头也不对应可完成单位。改为只报累计 —— 「只陈述已发生的事,不提醒还没做的事」(docs/plans/backlog.md 第二轮吸收的原则二)。

连带清理SessionFilter.totalDue 去掉分母后成死字段(全仓仅 ReviewSessionHeader 一处读)→ 从类型删除;addressBar.reviewSessionProgressAria 随之无引用 → facade + zh + en 三处摘除;文字自解释后去掉 role="progressbar" + aria-label (没有终点的进度条本身就是错误的语义承诺)。REVIEW_BATCH_SIZE 提到 components/review/types.ts 由取卡与承诺共读,漂移了承诺就变假话。

实机验证(dev app + rb-debug):picker 三段结构与单位词、会话头在「全部」与 「Newsforkids.net Notes」两种标题下均显示「已复习 1」、重温侧「N 页」全部正确, console/rust 日志无报错。

遗留(削弱非消除):221 仍在「按日期 · 更早」那一行 —— 按上面的判据它合法 (本周 1 词 vs 更早 221 词 可比较),但用户这一屏仍会看到它,只是从"第一眼 + 行动位" 退到"最后一组 + 浏览位"。后续可选:日期分组默认折叠,或把日期桶改细让它自然拆开。

外部提案(四模块视觉优化)的完整逐条裁定见 docs/plans/backlog.md §外部 backlog 第三轮。

「首次遇见于」进重温后滚动定位到当时那一句(2026-08-12)

用户实测反馈:点「首次遇见于」进了重温页,但语境句/单词没有任何高亮,得自己在页面里找。 并提出一个好问题——加了高亮是不是就和「复习」差不多了(复习本身就有高亮)?

不会合流,因为差别从来不在高亮,而在谁向谁提问。 复习是 app 向你提问(挖空出题 → 你评分 → SM-2 改排期),页面是卡片的支撑面,高亮是答题机制的一部分(揭晓前不能亮, 亮了就泄题);重温是你向页面提问(无题、无评分、不碰 SM-2),页面本身是主体,定位只是 导航。同样的像素回答两个不同问题。加定位不会让 revisit 在"出题/评分/排期"任何轴上靠近 recall;反倒是现在这样——从一个具体的词进去却落在页面顶部——比普通浏览标签还差, 「回到这一页重温」这句承诺没兑现"这一页的哪里"。

实现 = 把已建好的通道接上,不是新造高亮。 MainApp.tsx:333 早有一条专门的 revisit 定位分支(driveRevisitScroll__RB_SCROLL_TO_ANNOTATION,1.8s 脉冲后自动拆掉临时 mark),没生效的唯一原因是 SentenceRevisitPanel.openPage 把 sentence 传成了 '' ——对"手点某一页"确实没有句子,但 M5 这条动线有(「我的记忆」正在显示它)。

🔒 刻意不用复习那条 __RB_REVIEW_MODE:它是答案揭晓通道,且会盖过 ipc.js::effectiveSourceUrl 的归属回落(计划 §3.2 注 ②),把在该页查词/划线的来源写歪。 两条通道分开正是两个模块不合流的机制保障。

Rust 侧get_word_memory 原先只给 recent_context(最近一条),多页词的 「首次遇见于」那行就配不到句子,会表现为"有时候会定位有时候不会"。改为整池取回contexts,created_at DESC),recent_context/context_count 由它派生 —— 池被应用层 封顶 5(v21),故与原「LIMIT 1 + COUNT(*)」两条查询代价等价。前端按 source_url 与来源行 配对(cloze 的 source_urlreading_pages.source_ref 同值,实测 11/11 相等)。

三条边界:① 该页配不到语境句 → 传 '' 只开页不定位,不退而传裸词annotations.js 的三层匹配会把短/常见词跳到全文首个出现处);② 清单里手点某一页仍不定位;③ 一页多句时取 最早那句(与行首「首次遇见于」的语义一致)。真机验证:appoint 的语境句被整句脉冲高亮 且页面滚过去了,reviewWord 保持空(recall 通道未启用)。

模块状态保持 + 「首次遇见于」进回顾(M1-M7,2026-08-12)

as-built docs/plans/archive/module-state-preservation-plan.md零 migration、零 Supabase、零 RVH(纯前端 store/组件)。

Review 那侧的现状不是「忘了保存状态」,是主动销毁ActivityBar.switchModulem !== 'review' && fromReview → endSession(),清掉 currentCard / positions / 揭晓态 / 重温选择。 体感 = 复习到第 7 张卡 → 想去查点东西 → 回来从头开始。这个矛盾已被打过三次补丁 (Settings/Account 改弹 Modal 逃逸、复习卡「打开原文」只切 module 不动会话、endSession 里 activeTabId 回落 '' 的守卫),三处各自绕行、根因没动。本次正面解绑,确立 「模块切换只换视图,不销毁状态;销毁只由用户显式动作触发」

解绑后必须补的三样东西(不补就是把一个 bug 换成另一个): ① 显式结束入口——切走曾是唯一的结束方式,endSession 现在只剩「用户显式结束」+ 🔒登出两个 合法调用方,故 endSession* 文案在被删掉一轮后回归;且 Done/结束必须走 endReviewAndLeave连带切模块isInFocusSession 一置假,卡侧栏就没了、MainApp 非 read 分支里又没有 review 那一支,只结束不走人 = 用户盯着一片空白,完成页的 Done 此前正是这个下场)。 ② 卡侧栏改「挂载 ≠ 可见」——挂载条件 isInFocusSession / 可见条件 inFocusReview,因为 ReviewSession 的 cards/currentIndex/revealed 是组件 local state,卸载即丢、重挂 loadCards() 还会把 currentIndex 归 0。代价是它在别的模块里仍挂载着←/→ 切语境的 keydown 监听不判 activeModule 就会在用户浏览网页时把方向键劫走(同理适用于任何"只在复习界面才该发生"的副作用)。 ③ 回来时校验当前卡——会话跨模块存活,离开期间用户可能在生词本把这个词删了。只校验这一张、 失效静默跳下一张且不计数goNextCardadvanceCard 拆开);全量重取等于变相 endSession。

顺带修一个既有 bug:粘贴文档在「回顾」里一律显示"无来源"——判据是 cached_file_path, 而粘贴文本的正文本身就是那个 rb-cache 文件(source_ref = rb-cache://localhost/text/<id>.html), 从不另存快照,该列自然为空。判据改成「有没有可读的本地 URL」,抽成 lib/revisit.resolveRevisitUrl。 真正不可还原的只剩 live-only 网页 → 入口置灰 + 说明sourceUnavailableTitle),不静默无反应。 ⚠️ 该 helper 只问「有没有」不问「是什么」——EPUB 章节的 cached_file_path 也是 web/<hash>.html, 按路径前缀分派必踩坑。

「首次遇见于」改为进 revisit(原先只是开一个普通浏览 tab),并与复习卡里的「打开原文」 刻意保持分叉:前者是"我要重温这一页"(带着当时的痕迹进去),后者是"瞄一眼原文再回来答题"。 分叉理由写在 lib/moduleNav.openRevisitPage 头注释里备查,别当成漏改。 PreFocusSnapshotfromModule(每次进模块重取,不只首次——会话跨模块存活,只在首次拍 会越来越陈旧),据此在 focus chrome 顶部渲染「← 返回生词本」(挂起而非结束)。

review 时发现并修掉:指定的重温页在本组里找不到时会静默落到第 1 页(给用户一个跟他点击 无关的页面)。已知通路 = get_word_memoryreading_notes 是 LEFT JOIN、而清单数据源 get_revisit_sources 是 INNER JOIN,悬挂 note_id 的孤儿页在生词本里可点却不在清单里。 改为明确 nosource 空态;同时把「消费 target」提到 pagesForView 判空之前(否则本组为空时 target 滞留,下次进别的组被误消费)。

EPUB 可用性 Sprint 1 —— 打开一本书之后终于走得下去(B1-B4,2026-08-07)

交接稿 + as-built docs/plans/epub-usability-sprint1-handoff.md零 migration、零 Supabase、零 RVH。

四条一起做,因为它们合起来才是一件事:开书落在纯封面页 → 目录不自动开 → 全仓没有「下一章」 → 「继续阅读」和复习「打开原书」两个入口本身还是断的。 单修任一条都不解决「走不下去」。

B3 是地基,且实修出来是四处不是三处。 reading_pages.source_ref 对 EPUB 存的是 /…/book.epub#pgepubid00010;Rust 侧 classify_source_type 一直显式剥 fragment,TS 侧 一个同类 helper 都没有,于是三个消费点各自漏、各自呈现成不同的怪现象(后缀判假 → 直接 return,连异常都没有)。抽了 lib/url.ts::stripFragment 统一。第四处是剥完才显形的: AddressBarReviewSegment.handleOpen 从不切 workspace 模块——tab 在 store 里 active 了、 画面还停在复习卡上,按钮照样「点了没反应」。Library 的「回原文」早有同款处理,复习这条漏了。

B2 不是「间歇性」,是确定性的。 openContent 见到空白 tab 会就地复用(tab id 不变), 于是只依赖 [activeTabId] 的 TOC effect 根本不触发。判据:进 Read 模块自动建的那个空白 tab 还活着时开书,目录必空。依赖补 activeEpubInfo 即可。

B1② 的判据刻意放在 Rustepub.rs::first_content_spine_index)——只有它手里有解压后的 章节内容。判据是剥标签后的可见字符数而非文件名/索引:并非所有书的 spine[0] 都是封面, 按名字猜会在正常书上跳掉真正的第一章。B1③ 没有新起 EpubNavBar,而是把 EPUB tab 上本来 恒 disabled 的那对 ‹ › 改成上一章/下一章——位置形状本来就对,还顺手消灭一对死控件。

B4 只做到「恢复到章」(章内偏移需新列 → 独立立项,红线 #11 冻结后迁移从 v32 起)。章级 位置本来就藏在 source_ref 的 fragment 里,查库即可还原,故零 migration。代价要写下来: TOC 条目不带 #锚点 的书,source_ref 退化成纯路径、还原不出章节,只能落 start_index。 另一个刻意选择:没让章节导航去 touch last_opened_at——会让恢复更准,但改变「继续阅读」 排序行为(B9 的粒度问题),不在本 sprint 顺手做。

新增:lib/url.ts::stripFragment/fragmentOf · lib/epubStart.ts::resolveEpubStartHref (回落链:调用方锚点 → 库里最近读过的 → start_indexchapters[0])· EpubInfo.start_index · 命令 get_epub_last_read_ref(前缀匹配用 substr 不用 LIKE——路径里的 %/_ 是通配符) · OpenPayload.epub.startHref

快照只上传不下载 —— 换设备/重装即丢全部粘贴正文(2026-08-07)

方案与验收 docs/plans/snapshot-restore-and-user-scoped-library-plan.md

这不是新功能引入的缺陷,是 sync 从一开始就缺的一半。 upload_snapshots 是全仓唯一一处 Supabase Storage 调用,方法是 POST;全仓没有任何 GETstorage_path 被 pull 老老实实写进 DB (pull.rs 还专门用 COALESCE 保护它),却没有任何消费方 —— 一个只写不读的死字段。而 pull_reading_sources 的 INSERT 列清单里又不含 cached_file_path,于是新设备上它恒为 NULL。 三者叠加的结果:换台电脑登录,reading_pages 行被完整拉回来(笔记本里列着来源页、标题、词数、 存过的词全在),磁盘上却一个文件都没有,点开就是 404 —— 而字节其实一直躺在云端。

网页快照丢了还能回原 URL 重抓,粘贴文本(source_type='text')是唯一副本 = 永久丢失。 它把这条既有缺口的后果从「可重抓」放大成了不可逆,这才让它显形。

取回是按需的,不做登录即全量拖:打开页面时文件不在盘上,才去 Storage 取。收口点选在 rb-cache:// 协议处理器(从 lib.rs 外提到 cache_protocol.rs 并改成异步)—— 笔记本「打开页面」/ 复习卡右页 / 句子重温 / 标注跳转,所有入口最终都是「导航到某个 rb-cache URL」,在这里兜底 等于前端零改动且无遗漏。文件命中仍走原同步快路径,不引入任何额外延迟。

几处刻意的选择:反查用 storage_path 而不是 cached_file_path(后者在新设备上恒 NULL,正是要 解决的那个状态),带 user_id = ?1 + deleted_at IS NULL 双闸(红线 #5i / #6c);回填 cached_file_path不 bump updated_at —— 该列根本不进 push payload,bump 只会让这行每轮 sync 重推一次自己;推导出的路径只用于读查询的投影,绝不写回 DB —— 否则 save_page_content 会因为「cached_file_path 非空」而永不再缓存那张 web 页(text_source 注释里记过的老坑)。

取不回时渲染一张说人话的降级页,而不是过去那句 File not found: /path。三种情形分开:连不上 (可重试)/ 云端已删(粘贴文本明说不可恢复、不给重试按钮 —— 那按钮永远不会成功,给了只会让 用户以为内容还在)/ 还没从原设备上传。文案矩阵抽成纯函数 error_copy 并加了 4 个单测,其中 「重试按钮的判据 = 再点一次有没有可能成功」这条在写测试时才想清楚,顺手纠正了 web + 云端已删 那格的初版判断。

顺带把 5 处只认 cached_file_path 的读查询改用 effective_cached_rel(复习卡 source refs、note 分组 cached_count、句子重温清单、标注面板、词卡来源)—— 否则新设备上复习卡右页一律退化成 nosource 空态,协议处理器压根等不到那次请求。以及 save_page_content 由「有路径就跳过」改成 「文件真不在盘上就重抓」。

library 同时改成按用户隔离 library/{user_id}/...(此前全用户共用一个目录,换用户只清 DB 行 不清文件)。关键决定是 cached_file_pathrb-cache:// URL 仍不含 user_id,user_id 只出现在 磁盘解析与 Storage 前缀两处 —— 这一步同时买到三样:Storage 前缀不会双重拼接、存量 DB 行零迁移、 同步字段 source_ref(粘贴文档的 source_ref 就是那个 rb-cache URL)不用动也不泄漏 user_id。 存量文件在启动/登录时一次性 rename 进当前用户目录;挪错人的代价由上面的按需取回兜住。散在三处 的 library 路径实现(db/helpers.rslib.rs 里硬编码 com.lampio.dev 的那份、epub.rs)收敛成一处。

明确没做:换用户时删本地文件。隐私收益有限(受 macOS 文件权限保护,文件名是 UUID),而损失 不可逆 —— 正文是唯一副本,切回该用户会变成「行在、内容没了」,比干脆没有更糟。设置里加显式的 「清除本机缓存」入口留在 backlog。

无 schema 变更、不涉及 RVH。实机验收:清空本地文件后打开粘贴文档能看到正文(日志 [snapshot] restored bytes=4276,与云端对象字节数一致),重开走快路径不重复下载, cached_file_path 已回填。

粘贴文本编辑器从 content webview 迁到 React(2026-08-07)

方案 docs/plans/text-editor-react-migration-handoff.md(as-built 见其 §8)。边界一句话:编辑归 React,阅读归 webview —— 已发布的文档页原样不动,仍是 rb-cache://localhost/text/{id}.html 走 content webview + content-script 全套。

起因是同日用户实测报的两个 bug,两个都不是偶然:往已有内容里再粘一段会清空原文且 ⌘Z 全程失效(直接写 textContent)、笔记本下拉被裁掉一半(手写 position:absolute)。撤销栈、光标语义、下拉定位、焦点管理、无障碍,在 <textarea> + ui/Popover 里是白送的;在 Rust format! 字符串里嵌 CSS/JS 则要一个个亲手踩。

删掉 canvas.rs(385) + canvas.js(464) 两个整文件,连带一整套只为「编辑器在另一个 webview 里」而存在的跨边界机器:report_text_canvas_dirty 命令 + text-canvas-dirty 事件 + MainApp listener(React 里就是一次 updateTab)、load_doc_for_canvas + CanvasDoc + cache_protocol.rs 的两个哨兵分支、URL 哨兵判据 isTextCanvasUrl。新增 components/TextDraftEditor.tsx + lib/textNormalize.ts(归一算法逐字搬运——它是整个功能里唯一需要斟酌的地方)+ 命令 list_text_notebookssave_text_document 一个字没动,7 条产品决策一条未改。

关键结构变化:编辑态判据从 URL 哨兵改成显式的 Tab.type === 'text-draft'(编辑态不是一张页,没有 URL),三处「要不要 content webview」的手抄布尔收成 useTabsStore.needsContentWebview()。「重新编辑」是就地切换,那个 tab 的 webview 还停在已发布的文档页上,三处判据必须同时命中才不会盖住编辑器——其中 resize 那处最隐蔽,漏了它一次窗口缩放就会把它摆回来。

测试也变好了:textCanvasNormalize.test.ts 原本要 readFileSync + eval 一个 Rust 侧嵌入的页内脚本,外加 DOM fixture 和 execCommand 桩;现在是普通 import。

同日实机验收后的第二轮(详见交接书 §8):编辑区改「页面视图」并逐条镜像阅读态那张纸——权威源是 content-script settings.js 注入的 body 规则,宽度/字号改取 useReadingPrefsStore(与阅读态同一真相源,此前硬编码 680 而用户实际设 960),校准后两态首段行断点逐字对齐;::first-line<textarea> 在 WKWebView 上实测不生效,「编辑时首行长成标题」改由工具栏实时标题预览承担(它其实更准——::first-line 即便生效也只命中首个视觉行)。

地址栏粘贴按钮的语义重做:绿色从「你在编辑模式」改为「这篇还能改」——一个文档生命周期属性,跨越编辑态与「已发布但未留痕」的阅读态,存下第一个词才转中性(先例是隔壁 ★ 收藏,它的暖色同样表示页面属性而非模式)。连带修掉三件事:① 锁定态此前 disabled 整颗按钮,把「新建一篇」的能力也连坐带走了——站在一篇已存词的粘贴文档上就再也开不了新的粘贴文本;② WebKit 上 disabled 元素不派发指针事件,title tooltip 根本不显示,「灰掉 + hover 看原因」里"看原因"那半从来没生效过;③ 锁定提示原本走 toast,而 Toaster position="bottom-center" 正落在 content webview 覆盖区(原生兄弟层、永远在上),提示渲染在它背后——闸门一直是好的,只有解释看不见。反馈全部改到 chrome 层,并监听 word-saved 让按钮在存词当场转色。

修:退出复习后阅读区被复习 webview 占住(2026-08-06)

复现:关光所有浏览标签 → 进「回顾」→ 回「阅读」。阅读区显示上一篇复习页,地址栏与标签条却都是空的,任何操作都无从下手(用户原话「点阅读卡住了」)。

两处叠加:① endSession 的兜底目标是 HOME_TAB_ID,而它依据的「closeTab 会自动重建 Home」这条分支早已随 App-shell 重构删除——Home 现在是 Discover 模块,根本不是 tab;② setActiveTab 的存在性守卫把 '' 也当「找不到」拒掉,连「还原到空态」这条正常路径都走不通。结果 activeTabId 原地卡在 review,Read 模块遂把复习用的常驻 webview 当成可浏览标签摆进阅读区。

修:兜底改为 ''(= 无活动内容 tab,落 Read 空态)+ setActiveTab 显式放行 '';另在 BrowserPage 加一道闸——复习 webview 只在 Review focus session 下才允许定位,防这类症状经别的路径复现。回归测试 src/stores/reviewTabRestore.test.ts(5 例)。

粘贴文本作为第三类阅读来源(2026-08-06)

方案 docs/plans/paste-text-source-plan.md(as-built)。在网页 / EPUB 之外增加用户粘贴的英文文本。核心判断是「这是另一个数据来源,不是另一种阅读形态」——完成粘贴 ≈ 完成网页加载,此后查词 / 语境池 / 快照 / 笔记本 / 回到原文 / 我的高亮全部逐字复用。commit 27be094edd2f1d

零 schema 变更:复用 reading_pagessource_type='text',该列无 CHECK 约束),不建表、不加迁移、不碰 Supabase、不需要 RVH 新会话。

  • 入口:地址栏剪贴板按钮 / ⌘⇧V → 空画布 tab(Rust 合成的 rb-cache://localhost/__text_new__)→ ⌘V 即成文
  • 画布 UI 画在页内而非 React:content webview 会遮挡 main 的浮层,粘贴区 / 标题 / 笔记本选择器必须与用户视线同层
  • 粘贴瞬间就落盘建行(网页是 engagement 后才建):粘贴文本是唯一副本,等 engagement 会让「粘完读完一个词没查」的内容永久丢失
  • 生成即 reader 版式,不跑 Readability(charThreshold: 500 对短文本必然失败,而短文本是最高频场景)
  • 归一算法解决硬换行:不处理的话 getSentenceAround 把每行当独立句 → 语境池存半截句 → 复习卡挖空挖在残句上
  • 剪贴板预检:打开画布时读一次,像英文文本就浮条提示填入。不做后台常驻监听(会读到密码管理器内容)。那道启发式同时是隐私边界——只有通过它的内容才会离开剪贴板
  • resource_history.resource_type 有 CHECK('web','epub'),粘贴文本刻意记作 'web';放开它要整表重建,不划算

TabType 新增 'text' 后,四处按 tab.type 枚举的白名单没跟上,其中 BrowserPage 那处是阻塞级——粘贴 tab 的 webview 从未被创建,功能在入口处就是断的。改用 ownsContentWebview(tab)(按有无 webviewLabel 判定)替换类型白名单,后者每加一个 TabType 就会静默漏一类。

LLM 调用统一到服务端:成本可归因 + 反滥用配额 + 全页双语退役(2026-08-02)

方案 docs/plans/llm-server-unification-plan.md(含 7 条设计决策与否决记录)。起点是"翻译是最后一个还要用户自己填 API key 的功能",但真正的欠账是服务端花的钱不可归因——llm_call_loguser_id、TTS 完全在账本外、anon key 公开而无任何闸门。commit e41b86ed6af447

成本可归因(WP0-WP2)

  • Edge 调用带用户 JWT:9 个调用点全部从 anon key 换成用户 access_token同时补了一个不做就会自己种下的 bug——anon key 永不过期而 access_token 会,而 edge 调用是用户随手触发的(双击查词/点拆解),完全可能落在过期时刻;sync 路径有主动续命、edge 路径没有。新增 ensure_fresh_access_token(按 expires_at < now+60 判定,失败回落匿名不阻断)。
  • supabase.rs:239 的 PostgREST 缓冲池查询刻意不换:它读跨用户共享的 vocabulary 缓冲池,而 vocabulary-buffer.sql 明写"RLS 策略未在本次导出范围内"——仓里没有策略定义,顺手换等于拿一个无文档的 RLS 赌运气。
  • TTS 从零建账:新增 tts_call_log。与 llm_call_log 分表而非加列,因为计量单位是字符不是 token,混表会污染 governance.ts 按 cost 求和的预算闸语义。issue-speech-token 明确记为发放日志不是用量日志——token 出门后客户端直连 Azure,服务端结构上不可计量。
  • user_quota_usage 数的是「用户动作」,llm_call_log 数的是「LLM 调用」——故意不同粒度。一次批量短语判定只发 1 次 LLM 但对用户是 1 次动作;反之某功能内部拆成两次调用,用户不该被扣两次。配额面对人,成本面对账单,混为一谈必然让其一失真。
  • 顺带把 logCall 的静默 catch {} 改为留痕:schema 一漂就会让整条账本无声失效(你以为在记,其实没记),比没有账本更糟。同类风险 = 已退役的 phrase_interaction_log

反滥用配额闸(WP3 阶段 1)

  • 9 个 function 在调 LLM 之前问额度,超限返回 429。实测被拦请求不产生 llm_call_log 行——真的零成本。
  • 429 而非「200+标记」是关键选择:让 graceful 型客户端(消歧/短语判定)自动静默降级、传播型(翻译/拆解/周报)自动明确报错,一个服务端决策省掉六处客户端改动
  • 主动 / 被动分成两个 meter:实测发现短语自动高亮是被动触发的(用户只是在读页面)且吃掉 78% 的 token;与主动动作共用 meter 会让用户因为读得多而撞墙,违反「墙不能压到核心阅读闭环」。
  • 阈值刻意宽松且存在 quota_config——收紧是一条 SQL UPDATE,不改代码不重部署。配额当前是 backstop 不是转化杠杆(产品暂时全免费,收费点看产品力)。
  • 一律 fail-open:配额查询自身出错就放行,不值得为严密而让一次 DB 故障打掉全部 AI 功能。

翻译收口 + 全页双语退役(WP4a-c,净删 587 行)

  • 新建 translate-batch edge function,选区翻译与句子工作台改走它,用户不再需要填 key。砍掉 DeepL(按字符计费与 token 记账维度打架,且做不了"按 CEFR 简化译文"这类产品化翻译)。
  • 全页双语整块退役:整页译文一旦并排,眼睛就走中文——没人双击查词、CEFR 高亮变装饰、语境不进 cloze 池、SRS 无新卡,产品在那一刻退化成"带译文的浏览器"。救援职能交给已有的选区翻译;拖选那点摩擦恰恰是想要的(先试过再对答案)。
  • translate_paragraphs / DeepL / 设置项保留一个版本周期作回退路径,下次发版前再删。 已于同日删除(WP4d,见下)。

删除旧翻译路径(WP4d)——「保留作回退」的前提证伪

  • 原计划留一个版本周期,理由是"删早了没有回退路径"。执行前核对发现旧路径运行时已不可达translateParagraphs 零调用方、content-script 里 translateProvider / translateApiKey / translateCustomEndpoint / translateCustomModel 四个字段只写不读bilingual.js 已随 WP4b 删除。留着并不构成回退路径——真要回退得重新接调用方,与 revert 一个删除 commit 等价。
  • 反而有正在发生的成本:设置面板仍在展示 provider 下拉和 sk-... 密码框,用户填进去的 key 静默无效。留着的不是保险,是一个会误导用户的空壳。
  • 删除范围:translate.rs 的 DeepL / chat / call_chat_api / translate_paragraphs 全链(373→130 行)、commands.ts wrapper、TranslationSettings.tsx 的 key 输入 UI(127→41 行,只剩目标语言)、BATCH_KEYS 5→1、strings/ 9 个孤儿 key × 3 文件。translate_target_lang 保留(仍是用户偏好);settings 表存量 translate_api_key 行留着不删(用户数据,不值得为它开新迁移)。
  • translationSubtitle 文案一并改写——旧文案「整页 / 划词翻译的 AI 服务配置」两处失实(整页双语已删、也不再有服务配置)。

语音(WP4e)

  • 删掉「系统语音」选项,恒走 Azure Neural。线上实测确认 tts_provider 从未被设置过——绝大多数用户一直听的是系统语音,注释里那句"验证 OK 后切默认"从没执行。一个等同于低质量的选项,用户不会归因为"我选了低档",只会觉得产品发音难听。连读取一起删:只删 UI 不删读取会让存量库存过 web-speech 的用户永远卡住且再无 UI 可改。web-speech 保留为内部兜底(Azure 失败自动 fallback + toast 标注)。

验证与后续

  • 端到端实测(真实登录用户):tts_call_log.user_id 为真实 uuid、char_count×$16/1M 与 cost_usd 精确吻合、user_quota_usage 各 meter 与实际调用逐项对齐。
  • code-review 出 1 个 P1(TTS 账本给被拦请求记了假成本,赶在数据变脏前修掉)+ 3 个 P2(已随本批处理)。
  • 剩余:仅 lookup-or-fetch-word 加闸一项。WP3 阶段 2(拒绝匿名)与 WP4d 均已完成;阻塞该项的 RVH JWT 实测已回填(docs/cross-end/18-rvh-edge-jwt-handoff.md §六,结论 = Dart SDK 自动携带用户 token,RVH 无需改代码)。

修复:精读面板跟读空白 / LLM 失败无重试 / 会话恢复假 spinner(2026-08-02)

三个实测反馈的修复(commit a1ca88a,纯 RB 前端,无 schema/sync/跨端影响)。共同点是症状在 UI、根因都在"状态归属"——某个状态由 A 设置却指望 B 清理,而 B 在该路径上根本不会被调用。

  • 会话恢复的非激活 tab 一直转圈(用户可见):restoreSessionaddTab,而 createTab 默认 isLoading: !!url;但恢复出来的 tab 是懒加载的——BrowserPage 只对 active tab 调 ensureWebview,其余 tab 既没有 webview 也永远收不到 navigation-state-changedisLoading 翻回 false,spinner 会挂满整个会话,误导用户以为后台在加载。修法 = 恢复态显式置 false,spinner 所有权收归 ensureWebview(真建 webview 时才置 true)。不动 createTab 默认值——正常开链路场景 webview 紧接着就建,去掉会多一帧无 spinner 空窗。
  • 选中新句子点「跟读」,面板打开却看不到该句(用户可见):跟读请求不落任何 annotationemit_pronunciation_request 只 ensure source + 缓存 HTML,真正的 pronunciation 条目要等录完音评完分才进 timeline),于是该句在 groups 里没有卡可路由,practiceRequest 空转。翻译/拆解没这问题是因为它们有 pendingSource 顶卡兜底。修法 = 补一张「草稿卡」承载内嵌练读段,且合并进 groups 数组而非像 pending 顶卡那样渲染在数组外——评分落库后同 key 的真卡出现时 React 才能复用同一实例,InlinePractice 刚录的评分和重录按钮不会被卸载重挂。
  • 翻译 / 拆解 / 指代·背景 LLM 失败后无重试入口(用户可见):新增 lensFailure 统一判定三个 lens 的失败形态(翻译的 [Translation failed: …] 文本 + 空译文 / 拆解·指代的 error 字段;React 端与 content-script 两条链路同形),B 区失败态统一走新组件 LensError(标题 + 详情 + 重试)。关键不在按钮而在幂等闸——失败同样会往 timeline 塞条目,闸不放行失败条目的话重试点了也是 no-op。AnalysisSection / ContextSection 各自的错误分支随之删除,错误呈现收敛到一处;顺带把 MainApp 两处 JSON 解析失败的兜底从「拿 lens 标题当错误详情」改成 resultParseFailed
  • 验证:tsc 0 / pnpm build 绿 / check:no-inline-zh 0;三条实机测通(重启后非激活 tab 不转圈、新句跟读出卡且评分不闪、断网翻译失败后重试真发起)。
  • 归属注记:配套的 strings(lensFailedTitle / retryButton / resultParseFailed)在并行会话的 11b8fc3 里被目录 pathspec 的 git add 连带提交,内容完整、与消费方对得上,未回滚(回滚等于重开丢失窗口)。教训已入 feedback_parallel_session_worktree_isolation 记忆的「路径 C」。

重构:Chrome / Toolbar 体系统一(批 1 + 批 2,2026-08-01/02)

App-shell 重构后 Read / Review / 词汇与笔记三处 toolbar 在高度、形状、分组、图标文字策略上各行其是。目标不是"改一次好看",而是把规矩物化进 token + 基元 + skill,让后续新增 chrome 控件自动落在规范内。规范正文 docs/ui-standards.md §22(v1.4),审计与批次 docs/plans/toolbar-unification-plan.md

批 1 — 物化 + token 层

  • 一条带高 32px:新增 design-tokens.tsCHROME 常量块作为唯一物化点(band 48 / row 36 / rail 32 / button 28)。Read rail 36→32 与 Review 段控对齐;rail 内按钮 24→28 保住点击靶。URL 输入框 36 不属于本体系(输入域比图标按钮高一档是浏览器惯例)。
  • 形状 / hover / 焦点 / 禁用收敛:一律 rounded-full;chrome 激活态一律 neutral(primary 退出 chrome);hover 统一实色 surface-muted(§20 U3 的显式例外——chrome 坐灰底上 4% 黑几乎不可见);禁用态与焦点环内建进 ChromeButton / TabButton / PageToggleButton,删掉 AddressBar.disabledNavClass 与调用点的 disabled: 覆盖串(补齐了 chrome 基元此前完全没有键盘焦点环的 a11y 缺口)。
  • 段控纵向对齐:没有 URL 输入框的 toolbar 会按内容高塌陷成 44/46px,与 Read 一高一低 → Library / Review 段控显式吃 CHROME.band / CHROME.row,三处 rail 中心统一在距窗口顶 60px。

批 2 — 结构层

  • rail 语义收敛SegmentedGroup(浮起白壳)收紧为只表示互斥选择器,全仓只剩 4 组。命令簇(前进/后退/刷新,根本没有激活态)与开关簇改走新 token CHROME.cluster裸按钮串,分组由间距对比表达(串内 2px vs 区间 8px)。
  • 「当前页」混装簇拆分(用户可见):原 rail 里挤了四种行为——★收藏(站点 toggle) / 阅读净度(菜单触发器) / 尺寸%(复位命令) / 双语(页面 toggle),四者无一互斥。拆分判据定为按作用对象而不是按控件形态:★收藏作用于「这个地址 / 站点」(按域名存 favorite_sites),不是「页面怎么显示」→ 迁进 URL 输入框内右端与「打开文件」并排(Chrome / Safari / Edge 同位),随之换用输入框自己的 4% 黑 hover;剩下三件 = 页面呈现簇。
  • Review 段控迁进真·左区:focus review 下左区曾被置 0、由 AddressBarReviewSegment 在中区内自画"假左区",两套坐标系嵌套(真左区的居中 / 溢出 / 降级规则对它一概无效,段控还被区间间距推离侧栏左缘 8px)。现左区宽在 focus review = reviewCardWidth。连带修:底部位置发丝条改绝对定位到中区底缘(进流会撑高中区、把侧区内容推离主行中心),focus review 的 toolbar 带高随之回到与 Read 一致的 48px。
  • 窄窗降级(用户可见):侧区原是 width + flex-shrink-0 + overflow-hidden,窗口一窄整行溢出、右区被推出窗口或按钮被切掉半个。改为用 flex 的 shrink 权重编码优先级(侧区 100 : 中区 1),得四档阶梯:中区吃余量 → 侧区放弃"正对面板"的对齐收到内容宽(按钮一个不少)→ 中区收到硬底 → 溢出。实测(Chromium):旧行为 904 视口就开始硬裁右区、中区被压到 280(输入框仅 88px);新行为同一视口给到中区 478 / 输入框 286,直到 560 才触底。
  • skill 接线/arch-check 新增 S24-S27(chrome 层裸 button 计数 / 禁绕过 CHROME 写尺寸 / 基元焦点环 3-3 / 禁 primary 激活态,v1.6.1);/ui-check 新增 U7-U9(rail 语义 / 图标文字层级 / 三区骨架,v1.1.2)。设计要点:规则只断言物化点没被绕过,不到处扫 px 值——散扫的正则又脆又误伤(S11 spacing 已踩过)。

未完(批 3):5 处裸 <button> 收编、统计 range picker 与页面工具 accent 两个异类激活态收编、TabButton variant="chrome" 死代码清理、TitleBar tab pill 是否并入。

架构:预装词库 pipeline 迁入 RB(2026-07-19)

  • 迁移:预装词库构建 pipeline(tools/vocabulary_builder_v3/,Dart)从 RVH 仓 lift-and-shift 迁入 RB,RB 成为预装词库单一产源(原为 RVH 产 → RB 消费的环状 split-brain)。
  • lemmatizer 环打开build_dict.rs 产的 src-tauri/assets/nlp/{surface_to_base,base_forms}.json 与 pipeline 同仓消费(config app_db / lemmatizer_* 改指 ../../src-tauri/assets/),不再跨 RVH 仓相对路径。
  • sync 方向反转scripts/sync-rvh-vocabulary.sh 从 RVH→RB 反转为 RB→RVH(4 表白名单 + lemma 行数 + 机器污染断言不变,源现为 RB db;REPO_ROOT 锚定,cwd 无关)。
  • byte-equal 护栏(红线 #10):结构迁移零内容变更——迁移前后预装库 SHA 恒为 b2821764…,双端 byte-equal,不 bump VOCABULARY_SEED_VERSION、用户零 reseed
  • skill 升级vocab-reseed 从「RVH→RB 单向同步」升级为「RB 产库 + 生效 + 反向 sync RVH」(v2.0.0)。
  • 方案与 RVH 侧协调清单:docs/plans/rvh-vocab-pipeline-migration-plan.md(RVH 侧移除旧 pipeline / 改 RVH skill 属另会话)。

优化:「句子工作台」面板 8 点体验打磨(2026-06-24)

  • 改名:面板标题「本页标注」→「本页精读」,左侧开关按钮标签同步「标注」→「精读」(SentenceWorkbench 内部名早已收口,对外名称跟进;空泛的"标注"换成涵盖翻译/拆解/指代的"精读")。
  • 手风琴折叠:句子卡新增折叠态——最多 1 张展开,折叠态句子 line-clamp-2 省略号、不渲染 tab bar/B 区;点句子区域切换。expandedKey 状态上提到 SentenceWorkbench(父级 useState,跨卡"最多 1"约束);新结果/新建卡跳顶自动展开并收起其它、句级跟读请求展开目标卡。
  • 展开 ↔ 定位联动:点句子展开时,若源页已在某 tab 打开 → 滚动居中 + 闪一下(抽 scrollToSentenceIfOpen,与「位置」按钮共用);源页未开不强开 tab(读分析不附带跳转),「位置」按钮才负责"未开则开页"。清理未实现的 __RB_HIGHLIGHT_SENTENCE/__RB_CLEAR_SENTENCE 死代码 + hover handler。
  • 子块可折叠:拆解(主干/修饰/词汇/语法点)+ 指代(指代/背景)各子块改为可折叠小节,header 为「图标 + 标题 + 可选数量 + 右箭头」、hover 三元素同步响应——抽共享组件 ui/CollapsibleSection(视觉 idiom 源自 DiscoveryPanel 短语清单)。
  • 收藏 = 标记统一:工作台「收藏」与页面选区「标记」统一为同一概念(术语全量「标记」:标记此句/取消标记),点星 → 落 highlight 记录 + 在已开源页即时画持久黄 mark(新 content-script hook __RB_ADD_MARK,按文本定位 + wrapRangeAsMark,经 highlight-queue 自动 pause observer;刷新后 loadAndWrapAnnotations 自动重建)。savePageAnnotation 返回 db id。
  • 字号 + 边距:正文字号整体上移一档贴齐 review 卡(句子/译文 text-base 16 / 正文 text-sm 14 / 小标题·chip text-xs 12,原 caption 10 / badge 8);白卡更贴面板边缘(PanelSection px-2、卡内 px-2.5)。去掉 B 区 per-tab 时间戳。
  • 文案统一:tab「指代」→「指代·背景」、「练读」→「跟读」,与选区工具栏对齐。
  • 验证:实机核对全 8 点(手风琴折叠/展开滚动/子块折叠/收藏画黄 mark 持久化);tsc 0 / arch-check 关键规则清 / i18n 三件套(no-inline-zh / audit:locale)0 / code-review 0 P0·P1。纯 RB 端(无 schema/sync/SM-2/跨端协同)。详见 docs/plans/archive/sentence-workbench-polish-plan.md
  • 打磨(同批):① 展开/「位置」定位的 flash 时长 1.2s→1.8s(annotations.js flashElements 1800ms + transient 拆除 1900ms + styles.js 两条 rb-annotation-flash 动画 1.8s,四常量同调);② 选区工具栏按钮顺序改为「翻译 / 拆解 / 指代·背景 / 跟读 / 朗读 / 标记」(前 4 = 工作台 4 tab 同序),下线「短语」按钮(+ 清理 handleSelectionPhrases 死代码及 import);③ 工作台「标记」图标由 Star(星)改为 Highlighter(荧光笔),与选区「标记」按钮同图标——星形=收藏/喜欢的旧语义已被「标记」取代。content-script 改动需重启 tauri dev 生效。

新增:词源 / 助记(LLM 产品升级 ⑦)+ 词族 chips(2026-06-15)

  • 功能:词头下方新增收起态「词源」chip,点开展开圆角卡——一句话双语词源(en 斜体 / zh 正体)+ 可选词根拆解(bene 好 · volens 意愿)。词根「一根串多词」对长难词记忆有奇效;习语给 origin story。同区顺手露出词族派生词静态 chips(national → international · nationally · nationality …word_family 4408 词已填的构词网络)。入口 v1 落 Library 详情 + 复习卡背面(查词弹窗留 v2)。
  • 架构:⑦ 是词自身属性(context-independent,同 ⑤)→ 离线烤进预装库 word 级独立列 vocabulary.etymology(红线 #10),零运行时 LLM、零延迟、双端 byte-equal、无 opt-in。生成在 RVH pipeline(deepseek t=0,核心纪律 = 反 folk etymology:posh/golf/tip 假缩写词源一律拒绝、不确定 hedge 或留 NULL、roots 只给真根、不做谐音/故事助记),RB 仅消费渲染。
  • 数据:RVH commit 0b7a42e 生成 → RB byte-equal reseed v20(SHA c81d137…,seed ver rvh-18899-v20-etymology,纯 additive 无 migration)——etymology 列填充 12743 词(含 roots 10267 词)。⑦ 比 ⑤ 多一步etymology 是【独立列】(区别于 ⑤ differentiation 在 pos_definitions JSON 内部),helpers.rs merge SQL 的 DO UPDATE SET 新增 etymology = excluded.etymology,否则老用户升级拉不到词源。
  • 渲染dictionary.rs/srs.rs SELECT 加 etymology+word_family 列 → parse_etymology/parse_word_family 解析 JSON(容错:缺 summary / 坏 JSON → null 不渲染)→ DictResult/DueCard 加词级字段。新组件 EtymologyAction.tsx——纯本地展开/收起,无网络、无 loading、无缓存(复用 ⑤/④「深度 chip → 圆角卡」原语);空数据不渲染。词级一次贴词头(区别于 ⑤ per-POS、不进 PosGroupBlock):WordDetailView + ReviewCardBack + ClozeRevealCard 三处词头区接线。
  • 边界:查词弹窗(content-script)入口留 v2;word_family 可跳转导航留 backlog(v1 静态展示构词网络);跨端 RVH 复习背面同展示挂 ⑥ cloze v2。详见 docs/plans/archive/etymology-mnemonic-plan.md + docs/cross-end/12-rb-etymology-reseed-confirmation.md

新增:近义词辨析 / 搭配(LLM 产品升级 ⑤)(2026-06-14)

  • 功能:词的「近义/反义」行下方新增收起态「用法区别」chip,点开展开——为每个高频易混近义词讲何时用哪个large 更正式 / huge 更强烈 / massive 强调体量)+ headword 的典型搭配big difference / big problem)。补 CEFR 高亮 + 词表给不了的「用法直觉」。入口 v1 落 Library 详情 + 复习卡背面(查词弹窗留 v2)。
  • 架构(关键决策):⑤ 不依赖 context(词/义项自身属性,区别于 ④ 个性化例句锚用户读句的 per-user 本质)→ 不走运行时 Edge Function,而是离线烤进预装库 pos_definitions(沿 synonyms/examples precedent,红线 #10):零运行时 LLM、零延迟、byte-equal 双端免费、无 opt-in。生成在 RVH pipeline(t=0 + 从噪声 synonyms 筛 3–5 高频易混 + 反幻觉),RB 仅消费渲染。
  • 数据:RVH v19 reseed(SHA e180…,seed ver rvh-18899-v19-synonym-diff,纯 additive 无 migration)——每个带 synonyms 的 def 加 differentiation[{synonym,distinction_en,distinction_zh}] + collocations[{pattern,note_zh}],覆盖 5329/5600 词。helpers.rs merge SQL 无需改动(两键在 pos_definitions JSON 内随既有 DO UPDATE SET pos_definitions 覆盖)。
  • 渲染dictionary.rs 解析两键聚合到 POS 级(dedup + 截断 6);新组件 SynonymUsageAction.tsx——纯本地展开/收起,无网络、无 loading、无缓存机制(复用 ④ 「深度 chip」视觉,丢弃 ④ 异步);空数据不渲染。PosGroupBlock 一次接线 → Library + 复习背面两端同获。
  • 边界:短语 v1 不产(留 v2);查词弹窗 v2(数据已在 blob,仅多渲一 key);跨端 RVH 复习背面同展示挂 ⑥ cloze v2。

新增:难句拆解(LLM 产品升级 ②,主干/修饰分层)(2026-06-10)

  • 功能:现有选区「分析」重构为长难句主干/修饰分层拆解——先剥主干 SVO,再把定语/状语/介词短语/分词/同位语逐层挂回,内置句内回指标注(which/that 指向主句哪成分),弱化翻译、像真人老师讲结构。命中 CEFR 逐词高亮永远做不到的"每个词都认识但句子读不懂"。
  • 基础设施迁移analyze_sentence客户端直连用户 LLM key 迁到新 Edge Function breakdown-sentence(照搬 disambiguate-sense/explain-phrase 骨架:deepseek temperature 0 + response_format: json_object + graceful null;服务端持 key,项目付费、用户零配置)。命令名保留以减少 churn,签名瘦身去 provider/apiKey。
  • 持久化:复用 page_annotations type='analysis',result_text JSON 升级为旧结构超集(加 backbone 主干 + layers 修饰分层,layer 含 modifies 文本锚 + 可选 coref 句内回指)。Rust #[serde(default)] + TS optional 向后兼容老 flat analysis 数据;零 schema / migration / 同步矩阵变更
  • 展示TranslationTimeline AnalysisCardContenthasBreakdown 渲染分层视图(主干高亮块 + 修饰缩进列表 + ↪挂回 / ↺回指),老数据 fall-back 现有 flat 视图;TranslationCardContent 加「拆解这句」升级入口(主 React 端独立链路,savePageAnnotation content_html:null 借 first-write-wins 缓存)。
  • 隐私:手动动作(用户显式点「拆解」/「拆解这句」)不 gate context_disambiguation 自动触发开关——点击即视为同意,与原 analyze 行为一致。跨句指代/背景升级入口留 roadmap ③。
  • 验证:Edge Function curl 实机通过(HTTP 200,backbone/layers/coref 全产出正确)+ in-app UI 实机通过(选区「拆解」→ 主干块 + 修饰层「↳挂回」+ role/CEFR 徽标 + 弱化翻译,分层视图渲染正确);cargo check + pnpm build + build:cs + check:no-inline-zh + arch-check 全绿。详见 docs/plans/archive/sentence-breakdown-plan.md
  • 打磨(同日):① 翻译卡「拆解这句」加防双击 + 同句幂等去重(已拆解过则展开已有卡,不再重复触发 LLM / 重复落库);② 拆解一开始(选区工具栏 + 翻译卡两路径,useEffect 监听 isAnalyzing)自动切到「拆解」filter 让结果可见;③ 命名全量统一为「拆解」——timeline filter chip / 失败提示 / NotesPanel「翻译&拆解」tab+空态提示 + 卡片徽标 A→B 全部由「分析」改「拆解」,与 toolbar 按钮一致。内部 annotation_type='analysis' / 命令名 analyze_sentence / emit 事件因持久化+Supabase 同步契约保留(显示名与内部 enum 解耦)。filter chip「全部」移到末尾。audit:locale 0 漏译。
  • 修复:翻译/拆解跳转定位高亮的位置偏移闪动(同日):根因——跳转无持久 mark 时走 wrapRangeAsMark 临时包 <mark class="rb-annotation">,该类带 padding:1px 2px,包裹撑宽文字 + 1.3s 后拆除复位 = 两次 reflow 抖动。修法——wrapTextNodePortion/wrapRangeAsMarktransient 旗标,临时 mark 挂 rb-annotation-transientpadding:0/border-radius:0/background:transparent)→ 包裹/拆除零盒模型变化;flash 改 rb-annotation-flash-transient 纯 background 脉冲淡出到透明(不触发 reflow)。定位脉冲保留、原句纹丝不动、拆除不留痕(持久黄色高亮仍只属「标记」类型)。content-script 改动,需重启 tauri dev 生效。

新增:词汇插图(Word Illustration)OpenMoji 离线层(2026-06-06)

  • 功能:具象名词在 Library 详情页标题区 + Review 复习卡正面显示 OpenMoji 插图(dog→🐕、apple→🍎),作为"基于语境学习"的初学者增强(锦上添花,非核心)。词级一词一图,无图则不显示(宁缺毋滥,自然偏 A1/A2 具象名词)。
  • 数据(跨端,红线 #10):emoji 映射由 RVH pipeline 单点产出进预装库(RVH commit ef10b0e / v53,回填 505 词 / 489 去重 hexcode)。RB 走 byte-equal reseed 消费(scripts/sync-rvh-vocabulary.sh + bump VOCABULARY_SEED_VERSION → v16-emoji)。emoji 不进 Supabase 同步矩阵。
  • Schema:migration v14 vocabulary ALTER ADD emoji TEXT(沿 v9/v12 ALTER-only precedent);helpers.rs reseed UPSERT 加 emoji(ON CONFLICT 覆盖,保留 audio_local_path 用户态,已模拟验证)。
  • 读取链路dictionary.rs::lookup_word + srs.rs::get_due_cards 读 emoji → DictResult/DueCardillustration_hexcode → 前端 DictEntry/DueCard 同名字段。
  • 渲染components/vocab/WordIllustration.tsx<img src="/openmoji/{hexcode}.svg">,无 hexcode 返回 null);489 个 OpenMoji color SVG 打包 public/openmoji/(2.4M,CC BY-SA 4.0);Settings 页放署名。
  • 配图粒度决策:word 级 + 歧义黑名单(RVH 复核弃 bank/bat/watch 等弱主导义同形词),不做 sense 级(OpenMoji 无第二义图,sense 级买不到额外收益)。跨端交接 docs/cross-end/04-word-illustration-handoff.md

修复:Modal 被 content webview 遮挡导致全 App 死锁(2026-06-01)

  • 现象:CEFR 升级(A1→A2)时正开着网页,LevelUpModal 弹出后全屏暗遮罩盖住 chrome、中间网页清晰、整个 App 无法操作,点遮罩和直接 ESC 都解不开(closeOnBackdrop={false} + 焦点陷在原生网页里)。
  • 根因:content webview 是原生子窗口、层级永远在主 webview 之上;ModaluseHideContentWebviewsWhileOpen 在打开那一刻一次性把它移到屏外,但 BrowserPage 的两条定位路径(activeTab effect 的 setTimeout(80) show + ResizeObserver/handleResize)会在 modal 打开期间把网页摆回来盖住对话框。启动时序:checkAndPushLevelUpstartAutoSync 异步设 pendingLevelUp,常在默认网页定位之后到达。
  • 修复(单一真相源,架构原则 2)usePanelStore 加全局 overlayCountpushOverlay/popOverlayresetLayout 归零);useHideContentWebviewsWhileOpen 开时 push、cleanup 先 pop 再 dispatch resize;BrowserPage 两处定位 call site 加 overlayCount > 0 → return 门。overlay 期间 BrowserPage 不再把 content webview 摆回来,最后一个 overlay 关闭(count→0)时经既有 resize 恢复。计数器(非布尔)支持多 overlay 叠加。
  • 纯前端,不涉 Rust/schema/同步/RVH。ConfirmDialog 等其它 Modal 自动受益。

释义弹窗结构化 + MDict 死代码清理(2026-06-01)

  • 后端结构化释义commands/dictionary.rs 新增共享提取函数 extract_pos_groups()(兼容 object/array 双 shape、per-sense 中文回退、syn/ant 跨义项去重,每 sense ≤3 例句 / syn-ant ≤5)+ Sense / PosGroup 结构体。DictResult 由"每字段 join 成字符串"重构为"word 级元信息 + 内联 PosGroup(每词性一条,senses 数组)"。commands/srs.rs 删除发散的 extract_all_defsDueCardpos_groups: Vec<PosGroup>,与 lookup_word 共用同一提取逻辑
  • 修复例句静默失效:旧 popup.jsentry.examplesJSON.parse,但后端发的是 " | " 拼接字符串,parse 抛错被空 catch 吞掉 → 词库词条例句从未渲染。现 examples 为真数组,直接遍历
  • 前端统一渲染:新建 components/vocab/WordSenses.tsxPosGroupBlock,版式 A:词性分组 → 编号义项 英上中下 + 例句 → 同/反义词),由 WordDetailView(生词本/排除词/Browse 详情)+ ReviewCardBack 复用;content-script popup.js 同构 vanilla 实现。释义从"压成一行"升级为多词性平铺 + 编号多义项 + 例句 + 同反义词
  • MDict 死代码全清:导入功能早已删除(mdict.rs 模块不存在、无写入路径)。移除残留的 external_dictionaries 表(schema.sql)+ lookup_word 兜底 SELECT + DictResult.dict_name / DictEntry.dict_name + popup.js 的 dict_name HTML 分支 + CLAUDE.md 命令清单中的 mdict
  • 文档校准docs/database-tables-overview.md + docs/database-schema.md 全量对齐真实 schema(清幽灵表 dictionary/nav_sites/user_dictionary、补 reading_log/recommended_excluded_words/pronunciation_attempts、表数 16/17→20、同步矩阵 6→9 用户表、迁移历史 v6→v13、reference_words 标注不再同步);docs/architecture.md 修 dictionary 描述 + 删 mdict 行 + sync 5 表→9 表
  • 跨端:仅 RB 内部数据形状变化,不动 vocabulary 表 / pos_definitions 列 / 同步矩阵 / SM-2 → 无需 RVH 同步、无需动 Supabase DDL。详见 docs/plans/archive/word-popup-structured-definition-plan.md

Sprint C-1 跨端续读 RB 侧(2026-05-21)

  • Schema:v12 migration ALTER reading_sources ADD COLUMN last_opened_at TEXT + 部分索引 (user_id, last_opened_at DESC) WHERE last_opened_at IS NOT NULL;沿 v8/v9 precedent 不动 v1 baseline
  • Touch 内聚log_resource_open(history.rs)在既有 resource_history INSERT/UPDATE 之后追加 UPDATE reading_sources SET last_opened_at = ?, updated_at = ? WHERE source_ref = ? AND (user_id = ? OR user_id IS NULL)。不存在源行(即用户未在该 URL engaged 过)UPDATE 命中 0 行 = 零成本 no-op,SPA / 搜索引擎结果页 drive-by 不污染 reading_sources / 不放大 sync 流量
  • Sync push/pull:push_reading_sources SELECT 加 last_opened_at;pull_reading_sources RemoteReadingSource 加 Option 字段(#[serde(default)] 兼容 Supabase ALTER 未执行的部署窗口);ON CONFLICT 用 CASE WHEN ... THEN MAX(...) 合并(RFC3339 字典序 = 时间序,安全)。服务 RB 多设备闭环
  • 新命令 get_continue_reading(limit?)(notes.rs):返回 last_opened_at IS NOT NULL 的最近 N 条 reading_sources(默认 5)
  • HomePage Continue Reading section:位于 "Recently Opened" 上方,复用 SectionHeader / ListItem variant=double / TypeIcons / formatTimeAgo,零新组件。监听 Tauri sync-completed 事件,pull 回来后自动刷新
  • Supabase DDLsupabase/sql/sync-tables.sql 加 last_opened_at 列定义 + 部分索引 + Dashboard ALTER 升级片段
  • RVH 协议:RVH 移动端无阅读功能,只 pull 不 push 此列;UI 建议位置 = 词卡详情页 / 笔记页底部"Recently on RB"展示。完整清单见 docs/plans/sprint-c1-cross-device-continue-reading-rb-plan.md 末尾"RVH 镜像协议清单"段,待新会话执行
  • 详见 docs/plans/sprint-c1-cross-device-continue-reading-rb-plan.md

Sprint A — 产品快速收割(2026-05-21)

  • Practice 学习痕迹露出(方向 A)pronunciation_attempts 接入 TranslationTimeline 第 4 类标注(teal P 卡片,与 M/T/A 并列)。get_translation_history 改 UNION ALL(page_annotations + pronunciation_attempts),列表查询不返回 audio_blob。filter chips 加第 5 片"跟读";点击 P 卡片跳转 cached_file_path / live URL(与 highlight 路径完全一致);解决 pronunciation_attempts 长期"写入井"问题
  • Readability extract 失败兜底page-snapshot.js 从 fail-fast(Readability < 100 字符直接 null)改 fail-degraded 4 层链路(Readability → <main><article> → body innerHTML 截 50KB)。抽公共 extractBestEffortContent() helper,savePageContentOnce + consumeCachedContentHtml 复用。提升 SPA / Twitter / Reddit 等极简页面的 cached_file_path 命中率,直接改善 Annotations panel 跳转体验
  • CEFR last_known_cefr_level per-user 隔离clear_learning_data_if_user_changed 事务里加 DELETE FROM settings WHERE key='last_known_cefr_level',消除多用户共用同一设备时 user A baseline 污染 user B Level Up 阈值的边缘 bug
  • Practice 面板露出历史 P 卡片 + 重听按钮砍除(任务 4,中途加入):
    • 重听砍除:实测 audio_blob 100% NULL,根因 Azure SDK AudioConfig.fromDefaultMicrophoneInput() 不暴露本地字节。任务 1 加的"重听"IconButton + get_pronunciation_audio Rust 命令 + getPronunciationAudio wrapper 全部砍除(attempt_id 字段保留为未来 MediaRecorder 落字节预留接口)
    • PronunciationHistoryStrip 新组件挂在 PronunciationPanel 顶部:折叠头显示 "最近练读 (N)" + "本篇 / 全部" scope chip;展开是紧凑 P 卡片列表(评分 + target_text 截断 + 相对时间);点击行触发 handlePickHistory(text) 灌入 displayText 重新练,不重 mount panel 保留 SDK 已加载状态
    • 后端:新增 list_recent_pronunciation_attempts(source_id?, limit?) 命令,按 user_id + 可选 source_id 过滤,不返回 audio_blob / word_scores(紧凑列表)
    • PronunciationPanel state 提升text prop 仍是入口,内部 displayText state 接管所有 reference 显示与 assess 调用;persist 成功后 historyRefetchKey +1 触发 strip 自动刷新
  • 详见 docs/plans/archive/sprint-a-product-collection-plan.md

Azure Speech 全链路(2026-05-19)

  • TTS(Phase 1 + 1.6):Edge Function tts-synthesize + Rust tts_synthesize 本地 LRU 缓存(200MB)+ 前端 tts.ts 统一路由(azure / web-speech + fallback)+ 4 处调用切换 + Settings UI
  • Pronunciation Assessment(Phase 2A + 2B):Edge Function issue-speech-token 签发 10min STS token + 浏览器侧 microsoft-cognitiveservices-speech-sdk dynamic import(不入首屏 chunk)+ recognizeOnceAsync 自动 EOS + IPA phoneme set + pronunciation_attempts 表落库
  • 四处入口:WordPopup mic toggle / SelectionToolbar Practice / ReviewCardFace 练读 / content-script emit_pronunciation_request → RightPanel slide-in
  • UI 设计(调研后):行内三档着色(绿/琥珀,无红色)+ 简化分数(Overall + Accuracy + Fluency,Completeness / Prosody 进 tooltip)+ 听-录互斥(同款 48px 圆按钮)+ Eye toggle 折叠 weak-word 列表 + Help Popover 解释 errorType(Mispronunciation / Omission / Insertion)
  • macOS 权限Info.plist + build.rsNSMicrophoneUsageDescription 嵌入 dev binary 的 __TEXT,__info_plist section(否则 dev 模式 TCC 不弹权限);open_system_microphone_settings Rust 命令直接深链跳到系统设置面板
  • 详见 docs/plans/archive/azure-tts-pronunciation-plan.md

三层回填机制(2026-05-14 v9 + v10 + L3 三段完成)

  • L1 本地 vocabulary 表:12,291 词,与 RVH byte-equal 共享 assets/reading_vocab.db(红线 #10 byte-equal 不变式;v10 切换),命中 < 20ms
  • L2 PostgREST 直查/rest/v1/vocabulary(Supabase 缓冲池),命中 < 2s;5xx 上抛 surface 故障
  • L3 Edge Function 兜底/functions/v1/lookup-or-fetch-word(Free Dictionary API + 自动晋升缓冲池),命中 < 20s;全 graceful(timeout / 网络 / parse / 200+null → log::warn + found:false,对齐 RVH client v46+)
  • v9 migration:vocabulary +cefr_inferred / +cefr_source / +word_tags(RVH v48-v50);notebook_entries word FK CASCADE → RESTRICT(RVH v51)
  • v10 migration:预装源从 vocabulary_seed.sql (4,953 行) 切到 reading_vocab.db (12,291 行,与 RVH byte-equal);首次引入 Tauri Resource 资产加载机制;helpers.rs ATTACH + INSERT 重灌;红线 #10 落地(assets/reading_vocab.db 不可 RB 端就地修改)
  • BackfillResult shape 不变:content-script.js / sync/pull.rs 零改动自动受益
  • Supabase vocabulary 角色澄清:从"权威源"降级为"预装库外缓冲池"(每次发版清空);权威源 = byte-equal 预装库
  • 详见 docs/cross-end/02-rvh-v51-sync-log.md

封面图字节直传重构(2026-05-02 至 2026-05-03)

  • schema 改造(2026-05-02):删 cover_image_path TEXT URL(跨端语义分裂:RB URL / RVH 本地路径,对端永远不可达);加 cover_image_data BLOB + cover_image_mime + cover_source_url(migration v7 表重建)
  • 建笔记不再写占位 URLauto_create_note_for_domainhttps://google.com/s2/favicons 占位,建笔记时三字段全 NULL,等首次访问时由 content-script 上报真实字节
  • sync 字段扩展:push / pull reading_notes 补封面三字段(PostgREST bytea 走原生 \x hex transit)
  • 前端 data URL 直渲:新增 src/lib/coverImage.ts::coverDataUrl(),组件用 data URL 直渲避免 createObjectURL 生命周期管理
  • 2026-05-03 网络层修订:从 Rust reqwest 移回 content-script + canvas(reqwest 不读 macOS 系统代理,国内 dev 环境跨平台代理探测 / TLS UA 兼容多块复杂度)
  • content-script reportSiteIcon:async fetch → canvas resize 192×192 → JPEG q85 → invoke 传字节;DDG 兜底;Rust 新 save_note_cover 仅做 SQL UPDATE
  • 删除update_note_cover_from_site / fetch_and_compress_icon / proxy.rs 整模块 / image crate / og:image selector(hero 图非 logo,画风不一致)
  • 跟进事项:(1) Supabase Dashboard 手动 ALTER user_reading_notes 删 cover_image_path 加 3 列;(2) RVH Dart 端镜像(CLAUDE.md §9 会话隔离)
  • 详见 docs/plans/archive/cover-image-blob-refactor-plan.md + docs/plans/archive/cover-image-webview-refactor-plan.md

vocabulary 表 word 主键化(2026-04-27 ~ 2026-04-28,Phase 1 + Phase 2)

  • schema 重构:vocabulary 表 PK 由 UUID id 改为 word TEXT PRIMARY KEY COLLATE NOCASE,删除 id 列;notebook_entries / excluded_words / reference_words 删除 vocabulary_id 列改用 word 作 FK
  • 本地约束notebook_entries 新增 UNIQUE(user_id, word COLLATE NOCASE),与 Supabase v43 一致;双端独立 INSERT 同 word 在本地写入即阻冲突,sync 协议无需特殊处理
  • Supabase v43 migration(RVH 主导部署,2026-04-26):公共 vocabulary PK 同步改 word;user_notebook_entries.vocabulary_id → word + UNIQUE(user_id, word)user_excluded_words 删除 vocabulary_id;Edge Function lookup-or-fetch-word 切换至新 vocabulary 表 + ON CONFLICT(word) DO NOTHING
  • 跨端 sync 路径:原 UUID 映射改为 word 直接映射,eliminates UUID split-brain silent drop;pull_notebook_entries 重写为 normalize → backfill_word → log::warn! + skip 三段式
  • production lemmatizer 入口全切 normalize(含 NFC):与 RVH Dart 端 byte-equal(Phase 0 跑分回归通过);新增技术红线 #9(写入 word 字段前必经 normalize
  • 改动量:54 处 Rust callsite + 4 处 TS 类型 + 4 张本地表 + 5 份文档同步到 v43 终态
  • Phase 2 治标修复pull_*synced_at 设为 row.updated_at + mark_syncedCOALESCE,解决 RVH 端 timezone-naive timestamp 引发的 dirty 死循环(RVH 端 toUtc 修复并行落地)
  • 详见 docs/plans/archive/vocabulary-word-pk-phase1.md + docs/plans/archive/vocabulary-word-pk-phase2.md + docs/cross-end/01-vocabulary-word-pk-log.md

强制登录 SPA 门(2026-04-21)

  • 架构App.tsx 由 487 行的"主壳"瘦身为 39 行的薄壳门控(loading / AuthGate / MainApp 三分支);原内容整体迁入新 src/MainApp.tsx。这样未登录态下主 UI 的 ~15 个 useEffect(事件监听、refreshRemoteData 等)完全不挂载——既满足 React Rules of Hooks(无条件调用),也避免向 Tauri / 网络发送未授权请求。
  • 新增 AuthGate.tsx:全屏居中卡片 + app 名 + 一行定位文案 + 内嵌现有 AuthPanel。AuthPanel 本体不改(session=null 时只走未登录分支,Settings 内挂载点仍可显示已登录态)。
  • useAuthStore.isLoading 初值 false → true:避免首次渲染瞬间被 SPA 门误判为"未登录"。initialize() 翻成 false 后才触发分支判定。
  • 首次登录清空游客数据auth.rs::clear_learning_data_if_user_changed 扩展覆盖"old_user_id=None"场景。WHERE 由 IS NULL / != ?1 合并为 user_id IS NULL OR user_id != ?1。涉及 5 张表(word_sources / reading_sources / reading_notes / notebook_entries / excluded_words);reference_words 故意不清(NULL 行是系统预置 seed,全用户共享);同时 DELETE FROM settings WHERE key = 'last_sync_at' 触发下一次全量 pull。
  • 不在范围(刻意留出):后端 require_session() 命令守卫(SPA 门下主 UI 不可达,T1 长尾);写入端给本地行补 user_id(push.rs 已在 push 时注入);Supabase 配置页 / BYOK LLM key(独立议题)。
  • 回归守护:arch-check(基线无新增违规)+ tsc --noEmit + cargo check + pnpm build 全过。
  • 详见 docs/plans/archive/force-login-plan.md

伴随修复 / 补坑

  • SPA 门屏蔽 migrations 触发点:前端 Database.load('sqlite:readbrowser.db') 是 tauri-plugin-sql 迁移的唯一触发,AuthGate 从不调前端 DB 导致 fresh-install 后 settings 表不存在、get_supabase_configNotConfigured。修复:commands.ts 导出 ensureDbMigrated()App.tsx useEffect 中 await ensureDbMigrated() → await initialize()
  • init_data.sql 补 Supabase 连接 seed:原来以为已预置,实际没有。追加 supabase_url + supabase_anon_key 两条 INSERT OR IGNORE(CLAUDE.md §5 备注:anon_key 公开 by design,分发前需改构建期注入避免项目身份跟随 fork 流出)。
  • sync 运行期 token 续命:原 useAuthStore.initialize() 只在启动时 refresh,长驻的 60s auto-sync 在 access_token 过期(1 小时)后会 401 静默失败。新增 auth.rs::refresh_if_expiring(&DbConn, buffer_secs)sync_now 每轮开头调用(buffer=5min),主动换新 token;失败仅 warn 不中断(下个周期再试,下次 app 启动会回到 AuthGate)。
  • scripts/reset-dev-data.sh 大改:移除死代码(temp/*.db 扫描不到真 DB),补 library/epub/ 清理,新增 --remote 模式(调用 service_role key 清 6 张 Supabase 同步表 + reading-snapshots Storage bucket,二次确认 + env 变量隔离);SSL 问题改用 curl + python3 只做 JSON 解析规避 macOS Python urllib CERTIFICATE_VERIFY_FAILED。

Supabase Storage 快照同步 — Q3 阶段 A(2026-04-21)

  • 目标:桌面侧抽取的文章 HTML 快照跨端搬运,让 RVH 复习时能看到单词当初出现的段落(阶段 A:Web 快照;EPUB 章节切片依赖 Q2 anchor URL 已顺带覆盖)。
  • Supabase 侧:新建私有 bucket reading-snapshots(5 MB / text-html 限制)+ 4 条 storage.objects RLS policy(split_part(name,'/',1) = auth.uid()::text 路径前缀隔离);user_reading_sources 增加 storage_path TEXT。DDL 脚本 supabase/sql/storage-setup.sql(幂等,DROP POLICY IF EXISTS 保护)。
  • 本地 migration v6reading_sources.storage_path TEXTschema.sql baseline 同步。
  • sync/push.rs::upload_snapshots:遍历 cached_file_path IS NOT NULL AND storage_path IS NULL 的最多 20 行/批,POST {supabase}/storage/v1/object/reading-snapshots/{user_id}/{rel} + x-upsert: true,成功后 UPDATE 本地 storage_path 并 bump updated_at,令同周期内 push_reading_sources 能带上新字段;失败只 log::warn! 下轮重试(best-effort)。批量与 push_reading_sources 的 LIMIT 100 之间会在首次大批量场景产生自愈型二轮,接受。
  • Push/Pull payload 扩展push_reading_sources SELECT 加 storage_pathpull_reading_sources UPSERT 用 storage_path = COALESCE(excluded.storage_path, reading_sources.storage_path) 防覆盖本地已回填值。sync_now 签名加 AppHandle 以解析 library_dir
  • 验证:登录 → 保存词 → 60s 自动同步后 Dashboard Storage → reading-snapshots{uid}/web/*.html 可见;user_reading_sources.storage_path 非空。
  • RVH 端待办:消费 storage_path(download → 本地 LRU)+ 对齐 Dart schema,另起 RVH 会话处理,登记在 docs/09-双端整合实施计划.md 阶段 3。
  • 详见 docs/plans/archive/epub-context-sync-plan.md

reference_words 回归纯系统共享(2026-04-21)

  • 背景reference_words 原定位自相矛盾——设计为系统共享预装 ~199 条专有名词/缩写,却有 user_id 列 + synced_at 列 + 进入 Supabase 同步。push 时 user_id 永远为 NULL 被 RLS 拒绝,Supabase 侧 user_reference_words 长期空表。
  • 本地 migration v7CREATE TABLE reference_words_newINSERT SELECTDROP TABLERENAME 标准 SQLite 列删除模式,去掉 user_id / synced_at,保留 deleted_at(允许用户软删除自定义词)。重建 idx_rw_word (COLLATE NOCASE) / idx_rw_category 索引;schema.sql baseline 同步。
  • sync 三处删除sync/push.rs::push_reference_words(41 行)/ sync/pull.rs::pull_reference_words + struct RemoteReferenceWord(99 行)/ sync/mod.rs push+pull 调用(10 行)全部移除。同步表从 6 张降为 5 张(notebook_entries / reading_notes / reading_sources / word_sources / excluded_words)。
  • Supabase DDL 文档supabase/sql/sync-tables.sql 删除 user_reference_words 表定义 + RLS policy + 索引,头部加废弃说明。用户需在 Dashboard 执行 DROP TABLE IF EXISTS user_reference_words CASCADE
  • auth.rs 注释更新clear_learning_data_if_user_changed 从"NULL 行是系统预置 seed"改为"纯系统共享表(无 user_id 列),所有用户共用同一份预装词"。
  • RVH 端待办:移除对 user_reference_words 的 Dart push/pull 实现 + 本地 schema 对齐(另起 RVH 会话)。
  • 详见 docs/plans/archive/account-data-binding-plan.md 阶段 B。

EPUB 阅读闭环(2026-04-20 至 2026-04-21)

  • Q1 章节导航修复EpubNavBar.navigate() 从 spine 粒度改为 TOC 粒度,解决 Gutenberg "多章一个 xhtml" 包装下 Next/Prev 跳若干章的 bug。handleTocNavigate 区分同/跨 xhtml:同文件用 evalInWebview(scrollIntoView) 不刷新 webview,跨文件走 navigateTo
  • Q2 单词语境粒度__RB_EPUB_META 新增 tocHref / tocLabel / nextTocAnchoreffectiveSourceUrl() 优先用 TOC 锚点,旧的 #ch<spineIdx> 形式作为兼容回退。同一 spine 内的不同章保存的词从此有独立的 source_url。
  • source_type 写入端规范化notes.rs 新增 classify_source_type()ensure_source_for_url() 不再硬编码 'web'——按 URL 形态分类为 web / book / pdfSourceRef 增加 source_type 字段;ReviewSession.handleSourceSwitchApp.tsx review 路由统一改为 cached_file_path → book/pdf → web empty 决策树,去掉 url.startsWith('/') 启发式。
  • 快照切片质量extractArticleContent 检测到 __RB_EPUB_META.tocHref 时调用 extractEpubChapter(),用 DOM Range 在 [currentAnchor, nextAnchor) 之间切片,避开"多章合一"导致整本文件被存进单个章节快照。所有候选/clone 路径新增 stripRbNoise / isRbNoise 排除 rb-popup-* / rb-toolbar / rb-reader-overlay 等 content-script 注入的 DOM——修复 popup 打开时被误判为文章主体写入快照的 bug。
  • EPUB panic 修复commands/epub.rs:343 find_closing_tagpos += 1 按字节推进碰到多字节 UTF-8(如 ')会跨 char 边界 panic(aborting,整进程挂掉)。改为按 char.len_utf8() 推进。
  • migration v5:一次性回填历史 reading_sources.source_type='web'source_ref 形如 /path.epub#… / /path.pdf 的行 → book / pdf
  • 回归守护:变更触发的 arch-check / ui-check / build-check / code-review 全过;本次未引入新软警告。

UI 深化(S1 → S6.5,2026-04-18 至 2026-04-19)

  • 底层工具链:引入 lucide-react(图标,60 处调用)+ class-variance-authority + tailwind-merge + clsx(cva 类型安全 + className 冲突消除)+ sonner(toast)+ @radix-ui/react-{dialog,popover,tooltip}(Radix primitives 档位 1)
  • 新业务 molecule 组件(全部 src/components/ui/):
    • ListItem(13 调用,4 variant × 3 density × cva × slot 化:simple/double/rich/tree)
    • Tile(4 调用,2-col 网格瓷砖 + 右缘 action slot)
    • SectionHeader(5 调用,uppercase caption + optional link action)
    • EmptyState(16 调用,icon + primary + secondary + action slot)
    • Divider(2 调用)
    • Icon(60 调用,lucide 强制走 ICON_SIZE scale)
    • SiteFavicon(两 size:sm=16px / lg=28×28 wrapped,统一 duckduckgo + 首字母 fallback)
    • TabButtonsrc/components/ 迁入 ui/
  • 刚性 API 设计:ListItem 删除 titleClassName / subtitleClassName 逃生舱,改用 avatar slot(rich-only 28×28)+ titleLines: 1|2 + tone: 'default'|'muted';每 variant 锁死 typography
  • 规范规则固化:docs/ui-standards.md §13.2 新增"实体 → variant 强制映射表"(13 类业务实体登记),§13.5 Tile 与 ListItem 边界表
  • Radix Primitives 档位 1ui/Modal / Popover / Tooltip 重写为 Radix wrapper,删除 3 个自研 hook(useFocusTrap / useEscapeKey / useClickOutside 共 119 LOC)
  • Toast 反馈:3 处 mutation 启用 toast(剪贴板复制 / 删词典 / 清缓存),三档反馈模型(toast / inline / field)确立
  • 回归守护双层机制
    • arch-check v1.2 → v1.5.2:新增 S13(z-scale)/S14(rounded-scale)/S15(text-arbitrary)/S16(shadow-arbitrary)/S17(散落 svg)/S18(Panel 未走 ListItem)/H6(硬封 titleClassName 复活)
    • 新建 /ui-check skill v1.0.2:U1(列表走 ListItem)/U2(空态走 EmptyState)/U3(hover:bg-hover 一致性)/U4(ListItem ReactNode 密度)+ 基线追踪表
    • CLAUDE.md §8 开发工作流插入 /ui-check 步骤(五 skill 链)
  • ui/ 清理:删除 0 调用的 Card + SectionCard(语义被 ListItem rich + Settings 独立 row 吸收)
  • 视觉修正
    • rich variant bg: bg-surfacebg-surface-raised(卡片在 bg-surface wrapper 上自然抬升,解决"子父同色漏底")
    • U3 hover 一致性:9 处 hover:bg-surface-secondary 及其 /70 /80 alpha 变体全部统一到 hover:bg-hover
    • NotesPanel 笔记本列表对齐 ReviewOverview 视觉(rich + avatar + subtitle + chevron,删除按钮挪到详情视图)
  • 文档:新增 docs/ui-component-inventory.md(组件使用全景,10 节),docs/ui-standards.md 升 v1.2(Sprint G-H)
  • Bundle:420.77 KB → 545 KB / 158 KB gzip(全 UI 深化阶段总增量 +~120 KB raw,含 sonner + Radix + cva)

工程化

  • 新增 CLAUDE.md 项目控制中心(12 个章节)
  • 新增 5 个 Claude Code Skills:/arch-check/build-check/code-review/doc-sync-check/ui-check
  • 新增 CHANGELOG.md 版本追踪

文档

  • 新增 docs/product.md — 产品概览(定位、设计原则、功能清单、用户画像)
  • 新增 docs/architecture.md — 系统架构(9 章节,含架构图、模块分组、IPC、Auth/Sync 数据流)
  • 新增 docs/database-schema.md — 数据库设计(19 表完整列定义、索引策略、Supabase 映射)
  • 新增 docs/database-tables-overview.md — 数据库表速览(19 表分 5 类、关系图、数据流全景)
  • 新增 docs/ui-standards.md v1.2 + docs/ui-component-inventory.md(UI 规范 + 组件使用全景)
  • 整合 Supabase 表完整列定义到 database-schema.md §9(不再需要单独查 SQL 文件)
  • 扩展 CLAUDE.md §9 双端开发协议(会话隔离、Schema 同步、SM-2 一致性、变更通知)
  • 归档 8 份过时规划文档到 docs/archive/

Phase 5: 三端 Schema 对齐 — 2026-04-12

Added

  • reference_words 表 — ~199 条系统预装(国家/城市/组织/缩写/品牌),CEFR 高亮/发现模式过滤
  • reference_words.rs — 7 个 Tauri 命令(add/remove/get_list/get_set/is/batch_remove)
  • reference_words 同步 — sync.rs push/pull(第 7 张同步表)
  • recommended_feeds 本地表 — 推荐 RSS 源独立表(原存 settings JSON),预装 8 条
  • Supabase user_reference_words 表 — DDL + RLS
  • docs/database-tables-overview.md — 19 表分类速览文档
  • src-tauri/assets/sql/schema.sql — 数据库 schema 快照
  • src-tauri/assets/sql/init_data.sql — 初始化数据(settings + 33 sites + 211 stopwords + 8 feeds)
  • src-tauri/assets/sql/seed_reference_words.sql — reference_words 种子数据

Changed

  • migrations.rs 快照重构 — 973 行 35 次迁移 → 30 行 3 次迁移(v1 schema + v2 init + v3 ref_words)
  • 翻译历史改用 page_annotationsget_translation_history() 从查 cache 改为查 page_annotations JOIN reading_sources,历史记录自带 source_url
  • 查词优先级调整 — vocabulary(结构化)优先,external_dictionaries(MDict)补充
  • Supabase vocabulary_items → vocabulary — 表名三端统一
  • nav_sitesrecommended_sites — 表名与 Supabase 统一
  • recommended_articles_cacherecommended_articles — 表名统一
  • user_dictionaryexternal_dictionaries — 名称匹配其定位
  • sites.site_type DEFAULT 'web''user' — 语义修正
  • 同步表字段对齐 — 6 张表加 user_id 等冗余列(三端一致)
  • storage.rs — dictionary 计数改为 vocabulary 计数
  • vocabulary.rs 3 个查询 + content-script.js 高亮/发现 — 加 reference_words 过滤
  • Supabase DDL — 新增 user_reference_words + 字段补齐 ALTER

Removed

  • translation_cache 表 — 翻译 API 缓存(改用 page_annotations 永久记录)
  • analysis_cache 表 — 分析 API 缓存(同上)
  • clear_translation_cache / clear_analysis_cache 命令 — 不再需要
  • Settings 面板 2 个 "Clear cache" 按钮
  • RECOMMENDED_FEEDS 硬编码常量(数据迁移到 recommended_feeds 表)
  • vocabulary 冗余索引 idx_vocab_word(UNIQUE 约束已隐式创建)
  • reading_log 遗留表(从 schema 快照中移除)

Phase 4: UI Architecture Overhaul — 2026-04-11

Added

  • AddressBar 新增 4 个入口按钮:☆收藏 / 🕐历史 / ⚙设置 / 👤账户
  • 左侧面板 4 种类型:discovery / translation / settings / library
  • Account Popover(替代独立面板入口)
  • 新 Rust commands 模块:annotations / excluded_words / storage
  • migration v27-v30:page_annotations / excluded_words / 相关索引

Changed

  • 分隔线视觉策略:实线 → 色差 / 阴影 / 极淡线(24 个组件统一)
  • 面板布局重构:Settings / Library 移到左侧面板(原右侧);Account 改为 Popover
  • 右侧面板精简为纯学习输出:Review / Vocab / Notes
  • HomePage 首页重设计:问候语 + 横向历史卡片 + 两列推荐网格 + Sites 列表化
  • 紧接 Phase 4.5 进一步首页 IA 重设计(同 commit 群组)

Removed

  • Account 独立面板(合并到 Popover)

Phase 4.5: 首页信息架构重设计 — 2026-04-11

Added

  • sites 表重建为 domain 级别(migration v31)— domain, name, url, icon_url, site_type, created_at
  • nav_sites 新增 has_rss + rss_url 列(migration v31)
  • 清理迁移 v32:移除无效站点记录
  • MySitesPanel 组件 — 我的收藏站点管理
  • RssPanel 组件 — RSS 订阅管理(drill-down:feed 管理 → 文章列表)
  • src/lib/constants.ts — 推荐 feeds + 分类排序常量
  • src/lib/rssHelpers.ts — RSS 订阅辅助函数
  • HomePage MY SITES 区域(My Favorites + Recommended Sites 两列卡片)
  • HomePage RSS FEEDS 区域(My Feeds + Recommended Feeds 两列卡片)

Changed

  • sites.rs 重写为 domain 级别操作
  • AddressBar 收藏按钮改为 domain 级别站点收藏
  • usePanelStore 面板类型:'library' → 'mysites' | 'history' | 'rss'
  • HomePage:Recently Opened / Recommended For You 改为紧凑列表
  • HistoryPanel 改为嵌入式独立面板

Removed

  • LibraryPanel.tsx(拆分为 MySitesPanel + HistoryPanel + RssPanel)
  • 旧折叠式 Sites/RSS 区域 + RSS Discover

Phase 3: UI Polish — 2026-03-29

Added

  • resource_history 表(migration v21)— 统一的最近打开资源追踪
  • TocPanel 组件 — PDF/EPUB 目录侧边栏
  • SelectionToolbar 组件 — 浮动文本选择工具栏
  • WordPopup 组件 — 独立查词弹窗(从 PdfPage/EpubPage 提取)
  • url.ts / polyfill-streams.ts 工具库

Changed

  • EpubPage + PdfPage 重构为共享组件模式
  • HistoryPanel 整合 resource history
  • Toolbar: "Open PDF" → "Open PDF/EPUB"

双端整合 阶段 2-B: 5 表同步 + Soft-Delete — 2026-03-28

Added

  • 2-Button 复习系统:Hard (amber) / Easy (emerald) 替代旧 3 按钮
  • last_review_quality 列(migration v20)— 序列推导上下文
  • Soft-delete:deleted_at 列(migration v22)
  • save_word 自动检测并重新激活 soft-deleted 条目
  • Push/Pull 支持 soft-delete 传播

Fixed

  • Pull merge 增加 mastery_level 比较(之前只比较 repetitions/next_review_date)
  • FK 约束修复:pull word_sources/contexts 前检查父记录存在性
  • 用户切换检测:logout 时保留 auth_user_id

双端整合 阶段 2-A: Auth — 2026-03-28

Added

  • Supabase Auth(signup/signin/signout/refresh_token/get_session)
  • Token 持久化到 settings 表
  • 用户切换数据清理逻辑
  • AuthPanel 登录注册 + 同步状态 UI

双端整合 阶段 1: 阅读笔记 + Supabase 回填 — 2026-03-28

Added

  • reading_notes / reading_sources
  • EPUB 阅读器(epubjs 集成)
  • Supabase 同步表 DDL + RLS

双端整合 阶段 0: 数据模型统一 — 2026-03-27

Changed

  • 破坏性重建:DROP 旧表建新表,统一 RB + RVH 数据模型(migration v17)
  • 表名统一:vocabulary, notebook_entries, reading_notes, reading_sources, word_sources, word_contexts
  • 嵌入 4,953 条 RVH 预装词汇(替代 927K Kaikki 词典方案)
  • 添加 synced_at 列(migration v19)

Phase 2 Sprints 5-10: 体验增强 — 2026-03-23

Added

  • 多标签浏览(Multi-tab:content-{id} webviews + offscreen 切换)
  • PDF 阅读器(pdfjs-dist 集成)
  • 双语翻译模式(DeepL/DeepSeek/OpenAI)
  • CEFR 词汇高亮(A1-C2 颜色编码)
  • 阅读统计面板
  • RSS 订阅支持

Phase 1 Sprints 1-4: MVP — 2026-03-17

Added

  • Tauri 2.0 + React 19 + TypeScript + Vite 8 基础框架
  • Multi-webview 架构(main + content-{id})
  • 10 张数据库表 + 24 个预设英文网站
  • Content-script 注入:双击查词 + 弹窗 + TTS + 上下文记录
  • Dictionary 查询(rusqlite 0.31 + libsqlite3-sys 0.28 共享)
  • Vocabulary 管理(保存/查询/分页/CEFR 过滤)
  • SRS 复习(SM-2 算法)
  • Reader 模式
  • 设置面板(翻译 API 配置、主题)
  • CSV 导入/导出