Repository navigation
fix(service-analytics)!: the NativeSQL face and the /analytics/sql echo resolve a read-scope placeholder with the caller's context, as the ObjectQL face does (#20075) - #20111
Conversation
…nt before the fix Measurement only; no source change. Taken on this branch at its base (origin/main a8bcce6) before any fix, with throwaway harnesses that are not committed: a real sql.js engine (driver-sqlite-wasm) holding deal rows d1 (owner u_me, closed_on 2020-01-10), d2 (u_other, 2099-02-10), d3 (u_me, 2020-03-10), d4 (owner NULL, closed_on NULL) and joined account rows acc_gold (owner u_me, 2020-01-01), acc_silver (u_other, 2099-01-01); a real ObjectQL engine behind the ObjectQL face; the echo's SQL run on the same database. Every face is driven through AnalyticsService (query / generateSql) with the caller context { userId: 'u_me' } and no tenantId, so ctx.context is the one the service binds. scope | compileScopedFilterToSql binds | native base | native joined hop | echo (SQL run) | ObjectQL base (control) | ObjectQL hop (control) owner = {current_user_id} | the literal | none | none | none | d1 d3 | gold=2 (restricted)=2 owner $ne {current_user_id} | the literal | d1 d2 d3 d4 | gold=2 silver=2 | d1 d2 d3 d4 | d2 d4 | silver=2 (restricted)=2 owner $in [{current_user_id}, u_nobody] | the literal + u_nobody | none | none | none | d1 d3 | gold=2 (restricted)=2 owner = an unknown {token} | the literal | none, served | none, served | none, served | READ_SCOPE_COMPILE_FAILED/500 | READ_SCOPE_COMPILE_FAILED/500 owner = {current_org_id}, no tenantId (FILTER_TOKEN_UNRESOLVED) | the literal | none, served | none, served | none, served | READ_SCOPE_COMPILE_FAILED/500 | READ_SCOPE_COMPILE_FAILED/500 closed_on $lt {today} (date macro) | the literal | d1 d2 d3 | gold=2 silver=2 | d1 d2 d3 | d1 d3 | gold=2 (restricted)=2 CONTROL owner = u_me | u_me | d1 d3 | gold=2 | d1 d3 | d1 d3 | gold=2 (restricted)=2 CONTROL owner $ne u_me | u_me | d2 d4 | silver=2 | d2 d4 | d2 d4 | silver=2 (restricted)=2 CONTROL owner $in [u_me, u_nobody] | u_me, u_nobody | d1 d3 | gold=2 | d1 d3 | d1 d3 | gold=2 (restricted)=2 CONTROL closed_on $lt 2026-09-25 | 2026-09-25 | d1 d3 | gold=2 | d1 d3 | d1 d3 | gold=2 (restricted)=2 The joined hop answers in a different shape per face by design, with or without a placeholder: NativeSQL filters base rows by the joined object's scope, and the ObjectQL cross-object path keeps them under the RESTRICTED_BUCKET label (ADR-0021 D-C). So each hop is read against its own literal twin: on the ObjectQL hop every placeholder row equals its twin; on the NativeSQL hop none does. With NO ExecutionContext (the same scopes, base table): - NativeSQL: the same literal binds as above (none; d1 d2 d3 d4 under $ne; d1 d2 d3 for the date macro). - ObjectQL (control): every context-token scope is refused READ_SCOPE_COMPILE_FAILED/500 (the resolver's FILTER_TOKEN_UNRESOLVED re-raised), the unknown token too, and the date macro RESOLVES (UTC) and serves d1 d3. So a context-less resolution is the engine's, not a refusal of every placeholder. HTTP doors (throwaway harnesses over a real AnalyticsService on the NativeSQL path with a capturing executeRawSql; read and measured only, no edit to either package): - runtime dispatcher POST /api/v1/analytics/query and POST /api/v1/analytics/sql (anonymous: the harness carries no credential): owner = {current_user_id}, $ne {current_user_id}, an unknown {token}, $lt {today} -> 200 each, the literal bound; the /analytics/sql echo's params print the literal. - rest POST /analytics/dataset/query (exec context userId test-user): the same four plus {current_org_id} -> 200 each, the literal bound. Reach, confirmed by reading: a host-supplied getReadScope (AnalyticsServicePluginOptions.getReadScope / AnalyticsServiceConfig .getReadScope). The auto-bridged producer, security.getReadFilter, composes the sharing predicate, the RLS compiler's output and the controlled-by-parent derivation from the resolved context, and none of them emits a placeholder string. The in-repo brace strings in platform objects are list-view filters, not read scopes. The context at the two call sites: NativeSQLStrategy.applyReadScope (native-sql-strategy.ts:654) and the echo's scope compile in ObjectQLStrategy.generateSql (objectql-strategy.ts:564) both hold ctx.context, the spec StrategyContext.context that AnalyticsService.callCtx binds from the context argument of query() / generateSql(), the same value withReadScope already hands assertReadScopePlaceholdersResolvable. Nothing in spec, core or the service needs to change to reach it. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…d-scope placeholder before lowering it compileScopedFilterToSql resolves the scope's filter placeholders with the engine's own resolver (resolveFilterTokens over filterTokenContextFrom, the function and bridge the ObjectQL face's engine uses) over a new ReadScopeCompileOptions.context, and lowers the resolved tree. Both callers, NativeSQLStrategy.applyReadScope (every hop) and the echo's scope compile in ObjectQLStrategy.generateSql, pass ctx.context. A placeholder the resolver refuses is refused as READ_SCOPE_COMPILE_FAILED / 500, through the one helper assertReadScopePlaceholdersResolvable now shares, raised after the lowering's own gates and the comparand faces (the engine's order). A scope with no placeholder comes back by reference and lowers as before. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
… on the ObjectQL face, the echo and the NativeSQL face Real SQLite and a real ObjectQL engine: a resolvable placeholder admits its literal twin's rows on every face and on both joined hops, and both echoes print the resolved value; an unknown or unresolvable placeholder is refused READ_SCOPE_COMPILE_FAILED / 500, withheld, before any native statement runs; with no context a context token is refused and a date macro served on every face. Pinned beside them: a doubly-refused scope keeps the other gate's sentence, a placeholder-free scope compiles byte-for-byte as before, the caller's own where keeps its resolution and its FILTER_TOKEN_UNKNOWN / 400, and the public export's context-less answer. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…e NativeSQL face and the echo Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…tive-scope-placeholders
…tive-scope-placeholders
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7fd9c4ebf25fd3486117fedb4028c462ffedcb58 && git checkout 7fd9c4ebf25fd3486117fedb4028c462ffedcb58
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fa00ebf44759b7fcfb0a1e9f55256265068399a8 eafeaccea26b513d584407062170b3963784890d && git checkout -B drift-repro fa00ebf44759b7fcfb0a1e9f55256265068399a8 && git merge --no-ff eafeaccea26b513d584407062170b3963784890d
node scripts/docs-audit/affected-docs.mjs --json fa00ebf44759b7fcfb0a1e9f55256265068399a8
|
Fixes #20075
Clause-②: no
What this changes
A read scope now gets one placeholder verdict on all three analytics faces: the same resolved values and rows, or the same withheld 500.
compileScopedFilterToSql(read-scope-sql.ts) is the lowering behind the NativeSQL execute face (NativeSQLStrategy.applyReadScope, the base table and every joined hop) and the/analytics/sqlecho (ObjectQLStrategy.generateSql). It never resolved a filter placeholder, so both faces bound{current_user_id},{current_org_id}or a date macro as literal text. The ObjectQL execute face hands the same scope to the engine, which resolves it with the caller's context and, since #19995, refuses one it cannot resolve.The compiler now takes an optional
ReadScopeCompileOptions.context. It resolves the scope withresolveFilterTokens(scope, filterTokenContextFrom(context))from@objectstack/core, the published resolver the engine calls, and lowers the resolved tree. Both call sites passctx.context. A placeholder the resolver refuses is refused asREAD_SCOPE_COMPILE_FAILED/ 500 with the message withheld.That judgement has one spelling: a module-local
resolveReadScopePlaceholders. The existingassertReadScopePlaceholdersResolvable(PR #20072, used at the ObjectQL merge sites) now calls it too. There is no second implementation of token classification or resolution, and no second spelling of the envelope or its sentence.Measured before the fix
Recorded on this branch (
8bf233a1fc, an empty commit whose message carries the full table), on basea8bcce69c8. The setup: a realSqliteWasmDriver, a realObjectQLbehind the ObjectQL face, and the echo's SQL run on the same database. Every face is driven throughAnalyticsServicewith the caller{ userId: 'u_me' }and no active org. Rows d1 and d3 are owned by u_me, d2 by u_other, and d4 has no owner.owner = {current_user_id}owner $ne {current_user_id}owner $in [{current_user_id}, u_nobody]closed_on $lt {today}{token}{current_org_id}with no active orgThe
$nerow widened the scope: the literal matches nobody, so the exclusion also admitted the caller's own rows. The joined hop was measured too. Each face is compared with its own literal twin there, because the two strategies shape a hop differently by design: NativeSQL filters base rows, and the ObjectQL cross-object path uses theRESTRICTED_BUCKET. On the ObjectQL hop every placeholder row equalled its twin. On the NativeSQL hop none did.With no context, the ObjectQL control refused every context-token scope and still resolved the date macro (UTC), serving d1 d3.
HTTP doors (throwaway harnesses in
packages/runtimeandpackages/rest, read and measured only, not committed):POST /api/v1/analytics/queryand/api/v1/analytics/sql: 200 with the literal bound, and the echo printed the literal.POST /analytics/dataset/query: 200 with the literal bound.Reach: the card's reach is confirmed by reading. It takes a host-supplied
getReadScope. The auto-bridgedsecurity.getReadFiltercomposes concrete values: the sharing predicate, the RLS compiler's output and the controlled-by-parent derivation.Where the resolution sits among the scope gates, and why
$icontainswith an empty comparand answers every non-NULL row on the analytics where and read-scope compilers, where FILTER_TEXT_CASES declares it refused (INVALID_FILTER) and driver-sql refuses it #20068$icontainsarm among them) and after the service-analytics: the NativeSQL read-scope compiler and the/analytics/sqlecho compile two scope shapes the shared comparand faces refuse (plain-object comparand under$eq, null member in$in): one scope, two answers across faces #20018 comparand faces. This is the engine's order: its lowering doors run before its resolver. No gate's verdict moves either way. Resolution only replaces a fully-wrapped placeholder string with a non-empty string, and it copies the tree around it, where a non-Dateclass-instance comparand becomes a plain object; every such comparand is refused in both forms. So a scope that another gate also refuses keeps that gate's sentence. This is pinned.native-sql-strategy.ts:654andobjectql-strategy.ts:564both holdctx.context. That is the specStrategyContext.context, whichAnalyticsService.callCtxbinds from thecontextargument ofquery()/generateSql(). It is the same valuewithReadScopealready hands the ObjectQL-face assertion. Nothing inspec,core,objectql,restor the service changes.The public export without a context
I did not follow the default suggestion to refuse every placeholder when there is no context, because it disagrees with the control face. The engine resolves a date macro for a context-less operation (UTC now) and refuses a context token. Refusing every placeholder would give the NativeSQL face and the echo a second answer whenever
ctx.contextisundefined. So an absentcontextresolves the way the engine does: a date macro resolves against UTC, and a context token or an unknown placeholder gets the withheld 500. A placeholder is never bound as its literal text. Both halves are pinned, and so is the context's time zone being read.Tests (at
eafeaccea2)read-scope-placeholder-three-faces.test.ts, 22 tests. It uses a real SQLite database and a realObjectQL, with the ObjectQL face, the echo (its SQL executed) and the NativeSQL face, plus both joined hops. It pins:whereis unchanged: it still resolves, and still answersFILTER_TOKEN_UNKNOWN/ 400 with its message;pnpm --filter @objectstack/service-analytics test: 125 files / 2908 tests passed.typecheckexit 0, and--listFilesincludes the new test file.node scripts/ablation-replace.mjsin WRAP mode. The tests import the mutated sources by relative path (src), so no build sits between mutation and measurement. Each leg was proven restored:git hash-objectequals the HEAD blob, andgit diff HEADis empty.lowered = filter)throw e)context: ctx.contextremoved atapplyReadScopecontext: ctx.contextremoved at the echo's compile/analytics/queryand/analytics/sql: a context token gets the withheld 500; a date macro gets 200 with the resolved date bound and printed./analytics/dataset/querywith a user: 200 with the user id bound; an unknown or unresolvable placeholder gets the withheld 500.Gates (at
eafeaccea2)dispatch-gates --commandsderived 61 families. 59 exited 0.check:dual-build-cjs-loadsneeds every package'sdist. A narrowed spot check does pass: theservice-analyticsCJS build loads and resolves a placeholder.check:type-check-debtneeds the whole-workspace closure built.--ranreconciliation: 61 accounted, 0 UNRUN.check-issue-citationswith a token: 14 citations, all resolve..tsfiles: 0 errors and 0 warnings over 4 files, read from the--format jsonoutput. ESLint's own--print-configshows noparserOptions.projectand noprojectService, so this config is not type-aware and the diff cannot move any untouched file's verdict. The repo-widepnpm lintis CI's.mainmoved twice during the work and was merged both times, never rebased (7c2146223c,eafeaccea2). The dependencies each merge touched were rebuilt before the suite and every gate above were re-run on the final head.Changeset classification: differs from the dispatch default
.changeset/20075-native-scope-placeholders.mdisminor,Clause-②: no (narrowing), BREAKING, with an ADR-0087not-required (no-migration-prescription)disposition. The dispatch's default waspatch. Two reasons:/analytics/sqlecho compile two scope shapes the shared comparand faces refuse (plain-object comparand under$eq, null member in$in): one scope, two answers across faces #20018 made the same alignment on the same faces and was declaredno (narrowing)/minor.Check Changesetlevel rule counts a new accepted key on a published package's public surface as at leastminor, andcontextis such a key.The declaration line at the top of this body is the claim's, verbatim. Reverting to
patchmeans dropping the arm, the!, the banner and the marker in that one file.Acceptance notes
aggregatetime./analytics/sqlecho compile two scope shapes the shared comparand faces refuse (plain-object comparand under$eq, null member in$in): one scope, two answers across faces #20018 comparand faces already have the same asymmetry.RESTRICTED_BUCKET). That is a documented design, not this card's.Generated by Claude Code