Skip to content

fix(analytics): refuse a non-binding read scope before the ObjectQL engine - #13924

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13640-objectql-strategy-read-scope-guard
Aug 31, 2026
Merged

fix(analytics): refuse a non-binding read scope before the ObjectQL engine#13924
os-steve merged 2 commits into
mainfrom
claude/issue-13640-objectql-strategy-read-scope-guard

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13640

ObjectQLStrategy merges StrategyContext.getReadScope output straight into the FilterCondition it hands engine.aggregate. On that route the scope never reaches compileScopedFilterToSql, so the empty-$nin refusal that compiler carries (PR #13649) guards the NativeSQL path and the /analytics/sql echo only — and driver-sql lowers $nin: [] through whereNotIn(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-wasm over driver-sql), three fixture rows, the ObjectQL aggregate route selected. Pre-fix, on 46b53a25b:

read scope handed by a non-RLS getReadScoperows the engine admittednow
{ owner: { $nin: [] } }all threeREFUSED
{ $not: { owner: { $in: [] } } }all threeREFUSED
{ $not: { owner: [] } }all threeREFUSED
{ $not: { owner: { $in: [], $ne: 'u_other' } } }all threeREFUSED
{ $or: [{ $not: { owner: { $in: [] } } }, { owner: 'u_me' }] }all threeREFUSED
{ $not: { $not: { owner: { $nin: [] } } } }all threeREFUSED
{ owner: { $in: [] } }nonenone
{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }the own rowthe own row
{ $and: [{ owner: { $in: [] } }, { owner: 'u_me' }] }nonenone
{ owner: 'u_me' }the own rowthe own row

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 from packages/services/service-analytics/src/read-scope-sql.ts and called at the two engine-bound merges on the ObjectQL strategy:

  1. ObjectQLStrategy.withReadScope — immediately after ctx.getReadScope(objectName) returns and before the $and merge 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.
  2. ObjectQLStrategy.resolveFkAttr — the second getReadScope-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:

  • Same envelope.readScopeCompileErrorREAD_SCOPE_COMPILE_FAILED / 500, withheld from the response by declaration, exactly as the module's other refusals are. No second error vocabulary.
  • Refused, not reduced. An emptied exclusion is a refusal on both routes now, not a silent fold.
  • The asymmetry is preserved.$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 by rls-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, matching compileOperator's own $nin arm, 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.
  • Nothing re-decides the ruled follow-up.$not over $in: [] still compiles to constant TRUE inside compileScopedFilterToSql; 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.
  • Second line of defence, not a replacement. The producer-side polarity guard in plugin-security is untouched, and deliberately not shared code: it lives in a layer service-analytics must 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:

  • the six vacating spellings above, each asserted to carry READ_SCOPE_COMPILE_FAILED / 500 and to name the offending path;
  • a harness control (no scope admits every row) so a refusal assertion cannot pass because the fixture admits nothing;
  • the asymmetry ($in: [] still means zero rows) and both over-denial controls;
  • the ordinary case, including a non-empty $nin keeping its NULL-safety;
  • the FK-resolution door, with an ordinary-scope control beside it;
  • an immobility block over compileScopedFilterToSql itself — 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-$nin refusal 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 $not over $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/*.ts directly — there is no dist leg to rebuild, and none is claimed.

HEAD_BLOB=baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
PRE_HASH =baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
CALLS_BEFORE=2
CALLS_AFTER=0 MARKERS=2
MUT_HASH =48b7d5978c72a5a1b9066f29477f9a9c7d1db033
MUTATION CONFIRMED ON DISK (2 anchored call sites replaced, blob hash moved)
x `{ owner: { $nin: [] } }` is REFUSED - pre-fix it admitted the whole table
x `{ $not: { owner: { $in: [] } } }` - the other spelling - is REFUSED too
x the bare `[]` comparand under `$not` is the same emptied membership, and is REFUSED
x a sibling operator does not rescue it: `{ $in: [], $ne }` under `$not` is REFUSED
x nested inside a composite it is still REFUSED - a TRUE `$or` arm absorbs the scope
x an empty `$nin` is refused at EVERY polarity, matching the compiler arm
x a vacating referenced-object scope is REFUSED at that merge too
Tests 7 failed | 11 passed (18)
AssertionError: expected [ 'r1', 'r2', 'r3' ] to be undefined
- Expected: undefined
+ Received: [ "r1", "r2", "r3" ]
POST_HASH=baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
RESTORE PROVEN BY STATE: git diff HEAD empty, blob hash back to HEAD blob, 0 markers left

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 test85 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 --listFiles shows both edited sources and the new test file inside the program, so the green covers the edits rather than merely running.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (both output sections read whole; no .mdx in the diff): 35 commands, 34 green. The one exception is scripts/check-test-completeness.mjs, which exits 3 = PREREQUISITE NOT MET because it grades a saved turbo run test log that only CI produces — recorded as NOT MEASURED, not as a pass and not as a red. check:type-check-debt first refused for an unbuilt closure; the closure named in lint.yml was 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

…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.
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 8 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 4902a250439a6b054ccb71ac7d0c12762712cc6fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 695fcd2fefb1a60f591ab2890df3cd3a8f2b68ec — the merge of head a050efce99c2c60b2a47a6bf862f45448657acda into base 4902a250439a6b054ccb71ac7d0c12762712cc6f, 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 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

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

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(analytics): refuse a non-binding read scope before the ObjectQL engine by claude[bot] · Pull Request #13924 · objectstack-ai/objectstack · GitHub
Skip to content

fix(analytics): refuse a non-binding read scope before the ObjectQL engine - #13924

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13640-objectql-strategy-read-scope-guard
Aug 31, 2026
Merged

fix(analytics): refuse a non-binding read scope before the ObjectQL engine#13924
os-steve merged 2 commits into
mainfrom
claude/issue-13640-objectql-strategy-read-scope-guard

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13640

ObjectQLStrategy merges StrategyContext.getReadScope output straight into the FilterCondition it hands engine.aggregate. On that route the scope never reaches compileScopedFilterToSql, so the empty-$nin refusal that compiler carries (PR #13649) guards the NativeSQL path and the /analytics/sql echo only — and driver-sql lowers $nin: [] through whereNotIn(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-wasm over driver-sql), three fixture rows, the ObjectQL aggregate route selected. Pre-fix, on 46b53a25b:

read scope handed by a non-RLS getReadScoperows the engine admittednow
{ owner: { $nin: [] } }all threeREFUSED
{ $not: { owner: { $in: [] } } }all threeREFUSED
{ $not: { owner: [] } }all threeREFUSED
{ $not: { owner: { $in: [], $ne: 'u_other' } } }all threeREFUSED
{ $or: [{ $not: { owner: { $in: [] } } }, { owner: 'u_me' }] }all threeREFUSED
{ $not: { $not: { owner: { $nin: [] } } } }all threeREFUSED
{ owner: { $in: [] } }nonenone
{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }the own rowthe own row
{ $and: [{ owner: { $in: [] } }, { owner: 'u_me' }] }nonenone
{ owner: 'u_me' }the own rowthe own row

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 from packages/services/service-analytics/src/read-scope-sql.ts and called at the two engine-bound merges on the ObjectQL strategy:

  1. ObjectQLStrategy.withReadScope — immediately after ctx.getReadScope(objectName) returns and before the $and merge 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.
  2. ObjectQLStrategy.resolveFkAttr — the second getReadScope-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:

  • Same envelope.readScopeCompileErrorREAD_SCOPE_COMPILE_FAILED / 500, withheld from the response by declaration, exactly as the module's other refusals are. No second error vocabulary.
  • Refused, not reduced. An emptied exclusion is a refusal on both routes now, not a silent fold.
  • The asymmetry is preserved.$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 by rls-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, matching compileOperator's own $nin arm, 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.
  • Nothing re-decides the ruled follow-up.$not over $in: [] still compiles to constant TRUE inside compileScopedFilterToSql; 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.
  • Second line of defence, not a replacement. The producer-side polarity guard in plugin-security is untouched, and deliberately not shared code: it lives in a layer service-analytics must 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:

  • the six vacating spellings above, each asserted to carry READ_SCOPE_COMPILE_FAILED / 500 and to name the offending path;
  • a harness control (no scope admits every row) so a refusal assertion cannot pass because the fixture admits nothing;
  • the asymmetry ($in: [] still means zero rows) and both over-denial controls;
  • the ordinary case, including a non-empty $nin keeping its NULL-safety;
  • the FK-resolution door, with an ordinary-scope control beside it;
  • an immobility block over compileScopedFilterToSql itself — 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-$nin refusal 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 $not over $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/*.ts directly — there is no dist leg to rebuild, and none is claimed.

HEAD_BLOB=baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
PRE_HASH =baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
CALLS_BEFORE=2
CALLS_AFTER=0 MARKERS=2
MUT_HASH =48b7d5978c72a5a1b9066f29477f9a9c7d1db033
MUTATION CONFIRMED ON DISK (2 anchored call sites replaced, blob hash moved)
x `{ owner: { $nin: [] } }` is REFUSED - pre-fix it admitted the whole table
x `{ $not: { owner: { $in: [] } } }` - the other spelling - is REFUSED too
x the bare `[]` comparand under `$not` is the same emptied membership, and is REFUSED
x a sibling operator does not rescue it: `{ $in: [], $ne }` under `$not` is REFUSED
x nested inside a composite it is still REFUSED - a TRUE `$or` arm absorbs the scope
x an empty `$nin` is refused at EVERY polarity, matching the compiler arm
x a vacating referenced-object scope is REFUSED at that merge too
Tests 7 failed | 11 passed (18)
AssertionError: expected [ 'r1', 'r2', 'r3' ] to be undefined
- Expected: undefined
+ Received: [ "r1", "r2", "r3" ]
POST_HASH=baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
RESTORE PROVEN BY STATE: git diff HEAD empty, blob hash back to HEAD blob, 0 markers left

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 test85 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 --listFiles shows both edited sources and the new test file inside the program, so the green covers the edits rather than merely running.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (both output sections read whole; no .mdx in the diff): 35 commands, 34 green. The one exception is scripts/check-test-completeness.mjs, which exits 3 = PREREQUISITE NOT MET because it grades a saved turbo run test log that only CI produces — recorded as NOT MEASURED, not as a pass and not as a red. check:type-check-debt first refused for an unbuilt closure; the closure named in lint.yml was 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

…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.
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 8 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 4902a250439a6b054ccb71ac7d0c12762712cc6fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 695fcd2fefb1a60f591ab2890df3cd3a8f2b68ec — the merge of head a050efce99c2c60b2a47a6bf862f45448657acda into base 4902a250439a6b054ccb71ac7d0c12762712cc6f, 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 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

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

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(analytics): refuse a non-binding read scope before the ObjectQL engine by claude[bot] · Pull Request #13924 · objectstack-ai/objectstack · GitHub
Skip to content

fix(analytics): refuse a non-binding read scope before the ObjectQL engine - #13924

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13640-objectql-strategy-read-scope-guard
Aug 31, 2026
Merged

fix(analytics): refuse a non-binding read scope before the ObjectQL engine#13924
os-steve merged 2 commits into
mainfrom
claude/issue-13640-objectql-strategy-read-scope-guard

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13640

ObjectQLStrategy merges StrategyContext.getReadScope output straight into the FilterCondition it hands engine.aggregate. On that route the scope never reaches compileScopedFilterToSql, so the empty-$nin refusal that compiler carries (PR #13649) guards the NativeSQL path and the /analytics/sql echo only — and driver-sql lowers $nin: [] through whereNotIn(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-wasm over driver-sql), three fixture rows, the ObjectQL aggregate route selected. Pre-fix, on 46b53a25b:

read scope handed by a non-RLS getReadScoperows the engine admittednow
{ owner: { $nin: [] } }all threeREFUSED
{ $not: { owner: { $in: [] } } }all threeREFUSED
{ $not: { owner: [] } }all threeREFUSED
{ $not: { owner: { $in: [], $ne: 'u_other' } } }all threeREFUSED
{ $or: [{ $not: { owner: { $in: [] } } }, { owner: 'u_me' }] }all threeREFUSED
{ $not: { $not: { owner: { $nin: [] } } } }all threeREFUSED
{ owner: { $in: [] } }nonenone
{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }the own rowthe own row
{ $and: [{ owner: { $in: [] } }, { owner: 'u_me' }] }nonenone
{ owner: 'u_me' }the own rowthe own row

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 from packages/services/service-analytics/src/read-scope-sql.ts and called at the two engine-bound merges on the ObjectQL strategy:

  1. ObjectQLStrategy.withReadScope — immediately after ctx.getReadScope(objectName) returns and before the $and merge 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.
  2. ObjectQLStrategy.resolveFkAttr — the second getReadScope-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:

  • Same envelope.readScopeCompileErrorREAD_SCOPE_COMPILE_FAILED / 500, withheld from the response by declaration, exactly as the module's other refusals are. No second error vocabulary.
  • Refused, not reduced. An emptied exclusion is a refusal on both routes now, not a silent fold.
  • The asymmetry is preserved.$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 by rls-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, matching compileOperator's own $nin arm, 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.
  • Nothing re-decides the ruled follow-up.$not over $in: [] still compiles to constant TRUE inside compileScopedFilterToSql; 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.
  • Second line of defence, not a replacement. The producer-side polarity guard in plugin-security is untouched, and deliberately not shared code: it lives in a layer service-analytics must 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:

  • the six vacating spellings above, each asserted to carry READ_SCOPE_COMPILE_FAILED / 500 and to name the offending path;
  • a harness control (no scope admits every row) so a refusal assertion cannot pass because the fixture admits nothing;
  • the asymmetry ($in: [] still means zero rows) and both over-denial controls;
  • the ordinary case, including a non-empty $nin keeping its NULL-safety;
  • the FK-resolution door, with an ordinary-scope control beside it;
  • an immobility block over compileScopedFilterToSql itself — 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-$nin refusal 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 $not over $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/*.ts directly — there is no dist leg to rebuild, and none is claimed.

HEAD_BLOB=baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
PRE_HASH =baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
CALLS_BEFORE=2
CALLS_AFTER=0 MARKERS=2
MUT_HASH =48b7d5978c72a5a1b9066f29477f9a9c7d1db033
MUTATION CONFIRMED ON DISK (2 anchored call sites replaced, blob hash moved)
x `{ owner: { $nin: [] } }` is REFUSED - pre-fix it admitted the whole table
x `{ $not: { owner: { $in: [] } } }` - the other spelling - is REFUSED too
x the bare `[]` comparand under `$not` is the same emptied membership, and is REFUSED
x a sibling operator does not rescue it: `{ $in: [], $ne }` under `$not` is REFUSED
x nested inside a composite it is still REFUSED - a TRUE `$or` arm absorbs the scope
x an empty `$nin` is refused at EVERY polarity, matching the compiler arm
x a vacating referenced-object scope is REFUSED at that merge too
Tests 7 failed | 11 passed (18)
AssertionError: expected [ 'r1', 'r2', 'r3' ] to be undefined
- Expected: undefined
+ Received: [ "r1", "r2", "r3" ]
POST_HASH=baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
RESTORE PROVEN BY STATE: git diff HEAD empty, blob hash back to HEAD blob, 0 markers left

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 test85 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 --listFiles shows both edited sources and the new test file inside the program, so the green covers the edits rather than merely running.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (both output sections read whole; no .mdx in the diff): 35 commands, 34 green. The one exception is scripts/check-test-completeness.mjs, which exits 3 = PREREQUISITE NOT MET because it grades a saved turbo run test log that only CI produces — recorded as NOT MEASURED, not as a pass and not as a red. check:type-check-debt first refused for an unbuilt closure; the closure named in lint.yml was 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

…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.
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 8 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 4902a250439a6b054ccb71ac7d0c12762712cc6fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 695fcd2fefb1a60f591ab2890df3cd3a8f2b68ec — the merge of head a050efce99c2c60b2a47a6bf862f45448657acda into base 4902a250439a6b054ccb71ac7d0c12762712cc6f, 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 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

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

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(analytics): refuse a non-binding read scope before the ObjectQL engine by claude[bot] · Pull Request #13924 · objectstack-ai/objectstack · GitHub
Skip to content

fix(analytics): refuse a non-binding read scope before the ObjectQL engine - #13924

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13640-objectql-strategy-read-scope-guard
Aug 31, 2026
Merged

fix(analytics): refuse a non-binding read scope before the ObjectQL engine#13924
os-steve merged 2 commits into
mainfrom
claude/issue-13640-objectql-strategy-read-scope-guard

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13640

ObjectQLStrategy merges StrategyContext.getReadScope output straight into the FilterCondition it hands engine.aggregate. On that route the scope never reaches compileScopedFilterToSql, so the empty-$nin refusal that compiler carries (PR #13649) guards the NativeSQL path and the /analytics/sql echo only — and driver-sql lowers $nin: [] through whereNotIn(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-wasm over driver-sql), three fixture rows, the ObjectQL aggregate route selected. Pre-fix, on 46b53a25b:

read scope handed by a non-RLS getReadScoperows the engine admittednow
{ owner: { $nin: [] } }all threeREFUSED
{ $not: { owner: { $in: [] } } }all threeREFUSED
{ $not: { owner: [] } }all threeREFUSED
{ $not: { owner: { $in: [], $ne: 'u_other' } } }all threeREFUSED
{ $or: [{ $not: { owner: { $in: [] } } }, { owner: 'u_me' }] }all threeREFUSED
{ $not: { $not: { owner: { $nin: [] } } } }all threeREFUSED
{ owner: { $in: [] } }nonenone
{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }the own rowthe own row
{ $and: [{ owner: { $in: [] } }, { owner: 'u_me' }] }nonenone
{ owner: 'u_me' }the own rowthe own row

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 from packages/services/service-analytics/src/read-scope-sql.ts and called at the two engine-bound merges on the ObjectQL strategy:

  1. ObjectQLStrategy.withReadScope — immediately after ctx.getReadScope(objectName) returns and before the $and merge 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.
  2. ObjectQLStrategy.resolveFkAttr — the second getReadScope-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:

  • Same envelope.readScopeCompileErrorREAD_SCOPE_COMPILE_FAILED / 500, withheld from the response by declaration, exactly as the module's other refusals are. No second error vocabulary.
  • Refused, not reduced. An emptied exclusion is a refusal on both routes now, not a silent fold.
  • The asymmetry is preserved.$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 by rls-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, matching compileOperator's own $nin arm, 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.
  • Nothing re-decides the ruled follow-up.$not over $in: [] still compiles to constant TRUE inside compileScopedFilterToSql; 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.
  • Second line of defence, not a replacement. The producer-side polarity guard in plugin-security is untouched, and deliberately not shared code: it lives in a layer service-analytics must 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:

  • the six vacating spellings above, each asserted to carry READ_SCOPE_COMPILE_FAILED / 500 and to name the offending path;
  • a harness control (no scope admits every row) so a refusal assertion cannot pass because the fixture admits nothing;
  • the asymmetry ($in: [] still means zero rows) and both over-denial controls;
  • the ordinary case, including a non-empty $nin keeping its NULL-safety;
  • the FK-resolution door, with an ordinary-scope control beside it;
  • an immobility block over compileScopedFilterToSql itself — 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-$nin refusal 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 $not over $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/*.ts directly — there is no dist leg to rebuild, and none is claimed.

HEAD_BLOB=baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
PRE_HASH =baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
CALLS_BEFORE=2
CALLS_AFTER=0 MARKERS=2
MUT_HASH =48b7d5978c72a5a1b9066f29477f9a9c7d1db033
MUTATION CONFIRMED ON DISK (2 anchored call sites replaced, blob hash moved)
x `{ owner: { $nin: [] } }` is REFUSED - pre-fix it admitted the whole table
x `{ $not: { owner: { $in: [] } } }` - the other spelling - is REFUSED too
x the bare `[]` comparand under `$not` is the same emptied membership, and is REFUSED
x a sibling operator does not rescue it: `{ $in: [], $ne }` under `$not` is REFUSED
x nested inside a composite it is still REFUSED - a TRUE `$or` arm absorbs the scope
x an empty `$nin` is refused at EVERY polarity, matching the compiler arm
x a vacating referenced-object scope is REFUSED at that merge too
Tests 7 failed | 11 passed (18)
AssertionError: expected [ 'r1', 'r2', 'r3' ] to be undefined
- Expected: undefined
+ Received: [ "r1", "r2", "r3" ]
POST_HASH=baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
RESTORE PROVEN BY STATE: git diff HEAD empty, blob hash back to HEAD blob, 0 markers left

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 test85 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 --listFiles shows both edited sources and the new test file inside the program, so the green covers the edits rather than merely running.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (both output sections read whole; no .mdx in the diff): 35 commands, 34 green. The one exception is scripts/check-test-completeness.mjs, which exits 3 = PREREQUISITE NOT MET because it grades a saved turbo run test log that only CI produces — recorded as NOT MEASURED, not as a pass and not as a red. check:type-check-debt first refused for an unbuilt closure; the closure named in lint.yml was 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

…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.
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 8 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 4902a250439a6b054ccb71ac7d0c12762712cc6fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 695fcd2fefb1a60f591ab2890df3cd3a8f2b68ec — the merge of head a050efce99c2c60b2a47a6bf862f45448657acda into base 4902a250439a6b054ccb71ac7d0c12762712cc6f, 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 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

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

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(analytics): refuse a non-binding read scope before the ObjectQL engine by claude[bot] · Pull Request #13924 · objectstack-ai/objectstack · GitHub
Skip to content

fix(analytics): refuse a non-binding read scope before the ObjectQL engine - #13924

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13640-objectql-strategy-read-scope-guard
Aug 31, 2026
Merged

fix(analytics): refuse a non-binding read scope before the ObjectQL engine#13924
os-steve merged 2 commits into
mainfrom
claude/issue-13640-objectql-strategy-read-scope-guard

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13640

ObjectQLStrategy merges StrategyContext.getReadScope output straight into the FilterCondition it hands engine.aggregate. On that route the scope never reaches compileScopedFilterToSql, so the empty-$nin refusal that compiler carries (PR #13649) guards the NativeSQL path and the /analytics/sql echo only — and driver-sql lowers $nin: [] through whereNotIn(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-wasm over driver-sql), three fixture rows, the ObjectQL aggregate route selected. Pre-fix, on 46b53a25b:

read scope handed by a non-RLS getReadScoperows the engine admittednow
{ owner: { $nin: [] } }all threeREFUSED
{ $not: { owner: { $in: [] } } }all threeREFUSED
{ $not: { owner: [] } }all threeREFUSED
{ $not: { owner: { $in: [], $ne: 'u_other' } } }all threeREFUSED
{ $or: [{ $not: { owner: { $in: [] } } }, { owner: 'u_me' }] }all threeREFUSED
{ $not: { $not: { owner: { $nin: [] } } } }all threeREFUSED
{ owner: { $in: [] } }nonenone
{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }the own rowthe own row
{ $and: [{ owner: { $in: [] } }, { owner: 'u_me' }] }nonenone
{ owner: 'u_me' }the own rowthe own row

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 from packages/services/service-analytics/src/read-scope-sql.ts and called at the two engine-bound merges on the ObjectQL strategy:

  1. ObjectQLStrategy.withReadScope — immediately after ctx.getReadScope(objectName) returns and before the $and merge 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.
  2. ObjectQLStrategy.resolveFkAttr — the second getReadScope-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:

  • Same envelope.readScopeCompileErrorREAD_SCOPE_COMPILE_FAILED / 500, withheld from the response by declaration, exactly as the module's other refusals are. No second error vocabulary.
  • Refused, not reduced. An emptied exclusion is a refusal on both routes now, not a silent fold.
  • The asymmetry is preserved.$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 by rls-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, matching compileOperator's own $nin arm, 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.
  • Nothing re-decides the ruled follow-up.$not over $in: [] still compiles to constant TRUE inside compileScopedFilterToSql; 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.
  • Second line of defence, not a replacement. The producer-side polarity guard in plugin-security is untouched, and deliberately not shared code: it lives in a layer service-analytics must 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:

  • the six vacating spellings above, each asserted to carry READ_SCOPE_COMPILE_FAILED / 500 and to name the offending path;
  • a harness control (no scope admits every row) so a refusal assertion cannot pass because the fixture admits nothing;
  • the asymmetry ($in: [] still means zero rows) and both over-denial controls;
  • the ordinary case, including a non-empty $nin keeping its NULL-safety;
  • the FK-resolution door, with an ordinary-scope control beside it;
  • an immobility block over compileScopedFilterToSql itself — 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-$nin refusal 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 $not over $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/*.ts directly — there is no dist leg to rebuild, and none is claimed.

HEAD_BLOB=baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
PRE_HASH =baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
CALLS_BEFORE=2
CALLS_AFTER=0 MARKERS=2
MUT_HASH =48b7d5978c72a5a1b9066f29477f9a9c7d1db033
MUTATION CONFIRMED ON DISK (2 anchored call sites replaced, blob hash moved)
x `{ owner: { $nin: [] } }` is REFUSED - pre-fix it admitted the whole table
x `{ $not: { owner: { $in: [] } } }` - the other spelling - is REFUSED too
x the bare `[]` comparand under `$not` is the same emptied membership, and is REFUSED
x a sibling operator does not rescue it: `{ $in: [], $ne }` under `$not` is REFUSED
x nested inside a composite it is still REFUSED - a TRUE `$or` arm absorbs the scope
x an empty `$nin` is refused at EVERY polarity, matching the compiler arm
x a vacating referenced-object scope is REFUSED at that merge too
Tests 7 failed | 11 passed (18)
AssertionError: expected [ 'r1', 'r2', 'r3' ] to be undefined
- Expected: undefined
+ Received: [ "r1", "r2", "r3" ]
POST_HASH=baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
RESTORE PROVEN BY STATE: git diff HEAD empty, blob hash back to HEAD blob, 0 markers left

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 test85 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 --listFiles shows both edited sources and the new test file inside the program, so the green covers the edits rather than merely running.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (both output sections read whole; no .mdx in the diff): 35 commands, 34 green. The one exception is scripts/check-test-completeness.mjs, which exits 3 = PREREQUISITE NOT MET because it grades a saved turbo run test log that only CI produces — recorded as NOT MEASURED, not as a pass and not as a red. check:type-check-debt first refused for an unbuilt closure; the closure named in lint.yml was 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

…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.
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 8 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 4902a250439a6b054ccb71ac7d0c12762712cc6fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 695fcd2fefb1a60f591ab2890df3cd3a8f2b68ec — the merge of head a050efce99c2c60b2a47a6bf862f45448657acda into base 4902a250439a6b054ccb71ac7d0c12762712cc6f, 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 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

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

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(analytics): refuse a non-binding read scope before the ObjectQL engine by claude[bot] · Pull Request #13924 · objectstack-ai/objectstack · GitHub
Skip to content

fix(analytics): refuse a non-binding read scope before the ObjectQL engine - #13924

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13640-objectql-strategy-read-scope-guard
Aug 31, 2026
Merged

fix(analytics): refuse a non-binding read scope before the ObjectQL engine#13924
os-steve merged 2 commits into
mainfrom
claude/issue-13640-objectql-strategy-read-scope-guard

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13640

ObjectQLStrategy merges StrategyContext.getReadScope output straight into the FilterCondition it hands engine.aggregate. On that route the scope never reaches compileScopedFilterToSql, so the empty-$nin refusal that compiler carries (PR #13649) guards the NativeSQL path and the /analytics/sql echo only — and driver-sql lowers $nin: [] through whereNotIn(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-wasm over driver-sql), three fixture rows, the ObjectQL aggregate route selected. Pre-fix, on 46b53a25b:

read scope handed by a non-RLS getReadScoperows the engine admittednow
{ owner: { $nin: [] } }all threeREFUSED
{ $not: { owner: { $in: [] } } }all threeREFUSED
{ $not: { owner: [] } }all threeREFUSED
{ $not: { owner: { $in: [], $ne: 'u_other' } } }all threeREFUSED
{ $or: [{ $not: { owner: { $in: [] } } }, { owner: 'u_me' }] }all threeREFUSED
{ $not: { $not: { owner: { $nin: [] } } } }all threeREFUSED
{ owner: { $in: [] } }nonenone
{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }the own rowthe own row
{ $and: [{ owner: { $in: [] } }, { owner: 'u_me' }] }nonenone
{ owner: 'u_me' }the own rowthe own row

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 from packages/services/service-analytics/src/read-scope-sql.ts and called at the two engine-bound merges on the ObjectQL strategy:

  1. ObjectQLStrategy.withReadScope — immediately after ctx.getReadScope(objectName) returns and before the $and merge 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.
  2. ObjectQLStrategy.resolveFkAttr — the second getReadScope-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:

  • Same envelope.readScopeCompileErrorREAD_SCOPE_COMPILE_FAILED / 500, withheld from the response by declaration, exactly as the module's other refusals are. No second error vocabulary.
  • Refused, not reduced. An emptied exclusion is a refusal on both routes now, not a silent fold.
  • The asymmetry is preserved.$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 by rls-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, matching compileOperator's own $nin arm, 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.
  • Nothing re-decides the ruled follow-up.$not over $in: [] still compiles to constant TRUE inside compileScopedFilterToSql; 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.
  • Second line of defence, not a replacement. The producer-side polarity guard in plugin-security is untouched, and deliberately not shared code: it lives in a layer service-analytics must 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:

  • the six vacating spellings above, each asserted to carry READ_SCOPE_COMPILE_FAILED / 500 and to name the offending path;
  • a harness control (no scope admits every row) so a refusal assertion cannot pass because the fixture admits nothing;
  • the asymmetry ($in: [] still means zero rows) and both over-denial controls;
  • the ordinary case, including a non-empty $nin keeping its NULL-safety;
  • the FK-resolution door, with an ordinary-scope control beside it;
  • an immobility block over compileScopedFilterToSql itself — 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-$nin refusal 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 $not over $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/*.ts directly — there is no dist leg to rebuild, and none is claimed.

HEAD_BLOB=baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
PRE_HASH =baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
CALLS_BEFORE=2
CALLS_AFTER=0 MARKERS=2
MUT_HASH =48b7d5978c72a5a1b9066f29477f9a9c7d1db033
MUTATION CONFIRMED ON DISK (2 anchored call sites replaced, blob hash moved)
x `{ owner: { $nin: [] } }` is REFUSED - pre-fix it admitted the whole table
x `{ $not: { owner: { $in: [] } } }` - the other spelling - is REFUSED too
x the bare `[]` comparand under `$not` is the same emptied membership, and is REFUSED
x a sibling operator does not rescue it: `{ $in: [], $ne }` under `$not` is REFUSED
x nested inside a composite it is still REFUSED - a TRUE `$or` arm absorbs the scope
x an empty `$nin` is refused at EVERY polarity, matching the compiler arm
x a vacating referenced-object scope is REFUSED at that merge too
Tests 7 failed | 11 passed (18)
AssertionError: expected [ 'r1', 'r2', 'r3' ] to be undefined
- Expected: undefined
+ Received: [ "r1", "r2", "r3" ]
POST_HASH=baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
RESTORE PROVEN BY STATE: git diff HEAD empty, blob hash back to HEAD blob, 0 markers left

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 test85 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 --listFiles shows both edited sources and the new test file inside the program, so the green covers the edits rather than merely running.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (both output sections read whole; no .mdx in the diff): 35 commands, 34 green. The one exception is scripts/check-test-completeness.mjs, which exits 3 = PREREQUISITE NOT MET because it grades a saved turbo run test log that only CI produces — recorded as NOT MEASURED, not as a pass and not as a red. check:type-check-debt first refused for an unbuilt closure; the closure named in lint.yml was 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

…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.
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 8 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 4902a250439a6b054ccb71ac7d0c12762712cc6fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 695fcd2fefb1a60f591ab2890df3cd3a8f2b68ec — the merge of head a050efce99c2c60b2a47a6bf862f45448657acda into base 4902a250439a6b054ccb71ac7d0c12762712cc6f, 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 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

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

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); fix(analytics): refuse a non-binding read scope before the ObjectQL engine by claude[bot] · Pull Request #13924 · objectstack-ai/objectstack · GitHub
Skip to content

fix(analytics): refuse a non-binding read scope before the ObjectQL engine - #13924

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13640-objectql-strategy-read-scope-guard
Aug 31, 2026
Merged

fix(analytics): refuse a non-binding read scope before the ObjectQL engine#13924
os-steve merged 2 commits into
mainfrom
claude/issue-13640-objectql-strategy-read-scope-guard

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13640

ObjectQLStrategy merges StrategyContext.getReadScope output straight into the FilterCondition it hands engine.aggregate. On that route the scope never reaches compileScopedFilterToSql, so the empty-$nin refusal that compiler carries (PR #13649) guards the NativeSQL path and the /analytics/sql echo only — and driver-sql lowers $nin: [] through whereNotIn(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-wasm over driver-sql), three fixture rows, the ObjectQL aggregate route selected. Pre-fix, on 46b53a25b:

read scope handed by a non-RLS getReadScoperows the engine admittednow
{ owner: { $nin: [] } }all threeREFUSED
{ $not: { owner: { $in: [] } } }all threeREFUSED
{ $not: { owner: [] } }all threeREFUSED
{ $not: { owner: { $in: [], $ne: 'u_other' } } }all threeREFUSED
{ $or: [{ $not: { owner: { $in: [] } } }, { owner: 'u_me' }] }all threeREFUSED
{ $not: { $not: { owner: { $nin: [] } } } }all threeREFUSED
{ owner: { $in: [] } }nonenone
{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }the own rowthe own row
{ $and: [{ owner: { $in: [] } }, { owner: 'u_me' }] }nonenone
{ owner: 'u_me' }the own rowthe own row

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 from packages/services/service-analytics/src/read-scope-sql.ts and called at the two engine-bound merges on the ObjectQL strategy:

  1. ObjectQLStrategy.withReadScope — immediately after ctx.getReadScope(objectName) returns and before the $and merge 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.
  2. ObjectQLStrategy.resolveFkAttr — the second getReadScope-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:

  • Same envelope.readScopeCompileErrorREAD_SCOPE_COMPILE_FAILED / 500, withheld from the response by declaration, exactly as the module's other refusals are. No second error vocabulary.
  • Refused, not reduced. An emptied exclusion is a refusal on both routes now, not a silent fold.
  • The asymmetry is preserved.$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 by rls-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, matching compileOperator's own $nin arm, 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.
  • Nothing re-decides the ruled follow-up.$not over $in: [] still compiles to constant TRUE inside compileScopedFilterToSql; 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.
  • Second line of defence, not a replacement. The producer-side polarity guard in plugin-security is untouched, and deliberately not shared code: it lives in a layer service-analytics must 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:

  • the six vacating spellings above, each asserted to carry READ_SCOPE_COMPILE_FAILED / 500 and to name the offending path;
  • a harness control (no scope admits every row) so a refusal assertion cannot pass because the fixture admits nothing;
  • the asymmetry ($in: [] still means zero rows) and both over-denial controls;
  • the ordinary case, including a non-empty $nin keeping its NULL-safety;
  • the FK-resolution door, with an ordinary-scope control beside it;
  • an immobility block over compileScopedFilterToSql itself — 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-$nin refusal 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 $not over $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/*.ts directly — there is no dist leg to rebuild, and none is claimed.

HEAD_BLOB=baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
PRE_HASH =baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
CALLS_BEFORE=2
CALLS_AFTER=0 MARKERS=2
MUT_HASH =48b7d5978c72a5a1b9066f29477f9a9c7d1db033
MUTATION CONFIRMED ON DISK (2 anchored call sites replaced, blob hash moved)
x `{ owner: { $nin: [] } }` is REFUSED - pre-fix it admitted the whole table
x `{ $not: { owner: { $in: [] } } }` - the other spelling - is REFUSED too
x the bare `[]` comparand under `$not` is the same emptied membership, and is REFUSED
x a sibling operator does not rescue it: `{ $in: [], $ne }` under `$not` is REFUSED
x nested inside a composite it is still REFUSED - a TRUE `$or` arm absorbs the scope
x an empty `$nin` is refused at EVERY polarity, matching the compiler arm
x a vacating referenced-object scope is REFUSED at that merge too
Tests 7 failed | 11 passed (18)
AssertionError: expected [ 'r1', 'r2', 'r3' ] to be undefined
- Expected: undefined
+ Received: [ "r1", "r2", "r3" ]
POST_HASH=baeb0aee2d68d940d0a4aa9ee305622bfcb16a42
RESTORE PROVEN BY STATE: git diff HEAD empty, blob hash back to HEAD blob, 0 markers left

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 test85 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 --listFiles shows both edited sources and the new test file inside the program, so the green covers the edits rather than merely running.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (both output sections read whole; no .mdx in the diff): 35 commands, 34 green. The one exception is scripts/check-test-completeness.mjs, which exits 3 = PREREQUISITE NOT MET because it grades a saved turbo run test log that only CI produces — recorded as NOT MEASURED, not as a pass and not as a red. check:type-check-debt first refused for an unbuilt closure; the closure named in lint.yml was 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

…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.
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 8 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 4902a250439a6b054ccb71ac7d0c12762712cc6fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 695fcd2fefb1a60f591ab2890df3cd3a8f2b68ec — the merge of head a050efce99c2c60b2a47a6bf862f45448657acda into base 4902a250439a6b054ccb71ac7d0c12762712cc6f, 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 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

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

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude