Skip to content

Commit c745e2b

Browse files
fix(service-analytics): an inferred cube lives only in its request — no request writes the shared cube registry (#20381) (#20433)
Fixes #20381 Clause-②: no Item 3 of #20381, under director ruling `5866558247` (letter A, maintainer 「同意」): registry source 3 is retired. Items 1–2 landed in PR #20407 (`50e273fd`), so this round completes the card. ## What changes - The ad-hoc `query` and `sql` doors (`POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql`) no longer publish the cube `ensureCube` infers for an ADMITTED request. That cube stays in the call's request scope, the one PR #20407 gave these doors, and it is dropped with the call, like a measure appended to a configured cube. The next request for the same name infers the cube again, through the same existence and source-field gates, and gets the same answer. - The shared `CubeRegistry`, and therefore `getMeta()` and `GET /api/v1/analytics/meta`, is now written by configuration only: manifest cubes (`AnalyticsServiceConfig.cubes`) and `registerDataset` datasets. `/analytics/meta` lists the authored vocabulary, whatever traffic the server has seen since boot. - `publishInferredCube` had no caller left and is removed, and `ensureCube` returns `void` again. The `CubeRegistry` class docblock now lists the two configuration sources and states that no request writes the registry. The `CubeScope`, `queryIn`, `requestScope`, `ensureCube` and #5918 comments in `analytics-service.ts` no longer describe the publication. - No refusal, code or status changes. There is no `packages/spec` change, no visibility marker and no caller-aware `getMeta`; options B, C and D are not taken. Landing point, as dispatched: `packages/services/service-analytics/src/analytics-service.ts` (the producer of the write) and `cube-registry.ts` (docblock only). ## Tests: re-observed, not deleted On the fix commit, 36 cases in six service-analytics test files went red; each of those files read an inferred cube back through `getMeta` or the shared registry. PR #20348, which landed while this round ran, added a seventh such case. Each case is re-observed through a window that still exists after A: the cube the request's own strategies are handed. A probe strategy placed ahead of the built-in ones records `ctx.getCube(query.cube)` and always declines, so the chain runs as it would without the probe. Where an assertion's subject was the retired registration itself, the assertion now pins its absence. | File | Was | Now | |---|---|---| | `infer-cube-where-spelling-parity.test.ts` | dimension keys via `getMeta('deal')` | the same keys, read from the request's cube | | `infer-cube-relation-traversal.test.ts` | `run()` members via `getMeta` | the request's cube | | `dotted-measure-refusal.test.ts` | `run()` measures via `getMeta`; block 2 case 1 asserted that the first query warmed the registry | the request's cube; case 1 now pins that the first query warms nothing and that the second, cold again, is still refused (the augmentation site stays covered by the block's authored-cube case) | | `analytics-service.test.ts` 'auto-infer' | `cubeRegistry.has('case')` is true | the request was handed a cube named `case` and backed by `case`; `has('case')` is false | | `cube-inference-gate.test.ts` KPI case | `cubeRegistry.get('crm_account')` is truthy | it is undefined; a second request is served the same way and asks the existence gate again | | `adhoc-query-request-scope.test.ts` (PR #20407) | the admitted inference "publishes after admission (source 3)"; the CONTROL case runs "through the published cube" | the admitted inference is served from its own cube, and both the registry and the observer's view are exactly unchanged (the order pin is kept); the CONTROL case is now "a second same-name request infers again and gets the same answer" | | `cube-public-visibility.test.ts` (PR #20348) | the ad-hoc KPI path's inferred cube is registered `public: true` and listed | it is answered on every request, and never registered or listed | The route pin is `packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts`: `bootStack` with two sign-ups plus the administrator, on sqlite-wasm and memory, through both doors. - Both CONTROL legs are tightened from `arrayContaining` to exact equality on member B's cube list. B's `meta` is also kept as the raw response bytes. - After the administrator's admitted ad-hoc query over the walled object, B's `meta` is byte-identical and B's query of the configured cube is unchanged. B's own query of that object is still refused with the ADR-0112 envelope. - An admitted scalar metric is re-inferred on a second request and answers identically. Between the two requests, not even the asker's own `meta` lists the name. - A configured cube still serves: the baseline leg, and every observation. ## Evidence (head `8214a5b6` unless stated) - `pnpm --filter @objectstack/service-analytics typecheck` is clean. `vitest run`: 132 files, 3093 passed. `tsc --listFiles` includes every changed test file. - `pnpm --filter @objectstack/dogfood typecheck` is clean, and `--listFiles` includes the route pin. The six analytics dogfood files: 54 passed, on a `dist` rebuilt after merging `main`. - **Ablation M1** puts the publication back at both ad-hoc sites, after `callCtx`, as in `50e273fd`. It was applied with `scripts/ablation-replace.mjs` (anchor 2 → 0) and predicted before running. - Unit, 7 files: 10 red / 132 green. The red cases are the admitted-inference and CONTROL cases (4 + 2), auto-infer, the KPI gate case, the #20348 KPI case and the dotted warm case. - Route, after rebuilding `service-analytics`, with `ablation-dist-preflight` finding the marker in 2 dist files: 8 red / 16 green, both CONTROL legs on all four boots. For example: `expected [ 'open_summary', …(3) ] to deeply equal [ 'open_summary', …(2) ]`, with `+ "admission_walled"`. - Restore: blob equals `HEAD`, `git diff HEAD` is empty, rebuilt, and `--absent` preflight is green with a clean tree. - On the pre-merge head `fccfc3e5` the same ablation gave 9/117 and 8/16. - **Ablation M2**, on `fccfc3e5`, proves the probe window can fail. It makes an array `where` seed no dimension, the pre-#5353 shape. Across parity and traversal: 16 red / 24 green. In parity, the 11 conjunction table cases, ALONGSIDE and the two dotted array-versus-object cases went red; both `$or` cases stayed green, as the file predicts. In traversal, the two array-spelling mint cases went red. The restore was proven the same way. - **Gates**: `dispatch-gates --commands` derives 66 commands over the 12 changed paths. `--ran` reconciles 66 derived, 66 run, 0 NOT MEASURED and 0 UNRUN. 65 exit 0; `check:empty-changeset` exits 1 by design (see Changesets). - **Lint, narrowed**: eslint `--no-inline-config --format json` over the 10 changed `.ts` files reports 10 files, 0 errors and 0 warnings. The population is those 10 files, none ignored. Invariance: `eslint.config.mjs` never enables type-aware linting, so an untouched file's verdict cannot move. The full `pnpm lint` is left to CI. ## Changesets - New: `.changeset/20381-retire-inferred-cube-source.md`, `@objectstack/service-analytics` `patch`, `Clause-②: no`. - **A deliberate correction of a pending release note, for confirmation:** `.changeset/20381-adhoc-cube-request-scope.md` was added by PR #20407 and is not yet released. It said that an admitted request's inferred cube "still registers the cube it inferred, as before" and that it "is still listed". This PR makes both sentences false, so they are removed and replaced by a pointer to the new entry. `check:empty-changeset` refuses any PR that modifies a changeset it did not add. For this DELIBERATE CORRECTION class it stays red by design (ruling D on #17712), and it needs a person's confirmation here. Restoring the file from `50e273fd` would clear the gate, but the release would then ship both statements in one CHANGELOG. ## Overlap with PR #20348 PR #20348 landed first (`f2c7eef5`), and `main` is merged here (`dfd185d7`) with no textual conflict. - Its `public: true` on the inferred cube is moot under ruling A, because no visibility verdict ever reads that cube. The literal is **kept**: the pending note `.changeset/20282-analytics-cube-public-enforced.md` says the inferred cube "now writes `true`", and dropping the key would falsify a second foreign changeset. Only its comment, which said the cube is registered, is corrected. - Its `generateSql` gate still asks `this.sharedScope` rather than the call's scope. The answer is identical, because a fresh request scope with no dataset reads through to the shared registry, so this is noted, not changed. - Its other changes are untouched. ## Acceptance notes - Log frequency: for a GROUPED ad-hoc query over an object with no configured cube, `ensureCube`'s `warn` ("No cube registered …; auto-inferred a minimal cube …") used to fire once per name per process, because the second request found the published cube. It now fires on every such request; scalar metrics stay at `debug`. This was not measured against real dashboard traffic, and it is noted, not changed: the ruling adds no state. - `content/docs/api/data-api.mdx`, in its `GET /analytics/meta` section, says that a cube a query references "is lazily auto-inferred from that query's shape". It does not claim the cube gets listed, but it could now say that it does not. That file is outside this card's file surface. - Per the ruling, the two unmeasured cases (a cross-org boot, and an FLS-hidden field used as a dimension) cannot leak through `meta` for inferred cubes once this lands. They stay as notes for the ADR-0106 D5 audit. - The refusal tests that assert `cubeRegistry.get(...)` is undefined after a rejected query (the three source-field gate files, `cube-inference-gate`, `dotted-measure-refusal`) now hold by construction for every request, not only for refused ones. They are left as they are. --- _Generated by [Claude Code](https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 95f729a commit c745e2b

12 files changed

Lines changed: 389 additions & 183 deletions

‎.changeset/20381-adhoc-cube-request-scope.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@
77
Clause-②: no
88

99
- **What changes**: both ad-hoc doors — `query()` (`POST /api/v1/analytics/query`) and `generateSql()` (`POST /api/v1/analytics/sql`) — resolved the query's cube and recorded what `ensureCube` minted straight into the shared registry, ahead of the admission check. A request refused `PERMISSION_DENIED` still left the cube it inferred for the refused object in the registry, and a suffix measure a caller named on a registered cube (`<field>_sum`, `<field>_count_distinct`, …) was appended to that cube for every later reader, whether the request was refused or admitted. Both doors now run in the same request-local scope `queryDataset` runs in: what `ensureCube` mints stays with the call, and the admission, read scope and strategy all read it from there.
10-
- **What does not change**: every request is served as before, with the same admission, read scope, refusals, codes and statuses, and a caller-named suffix measure is still served to the caller who named it. An ADMITTED ad-hoc query over an object with no configured cube still registers the cube it inferred, as before — now only after the admission has admitted the request, and never over a cube registered under the same name in the meantime. Configured cubes and datasets registered at construction (`AnalyticsServiceConfig.cubes` / `datasets`) are untouched.
11-
- **What `getMeta()` lists, the one observable difference**: `getMeta()` and `GET /api/v1/analytics/meta` no longer list a cube inferred for a refused request, and no longer list a suffix measure some caller named on a registered cube — a registered cube is listed as it was registered. A cube inferred for an admitted request is still listed.
10+
- **What does not change**: every request is served as before, with the same admission, read scope, refusals, codes and statuses, and a caller-named suffix measure is still served to the caller who named it. A cube inferred for an ADMITTED ad-hoc query is no longer registered either; the separate #20381 entry that retires inferred-cube registration describes that change. Configured cubes and datasets registered at construction (`AnalyticsServiceConfig.cubes` / `datasets`) are untouched.
11+
- **What `getMeta()` lists, the one observable difference**: `getMeta()` and `GET /api/v1/analytics/meta` no longer list a cube inferred for a refused request, and no longer list a suffix measure some caller named on a registered cube — a registered cube is listed as it was registered.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
A cube `AnalyticsService` infers for an ad-hoc `query()` or `generateSql()` request is no longer registered in the service-wide cube registry, even when the request is admitted, so `getMeta()` and `GET /api/v1/analytics/meta` list configured cubes only (#20381).
6+
7+
Clause-②: no
8+
9+
- **What changes**: an ad-hoc request naming an object that no cube is configured over (`POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql`) is still served from a minimal cube inferred from that request's own members. That cube now lives only in the request that inferred it, like a suffix measure a caller appends to a configured cube. Before, an admitted request left it in the shared registry, so `getMeta()` listed it to every caller, including callers who may not read the object, together with the member names the first caller used. Its contents depended on who had queried what since boot, and it was lost on restart.
10+
- **What does not change**: every request is served as before, with the same answer, admission, read scope, refusals, codes and statuses. A repeat request for the same object infers the cube again, through the same existence and source-field checks, and gets the same answer. Configured cubes (`AnalyticsServiceConfig.cubes`) and datasets registered through `registerDataset` (the constructor's `datasets`, or an embedder) are registered and listed as before, and they are now the registry's only writers.
11+
- **What to do**: nothing, unless something reads `getMeta()` / `GET /api/v1/analytics/meta` expecting to find a cube that only an ad-hoc query inferred. No consumer in this repository does. Author that cube explicitly (`defineCube`, or the analytics service's `cubes` config) so that it is listed, and listed the same way after a restart.

‎packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts‎

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
//
33
// END-TO-END gate: an ad-hoc query on `POST /analytics/query` or
4-
// `POST /analytics/sql` changes nothing another member sees unless the
5-
// object-level admission admitted it — and even then, a measure the caller
6-
// named on top of a configured cube stays that caller's (#20381).
4+
// `POST /analytics/sql` changes nothing another member sees — refused or
5+
// admitted. A measure the caller named on top of a configured cube, and a cube
6+
// inferred for an object no cube is configured over, stay that request's own
7+
// (#20381).
78
//
89
// ## The defect
910
//
@@ -14,17 +15,21 @@
1415
// `GET /analytics/meta`, and a suffix measure a caller named on a configured
1516
// cube was appended to that cube for every member, admitted or refused. The
1617
// doors now run in a request scope of their own (the one the dataset door got
17-
// for #20356); an inferred cube is published only once the request has been
18-
// admitted, and an appended measure never is.
18+
// for #20356), and nothing minted there leaves it. An ADMITTED request's
19+
// inferred cube used to be published to the shared registry ("CubeRegistry
20+
// source 3"), so `meta` listed to every member an object someone had queried
21+
// and the member names they used; that source is retired (ruling A on
22+
// #20381), and the registry is written by configuration alone.
1923
//
2024
// ## How it is observed
2125
//
2226
// Two separate sign-ups, A and B, holding the same grant (read on
2327
// `admission_open` only), plus the administrator. Member A — or the admin —
2428
// asks; member B observes. B's observation is the whole of what B can see of
25-
// the analytics registry through the two doors B uses — the `meta` listing
26-
// and B's query of the configured cube — taken immediately before and after
27-
// each leg and compared for EQUALITY, so a partial rewrite cannot pass.
29+
// the analytics registry through the two doors B uses — the `meta` listing,
30+
// kept as the raw response bytes as well as parsed, and B's query of the
31+
// configured cube — taken immediately before and after each leg and compared
32+
// for EQUALITY, so neither a partial rewrite nor an added cube can pass.
2833
//
2934
// ## The legs, on each door
3035
//
@@ -41,12 +46,12 @@
4146
//
4247
// - A configured cube still serves: every B observation is a `200` count of
4348
// B's own rows.
44-
// - An admitted scalar metric over an object still works on a second request
45-
// — the documented "CubeRegistry source 3" path, which stays: the inferred
46-
// cube is registered once the first request is admitted.
47-
// - That published cube widens nothing: after the administrator's admitted
48-
// ad-hoc query over the walled object, B's own query of that object is still
49-
// refused on both doors, and every cube B saw before is listed unchanged.
49+
// - An admitted scalar metric over an object is served on a second request
50+
// too, with the same answer: it infers again, because nothing was
51+
// published between the two, and B's view is unchanged.
52+
// - The administrator's admitted ad-hoc query over the walled object leaves
53+
// B's `meta` byte-identical and B's configured-cube query unchanged, and B's
54+
// own query of that object is still refused on both doors.
5055

5156
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
5257
import { bootStack, type VerifyStack } from '@objectstack/verify';
@@ -123,7 +128,7 @@ interface Boot {
123128
}
124129

125130
interface Observation {
126-
meta: { status: number; body: unknown };
131+
meta: { status: number; body: unknown; bytes: string };
127132
authored: { status: number; body: unknown };
128133
}
129134

@@ -133,10 +138,16 @@ async function read(res: Response): Promise<{ status: number; body: unknown }> {
133138
return { status: res.status, body: await res.json() };
134139
}
135140

141+
/** A response read as its raw bytes too — for the listing whose sameness is the pin. */
142+
async function readBytes(res: Response): Promise<{ status: number; body: unknown; bytes: string }> {
143+
const bytes = await res.text();
144+
return { status: res.status, body: JSON.parse(bytes), bytes };
145+
}
146+
136147
/** Everything member B can see of the analytics registry. */
137148
async function observeAsB(stack: VerifyStack, tokenB: string): Promise<Observation> {
138149
return {
139-
meta: await read(await stack.apiAs(tokenB, 'GET', '/analytics/meta')),
150+
meta: await readBytes(await stack.apiAs(tokenB, 'GET', '/analytics/meta')),
140151
authored: await read(
141152
await stack.apiAs(tokenB, 'POST', '/analytics/query', {
142153
cube: 'open_summary',
@@ -147,11 +158,16 @@ async function observeAsB(stack: VerifyStack, tokenB: string): Promise<Observati
147158
}
148159

149160
/** The listed cubes, whichever envelope the door uses. */
150-
function cubesOf(meta: Observation['meta']): unknown[] {
161+
function cubesOf(meta: { body: unknown }): unknown[] {
151162
const payload = (meta.body as { data?: unknown })?.data ?? meta.body;
152163
return Array.isArray(payload) ? payload : [];
153164
}
154165

166+
/** The listed cube names. */
167+
function cubeNamesOf(meta: { body: unknown }): string[] {
168+
return cubesOf(meta).map((c) => (c as { name: string }).name);
169+
}
170+
155171
/** The single count a one-measure answer carries, whichever envelope the door uses. */
156172
function countOf(body: unknown, measure: string): number {
157173
const payload = (body as { data?: unknown })?.data ?? body;
@@ -257,35 +273,50 @@ describe.each(CASES)(
257273
expect(await observeAsB(stack, tokenB)).toEqual(before);
258274
});
259275

260-
it('CONTROL: an admitted scalar metric over an object still works on a second request', async () => {
276+
it('CONTROL: an admitted scalar metric over an object is served again on a second request, re-inferred, with the same answer', async () => {
261277
const { stack, tokenA, tokenB } = boots.get(key)!;
262278
const before = await observeAsB(stack, tokenB);
263279

280+
const answers: unknown[] = [];
264281
for (let i = 0; i < 2; i++) {
265282
const res = await stack.apiAs(tokenA, 'POST', door, { cube: 'admission_open', measures: ['count'] });
266283
expect(res.status).toBe(200);
267284
const body = await res.json();
268285
if (door === '/analytics/query') expect(countOf(body, 'count')).toBe(A_OPEN_ROWS);
269286
else expect(JSON.stringify(body)).toContain('admission_open');
287+
answers.push(body);
288+
// Between the two, not even the asker's own `meta` lists the name, so
289+
// the second request cannot resolve it from the registry: it infers.
290+
if (i === 0) {
291+
const askersMeta = await read(await stack.apiAs(tokenA, 'GET', '/analytics/meta'));
292+
expect(cubeNamesOf(askersMeta)).not.toContain('admission_open');
293+
}
270294
}
295+
expect(answers[1]).toEqual(answers[0]);
271296

272297
const after = await observeAsB(stack, tokenB);
273-
expect(after.authored).toEqual(before.authored);
274-
expect(cubesOf(after.meta)).toEqual(expect.arrayContaining(cubesOf(before.meta)));
298+
expect(cubesOf(after.meta)).toEqual(cubesOf(before.meta));
299+
expect(after).toEqual(before);
275300
});
276301

277-
it('CONTROL: the administrator\'s admitted ad-hoc query over the walled object widens nothing for B', async () => {
302+
it('CONTROL: the administrator\'s admitted ad-hoc query over the walled object leaves B\'s meta byte-identical', async () => {
278303
const { stack, adminToken, tokenB } = boots.get(key)!;
279304
const before = await observeAsB(stack, tokenB);
280305

281306
const res = await stack.apiAs(adminToken, 'POST', door, { cube: 'admission_walled', measures: ['count'] });
282307
expect(res.status).toBe(200);
283308

284-
// B still may not read the object, whatever the registry now holds under its name.
309+
// B still may not read the object.
285310
await expectRefused(await stack.apiAs(tokenB, 'POST', door, { cube: 'admission_walled', measures: ['count'] }));
286311
const after = await observeAsB(stack, tokenB);
312+
// Stated first on its own, so a failure reads as the defect: B's cube list
313+
// is EXACTLY what it was — nothing named after the walled object joined it.
314+
expect(cubeNamesOf(after.meta)).toEqual(cubeNamesOf(before.meta));
315+
expect(cubesOf(after.meta)).toEqual(cubesOf(before.meta));
316+
expect(after.meta.bytes).toBe(before.meta.bytes);
317+
// …and B's query of the configured cube answers as before.
287318
expect(after.authored).toEqual(before.authored);
288-
expect(cubesOf(after.meta)).toEqual(expect.arrayContaining(cubesOf(before.meta)));
319+
expect(countOf(after.authored.body, 'authored_total')).toBe(B_OPEN_ROWS);
289320
});
290321
},
291322
);

0 commit comments

Comments
 (0)