Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
99f0e9d
test(service-analytics): pin analytics_cube.public visibility at getM…
claude Sep 27, 2026
bdfdee2
feat(analytics): enforce analytics_cube.public and default it to visible
claude Sep 27, 2026
02087be
chore(spec): declare the Cube.public default change and regenerate it…
claude Sep 27, 2026
c9382ff
Merge remote-tracking branch 'origin/main' into claude/issue-20282-an…
claude Sep 27, 2026
33348d6
test(service-analytics): give the compileDataset visibility case a de…
claude Sep 27, 2026
2cfa134
docs(qa): re-spell the showcase_delivery checklist item for the enfor…
claude Sep 28, 2026
85103aa
Merge remote-tracking branch 'origin/main' into claude/issue-20282-an…
claude Sep 28, 2026
f84cd18
chore(spec): regenerate the liveness state counts on the merged tree
claude Sep 28, 2026
d392f0c
Merge remote-tracking branch 'origin/main' into claude/issue-20282-an…
claude Sep 28, 2026
01aa667
chore(spec): regenerate the reference page and liveness counts on the…
claude Sep 28, 2026
97c25bb
fix(service-analytics): answer a hidden cube with the unknown-cube re…
claude Sep 28, 2026
b21fd50
Merge remote-tracking branch 'origin/main' into claude/issue-20282-an…
claude Sep 28, 2026
fd67ac1
feat(spec): declare the cube visibility change as a narrowing and reg…
claude Sep 28, 2026
ac5260a
chore(changeset): record the ADR-0087 disposition of the cube visibil…
claude Sep 28, 2026
a81d393
Merge remote-tracking branch 'origin/main' into claude/issue-20282-an…
claude Sep 28, 2026
4e4137e
chore(spec): regenerate authorable-defaults on the merged tree
claude Sep 28, 2026
036af03
fix(service-analytics): ask the visibility gate of the call's own cub…
claude Sep 28, 2026
5ddea95
test(service-analytics): the request-scope observer sees a hidden cub…
claude Sep 28, 2026
0210414
Merge remote-tracking branch 'origin/main' into claude/issue-20282-an…
claude Sep 28, 2026
e28937a
chore(spec): regenerate the liveness state counts on the merged tree
claude Sep 28, 2026
dca5956
Merge remote-tracking branch 'origin/main' into claude/issue-20282-an…
claude Sep 28, 2026
93376b4
chore(spec): regenerate the liveness state counts on the merged tree
claude Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/20282-analytics-cube-public-enforced.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/spec': minor
'@objectstack/service-analytics': minor
---

An analytics cube's `public` now takes effect, and it defaults to visible: `CubeSchema.public` defaults to `true` (it was `false`), and the analytics service hides a cube that declares `public: false` from discovery and refuses every query against it (#20282).

Clause-②: yes (narrowing)

<!-- adr-0087: registered analytics-cube-public-default-visible-enforced -->

**BREAKING**: this narrows what the analytics API answers. A query or SQL dry run against a cube declared `public: false` (`POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql`) was answered before this change and is now refused with `404 CUBE_NOT_FOUND`, and `GET /api/v1/analytics/meta` no longer lists that cube. The same happens to every cube in an artifact built by `os compile` before this release, which carries a materialized `public: false` from the old default. The remedy: delete `public: false` from any cube that is meant to be queried (cubes are visible by default), and recompile pre-release artifacts. It ships as `minor` under the launch-window convention; the widening half is the default moving to visible.

Until this change nothing read `public`. `GET /api/v1/analytics/meta` listed a `public: false` cube and every query door answered it, so the flag withheld nothing. Its declared default, `false`, could not simply be switched on: enforcing it as declared would have hidden every cube that omits the key. The default is now the Cube.dev default (visible), and an explicit `false` is enforced:

