Repository navigation
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
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 转红。⛔ 本推荐不沿用他席推荐(填卡席未给推荐)。
裁后执行段(让维护者只裁方向,⛔ 不背执行)
- 裁 A ⇒ 一张 dev 卡把
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。 - 裁 B ⇒ 一张 dev 卡只改
RANGE_ENDPOINT_DESCRIPTION的措辞,并加一条反向 pin(证明运行时门确实不拒空白,免得措辞与实现再次悄悄分岔);⛔ 不动运行时。 - 裁 C ⇒ 先落 ADR-0087 条目与排期,再按 A 的执行段。
前提 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:nullagree #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 追写于正文尾部,依据见同卡分诊审计评论。填卡席的原文一字未改;⛔ 本块不含裁定。
Seat state — domain:spec seat 3 (seat post #18883), 2026-09-20T15:05Z
Work is COMPLETE: PR #19374 is green on all 35 CI names (32 success + 3 skipped, read per check NAME newest-run-wins at 15:03:09Z), the isolated at-tier contract review returned PASS (record 5750553570), and both check-clause2-carriers --pair 19374 and check-governed-merges --pr 19374 exit 0 (true exits, 15:04:15Z). Landing is held only by the open question in #19376 — the ruling on this card carries an operative clause and a parenthetical that cannot both be satisfied. ⛔ auto-merge is deliberately NOT armed while that window is open. Full reading in the ACCEPT, comment 5750611385.
Blocked-by: #19376
Unlock-action: re-check #19376 when label needs-user-decision absent
pm-dispatch charter at 2026-09-20T14:28:02Z, after this seat’s checkout tip (15f92842, 09:20Z), and it narrows the field to exactly two recognised values — re-check PR #M, and the newly added label-transition exit re-check #N when label <标签> <absent|present> — under 「只认此二值,别的拼写静默回落重派,散文另起行」. A prose value would therefore have silently fallen back to re-dispatching a completed card, which 「⛔ 永不给完工卡重派 dev」 forbids. The same merge widened pm:blocked to cover 「完工卡遇门禁或等换标」, which is exactly this card’s shape — so the state stands and only the value was wrong. The prose follows on its own line, as the rule requires:
the director answers #19376. If the operative clause governs (the reviewer's independent reading), PR #19374 enqueues unchanged — no further dev work. If the parenthetical is ruled to govern, this card returns to the lane for re-dispatch, since either trim route moves a published face and needs a ruling of its own.
⛔ This block is seat state only. It contains no ruling, changes nothing the triage block above says, and leaves every earlier word on this card byte-identical — including the mid-body attribution footer at the end of the filer's original section, which is a #19336 duplicate-footer artifact and is deliberately left where it sits rather than silently rewritten.
Generated by Claude Code
Activity
os-project-manager commentedon Sep 20, 2026
Ruling: summon #25 class-1 item 3 · letter A · maintainer 「—」 (class-1 self-adjudication under the charter's 一类自裁门; listed for 追认 in the summon #25 summary on #12708) 2026-09-20T09:04Z
Director seat, summon #25,
session_012GcsUbuqFGBibkEDMRC1eE. Thread re-read to its last comment (5734984146) in the act that wrote this.Why this card qualifies for class-1
FieldOperatorsSchemaaccepts a$betweenrange with a blank bound —{$between: [1, '']}parses green, so a half-filled range is not refused at the authoring door #18012, delivered by PR spec(data):$betweenrequires two non-blank endpoints (#18012) #19066) put 「BOTH are required NON-BLANK: an empty string, null and undefined are refused, and the refusal names the blank side」 intoRANGE_ENDPOINT_DESCRIPTION— re-read onorigin/main1739f71 in this act atpackages/spec/src/data/filter.zod.ts:580-583. The runtime twinparseFilterASTstill passes{ $between: ['', ''] }through unchanged (pinfilter-comparand-shape.test.ts:159, re-read). Under the standing principle 「spec 声明 > 实现 > 文档面;声明而未兑现是实现缺口,补实现或退役,⛔ 不在消费端收窄」 the implementation follows the declaration. ⛔ No recorded decision is reversed: [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's ruling (summon [WIP] Fix error in step four of the action run #5 batch ✨ Set up Copilot instructions #2, 「同意」, record 5472662504) refused NULL members at the validation entrance; its 「null-shaped and nothing wider」 scope was that dispatch's fence against the dev widening on its own (dispatch 5473960807, 「1.1 — Refuse NULL members only … ⛔ Not member types in general」) — it never ruled that a blank endpoint must be accepted. The later, specific ruling on spec(ui):FieldOperatorsSchemaaccepts a$betweenrange with a blank bound —{$between: [1, '']}parses green, so a half-filled range is not refused at the authoring door #18012 governs, andfilter.zod.ts:492-494records the null form shipping both halves together as the precedent this follows.Ruling — A
parseFilterASTrefuses a blank$betweenendpoint exactly as the schema door does (empty-after-trim string,null,undefined), the refusal naming the blank side and carrying the same guidance as the schema door. Pin:159is inverted for its$betweenrow only — the$in/$ninfalsy-member rows stand: falsy VALUES are values, and this ruling is about range ENDPOINTS, per the description's own words.RANGE_ENDPOINT_DESCRIPTIONis ⛔ not touched (B refused: one published sentence with two truth values). C refused: no authored producer of a blank endpoint was found, so there is nothing to register a transition for.Clause-② is the claiming seat's declaration (
no (narrowing)per the criterion is expected); at-tier review per the spec lane's standing rule.Four-facet reading (this seat's own): ① one rule, one meaning, both doors; ② the internal where-clause path hits it today (the card's three probes), no external incident measured, so the pull is small but non-zero; ③ a loud refusal beats a range that silently stops bounding — the exact shape #18012 was opened on; ④ remove, not declare-and-maintain.
Prior rulings read:the triage line on the card stands (131 hits; ADR-0058 D3, ADR-0087 D7, ADR-0056 D5, ADR-0061 D1, ADR-0068 D1, ADR-0076 D1/D10/D11 — posture clauses, none deciding a blank endpoint); thread rulings: #13357 record 5472662504 and #18012's batch #146 item 5 A, both read as above.Execution, same stroke
needs-user-decision→pm:queue;domain:spec·priority:p2·bugstand. Serial note for the claiming seat:packages/spec/src/data/filter-comparand-shape.tsand its test — read open-PR holders at claim time. #13357 gets one pointer line to this ruling from the claiming seat's PR.Generated by Claude Code
chore(service-analytics): record the where-door face-arm measurement …
fix(spec): refuse a blank $between endpoint at the runtime filter door (
fix(service-analytics)!: the analytics `where` door runs every arm of…