Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/read-scope-empty-nin-refusal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/service-analytics': patch
---

The analytics read-scope compiler (`read-scope-sql.ts`) now refuses an empty `$nin` (`READ_SCOPE_COMPILE_FAILED` / 500) instead of folding it to constant TRUE. An emptied exclusion ("NOT IN () excludes nothing") vacated the whole read scope — every row admitted — on the ADR-0021 lowering, where a widening is scope over-reach; no in-repo producer can emit the shape (the CEL lowering never emits `$nin`, and the RLS guard drops even-polarity empty-`$nin` policies upstream), so the refusal costs no live traffic. Deliberately asymmetric: `$in: []` keeps its ruled constant-FALSE fold (#5322/#5243), which the RLS compiler's inert positive composite — an emptied membership `$or`-ed beside an own-rows grant — depends on.
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,11 +266,30 @@ describe('[#5234] the read-scope lowering refuses the same two shapes, fail-clos
expect(scope({ name: { $startsWith: '_admin' } }).params).toEqual(['\\_admin%', '\\']);
});

it('an empty `$in` / `$nin` still lowers to its boolean constant, not a refusal', () => {
it('an empty `$in` still lowers to its FALSE constant, not a refusal', () => {
// The member scan runs AFTER the arity identities (#5134), so the empty
// list keeps compiling to `1 = 0` / `1 = 1` rather than becoming an error.
// INCLUSION keeps compiling to `1 = 0` rather than becoming an error. It
// KEEPS its #5322/#5243 reduction because that constant is FALSE —
// narrowing at this arm — and because a live producer depends on it: the
// RLS compiler deliberately emits `$in: []` at positive polarity inside
// composites (#13570's "own rows keep flowing" pin), and that filter
// reaches this compiler through `security.getReadFilter`.
expect(scope({ status: { $in: [] } }).sql).toContain('1 = 0');
expect(scope({ status: { $nin: [] } }).sql).toContain('1 = 1');
});

it('an empty `$nin` is REFUSED — its constant is TRUE, which vacates the scope (#13571)', () => {
// Deliberately ASYMMETRIC with the `$in` case above, and not an
// oversight: the #5322 boundary is "shape errors throw, boolean
// identities reduce", and `$nin: []` sits on the THROW side because its
// faithful reduction is constant TRUE — a read scope silently widened to
// every row, the exact thing the module header forbids. No in-repo
// producer can emit the shape (the CEL lowering never emits `$nin`;
// #13570's guard drops even-polarity empty-`$nin` policies), so the
// refusal costs no live traffic. See read-scope-sql.ts's #13571 section.
const err = refusalOf(() => scope({ status: { $nin: [] } }));
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(err.status).toBe(500);
expect(err.message).toContain('$nin for "status" is empty');
});
});
});
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13571] The empty-`$nin` compile refusal, exercised on the path that made it
* matter — a NON-RLS `getReadScope` provider — plus the over-denial control
* that bounds the fix.
*
* ## Why a non-RLS provider, and not an RLS regression
*
* In-repo, the only scope producer is the RLS compiler, and since PR #13570 its
* polarity-aware guard drops every emptied-membership shape at widening
* polarity before it is emitted — so an RLS-path regression for `$nin: []`
* would exercise a route that cannot reach this compiler at all and would test
* nothing. But `StrategyContext.getReadScope` is a spec contract
* (`packages/spec/src/contracts/analytics-service.ts` — its doc carries a
* hand-written example), fillable by ANY provider. The provider below is that
* contract filled by hand, which is exactly the surface the #13571 card named.
*
* ## The two controls
*
* 1. **Refusal (the fix).** A provider handing `{ owner: { $nin: [] } }` used
* to get a scope clause of constant TRUE (`1 = 1`) — on the read-scope
* lowering, the WHOLE TABLE, an ADR-0021 over-reach. It now gets the
* module's one refusal envelope: `READ_SCOPE_COMPILE_FAILED` / 500.
* MEASURED pre-fix (this file run against the pre-#13571 compiler, see the
* PR): the same case admitted every fixture row.
*
* 2. **Over-denial (the bound — the reason #13571 is NOT a uniform throw).**
* The RLS compiler deliberately emits an emptied POSITIVE membership inside
* a composite — `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }`,
* pinned by #13570's `rls-empty-membership-polarity.test.ts` as "own rows
* keep flowing" — and that filter reaches this compiler through
* `security.getReadFilter`. The refusal must NOT catch it: the scope still
* compiles and still admits exactly the own row. A uniform throw at both
* arms fails this block, which is the availability regression the #13571
* verdict rejected.
*
* `$in: []` under `$not` from a non-RLS provider (constant TRUE via inversion)
* is the verdict's DECLARED residue, deliberately not asserted here either way
* as a contract — `read-scope-not-null-safe.test.ts` pins its current
* behaviour next to the ruling's reasoning.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Cube } from '@objectstack/spec/data';
import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts';

import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js';

const FIXTURE = [
{ id: 'r1', owner: 'u_me' },
{ id: 'r2', owner: 'u_other' },
{ id: 'r3', owner: null },
];

const CUBE: Cube = {
name: 'deals',
title: 'Deals',
sql: 'deal',
measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } },
dimensions: Object.fromEntries(
['id', 'owner'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]),
),
public: false,
} as unknown as Cube;

/** Point sql.js at the `.wasm` shipped inside its own package (Node-safe). */
async function locateWasm(): Promise<((file: string) => string) | undefined> {
try {
const { createRequire } = await import('node:module');
const require = createRequire(import.meta.url);
const pkgJsonPath = require.resolve('sql.js/package.json');
const { dirname, join } = await import('node:path');
const dir = dirname(pkgJsonPath);
return (file: string) => join(dir, 'dist', file);
} catch {
return undefined;
}
}