- `GET /api/v1/analytics/meta` omits a cube declared `public: false`, and `?cube=` naming one answers `[]`, the same as a name no cube has.
- `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` refuse it with `404 CUBE_NOT_FOUND` — the same refusal, byte for byte, that an unknown cube name gets, so a caller cannot use it to learn that a hidden cube exists. The one shared message names both possibilities, so it still tells an author how to expose a hidden cube. The refusal comes before any SQL is built, and it is never an empty result.

`public` is visibility on the analytics API, not row security. An object's records stay governed by its permissions and row-level security on every door, whether or not a cube over it is hidden. What `public: false` does is exactly the two points above: the cube is left out of `/analytics/meta`, and queries and SQL generation against it are refused. The cube's definition stays readable on the metadata door, like any other authored schema.

What to expect after upgrading:

- **A cube that omits `public`** stays visible and queryable. It was visible before too, because nothing read the key. A client that parses cube metadata through the published JSON Schema now materializes `public: true` where it materialized `false`.
- **A cube that writes `public: false`** is now hidden and refused. If you wrote it only because it was the old default, delete the line (cubes are visible by default). A dashboard or report that queries such a cube starts answering `404 CUBE_NOT_FOUND` until you do.
- **A compiled artifact built before this release** carries a materialized `public: false` on every cube, because `os compile` writes the parsed stack with its defaults applied. Recompile it with this release before serving cubes from it.
- **Cubes the platform mints itself** stay visible: the cube inferred for an ad-hoc query on an object (the KPI path), a compiled dataset's cube (`POST /api/v1/analytics/dataset/query`), and `CubeRegistry.inferFromObject`. Each wrote a literal `false`, the old default, and now writes `true`.

The showcase example's `showcase_delivery` cube, which is the app's demonstration of `/api/v1/analytics/*`, drops its `public: false`.
2 changes: 1 addition & 1 deletion content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ const result = await client.analytics.query({
});
console.log(result.rows.length, result.fields[0].name); // AnalyticsResult, unwrapped

// Get cube metadata — all cubes, or one with meta('account')
// Get cube metadata — all cubes (those declared public: false are omitted), or one with meta('account')
const meta = await client.analytics.meta('account');
console.log(meta[0].name, meta[0].measures.length); // the bare cube list

Expand Down
10 changes: 6 additions & 4 deletions content/docs/api/data-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -479,10 +479,12 @@ so its column shows the tenant default. The cube query on this page carries no c

### `GET /analytics/meta`

