Skip to content

fix(service-analytics): the ObjectQL face refuses a read scope carrying a placeholder the engine cannot resolve in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope (#19995) - #20072

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-19995-engine-door-scope-residue
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-19995-engine-door-scope-residue

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #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 #5367 ruling (re-affirmed as #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 fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope #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 fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope #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

…ope on the ObjectQL face (measurement, before the fix)

Test only, recorded before any source change. The new file pins that a read
scope carrying a filter placeholder the engine cannot resolve is refused as
READ_SCOPE_COMPILE_FAILED / 500 with the prose withheld, on the direct path,
beside a caller where, and on both cross-object merges. At this commit:
"Tests 8 failed | 10 passed (18)"; every refusal case receives the engine's
FILTER_TOKEN_UNKNOWN or FILTER_TOKEN_UNRESOLVED / 400. The ten controls are
green (a resolvable placeholder is served; a well-formed scope is served; the
caller's own where keeps its placeholder, text-operator and temporal 400s
with their messages; four driver-sql door classes keep their withheld 400).

Measurement on base 3557f85 (980bc05 plus one driver-turso README
commit). Scratch probe, not committed: a real ObjectQL over SqliteWasmDriver,
AnalyticsService on the ObjectQL face only, the scope from getReadScope. HTTP
legs through the real routes: the runtime dispatcher POST
/api/v1/analytics/query and the rest POST /analytics/dataset/query. The
direct service answered the same code and status on every row.

class (layer)                                        | both HTTP doors            | policy content in body
text operator on a non-text field (engine door)      | 400 INVALID_FILTER         | yes: field, operator
temporal comparand it cannot read (engine door)      | 400 INVALID_FILTER         | yes: field, comparand
unknown filter placeholder (engine token resolver)   | 400 FILTER_TOKEN_UNKNOWN   | yes: the token
known placeholder, context cannot resolve (resolver) | 400 FILTER_TOKEN_UNRESOLVED| yes: the token
filter on a virtual (formula) field (engine door)    | 400 INVALID_FIELD          | yes: field
dotted path through a lookup (engine door)           | 400 INVALID_FIELD          | yes: field, path
column the object does not have (driver-sql)         | 400 INVALID_FILTER         | no (withheld)
retired operator (driver-sql)                        | 400 INVALID_FILTER         | no (withheld)
unknown operator (driver-sql)                        | 400 INVALID_FILTER         | no (withheld)
combinator with a non-array operand (driver-sql)     | 400 INVALID_FILTER         | no (withheld)
non-boolean $null (driver-sql)                       | 400 INVALID_FILTER         | no (withheld)
non-boolean $exists (driver-sql)                     | 400 INVALID_FILTER         | no (withheld)
well-formed scope (control)                          | 200, the scoped rows       | -
caller where: text operator / temporal / placeholder | 400, the caller's message  | the caller's own

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
…ng a placeholder the engine cannot resolve in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope

assertReadScopePlaceholdersResolvable (read-scope-sql.ts) runs the engine's
own placeholder resolver, resolveFilterTokens from @objectstack/core, on the
scope alone with filterTokenContextFrom over the context forwarded to
executeAggregate, and re-raises its two refusals through readScopeCompileError.
Called at both engine-bound merges (withReadScope, resolveFkAttr), after the
comparand faces and before the 'policy' mark.

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

The resolver reads the scope and the request's token context and nothing
else, and an unusable time zone falls back to UTC rather than throwing
(measured), so a type filter guarded no reachable branch.

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
… door's full text carries

Its comparand is never in that door's text, so the old secret could not
turn the row red under a mis-stamped scope (measured: 3 of 4 driver rows
went red when the scope was stamped 'author', this one stayed green).

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 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 b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 25, 2026 01:55
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit 60fdaa9 Sep 25, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19995-engine-door-scope-residue branch September 25, 2026 02:30
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ho resolve a read-scope placeholder with the caller's context, as the ObjectQL face does (objectstack-ai#20075) (objectstack-ai#20111)

Fixes objectstack-ai#20075
Clause-②: no

## What this changes

A read scope now gets one placeholder verdict on all three analytics
faces: the same resolved values and rows, or the same withheld 500.

`compileScopedFilterToSql` (`read-scope-sql.ts`) is the lowering behind
the NativeSQL execute face (`NativeSQLStrategy.applyReadScope`, the base
table and every joined hop) and the `/analytics/sql` echo
(`ObjectQLStrategy.generateSql`). It never resolved a filter
placeholder, so both faces bound `{current_user_id}`, `{current_org_id}`
or a date macro as literal text. The ObjectQL execute face hands the
same scope to the engine, which resolves it with the caller's context
and, since objectstack-ai#19995, refuses one it cannot resolve.

The compiler now takes an optional `ReadScopeCompileOptions.context`. It
resolves the scope with `resolveFilterTokens(scope,
filterTokenContextFrom(context))` from `@objectstack/core`, the
published resolver the engine calls, and lowers the **resolved** tree.
Both call sites pass `ctx.context`. A placeholder the resolver refuses
is refused as `READ_SCOPE_COMPILE_FAILED` / 500 with the message
withheld.

That judgement has one spelling: a module-local
`resolveReadScopePlaceholders`. The existing
`assertReadScopePlaceholdersResolvable` (PR objectstack-ai#20072, used at the ObjectQL
merge sites) now calls it too. There is no second implementation of
token classification or resolution, and no second spelling of the
envelope or its sentence.

## Measured before the fix

Recorded on this branch (`8bf233a1fc`, an empty commit whose message
carries the full table), on base `a8bcce69c8`. The setup: a real
`SqliteWasmDriver`, a real `ObjectQL` behind the ObjectQL face, and the
echo's SQL run on the same database. Every face is driven through
`AnalyticsService` with the caller `{ userId: 'u_me' }` and no active
org. Rows d1 and d3 are owned by u_me, d2 by u_other, and d4 has no
owner.

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

The `$ne` row widened the scope: the literal matches nobody, so the
exclusion also admitted the caller's own rows. The joined hop was
measured too. Each face is compared with its own literal twin there,
because the two strategies shape a hop differently by design: NativeSQL
filters base rows, and the ObjectQL cross-object path uses the
`RESTRICTED_BUCKET`. On the ObjectQL hop every placeholder row equalled
its twin. On the NativeSQL hop none did.

With **no** context, the ObjectQL control refused every context-token
scope and still **resolved** the date macro (UTC), serving d1 d3.

**HTTP doors** (throwaway harnesses in `packages/runtime` and
`packages/rest`, read and measured only, not committed):
- `POST /api/v1/analytics/query` and `/api/v1/analytics/sql`: 200 with
the literal bound, and the echo printed the literal.
- `POST /analytics/dataset/query`: 200 with the literal bound.

**Reach:** the card's reach is confirmed by reading. It takes a
host-supplied `getReadScope`. The auto-bridged `security.getReadFilter`
composes concrete values: the sharing predicate, the RLS compiler's
output and the controlled-by-parent derivation.

## Where the resolution sits among the scope gates, and why

- **Before the lowering**, because the lowering binds values. What is
bound, and what the echo prints, is the value the engine resolves.
- **The refusal is raised after the lowering's own gates** (the objectstack-ai#20068
`$icontains` arm among them) **and after the objectstack-ai#20018 comparand faces.**
This is the engine's order: its lowering doors run before its resolver.
No gate's verdict moves either way. Resolution only replaces a
fully-wrapped placeholder string with a non-empty string, and it copies
the tree around it, where a non-`Date` class-instance comparand becomes
a plain object; every such comparand is refused in both forms. So a
scope that another gate also refuses keeps that gate's sentence. This is
pinned.
- **The objectstack-ai#13926 vacancy guard** still runs at the two merge sites after
the compiler returns, unchanged.
- **Context at the call sites:** `native-sql-strategy.ts:654` and
`objectql-strategy.ts:564` both hold `ctx.context`. That is the spec
`StrategyContext.context`, which `AnalyticsService.callCtx` binds from
the `context` argument of `query()` / `generateSql()`. It is the same
value `withReadScope` already hands the ObjectQL-face assertion. Nothing
in `spec`, `core`, `objectql`, `rest` or the service changes.

