Skip to content

Commit 4f30943

Browse files
authored
fix(runtime,metadata-protocol): the two discovery builders compute the metadata slot instead of contradicting each other (#4089) (#4114)
ADR-0076 D12's rule is that a service self-identifies (`__serviceInfo`) and the discovery builder reports what it declares. #4058 step 1 gave the kernel's `createMemoryMetadata` that marker. It went unread anyway: `metadata` was not in the loop that reads markers, but in a "kernel-provided (always available)" block above it — hardcoded separately in each builder, and the two hardcodes said opposite things about the same slot. - `runtime/http-dispatcher.ts`: permanently `degraded` + "In-memory registry; DB persistence pending". A stack with MetadataPlugin and a real `sys_metadata` table was reported as having no persistence. - `metadata-protocol/protocol.ts`: permanently `available`. A registry that never reaches disk was reported as indistinguishable from the persisted one — the over-declaration #4089 filed. So each builder was wrong for half the stacks, in opposite directions, and a consumer reading `discovery.services.metadata` got a different answer depending on which host answered. Both now read the registered implementation's self-description: `degraded` plus that implementation's own message (which names what is missing and what to install) for the kernel fallback and plugin-dev's dev registry; `available` with no message for MetadataPlugin, or any implementation carrying no marker — the absent-service case included, since the protocol serves `/meta` regardless. `handlerReady: true` is stated unconditionally on both sides, deliberately NOT taken from the self-description: it answers "is `/api/v1/meta` mounted?", and that route is served by the protocol, so a degraded service in the slot does not unmount it. (Contrast `realtime`, where D12 pins `handlerReady: false` because no surface exists at all.) `data` keeps its hardcode — ObjectQL is its only producer and plugin-dev always loads ObjectQLPlugin as a child — but that truth rests on a load-order convention rather than construction, filed as #4130. No routing, gating or dispatch behavior changes; `svcAvailable` and every domain predicate are untouched. Tests pin both directions per builder, the real kernel-boot path end-to-end (ObjectQLPlugin with no MetadataPlugin ⇒ `degraded`), and — the invariant this closes — that the two builders now answer the same service instance identically. Docs: ADR-0076 D12 inventory, the `services-checklist` / `api/index` discovery samples, and the two docs that still repeated the removed hardcode (`api/client-sdk`, `releases/implementation-status`). Closes #4089
1 parent 3df8819 commit 4f30943

11 files changed

Lines changed: 212 additions & 9 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
Both discovery builders now compute the `metadata` service entry from the implementation that fills the slot, instead of hardcoding opposite verdicts for it (#4089).
7+
8+
`metadata` sat in a "kernel-provided (always available)" block above the loop that reads `__serviceInfo`, hardcoded separately in each builder — and the two disagreed about the same slot:
9+
10+
- `@objectstack/runtime`'s dispatcher declared it permanently `status: 'degraded'` with `message: 'In-memory registry; DB persistence pending'`, so a stack with `MetadataPlugin` and a real `sys_metadata` table was still reported as having no persistence.
11+
- `@objectstack/metadata-protocol` declared the same slot permanently `status: 'available'`, so the kernel's in-memory fallback (`createMemoryMetadata`, auto-registered when no metadata plugin is present) read exactly like a persisted registry — the `__serviceInfo` marker #4058 gave it went unread here.
12+
13+
Both now read the registered service's `__serviceInfo` (via `readServiceSelfInfo`) and report what it declares:
14+
15+
- kernel in-memory fallback, or plugin-dev's dev registry → `status: 'degraded'` plus that implementation's own `message`, which names what is missing and what to install.
16+
- `MetadataPlugin` (or any implementation carrying no marker) → `status: 'available'` with no message.
17+
18+
`handlerReady: true` is now stated unconditionally on both sides: it answers "is `/api/v1/meta` mounted?", and that route is served by the protocol whichever implementation occupies the slot — a degraded service in it does not unmount the route. Nothing about routing, gating, or dispatch changes; consumers that treat `status` as a capability claim (AI agents, the console) simply stop being told two different things by two hosts.

content/docs/api/client-sdk.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ async function main() {
4040

4141
// 2. Check available services
4242
console.log('Services:', discovery.services);
43-
// → { metadata: { enabled: true, status: 'degraded' }, data: { enabled: true, status: 'available' }, auth: { enabled: false, ... } }
43+
// → { metadata: { enabled: true, status: 'degraded', message: 'In-memory metadata registry — …' },
44+
// data: { enabled: true, status: 'available' }, auth: { enabled: false, ... } }
45+
// `metadata` reports the implementation actually behind it: `degraded` plus a
46+
// message naming what is missing while the kernel's in-memory fallback fills
47+
// the slot, `available` once MetadataPlugin provides a persisted registry.
4448

4549
// 3. Query data
4650
const tasks = await client.data.find('todo_task', {

content/docs/api/index.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ Returns the full discovery manifest.
132132

133133
Disabled/uninstalled route keys (e.g. `auth`, `analytics`, `workflow`) are omitted from `routes` entirely rather than set to `null`; check `services` to tell "not installed" apart from "installed but not yet mounted here." The sample above shows a minimal install: `analytics` reports `unavailable` and advertises no route until `@objectstack/service-analytics` registers the engine — the dispatcher then also mounts `/api/v1/analytics/*` (the routes are capability-conditional; an uninstalled capability answers 404 for every method).
134134

135+
`metadata` is reported from whatever implementation fills its slot, so the sample's `available` is the `MetadataPlugin` case (a persisted `sys_metadata` registry). A stack running the kernel's in-memory fallback instead reports `status: "degraded"` with a `message` naming what is missing and what to install. `handlerReady` is `true` either way: `/api/v1/meta` is served by the protocol, so the route is mounted whichever registry sits behind it.
136+
135137
### `GET /.well-known/objectstack`
136138

137139
Served by the runtime dispatcher (`@objectstack/runtime`), not `@objectstack/rest` — its body is wrapped as `{ "data": { ... } }` and includes fields (`name`, `environment`, `features`, `locale`) that the `@objectstack/rest`-served `/api/v1` response above does not. The client SDK's `connect()` tries `/api/v1/discovery` first and falls back to this endpoint, unwrapping either `body.data` or the bare `body`.

content/docs/kernel/services-checklist.mdx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,15 +113,19 @@ The metadata service is a **framework** — it works with an in-memory `SchemaRe
113113

114114
### Discovery: Per-Service Status
115115

116-
The discovery endpoint returns a `services` map so clients know what is available:
116+
The discovery endpoint returns a `services` map so clients know what is available.
117+
The `metadata` entry is **computed from the implementation that fills the slot**, not
118+
fixed (#4089): a stack running the kernel's in-memory fallback reports `degraded` with
119+
that fallback's own message, while a stack with `MetadataPlugin` reports `available` and
120+
no message. Below is the former — the fallback case:
117121

118122
```json
119123
{
120124
"services": {
121125
"metadata": {
122-
"enabled": true, "status": "degraded",
126+
"enabled": true, "status": "degraded", "handlerReady": true,
123127
"route": "/api/v1/meta", "provider": "kernel",
124-
"message": "In-memory registry; DB persistence pending"
128+
"message": "In-memory metadata registry — real reads and writes, no persistence (lost on restart). Register MetadataPlugin for a persisted registry."
125129
},
126130
"data": {
127131
"enabled": true, "status": "available",

content/docs/releases/implementation-status.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ Implementation spans every layer of the platform. Core infrastructure, data mode
468468
| **Query Engine** | ⚠️ | Partial (depends on driver) |
469469
| **REST API** || Yes |
470470
| **Client SDKs** || Yes |
471-
| **Metadata System** | ⚠️ | Partial (in-memory only, DB persistence pending) |
471+
| **Metadata System** | ⚠️ | Partial (`sys_metadata` persistence via `MetadataPlugin`; without it the kernel's in-memory fallback fills the slot and discovery reports it `degraded`) |
472472
| **HTTP Caching** || Yes |
473473
| **Testing Tools** || Yes |
474474
| **UI Rendering** || No |

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ Decision: each capability plugin registers its routes as a **normalized handler*
134134
**Inventory of current fakes / mis-reports:**
135135
- `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).
137-
- `http-dispatcher.ts` `svcAvailable` — the hardcode above.
137+
- `http-dispatcher.ts` `svcAvailable` — the hardcode above. *(Update #4089: `svcAvailable` respects the marker, but two entries never went through it at all — a "kernel-provided (always available)" block above the loop, hardcoded per builder and **contradicting itself across them**: the dispatcher declared `metadata` permanently `degraded` with "In-memory registry; DB persistence pending", while metadata-protocol declared the same slot permanently `available`. So one host called a `sys_metadata`-backed registry degraded and the other called the in-memory fallback fully real — the marker #4058 had just added to `createMemoryMetadata` went unread on both sides, because neither side read anything. Both now compute the slot from the registered implementation's `__serviceInfo` and agree; `handlerReady: true` stays unconditional on both, since `/api/v1/meta` is served by the protocol whichever implementation occupies the slot. `data` keeps its hardcode: ObjectQL is the only producer that ever fills it, and `ObjectQLPlugin` — which plugin-dev always loads as a child — registers the real engine, so plugin-dev's `data` stub is unreachable in a stack that has a discovery builder at all.)*
138138
- *(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.)*
139139

140140
**Decision — honest capabilities:**

packages/metadata-protocol/src/protocol.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1463,9 +1463,34 @@ export class ObjectStackProtocolImplementation implements
14631463
// registry via SERVICE_CONFIG below — absent means `unavailable`, and
14641464
// no route is advertised (the pre-#2462 hardcode here is exactly what
14651465
// the fallback was invented to make true).
1466+
//
1467+
// [#4089] The `metadata` slot is reported from whatever fills it, not
1468+
// hardcoded `available`. The kernel auto-registers `createMemoryMetadata`
1469+
// when no MetadataPlugin is present, and plugin-dev registers its own
1470+
// in-memory registry; both self-describe as `degraded` (D12), and
1471+
// hardcoding `available` here overstated them as exactly equivalent to a
1472+
// sys_metadata-backed registry. Absent or unmarked ⇒ `available`, which
1473+
// is what the real MetadataPlugin (carrying no marker) reports.
1474+
//
1475+
// This is also where the two builders stopped disagreeing: the runtime
1476+
// dispatcher hardcoded the opposite verdict for the same slot
1477+
// (`degraded` + "DB persistence pending"), so one host called a persisted
1478+
// registry degraded while the other called an in-memory one available.
1479+
// Both now compute it, and `handlerReady: true` is stated on both sides:
1480+
// `/api/v1/meta` is served by the protocol, not by this service, so it is
1481+
// mounted whichever implementation occupies the slot.
1482+
const metadataSelf = readServiceSelfInfo(registeredServices.get('metadata'));
1483+
14661484
const services: Record<string, ServiceInfo> = {
14671485
// --- Kernel-provided (objectql is an example kernel implementation) ---
1468-
metadata: { enabled: true, status: 'available' as const, route: '/api/v1/meta', provider: 'objectql' },
1486+
metadata: {
1487+
enabled: true,
1488+
status: metadataSelf?.status ?? ('available' as const),
1489+
handlerReady: true,
1490+
route: '/api/v1/meta',
1491+
provider: 'objectql',
1492+
...(metadataSelf?.message ? { message: metadataSelf.message } : {}),
1493+
},
14691494
data: { enabled: true, status: 'available' as const, route: '/api/v1/data', provider: 'objectql' },
14701495
};
14711496

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,27 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
3535
expect(readServiceSelfInfo(metadataService)?.status).toBe('degraded');
3636
});
3737

38+
// #4089 — the self-description above only helps if the builder reads it.
39+
// On this boot path (no MetadataPlugin) the whole chain runs for real:
40+
// the kernel injects `createMemoryMetadata`, the protocol shim walks the
41+
// live service registry, and discovery must say `degraded` — it used to
42+
// hardcode `available` for the slot and report a registry that never
43+
// reaches disk as fully real.
44+
it('should report the kernel metadata fallback as degraded in discovery', async () => {
45+
await kernel.use(new ObjectQLPlugin());
46+
await kernel.bootstrap();
47+
48+
const protocol = kernel.getService('protocol') as any;
49+
const discovery = await protocol.getDiscovery();
50+
51+
expect(discovery.services.metadata.enabled).toBe(true);
52+
expect(discovery.services.metadata.status).toBe('degraded');
53+
expect(discovery.services.metadata.message).toContain('MetadataPlugin');
54+
// Serving is unaffected — `/api/v1/meta` is the protocol's route.
55+
expect(discovery.services.metadata.handlerReady).toBe(true);
56+
expect(discovery.routes.metadata).toBe('/api/v1/meta');
57+
});
58+
3859
it('should serve in-memory metadata definitions', async () => {
3960
// Arrange
4061
const plugin = new ObjectQLPlugin();

packages/objectql/src/protocol-discovery.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { describe, it, expect, beforeEach } from 'vitest';
44
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
5+
import { createMemoryMetadata } from '@objectstack/core';
56
import { ObjectQL } from './engine.js';
67

78
describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => {
@@ -146,6 +147,40 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
146147
expect(discovery.services.data.status).toBe('available');
147148
});
148149

150+
// #4089 — `metadata` was in the hardcoded kernel block, so it reported
151+
// `available` even when the slot held the kernel's in-memory fallback: a
152+
// registry that never reaches disk read exactly like a sys_metadata-backed
153+
// one. The slot is now computed from the implementation's own D12
154+
// self-description, like every optional service below it.
155+
it('should report the kernel in-memory metadata fallback as degraded, never available', async () => {
156+
const mockServices = new Map<string, any>();
157+
mockServices.set('metadata', createMemoryMetadata());
158+
159+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
160+
const discovery = await protocol.getDiscovery();
161+
162+
expect(discovery.services.metadata.enabled).toBe(true);
163+
expect(discovery.services.metadata.status).toBe('degraded');
164+
// The message names what is missing and what to install for the real thing.
165+
expect(discovery.services.metadata.message).toContain('MetadataPlugin');
166+
// Still served: `/api/v1/meta` is the protocol's route, not this service's.
167+
expect(discovery.services.metadata.handlerReady).toBe(true);
168+
expect(discovery.services.metadata.route).toBe('/api/v1/meta');
169+
expect(discovery.routes.metadata).toBe('/api/v1/meta');
170+
});
171+
172+
it('should report an unmarked (real) metadata service as available', async () => {
173+
const mockServices = new Map<string, any>();
174+
mockServices.set('metadata', { register: async () => {}, list: async () => [] });
175+
176+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
177+
const discovery = await protocol.getDiscovery();
178+
179+
expect(discovery.services.metadata.status).toBe('available');
180+
expect(discovery.services.metadata.message).toBeUndefined();
181+
expect(discovery.services.metadata.handlerReady).toBe(true);
182+
});
183+
149184
// #3891 — the degraded ObjectQL fallback is retired. Analytics is an
150185
// ordinary optional service now: absent ⇒ unavailable, and the route must
151186
// NOT be advertised (an advertised route with no handler 404s — the exact

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2354,6 +2354,67 @@ describe('HttpDispatcher', () => {
23542354
expect(info.services.workflow.status).toBe('available');
23552355
expect(info.services.workflow.handlerReady).toBe(true);
23562356
});
2357+
2358+
// ── The `metadata` slot: computed, not hardcoded (#4089) ──────────────
2359+
//
2360+
// This entry used to be a fixed `degraded` + "In-memory registry; DB
2361+
// persistence pending" whatever filled the slot, so it was wrong for
2362+
// every stack with a persisted registry — and it was the exact reverse
2363+
// of metadata-protocol's hardcoded `available`, which was wrong for
2364+
// every stack running the kernel's in-memory fallback.
2365+
2366+
it('reports the kernel in-memory metadata fallback as degraded, with the fallback\'s own message', async () => {
2367+
const { createMemoryMetadata } = await import('@objectstack/core');
2368+
const fallback = createMemoryMetadata();
2369+
(kernel as any).getService = vi.fn().mockImplementation((name: string) =>
2370+
name === 'metadata' ? fallback : null,
2371+
);
2372+
2373+
const info = await dispatcher.getDiscoveryInfo('/api/v1');
2374+
expect(info.services.metadata.enabled).toBe(true);
2375+
expect(info.services.metadata.status).toBe('degraded');
2376+
expect(info.services.metadata.message).toContain('no persistence');
2377+
// `handlerReady` is about the route, not the service: `/meta` is
2378+
// served by the protocol on every host, so a degraded service in
2379+
// this slot does not unmount it.
2380+
expect(info.services.metadata.handlerReady).toBe(true);
2381+
expect(info.routes.metadata).toBe('/api/v1/meta');
2382+
});
2383+
2384+
it('reports an unmarked metadata service as available, with no stale "persistence pending" message', async () => {
2385+
(kernel as any).getService = vi.fn().mockImplementation((name: string) =>
2386+
name === 'metadata' ? { register: vi.fn(), get: vi.fn(), list: vi.fn() } : null,
2387+
);
2388+
2389+
const info = await dispatcher.getDiscoveryInfo('/api/v1');
2390+
expect(info.services.metadata.status).toBe('available');
2391+
expect(info.services.metadata.message).toBeUndefined();
2392+
expect(info.services.metadata.handlerReady).toBe(true);
2393+
});
2394+
2395+
it('answers the metadata slot identically to the metadata-protocol builder', async () => {
2396+
const [{ createMemoryMetadata }, { ObjectStackProtocolImplementation }] = await Promise.all([
2397+
import('@objectstack/core'),
2398+
import('@objectstack/metadata-protocol'),
2399+
]);
2400+
// One service instance, both builders — the two used to give this
2401+
// very object opposite verdicts (`degraded` here, `available` there).
2402+
const fallback = createMemoryMetadata();
2403+
(kernel as any).getService = vi.fn().mockImplementation((name: string) =>
2404+
name === 'metadata' ? fallback : null,
2405+
);
2406+
2407+
const fromDispatcher = (await dispatcher.getDiscoveryInfo('/api/v1')).services.metadata;
2408+
const fromProtocol = (await new ObjectStackProtocolImplementation(
2409+
mockObjectQL as any,
2410+
() => new Map<string, any>([['metadata', fallback]]),
2411+
).getDiscovery()).services.metadata;
2412+
2413+
expect(fromDispatcher.status).toBe(fromProtocol.status);
2414+
expect(fromDispatcher.handlerReady).toBe(fromProtocol.handlerReady);
2415+
expect(fromDispatcher.message).toBe(fromProtocol.message);
2416+
expect(fromDispatcher.route).toBe(fromProtocol.route);
2417+
});
23572418
});
23582419

23592420
// ═══════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)