Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/honest-service-self-description.md
Original file line number Diff line number Diff line change
@@ -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).
29 changes: 26 additions & 3 deletions content/docs/kernel/services.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<IAnalytics>('analytics');
analytics.track('page_view', { url: '/dashboard' });
if (ctx.getServices().has('search')) {
const search = ctx.getService<ISearchService>('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:
Expand Down
3 changes: 2 additions & 1 deletion docs/adr/0076-objectql-core-tiering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
51 changes: 43 additions & 8 deletions packages/core/src/fallbacks/fallbacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
});

Expand Down Expand Up @@ -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');
});

Expand Down Expand Up @@ -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');
});

Expand Down Expand Up @@ -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');
});

Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/fallbacks/memory-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { value: unknown; expires?: number }>();
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<T = unknown>(key: string): Promise<T | undefined> {
const entry = store.get(key);
if (!entry || (entry.expires && Date.now() > entry.expires)) {
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/fallbacks/memory-i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>): string {
const data = resolveTranslations(locale) ?? mergedLocale(defaultLocale);
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/fallbacks/memory-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>();
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<void> { jobs.set(name, { schedule, handler }); },
async cancel(name: string): Promise<void> { jobs.delete(name); },
async trigger(name: string, data?: unknown): Promise<void> {
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/fallbacks/memory-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
getTypeMap(type).set(name, data);
},
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/fallbacks/memory-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Function[]>();
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<T = unknown>(queue: string, data: T): Promise<string> {
const id = `fallback-msg-${++msgId}`;
const fns = handlers.get(queue) ?? [];
Expand Down
7 changes: 5 additions & 2 deletions packages/objectql/src/plugin.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 () => {
Expand Down
Loading
Loading