Skip to content

fix(api): don't count $ne/$nin-excluded languages toward the language cap (#1764)#1769

Open
johan-bell wants to merge 2 commits into
mainfrom
fix/1764-language-validator-exclusion-cap
Open

fix(api): don't count $ne/$nin-excluded languages toward the language cap (#1764)#1769
johan-bell wants to merge 2 commits into
mainfrom
fix/1764-language-validator-exclusion-cap

Conversation

@johan-bell

@johan-bell johan-bell commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

What & why

The /query universal validator enforces a per-request language cap for non-CMS queries by collecting the distinct language ids a selector references. walkSelector collected every string leaf whose nearest owning field is language.

The bug: exclusion operators start with $, so walkSelector preserved owningField = "language" when descending into them. That meant:

{ language: { $ne: "fr" } }            // "fr" counted toward the cap
{ language: { $nin: ["fr", "de", ] } } // every excluded id counted toward the cap

Exclusion operators narrow the result — they reference languages the query wants to exclude, not include — so they should never count toward the cap. This was a latent over-count that could reject a perfectly legitimate narrowing query.

Change

  • New EXCLUSION_OPERATORS set ($ne, $nin). When walkSelector descends into one, it clears the owning field so the excluded ids aren't collected.
  • Included ids (equality / $eq / $in) still count exactly as before.
  • Tests: $nin over-cap exclusion accepted, $ne exclusion on top of an at-cap $in accepted, and a mixed case proving over-cap inclusions are still rejected while a sibling exclusion is ignored.

Verification

npx jest src/validation/query/validateQuery.spec.ts46 passed (11 in the language-cap block, incl. 3 new).

Closes #1764

… cap (#1764)

The /query language validator collected every string leaf under a
`language` field into the distinct-language set used for the non-CMS
language cap. Because exclusion operators start with `$`, walkSelector
preserved `owningField = "language"` through them, so
`{language: {$ne: "fr"}}` and `{language: {$nin: [...]}}` counted the
EXCLUDED ids toward the cap — a latent over-count that could reject a
legitimate narrowing query.

Exclusion operators ($ne/$nin) now clear the owning field when descending,
so their values are not collected. Included ids ($eq/$in/equality) still
count. Adds tests for $ne/$nin exclusion and the mixed include+exclude case.

Closes #1764

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ivanslabbert

Copy link
Copy Markdown
Contributor

Review summary — 🟡 Approve with nits

Small, clean fix (+56 −5). No squash-merge diff artifact here — origin/main is an ancestor of the branch and the two-dot delta equals the three-dot diff, so no rebase needed.

  • Fix is correct for the ticketed cases. validateQuery.ts:240-245 sets nextOwningField = undefined when descending into $ne/$nin, so their string leaves never satisfy owningField === "language" at the collection point (:193). Positive selectors ($eq/$in/bare equality) are untouched — the cap still applies exactly as before.
  • Mixed / sibling cases hold. Owning-field clearing is scoped to the $ne/$nin subtree only, so a sibling $in under the same language/$and still counts (verified by the new test). Nested $or/$and preserve context correctly.
  • Null-guard contract preserved. $nin stays in ARRAY_OPERATORS, so the $in:[null] → CouchDB function_clause guard (:224-228) still fires — that check runs on the key before owning-field computation, independent of this change.
  • Good test coverage (validateQuery.spec.ts:261-297): over-cap $nin accepted, $ne on top of an at-cap $in accepted, over-cap $in with sibling $nin still rejected. API-only /query validation — no DTO/FTS/sync-query contract impact.
  • Requested change (latent edge case): a $not-wrapped inclusion — {language: {$not: {$in: [...many]}}} — is semantically an exclusion but is still counted toward the cap, because $not is a LOGICAL_OPERATOR (preserves owning field) and the inner $in also preserves it. It's outside the literal ask of Latent: language validator counts exclusion operators toward cap #1764 and $not on a scalar language field is unusual, but since we're closing the "exclusion operators shouldn't count" gap, please either handle $not-wrapped $in/$eq consistently or add a one-line comment documenting that $not is intentionally excluded from this fix.

🤖 AI-assisted review summary (Claude Code). Read-only analysis of the PR diff — not a substitute for human review.

…lector

Per review: {language: {$not: {$in: [...]}}} and {$not: {$eq: ...}} are
semantically exclusions (same as $ne/$nin) but $not was bucketed as a
plain logical operator, so it preserved the owning field and its wrapped
ids still counted toward the cap. $not now clears the owning field like
the other exclusion operators - checked first, since $not remains (and
still needs to remain) a member of LOGICAL_OPERATORS for its own taxonomy.

The one deliberately unhandled case: a double-negated $not: {$ne: x}
(meaning "must equal x") gets miscategorized as an exclusion by this
blanket rule - documented in the EXCLUSION_OPERATORS comment as an
unrealistic shape for a scalar language field, not worth special-casing.

@MrDirkelz MrDirkelz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Latent: language validator counts exclusion operators toward cap

3 participants