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.

Merged via the queue into main with commit 8d329f0 Oct 1, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20887-analytics-nested-relation branch October 1, 2026 01:08
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…oor, before either strategy (objectstack-ai#20917) (objectstack-ai#20931)

Fixes objectstack-ai#20917
Clause-②: yes (narrowing)

One admission gate now judges every member an analytics query names
against the caller's field-level read permissions, before either
strategy runs. A member the caller may not read answers the engine's own
refusal: `403 PERMISSION_DENIED`, in the engine's words. The gate sits
at the analytics door (`AnalyticsService.callCtx`, right after the
object-level admission and before strategy selection), so
`NativeSQLStrategy`, `ObjectQLStrategy`, the SQL echo and any strategy
added later inherit it by construction. There is no second copy of the
permission rule: which fields are readable is the `security` service's
answer, and the analytics layer contributes only the resolution from
member to field.

## Why line 2 is `yes (narrowing)`, not the claim's expected `no
(narrowing)`

Both directions measured:

- **Narrowing.** On the native-SQL strategy every position in the table
below was answered and is now refused. On the ObjectQL strategy an order
key naming a hidden field was ignored and is now refused, a joined
filtered member moves from `400 INVALID_FIELD` to the engine's refusal,
and the SQL echo printed the statement and now refuses.
- **Widening.** `AnalyticsServiceConfig` gains one optional member,
`getReadableFields`, the hook `AnalyticsServicePlugin` fills. It is a
new member of a published config type, so the public surface grows by
one hook. No query accept set widens. (There is no matching plugin
option: nothing in the tree passes the object-level sibling
`admitObjectRead` either, so the plugin always bridges to the `security`
service.)

`@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.

## Per position

Measured on SQLite with the real `SecurityPlugin`, `ObjectQL` and
`SqlDriver`, as a member whose permission set hides fields on the
queried object and on a related one. "Refusal" means `403
PERMISSION_DENIED` whose code, status and message equal what the engine
answers for the same field as the same caller: `engine.aggregate` for a
member the query groups or aggregates, `engine.find` for one it filters
or sorts by. Each face is the cube read (`AnalyticsService.query`, what
`POST /api/v1/analytics/query` relays) and, per row, the dataset door
(`POST /api/v1/analytics/dataset/query`) where the position exists
there.

| position | native-SQL strategy: before → after | ObjectQL strategy:
before → after |
|:--|:--|:--|
| grouped member (dimension) | answered, the hidden values as group keys
→ refusal | refusal → refusal |
| aggregated member (measure) | answered, an aggregate over the hidden
field → refusal | refusal → refusal |
| bucketed time dimension | refusal (the strategy declines, the engine
refuses) → refusal | refusal → refusal |
| time-dimension window | answered, rows selected by the hidden field →
refusal | refusal → refusal |
| filtered member, including under `$or` / `$not` | answered, rows
selected by the hidden field → refusal | refusal → refusal |
| order key | answered, groups ordered by the hidden field → refusal |
answered, the key ignored → refusal |
| joined member, grouped: a dataset `include`, an authored join, an
inferred cube's relationship path | answered, the related hidden values
→ refusal on the related object | refusal → refusal |
| joined member, filtered | answered, rows selected by the related
hidden field → refusal on the related object | `400 INVALID_FIELD`
(cross-object filter) → refusal |
| a dataset's own `filter`; a requested measure's own `filter` |
answered → refusal | refusal → refusal |
| authored cube alias over a hidden field: grouped, aggregated,
filtered, joined | answered → refusal naming the field the alias
resolves to | refusal (joined filtered: `400`) → refusal |
| SQL echo (`AnalyticsService.generateSql`, what `POST
/api/v1/analytics/sql` relays), every row above | printed the statement
→ refusal | printed the statement (joined filtered: `400`) → refusal |
| readable members (the control) | answered → answered, unchanged |
answered → answered, unchanged |

The dataset door answers every refusal as `403` with `{ code, message }`
and no rows beside it.

## The reader, and how the gate reaches it

- `engine.find` and `engine.aggregate` refuse a hidden field in
`plugin-security`'s middleware: the aggregate-input guard for a grouped
or aggregated field, the predicate guard for a filtered or sorted one.
Both build their mask from the caller's permission sets through
`permissionEvaluator.getFieldPermissions`, the `requiredPermissions`
fold and the on-behalf-of delegator intersection.
- The published cross-package reader of that same derivation is
`ISecurityService.getReadableFields(object, context)`
(`resolveProjectionFieldMask`). It is called, not changed: no export or
signature change in `objectql`, `spec` or `plugin-security`.
- `AnalyticsServicePlugin` bridges
`AnalyticsServiceConfig.getReadableFields` to the registered `security`
service at call time, with the three resolutions its object-level and
row-scope bridges keep apart. No security service: no field-level gate,
as on `/data`. A service that throws on resolution or carries no
`getReadableFields`: the query is refused, fail-closed, logged at
`error`. Otherwise: ask it, once per object the query names a field of,
with the caller's context.
- The analytics layer adds the member resolution (`namedQueryFields` in
`analytics-service.ts`, the judgement in the new
`field-read-admission.ts`). Each member is resolved as the strategies
resolve it (`declaredMemberEntry`). A joined member is resolved through
the cube's join at each hop, and its relationship fields are judged on
the object before them, as the engine judges a path's first segment.
Filter members are read through the strategies' own lowering
(`normalizeAnalyticsFilterTree` + `collectFilterLeaves`).
- The words are the engine's two refusals, verbatim: the aggregate
refusal for a grouped or aggregated member, the predicate refusal for a
filtered or sorted one. On one object the aggregate refusal speaks
first, and the base object before a joined one, as on the engine path.
The draft-preview branch of the dataset door asks the same gate before
it evaluates drafted rows.

## What is judged, and what is not

- **Triage's floor:** dimensions, measures, filter members, joined
members and time-dimension members. **Also order keys:** the native
statement ordered by the named column.
- **Authored versus inferred cubes:** a member is judged by the field
its `sql` resolves to, never by its name in the cube. Both are measured
above and pinned.
- **A dataset's own `filter` and a requested measure's own `filter`:
judged.** The nearest engine analogue, measured: the ObjectQL strategy
hands both to the engine, which refuses a hidden field in either. On the
inline dataset door the caller writes both.
- **A host read scope: not judged.** The engine's field guard runs on
the caller's own predicate before row-level policy is composed, and
exempts policy predicates by design: they may name fields the caller
cannot read. A read scope naming a hidden field is served, as on
`/data`; pinned.
- **Stand-downs, each pinned:** a member of an authored cube whose `sql`
is an expression names no field the gate can attribute (flagged for a
decision in the dev report); an object the reader answers "no answer"
for has none of its fields judged, since an object the security service
cannot resolve is one the engine serves nothing from; a name the
object's declared field list does not carry is not judged, and with no
field list available every name is.

## Pins, committed red ahead of the fix, pushed together with it

- `77f73705a` pins, then `40c4979d8` the gate, then `992a592db` the
changeset and the plugin tidy.
- `packages/rest/src/analytics-field-permission-gate.test.ts`: both
compositions over the real `SecurityPlugin`, `ObjectQL` and `SqlDriver`;
the cube read and the SQL echo over the inferred cube and an authored
cube, and the dataset door through this package's route; every refusal
compared with the engine's live answer for the same field, computed in
the same test. Readable members and a system caller are the controls.
Against the base tree: 6 of 12 red (the three position tests per
strategy), 6 green (the references and the controls). Now 12 of 12.
-
`packages/services/service-analytics/src/__tests__/field-read-admission-gate.test.ts`:
every position through both strategy paths from one table with nothing
executed; the words and their order; the stand-downs; a throwing reader
refused fail-closed; no reader wired; the draft-preview branch; and the
plugin's bridge. With the three source files restored to the pins commit
(by absolute path, the restore proven byte-identical to HEAD and `git
diff HEAD` empty): 39 red, 9 green, the 9 being the stand-down and
no-reader controls. Now 48 of 48.
- The client census (`envelope-caller-census.test.ts`) counts no new
call site: the pins call the producer through a receiver named
`service`. Its suite passes unchanged.

## Ablation, predicted before running, at `40c4979d8`

The door's gate call was replaced by a no-op through
`scripts/ablation-replace.mjs` (anchor hit once, blob moved),
`@objectstack/service-analytics` rebuilt, and `ablation-dist-preflight`
found the marker in 2 built files. Predicted: route pin 6 red and 6
green; unit pin 38 red and 10 green (the draft-preview branch keeps its
own call, so it stays green). Observed: exactly that. The restore leg
proved the blob equal to HEAD, `git diff HEAD` empty and the whole tree
clean, rebuilt, and found the marker absent from all 6 built files.

## Verification, at `992a592db`

- `@objectstack/service-analytics`: `test` 146 files, 3374 passed;
`typecheck` exit 0, with 144 of 144 `__tests__` files in the tsc program
(`--listFiles`).
- `@objectstack/rest`: the 12 `analytics-*` route files plus
`error-response-structured-arm-door-parity`, 13 files, 215 passed and 3
skipped; `typecheck` passes, including the test layer
(`check:test-typecheck` OK).
- Consumer sweep, narrowed to the test files that load this package:
`@objectstack/runtime` `analytics-*` (3) plus
`cross-field-refusal-operand-withhold`, 28 passed and 4 skipped;
`@objectstack/client` `analytics-automation-json-erasure` plus the
census, 27 passed; `@objectstack/dogfood` `analytics-*` (6 files), 54
passed.
- Gates: `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`, fed each command with its exit code: 62 derived,
62 run, 0 NOT-MEASURED, a derived zero. `check:dual-build-cjs-loads`
first exited 3 (prerequisite not met) and `check:type-check-debt` was
cut by my own runner's time limit; both were re-run green after `turbo
run build` over `./packages/*`.
- Lint, narrowed and proved: the population is the 5 changed `.ts`
files, none ignored by eslint's own config (`isPathIgnored` false for
all 5). `eslint --no-inline-config` over them gives 5 files, 0 errors, 0
warnings. `parserOptions.project` and `projectService` are unset for all
5, so no type-aware rule runs and no untouched file's verdict can move.

## Docs

No hand-written `content/docs/**` page states how the analytics routes
treat field permissions. `permissions/authorization.mdx` states the
engine's field guard in general terms and stays true;
`permissions/index.mdx` names the analytics row-scope bridge only.

## Acceptance notes

- NOT MEASURED: a live PostgreSQL cell. This package's test matrix runs
no live SQL server, and the gate answers before a strategy is chosen and
before any statement is compiled, so the dialect does not enter the
verdict.
- NOT MEASURED locally: the full `@objectstack/rest` `local` project and
the whole-workspace typecheck. Left to CI.
- The gate is exactly as wide as `getReadableFields`, the published
reader. Where that reader and the engine's own field guard differ, the
gate follows the reader; the difference is reported separately, as a
class, in the dev report. (Reworded by the `domain:services` seat under
the lane's disclosure discipline.)
- A refusal names the field an authored alias resolves to, as the
engine's refusal on the ObjectQL strategy already did for the same
query.
- An inferred cube's relationship path whose relationship field is not
named after its target object names no object the security service
knows, so none of its fields is judged. Neither strategy reads the
related object there: the native statement fails and the ObjectQL
strategy refuses, both unchanged.
- `MemoryAnalyticsService` (driver-memory's own cube face) is not
touched and not measured.
- PR objectstack-ai#20916 (objectstack-ai#20887, the nested-relation form) holds
`analytics-service.ts` and both strategies. It merges `main` after this
lands, and the gate runs ahead of its nested-relation decline.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ch fields a caller may query on, and the analytics field gate refuses a masked field as a group or filter member (objectstack-ai#20935) (objectstack-ai#20955)

Closes objectstack-ai#20935
Clause-②: yes (narrowing)

A field whose `maskingRule` applies to the caller is SERVED, with its
value replaced, so the published read projection
`ISecurityService.getReadableFields` counts it readable. It is not
queryable: the engine refuses it as a group key, an aggregate input, a
filter or a sort key, because each of those gives back what the mask
hides. The analytics field gate from objectstack-ai#20917 was exactly as wide as the
read projection. So a masked-for-this-caller field could be grouped or
filtered by on the native-SQL strategy, which compiles its own
statement. The engine and the ObjectQL strategy refuse the same query.

This PR publishes the missing answer on the security contract,
implements it from the one decision the engine already makes, and has
the analytics gate ask it. The masking rule itself is unchanged, and the
analytics layer does not re-derive it.

## What changed, per surface

- **Contract (`packages/spec/src/contracts/security-service.ts`).**
`ISecurityService` gains an optional `getQueryableFields(object,
context)`. It answers the fields the caller may filter, sort, group or
aggregate by: the exact complement of what the engine's field guards
refuse. It is a subset of `getReadableFields`, and the two differ by
exactly the fields the caller is served masked. It has the same two
empty answers as its siblings: `undefined` is no answer, `[]` is none. A
system context gets every field.
- Its absence is **not** soft. A consumer that cannot get this answer
(the method is missing, or it answered `undefined`) must treat every
field that declares a `maskingRule` as not queryable, whoever the caller
is. Falling back to the read projection alone would admit exactly the
masked fields.
- **Implementation
(`packages/plugins/plugin-security/src/security-plugin.ts`).**
- The engine's predicate guard (step 2.9) and aggregate-input guard
(step 2.5b) each built their "not queryable" map inline. They now call
one private derivation, `computeQueryGuardFieldPerms`: the evaluator
map, the `requiredPermissions` fold, the on-behalf-of delegator
intersection, then every masked-for-this-caller field folded in as
non-queryable.
- `getQueryableFields` reads that same map, so a field is in the answer
if and only if a query naming it passes both guards. The guards refuse
exactly what they refused before.
- It is registered on the `security` service beside `getReadableFields`.
- **Gate
(`packages/services/service-analytics/src/field-read-admission.ts` +
minimal wiring in `analytics-service.ts` and `plugin.ts`).**
- `AnalyticsServiceConfig` gains `getQueryableFields`. The door's one
field gate asks it beside `getReadableFields` for the same objects. A
member is admitted only when both answers carry its field. There is no
second gate and no per-strategy copy.
- A refused member answers `403 PERMISSION_DENIED` in the engine's own
words for that field. Those are the words the engine answers a masked
field with.
- A queryable reader that throws refuses the query, fail-closed and
logged at `error`, the same way the read reader does.
- `AnalyticsServicePlugin` bridges the hook to the `security` service
with the same absent / unusable / usable resolutions as the read half,
plus one more state. When a usable service predates the method, or
answers `undefined`, the bridge **fails closed**: the read projection
less every field whose declaration carries a `maskingRule`, for every
caller. That over-refuses a caller for whom the rule is lifted, which is
the safe direction. It is also the only one available, because deciding
for whom a rule is lifted would be a second copy of the masking rule.
- A host that constructs `AnalyticsService` itself with
`getReadableFields` and without `getQueryableFields` is warned once at
construction.

## Why the PR line reads `yes (widening)`, and the analytics changeset
declares a narrowing

The line above is the claim's, as corrected under the seat's review
adoption `5921385686` (`scripts/pm/clause2-line.mjs:93`: a diff that
widens one surface and narrows another is spelled `yes (narrowing)`).
The diff does widen the public surface: one optional contract member,
its implementation on the service, and one optional config hook. It also
narrows one accept set. Analytics queries that grouped, aggregated,
filtered or sorted by a field the caller sees masked were answered on
the native-SQL strategy (and printed by the SQL echo on both
strategies), and they are now refused. That narrowing is declared where
the level and ADR-0087 gates read it: in
`.changeset/20935-analytics-masked-field-not-queryable.md`, which
carries the `**BREAKING**` banner, its own `Clause-②: yes (narrowing)`
line and an ADR-0087 `not-required (no-migration-prescription)`
disposition, as objectstack-ai#20917's changeset did. `@objectstack/spec` and
`@objectstack/plugin-security` ship `minor` (widening). Every bumped
package is `minor`, which is what `yes` requires.
`check-changeset-no-major` and `check-adr-0087-registration` both exit
0.

## Pins

- `packages/rest/src/analytics-masked-field-gate.test.ts` covers both
compositions over the real `SecurityPlugin`, `ObjectQL` and `SqlDriver`
(native-SQL first, and ObjectQL only), with synthetic fixtures.
- Two callers read the same fields: a member for whom each field's
masking rule applies, and an unmasker who holds the capability that
lifts it.
- For the member, a masked field as a group member and as a filter
member answers the engine's live refusal for the same field and caller
(code, status and message equal). This holds on the base object, through
an authored alias and through a joined object, on the cube read, the SQL
echo and the dataset door. Neither the engine's aggregate nor its raw
statement runs.
- Controls: the unmasker is answered exactly as the system caller is,
and the member is answered for fields it may query on.
- `packages/plugins/plugin-security/src/get-queryable-fields.test.ts` is
an equivalence. For every field and four positions (filter, sort key,
group key, aggregate input), "the real middleware admitted it" equals
"the field is in `getQueryableFields`". The cases cover a member, the
capability holder, an agent alone and the same agent on behalf of a
delegator. It also pins the contract's answers: masked means readable
and not queryable, the system and no-sets answers, the unresolvable
object, the dangling delegator, and registration on the service.
-
`packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts`
runs the masked positions its cases name (six) through both strategy
paths with nothing executed. It also covers the throwing reader, each
reader judged on its own, the construction warning, and the bridge:
service answer, rule lifted, the fail-closed fallback for a service that
predates the method and for an `undefined` answer, and no security
service.
- `packages/spec/src/contracts/security-service.test.ts` pins the
member's optionality (an unguarded call does not compile), the
masked-readable-not-queryable relation and the two empty answers.

## Ablations, predicted before running, at `20c56c9810`

Each ablation followed the same legs:
1. Mutate through `scripts/ablation-replace.mjs`: the anchor hits once
and the blob moves.
2. Where the suite reads `dist/`, rebuild the package and confirm with
`ablation-dist-preflight` that the marker is in 2 built files.
3. Run the suites.
4. Restore, and prove the blob equals HEAD with an empty `git diff
HEAD`.
5. Rebuild, and confirm with the `--absent` preflight that the marker is
gone from all 6 built files and the tree is clean.

- **A: the gate ignores the queryable answer**
(`field-read-admission.ts`, service-analytics rebuilt).
  - Predicted and observed: route pins 4 red and 18 green.
- Red, on each composition: the cube read and SQL echo, and the dataset
door. Native answered the rows. ObjectQL printed the statement on the
echo and reached the engine on the dataset door.
- Green: the 6 references and controls, and all 12 of objectstack-ai#20917's route
pins.
- Unit pins: 19 red and 53 green. The 5 greens are the "no answer",
construction-warning, rule-lifted and no-security cases, plus all 48 of
objectstack-ai#20917's unit pins.
- **B: plugin-security stops registering `getQueryableFields` on the
service** (plugin-security rebuilt).
- Predicted and observed: route pins 2 red and 8 green. Every member
refusal stays green through the bridge's fail-closed fallback. The
unmasker control goes red on both compositions, because the fallback
over-refuses by design.
  - plugin-security: 1 red (registration on the service) and 9 green.
- **C: the shared derivation stops folding masked fields in**
(src-resolved suite, so no build leg). Observed 8 red and 38 green.
  - The 6 predicted `get-queryable-fields` reds.
- The two pre-existing engine pins in `field-masking-rule.test.ts` (a
filter and an aggregate over a masked field). This shows both engine
guards now read the one derivation.

Commits after `20c56c9810` touched only the bridge's fallback. It no
longer exempts a system caller, which kept a new elevation read site out
of the system-context census. None of the three ablations reaches that
branch.

## Verification, at `fc844c433e` (main merged at `95fed33a20`)

- `@objectstack/service-analytics`: `test` 147 files, 3398 passed, and
`typecheck` 0 (the new test file is in the tsc program: it reported a
tuple error before its fix). This run was taken at `e84882a09f`. The
later edits were re-run: the two gate unit files, 72 passed, plus
`typecheck` 0.
- `@objectstack/plugin-security`: `test` 150 files, 3237 passed and 23
skipped, and `typecheck` 0 including the test layer.
- `@objectstack/spec`: `typecheck` 0, `src/contracts` 45 files and 434
passed, and `check:generated` shows all 15 artifacts up to date.
- `@objectstack/rest`: every `analytics-*` route file (13 files, 175
passed and 3 skipped), and `typecheck` 0 including the test layer. Both
field-gate route files re-ran at `03153e00f1` after the second merge of
main: 22 passed.
- `dispatch-gates --commands` derived 87 commands at `fc844c433e`, with
no stale tree.
  - Commands 1 to 59 exit 0.
- `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: 44
packages have no `dist/` in this worktree). That is NOT MEASURED, not a
pass.
- The remaining commands, the lint narrowing and the `--ran`
reconciliation are recorded in the os-dev-report on objectstack-ai#20935, because this
body is written once.

## Acceptance notes

- NOT MEASURED: a live PostgreSQL cell. The gate answers before a
strategy is chosen and before any statement is compiled.
- The ObjectQL strategy serves no cross-object filter to anyone. So in
the unmasker control, that one case is answered by the strategy's own
refusal, identically for the system caller. That limitation is unchanged
here.
- `MemoryAnalyticsService` (driver-memory's cube face) is neither
touched nor measured.
- The bridge's fallback reads masking declarations from the data
engine's registered schema, the registry the security service reads
first. An object only the metadata service knows contributes no
declarations to the fallback. This affects only a security service that
predates the member.
- Other consumers of `getReadableFields` are not changed here. Only the
analytics door uses it to admit query positions.
- objectstack-ai#20933 (relationship-path object admission) is not addressed here.
- PR objectstack-ai#20916 also edits `analytics-service.ts`. The wiring here is kept
to the config member, the stored provider, the construction warning and
one extra argument at the one gate call site.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… to the SQL echo on either strategy, and anchor the nested-relation note to its measured base (objectstack-ai#21012)

Part of objectstack-ai#20917
Clause-②: no
Part of objectstack-ai#20933 · Part of objectstack-ai#20887

A follow-up on three landed cards. It corrects one sentence in each of
three release notes of `@objectstack/service-analytics` that are still
pending on `main`, before release PR objectstack-ai#20639 consumes them. The order is
this lane's dispatch note on objectstack-ai#20917. The reasons are the `domain:spec`
seat's pointer `5922364600` on objectstack-ai#6021 (two banners) and the named
follow-up in section ② of the delta review `5922352898` on PR objectstack-ai#20916
(one paragraph). PR objectstack-ai#20991 is the precedent: it makes the same banner
correction to objectstack-ai#20935's note, and that note is not touched here. No code,
test or other file changes.

## The three sentences

| Note | Before | After |
|:---|:---|:---|
| `.changeset/20917-analytics-field-permission-gate.md`, the BREAKING
banner | "BREAKING for analytics queries on a SQL deployment that read a
field the caller may not read." | "BREAKING for analytics queries that
read a field the caller may not read: on a SQL deployment, and on `POST
/api/v1/analytics/sql` whichever strategy serves the cube." |
| `.changeset/20933-analytics-relationship-path-admission.md`, the
BREAKING banner | "BREAKING for analytics queries on a SQL deployment
that read a related object through a relationship path the cube does not
declare." | "BREAKING for analytics queries that read a related object
through a relationship path the cube does not declare: on a SQL
deployment, and on `POST /api/v1/analytics/sql` whichever strategy
serves the cube." |
| `.changeset/20887-analytics-nested-relation-engine-answer.md`, the
first sentence of **Why** | "Measured on the base over one fixture with
the real security layer (…)." | "Measured on the base before the
field-level gate (objectstack-ai#20917) and the relationship-path admission (objectstack-ai#20933)
landed, over one fixture with the real security layer (…)." |

The parenthesis in the third row is unchanged and elided here. Both
banners keep their bold. Everything else in the three files is
byte-identical: front matter, summary line, `Clause-②` line, ADR-0087
disposition marker and every other paragraph. Each file's diff is one
line (+1/−1).

## The readings behind each sentence

### Banner of the objectstack-ai#20917 note

**Before, by source, at `95555e71` (the parent of objectstack-ai#20931's landing
`1571aedc`):**

-
`packages/services/service-analytics/src/analytics-service.ts:2305-2333`:
`generateSql()` runs `callCtx` (2329), resolves a strategy (2330) and
returns that strategy's `generateSql` (2333).
- `analytics-service.ts:1283-1327`: `callCtx` runs the object-level
admission (1308) and the read-scope pre-pass. It has no field-level
gate. No non-test source in the package names `getReadableFields` at
that commit (`git grep` exit 1). The control is the landing `1571aedc`,
where it is named in three files.
- `strategies/objectql-strategy.ts:372-638`: the ObjectQL strategy's
`generateSql` renders the statement from the cube definition and returns
it (638). It makes no engine call, so it never reaches the engine's
field guards. That strategy's `execute()` reaches `engine.aggregate`,
which is why the query door already refused on it.
- So for a field the caller may not read, the echo printed a statement
on the ObjectQL strategy as well.

**After, on `main` at `a5bce40888`:**

- `analytics-service.ts:1453-1515`: `callCtx` runs the field-level gate
(1487) after the object admission (1481).
- `generateSql()` calls `callCtx` before it resolves a strategy (2580).

### Banner of the objectstack-ai#20933 note

**Before, by source, at `83480c6a` (the parent of objectstack-ai#20962's landing
`5f6b63a6`):**

- `analytics-service.ts:1452-1507`: `callCtx` admits objects over
`queryObjects` (1477). That set is `cubeObjects` (1584-1588, 1596-1608):
the base object and the declared joins only. An object reached through
an undeclared relationship path is not in it.
- The field-level gate (1483) asks the security service's
`getReadableFields`. That answer is field-level only and never asks
about object-level read
(`packages/plugins/plugin-security/src/security-plugin.ts:5410-5412`,
`computeReadableFields` 5440-5461 over `resolveProjectionFieldMask` 5517
ff.). Object-level read is the separate `canReadObject` (5641). So the
field gate does not refuse a readable field on an unreadable related
object.
- `objectql-strategy.ts` is byte-identical at `95555e71`, `83480c6a` and
`5f6b63a6` (`git diff --quiet`, exit 0 for both ranges).
- Its `generateSql` passes a one-hop cross-object dimension through
`planCrossObject` (895 ff.). That function throws only for the
out-of-envelope shapes: a cross-object time dimension, measure or
filter, a multi-hop dimension, a non-recombinable measure. The one-hop
dimension renders a LEFT JOIN (461-466), and the statement is returned
(638).
- So on the ObjectQL strategy the echo printed a statement for a one-hop
dimension through a related object the caller may not read.

**After, on `main` at `a5bce40888`:**

- `queryObjects` (1607-1616) adds every object a named member reads.
- The admission at 1481 covers that set before `generateSql()` resolves
a strategy (2580).

### Measured after, on both strategies

The measurement was a scratch probe at `a5bce40888`, copied into
`packages/rest/src` for one run and removed by an EXIT trap. It was
never committed; `git status --porcelain` was empty afterwards.

- **Build:** the probe's dependency closure was built under
`os-verify-lock.sh` first: `turbo run build --concurrency=1` over
`@objectstack/service-analytics...`, `@objectstack/plugin-security...`,
`@objectstack/objectql...` and `@objectstack/driver-sql...`. That is 19
tasks, `VERDICT command-exit 0`.
- **Run:** `pnpm --filter @objectstack/rest exec vitest run
--maxWorkers=2` on the one file gave 1 file / 2 tests passed, `VERDICT
command-exit 0`.
- **Fixture:** the composition is the shipped one, with the real
security layer: `SecurityPlugin` over `ObjectQL` on `SqlDriver`
(SQLite), and `AnalyticsServicePlugin` over the same engine. There are
two compositions: `native` (the plugin's own capabilities) and
`objectql` (narrowed to the engine-aggregate path).
- **What was asked:** each query body first passed the door's own body
schema (`AnalyticsQueryRequestSchema`). It then went to `generateSql`,
the call `POST /api/v1/analytics/sql` makes
(`packages/runtime/src/domains/analytics.ts:141-159`). The thrown
`status` is the HTTP status
(`packages/runtime/src/dispatcher-plugin.ts:646-649`).

| Question, inferred cube | Member caller, `native` | Member caller,
`objectql` | System caller, either |
|:---|:---|:---|:---|
| A field the member may not read, grouped | `403 PERMISSION_DENIED` |
`403 PERMISSION_DENIED` | statement printed, by the composition's
strategy |
| The same field, filtered | `403 PERMISSION_DENIED` | `403
PERMISSION_DENIED` | statement printed |
| A one-hop dimension through a related object the member may not read,
path not declared | `403 PERMISSION_DENIED` | `403 PERMISSION_DENIED` |
statement printed |
| Control: a readable field | statement printed (`NativeSQLStrategy`) |
statement printed (`ObjectQLStrategy`) | statement printed |
| Control: a readable related object, path not declared | statement
printed (`NativeSQLStrategy`) | statement printed (`ObjectQLStrategy`) |
statement printed |

Before and after both hold for both banners. So each banner is scoped
the way PR objectstack-ai#20991 scoped objectstack-ai#20935's. The note's own pins in
`packages/rest/src/analytics-field-permission-gate.test.ts` and
`analytics-relationship-path-admission.test.ts` assert the same echo
refusals on both compositions. The probe adds the system-caller and
readable controls, and it ran the bodies through the door's schema.

### The objectstack-ai#20887 **Why** paragraph

The dev measured on the branch point of PR objectstack-ai#20916, `00a92e18da`. That is
the parent of its first commit, `5687276296`.

**The base predates both gates.** `git merge-base --is-ancestor
00a92e1 X` exits 0 for X = `95555e71`, `1571aedc`, `83480c6a` and
`5f6b63a6`, so both landings descend from it. The reverse, `1571aedc`
against `00a92e18da`, exits 1. Its control leg, `00a92e18da~200` against
`00a92e18da`, exits 0, and the repository is not shallow.

**Clause one, "answered rows for a condition on a field the caller
cannot read", held on that base:**

- No non-test source in the package names `getReadableFields` (`git
grep` exit 1; control: six hits in `analytics-service.ts` on `main`).
- `callCtx` (`analytics-service.ts:1283` ff. at `00a92e18da`) runs only
the object admission (1308) and the read-scope pre-pass.
- The base filter normalizer flattened the form to a dotted member
(`strategies/filter-normalizer.ts:1296-1299`). The native strategy
joined the declared include.
- The dev's first report (`5916988260` on objectstack-ai#20887) records the measured
rows.

**Clause two, "without the declared join it named a table that does not
exist (500)", held on that base:**

- `strategies/native-sql-strategy.ts:771-783` falls back to the
relationship name as the joined table when no join is declared.
- The join allowlist applies to a compiled dataset only. An inferred
cube has none (`analytics-service.ts:1266-1268`;
`native-sql-strategy.ts:575-576`).
- The object admission covered the base object and the declared joins
only (`analytics-service.ts:1404`, `1416` ff.). So nothing refused
before the statement ran.

The paragraph is anchored to that base in the delta review's own words;
the clauses are kept.

### The ADR-0087 gate's reading

`breakingDeclaration` and `readDisposition` were re-run on each note at
`a5bce40888` and at this head. All three notes read the same on both:

- `breaking: true`;
- the signals `BREAKING`, `bang` and `clause-②-narrowing`;
- the disposition `not-required (no-migration-prescription)`.

The gate itself counts the three as inherited, not introduced: it skips
a changeset already breaking at the branch point.

## Changeset gate: no `skip-changeset`, and `Check Changeset` stays red

This PR edits three pending changesets and adds none, so `Check
Changeset` goes red by design. `check-empty-changeset.mjs --base
origin/main` exits 1 and refuses all three files as the DELIBERATE
CORRECTION class. Ruling D on objectstack-ai#18375 says `skip-changeset` is never
applied to a PR that edits an existing changeset, so no label is
applied. `Check Changeset` is not a required context.

**Confirmation:** this lane's at-tier contract review record on this
PR's head judges each rewritten sentence above. The sentences are:

1. the objectstack-ai#20917 note's banner, now scoped to include `POST
/api/v1/analytics/sql` on either strategy;
2. the objectstack-ai#20933 note's banner, scoped the same way;
3. the first sentence of the objectstack-ai#20887 note's **Why**, now anchored to the
base before the field-level gate and the relationship-path admission
landed.

`Clause-②: no` was checked against `scripts/pm/clause2-line.mjs`. A
wording edit to pending notes widens no accept set and adds no public
surface, so the value is `no`. With no arm, the line declares no
direction.

## Verification at `56fc0e77e3`

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (exit 0) derived 19 commands from the three
changed paths. Each ran on this head, with its exit code captured before
any pipe.

- **18 exit 0:**
- `check-adr-0087-registration.mjs --base origin/main` and `--self-test`
  - `check-changeset-no-major.mjs --base origin/main` and `--self-test`
  - `check-closing-keyword-parity.mjs` and `--self-test`
  - `check-comment-mask-corpus.mjs`
  - `check-empty-changeset.mjs --self-test`
  - `pm/release-rehearsal-clone.mjs --self-test`
- `check:changeset-gate-self-tests`, `check:driver-memory-census`,
`check:gitlink-declared`, `check:nul-bytes`, `check:objectui-changeset`,
`check:pm-changeset-deadline-census`, `check:published-files`,
`check:refd-timer-probe`, `check:watch-hint-literal`
- **1 exit 1, by design:** `check-empty-changeset.mjs --base
origin/main`, the DELIBERATE CORRECTION class above.
- **Roster family:** `check-changeset-fixed.mjs` (its roster lives under
`.changeset/`) exited 0.
- **Reconciliation:** `dispatch-gates.mjs --ran` with the recorded exit
codes exited 0. It read "19 derived, 19 run, 0 NOT-MEASURED, 0 UNRUN".
- **Main moved:** `origin/main` gained `f8178ffece` after the branch
point. It touches none of the three notes, and all three are still
present there.
- **NOT MEASURED, by design:** the type-check lanes and the package test
suites. The diff touches no TypeScript and no package source.

## Acceptance notes

- **Wording noted, not changed (corrections only, no new claims).**
- The objectstack-ai#20917 note's **What changed** ends: "the ObjectQL strategy and
the data API already refused them". Its ADR-0087 marker says the engine
"already refuses" such queries "on the ObjectQL strategy". Both hold for
the query and dataset doors, not for the SQL echo, which printed on that
strategy (reading above). The corrected banner carries the echo's scope.
- The objectstack-ai#20933 note's **Refusals that change form** says: "On the ObjectQL
strategy a related object the caller may not read was already refused".
That holds for the query door. On the echo, a one-hop dimension through
such an object printed (reading above). The corrected banner carries the
echo's scope.
- **Where the probe measured.** It measured the service call the door
relays, not the mounted HTTP route: identity resolution needs an auth
composition the probe did not boot. The relay and the status mapping are
cited at file:line above.
- **Not in this PR:** objectstack-ai#20935's note. PR objectstack-ai#20991 holds it.
- **Disclosure discipline holds.** No request recipe, field spelling or
returned value appears here.

## Patch rounds (the seat's append from the dev's report on objectstack-ai#20917; the
dev writes a body only once)

### Patch round 1

At `db64c37690`, on the seat's note `5923016038` on objectstack-ai#20917, which takes
the dev's class (a) finding into this PR. Two sentences beside the
corrected banners are qualified to the doors they hold for. Nothing else
changes: each is still one sentence, rewrapped in place, and both
ADR-0087 disposition markers are byte-identical.

| Note | Before | After |
|:---|:---|:---|
| `.changeset/20917-analytics-field-permission-gate.md`, **What
changed**, last sentence | "The native-SQL strategy, the one a SQL
driver serves first, answered such queries; the ObjectQL strategy and
the data API already refused them." | "The native-SQL strategy, the one
a SQL driver serves first, answered such queries; the ObjectQL strategy
already refused them on `POST /api/v1/analytics/query` and `POST
/api/v1/analytics/dataset/query`, as the data API did, but printed the
statement on `POST /api/v1/analytics/sql`." |
| `.changeset/20933-analytics-relationship-path-admission.md`,
**Refusals that change form**, first sentence | "On the ObjectQL
strategy a related object the caller may not read was already refused;
it now answers the analytics door's refusal rather than the engine's,
the same one a declared join gets." | "On the ObjectQL strategy a
related object the caller may not read was already refused on `POST
/api/v1/analytics/query` and `POST /api/v1/analytics/dataset/query`,
though `POST /api/v1/analytics/sql` printed the statement; on those two
doors it now answers the analytics door's refusal rather than the
engine's, the same one a declared join gets." |

**Readings:**

- **objectstack-ai#20917's sentence, at `95555e71`.** On the query door, the ObjectQL
strategy's `execute()` reaches the engine (`objectql-strategy.ts:301`)
before it renders its own echo (350-354). The dataset door runs the same
`execute()`, so both refused through the engine. The SQL echo's
`generateSql` (372-638) renders with no engine call, and `callCtx` had
no field gate (`analytics-service.ts:1283-1327`). So `POST
/api/v1/analytics/sql` printed the statement.
- **objectstack-ai#20933's sentence, at `83480c6a`.** On the query and dataset doors,
a one-hop dimension through a related object goes through
`executeCrossObject`. Its `resolveFkAttr` (1176 ff.) reads that object
through the engine as the caller (1211-1215). The echo renders the join
(461-466) and returns the statement (638), with no engine call, and the
object was outside the admitted set (`analytics-service.ts:1584-1608`).
After the change, on `main`, the echo answers `403 PERMISSION_DENIED` on
both strategies (round 0's probe).

**ADR-0087 reading at `db64c37690`:** for both notes,
`breakingDeclaration` reads `breaking: true` with the signals
`BREAKING`, `bang` and `clause-②-narrowing`, and `readDisposition` reads
`not-required (no-migration-prescription)`. Both are unchanged from
`a5bce40888`, and the objectstack-ai#20887 note reads the same.

**Gates at `db64c37690`:**
- `dispatch-gates.mjs --commands` derived the same 19 commands. 18
exited 0. `check-empty-changeset.mjs --base origin/main` exited 1 by
design: the DELIBERATE CORRECTION class, with all three notes named.
- The roster gate `check-changeset-fixed.mjs` exited 0.
- `--ran`: 19 derived, 19 run, 0 not measured.
- The derivation was repeated at `origin/main` `8055ff2279`, which had
changed one roster file, and gave the same 19 commands. None of the
three notes changed on `main` over that range.

**Acceptance note, wording kept:** both ADR-0087 disposition markers are
HTML comments, and they stay byte-identical. The objectstack-ai#20917 marker still
says the engine "already refuses" such queries "on the ObjectQL
strategy". Like the two sentences above, that wording holds for the
query and dataset doors, not for the SQL echo.

---
_Generated by [Claude
Code](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/xl tests tooling

Projects

None yet

2 participants