describe('[#13571] empty `$nin` on the read-scope lowering — non-RLS provider control', () => {
let db: any;

beforeAll(async () => {
const mod: any = await import('sql.js');
const initSqlJs = mod.default ?? mod;
const locateFile = await locateWasm();
const SQL = await initSqlJs(locateFile ? { locateFile } : undefined);

db = new SQL.Database();
db.run(`CREATE TABLE "deal" ("id" TEXT PRIMARY KEY, "owner" TEXT);`);
const insert = db.prepare(`INSERT INTO "deal" ("id","owner") VALUES (?,?)`);
for (const r of FIXTURE) insert.run([r.id, r.owner]);
insert.free();
});

afterAll(() => {
db?.close();
});

/**
* A `StrategyContext` whose `getReadScope` is filled BY HAND — the spec
* contract's own authoring mode, and deliberately not the RLS compiler.
*/
const ctxWithScope = (scope: unknown): StrategyContext =>
({
getCube: (name: string) => (name === 'deals' ? CUBE : undefined),
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
getReadScope: () => scope,
executeRawSql: async (_object: string, sql: string, params: unknown[]) => {
const stmt = db.prepare(sql.replace(/\$\d+/g, '?'));
stmt.bind(params as any[]);
const out: Record<string, unknown>[] = [];
while (stmt.step()) out.push(stmt.getAsObject());
stmt.free();
return out;
},
}) as StrategyContext;

const QUERY: AnalyticsQuery = {
cube: 'deals',
measures: ['total'],
dimensions: ['id'],
timezone: 'UTC',
} as AnalyticsQuery;

/** Run the query under `scope`; return either the refusal or the admitted ids. */
const outcome = async (
scope: unknown,
): Promise<{ refusal?: Error & { code?: unknown; status?: unknown }; admitted?: string[] }> => {
try {
const result = await new NativeSQLStrategy().execute(QUERY, ctxWithScope(scope));
return { admitted: result.rows.map((r) => String(r.id)).sort((x, y) => x.localeCompare(y)) };
} catch (e) {
return { refusal: e as Error & { code?: unknown; status?: unknown } };
}
};

it('a provider handing `{ owner: { $nin: [] } }` is REFUSED in the module envelope — it used to get the whole table', async () => {
const { refusal, admitted } = await outcome({ owner: { $nin: [] } });
// Pre-#13571 this assertion's diff read `admitted: ['r1','r2','r3']` — the
// whole fixture, from a scope clause of constant TRUE.
expect(admitted).toBeUndefined();
expect(refusal).toBeInstanceOf(Error);
expect(refusal?.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(refusal?.status).toBe(500);
expect(String(refusal?.message)).toContain('$nin for "owner" is empty');
});

it('OVER-DENIAL CONTROL: the #13570-pinned RLS composite still compiles and still admits exactly the own row', async () => {
// `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }` is what
// `RLSCompiler.compileFilter` returns for an emptied membership set beside
// an own-rows grant ("own rows keep flowing"), and it arrives here through
// `security.getReadFilter`. The empty-`$nin` refusal must not touch it:
// this block red under a uniform empty-membership throw is the
// availability regression the #13571 verdict exists to avoid.
const { refusal, admitted } = await outcome({ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(refusal).toBeUndefined();
expect(admitted).toEqual(['r1']);
});

it('the composite denies-by-itself shape stays working too: a lone empty `$in` arm inside `$and`', async () => {
// The other composite #13570's guard deliberately passes through: as an
// `$and` arm the emptied positive membership is constant FALSE — the whole
// scope denies. Zero rows, not a refusal and not the whole table.
const { refusal, admitted } = await outcome({ $and: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(refusal).toBeUndefined();
expect(admitted).toEqual([]);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -347,9 +347,29 @@ describe('[#5297] read-scope `$not` — boolean identities and NULL safety', ()
expect(ids({ $not: { stage: { $ne: null } } })).toEqual(['3', '4']);
});

it('an empty `$in` / `$nin` under a `$not` keeps its constant value', () => {
it('an empty `$in` under a `$not` keeps its constant value; an empty `$nin` refuses before `$not` matters (#13571)', () => {
// `$in: []` keeps its #5322/#5243 reduction — constant FALSE, total, no
// guard — and the negation flips it to TRUE: every row. That widened
// composition is the #13571 verdict's DECLARED residue for a non-RLS
// producer (the in-repo RLS compiler cannot emit the shape — #13570's
// polarity guard drops it upstream); closing it is a ruled follow-up
// design, not an edit to this pin.
expect(ids({ $not: { stage: { $in: [] } } })).toEqual(ALL);
expect(ids({ $not: { stage: { $nin: [] } } })).toEqual([]);
// `$nin: []` no longer HAS a constant to keep: its reduction is TRUE —
// scope-vacating on its own — so `compileOperator` refuses it whatever
// the polarity above it. Deliberately asymmetric with the `$in` line
// above ("shape errors throw, boolean identities reduce" is the #5322
// boundary, and a scope-vacating reduction is on the THROW side) — see
// read-scope-sql.ts's #13571 header section.
let refusal: (Error & { code?: unknown; status?: unknown }) | undefined;
try {
ids({ $not: { stage: { $nin: [] } } });
} catch (e) {
refusal = e as Error & { code?: unknown; status?: unknown };
}
expect(refusal?.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(refusal?.status).toBe(500);
expect(String(refusal?.message)).toContain('$nin for "stage" is empty');
});
});

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -93,7 +93,7 @@ function refusalFor(filter: unknown, alias = 'crm_opportunity'): Refusal | undef
/**
* Every refusing site in `read-scope-sql.ts`, in source order.
*
* FOURTEEN rows over TWELVE throw sites: TWO sites are each reached by two
* FIFTEEN rows over THIRTEEN throw sites: TWO sites are each reached by two
* triggers, and every trigger is listed on purpose.
*
* - `quoteIdent`, with two `kind` values. That alias-vs-field split was option
Expand DownExpand Up@@ -208,14 +208,26 @@ const REFUSALS: Array<{
sensitive: 'region_code',
},
{
name: '⑬ $between without [min,max]',
// [#13571] The empty EXCLUSION refuses; the empty INCLUSION keeps its
// constant — see the ACCEPTED table's "#5243" row. Deliberate asymmetry
// ("shape errors throw, boolean identities reduce" — #5322; a reduction to
// constant TRUE vacates the scope, so it is on the throw side), not an
// oversight: read-scope-sql.ts's #13571 header section carries the ruling.
name: '⑬ $nin with an EMPTY array',
site: 'compileOperator: empty $nin vacates the scope',
filter: { region_code: { $nin: [] } },
message: /\$nin for "region_code" is empty — an empty exclusion excludes nothing and would compile the read scope to constant TRUE \(fail-closed\)/,
sensitive: 'region_code',
},
{
name: '⑭ $between without [min,max]',
site: 'compileOperator: $between needs [min,max]',
filter: { credit_limit: { $between: [10] } },
message: /\$between for "credit_limit" needs \[min,max\] \(fail-closed\)/,
sensitive: 'credit_limit',
},
{
name: ' unsupported operator',
name: ' unsupported operator',
site: 'compileOperator: unsupported operator',
filter: { owner_email: { $regex: 'admin@' } },
message: /unsupported operator "\$regex" on "owner_email" \(fail-closed\)/,
Expand DownExpand Up@@ -244,6 +256,11 @@ const ACCEPTED: Array<{ name: string; filter: unknown; sql: string; params: unkn
params: ['emea', 'apac'],
},
{
// [#13571] STAYS accepted while the empty `$nin` refuses (REFUSALS ⑬):
// this constant is FALSE — narrowing at its own arm — and the RLS compiler
// deliberately emits the shape at positive polarity inside composites
// (#13570's "own rows keep flowing" pin), so refusing it here would 500 a
// live, ruled-correct scope. The asymmetry is the #13571 ruling itself.
name: 'an empty $in as the FALSE constant (#5243)',
filter: { region_code: { $in: [] } },
sql: '1 = 0',
Expand DownExpand Up@@ -306,13 +323,14 @@ describe('[#5367] every read-scope refusal carries the ADR-0112 envelope (READ_S
// #5352's lesson, stated as a guard: seven of `filter-normalizer.ts`'s nine
// sites carrying an envelope was indistinguishable from none of them at the
// HTTP boundary, because the commonest input hit one of the two bare ones.
// Fourteen inputs over the module's TWELVE throw sites (see the table's note
// on the two sites with two triggers each), and every one of them enveloped.
// [#6125] added the eleventh site, [#6387] the twelfth; these two numbers
// Fifteen inputs over the module's THIRTEEN throw sites (see the table's
// note on the two sites with two triggers each), and every one of them
// enveloped. [#6125] added the eleventh site, [#6387] the twelfth, and
// [#13571] the thirteenth (the empty-`$nin` refusal); these two numbers
// are the ratchet that makes a future unenveloped `throw` fail HERE instead
// of at an HTTP boundary.
expect(REFUSALS).toHaveLength(14);
expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(12);
expect(REFUSALS).toHaveLength(15);
expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(13);
for (const c of REFUSALS) {
expect(refusalFor(c.filter, c.alias)?.code, `${c.site} is still bare`).toBe('READ_SCOPE_COMPILE_FAILED');
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(service-analytics): refuse an empty $nin on the read-scope lowering instead of folding it to constant TRUE by os-steve · Pull Request #13649 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/read-scope-empty-nin-refusal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/service-analytics': patch
---

The analytics read-scope compiler (`read-scope-sql.ts`) now refuses an empty `$nin` (`READ_SCOPE_COMPILE_FAILED` / 500) instead of folding it to constant TRUE. An emptied exclusion ("NOT IN () excludes nothing") vacated the whole read scope — every row admitted — on the ADR-0021 lowering, where a widening is scope over-reach; no in-repo producer can emit the shape (the CEL lowering never emits `$nin`, and the RLS guard drops even-polarity empty-`$nin` policies upstream), so the refusal costs no live traffic. Deliberately asymmetric: `$in: []` keeps its ruled constant-FALSE fold (#5322/#5243), which the RLS compiler's inert positive composite — an emptied membership `$or`-ed beside an own-rows grant — depends on.
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,11 +266,30 @@ describe('[#5234] the read-scope lowering refuses the same two shapes, fail-clos
expect(scope({ name: { $startsWith: '_admin' } }).params).toEqual(['\\_admin%', '\\']);
});

it('an empty `$in` / `$nin` still lowers to its boolean constant, not a refusal', () => {
it('an empty `$in` still lowers to its FALSE constant, not a refusal', () => {
// The member scan runs AFTER the arity identities (#5134), so the empty
// list keeps compiling to `1 = 0` / `1 = 1` rather than becoming an error.
// INCLUSION keeps compiling to `1 = 0` rather than becoming an error. It
// KEEPS its #5322/#5243 reduction because that constant is FALSE —
// narrowing at this arm — and because a live producer depends on it: the
// RLS compiler deliberately emits `$in: []` at positive polarity inside
// composites (#13570's "own rows keep flowing" pin), and that filter
// reaches this compiler through `security.getReadFilter`.
expect(scope({ status: { $in: [] } }).sql).toContain('1 = 0');
expect(scope({ status: { $nin: [] } }).sql).toContain('1 = 1');
});

it('an empty `$nin` is REFUSED — its constant is TRUE, which vacates the scope (#13571)', () => {
// Deliberately ASYMMETRIC with the `$in` case above, and not an
// oversight: the #5322 boundary is "shape errors throw, boolean
// identities reduce", and `$nin: []` sits on the THROW side because its
// faithful reduction is constant TRUE — a read scope silently widened to
// every row, the exact thing the module header forbids. No in-repo
// producer can emit the shape (the CEL lowering never emits `$nin`;
// #13570's guard drops even-polarity empty-`$nin` policies), so the
// refusal costs no live traffic. See read-scope-sql.ts's #13571 section.
const err = refusalOf(() => scope({ status: { $nin: [] } }));
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(err.status).toBe(500);
expect(err.message).toContain('$nin for "status" is empty');
});
});
});
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13571] The empty-`$nin` compile refusal, exercised on the path that made it
* matter — a NON-RLS `getReadScope` provider — plus the over-denial control
* that bounds the fix.
*
* ## Why a non-RLS provider, and not an RLS regression
*
* In-repo, the only scope producer is the RLS compiler, and since PR #13570 its
* polarity-aware guard drops every emptied-membership shape at widening
* polarity before it is emitted — so an RLS-path regression for `$nin: []`
* would exercise a route that cannot reach this compiler at all and would test
* nothing. But `StrategyContext.getReadScope` is a spec contract
* (`packages/spec/src/contracts/analytics-service.ts` — its doc carries a
* hand-written example), fillable by ANY provider. The provider below is that
* contract filled by hand, which is exactly the surface the #13571 card named.
*
* ## The two controls
*
* 1. **Refusal (the fix).** A provider handing `{ owner: { $nin: [] } }` used
* to get a scope clause of constant TRUE (`1 = 1`) — on the read-scope
* lowering, the WHOLE TABLE, an ADR-0021 over-reach. It now gets the
* module's one refusal envelope: `READ_SCOPE_COMPILE_FAILED` / 500.
* MEASURED pre-fix (this file run against the pre-#13571 compiler, see the
* PR): the same case admitted every fixture row.
*
* 2. **Over-denial (the bound — the reason #13571 is NOT a uniform throw).**
* The RLS compiler deliberately emits an emptied POSITIVE membership inside
* a composite — `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }`,
* pinned by #13570's `rls-empty-membership-polarity.test.ts` as "own rows
* keep flowing" — and that filter reaches this compiler through
* `security.getReadFilter`. The refusal must NOT catch it: the scope still
* compiles and still admits exactly the own row. A uniform throw at both
* arms fails this block, which is the availability regression the #13571
* verdict rejected.
*
* `$in: []` under `$not` from a non-RLS provider (constant TRUE via inversion)
* is the verdict's DECLARED residue, deliberately not asserted here either way
* as a contract — `read-scope-not-null-safe.test.ts` pins its current
* behaviour next to the ruling's reasoning.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Cube } from '@objectstack/spec/data';
import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts';

import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js';

const FIXTURE = [
{ id: 'r1', owner: 'u_me' },
{ id: 'r2', owner: 'u_other' },
{ id: 'r3', owner: null },
];

const CUBE: Cube = {
name: 'deals',
title: 'Deals',
sql: 'deal',
measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } },
dimensions: Object.fromEntries(
['id', 'owner'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]),
),
public: false,
} as unknown as Cube;

/** Point sql.js at the `.wasm` shipped inside its own package (Node-safe). */
async function locateWasm(): Promise<((file: string) => string) | undefined> {
try {
const { createRequire } = await import('node:module');
const require = createRequire(import.meta.url);
const pkgJsonPath = require.resolve('sql.js/package.json');
const { dirname, join } = await import('node:path');
const dir = dirname(pkgJsonPath);
return (file: string) => join(dir, 'dist', file);
} catch {
return undefined;
}
}

describe('[#13571] empty `$nin` on the read-scope lowering — non-RLS provider control', () => {
let db: any;

beforeAll(async () => {
const mod: any = await import('sql.js');
const initSqlJs = mod.default ?? mod;
const locateFile = await locateWasm();
const SQL = await initSqlJs(locateFile ? { locateFile } : undefined);

db = new SQL.Database();
db.run(`CREATE TABLE "deal" ("id" TEXT PRIMARY KEY, "owner" TEXT);`);
const insert = db.prepare(`INSERT INTO "deal" ("id","owner") VALUES (?,?)`);
for (const r of FIXTURE) insert.run([r.id, r.owner]);
insert.free();
});

afterAll(() => {
db?.close();
});

/**
* A `StrategyContext` whose `getReadScope` is filled BY HAND — the spec
* contract's own authoring mode, and deliberately not the RLS compiler.
*/
const ctxWithScope = (scope: unknown): StrategyContext =>
({
getCube: (name: string) => (name === 'deals' ? CUBE : undefined),
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
getReadScope: () => scope,
executeRawSql: async (_object: string, sql: string, params: unknown[]) => {
const stmt = db.prepare(sql.replace(/\$\d+/g, '?'));
stmt.bind(params as any[]);
const out: Record<string, unknown>[] = [];
while (stmt.step()) out.push(stmt.getAsObject());
stmt.free();
return out;
},
}) as StrategyContext;

const QUERY: AnalyticsQuery = {
cube: 'deals',
measures: ['total'],
dimensions: ['id'],
timezone: 'UTC',
} as AnalyticsQuery;

/** Run the query under `scope`; return either the refusal or the admitted ids. */
const outcome = async (
scope: unknown,
): Promise<{ refusal?: Error & { code?: unknown; status?: unknown }; admitted?: string[] }> => {
try {
const result = await new NativeSQLStrategy().execute(QUERY, ctxWithScope(scope));
return { admitted: result.rows.map((r) => String(r.id)).sort((x, y) => x.localeCompare(y)) };
} catch (e) {
return { refusal: e as Error & { code?: unknown; status?: unknown } };
}
};

it('a provider handing `{ owner: { $nin: [] } }` is REFUSED in the module envelope — it used to get the whole table', async () => {
const { refusal, admitted } = await outcome({ owner: { $nin: [] } });
// Pre-#13571 this assertion's diff read `admitted: ['r1','r2','r3']` — the
// whole fixture, from a scope clause of constant TRUE.
expect(admitted).toBeUndefined();
expect(refusal).toBeInstanceOf(Error);
expect(refusal?.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(refusal?.status).toBe(500);
expect(String(refusal?.message)).toContain('$nin for "owner" is empty');
});

it('OVER-DENIAL CONTROL: the #13570-pinned RLS composite still compiles and still admits exactly the own row', async () => {
// `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }` is what
// `RLSCompiler.compileFilter` returns for an emptied membership set beside
// an own-rows grant ("own rows keep flowing"), and it arrives here through
// `security.getReadFilter`. The empty-`$nin` refusal must not touch it:
// this block red under a uniform empty-membership throw is the
// availability regression the #13571 verdict exists to avoid.
const { refusal, admitted } = await outcome({ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(refusal).toBeUndefined();
expect(admitted).toEqual(['r1']);
});

it('the composite denies-by-itself shape stays working too: a lone empty `$in` arm inside `$and`', async () => {
// The other composite #13570's guard deliberately passes through: as an
// `$and` arm the emptied positive membership is constant FALSE — the whole
// scope denies. Zero rows, not a refusal and not the whole table.
const { refusal, admitted } = await outcome({ $and: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(refusal).toBeUndefined();
expect(admitted).toEqual([]);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -347,9 +347,29 @@ describe('[#5297] read-scope `$not` — boolean identities and NULL safety', ()
expect(ids({ $not: { stage: { $ne: null } } })).toEqual(['3', '4']);
});

it('an empty `$in` / `$nin` under a `$not` keeps its constant value', () => {
it('an empty `$in` under a `$not` keeps its constant value; an empty `$nin` refuses before `$not` matters (#13571)', () => {
// `$in: []` keeps its #5322/#5243 reduction — constant FALSE, total, no
// guard — and the negation flips it to TRUE: every row. That widened
// composition is the #13571 verdict's DECLARED residue for a non-RLS
// producer (the in-repo RLS compiler cannot emit the shape — #13570's
// polarity guard drops it upstream); closing it is a ruled follow-up
// design, not an edit to this pin.
expect(ids({ $not: { stage: { $in: [] } } })).toEqual(ALL);
expect(ids({ $not: { stage: { $nin: [] } } })).toEqual([]);
// `$nin: []` no longer HAS a constant to keep: its reduction is TRUE —
// scope-vacating on its own — so `compileOperator` refuses it whatever
// the polarity above it. Deliberately asymmetric with the `$in` line
// above ("shape errors throw, boolean identities reduce" is the #5322
// boundary, and a scope-vacating reduction is on the THROW side) — see
// read-scope-sql.ts's #13571 header section.
let refusal: (Error & { code?: unknown; status?: unknown }) | undefined;
try {
ids({ $not: { stage: { $nin: [] } } });
} catch (e) {
refusal = e as Error & { code?: unknown; status?: unknown };
}
expect(refusal?.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(refusal?.status).toBe(500);
expect(String(refusal?.message)).toContain('$nin for "stage" is empty');
});
});

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -93,7 +93,7 @@ function refusalFor(filter: unknown, alias = 'crm_opportunity'): Refusal | undef
/**
* Every refusing site in `read-scope-sql.ts`, in source order.
*
* FOURTEEN rows over TWELVE throw sites: TWO sites are each reached by two
* FIFTEEN rows over THIRTEEN throw sites: TWO sites are each reached by two
* triggers, and every trigger is listed on purpose.
*
* - `quoteIdent`, with two `kind` values. That alias-vs-field split was option
Expand DownExpand Up@@ -208,14 +208,26 @@ const REFUSALS: Array<{
sensitive: 'region_code',
},
{
name: '⑬ $between without [min,max]',
// [#13571] The empty EXCLUSION refuses; the empty INCLUSION keeps its
// constant — see the ACCEPTED table's "#5243" row. Deliberate asymmetry
// ("shape errors throw, boolean identities reduce" — #5322; a reduction to
// constant TRUE vacates the scope, so it is on the throw side), not an
// oversight: read-scope-sql.ts's #13571 header section carries the ruling.
name: '⑬ $nin with an EMPTY array',
site: 'compileOperator: empty $nin vacates the scope',
filter: { region_code: { $nin: [] } },
message: /\$nin for "region_code" is empty — an empty exclusion excludes nothing and would compile the read scope to constant TRUE \(fail-closed\)/,
sensitive: 'region_code',
},
{
name: '⑭ $between without [min,max]',
site: 'compileOperator: $between needs [min,max]',
filter: { credit_limit: { $between: [10] } },
message: /\$between for "credit_limit" needs \[min,max\] \(fail-closed\)/,
sensitive: 'credit_limit',
},
{
name: ' unsupported operator',
name: ' unsupported operator',
site: 'compileOperator: unsupported operator',
filter: { owner_email: { $regex: 'admin@' } },
message: /unsupported operator "\$regex" on "owner_email" \(fail-closed\)/,
Expand DownExpand Up@@ -244,6 +256,11 @@ const ACCEPTED: Array<{ name: string; filter: unknown; sql: string; params: unkn
params: ['emea', 'apac'],
},
{
// [#13571] STAYS accepted while the empty `$nin` refuses (REFUSALS ⑬):
// this constant is FALSE — narrowing at its own arm — and the RLS compiler
// deliberately emits the shape at positive polarity inside composites
// (#13570's "own rows keep flowing" pin), so refusing it here would 500 a
// live, ruled-correct scope. The asymmetry is the #13571 ruling itself.
name: 'an empty $in as the FALSE constant (#5243)',
filter: { region_code: { $in: [] } },
sql: '1 = 0',
Expand DownExpand Up@@ -306,13 +323,14 @@ describe('[#5367] every read-scope refusal carries the ADR-0112 envelope (READ_S
// #5352's lesson, stated as a guard: seven of `filter-normalizer.ts`'s nine
// sites carrying an envelope was indistinguishable from none of them at the
// HTTP boundary, because the commonest input hit one of the two bare ones.
// Fourteen inputs over the module's TWELVE throw sites (see the table's note
// on the two sites with two triggers each), and every one of them enveloped.
// [#6125] added the eleventh site, [#6387] the twelfth; these two numbers
// Fifteen inputs over the module's THIRTEEN throw sites (see the table's
// note on the two sites with two triggers each), and every one of them
// enveloped. [#6125] added the eleventh site, [#6387] the twelfth, and
// [#13571] the thirteenth (the empty-`$nin` refusal); these two numbers
// are the ratchet that makes a future unenveloped `throw` fail HERE instead
// of at an HTTP boundary.
expect(REFUSALS).toHaveLength(14);
expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(12);
expect(REFUSALS).toHaveLength(15);
expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(13);
for (const c of REFUSALS) {
expect(refusalFor(c.filter, c.alias)?.code, `${c.site} is still bare`).toBe('READ_SCOPE_COMPILE_FAILED');
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(service-analytics): refuse an empty $nin on the read-scope lowering instead of folding it to constant TRUE by os-steve · Pull Request #13649 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/read-scope-empty-nin-refusal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/service-analytics': patch
---

The analytics read-scope compiler (`read-scope-sql.ts`) now refuses an empty `$nin` (`READ_SCOPE_COMPILE_FAILED` / 500) instead of folding it to constant TRUE. An emptied exclusion ("NOT IN () excludes nothing") vacated the whole read scope — every row admitted — on the ADR-0021 lowering, where a widening is scope over-reach; no in-repo producer can emit the shape (the CEL lowering never emits `$nin`, and the RLS guard drops even-polarity empty-`$nin` policies upstream), so the refusal costs no live traffic. Deliberately asymmetric: `$in: []` keeps its ruled constant-FALSE fold (#5322/#5243), which the RLS compiler's inert positive composite — an emptied membership `$or`-ed beside an own-rows grant — depends on.
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,11 +266,30 @@ describe('[#5234] the read-scope lowering refuses the same two shapes, fail-clos
expect(scope({ name: { $startsWith: '_admin' } }).params).toEqual(['\\_admin%', '\\']);
});

it('an empty `$in` / `$nin` still lowers to its boolean constant, not a refusal', () => {
it('an empty `$in` still lowers to its FALSE constant, not a refusal', () => {
// The member scan runs AFTER the arity identities (#5134), so the empty
// list keeps compiling to `1 = 0` / `1 = 1` rather than becoming an error.
// INCLUSION keeps compiling to `1 = 0` rather than becoming an error. It
// KEEPS its #5322/#5243 reduction because that constant is FALSE —
// narrowing at this arm — and because a live producer depends on it: the
// RLS compiler deliberately emits `$in: []` at positive polarity inside
// composites (#13570's "own rows keep flowing" pin), and that filter
// reaches this compiler through `security.getReadFilter`.
expect(scope({ status: { $in: [] } }).sql).toContain('1 = 0');
expect(scope({ status: { $nin: [] } }).sql).toContain('1 = 1');
});

it('an empty `$nin` is REFUSED — its constant is TRUE, which vacates the scope (#13571)', () => {
// Deliberately ASYMMETRIC with the `$in` case above, and not an
// oversight: the #5322 boundary is "shape errors throw, boolean
// identities reduce", and `$nin: []` sits on the THROW side because its
// faithful reduction is constant TRUE — a read scope silently widened to
// every row, the exact thing the module header forbids. No in-repo
// producer can emit the shape (the CEL lowering never emits `$nin`;
// #13570's guard drops even-polarity empty-`$nin` policies), so the
// refusal costs no live traffic. See read-scope-sql.ts's #13571 section.
const err = refusalOf(() => scope({ status: { $nin: [] } }));
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(err.status).toBe(500);
expect(err.message).toContain('$nin for "status" is empty');
});
});
});
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13571] The empty-`$nin` compile refusal, exercised on the path that made it
* matter — a NON-RLS `getReadScope` provider — plus the over-denial control
* that bounds the fix.
*
* ## Why a non-RLS provider, and not an RLS regression
*
* In-repo, the only scope producer is the RLS compiler, and since PR #13570 its
* polarity-aware guard drops every emptied-membership shape at widening
* polarity before it is emitted — so an RLS-path regression for `$nin: []`
* would exercise a route that cannot reach this compiler at all and would test
* nothing. But `StrategyContext.getReadScope` is a spec contract
* (`packages/spec/src/contracts/analytics-service.ts` — its doc carries a
* hand-written example), fillable by ANY provider. The provider below is that
* contract filled by hand, which is exactly the surface the #13571 card named.
*
* ## The two controls
*
* 1. **Refusal (the fix).** A provider handing `{ owner: { $nin: [] } }` used
* to get a scope clause of constant TRUE (`1 = 1`) — on the read-scope
* lowering, the WHOLE TABLE, an ADR-0021 over-reach. It now gets the
* module's one refusal envelope: `READ_SCOPE_COMPILE_FAILED` / 500.
* MEASURED pre-fix (this file run against the pre-#13571 compiler, see the
* PR): the same case admitted every fixture row.
*
* 2. **Over-denial (the bound — the reason #13571 is NOT a uniform throw).**
* The RLS compiler deliberately emits an emptied POSITIVE membership inside
* a composite — `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }`,
* pinned by #13570's `rls-empty-membership-polarity.test.ts` as "own rows
* keep flowing" — and that filter reaches this compiler through
* `security.getReadFilter`. The refusal must NOT catch it: the scope still
* compiles and still admits exactly the own row. A uniform throw at both
* arms fails this block, which is the availability regression the #13571
* verdict rejected.
*
* `$in: []` under `$not` from a non-RLS provider (constant TRUE via inversion)
* is the verdict's DECLARED residue, deliberately not asserted here either way
* as a contract — `read-scope-not-null-safe.test.ts` pins its current
* behaviour next to the ruling's reasoning.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Cube } from '@objectstack/spec/data';
import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts';

import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js';

const FIXTURE = [
{ id: 'r1', owner: 'u_me' },
{ id: 'r2', owner: 'u_other' },
{ id: 'r3', owner: null },
];

const CUBE: Cube = {
name: 'deals',
title: 'Deals',
sql: 'deal',
measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } },
dimensions: Object.fromEntries(
['id', 'owner'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]),
),
public: false,
} as unknown as Cube;

/** Point sql.js at the `.wasm` shipped inside its own package (Node-safe). */
async function locateWasm(): Promise<((file: string) => string) | undefined> {
try {
const { createRequire } = await import('node:module');
const require = createRequire(import.meta.url);
const pkgJsonPath = require.resolve('sql.js/package.json');
const { dirname, join } = await import('node:path');
const dir = dirname(pkgJsonPath);
return (file: string) => join(dir, 'dist', file);
} catch {
return undefined;
}
}

describe('[#13571] empty `$nin` on the read-scope lowering — non-RLS provider control', () => {
let db: any;

beforeAll(async () => {
const mod: any = await import('sql.js');
const initSqlJs = mod.default ?? mod;
const locateFile = await locateWasm();
const SQL = await initSqlJs(locateFile ? { locateFile } : undefined);

db = new SQL.Database();
db.run(`CREATE TABLE "deal" ("id" TEXT PRIMARY KEY, "owner" TEXT);`);
const insert = db.prepare(`INSERT INTO "deal" ("id","owner") VALUES (?,?)`);
for (const r of FIXTURE) insert.run([r.id, r.owner]);
insert.free();
});

afterAll(() => {
db?.close();
});

/**
* A `StrategyContext` whose `getReadScope` is filled BY HAND — the spec
* contract's own authoring mode, and deliberately not the RLS compiler.
*/
const ctxWithScope = (scope: unknown): StrategyContext =>
({
getCube: (name: string) => (name === 'deals' ? CUBE : undefined),
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
getReadScope: () => scope,
executeRawSql: async (_object: string, sql: string, params: unknown[]) => {
const stmt = db.prepare(sql.replace(/\$\d+/g, '?'));
stmt.bind(params as any[]);
const out: Record<string, unknown>[] = [];
while (stmt.step()) out.push(stmt.getAsObject());
stmt.free();
return out;
},
}) as StrategyContext;

const QUERY: AnalyticsQuery = {
cube: 'deals',
measures: ['total'],
dimensions: ['id'],
timezone: 'UTC',
} as AnalyticsQuery;

/** Run the query under `scope`; return either the refusal or the admitted ids. */
const outcome = async (
scope: unknown,
): Promise<{ refusal?: Error & { code?: unknown; status?: unknown }; admitted?: string[] }> => {
try {
const result = await new NativeSQLStrategy().execute(QUERY, ctxWithScope(scope));
return { admitted: result.rows.map((r) => String(r.id)).sort((x, y) => x.localeCompare(y)) };
} catch (e) {
return { refusal: e as Error & { code?: unknown; status?: unknown } };
}
};

it('a provider handing `{ owner: { $nin: [] } }` is REFUSED in the module envelope — it used to get the whole table', async () => {
const { refusal, admitted } = await outcome({ owner: { $nin: [] } });
// Pre-#13571 this assertion's diff read `admitted: ['r1','r2','r3']` — the
// whole fixture, from a scope clause of constant TRUE.
expect(admitted).toBeUndefined();
expect(refusal).toBeInstanceOf(Error);
expect(refusal?.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(refusal?.status).toBe(500);
expect(String(refusal?.message)).toContain('$nin for "owner" is empty');
});

it('OVER-DENIAL CONTROL: the #13570-pinned RLS composite still compiles and still admits exactly the own row', async () => {
// `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }` is what
// `RLSCompiler.compileFilter` returns for an emptied membership set beside
// an own-rows grant ("own rows keep flowing"), and it arrives here through
// `security.getReadFilter`. The empty-`$nin` refusal must not touch it:
// this block red under a uniform empty-membership throw is the
// availability regression the #13571 verdict exists to avoid.
const { refusal, admitted } = await outcome({ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(refusal).toBeUndefined();
expect(admitted).toEqual(['r1']);
});

it('the composite denies-by-itself shape stays working too: a lone empty `$in` arm inside `$and`', async () => {
// The other composite #13570's guard deliberately passes through: as an
// `$and` arm the emptied positive membership is constant FALSE — the whole
// scope denies. Zero rows, not a refusal and not the whole table.
const { refusal, admitted } = await outcome({ $and: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(refusal).toBeUndefined();
expect(admitted).toEqual([]);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -347,9 +347,29 @@ describe('[#5297] read-scope `$not` — boolean identities and NULL safety', ()
expect(ids({ $not: { stage: { $ne: null } } })).toEqual(['3', '4']);
});

it('an empty `$in` / `$nin` under a `$not` keeps its constant value', () => {
it('an empty `$in` under a `$not` keeps its constant value; an empty `$nin` refuses before `$not` matters (#13571)', () => {
// `$in: []` keeps its #5322/#5243 reduction — constant FALSE, total, no
// guard — and the negation flips it to TRUE: every row. That widened
// composition is the #13571 verdict's DECLARED residue for a non-RLS
// producer (the in-repo RLS compiler cannot emit the shape — #13570's
// polarity guard drops it upstream); closing it is a ruled follow-up
// design, not an edit to this pin.
expect(ids({ $not: { stage: { $in: [] } } })).toEqual(ALL);
expect(ids({ $not: { stage: { $nin: [] } } })).toEqual([]);
// `$nin: []` no longer HAS a constant to keep: its reduction is TRUE —
// scope-vacating on its own — so `compileOperator` refuses it whatever
// the polarity above it. Deliberately asymmetric with the `$in` line
// above ("shape errors throw, boolean identities reduce" is the #5322
// boundary, and a scope-vacating reduction is on the THROW side) — see
// read-scope-sql.ts's #13571 header section.
let refusal: (Error & { code?: unknown; status?: unknown }) | undefined;
try {
ids({ $not: { stage: { $nin: [] } } });
} catch (e) {
refusal = e as Error & { code?: unknown; status?: unknown };
}
expect(refusal?.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(refusal?.status).toBe(500);
expect(String(refusal?.message)).toContain('$nin for "stage" is empty');
});
});

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -93,7 +93,7 @@ function refusalFor(filter: unknown, alias = 'crm_opportunity'): Refusal | undef
/**
* Every refusing site in `read-scope-sql.ts`, in source order.
*
* FOURTEEN rows over TWELVE throw sites: TWO sites are each reached by two
* FIFTEEN rows over THIRTEEN throw sites: TWO sites are each reached by two
* triggers, and every trigger is listed on purpose.
*
* - `quoteIdent`, with two `kind` values. That alias-vs-field split was option
Expand DownExpand Up@@ -208,14 +208,26 @@ const REFUSALS: Array<{
sensitive: 'region_code',
},
{
name: '⑬ $between without [min,max]',
// [#13571] The empty EXCLUSION refuses; the empty INCLUSION keeps its
// constant — see the ACCEPTED table's "#5243" row. Deliberate asymmetry
// ("shape errors throw, boolean identities reduce" — #5322; a reduction to
// constant TRUE vacates the scope, so it is on the throw side), not an
// oversight: read-scope-sql.ts's #13571 header section carries the ruling.
name: '⑬ $nin with an EMPTY array',
site: 'compileOperator: empty $nin vacates the scope',
filter: { region_code: { $nin: [] } },
message: /\$nin for "region_code" is empty — an empty exclusion excludes nothing and would compile the read scope to constant TRUE \(fail-closed\)/,
sensitive: 'region_code',
},
{
name: '⑭ $between without [min,max]',
site: 'compileOperator: $between needs [min,max]',
filter: { credit_limit: { $between: [10] } },
message: /\$between for "credit_limit" needs \[min,max\] \(fail-closed\)/,
sensitive: 'credit_limit',
},
{
name: ' unsupported operator',
name: ' unsupported operator',
site: 'compileOperator: unsupported operator',
filter: { owner_email: { $regex: 'admin@' } },
message: /unsupported operator "\$regex" on "owner_email" \(fail-closed\)/,
Expand DownExpand Up@@ -244,6 +256,11 @@ const ACCEPTED: Array<{ name: string; filter: unknown; sql: string; params: unkn
params: ['emea', 'apac'],
},
{
// [#13571] STAYS accepted while the empty `$nin` refuses (REFUSALS ⑬):
// this constant is FALSE — narrowing at its own arm — and the RLS compiler
// deliberately emits the shape at positive polarity inside composites
// (#13570's "own rows keep flowing" pin), so refusing it here would 500 a
// live, ruled-correct scope. The asymmetry is the #13571 ruling itself.
name: 'an empty $in as the FALSE constant (#5243)',
filter: { region_code: { $in: [] } },
sql: '1 = 0',
Expand DownExpand Up@@ -306,13 +323,14 @@ describe('[#5367] every read-scope refusal carries the ADR-0112 envelope (READ_S
// #5352's lesson, stated as a guard: seven of `filter-normalizer.ts`'s nine
// sites carrying an envelope was indistinguishable from none of them at the
// HTTP boundary, because the commonest input hit one of the two bare ones.
// Fourteen inputs over the module's TWELVE throw sites (see the table's note
// on the two sites with two triggers each), and every one of them enveloped.
// [#6125] added the eleventh site, [#6387] the twelfth; these two numbers
// Fifteen inputs over the module's THIRTEEN throw sites (see the table's
// note on the two sites with two triggers each), and every one of them
// enveloped. [#6125] added the eleventh site, [#6387] the twelfth, and
// [#13571] the thirteenth (the empty-`$nin` refusal); these two numbers
// are the ratchet that makes a future unenveloped `throw` fail HERE instead
// of at an HTTP boundary.
expect(REFUSALS).toHaveLength(14);
expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(12);
expect(REFUSALS).toHaveLength(15);
expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(13);
for (const c of REFUSALS) {
expect(refusalFor(c.filter, c.alias)?.code, `${c.site} is still bare`).toBe('READ_SCOPE_COMPILE_FAILED');
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(service-analytics): refuse an empty $nin on the read-scope lowering instead of folding it to constant TRUE by os-steve · Pull Request #13649 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/read-scope-empty-nin-refusal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/service-analytics': patch
---

The analytics read-scope compiler (`read-scope-sql.ts`) now refuses an empty `$nin` (`READ_SCOPE_COMPILE_FAILED` / 500) instead of folding it to constant TRUE. An emptied exclusion ("NOT IN () excludes nothing") vacated the whole read scope — every row admitted — on the ADR-0021 lowering, where a widening is scope over-reach; no in-repo producer can emit the shape (the CEL lowering never emits `$nin`, and the RLS guard drops even-polarity empty-`$nin` policies upstream), so the refusal costs no live traffic. Deliberately asymmetric: `$in: []` keeps its ruled constant-FALSE fold (#5322/#5243), which the RLS compiler's inert positive composite — an emptied membership `$or`-ed beside an own-rows grant — depends on.
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,11 +266,30 @@ describe('[#5234] the read-scope lowering refuses the same two shapes, fail-clos
expect(scope({ name: { $startsWith: '_admin' } }).params).toEqual(['\\_admin%', '\\']);
});

it('an empty `$in` / `$nin` still lowers to its boolean constant, not a refusal', () => {
it('an empty `$in` still lowers to its FALSE constant, not a refusal', () => {
// The member scan runs AFTER the arity identities (#5134), so the empty
// list keeps compiling to `1 = 0` / `1 = 1` rather than becoming an error.
// INCLUSION keeps compiling to `1 = 0` rather than becoming an error. It
// KEEPS its #5322/#5243 reduction because that constant is FALSE —
// narrowing at this arm — and because a live producer depends on it: the
// RLS compiler deliberately emits `$in: []` at positive polarity inside
// composites (#13570's "own rows keep flowing" pin), and that filter
// reaches this compiler through `security.getReadFilter`.
expect(scope({ status: { $in: [] } }).sql).toContain('1 = 0');
expect(scope({ status: { $nin: [] } }).sql).toContain('1 = 1');
});

it('an empty `$nin` is REFUSED — its constant is TRUE, which vacates the scope (#13571)', () => {
// Deliberately ASYMMETRIC with the `$in` case above, and not an
// oversight: the #5322 boundary is "shape errors throw, boolean
// identities reduce", and `$nin: []` sits on the THROW side because its
// faithful reduction is constant TRUE — a read scope silently widened to
// every row, the exact thing the module header forbids. No in-repo
// producer can emit the shape (the CEL lowering never emits `$nin`;
// #13570's guard drops even-polarity empty-`$nin` policies), so the
// refusal costs no live traffic. See read-scope-sql.ts's #13571 section.
const err = refusalOf(() => scope({ status: { $nin: [] } }));
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(err.status).toBe(500);
expect(err.message).toContain('$nin for "status" is empty');
});
});
});
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13571] The empty-`$nin` compile refusal, exercised on the path that made it
* matter — a NON-RLS `getReadScope` provider — plus the over-denial control
* that bounds the fix.
*
* ## Why a non-RLS provider, and not an RLS regression
*
* In-repo, the only scope producer is the RLS compiler, and since PR #13570 its
* polarity-aware guard drops every emptied-membership shape at widening
* polarity before it is emitted — so an RLS-path regression for `$nin: []`
* would exercise a route that cannot reach this compiler at all and would test
* nothing. But `StrategyContext.getReadScope` is a spec contract
* (`packages/spec/src/contracts/analytics-service.ts` — its doc carries a
* hand-written example), fillable by ANY provider. The provider below is that
* contract filled by hand, which is exactly the surface the #13571 card named.
*
* ## The two controls
*
* 1. **Refusal (the fix).** A provider handing `{ owner: { $nin: [] } }` used
* to get a scope clause of constant TRUE (`1 = 1`) — on the read-scope
* lowering, the WHOLE TABLE, an ADR-0021 over-reach. It now gets the
* module's one refusal envelope: `READ_SCOPE_COMPILE_FAILED` / 500.
* MEASURED pre-fix (this file run against the pre-#13571 compiler, see the
* PR): the same case admitted every fixture row.
*
* 2. **Over-denial (the bound — the reason #13571 is NOT a uniform throw).**
* The RLS compiler deliberately emits an emptied POSITIVE membership inside
* a composite — `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }`,
* pinned by #13570's `rls-empty-membership-polarity.test.ts` as "own rows
* keep flowing" — and that filter reaches this compiler through
* `security.getReadFilter`. The refusal must NOT catch it: the scope still
* compiles and still admits exactly the own row. A uniform throw at both
* arms fails this block, which is the availability regression the #13571
* verdict rejected.
*
* `$in: []` under `$not` from a non-RLS provider (constant TRUE via inversion)
* is the verdict's DECLARED residue, deliberately not asserted here either way
* as a contract — `read-scope-not-null-safe.test.ts` pins its current
* behaviour next to the ruling's reasoning.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Cube } from '@objectstack/spec/data';
import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts';

import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js';

const FIXTURE = [
{ id: 'r1', owner: 'u_me' },
{ id: 'r2', owner: 'u_other' },
{ id: 'r3', owner: null },
];

const CUBE: Cube = {
name: 'deals',
title: 'Deals',
sql: 'deal',
measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } },
dimensions: Object.fromEntries(
['id', 'owner'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]),
),
public: false,
} as unknown as Cube;

/** Point sql.js at the `.wasm` shipped inside its own package (Node-safe). */
async function locateWasm(): Promise<((file: string) => string) | undefined> {
try {
const { createRequire } = await import('node:module');
const require = createRequire(import.meta.url);
const pkgJsonPath = require.resolve('sql.js/package.json');
const { dirname, join } = await import('node:path');
const dir = dirname(pkgJsonPath);
return (file: string) => join(dir, 'dist', file);
} catch {
return undefined;
}
}

describe('[#13571] empty `$nin` on the read-scope lowering — non-RLS provider control', () => {
let db: any;

beforeAll(async () => {
const mod: any = await import('sql.js');
const initSqlJs = mod.default ?? mod;
const locateFile = await locateWasm();
const SQL = await initSqlJs(locateFile ? { locateFile } : undefined);

db = new SQL.Database();
db.run(`CREATE TABLE "deal" ("id" TEXT PRIMARY KEY, "owner" TEXT);`);
const insert = db.prepare(`INSERT INTO "deal" ("id","owner") VALUES (?,?)`);
for (const r of FIXTURE) insert.run([r.id, r.owner]);
insert.free();
});

afterAll(() => {
db?.close();
});

/**
* A `StrategyContext` whose `getReadScope` is filled BY HAND — the spec
* contract's own authoring mode, and deliberately not the RLS compiler.
*/
const ctxWithScope = (scope: unknown): StrategyContext =>
({
getCube: (name: string) => (name === 'deals' ? CUBE : undefined),
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
getReadScope: () => scope,
executeRawSql: async (_object: string, sql: string, params: unknown[]) => {
const stmt = db.prepare(sql.replace(/\$\d+/g, '?'));
stmt.bind(params as any[]);
const out: Record<string, unknown>[] = [];
while (stmt.step()) out.push(stmt.getAsObject());
stmt.free();
return out;
},
}) as StrategyContext;

const QUERY: AnalyticsQuery = {
cube: 'deals',
measures: ['total'],
dimensions: ['id'],
timezone: 'UTC',
} as AnalyticsQuery;

/** Run the query under `scope`; return either the refusal or the admitted ids. */
const outcome = async (
scope: unknown,
): Promise<{ refusal?: Error & { code?: unknown; status?: unknown }; admitted?: string[] }> => {
try {
const result = await new NativeSQLStrategy().execute(QUERY, ctxWithScope(scope));
return { admitted: result.rows.map((r) => String(r.id)).sort((x, y) => x.localeCompare(y)) };
} catch (e) {
return { refusal: e as Error & { code?: unknown; status?: unknown } };
}
};

it('a provider handing `{ owner: { $nin: [] } }` is REFUSED in the module envelope — it used to get the whole table', async () => {
const { refusal, admitted } = await outcome({ owner: { $nin: [] } });
// Pre-#13571 this assertion's diff read `admitted: ['r1','r2','r3']` — the
// whole fixture, from a scope clause of constant TRUE.
expect(admitted).toBeUndefined();
expect(refusal).toBeInstanceOf(Error);
expect(refusal?.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(refusal?.status).toBe(500);
expect(String(refusal?.message)).toContain('$nin for "owner" is empty');
});

it('OVER-DENIAL CONTROL: the #13570-pinned RLS composite still compiles and still admits exactly the own row', async () => {
// `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }` is what
// `RLSCompiler.compileFilter` returns for an emptied membership set beside
// an own-rows grant ("own rows keep flowing"), and it arrives here through
// `security.getReadFilter`. The empty-`$nin` refusal must not touch it:
// this block red under a uniform empty-membership throw is the
// availability regression the #13571 verdict exists to avoid.
const { refusal, admitted } = await outcome({ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(refusal).toBeUndefined();
expect(admitted).toEqual(['r1']);
});

it('the composite denies-by-itself shape stays working too: a lone empty `$in` arm inside `$and`', async () => {
// The other composite #13570's guard deliberately passes through: as an
// `$and` arm the emptied positive membership is constant FALSE — the whole
// scope denies. Zero rows, not a refusal and not the whole table.
const { refusal, admitted } = await outcome({ $and: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(refusal).toBeUndefined();
expect(admitted).toEqual([]);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -347,9 +347,29 @@ describe('[#5297] read-scope `$not` — boolean identities and NULL safety', ()
expect(ids({ $not: { stage: { $ne: null } } })).toEqual(['3', '4']);
});

it('an empty `$in` / `$nin` under a `$not` keeps its constant value', () => {
it('an empty `$in` under a `$not` keeps its constant value; an empty `$nin` refuses before `$not` matters (#13571)', () => {
// `$in: []` keeps its #5322/#5243 reduction — constant FALSE, total, no
// guard — and the negation flips it to TRUE: every row. That widened
// composition is the #13571 verdict's DECLARED residue for a non-RLS
// producer (the in-repo RLS compiler cannot emit the shape — #13570's
// polarity guard drops it upstream); closing it is a ruled follow-up
// design, not an edit to this pin.
expect(ids({ $not: { stage: { $in: [] } } })).toEqual(ALL);
expect(ids({ $not: { stage: { $nin: [] } } })).toEqual([]);
// `$nin: []` no longer HAS a constant to keep: its reduction is TRUE —
// scope-vacating on its own — so `compileOperator` refuses it whatever
// the polarity above it. Deliberately asymmetric with the `$in` line
// above ("shape errors throw, boolean identities reduce" is the #5322
// boundary, and a scope-vacating reduction is on the THROW side) — see
// read-scope-sql.ts's #13571 header section.
let refusal: (Error & { code?: unknown; status?: unknown }) | undefined;
try {
ids({ $not: { stage: { $nin: [] } } });
} catch (e) {
refusal = e as Error & { code?: unknown; status?: unknown };
}
expect(refusal?.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(refusal?.status).toBe(500);
expect(String(refusal?.message)).toContain('$nin for "stage" is empty');
});
});

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -93,7 +93,7 @@ function refusalFor(filter: unknown, alias = 'crm_opportunity'): Refusal | undef
/**
* Every refusing site in `read-scope-sql.ts`, in source order.
*
* FOURTEEN rows over TWELVE throw sites: TWO sites are each reached by two
* FIFTEEN rows over THIRTEEN throw sites: TWO sites are each reached by two
* triggers, and every trigger is listed on purpose.
*
* - `quoteIdent`, with two `kind` values. That alias-vs-field split was option
Expand DownExpand Up@@ -208,14 +208,26 @@ const REFUSALS: Array<{
sensitive: 'region_code',
},
{
name: '⑬ $between without [min,max]',
// [#13571] The empty EXCLUSION refuses; the empty INCLUSION keeps its
// constant — see the ACCEPTED table's "#5243" row. Deliberate asymmetry
// ("shape errors throw, boolean identities reduce" — #5322; a reduction to
// constant TRUE vacates the scope, so it is on the throw side), not an
// oversight: read-scope-sql.ts's #13571 header section carries the ruling.
name: '⑬ $nin with an EMPTY array',
site: 'compileOperator: empty $nin vacates the scope',
filter: { region_code: { $nin: [] } },
message: /\$nin for "region_code" is empty — an empty exclusion excludes nothing and would compile the read scope to constant TRUE \(fail-closed\)/,
sensitive: 'region_code',
},
{
name: '⑭ $between without [min,max]',
site: 'compileOperator: $between needs [min,max]',
filter: { credit_limit: { $between: [10] } },
message: /\$between for "credit_limit" needs \[min,max\] \(fail-closed\)/,
sensitive: 'credit_limit',
},
{
name: ' unsupported operator',
name: ' unsupported operator',
site: 'compileOperator: unsupported operator',
filter: { owner_email: { $regex: 'admin@' } },
message: /unsupported operator "\$regex" on "owner_email" \(fail-closed\)/,
Expand DownExpand Up@@ -244,6 +256,11 @@ const ACCEPTED: Array<{ name: string; filter: unknown; sql: string; params: unkn
params: ['emea', 'apac'],
},
{
// [#13571] STAYS accepted while the empty `$nin` refuses (REFUSALS ⑬):
// this constant is FALSE — narrowing at its own arm — and the RLS compiler
// deliberately emits the shape at positive polarity inside composites
// (#13570's "own rows keep flowing" pin), so refusing it here would 500 a
// live, ruled-correct scope. The asymmetry is the #13571 ruling itself.
name: 'an empty $in as the FALSE constant (#5243)',
filter: { region_code: { $in: [] } },
sql: '1 = 0',
Expand DownExpand Up@@ -306,13 +323,14 @@ describe('[#5367] every read-scope refusal carries the ADR-0112 envelope (READ_S
// #5352's lesson, stated as a guard: seven of `filter-normalizer.ts`'s nine
// sites carrying an envelope was indistinguishable from none of them at the
// HTTP boundary, because the commonest input hit one of the two bare ones.
// Fourteen inputs over the module's TWELVE throw sites (see the table's note
// on the two sites with two triggers each), and every one of them enveloped.
// [#6125] added the eleventh site, [#6387] the twelfth; these two numbers
// Fifteen inputs over the module's THIRTEEN throw sites (see the table's
// note on the two sites with two triggers each), and every one of them
// enveloped. [#6125] added the eleventh site, [#6387] the twelfth, and
// [#13571] the thirteenth (the empty-`$nin` refusal); these two numbers
// are the ratchet that makes a future unenveloped `throw` fail HERE instead
// of at an HTTP boundary.
expect(REFUSALS).toHaveLength(14);
expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(12);
expect(REFUSALS).toHaveLength(15);
expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(13);
for (const c of REFUSALS) {
expect(refusalFor(c.filter, c.alias)?.code, `${c.site} is still bare`).toBe('READ_SCOPE_COMPILE_FAILED');
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(service-analytics): refuse an empty $nin on the read-scope lowering instead of folding it to constant TRUE by os-steve · Pull Request #13649 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/read-scope-empty-nin-refusal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/service-analytics': patch
---

The analytics read-scope compiler (`read-scope-sql.ts`) now refuses an empty `$nin` (`READ_SCOPE_COMPILE_FAILED` / 500) instead of folding it to constant TRUE. An emptied exclusion ("NOT IN () excludes nothing") vacated the whole read scope — every row admitted — on the ADR-0021 lowering, where a widening is scope over-reach; no in-repo producer can emit the shape (the CEL lowering never emits `$nin`, and the RLS guard drops even-polarity empty-`$nin` policies upstream), so the refusal costs no live traffic. Deliberately asymmetric: `$in: []` keeps its ruled constant-FALSE fold (#5322/#5243), which the RLS compiler's inert positive composite — an emptied membership `$or`-ed beside an own-rows grant — depends on.
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,11 +266,30 @@ describe('[#5234] the read-scope lowering refuses the same two shapes, fail-clos
expect(scope({ name: { $startsWith: '_admin' } }).params).toEqual(['\\_admin%', '\\']);
});

it('an empty `$in` / `$nin` still lowers to its boolean constant, not a refusal', () => {
it('an empty `$in` still lowers to its FALSE constant, not a refusal', () => {
// The member scan runs AFTER the arity identities (#5134), so the empty
// list keeps compiling to `1 = 0` / `1 = 1` rather than becoming an error.
// INCLUSION keeps compiling to `1 = 0` rather than becoming an error. It
// KEEPS its #5322/#5243 reduction because that constant is FALSE —
// narrowing at this arm — and because a live producer depends on it: the
// RLS compiler deliberately emits `$in: []` at positive polarity inside
// composites (#13570's "own rows keep flowing" pin), and that filter
// reaches this compiler through `security.getReadFilter`.
expect(scope({ status: { $in: [] } }).sql).toContain('1 = 0');
expect(scope({ status: { $nin: [] } }).sql).toContain('1 = 1');
});

it('an empty `$nin` is REFUSED — its constant is TRUE, which vacates the scope (#13571)', () => {
// Deliberately ASYMMETRIC with the `$in` case above, and not an
// oversight: the #5322 boundary is "shape errors throw, boolean
// identities reduce", and `$nin: []` sits on the THROW side because its
// faithful reduction is constant TRUE — a read scope silently widened to
// every row, the exact thing the module header forbids. No in-repo
// producer can emit the shape (the CEL lowering never emits `$nin`;
// #13570's guard drops even-polarity empty-`$nin` policies), so the
// refusal costs no live traffic. See read-scope-sql.ts's #13571 section.
const err = refusalOf(() => scope({ status: { $nin: [] } }));
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(err.status).toBe(500);
expect(err.message).toContain('$nin for "status" is empty');
});
});
});
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13571] The empty-`$nin` compile refusal, exercised on the path that made it
* matter — a NON-RLS `getReadScope` provider — plus the over-denial control
* that bounds the fix.
*
* ## Why a non-RLS provider, and not an RLS regression
*
* In-repo, the only scope producer is the RLS compiler, and since PR #13570 its
* polarity-aware guard drops every emptied-membership shape at widening
* polarity before it is emitted — so an RLS-path regression for `$nin: []`
* would exercise a route that cannot reach this compiler at all and would test
* nothing. But `StrategyContext.getReadScope` is a spec contract
* (`packages/spec/src/contracts/analytics-service.ts` — its doc carries a
* hand-written example), fillable by ANY provider. The provider below is that
* contract filled by hand, which is exactly the surface the #13571 card named.
*
* ## The two controls
*
* 1. **Refusal (the fix).** A provider handing `{ owner: { $nin: [] } }` used
* to get a scope clause of constant TRUE (`1 = 1`) — on the read-scope
* lowering, the WHOLE TABLE, an ADR-0021 over-reach. It now gets the
* module's one refusal envelope: `READ_SCOPE_COMPILE_FAILED` / 500.
* MEASURED pre-fix (this file run against the pre-#13571 compiler, see the
* PR): the same case admitted every fixture row.
*
* 2. **Over-denial (the bound — the reason #13571 is NOT a uniform throw).**
* The RLS compiler deliberately emits an emptied POSITIVE membership inside
* a composite — `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }`,
* pinned by #13570's `rls-empty-membership-polarity.test.ts` as "own rows
* keep flowing" — and that filter reaches this compiler through
* `security.getReadFilter`. The refusal must NOT catch it: the scope still
* compiles and still admits exactly the own row. A uniform throw at both
* arms fails this block, which is the availability regression the #13571
* verdict rejected.
*
* `$in: []` under `$not` from a non-RLS provider (constant TRUE via inversion)
* is the verdict's DECLARED residue, deliberately not asserted here either way
* as a contract — `read-scope-not-null-safe.test.ts` pins its current
* behaviour next to the ruling's reasoning.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Cube } from '@objectstack/spec/data';
import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts';

import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js';

const FIXTURE = [
{ id: 'r1', owner: 'u_me' },
{ id: 'r2', owner: 'u_other' },
{ id: 'r3', owner: null },
];

const CUBE: Cube = {
name: 'deals',
title: 'Deals',
sql: 'deal',
measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } },
dimensions: Object.fromEntries(
['id', 'owner'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]),
),
public: false,
} as unknown as Cube;

/** Point sql.js at the `.wasm` shipped inside its own package (Node-safe). */
async function locateWasm(): Promise<((file: string) => string) | undefined> {
try {
const { createRequire } = await import('node:module');
const require = createRequire(import.meta.url);
const pkgJsonPath = require.resolve('sql.js/package.json');
const { dirname, join } = await import('node:path');
const dir = dirname(pkgJsonPath);
return (file: string) => join(dir, 'dist', file);
} catch {
return undefined;
}
}

describe('[#13571] empty `$nin` on the read-scope lowering — non-RLS provider control', () => {
let db: any;

beforeAll(async () => {
const mod: any = await import('sql.js');
const initSqlJs = mod.default ?? mod;
const locateFile = await locateWasm();
const SQL = await initSqlJs(locateFile ? { locateFile } : undefined);

db = new SQL.Database();
db.run(`CREATE TABLE "deal" ("id" TEXT PRIMARY KEY, "owner" TEXT);`);
const insert = db.prepare(`INSERT INTO "deal" ("id","owner") VALUES (?,?)`);
for (const r of FIXTURE) insert.run([r.id, r.owner]);
insert.free();
});

afterAll(() => {
db?.close();
});

/**
* A `StrategyContext` whose `getReadScope` is filled BY HAND — the spec
* contract's own authoring mode, and deliberately not the RLS compiler.
*/
const ctxWithScope = (scope: unknown): StrategyContext =>
({
getCube: (name: string) => (name === 'deals' ? CUBE : undefined),
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
getReadScope: () => scope,
executeRawSql: async (_object: string, sql: string, params: unknown[]) => {
const stmt = db.prepare(sql.replace(/\$\d+/g, '?'));
stmt.bind(params as any[]);
const out: Record<string, unknown>[] = [];
while (stmt.step()) out.push(stmt.getAsObject());
stmt.free();
return out;
},
}) as StrategyContext;

const QUERY: AnalyticsQuery = {
cube: 'deals',
measures: ['total'],
dimensions: ['id'],
timezone: 'UTC',
} as AnalyticsQuery;

/** Run the query under `scope`; return either the refusal or the admitted ids. */
const outcome = async (
scope: unknown,
): Promise<{ refusal?: Error & { code?: unknown; status?: unknown }; admitted?: string[] }> => {
try {
const result = await new NativeSQLStrategy().execute(QUERY, ctxWithScope(scope));
return { admitted: result.rows.map((r) => String(r.id)).sort((x, y) => x.localeCompare(y)) };
} catch (e) {
return { refusal: e as Error & { code?: unknown; status?: unknown } };
}
};

it('a provider handing `{ owner: { $nin: [] } }` is REFUSED in the module envelope — it used to get the whole table', async () => {
const { refusal, admitted } = await outcome({ owner: { $nin: [] } });
// Pre-#13571 this assertion's diff read `admitted: ['r1','r2','r3']` — the
// whole fixture, from a scope clause of constant TRUE.
expect(admitted).toBeUndefined();
expect(refusal).toBeInstanceOf(Error);
expect(refusal?.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(refusal?.status).toBe(500);
expect(String(refusal?.message)).toContain('$nin for "owner" is empty');
});

it('OVER-DENIAL CONTROL: the #13570-pinned RLS composite still compiles and still admits exactly the own row', async () => {
// `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }` is what
// `RLSCompiler.compileFilter` returns for an emptied membership set beside
// an own-rows grant ("own rows keep flowing"), and it arrives here through
// `security.getReadFilter`. The empty-`$nin` refusal must not touch it:
// this block red under a uniform empty-membership throw is the
// availability regression the #13571 verdict exists to avoid.
const { refusal, admitted } = await outcome({ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(refusal).toBeUndefined();
expect(admitted).toEqual(['r1']);
});

it('the composite denies-by-itself shape stays working too: a lone empty `$in` arm inside `$and`', async () => {
// The other composite #13570's guard deliberately passes through: as an
// `$and` arm the emptied positive membership is constant FALSE — the whole
// scope denies. Zero rows, not a refusal and not the whole table.
const { refusal, admitted } = await outcome({ $and: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(refusal).toBeUndefined();
expect(admitted).toEqual([]);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -347,9 +347,29 @@ describe('[#5297] read-scope `$not` — boolean identities and NULL safety', ()
expect(ids({ $not: { stage: { $ne: null } } })).toEqual(['3', '4']);
});

it('an empty `$in` / `$nin` under a `$not` keeps its constant value', () => {
it('an empty `$in` under a `$not` keeps its constant value; an empty `$nin` refuses before `$not` matters (#13571)', () => {
// `$in: []` keeps its #5322/#5243 reduction — constant FALSE, total, no
// guard — and the negation flips it to TRUE: every row. That widened
// composition is the #13571 verdict's DECLARED residue for a non-RLS
// producer (the in-repo RLS compiler cannot emit the shape — #13570's
// polarity guard drops it upstream); closing it is a ruled follow-up
// design, not an edit to this pin.
expect(ids({ $not: { stage: { $in: [] } } })).toEqual(ALL);
expect(ids({ $not: { stage: { $nin: [] } } })).toEqual([]);
// `$nin: []` no longer HAS a constant to keep: its reduction is TRUE —
// scope-vacating on its own — so `compileOperator` refuses it whatever
// the polarity above it. Deliberately asymmetric with the `$in` line
// above ("shape errors throw, boolean identities reduce" is the #5322
// boundary, and a scope-vacating reduction is on the THROW side) — see
// read-scope-sql.ts's #13571 header section.
let refusal: (Error & { code?: unknown; status?: unknown }) | undefined;
try {
ids({ $not: { stage: { $nin: [] } } });
} catch (e) {
refusal = e as Error & { code?: unknown; status?: unknown };
}
expect(refusal?.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(refusal?.status).toBe(500);
expect(String(refusal?.message)).toContain('$nin for "stage" is empty');
});
});

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -93,7 +93,7 @@ function refusalFor(filter: unknown, alias = 'crm_opportunity'): Refusal | undef
/**
* Every refusing site in `read-scope-sql.ts`, in source order.
*
* FOURTEEN rows over TWELVE throw sites: TWO sites are each reached by two
* FIFTEEN rows over THIRTEEN throw sites: TWO sites are each reached by two
* triggers, and every trigger is listed on purpose.
*
* - `quoteIdent`, with two `kind` values. That alias-vs-field split was option
Expand DownExpand Up@@ -208,14 +208,26 @@ const REFUSALS: Array<{
sensitive: 'region_code',
},
{
name: '⑬ $between without [min,max]',
// [#13571] The empty EXCLUSION refuses; the empty INCLUSION keeps its
// constant — see the ACCEPTED table's "#5243" row. Deliberate asymmetry
// ("shape errors throw, boolean identities reduce" — #5322; a reduction to
// constant TRUE vacates the scope, so it is on the throw side), not an
// oversight: read-scope-sql.ts's #13571 header section carries the ruling.
name: '⑬ $nin with an EMPTY array',
site: 'compileOperator: empty $nin vacates the scope',
filter: { region_code: { $nin: [] } },
message: /\$nin for "region_code" is empty — an empty exclusion excludes nothing and would compile the read scope to constant TRUE \(fail-closed\)/,
sensitive: 'region_code',
},
{
name: '⑭ $between without [min,max]',
site: 'compileOperator: $between needs [min,max]',
filter: { credit_limit: { $between: [10] } },
message: /\$between for "credit_limit" needs \[min,max\] \(fail-closed\)/,
sensitive: 'credit_limit',
},
{
name: ' unsupported operator',
name: ' unsupported operator',
site: 'compileOperator: unsupported operator',
filter: { owner_email: { $regex: 'admin@' } },
message: /unsupported operator "\$regex" on "owner_email" \(fail-closed\)/,
Expand DownExpand Up@@ -244,6 +256,11 @@ const ACCEPTED: Array<{ name: string; filter: unknown; sql: string; params: unkn
params: ['emea', 'apac'],
},
{
// [#13571] STAYS accepted while the empty `$nin` refuses (REFUSALS ⑬):
// this constant is FALSE — narrowing at its own arm — and the RLS compiler
// deliberately emits the shape at positive polarity inside composites
// (#13570's "own rows keep flowing" pin), so refusing it here would 500 a
// live, ruled-correct scope. The asymmetry is the #13571 ruling itself.
name: 'an empty $in as the FALSE constant (#5243)',
filter: { region_code: { $in: [] } },
sql: '1 = 0',
Expand DownExpand Up@@ -306,13 +323,14 @@ describe('[#5367] every read-scope refusal carries the ADR-0112 envelope (READ_S
// #5352's lesson, stated as a guard: seven of `filter-normalizer.ts`'s nine
// sites carrying an envelope was indistinguishable from none of them at the
// HTTP boundary, because the commonest input hit one of the two bare ones.
// Fourteen inputs over the module's TWELVE throw sites (see the table's note
// on the two sites with two triggers each), and every one of them enveloped.
// [#6125] added the eleventh site, [#6387] the twelfth; these two numbers
// Fifteen inputs over the module's THIRTEEN throw sites (see the table's
// note on the two sites with two triggers each), and every one of them
// enveloped. [#6125] added the eleventh site, [#6387] the twelfth, and
// [#13571] the thirteenth (the empty-`$nin` refusal); these two numbers
// are the ratchet that makes a future unenveloped `throw` fail HERE instead
// of at an HTTP boundary.
expect(REFUSALS).toHaveLength(14);
expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(12);
expect(REFUSALS).toHaveLength(15);
expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(13);
for (const c of REFUSALS) {
expect(refusalFor(c.filter, c.alias)?.code, `${c.site} is still bare`).toBe('READ_SCOPE_COMPILE_FAILED');
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(service-analytics): refuse an empty $nin on the read-scope lowering instead of folding it to constant TRUE by os-steve · Pull Request #13649 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/read-scope-empty-nin-refusal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/service-analytics': patch
---

The analytics read-scope compiler (`read-scope-sql.ts`) now refuses an empty `$nin` (`READ_SCOPE_COMPILE_FAILED` / 500) instead of folding it to constant TRUE. An emptied exclusion ("NOT IN () excludes nothing") vacated the whole read scope — every row admitted — on the ADR-0021 lowering, where a widening is scope over-reach; no in-repo producer can emit the shape (the CEL lowering never emits `$nin`, and the RLS guard drops even-polarity empty-`$nin` policies upstream), so the refusal costs no live traffic. Deliberately asymmetric: `$in: []` keeps its ruled constant-FALSE fold (#5322/#5243), which the RLS compiler's inert positive composite — an emptied membership `$or`-ed beside an own-rows grant — depends on.
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,11 +266,30 @@ describe('[#5234] the read-scope lowering refuses the same two shapes, fail-clos
expect(scope({ name: { $startsWith: '_admin' } }).params).toEqual(['\\_admin%', '\\']);
});

it('an empty `$in` / `$nin` still lowers to its boolean constant, not a refusal', () => {
it('an empty `$in` still lowers to its FALSE constant, not a refusal', () => {
// The member scan runs AFTER the arity identities (#5134), so the empty
// list keeps compiling to `1 = 0` / `1 = 1` rather than becoming an error.
// INCLUSION keeps compiling to `1 = 0` rather than becoming an error. It
// KEEPS its #5322/#5243 reduction because that constant is FALSE —
// narrowing at this arm — and because a live producer depends on it: the
// RLS compiler deliberately emits `$in: []` at positive polarity inside
// composites (#13570's "own rows keep flowing" pin), and that filter
// reaches this compiler through `security.getReadFilter`.
expect(scope({ status: { $in: [] } }).sql).toContain('1 = 0');
expect(scope({ status: { $nin: [] } }).sql).toContain('1 = 1');
});

it('an empty `$nin` is REFUSED — its constant is TRUE, which vacates the scope (#13571)', () => {
// Deliberately ASYMMETRIC with the `$in` case above, and not an
// oversight: the #5322 boundary is "shape errors throw, boolean
// identities reduce", and `$nin: []` sits on the THROW side because its
// faithful reduction is constant TRUE — a read scope silently widened to
// every row, the exact thing the module header forbids. No in-repo
// producer can emit the shape (the CEL lowering never emits `$nin`;
// #13570's guard drops even-polarity empty-`$nin` policies), so the
// refusal costs no live traffic. See read-scope-sql.ts's #13571 section.
const err = refusalOf(() => scope({ status: { $nin: [] } }));
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(err.status).toBe(500);
expect(err.message).toContain('$nin for "status" is empty');
});
});
});
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13571] The empty-`$nin` compile refusal, exercised on the path that made it
* matter — a NON-RLS `getReadScope` provider — plus the over-denial control
* that bounds the fix.
*
* ## Why a non-RLS provider, and not an RLS regression
*
* In-repo, the only scope producer is the RLS compiler, and since PR #13570 its
* polarity-aware guard drops every emptied-membership shape at widening
* polarity before it is emitted — so an RLS-path regression for `$nin: []`
* would exercise a route that cannot reach this compiler at all and would test
* nothing. But `StrategyContext.getReadScope` is a spec contract
* (`packages/spec/src/contracts/analytics-service.ts` — its doc carries a
* hand-written example), fillable by ANY provider. The provider below is that
* contract filled by hand, which is exactly the surface the #13571 card named.
*
* ## The two controls
*
* 1. **Refusal (the fix).** A provider handing `{ owner: { $nin: [] } }` used
* to get a scope clause of constant TRUE (`1 = 1`) — on the read-scope
* lowering, the WHOLE TABLE, an ADR-0021 over-reach. It now gets the
* module's one refusal envelope: `READ_SCOPE_COMPILE_FAILED` / 500.
* MEASURED pre-fix (this file run against the pre-#13571 compiler, see the
* PR): the same case admitted every fixture row.
*
* 2. **Over-denial (the bound — the reason #13571 is NOT a uniform throw).**
* The RLS compiler deliberately emits an emptied POSITIVE membership inside
* a composite — `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }`,
* pinned by #13570's `rls-empty-membership-polarity.test.ts` as "own rows
* keep flowing" — and that filter reaches this compiler through
* `security.getReadFilter`. The refusal must NOT catch it: the scope still
* compiles and still admits exactly the own row. A uniform throw at both
* arms fails this block, which is the availability regression the #13571
* verdict rejected.
*
* `$in: []` under `$not` from a non-RLS provider (constant TRUE via inversion)
* is the verdict's DECLARED residue, deliberately not asserted here either way
* as a contract — `read-scope-not-null-safe.test.ts` pins its current
* behaviour next to the ruling's reasoning.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Cube } from '@objectstack/spec/data';
import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts';

import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js';

const FIXTURE = [
{ id: 'r1', owner: 'u_me' },
{ id: 'r2', owner: 'u_other' },
{ id: 'r3', owner: null },
];

const CUBE: Cube = {
name: 'deals',
title: 'Deals',
sql: 'deal',
measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } },
dimensions: Object.fromEntries(
['id', 'owner'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]),
),
public: false,
} as unknown as Cube;

/** Point sql.js at the `.wasm` shipped inside its own package (Node-safe). */
async function locateWasm(): Promise<((file: string) => string) | undefined> {
try {
const { createRequire } = await import('node:module');
const require = createRequire(import.meta.url);
const pkgJsonPath = require.resolve('sql.js/package.json');
const { dirname, join } = await import('node:path');
const dir = dirname(pkgJsonPath);
return (file: string) => join(dir, 'dist', file);
} catch {
return undefined;
}
}

describe('[#13571] empty `$nin` on the read-scope lowering — non-RLS provider control', () => {
let db: any;

beforeAll(async () => {
const mod: any = await import('sql.js');
const initSqlJs = mod.default ?? mod;
const locateFile = await locateWasm();
const SQL = await initSqlJs(locateFile ? { locateFile } : undefined);

db = new SQL.Database();
db.run(`CREATE TABLE "deal" ("id" TEXT PRIMARY KEY, "owner" TEXT);`);
const insert = db.prepare(`INSERT INTO "deal" ("id","owner") VALUES (?,?)`);
for (const r of FIXTURE) insert.run([r.id, r.owner]);
insert.free();
});

afterAll(() => {
db?.close();
});

/**
* A `StrategyContext` whose `getReadScope` is filled BY HAND — the spec
* contract's own authoring mode, and deliberately not the RLS compiler.
*/
const ctxWithScope = (scope: unknown): StrategyContext =>
({
getCube: (name: string) => (name === 'deals' ? CUBE : undefined),
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
getReadScope: () => scope,
executeRawSql: async (_object: string, sql: string, params: unknown[]) => {
const stmt = db.prepare(sql.replace(/\$\d+/g, '?'));
stmt.bind(params as any[]);
const out: Record<string, unknown>[] = [];
while (stmt.step()) out.push(stmt.getAsObject());
stmt.free();
return out;
},
}) as StrategyContext;

const QUERY: AnalyticsQuery = {
cube: 'deals',
measures: ['total'],
dimensions: ['id'],
timezone: 'UTC',
} as AnalyticsQuery;

/** Run the query under `scope`; return either the refusal or the admitted ids. */
const outcome = async (
scope: unknown,
): Promise<{ refusal?: Error & { code?: unknown; status?: unknown }; admitted?: string[] }> => {
try {
const result = await new NativeSQLStrategy().execute(QUERY, ctxWithScope(scope));
return { admitted: result.rows.map((r) => String(r.id)).sort((x, y) => x.localeCompare(y)) };
} catch (e) {
return { refusal: e as Error & { code?: unknown; status?: unknown } };
}
};

it('a provider handing `{ owner: { $nin: [] } }` is REFUSED in the module envelope — it used to get the whole table', async () => {
const { refusal, admitted } = await outcome({ owner: { $nin: [] } });
// Pre-#13571 this assertion's diff read `admitted: ['r1','r2','r3']` — the
// whole fixture, from a scope clause of constant TRUE.
expect(admitted).toBeUndefined();
expect(refusal).toBeInstanceOf(Error);
expect(refusal?.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(refusal?.status).toBe(500);
expect(String(refusal?.message)).toContain('$nin for "owner" is empty');
});

it('OVER-DENIAL CONTROL: the #13570-pinned RLS composite still compiles and still admits exactly the own row', async () => {
// `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }` is what
// `RLSCompiler.compileFilter` returns for an emptied membership set beside
// an own-rows grant ("own rows keep flowing"), and it arrives here through
// `security.getReadFilter`. The empty-`$nin` refusal must not touch it:
// this block red under a uniform empty-membership throw is the
// availability regression the #13571 verdict exists to avoid.
const { refusal, admitted } = await outcome({ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(refusal).toBeUndefined();
expect(admitted).toEqual(['r1']);
});

it('the composite denies-by-itself shape stays working too: a lone empty `$in` arm inside `$and`', async () => {
// The other composite #13570's guard deliberately passes through: as an
// `$and` arm the emptied positive membership is constant FALSE — the whole
// scope denies. Zero rows, not a refusal and not the whole table.
const { refusal, admitted } = await outcome({ $and: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(refusal).toBeUndefined();
expect(admitted).toEqual([]);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -347,9 +347,29 @@ describe('[#5297] read-scope `$not` — boolean identities and NULL safety', ()
expect(ids({ $not: { stage: { $ne: null } } })).toEqual(['3', '4']);
});

it('an empty `$in` / `$nin` under a `$not` keeps its constant value', () => {
it('an empty `$in` under a `$not` keeps its constant value; an empty `$nin` refuses before `$not` matters (#13571)', () => {
// `$in: []` keeps its #5322/#5243 reduction — constant FALSE, total, no
// guard — and the negation flips it to TRUE: every row. That widened
// composition is the #13571 verdict's DECLARED residue for a non-RLS
// producer (the in-repo RLS compiler cannot emit the shape — #13570's
// polarity guard drops it upstream); closing it is a ruled follow-up
// design, not an edit to this pin.
expect(ids({ $not: { stage: { $in: [] } } })).toEqual(ALL);
expect(ids({ $not: { stage: { $nin: [] } } })).toEqual([]);
// `$nin: []` no longer HAS a constant to keep: its reduction is TRUE —
// scope-vacating on its own — so `compileOperator` refuses it whatever
// the polarity above it. Deliberately asymmetric with the `$in` line
// above ("shape errors throw, boolean identities reduce" is the #5322
// boundary, and a scope-vacating reduction is on the THROW side) — see
// read-scope-sql.ts's #13571 header section.
let refusal: (Error & { code?: unknown; status?: unknown }) | undefined;
try {
ids({ $not: { stage: { $nin: [] } } });
} catch (e) {
refusal = e as Error & { code?: unknown; status?: unknown };
}
expect(refusal?.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(refusal?.status).toBe(500);
expect(String(refusal?.message)).toContain('$nin for "stage" is empty');
});
});

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -93,7 +93,7 @@ function refusalFor(filter: unknown, alias = 'crm_opportunity'): Refusal | undef
/**
* Every refusing site in `read-scope-sql.ts`, in source order.
*
* FOURTEEN rows over TWELVE throw sites: TWO sites are each reached by two
* FIFTEEN rows over THIRTEEN throw sites: TWO sites are each reached by two
* triggers, and every trigger is listed on purpose.
*
* - `quoteIdent`, with two `kind` values. That alias-vs-field split was option
Expand DownExpand Up@@ -208,14 +208,26 @@ const REFUSALS: Array<{
sensitive: 'region_code',
},
{
name: '⑬ $between without [min,max]',
// [#13571] The empty EXCLUSION refuses; the empty INCLUSION keeps its
// constant — see the ACCEPTED table's "#5243" row. Deliberate asymmetry
// ("shape errors throw, boolean identities reduce" — #5322; a reduction to
// constant TRUE vacates the scope, so it is on the throw side), not an
// oversight: read-scope-sql.ts's #13571 header section carries the ruling.
name: '⑬ $nin with an EMPTY array',
site: 'compileOperator: empty $nin vacates the scope',
filter: { region_code: { $nin: [] } },
message: /\$nin for "region_code" is empty — an empty exclusion excludes nothing and would compile the read scope to constant TRUE \(fail-closed\)/,
sensitive: 'region_code',
},
{
name: '⑭ $between without [min,max]',
site: 'compileOperator: $between needs [min,max]',
filter: { credit_limit: { $between: [10] } },
message: /\$between for "credit_limit" needs \[min,max\] \(fail-closed\)/,
sensitive: 'credit_limit',
},
{
name: ' unsupported operator',
name: ' unsupported operator',
site: 'compileOperator: unsupported operator',
filter: { owner_email: { $regex: 'admin@' } },
message: /unsupported operator "\$regex" on "owner_email" \(fail-closed\)/,
Expand DownExpand Up@@ -244,6 +256,11 @@ const ACCEPTED: Array<{ name: string; filter: unknown; sql: string; params: unkn
params: ['emea', 'apac'],
},
{
// [#13571] STAYS accepted while the empty `$nin` refuses (REFUSALS ⑬):
// this constant is FALSE — narrowing at its own arm — and the RLS compiler
// deliberately emits the shape at positive polarity inside composites
// (#13570's "own rows keep flowing" pin), so refusing it here would 500 a
// live, ruled-correct scope. The asymmetry is the #13571 ruling itself.
name: 'an empty $in as the FALSE constant (#5243)',
filter: { region_code: { $in: [] } },
sql: '1 = 0',
Expand DownExpand Up@@ -306,13 +323,14 @@ describe('[#5367] every read-scope refusal carries the ADR-0112 envelope (READ_S
// #5352's lesson, stated as a guard: seven of `filter-normalizer.ts`'s nine
// sites carrying an envelope was indistinguishable from none of them at the
// HTTP boundary, because the commonest input hit one of the two bare ones.
// Fourteen inputs over the module's TWELVE throw sites (see the table's note
// on the two sites with two triggers each), and every one of them enveloped.
// [#6125] added the eleventh site, [#6387] the twelfth; these two numbers
// Fifteen inputs over the module's THIRTEEN throw sites (see the table's
// note on the two sites with two triggers each), and every one of them
// enveloped. [#6125] added the eleventh site, [#6387] the twelfth, and
// [#13571] the thirteenth (the empty-`$nin` refusal); these two numbers
// are the ratchet that makes a future unenveloped `throw` fail HERE instead
// of at an HTTP boundary.
expect(REFUSALS).toHaveLength(14);
expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(12);
expect(REFUSALS).toHaveLength(15);
expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(13);
for (const c of REFUSALS) {
expect(refusalFor(c.filter, c.alias)?.code, `${c.site} is still bare`).toBe('READ_SCOPE_COMPILE_FAILED');
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(service-analytics): refuse an empty $nin on the read-scope lowering instead of folding it to constant TRUE by os-steve · Pull Request #13649 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/read-scope-empty-nin-refusal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/service-analytics': patch
---

The analytics read-scope compiler (`read-scope-sql.ts`) now refuses an empty `$nin` (`READ_SCOPE_COMPILE_FAILED` / 500) instead of folding it to constant TRUE. An emptied exclusion ("NOT IN () excludes nothing") vacated the whole read scope — every row admitted — on the ADR-0021 lowering, where a widening is scope over-reach; no in-repo producer can emit the shape (the CEL lowering never emits `$nin`, and the RLS guard drops even-polarity empty-`$nin` policies upstream), so the refusal costs no live traffic. Deliberately asymmetric: `$in: []` keeps its ruled constant-FALSE fold (#5322/#5243), which the RLS compiler's inert positive composite — an emptied membership `$or`-ed beside an own-rows grant — depends on.
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,11 +266,30 @@ describe('[#5234] the read-scope lowering refuses the same two shapes, fail-clos
expect(scope({ name: { $startsWith: '_admin' } }).params).toEqual(['\\_admin%', '\\']);
});

it('an empty `$in` / `$nin` still lowers to its boolean constant, not a refusal', () => {
it('an empty `$in` still lowers to its FALSE constant, not a refusal', () => {
// The member scan runs AFTER the arity identities (#5134), so the empty
// list keeps compiling to `1 = 0` / `1 = 1` rather than becoming an error.
// INCLUSION keeps compiling to `1 = 0` rather than becoming an error. It
// KEEPS its #5322/#5243 reduction because that constant is FALSE —
// narrowing at this arm — and because a live producer depends on it: the
// RLS compiler deliberately emits `$in: []` at positive polarity inside
// composites (#13570's "own rows keep flowing" pin), and that filter
// reaches this compiler through `security.getReadFilter`.
expect(scope({ status: { $in: [] } }).sql).toContain('1 = 0');
expect(scope({ status: { $nin: [] } }).sql).toContain('1 = 1');
});

it('an empty `$nin` is REFUSED — its constant is TRUE, which vacates the scope (#13571)', () => {
// Deliberately ASYMMETRIC with the `$in` case above, and not an
// oversight: the #5322 boundary is "shape errors throw, boolean
// identities reduce", and `$nin: []` sits on the THROW side because its
// faithful reduction is constant TRUE — a read scope silently widened to
// every row, the exact thing the module header forbids. No in-repo
// producer can emit the shape (the CEL lowering never emits `$nin`;
// #13570's guard drops even-polarity empty-`$nin` policies), so the
// refusal costs no live traffic. See read-scope-sql.ts's #13571 section.
const err = refusalOf(() => scope({ status: { $nin: [] } }));
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(err.status).toBe(500);
expect(err.message).toContain('$nin for "status" is empty');
});
});
});
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13571] The empty-`$nin` compile refusal, exercised on the path that made it
* matter — a NON-RLS `getReadScope` provider — plus the over-denial control
* that bounds the fix.
*
* ## Why a non-RLS provider, and not an RLS regression
*
* In-repo, the only scope producer is the RLS compiler, and since PR #13570 its
* polarity-aware guard drops every emptied-membership shape at widening
* polarity before it is emitted — so an RLS-path regression for `$nin: []`
* would exercise a route that cannot reach this compiler at all and would test
* nothing. But `StrategyContext.getReadScope` is a spec contract
* (`packages/spec/src/contracts/analytics-service.ts` — its doc carries a
* hand-written example), fillable by ANY provider. The provider below is that
* contract filled by hand, which is exactly the surface the #13571 card named.
*
* ## The two controls
*
* 1. **Refusal (the fix).** A provider handing `{ owner: { $nin: [] } }` used
* to get a scope clause of constant TRUE (`1 = 1`) — on the read-scope
* lowering, the WHOLE TABLE, an ADR-0021 over-reach. It now gets the
* module's one refusal envelope: `READ_SCOPE_COMPILE_FAILED` / 500.
* MEASURED pre-fix (this file run against the pre-#13571 compiler, see the
* PR): the same case admitted every fixture row.
*
* 2. **Over-denial (the bound — the reason #13571 is NOT a uniform throw).**
* The RLS compiler deliberately emits an emptied POSITIVE membership inside
* a composite — `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }`,
* pinned by #13570's `rls-empty-membership-polarity.test.ts` as "own rows
* keep flowing" — and that filter reaches this compiler through
* `security.getReadFilter`. The refusal must NOT catch it: the scope still
* compiles and still admits exactly the own row. A uniform throw at both
* arms fails this block, which is the availability regression the #13571
* verdict rejected.
*
* `$in: []` under `$not` from a non-RLS provider (constant TRUE via inversion)
* is the verdict's DECLARED residue, deliberately not asserted here either way
* as a contract — `read-scope-not-null-safe.test.ts` pins its current
* behaviour next to the ruling's reasoning.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Cube } from '@objectstack/spec/data';
import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts';

import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js';

const FIXTURE = [
{ id: 'r1', owner: 'u_me' },
{ id: 'r2', owner: 'u_other' },
{ id: 'r3', owner: null },
];

const CUBE: Cube = {
name: 'deals',
title: 'Deals',
sql: 'deal',
measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } },
dimensions: Object.fromEntries(
['id', 'owner'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]),
),
public: false,
} as unknown as Cube;

/** Point sql.js at the `.wasm` shipped inside its own package (Node-safe). */
async function locateWasm(): Promise<((file: string) => string) | undefined> {
try {
const { createRequire } = await import('node:module');
const require = createRequire(import.meta.url);
const pkgJsonPath = require.resolve('sql.js/package.json');
const { dirname, join } = await import('node:path');
const dir = dirname(pkgJsonPath);
return (file: string) => join(dir, 'dist', file);
} catch {
return undefined;
}
}

describe('[#13571] empty `$nin` on the read-scope lowering — non-RLS provider control', () => {
let db: any;

beforeAll(async () => {
const mod: any = await import('sql.js');
const initSqlJs = mod.default ?? mod;
const locateFile = await locateWasm();
const SQL = await initSqlJs(locateFile ? { locateFile } : undefined);

db = new SQL.Database();
db.run(`CREATE TABLE "deal" ("id" TEXT PRIMARY KEY, "owner" TEXT);`);
const insert = db.prepare(`INSERT INTO "deal" ("id","owner") VALUES (?,?)`);
for (const r of FIXTURE) insert.run([r.id, r.owner]);
insert.free();
});

afterAll(() => {
db?.close();
});

/**
* A `StrategyContext` whose `getReadScope` is filled BY HAND — the spec
* contract's own authoring mode, and deliberately not the RLS compiler.
*/
const ctxWithScope = (scope: unknown): StrategyContext =>
({
getCube: (name: string) => (name === 'deals' ? CUBE : undefined),
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
getReadScope: () => scope,
executeRawSql: async (_object: string, sql: string, params: unknown[]) => {
const stmt = db.prepare(sql.replace(/\$\d+/g, '?'));
stmt.bind(params as any[]);
const out: Record<string, unknown>[] = [];
while (stmt.step()) out.push(stmt.getAsObject());
stmt.free();
return out;
},
}) as StrategyContext;

const QUERY: AnalyticsQuery = {
cube: 'deals',
measures: ['total'],
dimensions: ['id'],
timezone: 'UTC',
} as AnalyticsQuery;

/** Run the query under `scope`; return either the refusal or the admitted ids. */
const outcome = async (
scope: unknown,
): Promise<{ refusal?: Error & { code?: unknown; status?: unknown }; admitted?: string[] }> => {
try {
const result = await new NativeSQLStrategy().execute(QUERY, ctxWithScope(scope));
return { admitted: result.rows.map((r) => String(r.id)).sort((x, y) => x.localeCompare(y)) };
} catch (e) {
return { refusal: e as Error & { code?: unknown; status?: unknown } };
}
};

it('a provider handing `{ owner: { $nin: [] } }` is REFUSED in the module envelope — it used to get the whole table', async () => {
const { refusal, admitted } = await outcome({ owner: { $nin: [] } });
// Pre-#13571 this assertion's diff read `admitted: ['r1','r2','r3']` — the
// whole fixture, from a scope clause of constant TRUE.
expect(admitted).toBeUndefined();
expect(refusal).toBeInstanceOf(Error);
expect(refusal?.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(refusal?.status).toBe(500);
expect(String(refusal?.message)).toContain('$nin for "owner" is empty');
});

it('OVER-DENIAL CONTROL: the #13570-pinned RLS composite still compiles and still admits exactly the own row', async () => {
// `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }` is what
// `RLSCompiler.compileFilter` returns for an emptied membership set beside
// an own-rows grant ("own rows keep flowing"), and it arrives here through
// `security.getReadFilter`. The empty-`$nin` refusal must not touch it:
// this block red under a uniform empty-membership throw is the
// availability regression the #13571 verdict exists to avoid.
const { refusal, admitted } = await outcome({ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(refusal).toBeUndefined();
expect(admitted).toEqual(['r1']);
});

it('the composite denies-by-itself shape stays working too: a lone empty `$in` arm inside `$and`', async () => {
// The other composite #13570's guard deliberately passes through: as an
// `$and` arm the emptied positive membership is constant FALSE — the whole
// scope denies. Zero rows, not a refusal and not the whole table.
const { refusal, admitted } = await outcome({ $and: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(refusal).toBeUndefined();
expect(admitted).toEqual([]);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -347,9 +347,29 @@ describe('[#5297] read-scope `$not` — boolean identities and NULL safety', ()
expect(ids({ $not: { stage: { $ne: null } } })).toEqual(['3', '4']);
});

it('an empty `$in` / `$nin` under a `$not` keeps its constant value', () => {
it('an empty `$in` under a `$not` keeps its constant value; an empty `$nin` refuses before `$not` matters (#13571)', () => {
// `$in: []` keeps its #5322/#5243 reduction — constant FALSE, total, no
// guard — and the negation flips it to TRUE: every row. That widened
// composition is the #13571 verdict's DECLARED residue for a non-RLS
// producer (the in-repo RLS compiler cannot emit the shape — #13570's
// polarity guard drops it upstream); closing it is a ruled follow-up
// design, not an edit to this pin.
expect(ids({ $not: { stage: { $in: [] } } })).toEqual(ALL);
expect(ids({ $not: { stage: { $nin: [] } } })).toEqual([]);
// `$nin: []` no longer HAS a constant to keep: its reduction is TRUE —
// scope-vacating on its own — so `compileOperator` refuses it whatever
// the polarity above it. Deliberately asymmetric with the `$in` line
// above ("shape errors throw, boolean identities reduce" is the #5322
// boundary, and a scope-vacating reduction is on the THROW side) — see
// read-scope-sql.ts's #13571 header section.
let refusal: (Error & { code?: unknown; status?: unknown }) | undefined;
try {
ids({ $not: { stage: { $nin: [] } } });
} catch (e) {
refusal = e as Error & { code?: unknown; status?: unknown };
}
expect(refusal?.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(refusal?.status).toBe(500);
expect(String(refusal?.message)).toContain('$nin for "stage" is empty');
});
});

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -93,7 +93,7 @@ function refusalFor(filter: unknown, alias = 'crm_opportunity'): Refusal | undef
/**
* Every refusing site in `read-scope-sql.ts`, in source order.
*
* FOURTEEN rows over TWELVE throw sites: TWO sites are each reached by two
* FIFTEEN rows over THIRTEEN throw sites: TWO sites are each reached by two
* triggers, and every trigger is listed on purpose.
*
* - `quoteIdent`, with two `kind` values. That alias-vs-field split was option
Expand DownExpand Up@@ -208,14 +208,26 @@ const REFUSALS: Array<{
sensitive: 'region_code',
},
{
name: '⑬ $between without [min,max]',
// [#13571] The empty EXCLUSION refuses; the empty INCLUSION keeps its
// constant — see the ACCEPTED table's "#5243" row. Deliberate asymmetry
// ("shape errors throw, boolean identities reduce" — #5322; a reduction to
// constant TRUE vacates the scope, so it is on the throw side), not an
// oversight: read-scope-sql.ts's #13571 header section carries the ruling.
name: '⑬ $nin with an EMPTY array',
site: 'compileOperator: empty $nin vacates the scope',
filter: { region_code: { $nin: [] } },
message: /\$nin for "region_code" is empty — an empty exclusion excludes nothing and would compile the read scope to constant TRUE \(fail-closed\)/,
sensitive: 'region_code',
},
{
name: '⑭ $between without [min,max]',
site: 'compileOperator: $between needs [min,max]',
filter: { credit_limit: { $between: [10] } },
message: /\$between for "credit_limit" needs \[min,max\] \(fail-closed\)/,
sensitive: 'credit_limit',
},
{
name: ' unsupported operator',
name: ' unsupported operator',
site: 'compileOperator: unsupported operator',
filter: { owner_email: { $regex: 'admin@' } },
message: /unsupported operator "\$regex" on "owner_email" \(fail-closed\)/,
Expand DownExpand Up@@ -244,6 +256,11 @@ const ACCEPTED: Array<{ name: string; filter: unknown; sql: string; params: unkn
params: ['emea', 'apac'],
},
{
// [#13571] STAYS accepted while the empty `$nin` refuses (REFUSALS ⑬):
// this constant is FALSE — narrowing at its own arm — and the RLS compiler
// deliberately emits the shape at positive polarity inside composites
// (#13570's "own rows keep flowing" pin), so refusing it here would 500 a
// live, ruled-correct scope. The asymmetry is the #13571 ruling itself.
name: 'an empty $in as the FALSE constant (#5243)',
filter: { region_code: { $in: [] } },
sql: '1 = 0',
Expand DownExpand Up@@ -306,13 +323,14 @@ describe('[#5367] every read-scope refusal carries the ADR-0112 envelope (READ_S
// #5352's lesson, stated as a guard: seven of `filter-normalizer.ts`'s nine
// sites carrying an envelope was indistinguishable from none of them at the
// HTTP boundary, because the commonest input hit one of the two bare ones.
// Fourteen inputs over the module's TWELVE throw sites (see the table's note
// on the two sites with two triggers each), and every one of them enveloped.
// [#6125] added the eleventh site, [#6387] the twelfth; these two numbers
// Fifteen inputs over the module's THIRTEEN throw sites (see the table's
// note on the two sites with two triggers each), and every one of them
// enveloped. [#6125] added the eleventh site, [#6387] the twelfth, and
// [#13571] the thirteenth (the empty-`$nin` refusal); these two numbers
// are the ratchet that makes a future unenveloped `throw` fail HERE instead
// of at an HTTP boundary.
expect(REFUSALS).toHaveLength(14);
expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(12);
expect(REFUSALS).toHaveLength(15);
expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(13);
for (const c of REFUSALS) {
expect(refusalFor(c.filter, c.alias)?.code, `${c.site} is still bare`).toBe('READ_SCOPE_COMPILE_FAILED');
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(service-analytics): refuse an empty $nin on the read-scope lowering instead of folding it to constant TRUE by os-steve · Pull Request #13649 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/read-scope-empty-nin-refusal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/service-analytics': patch
---

The analytics read-scope compiler (`read-scope-sql.ts`) now refuses an empty `$nin` (`READ_SCOPE_COMPILE_FAILED` / 500) instead of folding it to constant TRUE. An emptied exclusion ("NOT IN () excludes nothing") vacated the whole read scope — every row admitted — on the ADR-0021 lowering, where a widening is scope over-reach; no in-repo producer can emit the shape (the CEL lowering never emits `$nin`, and the RLS guard drops even-polarity empty-`$nin` policies upstream), so the refusal costs no live traffic. Deliberately asymmetric: `$in: []` keeps its ruled constant-FALSE fold (#5322/#5243), which the RLS compiler's inert positive composite — an emptied membership `$or`-ed beside an own-rows grant — depends on.
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,11 +266,30 @@ describe('[#5234] the read-scope lowering refuses the same two shapes, fail-clos
expect(scope({ name: { $startsWith: '_admin' } }).params).toEqual(['\\_admin%', '\\']);
});

it('an empty `$in` / `$nin` still lowers to its boolean constant, not a refusal', () => {
it('an empty `$in` still lowers to its FALSE constant, not a refusal', () => {
// The member scan runs AFTER the arity identities (#5134), so the empty
// list keeps compiling to `1 = 0` / `1 = 1` rather than becoming an error.
// INCLUSION keeps compiling to `1 = 0` rather than becoming an error. It
// KEEPS its #5322/#5243 reduction because that constant is FALSE —
// narrowing at this arm — and because a live producer depends on it: the
// RLS compiler deliberately emits `$in: []` at positive polarity inside
// composites (#13570's "own rows keep flowing" pin), and that filter
// reaches this compiler through `security.getReadFilter`.
expect(scope({ status: { $in: [] } }).sql).toContain('1 = 0');
expect(scope({ status: { $nin: [] } }).sql).toContain('1 = 1');
});

it('an empty `$nin` is REFUSED — its constant is TRUE, which vacates the scope (#13571)', () => {
// Deliberately ASYMMETRIC with the `$in` case above, and not an
// oversight: the #5322 boundary is "shape errors throw, boolean
// identities reduce", and `$nin: []` sits on the THROW side because its
// faithful reduction is constant TRUE — a read scope silently widened to
// every row, the exact thing the module header forbids. No in-repo
// producer can emit the shape (the CEL lowering never emits `$nin`;
// #13570's guard drops even-polarity empty-`$nin` policies), so the
// refusal costs no live traffic. See read-scope-sql.ts's #13571 section.
const err = refusalOf(() => scope({ status: { $nin: [] } }));
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(err.status).toBe(500);
expect(err.message).toContain('$nin for "status" is empty');
});
});
});
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13571] The empty-`$nin` compile refusal, exercised on the path that made it
* matter — a NON-RLS `getReadScope` provider — plus the over-denial control
* that bounds the fix.
*
* ## Why a non-RLS provider, and not an RLS regression
*
* In-repo, the only scope producer is the RLS compiler, and since PR #13570 its
* polarity-aware guard drops every emptied-membership shape at widening
* polarity before it is emitted — so an RLS-path regression for `$nin: []`
* would exercise a route that cannot reach this compiler at all and would test
* nothing. But `StrategyContext.getReadScope` is a spec contract
* (`packages/spec/src/contracts/analytics-service.ts` — its doc carries a
* hand-written example), fillable by ANY provider. The provider below is that
* contract filled by hand, which is exactly the surface the #13571 card named.
*
* ## The two controls
*
* 1. **Refusal (the fix).** A provider handing `{ owner: { $nin: [] } }` used
* to get a scope clause of constant TRUE (`1 = 1`) — on the read-scope
* lowering, the WHOLE TABLE, an ADR-0021 over-reach. It now gets the
* module's one refusal envelope: `READ_SCOPE_COMPILE_FAILED` / 500.
* MEASURED pre-fix (this file run against the pre-#13571 compiler, see the
* PR): the same case admitted every fixture row.
*
* 2. **Over-denial (the bound — the reason #13571 is NOT a uniform throw).**
* The RLS compiler deliberately emits an emptied POSITIVE membership inside
* a composite — `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }`,
* pinned by #13570's `rls-empty-membership-polarity.test.ts` as "own rows
* keep flowing" — and that filter reaches this compiler through
* `security.getReadFilter`. The refusal must NOT catch it: the scope still
* compiles and still admits exactly the own row. A uniform throw at both
* arms fails this block, which is the availability regression the #13571
* verdict rejected.
*
* `$in: []` under `$not` from a non-RLS provider (constant TRUE via inversion)
* is the verdict's DECLARED residue, deliberately not asserted here either way
* as a contract — `read-scope-not-null-safe.test.ts` pins its current
* behaviour next to the ruling's reasoning.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Cube } from '@objectstack/spec/data';
import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts';

import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js';

const FIXTURE = [
{ id: 'r1', owner: 'u_me' },
{ id: 'r2', owner: 'u_other' },
{ id: 'r3', owner: null },
];

const CUBE: Cube = {
name: 'deals',
title: 'Deals',
sql: 'deal',
measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } },
dimensions: Object.fromEntries(
['id', 'owner'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]),
),
public: false,
} as unknown as Cube;

/** Point sql.js at the `.wasm` shipped inside its own package (Node-safe). */
async function locateWasm(): Promise<((file: string) => string) | undefined> {
try {
const { createRequire } = await import('node:module');
const require = createRequire(import.meta.url);
const pkgJsonPath = require.resolve('sql.js/package.json');
const { dirname, join } = await import('node:path');
const dir = dirname(pkgJsonPath);
return (file: string) => join(dir, 'dist', file);
} catch {
return undefined;
}
}

describe('[#13571] empty `$nin` on the read-scope lowering — non-RLS provider control', () => {
let db: any;

beforeAll(async () => {
const mod: any = await import('sql.js');
const initSqlJs = mod.default ?? mod;
const locateFile = await locateWasm();
const SQL = await initSqlJs(locateFile ? { locateFile } : undefined);

db = new SQL.Database();
db.run(`CREATE TABLE "deal" ("id" TEXT PRIMARY KEY, "owner" TEXT);`);
const insert = db.prepare(`INSERT INTO "deal" ("id","owner") VALUES (?,?)`);
for (const r of FIXTURE) insert.run([r.id, r.owner]);
insert.free();
});

afterAll(() => {
db?.close();
});

/**
* A `StrategyContext` whose `getReadScope` is filled BY HAND — the spec
* contract's own authoring mode, and deliberately not the RLS compiler.
*/
const ctxWithScope = (scope: unknown): StrategyContext =>
({
getCube: (name: string) => (name === 'deals' ? CUBE : undefined),
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
getReadScope: () => scope,
executeRawSql: async (_object: string, sql: string, params: unknown[]) => {
const stmt = db.prepare(sql.replace(/\$\d+/g, '?'));
stmt.bind(params as any[]);
const out: Record<string, unknown>[] = [];
while (stmt.step()) out.push(stmt.getAsObject());
stmt.free();
return out;
},
}) as StrategyContext;

const QUERY: AnalyticsQuery = {
cube: 'deals',
measures: ['total'],
dimensions: ['id'],
timezone: 'UTC',
} as AnalyticsQuery;

/** Run the query under `scope`; return either the refusal or the admitted ids. */
const outcome = async (
scope: unknown,
): Promise<{ refusal?: Error & { code?: unknown; status?: unknown }; admitted?: string[] }> => {
try {
const result = await new NativeSQLStrategy().execute(QUERY, ctxWithScope(scope));
return { admitted: result.rows.map((r) => String(r.id)).sort((x, y) => x.localeCompare(y)) };
} catch (e) {
return { refusal: e as Error & { code?: unknown; status?: unknown } };
}
};

it('a provider handing `{ owner: { $nin: [] } }` is REFUSED in the module envelope — it used to get the whole table', async () => {
const { refusal, admitted } = await outcome({ owner: { $nin: [] } });
// Pre-#13571 this assertion's diff read `admitted: ['r1','r2','r3']` — the
// whole fixture, from a scope clause of constant TRUE.
expect(admitted).toBeUndefined();
expect(refusal).toBeInstanceOf(Error);
expect(refusal?.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(refusal?.status).toBe(500);
expect(String(refusal?.message)).toContain('$nin for "owner" is empty');
});

it('OVER-DENIAL CONTROL: the #13570-pinned RLS composite still compiles and still admits exactly the own row', async () => {
// `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }` is what
// `RLSCompiler.compileFilter` returns for an emptied membership set beside
// an own-rows grant ("own rows keep flowing"), and it arrives here through
// `security.getReadFilter`. The empty-`$nin` refusal must not touch it:
// this block red under a uniform empty-membership throw is the
// availability regression the #13571 verdict exists to avoid.
const { refusal, admitted } = await outcome({ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(refusal).toBeUndefined();
expect(admitted).toEqual(['r1']);
});

it('the composite denies-by-itself shape stays working too: a lone empty `$in` arm inside `$and`', async () => {
// The other composite #13570's guard deliberately passes through: as an
// `$and` arm the emptied positive membership is constant FALSE — the whole
// scope denies. Zero rows, not a refusal and not the whole table.
const { refusal, admitted } = await outcome({ $and: [{ owner: { $in: [] } }, { owner: 'u_me' }] });
expect(refusal).toBeUndefined();
expect(admitted).toEqual([]);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -347,9 +347,29 @@ describe('[#5297] read-scope `$not` — boolean identities and NULL safety', ()
expect(ids({ $not: { stage: { $ne: null } } })).toEqual(['3', '4']);
});

it('an empty `$in` / `$nin` under a `$not` keeps its constant value', () => {
it('an empty `$in` under a `$not` keeps its constant value; an empty `$nin` refuses before `$not` matters (#13571)', () => {
// `$in: []` keeps its #5322/#5243 reduction — constant FALSE, total, no
// guard — and the negation flips it to TRUE: every row. That widened
// composition is the #13571 verdict's DECLARED residue for a non-RLS
// producer (the in-repo RLS compiler cannot emit the shape — #13570's
// polarity guard drops it upstream); closing it is a ruled follow-up
// design, not an edit to this pin.
expect(ids({ $not: { stage: { $in: [] } } })).toEqual(ALL);
expect(ids({ $not: { stage: { $nin: [] } } })).toEqual([]);
// `$nin: []` no longer HAS a constant to keep: its reduction is TRUE —
// scope-vacating on its own — so `compileOperator` refuses it whatever
// the polarity above it. Deliberately asymmetric with the `$in` line
// above ("shape errors throw, boolean identities reduce" is the #5322
// boundary, and a scope-vacating reduction is on the THROW side) — see
// read-scope-sql.ts's #13571 header section.
let refusal: (Error & { code?: unknown; status?: unknown }) | undefined;
try {
ids({ $not: { stage: { $nin: [] } } });
} catch (e) {
refusal = e as Error & { code?: unknown; status?: unknown };
}
expect(refusal?.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(refusal?.status).toBe(500);
expect(String(refusal?.message)).toContain('$nin for "stage" is empty');
});
});

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -93,7 +93,7 @@ function refusalFor(filter: unknown, alias = 'crm_opportunity'): Refusal | undef
/**
* Every refusing site in `read-scope-sql.ts`, in source order.
*
* FOURTEEN rows over TWELVE throw sites: TWO sites are each reached by two
* FIFTEEN rows over THIRTEEN throw sites: TWO sites are each reached by two
* triggers, and every trigger is listed on purpose.
*
* - `quoteIdent`, with two `kind` values. That alias-vs-field split was option
Expand DownExpand Up@@ -208,14 +208,26 @@ const REFUSALS: Array<{
sensitive: 'region_code',
},
{
name: '⑬ $between without [min,max]',
// [#13571] The empty EXCLUSION refuses; the empty INCLUSION keeps its
// constant — see the ACCEPTED table's "#5243" row. Deliberate asymmetry
// ("shape errors throw, boolean identities reduce" — #5322; a reduction to
// constant TRUE vacates the scope, so it is on the throw side), not an
// oversight: read-scope-sql.ts's #13571 header section carries the ruling.
name: '⑬ $nin with an EMPTY array',
site: 'compileOperator: empty $nin vacates the scope',
filter: { region_code: { $nin: [] } },
message: /\$nin for "region_code" is empty — an empty exclusion excludes nothing and would compile the read scope to constant TRUE \(fail-closed\)/,
sensitive: 'region_code',
},
{
name: '⑭ $between without [min,max]',
site: 'compileOperator: $between needs [min,max]',
filter: { credit_limit: { $between: [10] } },
message: /\$between for "credit_limit" needs \[min,max\] \(fail-closed\)/,
sensitive: 'credit_limit',
},
{
name: ' unsupported operator',
name: ' unsupported operator',
site: 'compileOperator: unsupported operator',
filter: { owner_email: { $regex: 'admin@' } },
message: /unsupported operator "\$regex" on "owner_email" \(fail-closed\)/,
Expand DownExpand Up@@ -244,6 +256,11 @@ const ACCEPTED: Array<{ name: string; filter: unknown; sql: string; params: unkn
params: ['emea', 'apac'],
},
{
// [#13571] STAYS accepted while the empty `$nin` refuses (REFUSALS ⑬):
// this constant is FALSE — narrowing at its own arm — and the RLS compiler
// deliberately emits the shape at positive polarity inside composites
// (#13570's "own rows keep flowing" pin), so refusing it here would 500 a
// live, ruled-correct scope. The asymmetry is the #13571 ruling itself.
name: 'an empty $in as the FALSE constant (#5243)',
filter: { region_code: { $in: [] } },
sql: '1 = 0',
Expand DownExpand Up@@ -306,13 +323,14 @@ describe('[#5367] every read-scope refusal carries the ADR-0112 envelope (READ_S
// #5352's lesson, stated as a guard: seven of `filter-normalizer.ts`'s nine
// sites carrying an envelope was indistinguishable from none of them at the
// HTTP boundary, because the commonest input hit one of the two bare ones.
// Fourteen inputs over the module's TWELVE throw sites (see the table's note
// on the two sites with two triggers each), and every one of them enveloped.
// [#6125] added the eleventh site, [#6387] the twelfth; these two numbers
// Fifteen inputs over the module's THIRTEEN throw sites (see the table's
// note on the two sites with two triggers each), and every one of them
// enveloped. [#6125] added the eleventh site, [#6387] the twelfth, and
// [#13571] the thirteenth (the empty-`$nin` refusal); these two numbers
// are the ratchet that makes a future unenveloped `throw` fail HERE instead
// of at an HTTP boundary.
expect(REFUSALS).toHaveLength(14);
expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(12);
expect(REFUSALS).toHaveLength(15);
expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(13);
for (const c of REFUSALS) {
expect(refusalFor(c.filter, c.alias)?.code, `${c.site} is still bare`).toBe('READ_SCOPE_COMPILE_FAILED');
}
Expand Down
Loading
Loading