From e1147fdfea20234eebf86420128898b305d89d63 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 08:48:40 +0000 Subject: [PATCH] fix(core,plugin-dev,runtime): every in-memory fallback and dev stub self-describes honestly (#4058 step 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0076 D12 gave services one standard way to say "I am not the real thing" (`__serviceInfo`), but the producers never converged on it, and the result was wrong in both directions: - The kernel's OWN fallbacks (createMemoryCache/Queue/Job/I18n/Metadata) carried `_fallback: true` — a marker NO consumer recognized, `readServiceSelfInfo` included — so both discovery builders reported them as fully `available`. They were absent from D12's inventory and were its worst case. - plugin-dev marked every implementation with the same `_dev: true`, normalized to `{ status: 'stub', handlerReady: false }`. That declared a working in-memory search index exactly as fake as an AI stub returning invented text. The single marker is why #4058's real question — should the dispatcher gate other domains on `handlerReady`, as #4000 did for analytics? — could not be answered: the two kinds of implementation in there were indistinguishable. This change makes them distinguishable, and changes nothing else. The classification, now one reviewable table (`DEV_STUB_SELF_INFO`): - `degraded` — really does the work, with reduced capability. `file-storage` really stores and returns the bytes, `search` really indexes and matches, `metadata`/`workflow` really record and read back, `realtime` really delivers in-process, and the wrapped kernel fallbacks describe themselves. Their answers are true answers; each `message` names what is missing — including the ones that are easy to miss (memory-job's `schedule()` never fires: no timer; the workflow stub validates no state machine). - `stub` — the answer is fabricated: `ai` (invented text), `automation` (success without running the flow), `notification` (claims delivery, delivers nothing), `data` (accepts writes, stores none), `auth` (waves everyone through as an admin), `security.*` (allow-all). `handlerReady: false` answers the separate question — no HTTP handler serves this — and is set for every `stub` plus the surfaceless `degraded` ones (`cache` / `queue` / `job` / `realtime`, the D12 realtime precedent). Also: the one duck-typed consumer of the ad-hoc markers (an i18n diagnostic in app-plugin) now reads `readServiceSelfInfo`, and plugin-dev never overwrites a self-description an implementation already carries — a wrapped kernel fallback knows better than this plugin what it is. No routing, gating, or dispatch behavior changes: every dispatcher domain resolves services exactly as before. Discovery output does change, which is the point — a kernel fallback that claimed `available` now reports `degraded` and says why. `_dev` stays understood by `readServiceSelfInfo`, so third-party stubs carrying it keep working. #4058 step 2 (which domains adopt the `handlerReady` gate) stays open, now with the two classes separable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UvcnzFr5XBuvvAd7mosG54 --- .changeset/honest-service-self-description.md | 23 ++++ content/docs/kernel/services.mdx | 29 ++++- docs/adr/0076-objectql-core-tiering.md | 3 +- packages/core/src/fallbacks/fallbacks.test.ts | 51 ++++++-- packages/core/src/fallbacks/memory-cache.ts | 15 ++- packages/core/src/fallbacks/memory-i18n.ts | 11 +- packages/core/src/fallbacks/memory-job.ts | 13 +- .../core/src/fallbacks/memory-metadata.ts | 10 +- packages/core/src/fallbacks/memory-queue.ts | 12 +- .../objectql/src/plugin.integration.test.ts | 7 +- .../plugins/plugin-dev/src/dev-plugin.test.ts | 77 +++++++++++- packages/plugins/plugin-dev/src/dev-plugin.ts | 119 ++++++++++++++---- packages/runtime/src/app-plugin.ts | 10 +- 13 files changed, 335 insertions(+), 45 deletions(-) create mode 100644 .changeset/honest-service-self-description.md diff --git a/.changeset/honest-service-self-description.md b/.changeset/honest-service-self-description.md new file mode 100644 index 0000000000..11395f5047 --- /dev/null +++ b/.changeset/honest-service-self-description.md @@ -0,0 +1,23 @@ +--- +'@objectstack/core': patch +'@objectstack/plugin-dev': patch +'@objectstack/runtime': patch +--- + +Every in-memory fallback and dev stub now self-describes with the standard `__serviceInfo` descriptor, classified by what it actually is (#4058 step 1). + +ADR-0076 D12 gave services one way to say "I am not the real thing", but the producers never converged on it: + +- The kernel's own fallbacks (`createMemoryCache` / `Queue` / `Job` / `I18n` / `Metadata`) carried `_fallback: true` — a marker **no** consumer recognized, `readServiceSelfInfo` included — so both discovery builders reported them as fully `available`. +- `plugin-dev` marked all of its implementations with the same `_dev: true`, normalized to `status: 'stub', handlerReady: false`. That declared a working in-memory search index exactly as fake as an AI stub returning invented text. + +Both now carry `__serviceInfo`, split by a rule that holds across the whole set: + +- **`degraded`** — really does the work, with reduced capability: `cache`, `queue`, `job`, `file-storage`, `search`, `i18n`, `metadata`, `workflow`, `realtime`. Its answers are true answers; the `message` names what is missing (no persistence, no scheduling timer, no state-machine validation, …). +- **`stub`** — the answer is fabricated: `ai`, `automation`, `notification`, `data`, `auth`, `security.permissions`, `security.rls`, `security.fieldMasker`. Never to be mistaken for a capability. + +`handlerReady: false` is set independently wherever no HTTP handler serves the slot (`cache` / `queue` / `job` / `realtime`, and every `stub`). + +Discovery output changes accordingly — a kernel fallback that used to report `status: 'available'` now reports `degraded` with an explanatory message. No routing, gating, or dispatch behavior changes: every dispatcher domain still resolves services exactly as before. Consumers reading `discovery.services.*` get the truth instead of a uniform claim. + +For anything that duck-typed the old markers: `svc._fallback` / `svc._dev` → `readServiceSelfInfo(svc)` from `@objectstack/spec/api` (the legacy `_dev` key is still understood by that reader, so third-party stubs carrying it keep working). diff --git a/content/docs/kernel/services.mdx b/content/docs/kernel/services.mdx index 7c47c220fb..822e9c9d9c 100644 --- a/content/docs/kernel/services.mdx +++ b/content/docs/kernel/services.mdx @@ -73,12 +73,35 @@ const users = await db.find('user', { where: { active: true } }); check the registry map with `ctx.getServices()`: ```typescript -if (ctx.getServices().has('analytics')) { - const analytics = ctx.getService('analytics'); - analytics.track('page_view', { url: '/dashboard' }); +if (ctx.getServices().has('search')) { + const search = ctx.getService('search'); + await search.index('account', record.id, record); } ``` +**Presence is not capability.** A registered service may be a stub or a degraded +fallback — dev-mode fakes and the kernel's own in-memory fallbacks both occupy +real slots. They say so with the `__serviceInfo` descriptor (ADR-0076 D12), and +consumers are expected to read it rather than trust mere registration: + +```typescript +import { readServiceSelfInfo } from '@objectstack/spec/api'; + +const ai = ctx.getServices().get('ai'); +const self = readServiceSelfInfo(ai); // undefined ⇒ a real implementation + +if (ai && self?.status !== 'stub') { + // Safe: a real implementation, or a `degraded` one that genuinely works. +} else if (self) { + ctx.logger.info(`ai is a stub — ${self.message}`); +} +``` + +`status: 'degraded'` means real work with reduced capability (the in-memory +cache, the in-memory search index); `status: 'stub'` means the answer is +fabricated and must not be used for real work. `handlerReady: false` +additionally means no HTTP handler serves it. + ## Standard Services The core ecosystem defines several standard service contracts: diff --git a/docs/adr/0076-objectql-core-tiering.md b/docs/adr/0076-objectql-core-tiering.md index c8228a5ca1..5dca62d51f 100644 --- a/docs/adr/0076-objectql-core-tiering.md +++ b/docs/adr/0076-objectql-core-tiering.md @@ -132,9 +132,10 @@ Decision: each capability plugin registers its routes as a **normalized handler* **Root cause of agents being misled.** Several plugins register stub / dev / fallback services under canonical names, and the discovery builder reports *any* present service as fully real: `runtime/http-dispatcher.ts`'s `svcAvailable` hardcodes `{ enabled: true, status: 'available', handlerReady: true }` for every registered service — it **ignores stub markers** (its own comment even says "handlerReady:false … may be served by a stub", but the code never computes it). So `discovery.services.*` claims capabilities that are only stubbed, and consumers (AI agents, the console) trust them. A dev AI stub advertised this way has already confused an agent. **Inventory of current fakes / mis-reports:** -- `plugin-dev` registers ~8 dev stubs — `storage` / `search` / `automation` / `graphql` / `analytics` / `realtime` / `notification` / `ai` (they already carry a `_dev: true` marker that nothing respects). *(Update #4000: the `analytics` one is **gone** — after the fallback's retirement (#3891/#3989) it was the last thing refilling that slot, and refilling it re-created the retired shape in dev: the dispatcher gated on service presence, so the stub was called like an engine and answered 200 with fabricated rows. The remaining stubs are unchanged, and the class-wide question they raise is tracked separately — see conclusion 3 below.)* +- `plugin-dev` registers ~8 dev stubs — `storage` / `search` / `automation` / `graphql` / `analytics` / `realtime` / `notification` / `ai` (they already carry a `_dev: true` marker that nothing respects). *(Update #4000: the `analytics` one is **gone** — after the fallback's retirement (#3891/#3989) it was the last thing refilling that slot, and refilling it re-created the retired shape in dev: the dispatcher gated on service presence, so the stub was called like an engine and answered 200 with fabricated rows. The remaining stubs are unchanged, and the class-wide question they raise is tracked separately — see conclusion 3 below.)* *(Update #4058 step 1: the blanket `_dev: true` is **gone** too. Every dev implementation now carries the standard `__serviceInfo`, classified in one reviewable table (`DEV_STUB_SELF_INFO`) by what it actually is — `degraded` for the ones that really work with reduced capability (`file-storage` / `search` / `metadata` / `workflow` / `realtime`, plus the wrapped kernel fallbacks), `stub` for the ones whose answer is fabricated (`ai` / `automation` / `notification` / `data` / `auth` / `security.*`). The single marker made those two indistinguishable and declared the working ones fake, which is why "adopt the dispatcher gate everywhere?" could not be answered. The gates themselves are untouched — that is #4058 step 2.)* - `ObjectQLPlugin` registers a ~66-line `analytics` **fallback** (the D10 note — deliberate, but it reports as fully available). - `http-dispatcher.ts` `svcAvailable` — the hardcode above. +- *(Added by #4058: the **kernel's own** fallbacks — `createMemoryCache` / `Queue` / `Job` / `I18n` / `Metadata`, auto-registered by ObjectKernel — were missing from this inventory and were the worst case in it: they carried `_fallback: true`, a marker **no** reader recognized (not even `readServiceSelfInfo`), so both discovery builders reported them as fully `available`. They now self-describe as `degraded` with `handlerReady: false` where no HTTP surface exists (`cache` / `queue` / `job`). The lone duck-typed consumer of `_fallback`/`_dev` — an i18n diagnostic in `app-plugin.ts` — reads `readServiceSelfInfo` instead.)* **Decision — honest capabilities:** 1. A registered service that is a stub / dev / fallback MUST self-identify with a **standard marker** (standardise one; `_dev` is the existing precedent). diff --git a/packages/core/src/fallbacks/fallbacks.test.ts b/packages/core/src/fallbacks/fallbacks.test.ts index 8d21f619d6..efffdc30a6 100644 --- a/packages/core/src/fallbacks/fallbacks.test.ts +++ b/packages/core/src/fallbacks/fallbacks.test.ts @@ -4,12 +4,27 @@ import { createMemoryQueue } from './memory-queue'; import { createMemoryJob } from './memory-job'; import { createMemoryI18n, resolveLocale } from './memory-i18n'; import { CORE_FALLBACK_FACTORIES } from './index'; +import { readServiceSelfInfo } from '@objectstack/spec/api'; describe('CORE_FALLBACK_FACTORIES', () => { it('should have exactly 5 entries: metadata, cache, queue, job, i18n', () => { expect(Object.keys(CORE_FALLBACK_FACTORIES)).toEqual(['metadata', 'cache', 'queue', 'job', 'i18n']); }); + // [#4058] Every kernel fallback must be readable through the ONE standard + // descriptor. They used to carry `_fallback: true`, which `readServiceSelfInfo` + // does not recognize — so each of them was reported as a fully available + // service by both discovery builders. + it('every fallback self-describes via the standard D12 descriptor', () => { + for (const [name, factory] of Object.entries(CORE_FALLBACK_FACTORIES)) { + const info = readServiceSelfInfo(factory()); + expect(info, `${name} must carry __serviceInfo`).toBeDefined(); + // These are real implementations with reduced capability — never fakes. + expect(info?.status, `${name} status`).toBe('degraded'); + expect(info?.message, `${name} message`).toBeTruthy(); + } + }); + it('should map to factory functions', () => { for (const factory of Object.values(CORE_FALLBACK_FACTORIES)) { expect(typeof factory).toBe('function'); @@ -18,9 +33,14 @@ describe('CORE_FALLBACK_FACTORIES', () => { }); describe('createMemoryCache', () => { - it('should return an object with _fallback: true', () => { + // [#4058] Self-describes with the STANDARD D12 descriptor. The old + // `_fallback: true` marker was read by nothing, so discovery reported + // this as fully `available` — the honesty gap D12 exists to close. + it('self-describes as a degraded implementation, not a stub', () => { const cache = createMemoryCache(); - expect(cache._fallback).toBe(true); + expect(readServiceSelfInfo(cache)?.status).toBe('degraded'); + expect(readServiceSelfInfo(cache)?.handlerReady).toBe(false); // no HTTP surface for cache + expect(readServiceSelfInfo(cache)?.message).toBeTruthy(); expect(cache._serviceName).toBe('cache'); }); @@ -80,9 +100,14 @@ describe('createMemoryCache', () => { }); describe('createMemoryQueue', () => { - it('should return an object with _fallback: true', () => { + // [#4058] Self-describes with the STANDARD D12 descriptor. The old + // `_fallback: true` marker was read by nothing, so discovery reported + // this as fully `available` — the honesty gap D12 exists to close. + it('self-describes as a degraded implementation, not a stub', () => { const queue = createMemoryQueue(); - expect(queue._fallback).toBe(true); + expect(readServiceSelfInfo(queue)?.status).toBe('degraded'); + expect(readServiceSelfInfo(queue)?.handlerReady).toBe(false); // no HTTP surface for queue + expect(readServiceSelfInfo(queue)?.message).toBeTruthy(); expect(queue._serviceName).toBe('queue'); }); @@ -121,9 +146,14 @@ describe('createMemoryQueue', () => { }); describe('createMemoryJob', () => { - it('should return an object with _fallback: true', () => { + // [#4058] Self-describes with the STANDARD D12 descriptor. The old + // `_fallback: true` marker was read by nothing, so discovery reported + // this as fully `available` — the honesty gap D12 exists to close. + it('self-describes as a degraded implementation, not a stub', () => { const job = createMemoryJob(); - expect(job._fallback).toBe(true); + expect(readServiceSelfInfo(job)?.status).toBe('degraded'); + expect(readServiceSelfInfo(job)?.handlerReady).toBe(false); // no HTTP surface for job + expect(readServiceSelfInfo(job)?.message).toBeTruthy(); expect(job._serviceName).toBe('job'); }); @@ -159,9 +189,14 @@ describe('createMemoryJob', () => { }); describe('createMemoryI18n', () => { - it('should return an object with _fallback: true', () => { + // [#4058] Self-describes with the STANDARD D12 descriptor. The old + // `_fallback: true` marker was read by nothing, so discovery reported + // this as fully `available` — the honesty gap D12 exists to close. + it('self-describes as a degraded implementation, not a stub', () => { const i18n = createMemoryI18n(); - expect(i18n._fallback).toBe(true); + expect(readServiceSelfInfo(i18n)?.status).toBe('degraded'); + expect(readServiceSelfInfo(i18n)?.handlerReady).toBe(true); // the dispatcher's /i18n domain serves it + expect(readServiceSelfInfo(i18n)?.message).toBeTruthy(); expect(i18n._serviceName).toBe('i18n'); }); diff --git a/packages/core/src/fallbacks/memory-cache.ts b/packages/core/src/fallbacks/memory-cache.ts index 5d86118432..15e78d0363 100644 --- a/packages/core/src/fallbacks/memory-cache.ts +++ b/packages/core/src/fallbacks/memory-cache.ts @@ -6,13 +6,26 @@ * Implements the ICacheService contract with basic get/set/delete/has/clear * and TTL expiry. Used by ObjectKernel as an automatic fallback when no * real cache plugin (e.g. Redis) is registered. + * + * [#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 + * no HTTP surface is mounted for `cache` at all — the same reason realtime + * reports false. */ export function createMemoryCache() { const store = new Map(); let hits = 0; let misses = 0; return { - _fallback: true, _serviceName: 'cache', + __serviceInfo: { + status: 'degraded' as const, + handlerReady: false, + message: 'In-process Map cache — not shared across instances, lost on restart. Register a cache plugin (e.g. Redis) for a real one.', + }, + _serviceName: 'cache', async get(key: string): Promise { const entry = store.get(key); if (!entry || (entry.expires && Date.now() > entry.expires)) { diff --git a/packages/core/src/fallbacks/memory-i18n.ts b/packages/core/src/fallbacks/memory-i18n.ts index 7214becf9f..b5e3e43e4a 100644 --- a/packages/core/src/fallbacks/memory-i18n.ts +++ b/packages/core/src/fallbacks/memory-i18n.ts @@ -123,7 +123,16 @@ export function createMemoryI18n() { } return { - _fallback: true, _serviceName: 'i18n', + // [#4058] `degraded` (ADR-0076 D12): translations, locale fallback and + // interpolation are all real — what is missing is persistence and the + // authoring surface service-i18n adds. `handlerReady` left at the + // `degraded` default (true): the dispatcher's `/i18n` domain does serve + // this implementation. + __serviceInfo: { + status: 'degraded' as const, + message: 'In-memory translations — real lookup and locale fallback, but nothing is persisted. Register I18nServicePlugin from @objectstack/service-i18n for the full implementation.', + }, + _serviceName: 'i18n', t(key: string, locale: string, params?: Record): string { const data = resolveTranslations(locale) ?? mergedLocale(defaultLocale); diff --git a/packages/core/src/fallbacks/memory-job.ts b/packages/core/src/fallbacks/memory-job.ts index 007e14b4df..b97da058b4 100644 --- a/packages/core/src/fallbacks/memory-job.ts +++ b/packages/core/src/fallbacks/memory-job.ts @@ -6,11 +6,22 @@ * Implements the IJobService contract with basic schedule/cancel/trigger * operations. Used by ObjectKernel as an automatic fallback when no real * job plugin (e.g. Agenda / BullMQ) is registered. + * + * [#4058] `degraded` (ADR-0076 D12), with the missing half named in the + * message rather than left for a deployer to discover: `trigger()` really runs + * the registered handler, but nothing here owns a timer, so a `schedule()`d job + * NEVER fires on its own. That is reduced capability, not fabricated output — + * no call returns a made-up answer. `handlerReady: false`: no HTTP surface. */ export function createMemoryJob() { const jobs = new Map(); return { - _fallback: true, _serviceName: 'job', + __serviceInfo: { + status: 'degraded' as const, + handlerReady: false, + message: 'In-process job registry — trigger() runs handlers, but scheduled jobs never fire on their own (no timer). Register a job plugin (e.g. Agenda) for real scheduling.', + }, + _serviceName: 'job', async schedule(name: string, schedule: any, handler: any): Promise { jobs.set(name, { schedule, handler }); }, async cancel(name: string): Promise { jobs.delete(name); }, async trigger(name: string, data?: unknown): Promise { diff --git a/packages/core/src/fallbacks/memory-metadata.ts b/packages/core/src/fallbacks/memory-metadata.ts index 1d0fcdb0aa..d2f68155b2 100644 --- a/packages/core/src/fallbacks/memory-metadata.ts +++ b/packages/core/src/fallbacks/memory-metadata.ts @@ -21,7 +21,15 @@ export function createMemoryMetadata() { } return { - _fallback: true, _serviceName: 'metadata', + // [#4058] `degraded` (ADR-0076 D12): the registry is real — everything + // registered is listable and readable back — it simply never reaches disk + // or a database. `handlerReady` keeps the `degraded` default (true): the + // dispatcher's `/meta` domain serves this implementation. + __serviceInfo: { + status: 'degraded' as const, + message: 'In-memory metadata registry — real reads and writes, no persistence (lost on restart). Register MetadataPlugin for a persisted registry.', + }, + _serviceName: 'metadata', async register(type: string, name: string, data: any): Promise { getTypeMap(type).set(name, data); }, diff --git a/packages/core/src/fallbacks/memory-queue.ts b/packages/core/src/fallbacks/memory-queue.ts index 1e35556f51..a907aa1914 100644 --- a/packages/core/src/fallbacks/memory-queue.ts +++ b/packages/core/src/fallbacks/memory-queue.ts @@ -6,12 +6,22 @@ * Implements the IQueueService contract with synchronous in-process delivery. * Used by ObjectKernel as an automatic fallback when no real queue plugin * (e.g. BullMQ / RabbitMQ) is registered. + * + * [#4058] `degraded`, not `stub` (ADR-0076 D12): messages really reach real + * subscribers — synchronously, in-process, with no durability or retry. + * `getQueueSize()` answering 0 follows from that rather than faking it: nothing + * is ever buffered. `handlerReady: false` — no HTTP surface exists for `queue`. */ export function createMemoryQueue() { const handlers = new Map(); let msgId = 0; return { - _fallback: true, _serviceName: 'queue', + __serviceInfo: { + status: 'degraded' as const, + handlerReady: false, + message: 'Synchronous in-process delivery — no durability, retry, or cross-instance fan-out. Register a queue plugin (e.g. BullMQ) for a real one.', + }, + _serviceName: 'queue', async publish(queue: string, data: T): Promise { const id = `fallback-msg-${++msgId}`; const fns = handlers.get(queue) ?? []; diff --git a/packages/objectql/src/plugin.integration.test.ts b/packages/objectql/src/plugin.integration.test.ts index 4484d0bd48..4bad4fe805 100644 --- a/packages/objectql/src/plugin.integration.test.ts +++ b/packages/objectql/src/plugin.integration.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ObjectKernel } from '@objectstack/core'; import { ObjectQLPlugin } from '../src/plugin'; import { ObjectSchema } from '@objectstack/spec/data'; +import { readServiceSelfInfo } from '@objectstack/spec/api'; describe('ObjectQLPlugin - Metadata Service Integration', () => { let kernel: ObjectKernel; @@ -26,10 +27,12 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { expect(objectql).toBeDefined(); expect(kernel.getService('data')).toBeDefined(); expect(kernel.getService('protocol')).toBeDefined(); - // metadata is provided by kernel's core fallback, not ObjectQL + // metadata is provided by kernel's core fallback, not ObjectQL — + // identified by its standard D12 self-description (#4058; it used to + // carry a non-standard `_fallback: true` no consumer recognized). const metadataService = kernel.getService('metadata'); expect(metadataService).toBeDefined(); - expect((metadataService as any)._fallback).toBe(true); + expect(readServiceSelfInfo(metadataService)?.status).toBe('degraded'); }); it('should serve in-memory metadata definitions', async () => { diff --git a/packages/plugins/plugin-dev/src/dev-plugin.test.ts b/packages/plugins/plugin-dev/src/dev-plugin.test.ts index 72d9e39c27..5fe4a28a81 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.test.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; +import { readServiceSelfInfo } from '@objectstack/spec/api'; import { DevPlugin } from './dev-plugin'; // #3060: init()'s graceful-degradation path dynamically imports ~10 real @@ -114,7 +115,10 @@ describe('DevPlugin', () => { // ── Verify ICacheService contract ── const cache = registeredServices.get('cache'); - expect(cache._dev).toBe(true); + // [#4058] The wrapped kernel fallback keeps its OWN self-description — + // plugin-dev never overwrites one, and `createMemoryCache` knows better + // than this plugin what it is (a real cache, process-local). + expect(readServiceSelfInfo(cache)?.status).toBe('degraded'); await cache.set('k1', 'v1'); expect(await cache.get('k1')).toBe('v1'); expect(await cache.has('k1')).toBe(true); @@ -214,6 +218,77 @@ describe('DevPlugin', () => { // The stubs follow the same contracts as the real implementations. }); + // [#4058] The classification is the deliverable, so it is pinned here rather + // than left to a code review of the table. Every dev implementation must say + // what it IS: `degraded` when it really does the work with reduced capability, + // `stub` when the answer is fabricated. One blanket `_dev: true` used to + // declare all of them equally fake, which is why the two could not be told + // apart — and why a consumer had no way to distinguish "search works here" + // from "this AI reply is invented". + it('every dev implementation self-describes honestly (degraded vs stub)', async () => { + const registeredServices = new Map(); + const ctx: any = { + logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getService: vi.fn().mockImplementation((name: string) => { + if (registeredServices.has(name)) return registeredServices.get(name); + throw new Error('not found'); + }), + getServices: vi.fn().mockReturnValue(new Map()), + registerService: vi.fn().mockImplementation((name: string, svc: any) => { + registeredServices.set(name, svc); + }), + hook: vi.fn(), + trigger: vi.fn(), + getKernel: vi.fn(), + }; + + // Only the plugin-loading toggles are turned off — `auth` and `security` + // stay ON so their stub slots are exercised too (the real plugins behind + // them are mocked away as missing at the top of this file, so init falls + // through to the stubs exactly as a dev boot without them would). + await new DevPlugin({ + seedAdminUser: false, + services: { + objectql: false, driver: false, setup: false, + server: false, rest: false, dispatcher: false, + }, + }).init(ctx); + + // Really do the work, just less of it — their answers are true answers. + const DEGRADED = ['cache', 'queue', 'job', 'file-storage', 'search', 'realtime', 'i18n', 'workflow', 'metadata']; + // Fabricate the answer — must never be mistaken for a capability. + const STUB = ['automation', 'notification', 'ai', 'data', 'auth', + 'security.permissions', 'security.rls', 'security.fieldMasker']; + + for (const name of DEGRADED) { + const info = readServiceSelfInfo(registeredServices.get(name)); + expect(info?.status, `${name} must be degraded`).toBe('degraded'); + expect(info?.message, `${name} must explain what is missing`).toBeTruthy(); + } + for (const name of STUB) { + const info = readServiceSelfInfo(registeredServices.get(name)); + expect(info?.status, `${name} must be stub`).toBe('stub'); + // A fabricated answer is never served by a ready handler. + expect(info?.handlerReady, `${name} handlerReady`).toBe(false); + expect(info?.message, `${name} must say what it fakes`).toBeTruthy(); + } + + // No HTTP/WS surface is mounted for these, so no handler can be ready — + // independent of the implementation being real (ADR-0076 D12, realtime). + for (const name of ['cache', 'queue', 'job', 'realtime']) { + expect(readServiceSelfInfo(registeredServices.get(name))?.handlerReady, `${name} handlerReady`).toBe(false); + } + + // `ui` has no factory at all — the shapeless placeholder must still be + // honest about being nothing. + const ui = readServiceSelfInfo(registeredServices.get('ui')); + expect(ui?.status).toBe('stub'); + expect(ui?.handlerReady).toBe(false); + + // The retired analytics slot stays empty (#4000). + expect(registeredServices.has('analytics')).toBe(false); + }); + it('should skip disabled services', async () => { const ctx: any = { logger: { diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index 57d88d9781..9e466f2a3e 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -2,6 +2,7 @@ import { Plugin, PluginContext, createMemoryCache, createMemoryQueue, createMemoryJob, createMemoryI18n } from '@objectstack/core'; import { resolveMultiOrgEnabled } from '@objectstack/types'; +import { SERVICE_SELF_INFO_KEY } from '@objectstack/spec/api'; /** * All 17 core kernel service names as defined in CoreServiceName. @@ -38,7 +39,7 @@ const SECURITY_SERVICE_NAMES = [ function createStorageStub() { const files = new Map(); return { - _dev: true, _serviceName: 'file-storage', + _serviceName: 'file-storage', async upload(key: string, data: any, options?: any): Promise { files.set(key, { data: Buffer.from(data), meta: { contentType: options?.contentType, metadata: options?.metadata } }); }, @@ -60,7 +61,7 @@ function createStorageStub() { function createSearchStub() { const indexes = new Map>>(); return { - _dev: true, _serviceName: 'search', + _serviceName: 'search', async index(object: string, id: string, document: Record): Promise { if (!indexes.has(object)) indexes.set(object, new Map()); indexes.get(object)!.set(id, document); @@ -88,7 +89,7 @@ function createSearchStub() { function createAutomationStub() { const flows = new Map(); return { - _dev: true, _serviceName: 'automation', + _serviceName: 'automation', async execute(_flowName: string) { return { success: true, output: undefined, durationMs: 0 }; }, async listFlows(): Promise { return [...flows.keys()]; }, registerFlow(name: string, definition: unknown) { flows.set(name, definition); }, @@ -102,7 +103,7 @@ function createRealtimeStub() { const subs = new Map(); let subId = 0; return { - _dev: true, _serviceName: 'realtime', + _serviceName: 'realtime', async publish(event: any): Promise { for (const fn of subs.values()) fn(event); }, async subscribe(_channel: string, handler: Function): Promise { const id = `dev-sub-${++subId}`; subs.set(id, handler); return id; @@ -115,7 +116,7 @@ function createRealtimeStub() { function createNotificationStub() { const sent: any[] = []; return { - _dev: true, _serviceName: 'notification', + _serviceName: 'notification', async send(message: any) { sent.push(message); return { success: true, messageId: `dev-notif-${sent.length}` }; }, async sendBatch(messages: any[]) { return messages.map(m => { sent.push(m); return { success: true, messageId: `dev-notif-${sent.length}` }; }); }, getChannels() { return ['email', 'in-app'] as const; }, @@ -125,7 +126,7 @@ function createNotificationStub() { /** IAIService — dev stub returning placeholder responses */ function createAIStub() { return { - _dev: true, _serviceName: 'ai', + _serviceName: 'ai', async chat() { return { content: '[dev-stub] AI not available in development mode', model: 'dev-stub' }; }, async complete() { return { content: '[dev-stub] AI not available in development mode', model: 'dev-stub' }; }, async embed() { return [[0]]; }, @@ -138,7 +139,7 @@ function createI18nStub() { const base = createMemoryI18n(); return { ...base, - _dev: true, _serviceName: 'i18n', + _serviceName: 'i18n', }; } @@ -147,7 +148,7 @@ function createWorkflowStub() { const states = new Map(); // recordKey → currentState const key = (obj: string, id: string) => `${obj}:${id}`; return { - _dev: true, _serviceName: 'workflow', + _serviceName: 'workflow', async transition(t: any) { states.set(key(t.object, t.recordId), t.targetState); return { success: true, currentState: t.targetState }; @@ -163,7 +164,7 @@ function createWorkflowStub() { function createMetadataStub() { const store = new Map>(); // type → (name → def) return { - _dev: true, _serviceName: 'metadata', + _serviceName: 'metadata', register(type: string, nameOrDef: string | Record, data?: unknown) { if (!store.has(type)) store.set(type, new Map()); if (typeof nameOrDef === 'object' && nameOrDef !== null) { @@ -199,7 +200,7 @@ function createMetadataStub() { /** IAuthService — dev auth stub returning success for all */ function createAuthStub() { return { - _dev: true, _serviceName: 'auth', + _serviceName: 'auth', async handleRequest() { return new Response(JSON.stringify({ success: true }), { status: 200 }); }, async verify() { return { success: true, user: { id: 'dev-admin', email: 'admin@dev.local', name: 'Admin', roles: ['admin'] } }; }, async logout() {}, @@ -210,7 +211,7 @@ function createAuthStub() { /** IDataEngine — minimal no-op data stub (fallback) */ function createDataStub() { return { - _dev: true, _serviceName: 'data', + _serviceName: 'data', async find() { return []; }, async findOne() { return undefined; }, async insert(_obj: string, params: any) { return { id: `dev-${Date.now()}`, ...params?.data }; }, @@ -224,7 +225,7 @@ function createDataStub() { /** Security sub-service stubs (PermissionEvaluator, RLSCompiler, FieldMasker) */ function createSecurityPermissionsStub() { return { - _dev: true, _serviceName: 'security.permissions', + _serviceName: 'security.permissions', resolvePermissionSets() { return []; }, checkObjectPermission() { return true; }, getFieldPermissions() { return {}; }, @@ -232,14 +233,14 @@ function createSecurityPermissionsStub() { } function createSecurityRLSStub() { return { - _dev: true, _serviceName: 'security.rls', + _serviceName: 'security.rls', compileFilter() { return null; }, getApplicablePolicies() { return []; }, }; } function createSecurityFieldMaskerStub() { return { - _dev: true, _serviceName: 'security.fieldMasker', + _serviceName: 'security.fieldMasker', maskResults(results: any) { return results; }, }; } @@ -250,9 +251,9 @@ function createSecurityFieldMaskerStub() { * from `packages/spec/src/contracts/`. */ const DEV_STUB_FACTORIES: Record Record> = { - 'cache': () => ({ ...createMemoryCache(), _dev: true }), - 'queue': () => ({ ...createMemoryQueue(), _dev: true }), - 'job': () => ({ ...createMemoryJob(), _dev: true }), + 'cache': () => createMemoryCache(), + 'queue': () => createMemoryQueue(), + 'job': () => createMemoryJob(), 'file-storage': createStorageStub, 'search': createSearchStub, 'automation': createAutomationStub, @@ -270,9 +271,73 @@ const DEV_STUB_FACTORIES: Record Record> = { 'security.fieldMasker': createSecurityFieldMaskerStub, }; +/** + * How each dev implementation describes ITSELF (ADR-0076 D12 `__serviceInfo`), + * in one reviewable table — the point of #4058. + * + * Every stub used to carry the same `_dev: true`, which `readServiceSelfInfo` + * normalizes to `{ status: 'stub', handlerReady: false }`. That single marker + * declared all of these equally fake, and so could not tell apart the two + * kinds actually in here: + * + * - **`degraded`** — the implementation really does the work, with reduced + * capability (no persistence, no cross-process scope, no validation). Its + * answers are true answers. `storage` really stores and returns the bytes; + * `search` really indexes and matches; `metadata` really registers and lists. + * Calling these "stub" understated them and pushed consumers to ignore a + * capability that works. + * - **`stub`** — the answer is fabricated. `ai.chat` returns invented text, + * `automation.execute` reports success without running the flow, + * `notification.send` claims delivery and delivers nothing, `data` accepts + * writes and stores none, `auth.verify` waves everyone through as an admin, + * and the `security.*` trio answers allow-all. These must never be mistaken + * for a capability — the AI stub already misled an agent (ADR-0076 D12). + * + * `handlerReady` answers a different question: does an HTTP handler genuinely + * serve this? It stays `false` for every `stub`, and also for `degraded` + * services with no HTTP surface at all (`realtime` — the D12 precedent). + * + * Entries are only needed for plugin-dev's OWN fakes. The wrapped kernel + * fallbacks (`cache` / `queue` / `job` / `i18n`) already carry their own + * `__serviceInfo`, and {@link applySelfInfo} never overwrites one. + */ +const DEV_STUB_SELF_INFO: Record = { + 'file-storage': { status: 'degraded', message: 'Dev in-memory file storage — really stores and returns bytes, but nothing survives a restart. Register a storage plugin for durable files.' }, + 'search': { status: 'degraded', message: 'Dev in-memory index — real substring matching over indexed documents, no ranking, analyzers, or persistence. Register a search plugin for the real engine.' }, + 'metadata': { status: 'degraded', message: 'Dev in-memory metadata registry — real reads and writes, no persistence. Register MetadataPlugin for a persisted registry.' }, + 'workflow': { status: 'degraded', message: 'Dev in-memory workflow state — transitions are recorded and read back, but NOTHING is validated: every transition is accepted and availableTransitions is always empty.' }, + 'realtime': { status: 'degraded', handlerReady: false, message: 'Dev in-process pub/sub — real delivery to in-process subscribers, but no HTTP or WebSocket surface is mounted.' }, + 'automation': { status: 'stub', message: 'Dev stub — execute() reports success WITHOUT running the flow. Register an automation plugin to actually run flows.' }, + 'notification': { status: 'stub', message: 'Dev stub — messages are recorded in memory and reported as sent; nothing is delivered. Register a notification plugin to actually send.' }, + 'ai': { status: 'stub', message: 'Dev stub — replies are placeholder text, not model output. Register AIServicePlugin from @objectstack/service-ai for real completions.' }, + 'data': { status: 'stub', message: 'Dev stub — find() always returns [], insert() mints an id and stores nothing. Register ObjectQLPlugin for a real engine.' }, + 'auth': { status: 'stub', message: 'Dev stub — verify() accepts EVERY request as a fixed dev admin. Never use outside local development; register plugin-auth for real authentication.' }, + 'security.permissions': { status: 'stub', message: 'Dev stub — every object permission check returns allowed. Register SecurityPlugin for real permission evaluation.' }, + 'security.rls': { status: 'stub', message: 'Dev stub — compiles no row-level filter, so NO RLS predicate is applied. Register SecurityPlugin for real row-level security.' }, + 'security.fieldMasker': { status: 'stub', message: 'Dev stub — returns results unmasked. Register SecurityPlugin for real field-level masking.' }, +}; + +/** Self-description for a slot with no factory at all (see the registration loop). */ +const SHAPELESS_STUB_SELF_INFO = { + status: 'stub' as const, + handlerReady: false, + message: 'Dev placeholder with no implementation — the slot is occupied so lookups do not throw, and nothing else.', +}; + +/** + * Attach the D12 self-description, without ever overwriting one the + * implementation already carries: the kernel fallbacks this plugin wraps + * (`createMemoryCache` & co.) describe themselves, and their own account of + * what they do beats anything this table could restate. + */ +function applySelfInfo(svc: Record, info: Record): Record { + if (SERVICE_SELF_INFO_KEY in svc) return svc; + return Object.assign(svc, { [SERVICE_SELF_INFO_KEY]: info }); +} + /** * Core service slots that deliberately get NO dev stub — not even the - * shapeless `{ _dev: true }` fallback the registration loop uses for slots + * shapeless placeholder the registration loop uses for slots * without a factory. * * `analytics` (#4000): #3891/#3989 retired the degraded analytics shim, making @@ -665,9 +730,21 @@ export class DevPlugin implements Plugin { // so that the full kernel service map is populated and downstream code // receives correct return types (arrays, booleans, objects — not undefined). // Exception: NO_DEV_STUB_SERVICES slots stay empty on purpose (#4000). + // + // Each registered implementation carries its D12 self-description from + // DEV_STUB_SELF_INFO (#4058), so discovery reports what it actually is — + // `degraded` for the ones that really work, `stub` for the ones that + // fabricate — instead of one blanket `_dev: true` for all of them. const stubNames: string[] = []; + /** Build + self-describe one slot's dev implementation. */ + const makeStub = (svc: string) => { + const factory = DEV_STUB_FACTORIES[svc]; + const info = DEV_STUB_SELF_INFO[svc] ?? SHAPELESS_STUB_SELF_INFO; + return applySelfInfo(factory ? factory() : { _serviceName: svc }, info); + }; + for (const svc of CORE_SERVICE_NAMES) { if (!enabled(svc)) continue; if (NO_DEV_STUB_SERVICES.has(svc)) continue; @@ -675,8 +752,7 @@ export class DevPlugin implements Plugin { ctx.getService(svc); // Already registered by a real plugin — skip } catch { - const factory = DEV_STUB_FACTORIES[svc]; - ctx.registerService(svc, factory ? factory() : { _dev: true, _serviceName: svc }); + ctx.registerService(svc, makeStub(svc)); stubNames.push(svc); } } @@ -687,8 +763,7 @@ export class DevPlugin implements Plugin { try { ctx.getService(svc); } catch { - const factory = DEV_STUB_FACTORIES[svc]; - ctx.registerService(svc, factory ? factory() : { _dev: true, _serviceName: svc }); + ctx.registerService(svc, makeStub(svc)); stubNames.push(svc); } } diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 1c8c1f7158..1f76cdeef5 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -8,6 +8,7 @@ import { recordSeedOutcome } from './seed-summary.js'; import { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from './seed-datasets.js'; import { loadDisabledPackageIds } from './package-state-store.js'; import type { IMetadataService, II18nService } from '@objectstack/spec/contracts'; +import { readServiceSelfInfo } from '@objectstack/spec/api'; import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; import { hookBodyRunnerFactory, actionBodyRunnerFactory } from './sandbox/body-runner.js'; import { GLOBAL_ACTION_OBJECT_KEY } from './action-execution.js'; @@ -1266,9 +1267,12 @@ export class AppPlugin implements Plugin { } } - // Emit diagnostic when the active i18n service is a fallback/stub - const svcAny = i18nService as unknown as Record; - if (svcAny._fallback || svcAny._dev) { + // Emit diagnostic when the active i18n service is a fallback/stub. + // [#4058] Reads the standard D12 self-description instead of duck-typing + // 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`. + if (readServiceSelfInfo(i18nService)) { ctx.logger.info( `[i18n] Loaded ${loadedLocales} locale(s) into in-memory i18n fallback for "${appId}". ` + 'For production, consider registering I18nServicePlugin from @objectstack/service-i18n.'