Skip to content

fix(service-analytics)!: a relationship-path hop with no declared join reads the object its lookup field declares (#20986) - #21088

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20986-hop-object-resolver
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20986-hop-object-resolver

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 (owner referencing 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:

  1. the cube's declared join at that path, keyed by the path with its dots as __. An authored cube that declares its join keeps it;
  2. the relationship field's declared reference, asked of the host's relationshipResolver. AnalyticsServicePlugin already wires it from the data engine's object schema, through referenceCarrierOf, for dataset compilation;
  3. the alias itself, when the host cannot answer.

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 new relationshipReference, 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 as parent, still plans as a cross-object hop.

Per site (H2), file:line on base 8f784959cf and on head e75208968e

site base role head
analytics-service.ts fieldsOfColumnSql (via namedQueryFields) :428 joins?.[alias]?.name, else alias the field gate, AND the admitted and scoped set (queryObjects :1612, assertFieldsReadable :1682) :432 resolvePathHops; callers :1652, :1722 pass this.hopReference
analytics-service.ts cubeObjects :1634 j?.name ?? alias admits and scopes the DECLARED joins only unchanged :1674; ?? alias is unreachable, CubeJoin.name is required by the spec
native-sql-strategy.ts crossFieldComparisonIn fallback :364 cube.joins?.[alias]?.name ?? alias reads scopes for the decline, declared joins only, only for a context without the door's set unchanged :388; the door always passes its set
native-sql-strategy.ts generateSql read-scope loop :686 cube.joins?.[alias]?.name ?? alias scopes each joined alias :751 join.object, the object the join recorded
native-sql-strategy.ts qualifyAndRegisterJoin :848 cube?.joins?.[alias]?.name ?? alias joins :904 resolvePathHops
native-sql-strategy.ts resolveStorageTarget :1044 cube.joins?.[joinAlias(relPath)]?.name ?? relPath reads (declared type, temporal coercion) :1104 columnObjectOf
native-sql-strategy.ts canHandle external decline :169 declared join targets reads (routing), declared joins only unchanged :193; see Acceptance notes
objectql-strategy.ts isCrossObjectField :724 cube.joins?.[alias]?.name ?? alias reads (plans the FK-expand or the refusal) :736 resolvePathHops
objectql-strategy.ts planCrossObject cross dimension :1054 refObject: cube.joins?.[alias]?.name ?? alias reads and scopes (the FK-expand executeAggregate and its getReadScope) :1070 resolvePathHops
objectql-strategy.ts resolveStorageTarget :1456 ... ?? relPath reads (declared type for the lowering and the echo) :1478 columnObjectOf
structured-json-dimension-door.ts columnOf :192 declared join only, stands down otherwise judges a grouped column's class unchanged; outside this claim, see Acceptance notes
dataset-compiler.ts :648 relationshipResolver per include joins (compiled, declared) unchanged: already tier 2's source, read into tier 1

H3, where the declared reference is reachable. At the door, AnalyticsServiceConfig.relationshipResolver. plugin.ts answers it from engine.getObject(base).fields[rel] for a lookup or master_detail field, through referenceCarrierOf. At the strategies it was not reachable on base: StrategyContext carried a field's type and value shape (declaredFieldType, declaredValueShape), never its reference. It is now, as DatasetScopedStrategyContext.relationshipReference, which is package-internal. The context type is not exported. The member's one hit in the built dist/index.d.ts is prose in the doc comment of the private hopReference field, against 19 hits for StrategyContext. A host returning a RelationshipTarget is read by its object.

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 call resolvePathHops with 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 no security stop. Measured on the route pin: the security spy shows canReadObject asked only for the base object and the target.

Per row (H1), measured in the shipped composition

Fixture: real SecurityPlugin and AnalyticsServicePlugin over ObjectQL on SQLite. A member who may read everything except two objects. The "before" column was read at base 8f784959cf. 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 commit bf58903995. The member is the caller. At head e75208968e, the two pins re-ran green over these rows: the readable dimension and filter, the unreadable target, the self-reference, and the controls.

strategy lookup target position before after
native named differently (owner) readable dimension 403 naming owner (system caller: 500 DATABASE_ERROR) rows, equal to a declared join's
native named differently readable filter member 403 naming owner (system: 500) rows, equal to a declared join's and to the nested form { owner: { region } }
native named differently (keeper) unreadable dimension, filter 403 naming keeper 403 naming the target
native named after its target (control) readable / unreadable dimension rows / 403 naming it unchanged
native self-reference (parent) the base object dimension, filter 403 naming parent rows
native named after ANOTHER object (decoy) unreadable dimension answered from the other object's table 403 naming the target
ObjectQL named differently readable dimension 403 naming owner (system: 500) rows (FK-expand), equal to a declared join's
ObjectQL named differently readable filter member 403 naming owner (system: 400) 400 INVALID_FIELD, the strategy's own capability refusal, equal to a declared join's
ObjectQL named differently unreadable dimension, filter 403 naming keeper 403 naming the target
ObjectQL named after its target (control) readable / unreadable dimension rows / 403 naming it unchanged
ObjectQL self-reference the base object dimension 403 naming parent rows (FK-expand)
ObjectQL named after another object (decoy) unreadable dimension answered from the other object 403 naming the target

Clause-②: yes (narrowing), measured. Widening: the first rows move from refused to served. Narrowing: the decoy rows move from answered to 403. 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 plain yes; the measured grammar adds the arm. Line 2 reads declared · yes · narrowing through scripts/pm/clause2-line.mjs's readClause2Line. The changeset is minor, carries the BREAKING banner, and carries its ADR-0087 marker (not-required (no-migration-prescription)).

Pins, red first, and the ablation

  • Unit pin 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:
    • admission and the read scope ask one set, carrying the target and never the field's name;
    • the field gate judges the column on the target;
    • the native join and its scope name the target;
    • the native strategy asks the target's declared column type;
    • the ObjectQL FK-expand reads the target under its scope;
    • the self-reference on both strategies;
    • and two controls: a lookup named after its target, and a declared join is kept.
  • Route pin 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:
    • readable dimension: rows, with only the base and the target admitted (security spy);
    • readable filter: rows on native, equal to the nested form; ObjectQL's own 400;
    • unreadable dimension and filter: 403 naming the target, on the read and the echo;
    • the control.
  • Red first. Pins committed at f31ca819e2, before the fix at bf58903995: 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.
  • Ablation A1, the resolver falls back to the alias again. Predicted in writing before the run: exactly the red set above.
    • scripts/ablation-replace.mjs replaced tier 2's const reference = referenceOf?.(from, field); with const reference = undefined as string | undefined;: anchor 1 to 0, blob 489bc64f35f0 to a783c0f62a1c. The package was rebuilt, and ablation-dist-preflight --absent found the marker gone from all 6 built files. The pristine build carried it in dist/index.js and dist/index.cjs, 1 each.
    • Result: unit 24 failed / 4 passed, route 8 failed / 2 passed. The failing names are identical, as sorted lists, to the pins-first red set.
    • Restore: by absolute path, under an EXIT/INT/TERM trap. The blob equals the HEAD blob (489bc64f35f0), git diff HEAD is 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 e75208968e

Each command below ran under os-verify-lock, in ONE locked sequential script, on head e75208968e. The script printed git rev-parse --short HEAD at its start and its end, with a clean porcelain both times. Each exit code was captured before any pipe.

  • Build: turbo run build --filter=!@objectstack/docs --concurrency=1, 72 of 72 tasks, exit 0.
  • Gate union: dispatch-gates --commands --repo objectstack-ai/objectstack derives 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.
    • Verdict lines include 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 (the no-migration-prescription exemption read from this changeset) and check-empty-changeset.
    • check-changeset-no-major's clause-② axis reads the PR payload, so it is NOT APPLICABLE locally and is CI's.
  • The four ⛔ roster families the derivation names, run anyway: check-changeset-fixed, check:authz-resolver, check:error-code-casing and check:filter-alias-parity. All exit 0.
  • @objectstack/service-analytics: vitest run --maxWorkers=2: 155 files, 3526 passed, 10 skipped. typecheck exits 0, and --listFiles counts 152 of the 152 __tests__ files, the unit pin among them, in the program.
  • The rest analytics pins: every src/analytics-*.test.ts plus data-nested-relation-permission.test.ts, 20 files: 243 passed, 8 skipped. @objectstack/rest typecheck exits 0, with check:test-typecheck OK; the route pin is in tsconfig.test.json's program (--listFiles).
  • The client census envelope-caller-census.test.ts: 20 of 20. Neither pin calls the censused spelling: the pins call service.query(, never analytics.query(.
  • ESLint, a proven narrowing.
    • The population is the 7 changed .ts files (git diff --name-only 2821e9f15b...HEAD). Per eslint's own config, isPathIgnored is false for all 7, and parserOptions.project and projectService are unset for all 7.
    • lintFiles gives 7 results, 0 errors, 0 warnings.
    • With no type-aware lint, this diff cannot move an untouched file's verdict.
  • On the first merge (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: fieldsOfColumnSql and queryObjects in analytics-service.ts; the hop-object sites in both strategies; the new module; the pins; and the changeset. ⛔ Not touched: the native execute, buildFilterClause, convertFilter, packages/objectql/src/** or packages/spec/src/**.

Four further lines carry the resolver to the strategies, outside the claim's listed regions:

  • analytics-service.ts: the hopReference class field, baseCtx.relationshipReference, the assertFieldsReadable call to namedQueryFields, and the relationshipResolver config doc;
  • strategies/types.ts: one member, relationshipReference, on the package-internal context type.

No in-flight sibling PR touches them. main was merged twice, with no rebase: 5dbeb7d7b7 brought PR #21036 (convertFilter), and 2821e9f15b brought PR #21040 (execute) and PR #21037. Both merges were clean, and the branch delta against main stayed the same 8 files.

Acceptance notes

  • The ObjectQL strategy still refuses a dotted FILTER or time window through any relationship with its own 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 to convertFilter, outside this claim. The dimension position is served (FK-expand).
  • The grouped-column door (structured-json-dimension-door.ts columnOf) 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.
  • The native external-object decline (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.
  • The ObjectQL SQL echo for a self-reference prints an unaliased self-join. It describes a statement that is not the FK-expand the query runs. NOT MEASURED by a pin; it affects the echo only.
  • A null FK buckets as (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 hop no tier answers (a field that declares no reference, or a host with no resolver) reads the alias, as before. For a multi-hop path that fallback is now spelled as the join alias (a__b) on the two reads sites, where it was the dotted path. Either spelling names no object.
  • relationshipResolver's warn for a field whose reference is not a string is now reachable per query hop, not only at dataset compilation. NOT MEASURED.
  • The dataset door with a selection naming an undeclared, differently named path is NOT MEASURED. The route pin covers the cube read and the echo.
  • Drivers. SQLite only. The resolution is computed before any driver.
  • CI is not awaited. These lanes are CI's: the test shards, dogfood, temporal conformance, build core, the workspace type-check lanes, and the wide-population and roster families the derivation lists outside its total.

Generated by Claude Code

claude added 5 commits October 1, 2026 04:21
…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/releases/v14.mdx (via generateSql (symbol, a method of class NativeSQLStrategy; a method of class ObjectQLStrategy))
  • 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), generateSql (symbol, a method of class NativeSQLStrategy; a method of class ObjectQLStrategy))

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
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • 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 94608a7d72ecef7bf61d10dbab3bd80aea311055 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 756c4855ba9ca77d8b107edff564c96fdc4094a4 — the merge of head e75208968e97624116a8f519fd3eec3e5828095e into base 94608a7d72ecef7bf61d10dbab3bd80aea311055, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e75208968e97624116a8f519fd3eec3e5828095e
Local-runs: none

PR #21088 (card #20986), claude/issue-20986-hop-object-resolver onto main. Inputs: the card body and its five comments (triage 5923304034 and its correction 5923774857, the claim 5924439999, the dev report 5925534567, the seat's ACCEPT 5925554949); the PR body and its 8-file list; the net diff of the head against main (merge-base 2821e9f15b, 8 files, +874 / −96); and the 34 check-runs on the head, polled to completion. Not read as conclusions: the dispatch order and the seat's own ACCEPT — every statement below was re-derived from the diff.

① Derived judgments

The one resolver, and who reads it — right. service-analytics/src/hop-object.ts resolvePathHops names a hop's object in three tiers, first answer wins: the cube's declared join keyed by the path with its dots as __; else the relationship field's declared reference, asked of the host; else the alias. Six sites moved onto it, and every one hands it the same three inputs — the same cube (scope.getCube at the door, ctx.getCube in the strategies, one request scope), the same base object (the door's cube.sql.trim(); both strategies' extractObjectName is cube.sql.trim()), and the same reference function (AnalyticsService.hopReference, set once as baseCtx.relationshipReference, read back by relationshipReferenceOf(ctx); callCtx spreads baseCtx on both of its branches and is the one builder query() and generateSql() share):

  • the door's field gate and its admitted-and-scoped set: fieldsOfColumnSql through namedQueryFields, read by queryObjects (hence assertReadAdmitted, resolveReadScopes, readScopedObjects) and by assertFieldsReadable;
  • native: qualifyAndRegisterJoin (the joined table, recorded as StatementJoins.object), generateSql's scope loop (reads join.object, the recorded value, never a second resolution), resolveStorageTarget (columnObjectOf);
  • ObjectQL: isCrossObjectField, planCrossObject (refObject, which resolveFkAttr both reads with executeAggregate(refObject) and scopes with getReadScope(refObject)), resolveStorageTarget (columnObjectOf).
    No reader walks a path on its own; the removed joinAlias helper and the three removed ?? alias / ?? relPath fallbacks were the second resolutions. Right.

The admission/join divergence — the security stop — not tripped, on either strategy, including the read scope applied to the hop. Every remaining cube.joins reader at the head, enumerated: analytics-service.ts:1671 cubeObjects (declared joins only; CubeJoinSchema.name is a required string, so its ?? alias cannot run); native-sql-strategy.ts:193 canHandle's external decline (declared joins; routing, names no hop); :387-388 crossFieldComparisonIn's fallback (declared joins; reachable only on a context that carries getReadScope but no readScopedObjects, and callCtx sets both or neither — baseCtx carries no getReadScope — so the door never reaches it); :876 canJoin (qualifies bare columns; names no object); structured-json-dimension-door.ts:192 columnOf (declared joins only, stands down otherwise); dataset-compiler.ts:640-661 (WRITES declared joins from the same relationshipResolver — tier 1's source). Each reads tier 1 verbatim or names nothing; none names an undeclared hop by its alias while a changed site names it by its reference. On native, the join table and the alias's read scope are one recorded value; on ObjectQL, the FK-expand's read and its scope are one refObject. The route pin's security spy pins the door asking canReadObject for the base and the target only. Right.

Accept-set changes the diff implies, each judged against the diff and the pins:

  • Widening — a dotted path through an undeclared hop whose lookup is named differently from its target: refused (403 naming the alias; 500 for a caller the object check passes) → served. Native answers rows for a dimension and a filter member (the filter equal to the nested form); ObjectQL answers the dimension by FK-expand and keeps its own 400 INVALID_FIELD for a cross-object filter, exactly as it does through a declared join. Triage's first branch (5923304034) — dotted paths on inferred cubes map onto the served nested form, not ruled out. Right.
  • Narrowing — a lookup whose name is also ANOTHER object's name: it used to be admitted, scoped and joined as that other object; it now reads its declared target, so an unreadable target moves answered → 403 naming the target. Right, and the correct direction: the old read was of the wrong table, by the ids the field holds.
  • A self-reference (parent → the base object): refused → served; ObjectQL still plans it as a cross-object FK-expand (via === 'reference'), while a join the cube DECLARES onto its own object keeps reading as base, as on base. Right.
  • The control (a lookup named after its target): unchanged — tier 2 names the object tier 3 would have. Right.
  • Tier 3 (no resolver, unknown field, no reference): the alias, as before. On the two resolveStorageTarget sites the multi-hop fallback is now spelled a__b where it read a.b; neither names an object, and a__b is the spelling the door already admitted, so a third spelling went away. Right.
  • Tier 1 (a declared join): unchanged, and the unit pin holds it (DECLARED admits OTHER, never the field's reference). Right.
  • The refusal names the TARGET, on the cube read and on the SQL echo, before any statement or aggregate runs (unit pin: seen.sql and seen.aggregate empty). Right.

Public surface — nothing moves. src/index.ts and package.json are not in the diff. hop-object.ts is not exported. DatasetScopedStrategyContext, which gains the optional relationshipReference, is not exported from the entry and is reachable from no exported signature (assertReadScopeAdmittedByEngine takes it but is not re-exported). AnalyticsServiceConfig.relationshipResolver keeps its type; its docblock gains the second reader — a documented widening of an existing hook's role, not a type change. AnalyticsService.hopReference is private. The changeset's "No export or published type changes" is right.

The four wiring spots the seat accepted as an amendment — the constructor's hopReference field and baseCtx.relationshipReference, the assertFieldsReadable call to namedQueryFields, the relationshipResolver config docblock, and one optional member on the package-internal context type: none changes src/index.ts, package.json or a published type's shape, and without them the strategies would have to resolve a second time — the exact shape the card forbids. Accepting them was right.

Pins (judged on content; CI's Test Core shards are the gate): the route pin boots the shipped composition (SecurityPlugin and AnalyticsServicePlugin over ObjectQL on SQLite), once per strategy, and compares every answer with the same question through a declared join plus an absolute check; the unit pin covers five positions on both strategies (an inferred dimension, a filter member, a time-dimension window, an authored member over an undeclared relationship, a two-hop path's second hop), the one-set admission/scope equality, the field gate on the target, the native join and its scope, the declared-type ask, the ObjectQL FK-expand under scope, the self-reference and the two controls. The red-first and ablation readings are the dev's and were not re-run here. Right.

Check-runs on the head: 34 — 31 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke: path and opt-in filters), 0 failure. All seven required contexts are success: Lint & Repo Gates, TypeScript Type Check, Test Core (and 6 of 6 shards), Dogfood Regression Gate (and 3 of 3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard (the diff touches no governed surface). Check Changeset success.

② Semver level

  • Changeset .changeset/20986-analytics-hop-object-reference.md: @objectstack/service-analytics: minor; summary fix(service-analytics)!:; the **BREAKING** banner; exactly one adr-0087: not-required (no-migration-prescription) … HTML-comment marker; and the declaration line with the narrowing arm in the body (signal 4 of the registration gate's breakingDeclaration). The body carries no FROM → TO rewrite — no arrow, no migration heading, no rewrite table — so the category's statement-against-statement check holds, and the marker closes the other four categories on facts (a published package; no registry id to name; runtime behaviour, not an interface or type surface). Right.
  • Level. The diff widens what a published package serves and narrows one accept set. yes takes at least minor; (narrowing) is breaking, and under the launch-window convention (check-changeset-no-major.mjs: no GA major yet, no .changeset/pre.json at the head) a breaking change ships minor, with the banner and the ADR-0087 disposition as its carriers — both present. minor is right; patch would under-grade a yes, major would be refused by the guard, and skip-changeset is wrong for a diff that publishes. Right.
  • The Clause-② line. PR body line 2 declares yes with the narrowing arm, bare at the start of its own line — a spelling of the closed grammar for a diff that widens AND narrows (the self-tested case in both changeset gates). A bare yes, the dispatch's expectation, would have hidden the narrowing from the registration gate's signal 4; yes (widening) would deny it; no (narrowing) would deny the widening. The dev's correction of the grammar is right, and the changeset body carries the same line.
  • Gate verdicts: Check Changeset success; Lint & Repo Gates, which runs the ADR-0087 registration and empty-changeset gates, success.

③ Boundary flags

open_questions: none declared — nothing to answer.

Deviations declared by the dev (5925534567), each answered:

  1. Surface amendment, four wiring spots — judged in ①: no published surface moves; accepted.
  2. Measured grammar yes (narrowing) where the dispatch expected yes — judged in ②: right; accepted.
  3. Lock holds of 18m38s and 22m57s on the dev's local gate runs — a process note; no bearing on the diff. Noted.
  4. The final-head probe's queue timeout and re-queue, and the PR body's "after" column read at bf58903995 with the head reading recorded identical in the report — the head's pins are CI's verdict here (Test Core success). Noted; no action.

Out-of-scope findings (each carrier: none, NOT MEASURED, in Acceptance notes) — is any a data-exposure class that needs a carrier?

  1. structured-json-dimension-door.ts columnOf stands down on an undeclared path — the door that refuses GROUPING by a structured-JSON or multi-value column. Not an exposure class: object admission, the field gate and the read scope are applied upstream regardless; an unjudged grouped column yields a backend-dependent bucket, never an unadmitted read. Pre-existing for same-named lookups. Acceptance note stands.
  2. NativeSQLStrategy.canHandle's external decline reads declared joins only, so an undeclared hop to a federated object is not declined and native hand-compiles a local LEFT JOIN on the object's name. Not an exposure class: the object the door admitted and scoped and the name the strategy joins are one value; the failure mode is a missing or wrong physical table (a 500, or ADR-0062 D6's "silently wrong") — routing and correctness, pre-existing for same-named lookups, now reachable for differently named ones. No measured reach and no federated fixture, so by the filing gate no card today; a card the day a reach is measured. Acceptance note stands.
  3. The ObjectQL SQL echo for a self-reference prints an unaliased self-join — echo text only, nothing is read by it. Not an exposure class.

One observation from the diff, for the next reader and NOT a flag on this verdict: native's bare-column qualification (qualifyAndRegisterJoin, canJoin) keys on DECLARED joins, so an inferred cube that mixes a dotted path with a bare column both objects carry emits the bare column unqualified beside a LEFT JOIN — a possible ambiguous-column error. Pre-existing for same-named lookups, now reachable for differently named ones; NOT MEASURED; correctness, not exposure; outside this claim.

Nothing escalated. No security stop.

Implemented-by: claude/issue-20986-hop-object-resolver
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 06:06
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 06:06
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 9b81314 Oct 1, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20986-hop-object-resolver branch October 1, 2026 06:27
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants