Skip to content

fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope - #20017

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-19995-objectql-read-scope-envelope
Sep 24, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-19995-objectql-read-scope-envelope

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #19995 — this PR closes the comparand half of the card: every scope shape the engine's two SHARED comparand faces refuse. The card stays open for the residue: scope shapes refused by engine doors that read the object's schema or the request's context, and by driver-sql, still answer a relayed 400. Why that half is not done here, and the decision it needs, is under "Residue" below.

Clause-②: no

What changed

The ObjectQL execute face composed a row-level read scope into the engine's where without judging it. A scope carrying a comparand the engine refuses came back as the engine's INVALID_FILTER / 400. The HTTP doors relay a 4xx's message, and that message named the policy's field and comparand. The NativeSQL face and the /analytics/sql echo refuse the same scope as READ_SCOPE_COMPILE_FAILED / 500 with the message withheld (the #5367 ruling, re-affirmed as #7598 Q2 = A). So one scope got two envelopes, depending on which analytics face served it.

Why at the merge boundary, on the scope alone. At that point the scope is still a distinguishable object. One line later it is $and-composed with the caller's filter, and then nothing downstream can tell whose clause a refusal came from.

Why the refusal set does not move. The engine's comparand-shape refusal does not read the 'policy' provenance mark (measured below). Both faces are pure walks whose verdict on a subtree depends neither on the rest of the tree nor on any schema. So the scope alone answers exactly as the scope inside { $and: [userFilter, scope] } does, and nothing the engine serves is refused. A { $field } scope (served on this path under #7598 Q1 = B) is stepped around by both faces, as the engine steps around it.

⛔ This is not a catch around executeAggregate. The caller's own where still reaches the engine's doors for some shapes, and those keep their INVALID_FILTER / 400 with the message. This is pinned, and ablation A5 below shows that pin going red under misattribution.

Measurement: recorded before the fix

Measured on base 14add487b4, using a scratch probe that is not committed: AnalyticsService (ObjectQL only) over a real ObjectQL, run twice, once over SqlDriver (better-sqlite3) and once over SqliteWasmDriver. The two drivers gave the same answer in every row. The HTTP leg went through the real routes: @objectstack/runtime's dispatcher POST /api/v1/analytics/query and @objectstack/rest's POST /analytics/dataset/query. The read scopes are the getReadScope contract filled by hand. The same probe was re-run after the fix, against a rebuilt dist.

Read-scope shape class Layer that answers on the ObjectQL face Before (ObjectQL face and both HTTP doors) Refusal text names policy content After
List in the implicit equality slot Engine, shared list-shape face INVALID_FILTER / 400, relayed yes READ_SCOPE_COMPILE_FAILED / 500, withheld
List under $eq Engine, shared list-shape face 400, relayed yes 500, withheld
Scalar under $in or $nin Engine, shared list-shape face 400, relayed yes 500, withheld
One-bound $between Engine, shared list-shape face 400, relayed yes 500, withheld
Null member in $in Engine, shared list-shape face 400, relayed yes 500, withheld
Plain-object member in $in; plain-object comparand under $eq; undefined comparand Engine, shared comparand-type face 400, relayed yes 500, withheld
Emptied $nin (control) Analytics vacancy guard (#13640) 500, withheld no (withheld) unchanged
List under $ne or $gt; nested relation value; cross-field reference driver-sql, which reads the mark 400, message withheld no unchanged
Column the object does not have driver-sql, missing column 400, relayed yes unchanged (residue)
Retired or unknown operator driver-sql operator vocabulary 400, relayed yes (the field) unchanged (residue)
Text operator on a non-text field Engine, declared-type door 400, relayed yes unchanged (residue)
Temporal comparand the platform cannot read Engine, temporal door 400, relayed yes unchanged (residue)
Combinator with a non-array operand driver-sql 400, relayed yes unchanged (residue)
Non-boolean $null or $exists driver-sql 400, relayed yes (the field) unchanged (residue)
Unknown filter placeholder Engine token resolver FILTER_TOKEN_UNKNOWN / 400, relayed yes unchanged (residue)
Well-formed scope (control) — 200, scoped rows — unchanged
  • Hypothesis 2 (does the engine refusal read the mark?): confirmed. The scope reached the engine stamped 'policy' by withReadScope, and the comparand-shape refusal still returned its full text. Of the refusals in the table, only driver-sql's bind and cross-field refusals read the mark.
  • Hypothesis 3 (does the ObjectQL face reach the read-scope compiler?): confirmed. The ObjectQL execute face never reaches compileScopedFilterToSql. The NativeSQL face and the echo answered READ_SCOPE_COMPILE_FAILED / 500 for every fixed class except two: a plain-object comparand under $eq and a null member in $in. That compiler compiles both of those (see Acceptance notes).
  • Scope reads quoted. The strategy has four:
    • generateSql's echo merge compiles through compileScopedFilterToSql, is already withheld, and is untouched.
    • withReadScope and resolveFkAttr are the two engine-bound merges, and both are now guarded.
    • NativeSQLStrategy.applyReadScope compiles and is untouched.

Tests

The new file is packages/services/service-analytics/src/__tests__/objectql-read-scope-refusal-envelope.test.ts, with 19 cases over a real ObjectQL and SqliteWasmDriver.

  • Refusal cases. Each refused shape class asserts:
    • code READ_SCOPE_COMPILE_FAILED and status 500;
    • that the reads every analytics HTTP door takes before relaying prose (serverFaultProvenance(resolveThrownHttpError(err, 500)) is 'declared', declaredRefusalMessage(err) is undefined) mean the prose is withheld;
    • that the thrown message, which is the operator's log channel, still carries the detail.
  • Paths covered: the direct path, a well-formed caller where beside a refused scope, the cross-object base scope, and the referenced-object scope.
  • Controls:
    • a well-formed scope is served with its rows;
    • a cross-field scope is served;
    • an emptied $in beside an own-rows grant is served;
    • a well-formed referenced-object scope buckets what it hides as (restricted);
    • the caller's own where refused by the ENGINE stays INVALID_FILTER / 400 with its message;
    • the caller's own where in the scope's refused shape stays 400 with its message.

Results:

  • Measurement commit e4908254bc (test only, before the fix): Tests 13 failed | 6 passed (19). Every refusal case received INVALID_FILTER, and every control was green.
  • At 9a40317b15:
    • the new file gives Tests 19 passed (19);
    • the whole package gives Test Files 118 passed (118), Tests 2560 passed (2560);
    • pnpm --filter @objectstack/service-analytics typecheck exits 0, and tsc --listFiles includes the new test.

Ablations. Each leg ran through scripts/ablation-replace.mjs (the anchor must hit exactly once, and the blob must change) with a trap. Each restore was proven: the blob equals HEAD, and git diff HEAD is empty.

Leg Mutation Result
A1 Delete the withReadScope call 12 failed, 7 passed. Every direct and cross-object-base refusal case received INVALID_FILTER.
A2 Delete the resolveFkAttr call 1 failed (the referenced-object case), 18 passed
A3 Delete the comparand-type face call 3 failed (the three type-face rows)
A4 Delete the list-shape face call 10 failed (the shape rows, the composed case, both cross-object cases)
A5 Judge the COMPOSED tree instead of the scope alone 1 failed: the caller's engine-refused where received READ_SCOPE_COMPILE_FAILED instead of INVALID_FILTER

Gates

  • Derived gates. Derived at 9a40317b15 with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 72 families, a superset of the dispatch-time list. All 72 exited 0.
    • check:dual-build-cjs-loads and check:type-check-debt first answered PREREQUISITE NOT MET (exit 3). I built the ./packages/* closure and re-ran both, and both exited 0.
    • The --ran reconciliation reported 72 derived, 72 run, 0 unrun.
  • Issue citations. GITHUB_TOKEN=… node scripts/check-issue-citations.mjs exited 0: 10 citations judged, 10 resolve.
  • Lint, narrowed to the four touched TypeScript files. eslint --no-inline-config --format json read 4 files and reported 0 errors and 0 warnings. eslint.config.mjs enables no type-aware linting (no parserOptions.project, no projectService), so this diff cannot move a verdict on an untouched file. pnpm lint itself is CI's.

Acceptance notes

  • @objectstack/objectql is a new devDependency, aliased to source in the package's vitest.config.ts with an anchored regex. This is the repair check:test-source-alias prescribes; the ledger does not grow, and the gate is green. The refusal under test is the engine's own, so a stub bridge would only have tested the stub.
  • Envelope reuse. The guard lives in read-scope-sql.ts because readScopeCompileError is module-local by design. A guard in the strategy file would have needed a second spelling of the envelope.
  • A host with its own executeAggregate bridge: the scope is now judged against the spec's shared comparand faces whichever bridge executes, the same posture as the vacancy guard. A custom bridge that used to tolerate one of these off-contract scope shapes now gets the withheld 500. The engine bridge refused all of them already.
  • Precedence. When the caller's where and the scope are both refused, and only the engine would refuse the caller's clause, the scope's 500 now answers first. A caller clause refused by the analytics where door still answers first, with its 400.
  • NativeSQL face and echo: unchanged. They compile two shapes (a plain-object comparand under $eq, a null member in $in) that the shared faces and the ObjectQL face refuse. This was measured with a stubbed executeRawSql, so the database-level outcome is unmeasured. It is reported to the seat and not touched here.
  • origin/main has moved on by one commit (fc6ddb87a4, driver-sqlite-wasm text round-trip). It shares no path with this diff and is not merged.
  • Files not touched: filter-normalizer.ts, packages/spec, packages/objectql, packages/rest, packages/runtime.

Residue: why "Part of"

These classes still answer the engine's or the driver's 400 with the message relayed:

  • a column the object does not have;
  • a retired or unknown operator;
  • a text operator on a non-text field;
  • a temporal comparand the platform cannot read;
  • a combinator with a non-array operand;
  • a non-boolean $null or $exists;
  • an unknown filter placeholder.

They are refused by engine doors that read the object's schema or the request's context, and by driver-sql's own compile. This package cannot judge them without a second copy of those rules. The sound fix is at the refusing layer: the refusal reads the 'policy' provenance mark (#8220), as driver-sql's bind and cross-field refusals already do. That change is in the engine and driver lanes, and it opens an envelope question between the #5367 and #7929 rulings. It is handed back to the seat as a decision.


Generated by Claude Code

…pe refusal envelope

Runs the ObjectQL strategy against a real ObjectQL engine over
SqliteWasmDriver with hand-filled read scopes the engine's shared comparand
faces refuse. Committed before the fix: on this tree every refusal case
answers the engine's INVALID_FILTER / 400 (13 red), while the six controls
(well-formed scope, cross-field scope, emptied $in beside an own-rows grant,
the caller's own where refused by the engine and by the analytics door, a
well-formed referenced-object scope) are green.

Adds @objectstack/objectql as a devDependency, aliased to source in the
package's vitest config so the verdict is about the checkout, not a build.

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
…le read scope in the withheld 500 envelope

A read scope carrying a comparand the engine's shared comparand faces refuse
reached engine.aggregate composed with the caller's filter and came back as
the engine's INVALID_FILTER / 400, whose message the HTTP doors relay. The
NativeSQL face and the echo refuse the same scope as READ_SCOPE_COMPILE_FAILED
/ 500 with the message withheld.

assertReadScopeComparandsRunnable (read-scope-sql.ts, next to the vacancy
guard) runs the engine's own two faces, assertListComparandShapes and
normalizeFilterComparandTypes from @objectstack/spec/data, on the scope alone
and re-raises any refusal in the module's one envelope. It is called at both
engine-bound merges: withReadScope (direct and cross-object base) and
resolveFkAttr (the referenced object's scope). The caller's own where is not
judged here and keeps its 400.

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
…ead of casting it away

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 4 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-analytics/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

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

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

Which tree this was computed on

This run read content/docs from 4687452427a9942d0f0817997ed3b3403ad743c9 — the merge of head 9a40317b15ed67c01ea5d7eaca0a8fb9afdfb938 into base a0920b42dc33efadc483f5112da36cfa53124cbb, 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 4687452427a9942d0f0817997ed3b3403ad743c9 && git checkout 4687452427a9942d0f0817997ed3b3403ad743c9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a0920b42dc33efadc483f5112da36cfa53124cbb 9a40317b15ed67c01ea5d7eaca0a8fb9afdfb938 && git checkout -B drift-repro a0920b42dc33efadc483f5112da36cfa53124cbb && git merge --no-ff 9a40317b15ed67c01ea5d7eaca0a8fb9afdfb938

node scripts/docs-audit/affected-docs.mjs --json a0920b42dc33efadc483f5112da36cfa53124cbb

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

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

@github-actions github-actions Bot added dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 24, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 24, 2026 19:58
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit 7b76fff Sep 24, 2026
36 of 37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19995-objectql-read-scope-envelope branch September 24, 2026 20:28
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… the shared comparand-shape face on the object spelling (objectstack-ai#20010) (objectstack-ai#20032)

Part of objectstack-ai#20010: this PR carries the shared comparand-SHAPE face's arms.
objectstack-ai#20010 remains open for two arms: `$ne` with a list, which waits on
objectstack-ai#19886 stage 2, and the comparand-TYPE face, which the card has answered
and objectstack-ai#20035 carries.

Clause-②: no (narrowing)

## What this changes

The analytics `where` door (`lowerAnalyticsWhere` in
`packages/services/service-analytics/src/strategies/filter-normalizer.ts`)
now hands every field entry of an object-form condition to the shared
comparand-shape face, `assertListComparandShapes`
(`@objectstack/spec/data`). Before, it ran only the equality arm (PR
objectstack-ai#20008). Each shape below is now refused with `INVALID_FILTER` / 400,
carrying the face's own message, path and prescription. The
`FilterArray` spelling of the same condition gets the same bytes,
because it met the face inside `parseFilterAST` all along.

The face's docblock records the rulings that put these positions on it:

- 2026-08-31 (objectstack-ai#13357): "A `null` member of `$in` / `$nin`, and a `null`
`$between` endpoint (objectstack-ai#13495's shape), are refused at this door".
- 2026-09-01 (objectstack-ai#14080): "A `null` comparand of `$gt` / `$gte` / `$lt` /
`$lte` … refused at this door, same envelope, so the divergent cells are
constructively unreachable".
- 2026-09-20 (objectstack-ai#19071): "`''` and `undefined` are refused, naming the
blank side (MIN / MAX and the index)".
- Its original rule (objectstack-ai#5869, moved to the face by objectstack-ai#9228): `$in` / `$nin`
/ `$between` receive a list at all.

How:

- `assertWhereComparandShapes` makes two passes over one shared
traversal, `forEachWhereFieldEntry`. The traversal walks `$and` / `$or`
/ `$not` and field entries, the way the face walks them, and also
descends a nested relation, because this compiler flattens one to its
dotted member.
- Pass 1 is the objectstack-ai#19888 equality pass, unchanged, so a list in the
equality slot is still diagnosed first.
- Pass 2 hands each whole entry to the face as a one-entry node with the
same path seed, `where`. The face therefore reports exactly the path and
field it reports on the whole condition.
- The draft-data preview (`preview-evaluator.ts`) calls the same
exported gate, so a drafted chart refuses what the published chart
refuses.

## Measured first (recorded on this branch as `a508dddc2c`, before any
source change)

Base `origin/main` `44639665ee`. The harness was a real sql.js engine
(driver-sqlite-wasm) over rows d1 (amt 1, 'won'), d2 (5, 'lost'), d3
(10, 'open'), d4 (NULL, NULL) and d5 (3, ''). It ran five faces:

- `normalizeAnalyticsFilterTree`;
- the native SQL execute;
- the `/analytics/sql` echo;
- the ObjectQL engine path, where a probe `engine.aggregate` records the
condition it receives and the engine seam's two spec faces are then run
on it;
- the draft preview.

| arm | object spelling at base | `FilterArray` spelling | governing
record |
|:--|:--|:--|:--|
| null `$in` member `{stage:{$in:['won',null]}}` | tree `in
['won',null]`; native `stage IN (?, ?)`: d1; the engine seam refused it;
the preview served d1, d4 | 400, null list member | 2026-08-31 ruling,
objectstack-ai#13357 record 5472662504 |
| null `$nin` member | `notSet OR notIn ['won',null]`; native: d4 only;
the preview served d2, d3, d5 | 400 | 2026-08-31 |
| null ordering comparand `$gt`/`$gte`/`$lt`/`$lte` | `amt > NULL`: no
row; the preview served d1, d2, d3, d5 for `$lt` / `$lte` | 400, null
ordering comparand | 2026-09-01 ruling, objectstack-ai#14080 record 5494521768 |
| null `$between` endpoint `[null, 5]` | `amt >= NULL AND amt <= 5`: no
row; the engine received `{amt:{$gte:null,$lte:5}}` and refused it as a
`$gte`, an operator the author never wrote | 400, null range bound |
2026-08-31 |
| blank `$between` endpoint `['', 5]` | `amt >= '' AND amt <= 5`; the
engine path ACCEPTED `{$gte:''}`; the preview served d1, d2, d5 | 400,
blank bound, MIN named | 2026-09-20 ruling, objectstack-ai#19071 record 5748839561 |
| scalar `$in` `'won'` | laundered to `in ['won']`: native d1; the
engine accepted it as a list | 400, requires an ARRAY | objectstack-ai#5869 / objectstack-ai#9228 |
| scalar `$nin` `'won'` | laundered: native d2, d3, d4, d5; the engine
accepted it | 400 | objectstack-ai#5869 / objectstack-ai#9228 |
| `$in: null` | `in [null]`; the engine refused it as a null MEMBER
(after the laundering) | 400, requires an ARRAY | objectstack-ai#5869 / objectstack-ai#9228 |
| undefined `$between` endpoint | refused 400, in this door's objectstack-ai#6386
wording | refused 400, the face's blank wording | 2026-09-20 names
`undefined`; wording only |
| `{ $field }` `$between` endpoint | refused 400, this door's objectstack-ai#7598
wording | refused 400, the face's wording | 2026-08-11 (objectstack-ai#7596); wording
only |
| one-bound / scalar `$between` | refused 400, this door's "two-element"
wording | refused 400, the face's wording | objectstack-ai#5869 / objectstack-ai#9228; wording only
|
| nested relation `{acct:{amt:{$gt:null}}}`, dotted `{'acct.amt':…}` |
compiled `acct.amt gt [null]` | refused | as the arm |
| `$ne` with a list `{stage:{$ne:['won','lost']}}` | both spellings
compile `stage IS NULL OR stage != 'won'`; `'lost'` is dropped | the
same: the face does not judge `$ne` yet | waits on objectstack-ai#19886 stage 2
(ruling A, record 5805254639) |

After the change, re-measured at `142b554f0e`, every row above except
`$ne` is refused on all five faces. The object message equals the
`FilterArray` message byte for byte, for every arm that has a
`FilterArray` spelling. The one exception is the nested-relation
spelling: the face names the leaf key (`"amt"` at `where.acct.amt.$gt`),
which is the convention objectstack-ai#19888 set, while the dotted spelling names
`"acct.amt"`. The `$ne` row is unchanged on both spellings.

**Stored filters in this repository.** A text scan covered 4013 non-test
tracked files under `examples/`, `packages/`, `content/`, `skills/`,
`apps/` and `docs/`. Its detectors were: a `null` member of a `$in` /
`$nin` list; a `null` ordering comparand; a `null` / blank / `undefined`
`$between` endpoint; and a scalar `$in` / `$nin`. Positive controls: a
synthetic fixture hit on all four detectors; C1 = 451 `$in`/`$nin`
lists, C2 = 495 ordering operators and C3 = 61 `$between` lists were
counted. Every hit is a false positive: a comment, an operator-name map,
a doc line or a CHANGELOG line. **No authored filter carries a moved
shape.** Limits: this is a text scan, not an AST scan, so a comparand
held in a variable is invisible to it. Deployed `sys_metadata` rows: NOT
MEASURED.

## Compile surfaces (the face's non-equality arms)

| surface | verdict |
|:--|:--|
| `lowerAnalyticsWhere` / `normalizeAnalyticsFilterTree`: caller
`where`, dataset scope `filter`, measure `filter`; native execute,
`/analytics/sql` echo, ObjectQL engine path | **changed.** Every arm is
refused before any statement or `engine.aggregate` call. Measured over
sql.js before and after; pinned per face. |
| `evaluateAnalyticsQueryOverRows` / `matchesWhere` (draft preview) |
**changed**, as a bounded in-place fix (see Deviations). It calls the
same exported gate. |
| `assertListComparandShapes` (the spec's shared face) | **already
compliant.** It is the face; this PR calls it and does not change it. |
| `parseFilterAST` (this door's `FilterArray` spelling) | **already
compliant.** Measured at base: every arm's `FilterArray` spelling was
refused. |
| ObjectQL engine seam (`lowerWhereFilterArray`, `engine.ts`) |
**already compliant.** It runs the shape and type faces on the object
form. At base it refused the null arms it received, but it accepted what
this door had already laundered (`$in: ['won']`, `$gte: ''`). That is no
longer reachable from this door. |
| `compileScopedFilterToSql` (service-analytics read scope) | **out of
scope.** It is a separate door with a separate envelope (500), and
`read-scope-sql.ts` was held by PR objectstack-ai#20017 during this dispatch. Not
re-measured here. |
| `applyFilterCondition` (driver-sql), `checkCondition` (driver-memory),
`buildWhereSQL` (driver-turso), `matchesFilterCondition` (formula),
`translateFieldOperators` (driver-mongodb) | **already compliant at the
platform doors.** Each sits behind `parseFilterAST` or the engine seam,
which run the face (the rulings' own scope). Not re-measured here. |
| `applyHaving` / `matchesHaving` (objectql HAVING) | **out of scope.**
A different door, over aggregated rows. |

## Tests and evidence (head `33c73fdff4`, `origin/main` `26550c6603`
merged in)

- **Round 2 (`33c73fdff4`): a consumer pin in `packages/rest`.** CI was
red on `a1bdbf1d59` (Test Core 3/6):
`src/analytics-filter-refusal-envelope.test.ts` asserted the old
analytics sentence for a one-bound `$between` (`/needs a two-element
\[min, max\] array/`). The verdict was already right; only the message
had moved. That one case is re-judged, test-only. Its verdict assertions
stay (400 `INVALID_FILTER` through the real dataset route, which is
objectstack-ai#5352's point), and the message assertion now matches the face's
sentence, with a note citing the face's rule (objectstack-ai#5869, moved to the face
by objectstack-ai#9228).
- **Census of consumer pins** on the three re-worded refusals, across
every test file under `packages/**` and `examples/**` (4014 files). It
searched for the analytics "two-element" sentence, the objectstack-ai#7598 `{ $field
}` endpoint sentence ("may not be a field reference", "has the field
reference"), the objectstack-ai#6386 `undefined` sentence at a `$between` position,
and the `[analytics] "$between"` prefix. It then checked, one by one,
every test in the 100 files outside this package that reach
`service-analytics`, for `$between` / null / scalar-`$in` shapes. **One
hit:** this `packages/rest` case. Every other "two-element" hit is a
date-range arity message, a search-term arity or a spec test. The
remaining `$between` hits are drivers' own refusals or the read-scope
door, none of which this PR moves.
- **Full `@objectstack/rest` suite** (built against this branch's
`service-analytics` `dist/`): 194 files, 3265 passed and 1 skipped.
`VERDICT command-exit 0`. The touched file alone: 31 passed.
- **Ablation D, dist-resolved.** This case reads
`@objectstack/service-analytics` through `dist/`, so it was run as a
dist ablation. The all-arms pass was removed through
`scripts/ablation-replace.mjs`, the package was rebuilt, and
`ablation-dist-preflight` put the marker in 2 built files. Predicted 1;
measured **1 red / 30 green**: `expected '[analytics] "$between" on
"amount" ne…' to match /Operator "\$between" on field "amount…/`.
Restore: source blob == HEAD, rebuilt, `--absent` read the marker absent
from all 6 built files, whole tree clean.
- `pnpm --filter @objectstack/rest typecheck` exits 0, but that
package's `tsconfig` does not compile its test files (0 in
`--listFiles`), so it measures nothing about this one. `eslint
--no-inline-config` on the file: 0 errors, 0 warnings.

- **New `src/__tests__/where-face-arms-refusal.test.ts`, 101 tests.**
Every refusal asserts `code` and `status`, plus the face's opening
sentence.
- Every arm at 25 positions: each null, blank, non-list, one-bound and
`{ $field }` shape; under `$and`, `$or`, `$not` and `$not` over `$or`;
beside a legal operator; inside a nested relation; on a dotted member.
- Byte identity (17): the object spelling equals the `FilterArray`
spelling, which equals the face's own message.
- What is diagnosed first (3). The equality list comes first even when
another refused entry precedes it. The face comes before this door's
member gates (objectstack-ai#6386 undefined, objectstack-ai#6444 mixed wrapper). CONTROL: an
`undefined` outside a `$between` endpoint keeps objectstack-ai#6386's sentence.
- `$ne` held as PARITY with the face (2). The test does not pin the
objectstack-ai#19886 defect in either direction.
- 15 neighbouring shapes that compile as before: `$in: []`, `$nin: []`,
falsy members, scalar ordering comparands, `{ $field }` in an ordering
slot, the null predicate spellings, `$contains: null`, a nested scalar,
and four legal `$between` ranges, including a whitespace-only endpoint,
which the face does not judge.
- Four faces over a real engine × 8 arms, plus a CONTROL. Each is
refused before any statement and before any `engine.aggregate` call.
- A stored dataset through the service doors (6): the dashboard door and
the draft preview, for a scope filter and a measure filter; the
registered cube on the ObjectQL door; and a CONTROL with the face's
prescribed spelling.
- **Pins re-judged with a note, each quoting the ruling that moves it:**
- `comparand-door-single-source.test.ts`: the `null` row's `whereIn`
cell goes from `accept` to `INVALID_FILTER/400` (2026-08-31). Its
six-types assertion now names that one exception.
- `filter-normalizer-undefined-comparand.test.ts`: four `null`-control
rows leave the group: `$gt: null` (objectstack-ai#5526's by-construction reading,
which the face records was a position "no ruling covers" until
2026-09-01), `$in: [null]`, `$nin: [null]` and `$between: [null, 5]`.
The `$between: [undefined, 5]` row moves to the new file (2026-09-20
names `undefined`).
- `filter-value-type-fidelity.test.ts`: `$gt: null` goes from "binds
NULL" to refused before anything binds. The case's own concern, that no
`''` is bound, still holds.
- `comparand-shape-refusal.test.ts`: the `$in` member list drops its
`null`, and the null-carrying list is asserted refused in the face's
words.
- `cross-field-reference-refusal.test.ts` (2),
`filter-operator-coverage.test.ts` (1) and
`filter-refusal-envelope.test.ts` (1): a `{ $field }` endpoint and a
one-bound `$between` keep their verdict and now carry the face's
wording.
- **Package run** (`pnpm --filter @objectstack/service-analytics test`,
at `a1bdbf1d59`; no `service-analytics` file changed since): 119 files,
2656 tests passed. `typecheck` exited 0, and `tsc --listFiles` includes
all ten touched files. Base (before the merge of `origin/main`): 117
files, 2541 tests.
- **Ablations.** Each ran from the committed fix through
`scripts/ablation-replace.mjs` (the anchor must hit, the mutation is
verified on disk, the restore is proven by the blob matching HEAD and an
empty `git diff HEAD`), with the red count predicted before running. The
tests import the source by relative path, so no build is on the path.
- **A: the all-arms pass removed** (the equality pass kept). Predicted
87; measured **87 red / 2550 green**: 80 in the new file, plus the 7
re-judged pins in 6 files. Samples: `native execute: expected a refusal,
got rows: expected [ 'd1' ] to be undefined`; `draft preview: expected a
refusal, got rows: expected [ 'd1', 'd4' ] to be undefined`; `expected
'accept' to be 'INVALID_FILTER/400'`.
- **B: the preview's gate call removed.** Predicted 16; measured **16
red / 2621 green**: this file's 8 preview face cells and 2 stored
preview-door cells, plus objectstack-ai#19888's 4 and 2, since the one call now
carries both. Sample: `draft preview: expected a refusal, got rows:
expected [ 'd1', 'd2', 'd3', 'd4' ] to be undefined`.
- **C: the equality-first pass removed.** Predicted 1; measured **1 red
/ 2636 green**: the precedence case. The all-arms pass runs the face's
equality arm per entry, so pass 1 buys exactly the cross-entry order.
- **Gates.** `dispatch-gates --repo objectstack-ai/objectstack
--commands` derived 61 at `33c73fdff4`: the `packages/rest` edit adds
`check:dispatcher-error-vocabulary`, which round 1 ran from the dispatch
list. The union was re-run on this head.
  - 59 exited 0.
- **NOT MEASURED (2):** `check:dual-build-cjs-loads` and
`check:type-check-debt` exited 3 (PREREQUISITE NOT MET: they need the
whole workspace built, which CI does).
  - `--ran`: 61 derived, 59 run, 2 NOT-MEASURED, 0 UNRUN.
- Among the passes: `check-adr-0087-registration --base origin/main`
(one declared-breaking changeset, `not-required (already-registered)`),
`check-changeset-no-major`, `check:changeset-gate-self-tests`,
`check:nul-bytes`, `check:where-matcher`, `check:test-source-alias`, and
`check-issue-citations` in its board-probing mode (32 citations, all of
which resolve).
- **Lint, narrowed to the change.** `eslint --no-inline-config --format
json` over the 10 touched TypeScript files: 10 files, 0 errors, 0
warnings. `eslint --print-config` resolves a config for each.
`eslint.config.mjs` never enables type-aware linting (its note near line
327), so this diff cannot move the verdict on an untouched file.

## Deviations from the dispatch, stated

1. **The comparand-TYPE face is not carried.** Measured, the object
spelling skips it too. A plain object under `$gt` / `$ne` / `$eq`, a
`$between` endpoint that is a plain object, a binary or `Map` comparand,
and a bigint beyond 2^53 are all accepted on the native path, bound as
JSON text or as-is. `{stage:{$ne:{a:1}}}` served every row. The
`FilterArray` spelling and the engine seam refuse each of them.
`undefined` is not one of these: it was already refused on both
spellings (the wording differs).
- Measured cost of carrying the whole type walk the way `parseFilterAST`
and the engine seam run it: **48 pins red, 35 more than the shape
face**, across nine files.
- It re-words this door's own refusals from five earlier cards: objectstack-ai#6386's
undefined prescription, objectstack-ai#5234's unbindable `$in` member and LIKE-family
sentences, objectstack-ai#6444's order, and objectstack-ai#7598 / objectstack-ai#7693.
- It flips the `{ $eq: {…} }` account that objectstack-ai#5234 "left open" and that
objectstack-ai#7598 made load-bearing, and objectstack-ai#8186's recorded binary admission.
- Carrying only the accept-cells instead needs the type walk run after
this door's own gates, as an assert whose narrowing is discarded. That
is a second architecture.
- Two readings lead to two architectures, so this set is returned as a
decision (the dispatch's four-axis block, in the report). No
door-specific ruling gives these cells a different meaning; the objectstack-ai#7872
ruling covers them. The stop is taken on the role file's
two-architectures clause, not on Section 1's ruling clause.
- **Round 2:** the card has answered it, and objectstack-ai#20035 carries it. Nothing
of it is started here.
2. **File surface.** The claim declared `filter-normalizer.ts`, the
matrix's `where*` cells, the named pin files, new tests and the
changeset. This PR also touches:
- `src/preview-evaluator.ts`: one import, one call and comments. This is
a bounded in-place fix, and all four conditions hold. It is the same
defect class. It is a mechanical call of the same exported gate, whose
shape the face pins. No open PR touches the file: objectstack-ai#20017, the only other
PR in this package, landed before this branch merged `origin/main`. It
is the same gate family, with no new verification surface. Without it,
this change would have created a publish-refuses / preview-charts split
for every moved arm. The claim's file surface needs this path added.
- `comparand-shape-refusal.test.ts`,
`cross-field-reference-refusal.test.ts`,
`filter-operator-coverage.test.ts` and
`filter-refusal-envelope.test.ts`: existing pins on a moved arm or its
wording, re-judged with notes.
- **Round 2:**
`packages/rest/src/analytics-filter-refusal-envelope.test.ts` (the
`domain:cli` lane), one case, test-only. The seat authorized it as this
claim's own follow-through, and the claim's file surface is revised on
the card. The census found no other consumer pin.
3. **ADR-0087 disposition.** The changeset's marker is `not-required
(already-registered filter-between-blank-endpoint-refused)`. The blank
endpoint is the one moved arm whose transition is on the ledger. The
null-member, null-endpoint and null-ordering transitions were ruled at
the face with no ledger entry: their changesets declared
`no-migration-prescription`. This changeset carries the FROM → TO table
the dispatch asked for, so `no-migration-prescription` would be refused
by the gate. The marker's own text states each arm's ledger status.
4. **`origin/main` merged in** (`da44a2be39`): PR objectstack-ai#20017 landed in this
package during the run. After a reinstall and a rebuild, the package
suite, the typecheck and the gate union were re-run on the final head.
5. **Round 1 missed a consumer outside the package.** Round 1 ran only
the `service-analytics` suite and so did not see the `packages/rest` pin
on the re-worded one-bound `$between` sentence; CI did. Round 2 fixed
that case, ran the census, and ran the full `@objectstack/rest` suite.

## Acceptance notes

- **`$ne` with a list** is carried the day objectstack-ai#19886 stage 2 puts its
refusal on the face, with no change here. The parity test holds this
door to whatever the face answers.
- **Wording convergence.** Three shapes this door already refused now
read in the face's words: a one-bound `$between`, a `{ $field }`
endpoint and an `undefined` endpoint. `fieldLeaves`' own checks for them
stay as that function's invariants, annotated, and are no longer reached
from the door.
- **Nested-relation field naming** follows objectstack-ai#19888: the leaf key in the
message, the full path in `at`.
- **Draft preview and an `undefined` comparand** (not this card's arm;
reported). `evaluateAnalyticsQueryOverRows` answers no row for `{stage:
undefined}` / `{amt: {$gt: undefined}}`, while every published face
refuses it 400 (objectstack-ai#6386). The preview never ran objectstack-ai#6386's gate. This is
reachable only in-process, because `undefined` cannot cross JSON.

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…s/sql echo refuse a read scope the shared comparand faces refuse (objectstack-ai#20046)

Fixes objectstack-ai#20018

Clause-②: no (narrowing)

## What changed

`compileScopedFilterToSql`
(`packages/services/service-analytics/src/read-scope-sql.ts`) is the
read-scope lowering behind two analytics faces:

- the NativeSQL execute face, `NativeSQLStrategy.applyReadScope`, for
the base table and every joined hop;
- the `/analytics/sql` echo, `ObjectQLStrategy.generateSql`.

It is also a public export of the package. Once its own lowering
returns, it now calls `assertReadScopeComparandsRunnable` on the scope.
That is the helper PR objectstack-ai#20017 added in the same module, and it runs
`@objectstack/spec/data`'s `assertListComparandShapes` and
`normalizeFilterComparandTypes` on the scope alone.

A scope those faces refuse now gets `READ_SCOPE_COMPILE_FAILED` / 500
with the message withheld (the objectstack-ai#5367 ruling, re-affirmed as objectstack-ai#7598 Q2 =
A) on the native face and the echo. The refusal comes before any
statement is built or executed. That is the answer the ObjectQL execute
face has given since PR objectstack-ai#20017, so one read scope gets one verdict on
all three analytics faces.

- **Files:** the call itself is one line. The rest of the diff is:
  - the module-header section (objectstack-ai#20018);
  - a note on the helper's docblock;
- a one-paragraph note on the binary extra in `comparand-shape.ts`,
whose "accepted in every bind position" is no longer true of the
read-scope door;
  - tests and the changeset.
- **Not touched:** `objectql-strategy.ts` and `native-sql-strategy.ts`.
Both faces reach the guard through the compiler, so neither needs a call
site of its own.
- **Also not touched:** `filter-normalizer.ts`, `preview-evaluator.ts`
and `packages/spec`.

## Measurement, recorded before the fix

Everything in this section was measured on `9d81af714f` (`origin/main`,
pre-fix). The rows are **executed**, not read from the compiled string.

- **Harness:** a scratch probe that is not committed, plus measurement
commit `8c80d9c3d2` (the new test file alone).
- **Database:** one real `SqliteWasmDriver` with four fixture rows.
`region` is NULL on d3, and `owner` and `amount` are NULL on d4.
- **ObjectQL face:** a real `ObjectQL` engine.
- **Echo:** its SQL was run on the same database.
- **Native:** `NativeSQLStrategy.execute` through `executeRawSql` on the
same database. It was not stubbed.
- **Scopes:** the `getReadScope` contract, filled by hand.

| read-scope shape class | ObjectQL execute | echo (SQL executed) |
native execute |
|:--|:--|:--|:--|
| plain-object comparand under `$eq` *(the card's first shape)* |
`READ_SCOPE_COMPILE_FAILED` / 500 | compiles; the database refuses the
bind | `DATABASE_ERROR` / 500 |
| null member in `$in`, `['emea', null]` *(the card's second shape)* |
500 | d1 | d1: the NULL member matches nothing |
| null-only `$in` | 500 | no rows | no rows |
| null member in `$in` under `$not` | 500 | d3 | d3: **only** the NULL
row, which the scope names as excluded |
| null member in `$nin` | 500 | d3 | d3: **only** the NULL row, which
the scope names as excluded |
| null comparand under `$gt` / `$lte` | 500 | no rows | no rows |
| null `$between` bound | 500 | no rows | no rows |
| blank `$between` bound | 500 | d1 d2 d4 | d1 d2 d4 |
| plain-object comparand under `$ne` / `$gt`; empty object under `$eq` |
500 | compiles; DB refuses | `DATABASE_ERROR` / 500 |
| bigint beyond 2^53 (implicit, `$in`) | 500 | no rows | no rows |
| binary comparand, binary `$in` member | 500 | no rows | no rows |
| `Map` or function comparand | 500 | compiles; DB refuses |
`DATABASE_ERROR` / 500 |
| controls (9 well-formed scopes, including the null predicates and the
live RLS composite) | the same rows on all three faces | | |

The card named two shapes. The measurement found the gap is every shape
the two shared faces refuse and this compiler's own gates did not: null
list members and bounds, null ordering comparands, blank bounds,
oversized bigints, binary, and non-scalar objects in a scalar position.
That is the set that moves.

### The mechanism hypotheses

- **Hypothesis 1 is confirmed, and the set is wider than the card's two
shapes.** These are the compile arms that accepted them:
- ``case '$eq': return val === null ? … : `${col} = ${bind(params,
val)}`;``. A plain object is bound, and no gate judges a `$eq`
comparand's type. `assertNoFieldReferenceComparand` steps past an object
that is not `{ $field: string }`.
- `case '$in'` → `assertCompilableMembers(op, field, val)` →
`isBindableComparand(member)`, which admits `null` (an accepted
comparand type) and binary (a package-local extra), then `IN (…)` binds
each member.
- The ordering arms and `$between` bind whatever passes those gates. The
implicit-equality arm binds any non-object.
- **Hypothesis 2: measured.** See the table above, and the producers
section below.
- **Hypothesis 3 is confirmed in verdict, but the placement is better
than "at the entry".** The two walks are pure functions of the scope, so
the placement cannot change which scopes are refused, only which log
sentence a doubly-refused scope carries.
- Ablation A2 below put the call at the entry and turned 37 tests red in
7 files: the new ordering pin, plus 36 existing log-sentence pins, for
example `read-scope-eq-array-refusal` (15) and
`read-scope-undefined-comparand` (8).
- After the lowering, every shape this compiler already refused keeps
its own sentence (the objectstack-ai#13926 ordering), and the faces add only what
would otherwise have been lowered.

### Producers: who can emit these shapes today

All readings below are on `9d81af714f`.

| producer | emits a refused shape? | evidence |
|:--|:--|:--|
| RLS `using` predicates, through `compileCelToFilter` →
`RLSCompiler.compileFilter` | **yes, from an authored predicate.** `f in
['a', null]`, `f in [null]`, `!(f in ['a', null])`, `f > null`, `f <=
null` and `f != current_user` each lower verbatim into a refused shape.
| Scratch probe. All six pass `isSupportedRlsExpression`, and
`validateRlsPredicateEnforceability` returns 0 findings for each. |
| authored policies in this repository | **none** | `git grep` of
`using` / `check` predicates for a null list member or a null ordering
comparand, over `examples/**` and `packages/**/*.ts` (tests excluded): 0
matches, exit 1. The control on the same tree and file set, predicates
naming `current_user`, matched 77 lines in 7 files, exit 0. |
| a resolved membership variable with a null member | no | The objectstack-ai#13496
guard. Measured: `f in current_user.teams` with a null member lowers to
the deny sentinel. |
| `plugin-sharing` `buildReadFilter` | no | Owner ids are
`String(userId)` or a resolver's `string[]`. `grantedRecordIds` filters
out `null` and `''`. |
| a host `getReadScope` option, or a direct caller of the export |
anything | The door the card names. |

Verdict: **no producer both legitimately authors one of these shapes and
relies on the native answer.** Three facts support that:

- The in-repo producer emits these shapes only from predicates that the
standing rulings, carried by the shared faces, refuse. No policy in this
repository is one of them.
- The ObjectQL analytics face already refused every such scope.
- The native answer was not the predicate's meaning. It dropped the null
member, admitted only the NULL rows the scope excludes, returned zero
rows, or hit a database error.

So this is execution under the rulings, not a `needs_decision`. The
authoring half is reported separately as a finding; see the Acceptance
notes.

## Tests

The new file is
`packages/services/service-analytics/src/__tests__/read-scope-comparand-three-faces.test.ts`,
with 32 cases. It uses one `SqliteWasmDriver`, a real `ObjectQL` behind
the ObjectQL face, and the echo's SQL executed.

- **13 refusal classes.** Each asserts on all three faces: `code`
`READ_SCOPE_COMPILE_FAILED`, `status` 500, and the prose withheld.
"Withheld" means `serverFaultProvenance(resolveThrownHttpError(err,
500))` is `'declared'` and `declaredRefusalMessage(err)` is undefined.
- **The native face refuses before any statement reaches the database.**
Zero `executeRawSql` calls across all 13 classes.
- **The joined hop.** `applyReadScope`'s per-hop lowering refuses a
joined object's scope, named for that hop.
- **The public export** refuses every class, and a well-formed scope
compiles to the same bound predicate as before.
- **Log sentences.** A shape the compiler already refused, a list under
`$eq`, keeps its own sentence. A shape only the faces refuse carries
their sentence, the same on all three faces.
- **Controls:**
  - with no scope, every face serves all four rows;
- 9 well-formed scopes admit the same rows on every face. They include
`{ region: null }`, `$ne: null`, a non-empty `$nin`, the live RLS
composite with an emptied `$in` beside an own-rows grant, and the
spelling the null-member ruling prescribes (`$or` of `$in` and `$null:
true`);
  - a well-formed caller `where` composes with a well-formed scope;
- a caller `where` in a refused scope shape answers exactly as it does
with no scope, and is never attributed to the read scope.

**Existing pins that asserted the lowering binds a refused shape.** Each
is re-judged with a `[objectstack-ai#20018]` note, and each keeps what it was there to
pin:

- `comparand-door-single-source.test.ts`. Three matrix cells now read
`READ_SCOPE_COMPILE_FAILED/500`: `null` under `scopeIn`, `binary` under
`scopeIn` and `scopeEq`, and `plain object` under `scopeEq`. This PR
moves no `where`-door cell.
- After merging objectstack-ai#20032 (objectstack-ai#20010), the `null` row carries both re-judged
cells: `whereIn` is `INVALID_FILTER/400` (objectstack-ai#20010) and `scopeIn` is
`READ_SCOPE_COMPILE_FAILED/500` (this PR).
- The "six accepted types, every position" check names each moved cell
with the change that moved it, and holds every other cell at `accept`.
- `comparand-shape-refusal.test.ts`. "Keeps binding every legitimate
`$in` member" drops `null`, and a new case pins `null` refused with the
face's sentence.
- `cross-field-reference-refusal.test.ts`. `{ $gt: { $field: 5 } }` is
refused as a plain object, with the type face's sentence and not the
field-reference gate's.
- `read-scope-boolean-flag-comparand.test.ts`. An inherited `$null`
still cannot trip the flag gate, because the refusal carries no flag
sentence. The object is refused by the type face as a non-plain value.
- `read-scope-undefined-comparand.test.ts`. `$in: [null]`, `$nin:
[null]` and `$between: [null, 5]` leave the null control group for their
own "refused by the shared face" block. Every null predicate stays in
the group, unmoved.

**Results:**

| run | tree | result |
|:--|:--|:--|
| new file, measurement commit (test only) | `8c80d9c3d2` | `Tests 17
failed \| 15 passed (32)`. Every refusal class and the three pins built
on them are red; every control is green. The first face to fail in each
row is the echo; the ObjectQL face passed first. |
| whole package | `0fbed27877` (final; `origin/main` `adbbc5d01e`
merged) | `Test Files 120 passed (120)`, `Tests 2689 passed (2689)` |
| `pnpm --filter @objectstack/service-analytics typecheck` |
`0fbed27877` | exit 0; `tsc --noEmit --listFiles` includes the new test
file |

**Ablations.** Both ran through `node scripts/ablation-replace.mjs` in
WRAP mode (the anchor must hit exactly once, the blob must change, and
the tool's own trap restores). The subject resolves to `src/` through
relative imports, so no `dist/` leg applies. Each restore was proven:
blob `34705e267dba` equals `HEAD`, and `git diff HEAD` is empty.

A1 was re-run on the final head `0fbed27877`. A2 ran on `f293340e94`.
`read-scope-sql.ts` is the same blob, `34705e267dba`, on both trees
(neither merge touched it), so A2's placement result stands.

| leg | mutation | result (whole package) |
|:--|:--|:--|
| A1 (at `0fbed27877`) | delete the new
`assertReadScopeComparandsRunnable(filter, alias);` call | `26 failed \|
2663 passed (2689)`: all 17 negative pins in the new file, plus the 9
re-judged pins (3, 1, 1, 1 and 3 across the five files). Every control
stayed green. The same 17 + 9 went red at `f293340e94` (`26 failed \|
2567 passed (2593)`). |
| A2 (at `f293340e94`) | call it BEFORE `compileNode` instead of after |
`37 failed \| 2556 passed (2593)`: the new ordering pin, plus 36
existing log-sentence and precedence pins in 6 files |

The first attempt at A2 was a void run. Its replacement text contained
its anchor, so the tool refused with "anchor count moved 1 -> 1" and
restored. Nothing was measured. It was re-run with a three-line anchor,
and that is the result above.

## Gates

**Derived gates.** Derived at `0fbed27877` with `node
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands`: 61 families, not stale. They are a superset of the 48-line
dispatch-time list.

- **59 exited 0.**
- **`check:dual-build-cjs-loads` and `check:type-check-debt`** first
answered `PREREQUISITE NOT MET` (exit 3), because the workspace had no
`dist/`. After building every `./packages/**` workspace package
(`VERDICT command-exit 0`), both exited 0: `check:dual-build-cjs-loads`
passes its floors, and `check:type-check-debt` re-measured 4 ledger
entries (53 raw tsc errors) with none above its recorded number.
- **The `--ran` reconciliation:** 61 derived, 61 run, 0 NOT-MEASURED, 0
UNRUN. That zero is derived, not claimed: all 61 records carry an exit
code and none is 3.

**Other checks:**

- `GITHUB_TOKEN=… node scripts/check-issue-citations.mjs`: exit 0; 8
citations judged, 8 resolve.
- `node scripts/check-adr-0087-registration.mjs --base origin/main` (one
of the derived families, at `0fbed27877`): exit 0. The changeset is
`BREAKING+bang+clause-②-narrowing`, disposition `not-required
(no-migration-prescription)`.
- **Lint, narrowed to the 8 touched TypeScript files** with `eslint
--no-inline-config --format json` at `0fbed27877`: 8 files read, 0
errors, 0 warnings, none ignored.
- The changeset is outside eslint's population ("no matching
configuration").
- `eslint.config.mjs` enables no type-aware linting: its `parserOptions`
carry only `ecmaVersion` and `sourceType`, with no `project` and no
`projectService`. So this diff cannot move a verdict on an untouched
file.
  - `pnpm lint` itself is CI's.

## Acceptance notes

- **BREAKING, `minor` with `!`.** Read scopes the native face and the
echo used to serve are refused now; the table above lists what they
served. The changeset carries the banner and the fix spellings: the null
predicate beside a membership, `$gte` / `$lte` for a one-sided range,
and a scalar comparand.
- **The refusal set is the shared faces' set, not only the card's two
shapes.** Judging the scope with the same function the ObjectQL face
uses is what makes the verdicts equal. A narrower hand-picked subset
would have left the other rows of the table as "one scope, two answers".
- **Binary.** The package-local binary bindable (`isBindableComparand`)
no longer reaches the read-scope door. The shared type face refuses
binary, and the ObjectQL face already did. The `where` door and the
predicate itself are unchanged.
- **The joined-hop log sentence names the alias.**
`compileScopedFilterToSql` knows only the alias, so the operator's log
reads `read scope for "ALIAS"`: the object name on the base table, the
join alias on a hop. The response withholds it either way.
- **Precedence on the native face.** When a scope carries both a shape
this compiler refuses and one only the faces refuse, the compiler's
sentence answers. The verdict is the same either way.
- **The CRUD path is not an analytics face and was not measured here.**
The security middleware composes the same RLS filter into the engine's
`where` after the engine's shared-face seam has run. What `driver-sql`
answers for these shapes there is outside this card.
- **Finding, class (c), not fixed here, for the seat to file.** The RLS
authoring surface admits predicates that lower into scope shapes the
shared faces refuse: a literal `null` in an `in` list, an ordering
comparison against `null`, or a comparison against the whole
`current_user` object. All three pass `isSupportedRlsExpression`, and
`validateRlsPredicateEnforceability` returns no finding for them. They
are refused (500) on every analytics face after this PR. The evidence is
in the producers table.
- **`origin/main` was merged in twice.**
- At `b3735968ba`: three commits in `packages/objectql` and
`packages/plugins/plugin-security`, sharing no path with this diff.
- At `adbbc5d01e`: objectstack-ai#20032 (objectstack-ai#20010) and a driver-sql / driver-turso fix.
That merge had one content conflict, in the comparand matrix, resolved
as above.
- `comparand-shape-refusal.test.ts` and
`cross-field-reference-refusal.test.ts` auto-merged and were re-read on
the merged tree. objectstack-ai#20032's edits there are to the `where`-door pins and
this PR's are to the read-scope pins, and their notes agree: the null
member is refused by the same ruling at each door, each in its own
envelope. One sentence in the non-string `$field` case ("it binds as
JSON") is now scoped to the `where` door, where it still holds.
  - The dependency closure was rebuilt before the final runs.
- **Files not touched:** `filter-normalizer.ts`, `preview-evaluator.ts`,
`objectql-strategy.ts`, `native-sql-strategy.ts`, `packages/spec`.
- **objectstack-ai#19995** stays open behind objectstack-ai#20020, for its engine- and driver-door
residue. This PR does not address it.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ng a placeholder the engine cannot resolve in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope (objectstack-ai#19995) (objectstack-ai#20072)

Part of objectstack-ai#19995. This PR closes the placeholder class of the card's
engine-door residue. The card stays open: two of the three engine-door
classes still answer a relayed 400 on both analytics HTTP doors, and two
more classes of the same kind were measured here. They are listed under
"Residue" below, with the reason each is not fixed here.

Clause-②: no

## What changed

The analytics ObjectQL execute face composes a row-level read scope into
the `where` it hands `engine.aggregate`. The engine resolves
`{placeholder}` filter values on that composed `where`. A scope carrying
an unknown placeholder, or a known one the request has no value for,
therefore came back as the engine's `FILTER_TOKEN_UNKNOWN` /
`FILTER_TOKEN_UNRESOLVED` / 400. Both HTTP doors relay a 4xx's message,
and this one named the policy's placeholder. The objectstack-ai#5367 ruling
(re-affirmed as objectstack-ai#7598 Q2 = A) makes a read-scope refusal a withheld
`READ_SCOPE_COMPILE_FAILED` / 500.

- **`read-scope-sql.ts`: new
`assertReadScopePlaceholdersResolvable(scope, objectName, context)`**,
beside `assertReadScopeComparandsRunnable` (PR objectstack-ai#20017).
- It runs the engine's own placeholder resolver on the scope alone. That
resolver is `resolveFilterTokens` from `@objectstack/core`, the function
`ObjectQL.resolveWhereTokens` calls.
- It builds the token context with `filterTokenContextFrom`, over the
context the strategy forwards to `executeAggregate`.
- Anything the resolver throws is re-raised through the module's one
envelope helper, `readScopeCompileError`. The resolver's sentence stays
in the operator's log.
  - It is exported from the file only. The package entry is unchanged.
- **`objectql-strategy.ts`: called at both engine-bound merges.**
- `withReadScope` covers the direct path and the cross-object base
aggregate.
  - `resolveFkAttr` covers the referenced object's own scope.

**Order at each merge:** after `assertReadScopeCannotVacate` and
`assertReadScopeComparandsRunnable`, and before the `'policy'` mark.

- **After the comparand faces**, because the engine resolves
placeholders after its lowering doors: `lowerWhereFilterArray` runs the
comparand faces on the unresolved `where`, and `resolveWhereTokens` runs
next. A scope with both defects therefore logs the sentence the engine
would have given. On the wire it is the same withheld 500 either way.
- **Before the mark**, like its two siblings, so a refused scope is
never stamped as vouched-for policy content.

**Why the set of served scopes does not move:**

- **Same function.** The resolver's verdict on one string depends on
nothing else in the tree. So the scope alone answers exactly as it does
inside `{ $and: [userFilter, scope] }`.
- **Same inputs.** The token context comes from the engine's own bridge
over the context `executeAggregate` receives, which is the context the
engine resolves with.
- **Resolved scopes are still served.** A placeholder the engine
resolves is resolved here too, and the scope is served. The resolved
tree is discarded, and the engine resolves the original as before.
Ablation B3 below turns this control red by having the judgement read a
different context.

⛔ **This is not a catch around `executeAggregate`.** The caller's own
`where` never reaches the engine's resolver with a placeholder still in
it: `AnalyticsService` resolves the query's own positions first and
answers with its own 400 and message. The caller's text-operator and
temporal refusals, which the engine answers, keep their 400 with the
message. Ablation B5 shows those pins going red under a blanket catch.

**Instrument.** The dispatch named `classifyFilterToken`. The engine's
door is `resolveFilterTokens`, which walks the tree and classifies each
string with `classifyFilterToken`. Judging with the resolver reuses that
walk instead of writing a second one. It also covers the resolver's
second refusal, a known placeholder the context cannot resolve, with no
extra code.

## Measurement

**How it was measured.** A scratch probe (not committed; it lived in
`packages/runtime/src` and was deleted) used a real `ObjectQL` over
`SqliteWasmDriver` and `AnalyticsService` on the ObjectQL face only,
with the scope taken from `getReadScope`. The HTTP legs went through the
real routes:

- `@objectstack/runtime`'s dispatcher, `POST /api/v1/analytics/query`;
- `@objectstack/rest`, `POST /analytics/dataset/query`.

**Before:** base `3557f85fa5`, which is `980bc05e5b` plus one
`driver-turso` README commit. **After:** head `df5caa9afa`, with the
`service-analytics` dist rebuilt; the new message is present once in
each of `dist/index.js` and `dist/index.cjs`. `AnalyticsService` called
directly gave the same code and status as the HTTP doors on every row.
The before table is also recorded in the branch's first commit,
`eab6d874b9`, which is test-only.

**Close condition** (5821737887): no policy content in any body on both
HTTP doors, for the seven residue classes. Measured on the final head:

| Read-scope class | Layer that answers | Both HTTP doors, before | Both
HTTP doors, after | Policy content in the body, after |
|---|---|---|---|---|
| Text operator on a non-text field | Engine, declared-type door |
`INVALID_FILTER` / 400 | unchanged | **yes**: field and operator
(residue) |
| Temporal comparand the platform cannot read | Engine, temporal door |
`INVALID_FILTER` / 400 | unchanged | **yes**: field and comparand
(residue) |
| Unknown filter placeholder | Engine, placeholder resolver |
`FILTER_TOKEN_UNKNOWN` / 400, token relayed |
`READ_SCOPE_COMPILE_FAILED` / 500 | no |
| Column the object does not have | `driver-sql` | `INVALID_FILTER` /
400, withheld | unchanged | no |
| Retired or unknown operator | `driver-sql` | `INVALID_FILTER` / 400,
withheld | unchanged | no |
| Combinator with a non-array operand | `driver-sql` | `INVALID_FILTER`
/ 400, withheld | unchanged | no |
| Non-boolean `$null` or `$exists` | `driver-sql` | `INVALID_FILTER` /
400, withheld | unchanged | no |

**Also measured**, beyond the card's seven:

| Read-scope class | Layer that answers | Both HTTP doors, before | Both
HTTP doors, after | Policy content in the body, after |
|---|---|---|---|---|
| A known placeholder the request context cannot resolve | Engine,
placeholder resolver | `FILTER_TOKEN_UNRESOLVED` / 400, token relayed |
`READ_SCOPE_COMPILE_FAILED` / 500 | no |
| A filter on a virtual (formula) field | Engine, materializable-field
door | `INVALID_FIELD` / 400 | unchanged | **yes**: field (residue) |
| A dotted path through a lookup | Engine, materializable-field door |
`INVALID_FIELD` / 400 | unchanged | **yes**: field and path (residue) |

**Controls**, identical before and after:

- A well-formed scope answers 200 with exactly its rows.
- The caller's own `where`, in each of the three card shapes (text
operator, temporal comparand, placeholder), answers its 400 with its own
message.

## Tests

The new file is
`packages/services/service-analytics/src/__tests__/objectql-read-scope-placeholder-refusal.test.ts`:
18 cases over a real `ObjectQL` and `SqliteWasmDriver`.

- **Refusal cases** assert `code` `READ_SCOPE_COMPILE_FAILED` and
`status` 500, and the two reads every analytics HTTP door takes before
relaying prose: `serverFaultProvenance(resolveThrownHttpError(err,
500))` is `'declared'`, and `declaredRefusalMessage(err)` is undefined.
The thrown message, the operator's log channel, still carries the
detail.
- Five scope shapes on the direct path: an unknown placeholder, a
near-miss spelling, a brace-wrapped non-token, an unknown list member
nested in an `$or`, and a known placeholder the context cannot resolve.
  - A well-formed caller `where` beside a refused scope.
  - The cross-object base scope, and the referenced-object scope.
- **Controls:**
- A placeholder the context resolves is served with exactly its rows, on
the direct path and on the referenced object.
  - A well-formed scope is served.
- The caller's own `where` keeps its 400 with its message for an unknown
placeholder, a text operator on a number field, and an uninterpretable
temporal comparand.
- The four `driver-sql` door classes keep `INVALID_FILTER` / 400 with no
policy content in the message.

**Results:**

- Test-only commit `eab6d874b9`, before the fix: `Tests 8 failed | 10
passed (18)`. Every refusal case received `FILTER_TOKEN_UNKNOWN` or
`FILTER_TOKEN_UNRESOLVED`, and every control was green.
- Final head `df5caa9afa`:
- `pnpm --filter @objectstack/service-analytics test`: `Test Files 121
passed (121)`, `Tests 2707 passed (2707)`.
- `pnpm --filter @objectstack/service-analytics typecheck` exits 0, and
`tsc --listFiles` includes the new test.

**Ablations.** Each leg ran from committed state, through
`scripts/ablation-replace.mjs` with the file's restore armed. The anchor
had to hit exactly once and the blob had to change. Every restore was
proven: the blob equals HEAD, and `git diff HEAD` is empty. The subject
is imported from `src`, so no dist is involved.

| Leg | Mutation | Result |
|---|---|---|
| B1 | Delete the `withReadScope` call | 7 failed, 11 passed: every
direct and cross-object-base refusal case received `FILTER_TOKEN_*` |
| B2 | Delete the `resolveFkAttr` call | 1 failed (the referenced-object
case), 17 passed |
| B3 | Judge with an empty token context instead of the forwarded one |
2 failed: both "served" controls were refused |
| B4 | Stamp the scope `'author'` instead of `'policy'` | 4 failed: all
four `driver-sql` controls disclosed. A first run found the
retired-operator row's secret vacuous (3 of 4 went red), so that row was
corrected before this run. |
| B5 | Wrap the direct `executeAggregate` in a blanket catch that
re-raises as the withheld 500 | 6 failed: the caller's text-operator and
temporal controls, and the four `driver-sql` controls. The
caller-placeholder control stays green, because `AnalyticsService`
refuses that one before the strategy runs. |

## Gates

- **Derived gates.** Derived at `df5caa9afa` with `node
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands`: 61 families, a superset of the dispatch-time list. All 61
exited 0.
- `check:dual-build-cjs-loads` and `check:type-check-debt` first
answered PREREQUISITE NOT MET (exit 3). After a build of the
`./packages/*` closure, both exited 0. `check:dts-closure`,
`check:lean-entry-closure`, `check:published-files` and
`check:sourcemap-no-sources-content` were re-run on that full build and
exited 0.
- The `--ran` reconciliation reported 61 derived, 61 run, 0
NOT-MEASURED, 0 UNRUN.
- **Issue citations.** `node scripts/check-issue-citations.mjs` exited
0: 5 citations judged, 5 resolve.
- **Lint, narrowed to the three touched TypeScript files.** `eslint
--no-inline-config --format json` read 3 files and reported 0 errors and
0 warnings. The resolved config for these files sets `parserOptions` to
`ecmaVersion` and `sourceType` only: no `project`, no `projectService`.
So this diff cannot move a verdict on an untouched file. `pnpm lint`
itself is CI's.

## Acceptance notes

- **Precedence.** When the caller's `where` and the scope are both
refused, and only the engine would refuse the caller's clause, the
scope's 500 now answers first. PR objectstack-ai#20017 records the same precedence for
the comparand classes.
- **No dependency change.** The resolver was already reachable through
`@objectstack/core`, a runtime dependency, so `package.json` and
`pnpm-lock.yaml` are untouched.
- **NativeSQL face and `/analytics/sql` echo:** unchanged. Measured with
a stubbed `executeRawSql`, they bind a read scope's placeholder as a
literal string instead of resolving or refusing it. This is reported to
the seat and not touched here.
- **`origin/main`** was merged once (`5b9402d89b`, currency `scale`
retirement) and has since moved by one more commit (`b76aad5f6f`,
`packages/client`). That commit shares no path with this diff and is not
merged.
- **Files not touched:** `filter-normalizer.ts`, `preview-evaluator.ts`,
`text-match-sql.ts`, `packages/objectql`, `packages/spec`,
`packages/rest`, `packages/drivers/*`.

## Residue: why "Part of"

These classes still answer an engine 400 on both HTTP doors, and the
message names the policy:

- a text operator over a field that never holds a string (the card's);
- a temporal comparand the field's storage rule cannot read (the
card's);
- a filter on a virtual field, or through a dotted path (measured here;
the engine's materializable-field door).

Each is refused by an engine door that reads the object's schema. The
doors' walks live in `@objectstack/objectql`, and neither of its package
entries (`.` and `./core`) exports them.
`@objectstack/service-analytics` has no runtime dependency on the
engine. So this package cannot run those walks without a copy of each,
and none of the routes the dispatch listed stays inside this package:

- the engine instance exposes no filter-judging method today;
- a host-wired judge on the strategy context would still need the walks,
or an engine method, to answer with.

The route needs an engine-lane change, and it is handed back to the seat
as a decision.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…dmission before composing it (objectstack-ai#20232)

Fixes objectstack-ai#19995

Clause-②: no

The analytics ObjectQL face now asks the engine's own `where` admission,
`IObjectQLEngine.judgeFilter` (objectstack-ai#20157, ruling C), about each row-level
read scope on its own, before composing it into the `where` it hands
`executeAggregate`. A scope the engine refuses is refused in the
withheld `READ_SCOPE_COMPILE_FAILED` / 500 (objectstack-ai#5367). A scope the engine
serves is still served. The caller's own `where` keeps the engine's
answer.

The close condition in the ruling is met on the final head: both
analytics HTTP doors were re-measured over every class the ruling names
(the four here, the eleven withheld by PR objectstack-ai#20017 / objectstack-ai#20046 / objectstack-ai#20072, and
the four `driver-sql` doors from PR objectstack-ai#20037), and no response body
carries policy content.

## What changed

All in `packages/services/service-analytics/src/`.

- **`read-scope-sql.ts`: new `assertReadScopeAdmittedByEngine(scope,
objectName, context, host)`.** It calls the host's judge on the scope
alone, under the verb every engine-bound merge runs (`'aggregate'`) and
the context that merge forwards.
- An `ok: false` verdict is raised through the module's one envelope
helper, `readScopeCompileError`. The engine's sentence stays in the
thrown message for the operator's log, and the 500 declaration withholds
it on the wire. The verdict's own `code` / `status` describe a caller's
mistake, so they do not travel either.
- A throw from the judge itself (a fault, not a verdict) is raised in
the same envelope.
- No judge, or an `undefined` answer, means the scope is not judged here
("cannot answer, do not block").
  - It is exported from the file only. The package entry is unchanged.
- The header gains a ruling-C section, and the paragraph that said these
doors were out of reach is updated.
- **`strategies/objectql-strategy.ts`: called at both engine-bound
merges**, `withReadScope` (direct path and cross-object base aggregate)
and `resolveFkAttr` (the referenced object's scope). It runs after the
existing guards (vacancy, comparand faces, placeholders), so a scope
they refuse keeps their sentence. It runs before the `'policy'` mark,
like them.
- **`strategies/types.ts`:
`DatasetScopedStrategyContext.judgeFilter?`**, the package-local hook,
typed from the contract member itself (`IObjectQLEngine['judgeFilter']`,
made non-nullable) plus the `undefined` answer. This is the
`declaredFieldType` / `sqlDialect` pattern.
- **`analytics-service.ts`: `AnalyticsServiceConfig.judgeFilter?`**,
passed to the strategy context untouched. A service configured with no
judge logs one `warn` on its first unjudged scoped merge, naming the
consequence and the remedy.
- **`plugin.ts`: the judge is wired to the engine the `executeAggregate`
auto-bridge executes on**, resolved per call through the same
`tryGetDataEngine`. It is wired ONLY when the plugin bridges
`executeAggregate` itself. The judge must be the executor, or it would
refuse scopes the executor serves, and a host that supplies its own
`executeAggregate` has not said which engine that is.
- A `data` engine without `judgeFilter`: `undefined`, plus one `warn`
from the plugin.
- No engine at all: `undefined`, silently, because the executor refuses
that query itself.
- **`plugin.ts`, record-label fetch (the objectstack-ai#14329 door): the same checks
as `resolveFkAttr`.** This is a fourth engine-bound merge. It `$and`s
the referenced object's scope into `executeAggregate` to turn a lookup
dimension's ids into labels, and it ran only the vacancy guard. Measured
before this change: on the dataset door, a selection ordered by a lookup
dimension runs the sort-key label pass, and that pass relayed the
engine's 400 with policy content. It did so for the residue classes and
also for classes every other merge already withheld (a list in the
equality slot, an unknown placeholder). It now runs the comparand faces,
the placeholder resolver and the engine's admission on the scope alone.
See "Scope" under Acceptance notes.

⛔ **Not a catch around `executeAggregate`.** The caller's own `where` is
never judged here. Pinned, and ablation E6 shows those pins turning red
under a blanket catch.

**Why a served scope stays served.** The judge is the executing engine,
under the same verb and context. `judgeFilter` runs the engine's two
admission stages, the same functions in the same order execution runs,
and stops before any driver. Every object-form door judges a node
against the field map and the context, never against its siblings. So
the scope alone is admitted exactly when the scope inside `{ $and:
[userFilter, scope] }` is. Ablation E8 turns the served-placeholder
control red when the judge reads a different context.

## Premises, measured before writing the fix

1. **The contract member and its implementation**
(`objectql-engine.ts:306`, `engine.ts:8783` on `ce70876e4c`). Read, and
measured: for one refused filter, `judgeFilter(..., { operation:
'aggregate' })` returned the same `code`, `status` and message string
that `aggregate` raised.
2. **What the `data` service hands out.** In a booted `LiteKernel` with
`ObjectQLPlugin` and `AnalyticsServicePlugin`, `getService('data')` is
the same object as `getService('objectql')`. It is an `ObjectQL`
instance, and `typeof judgeFilter` is `'function'`. It is not a wrapper.
3. **The four classes on current main.** They relayed policy content on
both doors at base `ce70876e4c` (table below).
4. **The verb.** `'aggregate'` reproduces the execution message exactly
(premise 1). The verb changes only the message prefix, never the
verdict.
5. **The existing guards.** Kept. An unwired host relies on them, and
the pins below show such a host still withholds a guarded class while
the four residue classes keep today's engine 400.

## Measurement: both analytics HTTP doors, before and after

**How.** A scratch probe, never committed, lived in
`packages/runtime/src` only while it ran.

- **Kernel:** a real `LiteKernel` booted with `ObjectQLPlugin` and
`AnalyticsServicePlugin`. The plugin auto-bridged `executeAggregate`,
and after the fix it wired the judge, to a real `ObjectQL` over
`SqliteWasmDriver`. The plugin options supplied `getReadScope` and
`admitObjectRead`, and fixed `queryCapabilities` to the ObjectQL face.
Nothing else was stubbed.
- **Doors:** `@objectstack/runtime`'s dispatcher, `POST
/api/v1/analytics/query`, and `@objectstack/rest`, `POST
/analytics/dataset/query`.
- **Runs:** before = base `ce70876e4c`; after = this branch with the
`service-analytics` dist rebuilt (the new sentence is present in
`dist/index.js` and `dist/index.cjs`).
- **"Policy content"** = the synthetic policy field name or comparand
appears anywhere in the response body. **"Log"** = the refusal's detail
reached the door's error-log channel.

| Read-scope class | Both doors, before | Policy content in body, before
| Both doors, after | Policy content in body, after | Detail in the
server log, after |
|---|---|---|---|---|---|
| Text operator over a non-text field | `INVALID_FILTER` / 400 | yes |
`READ_SCOPE_COMPILE_FAILED` / 500 | no | yes |
| Temporal comparand the field cannot interpret | `INVALID_FILTER` / 400
| yes | 500 | no | yes |
| Filter on a virtual (formula) field | `INVALID_FIELD` / 400 | yes |
500 | no | yes |
| Dotted path through a lookup | `INVALID_FIELD` / 400 | yes | 500 | no
| yes |
| A residue class in the BASE scope on the cross-object path | 400 | yes
| 500 | no | yes |
| A residue class in the REFERENCED object's scope (text operator;
dotted path into a scalar) | 400 | yes | 500 | no | yes |
| The nine comparand classes (PR objectstack-ai#20017 / objectstack-ai#20046): list in the implicit
equality slot, list under `$eq`, scalar under `$in`, scalar under
`$nin`, one-bound `$between`, plain-object member in `$in`, `undefined`
comparand, plain-object comparand under `$eq`, `null` member in `$in` |
`READ_SCOPE_COMPILE_FAILED` / 500 | no | unchanged | no | yes |
| The two placeholder classes (PR objectstack-ai#20072): unknown placeholder; known
placeholder the context cannot resolve | 500 | no | unchanged | no | yes
|
| Refused `$icontains` comparand (objectstack-ai#20068) | 500 | no | unchanged | no |
yes |
| The four `driver-sql` doors (PR objectstack-ai#20037): missing column; retired or
unknown operator; combinator with a non-array operand; non-boolean
`$null` / `$exists` | `INVALID_FILTER` / 400, withheld | no | unchanged
| no | unchanged |
| Record-label fetch, sort-key pass (dataset door): a residue class on
the referenced object | 400 | yes | 500 | no | yes |
| Record-label fetch, sort-key pass (dataset door): list in the equality
slot; unknown placeholder | 400 / `FILTER_TOKEN_UNKNOWN` 400 | yes | 500
| no | yes |

The `/analytics/query` door has no label pass. The display label pass
catches a failed fetch and renders raw ids: 200 before and after, with
the detail in the `warn` log.

**Controls, identical before and after:**

- A well-formed scope answers 200 with exactly its rows.
- A scope with a placeholder the context resolves answers 200 on the
dataset door. The dispatcher harness carries no user, so that door
answers the withheld 500 both before and after.
- A well-formed referenced-object scope buckets what it hides as
`(restricted)`.
- A well-formed referenced scope on the label pass is served.
- The caller's own `where` in each of four shapes (text operator on a
number field, uninterpretable temporal comparand, virtual field, dotted
path) answers its 400 on both doors, and the body carries the caller's
own diagnostic.

## Tests

New file: `src/__tests__/objectql-read-scope-engine-admission.test.ts`,
19 cases. Each builds `AnalyticsServicePlugin`'s own composition over a
real `ObjectQL` + `SqliteWasmDriver` as its `data` service. Only
`queryCapabilities` is fixed to the ObjectQL face.

- **Refusal pins** assert `code` `READ_SCOPE_COMPILE_FAILED` and
`status` 500. They also assert the two reads every analytics HTTP door
takes before relaying prose:
`serverFaultProvenance(resolveThrownHttpError(err, 500))` is
`'declared'`, and `declaredRefusalMessage(err)` is undefined. The thrown
message, which is the log channel, still names the detail.
  - The four classes on the direct path.
  - A well-formed caller `where` beside a refused scope.
  - The cross-object base scope, and the referenced-object scope.
  - The record-label sort-key pass.
  - A judge that throws.
- **Preservation pins:**
- A well-formed scope, and a placeholder the forwarded context resolves,
are served with exactly their rows.
  - A well-formed referenced scope keeps its `(restricted)` bucket.
  - The label pass with a well-formed scope is served, sorted by label.
- The caller's own `where` (text operator, temporal comparand, virtual
field) keeps `INVALID_FILTER` / `INVALID_FIELD` / 400 with its message
and no server-fault declaration.
- **Unwired tiers:**
- `AnalyticsService` with no judge keeps the engine's 400 for a residue
class, still withholds a guarded class, serves a well-formed scope, and
logs exactly one line across three queries.
- A plugin host with its own `executeAggregate` is not wired to a
guessed engine: a residue class keeps the engine's 400. At the
record-label fetch, the comparand and placeholder guards this PR adds
there withhold a guarded class and an unresolvable placeholder; they are
new on that host too.
- A `data` engine without `judgeFilter` keeps the 400, and the plugin
logs exactly once across two queries.

**Results on the final head `f6dbebe5`:**

- `pnpm --filter @objectstack/service-analytics test`: `Test Files 129
passed (129)`, `Tests 3041 passed (3041)`.
- `pnpm --filter @objectstack/service-analytics typecheck`: exit 0. `tsc
--noEmit --listFiles` includes the new test (count 1).
- **Downstream consumers**, run because the wire envelope of
already-refused scopes moves. Each run was against the rebuilt
`service-analytics` dist:
  - `@objectstack/rest`: 10 `analytics-*` files, 148 tests green.
- `@objectstack/runtime`: the 19 test files that touch analytics, 566
tests green.
- `@objectstack/dogfood`: the 6 analytics-touching files, 36 tests
green. These boot the real stack, where the judge is wired, and include
the label-scope and RLS suites.
  - `@objectstack/client`: the analytics test, 7 green.

## Ablations

Each leg ran from committed state through `scripts/ablation-replace.mjs`
in WRAP mode, against the new test file. The anchor had to hit exactly
once and the blob had to change. Every restore was proven: the blob
equals HEAD, and `git diff HEAD` is empty. An outer shell trap restored
all four source files from `HEAD` on any exit. The subject is imported
relatively from `src`, so no dist is involved. Every direction was
predicted before the run; E1 reddened two more pins than predicted
(below).

| Leg | Mutation | Result |
|---|---|---|
| E1 | Delete the `withReadScope` judge call | 9 failed. Predicted 7
(the four classes, the scope beside a caller `where`, the cross-object
base scope, the throwing judge). Also red: both once-log pins, which
need that call to ask at all. |
| E2 | Delete the `resolveFkAttr` judge call | 1 failed: the
referenced-object pin |
| E3 | Delete the label-fetch judge call | 1 failed: the label sort-key
pin |
| E4 | Delete the label-fetch comparand guard | 1 failed: the unwired
plugin host's label pin |
| E4b | Delete the label-fetch placeholder guard | 1 failed: the same
test's placeholder assertion |
| E5 | Wire the judge from the `data` engine even when the host supplied
its own `executeAggregate` | 1 failed: "not wired to a guessed engine" |
| E6 | Wrap the direct `executeAggregate` in a blanket catch re-raised
as the withheld 500 | 6 failed: the three caller-`where` pins and the
three unwired-tier pins that expect the engine's 400 |
| E7 | Judge the COMPOSED tree instead of the scope alone | 3 failed:
the caller's own `where` was misattributed as the scope's 500 |
| E8 | Judge with no context instead of the forwarded one | 1 failed:
the served-placeholder control was refused |
| E9 | Drop the service's once-flag | 1 failed: two warn lines |
| E10 | Drop the plugin's once-flag | 1 failed: two warn lines |
| E11 | The service never logs the missing judge | 1 failed: zero warn
lines |
| E12b | Relay the engine's verdict as-is (its code, status and message)
| 8 failed: every judge-dependent refusal pin |

The first E12 attempt was a no-op: its replacement contained its own
anchor, so the anchor count stayed at 1, the tool refused, and no test
ran. It was re-run as E12b with a replacement that does not contain the
anchor.

## Gates

- **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` on `f6dbebe5` derived 61
commands, the same count as the dispatch-time list. All 61 exited 0,
each exit code captured right after a single redirect. The `--ran`
reconciliation read: `61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN (a
DERIVED zero — all 61 recorded an exit code and none of them is 3)`.
- `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET).
After `turbo run build --filter=./packages/* --filter=./packages/*/*`
(71/71 tasks) it exited 0. `check:dts-closure`,
`check:sourcemap-no-sources-content`, `check:published-files` and
`check:lean-entry-closure` were re-run on that build and exited 0.
- `check-plugin-teardown-shape.mjs --self-test` first exited 3: the
shallow checkout could not reach its pinned positive-control commit.
After fetching that one commit it exited 0.
- **Outside the derivation, run because the diff adds a `warn` in
`plugin.ts` and in the service:** `check:startup-registry-verdict` 0,
`check:durability-log-level` 0.
- **Issue citations.** `node scripts/check-issue-citations.mjs`: 28
citations across 5 files, all resolve.
- **Lint, narrowed to the 6 touched TypeScript files.**
- `eslint --no-inline-config --format json` reported 6 files, 0 errors,
0 warnings, none ignored.
- `eslint --print-config` for each file shows `parserOptions` limited to
`ecmaVersion` / `sourceType`, with no `project` and no `projectService`.
Type-aware linting is off, so this diff cannot move a verdict on an
untouched file.
  - `pnpm lint` itself is CI's.

## Acceptance notes

- **Exported types.** `AnalyticsServiceConfig` (exported from the
package index) gains one optional member, `judgeFilter`. Its type,
`ReadScopeFilterJudge`, is exported from `strategies/types.ts` only, not
from the index; it reaches the published declarations through that
member. `DatasetScopedStrategyContext` (not exported) gains
`judgeFilter`. `AnalyticsServicePluginOptions` is unchanged. Changeset:
`@objectstack/service-analytics` patch.
- **Wiring.** The plugin wires the judge only when it auto-bridges
`executeAggregate`. That is how every shipped composition boots (`os
serve`'s capability provider, the verify harness): no host in this
repository passes its own `executeAggregate`. A plugin host that does
keeps today's behaviour, and logs one `warn`, everywhere except the
plugin's record-label fetch, whose new comparand and placeholder guards
run on every host that uses it (see Scope below). A host constructing
`AnalyticsService` directly can pass `judgeFilter`, from the engine its
`executeAggregate` runs on.
- **Precedence.** When the caller's `where` and the scope are both
refused, and only the engine would refuse the caller's clause, the
scope's 500 answers first. This is the same precedence PR objectstack-ai#20017 and PR
objectstack-ai#20072 recorded.
- **Scope: the record-label fetch.** The fourth merge is repaired in
place: same defect class, a mechanical repeat of the `resolveFkAttr`
form, a file on the claim's surface, and no new gate family. On a host
whose own `executeAggregate` runs on something other than ObjectQL, the
label fetch now refuses off-contract scope shapes that such an executor
tolerated. That is the same note PR objectstack-ai#20017 carried for `resolveFkAttr`;
the ObjectQL executor refused every one of them already. The display
label pass's catch is unchanged.
- **The once-lines** are per service instance and per plugin instance,
emitted on first use, never at init. They show up once per test file
that builds a service without a judge.
- **`origin/main`** moved by one commit after the base, `805af4f2`
(`packages/cli` only). It shares no path or behaviour with this diff and
is not merged.
- **Observation, not filed (zero pull).** In a kernel with no `security`
service, the analytics object-read admission bridge answers
`PERMISSION_DENIED` / 403 on every query. The kernel's `getService`
throws for a missing service, and the bridge reads a throw as "unusable"
(fail-closed). Its init warning describes the opposite. Every shipped
composition includes `SecurityPlugin`, and the failure direction is
fail-closed. Measured incidentally by the probe.
- **Files not touched:** `filter-normalizer.ts`, `preview-evaluator.ts`,
`text-match-sql.ts`, `native-sql-strategy.ts`, `packages/objectql`,
`packages/spec`.

## Seat append — patch round 1 (head `d9a1002f`)

- The at-tier contract review of `f6dbebe5` (record `5856105439`) found
two overclaims in the changeset prose. The dev corrected them, plus two
adjacent imprecisions, in `d9a1002f` (`.changeset` only, +3/−3).
- Two sentences of this body carried the same overclaim as the review's
defect 2: the Tests "Unwired tiers" bullet and the Acceptance-notes
"Wiring" sentence. The seat corrected both in place, using the dev's
text from its round-1 report. No other byte of this body changed.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant