From 7327a94182bc6b55c3375aeb2d86b8c37e0bb0ab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 19:49:12 +0000 Subject: [PATCH 1/3] fix(driver-mongodb): reduce empty $and/$or/$not to their boolean identity, refusing non-nodes first (#5239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `translateFilter` passed combinator arrays through verbatim, and MongoDB answers an empty one with neither TRUE nor FALSE but a third behaviour: it refuses the query (`$and/$or/$nor must be a nonempty array`). So `{$and: []}` and `{$or: []}` reached find/count/updateMany/deleteMany as a server error carrying no ADR-0112 code, while driver-sql (#5134), driver-memory and formula all answered them as identities. Replaced with the same STRUCTURAL three-valued reduction: reduce the whole tree to true/false/clause first, then emit. Empty `$and` becomes TRUE (no condition); empty `$or` becomes FALSE and emits a real zero-row condition (`{_id: {$in: []}}`) — emitting nothing would be `{}`, which find/updateMany/ deleteMany read as EVERY document, the opposite answer. Every `$and`/`$or` array emitted is therefore guaranteed non-empty. Shape rejection lands in the same change and runs BEFORE any identity: measured on main, `{$or: [new Date()]}` translated to `{$or: [{}]}` (every document) and `{$or: 'x'}` / `{$not: null}` translated to `{}` (every document). updateMany and deleteMany translate the same `where`, where that is data loss rather than a wrong row count. Non-nodes now raise INVALID_FILTER / 400 naming the position; the gate judges by PROTOTYPE, since Date/RegExp/class instances satisfy `typeof x === 'object'` while enumerating empty. spec is documentation only: FilterConditionSchema's contract TSDoc now states the NULL-safe `$not` semantics ruled in #5146, and filter-logic-conformance.ts records the measured matrix for the three ruled-but-not-yet-enrolled case families. The four FILTER_LOGIC_CASES rows #5239 asks for are deliberately NOT added: read-scope-sql and the analytics filter-normalizer, both enrolled backends, refuse empty combinators fail-closed by design and pinned test, which contradicts the identity ruling — escalated as #5322. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB --- .../mongodb-boolean-identity-reduction.md | 35 ++ .../mongodb-filter-boolean-identity.test.ts | 361 ++++++++++++++++++ .../driver-mongodb/src/mongodb-filter.ts | 273 ++++++++++++- .../spec/src/data/filter-logic-conformance.ts | 63 +++ packages/spec/src/data/filter.zod.ts | 58 ++- 5 files changed, 769 insertions(+), 21 deletions(-) create mode 100644 .changeset/mongodb-boolean-identity-reduction.md create mode 100644 packages/plugins/driver-mongodb/src/mongodb-filter-boolean-identity.test.ts diff --git a/.changeset/mongodb-boolean-identity-reduction.md b/.changeset/mongodb-boolean-identity-reduction.md new file mode 100644 index 0000000000..c1081ed675 --- /dev/null +++ b/.changeset/mongodb-boolean-identity-reduction.md @@ -0,0 +1,35 @@ +--- +"@objectstack/driver-mongodb": patch +"@objectstack/spec": patch +--- + +fix(driver-mongodb): 空 `$and` / `$or` / `$not` 按布尔单位元归约,非 filter 节点先响亮拒收 (#5239) + +`translateFilter` 过去把组合子数组**原样透传**给 MongoDB。而 MongoDB 对空数组既不答 +TRUE 也不答 FALSE,是第三种行为:**直接拒绝整条查询**(`$and/$or/$nor must be a +nonempty array`)。于是 `{ $and: [] }` 与 `{ $or: [] }` 一路走到 `find` / +`countDocuments` / `updateMany` / `deleteMany`,变成一个不带 ADR-0112 错误码的服务端 +异常 —— 而 `driver-sql`(#5134 / PR #5243)、`driver-memory`、`formula` 三家早已按单位 +元作答。 + +改成与它们同一套**结构性三值归约**:先把整棵 filter 树判成 `true` / `false` / +`clause`,再据此产出。空 `$and` 归约为 TRUE(不产出条件),空 `$or` 归约为 FALSE 并产出 +一个**真实的零行条件** `{ _id: { $in: [] } }` —— 关键在于「什么都不产出」等于 `{}`,而 +`find` / `updateMany` / `deleteMany` 把 `{}` 读作**全部文档**,方向正好相反。`{}` 作为 +`$or` 的分支仍是 TRUE 析取项,`{ $not: {} }` 仍是零行,这两条 MongoDB 本来就与布尔代数 +一致,所以归约按结构做而不是只判 `length === 0`。发出的每个 `$and` / `$or` 数组因此都保 +证非空。 + +**同一改动里的形状拒收**,顺序是先拒收后归约:单位元把「这个节点没有谓词」读作「匹配全部 +文档」,所以空节点必须只有一个成因。改前实测,本驱动这一格比 `driver-sql` 当年更糟 —— +`{ $or: [new Date()] }` 译成 `{ $or: [{}] }`,即**每一份文档**;`{ $or: 'x' }` 与 +`{ $not: null }` 译成 `{}`,同样是每一份文档。`updateMany` / `deleteMany` 走的是同一个 +translate 层,在那里「放宽到全部文档」不是行数不对而是数据丢失。现在这类操作数按 +ADR-0112 以 `INVALID_FILTER` / `status: 400` 拒收,并在消息里点出位置 +(`filter.$or[0]`)。`Date` / `RegExp` / class 实例都满足 `typeof x === 'object'` 却枚举 +为空,故判定按**原型**而非 `typeof`。 + +`packages/spec` 侧只动文档:`FilterConditionSchema` 的契约 TSDoc 写明 `$not` 的 +**NULL-safe** 语义(#5146 维护者拍板 —— 被比较列为 NULL 的行不满足被否定的条件,应当被 +返回,即 `NOT (…) OR col IS NULL`),并在 `filter-logic-conformance.ts` 记下三族已裁定但 +**尚未进表**的 case 及其实测矩阵。无运行时行为变化,无 API 变化。 diff --git a/packages/plugins/driver-mongodb/src/mongodb-filter-boolean-identity.test.ts b/packages/plugins/driver-mongodb/src/mongodb-filter-boolean-identity.test.ts new file mode 100644 index 0000000000..0a92e10182 --- /dev/null +++ b/packages/plugins/driver-mongodb/src/mongodb-filter-boolean-identity.test.ts @@ -0,0 +1,361 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#5239] An empty `$and` / `$or` / `$not` group translates to its BOOLEAN + * IDENTITY — and a non-node operand is refused before any identity is applied. + * + * # What was wrong + * + * `translateCondition` passed the combinator arrays through verbatim. MongoDB + * answers an empty one with neither TRUE nor FALSE but a THIRD behaviour: it + * refuses the query outright (`$and/$or/$nor must be a nonempty array`). So + * `{ $and: [] }` and `{ $or: [] }` reached `find` / `countDocuments` / + * `updateMany` / `deleteMany` as a server error carrying no ADR-0112 code, + * while `driver-sql` (#5134 / PR #5243), `driver-memory` and `formula` all + * answered them as identities. Measured on `main` before this change: + * + * | filter | boolean algebra | old translation | + * |--------------------|--------------------------|----------------------| + * | `{$and: []}` | TRUE -> every document | `{$and: []}` -> ERROR| + * | `{$or: []}` | FALSE -> zero documents | `{$or: []}` -> ERROR| + * | `{$or:[{a},{}]}` | `{}` is a TRUE disjunct | already correct | + * | `{$not: {}}` | NOT TRUE = zero documents| already correct | + * + * The last two rows are why the reduction is STRUCTURAL rather than a special + * case for the empty array: MongoDB happens to agree with boolean algebra about + * `{}` and about `$nor: [{}]`, so a fix that only looked at `length === 0` + * would have been right by luck on half the table and silent about the rest. + * + * # Why the shape rejection is part of the SAME change + * + * Identity reduction reads "this node has no predicates" as "matches every + * document", so it is sound only once an empty node has exactly ONE cause. + * Before it, this translator's handling of a non-node operand was worse than + * driver-sql's ever was — measured on `main`: + * + * - `{ $or: [new Date()] }` -> `{ $or: [{}] }` — a TRUE disjunct, i.e. EVERY + * document. Not "silently ignored": silently WIDENED, already. + * - `{ $or: 'x' }` and `{ $not: null }` -> `{}` — the absent filter, i.e. every + * document. + * - `{ $or: ['x'] }` -> `{ $or: [{ '0': 'x' }] }` — a predicate on a field + * named `0` that no document has. + * + * `find` is not the only consumer: `updateMany` and `deleteMany` translate the + * same `where`. A filter that widens to every document there is not a wrong row + * count, it is data loss. So non-nodes are refused loudly (ADR-0112 + * `INVALID_FILTER`, `status: 400`) BEFORE any identity is applied — the same + * discipline as #5134 in `driver-sql` and cloud#1073 in Turso's + * `RemoteTransport.buildWhereSQL`. `Date` / `RegExp` / class instances all + * satisfy `typeof x === 'object'` while enumerating to nothing, so the gate + * judges by PROTOTYPE, not by `typeof`. + * + * # Where these cases do NOT yet live + * + * They belong in `FILTER_LOGIC_CASES` (`@objectstack/spec/data`) so every + * backend is held to them at once — that is #5239's headline. They are not + * there yet: two of the table's enrolled backends (`read-scope-sql` and the + * analytics `filter-normalizer`, both in `packages/services/service-analytics`) + * answer the empty combinators by THROWING fail-closed, which is a deliberate, + * pinned position that contradicts the identity ruling. Adding the rows today + * turns those two suites red. The conflict is filed as #5322, with the measured + * matrix in `filter-logic-conformance.ts`. This file is the pin until then. + * + * # The two halves + * + * Translation-level assertions always run — the emitted document IS the + * semantics for these four filters (`{}` is "every document", `{_id:{$in:[]}}` + * is "no document", both unambiguous). The real-mongod half below answers the + * question a translator test cannot — whether the SERVER agrees — and skips + * when the binary cannot be fetched, the convention every suite in this package + * follows. **A skip is not a pass**: on a machine without the binary the + * translation half is the whole proof, which is why it carries the load. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { MongoMemoryServer } from 'mongodb-memory-server'; +import { translateFilter } from './mongodb-filter.js'; +import { MongoDBDriver } from './mongodb-driver.js'; +import { createTestMongod } from './test-mongod.js'; + +/** The shape `mapDataError` / `sendError` read off a thrown driver error. */ +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +/** "Every document" — MongoDB's absent filter. */ +const MATCH_ALL = {}; +/** "No document" — the FALSE constant this translator emits. */ +const MATCH_NONE = { _id: { $in: [] } }; + +const refusalOf = (where: unknown): WireBearingError => { + try { + translateFilter(where); + } catch (e) { + return e as WireBearingError; + } + throw new Error('expected the translator to refuse this filter, but it returned a document'); +}; + +// ── Half 1: the emitted document, always run ──────────────────────────────── + +describe('[#5239] translateFilter reduces empty combinators to their boolean identity', () => { + describe('the identity batch', () => { + it('empty $and is TRUE — the absent filter, which MongoDB reads as every document', () => { + expect(translateFilter({ $and: [] })).toEqual(MATCH_ALL); + }); + + it('empty $or is FALSE — a real zero-document condition, never the absent filter', () => { + // The distinction this assertion exists for: emitting NOTHING would be + // `{}`, which `find` / `updateMany` / `deleteMany` all read as EVERY + // document — the opposite answer. + expect(translateFilter({ $or: [] })).toEqual(MATCH_NONE); + expect(translateFilter({ $or: [] })).not.toEqual(MATCH_ALL); + }); + + it('empty $not is FALSE — NOT TRUE, so zero documents', () => { + expect(translateFilter({ $not: {} })).toEqual(MATCH_NONE); + }); + }); + + it('an RLS read scope whose disjunct list came out empty hides every document', () => { + // The production shape #5134 reports: a scope builder looped over zero + // grants and handed the driver `{$or: []}`. + expect(translateFilter({ $or: [] })).toEqual(MATCH_NONE); + }); + + it('a scope that ANDs a real predicate with an empty $or still hides everything', () => { + // FALSE dominates the node's own AND, so the whole tree reduces before any + // document is built — `owner = u1` is never emitted at all. That is the + // reduction being STRUCTURAL rather than a post-hoc filter over emitted + // clauses, and it is why the result is the bare FALSE constant. + expect(translateFilter({ owner: 'u1', $or: [] })).toEqual(MATCH_NONE); + }); + + // ── `{}` is a TRUE operand wherever it appears ──────────────────────────── + + it('an empty branch makes the whole $or TRUE (it is a TRUE disjunct)', () => { + expect(translateFilter({ $or: [{ stage: 'won' }, {}] })).toEqual(MATCH_ALL); + }); + + it('an empty branch inside $and is the AND identity — siblings still apply', () => { + expect(translateFilter({ $and: [{ stage: 'won' }, {}] })).toEqual({ + $and: [{ stage: 'won' }], + }); + }); + + it('an empty $or branch is dropped as the OR identity, siblings survive', () => { + // The emitted `$or` must stay NON-EMPTY: an empty one is the shape MongoDB + // refuses, so "drop the FALSE member" and "emit `$or: []`" are not the same + // thing even though both start by removing the branch. + expect(translateFilter({ $or: [{ stage: 'won' }, { $or: [] }] })).toEqual({ + $or: [{ stage: 'won' }], + }); + }); + + // ── The identities compose through nesting ─────────────────────────────── + + it('a FALSE branch makes the enclosing $and FALSE', () => { + expect(translateFilter({ $and: [{ stage: 'won' }, { $or: [] }] })).toEqual(MATCH_NONE); + }); + + it('$not of a FALSE group is TRUE', () => { + expect(translateFilter({ $not: { $or: [] } })).toEqual(MATCH_ALL); + }); + + it('$not of a TRUE group is FALSE', () => { + expect(translateFilter({ $not: { $and: [] } })).toEqual(MATCH_NONE); + }); + + it('a nested empty $not still collapses to FALSE under $and', () => { + expect(translateFilter({ $and: [{ stage: 'won' }, { $not: {} }] })).toEqual(MATCH_NONE); + }); + + it('an empty $not as a $or branch is dropped, not promoted', () => { + expect(translateFilter({ $or: [{ stage: 'won' }, { $not: {} }] })).toEqual({ + $or: [{ stage: 'won' }], + }); + }); + + // ── Shape rejection: an empty translation must have exactly ONE cause ───── + + describe('non-filter-node operands are refused loudly, never reduced', () => { + const cases: Array<[string, unknown, string]> = [ + ['null element', { $or: [null] }, 'filter.$or[0]'], + ['string element', { $or: ['x'] }, 'filter.$or[0]'], + ['array element', { $or: [[{ stage: 'won' }]] }, 'filter.$or[0]'], + ['Date element', { $or: [new Date()] }, 'filter.$or[0]'], + ['number element in $and', { $and: [42] }, 'filter.$and[0]'], + ['non-node deeper in the list', { $or: [{ stage: 'won' }, null] }, 'filter.$or[1]'], + ['nested under a good branch', { $and: [{ $or: [null] }] }, 'filter.$and[0].$or[0]'], + ['$not operand is an array', { $not: [] }, 'filter.$not'], + ['$not operand is null', { $not: null }, 'filter.$not'], + ['$not operand is a string', { $not: 'x' }, 'filter.$not'], + ['$or is not an array at all', { $or: 'x' }, 'filter.$or'], + ['$and is not an array at all', { $and: { stage: 'won' } }, 'filter.$and'], + ]; + + for (const [name, where, position] of cases) { + it(`${name} -> 400 INVALID_FILTER naming ${position}`, () => { + const err = refusalOf(where); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain(position); + // #3867 — driver-internal wording never reaches the wire. + expect(err.message).not.toContain('[mongodb]'); + }); + } + + it('garbage is NOT upgraded to match-all by the identity reduction', () => { + // The exact regression the gate exists to prevent, and it is not + // hypothetical here: on `main` these two ALREADY translated to every + // document, before any identity rule was added. + expect(() => translateFilter({ $or: [new Date()] })).toThrow(); + expect(() => translateFilter({ $or: 'x' })).toThrow(); + expect(() => translateFilter({ $not: null })).toThrow(); + }); + + it('a class instance is not a filter node either', () => { + // `Object.entries(new Foo())` can be empty, which would reduce to TRUE and + // match every document. Prototype identity is what separates a filter node + // from an arbitrary object. + class NotAFilter { + stage = 'won'; + } + expect(refusalOf({ $or: [new NotAFilter()] }).code).toBe('INVALID_FILTER'); + }); + + it('the shape gate runs even when a sibling already decided the verdict', () => { + // The walk does not short-circuit: `$or: []` alone would settle the node + // as FALSE, but a malformed node further along must still be refused, or + // the gate would depend on key order. + expect(() => translateFilter({ $or: [], $and: [null] })).toThrow(/filter\.\$and\[0\]/); + }); + }); + + // ── Nothing that worked before changes ─────────────────────────────────── + + describe('existing translation is untouched', () => { + it('a plain $or still ORs its branches', () => { + expect(translateFilter({ $or: [{ stage: 'won' }, { stage: 'lost' }] })).toEqual({ + $or: [{ stage: 'won' }, { stage: 'lost' }], + }); + }); + + it('a $or branch still ANDs its own keys (#3774)', () => { + expect(translateFilter({ $or: [{ stage: 'won', owner: 'u1' }, { stage: 'nope' }] })).toEqual({ + $or: [{ stage: 'won', owner: 'u1' }, { stage: 'nope' }], + }); + }); + + it('a non-empty $not still leaves as $nor (MongoDB has no document-level $not)', () => { + expect(translateFilter({ $not: { stage: 'won' } })).toEqual({ $nor: [{ stage: 'won' }] }); + }); + + it('$not still ANDs with its sibling keys', () => { + expect(translateFilter({ $not: { stage: 'won' }, owner: 'u1' })).toEqual({ + $and: [{ owner: 'u1' }, { $nor: [{ stage: 'won' }] }], + }); + }); + + it('an absent filter is not a failed filter', () => { + expect(translateFilter({})).toEqual(MATCH_ALL); + expect(translateFilter(undefined)).toEqual(MATCH_ALL); + }); + + it('operators inside a branch still translate', () => { + expect(translateFilter({ $or: [{ amount: { $gte: 25 } }, { stage: 'lost' }] })).toEqual({ + $or: [{ amount: { $gte: 25 } }, { stage: 'lost' }], + }); + }); + + it('query-level keys are still skipped, and still carry no predicate', () => { + expect(translateFilter({ limit: 5, offset: 2 })).toEqual(MATCH_ALL); + expect(translateFilter({ stage: 'won', limit: 5 })).toEqual({ stage: 'won' }); + }); + + it('a field constrained by zero operators is still not ruled on (#5240)', () => { + // `{ stage: {} }` translates to an exact-match on an empty document, as it + // always did. Reducing it to TRUE would have decided #5240 from here. + expect(translateFilter({ stage: {} })).toEqual({ stage: {} }); + expect(translateFilter({ $or: [{ stage: {} }, { owner: 'u1' }] })).toEqual({ + $or: [{ stage: {} }, { owner: 'u1' }], + }); + }); + + it('the legacy array dialect is untouched', () => { + expect(translateFilter([['stage', '=', 'won']])).toEqual({ stage: 'won' }); + expect(translateFilter([])).toEqual(MATCH_ALL); + }); + }); +}); + +// ── Half 2: does the SERVER agree? ───────────────────────────────────────── + +const sharedMongod: MongoMemoryServer | undefined = await createTestMongod('boolean identity'); + +describe.skipIf(!sharedMongod)('[#5239] a real mongod returns the identity row sets', () => { + const mongod = sharedMongod as MongoMemoryServer; + let driver: MongoDBDriver; + + const FIXTURE = [ + { id: '1', stage: 'won', owner: 'u1' }, + { id: '2', stage: 'lost', owner: 'u2' }, + { id: '3', stage: 'open', owner: 'u1' }, + ]; + const ALL = ['1', '2', '3']; + + beforeAll(async () => { + driver = new MongoDBDriver({ url: mongod.getUri(), database: 'boolean_identity' }); + await driver.connect(); + await driver.syncSchema('deal', { + name: 'deal', + fields: { stage: { type: 'string' }, owner: { type: 'string' } }, + } as never); + for (const row of FIXTURE) await driver.create('deal', { ...row }); + }, 90_000); + + afterAll(async () => { + if (driver) await driver.disconnect(); + if (sharedMongod) await sharedMongod.stop(); + }); + + const ids = async (where: unknown): Promise => { + const rows = await driver.find('deal', { object: 'deal', where } as never); + return (rows as Record[]).map((r) => String(r.id)).sort(); + }; + + it('the fixture really is all three rows', async () => { + expect(await ids(undefined)).toEqual(ALL); + }); + + it('empty $and is every document (it used to be a server error)', async () => { + expect(await ids({ $and: [] })).toEqual(ALL); + }); + + it('empty $or is ZERO documents (it used to be a server error)', async () => { + expect(await ids({ $or: [] })).toEqual([]); + }); + + it('empty $not is ZERO documents', async () => { + expect(await ids({ $not: {} })).toEqual([]); + }); + + it('an empty branch makes the whole $or every document', async () => { + expect(await ids({ $or: [{ stage: 'won' }, {}] })).toEqual(ALL); + }); + + it('a surviving $or branch is never emitted as an empty array', async () => { + // The regression this guards: dropping the FALSE member down to `$or: []` + // is the very shape the server refuses. + expect(await ids({ $or: [{ stage: 'won' }, { $or: [] }] })).toEqual(['1']); + }); + + it('the FALSE constant really selects nothing on the server', async () => { + expect(await ids({ $and: [{ stage: 'won' }, { $or: [] }] })).toEqual([]); + }); + + it('a non-empty $not still negates', async () => { + expect(await ids({ $not: { stage: 'won' } })).toEqual(['2', '3']); + }); +}); diff --git a/packages/plugins/driver-mongodb/src/mongodb-filter.ts b/packages/plugins/driver-mongodb/src/mongodb-filter.ts index 6dc7382854..3a0f28298e 100644 --- a/packages/plugins/driver-mongodb/src/mongodb-filter.ts +++ b/packages/plugins/driver-mongodb/src/mongodb-filter.ts @@ -18,12 +18,212 @@ import type { Filter } from 'mongodb'; import { nextUtcCalendarDay } from '@objectstack/core'; +import { StandardErrorCode } from '@objectstack/spec/api'; import { coerceTemporalValue, type TemporalFieldKind, type TemporalFieldKindResolver, } from './mongodb-temporal.js'; +// ── [#5239] Boolean identities for the empty combinators ───────────────────── + +/** + * Keys that reach this translator inside a `where` object but describe the + * QUERY rather than a predicate. The emitter has always skipped them; the + * reduction below has to agree, or "what this node is worth as a boolean" and + * "what this node emits" could disagree — which is the class of defect the + * reduction exists to remove. + */ +const QUERY_LEVEL_KEYS = new Set(['limit', 'offset', 'fields', 'orderBy']); + +/** + * A MongoDB query document that matches NO document, for the `false` verdict. + * + * `$in: []` is empty-set membership — no value satisfies it, on every server + * version, with no dependence on a collection's fields. `_id` is the one field + * MongoDB guarantees exists. This is the shape #5239 names, and it is emitted + * as a REAL condition: `$or: []` must reach `find` / `updateMany` / + * `deleteMany` as "zero rows", never as the absent filter `{}`, which those + * three read as "every document". + */ +function matchNothing(): Filter { + return { _id: { $in: [] } }; +} + +/** + * [#5239, mirroring #5134] What a filter node is worth as a boolean, decided + * BEFORE any query document is built. + * + * - `'true'` — matches every document; the translator emits no condition. + * - `'false'` — matches no document; the translator emits {@link matchNothing}. + * - `'clause'` — carries at least one real predicate; translate it normally. + */ +type FilterVerdict = 'true' | 'false' | 'clause'; + +/** + * [#5239] Is `value` a Filter Protocol NODE — the shape `FilterConditionSchema` + * declares for every element of `$and`/`$or` and for the operand of `$not`? + * + * The PROTOTYPE check is the load-bearing half. The identity reduction turns + * "this node has no predicates" into "matches every document", so any object + * whose own enumerable keys are empty reads as TRUE. A `Date`, a `RegExp`, a + * `Map` or a class instance all satisfy `typeof x === 'object' && + * !Array.isArray(x)` while enumerating to nothing — accepting them would + * PROMOTE garbage from "silently mistranslated" to "matches all documents", + * which on `deleteMany` is not a wrong row count but data loss. Measured on + * `main` before this change: `{ $or: [new Date()] }` translated to + * `{ $or: [{}] }`, i.e. every document, and `{ $or: 'x' }` / `{ $not: null }` + * translated to `{}`, likewise every document. + */ +function isFilterNode(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** A short type name for an operand the translator refuses. */ +function describeFilterOperand(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + const kind = typeof value; + if (kind !== 'object') return kind; + const ctor = (value as { constructor?: { name?: string } }).constructor; + return ctor?.name && ctor.name !== 'Object' ? ctor.name : 'object'; +} + +/** A short, non-throwing rendering of an offending operand for the message. */ +function safeShapePreview(value: unknown): string { + try { + const json = JSON.stringify(value); + if (typeof json !== 'string') return typeof value; + return json.length > 80 ? `${json.slice(0, 77)}...` : json; + } catch { + return typeof value; + } +} + +/** + * [#5239] The ADR-0112 envelope this driver's filter refusals speak, matching + * `driver-sql`'s `unsupportedFilterError` exactly: one condition — "this filter + * cannot run" — carries one wire code however the caller reached it, and + * `status: 400` keeps a caller's mistake off the unhandled-server-error path. + * + * The `[mongodb]` prefix is deliberately absent from the text: driver-internal + * wording does not belong on the wire (#3867). + */ +function unsupportedFilterError(message: string): Error { + const err = new Error(message) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.INVALID_FILTER; + err.status = 400; + return err; +} + +/** + * [#5239] The gate that gives "this group translated to empty" exactly ONE + * cause. + * + * Identity reduction is sound only once an empty group can mean "the author + * wrote an empty group" and nothing else. Refusing non-nodes here — before any + * identity is applied — is what makes the reduction safe rather than a + * promotion of garbage to match-all. Same discipline as #5134 in `driver-sql` + * and cloud#1073 in Turso's `RemoteTransport.buildWhereSQL`. + */ +function assertFilterNode(value: unknown, path: string): asserts value is Record { + if (isFilterNode(value)) return; + throw unsupportedFilterError( + `Filter node at ${path} is a ${describeFilterOperand(value)} (${safeShapePreview(value)}), not a filter ` + + `condition object. Every element of "$and"/"$or" and the operand of "$not" must be a plain object of ` + + `field constraints (e.g. { "status": "active" }) or nested combinators — @objectstack/spec ` + + `FilterConditionSchema declares this position as a FilterCondition. It is refused rather than skipped ` + + `because skipping it would silently change which documents match.`, + ); +} + +/** [#5239] `$and`/`$or` take a list; anything else is refused, never coerced. */ +function assertFilterNodeList(value: unknown, key: string, path: string): asserts value is unknown[] { + if (Array.isArray(value)) return; + throw unsupportedFilterError( + `Filter combinator "${key}" at ${path} requires an array of filter conditions, but received a ` + + `${describeFilterOperand(value)} (${safeShapePreview(value)}). @objectstack/spec FilterConditionSchema ` + + `declares "${key}" as FilterCondition[].`, + ); +} + +/** + * [#5239] Reduce one filter node to its boolean verdict, validating shapes on + * the way down. + * + * A node is the AND of its entries, so FALSE dominates and a node with no + * entries at all is TRUE (the empty conjunction) — which is why `{}` is a TRUE + * disjunct inside `$or` and why `{ $not: {} }` is FALSE. + * + * The walk does NOT short-circuit: a `$or: []` sibling must not stop it from + * reaching — and refusing — a malformed node further along, or the shape gate + * would depend on key order. + * + * Deciding STRUCTURALLY, rather than translating and then asking whether the + * emitted document came out empty, is the point. "Nothing was emitted" cannot + * distinguish "the author wrote an empty group" from "something failed to + * translate"; a structural verdict has no such blind spot. + */ +function reduceFilterNode(node: Record, path: string): FilterVerdict { + let sawFalse = false; + let sawClause = false; + for (const [key, value] of Object.entries(node)) { + const verdict = reduceFilterKey(key, value, path); + if (verdict === 'false') sawFalse = true; + else if (verdict === 'clause') sawClause = true; + } + return sawFalse ? 'false' : sawClause ? 'clause' : 'true'; +} + +/** [#5239] The verdict of ONE key of a filter node. */ +function reduceFilterKey(key: string, value: unknown, path: string): FilterVerdict { + const here = path ? `${path}.${key}` : key; + + if (key === '$and' || key === '$or') { + assertFilterNodeList(value, key, here); + let sawTrue = false; + let sawFalse = false; + let sawClause = false; + value.forEach((element, index) => { + const elementPath = `${here}[${index}]`; + assertFilterNode(element, elementPath); + const verdict = reduceFilterNode(element, elementPath); + if (verdict === 'true') sawTrue = true; + else if (verdict === 'false') sawFalse = true; + else sawClause = true; + }); + // `$and: []` → no FALSE, no clause → TRUE (the AND identity). + if (key === '$and') return sawFalse ? 'false' : sawClause ? 'clause' : 'true'; + // `$or: []` → no TRUE, no clause → FALSE (the OR identity). MongoDB itself + // answers neither: it rejects the empty array outright + // (`$and/$or/$nor must be a nonempty array`), so this filter used to be a + // 500-shaped throw rather than a verdict. + return sawTrue ? 'true' : sawClause ? 'clause' : 'false'; + } + + if (key === '$not') { + assertFilterNode(value, here); + const inner = reduceFilterNode(value, here); + // NOT TRUE ≡ FALSE — so `{ $not: {} }` matches nothing. + return inner === 'true' ? 'false' : inner === 'false' ? 'true' : 'clause'; + } + + // Query-level keys carry no predicate; the emitter skips them and so does the + // verdict, so the two never disagree about what this node is worth. + if (QUERY_LEVEL_KEYS.has(key)) return 'true'; + + // A field key always contributes a predicate. This stays `'clause'` even for + // `{ field: {} }` (a field constrained by zero operators), which this + // translator emits as `{ field: {} }` — an exact-match on an empty document. + // That shape is a SEPARATE divergence with three answers across the repo, + // ruled REJECT in #5240 but not yet gated in any backend; classifying it as + // `'clause'` rather than `'true'` is precisely what keeps this change from + // silently ruling on it. + return 'clause'; +} + /** * Translate an ObjectStack `where` clause into a MongoDB filter document. * @@ -51,15 +251,32 @@ export function translateFilter( if (typeof where !== 'object') return {}; - return translateCondition(where as Record, temporalKind); + // [#5239] Shape gate + structural reduction FIRST, over the WHOLE tree. Only + // a `'clause'` node reaches the emitter, so `translateCondition` never has to + // ask whether what it built came out empty. + const node = where as Record; + const verdict = reduceFilterNode(node, 'filter'); + if (verdict === 'true') return {}; + if (verdict === 'false') return matchNothing(); + + return translateCondition(node, temporalKind, 'filter'); } /** * Translate a FilterCondition object to a MongoDB filter. + * + * [#5239] Every combinator key is decided by {@link reduceFilterKey} BEFORE + * anything is emitted, so an empty group is applied as its boolean IDENTITY + * rather than dropped: a `'true'` key contributes nothing to the node's AND, a + * `'false'` key contributes {@link matchNothing}, and only `'clause'` members + * are translated. That is why every `$and`/`$or` array this function emits is + * guaranteed non-empty — MongoDB rejects an empty one, and the old code handed + * it straight through. */ function translateCondition( condition: Record, temporalKind?: TemporalFieldKindResolver, + path = 'filter', ): Filter { const mongoFilter: Record = {}; const andClauses: Filter[] = []; @@ -67,32 +284,50 @@ function translateCondition( for (const [key, value] of Object.entries(condition)) { switch (key) { case '$and': - if (Array.isArray(value)) { - andClauses.push({ - $and: value.map((sub) => translateCondition(sub as Record, temporalKind)), - }); - } - break; - - case '$or': - if (Array.isArray(value)) { - andClauses.push({ - $or: value.map((sub) => translateCondition(sub as Record, temporalKind)), - }); + case '$or': { + const here = `${path}.${key}`; + const keyVerdict = reduceFilterKey(key, value, path); + // TRUE is the AND identity for the node — it adds no condition. FALSE + // makes the node match nothing. + if (keyVerdict === 'true') break; + if (keyVerdict === 'false') { + andClauses.push(matchNothing()); + break; } + // `'clause'` guarantees at least one branch survives: a TRUE member + // would have made a `$or` TRUE, a FALSE member would have made a `$and` + // FALSE, and both were handled above. Dropping the identity members is + // what makes `{ $or: [{ a: 'x' }, { $or: [] }] }` mean `a = x` rather + // than an empty `$or` MongoDB refuses. + const branches = (value as unknown[]) + .map((sub, index) => ({ sub: sub as Record, index })) + .filter(({ sub, index }) => reduceFilterNode(sub, `${here}[${index}]`) === 'clause') + .map(({ sub, index }) => translateCondition(sub, temporalKind, `${here}[${index}]`)); + andClauses.push(key === '$and' ? { $and: branches } : { $or: branches }); break; + } - case '$not': - if (value && typeof value === 'object') { - const inner = translateCondition(value as Record, temporalKind); - // MongoDB $not applies per-field; for top-level negation use $nor - andClauses.push({ $nor: [inner] }); + case '$not': { + const keyVerdict = reduceFilterKey(key, value, path); + // NOT FALSE ≡ TRUE — no condition. NOT TRUE ≡ FALSE — zero documents. + if (keyVerdict === 'true') break; + if (keyVerdict === 'false') { + andClauses.push(matchNothing()); + break; } + const inner = translateCondition( + value as Record, + temporalKind, + `${path}.$not`, + ); + // MongoDB $not applies per-field; for top-level negation use $nor + andClauses.push({ $nor: [inner] }); break; + } default: // Skip query-level keys that are not filter conditions - if (['limit', 'offset', 'fields', 'orderBy'].includes(key)) continue; + if (QUERY_LEVEL_KEYS.has(key)) continue; if (value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) { // Check if this is an operator object (has $ keys) diff --git a/packages/spec/src/data/filter-logic-conformance.ts b/packages/spec/src/data/filter-logic-conformance.ts index a0577aadf5..61f8e005ef 100644 --- a/packages/spec/src/data/filter-logic-conformance.ts +++ b/packages/spec/src/data/filter-logic-conformance.ts @@ -50,6 +50,69 @@ * differ between a SQL engine and a JS matcher, and folding them in would make * the table unpassable rather than more useful. Keep it that way: a case belongs * here only if **every** backend must agree on it. + * + * ## Three case families that are RULED but not yet enrolled + * + * All three were ruled by the maintainer and are implemented in some backends. + * None is in the table, because **every one of them is red on at least one + * enrolled backend today**, and a red row here does not enforce a ruling — it + * just turns another lane's unfinished work into this table's failure. Each is + * recorded with what was actually measured, against `main` at `175d789`, so the + * next author does not have to re-measure. Add the rows in the PR that closes + * the gap, not before. + * + * ### 1. Boolean identities of the empty combinators (#5239) + * + * `{ $and: [] }` = TRUE / all rows, `{ $or: [] }` = FALSE / **zero** rows, + * `{ $or: [{ a: 'x' }, {}] }` = all rows (`{}` is a TRUE disjunct), + * `{ $not: {} }` = FALSE / zero rows. + * + * | backend | `$and:[]` | `$or:[]` | `$or:[{a},{}]` | `$not:{}` | + * |---|---|---|---|---| + * | `formula` | all | zero | all | zero | + * | `driver-memory` | all | zero | all | zero | + * | `driver-sql` (#5134/PR #5243) | all | zero | all | zero | + * | `driver-sqlite-wasm` | all | zero | all | zero | + * | `driver-mongodb` (#5239) | all | zero | all | zero | + * | `read-scope-sql` | **THROWS** | **THROWS** | **rows 1,2** | **whole table** | + * | analytics `filter-normalizer` | **THROWS** | **THROWS** | **rows 1,2** | **whole table** | + * + * The two analytics backends do not merely lag: they hold the OPPOSITE + * position, in writing. `read-scope-sql.ts` and `filter-normalizer.ts` both + * refuse an empty `$and`/`$or` fail-closed ("An empty combinator has no + * defensible reading — dropping it widens the query, and treating it as 'match + * nothing' silently empties a chart"), and `read-scope-sql.test.ts` pins that + * throw. "Reject loudly" is a defensible answer — it is the one #5240 took for + * `{ field: {} }` — but it is not the same answer as "reduce to the identity", + * and one of the two has to give. That is a contract ruling, so it is escalated + * rather than guessed at: **#5322**. + * + * ### 2. NULL-safe `$not` (#5146) + * + * A row whose column is NULL does not satisfy the negated condition and IS + * returned. Landed in `driver-sql` via PR #5296; `driver-memory`, `formula`, + * `driver-sqlite-wasm` and `driver-mongodb` already agreed. `read-scope-sql` + * and `filter-normalizer` still emit a bare `NOT (…)` and return only row 2 + * where the others return rows 2, 3 and 4 — tracked by #5297. + * + * Enrolling this family also needs a fixture change, which is the other reason + * it is not a one-line addition: every column of {@link FILTER_LOGIC_ROWS} is + * non-null by construction, so a NULL-bearing column has to be added here AND + * declared in all seven harnesses that seed it. + * + * ### 3. `{ field: {} }` — a field constrained by zero operators (#5240) + * + * Ruled **REJECT** (`INVALID_FILTER`) on all four backends. No backend gates it + * yet — the engine lane's four-backend gate is the next dispatch — and today + * the repo gives four different answers: `formula` and `driver-memory` say + * FALSE, `driver-sql` rejects at top level but says TRUE inside a combinator, + * `read-scope-sql` throws its own fail-closed error, `driver-mongodb` emits an + * exact-match on an empty document. Beyond that, {@link FilterLogicCase} has no + * way to spell "this filter must be REJECTED": `expected` is a row-id list, and + * an empty list means "matched nothing", which is precisely the FALSE answer + * the ruling did NOT take. Enrolling this case needs the table's own shape + * extended first (an `expectRejection` discriminant, or a sibling table) — + * deliberately not invented here. The case lands with the four-backend gate. */ import type { FilterCondition } from './filter.zod'; diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 8cdff1cca7..55962b45af 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -227,8 +227,17 @@ export type FilterCondition = { /** Logical OR - at least one condition must be true */ $or?: FilterCondition[]; - - /** Logical NOT - negates the condition */ + + /** + * Logical NOT - negates the condition, **NULL-safely** (#5146). + * + * A row whose compared column is NULL does NOT satisfy the negated condition + * and IS returned. In SQL terms the operand is negated as + * `NOT (…) OR col IS NULL` rather than as a bare `NOT (…)`. + * + * See {@link FilterConditionSchema} for why this is part of the contract + * rather than each backend's own choice. + */ $not?: FilterCondition; }; @@ -242,6 +251,51 @@ export type FilterCondition = { * `z.input`, so the authoring surface underneath goes unchecked. Nothing here * carries a `.default()` or a `.transform()`, so input and output are the same * type and the second argument is simply the first. + * + * ## `$not` is NULL-safe (#5146, maintainer ruling 2026-08-04) + * + * **A row whose compared column is NULL does NOT satisfy the negated condition + * and IS returned** — `NOT (…) OR col IS NULL`, not a bare `NOT (…)`. + * + * This is written down here because it was not, and the omission had a cost. + * The schema declared `$not` beside `$and` / `$or` and said nothing about what + * it MEANS, so each backend answered from its own host language: the SQL + * compilers negated in three-valued logic (`NULL = 'won'` is UNKNOWN, + * `NOT UNKNOWN` is UNKNOWN, a `WHERE` keeps only TRUE) and dropped every + * NULL-column row, while `driver-memory` and `formula` evaluated in ordinary + * two-valued JS (`undefined !== 'won'`) and returned them. One declared + * operator, two answers, chosen by which driver happened to run the query. + * + * That is a permission defect, not a rounding difference. A CEL `!expr` in an + * RLS rule lowers to `{ $not: {…} }` (`packages/formula/src/cel-to-filter.ts`), + * so the SAME read scope admitted a different set of rows per backend. #5146 + * ruled the two-valued answer canonical: it is the majority, and nobody writing + * `!(stage == 'won')` expects rows with no stage to be hidden by it. + * + * Note the guard belongs on each LEAF inside the negation, not hoisted next to + * the `$not`: a top-level `NOT (…) OR col IS NULL` re-admits rows that satisfy + * a nested `$or` through a different branch, which widens the scope. Following + * each operator's own answer for a missing value is also what keeps `$ne` / + * `$nin` from being widened — `{ $not: { stage: { $ne: 'won' } } }` still means + * "the column IS that value". + * + * Conformance status, honestly: `driver-sql` (PR #5296), `driver-sqlite-wasm`, + * `driver-memory`, `formula` and `driver-mongodb` answer this way today. + * `read-scope-sql` and the analytics `filter-normalizer`, both in + * `packages/services/service-analytics`, still emit a bare `NOT (…)` and are + * tracked by #5297 — declaring the rule here is what makes that gap a tracked + * bug instead of an invisible one, per Prime Directive #10. + * + * ## Deliberately NOT declared here + * + * The boolean identities of the EMPTY combinators (`{ $and: [] }` = TRUE, + * `{ $or: [] }` = FALSE, `{ $not: {} }` = FALSE) are implemented by the four + * drivers but are NOT yet stated as contract, because two backends answer them + * by refusing the filter instead — see the note in + * `filter-logic-conformance.ts`. Likewise `{ field: {} }` (a field constrained + * by zero operators), which #5240 ruled must be REJECTED but which no backend + * gates yet. Declaring either before it is enforced everywhere would be exactly + * the `declared ≠ enforced` shape this file exists to prevent. */ export const FilterConditionSchema: z.ZodType = z.lazy(() => z.record(z.string(), z.unknown()).and( From e8a84426cb28feed18e9e2076f7bfa1e3d89a43d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 02:51:08 +0000 Subject: [PATCH 2/3] =?UTF-8?q?docs(spec):=20=E5=90=8C=E6=AD=A5=E8=BD=AE?= =?UTF-8?q?=E6=95=A3=E6=96=87=E6=A0=A1=E8=AE=A2=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=AF=B9=20main@cdfbee2f0=20=E5=AE=9E=E6=B5=8B=E5=90=8E?= =?UTF-8?q?=E8=90=BD=E7=AC=94=20(#5239)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本 PR 的 spec 半边是契约文档,机械合并会把 base 时代的论断带上 main; 逐条对当前 origin/main 实测后校订: - FilterConditionSchema 的 NULL-safe $not 合规段:read-scope-sql 已由 #5326 对齐(#5297 关闭)、filter-normalizer 已由 #5335 对齐(#5325 关闭),七个面全部一致 —— 「尚未合规、指向 #5297」改写为已闭合的事实。 - 「Deliberately NOT declared here」:空组合子单位元由「两立场对峙、 上交 #5322」改为「#5322 已拍板取单位元,实施在 #5365(排在本 PR 之后 合入);main 上两个 analytics 编译器今天仍拒收,故本 PR 仍不在此声明, 声明随 #5365 翻正」;{ field: {} } 由「无后端设闸」改为「#5327 已闸 四家,driver-mongodb 是唯一还在作答的后端(#5376)」。 - filter-logic-conformance.ts 族 2/3 状态行同步重测:族 2 的后端阻塞 已清零,唯余 fixture 工作;族 3 的四家闸门已落,阻塞改为表形扩展 + mongodb(#5376)。族 1 段落一字未动 —— 由 #5365 在其同步轮删除, 已约定分工。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB --- .../spec/src/data/filter-logic-conformance.ts | 53 ++++++++++--------- packages/spec/src/data/filter.zod.ts | 35 +++++++----- 2 files changed, 51 insertions(+), 37 deletions(-) diff --git a/packages/spec/src/data/filter-logic-conformance.ts b/packages/spec/src/data/filter-logic-conformance.ts index 61f8e005ef..8ca95a2a53 100644 --- a/packages/spec/src/data/filter-logic-conformance.ts +++ b/packages/spec/src/data/filter-logic-conformance.ts @@ -54,12 +54,13 @@ * ## Three case families that are RULED but not yet enrolled * * All three were ruled by the maintainer and are implemented in some backends. - * None is in the table, because **every one of them is red on at least one - * enrolled backend today**, and a red row here does not enforce a ruling — it - * just turns another lane's unfinished work into this table's failure. Each is - * recorded with what was actually measured, against `main` at `175d789`, so the - * next author does not have to re-measure. Add the rows in the PR that closes - * the gap, not before. + * None is in the table yet — a red row here does not enforce a ruling, it just + * turns another lane's unfinished work into this table's failure, and each + * family still has one blocker standing, named per family below. Family 1 is + * recorded with what was actually measured against `main` at `175d789`; + * families 2 and 3 were re-measured at this PR's 2026-08-05 sync against + * `cdfbee2f0`, so the next author does not have to re-measure. Add the rows in + * the PR that closes the gap, not before. * * ### 1. Boolean identities of the empty combinators (#5239) * @@ -91,28 +92,32 @@ * * A row whose column is NULL does not satisfy the negated condition and IS * returned. Landed in `driver-sql` via PR #5296; `driver-memory`, `formula`, - * `driver-sqlite-wasm` and `driver-mongodb` already agreed. `read-scope-sql` - * and `filter-normalizer` still emit a bare `NOT (…)` and return only row 2 - * where the others return rows 2, 3 and 4 — tracked by #5297. + * `driver-sqlite-wasm` and `driver-mongodb` already agreed; `read-scope-sql` + * was aligned by #5326 (closing #5297) and `filter-normalizer` by #5335 + * (closing #5325), so as of the 2026-08-05 sync (`cdfbee2f0`) every surface + * answers this family the same way — no backend blocker remains. * - * Enrolling this family also needs a fixture change, which is the other reason - * it is not a one-line addition: every column of {@link FILTER_LOGIC_ROWS} is - * non-null by construction, so a NULL-bearing column has to be added here AND - * declared in all seven harnesses that seed it. + * What still keeps it out of the table is the fixture: every column of + * {@link FILTER_LOGIC_ROWS} is non-null by construction, so a NULL-bearing + * column has to be added here AND declared in all seven harnesses that seed + * it. That is the whole remaining work item, and it is why this family is not + * a one-line addition. * * ### 3. `{ field: {} }` — a field constrained by zero operators (#5240) * - * Ruled **REJECT** (`INVALID_FILTER`) on all four backends. No backend gates it - * yet — the engine lane's four-backend gate is the next dispatch — and today - * the repo gives four different answers: `formula` and `driver-memory` say - * FALSE, `driver-sql` rejects at top level but says TRUE inside a combinator, - * `read-scope-sql` throws its own fail-closed error, `driver-mongodb` emits an - * exact-match on an empty document. Beyond that, {@link FilterLogicCase} has no - * way to spell "this filter must be REJECTED": `expected` is a row-id list, and - * an empty list means "matched nothing", which is precisely the FALSE answer - * the ruling did NOT take. Enrolling this case needs the table's own shape - * extended first (an `expectRejection` discriminant, or a sibling table) — - * deliberately not invented here. The case lands with the four-backend gate. + * Ruled **REJECT** (`INVALID_FILTER`), and since gated: #5327 landed the + * refusal on `driver-sql` (top level AND inside combinators), + * `driver-sqlite-wasm`, `driver-memory` (both filter surfaces) and `formula`, + * one wording, `INVALID_FILTER` / 400. As of the 2026-08-05 sync + * (`cdfbee2f0`) `driver-mongodb` is the one backend still ANSWERING it — an + * exact-match on an empty document — tracked by #5376. What blocks enrolment + * is the table's own shape: {@link FilterLogicCase} has no way to spell "this + * filter must be REJECTED" — `expected` is a row-id list, and an empty list + * means "matched nothing", which is precisely the FALSE answer the ruling did + * NOT take. Enrolling this case needs the shape extended first (an + * `expectRejection` discriminant, or a sibling table) — deliberately not + * invented here. The case lands with that extension, alongside the + * schema-side narrowing that stays with the spec lane. */ import type { FilterCondition } from './filter.zod'; diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 2a6c62438f..730d59f3b5 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -279,23 +279,32 @@ export type FilterCondition = { * `$nin` from being widened — `{ $not: { stage: { $ne: 'won' } } }` still means * "the column IS that value". * - * Conformance status, honestly: `driver-sql` (PR #5296), `driver-sqlite-wasm`, - * `driver-memory`, `formula` and `driver-mongodb` answer this way today. - * `read-scope-sql` and the analytics `filter-normalizer`, both in - * `packages/services/service-analytics`, still emit a bare `NOT (…)` and are - * tracked by #5297 — declaring the rule here is what makes that gap a tracked - * bug instead of an invisible one, per Prime Directive #10. + * Conformance status, re-measured at the 2026-08-05 sync of this PR (main @ + * `cdfbee2f0`): EVERY surface answers this way today. `driver-sql` (PR #5296), + * `driver-sqlite-wasm`, `driver-memory`, `formula` and `driver-mongodb` + * already did; `read-scope-sql` was aligned by #5326 (closing #5297) and the + * analytics `filter-normalizer` by #5335 (closing #5325). The gap an earlier + * revision of this paragraph tracked is closed — declaring the rule here is + * what turned it into a tracked bug instead of an invisible one, per Prime + * Directive #10, and this sentence is kept as the record that the tracking + * worked. * * ## Deliberately NOT declared here * * The boolean identities of the EMPTY combinators (`{ $and: [] }` = TRUE, - * `{ $or: [] }` = FALSE, `{ $not: {} }` = FALSE) are implemented by the four - * drivers but are NOT yet stated as contract, because two backends answer them - * by refusing the filter instead — see the note in - * `filter-logic-conformance.ts`. Likewise `{ field: {} }` (a field constrained - * by zero operators), which #5240 ruled must be REJECTED but which no backend - * gates yet. Declaring either before it is enforced everywhere would be exactly - * the `declared ≠ enforced` shape this file exists to prevent. + * `{ $or: [] }` = FALSE, `{ $not: {} }` = FALSE) are RULED — #5322 + * (maintainer, 2026-08-04) took the identity over the analytics compilers' + * fail-closed throw — but not yet stated here as contract: on main today + * `read-scope-sql` and `filter-normalizer` still refuse an empty `$and`/`$or`, + * and the ruling's implementation PR #5365 (aligns both compilers, enrolls the + * four cases in `FILTER_LOGIC_CASES`) is sequenced to land after this one. The + * declaration flips to stated contract with that PR, not here — declaring it + * first would out-run enforcement. Likewise `{ field: {} }` (a field + * constrained by zero operators): #5240 ruled it REJECTED and #5327 gated + * driver-sql / driver-sqlite-wasm / driver-memory / formula; `driver-mongodb` + * still answers it (tracked by #5376), and the schema-side narrowing stays + * with the spec lane. Declaring either before it is enforced everywhere would + * be exactly the `declared ≠ enforced` shape this file exists to prevent. */ export const FilterConditionSchema: z.ZodType = z.lazy(() => z.record(z.string(), z.unknown()).and( From 8069495674637f408b86957c23007a5838656ff7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 03:00:03 +0000 Subject: [PATCH 3/3] =?UTF-8?q?chore(spec):=20gen:schema=20=E5=89=8D?= =?UTF-8?q?=E7=A7=BB=20authorable-surface=20=E9=94=9A=E7=82=B9=E8=87=B3?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=90=8E=E7=9A=84=20merge-base=20(cdfbee2f0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 origin/main 后重建时由 gen:schema 写出(先 commit merge 再跑生成, #5370 的锚点倒退陷阱按序避开):baseRev 28ad90e → cdfbee2f0,随锚点带入 #5312 的 api/ApiEndpoint 键面。check:generated 9/9 up to date, check:authorable-surface 绿。非手改。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB --- packages/spec/authorable-surface.base.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/spec/authorable-surface.base.json b/packages/spec/authorable-surface.base.json index 8a81d3bbeb..b5239b7db6 100644 --- a/packages/spec/authorable-surface.base.json +++ b/packages/spec/authorable-surface.base.json @@ -1,6 +1,6 @@ { "description": "In-tree anchor for the authorable-surface deletion gate (#4650, #5235): a verbatim copy of the keys in authorable-surface.json as they stood at `baseRev`, a commit on origin/main. A build that CAN reach origin/main anchors on the merge base instead, and re-verifies this file against `baseRev` — so a PR that edits it to hide a deletion goes red wherever the network exists. A build that CANNOT reach GitHub (image-build stages, air-gapped, fork, historical-tag reproduction) anchors here instead of failing. Written only by `gen:schema`, only from a git-resolved baseline — never from the build that is being checked. See #5235.", - "baseRev": "28ad90e9adb901e66c96bb5be679f376885aec80", + "baseRev": "cdfbee2f08e3316d4606d21dd8e7c2403a59030b", "keys": [ "ai/AIModelConfig:maxTokens", "ai/AIModelConfig:model", @@ -492,6 +492,13 @@ "api/ApiDocumentationConfig:title", "api/ApiDocumentationConfig:ui", "api/ApiDocumentationConfig:version", + "api/ApiEndpoint:_lock", + "api/ApiEndpoint:_lockDocsUrl", + "api/ApiEndpoint:_lockReason", + "api/ApiEndpoint:_lockSource", + "api/ApiEndpoint:_packageId", + "api/ApiEndpoint:_packageVersion", + "api/ApiEndpoint:_provenance", "api/ApiEndpoint:authRequired", "api/ApiEndpoint:cacheTtl", "api/ApiEndpoint:description",