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
15 changes: 15 additions & 0 deletions .changeset/dispatcher-handler-ready-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/runtime': patch
'@objectstack/metadata-protocol': patch
---

Gate every dispatcher service domain on `handlerReady` instead of on slot occupancy (#4058 step 2).

#4000 made the `/analytics` domain execute ADR-0076 D12's third conclusion ("consumers treat only `handlerReady: true` as a real capability"); every other domain still gated on "is a service registered", so a self-declared stub occupying `automation` / `notification` / `ai` / `file-storage` / `i18n` was called like a real implementation and its fabricated answer went out as a 200. Step 1 (#4082) made the two kinds of dev implementation distinguishable; this is the gate that reads the distinction.

- The `/analytics`, `/automation`, `/notifications`, `/ai`, `/storage` and `/i18n` domains, the route-mount gate, discovery's `routes`/`features`, and the metadata-protocol builder's route advertisement now share one predicate (`isServiceServeable`): a slot whose occupant self-declares `handlerReady: false` is answered exactly as an empty slot is — the domain's existing 404, or the explicit 501 `/storage` and `/i18n` use. One predicate, so what is advertised and what is served cannot disagree.
- `handlerReady`, not `status`, is the test. An implementation that declares `degraded` defaults to `handlerReady: true` and keeps serving — which is why the in-memory `file-storage` and `i18n` implementations are unaffected.
- `discovery.services.*` stays presence-gated: a registered stub still reports `{ enabled: true, status: 'stub', handlerReady: false }` (with no `route`), which says strictly more than collapsing it to `unavailable` would.
- `/ai` improves for the stub case: an occupied-but-unserveable slot used to fall through to a 503 "AI service routes not yet initialized" and lose the `GET /ai/agents` empty-list answer the console polls for on every navigation. Both are restored.

No change for a host whose services are real implementations. If you register your own stub under one of those six slots and relied on the dispatcher calling it, either drop the `handlerReady: false` self-declaration (declare `degraded` if it genuinely serves) or install the real service. Not gated, deliberately: `/data`, `/meta`, `/auth` and the security path — their dev stubs back the dev stack's own core loop, and gating them would 404 the dev stack itself.
33 changes: 33 additions & 0 deletions content/docs/kernel/services-checklist.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,32 @@ The discovery endpoint returns a `services` map so clients know what is availabl
}
```

#### `stub` vs `degraded` — and what the dispatcher does with each

A service may self-declare that it is not the full thing (ADR-0076 D12), via a
`__serviceInfo: { status, handlerReady?, message? }` property on the registered
instance — see [Services → presence is not capability](./services.mdx) for how
an in-process caller reads it. The two values mean different things, and for the
HTTP surface the difference is load-bearing (#4058):

| Declared | `handlerReady` default | Meaning | Dispatcher behaviour |
|:---|:---|:---|:---|
| `degraded` | `true` | Really does the work, with reduced capability (in-memory, no persistence, no cross-process fan-out) | **Served normally.** Route advertised, requests handled. |
| `stub` | `false` | Fabricates its answers — reports success for work that never happened | **Treated as an EMPTY slot.** Route not advertised, request answered exactly as if nothing were registered (404, or the domain's 501). |

Only `handlerReady` is consulted, never `status` — so an implementation that
declares `degraded` but explicitly sets `handlerReady: false` is also treated as
empty. The rule is enforced by one predicate (`isServiceServeable`) shared by
the service domains, the route-mount gate, and both discovery builders, so what
is advertised and what is served cannot disagree. It applies to the
dispatcher-owned domains: `/analytics`, `/automation`, `/notifications`, `/ai`,
`/storage`, `/i18n`.

The `services` map still reports a registered stub as
`{ enabled: true, status: "stub", handlerReady: false }` rather than collapsing
it to `unavailable` — "something is in this slot, and it is a fake" says more
than "install a plugin".

---

## 2. data Service ✅ Implemented
Expand Down Expand Up @@ -193,6 +219,13 @@ self-declared stub (`handlerReady: false`, ADR-0076 D12) exactly like an empty
one — routes unmounted, request 404. To use analytics locally, install the real
engine; `@objectstack/service-analytics` runs an InMemory strategy, so it needs
no database of its own.

**And it is no longer analytics-only** (#4058 step 2): every dispatcher-owned
domain now gates on `handlerReady`, so a slot occupied by a self-declared `stub`
— `automation`, `notification`, `ai`, wherever one is registered — answers as an
empty slot does. The implementations that really do the work in memory declare
`degraded` and keep serving. See
[`stub` vs `degraded`](#stub-vs-degraded--and-what-the-dispatcher-does-with-each).
</Callout>

### Features (via `@objectstack/service-analytics`)
Expand Down
2 changes: 2 additions & 0 deletions docs/adr/0076-objectql-core-tiering.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ Decision: each capability plugin registers its routes as a **normalized handler*
2. Discovery MUST respect it: such services report **`status: 'stub'` (or `'degraded'`) with `handlerReady: false`**, never `status: 'available'`. Add the status value to the discovery schema.
3. Consumers (agents, console) treat **only `handlerReady: true` / `status: 'available'`** as a real capability; `stub`/`unavailable` ⇒ do not use for real work. *(Update #4000: the **dispatcher is a consumer too**, and was not executing this — every service domain gates on "is the slot occupied", never on `handlerReady`, so a stub in the slot was called like a real implementation. Enforced for `analytics` (`isAnalyticsServiceServeable` in `runtime/src/domains/analytics.ts`, read by the domain, the route-mount gate, and `routes`/`features` in discovery — one predicate, so advertised and served cannot disagree). The other stub-backed domains — `file-storage` / `search` / `automation` / `realtime` / `notification` / `ai` / `i18n` — still gate on presence alone; adopting the same rule across them is a per-domain call, tracked in #4058.)*

*(Update #4058 step 2 — the per-domain call, made, on top of step 1's classification. The rule is now class-wide across the dispatcher-owned domains (`/analytics`, `/automation`, `/notifications`, `/ai`, `/storage`, `/i18n`), reading ONE predicate — `isServiceServeable` in `runtime/src/service-serveable.ts`, which absorbed `isAnalyticsServiceServeable` — from the domain, the route-mount gate, discovery's `routes`/`features`, and the metadata-protocol builder's route advertisement. A slot whose occupant self-declares `handlerReady: false` gets the domain's own empty-slot answer (404, or the explicit 501 `/storage` and `/i18n` use). What makes that safe is step 1: `handlerReady` — not `status` — is the test, and the implementations that really work declare `degraded`, which defaults it to `true`, so `file-storage` / `i18n` keep serving and only the fabricating occupants (`automation` / `notification` / `ai` / `data` / `auth` / `security.*`, `handlerReady: false`) are answered as empty. Deliberately NOT gated: `/data`, `/meta`, `/auth` and the security path, whose dev stubs back the dev stack's own core loop — gating those would 404 the dev stack itself. They are honestly labelled either way; whether the fabricating class should be **registered at all** is the wider evaluation in #4093 (its A tier), where the three `security.*` stubs are also flagged against this ADR's own "a fallback may degrade features, never security semantics". `search` / `realtime` are unaffected: neither has a dispatcher domain, and the surfaceless `degraded` fallbacks (`cache`/`queue`/`job`) carry `handlerReady: false` for the separate D12 reason that no HTTP surface exists for them.)*

This fixes the **whole class at once — without deleting any fallback** (no `/analytics` 404 regression): the analytics fallback and the dev stubs simply stop *lying*; they keep serving but are honestly labelled. It is the runtime enforcement of the D9-refinement principle (capabilities = what is actually installed, computed at runtime).

*(Update #4018: the same honesty binds the `routes` table, not only `services` — and it had a third publisher. `plugin-hono-server`'s `registerStandardEndpoints` convenience block still served a fully **static** `routes` map listing auth/packages/analytics/workflow/automation/ai/notifications/i18n/storage/ui regardless of what was mounted, so a standalone Hono host advertised ten route families and 404'd on the ones no plugin bridged. Closed on both axes: it now **cedes** `/discovery` to `@objectstack/rest` or the runtime dispatcher whenever either is on the kernel (single owner — D11 / OQ#9 worklist item 2; both register during `start()`, so first-registration-wins already shadowed this handler and the cede is behaviour-preserving), and when it does own the route it computes `routes` from the app's **live route table** — a family is advertised iff a route is really registered at or under its base. That keeps the honest answer without adding a third service-registry walk to keep in sync with the other two.)*
Expand Down
33 changes: 22 additions & 11 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1408,18 +1408,29 @@ export class ObjectStackProtocolImplementation implements
data: { enabled: true, status: 'available' as const, route: '/api/v1/data', provider: 'objectql' },
};

// [#4000] The dispatcher answers a self-declared stub in the `analytics`
// slot with the same 404 an empty slot gets (`isAnalyticsServiceServeable`,
// runtime/src/domains/analytics.ts), so this builder must not advertise a
// route for it — that would be the `declared ≠ enforced` gap discovery
// exists to close. Analytics-only on purpose: every other stub-backed
// slot IS still served by its dispatcher domain, so their route
// advertisement stays presence-gated and honest. Unifying the rule
// across domains is #4058.
const analyticsUnserveable =
readServiceSelfInfo(registeredServices.get('analytics'))?.handlerReady === false;
// [#4000, #4058] The dispatcher answers a self-declared non-handler in
// one of ITS domains' slots with the same 404/501 an empty slot gets
// (`isServiceServeable`, runtime/src/service-serveable.ts), so this
// builder must not advertise a route for one — that would be the
// `declared ≠ enforced` gap discovery exists to close.
//
// Scoped to the dispatcher-owned domains on purpose. For the other
// entries in SERVICE_CONFIG the route belongs to the plugin that
// registers the service (service-storage's own `/api/v1/storage`
// routes, plugin-search, plugin-graphql, …) rather than to a dispatcher
// domain, so `handlerReady` there says nothing about whether THAT route
// is mounted, and suppressing it would be a guess. `file-storage` is
// listed because the dispatcher does own a `/storage` bridge for the
// no-plugin case; when service-storage is installed it registers a real
// (unmarked) service, so the entry never fires.
const DISPATCHER_GATED_SERVICES = new Set([
'analytics', 'automation', 'notification', 'ai', 'i18n', 'file-storage',
]);
const unserveable = (serviceName: string) =>
DISPATCHER_GATED_SERVICES.has(serviceName)
&& readServiceSelfInfo(registeredServices.get(serviceName))?.handlerReady === false;
const advertisedRoute = (serviceName: string, route?: string) =>
serviceName === 'analytics' && analyticsUnserveable ? undefined : route;
unserveable(serviceName) ? undefined : route;

// Check which services are actually registered
for (const [serviceName, config] of Object.entries(SERVICE_CONFIG)) {
Expand Down
56 changes: 56 additions & 0 deletions packages/objectql/src/protocol-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,62 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
expect(discovery.routes.analytics).toBeUndefined();
});

// #4058 — the same rule for every OTHER slot whose HTTP surface is a
// dispatcher domain, now that those domains gate on `handlerReady` too.
it('should not advertise routes for self-declared stubs in the other dispatcher-owned slots', async () => {
const mockServices = new Map<string, any>();
for (const name of ['automation', 'notification', 'ai', 'i18n', 'file-storage']) {
mockServices.set(name, { __serviceInfo: { status: 'stub', message: 'dev fake' } });
}

protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
const discovery = await protocol.getDiscovery();

for (const name of ['automation', 'notification', 'ai', 'i18n', 'file-storage']) {
expect(discovery.services[name].enabled, `${name}.enabled`).toBe(true);
expect(discovery.services[name].status, `${name}.status`).toBe('stub');
expect(discovery.services[name].handlerReady, `${name}.handlerReady`).toBe(false);
expect(discovery.services[name].route, `${name}.route`).toBeUndefined();
}
for (const key of ['automation', 'notifications', 'ai', 'i18n', 'storage']) {
expect(discovery.routes[key], `routes.${key}`).toBeUndefined();
}
});

// The limit of that rule: `degraded` means "really serves, reduced
// capability", so the route stays advertised — plugin-dev's in-memory file
// store and i18n provider are exactly this, and the dispatcher keeps serving
// them.
it('should keep advertising routes for degraded implementations that really serve', async () => {
const mockServices = new Map<string, any>();
mockServices.set('file-storage', { __serviceInfo: { status: 'degraded', message: 'in-memory' } });
mockServices.set('i18n', { __serviceInfo: { status: 'degraded', message: 'in-memory' } });

protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
const discovery = await protocol.getDiscovery();

expect(discovery.services['file-storage'].status).toBe('degraded');
expect(discovery.services['file-storage'].handlerReady).toBe(true);
expect(discovery.routes.storage).toBe('/api/v1/storage');
expect(discovery.routes.i18n).toBe('/api/v1/i18n');
});

// Not every SERVICE_CONFIG entry is dispatcher-owned: `search` (REST layer),
// `workflow`, `graphql` and the queue/job/cache families have their routes
// mounted by the plugin that registers the service, so `handlerReady` there
// says nothing about whether THAT route is mounted and the advertisement
// stays presence-gated. Suppressing it would be a guess, not honesty.
it('should leave non-dispatcher-owned routes presence-gated', async () => {
const mockServices = new Map<string, any>();
mockServices.set('search', { __serviceInfo: { status: 'stub', message: 'dev fake' } });

protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
const discovery = await protocol.getDiscovery();

expect(discovery.services.search.status).toBe('stub');
expect(discovery.services.search.route).toBe('/api/v1/search');
});

it('should map file-storage service to storage route', async () => {
const mockServices = new Map<string, any>();
mockServices.set('file-storage', {});
Expand Down
4 changes: 2 additions & 2 deletions packages/runtime/src/dispatcher-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Plugin, PluginContext, IHttpServer } from '@objectstack/core';
import { looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE } from '@objectstack/types';
import { DispatcherErrorCode } from '@objectstack/spec/api';
import { HttpDispatcher, HttpDispatcherResult } from './http-dispatcher.js';
import { isAnalyticsServiceServeable } from './domains/analytics.js';
import { isServiceServeable } from './service-serveable.js';
import { validationFailureDetails, VALIDATION_FAILED_STATUS } from './validation-failure.js';
import { buildApiError } from './error-envelope.js';
import {
Expand Down Expand Up @@ -665,7 +665,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
svc = await k.getServiceAsync('analytics').catch(() => undefined);
}
if (!svc) svc = k?.getService?.('analytics');
return isAnalyticsServiceServeable(svc);
return isServiceServeable(svc);
} catch {
return false;
}
Expand Down
9 changes: 8 additions & 1 deletion packages/runtime/src/domains/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import {
shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE,
} from '@objectstack/core';
import { isServiceServeable } from '../service-serveable.js';
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';

Expand All @@ -36,7 +37,13 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string,
// AI service not registered
}

if (!aiService) {
// [#4058] A slot filled by a self-declared non-handler (`handlerReady:
// false`, ADR-0076 D12) is no AI capability, so it takes the same exits an
// empty slot takes. This is also strictly better than what such an occupant
// used to get: being truthy, it fell through to the `!routes` 503 below —
// which both reads as a fault and loses the `/ai/agents` empty-list
// courtesy the console depends on.
if (!isServiceServeable(aiService)) {
// The console polls `GET /ai/agents` on every navigation to decide
// whether to show AI affordances. Reporting that as a 404 turns the
// normal "no AI service configured" state (the open-source default —
Expand Down
Loading
Loading