From 15da12ad7da7707240b3fa0b73869e3c9ec09478 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 06:29:33 +0000 Subject: [PATCH 1/3] refactor(rest): narrow the REST protocol dependency to DataProtocol & MetadataProtocol (ADR-0076 D9/A1.5, #2462) The REST layer was typed against the full ObjectStackProtocol god-union while actually calling only the data CRUD + metadata control-plane slices (server-only extensions are feature-detected via casts). Introduce RestProtocol = DataProtocol & MetadataProtocol, thread it through RestServer / rest-api-plugin, and export it from the package barrel, per the D9 incremental narrowing guidance. Type-level only. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01A1BtxNeUaGmvUYr8FwtUNu --- packages/rest/src/index.ts | 2 ++ packages/rest/src/rest-api-plugin.ts | 10 +++++----- packages/rest/src/rest-server.ts | 21 +++++++++++++++------ 3 files changed, 22 insertions(+), 11 deletions(-) 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 { From 7f68068e7753efb7f927eed4ac1c89af08061166 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 06:29:55 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(discovery):=20honest=20capabilities=20?= =?UTF-8?q?=E2=80=94=20stub/fallback=20marker=20+=20realtime=20route=20hon?= =?UTF-8?q?esty=20(ADR-0076=20D12=20framework=20slice,=20#2462)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01A1BtxNeUaGmvUYr8FwtUNu --- ...076-honest-capabilities-framework-slice.md | 42 ++++++++++ .../docs/protocol/kernel/http-protocol.mdx | 5 +- .../protocol/kernel/realtime-protocol.mdx | 15 +++- packages/metadata-protocol/src/protocol.ts | 50 ++++++++++-- packages/objectql/src/plugin.ts | 11 +++ .../objectql/src/protocol-discovery.test.ts | 69 +++++++++++++--- .../plugin-hono-server/src/hono-plugin.ts | 4 +- packages/runtime/src/http-dispatcher.test.ts | 78 +++++++++++++++++++ packages/runtime/src/http-dispatcher.ts | 78 ++++++++++++++----- packages/spec/api-surface.json | 7 +- packages/spec/json-schema.manifest.json | 1 + packages/spec/src/api/discovery.test.ts | 57 ++++++++++++++ packages/spec/src/api/discovery.zod.ts | 78 +++++++++++++++++++ 13 files changed, 447 insertions(+), 48 deletions(-) create mode 100644 .changeset/adr-0076-honest-capabilities-framework-slice.md 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/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/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. From 9db4361469a5dc7f0881a0b84212bc4d2f95de07 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 06:30:09 +0000 Subject: [PATCH 3/3] ci: track the ADR-0076 D7 engine repo-split trigger metric (#2462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D7 (extracting the ObjectQL engine into its own repo) is gated on the cross-package commit ratio of engine.ts/registry.ts falling from ~88% to a low, stable level — but nothing measured it. Add scripts/check-engine-split-ratio.mjs (report-only by default; optional --threshold gate once OQ#5 is decided) and a weekly workflow that writes the ratio to the run's step summary. Current reading: 100% over the last 30 days — D7 stays firmly deferred. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01A1BtxNeUaGmvUYr8FwtUNu --- .github/workflows/engine-split-metric.yml | 33 +++++++ scripts/check-engine-split-ratio.mjs | 105 ++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 .github/workflows/engine-split-metric.yml create mode 100644 scripts/check-engine-split-ratio.mjs 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/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); +}