## The public export without a context

I did not follow the default suggestion to refuse every placeholder when
there is no context, because it disagrees with the control face. The
engine resolves a date macro for a context-less operation (UTC now) and
refuses a context token. Refusing every placeholder would give the
NativeSQL face and the echo a second answer whenever `ctx.context` is
`undefined`. So an absent `context` resolves the way the engine does: a
date macro resolves against UTC, and a context token or an unknown
placeholder gets the withheld 500. A placeholder is never bound as its
literal text. Both halves are pinned, and so is the context's time zone
being read.

## Tests (at `eafeaccea2`)

- New file: `read-scope-placeholder-three-faces.test.ts`, 22 tests. It
uses a real SQLite database and a real `ObjectQL`, with the ObjectQL
face, the echo (its SQL executed) and the NativeSQL face, plus both
joined hops. It pins:
  - each resolvable scope admits its literal twin's rows on every face;
  - both echoes print the resolved value;
- each refused scope gets the withheld 500 on all three faces and both
hops, before any native statement runs;
  - the context-less answer is the same on every face;
  - the sentence ordering;
- placeholder-free scopes compile byte-for-byte as before, with or
without a context;
- the caller's own `where` is unchanged: it still resolves, and still
answers `FILTER_TOKEN_UNKNOWN` / 400 with its message;
  - the public export's context-less decision.
