Skip to content

finding(spec): the RUNTIME filter door accepts a blank $between endpoint that the published endpoint contract now refuses — parseFilterAST disagrees with RANGE_ENDPOINT_DESCRIPTION #19071

Description

@os-steve

Filed by the domain:spec#4 seat (session_01AmH9bKvGoLjiY86Q4Z3og2) on the at-tier contract reviewer's recommendation while reviewing PR #19066 (card #18012). ⛔ Filed unassigned, ⛔ no priority or type applied — routing and grading are triage's. ⛔ Not a claim.

The contradiction

PR #19066 (ruling batch #146 item 5 letter A) makes the SCHEMA door refuse a blank $between endpoint, and writes that rule into the published endpoint contract. RANGE_ENDPOINT_DESCRIPTION now states verbatim that both endpoints are required NON-BLANK.

The RUNTIME door does not agree. Measured at that PR's head e849c873cd by the at-tier reviewer, re-stated here:

probe result
parseFilterAST({ at: { $between: ['', ''] } }) returns the filter unchanged (same reference)
parseFilterAST({ at: { $between: ['2026-01-01', ''] } }) likewise unchanged
control parseFilterAST({ at: { $between: [null, 1] } }) throws Operator "$between" on field "at" requires two non-null bounds

The green is pinned on purpose at packages/spec/src/data/filter-comparand-shape.test.ts:159 — 「refuses ONLY null — falsy and empty-ish members are values, not absence」.

⇒ A caller handing a where-clause straight to the engine still gets a range that silently stops bounding, which is the defect class #18012 was opened on. The published declaration and the runtime door now say different things about the same value.

Why it was correctly left out of PR #19066

⛔ Not a miss by that PR, and ⛔ not a regression it introduced — the runtime door behaved this way before it. Ruling A scoped the spec half to 「a refinement on $between (and the RangeOperatorSchema it shares)」 — the schema door. The runtime twin lives in filter-comparand-shape.ts, outside that card's claimed file surface, under its own pinned #13357 ruling that the carve-out is null-shaped and nothing wider.

⇒ Aligning it is a second narrowing of a published face, which is the same decision class the maintainer had to rule on for the first one. ⛔ Not a seat call, ⛔ not a dev call.

The precedent that makes this a real gap rather than a preference

The null form shipped BOTH halves together and says so in the code: filter.zod.ts:492-494 records the schema message and the runtime twin as 「reconciled by pin — two moments, one author, no contradiction」. This ruling shipped the schema half only. ⇒ the pair that the null precedent deliberately kept together has been split.

What a decision would have to settle

Whether the runtime door narrows to match the published contract (and filter-comparand-shape.test.ts:159's pin is rewritten with it), or the published contract's wording is scoped to the schema door so the two stop contradicting each other. ⛔ This card proposes neither — the shape is the maintainer's.

Dedupe words

between blank bound · parseFilterAST runtime door · empty string endpoint · filter-comparand-shape · $between bounds

⛔ The filer does not dedupe (only these words); triage searches its own list, and a zero needs a control that hits.

Related: #18012 (the schema half, ruled and delivered as PR #19066) · #13357 (the null-shaped runtime carve-out this would touch)


Generated by Claude Code


维护者速读(由分诊席补写;⛔ 不含判定,只把选项摆齐)

一句话问题:同一个「起止范围」筛选条件,我们公开写下的规则说两头都必须填,而引擎真正的入口允许把一头留空 —— 留空的那一头会被当成「没有边界」,于是本该只取一段窗口的查询静默返回窗外的行,不报错、不告警。

选项 × 真实代价

选项 做什么 客户可感知的后果
A 引擎入口收窄到与公开规则一致 空端点改为响亮抛错;连带改写 filter-comparand-shape.test.ts:159 的 pin 与 #13357「只拒 null」的记录 今天靠空串表达「这头不限」的调用会立刻报错(一次可见的迁移);此后不再有静默越窗
B 公开规则的措辞收窄到只管 schema 门 明写「两头必填」只在 schema 校验处成立,直接进引擎的 where 子句不受此约束 无迁移成本;但「两头必填」从此是一句只在一条路上为真的话,静默越窗永久保留为已声明行为
C 两门都不动,把差异登记为 ADR-0087 式声明过的转换层条目 差异变成有出处、有移除排期的口径差 迁移延后;每条已声明的口径差都是一笔要还的永久义务,直到排期兑现

业务含义直译:A = 「范围就是范围,少一头就不叫范围」;B = 「我们保留一条后门,并把后门写进说明书」;C = 「先记账,以后再还」。

四轴从业务立场论证

  • 长远合理性:A 缩小特例(一条公开规则只有一个含义);B 让同一句话在两条路上有两种真值,是契约增生;C 是 declare-and-maintain。
  • 今天谁撞上:把 where 子句直接交给引擎的内部调用面(REST/GraphQL 之外)今天就撞上 —— 本卡三条探针已实测;⛔ 未量到任何外部事故,故拉动非零但小。
  • 防 AI 犯错(出错时谁看到什么):A = 响亮拒绝,写错的人当场看到抛错;B/C = 静默泄露,生成器写出空端点没人看到,返回行数默默变多。这一轴对 A 的偏向最强。
  • 创业阶段不扩散:A 是 remove;C 是带排期的 declare;B 是不可移除的永久义务(措辞一旦放宽,日后收紧又要一次裁决)。

推荐 + 回退 + 置信缺口:荐 A。回退 C(若今天确有调用方靠空串表达开区间,先按 ADR-0087 登记一条带排期的转换条目,再走 A)。置信缺口:⛔ 本席未量到线上有多少调用方正靠空串端点表达开区间 —— 三条探针只证明入口接受它,⛔ 不证明有人在用;也⛔ 未量到 A 会让除 :159 这一条之外哪些既有 pin 转红。⛔ 本推荐不沿用他席推荐(填卡席未给推荐)。

裁后执行段(让维护者只裁方向,⛔ 不背执行)

前提 re-check 命令(裁决前或执行前任一方可自行重跑,⛔ 不必信本席的读数)

pnpm --filter @objectstack/spec exec vitest run src/data/filter-comparand-shape.test.ts
git grep -n "RANGE_ENDPOINT_DESCRIPTION" origin/main -- packages/spec/src

协议 / 公开契约是否改动:是 —— 两个方向各改一面(A 改运行时可接受集,B 改已发布措辞)。按「协议/公开契约变化 ⇒ 升维护者」,本卡必须由维护者裁向;⛔ 分诊席不代答、⛔ 不派发。

Governing text(引文 + grep)

  • AGENTS.md:230 规则 12:「Contract-first — fix the metadata, not the runtime … one strict contract beats N dialects, and this is an internal contract (we own both ends), so "be liberal in what you accept" does not apply」⇒ 姿态偏 A,该条约束的是消费者不得宽容,而本卡的门住在 packages/spec 自身 ⇒ ⛔ 不构成已裁。
  • AGENTS.md:241 规则 13:「An accepted ADR binds until a superseding ADR says otherwise. Reversing a recorded decision is itself a decision」⇒ A 会推翻 [finding] driver-memory's matcher answers a NULL comparand inconsistently across the two readings of "no value" — $in:[null] / $nin:[null] disagree while $null / $ne:null agree #13357 的 pin,B 会推翻裁决批 Release v0.3.3 #146 项 5 letter A 的措辞 ⇒ 两个方向都是在改一条已记录的决定,故按 SKILL.md〈冲突立成 needs-user-decision 卡〉入箱,⛔ 不是执行。
  • grep:git grep -n -iE 'between|parseFilterAST|RANGE_ENDPOINT_DESCRIPTION' origin/main -- AGENTS.md docs/adr packages/spec/src;机械半由 scripts/pm/check-prior-rulings.mjs --card 19071 跑出,结果见下行。

os-decision-facets
① 项目长远合理性:A 缩小特例 —— 一条公开规则只保留一个含义;B 让同一句话在两条路上有两种真值,属契约增生;C 是 declare-and-maintain,义务留到排期兑现才消。
② 实际业务拉动:今天撞上的是把 where 子句直接交给引擎的内部调用面(本卡三条探针实测);⛔ 无外部事故读数 ⇒ 拉动非零但小,故⛔ 不按零拉动默认 defer。
③ 防 AI 犯错:A 是响亮拒绝(空端点当场抛错);B/C 是静默容忍(空端点被当成无界,返回行数默默变多,没有任何人看到)⇒ 按「响亮拒绝优于静默容忍」偏 A。
④ 创业阶段不扩散:A = remove;C = 带排期的 declare;B = 不可移除的永久义务 —— 公开措辞一旦放宽,日后收紧本身又需一次裁决。
Prior rulings read: between,parsefilterast,range_endpoint_description,spec,runtime,filter,door,accepts,blank,endpoint,published,contract (+2 more) → 131 hits; ADR-0058 D3, ADR-0087 D7, ADR-0056 D5, ADR-0061 D1, ADR-0068 D1, ADR-0076 D1, ADR-0076 D10, ADR-0076 D11
推荐 A;自检「只看①选 A;②③④ 是否翻转:否 —— ② 拉动小但非零,③④ 与 ① 同向加强 A」。
置信缺口:⛔ 未量到线上有多少调用方正靠空串端点表达开区间(探针只证明入口接受,不证明在用);⛔ 未量到 A 会让除 :159 之外哪些 pin 转红;⛔ 未量到失败步日志正文以外的第三方消费面。

末句一字问:A / B / C?

本块由分诊席于 2026-09-18T19:15Z 追写于正文尾部,依据见同卡分诊审计评论。填卡席的原文一字未改;⛔ 本块不含裁定。


Generated by Claude Code

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions