Repository navigation
fix(i18n): stop the Review Queue tab claiming a 180-day window its filter never applied (#769) - #776
Conversation
…lter never applied (#769) All four locale packs named `crm_knowledge_article.stale_articles` "Stale (>180d)" / 过期 (>180 天) / "Obsoletos (>180d)" / 古い (>180日) over a filter reading only `status = published`. The tab therefore returned EVERY published article — one reviewed minutes ago included, merely sorted last — under a heading promising a six-month cut. Same defect class as #730, and as there the lie lived only in the translated half: the metadata label ("Review Queue · Oldest First") promised an ordering throughout, and the `last_reviewed_at asc` sort delivers it. The window was measured before being ruled out, not assumed impossible. On the pinned 17.0.0-rc.2 `{180_days_ago}` resolves on the read path and lands on the START of the calendar day 180 days back, so `less_than` excludes that whole day — the #3777 day-boundary convention, and the right sense for "> 180d". But `$lt` matches neither null nor absent values, so the window deletes never-reviewed articles, which are a review queue's most overdue population. The honest condition is a DISJUNCTION ("older than the window OR never reviewed"), and a view `filter` is a flat, strict array of {field, operator, value} rules combined with AND: no `or`, no nesting, no logic key. Spelled the only way the grammar allows, the two rules AND together and the tab returns zero rows. The engine itself answers correctly with `$or` — the limit is the view-authoring grammar, not the data layer. So the fix is the name, not the filter. Which rows the tab returns is unchanged, and no emptyState is authored because no new empty state exists (the #746 reason does not apply here). The house-rule guard grew a second claim family. It read only current-calendar-period phrases and so was blind to "> 180d": run against these four labels verbatim it passes 17/17. It now also reads parameterised day windows in all four locales, and carries a positive control — the deleted labels, fed through the shipped filter, must be reported as a breach — so the family cannot pass vacuously now that its only instances are gone. A new runtime block pins every measurement above against a real engine. Fixes #769 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHrPAGEgFDoHjphqYG4BMa
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
…article unreviewed (#769) The note added in the previous commit said `last_reviewed_at` is nullable without saying how a published article reaches that state, and the measurement behind it was taken on a bare `ObjectQL.create()` — which does NOT register the engine's `sys_fetch_previous_update` builtin. Re-measured with a faithful replica of that builtin bound (priority 5, beforeUpdate, object '*'), the first reading was wrong in one direction and right in another: single-row update prev-fetch sets previous, hook sees previous.status "published" and re-stamps — a null write is overwritten multi:true update input.id is undefined, so the builtin fetches nothing, the hook sees no status and stamps nothing — a bulk write puts null straight onto published rows, and a bulk edit never marks them reviewed So the never-reviewed population is not produced by ordinary editing; it is produced by bulk loads and mass edits — which is how an org imports an existing knowledge base. That makes the window's loss worse than "rare and pathological", not better: the rows it would hide are the freshly imported ones, which no human has ever reviewed. Comment only. The runtime block's assertions are about `$lt`, null and the filter grammar, none of which depend on hooks, so they are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHrPAGEgFDoHjphqYG4BMa
越界发现(均已另立 issue,未折入本 PR)按 Prime Directive #10,实测过程中撞到的三条与 #769 无关的问题,先去重再单独立单,不带 label、不 assign,留给 PM triage:
三条都先做了关键词 + 文件路径检索去重( Generated by Claude Code |
Fixes #769
四个语言包把
crm_knowledge_article.stale_articles译成Stale (>180d)/过期 (>180 天)/Obsoletos (>180d)/古い (>180日),而 filter 只有status = published—— 这个页签实际返回每一篇已发布文章(包括几分钟前刚复核过的,只是排在最后)。与 #730 同缺陷类,谎言同样只活在翻译那一半:canonical metadata label 是Review Queue · Oldest First,自始至终只承诺排序,而last_reviewed_at asc确实兑现了这个排序。裁定:走退路(翻译回归 canonical),依据是实测而非偏好
派单预授权了两个分支,并给了明确的切换判据:「若 filter 层无法无损表达『早于 {180_days_ago} 或为空』,这就是切换退路的判据,不许把空值行悄悄藏掉。」这个判据实测成立,所以本 PR 改的是名字,不是 filter。
四条实测(全部在 pinned 17.0.0-rc.2 +
InMemoryDriver+ 真ObjectQL.find上跑,last_reviewed_at声明为datetime):1. 宏解析,且日界落在 180 天前那一天的开始
用字面量二分定位边界(fixtures 分别在 179d / 180d 的 00:00、11:59、23:59 / 181d / 400d 复核):
即
{180_days_ago}解析为「180 天前那个日历日的 00:00:00.000Z」。配less_than(排他上界)整天被排除 —— 正是 #3777 的日界惯例,也正是> 180d该有的语义。三态(窗口内 / 窗口外 / 边界日)已钉进测试。2. 空值在比较运算下不命中,而空值行由 BULK 写入路径产生(这一条我先测错了一次,如实记录)
引擎侧的事实无争议:
显式
null与键缺失两种写法都掉出窗口。有争议的是「已发布文章能不能真的没有复核时间戳」。第一次实测用的是裸ObjectQL.create(),它并不注册引擎内建的sys_fetch_previous_update(那个由 kernel 服务的registerAuditHooks()装上),于是ctx.previous恒为 undefined,我据此得出「一条普通 update 就能写回 null」—— 这个结论是错的,已作废。补上该内建的忠实复刻(priority 5 / beforeUpdate / object*)后重测:单行路径上 hook 正常补戳,null 写不进去;
multi: true路径上没有input.id,内建取不到 previous,hook 看不到 status、什么也不戳 —— 于是批量写把 null 直接落到已发布行上,批量编辑也不会把文章标记为已复核。修正后的结论比原先更硬,不是更软:空值行不是「畸形操作产生的理论行」,而是批量导入/批量编辑产生的行 —— 而批量导入正是一家公司把既有知识库搬进 CRM 的常规方式。最后两行是最锋利的陈述:加窗之后,这批从来没有人复核过的导入文章恰好一条都不显示。(该 bulk 路径缺陷已另立 issue,不在本 PR 修。)
3. 诚实的条件是析取,而 view filter 语法写不出析取
ViewFilterRuleSchema是{field, operator, value}的扁平严格数组,规则之间 AND;没有or,没有嵌套,没有 logic key。用语法允许的唯一写法拼出来,两条规则在同一字段上 AND,结果是零行:一个空白页签和「没有文章需要复核」在界面上完全一样 —— 这正是不能悄悄采用的形态。
4. 引擎本身能答对,所以这是 view 授权语法的限制,不是数据层的限制
这一条特意写成正面陈述并钉进测试,免得后来人把上面第 3 条误读成「引擎做不到」。
另一条独立理由:漂移发生在下游
#730 / #743 两个先例里,时间口径写在 metadata label 本身(
This Quarter、Closing This Quarter)—— 契约自己作了承诺,所以必须由 filter 兑现。本条不同:canonical label 不含任何窗口承诺,> 180d只存在于四个语言包。按 contract-first,漂移在翻译这半边,就在这半边修;让下游译文去反向重塑上游行为是反的。不补
emptyState:本 PR 不引入新的空态(「没有已发布文章」是这个页签一直就有的零),#746 补空态的理由(加窗使「空」成为新可达状态)在这里不成立。改了什么
src/translations/{en,zh-CN,es-ES,ja-JP}.tsstale_articleslabel 回归 canonical 的忠实翻译src/views/knowledge_article.view.tsstale — published > 180 days without review同样是假声明,一并订正test/forecast-current-quarter-view.test.ts.changeset/…行为不变:页签返回的行与顺序与 main 完全一致。
反向验证(两个方向,均先声明预期再跑)
方向一 —— 预期 RED。 把删掉的四个
>180dlabel 原样装回(filter 保持不加窗),新家族必须变红,且只红两条:every current-period label is backed by a filter that pins that period与no name this stack actually ships claims a day window;四条字面量/自带标签的探针必须保持绿,因为它们不读 stack。实跑:预期与实测一致。
方向二 —— 预期 GREEN,证明词表扩展是承重的。 同样装回四个 label,但把测试文件换回
origin/main版本(即 #769 之前的守卫)。若旧词表本就能抓,这次扩展就是装饰:旧守卫在这四个 label 上 17/17 全绿 —— 只认「当前日历周期」短语的词表看不见
> 180d,这也是为什么 #769 只能靠人眼发现。扩展是承重的。正控在文件里长期保留:
re-installing those labels over the shipped filter is reported as a breach把删掉的四个 label 喂给stale_articles实际发布的 filter,断言守卫必须报一条 breach —— 两个家族都是靠删除其唯一实例来修的,没有这条正控,词表会在实例清零后长期空转而无人察觉。验证
改动文件控制字符自扫
grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f]'零命中。越界发现(已另立 issue,未折入本 PR)
见下方评论。