- `pnpm --filter @objectstack/service-analytics test`: 125 files / 2908
tests passed. `typecheck` exit 0, and `--listFiles` includes the new
test file.
- **Ablations**, through `node scripts/ablation-replace.mjs` in WRAP
mode. The tests import the mutated sources by relative path (src), so no
build sits between mutation and measurement. Each leg was proven
restored: `git hash-object` equals the HEAD blob, and `git diff HEAD` is
empty.

| leg | mutation | red |
|:--|:--|:--|
| A1 | resolution removed (`lowered = filter`) | 18 of 22; the 4
survivors are the controls, including the byte-for-byte pin, which shows
those pins encode the pre-fix output |
| A2 | refusal raised early (`throw e`) | 1: the sentence-ordering pin |
| A3 | refusal dropped | 9: every refusal, no-statement, context-less
and sentence pin |
| A4 | `context: ctx.context` removed at `applyReadScope` | 6 |
| A5 | `context: ctx.context` removed at the echo's compile | 5 |

- **HTTP doors after the fix** (the same throwaway harnesses, not
committed):
- anonymous `/analytics/query` and `/analytics/sql`: a context token
gets the withheld 500; a date macro gets 200 with the resolved date
bound and printed.
- `/analytics/dataset/query` with a user: 200 with the user id bound; an
unknown or unresolvable placeholder gets the withheld 500.

## Gates (at `eafeaccea2`)

- `dispatch-gates --commands` derived 61 families. 59 exited 0.
- Two are NOT MEASURED because a prerequisite is missing (exit 3):
- `check:dual-build-cjs-loads` needs every package's `dist`. A narrowed
spot check does pass: the `service-analytics` CJS build loads and
resolves a placeholder.
  - `check:type-check-debt` needs the whole-workspace closure built.
- `--ran` reconciliation: 61 accounted, 0 UNRUN.
- `check-issue-citations` with a token: 14 citations, all resolve.
- ESLint, narrowed to the 4 changed `.ts` files: 0 errors and 0 warnings
over 4 files, read from the `--format json` output. ESLint's own
`--print-config` shows no `parserOptions.project` and no
`projectService`, so this config is not type-aware and the diff cannot
move any untouched file's verdict. The repo-wide `pnpm lint` is CI's.
- `main` moved twice during the work and was merged both times, never
rebased (`7c2146223c`, `eafeaccea2`). The dependencies each merge
touched were rebuilt before the suite and every gate above were re-run
on the final head.

## Changeset classification: differs from the dispatch default

`.changeset/20075-native-scope-placeholders.md` is `minor`, `Clause-②:
no (narrowing)`, **BREAKING**, with an ADR-0087 `not-required
(no-migration-prescription)` disposition. The dispatch's default was
`patch`. Two reasons:
- The NativeSQL face, the echo and the public export now refuse scopes
they used to serve. objectstack-ai#20018 made the same alignment on the same faces and
was declared `no (narrowing)` / `minor`.
- The `Check Changeset` level rule counts a new accepted key on a
published package's public surface as at least `minor`, and `context` is
such a key.

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

## Acceptance notes

- **Date-macro instant.** A scope's date macro resolves when the
compiler runs, not at the query's pinned instant. That is the same
property as the ObjectQL face, where the engine resolves at `aggregate`
time.
- **Log sentence for a scope with two defects.** If a scope carries a
vacancy shape and also a bad placeholder, the log sentence differs
between faces: the vacancy guard runs after the compiler on NativeSQL
and the echo, but first on the ObjectQL face. The wire envelope is
identical. The objectstack-ai#20018 comparand faces already have the same asymmetry.
- **Joined hops.** The two strategies still differ in shape on a joined
hop (row filter versus `RESTRICTED_BUCKET`). That is a documented
design, not this card's.

