Skip to content

Commit 7f68068

Browse files
committed
feat(discovery): honest capabilities — stub/fallback marker + realtime route honesty (ADR-0076 D12 framework slice, #2462)
Root cause (D12): both discovery builders hardcoded {status:'available', handlerReady:true} for every registered service, so stubs, dev fakes and fallbacks reported as fully real — agents and the console were misled. - spec: standardize the self-description marker (resolves OQ#11): SERVICE_SELF_INFO_KEY ('__serviceInfo') + ServiceSelfInfoSchema + readServiceSelfInfo(), which also normalizes plugin-dev's legacy _dev:true to {status:'stub', handlerReady:false}. - runtime + metadata-protocol: getDiscoveryInfo()/getDiscovery() honor the marker — stubs report 'stub', fallbacks 'degraded', never 'available'. - objectql: the deliberate lightweight analytics fallback self-identifies as degraded (it keeps serving — no /analytics 404; service-analytics replaces it via replaceService and reports 'available' untouched). - realtime (concrete D12 instance from #2462): discovery no longer advertises a /realtime route or websockets:true — service-realtime is an in-process pub/sub bus, nothing mounts /realtime, so the advertised route always 404'd. The registered service reports degraded with handlerReady:false; provider corrected from the nonexistent 'plugin-realtime' to 'service-realtime'. The client SDK is unaffected (it falls back to the conventional path, which 404s exactly as before). - docs: update the discovery examples in realtime-protocol.mdx / http-protocol.mdx to the honest output. Verified end-to-end: fresh app-crm boot with --preset minimal reports analytics degraded with the honest message; default preset (real service-analytics) reports available; no routes.realtime in either. The console-side consumer update (trust only handlerReady/available) stays in the coordinated cross-repo window per the issue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1BtxNeUaGmvUYr8FwtUNu
1 parent 15da12a commit 7f68068

13 files changed

Lines changed: 447 additions & 48 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/runtime': minor
4+
'@objectstack/metadata-protocol': minor
5+
'@objectstack/objectql': patch
6+
'@objectstack/rest': patch
7+
'@objectstack/plugin-hono-server': patch
8+
---
9+
10+
feat(discovery): honest capabilities — standardized stub/fallback marker + realtime route honesty (ADR-0076 D12/A1.5 framework slice, #2462)
11+
12+
**Spec** — new service self-description marker for honest discovery
13+
(ADR-0076 D12): `SERVICE_SELF_INFO_KEY` (`__serviceInfo`),
14+
`ServiceSelfInfoSchema` / `ServiceSelfInfo`, and `readServiceSelfInfo()`,
15+
which also normalizes plugin-dev's legacy `_dev: true` flag to
16+
`{ status: 'stub', handlerReady: false }`. A registered service that is a
17+
stub / dev fake / degraded fallback self-identifies via this marker; a fully
18+
real service carries no marker.
19+
20+
**Runtime + metadata-protocol** — both discovery builders
21+
(`HttpDispatcher.getDiscoveryInfo` and the protocol shim's `getDiscovery`)
22+
now honor the marker instead of hardcoding `status: 'available',
23+
handlerReady: true` for every registered service. Dev stubs report `stub`,
24+
the ObjectQL analytics fallback reports `degraded` (it keeps serving — no
25+
`/analytics` 404), and consumers can finally trust
26+
`status === 'available'` / `handlerReady === true`.
27+
28+
**Realtime honesty fix** — discovery no longer advertises a
29+
`/realtime` route or `websockets: true`: `service-realtime` is an
30+
in-process pub/sub bus, no dispatcher branch or plugin mounts any
31+
`/realtime` HTTP surface, so the advertised route always 404'd. The
32+
registered service now reports `status: 'degraded', handlerReady: false`
33+
with no route (clients using the SDK are unaffected — it falls back to the
34+
conventional path, which behaves exactly as before). Also corrects the
35+
advertised realtime provider from the nonexistent `plugin-realtime` to
36+
`service-realtime`.
37+
38+
**REST (A1.5)** — the REST layer's protocol dependency is narrowed from the
39+
`ObjectStackProtocol` god-union to the new `RestProtocol =
40+
DataProtocol & MetadataProtocol` slice (exported from
41+
`@objectstack/rest`), per the ADR-0076 D9 incremental narrowing guidance.
42+
Type-level only; no runtime change.

content/docs/protocol/kernel/http-protocol.mdx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,11 @@ GET /api/v1/discovery HTTP/1.1
4747
"auth": "/api/v1/auth",
4848
"ui": "/api/v1/ui",
4949
"storage": "/api/v1/storage",
50-
"graphql": "/api/v1/graphql",
51-
"realtime": "/api/v1/realtime"
50+
"graphql": "/api/v1/graphql"
5251
},
5352
"features": {
5453
"graphql": true,
55-
"websockets": true,
54+
"websockets": false,
5655
"search": true,
5756
"files": true,
5857
"analytics": true,

content/docs/protocol/kernel/realtime-protocol.mdx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ ObjectStack supports two real-time protocols:
104104

105105
### Connection Endpoint
106106

107-
The discovery endpoint advertises the realtime route when the realtime service is registered:
107+
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:
108108

109109
```http
110110
GET /.well-known/objectstack
@@ -114,14 +114,21 @@ GET /.well-known/objectstack
114114
{
115115
"routes": {
116116
"data": "/api/v1/data",
117-
"metadata": "/api/v1/meta",
118-
"realtime": "/api/v1/realtime"
117+
"metadata": "/api/v1/meta"
118+
},
119+
"services": {
120+
"realtime": {
121+
"enabled": true,
122+
"status": "degraded",
123+
"handlerReady": false,
124+
"message": "In-process event bus only — no HTTP/WS realtime surface is mounted"
125+
}
119126
}
120127
}
121128
```
122129

123130
<Callout type="info">
124-
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.
131+
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).
125132
</Callout>
126133

127134
### Establishing Connection

packages/metadata-protocol/src/protocol.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
InstallPackageResponse
1616
} from '@objectstack/spec/api';
1717
import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api';
18+
import { readServiceSelfInfo } from '@objectstack/spec/api';
1819
import type { IFeedService } from '@objectstack/spec/contracts';
1920
import { parseFilterAST, isFilterAST } from '@objectstack/spec/data';
2021
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
@@ -447,17 +448,23 @@ const CLONE_STRIP_FIELDS: readonly string[] = [
447448

448449
/**
449450
* Service Configuration for Discovery
450-
* Maps service names to their routes and plugin providers
451+
* Maps service names to their routes and plugin providers.
452+
*
453+
* `route: undefined` means the service has NO HTTP surface — discovery must
454+
* not advertise a route for it (ADR-0076 D12, #2462: an advertised route
455+
* with no mounted handler 404s and misleads consumers).
451456
*/
452-
const SERVICE_CONFIG: Record<string, { route: string; plugin: string }> = {
457+
const SERVICE_CONFIG: Record<string, { route?: string; plugin: string }> = {
453458
auth: { route: '/api/v1/auth', plugin: 'plugin-auth' },
454459
automation: { route: '/api/v1/automation', plugin: 'plugin-automation' },
455460
cache: { route: '/api/v1/cache', plugin: 'plugin-redis' },
456461
queue: { route: '/api/v1/queue', plugin: 'plugin-bullmq' },
457462
job: { route: '/api/v1/jobs', plugin: 'job-scheduler' },
458463
ui: { route: '/api/v1/ui', plugin: 'ui-plugin' },
459464
workflow: { route: '/api/v1/workflow', plugin: 'plugin-workflow' },
460-
realtime: { route: '/api/v1/realtime', plugin: 'plugin-realtime' },
465+
// service-realtime is an in-process pub/sub bus; nothing mounts
466+
// /api/v1/realtime, so no route is advertised (D12, #2462).
467+
realtime: { plugin: 'service-realtime' },
461468
notification: { route: '/api/v1/notifications', plugin: 'plugin-notifications' },
462469
ai: { route: '/api/v1/ai', plugin: 'plugin-ai' },
463470
i18n: { route: '/api/v1/i18n', plugin: 'service-i18n' },
@@ -1096,23 +1103,49 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
10961103
// Get registered services from kernel if available
10971104
const registeredServices = this.getServicesRegistry ? this.getServicesRegistry() : new Map();
10981105

1106+
// Honest capabilities (ADR-0076 D12, #2462): the registered analytics
1107+
// service may be the lightweight ObjectQL fallback rather than the
1108+
// full service-analytics engine — report whatever it declares about
1109+
// itself instead of hardcoding 'available'.
1110+
const analyticsSelf = readServiceSelfInfo(registeredServices.get('analytics'));
1111+
10991112
// Build dynamic service info with proper typing
11001113
const services: Record<string, ServiceInfo> = {
11011114
// --- Kernel-provided (objectql is an example kernel implementation) ---
11021115
metadata: { enabled: true, status: 'available' as const, route: '/api/v1/meta', provider: 'objectql' },
11031116
data: { enabled: true, status: 'available' as const, route: '/api/v1/data', provider: 'objectql' },
1104-
analytics: { enabled: true, status: 'available' as const, route: '/api/v1/analytics', provider: 'objectql' },
1117+
analytics: {
1118+
enabled: true,
1119+
status: analyticsSelf?.status ?? ('available' as const),
1120+
route: '/api/v1/analytics',
1121+
provider: 'objectql',
1122+
...(analyticsSelf?.handlerReady !== undefined ? { handlerReady: analyticsSelf.handlerReady } : {}),
1123+
...(analyticsSelf?.message ? { message: analyticsSelf.message } : {}),
1124+
},
11051125
};
11061126

11071127
// Check which services are actually registered
11081128
for (const [serviceName, config] of Object.entries(SERVICE_CONFIG)) {
11091129
if (registeredServices.has(serviceName)) {
1110-
// Service is registered and available
1130+
// Registered — but honor a stub/dev/fallback self-description
1131+
// instead of blindly reporting 'available' (ADR-0076 D12).
1132+
const self = readServiceSelfInfo(registeredServices.get(serviceName));
1133+
// No HTTP surface at all (e.g. realtime): the handler can never
1134+
// be ready and 'available' would overstate it — report degraded.
1135+
const noHttpSurface = !config.route;
11111136
services[serviceName] = {
11121137
enabled: true,
1113-
status: 'available' as const,
1138+
status: self?.status ?? (noHttpSurface ? ('degraded' as const) : ('available' as const)),
11141139
route: config.route,
11151140
provider: config.plugin,
1141+
...(noHttpSurface || self?.handlerReady !== undefined
1142+
? { handlerReady: noHttpSurface ? false : self?.handlerReady }
1143+
: {}),
1144+
...(self?.message
1145+
? { message: self.message }
1146+
: noHttpSurface
1147+
? { message: 'In-process service only — no HTTP surface is mounted' }
1148+
: {}),
11161149
};
11171150
} else {
11181151
// Service is not registered
@@ -1142,9 +1175,10 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
11421175
analytics: '/api/v1/analytics',
11431176
};
11441177

1145-
// Add routes for available plugin services
1178+
// Add routes for available plugin services. Services without an HTTP
1179+
// surface (config.route undefined) advertise no route (D12, #2462).
11461180
for (const [serviceName, config] of Object.entries(SERVICE_CONFIG)) {
1147-
if (registeredServices.has(serviceName)) {
1181+
if (registeredServices.has(serviceName) && config.route) {
11481182
const routeKey = serviceToRouteKey[serviceName];
11491183
if (routeKey) {
11501184
optionalRoutes[routeKey] = config.route;

packages/objectql/src/plugin.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ObjectQL } from './engine.js';
44
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
55
import { Plugin, PluginContext } from '@objectstack/core';
66
import { StorageNameMapping } from '@objectstack/spec/system';
7+
import { SERVICE_SELF_INFO_KEY, type ServiceSelfInfo } from '@objectstack/spec/api';
78
import { LifecycleService } from './lifecycle/lifecycle-service.js';
89
import { lifecycleSettingsManifest } from './lifecycle/lifecycle-settings.js';
910
import {
@@ -279,6 +280,16 @@ export class ObjectQLPlugin implements Plugin {
279280
// structured "not implemented" payload so callers see something
280281
// useful instead of a 500.
281282
ctx.registerService('analytics', {
283+
// Honest capabilities (ADR-0076 D12, #2462): this adapter is a
284+
// deliberate lightweight fallback, not the full analytics engine —
285+
// self-identify so discovery reports it as 'degraded' instead of
286+
// 'available'. AnalyticsServicePlugin replaces this service (via
287+
// ctx.replaceService) with the real engine, which carries no marker.
288+
[SERVICE_SELF_INFO_KEY]: {
289+
status: 'degraded',
290+
handlerReady: true,
291+
message: 'Lightweight ObjectQL analytics fallback — install @objectstack/service-analytics for the full engine',
292+
} satisfies ServiceSelfInfo,
282293
// HttpDispatcher passes the raw POST body (AnalyticsQuery shape:
283294
// `{ cube, measures, dimensions, where?, filters?, ... }`). The
284295
// protocol shim's `analyticsQuery` expects the wrapped envelope

packages/objectql/src/protocol-discovery.test.ts

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,29 +62,78 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
6262
mockServices.set('auth', {});
6363
mockServices.set('realtime', {});
6464
mockServices.set('ai', {});
65-
65+
6666
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
67-
67+
6868
const discovery = await protocol.getDiscovery();
69-
69+
7070
// Check auth
7171
expect(discovery.services.auth.enabled).toBe(true);
7272
expect(discovery.services.auth.status).toBe('available');
73-
74-
// Check realtime
73+
74+
// Check realtime — honest capabilities (ADR-0076 D12, #2462): the
75+
// realtime service is an in-process bus with NO HTTP surface, so it is
76+
// registered/enabled but degraded, with no advertised route (a route
77+
// would 404).
7578
expect(discovery.services.realtime.enabled).toBe(true);
76-
expect(discovery.services.realtime.status).toBe('available');
77-
79+
expect(discovery.services.realtime.status).toBe('degraded');
80+
expect(discovery.services.realtime.handlerReady).toBe(false);
81+
expect(discovery.services.realtime.route).toBeUndefined();
82+
7883
// Check AI
7984
expect(discovery.services.ai.enabled).toBe(true);
8085
expect(discovery.services.ai.status).toBe('available');
81-
82-
// Routes should include available services
86+
87+
// Routes should include available services — but never realtime (D12)
8388
expect(discovery.routes.auth).toBe('/api/v1/auth');
84-
expect(discovery.routes.realtime).toBe('/api/v1/realtime');
89+
expect(discovery.routes.realtime).toBeUndefined();
8590
expect(discovery.routes.ai).toBe('/api/v1/ai');
8691
});
8792

93+
// ── Honest capabilities (ADR-0076 D12, #2462) ─────────────────────────────
94+
95+
it('should report a _dev-marked service as a stub, never available', async () => {
96+
const mockServices = new Map<string, any>();
97+
mockServices.set('ai', { _dev: true });
98+
99+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
100+
const discovery = await protocol.getDiscovery();
101+
102+
expect(discovery.services.ai.enabled).toBe(true);
103+
expect(discovery.services.ai.status).toBe('stub');
104+
expect(discovery.services.ai.handlerReady).toBe(false);
105+
expect(discovery.services.ai.message).toContain('stub');
106+
});
107+
108+
it('should report a __serviceInfo-marked service with its declared status', async () => {
109+
const mockServices = new Map<string, any>();
110+
mockServices.set('workflow', {
111+
__serviceInfo: { status: 'degraded', message: 'partial impl' },
112+
});
113+
114+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
115+
const discovery = await protocol.getDiscovery();
116+
117+
expect(discovery.services.workflow.enabled).toBe(true);
118+
expect(discovery.services.workflow.status).toBe('degraded');
119+
expect(discovery.services.workflow.handlerReady).toBe(true);
120+
expect(discovery.services.workflow.message).toBe('partial impl');
121+
});
122+
123+
it('should report the analytics fallback honestly when it self-identifies', async () => {
124+
const mockServices = new Map<string, any>();
125+
mockServices.set('analytics', {
126+
__serviceInfo: { status: 'degraded', handlerReady: true, message: 'fallback' },
127+
});
128+
129+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
130+
const discovery = await protocol.getDiscovery();
131+
132+
expect(discovery.services.analytics.enabled).toBe(true);
133+
expect(discovery.services.analytics.status).toBe('degraded');
134+
expect(discovery.services.analytics.message).toBe('fallback');
135+
});
136+
88137
it('should always show core services as available', async () => {
89138
protocol = new ObjectStackProtocolImplementation(engine);
90139

packages/plugins/plugin-hono-server/src/hono-plugin.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,9 @@ export class HonoServerPlugin implements Plugin {
515515
auth: `${prefix}/auth`,
516516
packages: `${prefix}/packages`,
517517
analytics: `${prefix}/analytics`,
518-
realtime: `${prefix}/realtime`,
518+
// realtime deliberately absent (ADR-0076 D12, #2462): no
519+
// /realtime HTTP surface is mounted anywhere — advertising
520+
// it here made clients call a route that 404s.
519521
workflow: `${prefix}/workflow`,
520522
automation: `${prefix}/automation`,
521523
ai: `${prefix}/ai`,

0 commit comments

Comments
 (0)