Skip to content

fix(service-analytics)!: judge and present a relationship-path cube measure by its column's declaration on the object the path reaches - #21230

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21129-dotted-measure-type
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21129-dotted-measure-type

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #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 (#21044).

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 #21129.

Acceptance notes

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

claude added 5 commits October 1, 2026 18:59
…asure by the declaration on the object its last hop reaches

The cube door's aggregate x field-type judgment, the measure result type and
the native-SQL presenter now locate a measure column written as a
relationship path on the object the one hop resolver (hop-object.ts,
columnObjectOf) names for its last hop, and read the declaration there.

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

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…d hook answered for every object

Both cases pinned the compile check's stand-down on a dotted field with a
sourceFieldMeta stub that described the joined object too. The cube door now
reads the column there, so the stand-down case gets a hook that describes the
base object alone, as its comment says, and a sibling case pins the cube
door's query-time refusal where the joined object is described.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…a relationship-path measure

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…tted-measure-type

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 11 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-analytics/src/measure-result-type.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

⛔ 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
  • 1 changed file(s) yielded no anchor (packages/services/service-analytics/src/measure-result-type.ts) — pages documenting those are invisible to this run
  • 3 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 3ddd3d0c4a355b58d074245ae6732ac91489c2a7 → packageMentionDocs.

Which tree this was computed on

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

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

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

…r the claim revision

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 20:05
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 20:05
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 3a7b6eb Oct 1, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21129-dotted-measure-type branch October 1, 2026 20:26
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…inct over a relationship path the cube declares no join for, located through the one hop resolver (objectstack-ai#21247)

Fixes objectstack-ai#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 objectstack-ai#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 08ea135 b6e6418 -- 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 b6e6418 fafbf05` 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 (objectstack-ai#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](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