Uh oh!
There was an error while loading. Please reload this page.
fix(analytics): refuse a non-binding read scope before the ObjectQL engine - #13924
Conversation
…ngine ObjectQLStrategy merges StrategyContext.getReadScope output straight into the FilterCondition it hands engine.aggregate, so the scope never reaches compileScopedFilterToSql — the empty-$nin refusal that compiler carries guards the NativeSQL path and the /analytics/sql echo only. Measured against a real engine (driver-sqlite-wasm over driver-sql), a non-RLS scope provider handing an emptied membership that is constant TRUE in effective polarity received the whole table on any query this strategy served. assertReadScopeCannotVacate is exported from read-scope-sql.ts, next to the compiler whose disposition it matches, and called at both engine-bound merges: withReadScope and resolveFkAttr. The ruled $in: [] reduction is untouched, so the live RLS composite still admits exactly the own rows, and neither other route moves.
📓 Docs Drift Check8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 8 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 695fcd2fefb1a60f591ab2890df3cd3a8f2b68ec && git checkout 695fcd2fefb1a60f591ab2890df3cd3a8f2b68ec
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4902a250439a6b054ccb71ac7d0c12762712cc6f a050efce99c2c60b2a47a6bf862f45448657acda && git checkout -B drift-repro 4902a250439a6b054ccb71ac7d0c12762712cc6f && git merge --no-ff a050efce99c2c60b2a47a6bf862f45448657acda
node scripts/docs-audit/affected-docs.mjs --json 4902a250439a6b054ccb71ac7d0c12762712cc6f |
Uh oh!
There was an error while loading. Please reload this page.
Closes#13640
ObjectQLStrategymergesStrategyContext.getReadScopeoutput straight into theFilterConditionit handsengine.aggregate. On that route the scope never reachescompileScopedFilterToSql, so the empty-$ninrefusal that compiler carries (PR #13649) guards the NativeSQL path and the/analytics/sqlecho only — anddriver-sqllowers$nin: []throughwhereNotIn(field, [])wrapped null-safe, which is constant TRUE. A non-RLS scope provider therefore got the whole table on any query this strategy served.Measured before anything was written
Real engine (
driver-sqlite-wasmoverdriver-sql), three fixture rows, the ObjectQL aggregate route selected. Pre-fix, on46b53a25b:getReadScope{ owner: { $nin: [] } }{ $not: { owner: { $in: [] } } }{ $not: { owner: [] } }{ $not: { owner: { $in: [], $ne: 'u_other' } } }{ $or: [{ $not: { owner: { $in: [] } } }, { owner: 'u_me' }] }{ $not: { $not: { owner: { $nin: [] } } } }{ owner: { $in: [] } }{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }{ $and: [{ owner: { $in: [] } }, { owner: 'u_me' }] }{ owner: 'u_me' }The card named the first two spellings. The next two were found by measuring rather than assumed — a bare
[]comparand and a multi-key operator object are the same emptied membership at the engine, and a pin on two spellings would have left them open.The guard, and why it stands where it does
assertReadScopeCannotVacate(scope, objectName)is exported frompackages/services/service-analytics/src/read-scope-sql.tsand called at the two engine-bound merges on the ObjectQL strategy:ObjectQLStrategy.withReadScope— immediately afterctx.getReadScope(objectName)returns and before the$andmerge and the provenance mark. That is the last point at which the scope is still a distinguishable object: one line down it is composed with the caller's own filter and no consumer can tell whose half a clause came from. It is also the only place on this route where anything reads the scope at all, so a scope that lowers to a boolean constant used to reach the driver unexamined.ObjectQLStrategy.resolveFkAttr— the secondgetReadScope-to-engine merge in the same file, for the referenced object during FK-attribute expansion. Same door, same defect class, one call. A vacating scope there does not widen the aggregate — it widens the FK attribute map, so ids the policy hides resolve to their labels instead of bucketing under(restricted). Named here rather than left silent because it is a bounded in-place repair beyond the line the card pointed at; it is pinned by its own case, with the base object's scope kept ordinary so only that door can produce the refusal.It is not called from
compileScopedFilterToSql. Calling it there would move the two routes this PR is required to leave alone.Consistency with the refusal already landed
The disposition is deliberately the one PR #13649 established, and the function lives next to that compiler in the same file so the two answers sit under one header section rather than in two places that drift:
readScopeCompileError—READ_SCOPE_COMPILE_FAILED/ 500, withheld from the response by declaration, exactly as the module's other refusals are. No second error vocabulary.$in: []keeps its ruled constant-FALSE reduction (空组合子在同仓有两个对立答案:五个后端归约成布尔单位元,service-analytics 的两个编译器 fail-closed 抛错 —— #5239 的一致性表四条因此进不了表 #5322 / fix(driver-sql): 空$and/$or/$not按布尔单位元编译,$or: []不再返回全表 (#5134) #5243), so the live RLS composite — an emptied membership$or-ed beside an own-rows grant, pinned upstream byrls-empty-membership-polarity.test.ts— still compiles and still admits exactly the own rows. A uniform throw at both arms is the availability regression that verdict rejected, and it is pinned here as an over-denial control.$nin: []is refused at every polarity, matchingcompileOperator's own$ninarm, which throws whatever encloses it. A polarity-aware rule there would have been weaker than the compiler and given one read scope two answers depending on which strategy served the query.$notover$in: []still compiles to constant TRUE insidecompileScopedFilterToSql; that is untouched. The reason it could be closed at the other door without the polarity design that verdict asked for first is structural, not a change of mind: this guard is a walk over the scope tree that never reduces anything, so effective polarity is simply readable and there is no interaction with the$not-over-identity reductions to rule on. The consequence is declared in the module header rather than hidden — for that one spelling the ObjectQL echo (which compiles) and the ObjectQL execution (which now refuses) disagree.plugin-securityis untouched, and deliberately not shared code: it lives in a layerservice-analyticsmust not depend on and answers a different question (whether to drop a degenerate policy before emitting it).Pins
packages/services/service-analytics/src/__tests__/objectql-read-scope-vacancy-refusal.test.ts, 18 cases:READ_SCOPE_COMPILE_FAILED/ 500 and to name the offending path;$in: []still means zero rows) and both over-denial controls;$ninkeeping its NULL-safety;compileScopedFilterToSqlitself — both other routes consume that one function and have no other read-scope translation, so pinning its answers pins theirs without a second copy of the end-to-end fixture PR fix(service-analytics): refuse an empty $nin on the read-scope lowering instead of folding it to constant TRUE #13649 already owns. It asserts the empty-$ninrefusal still carries its own message (not the new guard's), that an ordinary scope still compiles to the same bound predicate, that the ObjectQL echo still refuses through the compiler, and — labelled explicitly as an immobility control and not a contract — that$notover$in: []still compiles there.Ablation
Prediction stated before the run: reverting the two call sites reddens the six vacancy pins plus the FK-door pin, and leaves every control green in both directions.
The repair was committed first; the mutation replaced the two anchored call lines and was confirmed on disk by marker counts and blob hash before anything was measured; the restore is proven by state, not by an exit code. The test resolves the subject by relative path inside its own package, so vitest transforms
src/*.tsdirectly — there is nodistleg to rebuild, and none is claimed.The eleven that stayed green are declared controls, not ablation evidence: the asymmetry case, both over-denial composites, the ordinary case, the harness control, the FK ordinary-scope control and the five immobility assertions are all supposed to be green in both directions, and they were.
Verification
All on
a050efce9, the head of this branch.pnpm --filter @objectstack/service-analytics test— 85 files, 1837 tests, all pass (this package's whole suite, including the read-scope pins PR fix(service-analytics): refuse an empty $nin on the read-scope lowering instead of folding it to constant TRUE #13649 landed).pnpm --filter @objectstack/service-analytics exec tsc --noEmit --listFiles— clean, and--listFilesshows both edited sources and the new test file inside the program, so the green covers the edits rather than merely running.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(both output sections read whole; no.mdxin the diff): 35 commands, 34 green. The one exception isscripts/check-test-completeness.mjs, which exits 3 = PREREQUISITE NOT MET because it grades a savedturbo run testlog that only CI produces — recorded as NOT MEASURED, not as a pass and not as a red.check:type-check-debtfirst refused for an unbuilt closure; the closure named inlint.ymlwas built and the gate then re-measured 29 ledger entries with none above its recorded number.pnpm lint— the repo-wide ESLint run, clean. No narrowing claimed.node scripts/check-nul-bytes.mjs— clean, plus a direct control-byte scan over the four changed files.Changeset:
.changeset/objectql-read-scope-vacancy-refusal.md(@objectstack/service-analytics, patch).Generated by Claude Code
Generated by Claude Code