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)
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
Filed by the
domain:spec#4seat (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
$betweenendpoint, and writes that rule into the published endpoint contract.RANGE_ENDPOINT_DESCRIPTIONnow states verbatim that both endpoints are required NON-BLANK.The RUNTIME door does not agree. Measured at that PR's head
e849c873cdby the at-tier reviewer, re-stated here:parseFilterAST({ at: { $between: ['', ''] } })parseFilterAST({ at: { $between: ['2026-01-01', ''] } })parseFilterAST({ at: { $between: [null, 1] } })Operator "$between" on field "at" requires two non-null boundsThe 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 theRangeOperatorSchemait shares)」 — the schema door. The runtime twin lives infilter-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
nullform shipped BOTH halves together and says so in the code:filter.zod.ts:492-494records 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
维护者速读(由分诊席补写;⛔ 不含判定,只把选项摆齐)
一句话问题:同一个「起止范围」筛选条件,我们公开写下的规则说两头都必须填,而引擎真正的入口允许把一头留空 —— 留空的那一头会被当成「没有边界」,于是本该只取一段窗口的查询静默返回窗外的行,不报错、不告警。
选项 × 真实代价
filter-comparand-shape.test.ts:159的 pin 与 #13357「只拒 null」的记录业务含义直译:A = 「范围就是范围,少一头就不叫范围」;B = 「我们保留一条后门,并把后门写进说明书」;C = 「先记账,以后再还」。
四轴从业务立场论证
推荐 + 回退 + 置信缺口:荐 A。回退 C(若今天确有调用方靠空串表达开区间,先按 ADR-0087 登记一条带排期的转换条目,再走 A)。置信缺口:⛔ 本席未量到线上有多少调用方正靠空串端点表达开区间 —— 三条探针只证明入口接受它,⛔ 不证明有人在用;也⛔ 未量到 A 会让除
:159这一条之外哪些既有 pin 转红。⛔ 本推荐不沿用他席推荐(填卡席未给推荐)。裁后执行段(让维护者只裁方向,⛔ 不背执行)
filter-comparand-shape.ts的碑口从「null」扩到「null 或空白」,同笔改写:159的 pin 与其注释,并在 [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:nullagree #13357 上追一条「本条由本卡取代」的记录;⛔ 不动RANGE_ENDPOINT_DESCRIPTION。RANGE_ENDPOINT_DESCRIPTION的措辞,并加一条反向 pin(证明运行时门确实不拒空白,免得措辞与实现再次悄悄分岔);⛔ 不动运行时。前提 re-check 命令(裁决前或执行前任一方可自行重跑,⛔ 不必信本席的读数)
协议 / 公开契约是否改动:是 —— 两个方向各改一面(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:nullagree #13357 的 pin,B 会推翻裁决批 Release v0.3.3 #146 项 5 letter A 的措辞 ⇒ 两个方向都是在改一条已记录的决定,故按 SKILL.md〈冲突立成needs-user-decision卡〉入箱,⛔ 不是执行。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