From d60bf4ecaa4a377a910f88027d2aa7649b06b75b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 10:21:31 +0000 Subject: [PATCH 1/5] fix(service-analytics)!: a cube or dataset over an object the API does not serve is refused at registration, not listed by getMeta The registration face of the generic-exit gate asks the spec's one exposure decision (apiExposureDenialReason, aggregate) over a definition's base object and declared joins when it enters the shared registry: configured cubes are warned and skipped, registerDataset throws located, and a direct registry write is refused by the registry's own admission. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- .../src/__tests__/api-exposure-door.test.ts | 41 ++- .../unexposed-definition-registration.test.ts | 317 ++++++++++++++++++ .../src/analytics-service.ts | 67 +++- .../src/api-exposure-door.ts | 90 +++++ .../service-analytics/src/cube-registry.ts | 34 +- 5 files changed, 524 insertions(+), 25 deletions(-) create mode 100644 packages/services/service-analytics/src/__tests__/unexposed-definition-registration.test.ts diff --git a/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts b/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts index 4e8f12305f3..447ba7587f9 100644 --- a/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts +++ b/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts @@ -109,7 +109,10 @@ const ROWS: Record>> = { ], }; -/** Configured cubes — the authored cube door over each subject. */ +/** + * Configured cubes — the authored cube door over each subject, registered + * before the subjects are declared (see `beforeAll`). + */ const CUBES = [ { name: 'hidden_cube', @@ -217,6 +220,28 @@ describe.each(STRATEGIES)('the analytics door honours the generic-exit declarati engine = new ObjectQL({ logger: quiet } as any); engine.registerDriver(driver, true); await engine.init(); + + // The plugin inits BEFORE the subjects are declared, on purpose: a + // configured cube over an object the API does not serve is refused at + // registration when the declaration is already known + // (`unexposed-definition-registration.test.ts`), so the configured-cube + // rows below can only reach the QUERY face through the window registration + // cannot judge — an object registered after the analytics plugin's init. + // That window is what they pin. + const security = grantEverything(engine); + const registered: Record = {}; + await new AnalyticsServicePlugin({ + cubes: CUBES as any, + ...(capabilities ? { queryCapabilities: capabilities } : {}), + }).init({ + getService: (name: string) => (name === 'data' ? engine : name === 'security' ? security : registered[name]), + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + hook: () => {}, + logger: quiet, + } as never); + service = registered.analytics as AnalyticsService; + for (const obj of [HIDDEN_OBJECT, NO_LIST_OBJECT, VAULT_OBJECT, LEDGER_OBJECT, OPEN_OBJECT]) { engine.registry.registerObject(obj as any); } @@ -236,20 +261,6 @@ describe.each(STRATEGIES)('the analytics door honours the generic-exit declarati reads.set(object, readsOf(object) + 1); return (realAggregate as any)(object, ...rest); }; - - const security = grantEverything(engine); - const registered: Record = {}; - await new AnalyticsServicePlugin({ - cubes: CUBES as any, - ...(capabilities ? { queryCapabilities: capabilities } : {}), - }).init({ - getService: (name: string) => (name === 'data' ? engine : name === 'security' ? security : registered[name]), - registerService: (name: string, svc: unknown) => { registered[name] = svc; }, - replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, - hook: () => {}, - logger: quiet, - } as never); - service = registered.analytics as AnalyticsService; }); afterAll(async () => { diff --git a/packages/services/service-analytics/src/__tests__/unexposed-definition-registration.test.ts b/packages/services/service-analytics/src/__tests__/unexposed-definition-registration.test.ts new file mode 100644 index 00000000000..d35fb9aa32a --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/unexposed-definition-registration.test.ts @@ -0,0 +1,317 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A definition that can never answer is refused when it is registered, not + * published and left to fail at every query. + * + * The analytics door refuses every query over an object whose `enable` block + * the spec's one exposure decision (`apiExposureDenialReason`) denies the + * aggregate operation: `404 OBJECT_API_DISABLED` for `apiEnabled: false`, `405 + * OBJECT_API_METHOD_NOT_ALLOWED` for a whitelist that does not grant it + * (`api-exposure-door.test.ts`). A configured cube, or a dataset + * `registerDataset` compiled, over such an object used to register silently and + * be listed by `getMeta` — a cube on the discovery surface that answered 404 to + * every query, with nothing telling the author when they declared it. + * + * What this file pins, per registration door: + * + * - the configured cubes (`AnalyticsServiceConfig.cubes`, and the plugin's + * `cubes`): a cube whose base object or declared join is denied is warned + * with a located refusal and skipped, and every other cube still registers; + * - `registerDataset`, and the constructor's `datasets` that go through it: the + * same refusal is thrown, in the query face's code and status, and nothing is + * registered; + * - a cube written to the service's public `cubeRegistry` directly: the + * registry's own admission refuses it; + * - every refused definition is absent from `getMeta`; + * - the controls: an exposed object's cube and dataset register and list as + * before; + * - the tiering (cannot answer, do not block): an object with no declaration at + * registration time, or a lookup that throws, registers as before, and the + * query face still refuses the object at every query. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { Cube } from '@objectstack/spec/data'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { AnalyticsService, type AnalyticsServiceConfig } from '../analytics-service.js'; +import { AnalyticsServicePlugin } from '../plugin.js'; +import type { ObjectDeclaration } from '../api-exposure-door.js'; + +const HIDDEN = 'reg_hidden'; +const NO_LIST = 'reg_nolist'; +const OPEN = 'reg_open'; +const PARTNER = 'reg_partner'; + +const DECLARATIONS: Record = { + [HIDDEN]: { enable: { apiEnabled: false } }, + [NO_LIST]: { enable: { apiMethods: ['get'] } }, + [OPEN]: { enable: { apiEnabled: true } }, + [PARTNER]: {}, +}; + +const measures = { rows: { label: 'Rows', type: 'count' as const, sql: '*' } }; + +const cube = (name: string, base: string, joins?: Record): Cube => + ({ name, title: name, sql: base, public: true, measures, dimensions: {}, ...(joins ? { joins } : {}) }) as Cube; + +const dataset = (name: string, object: string, include?: string[]) => + DatasetSchema.parse({ + name, + label: name, + object, + ...(include ? { include } : {}), + dimensions: [], + measures: [{ name: 'row_count', label: 'Rows', aggregate: 'count' }], + }); + +/** `reg_open.partner` and `reg_open.hidden` resolve to the objects named. */ +const relationshipResolver = (object: string, relationship: string) => + object === OPEN && relationship === 'partner' + ? { object: PARTNER, table: PARTNER } + : object === OPEN && relationship === 'hidden' + ? { object: HIDDEN, table: HIDDEN } + : undefined; + +interface Refusal extends Error { + code?: string; + status?: number; + object?: string; +} + +const pick = (e: Refusal) => ({ code: e.code, status: e.status, object: e.object }); + +/** The error a call threw — and a loud failure if it returned. */ +function thrown(call: () => unknown): Refusal { + let returned: unknown; + try { + returned = call(); + } catch (e) { + return e as Refusal; + } + throw new Error(`expected a refusal, got ${JSON.stringify(returned)}`); +} + +async function rejection(call: () => Promise): Promise { + let resolved: unknown; + try { + resolved = await call(); + } catch (e) { + return e as Refusal; + } + throw new Error(`expected a refusal, got ${JSON.stringify(resolved)}`); +} + +const recordingLogger = () => { + const warnings: string[] = []; + const logger = { + debug() {}, + info() {}, + warn(message: string) { warnings.push(String(message)); }, + error() {}, + child() { return logger; }, + }; + return { logger: logger as unknown as AnalyticsServiceConfig['logger'], warnings }; +}; + +const serviceWith = (config: AnalyticsServiceConfig = {}) => + new AnalyticsService({ + getObjectDeclaration: (object) => DECLARATIONS[object], + relationshipResolver, + ...config, + }); + +const listed = async (service: AnalyticsService) => (await service.getMeta()).map((c) => c.name).sort(); + +describe('a configured cube over an object the API does not serve is refused at registration', () => { + const CUBES = [ + cube('open_cube', OPEN), + cube('hidden_cube', HIDDEN), + cube('nolist_cube', NO_LIST), + cube('hidden_join_cube', OPEN, { hidden: { name: HIDDEN } }), + cube('partner_join_cube', OPEN, { partner: { name: PARTNER } }), + ]; + + it('each refused cube is warned, located, and skipped; the others register and list as before', async () => { + const { logger, warnings } = recordingLogger(); + const service = serviceWith({ cubes: CUBES, logger }); + + expect(await listed(service)).toEqual(['open_cube', 'partner_join_cube']); + for (const name of ['hidden_cube', 'nolist_cube', 'hidden_join_cube']) { + expect(service.cubeRegistry.get(name), name).toBeUndefined(); + expect(await service.getMeta(name), name).toEqual([]); + } + + const said = (name: string) => warnings.filter((w) => w.includes(`"${name}"`)); + expect(said('hidden_cube')).toHaveLength(1); + expect(said('hidden_cube')[0]).toContain(`"${HIDDEN}"`); + expect(said('hidden_cube')[0]).toContain('apiEnabled: false'); + expect(said('nolist_cube')).toHaveLength(1); + expect(said('nolist_cube')[0]).toContain(`"${NO_LIST}"`); + expect(said('nolist_cube')[0]).toContain('apiMethods'); + // The joined object is the one named, not the exposed base object. + expect(said('hidden_join_cube')).toHaveLength(1); + expect(said('hidden_join_cube')[0]).toContain(`"${HIDDEN}"`); + expect(said('open_cube')).toEqual([]); + expect(said('partner_join_cube')).toEqual([]); + }); + + it('the refusal is the query face\'s envelope: 404 OBJECT_API_DISABLED / 405 OBJECT_API_METHOD_NOT_ALLOWED', () => { + const service = serviceWith(); + expect(pick(thrown(() => service.cubeRegistry.register(cube('hidden_cube', HIDDEN))))).toEqual({ + code: 'OBJECT_API_DISABLED', status: 404, object: HIDDEN, + }); + expect(pick(thrown(() => service.cubeRegistry.register(cube('nolist_cube', NO_LIST))))).toEqual({ + code: 'OBJECT_API_METHOD_NOT_ALLOWED', status: 405, object: NO_LIST, + }); + expect(pick(thrown(() => service.cubeRegistry.register(cube('hidden_join_cube', OPEN, { hidden: { name: HIDDEN } }))))).toEqual({ + code: 'OBJECT_API_DISABLED', status: 404, object: HIDDEN, + }); + }); + + it('a cube written to the public registry directly is refused there too, and nothing is stored', async () => { + const service = serviceWith(); + const before = await listed(service); + thrown(() => service.cubeRegistry.register(cube('hidden_cube', HIDDEN))); + expect(service.cubeRegistry.has('hidden_cube')).toBe(false); + expect(await listed(service)).toEqual(before); + }); +}); + +describe('a dataset over an object the API does not serve is refused by registerDataset', () => { + it('throws the located refusal, and registers nothing', async () => { + const service = serviceWith(); + const err = thrown(() => service.registerDataset(dataset('hidden_rows', HIDDEN))); + expect(pick(err)).toEqual({ code: 'OBJECT_API_DISABLED', status: 404, object: HIDDEN }); + expect(err.message).toContain('"hidden_rows"'); + expect(err.message).toContain(`"${HIDDEN}"`); + expect(service.cubeRegistry.has('hidden_rows')).toBe(false); + expect(await service.getMeta('hidden_rows')).toEqual([]); + + expect(pick(thrown(() => service.registerDataset(dataset('nolist_rows', NO_LIST))))).toEqual({ + code: 'OBJECT_API_METHOD_NOT_ALLOWED', status: 405, object: NO_LIST, + }); + expect(service.cubeRegistry.has('nolist_rows')).toBe(false); + }); + + it('a JOINED object the API does not serve refuses the dataset, naming the joined object', async () => { + const service = serviceWith(); + const err = thrown(() => service.registerDataset(dataset('open_by_hidden', OPEN, ['hidden']))); + expect(pick(err)).toEqual({ code: 'OBJECT_API_DISABLED', status: 404, object: HIDDEN }); + expect(err.message).toContain('"open_by_hidden"'); + expect(service.cubeRegistry.has('open_by_hidden')).toBe(false); + expect(await service.getMeta('open_by_hidden')).toEqual([]); + }); + + it('the constructor\'s datasets take the per-definition channel: warned and skipped, the others register', async () => { + const { logger, warnings } = recordingLogger(); + const service = serviceWith({ + datasets: [dataset('hidden_rows', HIDDEN), dataset('open_rows', OPEN), dataset('open_by_hidden', OPEN, ['hidden'])], + logger, + }); + expect(await listed(service)).toEqual(['open_rows']); + expect(warnings.filter((w) => w.includes('"hidden_rows"'))).toHaveLength(1); + expect(warnings.filter((w) => w.includes('"open_by_hidden"'))).toHaveLength(1); + }); + + it('CONTROL: an exposed object\'s dataset, joined to an exposed object, registers and lists as before', async () => { + const service = serviceWith(); + const compiled = service.registerDataset(dataset('open_by_partner', OPEN, ['partner'])); + expect(compiled.cube.joins?.partner?.name).toBe(PARTNER); + expect(await listed(service)).toEqual(['open_by_partner']); + }); +}); + +describe('cannot answer, do not block — the registration doors\' own tiering', () => { + it('an object with no declaration yet registers as before, and the query face judges it once it is declared', async () => { + const declared: Record = {}; + const service = new AnalyticsService({ + cubes: [cube('late_cube', 'reg_late')], + getObjectDeclaration: (object) => declared[object], + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async () => [{ rows: 1 }], + }); + expect(await listed(service)).toEqual(['late_cube']); + + declared.reg_late = { enable: { apiEnabled: false } }; + expect(pick(await rejection(() => service.query({ cube: 'late_cube', measures: ['rows'] })))).toEqual({ + code: 'OBJECT_API_DISABLED', status: 404, object: 'reg_late', + }); + }); + + it('a lookup that throws registers as before, and the query face refuses fail-closed', async () => { + const service = new AnalyticsService({ + cubes: [cube('open_cube', OPEN)], + getObjectDeclaration: () => { + throw new Error('registry unavailable'); + }, + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async () => [{ rows: 1 }], + logger: recordingLogger().logger, + }); + expect(await listed(service)).toEqual(['open_cube']); + expect(pick(await rejection(() => service.query({ cube: 'open_cube', measures: ['rows'] })))).toEqual({ + code: 'PERMISSION_DENIED', status: 403, object: OPEN, + }); + }); + + it('a host that wires no declaration probe registers every definition as before', async () => { + const service = new AnalyticsService({ cubes: [cube('hidden_cube', HIDDEN)], relationshipResolver }); + service.registerDataset(dataset('hidden_rows', HIDDEN)); + expect(await listed(service)).toEqual(['hidden_cube', 'hidden_rows']); + }); +}); + +describe('the shipped wiring: AnalyticsServicePlugin over a real ObjectQL engine', () => { + const quiet: any = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + const text = (name: string) => ({ name, type: 'text' as const }); + const OBJECTS = [ + { name: HIDDEN, label: 'Hidden', enable: { apiEnabled: false, apiMethods: [] }, fields: { region: text('region') } }, + { name: NO_LIST, label: 'No list', enable: { apiMethods: ['get'] }, fields: { region: text('region') } }, + { name: OPEN, label: 'Open', fields: { region: text('region') } }, + ]; + let engine: ObjectQL; + let service: AnalyticsService; + const warnings: string[] = []; + + beforeAll(async () => { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any); + engine = new ObjectQL({ logger: quiet } as any); + engine.registerDriver(driver, true); + await engine.init(); + for (const obj of OBJECTS) engine.registry.registerObject(obj as any); + + const registered: Record = {}; + await new AnalyticsServicePlugin({ + cubes: [cube('open_cube', OPEN), cube('hidden_cube', HIDDEN), cube('nolist_cube', NO_LIST)], + }).init({ + getService: (name: string) => (name === 'data' ? engine : registered[name]), + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + hook: () => {}, + logger: { ...quiet, warn: (message: string) => { warnings.push(String(message)); } }, + } as never); + service = registered.analytics as AnalyticsService; + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + it('the plugin\'s configured cubes over unexposed objects are refused at init, warned, and absent from getMeta', async () => { + expect(await listed(service)).toEqual(['open_cube']); + expect(warnings.filter((w) => w.includes('"hidden_cube"') && w.includes(`"${HIDDEN}"`))).toHaveLength(1); + expect(warnings.filter((w) => w.includes('"nolist_cube"') && w.includes(`"${NO_LIST}"`))).toHaveLength(1); + }); + + it('registerDataset on the plugin\'s service refuses a dataset over an unexposed object', async () => { + expect(pick(thrown(() => service.registerDataset(dataset('hidden_rows', HIDDEN))))).toEqual({ + code: 'OBJECT_API_DISABLED', status: 404, object: HIDDEN, + }); + expect(await service.getMeta('hidden_rows')).toEqual([]); + service.registerDataset(dataset('open_rows', OPEN)); + expect(await listed(service)).toEqual(['open_cube', 'open_rows']); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 721d122eac4..07cb9454e93 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -64,9 +64,11 @@ import { storedMetadataBodyAnalyticsRefusal } from './stored-metadata-body-refus // The generic-exit declarations — `enable` (ADR-0049) and a field's `internal` // flag — asked at this door as every other door asks them. import { + assertDefinitionExposed, assertNoInternalFieldNamed, assertObjectsExposed, type ObjectDeclarationProvider, + type RegisteredDefinitionKind, } from './api-exposure-door.js'; // [#15768] The measure result-type rule — which aggregates return a value of // the aggregated field's own type, and which are numeric whatever they read. @@ -1067,6 +1069,12 @@ export interface AnalyticsServiceConfig { * no gate, reported once at the first query: that is a host constructing * this service by hand. The production bridge in `plugin.ts` always wires it, * from the same schema registry `/data` reads. + * + * [#22663] Also asked when a definition enters the shared registry: a + * configured cube, or a dataset `registerDataset` compiled, whose base + * object or declared join is denied is refused there, and `getMeta` never + * lists it. A throw at registration is no answer, and registers the + * definition as before; the query face still refuses fail-closed. */ getObjectDeclaration?: ObjectDeclarationProvider; /** @@ -1436,18 +1444,17 @@ export class AnalyticsService implements IAnalyticsService { constructor(config: AnalyticsServiceConfig = {}) { this.logger = config.logger || createLogger({ level: 'info', format: 'pretty' }); - this.cubeRegistry = new CubeRegistry(); + // [#22663] Every write to the shared registry is admitted by the + // generic-exit gate's registration face: a cube that reads an object the + // API does not serve for the aggregate operation can never answer a query, + // so it is refused rather than published by `getMeta`. + this.cubeRegistry = new CubeRegistry((cube) => this.assertRegistrable('cube', cube)); this.sharedScope = { getCube: (name) => this.cubeRegistry.get(name), getCompiledDataset: (name) => this.datasetRegistry.get(name), register: (cube) => this.cubeRegistry.register(cube), }; - // Register pre-defined cubes - if (config.cubes) { - this.cubeRegistry.registerAll(config.cubes); - } - this.readScopeProvider = config.getReadScope; this.readAdmissionProvider = config.admitObjectRead; this.readableFieldsProvider = config.getReadableFields; @@ -1474,6 +1481,22 @@ export class AnalyticsService implements IAnalyticsService { // field's doc for the ruling this inherits. this.debugSql = config.debugSql ?? (getEnv('NODE_ENV') === 'development'); + // Register pre-defined cubes. After the probes above are assigned, so the + // registry's admission can ask `getObjectDeclaration`. [#22663] A refused + // cube takes the same per-definition channel the pre-registered datasets + // below take: warned with the refusal's own words (the cube, the object, + // the declaration) and skipped, so one bad definition does not take the + // boot down and every other cube still registers. + if (config.cubes) { + for (const cube of config.cubes) { + try { + this.cubeRegistry.register(cube); + } catch (e) { + this.logger?.warn?.(`[Analytics] Failed to register cube "${cube?.name}": ${String((e as Error)?.message ?? e)}`); + } + } + } + // Compile + register pre-defined datasets (ADR-0021). if (config.datasets) { for (const ds of config.datasets) { @@ -1986,6 +2009,30 @@ export class AnalyticsService implements IAnalyticsService { assertObjectsExposed(objects, provider, this.logger); } + /** + * [#22663] The REGISTRATION face of the object half: a definition entering + * the shared registry — a configured cube, a dataset {@link registerDataset} + * compiled, a cube written to {@link cubeRegistry} directly — is refused when + * its base object or a declared join ({@link cubeObjects}, the set the query + * face's first ask reads) is one the spec's decision denies the aggregate + * operation. Such a cube could never answer: every query of it is refused + * `404 OBJECT_API_DISABLED` / `405 OBJECT_API_METHOD_NOT_ALLOWED`, so + * publishing it through `getMeta` advertised a cube that does not work. + * + * Thrown, located, in the query face's codes; the constructor turns it into + * its per-definition warning. Cannot answer, do not block — the + * compile-time probes' tiering (#5115): an object with no declaration yet + * (registration runs inside the plugin's `init()`, before a later plugin's + * objects are registered) or a lookup that throws is not refused here, and + * the query face still judges it at every query. No provider wired: no gate, + * as on the query face, which reports that once at the first query. + */ + private assertRegistrable(kind: RegisteredDefinitionKind, cube: Cube): void { + const provider = this.objectDeclarationProvider; + if (!provider) return; + assertDefinitionExposed(kind, cube.name, this.cubeObjects(cube), provider); + } + /** * The FIELD half of the generic-exit gate: a member that reads a field its * object declares `internal: true` — a dimension, a measure's input, a @@ -2458,9 +2505,17 @@ export class AnalyticsService implements IAnalyticsService { * under the name, which is why no request path calls it: `queryDataset` * compiles into a request scope instead (#20356). Idempotent. Returns the * compiled dataset. + * + * [#22663] Throws, registering nothing, when the dataset's base object or a + * joined object is one the API does not serve for the aggregate operation + * ({@link assertRegistrable}). */ registerDataset(dataset: Dataset): CompiledDataset { const compiled = this.compile(dataset); + // Judged as the DATASET first, so the refusal names what the caller + // registered. The registry's own admission asks the same question of the + // same compiled cube on the next line, and answers the same. + this.assertRegistrable('dataset', compiled.cube); this.cubeRegistry.register(compiled.cube); this.datasetRegistry.set(dataset.name, compiled); return compiled; diff --git a/packages/services/service-analytics/src/api-exposure-door.ts b/packages/services/service-analytics/src/api-exposure-door.ts index 674ffde5467..28d0e053a7c 100644 --- a/packages/services/service-analytics/src/api-exposure-door.ts +++ b/packages/services/service-analytics/src/api-exposure-door.ts @@ -70,12 +70,34 @@ * spec decision answers "served" for it — the same default every door reads. * A host that constructs `AnalyticsService` with no provider gets no gate and * is told so once; `AnalyticsServicePlugin` always wires one. + * + * ## The registration face — the same decision, asked earlier + * + * A configured cube, or a dataset `registerDataset` compiled, enters the SHARED + * registry that `getMeta` publishes. When its base object or a declared join + * is one the decision denies, every query of it is refused by the query face + * above, so listing it advertises a cube that can never answer. + * {@link assertDefinitionExposed} asks the SAME decision, for the same + * operation and over the same object set the query face's first ask reads (the + * cube's base object and declared joins), at the moment the definition is + * registered, and refuses it with the same codes, naming the definition, the + * object and the declaration. A relationship hop one MEMBER walks is not asked + * here: it refuses that member's queries, not the cube's. + * + * Its tiering is the registration doors' own, the one the compile-time probes + * in `dataset-compiler.ts` already take: cannot answer, do not block. An object + * the provider answers no declaration for is not refused (registration can run + * before every object is registered: a configured cube is registered when the + * plugin's `init()` constructs the service), and a provider that THROWS is not + * an answer either. Neither leaves anything open: the query face still asks + * at every query, fail-closed. */ import { apiExposureDenialReason, effectiveOperationsArray, resolveEffectiveApiMethods, + type ApiExposureDenialReason, type EnableLike, } from '@objectstack/spec/data'; import type { RegisteredErrorCode, StandardErrorCode } from '@objectstack/spec/api'; @@ -152,6 +174,42 @@ function objectApiMethodNotAllowedError(object: string, allowed: string[]): Erro return err; } +/** What a registration refusal names as the refused definition. */ +export type RegisteredDefinitionKind = 'cube' | 'dataset'; + +/** + * The registration face's refusal: the code and status the query face answers + * for the same declaration, with a message that locates it — the definition + * that was not registered, the object it reads, and the declaration that + * denies the operation. + */ +function unexposedDefinitionError( + kind: RegisteredDefinitionKind, + name: string, + object: string, + reason: ApiExposureDenialReason, + enable: EnableLike, +): Error { + const allowed = + reason === 'method-not-allowed' ? effectiveOperationsArray(resolveEffectiveApiMethods(enable)) : undefined; + const declaration = + allowed === undefined + ? '`enable.apiEnabled: false`' + : `\`enable.apiMethods\`, which grants ${allowed.length > 0 ? allowed.map((op) => `'${op}'`).join(', ') : 'no operation'}`; + const code = allowed === undefined ? OBJECT_API_DISABLED_CODE : OBJECT_API_METHOD_NOT_ALLOWED_CODE; + const status = allowed === undefined ? 404 : 405; + const err = new Error( + `[Analytics] The ${kind} "${name}" was not registered: it reads the object "${object}", whose ${declaration} ` + + `denies the '${ANALYTICS_OPERATION}' operation an analytics query is judged as (${reason}), so every query ` + + `of it would be refused ${status} ${code}. Point the ${kind} at an object the API serves, or remove it.`, + ) as DoorRefusal; + err.code = code; + err.status = status; + err.object = object; + if (allowed !== undefined) err.allowed = allowed; + return err; +} + /** `403 PERMISSION_DENIED` — the declaration could not be read, so nothing runs. */ function exposureUnresolvedError(object: string): Error { const err = new Error( @@ -221,6 +279,38 @@ export function assertObjectsExposed( } } +/** + * The registration face (see the module doc): refuse to register the `kind` + * named `name` when an object in `objects` — its base object and declared + * joins — is one the spec's decision denies the aggregate operation. The first + * denied object, in the set's order, is the one named. + * + * Cannot answer, do not block: an object the provider answers no declaration + * for is not refused, and neither is one whose lookup throws. Registration + * records nothing in either case beyond what it recorded before this face + * existed, and the query face still judges the object at every query. + */ +export function assertDefinitionExposed( + kind: RegisteredDefinitionKind, + name: string, + objects: Iterable, + provider: ObjectDeclarationProvider, +): void { + for (const object of objects) { + let enable: EnableLike | null | undefined; + try { + enable = (provider(object) ?? undefined)?.enable as EnableLike | null | undefined; + } catch { + // No answer at registration time. The query face refuses fail-closed when + // the same lookup throws for a query, so nothing is served on this branch. + continue; + } + const reason = apiExposureDenialReason(enable, ANALYTICS_OPERATION); + if (reason === null) continue; + throw unexposedDefinitionError(kind, name, object, reason, enable!); + } +} + /** * Refuse the query when any member it names reads a field its object declares * `internal: true` — in every position, every role. `named` is what the door's diff --git a/packages/services/service-analytics/src/cube-registry.ts b/packages/services/service-analytics/src/cube-registry.ts index 82ade36f2a2..8e8ae008297 100644 --- a/packages/services/service-analytics/src/cube-registry.ts +++ b/packages/services/service-analytics/src/cube-registry.ts @@ -11,8 +11,9 @@ import type { Cube } from '@objectstack/spec/data'; * chain resolves a query's cube through it. * * Two sources write to it, both of them configuration, from `AnalyticsService`: - * 1. **Manifest definitions** — `AnalyticsServiceConfig.cubes` (`registerAll`), - * i.e. explicit cube definitions authored in `objectstack.config.ts`. + * 1. **Manifest definitions** — `AnalyticsServiceConfig.cubes` (one `register` + * per cube), i.e. explicit cube definitions authored in + * `objectstack.config.ts`. * 2. **Compiled datasets** (ADR-0021) — `compileDataset()`'s Cube, registered * under the dataset's name by `registerDataset`: the constructor's * `datasets`, or an embedder. ⛔ Never by `queryDataset`, which compiles a @@ -34,16 +35,41 @@ import type { Cube } from '@objectstack/spec/data'; * repository calls (#15019). It is described at the method rather than * advertised here, because listing it would promise a source the platform does * not deliver. + * + * ## Admission — every write, one place + * + * The constructor takes an optional {@link CubeAdmission}, asked by + * {@link CubeRegistry.register} before the cube is stored, and every other write + * here goes through `register`. `AnalyticsService` passes the generic-exit + * gate's registration face (`api-exposure-door.ts`), so a cube whose base + * object or declared join the API does not serve is refused on every write + * path: the configured cubes, `registerDataset`, and a caller holding the + * service's public `cubeRegistry` directly. A registry constructed without one + * admits every cube, as it always has. */ +export type CubeAdmission = (cube: Cube) => void; + export class CubeRegistry { private cubes = new Map(); - /** Register a single cube definition. Overwrites if name already exists. */ + /** @param admit Asked before every write; a throw refuses the cube and stores nothing. */ + constructor(private readonly admit?: CubeAdmission) {} + + /** + * Register a single cube definition. Overwrites if name already exists. The + * admission hook, when there is one, is asked first, and its throw is this + * call's: the registry is left exactly as it was. + */ register(cube: Cube): void { + this.admit?.(cube); this.cubes.set(cube.name, cube); } - /** Register multiple cube definitions at once. */ + /** + * Register multiple cube definitions, in order. A refused cube throws at its + * turn, and the ones before it stay registered; a caller that wants every + * admissible cube registered calls {@link register} per cube. + */ registerAll(cubes: Cube[]): void { for (const cube of cubes) { this.register(cube); From c8dae0a783a570abce8df11ecf39f869d23c05f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 10:36:40 +0000 Subject: [PATCH 2/5] changeset: service-analytics registration refuses a definition over an unexposed object (minor, narrowing) Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- ...3-analytics-unexposed-cube-registration.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .changeset/22663-analytics-unexposed-cube-registration.md diff --git a/.changeset/22663-analytics-unexposed-cube-registration.md b/.changeset/22663-analytics-unexposed-cube-registration.md new file mode 100644 index 00000000000..eb01321f287 --- /dev/null +++ b/.changeset/22663-analytics-unexposed-cube-registration.md @@ -0,0 +1,31 @@ +--- +'@objectstack/service-analytics': minor +--- + +fix(service-analytics)!: a configured cube, or a dataset `registerDataset` registers, over an object the API does not serve is refused at registration, and `GET /analytics/meta` no longer lists it (#22663) + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +Since the analytics door started judging an object's `enable` block, every query over an object whose `enable` the spec's `apiExposureDenialReason` denies for the `aggregate` operation is refused: `404 OBJECT_API_DISABLED` for `apiEnabled: false`, `405 OBJECT_API_METHOD_NOT_ALLOWED` for an `apiMethods` whitelist that does not grant it. The registration doors did not ask the same question. + +**FROM.** A configured cube (`analyticsCubes`, or `AnalyticsServicePlugin({ cubes })`), or a dataset registered through `registerDataset` (or the service's `datasets` option), whose base object or declared join is such an object registered silently. `GET /analytics/meta` listed it, and every query of it then answered 404 or 405. Nothing told the author at registration time. + +**TO.** Registration asks the same decision, for the same operation, over the same objects the query door's first check reads: the base object and the declared joins. + +- **A configured cube** over such an object is not registered. The service logs one warning per cube that names the cube, the object and the declaration (`enable.apiEnabled: false`, or the `enable.apiMethods` whitelist and the operations it grants). Every other cube still registers, and the boot continues. This is the channel a pre-registered dataset that cannot compile already takes. +- **`registerDataset`** throws, registering nothing. The error names the dataset, the object and the declaration, and carries the query door's code and status for that declaration (`OBJECT_API_DISABLED` / 404 or `OBJECT_API_METHOD_NOT_ALLOWED` / 405) with the object. +- **A cube written straight to the service's public `cubeRegistry`** is refused by the registry's own admission in the same words. +- **`GET /analytics/meta`** never lists a refused definition. + +**Unchanged.** + +- A cube or dataset over an exposed object registers and lists exactly as before. +- A host that wires no `getObjectDeclaration` hook registers every definition as before. `AnalyticsServicePlugin` always wires it. +- Registration judges only what is known when it runs, and does not block otherwise. An object that is not registered yet when the analytics plugin's `init()` registers the configured cubes is not refused, and neither is one whose lookup throws. Objects declared by a plugin that inits later, or in a `start()`, fall in this class. Such a cube registers and lists as before, and the query door still refuses every query of it. +- A relationship hop that one member walks is judged at query time, per member, and does not refuse the whole cube. + +**Measured producers.** On `origin/main` `ee3ae0360d`, this repository has one authored cube, `showcase_delivery` (`showcase_task` joined to `showcase_project`), and no `registerDataset` producer outside tests. Neither object declares an `enable` block. The eleven example and platform datasets (six in `examples/**`, five in `platform-objects`) are queried through the request-scoped dataset door and never enter the registry. Deployed and cloud-held definitions were NOT MEASURED. From 393b179be6b23f8a28fa50c12cdc99c12ca3003f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 11:50:03 +0000 Subject: [PATCH 3/5] fix(spec/liveness): re-anchor analytics_cube producers on analytics-service.ts#register The constructor now registers configured cubes one at a time through this.cubeRegistry.register(cube), so the 16 producer citations that named registerAll pointed at a symbol the file no longer contains. Value-only: only the anchor's symbol moves. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- packages/spec/liveness/analytics_cube.json | 32 +++++++++++----------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/spec/liveness/analytics_cube.json b/packages/spec/liveness/analytics_cube.json index d1a66db7a59..225858ad0ee 100644 --- a/packages/spec/liveness/analytics_cube.json +++ b/packages/spec/liveness/analytics_cube.json @@ -6,28 +6,28 @@ "status": "live", "verifiedAt": "2026-09-17", "evidence": "packages/services/service-analytics/src/cube-registry.ts#register — `this.cubes.set(cube.name, cube)`: the authored name IS the registry key, so it decides what `AnalyticsQuery.cube` can resolve at all; packages/services/service-analytics/src/analytics-service.ts#getMeta emits it as the cube's `name` and as the `${cube.name}.${key}` member id of every measure and dimension served by `GET /api/v1/analytics/meta`; packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveMeasureSql names it in the `INVALID_FIELD` refusal for an undeclared member.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Identity, not display — `title` is the display half. A second cube registered under the same name silently replaces the first (`register` is a `Map.set`)." }, "title": { "status": "live", "verifiedAt": "2026-09-17", "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — `title: cube.title` is the cube's display title on `GET /api/v1/analytics/meta`, the discovery surface a dashboard/report builder picks a cube from; packages/drivers/driver-memory/src/memory-analytics.ts#getMeta answers the identical projection for the in-memory driver, so the value reaches the wire on both execution paths.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Display-shaped, so the #7131 split settles it: being shown to a human IS the whole of the claimed effect, and there is no second layer where a 'real' consumer would live. The read point is the platform's own analytics discovery endpoint, NOT a metadata-admin preview — this type has none (enumerated in the file note above). `label` is the metric/dimension spelling of the same idea and is declared as an ALIAS of `title` on the cube (`strictObject` aliases), so a mis-spelled `label:` is refused with the corrective name rather than dropped." }, "description": { "status": "live", "verifiedAt": "2026-09-29", "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — the cube's `description` is copied onto its `CubeMeta` entry on `GET /api/v1/analytics/meta` when the definition declares one (and left off when it does not), the discovery surface a dashboard/report builder or an API client picks a cube from — the read point the `title` row above cites. Pinned in packages/services/service-analytics/src/__tests__/cube-meta-description-format.test.ts.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Dead until 2026-09-29: the `CubeMeta` projection carried `name`, `title`, `measures` and `dimensions` and no `description`, so the value was parsed, stored and read by nothing. Display-shaped, so the #7131 split settles it the way it settles `title`: being shown is the whole of the claimed effect, and the platform's own discovery endpoint is the read point — this type has no metadata-admin preview (the file note). `@objectstack/driver-memory`'s standalone `MemoryAnalyticsService#getMeta` does NOT project it: no in-repo composition registers that service as the `analytics` service (the `public` row), and the contract declares the key optional. A compiled dataset's cube carries none: the dataset compiler does not copy the dataset's own `description` onto the cube it mints." }, "sql": { "status": "live", "verifiedAt": "2026-09-17", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#extractObjectName — `return cube.sql.trim()` is the FROM table of every generated statement and the object whose RLS/tenant read scope is injected (`applyReadScope(this.extractObjectName(cube), …)`); packages/services/service-analytics/src/strategies/objectql-strategy.ts#extractObjectName resolves the same value for the `engine.aggregate` path; packages/services/service-analytics/src/analytics-service.ts#cubeObjects adds `cube.sql.trim()` to the object set the tenant scope is computed over; packages/drivers/driver-memory/src/memory-analytics.ts#extractTableName reads it as the in-memory collection name.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "'Base SQL statement or Table Name' in the describe, and the platform's own aliases (`table`, `sqlTable`) say which half is load-bearing: every consumer treats it as an OBJECT/TABLE name. Authoring it wrong is not silent — an unknown object fails the query." }, "measures": { @@ -41,35 +41,35 @@ "status": "live", "verifiedAt": "2026-09-17", "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — `title: measure.label` is the human title of each measure on `GET /api/v1/analytics/meta`; packages/drivers/driver-memory/src/memory-analytics.ts#getMeta emits the identical projection.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Display-shaped and settled by the #7131 split, same as the cube's `title`. `CubeRegistry`'s own header names this read point ('maps every registered cube's measure/dimension `label` onto the `CubeMeta` titles served by GET /api/v1/analytics/meta'). REQUIRED on the schema, so there is no empty state to classify." }, "description": { "status": "live", "verifiedAt": "2026-09-29", "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — each measure's `description` is copied onto that measure's entry in `CubeMeta.measures` on `GET /api/v1/analytics/meta` when the definition declares one, beside the `title` its `label` becomes (the `label` row). Pinned in packages/services/service-analytics/src/__tests__/cube-meta-description-format.test.ts.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Dead until 2026-09-29, for the reason the cube-level `description` row gives; live on the same reading (#7131 display-shaped, the discovery endpoint as the read point). A compiled dataset's cube never carries one: a dataset measure has no `description` key to copy (the dataset schema refuses it and points to the dataset's own `description`)." }, "type": { "status": "live", "verifiedAt": "2026-10-02", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveMeasureSql — `aggregateOfMeasure(cube.name, member, measure.type)` resolves the aggregate, `AGGREGATE_SQL[aggregate]` picks the function the column is wrapped in (SUM / COUNT / AVG / …) and `CONDITIONAL_AGGREGATE_SQL[aggregate]` its conditional form for a scoped measure filter; packages/services/service-analytics/src/strategies/objectql-strategy.ts#resolveMeasureAggregation hands the same verdict to the engine as the aggregation `method`. A type no aggregate lowers is refused in the spec's words rather than substituting `COUNT(*)` (#4157). packages/services/service-analytics/src/analytics-service.ts#getMeta also publishes it as the measure's `type` on `GET /api/v1/analytics/meta`.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "The arithmetic itself: this key decides what number the query returns. NARROWED 2026-10-02 (#21000, ADR-0049 enforce-or-remove): the custom-SQL-expression values `number` / `string` / `boolean` were retired from `AggregationMetricType` — a member's `sql` is a column reference, so they had nothing left to compute — and are refused at parse with a prescription naming the six aggregates (`enumWithRetiredValues`); the strategies' `EXPRESSION_METRIC_TYPES` partition went with them, and both strategies refuse a cube that reached them unparsed with the same text (`aggregateOfMeasure`). Still LIVE: the key itself is unchanged and read at the sites above. `metric-type-coverage.test.ts` pins `AGGREGATE_SQL`'s keys EQUAL to the enum's options, so no spec-valid value can fall through. The D3 entry is `cube-metric-expression-types-retired`; there is no D2 conversion, because the column alone does not say which aggregate the author meant." }, "sql": { "status": "live", "verifiedAt": "2026-10-02", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveMeasureSql — `measure.sql` is the column the aggregate is applied to (`'*'` the COUNT(*) form), and `qualifyAndRegisterJoin(measure.sql, …)` is what lowers a dotted reference into a LEFT JOIN chain; packages/services/service-analytics/src/strategies/objectql-strategy.ts#resolveFieldName reads `measure.sql.replace(/^\\$/, '')` as the aggregate field for the `engine.aggregate` path; packages/services/service-analytics/src/analytics-service.ts#fieldsOfColumnSql resolves it to the field(s) the analytics door's field-level read gate judges.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "REQUIRED. NARROWED 2026-09-30 (#20943, maintainer ruling D; ADR-0021 zero raw expressions, ADR-0049 enforce-or-remove): the value is a COLUMN REFERENCE — a bare identifier, a dotted identifier path (relationship hops, then the column), or `'*'` — and any SQL expression is refused at parse with a prescription naming the ADR-0021 dataset form (a measure-scoped `filter`, `derived: { op, of }`). The admitted pattern is the one `IDENTIFIER_PATH` (native-sql-strategy.ts) and the read gate already use to tell a column path from an expression (#4157), so every admitted value other than `'*'` (which reads no field value) resolves to a field the gate can judge. Still LIVE: the key itself is unchanged and read at the three sites above. Both runtime expression branches are gone: the field-level read gate's stand-down with #20965, and the raw-SQL path's verbatim emit with #21000. What remains for a cube that reaches the service without meeting the parse is packages/services/service-analytics/src/strategies/native-sql-strategy.ts#qualifyAndRegisterJoin passing a non-column `sql` through as-is, inside the measure's aggregate (`SUM(amount + 1)`, `SUM(COALESCE(account.amount, 0) / 2)`, measured 2026-10-02 through `AnalyticsService#generateSql` with no field reader wired). The D3 entry is `cube-member-sql-expression-retired`; there is no D2 conversion, because an expression has no mechanical rewrite into a dataset. NARROWED again 2026-10-02 (#21409, the count-only boundary): `'*'` is admitted only under `type: 'count'` — the measure's refinement asks the one predicate it shares with the dataset measure (`data/analytics-column-reference.ts#rowWildcardOutsideCount`), and refuses `'*'` under any other type at `sql`, naming the slot and prescribing a `count` or a column; a dataset `sum` over `'*'`, which compiles to this very member, answered 500 DATABASE_ERROR at `POST /analytics/dataset/query` on both strategies before the rule. Cross-field, so a declared dropped-refinement site (`data/Metric` in `dropped-refinements.baseline.json`). D3 entry `analytics-row-wildcard-outside-count-refused`; no D2 conversion." }, "format": { "status": "live", "verifiedAt": "2026-09-29", "evidence": "packages/services/service-analytics/src/analytics-service.ts#withDeclaredMeasureFormats — every measure column a query names leaves `query()` (`POST /api/v1/analytics/query`) carrying the `format` its cube measure declares, as `fields[].format`, the presentation slot `AnalyticsResult` documents. It runs at the one seam every strategy's result leaves through (`queryIn`, beside the SQL-echo gate), so the column is described whichever of NativeSQL, ObjectQL or a delegated fallback answered, and on the dataset door's queries too. Pinned per strategy, each case against a compiled-dataset control, in packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts, and over the route in packages/runtime/src/analytics-authored-cube-format-granularity.test.ts.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Dead until 2026-09-29: the DATASET compiler wrote it and nothing read it back, because the value a caller received was threaded from the DATASET measure (`enrichResultColumns`), and an authored cube has no dataset — so a hand-written `format:` reached no reader and its column went out with `name` and `type` only. It is now read off the cube, for every cube that answers the name; on the dataset path the value read is the compiler's copy of the dataset measure's own `format`, the one `enrichResultColumns` writes anyway, so that path is unchanged. Also on discovery since 2026-09-29: `analytics-service.ts#getMeta` copies it onto the measure's `CubeMeta` entry on `GET /api/v1/analytics/meta` (the additive return path the narrowing recorded on `AnalyticsMetadataResponseSchema`, #6442, names); content/docs/api/data-api.mdx still sends a client that formats amounts to `fields[]`, the per-column surface. The slot's vocabulary is the one `fields[].format` and the dataset measure's `format` both document, a numeral pattern (\"$0,0\", \"0.0%\"); a named style is relayed verbatim and is not a pattern. ⛔ Retirement was never the remedy: the key is also the dataset compiler's output slot on the shared shape." } } @@ -85,35 +85,35 @@ "status": "live", "verifiedAt": "2026-09-17", "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — `title: dimension.label` is the human title of each dimension on `GET /api/v1/analytics/meta`; packages/drivers/driver-memory/src/memory-analytics.ts#getMeta emits the identical projection.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Display-shaped, #7131 split, same as `measures.label`. The dataset side of the same field is where #6761 was found (an inline locale map was dropped and the machine name published as a title); a hand-authored cube declares a plain string, so that resolver is not in this path." }, "description": { "status": "live", "verifiedAt": "2026-09-29", "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — each dimension's `description` is copied onto that dimension's entry in `CubeMeta.dimensions` on `GET /api/v1/analytics/meta` when the definition declares one. Pinned in packages/services/service-analytics/src/__tests__/cube-meta-description-format.test.ts.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Dead until 2026-09-29, for the reason the cube-level `description` row gives; live on the same reading. A compiled dataset's cube never carries one: a dataset dimension has no `description` key to copy." }, "type": { "status": "live", "verifiedAt": "2026-09-17", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#carriesUninterpretableTemporalComparand — `this.lookupMember(cube, member, 'dimension')?.type === 'time' ? 'datetime' : null` decides how a filter comparand is shaped before it is bound as a parameter; packages/services/service-analytics/src/analytics-service.ts#getMeta publishes it as the dimension's `type` on `GET /api/v1/analytics/meta`; packages/services/service-analytics/src/dataset-executor.ts#timeDimensionsOf selects the bucketable dimensions with `compiled.cube.dimensions[d]?.type === 'time'`.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "`time` is the member that carries behaviour (comparand shaping, bucketability); the other members are published and otherwise inert. The dataset-executor citation is on the compiled-dataset path — it is listed because it reads the same key off the same shape, and the authoring-door consumer is the comparand shaping in native-sql-strategy." }, "sql": { "status": "live", "verifiedAt": "2026-10-02", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveDimensionSql — `dim.sql` is the GROUP BY / SELECT column, and `qualifyAndRegisterJoin(dim.sql, …)` lowers a dotted path into the LEFT JOIN chain; packages/services/service-analytics/src/strategies/objectql-strategy.ts#resolveFieldName reads `dim.sql.replace(/^\\$/, '')` as the group-by field for the `engine.aggregate` path; packages/services/service-analytics/src/analytics-service.ts#fieldsOfColumnSql resolves it to the field(s) the analytics door's field-level read gate judges.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "REQUIRED. NARROWED 2026-09-30 with `measures.sql` (#20943, maintainer ruling D): a bare identifier or a dotted identifier path; a SQL expression — a CASE bucket included — is refused at parse. NARROWED again 2026-10-02 (#21409, the count-only boundary): `'*'`, which ruling D's execution parameters had named in the accept set of both members and which never made a working query on a dimension (`GROUP BY *`), is refused too — no aggregate consumes it on a dimension, so the slot takes `ANALYTICS_COLUMN_PATH` (`data/analytics-column-reference.ts`), the dataset dimension's own pattern, and the prescription sends a row count to a `count` measure. A pattern, so the published JSON Schema carries it. D3 entry `analytics-row-wildcard-outside-count-refused`. A bucket computed over a column's values has no expression form in the cube or the dataset layer; the prescription sends it to a field of the object that a dimension names. Still LIVE, at the sites above." }, "granularities": { "status": "live", "verifiedAt": "2026-09-29", "evidence": "packages/services/service-analytics/src/analytics-service.ts#withDeclaredGranularityDefaults — on `query()` (`POST /api/v1/analytics/query`) and `generateSql()` (`POST /api/v1/analytics/sql`), before the source-field gates and strategy selection run, a time dimension the query GROUPS BY with no stated granularity is bucketed at the default its cube dimension declares; packages/services/service-analytics/src/dataset-executor.ts#declaredDefaultGranularity is that default — the one reading of this key, a single-entry list — shared with the compiled-dataset path's `granularityOf`, so both producers' cubes are read by one rule. Pinned against a compiled-dataset control in packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts, and over both routes in packages/runtime/src/analytics-authored-cube-format-granularity.test.ts.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Dead until 2026-09-29: the one read (`granularityOf`) took a `CompiledDataset`, which an authored cube never becomes, so grouping by an authored time dimension grouped raw timestamps whatever it declared. What the key does now is exactly the compiled-dataset path's reading: a SINGLE-entry list is the dimension's default bucket; a stated `timeDimensions[].granularity` always wins; a multi-entry list (the compiler's five-entry 'all intervals' shape included) states no default; an entry carrying only a `dateRange` for a dimension the query does not group stays a filter. ⚠️ What it does NOT do: refuse a requested granularity the list does not name — the dataset path compares against no list, so neither does this — and whether a multi-entry list should narrow the accept set (the 'Supported Granularities' reading of the declaration) is an open question on #20282, not decided by this row. A declared default routes a grouped query to the engine path, because NativeSQL declines any bucketed query, and the engine path answers 400 `INVALID_FIELD` — the same answer a caller who states that granularity by hand gets — for every member it cannot evaluate: a custom-SQL measure (`ObjectQLStrategy.resolveMeasureAggregation`), and, from `ObjectQLStrategy.planCrossObject`, on a cube whose members resolve through its `joins`: a measure or `where` field over a joined object, a `timeDimensions` entry over a joined object (bucketed or a window), a dimension that traverses more than one relationship, and an `avg` / `count_distinct` measure beside a dimension over a joined object (planCrossObject's two dataset-definition arms cannot fire on an authored cube). NativeSQL serves all of them, so each goes from answered to refused when grouped by a one-interval dimension. On a host whose `queryCapabilities` offers raw SQL with no engine aggregate bridge (a hand override), no strategy remains, so every newly bucketed query answers \"No strategy can handle query\". The declared narrowing and its D3 entry `analytics-cube-single-granularity-default-enforced` name this class. The dataset-side authoring key is still `dataset.dimensions[].dateGranularity` (dataset.json), which the compiler lowers into this slot. ⛔ Retirement was never the remedy." } } @@ -124,7 +124,7 @@ "status": "live", "verifiedAt": "2026-09-17", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#qualifyAndRegisterJoin — `const joinTable = cube?.joins?.[alias]?.name ?? alias` is the TABLE the LEFT JOIN is emitted against, which is what makes an alias differ from its object (`account` -> `crm_account`); packages/services/service-analytics/src/strategies/native-sql-strategy.ts#crossFieldComparisonIn resolves `cube.joins?.[alias]?.name ?? alias` for every joined object when it walks the RLS read scopes; packages/services/service-analytics/src/analytics-service.ts#cubeObjects adds every `cube.joins[*].name` to the tenant-scoped object set; packages/services/service-analytics/src/strategies/objectql-strategy.ts#isCrossObjectField resolves the same value for the aggregate path.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Security-relevant, not cosmetic: the joined object resolved here is what the read scope is computed for, so a wrong name would scope the wrong table. Note the JOIN is keyed by the record key (the alias) — `name` is its TARGET. It is also the WHOLE of the join contract since #18612: `relationship` and `sql` were removed (ADR-0049 enforce-or-remove), so their rows left this ledger with the keys — the strict-deletion route drops the row, and an orphan row for a key no longer in the walked shape is its own gate failure." } } @@ -138,7 +138,7 @@ "status": "live", "verifiedAt": "2026-09-27", "evidence": "packages/services/service-analytics/src/cube-visibility.ts#isCubePublic — the one reader: a cube is exposed unless it declares `public: false` (the registry holds the input shape, so an omitted key reads as the schema default, visible); packages/services/service-analytics/src/analytics-service.ts#getMeta filters every cube it serves on `GET /api/v1/analytics/meta` through it, so a hidden cube is omitted and a `?cube=` naming one answers `[]`; packages/services/service-analytics/src/analytics-service.ts#assertCubePublic runs first in `query()` (`POST /api/v1/analytics/query`) and in `generateSql()` (`POST /api/v1/analytics/sql`), and on the dataset door's queries too, which run through the same body asked of the call's own request scope, refusing a hidden cube with packages/services/service-analytics/src/cube-visibility.ts#cubeNotFoundError — the same `CUBE_NOT_FOUND` / 404 refusal, byte for byte, that a name which is neither a cube nor an object gets, so the refusal does not confirm a hidden cube exists — before token resolution, cube inference, admission or any strategy runs. Pinned door by door in packages/services/service-analytics/src/__tests__/cube-public-visibility.test.ts.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "VISIBILITY on the analytics API — the Cube.dev semantics this schema follows — not row security: an object's records stay governed by its permissions and RLS on every door (`assertReadAdmitted` and the injected read scope), whether or not a cube over it is hidden; what `false` does is leave the cube out of `/analytics/meta` and refuse queries and SQL generation against it; the definition stays readable on the metadata door (below). Dead until 2026-09-27 — declared `z.boolean().default(false)` under an 'Access Control' comment and read by nothing, so `public: false` withheld nothing (false compliance). Enforced in the same change that moved the default to `true`: enforcing the old default as declared would have hidden every authored cube. The three INTERNAL mints (`inferCubeFromQuery`, `compileDataset`, `CubeRegistry.inferFromObject`) wrote the old default `false` as a literal; they now write `true`, because the ad-hoc KPI path and the dataset door both reach their own minted cube through the refusing `query()`. The one in-repo author of `public: false` was `examples/app-showcase`, whose cube IS the app's /analytics/* demonstration — it now omits the key. Not a door: the metadata API (`/api/v1/meta/analytics_cube`) is the model-authoring surface (the Cube.dev schema-file split) and keeps serving every cube definition, hidden ones included, to its authenticated callers; and `@objectstack/driver-memory`'s standalone `MemoryAnalyticsService` does not read the key — no in-repo composition registers it as the `analytics` service, and behind `AnalyticsService` it is reached only through the gated `query()`/`generateSql()`." } } From dabf17b4b8be2947426c4f9a1b0fee766898b412 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 12:13:28 +0000 Subject: [PATCH 4/5] =?UTF-8?q?fix(spec/liveness,changeset):=20analytics?= =?UTF-8?q?=5Fcube=20producer=20rows=20re-verified;=20Clause-=E2=91=A1=20d?= =?UTF-8?q?eclared=20yes=20(narrowing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 16 rows whose producer runs through the AnalyticsService constructor carry verifiedAt 2026-10-10, and their shared producer quotes the code at HEAD (serve.ts resolveStackCollection fallback; one register per cube with the exposure admission). The _note no longer says registerAll. Value-only: no status, key or row moves. The changeset declares Clause-② yes (narrowing): CubeRegistry is re-exported and its optional admit parameter enlarges the published surface. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- ...3-analytics-unexposed-cube-registration.md | 2 +- packages/spec/liveness/analytics_cube.json | 66 +++++++++---------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/.changeset/22663-analytics-unexposed-cube-registration.md b/.changeset/22663-analytics-unexposed-cube-registration.md index eb01321f287..706cd50f3dc 100644 --- a/.changeset/22663-analytics-unexposed-cube-registration.md +++ b/.changeset/22663-analytics-unexposed-cube-registration.md @@ -4,7 +4,7 @@ fix(service-analytics)!: a configured cube, or a dataset `registerDataset` registers, over an object the API does not serve is refused at registration, and `GET /analytics/meta` no longer lists it (#22663) -Clause-②: no (narrowing) +Clause-②: yes (narrowing) diff --git a/packages/spec/liveness/analytics_cube.json b/packages/spec/liveness/analytics_cube.json index 225858ad0ee..a780de2e24c 100644 --- a/packages/spec/liveness/analytics_cube.json +++ b/packages/spec/liveness/analytics_cube.json @@ -1,33 +1,33 @@ { "type": "analytics_cube", - "_note": "CubeSchema (packages/spec/src/data/analytics.zod.ts). Seeded 2026-09-17 (#18582): the LAST of the three PENDING_GOVERNANCE debts #18133 declared when PR #18581 widened the governance denominator from the registered kinds to `authorableTypes()`; `sharing_rule` was paid first (PR #18587) and `connector` is paid in the same diff as this file, which empties the map. NOT a registered metadata KIND — it is bound in `UNREGISTERED_KIND_SCHEMAS` (commit 2306a765c) and reaches this walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so the ledger governs it while `listMetadataTypeSchemaTypes()` still does not enumerate it. THE SHAPE FACT THAT DECIDES EVERY ROW BELOW: one Cube shape, THREE producers, one registry. `packages/services/service-analytics/src/cube-registry.ts` names them itself — (1) authored cubes (`defineStack({ analyticsCubes })` / `defineCube()`), threaded by the CLI into `AnalyticsServiceConfig.cubes` and registered by `registerAll`; (2) COMPILED DATASETS (ADR-0021), where `dataset-compiler.ts` MINTS a Cube from a `dataset` document; (3) ad-hoc query inference (`inferCubeFromQuery`). Only (1) is the authoring door this ledger governs, so a key whose only reader sits on path (2) is NOT live here however busy that reader is — that is the #4837 producer rule applied to a shape with three producers, and it is what kept `dimensions.granularities` and `measures.format` dead until 2026-09-29, when the query doors began reading both off whichever cube answers the name (their rows). Every `live` row therefore carries a `producer` naming the CLI threading site: a consumer citation alone would be the `seed.env` shape, where the mechanism was right and nobody supplied the input. THE END-TO-END MEASUREMENT IS NOT PREJUDGED: the PENDING_GOVERNANCE row this file discharges said whether cube authoring is live end-to-end is its own measurement and 'this row does not prejudge it'. This ledger does not answer that question either — it answers the per-key one (who reads this key?), and the answers below are mixed: the query path (`sql`, `measures.sql`/`.type`, `dimensions.sql`/`.type`, `joins.name`) is genuinely consumed, and so is the visibility key `public` (enforced at discovery and at every query door since 2026-09-27 — its row), and so are the measure display `format` and the dimension default bucket `granularities` (read on the query doors since 2026-09-29 — their rows), and so are the three `description` annotations (published on discovery by `getMeta` since 2026-09-29 — their rows), while the retired caching block is not. PREVIEW READ POINTS ENUMERATED (the #7131 mechanical rule, objectui @dda8f3815): `registerBuiltinPreviews()` in packages/app-shell/src/views/metadata-admin/previews/index.ts registers nineteen types and `analytics_cube` is NOT one of them — this type has no registered metadata-admin preview. Recorded rather than skipped, because 'the type has no registered preview' is the sentence a later sweep needs. What objectui DOES consume is the whole SHAPE: `clientValidation.ts` maps `analytics_cube` to `CubeSchema` itself, and unlike `sharing_rule` it is absent from `AUTHOR_SHAPE_ONLY_TYPES`, so both the CREATE and the EDIT door in metadata-admin refuse a cube this schema rejects. ADR-0054: no row here carries a `proof`, and none is owed — the `analytics` high-risk class binds `dataset/dimensions.dateGranularity` (the dataset door), not this type.", + "_note": "CubeSchema (packages/spec/src/data/analytics.zod.ts). Seeded 2026-09-17 (#18582): the LAST of the three PENDING_GOVERNANCE debts #18133 declared when PR #18581 widened the governance denominator from the registered kinds to `authorableTypes()`; `sharing_rule` was paid first (PR #18587) and `connector` is paid in the same diff as this file, which empties the map. NOT a registered metadata KIND — it is bound in `UNREGISTERED_KIND_SCHEMAS` (commit 2306a765c) and reaches this walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so the ledger governs it while `listMetadataTypeSchemaTypes()` still does not enumerate it. THE SHAPE FACT THAT DECIDES EVERY ROW BELOW: one Cube shape, THREE producers, one registry. `packages/services/service-analytics/src/cube-registry.ts` names them itself — (1) authored cubes (`defineStack({ analyticsCubes })` / `defineCube()`), threaded by the CLI into `AnalyticsServiceConfig.cubes` and registered one cube at a time by `register`; (2) COMPILED DATASETS (ADR-0021), where `dataset-compiler.ts` MINTS a Cube from a `dataset` document; (3) ad-hoc query inference (`inferCubeFromQuery`). Only (1) is the authoring door this ledger governs, so a key whose only reader sits on path (2) is NOT live here however busy that reader is — that is the #4837 producer rule applied to a shape with three producers, and it is what kept `dimensions.granularities` and `measures.format` dead until 2026-09-29, when the query doors began reading both off whichever cube answers the name (their rows). Every `live` row therefore carries a `producer` naming the CLI threading site: a consumer citation alone would be the `seed.env` shape, where the mechanism was right and nobody supplied the input. THE END-TO-END MEASUREMENT IS NOT PREJUDGED: the PENDING_GOVERNANCE row this file discharges said whether cube authoring is live end-to-end is its own measurement and 'this row does not prejudge it'. This ledger does not answer that question either — it answers the per-key one (who reads this key?), and the answers below are mixed: the query path (`sql`, `measures.sql`/`.type`, `dimensions.sql`/`.type`, `joins.name`) is genuinely consumed, and so is the visibility key `public` (enforced at discovery and at every query door since 2026-09-27 — its row), and so are the measure display `format` and the dimension default bucket `granularities` (read on the query doors since 2026-09-29 — their rows), and so are the three `description` annotations (published on discovery by `getMeta` since 2026-09-29 — their rows), while the retired caching block is not. PREVIEW READ POINTS ENUMERATED (the #7131 mechanical rule, objectui @dda8f3815): `registerBuiltinPreviews()` in packages/app-shell/src/views/metadata-admin/previews/index.ts registers nineteen types and `analytics_cube` is NOT one of them — this type has no registered metadata-admin preview. Recorded rather than skipped, because 'the type has no registered preview' is the sentence a later sweep needs. What objectui DOES consume is the whole SHAPE: `clientValidation.ts` maps `analytics_cube` to `CubeSchema` itself, and unlike `sharing_rule` it is absent from `AUTHOR_SHAPE_ONLY_TYPES`, so both the CREATE and the EDIT door in metadata-admin refuse a cube this schema rejects. ADR-0054: no row here carries a `proof`, and none is owed — the `analytics` high-risk class binds `dataset/dimensions.dateGranularity` (the dataset door), not this type.", "props": { "name": { "status": "live", - "verifiedAt": "2026-09-17", + "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/cube-registry.ts#register — `this.cubes.set(cube.name, cube)`: the authored name IS the registry key, so it decides what `AnalyticsQuery.cube` can resolve at all; packages/services/service-analytics/src/analytics-service.ts#getMeta emits it as the cube's `name` and as the `${cube.name}.${key}` member id of every measure and dimension served by `GET /api/v1/analytics/meta`; packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveMeasureSql names it in the `INVALID_FIELD` refusal for an undeclared member.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Identity, not display — `title` is the display half. A second cube registered under the same name silently replaces the first (`register` is a `Map.set`)." }, "title": { "status": "live", - "verifiedAt": "2026-09-17", + "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — `title: cube.title` is the cube's display title on `GET /api/v1/analytics/meta`, the discovery surface a dashboard/report builder picks a cube from; packages/drivers/driver-memory/src/memory-analytics.ts#getMeta answers the identical projection for the in-memory driver, so the value reaches the wire on both execution paths.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Display-shaped, so the #7131 split settles it: being shown to a human IS the whole of the claimed effect, and there is no second layer where a 'real' consumer would live. The read point is the platform's own analytics discovery endpoint, NOT a metadata-admin preview — this type has none (enumerated in the file note above). `label` is the metric/dimension spelling of the same idea and is declared as an ALIAS of `title` on the cube (`strictObject` aliases), so a mis-spelled `label:` is refused with the corrective name rather than dropped." }, "description": { "status": "live", - "verifiedAt": "2026-09-29", + "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — the cube's `description` is copied onto its `CubeMeta` entry on `GET /api/v1/analytics/meta` when the definition declares one (and left off when it does not), the discovery surface a dashboard/report builder or an API client picks a cube from — the read point the `title` row above cites. Pinned in packages/services/service-analytics/src/__tests__/cube-meta-description-format.test.ts.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Dead until 2026-09-29: the `CubeMeta` projection carried `name`, `title`, `measures` and `dimensions` and no `description`, so the value was parsed, stored and read by nothing. Display-shaped, so the #7131 split settles it the way it settles `title`: being shown is the whole of the claimed effect, and the platform's own discovery endpoint is the read point — this type has no metadata-admin preview (the file note). `@objectstack/driver-memory`'s standalone `MemoryAnalyticsService#getMeta` does NOT project it: no in-repo composition registers that service as the `analytics` service (the `public` row), and the contract declares the key optional. A compiled dataset's cube carries none: the dataset compiler does not copy the dataset's own `description` onto the cube it mints." }, "sql": { "status": "live", - "verifiedAt": "2026-09-17", + "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#extractObjectName — `return cube.sql.trim()` is the FROM table of every generated statement and the object whose RLS/tenant read scope is injected (`applyReadScope(this.extractObjectName(cube), …)`); packages/services/service-analytics/src/strategies/objectql-strategy.ts#extractObjectName resolves the same value for the `engine.aggregate` path; packages/services/service-analytics/src/analytics-service.ts#cubeObjects adds `cube.sql.trim()` to the object set the tenant scope is computed over; packages/drivers/driver-memory/src/memory-analytics.ts#extractTableName reads it as the in-memory collection name.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "'Base SQL statement or Table Name' in the describe, and the platform's own aliases (`table`, `sqlTable`) say which half is load-bearing: every consumer treats it as an OBJECT/TABLE name. Authoring it wrong is not silent — an unknown object fails the query." }, "measures": { @@ -39,37 +39,37 @@ }, "label": { "status": "live", - "verifiedAt": "2026-09-17", + "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — `title: measure.label` is the human title of each measure on `GET /api/v1/analytics/meta`; packages/drivers/driver-memory/src/memory-analytics.ts#getMeta emits the identical projection.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Display-shaped and settled by the #7131 split, same as the cube's `title`. `CubeRegistry`'s own header names this read point ('maps every registered cube's measure/dimension `label` onto the `CubeMeta` titles served by GET /api/v1/analytics/meta'). REQUIRED on the schema, so there is no empty state to classify." }, "description": { "status": "live", - "verifiedAt": "2026-09-29", + "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — each measure's `description` is copied onto that measure's entry in `CubeMeta.measures` on `GET /api/v1/analytics/meta` when the definition declares one, beside the `title` its `label` becomes (the `label` row). Pinned in packages/services/service-analytics/src/__tests__/cube-meta-description-format.test.ts.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Dead until 2026-09-29, for the reason the cube-level `description` row gives; live on the same reading (#7131 display-shaped, the discovery endpoint as the read point). A compiled dataset's cube never carries one: a dataset measure has no `description` key to copy (the dataset schema refuses it and points to the dataset's own `description`)." }, "type": { "status": "live", - "verifiedAt": "2026-10-02", + "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveMeasureSql — `aggregateOfMeasure(cube.name, member, measure.type)` resolves the aggregate, `AGGREGATE_SQL[aggregate]` picks the function the column is wrapped in (SUM / COUNT / AVG / …) and `CONDITIONAL_AGGREGATE_SQL[aggregate]` its conditional form for a scoped measure filter; packages/services/service-analytics/src/strategies/objectql-strategy.ts#resolveMeasureAggregation hands the same verdict to the engine as the aggregation `method`. A type no aggregate lowers is refused in the spec's words rather than substituting `COUNT(*)` (#4157). packages/services/service-analytics/src/analytics-service.ts#getMeta also publishes it as the measure's `type` on `GET /api/v1/analytics/meta`.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "The arithmetic itself: this key decides what number the query returns. NARROWED 2026-10-02 (#21000, ADR-0049 enforce-or-remove): the custom-SQL-expression values `number` / `string` / `boolean` were retired from `AggregationMetricType` — a member's `sql` is a column reference, so they had nothing left to compute — and are refused at parse with a prescription naming the six aggregates (`enumWithRetiredValues`); the strategies' `EXPRESSION_METRIC_TYPES` partition went with them, and both strategies refuse a cube that reached them unparsed with the same text (`aggregateOfMeasure`). Still LIVE: the key itself is unchanged and read at the sites above. `metric-type-coverage.test.ts` pins `AGGREGATE_SQL`'s keys EQUAL to the enum's options, so no spec-valid value can fall through. The D3 entry is `cube-metric-expression-types-retired`; there is no D2 conversion, because the column alone does not say which aggregate the author meant." }, "sql": { "status": "live", - "verifiedAt": "2026-10-02", + "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveMeasureSql — `measure.sql` is the column the aggregate is applied to (`'*'` the COUNT(*) form), and `qualifyAndRegisterJoin(measure.sql, …)` is what lowers a dotted reference into a LEFT JOIN chain; packages/services/service-analytics/src/strategies/objectql-strategy.ts#resolveFieldName reads `measure.sql.replace(/^\\$/, '')` as the aggregate field for the `engine.aggregate` path; packages/services/service-analytics/src/analytics-service.ts#fieldsOfColumnSql resolves it to the field(s) the analytics door's field-level read gate judges.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "REQUIRED. NARROWED 2026-09-30 (#20943, maintainer ruling D; ADR-0021 zero raw expressions, ADR-0049 enforce-or-remove): the value is a COLUMN REFERENCE — a bare identifier, a dotted identifier path (relationship hops, then the column), or `'*'` — and any SQL expression is refused at parse with a prescription naming the ADR-0021 dataset form (a measure-scoped `filter`, `derived: { op, of }`). The admitted pattern is the one `IDENTIFIER_PATH` (native-sql-strategy.ts) and the read gate already use to tell a column path from an expression (#4157), so every admitted value other than `'*'` (which reads no field value) resolves to a field the gate can judge. Still LIVE: the key itself is unchanged and read at the three sites above. Both runtime expression branches are gone: the field-level read gate's stand-down with #20965, and the raw-SQL path's verbatim emit with #21000. What remains for a cube that reaches the service without meeting the parse is packages/services/service-analytics/src/strategies/native-sql-strategy.ts#qualifyAndRegisterJoin passing a non-column `sql` through as-is, inside the measure's aggregate (`SUM(amount + 1)`, `SUM(COALESCE(account.amount, 0) / 2)`, measured 2026-10-02 through `AnalyticsService#generateSql` with no field reader wired). The D3 entry is `cube-member-sql-expression-retired`; there is no D2 conversion, because an expression has no mechanical rewrite into a dataset. NARROWED again 2026-10-02 (#21409, the count-only boundary): `'*'` is admitted only under `type: 'count'` — the measure's refinement asks the one predicate it shares with the dataset measure (`data/analytics-column-reference.ts#rowWildcardOutsideCount`), and refuses `'*'` under any other type at `sql`, naming the slot and prescribing a `count` or a column; a dataset `sum` over `'*'`, which compiles to this very member, answered 500 DATABASE_ERROR at `POST /analytics/dataset/query` on both strategies before the rule. Cross-field, so a declared dropped-refinement site (`data/Metric` in `dropped-refinements.baseline.json`). D3 entry `analytics-row-wildcard-outside-count-refused`; no D2 conversion." }, "format": { "status": "live", - "verifiedAt": "2026-09-29", + "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/analytics-service.ts#withDeclaredMeasureFormats — every measure column a query names leaves `query()` (`POST /api/v1/analytics/query`) carrying the `format` its cube measure declares, as `fields[].format`, the presentation slot `AnalyticsResult` documents. It runs at the one seam every strategy's result leaves through (`queryIn`, beside the SQL-echo gate), so the column is described whichever of NativeSQL, ObjectQL or a delegated fallback answered, and on the dataset door's queries too. Pinned per strategy, each case against a compiled-dataset control, in packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts, and over the route in packages/runtime/src/analytics-authored-cube-format-granularity.test.ts.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Dead until 2026-09-29: the DATASET compiler wrote it and nothing read it back, because the value a caller received was threaded from the DATASET measure (`enrichResultColumns`), and an authored cube has no dataset — so a hand-written `format:` reached no reader and its column went out with `name` and `type` only. It is now read off the cube, for every cube that answers the name; on the dataset path the value read is the compiler's copy of the dataset measure's own `format`, the one `enrichResultColumns` writes anyway, so that path is unchanged. Also on discovery since 2026-09-29: `analytics-service.ts#getMeta` copies it onto the measure's `CubeMeta` entry on `GET /api/v1/analytics/meta` (the additive return path the narrowing recorded on `AnalyticsMetadataResponseSchema`, #6442, names); content/docs/api/data-api.mdx still sends a client that formats amounts to `fields[]`, the per-column surface. The slot's vocabulary is the one `fields[].format` and the dataset measure's `format` both document, a numeral pattern (\"$0,0\", \"0.0%\"); a named style is relayed verbatim and is not a pattern. ⛔ Retirement was never the remedy: the key is also the dataset compiler's output slot on the shared shape." } } @@ -83,37 +83,37 @@ }, "label": { "status": "live", - "verifiedAt": "2026-09-17", + "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — `title: dimension.label` is the human title of each dimension on `GET /api/v1/analytics/meta`; packages/drivers/driver-memory/src/memory-analytics.ts#getMeta emits the identical projection.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Display-shaped, #7131 split, same as `measures.label`. The dataset side of the same field is where #6761 was found (an inline locale map was dropped and the machine name published as a title); a hand-authored cube declares a plain string, so that resolver is not in this path." }, "description": { "status": "live", - "verifiedAt": "2026-09-29", + "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — each dimension's `description` is copied onto that dimension's entry in `CubeMeta.dimensions` on `GET /api/v1/analytics/meta` when the definition declares one. Pinned in packages/services/service-analytics/src/__tests__/cube-meta-description-format.test.ts.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Dead until 2026-09-29, for the reason the cube-level `description` row gives; live on the same reading. A compiled dataset's cube never carries one: a dataset dimension has no `description` key to copy." }, "type": { "status": "live", - "verifiedAt": "2026-09-17", + "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#carriesUninterpretableTemporalComparand — `this.lookupMember(cube, member, 'dimension')?.type === 'time' ? 'datetime' : null` decides how a filter comparand is shaped before it is bound as a parameter; packages/services/service-analytics/src/analytics-service.ts#getMeta publishes it as the dimension's `type` on `GET /api/v1/analytics/meta`; packages/services/service-analytics/src/dataset-executor.ts#timeDimensionsOf selects the bucketable dimensions with `compiled.cube.dimensions[d]?.type === 'time'`.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "`time` is the member that carries behaviour (comparand shaping, bucketability); the other members are published and otherwise inert. The dataset-executor citation is on the compiled-dataset path — it is listed because it reads the same key off the same shape, and the authoring-door consumer is the comparand shaping in native-sql-strategy." }, "sql": { "status": "live", - "verifiedAt": "2026-10-02", + "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveDimensionSql — `dim.sql` is the GROUP BY / SELECT column, and `qualifyAndRegisterJoin(dim.sql, …)` lowers a dotted path into the LEFT JOIN chain; packages/services/service-analytics/src/strategies/objectql-strategy.ts#resolveFieldName reads `dim.sql.replace(/^\\$/, '')` as the group-by field for the `engine.aggregate` path; packages/services/service-analytics/src/analytics-service.ts#fieldsOfColumnSql resolves it to the field(s) the analytics door's field-level read gate judges.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "REQUIRED. NARROWED 2026-09-30 with `measures.sql` (#20943, maintainer ruling D): a bare identifier or a dotted identifier path; a SQL expression — a CASE bucket included — is refused at parse. NARROWED again 2026-10-02 (#21409, the count-only boundary): `'*'`, which ruling D's execution parameters had named in the accept set of both members and which never made a working query on a dimension (`GROUP BY *`), is refused too — no aggregate consumes it on a dimension, so the slot takes `ANALYTICS_COLUMN_PATH` (`data/analytics-column-reference.ts`), the dataset dimension's own pattern, and the prescription sends a row count to a `count` measure. A pattern, so the published JSON Schema carries it. D3 entry `analytics-row-wildcard-outside-count-refused`. A bucket computed over a column's values has no expression form in the cube or the dataset layer; the prescription sends it to a field of the object that a dimension names. Still LIVE, at the sites above." }, "granularities": { "status": "live", - "verifiedAt": "2026-09-29", + "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/analytics-service.ts#withDeclaredGranularityDefaults — on `query()` (`POST /api/v1/analytics/query`) and `generateSql()` (`POST /api/v1/analytics/sql`), before the source-field gates and strategy selection run, a time dimension the query GROUPS BY with no stated granularity is bucketed at the default its cube dimension declares; packages/services/service-analytics/src/dataset-executor.ts#declaredDefaultGranularity is that default — the one reading of this key, a single-entry list — shared with the compiled-dataset path's `granularityOf`, so both producers' cubes are read by one rule. Pinned against a compiled-dataset control in packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts, and over both routes in packages/runtime/src/analytics-authored-cube-format-granularity.test.ts.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Dead until 2026-09-29: the one read (`granularityOf`) took a `CompiledDataset`, which an authored cube never becomes, so grouping by an authored time dimension grouped raw timestamps whatever it declared. What the key does now is exactly the compiled-dataset path's reading: a SINGLE-entry list is the dimension's default bucket; a stated `timeDimensions[].granularity` always wins; a multi-entry list (the compiler's five-entry 'all intervals' shape included) states no default; an entry carrying only a `dateRange` for a dimension the query does not group stays a filter. ⚠️ What it does NOT do: refuse a requested granularity the list does not name — the dataset path compares against no list, so neither does this — and whether a multi-entry list should narrow the accept set (the 'Supported Granularities' reading of the declaration) is an open question on #20282, not decided by this row. A declared default routes a grouped query to the engine path, because NativeSQL declines any bucketed query, and the engine path answers 400 `INVALID_FIELD` — the same answer a caller who states that granularity by hand gets — for every member it cannot evaluate: a custom-SQL measure (`ObjectQLStrategy.resolveMeasureAggregation`), and, from `ObjectQLStrategy.planCrossObject`, on a cube whose members resolve through its `joins`: a measure or `where` field over a joined object, a `timeDimensions` entry over a joined object (bucketed or a window), a dimension that traverses more than one relationship, and an `avg` / `count_distinct` measure beside a dimension over a joined object (planCrossObject's two dataset-definition arms cannot fire on an authored cube). NativeSQL serves all of them, so each goes from answered to refused when grouped by a one-interval dimension. On a host whose `queryCapabilities` offers raw SQL with no engine aggregate bridge (a hand override), no strategy remains, so every newly bucketed query answers \"No strategy can handle query\". The declared narrowing and its D3 entry `analytics-cube-single-granularity-default-enforced` name this class. The dataset-side authoring key is still `dataset.dimensions[].dateGranularity` (dataset.json), which the compiler lowers into this slot. ⛔ Retirement was never the remedy." } } @@ -122,9 +122,9 @@ "children": { "name": { "status": "live", - "verifiedAt": "2026-09-17", + "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#qualifyAndRegisterJoin — `const joinTable = cube?.joins?.[alias]?.name ?? alias` is the TABLE the LEFT JOIN is emitted against, which is what makes an alias differ from its object (`account` -> `crm_account`); packages/services/service-analytics/src/strategies/native-sql-strategy.ts#crossFieldComparisonIn resolves `cube.joins?.[alias]?.name ?? alias` for every joined object when it walks the RLS read scopes; packages/services/service-analytics/src/analytics-service.ts#cubeObjects adds every `cube.joins[*].name` to the tenant-scoped object set; packages/services/service-analytics/src/strategies/objectql-strategy.ts#isCrossObjectField resolves the same value for the aggregate path.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Security-relevant, not cosmetic: the joined object resolved here is what the read scope is computed for, so a wrong name would scope the wrong table. Note the JOIN is keyed by the record key (the alias) — `name` is its TARGET. It is also the WHOLE of the join contract since #18612: `relationship` and `sql` were removed (ADR-0049 enforce-or-remove), so their rows left this ledger with the keys — the strict-deletion route drops the row, and an orphan row for a key no longer in the walked shape is its own gate failure." } } @@ -136,9 +136,9 @@ }, "public": { "status": "live", - "verifiedAt": "2026-09-27", + "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/cube-visibility.ts#isCubePublic — the one reader: a cube is exposed unless it declares `public: false` (the registry holds the input shape, so an omitted key reads as the schema default, visible); packages/services/service-analytics/src/analytics-service.ts#getMeta filters every cube it serves on `GET /api/v1/analytics/meta` through it, so a hidden cube is omitted and a `?cube=` naming one answers `[]`; packages/services/service-analytics/src/analytics-service.ts#assertCubePublic runs first in `query()` (`POST /api/v1/analytics/query`) and in `generateSql()` (`POST /api/v1/analytics/sql`), and on the dataset door's queries too, which run through the same body asked of the call's own request scope, refusing a hidden cube with packages/services/service-analytics/src/cube-visibility.ts#cubeNotFoundError — the same `CUBE_NOT_FOUND` / 404 refusal, byte for byte, that a name which is neither a cube nor an object gets, so the refusal does not confirm a hidden cube exists — before token resolution, cube inference, admission or any strategy runs. Pinned door by door in packages/services/service-analytics/src/__tests__/cube-public-visibility.test.ts.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "VISIBILITY on the analytics API — the Cube.dev semantics this schema follows — not row security: an object's records stay governed by its permissions and RLS on every door (`assertReadAdmitted` and the injected read scope), whether or not a cube over it is hidden; what `false` does is leave the cube out of `/analytics/meta` and refuse queries and SQL generation against it; the definition stays readable on the metadata door (below). Dead until 2026-09-27 — declared `z.boolean().default(false)` under an 'Access Control' comment and read by nothing, so `public: false` withheld nothing (false compliance). Enforced in the same change that moved the default to `true`: enforcing the old default as declared would have hidden every authored cube. The three INTERNAL mints (`inferCubeFromQuery`, `compileDataset`, `CubeRegistry.inferFromObject`) wrote the old default `false` as a literal; they now write `true`, because the ad-hoc KPI path and the dataset door both reach their own minted cube through the refusing `query()`. The one in-repo author of `public: false` was `examples/app-showcase`, whose cube IS the app's /analytics/* demonstration — it now omits the key. Not a door: the metadata API (`/api/v1/meta/analytics_cube`) is the model-authoring surface (the Cube.dev schema-file split) and keeps serving every cube definition, hidden ones included, to its authenticated callers; and `@objectstack/driver-memory`'s standalone `MemoryAnalyticsService` does not read the key — no in-repo composition registers it as the `analytics` service, and behind `AnalyticsService` it is reached only through the gated `query()`/`generateSql()`." } } From 0dd442683534bf37b941ae9d4b2050e668f397f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 13:06:59 +0000 Subject: [PATCH 5/5] fix(spec/liveness): analytics_cube rows under the fresh verifiedAt quote the code at head joins.name: the first evidence citation quoted a joinTable line that left native-sql-strategy.ts; it is re-sited at hop-object.ts#resolvePathHops, whose tier 1 reads the join's name, with qualifyAndRegisterJoin emitting the LEFT JOIN against it. The 16 producer strings site the configKey declaration at core capability-providers.ts#CAPABILITY_PROVIDERS and keep the serve.ts threading quote at the resolver's handle. Value-only: no status, key or row. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- packages/spec/liveness/analytics_cube.json | 34 +++++++++++----------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/spec/liveness/analytics_cube.json b/packages/spec/liveness/analytics_cube.json index a780de2e24c..79771f0a070 100644 --- a/packages/spec/liveness/analytics_cube.json +++ b/packages/spec/liveness/analytics_cube.json @@ -6,28 +6,28 @@ "status": "live", "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/cube-registry.ts#register — `this.cubes.set(cube.name, cube)`: the authored name IS the registry key, so it decides what `AnalyticsQuery.cube` can resolve at all; packages/services/service-analytics/src/analytics-service.ts#getMeta emits it as the cube's `name` and as the `${cube.name}.${key}` member id of every measure and dimension served by `GET /api/v1/analytics/meta`; packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveMeasureSql names it in the `INVALID_FIELD` refusal for an undeclared member.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/core/src/capability-providers.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'`; packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the capability resolver's handle on that table (`const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS`) — tests `spec.configKey === 'analyticsCubes'` and threads the authored cubes into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Identity, not display — `title` is the display half. A second cube registered under the same name silently replaces the first (`register` is a `Map.set`)." }, "title": { "status": "live", "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — `title: cube.title` is the cube's display title on `GET /api/v1/analytics/meta`, the discovery surface a dashboard/report builder picks a cube from; packages/drivers/driver-memory/src/memory-analytics.ts#getMeta answers the identical projection for the in-memory driver, so the value reaches the wire on both execution paths.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/core/src/capability-providers.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'`; packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the capability resolver's handle on that table (`const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS`) — tests `spec.configKey === 'analyticsCubes'` and threads the authored cubes into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Display-shaped, so the #7131 split settles it: being shown to a human IS the whole of the claimed effect, and there is no second layer where a 'real' consumer would live. The read point is the platform's own analytics discovery endpoint, NOT a metadata-admin preview — this type has none (enumerated in the file note above). `label` is the metric/dimension spelling of the same idea and is declared as an ALIAS of `title` on the cube (`strictObject` aliases), so a mis-spelled `label:` is refused with the corrective name rather than dropped." }, "description": { "status": "live", "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — the cube's `description` is copied onto its `CubeMeta` entry on `GET /api/v1/analytics/meta` when the definition declares one (and left off when it does not), the discovery surface a dashboard/report builder or an API client picks a cube from — the read point the `title` row above cites. Pinned in packages/services/service-analytics/src/__tests__/cube-meta-description-format.test.ts.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/core/src/capability-providers.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'`; packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the capability resolver's handle on that table (`const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS`) — tests `spec.configKey === 'analyticsCubes'` and threads the authored cubes into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Dead until 2026-09-29: the `CubeMeta` projection carried `name`, `title`, `measures` and `dimensions` and no `description`, so the value was parsed, stored and read by nothing. Display-shaped, so the #7131 split settles it the way it settles `title`: being shown is the whole of the claimed effect, and the platform's own discovery endpoint is the read point — this type has no metadata-admin preview (the file note). `@objectstack/driver-memory`'s standalone `MemoryAnalyticsService#getMeta` does NOT project it: no in-repo composition registers that service as the `analytics` service (the `public` row), and the contract declares the key optional. A compiled dataset's cube carries none: the dataset compiler does not copy the dataset's own `description` onto the cube it mints." }, "sql": { "status": "live", "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#extractObjectName — `return cube.sql.trim()` is the FROM table of every generated statement and the object whose RLS/tenant read scope is injected (`applyReadScope(this.extractObjectName(cube), …)`); packages/services/service-analytics/src/strategies/objectql-strategy.ts#extractObjectName resolves the same value for the `engine.aggregate` path; packages/services/service-analytics/src/analytics-service.ts#cubeObjects adds `cube.sql.trim()` to the object set the tenant scope is computed over; packages/drivers/driver-memory/src/memory-analytics.ts#extractTableName reads it as the in-memory collection name.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/core/src/capability-providers.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'`; packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the capability resolver's handle on that table (`const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS`) — tests `spec.configKey === 'analyticsCubes'` and threads the authored cubes into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "'Base SQL statement or Table Name' in the describe, and the platform's own aliases (`table`, `sqlTable`) say which half is load-bearing: every consumer treats it as an OBJECT/TABLE name. Authoring it wrong is not silent — an unknown object fails the query." }, "measures": { @@ -41,35 +41,35 @@ "status": "live", "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — `title: measure.label` is the human title of each measure on `GET /api/v1/analytics/meta`; packages/drivers/driver-memory/src/memory-analytics.ts#getMeta emits the identical projection.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/core/src/capability-providers.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'`; packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the capability resolver's handle on that table (`const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS`) — tests `spec.configKey === 'analyticsCubes'` and threads the authored cubes into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Display-shaped and settled by the #7131 split, same as the cube's `title`. `CubeRegistry`'s own header names this read point ('maps every registered cube's measure/dimension `label` onto the `CubeMeta` titles served by GET /api/v1/analytics/meta'). REQUIRED on the schema, so there is no empty state to classify." }, "description": { "status": "live", "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — each measure's `description` is copied onto that measure's entry in `CubeMeta.measures` on `GET /api/v1/analytics/meta` when the definition declares one, beside the `title` its `label` becomes (the `label` row). Pinned in packages/services/service-analytics/src/__tests__/cube-meta-description-format.test.ts.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/core/src/capability-providers.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'`; packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the capability resolver's handle on that table (`const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS`) — tests `spec.configKey === 'analyticsCubes'` and threads the authored cubes into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Dead until 2026-09-29, for the reason the cube-level `description` row gives; live on the same reading (#7131 display-shaped, the discovery endpoint as the read point). A compiled dataset's cube never carries one: a dataset measure has no `description` key to copy (the dataset schema refuses it and points to the dataset's own `description`)." }, "type": { "status": "live", "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveMeasureSql — `aggregateOfMeasure(cube.name, member, measure.type)` resolves the aggregate, `AGGREGATE_SQL[aggregate]` picks the function the column is wrapped in (SUM / COUNT / AVG / …) and `CONDITIONAL_AGGREGATE_SQL[aggregate]` its conditional form for a scoped measure filter; packages/services/service-analytics/src/strategies/objectql-strategy.ts#resolveMeasureAggregation hands the same verdict to the engine as the aggregation `method`. A type no aggregate lowers is refused in the spec's words rather than substituting `COUNT(*)` (#4157). packages/services/service-analytics/src/analytics-service.ts#getMeta also publishes it as the measure's `type` on `GET /api/v1/analytics/meta`.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/core/src/capability-providers.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'`; packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the capability resolver's handle on that table (`const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS`) — tests `spec.configKey === 'analyticsCubes'` and threads the authored cubes into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "The arithmetic itself: this key decides what number the query returns. NARROWED 2026-10-02 (#21000, ADR-0049 enforce-or-remove): the custom-SQL-expression values `number` / `string` / `boolean` were retired from `AggregationMetricType` — a member's `sql` is a column reference, so they had nothing left to compute — and are refused at parse with a prescription naming the six aggregates (`enumWithRetiredValues`); the strategies' `EXPRESSION_METRIC_TYPES` partition went with them, and both strategies refuse a cube that reached them unparsed with the same text (`aggregateOfMeasure`). Still LIVE: the key itself is unchanged and read at the sites above. `metric-type-coverage.test.ts` pins `AGGREGATE_SQL`'s keys EQUAL to the enum's options, so no spec-valid value can fall through. The D3 entry is `cube-metric-expression-types-retired`; there is no D2 conversion, because the column alone does not say which aggregate the author meant." }, "sql": { "status": "live", "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveMeasureSql — `measure.sql` is the column the aggregate is applied to (`'*'` the COUNT(*) form), and `qualifyAndRegisterJoin(measure.sql, …)` is what lowers a dotted reference into a LEFT JOIN chain; packages/services/service-analytics/src/strategies/objectql-strategy.ts#resolveFieldName reads `measure.sql.replace(/^\\$/, '')` as the aggregate field for the `engine.aggregate` path; packages/services/service-analytics/src/analytics-service.ts#fieldsOfColumnSql resolves it to the field(s) the analytics door's field-level read gate judges.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/core/src/capability-providers.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'`; packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the capability resolver's handle on that table (`const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS`) — tests `spec.configKey === 'analyticsCubes'` and threads the authored cubes into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "REQUIRED. NARROWED 2026-09-30 (#20943, maintainer ruling D; ADR-0021 zero raw expressions, ADR-0049 enforce-or-remove): the value is a COLUMN REFERENCE — a bare identifier, a dotted identifier path (relationship hops, then the column), or `'*'` — and any SQL expression is refused at parse with a prescription naming the ADR-0021 dataset form (a measure-scoped `filter`, `derived: { op, of }`). The admitted pattern is the one `IDENTIFIER_PATH` (native-sql-strategy.ts) and the read gate already use to tell a column path from an expression (#4157), so every admitted value other than `'*'` (which reads no field value) resolves to a field the gate can judge. Still LIVE: the key itself is unchanged and read at the three sites above. Both runtime expression branches are gone: the field-level read gate's stand-down with #20965, and the raw-SQL path's verbatim emit with #21000. What remains for a cube that reaches the service without meeting the parse is packages/services/service-analytics/src/strategies/native-sql-strategy.ts#qualifyAndRegisterJoin passing a non-column `sql` through as-is, inside the measure's aggregate (`SUM(amount + 1)`, `SUM(COALESCE(account.amount, 0) / 2)`, measured 2026-10-02 through `AnalyticsService#generateSql` with no field reader wired). The D3 entry is `cube-member-sql-expression-retired`; there is no D2 conversion, because an expression has no mechanical rewrite into a dataset. NARROWED again 2026-10-02 (#21409, the count-only boundary): `'*'` is admitted only under `type: 'count'` — the measure's refinement asks the one predicate it shares with the dataset measure (`data/analytics-column-reference.ts#rowWildcardOutsideCount`), and refuses `'*'` under any other type at `sql`, naming the slot and prescribing a `count` or a column; a dataset `sum` over `'*'`, which compiles to this very member, answered 500 DATABASE_ERROR at `POST /analytics/dataset/query` on both strategies before the rule. Cross-field, so a declared dropped-refinement site (`data/Metric` in `dropped-refinements.baseline.json`). D3 entry `analytics-row-wildcard-outside-count-refused`; no D2 conversion." }, "format": { "status": "live", "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/analytics-service.ts#withDeclaredMeasureFormats — every measure column a query names leaves `query()` (`POST /api/v1/analytics/query`) carrying the `format` its cube measure declares, as `fields[].format`, the presentation slot `AnalyticsResult` documents. It runs at the one seam every strategy's result leaves through (`queryIn`, beside the SQL-echo gate), so the column is described whichever of NativeSQL, ObjectQL or a delegated fallback answered, and on the dataset door's queries too. Pinned per strategy, each case against a compiled-dataset control, in packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts, and over the route in packages/runtime/src/analytics-authored-cube-format-granularity.test.ts.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/core/src/capability-providers.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'`; packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the capability resolver's handle on that table (`const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS`) — tests `spec.configKey === 'analyticsCubes'` and threads the authored cubes into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Dead until 2026-09-29: the DATASET compiler wrote it and nothing read it back, because the value a caller received was threaded from the DATASET measure (`enrichResultColumns`), and an authored cube has no dataset — so a hand-written `format:` reached no reader and its column went out with `name` and `type` only. It is now read off the cube, for every cube that answers the name; on the dataset path the value read is the compiler's copy of the dataset measure's own `format`, the one `enrichResultColumns` writes anyway, so that path is unchanged. Also on discovery since 2026-09-29: `analytics-service.ts#getMeta` copies it onto the measure's `CubeMeta` entry on `GET /api/v1/analytics/meta` (the additive return path the narrowing recorded on `AnalyticsMetadataResponseSchema`, #6442, names); content/docs/api/data-api.mdx still sends a client that formats amounts to `fields[]`, the per-column surface. The slot's vocabulary is the one `fields[].format` and the dataset measure's `format` both document, a numeral pattern (\"$0,0\", \"0.0%\"); a named style is relayed verbatim and is not a pattern. ⛔ Retirement was never the remedy: the key is also the dataset compiler's output slot on the shared shape." } } @@ -85,35 +85,35 @@ "status": "live", "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — `title: dimension.label` is the human title of each dimension on `GET /api/v1/analytics/meta`; packages/drivers/driver-memory/src/memory-analytics.ts#getMeta emits the identical projection.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/core/src/capability-providers.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'`; packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the capability resolver's handle on that table (`const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS`) — tests `spec.configKey === 'analyticsCubes'` and threads the authored cubes into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Display-shaped, #7131 split, same as `measures.label`. The dataset side of the same field is where #6761 was found (an inline locale map was dropped and the machine name published as a title); a hand-authored cube declares a plain string, so that resolver is not in this path." }, "description": { "status": "live", "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — each dimension's `description` is copied onto that dimension's entry in `CubeMeta.dimensions` on `GET /api/v1/analytics/meta` when the definition declares one. Pinned in packages/services/service-analytics/src/__tests__/cube-meta-description-format.test.ts.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/core/src/capability-providers.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'`; packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the capability resolver's handle on that table (`const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS`) — tests `spec.configKey === 'analyticsCubes'` and threads the authored cubes into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Dead until 2026-09-29, for the reason the cube-level `description` row gives; live on the same reading. A compiled dataset's cube never carries one: a dataset dimension has no `description` key to copy." }, "type": { "status": "live", "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#carriesUninterpretableTemporalComparand — `this.lookupMember(cube, member, 'dimension')?.type === 'time' ? 'datetime' : null` decides how a filter comparand is shaped before it is bound as a parameter; packages/services/service-analytics/src/analytics-service.ts#getMeta publishes it as the dimension's `type` on `GET /api/v1/analytics/meta`; packages/services/service-analytics/src/dataset-executor.ts#timeDimensionsOf selects the bucketable dimensions with `compiled.cube.dimensions[d]?.type === 'time'`.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/core/src/capability-providers.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'`; packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the capability resolver's handle on that table (`const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS`) — tests `spec.configKey === 'analyticsCubes'` and threads the authored cubes into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "`time` is the member that carries behaviour (comparand shaping, bucketability); the other members are published and otherwise inert. The dataset-executor citation is on the compiled-dataset path — it is listed because it reads the same key off the same shape, and the authoring-door consumer is the comparand shaping in native-sql-strategy." }, "sql": { "status": "live", "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveDimensionSql — `dim.sql` is the GROUP BY / SELECT column, and `qualifyAndRegisterJoin(dim.sql, …)` lowers a dotted path into the LEFT JOIN chain; packages/services/service-analytics/src/strategies/objectql-strategy.ts#resolveFieldName reads `dim.sql.replace(/^\\$/, '')` as the group-by field for the `engine.aggregate` path; packages/services/service-analytics/src/analytics-service.ts#fieldsOfColumnSql resolves it to the field(s) the analytics door's field-level read gate judges.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/core/src/capability-providers.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'`; packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the capability resolver's handle on that table (`const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS`) — tests `spec.configKey === 'analyticsCubes'` and threads the authored cubes into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "REQUIRED. NARROWED 2026-09-30 with `measures.sql` (#20943, maintainer ruling D): a bare identifier or a dotted identifier path; a SQL expression — a CASE bucket included — is refused at parse. NARROWED again 2026-10-02 (#21409, the count-only boundary): `'*'`, which ruling D's execution parameters had named in the accept set of both members and which never made a working query on a dimension (`GROUP BY *`), is refused too — no aggregate consumes it on a dimension, so the slot takes `ANALYTICS_COLUMN_PATH` (`data/analytics-column-reference.ts`), the dataset dimension's own pattern, and the prescription sends a row count to a `count` measure. A pattern, so the published JSON Schema carries it. D3 entry `analytics-row-wildcard-outside-count-refused`. A bucket computed over a column's values has no expression form in the cube or the dataset layer; the prescription sends it to a field of the object that a dimension names. Still LIVE, at the sites above." }, "granularities": { "status": "live", "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/analytics-service.ts#withDeclaredGranularityDefaults — on `query()` (`POST /api/v1/analytics/query`) and `generateSql()` (`POST /api/v1/analytics/sql`), before the source-field gates and strategy selection run, a time dimension the query GROUPS BY with no stated granularity is bucketed at the default its cube dimension declares; packages/services/service-analytics/src/dataset-executor.ts#declaredDefaultGranularity is that default — the one reading of this key, a single-entry list — shared with the compiled-dataset path's `granularityOf`, so both producers' cubes are read by one rule. Pinned against a compiled-dataset control in packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts, and over both routes in packages/runtime/src/analytics-authored-cube-format-granularity.test.ts.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/core/src/capability-providers.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'`; packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the capability resolver's handle on that table (`const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS`) — tests `spec.configKey === 'analyticsCubes'` and threads the authored cubes into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Dead until 2026-09-29: the one read (`granularityOf`) took a `CompiledDataset`, which an authored cube never becomes, so grouping by an authored time dimension grouped raw timestamps whatever it declared. What the key does now is exactly the compiled-dataset path's reading: a SINGLE-entry list is the dimension's default bucket; a stated `timeDimensions[].granularity` always wins; a multi-entry list (the compiler's five-entry 'all intervals' shape included) states no default; an entry carrying only a `dateRange` for a dimension the query does not group stays a filter. ⚠️ What it does NOT do: refuse a requested granularity the list does not name — the dataset path compares against no list, so neither does this — and whether a multi-entry list should narrow the accept set (the 'Supported Granularities' reading of the declaration) is an open question on #20282, not decided by this row. A declared default routes a grouped query to the engine path, because NativeSQL declines any bucketed query, and the engine path answers 400 `INVALID_FIELD` — the same answer a caller who states that granularity by hand gets — for every member it cannot evaluate: a custom-SQL measure (`ObjectQLStrategy.resolveMeasureAggregation`), and, from `ObjectQLStrategy.planCrossObject`, on a cube whose members resolve through its `joins`: a measure or `where` field over a joined object, a `timeDimensions` entry over a joined object (bucketed or a window), a dimension that traverses more than one relationship, and an `avg` / `count_distinct` measure beside a dimension over a joined object (planCrossObject's two dataset-definition arms cannot fire on an authored cube). NativeSQL serves all of them, so each goes from answered to refused when grouped by a one-interval dimension. On a host whose `queryCapabilities` offers raw SQL with no engine aggregate bridge (a hand override), no strategy remains, so every newly bucketed query answers \"No strategy can handle query\". The declared narrowing and its D3 entry `analytics-cube-single-granularity-default-enforced` name this class. The dataset-side authoring key is still `dataset.dimensions[].dateGranularity` (dataset.json), which the compiler lowers into this slot. ⛔ Retirement was never the remedy." } } @@ -123,8 +123,8 @@ "name": { "status": "live", "verifiedAt": "2026-10-10", - "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#qualifyAndRegisterJoin — `const joinTable = cube?.joins?.[alias]?.name ?? alias` is the TABLE the LEFT JOIN is emitted against, which is what makes an alias differ from its object (`account` -> `crm_account`); packages/services/service-analytics/src/strategies/native-sql-strategy.ts#crossFieldComparisonIn resolves `cube.joins?.[alias]?.name ?? alias` for every joined object when it walks the RLS read scopes; packages/services/service-analytics/src/analytics-service.ts#cubeObjects adds every `cube.joins[*].name` to the tenant-scoped object set; packages/services/service-analytics/src/strategies/objectql-strategy.ts#isCrossObjectField resolves the same value for the aggregate path.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "evidence": "packages/services/service-analytics/src/hop-object.ts#resolvePathHops — `const joined = joins?.[alias]?.name;` is tier 1 of the one hop resolver: the cube's join keyed by the hop's alias names the object, and packages/services/service-analytics/src/strategies/native-sql-strategy.ts#qualifyAndRegisterJoin emits the LEFT JOIN against it (`object: hop.object`), which is what makes an alias differ from its object (`account` -> `crm_account`); packages/services/service-analytics/src/strategies/native-sql-strategy.ts#crossFieldComparisonIn resolves `cube.joins?.[alias]?.name ?? alias` for every joined object when it walks the RLS read scopes; packages/services/service-analytics/src/analytics-service.ts#cubeObjects adds every `cube.joins[*].name` to the tenant-scoped object set; packages/services/service-analytics/src/strategies/objectql-strategy.ts#isCrossObjectField resolves the same value for the aggregate path.", + "producer": "packages/core/src/capability-providers.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'`; packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the capability resolver's handle on that table (`const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS`) — tests `spec.configKey === 'analyticsCubes'` and threads the authored cubes into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "Security-relevant, not cosmetic: the joined object resolved here is what the read scope is computed for, so a wrong name would scope the wrong table. Note the JOIN is keyed by the record key (the alias) — `name` is its TARGET. It is also the WHOLE of the join contract since #18612: `relationship` and `sql` were removed (ADR-0049 enforce-or-remove), so their rows left this ledger with the keys — the strict-deletion route drops the row, and an orphan row for a key no longer in the walked shape is its own gate failure." } } @@ -138,7 +138,7 @@ "status": "live", "verifiedAt": "2026-10-10", "evidence": "packages/services/service-analytics/src/cube-visibility.ts#isCubePublic — the one reader: a cube is exposed unless it declares `public: false` (the registry holds the input shape, so an omitted key reads as the schema default, visible); packages/services/service-analytics/src/analytics-service.ts#getMeta filters every cube it serves on `GET /api/v1/analytics/meta` through it, so a hidden cube is omitted and a `?cube=` naming one answers `[]`; packages/services/service-analytics/src/analytics-service.ts#assertCubePublic runs first in `query()` (`POST /api/v1/analytics/query`) and in `generateSql()` (`POST /api/v1/analytics/sql`), and on the dataset door's queries too, which run through the same body asked of the call's own request scope, refusing a hidden cube with packages/services/service-analytics/src/cube-visibility.ts#cubeNotFoundError — the same `CUBE_NOT_FOUND` / 404 refusal, byte for byte, that a name which is neither a cube nor an object gets, so the refusal does not confirm a hidden cube exists — before token resolution, cube inference, admission or any strategy runs. Pinned door by door in packages/services/service-analytics/src/__tests__/cube-public-visibility.test.ts.", - "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "producer": "packages/core/src/capability-providers.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'`; packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the capability resolver's handle on that table (`const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS`) — tests `spec.configKey === 'analyticsCubes'` and threads the authored cubes into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? resolveStackCollection(config, 'analyticsCubes'); arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#register (`for (const cube of config.cubes) … this.cubeRegistry.register(cube)`, one cube at a time; a cube whose base object or declared join the API does not serve is refused there and never registered) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", "note": "VISIBILITY on the analytics API — the Cube.dev semantics this schema follows — not row security: an object's records stay governed by its permissions and RLS on every door (`assertReadAdmitted` and the injected read scope), whether or not a cube over it is hidden; what `false` does is leave the cube out of `/analytics/meta` and refuse queries and SQL generation against it; the definition stays readable on the metadata door (below). Dead until 2026-09-27 — declared `z.boolean().default(false)` under an 'Access Control' comment and read by nothing, so `public: false` withheld nothing (false compliance). Enforced in the same change that moved the default to `true`: enforcing the old default as declared would have hidden every authored cube. The three INTERNAL mints (`inferCubeFromQuery`, `compileDataset`, `CubeRegistry.inferFromObject`) wrote the old default `false` as a literal; they now write `true`, because the ad-hoc KPI path and the dataset door both reach their own minted cube through the refusing `query()`. The one in-repo author of `public: false` was `examples/app-showcase`, whose cube IS the app's /analytics/* demonstration — it now omits the key. Not a door: the metadata API (`/api/v1/meta/analytics_cube`) is the model-authoring surface (the Cube.dev schema-file split) and keeps serving every cube definition, hidden ones included, to its authenticated callers; and `@objectstack/driver-memory`'s standalone `MemoryAnalyticsService` does not read the key — no in-repo composition registers it as the `analytics` service, and behind `AnalyticsService` it is reached only through the gated `query()`/`generateSql()`." } }