Skip to content

fix(service-analytics)!: an object read through a relationship path joins the one admitted and scoped object set (#20933) - #20962

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20933-analytics-traversal-admission
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20933-analytics-traversal-admission

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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's include). 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: queryObjects adds, per hop, the object each member the query names reaches through a relationship path. The hop's object is read from namedQueryFields, 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. callCtx derives the set once and hands the same value to the admission and to the read-scope pre-pass, and passes it to the strategy as readScopedObjects.
  • 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: declares readScopedObjects on the package-internal DatasetScopedStrategyContext. 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.

Position Strategy Before (class) After
Inferred cube: a dimension through a relationship path native related object read with no admission and no row scope unreadable related object: 403 PERMISSION_DENIED naming it, before any statement runs; related rows outside the caller's scope are not read
ObjectQL refused by the engine's own admission (its generic refusal); the door never asked the door's refusal naming the object (same code and status, differs in form); scope unchanged (rows outside it grouped as restricted)
Inferred cube: a filter member through a path native filtered on related values with no admission and no row scope refused as above; a related value outside the scope matches nothing
ObjectQL 400 INVALID_FIELD (the strategy cannot filter across objects) unreadable related object: the door's 403; otherwise the same 400
Inferred cube: a time-dimension window through a path native windowed on related values with no row scope refused, or scoped, as above
ObjectQL 400 INVALID_FIELD unreadable related object: the door's 403; otherwise the same 400
Authored cube: a dimension or measure over a relationship its joins does not list native read with no admission and no row scope refused, or scoped, as a declared join is
ObjectQL engine refusal / engine scope the door's refusal; scope unchanged
Authored cube: a path the query names itself both as the two rows above as the two rows above
Two-hop path (first hop falls back to its alias, second hop keyed by the cube's join) native the first hop's object read with no admission and no row scope each hop admitted and scoped on its own object
ObjectQL 400 INVALID_FIELD (single hop only) an unreadable hop: the door's 403; otherwise the same 400
SQL echo of any row above both statement echoed with no admission of the related object refused as the read is; on the native strategy the echoed statement carries the related object's scope clause, as a declared join's does
Dataset door: a related object named through a relationship the dataset does not declare native 400 DATASET_INVALID (the native compile's own refusal) unreadable related object: the door's 403; otherwise the same 400
ObjectQL engine refusal the door's refusal
Control: a readable related object both answered answered, within the caller's row scope

E4, 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)

  • E1, the set (analytics-service.ts at HEAD: admission :1449, queryObjects :1575, cubeObjects :1594, resolveReadScopes :1683). At base 1571aedce, queryObjects returned cubeObjects(cube): the base object plus every declared join.
    • An inferred cube's relationship path: the base object only. The admission asked for one object, and the scope pre-pass resolved one object.
    • An authored cube's declared join: base plus the join's object.
    • A dataset's include: base plus each included object, since the compiled dataset carries it as a declared join.
    • After the fix, each case also carries every hop's object. Admission and the scope pre-pass read the same value, and a unit pin asserts that the two sets are equal.
  • E2, the native 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, through applyReadScope. It reads getReadScope for that join's object: the same mechanism a declared join uses.
    • Before the fix, the pre-resolved scope map had no entry for a path object, so getReadScope answered nothing and no predicate was applied.
    • The fix changes the set and leaves the application path alone. The one strategy line that re-derived the object list, the cross-field decline (:340), now reads the door's set.
  • E3, the object a path reads. Hop by hop, through 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.
  • E5, containment. The fix lands within this card on both strategies, so routing restricted callers through the ObjectQL strategy was not needed and is not proposed.
  • E6, Clause-②. Measured in both directions.
    • Widening: none.
      • The public type surface is unchanged: readScopedObjects has 0 hits in the built dist/index.d.ts, against 19 for StrategyContext as a positive control, and the context type is not exported.
      • No probed position moved from refused to answered.
      • The native decline set is a superset of what it was: the same base and declared-join derivation, plus the path objects.
    • Narrowing: yes. Positions move from answered-unadmitted to 403, and from unscoped to scoped.
    • Line 2 reads declared · no · narrowing through scripts/pm/clause2-line.mjs. The changeset is minor, 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:

    • an unreadable related object is refused by name, on the read and on the echo, before anything runs;
    • admission and the scope pre-pass ask for one set, and that set carries the path's object;
    • the related object's scope reaches what each strategy executes.

    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 real SecurityPlugin, ObjectQL and SqlDriver (SQLite), once per strategy, and every answer is compared with the same question through a declared join. It covers:

    • an unreadable related object is refused;
    • related rows outside the caller's scope are not read;
    • a filter on an out-of-scope related value counts nothing;
    • the control;
    • the dataset door.
  • Red before the fix. Pins were committed at 9c12bad1c, before the fix at b48c42e1a, 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 in dist/.

  • Ablation 1, the widened set. The path-object loop in queryObjects was deleted through scripts/ablation-replace.mjs, which confirmed the anchor went from 1 to 0 and the blob changed. The package was rebuilt, and ablation-dist-preflight --absent confirmed the marker was gone from dist/.

    • Result: unit 25 failed / 2 passed, route 20 failed / 8 passed. Predicted in writing beforehand, and exactly the pre-fix red set.
    • Restore: the blob equals HEAD (7e80788d9873), git diff HEAD is empty, and porcelain is empty, re-proved by an outer trap by absolute path. After a rebuild the marker is present in dist/ 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 HEAD is empty, and the unit pin reads 27/27 afterwards. The unit pin imports source, so no build was involved.

Tests and gates (HEAD fdbdc670d)

  • Gate union, one locked sequential run on HEAD fdbdc670dc (git rev-parse --short HEAD, printed by the run at start and end with an empty git diff HEAD). The run covered the 62 commands dispatch-gates --commands derives from this diff: 61 exited 0, and 1 is NOT MEASURED.
    • pnpm check:dual-build-cjs-loads exited 3, PREREQUISITE NOT MET. The gate reads every workspace package's built output, and 34 packages have no dist/ in this worktree. For the one package this diff changes, a direct require() of its CommonJS entry loads, with 17 exports. CI runs the gate itself.
    • dispatch-gates --ran reconciliation: 62 derived, 61 run, 1 NOT MEASURED, 0 unrun (exit 0).
  • The four roster families named at dispatch are not printed by this diff's derivation. They were run anyway, and all exited 0: node scripts/check-changeset-fixed.mjs, pnpm check:authz-resolver, pnpm check:error-code-casing and pnpm check:filter-alias-parity.
  • Package suites at HEAD 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.
  • The client census. envelope-caller-census.test.ts is 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.
  • Typecheck. @objectstack/service-analytics and @objectstack/rest (its test-layer typecheck included) exit 0. Both pin files are in their package's typecheck program, counted with --listFiles.
  • eslint, narrowed. The whole-repo run is CI's. The 5 changed TypeScript files give 0 errors and 0 warnings, and eslint's own JSON output reports all 5 and none as ignored. The config enables no type-aware linting, so this diff cannot move an untouched file's verdict.
  • Build. The dependency closure and @objectstack/service-analytics at HEAD built with exit 0.

Acceptance notes

  • Native and ObjectQL differ in form on out-of-scope related rows (E4 above). Native drops the base row, and ObjectQL groups it as restricted. This is pre-existing on declared joins, and this PR leaves it unchanged.
  • A relationship name that is not itself an object name was never served: a 500 on the native strategy, and an engine refusal or a 400 on the ObjectQL strategy. For a caller the object-level check applies to, it now answers the door's 403 naming 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.
  • The native decline's fallback. The decline re-derives base plus declared joins only for a strategy context built without the door's set. The door always passes the set whenever it resolves read scopes, so no production path reaches the fallback. NOT MEASURED by a pin.
  • The draft-preview branch keeps using cubeObjects alone, because it evaluates the executor's queries over the drafted base rows in memory. NOT MEASURED by a pin here.
  • A cube whose sql is not a bare object name names no attributable field (namedQueryFields), so its set is unchanged by this PR.
  • Drivers. The route pin runs on SQLite only. The set is computed at the door, before any driver.
  • main moved. origin/main is at f6ccca4a4, 11 commits past this branch's base 1571aedce (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-gates flagged three of its derivation inputs as changed on main: 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.
  • CI is not awaited. The CI-only lanes dispatch-gates names (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

…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

⛔ 1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v17/17-5.mdx (via AnalyticsService (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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

Which tree this was computed on

This run read content/docs from a08b0156efb5ae5cdfac2f335059618b0d68a94f — the merge of head fdbdc670dc858a9198add526963a2f21ad7c1a3f into base f6ccca4a446a2f6c6b822c018388fc0c25b7a351, 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 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

⚠️ 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 f6ccca4a446a2f6c6b822c018388fc0c25b7a351 → 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: fdbdc670dc858a9198add526963a2f21ad7c1a3f
Local-runs: none

PR #20962 · card #20933 · rendered 2026-09-30T23:21Z by the contract-review subagent of the domain:services seat session, read-only and adversarial to the dispatch. Inputs: the card body and its five comments (5919572657, 5919712332, 5920636838, 5921214635, 5921260740), the PR body, its file list and the net diff against main at the merge-base 1571aedce, and the check-runs on the head. Disclosure discipline holds in this record as on the card: classes and positions only, no request shape, no field spelling, no returned value.

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 Test Core, Dogfood Regression Gate and TypeScript Type Check each report success. The seven required contexts are green: Lint & Repo Gates, the Type Check lanes, Test Core 1–6, Dogfood Regression Gate 1–3, Build Core, Temporal Conformance, Governed Surface Queue Guard. Build Core hosts check:dual-build-cjs-loads (ci.yml), the one family the dev did not measure locally — its CI verdict is success. Check Changeset (pr-automation.yml: the ADR-0087 disposition gate and the no-major/level gate) is success.

① Derived judgments

  1. The admitted object set widens to the objects a relationship path reads — right. AnalyticsService.queryObjects now adds, per hop, the object each named member reaches (dimensions, time dimensions, measures' columns, where leaves, the compiled dataset's own filters and measure filters, order keys), taken from namedQueryFields, the field gate's own resolution from PR fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) #20931: the cube's join keyed by the path with its dots as a double underscore, else the alias itself. That is byte-for-byte the name the native build loop scopes a synthesized join by (cube.joins[alias]?.name ?? alias, the alias spelled the same way by joinAlias) and the name the ObjectQL strategy reads the related record from (refObject), so the admitted name and the read name cannot diverge. callCtx derives the set once and hands the same value to assertReadAdmitted and to resolveReadScopes; the unit pin asserts the two sets are equal. Accept-set narrowing on every face that passes through callCtx — the cube read, the SQL echo, and every dataset door through DatasetExecutor: an unreadable related object is refused with the door's 403 PERMISSION_DENIED naming it before a strategy is selected.

  2. The read-scope pre-pass resolves over that same set — right, and no rule was added to a strategy. resolveReadScopes (private) now takes the set instead of re-deriving it. The native strategy's per-join applyReadScope and the ObjectQL strategy's scoped FK-attribute read are untouched; they simply find a scope for the path's object where the map used to answer nothing. Fail-closed on a provider throw is inherited for path objects. On the ObjectQL strategy the four scope guards a declared join's scope already meets now meet a path object's scope too — the parity triage asked for, in the refusing direction.

  3. readScopedObjects on DatasetScopedStrategyContext — package-internal, no public surface change — right. src/index.ts re-exports only AnalyticsStrategy, StrategyContext and AnalyticsDriverCapabilities from the strategies types module; the dataset-scoped context type is not exported, so the built declaration surface does not move (consistent with the dev's zero-hit reading of dist/index.d.ts). Optional field, set in the same return as getReadScope.

  4. The native cross-field decline reads the door's set — right; the retained fallback is dead in production. crossFieldComparisonIn walks readScopedObjects when present, so a field reference in a path object's scope routes the query to the engine path exactly as a declared join's does; the decline set is a superset of the old one. When the set is absent it falls back to the pre-existing base-plus-declared-joins derivation — the old code kept for a context built without the set, not a copy of the new rule — and the one producer of getReadScope on a strategy context in the package (callCtx) sets both fields in the same return, so no production context reaches it. The dev flagged it unpinned; answered under ③.

  5. Refusals that change form — right: all refusal-to-refusal, none moves from refused to answered. On the ObjectQL strategy a filter, a time window or a two-hop path through an unreadable related object moves from the strategy's 400 INVALID_FIELD to the door's 403; on the dataset door a related object named through a relationship the dataset does not declare moves from the native compile's 400 DATASET_INVALID to 403 when unreadable (a readable one keeps its 400); a relationship name that is not itself an object name, never served on either strategy, now answers 403 naming that relationship, because the admission asks the security service about a name it does not know and the bridge answers what the provider answers (denied). The PR table and the changeset state each.

  6. What is deliberately unchanged, verified against the head. The draft-preview branch keeps cubeObjects alone: evaluateAnalyticsQueryOverRows (preview-evaluator.ts) is pure in-memory over the base object's seed rows — it imports only spec and filter-normalizer helpers and touches no engine, driver or read-scope seam — so no related object is read there. A cube whose sql is not a bare identifier names no attributable field, so its set is unchanged (the field gate's own posture; the base admission still asks about whatever sql holds). Measure-level filters reach the native strategy only through the compiled dataset's measureFilters, which namedQueryFields walks — no authored-cube measure-filter path escapes the set. Order keys are walked, the superset direction (the native ORDER BY joins nothing).

  7. Strategy parity on out-of-scope related rows — pre-existing form, not this PR's. Native compiles the joined object's scope as a predicate over the left join, so a base row whose related record is out of scope drops out (ADR-0021 D-C); ObjectQL groups it as restricted (analytics: 让 executeAggregate 桥携带 ExecutionContext(#3597 的纵深防御第二层)+ 两处残留无 scope 调用 #3602). Neither reads the related value, and each strategy answers a path exactly as it answers a declared join to the same object — which is the reference the route pin compares against, per strategy and per question.

  8. Pins — both halves triage named, on both strategies. Unit pin (27 cases): refusal by name on the read and on the echo before any statement runs, for seven positions on each strategy; the admitted set equals the scoped set and carries the path object; the path object's scope reaches what each strategy executes; the native decline routes a path exactly as a declared join; hop-by-hop resolution of a two-hop path; the control. Route pin (28 cases): the shipped composition with the real SecurityPlugin, ObjectQL and SqlDriver, once per strategy; every answer compared to the same question through a declared join and also checked absolutely; the dataset door. Red-before-fix, two ablations with proven restores, the package suites, typecheck and the gate union are the dev's readings (report 5921214635); this review re-ran none of them — the head's check-runs are the derived gate verdicts here. The pin files necessarily encode fixture shapes in code, as any regression pin must; the PR body, the changeset, the commit titles and the comments carry classes and positions only, which is where the card binds the discipline.

  9. Shape and governance. Six files, +737 / −26; no governed path; head repo equals base repo; draft, un-armed. Every path is on the claim's file surface (5919712332): three service-analytics/src files and a unit pin, one new rest route pin beside the analytics HTTP pins, and the changeset — no objectql, spec or plugin-security path. No client census row is owed (the dev measured zero censused call sites in either pin). The newest Claim: on the card names the PR's branch, and the branch-claim check on the head is success. origin/main moved eleven commits past the merge-base; CI ran on the merge ref, and the queue rebuilds again.

② Semver level

  • .changeset/20933-analytics-relationship-path-admission.md: @objectstack/service-analytics minor, with the BREAKING banner and exactly one ADR-0087 marker, not-required (no-migration-prescription). The Clause-② declaration on line 2 of the PR body and the changeset's own line both read no (narrowing).
  • The declaration matches the diff. Narrowing: positions move from answered-without-admission to refused and from unscoped to scoped, so this is BREAKING and the arm is the right one. Widening: none — no export, no exported type and no authorable key changes (① item 3), and no probed position moves from refused to answered (① item 5). no (narrowing) is the measured grammar, not a copied one.
  • The level matches. A breaking change grades at least minor under the launch-window convention check-changeset-no-major enforces (a major is refused there), with the banner and the ADR-0087 marker as the breaking carriers — both present. no-migration-prescription is honest: nothing authorable, exported or stored is removed or renamed, so objectstack migrate meta has nothing to rewrite; the changeset's closing remedy (grant read on the related object, or build the widget on readable objects) is operator guidance, not a rewrite prescription. The gate that re-validates the category and the level gate both ran on the head in Check Changeset — success.
  • Publishing scope. The only released package whose source moves is service-analytics; rest receives a test file only, so no second changeset is owed. Not a skip-changeset case.
  • Consumer text. It names the three doors, the two refusals, the form changes and the unaffected cases (system context, no permission sets, no security service — as on the data API) in classes, and says what an author sees when a widget stops answering. Adequate for the upgrading reader; no reproduction recipe.

③ Boundary flags

open_questions in the dev report is empty. Every flag the dev raised, answered:

  1. Native drop-out vs ObjectQL restricted bucket on out-of-scope related rows (out-of-scope finding, noted not filed) — accepted as an acceptance note. Pre-existing on declared joins, no value read on either strategy, and the governing decision (ADR-0021 D-C, per-joined-object RLS) is met by both forms. Not a defect this PR owes a card for; if the seat wants one form as a product decision, that is a separate triage question, not a landing condition.
  2. A relationship name that is not an object name now answers 403 naming the relationship instead of a native 500 or an engine refusal; whether authoring accepts such a member was not measured — accepted. Refusal-to-refusal, stated in the changeset. Whether the authoring lint should reject such a member at publish is a producer-side question (Prime Directive Add comprehensive test suite for Zod schema validation #12) outside this card's surface; noted to the seat, not blocking.
  3. The native decline's fallback arm is unpinned — answered by source: the sole producer of a scoped context sets getReadScope and readScopedObjects together, so the arm is unreachable in production, and it is the pre-existing derivation rather than a copy of the new rule. Acceptable; a later hardening could make the strategy refuse a scoped context that omits the set — not owed here.
  4. The draft-preview branch is unpinned — answered by source (① item 6): pure in-memory over the base object's seed rows; no related object is read, so cubeObjects alone is the right set there.
  5. A cube whose sql is not a bare object name keeps its set — answered: the same posture the field gate takes; the base admission still applies.
  6. Route pin on SQLite only — answered: the set is computed at the door before any driver, and no strategy application path changed; the driver matrix is CI's (Temporal Conformance green on the head).
  7. main not merged; dispatch-gates flagged a stale tree — answered: none of the eleven incoming commits touches the claim's surface per the dev, the derived family list is unchanged, CI ran on the merge ref and the queue rebuilds. Landing order per the ACCEPT (5921260740): of security(analytics): a field the caller may only see masked is answered unmasked as a grouped or filtered member on the native-SQL strategy; the published field reader has no masked-for-this-caller answer #20935 and this PR, whichever lands second merges main first; PR fix(service-analytics)!: the nested-relation filter gets the engine's answer on every analytics face — the related object read as the caller, capped (#20887) #20916 takes its round after.
  8. check:dual-build-cjs-loads NOT MEASURED locally — answered by Build Core success on the head.
  9. Three machine restarts; readings re-used only where they carry an exit code on this head — a process deviation the seat accepted; for this review the check-runs on the head are the gate verdicts, and they are all green.
  10. No empty-branch probe push; the PR body's footer form — process notes outside this review's remit; nothing in them changes a verdict.

Nothing is escalated. Disclosure held on the card, the PR body, the changeset, the commit titles and every comment.

Implemented-by: claude/issue-20933-analytics-traversal-admission
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 23:22
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 5f6b63a Sep 30, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20933-analytics-traversal-admission branch September 30, 2026 23:42
os-justin pushed a commit that referenced this pull request Oct 1, 2026
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>
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