From 663ec07ba9082b5ddad21bea658f8b58e72ce45c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:55:29 +0000 Subject: [PATCH 1/5] fix(service-analytics): judge each read scope with the engine's own admission before composing it The ObjectQL execute face composed a row-level read scope into the where it hands engine.aggregate without the engine judging the scope first. A scope refused by an engine door that reads the object's field map (a text operator over a non-text field, an uninterpretable temporal comparand, a virtual field, a dotted path through a lookup) came back as the engine's 400, whose message both analytics HTTP doors relay: the policy's field and comparand. Ruling C: ask IObjectQLEngine.judgeFilter about the scope alone, at every engine-bound merge (withReadScope, resolveFkAttr, and the plugin's record-label fetch), and refuse in the withheld READ_SCOPE_COMPILE_FAILED / 500. The plugin wires the judge only to the engine its own executeAggregate auto-bridge runs on. A host with no judge keeps today's guards and behaviour, and is told once. Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude --- .../src/analytics-service.ts | 67 +++++++++- .../services/service-analytics/src/plugin.ts | 64 ++++++++- .../service-analytics/src/read-scope-sql.ts | 123 ++++++++++++++++-- .../src/strategies/objectql-strategy.ts | 13 ++ .../service-analytics/src/strategies/types.ts | 45 ++++++- 5 files changed, 297 insertions(+), 15 deletions(-) diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 2be919c5db..178fc71795 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -49,7 +49,7 @@ import { readScopeUnresolvedError } from './read-scope-refusal.js'; // Owned in its own module so the enumerated verdict per `AggregationFunction` // member has one home rather than being inlined at the enrichment site. import { measureResultType } from './measure-result-type.js'; -import type { AnalyticsStrategy, AnalyticsDriverCapabilities, StrategyContext, DatasetScopedStrategyContext, DatasetScope } from './strategies/types.js'; +import type { AnalyticsStrategy, AnalyticsDriverCapabilities, StrategyContext, DatasetScopedStrategyContext, DatasetScope, ReadScopeFilterJudge } from './strategies/types.js'; import { NativeSQLStrategy } from './strategies/native-sql-strategy.js'; import { ObjectQLStrategy } from './strategies/objectql-strategy.js'; // [#5669] The `where` source-field gate reads the filter tree through the SAME @@ -748,6 +748,28 @@ export interface AnalyticsServiceConfig { * "cannot answer, do not block". */ sqlDialect?: (object: string) => AcceptedSqlDialect | undefined; + /** + * [#19995, ruling C] The data engine's judge-only `where` admission, + * `IObjectQLEngine.judgeFilter` (#20157): would the engine admit this + * `where` on this object, without running it? `undefined` when this host + * cannot answer. + * + * `ObjectQLStrategy` asks it about each object's read scope, alone, before it + * composes the scope into the `where` it hands `executeAggregate`. A scope the + * engine refuses is then refused in the withheld `READ_SCOPE_COMPILE_FAILED` / + * 500 (#5367), instead of coming back as the engine's 400 whose message names + * the policy's fields and comparands. + * + * ⚠️ It must be the judgement of the engine `executeAggregate` executes on. A + * judge that disagrees with the executor would refuse scopes the executor + * serves. `AnalyticsServicePlugin` wires it only when it bridges + * `executeAggregate` itself, and then to the same engine. + * + * A host that wires nothing keeps the behaviour it had: this package's own + * read-scope guards still refuse the shapes they can judge, and the service + * says once, in its log, that the rest reach the engine unjudged. + */ + judgeFilter?: ReadScopeFilterJudge; /** Pre-defined datasets to compile + register at construction (ADR-0021). */ datasets?: Dataset[]; /** @@ -868,6 +890,8 @@ export class AnalyticsService implements IAnalyticsService { private readonly isExternalObject?: AnalyticsServiceConfig['isExternalObject']; /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */ private warnedNoObjectRegistry = false; + /** [#19995] One-shot flag for the {@link reportUnjudgedReadScope} warning. */ + private warnedUnjudgedReadScope = false; /** * [#16206] The out-of-contract `sqlDialect` answers this service has already * diagnosed — the dedupe key for {@link diagnoseSqlDialectAnswer}. @@ -973,6 +997,17 @@ export class AnalyticsService implements IAnalyticsService { this.diagnoseSqlDialectAnswer(object, answered); return answered; }, + // [#19995, ruling C] The engine's own admission verdict on a read scope, + // asked by `ObjectQLStrategy` at its engine-bound merges. The host's + // answer is passed through untouched, `undefined` included. A host that + // wired no judge at all is told once, here, where that absence arrives. + judgeFilter: (objectName, where, options) => { + if (typeof config.judgeFilter !== 'function') { + this.reportUnjudgedReadScope(objectName); + return undefined; + } + return config.judgeFilter(objectName, where, options); + }, }; // Build strategy chain (built-in + custom, sorted by priority) @@ -1048,6 +1083,36 @@ export class AnalyticsService implements IAnalyticsService { ); } + /** + * [#19995, ruling C] Tell a host that composed a read scope into an ObjectQL + * aggregate with no `judgeFilter` wired that the engine did not judge it. + * + * That host keeps the behaviour it had, which is the ruling's bar for it: this + * package's own read-scope guards still refuse, with the policy withheld, the + * shapes they can judge. The rest reach the engine unjudged, and a refusal + * there is the engine's 400, whose message names the policy. The line says + * that once, with the remedy. + * + * `warn`, not `error`, by AGENTS.md's one question: every query is answered + * as it was before this hook existed, and nothing that claims to be persisted + * fails to land. Once per service instance, on the first scoped merge that + * goes unjudged: a host that never composes a scope is never told. + */ + private reportUnjudgedReadScope(objectName: string): void { + if (this.warnedUnjudgedReadScope) return; + this.warnedUnjudgedReadScope = true; + this.logger.warn( + `[Analytics] The row-level read scope for "${objectName}" was composed into an ObjectQL aggregate without ` + + `the engine judging it first: this AnalyticsService was configured with no judgeFilter. Scope shapes this ` + + `package can judge itself are still refused with the policy withheld (READ_SCOPE_COMPILE_FAILED / 500). A scope ` + + `the engine refuses through a door that reads the object's fields (a text operator on a non-text field, a date ` + + `comparand the field cannot read, a formula field, a dotted path) comes back as the engine's 400 instead, and ` + + `that message names the policy's field and comparand. Supply judgeFilter from the engine that executeAggregate ` + + `runs on (IObjectQLEngine.judgeFilter); AnalyticsServicePlugin wires it when it bridges executeAggregate itself. ` + + `Reported once.`, + ); + } + /** * Build a per-call StrategyContext that binds the read-scope provider to the * current request's ExecutionContext (ADR-0021 D-C). The strategy then sees a diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index 56f4c45ef9..899feba016 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -10,7 +10,12 @@ import { AnalyticsService } from './analytics-service.js'; import type { AnalyticsServiceConfig } from './analytics-service.js'; import type { AnalyticsDriverCapabilities } from './strategies/types.js'; import { pickDisplayField, type DimensionLabelDeps } from './dimension-labels.js'; -import { assertReadScopeCannotVacate } from './read-scope-sql.js'; +import { + assertReadScopeAdmittedByEngine, + assertReadScopeCannotVacate, + assertReadScopeComparandsRunnable, + assertReadScopePlaceholdersResolvable, +} from './read-scope-sql.js'; import { readScopeUnresolvedError } from './read-scope-refusal.js'; // [#16206] The narrowing from a driver's FOUR-name `dialectName` to the THREE // this package's config hook declares — see the bridge below. @@ -48,11 +53,13 @@ import { asAcceptedSqlDialect, type AcceptedSqlDialect } from './text-match-sql. * other half of that graceful-degradation contract. `getObject` is REQUIRED on * `IObjectQLEngine`, so the `Partial<>` around it is not decoration — it is * what keeps this seam usable against an engine that is not ObjectQL. + * `judgeFilter` (#20157) is optional on the contract itself, by ruling, and + * the auto-bridge probes it the same way. */ type DataEngineLike = Pick & Partial> - & Partial>; + & Partial>; /** * The slice of the `IDataDriver` CONTRACT the analytics layer consumes — @@ -305,6 +312,12 @@ export class AnalyticsServicePlugin implements Plugin { // without re-implementing the bridge in every app. let executeAggregate = this.options.executeAggregate; let autoBridged = false; + // [#19995, ruling C] The engine's judge-only `where` admission, filled below + // ONLY when this plugin bridges `executeAggregate` itself, and then to the + // same engine. The judge must be the executor, or it would refuse scopes the + // executor serves; a host that supplied its own `executeAggregate` has not + // said which engine that is, so it is not guessed. + let judgeFilter: AnalyticsServiceConfig['judgeFilter']; if (!executeAggregate) { const tryGetDataEngine = (): DataEngineLike | undefined => { try { @@ -399,6 +412,36 @@ export class AnalyticsServicePlugin implements Plugin { return rows as Record[]; }; autoBridged = true; + + // [#19995, ruling C] …and the judge, resolved per call through the same + // `tryGetDataEngine` the executor above uses, so the two are one engine by + // construction. Called as the engine's own method: it reads the engine's + // registry. Three answers: + // - the engine carries `judgeFilter` (ObjectQL does): its verdict; + // - no engine at all: `undefined`, silently, because the executor above + // refuses the query itself, loudly; + // - an engine without the member (a 'data' service that is not + // ObjectQL): `undefined`, and the host is told ONCE that read scopes + // reach that engine unjudged. `warn`: a functional degradation, the + // answers are the ones this host gave before the hook existed. + let reportedEngineWithoutJudge = false; + judgeFilter = (objectName, where, options) => { + const engine = tryGetDataEngine(); + if (!engine) return undefined; + if (typeof engine.judgeFilter === 'function') return engine.judgeFilter(objectName, where, options); + if (!reportedEngineWithoutJudge) { + reportedEngineWithoutJudge = true; + ctx.logger.warn( + `[Analytics] The "data" engine has no judgeFilter (IObjectQLEngine.judgeFilter), so row-level read ` + + `scopes composed into its aggregates (first: "${objectName}") are not judged by the engine before they ` + + `are composed. Scope shapes this package can judge itself are still refused with the policy withheld ` + + `(READ_SCOPE_COMPILE_FAILED / 500); a scope the engine refuses through a door that reads the object's ` + + `fields comes back as its 400, whose message names the policy. Register ObjectQLPlugin's engine as ` + + `"data" to have it judged. Reported once.`, + ); + } + return undefined; + }; } // Auto-bridge raw SQL when the data engine exposes `execute()` and the @@ -838,6 +881,20 @@ export class AnalyticsServicePlugin implements Plugin { // ⛔ Zero compiler change: the #13571 lowering residue is ruled and // untouched. This guard is the walk, not the lowering. if (scope) assertReadScopeCannotVacate(scope, targetObject); + // [#19995] …and the rest of `resolveFkAttr`'s door, which #14329's + // placement argument extends to: the comparand faces, the placeholder + // resolver, and (ruling C) the engine's own admission, all on the scope + // alone, before the `$and` below. Without them the scope reached the + // engine unjudged here, and a scope the engine refuses came back as its + // 400 with the policy's field and comparand in the message. Measured on + // the dataset door, whose sort-key label pass (an `order` on a lookup + // dimension) propagates that refusal to the caller. The display pass + // catches it and renders raw ids, so there the refusal only reaches the + // log. Same envelope as the other engine-bound merges, withheld on the + // wire; the display pass's catch is untouched. + if (scope) assertReadScopeComparandsRunnable(scope, targetObject); + if (scope) assertReadScopePlaceholdersResolvable(scope, targetObject, context); + if (scope) assertReadScopeAdmittedByEngine(scope, targetObject, context, { judgeFilter }); // #3680 — the sort-key pass hands over the PRE-window id set (every // grouped value, not just the displayed page), so a high-cardinality // lookup dimension can push thousands of ids through here. Chunk the @@ -1121,6 +1178,9 @@ export class AnalyticsServicePlugin implements Plugin { getObjectDatasource: (objectName: string) => dataEngine()?.resolveEffectiveDatasource?.(objectName), // [#15684] The executing driver's own dialect — see `sqlDialect` above. sqlDialect, + // [#19995, ruling C] The executing engine's own `where` admission — see + // `judgeFilter` beside the `executeAggregate` auto-bridge above. + judgeFilter, // ADR-0062 D6 — a federated object carries an `external` block (ADR-0015). // Reported so NativeSQLStrategy declines it (its hand-compiled FROM would // hit the wrong physical table) and the driver-correct ObjectQL path runs. diff --git a/packages/services/service-analytics/src/read-scope-sql.ts b/packages/services/service-analytics/src/read-scope-sql.ts index b7abc37a74..56730426f1 100644 --- a/packages/services/service-analytics/src/read-scope-sql.ts +++ b/packages/services/service-analytics/src/read-scope-sql.ts @@ -15,6 +15,10 @@ import { isRefusedTextComparand, textComparandRefusalReason } from '@objectstack // lowering in {@link compileScopedFilterToSql}. import { filterTokenContextFrom, resolveFilterTokens, type ExecutionContextLike } from '@objectstack/core'; import type { RegisteredErrorCode } from '@objectstack/spec/api'; +// [#19995, ruling C] The engine's judge-only admission verdict, asked through +// the host by {@link assertReadScopeAdmittedByEngine}. +import type { EngineFilterJudgement, EngineFilterJudgementOptions } from '@objectstack/spec/contracts'; +import type { ReadScopeFilterJudge } from './strategies/types.js'; import { type LikeShape } from './like-pattern.js'; import { textMatchPredicateSql, normalizeSqlDialect } from './text-match-sql.js'; import { textOperatorPolarity } from './non-text-column.js'; @@ -499,13 +503,8 @@ import { * two merge sites. A placeholder the engine resolves is resolved there too, so * the scope is served as before. * - * Still the engine's to answer on that path, with a 400 that names the policy: - * the doors that read the object's SCHEMA — a text operator over a field that - * never holds a string, a temporal comparand the field's storage rule cannot - * read, a filter over a virtual field or through a dotted path. Their walks - * live in `@objectstack/objectql`, are not exported from its package entries, - * and this package does not depend on the engine at runtime; judging them here - * would take a copy of each. + * That left the doors that read the object's SCHEMA to the engine, answering a + * 400 that names the policy. The section after next closes them. * * ## …and THIS compiler resolves the placeholder before it lowers (#20075) * @@ -546,6 +545,45 @@ import { * With no context the resolution is the engine's for a context-less * operation: a date macro resolves against UTC now, and a context token is * refused. A placeholder is never bound as its literal text. + * + * ## …and the engine's own admission judges the rest, where the host can ask (#19995, ruling C) + * + * Four scope classes still reached the engine unjudged on the ObjectQL face, + * because their doors read the object's declared field map: a text operator + * over a field that never holds a string, a temporal comparand the field's + * storage rule cannot read, a filter on a virtual (formula) field, and a + * dotted path through a lookup. Each came back as the engine's + * `INVALID_FILTER` or `INVALID_FIELD` / 400, whose message names the policy's + * field and comparand, on both analytics HTTP doors. The walks live in + * `@objectstack/objectql`, which this package does not depend on at runtime, + * and a copy of each would drift from the engine. + * + * Ruling C gave the engine a judge-only admission member, + * `IObjectQLEngine.judgeFilter` (#20157). It runs the engine's own `where` + * admission, the same stage functions in the same order every verb runs, and + * stops before any driver. {@link assertReadScopeAdmittedByEngine} asks it about + * the scope ALONE, at every engine-bound merge: `ObjectQLStrategy.withReadScope` + * and `resolveFkAttr`, and the plugin's record-label fetch (the #14329 door). + * A refusal is raised in this module's one envelope. The engine's sentence + * goes to the operator's log only. + * + * Why a scope the engine serves is still served: + * + * - **Same judge.** The host answers the hook from the engine that executes + * the aggregate (`AnalyticsServicePlugin` wires it only to its own + * `executeAggregate` auto-bridge), under the verb the strategy runs + * (`'aggregate'`) and the context it forwards. + * - **Same verdict alone as composed.** Every object-form door judges a node + * against the field map and the context, never against its siblings. So + * the scope alone is admitted exactly when the scope inside + * `{ $and: [userFilter, scope] }` is. + * + * It runs after this module's own guards (vacancy, comparand faces, + * placeholders), so a scope they already refuse keeps the sentence they give + * it. Those guards stay: a host that cannot answer the hook still relies on + * them. Such a host keeps today's behaviour for the four classes, and says so + * once in its log: `AnalyticsService` when it was given no judge at all, + * `AnalyticsServicePlugin` when its data engine lacks the member. */ const IDENT = /^[a-z_][a-z0-9_]*$/i; @@ -857,10 +895,11 @@ export function assertReadScopeCannotVacate(scope: unknown, objectName: string): * SCHEMA or the request's CONTEXT (text operators on non-text fields, temporal * comparands, filter placeholders), and `driver-sql` refuses more at compile * time. Judging those here would mean a second copy of rules this package - * cannot see; their envelope is the engine's and the driver's to give. The - * CONTEXT one is the exception, because its rule IS reachable from here: the - * placeholder resolver lives in `@objectstack/core`, and the sibling - * {@link assertReadScopePlaceholdersResolvable} runs it. + * cannot see. The CONTEXT one is reachable from here: the placeholder + * resolver lives in `@objectstack/core`, and the sibling + * {@link assertReadScopePlaceholdersResolvable} runs it. The SCHEMA ones are + * the engine's own to judge, and {@link assertReadScopeAdmittedByEngine} asks + * the engine (#19995, ruling C). * * Anything the two walks throw is attributable to the scope — they read * nothing else — so every throw is re-raised in the one envelope, the walk's @@ -993,6 +1032,68 @@ function resolveReadScopePlaceholders( } } +/** + * [#19995, ruling C] Refuse, in this module's envelope, a read scope the + * ENGINE's own `where` admission refuses. The engine is asked through the + * host's judge (`IObjectQLEngine.judgeFilter`, #20157), about the scope ALONE, + * at an engine-bound merge, before the scope is composed with anything. + * + * This is what closes the scope classes whose doors read the object's field + * map (see the module header's ruling-C section). It also re-judges every + * class the sibling guards above refuse, since the engine runs those faces + * too; they run first, so their sentences are the ones logged. + * + * `'aggregate'` is the verb every engine-bound merge runs (`executeAggregate`). + * The verb changes only the prefix of the engine's message, never its verdict. + * + * A host that cannot answer (no judge, or an `undefined` answer) is not judged + * here: "cannot answer, do not block". That host keeps the sibling guards and + * the behaviour it had, and says so once in its own log. + * + * The verdict's `message` is the refusing door's own text, unredacted by the + * contract, so it names the policy's fields and comparands. It stays in the + * thrown message for the operator's log, and the `READ_SCOPE_COMPILE_FAILED` / + * 500 declaration withholds it from every response. The verdict's `code` and + * `status` describe a caller's mistake, and the scope is not the caller's, so + * neither travels either. + * + * A throw from the judge itself is a fault, not a verdict (the engine re-throws + * anything that is not a door diagnostic). It is raised in the same envelope: + * it read the scope and nothing the caller sent. + * + * ⛔ Not a catch around `executeAggregate`: the caller's own `where` is never + * asked here, and its refusals remain the caller's to read. + * + * @param scope the read scope, exactly as the provider returned it + * @param objectName the object the merge hands `executeAggregate` + * @param context the request context the merge forwards to `executeAggregate` + * @param host whatever carries the judge, called as its method: the + * strategy context (`DatasetScopedStrategyContext.judgeFilter`) at the + * strategy's merges, the plugin's bridge at the record-label fetch + */ +export function assertReadScopeAdmittedByEngine( + scope: unknown, + objectName: string, + context: EngineFilterJudgementOptions['context'], + host: { judgeFilter?: ReadScopeFilterJudge }, +): void { + if (typeof host.judgeFilter !== 'function') return; + let verdict: EngineFilterJudgement | undefined; + try { + verdict = host.judgeFilter(objectName, scope as Record, { operation: 'aggregate', context }); + } catch (e) { + throw readScopeCompileError( + `[read-scope-sql] read scope for "${objectName}" could not be judged by the engine's filter admission — ` + + `${e instanceof Error ? e.message : String(e)} (fail-closed).`, + ); + } + if (verdict === undefined || verdict.ok) return; + throw readScopeCompileError( + `[read-scope-sql] read scope for "${objectName}" is refused by the engine's own filter admission ` + + `(${verdict.code} / ${verdict.status}) — ${verdict.message} (fail-closed).`, + ); +} + /** * Compile a child node into its OWN bind buffer. * diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index 91aacf6cf5..1e2191e7d5 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -17,6 +17,7 @@ import { } from './filter-normalizer.js'; import { findCrossFieldComparand, isFieldReference } from '../comparand-shape.js'; import { + assertReadScopeAdmittedByEngine, assertReadScopeCannotVacate, assertReadScopeComparandsRunnable, assertReadScopePlaceholdersResolvable, @@ -676,6 +677,14 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // both defects logs the sentence the engine would have given; the wire // envelope is the same either way. Before the mark, like its siblings. assertReadScopePlaceholdersResolvable(scope, objectName, ctx.context); + // [#19995, ruling C] …and everything else the ENGINE's own admission + // refuses, asked of the engine itself (`IObjectQLEngine.judgeFilter`, + // through the host's hook) about the scope alone: the doors that read the + // object's field map, whose walks this package cannot run. Same verb and + // context as the `executeAggregate` below. Last, so a scope the guards + // above refuse keeps their sentence; before the mark, like them. A host + // that cannot answer is not judged, and keeps the guards above. + assertReadScopeAdmittedByEngine(scope, objectName, ctx.context, ctx as DatasetScopedStrategyContext); const scopeFilter = markFilterSubtreeProvenance(scope as Record, 'policy'); if (!userFilter) return scopeFilter; return { $and: [userFilter, scopeFilter] }; @@ -1166,6 +1175,10 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // [#19995] …and the placeholder half, as at `withReadScope`, with the // context forwarded to `executeAggregate` below. if (scope != null) assertReadScopePlaceholdersResolvable(scope, refObject, ctx.context); + // [#19995, ruling C] …and the engine's own admission, as at `withReadScope`. + if (scope != null) { + assertReadScopeAdmittedByEngine(scope, refObject, ctx.context, ctx as DatasetScopedStrategyContext); + } if (scope != null) markFilterSubtreeProvenance(scope, 'policy'); const filter = scope != null ? { $and: [idFilter, scope] } : idFilter; const rows = await ctx.executeAggregate(refObject, { diff --git a/packages/services/service-analytics/src/strategies/types.ts b/packages/services/service-analytics/src/strategies/types.ts index 58f1d18d91..8132bf10ae 100644 --- a/packages/services/service-analytics/src/strategies/types.ts +++ b/packages/services/service-analytics/src/strategies/types.ts @@ -16,7 +16,25 @@ export type { } from '@objectstack/spec/contracts'; import type { FilterCondition } from '@objectstack/spec/data'; -import type { StrategyContext } from '@objectstack/spec/contracts'; +import type { IObjectQLEngine, StrategyContext } from '@objectstack/spec/contracts'; + +/** + * [#19995] The engine's judge-only `where` admission, as the contract declares + * it: `IObjectQLEngine.judgeFilter` (#20157). Derived from the contract rather + * than re-declared, so a change to the member's signature reaches this + * package's hook as a compile error instead of as drift (#4251). + */ +type EngineJudgeFilter = NonNullable; + +/** + * [#19995] A host's answer to "would the ENGINE admit this `where`?": the + * engine's own verdict, or `undefined` when the host cannot answer. The + * arguments are `judgeFilter`'s own; only the `undefined` answer is added, + * which is this package's "cannot answer, do not block" tier. + */ +export type ReadScopeFilterJudge = ( + ...args: Parameters +) => ReturnType | undefined; /** * The semantic scope a compiled DATASET carries beside its Cube (#10298). @@ -95,4 +113,29 @@ export interface DatasetScopedStrategyContext extends StrategyContext { * know the hook keeps the behaviour it had — "cannot answer, do not block". */ sqlDialect?(objectName: string): string | undefined; + /** + * [#19995] The ENGINE's own `where` admission verdict for `objectName`, + * `IObjectQLEngine.judgeFilter` (#20157), or `undefined` when the host + * cannot answer (no data engine wired, an engine without the member, or an + * `executeAggregate` this package did not bridge to that engine). + * + * The one question the ObjectQL face cannot answer from a read scope alone. + * The engine refuses some scope shapes through doors that read the object's + * declared field map: a text operator over a field that never holds a + * string, a temporal comparand the field's storage rule cannot read, a + * filter on a virtual field or through a dotted path. Those refusals are + * the engine's 400, and their message names the policy. So + * `ObjectQLStrategy` asks the engine about the scope, alone, before it + * composes it with the caller's filter, and refuses in the withheld + * `READ_SCOPE_COMPILE_FAILED` / 500 (#5367). + * + * `AnalyticsService` answers it from `AnalyticsServiceConfig.judgeFilter`, + * which `AnalyticsServicePlugin` fills from the data engine its own + * `executeAggregate` auto-bridge executes on, so the judge is the executor. + * Declared HERE rather than on the spec's {@link StrategyContext} for the + * reason `declaredFieldType` is: nothing about it is an authorable surface, + * and a strategy that does not know the hook keeps the behaviour it had. + * "Cannot answer, do not block". + */ + judgeFilter?: ReadScopeFilterJudge; } From 079651a0499f6c6f520a8ba8ac6b08ad4a225c61 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:57:32 +0000 Subject: [PATCH 2/5] test(service-analytics): pin the engine-admission judgement at every engine-bound read-scope merge Refusal pins for the four field-map classes on the direct path, both cross-object merges and the plugin's record-label fetch; preservation pins for served scopes and for the caller's own refused where; and the unwired tiers (no judge, a custom executeAggregate, a data engine without the member), each keeping today's behaviour and logging once. Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude --- ...jectql-read-scope-engine-admission.test.ts | 415 ++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 packages/services/service-analytics/src/__tests__/objectql-read-scope-engine-admission.test.ts diff --git a/packages/services/service-analytics/src/__tests__/objectql-read-scope-engine-admission.test.ts b/packages/services/service-analytics/src/__tests__/objectql-read-scope-engine-admission.test.ts new file mode 100644 index 0000000000..a271a820c3 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/objectql-read-scope-engine-admission.test.ts @@ -0,0 +1,415 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19995, ruling C] Every engine-bound read-scope merge asks the ENGINE's own + * `where` admission (`IObjectQLEngine.judgeFilter`, #20157) about the scope + * alone, before composing it, and refuses in the withheld + * `READ_SCOPE_COMPILE_FAILED` / 500. A scope the engine serves is still served, + * and the caller's own `where` keeps the engine's answer. + * + * ## The ruling this holds + * + * #5367 (re-affirmed as #7598 Q2 = A), recorded in `read-scope-sql.ts`'s + * header: a read-scope refusal is a SERVER fault, and its message goes to the + * operator's log, never into a response. Four scope classes still reached the + * engine unjudged on the ObjectQL face: their doors read the object's declared + * field map (a text operator over a non-text field, an uninterpretable temporal + * comparand, a virtual field, a dotted path through a lookup). Each came back as + * the engine's 400, whose message the analytics HTTP doors relay. Ruling C gave + * the engine a judge-only admission member, and this package asks it. + * + * ## The composition is the plugin's, over a real engine + * + * `AnalyticsServicePlugin` is initialised with a real `ObjectQL` (over + * `SqliteWasmDriver`) as its `'data'` service. It auto-bridges + * `executeAggregate` to that engine and wires the judge to the same engine, + * which is the composition every shipped host boots (`os serve`, the verify + * harness). Only `queryCapabilities` is fixed, to the ObjectQL face; the + * NativeSQL face compiles the scope itself and never reaches these merges. + * + * ## The four merges + * + * - `withReadScope`, on the direct path and the cross-object base aggregate; + * - `resolveFkAttr`, the referenced object's scope on the cross-object path; + * - the plugin's record-label fetch, reached by the dataset door's sort-key + * label pass (an `order` on a lookup dimension), which propagates a refusal + * to the caller. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { declaredRefusalMessage, resolveThrownHttpError, serverFaultProvenance } from '@objectstack/types'; +import type { AnalyticsQuery } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { DatasetSchema } from '@objectstack/spec/ui'; + +import { AnalyticsService } from '../analytics-service.js'; +import { AnalyticsServicePlugin, type AnalyticsServicePluginOptions } from '../plugin.js'; + +const BASE = 'deal'; +const REF = 'account'; + +const BASE_FIELDS: Record> = { + id: { type: 'text', name: 'id' }, + region: { type: 'text', name: 'region' }, + owner: { type: 'text', name: 'owner' }, + amount: { type: 'number', name: 'amount' }, + closed_on: { type: 'date', name: 'closed_on' }, + score: { type: 'formula', name: 'score', expression: 'amount * 2', returnType: 'number' }, + account: { type: 'lookup', name: 'account', reference: REF }, +}; +const REF_FIELDS: Record> = { + id: { type: 'text', name: 'id' }, + name: { type: 'text', name: 'name' }, + rank: { type: 'number', name: 'rank' }, +}; + +const BASE_ROWS = [ + { id: 'd1', region: 'emea', owner: 'u_me', amount: 10, closed_on: '2026-01-10', account: 'acc_gold' }, + { id: 'd2', region: 'apac', owner: 'u_other', amount: 20, closed_on: '2026-02-10', account: 'acc_silver' }, + { id: 'd3', region: 'amer', owner: 'u_me', amount: 30, closed_on: '2026-03-10', account: 'acc_gold' }, +]; +const REF_ROWS = [ + { id: 'acc_gold', name: 'Gold Corp', rank: 1 }, + { id: 'acc_silver', name: 'Silver Ltd', rank: 2 }, +]; + +const dataset = DatasetSchema.parse({ + name: 'deal_admission', + label: 'Deal admission', + object: BASE, + include: ['account'], + dimensions: [ + { name: 'region', field: 'region', type: 'string' }, + { name: 'account_name', field: 'account.name', type: 'string' }, + { name: 'account', field: 'account', type: 'string' }, + ], + measures: [{ name: 'deal_count', aggregate: 'count' }], +}); + +/** A base-only query: `execute()`'s direct path, one `withReadScope` merge. */ +const DIRECT: AnalyticsQuery = { cube: 'deal_admission', dimensions: ['region'], measures: ['deal_count'] }; +/** A cross-object dimension: `executeCrossObject` + `resolveFkAttr`, two merges. */ +const CROSS: AnalyticsQuery = { cube: 'deal_admission', dimensions: ['account_name'], measures: ['deal_count'] }; +/** The dataset door's sort-key label pass: an `order` on a lookup dimension. */ +const LABEL_SORT = { dimensions: ['account'], measures: ['deal_count'], order: { account: 'asc' as const } }; + +const MEMBER: ExecutionContext = { userId: 'u_me' } as ExecutionContext; +const ALL_REGIONS = { region: { $in: ['emea', 'apac', 'amer'] } }; + +interface WireBearingError extends Error { + code?: unknown; + status?: unknown; +} + +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +/** + * The four classes, each a scope the engine refuses through a door that reads + * the object's field map, with the field its refusal names (the policy content + * the relayed 400 used to carry). + */ +const RESIDUE: Array<{ name: string; scope: Record; names: string }> = [ + { name: 'a text operator over a number field', scope: { amount: { $contains: 'RESTRICTED_TX' } }, names: 'amount' }, + { name: 'a temporal comparand the date field cannot read', scope: { closed_on: { $gte: 'RESTRICTED-NOT-A-DATE' } }, names: 'RESTRICTED-NOT-A-DATE' }, + { name: 'a filter on a virtual (formula) field', scope: { score: 42 }, names: 'score' }, + { name: 'a dotted path through a lookup', scope: { 'account.name': 'RESTRICTED_DP' }, names: 'account.name' }, +]; + +function assertWithheldServerFault(err: WireBearingError | undefined, names: string): void { + expect(err, 'the scope must be refused').toBeInstanceOf(Error); + expect(err?.code).toBe('READ_SCOPE_COMPILE_FAILED'); + expect(err?.status).toBe(500); + // The reads every analytics HTTP door takes before relaying prose: a + // producer-declared 5xx that is not a declared refusal ⇒ withheld. + expect(serverFaultProvenance(resolveThrownHttpError(err, 500))).toBe('declared'); + expect(declaredRefusalMessage(err)).toBeUndefined(); + // …and the detail is RELOCATED, not deleted: the operator's log keeps it. + expect(String(err?.message)).toContain(names); +} + +function assertCallersOwn(err: WireBearingError | undefined, code: string, names: string): void { + expect(err, "the caller's own where must be refused").toBeInstanceOf(Error); + expect(err?.code).toBe(code); + expect(err?.status).toBe(400); + expect(String(err?.message)).toContain(names); + expect(serverFaultProvenance(resolveThrownHttpError(err, 500))).toBeUndefined(); +} + +function pluginContext(services: Record) { + const registered: Record = {}; + const warn = vi.fn(); + return { + registered, + warn, + ctx: { + getService: (name: string) => services[name] ?? registered[name], + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + logger: { info() {}, warn, error() {}, debug() {} }, + }, + }; +} + +describe('[#19995, ruling C] a read scope the engine refuses is refused by the analytics ObjectQL face as a withheld server fault', () => { + let driver: SqliteWasmDriver; + let engine: ObjectQL; + /** Swapped per case; the `getReadScope` contract filled by hand. */ + let scopes: Record = {}; + + const getReadScope = (object: string) => (scopes[object] ?? undefined) as never; + + /** The plugin's own composition over the real engine, ObjectQL face only. */ + async function pluginService( + data: unknown, + options: Partial = {}, + ): Promise<{ service: AnalyticsService; warn: ReturnType }> { + const { ctx, registered, warn } = pluginContext({ data }); + await new AnalyticsServicePlugin({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + getReadScope, + ...options, + }).init(ctx as never); + const service = registered.analytics as AnalyticsService; + service.registerDataset(dataset); + return { service, warn }; + } + + let wired: AnalyticsService; + + beforeAll(async () => { + driver = new SqliteWasmDriver({ filename: ':memory:' }); + (driver as unknown as { logger: unknown }).logger = quiet; + await driver.initObjects([ + { name: BASE, fields: BASE_FIELDS }, + { name: REF, fields: REF_FIELDS }, + ] as never); + for (const row of BASE_ROWS) await driver.create(BASE, { ...row }); + for (const row of REF_ROWS) await driver.create(REF, { ...row }); + + engine = new ObjectQL({ logger: quiet }); + engine.registerDriver(driver as never, true); + await engine.init(); + engine.registerObject({ name: BASE, label: 'Deal', fields: BASE_FIELDS } as never); + engine.registerObject({ name: REF, label: 'Account', fields: REF_FIELDS } as never); + + wired = (await pluginService(engine)).service; + }); + + afterAll(async () => { + await driver?.disconnect?.(); + }); + + async function outcome( + run: () => Promise<{ rows: Record[] }>, + scopeFor: Record, + ): Promise<{ refusal?: WireBearingError; rows?: Record[] }> { + scopes = scopeFor; + try { + return { rows: (await run()).rows }; + } catch (e) { + return { refusal: e as WireBearingError }; + } finally { + scopes = {}; + } + } + + /** `{ dimensionValue: count }`, order-free. */ + function counts(rows: Record[] | undefined, dim: string): Record { + return Object.fromEntries((rows ?? []).map((r) => [String(r[dim]), Number(r.deal_count)])); + } + + describe('refused: the direct path (`execute()` → `withReadScope`)', () => { + for (const c of RESIDUE) { + it(`${c.name} → READ_SCOPE_COMPILE_FAILED / 500, prose withheld`, async () => { + const { refusal, rows } = await outcome(() => wired.query(DIRECT, MEMBER), { [BASE]: c.scope }); + expect(rows, 'a scope the engine refuses must not be served').toBeUndefined(); + assertWithheldServerFault(refusal, c.names); + }); + } + + it('a well-formed caller `where` beside a refused scope → still the scope’s withheld 500', async () => { + const { refusal } = await outcome( + () => wired.query({ ...DIRECT, where: { region: 'emea' } } as AnalyticsQuery, MEMBER), + { [BASE]: { amount: { $contains: 'RESTRICTED_TX' } } }, + ); + assertWithheldServerFault(refusal, 'amount'); + }); + }); + + describe('refused: the cross-object path', () => { + it('a refused BASE scope (`executeCrossObject` → `withReadScope`) → withheld 500', async () => { + const { refusal, rows } = await outcome(() => wired.query(CROSS, MEMBER), { + [BASE]: { closed_on: { $gte: 'RESTRICTED-NOT-A-DATE' } }, + }); + expect(rows).toBeUndefined(); + assertWithheldServerFault(refusal, 'RESTRICTED-NOT-A-DATE'); + }); + + it('a refused REFERENCED-object scope (`resolveFkAttr`) → withheld 500', async () => { + const { refusal, rows } = await outcome(() => wired.query(CROSS, MEMBER), { + [REF]: { rank: { $contains: 'RESTRICTED_RANK' } }, + }); + expect(rows).toBeUndefined(); + assertWithheldServerFault(refusal, 'rank'); + }); + }); + + describe('refused: the plugin’s record-label fetch (the dataset door’s sort-key label pass)', () => { + it('a referenced-object scope the engine refuses → withheld 500, not the engine’s 400', async () => { + const { refusal, rows } = await outcome(() => wired.queryDataset(dataset, LABEL_SORT, MEMBER), { + [REF]: { rank: { $contains: 'RESTRICTED_RANK' } }, + }); + expect(rows).toBeUndefined(); + assertWithheldServerFault(refusal, 'rank'); + }); + }); + + describe('served: what must NOT move', () => { + it('a well-formed scope is served with exactly its rows', async () => { + const { refusal, rows } = await outcome(() => wired.query(DIRECT, MEMBER), { + [BASE]: { region: { $in: ['emea', 'apac'] } }, + }); + expect(refusal).toBeUndefined(); + expect(counts(rows, 'region')).toEqual({ emea: 1, apac: 1 }); + }); + + it('a scope with a placeholder the forwarded context resolves is served with exactly its rows', async () => { + // The judge resolves `{current_user_id}` from the context the strategy + // forwards, as the engine does when it executes. A judgement that read + // another context, or none, would refuse this scope. + const { refusal, rows } = await outcome(() => wired.query(DIRECT, MEMBER), { + [BASE]: { owner: '{current_user_id}' }, + }); + expect(refusal).toBeUndefined(); + expect(counts(rows, 'region')).toEqual({ emea: 1, amer: 1 }); + }); + + it('a well-formed referenced-object scope buckets what it hides as `(restricted)`', async () => { + const { refusal, rows } = await outcome(() => wired.query(CROSS, MEMBER), { + [REF]: { rank: { $lte: 1 } }, + }); + expect(refusal).toBeUndefined(); + expect(counts(rows, 'account_name')).toEqual({ 'Gold Corp': 2, '(restricted)': 1 }); + }); + + it('a well-formed referenced-object scope on the label pass is served, sorted by label', async () => { + const { refusal, rows } = await outcome(() => wired.queryDataset(dataset, LABEL_SORT, MEMBER), { + [REF]: { rank: { $gte: 1 } }, + }); + expect(refusal).toBeUndefined(); + expect(rows?.map((r) => r.account)).toEqual(['Gold Corp', 'Silver Ltd']); + }); + }); + + describe("the caller's own `where` keeps the engine's answer (no blanket catch)", () => { + it('a text operator over a number field → INVALID_FILTER / 400 with its message', async () => { + const { refusal } = await outcome( + () => wired.query({ ...DIRECT, where: { amount: { $contains: '7' } } } as AnalyticsQuery, MEMBER), + { [BASE]: ALL_REGIONS }, + ); + assertCallersOwn(refusal, 'INVALID_FILTER', 'amount'); + }); + + it('a temporal comparand the date field cannot read → INVALID_FILTER / 400 with its message', async () => { + const { refusal } = await outcome( + () => wired.query({ ...DIRECT, where: { closed_on: { $gte: 'caller-not-a-date' } } } as AnalyticsQuery, MEMBER), + { [BASE]: ALL_REGIONS }, + ); + assertCallersOwn(refusal, 'INVALID_FILTER', 'caller-not-a-date'); + }); + + it('a filter on a virtual (formula) field → INVALID_FIELD / 400 with its message', async () => { + const { refusal } = await outcome( + () => wired.query({ ...DIRECT, where: { score: 7 } } as AnalyticsQuery, MEMBER), + { [BASE]: ALL_REGIONS }, + ); + assertCallersOwn(refusal, 'INVALID_FIELD', 'score'); + }); + }); + + describe('a host that cannot answer keeps today’s behaviour and says so once', () => { + it('AnalyticsService given no judgeFilter: the engine’s 400 as before, one warn across queries', async () => { + const warn = vi.fn(); + const service = new AnalyticsService({ + logger: { ...quiet, warn }, + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + getReadScope, + executeAggregate: async (objectName, options) => + (await engine.aggregate(objectName, { + where: options.filter, + groupBy: options.groupBy, + aggregations: options.aggregations?.map((a) => ({ function: a.method, field: a.field, alias: a.alias })), + context: options.context, + } as never)) as Record[], + }); + service.registerDataset(dataset); + + const first = await outcome(() => service.query(DIRECT, MEMBER), { [BASE]: { amount: { $contains: 'RESTRICTED_TX' } } }); + expect(first.refusal?.code).toBe('INVALID_FILTER'); + expect(first.refusal?.status).toBe(400); + // This package's own guards still refuse, withheld, what they can judge. + const guarded = await outcome(() => service.query(DIRECT, MEMBER), { [BASE]: { region: ['emea', 'apac'] } }); + assertWithheldServerFault(guarded.refusal, 'region'); + const served = await outcome(() => service.query(DIRECT, MEMBER), { [BASE]: { region: { $in: ['emea'] } } }); + expect(counts(served.rows, 'region')).toEqual({ emea: 1 }); + + const lines = warn.mock.calls.map((c) => String(c[0])).filter((l) => l.includes('judgeFilter')); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain(`"${BASE}"`); + }); + + it('a plugin host with its own executeAggregate is not wired to a guessed engine', async () => { + const { service } = await pluginService(engine, { + executeAggregate: async (objectName, options) => + (await engine.aggregate(objectName, { + where: options.filter, + groupBy: options.groupBy, + aggregations: options.aggregations?.map((a) => ({ function: a.method, field: a.field, alias: a.alias })), + context: options.context, + } as never)) as Record[], + }); + const residue = await outcome(() => service.query(DIRECT, MEMBER), { [BASE]: { amount: { $contains: 'RESTRICTED_TX' } } }); + expect(residue.refusal?.code).toBe('INVALID_FILTER'); + expect(residue.refusal?.status).toBe(400); + // The label fetch keeps this package's own guards on that host too. + const labelGuarded = await outcome(() => service.queryDataset(dataset, LABEL_SORT, MEMBER), { + [REF]: { name: ['Gold Corp', 'Silver Ltd'] }, + }); + assertWithheldServerFault(labelGuarded.refusal, 'name'); + }); + + it('a "data" engine without judgeFilter: the engine’s 400 as before, and the plugin warns once', async () => { + // A 'data' service that is not ObjectQL: the members the bridges read, and + // no judge. + const bare = { + aggregate: engine.aggregate.bind(engine), + getObject: engine.getObject.bind(engine), + }; + const { service, warn } = await pluginService(bare); + for (let i = 0; i < 2; i++) { + const { refusal } = await outcome(() => service.query(DIRECT, MEMBER), { [BASE]: { amount: { $contains: 'RESTRICTED_TX' } } }); + expect(refusal?.code).toBe('INVALID_FILTER'); + expect(refusal?.status).toBe(400); + } + const lines = warn.mock.calls.map((c) => String(c[0])).filter((l) => l.includes('judgeFilter')); + expect(lines).toHaveLength(1); + }); + }); + + it('a judge that throws (a fault, not a verdict) → the withheld 500, never its text on the wire', async () => { + const service = new AnalyticsService({ + logger: quiet, + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + getReadScope, + executeAggregate: async () => [], + judgeFilter: () => { + throw new Error('judge fault naming RESTRICTED_FAULT'); + }, + }); + service.registerDataset(dataset); + const { refusal } = await outcome(() => service.query(DIRECT, MEMBER), { [BASE]: { region: 'emea' } }); + assertWithheldServerFault(refusal, 'RESTRICTED_FAULT'); + }); +}); From fbb7ae0c87d7db29f3bddcf2214fef79c89f4544 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:11:11 +0000 Subject: [PATCH 3/5] test(service-analytics): pin the record-label fetch's placeholder guard on a host with no judge Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude --- .../__tests__/objectql-read-scope-engine-admission.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/services/service-analytics/src/__tests__/objectql-read-scope-engine-admission.test.ts b/packages/services/service-analytics/src/__tests__/objectql-read-scope-engine-admission.test.ts index a271a820c3..e459cb830e 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-read-scope-engine-admission.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-read-scope-engine-admission.test.ts @@ -378,6 +378,10 @@ describe('[#19995, ruling C] a read scope the engine refuses is refused by the a [REF]: { name: ['Gold Corp', 'Silver Ltd'] }, }); assertWithheldServerFault(labelGuarded.refusal, 'name'); + const labelPlaceholder = await outcome(() => service.queryDataset(dataset, LABEL_SORT, MEMBER), { + [REF]: { name: '{restricted_label_token}' }, + }); + assertWithheldServerFault(labelPlaceholder.refusal, 'restricted_label_token'); }); it('a "data" engine without judgeFilter: the engine’s 400 as before, and the plugin warns once', async () => { From f6dbebe5412b369ed9e10bbc04126f7b9b5221f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:16:02 +0000 Subject: [PATCH 4/5] chore(changeset): service-analytics patch for the engine-admission read-scope judgement Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude --- .changeset/19995-judge-filter-read-scope.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/19995-judge-filter-read-scope.md diff --git a/.changeset/19995-judge-filter-read-scope.md b/.changeset/19995-judge-filter-read-scope.md new file mode 100644 index 0000000000..80b94bd39b --- /dev/null +++ b/.changeset/19995-judge-filter-read-scope.md @@ -0,0 +1,19 @@ +--- +'@objectstack/service-analytics': patch +--- + +fix(service-analytics): the analytics ObjectQL face asks the engine's own filter admission about a row-level read scope before composing it, and refuses a scope the engine refuses with the policy withheld (#19995) + +Clause-②: no + +The ObjectQL execute face composes each object's read scope into the `where` it hands `engine.aggregate`. A scope the engine refuses through a door that reads the object's declared fields (a text operator over a field that never holds a string, a temporal comparand the field cannot read, a filter on a formula field, a dotted path through a lookup) came back as the engine's `INVALID_FILTER` or `INVALID_FIELD` / 400. Both analytics HTTP doors relay a 400's message, and that message named the policy's field and comparand. A read-scope refusal is a server fault whose detail belongs in the server log only (the #5367 ruling), so these scopes now answer `READ_SCOPE_COMPILE_FAILED` / 500 with the message withheld, like every other read-scope refusal on every analytics face. + +**How.** The analytics face asks the engine's judge-only admission, `IObjectQLEngine.judgeFilter`, about the scope on its own before composing it. It asks at every engine-bound merge: the direct aggregate, both merges on the cross-object path, and the record-label lookup behind a lookup dimension. The engine runs the same admission it runs when it executes and stops before any driver, so a scope the engine serves is still served. The caller's own `where` is not judged here and keeps the engine's answer, including its 400 and message. + +**Also fixed.** The record-label lookup a dataset runs to sort by a lookup dimension's labels composed the referenced object's scope with only the vacancy guard. A scope the engine refused there came back as its 400, with the policy in the message. It now runs the same checks as the other merges and answers the same withheld 500. + +**Wiring, and what a host without it keeps.** + +- `AnalyticsServicePlugin` wires the judge automatically when it bridges `executeAggregate` to the kernel's `data` engine itself. That is the default composition, so nothing changes in host code. +- A host that constructs `AnalyticsService` directly can pass the new optional `AnalyticsServiceConfig.judgeFilter`. It must be the judgement of the engine its `executeAggregate` runs on. +- A host with no judge (a custom `executeAggregate`, or a `data` engine without `judgeFilter`) keeps today's behaviour. The scope shapes this package judges itself are still refused with the policy withheld, and the rest reach the engine unjudged. It logs one `warn` saying so, with the remedy. From d9a1002f31fc710d6cb09f92a904badd8afe086a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:06:21 +0000 Subject: [PATCH 5/5] docs(changeset): scope the envelope sentence and the no-judge wiring note to what the diff does The driver-sql refusals that read the policy mark stay a withheld 400, so the 500 sentence names this package's own refusals only. The plugin's record-label lookup gains its comparand and placeholder checks on every host that uses it, so a host with no judge keeps today's behaviour everywhere except there. Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude --- .changeset/19995-judge-filter-read-scope.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/19995-judge-filter-read-scope.md b/.changeset/19995-judge-filter-read-scope.md index 80b94bd39b..04386eee4b 100644 --- a/.changeset/19995-judge-filter-read-scope.md +++ b/.changeset/19995-judge-filter-read-scope.md @@ -6,14 +6,14 @@ fix(service-analytics): the analytics ObjectQL face asks the engine's own filter Clause-②: no -The ObjectQL execute face composes each object's read scope into the `where` it hands `engine.aggregate`. A scope the engine refuses through a door that reads the object's declared fields (a text operator over a field that never holds a string, a temporal comparand the field cannot read, a filter on a formula field, a dotted path through a lookup) came back as the engine's `INVALID_FILTER` or `INVALID_FIELD` / 400. Both analytics HTTP doors relay a 400's message, and that message named the policy's field and comparand. A read-scope refusal is a server fault whose detail belongs in the server log only (the #5367 ruling), so these scopes now answer `READ_SCOPE_COMPILE_FAILED` / 500 with the message withheld, like every other read-scope refusal on every analytics face. +The ObjectQL execute face composes each object's read scope into the `where` it hands `engine.aggregate`. A scope the engine refuses through a door that reads the object's declared fields (a text operator over a field that never holds a string, a temporal comparand the field cannot read, a filter on a formula field, a dotted path through a lookup) came back as the engine's `INVALID_FILTER` or `INVALID_FIELD` / 400. Both analytics HTTP doors relay a 400's message, and that message named the policy's field, and for some classes its operator or comparand. A read-scope refusal is a server fault whose detail belongs in the server log only (the #5367 ruling), so these scopes now answer `READ_SCOPE_COMPILE_FAILED` / 500 with the message withheld, like the other refusals this package's read-scope compiler and guards raise. The `driver-sql` refusals that read the `'policy'` provenance mark are unchanged: they stay a withheld `INVALID_FILTER` / 400. **How.** The analytics face asks the engine's judge-only admission, `IObjectQLEngine.judgeFilter`, about the scope on its own before composing it. It asks at every engine-bound merge: the direct aggregate, both merges on the cross-object path, and the record-label lookup behind a lookup dimension. The engine runs the same admission it runs when it executes and stops before any driver, so a scope the engine serves is still served. The caller's own `where` is not judged here and keeps the engine's answer, including its 400 and message. -**Also fixed.** The record-label lookup a dataset runs to sort by a lookup dimension's labels composed the referenced object's scope with only the vacancy guard. A scope the engine refused there came back as its 400, with the policy in the message. It now runs the same checks as the other merges and answers the same withheld 500. +**Also fixed.** The record-label lookup `AnalyticsServicePlugin` supplies for a lookup dimension composed the referenced object's scope with only the vacancy guard. When a dataset sorted by that dimension's labels, a scope the engine refused there came back as its 400, with the policy in the message. When a dataset only displays the labels, a failed lookup is caught and the raw ids render, as before. The lookup now runs the same checks as the other merges and answers the same withheld 500. **Wiring, and what a host without it keeps.** - `AnalyticsServicePlugin` wires the judge automatically when it bridges `executeAggregate` to the kernel's `data` engine itself. That is the default composition, so nothing changes in host code. - A host that constructs `AnalyticsService` directly can pass the new optional `AnalyticsServiceConfig.judgeFilter`. It must be the judgement of the engine its `executeAggregate` runs on. -- A host with no judge (a custom `executeAggregate`, or a `data` engine without `judgeFilter`) keeps today's behaviour. The scope shapes this package judges itself are still refused with the policy withheld, and the rest reach the engine unjudged. It logs one `warn` saying so, with the remedy. +- A host with no judge (a custom `executeAggregate`, or a `data` engine without `judgeFilter`) keeps today's behaviour everywhere except the plugin's record-label lookup. The scope shapes this package judges itself are still refused with the policy withheld, and the rest reach the engine unjudged, as before. That lookup's comparand and placeholder checks are new for every host that uses it, with or without a judge. So on such a host a referenced-object scope that fails one of them now answers the withheld 500 at that lookup, where it used to reach the executor. The host logs one `warn` that no judge is wired, with the remedy.