Skip to content

fix(service-analytics): judge each read scope with the engine's own admission before composing it - #20232

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-19995-judge-filter-read-scope
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-19995-judge-filter-read-scope

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #19995

Clause-②: no

The analytics ObjectQL face now asks the engine's own where admission, IObjectQLEngine.judgeFilter (#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 (#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 #20017 / #20046 / #20072, and the four driver-sql doors from PR #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 [finding] analytics fetchRecordLabels consumes the referenced object's read scope with neither compileScopedFilterToSql nor assertReadScopeCannotVacate in front — a fourth read-scope door outside the three faces #14322 unified #14329 door): the same checks as resolveFkAttr. This is a fourth engine-bound merge. It $ands 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 #20017 / #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 #20072): unknown placeholder; known placeholder the context cannot resolve 500 no unchanged no yes
Refused $icontains comparand (#20068) 500 no unchanged no yes
The four driver-sql doors (PR #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 fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope #20017 and PR 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 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 fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope #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

…dmission before composing it

The ObjectQL execute face composed a row-level read scope into the where it
hands engine.aggregate without the engine judging the scope first. A scope
refused by an engine door that reads the object's field map (a text operator
over a non-text field, an uninterpretable temporal comparand, a virtual field,
a dotted path through a lookup) came back as the engine's 400, whose message
both analytics HTTP doors relay: the policy's field and comparand.

Ruling C: ask IObjectQLEngine.judgeFilter about the scope alone, at every
engine-bound merge (withReadScope, resolveFkAttr, and the plugin's record-label
fetch), and refuse in the withheld READ_SCOPE_COMPILE_FAILED / 500. The plugin
wires the judge only to the engine its own executeAggregate auto-bridge runs
on. A host with no judge keeps today's guards and behaviour, and is told once.

Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ
Co-authored-by: Claude <noreply@anthropic.com>
…engine-bound read-scope merge

Refusal pins for the four field-map classes on the direct path, both
cross-object merges and the plugin's record-label fetch; preservation pins for
served scopes and for the caller's own refused where; and the unwired tiers
(no judge, a custom executeAggregate, a data engine without the member), each
keeping today's behaviour and logging once.

Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ
Co-authored-by: Claude <noreply@anthropic.com>
…rd on a host with no judge

Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ
Co-authored-by: Claude <noreply@anthropic.com>
…ad-scope judgement

Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ
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 27, 2026
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 15 documentable anchor(s).

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

  • content/docs/kernel/contracts/metadata-service.mdx (via getObject (literal, a string literal in DataEngineLike))
  • content/docs/plugins/packages.mdx (via AnalyticsServicePlugin (symbol, a top-level class))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 fc91239415261a27a053f4a0e9079c23bc1d9c92 → packageMentionDocs.

Which tree this was computed on

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

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f6dbebe5412b369ed9e10bbc04126f7b9b5221f3

① Derived judgments

  • Diff read at git diff ce70876e4c refs/pm/pr-20232 (7 files, +735/−15; merge base confirmed ce70876e4c). Engine-bound merges at head: ObjectQLStrategy.withReadScope (called at objectql-strategy.ts:297 direct path and :1097 cross-object base), resolveFkAttr (:1184), and the plugin's fetchRecordLabels (plugin.ts:915). No other executeAggregate( call composes a scope (git grep over the package at head: exactly those hits; objectql-strategy.ts:557 is the SQL echo through compileScopedFilterToSql, not engine-bound).
  • Accept-set changes, wired composition (plugin auto-bridge over ObjectQL): a scope refused by the engine's stage-1/stage-2 admission at any of the four merges now answers READ_SCOPE_COMPILE_FAILED / 500 with the message withheld (was the engine's INVALID_FILTER / INVALID_FIELD / FILTER_TOKEN_* 400 relayed with policy content). A judge throw also answers 500. Right under analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 (read-scope-sql.ts header: "Deliberately NOT a 4xx of any flavour") and under ruling C item 2 (5852158605).
  • Accept-set change on EVERY host, wired or not: the record-label fetch gains assertReadScopeComparandsRunnable and assertReadScopePlaceholdersResolvable (plugin.ts diff), so comparand-shape and placeholder classes that previously reached the executor from that door now answer 500 there. Right under analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367; beyond the ruling's two named sites but the same defect class on a file inside the claim's surface (Claim 5855428952 lists src/plugin.ts).
  • Served scopes stay served under ObjectQL: judgeFilter (engine.ts:8783) runs lowerWhereFilterArray and resolveWhereFilterTokens, the same two stages aggregate runs (engine.ts:16202 stage 1 before getDriver; resolveWhereTokens(opCtx.ast, opCtx.context) before middleware; opCtx.context = mergeReadContext(query.context, undefined) equals the bridged context). Every door descends $and/$or/$not per arm and judges a node against the field map or context only (filter-comparand-shape.ts collectFilterFieldNames; text-operator-declared-type-door.ts:203; temporal-comparand-door.ts:171; spec assertListComparandShapes :809; filter-comparand-type.ts:354). No engine door reads the 'policy' mark: git grep SubtreeProvenance -- packages/objectql/src 0 hits, positive control assertListComparandShapes 3 files. Only asymmetry found: the depth-32 backstops, where the composed tree sits one level deeper than the scope alone, so a 32-deep scope would be refused alone and ungated composed. Fail-closed direction, unreachable from any policy compiler; the PR's "exactly when" holds for every real scope.
  • Caller-authored where: judged nowhere new. withReadScope judges scope before composing; userFilter stays 'author'-marked and goes to the engine unjudged; there is no catch around executeAggregate. resolveFkAttr and the label fetch compose only the package's own id $in filter. Misattribution and 500-swallowing: none found. Precedence (scope refusal answers before an engine-only caller refusal) is the same as PR fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope #20017 and is declared in the PR body.
  • Public surface: package.json exports is "." only; src/index.ts is unchanged by the diff and exports AnalyticsServiceConfig (gains optional judgeFilter?: ReadScopeFilterJudge). ReadScopeFilterJudge and DatasetScopedStrategyContext are not exported from the entry; the former reaches the published .d.ts only structurally through that member. assertReadScopeAdmittedByEngine is file-exported only. AnalyticsServicePluginOptions unchanged. Premise "getService('data') is getService('objectql')" holds: packages/objectql/src/plugin.ts:401,403 register this.ql under both names.

② Semver level

③ Boundary flags

  • DEFECT, changeset prose: "these scopes now answer READ_SCOPE_COMPILE_FAILED / 500 with the message withheld, like every other read-scope refusal on every analytics face". False for the four driver-sql doors, which refuse a policy-marked scope on this same face as a withheld INVALID_FILTER / 400 ([A of #7929] a spec-declared provenance mark set at both read-scope merge boundaries, so the driver can restore the author-facing cross-field diagnostic without re-disclosing policy #8220 / PR fix(driver-sql, driver-turso): four filter-refusal doors read the provenance mark before naming a read scope field or comparand (#20020) #20037); the PR body's own table row says "INVALID_FILTER / 400, withheld, unchanged". This sentence ships in CHANGELOG.md.
  • DEFECT, changeset prose: "A host with no judge (a custom executeAggregate, or a data engine without judgeFilter) keeps today's behaviour. The scope shapes this package judges itself are still refused with the policy withheld". Overstated: the record-label fetch's comparand and placeholder guards are new on every host, so such a host does not keep today's behaviour at that door and those shapes were not refused there before. The PR body's Scope note concedes it ("the label fetch now refuses off-contract scope shapes that such an executor tolerated") and the test "a plugin host with its own executeAggregate is not wired to a guessed engine" pins the new 500s (labelGuarded, labelPlaceholder) on an unwired host.
  • Flag, fourth merge repaired in place: sound on the merits (measured leak on the dataset door's sort-key pass; same defect class; plugin.ts is on the claim surface; pinned; ablations E3/E4/E4b named). The "four-condition exemption" cites no standing rule (grep of .claude/** for "same defect class", "no new gate family", "mechanical repeat", "repaired in place": 0 hits; control "out of scope" hits os-dev.md:283); os-dev.md §3 says scope is the issue alone. The owning seat should record the claim-surface revision to include fetchRecordLabels, as it did on PR fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope #20017.
  • Flag, wiring only under the auto-bridge: sound. plugin.ts resolves the judge per call through the same tryGetDataEngine as the executor, so judge equals executor by construction; a host-supplied executeAggregate names no engine.
  • Flag, log-once at first use: sound. The read is at query time, the once-flag records only the warn, and the engine is re-resolved per call, so the startup-registry-verdict rule is not hit.
  • Flag, model-free commit trailers: verified on all four commits (Claude-Session plus Co-authored-by: Claude).
  • Flag, out-of-scope 403 finding: sound in substance. packages/core/src/kernel.ts PluginContext.getService throws "[Kernel] Service 'x' not found"; plugin.ts:709 reads the throw as unusable and admitObjectRead denies with a logger.error asserting "A security service is wired on this deployment", while the init warn at plugin.ts:1245 says queries "will NOT enforce the OBJECT-LEVEL read grant". The disposition "noted, not filed (zero pull, carrier none)" matches os-dev.md §3 and the filing gate (no measured reach: through a shipped composition).
  • Warn-level lines (analytics-service.ts reportUnjudgedReadScope, plugin.ts engine-without-judge): functional degradation, nothing claimed persisted fails to land, so warn is the level AGENTS.md prescribes; both name consequence and remedy; no tracker number in any runtime string.
  • Tests: 19 cases counted in objectql-read-scope-engine-admission.test.ts; every refusal asserts the ADR-0112 pair (assertWithheldServerFault: code READ_SCOPE_COMPILE_FAILED, status 500, serverFaultProvenance declared, declaredRefusalMessage undefined; assertCallersOwn: code plus 400; unwired tiers assert code/status explicitly). No bare-throw assertion.
  • Other PR-body claims checked true: entry unchanged; /analytics/query has no label pass (query() analytics-service.ts:1346–1410; label passes at :1522–:1747 inside queryDataset); untouched files match the diff stat; origin/main moved by 805af4f2 (packages/cli). Claims not verifiable from committed artifacts: local test counts, ablation table, and the HTTP before/after table (uncommitted probe; the implementer declares "no committed HTTP-level pin").
  • Check-runs on f6dbebe5 at read time: 13 success, 3 skipped, 15 in progress, 0 failure. Required contexts still in progress at that read. Not green yet at that read.

Implemented-by: claude/issue-19995-judge-filter-read-scope
Reviewed-by: session_01TEah6PeJGjxJfbHaySJjLQ

VERDICT: FAIL

  1. Changeset: correct "like every other read-scope refusal on every analytics face" so it excludes the driver-sql residue, which stays a withheld INVALID_FILTER / 400 under [A of #7929] a spec-declared provenance mark set at both read-scope merge boundaries, so the driver can restore the author-facing cross-field diagnostic without re-disclosing policy #8220 (for example: "like the other refusals this package's read-scope compiler and guards raise").
  2. Changeset "Wiring" bullet: say that the record-label lookup's comparand and placeholder guards are new on every host, so a host with no judge keeps today's behaviour everywhere except that door; align the sentence with the PR body's Scope note and the unwired-host test that pins the new 500s.

Generated by Claude Code

…note to what the diff does

The driver-sql refusals that read the policy mark stay a withheld 400, so the
500 sentence names this package's own refusals only. The plugin's record-label
lookup gains its comparand and placeholder checks on every host that uses it,
so a host with no judge keeps today's behaviour everywhere except there.

Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d9a1002f31fc710d6cb09f92a904badd8afe086a

① Derived judgments

  • Code did not move. git diff --stat f6dbebe5 refs/pm/pr-20232 shows .changeset/19995-judge-filter-read-scope.md alone, +3/−3; refs/pm/pr-20232 resolves to d9a1002f; merge base re-confirmed ce70876e4c; the branch is five commits (three code/test, two changeset). The full diff from the merge base is 7 files, +735/−15, the same seven as the previous record.
  • Re-checked against the head, not carried: (a) the engine-bound merges are exactly four (git grep executeAggregate over package src, non-test: objectql-strategy.ts:291 direct via withReadScope at :297, :1094 cross-object base via withReadScope at :1097, :1184 resolveFkAttr, plugin.ts:915 record-label fetch), and assertReadScopeAdmittedByEngine is called at all four (objectql-strategy.ts:687, :1180; plugin.ts label fetch), after vacancy, comparand and placeholder guards and before the 'policy' mark at the strategy merges; (b) same admission: judgeWhereAdmission (engine.ts:1168) runs lowerWhereFilterArray then resolveWhereFilterTokens and returns before getDriver; aggregate runs lowerWhereFilterArray(object, 'aggregate', …) at :16202 and resolveWhereTokens(opCtx.ast, opCtx.context) at :16417, with opCtx.context = mergeReadContext(query.context, options?.context) and mergeReadContext(x, undefined) returning x (:2539), so the judged context equals the executed one; nothing between the two stages reads where; (c) no blanket catch: the only try blocks in objectql-strategy.ts wrap generateSql (:340) and an unrelated site (:1936), never executeAggregate; the userFilter is 'author'-marked and unjudged; (d) hasDeclaredErrorEnvelope (analytics-service.ts:150) re-throws any code plus numeric status, so both the engine's 400 and the withheld 500 propagate from the dataset executor; the sort-key pass (dataset-executor.ts:1187 via createOrderLabelResolver.resolveLabels, dimension-labels.ts:234) has no catch around fetchRecordLabels, while the display pass (analytics-service.ts:1741–1750) catches and warns; that catch exists at the merge base (:1686); (e) public surface: package.json exports is "." only; src/index.ts unchanged (diff stat 0) and exports AnalyticsServiceConfig (gains optional judgeFilter?: ReadScopeFilterJudge) and AnalyticsServicePluginOptions (unchanged); ReadScopeFilterJudge and assertReadScopeAdmittedByEngine are not entry exports; (f) getService('data') is the ObjectQL instance: packages/objectql/src/plugin.ts:401,403 register this.ql under both names; (g) no in-repo host passes its own executeAggregate (git grep 'executeAggregate:' outside the package, non-test: 0; positive control: packages/spec/src/contracts/analytics-service.ts 2 hits); (h) driver-sql reads the mark (sql-driver.ts:53 imports resolveFilterSubtreeProvenance; withheldFilterError at :1287 answers INVALID_FILTER / 400 withheld) and is untouched by the diff; the spec mark (filter-subtree-provenance.ts) withholds unmarked and null provenance, so the label fetch's unmarked scope is withheld there too; (i) the four engine doors name the field, and only some name more: the text-operator door names field plus operator plus declared type (text-operator-declared-type-door.ts:260), the temporal door names field plus a comparand preview (temporal-comparand-door.ts:281), the virtual-field and dotted-path refusals are INVALID_FIELD naming the field or path (engine.ts:1538–1552, :1697), none names a comparand except the temporal class.
  • Carried from the previous record without re-derivation: the depth-32 backstop asymmetry (fail-closed direction) and the per-node judging of every door (confirmed only that both door walkers take schema and where and descend $and / $or / $not).
  • Accept-set: under the wired composition a scope refused by the engine's admission at any of the four merges now answers READ_SCOPE_COMPILE_FAILED / 500 withheld; on every host the label fetch additionally refuses comparand-shape and placeholder classes it used to pass to the executor. Right under analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 (read-scope-sql.ts:134–:193, "Deliberately NOT a 4xx of any flavour") and ruling C item 2 (5852158605).

② Semver level

  • Frontmatter re-read at the new head: '@objectstack/service-analytics': patch; body line Clause-②: no, no arm; both byte-identical to the old head (the +3/−3 touch three prose sentences only). PR body carries the same Clause-②: no line. Check Changeset on d9a1002f: success.
  • The diff is a bug fix in a released package (17.4.0), removes and renames nothing, so patch is the AGENTS.md step-3 level and no migration text is owed.
  • Applying the lane's standing reading (ruling 216): exactly one new optional member on a published export (AnalyticsServiceConfig.judgeFilter, its type reachable only structurally) reads Clause-②: no; patch is therefore not contradicted. The label-fetch guards refuse off-contract shapes only (Directive Add comprehensive test suite for Zod schema validation #12), no in-repo custom executor exists, so no (narrowing) arm is owed. The reading itself stays the maintainer's (feat(automation): accept a per-kernel scheduledWorkPolicy in the engine and both schedule triggers #19858).

③ Boundary flags

  • Every changeset sentence read against the diff and code; all judged true: the title line; "composes each object's read scope into the where it hands engine.aggregate"; the four-class list (text and temporal doors are INVALID_FILTER; virtual and dotted are INVALID_FIELD); "that message named the policy's field, and for some classes its operator or comparand" (now exact: operator from the text door, comparand from the temporal door only; door relay rest/error-response.ts bounds a 4xx message by truncation, never replacement; runtime/dispatcher-plugin.ts:742 reads declaredRefusalMessage, which returns undefined below 500); "like the other refusals this package's read-scope compiler and guards raise" (readScopeCompileError, read-scope-sql.ts:614; previous defect 1 fixed); "The driver-sql refusals that read the 'policy' provenance mark are unchanged" (see ① h); the "How." paragraph (see ① a, b, c); the "Also fixed." rewrite (the lookup is the plugin's labelResolver, plugin.ts:854, passed at :1120; before the diff it ran only assertReadScopeCannotVacate; the sort-key pass propagates, the display pass catches at analytics-service.ts:1750 and that catch pre-exists the diff); "Wiring" bullets 1–2; "Wiring" bullet 3 rewrite (the label fetch gains comparand and placeholder checks regardless of judge; one warn per instance from AnalyticsService.reportUnjudgedReadScope or the plugin closure, pinned by the unwired-tier tests; previous defect 2 fixed).
  • PR body, two corrected sentences: both present verbatim as the dev's corrections, the old wordings absent, and both true (the test at objectql-read-scope-engine-admission.test.ts:363 pins labelGuarded and labelPlaceholder on a host with its own executeAggregate; the guards are new in the plugin.ts diff).
  • Seat's appended note: true as far as verifiable; "No other byte of this body changed" is consistent with the body still reading "final head f6dbebe5" in the Results and Gates sections.
  • Flag, not a defect: the PR body's results and gates paragraphs name f6dbebe5, one prose-only commit behind; the code is byte-identical so the measurements carry. Not CHANGELOG text.
  • Flag, carried and sound: the fourth merge repaired in place, with the claim surface revised in 5856109929.
  • Model-free prose: the changeset and the new commit's trailer pair carry no model identifier.
  • Tests at head: 19 cases; refusal pins assert the ADR-0112 pair and the declared withholding.
  • Check-runs on d9a1002f, latest run per name: 34 names, 29 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke opt-in), 0 failure, 0 in progress.

Implemented-by: claude/issue-19995-judge-filter-read-scope
Reviewed-by: session_01TEah6PeJGjxJfbHaySJjLQ

VERDICT: PASS


Generated by Claude Code

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

2 participants