diff --git a/.changeset/adr-0076-honest-capabilities-framework-slice.md b/.changeset/adr-0076-honest-capabilities-framework-slice.md
new file mode 100644
index 0000000000..422756a1a2
--- /dev/null
+++ b/.changeset/adr-0076-honest-capabilities-framework-slice.md
@@ -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.
diff --git a/.github/workflows/engine-split-metric.yml b/.github/workflows/engine-split-metric.yml
new file mode 100644
index 0000000000..63a55eb4c7
--- /dev/null
+++ b/.github/workflows/engine-split-metric.yml
@@ -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
diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx
index 261e2ca919..b158ecb9e7 100644
--- a/content/docs/protocol/kernel/http-protocol.mdx
+++ b/content/docs/protocol/kernel/http-protocol.mdx
@@ -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,
diff --git a/content/docs/protocol/kernel/realtime-protocol.mdx b/content/docs/protocol/kernel/realtime-protocol.mdx
index aeafc79861..16409b11cf 100644
--- a/content/docs/protocol/kernel/realtime-protocol.mdx
+++ b/content/docs/protocol/kernel/realtime-protocol.mdx
@@ -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
@@ -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"
+ }
}
}
```
- 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).
### Establishing Connection
diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts
index 8a82562d40..7614ef07c7 100644
--- a/packages/metadata-protocol/src/protocol.ts
+++ b/packages/metadata-protocol/src/protocol.ts
@@ -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';
@@ -447,9 +448,13 @@ 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 = {
+const SERVICE_CONFIG: Record = {
auth: { route: '/api/v1/auth', plugin: 'plugin-auth' },
automation: { route: '/api/v1/automation', plugin: 'plugin-automation' },
cache: { route: '/api/v1/cache', plugin: 'plugin-redis' },
@@ -457,7 +462,9 @@ const SERVICE_CONFIG: Record = {
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' },
@@ -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 = {
// --- 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
@@ -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;
diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts
index 9a05666afe..e7809a57ca 100644
--- a/packages/objectql/src/plugin.ts
+++ b/packages/objectql/src/plugin.ts
@@ -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 {
@@ -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
diff --git a/packages/objectql/src/protocol-discovery.test.ts b/packages/objectql/src/protocol-discovery.test.ts
index 9a3be0e302..d32130e0f8 100644
--- a/packages/objectql/src/protocol-discovery.test.ts
+++ b/packages/objectql/src/protocol-discovery.test.ts
@@ -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();
+ 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();
+ 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();
+ 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);
diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts
index 126a812882..7db54bd902 100644
--- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts
+++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts
@@ -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`,
diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts
index 02a5b54848..4265e2c544 100644
--- a/packages/rest/src/index.ts
+++ b/packages/rest/src/index.ts
@@ -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';
diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts
index 8aec444123..2484d5fce1 100644
--- a/packages/rest/src/rest-api-plugin.ts
+++ b/packages/rest/src/rest-api-plugin.ts
@@ -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';
@@ -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 {
@@ -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(serverService);
@@ -68,7 +68,7 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
}
try {
- protocol = ctx.getService(protocolService);
+ protocol = ctx.getService(protocolService);
} catch (e) {
// Ignore missing service
}
diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts
index c66e111837..3c8ea033c4 100644
--- a/packages/rest/src/rest-server.ts
+++ b/packages/rest/src/rest-server.ts
@@ -7,7 +7,16 @@ import {
import { isMcpServerEnabled } from '@objectstack/types';
import { RouteManager } from './route-manager.js';
import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api';
-import { ObjectStackProtocol } from '@objectstack/spec/api';
+import { DataProtocol, MetadataProtocol } from '@objectstack/spec/api';
+
+/**
+ * The protocol slice the REST layer actually consumes (ADR-0076 D9 / #2462
+ * A1.5): wire-normalized data CRUD plus the metadata control plane — not the
+ * full `ObjectStackProtocol` union. Server-only extensions (drafts, history,
+ * diagnostics, clone, …) are feature-detected via runtime casts and so don't
+ * widen this contract.
+ */
+export type RestProtocol = DataProtocol & MetadataProtocol;
import {
buildFieldMetaMap,
referenceFieldNames,
@@ -693,7 +702,7 @@ export interface RestEnvRegistry {
}
export class RestServer {
- private protocol: ObjectStackProtocol;
+ private protocol: RestProtocol;
private config: NormalizedRestServerConfig;
private routeManager: RouteManager;
private kernelManager?: RestKernelManager;
@@ -749,7 +758,7 @@ export class RestServer {
constructor(
server: IHttpServer,
- protocol: ObjectStackProtocol,
+ protocol: RestProtocol,
config: RestServerConfig = {},
kernelManager?: RestKernelManager,
envRegistry?: RestEnvRegistry,
@@ -876,12 +885,12 @@ export class RestServer {
return undefined;
}
- private async resolveProtocol(environmentId?: string, req?: any): Promise {
+ private async resolveProtocol(environmentId?: string, req?: any): Promise {
if (environmentId === 'platform') return this.protocol;
const envId = await this.resolveRequestEnvironmentId(environmentId, req);
if (!envId || !this.kernelManager) return this.protocol;
const kernel = await this.kernelManager.getOrCreate(envId);
- return kernel.getServiceAsync('protocol');
+ return kernel.getServiceAsync('protocol');
}
/**
@@ -998,7 +1007,7 @@ export class RestServer {
private async enforceApiAccess(
req: any,
res: any,
- p: ObjectStackProtocol,
+ p: RestProtocol,
environmentId: string | undefined,
operation: string,
): Promise {
diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts
index 3f13887832..b169c7bb89 100644
--- a/packages/runtime/src/http-dispatcher.test.ts
+++ b/packages/runtime/src/http-dispatcher.test.ts
@@ -1918,6 +1918,84 @@ describe('HttpDispatcher', () => {
});
});
+ // ═══════════════════════════════════════════════════════════════
+ // Honest capabilities — ADR-0076 D12 (#2462)
+ // ═══════════════════════════════════════════════════════════════
+
+ describe('discovery honest capabilities (D12)', () => {
+ it('never advertises a /realtime route and reports a registered realtime service as degraded', async () => {
+ (kernel as any).getService = vi.fn().mockImplementation((name: string) => {
+ if (name === 'realtime') return { publish: vi.fn(), subscribe: vi.fn() };
+ return null;
+ });
+
+ const info = await dispatcher.getDiscoveryInfo('/api/v1');
+
+ // No HTTP/WS surface exists — a discovery-advertised route would 404.
+ expect(info.routes.realtime).toBeUndefined();
+ expect(info.features.websockets).toBe(false);
+ expect(info.services.realtime.enabled).toBe(true);
+ expect(info.services.realtime.status).toBe('degraded');
+ expect(info.services.realtime.handlerReady).toBe(false);
+ // …and a /realtime request indeed has no handler
+ const result = await dispatcher.dispatch('POST', '/realtime/subscribe', {}, {}, { request: {} });
+ expect(result.response?.status).toBe(404);
+ });
+
+ it('reports realtime as unavailable when no service is registered', async () => {
+ (kernel as any).getService = vi.fn().mockResolvedValue(null);
+ (kernel as any).services = new Map();
+
+ const info = await dispatcher.getDiscoveryInfo('/api/v1');
+ expect(info.routes.realtime).toBeUndefined();
+ expect(info.services.realtime.enabled).toBe(false);
+ expect(info.services.realtime.status).toBe('unavailable');
+ });
+
+ it('reports a _dev-marked stub service as stub, never available', async () => {
+ (kernel as any).getService = vi.fn().mockImplementation((name: string) => {
+ if (name === 'ai') return { _dev: true, chat: vi.fn() };
+ return null;
+ });
+
+ const info = await dispatcher.getDiscoveryInfo('/api/v1');
+ expect(info.services.ai.enabled).toBe(true);
+ expect(info.services.ai.status).toBe('stub');
+ expect(info.services.ai.handlerReady).toBe(false);
+ expect(info.services.ai.message).toContain('stub');
+ });
+
+ it('reports a __serviceInfo-marked fallback with its declared status and message', async () => {
+ (kernel as any).getService = vi.fn().mockImplementation((name: string) => {
+ if (name === 'analytics') return {
+ __serviceInfo: { status: 'degraded', handlerReady: true, message: 'Lightweight fallback' },
+ query: vi.fn(),
+ };
+ return null;
+ });
+
+ const info = await dispatcher.getDiscoveryInfo('/api/v1');
+ expect(info.services.analytics.enabled).toBe(true);
+ expect(info.services.analytics.status).toBe('degraded');
+ expect(info.services.analytics.handlerReady).toBe(true);
+ expect(info.services.analytics.message).toBe('Lightweight fallback');
+ // Route stays advertised — the fallback genuinely serves it.
+ expect(info.routes.analytics).toBe('/api/v1/analytics');
+ });
+
+ it('keeps reporting unmarked services as available', async () => {
+ (kernel as any).getService = vi.fn().mockImplementation((name: string) => {
+ if (name === 'workflow') return { getConfig: vi.fn() };
+ return null;
+ });
+
+ const info = await dispatcher.getDiscoveryInfo('/api/v1');
+ expect(info.services.workflow.enabled).toBe(true);
+ expect(info.services.workflow.status).toBe('available');
+ expect(info.services.workflow.handlerReady).toBe(true);
+ });
+ });
+
// ═══════════════════════════════════════════════════════════════
// i18n across server/dev/mock environments
// ═══════════════════════════════════════════════════════════════
diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts
index 07352fe0a1..8d0ae0ed0e 100644
--- a/packages/runtime/src/http-dispatcher.ts
+++ b/packages/runtime/src/http-dispatcher.ts
@@ -6,6 +6,7 @@ import {
} from '@objectstack/core';
import { isMcpServerEnabled } from '@objectstack/types';
import { CoreServiceName } from '@objectstack/spec/system';
+import { readServiceSelfInfo } from '@objectstack/spec/api';
import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
import { pluralToSingular, PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
import type { ExecutionContext } from '@objectstack/spec/kernel';
@@ -1565,7 +1566,6 @@ export class HttpDispatcher {
const hasAuth = !!authSvc;
const hasGraphQL = !!(graphqlSvc || this.kernel.graphql);
const hasSearch = !!searchSvc;
- const hasWebSockets = !!realtimeSvc;
const hasFiles = !!filesSvc;
const hasAnalytics = !!analyticsSvc;
const hasWorkflow = !!workflowSvc;
@@ -1590,7 +1590,12 @@ export class HttpDispatcher {
analytics: hasAnalytics ? `${prefix}/analytics` : undefined,
automation: hasAutomation ? `${prefix}/automation` : undefined,
workflow: hasWorkflow ? `${prefix}/workflow` : undefined,
- realtime: hasWebSockets ? `${prefix}/realtime` : undefined,
+ // Never advertised (ADR-0076 D12, #2462): service-realtime is an
+ // in-process pub/sub bus — the dispatcher has no /realtime branch
+ // and no plugin mounts one, so an advertised route would 404.
+ // Re-add only when a real HTTP/WS surface exists (and then it must
+ // pass through the shouldDenyAnonymous gate, #2567).
+ realtime: undefined,
notifications: hasNotification ? `${prefix}/notifications` : undefined,
ai: hasAi ? `${prefix}/ai` : undefined,
i18n: hasI18n ? `${prefix}/i18n` : undefined,
@@ -1604,14 +1609,31 @@ export class HttpDispatcher {
// handlerReady: true means the dispatcher has a real, bound handler for this route.
// handlerReady: false means the route is present in the discovery table but may not
// yet have a concrete implementation or may be served by a stub.
- const svcAvailable = (route?: string, provider?: string) => ({
- enabled: true, status: 'available' as const, handlerReady: true, route, provider,
- });
+ //
+ // Honest capabilities (ADR-0076 D12, #2462): a registered service that
+ // self-identifies as a stub / dev fake / degraded fallback (via the
+ // `__serviceInfo` marker or plugin-dev's legacy `_dev: true`) is
+ // reported with its declared status — never as `available` — so
+ // consumers (AI agents, the console) don't mistake a fake capability
+ // for a real one.
+ const svcAvailable = (route?: string, provider?: string, svc?: unknown) => {
+ const self = svc ? readServiceSelfInfo(svc) : undefined;
+ if (self) {
+ return {
+ enabled: true, status: self.status, handlerReady: self.handlerReady ?? false,
+ route, provider, message: self.message,
+ };
+ }
+ return { enabled: true, status: 'available' as const, handlerReady: true, route, provider };
+ };
const svcUnavailable = (name: string) => ({
enabled: false, status: 'unavailable' as const, handlerReady: false,
message: `Install a ${name} plugin to enable`,
});
+ // Self-description of the registered realtime service, if any (D12).
+ const realtimeSelf = realtimeSvc ? readServiceSelfInfo(realtimeSvc) : undefined;
+
// Derive locale info from actual i18n service when available
let locale = { default: 'en', supported: ['en'], timezone: 'UTC' };
if (hasI18n && i18nSvc) {
@@ -1635,7 +1657,10 @@ export class HttpDispatcher {
features: {
graphql: hasGraphQL,
search: hasSearch,
- websockets: hasWebSockets,
+ // No WS/HTTP realtime surface is mounted anywhere — a mere
+ // in-process realtime service must not advertise websockets
+ // (ADR-0076 D12, #2462).
+ websockets: false,
files: hasFiles,
analytics: hasAnalytics,
ai: hasAi,
@@ -1648,21 +1673,32 @@ export class HttpDispatcher {
metadata: { enabled: true, status: 'degraded' as const, handlerReady: true, route: routes.metadata, provider: 'kernel', message: 'In-memory registry; DB persistence pending' },
data: svcAvailable(routes.data, 'kernel'),
// Plugin-provided — only available when a plugin registers the service
- auth: hasAuth ? svcAvailable(routes.auth) : svcUnavailable('auth'),
- automation: hasAutomation ? svcAvailable(routes.automation) : svcUnavailable('automation'),
- analytics: hasAnalytics ? svcAvailable(routes.analytics) : svcUnavailable('analytics'),
- cache: hasCache ? svcAvailable() : svcUnavailable('cache'),
- queue: hasQueue ? svcAvailable() : svcUnavailable('queue'),
- job: hasJob ? svcAvailable() : svcUnavailable('job'),
- ui: hasUi ? svcAvailable(routes.ui) : svcUnavailable('ui'),
- workflow: hasWorkflow ? svcAvailable(routes.workflow) : svcUnavailable('workflow'),
- realtime: hasWebSockets ? svcAvailable(routes.realtime) : svcUnavailable('realtime'),
- notification: hasNotification ? svcAvailable(routes.notifications) : svcUnavailable('notification'),
- ai: hasAi ? svcAvailable(routes.ai) : svcUnavailable('ai'),
- i18n: hasI18n ? svcAvailable(routes.i18n) : svcUnavailable('i18n'),
- graphql: hasGraphQL ? svcAvailable(routes.graphql) : svcUnavailable('graphql'),
- 'file-storage': hasFiles ? svcAvailable(routes.storage) : svcUnavailable('file-storage'),
- search: hasSearch ? svcAvailable() : svcUnavailable('search'),
+ auth: hasAuth ? svcAvailable(routes.auth, undefined, authSvc) : svcUnavailable('auth'),
+ automation: hasAutomation ? svcAvailable(routes.automation, undefined, automationSvc) : svcUnavailable('automation'),
+ analytics: hasAnalytics ? svcAvailable(routes.analytics, undefined, analyticsSvc) : svcUnavailable('analytics'),
+ cache: hasCache ? svcAvailable(undefined, undefined, cacheSvc) : svcUnavailable('cache'),
+ queue: hasQueue ? svcAvailable(undefined, undefined, queueSvc) : svcUnavailable('queue'),
+ job: hasJob ? svcAvailable(undefined, undefined, jobSvc) : svcUnavailable('job'),
+ ui: hasUi ? svcAvailable(routes.ui, undefined, uiSvc) : svcUnavailable('ui'),
+ workflow: hasWorkflow ? svcAvailable(routes.workflow, undefined, workflowSvc) : svcUnavailable('workflow'),
+ // Honest entry (ADR-0076 D12, #2462): the registered realtime
+ // service is an in-process event bus with NO mounted HTTP/WS
+ // surface — report it degraded with handlerReady:false (or as
+ // the stub it declares itself to be), never as an available
+ // HTTP capability with a route that would 404.
+ realtime: realtimeSvc ? {
+ enabled: true,
+ status: realtimeSelf?.status ?? ('degraded' as const),
+ handlerReady: false,
+ message: realtimeSelf?.message
+ ?? 'In-process event bus only — no HTTP/WS realtime surface is mounted',
+ } : svcUnavailable('realtime'),
+ notification: hasNotification ? svcAvailable(routes.notifications, undefined, notificationSvc) : svcUnavailable('notification'),
+ ai: hasAi ? svcAvailable(routes.ai, undefined, aiSvc) : svcUnavailable('ai'),
+ i18n: hasI18n ? svcAvailable(routes.i18n, undefined, i18nSvc) : svcUnavailable('i18n'),
+ graphql: hasGraphQL ? svcAvailable(routes.graphql, undefined, graphqlSvc) : svcUnavailable('graphql'),
+ 'file-storage': hasFiles ? svcAvailable(routes.storage, undefined, filesSvc) : svcUnavailable('file-storage'),
+ search: hasSearch ? svcAvailable(undefined, undefined, searchSvc) : svcUnavailable('search'),
},
locale,
};
diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json
index 5adc7ddb6a..fcc9b27947 100644
--- a/packages/spec/api-surface.json
+++ b/packages/spec/api-surface.json
@@ -2930,6 +2930,8 @@
"RouteHealthReportSchema (const)",
"RouterConfig (type)",
"RouterConfigSchema (const)",
+ "SERVICE_DEV_MARKER_KEY (const)",
+ "SERVICE_SELF_INFO_KEY (const)",
"SaveMetaItemRequest (type)",
"SaveMetaItemRequestSchema (const)",
"SaveMetaItemResponse (type)",
@@ -2947,6 +2949,8 @@
"SearchFeedResponseSchema (const)",
"ServiceInfo (type)",
"ServiceInfoSchema (const)",
+ "ServiceSelfInfo (type)",
+ "ServiceSelfInfoSchema (const)",
"ServiceStatus (type)",
"Session (type)",
"SessionResponse (type)",
@@ -3097,7 +3101,8 @@
"WorkflowTransitionResponseSchema (const)",
"getAuthEndpointUrl (function)",
"getDefaultRouteRegistrations (function)",
- "mapFieldTypeToGraphQL (const)"
+ "mapFieldTypeToGraphQL (const)",
+ "readServiceSelfInfo (function)"
],
"./ui": [
"ACTION_LOCATIONS (const)",
diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json
index 0eb394cc19..c4debcd66a 100644
--- a/packages/spec/json-schema.manifest.json
+++ b/packages/spec/json-schema.manifest.json
@@ -462,6 +462,7 @@
"api/SearchFeedRequest",
"api/SearchFeedResponse",
"api/ServiceInfo",
+ "api/ServiceSelfInfo",
"api/ServiceStatus",
"api/Session",
"api/SessionResponse",
diff --git a/packages/spec/src/api/discovery.test.ts b/packages/spec/src/api/discovery.test.ts
index 990cbe8086..6e18f92bea 100644
--- a/packages/spec/src/api/discovery.test.ts
+++ b/packages/spec/src/api/discovery.test.ts
@@ -7,6 +7,9 @@ import {
WellKnownCapabilitiesSchema,
RouteHealthEntrySchema,
RouteHealthReportSchema,
+ ServiceSelfInfoSchema,
+ readServiceSelfInfo,
+ SERVICE_SELF_INFO_KEY,
type DiscoveryResponse,
type ApiRoutes,
type ServiceInfo,
@@ -901,3 +904,57 @@ describe('RouteHealthReportSchema', () => {
})).toThrow();
});
});
+
+describe('Service self-description marker (ADR-0076 D12, #2462)', () => {
+ it('ServiceSelfInfoSchema accepts stub and degraded declarations', () => {
+ expect(() => ServiceSelfInfoSchema.parse({ status: 'stub' })).not.toThrow();
+ expect(() => ServiceSelfInfoSchema.parse({
+ status: 'degraded', handlerReady: true, message: 'fallback',
+ })).not.toThrow();
+ });
+
+ it('ServiceSelfInfoSchema rejects available — real services carry no marker', () => {
+ expect(() => ServiceSelfInfoSchema.parse({ status: 'available' })).toThrow();
+ });
+
+ it('readServiceSelfInfo returns undefined for unmarked services and non-objects', () => {
+ expect(readServiceSelfInfo({ query: () => [] })).toBeUndefined();
+ expect(readServiceSelfInfo(undefined)).toBeUndefined();
+ expect(readServiceSelfInfo(null)).toBeUndefined();
+ expect(readServiceSelfInfo('svc')).toBeUndefined();
+ });
+
+ it('readServiceSelfInfo reads the standard __serviceInfo descriptor', () => {
+ const svc = {
+ [SERVICE_SELF_INFO_KEY]: { status: 'degraded', message: 'partial' },
+ };
+ expect(readServiceSelfInfo(svc)).toEqual({
+ status: 'degraded',
+ handlerReady: true, // degraded defaults to a genuinely-serving handler
+ message: 'partial',
+ });
+ });
+
+ it('readServiceSelfInfo defaults handlerReady to false for stubs', () => {
+ const svc = { [SERVICE_SELF_INFO_KEY]: { status: 'stub' } };
+ expect(readServiceSelfInfo(svc)).toEqual({ status: 'stub', handlerReady: false });
+ });
+
+ it('readServiceSelfInfo honors an explicit handlerReady override', () => {
+ const svc = { [SERVICE_SELF_INFO_KEY]: { status: 'degraded', handlerReady: false } };
+ expect(readServiceSelfInfo(svc)?.handlerReady).toBe(false);
+ });
+
+ it('readServiceSelfInfo normalizes the legacy _dev marker to a stub', () => {
+ const info = readServiceSelfInfo({ _dev: true, chat: () => 'fake' });
+ expect(info?.status).toBe('stub');
+ expect(info?.handlerReady).toBe(false);
+ expect(info?.message).toContain('plugin-dev');
+ });
+
+ it('readServiceSelfInfo ignores malformed markers', () => {
+ expect(readServiceSelfInfo({ [SERVICE_SELF_INFO_KEY]: { status: 'available' } })).toBeUndefined();
+ expect(readServiceSelfInfo({ [SERVICE_SELF_INFO_KEY]: 'stub' })).toBeUndefined();
+ expect(readServiceSelfInfo({ _dev: 'yes' })).toBeUndefined();
+ });
+});
diff --git a/packages/spec/src/api/discovery.zod.ts b/packages/spec/src/api/discovery.zod.ts
index 6381e94796..d102f4afb1 100644
--- a/packages/spec/src/api/discovery.zod.ts
+++ b/packages/spec/src/api/discovery.zod.ts
@@ -70,6 +70,84 @@ export const ServiceInfoSchema = lazySchema(() => z.object({
}).optional().describe('Rate limit and quota info for this service'),
}));
+// ============================================================================
+// Honest capabilities — service self-description marker (ADR-0076 D12, #2462)
+// ============================================================================
+
+/**
+ * Well-known property name a registered kernel service can carry to
+ * self-identify as a stub / dev-fake / degraded fallback.
+ *
+ * Discovery builders MUST read this marker (via {@link readServiceSelfInfo})
+ * and report the declared status instead of hardcoding `available` — a stub
+ * or fallback that reports `status: 'available'` misleads consumers (AI
+ * agents, the console) into treating a fake capability as real.
+ */
+export const SERVICE_SELF_INFO_KEY = '__serviceInfo' as const;
+
+/**
+ * Legacy dev-stub marker used by plugin-dev's in-memory fakes.
+ * Recognized by {@link readServiceSelfInfo} as shorthand for
+ * `{ status: 'stub', handlerReady: false }`.
+ */
+export const SERVICE_DEV_MARKER_KEY = '_dev' as const;
+
+/**
+ * Shape of the {@link SERVICE_SELF_INFO_KEY} marker a service carries to
+ * describe its own honesty level. Only non-`available` self-reports exist:
+ * a service that is fully real simply carries no marker.
+ */
+export const ServiceSelfInfoSchema = lazySchema(() => z.object({
+ /** Declared honesty level: `stub` = placeholder/fake, `degraded` = working but partial fallback */
+ status: z.enum(['stub', 'degraded']).describe(
+ 'stub = placeholder or dev fake (do not use for real work); '
+ + 'degraded = functional fallback with reduced capability'
+ ),
+ /**
+ * Whether the service's HTTP handler genuinely serves requests.
+ * Defaults (when omitted): `false` for `stub`, `true` for `degraded`.
+ */
+ handlerReady: z.boolean().optional().describe(
+ 'Whether the HTTP handler genuinely serves requests. Defaults: false for stub, true for degraded.'
+ ),
+ /** Human-readable explanation shown in discovery (e.g. what to install for the real thing) */
+ message: z.string().optional().describe('Human-readable explanation, e.g. what to install for the full implementation'),
+}));
+
+export type ServiceSelfInfo = z.infer;
+
+/**
+ * Reads the standardized self-description marker off a registered service
+ * instance (ADR-0076 D12). Returns `undefined` for services that carry no
+ * marker — i.e. services claiming to be fully real.
+ *
+ * Recognizes:
+ * - `svc[SERVICE_SELF_INFO_KEY]` — the standard `{ status, handlerReady?, message? }` descriptor.
+ * - `svc[SERVICE_DEV_MARKER_KEY] === true` — plugin-dev's legacy `_dev: true`
+ * flag, normalized to `{ status: 'stub', handlerReady: false }`.
+ */
+export function readServiceSelfInfo(svc: unknown): ServiceSelfInfo | undefined {
+ if (!svc || typeof svc !== 'object') return undefined;
+ const self = (svc as Record)[SERVICE_SELF_INFO_KEY] as Record | undefined;
+ if (self && typeof self === 'object' && (self.status === 'stub' || self.status === 'degraded')) {
+ return {
+ status: self.status,
+ handlerReady: typeof self.handlerReady === 'boolean'
+ ? self.handlerReady
+ : self.status === 'degraded',
+ ...(typeof self.message === 'string' ? { message: self.message } : {}),
+ };
+ }
+ if ((svc as Record)[SERVICE_DEV_MARKER_KEY] === true) {
+ return {
+ status: 'stub',
+ handlerReady: false,
+ message: 'Development stub (plugin-dev) — not a production implementation',
+ };
+ }
+ return undefined;
+}
+
/**
* API Routes Schema
* The "Map" for the frontend to know where to send requests.
diff --git a/scripts/check-engine-split-ratio.mjs b/scripts/check-engine-split-ratio.mjs
new file mode 100644
index 0000000000..e558588adb
--- /dev/null
+++ b/scripts/check-engine-split-ratio.mjs
@@ -0,0 +1,105 @@
+#!/usr/bin/env node
+/**
+ * ADR-0076 D7 trigger metric (#2462): cross-package commit ratio of the
+ * ObjectQL engine core (`engine.ts` / `registry.ts`).
+ *
+ * The engine repo-split (D7) is trigger-gated: it may only happen once the
+ * share of engine-core commits that ALSO touch files outside
+ * `packages/objectql/` falls to a low, stable level (it was ~88% when the
+ * ADR was written — i.e. the engine still co-evolves with the rest of the
+ * monorepo and is NOT separable). This script computes that ratio from git
+ * history so CI can track it over time.
+ *
+ * Run: node scripts/check-engine-split-ratio.mjs [--days N] [--threshold PCT]
+ *
+ * --days N Look-back window in days (default 90).
+ * --threshold PCT Optional gate: exit 1 if the ratio is ABOVE the given
+ * percentage. By default the script is REPORT-ONLY (always
+ * exits 0) — the split threshold is an open question
+ * (ADR-0076 OQ#5) and is set deliberately, not by default.
+ *
+ * Output: a human-readable summary on stdout; when $GITHUB_STEP_SUMMARY is
+ * set, a markdown section is appended for the Actions run summary.
+ *
+ * Requires full git history (in CI: actions/checkout with fetch-depth: 0).
+ * Zero third-party dependencies.
+ */
+
+import { execFileSync } from 'node:child_process';
+import { appendFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(__dirname, '..');
+
+const ENGINE_CORE = ['packages/objectql/src/engine.ts', 'packages/objectql/src/registry.ts'];
+const ENGINE_PACKAGE_PREFIX = 'packages/objectql/';
+
+function arg(name, fallback) {
+ const i = process.argv.indexOf(`--${name}`);
+ if (i !== -1 && process.argv[i + 1] !== undefined) return process.argv[i + 1];
+ return fallback;
+}
+
+const days = Number(arg('days', '90'));
+const thresholdRaw = arg('threshold', '');
+const threshold = thresholdRaw === '' ? null : Number(thresholdRaw);
+if (!Number.isFinite(days) || days <= 0) {
+ console.error(`Invalid --days value: ${arg('days', '90')}`);
+ process.exit(2);
+}
+if (thresholdRaw !== '' && (!Number.isFinite(threshold) || threshold < 0 || threshold > 100)) {
+ console.error(`Invalid --threshold value: ${thresholdRaw} (expected 0-100)`);
+ process.exit(2);
+}
+
+function git(...args) {
+ return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
+}
+
+// Commits in the window that touched the engine core.
+const shas = git(
+ 'log', `--since=${days} days ago`, '--format=%H', '--', ...ENGINE_CORE,
+).split('\n').filter(Boolean);
+
+let crossPackage = 0;
+for (const sha of shas) {
+ const files = git('show', '--name-only', '--format=', sha).split('\n').filter(Boolean);
+ if (files.some((f) => !f.startsWith(ENGINE_PACKAGE_PREFIX))) crossPackage += 1;
+}
+
+const total = shas.length;
+const ratio = total === 0 ? 0 : (crossPackage / total) * 100;
+const ratioStr = ratio.toFixed(1);
+
+const lines = [
+ `ADR-0076 D7 trigger metric — engine cross-package commit ratio`,
+ ` window: last ${days} days`,
+ ` engine-core commits: ${total} (${ENGINE_CORE.join(', ')})`,
+ ` also cross-package: ${crossPackage}`,
+ ` ratio: ${total === 0 ? 'n/a (no engine-core commits in window)' : `${ratioStr}%`}`,
+ ``,
+ `Reference: ~88% at ADR time (2026-06) — the engine repo-split (D7) stays`,
+ `deferred until this ratio is low and stable (threshold TBD, ADR-0076 OQ#5).`,
+];
+console.log(lines.join('\n'));
+
+if (process.env.GITHUB_STEP_SUMMARY) {
+ const md = [
+ `### ADR-0076 D7 trigger metric — engine cross-package commit ratio`,
+ ``,
+ `| Window | Engine-core commits | Cross-package | Ratio |`,
+ `|---|---|---|---|`,
+ `| last ${days} days | ${total} | ${crossPackage} | ${total === 0 ? 'n/a' : `${ratioStr}%`} |`,
+ ``,
+ `Reference: ~88% at ADR time. D7 (engine repo-split) stays deferred until this is low and stable (OQ#5).`,
+ ``,
+ ].join('\n');
+ appendFileSync(process.env.GITHUB_STEP_SUMMARY, md);
+}
+
+if (threshold !== null && total > 0 && ratio > threshold) {
+ console.error(`\nRatio ${ratioStr}% exceeds the configured threshold of ${threshold}% — engine is not separable.`);
+ process.exit(1);
+}