diff --git a/.changeset/18012-between-blank-endpoint-refused.md b/.changeset/18012-between-blank-endpoint-refused.md new file mode 100644 index 0000000000..eeaf7d3ca3 --- /dev/null +++ b/.changeset/18012-between-blank-endpoint-refused.md @@ -0,0 +1,51 @@ +--- +'@objectstack/spec': minor +--- + +**BREAKING for authored metadata** — a `$between` range now requires two endpoints that are present and non-empty. A blank bound (`''` or an absent `undefined` bound, at either side) is refused at the authoring door, and the refusal names the blank side (#18012). + +Clause-②: yes + +Maintainer ruling A on decision batch #146 item 5, 2026-09-17 「146 同意」. + +## What changed, and why it is a new rule rather than a repair + +`FieldOperatorsSchema.safeParse({ $between: [1, ''] })` answered `success: true` — measured on the card against spec 17.4.0 and re-measured on `main` before this change. That acceptance was **conformant**: the endpoint contract shared by both bounds says verbatim that "Each endpoint is a number, a Date, or a string", and the empty string is a string. So this narrows a published face by adding a rule to it, rather than pulling code back to a declaration it was already violating. + +What made the acceptance wrong is the other half of the same contract — "Closed interval [min, max]" — which no backend can honour against a blank. `driver-sql` binds the blank into `whereBetween`; the JS matchers compare it as a value. Either way the range stops bounding on that side **while still reading as a complete two-element range**, so the query runs with one meaningless boundary and no signal at any layer. The reference matcher was already taught to survive the `null` form of exactly this (a bounded range answered every valued row, because both of the arm's comparisons are false against a missing bound); the door that admitted it was never addressed. + +The only producer ever measured is a UI builder padding a **half-typed** pair so a length-based completeness check passes it. Nobody writes a blank bound on purpose — which is why it is refused rather than given a published meaning. + +``` +FROM FieldOperatorsSchema.safeParse({ $between: [1, ''] }) + -> { success: true } // a half-filled range, green all the way + // to the driver + +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). …' }] } +``` + +## Migration — FROM → TO + +| You wrote | Write instead | +| --- | --- | +| `{ $between: [1, ''] }` | `{ $between: [1, 100] }` — the upper bound you meant, written out | +| `{ $between: ['', '2026-12-31'] }` | `{ $between: ['2026-01-01', '2026-12-31'] }` — the lower bound you meant | +| a range that was only ever bounded on ONE side | `{ "$gte": min }` or `{ "$lte": max }` — a one-sided bound is not a range | + +**The one-line fix: write the bound that is missing, or — if only one side was ever meant — drop `$between` and write that side as a scalar comparison.** ⛔ Not mechanically convertible: the bound the author did not type is not recoverable from the one they did, so this ships as an ADR-0087 D3 structured TODO and **no D2 conversion**. Both of the two readings a conversion could take are wrong — dropping the operator deletes a constraint the author wrote and silently WIDENS the result set, and treating the blank side as unbounded invents a filter nobody authored. + + + +## What does NOT change + +- **Arity.** A one-element or three-element `$between` was already refused, and still is, by the tuple's own contract. This rule is about a two-element range one of whose elements means nothing. +- **`null` bounds.** Already refused since 2026-08-31, and they keep **their own** message, which prescribes the null predicate — an author who wrote `null` was reaching for absence, not for a bound. Two blank spellings, two intents, two remedies. +- **Falsiness.** `{ $between: [0, 100] }` and `{ $between: ['0', '9'] }` parse exactly as before. The rule is blankness, not falsiness. +- **Whitespace-only endpoints** are deliberately **not** judged. The ruling is the empty string; widening the refusal past it would narrow a published face further than the ruling did. +- **The set slots.** `{ $in: ['', 'won'] }`, `{ $nin: [''] }`, `{ $eq: '' }` and `{ $gte: '' }` are untouched — an empty string is a legitimate stored VALUE, and only an interval ENDPOINT is judged here. +- **Stored documents.** The read path does not re-validate stored rows, and the stored-row conversion pass neither validates nor drops anything, so no stored view becomes unreadable. What changes is that **re-saving** one is refused, at the endpoint's own path, with the blank side named. +- **The published export surface.** No export is added, removed or renamed; the refusal rides the existing endpoint factory that both the documentation copy (`RangeOperatorSchema`) and the enforced copy (`FieldOperatorsSchema`) already share, so the two cannot drift. diff --git a/content/docs/references/data/filter.mdx b/content/docs/references/data/filter.mdx index e19865bc79..7595ccdaee 100644 --- a/content/docs/references/data/filter.mdx +++ b/content/docs/references/data/filter.mdx @@ -109,7 +109,7 @@ const result = ComparisonOperatorSchema.parse(data); | **$lte** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Less than or equal to. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | | **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | | **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | -| **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string. A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | +| **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string, and BOTH are required NON-BLANK: an empty string, null and undefined are refused, and the refusal names the blank side. A range bounded on ONE side only is not a $between at all — write the side you have as a scalar comparison ($gte for a lower bound, $lte for an upper one). A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | | **$contains** | `string` | optional | Contains substring, CASE-SENSITIVELY — "acme" does NOT match "ACME". Lowered to `LIKE '%?%'` (case-exact) on the SQL family, and answered case-exactly on every JS evaluation face the platform ships. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). Case-INSENSITIVE containment is $icontains, which folds ASCII case only. | | **$notContains** | `string` | optional | Does not contain substring, CASE-SENSITIVELY — the negation of $contains, on the same comparand contract. Lowered to `NOT LIKE '%?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | | **$startsWith** | `string` | optional | Starts with prefix, CASE-SENSITIVELY. Lowered to `LIKE '?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | @@ -235,7 +235,7 @@ Type: `[FilterArray](#filterarray)[]` | **$lte** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Less than or equal to. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | | **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | | **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | -| **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string. A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | +| **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string, and BOTH are required NON-BLANK: an empty string, null and undefined are refused, and the refusal names the blank side. A range bounded on ONE side only is not a $between at all — write the side you have as a scalar comparison ($gte for a lower bound, $lte for an upper one). A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | | **$contains** | `string` | optional | Contains substring, CASE-SENSITIVELY — "acme" does NOT match "ACME". Lowered to `LIKE '%?%'` (case-exact) on the SQL family, and answered case-exactly on every JS evaluation face the platform ships. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). Case-INSENSITIVE containment is $icontains, which folds ASCII case only. | | **$notContains** | `string` | optional | Does not contain substring, CASE-SENSITIVELY — the negation of $contains, on the same comparand contract. Lowered to `NOT LIKE '%?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | | **$startsWith** | `string` | optional | Starts with prefix, CASE-SENSITIVELY. Lowered to `LIKE '?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | @@ -258,7 +258,7 @@ Type: `[FilterArray](#filterarray)[]` | **$lte** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Less than or equal to. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | | **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | | **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | -| **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string. A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | +| **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string, and BOTH are required NON-BLANK: an empty string, null and undefined are refused, and the refusal names the blank side. A range bounded on ONE side only is not a $between at all — write the side you have as a scalar comparison ($gte for a lower bound, $lte for an upper one). A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | | **$contains** | `string` | optional | Contains substring, CASE-SENSITIVELY — "acme" does NOT match "ACME". Lowered to `LIKE '%?%'` (case-exact) on the SQL family, and answered case-exactly on every JS evaluation face the platform ships. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). Case-INSENSITIVE containment is $icontains, which folds ASCII case only. | | **$notContains** | `string` | optional | Does not contain substring, CASE-SENSITIVELY — the negation of $contains, on the same comparand contract. Lowered to `NOT LIKE '%?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | | **$startsWith** | `string` | optional | Starts with prefix, CASE-SENSITIVELY. Lowered to `LIKE '?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | @@ -281,7 +281,7 @@ Type: `[FilterArray](#filterarray)[]` | **$lte** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Less than or equal to. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | | **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | | **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | -| **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string. A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | +| **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string, and BOTH are required NON-BLANK: an empty string, null and undefined are refused, and the refusal names the blank side. A range bounded on ONE side only is not a $between at all — write the side you have as a scalar comparison ($gte for a lower bound, $lte for an upper one). A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | | **$contains** | `string` | optional | Contains substring, CASE-SENSITIVELY — "acme" does NOT match "ACME". Lowered to `LIKE '%?%'` (case-exact) on the SQL family, and answered case-exactly on every JS evaluation face the platform ships. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). Case-INSENSITIVE containment is $icontains, which folds ASCII case only. | | **$notContains** | `string` | optional | Does not contain substring, CASE-SENSITIVELY — the negation of $contains, on the same comparand contract. Lowered to `NOT LIKE '%?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | | **$startsWith** | `string` | optional | Starts with prefix, CASE-SENSITIVELY. Lowered to `LIKE '?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | @@ -312,7 +312,7 @@ Type: `[FilterArray](#filterarray)[]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string. A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | +| **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string, and BOTH are required NON-BLANK: an empty string, null and undefined are refused, and the refusal names the blank side. A range bounded on ONE side only is not a $between at all — write the side you have as a scalar comparison ($gte for a lower bound, $lte for an upper one). A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | --- diff --git a/packages/spec/src/data/filter.test.ts b/packages/spec/src/data/filter.test.ts index e0cfefde1d..4c02c4caa5 100644 --- a/packages/spec/src/data/filter.test.ts +++ b/packages/spec/src/data/filter.test.ts @@ -581,6 +581,130 @@ describe('RangeOperatorSchema', () => { expect(FieldOperatorsSchema.safeParse({ $eq: { $field: 'budget' } }).success).toBe(true); }); }); + + // ========================================================================== + // #18012 — a BLANK endpoint is ruled out, in both endpoint unions and in both + // copies of the schema. Ruled 2026-09-17 (decision batch #146 item 5, letter + // A): `$between` requires two endpoints that are present and non-empty. + // + // This is a NEW RULE narrowing a published face, not a pull-back to a + // declared one: `RANGE_ENDPOINT_DESCRIPTION` admitted string endpoints and + // `''` is a string, so `{ $between: [1, ''] }` parsed green — measured on the + // card against spec 17.4.0 and re-measured on `origin/main` before this + // change. The only producer ever measured is a UI builder padding a + // HALF-TYPED pair, which is why the refusal names the blank SIDE. + // + // Scope, deliberately: the empty string and `undefined`. `null` keeps the + // 2026-08-31 ruling's own message (it prescribes the null PREDICATE, a + // different remedy for a different intent), and whitespace-only endpoints are + // NOT judged — narrowing further than ruled is the seat call this card's + // whole history refuses to make. + // + // The RUNTIME door — `parseFilterAST`, which does not run this schema — is + // untouched by this ruling and still reads `''` as a value + // (`filter-comparand-shape.test.ts`). + // ========================================================================== + + describe('blank $between endpoints are refused (#18012)', () => { + const issuesOf = (result: { error?: { issues: Array<{ path: PropertyKey[]; message: string }> } }) => + result.error?.issues ?? []; + + it('refuses the card\'s own reading — { $between: [1, \'\'] } on the ENFORCED copy', () => { + // The card's measurement, flipped: `FieldOperatorsSchema.safeParse({ + // $between: [1, ''] })` answered `success: true`. + const result = FieldOperatorsSchema.safeParse({ $between: [1, ''] }); + expect(result.success).toBe(false); + const issue = issuesOf(result)[0]; + expect(issue?.path).toEqual(['$between', 1]); + expect(issue?.message).toContain('$between endpoint at index 1'); + expect(issue?.message).toContain('MAX'); + }); + + it('names the MIN side when the LOWER bound is the blank one', () => { + const result = RangeOperatorSchema.safeParse({ $between: ['', '2026-12-31'] }); + expect(result.success).toBe(false); + const issue = issuesOf(result)[0]; + expect(issue?.path).toEqual(['$between', 0]); + expect(issue?.message).toContain('$between endpoint at index 0'); + expect(issue?.message).toContain('MIN'); + }); + + it('reports BOTH sides when both are blank, each at its own path', () => { + const result = RangeOperatorSchema.safeParse({ $between: ['', ''] }); + expect(result.success).toBe(false); + expect(issuesOf(result).map((i) => i.path)).toEqual([['$between', 0], ['$between', 1]]); + }); + + it('prescribes the scalar comparison for a genuinely one-sided bound', () => { + // A half-filled range is not a range; the message must send the author to + // the operator that expresses what they actually have. + const message = issuesOf(RangeOperatorSchema.safeParse({ $between: ['2026-01-01', ''] }))[0]?.message ?? ''; + expect(message).toContain('{"$gte": min}'); + expect(message).toContain('{"$lte": max}'); + expect(message).toContain('Ruled 2026-09-17'); + }); + + it('refuses an ABSENT bound with the pointed message, not zod\'s generic union text', () => { + const result = RangeOperatorSchema.safeParse({ $between: [1, undefined] }); + expect(result.success).toBe(false); + const issue = issuesOf(result)[0]; + expect(issue?.path).toEqual(['$between', 1]); + expect(issue?.message).toContain('$between endpoint at index 1'); + expect(issue?.message).not.toBe('Invalid input'); + }); + + it('leaves the null bound on the 2026-08-31 ruling\'s own message', () => { + // Two blank spellings, two rulings. If this ever went red the null author + // would be sent to a scalar comparison instead of the null predicate. + const message = issuesOf(RangeOperatorSchema.safeParse({ $between: [null, '2026-12-31'] }))[0]?.message ?? ''; + expect(message).toContain('{"$null": true}'); + expect(message).not.toContain('Ruled 2026-09-17'); + }); + + it('is matched by the enforced copy and by the whole-filter face', () => { + expect(FieldOperatorsSchema.safeParse({ $between: ['', 65] }).success).toBe(false); + expect(NormalizedFilterSchema.safeParse({ + $and: [{ close_date: { $between: ['2026-01-01', ''] } }], + }).success).toBe(false); + }); + + /** + * `FilterConditionSchema` is `z.record(z.string(), z.unknown())` at every + * field position, so it judges no comparand at all — measured here against + * the ALREADY-RULED `{ $field }` endpoint (#7596), which it also lets + * through. That control is the point: the green below is this schema's + * standing shape and NOT a hole this narrowing opened, and the enforcement + * lives where it always did (`FieldOperatorsSchema` / the normalized AST). + */ + it('is not judged by the loose FilterConditionSchema — and neither is the #7596 shape', () => { + expect(FilterConditionSchema.safeParse({ age: { $between: [18, ''] } }).success).toBe(true); + expect(FilterConditionSchema.safeParse({ + age: { $between: [18, { $field: 'cap' }] }, + }).success).toBe(true); + }); + + it('narrows the blank endpoint and NOTHING wider — the falsy and short values stay', () => { + // Positive controls: every one of these is a real endpoint, and a red + // here would mean the check is reading falsiness instead of blankness. + expect(RangeOperatorSchema.safeParse({ $between: [0, 100] }).success).toBe(true); + expect(RangeOperatorSchema.safeParse({ $between: ['0', '9'] }).success).toBe(true); + expect(RangeOperatorSchema.safeParse({ $between: ['A', 'M'] }).success).toBe(true); + expect(RangeOperatorSchema.safeParse({ $between: ['08:00:00', '18:00:00'] }).success).toBe(true); + expect(FieldOperatorsSchema.safeParse({ $between: ['2026-01-01', '2026-12-31'] }).success) + .toBe(true); + // Whitespace-only is deliberately NOT judged — the ruling is the empty + // string, and this assertion is what keeps a later reader from widening + // it without a ruling of their own. + expect(RangeOperatorSchema.safeParse({ $between: [' ', 'M'] }).success).toBe(true); + }); + + it('leaves the SET slots taking an empty-string member — the ruling is $between only', () => { + expect(FieldOperatorsSchema.safeParse({ $in: ['', 'won'] }).success).toBe(true); + expect(FieldOperatorsSchema.safeParse({ $nin: [''] }).success).toBe(true); + expect(FieldOperatorsSchema.safeParse({ $eq: '' }).success).toBe(true); + expect(FieldOperatorsSchema.safeParse({ $gte: '' }).success).toBe(true); + }); + }); }); // ============================================================================ diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 8610e8ddf0..d17f958abb 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -578,7 +578,11 @@ export const SetOperatorSchema = lazySchema(() => z.object({ * sentence is in {@link RangeOperatorSchema}'s docblock. */ const RANGE_ENDPOINT_DESCRIPTION = - 'Closed interval [min, max]. Each endpoint is a number, a Date, or a string. ' + 'Closed interval [min, max]. Each endpoint is a number, a Date, or a string, and ' + + 'BOTH are required NON-BLANK: an empty string, null and undefined are refused, ' + + 'and the refusal names the blank side. A range bounded on ONE side only is not a ' + + '$between at all — write the side you have as a scalar comparison ' + + '($gte for a lower bound, $lte for an upper one). ' + 'A { $field } reference is NOT an endpoint shape: no backend resolves one ' + 'inside a list — put it in a scalar comparison ' + '($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. ' @@ -679,9 +683,45 @@ const RANGE_ENDPOINT_DESCRIPTION = * nothing, at every backend. */ /** - * [#7596] One `$between` endpoint, with the `{ $field }` shape ruled out. + * [#18012] The author-facing refusal for a BLANK `$between` endpoint — the + * empty string and `undefined`, at either bound. Ruled 2026-09-17 (decision + * batch #146 item 5, letter A): both endpoints present and non-empty. * - * ## Why the refusal rides on the union's `error` and not on a `superRefine` + * ## Why the message names the SIDE + * + * The only measured producer of this shape is a builder padding a HALF-TYPED + * pair, so what arrives is two endpoints of which exactly one means anything — + * and the author, who typed one bound and watched the row turn complete, is the + * one person who cannot see which. Naming `MIN` / `MAX` and the index makes the + * half that is missing the subject of the sentence. + * + * ## `null` is deliberately NOT routed here + * + * `null` is blank too, and it is already refused — by the 2026-08-31 ruling's + * own pointed message ({@link nullListComparandMemberMessage}), which + * prescribes the NULL PREDICATE because an author who wrote `null` was + * reaching for absence. An author who left a bound empty was reaching for a + * bound. Two blank spellings, two intents, two remedies; ⛔ do not unify them. + */ +function blankRangeBoundMessage(index: 0 | 1): string { + const side = index === 0 ? 'MIN' : 'MAX'; + return ( + `A blank value is not a valid $between endpoint at index ${index} (the ${side} bound). ` + + 'A closed interval [min, max] requires BOTH endpoints present and non-empty: an empty ' + + 'string is not an interval endpoint at any backend — it is compared as a value, so the ' + + 'range stops bounding on that side while still reading as a complete range. Write the ' + + 'bound you meant; and if only ONE side is genuinely bounded, that is not a range at all ' + + '— drop $between and write the side you have as a scalar comparison ' + + '({"$gte": min} for a lower bound, {"$lte": max} for an upper one). ' + + 'Ruled 2026-09-17: a blank $between bound is refused at the validation entrance.' + ); +} + +/** + * [#7596] One `$between` endpoint, with the `{ $field }` shape ruled out — and, + * since the 2026-09-17 ruling (#18012), the BLANK endpoint likewise. + * + * ## Why the union's `error` carries three of the four refusals * * Measured on zod 4.4.3: a check attached to the TUPLE does not run once an * ELEMENT has failed, so a tuple-level refinement could never see the endpoint @@ -689,9 +729,19 @@ const RANGE_ENDPOINT_DESCRIPTION = * `invalid_union` / "Invalid input" and nothing else. The union's own `error` * callback runs exactly when the union rejects and sees the offending input, so * it replaces that generic text with {@link listPositionFieldReferenceMessage} - * for this one shape and returns `undefined` for every other rejection, leaving - * zod's default wording — and, importantly, the issue's `code` and `path` — - * untouched for the endpoint shapes that were already invalid. + * or {@link blankRangeBoundMessage} for those shapes and returns `undefined` + * for every other rejection, leaving zod's default wording — and, importantly, + * the issue's `code` and `path` — untouched for the endpoint shapes that were + * already invalid. + * + * ## Why the EMPTY STRING is the one that needs a check + * + * `null`, `undefined` and `{ $field }` never passed the union; for all three + * the ruling adds only a POINTED SENTENCE. `''` is a string and the union + * ACCEPTS it, so the empty-string arm is the one place where #18012 changes + * what parses. It rides an ELEMENT-level `superRefine` — not the tuple-level + * one the paragraph above rules out — which runs exactly when this endpoint + * passed the union, i.e. precisely when there is an `''` to report. * * `index` is baked in per endpoint rather than read from the issue: at the time * the union reports, the path is still relative to the union itself and the @@ -706,9 +756,19 @@ const rangeEndpointSchema = (index: 0 | 1) => // mechanism the `{ $field }` shape uses one line down. issue.input === null ? nullListComparandMemberMessage(`$between endpoint at index ${index}`) - : isFieldReferenceShape(issue.input) - ? listPositionFieldReferenceMessage(`$between endpoint at index ${index}`) - : undefined, + // [#18012] `undefined` never passed it either — an absent bound is the + // same replace-only substitution, pointed at the side that is missing. + : issue.input === undefined + ? blankRangeBoundMessage(index) + : isFieldReferenceShape(issue.input) + ? listPositionFieldReferenceMessage(`$between endpoint at index ${index}`) + : undefined, + }).superRefine((endpoint, ctx) => { + // [#18012] The empty string is the one blank spelling the union accepts. + // ⛔ Not a trim and not a whitespace rule: the ruling is the empty string, + // and widening it here would narrow a published face further than ruled. + if (endpoint !== '') return; + ctx.addIssue({ code: 'custom', message: blankRangeBoundMessage(index) }); }); /** diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-between-blank-endpoint-refused.ts b/packages/spec/src/migrations/entries/semantic/18.filter-between-blank-endpoint-refused.ts new file mode 100644 index 0000000000..ac14705ca0 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.filter-between-blank-endpoint-refused.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The authoring-door half of the class #13495 ruled at the matcher. That card +// taught driver-memory what to do with a missing bound; this one decides what +// the SCHEMA does with one. +export const entry: SemanticMigration = { + id: 'filter-between-blank-endpoint-refused', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span already, and a nested backtick would close it. + surface: + 'either endpoint of a $between range, authored BLANK — the empty string, or an absent ' + + '(undefined) bound — on any carrier of FieldOperatorsSchema / RangeOperatorSchema: a view ' + + 'or dashboard widget filter, a dataset filter, a report runtimeFilter, a page or component ' + + 'filter, a rollup filter, and the NormalizedFilter AST the query faces validate against. ' + + 'ARITY is not what changed: a blank bound is a well-formed TWO-element range one of whose ' + + 'elements means nothing', + replacement: + 'two endpoints that are present and non-empty — the bound the author meant, written out. ' + + 'If only ONE side is genuinely bounded, that is not a range at all: drop `$between` and ' + + 'write the side you have as a scalar comparison, `{"$gte": min}` for a lower bound and ' + + '`{"$lte": max}` for an upper one, which every backend already answers. ⛔ There is no ' + + 'replacement that can be DERIVED from what was written: the bound the author did not type ' + + 'is not recoverable from the one they did, and picking either reading (drop the operator, ' + + 'or treat the blank side as unbounded) would be the platform inventing a filter. `null` ' + + 'bounds are a different entry: they were already refused by the 2026-08-31 ruling, whose ' + + 'message prescribes the null predicate because a `null` author was reaching for absence, ' + + 'not for a bound', + reason: + 'Maintainer ruling A on #18012 (decision batch #146 item 5, 2026-09-17 「146 同意」). ' + + '`FieldOperatorsSchema.safeParse({ $between: [1, \'\'] })` answered `success: true` — ' + + 'measured on the card against the installed spec 17.4.0 and re-measured on `origin/main` ' + + 'before the change. This is a NEW RULE narrowing a published face, ⛔ not a pull-back to a ' + + 'declared one: the endpoint contract shared by both bounds says verbatim that "Each ' + + 'endpoint is a number, a Date, or a string", and the empty string is a string, so the ' + + 'acceptance was conformant. What made it wrong is the other half of the same contract — ' + + '"Closed interval [min, max]" — which no backend can honour against a blank: driver-sql ' + + 'binds it into `whereBetween`, the JS matchers compare it as a value, and the range stops ' + + 'bounding on that side while still reading as a complete range. #13495 had already taught ' + + 'the reference matcher to survive the null-bound form of exactly this (a bounded range ' + + 'answered EVERY valued row, because both of the arm\'s comparisons are false against a ' + + 'missing bound); the door that admitted it was never addressed. The only producer ever ' + + 'measured is a UI builder padding a HALF-TYPED pair with `\'\'` so that a length-based ' + + 'completeness check passes it — nobody WANTS a blank bound, which is why it is refused ' + + 'rather than given a published meaning (option B was declined: a semantics nobody asked ' + + 'for, to be honoured per driver). The refusal names the blank SIDE (MIN / MAX plus the ' + + 'index) because with a padded pair both bounds are present and the author is the one ' + + 'person who cannot see which is empty. Scope is the empty string and `undefined` and ' + + 'nothing wider: whitespace-only endpoints are deliberately NOT judged, since narrowing a ' + + 'published face further than the ruling is the seat call this card\'s whole history ' + + 'refuses to make. Ships at once, no grace window and no dual spelling (2026-08-27 ' + + 'maintainer ruling 「短期不考虑渐进」). ' + + '⚠️ No D2 conversion and no stored-metadata rewrite, and the load path was MEASURED rather ' + + 'than assumed: `applyConversionsToStoredItem` — the one primitive every stored-row ' + + 'rehydration seam calls — never throws and never validates, and replays only the ' + + 'positively-recognised lossless transforms in the conversion registry; measured on ' + + '`origin/main`, a stored view carrying `{ close_date: { $between: [\'2026-01-01\', \'\'] } }` ' + + 'comes back as the SAME object reference. So the load path today neither drops a refused ' + + 'operator nor refuses the row, and no conversion in the registry drops a filter OPERATOR ' + + '(the three filter-adjacent entries are key strips and a key rename). That is also the ' + + 'precedent the two nearest narrowings of this same surface set — ' + + '`filter-preset-ordering-comparand-refused` and ' + + '`analytics-date-range-array-two-bounds-required` — both of which decline a D2 conversion ' + + 'on the ground that rewriting would be the platform guessing which bound was meant. ' + + 'Dropping the operator would be worse than guessing: it deletes a constraint the author ' + + 'wrote and WIDENS the result set silently, the failure mode `$nin` carries in the same ' + + 'file. The read path does not re-validate stored rows, so no stored view becomes ' + + 'unreadable; what changes is that RE-SAVING one is refused, at the key\'s own path, with ' + + 'the blank side named. The objectui half — the builder stops padding a half-typed pair, so ' + + 'the console never meets this refusal mid-typing — is objectui#9695 and lands on its own ' + + 'schedule, either side of this one. ADR-0049 / ADR-0078 / ADR-0087.', + acceptanceCriteria: + 'Grep every authored `$between` array — view and dashboard widget filters, dataset filters, ' + + 'report runtimeFilters, page and component filters, rollup filters, saved AST filters, SDK ' + + 'and MCP callers — and read BOTH of its elements. A range with two present, non-empty ' + + 'endpoints parses byte-identically to before, numbers, Dates, ISO days, UTC instants, ' + + 'clock times and non-temporal text included, and `[\'0\', \'9\']` and `[0, 100]` are ' + + 'untouched (the rule is blankness, not falsiness). An empty-string or absent bound now ' + + 'answers one prescriptive issue at that endpoint\'s own path (`$between.0` / `$between.1`) ' + + 'naming MIN or MAX, so `FieldOperatorsSchema.safeParse` and re-saving the document both ' + + 'make the sweep mechanical; a range blank on BOTH sides reports both positions. Nothing is ' + + 'normalised on the way through — no bound is trimmed, defaulted or copied from its ' + + 'neighbour — so an accepted range arrives byte-identical to what was written. ⚠️ Do not ' + + 'assume a converted range was previously showing the window it named: a blank bound stopped ' + + 'bounding on that side at every backend, so the surface was reading a wider set than its ' + + 'filter claimed. Decide the window from what the surface was SUPPOSED to show, and if only ' + + 'one side was ever meant, write it as `$gte` / `$lte` rather than inventing a second bound. ' + + '`null` bounds are unaffected by this entry and keep their own refusal and prescription.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 541ea75d87..00b32579a5 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -8216,6 +8216,92 @@ const step18: MigrationStep = { + 'malformed value keep loading (the rehydration seam replays the conversion, which drops ' + 'the meaningless key).', }, + // The authoring-door half of the class #13495 ruled at the matcher. That card + // taught driver-memory what to do with a missing bound; this one decides what + // the SCHEMA does with one. + { + id: 'filter-between-blank-endpoint-refused', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span already, and a nested backtick would close it. + surface: + 'either endpoint of a $between range, authored BLANK — the empty string, or an absent ' + + '(undefined) bound — on any carrier of FieldOperatorsSchema / RangeOperatorSchema: a view ' + + 'or dashboard widget filter, a dataset filter, a report runtimeFilter, a page or component ' + + 'filter, a rollup filter, and the NormalizedFilter AST the query faces validate against. ' + + 'ARITY is not what changed: a blank bound is a well-formed TWO-element range one of whose ' + + 'elements means nothing', + replacement: + 'two endpoints that are present and non-empty — the bound the author meant, written out. ' + + 'If only ONE side is genuinely bounded, that is not a range at all: drop `$between` and ' + + 'write the side you have as a scalar comparison, `{"$gte": min}` for a lower bound and ' + + '`{"$lte": max}` for an upper one, which every backend already answers. ⛔ There is no ' + + 'replacement that can be DERIVED from what was written: the bound the author did not type ' + + 'is not recoverable from the one they did, and picking either reading (drop the operator, ' + + 'or treat the blank side as unbounded) would be the platform inventing a filter. `null` ' + + 'bounds are a different entry: they were already refused by the 2026-08-31 ruling, whose ' + + 'message prescribes the null predicate because a `null` author was reaching for absence, ' + + 'not for a bound', + reason: + 'Maintainer ruling A on #18012 (decision batch #146 item 5, 2026-09-17 「146 同意」). ' + + '`FieldOperatorsSchema.safeParse({ $between: [1, \'\'] })` answered `success: true` — ' + + 'measured on the card against the installed spec 17.4.0 and re-measured on `origin/main` ' + + 'before the change. This is a NEW RULE narrowing a published face, ⛔ not a pull-back to a ' + + 'declared one: the endpoint contract shared by both bounds says verbatim that "Each ' + + 'endpoint is a number, a Date, or a string", and the empty string is a string, so the ' + + 'acceptance was conformant. What made it wrong is the other half of the same contract — ' + + '"Closed interval [min, max]" — which no backend can honour against a blank: driver-sql ' + + 'binds it into `whereBetween`, the JS matchers compare it as a value, and the range stops ' + + 'bounding on that side while still reading as a complete range. #13495 had already taught ' + + 'the reference matcher to survive the null-bound form of exactly this (a bounded range ' + + 'answered EVERY valued row, because both of the arm\'s comparisons are false against a ' + + 'missing bound); the door that admitted it was never addressed. The only producer ever ' + + 'measured is a UI builder padding a HALF-TYPED pair with `\'\'` so that a length-based ' + + 'completeness check passes it — nobody WANTS a blank bound, which is why it is refused ' + + 'rather than given a published meaning (option B was declined: a semantics nobody asked ' + + 'for, to be honoured per driver). The refusal names the blank SIDE (MIN / MAX plus the ' + + 'index) because with a padded pair both bounds are present and the author is the one ' + + 'person who cannot see which is empty. Scope is the empty string and `undefined` and ' + + 'nothing wider: whitespace-only endpoints are deliberately NOT judged, since narrowing a ' + + 'published face further than the ruling is the seat call this card\'s whole history ' + + 'refuses to make. Ships at once, no grace window and no dual spelling (2026-08-27 ' + + 'maintainer ruling 「短期不考虑渐进」). ' + + '⚠️ No D2 conversion and no stored-metadata rewrite, and the load path was MEASURED rather ' + + 'than assumed: `applyConversionsToStoredItem` — the one primitive every stored-row ' + + 'rehydration seam calls — never throws and never validates, and replays only the ' + + 'positively-recognised lossless transforms in the conversion registry; measured on ' + + '`origin/main`, a stored view carrying `{ close_date: { $between: [\'2026-01-01\', \'\'] } }` ' + + 'comes back as the SAME object reference. So the load path today neither drops a refused ' + + 'operator nor refuses the row, and no conversion in the registry drops a filter OPERATOR ' + + '(the three filter-adjacent entries are key strips and a key rename). That is also the ' + + 'precedent the two nearest narrowings of this same surface set — ' + + '`filter-preset-ordering-comparand-refused` and ' + + '`analytics-date-range-array-two-bounds-required` — both of which decline a D2 conversion ' + + 'on the ground that rewriting would be the platform guessing which bound was meant. ' + + 'Dropping the operator would be worse than guessing: it deletes a constraint the author ' + + 'wrote and WIDENS the result set silently, the failure mode `$nin` carries in the same ' + + 'file. The read path does not re-validate stored rows, so no stored view becomes ' + + 'unreadable; what changes is that RE-SAVING one is refused, at the key\'s own path, with ' + + 'the blank side named. The objectui half — the builder stops padding a half-typed pair, so ' + + 'the console never meets this refusal mid-typing — is objectui#9695 and lands on its own ' + + 'schedule, either side of this one. ADR-0049 / ADR-0078 / ADR-0087.', + acceptanceCriteria: + 'Grep every authored `$between` array — view and dashboard widget filters, dataset filters, ' + + 'report runtimeFilters, page and component filters, rollup filters, saved AST filters, SDK ' + + 'and MCP callers — and read BOTH of its elements. A range with two present, non-empty ' + + 'endpoints parses byte-identically to before, numbers, Dates, ISO days, UTC instants, ' + + 'clock times and non-temporal text included, and `[\'0\', \'9\']` and `[0, 100]` are ' + + 'untouched (the rule is blankness, not falsiness). An empty-string or absent bound now ' + + 'answers one prescriptive issue at that endpoint\'s own path (`$between.0` / `$between.1`) ' + + 'naming MIN or MAX, so `FieldOperatorsSchema.safeParse` and re-saving the document both ' + + 'make the sweep mechanical; a range blank on BOTH sides reports both positions. Nothing is ' + + 'normalised on the way through — no bound is trimmed, defaulted or copied from its ' + + 'neighbour — so an accepted range arrives byte-identical to what was written. ⚠️ Do not ' + + 'assume a converted range was previously showing the window it named: a blank bound stopped ' + + 'bounding on that side at every backend, so the surface was reading a wider set than its ' + + 'filter claimed. Decide the window from what the surface was SUPPOSED to show, and if only ' + + 'one side was ever meant, write it as `$gte` / `$lte` rather than inventing a second bound. ' + + '`null` bounds are unaffected by this entry and keep their own refusal and prescription.', + }, { id: 'filter-preset-ordering-comparand-refused', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a