diff --git a/.changeset/20749-spec-strings-stage6-conformance-notes.md b/.changeset/20749-spec-strings-stage6-conformance-notes.md new file mode 100644 index 0000000000..2ee23826ce --- /dev/null +++ b/.changeset/20749-spec-strings-stage6-conformance-notes.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': patch +--- + +The shared conformance tables' case notes and names no longer cite tracker numbers; each one states the decision behind it in words + +Clause-②: no + +The conformance tables in `@objectstack/spec` (`FILTER_LOGIC_CASES`, `FILTER_TEXT_CASES`, `FILTER_COMPARAND_TYPE_CASES`, `AGGREGATION_CASES`, `TEMPORAL_ROWS` / `TEMPORAL_CASES` / `TEMPORAL_TIME_CASES`, `VALUE_ROUNDTRIP_CASES`, `TEXT_OPERATOR_DOOR_TYPE_CLASSES` and `METADATA_ROUNDTRIP_CASES`) are what every driver, and any third-party implementation, is measured against. A case's `note` or `why` is printed when that case fails, and some drivers print it in the test title. Fifty-eight of those texts pointed at an issue-tracker number for the reason a case exists. The number goes; where the sentence did not already say what was decided, it now does. For example: + +- The four empty-combinator cases say every face reduces an empty combinator to its boolean identity, and why `{}` and `$not: {}` follow from it. +- The no-value cases say `$ne`, `$nin`, `$notContains` and `$not` are NULL-safe on every face, and that `$exists` means "has a value" because SQL cannot tell a missing key from a stored null. +- The boolean-aggregand cases say a boolean is worth 1 or 0 on every face, including for `min` / `max`, and that this ruling superseded an earlier `false` / `true` answer. +- The `$empty` cases say every face answers `$empty` by the field's declared type. + +Six case names change with them: `icontains (the infix/view spelling, ruled never an alias of ilike) lowers to $icontains — …` in `FILTER_TEXT_CASES`, and five names in `FILTER_COMPARAND_TYPE_CASES` (the control cell, the bigint crash cell, and the three array-in-the-equality-slot refusals, which now say they are refused at the shared face). + +Text only: no case's filter, input, expected rows, verdict, error code, order or count changes, and no export, type or schema moves. A tool or test that selects or pins a case by its old note or name (for example by a tracker-number substring) needs the new spelling. diff --git a/packages/lint/src/validate-empty-combinators.test.ts b/packages/lint/src/validate-empty-combinators.test.ts index 0b9e482954..0c21fda799 100644 --- a/packages/lint/src/validate-empty-combinators.test.ts +++ b/packages/lint/src/validate-empty-combinators.test.ts @@ -272,7 +272,7 @@ describe('validateEmptyCombinators — the vocabulary is the runtime\'s (#5322/# * retyped: `filter-logic-conformance.ts` is what every backend is measured * against, so a message derived from anything else would be a second opinion. */ - const identityCases = FILTER_LOGIC_CASES.filter((c) => (c.note ?? '').includes('#5322')); + const identityCases = FILTER_LOGIC_CASES.filter((c) => (c.note ?? '').includes('every face reduces an empty combinator to its boolean identity')); it('the identity cases are still findable in the conformance table', () => { // Guarded body: if the selection ever returns nothing, the loop below would diff --git a/packages/services/service-analytics/src/__tests__/icontains-dialect-sql.test.ts b/packages/services/service-analytics/src/__tests__/icontains-dialect-sql.test.ts index 6c52d5d9dd..3d2202361b 100644 --- a/packages/services/service-analytics/src/__tests__/icontains-dialect-sql.test.ts +++ b/packages/services/service-analytics/src/__tests__/icontains-dialect-sql.test.ts @@ -366,7 +366,7 @@ describe('[#15780] the three compilers, EXECUTED on a real SQLite engine', () => 'ASCII-only: a lower-case non-ASCII comparand does NOT match its upper-case row', 'ASCII-only: an upper-case non-ASCII comparand does NOT match its lower-case row', '$icontains treats % as a literal character, not a LIKE wildcard', - 'icontains (the infix/view spelling, #8934) lowers to $icontains — % stays a LITERAL through that door too', + 'icontains (the infix/view spelling, ruled never an alias of ilike) lowers to $icontains — % stays a LITERAL through that door too', '$icontains treats _ as a literal character, not a single-character wildcard', '$icontains treats . as a literal character, not a regex metacharacter', ]); diff --git a/packages/spec/src/contracts/metadata-service-roundtrip-conformance.ts b/packages/spec/src/contracts/metadata-service-roundtrip-conformance.ts index b1da15a03e..73a3f1e264 100644 --- a/packages/spec/src/contracts/metadata-service-roundtrip-conformance.ts +++ b/packages/spec/src/contracts/metadata-service-roundtrip-conformance.ts @@ -191,7 +191,7 @@ export const METADATA_ROUNDTRIP_CASES: readonly MetadataRoundTripCase[] = [ writes: [{ type: 'object', name: 'pin_account', data: PIN_ACCOUNT }], read: { type: 'object', name: 'pin_account' }, expected: { kind: 'readable', document: PIN_ACCOUNT }, - why: 'The contract\'s first two CRUD members, on the type whose reads are special-cased. This is the row #6725 would have failed.', + why: 'The contract\'s first two CRUD members, on the type whose reads are special-cased. This is the row that catches an object write landing in a store none of the object reads consult — the hole a shipped implementation once fell through.', }, { id: 'nonobject-roundtrip', @@ -199,7 +199,7 @@ export const METADATA_ROUNDTRIP_CASES: readonly MetadataRoundTripCase[] = [ writes: [{ type: 'view', name: 'pin_grid', data: PIN_GRID }], read: { type: 'view', name: 'pin_grid' }, expected: { kind: 'readable', document: PIN_GRID }, - why: 'The generic type store — NOT special-cased on the read side of SchemaRegistry, which is the asymmetry that produced #6725. An implementation can pass one of these two rows and fail the other.', + why: 'The generic type store — NOT special-cased on the read side of SchemaRegistry, which is the asymmetry that let an object write land in the generic store while every object read looked elsewhere. An implementation can pass one of these two rows and fail the other.', }, { id: 'get-before-register-object', @@ -277,7 +277,7 @@ export const METADATA_ROUNDTRIP_CASES: readonly MetadataRoundTripCase[] = [ writes: [{ type: 'object', name: 'pin_key', data: PIN_KEYED_OBJECT }], read: { type: 'object', name: 'pin_key' }, expected: { kind: 'refused' }, - why: 'Whether `name` or `data.name` is the key is the whole round-trip. **Ruled** (#7378, maintainer 2026-08-12, row 1, superseding the 2026-08-11 option (a) this row used to state): a disagreement is refused loudly — it is almost always an authoring bug, and silent resolution in EITHER direction can file the item under a key the author never wrote. A document with no `name` of its own still registers under the argument; the objectql driver pins that boundary.', + why: 'Whether `name` or `data.name` is the key is the whole round-trip. **Ruled** (maintainer 2026-08-12, row 1 of the round-trip ruling, superseding the 2026-08-11 option (a) this row used to state): a disagreement is refused loudly — it is almost always an authoring bug, and silent resolution in EITHER direction can file the item under a key the author never wrote. A document with no `name` of its own still registers under the argument; the objectql driver pins that boundary.', }, { id: 'data-name-mismatch-refused-nonobject', @@ -293,7 +293,7 @@ export const METADATA_ROUNDTRIP_CASES: readonly MetadataRoundTripCase[] = [ writes: [{ type: 'objects', name: 'pin_plural', data: PIN_PLURAL }], read: { type: 'object', name: 'pin_plural' }, expected: { kind: 'readable', document: PIN_PLURAL }, - why: "The two spellings of the object type address ONE store. **Ruled** (#7378, maintainer 2026-08-12, row 2): every implementation gives one answer, converged with `check:meta-type-normalized`'s enforced direction — the type folds plural→singular (`PLURAL_TO_SINGULAR`, `../shared`) before any store decision. This row was `plural-objects-type-is-its-own-store` (expected `absent`) while the answer was still a measured divergence; the reverse read direction is pinned driver-locally in objectql.", + why: "The two spellings of the object type address ONE store. **Ruled** (maintainer 2026-08-12, row 2 of the round-trip ruling): every implementation gives one answer, converged with `check:meta-type-normalized`'s enforced direction — the type folds plural→singular (`PLURAL_TO_SINGULAR`, `../shared`) before any store decision. This row was `plural-objects-type-is-its-own-store` (expected `absent`) while the answer was still a measured divergence; the reverse read direction is pinned driver-locally in objectql.", }, { id: 'primitive-data-refused', @@ -301,7 +301,7 @@ export const METADATA_ROUNDTRIP_CASES: readonly MetadataRoundTripCase[] = [ writes: [{ type: 'setting', name: 'pin_flag', data: 'enabled' }], read: { type: 'setting', name: 'pin_flag' }, expected: { kind: 'refused' }, - why: '`data` is declared `unknown`, not `object`, so this is a runtime refusal (#7378, maintainer 2026-08-12, row 3): a value the service cannot key was measured as accept-then-drop — written, then readable back through NO member — which is indefensible; and coercing it into storability is equally forbidden. The ruling fixes 「接受再丢」, it does not demand 「必须存下」.', + why: '`data` is declared `unknown`, not `object`, so this is a runtime refusal (maintainer 2026-08-12, row 3 of the round-trip ruling): a value the service cannot key was measured as accept-then-drop — written, then readable back through NO member — which is indefensible; and coercing it into storability is equally forbidden. The ruling fixes 「接受再丢」, it does not demand 「必须存下」.', }, { id: 'array-data-refused', diff --git a/packages/spec/src/data/aggregation-conformance.ts b/packages/spec/src/data/aggregation-conformance.ts index 5399f82c2e..e06bc4403b 100644 --- a/packages/spec/src/data/aggregation-conformance.ts +++ b/packages/spec/src/data/aggregation-conformance.ts @@ -339,7 +339,8 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [ field: 'stage', expected: [{ group: null, value: 2 }], note: - '#6409: `won` twice collapses to one and the two nulls contribute nothing — ' + 'count_distinct was kept and enforced on every face, as distinct non-null values: ' + + '`won` twice collapses to one and the two nulls contribute nothing — ' + '4 means the dedup was dropped, 3 means NULL was counted as a value, ' + '6 means the lowering fell through to count(*).', }, @@ -395,8 +396,10 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [ field: 'flag', expected: [{ group: null, value: 3 }], note: - '#11065/#11151: an arithmetic accumulator that drops booleans answers its ' - + 'identity 0 here — a plausible number, which is why this case exists.', + 'A boolean aggregand is worth 1 or 0 on every face — driver-memory and then ' + + 'driver-mongodb were both moved onto that answer: an arithmetic accumulator ' + + 'that drops booleans answers its identity 0 here — a plausible number, which ' + + 'is why this case exists.', }, { name: 'avg(flag) is the true-rate', @@ -404,8 +407,9 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [ field: 'flag', expected: [{ group: null, value: 0.5 }], note: - '#11065: the rate-over-a-flag-column shape (an SLA-violation rate, a win ' - + 'rate). A face that drops booleans answers null — a blank tile, ' + 'The rate-over-a-flag-column shape (an SLA-violation rate, a win rate) ' + + 'that driver-memory answered null for until it counted a boolean as 1 or 0. ' + + 'A face that drops booleans answers null — a blank tile, ' + 'indistinguishable from "no matching rows".', }, { @@ -414,9 +418,10 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [ field: 'flag', expected: [{ group: null, value: 0 }], note: - '#11152 ruling (2026-08-28): booleans aggregate as numbers with no ' + 'Ruled 2026-08-28: booleans aggregate as numbers with no ' + 'per-aggregate exception, so the order statistics answer 0/1 in the ' - + 'same domain sum/avg answer in — not false/true (#11249, superseded).', + + 'same domain sum/avg answer in — not the false/true an earlier ruling ' + + 'had chosen, which this one superseded.', }, { name: 'max(flag) answers the NUMBER 1', @@ -532,7 +537,8 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [ { group: 'west', value: 40 }, ], note: - '#15546: `east` has two rows and nothing to add. A face that hands SQL\'s ' + 'Ruled: a non-empty group whose aggregand is NULL in every row sums to 0. ' + + '`east` has two rows and nothing to add. A face that hands SQL\'s ' + 'NULL through answers null here (a blank tile, indistinguishable from ' + '"not computed"), and `west` at 40 keeps a face that folded EVERY sum ' + 'to 0 from passing.', @@ -547,7 +553,7 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [ { group: 'west', value: 2 }, ], note: - '#15546: the reachability control for the case above. COUNT(col) is ' + 'The reachability control for the all-NULL-sums-to-0 case above. COUNT(col) is ' + 'defined over non-null values on every backend, so `east` at 0 proves ' + 'the seed stored NULLs — a harness that wrote 0 in place of a null ' + 'answers 2 here and turns the sum cell green for the wrong reason.', @@ -558,7 +564,7 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [ field: 'amount', expected: [{ group: null, value: 40 }], note: - '#15546: the partial-null control. Four nulls among six rows contribute ' + 'The partial-null control for the same ruling. Four nulls among six rows contribute ' + 'nothing and the two values add to 40 on every face — the case that was ' + 'always green, which is why the all-null cell went unmeasured.', }, @@ -574,7 +580,9 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [ { group: 'west', value: 4 }, ], note: - '#6401: the VALUES are the `count(*) grouped by region` case verbatim — only ' + 'A structured group node\'s `alias` names the projected group column on every ' + + 'face — the SQL faces that ignored it were made to honour it. ' + + 'The VALUES are the `count(*) grouped by region` case verbatim — only ' + 'the key moves. A face that ignores `alias` returns the same two numbers ' + 'under `region`, so this case can only fail on the KEY, which is the whole ' + 'point: every wrong answer here is a valid query returning plausible rows. ' @@ -593,7 +601,8 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [ { group: 'west', value: 100 }, ], note: - '#6401: the degenerate alias. Its twin above cannot see a face that emits ' + 'The degenerate alias, under the same every-face `alias` rule. Its twin above ' + + 'cannot see a face that emits ' + '`"region" AS "region"` and breaks on the self-rename, and a face that ' + 'special-cases `alias === field` needs the case that exercises the ' + 'special case.', diff --git a/packages/spec/src/data/filter-comparand-type-conformance.ts b/packages/spec/src/data/filter-comparand-type-conformance.ts index 4f8e9d99f0..50de82ab71 100644 --- a/packages/spec/src/data/filter-comparand-type-conformance.ts +++ b/packages/spec/src/data/filter-comparand-type-conformance.ts @@ -150,14 +150,14 @@ export const FILTER_COMPARAND_TYPE_CASES: readonly ComparandTypeCase[] = [ expected: ['2'], }, { - name: 'number compiles and matches — the #7956 control cell', + name: 'number compiles and matches — the control cell of the cross-driver comparand-type measurement', filter: () => ({ qty: { $eq: 100 } }), verdict: 'matches', expected: ['1'], note: 'The matrix\'s control: this row returning on every driver is what made its zeros real answers.', }, { - name: 'bigint compiles and matches — the crash cell, dead (#7872)', + name: 'bigint compiles and matches — the crash cell, dead at the shared comparand door', filter: () => ({ qty: { $eq: BigInt(100) } }), verdict: 'matches', expected: ['1'], @@ -175,7 +175,7 @@ export const FILTER_COMPARAND_TYPE_CASES: readonly ComparandTypeCase[] = [ filter: () => ({ qty: { $in: [BigInt(100), 999] } }), verdict: 'matches', expected: ['1'], - note: '$in/$nin members are comparands in their own right (#5234) — the door narrows each.', + note: '$in/$nin members are comparands in their own right, each judged like a scalar comparand — the door narrows each.', }, { name: 'boolean compiles and matches — implicit-equality form', @@ -188,7 +188,7 @@ export const FILTER_COMPARAND_TYPE_CASES: readonly ComparandTypeCase[] = [ filter: () => ({ note: null }), verdict: 'matches', expected: ['1'], - note: 'null IS a comparand and IS the null predicate (#6050\'s untouched half) — the door must not confuse it with undefined.', + note: 'null IS a comparand and IS the null predicate — the half left untouched when an undefined comparand was ruled a loud refusal — so the door must not confuse it with undefined.', }, { name: 'Date compiles — row agreement is temporal-conformance\'s subject', @@ -269,7 +269,7 @@ export const FILTER_COMPARAND_TYPE_CASES: readonly ComparandTypeCase[] = [ + 'instead of deep-equality-on-two-drivers, refusal-on-three.', }, { - name: 'an ARRAY in the implicit-equality slot is refused (#19757)', + name: 'an ARRAY in the implicit-equality slot is refused at the shared face', filter: () => ({ label: ['alpha'] as unknown as string }), verdict: 'door-refusal', code: 'INVALID_FILTER', @@ -279,14 +279,14 @@ export const FILTER_COMPARAND_TYPE_CASES: readonly ComparandTypeCase[] = [ + 'it with MongoDB\'s array equality (a stored array equal to the list, or holding it as an element).', }, { - name: 'an ARRAY under $eq is refused (#19757)', + name: 'an ARRAY under $eq is refused at the shared face', filter: () => ({ label: { $eq: ['alpha'] as unknown as string } }), verdict: 'door-refusal', code: 'INVALID_FILTER', mustMention: ['Operator "$eq"', 'at where.label.$eq.', '{"$in": […]}'], }, { - name: 'an ARRAY in the equality slot is refused nested in $or too (#19757)', + name: 'an ARRAY in the equality slot is refused nested in $or too, at the shared face', filter: () => ({ $or: [{ qty: 100 }, { label: ['alpha'] as unknown as string }] }), verdict: 'door-refusal', code: 'INVALID_FILTER', diff --git a/packages/spec/src/data/filter-logic-conformance.ts b/packages/spec/src/data/filter-logic-conformance.ts index 6e141f4757..3f6adadd4a 100644 --- a/packages/spec/src/data/filter-logic-conformance.ts +++ b/packages/spec/src/data/filter-logic-conformance.ts @@ -336,7 +336,7 @@ export const FILTER_LOGIC_CASES: readonly FilterLogicCase[] = [ name: 'multi-key $or branch ANDs its own keys', filter: { $or: [{ a: 'x', b: 'y' }] }, expected: ['1'], - note: '#3774: compiled to `a = x OR b = y`, matching 1,2,3.', + note: 'A $or combines its branches, never the keys inside one: a driver that OR-ed a branch\'s own keys compiled this to `a = x OR b = y`, matching 1,2,3.', }, { name: 'each $or branch ANDs independently', @@ -359,7 +359,7 @@ export const FILTER_LOGIC_CASES: readonly FilterLogicCase[] = [ name: 'multiple operators on one field AND within a branch', filter: { $or: [{ a: { $ne: 'qq', $eq: 'x' }, b: 'y' }] }, expected: ['1'], - note: '#3774: a single-key branch is miscompilable too — the operator map is looped with the same flag.', + note: 'A single-key branch is miscompilable too — that driver looped the operator map with the same OR flag.', }, { name: 'an abutting $gte/$lt window ANDs its bounds', @@ -417,25 +417,25 @@ export const FILTER_LOGIC_CASES: readonly FilterLogicCase[] = [ name: 'empty $and is TRUE — the AND identity', filter: { $and: [] }, expected: ['1', '2', '3', '4'], - note: '#5322: a conjunction of zero conditions constrains nothing.', + note: 'Ruled: every face reduces an empty combinator to its boolean identity. A conjunction of zero conditions constrains nothing.', }, { name: 'empty $or is FALSE — the OR identity', filter: { $or: [] }, expected: [], - note: '#5322/#5134: a disjunction of zero conditions matches nothing. Fail-closed for an RLS scope — a disjunct list that loops to zero items hides every row instead of exposing the table.', + note: 'Ruled: every face reduces an empty combinator to its boolean identity. A disjunction of zero conditions matches nothing. Fail-closed for an RLS scope — a disjunct list that loops to zero items hides every row instead of exposing the table, as a SQL lowering that dropped the empty group once did.', }, { name: 'a {} branch is a TRUE disjunct and absorbs its $or', filter: { $or: [{ a: 'x' }, {}] }, expected: ['1', '2', '3', '4'], - note: '#5322: collapsing to the surviving branches instead compiles `a = x` — a silently NARROWED scope (#5297).', + note: 'Ruled: every face reduces an empty combinator to its boolean identity, so `{}` is a TRUE disjunct. Collapsing to the surviving branches instead compiles `a = x` — a silently NARROWED scope, the answer the RLS read-scope compiler gave until it was aligned.', }, { name: '$not of {} is FALSE — NOT TRUE', filter: { $not: {} }, expected: [], - note: '#5322: emitting nothing for it runs the query UNSCOPED — on an RLS lowering that is a permission bypass (#5297).', + note: 'Ruled: every face reduces an empty combinator to its boolean identity, so NOT of `{}` is FALSE. Emitting nothing for it runs the query UNSCOPED — on an RLS lowering that is a permission bypass, which the read-scope compiler was until it compiled this to an always-false clause.', }, // ── NULL / no-value semantics (#5146, #5298) ────────────────────────────── @@ -467,13 +467,13 @@ export const FILTER_LOGIC_CASES: readonly FilterLogicCase[] = [ name: '$ne returns the rows with no value', filter: { d: { $ne: 'v1' } }, expected: ['2', '3', '4'], - note: '#5298: "not v1" is true of a row whose d is absent. A three-valued `d <> ?` drops rows 3-4 — half the table, silently.', + note: 'Ruled NULL-safe on every face: "not v1" is true of a row whose d is absent. A three-valued `d <> ?` drops rows 3-4 — half the table, silently.', }, { name: '$not returns the rows with no value', filter: { $not: { d: 'v1' } }, expected: ['2', '3', '4'], - note: '#5146: the same ruling reached through the combinator. `NOT (NULL = ?)` is UNKNOWN, so an unguarded negation drops rows 3-4 — and on a CEL `!expr` read scope that is one permission rule admitting different row sets per backend.', + note: 'The same NULL-safe ruling reached through the combinator, where it was first made for `$not` itself. `NOT (NULL = ?)` is UNKNOWN, so an unguarded negation drops rows 3-4 — and on a CEL `!expr` read scope that is one permission rule admitting different row sets per backend.', }, // [#13540] The negated OPERATOR forms of the same ruling, enrollable since @@ -500,13 +500,13 @@ export const FILTER_LOGIC_CASES: readonly FilterLogicCase[] = [ name: '$nin returns the rows with no value', filter: { d: { $nin: ['v1'] } }, expected: ['2', '3', '4'], - note: '#5298 option A: the list form of `$ne` — "not one of [v1]" is true of a row whose d is absent. A three-valued `NOT IN` drops rows 3-4; the reference matcher answered this way on a stored null but not on a missing key until PR #13356.', + note: 'Ruled NULL-safe: the list form of `$ne` — "not one of [v1]" is true of a row whose d is absent. A three-valued `NOT IN` drops rows 3-4; the reference matcher answered this way on a stored null but not on a missing key until it was realigned to the ruling.', }, { name: '$notContains returns the rows with no value', filter: { d: { $notContains: 'v1' } }, expected: ['2', '3', '4'], - note: '#5298 option A: a row with no value satisfies the negated substring test. The reference matcher failed BOTH readings of this one on the type test (`null` is not a string) until PR #13356; the SQL faces reach it through `nullSafeNegative`.', + note: 'Ruled NULL-safe: a row with no value satisfies the negated substring test. The reference matcher failed BOTH readings of this one on the type test (`null` is not a string) until it was realigned to the ruling; the SQL faces reach it through `nullSafeNegative`.', }, { name: '$null true selects exactly the no-value rows', @@ -536,7 +536,7 @@ export const FILTER_LOGIC_CASES: readonly FilterLogicCase[] = [ name: '$exists true selects exactly the valued rows', filter: { d: { $exists: true } }, expected: ['1', '2'], - note: '#5299 cell 2 / #5962: `$exists` means HAS A VALUE, never key-presence. A key-presence reading returns all four rows here, because the fixture stores `d: null` with the key present — the reading every divergent exit failed on.', + note: 'Ruled: `$exists` means HAS A VALUE, never key-presence, because SQL cannot tell a missing key from a stored null. A key-presence reading returns all four rows here, because the fixture stores `d: null` with the key present — the reading every divergent exit failed on.', }, { name: '$exists false selects exactly the no-value rows', @@ -558,25 +558,25 @@ export const FILTER_LOGIC_CASES: readonly FilterLogicCase[] = [ name: '$empty true selects exactly the no-value rows', filter: { d: { $empty: true } }, expected: ['3', '4'], - note: '#20444: null is empty on every row of the ruled table. A face with no arm refuses, which is red here, never a silent answer.', + note: 'Every face answers `$empty` by the field\'s declared type, and null is empty under every type\'s arm. A face with no arm refuses, which is red here, never a silent answer.', }, { name: '$empty false selects exactly the valued rows', filter: { d: { $empty: false } }, expected: ['1', '2'], - note: '#20444: the exact complement, so `$empty` is pinned as a partition of the table rather than one half of one.', + note: '`$empty: false` is the exact complement by ruling, so `$empty` is pinned as a partition of the table rather than one half of one.', }, { name: '$not over $empty true returns the valued rows', filter: { $not: { d: { $empty: true } } }, expected: ['1', '2'], - note: '#20444: `$empty` spells its NULL case out, so it is never UNKNOWN; a three-valued `NOT (d IS NULL OR …)` that dropped a row would fail here.', + note: 'The ruled `$empty` arms spell their NULL case out, so `$empty` is never UNKNOWN; a three-valued `NOT (d IS NULL OR …)` that dropped a row would fail here.', }, { name: '$not over $empty false returns the no-value rows', filter: { $not: { d: { $empty: false } } }, expected: ['3', '4'], - note: '#20444: the negation of the complement is the empty partition, rows 3-4 — the rows an unguarded `NOT (d IS NOT NULL AND …)` loses to UNKNOWN.', + note: 'Under the same declared-type arms, the negation of the complement is the empty partition, rows 3-4 — the rows an unguarded `NOT (d IS NOT NULL AND …)` loses to UNKNOWN.', }, { name: '$empty inside a $or branch OR-s with its sibling branch', @@ -592,7 +592,7 @@ export const FILTER_LOGIC_CASES: readonly FilterLogicCase[] = [ name: '$empty ANDs with a sibling operator on the same field', filter: { d: { $empty: false, $ne: 'v1' } }, expected: ['2'], - note: '#20444: a face that lowers `$empty` beside the field\'s other operators must not let either overwrite the other.', + note: 'A face that lowers `$empty` to the field\'s declared-type arm beside the field\'s other operators must not let either overwrite the other.', }, // ── Shapes read scopes are actually written in ──────────────────────────── diff --git a/packages/spec/src/data/filter-text-conformance.ts b/packages/spec/src/data/filter-text-conformance.ts index d45bd0f302..3e555d8074 100644 --- a/packages/spec/src/data/filter-text-conformance.ts +++ b/packages/spec/src/data/filter-text-conformance.ts @@ -297,7 +297,7 @@ export const FILTER_TEXT_CASES: readonly FilterTextCase[] = [ note: 'An unescaped comparand compiles to LIKE \'%100%%\', which also matches row 6 (100X match).', }, { - name: 'icontains (the infix/view spelling, #8934) lowers to $icontains — % stays a LITERAL through that door too', + name: 'icontains (the infix/view spelling, ruled never an alias of ilike) lowers to $icontains — % stays a LITERAL through that door too', // Computed THROUGH the lowering on purpose: today this case is byte-equal // at runtime to the one above, and that is the point. If the infix spelling // is ever folded onto `$ilike` instead (the boundary #8934 rules out), the @@ -306,7 +306,7 @@ export const FILTER_TEXT_CASES: readonly FilterTextCase[] = [ // where it executes, not only in the spec's own suite. filter: parseFilterAST(['name', 'icontains', '100%']) as FilterCondition, expected: ['5'], - note: 'The three authoring dialects declare ONE capability (#8934): the infix door must reach the same escaped-substring operator the $ dialect names directly, never the raw-pattern $ilike.', + note: 'The three authoring dialects declare ONE capability, ruled when `icontains` joined the view and infix vocabularies: the infix door must reach the same escaped-substring operator the $ dialect names directly, never the raw-pattern $ilike.', }, { name: '$icontains treats _ as a literal character, not a single-character wildcard', @@ -346,7 +346,7 @@ export const FILTER_TEXT_CASES: readonly FilterTextCase[] = [ name: '$contains is case-SENSITIVE — a lower-case comparand misses the upper-case row', filter: { name: { $contains: 'acme' } }, expected: ['2'], - note: 'Row 1 (ACME Corp) must NOT match. SQLite\'s LIKE folds ASCII — the defect #6518 replaced with GLOB on the SQLite dialects; a JS backend\'s equivalent is a RegExp carrying the `i` flag, which #6682 took off the last two. A backend returning both here has regressed to one of them.', + note: 'Row 1 (ACME Corp) must NOT match: the `$contains` family is case-sensitive on every backend, by ruling. SQLite\'s LIKE folds ASCII — the defect the SQL family replaced with GLOB on the SQLite dialects; a JS backend\'s equivalent is a RegExp carrying the `i` flag, since taken off driver-memory and driver-mongodb, the last two. A backend returning both here has regressed to one of them.', }, { name: '$contains is case-SENSITIVE — an upper-case comparand misses the lower-case row', @@ -425,7 +425,7 @@ export const FILTER_TEXT_CASES: readonly FilterTextCase[] = [ expectRejection: true, code: 'INVALID_FILTER', mustMention: ['$regex', '$icontains'], - note: 'Not `expected: []`. Answering zero rows is what driver-memory already did for an invalid pattern — the silent wrong answer #4706 retired the operator over.', + note: 'Not `expected: []`. Answering zero rows is what driver-memory already did for an invalid pattern — the silent wrong answer `$regex` was retired over, by ruling, with a loud refusal naming `$icontains`.', }, { name: '$regex with $options is REFUSED as one mistake, not two', @@ -433,7 +433,7 @@ export const FILTER_TEXT_CASES: readonly FilterTextCase[] = [ expectRejection: true, code: 'INVALID_FILTER', mustMention: ['$regex', '$options', '$icontains'], - note: 'The exact shape plugin-auth\'s adapter used to emit, and the one `$icontains` replaces one-for-one. #5710 flipped that producer before any backend enrolled this case (re-verified 2026-08, #6993). "One mistake" is about the AUTHOR\'s fix being single (write $icontains), not about the message naming one key: it must name BOTH retired spellings, or an author who fixes only $regex trips the dangling-$options refusal on the next attempt.', + note: 'The exact shape plugin-auth\'s adapter used to emit, and the one `$icontains` replaces one-for-one. That producer was moved off `$regex` before any backend enrolled this case (re-verified 2026-08 by executing each face). "One mistake" is about the AUTHOR\'s fix being single (write $icontains), not about the message naming one key: it must name BOTH retired spellings, or an author who fixes only $regex trips the dangling-$options refusal on the next attempt.', }, { name: 'a dangling $options with no $regex is REFUSED', @@ -449,7 +449,7 @@ export const FILTER_TEXT_CASES: readonly FilterTextCase[] = [ expectRejection: true, code: 'INVALID_FILTER', mustMention: ['$icontains'], - note: 'Every row contains the empty substring, so evaluating it is a predicate that constrains nothing — the widening #5240 refused `{ field: {} }` over, one level in.', + note: 'Every row contains the empty substring, so evaluating it is a predicate that constrains nothing — the widening for which a field with zero operators, `{ field: {} }`, is refused by ruling, one level in.', }, { name: 'a non-string $icontains comparand is REFUSED', diff --git a/packages/spec/src/data/filter-text-operator-declared-type.ts b/packages/spec/src/data/filter-text-operator-declared-type.ts index 1482278a3f..ca54e13508 100644 --- a/packages/spec/src/data/filter-text-operator-declared-type.ts +++ b/packages/spec/src/data/filter-text-operator-declared-type.ts @@ -389,7 +389,7 @@ export const TEXT_OPERATOR_DOOR_TYPE_CLASSES: readonly TextOperatorDoorTypeClass name: 'formula', types: new Set(['formula']), verdict: 'by-return-type', - note: 'Judged as the FieldType its declared `returnType` names (`text` passes; `number` / `boolean` / `date` are refused through the same sets); `returnType` absent ⇒ deferred. ⚠️ Unreachable at the engine seam: the earlier #8296 door refuses EVERY formula filter with INVALID_FIELD 400 whatever the `returnType`, so this row states the contract\'s answer, not an observable one — see the module header.', + note: 'Judged as the FieldType its declared `returnType` names (`text` passes; `number` / `boolean` / `date` are refused through the same sets); `returnType` absent ⇒ deferred. ⚠️ Unreachable at the engine seam: the earlier unmaterializable-field door refuses EVERY formula filter with INVALID_FIELD 400 whatever the `returnType` (no driver stores a formula column), so this row states the contract\'s answer, not an observable one — see the module header.', }, ]; @@ -550,8 +550,8 @@ function caseFor( ...base, verdict, note: dotted - ? 'A dotted path into a structured-JSON field is filter-dotted-head\'s subject (deliberately unjudged there, #8371); this door must not re-close that carve-out by reading the head\'s declared type.' - : 'The declared return type is not readable here — the ruling judges formula only when it is. ⚠️ Unreachable at the engine seam: #8296 refuses every formula filter one door earlier (INVALID_FIELD 400) — see the module header.', + ? 'A dotted path into a structured-JSON field is filter-dotted-head\'s subject (deliberately left unjudged there, by ruling); this door must not re-close that carve-out by reading the head\'s declared type.' + : 'The declared return type is not readable here — the ruling judges formula only when it is. ⚠️ Unreachable at the engine seam: the unmaterializable-field door refuses every formula filter one door earlier (INVALID_FIELD 400) — see the module header.', }; } } diff --git a/packages/spec/src/data/temporal-conformance.ts b/packages/spec/src/data/temporal-conformance.ts index b9882f3699..776df32914 100644 --- a/packages/spec/src/data/temporal-conformance.ts +++ b/packages/spec/src/data/temporal-conformance.ts @@ -208,16 +208,16 @@ export interface TemporalRow { * strictly after it, and `d_next` on the next midnight — the exclusive edge. */ export const TEMPORAL_ROWS: readonly TemporalRow[] = [ - { id: 'a_epoch', at: '1969-12-31T23:00:00.000Z', on: '1969-12-31', writerForm: 'wire', why: 'pre-epoch instant (negative epoch ms) — any surface assuming a non-negative epoch, or reading one as a Julian day (#3773), breaks here first' }, + { id: 'a_epoch', at: '1969-12-31T23:00:00.000Z', on: '1969-12-31', writerForm: 'wire', why: 'pre-epoch instant (negative epoch ms) — any surface assuming a non-negative epoch, or reading one as a Julian day as SQLite bucketing once did, breaks here first' }, { id: 'a_old', at: '2026-04-19T10:00:00.000Z', on: '2026-04-19', writerForm: 'wire', why: 'well before any window here' }, { id: 'b_prev', at: '2026-07-27T14:00:00.000Z', on: '2026-07-27', writerForm: 'native', why: 'the day before the boundary day' }, { id: 'c_open', at: '2026-07-28T00:00:00.000Z', on: '2026-07-28', writerForm: 'native', why: 'boundary day at exactly 00:00 — the only instant a midnight-anchored bound keeps' }, - { id: 'd_mid', at: '2026-07-28T09:15:00.000Z', on: '2026-07-28', writerForm: 'wire', why: 'boundary day, morning — dropped by the #3777 bug' }, - { id: 'e_late', at: '2026-07-28T21:40:00.000Z', on: '2026-07-28', writerForm: 'wire', why: 'boundary day, evening — dropped by the #3777 bug' }, + { id: 'd_mid', at: '2026-07-28T09:15:00.000Z', on: '2026-07-28', writerForm: 'wire', why: 'boundary day, morning — dropped when a bare-day upper bound stopped at midnight' }, + { id: 'e_late', at: '2026-07-28T21:40:00.000Z', on: '2026-07-28', writerForm: 'wire', why: 'boundary day, evening — dropped when a bare-day upper bound stopped at midnight' }, { id: 'f_next', at: '2026-07-29T00:00:00.000Z', on: '2026-07-29', writerForm: 'native', why: 'next midnight — the exclusive edge a half-open bound must NOT keep' }, { id: 'g_eom', at: '2026-07-31T23:59:59.999Z', on: '2026-07-31', writerForm: 'wire', why: 'last representable instant of a month — month rollover' }, { id: 'h_leap', at: '2024-02-29T12:00:00.000Z', on: '2024-02-29', writerForm: 'native', why: 'leap day — February rollover' }, - { id: 'z_last', at: '9999-12-31T10:00:00.000Z', on: '9999-12-31', writerForm: 'native', why: 'the last supported day (years 0001..9999), after its midnight — the one day with no next day, so its whole-day upper bound bounds nothing (#20600)' }, + { id: 'z_last', at: '9999-12-31T10:00:00.000Z', on: '9999-12-31', writerForm: 'native', why: 'the last supported day (years 0001..9999), after its midnight — the one day with no next day, so its whole-day upper bound bounds nothing and none is compiled' }, ] as const; /** Which declared field type a case filters on. */ @@ -267,7 +267,7 @@ export const TEMPORAL_CASES: readonly TemporalCase[] = [ tokenFilter: { at: { $gte: '{90_days_ago}', $lte: '{today}' } }, dateRange: ['{90_days_ago}', '{today}'], expected: ['b_prev', 'c_open', 'd_mid', 'e_late'], - note: '#3777: the default dashboard window. Pre-fix returned only b_prev + c_open — everything after 00:00 on the final day vanished.', + note: 'The default dashboard window, whose bare-day $lte keeps the whole final day. Pre-fix returned only b_prev + c_open — everything after 00:00 on the final day vanished.', }, { name: 'date: the same window is unchanged (already whole-day)', @@ -390,7 +390,7 @@ export const TEMPORAL_CASES: readonly TemporalCase[] = [ kind: 'datetime', filter: { at: { $gte: '1969-12-31', $lte: '1969-12-31' } }, expected: ['a_epoch'], - note: 'Negative epoch ms. The #3773 family: any surface that assumes a datetime is a non-negative epoch, or reads one as a Julian day, breaks here first.', + note: 'Negative epoch ms. The family of the SQLite bucketing defect that read an epoch-ms datetime as a Julian day: any surface that assumes a datetime is a non-negative epoch, or reads one as a Julian day, breaks here first.', }, // ── The last supported day: no next day, so no upper bound (#20600) ─────── @@ -445,7 +445,7 @@ export const TEMPORAL_CASES: readonly TemporalCase[] = [ filter: { on: '2026-07-28' }, tokenFilter: { on: '{today}' }, expected: ['c_open', 'd_mid', 'e_late'], - note: '#1874: `date == today` silently matched nothing while dates were stored as instants. Equality on a date column is plain calendar-day text equality — Phase 1\'s whole point.', + note: '`date == today` silently matched nothing while dates were stored as instants; ADR-0053 stores a date as its calendar day, so equality on a date column is plain calendar-day text equality — Phase 1\'s whole point.', }, { name: 'date: $in of two resolved days', @@ -454,7 +454,7 @@ export const TEMPORAL_CASES: readonly TemporalCase[] = [ filter: { on: { $in: ['2026-07-28', '2026-07-27'] } }, tokenFilter: { on: { $in: ['{today}', '{yesterday}'] } }, expected: ['b_prev', 'c_open', 'd_mid', 'e_late'], - note: 'The `expires_on: { $in: [daysFromNow(30)] }` template shape from the #1874 family — element-wise, order-independent.', + note: 'The `expires_on: { $in: [daysFromNow(30)] }` template shape that surfaced ADR-0053 — element-wise, order-independent.', }, ] as const; @@ -546,7 +546,7 @@ export const TEMPORAL_TIME_CASES: readonly TemporalTimeCase[] = [ name: 'time: a business-hours window keeps both bounds', filter: { at: { $gte: '09:00:00', $lte: '18:00:00' } }, expected: ['c_open', 'd_mid', 'e_mid_ms', 'f_close'], - note: '#3994, measured: 4 of 7 rows silently dropped. An epoch-ms row failed `>= 09:00:00` outright (INTEGER < TEXT on SQLite) and a full-timestamp row failed `<= 18:00:00` lexicographically.', + note: 'Measured before `Field.time` took one canonical `HH:MM:SS[.fff]` text form: 4 of 7 rows silently dropped. An epoch-ms row failed `>= 09:00:00` outright (INTEGER < TEXT on SQLite) and a full-timestamp row failed `<= 18:00:00` lexicographically.', }, { name: 'time: an inclusive upper bound is EXACT, not widened', diff --git a/packages/spec/src/data/value-roundtrip-conformance.ts b/packages/spec/src/data/value-roundtrip-conformance.ts index cbae80177b..dab6be4713 100644 --- a/packages/spec/src/data/value-roundtrip-conformance.ts +++ b/packages/spec/src/data/value-roundtrip-conformance.ts @@ -210,7 +210,7 @@ export const VALUE_ROUNDTRIP_CASES: readonly ValueRoundTripCase[] = [ name: 'm_two', column: 'v_multi', wrote: ['userA', 'userB'], - note: "#11535's exact shape: the array that came back as the string '[\"userA\",\"userB\"]'", + note: "the exact shape a single-value column kept for a multi-value field corrupted: the array that came back as the string '[\"userA\",\"userB\"]'", }, { name: 'm_one', column: 'v_multi', wrote: ['solo'], note: 'single-element array must not degrade to its element' }, { name: 'm_empty', column: 'v_multi', wrote: [], note: 'empty array must not degrade to null' }, @@ -222,8 +222,8 @@ export const VALUE_ROUNDTRIP_CASES: readonly ValueRoundTripCase[] = [ }, // ── scalar columns: the value class stated in the declaration ───────────── - { name: 'b_true', column: 'v_boolean', wrote: true, note: 'declared boolean — #11782 read this back as 1 on MySQL' }, - { name: 'b_false', column: 'v_boolean', wrote: false, note: 'declared boolean — the 0 half of #11782' }, + { name: 'b_true', column: 'v_boolean', wrote: true, note: 'declared boolean — MySQL read this back as 1 until every read door presented a declared boolean as true/false' }, + { name: 'b_false', column: 'v_boolean', wrote: false, note: 'declared boolean — the 0 half of that MySQL read-back' }, { name: 'num_int', column: 'v_number', wrote: 42, note: 'declared number, integral' }, { name: 'num_real', column: 'v_number', wrote: 1.5, note: 'declared number, fractional' }, { name: 'num_zero', column: 'v_number', wrote: 0, note: 'zero must survive as a number, not become null' },