Skip to content

spec(data): $between requires two non-blank endpoints (#18012) - #19066

Merged
os-steve merged 4 commits into
mainfrom
claude/issue-18012-between-blank-bound-refused
Sep 18, 2026
Merged

os-steve merged 4 commits into
mainfrom
claude/issue-18012-between-blank-bound-refused

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

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/spec minor, body carrying BREAKING for authored metadata, ADR-0087 disposition registered filter-between-blank-endpoint-refused.

What changed

$between now 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 sideMIN / MAX plus 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.

FROM  FieldOperatorsSchema.safeParse({ $between: [1, ''] })  ->  { success: true }
TO    FieldOperatorsSchema.safeParse({ $between: [1, ''] })  ->  { success: false,
        issues: [{ code: 'custom', path: ['$between', 1],
                   message: 'A blank value is not a valid $between endpoint at index 1
                             (the MAX bound). …' }] }

Three spellings, one rule, but only one of them changes what parses:

spelling before after
'' parsed green refused — the only behavioural change; '' is a string and the endpoint union accepted it
undefined refused with zod's bare Invalid input refused with the pointed sentence naming the side
null refused with the 2026-08-31 ruling's own message unchanged — it prescribes the null predicate, a different remedy for a different intent

The refinement rides the endpoint factory RangeOperatorSchema (the documentation copy) and FieldOperatorsSchema (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 regenerated content/docs/references/data/filter.mdx diff (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/main before 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-refused and analytics-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 $nin carries 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.ts

The ruling's Execution line sequenced this on registry.ts after #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 from entries/, and all four PRs said to hold it each add their own entry file. This PR did the same — one new file under entries/semantic/, then gen:migration-registry. Nothing was typed between the markers; the registry.ts diff is 86 lines of regenerated output and check:migration-registry proves the regeneration faithful.

Verification

Run on e849c873cd (the merge of origin/main into this branch), heavy runs serialized through the shared verify lock.

  • pnpm --filter @objectstack/spec test491 files / 14309 tests passed.
  • pnpm --filter @objectstack/spec typecheck — clean (tsc --noEmit + scripts + test-layer ledger).
  • pnpm --filter @objectstack/spec check:generatedall 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.
  • Targeted gates, all 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-debtNOT MEASURED, exit 3 PREREQUISITE NOT MET: it needs the whole workspace dist closure built, which lint.yml does 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 -i family):

anchor  "if (endpoint !== '') return;"   x1 -> x0
blob    d17f958abb64 -> 4450ff1fac67          (the mutation landed)
result  5 failed | 162 passed
restore blob == HEAD d17f958abb64, `git diff HEAD` empty

Predeclared direction: red, and exactly the five empty-string cases went red. The undefined case, the null case 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.zod from source, not from dist.

Fixture sweep

Every $between array 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 the parseFilterAST pin in filter-comparand-shape.test.ts. No fixture had to be rewritten. Instrument radius: tracked files this repo's git grep matches for $between, scanned for array literals; outside it lie the sibling ../objectui checkout (a different repo, and its half is its own card) and any range built programmatically rather than written as a literal.

Acceptance notes

  • The runtime door is untouched, and it now disagrees with the schema door about ''. parseFilterAST still reads an empty string as a value — pinned on purpose in filter-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.
  • Whitespace-only endpoints still parse. { $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.
  • FilterConditionSchema judges no comparand at all — it is z.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.
  • The Clause-②: yes line 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, and check:api-surface is 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's BREAKING for authored metadata banner rather than by rewriting the ruling's word.
  • The objectui half — the builder stops padding a half-typed pair — is app-shell: the filter builder stops padding a half-typed $between pair 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

`$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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d4cb05cbf0a8dda962974533ee634393a66440fapackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5d5f1ac5a3b85c37289d4525a0c47bbb78920b61 — the merge of head e849c873cdf3358eb8fdb992049b9f3e1c18805c into base d4cb05cbf0a8dda962974533ee634393a66440fa, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e849c873cdf3358eb8fdb992049b9f3e1c18805c

Trees read: head e849c873cdf3358eb8fdb992049b9f3e1c18805c (PR head, a merge of origin/main into the branch; first parent c0a4339a26, second parent 7af6983ccf); base 7af6983ccf99f02cb3f3cdf6296f4691922f8298 (= merge-base with origin/main at 0ec81857aa, = the head's second parent). GitHub's reported base sha d4cb05cbf0 is NOT an ancestor of head (main moved), so the merge-base is the base of record. The dev's measured origin/main 67624b78d5 is an ancestor of head. Diff base..head: 6 files, +425/-14.

Instruments: zod 4.4.3 in both trees, vitest 4.1.11, node v22.22.2, pnpm 10.31.0; filter.zod.ts at head blob d17f958abb64; registry.ts at head blob 00b32579a5; check-widening-tells.mjs blob 032bd9db74; check-clause2-carriers.mjs blob 3a270ef2eb; check-adr-0087-registration.mjs blob fdbe5c06e5; ablation-replace.mjs blob 96b8f64283. Exit codes captured before every pipe.

(1) Derived judgments

  1. Direction: narrowing only — CORRECT. Accept set base vs head, same probe, same zod: FieldOperatorsSchema {$between:[1,'']} base success:true to head false, one custom issue at path ['$between',1], message opens A blank value is not a valid $between endpoint at index 1 (the MAX bound). RangeOperatorSchema ['', '2026-12-31'] true to false at ['$between',0] naming MIN. ['',''] true to false with TWO issues. NormalizedFilterSchema nested case likewise. Nothing widens: every input parsing at base still parses at head except the empty-string endpoint. No new key, enum member or union arm; the only +export is export const entry in the new entries file, not on the package surface. check-widening-tells --declaration no --diff exit 0, no widening tell.
  2. The refusal stops exactly where the ruling put it — CORRECT. '' refused (the only behavioural change); undefined already refused at base, same code and path, text only; null byte-identical to the 2026-08-31 message. NOT widened: [' ', 'M'] and ['\t', 'M'] parse green at head and base; arity refusals identical. The whitespace pin EXISTS in filter.test.ts. Both copies share rangeEndpointSchema(index), so they cannot drift.
  3. Assertions are not vacuous (ablation, predeclared red). Anchor if (endpoint !== '') return; x1 to x0 (blob d17f958abb64 to 2b03ee01d6c1): 5 failed | 162 passed — exactly the five empty-string cases; undefined, null, whitespace and every pre-existing assertion stayed green. Restored to blob d17f958abb64, git diff HEAD empty. Full suite at head exit 0: 14307 passed + 1 skipped.
  4. ADR-0087 disposition (D3 semantic entry, no D2) — RIGHT; the precedent claim re-measures true. Load path returns the SAME object reference with zero notices for a blank bound, at base and head, with same-subject controls that HIT (conditionalRequired rewritten; widgets[].performance stripped from the same collection as the zero). No conversion drops a filter OPERATOR (grep 0; control finds the three filter-adjacent ids). Both cited precedents decline D2 in their own words. ONE PREMISE CORRECTED (conclusion unchanged): the dev wrote 'the read path does not re-validate stored rows'. It does — loadMetaFromDb re-validates after conversion, counts the row invalid, warns with the [metadata_spec_invalid] marker and STILL registers it. So a stored blank-bound view stays readable, is diagnosed on read, and is refused on re-save — exactly the 'refuse at load' the ruling named, in the only form the load path has: diagnose, never drop. check-adr-0087-registration exit 0.
  5. Declared = enforced — CORRECT. RANGE_ENDPOINT_DESCRIPTION states both endpoints are required NON-BLANK and that a one-sided range is not a $between at all. The regenerated docs diff is exactly 5 $between rows, one per carrier, whose only change is this sentence. check:generated all 16 artifacts up to date; tracked tree clean.
  6. The generated region of registry.ts was produced by the generator, not typed. gen:migration-registry rewrote the file to the SAME blob (00b32579a5 before and after). The PR's single hunk lies inside the os-generated semantic:18 region. Set comparison of semantic:18 ids: base 138, head 139, head minus base = filter-between-blank-endpoint-refused, base minus head = EMPTY (lost = 0) — the silent-loss failure mode the file's header names did not occur.

(2) Semver level

Changeset @objectstack/spec: minor, body opens BREAKING for authored metadata, carries Clause-②: yes, a FROM to TO migration table with the one-line fix, and the ADR-0087 marker. Matches the ruling verbatim and AGENTS.md's grammar. check-changeset-no-major exit 0, check-empty-changeset exit 0. Declared = enforced = gated: consistent.

(3) Boundary flags

  • The Clause-② VALUE (dev's open question A/B/C): answer A — keep bare yes. Measured, not inherited: check-widening-tells recognises ONLY bare yes/no — both yes (narrowing) and no (narrowing) are refused exit 1. So option B would put a spelling on the claim that one gate refuses outright, and option C's characterisation is not what the instruments do. No body edit needed.
  • The RUNTIME door disagrees with the published endpoint contract about ''. Measured at head: parseFilterAST({ at: { $between: ['', ''] } }) returns the same reference unchanged; control [null, 1] throws in both trees; the green is pinned at filter-comparand-shape.test.ts:159. Agreed: correctly left out of scope — aligning it is a second narrowing of a published face and a maintainer decision. It should be FILED as a class (b) decision card: the null precedent shipped a schema message AND a runtime twin 'reconciled by pin'; this ruling ships the schema half only, so a caller handing a where-clause to the engine still gets a range that silently stops bounding. Not blocking here.
  • Whitespace-only endpoints still parse. Correct per the ruling's enumeration; pinned. Noted, not filed.
  • FilterConditionSchema judges no comparand. Standing shape, not opened by this PR; now pinned with a control beside it. Noted, not filed.
  • The ruling's 'Sequence on registry.ts after feat(spec)!: manifest.id enforces the reverse-domain rule its registry face already had #18319 / feat(spec,types,triggers)!: group runs package-authored scheduled work without a declaration, owning each run's writes per record #18420': the dispatching seat's correction held on the evidence — base carries 138 semantic:18 entries reproduced byte-identically by the generator, head adds one, none lost.

Implemented-by: claude/issue-18012-between-blank-bound-refused
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: PASS


Generated by Claude Code


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

席位记账更正 —— 条款②闸的双载体

席位 domain:spec#4,session_01AmH9bKvGoLjiY86Q4Z3og2,2026-09-18T15:4xZ。达档复核记录见上一条(PASS,无 BLOCKING)。这一条只说我自己的记账错误,因为它在事件流里留下了痕迹,而那个痕迹不解释就会被读成别的东西。

我先把闸只挂在卡上,又只从卡上清掉。 check-clause2-carriers 随即判 C3,并说明了原因:

该闸是双载体(maintainer 2026-08-22,「两边都挂好」),一笔挂、一笔清。第二个载体不是装饰 —— 它是「闸被」而非「被」的唯一机器可读证据:合法清闸留下两条相隔数秒的摘除,剥闸只留一条。而「闸门被剥不是红灯是放行」,所以没有它,「被剥」与「从未挂过」在证据里无法区分。

⇒ 我那一条孤零零的摘除,正是剥闸签名

补正,四步,全部有回读:

时刻 动作
15:40:22Z needs:contract-review 挂回卡 #18012
15:40:24Z 同一标签挂上 PR #19066(这是我先前漏掉的那一半)
15:40:26Z 从卡上清
15:40:28Z 从 PR 上清

后两笔相隔 2 秒 ⇒ 合法清闸签名。重跑 check-clause2-carriers --pair 19066(管道前捕获退出码)⇒ EXIT=0,判定「条款②声明以固定拼写可读,两个载体一致,且有一份指名本 head 的复核记录」。

记在这里而不是私下修掉:复核本身是真的、PASS 也是真的,错的只是我的挂标顺序。一个只有一条摘除的事件流,在 H35 与本门禁眼里都读作放行 —— 那才是这条规则存在的理由。


Generated by Claude Code


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 18, 2026 15:42
@os-steve
os-steve added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 176b035 Sep 18, 2026
44 checks passed
@os-steve
os-steve deleted the claude/issue-18012-between-blank-bound-refused branch September 18, 2026 16:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/m tests tooling

Projects

None yet

2 participants