Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/adr-0076-honest-capabilities-framework-slice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'@objectstack/spec': minor
'@objectstack/runtime': minor
'@objectstack/metadata-protocol': minor
'@objectstack/objectql': patch
'@objectstack/rest': patch
'@objectstack/plugin-hono-server': patch
---

feat(discovery): honest capabilities — standardized stub/fallback marker + realtime route honesty (ADR-0076 D12/A1.5 framework slice, #2462)

**Spec** — new service self-description marker for honest discovery
(ADR-0076 D12): `SERVICE_SELF_INFO_KEY` (`__serviceInfo`),
`ServiceSelfInfoSchema` / `ServiceSelfInfo`, and `readServiceSelfInfo()`,
which also normalizes plugin-dev's legacy `_dev: true` flag to
`{ status: 'stub', handlerReady: false }`. A registered service that is a
stub / dev fake / degraded fallback self-identifies via this marker; a fully
real service carries no marker.

**Runtime + metadata-protocol** — both discovery builders
(`HttpDispatcher.getDiscoveryInfo` and the protocol shim's `getDiscovery`)
now honor the marker instead of hardcoding `status: 'available',
handlerReady: true` for every registered service. Dev stubs report `stub`,
the ObjectQL analytics fallback reports `degraded` (it keeps serving — no
`/analytics` 404), and consumers can finally trust
`status === 'available'` / `handlerReady === true`.

**Realtime honesty fix** — discovery no longer advertises a
`/realtime` route or `websockets: true`: `service-realtime` is an
in-process pub/sub bus, no dispatcher branch or plugin mounts any
`/realtime` HTTP surface, so the advertised route always 404'd. The
registered service now reports `status: 'degraded', handlerReady: false`
with no route (clients using the SDK are unaffected — it falls back to the
conventional path, which behaves exactly as before). Also corrects the
advertised realtime provider from the nonexistent `plugin-realtime` to
`service-realtime`.

**REST (A1.5)** — the REST layer's protocol dependency is narrowed from the
`ObjectStackProtocol` god-union to the new `RestProtocol =
DataProtocol & MetadataProtocol` slice (exported from
`@objectstack/rest`), per the ADR-0076 D9 incremental narrowing guidance.
Type-level only; no runtime change.
33 changes: 33 additions & 0 deletions .github/workflows/engine-split-metric.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Engine Split Metric (ADR-0076 D7)

# Tracks the D7 repo-split trigger metric (#2462): the cross-package commit
# ratio of the ObjectQL engine core (engine.ts / registry.ts). The engine may
# only be extracted into its own repo once this ratio is low and stable
# (~88% at ADR time). Report-only — no gate until a threshold is agreed
# (ADR-0076 OQ#5); the ratio lands in the run's step summary.

on:
schedule:
- cron: '17 3 * * 1' # weekly, Monday 03:17 UTC
workflow_dispatch: {}
pull_request:
paths:
- 'scripts/check-engine-split-ratio.mjs'
- '.github/workflows/engine-split-metric.yml'

permissions:
contents: read

jobs:
ratio:
name: Engine cross-package commit ratio
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository (full history)
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Compute ratio (90-day window)
run: node scripts/check-engine-split-ratio.mjs --days 90
5 changes: 2 additions & 3 deletions content/docs/protocol/kernel/http-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,11 @@ GET /api/v1/discovery HTTP/1.1
"auth": "/api/v1/auth",
"ui": "/api/v1/ui",
"storage": "/api/v1/storage",
"graphql": "/api/v1/graphql",
"realtime": "/api/v1/realtime"
"graphql": "/api/v1/graphql"
},
"features": {
"graphql": true,
"websockets": true,
"websockets": false,
"search": true,
"files": true,
"analytics": true,
Expand Down
15 changes: 11 additions & 4 deletions content/docs/protocol/kernel/realtime-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ ObjectStack supports two real-time protocols:

### Connection Endpoint

The discovery endpoint advertises the realtime route when the realtime service is registered:
The discovery endpoint reports the realtime service honestly (ADR-0076 D12): because the in-process realtime service mounts **no** HTTP/WS surface today, **no `routes.realtime` entry is advertised** — an advertised route with no handler would 404. The service itself appears in `services.realtime` as `degraded` with `handlerReady: false` when registered:

```http
GET /.well-known/objectstack
Expand All @@ -114,14 +114,21 @@ GET /.well-known/objectstack
{
"routes": {
"data": "/api/v1/data",
"metadata": "/api/v1/meta",
"realtime": "/api/v1/realtime"
"metadata": "/api/v1/meta"
},
"services": {
"realtime": {
"enabled": true,
"status": "degraded",
"handlerReady": false,
"message": "In-process event bus only — no HTTP/WS realtime surface is mounted"
}
}
}
```

<Callout type="info">
The discovery `routes.realtime` value is an HTTP path (`/api/v1/realtime`), not a `wss://` URL. A WebSocket upgrade endpoint is part of the planned transport (`IRealtimeService.handleUpgrade()`) and is not yet served.
A WebSocket upgrade endpoint is part of the planned transport (`IRealtimeService.handleUpgrade()`) and is not yet served. When it lands, discovery will advertise `routes.realtime` again — until then clients must treat `services.realtime.handlerReady: false` as "no wire transport" (see #2462).
</Callout>

### Establishing Connection
Expand Down
50 changes: 42 additions & 8 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
InstallPackageResponse
} from '@objectstack/spec/api';
import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api';
import { readServiceSelfInfo } from '@objectstack/spec/api';
import type { IFeedService } from '@objectstack/spec/contracts';
import { parseFilterAST, isFilterAST } from '@objectstack/spec/data';
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
Expand Down Expand Up @@ -447,17 +448,23 @@ const CLONE_STRIP_FIELDS: readonly string[] = [

/**
* Service Configuration for Discovery
* Maps service names to their routes and plugin providers
* Maps service names to their routes and plugin providers.
*
* `route: undefined` means the service has NO HTTP surface — discovery must
* not advertise a route for it (ADR-0076 D12, #2462: an advertised route
* with no mounted handler 404s and misleads consumers).
*/
const SERVICE_CONFIG: Record<string, { route: string; plugin: string }> = {
const SERVICE_CONFIG: Record<string, { route?: string; plugin: string }> = {
auth: { route: '/api/v1/auth', plugin: 'plugin-auth' },
automation: { route: '/api/v1/automation', plugin: 'plugin-automation' },
cache: { route: '/api/v1/cache', plugin: 'plugin-redis' },
queue: { route: '/api/v1/queue', plugin: 'plugin-bullmq' },
job: { route: '/api/v1/jobs', plugin: 'job-scheduler' },
ui: { route: '/api/v1/ui', plugin: 'ui-plugin' },
workflow: { route: '/api/v1/workflow', plugin: 'plugin-workflow' },
realtime: { route: '/api/v1/realtime', plugin: 'plugin-realtime' },
// service-realtime is an in-process pub/sub bus; nothing mounts
// /api/v1/realtime, so no route is advertised (D12, #2462).
realtime: { plugin: 'service-realtime' },
notification: { route: '/api/v1/notifications', plugin: 'plugin-notifications' },
ai: { route: '/api/v1/ai', plugin: 'plugin-ai' },
i18n: { route: '/api/v1/i18n', plugin: 'service-i18n' },
Expand Down Expand Up @@ -1096,23 +1103,49 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
// 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
const services: Record<string, ServiceInfo> = {
// --- 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: 'available' as const, route: '/api/v1/analytics', 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
for (const [serviceName, config] of Object.entries(SERVICE_CONFIG)) {
if (registeredServices.has(serviceName)) {
// Service is registered and available
// Registered — but honor a stub/dev/fallback self-description
// instead of blindly reporting 'available' (ADR-0076 D12).
const self = readServiceSelfInfo(registeredServices.get(serviceName));
// No HTTP surface at all (e.g. realtime): the handler can never
// be ready and 'available' would overstate it — report degraded.
const noHttpSurface = !config.route;
services[serviceName] = {
enabled: true,
status: 'available' as const,
status: self?.status ?? (noHttpSurface ? ('degraded' as const) : ('available' as const)),
route: config.route,
provider: config.plugin,
...(noHttpSurface || self?.handlerReady !== undefined
? { handlerReady: noHttpSurface ? false : self?.handlerReady }
: {}),
...(self?.message
? { message: self.message }
: noHttpSurface
? { message: 'In-process service only — no HTTP surface is mounted' }
: {}),
};
} else {
// Service is not registered
Expand Down Expand Up @@ -1142,9 +1175,10 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
analytics: '/api/v1/analytics',
};

// Add routes for available plugin services
// Add routes for available plugin services. Services without an HTTP
// surface (config.route undefined) advertise no route (D12, #2462).
for (const [serviceName, config] of Object.entries(SERVICE_CONFIG)) {
if (registeredServices.has(serviceName)) {
if (registeredServices.has(serviceName) && config.route) {
const routeKey = serviceToRouteKey[serviceName];
if (routeKey) {
optionalRoutes[routeKey] = config.route;
Expand Down
11 changes: 11 additions & 0 deletions packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ObjectQL } from './engine.js';
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
import { Plugin, PluginContext } from '@objectstack/core';
import { StorageNameMapping } from '@objectstack/spec/system';
import { SERVICE_SELF_INFO_KEY, type ServiceSelfInfo } from '@objectstack/spec/api';
import { LifecycleService } from './lifecycle/lifecycle-service.js';
import { lifecycleSettingsManifest } from './lifecycle/lifecycle-settings.js';
import {
Expand Down Expand Up @@ -279,6 +280,16 @@ export class ObjectQLPlugin implements Plugin {
// structured "not implemented" payload so callers see something
// useful instead of a 500.
ctx.registerService('analytics', {
// Honest capabilities (ADR-0076 D12, #2462): this adapter is a
// deliberate lightweight fallback, not the full analytics engine —
// self-identify so discovery reports it as 'degraded' instead of
// 'available'. AnalyticsServicePlugin replaces this service (via
// ctx.replaceService) with the real engine, which carries no marker.
[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:
// `{ cube, measures, dimensions, where?, filters?, ... }`). The
// protocol shim's `analyticsQuery` expects the wrapped envelope
Expand Down
69 changes: 59 additions & 10 deletions packages/objectql/src/protocol-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,29 +62,78 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
mockServices.set('auth', {});
mockServices.set('realtime', {});
mockServices.set('ai', {});

protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);

const discovery = await protocol.getDiscovery();

// Check auth
expect(discovery.services.auth.enabled).toBe(true);
expect(discovery.services.auth.status).toBe('available');

// Check realtime

// Check realtime — honest capabilities (ADR-0076 D12, #2462): the
// realtime service is an in-process bus with NO HTTP surface, so it is
// registered/enabled but degraded, with no advertised route (a route
// would 404).
expect(discovery.services.realtime.enabled).toBe(true);
expect(discovery.services.realtime.status).toBe('available');

expect(discovery.services.realtime.status).toBe('degraded');
expect(discovery.services.realtime.handlerReady).toBe(false);
expect(discovery.services.realtime.route).toBeUndefined();

// Check AI
expect(discovery.services.ai.enabled).toBe(true);
expect(discovery.services.ai.status).toBe('available');
// Routes should include available services

// Routes should include available services — but never realtime (D12)
expect(discovery.routes.auth).toBe('/api/v1/auth');
expect(discovery.routes.realtime).toBe('/api/v1/realtime');
expect(discovery.routes.realtime).toBeUndefined();
expect(discovery.routes.ai).toBe('/api/v1/ai');
});

// ── Honest capabilities (ADR-0076 D12, #2462) ─────────────────────────────

it('should report a _dev-marked service as a stub, never available', async () => {
const mockServices = new Map<string, any>();
mockServices.set('ai', { _dev: true });

protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
const discovery = await protocol.getDiscovery();

expect(discovery.services.ai.enabled).toBe(true);
expect(discovery.services.ai.status).toBe('stub');
expect(discovery.services.ai.handlerReady).toBe(false);
expect(discovery.services.ai.message).toContain('stub');
});

it('should report a __serviceInfo-marked service with its declared status', async () => {
const mockServices = new Map<string, any>();
mockServices.set('workflow', {
__serviceInfo: { status: 'degraded', message: 'partial impl' },
});

protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
const discovery = await protocol.getDiscovery();

expect(discovery.services.workflow.enabled).toBe(true);
expect(discovery.services.workflow.status).toBe('degraded');
expect(discovery.services.workflow.handlerReady).toBe(true);
expect(discovery.services.workflow.message).toBe('partial impl');
});

it('should report the analytics fallback honestly when it self-identifies', async () => {
const mockServices = new Map<string, any>();
mockServices.set('analytics', {
__serviceInfo: { status: 'degraded', handlerReady: true, message: 'fallback' },
});

protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
const discovery = await protocol.getDiscovery();

expect(discovery.services.analytics.enabled).toBe(true);
expect(discovery.services.analytics.status).toBe('degraded');
expect(discovery.services.analytics.message).toBe('fallback');
});

it('should always show core services as available', async () => {
protocol = new ObjectStackProtocolImplementation(engine);

Expand Down
4 changes: 3 additions & 1 deletion packages/plugins/plugin-hono-server/src/hono-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,9 @@ export class HonoServerPlugin implements Plugin {
auth: `${prefix}/auth`,
packages: `${prefix}/packages`,
analytics: `${prefix}/analytics`,
realtime: `${prefix}/realtime`,
// realtime deliberately absent (ADR-0076 D12, #2462): no
// /realtime HTTP surface is mounted anywhere — advertising
// it here made clients call a route that 404s.
workflow: `${prefix}/workflow`,
automation: `${prefix}/automation`,
ai: `${prefix}/ai`,
Expand Down
2 changes: 2 additions & 0 deletions packages/rest/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

// REST Server
export { RestServer } from './rest-server.js';
// The protocol slice the REST layer consumes (ADR-0076 D9 / #2462 A1.5)
export type { RestProtocol } from './rest-server.js';

// Route Management
export { RouteManager, RouteGroupBuilder } from './route-manager.js';
Expand Down
10 changes: 5 additions & 5 deletions packages/rest/src/rest-api-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { Plugin, PluginContext, IHttpServer } from '@objectstack/core';
import { RestServer, RestKernelManager } from './rest-server.js';
import { ObjectStackProtocol, RestServerConfig } from '@objectstack/spec/api';
import { RestServer, RestKernelManager, RestProtocol } from './rest-server.js';
import { RestServerConfig } from '@objectstack/spec/api';
import { registerPackageRoutes } from './package-routes.js';
import { registerExternalDatasourceRoutes } from './external-datasource-routes.js';
import type { PackageService } from '@objectstack/service-package';
Expand All @@ -25,7 +25,7 @@ export interface RestApiPluginConfig {
*
* Responsibilities:
* 1. Consumes 'http.server' (or configured service)
* 2. Consumes 'protocol' (ObjectStackProtocol)
* 2. Consumes 'protocol' (the `RestProtocol` slice — DataProtocol + MetadataProtocol, ADR-0076 D9)
* 3. Instantiates RestServer to auto-generate routes
*/
export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
Expand Down Expand Up @@ -59,7 +59,7 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
const protocolService = config.protocolServiceName || 'protocol';

let server: IHttpServer | undefined;
let protocol: ObjectStackProtocol | undefined;
let protocol: RestProtocol | undefined;

try {
server = ctx.getService<IHttpServer>(serverService);
Expand All @@ -68,7 +68,7 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
}

try {
protocol = ctx.getService<ObjectStackProtocol>(protocolService);
protocol = ctx.getService<RestProtocol>(protocolService);
} catch (e) {
// Ignore missing service
}
Expand Down
Loading