From e4a1edf38a5fde328c950a2692e31ebc6f2617a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 08:20:06 +0000 Subject: [PATCH 1/3] fix(filter): refuse a where on a virtual formula field at both doors (#8296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FILTER axis was the last of the three query axes with no unmaterializable verdict: a `where` on a `formula` field cleared `assertFilterFieldsExist` because the field IS known, reached a driver that materialises no column for it, and answered 200 with zero rows in BOTH directions — while SORT (#6994/#7095) and SEARCH (#6674) refuse the same field by name. Ingress: `assertFilterFieldsExist` grows a second verdict, judged by the same `@objectstack/spec/data` predicate the search axis uses (`isVirtualSearchField`), so gate and drivers cannot disagree about which types have a column. `summary`/`autonumber` keep filtering — both have real stored columns. Engine: `assertFilterIsMaterializable` closes the door the REST ingress cannot reach — a saved report forwards `query.filter` straight into `engine.find` — at `lowerWhereFilterArray`, the one seam every caller-supplied `where` passes through (find/findOne/count/aggregate/ update/delete). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH --- packages/metadata-protocol/src/protocol.ts | 118 ++++++++- packages/objectql/src/engine.ts | 25 +- .../objectql/src/filter-comparand-shape.ts | 165 +++++++++++++ .../src/query-expression-conformance.test.ts | 224 ++++++++++++++++++ 4 files changed, 512 insertions(+), 20 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 38ce97f975..f72046b870 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -5866,9 +5866,29 @@ export class ObjectStackProtocolImplementation implements * * Value shapes are NOT judged here: a wrong-typed or unrunnable filter is * `INVALID_FILTER`'s job (#4121 / #4181), already answered upstream in this - * same block. This gate answers exactly one question — does this field - * exist — with exactly the envelope the write path and the bare-key door - * already give it. + * same block. This gate answers questions about the NAME, with exactly the + * envelope the write path and the bare-key door already give it. + * + * [#8296] It answers TWO of them now — "does this field exist" and, second, + * "does this field's TYPE materialise a column to filter on". A `formula` + * field is known, undotted and unfilterable: it cleared this gate precisely + * BECAUSE the object declares it, reached a driver that has no column for + * it, and answered 200 with zero rows in BOTH directions. That was the last + * axis in this family still fail-open — SORT refuses the same field + * (#6994/#7095) and SEARCH refuses it (#6674) — and it is the shape the + * standing ruling of 2026-08-12 names: a declaration the platform cannot + * honour is refused at the latest checkpoint that can see the whole + * picture, naming the offending key path, never answered 200. + * + * SCOPE: this is an INGRESS gate, so it covers what reaches {@link + * findData}. The half it cannot reach — a caller handing a `where` straight + * to `engine.find` / `findOne` / `count` / `aggregate` / `update` / + * `delete`, which is how a saved report's `query.filter` travels + * (`plugin-reports` forwards it verbatim) — is closed at the engine's own + * filter seam by `assertFilterIsMaterializable` (`@objectstack/objectql`, + * `filter-comparand-shape.ts`), with the same `400 INVALID_FIELD` and the + * same remedy sentence. Same two-door shape, and same reason, as the sort + * axis' #7095. */ private assertFilterFieldsExist(object: string, where: unknown, param: string): void { if (!where || typeof where !== 'object') return; @@ -5878,20 +5898,92 @@ export class ObjectStackProtocolImplementation implements if (!gate) return; // Head segment only, exactly as the bare-key door judges `owner_id.name`. const unknown = names.filter((f) => !gate.known.has(f.split('.')[0])); - if (unknown.length === 0) return; - const first = unknown[0]; + if (unknown.length > 0) { + const first = unknown[0]; + const err: any = new Error( + `Query parameter '${param}' filters on '${first}', which is not a field on object ` + + `'${object}'` + + (unknown.length > 1 ? ` (also: ${unknown.slice(1).join(', ')})` : '') + + '. A filter on a field that does not exist can only match zero records, so the ' + + 'query was refused instead of answered with an empty list.' + + suggestFieldName(first, gate.declared), + ); + err.code = 'INVALID_FIELD'; + err.status = 400; + err.field = first; + err.fields = unknown; + err.object = object; + err.param = param; + throw err; + } + + // [#8296] The SECOND verdict on this axis: a name that is a REAL field + // of this object and still cannot be filtered on, because its TYPE + // materialises no column. It is the FILTER axis finally growing the + // verdict its two neighbours already have — {@link + // assertSortFieldsExist} splits `unknown` from unmaterializable + // (#6994) and {@link assertSearchFieldsAreSearchable} splits `unknown` + // from `virtual` (#6674) — and it was the last axis on which a + // declaration the platform cannot honour still answered 200. + // + // Measured on a real `ObjectQL` + this protocol, base cb43296ef + // (`is_open` a `formula` over the stored `status` column): + // + // ``` + // where { is_open: true } -> 0 rows, NO ERROR + // where { is_open: false } -> 0 rows, NO ERROR + // CONTROL where { status: 'open' } -> 4 rows + // CONTROL where { subtask_total: 5 } -> 1 row (`summary` HAS a column) + // ``` + // + // BOTH directions are wrong and the `false` one is the dangerous one: + // the same predicate against a STORED boolean returns every row, so a + // filter meaning "not yet done" silently becomes "no records at all". + // The response is indistinguishable from an empty table, and the + // formula READS correctly in that very same response (`applyFormulaPlan` + // hydrates it), so the field is visibly populated and simultaneously + // unfilterable. + // + // Judged by the same `@objectstack/spec/data` predicate the SEARCH axis + // uses ({@link isVirtualSearchField} / `SEARCH_VIRTUAL_TYPES`) rather + // than a list minted here, so this gate and the drivers cannot disagree + // about which types have a column. `summary` and `autonumber` are NOT + // in it and must not be: both get real stored columns and filter + // correctly — a gate widened to the spec's `COMPUTED_VALUE_TYPES` (the + // WRITE contract) would refuse two working types. + // + // PRECEDENCE — `unknown` first, then this, mirroring the sort axis' + // `unknown` > `dotted` > unmaterializable: identity errors before type + // errors. DOTTED names are deliberately NOT judged here: a dotted + // filter path has no verdict on this axis at all (its head being a real + // field is what carries it through the check above), and inventing one + // for the formula-headed case alone would answer two spellings of one + // unjudged shape differently. + const virtual = names.filter((f) => !f.includes('.') && isVirtualSearchField(gate.fields[f])); + if (virtual.length === 0) return; + const virtualFirst = virtual[0]; + const virtualType = String(gate.fields[virtualFirst]?.type ?? 'formula'); const err: any = new Error( - `Query parameter '${param}' filters on '${first}', which is not a field on object ` - + `'${object}'` - + (unknown.length > 1 ? ` (also: ${unknown.slice(1).join(', ')})` : '') - + '. A filter on a field that does not exist can only match zero records, so the ' - + 'query was refused instead of answered with an empty list.' - + suggestFieldName(first, gate.declared), + `Query parameter '${param}' filters on '${virtualFirst}', a virtual '${virtualType}' ` + + `field on object '${object}'` + + (virtual.length > 1 ? ` (also: ${virtual.slice(1).join(', ')})` : '') + + '. Its value is computed on read and never stored, so no driver materializes a ' + + 'column to filter on: the predicate reaches the driver, matches nothing, and the ' + + 'query answers an empty list under a 200 — in BOTH directions, so a false test ' + + 'returns no records where the same test against a stored boolean returns every ' + + 'record.' + // Deliberately the same remedy, in the same words, as the SORT + // axis' formula refusal (#6994) and #6673's SEARCH-axis + // correction, with only the verb changed to name this axis. One + // vocabulary across the doors: an author refused on two axes must + // not be sent two different ways. + + ` Denormalise the value onto '${object}' (a stored field, written when the source` + + ' changes) and filter that.', ); err.code = 'INVALID_FIELD'; err.status = 400; - err.field = first; - err.fields = unknown; + err.field = virtualFirst; + err.fields = virtual; err.object = object; err.param = param; throw err; diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index e2ddec9d7b..fff2e04052 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -38,7 +38,7 @@ import { // `packages/spec/src/data/bulk-write-hook-conformance.ts` so BOTH phases and // both verbs enforce one definition; the engine raises, the contract decides. import { MAX_BULK_PER_ROW_HOOK_ROWS, resolveBulkPerRowHookBudget } from '@objectstack/spec/data'; -import { assertListComparandShapes } from './filter-comparand-shape.js'; +import { assertListComparandShapes, assertFilterIsMaterializable } from './filter-comparand-shape.js'; // Seek pagination for the walks that must read EVERY row — the autonumber seed // scan is one (#6249). Shared with `summary-backfill` rather than re-rolled: // the cursor merge is the part that is easy to get subtly wrong. @@ -620,6 +620,7 @@ function lowerWhereFilterArray( object: string, operation: string, bag: T, + schema?: unknown, ): T { if (!bag) return bag; const where = (bag as Record).where; @@ -631,6 +632,12 @@ function lowerWhereFilterArray( // one either way — it reads the lowered condition, which is what both doors // produce. assertListComparandShapes(object, operation, where); + // [#8296] The unmaterializable-FIELD door, on the same object form. It sits + // beside the shape gate because the two answer different questions about + // the same predicate — "can this comparand run" vs "is there a column to + // run it against" — and because this seam is the one place EVERY + // caller-supplied `where` passes through, whichever verb it arrived by. + assertFilterIsMaterializable(object, operation, schema, where); // [#7872] The comparand-type door, on the OBJECT form. `parseFilterAST` // runs the same walk on everything it lowers or passes through, but // NEITHER door routes an object-form filter through it — Door 1 gates on @@ -687,6 +694,10 @@ function lowerWhereFilterArray( // comparand — `['status', 'not_in', 'done']` lowers to `{status: {$nin: // 'done'}}` and a scalar `$nin` is what reached the driver as a 500. assertListComparandShapes(object, operation, condition); + // [#8296] Same door as the object branch above, on the LOWERED condition — + // the array sugar (`[['is_open','=',true]]`) names fields too, and a gate on + // one branch would answer one mistake two ways depending on the spelling. + assertFilterIsMaterializable(object, operation, schema, condition); lowered.where = condition; return lowered as T; } @@ -7213,7 +7224,7 @@ export class ObjectQL implements IObjectQLEngine { // (#4371, three shipped instances in #4370). query = foldEngineOptionAliases(object, 'find', query, ENGINE_QUERY_SLOTS, ENGINE_WIRE_ONLY_SLOTS); rejectUnknownEngineOptions(object, 'find', query, ENGINE_FIND_OPTION_KEYS); - query = lowerWhereFilterArray(object, 'find', query); + query = lowerWhereFilterArray(object, 'find', query, this._registry.getObject(object)); this.logger.debug('Find operation starting', { object, query }); const driver = this.getDriver(object); // `object` LAST: the resolved name must win. Spread-first used to let a @@ -7388,7 +7399,7 @@ export class ObjectQL implements IObjectQLEngine { // matters here too: findOne({ sort }) means "first row of THIS order". query = foldEngineOptionAliases(objectName, 'findOne', query, ENGINE_QUERY_SLOTS, ENGINE_WIRE_ONLY_SLOTS); rejectUnknownEngineOptions(objectName, 'findOne', query, ENGINE_FIND_OPTION_KEYS); - query = lowerWhereFilterArray(objectName, 'findOne', query); + query = lowerWhereFilterArray(objectName, 'findOne', query, this._registry.getObject(objectName)); this.logger.debug('FindOne operation', { objectName }); const driver = this.getDriver(objectName); // `object` after the spread for the same reason as find(); `limit: 1` @@ -8167,7 +8178,7 @@ export class ObjectQL implements IObjectQLEngine { // [#5158] Lower before the by-id extraction below reads `where.id`: on an // array that read is `undefined` whatever the caller wrote, so an // `update({ where: [['id','=',x]] })` used to route to the multi-row path. - options = lowerWhereFilterArray(object, 'update', options); + options = lowerWhereFilterArray(object, 'update', options, this._registry.getObject(object)); // Expand `{filter-placeholder}` values BEFORE the id is extracted (#3810). // The read path resolves them; without the same call here the SAME filter @@ -9479,7 +9490,7 @@ export class ObjectQL implements IObjectQLEngine { rejectUnknownEngineOptions(object, 'delete', options, ENGINE_DELETE_OPTION_KEYS); // [#5158] Same ordering reason as update(): the dispatch decision below // reads `where.id`, which an unlowered array never carries. - options = lowerWhereFilterArray(object, 'delete', options); + options = lowerWhereFilterArray(object, 'delete', options, this._registry.getObject(object)); // Expand `{filter-placeholder}` values before the id is extracted — same // reasoning as update() above (#3810). @@ -9937,7 +9948,7 @@ export class ObjectQL implements IObjectQLEngine { // `query.where` only, so an unfolded `{ filter }` counted the whole table. query = foldEngineOptionAliases(object, 'count', query, ENGINE_WHERE_SLOTS); rejectUnknownEngineOptions(object, 'count', query, ENGINE_COUNT_OPTION_KEYS); - query = lowerWhereFilterArray(object, 'count', query); + query = lowerWhereFilterArray(object, 'count', query, this._registry.getObject(object)); const driver = this.getDriver(object); // The AST must ride on the opCtx so the security/sharing middlewares can @@ -10041,7 +10052,7 @@ export class ObjectQL implements IObjectQLEngine { // `query.where` only, so an unfolded `{ filter }` aggregated every row. query = foldEngineOptionAliases(object, 'aggregate', query, ENGINE_WHERE_SLOTS); rejectUnknownEngineOptions(object, 'aggregate', query, ENGINE_AGGREGATE_OPTION_KEYS); - query = lowerWhereFilterArray(object, 'aggregate', query); + query = lowerWhereFilterArray(object, 'aggregate', query, this._registry.getObject(object)); this.rejectCredentialAggregation(object, query); const driver = this.getDriver(object); this.logger.debug(`Aggregate on ${object} using ${driver.name}`, query); diff --git a/packages/objectql/src/filter-comparand-shape.ts b/packages/objectql/src/filter-comparand-shape.ts index 36c63da6a0..b5ddd252bf 100644 --- a/packages/objectql/src/filter-comparand-shape.ts +++ b/packages/objectql/src/filter-comparand-shape.ts @@ -72,6 +72,7 @@ */ import { StandardErrorCode } from '@objectstack/spec/api'; +import { isVirtualSearchField } from '@objectstack/spec/data'; /** * The operators whose comparand `FieldOperatorsSchema` declares as a list, with @@ -296,3 +297,167 @@ function assertFieldListComparands( } } } + +/** + * [#8296] FILTER on a field whose value is computed on read — refused HERE, on + * the engine's own filter seam, and no longer only at the REST ingress. + * + * `formula` is the one field type no driver materialises a column for. Three + * query axes can name a field, and until this landed only two of them said so: + * SORT answers `400 INVALID_SORT` (#6994 at ingress, #7095 at this engine + * boundary) and SEARCH answers `400 INVALID_FIELD` (#6674) — while FILTER + * accepted the same field, handed the predicate to a driver with no column for + * it, and answered 200 with zero rows. Measured on a real `ObjectQL`, base + * cb43296ef, `is_open` a `formula` over the stored `status` column: + * + * ``` + * engine.find(o, { where: { is_open: true } }) -> [] 200, no error + * engine.find(o, { where: { is_open: false } }) -> [] 200, no error + * engine.findOne(o, { where: { is_open: true } }) -> null + * engine.count(o, { where: { is_open: true } }) -> 0 + * CONTROL find(o, { where: { status: 'open' } }) -> 4 rows + * ``` + * + * BOTH directions returning nothing is what makes this worse than the sort + * axis' dropped ORDER BY: a filter changes the row SET, and the `false` + * direction is the dangerous one — the same predicate against a STORED boolean + * returns every row, so a filter meaning "not yet done" silently becomes "no + * records at all". The rows are not merely misordered; they are absent, and the + * response is indistinguishable from an empty table. The formula READS + * correctly in that same response (`applyFormulaPlan` hydrates it after the + * driver returns), so the field is visibly populated and simultaneously + * unfilterable. + * + * WHY A REFUSAL AND NOT A SILENT ZERO: the standing maintainer ruling of + * 2026-08-12 — if the platform cannot honour a declaration, refuse it at the + * latest checkpoint that can see the whole picture, name the offending key + * path, and never answer 200. Same direction as ADR-0032 (no silent failure) + * and as the sort axis' #7095 ruling one axis over. + * + * WHY AT THIS SEAM AND NOT ONLY AT INGRESS: #7095 had to add + * `assertOrderByIsMaterializable` inside this package because a saved report's + * `query.orderBy` is forwarded verbatim into `engine.find` and never passes the + * REST door. Filters travel the SAME path — `plugin-reports`' `executeReport` + * calls `this.engine.find(report.object_name, { where: q.filter, … })` — so an + * ingress-only fix would have left the author-reachable half open. This gate + * runs inside `lowerWhereFilterArray`, the one seam EVERY caller-supplied + * `where` passes through (`find` / `findOne` / `count` / `aggregate` / `update` + * / `delete`), which is what makes a new verb unable to miss it by omission. + * + * ORDERING: it judges the CALLER's own `where`, before the middleware chain + * composes RLS / sharing / tenant predicates onto `opCtx.ast.where`. That is + * deliberate — an injected read filter is the platform's own, not a + * declaration the caller can fix, and refusing one would turn a policy into a + * 400 nobody can act on. + * + * SCOPE — the unmaterializable verdict ONLY. An UNKNOWN filter field is not + * judged here: that is the ingress gate's first verdict + * (`assertFilterFieldsExist`, #7534), the engine deliberately keeps its + * registry-less tolerance, and widening this door to it is a separate posture + * change on a second verdict, exactly as #7095 declined to inherit sort's + * `unknown` and `dotted` legs. DOTTED filter paths are likewise untouched — + * they have no verdict on this axis at either door. + * + * A registry-less host (`schema.fields` undefined) returns early, exactly as + * the ingress gate returns early when `resolveQueryFields` cannot answer: a + * door that cannot see the field map must not invent a verdict about it. + * + * The wording deliberately shares its remedy sentence with the ingress door, + * duplicated rather than imported because `metadata-protocol` is assembled FROM + * an engine, so the engine cannot import from it without inverting the + * layering (the same argument `assertOrderByIsMaterializable` records). The + * agreement pin in `query-expression-conformance.test.ts` is what keeps the + * duplication honest. + */ +export function assertFilterIsMaterializable( + object: string, + operation: string, + schema: unknown, + where: unknown, +): void { + const fields = (schema as { fields?: Record } | undefined)?.fields; + if (!fields || typeof fields !== 'object') return; + const named = collectFilterFieldNames(where); + if (named.length === 0) return; + // Judged by the same `@objectstack/spec/data` predicate the SEARCH axis and + // the ingress door use, never a list minted here, so gate and drivers cannot + // disagree about which types have a column. `summary` and `autonumber` are + // deliberately NOT in it: both get real stored columns and filter correctly. + const virtual = named.filter((f) => isVirtualSearchField(fields[f] as never)); + if (virtual.length === 0) return; + const first = virtual[0]; + const type = String((fields[first] as { type?: unknown } | undefined)?.type ?? 'formula'); + const err = new Error( + `ObjectQL.${operation}('${object}') filters on '${first}', a virtual ${type} field on ` + + `'${object}' — a ${type} value is computed on read, so no driver materialises a column ` + + 'to filter on' + + (virtual.length > 1 ? ` (also: ${virtual.slice(1).join(', ')})` : '') + + '. The predicate was not applied as written: it reaches the driver, matches nothing, and ' + + 'returns an empty result in BOTH directions — a false test answers no records where the ' + + 'same test against a stored boolean answers every record.' + // Deliberately the SAME remedy, in the same words, as the ingress door's + // formula refusal, with only the verb naming this axis. One vocabulary + // across the doors: a caller refused at the REST boundary and a caller + // refused here must not be sent two different ways. + + ` Denormalise the value onto '${object}' (a stored field, written when the source` + + ' changes) and filter that.', + ) as Error & { code?: string; status?: number; field?: string; fields?: string[]; object?: string }; + // `INVALID_FIELD`, not `INVALID_FILTER`, and not a new code: this verdict is + // about the NAME's type, which is the question the ingress door answers with + // `INVALID_FIELD` on its neighbouring `unknown` verdict and the SEARCH axis + // answers with `INVALID_FIELD` for this very field class. `INVALID_FILTER` is + // this package's VALUE-shape envelope (#5869 / #7047) — a different fact. + // 400 rather than 500 for the reason `assertOrderByIsMaterializable` records: + // one condition keeps ONE wire code however the caller reached it, so a host + // surfacing engine errors over HTTP answers the same envelope on both doors. + err.status = 400; + err.code = StandardErrorCode.enum.INVALID_FIELD; + err.field = first; + err.fields = virtual; + err.object = object; + throw err; +} + +/** + * Every key of a `FilterCondition` that NAMES A FIELD of THIS object, structure + * discarded — whether a predicate sits under an `$or` changes nothing about + * whether its column exists. + * + * The same three conservative rules the ingress collector applies + * (`collectFilterFieldKeys`, `metadata-protocol`), for the same reasons: + * + * - **A `$`-prefixed key is never a field.** `$and` / `$or` / `$not` are + * recursed into; any OTHER `$` key is skipped WITHOUT descending, so an + * unrecognised combinator leaves the fields beneath it ungated — a hole, not + * a false 400, which is the right failure direction for a gate that exists to + * stop wrong answers rather than invent new ones. + * - **A field key's VALUE is not descended into.** It is an operator bag + * (`{$gte: 18}`) or a nested-relation condition (`{owner: {region: 'NA'}}`), + * and the latter's keys belong to a DIFFERENT object whose field map this + * gate has not resolved. + * - **A DOTTED key is dropped here**, not judged on its head: this door has one + * verdict and a dotted filter path has none on this axis (see the scope note + * above). + * + * `depth` is a cheap backstop against a self-referential `where` — in-process + * callers hand over live objects, and a gate that can hang the read path is + * worse than the defect it closes. + */ +function collectFilterFieldNames(where: unknown, out: string[] = [], depth = 0): string[] { + if (depth > 32) return out; + if (!isFilterNode(where)) return out; + for (const [key, value] of Object.entries(where)) { + if (key.startsWith('$')) { + if (key !== '$and' && key !== '$or' && key !== '$not') continue; + if (Array.isArray(value)) { + for (const arm of value) collectFilterFieldNames(arm, out, depth + 1); + } else { + collectFilterFieldNames(value, out, depth + 1); + } + continue; + } + if (key.includes('.')) continue; + out.push(key); + } + return out; +} diff --git a/packages/objectql/src/query-expression-conformance.test.ts b/packages/objectql/src/query-expression-conformance.test.ts index f3e39939f2..f91250b0a3 100644 --- a/packages/objectql/src/query-expression-conformance.test.ts +++ b/packages/objectql/src/query-expression-conformance.test.ts @@ -101,6 +101,22 @@ const taskObject = { name: 'subtask_total', label: 'Subtask total', type: 'summary' as const, summaryOperations: { object: 'showcase_task', field: 'estimate', function: 'sum' as const }, }, + // [#8296] A BOOLEAN formula, beside the text one above, because the + // FILTER axis fails in a direction the sort axis does not have: the + // dangerous case is `{ is_open: false }`, which against a stored + // boolean returns every row and against a virtual one returns none. + // Its expression reads a stored column (`status`), so every assertion + // about it has a stored CONTROL with a known, different answer. + is_open: { + name: 'is_open', label: 'Open?', type: 'formula' as const, + expression: 'record.status == "open"', returnType: 'boolean' as const, + }, + // [#8296] The THIRD "calculated" type, and the second control the + // filter gate must not catch: `autonumber` is server-assigned but + // STORED, so it filters exactly like any other column. It sits beside + // `subtask_total` so both non-members of the virtual family are pinned + // on the same axis at once. + ticket_no: { name: 'ticket_no', label: 'Ticket #', type: 'autonumber' as const }, }, }; @@ -281,6 +297,11 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin // A control that agreed with either would pass against a driver // that ignored `orderBy` entirely. subtask_total: [2, 5, 1, 4, 3][i], + // [#8296] Seeded, as a real driver's stored autonumber column + // is: `C A E B D` get 1..5, so `ticket_no: 3` is `E` — a value + // that matches neither insertion nor title order, so the + // control cannot hold vacuously. + ticket_no: i + 1, }); }); stores.set('showcase_task', tasks); @@ -291,6 +312,7 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin expect(titles(r)).toEqual(INSERTION_ORDER); }); + // ───────────────────────────────────────────────────────────── // SORT — control group // ───────────────────────────────────────────────────────────── @@ -837,6 +859,208 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin expect(rows.map((r: any) => r.sort_key).sort()).toEqual(['A', 'B', 'C', 'D', 'E']); }); + // ───────────────────────────────────────────────────────────── + // FILTER — [#8296] the third axis' unmaterializable verdict, on both + // doors. `formula` is the one field type no driver materialises a column + // for; SORT refuses it (#6994/#7095) and SEARCH refuses it (#6674), while + // FILTER accepted it and answered 200 with ZERO ROWS. Measured on this + // file's base (cb43296ef), real `ObjectQL` + real protocol: + // + // INGRESS where {is_open:true} -> 0 rows, NO ERROR + // INGRESS where {is_open:false} -> 0 rows, NO ERROR + // ENGINE find/findOne/count -> 0 rows / null / 0, NO ERROR + // CONTROL where {status:'open'} -> 4 rows + // CONTROL where {subtask_total:5} -> 1 row (`summary` HAS a column) + // + // Worse than the sort axis one axis over: a filter changes the row SET, + // and BOTH directions answer nothing — so `{is_open:false}`, which against + // a stored boolean returns every row, silently became "no records at all". + // ───────────────────────────────────────────────────────────── + + it('CONTROL — stored columns still filter, in both directions', async () => { + // FIRST, because every refusal pin below is vacuous against a harness + // whose filtering is simply broken: the `false` direction of a REAL + // predicate must return the rows the virtual one cannot. + const open: any = await protocol.findData({ object: 'showcase_task', query: { where: { status: 'open' } } }); + expect(titles(open)).toEqual(['A', 'E', 'B', 'D']); + const done: any = await protocol.findData({ object: 'showcase_task', query: { where: { status: 'done' } } }); + expect(titles(done)).toEqual(['C']); + }); + + it('CONTROL — a `summary` field still filters: the family is `formula`, not "computed"', async () => { + // The regression this gate must never cause. `summary` and + // `autonumber` get REAL stored columns and filter correctly, and both + // are excluded by the shared `SEARCH_VIRTUAL_TYPES` predicate. This is + // what fails if the gate is ever widened to the spec's + // `COMPUTED_VALUE_TYPES` (`formula`/`summary`/`autonumber`) — the WRITE + // contract, which would refuse two working types on a read axis. + const bySummary: any = await protocol.findData({ object: 'showcase_task', query: { where: { subtask_total: 5 } } }); + expect(titles(bySummary)).toEqual(['A']); + const byAutonumber: any = await protocol.findData({ object: 'showcase_task', query: { where: { ticket_no: 3 } } }); + expect(titles(byAutonumber)).toEqual(['E']); + // Through the engine door too — same two types, same answer. + expect((await engine.find('showcase_task', { where: { subtask_total: 5 } })).map((r: any) => r.title)).toEqual(['A']); + expect((await engine.find('showcase_task', { where: { ticket_no: 3 } })).map((r: any) => r.title)).toEqual(['E']); + }); + + it.each([ + ['true — the direction that merely looks empty', { where: { is_open: true } }], + ['FALSE — the dangerous one: a stored boolean answers every row', { where: { is_open: false } }], + ['a text formula', { where: { sort_key: 'A' } }], + ['an operator bag', { where: { sort_key: { $in: ['A', 'B'] } } }], + ['second of two predicates', { where: { status: 'open', is_open: true } }], + ['under $and', { where: { $and: [{ status: 'open' }, { is_open: true }] } }], + ['under $or', { where: { $or: [{ is_open: true }, { is_open: false } ] } }], + ['the `filter` spelling', { filter: { is_open: true } }], + ['the `filters` spelling', { filters: { is_open: true } }], + ['the OData `$filter` spelling', { $filter: ['is_open', '=', true] }], + ['the filter-ARRAY sugar', { where: [['is_open', '=', true]] }], + ])('filtering on a formula field is a 400, not a silent empty list — %s', async (_label, query) => { + // `is_open` / `sort_key` are REAL fields of this object, so they are in + // `gate.known` and cleared the #7534 unknown check; they carry no dot. + // They then reached a driver with no column for them. + await expect(protocol.findData({ object: 'showcase_task', query })) + .rejects.toMatchObject({ + status: 400, + code: 'INVALID_FIELD', + object: 'showcase_task', + }); + }); + + it('the ingress refusal names the offending key path, the type and the fix', async () => { + const err: any = await protocol + .findData({ object: 'showcase_task', query: { where: { is_open: false } } }) + .then(() => null, (e: unknown) => e); + expect(err).toBeTruthy(); + // ADR-0112 envelope — a rejection case asserts code AND status, never + // merely that something was thrown. + expect(err.status).toBe(400); + expect(err.code).toBe('INVALID_FIELD'); + // The standing ruling's third clause: NAME the offending key path. + expect(err.field).toBe('is_open'); + expect(err.param).toBe('where'); + // It must say WHICH type, or the author cannot tell this apart from a + // typo — the whole reason it needs its own verdict. + expect(err.message).toMatch(/a virtual 'formula' field on object 'showcase_task'/); + expect(err.message).toMatch(/computed on read/); + // ...and it must state the consequence a caller cannot infer from a + // status code: the answer would have been an empty list, both ways. + expect(err.message).toMatch(/BOTH directions/); + }); + + it('the refusal names the caller’s OWN wire spelling, not `where`', async () => { + // #4226's discipline: telling someone who sent `?$filter=…` that + // "'where' is invalid" names a parameter absent from their request. + const err: any = await protocol + .findData({ object: 'showcase_task', query: { $filter: ['is_open', '=', true] } }) + .then(() => null, (e: unknown) => e); + expect(err.status).toBe(400); + expect(err.code).toBe('INVALID_FIELD'); + expect(err.param).toBe('$filter'); + }); + + it('precedence is unknown > unmaterializable — identity errors first', async () => { + // The same order the sort axis (`unknown` > `dotted` > type) and the + // expand axis (`unknown` > `not-a-reference`) use. Pinned so it stays a + // decision rather than an accident: the older verdict keeps answering + // exactly what it answered before this gate grew a second one. + await expect(protocol.findData({ + object: 'showcase_task', query: { where: { no_such_field: 1, is_open: true } }, + })).rejects.toMatchObject({ status: 400, code: 'INVALID_FIELD', field: 'no_such_field' }); + }); + + it.each([ + ['find', (e: ObjectQL) => e.find('showcase_task', { where: { is_open: true } })], + ['findOne', (e: ObjectQL) => e.findOne('showcase_task', { where: { is_open: true } })], + ['count', (e: ObjectQL) => e.count('showcase_task', { where: { is_open: true } })], + ['aggregate', (e: ObjectQL) => e.aggregate('showcase_task', { + where: { is_open: true }, aggregations: [{ function: 'count', field: 'id', alias: 'n' }], + })], + ['update', (e: ObjectQL) => e.update('showcase_task', { status: 'done' }, { where: { is_open: true }, multi: true })], + ['delete', (e: ObjectQL) => e.delete('showcase_task', { where: { is_open: true }, multi: true })], + ])('`engine.%s` REFUSES it too — the door the REST ingress cannot reach', async (_verb, call) => { + // The half an ingress-only fix leaves open, and it is AUTHOR-reachable + // rather than merely internal: `plugin-reports`' `executeReport` + // forwards a saved report's `query.filter` verbatim into + // `engine.find(object, { where: q.filter, … })`, exactly as #7095 + // measured for `query.orderBy`. Flows and dashboards travel the same + // path. Every verb that accepts a caller `where` passes through the one + // lowering seam this gate lives in, which is why all six answer alike. + await expect(call(engine)).rejects.toMatchObject({ + status: 400, + code: 'INVALID_FIELD', + field: 'is_open', + object: 'showcase_task', + }); + }); + + it('the engine refusal names the entry point, the field and the fix', async () => { + const err: any = await engine + .find('showcase_task', { where: { is_open: false } }) + .then(() => null, (e: unknown) => e); + expect(err).toBeTruthy(); + expect(err.status).toBe(400); + expect(err.code).toBe('INVALID_FIELD'); + // A caller who never wrote a query parameter must be told which door + // refused them — the same reason the sort engine door names itself. + expect(err.message).toMatch(/ObjectQL\.find\('showcase_task'\)/); + expect(err.message).toMatch(/a virtual formula field on 'showcase_task'/); + expect(err.fields).toEqual(['is_open']); + }); + + it('the FILTER refusals agree word-for-word with the SORT refusals on the remedy', async () => { + // Pins the AGREEMENT itself rather than each wording separately — the + // one-vocabulary-across-doors discipline. Four doors share one + // sentence, differing only in the verb that names the axis: an author + // refused on two axes must not be sent two different ways, which is + // exactly how #4256 and #6673 drifted apart in the first place. + const stem = /Denormalise the value onto 'showcase_task' \(a stored field, written when the source changes\) and /; + const filterIngress: any = await protocol + .findData({ object: 'showcase_task', query: { where: { is_open: true } } }) + .then(() => null, (e: unknown) => e); + const filterEngine: any = await engine + .find('showcase_task', { where: { is_open: true } }) + .then(() => null, (e: unknown) => e); + const sortIngress: any = await protocol + .findData({ object: 'showcase_task', query: { sort: 'sort_key' } }) + .then(() => null, (e: unknown) => e); + const sortEngine: any = await engine + .find('showcase_task', { orderBy: [{ field: 'sort_key', order: 'asc' }] }) + .then(() => null, (e: unknown) => e); + for (const err of [filterIngress, filterEngine, sortIngress, sortEngine]) { + expect(err.message).toMatch(stem); + } + expect(filterIngress.message).toMatch(new RegExp(stem.source + 'filter that\\.')); + expect(filterEngine.message).toMatch(new RegExp(stem.source + 'filter that\\.')); + expect(sortIngress.message).toMatch(new RegExp(stem.source + 'sort by that\\.')); + expect(sortEngine.message).toMatch(new RegExp(stem.source + 'sort by that\\.')); + }); + + it('BLAST RADIUS — a formula field is still readable, projectable and computed', async () => { + // This card narrows ONE axis. A refusal that also stopped formulas + // being RETURNED would be a much larger change wearing this one's + // clothes — and the hydration is precisely what makes the defect so + // hard to see: the value is visibly present in the response that + // cannot filter on it. + const rows = await engine.find('showcase_task', { fields: ['id', 'title', 'is_open'] }); + expect(rows).toHaveLength(5); + expect(rows.map((r: any) => r.is_open)).toEqual([false, true, true, true, true]); + const viaIngress: any = await protocol.findData({ object: 'showcase_task', query: { select: 'id,title,sort_key' } }); + expect(viaIngress.records.map((r: any) => r.sort_key).sort()).toEqual(['A', 'B', 'C', 'D', 'E']); + // And an unfiltered read is untouched. + expect(titles(await protocol.findData({ object: 'showcase_task' }))).toEqual(INSERTION_ORDER); + }); + + it('a registry-less host keeps its old answer — a door that cannot see the field map invents no verdict', async () => { + // Same early return both gates already make: `resolveQueryFields` + // returns null and `schema.fields` is undefined for an object nobody + // registered, so the engine door must not refuse there. + const bare = new ObjectQL(); + bare.registerDriver(makeStubDriver().driver, true); + await bare.init(); + await expect(bare.find('unregistered_object', { where: { anything: true } })).resolves.toEqual([]); + }); + // ───────────────────────────────────────────────────────────── // SELECT — control group, then rejected // ───────────────────────────────────────────────────────────── From 5e9aa78dcdada258080e27a4e35ba7f024645edd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 09:15:55 +0000 Subject: [PATCH 2/3] chore: changeset for the filter-axis unmaterializable verdict (#8296) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH --- .changeset/filter-formula-field-refusal.md | 69 ++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .changeset/filter-formula-field-refusal.md diff --git a/.changeset/filter-formula-field-refusal.md b/.changeset/filter-formula-field-refusal.md new file mode 100644 index 0000000000..e72bf1fe7d --- /dev/null +++ b/.changeset/filter-formula-field-refusal.md @@ -0,0 +1,69 @@ +--- +"@objectstack/metadata-protocol": minor +"@objectstack/objectql": minor +--- + +fix(filter): a `where` on a virtual `formula` field is refused, not answered with zero rows (#8296) + +`formula` is the one field type no driver materialises a column for. Three query +axes can name a field; until now only two of them said so. + +| axis | verdict for a `formula` field | +|:---|:---| +| SORT | `400 INVALID_SORT`, ingress (#6994) and engine (#7095) | +| SEARCH | `400 INVALID_FIELD`, refused by name (#6674) | +| **FILTER** | **accepted — 200, 0 rows, no error** | + +`assertFilterFieldsExist` computed exactly one verdict — is this name a field of +the object — and a `formula` field IS one, so the predicate cleared the door and +reached a driver with no column behind it. Measured on a real `ObjectQL`, with +`is_open` a `formula` over the stored `status` column: + +``` +where { is_open: true } -> 0 rows, no error +where { is_open: false } -> 0 rows, no error +CONTROL where { status: 'open' } -> 4 rows +CONTROL where { subtask_total: 5 } -> 1 row (`summary` HAS a column) +``` + +Both directions are wrong and the `false` one is the dangerous one: the same +predicate against a STORED boolean returns every row, so a filter meaning "not +yet done" silently became "no records at all" — a row SET changed under a 200, +which no amount of inspecting the response can reveal. The formula READS +correctly in that very same response, so the field is visibly populated and +simultaneously unfilterable. + +Both doors now refuse it with `400 INVALID_FIELD`, naming the offending key path +and prescribing the remedy the sort and search axes already share: + +- **ingress** — `assertFilterFieldsExist` grows a second verdict, after + `unknown`, covering everything that reaches `findData`: the list route, + `POST /data/:object/query`, the export route and the RPC dispatcher, in every + filter spelling (`where` / `filter` / `filters` / `$filter`, the array sugar, + and nested `$and` / `$or`); +- **engine** — `assertFilterIsMaterializable` closes the half the ingress cannot + reach. It is author-reachable, not merely internal: a saved report's + `query.filter` is forwarded verbatim into `engine.find`, exactly as #7095 + measured for `query.orderBy`. It runs at the engine's one filter-lowering + seam, so `find`, `findOne`, `count`, `aggregate`, `update` and `delete` all + answer alike, and it judges the CALLER's `where` only — a middleware-injected + RLS or sharing predicate is the platform's own and is never refused. + +Both doors judge the field by the same `@objectstack/spec/data` predicate the +search axis uses (`isVirtualSearchField` / `SEARCH_VIRTUAL_TYPES`) rather than a +locally minted type list, so a gate and the drivers cannot disagree about which +types have a column. + +**`summary` and `autonumber` are unaffected and still filter** — both get real +stored columns; the set is exactly `formula`. Reading, projecting and computing a +formula field are untouched; only the predicate is refused. + +**What to change if this refuses one of your queries:** denormalise the value +onto the object (a stored field, written when the source changes) and filter +that. There is no mechanical rewrite in either direction — the platform cannot +invent the stored column, and it must not filter post-hoc after the formulas are +evaluated, because the driver has already applied `limit` / `offset`, so a +post-hoc predicate would filter an arbitrary PAGE. Grep your saved reports, +flows, dashboards and view filters for a filtered field whose object declares it +as a `formula`. Every shipped example app in this repo was swept: none filters on +one, so nothing in-tree needed changing. From b386caffe9a412fbce424894abc475ba8bb1ab58 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 10:39:39 +0000 Subject: [PATCH 3/3] test(app-todo): the derive-route reverse test now pins the refusal envelope (#8296) `derived-flag-removal.test.ts` registers a test-local formula-shaped object (`derived_task`, invented by that file) to record why #7226 removed two inert flags rather than deriving them, and it pinned the exact behaviour #8296 abolishes: filtering a formula answering 0 rows with no error. Its three filtering assertions now assert the rejection envelope (status 400, INVALID_FIELD, field, object) instead of an empty array, and the `it` title no longer claims "0 rows, no error". #7226's decision is unchanged and its reasoning is stronger: a formula field still materialises no column and still cannot carry a predicate, so the eight app filters that named those flags still could not have worked. Only the failure mode changed, from an invisible zero to a named 400 -- which is the exception this very docblock had named as the safe design. Both docblocks are rewritten to state that. The read/projection half (a formula COMPUTES both flags correctly) and the stored-column CONTROL assertions are untouched; nothing under examples/app-todo/src/ or objectstack.config.ts is touched, and that app declares no formula field at all. The changeset's blast-radius sentence is corrected in the same commit: the original sweep covered app source and missed test files, which is where current behaviour is pinned and therefore where a behaviour change lands first. No app metadata filters a formula field -- that half held. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH --- .changeset/filter-formula-field-refusal.md | 18 ++++- .../test/derived-flag-removal.test.ts | 80 ++++++++++++++----- 2 files changed, 75 insertions(+), 23 deletions(-) diff --git a/.changeset/filter-formula-field-refusal.md b/.changeset/filter-formula-field-refusal.md index e72bf1fe7d..a181f28f1b 100644 --- a/.changeset/filter-formula-field-refusal.md +++ b/.changeset/filter-formula-field-refusal.md @@ -65,5 +65,19 @@ invent the stored column, and it must not filter post-hoc after the formulas are evaluated, because the driver has already applied `limit` / `offset`, so a post-hoc predicate would filter an arbitrary PAGE. Grep your saved reports, flows, dashboards and view filters for a filtered field whose object declares it -as a `formula`. Every shipped example app in this repo was swept: none filters on -one, so nothing in-tree needed changing. +as a `formula`. + +**In-tree sweep — source AND tests.** No shipped example app's *metadata* filters +a formula field: the ones the examples declare (`crm_contact.full_name`, +`crm_opportunity.expected_revenue` / `days_to_close`, `crm_lead.is_closed`, +`showcase_project.budget_remaining`, `showcase_field_zoo.f_formula`) appear only +as view columns, form fields, permission entries and record-level CEL +predicates — never in a `where` / `filter`. One in-tree TEST did filter one and +is updated in this change: `examples/app-todo/test/derived-flag-removal.test.ts` +registers a test-local formula-shaped object to record *why* two inert flags were +removed rather than derived, and pinned the behaviour this refusal abolishes — +filtering a formula answering 0 rows with no error. It now asserts the +`400 INVALID_FIELD` envelope instead; its conclusion is unchanged, because a +formula still cannot be filtered. The first sweep read app source only, which is +the wrong half: current behaviour is pinned in tests, so a behaviour change lands +there first. diff --git a/examples/app-todo/test/derived-flag-removal.test.ts b/examples/app-todo/test/derived-flag-removal.test.ts index cfa89f5723..846f36f174 100644 --- a/examples/app-todo/test/derived-flag-removal.test.ts +++ b/examples/app-todo/test/derived-flag-removal.test.ts @@ -21,12 +21,24 @@ * A formula computes both correctly — including the temporal one — so the * obvious repair looks available. It is not, and the reason is a STORAGE fact * rather than a taste judgment: a `formula` field is virtual, no driver - * materialises a column for it, and so a FILTER naming one matches nothing. - * That is measured here, not asserted — {@link REVERSE} registers the - * formula-shaped object and shows `where { is_completed: false }` answering - * **0 rows with no error** where the stored column answers every row. Deriving - * would have silently emptied the "Due Today" view, the daily reminder flow and - * both open-task reports: a wrong answer traded for an invisible one. + * materialises a column for it, and so a FILTER naming one cannot be applied + * as written. That is measured here, not asserted — {@link REVERSE} registers + * the formula-shaped object and shows `where { is_completed: false }` failing + * where the stored column answers every row. Deriving would have emptied the + * "Due Today" view, the daily reminder flow and both open-task reports. + * + * Since **#8296** that failure is VISIBLE. The engine's filter seam refuses a + * `where` naming a virtual `formula` field with `400 INVALID_FIELD` instead of + * handing the predicate to a driver with no column behind it and answering + * **0 rows with no error** — which is what this test measured when #7226 was + * decided, and the invisible zero was the danger: a wrong answer traded for an + * unobservable one. + * + * The storage fact that decided #7226 is unchanged, so the decision stands and + * its reasoning is stronger, not weaker: a formula field still carries no + * column, a filter naming one still could not have worked, and the eight app + * filters that read these flags still had to move to stored columns. Only the + * failure mode changed — a silent zero became a named 400. * * `status` and `due_date` are stored, indexed columns that already carry the * information, and both are declared dimensions on the `task_metrics` dataset, @@ -229,10 +241,23 @@ describe('#7226 — the replacement filters really select, on BOTH sides of the * REVERSE VERIFICATION — the measurement that chose removal over derivation. * * Predicted direction, recorded BEFORE running it: the formula field READS - * correctly (so "just derive it" looks right) but is UNFILTERABLE, and the - * failure is silent — 0 rows, no error — rather than an exception. That - * asymmetry is the whole argument: an exception would have been safe, because - * someone would have seen it. + * correctly (so "just derive it" looks right) but is UNFILTERABLE. When #7226 + * ran it the failure was silent — 0 rows, no error — rather than an exception, + * and this docblock named that asymmetry as the whole argument: **an exception + * would have been safe, because someone would have seen it.** + * + * **#8296 supplied that exception**, and the second `it` below therefore + * asserts a rejection envelope (`400 INVALID_FIELD`, naming the field and the + * object) where it used to assert an empty array. That is this file's own + * argument being adopted platform-wide — the safe design it asked for is now + * the shipped one — not a correction of it. + * + * The verdict on the derive route is UNCHANGED. A formula field still + * materialises no column and still cannot carry a predicate, so the eight app + * filters that named these flags still could not have worked; removal in + * favour of the stored `status` / `due_date` columns remains the only repair. + * What #8296 changed is that choosing the derive route now fails where someone + * can see it, instead of quietly answering an empty set. */ describe('REVERSE — why the derive route was rejected, measured', () => { /** `todo_task` as it would look on the derive route. */ @@ -276,26 +301,39 @@ describe('REVERSE — why the derive route was rejected, measured', () => { expect(byId.d.is_overdue).toBe(false); // no due date at all }); - it('...and is UNFILTERABLE: 0 rows, no error — which is why deriving was refused', async () => { + it('...and is UNFILTERABLE: a `where` naming one is REFUSED, 400 INVALID_FIELD (#8296)', async () => { const ql = await bootEngine(DERIVED); await ql.insert('derived_task', { id: 'a', subject: 'done', status: 'completed', due_date: '2020-01-01' }); await ql.insert('derived_task', { id: 'b', subject: 'late', status: 'in_progress', due_date: '2020-01-01' }); // A formula field materialises no column on any driver, so the predicate - // matches nothing — and returns cleanly rather than throwing. - expect(await ql.find('derived_task', { where: { is_completed: true } })).toEqual([]); - expect(await ql.find('derived_task', { where: { is_overdue: true } })).toEqual([]); + // cannot be applied as written. When #7226 measured this the engine handed + // it to the driver anyway and answered 0 rows with no error; since #8296 + // the engine's filter seam refuses it by name. The full envelope is pinned, + // not merely "it throws": a driver that happened to throw a bare `Error` + // would satisfy a bare `.rejects` while proving nothing about the verdict. + await expect(ql.find('derived_task', { where: { is_completed: true } })).rejects.toMatchObject({ + status: 400, code: 'INVALID_FIELD', field: 'is_completed', object: 'derived_task', + }); + await expect(ql.find('derived_task', { where: { is_overdue: true } })).rejects.toMatchObject({ + status: 400, code: 'INVALID_FIELD', field: 'is_overdue', object: 'derived_task', + }); // THE decisive one. On the old stored boolean this returned EVERY row; as a - // formula it returns NONE. Eight filters in this app relied on exactly this - // predicate ("Due Today", the reminder flow, both open-task reports, three - // distribution charts), so the derive route would have silently emptied - // every one of them. - expect(await ql.find('derived_task', { where: { is_completed: false } })).toEqual([]); + // formula it is not answerable at all. Eight filters in this app relied on + // exactly this predicate ("Due Today", the reminder flow, both open-task + // reports, three distribution charts), so the derive route would have + // broken every one of them — before #8296 by silently emptying them, after + // #8296 by failing loudly on the first query. Neither is a working app, + // which is why these flags were removed rather than derived. + await expect(ql.find('derived_task', { where: { is_completed: false } })).rejects.toMatchObject({ + status: 400, code: 'INVALID_FIELD', field: 'is_completed', object: 'derived_task', + }); // CONTROL — the stored column answers correctly on the same rows and the - // same engine, so the emptiness above is about the field being virtual, not - // about the fixture or the driver. + // same engine, so the refusal above is about the field being virtual, not + // about the fixture or the driver. (Assertions unchanged from #7226: the + // anti-vacuity arm never depended on the formula's failure mode.) expect((await ql.find('derived_task', { where: { status: 'completed' } })).map((r: any) => r.id)).toEqual(['a']); expect((await ql.find('derived_task', { where: { status: { $ne: 'completed' } } })).map((r: any) => r.id)).toEqual(['b']); });