Skip to content

Commit 2604d34

Browse files
fix(analytics): refuse a field wrapper mixing $ operators with non-$ sibling keys (#6444) (#6583)
A field constraint object carrying $-operator keys and non-$ keys at once used to compile its operators and silently DROP every non-$ sibling — fieldLeaves's operator arm iterated opKeys only and returned, and the nested-relation flatten sits after that early return. Dropping a conjunct WIDENS the query (#3650), and inside a $not the surviving guard could be contradictory, negating to TRUE — every row. Ruled Option A (refuse) on 2026-08-08: the mixed wrapper is refused through the module's one envelope (INVALID_FILTER / 400), with a message that names the offending non-$ key(s) and shows BOTH legal rewrites — the operator spelling (gte -> $gte) and the nested-relation form — because the shape has two intents this door cannot tell apart. Option B (flattening) was rejected: it would compile the missing-$ typo into a predicate on a non-existent member such as amount.gte. The two pure shapes do not move: all-$ wrappers compile as before, all-non-$ wrappers keep flattening to the dotted member. #6386's sibling-drop pin flips to a positive refusal assertion; the refusal ledger gains the eleventh row (addedAfter5352: #6444). read-scope-sql.ts, the $null/$exists flag semantics and the null-comparand rulings (#5332 / #5526) are untouched. Claude-Session: https://claude.ai/code/session_01USNUyHEr7uaU6MoEWXitei Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2873eb9 commit 2604d34

5 files changed

Lines changed: 580 additions & 14 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(analytics): a field constraint mixing `$` operators with non-`$` sibling keys is refused (400 `INVALID_FILTER`), not silently narrowed to its operators
6+
7+
**Observable behaviour change.** A `where` field wrapper that carries `$`-operator
8+
keys and non-`$` keys at once used to compile its operators and silently DROP
9+
every non-`$` sibling. It is now refused with `INVALID_FILTER` / 400, the
10+
envelope every other refusal at this door already carries. Ruled Option A
11+
(refuse) on #6444, 2026-08-08; Option B (flattening the siblings as nested
12+
paths) was rejected because it would compile the likely-real cause — a dropped
13+
`$` — into a predicate on a non-existent member such as `amount.gte`.
14+
15+
| `where` | used to normalize to | reading |
16+
|---|---|---|
17+
| `{d: {$eq: 1, nested: 'x'}}` | `d equals [1]` | the `nested` conjunct vanished in silence |
18+
| `{amount: {gte: 10, $lte: 20}}` | `amount lte 20` | the missing-`$` typo: the lower bound silently gone |
19+
| `{$not: {d: {$null: true, nested: 'x'}}}` | `NOT(d set AND d notSet)` | a contradiction that negates to TRUE — every row |
20+
21+
Every row WIDENED the query — a dropped conjunct returns rows the author
22+
excluded, with nothing to read (the #3650 family this module refuses everywhere
23+
else). Unlike #6386's `undefined` comparand, this shape survives JSON, so it can
24+
sit in stored dashboard / report / dataset metadata as well as in-process
25+
callers of `AnalyticsService.query({ where })`.
26+
27+
**What to change if this refuses your filter.** The message names the offending
28+
key(s) and both repairs, because the shape has two readings this door cannot
29+
tell apart:
30+
31+
- an operator missing its `$` was meant → spell it with the prefix
32+
(`gte``$gte`: `{ "amount": { "$gte": 10, "$lte": 20 } }`);
33+
- a nested-relation member was meant → give it a wrapper of its own with no `$`
34+
siblings (`{ "d": { "nested": "x" } }` compiles to the member `d.nested`) and
35+
AND it with the operator constraint explicitly via `$and`.
36+
37+
**The two pure shapes do not move.** A wrapper that is all `$`-operators
38+
compiles exactly as before (`{amount: {$gte: 10, $lte: 20}}` stays the AND of
39+
its bounds), and a wrapper that is all non-`$` keys keeps flattening to the
40+
dotted member (`{d: {nested: 'x'}}``d.nested`). `$null` / `$exists` flag
41+
semantics, the `null` comparand rulings (#5332 / #5526) and the sibling door
42+
`read-scope-sql.ts` — which has always failed closed on this shape — are
43+
untouched.
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#6444, ruled Option A on 2026-08-08] A field wrapper mixing `$`-operator
5+
* keys with non-`$` sibling keys is REFUSED — `INVALID_FILTER` / 400 — and the
6+
* two pure shapes on either side of it do not move.
7+
*
8+
* ## What was wrong
9+
*
10+
* The value-independent sibling of #6386, in the same function. `fieldLeaves`'s
11+
* operator arm iterated `opKeys` only and returned, and the nested-relation
12+
* flatten sits after that early return — so with even one `$` key in the
13+
* wrapper, every non-`$` sibling was silently dropped, whatever its value.
14+
* Measured on `origin/main` (`1a53a0253`) by calling
15+
* `normalizeAnalyticsFilterTree({ where })` directly:
16+
*
17+
* | `where` | normalized to | reading |
18+
* |---|---|---|
19+
* | `{d: {$eq: 1, nested: 'x'}}` | `d equals [1]` | the `nested` conjunct vanished in silence |
20+
* | `{d: {$eq: 1, nested: undefined}}` | `d equals [1]` | same — value-independent, unlike #6386 |
21+
* | `{amount: {gte: 10, $lte: 20}}` | `amount lte 20` | the missing-`$` typo: the LOWER BOUND silently gone |
22+
* | `{$not: {d: {$eq: 1, nested: 'x'}}}` | `NOT(d set AND d = 1)` | the sibling vanished INSIDE the negation |
23+
* | `{$not: {d: {$null: true, nested: 'x'}}}` | `NOT(d set AND d notSet)` | a CONTRADICTION that negates to TRUE — every row |
24+
*
25+
* Every row WIDENS — a dropped conjunct does not narrow the query (#3650 /
26+
* #4128), the failure mode this module's own `MONGO_TO_CUBE_OP` miss-branch
27+
* comment forbids. The last row is the strangest: `nullGuardForFieldSpec`
28+
* judged the wrapper while the sibling still existed (`requireValue`), the
29+
* sibling then vanished in `fieldLeaves`, and the surviving guard was
30+
* contradictory — so the negation returned the WHOLE dataset.
31+
*
32+
* ## The ruling, and what the message owes (#6444, 2026-08-08)
33+
*
34+
* Option A — refuse, through the module's one envelope (#5352). Option B
35+
* (flatten the siblings as nested paths) was rejected: it would compile the
36+
* likely-real cause — a dropped `$` — into a predicate on a non-existent
37+
* member `amount.gte`. Because the refused shape has TWO legitimate repairs
38+
* answering two intents the module cannot tell apart, the message must name
39+
* the offending non-`$` key(s) and show BOTH rewrites: the operator spelling
40+
* (`gte` → `$gte`) and the nested-relation form — asserted below as the
41+
* message contract, not prose.
42+
*
43+
* ## The blocks, and which one is the change
44+
*
45+
* `the mixed wrapper is ONE refusal` is the change: run it against pre-#6444
46+
* code and every row fails, because every row COMPILES (dropping siblings).
47+
*
48+
* `the two pure shapes do not move` is the risk. The gate must move the
49+
* refusal set by EXACTLY the mixed shape: all-`$` wrappers keep compiling,
50+
* all-non-`$` wrappers keep flattening to dotted members. An over-reaching
51+
* gate shows up there as a throw.
52+
*
53+
* `the #5146 rewrite cannot swallow the wrapper` is the gate-side question,
54+
* same as #6386's: the gate sits in `fieldLeaves`, downstream of
55+
* `nullSafeNegationOperand`. For a MIXED wrapper the carry-through is
56+
* structural: a non-`$` key never satisfies `operatorIsNullTotal`, so
57+
* `nullGuardForFieldSpec` never answers `none` for one — the disposition is
58+
* always `requireValue`/`allowNull`, both of which push the spec by
59+
* reference, so the gate always sees the author's wrapper.
60+
*
61+
* ## Reverse verification — direction predicted BEFORE running
62+
*
63+
* Ordinary direction, one knob (the `assertUnmixedFieldWrapper` call in
64+
* `fieldLeaves`). Predicted with the call removed: every refusal row in this
65+
* file goes red (the mixed shapes compile again, siblings dropped), the
66+
* flipped pin in `filter-normalizer-undefined-comparand.test.ts` goes red,
67+
* and the #6444 ledger row in `filter-refusal-envelope.test.ts` goes red —
68+
* while both pure-shape control blocks and the #6386/`null` groups stay
69+
* green (they never depended on this gate). One deliberate exception stays
70+
* green in THIS file too: `{d: {$eq: undefined, nested: 'x'}}` is refused by
71+
* the #6386 gate, not this one. Measured counts are in the PR body.
72+
*
73+
* ## Scope, so a later reader does not "finish the job"
74+
*
75+
* ⛔ `read-scope-sql.ts` is the sibling door; it has ALWAYS failed closed on
76+
* this shape (`compileField`'s non-`$`-key check) and is not touched — this
77+
* change makes the two doors give one answer, in this door's own envelope
78+
* (400: the `where` is caller input; that door compiles a platform artifact
79+
* and answers 500).
80+
* ⛔ `$null` / `$exists` flag semantics (#5347 / #5369 / #6387), `comparand()`
81+
* (#5526) and the null-predicate identity (#5332) are RULED elsewhere and
82+
* untouched; the `null` control group lives in
83+
* `filter-normalizer-undefined-comparand.test.ts` and did not move.
84+
* ⛔ The other compiler faces (#5930's five) are out of scope per the ruling;
85+
* the per-face statement is in PR #6444's body per the #6410 checklist.
86+
*/
87+
88+
import { describe, it, expect } from 'vitest';
89+
import { normalizeAnalyticsFilterTree } from '../strategies/filter-normalizer.js';
90+
91+
/** The ADR-0112 fields a refusal must carry. */
92+
interface FilterRefusal extends Error {
93+
code?: unknown;
94+
status?: unknown;
95+
}
96+
97+
function refusalFor(where: unknown): FilterRefusal | undefined {
98+
try {
99+
normalizeAnalyticsFilterTree({ where });
100+
return undefined;
101+
} catch (e) {
102+
return e as FilterRefusal;
103+
}
104+
}
105+
106+
function treeFor(where: unknown): unknown {
107+
return normalizeAnalyticsFilterTree({ where });
108+
}
109+
110+
/**
111+
* The mixed shapes, one per position the wrapper can sit in. `field` is the
112+
* member the refusal must name (for a nested mix, the DOTTED member —
113+
* `fieldLeaves` recurses before the gate sees it). `opKeys`/`nonOpKeys` are
114+
* the two lists the message quotes, in wrapper key order. `wasReadAs` is the
115+
* pre-fix normalisation — recorded so each row says what it protects against.
116+
*/
117+
const MIXED: Array<{
118+
name: string;
119+
where: unknown;
120+
field: string;
121+
opKeys: string[];
122+
nonOpKeys: string[];
123+
wasReadAs: string;
124+
}> = [
125+
{
126+
name: '① operator + nested member in one wrapper',
127+
where: { d: { $eq: 1, nested: 'x' } },
128+
field: 'd',
129+
opKeys: ['$eq'],
130+
nonOpKeys: ['nested'],
131+
wasReadAs: "d equals [1] — `nested` dropped in silence",
132+
},
133+
{
134+
name: '② the same mix with an undefined sibling VALUE (value-independent)',
135+
where: { d: { $eq: 1, nested: undefined } },
136+
field: 'd',
137+
opKeys: ['$eq'],
138+
nonOpKeys: ['nested'],
139+
wasReadAs: 'd equals [1] — the drop never read the value (#6386 pinned this)',
140+
},
141+
{
142+
name: '③ the canonical agent typo — an operator missing its $',
143+
where: { amount: { gte: 10, $lte: 20 } },
144+
field: 'amount',
145+
opKeys: ['$lte'],
146+
nonOpKeys: ['gte'],
147+
wasReadAs: 'amount lte 20 — the LOWER BOUND silently gone',
148+
},
149+
{
150+
name: '④ a $between beside a stray member',
151+
where: { d: { $between: [1, 5], nested: 'x' } },
152+
field: 'd',
153+
opKeys: ['$between'],
154+
nonOpKeys: ['nested'],
155+
wasReadAs: 'd gte [1] AND d lte [5] — the range survived, the member did not',
156+
},
157+
{
158+
name: '⑤ a $null flag beside a stray member',
159+
where: { d: { $null: true, nested: 'x' } },
160+
field: 'd',
161+
opKeys: ['$null'],
162+
nonOpKeys: ['nested'],
163+
wasReadAs: 'd notSet — the flag survived, the member did not',
164+
},
165+
{
166+
name: '⑥ the mix one relation DOWN, refused on the DOTTED member',
167+
where: { profile: { verified: { $eq: 1, extra: 'x' } } },
168+
field: 'profile.verified',
169+
opKeys: ['$eq'],
170+
nonOpKeys: ['extra'],
171+
wasReadAs: 'profile.verified equals [1]',
172+
},
173+
{
174+
name: '⑦ inside a $and branch',
175+
where: { $and: [{ d: { $eq: 1, nested: 'x' } }] },
176+
field: 'd',
177+
opKeys: ['$eq'],
178+
nonOpKeys: ['nested'],
179+
wasReadAs: 'd equals [1]',
180+
},
181+
{
182+
name: '⑧ inside a $or branch — the branch quietly LOST a conjunct',
183+
where: { $or: [{ d: { gte: 1, $lte: 2 } }, { stage: 'won' }] },
184+
field: 'd',
185+
opKeys: ['$lte'],
186+
nonOpKeys: ['gte'],
187+
wasReadAs: "(d lte 2) OR (stage = 'won') — the branch widened, so the whole $or did",
188+
},
189+
];
190+
191+
// ─────────────────────────────────────────────────────────────────────────────
192+
193+
describe('[#6444] a mixed $/non-$ field wrapper is ONE refusal', () => {
194+
for (const c of MIXED) {
195+
it(`refuses ${c.name} (was: ${c.wasReadAs})`, () => {
196+
const err = refusalFor(c.where);
197+
expect(err, 'compiled instead of refusing — the #3650 sibling-drop widening is back').toBeInstanceOf(Error);
198+
const message = String(err?.message);
199+
// Names the field, the operator side, and — the ruling requirement —
200+
// every offending non-$ key.
201+
expect(message).toContain(`"${c.field}" mixes $-operator keys (${c.opKeys.join(', ')})`);
202+
for (const k of c.nonOpKeys) expect(message).toContain(`"${k}"`);
203+
// The envelope every refusal in this module carries since #5352: the
204+
// `where` is caller input, so 400. A bare toThrow() would carry one bit
205+
// where this defect has two (the ADR-0112 refusal-test rule).
206+
expect(err?.code).toBe('INVALID_FILTER');
207+
expect(err?.status).toBe(400);
208+
});
209+
}
210+
211+
it('names EVERY offending sibling when there are several, not just the first', () => {
212+
const err = refusalFor({ d: { $eq: 1, a: 1, b: 2 } });
213+
expect(err).toBeInstanceOf(Error);
214+
const message = String(err?.message);
215+
expect(message).toContain('non-$ sibling key(s) "a", "b"');
216+
// Both get the operator rewrite, so the author repairs the wrapper once.
217+
expect(message).toContain('"a" → "$a"');
218+
expect(message).toContain('"b" → "$b"');
219+
expect(err?.code).toBe('INVALID_FILTER');
220+
expect(err?.status).toBe(400);
221+
});
222+
223+
it('says ONE thing, differing only in the field and the two key lists (#5240)', () => {
224+
// Erase the parts that legitimately vary and every message must be the
225+
// same string. Restricted to the single-op/single-sibling rows so the
226+
// erased skeletons are comparable; the multi-sibling wording is asserted
227+
// in its own case above. Longest tokens first, so `"$gte"` is consumed
228+
// before a bare `"gte"` replacement could split it.
229+
const generic = MIXED.map((c) => {
230+
const err = refusalFor(c.where);
231+
// Load-bearing (measured on #6386's twin of this case): without it the
232+
// wording check is VACUOUSLY green when nothing throws — every row maps
233+
// to the same "undefined" string.
234+
expect(err, `${c.name} did not refuse — the wording check would pass on nothing`).toBeInstanceOf(Error);
235+
const k = c.nonOpKeys[0];
236+
return String(err?.message)
237+
.split(`"${c.field}.${k}"`).join('"<field>.<key>"')
238+
.split(`(${c.opKeys.join(', ')})`).join('(<ops>)')
239+
.split(`"$${k}"`).join('"$<key>"')
240+
.split(`"${k}" → `).join('"<key>" → ')
241+
.split(`"${k}"`).join('"<key>"')
242+
.split(`"${c.field}"`).join('"<field>"');
243+
});
244+
expect(new Set(generic).size, `expected one wording, got:\n${[...new Set(generic)].join('\n\n')}`).toBe(1);
245+
});
246+
247+
it('shows BOTH legal rewrites — the two intents it cannot disambiguate (ruling req.)', () => {
248+
const message = String(refusalFor({ amount: { gte: 10, $lte: 20 } })?.message);
249+
// Intent 1 — an operator missing its $: the prefixed spelling, named
250+
// key-by-key and shown in place.
251+
expect(message).toContain('"gte" → "$gte"');
252+
expect(message).toContain('{ "amount": { "$gte": ... } }');
253+
// Intent 2 — a nested-relation member: a wrapper of its own, the dotted
254+
// member it compiles to, and the explicit $and (one JSON object cannot
255+
// spell the same field key twice).
256+
expect(message).toContain('{ "amount": { "gte": ... } }');
257+
expect(message).toContain('"amount.gte"');
258+
expect(message).toContain('"$and"');
259+
// …and why it refuses rather than picking: the drop it replaces WIDENED.
260+
expect(message).toContain('WIDENS');
261+
expect(message).toContain('read-scope-sql.ts');
262+
});
263+
});
264+
265+
describe('[#6444] the #5146 rewrite cannot swallow the wrapper', () => {
266+
// The gate lives in `fieldLeaves`, DOWNSTREAM of `nullSafeNegationOperand`.
267+
// A mixed wrapper reaches it because a non-$ key never satisfies
268+
// `operatorIsNullTotal`, so `nullGuardForFieldSpec` never answers `none` for
269+
// one — `requireValue` and `allowNull` both push the author's spec by
270+
// REFERENCE. One case per rewrite path that can carry a mixed wrapper.
271+
const REWRITE_PATHS: Array<{ name: string; where: unknown; field: string }> = [
272+
{
273+
name: '`requireValue` — pushes {k: {$null: false}}, {k: spec}; spec kept by reference',
274+
where: { $not: { d: { $eq: 1, nested: 'x' } } },
275+
field: 'd',
276+
},
277+
{
278+
name: 'a null-total operator whose SIBLING forces the guard (was the every-row contradiction)',
279+
where: { $not: { d: { $null: true, nested: 'x' } } },
280+
field: 'd',
281+
},
282+
{
283+
name: '`$eq: null` beside a sibling — null-total op, still guarded because of the mix',
284+
where: { $not: { d: { $eq: null, nested: 'x' } } },
285+
field: 'd',
286+
},
287+
{
288+
name: 'the nested-relation recursion in `guardFieldEntry`, which guards the DOTTED member',
289+
where: { $not: { profile: { verified: { $eq: 1, extra: 2 } } } },
290+
field: 'profile.verified',
291+
},
292+
];
293+
294+
for (const c of REWRITE_PATHS) {
295+
it(`throws rather than changing shape: ${c.name}`, () => {
296+
const err = refusalFor(c.where);
297+
expect(err, 'the rewrite swallowed the wrapper and the gate blessed the new shape').toBeInstanceOf(Error);
298+
expect(String(err?.message)).toContain(`"${c.field}" mixes $-operator keys`);
299+
expect(err?.code).toBe('INVALID_FILTER');
300+
expect(err?.status).toBe(400);
301+
});
302+
}
303+
});
304+
305+
describe('[#6444] the two pure shapes do not move', () => {
306+
it('an ALL-non-$ wrapper still flattens to the dotted member (the nested-relation path)', () => {
307+
// The pin the issue's own control row named: the ONLY reason the siblings
308+
// were droppable is that this legitimate path sat after the early return.
309+
expect(treeFor({ d: { nested: 'x' } })).toEqual({
310+
kind: 'leaf', member: 'd.nested', operator: 'equals', values: ['x'],
311+
});
312+
expect(treeFor({ a: { b: { c: 1 } } })).toEqual({
313+
kind: 'leaf', member: 'a.b.c', operator: 'equals', values: [1],
314+
});
315+
// A nested member carrying an OPERATOR wrapper (all-$ one level down) is
316+
// legal on both levels and keeps compiling.
317+
expect(treeFor({ profile: { verified: { $eq: true } } })).toEqual({
318+
kind: 'leaf', member: 'profile.verified', operator: 'equals', values: [true],
319+
});
320+
});
321+
322+
it('an ALL-$ wrapper still compiles exactly as before, multi-operator included', () => {
323+
expect(treeFor({ amount: { $gte: 10, $lte: 20 } })).toEqual({
324+
kind: 'and',
325+
children: [
326+
{ kind: 'leaf', member: 'amount', operator: 'gte', values: [10] },
327+
{ kind: 'leaf', member: 'amount', operator: 'lte', values: [20] },
328+
],
329+
});
330+
expect(treeFor({ d: { $null: true } })).toEqual({ kind: 'leaf', member: 'd', operator: 'notSet', values: [] });
331+
expect(treeFor({ d: { $exists: false } })).toEqual({ kind: 'leaf', member: 'd', operator: 'notSet', values: [] });
332+
expect(treeFor({ stage: { $in: [] } })).toEqual({ kind: 'const', value: false });
333+
});
334+
335+
it('the neighbouring refusals keep their OWN wordings — the set moved by exactly one shape', () => {
336+
// #5240's zero-operator refusal is disjoint by construction ({} has no
337+
// keys of either kind) and must not borrow the mixed wording.
338+
expect(String(refusalFor({ stage: {} })?.message)).toContain('zero operators');
339+
// #3948's vocabulary refusal still owns the all-$-but-unknown wrapper.
340+
expect(String(refusalFor({ stage: { $sortOf: 'won' } })?.message)).toContain('Unsupported filter operator');
341+
});
342+
343+
it('a mixed wrapper whose $-comparand is undefined is refused by the #6386 gate first', () => {
344+
// Measured ordering, pinned as a fact rather than a contract:
345+
// `assertDefinedComparands` runs at `fieldLeaves`'s entry, this gate in the
346+
// wrapper arm below it. Both refusals share the envelope, so the REST face
347+
// answers 400 either way — which is why the ordering is allowed to be an
348+
// implementation fact.
349+
const err = refusalFor({ d: { $eq: undefined, nested: 'x' } });
350+
expect(String(err?.message)).toContain('comparand at "d".$eq is undefined');
351+
expect(err?.code).toBe('INVALID_FILTER');
352+
expect(err?.status).toBe(400);
353+
});
354+
});

0 commit comments

Comments
 (0)