Skip to content

fix(service-analytics)!: the NativeSQL execute face and the /analytics/sql echo refuse a read scope the shared comparand faces refuse - #20046

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-20018-native-read-scope-faces
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-20018-native-read-scope-faces

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #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 #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 #5367 ruling, re-affirmed as #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 #20017, so one read scope gets one verdict on all three analytics faces.

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.

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 #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 [#20018] note, and each keeps what it was there to pin:

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


Generated by Claude Code

…oss the three analytics faces (measurement, before the fix)

The ObjectQL execute face refuses a read scope the shared comparand faces
refuse; the NativeSQL execute face and the /analytics/sql echo lower the same
scope through compileScopedFilterToSql. This commit adds the three-face suite
alone, so its run on this tree records what each face answers today.

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
…rand faces, so native and echo answer as the ObjectQL face does

compileScopedFilterToSql now calls assertReadScopeComparandsRunnable once its
own lowering returns. A read scope the shared comparand faces refuse is
refused as READ_SCOPE_COMPILE_FAILED / 500 on the NativeSQL execute face and
the /analytics/sql echo, as the ObjectQL execute face already refuses it,
instead of being lowered and served.

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
…pe lowering binds a shape the shared faces refuse

Nine existing cases asserted that compileScopedFilterToSql lowers a null
list member or range bound, a binary comparand, a plain-object comparand
under a scalar operator, or an object with a non-Object prototype. Each is
refused now, as the ObjectQL execute face already refused it; every case
keeps what it was there to pin, with a note naming this change.

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
…ing moves on the read-scope door

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
…read-scope lowering

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
Resolves the #8186 comparand matrix against #20032 (#20010): the null row
carries both re-judged cells, whereIn INVALID_FILTER/400 and scopeIn
READ_SCOPE_COMPILE_FAILED/500, and the six-accepted-types check names each
cell with the change that moved it.

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
…e door, where it still binds

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-analytics/src/comparand-shape.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-analytics/src/comparand-shape.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 7e6ca1787aa97a98dd88b1d6fe32ec1962655edc → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 7e6ca1787aa97a98dd88b1d6fe32ec1962655edc

