Skip to content

fix(service-analytics)!: refuse a JSON-stored dimension or count_distinct over a relationship path the cube declares no join for, located through the one hop resolver - #21247

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21232-json-door-hop-resolver
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21232-json-door-hop-resolver

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21232
Clause-②: no (narrowing)

What changed

The structured-JSON door (structured-json-dimension-door.ts) now locates a dotted path's column through the one hop resolver, the way every other reader in the package does.

  • columnOf asks columnObjectOf (hop-object.ts, consumed unchanged) with the host's hop reference. The answer is the cube's declared join at that path, else the relationship field's declared reference, else the alias. That is the object both strategies join and read for the path. Before, columnOf read cube.joins alone and stood down on a path the cube declares no join for. ⛔ No second resolver: the hop walk is hop-object.ts's, as for the measure side in PR fix(service-analytics)!: judge and present a relationship-path cube measure by its column's declaration on the object the path reaches #21230.
  • assertNoStructuredJsonDimension takes one more argument, referenceOf (the HopReference type from hop-object.ts). Its one caller, AnalyticsService.assertDimensionsGroupScalarColumns, passes this.hopReference. That is the same function the field gate, the admitted and scoped set, and both strategies resolve a hop with. The function is internal to the package (not exported from index.ts).
  • The refusal words and the envelope are unchanged. A member over an undeclared-join path now gets the refusal a member over a declared join already got: INVALID_FIELD / 400, member, param, cube, field (the path), object (the object the lookup declares as its target).

Measured: POST /api/v1/analytics/query and /sql on the real dispatcher route

Setup: AnalyticsServicePlugin over a real ObjectQL engine and SqlDriver, a signed-in caller, the real dispatcher-plugin mount, SQLite in memory and a private PostgreSQL 16.14. A configured cube over os21232_deal declares a join for account (hq json, name text) and none for owner. owner is a lookup whose reference is os21232_person (prefs json, labels tags, email text). Before: origin/main at 3a7b6eb0. After: this branch's service-analytics build. There are 80 cells (2 drivers x 2 faces x 2 doors x 10 members). 32 changed and 48 are byte-identical. The scratch probe was deleted.

member face SQLite before → after PostgreSQL 16.14 before → after
dimension owner.prefs (json) / owner.labels (tags) native 200, one group per serialized value → 400 INVALID_FIELD 500 DATABASE_ERROR → 400
the same ObjectQL 400 INVALID_FIELD from the engine, groupBy[1] → 400 from this door, naming the member the same
count_distinct over owner.prefs / owner.labels native 200, 2 / 2 → 400 500 → 400
the same ObjectQL 400, the cross-object refusal (no field / object) → 400 from this door the same
any of the above on /sql (dry run) both 200 (native, and the ObjectQL dimension) → 400 the same
control: the same members over account.hq (declared join) both 400 INVALID_FIELD, this door → unchanged unchanged
control: owner.email dimension both 200 → unchanged unchanged
control: owner.email / account.name count_distinct native / ObjectQL 200 / 400 cross-object refusal → unchanged unchanged

Pins

New file: packages/services/service-analytics/src/__tests__/json-stored-door-undeclared-join.test.ts. It uses the plugin's own composition over a real engine, a SQLite cell and a PostgreSQL cell (a named skip without OS_TEST_POSTGRES_URL), and both faces. It has 10 tests, 5 per cell.

  • A dimension and a count_distinct over owner.prefs / owner.labels are refused INVALID_FIELD / 400 on both faces, with nothing read. The checked fields are code, status, member, param, cube, field (the path) and object (os21232_person), and the raw-SQL and engine-aggregate counters stay at 0. The two faces' envelopes must be equal. Neither face's own refusal carries this envelope, so equality shows the door answered.
  • An ad-hoc query's inferred cube declares no join at all. A dotted dimension on it (owner.prefs) is refused the same way.
  • The dry-run door refuses what the query door refuses.
  • Controls: the same members over the declared join account.hq get the same refusal (object the joined object). The scalar owner.email dimension is served on both faces (one group per owner), and its count_distinct answers 3.

Unit file dimension-structured-json-door.test.ts: a new block with 5 tests. The relationship field's declared reference names the object, on both faces (crm_account, not the alias). The reference wins over an alias that names a described object (the door stands down and the statement joins "crm_account"). A host with no reference resolves the alias, and both doors refuse there. Control: the referenced object's text column is served.

Ablations

