diff --git a/.changeset/retire-degraded-analytics-shim.md b/.changeset/retire-degraded-analytics-shim.md
new file mode 100644
index 0000000000..a103eacef8
--- /dev/null
+++ b/.changeset/retire-degraded-analytics-shim.md
@@ -0,0 +1,47 @@
+---
+"@objectstack/metadata-protocol": major
+"@objectstack/objectql": major
+---
+
+fix(metadata-protocol,objectql)!: retire the degraded analytics shim — the `analytics` slot stays empty without service-analytics (#3891, #3878)
+
+The protocol assembly (`assembleMetadataProtocol`, used by both
+`MetadataProtocolPlugin` and `ObjectQLPlugin`'s built-in mode) used to register
+a lightweight `analytics` fallback so `POST /api/v1/analytics/query` kept
+answering on installs without `@objectstack/service-analytics`. That fallback
+is **removed**, and with it the facade methods that existed only to serve it:
+`ObjectStackProtocolImplementation.analyticsQuery` / `getAnalyticsMeta` (the
+class no longer implements `AnalyticsProtocol`).
+
+Why removal instead of repair (#3891):
+
+- **It dropped the caller's ExecutionContext at the door.** The dispatcher
+ passes `context.executionContext` (#2852), but the shim's `query` was
+ unary — aggregation reached `engine.aggregate` with no context, the security
+ middleware's empty-principal branch waved it through, and **no RLS or tenant
+ predicate was injected**. An authenticated caller got a 200 with rows RLS
+ would hide.
+- **It ignored the contract filter.** `AnalyticsQuery`'s canonical filter field
+ is `where`; the shim read only a non-contract `filters` key, so a
+ spec-conformant filtered request silently returned a full-table aggregate.
+- **Every security gate had to be built twice** (#3770 on the shim vs
+ #3867/#3875 on the real engine) — the "duplicates logic only, harmless"
+ assessment in ADR-0076 D10 did not survive contact with reality.
+
+`getDiscovery()` stops hardcoding analytics as an always-on kernel service —
+the entry is now computed from the service registry like every other optional
+service (`enabled: false, status: 'unavailable'` and **no advertised route**
+when absent), which also removes the pre-#2462 discovery lie the shim was
+originally invented to make true.
+
+**Migration.** Deployments that relied on the fallback (programmatic
+`createStandaloneStack()` / `createObjectQLKernel()` embeds, hosts whose bundle
+doesn't require `analytics`): install `@objectstack/service-analytics` and
+mount `AnalyticsServicePlugin` — the real, context-aware engine. Without it,
+`/api/v1/analytics/*` now answers **404 ROUTE_NOT_FOUND** (previously: 200 with
+unscoped, unfiltered aggregates) and discovery reports
+`analytics: { enabled: false, status: 'unavailable' }`. Callers of
+`protocol.analyticsQuery(...)` / `protocol.getAnalyticsMeta(...)` must use the
+`analytics` service (`kernel.getService('analytics')`) instead. `os serve`
+default/full presets and managed environments already force the real engine and
+are unaffected.
diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx
index 4c1221e104..a46690ba92 100644
--- a/content/docs/api/data-api.mdx
+++ b/content/docs/api/data-api.mdx
@@ -184,7 +184,10 @@ to set a new name or clear a unique field.
## Analytics
-Semantic BI queries using a cube-style API. Available when the analytics service is enabled.
+Semantic BI queries using a cube-style API. Provided by `@objectstack/service-analytics` —
+on deployments without it these endpoints answer `404 ROUTE_NOT_FOUND` and discovery
+reports `analytics: { enabled: false, status: "unavailable" }`. (The former kernel-level
+degraded fallback was retired — it served unscoped, unfiltered aggregates.)
### `POST /analytics/query`
diff --git a/content/docs/kernel/services-checklist.mdx b/content/docs/kernel/services-checklist.mdx
index 3bf944daa0..15f1128dff 100644
--- a/content/docs/kernel/services-checklist.mdx
+++ b/content/docs/kernel/services-checklist.mdx
@@ -34,16 +34,16 @@ The ObjectStack protocol defines **16 kernel services** registered via the `Core
┌─────────────────────────────────────────────────────────┐
│ Kernel Layer │
│ ┌──────────────────┐ ┌──────────────────────────────┐ │
-│ │ metadata (⚠️) │ │ data + analytics (✅) │ │
+│ │ metadata (⚠️) │ │ data (✅) │ │
│ │ In-memory only │ │ ObjectQL example kernel │ │
│ │ DB persistence │ │ Will be rebuilt as plugins │ │
│ │ pending │ │ │ │
│ └──────────────────┘ └──────────────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ Plugin Layer │
-│ All other services: auth, automation, workflow, ui, │
-│ realtime, notification, ai, i18n, search, │
-│ file-storage, cache, queue, job │
+│ All other services: analytics, auth, automation, │
+│ workflow, ui, realtime, notification, ai, i18n, │
+│ search, file-storage, cache, queue, job │
│ │
│ Discovery API reports: enabled/unavailable/degraded │
│ per service so clients adapt their UI accordingly │
@@ -58,7 +58,7 @@ The ObjectStack protocol defines **16 kernel services** registered via the `Core
|:--|:-------------|:------------|:--------|:-------|:---------|
| 1 | **metadata** | `core` | 7 | ⚠️ Framework | Kernel (in-memory) |
| 2 | **data** | `required` | 9 | ✅ Implemented | `@objectstack/objectql` |
-| 3 | **analytics** | `optional` | 2 | ✅ Implemented | `@objectstack/objectql` |
+| 3 | **analytics** | `optional` | 2 | ❌ Plugin Required | `@objectstack/service-analytics` |
| 4 | **auth** | `core` | — | 🟡 In Development | `@objectstack/plugin-auth` |
| 5 | **ui** | `optional` | 5 | ❌ Plugin Required | TBD plugin |
| 6 | **workflow** | `optional` | 3 | ❌ Plugin Required | TBD plugin |
@@ -173,23 +173,26 @@ The discovery endpoint returns a `services` map so clients know what is availabl
---
-## 3. analytics Service ✅ Implemented
+## 3. analytics Service — Plugin Required
**Service Name**: `analytics` · **Criticality**: `optional`
-**Implementation**: `@objectstack/objectql` (driver-level capability)
-**Route Mount**: `/api/v1/analytics`
+**Implementation**: `@objectstack/service-analytics` (the only implementation)
+**Route Mount**: `/api/v1/analytics` — only served when the plugin registers the service
-### Protocol Methods
-
-| Method | Signature | Status |
-|:-------|:----------|:------:|
-| `analyticsQuery` | `AnalyticsQueryRequest → AnalyticsResultResponse` | ✅ |
-| `getAnalyticsMeta` | `GetAnalyticsMetaRequest → AnalyticsMetadataResponse` | ✅ |
+
+The kernel-level "degraded analytics fallback" (a lightweight ObjectQL adapter
+registered by the protocol assembly) was **retired** (#3891): it dropped the
+caller's `ExecutionContext` — aggregates ran without RLS/tenant scoping — and
+silently ignored the contract `where` filter. Without
+`@objectstack/service-analytics`, `/api/v1/analytics/*` now answers **404** and
+discovery reports `analytics: { enabled: false, status: "unavailable" }`.
+
-### Features
+### Features (via `@objectstack/service-analytics`)
-- Cube semantic queries: measures / dimensions / filters → ObjectQL aggregation
-- Auto-generated metadata from SchemaRegistry (numeric → sum/avg, date → time dimensions)
+- Cube semantic queries: measures / dimensions / `where` filter → SQL or ObjectQL aggregation
+- Context-aware read scoping: per-object RLS/tenant predicates resolved from the request's `ExecutionContext` (fail-closed)
+- Cube metadata (`/analytics/meta`), SQL preview (`/analytics/sql`), dataset queries (`/analytics/dataset/query`)
---
diff --git a/docs/adr/0076-objectql-core-tiering.md b/docs/adr/0076-objectql-core-tiering.md
index cc6a3c53cc..626763222a 100644
--- a/docs/adr/0076-objectql-core-tiering.md
+++ b/docs/adr/0076-objectql-core-tiering.md
@@ -104,7 +104,7 @@ The segmentation is **spec/type-level and may start incrementally now** (define
A single `ObjectStackProtocolImplementation` facade — and any `*-protocol` *implementation* package — is the wrong end-state. Verified against source:
- The facade implements only **4 of the 11** contract domains (data, metadata, analytics, feed). The other 7 (realtime / notifications / workflow / ai / i18n / views / permissions) are **not implemented in it** — they belong to their own services or aren't implemented at all. *(Update #1959: the feed domain was retired per ADR-0052 §5 — `IFeedService`, `FeedProtocol`, and the facade's feed forwarding no longer exist, so the facade now implements 3 domains: data, metadata, analytics.)*
-- **Analytics uses a deliberate fallback + `replaceService` pattern (NOT a collision/bug — corrected in rev.9)**: `registerService` throws on duplicate (`core/kernel.ts`), so `ObjectQLPlugin` registers a lightweight ~66-line analytics **fallback** (so `/analytics` doesn't 404 in minimal deployments), and `AnalyticsServicePlugin`, when installed, calls **`ctx.replaceService('analytics', …)`** to swap in the full ~1.8k-LOC engine (three strategies incl. native-SQL). With service-analytics present the real engine serves (no shadowing); without it the fallback serves. The fallback duplicates *logic* only (a minor maintenance cost) and is intentional and harmless.
+- **Analytics uses a deliberate fallback + `replaceService` pattern (NOT a collision/bug — corrected in rev.9)**: `registerService` throws on duplicate (`core/kernel.ts`), so `ObjectQLPlugin` registers a lightweight ~66-line analytics **fallback** (so `/analytics` doesn't 404 in minimal deployments), and `AnalyticsServicePlugin`, when installed, calls **`ctx.replaceService('analytics', …)`** to swap in the full ~1.8k-LOC engine (three strategies incl. native-SQL). With service-analytics present the real engine serves (no shadowing); without it the fallback serves. The fallback duplicates *logic* only (a minor maintenance cost) and is intentional and harmless. *(Update #3891: "duplicates logic only / harmless" was refuted by evidence — the same security gate had to be built twice (#3770 for the fallback, #3867/#3875 for the real engine), and the fallback dropped the caller's ExecutionContext (aggregates ran without RLS/tenant predicates) and ignored the contract `where` filter. The fallback is **retired**: without service-analytics the `analytics` slot stays empty, `/analytics` answers the dispatcher's existing 404, and discovery reports `unavailable` — the D12 machinery makes the empty slot honest, which removes the fallback's original reason to exist (the discovery hardcode).)*
- ~~**Feed is already delegated** to `IFeedService`; the facade only forwards.~~ *(Retired per ADR-0052 §5 / #1959 — `IFeedService` and the facade's feed forwarding were removed.)*
- The domain service packages already exist: `service-analytics`, `service-messaging`, `service-realtime`.
@@ -112,7 +112,7 @@ Target end-state:
- **"protocol" names ONLY the contract** — the segmented interfaces in `@objectstack/spec/api` (D9). There is **no `*-protocol` implementation package**.
- **`DataProtocol`** impl → engine-adjacent / transport (thin wire-normalizers).
- **`MetadataProtocol`** impl → stays in **`@objectstack/metadata-protocol`** (name **retained** — already published; renaming churns downstream for ~0 benefit). The package's *content* converges to the metadata-management impl (it owns `sys_metadata`). The `protocol` in the name is a deliberate, low-cost naming exception from being published — the real contract lives in `@objectstack/spec/api`, not here. (Open Question #7, resolved.)
-- **Analytics / Realtime / Notification / …** → each domain's *full* impl lives in its existing service package. For analytics specifically, the `ObjectQLPlugin` fallback **must be preserved or consciously dropped** (it prevents `/analytics` 404 for deployments without service-analytics) — **not blindly deleted**. The engine keeps only the minimal fallback it deliberately provides. *(Feed was dropped entirely per ADR-0052 §5 / #1959 — no service package, no facade forwarding.)*
+- **Analytics / Realtime / Notification / …** → each domain's *full* impl lives in its existing service package. For analytics specifically, the `ObjectQLPlugin` fallback **must be preserved or consciously dropped** (it prevents `/analytics` 404 for deployments without service-analytics) — **not blindly deleted**. The engine keeps only the minimal fallback it deliberately provides. *(Feed was dropped entirely per ADR-0052 §5 / #1959 — no service package, no facade forwarding.)* *(Update #3891: consciously dropped — see the fallback bullet above. The facade's `analyticsQuery`/`getAnalyticsMeta` went with it, so the facade now implements 2 domains (data, metadata) and analytics has exactly one implementation, in service-analytics — this bullet's end-state for the domain.)*
- **The transport/dispatcher routes each contract-slice to the owning service** (it already resolves services by name) — no central facade class.
This **refines D1** (the `metadata-protocol` package was an intermediate, not the end-state) and **completes D9**. Executed at the cross-repo window with D7 / Step 2.
@@ -143,7 +143,7 @@ Decision: each capability plugin registers its routes as a **normalized handler*
This fixes the **whole class at once — without deleting any fallback** (no `/analytics` 404 regression): the analytics fallback and the dev stubs simply stop *lying*; they keep serving but are honestly labelled. It is the runtime enforcement of the D9-refinement principle (capabilities = what is actually installed, computed at runtime).
-**Supersedes the rev.9 analytics conclusion**: the fix for the analytics fallback is to **mark it honestly (this D12)**, not "preserve-or-delete".
+**Supersedes the rev.9 analytics conclusion**: the fix for the analytics fallback is to **mark it honestly (this D12)**, not "preserve-or-delete". *(Update #3891: superseded in turn for the analytics fallback specifically — honest labelling was necessary but not sufficient. The fallback's `degraded` label was accurate about capability, yet nothing in it disclosed that aggregates ran WITHOUT the caller's RLS/tenant scoping and that the contract `where` filter was ignored; an authorized caller still got a 200 with wrong (over-broad) numbers. A fallback may degrade features, never security semantics — so it was retired, and the "no `/analytics` 404 regression" goal above is deliberately abandoned for this slot: the 404 IS the honest signal. D12's marker/discovery machinery stays, and is what makes the now-empty slot report `unavailable`.)*
**Execution**: framework (marker convention + `svcAvailable` respects it + discovery schema `stub` status) and console (read the honest status) land **together at the cross-repo window** — the console reads `discovery.services`, so this is a cross-repo contract change.
diff --git a/packages/metadata-protocol/src/plugin.ts b/packages/metadata-protocol/src/plugin.ts
index 1363a4b5bf..bb71da2432 100644
--- a/packages/metadata-protocol/src/plugin.ts
+++ b/packages/metadata-protocol/src/plugin.ts
@@ -5,8 +5,9 @@
*
* Owns what `ObjectQLPlugin` historically assembled inline: the
* `ObjectStackProtocolImplementation` construction + `protocol` service
- * registration, the metadata-storage platform objects, and the lightweight
- * `analytics` fallback. Registering it NEXT TO `ObjectQLPlugin` (with
+ * registration and the metadata-storage platform objects. (The lightweight
+ * `analytics` fallback that used to ride here was retired in #3891 — see
+ * {@link assembleMetadataProtocol}.) Registering it NEXT TO `ObjectQLPlugin` (with
* `registerProtocol: false` on the engine plugin) makes `@objectstack/objectql`
* effectively protocol-free at boot-assembly level — the engine plugin keeps
* only protocol CONSUMERS (DB hydration + authored hook/action rebind, both of
@@ -22,7 +23,6 @@
*/
import type { Plugin, PluginContext } from '@objectstack/core';
-import { SERVICE_SELF_INFO_KEY, type ServiceSelfInfo } from '@objectstack/spec/api';
import {
SysMetadataObject,
SysMetadataHistoryObject,
@@ -73,12 +73,19 @@ export function createMetadataProtocolPlugin(options: MetadataProtocolPluginOpti
/**
* The ONE protocol assembly (ADR-0076 Step 2 PR-C): metadata-storage platform
- * objects + `ObjectStackProtocolImplementation` as the `protocol` service +
- * the D12 `degraded` analytics fallback. Called by
- * {@link createMetadataProtocolPlugin} (delegated mode) AND by
+ * objects + `ObjectStackProtocolImplementation` as the `protocol` service.
+ * Called by {@link createMetadataProtocolPlugin} (delegated mode) AND by
* `ObjectQLPlugin`'s built-in convenience mode (`registerProtocol !== false`)
* — single source, two mounts, identical result.
*
+ * The `analytics` slot is deliberately NOT filled here (#3891 / #3878,
+ * superseding the ADR-0076 D10/D12 "preserve the fallback" stance): the
+ * degraded shim dropped the caller's ExecutionContext (no RLS/tenant
+ * predicates) and ignored the contract `where` filter, returning full-table
+ * aggregates with a 200. An empty slot degrades honestly instead — the
+ * dispatcher's `/analytics` domain answers 404 and discovery reports
+ * `unavailable` until `AnalyticsServicePlugin` registers the real engine.
+ *
* @returns the protocol shim, so the engine-side caller can arm its
* mutation-rebind subscription synchronously.
*/
@@ -119,42 +126,13 @@ export function assembleMetadataProtocol(
ctx.registerService('protocol', protocolShim);
ctx.logger.info('Protocol service registered (MetadataProtocolPlugin)');
- // Lightweight `analytics` fallback mapped onto the protocol shim's
- // `analyticsQuery` — kept with the protocol assembly so `/analytics`
- // keeps answering on installs without service-analytics. Honest
- // capabilities (ADR-0076 D12): self-identifies as `degraded`;
- // AnalyticsServicePlugin replaces it (ctx.replaceService) with the
- // real engine.
- ctx.registerService('analytics', {
- [SERVICE_SELF_INFO_KEY]: {
- status: 'degraded',
- handlerReady: true,
- message: 'Lightweight ObjectQL analytics fallback — install @objectstack/service-analytics for the full engine',
- } satisfies ServiceSelfInfo,
- // HttpDispatcher passes the raw POST body (AnalyticsQuery
- // shape); the shim's `analyticsQuery` expects the wrapped
- // `{ cube, query }` envelope and returns its own `{ success,
- // data }` — reshape in, unwrap out (one level), exactly as the
- // historical inline adapter did.
- query: async (body: any) => {
- const envelope = body && typeof body === 'object' && 'query' in body && 'cube' in body
- ? body
- : { cube: body?.cube, query: body };
- const result = await protocolShim.analyticsQuery(envelope);
- if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
- return (result as any).data;
- }
- return result;
- },
- getMeta: async () => ({
- cubes: [],
- message: 'Analytics meta endpoint not implemented by ObjectQL adapter',
- }),
- generateSql: async (_body: any) => ({
- sql: null,
- message: 'Analytics SQL generation not implemented by ObjectQL adapter',
- }),
- });
+ // NO `analytics` fallback rides here anymore (#3891 / #3878). The
+ // degraded shim this assembly used to register dropped the request's
+ // ExecutionContext (aggregates ran without RLS/tenant predicates)
+ // and ignored the contract filter field `where` — a 200 with wrong
+ // numbers. The slot now stays empty: `/analytics/*` answers 404
+ // (ROUTE_NOT_FOUND) and discovery reports the service unavailable
+ // until @objectstack/service-analytics registers the real engine.
return protocolShim;
}
diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts
index 8f06558247..7f23e66831 100644
--- a/packages/metadata-protocol/src/protocol.ts
+++ b/packages/metadata-protocol/src/protocol.ts
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import type {
- DataProtocol, MetadataProtocol, AnalyticsProtocol, PackageProtocol,
+ DataProtocol, MetadataProtocol, PackageProtocol,
} from '@objectstack/spec/api';
import { IDataEngine } from '@objectstack/core';
import { readEnvWithDeprecation } from '@objectstack/types';
@@ -660,6 +660,11 @@ function mergeDroppedFieldEvents(events: DroppedFieldsEvent[]): DroppedFieldsEve
* with no mounted handler 404s and misleads consumers).
*/
const SERVICE_CONFIG: Record = {
+ // Plugin-provided like every other optional service since the degraded
+ // ObjectQL fallback was retired (#3891): advertised iff the real engine
+ // is registered — never hardcoded 'available' (the pre-#2462 lie the
+ // fallback existed to paper over).
+ analytics: { route: '/api/v1/analytics', plugin: 'service-analytics' },
auth: { route: '/api/v1/auth', plugin: 'plugin-auth' },
automation: { route: '/api/v1/automation', plugin: 'plugin-automation' },
cache: { route: '/api/v1/cache', plugin: 'plugin-redis' },
@@ -1042,10 +1047,12 @@ export type MetadataAuthoringGate = (ctx: MetadataAuthoringGateContext) => void
* Implements the per-domain contracts this class ACTUALLY provides (ADR-0076
* D10 — the facade never implemented the other domains; those live in their
* owning services and are reached through the discovery `services` registry,
- * never through this class).
+ * never through this class). Analytics left this list with the degraded shim
+ * retirement (#3891) — the domain's one implementation is
+ * `@objectstack/service-analytics`.
*/
export class ObjectStackProtocolImplementation implements
- DataProtocol, MetadataProtocol, AnalyticsProtocol, PackageProtocol {
+ DataProtocol, MetadataProtocol, PackageProtocol {
private engine: MetadataHostEngine;
private getServicesRegistry?: () => Map;
/**
@@ -1388,26 +1395,17 @@ export class ObjectStackProtocolImplementation implements
async getDiscovery() {
// Get registered services from kernel if available
const registeredServices = this.getServicesRegistry ? this.getServicesRegistry() : new Map();
-
- // Honest capabilities (ADR-0076 D12, #2462): the registered analytics
- // service may be the lightweight ObjectQL fallback rather than the
- // full service-analytics engine — report whatever it declares about
- // itself instead of hardcoding 'available'.
- const analyticsSelf = readServiceSelfInfo(registeredServices.get('analytics'));
- // Build dynamic service info with proper typing
+ // Build dynamic service info with proper typing. Analytics is NOT in
+ // this kernel-provided block: since the degraded ObjectQL fallback was
+ // retired (#3891) it is an ordinary optional service, computed from the
+ // registry via SERVICE_CONFIG below — absent means `unavailable`, and
+ // no route is advertised (the pre-#2462 hardcode here is exactly what
+ // the fallback was invented to make true).
const services: Record = {
// --- Kernel-provided (objectql is an example kernel implementation) ---
metadata: { enabled: true, status: 'available' as const, route: '/api/v1/meta', provider: 'objectql' },
data: { enabled: true, status: 'available' as const, route: '/api/v1/data', provider: 'objectql' },
- analytics: {
- enabled: true,
- status: analyticsSelf?.status ?? ('available' as const),
- route: '/api/v1/analytics',
- provider: 'objectql',
- ...(analyticsSelf?.handlerReady !== undefined ? { handlerReady: analyticsSelf.handlerReady } : {}),
- ...(analyticsSelf?.message ? { message: analyticsSelf.message } : {}),
- },
};
// Check which services are actually registered
@@ -1445,6 +1443,7 @@ export class ObjectStackProtocolImplementation implements
// Build routes from services — a flat convenience map for client routing
const serviceToRouteKey: Record = {
+ analytics: 'analytics',
auth: 'auth',
automation: 'automation',
ui: 'ui',
@@ -1456,9 +1455,7 @@ export class ObjectStackProtocolImplementation implements
'file-storage': 'storage',
};
- const optionalRoutes: Partial = {
- analytics: '/api/v1/analytics',
- };
+ const optionalRoutes: Partial = {};
// Add routes for available plugin services. Services without an HTTP
// surface (config.route undefined) advertise no route (D12, #2462).
@@ -3646,183 +3643,16 @@ export class ObjectStackProtocolImplementation implements
} as BatchUpdateResponse;
}
- async analyticsQuery(request: any): Promise {
- // Map AnalyticsQuery (cube-style) to engine aggregation.
- // cube name maps to object name; measures → aggregations; dimensions → groupBy.
- const { query, cube } = request;
- const object = cube;
- // [#3770] A cube name IS an object name here (`getAnalyticsMeta` derives
- // every cube from `registry.listItems('object')`), so this read surface
- // needs the same existence gate as the CRUD ones — otherwise it stays a
- // way to aggregate over an arbitrary physical table.
- this.assertObjectRegistered(object);
-
- // Build groupBy from dimensions
- const groupBy = query.dimensions || [];
-
- // Build aggregations from measures
- // Measures can be simple field names like "count" or "field_name.sum"
- // Or cube-defined measure names. We support: field.function or just function(field).
- const aggregations: Array<{ field: string; method: string; alias: string }> = [];
- if (query.measures) {
- for (const measure of query.measures) {
- // Support formats: "count", "amount.sum", "revenue.avg"
- if (measure === 'count' || measure === 'count_all') {
- aggregations.push({ field: '*', method: 'count', alias: 'count' });
- } else if (measure.includes('.')) {
- const [field, method] = measure.split('.');
- aggregations.push({ field, method, alias: `${field}_${method}` });
- } else {
- // Treat as count of the field
- aggregations.push({ field: measure, method: 'sum', alias: measure });
- }
- }
- }
-
- // Build filter from analytics filters
- let filter: any = undefined;
- if (query.filters && query.filters.length > 0) {
- const conditions: any[] = query.filters.map((f: any) => {
- const op = this.mapAnalyticsOperator(f.operator);
- if (f.values && f.values.length === 1) {
- return { [f.member]: { [op]: f.values[0] } };
- } else if (f.values && f.values.length > 1) {
- return { [f.member]: { $in: f.values } };
- }
- return { [f.member]: { [op]: true } };
- });
- filter = conditions.length === 1 ? conditions[0] : { $and: conditions };
- }
-
- // Execute via engine.aggregate (which delegates to driver.find with groupBy/aggregations)
- const rows = await this.engine.aggregate(object, {
- where: filter,
- groupBy: groupBy.length > 0 ? groupBy : undefined,
- aggregations: aggregations.length > 0
- ? aggregations.map(a => ({ function: a.method as any, field: a.field, alias: a.alias }))
- : [{ function: 'count' as any, alias: 'count' }],
- });
-
- // Build field metadata
- const fields = [
- ...groupBy.map((d: string) => ({ name: d, type: 'string' })),
- ...aggregations.map(a => ({ name: a.alias, type: 'number' })),
- ];
-
- return {
- success: true,
- data: {
- rows,
- fields,
- },
- };
- }
-
- async getAnalyticsMeta(request: any): Promise {
- // Auto-generate cube metadata from registered objects in SchemaRegistry.
- // Each object becomes a cube; number fields → measures; other fields → dimensions.
- const objects = this.engine.registry.listItems('object');
- const cubeFilter = request?.cube;
-
- const cubes: any[] = [];
- for (const obj of objects) {
- const schema = obj as any;
- if (cubeFilter && schema.name !== cubeFilter) continue;
-
- const measures: Record = {};
- const dimensions: Record = {};
- const fields = schema.fields || {};
-
- // Always add a count measure
- measures['count'] = {
- name: 'count',
- label: 'Count',
- type: 'count',
- sql: '*',
- };
-
- for (const [fieldName, fieldDef] of Object.entries(fields)) {
- const fd = fieldDef as any;
- const fieldType = fd.type || 'text';
-
- if (['number', 'currency', 'percent'].includes(fieldType)) {
- // Numeric fields become both measures and dimensions
- measures[`${fieldName}_sum`] = {
- name: `${fieldName}_sum`,
- label: `${fd.label || fieldName} (Sum)`,
- type: 'sum',
- sql: fieldName,
- };
- measures[`${fieldName}_avg`] = {
- name: `${fieldName}_avg`,
- label: `${fd.label || fieldName} (Avg)`,
- type: 'avg',
- sql: fieldName,
- };
- dimensions[fieldName] = {
- name: fieldName,
- label: fd.label || fieldName,
- type: 'number',
- sql: fieldName,
- };
- } else if (['date', 'datetime'].includes(fieldType)) {
- dimensions[fieldName] = {
- name: fieldName,
- label: fd.label || fieldName,
- type: 'time',
- sql: fieldName,
- granularities: ['day', 'week', 'month', 'quarter', 'year'],
- };
- } else if (['boolean'].includes(fieldType)) {
- dimensions[fieldName] = {
- name: fieldName,
- label: fd.label || fieldName,
- type: 'boolean',
- sql: fieldName,
- };
- } else {
- // text, select, lookup, etc. → dimension
- dimensions[fieldName] = {
- name: fieldName,
- label: fd.label || fieldName,
- type: 'string',
- sql: fieldName,
- };
- }
- }
-
- cubes.push({
- name: schema.name,
- title: schema.label || schema.name,
- description: schema.description,
- sql: schema.name,
- measures,
- dimensions,
- public: true,
- });
- }
-
- return {
- success: true,
- data: { cubes },
- };
- }
-
- private mapAnalyticsOperator(op: string): string {
- const map: Record = {
- equals: '$eq',
- notEquals: '$ne',
- contains: '$contains',
- notContains: '$notContains',
- gt: '$gt',
- gte: '$gte',
- lt: '$lt',
- lte: '$lte',
- set: '$ne',
- notSet: '$eq',
- };
- return map[op] || '$eq';
- }
+ // `analyticsQuery` / `getAnalyticsMeta` were retired with the degraded
+ // `analytics` service shim (#3891 / #3878). They aggregated through
+ // `engine.aggregate` WITHOUT the caller's ExecutionContext — no RLS or
+ // tenant predicate was ever injected — and read a non-contract `filters`
+ // field while silently ignoring the canonical `AnalyticsQuery.where`, so a
+ // spec-conformant filtered request returned an unscoped full-table
+ // aggregate. The analytics domain has exactly one implementation now:
+ // `@objectstack/service-analytics` (context-aware, fail-closed
+ // `getReadScope`). Deployments without it get an honest 404 from the
+ // dispatcher's `/analytics` domain instead of wrong numbers.
async triggerAutomation(_request: any): Promise {
throw new Error('triggerAutomation requires plugin-automation service. Install and register a plugin that provides the "automation" service.');
diff --git a/packages/objectql/src/plugin.step2.test.ts b/packages/objectql/src/plugin.step2.test.ts
index aebca61466..838631f953 100644
--- a/packages/objectql/src/plugin.step2.test.ts
+++ b/packages/objectql/src/plugin.step2.test.ts
@@ -12,7 +12,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ObjectKernel } from '@objectstack/core';
-import { SERVICE_SELF_INFO_KEY } from '@objectstack/spec/api';
import { createMetadataProtocolPlugin, ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
import { ObjectQLPlugin } from './plugin';
@@ -34,12 +33,11 @@ describe('ADR-0076 Step 2 — delegated protocol assembly', () => {
// getService('protocol') + the loadMetaFromDb type guard.
expect(typeof (protocol as any).loadMetaFromDb).toBe('function');
- // The lightweight analytics fallback rides with the protocol assembly and
- // keeps its D12 honest-capabilities self-descriptor.
- const analytics: any = kernel.getService('analytics');
- expect(analytics).toBeDefined();
- expect(analytics[SERVICE_SELF_INFO_KEY]?.status).toBe('degraded');
- expect(typeof analytics.query).toBe('function');
+ // The degraded analytics fallback was retired (#3891): it dropped the
+ // caller's ExecutionContext (unscoped aggregates) and ignored the contract
+ // `where` filter. The slot stays EMPTY until service-analytics fills it —
+ // /analytics 404s honestly instead of answering with wrong numbers.
+ expect(() => kernel.getService('analytics')).toThrow();
});
it('registerProtocol:false WITHOUT the plugin boots protocol-free (consumers degrade)', async () => {
@@ -58,10 +56,10 @@ describe('ADR-0076 Step 2 — delegated protocol assembly', () => {
await expect(kernel.bootstrap()).rejects.toThrow(/registerProtocol: false/);
});
- it('default assembly is unchanged (backward compatibility)', async () => {
+ it('default assembly registers protocol but leaves the analytics slot empty (#3891)', async () => {
await kernel.use(new ObjectQLPlugin());
await kernel.bootstrap();
expect(kernel.getService('protocol')).toBeInstanceOf(ObjectStackProtocolImplementation);
- expect((kernel.getService('analytics') as any)[SERVICE_SELF_INFO_KEY]?.status).toBe('degraded');
+ expect(() => kernel.getService('analytics')).toThrow();
});
});
diff --git a/packages/objectql/src/protocol-discovery.test.ts b/packages/objectql/src/protocol-discovery.test.ts
index 51791bbf50..da139598e0 100644
--- a/packages/objectql/src/protocol-discovery.test.ts
+++ b/packages/objectql/src/protocol-discovery.test.ts
@@ -120,10 +120,10 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
expect(discovery.services.workflow.message).toBe('partial impl');
});
- it('should report the analytics fallback honestly when it self-identifies', async () => {
+ it('should report a registered analytics service with its self-declared status', async () => {
const mockServices = new Map();
mockServices.set('analytics', {
- __serviceInfo: { status: 'degraded', handlerReady: true, message: 'fallback' },
+ __serviceInfo: { status: 'degraded', handlerReady: true, message: 'partial engine' },
});
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
@@ -131,21 +131,48 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
expect(discovery.services.analytics.enabled).toBe(true);
expect(discovery.services.analytics.status).toBe('degraded');
- expect(discovery.services.analytics.message).toBe('fallback');
+ expect(discovery.services.analytics.message).toBe('partial engine');
});
it('should always show core services as available', async () => {
protocol = new ObjectStackProtocolImplementation(engine);
-
+
const discovery = await protocol.getDiscovery();
-
+
// Core services should always be available
expect(discovery.services.metadata.enabled).toBe(true);
expect(discovery.services.metadata.status).toBe('available');
expect(discovery.services.data.enabled).toBe(true);
expect(discovery.services.data.status).toBe('available');
+ });
+
+ // #3891 — the degraded ObjectQL fallback is retired. Analytics is an
+ // ordinary optional service now: absent ⇒ unavailable, and the route must
+ // NOT be advertised (an advertised route with no handler 404s — the exact
+ // discovery lie the fallback was invented to make true).
+ it('should report analytics unavailable with no advertised route when not registered', async () => {
+ protocol = new ObjectStackProtocolImplementation(engine);
+
+ const discovery = await protocol.getDiscovery();
+
+ expect(discovery.services.analytics.enabled).toBe(false);
+ expect(discovery.services.analytics.status).toBe('unavailable');
+ expect(discovery.services.analytics.message).toContain('service-analytics');
+ expect(discovery.services.analytics.route).toBeUndefined();
+ expect(discovery.routes.analytics).toBeUndefined();
+ });
+
+ it('should advertise analytics route and availability when the real engine registers', async () => {
+ const mockServices = new Map();
+ mockServices.set('analytics', { /* real engine — no self-info means available */ });
+
+ protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
+ const discovery = await protocol.getDiscovery();
+
expect(discovery.services.analytics.enabled).toBe(true);
expect(discovery.services.analytics.status).toBe('available');
+ expect(discovery.services.analytics.route).toBe('/api/v1/analytics');
+ expect(discovery.routes.analytics).toBe('/api/v1/analytics');
});
it('should map file-storage service to storage route', async () => {
@@ -166,7 +193,8 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
mockServices.set('auth', {});
mockServices.set('automation', {});
mockServices.set('ai', {});
-
+ mockServices.set('analytics', {});
+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
const discovery = await protocol.getDiscovery();
diff --git a/packages/objectql/src/protocol-unregistered-object.test.ts b/packages/objectql/src/protocol-unregistered-object.test.ts
index fb8f980074..d45277afa9 100644
--- a/packages/objectql/src/protocol-unregistered-object.test.ts
+++ b/packages/objectql/src/protocol-unregistered-object.test.ts
@@ -187,7 +187,10 @@ describe('#3770 — data-plane object-existence gate (real ObjectQL engine)', ()
['updateManyData', () => protocol.updateManyData({ object: UNREGISTERED, records: [{ id: 'r1', data: {} }] } as any)],
['deleteManyData', () => protocol.deleteManyData({ object: UNREGISTERED, ids: ['r1'] } as any)],
['batchData', () => protocol.batchData({ object: UNREGISTERED, request: { operation: 'create', records: [{ data: {} }] } as any })],
- ['analyticsQuery', () => protocol.analyticsQuery({ cube: UNREGISTERED, query: { measures: ['count'] } })],
+ // `analyticsQuery` is gone from this list because the method itself
+ // was retired with the degraded analytics shim (#3891) — the
+ // analytics-side twin of this gate lives in service-analytics'
+ // `ensureCube` (#3867/#3875).
];
for (const [name, call] of calls) {
await expect(call(), `${name} must reject`).rejects.toMatchObject(OBJECT_NOT_FOUND);
diff --git a/packages/runtime/src/domains/analytics.ts b/packages/runtime/src/domains/analytics.ts
index a1a1b3f37d..31e5a81fb8 100644
--- a/packages/runtime/src/domains/analytics.ts
+++ b/packages/runtime/src/domains/analytics.ts
@@ -2,10 +2,12 @@
/**
* `/analytics` domain — extracted dispatcher body (ADR-0076 D11 step ③,
- * PR-2). Bridges to whatever provides the `analytics` service slot: the
- * service-analytics engine when installed, or the ObjectQLPlugin degraded
- * fallback otherwise (deliberate fallback + `replaceService`, see ADR-0076
- * D10/D12) — which is exactly why route registration stays dispatcher-owned.
+ * PR-2). Bridges to whatever provides the `analytics` service slot — in
+ * practice the service-analytics engine, the slot's ONE implementation since
+ * the degraded ObjectQL fallback was retired (#3891: it dropped the caller's
+ * ExecutionContext and the contract `where` filter). Route registration stays
+ * dispatcher-owned so the URL contract is stable regardless of what occupies
+ * the slot; an empty slot answers the `handled: false` 404 below.
*/
import { CoreServiceName } from '@objectstack/spec/system';
@@ -49,8 +51,8 @@ export async function handleAnalyticsRequest(
// GET /analytics/meta[?cube=]
if (subPath === 'meta' && m === 'GET') {
// [#3584] Optional single-cube filter. `AnalyticsService.getMeta`
- // already accepts `cubeName?`; degraded fallbacks that ignore the
- // argument keep returning the full listing, which is still correct.
+ // already accepts `cubeName?`; an implementation that ignores the
+ // argument keeps returning the full listing, which is still correct.
const cube = typeof query?.cube === 'string' && query.cube !== '' ? query.cube : undefined;
const result = await analyticsService.getMeta(cube);
return { handled: true, response: deps.success(result) };