fix(service-analytics)!: an object read through a relationship path joins the one admitted and scoped object set (#20933) - #20962
Conversation
…ip path is admitted and scoped as a declared join is (#20933) Pins, on both strategies and every analytics face, that an object a query reads through a relationship path answers what a declared join to the same object answers: refused without a read grant, its rows outside the caller's row scope not read, answered when readable, and a read scope the native strategy cannot compile routes the query to the engine path. Covers inferred and authored cubes, a two-hop path resolved hop by hop, and the dataset door. These pins are red on this commit; the next commit makes them green. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…oins the one admitted and scoped object set (#20933) The analytics door admits and row-scopes one object set, and both strategies read their scopes from it. That set held the cube's base object and its declared joins, but not an object reached through a relationship path the cube does not declare, although both strategies read that object. It is now in the set: each hop's object, resolved as the field gate resolves it, is admitted and scoped exactly as a declared join is. The set is derived once per call and handed to the admission, the scope resolution and the native strategy's cross-field decline, so a read scope that strategy cannot compile routes the query to the engine path for a related object as it does for a declared join. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…ship-path admission and row scope (#20933) Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…der for both faces (#20933) Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 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 a08b0156efb5ae5cdfac2f335059618b0d68a94f && git checkout a08b0156efb5ae5cdfac2f335059618b0d68a94f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f6ccca4a446a2f6c6b822c018388fc0c25b7a351 fdbdc670dc858a9198add526963a2f21ad7c1a3f && git checkout -B drift-repro f6ccca4a446a2f6c6b822c018388fc0c25b7a351 && git merge --no-ff fdbdc670dc858a9198add526963a2f21ad7c1a3f
node scripts/docs-audit/affected-docs.mjs --json f6ccca4a446a2f6c6b822c018388fc0c25b7a351
|
Contract reviewServed-tier: PR #20962 · card #20933 · rendered 2026-09-30T23:21Z by the contract-review subagent of the Gates on the head (read 2026-09-30T23:17Z, latest run per check name): 34 check names, 31 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke — path-filtered or opt-in), 0 failure, 0 in progress; the roll-up contexts ① Derived judgments
② Semver level
③ Boundary flags
Nothing is escalated. Disclosure held on the card, the PR body, the changeset, the commit titles and every comment. Implemented-by: VERDICT: PASS Generated by Claude Code |
main's #20931 (the field-read admission gate), #20955 (the queryable-field gate), #20954 (plugin-security's comparand guard) and #20962 (relationship path objects in the admitted and scoped set) touched packages/services/service-analytics. The merge is clean at the text level; both sides' additions to analytics-service.ts and native-sql-strategy.ts are kept whole. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20933
Clause-②: no (narrowing)
What this changes
The analytics door asks two questions over one object set before either strategy runs: the object-level read admission, and the read scope each strategy applies to the objects it reads. That set held the cube's base object and the joins the cube declares (
joins, or a dataset'sinclude). An object a query reaches through a relationship path the cube does not declare was not in it, although both strategies read that object. The class: a related object reached through a relationship path was read without its admission or its row scope on the native-SQL strategy, and was not asked for at the door on the ObjectQL strategy.Every such object now joins the one set (
AnalyticsService.queryObjects). It is admitted and row-scoped exactly as the base object and a declared join are, through the same mechanism, on both strategies and on every analytics face (the cube read, the SQL echo and the dataset door). There is no per-strategy copy of the rule and no scope applied only inside the native join: the strategies apply the scope of every object they read from the set they are handed.Three source files change, all in
@objectstack/service-analytics:analytics-service.ts:queryObjectsadds, per hop, the object each member the query names reaches through a relationship path. The hop's object is read fromnamedQueryFields, the field gate's own resolution (PR fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) #20931), reused rather than re-derived.callCtxderives the set once and hands the same value to the admission and to the read-scope pre-pass, and passes it to the strategy asreadScopedObjects.strategies/native-sql-strategy.ts: the cross-field decline (a read scope carrying a field reference routes the query to the engine path) reads the scopes over the door's set, rather than re-deriving base plus declared joins.strategies/types.ts: declaresreadScopedObjectson the package-internalDatasetScopedStrategyContext. It is not exported: the built declaration file carries no hit for it.Per position (disclosure-safe: classes, no request shapes)
Reference: the ObjectQL strategy's answer, and the same question asked through a declared join to the same object.
403 PERMISSION_DENIEDnaming it, before any statement runs; related rows outside the caller's scope are not read400 INVALID_FIELD(the strategy cannot filter across objects)403; otherwise the same400400 INVALID_FIELD403; otherwise the same400joinsdoes not list400 INVALID_FIELD(single hop only)403; otherwise the same400400 DATASET_INVALID(the native compile's own refusal)403; otherwise the same400E4, native "after" against the reference. The native answer now equals the native answer for the same question through a declared join, exactly: the same refusal envelope, and the same scope clause on the joined object. Against the ObjectQL reference it differs in form on out-of-scope related rows only. Native applies the joined object's scope as a predicate over the join (ADR-0021 D-C), so a base row whose related record is outside the caller's scope drops out of the answer. ObjectQL groups such a row as restricted. Neither reads the related value. This is the pre-existing declared-join form on each strategy, and this PR does not change it.
Mechanism readings (E1 to E3)
analytics-service.tsat HEAD: admission:1449,queryObjects:1575,cubeObjects:1594,resolveReadScopes:1683). At base1571aedce,queryObjectsreturnedcubeObjects(cube): the base object plus every declared join.include: base plus each included object, since the compiled dataset carries it as a declared join.qualifyAndRegisterJoin(native-sql-strategy.ts:740) registers one join per path prefix, aliased by the path with dots as__. The build loop (:617–:624) already applied the read scope for the base object and for every registered join, whether synthesized or declared, throughapplyReadScope. It readsgetReadScopefor that join's object: the same mechanism a declared join uses.getReadScopeanswered nothing and no predicate was applied.:340), now reads the door's set.namedQueryFields(PR fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) #20931's resolution): the cube's join keyed by the path with dots as__, falling back to the alias itself. Pinned on a two-hop path whose first hop falls back and whose second is keyed: the admitted set is exactly base, first hop and second hop. The route pin serves the two-hop case on the native strategy. The ObjectQL strategy serves a single hop only.Clause-②. Measured in both directions.readScopedObjectshas 0 hits in the builtdist/index.d.ts, against 19 forStrategyContextas a positive control, and the context type is not exported.403, and from unscoped to scoped.declared · no · narrowingthroughscripts/pm/clause2-line.mjs. The changeset isminor, carries the BREAKING banner, and carries its ADR-0087 marker (not-required (no-migration-prescription)).Pins, red and green, and ablation
Unit pin
packages/services/service-analytics/src/__tests__/relationship-path-admission.test.ts(27 cases), on both strategies. It covers the seven positions above, and for each one checks three things:It also carries the two-hop resolution, the native decline, and the control.
Route pin
packages/rest/src/analytics-relationship-path-admission.test.ts(28 cases). It runs over the shipped composition with the realSecurityPlugin,ObjectQLandSqlDriver(SQLite), once per strategy, and every answer is compared with the same question through a declared join. It covers:Red before the fix. Pins were committed at
9c12bad1c, before the fix atb48c42e1a, and pushed only together with it. On the pins tree: unit 25 failed, 2 passed (the controls); route 20 failed, 8 passed (the fixture checks, the controls, and the ObjectQL positions that were already right). Ablation 1 below reproduces exactly that red set from the committed HEAD.Green at HEAD
fdbdc670d. Unit 27/27, route 28/28, read after a rebuild with the marker present indist/.Ablation 1, the widened set. The path-object loop in
queryObjectswas deleted throughscripts/ablation-replace.mjs, which confirmed the anchor went from 1 to 0 and the blob changed. The package was rebuilt, andablation-dist-preflight --absentconfirmed the marker was gone fromdist/.7e80788d9873),git diff HEADis empty, and porcelain is empty, re-proved by an outer trap by absolute path. After a rebuild the marker is present indist/again, and the pins read unit 27/27, route 28/28.Ablation 2, the decline reads the door's set. The strategy was made to ignore
readScopedObjects. Result: unit 1 failed / 26 passed, exactly the decline pin, as predicted. Restore: the blob equals HEAD (bc6e15abfc96),git diff HEADis empty, and the unit pin reads 27/27 afterwards. The unit pin imports source, so no build was involved.Tests and gates (HEAD
fdbdc670d)fdbdc670dc(git rev-parse --short HEAD, printed by the run at start and end with an emptygit diff HEAD). The run covered the 62 commandsdispatch-gates --commandsderives from this diff: 61 exited 0, and 1 is NOT MEASURED.pnpm check:dual-build-cjs-loadsexited 3, PREREQUISITE NOT MET. The gate reads every workspace package's built output, and 34 packages have nodist/in this worktree. For the one package this diff changes, a directrequire()of its CommonJS entry loads, with 17 exports. CI runs the gate itself.dispatch-gates --ranreconciliation: 62 derived, 61 run, 1 NOT MEASURED, 0 unrun (exit 0).node scripts/check-changeset-fixed.mjs,pnpm check:authz-resolver,pnpm check:error-code-casingandpnpm check:filter-alias-parity.fdbdc670d.@objectstack/service-analytics: 147 files, 3401 tests passed.@objectstack/rest(--project local): 244 files, 4893 passed, 114 skipped.@objectstack/client: 50 files, 641 passed.envelope-caller-census.test.tsis 20/20. Neither pin adds a censused call site: the census's own pattern has 0 hits in each pin, against 6 in a positive-control file. No ledger row is needed.@objectstack/service-analyticsand@objectstack/rest(its test-layer typecheck included) exit 0. Both pin files are in their package's typecheck program, counted with--listFiles.@objectstack/service-analyticsat HEAD built with exit 0.Acceptance notes
400on the ObjectQL strategy. For a caller the object-level check applies to, it now answers the door's403naming that relationship. That is the resolution PR fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) #20931's field gate already uses. The changeset states it.cubeObjectsalone, because it evaluates the executor's queries over the drafted base rows in memory. NOT MEASURED by a pin here.sqlis not a bare object name names no attributable field (namedQueryFields), so its set is unchanged by this PR.mainmoved.origin/mainis atf6ccca4a4, 11 commits past this branch's base1571aedce(read just before this PR opened). None of them touches this claim's surface, so the branch is not merged, per the dispatch order. This PR's CI on the merge ref reads the merged tree.dispatch-gatesflagged three of its derivation inputs as changed onmain: two ADR-anchor files for other packages, and the doc-authoring prose-id baseline. This branch adds tracker ids only in code comments, and the derived family list is unchanged.dispatch-gatesnames (the test shards, dogfood, temporal conformance, build core, the workspace type-check lanes and the wide-population families) are CI's.Generated by Claude Code