Get metadata for all registered cubes. Cubes are explicitly defined (via `defineCube`
or the analytics service's `cubes` config) — a cube referenced by a query that isn't
yet registered is lazily auto-inferred from that query's shape, but metadata isn't
proactively generated for every object.
Get metadata for all registered cubes, except those declared `public: false`, which are
omitted (and refused by `POST /analytics/query` and `POST /analytics/sql` with the same
`404 CUBE_NOT_FOUND` an unknown cube name gets). Cubes are explicitly defined (via
`defineCube` or the analytics service's `cubes` config) — a cube referenced by a query
that isn't yet registered is lazily auto-inferred from that query's shape, but metadata
isn't proactively generated for every object.

Pass `?cube=<name>` to filter the listing to a single cube (this is what
`client.analytics.meta(cube)` sends).
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ Type: `[string, string]`
| **dimensions** | `Record<string, { name: string; label: string; description?: string; type: Enum<'string' \| 'number' \| 'boolean' \| 'time' \| 'geo'>; … }>` | ✅ | Qualitative attributes |
| **joins** | `Record<string, { name: string }>` | optional | |
| **refreshKey** | `{ every?: string; sql?: string }` | optional | |
| **public** | `boolean` | optional (default: `false`) | |
| **public** | `boolean` | optional (default: `true`) | Whether the analytics API exposes this cube. Default true (visible). false hides it from GET /analytics/meta and refuses POST /analytics/query and /analytics/sql for it (CUBE_NOT_FOUND). Visibility only: the underlying object's permissions and row-level security still govern its records on every door. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand Down
14 changes: 10 additions & 4 deletions docs/qa/platform-checklist/areas/dashboards.json
Original file line number Diff line number Diff line change
Expand Up @@ -964,7 +964,7 @@
"title": "The showcase_delivery analytics cube serves /api/v1/analytics/*: meta discovers its measures/dimensions, a query answers a known aggregate that reconciles against a direct /data aggregate, and an unwired analytics slot degrades honestly to 404",
"since": "v16",
"status": "active",
"revision": 1,
"revision": 2,
"priority": "P2",
"surface": "api",
"personas": [
Expand All @@ -990,7 +990,7 @@
{
"clause": "meta discovers the cube: GET /analytics/meta lists showcase_delivery with exactly its four measures (namespaced showcase_delivery.count / .total_estimate_hours / .avg_estimate_hours / .done_rate) and four dimensions (showcase_delivery.status / .priority / .due_date / .assignee)",
"oracle": "api",
"verify": "the meta response's cubes[] entry for showcase_delivery names all four measures and four dimensions (getMeta keys them `${cube}.${key}` and does NOT filter on the cube's public:false flag)",
"verify": "the meta response's cubes[] entry for showcase_delivery names all four measures and four dimensions (getMeta keys them `${cube}.${key}`, and lists a cube only when its `public` is not `false`: a cube declaring `public: false` is omitted from meta and refused by query()/generateSql() with 404 CUBE_NOT_FOUND. showcase_delivery declares no `public`, so it is visible by the schema default `true`)",
"evidence": "the /analytics/meta?cube=showcase_delivery body"
},
{
Expand Down Expand Up @@ -1042,11 +1042,11 @@
"single-datapoint"
],
"source": [
"examples/app-showcase/src/data/analytics/showcase.cube.ts#showcase_project (the showcase_delivery cube — measures, dimensions, base table, showcase_project join, public:false)",
"examples/app-showcase/src/data/analytics/showcase.cube.ts#showcase_project (the showcase_delivery cube — measures, dimensions, base table, showcase_project join; no `public` key, so visible by the default `true`)",
"examples/app-showcase/src/coverage.ts#analyticsCubes (analyticsCubes registration → src/data/analytics/showcase.cube.ts, served by /api/v1/analytics/*)",
"packages/runtime/src/domains/analytics.ts#cube (route contract: POST /analytics/query, GET /analytics/meta[?cube], entry validation, ExecutionContext scoping #2852, handled:false 404 for an absent/stub slot #3891/#4000)",
"packages/spec/src/api/analytics.zod.ts#AnalyticsQueryRequestSchema (AnalyticsQueryRequestSchema bare shape + retiredKey query/format; meta response cubes[])",
"packages/services/service-analytics/src/analytics-service.ts#getMeta (getMeta keys measures/dimensions as `${cube}.${key}`, returns all registry cubes)",
"packages/services/service-analytics/src/analytics-service.ts#getMeta (getMeta keys measures/dimensions as `${cube}.${key}` and omits a registry cube whose `public` is `false` (cube-visibility.ts#isCubePublic); query()/generateSql() refuse such a cube with 404 CUBE_NOT_FOUND through assertCubePublic)",
"packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS (CAPABILITY_PROVIDERS.analytics → @objectstack/service-analytics, configKey analyticsCubes)"
],
"history": [
Expand All @@ -1055,6 +1055,12 @@
"date": "2026-08-08",
"change": "new item: showcase_delivery cube /analytics/* meta+query reconciliation against the direct /data aggregate, with the honest empty-slot 404 degradation clause and the entry-validation negatives",
"ref": "claude/platform-test-checklist-ocwugl"
},
{
"revision": 2,
"date": "2026-09-28",
"change": "re-spelled the meta clause's verify text and two source lines, which stopped being true when the analytics service began reading `analytics_cube.public`: getMeta no longer returns every registry cube (it omits one declaring `public: false`, and query()/generateSql() refuse it with 404 CUBE_NOT_FOUND), and the showcase cube no longer declares `public: false` (it is visible by the default `true`). Text only: every clause, step and verdict is unchanged, because showcase_delivery stays visible and its meta entry still names the same four measures and four dimensions",
"ref": "#20348"
}
]
},
Expand Down
7 changes: 6 additions & 1 deletion examples/app-showcase/src/data/analytics/showcase.cube.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,12 @@ export const DeliveryCube = defineCube({
refreshKey: {
every: '1 hour',
},
public: false,
// No `public` key: the cube is VISIBLE, the default. It is this app's
// demonstration of the `/api/v1/analytics/*` surface (src/coverage.ts marks
// `analyticsCubes` demonstrated, and the platform checklist's dashboards item
// discovers and queries it there). `public: false` would hide it from
// `/analytics/meta` and refuse every query against it — this file authored
// exactly that, inertly, until the analytics service began reading the key.
});

export const allCubes = [DeliveryCube];
9 changes: 9 additions & 0 deletions examples/app-showcase/test/gap-fill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ describe('showcase gap fill — analytics cube', () => {
expect(DeliveryCube.joins?.project?.name).toBe('showcase_project');
});

it('is VISIBLE on the analytics API — it demonstrates /api/v1/analytics/*, so it cannot be hidden', () => {
// `public: false` hides a cube from `/analytics/meta` and refuses every
// query against it (service-analytics `cube-visibility.ts`). This cube is
// the showcase's `analyticsCubes` demonstration (src/coverage.ts), so a
// hidden one would demonstrate a 404. `defineCube` parses, so the omitted
// key reads back as the schema default.
expect(DeliveryCube.public).toBe(true);
});

it('keys every join by a FOREIGN-KEY FIELD of its own base object, not by the target', () => {
// #18612: the `joins` record KEY is what both strategies join ON — native
// emits `ON "<base>"."<key>" = "<key>"."id"`, ObjectQL lowers `fkField: key`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ const CUBE: Cube = {
(n) => [n, { name: n, label: n, type: 'string', sql: n }],
),
),
public: false,
public: true,
} as unknown as Cube;