The tests import the subject by relative path (../analytics-service.js, ../plugin.js), so each run reads src and 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. Predictions were written before each run. They ran from committed 08ea135b, and git diff 08ea135b b6e64185 -- packages/services/service-analytics is empty. The PostgreSQL cell was live.

ablation mutation predicted observed
A1 columnOf gets back the joins-only resolution (the removed code, byte for byte) 9 red: the undeclared-join, ad-hoc and dry-run tests on each cell, the two reference-tier face tests and the alias-tier test 9 failed / 19 passed
A2 the call site passes undefined in place of this.hopReference 9 red: the same six live tests, the two reference-tier face tests, and "the reference, not the alias" (the alias tier stays green) 9 failed / 19 passed

The prediction's total (30) was an arithmetic slip: 28 tests ran. Each mutation landed: anchor 1 → 0, and the blob changed (A1 04bf4095e712 → db237bdc284e, A2 16d63d721b6e → 5d257e0a59d3). Each was restored and proven: the blob equals the HEAD blob, git diff HEAD is empty, and porcelain shows 0. An earlier pair of runs at 50d5511f, before the ad-hoc pin existed, gave 7 failed / 19 passed for each, as predicted then.

Fixture triage

The full service-analytics suite turned up exactly one fixture that pinned the removed stand-down: dimension-structured-json-door.test.ts, "a dotted path the cube declares no join for is a synthetic traversal … not judged". It pinned the branch this PR deletes, so it was replaced, not respelled. Its stand-down now has an honest reason, a host that describes nothing on the object the hop reaches (describes: null), and the new block above pins the judged tiers.

Consumer radius: the analytics fixtures with dotted members in packages/rest (8 files), packages/runtime (4) and packages/driver-memory (3) were run against this branch's build. They gave 107 passed / 3 skipped, 24 / 10 and 239 / 0, with no failures.

Other readers of cube.joins in service-analytics (dispatch Zone 2, item 2)

No other reader resolves a dotted path through cube.joins alone. Every path-splitting reader goes through resolvePathHops / columnObjectOf. Six sites enumerate cube.joins without splitting a path. They are listed for the seat and not touched here.

  • strategies/native-sql-strategy.ts qualifyAndRegisterJoin, canJoin: it qualifies a bare base column only when the cube declares a join. Measured at b6e64185 (this file is byte-identical to origin/main). Take a configured cube with no declared join and the dimensions note + owner.email, where the lookup's target also declares note. The native face answers 500 DATABASE_ERROR on SQLite ("ambiguous column name: note") and PostgreSQL (42702). The ObjectQL face answers 200. This is reported to the seat as a finding and is not handled in this PR.
  • strategies/native-sql-strategy.ts canHandle, the federated-object decline: it asks isExternalObject of the declared join targets only, not of an object reached through a path with no declared join. Reach not measured.
  • native-sql-strategy.ts (three sites) and analytics-service.ts cubeObjects: these read the declared joins as a fallback for a context built without readScopedObjects, or beside namedQueryFields, which adds the path-reached objects. No defect was found.

Docs

git grep -nE "declares no join|declared join|structured-JSON|structured JSON|count_distinct" over content/docs/**, excluding releases/ and references/, gave 32 hits. None says the door stands down on a path without a declared join, and none speaks about a count_distinct over a related field's JSON-stored column. Positive control: the pattern hits content/docs/deployment/validating-metadata.mdx:228, the dataset-dimension door sentence. That page speaks about datasets, whose dotted fields must traverse a declared include (a declared join), and it stays true. No page edited.

Deviation from the declared file surface

The claim names structured-json-dimension-door.ts (columnOf) and its tests. Routing columnOf through the resolver's reference tier, which is the triage direction, needs the host's HopReference, and only the door's one caller holds it. So analytics-service.ts changes by one argument (this.hopReference) and three docblock lines in assertDimensionsGroupScalarColumns, the door's caller. Nothing else in that file moved. A2 above pins that line.

Verification at b6e64185

The branch head fafbf053 carries a tree byte-identical to b6e64185 (git diff b6e64185 fafbf053 is empty), so every reading below holds for it. b6e64185 has origin/main (ef96c9ed) merged in. Install and a full build were refreshed after the merge, and pnpm --filter @objectstack/spec check:generated reports all 15 artifacts up to date.

  • pnpm --filter @objectstack/service-analytics test: 164 files, 3758 passed / 56 skipped (the live-PostgreSQL cells), 0 failed. typecheck (tsc --noEmit): exit 0.
  • PostgreSQL 16.14 live (OS_TEST_POSTGRES_URL): json-stored-door-undeclared-join, json-stored-door-live-drivers, cube-measure-relationship-path-type, dimension-structured-json-door and multi-value-json-stored-door gave 72 passed / 0 skipped. runtime analytics-json-dimension-door and analytics-cube-measure-field-type-door gave 20 passed / 0 skipped (run at 9a32e950, whose analytics source is the same).
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derives 62 commands, and all 62 exit 0 at b6e64185. --ran reconciliation: "62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN", with every exit code recorded. check:adr-0087-registration was red once, on the changeset's "FROM → TO" label, which the gate reads as a rewrite prescription. The section describes behaviour, not a rewrite, so it was relabelled "Before and after". The gate is now green with not-required (no-migration-prescription), the disposition the door's earlier entries carry.
  • Lint, a declared narrowing: eslint --no-inline-config --format json over the 4 touched .ts files at b6e64185 reports 4 files, 0 errors and 0 warnings. ① All 4 are inside the population eslint.config.mjs lints (packages/**/*.{ts,tsx,mts,cts}; none ignored), and the changeset .md is in no files glob. ② The count of 4 is read from the JSON output. ③ The config enables no type-aware linting (--print-config gives parserOptions {ecmaVersion:'latest', sourceType:'module'}, with no project), so this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint is left to CI.

