spec(data): $between requires two non-blank endpoints (#18012) - #19066
Conversation
`$between` admitted an endpoint that is blank: `''` is a string, and the
endpoint union declared `number | Date | string`, so `{ $between: [1, '']
}` parsed green and a half-filled range reached the query with one
meaningless boundary and no signal at any layer.
Both endpoints are now required present and non-empty. The empty string is
the one spelling that changes what parses; `undefined` was already a union
rejection and gains the pointed sentence. `null` keeps the 2026-08-31
ruling's own message, which prescribes the null predicate — a different
remedy for a different intent.
The refusal names the blank SIDE (MIN / MAX plus the index): the only
measured producer is a builder padding a half-typed pair, so both bounds
are present and the author cannot see which one is empty.
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
…087 D3) A semantic entry, not a D2 conversion, and the load path was measured rather than assumed: `applyConversionsToStoredItem` never throws and never validates, and a stored view carrying a blank bound comes back as the same object reference. Dropping the operator would delete a constraint the author wrote and widen the result set silently. Adds the entry FILE and regenerates `registry.ts` — never typed between the `os-generated` markers. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
…t rule `check:generated` proved exactly one artifact stale; regenerated with `--fix`, which touched only it. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5d5f1ac5a3b85c37289d4525a0c47bbb78920b61 && git checkout 5d5f1ac5a3b85c37289d4525a0c47bbb78920b61
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d4cb05cbf0a8dda962974533ee634393a66440fa e849c873cdf3358eb8fdb992049b9f3e1c18805c && git checkout -B drift-repro d4cb05cbf0a8dda962974533ee634393a66440fa && git merge --no-ff e849c873cdf3358eb8fdb992049b9f3e1c18805c
node scripts/docs-audit/affected-docs.mjs --json d4cb05cbf0a8dda962974533ee634393a66440fa |
Contract reviewServed-tier: Trees read: head Instruments: zod (1) Derived judgments
(2) Semver levelChangeset (3) Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code Generated by Claude Code |
席位记账更正 —— 条款②闸的双载体席位 我先把闸只挂在卡上,又只从卡上清掉。
⇒ 我那一条孤零零的摘除,正是剥闸签名。 补正,四步,全部有回读:
后两笔相隔 2 秒 ⇒ 合法清闸签名。重跑 ⭐ 记在这里而不是私下修掉:复核本身是真的、PASS 也是真的,错的只是我的挂标顺序。一个只有一条摘除的事件流,在 H35 与本门禁眼里都读作放行 —— 那才是这条规则存在的理由。 Generated by Claude Code Generated by Claude Code |
Fixes #18012
Clause-②: yes
Ruling executed: decision batch #146 item 5, letter A — maintainer 「146 同意」 2026-09-17T13:16Z. Carrier: the changeset
.changeset/18012-between-blank-endpoint-refused.md—@objectstack/specminor, body carrying BREAKING for authored metadata, ADR-0087 dispositionregistered filter-between-blank-endpoint-refused.What changed
$betweennow requires two endpoints that are present and non-empty. A blank bound at either side is refused at the authoring door, and the refusal names the blank side —MIN/MAXplus the index — because the only measured producer pads a half-typed pair, so both bounds are present and the author is the one person who cannot see which one is empty.Three spellings, one rule, but only one of them changes what parses:
''''is a string and the endpoint union accepted itundefinedInvalid inputnullThe refinement rides the endpoint factory
RangeOperatorSchema(the documentation copy) andFieldOperatorsSchema(the enforced copy) already share, so the two cannot drift. The published endpoint description gained the rule in the same edit — declared = enforced — which is the whole of the regeneratedcontent/docs/references/data/filter.mdxdiff (5 rows, one per carrier).The empty-string arm is an element-level
superRefine, deliberately not the tuple-level refinement the factory's docblock rules out: a tuple check does not run once an element has failed, whereas the element check runs exactly when the union accepted the endpoint, which is precisely when there is an''to report.The ADR-0087 half the ruling left to measurement
The ruling asked for a D2 conversion entry and explicitly did not pick the behaviour: 「the dev measures which the load path already does for a refused operator and follows that precedent」 (drop the operator, or refuse at load).
Measured, on
origin/mainbefore the change: the load path does neither.applyConversionsToStoredItem— the one primitive every stored-row rehydration seam calls — never throws and never validates; it replays only the positively-recognised lossless transforms in the conversion registry. A stored view carrying{ close_date: { $between: ['2026-01-01', ''] } }comes back as the same object reference. No conversion in the registry drops a filter operator either: the three filter-adjacent entries are two key strips and a key rename.So the precedent to follow is the one the two nearest narrowings of this same surface already set —
filter-preset-ordering-comparand-refusedandanalytics-date-range-array-two-bounds-required, both of which decline a D2 conversion because rewriting would be the platform guessing which bound was meant. Registered as an ADR-0087 D3 semantic entry, with no D2 conversion and no stored-metadata rewrite. Dropping the operator would be worse than guessing: it deletes a constraint the author wrote and silently widens the result set — the failure mode$nincarries in the same file.Consequence, stated rather than left to be discovered: the read path does not re-validate stored rows, so no stored document becomes unreadable. What changes is that re-saving one is refused, at the endpoint's own path, with the blank side named.
migrations/registry.tsThe ruling's Execution line sequenced this on
registry.tsafter #18319 / #18420. The dispatching seat measured that this no longer applies and said so on the card: the file's three tables are generated regions fed one-file-per-entry fromentries/, and all four PRs said to hold it each add their own entry file. This PR did the same — one new file underentries/semantic/, thengen:migration-registry. Nothing was typed between the markers; theregistry.tsdiff is 86 lines of regenerated output andcheck:migration-registryproves the regeneration faithful.Verification
Run on
e849c873cd(the merge oforigin/maininto this branch), heavy runs serialized through the shared verify lock.pnpm --filter @objectstack/spec test— 491 files / 14309 tests passed.pnpm --filter @objectstack/spec typecheck— clean (tsc --noEmit+ scripts + test-layer ledger).pnpm --filter @objectstack/spec check:generated— all 16 generated artifacts up to date after the merge. Exactly one was proved stale during the change (content/docs/references/**) and regenerated with--fix, which touched only it.pnpm lint— repo-wide, exit 0.check-adr-0087-registration --base origin/main,check-changeset-no-major --base origin/main,check-empty-changeset --base origin/main,check:nul-bytes,check:where-matcher,check:query-options-erasure,check:test-source-alias,check:spec-parsed-alias,check:cross-package-test-inputs,check:merge-driver,check:published-files,check:objectui-changeset,check:type-check-coverage,check:doc-anchors,check:docs-single-h1,check:docs-spec-enumerations,check:quick-reference-counts,check-doc-frontmatter,check-docs-section-name,check-closing-keyword-parity.check:type-check-debt— NOT MEASURED, exit 3PREREQUISITE NOT MET: it needs the whole workspace dist closure built, whichlint.ymldoes before the step and this run did not. Not a pass and not a finding. This diff adds no package and moves no ledger entry.Reverse verification — the new assertions are not vacuous
Ablated through
scripts/ablation-replace.mjs, which proves the mutation reached disk before the command runs (no-ifamily):Predeclared direction: red, and exactly the five empty-string cases went red. The
undefinedcase, thenullcase and all 162 pre-existing assertions stayed green — which is what separates "this rule is enforced" from "this file's tests pass". No build step is involved: the spec suite resolves./filter.zodfrom source, not fromdist.Fixture sweep
Every
$betweenarray literal in the tree was read for a blank or absent bound: 3 distinct sites, none of them parsing through this schema — the driver-sql undefined-comparand refusal pin, the service-analytics filter-normalizer pin, and theparseFilterASTpin infilter-comparand-shape.test.ts. No fixture had to be rewritten. Instrument radius: tracked files this repo'sgit grepmatches for$between, scanned for array literals; outside it lie the sibling../objectuicheckout (a different repo, and its half is its own card) and any range built programmatically rather than written as a literal.Acceptance notes
''.parseFilterASTstill reads an empty string as a value — pinned on purpose infilter-comparand-shape.test.ts("refuses ONLY null — falsy and empty-ish members are values, not absence"), and that file is outside this card's file surface and outside the ruling, which scoped the spec half to the schema refinement. Flagged, not filed: the two doors serve two different populations (an author saving a document vs a caller handing a where-clause to the engine) and aligning them is a decision of the same class as this card's, not a seat call. Carrier if it is ever wanted: the same file that carries the null and ordering runtime twins.{ $between: [' ', 'M'] }is green, and there is a positive assertion pinning that, so a later reader cannot widen the refusal without noticing they are doing it. The ruling enumerated'',null,undefined; narrowing a published face past what was ruled is the seat call this card's whole history refuses to make. Noted, not filed.FilterConditionSchemajudges no comparand at all — it isz.record(z.string(), z.unknown())at every field position, so it also lets the already-ruled{ $field }endpoint through. Standing shape, not a hole this narrowing opened; a test now pins it with that{ $field }control beside it so the green reads as a measurement rather than an oversight. Noted, not filed.Clause-②: yesline is copied from the dispatch's claim comment, as the ruling set it. For the record, this diff carries no widening tell: no key, enum member, union arm, export row or registry registration is added, andcheck:api-surfaceis green with no export delta. Read strictly against the clause's own question (「本卡放宽接受集或扩大公开面吗」) the direction is narrowing-only; the direction is carried in prose and by the changeset'sBREAKING for authored metadatabanner rather than by rewriting the ruling's word.$betweenpair with''— the objectui half of objectstack#18012 (batch #146 item 5, letter A) objectui#9695 and is untouched here. It is safe on its own and may land either side of this PR.Generated by Claude Code