From eab6d874b9f4a1241fd15f99da7662d8c7b7669f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:52:40 +0000 Subject: [PATCH 1/4] test(service-analytics): pin the placeholder read-scope refusal envelope on the ObjectQL face (measurement, before the fix) Test only, recorded before any source change. The new file pins that a read scope carrying a filter placeholder the engine cannot resolve is refused as READ_SCOPE_COMPILE_FAILED / 500 with the prose withheld, on the direct path, beside a caller where, and on both cross-object merges. At this commit: "Tests 8 failed | 10 passed (18)"; every refusal case receives the engine's FILTER_TOKEN_UNKNOWN or FILTER_TOKEN_UNRESOLVED / 400. The ten controls are green (a resolvable placeholder is served; a well-formed scope is served; the caller's own where keeps its placeholder, text-operator and temporal 400s with their messages; four driver-sql door classes keep their withheld 400). Measurement on base 3557f85fa5 (980bc05e5b plus one driver-turso README commit). Scratch probe, not committed: a real ObjectQL over SqliteWasmDriver, AnalyticsService on the ObjectQL face only, the scope from getReadScope. HTTP legs through the real routes: the runtime dispatcher POST /api/v1/analytics/query and the rest POST /analytics/dataset/query. The direct service answered the same code and status on every row. class (layer) | both HTTP doors | policy content in body text operator on a non-text field (engine door) | 400 INVALID_FILTER | yes: field, operator temporal comparand it cannot read (engine door) | 400 INVALID_FILTER | yes: field, comparand unknown filter placeholder (engine token resolver) | 400 FILTER_TOKEN_UNKNOWN | yes: the token known placeholder, context cannot resolve (resolver) | 400 FILTER_TOKEN_UNRESOLVED| yes: the token filter on a virtual (formula) field (engine door) | 400 INVALID_FIELD | yes: field dotted path through a lookup (engine door) | 400 INVALID_FIELD | yes: field, path column the object does not have (driver-sql) | 400 INVALID_FILTER | no (withheld) retired operator (driver-sql) | 400 INVALID_FILTER | no (withheld) unknown operator (driver-sql) | 400 INVALID_FILTER | no (withheld) combinator with a non-array operand (driver-sql) | 400 INVALID_FILTER | no (withheld) non-boolean $null (driver-sql) | 400 INVALID_FILTER | no (withheld) non-boolean $exists (driver-sql) | 400 INVALID_FILTER | no (withheld) well-formed scope (control) | 200, the scoped rows | - caller where: text operator / temporal / placeholder | 400, the caller's message | the caller's own Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- ...tql-read-scope-placeholder-refusal.test.ts | 352 ++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 packages/services/service-analytics/src/__tests__/objectql-read-scope-placeholder-refusal.test.ts 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..1e9005424f1 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/objectql-read-scope-placeholder-refusal.test.ts @@ -0,0 +1,352 @@ +// 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. + */ + 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'] }, + { name: 'a retired operator', scope: { stage: { $regex: 'RESTRICTED_RX' } }, secrets: ['RESTRICTED_RX'] }, + { 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); + }); + } + }); +}); From a33711d448e4c4d00e5417de95f0f6ee632081ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:55:39 +0000 Subject: [PATCH 2/4] fix(service-analytics): the ObjectQL face refuses a read scope carrying a placeholder the engine cannot resolve in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope assertReadScopePlaceholdersResolvable (read-scope-sql.ts) runs the engine's own placeholder resolver, resolveFilterTokens from @objectstack/core, on the scope alone with filterTokenContextFrom over the context forwarded to executeAggregate, and re-raises its two refusals through readScopeCompileError. Called at both engine-bound merges (withReadScope, resolveFkAttr), after the comparand faces and before the 'policy' mark. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .changeset/19995-engine-door-scope-residue.md | 13 +++ .../service-analytics/src/read-scope-sql.ts | 91 ++++++++++++++++++- .../src/strategies/objectql-strategy.ts | 13 +++ 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 .changeset/19995-engine-door-scope-residue.md 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/read-scope-sql.ts b/packages/services/service-analytics/src/read-scope-sql.ts index 34705e267db..94c17d6a061 100644 --- a/packages/services/service-analytics/src/read-scope-sql.ts +++ b/packages/services/service-analytics/src/read-scope-sql.ts @@ -5,6 +5,16 @@ 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, + UnknownFilterTokenError, + UnresolvedFilterTokenError, + 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 +471,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 +771,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 +797,62 @@ 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. + * + * Only the resolver's two refusals are re-raised in the envelope, by type. + * Anything else it throws is not about the scope — an unusable time zone on + * the request, say — and propagates unchanged, as the engine would throw it. + * + * @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) { + if (!(e instanceof UnknownFilterTokenError) && !(e instanceof UnresolvedFilterTokenError)) throw e; + throw readScopeCompileError( + `[read-scope-sql] read scope for "${objectName}" carries a filter placeholder the engine cannot resolve — ` + + `${e.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 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, { From 89775342caa7ab83b46203f1a48adea157a7d626 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:57:45 +0000 Subject: [PATCH 3/4] fix(service-analytics): re-raise every resolver throw on a read scope in the one envelope The resolver reads the scope and the request's token context and nothing else, and an unusable time zone falls back to UTC rather than throwing (measured), so a type filter guarded no reachable branch. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../service-analytics/src/read-scope-sql.ts | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/services/service-analytics/src/read-scope-sql.ts b/packages/services/service-analytics/src/read-scope-sql.ts index 94c17d6a061..d6526437ede 100644 --- a/packages/services/service-analytics/src/read-scope-sql.ts +++ b/packages/services/service-analytics/src/read-scope-sql.ts @@ -8,13 +8,7 @@ import { assertListComparandShapes, normalizeFilterComparandTypes } from '@objec // [#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, - UnknownFilterTokenError, - UnresolvedFilterTokenError, - type ExecutionContextLike, -} from '@objectstack/core'; +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'; @@ -826,9 +820,12 @@ export function assertReadScopeComparandsRunnable(scope: unknown, objectName: st * scope is served; the resolved tree is discarded, and the engine resolves the * original as it always has. * - * Only the resolver's two refusals are re-raised in the envelope, by type. - * Anything else it throws is not about the scope — an unusable time zone on - * the request, say — and propagates unchanged, as the engine would throw it. + * 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 @@ -845,10 +842,9 @@ export function assertReadScopePlaceholdersResolvable( try { resolveFilterTokens(scope, filterTokenContextFrom(context)); } catch (e) { - if (!(e instanceof UnknownFilterTokenError) && !(e instanceof UnresolvedFilterTokenError)) throw e; throw readScopeCompileError( `[read-scope-sql] read scope for "${objectName}" carries a filter placeholder the engine cannot resolve — ` + - `${e.message} (fail-closed).`, + `${e instanceof Error ? e.message : String(e)} (fail-closed).`, ); } } From 8df415774666800ca9eddb91b1c2e3b8272957e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 01:00:15 +0000 Subject: [PATCH 4/4] test(service-analytics): the retired-operator control names what that door's full text carries Its comparand is never in that door's text, so the old secret could not turn the row red under a mis-stamped scope (measured: 3 of 4 driver rows went red when the scope was stamped 'author', this one stayed green). Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../objectql-read-scope-placeholder-refusal.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 index 1e9005424f1..c9851f5fd3b 100644 --- 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 @@ -331,11 +331,14 @@ describe('[#19995] ObjectQL execute face — a read scope carrying a placeholder /** * 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. + * 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'] }, - { name: 'a retired operator', scope: { stage: { $regex: 'RESTRICTED_RX' } }, secrets: ['RESTRICTED_RX'] }, + // 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'] }, ];