Skip to content
Merged
58 changes: 58 additions & 0 deletions .changeset/20018-native-read-scope-faces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
'@objectstack/service-analytics': minor
---

fix(service-analytics)!: the NativeSQL execute face and the `/analytics/sql` echo refuse a read scope the shared comparand faces refuse, as the ObjectQL execute face already does (#20018)

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves: `packages/spec` is untouched, and the shapes refused here are ones the spec's shared comparand faces (`assertListComparandShapes`, `normalizeFilterComparandTypes`) already refuse on every object-form `where` and, since #19995, on the ObjectQL analytics face. What changes is which runtime face refuses a read scope, so `objectstack migrate meta` has nothing to act on and the ledger has no row to gain. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a read-scope comparand shape (not `registered` / `already-registered`); and the change is runtime behaviour of a function, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->

**BREAKING** — an accept-set narrowing on the read-scope lowering, shipped as
`minor` under the launch-window convention (`check-changeset-no-major` refuses
`major` until GA; breaking-ness is carried by this banner and the ADR-0087
disposition above, not by the level).

**What changed.** `compileScopedFilterToSql` is the read-scope lowering behind the
NativeSQL execute face (`NativeSQLStrategy.applyReadScope`, base table and every
joined hop) and the `/analytics/sql` echo (`ObjectQLStrategy.generateSql`), and a
public export of this package. Once its own lowering succeeds, it now runs the two
shared comparand faces of `@objectstack/spec/data` on the scope. A scope they
refuse is refused as `READ_SCOPE_COMPILE_FAILED` / 500 with the message withheld
(the #5367 envelope), before any statement is built or executed. That is the
answer the ObjectQL execute face has given the same scope since #19995, so one read
scope now gets one verdict on every analytics face.

**Which read scopes stop being served.** Each was lowered and executed before, and
each is refused by a standing ruling the shared faces carry. Measured on SQLite:

| read-scope shape | what the native face and the echo served |
| --- | --- |
| a `null` member of `$in` | only the named non-null values; the NULL matched nothing |
| a `null` member of `$in` under `$not`, or of `$nin` | only the rows whose column is NULL, which the scope excludes |
| a `null` comparand under `$gt` / `$gte` / `$lt` / `$lte`, or a `null` `$between` bound | zero rows |
| a blank (`''`) `$between` bound | the rows inside the half-blank range |
| a bigint beyond ±2^53, or a binary comparand | zero rows |
| a plain-object or other non-plain-object comparand in a scalar position | the database refused the statement (`DATABASE_ERROR` / 500) |

**Who is affected.** A host `getReadScope` provider, or a direct caller of
`compileScopedFilterToSql`, that produces one of these shapes. The ObjectQL
analytics face already refused all of them. No in-repo read-scope producer emits
them for a policy in this repository. An RLS `using` predicate can still be
written so that it lowers into the null shapes (a literal `null` inside an `in`
list, or an ordering comparison against `null`), and such a policy now gets the
withheld 500 on every analytics face.

**Fix.** State absence with the null predicate. "One of these values, or no
value" is `{ "$or": [{ "f": { "$in": ["a"] } }, { "f": { "$null": true } }] }`,
which in an RLS predicate is `f in ['a'] || f == null`. A one-sided range is `$gte`
or `$lte`. A comparand is a string, number, bigint within ±2^53, boolean, `null` or
`Date`.

**Unchanged.**

- Well-formed scopes compile to the same SQL and admit the same rows. That includes
the null predicates, an emptied `$in` beside an own-rows grant, and the spelling
above.
- A shape the lowering already refused keeps its own log sentence.
- The caller's own `where` never reaches this lowering, and it is untouched.
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
* - **binary** binds but has no faithful text rendering, so it is accepted in
* a bind position and refused by the LIKE family. That asymmetry is the
* reason the package carries two predicates rather than one with a flag.
* [#20018] On the READ-SCOPE door it is now refused in every position: the
* lowering runs the shared comparand-type face after its own gates, as the
* ObjectQL execute face does. The predicate's own answer is unchanged.
*
* @see comparand-shape.ts — the predicates and the messages this pins
* @see https://github.com/objectstack-ai/objectstack/issues/8186
Expand Down Expand Up @@ -131,9 +134,15 @@ const MATRIX: readonly Row[] = [
// spelling used to compile it to `status IN (NULL)`; the FilterArray
// spelling was already refused (`where-face-arms-refusal.test.ts`). The
// read-scope cell is that door's own and is not this card's.
// [#20018] …and the read-scope cell moved too, by the same ruling at the
// other door: the read-scope lowering now runs the shared list-shape face
// after its own gates, so a `null` MEMBER of `$in` is refused there as the
// ObjectQL execute face already refused it
// (`read-scope-comparand-three-faces.test.ts`). `null` as a scalar or LIKE
// comparand is not a list member and moves at neither door.
{ label: 'null', value: null,
bindable: true, renderable: true,
whereLike: OK, whereIn: REFUSED_WHERE, whereEq: OK, scopeLike: OK, scopeIn: OK, scopeEq: OK },
whereLike: OK, whereIn: REFUSED_WHERE, whereEq: OK, scopeLike: OK, scopeIn: REFUSED_SCOPE, scopeEq: OK },
{ label: 'Date', value: new Date('2026-01-01T00:00:00.000Z'),
bindable: true, renderable: true,
whereLike: OK, whereIn: OK, whereEq: OK, scopeLike: OK, scopeIn: OK, scopeEq: OK },
Expand All @@ -146,17 +155,27 @@ const MATRIX: readonly Row[] = [
whereLike: REFUSED_WHERE, whereIn: REFUSED_WHERE, whereEq: REFUSED_WHERE,
scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: REFUSED_SCOPE },
// binary: binds, does not render — accepted where it binds, refused by LIKE.
// [#20018] Two cells moved AFTER the #8186 measurement, on purpose: the
// read-scope lowering now runs the shared comparand-type face after its own
// gates, and that face does not admit binary (the predicate above it still
// does — the `where` door keeps the extra, the read scope no longer reaches
// it). The ObjectQL execute face already refused a binary read-scope
// comparand.
{ label: 'binary', value: new Uint8Array([1, 2]),
bindable: true, renderable: false,
whereLike: REFUSED_WHERE, whereIn: OK, whereEq: OK,
scopeLike: REFUSED_SCOPE, scopeIn: OK, scopeEq: OK },
scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: REFUSED_SCOPE },

// ── shapes outside the fence ──────────────────────────────────────────────
// `$eq` accepts them on purpose: #5234 left the `{$eq: {…}}` account alone.
// [#20018] …on the `where` door. On the read-scope lowering the shared
// comparand-type face now closes that account (#7872's set), the answer the
// ObjectQL execute face already gave: a plain object bound as a scalar was
// refused by the database at execution, not by this package.
{ label: 'plain object', value: { foo: 1 },
bindable: false, renderable: false,
whereLike: REFUSED_WHERE, whereIn: REFUSED_WHERE, whereEq: OK,
scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: OK },
scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: REFUSED_SCOPE },
// [#19975] One cell of this row moved AFTER the #8186 measurement, on
// purpose: ruling 乙 (#19757) refuses a list in the equality slot, and the
// read-scope lowering now refuses it under `$eq` instead of binding the list
Expand Down Expand Up @@ -272,16 +291,22 @@ describe('[#8186] the comparand matrix is unchanged by the door reconciliation',
expect(doorTypes.map((r) => r.label)).toEqual([
'string', 'number', 'bigint', 'boolean', 'null', 'Date',
]);
// The cells a SHAPE ruling moved, not a TYPE verdict: `null` is an accepted
// comparand type, but not as an `$in` MEMBER — the shared shape face's
// 2026-08-31 carve-out, now run at BOTH doors. Every cell is named with the
// change that moved it, so no other cell can move under this sentence.
const moved: Record<string, string> = {
'null whereIn': REFUSED_WHERE, // [#20010] the `where` door's envelope
'null scopeIn': REFUSED_SCOPE, // [#20018] the read-scope lowering's envelope
};
for (const row of doorTypes) {
// [#20010] ONE exception, and it is not a TYPE verdict: `null` is an
// accepted comparand type, but not as an `$in` MEMBER — the SHAPE face's
// 2026-08-31 carve-out, which this door now runs. Every other position
// of every accepted type still accepts.
const whereIn = row.label === 'null' ? REFUSED_WHERE : OK;
expect(row.whereIn, `${row.label} $in`).toBe(whereIn);
for (const cell of [row.whereLike, row.whereEq,
row.scopeLike, row.scopeIn, row.scopeEq]) {
expect(cell, row.label).toBe(OK);
const cells = {
whereLike: row.whereLike, whereIn: row.whereIn, whereEq: row.whereEq,
scopeLike: row.scopeLike, scopeIn: row.scopeIn, scopeEq: row.scopeEq,
};
for (const [position, cell] of Object.entries(cells)) {
const key = `${row.label} ${position}`;
expect(cell, key).toBe(moved[key] ?? OK);
}
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,19 @@ describe('[#5234] the read-scope lowering refuses the same two shapes, fail-clos

describe('the guard is narrow here too', () => {
it('keeps binding every legitimate `$in` member', () => {
expect(scope({ status: { $in: ['a', null, 7, true] } }).params).toEqual(['a', null, 7, true]);
expect(scope({ status: { $in: ['a', 7, true] } }).params).toEqual(['a', 7, true]);
});

it('[#20018] a `null` member is no longer one of them — refused by the shared list-shape face', () => {
// This row used to bind `null` beside the three members above. The
// null-member ruling (2026-08-31) refuses it at the shared face, and the
// lowering now runs that face after its own gates, as the ObjectQL
// execute face does — so this door's own member gate is still narrow,
// and the refusal carries the face's sentence, not this door's.
const err = refusalOf(() => scope({ status: { $in: ['a', null, 7, true] } }));
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(err.status).toBe(500);
expect(err.message).toContain('does not accept null as a list member');
});

it('keeps every primitive LIKE comparand', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -417,13 +417,22 @@ describe('[#7598] the field-reference shape is read exactly as `driver-sql` read
it('a NON-STRING `$field` is not a reference — it stays the object account', () => {
// `driver-sql`'s `fieldReferenceOf` requires `typeof ref === 'string'`, and
// this package mirrors that spelling rather than inventing a third reading.
// It is therefore NOT routed either: it binds as JSON, exactly as any other
// object comparand does, which is the account #5234 left open on purpose.
// It is therefore NOT routed either: on the `where` door it binds as JSON,
// exactly as any other object comparand does, which is the account #5234
// left open on purpose there.
expect(findCrossFieldComparand({ amount: { $gt: { $field: 5 } } })).toBeNull();
expect(tree({ amount: { $gt: { $field: 5 } } })).toEqual({
kind: 'leaf', member: 'amount', operator: 'gt', values: [{ $field: 5 }],
});
expect(scope({ amount: { $gt: { $field: 5 } } }).params).toEqual([{ $field: 5 }]);
// [#20018] The read-scope lowering no longer BINDS it: it now runs the
// shared comparand-type face after its own gates, which refuses a plain
// object in a scalar position (the ObjectQL execute face's answer too). It
// is still read as the object it is, not as a reference: the refusal is the
// type face's, not the field-reference gate's.
const err = refusalOf(() => scope({ amount: { $gt: { $field: 5 } } }));
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(err.message).toContain('is a plain object');
expect(err.message).not.toContain('compares against the field reference');
});

it('an ordinary object comparand is untouched — #5234 left that account open', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,17 @@ describe('[#6387] the eight measured cells are REFUSED, in this module’s own e
// A prototype-borne key is not something an author wrote.
const spec = Object.create({ $null: 'false' }) as Record<string, unknown>;
spec.$eq = 'u1';
expect(compileScopedFilterToSql({ d: spec } as FilterCondition, ALIAS).sql).toBe('"t"."d" = ?');
// [#20018] This used to compile to `"t"."d" = ?`. The scope as a whole is
// refused now — by the shared comparand-type face, which runs after this
// module's own gates and reads an object whose prototype is not
// `Object.prototype` as a VALUE, not as an operator spec (the ObjectQL
// execute face refuses it the same way). What this pin is about still
// holds, and still discriminates: this module's gates run FIRST, so a flag
// gate reading the inherited key would have answered with its own sentence.
const err = refusalFor({ d: spec } as FilterCondition);
expect(err?.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(String(err?.message)).not.toContain('is not a boolean');
expect(String(err?.message)).toContain('carries a comparand the engine refuses');
});
});

Expand Down
Loading
Loading