interface WireBearingError extends Error {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ const cubeWithMeasureType = (type: string): Cube => ({
sql: 'opportunity',
measures: { revenue: { name: 'revenue', label: 'Revenue', type, sql: 'amount' } as Cube['measures'][string] },
dimensions: { region: { name: 'region', label: 'Region', type: 'string', sql: 'region' } },
public: false,
public: true,
});

async function analyticsVia(engine: unknown, cube: Cube): Promise<AnalyticsService> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const ordersCube: Cube = {
granularities: ['day', 'week', 'month'],
},
},
public: false,
public: true,
};

const baseQuery: AnalyticsQuery = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ const CUBE: Cube = {
(n) => [n, { name: n, label: n, type: 'string', sql: n }],
),
),
public: false,
public: true,
} as unknown as Cube;

interface WireBearingError extends Error {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ const CUBE: Cube = {
dimensions: Object.fromEntries(
Object.keys(CROSS_FIELD_OFFSET_OBJECT_FIELDS).map((n) => [n, { name: n, label: n, type: 'string', sql: n }]),
),
public: false,
public: true,
} as unknown as Cube;

/** The ruling's dataset: the on-time count, the late count, and the rate. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ const CUBE: Cube = {
amount: { name: 'amount', label: 'Amount', type: 'number', sql: 'amount' },
budget: { name: 'budget', label: 'Budget', type: 'number', sql: 'budget' },
},
public: false,
public: true,
} as unknown as Cube;

// ── The supported arm: routed, not refused ───────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const authoredCube: Cube = {
sql: 'some_physical_table',
measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } },
dimensions: {},
public: false,
public: true,
};

/** Records which object each aggregate ran against, so we can assert none ran. */
Expand Down
Loading
Loading