Skip to content

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

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20075-native-scope-placeholders
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20075-native-scope-placeholders

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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/sql echo (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 with resolveFilterTokens(scope, filterTokenContextFrom(context)) from @objectstack/core, the published resolver the engine calls, and lowers the resolved tree. Both call sites pass ctx.context. A placeholder the resolver refuses is refused as READ_SCOPE_COMPILE_FAILED / 500 with the message withheld.

That judgement has one spelling: a module-local resolveReadScopePlaceholders. The existing assertReadScopePlaceholdersResolvable (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 base a8bcce69c8. The setup: a real SqliteWasmDriver, a real ObjectQL behind the ObjectQL face, and the echo's SQL run on the same database. Every face is driven through AnalyticsService with 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.

read scope compiler binds native base echo (SQL run) ObjectQL base (control)
owner = {current_user_id} the literal none none d1 d3
owner $ne {current_user_id} the literal d1 d2 d3 d4 d1 d2 d3 d4 d2 d4
owner $in [{current_user_id}, u_nobody] the literal none none d1 d3
closed_on $lt {today} the literal d1 d2 d3 d1 d2 d3 d1 d3
an unknown {token} the literal served, none served, none 500 withheld
{current_org_id} with no active org the literal served, none served, none 500 withheld

The $ne row 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 the RESTRICTED_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/runtime and packages/rest, read and measured only, not committed):

  • POST /api/v1/analytics/query and /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-bridged security.getReadFilter composes 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

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.context is undefined. So an absent context resolves 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)

  • New file: read-scope-placeholder-three-faces.test.ts, 22 tests. It uses a real SQLite database and a real ObjectQL, with the ObjectQL face, the echo (its SQL executed) and the NativeSQL face, plus both joined hops. It pins:
    • each resolvable scope admits its literal twin's rows on every face;
    • both echoes print the resolved value;
    • each refused scope gets the withheld 500 on all three faces and both hops, before any native statement runs;
    • the context-less answer is the same on every face;
    • the sentence ordering;
    • placeholder-free scopes compile byte-for-byte as before, with or without a context;
    • the caller's own where is unchanged: it still resolves, and still answers FILTER_TOKEN_UNKNOWN / 400 with its message;
    • the public export's context-less decision.
  • pnpm --filter @objectstack/service-analytics test: 125 files / 2908 tests passed. typecheck exit 0, and --listFiles includes the new test file.
  • Ablations, through node scripts/ablation-replace.mjs in 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-object equals the HEAD blob, and git diff HEAD is empty.
leg mutation red
A1 resolution removed (lowered = filter) 18 of 22; the 4 survivors are the controls, including the byte-for-byte pin, which shows those pins encode the pre-fix output
A2 refusal raised early (throw e) 1: the sentence-ordering pin
A3 refusal dropped 9: every refusal, no-statement, context-less and sentence pin
A4 context: ctx.context removed at applyReadScope 6
A5 context: ctx.context removed at the echo's compile 5
  • HTTP doors after the fix (the same throwaway harnesses, not committed):
    • anonymous /analytics/query and /analytics/sql: a context token gets the withheld 500; a date macro gets 200 with the resolved date bound and printed.
    • /analytics/dataset/query with a user: 200 with the user id bound; an unknown or unresolvable placeholder gets the withheld 500.

Gates (at eafeaccea2)

  • dispatch-gates --commands derived 61 families. 59 exited 0.
  • Two are NOT MEASURED because a prerequisite is missing (exit 3):
    • check:dual-build-cjs-loads needs every package's dist. A narrowed spot check does pass: the service-analytics CJS build loads and resolves a placeholder.
    • check:type-check-debt needs the whole-workspace closure built.
  • --ran reconciliation: 61 accounted, 0 UNRUN.
  • check-issue-citations with a token: 14 citations, all resolve.
  • ESLint, narrowed to the 4 changed .ts files: 0 errors and 0 warnings over 4 files, read from the --format json output. ESLint's own --print-config shows no parserOptions.project and no projectService, so this config is not type-aware and the diff cannot move any untouched file's verdict. The repo-wide pnpm lint is CI's.
  • main moved 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.md is minor, Clause-②: no (narrowing), BREAKING, with an ADR-0087 not-required (no-migration-prescription) disposition. The dispatch's default was patch. Two reasons:

The declaration line at the top of this body is the claim's, verbatim. Reverting to patch means dropping the arm, the !, the banner and the marker in that one file.

Acceptance notes


Generated by Claude Code

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 6 documentable anchor(s).

⛔ 1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx (via generateSql (symbol, a method of class ObjectQLStrategy))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json fa00ebf44759b7fcfb0a1e9f55256265068399a8 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7fd9c4ebf25fd3486117fedb4028c462ffedcb58 — the merge of head eafeaccea26b513d584407062170b3963784890d into base fa00ebf44759b7fcfb0a1e9f55256265068399a8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs fa00ebf44759b7fcfb0a1e9f55256265068399a8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants