Repository navigation
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
Conversation
…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>
…a rewrite label Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
…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>
… 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>
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.columnOfaskscolumnObjectOf(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 declaredreference, else the alias. That is the object both strategies join and read for the path. Before,columnOfreadcube.joinsalone and stood down on a path the cube declares no join for. ⛔ No second resolver: the hop walk ishop-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.assertNoStructuredJsonDimensiontakes one more argument,referenceOf(theHopReferencetype fromhop-object.ts). Its one caller,AnalyticsService.assertDimensionsGroupScalarColumns, passesthis.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 fromindex.ts).INVALID_FIELD/ 400,member,param,cube,field(the path),object(the object the lookup declares as its target).Measured:
POST /api/v1/analytics/queryand/sqlon the real dispatcher routeSetup:
AnalyticsServicePluginover a realObjectQLengine andSqlDriver, a signed-in caller, the realdispatcher-pluginmount, SQLite in memory and a private PostgreSQL 16.14. A configured cube overos21232_dealdeclares a join foraccount(hqjson,nametext) and none forowner.owneris a lookup whosereferenceisos21232_person(prefsjson,labelstags,emailtext). Before:origin/mainat3a7b6eb0. After: this branch'sservice-analyticsbuild. 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.owner.prefs(json) /owner.labels(tags)INVALID_FIELDDATABASE_ERROR→ 400INVALID_FIELDfrom the engine,groupBy[1]→ 400 from this door, naming the membercount_distinctoverowner.prefs/owner.labels2/2→ 400field/object) → 400 from this door/sql(dry run)account.hq(declared join)INVALID_FIELD, this door → unchangedowner.emaildimensionowner.email/account.namecount_distinctPins
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 withoutOS_TEST_POSTGRES_URL), and both faces. It has 10 tests, 5 per cell.count_distinctoverowner.prefs/owner.labelsare refusedINVALID_FIELD/ 400 on both faces, with nothing read. The checked fields arecode,status,member,param,cube,field(the path) andobject(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.owner.prefs) is refused the same way.account.hqget the same refusal (objectthe joined object). The scalarowner.emaildimension is served on both faces (one group per owner), and itscount_distinctanswers3.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 readssrcand there is nodistleg. Every mutation went throughscripts/ablation-replace.mjsin WRAP mode, with an outertraprestore onEXIT INT TERMagainst the absolute path. Predictions were written before each run. They ran from committed08ea135b, andgit diff 08ea135b b6e64185 -- packages/services/service-analyticsis empty. The PostgreSQL cell was live.columnOfgets back the joins-only resolution (the removed code, byte for byte)undefinedin place ofthis.hopReferenceThe prediction's total (30) was an arithmetic slip: 28 tests ran. Each mutation landed: anchor 1 → 0, and the blob changed (A1
04bf4095e712→db237bdc284e, A216d63d721b6e→5d257e0a59d3). Each was restored and proven: the blob equals the HEAD blob,git diff HEADis empty, and porcelain shows 0. An earlier pair of runs at50d5511f, before the ad-hoc pin existed, gave 7 failed / 19 passed for each, as predicted then.Fixture triage
The full
service-analyticssuite 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) andpackages/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.joinsinservice-analytics(dispatch Zone 2, item 2)No other reader resolves a dotted path through
cube.joinsalone. Every path-splitting reader goes throughresolvePathHops/columnObjectOf. Six sites enumeratecube.joinswithout splitting a path. They are listed for the seat and not touched here.strategies/native-sql-strategy.tsqualifyAndRegisterJoin,canJoin: it qualifies a bare base column only when the cube declares a join. Measured atb6e64185(this file is byte-identical toorigin/main). Take a configured cube with no declared join and the dimensionsnote+owner.email, where the lookup's target also declaresnote. The native face answers 500DATABASE_ERRORon 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.tscanHandle, the federated-object decline: it asksisExternalObjectof 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) andanalytics-service.tscubeObjects: these read the declared joins as a fallback for a context built withoutreadScopedObjects, or besidenamedQueryFields, 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"overcontent/docs/**, excludingreleases/andreferences/, gave 32 hits. None says the door stands down on a path without a declared join, and none speaks about acount_distinctover a related field's JSON-stored column. Positive control: the pattern hitscontent/docs/deployment/validating-metadata.mdx:228, the dataset-dimension door sentence. That page speaks about datasets, whose dotted fields must traverse a declaredinclude(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. RoutingcolumnOfthrough the resolver's reference tier, which is the triage direction, needs the host'sHopReference, and only the door's one caller holds it. Soanalytics-service.tschanges by one argument (this.hopReference) and three docblock lines inassertDimensionsGroupScalarColumns, the door's caller. Nothing else in that file moved. A2 above pins that line.Verification at
b6e64185The branch head
fafbf053carries a tree byte-identical tob6e64185(git diff b6e64185 fafbf053is empty), so every reading below holds for it.b6e64185hasorigin/main(ef96c9ed) merged in. Install and a full build were refreshed after the merge, andpnpm --filter @objectstack/spec check:generatedreports 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.OS_TEST_POSTGRES_URL):json-stored-door-undeclared-join,json-stored-door-live-drivers,cube-measure-relationship-path-type,dimension-structured-json-doorandmulti-value-json-stored-doorgave 72 passed / 0 skipped.runtimeanalytics-json-dimension-doorandanalytics-cube-measure-field-type-doorgave 20 passed / 0 skipped (run at9a32e950, whose analytics source is the same).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderives 62 commands, and all 62 exit 0 atb6e64185.--ranreconciliation: "62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN", with every exit code recorded.check:adr-0087-registrationwas 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 withnot-required (no-migration-prescription), the disposition the door's earlier entries carry.eslint --no-inline-config --format jsonover the 4 touched.tsfiles atb6e64185reports 4 files, 0 errors and 0 warnings. ① All 4 are inside the populationeslint.config.mjslints (packages/**/*.{ts,tsx,mts,cts}; none ignored), and the changeset.mdis in nofilesglob. ② The count of 4 is read from the JSON output. ③ The config enables no type-aware linting (--print-configgivesparserOptions{ecmaVersion:'latest', sourceType:'module'}, with noproject), so this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis left to CI.Acceptance notes
20807-analytics-json-dimension-refused.mdand20912-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'sminorchangeset and every gate stayed green #17712).groupBy[1], the cross-object measure refusal) are now unreachable for them, because the door answers first. Neither refusal changes.packages/runtime/src/analytics-json-dimension-door.test.tsalready pins that relay for this door. The route-level readings above were taken through the real route.Generated by Claude Code