diff --git a/.changeset/retire-dev-service-marker.md b/.changeset/retire-dev-service-marker.md new file mode 100644 index 0000000000..d599898770 --- /dev/null +++ b/.changeset/retire-dev-service-marker.md @@ -0,0 +1,39 @@ +--- +'@objectstack/spec': major +--- + +**BREAKING**: the legacy `_dev: true` service marker is retired. `readServiceSelfInfo()` +now reads exactly one marker — the standard `__serviceInfo` descriptor — and the +`SERVICE_DEV_MARKER_KEY` export is removed. + +FROM → TO, for any service that self-identifies as not-fully-real: + +```ts +// FROM — normalized to { status: 'stub', handlerReady: false } +const svc = { _dev: true, chat }; + +// TO — say which kind of unreal it is +const svc = { + __serviceInfo: { + status: 'stub', // 'stub' = fabricates answers | 'degraded' = really serves, reduced capability + message: 'Development stub — register for a real implementation', + }, + chat, +}; +``` + +`handlerReady` defaults to `false` for `stub` and `true` for `degraded`; set it +explicitly when the slot has no HTTP surface at all (`cache` / `queue` / `job`). + +**Why it matters if you skip the migration:** a service still carrying `_dev: true` +reads as *unmarked* — i.e. as fully real — so discovery will report it +`status: 'available', handlerReady: true`, and dispatcher domains will call it +instead of refusing it. That is the "fake reported as real" failure ADR-0076 D12 +exists to prevent, so migrate rather than leave the marker in place. + +Removing rather than aliasing is deliberate: a boolean cannot express the +`stub` / `degraded` split every consumer gates on (a stub's domain refuses it, a +degraded implementation's domain keeps serving it). No producers remained in this +repo when the reader was deleted — plugin-dev's stub table was retired in +ADR-0115, and the kernel's in-memory fallbacks moved onto the descriptor in the +same lineage. diff --git a/docs/adr/0076-objectql-core-tiering.md b/docs/adr/0076-objectql-core-tiering.md index 2fc8e8b36b..0b6a5e4fbb 100644 --- a/docs/adr/0076-objectql-core-tiering.md +++ b/docs/adr/0076-objectql-core-tiering.md @@ -214,4 +214,4 @@ import { SecurityPlugin } from '@objectstack/plugin-security'; - **(1) re-scoped (cross-repo finding)**: the "dead" branches are NOT safely killable — the `@objectstack/hono` `createHonoApp()` catch-all delegates every unmatched `${prefix}/*` to `dispatch()`, and both cloud hosts mount it under their plugin routes. In that composition `/share-links` is the **designed primary surface** (per-env kernels register the `shareLinks` service with `registerShareLinkRoutes:false`; the host dispatcher serves the route after kernel swap — documented in cloud's `artifact-kernel-factory` and `default-environment-plugins`), and `/notifications` has **no other HTTP owner anywhere**. `/data`/`/meta`/`/ui`/`/security` are REST-shadowed but still catch REST misses. Killing therefore waits until the catch-all is retired in favor of per-domain handlers (step 3); until then the branches are the cloud fallback fabric, not dead code. - **(1)+(3) VERDICT (2026-07-27, #2462 series #3491–#3573): the catch-all is NOT retired — it is rehabilitated, and step (1) is absorbed.** Step (3) decomposed the dispatcher the other way around: every domain body moved to `packages/runtime/src/domains/*` (+ the `action-execution` subsystem), `dispatch()` shrank to the cross-cutting gate stages (env resolution → identity → auth gate → membership → scope strip) followed by a first-match `DomainHandlerRegistry` lookup, and the legacy if-chain reached zero domain branches. The original disease was never the catch-all itself but what it delegated INTO — an unenumerable god if-chain where "declared ≠ mounted ≠ implemented" could hide (the v16 seam bug class). Delegating into a thin gates+registry pipeline is the sane terminal shape: the route table is enumerable (`DomainHandlerRegistry.list()`), every entry has an owner module and seam tests, and the cloud fallback fabric (`/share-links` as the per-env primary surface, `/notifications` as sole owner) rides the SAME pipeline verified end-to-end by `@objectstack/http-conformance` (41 cross-adapter assertions) + the dogfood suite (351) — cloud-side `objectos-runtime` (240 tests incl. the kernel-resolver strategy) passes against the decomposed dispatcher unchanged. "Kill the dead callable branches" (1) is thereby absorbed: there are no branches left to kill, only registry entries with real owners. The optional future polish — having adapters enumerate the registry into explicit per-prefix mounts so the HTTP layer prints its own route table — would require turning the gate stages into per-route middleware; deliberately NOT scheduled (cost exceeds benefit while conformance + route-parity gates hold the line). 10. **Validate multi-adapter** — **Resolved (validated, #2462): the port is free of hard Hono-isms.** A zero-dependency `node:http` reference adapter (`@objectstack/http-conformance`, private QA gate under `packages/qa/`) runs the dispatcher bridge + REST generator unchanged; a cross-adapter conformance suite (40 assertions incl. `/data` CRUD roundtrip, `:param` routing, 404/405 semantics, SSE streaming, discovery) passes identically on both adapters. Findings: the Hono adapter's Host-header backfill is adapter-local (a Fetch-API artifact, not a port leak); all remaining Hono coupling is confined to the `getRawApp()` escape hatch (metadata HMR, cloud-connection/marketplace routes, static/SPA + CORS + Server-Timing), whose consumers feature-detect and degrade. Follow-up for D11: codify the two soft extensions consumers already rely on (`res.write`/`res.end` for SSE, `getPort()`) and 404/405 semantics into the `IHttpServer` contract. -11. **D12 stub marker** — **Resolved (#2462): standard `__serviceInfo` self-descriptor** (`ServiceSelfInfoSchema` in `spec/api/discovery.zod.ts`, read via `readServiceSelfInfo()`), with plugin-dev's legacy `_dev: true` normalized to `{ status: 'stub', handlerReady: false }`. Both discovery builders honor it; the analytics fallback self-identifies as `degraded`. +11. **D12 stub marker** — **Resolved (#2462): standard `__serviceInfo` self-descriptor** (`ServiceSelfInfoSchema` in `spec/api/discovery.zod.ts`, read via `readServiceSelfInfo()`), with plugin-dev's legacy `_dev: true` normalized to `{ status: 'stub', handlerReady: false }`. Both discovery builders honor it; the analytics fallback self-identifies as `degraded`. *(Update #4319: "standardise one" is now literally true — `readServiceSelfInfo` reads the descriptor and nothing else. The `_dev` normalization above is **gone**, and with it the last of the three markers this decision inherited. Neither retirement taught the reader a new dialect: #4082 moved the kernel fallbacks off `_fallback` and ADR-0115 retired the stub table that produced `_dev`, so by the time the branch was deleted it had no producers left in framework, objectui or cloud and fired only from test fixtures. The reason to delete rather than keep a harmless alias is that a boolean cannot say WHICH kind of unreal a service is: `stub` (fabricates — the domain refuses it) and `degraded` (really serves, reduced capability — the domain keeps serving it) is the split every consumer gates on, and `_dev: true` collapsed both into "fake", which is what made "adopt the dispatcher gate everywhere?" unanswerable until #4082 unpicked it. Cost, stated plainly: a service still carrying a retired marker now reads as unmarked, i.e. as fully real — over-reporting, the direction D12 exists to prevent — which is acceptable only because the producer count was zero. The mapping ships in the package CHANGELOG.)* diff --git a/packages/core/src/fallbacks/memory-cache.ts b/packages/core/src/fallbacks/memory-cache.ts index 15e78d0363..1a0f332418 100644 --- a/packages/core/src/fallbacks/memory-cache.ts +++ b/packages/core/src/fallbacks/memory-cache.ts @@ -10,8 +10,9 @@ * [#4058] Self-describes as `degraded`, not `stub` (ADR-0076 D12): this is a * real cache — it stores, expires, and reports true stats — just process-local * and unshared. The non-standard `_fallback: true` it used to carry was read by - * nothing (`readServiceSelfInfo` knows `__serviceInfo` and the legacy `_dev`), - * so discovery reported it as fully `available`. `handlerReady: false` because + * nothing (`readServiceSelfInfo` reads only `__serviceInfo` — `_dev`, the other + * marker it knew back then, was itself retired in #4319), so discovery reported + * it as fully `available`. `handlerReady: false` because * no HTTP surface is mounted for `cache` at all — the same reason realtime * reports false. */ diff --git a/packages/objectql/src/protocol-discovery.test.ts b/packages/objectql/src/protocol-discovery.test.ts index 1341c654ae..1b98d0835b 100644 --- a/packages/objectql/src/protocol-discovery.test.ts +++ b/packages/objectql/src/protocol-discovery.test.ts @@ -95,9 +95,9 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => // ── Honest capabilities (ADR-0076 D12, #2462) ───────────────────────────── - it('should report a _dev-marked service as a stub, never available', async () => { + it('should report a stub-marked service as a stub, never available', async () => { const mockServices = new Map(); - mockServices.set('ai', { _dev: true }); + mockServices.set('ai', { __serviceInfo: { status: 'stub', message: 'Development stub — not a production implementation' } }); protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); const discovery = await protocol.getDiscovery(); @@ -280,7 +280,7 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => // `unavailable` would. it('should not advertise the analytics route for a self-declared stub', async () => { const mockServices = new Map(); - mockServices.set('analytics', { _dev: true, query: async () => ({ rows: [], fields: [] }) }); + mockServices.set('analytics', { __serviceInfo: { status: 'stub' }, query: async () => ({ rows: [], fields: [] }) }); protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); const discovery = await protocol.getDiscovery(); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 64fc9e6199..542ec58e20 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -1300,6 +1300,9 @@ export class AppPlugin implements Plugin { // the two ad-hoc markers this branch knew about (`_fallback` / `_dev`). // `_fallback` was recognized by nothing else — which is exactly how the // kernel fallbacks carrying it ended up reported as fully `available`. + // Both ad-hoc markers are gone now (#4082 moved their producers onto + // the descriptor; #4319 retired the last `_dev` reader), so this is the + // only spelling left to read. if (readServiceSelfInfo(i18nService)) { ctx.logger.info( `[i18n] Loaded ${loadedLocales} locale(s) into in-memory i18n fallback for "${appId}". ` + diff --git a/packages/runtime/src/dispatcher-plugin.routes.test.ts b/packages/runtime/src/dispatcher-plugin.routes.test.ts index c5f587f836..31a9185431 100644 --- a/packages/runtime/src/dispatcher-plugin.routes.test.ts +++ b/packages/runtime/src/dispatcher-plugin.routes.test.ts @@ -164,7 +164,7 @@ describe('createDispatcherPlugin — HTTP route registration', () => { const { server, routes } = makeFakeServer(); const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); await plugin.start?.(ctxWithServices(server, { - analytics: { _dev: true, query: async () => ({ rows: [], fields: [] }) }, + analytics: { __serviceInfo: { status: 'stub' }, query: async () => ({ rows: [], fields: [] }) }, })); for (const r of ANALYTICS_ROUTES) expect(routes).not.toContain(r); diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index eafebb7978..1b80da7f7f 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -682,7 +682,7 @@ describe('HttpDispatcher', () => { // (retired with this change). A stub slot is an empty slot. it('returns unhandled when the analytics slot holds a self-declared stub, without calling it', async () => { const stub = { - _dev: true, + __serviceInfo: { status: 'stub' }, query: vi.fn().mockResolvedValue({ rows: [], fields: [] }), getMeta: vi.fn().mockResolvedValue([]), generateSql: vi.fn().mockResolvedValue({ sql: '', params: [] }), @@ -2466,9 +2466,9 @@ describe('HttpDispatcher', () => { expect(info.services.realtime.status).toBe('unavailable'); }); - it('reports a _dev-marked stub service as stub, never available', async () => { + it('reports a stub-marked service as stub, never available', async () => { (kernel as any).getService = vi.fn().mockImplementation((name: string) => { - if (name === 'ai') return { _dev: true, chat: vi.fn() }; + if (name === 'ai') return { __serviceInfo: { status: 'stub', message: 'Development stub — not a production implementation' }, chat: vi.fn() }; return null; }); @@ -2504,7 +2504,7 @@ describe('HttpDispatcher', () => { // `handlerReady: false` says more than `unavailable` would. it('stops advertising the analytics route for a stub, while still reporting it as a stub', async () => { (kernel as any).getService = vi.fn().mockImplementation((name: string) => - name === 'analytics' ? { _dev: true, query: vi.fn() } : null, + name === 'analytics' ? { __serviceInfo: { status: 'stub' }, query: vi.fn() } : null, ); const info = await dispatcher.getDiscoveryInfo('/api/v1'); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index b1e27a4194..05b492879c 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1006,10 +1006,10 @@ export class HttpDispatcher { // // 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. + // `__serviceInfo` marker — the one marker left since #4319 retired the + // legacy `_dev` alias) 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) { diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 7510cba68d..345c324a51 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -2984,7 +2984,6 @@ "RouteHealthReportSchema (const)", "RouterConfig (type)", "RouterConfigSchema (const)", - "SERVICE_DEV_MARKER_KEY (const)", "SERVICE_SELF_INFO_KEY (const)", "SaveMetaItemRequest (type)", "SaveMetaItemRequestSchema (const)", diff --git a/packages/spec/src/api/discovery.test.ts b/packages/spec/src/api/discovery.test.ts index 9d918f9459..4e6165fd28 100644 --- a/packages/spec/src/api/discovery.test.ts +++ b/packages/spec/src/api/discovery.test.ts @@ -918,16 +918,21 @@ describe('Service self-description marker (ADR-0076 D12, #2462)', () => { 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'); + // The retired markers read as unmarked — i.e. as fully real. That is the + // deliberate cost of collapsing three dialects into one (#4319): a service + // still carrying `_dev` / `_fallback` is now OVER-reported, not under-. It is + // acceptable only because both had zero producers left when they went (the + // `_fallback` case never worked at all — nothing ever read it, which is how + // the kernel fallbacks came to be reported `available` in the first place), + // and because keeping a boolean alive cannot express the `stub` / `degraded` + // split that every consumer gates on. + it('readServiceSelfInfo does not recognize the retired _dev / _fallback markers', () => { + expect(readServiceSelfInfo({ _dev: true, chat: () => 'fake' })).toBeUndefined(); + expect(readServiceSelfInfo({ _fallback: true, get: () => undefined })).toBeUndefined(); }); 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 a88d53b217..a926fef9e2 100644 --- a/packages/spec/src/api/discovery.zod.ts +++ b/packages/spec/src/api/discovery.zod.ts @@ -85,13 +85,6 @@ export const ServiceInfoSchema = lazySchema(() => z.object({ */ 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: @@ -121,10 +114,20 @@ export type ServiceSelfInfo = z.infer; * 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 }`. + * Reads exactly one marker: `svc[SERVICE_SELF_INFO_KEY]`, the + * `{ status, handlerReady?, message? }` descriptor. + * + * There were once three. `_fallback: true` (the kernel's in-memory fallbacks) + * was recognized by nothing, so discovery reported those as fully `available` + * — the honesty gap D12 exists to close; `_dev: true` (plugin-dev's stub + * table) was normalized here to `{ status: 'stub', handlerReady: false }`. + * Both were retired by moving their producers onto this descriptor rather than + * by teaching this function more dialects (#4082, ADR-0115): one marker that + * says WHICH kind of unreal a service is beats N markers that only say "unreal" + * — `degraded` (really serves, reduced capability) and `stub` (fabricates) are + * the distinction every consumer actually gates on, and a boolean cannot carry + * it. A service still carrying a retired marker reads as unmarked here, i.e. + * as fully real — migrate it (see the CHANGELOG entry for the mapping). */ export function readServiceSelfInfo(svc: unknown): ServiceSelfInfo | undefined { if (!svc || typeof svc !== 'object') return undefined; @@ -138,13 +141,6 @@ export function readServiceSelfInfo(svc: unknown): ServiceSelfInfo | undefined { ...(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; }