Acceptance notes

  • Carrier note for release compilation (not filed). The two pending release notes for this door, 20807-analytics-json-dimension-refused.md and 20912-analytics-multi-value-distinct-refused.md, list a dotted path the cube declares no join for as Unchanged. This PR makes that clause untrue, and its own changeset states the reversal. When the CHANGELOG is assembled, the release compiler should drop that clause from both entries. They cannot be corrected from this PR: editing another PR's pending changeset is a foreign-changeset edit that stays refused until a person confirms it (finding: random changeset filenames collide silently across parallel agents — a round overwrote a sibling PR's minor changeset and every gate stayed green #17712).
  • The ObjectQL face's own refusals of these members (the engine's groupBy[1], the cross-object measure refusal) are now unreachable for them, because the door answers first. Neither refusal changes.
  • No route-level pin was added. The dispatcher relays this door's envelope generically, and packages/runtime/src/analytics-json-dimension-door.test.ts already pins that relay for this door. The route-level readings above were taken through the real route.

Generated by Claude Code

claude added 6 commits October 1, 2026 20:39
…the one hop resolver

columnOf read a dotted path's object from cube.joins alone and stood
down on a path the cube declares no join for, while both strategies
joined the lookup's declared reference. It now asks columnObjectOf
(hop-object.ts) with the service's hopReference.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…e resolver's object

Replaces the pin that held the removed stand-down (a dotted path with no
declared join was not judged) with pins for the reference tier, the alias
tier, and the cannot-answer stand-down.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…on both faces and both dialects

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…ed-join path; add the changeset

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
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 5 documentable anchor(s).

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

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

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

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3dc33b2d13a919db611bc077d42337df51ec626d → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 3dc33b2d13a919db611bc077d42337df51ec626d

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

…r Unchanged clauses

The door now judges a dotted path the cube declares no join for, so the
20807 and 20912 entries no longer list it as unchanged, and this PR's
entry no longer needs to say it reverses them.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…s earlier Unchanged clauses"

This reverts commit c0a865b. The 20807 and 20912 entries are other PRs'
pending release notes; correcting them here trips the foreign-changeset
refusal, which needs a person to confirm. The stale clause goes to
release compilation as a carrier note instead, and this PR's changeset
keeps the sentence stating the reversal.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 22:08
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 22:08
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 4727fcb Oct 1, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21232-json-door-hop-resolver branch October 1, 2026 22:29
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… statement joins a relationship path, read from the hop resolver's joins, not cube.joins (objectstack-ai#21266)

Fixes objectstack-ai#21249
Clause-②: no

## What changed

On the native-SQL strategy, a bare base-table column is now qualified
with the base table (`"deal"."note"`) exactly when the statement joins
something. What the statement joins is read from the joins the one hop
resolver (`hop-object.ts`, consumed unchanged) registered for the query.
It is no longer read from whether the cube declares `joins`. ⛔ No new
resolver.

- **`qualifyAndRegisterJoin`** reads `joins.qualifyBaseColumns`, a flag
the statement's `StatementJoins` now carries, instead of `canJoin =
!!cube?.joins && Object.keys(cube.joins).length > 0`.
- **`generateSql`** compiles the query once with bare base columns. When
that compile registered any join, it compiles the query once more with
every base column qualified. The flag cannot be read at call time,
because joins are registered lazily: whichever member walks a
relationship path first registers the join, and an absorbed `$or` takes
back the joins its branches registered. In the card's own pair, `note`
is resolved before `owner.email` registers the join. Ablation A2 below
measures that a call-time read (`joins.size > 0`) leaves the card's pair
broken. The method is split into `compileClauses` (the SELECT / GROUP BY
/ WHERE half, unchanged in place) and `assembleStatement` (the
allowlist, read-scope and assembly half, unchanged in place). Both
compiles walk the same members through the same resolver, so the second
registers the same joins. A statement that joins nothing is compiled
once.
- **`resolveFieldSql`'s fallback** for a filter member the cube does not
declare (`where: { id }`) used to return the bare name. It now goes
through `qualifyAndRegisterJoin` like a declared member. Measured below:
the fallback was the same 500 beside a join.
- Every base-column reference now takes the one rule: the select list,
GROUP BY, WHERE (the `where`, the dataset scope, measure filters),
measures and time-dimension windows. **ORDER BY** emits the member's
output alias (`ORDER BY "note"`), never a table column, so it is not a
qualification site. See the Acceptance notes for ordering by a member
the query does not select.

## Measured: `POST /api/v1/analytics/query` and `/sql` on the real
dispatcher route

Setup: `AnalyticsServicePlugin` over a real `ObjectQL` engine and
`SqlDriver`, a signed-in caller, the real `dispatcher-plugin` mount,
SQLite in memory and a private PostgreSQL 16.14. A configured cube over
`deal` declares **no** join. `owner` is a lookup whose `reference` is
`person`, and `person` also declares `note`, `amount`, `closed_on` and
`id`. A second cube declares the join (the control). Before:
`origin/main` at `4727fcb2`. After: this branch's build, re-taken at the
merge with `origin/main` (`1b176fbf`). The scratch probe was deleted.

| query (no-join cube unless named) | native, SQLite before → after |
native, PostgreSQL 16.14 before → after | ObjectQL face |
|:--|:--|:--|:--|
| dimensions `note` + `owner.email` (the card's pair) | **500** → 200, 3
groups by deal note | **500** (42702 `note`) → 200 | 200 → 200, same
groups |
| the same pair, the dimension declared on the cube (`owner_email`) |
**500** → 200 | **500** → 200 | 200, unchanged |
| the pair with `where: { note: 'x' }` and `order: { note: 'asc' }` |
**500** → 200 | **500** → 200 | 200, unchanged |
| `sum(amount)` by `owner.email` | **500** → 200 (15 / 8) | **500**
(42702 `amount`) → 200 | 200, unchanged |
| `where: { note }` by `owner.email` | **500** → 200 | **500** → 200 |
200, unchanged |
| a `closed_on` time-dimension window by `owner.email` | **500** → 200 |
**500** (42702 `closed_on`) → 200 | 200, unchanged |
| `where: { id: 'd1' }` (undeclared member) by `owner.email` | **500** →
200 | **500** (42702 `id`) → 200 | 200, unchanged |
| ad-hoc query over `deal` (inferred cube), the pair | **500** → 200 |
**500** → 200 | 200, unchanged |
| control: the declared-join cube, the pair | 200 → 200, statement
byte-identical | same | 200, unchanged |
| control: `note` alone, either cube, or an absorbed `$or` over
`owner.email` | 200 → 200 | 200 → 200 | 200, unchanged |

The compiled statement for the card's pair, before: `SELECT note AS
"note", "owner"."email" AS "owner.email", COUNT(*) AS "count" FROM
"deal" LEFT JOIN "person" "owner" ON "deal"."owner" = "owner"."id" GROUP
BY note, "owner"."email"`. After, it is the statement the declared-join
cube always compiled: `SELECT "deal"."note" AS "note", … GROUP BY
"deal"."note", "owner"."email"`.

**The no-path control, stated (dispatch Zone 2, item 3).** A statement
that joins nothing stays **unqualified, as before**, on a cube that
declares no join. On a cube that declares a join, a query that uses none
of it now also compiles bare columns. The `/sql` dry run shows `SELECT
note AS "note", COUNT(*) AS "count" FROM "deal" GROUP BY note`, where
before it showed `"deal"."note"`. The answer is the same on both
drivers: the statement reads one table, so both spellings name one
column. This follows from triage's predicate (what the query joins, not
`cube.joins`). The other way to keep that statement byte-identical is to
keep reading `cube.joins` beside the predicate, which is what triage
rules out. A third reading, qualifying every base column always, was
measured and rejected: it reddens 64 tests in 21 files of this package,
among them `native-sql-rls.test.ts`'s deliberate pin that a
single-object cube keeps bare columns.

## Pins

**New file:**
`packages/services/service-analytics/src/__tests__/native-sql-base-column-qualify.test.ts`.
It uses the plugin's own composition over a real engine, a SQLite cell
and a PostgreSQL cell (a named skip without `OS_TEST_POSTGRES_URL`), and
both faces. It has 12 tests, 6 per cell. Each serve pin checks the rows
on both faces, that the native face answered with one raw statement and
no engine aggregate, and the compiled statement.

1. The card's pair, grouped by the deal's `note`: (x, a, 2) · (x, b, 1)
· (y, b, 1). The person rows carry different `note` / `amount` /
`closed_on` values, so a statement that read the person's column would
answer differently.
2. The pair with a `where` on `note` and an `order` by it.
3. The other emitters beside the join: a measure (`sum(amount)`), a
time-dimension window (`closed_on`), and a member the cube does not
declare (`where: { id }`).
4. A join registered by a LATER member: `dimensions: ['note']` with
`where: { 'owner.email': … }`. The SELECT list is compiled before the
filter joins `owner`. Native face only, because the ObjectQL face
refuses a cross-object filter of its own accord. The reversed pair is
pinned too.
5. CONTROL: the declared-join cube compiles the pair to the exact
statement it compiled on `4727fcb2`, and the no-join cube now compiles
the same statement.
6. CONTROL: a statement that joins nothing keeps bare columns, on either
cube, and when an absorbed `$or` takes its join back.

## Ablations

The tests import the subject by relative path (`../plugin.js`), so each
run reads `src` and 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. They ran from
committed `2a575561`, with the PostgreSQL cell live. Predictions were
written before each run.

| ablation | mutation | predicted | observed |
|:--|:--|:--|:--|
| A1 | `canJoin` put back on `cube.joins` (`joins.qualifyBaseColumns` →
the removed expression) | all 12 red: pins 1–4 on the 500 / bare
statement, pin 5 on "the no-join cube compiles the same statement", pin
6 on the declared cube's bare statement | **12 failed / 0 passed** |
| A2 | the flag read at call time (`joins.qualifyBaseColumns` →
`joins.size > 0`) | 8 red (pins 1, 2, 4, 5 on each cell). Pin 3 stays
green because there the join is registered before the base column. Pin 6
stays green because nothing joins | **8 failed / 4 passed** |
| A3 | `resolveFieldSql`'s fallback back to `return fieldName;` | 2 red
(pin 3 on each cell) | **2 failed / 10 passed** |

Each mutation landed: anchor 1 → 0, and the blob changed from
`d2c652842c9f` to A1 `06e880595f77`, A2 `79a30a8137c0` and A3
`09066c0079e7`. Each was restored and proven: the blob equals the HEAD
blob `d2c652842c9f`, and `git diff HEAD` is empty.

## Fixture triage

The full `service-analytics` suite turned up exactly three expectations
that pinned a bare base column inside a statement that joins. That is
the shape this PR corrects. Each was replaced, and its comment, which
described the old rule, now describes the new one. The tests' subjects
(member resolution, spelling parity, traversal unification) are
unchanged.

- `analytics-service.test.ts`, "should resolve client-style lookup.field
member references": `SUM(amount)` → `SUM("opportunity"."amount")`.
- `infer-cube-relation-traversal.test.ts`, "unifies a dotted key riding
alongside a bare one": `WHERE (industry = $1 AND …)` → `WHERE
("crm_account"."industry" = $1 AND …)`.
- `infer-cube-where-spelling-parity.test.ts`, "bare and dotted keys
reach parity together": `WHERE (stage = $1 AND …)` → `WHERE
("deal"."stage" = $1 AND …)`. The file header and block 2's comment
named the old rule, and now name the new one. Block 2's assertion (a
statement that joins nothing stays bare) is unchanged and green.

Consumer radius, run against this branch's build at `560ab361` with
`OS_TEST_POSTGRES_URL` set: every test file that drives the analytics
service in `packages/rest` (23 files), `packages/runtime` (19),
`packages/drivers/driver-memory` (27) and `packages/drivers/driver-sql`
(7). The results were 359 / 359, 551 / 551, 863 / 863 and 133 passed / 1
skipped, with no failures. The 9 `packages/qa/dogfood` files that drive
analytics are **NOT MEASURED**: their build closure (the example apps,
the CLI) was not built here. They pin no statement text (`git grep` for
`GROUP BY` / `LEFT JOIN` / `SELECT` over them has zero hits). They are
left to the required `Dogfood Regression Gate`.

## Docs

`git grep -niE` for `ambiguous`, `qualif…`, `declares (no) join`, `bare
column`, `single-object cube`, `cube can/has join` and `declared join`
over `content/docs/**`, excluding `releases/` and `references/`, gave no
sentence about analytics column qualification. The native-SQL join
sentences are `protocol/objectql/query-syntax.mdx:517` (the positive
control: "on the native-SQL path those joins serve dimensions, measures
and filter members", about a dataset's `include`) and
`data-modeling/analytics.mdx:189-193` (dataset joins come from
`include`). Both stay true. No page edited.

## Verification at `560ab361`

`560ab361` is this branch's head. It carries `origin/main` at `434c6c7c`
merged in (no overlap with this diff's files), with install and the
build refreshed after the merge.

- `pnpm --filter @objectstack/service-analytics exec vitest run`, with
the PostgreSQL cells live: 165 files, **3827 passed, 0 skipped, 0
failed**. `typecheck` (`tsc --noEmit`): exit 0, and its program lists
all 5 touched `.ts` files (`--listFiles`).
- Base reading on `4727fcb2`, cells skipped: 164 files, 3758 passed / 56
skipped.
- Gates: `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derives 61 commands, and all 61
exit 0 at `560ab361`. `--ran` reconciliation: "61 derived, 61 run, 0
NOT-MEASURED, 0 UNRUN", with every exit code recorded.
`check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit
3), because some packages had no `dist`. After `pnpm build` (72 tasks,
71 cached) it exits 0, and that is the run recorded.
- Lint, a declared narrowing: `eslint --no-inline-config --format json`
over the 5 touched `.ts` files at `560ab361` reports 5 files, 0 errors
and 0 warnings. ① All 5 are inside the population `eslint.config.mjs`
lints (`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`, minus the global
`node_modules` / `dist` / `build` / `.next` / `.turbo` ignores), and the
changeset `.md` is in no `files` glob. ② The count of 5 is read from the
JSON output. ③ The config enables no type-aware linting
(`--print-config` gives `parserOptions`
`{"ecmaVersion":"latest","sourceType":"module"}`, with no `project`), so
this diff cannot move a verdict on an untouched file. The repo-wide
`pnpm lint` is left to CI.

## Acceptance notes

- **Ordering by a member the query does not select is a separate defect,
reported to the seat and not handled here.** The ORDER BY emitter writes
the request key as an identifier (`ORDER BY "note"`), which names an
output column only when that member is selected. Measured after this PR,
all through `POST /api/v1/analytics/query`, native face, with the
ObjectQL face answering 200 in every row:
- `{ dimensions: ['owner.email'], order: { note: 'asc' } }` answers 500
on both drivers. On PostgreSQL that is 42702, `note` ambiguous at the
ORDER BY.
- `{ dimensions: ['note'], order: { amount: 'asc' } }` with no join at
all answers 500 on PostgreSQL (42803, must appear in GROUP BY). On
SQLite it answers 200, ordered by an arbitrary row's value.
- `order: { 'owner.email': 'asc' }`, unselected, answers 500 on both
drivers (42703, column "owner.email" does not exist).

Qualifying the column would not mend it (PostgreSQL would still answer
42803). The fix is a decision about what an unselected order key means.
- The other `cube.joins` readers in this file (`canHandle`'s federated
decline, and the three `readScopedObjects` fallbacks) were not seen
failing at a door in these measurements. They stay with PR objectstack-ai#21247's
acceptance notes.
- The `/sql` dry run's text changes for every statement that joins
through an undeclared lookup (now qualified), and for a declared-join
cube's statement that joins nothing (now bare). The consumer radius
above found no test outside this package that pins that text.

## Deviation from the declared file surface

The claim names `native-sql-strategy.ts` and its tests, plus a
changeset. The diff stays inside that. The three fixture files above are
this strategy's tests in `service-analytics`. The `resolveFieldSql`
fallback is in the same file and is the same 500 beside a join, measured
at the door.

---
_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