Skip to content

spec: $regex 按 ADR-0049 退役 + $icontains 入算子词表与 FILTER_LOGIC_CASES(#4706 裁决 B 案 · 契约半边,先行) #5701

Closed

Description

@os-zhuang

Part of #4706(维护者 2026-08-06 裁决 B 案,裁决与三轴分析见该单 2026-08-06 两条评论)。契约半边,先行;驱动半边 Blocked-by 本单。

裁定摘录

$regex 退役:查询校验期响亮拒收,拒收信息点名替代物($icontains);同批新增 $icontains(大小写不敏感包含;$istartsWith / $iendsWith 仅当与既有算子族对称性成立时一并加,不强求)。排除项:五后端真正则(turso remote 线协议无法注册 REGEXP,结构性不可行)、维持现状(名实不符)。

本单范围(packages/spec)

  1. 词表:filter 算子 zod schema / 类型中移除 $regex(含 $options,它只被 $regex 消费 — 实施时核实),新增 $icontains;按 ADR-0049 / ADR-0087 退役路线处理注册表与转换记录(适用面按 spec-property-retirement playbook 判断 — 查询算子词表若不属 authorable-surface 轨道,如实记录即可,不硬套)。
  2. case-set:FILTER_LOGIC_CASES 新增 $icontains 用例 — 至少覆盖:双向大小写折叠命中、% / _ / 正则元字符作字面量处理(转义纪律沿 applyLike,见 driver-sql 的 LIKE 转义(自标 P0 的过滤器旁路)只在 SQLite 上验证过 —— live PG + MySQL 矩阵已存在、testkit 也已存在,LIKE 族一个用例都没接进去 #5589 的方言矩阵)、空串/非字符串比较值的拒收形态(循 ADR-0112 信封惯例);外加 $regex 的拒收 pin(校验期报错,信息含替代物指名)。case-set 落地即由 5×5 conformance 矩阵在各 driver 侧转成待实现红/DEBT — 这是设计内的推进机制。
  3. 全仓 $regex 使用面清点:examples / 测试 / 文档中现存用法随退役同 PR 翻转(裁决实施的 pin sweep 纪律:一轮翻完,翻后的断言仍承重)。

边界

归属:packages/spec → domain:spec 座位(shared contract surfaces have one owner);domain:* 标签待分诊座位补。

Activity

  1. added
    priority:p0Queue-jump: outranks batch and breaks the round — never exempts claiming or same-file serial
    domain:specDomain lane — seat card indexed by label:pm:seat
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 6, 2026
  2. self-assigned this
    on Aug 6, 2026
  3. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    PM 认领(spec 车道 PM,session_018fxLGQdatPbBUvCgiVxg6D,2026-08-06,维护者插队令,P0 第 6 席)


    Generated by Claude Code

  4. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    开发前置核验报告(os-dev,claude/issue-5701-regex-retire-icontains 工作树,基于 origin/main@eb26126d5)。结论:不提 PR,回报 needs_decision。

    理由分两层:词表半边的前提已失效(可直接销案),case-set 与拒收半边则撞上三处必须由维护者拍板的契约决定 —— 每一处我一旦动手写,就等于替契约做了决定。以下全部为实测,附 file:line。


    一、前提核验:packages/spec 里没有 $regex,也没有 $options

    本单第 1 点(「filter 算子 zod schema / 类型中移除 $regex(含 $options)」)在 origin/main 上无物可移。逐面核过:

    面 位置 $regex / $options
    FILTER_OPERATORS packages/spec/src/data/filter.zod.ts:957-968 15 个算子,无
    FieldOperatorsSchema filter.zod.ts:178-206 无
    StringOperatorSchema filter.zod.ts:141-153 无(仅注释里出现 MongoDB: $regex,是 mongo 侧译码说明)
    AST_OPERATOR_MAP / VALID_AST_OPERATORS filter.zod.ts:505-586 无
    VIEW_FILTER_OPERATORS / VIEW_FILTER_OPERATOR_ALIASES ui/view.zod.ts:173-214 无
    FilterConditionSchema filter.zod.ts:320-328 z.record(z.string(), z.unknown()) —— 不校验任何算子名

    唯一曾声明过 regex 算子的 spec 面是 api/websocket.zod.ts 的 FilterOperator 枚举,早已被 objectui#2945 删除,墓碑注释(websocket.zod.ts:56-78)还专门写了「将来长出真过滤应 lower 到 AST_OPERATOR_MAP,不要再自建词表」。

    $options 的测量(本单要求「实施时核实」):在 ObjectQL 词表意义上,它确实只作 $regex 的 flags 修饰符存在 —— objectql/having-filter.ts:110 直接 continue 跳过再喂给 $regex;driver-memory/filter-refusal.ts:421-435 专门为「有 $options 无 $regex」写了悬空错误。但同样地,它也不在 spec 里,所以一样无物可移。

    $regex 今天的真实落点是「一个生产者 + 四个消费者」,全在 spec 之外:

    • 生产者(唯一,全仓实测):packages/plugins/plugin-auth/src/objectql-adapter.ts:137 —— better-auth 的 contains 算子被译成 { $regex: condition.value }。
    • 消费者:driver-sql/src/sql-driver.ts:6511、driver-turso/src/remote-transport.ts:58,1258 + turso-driver.ts:689、driver-memory/src/filter-refusal.ts:171 + memory-matcher.ts:209、objectql/src/having-filter.ts:37,140。

    这四处消费者的代码注释已经写明它们是为那一个生产者而存在的。driver-memory/src/filter-refusal.ts:154-158 原话:

    $regex —— not in FILTER_OPERATORS, but really produced: plugin-auth's ObjectQL adapter emits { field: { $regex: value } } for a contains search. ... Refusing it here would break a live producer.

    这直接给出了一条裁决没点名的时序约束:任何「校验期响亮拒收 $regex」在 objectql-adapter.ts:137 翻转之前落地,都会打断 better-auth 的认证路径。 该文件既不属契约半边也不属驱动半边,是第三块,须先排。

    硬约束核验(PM 认领里点名的 conversions/registry.ts):不触发。 退役的不是 authorable key(spec 从未声明该键),ADR-0087 D2 没有对象可登记;且登记处里唯一与 filter 相关的条目是 flow-node-crud-filter-alias(conversions/registry.ts:142),它改写的是节点 config 上的 filters -> filter 键名,并不下潜进 filter body 改算子名 —— 存量 view/flow 里内嵌的 $regex 无法、也从未由登记处触及。本单不需要动 conversions/registry.ts,#4923 无冲突。


    二、实测:$icontains 的方言坑与 A 案的 turso 坑是同一种,只是可解

    裁决把 B 案排在 A 案之前的理由之一是「$icontains LIKE/ILIKE 可实现,无方言正则坑」。这句在大小写折叠的「域」上不成立,已实测。

    在本仓 better-sqlite3@13.0.2(SQLite 3.53.4)上跑 LOWER(col) LIKE LOWER(?)(即 $icontains 最自然的 SQL 下译),与 JS toLowerCase().includes()(即 driver-memory / formula 的下译)对照:

    探针 SQLite LOWER(col) LIKE LOWER(?) JS toLowerCase().includes()
    acme(ASCII) ["ascii-up","ascii-lo"] ["ascii-up","ascii-lo"]
    café ["accent-lo"] ["accent-up","accent-lo"]
    москва ["cyr-lo"] ["cyr-up","cyr-lo"]
    σιγμα ["gr-lo"] ["gr-up","gr-lo"]

    SQLite 的 LOWER() / LIKE 只折叠 ASCII(无 ICU 编译时),JS 折叠全 Unicode,mongo 的 $options: 'i' 亦全 Unicode,Postgres ILIKE 依 collation/ICU。也就是说:「同一条 $icontains 查询在五后端返回同一结果集」在非 ASCII 上结构性不成立,除非契约明确把折叠域限死。这与 turso remote 注册不了 REGEXP 是同一类不可行性 —— 区别在于它有解(把域声明为 ASCII),但那个解要付出可见的能力代价,不能由我在写 fixture 时默默选定。

    而 case-set 的 expected: [...] 正是这个选择本身。 我无法在不回答「折叠域是什么」的前提下写出本单第 2 点要求的「双向大小写折叠命中」用例。


    三、实测:$contains 家族今天已经在同一根轴上分叉,而且是 spec 声明放弃的

    后端 $contains 的大小写行为 证据
    driver-memory 大小写敏感(String.includes) memory-matcher.ts:182
    formula 大小写敏感 matches-filter.ts:182
    driver-sql / driver-sqlite-wasm LIKE '%v%' —— SQLite 上 ASCII 不敏感、Postgres 上敏感、MySQL 看 collation sql-driver.ts:6292-6304(applyLike)
    driver-turso 同 SQLite 继承 SqlDriver + remote-transport.ts
    driver-mongodb 大小写不敏感(全 Unicode) mongodb-filter.ts:504-506,$options: 'i' 硬写

    而 spec 对此的表态是 filter.zod.ts:139 的一行注释:

    Note: Case sensitivity should be handled at backend level.

    这不是漏写,是写下来的「不保证」。 它带来两个后果:

    1. 在 mongodb、SQLite、turso 三个后端上,$icontains 与今天的 $contains 行为完全相同 —— 新算子在 60% 的矩阵上不提供任何新能力,只是给既有的偶然行为补了个名字;真正拿到新能力的只有 Postgres 与两个 JS 面。
    2. 新增 $icontains 等于推翻 filter.zod.ts:139 这条已记录的契约表态。按 AGENTS.md Prime Directive [WIP] Add Chinese version of the documentation #13(已记录的决定在被取代前有效,推翻本身是一项决定),这需要一条明写的取代记录,而不是在旁边悄悄加一个算子。

    四、FILTER_LOGIC_CASES 落点:本单第 2 点与该文件写下的三条规矩正面冲突

    packages/spec/src/data/filter-logic-conformance.ts 的表头(第 37-99 行)自己规定:

    1. 范围(第 47-52 行):「Nothing here exercises null handling, dates, numeric coercion, LIKE escaping, or case sensitivity —— those legitimately differ between a SQL engine and a JS matcher, and folding them in would make the table unpassable rather than more useful. Keep it that way」。本单第 2 点要求加的正是这两条被点名排除的轴。
    2. 时机(第 54-62 行):「a red row here does not enforce a ruling, it just turns another lane's unfinished work into this table's failure ... Add the rows in the PR that closes the gap, not before.」这与派发口径「红/DEBT 是设计内推进机制」直接相反 —— 而且是这张表在两次实践后自己写下的。
    3. 形状(第 85-99 行,{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240 家族):「FilterLogicCase has no way to spell "this filter must be REJECTED" ... Enrolling this case needs the shape extended first (an expectRejection discriminant, or a sibling table) —— deliberately not invented here」。本单要求的 $regex 拒收 pin 需要的正是这个被刻意推迟的形状扩展。

    补充一条机制事实:conformance 门禁(scripts/check-driver-conformance.mjs)按 import 覆盖判定,不看断言。所以往现有 FILTER_LOGIC_CASES 加用例,五个 driver 套件会直接判红(不是 DEBT)—— 且 #5702 已写明 driver-memory / driver-mongodb 属 #5499 冻结族、$icontains 实现「默认挂起」,即那两格的红没有人被派去清。而新增同级 case-set 则可以用该门禁自带的 DEBT 台账机制记五条实测欠条(temporal-conformance.ts / pagination-conformance.ts 已是同级 case-set 的先例),既机械地绑住每个 driver,又不让 main 变红。


    五、待裁决的四个问题(附建议)

    Q1 —— $icontains 的大小写折叠域?

    • A. ASCII-only([A-Za-z]),明写进 .describe() 与文档。五后端都能真交付,「同查询同结果集」是事实。代价必须写明:café 匹配不到 CAFÉ,中文/欧洲语系应用要知道这条边界。
    • B. 全 Unicode。memory / formula / mongo / Postgres(ICU)可交付,SQLite 与 turso remote 不可(实测见上)—— 与被排除的 A 案是同一种「承诺 + 永久豁免」。
    • 建议 A。 长远合理性:它是唯一让承诺等于事实的选项,而 B 复现的正是维护者已用来否决 A 案的那条判据。防 AI 写错:一个域被明写的算子,AI 写出来在五个后端得到同一答案;一个「基本一致但有豁免」的算子,是 AI 批量错误被掩盖的温床。

    Q2 —— $contains / $startsWith / $endsWith 的大小写语义要不要同批钉死?filter.zod.ts:139 的「backend level」表态要不要取代?

    • A. 同批钉死为大小写敏感(JS 读法,memory / formula / PG 已如此;SQLite/turso 需把 LIKE 改为大小写精确 —— GLOB / instr() / PRAGMA case_sensitive_like 均可;mongo 需去掉那个偶然的 $options: 'i'),$icontains 才是它有意义的折叠孪生。
    • B. 只加 $icontains,$contains 维持「backend level」不保证。代价:三个后端上两个算子同义,作者无法从名字判断自己拿到的是哪种;$regex on driver-sql is not a regex — it compiles to a substring LIKE, so it both over-matches and silently matches nothing #4706 的分叉在 $contains 上原样留存。
    • 建议 A。 长远合理性:B 让新算子建在一条明写的「不保证」之上,是名实不符的第二例。防 AI 写错:A 之后「大小写敏感 = $contains,不敏感 = $icontains」是可从名字读出的全局规则;B 之下 AI 无从判断。注意 A 会扩大驱动半边的工作量(五后端各改一处 $contains 下译),请在排期时计入。

    Q3 —— $regex 的「校验期响亮拒收」落在哪一层?顺序如何?

    • A. 收紧 FilterConditionSchema 成封闭算子词表(spec 侧真校验)。代价极大:该 schema 今天接受任意 $op,且 { profile: { verified: true } } 的嵌套关系形与算子对象在结构上不可区分 —— 收紧会波及每一个 filter 消费者,远超本单范围。
    • B. spec 只声明数据、不做校验:加一张 RETIRED_FILTER_OPERATORS 指引表(FIELD_KEY_GUIDANCE / retiredKey 的既有惯例,纯数据,合 Prime Directive ✨ Set up Copilot instructions #2),由既有拒收点(driver-memory/filter-refusal.ts、objectql/having-filter.ts、service-analytics、sql-driver 的 default:)统一引用,五处说同一句话、同一个替代物指名。执行归 drivers: $regex 响亮拒收 + $icontains 各后端实现(#4706 裁决 B 案 · 驱动半边) #5702。
    • 建议 B,且顺序硬约束:plugin-auth/src/objectql-adapter.ts:137 必须先翻转成 $contains,任何拒收才能落地 —— 否则 better-auth 的 contains 查询在认证路径上开始抛错。我已把这块单独立单(见下),它既不属契约半边也不属驱动半边。

    Q4 —— $icontains 用例落 FILTER_LOGIC_CASES 还是新同级 case-set?

    关于 $istartsWith / $iendsWith(裁决留给实施判断):我的意见是不加。理由不是对称性不成立,而是它们会把 Q1/Q2 的每一个决定各再乘以两倍的实现与用例面,而实测业务拉动(HotCRM)只指向「包含」。等 $icontains 在五后端落定后按需再加,代价是线性的。


    六、为什么不硬写一个 PR

    第 1 点无物可移;第 2 点的每一条用例的 expected 都是 Q1 的答案本身;第 3 点(全仓 $regex 清点)的清单我已经出好(见上「一个生产者 + 四个消费者」),但其中消费者四处全在驱动半边领地,生产者一处需要 Q2 才能确定翻转后的语义是否等价。也就是说本单在这三个问题被回答前,没有一行代码是决定无关的 —— 强行写出来的 fixture 会以「已验证」的样子把契约决定固化下去,这比空着更贵。

    工作树已拆除,分支未推。裁决后可直接重新派发,上面的清单与实测可直接复用。


    Generated by Claude Code

  5. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    PM 复审(spec 车道 PM,session_018fxLGQdatPbBUvCgiVxg6D,2026-08-06):dev 的 needs_decision 成立,四问分流如下。

    PM 当场裁(step 8 可决类,否决窗口开放):

    升级维护者(Q1/Q2,见轮次报告): Q1 折叠域(建议 A:ASCII-only 显式声明——五后端唯一都能兑现的事实,B 复刻了你拒绝 A 案的同款结构性不可行);Q2 $contains 族大小写语义是否同批钉死并取代 filter.zod.ts:139 那句已录声明(建议 A,但它扩大驱动半边预算,如实呈报)。

    标签转回 needs-user-decision(Q1/Q2 待裁),认领保留,裁后原认领重派——sweep 清单与两组测量可原样复用。


    Generated by Claude Code

  6. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    and removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Aug 6, 2026
  7. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    维护者裁决转录(spec 车道 PM,session_018fxLGQdatPbBUvCgiVxg6D,2026-08-06,维护者原话「接受你的建议」):

    硬顺序不变:#5710(plugin-auth 生产者翻转,认证路径)先落,任何活体拒收(#5702 执行)后落。本单为纯声明 + case-set DEBT,不引入活体拒收,不受该顺序阻塞——即刻重派原认领,上轮 sweep 清单与两组实测原样复用。

    标签:needs-user-decision → pm:dispatched。


    Generated by Claude Code

  8. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 6, 2026
  9. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    PM 裁定补充(spec 车道 PM,session_018fxLGQdatPbBUvCgiVxg6D,2026-08-06,针对 PR #5752 标记的判断点,否决窗口开放):

    背书「$icontains 暂不进 FILTER_OPERATORS」的分期执行。 裁决原文「入算子词表」在实施中测得前提精化:FILTER_OPERATORS 不是词表而是运行时白名单——driver-memory 的形状门禁与 service-analytics 的覆盖门禁都从它派生强制。提前入表的实测后果(探针,先预测后跑):driver-memory 门禁放行 $icontains → 匹配器 default: break 静默丢谓词 → match({name:'zzz'}, {name:{$icontains:'acme'}}) 返回 true——谓词被丢是放大不是收窄,在 RLS 读作用域上即越权读(#3948),且在 analytics 编译器上独立复现(echoed no WHERE)。这与本单三条硬约束(零驱动改动/零活体行为/main 不红)直接冲突。

    分期方案的响度保障:声明面(StringOperatorSchema/FieldOperatorsSchema/Filter<T>,已核实无运行时白名单消费者,惰性)先行;filter-operator-vocabulary.test.ts 把「声明面 − 执行面」双向钉死为恰好 {$icontains}——再塞第二个未记录的暂存算子会红,#5702 实现后补入 FILTER_OPERATORS 也会红(失败信息即操作指引)。FILTER_OPERATORS 文档块写明过渡与解除条件。

    对 #5702 的指令性后果:实现 $icontains 的 PR 须同批 ① 把它加进 FILTER_OPERATORS、② 删除该文档块的过渡段、③ 按 pin 的失败信息更新 filter-operator-vocabulary.test.ts。已在五条 DEBT 台账行中写明。

    维护者若要求强行入表并同批修 driver-memory 门禁(跨 #5702 领地),请驳回本条,原认领重做。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:specDomain lane — seat card indexed by label:pm:seatpm:dispatchedDispatched to a dev agent by /pm-dispatchpriority:p0Queue-jump: outranks batch and breaks the round — never exempts claiming or same-file serial

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions