Repository navigation
fix(service-analytics)!: a relationship-path hop with no declared join reads the object its lookup field declares (#20986) - #21088
Conversation
…eads, red A relationship-path hop the cube declares no join for must read the object its lookup field declares as its reference, on both strategies and at every reader: the door's admission, read scope and field gate, the native join and the scope applied to it, and the engine-aggregate strategy's FK-expand. Committed red against the base tree: unit 24 failed / 4 passed (the controls), route 8 failed / 2 passed (the controls). Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…its lookup field declares An inferred cube declares no join, so each relationship-path hop fell back to its alias, the lookup field's own name. For a lookup named differently from its target that names no object: the door admitted and refused the field's name as if it were one, and the strategies joined and read a table of that name. One resolver (hop-object.ts) now names a hop's object in three tiers: the cube's declared join, else the field's declared reference (the host's relationshipResolver), else the alias. The door's field gate and its admitted and scoped set read it, and both strategies read it through the context's relationshipReference, the same function. The native strategy records the object on each registered join and scopes the alias as that object; the engine-aggregate strategy plans and reads its FK-expand from it. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…n reads its declared target Clause-② measured yes (narrowing): a refused dotted path through a lookup named differently from its target is answered, and a lookup whose name is also another object's name now reads its declared target instead of that object, so a caller who may not read the target is refused where the query used to be answered. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 3 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 756c4855ba9ca77d8b107edff564c96fdc4094a4 && git checkout 756c4855ba9ca77d8b107edff564c96fdc4094a4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 94608a7d72ecef7bf61d10dbab3bd80aea311055 e75208968e97624116a8f519fd3eec3e5828095e && git checkout -B drift-repro 94608a7d72ecef7bf61d10dbab3bd80aea311055 && git merge --no-ff e75208968e97624116a8f519fd3eec3e5828095e
node scripts/docs-audit/affected-docs.mjs --json 94608a7d72ecef7bf61d10dbab3bd80aea311055
|
Contract reviewServed-tier: PR #21088 (card #20986), ① Derived judgmentsThe one resolver, and who reads it — right.
The admission/join divergence — the Accept-set changes the diff implies, each judged against the diff and the pins:
Public surface — nothing moves. The four wiring spots the seat accepted as an amendment — the constructor's Pins (judged on content; CI's Check-runs on the head: 34 — 31 success, 3 skipped ( ② Semver level
③ Boundary flags
Deviations declared by the dev (5925534567), each answered:
Out-of-scope findings (each
One observation from the diff, for the next reader and NOT a flag on this verdict: native's bare-column qualification ( Nothing escalated. No Implemented-by: VERDICT: PASS |
…easure by its column's declaration on the object the path reaches (objectstack-ai#21230) Fixes objectstack-ai#21129 Clause-②: no (narrowing) ## What changed A configured cube measure whose `sql` is a relationship path (`account.name`) is now judged, described and presented by the declaration on the object the path's last hop reaches, exactly as a measure over the cube's own column already was (objectstack-ai#21044). - **One column, located once** (`analytics-service.ts`). `declaredMeasureColumn` locates a relationship-path column on the object its last hop reaches through `columnObjectOf` (`hop-object.ts`, PR objectstack-ai#21088, consumed unchanged), with the service's `hopReference`: the cube's declared join, else the relationship field's declared `reference`, else the alias. That is the answer the field gate admits the path with and both strategies join it by. ⛔ No second path walk. - **The door** (`cube-measure-field-type-door.ts`). `assertCubeMeasureFieldTypesAccepted` now takes the located column (`MeasureColumn`: object, column, path) and asks `isAggregateCompatibleWithFieldType` with that object's declaration. `field` on the error is the path as the measure spells it, and `object` is the related object that declares the column: the convention the objectstack-ai#20912 door already uses for a joined dimension. The words for a base-object column are byte-identical to before. - **The result type** (`withMeasureResultTypes`). It reads the same `declaredMeasureColumn`, so a relationship-path `min` / `max` over a temporal column is described `time`, as a base-object one is. - **The native presenter** (`strategies/native-sql-strategy.ts`). A new module helper, `measureColumnOf`, is read by the presenter and by the aggregand operand policy. The policy is the same code, moved onto the helper with unchanged behaviour. A relationship-path `min` / `max` over a numeric column now goes through `presentAsNumber`, as a base-object one does. ## Why "refused" rather than "presented as text" (dispatch Zone 2, item 2) The base-object door's answer for a base `text` `max` is a refusal on both faces, and following it needs no new surface. The ObjectQL face already refused the related pair as a cross-object measure. The native face now refuses it through the same door, so the two faces give one envelope. "Presented as text" would need two things. First, the ObjectQL face would have to serve cross-object measures, a widening and so a Clause-② question. Second, `fields[]` would need a new word for a text measure. It would also disagree with the base-object door on the same declared type. ## Measured: `POST /api/v1/analytics/query` on the real dispatcher route Setup: `AnalyticsServicePlugin` over a real `ObjectQL` engine and `SqlDriver`, the cube handed in as `cubes`, and a signed-in caller. The cube is over `deal` with a declared join `account` (`name` text, `revenue` number, `opened_at` datetime, `tier` select). Readings were taken before at `c6b6889193` (base) and after with this branch's `service-analytics` build. There are 40 cells (2 drivers x 2 faces x 10 measures): 20 changed and 20 are identical. The scratch probe was deleted. | measure `sql` | face | SQLite before → after | PostgreSQL 16.14 before → after | |:--|:--|:--|:--| | `max` / `min` over `account.name` (text) | native | 200 `"zeta"` / `"alpha"`, `fields[]` number → **400 `INVALID_FIELD`** | the same → **400** | | `max` over `account.tier` (select) | native | 200 `"b"`, number → **400** | the same → **400** | | `sum` over `account.name` | native | 200 `0` → **400** | 500 `DATABASE_ERROR` → **400** | | `max` / `min` over `account.revenue` (number) | native | 200 `250` / `100` → unchanged | 200 `"250.000000000000000000000000000000"` (string) → **200 `250` (number)** | | `max` over `account.opened_at` (datetime) | native | 200 instant, `fields[]` number → **`time`** | the same → **`time`** | | the text / select / sum-of-text rows above | ObjectQL | 400 `INVALID_FIELD` (cross-object refusal) → 400 `INVALID_FIELD` (this door, with `field` / `object`) | the same | | the number / datetime rows above | ObjectQL | 400 cross-object refusal → unchanged | unchanged | | controls: `max` over `note` (base text), `max` over `amount` (base number) | both | 400 / 200 `32` → unchanged | unchanged | ## Pins New file: `packages/services/service-analytics/src/__tests__/cube-measure-relationship-path-type.test.ts`. It uses the plugin's own composition over a real engine, on a SQLite cell and a PostgreSQL cell (a named skip without `OS_TEST_POSTGRES_URL`), and both faces. It has 13 tests: 6 per cell and 1 more. - Every refused relationship-path pair is `INVALID_FIELD` / 400 on both faces, with nothing read. The checked fields are `code`, `status`, `member`, `param`, `cube`, `field` (the path) and `object` (the related object), and the raw-SQL and engine-aggregate counters stay at 0. The two faces' envelopes must be equal. The ObjectQL face's own cross-object refusal carries no `field` / `object`, so equality proves the door answered and not two refusals that happen to agree. The pairs include `max` over `owner.email`, where `owner` is a lookup the cube declares no join for. Only the field's declared `reference` reaches `os21129_person` (tier 2). - Native: a related numeric `min` / `max` is a JS number typed `number` (`250`, `100`, `41`), and a related temporal `max` is typed `time`. - The dry-run door refuses what the query door refuses. - Controls: a base text `max` is refused the same way, and a base number `max` answers `32` on both faces. - Cannot answer, do not block: a host whose field metadata does not describe the related object gets no verdict, and the statement runs. ## Ablations (from committed `37196767`; predictions written before each run) The tests import the subject by relative path (`../analytics-service.js`, `../plugin.js`), so each run reads `src`. There is no `dist` leg. Every mutation went through `scripts/ablation-replace.mjs` in WRAP mode, with an outer `trap` restore on `EXIT INT TERM` against the absolute path. | ablation | mutation | predicted | observed | |:--|:--|:--|:--| | A1 | `declaredMeasureColumn`: a relationship path answers `column: null` (the old stand-down) | 7 red: refused-pair, temporal `time` and dry-run on each cell, plus the dataset query-time refusal case | **7 failed / 32 passed**, e.g. `native max_acct_name must not be served`, `expected 'number' to be 'time'` | | A2 | presenter: `target = { object: objectName, field: measure.sql }` (the old base-object lookup) | 1 red: the PostgreSQL numeric case; SQLite answers numbers anyway | **1 failed / 22 passed**: `expected '250.000000000000000000000000000000' to be 250` | | A3 | `columnObjectOf(..., undefined)`: the host's reference answer is dropped | 2 red: the refused-pair test on each cell, at `max_owner_email` | **2 failed / 11 passed**: `native max_owner_email must not be served` | Each mutation landed (anchor 1 → 0, and the blob changed: A1 `34fb71b6048c` → `c55b95c96e8a`, A2 `d7c20d35c3ba` → `aebd3cb1f1fc`, A3 `34fb71b6048c` → `ee3a18cc777f`). Each was restored and proven: the blob equals the HEAD blob, `git diff HEAD` is empty, and porcelain shows 0. ## Fixture triage The full `service-analytics` suite turned up exactly two fixtures that pinned the removed stand-down. Each drove a dataset measure over `account.FIELD` through `queryDataset`, with a `sourceFieldMeta` stub that answered by field name for every object, so the stub described the joined object too. - `aggregate-nontemporal-measure-refusal.test.ts`, tier 2 of "the three cannot-answer tiers": the stub now describes the base object alone, as the comment says, and the case also asserts that the statement ran. - `aggregate-datetime-measure-refusal.test.ts`: the stand-down case gets a base-only hook, the same way. A sibling case keeps the any-object hook and pins the new behaviour: the dataset's compile check still stands down on the dotted field, and its query is refused by the cube door with `INVALID_FIELD` / 400, `field` `account.submitted_at` and `object` `account`, and no statement runs. Consumer radius: no fixture outside `service-analytics` feeds the door a relationship-path `min` / `max` / `sum` / `avg` with field metadata wired. A `git grep` over `packages/rest`, `packages/runtime`, `packages/qa`, `examples`, `packages/cli`, `packages/plugins` and `apps` turned up only dimensions, plus one `rest` dataset measure (`sum` over `account.balance`) whose service wires no `sourceFieldMeta`. ## Docs `content/docs/deployment/validating-metadata.mdx:216-217`. Old: "The analytics service refuses the same pair with `400 DATASET_INVALID` when a query is built; this is the identical verdict," New: "The analytics service refuses the same pair with `400 DATASET_INVALID` when a query is built — or, for a field reached through a relationship path, with `400 INVALID_FIELD` when the query runs, judged on the object the path reaches; this is the identical verdict,". The lint rule on that page resolves relationship paths, and the compile check does not, so the pair is now refused one door later with the member-level code. No other sentence under `content/docs/**` (outside `releases/` and `references/`) speaks about the type of a measure over a related field. ## Open question (not decided here, per the dispatch) The ObjectQL face still refuses a relationship-path pair the table **accepts** (`max` over `account.revenue`) as a cross-object measure: the engine aggregate cannot join. The native face serves it. The two faces now agree on every refused pair and disagree only on this capability, and the refusal names its remedy (run on a native-SQL driver). The four-axis options are in the dev report on objectstack-ai#21129. ## Acceptance notes - **Route pin not added.** The dispatcher relays this door's envelope generically, and `packages/runtime/src/analytics-cube-measure-field-type-door.test.ts` already pins that relay for this door. The route-level readings above were taken through the real route. - **Dataset compile check.** `assertAggregateFieldTypeCompatible` (`dataset-compiler.ts`) still returns early on a dotted field, so such a dataset is refused when its query runs (`INVALID_FIELD`), not at compile (`DATASET_INVALID`). It is refused loudly before anything is read, so this is not a defect, and it is not changed here. - **The changeset's direction.** `Clause-②: no (narrowing)`, as claim revision `5939237083` sets it, in this body and in the changeset: the change narrows the native face's accept set, the same class as objectstack-ai#21044. The changeset also carries a `**BREAKING**` banner and a `!` summary; `check-adr-0087-registration` reads `[BREAKING+bang+clause-②-narrowing]` with an `already-registered` disposition. The package is graded `minor`. - **Same-family observation, reported for the seat to file.** The objectstack-ai#20807 / objectstack-ai#20912 door (`structured-json-dimension-door.ts`, `columnOf`) resolves a relationship path through the cube's declared join only. It reads no relationship field's declared `reference`, so it does not use the one hop resolver. A cube that reaches a related `json` field only through the lookup's `reference` gets 500 `DATABASE_ERROR` on PostgreSQL when grouping by it or taking `count_distinct` over it on the native face. On SQLite it answers 200 (one group per serialized document). The declared-join control answers 400 `INVALID_FIELD`. The measurement is in the dev report. Not touched here: the file is outside this card's surface. ## Local verification (at `17e58d67`, after merging `origin/main` `d6d6e872` and refreshing install and build) - `pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2` with `OS_TEST_POSTGRES_URL` (private PostgreSQL 16.14): **163 files, 3799 passed**. - `pnpm --filter @objectstack/service-analytics typecheck`: exit 0. `tsc --listFiles` lists all three touched test files. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 91 families, all **exit 0**. Two of them (`check:skill-examples`, `check:dual-build-cjs-loads`) first answered exit 3 (PREREQUISITE NOT MET, no `dist/`) and were re-run after a full turbo build. `--ran`: "91 derived famil(ies) accounted for — 91 run, 0 NOT-MEASURED (a DERIVED zero — all 91 recorded an exit code and none of them is 3)". - ESLint, a proven narrowing at `17e58d67`: `eslint --no-inline-config --format json` over the 7 touched `.ts` files reads 7 files, 0 errors, 0 warnings. The population comes from `eslint.config.mjs` (`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus `NEVER_LINTED`). Invariance: the config never enables type-aware linting (no `parserOptions.project` or `projectService`), so this diff cannot move a verdict on an untouched file. - NOT MEASURED: MySQL (no server here), `packages/qa/dogfood` (CI's Dogfood Regression Gate), and the full `rest` / `runtime` suites (CI). --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20986
Clause-②: yes (narrowing)
What this changes
A relationship-path hop the cube declares no join for now reads the object its lookup field DECLARES as its target, the field's
reference. Before, such a hop fell back to its ALIAS, the lookup field's own name. An inferred cube declares no join, so a dotted path through a lookup named differently from its target (ownerreferencing a person object) named no object at all. The door admitted, and refused, the field's name as if it were an object, and the strategies joined and read a table of that name.One resolver,
service-analytics/src/hop-object.ts(resolvePathHops), names a hop's object in three tiers, first answer wins:__. An authored cube that declares its join keeps it;reference, asked of the host'srelationshipResolver.AnalyticsServicePluginalready wires it from the data engine's object schema, throughreferenceCarrierOf, for dataset compilation;Every reader takes its answer from there, with the same function: the door's field gate and its admitted and scoped set (
fieldsOfColumnSql,queryObjects), and both strategies through the context's newrelationshipReference, which the service sets to the very function its own gate uses. There is no second resolution.NativeSQLStrategy: each registered join records the object it reads (StatementJoins), and the read scope applied to the alias is that recorded object's. The join table and the scoped object are one value.ObjectQLStrategy: the cross-object plan and the FK-expand read (and its read scope) take the hop's object from the resolver. A lookup whose declared target is the base object itself, a self-reference such asparent, still plans as a cross-object hop.Per site (H2), file:line on base
8f784959cfand on heade75208968eanalytics-service.tsfieldsOfColumnSql(vianamedQueryFields):428joins?.[alias]?.name, else aliasqueryObjects:1612,assertFieldsReadable:1682):432resolvePathHops; callers:1652,:1722passthis.hopReferenceanalytics-service.tscubeObjects:1634j?.name ?? alias:1674;?? aliasis unreachable,CubeJoin.nameis required by the specnative-sql-strategy.tscrossFieldComparisonInfallback:364cube.joins?.[alias]?.name ?? alias:388; the door always passes its setnative-sql-strategy.tsgenerateSqlread-scope loop:686cube.joins?.[alias]?.name ?? alias:751join.object, the object the join recordednative-sql-strategy.tsqualifyAndRegisterJoin:848cube?.joins?.[alias]?.name ?? alias:904resolvePathHopsnative-sql-strategy.tsresolveStorageTarget:1044cube.joins?.[joinAlias(relPath)]?.name ?? relPath:1104columnObjectOfnative-sql-strategy.tscanHandleexternal decline:169declared join targets:193; see Acceptance notesobjectql-strategy.tsisCrossObjectField:724cube.joins?.[alias]?.name ?? alias:736resolvePathHopsobjectql-strategy.tsplanCrossObjectcross dimension:1054refObject: cube.joins?.[alias]?.name ?? aliasexecuteAggregateand itsgetReadScope):1070resolvePathHopsobjectql-strategy.tsresolveStorageTarget:1456... ?? relPath:1478columnObjectOfstructured-json-dimension-door.tscolumnOf:192declared join only, stands down otherwisedataset-compiler.ts:648relationshipResolverperincludeH3, where the declared reference is reachable. At the door,
AnalyticsServiceConfig.relationshipResolver.plugin.tsanswers it fromengine.getObject(base).fields[rel]for alookupormaster_detailfield, throughreferenceCarrierOf. At the strategies it was not reachable on base:StrategyContextcarried a field's type and value shape (declaredFieldType,declaredValueShape), never its reference. It is now, asDatasetScopedStrategyContext.relationshipReference, which is package-internal. The context type is not exported. The member's one hit in the builtdist/index.d.tsis prose in the doc comment of the privatehopReferencefield, against 19 hits forStrategyContext. A host returning aRelationshipTargetis read by itsobject.H4, the divergence check, both before and after. On base, every site resolved the alias (the reads site resolved a dotted
relPath), so the object admitted and the object joined were the same name. After, the six changed sites callresolvePathHopswith the same function. The native join and the scope applied to it are one recorded value. The unchanged sites read declared joins only (tier 1), which the resolver returns verbatim. No site uses the alias where another uses the reference, so there is nosecuritystop. Measured on the route pin: the security spy showscanReadObjectasked only for the base object and the target.Per row (H1), measured in the shipped composition
Fixture: real
SecurityPluginandAnalyticsServicePluginoverObjectQLon SQLite. A member who may read everything except two objects. The "before" column was read at base8f784959cf. The decoy row's "before" was read on the ablation leg below, whose resolver is the base one. The "after" column was read at the fix commitbf58903995. The member is the caller. At heade75208968e, the two pins re-ran green over these rows: the readable dimension and filter, the unreadable target, the self-reference, and the controls.owner)owner(system caller: 500DATABASE_ERROR)owner(system: 500){ owner: { region } }keeper)keeperparent)parentowner(system: 500)owner(system: 400)INVALID_FIELD, the strategy's own capability refusal, equal to a declared join'skeeperparentClause-②: yes (narrowing), measured. Widening: the first rows move from refused to served. Narrowing: the decoy rows move from answered to403. A lookup whose name is also the name of ANOTHER object used to read that other object's rows, by the ids of the records the field points to. The dispatch expected a plainyes; the measured grammar adds the arm. Line 2 readsdeclared · yes · narrowingthroughscripts/pm/clause2-line.mjs'sreadClause2Line. The changeset isminor, carries the BREAKING banner, and carries its ADR-0087 marker (not-required (no-migration-prescription)).Pins, red first, and the ablation
service-analytics/src/__tests__/hop-object-reference-resolution.test.ts(28 cases), on both strategies. It covers five positions: an inferred dimension, a filter member, a time-dimension window, an authored member over an undeclared relationship, and a two-hop path's second hop. For each, an unreadable target is refused by name before anything runs, on the read and on the echo. It also pins:packages/rest/src/analytics-hop-object-reference.test.ts(10 cases), once per strategy. Every answer is compared with the same question through a DECLARED join and checked absolutely. It covers:f31ca819e2, before the fix atbf58903995: unit 24 failed / 4 passed (the 4 are the controls), route 8 failed / 2 passed (the controls). Every failure sits at the assertion naming the target, never at a reference assertion.scripts/ablation-replace.mjsreplaced tier 2'sconst reference = referenceOf?.(from, field);withconst reference = undefined as string | undefined;: anchor 1 to 0, blob489bc64f35f0toa783c0f62a1c. The package was rebuilt, andablation-dist-preflight --absentfound the marker gone from all 6 built files. The pristine build carried it indist/index.jsanddist/index.cjs, 1 each.489bc64f35f0),git diff HEADis empty, and porcelain is 0. After a rebuild, the preflight found the marker present in 2 built files, and the tree clean. The pins then read unit 28/28, route 10/10.Tests and gates, head
e75208968eEach command below ran under
os-verify-lock, in ONE locked sequential script, on heade75208968e. The script printedgit rev-parse --short HEADat its start and its end, with a clean porcelain both times. Each exit code was captured before any pipe.turbo run build --filter=!@objectstack/docs --concurrency=1, 72 of 72 tasks, exit 0.dispatch-gates --commands --repo objectstack-ai/objectstackderives 62 commands from this diff's 8 paths, the same 62 as on the first merge. All 62 exit 0.dispatch-gates --ran:62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN.check:nul-bytes(OK, 9671 files, no raw control bytes),check:cross-package-test-inputs("29 package(s) read outside themselves, all declared"),check:engine-double-contract,check:dual-build-cjs-loads,check:type-check-debt(no entry above its count),check-adr-0087-registration(theno-migration-prescriptionexemption read from this changeset) andcheck-empty-changeset.check-changeset-no-major's clause-② axis reads the PR payload, so it is NOT APPLICABLE locally and is CI's.check-changeset-fixed,check:authz-resolver,check:error-code-casingandcheck:filter-alias-parity. All exit 0.@objectstack/service-analytics:vitest run --maxWorkers=2: 155 files, 3526 passed, 10 skipped.typecheckexits 0, and--listFilescounts 152 of the 152__tests__files, the unit pin among them, in the program.src/analytics-*.test.tsplusdata-nested-relation-permission.test.ts, 20 files: 243 passed, 8 skipped.@objectstack/resttypecheckexits 0, withcheck:test-typecheckOK; the route pin is intsconfig.test.json's program (--listFiles).envelope-caller-census.test.ts: 20 of 20. Neither pin calls the censused spelling: the pins callservice.query(, neveranalytics.query(..tsfiles (git diff --name-only 2821e9f15b...HEAD). Per eslint's own config,isPathIgnoredis false for all 7, andparserOptions.projectandprojectServiceare unset for all 7.lintFilesgives 7 results, 0 errors, 0 warnings.cff14ac80f), the same union was 62 of 62 exit 0, and the four rosters were green. Analytics was 154 files / 3521 passed, and the rest pins 19 files / 242 passed.Surface
Within the claim's regions:
fieldsOfColumnSqlandqueryObjectsinanalytics-service.ts; the hop-object sites in both strategies; the new module; the pins; and the changeset. ⛔ Not touched: the nativeexecute,buildFilterClause,convertFilter,packages/objectql/src/**orpackages/spec/src/**.Four further lines carry the resolver to the strategies, outside the claim's listed regions:
analytics-service.ts: thehopReferenceclass field,baseCtx.relationshipReference, theassertFieldsReadablecall tonamedQueryFields, and therelationshipResolverconfig doc;strategies/types.ts: one member,relationshipReference, on the package-internal context type.No in-flight sibling PR touches them.
mainwas merged twice, with no rebase:5dbeb7d7b7brought PR #21036 (convertFilter), and2821e9f15bbrought PR #21040 (execute) and PR #21037. Both merges were clean, and the branch delta againstmainstayed the same 8 files.Acceptance notes
400 INVALID_FIELD, as it does through a declared join. The engine serves the nested form there. Lowering the dotted filter onto the nested form for the engine belongs toconvertFilter, outside this claim. The dimension position is served (FK-expand).structured-json-dimension-door.tscolumnOf) judges declared joins only and stands down on a path the cube does not declare. A multi-value or structured-JSON column reached through an undeclared path is not judged there. That was already true for a lookup named after its target, and is now reachable for one named differently. NOT MEASURED. Outside this claim.canHandle) reads declared joins only, so an undeclared hop to a federated object is not declined. That holds for same-named lookups on base, and now for differently named ones. NOT MEASURED: there is no federated fixture.(restricted)on the ObjectQL FK-expand and as null on native. This is pre-existing, and the same for a lookup named after its target.a__b) on the two reads sites, where it was the dotted path. Either spelling names no object.relationshipResolver's warn for a field whosereferenceis not a string is now reachable per query hop, not only at dataset compilation. NOT MEASURED.Generated by Claude Code