---
_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>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…s declared type (objectstack-ai#20445) (objectstack-ai#20498)

Fixes objectstack-ai#20445

Clause-②: yes (widening)

The `domain:services` lane's arms for the `$empty` operator, under
ruling A on objectstack-ai#20399 (`5865693155`). Both service-analytics filter faces
now answer `{ f: { $empty: true | false } }` by the field's DECLARED row
of the ruled per-type table. They reach it through the spec's one
expansion, `expandEmptyOperator(fieldDef)` from `@objectstack/spec/data`
(PR objectstack-ai#20442), and keep no copy of the table:

| declared row | `$empty: true` matches | `$empty: false` |
|---|---|---|
| text-like | null or `''` | the complement |
| multi-value (incl. `multiple: true` on a multi-capable type) | null or
`[]` | the complement |
| every other type | null only | the complement |

The staging does not move (「照 $like 先例分阶段」): `$empty` is **not** added
to `FILTER_OPERATORS`, and the `is_empty` / `is_not_empty` lowering
still emits `$null`. There is no `$eq: []` comparand anywhere (ruling 乙
on objectstack-ai#19757 stands).

## What changed

- **`empty-operator-sql.ts` (new).** The SQL for one `$empty` predicate
per declared row. Null-only: `col IS NULL`. Text: `(col IS NULL OR col =
'')`, with the empty string bound. Multi-value: `(col IS NULL OR L)`,
where `L` is the dialect's empty-JSON-list test: SQLite `json_valid`
guard inside a `CASE`, then `json_type(col) = 'array' AND
json_array_length(col) = 0`; Postgres a `jsonb` equality with `'[]'`;
MySQL `JSON_TYPE` = `'ARRAY'` and `JSON_LENGTH` = 0. `$empty: false` is
the exact complement of each. Every predicate is TOTAL (never UNKNOWN),
so a `$not` over `$empty` needs no NULL guard.
- **Read-scope face (`compileScopedFilterToSql`).** A new `$empty` arm
in `compileOperator`. It asks the caller for the field's declaration
(new optional `declaredValueShape` option; both callers pass it from the
context), calls `expandEmptyOperator`, and compiles the row. The flag
joins the existing boolean-domain gate with `$null` / `$exists`. Both
NULL-polarity tables gain the row (null satisfies `$empty: true`; the
arm is total).
- **`where` face (`lowerAnalyticsWhere` /
`normalizeAnalyticsFilterTree`).** `fieldLeaves` stops refusing `$empty`
and lowers it to a valueless `empty` / `notEmpty` leaf. The normalizer
sees no field declaration, and the multi-value row cannot be spelled in
the lowered vocabulary, so the row is resolved by each consumer of the
tree:
- `NativeSQLStrategy.buildFilterClause` (the executed statement) and the
`ObjectQLStrategy` echo both call `whereEmptyLeafSql`: the host's
declared shape, then the spec's expansion, then the row's SQL.
- `ObjectQLStrategy.convertFilter` (the engine path) hands `{ $empty }`
to the engine as written. Its arm is the engine lane's (objectstack-ai#20444).
- The flag joins `assertBooleanNullFlags` with `$null` / `$exists`. Both
polarity tables gain the row.
- **Where the declaration comes from.** A new context hook,
`DatasetScopedStrategyContext.declaredValueShape(object, field)`.
`AnalyticsService` answers it from the existing `sourceFieldMeta` hook
(`type`, and now `multiple`). `AnalyticsServicePlugin` relays `multiple`
from the field definition.

**The field's declaration is never guessed.** A face that cannot name it
refuses, before anything binds. On the `where` face that is
`INVALID_FILTER` / 400; on the read-scope face it is
`READ_SCOPE_COMPILE_FAILED` / 500. The same holds for a multi-value
field on the `'unknown'` dialect, where no JSON test parses everywhere;
the text and null-only rows need no dialect. Why a guess is not
possible: the "no declaration" reading the spec gives the JS faces
(null, `''` and `[]` all empty) has no SQL form without the type.
`amount = ''` is a type error on Postgres, and an empty list is only
recognisable as JSON. Both refusals are pinned with `code` + `status`.

## The question the card asked: should the read-scope face answer an
unknown operator with 400?

**No. It keeps `READ_SCOPE_COMPILE_FAILED` / 500 with the message
withheld, and this PR says so in code at `compileOperator`'s `default:`
arm.** The triage reading ("an authoring mistake answered as a server
error is the wrong class") assumes the caller authored the input.
Measured, the caller does not:

- **Who writes what reaches `compileScopedFilterToSql`.** Its only two
in-package callers are `NativeSQLStrategy.applyReadScope` and the
`ObjectQLStrategy.generateSql` echo. Both pass
`ctx.getReadScope(object)`. `AnalyticsServicePlugin` answers that hook
either from the `security` service's `getReadFilter`, which compiles
admin-authored sharing rules and permission sets, or from the host's own
`getReadScope` plugin option. The analytics caller's own filter takes
the other road, `filter-normalizer.ts`, and that road answers
`INVALID_FILTER` / 400 for an unknown operator.
- **The ruling already on file.** The read-scope module header records
the objectstack-ai#5367 maintainer ruling (2026-08-06, re-affirmed as objectstack-ai#7598 Q2 = A). A
read-scope refusal is a server fault: a 400 "told them to fix a request
that was never the problem, and hid the fault from the 5xx alerting". A
4xx body also relayed "THE FIELD NAMES AND COMPARANDS OF THE RLS
POLICY". The header states that the envelope "is not to be rewritten".
objectstack-ai#19995 (`60fdaa9e`, PR objectstack-ai#20072) extended the same withheld 500 to the
ObjectQL engine door for exactly that disclosure reason.
- Pinned: `$bogus` in a read scope still answers
`READ_SCOPE_COMPILE_FAILED` / 500 (`read-scope-empty-operator.test.ts`,
last case). The existing envelope suites stay green unchanged.

## Filter-semantics compile-surface declaration

Roster re-grepped on `fc0db22b` (`grep -rn
'matchesFilterCondition\|buildWhereSQL\|compileScopedFilterToSql'
packages --include=*.ts`).

| # | face | conclusion |
|---|---|---|
| 1 | `driver-sql` `applyFilterCondition` (and its `extends SqlDriver`
heirs) | **out of scope**: sibling objectstack-ai#20444 (`domain:engine`). Measured
today: it refuses `$empty` with `INVALID_FILTER` / 400, operator and
field withheld. Untouched here. |
| 2 | turso `RemoteTransport` `buildWhereSQL` | **out of scope**:
sibling objectstack-ai#20444. Untouched. |
| 3 | service-analytics `read-scope-sql` `compileScopedFilterToSql` |
**changed**: the `$empty` arm above, and the boolean gate. |
| 4 | service-analytics `filter-normalizer` `lowerAnalyticsWhere` /
`normalizeAnalyticsFilterTree` | **changed**: the `empty` / `notEmpty`
leaf, answered by NativeSQL and the ObjectQL echo, and handed to the
engine by ObjectQL execute. |
| 5 | `formula` `matchesFilterCondition` | **out of scope**: sibling
objectstack-ai#20444. Untouched. |
| half-face | objectql `having-filter` (`applyHaving` / `matchesHaving`)
| **out of scope**: sibling objectstack-ai#20444. Untouched. |
| unfrozen | `driver-memory` `checkCondition`, `driver-mongodb`
`translateFieldOperators` | **out of scope**: sibling objectstack-ai#20444. Untouched.
|

Two package-local consumers of face 4's tree, named so they don't read
as missed:

- **ObjectQL execute.** It hands `{ $empty }` to the engine. Until
objectstack-ai#20444's `driver-sql` arm lands, the engine refuses it, so this query is
refused on this strategy, while the echo prints the declared arm and the
native statement answers it. No face drops it.
- **Draft preview (`preview-evaluator.ts`).** Unchanged. It already
refuses `$empty` (`INVALID_FILTER` / 400) with its other unevaluated
operators, `$null` among them.

## Evidence (HEAD `6a07f8cc`; the suite ran at `20994c10`, and HEAD adds
only the changeset on top of it)

- **Premise, re-measured on `fc0db22b` before editing.**
`lowerAnalyticsWhere` passed `{ f: { $empty: true } }` through, and
`normalizeAnalyticsFilterTree` refused it `INVALID_FILTER` / 400.
`compileScopedFilterToSql` refused it `READ_SCOPE_COMPILE_FAILED` / 500,
and `$bogus` got the same answer. `git grep -c '$empty'` over
`service-analytics/src` read 0 hits; the control word `$null` read 10+
files.
- **New pins.**
  - `read-scope-empty-operator.test.ts`: 31 tests, executed on `sql.js`.
- `where-empty-operator.test.ts`: 27 tests. NativeSQL executes on
`sql.js`, the ObjectQL echo runs on the same database and must return
the same rows, and the condition handed to the engine is pinned.
- Fixture: a text, a `tags`, a `lookup` with `multiple: true`, a
`select` and a `number` field. Rows: null, `''`, `[]`, a non-list JSON
value, and a value.
- Covered: `$empty: false` as the complement; nesting under `$and` /
`$or` / `$not`; beside another operator on the same field. Refusals
assert `code` + `status`.
- Postgres / MySQL SQL strings are pinned as compiled, **NOT MEASURED**
as executed: there is no live server here.
- **Package suite.** `pnpm --filter @objectstack/service-analytics exec
vitest run --maxWorkers=2`: `Test Files 134 passed (134)` · `Tests 3151
passed (3151)`.
- **Typecheck.** `pnpm --filter @objectstack/service-analytics exec tsc
--noEmit --listFiles` exits 0, and its file list contains all three new
files.
- **Gates.** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derived 62 commands; all 62 ran.
`--ran` reconciliation: "62 derived famil(ies) accounted for — 60 run, 2
NOT-MEASURED".
- NOT MEASURED: `check:dual-build-cjs-loads` and
`check:type-check-debt`. Both exit 3 with `PREREQUISITE NOT MET`,
because they need the whole workspace built. Narrowed probe instead:
this package's built `dist/index.cjs` and `dist/index.js` both load and
export `compileScopedFilterToSql`.
- **Lint, narrowed.**
- Population: `eslint.config.mjs` lints `packages/**/*.{ts,tsx,mts,cts}`
with no type-aware parsing (no `parserOptions.project`). A verdict on an
untouched file therefore cannot move with this diff.
- `pnpm exec eslint --no-inline-config --format json` over the 10
changed `.ts` files: the JSON has 10 file entries, 0 errors, 0 warnings.

### Ablation: the negative pins can fail

Committed first; each leg ran through `scripts/ablation-replace.mjs`,
which applies the mutation, runs, and restores. Restore was proven blob
== HEAD (`05d539c76470`) with `git diff HEAD` empty.

- **A1: the null-only row counts `''`.** The `null_only` arm falls
through to the text arm. **9 red across both files**, including "the
null-only row does NOT count the empty string": `AssertionError:
expected [ 'n', 's' ] to not include 's'`.
- **A2: `$empty: false` stops being the complement.** The text arm's
false branch becomes an OR. **7 red**, including "name: $empty: false is
the exact complement": `expected [ 'l', 'o', 's', 'v' ] to deeply equal
[ 'l', 'o', 'v' ]`.

## Acceptance notes (observations, not filed)

- **The spec's staging prose goes stale here.** The `FILTER_OPERATORS`
TSDoc table in `packages/spec/src/data/filter.zod.ts` still lists both
service-analytics rows as REFUSES. Carrier: the flip card, which
rewrites that table. No `packages/spec` edit here.
- **Shared conformance cases belong in the spec.** A shared `$empty`
conformance table (per-type rows × stored states, the way
`FILTER_LOGIC_CASES` works) would let every face run one standard. That
is the spec lane's to add, with the flip card, and is not added here.
- **The flip card will need `$empty` rows** in
`objectql-echo-operator-coverage.test.ts` (`OPERATOR_CASES`) and
`objectql-icontains-arm.test.ts` (`SAMPLES`). Both assert their tables
equal `FILTER_OPERATORS`, so they go red on the flip until the rows
exist.
- **A read scope carrying `$empty` on the ObjectQL execute face** is
refused by the engine's driver as `INVALID_FILTER` / 400 with the
operator and field withheld, not the read-scope 500. `judgeFilter`
admits the operator because it stops before the driver. This predates
the PR and closes when objectstack-ai#20444 lands the driver arm. No in-repo producer
emits `$empty` in a read scope (the CEL lowering's `is_empty` emits
`$null`).


## Seat append (`domain:services` seat objectstack-ai#6021,
`session_017B6YKCGu8CTY2KBWgwaHAs`)

- The `Clause-②` line changed from `no` to `yes (widening)`, per
contract review FAIL `5876996555`. The PR adds two published members,
`multiple?` on `AnalyticsServiceConfig.sourceFieldMeta`'s return shape
and `declaredValueShape?` on `compileScopedFilterToSql`'s options. The
changeset moves to `minor` in the patch round on this PR.

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

---------

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

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant