Skip to content

Commit 920ee26

Browse files
committed
fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld 500 envelope
A read scope carrying a comparand the engine's shared comparand faces refuse reached engine.aggregate composed with the caller's filter and came back as the engine's INVALID_FILTER / 400, whose message the HTTP doors relay. The NativeSQL face and the echo refuse the same scope as READ_SCOPE_COMPILE_FAILED / 500 with the message withheld. assertReadScopeComparandsRunnable (read-scope-sql.ts, next to the vacancy guard) runs the engine's own two faces, assertListComparandShapes and normalizeFilterComparandTypes from @objectstack/spec/data, on the scope alone and re-raises any refusal in the module's one envelope. It is called at both engine-bound merges: withReadScope (direct and cross-object base) and resolveFkAttr (the referenced object's scope). The caller's own where is not judged here and keeps its 400. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
1 parent e490825 commit 920ee26

3 files changed

Lines changed: 104 additions & 1 deletion

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): the ObjectQL execute face refuses a read scope it cannot run in the withheld `READ_SCOPE_COMPILE_FAILED` / 500 envelope, not the engine's `INVALID_FILTER` / 400 (#19995)
6+
7+
Clause-②: no
8+
9+
A row-level read scope carrying a comparand the engine's shared comparand faces refuse — a list in the equality slot, a scalar under `$in` / `$nin`, a one-bound `$between`, a null list member, a plain-object or `undefined` comparand — used to reach `engine.aggregate` composed with the caller's own filter, and came back as the engine's `INVALID_FILTER` / 400. A 4xx's message is relayed to the caller, and this one named the policy's fields and comparands. The NativeSQL execute face and the `/analytics/sql` echo already refused the same scope as a server fault with the message withheld (the #5367 ruling), so one scope got two envelopes depending on which analytics face served it.
10+
11+
The ObjectQL strategy now judges the scope on its own at both engine-bound merge sites (the base aggregate, direct and cross-object, and the referenced object's scope in the cross-object label lookup), with the same two shared functions the engine runs, before composing it. A refusal there is `READ_SCOPE_COMPILE_FAILED` / 500: `POST /analytics/query` and `POST /analytics/dataset/query` withhold its message, and the full text goes to the operator's log.
12+
13+
Unchanged: which scopes are served. The judgement uses the engine's own functions, so a scope the engine serves is still served, including a `{ $field }` cross-field scope and an emptied `$in` beside an own-rows grant. The caller's own `where` still answers `INVALID_FILTER` / 400 with its message, whether the analytics door or the engine refuses it.

‎packages/services/service-analytics/src/read-scope-sql.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { FilterCondition } from '@objectstack/spec/data';
4+
// [#19995] The engine's own shared comparand faces — run on a read scope, alone,
5+
// at the ObjectQL merge sites by {@link assertReadScopeComparandsRunnable}.
6+
import { assertListComparandShapes, normalizeFilterComparandTypes } from '@objectstack/spec/data';
47
import type { RegisteredErrorCode } from '@objectstack/spec/api';
58
import { type LikeShape } from './like-pattern.js';
69
import { textMatchPredicateSql, normalizeSqlDialect } from './text-match-sql.js';
@@ -414,6 +417,20 @@ import {
414417
* already refused by the bare-array arm. {@link assertNoListInEqualitySlot}
415418
* refuses it in this module's envelope. See there for the measured answers, the
416419
* reachability reading, and why `$ne` is not judged here.
420+
*
421+
* ## The ObjectQL ENGINE path refuses a bad comparand in THIS envelope too (#19995)
422+
*
423+
* The #13640 section above is the vacancy half of the engine path; this is the
424+
* comparand half. A scope carrying a comparand the ENGINE's shared comparand
425+
* faces refuse was handed to `engine.aggregate` and came back as the engine's
426+
* `INVALID_FILTER` / 400, a 4xx whose prose the HTTP doors relay — while the
427+
* NativeSQL face and the echo refused the same scope in the withheld envelope
428+
* above. One scope, two envelopes, and the 400 one is the disclosure #5367
429+
* closed. The engine's refusal does not read the `'policy'` provenance mark
430+
* (#8220); only `driver-sql`'s cross-field and bind refusals do.
431+
* {@link assertReadScopeComparandsRunnable} closes it at the two engine-bound
432+
* merge sites. See there for why its refusal set is exactly the engine's, and
433+
* for what it deliberately leaves to the engine.
417434
*/
418435

419436
const IDENT = /^[a-z_][a-z0-9_]*$/i;
@@ -647,6 +664,63 @@ export function assertReadScopeCannotVacate(scope: unknown, objectName: string):
647664
);
648665
}
649666

667+
/**
668+
* [#19995] Refuse, in this module's envelope, a read scope the ENGINE would
669+
* refuse for one of its comparands — judged on the scope ALONE, before it is
670+
* composed with the caller's filter.
671+
*
672+
* The door for the two ENGINE-bound merges: `ObjectQLStrategy.withReadScope`
673+
* (the direct and the cross-object base aggregate) and `resolveFkAttr` (the
674+
* referenced object's scope). There the scope used to reach `engine.aggregate`
675+
* unjudged, and a comparand the engine refuses came back as its
676+
* `INVALID_FILTER` / 400, message relayed. At the merge site the scope is
677+
* still a distinguishable object; one line later it is `$and`-composed with
678+
* the caller's own filter and no consumer can tell whose clause a refusal
679+
* came from — which is why this is a judgement here and ⛔ never a catch
680+
* around `executeAggregate`: the caller's own `where` still reaches the
681+
* engine's doors for some shapes, and those refusals are the caller's to read.
682+
*
683+
* ## Why the refusal set is exactly the engine's
684+
*
685+
* The two faces called below are the ones the engine runs on every
686+
* object-form `where` (`lowerWhereFilterArray`, `@objectstack/objectql`):
687+
* `@objectstack/spec/data`'s list-shape face and comparand-type face. Both are
688+
* pure walks whose verdict on a subtree does not depend on the rest of the
689+
* tree or on any object's schema, so the scope alone answers exactly as the
690+
* scope inside `{ $and: [userFilter, scope] }` does. Same functions, same
691+
* verdicts: nothing the engine serves is refused here, and a `{ $field }`
692+
* reference (served on this path under #7598 Q1 = B) is stepped around by
693+
* both, as the engine steps around it.
694+
*
695+
* ## What it deliberately does not judge
696+
*
697+
* The engine refuses other scope shapes through doors that read the object's
698+
* SCHEMA or the request's CONTEXT (text operators on non-text fields, temporal
699+
* comparands, filter placeholders), and `driver-sql` refuses more at compile
700+
* time. Judging those here would mean a second copy of rules this package
701+
* cannot see; their envelope is the engine's and the driver's to give.
702+
*
703+
* Anything the two walks throw is attributable to the scope — they read
704+
* nothing else — so every throw is re-raised in the one envelope, the walk's
705+
* own sentence kept for the operator's log.
706+
*
707+
* @param scope the `StrategyContext.getReadScope` output, exactly as returned
708+
* @param objectName the object the scope was requested for — for the operator's
709+
* log only; withheld from the response by the `READ_SCOPE_COMPILE_FAILED` /
710+
* 500 declaration, like every message in this module.
711+
*/
712+
export function assertReadScopeComparandsRunnable(scope: unknown, objectName: string): void {
713+
try {
714+
assertListComparandShapes(scope, undefined, 'readScope');
715+
normalizeFilterComparandTypes(scope, undefined, 'readScope');
716+
} catch (e) {
717+
throw readScopeCompileError(
718+
`[read-scope-sql] read scope for "${objectName}" carries a comparand the engine refuses — ` +
719+
`${e instanceof Error ? e.message : String(e)} (fail-closed).`,
720+
);
721+
}
722+
}
723+
650724
/**
651725
* Compile a child node into its OWN bind buffer.
652726
*

‎packages/services/service-analytics/src/strategies/objectql-strategy.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import {
1616
type NormalizedFilterNode,
1717
} from './filter-normalizer.js';
1818
import { findCrossFieldComparand, isFieldReference } from '../comparand-shape.js';
19-
import { assertReadScopeCannotVacate, compileScopedFilterToSql } from '../read-scope-sql.js';
19+
import {
20+
assertReadScopeCannotVacate,
21+
assertReadScopeComparandsRunnable,
22+
compileScopedFilterToSql,
23+
} from '../read-scope-sql.js';
2024
import { nonTextColumnResolver, textOperatorPolarity } from '../non-text-column.js';
2125
import { invalidMemberError } from '../dataset-refusal.js';
2226
import { type LikeShape } from '../like-pattern.js';
@@ -650,6 +654,14 @@ export class ObjectQLStrategy implements AnalyticsStrategy {
650654
// the same disposition from `compileScopedFilterToSql` itself (#13571);
651655
// this is the same ruling at the door that compiler never sees.
652656
assertReadScopeCannotVacate(scope, objectName);
657+
// [#19995] …and the comparand half of the same door. A scope carrying a
658+
// comparand the engine's shared faces refuse came back as the engine's
659+
// `INVALID_FILTER` / 400 — a 4xx the HTTP doors relay verbatim, naming the
660+
// policy's field and comparand, where NativeSQL and the echo refuse the
661+
// same scope in the withheld `READ_SCOPE_COMPILE_FAILED` / 500 (#5367).
662+
// Judged on the scope ALONE, for the attribution reason above; the
663+
// caller's own `where` keeps reaching the engine's doors and its 400.
664+
assertReadScopeComparandsRunnable(scope, objectName);
653665
const scopeFilter = markFilterSubtreeProvenance(scope as Record<string, unknown>, 'policy');
654666
if (!userFilter) return scopeFilter;
655667
return { $and: [userFilter, scopeFilter] };
@@ -1133,6 +1145,10 @@ export class ObjectQLStrategy implements AnalyticsStrategy {
11331145
// landing in the RESTRICTED bucket. Guarded before the mark, so a refused
11341146
// scope is never stamped as vouched-for policy content.
11351147
if (scope != null) assertReadScopeCannotVacate(scope, refObject);
1148+
// [#19995] …and the comparand half, as at `withReadScope`: a comparand the
1149+
// engine refuses would come back as its relayed 400, naming the referenced
1150+
// object's policy. Before the mark, for the same reason as the line above.
1151+
if (scope != null) assertReadScopeComparandsRunnable(scope, refObject);
11361152
if (scope != null) markFilterSubtreeProvenance(scope, 'policy');
11371153
const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
11381154
const rows = await ctx.executeAggregate(refObject, {

0 commit comments

Comments
 (0)