From 9383ed1f70db2134e3d6b3248e404421335a7b54 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 22:27:50 +0000 Subject: [PATCH 1/3] feat(spec,service-analytics): GET /analytics/meta publishes cube and member descriptions and the measure format CubeMeta gains an optional description on the cube, measures and dimensions, and an optional format on measures; AnalyticsMetadataResponseSchema mirrors it. AnalyticsService.getMeta copies the values a cube definition declares. The three analytics_cube description ledger rows move dead -> live, and the showcase done_rate format becomes the numeral pattern '0.0%'. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...tics-cube-format-granularities-enforced.md | 2 +- .../20282-analytics-cube-meta-descriptions.md | 12 ++ content/docs/api/data-api.mdx | 9 +- .../src/data/analytics/showcase.cube.ts | 4 +- .../cube-meta-description-format.test.ts | 154 ++++++++++++++++++ .../src/analytics-service.ts | 14 +- packages/spec/liveness/README.md | 2 +- packages/spec/liveness/analytics_cube.json | 28 ++-- packages/spec/src/api/analytics.test.ts | 40 ++++- packages/spec/src/api/analytics.zod.ts | 24 ++- .../spec/src/contracts/analytics-service.ts | 27 ++- packages/spec/src/data/analytics.zod.ts | 6 +- 12 files changed, 286 insertions(+), 36 deletions(-) create mode 100644 .changeset/20282-analytics-cube-meta-descriptions.md create mode 100644 packages/services/service-analytics/src/__tests__/cube-meta-description-format.test.ts diff --git a/.changeset/20282-analytics-cube-format-granularities-enforced.md b/.changeset/20282-analytics-cube-format-granularities-enforced.md index 2881db884c9..8805e04deaf 100644 --- a/.changeset/20282-analytics-cube-format-granularities-enforced.md +++ b/.changeset/20282-analytics-cube-format-granularities-enforced.md @@ -13,7 +13,7 @@ Clause-②: yes (narrowing) Until this change both keys were read on the compiled-dataset path only. One cube shape has three producers — cubes authored with `defineCube()` / `defineStack({ analyticsCubes })`, cubes the dataset compiler mints, and cubes inferred for an ad-hoc query — and only a compiled dataset's cube reached the two readers: -- **`measures..format`** reached a caller as `fields[].format` only because the dataset door copies it from the DATASET measure. An authored cube has no dataset, so `POST /api/v1/analytics/query` described its measure columns with `name` and `type` alone. Now every measure column a query names carries the `format` its cube measure declares, whichever strategy answered, and a column that declares none carries no `format` key at all. `GET /api/v1/analytics/meta` is unchanged: its projection stays `name`, `type` and `title`, and a client reads `format` off the query result's `fields[]`, as the Data API page already says. The value is relayed verbatim; the vocabulary `fields[].format` documents is a numeral pattern such as `"$0,0.00"` or `"0.0%"`. +- **`measures..format`** reached a caller as `fields[].format` only because the dataset door copies it from the DATASET measure. An authored cube has no dataset, so `POST /api/v1/analytics/query` described its measure columns with `name` and `type` alone. Now every measure column a query names carries the `format` its cube measure declares, whichever strategy answered, and a column that declares none carries no `format` key at all. A client that formats a result column reads `format` off the query result's `fields[]`, as the Data API page says; `GET /api/v1/analytics/meta` also publishes each measure's declared `format`, a separate entry in this release. The value is relayed verbatim; the vocabulary `fields[].format` documents is a numeral pattern such as `"$0,0.00"` or `"0.0%"`. - **`dimensions..granularities`** was the default bucket only for a compiled dataset, which the dataset executor filled in before querying. An authored cube's time dimension grouped raw timestamps whatever it declared. Now `query()` and the `generateSql()` dry run read it the same way, through the one rule both paths share: a single-entry list is the dimension's default bucket for a query that groups by it; a granularity the query states always wins, and one the list does not name is not refused (the dataset path compares against no list either); a list of two or more states no default; and a `timeDimensions` entry that carries only a `dateRange` for a dimension the query does not group stays a filter. What to expect after upgrading: diff --git a/.changeset/20282-analytics-cube-meta-descriptions.md b/.changeset/20282-analytics-cube-meta-descriptions.md new file mode 100644 index 00000000000..df638ae6f1b --- /dev/null +++ b/.changeset/20282-analytics-cube-meta-descriptions.md @@ -0,0 +1,12 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-analytics': minor +--- + +`GET /api/v1/analytics/meta` now publishes an analytics cube's `description`, each measure's and dimension's `description`, and each measure's `format`, when the cube definition declares them (#20282). + +Clause-②: yes (widening) + +- `CubeMeta` (`@objectstack/spec/contracts`) gains an optional `description` on the cube and on each measure and dimension, and an optional `format` on each measure. `AnalyticsMetadataResponseSchema` declares the same members. A definition that declares none of them is published exactly as before. +- `AnalyticsService.getMeta` copies what the definition declares and fills in nothing. A cube compiled from a dataset carries each dataset measure's `format` and no `description`. +- The liveness ledger rows `analytics_cube.description`, `measures.description` and `dimensions.description` move from `dead` to `live`. diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index 6c274210c41..0e2cf7d0786 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -461,12 +461,13 @@ never as guaranteed present. The **cube's metric/dimension definition** (`MetricSchema.label` / `MetricSchema.format`, `DimensionSchema.label`) is the *declaration* surface, not a substitute for this one. `GET /analytics/meta` below publishes a deliberately narrow projection of it — `name`, -`type` and `title` (the definition's `label`) only, with `format` dropped +`type`, `title` (the definition's `label`), `description`, and on a measure its declared +`format`; never `sql` or `granularities` ([#6442](https://github.com/objectstack-ai/objectstack/issues/6442)). So a client that renders table headers or formats amounts takes `label` / `format` / `currency` / -`percentScale` off the query result's `fields[]`: those are resolved server-side (the -currency chain below, the percent-scale chain), and `format` is not reachable through -the metadata endpoint at all. +`percentScale` off the query result's `fields[]`: those are resolved server-side, per +column (the currency chain below, the percent-scale chain), and `currency` and +`percentScale` are not reachable through the metadata endpoint at all. The currency chain runs on the dataset query (`POST /analytics/dataset/query`). A monetary measure column takes the measure's own `currency`; then, only when the source diff --git a/examples/app-showcase/src/data/analytics/showcase.cube.ts b/examples/app-showcase/src/data/analytics/showcase.cube.ts index 0cbceb912d9..3d9e7401458 100644 --- a/examples/app-showcase/src/data/analytics/showcase.cube.ts +++ b/examples/app-showcase/src/data/analytics/showcase.cube.ts @@ -39,7 +39,9 @@ export const DeliveryCube = defineCube({ label: 'Done Rate (%)', type: 'number', sql: "SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) * 100.0 / COUNT(*)", - format: 'percent', + // A numeral pattern, the vocabulary `fields[].format` documents: `%` marks a + // percent, `.0` one decimal. The value above is in percentage points (0-100). + format: '0.0%', }, }, dimensions: { diff --git a/packages/services/service-analytics/src/__tests__/cube-meta-description-format.test.ts b/packages/services/service-analytics/src/__tests__/cube-meta-description-format.test.ts new file mode 100644 index 00000000000..dbda5476858 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/cube-meta-description-format.test.ts @@ -0,0 +1,154 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `analytics_cube.description`, `measures.description`, `dimensions.description` + * and `measures.format` on the discovery door — `getMeta()`, which + * `GET /api/v1/analytics/meta` hands to `success()` verbatim. + * + * The `CubeMeta` projection carried `{ name, type, title }` per member and + * `{ name, title }` per cube, so an authored description reached no reader at + * all, and a measure's `format` reached only the query door's `fields[]`. + * + * What this file pins: + * - an authored cube's descriptions (cube, measure, dimension) and a measure's + * `format` are published exactly as written; + * - a definition that declares none of them publishes no key — the projection + * copies, it never fills in; + * - a compiled dataset's cube publishes the `format` the dataset compiler + * copies from each dataset measure, and no `description`: the compiler + * writes none, so there is none to publish; + * - a hidden cube stays hidden, descriptions or not. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { CubeSchema, type Cube } from '@objectstack/spec/data'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import { AnalyticsService } from '../analytics-service.js'; + +const silentLogger = { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), +} as any; + +/** Parsed the way `defineCube()` and `defineStack({ analyticsCubes })` parse an authored cube. */ +const described: Cube = CubeSchema.parse({ + name: 'orders', + title: 'Orders', + description: 'Every order placed in the shop, one row per order.', + sql: 'shop_order', + measures: { + revenue: { + label: 'Revenue', + description: 'Sum of order amounts, in the order currency.', + type: 'sum', + sql: 'amount', + format: '$0,0.00', + }, + margin: { label: 'Margin', type: 'avg', sql: 'margin', format: '0.0%' }, + count: { label: 'Orders', description: 'Number of orders.', type: 'count', sql: '*' }, + }, + dimensions: { + status: { label: 'Status', description: 'Fulfilment status of the order.', type: 'string', sql: 'status' }, + placed_at: { label: 'Placed', type: 'time', sql: 'placed_at' }, + }, +}); + +/** The same kind of cube with no description and no format anywhere. */ +const bare: Cube = CubeSchema.parse({ + name: 'refunds', + sql: 'shop_refund', + measures: { count: { label: 'Refunds', type: 'count', sql: '*' } }, + dimensions: { reason: { label: 'Reason', type: 'string', sql: 'reason' } }, +}); + +function service(cubes: Cube[]) { + return new AnalyticsService({ logger: silentLogger, cubes }); +} + +describe('getMeta — an authored cube publishes its descriptions and measure formats', () => { + it('copies the cube, measure and dimension descriptions and each measure format as written', async () => { + const [cube] = await service([described]).getMeta('orders'); + + expect(cube).toEqual({ + name: 'orders', + title: 'Orders', + description: 'Every order placed in the shop, one row per order.', + measures: [ + { + name: 'orders.revenue', + type: 'sum', + title: 'Revenue', + description: 'Sum of order amounts, in the order currency.', + format: '$0,0.00', + }, + { name: 'orders.margin', type: 'avg', title: 'Margin', format: '0.0%' }, + { name: 'orders.count', type: 'count', title: 'Orders', description: 'Number of orders.' }, + ], + dimensions: [ + { name: 'orders.status', type: 'string', title: 'Status', description: 'Fulfilment status of the order.' }, + { name: 'orders.placed_at', type: 'time', title: 'Placed' }, + ], + }); + }); + + it('publishes no key a definition does not declare', async () => { + const [cube] = await service([bare]).getMeta('refunds'); + + expect(cube).not.toHaveProperty('description'); + expect(cube.measures[0]).not.toHaveProperty('description'); + expect(cube.measures[0]).not.toHaveProperty('format'); + expect(cube.dimensions[0]).not.toHaveProperty('description'); + // What the projection did publish before this change is unchanged. + expect(cube.measures[0]).toEqual({ name: 'refunds.count', type: 'count', title: 'Refunds' }); + expect(cube.dimensions[0]).toEqual({ name: 'refunds.reason', type: 'string', title: 'Reason' }); + }); + + it('answers the listing and the by-name lookup alike', async () => { + const svc = service([described, bare]); + const listed = (await svc.getMeta()).find((c) => c.name === 'orders'); + const [byName] = await svc.getMeta('orders'); + expect(listed).toEqual(byName); + expect(listed?.description).toBe('Every order placed in the shop, one row per order.'); + }); + + it('still omits a cube declared `public: false`, whatever it describes', async () => { + const hidden = CubeSchema.parse({ ...described, name: 'hidden_orders', public: false }); + const svc = service([hidden]); + expect(await svc.getMeta()).toEqual([]); + expect(await svc.getMeta('hidden_orders')).toEqual([]); + }); +}); + +describe('getMeta — a compiled dataset cube publishes what the compiler wrote', () => { + const dataset = DatasetSchema.parse({ + name: 'order_metrics', + label: 'Order metrics', + description: 'Order KPIs for the sales dashboard.', + object: 'shop_order', + include: [], + dimensions: [{ name: 'status', field: 'status', type: 'string' }], + measures: [ + { name: 'revenue', aggregate: 'sum', field: 'amount', format: '$0,0' }, + { name: 'count', aggregate: 'count' }, + ], + }); + + it('carries each dataset measure `format`, and no description (the compiler writes none)', async () => { + const svc = service([]); + svc.registerDataset(dataset); + const [cube] = await svc.getMeta('order_metrics'); + + expect(cube.measures.find((m) => m.name === 'order_metrics.revenue')?.format).toBe('$0,0'); + expect(cube.measures.find((m) => m.name === 'order_metrics.count')).not.toHaveProperty('format'); + // The dataset's own `description` is not copied onto the cube it compiles to + // (`dataset-compiler.ts#compileDataset`), and a dataset measure or dimension + // has no `description` key to copy. Nothing is filled in on this path. + expect(cube).not.toHaveProperty('description'); + for (const member of [...cube.measures, ...cube.dimensions]) { + expect(member).not.toHaveProperty('description'); + } + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index f5a69cba723..7d187839945 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -487,7 +487,8 @@ function withDeclaredGranularityDefaults(query: AnalyticsQuery, cube: Cube | und * column of a result with the display `format` its cube measure declares. * * `fields[].format` is the presentation surface a client formats amounts from - * (`AnalyticsResult`); `GET /analytics/meta` deliberately does not carry it. + * (`AnalyticsResult`), column by column; `GET /analytics/meta` publishes the + * same declared value per measure (`getMeta`), with no column to attach it to. * The compiled-dataset path fills it from the dataset's own measure * (`enrichResultColumns`), and the dataset compiler copies that same value onto * the cube it mints — so for a compiled dataset the value read here is the one @@ -2307,6 +2308,13 @@ export class AnalyticsService implements IAnalyticsService { * Only cubes the analytics API exposes are listed: a cube declared * `public: false` is omitted, and asking for it by name answers `[]` — the * same answer as a name no cube has (`cube-visibility.ts`). + * + * `description` (cube, measure, dimension) and a measure's `format` are the + * registered definition's own values, copied when declared and left off when + * not — never filled in. An authored cube carries what its author wrote; a + * compiled dataset's cube carries the `format` the dataset compiler copies + * from each dataset measure, and no `description`, because the compiler + * writes none. */ async getMeta(cubeName?: string): Promise { const cubes = (cubeName @@ -2317,15 +2325,19 @@ export class AnalyticsService implements IAnalyticsService { return cubes.map(cube => ({ name: cube.name, title: cube.title, + ...(cube.description === undefined ? {} : { description: cube.description }), measures: Object.entries(cube.measures).map(([key, measure]) => ({ name: `${cube.name}.${key}`, type: measure.type, title: measure.label, + ...(measure.description === undefined ? {} : { description: measure.description }), + ...(measure.format === undefined ? {} : { format: measure.format }), })), dimensions: Object.entries(cube.dimensions).map(([key, dimension]) => ({ name: `${cube.name}.${key}`, type: dimension.type, title: dimension.label, + ...(dimension.description === undefined ? {} : { description: dimension.description }), })), })); } diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index fe291d1f8af..28406e1b10f 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -945,7 +945,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | realtime_subscription | seeded 2026-09-04 (#14446) — a TRANSPORT-PROTOCOL surface, the fifth category the `SPEC_ONLY_SCHEMAS` override has had to reach. `SubscriptionSchema` (`packages/spec/src/api/realtime.zod.ts`) is what a client declares to open a realtime subscription: the item type of `RealtimeConfigSchema.subscriptions` and the `Subscription` the generated API reference publishes. Like `query` it is a request surface rather than stored metadata, and like `query` that is exactly why it went unasked — no registry holds it, `RealtimeConfigSchema` is `.passthrough()` so nothing downstream even refuses an unknown key, and the whole vocabulary sat outside the denominator while the reference kept publishing it. Rooted on `SubscriptionSchema` rather than on `RealtimeConfigSchema` for the reason the four `RestServerConfig` sub-objects document one row up: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with the config as the root `events[].type` and `events[].filters` would inherit a container verdict instead of carrying rows of their own — #4956's shape. **Dead 6 = every key it has, and the CONTAINER is the finding**: nothing outside `packages/spec` imports `SubscriptionSchema`, `SubscriptionEventSchema` or `RealtimeConfigSchema` at all, so no key beneath them can be read (the `manifest.contributes` reasoning). The two keys the card measured are the sharp ones. `events[].type` accepts `RealtimeEventType`, whose four members (`record.created` / `record.updated` / `record.deleted` / `field.changed`), as measured at `5f5511f0` before #20288 repointed the enum at the emitted `DataEventType` + `BulkDataEventType` names, are DISJOINT from what the engine publishes (`DataEventType`'s `data.record.*`, live emitter in `service-knowledge`), so an author who writes the enum's own `record.created` gets a subscription that silently never fires — and the enum is what the API reference shows them. Its direction is settled by the 2026-09-02 triage and quoted verbatim in the row: enforce means REPOINTING THE ENUM, never changing what the runtime publishes. `field.changed` is the same spelling the sibling `DataEventType` REMOVED in 17.0.0 (#4673, PR #4685) for having no producer; it survives here only because this enum was never in a ratchet's denominator. `events[].filters` is `z.unknown().optional()` — the textbook ADR-0049 fourth state, no shape and no reader, failing in the permissive direction (a subscriber who filters receives every event). ⚠️ Three spellings of a realtime subscription exist and only the third is executed: this one, `websocket.zod.ts#EventSubscriptionSchema`, and the plain interface `contracts/realtime-service.ts#RealtimeSubscriptionOptions` that `in-memory-realtime-adapter.ts#matchesSubscription` actually reads. The file note names the same-name-different-shape traps so the next census does not mistake one for a consumer. Zero live | | sharing_rule | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, and the first one PAID (`connector` and `analytics_cube` are still owed on that card). Not a registered kind: it is bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reaches the walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so this ledger governs a type `listMetadataTypeSchemaTypes()` still does not enumerate. One shape fact decides every row: the AUTHORING shape is not the ENFORCED shape. ADR-0057 D6 makes the `sys_sharing_rule` row canonical (`object_name` + `criteria_json` + `recipient_type`/`recipient_id` + `access_level`) and `bootstrapDeclaredSharingRules` translates each authored key into it at boot — nothing re-parses `SharingRuleSchema` at enforcement time — so every consumer cited reads a COLUMN and every row carries the `producer` (#4837) that populates it, which is the `seed.env` lesson applied to a whole type rather than to one key. Preview read points ENUMERATED per the #7131 rule and the answer recorded rather than skipped: `registerBuiltinPreviews()` (objectui @dda8f381) registers twenty types and `sharing_rule` is not one of them; what objectui does consume is the whole shape, on the CREATE door only (`AUTHOR_SHAPE_ONLY_TYPES` — the EDIT door is deliberately ungated because a served body carries the `_diagnostics` decoration this `.strict()` schema rejects). The single non-`live` row is `type`, the `SharingRuleType` discriminator: one member, `criteria`, whose only reader is a defensive `=== 'owner'` comparison that is unreachable for every value the schema admits. `planned` on the `action.operation` precedent (a one-member discriminator held `planned` until a runtime half dispatched on it, #15080), and deliberately NOT an enforce-or-remove candidate: the key is required, so removing it would break every authored rule to delete nothing. | | connector | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, paid in the same diff as `analytics_cube`, which empties that map. Not a registered kind: bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reached through `getMetadataTypeSchema`'s unregistered-kind fallback. **What the walk actually resolves, measured:** the binding names `DeclarativeConnectorEntrySchema`. ⚠️ The MECHANISM changed with the `connectionTimeoutMs` retirement and the prior sentence here is corrected rather than carried: that schema USED TO BE `ConnectorSchema.superRefine(...)`, a Zod 4 check attached to the same object def, and the key-set conclusion used to rest on that attachment. It is now a `z.preprocess` PIPE — both published carriers wrap one shared private `ConnectorBaseSchema` in the ADR-0049 retired-default residue stage, the entry schema adding the ADR-0097 cross-field rules on the base before wrapping, so the two are SIBLINGS rather than parent and child, and what preserves the walked shape is the pipe's read-through `shape`, NOT a `superRefine` attachment. The CONCLUSION is unchanged and re-measured on the built entry rather than inherited: both carriers expose 30 keys and the key sets are byte-identical, with no entry-only and no base-only key. The gate cannot tell the two schemas apart; what the entry schema buys is REFUSALS, invisible to the walk and visible only in the two rows where they are the whole verdict (`authentication` and `actions`; `triggers` was the third until its retirement made it a tombstone both carriers refuse). **ONE SCHEMA, TWO DOORS** is the shape fact behind the 29/1/25 split (live/planned/dead; counts read from the generated `state-counts/connector.md` shard, never hand-kept here): the ledger's denominator entry exists for the AUTHORING doors (`defineStack({ connectors })`, `PUT /meta/connector/:name`), while the same `ConnectorSchema` is what `AutomationEngine.registerConnector` parses for a def a PLUGIN or an ADR-0097 provider factory builds in code — so a key can have a real consumer and still do nothing when a metadata author writes it. The keys an authored entry can reach are exactly the author-supplied `ConnectorProviderContext` fields plus `provider` and `enabled` — `name` is itself one of those fields (the former "plus `name`" tail double-counted it), `loadPackageFile` is host-injected rather than authored, and `provider` selects the factory without ever reaching the context; `type` and `icon` reach that context and are dropped by all three shipped factories, and each says so on its own row. `authentication` is the ledger's `planned`, and ⛔ NOT "refused outright" — the former tail here said exactly that and all three instruments contradict it, including the one it cites: the KEY is ACCEPTED (`connector.zod.ts` declares `authentication: ConnectorAuthConfigSchema.optional().default({ type: 'none' })`, and the accepted value does nothing); what #7990 refuses is a non-`none` VALUE (`if (entry.authentication && entry.authentication.type !== 'none')`, whose own message prescribes "drop `authentication` (or set `{ type: 'none' }`)"); and ADR-0097 §3, titled "Credentials are references", rejects **inline secrets** in stack metadata, not the key. Accepted-and-ignored, plus a loud refusal of every value but `{ type: 'none' }`, is exactly the basis of the `planned` verdict — which the row itself already stated ("the accepted value does nothing"), so the summary, not the row, was the wrong half. The 25 `dead`, re-measured at this head and partitioned so every row is counted exactly once: two declared subsystems with no engine — `syncConfig` (8), `fieldMappings` (7) — plus `metadata`, `actions.description`/`.outputSchema`, and the seven top-level `retiredKey` tombstones `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status`, `webhooks` and `triggers`. That sums to 25, the dead count the generated `state-counts/connector.md` shard carries. ⚠️ It was 30 until the connector `triggers` array was retired (ADR-0049; ADR-0041 unchanged): `triggers` counted 6 drilled rows (`key`, `label`, `description`, `type`, `intervalSeconds` and the `interval` rename tombstone — dead because nothing read a connector trigger, which the schema's own docblock said: #3197) and is now ONE leaf tombstone row, by the same gate rule as `health` below. ⚠️ It was 44 until the connector resilience family was retired (ADR-0049): `health` counted 15 drilled rows (both sub-blocks plus the `monitoringWindow` tombstone) and is now ONE leaf tombstone row — the gate refuses `children` under a property that is no longer a container — and `webhooks` left the undrilled baseline for the same reason; `status` and `webhooks` stayed one row each and changed only from dead-awaiting-a-decision to dead-and-tombstoned. ⚠️ `retryConfig` IS NO LONGER IN THIS LIST: all eight of its sub-keys went `live` when #18975 made the declared policy execute at the one platform fetch site, which is the same measurement the falsification note at the end of this row records — so a reader who still finds "`retryConfig` (8)" among the dead is reading a stale copy. ⚠️ Nor is it "the two timeouts" any more: `requestTimeoutMs` is `live` (it becomes `resilientFetch`'s per-attempt deadline) and `connectionTimeoutMs` is the retired tombstone named above. ⭐ EIGHT rows in this ledger are `retiredKey` tombstones that keep their rows because the key stays in the walked shape (the `rls.priority` precedent) — `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status`, `webhooks`, `triggers` and `fieldMappings.transform` — but ⛔ that eight is NOT a separate addend: the first seven ARE the top-level tombstones counted above and the last one is already inside the `fieldMappings` count, which is exactly the double-count that made the previous "and four `retiredKey` tombstones" tail drift. (`triggers.interval` was one of the eight until its array left whole with `triggers`, whose own leaf row took its place — the count held at eight by a swap, not by standing still.) (`health.circuitBreaker.monitoringWindow` was the ninth until its block left whole with `health`.) Count them by name, never by adding the tail. **A prior in-repo claim is recorded here with its DIRECTION measured rather than remembered, because this row's job is the history of how the type got here**: the conversion registry's note inside `connector-rate-limit-config-removed`'s fixture reads "`retryConfig` and the timeouts beside it are untouched by THIS conversion — a statement about its scope, not a liveness verdict. They are not live: declared, defaulted and documented, and read by nothing." ⚠️ It asserts they are NOT live, and it scopes "untouched" to that one conversion. The former tail here quoted it as asserting the OPPOSITE ("they are live") and called it false when seeded — an inversion that turned this whole passage upside down, and it is corrected rather than carried. Measured direction: the note was TRUE when this ledger was seeded (2026-09-17) and is STALE now, #18975 having made the declared policy execute at the one platform fetch site (`connectorFetchOptions` → `resilientFetch`), so `retryConfig`'s eight sub-keys are `live` on their own rows and `requestTimeoutMs` is `live` beside them; only `connectionTimeoutMs` still answers to it, as the retired tombstone. ⛔ The stale comment is not rewritten from here — it is #19729's, as a dated note beside it — and it is not a line this PR's diff touches. ⚠️ The seeding note's supporting census — "the word does not occur outside `packages/spec` at all" — is FALSE at this head and is corrected rather than carried: `git grep -n retryConfig 14fdebd766 -- . ':!packages/spec'` returns 67 **matching lines** over 15 files — `git grep -o` on the same tree and pathspec returns 77 **occurrences**, and a line is not an occurrence, which is the trap a re-measurer falls into next (26 matching lines in the materializer `packages/services/service-automation/src/plugin.ts` and its materialization test, 22 across `connector-rest` and `connector-openapi` — providers, connectors and their tests — 13 in five `.changeset` fragments, and 6 on two `content/docs` pages). ⛔ Re-read that as the standing lesson of this row: a census is a count plus the tree it was taken against, and a bare "does not occur" with no commit behind it is the shape that rots first. The timeouts half is settled on its own rows: `requestTimeoutMs` is `live`, `connectionTimeoutMs` is retired | -| analytics_cube | seeded 2026-09-17 (#18582) — the third debt, paid in the same diff as `connector`. Not a registered kind either: bound in `UNREGISTERED_KIND_SCHEMAS` by #10194 and reached through the same unregistered-kind fallback. **ONE Cube shape, THREE producers, one registry** is what decides every row: `cube-registry.ts` names them itself — authored cubes (`analyticsCubes[]` / `defineCube()`, threaded by the CLI into `AnalyticsServiceConfig.cubes`), COMPILED DATASETS (ADR-0021, where `dataset-compiler` mints a Cube), and ad-hoc query inference. Only the first is the authoring door governed here, so a key whose only reader sits on the compiled-dataset path is not live for an authored cube however busy that reader is — the #4837 producer rule on a shape with three producers. That kept `dimensions.granularities` (read only by `dataset-executor#granularityOf`, whose argument is a `CompiledDataset` an authored cube never becomes) and `measures.format` (written by the compiler, threaded to the wire from the DATASET measure instead) `dead` until **#20282**'s second stage (2026-09-29) read both on the query doors off whichever cube answers the name: `analytics-service#withDeclaredMeasureFormats` describes each measure column's `fields[].format`, and `#withDeclaredGranularityDefaults` buckets a grouped time dimension at the default `dataset-executor#declaredDefaultGranularity` reads — the one reading (a single-entry list) the dataset path's `granularityOf` now shares. The query path is genuinely live: `sql` is the FROM table AND the object whose RLS read scope is injected, `measures.type` picks the aggregate, `measures.sql`/`dimensions.sql` the column, `joins[].name` the joined table. The 6 `dead` are the `refreshKey` tombstone, the three `description`s, and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity — RETIRED by #20300 (ADR-0049 enforce-or-remove) as `retiredKey()` tombstones on the member `strictObject`s, so those two rows STAY `dead` (the tombstone keeps the key in the walked shape) and the count does not move. It was 7 until #20637 RETIRED `refreshKey` whole (ADR-0049 enforce-or-remove, maintainer letter C): the caching block's `every` and `sql` were two drilled `dead` rows — no refresh scheduler, pre-aggregation or analytics result cache exists anywhere, re-measured 2026-09-29 — and the `retiredKey()` tombstone that replaced the block is ONE leaf row, because the gate refuses `children` on a property that is no longer a container (the connector `health` precedent). **#20282** flips the tenth, the visibility flag `public`, `dead` → `live` 2026-09-27: seeded as a knob that was never wired (three internal mints wrote `false`, nothing read it), it is now read by `service-analytics`' `cube-visibility.ts#isCubePublic` — `getMeta` omits a hidden cube and `query()` / `generateSql()` refuse it — in the same change that moved its default from `false` to the Cube.dev `true`, since enforcing the old default would have hidden every authored cube. It was 12 until #18612 RETIRED `joins[].relationship` and the REQUIRED `joins[].sql` (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): the ON clause is SYNTHESISED as an FK equality and the authored one was never consulted, so a declared join condition came back REPLACED under a 200. `CubeJoinSchema` is a `strictObject`, so the route was strict deletion plus a `guidance` prescription and the two rows left this ledger with the keys — not the `retiredKey()` route, which keeps the row. **#10238 is not prejudged**: whether cube authoring is live end to end is still its own measurement — this ledger answers the per-key question only | +| analytics_cube | seeded 2026-09-17 (#18582) — the third debt, paid in the same diff as `connector`. Not a registered kind either: bound in `UNREGISTERED_KIND_SCHEMAS` by #10194 and reached through the same unregistered-kind fallback. **ONE Cube shape, THREE producers, one registry** is what decides every row: `cube-registry.ts` names them itself — authored cubes (`analyticsCubes[]` / `defineCube()`, threaded by the CLI into `AnalyticsServiceConfig.cubes`), COMPILED DATASETS (ADR-0021, where `dataset-compiler` mints a Cube), and ad-hoc query inference. Only the first is the authoring door governed here, so a key whose only reader sits on the compiled-dataset path is not live for an authored cube however busy that reader is — the #4837 producer rule on a shape with three producers. That kept `dimensions.granularities` (read only by `dataset-executor#granularityOf`, whose argument is a `CompiledDataset` an authored cube never becomes) and `measures.format` (written by the compiler, threaded to the wire from the DATASET measure instead) `dead` until **#20282**'s second stage (2026-09-29) read both on the query doors off whichever cube answers the name: `analytics-service#withDeclaredMeasureFormats` describes each measure column's `fields[].format`, and `#withDeclaredGranularityDefaults` buckets a grouped time dimension at the default `dataset-executor#declaredDefaultGranularity` reads — the one reading (a single-entry list) the dataset path's `granularityOf` now shares. The query path is genuinely live: `sql` is the FROM table AND the object whose RLS read scope is injected, `measures.type` picks the aggregate, `measures.sql`/`dimensions.sql` the column, `joins[].name` the joined table. The 3 `dead` are the `refreshKey` tombstone and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity — RETIRED by #20300 (ADR-0049 enforce-or-remove) as `retiredKey()` tombstones on the member `strictObject`s, so those two rows STAY `dead` (the tombstone keeps the key in the walked shape) and the count does not move. It was 6 until **#20282**'s third stage (2026-09-29) published the three `description`s on discovery: `analytics-service#getMeta` copies each onto its `CubeMeta` entry, the read point the `title`/`label` rows already cite (display-shaped, the #7131 split). It was 7 until #20637 RETIRED `refreshKey` whole (ADR-0049 enforce-or-remove, maintainer letter C): the caching block's `every` and `sql` were two drilled `dead` rows — no refresh scheduler, pre-aggregation or analytics result cache exists anywhere, re-measured 2026-09-29 — and the `retiredKey()` tombstone that replaced the block is ONE leaf row, because the gate refuses `children` on a property that is no longer a container (the connector `health` precedent). **#20282** flips the tenth, the visibility flag `public`, `dead` → `live` 2026-09-27: seeded as a knob that was never wired (three internal mints wrote `false`, nothing read it), it is now read by `service-analytics`' `cube-visibility.ts#isCubePublic` — `getMeta` omits a hidden cube and `query()` / `generateSql()` refuse it — in the same change that moved its default from `false` to the Cube.dev `true`, since enforcing the old default would have hidden every authored cube. It was 12 until #18612 RETIRED `joins[].relationship` and the REQUIRED `joins[].sql` (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): the ON clause is SYNTHESISED as an FK equality and the authored one was never consulted, so a declared join condition came back REPLACED under a 200. `CubeJoinSchema` is a `strictObject`, so the route was strict deletion plus a `guidance` prescription and the two rows left this ledger with the keys — not the `retiredKey()` route, which keeps the row. **#10238 is not prejudged**: whether cube authoring is live end to end is still its own measurement — this ledger answers the per-key question only | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every misleading entry carries `authorWarn` so authors hear about it at compile time diff --git a/packages/spec/liveness/analytics_cube.json b/packages/spec/liveness/analytics_cube.json index 5707c8a840f..e6eaffcba14 100644 --- a/packages/spec/liveness/analytics_cube.json +++ b/packages/spec/liveness/analytics_cube.json @@ -1,6 +1,6 @@ { "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` (#10194) 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. #10238 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), while the caching block and the three `description` annotations are 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` (#10194) 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. #10238 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", @@ -17,9 +17,11 @@ "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": "dead", - "verifiedAt": "2026-09-17", - "note": "Parsed, stored, and read by NOTHING. The `CubeMeta` projection both `getMeta` implementations build (`analytics-service.ts#getMeta`, `memory-analytics.ts#getMeta`) carries `name`, `title`, `measures` and `dimensions` — `description` is not in it, so unlike `title` it never reaches the discovery wire, and no strategy, driver or lint rule reads it. Census: zero reads of `cube.description` across packages/services, packages/drivers, packages/rest and packages/objectql, with `cube.title` as the lit control in the same scan (two hits, both cited on the `title` row above). ⛔ NOT an ADR-0049 retirement candidate on this reading alone: it is an administrative note on an authoring surface whose sibling annotations (`measures.description`, `dimensions.description`) are dead for the same reason and would go with it, and the `position.description` precedent keeps a documentation-shaped key that only documents. What it is NOT is enforced — an author who expects it on a dashboard picker is wrong today." + "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).", + "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", @@ -43,9 +45,11 @@ "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": "dead", - "verifiedAt": "2026-09-17", - "note": "Not in the `CubeMeta` measure projection (`{ name, type, title }`) and read nowhere else — the same census, and the same reasoning, as the cube-level `description` row above." + "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).", + "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", @@ -66,7 +70,7 @@ "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).", - "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. NOT on discovery: `GET /api/v1/analytics/meta` keeps the `CubeMeta` projection `{ name, type, title }` (the narrowing recorded on `AnalyticsMetadataResponseSchema`, #6442), and content/docs/api/data-api.mdx sends a client to `fields[]` for `format`. 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." + "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,9 +89,11 @@ "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": "dead", - "verifiedAt": "2026-09-17", - "note": "Not in the `CubeMeta` dimension projection (`{ name, type, title }`) and read nowhere else — same census as the two `description` rows above." + "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).", + "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", diff --git a/packages/spec/src/api/analytics.test.ts b/packages/spec/src/api/analytics.test.ts index d5a064fccad..1f437dd3610 100644 --- a/packages/spec/src/api/analytics.test.ts +++ b/packages/spec/src/api/analytics.test.ts @@ -386,9 +386,10 @@ describe('GetAnalyticsMetaRequestSchema', () => { */ describe('AnalyticsMetadataResponseSchema — the CubeMeta[] projection (#6442)', () => { /** - * A real `GET /analytics/meta` body: what `AnalyticsService.getMeta` and its - * `driver-memory` twin both build — measure/dimension names CUBE-QUALIFIED, - * `title` projected from the definition's `label`, and no `sql` anywhere. + * A real `GET /analytics/meta` body: what `AnalyticsService.getMeta` builds — + * measure/dimension names CUBE-QUALIFIED, `title` projected from the + * definition's `label`, `description` and a measure's `format` copied from + * the definition when it declares them, and no `sql` anywhere. */ const SERVED_BODY = { success: true, @@ -396,11 +397,18 @@ describe('AnalyticsMetadataResponseSchema — the CubeMeta[] projection (#6442)' { name: 'orders', title: 'Orders', + description: 'Every order placed in the shop', measures: [ - { name: 'orders.total_revenue', type: 'sum', title: 'Total Revenue' }, + { + name: 'orders.total_revenue', + type: 'sum', + title: 'Total Revenue', + description: 'Sum of order amounts', + format: '$0,0.00', + }, ], dimensions: [ - { name: 'orders.status', type: 'string', title: 'Status' }, + { name: 'orders.status', type: 'string', title: 'Status', description: 'Fulfilment status' }, ], }, ], @@ -416,6 +424,12 @@ describe('AnalyticsMetadataResponseSchema — the CubeMeta[] projection (#6442)' expect(resp.data[0].measures[0].title).toBe('Total Revenue'); expect(resp.data[0].dimensions[0].name).toBe('orders.status'); expect(resp.data[0]).not.toHaveProperty('sql'); + // The descriptions and the measure format are DECLARED members: a key the + // object schema did not declare would be stripped by this parse, not kept. + expect(resp.data[0].description).toBe('Every order placed in the shop'); + expect(resp.data[0].measures[0].description).toBe('Sum of order amounts'); + expect(resp.data[0].measures[0].format).toBe('$0,0.00'); + expect(resp.data[0].dimensions[0].description).toBe('Fulfilment status'); }); it('accepts an empty cube list', () => { @@ -468,6 +482,22 @@ describe('AnalyticsMetadataResponseSchema — the CubeMeta[] projection (#6442)' dimensions: [{ name: 'orders.status', type: 'string' }], }; expect(() => AnalyticsMetadataResponseSchema.parse({ success: true, data: [fromContract] })).not.toThrow(); + + // Every optional member the contract declares survives the parse, on the + // member kind that declares it: `format` is a measure's, not a dimension's. + const fullContract: CubeMeta = { + name: 'orders', + title: 'Orders', + description: 'd', + measures: [{ name: 'orders.total_revenue', type: 'sum', title: 't', description: 'd', format: '0.0%' }], + dimensions: [{ name: 'orders.status', type: 'string', title: 't', description: 'd' }], + }; + expect(AnalyticsMetadataResponseSchema.parse({ success: true, data: [fullContract] }).data).toEqual([fullContract]); + const [cube] = AnalyticsMetadataResponseSchema.parse({ + success: true, + data: [{ ...fullContract, dimensions: [{ name: 'orders.status', type: 'string', format: '0.0%' }] }], + }).data; + expect(cube.dimensions[0]).not.toHaveProperty('format'); }); }); diff --git a/packages/spec/src/api/analytics.zod.ts b/packages/spec/src/api/analytics.zod.ts index f96531cb30f..637b411ef84 100644 --- a/packages/spec/src/api/analytics.zod.ts +++ b/packages/spec/src/api/analytics.zod.ts @@ -208,17 +208,22 @@ export const GetAnalyticsMetaRequestSchema = lazySchema(() => z.object({ * `/analytics/query` expects back in `measures[]` / `dimensions[]`; the * unqualified key it was defined under is not published. `title` carries the * definition's `label`, so it is the display name a dashboard renders. + * `description` carries the definition's `description`, and a measure also + * carries its definition's `format` (#20282, the additive return path the + * #6442 ruling recorded below); each is absent when the definition declares + * none. * * Deliberately narrower than the authoring definitions (`MetricSchema` / - * `DimensionSchema` in `data/analytics.zod.ts`): `sql`, `description`, - * `granularities` and `format` are dropped by the projection and are NOT - * reachable through this endpoint (#6442). (`filters` used to head this list; - * #10414 removed it from the authoring definition itself.) + * `DimensionSchema` in `data/analytics.zod.ts`): `sql` and `granularities` + * are dropped by the projection and are NOT reachable through this endpoint + * (#6442). (`filters` used to head this list; #10414 removed it from the + * authoring definition itself.) * * Module-local, and NOT exported as its own named schema: `CubeMeta` in * `contracts/analytics-service.ts` is already THE name for this shape, so a * second exported name would be the permanent synonym ADR-0122 D3 forbids AND a * new dual-source export. `analytics.test.ts` binds the two at compile time. + * This is the dimension member; {@link cubeMetaMeasureShape} adds `format`. */ const cubeMetaMemberShape = () => z.object({ name: z.string().describe('Cube-qualified member name, `"."` — the spelling `/analytics/query` accepts'), @@ -229,6 +234,12 @@ const cubeMetaMemberShape = () => z.object({ + 'shape serves both member kinds.', ), title: z.string().optional().describe('Display label, projected from the definition\'s `label`'), + description: z.string().optional().describe('Description, projected from the definition\'s `description`'), +}); + +/** A measure as `GET /analytics/meta` publishes it: the member shape plus `format`. */ +const cubeMetaMeasureShape = () => cubeMetaMemberShape().extend({ + format: z.string().optional().describe('Display format, projected from the measure definition\'s `format`'), }); /** @@ -262,11 +273,12 @@ export const AnalyticsMetadataResponseSchema = lazySchema(() => BaseResponseSche data: z.array(z.object({ name: z.string().describe('Cube name'), title: z.string().optional().describe('Human-readable cube title'), - measures: z.array(cubeMetaMemberShape()).describe('Measures this cube accepts in `/analytics/query`'), + description: z.string().optional().describe('Cube description, projected from the cube definition\'s `description`'), + measures: z.array(cubeMetaMeasureShape()).describe('Measures this cube accepts in `/analytics/query`'), dimensions: z.array(cubeMetaMemberShape()).describe('Dimensions this cube accepts in `/analytics/query`'), })).describe( 'Available cubes, each as the `CubeMeta` discovery projection — the cube name, ' - + 'its title, and the measures/dimensions a client may name in a query. A bare ' + + 'its title and description, and the measures/dimensions a client may name in a query. A bare ' + 'array: there is no `cubes` wrapper object, and no cube `sql` is published.', ), })); diff --git a/packages/spec/src/contracts/analytics-service.ts b/packages/spec/src/contracts/analytics-service.ts index 0e0d4c9ff9a..ed071bd0bba 100644 --- a/packages/spec/src/contracts/analytics-service.ts +++ b/packages/spec/src/contracts/analytics-service.ts @@ -213,17 +213,38 @@ export interface AnalyticsResult { } /** - * Cube metadata for discovery + * Cube metadata for discovery — the projection `GET /analytics/meta` serves. + * + * `description` (on the cube and on each member) and a measure's `format` are + * copied from the cube definition when it declares them and absent when it + * does not; `api/analytics.zod.ts#AnalyticsMetadataResponseSchema` declares + * the same shape and `api/analytics.test.ts` binds the two at compile time. */ export interface CubeMeta { /** Cube name */ name: string; /** Human-readable title */ title?: string; + /** The cube definition's `description` */ + description?: string; /** Available measures */ - measures: Array<{ name: string; type: string; title?: string }>; + measures: Array<{ + name: string; + type: string; + title?: string; + /** The measure definition's `description` */ + description?: string; + /** The measure definition's `format` */ + format?: string; + }>; /** Available dimensions */ - dimensions: Array<{ name: string; type: string; title?: string }>; + dimensions: Array<{ + name: string; + type: string; + title?: string; + /** The dimension definition's `description` */ + description?: string; + }>; } /** diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index afee2bdb8e5..91d0e9f4fb7 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -260,12 +260,12 @@ export const MetricSchema = lazySchema(() => strictObject( * Display format for this measure's result column. The analytics service * relays it as `fields[].format` on `POST /analytics/query` results, the * slot the dataset door fills from a dataset measure's own `format`, so its - * vocabulary is that slot's: a numeral pattern. It is not part of the - * `GET /analytics/meta` projection. + * vocabulary is that slot's: a numeral pattern. The `GET /analytics/meta` + * projection publishes it on the measure too. */ format: z.string().optional().describe( 'Display format for this measure\'s result column: a numeral pattern such as "$0,0.00" or "0.0%". ' - + 'Relayed verbatim as fields[].format on POST /analytics/query results; not published by GET /analytics/meta.', + + 'Relayed verbatim as fields[].format on POST /analytics/query results, and on the measure by GET /analytics/meta.', ), }, )); From 8ae35241275bab588e74cb4ab0f0d220b4d62ae7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 22:31:11 +0000 Subject: [PATCH 2/3] chore(spec): regenerate the analytics reference docs and the analytics_cube state counts Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- content/docs/references/api/analytics.mdx | 7 ++++--- content/docs/references/data/analytics.mdx | 4 ++-- packages/spec/liveness/state-counts/analytics_cube.md | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index 9c581188bbe..731f902a8eb 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -47,7 +47,7 @@ const result = AnalyticsEndpoint.parse(data); | **success** | `boolean` | ✅ | Operation success status | | **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ name: string; title?: string; measures: object[]; dimensions: object[] }[]` | ✅ | Available cubes, each as the `CubeMeta` discovery projection — the cube name, its title, and the measures/dimensions a client may name in a query. A bare array: there is no `cubes` wrapper object, and no cube `sql` is published. | +| **data** | `{ name: string; title?: string; description?: string; measures: object[]; … }[]` | ✅ | Available cubes, each as the `CubeMeta` discovery projection — the cube name, its title and description, and the measures/dimensions a client may name in a query. A bare array: there is no `cubes` wrapper object, and no cube `sql` is published. | ### Nested Shape: `AnalyticsMetadataResponse.error` @@ -78,8 +78,9 @@ const result = AnalyticsEndpoint.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Cube name | | **title** | `string` | optional | Human-readable cube title | -| **measures** | `{ name: string; type: string; title?: string }[]` | ✅ | Measures this cube accepts in `/analytics/query` | -| **dimensions** | `{ name: string; type: string; title?: string }[]` | ✅ | Dimensions this cube accepts in `/analytics/query` | +| **description** | `string` | optional | Cube description, projected from the cube definition's `description` | +| **measures** | `{ name: string; type: string; title?: string; description?: string; … }[]` | ✅ | Measures this cube accepts in `/analytics/query` | +| **dimensions** | `{ name: string; type: string; title?: string; description?: string }[]` | ✅ | Dimensions this cube accepts in `/analytics/query` | --- diff --git a/content/docs/references/data/analytics.mdx b/content/docs/references/data/analytics.mdx index c0cf01005f7..52d129948cb 100644 --- a/content/docs/references/data/analytics.mdx +++ b/content/docs/references/data/analytics.mdx @@ -148,7 +148,7 @@ Type: `[string, string]` | **description** | `string` | optional | | | **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | | | **sql** | `string` | ✅ | SQL expression or field reference | -| **format** | `string` | optional | Display format for this measure's result column: a numeral pattern such as "$0,0.00" or "0.0%". Relayed verbatim as fields[].format on POST /analytics/query results; not published by GET /analytics/meta. | +| **format** | `string` | optional | Display format for this measure's result column: a numeral pattern such as "$0,0.00" or "0.0%". Relayed verbatim as fields[].format on POST /analytics/query results, and on the measure by GET /analytics/meta. | ### Nested Shape: `Cube.dimensions[string]` @@ -221,7 +221,7 @@ Type: `[string, string]` | **description** | `string` | optional | | | **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | | | **sql** | `string` | ✅ | SQL expression or field reference | -| **format** | `string` | optional | Display format for this measure's result column: a numeral pattern such as "$0,0.00" or "0.0%". Relayed verbatim as fields[].format on POST /analytics/query results; not published by GET /analytics/meta. | +| **format** | `string` | optional | Display format for this measure's result column: a numeral pattern such as "$0,0.00" or "0.0%". Relayed verbatim as fields[].format on POST /analytics/query results, and on the measure by GET /analytics/meta. | --- diff --git a/packages/spec/liveness/state-counts/analytics_cube.md b/packages/spec/liveness/state-counts/analytics_cube.md index 6913cf750d5..431e2708335 100644 --- a/packages/spec/liveness/state-counts/analytics_cube.md +++ b/packages/spec/liveness/state-counts/analytics_cube.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `analytics_cube` | 20 | 0 | 0 | 6 | 0 | 26 | +| `analytics_cube` | 23 | 0 | 0 | 3 | 0 | 26 | From 65022850803c556b519ec7c681168f112ca9dd24 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 23:04:17 +0000 Subject: [PATCH 3/3] fix(spec): repo-rooted anchors in the CubeMeta docblock; leave the stage-2 changeset untouched The pending stage-2 release note is restored byte-for-byte from the merge base (the foreign changeset rule); this change's own changeset states which of its sentences /meta now supersedes. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../20282-analytics-cube-format-granularities-enforced.md | 2 +- .changeset/20282-analytics-cube-meta-descriptions.md | 2 ++ packages/spec/src/contracts/analytics-service.ts | 5 +++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.changeset/20282-analytics-cube-format-granularities-enforced.md b/.changeset/20282-analytics-cube-format-granularities-enforced.md index 8805e04deaf..2881db884c9 100644 --- a/.changeset/20282-analytics-cube-format-granularities-enforced.md +++ b/.changeset/20282-analytics-cube-format-granularities-enforced.md @@ -13,7 +13,7 @@ Clause-②: yes (narrowing) Until this change both keys were read on the compiled-dataset path only. One cube shape has three producers — cubes authored with `defineCube()` / `defineStack({ analyticsCubes })`, cubes the dataset compiler mints, and cubes inferred for an ad-hoc query — and only a compiled dataset's cube reached the two readers: -- **`measures..format`** reached a caller as `fields[].format` only because the dataset door copies it from the DATASET measure. An authored cube has no dataset, so `POST /api/v1/analytics/query` described its measure columns with `name` and `type` alone. Now every measure column a query names carries the `format` its cube measure declares, whichever strategy answered, and a column that declares none carries no `format` key at all. A client that formats a result column reads `format` off the query result's `fields[]`, as the Data API page says; `GET /api/v1/analytics/meta` also publishes each measure's declared `format`, a separate entry in this release. The value is relayed verbatim; the vocabulary `fields[].format` documents is a numeral pattern such as `"$0,0.00"` or `"0.0%"`. +- **`measures..format`** reached a caller as `fields[].format` only because the dataset door copies it from the DATASET measure. An authored cube has no dataset, so `POST /api/v1/analytics/query` described its measure columns with `name` and `type` alone. Now every measure column a query names carries the `format` its cube measure declares, whichever strategy answered, and a column that declares none carries no `format` key at all. `GET /api/v1/analytics/meta` is unchanged: its projection stays `name`, `type` and `title`, and a client reads `format` off the query result's `fields[]`, as the Data API page already says. The value is relayed verbatim; the vocabulary `fields[].format` documents is a numeral pattern such as `"$0,0.00"` or `"0.0%"`. - **`dimensions..granularities`** was the default bucket only for a compiled dataset, which the dataset executor filled in before querying. An authored cube's time dimension grouped raw timestamps whatever it declared. Now `query()` and the `generateSql()` dry run read it the same way, through the one rule both paths share: a single-entry list is the dimension's default bucket for a query that groups by it; a granularity the query states always wins, and one the list does not name is not refused (the dataset path compares against no list either); a list of two or more states no default; and a `timeDimensions` entry that carries only a `dateRange` for a dimension the query does not group stays a filter. What to expect after upgrading: diff --git a/.changeset/20282-analytics-cube-meta-descriptions.md b/.changeset/20282-analytics-cube-meta-descriptions.md index df638ae6f1b..9b5454ae077 100644 --- a/.changeset/20282-analytics-cube-meta-descriptions.md +++ b/.changeset/20282-analytics-cube-meta-descriptions.md @@ -10,3 +10,5 @@ Clause-②: yes (widening) - `CubeMeta` (`@objectstack/spec/contracts`) gains an optional `description` on the cube and on each measure and dimension, and an optional `format` on each measure. `AnalyticsMetadataResponseSchema` declares the same members. A definition that declares none of them is published exactly as before. - `AnalyticsService.getMeta` copies what the definition declares and fills in nothing. A cube compiled from a dataset carries each dataset measure's `format` and no `description`. - The liveness ledger rows `analytics_cube.description`, `measures.description` and `dimensions.description` move from `dead` to `live`. + +This supersedes one sentence of this release's note on an authored cube's measure `format` and `granularities`: it says `GET /api/v1/analytics/meta` is unchanged and keeps `name`, `type` and `title`. With this change `/meta` also publishes each measure's declared `format`. A client that formats a result column still reads `format` off the query result's `fields[]`. diff --git a/packages/spec/src/contracts/analytics-service.ts b/packages/spec/src/contracts/analytics-service.ts index ed071bd0bba..ee792541cce 100644 --- a/packages/spec/src/contracts/analytics-service.ts +++ b/packages/spec/src/contracts/analytics-service.ts @@ -217,8 +217,9 @@ export interface AnalyticsResult { * * `description` (on the cube and on each member) and a measure's `format` are * copied from the cube definition when it declares them and absent when it - * does not; `api/analytics.zod.ts#AnalyticsMetadataResponseSchema` declares - * the same shape and `api/analytics.test.ts` binds the two at compile time. + * does not; `packages/spec/src/api/analytics.zod.ts#AnalyticsMetadataResponseSchema` + * declares the same shape and `packages/spec/src/api/analytics.test.ts` binds + * the two at compile time. */ export interface CubeMeta { /** Cube name */