Skip to content

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

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-20887-analytics-nested-relation
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-20887-analytics-nested-relation

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #20887
Clause-②: yes (narrowing)

The analytics half of ruling 5907789183, whose parent card is #20802 (its engine half landed as #20872, ca5408c62). The nested-relation filter { relation: { field: value } } now gets ONE answer on every analytics face, and it is the engine's: the related object read as the caller (its row scope and field permissions), capped at RELATION_FILTER_ID_CAP, a multi-valued relation matching on any member. The analytics layer holds no copy of that rule. The native-SQL strategy declines a query carrying the form, and the engine-aggregate strategy hands the form to the engine as written.

Per face

The engine's answer for the same filter, computed in the same test over the same rows, is the reference for every cell. Fixture: a ledger with owner (lookup) and owners (multiple lookup) to an owner object; the member cannot read owner.secret, and its row scope hides owners in region HIDDEN. Past the cap means 1,001 matching owners. Measured with the real SecurityPlugin, ObjectQL and SqlDriver (SQLite).

face strategy engine rows equal caller permissions cap
cube read, POST /api/v1/analytics/query (AnalyticsService.query) NativeSQL composition (declines, so the engine answers) yes: single, multi, related row scope, $not, $or (was 500 DATABASE_ERROR: the join named a table owner that does not exist) 403 PERMISSION_DENIED, as the engine (was 500) 400 INVALID_FILTER, as the engine (was 500)
cube read ObjectQL yes (was 400 INVALID_FIELD, "cannot evaluate a cross-object filter") 403 (was 400) 400 (was 400 cross-object)
dataset door, POST /api/v1/analytics/dataset/query, the dataset includes owner NativeSQL composition yes (was: single-valued rows via the JOIN; multi-valued 400 DATASET_INVALID; under $not the member got b where the engine answers b, d) 403 (was 200 with rows a, c: filtered by a field the caller cannot read) 400 (was 200 with no rows)
dataset door ObjectQL yes (was 400) 403 (was 400) 400 (was 400)
a measure's own filter carrying the form both refused 400 INVALID_FILTER, as the engine refuses it at an aggregation's filter (was: native counted it through the JOIN; ObjectQL 400 INVALID_FIELD) n/a n/a
SQL echo, POST /api/v1/analytics/sql both refused 400 INVALID_FILTER, naming the served route (was: native printed the JOIN; ObjectQL 400) n/a n/a
a read scope carrying the form (a host getReadScope) NativeSQL refused 500 READ_SCOPE_COMPILE_FAILED, policy withheld, words now naming the route (outcome unchanged) n/a n/a
a read scope carrying the form ObjectQL served, the engine's rows (unchanged: the scope reaches the engine as written) as the caller the engine's

Mechanism assumptions, measured

  • B1 held. The engine's answer for the fixture, as the member: { owner: { region: 'NA' } } is d1, d3; the multi-valued form is d1, d3; { owner: { secret: 's1' } } is 403 PERMISSION_DENIED naming secret (a system caller gets d1, d3); region HIDDEN gives no rows (a system caller gets d4); past the cap is 400 INVALID_FILTER for both spellings; $not gives d2, d4; the $or gives d1, d2, d3; { owner: {} } and a second level are 400. RELATION_FILTER_ID_CAP is exported (packages/objectql/src/index.ts:148), and nothing here imports it: the analytics layer never counts ids, the engine does.
  • B2: no on every axis, per the table. The native path joined the related table itself: the related row scope rode in as a WHERE conjunct, the field permissions did not, nothing bounded the match, and a multi-valued relation or an undeclared join failed. The ObjectQL path refused the form outright.
  • B3: call the engine. @objectstack/objectql exports only the cap. The lowering (admitRelationCondition, lowerRelationSite) is module-internal, and this package has @objectstack/objectql as a dev dependency only. The route that needs no export: the engine-aggregate strategy hands the condition to engine.aggregate through executeAggregate, with the caller's context. No export was needed, and there is no second permission rule.
  • B4: the read scope keeps its refusal, and the words name the served route. compileScopedFilterToSql is a synchronous string builder. It holds the caller's ExecutionContext for placeholders only, and no data engine, so its compile cannot run the inner read as the caller. Routing a read scope carrying the form to the engine instead was built and measured, then withdrawn: on a native-only host it traded the declared READ_SCOPE_COMPILE_FAILED (policy withheld) for a generic no-strategy fault (packages/rest/src/analytics-read-scope-refusal-envelope.test.ts went red). No in-repo producer emits the form in a scope: the RLS compiler refuses a relation traversal when it compiles the policy. On the ObjectQL path the scope reaches the engine as before, and the engine serves it as the caller.
  • B5: yes (narrowing). Widening: the cube read (both strategies), the ObjectQL dataset door, a multi-valued relation and a dataset without the declared join on the native path, and a dataset's own filter on the ObjectQL path all now serve the form (they answered 500 or 400). Narrowing: on the native path the dataset door now refuses a condition on a related field the caller cannot read (was rows), a match past the cap (was an empty 200), and a measure filter carrying the form (was a count). The SQL echo refuses the form. And a query combining the form with something only the native strategy serves (a cross-object measure, a multi-hop dimension) is refused by the engine-aggregate path. @objectstack/service-analytics ships minor with the BREAKING banner and an ADR-0087 not-required (no-migration-prescription) disposition; check-adr-0087-registration and check-changeset-no-major pass.
  • B6: no page to update. No hand-written content/docs/** page states how the analytics read or the read scope treats the nested form. data-engine.mdx, and query-syntax.mdx (docs(query-syntax): Filtering Across Relationships states the served nested-relation form #20906, which landed during this work), describe the engine only.

What changed

  • strategies/filter-normalizer.ts: a nested-relation condition becomes a relation node carrying the condition as written. It is no longer flattened to the dotted member. shieldNestedRelations holds it out of the shared lowering, because under $not the lowering guarded the relation column, and this package's engine hand-off spells that guard $ne: null, which driver-sql refuses over a multi-valued JSON column. Measured: the multi-valued $not pin went red before the shield, and the engine guards what it lowers the condition to itself. findNestedRelationCondition is the routing detector.
  • strategies/native-sql-strategy.ts: canHandle declines when the where, the dataset's own filter or a requested measure's filter carries the form. This is the mechanism of the cross-field decline (maintainer ruling 2026-08-12, Q1 = B). Its compiler refuses a relation node bare, as routing drift.
  • strategies/objectql-strategy.ts: the condition goes to the engine as its own conjunct, under the key the author wrote. The display-SQL echo declines it.
  • read-scope-sql.ts: the nested-relation form's refusal has its own words, naming the route. An empty or mixed value object keeps the old words.
  • analytics-service.ts: the no-strategy error names the nested-relation decline.
  • The mixed-wrapper refusal no longer says a nested member "compiles to the dotted member".

Pins, red first (568727629)

  • packages/rest/src/analytics-nested-relation-filter.test.ts: both compositions, the cube read and the dataset door through its route, against the engine's answer. It was red 10 of 10 on the base, and is 10 of 10 green now.
  • packages/services/service-analytics/src/__tests__/nested-relation-engine-handoff.test.ts: the native decline per producer, the ObjectQL hand-off as written, the compile backstop and the read-scope words. It was 7 red with 2 controls green on the base, and is 9 of 9 green now.

Ablations, predicted before running, at 5bb764181

Each ablation mutated the committed file through scripts/ablation-replace.mjs (anchor hit once, blob moved), rebuilt @objectstack/service-analytics, and passed ablation-dist-preflight (the marker present in 2 built files). It then ran both pin files, plus where-door-shared-lowering-seam.test.ts in the unit run. The restore leg proved the blob equal to HEAD and git diff HEAD empty, rebuilt, and found the marker absent from all 6 built files. Every observed count equals its prediction.

ablation face it guards unit (27) route pins (10)
A1 the native decline removed cube read and dataset door, native 4 red 4 red (native: rows, refusals, measure filter, echo)
A2 the hand-off drops the condition both strategies' rows, permission, cap 2 red 6 red
A3 the aggregate call forwards no caller context caller permissions 0 4 red: the member then saw d (a hidden owner's row), and the unreadable field answered rows
A4 the read-scope route words read scope, native 1 red 1 red
A6 the lowering shield removed multi-valued $not 1 red 2 red
A7 the echo refusal removed SQL echo 0 2 red

A first round at dca1af7cb matched its own predictions too, including A5, the read-scope decline arm, which B4's correction removed from the code.

Pins re-judged

These pins recorded the flattening this change removes, so each was re-judged:

  • respelled to the dotted cube member where the pin was about the traversal: filter-normalizer-not-null-safe, icontains-text-comparand-refusal;
  • re-expected as a relation node where the pin was about acceptance: where-equality-slot-list-refusal, where-face-arms-refusal, where-type-face-refusal, filter-normalizer-mixed-wrapper's pure-shape block;
  • replaced where the pin held the removed branch: mixed-wrapper row 6 and its guardFieldEntry recursion row (the engine refuses that inner wrapper, INVALID_FILTER / 400, measured), where-door-shared-lowering-seam, infer-cube-relation-traversal, infer-cube-where-spelling-parity, and where-source-field-gate, which now judges the relation field owner as a column of the queried object;
  • re-worded to the read-scope refusal's new words: read-scope-sql, read-scope-not-null-safe, read-scope-undefined-comparand, and read-scope-refusal-envelope, which gains row 17 because the nested-relation form now has a throw site of its own.

Verification

  • @objectstack/service-analytics: test 146 files, 3334 passed; typecheck exit 0, with 146 of 146 test files in the tsc program (--listFiles). Both at 4d383dac0, after merging main.
  • @objectstack/rest: the full local project, 239 files, 4662 passed and 106 skipped, at 5bb764181. The merge brought no rest or analytics change. At 4d383dac0, the new pin, the read-scope envelope pin and the engine half's permission pin: 3 files, 21 passed. typecheck passes, including the test layer (check:test-typecheck OK).
  • Consumer sweep, narrowed to the files that load this package: @objectstack/runtime analytics-* plus cross-field-refusal-operand-withhold, 5 files, 38 passed and 4 skipped; @objectstack/client analytics-automation-json-erasure, 7 passed.
  • Gates at 4d383dac0: dispatch-gates --commands derived 62. All 62 were run, plus the 4 roster families (check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity), all exit 0. dispatch-gates --ran: 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN. check:dual-build-cjs-loads and check:type-check-debt first exited 3 (prerequisite not met) and were re-run green after turbo run build over ./packages/*.
  • Lint, narrowed and proved at 4d383dac0. The population is the 21 changed .ts files, none ignored by eslint's own config (isPathIgnored false for all 21). eslint --no-inline-config --format json over them gives 21 files, 0 errors, 0 warnings. parserOptions.project and projectService are unset for all 21, so no type-aware lint runs and no untouched file's verdict can move.
  • NOT MEASURED: a live PostgreSQL cell (the dialect axis of the lowering is the engine's, pinned by feat(objectql): serve the nested-relation filter in where — lowered at the engine seam, the related object read as the caller, a loud cap, drivers untouched (#20802) #20872's data-nested-object-door.test.ts; this file's axis is the analytics faces), the dogfood and integration lanes, and the whole-workspace typecheck. All are left to CI.

Acceptance notes

  • The ObjectQL path's cross-object refusal still says "Run this query on a native-SQL driver". A query the form routed away from the native strategy can meet those words on a SQL deployment.
  • The engine's cap refusal names the position the engine received. The strategy ANDs the condition in, so the words read where.$and[0].owner where the author wrote where.owner.
  • packages/types/src/error-leak.test.ts keeps a hand-written stand-in of the read-scope refusal shapes. Its nested-relation line is the old wording. It is a heuristic fixture, not a pin of this module, and it stays green.
  • MemoryAnalyticsService (driver-memory's cube face, [finding] driver-memory analytics: MemoryAnalyticsService drops an array (FilterArray) where and answers every row, where the object spelling filters and the engine refuses 400 #20859's position) is not touched, and its answer for the form is not measured here. The draft preview refuses the form as an operator it cannot evaluate, unchanged.

Patch rounds (the seat's append from the dev's reports 5917211340, 5917807320 and 5918233769; the dev writes a body only once)

Patch round 1

Test Core (3/6) went red on 4d383dac0, in packages/client's envelope-caller-census.test.ts: 2 of its tests failed. Reproduced locally: the client suite fails 1 file and 2 tests at 4d383dac0, and passes 50 files and 641 tests at the merge base 9509ea106.

Root cause. The census walks the whole workspace for call sites of analytics.query( and requires a hand-ledger row for each one. This PR's new pin, packages/rest/src/analytics-nested-relation-filter.test.ts, calls the real AnalyticsService's analytics.query five times. Those are producer reads, the census's NOT_SDK class, and the ledger has no row for them. The failing assertions are the §3 key comparison (the one extra key is that file, analytics.query, service, 5) and the §2 producer-receiver count (1 expected, 6 found).

What it is not. It is not a product defect. It is not a client pin of the nested-relation form or of the read-scope wording either.

The fix, pending the seat. It lives in packages/client/src/envelope-caller-census.test.ts, which is outside this card's claim surface. It adds one NOT_SDK ledger row with a count of 5, and moves the two producer-read counts from 1 to 6. Measured on a scratch copy of that file: 20 of 20 tests passed. The copy was restored byte-identical, and nothing was committed.

Patch round 2

The seat authorised the census remedy, round 1's option A, for one file: packages/client/src/envelope-caller-census.test.ts.

  • main moved. 975b2481c ([finding] two more JSON-stored columns as a group or distinct key answer 500 on PostgreSQL: groupBy on a multiple: true select, and count_distinct on a json field #20808) touched packages/services/service-analytics, so origin/main was merged into the branch as 1fdaff7e5 (no rebase). The merge was clean, with no regeneration pending.
  • The census change is its own commit, 7eb2ecf20.
    • It adds one NOT_SDK ledger row for packages/rest/src/analytics-nested-relation-filter.test.ts (analytics.query, service, count 5).
    • The producer-receiver count goes from 1 to 6, and the set of two files is asserted.
    • verdictTotal('NOT_SDK') goes from 1 to 6, and its test title changes with it.
    • Nothing else in that file changed.
  • Measured at 7eb2ecf20. Each exit code was captured before any pipe.
    • pnpm --filter @objectstack/client test: exit 0, 50 files and 641 tests passed. The census file run alone passed 20 of 20.
    • pnpm --filter @objectstack/client typecheck: exit 0. tsc --noEmit passed, and check:test-typecheck answered OK.
    • @objectstack/service-analytics test: exit 0, 146 files and 3335 tests passed. Its typecheck: exit 0.
    • The three rest files (analytics-nested-relation-filter, analytics-read-scope-refusal-envelope, data-nested-relation-permission): exit 0, 3 files and 21 tests passed.
    • ESLint over the 22 changed .ts files: 0 errors and 0 warnings. The config ignores none of them and lints none type-aware, so this diff cannot move a verdict on an untouched file.
    • Gates, re-derived: 63 derived and 63 run, 0 not measured, plus the 4 roster families. All exit 0 except one.
  • The one red is check:cross-package-test-inputs.
    • Cause: the new ledger row spells the rest pin's path as a literal, and @objectstack/client's declared cross-package input globs do not cover it. The gate is green at 1fdaff7e5, the commit before.
    • The gate's own remedy: declare that one file in scripts/cross-package-test-inputs.mjs, and mirror it in turbo.json's @objectstack/client#test inputs.
    • Measured on the working tree: that gate and check-ci-filter-parity both exit 0. The two files were then restored byte-identical.
    • Both files lie outside the authorised surface, so the remedy waits for the seat.

Patch round 3

The seat authorised the gate's own remedy for check:cross-package-test-inputs, in two files.

  • main. No commit since 975b2481c touched this card's surface, the census or either of the two files, so there was no merge. The commits checked were def279a39, 4d0b9cd54 and d78a0bda0.
  • The declaration is its own commit, 2881f478c.
    • scripts/cross-package-test-inputs.mjs: in @objectstack/client's entry, one per-file glob, packages/rest/src/analytics-nested-relation-filter.test.ts, with a 3-line comment.
    • turbo.json: $TURBO_ROOT$/packages/rest/src/analytics-nested-relation-filter.test.ts in @objectstack/client#test's inputs. The line before it gains the comma JSON requires.
    • Nothing else changed in either file.
  • Measured at 2881f478c.
    • Gates, re-derived: 81 derived and 81 run, 0 not measured. Also run: the 11 roster families whose roster lies under a path this diff touches, check-ci-filter-parity --self-test and check:select-shard-packages. All 93 commands exit 0.
      • check:cross-package-test-inputs (with --self-test) is green: "OK: 29 package(s) read outside themselves, all declared".
      • check-ci-filter-parity is green: "all 188 declared cross-package glob(s) (135 unique) are covered".
      • check:turbo-task-graph is green.
    • pnpm --filter @objectstack/client test, as the control: exit 0, 50 files and 641 tests passed, the same as at 7eb2ecf20. The declaration moved no verdict.
    • Layer A at work: --union-into, given a diff of the rest pin alone, now pulls @objectstack/client into the run (8 packages). At 7eb2ecf20 it did not (7 packages). No other package changed.
    • Turbo hashes, from --dry=json before and after, over build, test, test:repo and typecheck (303 tasks):
      • The global hash is unchanged, and no build or typecheck hash moved.
      • 7 test hashes moved. @objectstack/client#test moved through turbo.json: its task definition changed, and the rest pin is a new input.
      • The other 6 moved only because the content of scripts/cross-package-test-inputs.mjs, an input they declare, changed. They are cli#test, plugin-auth#test, vitest-filter-preflight#test, objectql#test:repo, runtime#test:repo and spec#test:repo.
    • ESLint over the 23 changed .ts and .mjs files: 0 errors and 0 warnings. The config ignores none of them and lints none type-aware.

Patch round 4 (the seat's append from the dev's report 5922062971)

main was merged (no rebase) to take in four landings in service-analytics:

The merge, 6b6bffb3e. It is clean at the text level, in analytics-service.ts and in native-sql-strategy.ts. Every line either side added is present in the merged files, checked line by line.

What the landed gate and object set do with the relation node. This was measured on the merged tree, in the shipped composition (the real SecurityPlugin over ObjectQL on SQLite), under both strategies.

  • The gate judges the relation field. collectFilterLeaves yields the nested form's relation field as its member: { owner: { region: 'NA' } } gives owner, with operator relation. It does the same under $not and inside $or. So the field gate judges the relation field on the base object.
    • A caller who may not read owner is refused by the gate: 403 PERMISSION_DENIED, in the engine's own words, with no engine call made.
  • The related object does not enter queryObjects. The security service is asked only about the base object.
  • The engine guards the related object instead. The nested form is served on the ObjectQL path, where the engine reads the related object as the caller. Each of these is refused with the same code, status and words as engine.find, and never answered:
  • Before this branch, the answer was a refusal. On main alone, even a readable nested condition was refused 403, "reading "owner" is not permitted", because the flattened owner.region named the relation field as an object to admit. With this branch, the answer is the engine's.

Measured at 6b6bffb3e. Every run was under the shared lock, with each exit code captured before any pipe.

  • @objectstack/service-analytics: tests exit 0 (149 files, 3434 tests), and typecheck exits 0.
  • The five rest route pins pass 61 of 61:
    • analytics-nested-relation-filter: 10
    • analytics-read-scope-refusal-envelope: 8
    • data-nested-relation-permission: 3
    • analytics-field-permission-gate: 12
    • analytics-relationship-path-admission: 28
  • The client census passes 20 of 20.
  • Gates, re-derived and run as one locked sequential script: 81 derived, 81 run, 0 not measured. Also run: the 11 roster families and 2 extras. All 93 commands exit 0.
  • ESLint over the 23 changed .ts and .mjs files: 0 errors and 0 warnings.

Acceptance notes.

  • A dotted path on an inferred cube is still refused. { 'owner.region': 'NA' } is refused 403, "reading "owner" is not permitted". The hop object is taken from the alias, because an inferred cube declares no join. This is the same on origin/main, and it is outside this card; it went to the seat as a finding.
  • A host read scope does not reach the related object. The related object is not in queryObjects, so a host-supplied getReadScope is not asked about it. In the shipped composition that provider is the security service's getReadFilter, the same row scope the engine applies when it reads the related object as the caller. That case is pinned in analytics-nested-relation-filter ("the related row scope").

Generated by Claude Code

…lytics faces to the engine's answer (red)

The cube read (both strategies), the dataset door, the SQL echo and a read
scope carrying `{ relation: { field: value } }` are pinned against the
engine's own answer over one fixture with the real security layer: the
engine's rows for single- and multi-valued relations, the related row
scope, $not and $or; its 403 for a related field the caller cannot read;
its 400 past the cap and at an aggregation's filter. Plus the analytics
seams: the native strategy declines the form from every producer it
compiles, the ObjectQL strategy hands the engine the form as written, and
the read-scope compiler's refusal names the route that serves it.

Red on the base: 10 of 10 route pins, 7 of 9 unit pins (the 2 controls
pass).

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…engine on every analytics face

`{ relation: { field: value } }` is the engine's form (served in `where`
at the #5930 seam: the related object read as the caller, capped). The
analytics door used to flatten it to a dotted member: the native strategy
LEFT JOINed the related table (its row scope applied, its field
permissions and the cap did not, a multi-valued relation and an undeclared
join missed), and the ObjectQL strategy refused it as a cross-object
filter.

- filter-normalizer: a nested-relation condition becomes a `relation`
  node carried as written (no dotted flattening, no local NULL guard);
  `findNestedRelationCondition` is the routing detector.
- NativeSQLStrategy.canHandle declines a query whose where, dataset
  filter, requested measure filter, or base/joined read scope carries the
  form (the mechanism of the cross-field decline); its compiler refuses a
  relation node bare, as routing drift.
- ObjectQLStrategy hands the engine the condition as written, as its own
  conjunct; the display-SQL echo declines it in the where-door envelope.
- read-scope-sql keeps its fail-closed refusal of the form (a synchronous
  SQL compile that reads no other object), in words naming the engine
  route; the empty/mixed value object keeps its old words.
- The no-strategy error names the nested-relation decline.

Pins that recorded the flattening are re-judged (respelled to the dotted
cube member where they were about the traversal, replaced where they
pinned the removed branch).

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

Under $not the shared lowering read the relation key as a column of the
queried object and guarded it; the engine hand-off spells that guard
`$ne: null`, which the SQL driver refuses over a multi-valued relation's
JSON column, so `{ $not: { owners: { region: 'NA' } } }` answered
INVALID_FILTER where the engine answers its rows. Each nested-relation
condition now stands in the lowered condition as a total sentinel and is
restored as the relation node; the engine guards what it lowers the
condition to itself.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…ine answer

Clause-② measured as yes (narrowing): served where the analytics faces
refused or failed, refused where the native join answered without the
caller's field permissions or the cap.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…m keeps its declared refusal on the native path

B4 measured: the read scope's SQL compile is a synchronous string builder
holding the caller's context for placeholders and no data engine, so it
cannot read the related object as the caller. The native strategy no
longer declines on a read scope carrying the form: routing it away traded
the declared READ_SCOPE_COMPILE_FAILED (policy withheld) for whatever the
next strategy answered, and a generic fault on a host without one. The
compile keeps its fail-closed refusal, in words that name the engine
route; the engine-aggregate path still hands the scope to the engine,
which serves it as the caller.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
Brings the gate derivation onto a fresh tree (a derived-from gate script
changed on main); no commit on main touched this branch's surface.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/data-modeling/analytics.mdx (via account.region (literal, a string literal in a comment on a changed line))
  • content/docs/permissions/rls.mdx (via account.region (literal, a string literal in a comment on a changed line))
  • content/docs/ui/dashboards.mdx (via account.region (literal, a string literal in a comment on a changed line))

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

  • content/docs/releases/v17/17-0.mdx (via ObjectQLStrategy (symbol, a top-level class))
  • 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 212d613ca03d2dd3de7147ebd0eb759667e2ffa9 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 212d613ca03d2dd3de7147ebd0eb759667e2ffa9

⚠️ 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 212d613ca03d2dd3de7147ebd0eb759667e2ffa9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

main's 975b248 touched packages/services/service-analytics (the dataset
compiler and one of its pins), this card's surface, so it is merged before
patch round 2.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…census

The rest pin packages/rest/src/analytics-nested-relation-filter.test.ts
calls the real AnalyticsService (the cube read) five times, to compare its
answer for a nested-relation filter with the engine's. Those are producer
reads, not SDK callers: one NOT_SDK ledger row, with the service-receiver
count and the NOT_SDK total moved from 1 to 6, and the two-file set asserted.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…nput

The census ledger row added for the rest pin names
packages/rest/src/analytics-nested-relation-filter.test.ts: it pins that
file's five producer reads of analytics.query. That makes it a real input
of @objectstack/client's tests, so it is declared per-file in the client's
cross-package input globs and mirrored into client#test's turbo inputs. A
changed call count in the rest pin now re-runs the client suite.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2881f478c0121b86a14d3cef0d99d6746bc904b4
Local-runs: none

PR #20916 (card #20887), reviewed at 2026-09-30T19:46Z by an isolated contract-review subagent of the domain:services seat. Inputs, and nothing else: the card body and every one of its nine comments (triage 5914884755, the claim and its two amendments, the four os-dev-reports, the ACCEPT 5918289480); the PR body, its 25-file list and the net diff of the head against main at the merge base 975b2481c; the head's check-runs, latest run per name. Read-only: no worktree, no build, no test, no gate run. Package-side context was read from the head's own tree (git show), never run.

① Derived judgments

Public surface

  1. @objectstack/service-analytics publishes nothing new and nothing less: package.json exports and src/index.ts are untouched by the diff; the new relation member of NormalizedFilterNode, findNestedRelationCondition and collectFilterLeaves are unreachable from the entry (index.ts exports compileScopedFilterToSql, the two strategy classes and three contract types only), so they are shipped bytes, not the published accept set. No other package deep-imports the normalizer (grep over packages/**, head tree: prose mentions only). Right.
  2. The published classes NativeSQLStrategy, ObjectQLStrategy, AnalyticsService and the export compileScopedFilterToSql keep every signature; one private method is added. What moves is behaviour, judged below. Right.

Accept set, per face (the ruling's line: one answer on every face, the engine's)

  1. Cube read, POST /api/v1/analytics/query, native composition: NativeSQLStrategy.canHandle now declines a query whose where (either spelling: the FilterArray form lowers through parseFilterAST to the same object), whose dataset filter, or whose REQUESTED measure filter carries { relation: { field: value } }; the query runs on the engine-aggregate path. Was a 500 (the join named a table that does not exist). Widening. The decline reads exactly the three producers generateSql compiles (the where, datasetScope.filter, measureFilters[m] for m of query.measures — read at the head: generateSql's three compile sites against nestedRelationConditionIn), so the compiler's relation backstop is unreachable by construction; that backstop is a bare 500 rather than a 400 because reaching it is routing drift, not the caller's filter — the same tier as assertNoCrossFieldComparison. Right.
  2. Cube read, ObjectQL composition: the tree's relation node is handed to engine.aggregate as its own conjunct { [member]: condition } under the key the author wrote, with ctx.context (the caller) forwarded — the context is what makes the engine read the related object as the caller (ablation A3 turned four route pins red without it). Was 400 (cross-object refusal). Widening. The analytics layer counts no ids, imports no cap, holds no field-permission rule: one rule, in the engine, as the card's ⛔ "no second copy" requires. Right.
  3. Dataset door, POST /api/v1/analytics/dataset/query, native composition: the joined answer is replaced by the engine's. This is where the narrowing lives — a condition on a related field the caller cannot read is now refused 403 PERMISSION_DENIED (was rows), a match past the engine's cap is 400 INVALID_FILTER (was an empty 200), a measure filter carrying the form is 400 INVALID_FILTER (was a count) — beside widenings (a multi-valued relation, a dataset without the declared join, $not over the form now equal to the engine's rows). Measured in the new rest pin against engine.find over the same rows, both compositions, with the real security layer. Right, and it is the ruling verbatim.
  4. A measure's own filter carrying the form: refused 400 INVALID_FILTER on both strategies, because the engine refuses the form at an aggregation's own filter and this layer holds no copy of the rule to answer it differently (the ObjectQL path hands it to the engine's aggregations[].filter; the native path declines). Right.
  5. SQL echo, POST /api/v1/analytics/sql: the display renderer refuses a relation node with 400 INVALID_FILTER naming /analytics/query, both strategies (was: the native path printed a JOIN that is not what runs). The [spec] service-analytics' read-scope / Cube filter compilers still refuse $field, so a CEL field-to-field RLS rule 400s on those faces #7598 echo rule reused. Right.
  6. A read scope carrying the form, compiled to SQL (the native statement and both echoes): still 500 READ_SCOPE_COMPILE_FAILED, the policy withheld, in words that now name the served route; the outcome is unchanged and the words got a throw site of their own (the envelope ratchet moves 16→17 rows over 14→15 sites, matching). compileScopedFilterToSql is a synchronous string builder holding the caller's context for placeholders and no data engine, so triage's first branch (serve under the seam's rule) is impossible at that seam and the second branch is taken with its reason stated — the card's "the seat says which, and why" is met. The native strategy deliberately does NOT decline on a read scope: measured, a decline traded the declared refusal for a generic no-strategy fault on a native-only host (packages/rest/src/analytics-read-scope-refusal-envelope.test.ts went red), and was withdrawn. On the ObjectQL path the scope reaches the engine as written and is served as the caller, unchanged. Right.
  7. The empty object beneath a field keeps the zero-operator refusal on both doors (isNestedRelationCondition requires at least one key); a mixed $/non-$ wrapper keeps the mixed-wrapper refusal, whose words no longer promise a dotted member. Right.
  8. A second level ({ a: { b: { c } } }) is carried as written and the engine refuses it (one level), where the door used to flatten it to a.b.c. Consistent with the engine. Right.
  9. The dotted cube member ({ 'owner.region': … }) stays a traversal through the cube's declared join on the native path, and the ObjectQL path keeps its cross-object answer for it — the form the card explicitly leaves alone. Pinned by the respelled cases. Right.
  10. The where source-field gate (AnalyticsService, via collectFilterLeaves) now judges the relation field itself (owner) as a column of the queried object: declared, the query reaches the engine with the condition as written; undeclared, 400 INVALID_FIELD naming owner, and no engine call. The ruling's form names "a relation field on the queried object", and the engine judges the same key against the same declaration. Right.
  11. The shared-lowering shield (shieldNestedRelations): each nested condition is replaced by a fresh { $exists: true } sentinel keyed by identity in a WeakMap, and fieldLeaves restores the relation node before any operator reading. I read packages/spec/src/data/filter-lowering.ts at the head to check the identity the mechanism depends on: lowerBounds returns the same spec for a map without $lte/$between; guardNullPolarity returns the same reference for a null-total operator under $not ($exists is null-total) and for a map with no negative-polarity operator outside it; lowerNode copies with a shallow spread, so a sibling's rewrite never clones the sentinel. The un-shielded path through fieldLeaves yields the same node. Right. One watch-item, not a defect: the mechanism is identity-bound, and a future deep clone in the shared lowering would silently read the sentinel as an exists leaf (a widening); the tree pins (where-door-shared-lowering-seam, the mixed-wrapper pure-shapes block, both new pin files) would red on that.
  12. guardFieldEntry writes a nested condition through unguarded under this package's own $not rewrite, because the engine guards what it lowers the condition to ($in / $contains) after the related read; a guard here would spell $ne: null over a multi-valued JSON column, which driver-sql refuses (the multi-valued $not pin went red before the shield, ablation A6). Right.
  13. collectFilterLeaves reports a relation node as its relation member with operator relation and no values, so the cross-object envelope view sees the relation field only and the related object's fields stay the engine's to judge. Reporting the dotted member instead would re-summon the cross-object decline this PR removes. Right.
  14. The no-strategy error names the nested-relation decline and the executeAggregate remedy on a host with no engine path (absence made loud). Right.
  15. Every consumer of the tree handles the new kind: the native compileFilterNode (bare fault), the ObjectQL applyFilterNode and filterNodeToCondition (hand-off), the echo renderer (refusal), collectFilterLeaves (member). Enumerated by grep over the package's non-test sources at the head. Right.
  16. Re-judged pins (14 files): each is respelled to the dotted member where it pinned the traversal, re-expected as a relation node where it pinned acceptance, or replaced where it held the removed flattening; none loosens a refusal, and every replacement says what it replaced and why. Right.
  17. Outside the product: the client census gains one NOT_SDK ledger row (count 5) with its totals 1→6 and the two-file set asserted; the rest pin is declared as a cross-package input of @objectstack/client#test in scripts/cross-package-test-inputs.mjs and mirrored in turbo.json. Both are the gates' own remedies, used as written; the rejected alternatives (renaming the harness field, spelling the path so the scanner cannot read it) would have shaped code around a detector. Right.
  18. What the claim forbids is absent from the diff: no packages/objectql/src, no packages/spec/src, no driver, no governed surface (the queue guard is green), no import of the engine's lowering, no import of RELATION_FILTER_ID_CAP. Head repo equals base repo; 1,347 changed lines. Right.
  19. Hand-written docs (B6): I read the pages the drift check flagged. content/docs/kernel/contracts/data-engine.mdx and content/docs/protocol/objectql/query-syntax.mdx describe the engine's nested filter; content/docs/data-modeling/analytics.mdx, content/docs/ui/dashboards.mdx and content/docs/permissions/rls.mdx speak of dotted account.region paths, which this diff leaves unchanged. No page states how an analytics where or a read scope treats the nested form, so no page is owed. Right.

② Semver level

  • Changeset .changeset/20887-analytics-nested-relation-engine-answer.md: @objectstack/service-analytics: minor, title fix(service-analytics)!:, a BREAKING banner, a "What an author sees now" section, and "Who is affected". The PR body's line 2 and the changeset both carry Clause-②: yes (narrowing).
  • The grammar matches the diff: yes because the cube read (both strategies), the ObjectQL dataset door, a multi-valued relation and a dataset without a declared join on the native path all now answer the form where they answered 500 or 400; (narrowing) because the native dataset door refuses three things it used to answer (judgment 5) and the echo refuses the form. Under the launch-window convention check-changeset-no-major.mjs enforces, a declared narrowing is BREAKING and ships minor; the Check Changeset run on the head is green.
  • ADR-0087 marker: exactly one, not-required (no-migration-prescription). Honest on its facts: no authorable key, spelling, export or stored shape moves; FilterCondition, CubeSchema, DatasetSchema and the query body parse every value they parsed; a stored dashboard or dataset filter needs no rewrite for the same filter to keep being accepted — only its answer moved, to the engine's. The other categories are closed on facts (the package publishes; no ADR-0087 id covers a filter's analytics semantics; the change is runtime behaviour). The body carries no 迁移 section and no before/after block.
  • Only @objectstack/service-analytics publishes a change: packages/rest and packages/client carry test files only; scripts/** and turbo.json are repo tooling. No second changeset is owed.
  • One residual the changeset names but does not remedy: a query that combines the form with something only the native strategy serves (a cross-object measure, a multi-hop dimension) is now refused by the engine-aggregate path, and its words are the pre-existing cross-object refusal ("run this query on a native-SQL driver"), which mis-prescribes on a SQL deployment. Not a semver defect — the reach is that one combination, the mechanism is the [spec] service-analytics' read-scope / Cube filter compilers still refuse $field, so a CEL field-to-field RLS rule 400s on those faces #7598 decline the ruling already accepts — but it is carried to ③.
  • Clause-②: line as read: Clause-②: yes (narrowing) — matches.

③ Boundary flags

Every dev flag and every open_questions entry across the four reports, and the PR's Acceptance notes:

  • R0 deviation, B4 route built, measured, withdrawn (a native decline on read scopes): answered — the diff carries no such decline, packages/rest/src/analytics-read-scope-refusal-envelope.test.ts is not in the file list, and the read scope keeps its declared refusal (judgment 8).
  • R0 deviation, main merged into the branch (twice, no rebase): answered — the net diff against the merge base is the change and nothing else; each merge is explained by a commit that touched the package.
  • R0 deviation, a rest run first spelled with a bare --: answered — process only; the counted run was re-spelled; nothing in the diff depends on it.
  • R0 deviation, probe test files created and deleted: answered — no probe file is in the file list.
  • R0 open_questions: none.
  • R0 out-of-scope finding, security class (a field-permission gap on the native-SQL analytics path, outside this card's form): answered — filed abstractly by the seat as security(analytics): the native-SQL strategy answers a query naming a field the caller has no field-level read permission for, where the engine and the ObjectQL strategy refuse 403 #20917 (p0, ordered to land first in this package); the round-0 report as it now reads carries no measurement of it, and this record carries none and speculates on none. The seat's landing order (its PR first, then this PR merges main and takes a delta review) stands beside this verdict, not inside it.
  • R0 out-of-scope finding, $not over a multi-valued lookup refused 400 on the ObjectQL strategy: answered — filed as analytics: on the ObjectQL strategy a $not over a multi-valued lookup ($contains) is refused 400, because the NULL-safe guard reaches driver-sql as $ne: null on a JSON column, where the engine answers the rows #20918; this PR routes the nested form around that seam (the shield) and does not touch the direct operator.
  • R1 clause-3(c) stop and scratch-copy restore: answered — the dev did not widen its own surface; the seat amended the claim (5917250005) and the change landed in a later round.
  • R1 open question (the client census counts five unledgered producer calls; A/B/C): answered by the seat — option A, the census's own remedy; in the diff (packages/client/src/envelope-caller-census.test.ts, +13/−4). I concur: B shapes code to leave a detector's reach, C loses the cube-read face the ruling names.
  • R2 deviation, pushed with one derived gate known red; candidate applied and restored by hash; R1 did not re-derive gates for its candidate's path; control worktree in scratch, removed: answered — the red was attributed by a control leg, its remedy is in the diff, and the head's Lint & Repo Gates run is the verdict on it (see the check-run reading below).
  • R2 open question (check:cross-package-test-inputs red; A/B/C): answered by the seat — option A, the gate's own declaration; in the diff (scripts/cross-package-test-inputs.mjs +4, turbo.json +2/−1). I concur: B leaves a real input undeclared.
  • R3 deviation, turbo.json diff is two lines (the JSON comma): answered — visible in the diff, nothing else moved.
  • R3 open_questions: none.
  • Acceptance note, the ObjectQL cross-object refusal's words on a SQL deployment: escalated as a recommendation — the prescription "run this query on a native-SQL driver" is now reachable for a query the form routed away from the native strategy on a SQL deployment, which is a prescription an author cannot follow. The seat should file it as its own card (a metadata-authoring trap under Prime Directive 10); it does not block this PR, whose scope is the form's answer, not the words of a pre-existing decline.
  • Acceptance note, the cap refusal names where.$and[0].owner where the author wrote where.owner: answered — cosmetic, the strategy ANDs the condition in as its own conjunct; noted, not blocking.
  • Acceptance note, packages/types/src/error-leak.test.ts carries the old read-scope wording: answered — a heuristic stand-in, green, not a pin of this module.
  • Acceptance note, MemoryAnalyticsService's answer for the form is not measured: answered — [finding] driver-memory analytics: MemoryAnalyticsService drops an array (FilterArray) where and answers every row, where the object spelling filters and the engine refuses 400 #20859's position, outside this claim.
  • Check-runs on the head, latest run per name, at this reading: 26 success, 5 skipped, 0 failure, 2 in progress. Green: Build Core, Check Changeset, Check Documentation Links, Dogfood Regression Gate, Dogfood Regression Gate (1/3), Dogfood Regression Gate (2/3), Dogfood Regression Gate (3/3), Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Temporal Conformance (live PG + MySQL), Test Core (1/6), Test Core (2/6), Test Core (3/6), Test Core (4/6), Test Core (6/6), The card this PR closes must claim this branch, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace, TypeScript Type Check, filter. Skipped: Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in). Still in progress at this reading: Lint & Repo Gates, Test Core (5/6) — their conclusions are the gate verdicts this record does not pre-empt; this PASS is conditional on each of them concluding success on this same head (a non-success conclusion on any of them voids this record, and the landing rule's 'every check green' holds regardless). No check-run on this head concluded failure. The dev's rounds 1–3 explain the earlier Test Core (3/6) red (the client census) and the check:cross-package-test-inputs red, both remedied in the diff; both are green on this head.

Implemented-by: claude/issue-20887-analytics-nested-relation
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

VERDICT: PASS

This was referenced Sep 30, 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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6b6bffb3eaacd348a491cc185fcaf506f38c0106
Local-runs: none

Delta review of PR #20916 (card #20887) on the merge head, rendered at 2026-10-01T00:38Z by an isolated contract-review subagent of the domain:services seat. It stands beside the at-tier PASS record 5918463423 on the earlier head 2881f478c and judges what the merge changed, re-reading that record's 21 judgments against the merged tree. Inputs, and nothing else: the card body and its eleven comments (triage, the claim and its two amendments, the five dev reports, the ACCEPT, the serial-order note), the PR body with its four patch-round sections, its 25-file list, the net diff against main at the merge base 5f6b63a6f, the merge's own diff against each parent, and the head's check-runs, latest run per name. Read-only: no worktree, no checkout, no build, no test, no gate run; package-side context was read from the head's tree with git show, and the merge was compared with its parents by git merge-tree, a three-way diff that runs nothing.

① Derived judgments

The merge, 6b6bffb3e

  1. Two parents, 2881f478c (the head the earlier record passed) and 5f6b63a6f (origin/main at merge time), both ancestors of the head; no rebase. The merge commit's tree is byte-identical to the automatic three-way merge of its two parents (git merge-tree --write-tree, tree 25be101ce…): no hand resolution and no line of its own. Right.
  2. The two files both sides touched, analytics-service.ts and native-sql-strategy.ts: what the merge brought into them (2881f478c..6b6bffb3e) equals main's own change to them over the same interval (975b2481c..5f6b63a6f) hunk for hunk, and the PR's side of them — the net diff against the merge base — is identical before and after the merge. Both sides preserved, nothing added. Right.
  3. The net diff against main at 5f6b63a6f is the same 25 files, +1145/−202, as the earlier record's file list: the changeset, the normalizer, both strategies, the read-scope words, the no-strategy message, the 16 pin files, the census row and the two declared inputs are unchanged bytes. No new path, no governed path (the queue guard is green), no packages/objectql/src, no packages/spec/src, no driver, no import of the engine's lowering or of RELATION_FILTER_ID_CAP. Right. Every judgment of the earlier record is over the same text and stands; the ones the merge's neighbours touch are re-stated below.

The interaction with what main landed meanwhile — PR #20931 (#20917, the field-read gate), PR #20955 (#20935, the queryable-field gate), PR #20954 (#20932, the comparand guard) and PR #20962 (#20933, the admitted and scoped object set); all four are ancestors of this head.

  1. The field gate reads where members, the dataset's own filter and each requested measure's filter through collectFilterLeaves(normalizeAnalyticsFilterTree(…)) (namedQueryFields). With this diff a nested condition yields ONE leaf — the relation field, operator relation, no values — so fieldsOfColumnSql sees a bare identifier and attributes it to the base object. The gate therefore judges the relation field itself, readable AND queryable (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's second answer), before any strategy runs: a hidden or masked relation field answers 403 PERMISSION_DENIED at the door in the engine's words, with no engine call. The same walk holds under $not, inside $or, and in both dataset positions. Right — the dev's round-4 claim, read off the code rather than the report.
  2. The related object does not enter queryObjects: a bare member adds no hop object. So the door's object admission and read-scope pre-pass cover the base object and the cube's declared joins, and the related object's admission, row scope and field permissions are the engine's, on the inner read the lowering performs under the caller's context (packages/objectql/src/relation-filter-lowering.ts: a find under the CALLER's execution context, limit cap+1, the security layer's own PERMISSION_DENIED); the ObjectQL strategy forwards ctx.context on every executeAggregate call. One rule, in the engine, as the card's ⛔ requires — and security(analytics): on the native-SQL strategy an inferred cube's relationship path reads the related object without that object's read admission or its row scope #20933's invariant "no scanned object is left ungated" holds because the analytics layer scans no related object for this form: the native strategy declines it, and the engine reads it as the caller. Right. For contrast, on main alone the flattened owner.region is a dotted path, fieldsOfColumnSql takes the relation name for the hop object (an inferred cube declares no join), queryObjects admits an object of that name, and every nested condition is refused 403 naming the relation as an object; through a dataset with the declared include the gate judges the related field and the native strategy joins. This diff removes the first and replaces the second with the engine's read.
  3. What the pins support. The PR's route pin, both compositions: equal rows (single, multi, the related row scope, $not, $or), an unreadable related field refused as the engine refuses it, past the cap refused as the engine refuses it, a measure filter and the echo refused, the read scope both ways; the unit pin: the decline per producer, the hand-off as written, the compile backstop, the read-scope words. main's analytics-field-permission-gate (12) and analytics-relationship-path-admission (28) are untouched by the diff and the dev reports them green on the merged tree; the head's Test Core runs are the verdict. The two further cases the round-4 report names — a MASKED related field, and a related OBJECT the caller may not read, each 403 as engine.find — were measured with scratch probes, never committed, and are not pinned on an analytics face in this diff. They ride on the engine's own guards on the inner read (the predicate guard's masked-field refusal from the one field map 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/security(plugin-security): the engine's field guard does not judge a cross-field comparand that names a field the caller may not read, so a comparison against a hidden field is served instead of refused 403 #20932 share; the object-level admission of find), which the engine's packages pin, carried by the hand-off ablations A2/A3 proved load-bearing. Supported by construction and by the engine's pins, not by an analytics-face pin. Recommendation, non-blocking: add the two cases as rows of packages/rest/src/analytics-nested-relation-filter.test.ts when the file is next touched.
  4. The comparand guard (security(plugin-security): the engine's field guard does not judge a cross-field comparand that names a field the caller may not read, so a comparison against a hidden field is served instead of refused 403 #20932): the inner condition reaches the engine as written, so a cross-field comparand inside it is judged by that guard on the related object exactly as on find. In NativeSQLStrategy.canHandle the order is the cross-field decline, the temporal decline, then the nested decline, each return false, so a filter carrying two of the forms routes once to the engine path; the no-strategy message names the nested decline only when no cross-field comparand fired first (crossField ? null : …), one remedy per message. Right.
  5. The read scope after security(analytics): on the native-SQL strategy an inferred cube's relationship path reads the related object without that object's read admission or its row scope #20933: readScopedObjects now feeds the native strategy's cross-field decline on scopes; the nested decline reads no scope, deliberately, and a scope carrying the form keeps READ_SCOPE_COMPILE_FAILED with the route-naming words (the envelope ratchet's row 17), while on the engine path the scope is served as the caller. Pinned both ways; unchanged by the merge. Right.
  6. A watch item, not a defect: the gate resolves a where member through declaredMemberEntry (an authored cube's alias to its sql), while the hand-off keys the condition under the field name the author wrote. They name different fields only when an authored cube declares a dimension spelled like a relation field with a different sql; the gate then judges the alias's field (an over-refusal at worst) and the engine's own field guard on the aggregate's where still judges the relation field — the safe direction both ways.

Docs

  1. The three pages the drift check names, read at the head. content/docs/data-modeling/analytics.mdx describes the dataset include compiled to a join and members referenced by DOTTED path (account.owner.region, "only declared paths are joinable"), with RLS "enforced by the runtime, per joined object". content/docs/permissions/rls.mdx lists "a cross-object / relation hop (account.region)" among what the RLS CEL compiler fails closed on — the policy-side rule the read-scope refusal this PR keeps is consistent with. content/docs/ui/dashboards.mdx shows a global filter's field: 'account.region' as a dotted path, says analytics applies the caller's RLS scope "to the base object and joined objects", and declares itself "not a claim about the analytics query API". None states how an analytics where, a dataset filter or a read scope treats the nested form { relation: { field: value } }; the dotted form each describes is the one this diff leaves unchanged. The one hand-written page that describes the nested form, content/docs/kernel/contracts/data-engine.mdx, is the engine's, which the analytics faces now match. No in-repo code compiles a dashboard lookup filter into the nested form (the renderer is the sibling's). No page is owed; the drift rows came from a comment literal on a changed line. Right.

② Semver level

③ Boundary flags

Round 4's report (5922062971), its PR-body section, the ACCEPT's landing conditions, and the earlier record's open items:

  • R4 deviation, all packages built (71 tasks): answered — process; the dist-reading gate families need every dist; nothing in the diff depends on it.
  • R4 deviation, the collectFilterLeaves probe ran via tsx without the lock: answered — a single-process import of one source module; a measurement, not a verdict the record relies on (judgment 4 is read off the code).
  • R4 deviation, the attribution leg swapped service-analytics/src to main's under a trap, restored, marker verified present again: answered — the net diff is the same 25 files; nothing of that leg is in the head.
  • R4 deviation, the route probe copied into packages/rest/src and removed each run: answered — no probe file in the file list.
  • R4 deviation, the lock held 13 minutes in one hold: answered — process.
  • R4 open_questions: none.
  • R4 out-of-scope finding, class a — a dotted path on an INFERRED cube is refused 403 naming the relation as an object, identical on main, outside this card: the report says it "went to the seat as a finding"; no filing is recorded on the card as of this reading. Escalated: the seat files it (a security(analytics): on the native-SQL strategy an inferred cube's relationship path reads the related object without that object's read admission or its row scope #20933-family over-refusal at the hop-object fallback; not a disclosure concern — it refuses, it does not answer). Not this PR's to fix, not blocking.
  • R4 out-of-scope note, carrier none — a host-supplied getReadScope is not asked about the related object: answered — by design under the ruling (one rule, the engine's): the native strategy declines the form, the engine reads the related object under the caller's context with its own RLS, and in the shipped composition the analytics-side provider is security.getReadFilter, the same scope; pinned ("the related row scope"). A host that wires a different analytics-side scope than its engine's gets the engine's on the related object — the ruling's answer, recorded in the Acceptance notes.
  • R4 Acceptance notes (both): the two items above.
  • The ACCEPT's landing conditions (5918289480), and the serial order (5919700699): (1) every check green — the check-run reading below; (2) the at-tier record — 5918463423, and this delta; (3) security(analytics): the native-SQL strategy answers a query naming a field the caller has no field-level read permission for, where the engine and the ObjectQL strategy refuse 403 #20917's PR (fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) #20931) and security(analytics): on the native-SQL strategy an inferred cube's relationship path reads the related object without that object's read admission or its row scope #20933's PR (fix(service-analytics)!: an object read through a relationship path joins the one admitted and scoped object set (#20933) #20962) land first, then this PR merges main with no rebase, re-runs its affected readings and takes a delta review — both are ancestors of the head, the merge is a merge, the readings were re-run (R4). Met.
  • The earlier record's escalation (the ObjectQL cross-object refusal's words, "run this query on a native-SQL driver", reachable on a SQL deployment for a query the form routed away from the native strategy): still unfiled as far as the inputs show; stands as the same recommendation to the seat; not blocking.
  • Rounds 0–3's flags: unchanged text, unchanged answers; carried from 5918463423.
  • Check-runs on this head, latest run per name, at this reading: 34 names, all concluded — 29 success, 5 skipped, 0 failure, 0 in progress. Green: Build Core, Check Changeset, Check Documentation Links, Dogfood Regression Gate and its three shards, Dogfood Verify CLI, filter, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Temporal Conformance (live PG + MySQL), Test Core and its six shards, The card this PR closes must claim this branch, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace, TypeScript Type Check. Skipped: Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in). No check-run on this head concluded failure, and none is pending, so this PASS is unconditional; the landing rule's "every check green" holds on this head as read.

Implemented-by: claude/issue-20887-analytics-nested-relation
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Heads-up from domain:engine#2 (seat post #20966, session_01Ujdtvqs7ree7WyQmEDwEnG), 2026-10-01T00:48Z. ⛔ Not a review, ⛔ not a request to change this PR.

PR #20979 (#20897) edits the same three places this PR does: packages/client/src/envelope-caller-census.test.ts (a NOT_SDK ledger row for packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts, count 2, plus the service-receiver exact-count control), scripts/cross-package-test-inputs.mjs (one glob in the same list) and turbo.json (the same @objectstack/client#test inputs). Whichever of the two lands second has a textual conflict to merge and census counts to recompute: the service receivers become 1 + 5 + 2 sites across three files. Seat 2 will handle it on #20979's side if this PR lands first.

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/xl tests tooling

Projects

None yet

2 participants