Skip to content

Commit 45dc446

Browse files
authored
fix(core,plugin-dev,runtime): every in-memory fallback and dev stub self-describes honestly (#4058 step 1) (#4082)
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: `cache`, `queue`, `job`, `file-storage`, `search`, `i18n`, `metadata`, `workflow`, `realtime`. Their answers are true answers; each `message` names what is missing — including the ones easy to miss (memory-job's `schedule()` never fires: no timer; the workflow stub validates no state machine). - `stub` — the answer is fabricated: `ai`, `automation`, `notification`, `data`, `auth`, `security.permissions`, `security.rls`, `security.fieldMasker`. `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.
1 parent 1bd2795 commit 45dc446

13 files changed

Lines changed: 335 additions & 45 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/core': patch
3+
'@objectstack/plugin-dev': patch
4+
'@objectstack/runtime': patch
5+
---
6+
7+
Every in-memory fallback and dev stub now self-describes with the standard `__serviceInfo` descriptor, classified by what it actually is (#4058 step 1).
8+
9+
ADR-0076 D12 gave services one way to say "I am not the real thing", but the producers never converged on it:
10+
11+
- 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`.
12+
- `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.
13+
14+
Both now carry `__serviceInfo`, split by a rule that holds across the whole set:
15+
16+
- **`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, …).
17+
- **`stub`** — the answer is fabricated: `ai`, `automation`, `notification`, `data`, `auth`, `security.permissions`, `security.rls`, `security.fieldMasker`. Never to be mistaken for a capability.
18+
19+
`handlerReady: false` is set independently wherever no HTTP handler serves the slot (`cache` / `queue` / `job` / `realtime`, and every `stub`).
20+
21+
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.
22+
23+
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).

content/docs/kernel/services.mdx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,35 @@ const users = await db.find('user', { where: { active: true } });
7373
check the registry map with `ctx.getServices()`:
7474

7575
```typescript
76-
if (ctx.getServices().has('analytics')) {
77-
const analytics = ctx.getService<IAnalytics>('analytics');
78-
analytics.track('page_view', { url: '/dashboard' });
76+
if (ctx.getServices().has('search')) {
77+
const search = ctx.getService<ISearchService>('search');
78+
await search.index('account', record.id, record);
7979
}
8080
```
8181

82+
**Presence is not capability.** A registered service may be a stub or a degraded
83+
fallback — dev-mode fakes and the kernel's own in-memory fallbacks both occupy
84+
real slots. They say so with the `__serviceInfo` descriptor (ADR-0076 D12), and
85+
consumers are expected to read it rather than trust mere registration:
86+
87+
```typescript
88+
import { readServiceSelfInfo } from '@objectstack/spec/api';
89+
90+
const ai = ctx.getServices().get('ai');
91+
const self = readServiceSelfInfo(ai); // undefined ⇒ a real implementation
92+
93+
if (ai && self?.status !== 'stub') {
94+
// Safe: a real implementation, or a `degraded` one that genuinely works.
95+
} else if (self) {
96+
ctx.logger.info(`ai is a stub — ${self.message}`);
97+
}
98+
```
99+
100+
`status: 'degraded'` means real work with reduced capability (the in-memory
101+
cache, the in-memory search index); `status: 'stub'` means the answer is
102+
fabricated and must not be used for real work. `handlerReady: false`
103+
additionally means no HTTP handler serves it.
104+
82105
## Standard Services
83106

84107
The core ecosystem defines several standard service contracts:

docs/adr/0076-objectql-core-tiering.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,10 @@ Decision: each capability plugin registers its routes as a **normalized handler*
132132
**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.
133133

134134
**Inventory of current fakes / mis-reports:**
135-
- `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.)*
135+
- `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.)*
136136
- `ObjectQLPlugin` registers a ~66-line `analytics` **fallback** (the D10 note — deliberate, but it reports as fully available).
137137
- `http-dispatcher.ts` `svcAvailable` — the hardcode above.
138+
- *(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.)*
138139

139140
**Decision — honest capabilities:**
140141
1. A registered service that is a stub / dev / fallback MUST self-identify with a **standard marker** (standardise one; `_dev` is the existing precedent).

packages/core/src/fallbacks/fallbacks.test.ts

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,27 @@ import { createMemoryQueue } from './memory-queue';
44
import { createMemoryJob } from './memory-job';
55
import { createMemoryI18n, resolveLocale } from './memory-i18n';
66
import { CORE_FALLBACK_FACTORIES } from './index';
7+
import { readServiceSelfInfo } from '@objectstack/spec/api';
78

89
describe('CORE_FALLBACK_FACTORIES', () => {
910
it('should have exactly 5 entries: metadata, cache, queue, job, i18n', () => {
1011
expect(Object.keys(CORE_FALLBACK_FACTORIES)).toEqual(['metadata', 'cache', 'queue', 'job', 'i18n']);
1112
});
1213

14+
// [#4058] Every kernel fallback must be readable through the ONE standard
15+
// descriptor. They used to carry `_fallback: true`, which `readServiceSelfInfo`
16+
// does not recognize — so each of them was reported as a fully available
17+
// service by both discovery builders.
18+
it('every fallback self-describes via the standard D12 descriptor', () => {
19+
for (const [name, factory] of Object.entries(CORE_FALLBACK_FACTORIES)) {
20+
const info = readServiceSelfInfo(factory());
21+
expect(info, `${name} must carry __serviceInfo`).toBeDefined();
22+
// These are real implementations with reduced capability — never fakes.
23+
expect(info?.status, `${name} status`).toBe('degraded');
24+
expect(info?.message, `${name} message`).toBeTruthy();
25+
}
26+
});
27+
1328
it('should map to factory functions', () => {
1429
for (const factory of Object.values(CORE_FALLBACK_FACTORIES)) {
1530
expect(typeof factory).toBe('function');
@@ -18,9 +33,14 @@ describe('CORE_FALLBACK_FACTORIES', () => {
1833
});
1934

2035
describe('createMemoryCache', () => {
21-
it('should return an object with _fallback: true', () => {
36+
// [#4058] Self-describes with the STANDARD D12 descriptor. The old
37+
// `_fallback: true` marker was read by nothing, so discovery reported
38+
// this as fully `available` — the honesty gap D12 exists to close.
39+
it('self-describes as a degraded implementation, not a stub', () => {
2240
const cache = createMemoryCache();
23-
expect(cache._fallback).toBe(true);
41+
expect(readServiceSelfInfo(cache)?.status).toBe('degraded');
42+
expect(readServiceSelfInfo(cache)?.handlerReady).toBe(false); // no HTTP surface for cache
43+
expect(readServiceSelfInfo(cache)?.message).toBeTruthy();
2444
expect(cache._serviceName).toBe('cache');
2545
});
2646

@@ -80,9 +100,14 @@ describe('createMemoryCache', () => {
80100
});
81101

82102
describe('createMemoryQueue', () => {
83-
it('should return an object with _fallback: true', () => {
103+
// [#4058] Self-describes with the STANDARD D12 descriptor. The old
104+
// `_fallback: true` marker was read by nothing, so discovery reported
105+
// this as fully `available` — the honesty gap D12 exists to close.
106+
it('self-describes as a degraded implementation, not a stub', () => {
84107
const queue = createMemoryQueue();
85-
expect(queue._fallback).toBe(true);
108+
expect(readServiceSelfInfo(queue)?.status).toBe('degraded');
109+
expect(readServiceSelfInfo(queue)?.handlerReady).toBe(false); // no HTTP surface for queue
110+
expect(readServiceSelfInfo(queue)?.message).toBeTruthy();
86111
expect(queue._serviceName).toBe('queue');
87112
});
88113

@@ -121,9 +146,14 @@ describe('createMemoryQueue', () => {
121146
});
122147

123148
describe('createMemoryJob', () => {
124-
it('should return an object with _fallback: true', () => {
149+
// [#4058] Self-describes with the STANDARD D12 descriptor. The old
150+
// `_fallback: true` marker was read by nothing, so discovery reported
151+
// this as fully `available` — the honesty gap D12 exists to close.
152+
it('self-describes as a degraded implementation, not a stub', () => {
125153
const job = createMemoryJob();
126-
expect(job._fallback).toBe(true);
154+
expect(readServiceSelfInfo(job)?.status).toBe('degraded');
155+
expect(readServiceSelfInfo(job)?.handlerReady).toBe(false); // no HTTP surface for job
156+
expect(readServiceSelfInfo(job)?.message).toBeTruthy();
127157
expect(job._serviceName).toBe('job');
128158
});
129159

@@ -159,9 +189,14 @@ describe('createMemoryJob', () => {
159189
});
160190

161191
describe('createMemoryI18n', () => {
162-
it('should return an object with _fallback: true', () => {
192+
// [#4058] Self-describes with the STANDARD D12 descriptor. The old
193+
// `_fallback: true` marker was read by nothing, so discovery reported
194+
// this as fully `available` — the honesty gap D12 exists to close.
195+
it('self-describes as a degraded implementation, not a stub', () => {
163196
const i18n = createMemoryI18n();
164-
expect(i18n._fallback).toBe(true);
197+
expect(readServiceSelfInfo(i18n)?.status).toBe('degraded');
198+
expect(readServiceSelfInfo(i18n)?.handlerReady).toBe(true); // the dispatcher's /i18n domain serves it
199+
expect(readServiceSelfInfo(i18n)?.message).toBeTruthy();
165200
expect(i18n._serviceName).toBe('i18n');
166201
});
167202

packages/core/src/fallbacks/memory-cache.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,26 @@
66
* Implements the ICacheService contract with basic get/set/delete/has/clear
77
* and TTL expiry. Used by ObjectKernel as an automatic fallback when no
88
* real cache plugin (e.g. Redis) is registered.
9+
*
10+
* [#4058] Self-describes as `degraded`, not `stub` (ADR-0076 D12): this is a
11+
* real cache — it stores, expires, and reports true stats — just process-local
12+
* and unshared. The non-standard `_fallback: true` it used to carry was read by
13+
* nothing (`readServiceSelfInfo` knows `__serviceInfo` and the legacy `_dev`),
14+
* so discovery reported it as fully `available`. `handlerReady: false` because
15+
* no HTTP surface is mounted for `cache` at all — the same reason realtime
16+
* reports false.
917
*/
1018
export function createMemoryCache() {
1119
const store = new Map<string, { value: unknown; expires?: number }>();
1220
let hits = 0;
1321
let misses = 0;
1422
return {
15-
_fallback: true, _serviceName: 'cache',
23+
__serviceInfo: {
24+
status: 'degraded' as const,
25+
handlerReady: false,
26+
message: 'In-process Map cache — not shared across instances, lost on restart. Register a cache plugin (e.g. Redis) for a real one.',
27+
},
28+
_serviceName: 'cache',
1629
async get<T = unknown>(key: string): Promise<T | undefined> {
1730
const entry = store.get(key);
1831
if (!entry || (entry.expires && Date.now() > entry.expires)) {

packages/core/src/fallbacks/memory-i18n.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,16 @@ export function createMemoryI18n() {
123123
}
124124

125125
return {
126-
_fallback: true, _serviceName: 'i18n',
126+
// [#4058] `degraded` (ADR-0076 D12): translations, locale fallback and
127+
// interpolation are all real — what is missing is persistence and the
128+
// authoring surface service-i18n adds. `handlerReady` left at the
129+
// `degraded` default (true): the dispatcher's `/i18n` domain does serve
130+
// this implementation.
131+
__serviceInfo: {
132+
status: 'degraded' as const,
133+
message: 'In-memory translations — real lookup and locale fallback, but nothing is persisted. Register I18nServicePlugin from @objectstack/service-i18n for the full implementation.',
134+
},
135+
_serviceName: 'i18n',
127136

128137
t(key: string, locale: string, params?: Record<string, unknown>): string {
129138
const data = resolveTranslations(locale) ?? mergedLocale(defaultLocale);

packages/core/src/fallbacks/memory-job.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,22 @@
66
* Implements the IJobService contract with basic schedule/cancel/trigger
77
* operations. Used by ObjectKernel as an automatic fallback when no real
88
* job plugin (e.g. Agenda / BullMQ) is registered.
9+
*
10+
* [#4058] `degraded` (ADR-0076 D12), with the missing half named in the
11+
* message rather than left for a deployer to discover: `trigger()` really runs
12+
* the registered handler, but nothing here owns a timer, so a `schedule()`d job
13+
* NEVER fires on its own. That is reduced capability, not fabricated output —
14+
* no call returns a made-up answer. `handlerReady: false`: no HTTP surface.
915
*/
1016
export function createMemoryJob() {
1117
const jobs = new Map<string, any>();
1218
return {
13-
_fallback: true, _serviceName: 'job',
19+
__serviceInfo: {
20+
status: 'degraded' as const,
21+
handlerReady: false,
22+
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.',
23+
},
24+
_serviceName: 'job',
1425
async schedule(name: string, schedule: any, handler: any): Promise<void> { jobs.set(name, { schedule, handler }); },
1526
async cancel(name: string): Promise<void> { jobs.delete(name); },
1627
async trigger(name: string, data?: unknown): Promise<void> {

packages/core/src/fallbacks/memory-metadata.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,15 @@ export function createMemoryMetadata() {
2121
}
2222

2323
return {
24-
_fallback: true, _serviceName: 'metadata',
24+
// [#4058] `degraded` (ADR-0076 D12): the registry is real — everything
25+
// registered is listable and readable back — it simply never reaches disk
26+
// or a database. `handlerReady` keeps the `degraded` default (true): the
27+
// dispatcher's `/meta` domain serves this implementation.
28+
__serviceInfo: {
29+
status: 'degraded' as const,
30+
message: 'In-memory metadata registry — real reads and writes, no persistence (lost on restart). Register MetadataPlugin for a persisted registry.',
31+
},
32+
_serviceName: 'metadata',
2533
async register(type: string, name: string, data: any): Promise<void> {
2634
getTypeMap(type).set(name, data);
2735
},

packages/core/src/fallbacks/memory-queue.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,22 @@
66
* Implements the IQueueService contract with synchronous in-process delivery.
77
* Used by ObjectKernel as an automatic fallback when no real queue plugin
88
* (e.g. BullMQ / RabbitMQ) is registered.
9+
*
10+
* [#4058] `degraded`, not `stub` (ADR-0076 D12): messages really reach real
11+
* subscribers — synchronously, in-process, with no durability or retry.
12+
* `getQueueSize()` answering 0 follows from that rather than faking it: nothing
13+
* is ever buffered. `handlerReady: false` — no HTTP surface exists for `queue`.
914
*/
1015
export function createMemoryQueue() {
1116
const handlers = new Map<string, Function[]>();
1217
let msgId = 0;
1318
return {
14-
_fallback: true, _serviceName: 'queue',
19+
__serviceInfo: {
20+
status: 'degraded' as const,
21+
handlerReady: false,
22+
message: 'Synchronous in-process delivery — no durability, retry, or cross-instance fan-out. Register a queue plugin (e.g. BullMQ) for a real one.',
23+
},
24+
_serviceName: 'queue',
1525
async publish<T = unknown>(queue: string, data: T): Promise<string> {
1626
const id = `fallback-msg-${++msgId}`;
1727
const fns = handlers.get(queue) ?? [];

packages/objectql/src/plugin.integration.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
44
import { ObjectKernel } from '@objectstack/core';
55
import { ObjectQLPlugin } from '../src/plugin';
66
import { ObjectSchema } from '@objectstack/spec/data';
7+
import { readServiceSelfInfo } from '@objectstack/spec/api';
78

89
describe('ObjectQLPlugin - Metadata Service Integration', () => {
910
let kernel: ObjectKernel;
@@ -26,10 +27,12 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
2627
expect(objectql).toBeDefined();
2728
expect(kernel.getService('data')).toBeDefined();
2829
expect(kernel.getService('protocol')).toBeDefined();
29-
// metadata is provided by kernel's core fallback, not ObjectQL
30+
// metadata is provided by kernel's core fallback, not ObjectQL —
31+
// identified by its standard D12 self-description (#4058; it used to
32+
// carry a non-standard `_fallback: true` no consumer recognized).
3033
const metadataService = kernel.getService('metadata');
3134
expect(metadataService).toBeDefined();
32-
expect((metadataService as any)._fallback).toBe(true);
35+
expect(readServiceSelfInfo(metadataService)?.status).toBe('degraded');
3336
});
3437

3538
it('should serve in-memory metadata definitions', async () => {

0 commit comments

Comments
 (0)