diff --git a/.changeset/19995-engine-door-scope-residue.md b/.changeset/19995-engine-door-scope-residue.md new file mode 100644 index 00000000000..942f2d008dd --- /dev/null +++ b/.changeset/19995-engine-door-scope-residue.md @@ -0,0 +1,13 @@ +--- +'@objectstack/service-analytics': patch +--- + +fix(service-analytics): the ObjectQL execute face refuses a read scope carrying a filter placeholder the engine cannot resolve in the withheld `READ_SCOPE_COMPILE_FAILED` / 500 envelope, not the engine's `FILTER_TOKEN_UNKNOWN` / `FILTER_TOKEN_UNRESOLVED` / 400 (#19995) + +Clause-②: no + +A row-level read scope carrying an unknown filter placeholder, or a known one the request has no value for, used to reach `engine.aggregate` composed with the caller's own filter. The engine's placeholder resolver then refused it with a 400. A 4xx's message is relayed to the caller, and this one named the policy's placeholder. + +The ObjectQL strategy now runs the engine's own placeholder resolver on the scope by itself, with the token context the engine builds, 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. It does this before composing the scope. 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. + +Unchanged: which scopes are served. A placeholder the engine resolves, such as `{current_user_id}` for a signed-in caller, is resolved the same way here, and the scope is served. The caller's own `where` keeps its `FILTER_TOKEN_UNKNOWN` / 400 with its message. diff --git a/packages/services/service-analytics/src/__tests__/objectql-read-scope-placeholder-refusal.test.ts b/packages/services/service-analytics/src/__tests__/objectql-read-scope-placeholder-refusal.test.ts new file mode 100644 index 00000000000..c9851f5fd3b --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/objectql-read-scope-placeholder-refusal.test.ts @@ -0,0 +1,355 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19995] The ObjectQL execute face refuses a read scope carrying a filter + * placeholder the engine cannot resolve in the withheld + * `READ_SCOPE_COMPILE_FAILED` / 500 envelope — never as the engine's + * `FILTER_TOKEN_UNKNOWN` / `FILTER_TOKEN_UNRESOLVED` / 400. + * + * ## 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. `objectql-read-scope-refusal-envelope.test.ts` + * pins that for the comparand classes; this file pins it for the placeholder + * class, the one engine door on this path whose walk this package can reach + * today (`resolveFilterTokens`, `@objectstack/core` — the function the + * engine's `resolveWhereTokens` calls). + * + * ## What was measured before the fix + * + * The engine resolves placeholders on the COMPOSED `where` — after + * `withReadScope` has `$and`-ed the scope into it — so a scope carrying + * `{some_token}` came back as the engine's 400, and the HTTP doors relay a + * 4xx's message: the policy's placeholder, and with it what the policy + * compares against. The branch's first commit carries the table. + * + * ## The judgement is the engine's, so the accept set does not move + * + * The scope is judged ALONE at the merge boundary with the same resolver and + * the same token context the engine builds (`filterTokenContextFrom` over the + * context `executeAggregate` forwards). A placeholder the engine resolves is + * resolved here too, so the scope is served — the control below pins that, + * and it is the pin that goes red if the judgement ever reads a context the + * engine does not. + * + * ⛔ Not a catch around `executeAggregate`: the caller's own `where` keeps its + * placeholder refusal, answered by the analytics `where` door with its + * message. The controls pin that, and the caller's own text-operator and + * temporal refusals, which are the engine's and stay the caller's to read. + * + * ## The engine is real, because the refusal under test is the engine's + * + * A real `ObjectQL` over `SqliteWasmDriver` (a `SqlDriver`) stands behind + * `executeAggregate`, bridged the way `AnalyticsServicePlugin`'s auto-bridge + * bridges it. Every read scope is the `getReadScope` contract filled by hand. + */ + +import { describe, it, expect, beforeAll, afterAll } 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 { FilterCondition } from '@objectstack/spec/data'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { DatasetSchema } from '@objectstack/spec/ui'; + +import { AnalyticsService, type AnalyticsServiceConfig } from '../analytics-service.js'; +import { compileDataset } from '../dataset-compiler.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' }, + account: { type: 'text', name: 'account' }, + amount: { type: 'number', name: 'amount' }, + closed_on: { type: 'date', name: 'closed_on' }, + stage: { type: 'text', name: 'stage' }, +}; +const REF_FIELDS: Record> = { + id: { type: 'text', name: 'id' }, + tier: { type: 'text', name: 'tier' }, +}; + +const BASE_ROWS = [ + { id: 'd1', region: 'emea', owner: 'u_me', account: 'acc_gold', amount: 10, closed_on: '2026-01-10', stage: 'open' }, + { id: 'd2', region: 'apac', owner: 'u_other', account: 'acc_silver', amount: 20, closed_on: '2026-02-10', stage: 'won' }, + { id: 'd3', region: 'amer', owner: 'u_me', account: 'acc_gold', amount: 30, closed_on: '2026-03-10', stage: 'lost' }, +]; +const REF_ROWS = [ + { id: 'acc_gold', tier: 'gold' }, + { id: 'acc_silver', tier: 'silver' }, +]; + +const dataset = DatasetSchema.parse({ + name: 'deal_pipeline', + label: 'Deal pipeline', + object: BASE, + include: [REF], + dimensions: [ + { name: 'region', field: 'region', type: 'string' }, + { name: 'account_tier', field: 'account.tier', type: 'string' }, + ], + measures: [{ name: 'deal_count', aggregate: 'count' }], +}); + +/** A base-only query: `execute()`'s direct path, one `withReadScope` merge. */ +const DIRECT: AnalyticsQuery = { cube: 'deal_pipeline', dimensions: ['region'], measures: ['deal_count'] }; +/** A cross-object dimension: `executeCrossObject` + `resolveFkAttr`, two merges. */ +const CROSS: AnalyticsQuery = { cube: 'deal_pipeline', dimensions: ['account_tier'], measures: ['deal_count'] }; + +/** A caller with a user and no active organization. */ +const MEMBER: ExecutionContext = { userId: 'u_me' } as ExecutionContext; + +interface WireBearingError extends Error { + code?: unknown; + status?: unknown; +} + +const quiet = { + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return quiet; + }, +}; + +/** + * Each row: a scope carrying a placeholder the engine's resolver refuses, and + * the policy content its refusal text names. + */ +const REFUSED_SCOPES: Array<{ name: string; scope: unknown; secrets: string[] }> = [ + { + name: 'an unknown placeholder in the equality slot', + scope: { owner: '{restricted_token}' }, + secrets: ['restricted_token'], + }, + { + name: 'a near-miss spelling (the resolver suggests the real token)', + scope: { owner: '{current_user}' }, + secrets: ['current_user'], + }, + { + name: 'a brace-wrapped value that is not a token name', + scope: { stage: { $ne: '{TODAY()}' } }, + secrets: ['TODAY()'], + }, + { + name: 'an unknown placeholder as a list member, nested in an $or beside a well-formed arm', + scope: { $or: [{ owner: 'u_me' }, { region: { $in: ['emea', '{restricted_region}'] } }] }, + secrets: ['restricted_region'], + }, + { + name: 'a known placeholder the request context cannot resolve', + scope: { account: '{current_org_id}' }, + secrets: ['current_org_id'], + }, +]; + +function assertWithheldServerFault(err: WireBearingError | undefined, secrets: 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 still has it. + for (const secret of secrets) expect(String(err?.message)).toContain(secret); +} + +describe('[#19995] ObjectQL execute face — a read scope carrying a placeholder the engine refuses is a withheld server fault', () => { + let driver: SqliteWasmDriver; + let engine: ObjectQL; + let service: AnalyticsService; + /** Swapped per case; the `getReadScope` contract filled by hand. */ + let scopes: Record = {}; + + 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); + + const compiled = compileDataset(dataset); + const config: AnalyticsServiceConfig = { + cubes: [compiled.cube], + logger: quiet, + // ObjectQL only — the face `compileScopedFilterToSql` never sees. + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + getAllowedRelationships: () => compiled.allowedRelationships, + getReadScope: (object: string) => (scopes[object] ?? undefined) as FilterCondition | undefined, + // The auto-bridge's own mapping (`plugin.ts`): `filter` → `where`, + // `method` → `function`, and the request context forwarded — the context + // the engine's own placeholder resolver reads. + 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, + ...(a.filter ? { filter: a.filter } : {}), + })), + timezone: options.timezone, + context: options.context, + } as never)) as Record[], + }; + service = new AnalyticsService(config); + }); + + afterAll(async () => { + await driver?.disconnect?.(); + }); + + async function outcome( + query: AnalyticsQuery, + scopeFor: Record, + context: ExecutionContext = MEMBER, + ): Promise<{ refusal?: WireBearingError; rows?: Record[] }> { + scopes = scopeFor; + try { + const result = await service.query(query, context); + return { rows: result.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('the direct path (`execute()` → `withReadScope`)', () => { + for (const c of REFUSED_SCOPES) { + it(`${c.name} → READ_SCOPE_COMPILE_FAILED / 500, prose withheld`, async () => { + const { refusal, rows } = await outcome(DIRECT, { [BASE]: c.scope }); + expect(rows, 'a scope the engine cannot resolve must not be served').toBeUndefined(); + assertWithheldServerFault(refusal, c.secrets); + }); + } + + it('a well-formed caller `where` beside a refused scope → still the scope’s withheld 500', async () => { + const { refusal } = await outcome( + { ...DIRECT, where: { region: 'emea' } } as AnalyticsQuery, + { [BASE]: { owner: '{restricted_token}' } }, + ); + assertWithheldServerFault(refusal, ['restricted_token']); + }); + }); + + describe('the cross-object path', () => { + it('a refused BASE scope (`executeCrossObject` → `withReadScope`) → withheld 500', async () => { + const { refusal, rows } = await outcome(CROSS, { [BASE]: { owner: '{restricted_token}' } }); + expect(rows).toBeUndefined(); + assertWithheldServerFault(refusal, ['restricted_token']); + }); + + it('a refused REFERENCED-object scope (`resolveFkAttr`) → withheld 500', async () => { + const { refusal, rows } = await outcome(CROSS, { [REF]: { tier: '{restricted_tier}' } }); + expect(rows).toBeUndefined(); + assertWithheldServerFault(refusal, ['restricted_tier']); + }); + + it('CONTROL: a referenced-object scope with a placeholder the context resolves is served', async () => { + const { refusal, rows } = await outcome(CROSS, { [REF]: { id: { $ne: '{current_user_id}' } } }); + expect(refusal).toBeUndefined(); + expect(counts(rows, 'account_tier')).toEqual({ gold: 2, silver: 1 }); + }); + }); + + describe('controls — what must NOT move', () => { + it('a placeholder the request context resolves is served with exactly its rows', async () => { + // The engine resolves `{current_user_id}` to `u_me` from the forwarded + // context. A judgement that read another context — or none — would + // refuse this scope, which the engine serves. + const { refusal, rows } = await outcome(DIRECT, { [BASE]: { owner: '{current_user_id}' } }); + expect(refusal).toBeUndefined(); + expect(counts(rows, 'region')).toEqual({ emea: 1, amer: 1 }); + }); + + it('a well-formed scope with no placeholder is served with exactly its rows', async () => { + const { refusal, rows } = await outcome(DIRECT, { [BASE]: { stage: { $in: ['open', 'won'] } } }); + expect(refusal).toBeUndefined(); + expect(counts(rows, 'region')).toEqual({ emea: 1, apac: 1 }); + }); + + it('the caller’s own `where` with an unknown placeholder stays FILTER_TOKEN_UNKNOWN / 400 with its message', async () => { + const { refusal } = await outcome( + { ...DIRECT, where: { owner: '{caller_token}' } } as AnalyticsQuery, + { [BASE]: { stage: { $in: ['open', 'won', 'lost'] } } }, + ); + expect(refusal).toBeInstanceOf(Error); + expect(refusal?.code).toBe('FILTER_TOKEN_UNKNOWN'); + expect(refusal?.status).toBe(400); + expect(String(refusal?.message)).toContain('caller_token'); + expect(serverFaultProvenance(resolveThrownHttpError(refusal, 500))).toBeUndefined(); + }); + + it('the caller’s own `where` with a text operator on a number field stays INVALID_FILTER / 400 with its message', async () => { + const { refusal } = await outcome( + { ...DIRECT, where: { amount: { $contains: '7' } } } as AnalyticsQuery, + { [BASE]: { stage: { $in: ['open', 'won', 'lost'] } } }, + ); + expect(refusal).toBeInstanceOf(Error); + expect(refusal?.code).toBe('INVALID_FILTER'); + expect(refusal?.status).toBe(400); + expect(String(refusal?.message)).toContain('amount'); + }); + + it('the caller’s own `where` with a temporal comparand the platform cannot read stays INVALID_FILTER / 400 with its message', async () => { + const { refusal } = await outcome( + { ...DIRECT, where: { closed_on: { $gte: 'caller-not-a-date' } } } as AnalyticsQuery, + { [BASE]: { stage: { $in: ['open', 'won', 'lost'] } } }, + ); + expect(refusal).toBeInstanceOf(Error); + expect(refusal?.code).toBe('INVALID_FILTER'); + expect(refusal?.status).toBe(400); + expect(String(refusal?.message)).toContain('closed_on'); + expect(String(refusal?.message)).toContain('caller-not-a-date'); + }); + + /** + * The four `driver-sql` refusal doors that read the provenance mark: a + * scope refused there keeps `INVALID_FILTER` / 400 with no policy content + * in the message. This card does not move them. Each row's secrets are + * what that door's full text names, so a scope that reached the driver + * vouched as the caller's own would turn the row red. + */ + const DRIVER_WITHHELD: Array<{ name: string; scope: unknown; secrets: string[] }> = [ + { name: 'a column the object does not have', scope: { restricted_col: 'x' }, secrets: ['restricted_col'] }, + // The retired-operator text names the operator and the field, not the comparand. + { name: 'a retired operator', scope: { stage: { $regex: 'RESTRICTED_RX' } }, secrets: ['$regex', '"stage"'] }, + { name: 'a combinator with a non-array operand', scope: { $or: { stage: 'RESTRICTED_CB' } }, secrets: ['RESTRICTED_CB'] }, + { name: 'a non-boolean $null', scope: { stage: { $null: 'RESTRICTED_NL' } }, secrets: ['RESTRICTED_NL'] }, + ]; + for (const c of DRIVER_WITHHELD) { + it(`driver-sql door, ${c.name} → still INVALID_FILTER / 400 with the policy withheld`, async () => { + const { refusal } = await outcome(DIRECT, { [BASE]: c.scope }); + expect(refusal).toBeInstanceOf(Error); + expect(refusal?.code).toBe('INVALID_FILTER'); + expect(refusal?.status).toBe(400); + for (const secret of c.secrets) expect(String(refusal?.message)).not.toContain(secret); + }); + } + }); +}); diff --git a/packages/services/service-analytics/src/read-scope-sql.ts b/packages/services/service-analytics/src/read-scope-sql.ts index 34705e267db..d6526437ede 100644 --- a/packages/services/service-analytics/src/read-scope-sql.ts +++ b/packages/services/service-analytics/src/read-scope-sql.ts @@ -5,6 +5,10 @@ import type { FilterCondition } from '@objectstack/spec/data'; // at the ObjectQL merge sites by {@link assertReadScopeComparandsRunnable}, and // [#20018] at the end of {@link compileScopedFilterToSql} by the same function. import { assertListComparandShapes, normalizeFilterComparandTypes } from '@objectstack/spec/data'; +// [#19995] The engine's own placeholder resolver (`ObjectQL.resolveWhereTokens` +// is a call to it), run on a read scope, alone, at the ObjectQL merge sites by +// {@link assertReadScopePlaceholdersResolvable}. +import { filterTokenContextFrom, resolveFilterTokens, type ExecutionContextLike } from '@objectstack/core'; import type { RegisteredErrorCode } from '@objectstack/spec/api'; import { type LikeShape } from './like-pattern.js'; import { textMatchPredicateSql, normalizeSqlDialect } from './text-match-sql.js'; @@ -461,6 +465,26 @@ import { * ordering, for the same door-distinguishable log), and the faces add only what * would otherwise have been lowered. The envelope is this module's one * envelope; nothing here re-argues the rulings the faces carry. + * + * ## …and a placeholder the engine cannot resolve, on the engine path (#19995) + * + * The engine resolves `{placeholder}` filter values on the COMPOSED `where`, + * after `withReadScope` has `$and`-ed the scope into it, so a scope carrying an + * unknown placeholder, or a known one the request context has no value for, + * came back as the engine's `FILTER_TOKEN_UNKNOWN` / `FILTER_TOKEN_UNRESOLVED` + * / 400: the token was relayed, and with it what the policy compares against. + * {@link assertReadScopePlaceholdersResolvable} runs the engine's own resolver + * on the scope alone, with the token context the engine builds, at the same + * 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. */ const IDENT = /^[a-z_][a-z0-9_]*$/i; @@ -741,7 +765,10 @@ 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. + * 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. * * 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 @@ -764,6 +791,64 @@ export function assertReadScopeComparandsRunnable(scope: unknown, objectName: st } } +/** + * [#19995] Refuse, in this module's envelope, a read scope carrying a filter + * placeholder the ENGINE's resolver refuses — judged on the scope ALONE, at + * the two engine-bound merges, before it is composed with the caller's filter. + * + * The engine resolves `{placeholder}` values once per verb, on the whole + * `where` it is handed (`ObjectQL.resolveWhereTokens`, a call to + * `resolveFilterTokens`). On the ObjectQL face that `where` already carries + * the scope, so an unknown placeholder (`FILTER_TOKEN_UNKNOWN`) or a known one + * the request has no value for (`FILTER_TOKEN_UNRESOLVED`) came back as a 400 + * whose message — the token, and a suggestion for a near miss — the HTTP doors + * relay. The caller's own `where` never reaches that door with a placeholder + * still in it: the analytics service resolves the query's own positions first, + * with its own 400 and message, which is why this is a judgement of the scope + * and ⛔ never a catch around `executeAggregate`. + * + * ## Why the refusal set is exactly the engine's + * + * Same function, same inputs. `resolveFilterTokens` classifies every string + * with the spec's `classifyFilterToken` and resolves what it recognises; its + * verdict on one string depends on nothing else in the tree, so the scope + * alone answers exactly as the scope inside `{ $and: [userFilter, scope] }` + * does. The token context is built by the engine's own bridge, + * `filterTokenContextFrom`, over the context the strategy forwards to + * `executeAggregate` — the one the engine reads when it resolves. A + * placeholder the engine resolves is therefore resolved here too, and the + * scope is served; the resolved tree is discarded, and the engine resolves the + * original as it always has. + * + * Whatever the resolver throws here is re-raised in the envelope. It read the + * scope and the request's token context and nothing else, and the engine + * throws the same thing for the same scope under the same request. Today that + * is its two refusals: an unknown placeholder, and a known one the context + * has no value for. (An unusable time zone is not a third: the calendar maths + * falls back to UTC rather than throwing.) + * + * @param scope the `StrategyContext.getReadScope` output, exactly as returned + * @param objectName the object the scope was requested for — for the operator's + * log only; withheld from the response by the `READ_SCOPE_COMPILE_FAILED` / + * 500 declaration, like every message in this module. + * @param context the request context the strategy forwards to + * `executeAggregate` with this scope + */ +export function assertReadScopePlaceholdersResolvable( + scope: unknown, + objectName: string, + context: ExecutionContextLike | undefined, +): void { + try { + resolveFilterTokens(scope, filterTokenContextFrom(context)); + } catch (e) { + throw readScopeCompileError( + `[read-scope-sql] read scope for "${objectName}" carries a filter placeholder the engine cannot resolve — ` + + `${e instanceof Error ? e.message : String(e)} (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 b3d329cead7..82fa3ef77de 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -19,6 +19,7 @@ import { findCrossFieldComparand, isFieldReference } from '../comparand-shape.js import { assertReadScopeCannotVacate, assertReadScopeComparandsRunnable, + assertReadScopePlaceholdersResolvable, compileScopedFilterToSql, } from '../read-scope-sql.js'; import { nonTextColumnResolver, textOperatorPolarity } from '../non-text-column.js'; @@ -662,6 +663,15 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // Judged on the scope ALONE, for the attribution reason above; the // caller's own `where` keeps reaching the engine's doors and its 400. assertReadScopeComparandsRunnable(scope, objectName); + // [#19995] …and the placeholder half. The engine resolves `{…}` values on + // the COMPOSED `where`, so a placeholder in the scope it cannot resolve + // came back as its `FILTER_TOKEN_*` / 400 with the token relayed. Judged + // with the engine's own resolver and the context the caller forwards to + // `executeAggregate` alongside this filter. After the comparand faces + // because the engine resolves after its lowering doors, so a scope with + // 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); const scopeFilter = markFilterSubtreeProvenance(scope as Record, 'policy'); if (!userFilter) return scopeFilter; return { $and: [userFilter, scopeFilter] }; @@ -1149,6 +1159,9 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // engine refuses would come back as its relayed 400, naming the referenced // object's policy. Before the mark, for the same reason as the line above. if (scope != null) assertReadScopeComparandsRunnable(scope, refObject); + // [#19995] …and the placeholder half, as at `withReadScope`, with the + // context forwarded to `executeAggregate` below. + if (scope != null) assertReadScopePlaceholdersResolvable(scope, refObject, ctx.context); if (scope != null) markFilterSubtreeProvenance(scope, 'policy'); const filter = scope != null ? { $and: [idFilter, scope] } : idFilter; const rows = await ctx.executeAggregate(refObject, {