⚠️ 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 00:08
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit 980bc05 Sep 25, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20018-native-read-scope-faces branch September 25, 2026 00:27
os-sales pushed a commit that referenced this pull request Sep 25, 2026
Resolves the two test conflicts with #20046 (#20018) so both sets of
re-judgements hold: the comparand matrix keeps #20018's read-scope cells
and this branch's where-door cells, each named with the change that
moved it; the non-string $field case holds on both doors, each in its
own envelope. Sentences either PR made false on the merged tree are
scoped: the #5234 one-sentence note, the binary predicate note, the
comparand-shape.ts binary-extra docblock (#20018's "on the where door,
that is"), and this branch's changeset and normalizer lines on the
read-scope binary admission.

Claude-Session: 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
…le stored value and comparand, not only up to their first U+0000 (objectstack-ai#20025) (objectstack-ai#20067)

Fixes objectstack-ai#20025

Clause-②: no

## What this changes

`service-analytics` compiles its own SQL for the text operators, in
`text-match-sql.ts` `textMatchPredicateSql`, and three faces call it
with `dialect: 'sqlite'`:

- `compileScopedFilterToSql` (`read-scope-sql.ts`), the read scope,
reached directly and through both strategies' scope merge;
- `NativeSQLStrategy.buildFilterClause`, the executed `where`;
- `ObjectQLStrategy.buildFilterClauseSql`, the `/analytics/sql` echo,
which its caller runs on the same database.

Its `sqlite` arm emitted `GLOB` for every shape. SQLite's `glob()` reads
its pattern AND the stored value as C strings, so each is cut at its
first U+0000. `driver-sql` left that construct in objectstack-ai#19999 (the
comparand's cut) and objectstack-ai#20024 (the stored value's cut); this package
re-emits the driver's construct table rather than importing it, and its
copy had kept `GLOB`.

The `sqlite` arm now mirrors `driver-sql`'s `textMatchPredicate` /
`sqliteLengthAwareTextMatch` on `main`, cell for cell:

| shape | SQLite predicate |
|---|---|
| `contains` (`$contains`, `$notContains`, `$icontains`) | `instr(col,
?) > 0` |
| `ends` (`$endsWith`), non-empty comparand | `coalesce(substr(CAST(col
AS BLOB), -length(CAST(? AS BLOB))), CAST(col AS BLOB)) = CAST(? AS
BLOB)` |
| `ends`, empty comparand | `instr(col, ?) > 0` |
| `starts` (`$startsWith`), comparand without U+0000 | `col GLOB ?`,
unchanged byte for byte |
| `starts`, comparand holding U+0000 | `instr(col, ?) = 1` |

- This is the wider split the card's suggested shape predates. `glob()`
also cuts the stored value, so every `contains` / `ends` comparand
moves, not only a comparand holding U+0000. Only `$startsWith` without
U+0000 stays on `GLOB`, because neither cut can change that answer and
it is the one shape an index serves.
- The fold is still `lower()` on both sides, ASCII-only (objectstack-ai#4706 Q1 = A).
The four case-exact operators stay case-sensitive (Q2 = A); `instr()`
and the BLOB comparison are byte-wise.
- The negation is `NOT (…)`, which is NULL for a NULL value, as `NOT
GLOB` was. So the NULL-safe `$notContains` wrapper and the `$not`
totalisation compose unchanged (objectstack-ai#5298).
- Every comparand refusal, and the non-text-column constant, still runs
before the text arm. Neither door is touched.
- The suffix arm binds its comparand twice, in placeholder order,
through the existing `TextMatchBind`. The `LIKE` arms already bind two
values, so none of the three call sites changes.
- Nothing is escaped on the new constructs. `instr()` and the BLOB
suffix have no pattern language, so `*`, `?` and `[` bind as written.
- `text-match-sql.ts`'s header now describes the `sqlite` arm as it is,
and names the new parity suite beside
`text-operator-case-exactness.test.ts`.

## Measured before the fix: the face table

Executed, not read, at `980bc05e5b`. That is `main` after PR objectstack-ai#20046
landed, and it moved nothing in `text-match-sql.ts` since the dispatch
base `2274894cc4`. It ran on better-sqlite3 (SQLite 3.53.4) and sql.js
(3.49.1), each through its driver's own `execute()`. Every cell was
compared with `@objectstack/formula`'s `matchesFilterCondition`.

The grid: stored values with U+0000 at the start, middle and end,
without it, `''` and NULL; comparands with and without U+0000; each leaf
bare and under `$not`. All five faces answered alike, and so did both
engines: the direct read scope, the read scope through
`NativeSQLStrategy` and through the echo, the native `where`, and the
echo `where`.

| operator | comparand without U+0000 | comparand holding U+0000 |
|---|---|---|
| `$contains` | narrows → **matches JS** | widens → **matches JS** |
| `$notContains` | widens → **matches JS** | narrows → **matches JS** |
| `$startsWith` | matches JS (unchanged, still `GLOB`) | widens →
**matches JS** |
| `$endsWith` | widens and narrows → **matches JS** | widens → **matches
JS** |
| `$icontains` | narrows → **matches JS** | widens → **matches JS** |
| each of the above under `$not` | the opposite direction → **matches
JS** | the opposite direction → **matches JS** |

- **In this PR's pin grid** (`text-match-sqlite-nul.test.ts`, 128
cells), every face on both engines answered 88 cells differently from JS
at base, and answers 0 at head.
- **The ObjectQL execute face** goes through the engine to `driver-sql`,
which is already fixed on `main`. It is the control, and it answered all
128 like JS at base and at head.
- **The `unknown` arm on SQLite** emits `LIKE`, and SQLite's `LIKE` cuts
at U+0000 too. That was measured on both engines: `'a'` + U+0000 + `'b'`
is not `LIKE '%b%'`, and `'plain'` is `LIKE '%'` + U+0000 + `'%'`. The
arm is untouched. It is the residue for dialects nothing answered for,
PostgreSQL-like ones among them, and PostgreSQL has no `instr()`.
Choosing a construct there would be a dialect guess. It is reported to
the seat as a finding and noted in the header.
- **`$like` / `$ilike`** are refused on every analytics face, before
this arm: `READ_SCOPE_COMPILE_FAILED` on the read scope,
`INVALID_FILTER` / 400 on the `where`. This package compiles no
raw-pattern operator, so there is nothing to swap.

## Controllability (triage's point 1)

- **Comparand side: no caller-controlled producer measured.** The RLS
compile path (`RLSCompiler.compileFilter` → `compileCelToFilter`)
resolves a string method's argument from one of two places:
  - a literal in the admin-authored policy;
  - a string attribute of `current_user` (id, organization id, email).

The ids are system-generated. The email is refused at every auth entry
measured when it holds U+0000, and it is read-only on the user object.
Membership sets are arrays, which the string methods refuse.
`plugin-sharing` emits id / owner membership scopes, not text operators.
Sharing-rule criteria are admin-authored and evaluated through
`engine.find`, the driver face that is already fixed.
- **Stored-value side: a caller-controlled producer exists.** It is any
persona that holds create or edit on an object with a text field. The
engine's write path stores U+0000 unchanged under a non-system context
(executed). Reaching a read scope also needs a deployment-authored
`using` predicate with a string method over such a field. No shipped
example authors one.

## Tests (at `d000746b1d`)

- **New `src/__tests__/text-match-sqlite-nul.test.ts`, 29 tests.** It
covers:
- the 128-cell grid on every face, on better-sqlite3 and sql.js, each
through its driver's `execute()`. That is the transport `plugin.ts`
uses, and a bare sql.js bind would cut a comparand holding U+0000 before
any construct saw it;
- each cell equal to the JS answer AND to `driver-sql`'s own rows on the
same engine. That is the parity pin that keeps the two construct tables
from drifting;
  - literal pins for the headline cells;
- the compiled constructs per shape, including `$startsWith` without
U+0000 emitting `GLOB` and its escaped pattern byte for byte on all
three compilers.

It was committed first, red at base (`ced01cbad7`): 14 failed and 15
passed; each face on each engine had 88 of 128 cells differ;
`driver-sql` had 0.
- **Respelled pins, never loosened.** Four existing files asserted
`GLOB` for shapes that no longer compile to it:
`text-operator-case-exactness.test.ts`, `icontains-dialect-sql.test.ts`,
`sql-dialect-vocabulary.test.ts` and
`text-operator-non-text-column.test.ts`.
  - Each now names the exact per-operator construct.
- The read-scope and echo loops check a per-operator regex plus "not `
LIKE `", where they used one `/GLOB/`.
- The `$endsWith` / `$startsWith` compiled-text rows gained SQL
assertions they did not have.
- `pnpm --filter @objectstack/service-analytics test`: 121 files, 2718
tests passed. `typecheck` (`tsc --noEmit`, whose program lists the
touched test files) exit 0.

### Ablations: each negative pin, one shape at a time, through
`scripts/ablation-replace.mjs`

The subject is `src/text-match-sql.ts`, imported relatively by every
face, so vitest reads source and no `dist/` is involved. Every leg
landed on disk (the anchor count went from 1 to 0 and the blob changed)
and was restored with its blob equal to HEAD (`e913134711cb`) and `git
diff HEAD` empty.

| leg | mutation | red |
|---|---|---|
| A1 | `contains` back on `GLOB` | 21 tests; 60/128 cells per face and
engine |
| A2 | `ends` back on `GLOB` | 15 tests; 22/128 cells |
| A3 | `starts` holding U+0000 back on `GLOB` | 11 tests; 6/128 cells |
| A4 | `starts` without U+0000 moved off `GLOB` | 4 compiled-text tests
(the byte-for-byte pins); rows unchanged, as expected |
| A5 | empty `ends` sent to the suffix construct | 11 tests; 2/128 cells
(`''` bare and under `$not`) |
| A6 | `coalesce()` removed from the suffix | 14 tests; 12/128 cells
(`''` under `$not`) |

The first A6 attempt was a no-op. Its replacement text already occurred
inside its own anchor, so the tool refused the leg and ran nothing. It
was re-run with a non-overlapping anchor (the row above).

`EXPLAIN QUERY PLAN` over an indexed TEXT column, on both engines:
`$contains` / `$endsWith` scanned under `GLOB` and still scan, and
`$startsWith` without U+0000 keeps its `SEARCH … USING INDEX`.

## Gates (at `d000746b1d`)

- **The dispatch-gates derivation**: `node scripts/pm/dispatch-gates.mjs
--repo objectstack-ai/objectstack --commands`, run from the real diff.
It gave 60 families, identical to the dispatch-time list.
  - 58 exit 0.
- 2 are NOT MEASURED: `check:dual-build-cjs-loads` and
`check:type-check-debt` both exited 3 with PREREQUISITE NOT MET, because
they read the whole repo's built closure and this worktree built only
the package's own. CI builds it first.
  - `--ran` reconciliation: 60 derived, 58 run, 2 NOT MEASURED, 0 unrun.
- `GITHUB_TOKEN=… node scripts/check-issue-citations.mjs`: every
citation this change adds resolves.
- **Lint, a declared narrowing.** ESLint ran on the 6 changed TS files
(`--no-inline-config --format json`): 6 files, 0 errors, 0 warnings.
- The population is the config's `packages/**/*.{ts,…}` block; none of
the six is in `NEVER_LINTED`.
- Type-aware linting is not enabled: `--print-config` shows
`parserOptions` without `project`. So this diff cannot move a verdict on
an untouched file.
  - The full `pnpm lint` is CI's.

## Acceptance notes

- **Surface.** The claim named `text-operator-case-exactness.test.ts`
and new test files. Three more existing test files carried `GLOB` pins
for the moved shapes and are respelled here (listed above). No call
site, refusal door, driver, spec or formula file is touched.
- **Stale prose, no assertion reads it.** These comments still say the
SQLite arm is `GLOB` for every shape: `like-pattern.ts` (the
`$icontains` note), `read-scope-sql.ts` (the `textMatch` and
`$icontains` docblocks), `strategies/native-sql-strategy.ts`,
`strategies/objectql-strategy.ts` and `strategies/types.ts`. They ride
the next PR that touches those files. Carrier: none.
- **Observation: `$icontains: ''`.** It is not a U+0000 cell, and this
change does not move it.
- These compilers, and `AnalyticsService.query` in-process, answer every
non-NULL row for it, as they did under `GLOB '**'`.
- The spec's `FILTER_TEXT_CASES` declares it refused (`INVALID_FILTER`),
and `driver-sql` refuses it.
- Reachability through the REST door is not measured. It is reported to
the seat with its evidence.
- `main` moved during this run only by `3557f85fa5` (a `driver-turso`
README), which touches nothing here. No merge was needed.

---
_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
…parand-TYPE face on the object spelling (objectstack-ai#20035) (objectstack-ai#20058)

Fixes objectstack-ai#20035

Clause-②: no (narrowing)

## What this changes

The analytics `where` door (`lowerAnalyticsWhere` in
`packages/services/service-analytics/src/strategies/filter-normalizer.ts`)
now runs the shared comparand-TYPE face, `normalizeFilterComparandTypes`
(`@objectstack/spec/data`), on the object spelling. It runs after the
comparand-shape face and before any node is built, the order
`parseFilterAST` and the engine seam use. The condition the door lowers
is the face's RETURN value, so a bigint within 2^53 is narrowed to its
number, copy-on-write, as the engine does it.

The governing record is the objectstack-ai#7872 ruling (2026-08-12): the accepted
comparand types are `string | number | bigint | boolean | null | Date`,
and the face 「refuses everything else loudly at the compile face」. The
`FilterArray` spelling of this door (inside `parseFilterAST`) and the
ObjectQL engine seam already ran it. The object spelling did not.

How:

- `normalizeWhereComparands` (new, exported) is the door's comparand
gate: the shape passes (unchanged, now module-private as
`assertWhereComparandShapes`), then the type pass over the whole
condition.
- The type pass hands each field entry to the face as a one-entry node
with the path of the node that holds it, the same hand-over the shape
pass makes. The face therefore reports exactly the path it reports on
the whole condition, so the object spelling gets the `FilterArray`
spelling's bytes.
- Nested-relation entries are handed over too. The face leaves a
nested-relation object alone as filter structure; this compiler flattens
it to a dotted member, so its entries are comparands. `{ acct: { amt: …
} }` is judged at `where.acct.amt`, the path the dotted spelling gets.
- The shared traversal (`forEachWhereFieldEntry`, now a copy-on-write
`mapWhereFieldEntries`) descends a nested relation only when it is a
PLAIN object (prototype `Object.prototype` or `null`). That is the type
face's own structure test. Before, a `Uint8Array`, `Map` or class
instance in the implicit slot was descended as a relation: `{ stage:
Uint8Array }` compiled to `stage.0 = 1 AND stage.1 = 2`. Now it is
visited as the comparand it is.
- The draft preview (`preview-evaluator.ts`) calls the same gate and
evaluates the narrowed condition it returns. This closes the card's
class-a leg: an `undefined` comparand is refused instead of answering no
row.

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

Base `origin/main` `246314dffe`. 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, ''). The faces were: the native
SQL execute; the `/analytics/sql` echo; the ObjectQL engine path over a
real `ObjectQL` `engine.aggregate`; `engine.find` on the same object
`where` (the engine seam); and the draft preview.

| cell | object spelling at base | `FilterArray` spelling | engine seam
(object) |
|:--|:--|:--|:--|
| plain object `$ne` `{stage:{$ne:{a:1}}}` | native bound the JSON text
`'{"a":1}'`: EVERY row; echo `DATABASE_ERROR` / 500; engine path 400;
preview EVERY row | 400, type face at `where.stage.$ne` | 400 |
| plain object `$gt` | native: no row; echo 500; engine path 400;
preview: no row | 400 | 400 |
| plain object `$eq` | native: no row; echo 500; the engine path
received `{stage:{a:1}}` and driver-sql refused it in its own words | no
`$eq` spelling (`=` lowers to a nested relation) | 400 |
| plain-object `$between` endpoint | the lower bound compared `amt`
against the JSON text `'{"a":1}'`: no row; echo 500; the engine path
refused it as a `$gte` the author never wrote | 400 at
`where.amt.$between[0]` | 400 |
| plain object as an `$in` member / `$contains` | refused 400 in this
door's objectstack-ai#5234 wording | 400, type face | 400 |
| `{ $field: 5 }` under `$gt` (not a reference) | native: no row; echo
500; engine path 400 | 400 (a plain object) | 400 |
| binary `$eq` / `$in` member | native bound JSON TEXT
`'{"0":1,"1":2}'`, not a blob: no row; echo bound the raw buffer: no
row; engine path 400 | 400 | 400 |
| binary `$ne` | native, echo and preview: EVERY row; engine path 400 |
400 | 400 |
| binary implicit `{stage: Uint8Array}` | flattened as a nested
relation: native `DATABASE_ERROR` / 500; echo and engine path
`INVALID_FIELD` / 400; preview refused operator "0" | 400 at
`where.stage` | 400 |
| `Map` `$eq` | native bound `'{}'`: no row; echo 500; engine path 400 |
no `$eq` spelling | 400 |
| `Map` implicit | refused as objectstack-ai#5240's zero-operator wrapper; preview
EVERY row | 400 (a Map instance) | 400 |
| bigint within 2^53 `{amt:{$gt:2n}}` | native, echo and engine path d2,
d3, d5 (bound as a bigint); preview d2, d5 (ordered as text: '10' before
'2') | narrowed to 2: d2, d3, d5 | d2, d3, d5 |
| bigint within 2^53, implicit `5n` / `$in [5n,10n]` | the same rows as
the narrowed number on every face | narrowed | the same rows |
| bigint beyond 2^53 `$gt` / `$in` member | native and echo bound it: no
row; engine path 400; preview d2, d5 / no row | 400 | 400 |
| `undefined`, implicit / `$gt` / `$in` member | refused 400 in objectstack-ai#6386's
wording (`[analytics] comparand at "amt".$gt is undefined`); the preview
answered NO row | 400, the type face's `undefined` sentence | 400 |
| `{$null: undefined}` / `{$null: {a:1}}` | lowered to `set` (IS NOT
NULL): d1, d2, d3, d5 on native, echo and engine path; the preview
refused `$null` as unevaluable | no spelling | 400 |
| nested relation `{acct:{amt:{$gt:{a:1}}}}` | native: no row; echo 500;
engine path 400 | dotted `['acct.amt','>',{a:1}]` 400 | 400 |
| under `$or` `{$or:[{id:'d3'},{stage:{$ne:{a:1}}}]}` | native and
preview: EVERY row; echo 500 | 400 at `where.$or[1].stage.$ne` | 400 |
| controls `{amt:{$gt:2}}`, `{stage:null}` | d2, d3, d5 / d4 on every
face and both spellings | the same | the same |

After the change, every refusal row is refused on all four faces, before
any statement or `engine.aggregate` call. The object message equals the
`FilterArray` message and the face's own message, byte for byte. The
bigint rows serve the same rows on all four faces, the preview included,
and no face binds or receives a bigint.

## Binary: reconciled, not kept as a declared local extra

The type face's docblock lets a door keep "its recorded driver-local
extras — binary bindables … declared at the use site", and objectstack-ai#8186 asked
for an explicit keep-or-reconcile call. **The call is reconcile.** The
evidence:

- **This door never delivered the extra.** `isBindableComparand`
(`comparand-shape.ts`) admits binary, but the native path binds every
object through `toSqlBindValue`, which JSON-stringifies it. Measured:
`{stage:{$in:[Uint8Array]}}` bound `'{"0":1,"1":2}'`, a value no blob
column holds. `$ne` then served every row, and the implicit spelling
compiled to a dotted member no object has. The engine path and the
`FilterArray` spelling refused binary outright.
- **No producer relies on it.** JSON has no binary type, so no REST
caller can send one. A census of the 258 non-test source files under
`packages/**` and `examples/**` that mention analytics found no binary
value built into a `where`. The six files that mention a binary type at
all are the client's stream and upload code, `rest-server.ts`'s xlsx
streaming, the two drivers' own extras, and this package's two filter
files. Positive control: 21 of the 258 carry a `where:` object literal,
which the scan read.
- **Scope.** This PR does not touch the read-scope door
(`read-scope-sql.ts`). Since objectstack-ai#20018 landed (PR objectstack-ai#20046), that door also
refuses a binary comparand, with the same face after its own gates, so a
binary is now refused at both analytics doors. The `isBindableComparand`
predicate still admits it, and the `objectstack-ai#8186` predicate-level pins stay
green.

## Pins re-judged: predicted 39 red in 9 files, measured 39 red in the
same 9 files

Before wiring the gate I read every candidate pin and predicted the red
set. I then wired the gate and ran the package suite (`86907678c0`):
`Tests 39 failed | 2617 passed (2656)`, the identical set. Each pin
keeps its verdict, or flips with a note quoting the objectstack-ai#7872 ruling. ⛔ None
is deleted.

- `filter-normalizer-undefined-comparand.test.ts` (21). objectstack-ai#6386's twelve
positions, its five `$not` rewrite paths, "says ONE thing" and "names
the repairs" keep the refusal and now read in the face's sentence and
path (`where.d.$gt`). The face runs BEFORE the objectstack-ai#5146 rewrite, so the
rewrite block now pins that the author's own `$not` is judged. `{$null:
undefined}` / `{$exists: undefined}` flip from `set` to refused, because
the face judges those comparands as literals. The boolean-domain
question (objectstack-ai#5347 / objectstack-ai#5369 / objectstack-ai#6387) stays untouched for accepted values:
`{$null: 'false'}` still lowers as before, and a pin now says so.
- `comparand-shape-refusal.test.ts` (8). objectstack-ai#5234's `$in` / `$nin`
object-member and LIKE-family object sentences now read in the face's
sentence. The `{ $eq: {…} }` account objectstack-ai#5234 "left open" flips to refused,
`{ $eq: { $field: 5 } }` included.
- `cross-field-reference-refusal.test.ts` (3). A non-string `$field` and
a plain object under `$eq` flip to refused; the routing detector half is
unchanged. objectstack-ai#7693's `$icontains {foo: 1}` keeps its refusal and its
sibling-equality check, in the face's words.
- `comparand-door-single-source.test.ts` (2). The objectstack-ai#8186 matrix: `binary`
`whereIn` / `whereEq` and `plain object` `whereEq` move from accept to
refused. The predicate and read-scope cells are unchanged.
- `filter-normalizer-mixed-wrapper.test.ts` (1). objectstack-ai#6444's order:
`{d:{$eq: undefined, nested:'x'}}` is still diagnosed as the comparand
first, now by the face.
- `filter-value-type-fidelity.test.ts`,
`filter-refusal-envelope.test.ts`,
`filter-normalizer-not-null-safe.test.ts` (1 each). The objectstack-ai#6386 wording
only.
- `where-face-arms-refusal.test.ts` (1). The objectstack-ai#20010 CONTROL that pinned
"the TYPE face is not run here" now pins the type face's sentence.

**Census of consumer pins outside the package.** I searched all 3904
tracked test files under `packages/**` and `examples/**` (outside this
package) for objectstack-ai#6386's sentence, objectstack-ai#5234's two sentences, objectstack-ai#6444's wording
and the face's sentence. The hits were driver-sql's and driver-turso's
own refusals and spec's own tests, none through this door. A second pass
covered the 127 test files that reach analytics (`@objectstack/rest` 15,
`runtime` 17, `qa/dogfood` 11 and the rest). It looked for an
`undefined`, plain-object, binary, `Map` or bigint comparand in a
`where`, and found none. So there are **zero hits and no edit outside
the package**. `@objectstack/rest` ran in full anyway (below).

## Compile surfaces

| surface | verdict |
|:--|:--|
| `lowerAnalyticsWhere` / `normalizeAnalyticsFilterTree`: caller
`where`, dataset scope `filter`, measure `filter`; native execute,
`/analytics/sql` echo, ObjectQL engine path | **changed.** Refused
before any statement or `engine.aggregate`; a bigint within 2^53 is
narrowed. Pinned per face over a real engine. |
| `evaluateAnalyticsQueryOverRows` (draft preview) | **changed.** The
same gate; it evaluates the narrowed condition. |
| `normalizeFilterComparandTypes` (the shared face) | **already
compliant.** This PR calls it and does not change it. |
| `parseFilterAST` (this door's `FilterArray` spelling) | **already
compliant.** Measured at base: every cell refused or narrowed. |
| ObjectQL engine seam (`lowerWhereFilterArray`) | **already
compliant.** Measured at base with `engine.find`. |
| `compileScopedFilterToSql` (service-analytics read scope) | **out of
scope.** A separate door and envelope (500); objectstack-ai#20018 is in flight in
`read-scope-sql.ts`. Not touched. |
| driver-sql, driver-turso RemoteTransport, formula, driver-memory,
driver-mongodb | **already compliant at the platform doors.** They sit
behind `parseFilterAST` or the engine seam, which run the face. Not
re-measured here. |
| objectql HAVING (`applyHaving` / `matchesHaving`) | **out of scope.**
A different door, over aggregated rows. |

## Tests and evidence (head `f4ba18ba9a`)

- **New `src/__tests__/where-type-face-refusal.test.ts`, 106 tests.**
Every refusal asserts `code` + `status` + the face's opening sentence.
- Byte identity for 24 cells: object = `FilterArray` =
`normalizeFilterComparandTypes`. Another 9 object-only cells (`$eq`, the
`$null` / `$exists` flags, `$not`) are held to the face. The nested
relation is held to the dotted spelling.
- What is diagnosed first: the shape face before the type face, over the
whole condition (`parseFilterAST`'s order); the objectstack-ai#19888 equality list
before either; the type face before objectstack-ai#6444's mixed wrapper and the
unsupported-operator refusal.
- What the face does not judge keeps the door's sentences: an
`undefined` inside an array comparand or under an unknown operator
(objectstack-ai#6386), and an array or `{ $field }` member / LIKE comparand (objectstack-ai#5234 /
objectstack-ai#7598).
- Narrowing: the tree carries the number, the same tree the
`FilterArray` spelling compiles. The caller's condition is never edited,
and the same reference comes back when nothing narrowed. CONTROL: every
accepted comparand compiles as before.
- Four faces over a REAL `ObjectQL` engine on sql.js, with 14 refused
cells × 4 faces. Each asserts 0 statements and 0 `engine.aggregate`
calls. CONTROLS: scalars and `null` serve the same rows on every face. A
bigint within 2^53 serves its number's rows on every face, and no face
binds or receives a bigint.
- Stored datasets through the service doors: the dashboard door and the
draft preview, for a scope filter and a measure filter; the registered
cube on the ObjectQL door (0 aggregates); and a CONTROL.
- **Package run at `f4ba18ba9a`** (`pnpm --filter
@objectstack/service-analytics test && … typecheck`, under the verify
lock): `Test Files 120 passed (120)`, `Tests 2762 passed (2762)`, `tsc
--noEmit` exit 0. `tsc --listFiles` includes all 12 touched TS files.
Base: 119 files / 2656 tests.
- **`@objectstack/rest` full suite** (its closure built,
service-analytics `dist/` carrying `normalizeWhereComparands`): `Test
Files 194 passed (194)`, `Tests 3265 passed | 1 skipped (3266)`.
- **Ablations.** Each ran from the committed fix through
`scripts/ablation-replace.mjs`: anchor x1 to x0, marker `grep -c` 1,
blob changed. The restore was proven as blob == HEAD with `git diff
HEAD` empty, and each wrapper carried its own `trap` restore. The tests
import the source relatively, so no build is on the path. Every count
was predicted before the run:
- **A, the type walk removed** (`return
normalizeWhereComparandTypes(node, path);` became `return node;`).
Predicted 138; measured `Tests 138 failed | 2624 passed (2762)`: 99 in
the new file and the 39 re-judged pins. Samples: `native execute:
expected a refusal, got rows: expected [ 'd1', 'd2', 'd3', 'd4', 'd5' ]
to be undefined`, `expected 'accept' to be 'INVALID_FILTER/400'`,
`native execute: a bigint was bound: expected true to be false`.
- **B, the preview evaluates `query.where` instead of the gate's
return.** Predicted 1; measured `1 failed | 2761 passed`: `draft
preview: $gt 2n: expected [ 'd2', 'd5' ] to deeply equal [ 'd2', 'd3',
'd5' ]`.
- **C, the door discards the gate's return.** Predicted 3; measured `3
failed | 2759 passed`: the tree-narrowing, copy-on-write and
bound-bigint cases.
- **D, the plain-object check removed from the nested-relation test.**
Predicted 12; measured `12 failed | 2750 passed`: the binary / `Map` /
class-instance implicit cells. Samples: `expected 'DATABASE_ERROR' to be
'INVALID_FILTER'`, `expected '[analytics] "stage" carries a field c…' to
be 'Filter comparand at where.stage is a …'`.
- **Gates.** `dispatch-gates --repo objectstack-ai/objectstack
--commands` at `f4ba18ba9a` derived 60 families. The union run was 61:
those 60 plus `check:dispatcher-error-vocabulary` from the dispatch
list, which exited 0. 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`: `60 derived famil(ies) accounted for — 58 run, 2
NOT-MEASURED (2 DERIVED from a recorded exit 3)`, 0 UNRUN. The passes
include `check-adr-0087-registration --base origin/main` (it accepted
the changeset's disposition), `check-changeset-no-major`,
`check:changeset-gate-self-tests`, `check:nul-bytes`,
`check:where-matcher`, `check:test-source-alias`,
`check:cross-package-test-inputs` and `check:issue-citations`.
`check-issue-citations` also ran in its board-probing mode with
`GITHUB_TOKEN`: exit 0, 43 citations resolve.
- **Lint, narrowed to the change.** `eslint --no-inline-config --format
json` over the 12 touched TS files: 12 files, 0 errors, 0 warnings.
`eslint --print-config` resolves a config for each, which is the
population read from eslint's own config. `eslint.config.mjs` never
enables type-aware linting (its note at line 327), so this diff cannot
move the verdict on an untouched file.

## Changeset and the ADR-0087 disposition

`.changeset/20035-analytics-where-type-face.md`:
`@objectstack/service-analytics` minor, a `!` headline, `Clause-②: no
(narrowing)`, and a **BREAKING** paragraph with a before / now table for
every accept-cell that now refuses.

The marker is `not-required (no-migration-prescription)`, and it is the
honest one. **objectstack-ai#7872's transition is not on the ledger.** No entry in
`packages/spec/src/migrations/registry.ts` names the type face or its
accepted set, and objectstack-ai#7872's own changeset declared no breaking change. So
`already-registered` would name an id that does not cover this change,
which ADR-0087's own addendum calls out. `registered` needs a ledger
entry in `packages/spec`, which this card excludes. And no ledger entry
could carry this change: a stored plain-object comparand has no accepted
comparand to be rewritten to, and the other refused values cannot be
stored as JSON at all. The table records each cell's verdict before and
now and prescribes no rewrite.

## Deviations from the dispatch, stated

1. **"FROM → TO table" is spelled "before / now".** The gate reads a
literal `FROM` / `TO` label as a migration prescription, which would
refuse the only honest disposition above. The table is the transition in
substance (the verdict each accept-cell had and has), and its columns
say so. The seat may prefer registering a ledger entry in a
`packages/spec` follow-up instead; that is out of this card's surface.
2. **The traversal's nested-relation test changed** (plain objects
only). It was needed for byte identity: without it, `{ stage: Uint8Array
}` and `{ stage: new Map() }` never reach the type face. Ablation D pins
it.
3. **`assertWhereComparandShapes` is now module-private.** Its only
other caller, the preview, now calls `normalizeWhereComparands`. The
package entry never re-exported it.
4. **`origin/main` merged in (round 2), as a merge commit
(`0164eba2b7`), after objectstack-ai#20046 (objectstack-ai#20018) landed as `980bc05e5b`.** Two test
files conflicted; both sets of re-judgements hold on the merged tree
(see "Round 2" below). `origin/main` has since moved one commit, to
`3557f85fa5` (a driver-turso README). It touches none of these files, so
it was not merged again.

## Round 2: merged with `origin/main` after objectstack-ai#20046 (head `0164eba2b7`)

- **Conflict 1, `comparand-door-single-source.test.ts`.** The matrix
keeps both sets of cells:
- objectstack-ai#20018's scope cells: `null` `scopeIn`, and binary and plain object
`scopeIn` / `scopeEq`, all `READ_SCOPE_COMPILE_FAILED/500`;
- this PR's `where` cells: binary `whereIn` / `whereEq` and plain object
`whereEq`, all `INVALID_FILTER/400`.

Each moved cell's comment names the change that moved it. Every other
cell stays `accept`, and objectstack-ai#20046's per-cell `moved` map for the six
accepted types is kept as is. The header's binary bullet now says the
extra is reachable at neither door. objectstack-ai#20046's "the `where` door keeps the
extra" is gone, because this PR makes it false.
- **Conflict 2, `cross-field-reference-refusal.test.ts`.** The
non-string `$field` case now holds on both doors, each in its own
envelope. The `where` door refuses it as a plain object at
`where.amount.$gt` with `INVALID_FILTER` / 400 (this PR). The read-scope
lowering refuses it with `READ_SCOPE_COMPILE_FAILED` / 500, in the type
face's words and not the field-reference gate's (objectstack-ai#20018). The routing
detector still returns `null` for it.
- **Sentences one PR made false, re-read on the merged tree.** Each is
now scoped:
- `comparand-shape-refusal.test.ts`'s "one sentence, two envelopes"
note: for a plain object, `Map` or binary, the `where` door now answers
in the type face's sentence, while the read scope keeps this package's
sentence because its own gates run first;
  - the binary predicate note in the matrix file;
  - the `comparand-shape.ts` binary-extra docblock (above);
- this PR's changeset and normalizer lines about the read-scope binary
admission.

The file list against `main` is 14 files; `comparand-shape.ts` (docblock
only) is the one added.
- **Evidence on the merged head `0164eba2b7`.**
  - Dependency closure rebuilt (14 tasks, 0 cached).
- The service-analytics suite: `Test Files 121 passed (121)`, `Tests
2795 passed (2795)`. `tsc --noEmit` exit 0.
- Ablation A again (the type walk removed): predicted 138, measured
`Tests 138 failed | 2657 passed (2795)`, with the same per-file split
(99 new-file, 21 / 8 / 3 / 2 / 1×5 re-judged). Restore: blob == HEAD
(`6de15db882ed`), `git diff HEAD` empty.
- Gates re-derived: 60 families, identical to round 1. The union of 61
gave 59 exit 0 and 2 exit 3, the same two NOT MEASURED
(`check:dual-build-cjs-loads`, `check:type-check-debt`). `--ran`: `60
derived famil(ies) accounted for — 58 run, 2 NOT-MEASURED`.
- `check-adr-0087-registration --base origin/main` exit 0 (same
disposition). `check-issue-citations` board-probing exit 0 (48 resolve).
  - eslint over the 13 touched TS files: 0 errors, 0 warnings.

## Round 3: stale docblocks carried (head `8c78550a75`)

- One commit, `comparand-shape.ts` docblocks only: 53 comment lines
added, 6 removed, with no code or test line changed. So there is no
ablation this round: it moves no behaviour and no assertion.
- Evidence at `8c78550a75`:
- the service-analytics suite: `Test Files 121 passed (121)`, `Tests
2795 passed (2795)`; `tsc --noEmit` exit 0; eslint on the file: 0
errors, 0 warnings;
- `dispatch-gates --commands`: the same 60 families; the union of 61
gave 59 exit 0 and 2 exit 3 (the same NOT MEASURED pair); `--ran`: `60
derived famil(ies) accounted for — 58 run, 2 NOT-MEASURED`;
  - `check-issue-citations` board-probing: exit 0, 72 resolve.
- `origin/main` is at `b76aad5f6f` at the time of writing. None of its
commits since the merge base `980bc05e5b` touches `service-analytics` or
the shared filter faces, so it was not merged.

## Round 4: the unbindable list-member refusal no longer offers "(or a
binary value)" (head `cf1563d6f8`)

- **What changed.** This is the seat's round-3 answer (option A, on this
PR). It is one commit, `cf1563d6f8`: 3 files, +28 / −10.
- `comparand-shape.ts`: `unbindableListMemberMessage`'s runtime text no
longer offers "(or a binary value)" as a repair. It now names only the
accepted set. Its code, status and verdict do not change.
- Two things are unchanged. `isBindableComparand`'s binary arm stays as
driver-sql's mirror. driver-sql's own copy of the sentence
(`sql-driver.ts`) also stays, because that driver does bind a binary.
- `comparand-door-single-source.test.ts`: the objectstack-ai#8186 pin that asserted
`toContain('(or a binary value)')` is re-judged, flipped, with a note.
The message must not contain `binary`, and the accepted-set sentence
must be followed directly by "Refusing rather than binding it".
  - The changeset gains one line that says so.
- **Census of pins on the old wording.** This is a `git grep -F` over
all 9482 tracked files at `cf1563d6f8`, so every package is covered,
`packages/rest` included. The tree has no snapshot files.
- "or a binary value": 4 hits. One is `sql-driver.ts:3007`, driver-sql's
own copy, which is expected and stays. The other 3 are prose that names
the removed words: the changeset line, the re-judged pin's comment and
the `comparand-shape.ts` docblock. None of them is an assertion.
- The wider "binary value" gives 7 hits. The 3 extra hits are another
driver-sql message, a driver-sql comment and the docblock's quote of the
old hand copy. None of them is an assertion.
- "in its own right — use": 2 hits, both runtime strings: this message
(`comparand-shape.ts:700`) and driver-sql's copy (`sql-driver.ts:3006`).
- "Every member of an $in/$nin/$between list is a comparand": 1 hit,
this message itself.
- "Refusing rather than binding it": 5 hits. They are this message, the
new pin (`comparand-door-single-source.test.ts:325`), driver-sql's copy,
and two other messages (`comparand-shape.ts:595`, driver-turso
`remote-transport.ts:4141`).
- `unbindableListMemberMessage`: 9 hits. They are the definition, its
two door callers (`filter-normalizer.ts:680`, `read-scope-sql.ts:1009`),
their imports and the matrix test's three calls. The other two calls
assert only the accepted-set sentence and `bigint`, and neither moved.
- Control: "cannot be bound as a SQL", a string known to be present,
gives 10 hits. They include this message (`comparand-shape.ts:698`) and
three door pins that still hold on it
(`where-type-face-refusal.test.ts:229-230`,
`cross-field-reference-refusal.test.ts:288`).
  - **Result: the push missed no pin on the old wording.**
- **Ablation of the new negative pin.** It ran from the committed head
through `scripts/ablation-replace.mjs`, which put "(or a binary value)"
back into the runtime string.
- On disk: anchor x1 → x0, injected text x0 → x1, blob `4cc83d7bf44c` →
`77c17ca505c8`.
- Predicted: 1 red in the whole package suite. Measured: `Test Files 1
failed | 120 passed (121)`, `Tests 1 failed | 2794 passed (2795)`.
- The red was `comparand-door-single-source.test.ts` › "binary stays a
package-local extra the door does not admit": `AssertionError: expected
'"$in" on "status" has a value at inde…' not to contain 'binary'`.
- Restore: blob == HEAD (`4cc83d7bf44c`), `git diff HEAD` empty, `git
status --porcelain` empty. The wrapper carried its own `trap` restore.
- Vitest stops a case at its first failed assertion, so the pin's second
assertion (the accepted-set sentence followed directly by "Refusing
rather than binding it") was not measured on its own here.
- **Evidence at `cf1563d6f8`:**
  - the dependency closure: 14 of 14 turbo tasks cached;
- the service-analytics suite: `Test Files 121 passed (121)`, `Tests
2795 passed (2795)`; `tsc --noEmit` exit 0;
- eslint `--no-inline-config --format json` on the two touched TS files:
2 files, 0 errors, 0 warnings;
- `dispatch-gates --commands`: the same 60 families as rounds 1 to 3. 58
exited 0. 2 exited 3 and are NOT MEASURED (`check:dual-build-cjs-loads`,
`check:type-check-debt`), because they need the whole workspace built.
`--ran`: `60 derived famil(ies) accounted for — 58 run, 2 NOT-MEASURED
(2 DERIVED from a recorded exit 3)`;
- `check-issue-citations` in board-probing mode: exit 0, 74 citations
resolve.
- `origin/main` is at `66960564d9`, 5 commits past the merge base
`980bc05e5b`. None of those commits touches `service-analytics` or the
shared comparand-type face. This round changes no code, so `origin/main`
was not merged.

## Acceptance notes

- **Stale `comparand-shape.ts` docblocks: fixed.** Round 2 corrected
objectstack-ai#20046's binary-extra note so that it says NEITHER door serves the
extra. Round 3 (`8c78550a75`, docblocks only) fixes the rest of the
file's pre-objectstack-ai#20035 `where`-door account:
- the `undefined` table's `where` row now names the shared
comparand-TYPE face as the first refusal (same verdict and envelope),
with objectstack-ai#6386's gate kept for the two positions the face steps around;
- the header's envelope clause, the non-string `$field` note, the two
shared refusal sentences (`unrenderableTextComparandMessage`,
`unbindableListMemberMessage`) and the two `$between`-endpoint notes are
each scoped to the door they are true of.
- **`unbindableListMemberMessage`'s "(or a binary value)": fixed in
round 4 (`cf1563d6f8`).** Round 3 left it because that round changed
docblocks only. Neither door accepts a binary any more (objectstack-ai#20018 at the
read scope, this PR at the `where` door), so the runtime text now names
only the accepted set. The pin in `comparand-door-single-source.test.ts`
is re-judged. driver-sql's own copy keeps the words, because that driver
binds a binary.
- **`$ne` with a list** is still the face's to judge (objectstack-ai#19886 stage 2).
The type face steps around arrays, so this change neither moves nor pins
it.
- **Residual door-local sentences.** An `undefined` inside an array
comparand (`{d:{$contains:['a', undefined]}}`) or under an unknown
operator still reads in objectstack-ai#6386's sentence, and an array or `{ $field }`
member / LIKE comparand in objectstack-ai#5234's. Those are positions the face does
not judge, and both spellings agree on them.
- objectstack-ai#20010 remains open for its `$ne` arm, which is 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant