Repository navigation
fix(service-analytics): judge each read scope with the engine's own admission before composing it - #20232
Conversation
…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>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL
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>
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #19995
Clause-②: no
The analytics ObjectQL face now asks the engine's own
whereadmission,IObjectQLEngine.judgeFilter(#20157, ruling C), about each row-level read scope on its own, before composing it into thewhereit handsexecuteAggregate. A scope the engine refuses is refused in the withheldREAD_SCOPE_COMPILE_FAILED/ 500 (#5367). A scope the engine serves is still served. The caller's ownwherekeeps 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-sqldoors from PR #20037), and no response body carries policy content.What changed
All in
packages/services/service-analytics/src/.read-scope-sql.ts: newassertReadScopeAdmittedByEngine(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.ok: falseverdict 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 owncode/statusdescribe a caller's mistake, so they do not travel either.undefinedanswer, means the scope is not judged here ("cannot answer, do not block").strategies/objectql-strategy.ts: called at both engine-bound merges,withReadScope(direct path and cross-object base aggregate) andresolveFkAttr(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 theundefinedanswer. This is thedeclaredFieldType/sqlDialectpattern.analytics-service.ts:AnalyticsServiceConfig.judgeFilter?, passed to the strategy context untouched. A service configured with no judge logs onewarnon its first unjudged scoped merge, naming the consequence and the remedy.plugin.ts: the judge is wired to the engine theexecuteAggregateauto-bridge executes on, resolved per call through the sametryGetDataEngine. It is wired ONLY when the plugin bridgesexecuteAggregateitself. The judge must be the executor, or it would refuse scopes the executor serves, and a host that supplies its ownexecuteAggregatehas not said which engine that is.dataengine withoutjudgeFilter:undefined, plus onewarnfrom the plugin.undefined, silently, because the executor refuses that query itself.plugin.ts, record-label fetch (the [finding] analyticsfetchRecordLabelsconsumes the referenced object's read scope with neithercompileScopedFilterToSqlnorassertReadScopeCannotVacatein front — a fourth read-scope door outside the three faces #14322 unified #14329 door): the same checks asresolveFkAttr. This is a fourth engine-bound merge. It$ands the referenced object's scope intoexecuteAggregateto 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 ownwhereis 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.
judgeFilterruns 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
objectql-engine.ts:306,engine.ts:8783once70876e4c). Read, and measured: for one refused filter,judgeFilter(..., { operation: 'aggregate' })returned the samecode,statusand message string thataggregateraised.dataservice hands out. In a bootedLiteKernelwithObjectQLPluginandAnalyticsServicePlugin,getService('data')is the same object asgetService('objectql'). It is anObjectQLinstance, andtypeof judgeFilteris'function'. It is not a wrapper.ce70876e4c(table below).'aggregate'reproduces the execution message exactly (premise 1). The verb changes only the message prefix, never the verdict.Measurement: both analytics HTTP doors, before and after
How. A scratch probe, never committed, lived in
packages/runtime/srconly while it ran.LiteKernelbooted withObjectQLPluginandAnalyticsServicePlugin. The plugin auto-bridgedexecuteAggregate, and after the fix it wired the judge, to a realObjectQLoverSqliteWasmDriver. The plugin options suppliedgetReadScopeandadmitObjectRead, and fixedqueryCapabilitiesto the ObjectQL face. Nothing else was stubbed.@objectstack/runtime's dispatcher,POST /api/v1/analytics/query, and@objectstack/rest,POST /analytics/dataset/query.ce70876e4c; after = this branch with theservice-analyticsdist rebuilt (the new sentence is present indist/index.jsanddist/index.cjs).INVALID_FILTER/ 400READ_SCOPE_COMPILE_FAILED/ 500INVALID_FILTER/ 400INVALID_FIELD/ 400INVALID_FIELD/ 400$eq, scalar under$in, scalar under$nin, one-bound$between, plain-object member in$in,undefinedcomparand, plain-object comparand under$eq,nullmember in$inREAD_SCOPE_COMPILE_FAILED/ 500$icontainscomparand (#20068)driver-sqldoors (PR #20037): missing column; retired or unknown operator; combinator with a non-array operand; non-boolean$null/$existsINVALID_FILTER/ 400, withheldFILTER_TOKEN_UNKNOWN400The
/analytics/querydoor has no label pass. The display label pass catches a failed fetch and renders raw ids: 200 before and after, with the detail in thewarnlog.Controls, identical before and after:
(restricted).wherein 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 buildsAnalyticsServicePlugin's own composition over a realObjectQL+SqliteWasmDriveras itsdataservice. OnlyqueryCapabilitiesis fixed to the ObjectQL face.codeREAD_SCOPE_COMPILE_FAILEDandstatus500. They also assert the two reads every analytics HTTP door takes before relaying prose:serverFaultProvenance(resolveThrownHttpError(err, 500))is'declared', anddeclaredRefusalMessage(err)is undefined. The thrown message, which is the log channel, still names the detail.wherebeside a refused scope.(restricted)bucket.where(text operator, temporal comparand, virtual field) keepsINVALID_FILTER/INVALID_FIELD/ 400 with its message and no server-fault declaration.AnalyticsServicewith 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.executeAggregateis 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.dataengine withoutjudgeFilterkeeps 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 --listFilesincludes the new test (count 1).service-analyticsdist:@objectstack/rest: 10analytics-*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.mjsin 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, andgit diff HEADis empty. An outer shell trap restored all four source files fromHEADon any exit. The subject is imported relatively fromsrc, so no dist is involved. Every direction was predicted before the run; E1 reddened two more pins than predicted (below).withReadScopejudge callwhere, the cross-object base scope, the throwing judge). Also red: both once-log pins, which need that call to ask at all.resolveFkAttrjudge calldataengine even when the host supplied its ownexecuteAggregateexecuteAggregatein a blanket catch re-raised as the withheld 500wherepins and the three unwired-tier pins that expect the engine's 400wherewas misattributed as the scope's 500The 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
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsonf6dbebe5derived 61 commands, the same count as the dispatch-time list. All 61 exited 0, each exit code captured right after a single redirect. The--ranreconciliation 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-loadsfirst exited 3 (PREREQUISITE NOT MET). Afterturbo run build --filter=./packages/* --filter=./packages/*/*(71/71 tasks) it exited 0.check:dts-closure,check:sourcemap-no-sources-content,check:published-filesandcheck:lean-entry-closurewere re-run on that build and exited 0.check-plugin-teardown-shape.mjs --self-testfirst exited 3: the shallow checkout could not reach its pinned positive-control commit. After fetching that one commit it exited 0.warninplugin.tsand in the service:check:startup-registry-verdict0,check:durability-log-level0.node scripts/check-issue-citations.mjs: 28 citations across 5 files, all resolve.eslint --no-inline-config --format jsonreported 6 files, 0 errors, 0 warnings, none ignored.eslint --print-configfor each file showsparserOptionslimited toecmaVersion/sourceType, with noprojectand noprojectService. Type-aware linting is off, so this diff cannot move a verdict on an untouched file.pnpm lintitself is CI's.Acceptance notes
AnalyticsServiceConfig(exported from the package index) gains one optional member,judgeFilter. Its type,ReadScopeFilterJudge, is exported fromstrategies/types.tsonly, not from the index; it reaches the published declarations through that member.DatasetScopedStrategyContext(not exported) gainsjudgeFilter.AnalyticsServicePluginOptionsis unchanged. Changeset:@objectstack/service-analyticspatch.executeAggregate. That is how every shipped composition boots (os serve's capability provider, the verify harness): no host in this repository passes its ownexecuteAggregate. A plugin host that does keeps today's behaviour, and logs onewarn, 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 constructingAnalyticsServicedirectly can passjudgeFilter, from the engine itsexecuteAggregateruns on.whereand 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.resolveFkAttrform, a file on the claim's surface, and no new gate family. On a host whose ownexecuteAggregateruns 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 forresolveFkAttr; the ObjectQL executor refused every one of them already. The display label pass's catch is unchanged.origin/mainmoved by one commit after the base,805af4f2(packages/clionly). It shares no path or behaviour with this diff and is not merged.securityservice, the analytics object-read admission bridge answersPERMISSION_DENIED/ 403 on every query. The kernel'sgetServicethrows for a missing service, and the bridge reads a throw as "unusable" (fail-closed). Its init warning describes the opposite. Every shipped composition includesSecurityPlugin, and the failure direction is fail-closed. Measured incidentally by the probe.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)f6dbebe5(record5856105439) found two overclaims in the changeset prose. The dev corrected them, plus two adjacent imprecisions, ind9a1002f(.changesetonly, +3/−3).Generated by Claude Code